Upload templates/analysis.html with huggingface_hub
Browse files- 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">▲ 上升</span>
|
| 230 |
+
<span class="mfi-down ms-2">▼ 下降</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">▲ +{{ diff|int }}</span>
|
| 266 |
+
{% elif diff < -500 %}<span class="mfi-down">▼ {{ diff|int }}</span>
|
| 267 |
+
{% else %}<span class="text-muted">–</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>
|