Independent education · Sources and limitations documented
Portfolio / Smart NOA simulation

Software simulation

Source snapshot · 5 November 2026

simulator/ui/app.js

// Smart NOA web simulator — UI entry point. Educational simulation with fictional patients.
import * as LIB from '../data/drugs.js';
import { h, clear, announce, fmtClock } from './dom.js';
import { derivePatient, validatePatient } from '../engine/patient.js';
import { createSimulation } from '../engine/sim.js';
import { CASE_ACTIONS } from '../engine/case-actions.js';
import { EXAMPLE_PATIENTS, EXAMPLE_PLANS, buildPlan } from '../data/scenarios.js';
import { initSources, srcTags } from './sources.js';
import { renderPatient, defaultPatientInput } from './patient-form.js';
import { renderAssessment } from './assessment.js';
import { renderPlan, setMode, interlockExplanation } from './plan-editor.js';
import { renderMonitor, newRunState, readinessItem } from './monitor.js';
import { renderDebrief } from './debrief.js';
import { exportPlan, planIssues, stepsFromBuiltPlan, setLockoutRemoved } from './plan-model.js';
import { notModeled } from './labels.js';
import { renderModelCard, loadModelAssumptions, assumptionsList, AI_DISCLOSURE, TOOL_DESCRIPTION } from './model-card.js';
import { pal, renderPalliative, setPalModule, stop as stopPalliative, renderEthics, palSnapshot } from './palliative.js';

const lib = LIB;
const STEPS = [
  { n: 1, label: 'Patient' }, { n: 2, label: 'Assessment' }, { n: 3, label: 'Anesthetic plan' }, { n: 4, label: 'Run' }, { n: 5, label: 'Debrief' },
];

const store = {
  step: 1,
  module: 'periop',
  patientInput: defaultPatientInput(),
  patientVersion: 1,
  exampleId: null,
  plan: { mode: 'GA', sedationTarget: 'moderate', steps: [], stepsByMode: null },
  planVersion: 1,
  planNotice: null,
  interlocks: true,
  seed: 1,
  sim: null, simKey: null,
  run: newRunState(),
};
store.plan.stepsByMode = { GA: store.plan.steps, MAC: [] };

let derivedCache = { v: -1, p: null };
function getPatient() {
  if (derivedCache.v === store.patientVersion) return derivedCache.p;
  let p = null;
  try { if (validatePatient(store.patientInput, { library: lib }).ok) p = derivePatient(store.patientInput, { library: lib }); } catch { p = null; }
  derivedCache = { v: store.patientVersion, p };
  return p;
}

let fluidsCache = null;
function fluidsSupported() {
  if (fluidsCache !== null) return fluidsCache;
  try {
    const s = createSimulation({ patient: derivePatient(EXAMPLE_PATIENTS[0].patient, { library: lib }), library: lib });
    fluidsCache = !!s.action('fluid_bolus:250').ok;
  } catch { fluidsCache = false; }
  return fluidsCache;
}

const simKey = () => JSON.stringify([store.patientVersion, store.planVersion, store.interlocks, store.seed]);
function restartSim() {
  const p = getPatient();
  store.sim = createSimulation({ patient: p, plan: exportPlan(store.plan), seed: store.seed, interlocks: store.interlocks, library: lib });
  store.simKey = simKey();
  store.run = { ...newRunState(), testClock: !!(store.run && store.run.testClock) };
  return store.sim;
}
function ensureSim() { if (!store.sim || store.simKey !== simKey()) restartSim(); return store.sim; }

function planCtx() { return { lib, patient: getPatient(), interlocks: store.interlocks, patientVersion: store.patientVersion }; }

function canGo(n) {
  if (n <= 1) return { ok: true };
  if (!getPatient()) return { ok: false, why: 'Complete a valid patient first.' };
  if (n <= 3) return { ok: true };
  if (n === 4) { const iss = planIssues(planCtx(), store.plan); return iss.errors ? { ok: false, why: `Fix ${iss.errors} blocking plan issue(s) first.` } : { ok: true }; }
  if (n === 5) return store.sim && store.simKey === simKey() ? { ok: true } : { ok: false, why: 'Run the case first.' };
  return { ok: true };
}

