| <!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>
|
|
|
|
|
| <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 %}
|
|
|
| <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">
|
|
|
| <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="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>
|
|
|
|
|
| <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);
|
|
|
| tbl.querySelectorAll('[data-date]').forEach(el => {
|
| el.style.display = selected.has(el.dataset.date) ? '' : 'none';
|
| });
|
|
|
|
|
| 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); });
|
| });
|
|
|
| dateCells.forEach(c => { if (!placed.has(c)) row.insertBefore(c, anchor); });
|
| });
|
|
|
| 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">▲ +' + diff + '</span>';
|
| else if (diff < -500) trendTd.innerHTML = '<span class="mfi-down">▼ ' + diff + '</span>';
|
| else trendTd.innerHTML = '<span class="text-muted">–</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) {
|
|
|
| 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>
|
|
|
|
|
| {% 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();
|
|
|
| ['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());
|
| });
|
| });
|
|
|
| ['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());
|
| });
|
| });
|
|
|
| ['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) {
|
|
|
| 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));
|
| }
|
|
|
| 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(); };
|
|
|
|
|
| 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);
|
|
|
|
|
|
|
| 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>
|
|
|
|
|
| {% if comp_dates | length >= 2 %}
|
| <h6 class="mt-3">MFI 比較表</h6>
|
| <div class="small text-muted mb-1">
|
| <span class="mfi-up">▲ 上升</span>
|
| <span class="mfi-down ms-2">▼ 下降</span>
|
| <span class="mfi-new ms-2">新增</span>
|
| <span class="mfi-gone ms-2">✖ 消失</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">▲ +{{ diff|int }}</span>
|
| {% elif diff < -500 %}<span class="mfi-down">▼ {{ diff|int }}</span>
|
| {% else %}<span class="text-muted">–</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">✖ 消失</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>
|
|
|
| window.addEventListener('DOMContentLoaded', function() {
|
|
|
| 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); }
|
| }
|
|
|
| setTimeout(highlightDSA, 1000);
|
| });
|
|
|
| function setFilter(chartId, btn, val) {
|
|
|
| btn.parentElement.querySelectorAll('.btn').forEach(b => b.classList.remove('active'));
|
| btn.classList.add('active');
|
| window['updateChart_' + chartId](val);
|
| }
|
|
|
| function dsaCheckAndSave() {
|
| highlightDSA();
|
|
|
| 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) {
|
|
|
| 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() {
|
|
|
| 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());
|
| });
|
| });
|
|
|
|
|
| colorizeSpecCells(donorSet);
|
|
|
| if (donorSet.size === 0) return;
|
|
|
|
|
| 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' : '';
|
| }
|
| });
|
|
|
|
|
| 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>
|
|
|