Skip to content

Commit 2e264b8

Browse files
committed
.
1 parent fb69548 commit 2e264b8

1 file changed

Lines changed: 117 additions & 3 deletions

File tree

recmpox/recmpox.py

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -555,7 +555,7 @@ def _write_results_html(
555555
import json
556556
from datetime import datetime
557557
out_path.parent.mkdir(parents=True, exist_ok=True)
558-
gen_time = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
558+
gen_time = datetime.utcnow().strftime("%Y-%m-%d")
559559
n_diagnostic_sites = results[0]["n_diagnostic_snps"] if results else 0
560560
part_html = ""
561561
if part_index is not None and total_parts is not None and total_parts > 1:
@@ -721,7 +721,7 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
721721
strip_ref2_name = ref2_label if ref2_label not in ("ref1", "ref2") else (ref2_spec or ref2_label)
722722
rec_sites_html = (
723723
'<details class="collapsible-section diagnostic-strips-chart" open id="diagnosticStripsSection">'
724-
'<summary><h2>Classification of diagnostic sites per sample</h2></summary>'
724+
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>'
725725
'<div class="section-inner chart-section">'
726726
'<p class="threshold-note">One strip per consensus: each segment = one diagnostic site in genomic order. <span id="stripFilterCount" aria-live="polite"></span></p>'
727727
'<p class="threshold-note">{ref1} (blue), {ref2} (orange), other (gray).</p>'
@@ -814,7 +814,7 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
814814
strip_ref2_name = ref2_label if ref2_label not in ("ref1", "ref2") else (ref2_spec or ref2_label)
815815
breakpoints_section_html = (
816816
'<details class="collapsible-section diagnostic-strips-chart" open id="breakpointsStripsSection">'
817-
'<summary><h2>Recombination tracts and predicted breakpoints per sample</h2></summary>'
817+
f'<summary><h2>Recombination tracts and predicted breakpoints per sample</h2><button class="pdf-btn" onclick="event.stopPropagation();exportStripSvg(\'breakpointsStripsSection\',\'breakpointsStripsContainer\',\'Recombination tracts and predicted breakpoints per sample\',\'{html_escape(ref1_label)}\',\'{html_escape(ref2_label)}\')">&#8595; Download SVG</button></summary>'
818818
'<div class="section-inner chart-section">'
819819
'<p class="threshold-note">Each coloured tract spans from the <strong>first to the last diagnostic SNP</strong> unambiguously derived from that clade. The predicted breakpoint lies somewhere in the <strong>uncoloured gap</strong> between adjacent tracts — its exact position cannot be determined because those intervening regions lack clade-informative diagnostic SNPs. Minimum consecutive diagnostic SNPs per tract: <strong>{min_consecutive}</strong>. <span id="breakpointsFilterCount" aria-live="polite"></span></p>'
820820
'<p class="threshold-note">{ref1} (blue), {ref2} (orange). Grey gaps = predicted breakpoint region (may be widened by ambiguous bases or poorly sequenced areas).</p>'
@@ -958,6 +958,9 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
958958
.collapsible-section:not([open]) summary::before {{ transform: rotate(-90deg); }}
959959
.collapsible-section summary h2 {{ margin: 0; font-size: 1.2em; }}
960960
.collapsible-section .section-inner {{ padding: 20px; }}
961+
.pdf-btn {{ margin-left: auto; flex-shrink: 0; font-size: 0.78em; padding: 5px 13px; border: 1.5px solid #667eea; border-radius: 5px; background: white; color: #667eea; cursor: pointer; font-weight: 600; transition: background 0.15s, color 0.15s; white-space: nowrap; }}
962+
.pdf-btn:hover {{ background: #667eea; color: white; }}
963+
.pdf-btn:disabled {{ opacity: 0.6; cursor: default; }}
961964
</style>
962965
</head>
963966
<body>
@@ -1016,6 +1019,117 @@ def _ref_box(label: str, spec: Optional[str]) -> str:
10161019
</div>
10171020
</div>
10181021
<script>
1022+
function escSvg(s) {{
1023+
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1024+
}}
1025+
1026+
function exportStripSvg(sectionId, containerId, figTitle, ref1Label, ref2Label) {{
1027+
var section = document.getElementById(sectionId);
1028+
if (section) section.open = true;
1029+
var container = document.getElementById(containerId);
1030+
if (!container) return;
1031+
1032+
var rows = Array.from(container.querySelectorAll('.rec-sites-section:not(.hidden)'));
1033+
if (!rows.length) {{ alert('No visible samples to export.'); return; }}
1034+
1035+
// Layout constants (px → SVG user units)
1036+
var ml = 16, mr = 20;
1037+
var idW = 190; // sample-ID column
1038+
var stripW = 1100; // genome strip width
1039+
var W = ml + idW + stripW + mr;
1040+
var rowH = 28;
1041+
var rowGap = 5;
1042+
var rulerH = 22;
1043+
var titleH = 22;
1044+
var legendH = 26;
1045+
var topPad = titleH + 8;
1046+
var rowsH = rows.length * (rowH + rowGap) - rowGap;
1047+
var rulerLineY = topPad + rowsH + 6;
1048+
var rulerBaseY = rulerLineY + 18;
1049+
var legY = rulerBaseY + 22;
1050+
var H = legY + 14;
1051+
1052+
var colorMap = {{ ia: '#4A90D9', ib: '#E89B3C', other: '#95a5a6' }};
1053+
var isBp = !!container.querySelector('.breakpoints-strip');
1054+
var stripBg = isBp ? '#e2e7eb' : '#e9ecef';
1055+
1056+
var s = [];
1057+
s.push('<?xml version="1.0" encoding="UTF-8"?>');
1058+
s.push('<svg xmlns="http://www.w3.org/2000/svg" width="' + W + '" height="' + H + '">');
1059+
s.push('<rect width="100%" height="100%" fill="white"/>');
1060+
1061+
// Title
1062+
s.push('<text x="' + ml + '" y="' + (titleH - 4) + '" font-family="Arial,sans-serif" font-size="13" font-weight="bold" fill="#333">' + escSvg(figTitle) + '</text>');
1063+
1064+
// Sample rows
1065+
rows.forEach(function(row, ri) {{
1066+
var ry = topPad + ri * (rowH + rowGap);
1067+
1068+
// Sample ID (use title attr for full name if truncated)
1069+
var idEl = row.querySelector('.rec-sites-sample-id');
1070+
var sid = idEl ? (idEl.getAttribute('title') || idEl.textContent.trim()) : '';
1071+
if (sid.length > 28) sid = sid.slice(0,27) + '\u2026';
1072+
s.push('<text x="' + (ml+idW-4) + '" y="' + (ry+rowH/2) + '" font-family="Arial,sans-serif" font-size="10" fill="#333" text-anchor="end" dominant-baseline="middle">' + escSvg(sid) + '</text>');
1073+
1074+
// Strip background
1075+
s.push('<rect x="' + (ml+idW) + '" y="' + ry + '" width="' + stripW + '" height="' + rowH + '" fill="' + stripBg + '" rx="3"/>');
1076+
1077+
// Segments
1078+
var genome = row.querySelector('.strip-genome');
1079+
if (genome) {{
1080+
genome.querySelectorAll('.strip-segment').forEach(function(seg) {{
1081+
var cls = seg.classList;
1082+
var color = cls.contains('ia') ? colorMap.ia : cls.contains('ib') ? colorMap.ib : cls.contains('other') ? colorMap.other : null;
1083+
if (!color) return;
1084+
var lp = parseFloat(seg.style.left);
1085+
if (isNaN(lp)) return;
1086+
var wp = seg.style.width ? parseFloat(seg.style.width) : 0;
1087+
var sx = ml + idW + (lp/100) * stripW;
1088+
var sw = wp ? Math.max(2, (wp/100)*stripW) : 2;
1089+
s.push('<rect x="' + sx.toFixed(1) + '" y="' + ry + '" width="' + sw.toFixed(1) + '" height="' + rowH + '" fill="' + color + '"/>');
1090+
}});
1091+
// Strip border
1092+
s.push('<rect x="' + (ml+idW) + '" y="' + ry + '" width="' + stripW + '" height="' + rowH + '" fill="none" stroke="#dee2e6" stroke-width="0.5" rx="3"/>');
1093+
}}
1094+
}});
1095+
1096+
// Ruler – below the sample rows, read ticks from first visible row
1097+
var firstRuler = rows[0].querySelector('.strip-ruler');
1098+
if (firstRuler) {{
1099+
s.push('<line x1="' + (ml+idW) + '" y1="' + rulerLineY + '" x2="' + (ml+idW+stripW) + '" y2="' + rulerLineY + '" stroke="#ccc" stroke-width="0.5"/>');
1100+
firstRuler.querySelectorAll('.ruler-tick').forEach(function(tick) {{
1101+
var lp = parseFloat(tick.style.left);
1102+
if (isNaN(lp)) return;
1103+
var tx = ml + idW + (lp/100) * stripW;
1104+
s.push('<line x1="' + tx + '" y1="' + rulerLineY + '" x2="' + tx + '" y2="' + (rulerLineY+6) + '" stroke="#adb5bd" stroke-width="1"/>');
1105+
s.push('<text x="' + tx + '" y="' + rulerBaseY + '" font-family="Arial,sans-serif" font-size="9" fill="#777" text-anchor="middle">' + escSvg(tick.textContent.trim()) + '</text>');
1106+
}});
1107+
}}
1108+
1109+
// Legend
1110+
var legX = ml + idW;
1111+
function legItem(x, color, label, isRect) {{
1112+
if (isRect) s.push('<rect x="'+x+'" y="'+(legY-11)+'" width="13" height="13" fill="'+color+'" stroke="#adb5bd" stroke-width="0.5" rx="2"/>');
1113+
else s.push('<rect x="'+x+'" y="'+(legY-11)+'" width="13" height="13" fill="'+color+'" rx="2"/>');
1114+
s.push('<text x="'+(x+17)+'" y="'+legY+'" font-family="Arial,sans-serif" font-size="10" fill="#495057">'+escSvg(label)+'</text>');
1115+
}}
1116+
legItem(legX, '#4A90D9', ref1Label, false); legX += 14+8+ref1Label.length*6+12;
1117+
legItem(legX, '#E89B3C', ref2Label, false); legX += 14+8+ref2Label.length*6+12;
1118+
if (!isBp) {{
1119+
legItem(legX, '#95a5a6', 'other', false);
1120+
}} else {{
1121+
legItem(legX, '#e2e7eb', 'predicted breakpoint region', true);
1122+
}}
1123+
1124+
s.push('</svg>');
1125+
1126+
var blob = new Blob([s.join('\\n')], {{ type: 'image/svg+xml;charset=utf-8' }});
1127+
var a = document.createElement('a');
1128+
a.href = URL.createObjectURL(blob);
1129+
a.download = figTitle.replace(/[^a-z0-9]+/gi,'_').toLowerCase() + '.svg';
1130+
document.body.appendChild(a); a.click(); document.body.removeChild(a);
1131+
URL.revokeObjectURL(a.href);
1132+
}}
10191133
(function() {{
10201134
function run() {{
10211135
if (typeof Chart === "undefined") {{ setTimeout(run, 30); return; }}

0 commit comments

Comments
 (0)