let current = null;
// Context passed to every view (functions below are hoisted declarations).
const ctx = { store, lib, go, rerender, refreshStepper, getPatient, loadExample, ensureSim, restartSim, openExtubation, fluidsSupported,
  patientChanged() { store.patientVersion += 1; },
  planChanged() { store.planVersion += 1; } };

function go(n) {
  const c = canGo(n);
  if (!c.ok) { announce(c.why, true); refreshStepper(); return false; }
  if (current && current.leave) current.leave();
  current = null;
  store.step = n;
  render();
  const t = document.getElementById('step-title');
  if (t) t.focus();
  window.scrollTo({ top: 0 });
  return true;
}

function render() {
  const main = document.getElementById('main');
  if (store.module !== 'periop') { renderPalliative(main); main.dataset.step = `pal-${pal.step}`; return; }
  const views = { 1: renderPatient, 2: renderAssessment, 3: renderPlan, 4: renderMonitor, 5: renderDebrief };
  current = views[store.step](ctx, main) || null;
  main.dataset.step = String(store.step);
  refreshStepper();
}
function rerender() { if (current && current.leave) current.leave(); current = null; render(); }

/** Module chooser: Perioperative (existing) | Palliative pain management | End-of-life care. */
function setModule(m) {
  if (!['periop', 'pain', 'eol'].includes(m)) return;
  if (current && current.leave) current.leave();
  current = null;
  stopPalliative();
  if (m !== 'periop') setPalModule(m);
  store.module = m;
  const palOn = m !== 'periop';
  document.getElementById('pal-banner').hidden = !palOn;
  document.getElementById('open-ethics').hidden = !palOn;
  document.querySelector('.stepper').hidden = palOn;
  for (const b of document.querySelectorAll('[data-module]')) b.setAttribute('aria-pressed', String(b.dataset.module === m));
  render();
  const t = document.getElementById('step-title'); if (t) t.focus();
  announce(`Module: ${{ periop: 'Perioperative', pain: 'Palliative pain management', eol: 'End-of-life care' }[m]}.`);
}

function refreshStepper() {
  const ol = document.getElementById('stepper-list');
  clear(ol);
  for (const s of STEPS) {
    const c = canGo(s.n);
    const b = h('button', { type: 'button', class: 'stepper__btn', id: `stepper-${s.n}`, 'aria-current': store.step === s.n ? 'step' : null, 'aria-disabled': c.ok ? null : 'true', title: c.ok ? null : c.why },
      h('span', { class: 'stepper__num' }, String(s.n)), h('span', { class: 'stepper__label' }, s.label), c.ok ? null : h('span', { class: 'sr-only' }, ` (unavailable: ${c.why})`));
    b.addEventListener('click', () => { if (store.step !== s.n) go(s.n); });
    ol.append(h('li', { class: store.step === s.n ? 'is-current' : store.step > s.n ? 'is-done' : '' }, b));
  }
}

function loadExample(id) {
  const ex = EXAMPLE_PATIENTS.find((e) => e.id === id);
  if (!ex) return;
  store.patientInput = JSON.parse(JSON.stringify(ex.patient));
  store.exampleId = id;
  store.patientVersion += 1;
  const p = getPatient();
  const t = EXAMPLE_PLANS.find((pl) => pl.patientId === id);
  if (p && t) {
    const { plan, skipped } = buildPlan(t, p, lib);
    setMode(store.plan, t.mode);
    store.plan.sedationTarget = t.sedationTarget || store.plan.sedationTarget;
    store.plan.steps.splice(0, store.plan.steps.length, ...stepsFromBuiltPlan(planCtx(), plan));
    setLockoutRemoved(store.plan, plan.lockoutRemoved || []);
    store.planNotice = `Example plan “${t.label}” loaded.${skipped.length ? ` Skipped: ${skipped.map((s) => `${s.drugId} (${s.reason})`).join('; ')}` : ''}`;
  }
  store.planVersion += 1;
}

