pradsa / templates /analysis.html
plokmii's picture
Upload templates/analysis.html with huggingface_hub
1fedd73 verified
Raw
History Blame Contribute Delete
32 kB
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PRA 統計分析</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: 640px; background: #fff; border: 1px solid #dee2e6; border-radius: 6px; padding: 1rem; margin-bottom: 1.5rem; }
.ab-table { font-size: .82rem; }
.mfi-up { color: #dc3545; }
.mfi-down { color: #198754; }
.mfi-new { color: #0d6efd; font-weight: bold; }
.mfi-gone { color: #000; font-weight: bold; }
</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">PRA 統計分析</h2>
</div>
<div>
<a href="/" class="btn btn-dark btn-sm me-1">首頁</a>
<a href="/history" class="btn btn-outline-secondary btn-sm me-1">歷史紀錄</a>
{% if chart_no and patient %}
<a href="/history/{{ chart_no }}" class="btn btn-outline-info btn-sm me-1">此病患歷史</a>
{% endif %}
<a href="/new" class="btn btn-outline-primary btn-sm">新增資料</a>
</div>
</div>
<!-- Patient selector -->
<form class="row g-2 mb-4" method="get" action="/analysis">
<div class="col-sm-4">
<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>
</div>
<div class="col-sm-5">
<input type="text" class="form-control" id="patientSearch" list="patientList" autocomplete="off"
onchange="selectPatient(this.value)">
<datalist id="patientList">
{% for p in patients %}
<option value="{{ p.chart_no }}" label="{{ p.patient_name }} ({{ p.chart_no }})">
{% endfor %}
</datalist>
<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();
}
{% if chart_no %}
document.getElementById('patientSearch').value = '{{ chart_no }}';
{% endif %}
</script>
{% if patient %}
<!-- Donor HLA Input -->
<div class="card mb-3">
<div class="card-header fw-bold">Donor HLA</div>
<div class="card-body" style="font-size:.85rem;">
<style>.donor-hla { width: 65px !important; padding: 2px 4px !important; text-align: center; }</style>
<div class="row">
<!-- Serology -->
<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>
<!-- DNA Typing -->
<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="dsaCheckAndSave()">存檔</button>
</div>
</div>
</div>
<div class="mb-3">
<h4>{{ patient.patient_name }} <span class="text-muted fs-6">({{ patient.chart_no }})</span></h4>
</div>
<!-- Reports summary -->
<table class="table table-bordered table-sm bg-white mb-4" style="font-size:.9rem;">
<thead class="table-light">
<tr><th style="width:32px;"><input type="checkbox" id="chk-all" checked onchange="toggleAllReports(this)"></th>
<th style="width:72px;">順序</th>
<th>報告日期</th><th>Class</th><th>PRA%</th><th>Overall</th><th>Specificity</th></tr>
</thead>
<tbody id="reports-tbody">
{% for r in reports %}
<tr data-chart="{{ 'chart1' if r.pra_class == 'PRA Class I' else 'chart2' }}">
<td><input type="checkbox" class="chk-report" checked
data-chart="{{ 'chart1' if r.pra_class == 'PRA Class I' else 'chart2' }}"
data-label="{{ r.chart_label }}" onchange="onReportChk()"></td>
<td class="text-nowrap">
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1" onclick="moveRow(this, -1)" title="向上"></button>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1" onclick="moveRow(this, 1)" title="向下"></button>
</td>
<td>{{ r.chart_label }}</td>
<td>{{ r.pra_class }}</td>
<td class="fw-bold">{{ r.pra_percent }}%</td>
<td><span class="badge {{ 'bg-danger' if r.overall == 'Positive' else 'bg-success' }}">{{ r.overall }}</span></td>
<td class="small spec-cell" style="max-width:300px;">{{ r.specificity or '(-)' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<script>
function toggleAllReports(el) {
document.querySelectorAll('.chk-report').forEach(c => { c.checked = el.checked; });
onReportChk();
}
function onReportChk() {
['chart1', 'chart2'].forEach(cid => {
const fn = window['rebuildChart_' + cid];
if (fn) fn();
rebuildCompTable(cid);
});
const all = document.querySelectorAll('.chk-report');
const chkd = document.querySelectorAll('.chk-report:checked');
const chkAll = document.getElementById('chk-all');
if (chkAll) chkAll.checked = all.length === chkd.length;
}
function rebuildCompTable(chartId) {
const tbl = document.querySelector('.comp-table[data-chart-id="' + chartId + '"]');
if (!tbl) return;
const orderedLabels = (typeof getSelectedLabels === 'function') ? getSelectedLabels(chartId) : [];
const selected = new Set(orderedLabels);
// 1. show/hide cells by selection
tbl.querySelectorAll('[data-date]').forEach(el => {
el.style.display = selected.has(el.dataset.date) ? '' : 'none';
});
// 2. 依 orderedLabels 實際重排每列的日期 cells
// 用 row.lastElementChild 當 anchor(最後一格是趨勢/空 th)
const allRows = [...tbl.querySelectorAll('thead tr'), ...tbl.querySelectorAll('tbody tr')];
allRows.forEach(row => {
const dateCells = Array.from(row.querySelectorAll('[data-date]'));
if (!dateCells.length) return;
const byDate = {};
dateCells.forEach(c => {
(byDate[c.dataset.date] = byDate[c.dataset.date] || []).push(c);
});
const anchor = row.lastElementChild; // 趨勢欄
dateCells.forEach(c => c.remove());
const placed = new Set();
orderedLabels.forEach(d => {
(byDate[d] || []).forEach(c => { row.insertBefore(c, anchor); placed.add(c); });
});
// 未在 orderedLabels 的(被隱藏)放回尾端保留
dateCells.forEach(c => { if (!placed.has(c)) row.insertBefore(c, anchor); });
});
// 3. 以新 DOM 順序重算 trend
tbl.querySelectorAll('tbody tr').forEach(tr => {
const trendTd = tr.querySelector('.trend-cell');
if (!trendTd) return;
const visible = Array.from(tr.querySelectorAll('[data-kind="max"]'))
.filter(td => td.style.display !== 'none');
const parseMax = td => {
const v = td.dataset.max;
return (v === undefined || v === '') ? null : parseInt(v);
};
if (visible.length < 2) { trendTd.innerHTML = ''; return; }
const first = parseMax(visible[0]);
const last = parseMax(visible[visible.length - 1]);
if (first !== null && last !== null) {
const diff = last - first;
if (diff > 500) trendTd.innerHTML = '<span class="mfi-up">&#9650; +' + diff + '</span>';
else if (diff < -500) trendTd.innerHTML = '<span class="mfi-down">&#9660; ' + diff + '</span>';
else trendTd.innerHTML = '<span class="text-muted">&ndash;</span>';
} else if (last !== null && first === null) {
trendTd.innerHTML = '<span class="mfi-new">新增</span>';
} else if (first !== null && last === null) {
trendTd.innerHTML = '<span class="mfi-gone">\u2716 消失</span>';
} else {
trendTd.innerHTML = '';
}
});
}
function getSelectedLabels(chartId) {
// 依 tbody DOM 順序回傳,允許使用者重新排序
const rows = document.querySelectorAll('#reports-tbody tr[data-chart="' + chartId + '"]');
const labels = [];
rows.forEach(tr => {
const chk = tr.querySelector('.chk-report');
if (chk && chk.checked) labels.push(chk.dataset.label);
});
return labels;
}
function moveRow(btn, dir) {
const tr = btn.closest('tr');
if (!tr) return;
const sibling = dir < 0 ? tr.previousElementSibling : tr.nextElementSibling;
if (!sibling) return;
if (dir < 0) tr.parentNode.insertBefore(tr, sibling);
else tr.parentNode.insertBefore(sibling, tr);
onReportChk();
}
</script>
<!-- MFI Trend Charts -->
{% for comp_label, comp_id, comp_dates, comp_antigens, comp_pra in [
('PRA Class I', 'chart1', comparison_class1.dates if comparison_class1 else [],
comparison_class1.antigens if comparison_class1 else [],
comparison_class1.pra_by_date if comparison_class1 else {}),
('PRA Class II', 'chart2', comparison_class2.dates if comparison_class2 else [],
comparison_class2.antigens if comparison_class2 else [],
comparison_class2.pra_by_date if comparison_class2 else {})
] %}
{% if comp_dates and comp_dates | length >= 1 %}
<h5>{{ comp_label }} — MFI 趨勢圖</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="setFilter('{{ comp_id }}',this,'all')">全部</button>
<button type="button" class="btn btn-outline-danger" onclick="setFilter('{{ comp_id }}',this,'dsa')">DSA</button>
<button type="button" class="btn btn-outline-secondary" onclick="setFilter('{{ comp_id }}',this,'10')">Top 10</button>
<button type="button" class="btn btn-outline-secondary" onclick="setFilter('{{ 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('mean_mfi', 0) }}{% if not loop.last %},{% endif %}{% endfor %}] },
{% endfor %}
];
function getColor(ag, i) {
const ca = ['#e53935','#d81b60','#8e24aa','#5e35b1','#3949ab','#1e88e5','#ef5350','#ec407a'];
const cb = ['#00897b','#43a047','#7cb342','#c0ca33','#26a69a','#66bb6a','#9ccc65','#d4e157','#2e7d32','#558b2f'];
const cc = ['#ff8f00','#ff6f00','#f57f17','#ffb300','#ffa000'];
if (ag.startsWith('A') || ag.startsWith('DR')) return ca[i % ca.length];
if (ag.startsWith('B') || ag.startsWith('DQ')) return cb[i % cb.length];
return cc[i % cc.length];
}
allData.sort((a, b) => Math.max(...b.values.filter(v=>v!==null)) - Math.max(...a.values.filter(v=>v!==null)));
let chartInst = null;
function getDonorSet() {
const s = new Set();
// Serology: each locus has -1 and -2 fields
['A','B','Cw','DR','DQ','DP'].forEach(l => {
[1,2].forEach(n => {
const el = document.getElementById('donor-' + l + '-' + n);
if (!el || !el.value.trim()) return;
s.add(l + el.value.trim());
});
});
// DNA Typing Class I: A*, B*, C*
['A','B','C'].forEach(l => {
[1,2].forEach(n => {
const el = document.getElementById('donor-dna-' + l + '-' + n);
if (!el || !el.value.trim()) return;
s.add(l + '*' + el.value.trim());
});
});
// DNA Typing Class II: DRB1*, DQA1*, DQB1*, DPA1*, DPB1*
['DRB1','DQA1','DQB1','DPA1','DPB1'].forEach(l => {
[1,2].forEach(n => {
const el = document.getElementById('donor-dna-' + l + '-' + n);
if (!el || !el.value.trim()) return;
s.add(l + '*' + el.value.trim());
});
});
return s;
}
function matchesDonor(ag, ds) {
// 資料已以 allele 為主展開,直接比對 antigen(sero) 或 allele
if (ag.antigen && ds.has(ag.antigen)) return true;
if (ag.allele && ds.has(ag.allele)) return true;
return false;
}
let currentFilter = 'all';
function build(limit) {
if (limit) currentFilter = limit;
let data;
if (currentFilter === 'dsa') {
const ds = getDonorSet();
data = allData.filter(ag => matchesDonor(ag, ds));
} else {
data = currentFilter === 'all' ? allData : allData.slice(0, parseInt(currentFilter));
}
// 套用「報告日期」checkbox 篩選,並依表格 DOM 順序排列 x 軸
const orderedLabels = (typeof getSelectedLabels === 'function')
? getSelectedLabels('{{ comp_id }}') : dates.slice();
const keepIdx = [];
const visibleDates = [];
orderedLabels.forEach(lbl => {
const idx = dates.indexOf(lbl);
if (idx >= 0) { keepIdx.push(idx); visibleDates.push(lbl); }
});
const datasets = data.map((ag, i) => ({
label: ag.antigen + (ag.allele && ag.allele !== ag.antigen ? ' (' + ag.allele + ')' : ''),
antigen: ag.antigen,
allele: ag.allele,
data: keepIdx.map(j => ag.values[j]),
borderColor: getColor(ag.antigen, i),
backgroundColor: getColor(ag.antigen, i) + '33',
borderWidth: 2, pointRadius: 5, pointHoverRadius: 7, tension: 0.3, spanGaps: true,
}));
if (chartInst) chartInst.destroy();
chartInst = new Chart(document.getElementById('{{ comp_id }}'), {
type: 'line',
data: { labels: visibleDates, datasets },
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false },
plugins: {
legend: { position: 'right', maxWidth: 520, labels: { font: { size: 10 }, boxWidth: 10, padding: 3 } },
tooltip: { callbacks: { label: c => c.dataset.label + ': ' + (c.parsed.y !== null ? c.parsed.y.toLocaleString() : '-') } }
},
scales: {
y: { title: { display: true, text: 'Mean MFI' }, beginAtZero: true },
x: { title: { display: true, text: '報告日期' } }
}
}
});
}
window['updateChart_{{ comp_id }}'] = function(val) { build(val || 'all'); highlightDSA(); };
window['rebuildChart_{{ comp_id }}'] = function() { build(); };
// Default: DSA if donor HLA exists, otherwise all
var defaultFilter = 'all';
if (getDonorSet().size > 0) {
var _ds = getDonorSet();
var dsaData = allData.filter(ag => matchesDonor(ag, _ds));
if (dsaData.length > 0) defaultFilter = 'dsa';
}
build(defaultFilter);
// 注意:rebuildCompTable 的初始呼叫放到 DOMContentLoaded,
// 因為此 IIFE 執行時 .comp-table 還在後面還沒 parse 到 DOM
// Set active button
document.querySelectorAll('#filter-{{ comp_id }} .btn').forEach(b => {
b.classList.remove('active');
if ((defaultFilter === 'dsa' && b.textContent === 'DSA') ||
(defaultFilter === 'all' && b.textContent === '全部'))
b.classList.add('active');
});
})();
</script>
<!-- MFI Comparison Table -->
{% if comp_dates | length >= 2 %}
<h6 class="mt-3">MFI 比較表</h6>
<div class="small text-muted mb-1">
<span class="mfi-up">&#9650; 上升</span>
<span class="mfi-down ms-2">&#9660; 下降</span>
<span class="mfi-new ms-2">新增</span>
<span class="mfi-gone ms-2">&#10006; 消失</span>
</div>
<div style="overflow-x:auto;">
<table class="table table-bordered table-sm ab-table bg-white mb-4 comp-table" data-chart-id="{{ comp_id }}">
<thead class="table-light">
<tr>
<th>Antigen</th><th>Allele</th>
{% for d in comp_dates %}
<th class="text-center" colspan="2" data-date="{{ d }}">{{ d }}<br><small>PRA {{ comp_pra.get(d,'?') }}%</small></th>
{% endfor %}
<th class="text-center">趨勢</th>
</tr>
<tr><th></th><th></th>
{% for d in comp_dates %}<th class="text-end small" data-date="{{ d }}" data-kind="max">Max</th><th class="text-end small" data-date="{{ d }}" data-kind="mean">Mean</th>{% endfor %}
<th></th>
</tr>
</thead>
<tbody>
{% for ag in comp_antigens %}
<tr>
<td class="fw-bold ag-cell" data-ag="{{ ag.antigen }}">{{ ag.antigen }}</td><td class="fw-bold allele-cell" data-allele="{{ ag.allele }}">{{ ag.allele }}</td>
{% for d in comp_dates %}
{% set val = ag.mfi_by_date.get(d) %}
{% if val %}
<td class="text-end" data-date="{{ d }}" data-kind="max" data-max="{{ val.max_mfi|int }}">{{ val.max_mfi|int }}</td>
<td class="text-end" data-date="{{ d }}" data-kind="mean">{{ val.mean_mfi|int }}</td>
{% else %}<td class="text-center text-muted" data-date="{{ d }}" data-kind="max">-</td><td class="text-center text-muted" data-date="{{ d }}" data-kind="mean">-</td>{% endif %}
{% endfor %}
<td class="text-center trend-cell">
{% set first_val = ag.mfi_by_date.get(comp_dates[0]) %}
{% set last_val = ag.mfi_by_date.get(comp_dates[-1]) %}
{% if first_val and last_val %}
{% set diff = last_val.max_mfi - first_val.max_mfi %}
{% if diff > 500 %}<span class="mfi-up">&#9650; +{{ diff|int }}</span>
{% elif diff < -500 %}<span class="mfi-down">&#9660; {{ diff|int }}</span>
{% else %}<span class="text-muted">&ndash;</span>{% endif %}
{% elif last_val and not first_val %}<span class="mfi-new">新增</span>
{% elif first_val and not last_val %}<span class="mfi-gone">&#10006; 消失</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endif %}
{% endfor %}
{% elif chart_no %}
<div class="alert alert-warning">找不到病歷號 {{ chart_no }}</div>
{% else %}
<div class="text-center text-muted py-5">請選擇病人查看統計分析</div>
{% endif %}
</div>
<script>
// Load saved donor HLA on page load
window.addEventListener('DOMContentLoaded', function() {
// MFI 比較表:依 reports DOM 順序(= chart x 軸順序)重排欄位 + 重算趨勢
if (typeof rebuildCompTable === 'function') {
['chart1', 'chart2'].forEach(cid => rebuildCompTable(cid));
}
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) { console.log('donor_hla parse error', e); }
}
// 一律跑(沒 donor 也要套藍/灰預設色)
setTimeout(highlightDSA, 1000);
});
function setFilter(chartId, btn, val) {
// Toggle active state
btn.parentElement.querySelectorAll('.btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
window['updateChart_' + chartId](val);
}
function dsaCheckAndSave() {
highlightDSA();
// Save to DB
const obj = {};
document.querySelectorAll('.donor-hla').forEach(el => {
if (el.value.trim()) obj[el.id] = el.value.trim();
});
fetch('/save_donor_hla', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ chart_no: '{{ chart_no }}', donor_hla: JSON.stringify(obj) })
}).then(r => r.json()).then(d => {
if (d.ok) {
const btn = document.querySelector('[onclick="dsaCheckAndSave()"]');
btn.textContent = '已存檔';
}
});
}
function colorizeSpecCells(donorSet) {
// Specificity tokens: sero 藍、allele 灰、DSA 紅。即使 donorSet 為空也跑藍/灰。
document.querySelectorAll('.spec-cell').forEach(td => {
const orig = td.dataset.orig || td.textContent;
td.dataset.orig = orig;
const tokens = orig.match(/\S+?\([^)]*\)|\S+/g) || [];
td.innerHTML = tokens.map(seg => {
const m = seg.match(/^([^(]+)(\([^)]*\))?$/);
if (!m) return seg;
const sero = m[1];
const paren = m[2] || '';
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;background:#fff0f0;padding:0 2px;border-radius:2px;">' + 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() {
// Build donor HLA set from both sero and DNA inputs
const donorSet = new Set();
['A','B','Cw','DR','DQ','DP'].forEach(locus => {
[1,2].forEach(n => {
const el = document.getElementById('donor-' + locus + '-' + n);
if (el && el.value.trim()) donorSet.add(locus + el.value.trim());
});
});
['A','B','C','DRB1','DQB1','DQA1','DPB1','DPA1'].forEach(locus => {
[1,2].forEach(n => {
const el = document.getElementById('donor-dna-' + locus + '-' + n);
if (el && el.value.trim()) donorSet.add(locus + '*' + el.value.trim());
});
});
// 1. Spec-cell 藍/灰/紅(無條件跑,沒 donor 也要藍/灰)
colorizeSpecCells(donorSet);
if (donorSet.size === 0) return;
// 2. Highlight ag-cell + allele-cell (MFI comparison/antibody strength tables)
document.querySelectorAll('.ag-cell').forEach(td => {
const ag = td.dataset.ag;
const row = td.parentElement;
const alleleCell = row.querySelector('.allele-cell') || row.querySelector('td:nth-child(2)');
const alleleVal = alleleCell ? (alleleCell.dataset.allele || alleleCell.textContent) : '';
const alleles = alleleVal.split(',').map(a => a.trim()).filter(a => a);
const agMatch = donorSet.has(ag);
const alleleMatch = alleles.some(a => donorSet.has(a));
const isMatch = agMatch || alleleMatch;
td.style.color = isMatch ? '#dc3545' : '';
td.style.background = isMatch ? '#fff0f0' : '';
row.style.background = isMatch ? '#fff0f0' : '';
if (alleleCell) {
alleleCell.style.color = alleleMatch ? '#dc3545' : (isMatch ? '' : '');
alleleCell.style.background = isMatch ? '#fff0f0' : '';
}
});
// 3. Highlight chart legend labels
document.querySelectorAll('canvas').forEach(canvas => {
const chart = Chart.getChart(canvas);
if (!chart) return;
chart.data.datasets.forEach(ds => {
const cleanLabel = ds.label.replace(/ ★$/, '');
const labelMatch = (ds.antigen && donorSet.has(ds.antigen)) ||
(ds.allele && donorSet.has(ds.allele));
ds.label = labelMatch ? cleanLabel + ' ★' : cleanLabel;
});
chart.update();
});
}
</script>
</body>
</html>