diagcalc 3.2.4 → 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 +44 -7
- package/bin/diagcalc.js +142 -41
- package/docs/PROVENANCE.md +17 -0
- package/index.html +407 -92
- 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 +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 +1232 -274
- package/styles.css +2186 -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 +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();
|
|
@@ -721,10 +643,12 @@ function runTui() {
|
|
|
721
643
|
resultsBox.key(["pagedown"], () => resultsBox.scroll(10));
|
|
722
644
|
|
|
723
645
|
screen.key(["C-c"], () => {
|
|
646
|
+
screen.destroy();
|
|
724
647
|
process.exit(0);
|
|
725
648
|
});
|
|
726
649
|
screen.key(["q"], () => {
|
|
727
650
|
if (!state.modalOpen) {
|
|
651
|
+
screen.destroy();
|
|
728
652
|
process.exit(0);
|
|
729
653
|
}
|
|
730
654
|
});
|
|
@@ -733,6 +657,11 @@ function runTui() {
|
|
|
733
657
|
startAdHocCase();
|
|
734
658
|
}
|
|
735
659
|
});
|
|
660
|
+
screen.key(["+"], () => {
|
|
661
|
+
if (!state.modalOpen) {
|
|
662
|
+
chainSecondTest();
|
|
663
|
+
}
|
|
664
|
+
});
|
|
736
665
|
screen.key(["r"], () => {
|
|
737
666
|
if (!state.modalOpen) {
|
|
738
667
|
resetForm();
|
|
@@ -787,8 +716,11 @@ function buildPendingResults(caseLabel, message) {
|
|
|
787
716
|
].join("\n");
|
|
788
717
|
}
|
|
789
718
|
|
|
790
|
-
function buildResultSummary(caseLabel, input, metrics) {
|
|
719
|
+
function buildResultSummary(caseLabel, input, metrics, warnings) {
|
|
791
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
|
+
: "";
|
|
792
724
|
const caseSummary = [
|
|
793
725
|
buildStatusStrip({
|
|
794
726
|
caseLabel,
|
|
@@ -796,7 +728,7 @@ function buildResultSummary(caseLabel, input, metrics) {
|
|
|
796
728
|
statusColour: "green",
|
|
797
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)}`,
|
|
798
730
|
}),
|
|
799
|
-
|
|
731
|
+
warningsBlock,
|
|
800
732
|
"{bold}Case Overview{/bold}",
|
|
801
733
|
buildConfusionMatrixBox(input),
|
|
802
734
|
`Diseased ${totals.diseased} Non-diseased ${totals.nonDiseased} Total ${totals.total}`,
|
|
@@ -857,139 +789,10 @@ function renderFieldCell(state, fields, index, width = 10) {
|
|
|
857
789
|
return `{bold}${padded}{/bold}`;
|
|
858
790
|
}
|
|
859
791
|
|
|
860
|
-
function sanitiseRawInput(fieldKey, rawValue) {
|
|
861
|
-
if (typeof rawValue !== "string") {
|
|
862
|
-
return "";
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
if (fieldKey === "preTestProb") {
|
|
866
|
-
let cleaned = rawValue.replace(/,/g, ".").replace(/[^0-9.]/g, "");
|
|
867
|
-
const firstDotIndex = cleaned.indexOf(".");
|
|
868
|
-
if (firstDotIndex !== -1) {
|
|
869
|
-
cleaned = `${cleaned.slice(0, firstDotIndex + 1)}${cleaned.slice(firstDotIndex + 1).replace(/\./g, "")}`;
|
|
870
|
-
}
|
|
871
|
-
return cleaned;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
return rawValue.replace(/\D/g, "");
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
function isAcceptedCharacter(fieldKey, character, current) {
|
|
878
|
-
if (fieldKey === "preTestProb") {
|
|
879
|
-
if (/^[0-9]$/.test(character)) {
|
|
880
|
-
return true;
|
|
881
|
-
}
|
|
882
|
-
if ((character === "." || character === ",") && !current.includes(".") && !current.includes(",")) {
|
|
883
|
-
return true;
|
|
884
|
-
}
|
|
885
|
-
return false;
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
return /^[0-9]$/.test(character);
|
|
889
|
-
}
|
|
890
|
-
|
|
891
792
|
function buildStatusStrip({ caseLabel, status, statusColour, summary }) {
|
|
892
793
|
return `{bold}${caseLabel}{/bold} {${statusColour}-fg}[${status}]{/} ${summary}`;
|
|
893
794
|
}
|
|
894
795
|
|
|
895
|
-
function buildTextExport(state, metrics, validation) {
|
|
896
|
-
const lines = [
|
|
897
|
-
`DIAGCALC - ${state.activeCaseLabel}`,
|
|
898
|
-
"",
|
|
899
|
-
"Confusion matrix",
|
|
900
|
-
` TP ${displayValue(state.input.tp)} FP ${displayValue(state.input.fp)}`,
|
|
901
|
-
` FN ${displayValue(state.input.fn)} TN ${displayValue(state.input.tn)}`,
|
|
902
|
-
` Pre-test probability ${formatPreTest(state.input.preTestProb)}`,
|
|
903
|
-
"",
|
|
904
|
-
];
|
|
905
|
-
|
|
906
|
-
if (!validation.valid || !metrics) {
|
|
907
|
-
lines.push(`Validation: ${validation.message}`);
|
|
908
|
-
return `${lines.join("\n")}\n`;
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
lines.push(...buildPlainMetricSections(metrics));
|
|
912
|
-
return `${lines.join("\n")}\n`;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
function buildMarkdownExport(state, metrics, validation) {
|
|
916
|
-
const lines = [
|
|
917
|
-
`# DIAGCALC - ${state.activeCaseLabel}`,
|
|
918
|
-
"",
|
|
919
|
-
"## Case Input",
|
|
920
|
-
"",
|
|
921
|
-
"| | Disease + | Disease - |",
|
|
922
|
-
"|----------------|-----------|-----------|",
|
|
923
|
-
`| Test + | ${displayValue(state.input.tp)} | ${displayValue(state.input.fp)} |`,
|
|
924
|
-
`| Test - | ${displayValue(state.input.fn)} | ${displayValue(state.input.tn)} |`,
|
|
925
|
-
"",
|
|
926
|
-
`Pre-test probability: ${formatPreTest(state.input.preTestProb)}`,
|
|
927
|
-
"",
|
|
928
|
-
];
|
|
929
|
-
|
|
930
|
-
if (!validation.valid || !metrics) {
|
|
931
|
-
lines.push("## Validation");
|
|
932
|
-
lines.push("");
|
|
933
|
-
lines.push(validation.message);
|
|
934
|
-
lines.push("");
|
|
935
|
-
return `${lines.join("\n")}`;
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
lines.push(...buildMarkdownMetricSections(metrics));
|
|
939
|
-
return `${lines.join("\n")}`;
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
function buildPlainMetricSections(metrics) {
|
|
943
|
-
return [
|
|
944
|
-
"Performance Measures",
|
|
945
|
-
` Sensitivity: ${core.formatValue(metrics.sensitivity.value)} (${renderConfidence(metrics.sensitivity)})`,
|
|
946
|
-
` Specificity: ${core.formatValue(metrics.specificity.value)} (${renderConfidence(metrics.specificity)})`,
|
|
947
|
-
` PPV: ${core.formatValue(metrics.ppv.value)} (${renderConfidence(metrics.ppv)})`,
|
|
948
|
-
` NPV: ${core.formatValue(metrics.npv.value)} (${renderConfidence(metrics.npv)})`,
|
|
949
|
-
"",
|
|
950
|
-
"Bayesian Update",
|
|
951
|
-
` LR+: ${core.formatValue(metrics.lrPositive.value, metrics.lrPositive.formatter)}`,
|
|
952
|
-
` LR-: ${core.formatValue(metrics.lrNegative.value, metrics.lrNegative.formatter)}`,
|
|
953
|
-
` Post-test probability (+): ${core.formatValue(metrics.postTestPositive.value)}`,
|
|
954
|
-
` Post-test probability (-): ${core.formatValue(metrics.postTestNegative.value)}`,
|
|
955
|
-
"",
|
|
956
|
-
"Interpretation",
|
|
957
|
-
` Sensitivity: ${metrics.sensitivity.note}`,
|
|
958
|
-
` Specificity: ${metrics.specificity.note}`,
|
|
959
|
-
` LR+: ${metrics.lrPositive.note}`,
|
|
960
|
-
` LR-: ${metrics.lrNegative.note}`,
|
|
961
|
-
];
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
function buildMarkdownMetricSections(metrics) {
|
|
965
|
-
return [
|
|
966
|
-
"## Performance Measures",
|
|
967
|
-
"",
|
|
968
|
-
"| Metric | Value | Confidence interval |",
|
|
969
|
-
"|--------|-------|---------------------|",
|
|
970
|
-
`| Sensitivity | ${core.formatValue(metrics.sensitivity.value)} | ${renderConfidence(metrics.sensitivity)} |`,
|
|
971
|
-
`| Specificity | ${core.formatValue(metrics.specificity.value)} | ${renderConfidence(metrics.specificity)} |`,
|
|
972
|
-
`| PPV | ${core.formatValue(metrics.ppv.value)} | ${renderConfidence(metrics.ppv)} |`,
|
|
973
|
-
`| NPV | ${core.formatValue(metrics.npv.value)} | ${renderConfidence(metrics.npv)} |`,
|
|
974
|
-
"",
|
|
975
|
-
"## Bayesian Update",
|
|
976
|
-
"",
|
|
977
|
-
`- LR+: ${core.formatValue(metrics.lrPositive.value, metrics.lrPositive.formatter)}`,
|
|
978
|
-
`- LR-: ${core.formatValue(metrics.lrNegative.value, metrics.lrNegative.formatter)}`,
|
|
979
|
-
`- Pre-test probability: ${core.formatValue(metrics.preTestProbability.value)}`,
|
|
980
|
-
`- Post-test probability (+): ${core.formatValue(metrics.postTestPositive.value)}`,
|
|
981
|
-
`- Post-test probability (-): ${core.formatValue(metrics.postTestNegative.value)}`,
|
|
982
|
-
"",
|
|
983
|
-
"## Interpretation",
|
|
984
|
-
"",
|
|
985
|
-
`- Sensitivity: ${metrics.sensitivity.note}`,
|
|
986
|
-
`- Specificity: ${metrics.specificity.note}`,
|
|
987
|
-
`- LR+: ${metrics.lrPositive.note}`,
|
|
988
|
-
`- LR-: ${metrics.lrNegative.note}`,
|
|
989
|
-
"",
|
|
990
|
-
];
|
|
991
|
-
}
|
|
992
|
-
|
|
993
796
|
module.exports = {
|
|
994
797
|
runTui,
|
|
995
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 };
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
const core = require("../lib/diagcalc-core");
|
|
2
|
+
|
|
3
|
+
function renderResults(metrics) {
|
|
4
|
+
const performance = [
|
|
5
|
+
renderMetricLine(metrics.sensitivity, "sensitivity"),
|
|
6
|
+
renderMetricLine(metrics.specificity, "specificity"),
|
|
7
|
+
renderMetricLine(metrics.ppv, "ppv"),
|
|
8
|
+
renderMetricLine(metrics.npv, "npv"),
|
|
9
|
+
renderMetricLine(metrics.dor, "dor"),
|
|
10
|
+
renderMetricLine(metrics.numberNeededToScreen, "nns"),
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const bayes = [
|
|
14
|
+
renderMetricLine(metrics.lrPositive, "lrPositive"),
|
|
15
|
+
renderMetricLine(metrics.lrNegative, "lrNegative"),
|
|
16
|
+
renderMetricLine(metrics.preTestProbability, "neutral"),
|
|
17
|
+
renderMetricLine(metrics.postTestPositive, "postPositive"),
|
|
18
|
+
renderMetricLine(metrics.postTestNegative, "postNegative"),
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const bars = [
|
|
22
|
+
`Pre-test [${core.buildProbabilityBar(metrics.preTestProbability.value, 24)}] ${core.formatPercentage(metrics.preTestProbability.value)}`,
|
|
23
|
+
`Post-test (+) [${core.buildProbabilityBar(metrics.postTestPositive.value, 24)}] ${core.formatPercentage(metrics.postTestPositive.value)}`,
|
|
24
|
+
`Post-test (-) [${core.buildProbabilityBar(metrics.postTestNegative.value, 24)}] ${core.formatPercentage(metrics.postTestNegative.value)}`,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
return [
|
|
28
|
+
"{bold}Performance Measures{/bold}",
|
|
29
|
+
performance.join("\n"),
|
|
30
|
+
"",
|
|
31
|
+
"{bold}Bayesian Update{/bold}",
|
|
32
|
+
bayes.join("\n"),
|
|
33
|
+
"",
|
|
34
|
+
"{bold}Probability Shift{/bold}",
|
|
35
|
+
bars.join("\n"),
|
|
36
|
+
"",
|
|
37
|
+
"{bold}Interpretation{/bold}",
|
|
38
|
+
`Sensitivity ${metrics.sensitivity.note}`,
|
|
39
|
+
`Specificity ${metrics.specificity.note}`,
|
|
40
|
+
`LR+ ${metrics.lrPositive.note}`,
|
|
41
|
+
`LR- ${metrics.lrNegative.note}`,
|
|
42
|
+
].join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderMetricLine(metric, kind) {
|
|
46
|
+
const value = core.formatValue(metric.value, metric.formatter);
|
|
47
|
+
const valueTag = colourValue(value, kind, metric.value);
|
|
48
|
+
const confidence = renderConfidence(metric);
|
|
49
|
+
const confidenceText = confidence ? ` {gray-fg}${confidence}{/}` : "";
|
|
50
|
+
return `${metric.label.padEnd(36)} ${valueTag}${confidenceText}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderConfidence(metric) {
|
|
54
|
+
if (!metric.ci) {
|
|
55
|
+
return "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const lo = core.formatValue(metric.ci.lower, metric.formatter);
|
|
59
|
+
const hi = core.formatValue(metric.ci.upper, metric.formatter);
|
|
60
|
+
return `95% CI ${lo} to ${hi}`;
|
|
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
|
+
// Pequod crew accents — dark variants (TUI assumes dark terminal background)
|
|
69
|
+
const CREW = {
|
|
70
|
+
ahab: "#E3877C", // red — WEAK / LOW / HIGH
|
|
71
|
+
starbuck: "#A6DFFF", // cyan — INFO
|
|
72
|
+
queequeg: "#838CCF", // blue — primary / selection / focus
|
|
73
|
+
pip: "#DEC577", // gold — MOD / USEFUL / LIMITED / MID
|
|
74
|
+
ishmael: "#BFBBB6", // warm gray — borders / muted
|
|
75
|
+
stubb: "#FFD9BB", // warm orange — LR+ accent
|
|
76
|
+
tashtego: "#82C4A2", // green — STRONG / RULE-IN / RULE-OUT / HIGH(post+) / LOW(post-)
|
|
77
|
+
daggoo: "#A17069", // brown — EXTREME (infinite values) / second-stage trace
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function metricTone(kind, value) {
|
|
81
|
+
if (Number.isNaN(value)) return { colour: CREW.ishmael, badge: "UNDEFINED" };
|
|
82
|
+
if (!Number.isFinite(value)) {
|
|
83
|
+
return { colour: CREW.daggoo, badge: "EXTREME" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (kind === "sensitivity" || kind === "specificity" || kind === "ppv" || kind === "npv") {
|
|
87
|
+
if (value >= 0.9) {
|
|
88
|
+
return { colour: CREW.tashtego, badge: "STRONG" };
|
|
89
|
+
}
|
|
90
|
+
if (value >= 0.7) {
|
|
91
|
+
return { colour: CREW.pip, badge: "MOD" };
|
|
92
|
+
}
|
|
93
|
+
return { colour: CREW.ahab, badge: "WEAK" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (kind === "lrPositive") {
|
|
97
|
+
if (value >= 10) {
|
|
98
|
+
return { colour: CREW.tashtego, badge: "RULE-IN" };
|
|
99
|
+
}
|
|
100
|
+
if (value >= 5) {
|
|
101
|
+
return { colour: CREW.pip, badge: "USEFUL" };
|
|
102
|
+
}
|
|
103
|
+
if (value >= 2) {
|
|
104
|
+
return { colour: CREW.pip, badge: "LIMITED" };
|
|
105
|
+
}
|
|
106
|
+
return { colour: CREW.ahab, badge: "WEAK" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (kind === "dor") {
|
|
110
|
+
if (value >= 100) {
|
|
111
|
+
return { colour: CREW.tashtego, badge: "VERY STRONG" };
|
|
112
|
+
}
|
|
113
|
+
if (value >= 10) {
|
|
114
|
+
return { colour: CREW.tashtego, badge: "USEFUL" };
|
|
115
|
+
}
|
|
116
|
+
if (value > 1) {
|
|
117
|
+
return { colour: CREW.pip, badge: "LIMITED" };
|
|
118
|
+
}
|
|
119
|
+
return { colour: CREW.ahab, badge: "POOR" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (kind === "nns") {
|
|
123
|
+
if (value < 10) {
|
|
124
|
+
return { colour: CREW.tashtego, badge: "EFFICIENT" };
|
|
125
|
+
}
|
|
126
|
+
if (value < 100) {
|
|
127
|
+
return { colour: CREW.pip, badge: "MODERATE" };
|
|
128
|
+
}
|
|
129
|
+
return { colour: CREW.ahab, badge: "LOW YIELD" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (kind === "lrNegative") {
|
|
133
|
+
if (value <= 0.1) {
|
|
134
|
+
return { colour: CREW.tashtego, badge: "RULE-OUT" };
|
|
135
|
+
}
|
|
136
|
+
if (value <= 0.2) {
|
|
137
|
+
return { colour: CREW.pip, badge: "USEFUL" };
|
|
138
|
+
}
|
|
139
|
+
if (value <= 0.5) {
|
|
140
|
+
return { colour: CREW.pip, badge: "LIMITED" };
|
|
141
|
+
}
|
|
142
|
+
return { colour: CREW.ahab, badge: "WEAK" };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (kind === "postPositive") {
|
|
146
|
+
if (value >= 0.8) {
|
|
147
|
+
return { colour: CREW.tashtego, badge: "HIGH" };
|
|
148
|
+
}
|
|
149
|
+
if (value >= 0.4) {
|
|
150
|
+
return { colour: CREW.pip, badge: "MID" };
|
|
151
|
+
}
|
|
152
|
+
return { colour: CREW.ahab, badge: "LOW" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (kind === "postNegative") {
|
|
156
|
+
if (value <= 0.1) {
|
|
157
|
+
return { colour: CREW.tashtego, badge: "LOW" };
|
|
158
|
+
}
|
|
159
|
+
if (value <= 0.3) {
|
|
160
|
+
return { colour: CREW.pip, badge: "MID" };
|
|
161
|
+
}
|
|
162
|
+
return { colour: CREW.ahab, badge: "HIGH" };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { colour: CREW.starbuck, badge: "INFO" };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { renderResults, renderConfidence, CREW };
|