// ---------------------------------------------------------------- extubation dialog
/**
 * Extubation dialog. `opts.pause()` is called when it opens and `opts.resume()` when it closes, so
 * the simulation clock does not run while the clinician reads the checklist. Readiness is
 * re-evaluated when a button is pressed: if it changed since the dialog was drawn, the dialog is
 * redrawn with the current state instead of acting on a stale snapshot (2026-11 audit, finding 3).
 */
function openExtubation(sim, opts = {}) {
  const o = typeof opts === 'function' ? { after: opts } : opts;
  const after = o.after || (() => {});
  const dlg = document.getElementById('extubate-dialog');
  if (!dlg.open) {
    if (o.pause) o.pause();
    dlg.addEventListener('close', () => { if (o.resume) o.resume(); after(); }, { once: true });
  }
  drawExtubation(sim, dlg);
  if (!dlg.open) dlg.showModal();
  const first = document.getElementById('extubate-body').querySelector('button'); if (first) first.focus();
}

function drawExtubation(sim, dlg) {
  const body = document.getElementById('extubate-body');
  const r = sim.extubationReadiness();
  const unmet = r.criteria.filter((c) => !c.met);
  const sig = JSON.stringify(r.criteria.map((c) => c.met));
  const E = lib.SOURCE_IDS_ENGINE || {};
  const doIt = (override) => {
    const now = sim.extubationReadiness();
    if (JSON.stringify(now.criteria.map((c) => c.met)) !== sig) {
      // Readiness changed since the dialog was drawn: show the current checklist; do not act.
      drawExtubation(sim, dlg);
      announce('Readiness changed — the checklist was updated. Review it and confirm again.', true);
      const b = body.querySelector('#ext-confirm, #ext-override'); if (b) b.focus();
      return;
    }
    // Shared case actions (engine/case-actions.js); the guided/automatic runner never calls the override.
    if (override) store.run.overrides.push({ tSec: sim.state.tSec, unmet: now.criteria.filter((c) => !c.met).map((c) => c.label) });
    const res = override ? CASE_ACTIONS.extubateOverride(sim) : CASE_ACTIONS.extubateFromDialog(sim);
    if (!res.ok && !override) { drawExtubation(sim, dlg); announce(res.message, true); return; }
    announce(res.message, !now.ready);
    dlg.close();
  };
  clear(body).append(
    h('p', {}, `Sim time ${fmtClock(sim.state.tSec)}. TOFR ≥ 0.9 must be confirmed before extubation `, srcTags([E.tofExtubation]), '; other criteria are illustrative simulator rules.'),
    h('ul', { class: 'ready-list ready-list--light' }, r.criteria.map((c) => readinessItem(c))),
    r.ready ? h('p', { class: 'notice' }, 'All readiness criteria are met.')
      : h('p', { class: 'notice notice--warn', role: 'alert' }, `Not ready: ${unmet.map((c) => c.label).join('; ')}. Extubating now is logged as a decision against the checklist.`),
    h('div', { class: 'actions' },
      h('button', { type: 'button', class: 'btn', onclick: () => dlg.close() }, 'Cancel — keep the tube'),
      r.ready ? h('button', { type: 'button', class: 'btn btn--primary', id: 'ext-confirm', onclick: () => doIt(false) }, 'Extubate')
        : h('button', { type: 'button', class: 'btn btn--danger', id: 'ext-override', onclick: () => doIt(true) }, 'Extubate anyway (logged)')));
}

