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/LICENSE +21 -0
- package/README.md +160 -0
- package/bin/diagcalc.js +224 -0
- package/index.html +266 -0
- package/lib/diagcalc-core.js +305 -0
- package/lib/diagcalc-datasets.js +174 -0
- package/package.json +40 -0
- package/script.js +497 -0
- package/styles.css +789 -0
- package/tui/index.js +988 -0
package/tui/index.js
ADDED
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const blessed = require("blessed");
|
|
4
|
+
const core = require("../lib/diagcalc-core");
|
|
5
|
+
const datasetStore = require("../lib/diagcalc-datasets");
|
|
6
|
+
|
|
7
|
+
function renderResults(metrics) {
|
|
8
|
+
const performance = [
|
|
9
|
+
renderMetricLine(metrics.sensitivity, "sensitivity"),
|
|
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
|
+
}
|
|
131
|
+
|
|
132
|
+
function runTui() {
|
|
133
|
+
const datasets = [
|
|
134
|
+
{
|
|
135
|
+
key: "ad_hoc",
|
|
136
|
+
name: "Ad hoc case",
|
|
137
|
+
description: "Start from a blank custom case and enter your own confusion matrix values.",
|
|
138
|
+
reference: null,
|
|
139
|
+
isCustom: true,
|
|
140
|
+
},
|
|
141
|
+
...datasetStore.listDatasets(),
|
|
142
|
+
];
|
|
143
|
+
const screen = blessed.screen({
|
|
144
|
+
smartCSR: true,
|
|
145
|
+
title: "DIAGCALC TUI",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const prompt = blessed.prompt({
|
|
149
|
+
parent: screen,
|
|
150
|
+
border: "line",
|
|
151
|
+
height: 8,
|
|
152
|
+
width: "50%",
|
|
153
|
+
top: "center",
|
|
154
|
+
left: "center",
|
|
155
|
+
label: " Edit value ",
|
|
156
|
+
tags: true,
|
|
157
|
+
keys: true,
|
|
158
|
+
vi: true,
|
|
159
|
+
hidden: true,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const message = blessed.message({
|
|
163
|
+
parent: screen,
|
|
164
|
+
border: "line",
|
|
165
|
+
height: 8,
|
|
166
|
+
width: "60%",
|
|
167
|
+
top: "center",
|
|
168
|
+
left: "center",
|
|
169
|
+
label: " Export ",
|
|
170
|
+
tags: true,
|
|
171
|
+
keys: true,
|
|
172
|
+
vi: true,
|
|
173
|
+
hidden: true,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const outer = blessed.box({
|
|
177
|
+
parent: screen,
|
|
178
|
+
width: "100%",
|
|
179
|
+
height: "100%",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
blessed.box({
|
|
183
|
+
parent: outer,
|
|
184
|
+
top: 0,
|
|
185
|
+
left: 0,
|
|
186
|
+
width: "100%",
|
|
187
|
+
height: 3,
|
|
188
|
+
tags: true,
|
|
189
|
+
border: "line",
|
|
190
|
+
content: "{bold}DIAGCALC{/bold} Arrows move Enter edit/load n new ad hoc case x export txt m export md Tab/Ctrl-N next panel Shift-Tab/Ctrl-P previous r reset q quit",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const datasetList = blessed.list({
|
|
194
|
+
parent: outer,
|
|
195
|
+
top: 3,
|
|
196
|
+
left: 0,
|
|
197
|
+
width: "26%",
|
|
198
|
+
bottom: 0,
|
|
199
|
+
label: " Datasets ",
|
|
200
|
+
border: "line",
|
|
201
|
+
keys: true,
|
|
202
|
+
vi: true,
|
|
203
|
+
mouse: true,
|
|
204
|
+
style: {
|
|
205
|
+
selected: {
|
|
206
|
+
bg: "blue",
|
|
207
|
+
},
|
|
208
|
+
border: {
|
|
209
|
+
fg: "white",
|
|
210
|
+
},
|
|
211
|
+
focus: {
|
|
212
|
+
border: {
|
|
213
|
+
fg: "blue",
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
items: datasets.map((dataset) => dataset.isCustom ? `* ${dataset.name}` : `${dataset.key} ${dataset.name}`),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const datasetInfo = blessed.box({
|
|
221
|
+
parent: outer,
|
|
222
|
+
top: 3,
|
|
223
|
+
left: "26%",
|
|
224
|
+
width: "28%",
|
|
225
|
+
height: 8,
|
|
226
|
+
label: " Scenario ",
|
|
227
|
+
border: "line",
|
|
228
|
+
tags: true,
|
|
229
|
+
scrollable: true,
|
|
230
|
+
alwaysScroll: true,
|
|
231
|
+
content: "Select a dataset on the left or edit the fields manually.",
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const inputForm = blessed.box({
|
|
235
|
+
parent: outer,
|
|
236
|
+
top: 11,
|
|
237
|
+
left: "26%",
|
|
238
|
+
width: "28%",
|
|
239
|
+
bottom: 14,
|
|
240
|
+
label: " Inputs ",
|
|
241
|
+
border: "line",
|
|
242
|
+
keys: true,
|
|
243
|
+
vi: true,
|
|
244
|
+
mouse: true,
|
|
245
|
+
tags: true,
|
|
246
|
+
style: {
|
|
247
|
+
border: {
|
|
248
|
+
fg: "white",
|
|
249
|
+
},
|
|
250
|
+
focus: {
|
|
251
|
+
border: {
|
|
252
|
+
fg: "blue",
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const resultsBox = blessed.box({
|
|
259
|
+
parent: outer,
|
|
260
|
+
top: 3,
|
|
261
|
+
left: "54%",
|
|
262
|
+
width: "46%",
|
|
263
|
+
bottom: 0,
|
|
264
|
+
label: " Results ",
|
|
265
|
+
border: "line",
|
|
266
|
+
keys: true,
|
|
267
|
+
vi: true,
|
|
268
|
+
mouse: true,
|
|
269
|
+
scrollable: true,
|
|
270
|
+
alwaysScroll: true,
|
|
271
|
+
tags: true,
|
|
272
|
+
content: "Press Enter on a dataset or input row.",
|
|
273
|
+
style: {
|
|
274
|
+
border: {
|
|
275
|
+
fg: "white",
|
|
276
|
+
},
|
|
277
|
+
focus: {
|
|
278
|
+
border: {
|
|
279
|
+
fg: "blue",
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const feedbackBox = blessed.box({
|
|
286
|
+
parent: outer,
|
|
287
|
+
bottom: 8,
|
|
288
|
+
left: "26%",
|
|
289
|
+
width: "28%",
|
|
290
|
+
height: 6,
|
|
291
|
+
label: " Feedback ",
|
|
292
|
+
border: "line",
|
|
293
|
+
content: "Ready.",
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
blessed.box({
|
|
297
|
+
parent: outer,
|
|
298
|
+
bottom: 0,
|
|
299
|
+
left: "26%",
|
|
300
|
+
width: "28%",
|
|
301
|
+
height: 8,
|
|
302
|
+
label: " Shortcuts ",
|
|
303
|
+
border: "line",
|
|
304
|
+
content: [
|
|
305
|
+
"Enter edit/load",
|
|
306
|
+
"n ad hoc case",
|
|
307
|
+
"x export .txt",
|
|
308
|
+
"m export .md",
|
|
309
|
+
"Tab next panel",
|
|
310
|
+
"Ctrl-N next panel",
|
|
311
|
+
"Ctrl-P previous panel",
|
|
312
|
+
"Arrows move/select",
|
|
313
|
+
"r reset",
|
|
314
|
+
"q quit",
|
|
315
|
+
].join("\n"),
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const fields = [
|
|
319
|
+
{ key: "tp", label: "True positives", parser: core.safeParseInt },
|
|
320
|
+
{ key: "fp", label: "False positives", parser: core.safeParseInt },
|
|
321
|
+
{ key: "fn", label: "False negatives", parser: core.safeParseInt },
|
|
322
|
+
{ key: "tn", label: "True negatives", parser: core.safeParseInt },
|
|
323
|
+
{
|
|
324
|
+
key: "preTestProb",
|
|
325
|
+
label: "Pre-test probability (%)",
|
|
326
|
+
parser: (value) => {
|
|
327
|
+
const normalised = core.normaliseDecimal(value);
|
|
328
|
+
return normalised === "" ? NaN : parseFloat(normalised);
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
const state = {
|
|
334
|
+
activeCaseLabel: "Ad hoc case",
|
|
335
|
+
activeDatasetKey: "ad_hoc",
|
|
336
|
+
selectedFieldIndex: 0,
|
|
337
|
+
modalOpen: false,
|
|
338
|
+
rawInput: {
|
|
339
|
+
tp: "",
|
|
340
|
+
fp: "",
|
|
341
|
+
fn: "",
|
|
342
|
+
tn: "",
|
|
343
|
+
preTestProb: "",
|
|
344
|
+
},
|
|
345
|
+
input: {
|
|
346
|
+
tp: NaN,
|
|
347
|
+
fp: NaN,
|
|
348
|
+
fn: NaN,
|
|
349
|
+
tn: NaN,
|
|
350
|
+
preTestProb: NaN,
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const panels = [datasetList, inputForm, resultsBox];
|
|
355
|
+
|
|
356
|
+
function currentFocusIndex() {
|
|
357
|
+
const index = panels.indexOf(screen.focused);
|
|
358
|
+
return index === -1 ? 0 : index;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function focusPanel(offset) {
|
|
362
|
+
if (state.modalOpen) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const nextIndex = (currentFocusIndex() + offset + panels.length) % panels.length;
|
|
367
|
+
panels[nextIndex].focus();
|
|
368
|
+
screen.render();
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function setFeedback(message) {
|
|
372
|
+
feedbackBox.setContent(message);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function syncParsedInputField(fieldKey) {
|
|
376
|
+
const field = fields.find((item) => item.key === fieldKey);
|
|
377
|
+
if (!field) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const rawValue = state.rawInput[fieldKey];
|
|
382
|
+
state.input[fieldKey] = rawValue === "" ? NaN : field.parser(rawValue);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function syncAllParsedInputs() {
|
|
386
|
+
fields.forEach((field) => {
|
|
387
|
+
syncParsedInputField(field.key);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function setFieldValue(fieldKey, rawValue) {
|
|
392
|
+
state.rawInput[fieldKey] = rawValue;
|
|
393
|
+
syncParsedInputField(fieldKey);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function resetFieldValues() {
|
|
397
|
+
fields.forEach((field) => {
|
|
398
|
+
state.rawInput[field.key] = "";
|
|
399
|
+
state.input[field.key] = NaN;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function runModal(task) {
|
|
404
|
+
state.modalOpen = true;
|
|
405
|
+
screen.saveFocus();
|
|
406
|
+
task(() => {
|
|
407
|
+
state.modalOpen = false;
|
|
408
|
+
screen.restoreFocus();
|
|
409
|
+
screen.render();
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function currentCaseSlug(extension) {
|
|
414
|
+
const base = state.activeCaseLabel
|
|
415
|
+
.toLowerCase()
|
|
416
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
417
|
+
.replace(/^-|-$/g, "") || "diagcalc-case";
|
|
418
|
+
return `${base}.${extension}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function setDatasetInfo(dataset) {
|
|
422
|
+
if (!dataset) {
|
|
423
|
+
datasetInfo.setContent("Select a dataset on the left or edit the fields manually.");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const reference = dataset.reference
|
|
428
|
+
? `\n\nReference: ${dataset.reference.authors} ${dataset.reference.title}. ${dataset.reference.journal}. DOI ${dataset.reference.doi}`
|
|
429
|
+
: dataset.isCustom
|
|
430
|
+
? "\n\nReference: user-defined case."
|
|
431
|
+
: "\n\nReference: generic teaching scenario.";
|
|
432
|
+
datasetInfo.setContent(`{bold}${dataset.name}{/bold}\n${dataset.description}${reference}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function moveFieldSelectionGrid(direction) {
|
|
436
|
+
const current = state.selectedFieldIndex;
|
|
437
|
+
|
|
438
|
+
if (current === 4) {
|
|
439
|
+
if (direction === "up") {
|
|
440
|
+
state.selectedFieldIndex = 2;
|
|
441
|
+
}
|
|
442
|
+
} else if (current === 0) {
|
|
443
|
+
if (direction === "right") {
|
|
444
|
+
state.selectedFieldIndex = 1;
|
|
445
|
+
} else if (direction === "down") {
|
|
446
|
+
state.selectedFieldIndex = 2;
|
|
447
|
+
}
|
|
448
|
+
} else if (current === 1) {
|
|
449
|
+
if (direction === "left") {
|
|
450
|
+
state.selectedFieldIndex = 0;
|
|
451
|
+
} else if (direction === "down") {
|
|
452
|
+
state.selectedFieldIndex = 3;
|
|
453
|
+
}
|
|
454
|
+
} else if (current === 2) {
|
|
455
|
+
if (direction === "up") {
|
|
456
|
+
state.selectedFieldIndex = 0;
|
|
457
|
+
} else if (direction === "right") {
|
|
458
|
+
state.selectedFieldIndex = 3;
|
|
459
|
+
} else if (direction === "down") {
|
|
460
|
+
state.selectedFieldIndex = 4;
|
|
461
|
+
}
|
|
462
|
+
} else if (current === 3) {
|
|
463
|
+
if (direction === "up") {
|
|
464
|
+
state.selectedFieldIndex = 1;
|
|
465
|
+
} else if (direction === "left") {
|
|
466
|
+
state.selectedFieldIndex = 2;
|
|
467
|
+
} else if (direction === "down") {
|
|
468
|
+
state.selectedFieldIndex = 4;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
updateInputForm();
|
|
473
|
+
screen.render();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function updateInputForm() {
|
|
477
|
+
const totals = buildTotals(state.input);
|
|
478
|
+
const tpDisplay = renderFieldCell(state, fields, 0);
|
|
479
|
+
const fpDisplay = renderFieldCell(state, fields, 1);
|
|
480
|
+
const fnDisplay = renderFieldCell(state, fields, 2);
|
|
481
|
+
const tnDisplay = renderFieldCell(state, fields, 3);
|
|
482
|
+
const preDisplay = renderFieldCell(state, fields, 4, 18);
|
|
483
|
+
const lines = [
|
|
484
|
+
"{bold}Confusion Matrix Editor{/bold}",
|
|
485
|
+
"",
|
|
486
|
+
" Disease + Disease -",
|
|
487
|
+
`Test + ${tpDisplay} ${fpDisplay}`,
|
|
488
|
+
`Test - ${fnDisplay} ${tnDisplay}`,
|
|
489
|
+
"",
|
|
490
|
+
`{bold}${fields[4].label}{/bold}`,
|
|
491
|
+
preDisplay,
|
|
492
|
+
"",
|
|
493
|
+
`{bold}Totals{/bold}`,
|
|
494
|
+
`Diseased: ${totals.diseased} Non-diseased: ${totals.nonDiseased}`,
|
|
495
|
+
`Study total: ${totals.total}`,
|
|
496
|
+
"",
|
|
497
|
+
"Use arrows to move between cells and type directly.",
|
|
498
|
+
];
|
|
499
|
+
|
|
500
|
+
inputForm.setLabel(` Inputs - ${state.activeCaseLabel} `);
|
|
501
|
+
inputForm.setContent(lines.join("\n"));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function calculate() {
|
|
505
|
+
const validation = core.validateInputs(state.input);
|
|
506
|
+
if (!validation.valid) {
|
|
507
|
+
resultsBox.setContent(buildPendingResults(state.activeCaseLabel, validation.message));
|
|
508
|
+
setFeedback(validation.message);
|
|
509
|
+
screen.render();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const metrics = core.calculateMetrics(state.input);
|
|
514
|
+
resultsBox.setLabel(` Results - ${state.activeCaseLabel} `);
|
|
515
|
+
resultsBox.setContent(buildResultSummary(state.activeCaseLabel, state.input, metrics));
|
|
516
|
+
resultsBox.setScrollPerc(0);
|
|
517
|
+
setFeedback("Calculation complete.");
|
|
518
|
+
screen.render();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function applyDataset(dataset) {
|
|
522
|
+
if (dataset.isCustom) {
|
|
523
|
+
startAdHocCase();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
state.activeCaseLabel = dataset.name;
|
|
528
|
+
state.activeDatasetKey = dataset.key;
|
|
529
|
+
state.selectedFieldIndex = 0;
|
|
530
|
+
setFieldValue("tp", String(dataset.tp));
|
|
531
|
+
setFieldValue("fp", String(dataset.fp));
|
|
532
|
+
setFieldValue("fn", String(dataset.fn));
|
|
533
|
+
setFieldValue("tn", String(dataset.tn));
|
|
534
|
+
setFieldValue("preTestProb", String(dataset.preTestProb));
|
|
535
|
+
setDatasetInfo(dataset);
|
|
536
|
+
updateInputForm();
|
|
537
|
+
setFeedback(`Loaded dataset: ${dataset.name}`);
|
|
538
|
+
calculate();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function resetForm() {
|
|
542
|
+
resetFieldValues();
|
|
543
|
+
state.activeCaseLabel = "Ad hoc case";
|
|
544
|
+
state.activeDatasetKey = "ad_hoc";
|
|
545
|
+
state.selectedFieldIndex = 0;
|
|
546
|
+
updateInputForm();
|
|
547
|
+
resultsBox.setLabel(` Results - ${state.activeCaseLabel} `);
|
|
548
|
+
resultsBox.setContent(buildPendingResults(state.activeCaseLabel, "Enter or load a case to calculate metrics."));
|
|
549
|
+
setFeedback("Fields cleared.");
|
|
550
|
+
setDatasetInfo(datasets[datasetList.selected] || datasets[0] || null);
|
|
551
|
+
screen.render();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function startAdHocCase() {
|
|
555
|
+
state.activeCaseLabel = "Ad hoc case";
|
|
556
|
+
state.activeDatasetKey = "ad_hoc";
|
|
557
|
+
state.selectedFieldIndex = 0;
|
|
558
|
+
resetFieldValues();
|
|
559
|
+
datasetList.select(0);
|
|
560
|
+
setDatasetInfo(datasets[0]);
|
|
561
|
+
updateInputForm();
|
|
562
|
+
resultsBox.setLabel(` Results - ${state.activeCaseLabel} `);
|
|
563
|
+
resultsBox.setContent(buildPendingResults(state.activeCaseLabel, "Fill in the confusion matrix and pre-test probability."));
|
|
564
|
+
setFeedback("Started a blank ad hoc case.");
|
|
565
|
+
inputForm.focus();
|
|
566
|
+
screen.render();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function editSelectedField() {
|
|
570
|
+
const selectedIndex = state.selectedFieldIndex;
|
|
571
|
+
const field = fields[selectedIndex];
|
|
572
|
+
if (!field) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const currentValue = state.rawInput[field.key];
|
|
577
|
+
runModal((done) => {
|
|
578
|
+
prompt.input(`Enter ${field.label.toLowerCase()}`, currentValue, (error, value) => {
|
|
579
|
+
if (!error && value !== null) {
|
|
580
|
+
setFieldValue(field.key, sanitiseRawInput(field.key, value));
|
|
581
|
+
if (state.activeDatasetKey !== "ad_hoc") {
|
|
582
|
+
state.activeCaseLabel = `Customised: ${state.activeCaseLabel}`;
|
|
583
|
+
state.activeDatasetKey = "ad_hoc";
|
|
584
|
+
}
|
|
585
|
+
updateInputForm();
|
|
586
|
+
state.selectedFieldIndex = selectedIndex;
|
|
587
|
+
inputForm.focus();
|
|
588
|
+
calculate();
|
|
589
|
+
} else {
|
|
590
|
+
inputForm.focus();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
done();
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function handleDirectFieldInput(character, key) {
|
|
599
|
+
if (state.modalOpen || screen.focused !== inputForm) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const field = fields[state.selectedFieldIndex];
|
|
604
|
+
if (!field) {
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const current = state.rawInput[field.key];
|
|
609
|
+
|
|
610
|
+
if (key && key.name === "backspace") {
|
|
611
|
+
setFieldValue(field.key, current.slice(0, -1));
|
|
612
|
+
} else if (key && (key.name === "delete" || key.full === "C-u")) {
|
|
613
|
+
setFieldValue(field.key, "");
|
|
614
|
+
} else if (typeof character === "string" && character !== "") {
|
|
615
|
+
const next = sanitiseRawInput(field.key, `${current}${character}`);
|
|
616
|
+
if (next === current && !isAcceptedCharacter(field.key, character, current)) {
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
setFieldValue(field.key, next);
|
|
620
|
+
} else {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (state.activeDatasetKey !== "ad_hoc") {
|
|
625
|
+
state.activeCaseLabel = `Customised: ${state.activeCaseLabel}`;
|
|
626
|
+
state.activeDatasetKey = "ad_hoc";
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
updateInputForm();
|
|
630
|
+
calculate();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function exportCurrentCase(format) {
|
|
634
|
+
const extension = format === "markdown" ? "md" : "txt";
|
|
635
|
+
const defaultPath = path.join(process.cwd(), currentCaseSlug(extension));
|
|
636
|
+
|
|
637
|
+
runModal((done) => {
|
|
638
|
+
prompt.input(`Export ${format} to path`, defaultPath, (error, outputPath) => {
|
|
639
|
+
if (!error && outputPath !== null) {
|
|
640
|
+
const trimmedPath = outputPath.trim();
|
|
641
|
+
if (!trimmedPath) {
|
|
642
|
+
setFeedback("Export cancelled: empty path.");
|
|
643
|
+
done();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const validation = core.validateInputs(state.input);
|
|
648
|
+
const metrics = validation.valid ? core.calculateMetrics(state.input) : null;
|
|
649
|
+
const content = format === "markdown"
|
|
650
|
+
? buildMarkdownExport(state, metrics, validation)
|
|
651
|
+
: buildTextExport(state, metrics, validation);
|
|
652
|
+
|
|
653
|
+
try {
|
|
654
|
+
fs.writeFileSync(trimmedPath, content, "utf8");
|
|
655
|
+
setFeedback(`Exported ${format} report to ${trimmedPath}`);
|
|
656
|
+
message.display(`{green-fg}Saved{/}\n${trimmedPath}`, 3, () => {
|
|
657
|
+
done();
|
|
658
|
+
});
|
|
659
|
+
return;
|
|
660
|
+
} catch (writeError) {
|
|
661
|
+
setFeedback(`Export failed: ${writeError.message}`);
|
|
662
|
+
message.display(`{red-fg}Export failed{/}\n${writeError.message}`, 4, () => {
|
|
663
|
+
done();
|
|
664
|
+
});
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
done();
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
datasetList.on("select", (_, index) => {
|
|
675
|
+
setDatasetInfo(datasets[index] || null);
|
|
676
|
+
screen.render();
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
datasetList.key(["enter", "space"], () => {
|
|
680
|
+
const dataset = datasets[datasetList.selected];
|
|
681
|
+
if (dataset) {
|
|
682
|
+
applyDataset(dataset);
|
|
683
|
+
inputForm.focus();
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
inputForm.key(["up", "k"], () => {
|
|
688
|
+
moveFieldSelectionGrid("up");
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
inputForm.key(["down", "j"], () => {
|
|
692
|
+
moveFieldSelectionGrid("down");
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
inputForm.key(["left", "h"], () => {
|
|
696
|
+
moveFieldSelectionGrid("left");
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
inputForm.key(["right", "l"], () => {
|
|
700
|
+
moveFieldSelectionGrid("right");
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
inputForm.key(["enter", "e"], () => {
|
|
704
|
+
editSelectedField();
|
|
705
|
+
});
|
|
706
|
+
inputForm.on("keypress", (character, key) => {
|
|
707
|
+
if (!key) {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (["up", "down", "left", "right", "k", "j", "h", "l", "enter", "tab"].includes(key.name) || ["C-n", "C-p"].includes(key.full)) {
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
handleDirectFieldInput(character, key);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
resultsBox.key(["up"], () => resultsBox.scroll(-1));
|
|
719
|
+
resultsBox.key(["down"], () => resultsBox.scroll(1));
|
|
720
|
+
resultsBox.key(["pageup"], () => resultsBox.scroll(-10));
|
|
721
|
+
resultsBox.key(["pagedown"], () => resultsBox.scroll(10));
|
|
722
|
+
|
|
723
|
+
screen.key(["q", "C-c"], () => {
|
|
724
|
+
if (!state.modalOpen) {
|
|
725
|
+
process.exit(0);
|
|
726
|
+
}
|
|
727
|
+
});
|
|
728
|
+
screen.key(["n"], () => {
|
|
729
|
+
if (!state.modalOpen) {
|
|
730
|
+
startAdHocCase();
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
screen.key(["r"], () => {
|
|
734
|
+
if (!state.modalOpen) {
|
|
735
|
+
resetForm();
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
screen.key(["x"], () => {
|
|
739
|
+
if (!state.modalOpen) {
|
|
740
|
+
exportCurrentCase("text");
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
screen.key(["m"], () => {
|
|
744
|
+
if (!state.modalOpen) {
|
|
745
|
+
exportCurrentCase("markdown");
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
screen.key(["tab", "C-n"], () => {
|
|
749
|
+
if (!state.modalOpen) {
|
|
750
|
+
focusPanel(1);
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
screen.key(["S-tab", "backtab", "C-p"], () => {
|
|
754
|
+
if (!state.modalOpen) {
|
|
755
|
+
focusPanel(-1);
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
datasetList.focus();
|
|
760
|
+
datasetList.select(0);
|
|
761
|
+
syncAllParsedInputs();
|
|
762
|
+
updateInputForm();
|
|
763
|
+
setDatasetInfo(datasets[0] || null);
|
|
764
|
+
resultsBox.setLabel(` Results - ${state.activeCaseLabel} `);
|
|
765
|
+
resultsBox.setContent(buildPendingResults(state.activeCaseLabel, "Choose a library case or start an ad hoc case."));
|
|
766
|
+
screen.render();
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function buildPendingResults(caseLabel, message) {
|
|
770
|
+
return [
|
|
771
|
+
buildStatusStrip({
|
|
772
|
+
caseLabel,
|
|
773
|
+
status: "INCOMPLETE",
|
|
774
|
+
statusColour: "yellow",
|
|
775
|
+
summary: message,
|
|
776
|
+
}),
|
|
777
|
+
"",
|
|
778
|
+
`{bold}${caseLabel}{/bold}`,
|
|
779
|
+
"",
|
|
780
|
+
message,
|
|
781
|
+
"",
|
|
782
|
+
"Use the dataset panel for library scenarios, or press {bold}n{/bold} for a blank ad hoc case.",
|
|
783
|
+
"Then edit TP, FP, FN, TN, and pre-test probability in the input panel.",
|
|
784
|
+
].join("\n");
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function buildResultSummary(caseLabel, input, metrics) {
|
|
788
|
+
const totals = buildTotals(input);
|
|
789
|
+
const caseSummary = [
|
|
790
|
+
buildStatusStrip({
|
|
791
|
+
caseLabel,
|
|
792
|
+
status: "VALID",
|
|
793
|
+
statusColour: "green",
|
|
794
|
+
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
|
+
}),
|
|
796
|
+
"",
|
|
797
|
+
"{bold}Case Overview{/bold}",
|
|
798
|
+
buildConfusionMatrixBox(input),
|
|
799
|
+
`Diseased ${totals.diseased} Non-diseased ${totals.nonDiseased} Total ${totals.total}`,
|
|
800
|
+
`Pre-test probability entered: ${core.formatPercentage(metrics.preTestProbability.value)}`,
|
|
801
|
+
"",
|
|
802
|
+
].join("\n");
|
|
803
|
+
|
|
804
|
+
return `${caseSummary}${renderResults(metrics)}`;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function buildTotals(input) {
|
|
808
|
+
const tp = Number.isFinite(input.tp) ? input.tp : 0;
|
|
809
|
+
const fp = Number.isFinite(input.fp) ? input.fp : 0;
|
|
810
|
+
const fn = Number.isFinite(input.fn) ? input.fn : 0;
|
|
811
|
+
const tn = Number.isFinite(input.tn) ? input.tn : 0;
|
|
812
|
+
|
|
813
|
+
return {
|
|
814
|
+
diseased: tp + fn,
|
|
815
|
+
nonDiseased: fp + tn,
|
|
816
|
+
total: tp + fp + fn + tn,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function displayValue(value) {
|
|
821
|
+
return Number.isFinite(value) ? String(value) : "-";
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function buildConfusionMatrixBox(input) {
|
|
825
|
+
const tp = displayValue(input.tp).padStart(5);
|
|
826
|
+
const fp = displayValue(input.fp).padStart(5);
|
|
827
|
+
const fn = displayValue(input.fn).padStart(5);
|
|
828
|
+
const tn = displayValue(input.tn).padStart(5);
|
|
829
|
+
|
|
830
|
+
return [
|
|
831
|
+
"{bold}Confusion matrix{/bold}",
|
|
832
|
+
"┌───────────────┬───────────┬───────────┐",
|
|
833
|
+
"│ │ Disease + │ Disease - │",
|
|
834
|
+
"├───────────────┼───────────┼───────────┤",
|
|
835
|
+
`│ Test + │ ${tp} │ ${fp} │`,
|
|
836
|
+
`│ Test - │ ${fn} │ ${tn} │`,
|
|
837
|
+
"└───────────────┴───────────┴───────────┘",
|
|
838
|
+
].join("\n");
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function renderFieldCell(state, fields, index, width = 10) {
|
|
842
|
+
const field = fields[index];
|
|
843
|
+
const rawValue = state.rawInput[field.key] === "" ? "-" : state.rawInput[field.key];
|
|
844
|
+
const padded = rawValue.padEnd(width);
|
|
845
|
+
|
|
846
|
+
if (state.selectedFieldIndex === index) {
|
|
847
|
+
return `{black-fg}{white-bg} ${padded} {/}`;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
return `{bold}${padded}{/bold}`;
|
|
851
|
+
}
|
|
852
|
+
|
|
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
|
+
function buildStatusStrip({ caseLabel, status, statusColour, summary }) {
|
|
885
|
+
return `{bold}${caseLabel}{/bold} {${statusColour}-fg}[${status}]{/} ${summary}`;
|
|
886
|
+
}
|
|
887
|
+
|
|
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
|
+
module.exports = {
|
|
987
|
+
runTui,
|
|
988
|
+
};
|