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/lib/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# diagcalc engine — `lib/`
|
|
2
|
+
|
|
3
|
+
This directory contains the shared calculation engine that powers all three diagcalc interfaces (web, TUI, CLI). The wrappers use a UMD-style export, so the same files load as `<script>` tags in the browser (`window.DiagcalcCore`, `window.DiagcalcDatasets`) and via `require()` in Node.
|
|
4
|
+
|
|
5
|
+
Keep this folder free of any browser-only code (no `window`, no DOM access). Interfaces consume the exports and render them.
|
|
6
|
+
|
|
7
|
+
## `diagcalc-core.js`
|
|
8
|
+
|
|
9
|
+
### Calculation
|
|
10
|
+
|
|
11
|
+
| Export | Signature | Returns |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `validateInputs` | `({ tp, fp, fn, tn, preTestProb })` | `{ valid: boolean, message?: string }` — non-throwing input gate |
|
|
14
|
+
| `calculateMetrics` | `({ tp, fp, fn, tn, preTestProb }, opts?)` | Object of metric cards: `sensitivity`, `specificity`, `ppv`, `npv`, `dor`, `numberNeededToScreen`, `lrPositive`, `lrNegative`, `preTestProbability`, `postTestPositive`, `postTestNegative`. Each has `{ label, value, ci?, formatter?, note? }`. `opts.continuityCorrection` is `"auto"` (default) \| `"always"` \| `"never"`. |
|
|
15
|
+
| `calculateThresholds` | `({ treatmentThreshold, lrPositive, lrNegative })` | `{ treatmentThreshold, testingThreshold, testTreatmentThreshold }` or `null`. Pauker–Kassirer 1980 framework. |
|
|
16
|
+
| `calculateROC` | `(rows)` | `{ points, auc, optimalIndex, optimalPoint }` or `null`. Trapezoidal AUC with `(0,0)` and `(1,1)` anchors; Youden's J optimum. |
|
|
17
|
+
| `calcCohenKappa` | `({ bothPos, only1Pos, only2Pos, bothNeg })` | `{ value, ci, observed, expected, n, interpretation }` or `null`. Marginal-adjusted multinomial asymptotic variance; Landis–Koch 1977 label. |
|
|
18
|
+
| `buildBiasWarnings` | `({ tp, fp, fn, tn, preTestProb })` | `string[]` — heuristic flags for small N and study-vs-patient prevalence mismatch. |
|
|
19
|
+
|
|
20
|
+
### CI helpers
|
|
21
|
+
|
|
22
|
+
| Export | Signature | Notes |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `calcWilsonInterval` | `(successes, total)` | Wilson 95% interval for a proportion. Returns `null` if `total === 0`. |
|
|
25
|
+
| `calcLogRatioCI` | `(x1, n1, x2, n2, opts?)` | Log-normal 95% CI for a ratio of two binomial proportions (Simel–Samsa–Matchar 1991). `opts.continuityCorrection` controls the +0.5 adjustment. |
|
|
26
|
+
| `calcPostTestCI` | `(preTestProbability, lrCi)` | Propagates an LR CI to a post-test probability CI via the Bayes update (monotonic transformation of both interval endpoints, holding the prior fixed). |
|
|
27
|
+
| `calcDOR` | `(tp, fp, fn, tn, opts?)` | DOR with log-normal CI; continuity correction governed by `opts.continuityCorrection`. |
|
|
28
|
+
|
|
29
|
+
### Maths primitives
|
|
30
|
+
|
|
31
|
+
| Export | What |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `calculateRatio(a, b)` | `0/0 → NaN`, `n/0 → Infinity`, else `a/b`. |
|
|
34
|
+
| `calculateOdds(p)` | `p / (1 − p)`; handles `0`/`1` edges. |
|
|
35
|
+
| `multiplyOdds(odds, ratio)` | `0 × ∞` and `∞ × 0` remain `NaN`; valid infinite products remain `Infinity`. |
|
|
36
|
+
| `probabilityFromOdds(odds)` | Converts back to a probability; handles `0` and `∞`. |
|
|
37
|
+
| `clamp(value, min, max)` | Standard clamp. |
|
|
38
|
+
|
|
39
|
+
### Formatters & interpretation
|
|
40
|
+
|
|
41
|
+
| Export | Behaviour |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `formatValue(value, customFormatter?)` | Routes to `customFormatter` if provided; else `formatPercentage`. |
|
|
44
|
+
| `formatPercentage(value)` | `"12.3%"` for finite fractions; `"—"` for non-finite. |
|
|
45
|
+
| `formatLikelihood(value)` | `"12.3"` for `≥ 10`, `"1.23"` otherwise. `"∞"` for positive infinity, `"—"` for undefined values, `"0"` for zero. |
|
|
46
|
+
| `formatNNS(value)` | `Math.ceil(value)` as a string; `"—"` for non-finite or `≤ 0`. |
|
|
47
|
+
| `buildProbabilityBar(value, width)` | ASCII bar `"###---"`. |
|
|
48
|
+
| `interpretLRPositive`, `interpretLRNegative`, `interpretDOR`, `interpretNNS`, `interpretKappa`, `buildSensitivityNote`, `buildSpecificityNote` | Plain-English notes for the matching metric. |
|
|
49
|
+
|
|
50
|
+
### Input parsers
|
|
51
|
+
|
|
52
|
+
| Export | Behaviour |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `safeParseInt(value)` | Strict non-negative integer parse from a string; `NaN` on anything else. |
|
|
55
|
+
| `normaliseDecimal(value)` | Replaces `,` with `.` and trims. Used for pre-test probability input. |
|
|
56
|
+
|
|
57
|
+
## `diagcalc-datasets.js`
|
|
58
|
+
|
|
59
|
+
UMD module exporting `{ datasets, getDataset, listDatasets, buildDatasetWarnings }`. Each preset has:
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
{
|
|
63
|
+
tp, fp, fn, tn, // integers
|
|
64
|
+
preTestProb, // 0–100, inclusive
|
|
65
|
+
name, // display label
|
|
66
|
+
description, // short prose
|
|
67
|
+
referenceLastReviewed: "YYYY-MM-DD", // background reference review only
|
|
68
|
+
provenance: { kind: "illustrative", /* see docs/PROVENANCE.md */ },
|
|
69
|
+
reference: null | { // null on generic teaching scenarios
|
|
70
|
+
authors,
|
|
71
|
+
title,
|
|
72
|
+
journal,
|
|
73
|
+
doi,
|
|
74
|
+
url,
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Adding a preset: insert it in `datasets`, add a matching `<option value="<key>">` in `index.html`. The TUI and CLI pick it up automatically via `listDatasets()`.
|
|
80
|
+
|
|
81
|
+
## Engine-level invariants
|
|
82
|
+
|
|
83
|
+
- Calculation entry points guard invalid inputs; interface parsers reject incomplete strings. Counts and total must be safe integers, both disease cohorts present.
|
|
84
|
+
- Non-throwing: edge cases return `Infinity`, `0`, `NaN`, or `null` rather than raising.
|
|
85
|
+
- The UMD wrapper is load-bearing — do not convert to ES modules or CommonJS-only.
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
## Data and presentation separation
|
|
89
|
+
|
|
90
|
+
`calculateResults(input, options)` returns numeric metric records (`value`, optional `ci`, `unit`, interpretation id/params) or null on invalid input/options. It has no label text or formatter functions. `calculateMetrics(input, options, locale)` remains the compatibility adapter, using `diagcalc-presentation.js` to decorate numeric records. The shared types are in `diagcalc-types.d.ts`; `npm run typecheck` checks shared APIs and extracted helpers incrementally.
|
|
91
|
+
|
|
92
|
+
`parseProbability(string)` accepts complete dot/comma decimal strings. Exact 0%/100% priors are valid; an impossible observed event produces undefined posterior and no CI. `chainedPreTestProbability(fraction)` preserves precision and returns NaN for undefined input.
|
|
93
|
+
|
|
94
|
+
`validateRocRows(rows, { direction })` rejects partial/invalid data, mixed cohorts, inconsistent cutoff direction and non-monotonic operating points. `calculateROC` orders tied false-positive rates by sensitivity. Threshold calculations return null unless LR+ > 1 and 0 ≤ LR− < 1.
|
|
95
|
+
|
|
96
|
+
`diagcalc-case.js` creates schema-2 snapshots with engine version, inputs, correction options, case origin and full-precision encoded metrics. Exceptional values use `{ value: null, status }`; CI endpoints use the same encoding. History parsing limits size/count, rejects malformed entries and marks recomputed pre-schema entries legacy. Method metadata describes the selected correction rather than a fixed default.
|
|
97
|
+
|
|
98
|
+
`diagcalc-storage.js` guards backend access and preserves session writes when storage is blocked/full. `diagcalc-geometry.js` provides calibrated logarithmic Fagan coordinates with explicit off-scale flags. `diagcalc-meta.js` is the shared version/schema metadata.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
if (typeof module === "object" && module.exports) {
|
|
3
|
+
module.exports = factory(require("./diagcalc-core"), require("./diagcalc-meta"));
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
root.DiagcalcCase = factory(root.DiagcalcCore, root.DiagcalcMeta);
|
|
7
|
+
}(/** @type {any} */ (typeof globalThis !== "undefined" ? globalThis : this), /** @param {typeof import("./diagcalc-core")} core @param {typeof import("./diagcalc-meta")} meta */ (core, meta) => {
|
|
8
|
+
const metricKeys = ["sensitivity", "specificity", "ppv", "npv", "dor", "numberNeededToScreen", "lrPositive", "lrNegative", "preTestProbability", "postTestPositive", "postTestNegative"];
|
|
9
|
+
const modes = ["auto", "always", "never"];
|
|
10
|
+
|
|
11
|
+
/** @param {number} value @returns {import("./diagcalc-types").EncodedNumber} */
|
|
12
|
+
function encodeNumber(value) {
|
|
13
|
+
if (value === Infinity) return { value: null, status: "infinite" };
|
|
14
|
+
if (value === -Infinity) return { value: null, status: "negative-infinite" };
|
|
15
|
+
if (!Number.isFinite(value)) return { value: null, status: "undefined" };
|
|
16
|
+
return { value, status: "finite" };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** @param {import("./diagcalc-types").EncodedNumber} encoded */
|
|
20
|
+
function decodeNumber(encoded) {
|
|
21
|
+
if (!encoded || typeof encoded !== "object") return NaN;
|
|
22
|
+
if (encoded.status === "infinite" && encoded.value === null) return Infinity;
|
|
23
|
+
if (encoded.status === "negative-infinite" && encoded.value === null) return -Infinity;
|
|
24
|
+
if (encoded.status === "finite" && Number.isFinite(encoded.value)) return encoded.value;
|
|
25
|
+
return NaN;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validNumber(encoded) {
|
|
29
|
+
return encoded && (encoded.status === "finite" ? Number.isFinite(encoded.value) :
|
|
30
|
+
["infinite", "negative-infinite", "undefined"].includes(encoded.status) && encoded.value === null);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @param {import("./diagcalc-types").NumericMetric} metric */
|
|
34
|
+
function serialiseMetric(metric) {
|
|
35
|
+
return {
|
|
36
|
+
...encodeNumber(metric.value),
|
|
37
|
+
ci: metric.ci ? { lower: encodeNumber(metric.ci.lower), upper: encodeNumber(metric.ci.upper) } : null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a versioned, JSON-safe calculation snapshot; invalid input returns null.
|
|
43
|
+
* @param {import("./diagcalc-types").DiagnosticInput} input
|
|
44
|
+
* @param {import("./diagcalc-types").CalculationOptions} [options]
|
|
45
|
+
* @param {import("./diagcalc-types").CaseInfo} [caseInfo]
|
|
46
|
+
* @returns {import("./diagcalc-types").Snapshot | null}
|
|
47
|
+
*/
|
|
48
|
+
function createSnapshot(input, options = {}, caseInfo = {}) {
|
|
49
|
+
if (!core.validateInputs(input).valid) return null;
|
|
50
|
+
const continuityCorrection = options.continuityCorrection || "auto";
|
|
51
|
+
if (!modes.includes(continuityCorrection)) return null;
|
|
52
|
+
const { tp, fp, fn, tn, preTestProb } = input;
|
|
53
|
+
const cleanInput = { tp, fp, fn, tn, preTestProb };
|
|
54
|
+
const metrics = core.calculateResults(cleanInput, { continuityCorrection });
|
|
55
|
+
return {
|
|
56
|
+
schemaVersion: meta.schemaVersion,
|
|
57
|
+
engineVersion: meta.version,
|
|
58
|
+
id: typeof caseInfo.id === "string" ? caseInfo.id : `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
59
|
+
savedAt: caseInfo.savedAt || new Date().toISOString(),
|
|
60
|
+
label: typeof caseInfo.label === "string" ? caseInfo.label.slice(0, 200) : "Custom case",
|
|
61
|
+
datasetKey: typeof caseInfo.datasetKey === "string" ? caseInfo.datasetKey : null,
|
|
62
|
+
modified: Boolean(caseInfo.modified),
|
|
63
|
+
legacy: Boolean(caseInfo.legacy),
|
|
64
|
+
provenance: caseInfo.provenance || null,
|
|
65
|
+
input: cleanInput,
|
|
66
|
+
options: { continuityCorrection },
|
|
67
|
+
metrics: Object.fromEntries(Object.entries(metrics).map(([key, metric]) => [key, serialiseMetric(metric)])),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @param {import("./diagcalc-types").Snapshot} snapshot */
|
|
72
|
+
function fingerprint(snapshot) {
|
|
73
|
+
return JSON.stringify([snapshot.input, snapshot.options, snapshot.datasetKey, snapshot.modified, snapshot.engineVersion]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isSnapshot(entry) {
|
|
77
|
+
if (!entry || typeof entry !== "object" || entry.schemaVersion !== meta.schemaVersion) return false;
|
|
78
|
+
if (!core.validateInputs(entry.input).valid || !entry.options || !modes.includes(entry.options.continuityCorrection)) return false;
|
|
79
|
+
if (typeof entry.label !== "string" || entry.label.length > 200 || typeof entry.id !== "string" || entry.id.length > 100) return false;
|
|
80
|
+
if (typeof entry.savedAt !== "string" || !Number.isFinite(Date.parse(entry.savedAt))) return false;
|
|
81
|
+
if (typeof entry.engineVersion !== "string" || !/^\d+\.\d+\.\d+$/.test(entry.engineVersion)) return false;
|
|
82
|
+
if (entry.datasetKey !== null && (typeof entry.datasetKey !== "string" || entry.datasetKey.length > 100)) return false;
|
|
83
|
+
return entry.metrics && metricKeys.every((key) => {
|
|
84
|
+
const metric = entry.metrics[key];
|
|
85
|
+
return validNumber(metric) && (metric.ci === null ||
|
|
86
|
+
metric.ci && validNumber(metric.ci.lower) && validNumber(metric.ci.upper));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sanitiseProvenance(provenance) {
|
|
91
|
+
if (!provenance || typeof provenance !== "object" || Array.isArray(provenance)) return null;
|
|
92
|
+
const fields = ["kind", "sourceLocation", "extraction", "threshold", "referenceStandard", "outcome", "population", "reviewStatus", "note"];
|
|
93
|
+
return Object.fromEntries(fields.map((key) => [key,
|
|
94
|
+
typeof provenance[key] === "string" ? provenance[key].slice(0, 4000) : null,
|
|
95
|
+
]));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {unknown} raw @param {number} [limit] @returns {import("./diagcalc-types").Snapshot[]} */
|
|
99
|
+
function parseHistory(raw, limit = 10) {
|
|
100
|
+
if (typeof raw !== "string" || raw.length > 200000) return [];
|
|
101
|
+
let entries;
|
|
102
|
+
try { entries = JSON.parse(raw); } catch (_) { return []; }
|
|
103
|
+
if (!Array.isArray(entries)) return [];
|
|
104
|
+
const clean = [];
|
|
105
|
+
const ids = new Set();
|
|
106
|
+
for (const entry of entries.slice(0, 100)) {
|
|
107
|
+
let snapshot = entry;
|
|
108
|
+
if (entry && entry.schemaVersion === undefined && core.validateInputs(entry.input).valid && typeof entry.label === "string") {
|
|
109
|
+
snapshot = createSnapshot(entry.input, {}, {
|
|
110
|
+
id: String(entry.id), savedAt: entry.savedAt, label: entry.label,
|
|
111
|
+
datasetKey: entry.datasetKey, legacy: true,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (!isSnapshot(snapshot) || ids.has(snapshot.id)) continue;
|
|
115
|
+
ids.add(snapshot.id);
|
|
116
|
+
// Copy only validated fields; never trust persisted summaries or HTML.
|
|
117
|
+
clean.push({
|
|
118
|
+
schemaVersion: snapshot.schemaVersion, engineVersion: snapshot.engineVersion,
|
|
119
|
+
id: snapshot.id, savedAt: snapshot.savedAt, label: snapshot.label,
|
|
120
|
+
datasetKey: snapshot.datasetKey, modified: Boolean(snapshot.modified), legacy: Boolean(snapshot.legacy),
|
|
121
|
+
provenance: sanitiseProvenance(snapshot.provenance),
|
|
122
|
+
input: { tp: snapshot.input.tp, fp: snapshot.input.fp, fn: snapshot.input.fn, tn: snapshot.input.tn, preTestProb: snapshot.input.preTestProb },
|
|
123
|
+
options: { continuityCorrection: snapshot.options.continuityCorrection }, metrics: snapshot.metrics,
|
|
124
|
+
});
|
|
125
|
+
if (clean.length >= limit) break;
|
|
126
|
+
}
|
|
127
|
+
return clean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @param {import("./diagcalc-types").Snapshot} snapshot */
|
|
131
|
+
function summary(snapshot) {
|
|
132
|
+
return Object.fromEntries(metricKeys.map((key) => [key, decodeNumber(snapshot.metrics[key])]));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @param {import("./diagcalc-types").CalculationOptions} options */
|
|
136
|
+
function methodMetadata(options) {
|
|
137
|
+
const mode = options.continuityCorrection;
|
|
138
|
+
const correction = mode === "always" ? "+0.5 in every table" : mode === "never" ? "no continuity correction" : "+0.5 when a cell is zero";
|
|
139
|
+
return {
|
|
140
|
+
proportions: "Wilson 95%",
|
|
141
|
+
likelihoodRatios: `Simel-Samsa-Matchar 1991 log-normal 95%; ${correction}`,
|
|
142
|
+
postTestProbabilities: "Monotonic Bayes transformation of LR interval endpoints; fixed pre-test probability",
|
|
143
|
+
diagnosticOddsRatio: `Log-normal 95%; ${correction}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { createSnapshot, fingerprint, parseHistory, summary, encodeNumber, decodeNumber, serialiseMetric, methodMetadata };
|
|
148
|
+
}));
|