Skip to content

Commit 5e8426d

Browse files
committed
.
1 parent 2e264b8 commit 5e8426d

2 files changed

Lines changed: 109 additions & 17 deletions

File tree

html_example.png

-33.7 KB
Loading

recmpox/recmpox.py

Lines changed: 109 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -458,30 +458,70 @@ def _write_indel_regions_side_by_side(
458458
f.write("\n")
459459

460460

461-
def _snp_positions_svg(positions: List[int], genome_length: int, width_units: int = 1000, height: int = 50) -> str:
462-
"""Build an SVG showing diagnostic SNP positions along the genome; each tick has a title for hover."""
461+
def _snp_positions_svg(positions: List[int], genome_length: int, width_units: int = 1000, height: int = 44) -> str:
462+
"""Build an SVG showing diagnostic SNP positions along the genome; each tick has a title for hover.
463+
kbp scale marks and SNP ticks are drawn below the baseline, labels at the bottom.
464+
"""
463465
if genome_length <= 0 or not positions:
464466
return ""
465-
y_line = height // 2
466-
y_tick_bottom = y_line + 12
467-
hit_width = max(4, width_units // 80) # wider hover target so tooltip is easy to trigger
467+
y_line = 6 # horizontal baseline near the top
468+
y_kbp_tick = 16 # bottom of kbp tick (10 px below baseline)
469+
y_kbp_label = 30 # kbp label baseline (below the tick)
470+
y_snp_bottom = 19 # bottom of SNP tick (13 px below baseline)
471+
472+
# Same adaptive step logic as _genome_ruler_html
473+
step_bp = 10000
474+
for s in [1000, 2000, 5000, 10000, 20000, 50000, 100000, 200000, 500000]:
475+
if genome_length / s <= 20:
476+
step_bp = s
477+
break
478+
479+
hit_width = max(4, width_units // 80)
468480
parts = [
469481
f'<svg class="snp-positions-svg" viewBox="0 0 {width_units} {height}" preserveAspectRatio="xMidYMid meet" style="max-width:100%; height:auto;">',
470482
f'<line x1="0" y1="{y_line}" x2="{width_units}" y2="{y_line}" stroke="#333" stroke-width="1.5"/>',
471483
]
484+
485+
# kbp scale marks (downward from baseline)
486+
kbp_pos = 0
487+
while kbp_pos <= genome_length:
488+
x = (kbp_pos / genome_length) * width_units
489+
label = "0" if kbp_pos == 0 else f"{kbp_pos // 1000}k"
490+
parts.append(
491+
f'<line x1="{x:.1f}" y1="{y_line}" x2="{x:.1f}" y2="{y_kbp_tick}"'
492+
f' stroke="#adb5bd" stroke-width="0.8" pointer-events="none"/>'
493+
)
494+
anchor = "start" if kbp_pos == 0 else "middle"
495+
parts.append(
496+
f'<text x="{x:.1f}" y="{y_kbp_label}" font-size="9" fill="#888"'
497+
f' text-anchor="{anchor}">{label}</text>'
498+
)
499+
kbp_pos += step_bp
500+
# Final tick at exact genome end if not already aligned
501+
if genome_length % step_bp != 0:
502+
x_end = width_units
503+
parts.append(
504+
f'<line x1="{x_end}" y1="{y_line}" x2="{x_end}" y2="{y_kbp_tick}"'
505+
f' stroke="#adb5bd" stroke-width="0.8" pointer-events="none"/>'
506+
)
507+
parts.append(
508+
f'<text x="{x_end}" y="{y_kbp_label}" font-size="9" fill="#888"'
509+
f' text-anchor="end">{genome_length // 1000}k</text>'
510+
)
511+
512+
# SNP ticks (downward from baseline)
472513
for pos in positions:
473514
x = (pos / genome_length) * width_units
474515
x = max(0, min(width_units, x))
475516
rx = max(0, x - hit_width / 2)
476517
rw = min(hit_width, width_units - rx)
477518
parts.append(
478519
f'<g><title>Position: {pos} bp</title>'
479-
f'<rect x="{rx}" y="0" width="{rw}" height="{height}" fill="transparent" class="snp-tick-hit"/>'
480-
f'<line x1="{x}" y1="{y_line}" x2="{x}" y2="{y_tick_bottom}" stroke="#667eea" stroke-width="1" pointer-events="none"/>'
520+
f'<rect x="{rx:.2f}" y="0" width="{rw:.2f}" height="{height}" fill="transparent" class="snp-tick-hit"/>'
521+
f'<line x1="{x:.2f}" y1="{y_line}" x2="{x:.2f}" y2="{y_snp_bottom}" stroke="#667eea" stroke-width="1" pointer-events="none"/>'
481522
f'</g>'
482523
)
483-
parts.append(f'<text x="0" y="{height - 4}" font-size="10" fill="#495057">0</text>')
484-
parts.append(f'<text x="{width_units - 28}" y="{height - 4}" font-size="10" fill="#495057" text-anchor="end">{genome_length}</text>')
524+
485525
parts.append("</svg>")
486526
return "\n".join(parts)
487527

@@ -840,7 +880,11 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
840880
)
841881
snp_positions_section_html = (
842882
'<details class="collapsible-section" open id="snpPositionsSection">'
843-
'<summary><h2>Diagnostic SNP positions between reference genomes</h2></summary>'
883+
'<summary><h2>Diagnostic SNP positions between reference genomes</h2>'
884+
'<span style="display:flex;gap:6px;margin-left:auto;flex-shrink:0;">'
885+
'<button class="pdf-btn" style="margin-left:0" onclick="event.stopPropagation();exportChartPng(\'chartSnpPositions\',\'snp_positions_histogram.png\')">&#8595; Histogram PNG</button>'
886+
'<button class="pdf-btn" style="margin-left:0" onclick="event.stopPropagation();exportSnpRulerSvg()">&#8595; Ruler SVG</button>'
887+
'</span></summary>'
844888
'<div class="section-inner chart-section">'
845889
'<p class="threshold-note"><strong>{n_snps} diagnostic SNPs.</strong> Density of diagnostic SNPs along the reference (alignment coordinates). Use this to interpret where recombination breakpoints may fall. Squirrel always builds alignments relative to reference NC_003310 (Clade I) or NC_063383 (Clade II), not the refs you specified.</p>'
846890
'<div class="snp-positions-wrapper"><div class="chart-container" style="height:220px;"><canvas id="chartSnpPositions"></canvas></div></div>'
@@ -861,6 +905,7 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
861905
<meta name="viewport" content="width=device-width, initial-scale=1.0">
862906
<title>RecMpox Results</title>
863907
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
908+
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
864909
<style>
865910
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
866911
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 20px; min-height: 100vh; }}
@@ -925,8 +970,8 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
925970
.rec-sites-sample-id {{ font-size: 0.95em; font-weight: 600; color: #333; width: 300px; min-width: 300px; max-width: 300px; overflow: visible; white-space: normal; word-break: break-word; flex-shrink: 0; }}
926971
.strip-cell {{ flex: 1 0 0; min-width: 0; overflow-x: auto; overflow-y: hidden; border-radius: 4px; border: 1px solid #e9ecef; -webkit-overflow-scrolling: touch; }}
927972
.strip-genome {{ position: relative; width: 100%; height: 24px; min-width: 200px; border-radius: 4px; overflow: hidden; background: #e9ecef; }}
928-
.strip-genome.breakpoints-strip {{ background: #ced4da; height: 32px; border-radius: 6px; box-shadow: inset 0 2px 6px rgba(0,0,0,0.13); }}
929-
.strip-genome.breakpoints-strip::after {{ content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, rgba(255,255,255,0.18) 0%, transparent 55%); pointer-events: none; z-index: 5; border-radius: inherit; }}
973+
.strip-genome.breakpoints-strip {{ background: linear-gradient(135deg, #c4d3e0 0%, #b6c8d7 60%, #bfcdd9 100%); height: 32px; border-radius: 6px; box-shadow: inset 0 2px 8px rgba(74,144,217,0.13), inset 0 -1px 3px rgba(0,0,0,0.07); }}
974+
.strip-genome.breakpoints-strip::after {{ content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, rgba(255,255,255,0.22) 0%, transparent 50%); pointer-events: none; z-index: 5; border-radius: inherit; }}
930975
.strip-segment {{ position: absolute; top: 0; height: 100%; width: 2px; transition: opacity 0.15s; }}
931976
.strip-segment:hover {{ opacity: 0.75; }}
932977
.strip-segment.ia {{ background: #4A90D9; }}
@@ -941,7 +986,7 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
941986
.strip-legend-ia {{ display: inline-block; width: 14px; height: 14px; background: #4A90D9; border-radius: 2px; }}
942987
.strip-legend-ib {{ display: inline-block; width: 14px; height: 14px; background: #E89B3C; border-radius: 2px; }}
943988
.strip-legend-other {{ display: inline-block; width: 14px; height: 14px; background: #95a5a6; border-radius: 2px; }}
944-
.strip-legend-gap {{ display: inline-block; width: 14px; height: 14px; background: #ced4da; border: 1px solid #adb5bd; border-radius: 2px; }}
989+
.strip-legend-gap {{ display: inline-block; width: 14px; height: 14px; background: linear-gradient(135deg, #c4d3e0, #b6c8d7); border: 1px solid #9fb8cc; border-radius: 2px; }}
945990
.strip-ruler {{ position: relative; width: 100%; height: 22px; min-width: 200px; margin-top: 3px; }}
946991
.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; }}
947992
.ruler-tick::before {{ content: ''; display: block; position: absolute; top: 0; left: 50%; transform: translateX(-50%); width: 1px; height: 5px; background: #adb5bd; }}
@@ -984,7 +1029,7 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
9841029
{threshold_html}
9851030
</div>
9861031
<details class="collapsible-section" open>
987-
<summary><h2>Per-genome classification (recombinant genomes)</h2></summary>
1032+
<summary><h2>Per-genome classification (recombinant genomes)</h2><button class="pdf-btn" onclick="event.stopPropagation();exportTableXlsx()">&#8595; Download XLSX</button></summary>
9881033
<div class="section-inner table-section">
9891034
<table id="t">
9901035
<thead>
@@ -998,7 +1043,7 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
9981043
</div>
9991044
</details>
10001045
<details class="collapsible-section" open>
1001-
<summary><h2>Diagnostic SNPs per genome (stacked barplot)</h2></summary>
1046+
<summary><h2>Diagnostic SNPs per genome (stacked barplot)</h2><button class="pdf-btn" onclick="event.stopPropagation();exportChartPng(\'chartBar\',\'diagnostic_snps_barplot.png\')">&#8595; Download PNG</button></summary>
10021047
<div class="section-inner chart-section">
10031048
<p class="threshold-note">Stacked percentage per genome: % {html_escape(ref1_label)} (blue), % {html_escape(ref2_label)} (purple), % other (gray).</p>
10041049
<div class="chart-legend stacked-bar-legend">
@@ -1019,6 +1064,53 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
10191064
</div>
10201065
</div>
10211066
<script>
1067+
function exportChartPng(canvasId, filename) {{
1068+
var canvas = document.getElementById(canvasId);
1069+
if (!canvas) {{ alert('Chart not ready yet – please wait a moment and try again.'); return; }}
1070+
var a = document.createElement('a');
1071+
a.href = canvas.toDataURL('image/png');
1072+
a.download = filename;
1073+
document.body.appendChild(a); a.click(); document.body.removeChild(a);
1074+
}}
1075+
1076+
function exportSnpRulerSvg() {{
1077+
var svg = document.querySelector('.snp-positions-svg');
1078+
if (!svg) {{ alert('SVG ruler not found.'); return; }}
1079+
var serializer = new XMLSerializer();
1080+
var svgStr = serializer.serializeToString(svg);
1081+
var blob = new Blob([svgStr], {{ type: 'image/svg+xml;charset=utf-8' }});
1082+
var a = document.createElement('a');
1083+
a.href = URL.createObjectURL(blob);
1084+
a.download = 'diagnostic_snp_positions_ruler.svg';
1085+
document.body.appendChild(a); a.click(); document.body.removeChild(a);
1086+
URL.revokeObjectURL(a.href);
1087+
}}
1088+
1089+
function exportTableXlsx() {{
1090+
if (typeof XLSX === 'undefined') {{ alert('XLSX library not loaded.'); return; }}
1091+
var headers = [];
1092+
var thead = document.querySelector('#t thead tr:first-child');
1093+
if (!thead) return;
1094+
thead.querySelectorAll('th').forEach(function(th) {{
1095+
headers.push(th.textContent.trim().replace(/[\u2195\u2191\u2193\u25b2\u25bc]/g, '').trim());
1096+
}});
1097+
var tbody = document.querySelector('#t tbody');
1098+
var wsData = [headers];
1099+
Array.from(tbody.querySelectorAll('tr:not(.hidden)')).forEach(function(tr) {{
1100+
var row = [];
1101+
Array.from(tr.cells).forEach(function(td) {{
1102+
var val = td.textContent.trim();
1103+
var num = parseFloat(val);
1104+
row.push(isNaN(num) ? val : num);
1105+
}});
1106+
wsData.push(row);
1107+
}});
1108+
var ws = XLSX.utils.aoa_to_sheet(wsData);
1109+
var wb = XLSX.utils.book_new();
1110+
XLSX.utils.book_append_sheet(wb, ws, 'RecMpox Results');
1111+
XLSX.writeFile(wb, 'recmpox_results.xlsx');
1112+
}}
1113+
10221114
function escSvg(s) {{
10231115
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
10241116
}}
@@ -1051,7 +1143,7 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
10511143
10521144
var colorMap = {{ ia: '#4A90D9', ib: '#E89B3C', other: '#95a5a6' }};
10531145
var isBp = !!container.querySelector('.breakpoints-strip');
1054-
var stripBg = isBp ? '#e2e7eb' : '#e9ecef';
1146+
var stripBg = isBp ? '#bfcdd9' : '#e9ecef';
10551147
10561148
var s = [];
10571149
s.push('<?xml version="1.0" encoding="UTF-8"?>');
@@ -1118,7 +1210,7 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
11181210
if (!isBp) {{
11191211
legItem(legX, '#95a5a6', 'other', false);
11201212
}} else {{
1121-
legItem(legX, '#e2e7eb', 'predicted breakpoint region', true);
1213+
legItem(legX, '#bfcdd9', 'predicted breakpoint region (affected by ambiguous bases / poor coverage)', true);
11221214
}}
11231215
11241216
s.push('</svg>');

0 commit comments

Comments
 (0)