diagcalc 3.2.3 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -6
- package/bin/diagcalc.js +147 -41
- package/docs/PROVENANCE.md +17 -0
- package/index.html +408 -92
- package/lib/README.md +98 -0
- package/lib/diagcalc-case.js +148 -0
- package/lib/diagcalc-core.js +557 -89
- package/lib/diagcalc-datasets.js +140 -1
- package/lib/diagcalc-geometry.js +27 -0
- package/lib/diagcalc-i18n.js +255 -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 +1239 -275
- package/styles.css +2186 -325
- package/tui/index.js +83 -273
- 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 +234 -0
package/tui/index.js
CHANGED
|
@@ -4,130 +4,9 @@ const blessed = require("blessed");
|
|
|
4
4
|
const core = require("../lib/diagcalc-core");
|
|
5
5
|
const datasetStore = require("../lib/diagcalc-datasets");
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
renderMetricLine(metrics.specificity, "specificity"),
|
|
11
|
-
renderMetricLine(metrics.ppv, "ppv"),
|
|
12
|
-
renderMetricLine(metrics.npv, "npv"),
|
|
13
|
-
];
|
|
14
|
-
|
|
15
|
-
const bayes = [
|
|
16
|
-
renderMetricLine(metrics.lrPositive, "lrPositive"),
|
|
17
|
-
renderMetricLine(metrics.lrNegative, "lrNegative"),
|
|
18
|
-
renderMetricLine(metrics.preTestProbability, "neutral"),
|
|
19
|
-
renderMetricLine(metrics.postTestPositive, "postPositive"),
|
|
20
|
-
renderMetricLine(metrics.postTestNegative, "postNegative"),
|
|
21
|
-
];
|
|
22
|
-
|
|
23
|
-
const bars = [
|
|
24
|
-
`Pre-test [${core.buildProbabilityBar(metrics.preTestProbability.value, 24)}] ${core.formatPercentage(metrics.preTestProbability.value)}`,
|
|
25
|
-
`Post-test (+) [${core.buildProbabilityBar(metrics.postTestPositive.value, 24)}] ${core.formatPercentage(metrics.postTestPositive.value)}`,
|
|
26
|
-
`Post-test (-) [${core.buildProbabilityBar(metrics.postTestNegative.value, 24)}] ${core.formatPercentage(metrics.postTestNegative.value)}`,
|
|
27
|
-
];
|
|
28
|
-
|
|
29
|
-
return [
|
|
30
|
-
"{bold}Performance Measures{/bold}",
|
|
31
|
-
performance.join("\n"),
|
|
32
|
-
"",
|
|
33
|
-
"{bold}Bayesian Update{/bold}",
|
|
34
|
-
bayes.join("\n"),
|
|
35
|
-
"",
|
|
36
|
-
"{bold}Probability Shift{/bold}",
|
|
37
|
-
bars.join("\n"),
|
|
38
|
-
"",
|
|
39
|
-
"{bold}Interpretation{/bold}",
|
|
40
|
-
`Sensitivity ${metrics.sensitivity.note}`,
|
|
41
|
-
`Specificity ${metrics.specificity.note}`,
|
|
42
|
-
`LR+ ${metrics.lrPositive.note}`,
|
|
43
|
-
`LR- ${metrics.lrNegative.note}`,
|
|
44
|
-
].join("\n");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function renderMetricLine(metric, kind) {
|
|
48
|
-
const value = core.formatValue(metric.value, metric.formatter);
|
|
49
|
-
const valueTag = colourValue(value, kind, metric.value);
|
|
50
|
-
const confidence = renderConfidence(metric);
|
|
51
|
-
const confidenceText = confidence ? ` {gray-fg}${confidence}{/}` : "";
|
|
52
|
-
return `${metric.label.padEnd(36)} ${valueTag}${confidenceText}`;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function renderConfidence(metric) {
|
|
56
|
-
if (!metric.ci) {
|
|
57
|
-
return "";
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return `95% CI ${core.formatPercentage(metric.ci.lower)} to ${core.formatPercentage(metric.ci.upper)}`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function colourValue(value, kind, numericValue) {
|
|
64
|
-
const { colour, badge } = metricTone(kind, numericValue);
|
|
65
|
-
return `{${colour}-fg}${value}{/} {bold}{${colour}-fg}[${badge}]{/}{/}`;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function metricTone(kind, value) {
|
|
69
|
-
if (!Number.isFinite(value)) {
|
|
70
|
-
return { colour: "magenta", badge: "EXTREME" };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (kind === "sensitivity" || kind === "specificity" || kind === "ppv" || kind === "npv") {
|
|
74
|
-
if (value >= 0.9) {
|
|
75
|
-
return { colour: "green", badge: "STRONG" };
|
|
76
|
-
}
|
|
77
|
-
if (value >= 0.7) {
|
|
78
|
-
return { colour: "yellow", badge: "MOD" };
|
|
79
|
-
}
|
|
80
|
-
return { colour: "red", badge: "WEAK" };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (kind === "lrPositive") {
|
|
84
|
-
if (value >= 10) {
|
|
85
|
-
return { colour: "green", badge: "RULE-IN" };
|
|
86
|
-
}
|
|
87
|
-
if (value >= 5) {
|
|
88
|
-
return { colour: "yellow", badge: "USEFUL" };
|
|
89
|
-
}
|
|
90
|
-
if (value >= 2) {
|
|
91
|
-
return { colour: "yellow", badge: "LIMITED" };
|
|
92
|
-
}
|
|
93
|
-
return { colour: "red", badge: "WEAK" };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (kind === "lrNegative") {
|
|
97
|
-
if (value <= 0.1) {
|
|
98
|
-
return { colour: "green", badge: "RULE-OUT" };
|
|
99
|
-
}
|
|
100
|
-
if (value <= 0.2) {
|
|
101
|
-
return { colour: "yellow", badge: "USEFUL" };
|
|
102
|
-
}
|
|
103
|
-
if (value <= 0.5) {
|
|
104
|
-
return { colour: "yellow", badge: "LIMITED" };
|
|
105
|
-
}
|
|
106
|
-
return { colour: "red", badge: "WEAK" };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (kind === "postPositive") {
|
|
110
|
-
if (value >= 0.8) {
|
|
111
|
-
return { colour: "green", badge: "HIGH" };
|
|
112
|
-
}
|
|
113
|
-
if (value >= 0.4) {
|
|
114
|
-
return { colour: "yellow", badge: "MID" };
|
|
115
|
-
}
|
|
116
|
-
return { colour: "red", badge: "LOW" };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (kind === "postNegative") {
|
|
120
|
-
if (value <= 0.1) {
|
|
121
|
-
return { colour: "green", badge: "LOW" };
|
|
122
|
-
}
|
|
123
|
-
if (value <= 0.3) {
|
|
124
|
-
return { colour: "yellow", badge: "MID" };
|
|
125
|
-
}
|
|
126
|
-
return { colour: "red", badge: "HIGH" };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return { colour: "cyan", badge: "INFO" };
|
|
130
|
-
}
|
|
7
|
+
const { renderResults, renderConfidence, CREW } = require("./presentation");
|
|
8
|
+
const { sanitiseRawInput, isAcceptedCharacter } = require("./input");
|
|
9
|
+
const { buildTextExport, buildMarkdownExport } = require("./report");
|
|
131
10
|
|
|
132
11
|
function runTui() {
|
|
133
12
|
const datasets = [
|
|
@@ -187,7 +66,7 @@ function runTui() {
|
|
|
187
66
|
height: 3,
|
|
188
67
|
tags: true,
|
|
189
68
|
border: "line",
|
|
190
|
-
content: "{bold}DIAGCALC{/bold} Arrows move Enter edit/load n new
|
|
69
|
+
content: "{bold}DIAGCALC{/bold} Arrows move Enter edit/load n new + chain x export txt m export md Tab/Ctrl-N next panel r reset q quit",
|
|
191
70
|
});
|
|
192
71
|
|
|
193
72
|
const datasetList = blessed.list({
|
|
@@ -203,14 +82,14 @@ function runTui() {
|
|
|
203
82
|
mouse: true,
|
|
204
83
|
style: {
|
|
205
84
|
selected: {
|
|
206
|
-
bg:
|
|
85
|
+
bg: CREW.queequeg,
|
|
207
86
|
},
|
|
208
87
|
border: {
|
|
209
|
-
fg:
|
|
88
|
+
fg: CREW.ishmael,
|
|
210
89
|
},
|
|
211
90
|
focus: {
|
|
212
91
|
border: {
|
|
213
|
-
fg:
|
|
92
|
+
fg: CREW.queequeg,
|
|
214
93
|
},
|
|
215
94
|
},
|
|
216
95
|
},
|
|
@@ -245,11 +124,11 @@ function runTui() {
|
|
|
245
124
|
tags: true,
|
|
246
125
|
style: {
|
|
247
126
|
border: {
|
|
248
|
-
fg:
|
|
127
|
+
fg: CREW.ishmael,
|
|
249
128
|
},
|
|
250
129
|
focus: {
|
|
251
130
|
border: {
|
|
252
|
-
fg:
|
|
131
|
+
fg: CREW.queequeg,
|
|
253
132
|
},
|
|
254
133
|
},
|
|
255
134
|
},
|
|
@@ -272,11 +151,11 @@ function runTui() {
|
|
|
272
151
|
content: "Press Enter on a dataset or input row.",
|
|
273
152
|
style: {
|
|
274
153
|
border: {
|
|
275
|
-
fg:
|
|
154
|
+
fg: CREW.ishmael,
|
|
276
155
|
},
|
|
277
156
|
focus: {
|
|
278
157
|
border: {
|
|
279
|
-
fg:
|
|
158
|
+
fg: CREW.queequeg,
|
|
280
159
|
},
|
|
281
160
|
},
|
|
282
161
|
},
|
|
@@ -304,6 +183,7 @@ function runTui() {
|
|
|
304
183
|
content: [
|
|
305
184
|
"Enter edit/load",
|
|
306
185
|
"n ad hoc case",
|
|
186
|
+
"+ chain second test",
|
|
307
187
|
"x export .txt",
|
|
308
188
|
"m export .md",
|
|
309
189
|
"Tab next panel",
|
|
@@ -323,16 +203,15 @@ function runTui() {
|
|
|
323
203
|
{
|
|
324
204
|
key: "preTestProb",
|
|
325
205
|
label: "Pre-test probability (%)",
|
|
326
|
-
parser:
|
|
327
|
-
const normalised = core.normaliseDecimal(value);
|
|
328
|
-
return normalised === "" ? NaN : parseFloat(normalised);
|
|
329
|
-
},
|
|
206
|
+
parser: core.parseProbability,
|
|
330
207
|
},
|
|
331
208
|
];
|
|
332
209
|
|
|
333
210
|
const state = {
|
|
334
211
|
activeCaseLabel: "Ad hoc case",
|
|
335
212
|
activeDatasetKey: "ad_hoc",
|
|
213
|
+
originDatasetKey: null,
|
|
214
|
+
previousTestSnapshot: null,
|
|
336
215
|
selectedFieldIndex: 0,
|
|
337
216
|
modalOpen: false,
|
|
338
217
|
rawInput: {
|
|
@@ -511,10 +390,11 @@ function runTui() {
|
|
|
511
390
|
}
|
|
512
391
|
|
|
513
392
|
const metrics = core.calculateMetrics(state.input);
|
|
393
|
+
const warnings = [...core.buildBiasWarnings(state.input), ...datasetStore.buildDatasetWarnings(datasetStore.getDataset(state.originDatasetKey))];
|
|
514
394
|
resultsBox.setLabel(` Results - ${state.activeCaseLabel} `);
|
|
515
|
-
resultsBox.setContent(buildResultSummary(state.activeCaseLabel, state.input, metrics));
|
|
395
|
+
resultsBox.setContent(buildResultSummary(state.activeCaseLabel, state.input, metrics, warnings));
|
|
516
396
|
resultsBox.setScrollPerc(0);
|
|
517
|
-
setFeedback("Calculation complete.");
|
|
397
|
+
setFeedback(warnings.length > 0 ? `Calculation complete (${warnings.length} warning${warnings.length === 1 ? "" : "s"}).` : "Calculation complete.");
|
|
518
398
|
screen.render();
|
|
519
399
|
}
|
|
520
400
|
|
|
@@ -526,6 +406,8 @@ function runTui() {
|
|
|
526
406
|
|
|
527
407
|
state.activeCaseLabel = dataset.name;
|
|
528
408
|
state.activeDatasetKey = dataset.key;
|
|
409
|
+
state.originDatasetKey = dataset.key;
|
|
410
|
+
state.previousTestSnapshot = null;
|
|
529
411
|
state.selectedFieldIndex = 0;
|
|
530
412
|
setFieldValue("tp", String(dataset.tp));
|
|
531
413
|
setFieldValue("fp", String(dataset.fp));
|
|
@@ -539,6 +421,8 @@ function runTui() {
|
|
|
539
421
|
}
|
|
540
422
|
|
|
541
423
|
function resetForm() {
|
|
424
|
+
state.originDatasetKey = null;
|
|
425
|
+
state.previousTestSnapshot = null;
|
|
542
426
|
resetFieldValues();
|
|
543
427
|
state.activeCaseLabel = "Ad hoc case";
|
|
544
428
|
state.activeDatasetKey = "ad_hoc";
|
|
@@ -552,6 +436,8 @@ function runTui() {
|
|
|
552
436
|
}
|
|
553
437
|
|
|
554
438
|
function startAdHocCase() {
|
|
439
|
+
state.originDatasetKey = null;
|
|
440
|
+
state.previousTestSnapshot = null;
|
|
555
441
|
state.activeCaseLabel = "Ad hoc case";
|
|
556
442
|
state.activeDatasetKey = "ad_hoc";
|
|
557
443
|
state.selectedFieldIndex = 0;
|
|
@@ -566,6 +452,42 @@ function runTui() {
|
|
|
566
452
|
screen.render();
|
|
567
453
|
}
|
|
568
454
|
|
|
455
|
+
function chainSecondTest() {
|
|
456
|
+
const validation = core.validateInputs(state.input);
|
|
457
|
+
if (!validation.valid) {
|
|
458
|
+
setFeedback("Calculate a valid first test before chaining.");
|
|
459
|
+
screen.render();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
const m = core.calculateMetrics(state.input);
|
|
463
|
+
const posteriorFraction = m.postTestPositive.value;
|
|
464
|
+
if (!Number.isFinite(posteriorFraction)) {
|
|
465
|
+
setFeedback("First test's post-test (+) is not finite; cannot chain.");
|
|
466
|
+
screen.render();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const newPrePercent = core.chainedPreTestProbability(posteriorFraction);
|
|
470
|
+
state.previousTestSnapshot = require("../lib/diagcalc-case").createSnapshot(state.input, {}, {
|
|
471
|
+
label: state.activeCaseLabel, datasetKey: state.originDatasetKey,
|
|
472
|
+
modified: state.activeDatasetKey === "ad_hoc" && Boolean(state.originDatasetKey),
|
|
473
|
+
provenance: datasetStore.getDataset(state.originDatasetKey)?.provenance || null,
|
|
474
|
+
});
|
|
475
|
+
state.originDatasetKey = null;
|
|
476
|
+
const previousLabel = state.activeCaseLabel.replace(/^Chained from: /, "");
|
|
477
|
+
state.activeCaseLabel = `Chained from: ${previousLabel}`;
|
|
478
|
+
state.activeDatasetKey = "ad_hoc";
|
|
479
|
+
state.selectedFieldIndex = 0;
|
|
480
|
+
resetFieldValues();
|
|
481
|
+
setFieldValue("preTestProb", String(newPrePercent));
|
|
482
|
+
datasetInfo.setContent(`{bold}Chained second test{/bold}\nPre-test set to ${core.formatPercentage(newPrePercent / 100)} from previous post-test (+). Results must be conditionally independent given disease status.\n\nEnter the new TP/FP/FN/TN, then the result panel will update.`);
|
|
483
|
+
updateInputForm();
|
|
484
|
+
resultsBox.setLabel(` Results - ${state.activeCaseLabel} `);
|
|
485
|
+
resultsBox.setContent(buildPendingResults(state.activeCaseLabel, `Pre-test chained to ${core.formatPercentage(newPrePercent / 100)}. Enter the second test's matrix.`));
|
|
486
|
+
setFeedback(`Chained: pre-test now ${core.formatPercentage(newPrePercent / 100)} (from previous post-test +).`);
|
|
487
|
+
inputForm.focus();
|
|
488
|
+
screen.render();
|
|
489
|
+
}
|
|
490
|
+
|
|
569
491
|
function editSelectedField() {
|
|
570
492
|
const selectedIndex = state.selectedFieldIndex;
|
|
571
493
|
const field = fields[selectedIndex];
|
|
@@ -613,7 +535,7 @@ function runTui() {
|
|
|
613
535
|
setFieldValue(field.key, "");
|
|
614
536
|
} else if (typeof character === "string" && character !== "") {
|
|
615
537
|
const next = sanitiseRawInput(field.key, `${current}${character}`);
|
|
616
|
-
if (
|
|
538
|
+
if (!isAcceptedCharacter(field.key, character, current)) {
|
|
617
539
|
return;
|
|
618
540
|
}
|
|
619
541
|
setFieldValue(field.key, next);
|
|
@@ -635,7 +557,7 @@ function runTui() {
|
|
|
635
557
|
const defaultPath = path.join(process.cwd(), currentCaseSlug(extension));
|
|
636
558
|
|
|
637
559
|
runModal((done) => {
|
|
638
|
-
prompt.input(`Export ${format} to path`, defaultPath, (error, outputPath) => {
|
|
560
|
+
prompt.input(`Export ${format} to a new path`, defaultPath, async (error, outputPath) => {
|
|
639
561
|
if (!error && outputPath !== null) {
|
|
640
562
|
const trimmedPath = outputPath.trim();
|
|
641
563
|
if (!trimmedPath) {
|
|
@@ -651,7 +573,7 @@ function runTui() {
|
|
|
651
573
|
: buildTextExport(state, metrics, validation);
|
|
652
574
|
|
|
653
575
|
try {
|
|
654
|
-
fs.
|
|
576
|
+
await fs.promises.writeFile(trimmedPath, content, { encoding: "utf8", flag: "wx" });
|
|
655
577
|
setFeedback(`Exported ${format} report to ${trimmedPath}`);
|
|
656
578
|
message.display(`{green-fg}Saved{/}\n${trimmedPath}`, 3, () => {
|
|
657
579
|
done();
|
|
@@ -720,8 +642,13 @@ function runTui() {
|
|
|
720
642
|
resultsBox.key(["pageup"], () => resultsBox.scroll(-10));
|
|
721
643
|
resultsBox.key(["pagedown"], () => resultsBox.scroll(10));
|
|
722
644
|
|
|
723
|
-
screen.key(["
|
|
645
|
+
screen.key(["C-c"], () => {
|
|
646
|
+
screen.destroy();
|
|
647
|
+
process.exit(0);
|
|
648
|
+
});
|
|
649
|
+
screen.key(["q"], () => {
|
|
724
650
|
if (!state.modalOpen) {
|
|
651
|
+
screen.destroy();
|
|
725
652
|
process.exit(0);
|
|
726
653
|
}
|
|
727
654
|
});
|
|
@@ -730,6 +657,11 @@ function runTui() {
|
|
|
730
657
|
startAdHocCase();
|
|
731
658
|
}
|
|
732
659
|
});
|
|
660
|
+
screen.key(["+"], () => {
|
|
661
|
+
if (!state.modalOpen) {
|
|
662
|
+
chainSecondTest();
|
|
663
|
+
}
|
|
664
|
+
});
|
|
733
665
|
screen.key(["r"], () => {
|
|
734
666
|
if (!state.modalOpen) {
|
|
735
667
|
resetForm();
|
|
@@ -784,8 +716,11 @@ function buildPendingResults(caseLabel, message) {
|
|
|
784
716
|
].join("\n");
|
|
785
717
|
}
|
|
786
718
|
|
|
787
|
-
function buildResultSummary(caseLabel, input, metrics) {
|
|
719
|
+
function buildResultSummary(caseLabel, input, metrics, warnings) {
|
|
788
720
|
const totals = buildTotals(input);
|
|
721
|
+
const warningsBlock = Array.isArray(warnings) && warnings.length > 0
|
|
722
|
+
? `\n{${"#DEC577"}-fg}{bold}Heads up:{/bold}{/}\n${warnings.map((w) => ` • ${w}`).join("\n")}\n`
|
|
723
|
+
: "";
|
|
789
724
|
const caseSummary = [
|
|
790
725
|
buildStatusStrip({
|
|
791
726
|
caseLabel,
|
|
@@ -793,7 +728,7 @@ function buildResultSummary(caseLabel, input, metrics) {
|
|
|
793
728
|
statusColour: "green",
|
|
794
729
|
summary: `LR+ ${core.formatValue(metrics.lrPositive.value, metrics.lrPositive.formatter)} | LR- ${core.formatValue(metrics.lrNegative.value, metrics.lrNegative.formatter)} | Post+ ${core.formatValue(metrics.postTestPositive.value)} | Post- ${core.formatValue(metrics.postTestNegative.value)}`,
|
|
795
730
|
}),
|
|
796
|
-
|
|
731
|
+
warningsBlock,
|
|
797
732
|
"{bold}Case Overview{/bold}",
|
|
798
733
|
buildConfusionMatrixBox(input),
|
|
799
734
|
`Diseased ${totals.diseased} Non-diseased ${totals.nonDiseased} Total ${totals.total}`,
|
|
@@ -821,6 +756,10 @@ function displayValue(value) {
|
|
|
821
756
|
return Number.isFinite(value) ? String(value) : "-";
|
|
822
757
|
}
|
|
823
758
|
|
|
759
|
+
function formatPreTest(value) {
|
|
760
|
+
return Number.isFinite(value) ? core.formatPercentage(value / 100) : "-";
|
|
761
|
+
}
|
|
762
|
+
|
|
824
763
|
function buildConfusionMatrixBox(input) {
|
|
825
764
|
const tp = displayValue(input.tp).padStart(5);
|
|
826
765
|
const fp = displayValue(input.fp).padStart(5);
|
|
@@ -850,139 +789,10 @@ function renderFieldCell(state, fields, index, width = 10) {
|
|
|
850
789
|
return `{bold}${padded}{/bold}`;
|
|
851
790
|
}
|
|
852
791
|
|
|
853
|
-
function sanitiseRawInput(fieldKey, rawValue) {
|
|
854
|
-
if (typeof rawValue !== "string") {
|
|
855
|
-
return "";
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
if (fieldKey === "preTestProb") {
|
|
859
|
-
let cleaned = rawValue.replace(/,/g, ".").replace(/[^0-9.]/g, "");
|
|
860
|
-
const firstDotIndex = cleaned.indexOf(".");
|
|
861
|
-
if (firstDotIndex !== -1) {
|
|
862
|
-
cleaned = `${cleaned.slice(0, firstDotIndex + 1)}${cleaned.slice(firstDotIndex + 1).replace(/\./g, "")}`;
|
|
863
|
-
}
|
|
864
|
-
return cleaned;
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
return rawValue.replace(/\D/g, "");
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
function isAcceptedCharacter(fieldKey, character, current) {
|
|
871
|
-
if (fieldKey === "preTestProb") {
|
|
872
|
-
if (/^[0-9]$/.test(character)) {
|
|
873
|
-
return true;
|
|
874
|
-
}
|
|
875
|
-
if ((character === "." || character === ",") && !current.includes(".") && !current.includes(",")) {
|
|
876
|
-
return true;
|
|
877
|
-
}
|
|
878
|
-
return false;
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
return /^[0-9]$/.test(character);
|
|
882
|
-
}
|
|
883
|
-
|
|
884
792
|
function buildStatusStrip({ caseLabel, status, statusColour, summary }) {
|
|
885
793
|
return `{bold}${caseLabel}{/bold} {${statusColour}-fg}[${status}]{/} ${summary}`;
|
|
886
794
|
}
|
|
887
795
|
|
|
888
|
-
function buildTextExport(state, metrics, validation) {
|
|
889
|
-
const lines = [
|
|
890
|
-
`DIAGCALC - ${state.activeCaseLabel}`,
|
|
891
|
-
"",
|
|
892
|
-
"Confusion matrix",
|
|
893
|
-
` TP ${displayValue(state.input.tp)} FP ${displayValue(state.input.fp)}`,
|
|
894
|
-
` FN ${displayValue(state.input.fn)} TN ${displayValue(state.input.tn)}`,
|
|
895
|
-
` Pre-test probability ${displayValue(state.input.preTestProb)}%`,
|
|
896
|
-
"",
|
|
897
|
-
];
|
|
898
|
-
|
|
899
|
-
if (!validation.valid || !metrics) {
|
|
900
|
-
lines.push(`Validation: ${validation.message}`);
|
|
901
|
-
return `${lines.join("\n")}\n`;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
lines.push(...buildPlainMetricSections(metrics));
|
|
905
|
-
return `${lines.join("\n")}\n`;
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
function buildMarkdownExport(state, metrics, validation) {
|
|
909
|
-
const lines = [
|
|
910
|
-
`# DIAGCALC - ${state.activeCaseLabel}`,
|
|
911
|
-
"",
|
|
912
|
-
"## Case Input",
|
|
913
|
-
"",
|
|
914
|
-
"| | Disease + | Disease - |",
|
|
915
|
-
"|----------------|-----------|-----------|",
|
|
916
|
-
`| Test + | ${displayValue(state.input.tp)} | ${displayValue(state.input.fp)} |`,
|
|
917
|
-
`| Test - | ${displayValue(state.input.fn)} | ${displayValue(state.input.tn)} |`,
|
|
918
|
-
"",
|
|
919
|
-
`Pre-test probability: ${displayValue(state.input.preTestProb)}%`,
|
|
920
|
-
"",
|
|
921
|
-
];
|
|
922
|
-
|
|
923
|
-
if (!validation.valid || !metrics) {
|
|
924
|
-
lines.push("## Validation");
|
|
925
|
-
lines.push("");
|
|
926
|
-
lines.push(validation.message);
|
|
927
|
-
lines.push("");
|
|
928
|
-
return `${lines.join("\n")}`;
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
lines.push(...buildMarkdownMetricSections(metrics));
|
|
932
|
-
return `${lines.join("\n")}`;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
function buildPlainMetricSections(metrics) {
|
|
936
|
-
return [
|
|
937
|
-
"Performance Measures",
|
|
938
|
-
` Sensitivity: ${core.formatValue(metrics.sensitivity.value)} (${renderConfidence(metrics.sensitivity)})`,
|
|
939
|
-
` Specificity: ${core.formatValue(metrics.specificity.value)} (${renderConfidence(metrics.specificity)})`,
|
|
940
|
-
` PPV: ${core.formatValue(metrics.ppv.value)} (${renderConfidence(metrics.ppv)})`,
|
|
941
|
-
` NPV: ${core.formatValue(metrics.npv.value)} (${renderConfidence(metrics.npv)})`,
|
|
942
|
-
"",
|
|
943
|
-
"Bayesian Update",
|
|
944
|
-
` LR+: ${core.formatValue(metrics.lrPositive.value, metrics.lrPositive.formatter)}`,
|
|
945
|
-
` LR-: ${core.formatValue(metrics.lrNegative.value, metrics.lrNegative.formatter)}`,
|
|
946
|
-
` Post-test probability (+): ${core.formatValue(metrics.postTestPositive.value)}`,
|
|
947
|
-
` Post-test probability (-): ${core.formatValue(metrics.postTestNegative.value)}`,
|
|
948
|
-
"",
|
|
949
|
-
"Interpretation",
|
|
950
|
-
` Sensitivity: ${metrics.sensitivity.note}`,
|
|
951
|
-
` Specificity: ${metrics.specificity.note}`,
|
|
952
|
-
` LR+: ${metrics.lrPositive.note}`,
|
|
953
|
-
` LR-: ${metrics.lrNegative.note}`,
|
|
954
|
-
];
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
function buildMarkdownMetricSections(metrics) {
|
|
958
|
-
return [
|
|
959
|
-
"## Performance Measures",
|
|
960
|
-
"",
|
|
961
|
-
"| Metric | Value | Confidence interval |",
|
|
962
|
-
"|--------|-------|---------------------|",
|
|
963
|
-
`| Sensitivity | ${core.formatValue(metrics.sensitivity.value)} | ${renderConfidence(metrics.sensitivity)} |`,
|
|
964
|
-
`| Specificity | ${core.formatValue(metrics.specificity.value)} | ${renderConfidence(metrics.specificity)} |`,
|
|
965
|
-
`| PPV | ${core.formatValue(metrics.ppv.value)} | ${renderConfidence(metrics.ppv)} |`,
|
|
966
|
-
`| NPV | ${core.formatValue(metrics.npv.value)} | ${renderConfidence(metrics.npv)} |`,
|
|
967
|
-
"",
|
|
968
|
-
"## Bayesian Update",
|
|
969
|
-
"",
|
|
970
|
-
`- LR+: ${core.formatValue(metrics.lrPositive.value, metrics.lrPositive.formatter)}`,
|
|
971
|
-
`- LR-: ${core.formatValue(metrics.lrNegative.value, metrics.lrNegative.formatter)}`,
|
|
972
|
-
`- Pre-test probability: ${core.formatValue(metrics.preTestProbability.value)}`,
|
|
973
|
-
`- Post-test probability (+): ${core.formatValue(metrics.postTestPositive.value)}`,
|
|
974
|
-
`- Post-test probability (-): ${core.formatValue(metrics.postTestNegative.value)}`,
|
|
975
|
-
"",
|
|
976
|
-
"## Interpretation",
|
|
977
|
-
"",
|
|
978
|
-
`- Sensitivity: ${metrics.sensitivity.note}`,
|
|
979
|
-
`- Specificity: ${metrics.specificity.note}`,
|
|
980
|
-
`- LR+: ${metrics.lrPositive.note}`,
|
|
981
|
-
`- LR-: ${metrics.lrNegative.note}`,
|
|
982
|
-
"",
|
|
983
|
-
];
|
|
984
|
-
}
|
|
985
|
-
|
|
986
796
|
module.exports = {
|
|
987
797
|
runTui,
|
|
988
798
|
};
|
package/tui/input.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
function sanitiseRawInput(fieldKey, rawValue) {
|
|
2
|
+
if (typeof rawValue !== "string") {
|
|
3
|
+
return "";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// Preserve complete input for strict parsing. Never turn -5 into 5 or 1.5 into 15.
|
|
7
|
+
return rawValue.trim();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isAcceptedCharacter(fieldKey, character, current) {
|
|
11
|
+
if (fieldKey === "preTestProb") {
|
|
12
|
+
if (/^[0-9]$/.test(character)) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
if ((character === "." || character === ",") && !current.includes(".") && !current.includes(",")) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return /^[0-9]$/.test(character);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = { sanitiseRawInput, isAcceptedCharacter };
|