Skip to content

Commit f79c1cb

Browse files
committed
.
1 parent 5406ce1 commit f79c1cb

2 files changed

Lines changed: 132 additions & 22 deletions

File tree

recmpox/diagnostic_snp.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ def get_query_allegiance_from_alignment(
315315
) -> List[Tuple[int, str]]:
316316
"""
317317
At each diagnostic (pos, ia_allele, ib_allele), get query base from alignment column (1-based).
318-
N, gap, or other ambiguous -> 'ambiguous'. Same allegiance rules as get_query_allegiance.
318+
N -> 'other_n'; other non-ACGT/gap -> 'ambiguous'. Same allegiance rules as get_query_allegiance.
319319
If diagnostic_indels is provided, append one allegiance per column in each indel (each position counts as a site).
320320
"""
321321
if len(query_seq) < ref_length:
@@ -327,10 +327,12 @@ def get_query_allegiance_from_alignment(
327327
result.append((pos, "ambiguous"))
328328
continue
329329
q = query_seq[idx].upper()
330-
if q not in "ACGT-":
331-
result.append((pos, "ambiguous"))
332-
continue
330+
# Treat N explicitly as its own "other_n" category so we can split
331+
# "other (all)" vs "other (N's)" in the HTML report.
333332
if q == "N":
333+
result.append((pos, "other_n"))
334+
continue
335+
if q not in "ACGT-":
334336
result.append((pos, "ambiguous"))
335337
continue
336338
# Same allegiance logic as get_query_allegiance
@@ -419,20 +421,22 @@ def get_query_allegiance(
419421
return result
420422

421423

422-
def allegiance_summary(positions_allegiances: List[Tuple[int, str]]) -> Tuple[int, int, int]:
423-
"""Return (n_ia, n_ib, n_ambiguous) over all diagnostic SNPs."""
424+
def allegiance_summary(positions_allegiances: List[Tuple[int, str]]) -> Tuple[int, int, int, int]:
425+
"""Return (n_ia, n_ib, n_other, n_other_n) over all diagnostic sites. n_other = ambiguous + other_n."""
424426
n_ia = sum(1 for _, a in positions_allegiances if a == "ia")
425427
n_ib = sum(1 for _, a in positions_allegiances if a == "ib")
426428
n_amb = sum(1 for _, a in positions_allegiances if a == "ambiguous")
427-
return (n_ia, n_ib, n_amb)
429+
n_other_n = sum(1 for _, a in positions_allegiances if a == "other_n")
430+
n_other = n_amb + n_other_n
431+
return (n_ia, n_ib, n_other, n_other_n)
428432

429433

430434
def allegiance_summary_snp_only(
431435
positions_allegiances: List[Tuple[int, str]],
432436
diagnostic_snp_positions: List[int],
433-
) -> Tuple[int, int, int]:
437+
) -> Tuple[int, int, int, int]:
434438
"""
435-
Return (n_ia, n_ib, n_ambiguous) over diagnostic SNP positions only (excludes indel columns).
439+
Return (n_ia, n_ib, n_other, n_other_n) over diagnostic SNP positions only (excludes indel columns).
436440
Use this to classify consensus as Ia/Ib from SNP percentages so that poor coverage
437441
in deletion regions (often 'N') does not inflate 'other'.
438442
"""

recmpox/recmpox.py

Lines changed: 119 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,7 +1049,7 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
10491049

10501050
# Build list of dicts per row for JS (exclude allegiances to keep JSON small)
10511051
# Flatten tuple keys (merged display columns) and always include chart keys
1052-
_chart_keys = {"id", "pct_ia", "pct_ib", "pct_other"}
1052+
_chart_keys = {"id", "pct_ia", "pct_ib", "pct_other", "n_ia", "n_ib", "n_other", "n_other_n"}
10531053
_data_keys = {k for c in cols for k in (c[0] if isinstance(c[0], tuple) else (c[0],))} | _chart_keys
10541054
data_list = [{k: r.get(k, "") for k in _data_keys} for r in results]
10551055
data_json = json.dumps(data_list).replace("</", "<\\/")
@@ -1073,7 +1073,15 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
10731073
sorted_alle = sorted(allegiances, key=lambda x: x[0])
10741074
strip_segments = ""
10751075
for (pos, allegiance) in sorted_alle:
1076-
cls = "ia" if allegiance == "ia" else ("ib" if allegiance == "ib" else "other")
1076+
# Use "other-n" for N's so the strip filter can show only N segments when "Other (N's)" is selected
1077+
if allegiance == "ia":
1078+
cls = "ia"
1079+
elif allegiance == "ib":
1080+
cls = "ib"
1081+
elif allegiance == "other_n":
1082+
cls = "other other-n"
1083+
else:
1084+
cls = "other"
10771085
lbl = ref1_label if allegiance == "ia" else (ref2_label if allegiance == "ib" else "other")
10781086
pct = pos / display_length * 100 if display_length else 0
10791087
strip_segments += f'<span class="strip-segment {cls}" title="{pos} bp – {html_escape(lbl)}" style="left:{pct:.3f}%"></span>'
@@ -1089,16 +1097,20 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
10891097
f'<p class="threshold-note">Tracts = consecutive diagnostic sites with same classification. One row per tract.</p>'
10901098
f'<table class="rec-sites-table"><thead><tr><th>Start (bp)</th><th>End (bp)</th><th>Clade</th><th>Sites</th></tr></thead><tbody>'
10911099
)
1092-
# Group consecutive positions with same allegiance into tracts
1100+
# Group consecutive positions with same allegiance into tracts (treat other_n and ambiguous as "other")
1101+
def _eff_allegiance(a: str) -> str:
1102+
return "other" if a in ("ambiguous", "other_n") else a
1103+
10931104
i = 0
10941105
while i < len(sorted_alle):
10951106
start_pos, allegiance = sorted_alle[i]
10961107
end_pos = start_pos
1108+
eff = _eff_allegiance(allegiance)
10971109
j = i + 1
1098-
while j < len(sorted_alle) and sorted_alle[j][1] == allegiance:
1110+
while j < len(sorted_alle) and _eff_allegiance(sorted_alle[j][1]) == eff:
10991111
end_pos = sorted_alle[j][0]
11001112
j += 1
1101-
label = ref1_label if allegiance == "ia" else (ref2_label if allegiance == "ib" else "other")
1113+
label = ref1_label if eff == "ia" else (ref2_label if eff == "ib" else "other")
11021114
n_sites = j - i
11031115
rec_sites_html += f'<tr><td class="num">{start_pos}</td><td class="num">{end_pos}</td><td>{html_escape(label)}</td><td class="num">{n_sites}</td></tr>'
11041116
i = j
@@ -1112,6 +1124,15 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
11121124
'<details class="collapsible-section diagnostic-strips-chart" open id="diagnosticStripsSection">'
11131125
f'<summary><h2>Classification of diagnostic sites per sample</h2><button class="pdf-btn" onclick="event.stopPropagation();exportStripSvg(\'diagnosticStripsSection\',\'diagnosticStripsContainer\',\'Classification of diagnostic sites per sample\',\'{html_escape(ref1_label)}\',\'{html_escape(ref2_label)}\')">&#8595; Download SVG</button></summary>'
11141126
'<div class="section-inner chart-section">'
1127+
'<div class="strip-classification-filter-wrap">'
1128+
'<label class="strip-filter-check-label"><input type="checkbox" id="stripClassificationFilterCheck" aria-describedby="stripClassificationOptions"> Filter strips by classification</label>'
1129+
'<div id="stripClassificationOptions" class="strip-classification-options" aria-hidden="true">'
1130+
'<label><input type="checkbox" class="strip-class-opt" data-filter="ia"> {ref1}</label>'
1131+
'<label><input type="checkbox" class="strip-class-opt" data-filter="ib"> {ref2}</label>'
1132+
'<label><input type="checkbox" class="strip-class-opt" data-filter="other_all"> Other (all)</label>'
1133+
'<label><input type="checkbox" class="strip-class-opt" data-filter="other_n"> Other (N\'s)</label>'
1134+
'</div>'
1135+
'</div>'
11151136
'<p class="threshold-note">One strip per consensus: each segment = one diagnostic site in genomic order. <span id="stripFilterCount" aria-live="polite"></span></p>'
11161137
'<p class="threshold-note">{ref1} (blue), {ref2} (orange), other (gray).</p>'
11171138
'<div class="strip-legend"><span class="strip-legend-ia"></span> {ref1} &nbsp; <span class="strip-legend-ib"></span> {ref2} &nbsp; <span class="strip-legend-other"></span> other</div>'
@@ -1331,6 +1352,8 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
13311352
.strip-segment.ia {{ background: #4A90D9; }}
13321353
.strip-segment.ib {{ background: #E89B3C; }}
13331354
.strip-segment.other {{ background: #95a5a6; }}
1355+
.strip-segment.other-n {{ background: #95a5a6; }}
1356+
.strip-segment.segment-hidden-by-filter {{ display: none; }}
13341357
.strip-segment.region-segment {{ min-width: 4px; border-radius: 3px; }}
13351358
.strip-segment.breakpoint-marker {{ width: 4px; background: #c0392b; transform: translateX(-50%); }}
13361359
#breakpointsStripsContainer .rec-sites-section {{ background: linear-gradient(135deg, #fafbfc 0%, #f4f6f9 100%); border-left: 4px solid #dee2e6; box-shadow: 0 1px 4px rgba(0,0,0,0.05); transition: box-shadow 0.15s; }}
@@ -1341,6 +1364,12 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
13411364
.strip-legend-ib {{ display: inline-block; width: 14px; height: 14px; background: #E89B3C; border-radius: 2px; }}
13421365
.strip-legend-other {{ display: inline-block; width: 14px; height: 14px; background: #95a5a6; border-radius: 2px; }}
13431366
.strip-legend-gap {{ display: inline-block; width: 14px; height: 14px; background: linear-gradient(135deg, #c4d3e0, #b6c8d7); border: 1px solid #9fb8cc; border-radius: 2px; }}
1367+
.strip-classification-filter-wrap {{ display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 12px 20px; margin-bottom: 12px; padding: 10px 12px; background: #f8f9fa; border-radius: 6px; border: 1px solid #e9ecef; }}
1368+
.strip-classification-filter-wrap label {{ font-size: 0.95em; color: #495057; cursor: pointer; white-space: nowrap; }}
1369+
.strip-classification-filter-wrap input[type="checkbox"] {{ margin-right: 6px; vertical-align: middle; }}
1370+
.strip-classification-options {{ display: none; gap: 12px 20px; flex-wrap: wrap; align-items: center; }}
1371+
.strip-classification-options[aria-hidden="false"] {{ display: flex; }}
1372+
.strip-filter-check-label {{ font-weight: 600; }}
13441373
.strip-ruler {{ position: relative; width: 100%; height: 22px; min-width: 200px; margin-top: 3px; }}
13451374
.ruler-tick {{ position: absolute; transform: translateX(-50%); font-size: 0.67em; font-weight: 500; color: #6c757d; white-space: nowrap; line-height: 1; padding-top: 6px; letter-spacing: 0.01em; }}
13461375
.ruler-tick::before {{ content: ''; display: block; position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 1px; height: 5px; background: #adb5bd; }}
@@ -1622,36 +1651,93 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
16221651
if (ri !== null) visibleRows.add(ri);
16231652
}}
16241653
}});
1654+
var stripFilterCheck = document.getElementById("stripClassificationFilterCheck");
1655+
var stripOpts = document.querySelectorAll(".strip-class-opt:checked");
1656+
// When "Filter strips by classification" is checked: figure is empty until you pick Ia, Ib, etc.
1657+
// When unchecked: show everything.
1658+
var stripClassificationOn = stripFilterCheck && stripFilterCheck.checked;
1659+
function rowMatchesStripClassification(ri) {{
1660+
if (!stripClassificationOn || !data[ri]) return true;
1661+
var r = data[ri];
1662+
var n_ia = (r.n_ia != null) ? (parseInt(r.n_ia, 10) || 0) : 0;
1663+
var n_ib = (r.n_ib != null) ? (parseInt(r.n_ib, 10) || 0) : 0;
1664+
var n_other = (r.n_other != null) ? (parseInt(r.n_other, 10) || 0) : 0;
1665+
var n_other_n = (r.n_other_n != null) ? (parseInt(r.n_other_n, 10) || 0) : 0;
1666+
var hasOtherAll = n_other > 0;
1667+
var hasOtherN = n_other_n > 0;
1668+
for (var i = 0; i < stripOpts.length; i++) {{
1669+
var f = stripOpts[i].getAttribute("data-filter");
1670+
if (f === "ia" && n_ia > 0) return true;
1671+
if (f === "ib" && n_ib > 0) return true;
1672+
if (f === "other_all" && hasOtherAll) return true;
1673+
if (f === "other_n" && hasOtherN) return true;
1674+
}}
1675+
return false;
1676+
}}
1677+
var stripVisibleRows = new Set();
1678+
visibleRows.forEach(function(ri) {{
1679+
if (rowMatchesStripClassification(ri)) stripVisibleRows.add(ri);
1680+
}});
1681+
// When filter is on but no option selected, show nothing; when options selected, show matching rows
1682+
var rowsForStrips = !stripClassificationOn ? visibleRows : (stripOpts.length > 0 ? stripVisibleRows : new Set());
16251683
var container = document.getElementById("diagnosticStripsContainer");
16261684
if (container) {{
16271685
var sections = container.querySelectorAll(".rec-sites-section");
1686+
var showIa = false, showIb = false, showOtherAll = false, showOtherN = false;
1687+
for (var i = 0; i < stripOpts.length; i++) {{
1688+
var f = stripOpts[i].getAttribute("data-filter");
1689+
if (f === "ia") showIa = true;
1690+
if (f === "ib") showIb = true;
1691+
if (f === "other_all") showOtherAll = true;
1692+
if (f === "other_n") showOtherN = true;
1693+
}}
16281694
sections.forEach(function(sec) {{
16291695
var ri = sec.getAttribute("data-row");
1630-
sec.classList.toggle("hidden", !visibleRows.has(ri));
1696+
var sectionVisible = rowsForStrips.has(ri);
1697+
sec.classList.toggle("hidden", !sectionVisible);
1698+
var stripGenome = sec.querySelector(".strip-genome:not(.breakpoints-strip)");
1699+
if (stripGenome) {{
1700+
stripGenome.querySelectorAll(".strip-segment").forEach(function(seg) {{
1701+
if (!stripClassificationOn) {{
1702+
seg.classList.remove("segment-hidden-by-filter");
1703+
}} else if (sectionVisible) {{
1704+
var hide = false;
1705+
if (seg.classList.contains("ia")) hide = !showIa;
1706+
else if (seg.classList.contains("ib")) hide = !showIb;
1707+
else if (seg.classList.contains("other-n")) hide = !showOtherAll && !showOtherN;
1708+
else if (seg.classList.contains("other")) hide = !showOtherAll;
1709+
seg.classList.toggle("segment-hidden-by-filter", hide);
1710+
}}
1711+
}});
1712+
}}
16311713
}});
16321714
}}
16331715
var bpContainer = document.getElementById("breakpointsStripsContainer");
16341716
if (bpContainer) {{
16351717
var bpSections = bpContainer.querySelectorAll(".rec-sites-section");
16361718
bpSections.forEach(function(sec) {{
16371719
var ri = sec.getAttribute("data-row");
1638-
sec.classList.toggle("hidden", !visibleRows.has(ri));
1720+
sec.classList.toggle("hidden", !rowsForStrips.has(ri));
16391721
}});
16401722
}}
16411723
var countEl = document.getElementById("stripFilterCount");
16421724
if (countEl) {{
1643-
var n = visibleRows.size;
1725+
var n = rowsForStrips.size;
16441726
var total = rows.length;
1645-
if (n < total && total > 0)
1727+
if (stripClassificationOn)
1728+
countEl.textContent = "Showing " + n + " of " + total + " samples (filtered by classification).";
1729+
else if (n < total && total > 0)
16461730
countEl.textContent = "Showing " + n + " of " + total + " samples.";
16471731
else
16481732
countEl.textContent = "";
16491733
}}
16501734
var bpCountEl = document.getElementById("breakpointsFilterCount");
16511735
if (bpCountEl) {{
1652-
var n = visibleRows.size;
1736+
var n = rowsForStrips.size;
16531737
var total = rows.length;
1654-
if (n < total && total > 0)
1738+
if (stripClassificationOn)
1739+
bpCountEl.textContent = "Showing " + n + " of " + total + " samples (filtered by classification).";
1740+
else if (n < total && total > 0)
16551741
bpCountEl.textContent = "Showing " + n + " of " + total + " samples.";
16561742
else
16571743
bpCountEl.textContent = "";
@@ -1668,6 +1754,25 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
16681754
recFilterEl.addEventListener("change", applyFilters);
16691755
recFilterEl.addEventListener("input", applyFilters);
16701756
}}
1757+
var stripFilterCheckEl = document.getElementById("stripClassificationFilterCheck");
1758+
if (stripFilterCheckEl) {{
1759+
stripFilterCheckEl.addEventListener("change", function() {{
1760+
var opts = document.getElementById("stripClassificationOptions");
1761+
if (opts) opts.setAttribute("aria-hidden", this.checked ? "false" : "true");
1762+
applyFilters();
1763+
}});
1764+
}}
1765+
document.querySelectorAll(".strip-class-opt").forEach(function(el) {{
1766+
el.addEventListener("change", function() {{
1767+
// If any option is turned on, auto-open + check the master filter to make it obvious
1768+
var anyChecked = document.querySelector(".strip-class-opt:checked") != null;
1769+
var master = document.getElementById("stripClassificationFilterCheck");
1770+
var opts = document.getElementById("stripClassificationOptions");
1771+
if (master) master.checked = anyChecked;
1772+
if (opts) opts.setAttribute("aria-hidden", anyChecked ? "false" : "true");
1773+
applyFilters();
1774+
}});
1775+
}});
16711776
16721777
thead.querySelectorAll("th.sortable").forEach(function(th) {{
16731778
var colIndex = parseInt(th.dataset.col, 10);
@@ -2149,13 +2254,13 @@ def find_ref_key(ref_key: str, keys: List[str]) -> Optional[str]:
21492254
if not allegiances:
21502255
logger.warning("Query %s: no diagnostic calls", query_id)
21512256
continue
2152-
n_ia, n_ib, n_other = allegiance_summary(allegiances)
2257+
n_ia, n_ib, n_other, n_other_n = allegiance_summary(allegiances)
21532258
total = n_ia + n_ib + n_other
21542259
pct_ia = round(100.0 * n_ia / total, 2) if total else 0
21552260
pct_ib = round(100.0 * n_ib / total, 2) if total else 0
21562261
pct_other = round(100.0 * n_other / total, 2) if total else 0
21572262
# SNP-only summary for consensus and deletion present (SNP-based interpretation)
2158-
n_ia_snp, n_ib_snp, n_other_snp = allegiance_summary_snp_only(allegiances, diagnostic_snp_positions)
2263+
n_ia_snp, n_ib_snp, n_other_snp, _ = allegiance_summary_snp_only(allegiances, diagnostic_snp_positions)
21592264
total_snp = n_ia_snp + n_ib_snp + n_other_snp
21602265
consensus_snp = consensus_from_snp_percentages(
21612266
n_ia_snp, n_ib_snp, n_other_snp, ref1_label, ref2_label, pct_threshold=10.0
@@ -2170,6 +2275,7 @@ def find_ref_key(ref_key: str, keys: List[str]) -> Optional[str]:
21702275
"n_ia": n_ia,
21712276
"n_ib": n_ib,
21722277
"n_other": n_other,
2278+
"n_other_n": n_other_n,
21732279
"pct_ia": pct_ia,
21742280
"pct_ib": pct_ib,
21752281
"pct_other": pct_other,

0 commit comments

Comments
 (0)