Repository navigation
Expand file tree
/
Copy pathindex.html
More file actions
189 lines (181 loc) · 18.8 KB
/
Copy pathindex.html
File metadata and controls
189 lines (181 loc) · 18.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>LuPNet PH-IPF calculator</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Source+Serif+4:opsz,wght@8..60,500;8..60,650&display=swap" rel="stylesheet">
<style>
:root {
--ink: #1b2a35; --muted: #5b6b77; --paper: #f7f9fa; --panel: #ffffff; --line: #d7dee3;
--oxygen: #2b6cb0; --exchange: #2a9d8f; --venous: #7b2d5b; --warn: #9a5b00;
box-sizing: border-box;
padding-top: env(safe-area-inset-top, 0px); padding-bottom: env(safe-area-inset-bottom, 0px);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --ink: #e3eaee; --muted: #9fb0bb; --paper: #10181e; --panel: #172229; --line: #2a3a44;
--oxygen: #6fa8e0; --exchange: #4cc2b3; --venous: #d08ab3; --warn: #e2a64a; }
}
:root[data-theme="dark"] { --ink: #e3eaee; --muted: #9fb0bb; --paper: #10181e; --panel: #172229; --line: #2a3a44;
--oxygen: #6fa8e0; --exchange: #4cc2b3; --venous: #d08ab3; --warn: #e2a64a; }
*, *::before, *::after { box-sizing: inherit; }
html { scroll-padding-top: env(safe-area-inset-top, 0px); }
body { margin: 0; background: var(--paper); color: var(--ink); font: 400 16px/1.55 "IBM Plex Sans", "Segoe UI", system-ui, sans-serif; }
main { max-width: 1120px; margin: 0 auto; padding: 32px 20px 48px; }
h1 { font: 650 clamp(1.7rem, 3.4vw, 2.5rem)/1.15 "Source Serif 4", Georgia, serif; margin: 0 0 8px; max-width: 24ch; }
.lede { color: var(--muted); max-width: 68ch; margin: 0 0 28px; }
.grid { display: grid; grid-template-columns: minmax(280px, 380px) 1fr; gap: 28px; align-items: start; }
@media (max-width: 860px) { .grid { grid-template-columns: 1fr; } }
.inputs, .map { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 20px; }
label { display: block; font-weight: 500; margin: 14px 0 4px; }
label small { font-weight: 400; color: var(--muted); }
input[type=number] { width: 100%; font: inherit; padding: 9px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--paper); color: var(--ink); }
input:focus-visible { outline: 3px solid var(--oxygen); outline-offset: 1px; }
.results { margin-top: 18px; border-top: 1px solid var(--line); padding-top: 14px; }
.row { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-bottom: 1px dashed var(--line); }
.row:last-child { border-bottom: 0; }
.row span:first-child { color: var(--muted); }
.row b { font-weight: 600; white-space: nowrap; }
.verdict { margin: 16px 0 4px; font: 650 1.15rem/1.35 "Source Serif 4", Georgia, serif; }
.note { color: var(--warn); font-size: .92rem; margin-top: 8px; }
.map h2 { font: 650 1.2rem/1.3 "Source Serif 4", Georgia, serif; margin: 0 0 4px; }
.map p { color: var(--muted); margin: 0 0 12px; font-size: .95rem; }
.canvas-wrap { position: relative; width: 100%; aspect-ratio: 4 / 3; }
canvas { width: 100%; height: 100%; display: block; }
.legend { display: flex; gap: 18px; flex-wrap: wrap; font-size: .88rem; color: var(--muted); margin-top: 10px; }
.scale { display: inline-block; width: 120px; height: 10px; border-radius: 5px; vertical-align: middle;
background: linear-gradient(90deg, var(--exchange), var(--venous)); margin: 0 6px; }
details { margin-top: 28px; max-width: 76ch; }
summary { cursor: pointer; font-weight: 600; }
details p, details li { color: var(--muted); }
footer { margin-top: 28px; color: var(--muted); font-size: .88rem; max-width: 76ch; }
</style>
</head>
<body>
<main>
<h1>Is this patient's pulmonary hypertension more than fibrosis?</h1>
<p class="lede">Enter the lung function of a patient with idiopathic pulmonary fibrosis. LuPNet places them among 260 simulated cases simulated with and without a structural vasculopathy, and estimates how much of their expected pulmonary artery pressure fibrosis alone would explain. For research use only; it does not replace right heart catheterization.</p>
<div class="grid">
<section class="inputs" aria-label="Patient data">
<label for="fvc">FVC <small>(% predicted)</small></label>
<input id="fvc" type="number" min="20" max="120" step="1" value="55" inputmode="decimal">
<label for="dlco">DLCO <small>(% predicted, haemoglobin-corrected)</small></label>
<input id="dlco" type="number" min="5" max="120" step="1" value="30" inputmode="decimal">
<label for="spo2">Resting SpO₂ <small>(%, optional)</small></label>
<input id="spo2" type="number" min="70" max="100" step="1" placeholder="e.g. 93" inputmode="decimal">
<div class="results" aria-live="polite">
<p class="verdict" id="verdict"></p>
<div class="row"><span>Probability of mPAP ≥ 25 mmHg</span><b id="pph"></b></div>
<div class="row"><span>Probability of the vascular phenotype</span><b id="pvasc"></b></div>
<div class="row"><span>mPAP expected from fibrosis alone</span><b id="tissue"></b></div>
<div class="row"><span>DLCO expected for this FVC without vasculopathy</span><b id="dlexp"></b></div>
<div class="row"><span>25 most similar simulated cases: mPAP</span><b id="twins"></b></div>
<div class="row"><span>… of whom with mPAP ≥ 25 mmHg</span><b id="twph"></b></div>
<div class="row" id="zrow" hidden><span>Zisman 2007 formula (with SpO₂)</span><b id="zis"></b></div>
<p class="note" id="note" hidden></p>
</div>
</section>
<section class="map" aria-label="Virtual cohort">
<h2>Where the patient sits</h2>
<p>Each dot is a simulated case, coloured by its simulated mean pulmonary artery pressure. The line is the DLCO that tissue loss alone produces at each FVC; patients well below it carry the vascular phenotype.</p>
<div class="canvas-wrap"><canvas id="plot" role="img" aria-label="Scatter of FVC against DLCO for the in silico cohort, with the patient marked"></canvas></div>
<div class="legend">
<span>mPAP 15<span class="scale"></span>35+ mmHg</span>
<span>— expected DLCO without vasculopathy</span>
<span>○ this patient</span>
</div>
</section>
</div>
<details>
<summary>How the estimates are made</summary>
<ul>
<li>The simulated cases come from LuPNet, a network model of the fibrotic lung with a shared shear-stress law for the pulmonary vessels. Each simulated case is an independent run of the model, not a real patient. 70% of the cases carry a structural small-artery vasculopathy with its own course, mostly mild, and fibrosis progresses at a different speed in each case.</li>
<li>The vasculopathy was calibrated so that the cohort reproduces the distribution of mPAP in mild-to-moderate IPF (ARTEMIS-IPF), the weak correlation of mPAP with DLCO and its absence with FVC. The prevalence of PH in advanced disease was not fitted.</li>
<li>The probability of PH is a logistic model of FVC and FVC/DLCO fitted to the in silico cohort with realistic measurement noise (AUC 0.74). The vascular-phenotype probability comes from a two-component mixture of the DLCO deficit relative to the expected curve.</li>
<li>The in silico cohort spans FVC 24–80% and DLCO 7–65%. Estimates outside this range are extrapolations.</li>
</ul>
</details>
<footer>LuPNet, Instituto Nacional de Medicina Genómica. Code and data: github.com/Danpc11/LuPNet. Research software, not a medical device.</footer>
</main>
<script>
const COEF = {"tissue_curve": [0.003316, -0.473136, 34.035344], "dlco_curve": [-0.000482, 0.085487, 0.378269], "dlco_residual_sd": 0.0429, "mixture": {"weights": [0.747, 0.253], "means": [-0.0314, -0.2179], "sds": [0.0535, 0.1327], "vascular_component": 1}, "logistic": {"intercept": -0.3595, "coef_FVC": -0.02727, "coef_log_ratio": 1.6786, "auc_with_noise": 0.745}, "n_patients": 260, "fvc_range": [24.073, 79.602], "dlco_range": [7.303, 64.932]};
const COH = [[33.1, 12.8, 31.2, 1], [45.3, 26.0, 20.7, 1], [66.7, 49.0, 17.3, 0], [31.4, 12.2, 33.4, 1], [39.5, 18.2, 25.8, 1], [51.8, 27.8, 29.0, 1], [63.6, 44.6, 18.8, 1], [34.5, 16.5, 23.2, 1], [43.8, 23.0, 25.4, 1], [65.1, 42.4, 20.7, 1], [67.1, 47.9, 19.1, 1], [31.0, 12.2, 27.9, 1], [50.9, 24.3, 36.7, 1], [65.0, 48.3, 17.4, 0], [78.8, 47.6, 34.6, 1], [65.2, 48.7, 19.4, 1], [66.8, 48.5, 21.1, 1], [53.5, 33.8, 18.1, 0], [49.6, 30.1, 20.1, 1], [34.8, 16.0, 22.5, 1], [51.6, 32.8, 20.4, 1], [58.1, 37.1, 20.4, 1], [59.8, 42.4, 17.9, 0], [54.6, 35.8, 17.9, 0], [67.5, 52.9, 17.3, 0], [58.6, 37.5, 22.3, 1], [46.7, 26.6, 20.7, 1], [47.2, 27.2, 21.6, 1], [47.4, 25.5, 23.6, 1], [42.1, 22.7, 21.3, 1], [67.4, 50.2, 18.8, 1], [47.1, 28.7, 19.2, 0], [33.1, 14.8, 22.9, 1], [45.1, 26.9, 19.3, 0], [59.3, 38.7, 20.6, 1], [60.2, 41.5, 17.6, 0], [40.5, 22.0, 20.2, 0], [66.6, 45.9, 21.6, 1], [41.0, 20.8, 22.9, 1], [61.9, 45.7, 17.8, 0], [76.3, 58.7, 19.6, 1], [55.7, 36.5, 20.8, 1], [73.3, 45.7, 30.7, 1], [54.0, 28.2, 31.6, 1], [51.0, 31.0, 19.2, 0], [39.5, 18.9, 25.4, 1], [43.9, 23.2, 24.6, 1], [44.6, 25.7, 19.4, 0], [42.0, 22.2, 19.8, 0], [57.7, 35.1, 23.6, 1], [56.1, 37.2, 18.1, 0], [75.8, 63.2, 16.9, 0], [61.9, 31.3, 37.5, 1], [55.8, 37.5, 18.8, 0], [48.3, 26.5, 22.3, 1], [79.2, 64.9, 16.7, 0], [53.5, 34.5, 20.8, 1], [48.3, 30.0, 19.3, 0], [32.3, 14.1, 22.5, 0], [37.4, 18.2, 22.2, 1], [53.4, 36.5, 18.0, 0], [35.9, 13.7, 36.5, 1], [64.3, 50.6, 17.3, 0], [41.9, 22.5, 21.2, 1], [46.5, 23.1, 28.0, 1], [79.6, 62.1, 18.9, 1], [62.4, 38.9, 25.7, 1], [35.6, 17.4, 20.6, 0], [53.3, 34.1, 20.2, 1], [45.3, 24.2, 22.9, 1], [68.7, 51.5, 17.1, 0], [62.7, 40.7, 23.3, 1], [58.7, 40.3, 18.6, 1], [32.2, 14.3, 24.4, 1], [41.6, 20.3, 25.8, 1], [63.1, 46.5, 18.4, 1], [51.9, 31.1, 20.7, 1], [39.8, 19.7, 27.2, 1], [57.7, 39.6, 18.0, 0], [45.7, 28.0, 20.0, 1], [53.0, 33.5, 18.4, 0], [66.0, 48.7, 17.7, 0], [64.6, 33.4, 38.8, 1], [60.4, 43.7, 17.7, 0], [57.2, 34.6, 24.2, 1], [60.5, 39.8, 20.8, 1], [71.6, 52.4, 20.3, 1], [72.9, 48.6, 26.0, 1], [71.8, 46.6, 29.4, 1], [35.6, 16.3, 20.7, 0], [42.0, 22.8, 20.1, 0], [45.5, 22.3, 31.8, 1], [42.4, 20.8, 25.6, 1], [51.8, 33.3, 18.6, 0], [41.6, 20.9, 26.9, 1], [55.7, 27.2, 37.4, 1], [61.5, 41.8, 18.4, 1], [46.8, 29.1, 19.0, 0], [56.5, 33.6, 25.5, 1], [45.2, 23.5, 26.6, 1], [72.5, 49.2, 25.4, 1], [35.9, 16.5, 22.6, 1], [41.5, 17.1, 37.0, 1], [63.3, 44.5, 18.0, 0], [36.8, 17.4, 22.3, 1], [35.0, 13.6, 31.7, 1], [59.7, 34.4, 27.8, 1], [48.7, 28.4, 20.4, 1], [45.5, 26.2, 19.2, 0], [43.3, 23.4, 23.1, 1], [55.1, 35.5, 19.6, 1], [37.3, 15.3, 35.3, 1], [40.9, 15.2, 47.2, 1], [34.5, 15.4, 25.9, 1], [63.2, 45.3, 17.5, 0], [32.1, 14.1, 21.7, 0], [56.4, 37.6, 18.5, 0], [35.6, 13.4, 35.3, 1], [46.4, 27.2, 19.0, 0], [55.4, 37.4, 18.2, 0], [43.4, 25.6, 21.3, 1], [66.6, 41.0, 28.2, 1], [69.0, 40.8, 33.2, 1], [57.6, 38.2, 19.2, 1], [50.0, 29.6, 21.1, 1], [40.0, 21.4, 20.3, 0], [48.9, 17.2, 60.3, 1], [73.8, 59.4, 16.8, 0], [51.8, 32.0, 22.0, 1], [45.3, 23.4, 25.4, 1], [54.2, 34.8, 18.5, 0], [56.7, 31.2, 29.9, 1], [74.8, 59.8, 17.1, 0], [48.4, 29.0, 22.1, 1], [61.4, 37.4, 24.9, 1], [69.5, 51.3, 19.7, 1], [41.7, 20.4, 25.9, 1], [56.9, 39.8, 18.0, 0], [30.8, 13.8, 22.2, 0], [41.1, 20.5, 27.2, 1], [72.3, 57.0, 17.2, 0], [43.5, 26.0, 19.6, 0], [76.6, 61.0, 19.2, 1], [66.1, 44.4, 23.0, 1], [44.2, 14.9, 53.5, 1], [79.4, 58.4, 23.9, 1], [50.8, 31.2, 20.5, 1], [40.2, 17.8, 29.2, 1], [34.2, 15.0, 25.9, 1], [36.6, 17.5, 20.6, 0], [68.5, 53.9, 17.2, 0], [57.8, 37.3, 19.9, 1], [59.5, 26.7, 41.0, 1], [52.7, 33.3, 19.6, 1], [48.2, 29.7, 18.7, 0], [79.2, 56.7, 24.1, 1], [44.8, 25.1, 19.5, 0], [75.5, 60.7, 16.8, 0], [45.6, 23.1, 26.2, 1], [51.5, 31.0, 21.9, 1], [61.0, 43.7, 17.6, 0], [54.6, 37.5, 18.0, 0], [39.0, 17.7, 30.0, 1], [74.0, 59.2, 17.3, 1], [37.2, 17.6, 24.3, 1], [54.3, 35.7, 17.9, 0], [47.0, 26.0, 21.0, 1], [66.7, 48.3, 20.3, 1], [54.8, 33.2, 22.6, 1], [33.5, 14.0, 26.0, 1], [54.3, 33.8, 18.8, 0], [53.8, 32.3, 25.0, 1], [52.6, 31.9, 24.1, 1], [42.9, 20.4, 30.1, 1], [53.4, 25.7, 38.1, 1], [62.0, 43.3, 18.2, 1], [63.3, 45.1, 18.5, 1], [53.3, 35.0, 19.2, 1], [43.2, 20.8, 31.6, 1], [37.5, 15.2, 32.1, 1], [45.3, 26.2, 20.0, 1], [39.6, 19.8, 27.9, 1], [55.2, 36.8, 18.8, 1], [54.5, 31.6, 24.4, 1], [50.3, 29.1, 20.9, 1], [36.8, 14.3, 32.3, 1], [40.0, 15.1, 36.2, 1], [72.7, 57.4, 17.2, 0], [62.4, 40.1, 23.4, 1], [66.9, 50.7, 18.6, 1], [32.7, 10.7, 42.8, 1], [43.4, 24.3, 20.6, 0], [45.5, 22.1, 28.2, 1], [72.4, 57.4, 17.1, 0], [51.3, 29.2, 25.0, 1], [37.0, 18.1, 24.9, 1], [79.3, 64.3, 17.8, 1], [56.1, 32.5, 25.1, 1], [60.5, 40.5, 20.2, 1], [71.7, 56.3, 17.0, 0], [36.1, 16.8, 22.2, 1], [44.0, 24.8, 19.4, 0], [31.9, 14.6, 21.8, 0], [27.0, 10.9, 23.9, 0], [30.3, 9.5, 41.2, 1], [27.9, 10.7, 24.6, 1], [38.6, 20.8, 20.3, 0], [31.8, 14.2, 22.1, 0], [38.7, 19.3, 19.6, 0], [26.9, 9.4, 25.7, 1], [29.7, 9.8, 37.4, 1], [27.1, 10.0, 24.7, 1], [29.6, 11.5, 28.1, 1], [26.9, 7.3, 48.5, 1], [28.1, 9.7, 29.8, 1], [35.5, 16.5, 24.0, 1], [26.3, 9.4, 25.4, 0], [30.4, 11.5, 29.5, 1], [25.4, 8.6, 25.7, 0], [28.1, 10.1, 29.9, 1], [40.3, 20.3, 20.7, 1], [39.1, 17.5, 28.2, 1], [35.8, 17.2, 21.7, 1], [42.7, 16.8, 42.7, 1], [26.0, 8.4, 35.2, 1], [30.7, 12.8, 23.2, 1], [33.4, 13.4, 27.8, 1], [33.9, 15.4, 23.0, 1], [27.3, 9.9, 24.4, 0], [26.5, 9.5, 26.7, 1], [39.5, 14.9, 41.7, 1], [43.7, 24.3, 20.7, 1], [24.1, 8.1, 27.9, 1], [26.5, 7.7, 37.7, 1], [25.3, 8.8, 24.2, 0], [27.2, 8.4, 35.5, 1], [25.6, 9.1, 25.4, 0], [25.2, 8.5, 28.1, 1], [34.6, 16.0, 20.2, 0], [32.7, 14.7, 21.0, 0], [26.1, 9.9, 25.5, 1], [28.2, 11.1, 23.2, 0], [34.6, 13.4, 36.4, 1], [30.8, 7.5, 65.8, 1], [37.0, 17.8, 22.1, 1], [41.3, 18.1, 36.9, 1], [27.3, 9.9, 27.6, 1], [38.2, 21.4, 20.4, 0], [34.2, 13.5, 30.5, 1], [43.3, 24.3, 21.6, 1], [26.6, 9.8, 30.2, 1], [32.4, 15.0, 21.3, 0], [34.6, 11.3, 48.4, 1], [25.4, 8.1, 24.2, 0], [35.2, 15.9, 24.9, 1], [34.1, 15.7, 23.1, 1], [36.8, 18.0, 22.4, 1], [40.3, 12.0, 59.5, 1], [41.5, 15.2, 51.8, 1], [32.4, 13.7, 25.4, 1]]; // [FVC, DLCO, mPAP, vasculopathy]
const $ = id => document.getElementById(id);
const polyval = (c, x) => c.reduce((s, a) => s * x + a, 0);
const npdf = (x, m, s) => Math.exp(-0.5 * ((x - m) / s) ** 2) / s;
const fmt = (x, d = 0) => Number.isFinite(x) ? x.toFixed(d) : '–';
const css = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim();
function estimate(fvc, dlco) {
const tissue = polyval(COEF.tissue_curve, fvc);
const dlexp = Math.exp(polyval(COEF.dlco_curve, fvc));
const res = Math.log(dlco) - Math.log(dlexp);
const M = COEF.mixture, v = M.vascular_component;
const lik = M.weights.map((w, k) => w * npdf(res, M.means[k], M.sds[k]));
const pvasc = lik[v] / (lik[0] + lik[1]);
const L = COEF.logistic;
const pph = 1 / (1 + Math.exp(-(L.intercept + L.coef_FVC * fvc + L.coef_log_ratio * Math.log(fvc / dlco))));
const sF = 12, sD = 0.45;
const near = COH.map(p => [((p[0] - fvc) / sF) ** 2 + ((Math.log(p[1]) - Math.log(dlco)) / sD) ** 2, p])
.sort((a, b) => a[0] - b[0]).slice(0, 25).map(x => x[1]);
const m = near.map(p => p[2]).sort((a, b) => a - b);
const q = f => m[Math.min(m.length - 1, Math.floor(f * (m.length - 1)))];
return { tissue, dlexp, pvasc, pph, twin: [q(0.5), q(0.25), q(0.75)], twph: near.filter(p => p[2] >= 25).length / near.length };
}
function update() {
const fvc = parseFloat($('fvc').value), dlco = parseFloat($('dlco').value), spo2 = parseFloat($('spo2').value);
const note = $('note');
if (!(fvc > 0) || !(dlco > 0)) { $('verdict').textContent = 'Enter FVC and DLCO to see the estimates.'; return; }
const e = estimate(fvc, dlco);
$('pph').textContent = fmt(100 * e.pph) + '%';
$('pvasc').textContent = fmt(100 * e.pvasc) + '%';
$('tissue').textContent = fmt(e.tissue, 1) + ' mmHg';
$('dlexp').textContent = fmt(e.dlexp) + '%';
$('twins').textContent = `${fmt(e.twin[0], 1)} mmHg (IQR ${fmt(e.twin[1], 1)}–${fmt(e.twin[2], 1)})`;
$('twph').textContent = fmt(100 * e.twph) + '%';
$('verdict').textContent = e.pvasc > 0.5
? 'DLCO is lower than fibrosis alone explains: this pattern points to a vascular component.'
: 'DLCO is in line with the extent of fibrosis: a large vascular component is less likely.';
if (spo2 > 0) {
$('zrow').hidden = false;
$('zis').textContent = fmt(-11.9 + 0.272 * spo2 + 0.0659 * (100 - spo2) ** 2 + 3.06 * (fvc / dlco), 1) + ' mmHg';
} else { $('zrow').hidden = true; }
const msg = [];
if (fvc < COEF.fvc_range[0] || fvc > COEF.fvc_range[1]) msg.push(`FVC outside the simulated range (${fmt(COEF.fvc_range[0])}–${fmt(COEF.fvc_range[1])}%); estimates are extrapolated.`);
if (dlco < COEF.dlco_range[0] || dlco > COEF.dlco_range[1]) msg.push(`DLCO outside the simulated range (${fmt(COEF.dlco_range[0])}–${fmt(COEF.dlco_range[1])}%).`);
note.hidden = !msg.length; note.textContent = msg.join(' ');
draw(fvc, dlco);
}
function draw(fvc, dlco) {
const c = $('plot'), r = window.devicePixelRatio || 1, W = c.clientWidth, H = c.clientHeight;
c.width = W * r; c.height = H * r; const g = c.getContext('2d'); g.scale(r, r);
const pad = { l: 46, r: 12, t: 10, b: 36 }, x0 = 30, x1 = 95, y0 = 0, y1 = 80;
const X = v => pad.l + (v - x0) / (x1 - x0) * (W - pad.l - pad.r), Y = v => H - pad.b - (v - y0) / (y1 - y0) * (H - pad.t - pad.b);
g.clearRect(0, 0, W, H);
g.strokeStyle = css('--line'); g.fillStyle = css('--muted'); g.font = '12px "IBM Plex Sans", system-ui, sans-serif'; g.lineWidth = 1;
for (let v = 30; v <= 90; v += 10) { g.beginPath(); g.moveTo(X(v), Y(y0)); g.lineTo(X(v), Y(y1)); g.stroke(); g.fillText(v, X(v) - 7, H - pad.b + 16); }
for (let v = 0; v <= 80; v += 20) { g.beginPath(); g.moveTo(X(x0), Y(v)); g.lineTo(X(x1), Y(v)); g.stroke(); g.fillText(v, pad.l - 26, Y(v) + 4); }
g.fillText('FVC (% predicted)', W / 2 - 50, H - 4);
g.save(); g.translate(12, H / 2 + 50); g.rotate(-Math.PI / 2); g.fillText('DLCO (% predicted)', 0, 0); g.restore();
const lo = css('--exchange'), hi = css('--venous');
const mix = (a, b, t) => { const h = s => [1, 3, 5].map(i => parseInt(s.slice(i, i + 2), 16)); const A = h(a), B = h(b);
return `rgb(${A.map((v, i) => Math.round(v + (B[i] - v) * t)).join(',')})`; };
for (const p of COH) {
const t = Math.max(0, Math.min(1, (p[2] - 15) / 20));
g.fillStyle = mix(lo, hi, t); g.globalAlpha = 0.75;
g.beginPath(); g.arc(X(p[0]), Y(p[1]), 3.2, 0, 2 * Math.PI); g.fill();
}
g.globalAlpha = 1; g.strokeStyle = css('--ink'); g.lineWidth = 1.6; g.beginPath();
for (let v = COEF.fvc_range[0]; v <= COEF.fvc_range[1]; v += 0.5) { const yv = Math.exp(polyval(COEF.dlco_curve, v)); v === COEF.fvc_range[0] ? g.moveTo(X(v), Y(yv)) : g.lineTo(X(v), Y(yv)); }
g.stroke();
if (fvc >= x0 && fvc <= x1 && dlco >= y0 && dlco <= y1) {
g.strokeStyle = css('--oxygen'); g.lineWidth = 2.5; g.beginPath(); g.arc(X(fvc), Y(dlco), 8, 0, 2 * Math.PI); g.stroke();
}
}
['fvc', 'dlco', 'spo2'].forEach(id => $(id).addEventListener('input', update));
window.addEventListener('resize', () => update());
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', update);
update();
</script>
</body>
</html>