// ---------------------------------------------------------------- about + model card
async function renderAbout() {
  const body = document.getElementById('about-body');
  const assumptions = await loadModelAssumptions();
  const sim = store.sim;
  const info = sim ? sim.modelInfo() : {};
  clear(body).append(
    h('p', {}, h('strong', {}, TOOL_DESCRIPTION), ' It is not a medical device, not clinical decision support, and is not connected to any monitor, pump or record system. Nothing you enter leaves this page.'),
    h('h3', {}, 'What it does'),
    h('p', {}, 'You build a fictional adult patient, review a computed assessment, plan an anaesthetic with doses auto-filled from FDA prescribing information and society guidelines (each with its source), then run an illustrative monitor and debrief the case.'),
    h('h3', {}, 'Model limits'),
    h('ul', { class: 'list' },
      h('li', {}, 'Every displayed physiological value is illustrative model output; the simulator as a whole is not validated.'),
      h('li', {}, 'The depth number is a processed-EEG depth index (BIS-style, simulated; 0–100, illustrative). It is not a BIS value; BIS is a trademark of its owner.'),
      h('li', {}, 'Doses are label/guideline values; the auto-filled default within a range is a simulator convention, not a recommendation.')),
    h('h4', {}, 'Model assumptions recorded in SOURCES.md'), assumptionsList(assumptions),
    h('h4', {}, 'Engine model records for drugs used in the current case'),
    Object.keys(info).length ? h('ul', { class: 'list list--tight' }, Object.entries(info).map(([id, m]) => h('li', {}, h('strong', {}, (lib.DRUGS.find((d) => d.id === id) || {}).name || id), `: ${m.type} — ${m.provenance}`)))
      : h('p', { class: 'hint' }, 'No drugs used yet. The Model card lists every drug’s parameters.'),
    h('h4', {}, 'Not modelled'), h('ul', { class: 'chips' }, notModeled(lib, { fluids: fluidsSupported() }).map((x) => h('li', { class: 'chip chip--muted' }, x))),
    h('h3', {}, 'Relation to the Smart NOA Python controller'), interlockExplanation(lib),
    h('p', {}, 'The interlock layer is a port of the decision rules in smart_noa_controller.py with the thresholds of config.yaml; automated parity tests reproduce the Python reference outcomes. The simulator adds an explicit, logged “Resume hold” action that the Python controller does not have.'),
    h('h3', {}, 'AI assistance'), h('p', {}, AI_DISCLOSURE),
    h('h3', {}, 'Accessibility'), h('p', {}, 'Alarms are announced to screen readers, every colour signal is paired with text, and all controls work from the keyboard.'));
}

async function renderModel() {
  const body = document.getElementById('model-body');
  const assumptions = await loadModelAssumptions();
  const p = getPatient() || derivePatient(EXAMPLE_PATIENTS[0].patient, { library: lib });
  renderModelCard(body, { lib, patient: store.sim ? store.sim.patient : p, sim: store.sim, assumptions });
}

function init() {
  initSources(lib);
  document.getElementById('loading')?.remove();
  document.getElementById('open-sources').addEventListener('click', (e) => { document.getElementById('sources-dialog').showModal(); const r = e.currentTarget; document.getElementById('sources-dialog').addEventListener('close', () => r.focus(), { once: true }); });
  document.getElementById('open-about').addEventListener('click', async (e) => { const r = e.currentTarget; await renderAbout(); const d = document.getElementById('about-dialog'); d.showModal(); d.addEventListener('close', () => r.focus(), { once: true }); });
  document.getElementById('open-ethics').addEventListener('click', (e) => { const r = e.currentTarget; renderEthics(document.getElementById('ethics-body')); const d = document.getElementById('ethics-dialog'); d.showModal(); d.addEventListener('close', () => r.focus(), { once: true }); });
  for (const b of document.querySelectorAll('[data-module]')) b.addEventListener('click', () => { if (store.module !== b.dataset.module) setModule(b.dataset.module); });
  document.getElementById('open-model').addEventListener('click', async (e) => { const r = e.currentTarget; await renderModel(); const d = document.getElementById('model-dialog'); d.showModal(); d.addEventListener('close', () => r.focus(), { once: true }); });
  document.addEventListener('click', (e) => { const b = e.target.closest('[data-close-dialog]'); if (b) b.closest('dialog').close(); });
  for (const d of document.querySelectorAll('dialog')) d.addEventListener('click', (e) => { if (e.target === d) d.close(); });
  render();
}

