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