allelix 1.8.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- allelix/__init__.py +12 -0
- allelix/annotators/__init__.py +90 -0
- allelix/annotators/alphamissense.py +228 -0
- allelix/annotators/base.py +214 -0
- allelix/annotators/cadd.py +283 -0
- allelix/annotators/clinvar.py +404 -0
- allelix/annotators/gnomad.py +212 -0
- allelix/annotators/gwas.py +354 -0
- allelix/annotators/pharmgkb.py +406 -0
- allelix/annotators/snpedia.py +276 -0
- allelix/cli.py +1524 -0
- allelix/compare.py +149 -0
- allelix/config.py +143 -0
- allelix/data/__init__.py +3 -0
- allelix/data/high_value_snps.yaml +64 -0
- allelix/databases/__init__.py +30 -0
- allelix/databases/_versions.py +16 -0
- allelix/databases/alphamissense_loader.py +48 -0
- allelix/databases/cadd_loader.py +49 -0
- allelix/databases/cpic_loader.py +234 -0
- allelix/databases/gnomad_loader.py +49 -0
- allelix/databases/gwas_loader.py +546 -0
- allelix/databases/loader_utils.py +80 -0
- allelix/databases/manager.py +515 -0
- allelix/databases/pharmgkb_loader.py +437 -0
- allelix/databases/schema.py +165 -0
- allelix/databases/snpedia_loader.py +44 -0
- allelix/databases/snpedia_parser.py +342 -0
- allelix/exporters/__init__.py +3 -0
- allelix/exporters/plink.py +144 -0
- allelix/models.py +117 -0
- allelix/parsers/__init__.py +73 -0
- allelix/parsers/_helpers.py +41 -0
- allelix/parsers/ancestrydna.py +130 -0
- allelix/parsers/base.py +97 -0
- allelix/parsers/ftdna.py +129 -0
- allelix/parsers/livingdna.py +121 -0
- allelix/parsers/myhappygenes.py +135 -0
- allelix/parsers/myheritage.py +118 -0
- allelix/parsers/twentythreeandme.py +150 -0
- allelix/py.typed +0 -0
- allelix/reports/__init__.py +40 -0
- allelix/reports/_pipeline.py +497 -0
- allelix/reports/diff.py +169 -0
- allelix/reports/high_value.py +133 -0
- allelix/reports/html.py +1130 -0
- allelix/reports/json_report.py +163 -0
- allelix/reports/methylation.py +50 -0
- allelix/reports/terminal.py +203 -0
- allelix/utils/__init__.py +3 -0
- allelix/utils/allele.py +87 -0
- allelix/utils/build_detect.py +203 -0
- allelix-1.8.1.dist-info/METADATA +276 -0
- allelix-1.8.1.dist-info/RECORD +58 -0
- allelix-1.8.1.dist-info/WHEEL +5 -0
- allelix-1.8.1.dist-info/entry_points.txt +2 -0
- allelix-1.8.1.dist-info/licenses/LICENSE +671 -0
- allelix-1.8.1.dist-info/top_level.txt +1 -0
allelix/reports/html.py
ADDED
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 dial481
|
|
3
|
+
"""Self-contained HTML report renderer.
|
|
4
|
+
|
|
5
|
+
The output is a single ``.html`` file with inline CSS and JS. No external
|
|
6
|
+
assets, works from ``file://``. Per ADR-0003, every annotation carries its
|
|
7
|
+
source attribution and the page header restates the informational posture.
|
|
8
|
+
|
|
9
|
+
v1.8.0 redesign: 5-column table (Magnitude, Gene, Genotype, Repute,
|
|
10
|
+
Summary) with annotations grouped by ``(rsid, genotype_match)``. Clicking a
|
|
11
|
+
row opens a sliding detail sidebar showing all source annotations vertically.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import html
|
|
17
|
+
import json
|
|
18
|
+
from collections import defaultdict
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from allelix import __version__
|
|
23
|
+
from allelix.reports import REGULATORY_NOTICE, atomic_write_text
|
|
24
|
+
from allelix.reports._pipeline import rollup_gwas_duplicates
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Iterable
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from allelix.models import Annotation
|
|
31
|
+
from allelix.reports._pipeline import AnalysisResult
|
|
32
|
+
from allelix.reports.diff import DiffResult
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Repute classification
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
_BAD_SIGNIFICANCE = frozenset(
|
|
40
|
+
{
|
|
41
|
+
"clinvar_pathogenic",
|
|
42
|
+
"clinvar_pathogenic/likely_pathogenic",
|
|
43
|
+
"clinvar_likely_pathogenic",
|
|
44
|
+
"clinvar_risk_factor",
|
|
45
|
+
"snpedia_bad",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_GOOD_SIGNIFICANCE = frozenset(
|
|
50
|
+
{
|
|
51
|
+
"clinvar_benign",
|
|
52
|
+
"clinvar_benign/likely_benign",
|
|
53
|
+
"clinvar_likely_benign",
|
|
54
|
+
"snpedia_good",
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _classify_repute(significance: str) -> str:
|
|
60
|
+
"""Derive CSS class from the significance field."""
|
|
61
|
+
sig = significance.lower()
|
|
62
|
+
if sig in _BAD_SIGNIFICANCE:
|
|
63
|
+
return "repute-bad"
|
|
64
|
+
if sig in _GOOD_SIGNIFICANCE:
|
|
65
|
+
return "repute-good"
|
|
66
|
+
return "repute-neutral"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_repute(ann: Annotation) -> str:
|
|
70
|
+
"""Return ``'bad'``, ``'good'``, or ``'neutral'`` for an annotation."""
|
|
71
|
+
return _classify_repute(ann.significance).removeprefix("repute-")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# HTML escaping
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _escape(value: str) -> str:
|
|
80
|
+
return html.escape(value or "", quote=True)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Static assets
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
_CSS = """\
|
|
88
|
+
:root {
|
|
89
|
+
--bg: #fafafa;
|
|
90
|
+
--bg-surface: #fff;
|
|
91
|
+
--text: #212121;
|
|
92
|
+
--text-muted: #757575;
|
|
93
|
+
--border: #e0e0e0;
|
|
94
|
+
--border-light: #f0f0f0;
|
|
95
|
+
--hover: rgba(0, 0, 0, 0.04);
|
|
96
|
+
--selected: rgba(25, 118, 210, 0.08);
|
|
97
|
+
--backdrop: rgba(0, 0, 0, 0.3);
|
|
98
|
+
--panel-bg: #fff;
|
|
99
|
+
--panel-shadow: rgba(0, 0, 0, 0.15);
|
|
100
|
+
--notice-bg: #fff8e1;
|
|
101
|
+
--notice-border: #f9a825;
|
|
102
|
+
--notice-warn-bg: #fff3e0;
|
|
103
|
+
--notice-warn-border: #e65100;
|
|
104
|
+
}
|
|
105
|
+
@media (prefers-color-scheme: dark) {
|
|
106
|
+
:root:not([data-theme="light"]) {
|
|
107
|
+
--bg: #121212;
|
|
108
|
+
--bg-surface: #1e1e1e;
|
|
109
|
+
--text: #e0e0e0;
|
|
110
|
+
--text-muted: #9e9e9e;
|
|
111
|
+
--border: #333;
|
|
112
|
+
--border-light: #2a2a2a;
|
|
113
|
+
--hover: rgba(255, 255, 255, 0.06);
|
|
114
|
+
--selected: rgba(100, 181, 246, 0.12);
|
|
115
|
+
--backdrop: rgba(0, 0, 0, 0.5);
|
|
116
|
+
--panel-bg: #1e1e1e;
|
|
117
|
+
--panel-shadow: rgba(0, 0, 0, 0.4);
|
|
118
|
+
--notice-bg: #332b00;
|
|
119
|
+
--notice-border: #f9a825;
|
|
120
|
+
--notice-warn-bg: #331a00;
|
|
121
|
+
--notice-warn-border: #e65100;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
[data-theme="dark"] {
|
|
125
|
+
--bg: #121212;
|
|
126
|
+
--bg-surface: #1e1e1e;
|
|
127
|
+
--text: #e0e0e0;
|
|
128
|
+
--text-muted: #9e9e9e;
|
|
129
|
+
--border: #333;
|
|
130
|
+
--border-light: #2a2a2a;
|
|
131
|
+
--hover: rgba(255, 255, 255, 0.06);
|
|
132
|
+
--selected: rgba(100, 181, 246, 0.12);
|
|
133
|
+
--backdrop: rgba(0, 0, 0, 0.5);
|
|
134
|
+
--panel-bg: #1e1e1e;
|
|
135
|
+
--panel-shadow: rgba(0, 0, 0, 0.4);
|
|
136
|
+
--notice-bg: #332b00;
|
|
137
|
+
--notice-border: #f9a825;
|
|
138
|
+
--notice-warn-bg: #331a00;
|
|
139
|
+
--notice-warn-border: #e65100;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
143
|
+
|
|
144
|
+
body {
|
|
145
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
146
|
+
font-size: 14px;
|
|
147
|
+
line-height: 1.5;
|
|
148
|
+
color: var(--text);
|
|
149
|
+
background: var(--bg);
|
|
150
|
+
padding: 24px;
|
|
151
|
+
margin: 0;
|
|
152
|
+
}
|
|
153
|
+
h1 { margin-bottom: .25rem; }
|
|
154
|
+
.subtitle { color: var(--text-muted); margin-top: 0; }
|
|
155
|
+
|
|
156
|
+
.notice {
|
|
157
|
+
background: var(--notice-bg, #fff8e1); border-left: 4px solid var(--notice-border, #f9a825);
|
|
158
|
+
padding: 1rem; margin: 1.5rem 0; border-radius: 4px; font-size: .95rem;
|
|
159
|
+
}
|
|
160
|
+
.notice-warn {
|
|
161
|
+
background: var(--notice-warn-bg, #fff3e0);
|
|
162
|
+
border-left: 4px solid var(--notice-warn-border, #e65100);
|
|
163
|
+
padding: 1rem; margin: 1.5rem 0; border-radius: 4px; font-size: .95rem;
|
|
164
|
+
}
|
|
165
|
+
.education {
|
|
166
|
+
background: var(--bg-surface); border-left: 4px solid var(--border);
|
|
167
|
+
padding: 1rem 1.25rem; margin: 1.5rem 0; border-radius: 4px; font-size: .9rem;
|
|
168
|
+
}
|
|
169
|
+
.education h2 { margin-top: 0; font-size: 1.05rem; }
|
|
170
|
+
.education h3 { font-size: .95rem; margin: .75rem 0 .25rem; }
|
|
171
|
+
.education p { margin: .35rem 0; }
|
|
172
|
+
details.education summary { cursor: pointer; font-size: 1.05rem; }
|
|
173
|
+
details.education[open] summary { margin-bottom: .5rem; }
|
|
174
|
+
|
|
175
|
+
/* Summary cards */
|
|
176
|
+
.summary {
|
|
177
|
+
display: flex; flex-wrap: wrap; gap: .75rem; margin-bottom: 1.5rem;
|
|
178
|
+
}
|
|
179
|
+
.card {
|
|
180
|
+
background: var(--bg-surface); padding: .75rem 1rem; border-radius: 8px;
|
|
181
|
+
flex: 1 1 140px; min-width: 110px;
|
|
182
|
+
border: 1px solid var(--border);
|
|
183
|
+
}
|
|
184
|
+
.card .label {
|
|
185
|
+
font-size: .75rem; color: var(--text-muted);
|
|
186
|
+
text-transform: uppercase; letter-spacing: .05em;
|
|
187
|
+
}
|
|
188
|
+
.card .value { font-size: 1.2rem; font-weight: 600; }
|
|
189
|
+
.card-bad .value { color: #c62828; }
|
|
190
|
+
.card-good .value { color: #2e7d32; }
|
|
191
|
+
|
|
192
|
+
/* Controls */
|
|
193
|
+
.controls {
|
|
194
|
+
display: flex; flex-wrap: wrap; gap: .75rem;
|
|
195
|
+
align-items: center; margin-bottom: 1rem;
|
|
196
|
+
}
|
|
197
|
+
#search {
|
|
198
|
+
flex: 1 1 250px; padding: 8px 12px; border: 1px solid var(--border);
|
|
199
|
+
border-radius: 6px; font-size: 14px; outline: none;
|
|
200
|
+
background: var(--bg-surface); color: var(--text);
|
|
201
|
+
}
|
|
202
|
+
#search:focus { border-color: #1976d2; box-shadow: 0 0 0 2px rgba(25,118,210,.15); }
|
|
203
|
+
.filters { display: flex; gap: 4px; }
|
|
204
|
+
.filter-btn {
|
|
205
|
+
padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px;
|
|
206
|
+
background: var(--bg-surface); cursor: pointer; font-size: 13px; color: var(--text);
|
|
207
|
+
}
|
|
208
|
+
.filter-btn:hover { background: var(--hover); }
|
|
209
|
+
.filter-btn.active {
|
|
210
|
+
background: #1976d2; color: #fff; border-color: #1976d2;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* Table */
|
|
214
|
+
table {
|
|
215
|
+
width: 100%; table-layout: fixed; border-collapse: collapse;
|
|
216
|
+
background: var(--bg-surface); border: 1px solid var(--border);
|
|
217
|
+
border-radius: 8px; overflow: hidden;
|
|
218
|
+
}
|
|
219
|
+
thead th {
|
|
220
|
+
text-align: left; padding: 10px 12px;
|
|
221
|
+
font-size: 11px; font-weight: 600;
|
|
222
|
+
text-transform: uppercase; letter-spacing: 0.5px;
|
|
223
|
+
color: var(--text-muted); background: var(--bg);
|
|
224
|
+
border-bottom: 2px solid var(--border); user-select: none;
|
|
225
|
+
}
|
|
226
|
+
th.sortable { cursor: pointer; }
|
|
227
|
+
th.sortable:hover { color: var(--text); }
|
|
228
|
+
th .sort-arrow { font-size: .7rem; margin-left: .25rem; color: var(--text-muted); }
|
|
229
|
+
|
|
230
|
+
th:nth-child(1) { width: 50px; }
|
|
231
|
+
th:nth-child(2) { width: 100px; }
|
|
232
|
+
th:nth-child(3) { width: 80px; }
|
|
233
|
+
th:nth-child(4) { width: 80px; }
|
|
234
|
+
|
|
235
|
+
tbody td {
|
|
236
|
+
padding: 8px 12px; font-size: 13px;
|
|
237
|
+
border-bottom: 1px solid var(--border-light); vertical-align: middle;
|
|
238
|
+
}
|
|
239
|
+
tbody tr { cursor: pointer; transition: background-color 0.15s; }
|
|
240
|
+
tbody tr:hover { background: var(--hover); }
|
|
241
|
+
tbody tr.selected {
|
|
242
|
+
background: var(--selected);
|
|
243
|
+
box-shadow: inset 3px 0 0 #1976d2;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.gene-cell, .gt-cell {
|
|
247
|
+
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
|
248
|
+
font-size: 12px;
|
|
249
|
+
}
|
|
250
|
+
.sum-cell {
|
|
251
|
+
overflow: hidden; text-overflow: ellipsis;
|
|
252
|
+
white-space: nowrap; max-width: 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/* Badges and pills */
|
|
256
|
+
.badge {
|
|
257
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
258
|
+
width: 28px; height: 28px; border-radius: 50%;
|
|
259
|
+
font-weight: 700; font-size: 13px; line-height: 1;
|
|
260
|
+
}
|
|
261
|
+
.badge-bad { background: #ef5350; color: #fff; }
|
|
262
|
+
.badge-good { background: #66bb6a; color: #fff; }
|
|
263
|
+
.badge-neutral { background: #bdbdbd; color: #424242; }
|
|
264
|
+
|
|
265
|
+
.pill {
|
|
266
|
+
display: inline-block; padding: 2px 10px; border-radius: 12px;
|
|
267
|
+
font-size: 12px; font-weight: 600; white-space: nowrap;
|
|
268
|
+
}
|
|
269
|
+
.pill-bad { background: #ffebee; color: #c62828; border: 1px solid #ef9a9a; }
|
|
270
|
+
.pill-good { background: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7; }
|
|
271
|
+
.pill-neutral { background: var(--bg); color: var(--text-muted); border: 1px solid var(--border); }
|
|
272
|
+
|
|
273
|
+
/* Sidebar */
|
|
274
|
+
.sidebar-backdrop {
|
|
275
|
+
display: none; position: fixed;
|
|
276
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
277
|
+
background: var(--backdrop); z-index: 999;
|
|
278
|
+
}
|
|
279
|
+
.sidebar-backdrop.open { display: block; }
|
|
280
|
+
|
|
281
|
+
.detail-panel {
|
|
282
|
+
position: fixed; top: 0; right: 0;
|
|
283
|
+
width: 480px; height: 100vh;
|
|
284
|
+
background: var(--panel-bg);
|
|
285
|
+
box-shadow: -2px 0 12px var(--panel-shadow);
|
|
286
|
+
transform: translateX(100%);
|
|
287
|
+
transition: transform 0.25s ease;
|
|
288
|
+
z-index: 1000; overflow-y: auto;
|
|
289
|
+
display: flex; flex-direction: column;
|
|
290
|
+
}
|
|
291
|
+
.detail-panel.open { transform: translateX(0); }
|
|
292
|
+
|
|
293
|
+
.panel-header {
|
|
294
|
+
position: sticky; top: 0; background: var(--panel-bg); z-index: 1;
|
|
295
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
296
|
+
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
|
297
|
+
}
|
|
298
|
+
.panel-close {
|
|
299
|
+
font-size: 24px; background: none; border: none;
|
|
300
|
+
cursor: pointer; color: var(--text-muted); padding: 0 4px; line-height: 1;
|
|
301
|
+
}
|
|
302
|
+
.panel-close:hover { color: var(--text); }
|
|
303
|
+
.panel-nav {
|
|
304
|
+
display: flex; align-items: center; gap: 8px;
|
|
305
|
+
font-size: 13px; color: var(--text-muted);
|
|
306
|
+
}
|
|
307
|
+
.panel-nav button {
|
|
308
|
+
background: none; border: 1px solid var(--border); border-radius: 4px;
|
|
309
|
+
padding: 2px 8px; cursor: pointer; font-size: 13px; color: var(--text);
|
|
310
|
+
}
|
|
311
|
+
.panel-nav button:hover { background: var(--hover); }
|
|
312
|
+
|
|
313
|
+
.panel-body { padding: 16px; flex: 1; }
|
|
314
|
+
.panel-gene { font-size: 20px; font-weight: 700; margin: 0; }
|
|
315
|
+
.panel-rsid { font-size: 14px; color: var(--text-muted); margin: 2px 0 8px; }
|
|
316
|
+
.panel-meta { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; }
|
|
317
|
+
|
|
318
|
+
.field-row {
|
|
319
|
+
display: grid; grid-template-columns: 120px 1fr;
|
|
320
|
+
gap: 4px 12px; padding: 4px 0; font-size: 13px; line-height: 1.5;
|
|
321
|
+
}
|
|
322
|
+
.field-label { color: var(--text-muted); font-size: 12px; }
|
|
323
|
+
.field-value { color: var(--text); word-break: break-word; }
|
|
324
|
+
|
|
325
|
+
.source-header {
|
|
326
|
+
font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
|
|
327
|
+
color: var(--text-muted); border-bottom: 1px solid var(--border);
|
|
328
|
+
padding: 16px 0 4px; margin-top: 8px;
|
|
329
|
+
}
|
|
330
|
+
.ann-entry { padding-bottom: 8px; margin-bottom: 8px; }
|
|
331
|
+
.ann-entry:not(:last-child) {
|
|
332
|
+
border-bottom: 1px solid var(--border-light);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.empty { padding: 2rem; text-align: center; color: var(--text-muted); font-style: italic; }
|
|
336
|
+
|
|
337
|
+
footer {
|
|
338
|
+
margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--border);
|
|
339
|
+
font-size: .8rem; color: var(--text-muted);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/* Theme toggle */
|
|
343
|
+
.theme-toggle {
|
|
344
|
+
background: none; border: 1px solid var(--border); border-radius: 6px;
|
|
345
|
+
padding: 6px 10px; cursor: pointer; font-size: 16px; line-height: 1;
|
|
346
|
+
color: var(--text);
|
|
347
|
+
}
|
|
348
|
+
.theme-toggle:hover { background: var(--hover); }
|
|
349
|
+
|
|
350
|
+
@media (max-width: 1023px) {
|
|
351
|
+
.detail-panel { width: 100%; }
|
|
352
|
+
th:nth-child(2), td:nth-child(2) { width: 80px; }
|
|
353
|
+
th:nth-child(3), td:nth-child(3) { width: 60px; }
|
|
354
|
+
th:nth-child(4), td:nth-child(4) { width: 70px; }
|
|
355
|
+
}
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
_EDUCATION_SECTION = """\
|
|
359
|
+
<details class="education">
|
|
360
|
+
<summary><strong>Reading This Report</strong></summary>
|
|
361
|
+
|
|
362
|
+
<h3>Pseudogene cross-hybridization</h3>
|
|
363
|
+
<p>Some genes have known pseudogenes with high sequence similarity
|
|
364
|
+
(e.g., PKD1 has six pseudogenes sharing >97% identity). Array-based
|
|
365
|
+
genotyping probes can cross-hybridize, producing false genotype calls.
|
|
366
|
+
If a result seems inconsistent with your health history, confirmatory
|
|
367
|
+
testing by a different method is recommended.</p>
|
|
368
|
+
|
|
369
|
+
<h3>ClinVar aggregation</h3>
|
|
370
|
+
<p>ClinVar aggregates submissions from multiple sources. Different submitters
|
|
371
|
+
may classify the same variant differently, and significance labels may be
|
|
372
|
+
paired with conditions from a different submitter’s entry. Single-submitter
|
|
373
|
+
entries carry less weight than expert-panel-reviewed classifications.</p>
|
|
374
|
+
|
|
375
|
+
<h3>Carrier vs. affected</h3>
|
|
376
|
+
<p>A variant classified as pathogenic in a recessive condition requires two
|
|
377
|
+
copies to cause disease. If you are heterozygous (one copy), you are a carrier.
|
|
378
|
+
Carrier status does not typically cause symptoms but may be relevant for family
|
|
379
|
+
planning.</p>
|
|
380
|
+
|
|
381
|
+
<h3>Confirmatory testing</h3>
|
|
382
|
+
<p>No genotyping platform is 100% accurate. Clinically significant findings
|
|
383
|
+
should be confirmed with an independent method before making medical
|
|
384
|
+
decisions.</p>
|
|
385
|
+
</details>
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
_MAGNITUDE_LEGEND = """\
|
|
389
|
+
<details class="education">
|
|
390
|
+
<summary><strong>Understanding Magnitude Scores</strong></summary>
|
|
391
|
+
|
|
392
|
+
<p>Each annotation source uses its own criteria to assign a magnitude score
|
|
393
|
+
(0–10) that reflects clinical importance. Higher scores warrant more
|
|
394
|
+
attention. The score shown in the table is the maximum across all source
|
|
395
|
+
annotations for that variant.</p>
|
|
396
|
+
|
|
397
|
+
<h3>ClinVar (clinical significance)</h3>
|
|
398
|
+
<table style="width:auto; font-size:.85rem; margin:.5rem 0;">
|
|
399
|
+
<tr><td><span class="badge badge-bad" style="width:20px;height:20px;font-size:11px;\
|
|
400
|
+
">9</span></td><td>Pathogenic</td></tr>
|
|
401
|
+
<tr><td><span class="badge badge-bad" style="width:20px;height:20px;font-size:11px;\
|
|
402
|
+
">8</span></td><td>Pathogenic (single submitter / no assertion criteria)</td></tr>
|
|
403
|
+
<tr><td><span class="badge badge-bad" style="width:20px;height:20px;font-size:11px;\
|
|
404
|
+
">7</span></td><td>Likely pathogenic</td></tr>
|
|
405
|
+
<tr><td><span class="badge badge-neutral" style="width:20px;height:20px;font-size:11px;\
|
|
406
|
+
">5</span></td><td>Uncertain significance / conflicting</td></tr>
|
|
407
|
+
<tr><td><span class="badge badge-neutral" style="width:20px;height:20px;font-size:11px;\
|
|
408
|
+
">4</span></td><td>Risk factor / drug response / association</td></tr>
|
|
409
|
+
<tr><td><span class="badge badge-good" style="width:20px;height:20px;font-size:11px;\
|
|
410
|
+
">3</span></td><td>Likely benign</td></tr>
|
|
411
|
+
<tr><td><span class="badge badge-good" style="width:20px;height:20px;font-size:11px;\
|
|
412
|
+
">1</span></td><td>Benign</td></tr>
|
|
413
|
+
</table>
|
|
414
|
+
|
|
415
|
+
<h3>PharmGKB (pharmacogenomic evidence)</h3>
|
|
416
|
+
<table style="width:auto; font-size:.85rem; margin:.5rem 0;">
|
|
417
|
+
<tr><td><strong>9</strong></td><td>Level 1A — CPIC guideline or FDA label</td></tr>
|
|
418
|
+
<tr><td><strong>8</strong></td><td>Level 1B — strong clinical evidence</td></tr>
|
|
419
|
+
<tr><td><strong>6</strong></td><td>Level 2A — moderate evidence</td></tr>
|
|
420
|
+
<tr><td><strong>5</strong></td><td>Level 2B — moderate (weaker replication)</td></tr>
|
|
421
|
+
<tr><td><strong>4</strong></td><td>Level 3 — low evidence or annotation only</td></tr>
|
|
422
|
+
</table>
|
|
423
|
+
|
|
424
|
+
<h3>GWAS Catalog (trait associations)</h3>
|
|
425
|
+
<table style="width:auto; font-size:.85rem; margin:.5rem 0;">
|
|
426
|
+
<tr><td><strong>6</strong></td><td>p < 5×10<sup>-8</sup> (genome-wide)</td></tr>
|
|
427
|
+
<tr><td><strong>4</strong></td><td>p < 5×10<sup>-6</sup> (suggestive)</td></tr>
|
|
428
|
+
<tr><td><strong>3</strong></td><td>p < 1×10<sup>-4</sup> (nominal)</td></tr>
|
|
429
|
+
</table>
|
|
430
|
+
|
|
431
|
+
<h3>SNPedia (community-curated)</h3>
|
|
432
|
+
<p>Magnitude is assigned directly by community editors (0–10 scale).
|
|
433
|
+
Higher scores indicate greater clinical or personal relevance as judged by
|
|
434
|
+
contributors. See <a href="https://www.snpedia.com/index.php/Magnitude">\
|
|
435
|
+
SNPedia’s magnitude documentation</a> for details.</p>
|
|
436
|
+
|
|
437
|
+
<h3>CADD (variant deleteriousness)</h3>
|
|
438
|
+
<p>CADD PHRED scores rank how deleterious a variant is relative to all
|
|
439
|
+
possible human SNVs. Higher scores = more likely to be deleterious.</p>
|
|
440
|
+
<table style="width:auto; font-size:.85rem; margin:.5rem 0;">
|
|
441
|
+
<tr><td><strong>≥ 30</strong></td><td>Top 0.1% most deleterious</td></tr>
|
|
442
|
+
<tr><td><strong>≥ 20</strong></td><td>Top 1% most deleterious</td></tr>
|
|
443
|
+
<tr><td><strong>≥ 10</strong></td><td>Top 10% most deleterious</td></tr>
|
|
444
|
+
<tr><td><strong>< 10</strong></td><td>Below top 10%</td></tr>
|
|
445
|
+
</table>
|
|
446
|
+
|
|
447
|
+
<h3>AlphaMissense (missense pathogenicity)</h3>
|
|
448
|
+
<p>DeepMind’s protein-structure-based pathogenicity prediction for
|
|
449
|
+
missense variants. Score 0–1; higher = more likely pathogenic.</p>
|
|
450
|
+
<table style="width:auto; font-size:.85rem; margin:.5rem 0;">
|
|
451
|
+
<tr><td><strong>≥ 0.564</strong></td><td>Likely pathogenic</td></tr>
|
|
452
|
+
<tr><td><strong>0.340 – 0.564</strong></td><td>Ambiguous</td></tr>
|
|
453
|
+
<tr><td><strong>< 0.340</strong></td><td>Likely benign</td></tr>
|
|
454
|
+
</table>
|
|
455
|
+
|
|
456
|
+
</details>
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
# ---------------------------------------------------------------------------
|
|
461
|
+
# Helpers
|
|
462
|
+
# ---------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _hv_nocall_banner(warnings: list[str] | None) -> str:
|
|
466
|
+
if not warnings:
|
|
467
|
+
return ""
|
|
468
|
+
items = "".join(f"<li>{_escape(w)}</li>" for w in warnings)
|
|
469
|
+
return (
|
|
470
|
+
'<div class="notice-warn"><strong>High-value no-calls.</strong> '
|
|
471
|
+
"The following clinically important SNPs returned no genotype call:"
|
|
472
|
+
f"<ul>{items}</ul></div>"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _license_attributions(annotators_used: list[tuple[str, str | None]]) -> str:
|
|
477
|
+
"""Build license attribution HTML from annotator LicenseDescriptors."""
|
|
478
|
+
import logging
|
|
479
|
+
|
|
480
|
+
from allelix.annotators import get_annotator_class
|
|
481
|
+
|
|
482
|
+
logger = logging.getLogger(__name__)
|
|
483
|
+
parts: list[str] = []
|
|
484
|
+
for name, _version in annotators_used:
|
|
485
|
+
cls = get_annotator_class(name)
|
|
486
|
+
if cls is None:
|
|
487
|
+
logger.warning("No annotator class found for '%s' — attribution omitted", name)
|
|
488
|
+
continue
|
|
489
|
+
desc = cls.license
|
|
490
|
+
source_link = desc.source_url or desc.license_url
|
|
491
|
+
parts.append(
|
|
492
|
+
f" <a href='{html.escape(source_link)}'>"
|
|
493
|
+
f"{html.escape(cls.display_name)}</a>: "
|
|
494
|
+
f"{html.escape(desc.attribution_text)}"
|
|
495
|
+
f" (<a href='{html.escape(desc.license_url)}'>license</a>)"
|
|
496
|
+
)
|
|
497
|
+
return "".join(parts)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _review_stars(status: int | str | None) -> str:
|
|
501
|
+
"""Render ClinVar review status as filled/empty stars."""
|
|
502
|
+
if status is None or status == "" or status == "—":
|
|
503
|
+
return ""
|
|
504
|
+
if isinstance(status, str):
|
|
505
|
+
star_count = status.count("_") if "_" in status else 0
|
|
506
|
+
if "expert" in status.lower() or "practice" in status.lower():
|
|
507
|
+
star_count = 4
|
|
508
|
+
elif "multiple" in status.lower():
|
|
509
|
+
star_count = 3
|
|
510
|
+
elif "single" in status.lower():
|
|
511
|
+
star_count = 1
|
|
512
|
+
elif "no_assertion" in status.lower() or "no assertion" in status.lower():
|
|
513
|
+
star_count = 0
|
|
514
|
+
else:
|
|
515
|
+
star_count = int(status)
|
|
516
|
+
star_count = max(0, min(star_count, 4))
|
|
517
|
+
return "★" * star_count + "☆" * (4 - star_count)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# ---------------------------------------------------------------------------
|
|
521
|
+
# Variant grouping
|
|
522
|
+
# ---------------------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _group_annotations(
|
|
526
|
+
annotations: list[Annotation],
|
|
527
|
+
) -> list[list[Annotation]]:
|
|
528
|
+
"""Group annotations by ``(rsid, genotype_match)`` and sort by max magnitude."""
|
|
529
|
+
groups: dict[tuple[str, str], list[Annotation]] = defaultdict(list)
|
|
530
|
+
for ann in annotations:
|
|
531
|
+
key = (ann.rsid, ann.genotype_match)
|
|
532
|
+
groups[key].append(ann)
|
|
533
|
+
return sorted(
|
|
534
|
+
groups.values(),
|
|
535
|
+
key=lambda g: max(a.magnitude for a in g),
|
|
536
|
+
reverse=True,
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# ---------------------------------------------------------------------------
|
|
541
|
+
# Variant data JSON
|
|
542
|
+
# ---------------------------------------------------------------------------
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _build_variant_data(sorted_groups: list[list[Annotation]]) -> str:
|
|
546
|
+
"""Serialize grouped annotations to JSON for the detail sidebar."""
|
|
547
|
+
variant_data = []
|
|
548
|
+
for group in sorted_groups:
|
|
549
|
+
display = max(group, key=lambda a: a.magnitude)
|
|
550
|
+
entry: dict = {
|
|
551
|
+
"rsid": display.rsid,
|
|
552
|
+
"gene": display.gene or "",
|
|
553
|
+
"genotype": display.genotype_match,
|
|
554
|
+
"zygosity": display.zygosity,
|
|
555
|
+
"annotations": [],
|
|
556
|
+
}
|
|
557
|
+
for ann in sorted(group, key=lambda a: -a.magnitude):
|
|
558
|
+
if ann.allele_frequency is not None and "allele_frequency" not in entry:
|
|
559
|
+
entry["allele_frequency"] = round(ann.allele_frequency, 6)
|
|
560
|
+
if ann.am_pathogenicity is not None and "am_pathogenicity" not in entry:
|
|
561
|
+
entry["am_pathogenicity"] = round(ann.am_pathogenicity, 4)
|
|
562
|
+
if ann.am_class and "am_class" not in entry:
|
|
563
|
+
entry["am_class"] = ann.am_class
|
|
564
|
+
if ann.cadd_phred is not None and "cadd_phred" not in entry:
|
|
565
|
+
entry["cadd_phred"] = round(ann.cadd_phred, 1)
|
|
566
|
+
a: dict = {"source": ann.source, "magnitude": ann.magnitude}
|
|
567
|
+
if ann.significance:
|
|
568
|
+
a["significance"] = ann.significance
|
|
569
|
+
if ann.review_status:
|
|
570
|
+
a["reviewStatus"] = ann.review_status
|
|
571
|
+
a["reviewStars"] = _review_stars(ann.review_status)
|
|
572
|
+
if ann.condition:
|
|
573
|
+
a["condition"] = ann.condition
|
|
574
|
+
if ann.description:
|
|
575
|
+
a["description"] = ann.description
|
|
576
|
+
if ann.references:
|
|
577
|
+
a["references"] = ann.references
|
|
578
|
+
if ann.attribution:
|
|
579
|
+
a["attribution"] = ann.attribution
|
|
580
|
+
a["repute"] = _get_repute(ann)
|
|
581
|
+
if ann.category:
|
|
582
|
+
a["category"] = ann.category
|
|
583
|
+
entry["annotations"].append(a)
|
|
584
|
+
variant_data.append(entry)
|
|
585
|
+
|
|
586
|
+
json_str = json.dumps(variant_data, ensure_ascii=False)
|
|
587
|
+
return json_str.replace("</", "<\\/")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# ---------------------------------------------------------------------------
|
|
591
|
+
# Row rendering
|
|
592
|
+
# ---------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _build_search_text(group: list[Annotation]) -> str:
|
|
596
|
+
"""Concatenate all searchable fields from all annotations in a group."""
|
|
597
|
+
parts: list[str] = []
|
|
598
|
+
display = max(group, key=lambda a: a.magnitude)
|
|
599
|
+
parts.extend(
|
|
600
|
+
[
|
|
601
|
+
display.rsid,
|
|
602
|
+
display.gene,
|
|
603
|
+
display.genotype_match,
|
|
604
|
+
display.zygosity,
|
|
605
|
+
]
|
|
606
|
+
)
|
|
607
|
+
for ann in group:
|
|
608
|
+
parts.extend(
|
|
609
|
+
[
|
|
610
|
+
ann.source,
|
|
611
|
+
ann.significance,
|
|
612
|
+
ann.condition,
|
|
613
|
+
ann.description,
|
|
614
|
+
ann.attribution,
|
|
615
|
+
ann.gene,
|
|
616
|
+
ann.am_class,
|
|
617
|
+
]
|
|
618
|
+
)
|
|
619
|
+
return " ".join(p for p in parts if p).lower()
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _summary_text(group: list[Annotation]) -> str:
|
|
623
|
+
"""Build the summary cell content."""
|
|
624
|
+
display = max(group, key=lambda a: a.magnitude)
|
|
625
|
+
source_label = _escape(display.attribution or display.source)
|
|
626
|
+
extra = len(group) - 1
|
|
627
|
+
prefix = f"[{source_label} +{extra}]" if extra > 0 else f"[{source_label}]"
|
|
628
|
+
text = _escape(display.condition or display.description or "")
|
|
629
|
+
return f"{prefix} {text}"
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _row_html(group: list[Annotation], row_id: int) -> str:
|
|
633
|
+
"""Render a single variant group as an HTML table row."""
|
|
634
|
+
display = max(group, key=lambda a: a.magnitude)
|
|
635
|
+
repute = _get_repute(display)
|
|
636
|
+
mag = max(a.magnitude for a in group)
|
|
637
|
+
search_text = _build_search_text(group)
|
|
638
|
+
|
|
639
|
+
return (
|
|
640
|
+
f'<tr data-row-id="{row_id}"'
|
|
641
|
+
f' data-magnitude="{mag:.1f}"'
|
|
642
|
+
f' data-gene="{_escape(display.gene)}"'
|
|
643
|
+
f' data-genotype="{_escape(display.genotype_match)}"'
|
|
644
|
+
f' data-repute="{_escape(repute)}"'
|
|
645
|
+
f' data-search-text="{_escape(search_text)}">'
|
|
646
|
+
f'<td class="mag-cell">'
|
|
647
|
+
f'<span class="badge badge-{repute}">{int(mag)}</span></td>'
|
|
648
|
+
f'<td class="gene-cell">{_escape(display.gene) or "—"}</td>'
|
|
649
|
+
f'<td class="gt-cell">{_escape(display.genotype_match)}</td>'
|
|
650
|
+
f'<td class="repute-cell">'
|
|
651
|
+
f'<span class="pill pill-{repute}">{repute.capitalize()}</span></td>'
|
|
652
|
+
f'<td class="sum-cell">{_summary_text(group)}</td>'
|
|
653
|
+
"</tr>"
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
# ---------------------------------------------------------------------------
|
|
658
|
+
# Inline JavaScript
|
|
659
|
+
# ---------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
_SCRIPT = """\
|
|
662
|
+
<script>
|
|
663
|
+
document.addEventListener("DOMContentLoaded", function() {
|
|
664
|
+
var variantData = JSON.parse(
|
|
665
|
+
document.getElementById("variant-data").textContent
|
|
666
|
+
);
|
|
667
|
+
var searchInput = document.getElementById("search");
|
|
668
|
+
var tbody = document.querySelector("#variant-table tbody");
|
|
669
|
+
if (!tbody) return;
|
|
670
|
+
var panel = document.getElementById("detail-panel");
|
|
671
|
+
var backdrop = document.getElementById("backdrop");
|
|
672
|
+
var panelBody = document.getElementById("panel-body");
|
|
673
|
+
var panelPos = document.getElementById("panel-position");
|
|
674
|
+
var activeFilter = "all";
|
|
675
|
+
var selectedIndex = -1;
|
|
676
|
+
|
|
677
|
+
/* --- Filter counts --- */
|
|
678
|
+
var allRows = Array.from(tbody.querySelectorAll("tr"));
|
|
679
|
+
document.getElementById("count-all").textContent = allRows.length;
|
|
680
|
+
document.getElementById("count-bad").textContent =
|
|
681
|
+
allRows.filter(function(r){ return r.dataset.repute === "bad"; }).length;
|
|
682
|
+
document.getElementById("count-good").textContent =
|
|
683
|
+
allRows.filter(function(r){ return r.dataset.repute === "good"; }).length;
|
|
684
|
+
document.getElementById("count-neutral").textContent =
|
|
685
|
+
allRows.filter(function(r){ return r.dataset.repute === "neutral"; }).length;
|
|
686
|
+
|
|
687
|
+
/* --- Search + filter --- */
|
|
688
|
+
function getVisibleRows() {
|
|
689
|
+
return Array.from(tbody.querySelectorAll("tr")).filter(
|
|
690
|
+
function(r) { return r.style.display !== "none"; }
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function applyFilters() {
|
|
695
|
+
var term = searchInput.value.toLowerCase();
|
|
696
|
+
allRows.forEach(function(row) {
|
|
697
|
+
var matchesSearch = !term || row.dataset.searchText.indexOf(term) !== -1;
|
|
698
|
+
var matchesFilter =
|
|
699
|
+
activeFilter === "all" || row.dataset.repute === activeFilter;
|
|
700
|
+
row.style.display = matchesSearch && matchesFilter ? "" : "none";
|
|
701
|
+
});
|
|
702
|
+
selectedIndex = -1;
|
|
703
|
+
allRows.forEach(function(r) { r.classList.remove("selected"); });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
searchInput.addEventListener("input", applyFilters);
|
|
707
|
+
|
|
708
|
+
document.querySelectorAll(".filter-btn").forEach(function(btn) {
|
|
709
|
+
btn.addEventListener("click", function() {
|
|
710
|
+
document.querySelectorAll(".filter-btn").forEach(function(b) {
|
|
711
|
+
b.classList.remove("active");
|
|
712
|
+
});
|
|
713
|
+
btn.classList.add("active");
|
|
714
|
+
activeFilter = btn.dataset.filter;
|
|
715
|
+
applyFilters();
|
|
716
|
+
});
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
/* --- Sort --- */
|
|
720
|
+
document.querySelectorAll("th.sortable").forEach(function(th) {
|
|
721
|
+
th.addEventListener("click", function() {
|
|
722
|
+
var key = th.dataset.sort;
|
|
723
|
+
var rows = Array.from(tbody.querySelectorAll("tr"));
|
|
724
|
+
|
|
725
|
+
var wasAsc = th.classList.contains("sort-asc");
|
|
726
|
+
document.querySelectorAll("th.sortable").forEach(function(h) {
|
|
727
|
+
h.classList.remove("sort-asc", "sort-desc", "sort-active");
|
|
728
|
+
var arrow = h.querySelector(".sort-arrow");
|
|
729
|
+
if (arrow) arrow.textContent = "";
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
var dir = wasAsc ? -1 : 1;
|
|
733
|
+
th.classList.add("sort-active", dir === 1 ? "sort-asc" : "sort-desc");
|
|
734
|
+
var arrow = th.querySelector(".sort-arrow");
|
|
735
|
+
if (arrow) arrow.textContent = dir === 1 ? "\\u25B2" : "\\u25BC";
|
|
736
|
+
|
|
737
|
+
var reputeOrder = {"bad": 0, "neutral": 1, "good": 2};
|
|
738
|
+
rows.sort(function(a, b) {
|
|
739
|
+
var av = a.dataset[key];
|
|
740
|
+
var bv = b.dataset[key];
|
|
741
|
+
if (key === "magnitude") return dir * (Number(av) - Number(bv));
|
|
742
|
+
if (key === "repute") {
|
|
743
|
+
var ao = reputeOrder[av] !== undefined ? reputeOrder[av] : 1;
|
|
744
|
+
var bo = reputeOrder[bv] !== undefined ? reputeOrder[bv] : 1;
|
|
745
|
+
return dir * (ao - bo);
|
|
746
|
+
}
|
|
747
|
+
return dir * av.localeCompare(bv);
|
|
748
|
+
});
|
|
749
|
+
rows.forEach(function(row) { tbody.appendChild(row); });
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
/* --- Sidebar --- */
|
|
754
|
+
function populatePanel(row) {
|
|
755
|
+
var idx = Number(row.dataset.rowId);
|
|
756
|
+
var v = variantData[idx];
|
|
757
|
+
if (!v) return;
|
|
758
|
+
|
|
759
|
+
var visible = getVisibleRows();
|
|
760
|
+
var pos = visible.indexOf(row) + 1;
|
|
761
|
+
panelPos.textContent = pos + " of " + visible.length;
|
|
762
|
+
|
|
763
|
+
var h = '<h2 class="panel-gene">' + esc(v.gene || "\\u2014") + "</h2>";
|
|
764
|
+
h += '<div class="panel-rsid">' + esc(v.rsid) + "</div>";
|
|
765
|
+
|
|
766
|
+
var dispAnn = v.annotations[0];
|
|
767
|
+
var repute = dispAnn ? dispAnn.repute : "neutral";
|
|
768
|
+
var mag = dispAnn ? Math.floor(dispAnn.magnitude) : 0;
|
|
769
|
+
h += '<div class="panel-meta">';
|
|
770
|
+
h += '<span class="badge badge-' + repute + '">' + mag + "</span>";
|
|
771
|
+
h += '<span class="pill pill-' + repute + '">' +
|
|
772
|
+
repute.charAt(0).toUpperCase() + repute.slice(1) + "</span>";
|
|
773
|
+
h += "</div>";
|
|
774
|
+
|
|
775
|
+
h += fieldRow("Genotype", v.genotype);
|
|
776
|
+
h += fieldRow("Zygosity", v.zygosity);
|
|
777
|
+
|
|
778
|
+
var hasMetrics = v.allele_frequency != null ||
|
|
779
|
+
v.am_pathogenicity != null || v.cadd_phred != null;
|
|
780
|
+
if (hasMetrics) {
|
|
781
|
+
h += '<div class="source-header">VARIANT METRICS</div>';
|
|
782
|
+
if (v.allele_frequency != null)
|
|
783
|
+
h += fieldRow("Frequency",
|
|
784
|
+
(v.allele_frequency * 100).toFixed(1) + "%");
|
|
785
|
+
if (v.am_pathogenicity != null) {
|
|
786
|
+
var amText = v.am_pathogenicity.toFixed(3);
|
|
787
|
+
if (v.am_class) amText += " (" + v.am_class + ")";
|
|
788
|
+
h += fieldRow("AlphaMissense", amText);
|
|
789
|
+
}
|
|
790
|
+
if (v.cadd_phred != null) {
|
|
791
|
+
var cs = v.cadd_phred;
|
|
792
|
+
var tier = cs >= 30 ? "top 0.1%"
|
|
793
|
+
: cs >= 20 ? "top 1%"
|
|
794
|
+
: cs >= 10 ? "top 10%" : "";
|
|
795
|
+
var label = cs.toFixed(1) +
|
|
796
|
+
(tier ? " (" + tier + " most deleterious)" : "");
|
|
797
|
+
h += fieldRow("CADD PHRED", label);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
var groups = {};
|
|
802
|
+
var groupOrder = [];
|
|
803
|
+
for (var i = 0; i < v.annotations.length; i++) {
|
|
804
|
+
var a = v.annotations[i];
|
|
805
|
+
var src = a.source;
|
|
806
|
+
if (!groups[src]) { groups[src] = []; groupOrder.push(src); }
|
|
807
|
+
groups[src].push(a);
|
|
808
|
+
}
|
|
809
|
+
for (var gi = 0; gi < groupOrder.length; gi++) {
|
|
810
|
+
var src = groupOrder[gi];
|
|
811
|
+
var anns = groups[src];
|
|
812
|
+
h += '<div class="source-header">' + esc(src) + "</div>";
|
|
813
|
+
for (var ai = 0; ai < anns.length; ai++) {
|
|
814
|
+
var a = anns[ai];
|
|
815
|
+
h += '<div class="ann-entry">';
|
|
816
|
+
h += fieldRow("Magnitude", a.magnitude.toFixed(1));
|
|
817
|
+
if (a.significance)
|
|
818
|
+
h += fieldRow("Significance", a.significance);
|
|
819
|
+
if (a.reviewStars)
|
|
820
|
+
h += fieldRow("Review Status", a.reviewStars);
|
|
821
|
+
if (a.condition) h += fieldRow("Condition", a.condition);
|
|
822
|
+
if (a.description)
|
|
823
|
+
h += fieldRow("Description", a.description);
|
|
824
|
+
if (a.attribution)
|
|
825
|
+
h += fieldRow("Attribution", a.attribution);
|
|
826
|
+
if (a.references && a.references.length)
|
|
827
|
+
h += fieldRow("References", a.references.join(" "));
|
|
828
|
+
h += "</div>";
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
panelBody.innerHTML = h;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function fieldRow(label, value) {
|
|
835
|
+
return '<div class="field-row"><div class="field-label">' +
|
|
836
|
+
esc(label) + '</div><div class="field-value">' +
|
|
837
|
+
esc(String(value)) + "</div></div>";
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function esc(s) {
|
|
841
|
+
var d = document.createElement("div");
|
|
842
|
+
d.appendChild(document.createTextNode(s));
|
|
843
|
+
return d.innerHTML;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function openPanel(row) {
|
|
847
|
+
populatePanel(row);
|
|
848
|
+
panel.classList.add("open");
|
|
849
|
+
backdrop.classList.add("open");
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function closePanel() {
|
|
853
|
+
panel.classList.remove("open");
|
|
854
|
+
backdrop.classList.remove("open");
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function selectRow(row) {
|
|
858
|
+
allRows.forEach(function(r) { r.classList.remove("selected"); });
|
|
859
|
+
row.classList.add("selected");
|
|
860
|
+
row.scrollIntoView({ block: "nearest" });
|
|
861
|
+
if (panel.classList.contains("open")) {
|
|
862
|
+
populatePanel(row);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/* Row click */
|
|
867
|
+
tbody.addEventListener("click", function(e) {
|
|
868
|
+
var row = e.target.closest("tr");
|
|
869
|
+
if (!row) return;
|
|
870
|
+
var rows = getVisibleRows();
|
|
871
|
+
selectedIndex = rows.indexOf(row);
|
|
872
|
+
selectRow(row);
|
|
873
|
+
openPanel(row);
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
/* Close */
|
|
877
|
+
document.getElementById("panel-close").addEventListener("click", closePanel);
|
|
878
|
+
backdrop.addEventListener("click", closePanel);
|
|
879
|
+
|
|
880
|
+
/* Prev / Next */
|
|
881
|
+
document.getElementById("panel-prev").addEventListener("click", function() {
|
|
882
|
+
var rows = getVisibleRows();
|
|
883
|
+
if (selectedIndex > 0) {
|
|
884
|
+
selectedIndex--;
|
|
885
|
+
selectRow(rows[selectedIndex]);
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
document.getElementById("panel-next").addEventListener("click", function() {
|
|
889
|
+
var rows = getVisibleRows();
|
|
890
|
+
if (selectedIndex < rows.length - 1) {
|
|
891
|
+
selectedIndex++;
|
|
892
|
+
selectRow(rows[selectedIndex]);
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
/* Keyboard */
|
|
897
|
+
document.addEventListener("keydown", function(e) {
|
|
898
|
+
if (document.activeElement === searchInput) return;
|
|
899
|
+
var rows = getVisibleRows();
|
|
900
|
+
if (!rows.length) return;
|
|
901
|
+
|
|
902
|
+
if (e.key === "Escape") { closePanel(); return; }
|
|
903
|
+
if (e.key === "ArrowDown") {
|
|
904
|
+
e.preventDefault();
|
|
905
|
+
selectedIndex = Math.min(selectedIndex + 1, rows.length - 1);
|
|
906
|
+
selectRow(rows[selectedIndex]);
|
|
907
|
+
}
|
|
908
|
+
if (e.key === "ArrowUp") {
|
|
909
|
+
e.preventDefault();
|
|
910
|
+
selectedIndex = Math.max(selectedIndex - 1, 0);
|
|
911
|
+
selectRow(rows[selectedIndex]);
|
|
912
|
+
}
|
|
913
|
+
if (e.key === "Enter" && selectedIndex >= 0) {
|
|
914
|
+
openPanel(rows[selectedIndex]);
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
/* --- Theme toggle --- */
|
|
919
|
+
var toggleBtn = document.getElementById("theme-toggle");
|
|
920
|
+
function setTheme(theme) {
|
|
921
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
922
|
+
toggleBtn.textContent = theme === "dark" ? "\\u2600" : "\\u263E";
|
|
923
|
+
}
|
|
924
|
+
toggleBtn.addEventListener("click", function() {
|
|
925
|
+
var current = document.documentElement.getAttribute("data-theme");
|
|
926
|
+
if (!current) {
|
|
927
|
+
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
928
|
+
setTheme(prefersDark ? "light" : "dark");
|
|
929
|
+
} else {
|
|
930
|
+
setTheme(current === "dark" ? "light" : "dark");
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
});
|
|
934
|
+
</script>
|
|
935
|
+
"""
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
# ---------------------------------------------------------------------------
|
|
939
|
+
# Public API
|
|
940
|
+
# ---------------------------------------------------------------------------
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def render_html(
|
|
944
|
+
result: AnalysisResult,
|
|
945
|
+
*,
|
|
946
|
+
output_path: Path,
|
|
947
|
+
min_magnitude: float = 0.0,
|
|
948
|
+
category: str | None = None,
|
|
949
|
+
genes: Iterable[str] | None = None,
|
|
950
|
+
source_min_magnitudes: dict[str, float] | None = None,
|
|
951
|
+
title: str = "Allelix Genotype Report",
|
|
952
|
+
diff: DiffResult | None = None,
|
|
953
|
+
high_value_no_calls: list[str] | None = None,
|
|
954
|
+
) -> int:
|
|
955
|
+
"""Write a self-contained HTML report. Returns the number of annotations rendered."""
|
|
956
|
+
filtered = result.filter(
|
|
957
|
+
min_magnitude=min_magnitude,
|
|
958
|
+
category=category,
|
|
959
|
+
genes=genes,
|
|
960
|
+
source_min_magnitudes=source_min_magnitudes,
|
|
961
|
+
)
|
|
962
|
+
filtered = rollup_gwas_duplicates(filtered)
|
|
963
|
+
annotators_str = ", ".join(
|
|
964
|
+
f"{name} ({version or 'unknown'})" for name, version in result.annotators_used
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
build_warn = ""
|
|
968
|
+
diag = result.build_diagnostics
|
|
969
|
+
if diag is not None and diag.mismatch:
|
|
970
|
+
build_warn = (
|
|
971
|
+
'<div class="notice-warn"><strong>Build mismatch.</strong> '
|
|
972
|
+
f"File header claims {_escape(diag.header_build or '')} but position data "
|
|
973
|
+
f"indicates {_escape(diag.detected_build or '')}. "
|
|
974
|
+
f"This report uses {_escape(diag.effective_build)}. "
|
|
975
|
+
"Your provider may have mislabeled the genome build.</div>"
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
diff_banner = ""
|
|
979
|
+
if diff is not None:
|
|
980
|
+
from allelix.reports.diff import summarize_diff
|
|
981
|
+
|
|
982
|
+
diff_banner = (
|
|
983
|
+
f'<div class="notice"><strong>Diff: </strong>{_escape(summarize_diff(diff))}</div>'
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
floor_note = ""
|
|
987
|
+
if source_min_magnitudes and min_magnitude > 0:
|
|
988
|
+
lower_floors = {
|
|
989
|
+
src: floor for src, floor in source_min_magnitudes.items() if floor < min_magnitude
|
|
990
|
+
}
|
|
991
|
+
if lower_floors:
|
|
992
|
+
parts = ", ".join(
|
|
993
|
+
f"{src.upper()} (mag ≥ {floor:.1f})"
|
|
994
|
+
for src, floor in sorted(lower_floors.items())
|
|
995
|
+
)
|
|
996
|
+
floor_note = (
|
|
997
|
+
'<div class="notice">'
|
|
998
|
+
f"<strong>Source-specific thresholds.</strong> "
|
|
999
|
+
f"The global minimum magnitude is {min_magnitude:.1f}, "
|
|
1000
|
+
f"but lower thresholds apply to: {parts}. "
|
|
1001
|
+
"Some rows below the global threshold may appear from these sources."
|
|
1002
|
+
"</div>"
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
# Group annotations by variant
|
|
1006
|
+
sorted_groups = _group_annotations(filtered)
|
|
1007
|
+
|
|
1008
|
+
# Count reputes for summary cards
|
|
1009
|
+
def _display_repute(g: list[Annotation]) -> str:
|
|
1010
|
+
return _get_repute(max(g, key=lambda a: a.magnitude))
|
|
1011
|
+
|
|
1012
|
+
bad_count = sum(1 for g in sorted_groups if _display_repute(g) == "bad")
|
|
1013
|
+
good_count = sum(1 for g in sorted_groups if _display_repute(g) == "good")
|
|
1014
|
+
|
|
1015
|
+
if sorted_groups:
|
|
1016
|
+
rows_html = "\n".join(_row_html(g, i) for i, g in enumerate(sorted_groups))
|
|
1017
|
+
variant_json = _build_variant_data(sorted_groups)
|
|
1018
|
+
|
|
1019
|
+
body = (
|
|
1020
|
+
'<table id="variant-table">'
|
|
1021
|
+
"<thead><tr>"
|
|
1022
|
+
'<th data-sort="magnitude" class="sortable sort-active sort-desc">'
|
|
1023
|
+
'Mag<span class="sort-arrow">▼</span></th>'
|
|
1024
|
+
'<th data-sort="gene" class="sortable">'
|
|
1025
|
+
'Gene<span class="sort-arrow"></span></th>'
|
|
1026
|
+
'<th data-sort="genotype" class="sortable">'
|
|
1027
|
+
'Genotype<span class="sort-arrow"></span></th>'
|
|
1028
|
+
'<th data-sort="repute" class="sortable">'
|
|
1029
|
+
'Repute<span class="sort-arrow"></span></th>'
|
|
1030
|
+
"<th>Summary</th>"
|
|
1031
|
+
"</tr></thead>"
|
|
1032
|
+
f"<tbody>{rows_html}</tbody></table>"
|
|
1033
|
+
f'<script id="variant-data" type="application/json">{variant_json}</script>'
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
sidebar = (
|
|
1037
|
+
'<div class="sidebar-backdrop" id="backdrop"></div>'
|
|
1038
|
+
'<div class="detail-panel" id="detail-panel">'
|
|
1039
|
+
'<div class="panel-header">'
|
|
1040
|
+
'<button class="panel-close" id="panel-close" aria-label="Close">×</button>'
|
|
1041
|
+
'<div class="panel-nav">'
|
|
1042
|
+
'<button id="panel-prev" aria-label="Previous">‹ Prev</button>'
|
|
1043
|
+
'<span id="panel-position"></span>'
|
|
1044
|
+
'<button id="panel-next" aria-label="Next">Next ›</button>'
|
|
1045
|
+
"</div></div>"
|
|
1046
|
+
'<div class="panel-body" id="panel-body"></div>'
|
|
1047
|
+
"</div>"
|
|
1048
|
+
)
|
|
1049
|
+
else:
|
|
1050
|
+
body = '<div class="empty">No annotations matched the current filters.</div>'
|
|
1051
|
+
sidebar = ""
|
|
1052
|
+
variant_json = ""
|
|
1053
|
+
|
|
1054
|
+
controls = (
|
|
1055
|
+
'<div class="controls">'
|
|
1056
|
+
'<input type="text" id="search" placeholder="Search variants..." '
|
|
1057
|
+
'aria-label="Search variants">'
|
|
1058
|
+
'<div class="filters">'
|
|
1059
|
+
'<button class="filter-btn active" data-filter="all">'
|
|
1060
|
+
'All <span id="count-all"></span></button>'
|
|
1061
|
+
'<button class="filter-btn" data-filter="bad">'
|
|
1062
|
+
'Bad <span id="count-bad"></span></button>'
|
|
1063
|
+
'<button class="filter-btn" data-filter="good">'
|
|
1064
|
+
'Good <span id="count-good"></span></button>'
|
|
1065
|
+
'<button class="filter-btn" data-filter="neutral">'
|
|
1066
|
+
'Neutral <span id="count-neutral"></span></button>'
|
|
1067
|
+
"</div>"
|
|
1068
|
+
'<button class="theme-toggle" id="theme-toggle" '
|
|
1069
|
+
'aria-label="Toggle dark mode" title="Toggle dark/light mode">'
|
|
1070
|
+
"☾</button>"
|
|
1071
|
+
"</div>"
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
summary_cards = "\n".join(
|
|
1075
|
+
f'<div class="card{css}"><div class="label">{label}</div>'
|
|
1076
|
+
f'<div class="value">{value}</div></div>'
|
|
1077
|
+
for label, value, css in [
|
|
1078
|
+
("Sample", _escape(result.sample_id) or "(unknown)", ""),
|
|
1079
|
+
("Format", _escape(result.parser_display_name), ""),
|
|
1080
|
+
("Build", _escape(result.build), ""),
|
|
1081
|
+
("Variants", f"{len(sorted_groups):,}", ""),
|
|
1082
|
+
("Bad", str(bad_count), " card-bad"),
|
|
1083
|
+
("Good", str(good_count), " card-good"),
|
|
1084
|
+
("Total Annotations", f"{len(filtered):,}", ""),
|
|
1085
|
+
]
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
document = (
|
|
1089
|
+
"<!DOCTYPE html>"
|
|
1090
|
+
"<html lang='en'><head><meta charset='utf-8'>"
|
|
1091
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
1092
|
+
'<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,'
|
|
1093
|
+
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>"
|
|
1094
|
+
"<defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'>"
|
|
1095
|
+
"<stop offset='0%25' stop-color='%234f46e5'/>"
|
|
1096
|
+
"<stop offset='100%25' stop-color='%2306b6d4'/>"
|
|
1097
|
+
"</linearGradient></defs>"
|
|
1098
|
+
"<path d='M16 2C9 2 8 8 8 8s2-3 8-3 8 3 8 3-1-6-8-6z"
|
|
1099
|
+
"m0 6c-7 0-8 6-8 6s2-3 8-3 8 3 8 3-1-6-8-6z"
|
|
1100
|
+
"m0 6c-7 0-8 6-8 6s2-3 8-3 8 3 8 3-1-6-8-6z"
|
|
1101
|
+
"m0 6c-7 0-8 6-8 6s2-3 8-3 8 3 8 3-1-6-8-6z'"
|
|
1102
|
+
" fill='url(%23g)' opacity='0.9'/></svg>\">"
|
|
1103
|
+
f"<title>{_escape(title)}</title>"
|
|
1104
|
+
f"<style>{_CSS}</style>"
|
|
1105
|
+
"</head><body>"
|
|
1106
|
+
f"<h1>{_escape(title)}</h1>"
|
|
1107
|
+
f'<p class="subtitle">Source: <code>{_escape(result.file_path.name)}</code> · '
|
|
1108
|
+
f"Annotators: {_escape(annotators_str)} · "
|
|
1109
|
+
f"Generated {datetime.now(UTC).strftime('%Y-%m-%d %H:%M UTC')}</p>"
|
|
1110
|
+
'<div class="notice"><strong>Informational only.</strong> '
|
|
1111
|
+
f"{_escape(REGULATORY_NOTICE)}</div>"
|
|
1112
|
+
f"{build_warn}"
|
|
1113
|
+
f"{_hv_nocall_banner(high_value_no_calls)}"
|
|
1114
|
+
f"{_EDUCATION_SECTION}"
|
|
1115
|
+
f"{_MAGNITUDE_LEGEND}"
|
|
1116
|
+
f"{diff_banner}"
|
|
1117
|
+
f'<div class="summary">{summary_cards}</div>'
|
|
1118
|
+
f"{floor_note}"
|
|
1119
|
+
f"{controls}"
|
|
1120
|
+
f"{body}"
|
|
1121
|
+
f"{sidebar}"
|
|
1122
|
+
f"{_SCRIPT}"
|
|
1123
|
+
f"<footer>Generated by Allelix v{_escape(__version__)} — "
|
|
1124
|
+
"<a href='https://github.com/dial481/allelix'>github.com/dial481/allelix</a>. "
|
|
1125
|
+
"All variant classifications attributed to their source databases."
|
|
1126
|
+
f"{_license_attributions(result.annotators_used)}</footer>"
|
|
1127
|
+
"</body></html>"
|
|
1128
|
+
)
|
|
1129
|
+
atomic_write_text(output_path, document)
|
|
1130
|
+
return len(filtered)
|