// Test hook for the browser smoke test, only when the page URL has ?test=1. It exposes a
// snapshot() function returning a deep copy of the state (plain data; nothing live or mutable).
function testSnapshot() {
  const sim = store.sim; const a = store.run.auto;
  const copy = (x) => JSON.parse(JSON.stringify(x));
  return copy({
    step: store.step, interlocksOn: store.interlocks, overrides: store.run.overrides,
    sim: sim ? { t: sim.state.tSec, phase: sim.state.phase, v: sim.state.vitals, il: sim.state.interlocks.status, interlocks: sim.state.interlocks,
      airway: sim.state.airway, ready: sim.extubationReadiness().ready, readiness: sim.extubationReadiness(), log: sim.state.log, playing: store.run.playing,
      fluids: sim.state.fluids, prophylaxis: sim.state.prophylaxis, checklist: sim.prophylaxisChecklist(), alarms: sim.state.alarms, drugs: { lidocaine: sim.state.drugs.lidocaine } } : null,
    plan: { mode: store.plan.mode, sedationTarget: store.plan.sedationTarget, template: store.plan.template || null, steps: store.plan.steps.map((x) => ({ drugId: x.drugId, kind: x.kind, phase: x.phase, regimenKey: x.regimenKey, amount: x.amount, rate: x.rate, atSec: x.atSec })) },
    auto: a ? { ...a.status(), idx: a.idx, history: a.history } : null,
    hist: store.run && store.run.hist ? { hr: store.run.hist.hr.slice(-3), map: store.run.hist.map.slice(-3), spo2: store.run.hist.spo2.slice(-3) } : null, // trend-chart samples (NaN gap → null)
    periop: sim ? sim.state.periop : null, planStatus: sim ? sim.planStatus().map((x) => ({ id: x.id, phase: x.phase, drugId: x.drugId, kind: x.kind, status: x.status, waiting: x.waiting || null })) : null,
  });
}
function testLite() {
  const sim = store.sim; const a = store.run.auto; const st = a ? a.status() : null;
  return JSON.parse(JSON.stringify({ t: sim ? sim.state.tSec : null, phase: sim ? sim.state.phase : null, v: sim ? sim.state.vitals : null, airway: sim ? sim.state.airway : null,
    ready: sim ? sim.extubationReadiness().ready : null, playing: store.run.playing,
    auto: st ? { state: st.state, message: st.message, current: st.current, historyIds: a.history.map((x) => x.id) } : null }));
}
try {
  if (new URLSearchParams(window.location.search).get('test') === '1') {
    window.__smartNoa = Object.freeze({
      snapshot: testSnapshot,
      /** Step-driven tests: true = the wall clock never advances simulated time (only advance() does). */
      testClock: (on) => { store.run.testClock = !!on; return store.run.testClock; },
      /** Advance simulated time synchronously by `sec` seconds on the Run screen (runner ticks each second). */
      advance: (sec) => (store.run && typeof store.run.testAdvance === 'function' && store.run.testAdvance(sec) ? testLite() : null),
      /** Small status object (cheap to poll): time, phase, vitals, airway, readiness, runner state. */
      lite: () => testLite(),
      /** Injects a scenario event directly into the engine (no UI control is clicked) — used to reproduce self-pauses. */
      inject: (id) => (store.sim ? JSON.parse(JSON.stringify(store.sim.inject(id))) : null),
      /** Advisory record (engine/rescue.js), plain data. */
      advisories: () => (store.sim ? JSON.parse(JSON.stringify(store.sim.summary().advisories)) : null),
      /** Depth-low advisory record (engine/depth-advisory.js), plain data. */
      depthAdvisories: () => (store.sim ? JSON.parse(JSON.stringify(store.sim.summary().depthAdvisories)) : null),
      /** Palliative modules: plain-data snapshot, step-driven clock, synchronous advance by `min` simulated minutes. */
      pal: () => palSnapshot(),
      palTestClock: (on) => { pal.testClock = !!on; return pal.testClock; },
      palAdvance: (min) => (typeof pal.testAdvance === 'function' && pal.step === 3 ? (pal.testAdvance(min), palSnapshot()) : null),
    });
  }
} catch { /* no test hook */ }

init();
Source and scope

Published from Smart-NOA-Controller/simulator/ui/app.js at revision 6e5b205e3843. Full source text is retained; HTML presentation does not establish independent clinical review. In-memory simulation with no patient, monitor or pump interface; not clinically validated.