@@ -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\' )">↓ Histogram PNG</button>'
886+ '<button class="pdf-btn" style="margin-left:0" onclick="event.stopPropagation();exportSnpRulerSvg()">↓ 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; }}
866911body {{ 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()">↓ 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 \' )">↓ 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+
10221114function escSvg(s) {{
10231115 return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
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