|
|
@@ -0,0 +1,1321 @@
|
|
|
+// gene_svg.rs
|
|
|
+//
|
|
|
+// Overview
|
|
|
+// --------
|
|
|
+// Render a gene/transcript model as an SVG string with:
|
|
|
+// • Exons scaled to genomic length (rectangles)
|
|
|
+// • Introns at fixed pixel width (connectors)
|
|
|
+// • Protein domains drawn inside the exon band (to scale, optional labels)
|
|
|
+// • Lollipop pins for point-like events (e.g., variants), with automatic multi-lane layout
|
|
|
+// • Interval “ranges” tracks above/below the exon band, with lane packing
|
|
|
+// • Orientation baseline with 5′ / 3′ labels
|
|
|
+// • Auto-built legend (aggregates counts by `kind` across lollipops and ranges)
|
|
|
+//
|
|
|
+// Dependencies
|
|
|
+// ------------
|
|
|
+// • std only
|
|
|
+// • Consumes crate types: `crate::gene_model::{GeneModel, Interval, Strand}`,
|
|
|
+// `crate::loliplot_parser::{ProteinDomain, Rgb}` and `crate::mutation::Range`.
|
|
|
+//
|
|
|
+// This module does NOT read GFF3/TSV files. Upstream code should build the inputs
|
|
|
+// (`GeneModel`, protein `domains`, lollipop list, and `ranges`). Chromosome
|
|
|
+// matching is a simple string equality on `chrom` fields.
|
|
|
+//
|
|
|
+// Quick start
|
|
|
+// -----------
|
|
|
+// let svg = gene_svg::gene_model_to_svg(
|
|
|
+// &gm, // GeneModel { chrom, strand, exons, start, end, ... }
|
|
|
+// &domains, // &[ProteinDomain { start, end, name, color: Option<Rgb> }]
|
|
|
+// &lollipops, // &[Lollipop { text, chrom, pos, color, side, kind }]
|
|
|
+// &ranges, // &[Range { chrom, start, end, color, side, kind }]
|
|
|
+// &SvgConfig::default()
|
|
|
+// );
|
|
|
+// std::fs::write("gene.svg", svg)?;
|
|
|
+//
|
|
|
+// Coordinates & layout
|
|
|
+// --------------------
|
|
|
+// • Exons are drawn to scale in transcript left→right space. For minus-strand genes,
|
|
|
+// exons are reversed so exon1 is still on the left; internal mapping honors strand.
|
|
|
+// • Introns have a FIXED pixel width (not to scale) to keep long genes readable.
|
|
|
+// • A central “baseline” indicates transcription direction with chevrons and 5′/3′ labels.
|
|
|
+// • Domains are clipped to exons, drawn within the exon band; labels are rotated if enabled.
|
|
|
+// • Text rendering uses a sans-serif font with configurable sizes.
|
|
|
+//
|
|
|
+// Lollipops (point events)
|
|
|
+// ------------------------
|
|
|
+// • Each lollipop has: desired genomic x (`x_des`), final head x (`x_fin`), lane, side (Top/Bottom),
|
|
|
+// color, and label text.
|
|
|
+// • Placement algorithm (per side):
|
|
|
+// 1) Grid-based, order-preserving snapping with minimum head spacing (`pin_hgap + 2*radius`)
|
|
|
+// and max displacement (`pin_max_dx`), opening up to `pin_max_lanes` lanes.
|
|
|
+// 2) Try to realign heads exactly above their sticks when feasible (no overlaps, within budget).
|
|
|
+// 3) Balance head positions within each lane by bounded isotonic regression to share deviation.
|
|
|
+// • Geometry: a two-bend stem (vertical@stick → horizontal@lane → short vertical@head base) plus
|
|
|
+// a circular head with a triangular tip pointing toward the exon band. Labels are centered in heads.
|
|
|
+//
|
|
|
+// Ranges (interval tracks)
|
|
|
+// ------------------------
|
|
|
+// • Each range is mapped to transcript space and assigned to Top/Bottom. A greedy lane packer places
|
|
|
+// non-overlapping blocks with minimum horizontal gap (`range_hgap`), up to `range_max_lanes`.
|
|
|
+// • Rectangles are drawn with configurable height and alpha, outside the exon band with clearances.
|
|
|
+//
|
|
|
+// Legend
|
|
|
+// ------
|
|
|
+// • Aggregates `kind` across lollipops (summing `text` as count when it parses as usize, else +1)
|
|
|
+// and across ranges (+1 each). Centered multi-line layout with color swatches.
|
|
|
+//
|
|
|
+// Configuration (SvgConfig)
|
|
|
+// -------------------------
|
|
|
+// • Canvas & track: svg_width/height, exon_height, intron_width, left_right_margin.
|
|
|
+// • Domains: domain_height, domain_alpha, show_domain_labels.
|
|
|
+// • Exon labels: show_exon_numbers; base font size (`font_size`).
|
|
|
+// • Lollipops: pin_* (clearance, radius, point/stem sizes, stroke, font size, hgap, max_dx,
|
|
|
+// lane_gap, max_lanes).
|
|
|
+// • Ranges: range_height/alpha, range_clearance, range_lane_gap, range_hgap, range_max_lanes.
|
|
|
+//
|
|
|
+// Output
|
|
|
+// ------
|
|
|
+// • Returns a self-contained `<svg>` string with grouped layers: pins (stems, heads), track,
|
|
|
+// domains, ranges, baseline, legend.
|
|
|
+//
|
|
|
+// Notes
|
|
|
+// -----
|
|
|
+// • All distances are in pixels; genomic inputs are u64 base positions.
|
|
|
+// • This file focuses purely on drawing. Parsing/merging exons, domain sourcing, chromosome
|
|
|
+// normalization (e.g., stripping “chr”), and data filtering belong to upstream code.
|
|
|
+//
|
|
|
+
|
|
|
+use crate::gene_model::{GeneModel, Interval, Strand};
|
|
|
+use crate::loliplot_parser::{ProteinDomain, Rgb};
|
|
|
+use crate::mutation::Range;
|
|
|
+use std::fmt::Write as _;
|
|
|
+
|
|
|
+#[derive(Debug)]
|
|
|
+pub struct Lollipop {
|
|
|
+ pub text: String,
|
|
|
+ pub chrom: String,
|
|
|
+ pub pos: u64,
|
|
|
+ pub color: Rgb,
|
|
|
+ pub side: LollipopSide,
|
|
|
+ pub kind: String,
|
|
|
+}
|
|
|
+
|
|
|
+#[derive(Debug, Clone, Copy)]
|
|
|
+pub enum LollipopSide {
|
|
|
+ Top,
|
|
|
+ Bottom,
|
|
|
+}
|
|
|
+
|
|
|
+/// Rendering configuration for `gene_model_to_svg`.
|
|
|
+#[derive(Debug, Clone)]
|
|
|
+pub struct SvgConfig {
|
|
|
+ /// Overall SVG width in pixels.
|
|
|
+ pub svg_width: u32,
|
|
|
+ /// Overall SVG height in pixels.
|
|
|
+ pub svg_height: u32,
|
|
|
+
|
|
|
+ /// Height of exon rectangles (px).
|
|
|
+ pub exon_height: u32,
|
|
|
+ /// Fixed width for introns (px, not to scale).
|
|
|
+ pub intron_width: u32,
|
|
|
+ /// Left/right padding of the canvas (px).
|
|
|
+ pub left_right_margin: u32,
|
|
|
+
|
|
|
+ /// Maximum domain band height inside exons (px).
|
|
|
+ pub domain_height: u32,
|
|
|
+ /// Opacity for domain rectangles (0.0–1.0).
|
|
|
+ pub domain_alpha: f32,
|
|
|
+
|
|
|
+ /// Base font size for exon labels and legend (px).
|
|
|
+ pub font_size: u32,
|
|
|
+ /// Show exon indices (1..N) if true.
|
|
|
+ pub show_exon_numbers: bool,
|
|
|
+ /// Show protein domain names if true.
|
|
|
+ pub show_domain_labels: bool,
|
|
|
+
|
|
|
+ // Lollipops (point events)
|
|
|
+ /// Gap from exon band to pin apex (px).
|
|
|
+ pub pin_clearance: f32,
|
|
|
+ /// Radius of circular head (px).
|
|
|
+ pub pin_radius: f32,
|
|
|
+ /// Height of triangular tip pointing to band (px).
|
|
|
+ pub pin_point_h: f32,
|
|
|
+ /// Vertical stem length from baseline to apex (px).
|
|
|
+ pub pin_stem_h: f32,
|
|
|
+ /// Stroke width for pins (px).
|
|
|
+ pub pin_stroke_w: f32,
|
|
|
+ /// Font size for pin head text (px).
|
|
|
+ pub pin_font_size: u32,
|
|
|
+ /// Extra horizontal gap between pin heads (px).
|
|
|
+ pub pin_hgap: f32,
|
|
|
+ /// Maximum horizontal displacement from genomic x (px).
|
|
|
+ pub pin_max_dx: f32,
|
|
|
+ /// Vertical gap between lanes on same side (px).
|
|
|
+ pub pin_lane_gap: f32,
|
|
|
+ /// Maximum number of lanes per side (≥1).
|
|
|
+ pub pin_max_lanes: u32,
|
|
|
+
|
|
|
+ // Ranges (interval tracks)
|
|
|
+ /// Height of range rectangles (px).
|
|
|
+ pub range_height: u32,
|
|
|
+ /// Opacity for ranges (0.0–1.0).
|
|
|
+ pub range_alpha: f32,
|
|
|
+ /// Gap from exon band to first range lane (px).
|
|
|
+ pub range_clearance: f32,
|
|
|
+ /// Vertical gap between range lanes (px).
|
|
|
+ pub range_lane_gap: f32,
|
|
|
+ /// Minimum horizontal gap between ranges (px).
|
|
|
+ pub range_hgap: f32,
|
|
|
+ /// Maximum number of lanes per side (≥1).
|
|
|
+ pub range_max_lanes: u32,
|
|
|
+}
|
|
|
+
|
|
|
+impl Default for SvgConfig {
|
|
|
+ fn default() -> Self {
|
|
|
+ Self {
|
|
|
+ svg_width: 1500,
|
|
|
+ svg_height: 500,
|
|
|
+ exon_height: 150,
|
|
|
+ intron_width: 10,
|
|
|
+ left_right_margin: 20,
|
|
|
+ domain_height: 150,
|
|
|
+ domain_alpha: 0.65,
|
|
|
+ font_size: 11,
|
|
|
+ show_exon_numbers: true,
|
|
|
+ show_domain_labels: true,
|
|
|
+ pin_clearance: 20.0,
|
|
|
+ pin_radius: 9.0,
|
|
|
+ pin_point_h: 8.0,
|
|
|
+ pin_stem_h: 18.0,
|
|
|
+ pin_stroke_w: 1.5,
|
|
|
+ pin_font_size: 10,
|
|
|
+ pin_hgap: 1.0,
|
|
|
+ pin_max_dx: 60.0,
|
|
|
+ pin_lane_gap: 33.0,
|
|
|
+ pin_max_lanes: 4,
|
|
|
+
|
|
|
+ range_height: 10,
|
|
|
+ range_alpha: 0.95,
|
|
|
+ range_clearance: 18.0,
|
|
|
+ range_lane_gap: 18.0,
|
|
|
+ range_hgap: 10.0,
|
|
|
+ range_max_lanes: 6,
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// Generate a self-contained SVG for a gene/transcript view.
|
|
|
+///
|
|
|
+/// Renders:
|
|
|
+/// - Exons to genomic scale; introns at fixed pixel width.
|
|
|
+/// - Protein domains within the exon band (to scale; optional labels).
|
|
|
+/// - Lollipop pins (point events) above/below with multi-lane placement.
|
|
|
+/// - Interval ranges (tracks) above/below with greedy lane packing.
|
|
|
+/// - Orientation baseline with 5′/3′ labels and an aggregated legend.
|
|
|
+///
|
|
|
+/// Inputs:
|
|
|
+/// - `gm`: Gene model (`chrom`, `strand`, `exons`, `start..end`). Exon order is
|
|
|
+/// left→right; minus strand is reversed for display while coordinate mapping honors strand.
|
|
|
+/// - `domains`: Protein domains (genomic `start..end`, `name`, optional `Rgb`).
|
|
|
+/// - `lollipops`: Point events (`chrom`, `pos`, `text`, `color`, `side`, `kind`).
|
|
|
+/// - `ranges`: Interval events (`chrom`, `start..end`, `color`, `side`, `kind`).
|
|
|
+/// - `cfg`: Rendering parameters (sizes, spacing, alpha, fonts, lane limits).
|
|
|
+///
|
|
|
+/// Mapping & filtering:
|
|
|
+/// - Genomic → x is piecewise: exons to scale; introns linearly across `intron_width`.
|
|
|
+/// - Items with mismatched chromosome or outside `gm.start..=gm.end` are skipped.
|
|
|
+/// - Domains named `"NA"` are ignored. Domain and range segments are clipped to exon/track extent.
|
|
|
+///
|
|
|
+/// Lollipops:
|
|
|
+/// - Desired head x is the mapped genomic position. Per side (Top/Bottom), heads are
|
|
|
+/// placed on a grid with min spacing `2*pin_radius + pin_hgap`, bounded displacement
|
|
|
+/// `±pin_max_dx`, up to `pin_max_lanes` lanes. A realignment step snaps heads to their
|
|
|
+/// sticks when feasible, then a bounded isotonic pass balances deviations within each lane.
|
|
|
+/// - Geometry: vertical@stick → horizontal@lane → short vertical@head, circular head with a tip
|
|
|
+/// toward the exon band; label text centered with automatic contrast.
|
|
|
+///
|
|
|
+/// Ranges:
|
|
|
+/// - Packed per side with a greedy lane allocator enforcing `range_hgap`, up to
|
|
|
+/// `range_max_lanes`; drawn outside the exon band with `range_clearance` and `range_lane_gap`.
|
|
|
+///
|
|
|
+/// Labels & legend:
|
|
|
+/// - Exon indices shown if `cfg.show_exon_numbers`. Domain labels if `cfg.show_domain_labels`.
|
|
|
+/// - Baseline chevrons and 5′/3′ depend on `gm.strand`.
|
|
|
+/// - Legend groups by `kind`: lollipops add `text.parse::<usize>().unwrap_or(1)`, ranges add 1.
|
|
|
+///
|
|
|
+/// Returns:
|
|
|
+/// - `String` containing a complete `<svg>` element (no external assets).
|
|
|
+///
|
|
|
+/// Panics:
|
|
|
+/// - If `gm.exons` is empty.
|
|
|
+pub fn gene_model_to_svg(
|
|
|
+ gm: &GeneModel,
|
|
|
+ domains: &[ProteinDomain],
|
|
|
+ lollipops: &[Lollipop],
|
|
|
+ ranges: &[Range],
|
|
|
+ cfg: &SvgConfig,
|
|
|
+) -> String {
|
|
|
+ // ----- exon order: exon1 on the left
|
|
|
+ let mut exons = gm.exons.clone();
|
|
|
+ exons.sort_by_key(|iv| iv.start);
|
|
|
+ if matches!(gm.strand, Strand::Minus) {
|
|
|
+ exons.reverse();
|
|
|
+ }
|
|
|
+ let n = exons.len();
|
|
|
+ assert!(n > 0, "GeneModel has no exons");
|
|
|
+
|
|
|
+ // Layout scale (exons to scale, introns fixed)
|
|
|
+ let total_exon_bp: u64 = exons.iter().map(|iv| iv.end - iv.start + 1).sum();
|
|
|
+ let intron_total = cfg.intron_width.saturating_mul(n.saturating_sub(1) as u32) as u64;
|
|
|
+ let avail_width = (cfg.svg_width as u64)
|
|
|
+ .saturating_sub((2 * cfg.left_right_margin) as u64)
|
|
|
+ .saturating_sub(intron_total)
|
|
|
+ .max(1);
|
|
|
+ let bp_to_px = (avail_width as f64) / (total_exon_bp as f64);
|
|
|
+
|
|
|
+ let track_y = ((cfg.svg_height as i64 - cfg.exon_height as i64) / 2).max(0) as u32;
|
|
|
+ let baseline_y = track_y + cfg.exon_height / 2;
|
|
|
+
|
|
|
+ // Domain band
|
|
|
+ let dh = cfg.domain_height.min(cfg.exon_height);
|
|
|
+ let domain_y = track_y + ((cfg.exon_height - dh) / 2);
|
|
|
+ let alpha = cfg.domain_alpha.clamp(0.0, 1.0);
|
|
|
+
|
|
|
+ // ---- Precompute drawable segments (exon + intron) for position mapping ----
|
|
|
+ #[derive(Clone, Copy)]
|
|
|
+ struct Seg {
|
|
|
+ is_exon: bool,
|
|
|
+ g_start: u64,
|
|
|
+ g_end: u64,
|
|
|
+ x: f64,
|
|
|
+ w: f64,
|
|
|
+ exon: Option<Interval>,
|
|
|
+ }
|
|
|
+
|
|
|
+ let mut segs: Vec<Seg> = Vec::with_capacity(2 * n - 1);
|
|
|
+ let mut cursor_x = cfg.left_right_margin as f64;
|
|
|
+
|
|
|
+ for (i, ex) in exons.iter().enumerate() {
|
|
|
+ let exon_bp = (ex.end - ex.start + 1) as f64;
|
|
|
+ let exon_px = (exon_bp * bp_to_px).max(1.0);
|
|
|
+ segs.push(Seg {
|
|
|
+ is_exon: true,
|
|
|
+ g_start: ex.start,
|
|
|
+ g_end: ex.end,
|
|
|
+ x: cursor_x,
|
|
|
+ w: exon_px,
|
|
|
+ exon: Some(*ex),
|
|
|
+ });
|
|
|
+ cursor_x += exon_px;
|
|
|
+
|
|
|
+ if i + 1 < exons.len() {
|
|
|
+ // intron genomic range (for mapping positions inside introns to the fixed width)
|
|
|
+ let (g_start, g_end) = if matches!(gm.strand, Strand::Minus) {
|
|
|
+ // transcript order is reversed genomic; intron in between:
|
|
|
+ (exons[i + 1].end + 1, ex.start - 1)
|
|
|
+ } else {
|
|
|
+ (ex.end + 1, exons[i + 1].start - 1)
|
|
|
+ };
|
|
|
+ let w = cfg.intron_width as f64;
|
|
|
+ segs.push(Seg {
|
|
|
+ is_exon: false,
|
|
|
+ g_start,
|
|
|
+ g_end,
|
|
|
+ x: cursor_x,
|
|
|
+ w,
|
|
|
+ exon: None,
|
|
|
+ });
|
|
|
+ cursor_x += w;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ let gene_start_x = segs
|
|
|
+ .first()
|
|
|
+ .map(|s| s.x)
|
|
|
+ .unwrap_or(cfg.left_right_margin as f64);
|
|
|
+ let gene_end_x = cursor_x;
|
|
|
+
|
|
|
+ // Helper: map genomic pos -> canvas x in transcript left→right space
|
|
|
+ let map_pos_to_x = |pos: u64| -> Option<f64> {
|
|
|
+ for seg in &segs {
|
|
|
+ if seg.is_exon {
|
|
|
+ let ex = seg.exon.unwrap();
|
|
|
+ if pos < ex.start || pos > ex.end {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ // within exon: strand decides direction inside segment
|
|
|
+ let off_bp = match gm.strand {
|
|
|
+ Strand::Plus | Strand::Unknown => pos.saturating_sub(ex.start) as f64,
|
|
|
+ Strand::Minus => ex.end.saturating_sub(pos) as f64,
|
|
|
+ };
|
|
|
+ return Some(seg.x + off_bp * bp_to_px);
|
|
|
+ } else {
|
|
|
+ // intron: project linearly across fixed width
|
|
|
+ // guard against 0-length (rare)
|
|
|
+ let g_lo = seg.g_start.min(seg.g_end);
|
|
|
+ let g_hi = seg.g_start.max(seg.g_end);
|
|
|
+ if pos < g_lo || pos > g_hi || g_hi == g_lo {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ let frac = (pos - g_lo) as f64 / (g_hi - g_lo) as f64;
|
|
|
+ return Some(seg.x + frac * seg.w);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ None
|
|
|
+ };
|
|
|
+
|
|
|
+ // ----- SVG header -----
|
|
|
+ let mut svg = String::new();
|
|
|
+ let _ = write!(
|
|
|
+ &mut svg,
|
|
|
+ r#"<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" width="{}" height="{}" viewBox="0 0 {} {}">
|
|
|
+ <defs>
|
|
|
+ <!-- right-pointing chevron for baseline; inherits stroke color -->
|
|
|
+ <marker id="chev" markerWidth="4" markerHeight="4" refX="3.2" refY="2" orient="auto" markerUnits="strokeWidth">
|
|
|
+ <path d="M0,0 L3.2,2 L0,4" fill="none" stroke="context-stroke" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.3"/>
|
|
|
+ </marker>
|
|
|
+ </defs>
|
|
|
+ <style>
|
|
|
+ text {{ font-family: sans-serif; font-size: {}px; }}
|
|
|
+ .exon {{ stroke: #000; fill: #ccc; }}
|
|
|
+ .intron {{ stroke: #666; stroke-width: 2; fill: none; }}
|
|
|
+ .baseline {{ stroke: #555; stroke-width: 2; fill: none; }}
|
|
|
+ .label {{ fill: #555; dominant-baseline: text-before-edge; pointer-events: none; }}
|
|
|
+ .dom {{ stroke: #333; stroke-width: 1; }}
|
|
|
+ .domlbl {{ font-size: {}px; pointer-events: none; }}
|
|
|
+ .pinlbl {{ font-size: {}px; pointer-events: none; }}
|
|
|
+ .stem {{ stroke-linecap: round; stroke-linejoin: round; }}
|
|
|
+ </style>
|
|
|
+"#,
|
|
|
+ cfg.svg_width,
|
|
|
+ cfg.svg_height,
|
|
|
+ cfg.svg_width,
|
|
|
+ cfg.svg_height,
|
|
|
+ cfg.font_size,
|
|
|
+ cfg.font_size.saturating_sub(1),
|
|
|
+ cfg.pin_font_size
|
|
|
+ );
|
|
|
+
|
|
|
+ // ===== 1) LOLLIPOPS =====
|
|
|
+ let _ = writeln!(&mut svg, r#"<g id="pins">"#);
|
|
|
+
|
|
|
+ // Buffers for layered drawing
|
|
|
+ let mut pin_stems_svg = String::new(); // behind
|
|
|
+ let mut pin_heads_svg = String::new(); // on top
|
|
|
+
|
|
|
+ // Contrast: white/black for the head text
|
|
|
+ let pin_text_color = |rgb: Rgb| -> &'static str {
|
|
|
+ let l = 0.2126 * (rgb.r as f32) / 255.0
|
|
|
+ + 0.7152 * (rgb.g as f32) / 255.0
|
|
|
+ + 0.0722 * (rgb.b as f32) / 255.0;
|
|
|
+ if l > 0.55 {
|
|
|
+ "#000"
|
|
|
+ } else {
|
|
|
+ "#fff"
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // Collect desired x per side; skip if pos can’t be mapped
|
|
|
+ let mut wants_top: Vec<Want> = Vec::new();
|
|
|
+ let mut wants_bot: Vec<Want> = Vec::new();
|
|
|
+ for (i, lp) in lollipops.iter().enumerate() {
|
|
|
+ if lp.chrom != gm.chrom {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if let Some(x) = map_pos_to_x(lp.pos) {
|
|
|
+ match lp.side {
|
|
|
+ LollipopSide::Top => wants_top.push(Want { idx: i, x_des: x }),
|
|
|
+ LollipopSide::Bottom => wants_bot.push(Want { idx: i, x_des: x }),
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let r = cfg.pin_radius.max(1.0) as f64;
|
|
|
+ let min_dx = 2.0 * r + (cfg.pin_hgap as f64);
|
|
|
+ let max_dx = (cfg.pin_max_dx as f64).max(min_dx);
|
|
|
+ let max_lanes = cfg.pin_max_lanes.max(1);
|
|
|
+ let stroke_w = cfg.pin_stroke_w.max(0.5) as f64;
|
|
|
+ let tip_h = cfg.pin_point_h.max(1.0) as f64;
|
|
|
+ let clearance = cfg.pin_clearance.max(0.0) as f64;
|
|
|
+
|
|
|
+ let mut placed_top = place_side_grid_ordered(wants_top, min_dx, max_dx, max_lanes);
|
|
|
+ let mut placed_bot = place_side_grid_ordered(wants_bot, min_dx, max_dx, max_lanes);
|
|
|
+
|
|
|
+ // NEW: try to align heads with sticks where feasible (no overlap, within max_dx)
|
|
|
+ placed_top = align_heads_to_sticks(placed_top, min_dx, max_dx);
|
|
|
+ placed_bot = align_heads_to_sticks(placed_bot, min_dx, max_dx);
|
|
|
+
|
|
|
+ // NEW: balanced optimization per lane
|
|
|
+ placed_top = balance_heads_within_lanes(placed_top, min_dx, max_dx);
|
|
|
+ placed_bot = balance_heads_within_lanes(placed_bot, min_dx, max_dx);
|
|
|
+
|
|
|
+ // Lane → apex y (outside exon band). Lanes extend AWAY from the band.
|
|
|
+ let lane_gap = cfg.pin_lane_gap.max(0.0) as f64;
|
|
|
+ let apex_y_top = |lane: u32| -> f64 { (track_y as f64) - clearance - (lane as f64) * lane_gap };
|
|
|
+ let apex_y_bot = |lane: u32| -> f64 {
|
|
|
+ ((track_y + cfg.exon_height) as f64) + clearance + (lane as f64) * lane_gap
|
|
|
+ };
|
|
|
+
|
|
|
+ // Drawer with TWO bends: vertical (at x_des) → horizontal (to x_fin) → short vertical to head base.
|
|
|
+ // Tip orientation always points toward the exon band (down for Top, up for Bottom).
|
|
|
+ let draw_pin = |stems: &mut String,
|
|
|
+ heads: &mut String,
|
|
|
+ x_des: f64,
|
|
|
+ x_fin: f64,
|
|
|
+ lane: u32,
|
|
|
+ side: LollipopSide,
|
|
|
+ col: Rgb,
|
|
|
+ txt: &str| {
|
|
|
+ let (apex_y, circle_cy, tip_base_y, tip_apex_y) = match side {
|
|
|
+ LollipopSide::Top => {
|
|
|
+ let apex = apex_y_top(lane);
|
|
|
+ let cy = apex - tip_h - r;
|
|
|
+ let base = cy + r * 0.25;
|
|
|
+ (apex, cy, base, apex)
|
|
|
+ }
|
|
|
+ LollipopSide::Bottom => {
|
|
|
+ let apex = apex_y_bot(lane);
|
|
|
+ let cy = apex + tip_h + r;
|
|
|
+ let base = cy - r * 0.25;
|
|
|
+ (apex, cy, base, apex)
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // —— STEMS (behind): vertical@x_des, horizontal@apex, short vertical@x_fin
|
|
|
+ // let (y0, y1) = match side {
|
|
|
+ // LollipopSide::Top => (apex_y, baseline_y as f64),
|
|
|
+ // LollipopSide::Bottom => (baseline_y as f64, apex_y),
|
|
|
+ // };
|
|
|
+ // —— STEMS (behind): three exact segments + round caps at junctions
|
|
|
+ let stroke_attrs = format!(
|
|
|
+ r#"stroke="rgb({},{},{})" stroke-width="{}" stroke-linecap="round""#,
|
|
|
+ col.r, col.g, col.b, stroke_w
|
|
|
+ );
|
|
|
+
|
|
|
+ // 1) vertical (baseline ↔ apex)
|
|
|
+ let (y_vert_1, y_vert_2) = match side {
|
|
|
+ LollipopSide::Top => (apex_y, baseline_y as f64),
|
|
|
+ LollipopSide::Bottom => (baseline_y as f64, apex_y),
|
|
|
+ };
|
|
|
+ let _ = writeln!(
|
|
|
+ stems,
|
|
|
+ r#"<line x1="{x_des:.2}" y1="{y_vert_1:.2}" x2="{x_des:.2}" y2="{y_vert_2:.2}" {stroke_attrs}/>"#
|
|
|
+ );
|
|
|
+
|
|
|
+ // 2) horizontal at apex — EXACTLY from x_des to x_fin
|
|
|
+ let _ = writeln!(
|
|
|
+ stems,
|
|
|
+ r#"<line x1="{x_des:.2}" y1="{apex_y:.2}" x2="{x_fin:.2}" y2="{apex_y:.2}" {stroke_attrs}/>"#
|
|
|
+ );
|
|
|
+
|
|
|
+ // 3) short vertical to head base — EXACTLY from apex to tip_base
|
|
|
+ let _ = writeln!(
|
|
|
+ stems,
|
|
|
+ r#"<line x1="{x_fin:.2}" y1="{apex_y:.2}" x2="{x_fin:.2}" y2="{tip_base_y:.2}" {stroke_attrs}/>"#
|
|
|
+ );
|
|
|
+
|
|
|
+ // 4) elbow caps (mask the joints without changing geometry)
|
|
|
+ let rcap = 0.5 * stroke_w + 0.01; // tiny epsilon avoids hairline seams
|
|
|
+ let cap_attrs = format!(r#"fill="rgb({},{},{})""#, col.r, col.g, col.b);
|
|
|
+
|
|
|
+ // cap at (x_des, apex_y)
|
|
|
+ let _ = writeln!(
|
|
|
+ stems,
|
|
|
+ r#"<circle cx="{x_des:.2}" cy="{apex_y:.2}" r="{rcap:.3}" {cap_attrs}/>"#
|
|
|
+ );
|
|
|
+
|
|
|
+ // cap at (x_fin, apex_y)
|
|
|
+ let _ = writeln!(
|
|
|
+ stems,
|
|
|
+ r#"<circle cx="{x_fin:.2}" cy="{apex_y:.2}" r="{rcap:.3}" {cap_attrs}/>"#
|
|
|
+ );
|
|
|
+
|
|
|
+ // —— HEADS (on top): circle + tip + text
|
|
|
+ let tip_x1 = x_fin - r * 0.7;
|
|
|
+ let tip_x2 = x_fin + r * 0.7;
|
|
|
+
|
|
|
+ let _ = writeln!(
|
|
|
+ heads,
|
|
|
+ r#" <circle cx="{:.2}" cy="{:.2}" r="{:.2}" fill="rgb({},{},{})" stroke="\#222" stroke-width="{}"/>"#,
|
|
|
+ x_fin, circle_cy, r, col.r, col.g, col.b, stroke_w
|
|
|
+ );
|
|
|
+ let _ = writeln!(
|
|
|
+ heads,
|
|
|
+ r#" <path d="M{:.2},{:.2} L{:.2},{:.2} L{:.2},{:.2} Z" fill="rgb({},{},{})" stroke="\#222" stroke-width="{}"/>"#,
|
|
|
+ tip_x1,
|
|
|
+ tip_base_y,
|
|
|
+ tip_x2,
|
|
|
+ tip_base_y,
|
|
|
+ x_fin,
|
|
|
+ tip_apex_y,
|
|
|
+ col.r,
|
|
|
+ col.g,
|
|
|
+ col.b,
|
|
|
+ stroke_w
|
|
|
+ );
|
|
|
+ let _ = writeln!(
|
|
|
+ heads,
|
|
|
+ r#" <text class="pinlbl" x="{:.2}" y="{:.2}" text-anchor="middle" dominant-baseline="central" fill="{}">{}</text>"#,
|
|
|
+ x_fin,
|
|
|
+ circle_cy,
|
|
|
+ pin_text_color(col),
|
|
|
+ escape_text(txt)
|
|
|
+ );
|
|
|
+
|
|
|
+ // white inner circle (perimeter only)
|
|
|
+ let inset_px: f32 = 1.0; // how far inside the main circle
|
|
|
+ let ring_width: f32 = 0.5; // stroke thickness
|
|
|
+ let r_white = r as f32 - inset_px;
|
|
|
+
|
|
|
+ let _ = writeln!(
|
|
|
+ heads,
|
|
|
+ r#" <circle cx="{:.2}" cy="{:.2}" r="{:.2}" fill="none" stroke="white" stroke-width="{:.2}"/>"#,
|
|
|
+ x_fin, circle_cy, r_white, ring_width
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ // Draw TOP pins (lane 0 nearest the band; larger lane → further up)
|
|
|
+ for p in &placed_top {
|
|
|
+ let lp = &lollipops[p.idx];
|
|
|
+ draw_pin(
|
|
|
+ &mut pin_stems_svg,
|
|
|
+ &mut pin_heads_svg,
|
|
|
+ p.x_des,
|
|
|
+ p.x_fin,
|
|
|
+ p.lane,
|
|
|
+ LollipopSide::Top,
|
|
|
+ lp.color,
|
|
|
+ &lp.text,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ for p in &placed_bot {
|
|
|
+ let lp = &lollipops[p.idx];
|
|
|
+ draw_pin(
|
|
|
+ &mut pin_stems_svg,
|
|
|
+ &mut pin_heads_svg,
|
|
|
+ p.x_des,
|
|
|
+ p.x_fin,
|
|
|
+ p.lane,
|
|
|
+ LollipopSide::Bottom,
|
|
|
+ lp.color,
|
|
|
+ &lp.text,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ let _ = writeln!(&mut svg, r#"<g id="pin-stems">"#);
|
|
|
+ svg.push_str(&pin_stems_svg);
|
|
|
+ let _ = writeln!(&mut svg, "</g>");
|
|
|
+
|
|
|
+ let _ = writeln!(&mut svg, r#"<g id="pin-heads">"#);
|
|
|
+ svg.push_str(&pin_heads_svg);
|
|
|
+ let _ = writeln!(&mut svg, "</g>");
|
|
|
+
|
|
|
+ let _ = writeln!(&mut svg, "</g>");
|
|
|
+
|
|
|
+ // ===== 2) INTRONS + EXONS (BASE TRACK) =====
|
|
|
+ let _ = writeln!(&mut svg, r#"<g id="track">"#);
|
|
|
+ for (i, seg) in segs.iter().enumerate() {
|
|
|
+ if seg.is_exon {
|
|
|
+ let _ = writeln!(
|
|
|
+ &mut svg,
|
|
|
+ r#" <rect class="exon" x="{:.2}" y="{}" width="{:.2}" height="{}" />"#,
|
|
|
+ seg.x, track_y, seg.w, cfg.exon_height
|
|
|
+ );
|
|
|
+ if cfg.show_exon_numbers {
|
|
|
+ // exon index among exons only: seg index / 2 in this interleaved list
|
|
|
+ let exon_idx = (i / 2) + 1;
|
|
|
+ let _ = writeln!(
|
|
|
+ &mut svg,
|
|
|
+ r#" <text class="label" x="{:.2}" y="{}">{}</text>"#,
|
|
|
+ seg.x,
|
|
|
+ track_y + cfg.exon_height + 4,
|
|
|
+ exon_idx
|
|
|
+ );
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // intron line
|
|
|
+ let _ = writeln!(
|
|
|
+ &mut svg,
|
|
|
+ r#" <line class="intron" x1="{:.2}" y1="{}" x2="{:.2}" y2="{}" />"#,
|
|
|
+ seg.x,
|
|
|
+ baseline_y,
|
|
|
+ seg.x + seg.w,
|
|
|
+ baseline_y
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+ let _ = writeln!(&mut svg, "</g>");
|
|
|
+
|
|
|
+ // ===== 3) DOMAINS =====
|
|
|
+ let _ = writeln!(&mut svg, r#"<g id="domains">"#);
|
|
|
+
|
|
|
+ let to_left_offset_plus =
|
|
|
+ |ex: Interval, bp: u64| -> f64 { (bp.saturating_sub(ex.start) as f64) * bp_to_px };
|
|
|
+ let to_left_offset_minus =
|
|
|
+ |ex: Interval, bp: u64| -> f64 { (ex.end.saturating_sub(bp) as f64) * bp_to_px };
|
|
|
+ let min_label_w = 10.0_f64;
|
|
|
+
|
|
|
+ for d in domains {
|
|
|
+ if d.name.eq_ignore_ascii_case("NA") {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if d.end < gm.start || d.start > gm.end {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ for seg in segs.iter().filter(|s| s.is_exon) {
|
|
|
+ let ex = seg.exon.unwrap();
|
|
|
+ let s_bp = d.start.max(ex.start);
|
|
|
+ let e_bp = d.end.min(ex.end);
|
|
|
+ if s_bp > e_bp {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ let left_off_px = match gm.strand {
|
|
|
+ Strand::Plus | Strand::Unknown => to_left_offset_plus(ex, s_bp),
|
|
|
+ Strand::Minus => to_left_offset_minus(ex, e_bp),
|
|
|
+ };
|
|
|
+ let seg_bp = (e_bp - s_bp + 1) as f64;
|
|
|
+ let mut seg_w_px = (seg_bp * bp_to_px).max(1.0);
|
|
|
+ seg_w_px = seg_w_px.min(seg.w);
|
|
|
+
|
|
|
+ let (r, g, b) = d.color.map(|c| (c.r, c.g, c.b)).unwrap_or((153, 153, 153));
|
|
|
+ let x = seg.x + left_off_px;
|
|
|
+
|
|
|
+ let _ = writeln!(
|
|
|
+ &mut svg,
|
|
|
+ r#" <rect class="dom" x="{x:.2}" y="{domain_y}" width="{seg_w_px:.2}" height="{dh}" fill="rgb({r},{g},{b})" fill-opacity="{alpha:.3}" stroke-opacity="{alpha:.3}" />"#
|
|
|
+ );
|
|
|
+
|
|
|
+ if cfg.show_domain_labels && seg_w_px >= min_label_w && !d.name.is_empty() {
|
|
|
+ let cx = x + seg_w_px / 2.0;
|
|
|
+ let cy = (domain_y + dh / 2) as f64;
|
|
|
+ let txt_col = pin_text_color(Rgb { r, g, b });
|
|
|
+ let _ = writeln!(
|
|
|
+ &mut svg,
|
|
|
+ r#" <text class="domlbl" x="{:.2}" y="{:.2}" text-anchor="middle" dominant-baseline="central" fill="{}" transform="rotate(-90 {:.2} {:.2})">{}</text>"#,
|
|
|
+ cx,
|
|
|
+ cy,
|
|
|
+ txt_col,
|
|
|
+ cx,
|
|
|
+ cy,
|
|
|
+ escape_text(&d.name)
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ let _ = writeln!(&mut svg, "</g>");
|
|
|
+
|
|
|
+ // ===== 5) ORIENTATION BASELINE + 5′/3′ LABELS (FOREGROUND) =====
|
|
|
+ let _ = writeln!(&mut svg, r#"<g id="baseline">"#);
|
|
|
+ let stub = (cfg.intron_width as f64).min((cfg.svg_width as f64) * 0.15);
|
|
|
+ let left_x1 = (gene_start_x - stub).max(0.0);
|
|
|
+ let right_x2 = (gene_end_x + stub).min(cfg.svg_width as f64);
|
|
|
+
|
|
|
+ let (left_lbl, right_lbl) = match gm.strand {
|
|
|
+ Strand::Minus => ("3′", "5′"),
|
|
|
+ _ => ("5′", "3′"),
|
|
|
+ };
|
|
|
+
|
|
|
+ // left stub
|
|
|
+ let _ = writeln!(
|
|
|
+ &mut svg,
|
|
|
+ r#" <line class="baseline" x1="{left_x1:.2}" y1="{baseline_y}" x2="{gene_start_x}" y2="{baseline_y}" marker-end="url(#chev)" />"#
|
|
|
+ );
|
|
|
+ let _ = writeln!(
|
|
|
+ &mut svg,
|
|
|
+ r#" <text class="label" x="{:.2}" y="{}" text-anchor="end">{}</text>"#,
|
|
|
+ left_x1 - 2.0,
|
|
|
+ baseline_y.saturating_sub(12),
|
|
|
+ left_lbl
|
|
|
+ );
|
|
|
+ // right stub
|
|
|
+ let _ = writeln!(
|
|
|
+ &mut svg,
|
|
|
+ r#" <line class="baseline" x1="{gene_end_x}" y1="{baseline_y}" x2="{right_x2:.2}" y2="{baseline_y}" marker-end="url(#chev)" />"#
|
|
|
+ );
|
|
|
+ let _ = writeln!(
|
|
|
+ &mut svg,
|
|
|
+ r#" <text class="label" x="{:.2}" y="{}" text-anchor="start">{}</text>"#,
|
|
|
+ right_x2 + 2.0,
|
|
|
+ baseline_y.saturating_sub(12),
|
|
|
+ right_lbl
|
|
|
+ );
|
|
|
+ let _ = writeln!(&mut svg, "</g>");
|
|
|
+
|
|
|
+ let map_span_to_x = |s: u64, e: u64| -> Option<(f64, f64)> {
|
|
|
+ let s1 = s.max(gm.start);
|
|
|
+ let e1 = e.min(gm.end);
|
|
|
+ if s1 > e1 {
|
|
|
+ return None;
|
|
|
+ }
|
|
|
+ let x_s = map_pos_to_x(s1)?;
|
|
|
+ let x_e = map_pos_to_x(e1)?;
|
|
|
+ Some(if x_s <= x_e { (x_s, x_e) } else { (x_e, x_s) })
|
|
|
+ };
|
|
|
+ // ===== RANGES (interval tracks) =====
|
|
|
+ let mut ranges_top_xy: Vec<(usize, f64, f64)> = Vec::new();
|
|
|
+ let mut ranges_bot_xy: Vec<(usize, f64, f64)> = Vec::new();
|
|
|
+
|
|
|
+ for (i, rg) in ranges.iter().enumerate() {
|
|
|
+ if rg.chrom != gm.chrom {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if let Some((x1, x2)) = map_span_to_x(rg.start, rg.end) {
|
|
|
+ match rg.side {
|
|
|
+ LollipopSide::Top => ranges_top_xy.push((i, x1, x2)),
|
|
|
+ LollipopSide::Bottom => ranges_bot_xy.push((i, x1, x2)),
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let range_h = cfg.range_height as f64;
|
|
|
+ let r_clear = cfg.range_clearance.max(0.0) as f64;
|
|
|
+ let lane_gap = cfg.range_lane_gap.max(0.0) as f64;
|
|
|
+ let max_lanes = cfg.range_max_lanes.max(1);
|
|
|
+ let alpha_r = cfg.range_alpha.clamp(0.0, 1.0);
|
|
|
+ let hgap = cfg.range_hgap.max(0.0) as f64;
|
|
|
+
|
|
|
+ // pack per side
|
|
|
+ let packed_top = place_ranges_multilane(&ranges_top_xy, hgap, max_lanes);
|
|
|
+ let packed_bot = place_ranges_multilane(&ranges_bot_xy, hgap, max_lanes);
|
|
|
+
|
|
|
+ // lane → y coordinate (outside exon band, like pins)
|
|
|
+ let y_top = |lane: u32| (track_y as f64) - r_clear - (lane as f64) * lane_gap - range_h;
|
|
|
+ let y_bot =
|
|
|
+ |lane: u32| ((track_y + cfg.exon_height) as f64) + r_clear + (lane as f64) * lane_gap;
|
|
|
+
|
|
|
+ // draw
|
|
|
+ let _ = writeln!(&mut svg, r#"<g id="ranges">"#);
|
|
|
+ for d in packed_top {
|
|
|
+ let r = &ranges[d.idx];
|
|
|
+ let y = y_top(d.lane);
|
|
|
+ let w = (d.x2 - d.x1).max(1.0);
|
|
|
+ let _ = writeln!(
|
|
|
+ &mut svg,
|
|
|
+ r#" <rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="rgb({},{},{})" fill-opacity="{:.3}" stroke="rgb({},{},{})" stroke-opacity="{:.3}" stroke-width="1"/>"#,
|
|
|
+ d.x1,
|
|
|
+ y,
|
|
|
+ w,
|
|
|
+ range_h,
|
|
|
+ r.color.r,
|
|
|
+ r.color.g,
|
|
|
+ r.color.b,
|
|
|
+ alpha_r,
|
|
|
+ r.color.r,
|
|
|
+ r.color.g,
|
|
|
+ r.color.b,
|
|
|
+ alpha_r
|
|
|
+ );
|
|
|
+ }
|
|
|
+ for d in packed_bot {
|
|
|
+ let r = &ranges[d.idx];
|
|
|
+ let y = y_bot(d.lane);
|
|
|
+ let w = (d.x2 - d.x1).max(1.0);
|
|
|
+ let _ = writeln!(
|
|
|
+ &mut svg,
|
|
|
+ r#" <rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="rgb({},{},{})" fill-opacity="{:.3}" stroke="rgb({},{},{})" stroke-opacity="{:.3}" stroke-width="1"/>"#,
|
|
|
+ d.x1,
|
|
|
+ y,
|
|
|
+ w,
|
|
|
+ range_h,
|
|
|
+ r.color.r,
|
|
|
+ r.color.g,
|
|
|
+ r.color.b,
|
|
|
+ alpha_r,
|
|
|
+ r.color.r,
|
|
|
+ r.color.g,
|
|
|
+ r.color.b,
|
|
|
+ alpha_r
|
|
|
+ );
|
|
|
+ }
|
|
|
+ let _ = writeln!(&mut svg, "</g>");
|
|
|
+
|
|
|
+ // ===== LEGEND (kinds from lollipops + ranges) =====
|
|
|
+ use std::collections::BTreeMap;
|
|
|
+ let mut counts: BTreeMap<String, (Rgb, usize)> = BTreeMap::new();
|
|
|
+
|
|
|
+ // from lollipops
|
|
|
+ for lp in lollipops {
|
|
|
+ let e = counts.entry(lp.kind.clone()).or_insert((lp.color, 0));
|
|
|
+ e.1 += lp.text.parse::<usize>().unwrap_or(1);
|
|
|
+ }
|
|
|
+ // from ranges
|
|
|
+ for rg in ranges {
|
|
|
+ let e = counts.entry(rg.kind.clone()).or_insert((rg.color, 0));
|
|
|
+ e.1 += 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ let box_size = cfg.font_size as f64;
|
|
|
+ let hgap = (cfg.font_size as f64) * 2.5;
|
|
|
+ let line_height = box_size * 2.5;
|
|
|
+ let max_width = (cfg.svg_width as f64) - 2.0 * (cfg.left_right_margin as f64);
|
|
|
+
|
|
|
+ // Precompute items with widths (same as before)
|
|
|
+ let mut lines: Vec<Vec<(&str, &Rgb, &usize, f64)>> = Vec::new();
|
|
|
+ let mut current_line: Vec<(&str, &Rgb, &usize, f64)> = Vec::new();
|
|
|
+ let mut line_width = 0.0;
|
|
|
+
|
|
|
+ for (kind, (rgb, n)) in &counts {
|
|
|
+ let item_width =
|
|
|
+ box_size + 4.0 + (kind.len() as f64 + 6.0) * (cfg.font_size as f64 * 0.6) + hgap;
|
|
|
+
|
|
|
+ if line_width + item_width > max_width && !current_line.is_empty() {
|
|
|
+ lines.push(current_line);
|
|
|
+ current_line = Vec::new();
|
|
|
+ line_width = 0.0;
|
|
|
+ }
|
|
|
+
|
|
|
+ current_line.push((kind, rgb, n, item_width));
|
|
|
+ line_width += item_width;
|
|
|
+ }
|
|
|
+ if !current_line.is_empty() {
|
|
|
+ lines.push(current_line);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Start rendering from the top
|
|
|
+ let mut legend_y =
|
|
|
+ cfg.svg_height as f64 - (cfg.left_right_margin as f64 * 2.0) - cfg.font_size as f64;
|
|
|
+ writeln!(&mut svg, r#"<g id="legend">"#).unwrap();
|
|
|
+
|
|
|
+ for line in lines {
|
|
|
+ let total_width: f64 = line.iter().map(|(_, _, _, w)| *w).sum();
|
|
|
+ let mut legend_x = (cfg.svg_width as f64 - total_width) / 2.0; // center this line
|
|
|
+
|
|
|
+ for (kind, rgb, n, item_width) in line {
|
|
|
+ let box_x = legend_x - box_size / 2.0;
|
|
|
+ writeln!(
|
|
|
+ &mut svg,
|
|
|
+ r#" <rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}"
|
|
|
+ fill="rgb({},{},{})" stroke="\#000" stroke-width="0.5"/>"#,
|
|
|
+ box_x,
|
|
|
+ legend_y - box_size * 0.8,
|
|
|
+ box_size,
|
|
|
+ box_size,
|
|
|
+ rgb.r,
|
|
|
+ rgb.g,
|
|
|
+ rgb.b
|
|
|
+ )
|
|
|
+ .unwrap();
|
|
|
+
|
|
|
+ let label = format!("{kind} (n={n})");
|
|
|
+ let text_x = legend_x + box_size / 2.0 + 4.0;
|
|
|
+ writeln!(
|
|
|
+ &mut svg,
|
|
|
+ r#" <text x="{:.2}" y="{:.2}" font-size="{}"
|
|
|
+ dominant-baseline="hanging" text-anchor="start">{}</text>"#,
|
|
|
+ text_x,
|
|
|
+ legend_y - box_size * 0.8,
|
|
|
+ cfg.font_size,
|
|
|
+ escape_text(&label)
|
|
|
+ )
|
|
|
+ .unwrap();
|
|
|
+
|
|
|
+ legend_x += item_width;
|
|
|
+ }
|
|
|
+
|
|
|
+ legend_y += line_height;
|
|
|
+ }
|
|
|
+ writeln!(&mut svg, "</g>").unwrap();
|
|
|
+
|
|
|
+ svg.push_str("</svg>\n");
|
|
|
+ svg
|
|
|
+}
|
|
|
+// Minimal XML escaper for text nodes.
|
|
|
+fn escape_text(s: &str) -> String {
|
|
|
+ let mut out = String::with_capacity(s.len());
|
|
|
+ for ch in s.chars() {
|
|
|
+ match ch {
|
|
|
+ '&' => out.push_str("&"),
|
|
|
+ '<' => out.push_str("<"),
|
|
|
+ '>' => out.push_str(">"),
|
|
|
+ '"' => out.push_str("""),
|
|
|
+ '\'' => out.push_str("'"),
|
|
|
+ _ => out.push(ch),
|
|
|
+ }
|
|
|
+ }
|
|
|
+ out
|
|
|
+}
|
|
|
+
|
|
|
+#[derive(Clone, Copy)]
|
|
|
+struct Placed {
|
|
|
+ idx: usize,
|
|
|
+ x_des: f64,
|
|
|
+ x_fin: f64,
|
|
|
+ lane: u32,
|
|
|
+}
|
|
|
+
|
|
|
+#[derive(Clone, Copy)]
|
|
|
+struct Want {
|
|
|
+ idx: usize,
|
|
|
+ x_des: f64,
|
|
|
+}
|
|
|
+
|
|
|
+#[derive(Clone, Copy)]
|
|
|
+struct DrawRange {
|
|
|
+ idx: usize,
|
|
|
+ x1: f64,
|
|
|
+ x2: f64,
|
|
|
+ lane: u32,
|
|
|
+}
|
|
|
+
|
|
|
+fn place_ranges_multilane(
|
|
|
+ spans: &[(usize, f64, f64)], // (index in input vec, x1<=x2)
|
|
|
+ hgap: f64,
|
|
|
+ max_lanes: u32,
|
|
|
+) -> Vec<DrawRange> {
|
|
|
+ // sort by left edge
|
|
|
+ let mut items = spans.to_vec();
|
|
|
+ items.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
|
|
|
+
|
|
|
+ // for each lane, track rightmost end used
|
|
|
+ let mut right_edge: Vec<f64> = vec![f64::NEG_INFINITY; max_lanes as usize];
|
|
|
+ let mut out = Vec::with_capacity(items.len());
|
|
|
+
|
|
|
+ 'outer: for (idx, x1, x2) in items {
|
|
|
+ for lane in 0..max_lanes {
|
|
|
+ let re = right_edge[lane as usize];
|
|
|
+ if x1 >= re + hgap {
|
|
|
+ out.push(DrawRange { idx, x1, x2, lane });
|
|
|
+ right_edge[lane as usize] = x2;
|
|
|
+ continue 'outer;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // If no lane fits, put into the last lane anyway (minimal overlap fallback)
|
|
|
+ let lane = max_lanes.saturating_sub(1);
|
|
|
+ out.push(DrawRange { idx, x1, x2, lane });
|
|
|
+ right_edge[lane as usize] = right_edge[lane as usize].max(x2);
|
|
|
+ }
|
|
|
+
|
|
|
+ out
|
|
|
+}
|
|
|
+
|
|
|
+/// Grid-based, order-preserving lollipop placement.
|
|
|
+/// - Heads are snapped on a grid of step = min_dx
|
|
|
+/// - For each desired x (sorted), choose the lane & grid column with minimal |x_fin-x_des|
|
|
|
+/// - Enforces: monotone x within each lane, head spacing >= min_dx, |x_fin-x_des| <= max_dx
|
|
|
+fn place_side_grid_ordered(
|
|
|
+ mut wants: Vec<Want>,
|
|
|
+ min_dx: f64,
|
|
|
+ max_dx: f64,
|
|
|
+ max_lanes: u32,
|
|
|
+) -> Vec<Placed> {
|
|
|
+ wants.sort_by(|a, b| a.x_des.partial_cmp(&b.x_des).unwrap());
|
|
|
+
|
|
|
+ let mut lane_right_edge: Vec<f64> = Vec::new(); // rightmost x per lane
|
|
|
+ let mut placed: Vec<Placed> = Vec::with_capacity(wants.len());
|
|
|
+
|
|
|
+ let step = min_dx.max(f64::EPSILON);
|
|
|
+ let snap_to_grid = |x: f64| (x / step).round() * step;
|
|
|
+
|
|
|
+ for w in wants {
|
|
|
+ let lo = w.x_des - max_dx;
|
|
|
+ let hi = w.x_des + max_dx;
|
|
|
+
|
|
|
+ let cand_center = snap_to_grid(w.x_des);
|
|
|
+ let max_probes = 1 + ((hi - lo) / step).ceil().min(400.0) as i32;
|
|
|
+
|
|
|
+ let mut best: Option<(usize, f64, f64)> = None; // (lane, x_fin, cost)
|
|
|
+
|
|
|
+ // Try existing lanes first
|
|
|
+ 'probe: for k in 0..max_probes {
|
|
|
+ let off = (k / 2 + 1) as f64 * step;
|
|
|
+ let x_try = if k == 0 {
|
|
|
+ cand_center
|
|
|
+ } else if k % 2 == 1 {
|
|
|
+ cand_center + off
|
|
|
+ } else {
|
|
|
+ cand_center - off
|
|
|
+ };
|
|
|
+ if x_try < lo - 1e-9 || x_try > hi + 1e-9 {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ let lanes_len = lane_right_edge.len();
|
|
|
+
|
|
|
+ #[allow(clippy::needless_range_loop)]
|
|
|
+ for lane in 0..lanes_len {
|
|
|
+ // spacing constraint for this lane
|
|
|
+ let xmin = lane_right_edge[lane] + min_dx - 1e-9;
|
|
|
+
|
|
|
+ // advance to first grid column >= xmin
|
|
|
+ let x_snap = if x_try < xmin {
|
|
|
+ let m = ((xmin - x_try) / step).ceil().max(0.0);
|
|
|
+ x_try + m * step
|
|
|
+ } else {
|
|
|
+ x_try
|
|
|
+ };
|
|
|
+ if x_snap < lo - 1e-9 || x_snap > hi + 1e-9 {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if x_snap + 1e-9 < xmin {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ let cost = (x_snap - w.x_des).abs();
|
|
|
+ if let Some((_b_lane, _b_x, b_cost)) = best {
|
|
|
+ if cost < b_cost - 1e-9 {
|
|
|
+ best = Some((lane, x_snap, cost));
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ best = Some((lane, x_snap, cost));
|
|
|
+ }
|
|
|
+
|
|
|
+ // Perfect match found on an existing lane: stop probing.
|
|
|
+ if cost <= 1e-9 {
|
|
|
+ break 'probe;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // If no existing lane fits, try opening a new lane (if allowed).
|
|
|
+ if best.is_none() && (lane_right_edge.len() as u32) < max_lanes {
|
|
|
+ let new_lane = lane_right_edge.len(); // capture index before push
|
|
|
+ let mut local_best: Option<(usize, f64, f64)> = None;
|
|
|
+
|
|
|
+ for k in 0..max_probes {
|
|
|
+ let off = (k / 2 + 1) as f64 * step;
|
|
|
+ let x_try = if k == 0 {
|
|
|
+ snap_to_grid(w.x_des)
|
|
|
+ } else if k % 2 == 1 {
|
|
|
+ snap_to_grid(w.x_des + off)
|
|
|
+ } else {
|
|
|
+ snap_to_grid(w.x_des - off)
|
|
|
+ };
|
|
|
+ if x_try < lo - 1e-9 || x_try > hi + 1e-9 {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ let cost = (x_try - w.x_des).abs();
|
|
|
+ if let Some((_b_lane, _b_x, b_cost)) = local_best {
|
|
|
+ if cost < b_cost - 1e-9 {
|
|
|
+ local_best = Some((new_lane, x_try, cost));
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ local_best = Some((new_lane, x_try, cost));
|
|
|
+ }
|
|
|
+ if cost <= 1e-9 {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if let Some(b) = local_best {
|
|
|
+ best = Some(b);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if let Some((lane, x_fin, _cost)) = best {
|
|
|
+ // Open the lane if needed; no immutable borrow is active here.
|
|
|
+ if lane >= lane_right_edge.len() {
|
|
|
+ lane_right_edge.push(f64::NEG_INFINITY);
|
|
|
+ }
|
|
|
+ placed.push(Placed {
|
|
|
+ idx: w.idx,
|
|
|
+ x_des: w.x_des,
|
|
|
+ x_fin,
|
|
|
+ lane: lane as u32,
|
|
|
+ });
|
|
|
+ lane_right_edge[lane] = x_fin;
|
|
|
+ }
|
|
|
+ // else: skip if impossible within max_dx (or implement clamping if desired)
|
|
|
+ }
|
|
|
+
|
|
|
+ placed.sort_by(|a, b| {
|
|
|
+ a.lane
|
|
|
+ .cmp(&b.lane)
|
|
|
+ .then_with(|| a.x_fin.partial_cmp(&b.x_fin).unwrap())
|
|
|
+ });
|
|
|
+ placed
|
|
|
+}
|
|
|
+
|
|
|
+/// Try to align each head with its stick (x_fin := x_des) if it causes no overlap in its lane
|
|
|
+/// and stays within the ±max_dx displacement budget. Keeps order and spacing.
|
|
|
+fn align_heads_to_sticks(mut placed: Vec<Placed>, min_dx: f64, max_dx: f64) -> Vec<Placed> {
|
|
|
+ use std::collections::BTreeMap;
|
|
|
+
|
|
|
+ // Group indices by lane
|
|
|
+ let mut by_lane: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
|
|
|
+ for (i, p) in placed.iter().enumerate() {
|
|
|
+ by_lane.entry(p.lane).or_default().push(i);
|
|
|
+ }
|
|
|
+
|
|
|
+ // For each lane, consider items in current x_fin order and try to move to x_des if safe
|
|
|
+ for (_lane, idxs) in by_lane {
|
|
|
+ let mut ids = idxs;
|
|
|
+ ids.sort_by(|&a, &b| {
|
|
|
+ placed[a]
|
|
|
+ .x_fin
|
|
|
+ .partial_cmp(&placed[b].x_fin)
|
|
|
+ .unwrap()
|
|
|
+ .then_with(|| placed[a].x_des.partial_cmp(&placed[b].x_des).unwrap())
|
|
|
+ });
|
|
|
+
|
|
|
+ for k in 0..ids.len() {
|
|
|
+ let i = ids[k];
|
|
|
+
|
|
|
+ // Neighbor bounds using current positions
|
|
|
+ let left_x = if k == 0 {
|
|
|
+ f64::NEG_INFINITY
|
|
|
+ } else {
|
|
|
+ placed[ids[k - 1]].x_fin
|
|
|
+ };
|
|
|
+ let right_x = if k + 1 == ids.len() {
|
|
|
+ f64::INFINITY
|
|
|
+ } else {
|
|
|
+ placed[ids[k + 1]].x_fin
|
|
|
+ };
|
|
|
+
|
|
|
+ // We can move x_fin to any X in [left_x + min_dx, right_x - min_dx]
|
|
|
+ let left_bound = left_x + min_dx - 1e-9;
|
|
|
+ let right_bound = right_x - min_dx + 1e-9;
|
|
|
+
|
|
|
+ // Also must respect displacement budget around x_des
|
|
|
+ let lo = placed[i].x_des - max_dx;
|
|
|
+ let hi = placed[i].x_des + max_dx;
|
|
|
+
|
|
|
+ // Feasible interval for the head
|
|
|
+ let l = left_bound.max(lo);
|
|
|
+ let r = right_bound.min(hi);
|
|
|
+
|
|
|
+ // If x_des lies in the feasible interval, snap to it
|
|
|
+ if l <= placed[i].x_des && placed[i].x_des <= r {
|
|
|
+ placed[i].x_fin = placed[i].x_des;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Stable drawing order: by lane, then x_fin
|
|
|
+ placed.sort_by(|a, b| {
|
|
|
+ a.lane
|
|
|
+ .cmp(&b.lane)
|
|
|
+ .then_with(|| a.x_fin.partial_cmp(&b.x_fin).unwrap())
|
|
|
+ });
|
|
|
+ placed
|
|
|
+}
|
|
|
+
|
|
|
+/// Distribute divergence fairly among near heads within each lane using
|
|
|
+/// bounded isotonic regression on the transformed variables y_i = x_i - i*min_dx.
|
|
|
+/// Objective: minimize sum (x_i - x_des_i)^2 with spacing >= min_dx and |x_i - x_des_i| <= max_dx.
|
|
|
+fn balance_heads_within_lanes(mut placed: Vec<Placed>, min_dx: f64, max_dx: f64) -> Vec<Placed> {
|
|
|
+ use std::collections::BTreeMap;
|
|
|
+
|
|
|
+ // Group indices by lane, in *lane order* (use the order by desired x to keep identity stable)
|
|
|
+ let mut by_lane: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
|
|
|
+ for (i, p) in placed.iter().enumerate() {
|
|
|
+ by_lane.entry(p.lane).or_default().push(i);
|
|
|
+ }
|
|
|
+ for (_lane, ids) in by_lane.iter_mut() {
|
|
|
+ ids.sort_by(|&a, &b| placed[a].x_des.partial_cmp(&placed[b].x_des).unwrap());
|
|
|
+ }
|
|
|
+
|
|
|
+ // Solve per lane
|
|
|
+ for (_lane, ids) in by_lane {
|
|
|
+ let n = ids.len();
|
|
|
+ if n <= 1 {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Build transformed targets and bounds
|
|
|
+ let mut t: Vec<f64> = Vec::with_capacity(n);
|
|
|
+ let mut lo: Vec<f64> = Vec::with_capacity(n);
|
|
|
+ let mut hi: Vec<f64> = Vec::with_capacity(n);
|
|
|
+
|
|
|
+ for (k, &ix) in ids.iter().enumerate() {
|
|
|
+ let x_des = placed[ix].x_des;
|
|
|
+ let l = x_des - max_dx;
|
|
|
+ let r = x_des + max_dx;
|
|
|
+ let shift = (k as f64) * min_dx;
|
|
|
+
|
|
|
+ t.push(x_des - shift);
|
|
|
+ lo.push(l - shift);
|
|
|
+ hi.push(r - shift);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Run bounded isotonic regression on y with targets t and box [lo,hi]
|
|
|
+ let y = bounded_isotonic_regression(&t, &lo, &hi);
|
|
|
+
|
|
|
+ // Recover x_i = y_i + i*min_dx and assign back
|
|
|
+ for (k, &ix) in ids.iter().enumerate() {
|
|
|
+ let x_new = y[k] + (k as f64) * min_dx;
|
|
|
+ placed[ix].x_fin = x_new;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // keep stable draw order
|
|
|
+ placed.sort_by(|a, b| {
|
|
|
+ a.lane
|
|
|
+ .cmp(&b.lane)
|
|
|
+ .then_with(|| a.x_fin.partial_cmp(&b.x_fin).unwrap())
|
|
|
+ });
|
|
|
+ placed
|
|
|
+}
|
|
|
+
|
|
|
+/// Bounded isotonic regression (PAV) for squared loss:
|
|
|
+/// Given targets t[i] with box constraints lo[i] <= y[i] <= hi[i],
|
|
|
+/// find nondecreasing y minimizing sum (y[i]-t[i])^2.
|
|
|
+/// All vectors must have same length; panics if empty.
|
|
|
+fn bounded_isotonic_regression(t: &[f64], lo: &[f64], hi: &[f64]) -> Vec<f64> {
|
|
|
+ assert!(!t.is_empty() && t.len() == lo.len() && t.len() == hi.len());
|
|
|
+
|
|
|
+ // Each block keeps: weight (w), mean (m), lower (L), upper (U), and indices covered
|
|
|
+ #[derive(Clone)]
|
|
|
+ struct Block {
|
|
|
+ w: f64,
|
|
|
+ m: f64,
|
|
|
+ l: f64,
|
|
|
+ u: f64,
|
|
|
+ start: usize,
|
|
|
+ end: usize, // inclusive
|
|
|
+ }
|
|
|
+
|
|
|
+ let n = t.len();
|
|
|
+ let mut stack: Vec<Block> = Vec::new();
|
|
|
+
|
|
|
+ for i in 0..n {
|
|
|
+ // Start a new block with raw mean = t[i], then clamp to [lo,hi]
|
|
|
+ let b = Block {
|
|
|
+ w: 1.0,
|
|
|
+ m: t[i].max(lo[i]).min(hi[i]),
|
|
|
+ l: lo[i],
|
|
|
+ u: hi[i],
|
|
|
+ start: i,
|
|
|
+ end: i,
|
|
|
+ };
|
|
|
+ stack.push(b);
|
|
|
+
|
|
|
+ // Merge while monotonicity violated
|
|
|
+ while stack.len() >= 2 {
|
|
|
+ let len = stack.len();
|
|
|
+ let (b2, b1) = (stack[len - 2].clone(), stack[len - 1].clone());
|
|
|
+ if b2.m <= b1.m + 1e-12 {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ // Merge b2 and b1
|
|
|
+ let w = b2.w + b1.w;
|
|
|
+ let mut m = (b2.m * b2.w + b1.m * b1.w) / w;
|
|
|
+ let l = b2.l.max(b1.l);
|
|
|
+ let u = b2.u.min(b1.u);
|
|
|
+ // Clamp merged mean into intersection box
|
|
|
+ m = m.max(l).min(u);
|
|
|
+
|
|
|
+ // Replace last two by merged block
|
|
|
+ let merged = Block {
|
|
|
+ w,
|
|
|
+ m,
|
|
|
+ l,
|
|
|
+ u,
|
|
|
+ start: b2.start,
|
|
|
+ end: b1.end,
|
|
|
+ };
|
|
|
+ stack.pop();
|
|
|
+ *stack.last_mut().unwrap() = merged;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Expand blocks to solution vector
|
|
|
+ let mut y = vec![0.0; n];
|
|
|
+ for b in stack {
|
|
|
+ for v in &mut y[b.start..=b.end] {
|
|
|
+ *v = b.m;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ y
|
|
|
+}
|