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.
Files changed (58) hide show
  1. allelix/__init__.py +12 -0
  2. allelix/annotators/__init__.py +90 -0
  3. allelix/annotators/alphamissense.py +228 -0
  4. allelix/annotators/base.py +214 -0
  5. allelix/annotators/cadd.py +283 -0
  6. allelix/annotators/clinvar.py +404 -0
  7. allelix/annotators/gnomad.py +212 -0
  8. allelix/annotators/gwas.py +354 -0
  9. allelix/annotators/pharmgkb.py +406 -0
  10. allelix/annotators/snpedia.py +276 -0
  11. allelix/cli.py +1524 -0
  12. allelix/compare.py +149 -0
  13. allelix/config.py +143 -0
  14. allelix/data/__init__.py +3 -0
  15. allelix/data/high_value_snps.yaml +64 -0
  16. allelix/databases/__init__.py +30 -0
  17. allelix/databases/_versions.py +16 -0
  18. allelix/databases/alphamissense_loader.py +48 -0
  19. allelix/databases/cadd_loader.py +49 -0
  20. allelix/databases/cpic_loader.py +234 -0
  21. allelix/databases/gnomad_loader.py +49 -0
  22. allelix/databases/gwas_loader.py +546 -0
  23. allelix/databases/loader_utils.py +80 -0
  24. allelix/databases/manager.py +515 -0
  25. allelix/databases/pharmgkb_loader.py +437 -0
  26. allelix/databases/schema.py +165 -0
  27. allelix/databases/snpedia_loader.py +44 -0
  28. allelix/databases/snpedia_parser.py +342 -0
  29. allelix/exporters/__init__.py +3 -0
  30. allelix/exporters/plink.py +144 -0
  31. allelix/models.py +117 -0
  32. allelix/parsers/__init__.py +73 -0
  33. allelix/parsers/_helpers.py +41 -0
  34. allelix/parsers/ancestrydna.py +130 -0
  35. allelix/parsers/base.py +97 -0
  36. allelix/parsers/ftdna.py +129 -0
  37. allelix/parsers/livingdna.py +121 -0
  38. allelix/parsers/myhappygenes.py +135 -0
  39. allelix/parsers/myheritage.py +118 -0
  40. allelix/parsers/twentythreeandme.py +150 -0
  41. allelix/py.typed +0 -0
  42. allelix/reports/__init__.py +40 -0
  43. allelix/reports/_pipeline.py +497 -0
  44. allelix/reports/diff.py +169 -0
  45. allelix/reports/high_value.py +133 -0
  46. allelix/reports/html.py +1130 -0
  47. allelix/reports/json_report.py +163 -0
  48. allelix/reports/methylation.py +50 -0
  49. allelix/reports/terminal.py +203 -0
  50. allelix/utils/__init__.py +3 -0
  51. allelix/utils/allele.py +87 -0
  52. allelix/utils/build_detect.py +203 -0
  53. allelix-1.8.1.dist-info/METADATA +276 -0
  54. allelix-1.8.1.dist-info/RECORD +58 -0
  55. allelix-1.8.1.dist-info/WHEEL +5 -0
  56. allelix-1.8.1.dist-info/entry_points.txt +2 -0
  57. allelix-1.8.1.dist-info/licenses/LICENSE +671 -0
  58. allelix-1.8.1.dist-info/top_level.txt +1 -0
@@ -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 &gt;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&rsquo;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&ndash;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 &mdash; CPIC guideline or FDA label</td></tr>
418
+ <tr><td><strong>8</strong></td><td>Level 1B &mdash; strong clinical evidence</td></tr>
419
+ <tr><td><strong>6</strong></td><td>Level 2A &mdash; moderate evidence</td></tr>
420
+ <tr><td><strong>5</strong></td><td>Level 2B &mdash; moderate (weaker replication)</td></tr>
421
+ <tr><td><strong>4</strong></td><td>Level 3 &mdash; 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 &lt; 5&times;10<sup>-8</sup> (genome-wide)</td></tr>
427
+ <tr><td><strong>4</strong></td><td>p &lt; 5&times;10<sup>-6</sup> (suggestive)</td></tr>
428
+ <tr><td><strong>3</strong></td><td>p &lt; 1&times;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&ndash;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&rsquo;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>&ge; 30</strong></td><td>Top 0.1% most deleterious</td></tr>
442
+ <tr><td><strong>&ge; 20</strong></td><td>Top 1% most deleterious</td></tr>
443
+ <tr><td><strong>&ge; 10</strong></td><td>Top 10% most deleterious</td></tr>
444
+ <tr><td><strong>&lt; 10</strong></td><td>Below top 10%</td></tr>
445
+ </table>
446
+
447
+ <h3>AlphaMissense (missense pathogenicity)</h3>
448
+ <p>DeepMind&rsquo;s protein-structure-based pathogenicity prediction for
449
+ missense variants. Score 0&ndash;1; higher = more likely pathogenic.</p>
450
+ <table style="width:auto; font-size:.85rem; margin:.5rem 0;">
451
+ <tr><td><strong>&ge; 0.564</strong></td><td>Likely pathogenic</td></tr>
452
+ <tr><td><strong>0.340 &ndash; 0.564</strong></td><td>Ambiguous</td></tr>
453
+ <tr><td><strong>&lt; 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 &ge; {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">&#x25BC;</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">&times;</button>'
1041
+ '<div class="panel-nav">'
1042
+ '<button id="panel-prev" aria-label="Previous">&lsaquo; Prev</button>'
1043
+ '<span id="panel-position"></span>'
1044
+ '<button id="panel-next" aria-label="Next">Next &rsaquo;</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
+ "&#x263E;</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)