diagcalc 3.2.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 ADDED
@@ -0,0 +1,497 @@
1
+ document.addEventListener("DOMContentLoaded", () => {
2
+ const core = window.DiagcalcCore;
3
+ const datasetStore = window.DiagcalcDatasets;
4
+
5
+ if (!core || !datasetStore) {
6
+ return;
7
+ }
8
+
9
+ const datasets = datasetStore.datasets;
10
+ const form = document.getElementById("inputForm");
11
+ const feedbackEl = document.getElementById("feedback");
12
+ const resultsEl = document.getElementById("results");
13
+ const datasetSelect = document.getElementById("datasetSelect");
14
+ const resetButton = document.getElementById("resetButton");
15
+ const probabilityChartEl = document.getElementById("probabilityChart");
16
+ const printButton = document.getElementById("printButton");
17
+ const datasetReferenceEl = document.getElementById("dataset-reference");
18
+ const themeToggle = document.getElementById("themeToggle");
19
+ const faganNomogramEl = document.getElementById("faganNomogram");
20
+ const faganCanvas = document.getElementById("faganCanvas");
21
+
22
+ initializeTheme();
23
+
24
+ if (themeToggle) {
25
+ themeToggle.addEventListener("click", () => {
26
+ toggleTheme();
27
+ redrawFaganNomogram();
28
+ });
29
+ }
30
+
31
+ form.addEventListener("submit", (event) => {
32
+ event.preventDefault();
33
+ calculateAndRender();
34
+ });
35
+
36
+ datasetSelect.addEventListener("change", () => {
37
+ const selected = datasetSelect.value;
38
+ if (!selected || !datasets[selected]) {
39
+ datasetReferenceEl.style.display = "none";
40
+ return;
41
+ }
42
+
43
+ const dataset = datasets[selected];
44
+ applyDataset(dataset);
45
+ displayDatasetReference(dataset);
46
+ setFeedback("Scenario loaded. Adjust the values as needed and recalculate.", true);
47
+ calculateAndRender();
48
+ });
49
+
50
+ form.addEventListener("reset", () => {
51
+ window.requestAnimationFrame(() => {
52
+ resultsEl.innerHTML = "";
53
+ datasetSelect.value = "";
54
+ datasetReferenceEl.style.display = "none";
55
+ setFeedback("Fields cleared.", true);
56
+ clearProbabilityChart(probabilityChartEl);
57
+ clearFaganNomogram(faganNomogramEl, faganCanvas);
58
+ });
59
+ });
60
+
61
+ resetButton.addEventListener("click", () => {
62
+ datasetSelect.focus();
63
+ });
64
+
65
+ if (printButton) {
66
+ printButton.addEventListener("click", () => {
67
+ window.print();
68
+ });
69
+ }
70
+
71
+ function readFormValues() {
72
+ const preTestField = document.getElementById("preTestProb");
73
+ const preTestValue = core.normaliseDecimal(preTestField.value);
74
+ return {
75
+ tp: core.safeParseInt(document.getElementById("tp").value),
76
+ fp: core.safeParseInt(document.getElementById("fp").value),
77
+ fn: core.safeParseInt(document.getElementById("fn").value),
78
+ tn: core.safeParseInt(document.getElementById("tn").value),
79
+ preTestProb: preTestValue === "" ? NaN : parseFloat(preTestValue),
80
+ };
81
+ }
82
+
83
+ function calculateAndRender() {
84
+ const data = readFormValues();
85
+ const validation = core.validateInputs(data);
86
+
87
+ if (!validation.valid) {
88
+ resultsEl.innerHTML = "";
89
+ setFeedback(validation.message, false);
90
+ clearProbabilityChart(probabilityChartEl);
91
+ clearFaganNomogram(faganNomogramEl, faganCanvas);
92
+ return;
93
+ }
94
+
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,
101
+ });
102
+ renderFaganNomogram(faganCanvas, faganNomogramEl, {
103
+ preTest: metrics.preTestProbability.value,
104
+ lrPositive: metrics.lrPositive.value,
105
+ lrNegative: metrics.lrNegative.value,
106
+ postPositive: metrics.postTestPositive.value,
107
+ postNegative: metrics.postTestNegative.value,
108
+ });
109
+ setFeedback("Calculation complete. Explore the interpretation guide below.", true);
110
+ }
111
+
112
+ function redrawFaganNomogram() {
113
+ if (!faganNomogramEl || faganNomogramEl.style.display === "none") {
114
+ return;
115
+ }
116
+
117
+ const data = readFormValues();
118
+ const validation = core.validateInputs(data);
119
+ if (!validation.valid) {
120
+ return;
121
+ }
122
+
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,
130
+ });
131
+ }
132
+
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;
139
+ }
140
+
141
+ function setFeedback(message, isSuccess) {
142
+ feedbackEl.textContent = message;
143
+ feedbackEl.classList.remove("error", "success");
144
+ if (!message) {
145
+ return;
146
+ }
147
+ feedbackEl.classList.add(isSuccess ? "success" : "error");
148
+ }
149
+ });
150
+
151
+ function renderResults(container, metrics) {
152
+ if (!container) {
153
+ return;
154
+ }
155
+
156
+ container.innerHTML = "";
157
+ const entries = Object.entries(metrics);
158
+ entries.forEach(([, data]) => {
159
+ const card = document.createElement("article");
160
+ card.className = "result-card";
161
+
162
+ const title = document.createElement("h3");
163
+ title.textContent = data.label;
164
+ card.appendChild(title);
165
+
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);
170
+
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);
176
+ }
177
+
178
+ if (data.note) {
179
+ const note = document.createElement("p");
180
+ note.className = "result-note";
181
+ note.textContent = data.note;
182
+ card.appendChild(note);
183
+ }
184
+
185
+ container.appendChild(card);
186
+ });
187
+ }
188
+
189
+ function renderProbabilityChart(container, values) {
190
+ if (!container) {
191
+ return;
192
+ }
193
+
194
+ container.innerHTML = "";
195
+ container.setAttribute("role", "list");
196
+
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
+ ];
202
+
203
+ rows.forEach((row) => {
204
+ container.appendChild(createChartRow(row));
205
+ });
206
+ }
207
+
208
+ function clearProbabilityChart(container) {
209
+ if (container) {
210
+ container.innerHTML = "";
211
+ container.removeAttribute("role");
212
+ }
213
+ }
214
+
215
+ function createChartRow({ label, value, variant }) {
216
+ const row = document.createElement("div");
217
+ row.className = "chart-row";
218
+ row.setAttribute("role", "listitem");
219
+
220
+ const labelEl = document.createElement("span");
221
+ labelEl.className = "chart-label";
222
+ labelEl.textContent = label;
223
+
224
+ const bar = document.createElement("div");
225
+ bar.className = "chart-bar";
226
+
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");
233
+ }
234
+ const safeValue = Number.isFinite(value) ? window.DiagcalcCore.clamp(value, 0, 1) : 0;
235
+ fill.style.transform = `scaleX(${safeValue})`;
236
+
237
+ const valueEl = document.createElement("span");
238
+ valueEl.className = "chart-value";
239
+ valueEl.textContent = window.DiagcalcCore.formatPercentage(value);
240
+
241
+ bar.appendChild(fill);
242
+ row.appendChild(labelEl);
243
+ row.appendChild(bar);
244
+ row.appendChild(valueEl);
245
+
246
+ return row;
247
+ }
248
+
249
+ // Theme Management
250
+ function initializeTheme() {
251
+ const savedTheme = localStorage.getItem("diagcalc-theme");
252
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
253
+ const theme = savedTheme || (prefersDark ? "dark" : "light");
254
+ document.documentElement.setAttribute("data-theme", theme);
255
+ updateThemeIcon(theme);
256
+ }
257
+
258
+ function toggleTheme() {
259
+ const currentTheme = document.documentElement.getAttribute("data-theme");
260
+ const newTheme = currentTheme === "dark" ? "light" : "dark";
261
+ document.documentElement.setAttribute("data-theme", newTheme);
262
+ localStorage.setItem("diagcalc-theme", newTheme);
263
+ updateThemeIcon(newTheme);
264
+ }
265
+
266
+ function updateThemeIcon(theme) {
267
+ const toggle = document.getElementById("themeToggle");
268
+ if (!toggle) {
269
+ return;
270
+ }
271
+
272
+ const icon = toggle.querySelector(".theme-icon");
273
+ if (icon) {
274
+ icon.textContent = theme === "dark" ? "☀" : "◐";
275
+ }
276
+ }
277
+
278
+ // Dataset Reference Display
279
+ function displayDatasetReference(dataset) {
280
+ const referenceEl = document.getElementById("dataset-reference");
281
+ if (!referenceEl) {
282
+ return;
283
+ }
284
+
285
+ if (!dataset.reference) {
286
+ referenceEl.style.display = "none";
287
+ return;
288
+ }
289
+
290
+ const ref = dataset.reference;
291
+ referenceEl.innerHTML = "";
292
+
293
+ const heading = document.createElement("h4");
294
+ heading.textContent = "Reference";
295
+ referenceEl.appendChild(heading);
296
+
297
+ const desc = document.createElement("p");
298
+ const descStrong = document.createElement("strong");
299
+ descStrong.textContent = dataset.description;
300
+ desc.appendChild(descStrong);
301
+ referenceEl.appendChild(desc);
302
+
303
+ const citation = document.createElement("p");
304
+ citation.className = "reference-citation";
305
+ citation.textContent = `${ref.authors} ${ref.title}. `;
306
+
307
+ const em = document.createElement("em");
308
+ em.textContent = ref.journal;
309
+ citation.appendChild(em);
310
+ citation.appendChild(document.createTextNode(". "));
311
+
312
+ const link = document.createElement("a");
313
+ link.href = ref.url;
314
+ link.target = "_blank";
315
+ link.rel = "noopener noreferrer";
316
+ link.textContent = `DOI: ${ref.doi}`;
317
+ citation.appendChild(link);
318
+
319
+ referenceEl.appendChild(citation);
320
+ referenceEl.style.display = "block";
321
+ }
322
+
323
+ // Fagan Nomogram Rendering
324
+ function clearFaganNomogram(container, canvas) {
325
+ if (container) {
326
+ container.style.display = "none";
327
+ }
328
+
329
+ if (!canvas) {
330
+ return;
331
+ }
332
+
333
+ const context = canvas.getContext("2d");
334
+ if (context) {
335
+ context.setTransform(1, 0, 0, 1, 0, 0);
336
+ context.clearRect(0, 0, canvas.width, canvas.height);
337
+ }
338
+ }
339
+
340
+ function renderFaganNomogram(canvas, container, data) {
341
+ if (!canvas || !container) {
342
+ return;
343
+ }
344
+
345
+ const { preTest, lrPositive, lrNegative, postPositive, postNegative } = data;
346
+
347
+ if (!Number.isFinite(preTest)) {
348
+ clearFaganNomogram(container, canvas);
349
+ return;
350
+ }
351
+
352
+ 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);
497
+ }