| <!DOCTYPE html> |
| <html lang="zh-TW"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>DSA 統計分析</title> |
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> |
| <style> |
| .chart-container { position: relative; height: 540px; background: #fff; border: 1px solid #dee2e6; border-radius: 6px; padding: 1rem; margin-bottom: 1.5rem; } |
| .ab-table { font-size: .82rem; } |
| .strength-strong { --bs-table-bg: #f8d7da; background-color: #f8d7da !important; font-weight: bold; } |
| .strength-strong > td { background-color: #f8d7da !important; } |
| .strength-weak { --bs-table-bg: #fff9c4; background-color: #fff9c4 !important; } |
| .strength-weak > td { background-color: #fff9c4 !important; } |
| .spec-strong-bg { background-color: #f8d7da; padding: 3px 6px; border-radius: 3px; margin-bottom: 2px; display: block; } |
| .spec-weak-bg { background-color: #fff9c4; padding: 3px 6px; border-radius: 3px; display: block; } |
| </style> |
| </head> |
| <body> |
| <div class="container py-4" style="max-width: 1200px;"> |
| <div class="d-flex justify-content-between align-items-center mb-3"> |
| <div><h2 class="mb-0">DSA 統計分析</h2></div> |
| <div> |
| <a href="/" class="btn btn-dark btn-sm me-1">首頁</a> |
| <a href="/dsa/history" class="btn btn-outline-secondary btn-sm me-1">歷史紀錄</a> |
| {% if chart_no and patient %} |
| <a href="/dsa/history/{{ chart_no }}" class="btn btn-outline-info btn-sm me-1">此病患歷史</a> |
| {% endif %} |
| <a href="/dsa/new" class="btn btn-outline-primary btn-sm">新增資料</a> |
| </div> |
| </div> |
|
|
| <form class="row g-2 mb-4" method="get" action="/dsa/analysis"> |
| <div class="col-sm-6"> |
| <select class="form-select" id="patientSelect" onchange="selectPatient(this.value)"> |
| <option value="">-- 選擇病人 --</option> |
| {% for p in patients %} |
| <option value="{{ p.chart_no }}" {{ 'selected' if p.chart_no == chart_no else '' }}> |
| {{ p.patient_name }} ({{ p.chart_no }}) |
| </option> |
| {% endfor %} |
| </select> |
| <input type="hidden" name="chart_no" id="chartNoInput" value="{{ chart_no }}"> |
| </div> |
| </form> |
| <script> |
| function selectPatient(val) { |
| if (!val) return; |
| document.getElementById('chartNoInput').value = val; |
| document.getElementById('chartNoInput').form.submit(); |
| } |
| </script> |
|
|
| {% if patient %} |
| <div class="card mb-3"> |
| <div class="card-header fw-bold">{{ patient.patient_name }} ({{ patient.chart_no }}) — Donor HLA</div> |
| <div class="card-body" style="font-size:.85rem;"> |
| <style>.donor-hla { width: 80px !important; padding: 2px 4px !important; text-align: center; }</style> |
| <div class="row"> |
| <div class="col-md-5"> |
| <div class="fw-bold mb-1">Serology</div> |
| <table class="table table-sm table-bordered mb-1"><tbody> |
| <tr><td class="fw-bold">A</td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-1" oninput="highlightDSA()"></td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-A-2" oninput="highlightDSA()"></td> |
| <td class="fw-bold">DR</td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-1" oninput="highlightDSA()"></td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DR-2" oninput="highlightDSA()"></td></tr> |
| <tr><td class="fw-bold">B</td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-1" oninput="highlightDSA()"></td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-B-2" oninput="highlightDSA()"></td> |
| <td class="fw-bold">DQ</td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-1" oninput="highlightDSA()"></td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DQ-2" oninput="highlightDSA()"></td></tr> |
| <tr><td class="fw-bold">Cw</td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-1" oninput="highlightDSA()"></td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-Cw-2" oninput="highlightDSA()"></td> |
| <td class="fw-bold">DP</td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-1" oninput="highlightDSA()"></td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-DP-2" oninput="highlightDSA()"></td></tr> |
| </tbody></table> |
| </div> |
| <div class="col-md-7"> |
| <div class="fw-bold mb-1">DNA Typing</div> |
| <table class="table table-sm table-bordered mb-1"><tbody> |
| <tr><td class="fw-bold">A*</td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-1" oninput="highlightDSA()"></td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-A-2" oninput="highlightDSA()"></td> |
| <td class="fw-bold">DRB1*</td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-1" oninput="highlightDSA()"></td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DRB1-2" oninput="highlightDSA()"></td></tr> |
| <tr><td class="fw-bold">B*</td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-1" oninput="highlightDSA()"></td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-B-2" oninput="highlightDSA()"></td> |
| <td class="fw-bold">DQB1*</td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-1" oninput="highlightDSA()"></td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQB1-2" oninput="highlightDSA()"></td></tr> |
| <tr><td class="fw-bold">C*</td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-1" oninput="highlightDSA()"></td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-C-2" oninput="highlightDSA()"></td> |
| <td class="fw-bold">DQA1*</td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-1" oninput="highlightDSA()"></td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DQA1-2" oninput="highlightDSA()"></td></tr> |
| <tr><td></td><td></td><td></td> |
| <td class="fw-bold">DPB1*</td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-1" oninput="highlightDSA()"></td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPB1-2" oninput="highlightDSA()"></td></tr> |
| <tr><td></td><td></td><td></td> |
| <td class="fw-bold">DPA1*</td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-1" oninput="highlightDSA()"></td> |
| <td><input type="text" class="form-control form-control-sm donor-hla" id="donor-dna-DPA1-2" oninput="highlightDSA()"></td></tr> |
| </tbody></table> |
| </div> |
| </div> |
| <div class="text-end"> |
| <button class="btn btn-sm btn-outline-danger" onclick="saveDonorHLA(this)">存檔</button> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| function getDonorSet() { |
| const s = new Set(); |
| ['A','B','Cw','DR','DQ','DP'].forEach(l => { |
| [1,2].forEach(n => { |
| const el = document.getElementById('donor-' + l + '-' + n); |
| if (el && el.value.trim()) s.add(l + el.value.trim()); |
| }); |
| }); |
| ['A','B','C','DRB1','DQB1','DQA1','DPB1','DPA1'].forEach(l => { |
| [1,2].forEach(n => { |
| const el = document.getElementById('donor-dna-' + l + '-' + n); |
| if (el && el.value.trim()) s.add(l + '*' + el.value.trim()); |
| }); |
| }); |
| return s; |
| } |
| function highlightTokensInCell(cell, donorSet) { |
| if (!cell) return; |
| const text = cell.dataset.orig || cell.textContent; |
| cell.dataset.orig = text; |
| const tokens = text.split(/,\s*/); |
| cell.innerHTML = tokens.map(tok => { |
| const t = tok.trim(); |
| if (!t) return tok; |
| for (const ag of donorSet) { |
| if (t.includes(ag)) return '<span style="color:#dc3545;font-weight:bold;">' + tok + '</span>'; |
| } |
| return tok; |
| }).join(', '); |
| } |
| function highlightSpecText(specEl, donorSet) { |
| if (!specEl) return; |
| const orig = specEl.dataset.orig || specEl.textContent; |
| specEl.dataset.orig = orig; |
| const tokens = orig.match(/\S+?\([^)]*\)|\S+/g) || []; |
| specEl.innerHTML = tokens.map(seg => { |
| const m = seg.match(/^([^(]+)(\([^)]*\))?$/); |
| if (!m) return seg; |
| const sero = m[1]; |
| const paren = m[2] || ''; |
| if (!/^[A-Za-z]/.test(sero)) return seg; |
| let isDSA = false; |
| for (const ag of donorSet) { |
| if (!ag) continue; |
| if (sero === ag || (paren && paren.indexOf(ag) >= 0)) { isDSA = true; break; } |
| } |
| if (isDSA) return '<span style="color:#dc3545;font-weight:bold;">' + seg + '</span>'; |
| const seroHtml = '<span style="color:#1E40AF;font-weight:bold;">' + sero + '</span>'; |
| if (!paren) return seroHtml; |
| const inner = paren.slice(1, -1); |
| const alleleHtml = inner.split(/\s+/).map(a => |
| a ? '<span style="color:#374151;">' + a + '</span>' : a |
| ).join(' '); |
| return seroHtml + '(' + alleleHtml + ')'; |
| }).join(' '); |
| } |
| function highlightDSA() { |
| const ds = getDonorSet(); |
| document.querySelectorAll('.spec-cell').forEach(el => highlightSpecText(el, ds)); |
| document.querySelectorAll('.ab-table tbody tr').forEach(tr => { |
| highlightTokensInCell(tr.cells[0], ds); |
| highlightTokensInCell(tr.cells[1], ds); |
| }); |
| ['chart1', 'chart2'].forEach(cid => { |
| const fn = window['rebuildDsaChart_' + cid]; |
| if (typeof fn === 'function') fn(); |
| }); |
| } |
| function setDsaFilter(chartId, btn, val) { |
| btn.parentElement.querySelectorAll('.btn').forEach(b => b.classList.remove('active')); |
| btn.classList.add('active'); |
| const fn = window['updateDsaFilter_' + chartId]; |
| if (typeof fn === 'function') fn(val); |
| } |
| function saveDonorHLA(btn) { |
| const obj = {}; |
| document.querySelectorAll('.donor-hla').forEach(el => { |
| if (el.value.trim()) obj[el.id] = el.value.trim(); |
| }); |
| btn.disabled = true; |
| fetch('/save_donor_hla', { |
| method: 'POST', headers: {'Content-Type': 'application/json'}, |
| body: JSON.stringify({ chart_no: '{{ patient.chart_no }}', donor_hla: JSON.stringify(obj) }) |
| }).then(r => r.json()).then(d => { |
| btn.disabled = false; |
| if (d.ok) { btn.classList.remove('btn-outline-danger'); btn.classList.add('btn-danger'); btn.textContent = '已存檔'; } |
| }); |
| highlightDSA(); |
| } |
| window.addEventListener('DOMContentLoaded', function() { |
| const saved = {{ donor_hla | default('', true) | tojson }}; |
| if (saved) { |
| try { |
| const obj = JSON.parse(saved); |
| Object.keys(obj).forEach(k => { |
| const el = document.getElementById(k); |
| if (el) el.value = obj[k]; |
| }); |
| } catch(e) {} |
| } |
| |
| ['chart1', 'chart2'].forEach(cid => { |
| const fn = window['applyDsaDefault_' + cid]; |
| if (typeof fn === 'function') fn(); |
| }); |
| highlightDSA(); |
| }); |
| </script> |
|
|
| <h5>Reports</h5> |
| <table class="table table-bordered table-sm bg-white mb-4" style="font-size:.9rem;"> |
| <thead class="table-light"><tr><th>Date</th><th>Class</th><th>%SA</th><th>Overall</th><th>Specificity</th></tr></thead> |
| <tbody> |
| {% for r in reports %} |
| <tr> |
| <td>{{ r.report_date }}</td> |
| <td>{{ r.dsa_class }}</td> |
| <td class="fw-bold">{{ r.pct_sa }}%</td> |
| <td><span class="badge {{ 'bg-danger' if r.overall == 'Positive' else 'bg-success' }}">{{ r.overall }}</span></td> |
| <td class="small" style="max-width:400px;"> |
| {%- set spec_text = (r.specificity or '') | replace('>', '>') -%} |
| {%- if 'Weak (MFI' in spec_text -%} |
| {%- set parts = spec_text.split('Weak (MFI', 1) -%} |
| <div class="spec-cell spec-strong-bg">{{ parts[0]|trim }}</div> |
| <div class="spec-cell spec-weak-bg">Weak (MFI{{ parts[1] }}</div> |
| {%- else -%} |
| <div class="spec-cell">{{ spec_text or '(-)' }}</div> |
| {%- endif -%} |
| </td> |
| </tr> |
| {% endfor %} |
| </tbody> |
| </table> |
|
|
| {% for comp_label, comp_id, comp_dates, comp_antigens in [ |
| ('DSA Class I', 'chart1', comparison_class1.dates if comparison_class1 else [], comparison_class1.antigens if comparison_class1 else []), |
| ('DSA Class II', 'chart2', comparison_class2.dates if comparison_class2 else [], comparison_class2.antigens if comparison_class2 else []) |
| ] %} |
| {% if comp_dates and comp_dates | length >= 2 %} |
| <h5>{{ comp_label }} — MFI Trend</h5> |
| <div class="mb-2"> |
| <small class="text-muted me-2">點圖例可顯示/隱藏抗體</small> |
| <div class="btn-group btn-group-sm" id="filter-{{ comp_id }}"> |
| <button type="button" class="btn btn-outline-secondary" onclick="setDsaFilter('{{ comp_id }}',this,'all')">全部</button> |
| <button type="button" class="btn btn-outline-danger" onclick="setDsaFilter('{{ comp_id }}',this,'dsa')">DSA</button> |
| <button type="button" class="btn btn-outline-secondary" onclick="setDsaFilter('{{ comp_id }}',this,'10')">Top 10</button> |
| <button type="button" class="btn btn-outline-secondary" onclick="setDsaFilter('{{ comp_id }}',this,'20')">Top 20</button> |
| </div> |
| </div> |
| <div class="chart-container"><canvas id="{{ comp_id }}"></canvas></div> |
| <script> |
| (function() { |
| const dates = {{ comp_dates | tojson }}; |
| const allData = [ |
| {% for ag in comp_antigens %} |
| { antigen: '{{ ag.antigen }}', allele: '{{ ag.allele }}', |
| values: [{% for d in comp_dates %}{{ ag.mfi_by_date.get(d, {}).get('max_mfi', 'null') }}{% if not loop.last %},{% endif %}{% endfor %}] }, |
| {% endfor %} |
| ]; |
| function getColor(ag, i) { |
| const a = ['#e53935','#d81b60','#8e24aa','#5e35b1','#3949ab','#1e88e5','#ef5350','#ec407a']; |
| const b = ['#00897b','#43a047','#7cb342','#c0ca33','#26a69a','#66bb6a','#9ccc65','#d4e157']; |
| const c = ['#ff8f00','#ff6f00','#f57f17','#ffb300','#ffa000']; |
| if (ag.startsWith('A') || ag.startsWith('DR')) return a[i % a.length]; |
| if (ag.startsWith('B') || ag.startsWith('DQ')) return b[i % b.length]; |
| return c[i % c.length]; |
| } |
| allData.sort((x, y) => Math.max(...y.values.filter(v=>v!==null)) - Math.max(...x.values.filter(v=>v!==null))); |
| let chartInstance = null; |
| let currentFilter = '20'; |
| function matchesDonor(ag, ds) { |
| if (ag.antigen && ds.has(ag.antigen)) return true; |
| if (ag.allele && ds.has(ag.allele)) return true; |
| return false; |
| } |
| function buildChart(limit) { |
| if (limit) currentFilter = limit; |
| let data; |
| if (currentFilter === 'dsa') { |
| const ds = (typeof getDonorSet === 'function') ? getDonorSet() : new Set(); |
| data = allData.filter(ag => matchesDonor(ag, ds)); |
| } else if (currentFilter === 'all') { |
| data = allData; |
| } else { |
| data = allData.slice(0, parseInt(currentFilter)); |
| } |
| const datasets = data.map((ag, i) => ({ |
| label: ag.antigen + (ag.allele && ag.allele !== ag.antigen ? ' (' + ag.allele + ')' : ''), |
| data: ag.values, borderColor: getColor(ag.antigen, i), |
| backgroundColor: getColor(ag.antigen, i) + '33', |
| borderWidth: 2, pointRadius: 4, pointHoverRadius: 6, tension: 0.3, spanGaps: true, |
| })); |
| if (chartInstance) chartInstance.destroy(); |
| chartInstance = new Chart(document.getElementById('{{ comp_id }}'), { |
| type: 'line', data: { labels: dates, datasets: datasets }, |
| options: { responsive: true, maintainAspectRatio: false, |
| interaction: { mode: 'nearest', intersect: false }, |
| plugins: { legend: { position: 'right', labels: { font: { size: 11 }, boxWidth: 12, padding: 6 } } }, |
| scales: { y: { title: { display: true, text: 'Max MFI' }, beginAtZero: true }, |
| x: { title: { display: true, text: 'Report Date' } } } } |
| }); |
| } |
| window['rebuildDsaChart_{{ comp_id }}'] = function() { buildChart(); }; |
| window['updateDsaFilter_{{ comp_id }}'] = function(val) { buildChart(val); }; |
| function applyDefault() { |
| let defaultFilter = '20'; |
| try { |
| const ds = (typeof getDonorSet === 'function') ? getDonorSet() : new Set(); |
| if (ds.size > 0 && allData.some(ag => matchesDonor(ag, ds))) defaultFilter = 'dsa'; |
| } catch (e) {} |
| buildChart(defaultFilter); |
| document.querySelectorAll('#filter-{{ comp_id }} .btn').forEach(b => { |
| b.classList.remove('active'); |
| if ((defaultFilter === 'dsa' && b.textContent === 'DSA') || |
| (defaultFilter === '20' && b.textContent === 'Top 20')) |
| b.classList.add('active'); |
| }); |
| } |
| window['applyDsaDefault_{{ comp_id }}'] = applyDefault; |
| applyDefault(); |
| })(); |
| </script> |
| {% endif %} |
| {% endfor %} |
|
|
| <h5>Antibody Strength</h5> |
| {% for r in reports %} |
| {% if r.antibodies %} |
| <div class="card mb-3"> |
| <div class="card-header"><strong>{{ r.dsa_class }}</strong> — {{ r.report_date }} |
| <span class="fw-bold ms-1">%SA {{ r.pct_sa }}%</span></div> |
| <div class="card-body p-2"> |
| <table class="table table-sm table-bordered ab-table mb-0"> |
| <thead class="table-light"><tr> |
| <th>Antigen</th><th>Allele</th><th>Strength</th><th class="text-end">Max MFI</th> |
| <th class="text-end">Mean MFI</th><th class="text-center">No of Beads</th><th>Bead IDs</th> |
| </tr></thead> |
| <tbody> |
| {% for a in r.antibodies %} |
| <tr class="{% if a.strength == 'Strong' %}strength-strong{% elif a.strength == 'Weak' %}strength-weak{% endif %}"> |
| <td class="fw-bold">{{ a.antigen }}</td><td class="small">{{ a.allele }}</td> |
| <td>{{ a.strength or '' }}</td> |
| <td class="text-end">{{ a.max_mfi|int }}</td> |
| <td class="text-end">{{ a.mean_mfi|int }}</td> |
| <td class="text-center">{{ a.no_of_beads }}</td> |
| <td class="small text-muted">{{ a.bead_ids }}</td> |
| </tr> |
| {% endfor %} |
| </tbody> |
| </table> |
| </div> |
| </div> |
| {% endif %} |
| {% endfor %} |
| {% else %} |
| <div class="alert alert-info">請先選擇病人。</div> |
| {% endif %} |
| </div> |
| </body> |
| </html> |
|
|