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.
@@ -0,0 +1,305 @@
1
+ (function (root, factory) {
2
+ if (typeof module === "object" && module.exports) {
3
+ module.exports = factory();
4
+ return;
5
+ }
6
+
7
+ root.DiagcalcCore = factory();
8
+ }(typeof globalThis !== "undefined" ? globalThis : this, () => {
9
+ function validateInputs({ tp, fp, fn, tn, preTestProb }) {
10
+ const inputs = [tp, fp, fn, tn];
11
+ if (inputs.some((value) => Number.isNaN(value) || value < 0)) {
12
+ return { valid: false, message: "Enter non-negative integers for all confusion matrix cells." };
13
+ }
14
+
15
+ if (inputs.some((value) => !Number.isInteger(value))) {
16
+ return { valid: false, message: "Use whole numbers for TP, FP, FN, and TN." };
17
+ }
18
+
19
+ if (Number.isNaN(preTestProb)) {
20
+ return { valid: false, message: "Enter a valid pre-test probability." };
21
+ }
22
+
23
+ if (preTestProb < 0 || preTestProb >= 100) {
24
+ return { valid: false, message: "Pre-test probability must be between 0 and 99.9%." };
25
+ }
26
+
27
+ if (tp + fn === 0) {
28
+ return { valid: false, message: "Sensitivity is indeterminate: add cases with disease (TP or FN)." };
29
+ }
30
+
31
+ if (tn + fp === 0) {
32
+ return { valid: false, message: "Specificity is indeterminate: add cases without disease (TN or FP)." };
33
+ }
34
+
35
+ if (tp + fp === 0) {
36
+ return { valid: false, message: "PPV is indeterminate: at least one positive test result is required." };
37
+ }
38
+
39
+ if (tn + fn === 0) {
40
+ return { valid: false, message: "NPV is indeterminate: at least one negative test result is required." };
41
+ }
42
+
43
+ return { valid: true };
44
+ }
45
+
46
+ function calculateMetrics({ tp, fp, fn, tn, preTestProb }) {
47
+ const totals = {
48
+ diseased: tp + fn,
49
+ nonDiseased: tn + fp,
50
+ positives: tp + fp,
51
+ negatives: tn + fn,
52
+ };
53
+
54
+ const preTest = preTestProb / 100;
55
+ const sensitivity = tp / totals.diseased;
56
+ const specificity = tn / totals.nonDiseased;
57
+ const ppv = tp / totals.positives;
58
+ const npv = tn / totals.negatives;
59
+ const lrPositive = calculateRatio(sensitivity, 1 - specificity);
60
+ const lrNegative = calculateRatio(1 - sensitivity, specificity);
61
+ const preTestOdds = calculateOdds(preTest);
62
+ const postTestOddsPositive = multiplyOdds(preTestOdds, lrPositive);
63
+ const postTestOddsNegative = multiplyOdds(preTestOdds, lrNegative);
64
+ const postTestProbPositive = probabilityFromOdds(postTestOddsPositive);
65
+ const postTestProbNegative = probabilityFromOdds(postTestOddsNegative);
66
+
67
+ return {
68
+ sensitivity: {
69
+ label: "Sensitivity",
70
+ value: sensitivity,
71
+ ci: calcWilsonInterval(tp, totals.diseased),
72
+ note: buildSensitivityNote(sensitivity),
73
+ },
74
+ specificity: {
75
+ label: "Specificity",
76
+ value: specificity,
77
+ ci: calcWilsonInterval(tn, totals.nonDiseased),
78
+ note: buildSpecificityNote(specificity),
79
+ },
80
+ ppv: {
81
+ label: "Positive predictive value (PPV)",
82
+ value: ppv,
83
+ ci: calcWilsonInterval(tp, totals.positives),
84
+ note: "Probability of disease given a positive result.",
85
+ },
86
+ npv: {
87
+ label: "Negative predictive value (NPV)",
88
+ value: npv,
89
+ ci: calcWilsonInterval(tn, totals.negatives),
90
+ note: "Probability of no disease given a negative result.",
91
+ },
92
+ lrPositive: {
93
+ label: "Positive likelihood ratio (LR+)",
94
+ value: lrPositive,
95
+ formatter: formatLikelihood,
96
+ note: interpretLRPositive(lrPositive),
97
+ },
98
+ lrNegative: {
99
+ label: "Negative likelihood ratio (LR-)",
100
+ value: lrNegative,
101
+ formatter: formatLikelihood,
102
+ note: interpretLRNegative(lrNegative),
103
+ },
104
+ preTestProbability: {
105
+ label: "Pre-test probability",
106
+ value: preTest,
107
+ note: "Estimated starting point before the test result.",
108
+ },
109
+ postTestPositive: {
110
+ label: "Post-test probability (positive result)",
111
+ value: postTestProbPositive,
112
+ note: "Updated probability of disease after a positive test.",
113
+ },
114
+ postTestNegative: {
115
+ label: "Post-test probability (negative result)",
116
+ value: postTestProbNegative,
117
+ note: "Updated probability of disease after a negative test.",
118
+ },
119
+ };
120
+ }
121
+
122
+ function calcWilsonInterval(successes, total) {
123
+ if (total === 0) {
124
+ return null;
125
+ }
126
+
127
+ const z = 1.96;
128
+ const phat = successes / total;
129
+ const denominator = 1 + (z ** 2) / total;
130
+ const center = phat + (z ** 2) / (2 * total);
131
+ const margin = z * Math.sqrt((phat * (1 - phat) + (z ** 2) / (4 * total)) / total);
132
+ const lower = Math.max(0, (center - margin) / denominator);
133
+ const upper = Math.min(1, (center + margin) / denominator);
134
+ return { lower, upper };
135
+ }
136
+
137
+ function calculateRatio(numerator, denominator) {
138
+ if (denominator === 0) {
139
+ return Infinity;
140
+ }
141
+ if (numerator === 0) {
142
+ return 0;
143
+ }
144
+ return numerator / denominator;
145
+ }
146
+
147
+ function calculateOdds(probability) {
148
+ if (probability === 1) {
149
+ return Infinity;
150
+ }
151
+ if (probability === 0) {
152
+ return 0;
153
+ }
154
+ return probability / (1 - probability);
155
+ }
156
+
157
+ function multiplyOdds(odds, ratio) {
158
+ if (odds === 0 || ratio === 0) {
159
+ return 0;
160
+ }
161
+ if (!Number.isFinite(odds) || !Number.isFinite(ratio)) {
162
+ return Infinity;
163
+ }
164
+ return odds * ratio;
165
+ }
166
+
167
+ function probabilityFromOdds(odds) {
168
+ if (odds === Infinity) {
169
+ return 1;
170
+ }
171
+ if (odds === 0) {
172
+ return 0;
173
+ }
174
+ return odds / (1 + odds);
175
+ }
176
+
177
+ function formatValue(value, customFormatter) {
178
+ if (typeof customFormatter === "function") {
179
+ return customFormatter(value);
180
+ }
181
+ return formatPercentage(value);
182
+ }
183
+
184
+ function formatPercentage(value) {
185
+ if (!Number.isFinite(value)) {
186
+ return "—";
187
+ }
188
+ return `${(value * 100).toFixed(1)}%`;
189
+ }
190
+
191
+ function normaliseDecimal(value) {
192
+ if (typeof value !== "string") {
193
+ return "";
194
+ }
195
+ return value.replace(",", ".").trim();
196
+ }
197
+
198
+ function safeParseInt(value) {
199
+ if (typeof value !== "string") {
200
+ return NaN;
201
+ }
202
+ const trimmed = value.trim();
203
+ if (trimmed === "") {
204
+ return NaN;
205
+ }
206
+ if (!/^\d+$/.test(trimmed)) {
207
+ return NaN;
208
+ }
209
+ return parseInt(trimmed, 10);
210
+ }
211
+
212
+ function formatLikelihood(value) {
213
+ if (!Number.isFinite(value)) {
214
+ return "∞";
215
+ }
216
+ if (value === 0) {
217
+ return "0";
218
+ }
219
+ return value >= 10 ? value.toFixed(1) : value.toFixed(2);
220
+ }
221
+
222
+ function clamp(value, min, max) {
223
+ return Math.min(Math.max(value, min), max);
224
+ }
225
+
226
+ function buildSensitivityNote(value) {
227
+ if (value >= 0.9) {
228
+ return "Captures most cases with disease. Useful for screening.";
229
+ }
230
+ if (value >= 0.7) {
231
+ return "Moderate sensitivity: consider alongside clinical context.";
232
+ }
233
+ return "Low sensitivity: consider additional tests to reduce false negatives.";
234
+ }
235
+
236
+ function buildSpecificityNote(value) {
237
+ if (value >= 0.9) {
238
+ return "Few false positives. Suitable for confirming diagnoses.";
239
+ }
240
+ if (value >= 0.7) {
241
+ return "Moderate specificity: confirm with other laboratory or clinical data.";
242
+ }
243
+ return "Low specificity: beware of false positives and their impact on treatment decisions.";
244
+ }
245
+
246
+ function interpretLRPositive(value) {
247
+ if (!Number.isFinite(value)) {
248
+ return "LR+ is very high: a positive result virtually confirms the disease.";
249
+ }
250
+ if (value >= 10) {
251
+ return "LR+ >= 10 indicates strong evidence in favour of disease.";
252
+ }
253
+ if (value >= 5) {
254
+ return "Moderate LR+: substantially increases the probability of disease.";
255
+ }
256
+ if (value >= 2) {
257
+ return "Low LR+: limited gain; combine with other data.";
258
+ }
259
+ return "LR+ close to 1: a positive result barely changes the probability of disease.";
260
+ }
261
+
262
+ function interpretLRNegative(value) {
263
+ if (!Number.isFinite(value)) {
264
+ return "LR- is infinite: a negative result does not reduce the probability of disease.";
265
+ }
266
+ if (value <= 0.1) {
267
+ return "LR- <= 0.1 indicates strong evidence against disease.";
268
+ }
269
+ if (value <= 0.2) {
270
+ return "Moderate LR-: reduces the probability of disease.";
271
+ }
272
+ if (value <= 0.5) {
273
+ return "Low LR-: limited impact; consider further evaluation.";
274
+ }
275
+ return "LR- close to 1: a negative result does not rule out disease.";
276
+ }
277
+
278
+ function buildProbabilityBar(value, width) {
279
+ const safeWidth = Number.isInteger(width) && width > 0 ? width : 20;
280
+ const safeValue = Number.isFinite(value) ? clamp(value, 0, 1) : 0;
281
+ const filled = Math.round(safeValue * safeWidth);
282
+ return `${"#".repeat(filled)}${"-".repeat(safeWidth - filled)}`;
283
+ }
284
+
285
+ return {
286
+ buildProbabilityBar,
287
+ buildSensitivityNote,
288
+ buildSpecificityNote,
289
+ calcWilsonInterval,
290
+ calculateMetrics,
291
+ calculateOdds,
292
+ calculateRatio,
293
+ clamp,
294
+ formatLikelihood,
295
+ formatPercentage,
296
+ formatValue,
297
+ interpretLRNegative,
298
+ interpretLRPositive,
299
+ multiplyOdds,
300
+ normaliseDecimal,
301
+ probabilityFromOdds,
302
+ safeParseInt,
303
+ validateInputs,
304
+ };
305
+ }));
@@ -0,0 +1,174 @@
1
+ (function (root, factory) {
2
+ if (typeof module === "object" && module.exports) {
3
+ module.exports = factory();
4
+ return;
5
+ }
6
+
7
+ root.DiagcalcDatasets = factory();
8
+ }(typeof globalThis !== "undefined" ? globalThis : this, () => {
9
+ const datasets = {
10
+ screening: {
11
+ tp: 42,
12
+ fp: 58,
13
+ fn: 18,
14
+ tn: 882,
15
+ preTestProb: 6,
16
+ name: "Screening programme (low prevalence)",
17
+ description: "Generic population screening scenario with low prevalence.",
18
+ reference: null,
19
+ },
20
+ caseControl: {
21
+ tp: 92,
22
+ fp: 18,
23
+ fn: 8,
24
+ tn: 82,
25
+ preTestProb: 50,
26
+ name: "Case-control study",
27
+ description: "Typical case-control design with artificially balanced prevalence.",
28
+ reference: null,
29
+ },
30
+ clinic: {
31
+ tp: 75,
32
+ fp: 25,
33
+ fn: 10,
34
+ tn: 140,
35
+ preTestProb: 40,
36
+ name: "Specialist clinic",
37
+ description: "Specialist clinic setting with intermediate prevalence.",
38
+ reference: null,
39
+ },
40
+ ddimer: {
41
+ tp: 195,
42
+ fp: 87,
43
+ fn: 5,
44
+ tn: 413,
45
+ preTestProb: 28.6,
46
+ name: "D-dimer for pulmonary embolism",
47
+ description: "Low Wells score + D-dimer to rule out pulmonary embolism in the emergency department.",
48
+ reference: {
49
+ authors: "Righini M, et al.",
50
+ title: "Age-Adjusted D-Dimer Cutoff Levels to Rule Out Pulmonary Embolism",
51
+ journal: "JAMA. 2014;311(11):1117-1124",
52
+ doi: "10.1001/jama.2014.2135",
53
+ url: "https://jamanetwork.com/journals/jama/fullarticle/1839153",
54
+ },
55
+ },
56
+ troponin: {
57
+ tp: 289,
58
+ fp: 156,
59
+ fn: 11,
60
+ tn: 1544,
61
+ preTestProb: 15,
62
+ name: "High-sensitivity troponin for AMI",
63
+ description: "High-sensitivity troponin for diagnosis of acute myocardial infarction.",
64
+ reference: {
65
+ authors: "Reichlin T, et al.",
66
+ title: "Early Diagnosis of Myocardial Infarction with Sensitive Cardiac Troponin Assays",
67
+ journal: "N Engl J Med. 2009;361(9):858-867",
68
+ doi: "10.1056/NEJMoa0900428",
69
+ url: "https://www.nejm.org/doi/full/10.1056/NEJMoa0900428",
70
+ },
71
+ },
72
+ mammography: {
73
+ tp: 7,
74
+ fp: 93,
75
+ fn: 3,
76
+ tn: 897,
77
+ preTestProb: 1,
78
+ name: "Screening mammography",
79
+ description: "Mammographic screening in women aged 50-69 (aggregated data).",
80
+ reference: {
81
+ authors: "Kopans DB, et al.",
82
+ title: "Screening Mammography Performance Metrics",
83
+ journal: "Radiology. 2020;297(2):239-240",
84
+ doi: "10.1148/radiol.2020203635",
85
+ url: "https://pubs.rsna.org/doi/10.1148/radiol.2020203635",
86
+ },
87
+ },
88
+ covid_antigen: {
89
+ tp: 103,
90
+ fp: 8,
91
+ fn: 47,
92
+ tn: 192,
93
+ preTestProb: 42.9,
94
+ name: "Rapid COVID-19 antigen test",
95
+ description: "Rapid antigen test for SARS-CoV-2 in symptomatic patients (first 7 days).",
96
+ reference: {
97
+ authors: "Dinnes J, et al.",
98
+ title: "Rapid, Point-of-Care Antigen Tests for COVID-19",
99
+ journal: "Cochrane Database Syst Rev. 2022;7:CD013705",
100
+ doi: "10.1002/14651858.CD013705.pub3",
101
+ url: "https://www.cochranelibrary.com/cdsr/doi/10.1002/14651858.CD013705.pub3",
102
+ },
103
+ },
104
+ hiv_elisa: {
105
+ tp: 199,
106
+ fp: 1,
107
+ fn: 1,
108
+ tn: 9799,
109
+ preTestProb: 2,
110
+ name: "HIV ELISA",
111
+ description: "4th-generation ELISA for HIV screening in the general population.",
112
+ reference: {
113
+ authors: "Masciotra S, et al.",
114
+ title: "Performance of HIV Diagnostic Tests",
115
+ journal: "J Clin Microbiol. 2013;51(6):1694-1700",
116
+ doi: "10.1128/JCM.03552-12",
117
+ url: "https://journals.asm.org/doi/10.1128/JCM.03552-12",
118
+ },
119
+ },
120
+ strep_throat: {
121
+ tp: 142,
122
+ fp: 25,
123
+ fn: 18,
124
+ tn: 315,
125
+ preTestProb: 32,
126
+ name: "Rapid Streptococcus test",
127
+ description: "Rapid antigen test for streptococcal pharyngitis in children with fever and sore throat.",
128
+ reference: {
129
+ authors: "Cohen JF, et al.",
130
+ title: "Rapid Antigen Detection Test for Group A Streptococcus in Children",
131
+ journal: "Cochrane Database Syst Rev. 2016;7:CD010502",
132
+ doi: "10.1002/14651858.CD010502.pub2",
133
+ url: "https://www.cochranelibrary.com/cdsr/doi/10.1002/14651858.CD010502.pub2",
134
+ },
135
+ },
136
+ xray_pneumonia: {
137
+ tp: 168,
138
+ fp: 82,
139
+ fn: 32,
140
+ tn: 318,
141
+ preTestProb: 33.3,
142
+ name: "Chest X-ray for pneumonia",
143
+ description: "Chest X-ray for community-acquired pneumonia in symptomatic adults.",
144
+ reference: {
145
+ authors: "Hagaman JT, et al.",
146
+ title: "Admission Chest Radiograph Lacks Sensitivity in Pneumonia",
147
+ journal: "Am J Med Sci. 2009;337(4):236-240",
148
+ doi: "10.1097/MAJ.0b013e31818ad805",
149
+ url: "https://www.amjmedsci.org/",
150
+ },
151
+ },
152
+ };
153
+
154
+ function getDataset(key) {
155
+ if (!Object.prototype.hasOwnProperty.call(datasets, key)) {
156
+ return null;
157
+ }
158
+
159
+ return datasets[key];
160
+ }
161
+
162
+ function listDatasets() {
163
+ return Object.keys(datasets).map((key) => ({
164
+ key,
165
+ ...datasets[key],
166
+ }));
167
+ }
168
+
169
+ return {
170
+ datasets,
171
+ getDataset,
172
+ listDatasets,
173
+ };
174
+ }));
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "diagcalc",
3
+ "version": "3.2.0",
4
+ "description": "Diagnostic calculator for the browser, terminal, and plain CLI.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "diagnostics",
8
+ "bayes",
9
+ "clinical",
10
+ "cli",
11
+ "tui"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "files": [
17
+ "bin",
18
+ "lib",
19
+ "tui",
20
+ "index.html",
21
+ "script.js",
22
+ "styles.css",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "bin": {
27
+ "diag": "bin/diagcalc.js",
28
+ "diagcalc": "bin/diagcalc.js"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "tui": "node bin/diagcalc.js --tui",
35
+ "cli": "node bin/diagcalc.js --help"
36
+ },
37
+ "dependencies": {
38
+ "blessed": "^0.1.81"
39
+ }
40
+ }