plokmii commited on
Commit
8ea4c48
·
verified ·
1 Parent(s): be2aef7

Upload templates/analysis.html with huggingface_hub

Browse files
Files changed (1) hide show
  1. templates/analysis.html +385 -0
templates/analysis.html ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-TW">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>PRA 統計分析</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
9
+ <style>
10
+ .chart-container { position: relative; height: 420px; background: #fff; border: 1px solid #dee2e6; border-radius: 6px; padding: 1rem; margin-bottom: 1.5rem; }
11
+ .ab-table { font-size: .82rem; }
12
+ .mfi-up { color: #dc3545; }
13
+ .mfi-down { color: #198754; }
14
+ .mfi-new { color: #0d6efd; font-weight: bold; }
15
+ .mfi-gone { color: #adb5bd; text-decoration: line-through; }
16
+ </style>
17
+ </head>
18
+ <body>
19
+ <div class="container py-4" style="max-width: 1200px;">
20
+ <div class="d-flex justify-content-between align-items-center mb-3">
21
+ <div>
22
+ <h2 class="mb-0">PRA 統計分析</h2>
23
+ <small class="text-danger">病患需有兩筆以上不同日期的紀錄才會顯示趨勢圖表</small>
24
+ </div>
25
+ <div>
26
+ <a href="/" class="btn btn-dark btn-sm me-1">首頁</a>
27
+ <a href="/history" class="btn btn-outline-secondary btn-sm me-1">歷史紀錄</a>
28
+ <a href="/new" class="btn btn-outline-primary btn-sm">新增資料</a>
29
+ </div>
30
+ </div>
31
+
32
+ <!-- Patient selector -->
33
+ <form class="row g-2 mb-4" method="get" action="/analysis">
34
+ <div class="col-sm-4">
35
+ <select class="form-select" id="patientSelect" onchange="selectPatient(this.value)">
36
+ <option value="">-- 選擇病人 --</option>
37
+ {% for p in patients %}
38
+ <option value="{{ p.chart_no }}" {{ 'selected' if p.chart_no == chart_no else '' }}>
39
+ {{ p.patient_name }} ({{ p.chart_no }})
40
+ </option>
41
+ {% endfor %}
42
+ </select>
43
+ </div>
44
+ <div class="col-sm-5">
45
+ <input type="text" class="form-control" id="patientSearch" list="patientList"
46
+ placeholder="搜尋病患姓名或病歷號" autocomplete="off"
47
+ onchange="selectPatient(this.value)">
48
+ <datalist id="patientList">
49
+ {% for p in patients %}
50
+ <option value="{{ p.chart_no }}" label="{{ p.patient_name }} ({{ p.chart_no }})">
51
+ {% endfor %}
52
+ </datalist>
53
+ <input type="hidden" name="chart_no" id="chartNoInput" value="{{ chart_no }}">
54
+ </div>
55
+ </form>
56
+ <script>
57
+ function selectPatient(val) {
58
+ if (!val) return;
59
+ document.getElementById('chartNoInput').value = val;
60
+ document.getElementById('chartNoInput').form.submit();
61
+ }
62
+ {% if chart_no %}
63
+ document.getElementById('patientSearch').value = '{{ chart_no }}';
64
+ {% endif %}
65
+ </script>
66
+
67
+ {% if patient %}
68
+ <!-- Donor HLA Input -->
69
+ <div class="card mb-3">
70
+ <div class="card-header fw-bold">Donor HLA</div>
71
+ <div class="card-body">
72
+ <div class="row g-2">
73
+ <div class="col-md-6">
74
+ <div class="small fw-bold mb-1">Class I</div>
75
+ <div class="row g-1">
76
+ <div class="col-4"><div class="input-group input-group-sm"><span class="input-group-text">A</span><input type="text" class="form-control donor-hla" id="donor-A" placeholder="2,24" oninput="highlightDSA()"></div></div>
77
+ <div class="col-4"><div class="input-group input-group-sm"><span class="input-group-text">B</span><input type="text" class="form-control donor-hla" id="donor-B" placeholder="7,46" oninput="highlightDSA()"></div></div>
78
+ <div class="col-4"><div class="input-group input-group-sm"><span class="input-group-text">Cw</span><input type="text" class="form-control donor-hla" id="donor-Cw" placeholder="1,7" oninput="highlightDSA()"></div></div>
79
+ </div>
80
+ </div>
81
+ <div class="col-md-6">
82
+ <div class="small fw-bold mb-1">Class II</div>
83
+ <div class="row g-1">
84
+ <div class="col-3"><div class="input-group input-group-sm"><span class="input-group-text">DR</span><input type="text" class="form-control donor-hla" id="donor-DR" placeholder="4,7" oninput="highlightDSA()"></div></div>
85
+ <div class="col-3"><div class="input-group input-group-sm"><span class="input-group-text">DQ</span><input type="text" class="form-control donor-hla" id="donor-DQ" placeholder="7,9" oninput="highlightDSA()"></div></div>
86
+ <div class="col-3"><div class="input-group input-group-sm"><span class="input-group-text">DP</span><input type="text" class="form-control donor-hla" id="donor-DP" placeholder="2,4" oninput="highlightDSA()"></div></div>
87
+ <div class="col-3 d-flex align-items-end"><button class="btn btn-sm btn-outline-danger w-100" onclick="dsaCheckAndSave()">DSA確認</button></div>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ <div class="small text-muted mt-1">輸入數字,多個用逗號分隔(如 2,24)。符合的抗體會標紅。</div>
92
+ </div>
93
+ </div>
94
+ <div class="mb-3">
95
+ <h4>{{ patient.patient_name }} <span class="text-muted fs-6">({{ patient.chart_no }})</span></h4>
96
+ </div>
97
+
98
+ <!-- Reports summary -->
99
+ <table class="table table-bordered table-sm bg-white mb-4" style="font-size:.9rem;">
100
+ <thead class="table-light">
101
+ <tr><th>報告日期</th><th>Class</th><th>PRA%</th><th>Overall</th><th>Specificity</th></tr>
102
+ </thead>
103
+ <tbody>
104
+ {% for r in reports %}
105
+ <tr>
106
+ <td>{{ r.report_date }}</td>
107
+ <td>{{ r.pra_class }}</td>
108
+ <td class="fw-bold">{{ r.pra_percent }}%</td>
109
+ <td><span class="badge {{ 'bg-danger' if r.overall == 'Positive' else 'bg-success' }}">{{ r.overall }}</span></td>
110
+ <td class="small spec-cell" style="max-width:300px;">{{ r.specificity or '(-)' }}</td>
111
+ </tr>
112
+ {% endfor %}
113
+ </tbody>
114
+ </table>
115
+
116
+ <!-- MFI Trend Charts -->
117
+ {% for comp_label, comp_id, comp_dates, comp_antigens, comp_pra in [
118
+ ('PRA Class I', 'chart1', comparison_class1.dates if comparison_class1 else [],
119
+ comparison_class1.antigens if comparison_class1 else [],
120
+ comparison_class1.pra_by_date if comparison_class1 else {}),
121
+ ('PRA Class II', 'chart2', comparison_class2.dates if comparison_class2 else [],
122
+ comparison_class2.antigens if comparison_class2 else [],
123
+ comparison_class2.pra_by_date if comparison_class2 else {})
124
+ ] %}
125
+ {% if comp_dates and comp_dates | length >= 1 %}
126
+ <h5>{{ comp_label }} — MFI 趨勢圖</h5>
127
+ {% if comp_dates | length >= 2 %}
128
+ <div class="mb-2">
129
+ <small class="text-muted me-2">點圖例可顯示/隱藏抗體</small>
130
+ <div class="btn-group btn-group-sm" id="filter-{{ comp_id }}">
131
+ <button type="button" class="btn btn-outline-secondary" onclick="setFilter('{{ comp_id }}',this,'all')">全部</button>
132
+ <button type="button" class="btn btn-outline-danger" onclick="setFilter('{{ comp_id }}',this,'dsa')">DSA</button>
133
+ <button type="button" class="btn btn-outline-secondary" onclick="setFilter('{{ comp_id }}',this,'10')">Top 10</button>
134
+ <button type="button" class="btn btn-outline-secondary" onclick="setFilter('{{ comp_id }}',this,'20')">Top 20</button>
135
+ </div>
136
+ </div>
137
+ <div class="chart-container">
138
+ <canvas id="{{ comp_id }}"></canvas>
139
+ </div>
140
+
141
+ <script>
142
+ (function() {
143
+ const dates = {{ comp_dates | tojson }};
144
+ const allData = [
145
+ {% for ag in comp_antigens %}
146
+ { antigen: '{{ ag.antigen }}', allele: '{{ ag.allele }}',
147
+ values: [{% for d in comp_dates %}{{ ag.mfi_by_date.get(d, {}).get('max_mfi', 'null') }}{% if not loop.last %},{% endif %}{% endfor %}] },
148
+ {% endfor %}
149
+ ];
150
+
151
+ function getColor(ag, i) {
152
+ const ca = ['#e53935','#d81b60','#8e24aa','#5e35b1','#3949ab','#1e88e5','#ef5350','#ec407a'];
153
+ const cb = ['#00897b','#43a047','#7cb342','#c0ca33','#26a69a','#66bb6a','#9ccc65','#d4e157','#2e7d32','#558b2f'];
154
+ const cc = ['#ff8f00','#ff6f00','#f57f17','#ffb300','#ffa000'];
155
+ if (ag.startsWith('A') || ag.startsWith('DR')) return ca[i % ca.length];
156
+ if (ag.startsWith('B') || ag.startsWith('DQ')) return cb[i % cb.length];
157
+ return cc[i % cc.length];
158
+ }
159
+
160
+ allData.sort((a, b) => Math.max(...b.values.filter(v=>v!==null)) - Math.max(...a.values.filter(v=>v!==null)));
161
+ let chartInst = null;
162
+
163
+ function getDonorSet() {
164
+ const s = new Set();
165
+ ['A','B','Cw','DR','DQ','DP'].forEach(l => {
166
+ const el = document.getElementById('donor-' + l);
167
+ if (!el) return;
168
+ el.value.split(',').forEach(v => { v=v.trim(); if(v) s.add(l+v); });
169
+ });
170
+ return s;
171
+ }
172
+
173
+ function build(limit) {
174
+ let data;
175
+ if (limit === 'dsa') {
176
+ const ds = getDonorSet();
177
+ data = allData.filter(ag => ds.has(ag.antigen));
178
+ } else {
179
+ data = limit === 'all' ? allData : allData.slice(0, parseInt(limit));
180
+ }
181
+ const datasets = data.map((ag, i) => ({
182
+ label: ag.antigen + (ag.allele ? ' (' + ag.allele.split(',')[0].trim() + ')' : ''),
183
+ data: ag.values, borderColor: getColor(ag.antigen, i),
184
+ backgroundColor: getColor(ag.antigen, i) + '33',
185
+ borderWidth: 2, pointRadius: 5, pointHoverRadius: 7, tension: 0.3, spanGaps: true,
186
+ }));
187
+ if (chartInst) chartInst.destroy();
188
+ chartInst = new Chart(document.getElementById('{{ comp_id }}'), {
189
+ type: 'line',
190
+ data: { labels: dates, datasets },
191
+ options: {
192
+ responsive: true, maintainAspectRatio: false,
193
+ interaction: { mode: 'nearest', intersect: false },
194
+ plugins: {
195
+ legend: { position: 'right', labels: { font: { size: 11 }, boxWidth: 12, padding: 6 } },
196
+ tooltip: { callbacks: { label: c => c.dataset.label + ': ' + (c.parsed.y !== null ? c.parsed.y.toLocaleString() : '-') } }
197
+ },
198
+ scales: {
199
+ y: { title: { display: true, text: 'Max MFI' }, beginAtZero: true },
200
+ x: { title: { display: true, text: '報告日期' } }
201
+ }
202
+ }
203
+ });
204
+ }
205
+ window['updateChart_{{ comp_id }}'] = function(val) { build(val || 'all'); highlightDSA(); };
206
+
207
+ // Default: DSA if donor HLA exists, otherwise all
208
+ var defaultFilter = 'all';
209
+ if (getDonorSet().size > 0) {
210
+ var dsaData = allData.filter(ag => getDonorSet().has(ag.antigen));
211
+ if (dsaData.length > 0) defaultFilter = 'dsa';
212
+ }
213
+ build(defaultFilter);
214
+ // Set active button
215
+ document.querySelectorAll('#filter-{{ comp_id }} .btn').forEach(b => {
216
+ b.classList.remove('active');
217
+ if ((defaultFilter === 'dsa' && b.textContent === 'DSA') ||
218
+ (defaultFilter === 'all' && b.textContent === '全部'))
219
+ b.classList.add('active');
220
+ });
221
+ })();
222
+ </script>
223
+ {% endif %}
224
+
225
+ <!-- MFI Comparison Table -->
226
+ {% if comp_dates | length >= 2 %}
227
+ <h6 class="mt-3">MFI 比較表</h6>
228
+ <div class="small text-muted mb-1">
229
+ <span class="mfi-up">&#9650; 上升</span>
230
+ <span class="mfi-down ms-2">&#9660; 下降</span>
231
+ <span class="mfi-new ms-2">新增</span>
232
+ <span class="mfi-gone ms-2">消失</span>
233
+ </div>
234
+ <div style="overflow-x:auto;">
235
+ <table class="table table-bordered table-sm ab-table bg-white mb-4">
236
+ <thead class="table-light">
237
+ <tr>
238
+ <th>Antigen</th><th>Allele</th>
239
+ {% for d in comp_dates %}
240
+ <th class="text-center" colspan="2">{{ d }}<br><small>PRA {{ comp_pra.get(d,'?') }}%</small></th>
241
+ {% endfor %}
242
+ <th class="text-center">趨勢</th>
243
+ </tr>
244
+ <tr><th></th><th></th>
245
+ {% for d in comp_dates %}<th class="text-end small">Max</th><th class="text-end small">Mean</th>{% endfor %}
246
+ <th></th>
247
+ </tr>
248
+ </thead>
249
+ <tbody>
250
+ {% for ag in comp_antigens %}
251
+ <tr>
252
+ <td class="fw-bold ag-cell" data-ag="{{ ag.antigen }}">{{ ag.antigen }}</td><td class="small">{{ ag.allele }}</td>
253
+ {% for d in comp_dates %}
254
+ {% set val = ag.mfi_by_date.get(d) %}
255
+ {% if val %}
256
+ <td class="text-end {% if val.max_mfi >= 10000 %}text-danger fw-bold{% elif val.max_mfi >= 5000 %}text-warning{% endif %}">{{ val.max_mfi|int }}</td>
257
+ <td class="text-end">{{ val.mean_mfi|int }}</td>
258
+ {% else %}<td class="text-center text-muted">-</td><td class="text-center text-muted">-</td>{% endif %}
259
+ {% endfor %}
260
+ <td class="text-center">
261
+ {% set first_val = ag.mfi_by_date.get(comp_dates[0]) %}
262
+ {% set last_val = ag.mfi_by_date.get(comp_dates[-1]) %}
263
+ {% if first_val and last_val %}
264
+ {% set diff = last_val.max_mfi - first_val.max_mfi %}
265
+ {% if diff > 500 %}<span class="mfi-up">&#9650; +{{ diff|int }}</span>
266
+ {% elif diff < -500 %}<span class="mfi-down">&#9660; {{ diff|int }}</span>
267
+ {% else %}<span class="text-muted">&ndash;</span>{% endif %}
268
+ {% elif last_val and not first_val %}<span class="mfi-new">新增</span>
269
+ {% elif first_val and not last_val %}<span class="mfi-gone">消失</span>
270
+ {% endif %}
271
+ </td>
272
+ </tr>
273
+ {% endfor %}
274
+ </tbody>
275
+ </table>
276
+ </div>
277
+ {% endif %}
278
+ {% endif %}
279
+ {% endfor %}
280
+
281
+ {% elif chart_no %}
282
+ <div class="alert alert-warning">找不到病歷號 {{ chart_no }}</div>
283
+ {% else %}
284
+ <div class="text-center text-muted py-5">請選擇病人查看統計分析</div>
285
+ {% endif %}
286
+ </div>
287
+ <script>
288
+ // Load saved donor HLA on page load
289
+ window.addEventListener('DOMContentLoaded', function() {
290
+ const saved = {{ donor_hla | default("", true) | tojson }};
291
+ if (saved) {
292
+ try {
293
+ const obj = JSON.parse(saved);
294
+ ['A','B','Cw','DR','DQ','DP'].forEach(l => {
295
+ const el = document.getElementById('donor-' + l);
296
+ if (el && obj[l]) el.value = obj[l];
297
+ });
298
+ // delay to wait for charts to render
299
+ setTimeout(highlightDSA, 1000);
300
+ } catch(e) { console.log('donor_hla parse error', e); }
301
+ }
302
+ });
303
+
304
+ function setFilter(chartId, btn, val) {
305
+ // Toggle active state
306
+ btn.parentElement.querySelectorAll('.btn').forEach(b => b.classList.remove('active'));
307
+ btn.classList.add('active');
308
+ window['updateChart_' + chartId](val);
309
+ }
310
+
311
+ function dsaCheckAndSave() {
312
+ highlightDSA();
313
+ // Save to DB
314
+ const obj = {};
315
+ ['A','B','Cw','DR','DQ','DP'].forEach(l => {
316
+ const el = document.getElementById('donor-' + l);
317
+ if (el && el.value.trim()) obj[l] = el.value.trim();
318
+ });
319
+ fetch('/save_donor_hla', {
320
+ method: 'POST',
321
+ headers: {'Content-Type': 'application/json'},
322
+ body: JSON.stringify({ chart_no: '{{ chart_no }}', donor_hla: JSON.stringify(obj) })
323
+ }).then(r => r.json()).then(d => {
324
+ if (d.ok) {
325
+ const btn = document.querySelector('[onclick="dsaCheckAndSave()"]');
326
+ btn.textContent = 'Saved!';
327
+ setTimeout(() => btn.textContent = 'DSA確認', 1500);
328
+ }
329
+ });
330
+ }
331
+
332
+ function highlightDSA() {
333
+ // Build donor HLA set: {A2, A24, B7, B46, Cw1, DR4, DQ7, DP2, ...}
334
+ const donorSet = new Set();
335
+ const loci = ['A','B','Cw','DR','DQ','DP'];
336
+ loci.forEach(locus => {
337
+ const el = document.getElementById('donor-' + locus);
338
+ if (!el) return;
339
+ el.value.split(',').forEach(v => {
340
+ v = v.trim();
341
+ if (v) donorSet.add(locus + v);
342
+ });
343
+ });
344
+ if (donorSet.size === 0) return;
345
+
346
+ // 1. Highlight ag-cell (MFI comparison table antigen column)
347
+ document.querySelectorAll('.ag-cell').forEach(td => {
348
+ const ag = td.dataset.ag;
349
+ const match = donorSet.has(ag);
350
+ td.style.color = match ? '#dc3545' : '';
351
+ td.style.background = match ? '#fff0f0' : '';
352
+ td.parentElement.style.background = match ? '#fff0f0' : '';
353
+ });
354
+
355
+ // 2. Highlight Specificity text in reports table
356
+ document.querySelectorAll('.spec-cell').forEach(td => {
357
+ const orig = td.dataset.orig || td.textContent;
358
+ td.dataset.orig = orig;
359
+ const words = orig.split(/\s+/);
360
+ td.innerHTML = words.map(w => {
361
+ // Extract sero name: "A24" from "A24" or "A11(A*11:02)"
362
+ const m = w.match(/^(A\d+|B\d+|Cw\d+|DR\d+|DQ\d+|DP\d+)/);
363
+ if (m && donorSet.has(m[1])) {
364
+ return '<span style="color:#dc3545;font-weight:bold;background:#fff0f0;padding:0 2px;border-radius:2px;">' + w + '</span>';
365
+ }
366
+ return w;
367
+ }).join(' ');
368
+ });
369
+
370
+ // 3. Highlight chart legend labels
371
+ document.querySelectorAll('canvas').forEach(canvas => {
372
+ const chart = Chart.getChart(canvas);
373
+ if (!chart) return;
374
+ chart.data.datasets.forEach(ds => {
375
+ // Remove existing ★ first
376
+ const cleanLabel = ds.label.replace(/ ★$/, '');
377
+ const m = cleanLabel.match(/^(A\d+|B\d+|Cw\d+|DR\d+|DQ\d+|DP\d+)/);
378
+ ds.label = (m && donorSet.has(m[1])) ? cleanLabel + ' ★' : cleanLabel;
379
+ });
380
+ chart.update();
381
+ });
382
+ }
383
+ </script>
384
+ </body>
385
+ </html>