diagcalc 3.2.3 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/script.js CHANGED
@@ -1,30 +1,146 @@
1
+ const preferences = window.DiagcalcStorage.createStorage(() => window.localStorage);
2
+
1
3
  document.addEventListener("DOMContentLoaded", () => {
2
4
  const core = window.DiagcalcCore;
3
5
  const datasetStore = window.DiagcalcDatasets;
6
+ const i18n = window.DiagcalcI18n;
7
+ const caseStore = window.DiagcalcCase;
8
+ const scheduler = window.DiagcalcCharts.createScheduler();
9
+ let activeSnapshot = null;
10
+ let refreshLocaleResults = () => {};
11
+ const feedbackEl = document.getElementById("feedback");
4
12
 
5
13
  if (!core || !datasetStore) {
14
+ const missing = [!core && "DiagcalcCore", !datasetStore && "DiagcalcDatasets"].filter(Boolean).join(" and ");
15
+ console.error(`DIAGCALC: required script(s) failed to load: ${missing}.`);
16
+ if (feedbackEl) {
17
+ feedbackEl.textContent = "Calculator failed to load. Refresh the page or check the network tab for missing scripts.";
18
+ feedbackEl.classList.add("error");
19
+ }
6
20
  return;
7
21
  }
8
22
 
23
+ function tr(key, vars) {
24
+ return i18n ? i18n.t(key, vars) : key;
25
+ }
26
+
27
+ // ── Case strip in the app bar ────────────────────────────────────────────
28
+ // Tracks what to show next to the DIAGCALC brand. Re-rendered whenever the
29
+ // locale changes so the "No case yet" / "Case:" prefixes stay translated.
30
+ const appCaseStrip = document.getElementById("appCaseStrip");
31
+ const caseState = { kind: "empty", label: "", datasetKey: null, modified: false };
32
+ function renderCaseStrip() {
33
+ if (!appCaseStrip) return;
34
+ if (caseState.kind === "empty") {
35
+ appCaseStrip.textContent = tr("app.case.empty");
36
+ appCaseStrip.classList.remove("has-case");
37
+ return;
38
+ }
39
+ const name = caseState.kind === "adhoc" ? tr("app.case.adhoc") : caseState.label;
40
+ appCaseStrip.textContent = `${tr("app.case.prefix")} ${name}`;
41
+ appCaseStrip.classList.add("has-case");
42
+ }
43
+ function setCase(kind, label) {
44
+ caseState.kind = kind;
45
+ caseState.label = label || "";
46
+ renderCaseStrip();
47
+ }
48
+
49
+ // Initialise i18n: detect and apply the active locale, wire up the picker.
50
+ function applyLocale(locale) {
51
+ if (!i18n) return;
52
+ i18n.setLocale(locale);
53
+ i18n.applyToDom();
54
+ document.documentElement.lang = locale.split("-")[0] || "en";
55
+ renderCaseStrip();
56
+ refreshLocaleResults();
57
+ }
58
+ if (i18n) {
59
+ applyLocale(i18n.detectLocale());
60
+ const langSelect = document.getElementById("languageSelect");
61
+ if (langSelect) {
62
+ langSelect.value = i18n.getLocale();
63
+ langSelect.addEventListener("change", () => applyLocale(langSelect.value));
64
+ }
65
+ }
66
+
9
67
  const datasets = datasetStore.datasets;
10
68
  const form = document.getElementById("inputForm");
11
- const feedbackEl = document.getElementById("feedback");
12
- const resultsEl = document.getElementById("results");
69
+ const resultsHeadlineEl = document.getElementById("resultsHeadline");
70
+ const resultsSecondaryEl = document.getElementById("resultsSecondary");
71
+ const bayesianUpdateEl = document.getElementById("bayesianUpdate");
13
72
  const datasetSelect = document.getElementById("datasetSelect");
14
73
  const resetButton = document.getElementById("resetButton");
15
- const probabilityChartEl = document.getElementById("probabilityChart");
74
+ const biasWarningsEl = document.getElementById("biasWarnings");
75
+ const continuityModeEl = document.getElementById("continuityModeSelect");
76
+ const historyPanelEl = document.getElementById("historyPanel");
77
+ const historyListEl = document.getElementById("historyList");
78
+ const historyCompareEl = document.getElementById("historyCompare");
79
+ const historyClearAllBtn = document.getElementById("historyClearAll");
80
+ const HISTORY_KEY = "diagcalc-history";
81
+ const HISTORY_MAX = 10;
82
+ const historySelected = new Set();
83
+
84
+ function currentContinuityMode() {
85
+ return continuityModeEl ? continuityModeEl.value : "auto";
86
+ }
87
+
88
+ if (continuityModeEl) {
89
+ const stored = preferences.getItem("diagcalc-continuity");
90
+ if (stored && ["auto", "always", "never"].includes(stored)) {
91
+ continuityModeEl.value = stored;
92
+ }
93
+ continuityModeEl.addEventListener("change", () => {
94
+ preferences.setItem("diagcalc-continuity", continuityModeEl.value);
95
+ if (form && form.querySelector("#tp") && form.querySelector("#tp").value !== "") {
96
+ calculateAndRender();
97
+ }
98
+ });
99
+ }
16
100
  const printButton = document.getElementById("printButton");
17
101
  const datasetReferenceEl = document.getElementById("dataset-reference");
18
102
  const themeToggle = document.getElementById("themeToggle");
19
103
  const faganNomogramEl = document.getElementById("faganNomogram");
20
104
  const faganCanvas = document.getElementById("faganCanvas");
105
+ const prevalenceExplorerEl = document.getElementById("prevalenceExplorer");
106
+ const prevalenceSliderEl = document.getElementById("prevalenceSlider");
107
+ const prevalenceValueEl = document.getElementById("prevalenceValue");
108
+ const prevalenceReadoutEl = document.getElementById("prevalenceReadout");
109
+ const prevalenceCanvas = document.getElementById("prevalenceCanvas");
110
+ const prevalenceState = { sens: NaN, spec: NaN };
111
+ const sequentialTestEl = document.getElementById("sequentialTest");
112
+ const sequentialFromEl = document.getElementById("sequentialFrom");
113
+ const sequentialPretestEl = document.getElementById("sequentialPretest");
114
+ const sequentialCalcButton = document.getElementById("sequentialCalc");
115
+ const sequentialClearButton = document.getElementById("sequentialClear");
116
+ const sequentialFeedbackEl = document.getElementById("sequentialFeedback");
117
+ const sequentialResultsEl = document.getElementById("sequentialResults");
118
+ const sequentialState = {
119
+ postPositive: NaN,
120
+ postNegative: NaN,
121
+ };
122
+ const thresholdPanelEl = document.getElementById("thresholdPanel");
123
+ const treatmentThresholdInputEl = document.getElementById("treatmentThresholdInput");
124
+ const treatmentThresholdValueEl = document.getElementById("treatmentThresholdValue");
125
+ const thresholdGaugeEl = document.getElementById("thresholdGauge");
126
+ const thresholdMarkerPtEl = document.getElementById("thresholdMarkerPt");
127
+ const thresholdMarkerPreEl = document.getElementById("thresholdMarkerPre");
128
+ const thresholdReadoutEl = document.getElementById("thresholdReadout");
129
+ const thresholdState = { lrPositive: NaN, lrNegative: NaN, preTest: NaN };
130
+ const rocPanelEl = document.getElementById("rocPanel");
131
+ const rocRowsEl = document.getElementById("rocRows");
132
+ const rocAddRowButton = document.getElementById("rocAddRow");
133
+ const rocFillSyntheticButton = document.getElementById("rocFillSynthetic");
134
+ const rocResetButton = document.getElementById("rocReset");
135
+ const rocCanvas = document.getElementById("rocCanvas");
136
+ const rocReadoutEl = document.getElementById("rocReadout");
21
137
 
22
138
  initializeTheme();
23
139
 
24
140
  if (themeToggle) {
25
141
  themeToggle.addEventListener("click", () => {
26
142
  toggleTheme();
27
- redrawFaganNomogram();
143
+ scheduler.schedule();
28
144
  });
29
145
  }
30
146
 
@@ -36,25 +152,44 @@ document.addEventListener("DOMContentLoaded", () => {
36
152
  datasetSelect.addEventListener("change", () => {
37
153
  const selected = datasetSelect.value;
38
154
  if (!selected || !datasets[selected]) {
155
+ clearMainResults();
156
+ hideRocPanel();
157
+ activeSnapshot = null;
158
+ caseState.datasetKey = null;
159
+ caseState.modified = false;
39
160
  datasetReferenceEl.style.display = "none";
161
+ setCase("empty");
40
162
  return;
41
163
  }
42
164
 
43
165
  const dataset = datasets[selected];
166
+ caseState.datasetKey = selected;
167
+ caseState.modified = false;
168
+ hideRocPanel();
44
169
  applyDataset(dataset);
45
170
  displayDatasetReference(dataset);
46
- setFeedback("Scenario loaded. Adjust the values as needed and recalculate.", true);
171
+ setCase("named", dataset.name);
172
+ setFeedback(tr("feedback.scenario.loaded"), true);
47
173
  calculateAndRender();
48
174
  });
49
175
 
50
176
  form.addEventListener("reset", () => {
51
177
  window.requestAnimationFrame(() => {
52
- resultsEl.innerHTML = "";
178
+ clearMainResults();
53
179
  datasetSelect.value = "";
54
180
  datasetReferenceEl.style.display = "none";
55
- setFeedback("Fields cleared.", true);
56
- clearProbabilityChart(probabilityChartEl);
181
+ activeSnapshot = null;
182
+ caseState.datasetKey = null;
183
+ caseState.modified = false;
184
+ setCase("empty");
185
+ setFeedback(tr("feedback.cleared"), true);
57
186
  clearFaganNomogram(faganNomogramEl, faganCanvas);
187
+ hidePrevalenceExplorer();
188
+ hideSequentialTest();
189
+ hideThresholdPanel();
190
+ hideThresholdDemo();
191
+ hideRocPanel();
192
+ renderBiasWarnings([]);
58
193
  });
59
194
  });
60
195
 
@@ -68,15 +203,41 @@ document.addEventListener("DOMContentLoaded", () => {
68
203
  });
69
204
  }
70
205
 
206
+ // Expand printable <details> before printing so their content lands in the
207
+ // PDF, then restore previous state. Skip panels that should remain hidden
208
+ // on print (handled by the @media print stylesheet anyway).
209
+ const PRINT_SKIP_IDS = new Set([
210
+ "prevalenceExplorer",
211
+ "sequentialTest",
212
+ "thresholdPanel",
213
+ "thresholdDemo",
214
+ "rocPanel",
215
+ "historyPanel",
216
+ ]);
217
+ window.addEventListener("beforeprint", () => {
218
+ document.querySelectorAll("details").forEach((d) => {
219
+ if (PRINT_SKIP_IDS.has(d.id)) return;
220
+ d.dataset.printPrevOpen = d.open ? "1" : "0";
221
+ d.open = true;
222
+ });
223
+ });
224
+ window.addEventListener("afterprint", () => {
225
+ document.querySelectorAll("details").forEach((d) => {
226
+ if (!("printPrevOpen" in d.dataset)) return;
227
+ d.open = d.dataset.printPrevOpen === "1";
228
+ delete d.dataset.printPrevOpen;
229
+ });
230
+ });
231
+
71
232
  function readFormValues() {
72
233
  const preTestField = document.getElementById("preTestProb");
73
- const preTestValue = core.normaliseDecimal(preTestField.value);
234
+
74
235
  return {
75
236
  tp: core.safeParseInt(document.getElementById("tp").value),
76
237
  fp: core.safeParseInt(document.getElementById("fp").value),
77
238
  fn: core.safeParseInt(document.getElementById("fn").value),
78
239
  tn: core.safeParseInt(document.getElementById("tn").value),
79
- preTestProb: preTestValue === "" ? NaN : parseFloat(preTestValue),
240
+ preTestProb: core.parseProbability(preTestField.value),
80
241
  };
81
242
  }
82
243
 
@@ -85,20 +246,31 @@ document.addEventListener("DOMContentLoaded", () => {
85
246
  const validation = core.validateInputs(data);
86
247
 
87
248
  if (!validation.valid) {
88
- resultsEl.innerHTML = "";
249
+ activeSnapshot = null;
250
+ clearMainResults();
89
251
  setFeedback(validation.message, false);
90
- clearProbabilityChart(probabilityChartEl);
91
252
  clearFaganNomogram(faganNomogramEl, faganCanvas);
253
+ hidePrevalenceExplorer();
254
+ hideSequentialTest();
255
+ hideThresholdPanel();
256
+ hideThresholdDemo();
257
+ hideRocPanel();
258
+ renderBiasWarnings([]);
92
259
  return;
93
260
  }
94
261
 
95
- const metrics = core.calculateMetrics(data);
96
- renderResults(resultsEl, metrics);
97
- renderProbabilityChart(probabilityChartEl, {
98
- pre: metrics.preTestProbability.value,
99
- postPositive: metrics.postTestPositive.value,
100
- postNegative: metrics.postTestNegative.value,
262
+ const metrics = core.calculateMetrics(data, { continuityCorrection: currentContinuityMode(), locale: i18n ? i18n.getLocale() : "en" });
263
+ activeSnapshot = caseStore.createSnapshot(data, { continuityCorrection: currentContinuityMode() }, {
264
+ label: caseState.label || "Custom case", datasetKey: caseState.datasetKey,
265
+ modified: caseState.modified, provenance: caseState.datasetKey ? datasets[caseState.datasetKey].provenance : null,
101
266
  });
267
+ if (caseState.kind === "empty") {
268
+ setCase("adhoc");
269
+ }
270
+ renderBiasWarnings([...core.buildBiasWarnings(data), ...datasetStore.buildDatasetWarnings(datasets[caseState.datasetKey])]);
271
+ renderHeadline(resultsHeadlineEl, metrics);
272
+ renderSecondary(resultsSecondaryEl, metrics);
273
+ renderBayesianUpdate(bayesianUpdateEl, metrics);
102
274
  renderFaganNomogram(faganCanvas, faganNomogramEl, {
103
275
  preTest: metrics.preTestProbability.value,
104
276
  lrPositive: metrics.lrPositive.value,
@@ -106,151 +278,1070 @@ document.addEventListener("DOMContentLoaded", () => {
106
278
  postPositive: metrics.postTestPositive.value,
107
279
  postNegative: metrics.postTestNegative.value,
108
280
  });
109
- setFeedback("Calculation complete. Explore the interpretation guide below.", true);
281
+ showPrevalenceExplorer(metrics.sensitivity.value, metrics.specificity.value, metrics.preTestProbability.value);
282
+ showSequentialTest(metrics.postTestPositive.value, metrics.postTestNegative.value);
283
+ showThresholdPanel(metrics.lrPositive.value, metrics.lrNegative.value, metrics.preTestProbability.value);
284
+ showThresholdDemo(metrics.sensitivity.value, metrics.specificity.value, metrics.preTestProbability.value);
285
+ showRocPanel(data);
286
+ saveToHistory(data, metrics);
287
+ renderHistory();
288
+ scheduler.schedule();
289
+ setFeedback(tr("feedback.calc.ok"), true);
290
+ // Move keyboard focus to the headline strip so screen-reader users land
291
+ // on the result and sighted keyboard users don't have to tab back from
292
+ // the submit button. The container has tabindex="-1" so it's
293
+ // programmatically focusable but stays out of the tab order.
294
+ if (resultsHeadlineEl) {
295
+ resultsHeadlineEl.focus({ preventScroll: false });
296
+ }
110
297
  }
111
298
 
112
- function redrawFaganNomogram() {
113
- if (!faganNomogramEl || faganNomogramEl.style.display === "none") {
299
+ // ── Calculation history ──────────────────────────────────────────────────
300
+
301
+ function readHistory() {
302
+ return caseStore.parseHistory(preferences.getItem(HISTORY_KEY), HISTORY_MAX)
303
+ .map((entry) => ({ ...entry, summary: caseStore.summary(entry) }));
304
+ }
305
+
306
+ function writeHistory(entries) {
307
+ preferences.setItem(HISTORY_KEY, JSON.stringify(entries));
308
+ }
309
+
310
+ function saveToHistory() {
311
+ if (!activeSnapshot) return;
312
+ const entries = readHistory();
313
+ if (entries.length && caseStore.fingerprint(entries[0]) === caseStore.fingerprint(activeSnapshot)) return;
314
+ entries.unshift(activeSnapshot);
315
+ writeHistory(entries.slice(0, HISTORY_MAX));
316
+ }
317
+
318
+ function renderHistory() {
319
+ if (!historyPanelEl || !historyListEl) return;
320
+ const entries = readHistory();
321
+ const liveIds = new Set(entries.map((entry) => entry.id));
322
+ for (const id of historySelected) if (!liveIds.has(id)) historySelected.delete(id);
323
+ if (entries.length === 0) {
324
+ historyPanelEl.style.display = "none";
325
+ historyPanelEl.open = false;
326
+ if (historyCompareEl) historyCompareEl.replaceChildren();
114
327
  return;
115
328
  }
329
+ historyPanelEl.style.display = "block";
330
+ historyListEl.innerHTML = "";
331
+ for (const entry of entries) {
332
+ const li = document.createElement("li");
333
+ li.className = "history-item";
334
+
335
+ const checkbox = document.createElement("input");
336
+ checkbox.type = "checkbox";
337
+ checkbox.id = `history-select-${entry.id}`;
338
+ checkbox.checked = historySelected.has(entry.id);
339
+ checkbox.addEventListener("change", () => {
340
+ if (checkbox.checked) historySelected.add(entry.id);
341
+ else historySelected.delete(entry.id);
342
+ renderCompare();
343
+ });
344
+
345
+ const labelEl = document.createElement("label");
346
+ labelEl.htmlFor = checkbox.id;
347
+ labelEl.className = "history-item-label";
348
+ const ts = new Date(entry.savedAt).toLocaleString();
349
+ const fields = [
350
+ ["history-item-title", entry.label],
351
+ ["history-item-meta", `TP ${entry.input.tp} · FP ${entry.input.fp} · FN ${entry.input.fn} · TN ${entry.input.tn} · pre ${entry.input.preTestProb}%`],
352
+ ["history-item-stats", `sens ${core.formatPercentage(entry.summary.sensitivity)} · spec ${core.formatPercentage(entry.summary.specificity)} · post+ ${core.formatPercentage(entry.summary.postTestPositive)} · post− ${core.formatPercentage(entry.summary.postTestNegative)}`],
353
+ ["history-item-date", `${ts} · v${entry.engineVersion} · ${entry.options.continuityCorrection}${entry.legacy ? " · Legacy: original settings unknown; recomputed with auto" : ""}`],
354
+ ];
355
+ for (const [className, text] of fields) {
356
+ const span = document.createElement("span");
357
+ span.className = className;
358
+ span.textContent = text;
359
+ labelEl.appendChild(span);
360
+ }
361
+
362
+ const actions = document.createElement("div");
363
+ actions.className = "history-item-actions";
364
+ const reloadBtn = document.createElement("button");
365
+ reloadBtn.type = "button";
366
+ reloadBtn.className = "secondary";
367
+ reloadBtn.textContent = "Reload";
368
+ reloadBtn.addEventListener("click", () => reloadHistoryEntry(entry));
369
+ const deleteBtn = document.createElement("button");
370
+ deleteBtn.type = "button";
371
+ deleteBtn.className = "secondary history-item-delete";
372
+ deleteBtn.textContent = "×";
373
+ deleteBtn.title = "Delete this entry";
374
+ deleteBtn.setAttribute("aria-label", "Delete history entry");
375
+ deleteBtn.addEventListener("click", () => deleteHistoryEntry(entry.id));
376
+ actions.appendChild(reloadBtn);
377
+ actions.appendChild(deleteBtn);
378
+
379
+ li.appendChild(checkbox);
380
+ li.appendChild(labelEl);
381
+ li.appendChild(actions);
382
+ historyListEl.appendChild(li);
383
+ }
384
+ renderCompare();
385
+ }
116
386
 
117
- const data = readFormValues();
118
- const validation = core.validateInputs(data);
387
+ function deleteHistoryEntry(id) {
388
+ const entries = readHistory().filter((e) => e.id !== id);
389
+ writeHistory(entries);
390
+ historySelected.delete(id);
391
+ renderHistory();
392
+ }
393
+
394
+ function reloadHistoryEntry(entry) {
395
+ document.getElementById("tp").value = entry.input.tp;
396
+ document.getElementById("fp").value = entry.input.fp;
397
+ document.getElementById("fn").value = entry.input.fn;
398
+ document.getElementById("tn").value = entry.input.tn;
399
+ document.getElementById("preTestProb").value = entry.input.preTestProb;
400
+ if (continuityModeEl) continuityModeEl.value = entry.options.continuityCorrection;
401
+ preferences.setItem("diagcalc-continuity", entry.options.continuityCorrection);
402
+ caseState.datasetKey = entry.datasetKey && datasets[entry.datasetKey] ? entry.datasetKey : null;
403
+ caseState.modified = entry.modified;
404
+ hideRocPanel();
405
+ if (datasetSelect && caseState.datasetKey && !entry.modified) {
406
+ datasetSelect.value = entry.datasetKey;
407
+ displayDatasetReference(datasets[entry.datasetKey]);
408
+ setCase("named", datasets[entry.datasetKey].name);
409
+ } else if (datasetSelect) {
410
+ datasetSelect.value = "";
411
+ if (caseState.datasetKey) displayDatasetReference(datasets[caseState.datasetKey]);
412
+ else datasetReferenceEl.style.display = "none";
413
+ setCase("named", entry.label);
414
+ }
415
+ setFeedback(tr("feedback.history.reloaded", { label: entry.label }), true);
416
+ calculateAndRender();
417
+ if (entry.legacy || entry.engineVersion !== window.DiagcalcMeta.version) {
418
+ setFeedback("Case recalculated with the current engine. Legacy history used auto because its original settings were not recorded.", true);
419
+ }
420
+ }
421
+
422
+ function renderCompare() {
423
+ if (!historyCompareEl) return;
424
+ historyCompareEl.innerHTML = "";
425
+ if (historySelected.size < 2) return;
426
+ const entries = readHistory().filter((e) => historySelected.has(e.id));
427
+ if (entries.length < 2) return;
428
+
429
+ const table = document.createElement("table");
430
+ table.className = "history-compare-table";
431
+ const thead = document.createElement("thead");
432
+ const headerRow = document.createElement("tr");
433
+ headerRow.appendChild(document.createElement("th"));
434
+ for (const e of entries) {
435
+ const th = document.createElement("th");
436
+ th.scope = "col";
437
+ th.textContent = e.label;
438
+ headerRow.appendChild(th);
439
+ }
440
+ thead.appendChild(headerRow);
441
+ table.appendChild(thead);
442
+ const tbody = document.createElement("tbody");
443
+ const rows = [
444
+ ["TP/FP/FN/TN", (e) => `${e.input.tp} / ${e.input.fp} / ${e.input.fn} / ${e.input.tn}`],
445
+ ["Pre-test", (e) => `${e.input.preTestProb}%`],
446
+ ["Engine / continuity", (e) => `${e.engineVersion} / ${e.options.continuityCorrection}`],
447
+ ["Sensitivity", (e) => core.formatPercentage(e.summary.sensitivity)],
448
+ ["Specificity", (e) => core.formatPercentage(e.summary.specificity)],
449
+ ["PPV", (e) => core.formatPercentage(e.summary.ppv)],
450
+ ["NPV", (e) => core.formatPercentage(e.summary.npv)],
451
+ ["LR+", (e) => core.formatLikelihood(e.summary.lrPositive)],
452
+ ["LR−", (e) => core.formatLikelihood(e.summary.lrNegative)],
453
+ ["Post-test (+)", (e) => core.formatPercentage(e.summary.postTestPositive)],
454
+ ["Post-test (−)", (e) => core.formatPercentage(e.summary.postTestNegative)],
455
+ ];
456
+ for (const [label, fn] of rows) {
457
+ const tr = document.createElement("tr");
458
+ const th = document.createElement("th");
459
+ th.scope = "row";
460
+ th.textContent = label;
461
+ tr.appendChild(th);
462
+ for (const e of entries) {
463
+ const td = document.createElement("td");
464
+ td.textContent = fn(e);
465
+ tr.appendChild(td);
466
+ }
467
+ tbody.appendChild(tr);
468
+ }
469
+ table.appendChild(tbody);
470
+ historyCompareEl.appendChild(table);
471
+ }
472
+
473
+ if (historyClearAllBtn) {
474
+ historyClearAllBtn.addEventListener("click", () => {
475
+ if (!confirm("Clear all saved history? This cannot be undone.")) return;
476
+ writeHistory([]);
477
+ historySelected.clear();
478
+ renderHistory();
479
+ });
480
+ }
481
+
482
+ renderHistory();
483
+
484
+ function showRocPanel(firstRow) {
485
+ if (!rocPanelEl) return;
486
+ rocPanelEl.style.display = "block";
487
+ if (rocRowsEl.children.length === 0) {
488
+ addRocRow({ cutoff: "", tp: firstRow.tp, fp: firstRow.fp, fn: firstRow.fn, tn: firstRow.tn });
489
+ addRocRow();
490
+ }
491
+ recomputeRoc();
492
+ }
493
+
494
+ function hideRocPanel() {
495
+ if (!rocPanelEl) return;
496
+ rocPanelEl.style.display = "none";
497
+ rocPanelEl.open = false;
498
+ rocRowsEl.innerHTML = "";
499
+ if (rocCanvas) {
500
+ const ctx = rocCanvas.getContext("2d");
501
+ if (ctx) {
502
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
503
+ ctx.clearRect(0, 0, rocCanvas.width, rocCanvas.height);
504
+ }
505
+ }
506
+ if (rocReadoutEl) rocReadoutEl.innerHTML = "";
507
+ }
508
+
509
+ function addRocRow(prefill, opts) {
510
+ const tr = document.createElement("tr");
511
+ tr.dataset.provenance = opts && opts.synthetic ? "simulated" : "observed";
512
+ if (opts && opts.synthetic) {
513
+ tr.classList.add("roc-row--synthetic");
514
+ tr.title = "Synthetic cutoff — generated from a binormal fit of the first row.";
515
+ }
516
+ const fields = ["cutoff", "tp", "fp", "fn", "tn"];
517
+ for (const field of fields) {
518
+ const td = document.createElement("td");
519
+ const input = document.createElement("input");
520
+ input.type = "number";
521
+ input.inputMode = field === "cutoff" ? "decimal" : "numeric";
522
+ input.step = field === "cutoff" ? "any" : "1";
523
+ if (field !== "cutoff") input.min = "0";
524
+ input.dataset.field = field;
525
+ if (prefill && prefill[field] !== undefined && prefill[field] !== "") {
526
+ input.value = prefill[field];
527
+ }
528
+ input.addEventListener("input", () => {
529
+ if (tr.dataset.provenance !== "observed") {
530
+ tr.dataset.provenance = "edited-simulated";
531
+ tr.title = "Edited simulated cutoff — still derived from a binormal model.";
532
+ }
533
+ recomputeRoc();
534
+ });
535
+ input.setAttribute("aria-label", `ROC row ${field}`);
536
+ td.appendChild(input);
537
+ tr.appendChild(td);
538
+ }
539
+ const removeTd = document.createElement("td");
540
+ const removeBtn = document.createElement("button");
541
+ removeBtn.type = "button";
542
+ removeBtn.className = "roc-remove";
543
+ removeBtn.textContent = "×";
544
+ removeBtn.setAttribute("aria-label", "Remove this cutoff row");
545
+ removeBtn.addEventListener("click", () => {
546
+ tr.remove();
547
+ recomputeRoc();
548
+ });
549
+ removeTd.appendChild(removeBtn);
550
+ tr.appendChild(removeTd);
551
+ rocRowsEl.appendChild(tr);
552
+ }
553
+
554
+ function readRocRows() {
555
+ return Array.from(rocRowsEl.children).filter((tr) =>
556
+ Array.from(tr.querySelectorAll("input")).some((input) => input.value.trim() !== "")
557
+ ).map((tr) => {
558
+ const obj = { provenance: tr.dataset.provenance };
559
+ for (const input of tr.querySelectorAll("input")) {
560
+ const f = input.dataset.field;
561
+ if (f === "cutoff") {
562
+ const v = parseFloat(input.value);
563
+ obj.cutoff = Number.isFinite(v) ? v : input.value || null;
564
+ } else {
565
+ obj[f] = core.safeParseInt(input.value);
566
+ }
567
+ }
568
+ return obj;
569
+ });
570
+ }
571
+
572
+ function recomputeRoc() {
573
+ if (!rocReadoutEl) return;
574
+ const rows = readRocRows();
575
+ const direction = document.getElementById("rocDirection").value;
576
+ const validation = core.validateRocRows(rows, { direction });
119
577
  if (!validation.valid) {
578
+ rocReadoutEl.textContent = validation.errors.map((error) =>
579
+ `${error.row ? `Row ${error.row}: ` : ""}${error.message}`).join(" ");
580
+ window.DiagcalcCharts.clearCanvas(rocCanvas);
120
581
  return;
121
582
  }
583
+ const roc = core.calculateROC(rows, { direction });
584
+ drawRocCurve(roc);
585
+ renderRocReadout(roc);
586
+ }
122
587
 
123
- const metrics = core.calculateMetrics(data);
124
- renderFaganNomogram(faganCanvas, faganNomogramEl, {
125
- preTest: metrics.preTestProbability.value,
126
- lrPositive: metrics.lrPositive.value,
127
- lrNegative: metrics.lrNegative.value,
128
- postPositive: metrics.postTestPositive.value,
129
- postNegative: metrics.postTestNegative.value,
588
+ function renderRocReadout(roc) {
589
+ rocReadoutEl.innerHTML = "";
590
+ if (roc.simulated) {
591
+ const warning = document.createElement("p");
592
+ warning.textContent = "Includes simulated or edited simulated cutoffs. AUC and optimum are illustrative, not empirical estimates.";
593
+ rocReadoutEl.appendChild(warning);
594
+ }
595
+ const stats = [
596
+ [roc.simulated ? "Illustrative AUC (includes simulated rows)" : "AUC (trapezoid estimate)", roc.auc.toFixed(3)],
597
+ ["Cutoffs", String(roc.points.length)],
598
+ [roc.simulated ? "Illustrative Youden optimum" : "Youden optimum", roc.optimalPoint
599
+ ? `cutoff ${roc.optimalPoint.cutoff ?? "—"} (sens ${core.formatPercentage(roc.optimalPoint.sens)}, spec ${core.formatPercentage(roc.optimalPoint.spec)}, J=${roc.optimalPoint.youden.toFixed(3)})`
600
+ : "—"],
601
+ ];
602
+ for (const [label, value] of stats) {
603
+ const cell = document.createElement("div");
604
+ cell.className = "roc-stat";
605
+ const lab = document.createElement("span");
606
+ lab.className = "roc-stat-label";
607
+ lab.textContent = label;
608
+ const val = document.createElement("span");
609
+ val.className = "roc-stat-value";
610
+ val.textContent = value;
611
+ cell.appendChild(lab);
612
+ cell.appendChild(val);
613
+ rocReadoutEl.appendChild(cell);
614
+ }
615
+ }
616
+
617
+ function drawRocCurve(roc) {
618
+ window.DiagcalcCharts.drawROC(rocCanvas, roc);
619
+ }
620
+
621
+ document.getElementById("rocDirection").addEventListener("change", recomputeRoc);
622
+ if (rocAddRowButton) {
623
+ rocAddRowButton.addEventListener("click", () => addRocRow());
624
+ }
625
+ if (rocFillSyntheticButton) {
626
+ rocFillSyntheticButton.addEventListener("click", () => {
627
+ // Idempotent: drop any prior synthetic rows so repeated clicks refresh
628
+ // the scaffold instead of accumulating.
629
+ Array.from(rocRowsEl.querySelectorAll("tr.roc-row--synthetic")).forEach((tr) => tr.remove());
630
+
631
+ // Seed from the first non-empty (non-synthetic) ROC row; fall back to
632
+ // the main form (where the user just typed TP/FP/FN/TN) so the button
633
+ // works the moment the panel opens, before any rows are populated.
634
+ const rocRows = readRocRows();
635
+ const isValid = (r) =>
636
+ r &&
637
+ Number.isInteger(r.tp) && Number.isInteger(r.fp) &&
638
+ Number.isInteger(r.fn) && Number.isInteger(r.tn) &&
639
+ (r.tp + r.fn > 0) && (r.tn + r.fp > 0);
640
+ let seed = rocRows.find(isValid);
641
+ let seedSource = "ROC row 1";
642
+ if (!seed) {
643
+ const form = readFormValues();
644
+ if (isValid(form)) {
645
+ seed = form;
646
+ seedSource = "main form";
647
+ }
648
+ }
649
+ if (!seed) {
650
+ if (rocReadoutEl) {
651
+ rocReadoutEl.textContent = "Need a valid 2×2 (TP / FP / FN / TN) before synthetic cutoffs can be generated — fill the main form or row 1 first.";
652
+ }
653
+ return;
654
+ }
655
+ document.getElementById("rocDirection").value = "higher";
656
+ const synth = core.generateSyntheticRocPoints(seed, 7);
657
+ if (synth.length === 0) {
658
+ if (rocReadoutEl) {
659
+ rocReadoutEl.textContent = "Could not fit a binormal model to that 2×2 — the synthetic scaffold needs at least one diseased and one non-diseased case.";
660
+ }
661
+ return;
662
+ }
663
+ for (const row of synth) {
664
+ addRocRow(row, { synthetic: true });
665
+ }
666
+ recomputeRoc();
667
+ const firstSynth = rocRowsEl.querySelector("tr.roc-row--synthetic");
668
+ if (firstSynth && firstSynth.scrollIntoView) {
669
+ firstSynth.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "nearest" });
670
+ }
671
+ // Surface that the click did something — the AUC update alone is easy
672
+ // to miss when the scaffold lands on top of an already-populated curve.
673
+ // Prepended *after* recomputeRoc to survive its innerHTML reset.
674
+ if (rocReadoutEl) {
675
+ const flash = document.createElement("p");
676
+ flash.className = "roc-flash";
677
+ flash.textContent = `Added ${synth.length} synthetic cutoffs from a binormal fit (seed: ${seedSource}). Editing a row preserves its simulated origin.`;
678
+ rocReadoutEl.prepend(flash);
679
+ setTimeout(() => { if (flash.isConnected) flash.remove(); }, 5000);
680
+ }
681
+ });
682
+ }
683
+ if (rocResetButton) {
684
+ rocResetButton.addEventListener("click", () => {
685
+ rocRowsEl.innerHTML = "";
686
+ addRocRow();
687
+ recomputeRoc();
130
688
  });
131
689
  }
132
690
 
133
- function applyDataset(dataset) {
134
- document.getElementById("tp").value = dataset.tp;
135
- document.getElementById("fp").value = dataset.fp;
136
- document.getElementById("fn").value = dataset.fn;
137
- document.getElementById("tn").value = dataset.tn;
138
- document.getElementById("preTestProb").value = dataset.preTestProb;
691
+ // Cohen's κ companion tool — independent of the main diagnostic test workflow.
692
+ const kappaCalcButton = document.getElementById("kappaCalc");
693
+ const kappaClearButton = document.getElementById("kappaClear");
694
+ const kappaResultEl = document.getElementById("kappaResult");
695
+ const kappaFields = ["kappaBothPos", "kappaOnly1Pos", "kappaOnly2Pos", "kappaBothNeg"];
696
+
697
+ if (kappaCalcButton) {
698
+ kappaCalcButton.addEventListener("click", computeKappa);
699
+ }
700
+ if (kappaClearButton) {
701
+ kappaClearButton.addEventListener("click", () => {
702
+ for (const id of kappaFields) {
703
+ const el = document.getElementById(id);
704
+ if (el) el.value = "";
705
+ }
706
+ if (kappaResultEl) kappaResultEl.innerHTML = "";
707
+ });
708
+ }
709
+ for (const id of kappaFields) {
710
+ const el = document.getElementById(id);
711
+ if (el) el.addEventListener("keydown", (e) => { if (e.key === "Enter") computeKappa(); });
139
712
  }
140
713
 
141
- function setFeedback(message, isSuccess) {
142
- feedbackEl.textContent = message;
143
- feedbackEl.classList.remove("error", "success");
144
- if (!message) {
714
+ function computeKappa() {
715
+ if (!kappaResultEl) return;
716
+ const inputs = {
717
+ bothPos: core.safeParseInt(document.getElementById("kappaBothPos").value),
718
+ only1Pos: core.safeParseInt(document.getElementById("kappaOnly1Pos").value),
719
+ only2Pos: core.safeParseInt(document.getElementById("kappaOnly2Pos").value),
720
+ bothNeg: core.safeParseInt(document.getElementById("kappaBothNeg").value),
721
+ };
722
+ if (Object.values(inputs).some((v) => !Number.isInteger(v) || v < 0)) {
723
+ kappaResultEl.innerHTML = `<p class="kappa-error">Enter non-negative integers in all four cells.</p>`;
145
724
  return;
146
725
  }
147
- feedbackEl.classList.add(isSuccess ? "success" : "error");
726
+ const result = core.calcCohenKappa(inputs);
727
+ if (!result) {
728
+ kappaResultEl.innerHTML = `<p class="kappa-error">Total cannot be zero.</p>`;
729
+ return;
730
+ }
731
+ kappaResultEl.innerHTML = "";
732
+ const stats = [
733
+ ["κ", Number.isFinite(result.value) ? result.value.toFixed(3) : "—"],
734
+ ["95% CI", result.ci ? `${result.ci.lower.toFixed(3)} to ${result.ci.upper.toFixed(3)}` : "—"],
735
+ ["Observed agreement (p_o)", core.formatPercentage(result.observed)],
736
+ ["Expected agreement (p_e)", core.formatPercentage(result.expected)],
737
+ ["N", String(result.n)],
738
+ ["Interpretation", result.interpretation],
739
+ ];
740
+ for (const [label, value] of stats) {
741
+ const cell = document.createElement("div");
742
+ cell.className = "kappa-stat";
743
+ const lab = document.createElement("span");
744
+ lab.className = "kappa-stat-label";
745
+ lab.textContent = label;
746
+ const val = document.createElement("span");
747
+ val.className = "kappa-stat-value";
748
+ val.textContent = value;
749
+ cell.appendChild(lab);
750
+ cell.appendChild(val);
751
+ kappaResultEl.appendChild(cell);
752
+ }
148
753
  }
149
- });
150
754
 
151
- function renderResults(container, metrics) {
152
- if (!container) {
153
- return;
755
+ function clearMainResults() {
756
+ if (resultsHeadlineEl) resultsHeadlineEl.innerHTML = "";
757
+ if (resultsSecondaryEl) resultsSecondaryEl.innerHTML = "";
758
+ if (bayesianUpdateEl) bayesianUpdateEl.innerHTML = "";
154
759
  }
155
760
 
156
- container.innerHTML = "";
157
- const entries = Object.entries(metrics);
158
- entries.forEach(([, data]) => {
159
- const card = document.createElement("article");
160
- card.className = "result-card";
761
+ function renderBiasWarnings(warnings) {
762
+ if (!biasWarningsEl) return;
763
+ biasWarningsEl.innerHTML = "";
764
+ if (!Array.isArray(warnings) || warnings.length === 0) {
765
+ biasWarningsEl.classList.remove("visible");
766
+ return;
767
+ }
768
+ biasWarningsEl.classList.add("visible");
769
+ const heading = document.createElement("p");
770
+ heading.className = "bias-warnings-heading";
771
+ heading.textContent = warnings.length === 1 ? "Heads up" : "Heads up";
772
+ biasWarningsEl.appendChild(heading);
773
+ const list = document.createElement("ul");
774
+ list.className = "bias-warnings-list";
775
+ for (const msg of warnings) {
776
+ const li = document.createElement("li");
777
+ li.textContent = msg;
778
+ list.appendChild(li);
779
+ }
780
+ biasWarningsEl.appendChild(list);
781
+ }
161
782
 
162
- const title = document.createElement("h3");
163
- title.textContent = data.label;
164
- card.appendChild(title);
783
+ function showThresholdPanel(lrPositive, lrNegative, preTest) {
784
+ if (!thresholdPanelEl) return;
785
+ thresholdState.lrPositive = lrPositive;
786
+ thresholdState.lrNegative = lrNegative;
787
+ thresholdState.preTest = preTest;
788
+ thresholdPanelEl.style.display = "block";
789
+ updateThresholdView();
790
+ }
165
791
 
166
- const value = document.createElement("p");
167
- value.className = "result-value";
168
- value.textContent = window.DiagcalcCore.formatValue(data.value, data.formatter);
169
- card.appendChild(value);
792
+ function hideThresholdPanel() {
793
+ if (!thresholdPanelEl) return;
794
+ thresholdPanelEl.style.display = "none";
795
+ thresholdPanelEl.open = false;
796
+ thresholdState.lrPositive = NaN;
797
+ thresholdState.lrNegative = NaN;
798
+ thresholdState.preTest = NaN;
799
+ }
170
800
 
171
- if (data.ci) {
172
- const ci = document.createElement("p");
173
- ci.className = "result-ci";
174
- ci.textContent = `95% CI: ${window.DiagcalcCore.formatPercentage(data.ci.lower)} - ${window.DiagcalcCore.formatPercentage(data.ci.upper)}`;
175
- card.appendChild(ci);
801
+ function updateThresholdView() {
802
+ if (!thresholdReadoutEl) return;
803
+ const Ptpercent = parseFloat(treatmentThresholdInputEl.value);
804
+ if (!Number.isFinite(Ptpercent)) return;
805
+ const Pt = Ptpercent / 100;
806
+ treatmentThresholdValueEl.textContent = `${Ptpercent.toFixed(0)}%`;
807
+
808
+ const t = core.calculateThresholds({
809
+ treatmentThreshold: Pt,
810
+ lrPositive: thresholdState.lrPositive,
811
+ lrNegative: thresholdState.lrNegative,
812
+ });
813
+ if (!t) {
814
+ thresholdReadoutEl.textContent = "Thresholds unavailable: this display requires LR+ > 1 and 0 ≤ LR− < 1. Review test direction.";
815
+ return;
176
816
  }
177
817
 
178
- if (data.note) {
179
- const note = document.createElement("p");
180
- note.className = "result-note";
181
- note.textContent = data.note;
182
- card.appendChild(note);
818
+ const Plow = t.testingThreshold;
819
+ const Phigh = t.testTreatmentThreshold;
820
+ const lowPct = core.clamp(Plow * 100, 0, 100);
821
+ const highPct = core.clamp(Phigh * 100, lowPct, 100);
822
+
823
+ const zones = thresholdGaugeEl.querySelectorAll(".threshold-zone");
824
+ if (zones.length === 3) {
825
+ zones[0].style.flexBasis = `${lowPct}%`;
826
+ zones[1].style.flexBasis = `${Math.max(0, highPct - lowPct)}%`;
827
+ zones[2].style.flexBasis = `${Math.max(0, 100 - highPct)}%`;
183
828
  }
184
829
 
185
- container.appendChild(card);
186
- });
187
- }
830
+ thresholdMarkerPtEl.style.left = `${Ptpercent}%`;
831
+ const prePct = Number.isFinite(thresholdState.preTest) ? thresholdState.preTest * 100 : NaN;
832
+ if (Number.isFinite(prePct)) {
833
+ thresholdMarkerPreEl.style.left = `${core.clamp(prePct, 0, 100)}%`;
834
+ thresholdMarkerPreEl.style.display = "block";
835
+ } else {
836
+ thresholdMarkerPreEl.style.display = "none";
837
+ }
188
838
 
189
- function renderProbabilityChart(container, values) {
190
- if (!container) {
191
- return;
839
+ thresholdReadoutEl.innerHTML = "";
840
+ const stats = [
841
+ ["Testing threshold (don't test below)", core.formatPercentage(Plow)],
842
+ ["Treatment threshold (Pt)", core.formatPercentage(Pt)],
843
+ ["Test-treatment threshold (just treat above)", core.formatPercentage(Phigh)],
844
+ ["Decision at current pre-test", classifyPreTest(prePct, lowPct, highPct, Ptpercent)],
845
+ ];
846
+ for (const [label, value] of stats) {
847
+ const cell = document.createElement("div");
848
+ cell.className = "threshold-stat";
849
+ const lab = document.createElement("span");
850
+ lab.className = "threshold-stat-label";
851
+ lab.textContent = label;
852
+ const val = document.createElement("span");
853
+ val.className = "threshold-stat-value";
854
+ val.textContent = value;
855
+ cell.appendChild(lab);
856
+ cell.appendChild(val);
857
+ thresholdReadoutEl.appendChild(cell);
858
+ }
859
+ }
860
+
861
+ function classifyPreTest(prePct, lowPct, highPct, Ptpct) {
862
+ if (!Number.isFinite(prePct)) return "—";
863
+ if (prePct < lowPct) return "Don't test — even a positive result wouldn't reach Pt.";
864
+ if (prePct > highPct) return "Treat without testing — even a negative result stays above Pt.";
865
+ return `Test useful — result can move you across the Pt=${Ptpct.toFixed(0)}% line.`;
192
866
  }
193
867
 
194
- container.innerHTML = "";
195
- container.setAttribute("role", "list");
868
+ if (treatmentThresholdInputEl) {
869
+ treatmentThresholdInputEl.addEventListener("input", updateThresholdView);
870
+ }
196
871
 
197
- const rows = [
198
- { label: "Pre-test", value: values.pre, variant: "baseline" },
199
- { label: "Post-test (+)", value: values.postPositive, variant: "positive" },
200
- { label: "Post-test (-)", value: values.postNegative, variant: "negative" },
201
- ];
872
+ function showSequentialTest(postPositive, postNegative) {
873
+ if (!sequentialTestEl) return;
874
+ sequentialState.postPositive = postPositive;
875
+ sequentialState.postNegative = postNegative;
876
+ sequentialTestEl.style.display = "block";
877
+ updateSequentialPretestLabel();
878
+ setSequentialFeedback("", true);
879
+ sequentialResultsEl.innerHTML = "";
880
+ }
202
881
 
203
- rows.forEach((row) => {
204
- container.appendChild(createChartRow(row));
205
- });
206
- }
882
+ function hideSequentialTest() {
883
+ if (!sequentialTestEl) return;
884
+ sequentialState.postPositive = NaN;
885
+ sequentialState.postNegative = NaN;
886
+ sequentialTestEl.style.display = "none";
887
+ sequentialTestEl.open = false;
888
+ setSequentialFeedback("", true);
889
+ sequentialResultsEl.innerHTML = "";
890
+ ["tp2", "fp2", "fn2", "tn2"].forEach((id) => {
891
+ const el = document.getElementById(id);
892
+ if (el) el.value = "";
893
+ });
894
+ }
207
895
 
208
- function clearProbabilityChart(container) {
209
- if (container) {
210
- container.innerHTML = "";
211
- container.removeAttribute("role");
896
+ function chainedPreTestPercent() {
897
+ const from = sequentialFromEl ? sequentialFromEl.value : "positive";
898
+ const fraction = from === "negative" ? sequentialState.postNegative : sequentialState.postPositive;
899
+ if (!Number.isFinite(fraction)) return NaN;
900
+ return core.chainedPreTestProbability(fraction);
212
901
  }
213
- }
214
902
 
215
- function createChartRow({ label, value, variant }) {
216
- const row = document.createElement("div");
217
- row.className = "chart-row";
218
- row.setAttribute("role", "listitem");
903
+ function updateSequentialPretestLabel() {
904
+ if (!sequentialPretestEl) return;
905
+ const pct = chainedPreTestPercent();
906
+ if (!Number.isFinite(pct)) {
907
+ sequentialPretestEl.textContent = "";
908
+ return;
909
+ }
910
+ const fromLabel = sequentialFromEl && sequentialFromEl.value === "negative" ? "negative" : "positive";
911
+ sequentialPretestEl.textContent = `Test 2 pre-test probability: ${core.formatPercentage(pct / 100)} (from test 1's post-test after a ${fromLabel} result).`;
912
+ }
913
+
914
+ function setSequentialFeedback(message, isSuccess) {
915
+ if (!sequentialFeedbackEl) return;
916
+ sequentialFeedbackEl.textContent = message;
917
+ sequentialFeedbackEl.classList.remove("error", "success");
918
+ if (!message) return;
919
+ sequentialFeedbackEl.classList.add(isSuccess ? "success" : "error");
920
+ }
921
+
922
+ function calculateSequentialTest() {
923
+ if (!sequentialResultsEl) return;
924
+ const preTestProb = chainedPreTestPercent();
925
+ if (!Number.isFinite(preTestProb)) {
926
+ setSequentialFeedback("Calculate test 1 first.", false);
927
+ return;
928
+ }
929
+
930
+ const input = {
931
+ tp: core.safeParseInt(document.getElementById("tp2").value),
932
+ fp: core.safeParseInt(document.getElementById("fp2").value),
933
+ fn: core.safeParseInt(document.getElementById("fn2").value),
934
+ tn: core.safeParseInt(document.getElementById("tn2").value),
935
+ preTestProb,
936
+ };
937
+ const validation = core.validateInputs(input);
938
+ if (!validation.valid) {
939
+ sequentialResultsEl.innerHTML = "";
940
+ setSequentialFeedback(validation.message, false);
941
+ return;
942
+ }
943
+
944
+ const metrics = core.calculateMetrics(input, { continuityCorrection: currentContinuityMode(), locale: i18n ? i18n.getLocale() : "en" });
945
+ renderResults(sequentialResultsEl, metrics);
946
+ setSequentialFeedback(
947
+ `Test 2 complete. Post-test (+) ${core.formatPercentage(metrics.postTestPositive.value)}, post-test (−) ${core.formatPercentage(metrics.postTestNegative.value)}.`,
948
+ true
949
+ );
950
+ }
951
+
952
+ if (sequentialFromEl) {
953
+ sequentialFromEl.addEventListener("change", () => {
954
+ updateSequentialPretestLabel();
955
+ sequentialResultsEl.innerHTML = "";
956
+ setSequentialFeedback("", true);
957
+ });
958
+ }
959
+ if (sequentialCalcButton) {
960
+ sequentialCalcButton.addEventListener("click", calculateSequentialTest);
961
+ }
962
+ if (sequentialClearButton) {
963
+ sequentialClearButton.addEventListener("click", () => {
964
+ ["tp2", "fp2", "fn2", "tn2"].forEach((id) => {
965
+ const el = document.getElementById(id);
966
+ if (el) el.value = "";
967
+ });
968
+ sequentialResultsEl.innerHTML = "";
969
+ setSequentialFeedback("Test 2 fields cleared.", true);
970
+ });
971
+ }
219
972
 
220
- const labelEl = document.createElement("span");
221
- labelEl.className = "chart-label";
222
- labelEl.textContent = label;
973
+ function showPrevalenceExplorer(sens, spec, currentPrev) {
974
+ if (!prevalenceExplorerEl || !Number.isFinite(sens) || !Number.isFinite(spec)) {
975
+ return;
976
+ }
977
+ prevalenceState.sens = sens;
978
+ prevalenceState.spec = spec;
979
+ prevalenceExplorerEl.style.display = "block";
980
+ const startPercent = Math.max(0.1, Math.min(99.9, (currentPrev || 0.1) * 100));
981
+ prevalenceSliderEl.value = startPercent.toFixed(1);
982
+ updatePrevalenceReadout(startPercent);
983
+ drawPrevalenceChart(sens, spec, startPercent / 100);
984
+ }
223
985
 
224
- const bar = document.createElement("div");
225
- bar.className = "chart-bar";
986
+ function hidePrevalenceExplorer() {
987
+ if (!prevalenceExplorerEl) return;
988
+ prevalenceExplorerEl.style.display = "none";
989
+ prevalenceExplorerEl.open = false;
990
+ prevalenceState.sens = NaN;
991
+ prevalenceState.spec = NaN;
992
+ }
226
993
 
227
- const fill = document.createElement("span");
228
- fill.className = "chart-fill";
229
- if (variant === "positive") {
230
- fill.classList.add("positive");
231
- } else if (variant === "negative") {
232
- fill.classList.add("negative");
994
+ function updatePrevalenceReadout(prevPercent) {
995
+ if (!prevalenceReadoutEl) return;
996
+ const { sens, spec } = prevalenceState;
997
+ if (!Number.isFinite(sens) || !Number.isFinite(spec)) return;
998
+ const prev = prevPercent / 100;
999
+ const ppvDen = sens * prev + (1 - spec) * (1 - prev);
1000
+ const npvDen = spec * (1 - prev) + (1 - sens) * prev;
1001
+ const ppv = ppvDen > 0 ? (sens * prev) / ppvDen : NaN;
1002
+ const npv = npvDen > 0 ? (spec * (1 - prev)) / npvDen : NaN;
1003
+ const postPositive = ppv;
1004
+ const postNegative = Number.isFinite(npv) ? 1 - npv : NaN;
1005
+
1006
+ prevalenceValueEl.textContent = `${prevPercent.toFixed(1)}%`;
1007
+ prevalenceReadoutEl.innerHTML = "";
1008
+ const stats = [
1009
+ ["PPV", ppv],
1010
+ ["NPV", npv],
1011
+ ["Post-test (+)", postPositive],
1012
+ ["Post-test (−)", postNegative],
1013
+ ];
1014
+ for (const [label, value] of stats) {
1015
+ const cell = document.createElement("div");
1016
+ cell.className = "prevalence-stat";
1017
+ const lab = document.createElement("span");
1018
+ lab.className = "prevalence-stat-label";
1019
+ lab.textContent = label;
1020
+ const val = document.createElement("span");
1021
+ val.className = "prevalence-stat-value";
1022
+ val.textContent = core.formatPercentage(value);
1023
+ cell.appendChild(lab);
1024
+ cell.appendChild(val);
1025
+ prevalenceReadoutEl.appendChild(cell);
1026
+ }
233
1027
  }
234
- const safeValue = Number.isFinite(value) ? window.DiagcalcCore.clamp(value, 0, 1) : 0;
235
- fill.style.transform = `scaleX(${safeValue})`;
236
1028
 
237
- const valueEl = document.createElement("span");
238
- valueEl.className = "chart-value";
239
- valueEl.textContent = window.DiagcalcCore.formatPercentage(value);
1029
+ if (prevalenceSliderEl) {
1030
+ prevalenceSliderEl.addEventListener("input", () => {
1031
+ const prev = parseFloat(prevalenceSliderEl.value);
1032
+ if (!Number.isFinite(prev)) return;
1033
+ updatePrevalenceReadout(prev);
1034
+ drawPrevalenceChart(prevalenceState.sens, prevalenceState.spec, prev / 100);
1035
+ });
1036
+ }
240
1037
 
241
- bar.appendChild(fill);
242
- row.appendChild(labelEl);
243
- row.appendChild(bar);
244
- row.appendChild(valueEl);
1038
+ function drawPrevalenceChart(sens, spec, currentPrev) {
1039
+ window.DiagcalcCharts.drawPrevalence(prevalenceCanvas, sens, spec, currentPrev);
1040
+ }
245
1041
 
246
- return row;
247
- }
1042
+ // ── Threshold explorer demo ──────────────────────────────────────────────
1043
+ // Adapted from the ia-saude "Sensibilidade vs especificidade" demo.
1044
+ // Generates two Gaussian score distributions (diseased + healthy) with
1045
+ // overlap, renders them as a Canvas scatter plot, and lets the user drag
1046
+ // a threshold slicer to see how TP/FP/FN/TN and all metrics shift in real
1047
+ // time. Uses core.calculateMetrics() so the numbers match the main
1048
+ // calculator exactly.
1049
+ const thresholdDemoEl = document.getElementById("thresholdDemo");
1050
+ const thresholdDemoCanvas = document.getElementById("thresholdDemoCanvas");
1051
+ const thresholdDemoSliderEl = document.getElementById("thresholdDemoSlider");
1052
+ const thresholdDemoValueEl = document.getElementById("thresholdDemoValue");
1053
+ const thresholdDemoNoteEl = document.getElementById("thresholdDemoNote");
1054
+
1055
+ // Seeded LCG so the scatter plot looks the same on every page load.
1056
+ function makeSeededRng(seed) {
1057
+ let s = seed;
1058
+ return () => {
1059
+ s = (s * 9301 + 49297) % 233280;
1060
+ return s / 233280;
1061
+ };
1062
+ }
1063
+
1064
+ function gaussSeeded(rng, mu, sigma) {
1065
+ const u = rng();
1066
+ const v = rng();
1067
+ return mu + sigma * Math.sqrt(-2 * Math.log(u || 0.0001)) * Math.cos(2 * Math.PI * v);
1068
+ }
1069
+
1070
+ // State: the score distributions are regenerated whenever the scenario
1071
+ // changes, so the demo reflects the current case's sens/spec/prevalence.
1072
+ // We use a binormal model: healthy scores ~ N(0, 1), diseased ~ N(d, 1)
1073
+ // where d = Φ⁻¹(sens) + Φ⁻¹(spec) (the binormal separation). Scores are
1074
+ // then normalised to [0, 1] via Φ for display.
1075
+ let tdDiseasedScores = [];
1076
+ let tdHealthyScores = [];
1077
+ let tdDiseasedJitter = [];
1078
+ let tdHealthyJitter = [];
1079
+
1080
+ function regenerateThresholdDemoData(sens, spec, prevalence) {
1081
+ const rng = makeSeededRng(42);
1082
+ const N = 200;
1083
+ const nDiseased = Math.max(1, Math.min(N - 1, Math.round(N * prevalence)));
1084
+ const nHealthy = N - nDiseased;
1085
+
1086
+ const eps = 1 / (2 * N);
1087
+ const sClamp = Math.min(1 - eps, Math.max(eps, sens));
1088
+ const pClamp = Math.min(1 - eps, Math.max(eps, spec));
1089
+ const d = core.invStdNormal(sClamp) + core.invStdNormal(pClamp);
1090
+
1091
+ tdHealthyScores = [];
1092
+ for (let i = 0; i < nHealthy; i += 1) {
1093
+ const raw = gaussSeeded(rng, 0, 1);
1094
+ tdHealthyScores.push(Math.max(0, Math.min(1, core.stdNormalCdf(raw))));
1095
+ }
1096
+ tdDiseasedScores = [];
1097
+ for (let i = 0; i < nDiseased; i += 1) {
1098
+ const raw = gaussSeeded(rng, d, 1);
1099
+ tdDiseasedScores.push(Math.max(0, Math.min(1, core.stdNormalCdf(raw))));
1100
+ }
1101
+ tdDiseasedJitter = tdDiseasedScores.map(() => (rng() - 0.5) * 16);
1102
+ tdHealthyJitter = tdHealthyScores.map(() => (rng() - 0.5) * 14);
1103
+ }
1104
+
1105
+ function showThresholdDemo(sens, spec, prevalence) {
1106
+ if (!thresholdDemoEl) return;
1107
+ regenerateThresholdDemoData(sens, spec, prevalence);
1108
+ thresholdDemoEl.style.display = "block";
1109
+ renderThresholdDemo();
1110
+ }
1111
+
1112
+ function hideThresholdDemo() {
1113
+ if (!thresholdDemoEl) return;
1114
+ thresholdDemoEl.style.display = "none";
1115
+ thresholdDemoEl.open = false;
1116
+ if (thresholdDemoCanvas) {
1117
+ const ctx = thresholdDemoCanvas.getContext("2d");
1118
+ if (ctx) {
1119
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1120
+ ctx.clearRect(0, 0, thresholdDemoCanvas.width, thresholdDemoCanvas.height);
1121
+ }
1122
+ }
1123
+ }
1124
+
1125
+ function renderThresholdDemo() {
1126
+ if (!thresholdDemoCanvas || !thresholdDemoSliderEl) return;
1127
+ const t = parseFloat(thresholdDemoSliderEl.value) / 100;
1128
+ thresholdDemoValueEl.textContent = t.toFixed(2);
1129
+
1130
+ if (thresholdDemoEl.open) {
1131
+ const ctx = thresholdDemoCanvas.getContext("2d");
1132
+
1133
+
1134
+ const W = thresholdDemoCanvas.clientWidth;
1135
+ const H = thresholdDemoCanvas.clientHeight;
1136
+ const dpr = window.devicePixelRatio || 1;
1137
+ if (thresholdDemoCanvas.width !== Math.round(W * dpr)) thresholdDemoCanvas.width = Math.round(W * dpr);
1138
+ if (thresholdDemoCanvas.height !== Math.round(H * dpr)) thresholdDemoCanvas.height = Math.round(H * dpr);
1139
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1140
+ const margin = { l: 40, r: 20, t: 20, b: 30 };
1141
+ const plotW = W - margin.l - margin.r;
1142
+ const plotH = H - margin.t - margin.b;
1143
+ ctx.clearRect(0, 0, W, H);
1144
+
1145
+ const styles = getComputedStyle(document.documentElement);
1146
+ const axisColor = styles.getPropertyValue("--axis").trim();
1147
+ const mutedColor = styles.getPropertyValue("--text-muted").trim();
1148
+ const successColor = styles.getPropertyValue("--success").trim();
1149
+ const errorColor = styles.getPropertyValue("--error").trim();
1150
+ const primaryColor = styles.getPropertyValue("--primary").trim();
1151
+ const textColor = styles.getPropertyValue("--text").trim();
1152
+
1153
+ const px = (p) => margin.l + p * plotW;
1154
+ const yMid = H - margin.b - 20;
1155
+ const yLow = H - margin.b - 8;
1156
+
1157
+ // Axis
1158
+ ctx.strokeStyle = axisColor;
1159
+ ctx.lineWidth = 1;
1160
+ ctx.beginPath();
1161
+ ctx.moveTo(margin.l, H - margin.b);
1162
+ ctx.lineTo(W - margin.r, H - margin.b);
1163
+ ctx.stroke();
1164
+
1165
+ // Ticks
1166
+ ctx.fillStyle = mutedColor;
1167
+ ctx.font = "10px 'JetBrains Mono', ui-monospace, monospace";
1168
+ ctx.textAlign = "center";
1169
+ for (const v of [0, 0.25, 0.5, 0.75, 1]) {
1170
+ const x = px(v);
1171
+ ctx.beginPath();
1172
+ ctx.moveTo(x, H - margin.b);
1173
+ ctx.lineTo(x, H - margin.b + 4);
1174
+ ctx.strokeStyle = axisColor;
1175
+ ctx.stroke();
1176
+ ctx.fillText(v.toFixed(2), x, H - margin.b + 18);
1177
+ }
1178
+
1179
+ // Healthy dots (bottom row) — green if below threshold, red if above
1180
+ for (let i = 0; i < tdHealthyScores.length; i += 1) {
1181
+ const s = tdHealthyScores[i];
1182
+ const x = px(s);
1183
+ ctx.fillStyle = s >= t ? errorColor : successColor;
1184
+ ctx.beginPath();
1185
+ ctx.arc(x, yLow + tdHealthyJitter[i], 3.5, 0, 2 * Math.PI);
1186
+ ctx.fill();
1187
+ }
1188
+
1189
+ // Diseased dots (top row) — blue if above threshold (TP), red if below (FN)
1190
+ for (let i = 0; i < tdDiseasedScores.length; i += 1) {
1191
+ const s = tdDiseasedScores[i];
1192
+ const x = px(s);
1193
+ ctx.fillStyle = s >= t ? primaryColor : errorColor;
1194
+ ctx.beginPath();
1195
+ ctx.arc(x, yMid + tdDiseasedJitter[i] - 30, 3.5, 0, 2 * Math.PI);
1196
+ ctx.fill();
1197
+ }
1198
+
1199
+ // Threshold line
1200
+ const tx = px(t);
1201
+ ctx.strokeStyle = textColor;
1202
+ ctx.lineWidth = 1.5;
1203
+ ctx.setLineDash([4, 3]);
1204
+ ctx.beginPath();
1205
+ ctx.moveTo(tx, margin.t);
1206
+ ctx.lineTo(tx, H - margin.b);
1207
+ ctx.stroke();
1208
+ ctx.setLineDash([]);
1209
+
1210
+ // Labels
1211
+ ctx.fillStyle = mutedColor;
1212
+ ctx.font = "10px 'JetBrains Mono', ui-monospace, monospace";
1213
+ ctx.textAlign = "left";
1214
+ ctx.fillText("diseased", margin.l, yMid - 25);
1215
+ ctx.fillText("healthy", margin.l, yLow - 14);
1216
+
1217
+ }
1218
+
1219
+ // Compute confusion matrix at this threshold
1220
+ const tp = tdDiseasedScores.filter((s) => s >= t).length;
1221
+ const fn = tdDiseasedScores.length - tp;
1222
+ const fp = tdHealthyScores.filter((s) => s >= t).length;
1223
+ const tn = tdHealthyScores.length - fp;
1224
+
1225
+ // Update matrix cells
1226
+ const setCell = (id, value) => {
1227
+ const el = document.getElementById(id);
1228
+ if (el) el.textContent = String(value);
1229
+ };
1230
+ setCell("tdTp", tp);
1231
+ setCell("tdFp", fp);
1232
+ setCell("tdFn", fn);
1233
+ setCell("tdTn", tn);
1234
+
1235
+ // Compute metrics using core.calculateMetrics for consistency
1236
+ const prevalence = (tp + fn) / (tp + fp + fn + tn);
1237
+ const preTestProb = prevalence * 100;
1238
+ const metrics = core.calculateMetrics(
1239
+ { tp, fp, fn, tn, preTestProb },
1240
+ { continuityCorrection: "auto" }
1241
+ );
1242
+
1243
+ const setStat = (id, value) => {
1244
+ const el = document.getElementById(id);
1245
+ if (el) el.textContent = core.formatPercentage(value);
1246
+ };
1247
+ setStat("tdSens", metrics.sensitivity.value);
1248
+ setStat("tdSpec", metrics.specificity.value);
1249
+ setStat("tdPpv", metrics.ppv.value);
1250
+ setStat("tdNpv", metrics.npv.value);
1251
+
1252
+ // Contextual note
1253
+ const sens = metrics.sensitivity.value;
1254
+ const spec = metrics.specificity.value;
1255
+ let noteKey;
1256
+ if (sens > 0.95 && spec < 0.4) {
1257
+ noteKey = "thresholdDemo.note.low";
1258
+ } else if (spec > 0.95 && sens < 0.5) {
1259
+ noteKey = "thresholdDemo.note.high";
1260
+ } else if (sens > 0.8 && spec > 0.8) {
1261
+ noteKey = "thresholdDemo.note.balanced";
1262
+ } else if (sens >= spec) {
1263
+ noteKey = "thresholdDemo.note.sens";
1264
+ } else {
1265
+ noteKey = "thresholdDemo.note.spec";
1266
+ }
1267
+ if (thresholdDemoNoteEl) {
1268
+ thresholdDemoNoteEl.textContent = tr(noteKey, {
1269
+ sens: core.formatPercentage(sens),
1270
+ spec: core.formatPercentage(spec),
1271
+ });
1272
+ }
1273
+ }
1274
+
1275
+ if (thresholdDemoSliderEl) {
1276
+ thresholdDemoSliderEl.addEventListener("input", scheduler.schedule);
1277
+ }
1278
+
1279
+ function redrawFaganNomogram() {
1280
+ if (!activeSnapshot) return;
1281
+ const metrics = core.calculateMetrics(activeSnapshot.input, activeSnapshot.options);
1282
+ renderFaganNomogram(faganCanvas, faganNomogramEl, {
1283
+ preTest: metrics.preTestProbability.value, lrPositive: metrics.lrPositive.value,
1284
+ lrNegative: metrics.lrNegative.value, postPositive: metrics.postTestPositive.value,
1285
+ postNegative: metrics.postTestNegative.value,
1286
+ });
1287
+ }
1288
+
1289
+ scheduler.register(faganCanvas, redrawFaganNomogram, () => Boolean(activeSnapshot));
1290
+ scheduler.register(rocCanvas, recomputeRoc, () => rocPanelEl.style.display !== "none" && rocPanelEl.open);
1291
+ scheduler.register(prevalenceCanvas, () => {
1292
+ drawPrevalenceChart(prevalenceState.sens, prevalenceState.spec, Number(prevalenceSliderEl.value) / 100);
1293
+ }, () => prevalenceExplorerEl.style.display !== "none" && prevalenceExplorerEl.open);
1294
+ scheduler.register(thresholdDemoCanvas, renderThresholdDemo, () => thresholdDemoEl.style.display !== "none");
1295
+
1296
+ refreshLocaleResults = () => {
1297
+ if (!activeSnapshot) return;
1298
+ const metrics = core.calculateMetrics(activeSnapshot.input, { ...activeSnapshot.options, locale: i18n.getLocale() });
1299
+ renderHeadline(resultsHeadlineEl, metrics);
1300
+ renderSecondary(resultsSecondaryEl, metrics);
1301
+ renderBayesianUpdate(bayesianUpdateEl, metrics);
1302
+ if (sequentialResultsEl.children.length) calculateSequentialTest();
1303
+ renderThresholdDemo();
1304
+ scheduler.schedule();
1305
+ };
1306
+
1307
+ form.addEventListener("input", (event) => {
1308
+ if (!["tp", "fp", "fn", "tn", "preTestProb"].includes(event.target.id)) return;
1309
+ if (caseState.datasetKey) {
1310
+ caseState.modified = true;
1311
+ datasetSelect.value = "";
1312
+ setCase("named", `Customised: ${datasets[caseState.datasetKey].name}`);
1313
+ }
1314
+ activeSnapshot = null;
1315
+ clearMainResults();
1316
+ clearFaganNomogram(faganNomogramEl, faganCanvas);
1317
+ hidePrevalenceExplorer(); hideSequentialTest(); hideThresholdPanel(); hideThresholdDemo(); hideRocPanel();
1318
+ renderBiasWarnings([]);
1319
+ setFeedback("Inputs changed. Calculate to update results.", false);
1320
+ });
1321
+
1322
+ function applyDataset(dataset) {
1323
+ document.getElementById("tp").value = dataset.tp;
1324
+ document.getElementById("fp").value = dataset.fp;
1325
+ document.getElementById("fn").value = dataset.fn;
1326
+ document.getElementById("tn").value = dataset.tn;
1327
+ document.getElementById("preTestProb").value = dataset.preTestProb;
1328
+ }
1329
+
1330
+ function setFeedback(message, isSuccess) {
1331
+ feedbackEl.textContent = message;
1332
+ feedbackEl.classList.remove("error", "success");
1333
+ if (!message) {
1334
+ return;
1335
+ }
1336
+ feedbackEl.classList.add(isSuccess ? "success" : "error");
1337
+ }
1338
+ });
248
1339
 
249
1340
  // Theme Management
250
1341
  function initializeTheme() {
251
- const savedTheme = localStorage.getItem("diagcalc-theme");
1342
+ const savedTheme = preferences.getItem("diagcalc-theme");
252
1343
  const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
253
- const theme = savedTheme || (prefersDark ? "dark" : "light");
1344
+ const theme = ["dark", "light"].includes(savedTheme) ? savedTheme : (prefersDark ? "dark" : "light");
254
1345
  document.documentElement.setAttribute("data-theme", theme);
255
1346
  updateThemeIcon(theme);
256
1347
  }
@@ -259,7 +1350,7 @@ function toggleTheme() {
259
1350
  const currentTheme = document.documentElement.getAttribute("data-theme");
260
1351
  const newTheme = currentTheme === "dark" ? "light" : "dark";
261
1352
  document.documentElement.setAttribute("data-theme", newTheme);
262
- localStorage.setItem("diagcalc-theme", newTheme);
1353
+ preferences.setItem("diagcalc-theme", newTheme);
263
1354
  updateThemeIcon(newTheme);
264
1355
  }
265
1356
 
@@ -282,16 +1373,13 @@ function displayDatasetReference(dataset) {
282
1373
  return;
283
1374
  }
284
1375
 
285
- if (!dataset.reference) {
286
- referenceEl.style.display = "none";
287
- return;
288
- }
1376
+
289
1377
 
290
1378
  const ref = dataset.reference;
291
1379
  referenceEl.innerHTML = "";
292
1380
 
293
1381
  const heading = document.createElement("h4");
294
- heading.textContent = "Reference";
1382
+ heading.textContent = "Illustrative teaching scenario";
295
1383
  referenceEl.appendChild(heading);
296
1384
 
297
1385
  const desc = document.createElement("p");
@@ -300,6 +1388,11 @@ function displayDatasetReference(dataset) {
300
1388
  desc.appendChild(descStrong);
301
1389
  referenceEl.appendChild(desc);
302
1390
 
1391
+ const provenance = document.createElement("p");
1392
+ provenance.textContent = dataset.provenance.note;
1393
+ referenceEl.appendChild(provenance);
1394
+ referenceEl.style.display = "block";
1395
+ if (!ref) return;
303
1396
  const citation = document.createElement("p");
304
1397
  citation.className = "reference-citation";
305
1398
  citation.textContent = `${ref.authors} ${ref.title}. `;
@@ -325,6 +1418,7 @@ function clearFaganNomogram(container, canvas) {
325
1418
  if (container) {
326
1419
  container.style.display = "none";
327
1420
  }
1421
+ clearFaganSummary();
328
1422
 
329
1423
  if (!canvas) {
330
1424
  return;
@@ -337,161 +1431,31 @@ function clearFaganNomogram(container, canvas) {
337
1431
  }
338
1432
  }
339
1433
 
340
- function renderFaganNomogram(canvas, container, data) {
341
- if (!canvas || !container) {
342
- return;
1434
+ function clearFaganSummary() {
1435
+ const ids = ["faganPreValue", "faganLrPosValue", "faganLrNegValue", "faganPostPosValue", "faganPostNegValue"];
1436
+ for (const id of ids) {
1437
+ const el = document.getElementById(id);
1438
+ if (el) el.textContent = "—";
343
1439
  }
1440
+ }
344
1441
 
345
- const { preTest, lrPositive, lrNegative, postPositive, postNegative } = data;
1442
+ function updateFaganSummary({ preTest, lrPositive, lrNegative, postPositive, postNegative }) {
1443
+ const fmt = window.DiagcalcCore;
1444
+ setIf("faganPreValue", fmt.formatPercentage(preTest));
1445
+ setIf("faganLrPosValue", fmt.formatLikelihood(lrPositive));
1446
+ setIf("faganLrNegValue", fmt.formatLikelihood(lrNegative));
1447
+ setIf("faganPostPosValue", fmt.formatPercentage(postPositive));
1448
+ setIf("faganPostNegValue", fmt.formatPercentage(postNegative));
1449
+ }
346
1450
 
347
- if (!Number.isFinite(preTest)) {
348
- clearFaganNomogram(container, canvas);
349
- return;
350
- }
1451
+ function setIf(id, text) {
1452
+ const el = document.getElementById(id);
1453
+ if (el) el.textContent = text;
1454
+ }
351
1455
 
1456
+ function renderFaganNomogram(canvas, container, data) {
1457
+ if (!canvas || !container) return;
1458
+ updateFaganSummary(data);
352
1459
  container.style.display = "block";
353
- const ctx = canvas.getContext("2d");
354
- const dpr = window.devicePixelRatio || 1;
355
- const rect = canvas.getBoundingClientRect();
356
-
357
- canvas.width = rect.width * dpr;
358
- canvas.height = rect.height * dpr;
359
- ctx.setTransform(1, 0, 0, 1, 0, 0);
360
- ctx.scale(dpr, dpr);
361
-
362
- const displayWidth = rect.width;
363
- const displayHeight = rect.height;
364
-
365
- ctx.clearRect(0, 0, displayWidth, displayHeight);
366
-
367
- const margin = 60;
368
- const colWidth = (displayWidth - 2 * margin) / 3;
369
- const scaleHeight = displayHeight - 2 * margin;
370
-
371
- function probToY(probability) {
372
- const clampedProb = Math.max(0.001, Math.min(0.999, probability));
373
- const logit = Math.log(clampedProb / (1 - clampedProb));
374
- const minLogit = Math.log(0.001 / 0.999);
375
- const maxLogit = Math.log(0.999 / 0.001);
376
- const normalized = (logit - minLogit) / (maxLogit - minLogit);
377
- return margin + scaleHeight * (1 - normalized);
378
- }
379
-
380
- const styles = getComputedStyle(document.documentElement);
381
- const axisColor = styles.getPropertyValue("--base-600").trim();
382
- const textColor = styles.getPropertyValue("--text-color").trim();
383
- const warmColor = styles.getPropertyValue("--accent-warm").trim();
384
- const successColor = styles.getPropertyValue("--success-color").trim();
385
-
386
- ctx.strokeStyle = axisColor;
387
- ctx.lineWidth = 2;
388
-
389
- const leftX = margin;
390
- const middleX = margin + colWidth * 1.5;
391
- const rightX = margin + colWidth * 3;
392
-
393
- ctx.beginPath();
394
- ctx.moveTo(leftX, margin);
395
- ctx.lineTo(leftX, margin + scaleHeight);
396
- ctx.moveTo(middleX, margin);
397
- ctx.lineTo(middleX, margin + scaleHeight);
398
- ctx.moveTo(rightX, margin);
399
- ctx.lineTo(rightX, margin + scaleHeight);
400
- ctx.stroke();
401
-
402
- ctx.fillStyle = textColor;
403
- ctx.font = "bold 12px 'JetBrains Mono', monospace";
404
- ctx.textAlign = "center";
405
- ctx.fillText("Pre-test", leftX, margin - 30);
406
- ctx.fillText("Likelihood ratio", middleX, margin - 30);
407
- ctx.fillText("Post-test", rightX, margin - 30);
408
-
409
- ctx.font = "10px 'JetBrains Mono', monospace";
410
- ctx.textAlign = "right";
411
- [0.1, 1, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 99].forEach((p) => {
412
- const y = probToY(p / 100);
413
- ctx.fillText(`${p}%`, leftX - 5, y + 3);
414
- });
415
-
416
- ctx.textAlign = "center";
417
- ctx.fillText("Straight line from pre-test", middleX, margin + 8);
418
- ctx.fillText("to post-test probability", middleX, margin + 22);
419
-
420
- ctx.textAlign = "left";
421
- [0.1, 1, 5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95, 99].forEach((p) => {
422
- const y = probToY(p / 100);
423
- ctx.fillText(`${p}%`, rightX + 5, y + 3);
424
- });
425
-
426
- const preY = probToY(preTest);
427
- drawMarker(leftX, preY, axisColor);
428
-
429
- function midpointY(startY, endY) {
430
- return startY + (endY - startY) * ((middleX - leftX) / (rightX - leftX));
431
- }
432
-
433
- function drawMarker(x, y, color) {
434
- ctx.fillStyle = color;
435
- ctx.beginPath();
436
- ctx.arc(x, y, 5, 0, 2 * Math.PI);
437
- ctx.fill();
438
- }
439
-
440
- function drawLrLabel(y, label, value, color, alignRight) {
441
- const text = `${label} ${window.DiagcalcCore.formatLikelihood(value)}`;
442
- ctx.font = "11px 'JetBrains Mono', monospace";
443
- ctx.textAlign = alignRight ? "right" : "left";
444
- ctx.fillStyle = color;
445
- ctx.fillText(text, alignRight ? middleX - 12 : middleX + 12, y - 8);
446
- }
447
-
448
- if (lrPositive > 0) {
449
- const postPosY = probToY(postPositive);
450
- const lrPosY = midpointY(preY, postPosY);
451
-
452
- ctx.strokeStyle = warmColor;
453
- ctx.lineWidth = 2;
454
- ctx.setLineDash([5, 5]);
455
- ctx.beginPath();
456
- ctx.moveTo(leftX, preY);
457
- ctx.lineTo(middleX, lrPosY);
458
- ctx.lineTo(rightX, postPosY);
459
- ctx.stroke();
460
-
461
- ctx.setLineDash([]);
462
- drawMarker(middleX, lrPosY, warmColor);
463
- drawMarker(rightX, postPosY, warmColor);
464
- drawLrLabel(lrPosY, "LR+", lrPositive, warmColor, true);
465
- }
466
-
467
- if (Number.isFinite(lrNegative) && lrNegative > 0) {
468
- const postNegY = probToY(postNegative);
469
- const lrNegY = midpointY(preY, postNegY);
470
-
471
- ctx.strokeStyle = successColor;
472
- ctx.lineWidth = 2;
473
- ctx.setLineDash([5, 5]);
474
- ctx.beginPath();
475
- ctx.moveTo(leftX, preY);
476
- ctx.lineTo(middleX, lrNegY);
477
- ctx.lineTo(rightX, postNegY);
478
- ctx.stroke();
479
-
480
- ctx.setLineDash([]);
481
- drawMarker(middleX, lrNegY, successColor);
482
- drawMarker(rightX, postNegY, successColor);
483
- drawLrLabel(lrNegY, "LR-", lrNegative, successColor, false);
484
- }
485
-
486
- ctx.font = "11px 'JetBrains Mono', monospace";
487
- ctx.textAlign = "left";
488
- ctx.fillStyle = warmColor;
489
- ctx.fillRect(margin, displayHeight - 40, 15, 3);
490
- ctx.fillStyle = textColor;
491
- ctx.fillText("Positive test (LR+)", margin + 20, displayHeight - 36);
492
-
493
- ctx.fillStyle = successColor;
494
- ctx.fillRect(margin, displayHeight - 25, 15, 3);
495
- ctx.fillStyle = textColor;
496
- ctx.fillText("Negative test (LR-)", margin + 20, displayHeight - 21);
1460
+ window.DiagcalcCharts.drawFagan(canvas, data);
497
1461
  }