use crate::render::alluvial_order::optimize_sankey_alluvial_order;
use crate::render::annotations::{add_reference_lines, add_shaded_regions, add_text_annotations};
use crate::render::axis::{add_axes_and_grid, add_labels_and_title, add_y2_axis};
use crate::render::layout::{ComputedLayout, Layout, TickFormat};
use crate::render::palette::Palette;
use crate::render::plots::Plot;
use crate::render::render_utils::{self, linear_regression, pearson_corr, percentile};
use crate::render::theme::Theme;
use std::collections::HashMap;
use std::fmt::Write;
use std::sync::atomic::{AtomicU64, Ordering};

/// Monotonically increasing counter used to generate unique `<clipPath>` IDs.
/// Each call to `render_multiple` / `render_twin_y` grabs one ID so that
/// Figure panels — which merge many Scenes into a single SVG — never share an ID.
static PLOT_CLIP_COUNTER: AtomicU64 = AtomicU64::new(0);

#[inline]
fn next_clip_id() -> String {
    let n = PLOT_CLIP_COUNTER.fetch_add(1, Ordering::Relaxed);
    format!("kuva-clip-{n}")
}

/// Round to 2 decimal places for compact SVG output.
#[inline(always)]
fn round2(v: f64) -> f64 {
    (v * 100.0).round() * 0.01
}

/// Returns a tooltip string for data element `i` if tooltips are enabled.
/// Uses `custom_labels[i]` if set; otherwise calls `auto_fn` to generate text.
#[inline]
fn tooltip(
    show: bool,
    custom_labels: &Option<Vec<String>>,
    i: usize,
    auto_fn: impl FnOnce() -> String,
) -> Option<String> {
    if let Some(ref labels) = custom_labels {
        labels.get(i).cloned()
    } else if show {
        Some(auto_fn())
    } else {
        None
    }
}

use crate::plot::band::BandPlot;
use crate::plot::bar::BarPlot;
use crate::plot::bump::{BumpPlot, CurveStyle};
use crate::plot::calendar::{
    dow_mon0, from_jd, period_grid_pos, period_max_cols, to_jd, CalendarAgg, CalendarPlot,
    WeekStart,
};
use crate::plot::candlestick::{CandleDataPoint, CandlestickPlot};
use crate::plot::chord::ChordPlot;
use crate::plot::clustermap::{Clustermap, ClustermapNorm};
use crate::plot::contour::ContourPlot;
use crate::plot::density::DensityPlot;
use crate::plot::diceplot::DicePlot;
use crate::plot::dotplot::DotPlot;
use crate::plot::forest::ForestPlot;
use crate::plot::funnel::{FunnelColorMode, FunnelOrientation, FunnelPlot, FunnelStage};
use crate::plot::gantt::{GanttDisplayRow, GanttPlot};
use crate::plot::hexbin::{HexbinPlot, ZReduce};
use crate::plot::histogram::Histogram;
use crate::plot::horizon::HorizonPlot;
use crate::plot::legend_plot::LegendPlot;
use crate::plot::line::LinePlot;
use crate::plot::manhattan::ManhattanPlot;
use crate::plot::mosaic::MosaicPlot;
use crate::plot::network::{NetworkPlot, NodeShape};
use crate::plot::parallel::{ParallelPlot, ParallelRow};
use crate::plot::phylo::{PhyloTree, TreeBranchStyle, TreeOrientation};
use crate::plot::pie::PieLabelPosition;
use crate::plot::polar::{PolarMode, PolarPlot};
use crate::plot::pr::PrPlot;
use crate::plot::pyramid::{PopulationPyramid, PyramidMode};
use crate::plot::raincloud::RaincloudPlot;
use crate::plot::ridgeline::RidgelinePlot;
use crate::plot::roc::RocPlot;
use crate::plot::rose::{RoseEncoding, RoseMode, RosePlot};
use crate::plot::sankey::{SankeyLinkColor, SankeyNodeColoring, SankeyNodeOrder, SankeyPlot};
use crate::plot::scatter::{MarkerShape, ScatterPlot, TrendLine};
use crate::plot::scatter3d::Scatter3DPlot;
use crate::plot::slope::SlopePlot;
use crate::plot::stacked_area::StackedAreaPlot;
use crate::plot::strip::{StripPlot, StripStyle};
use crate::plot::sunburst::{SunburstColorMode, SunburstPlot};
use crate::plot::surface3d::Surface3DPlot;
use crate::plot::synteny::{Strand, SyntenyPlot};
use crate::plot::ternary::TernaryPlot;
use crate::plot::text::{TextAlign, TextPlot};
use crate::plot::treemap::{TreemapColorMode, TreemapLayout, TreemapNode, TreemapPlot};
use crate::plot::upset::UpSetPlot;
use crate::plot::venn::VennPlot;
use crate::plot::volcano::{LabelStyle, VolcanoPlot};
use crate::plot::waffle::{CellShape, FillOrder, WaffleCategory, WafflePlot};
use crate::plot::waterfall::{WaterfallKind, WaterfallPlot};
use crate::plot::{
    BoxPlot, BrickAnchor, BrickPlot, Heatmap, Histogram2D, PiePlot, SeriesPlot, SeriesStyle,
    ViolinPlot,
};

use crate::plot::legend::{ColorBarInfo, LegendEntry, LegendGroup, LegendPosition, LegendShape};
use crate::plot::Legend;

use crate::render::color::Color;

/// One styled run of text within a [`Primitive::RichText`] element.
#[derive(Debug, Clone)]
pub struct TextSpan {
    pub text: String,
    pub bold: bool,
    pub italic: bool,
    pub underline: bool,
}

impl TextSpan {
    pub fn plain(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            bold: false,
            italic: false,
            underline: false,
        }
    }
}

/// Data for a `<path>` SVG element.
///
/// Boxed inside `Primitive::Path` to keep the enum small.
#[derive(Debug)]
pub struct PathData {
    pub d: String,
    pub fill: Option<Color>,
    pub stroke: Color,
    pub stroke_width: f64,
    pub opacity: Option<f64>,
    pub stroke_dasharray: Option<String>,
}

#[derive(Debug)]
pub enum Primitive {
    Circle {
        cx: f64,
        cy: f64,
        r: f64,
        fill: Color,
        fill_opacity: Option<f64>,
        stroke: Option<Color>,
        stroke_width: Option<f64>,
    },
    Text {
        x: f64,
        y: f64,
        content: String,
        size: u32,
        anchor: TextAnchor,
        rotate: Option<f64>,
        bold: bool,
        color: Option<Color>,
    },
    /// Multi-span text with per-span bold/italic/underline styling.
    /// Rendered as `<text>` + `<tspan>` in SVG; flattened for other backends.
    RichText {
        x: f64,
        y: f64,
        spans: Vec<TextSpan>,
        size: u32,
        anchor: TextAnchor,
        color: Option<Color>,
    },
    Line {
        x1: f64,
        y1: f64,
        x2: f64,
        y2: f64,
        stroke: Color,
        stroke_width: f64,
        stroke_dasharray: Option<String>,
    },
    /// Boxed to avoid inflating the enum size.
    Path(Box<PathData>),
    Rect {
        x: f64,
        y: f64,
        width: f64,
        height: f64,
        fill: Color,
        stroke: Option<Color>,
        stroke_width: Option<f64>,
        opacity: Option<f64>,
    },
    /// Struct-of-arrays batch of circles with a shared fill color and radius.
    /// Produced by scatter-like renderers to avoid per-point enum overhead and
    /// String allocation.  The SVG/raster backends handle this specially.
    CircleBatch {
        cx: Vec<f64>,
        cy: Vec<f64>,
        r: f64,
        fill: Color,
        fill_opacity: Option<f64>,
        stroke: Option<Color>,
        stroke_width: Option<f64>,
    },
    /// Struct-of-arrays batch of axis-aligned rectangles, each with its own fill.
    /// Produced by heatmap/histogram2d renderers.
    RectBatch {
        x: Vec<f64>,
        y: Vec<f64>,
        w: Vec<f64>,
        h: Vec<f64>,
        fills: Vec<Color>,
    },
    GroupStart {
        transform: Option<String>,
        /// SVG `<title>` tooltip text emitted as first child of the `<g>` element.
        /// Ignored by terminal and raster backends.
        title: Option<String>,
        /// Verbatim attribute string injected into the `<g>` opening tag.
        /// Used for `data-*` attributes and `class=` overrides in interactive mode.
        /// Ignored by terminal and raster backends.
        extra_attrs: Option<String>,
    },
    GroupEnd,
    /// Opens a clipped region. All primitives until the matching `ClipEnd` are
    /// clipped to the given rectangle. The SVG backend emits an inline
    /// `<clipPath>` definition and a `<g clip-path="url(#id)">` wrapper;
    /// terminal and raster backends ignore this primitive entirely.
    ClipStart {
        x: f64,
        y: f64,
        width: f64,
        height: f64,
        id: String,
    },
    /// Closes a clipped region opened by `ClipStart`.
    ClipEnd,
}

#[derive(Debug, Clone, Copy)]
pub enum TextAnchor {
    Start,
    Middle,
    End,
}

/// Axis coordinate metadata embedded in the SVG root element when interactive mode
/// is enabled.  Allows the injected JavaScript to convert pixel coordinates back to
/// data coordinates for the tooltip readout.
#[derive(Debug)]
pub struct AxisMeta {
    pub x_min: f64,
    pub x_max: f64,
    pub y_min: f64,
    pub y_max: f64,
    pub plot_left: f64,
    pub plot_top: f64,
    pub plot_right: f64,
    pub plot_bottom: f64,
    pub log_x: bool,
    pub log_y: bool,
}

#[derive(Debug)]
pub struct Scene {
    pub width: f64,
    pub height: f64,
    pub background_color: Option<String>,
    pub text_color: Option<String>,
    pub font_family: Option<String>,
    pub elements: Vec<Primitive>,
    /// Raw SVG strings to emit inside a `<defs>` block (e.g. linearGradients).
    pub defs: Vec<String>,
    /// Set to `true` when any `GroupStart { title: Some(_) }` is added.
    /// The SVG backend uses this to inject hover-highlight CSS.
    pub has_tooltips: bool,
    /// When `true`, the SVG backend injects interactive CSS, JS, and UI elements.
    pub interactive: bool,
    /// Axis metadata for the interactive JS coordinate readout.
    /// `None` for pixel-space plots (Pie, Chord, etc.) or when not interactive.
    pub axis_meta: Option<AxisMeta>,
    /// Raw `<script>` blocks to emit just before `</svg>`.
    /// Used by pixel-space interactive plots (e.g. CalendarPlot).
    pub scripts: Vec<String>,
}

impl Scene {
    pub fn new(width: f64, height: f64) -> Self {
        Self {
            width,
            height,
            background_color: Some("white".to_string()),
            text_color: None,
            font_family: None,
            elements: Vec::new(),
            defs: Vec::new(),
            has_tooltips: false,
            interactive: false,
            axis_meta: None,
            scripts: Vec::new(),
        }
    }

    /// Create a scene with a pre-allocated element buffer.
    /// Use when the approximate number of primitives is known upfront.
    pub fn with_capacity(width: f64, height: f64, capacity: usize) -> Self {
        Self {
            width,
            height,
            background_color: Some("white".to_string()),
            text_color: None,
            font_family: None,
            elements: Vec::with_capacity(capacity),
            defs: Vec::new(),
            has_tooltips: false,
            interactive: false,
            axis_meta: None,
            scripts: Vec::new(),
        }
    }

    pub fn with_background(mut self, color: Option<&str>) -> Self {
        self.background_color = color.map(|c| c.to_string());
        self
    }

    pub fn add(&mut self, p: Primitive) {
        if let Primitive::GroupStart { title: Some(_), .. } = &p {
            self.has_tooltips = true;
        }
        self.elements.push(p);
    }
}

fn apply_theme(scene: &mut Scene, theme: &Theme) {
    scene.background_color = Some(theme.background.clone());
    scene.text_color = Some(theme.text_color.clone());
}

/// Build an SVG path string from a sequence of (x, y) screen-coordinate points.
pub fn build_path(points: &[(f64, f64)]) -> String {
    let mut path = String::with_capacity(points.len() * 16);
    let mut rb = ryu::Buffer::new();
    for (i, &(x, y)) in points.iter().enumerate() {
        path.push(if i == 0 { 'M' } else { 'L' });
        path.push(' ');
        path.push_str(rb.format(round2(x)));
        path.push(' ');
        path.push_str(rb.format(round2(y)));
        path.push(' ');
    }
    path
}

/// Build an SVG step-path (staircase) from a sequence of (x, y) screen-coordinate points.
/// For each pair of consecutive points, inserts a horizontal segment to x1 before moving to y1.
pub fn build_step_path(points: &[(f64, f64)]) -> String {
    let mut path = String::with_capacity(points.len() * 24);
    let mut rb = ryu::Buffer::new();
    for (i, &(x, y)) in points.iter().enumerate() {
        if i == 0 {
            path.push_str("M ");
            path.push_str(rb.format(round2(x)));
            path.push(' ');
            path.push_str(rb.format(round2(y)));
            path.push(' ');
        } else {
            let prev_y = points[i - 1].1;
            path.push_str("L ");
            path.push_str(rb.format(round2(x)));
            path.push(' ');
            path.push_str(rb.format(round2(prev_y)));
            path.push_str(" L ");
            path.push_str(rb.format(round2(x)));
            path.push(' ');
            path.push_str(rb.format(round2(y)));
            path.push(' ');
        }
    }
    path
}

#[allow(clippy::too_many_arguments)]
fn draw_marker(
    scene: &mut Scene,
    marker: MarkerShape,
    cx: f64,
    cy: f64,
    size: f64,
    fill: &str,
    fill_opacity: Option<f64>,
    stroke: Option<Color>,
    stroke_width: Option<f64>,
) {
    match marker {
        MarkerShape::Circle => {
            scene.add(Primitive::Circle {
                cx,
                cy,
                r: size,
                fill: fill.into(),
                fill_opacity,
                stroke,
                stroke_width,
            });
        }
        MarkerShape::Square => {
            scene.add(Primitive::Rect {
                x: cx - size,
                y: cy - size,
                width: size * 2.0,
                height: size * 2.0,
                fill: fill.into(),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });
        }
        MarkerShape::Triangle => {
            let h = size * 1.7;
            let mut d = String::with_capacity(64);
            let mut rb = ryu::Buffer::new();
            d.push('M');
            d.push_str(rb.format(round2(cx)));
            d.push(',');
            d.push_str(rb.format(round2(cy - h * 0.6)));
            d.push_str(" L");
            d.push_str(rb.format(round2(cx - size)));
            d.push(',');
            d.push_str(rb.format(round2(cy + h * 0.4)));
            d.push_str(" L");
            d.push_str(rb.format(round2(cx + size)));
            d.push(',');
            d.push_str(rb.format(round2(cy + h * 0.4)));
            d.push_str(" Z");
            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: Some(fill.into()),
                stroke: fill.into(),
                stroke_width: 0.5,
                opacity: None,
                stroke_dasharray: None,
            })));
        }
        MarkerShape::Diamond => {
            let s = size * 1.3;
            let mut d = String::with_capacity(80);
            let mut rb = ryu::Buffer::new();
            d.push('M');
            d.push_str(rb.format(round2(cx)));
            d.push(',');
            d.push_str(rb.format(round2(cy - s)));
            d.push_str(" L");
            d.push_str(rb.format(round2(cx + s)));
            d.push(',');
            d.push_str(rb.format(round2(cy)));
            d.push_str(" L");
            d.push_str(rb.format(round2(cx)));
            d.push(',');
            d.push_str(rb.format(round2(cy + s)));
            d.push_str(" L");
            d.push_str(rb.format(round2(cx - s)));
            d.push(',');
            d.push_str(rb.format(round2(cy)));
            d.push_str(" Z");
            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: Some(fill.into()),
                stroke: fill.into(),
                stroke_width: 0.5,
                opacity: None,
                stroke_dasharray: None,
            })));
        }
        MarkerShape::Cross => {
            let s = size * 0.9;
            scene.add(Primitive::Line {
                x1: cx - s,
                y1: cy - s,
                x2: cx + s,
                y2: cy + s,
                stroke: fill.into(),
                stroke_width: 1.5,
                stroke_dasharray: None,
            });
            scene.add(Primitive::Line {
                x1: cx - s,
                y1: cy + s,
                x2: cx + s,
                y2: cy - s,
                stroke: fill.into(),
                stroke_width: 1.5,
                stroke_dasharray: None,
            });
        }
        MarkerShape::Plus => {
            let s = size * 0.9;
            scene.add(Primitive::Line {
                x1: cx - s,
                y1: cy,
                x2: cx + s,
                y2: cy,
                stroke: fill.into(),
                stroke_width: 1.5,
                stroke_dasharray: None,
            });
            scene.add(Primitive::Line {
                x1: cx,
                y1: cy - s,
                x2: cx,
                y2: cy + s,
                stroke: fill.into(),
                stroke_width: 1.5,
                stroke_dasharray: None,
            });
        }
    }
}

fn add_band(band: &BandPlot, scene: &mut Scene, computed: &ComputedLayout) {
    if band.x.len() < 2 {
        return;
    }
    let cap = (band.x.len() * 2 + 1) * 16;
    let mut path = String::with_capacity(cap);
    let mut rb = ryu::Buffer::new();
    for (i, (&x, &y)) in band.x.iter().zip(band.y_upper.iter()).enumerate() {
        let sx = computed.map_x(x);
        let sy = computed.map_y(y);
        path.push(if i == 0 { 'M' } else { 'L' });
        path.push(' ');
        path.push_str(rb.format(round2(sx)));
        path.push(' ');
        path.push_str(rb.format(round2(sy)));
        path.push(' ');
    }
    for (&x, &y) in band.x.iter().zip(band.y_lower.iter()).rev() {
        let sx = computed.map_x(x);
        let sy = computed.map_y(y);
        path.push_str("L ");
        path.push_str(rb.format(round2(sx)));
        path.push(' ');
        path.push_str(rb.format(round2(sy)));
        path.push(' ');
    }
    path.push('Z');
    scene.add(Primitive::Path(Box::new(PathData {
        d: path,
        fill: Some(Color::from(&band.color)),
        stroke: "none".into(),
        stroke_width: 0.0,
        opacity: Some(band.opacity),
        stroke_dasharray: None,
    })));
}

fn add_scatter(scatter: &ScatterPlot, scene: &mut Scene, computed: &ComputedLayout) {
    if let Some(ref band) = scatter.band {
        add_band(band, scene, computed);
    }

    // Fast path: uniform circle markers with no per-point colors/sizes → emit CircleBatch
    let uniform_circles = matches!(scatter.marker, MarkerShape::Circle)
        && scatter.sizes.is_none()
        && scatter.colors.is_none()
        && !scatter.show_tooltips
        && scatter.tooltip_labels.is_none()
        && !scatter
            .data
            .iter()
            .any(|p| p.x_err.is_some() || p.y_err.is_some())
        && !computed.interactive;

    // Precompute stroke color once (matches fill, fully opaque) for the slow path.
    let marker_stroke = scatter
        .marker_stroke_width
        .map(|_| Color::from(scatter.color.as_str()));

    if uniform_circles {
        let (cx_vec, cy_vec): (Vec<f64>, Vec<f64>) = scatter
            .data
            .iter()
            .map(|point| (computed.map_x(point.x), computed.map_y(point.y)))
            .unzip();
        scene.add(Primitive::CircleBatch {
            cx: cx_vec,
            cy: cy_vec,
            r: scatter.size,
            fill: Color::from(scatter.color.as_str()),
            fill_opacity: scatter.marker_opacity,
            stroke: marker_stroke,
            stroke_width: scatter.marker_stroke_width,
        });
        // Still need to draw trend line, error bars, etc. below — but no error bars
        // in the fast path, and trend lines are handled after this block.
    } else {
        for (i, point) in scatter.data.iter().enumerate() {
            let size = scatter
                .sizes
                .as_ref()
                .and_then(|s| s.get(i).copied())
                .unwrap_or(scatter.size);
            let color = scatter
                .colors
                .as_ref()
                .and_then(|c| c.get(i).map(|s| s.as_str()))
                .unwrap_or(&scatter.color);
            // Per-point stroke color tracks the per-point fill color.
            let pt_stroke = scatter.marker_stroke_width.map(|_| Color::from(color));
            // In interactive mode always generate a title so the native browser tooltip
            // works and search has something to match against.
            let tip = tooltip(
                scatter.show_tooltips || computed.interactive,
                &scatter.tooltip_labels,
                i,
                || format!("x={:.2}, y={:.2}", point.x, point.y),
            );
            let scatter_extra = if computed.interactive {
                let group = scatter
                    .group_name
                    .as_deref()
                    .or(scatter.legend_label.as_deref());
                let group_attr = group
                    .map(|g| format!(r#" data-group="{g}""#))
                    .unwrap_or_default();
                Some(format!(
                    r#"class="tt" data-x="{x}" data-y="{y}"{group_attr}"#,
                    x = point.x,
                    y = point.y
                ))
            } else {
                None
            };
            if tip.is_some() || scatter_extra.is_some() {
                let title = tip.clone();
                scene.add(Primitive::GroupStart {
                    transform: None,
                    title,
                    extra_attrs: scatter_extra,
                });
            }
            draw_marker(
                scene,
                scatter.marker,
                computed.map_x(point.x),
                computed.map_y(point.y),
                size,
                color,
                scatter.marker_opacity,
                pt_stroke,
                scatter.marker_stroke_width,
            );

            // x error
            if let Some((neg, pos)) = point.x_err {
                let cy = computed.map_y(point.y);
                let cx_low = computed.map_x(point.x - neg);
                let cx_high = computed.map_x(point.x + pos);

                scene.add(Primitive::Line {
                    x1: cx_low,
                    y1: cy,
                    x2: cx_high,
                    y2: cy,
                    stroke: Color::from(&scatter.color),
                    stroke_width: 1.0,
                    stroke_dasharray: None,
                });

                // Add caps
                scene.add(Primitive::Line {
                    x1: cx_low,
                    y1: cy - 5.0,
                    x2: cx_low,
                    y2: cy + 5.0,
                    stroke: Color::from(&scatter.color),
                    stroke_width: 1.0,
                    stroke_dasharray: None,
                });

                scene.add(Primitive::Line {
                    x1: cx_high,
                    y1: cy - 5.0,
                    x2: cx_high,
                    y2: cy + 5.0,
                    stroke: Color::from(&scatter.color),
                    stroke_width: 1.0,
                    stroke_dasharray: None,
                });
            }

            // y error
            if let Some((neg, pos)) = point.y_err {
                let cx = computed.map_x(point.x);
                let cy_low = computed.map_y(point.y - neg);
                let cy_high = computed.map_y(point.y + pos);

                scene.add(Primitive::Line {
                    x1: cx,
                    y1: cy_low,
                    x2: cx,
                    y2: cy_high,
                    stroke: Color::from(&scatter.color),
                    stroke_width: 1.0,
                    stroke_dasharray: None,
                });

                // Add caps
                scene.add(Primitive::Line {
                    x1: cx - 5.0,
                    y1: cy_low,
                    x2: cx + 5.0,
                    y2: cy_low,
                    stroke: Color::from(&scatter.color),
                    stroke_width: 1.0,
                    stroke_dasharray: None,
                });

                scene.add(Primitive::Line {
                    x1: cx - 5.0,
                    y1: cy_high,
                    x2: cx + 5.0,
                    y2: cy_high,
                    stroke: Color::from(&scatter.color),
                    stroke_width: 1.0,
                    stroke_dasharray: None,
                });
            }
            if tip.is_some() || computed.interactive {
                scene.add(Primitive::GroupEnd);
            }
        }
    } // end else (non-batch path)

    // if trend, draw the line
    if let Some(trend) = scatter.trend {
        match trend {
            TrendLine::Linear => {
                if let Some((slope, intercept, r)) = linear_regression(&scatter.data) {
                    // get line start and end co-ords
                    let x1 = computed.x_range.0;
                    let x2 = computed.x_range.1;
                    let y1 = slope * x1 + intercept;
                    let y2 = slope * x2 + intercept;

                    // draw the line
                    scene.add(Primitive::Line {
                        x1: computed.map_x(x1),
                        y1: computed.map_y(y1),
                        x2: computed.map_x(x2),
                        y2: computed.map_y(y2),
                        stroke: Color::from(&scatter.trend_color),
                        stroke_width: scatter.trend_width,
                        stroke_dasharray: None,
                    });

                    // display equation and correlation
                    if scatter.show_equation || scatter.show_correlation {
                        let mut label = String::new();
                        if scatter.show_equation {
                            label.push_str(&format!("y = {:.2}x + {:.2}", slope, intercept));
                        }
                        if scatter.show_correlation {
                            if !label.is_empty() {
                                label.push_str("  ");
                            }
                            label.push_str(&format!("r = {:.2}", r));
                        }

                        scene.add(Primitive::Text {
                            x: computed.margin_left + 10.0,
                            y: computed.margin_top + 20.0,
                            content: label,
                            size: computed.body_size,
                            anchor: TextAnchor::Start,
                            rotate: None,
                            bold: false,
                            color: None,
                        });
                    }
                }
            } // _ => {}
        }
    }
}

fn add_line(line: &LinePlot, scene: &mut Scene, computed: &ComputedLayout) {
    // In interactive mode, wrap the entire series so legend toggle can mute it.
    let interactive_group = computed.interactive && line.legend_label.is_some();
    if interactive_group {
        let group = line.legend_label.as_deref().unwrap_or("");
        scene.add(Primitive::GroupStart {
            transform: None,
            title: None,
            extra_attrs: Some(format!("class=\"tt\" data-group=\"{}\"", group)),
        });
    }

    // Draw band behind line if present
    if let Some(ref band) = line.band {
        add_band(band, scene, computed);
    }

    if line.data.len() >= 2 {
        let points: Vec<(f64, f64)> = line
            .data
            .iter()
            .map(|c| (computed.map_x(c.x), computed.map_y(c.y)))
            .collect();

        let stroke_d = if line.step {
            build_step_path(&points)
        } else {
            build_path(&points)
        };

        // Draw fill area behind the stroke line
        if line.fill {
            let baseline_y = computed.map_y(computed.y_range.0.max(0.0));
            let first_x = points
                .first()
                .expect("line fill requires at least one point")
                .0;
            let last_x = points
                .last()
                .expect("line fill requires at least one point")
                .0;
            let fill_d = format!(
                "{}L {last_x} {baseline_y} L {first_x} {baseline_y} Z",
                stroke_d
            );
            scene.add(Primitive::Path(Box::new(PathData {
                d: fill_d,
                fill: Some(Color::from(&line.color)),
                stroke: "none".into(),
                stroke_width: 0.0,
                opacity: Some(line.fill_opacity),
                stroke_dasharray: None,
            })));
        }

        scene.add(Primitive::Path(Box::new(PathData {
            d: stroke_d,
            fill: None,
            stroke: Color::from(&line.color),
            stroke_width: line.stroke_width,
            opacity: None,
            stroke_dasharray: line.line_style.dasharray(),
        })));
    }

    // Draw error bars
    for point in &line.data {
        // x error
        if let Some((neg, pos)) = point.x_err {
            let cy = computed.map_y(point.y);
            let cx_low = computed.map_x(point.x - neg);
            let cx_high = computed.map_x(point.x + pos);

            scene.add(Primitive::Line {
                x1: cx_low,
                y1: cy,
                x2: cx_high,
                y2: cy,
                stroke: Color::from(&line.color),
                stroke_width: 1.0,
                stroke_dasharray: None,
            });
            scene.add(Primitive::Line {
                x1: cx_low,
                y1: cy - 5.0,
                x2: cx_low,
                y2: cy + 5.0,
                stroke: Color::from(&line.color),
                stroke_width: 1.0,
                stroke_dasharray: None,
            });
            scene.add(Primitive::Line {
                x1: cx_high,
                y1: cy - 5.0,
                x2: cx_high,
                y2: cy + 5.0,
                stroke: Color::from(&line.color),
                stroke_width: 1.0,
                stroke_dasharray: None,
            });
        }

        // y error
        if let Some((neg, pos)) = point.y_err {
            let cx = computed.map_x(point.x);
            let cy_low = computed.map_y(point.y - neg);
            let cy_high = computed.map_y(point.y + pos);

            scene.add(Primitive::Line {
                x1: cx,
                y1: cy_low,
                x2: cx,
                y2: cy_high,
                stroke: Color::from(&line.color),
                stroke_width: 1.0,
                stroke_dasharray: None,
            });
            scene.add(Primitive::Line {
                x1: cx - 5.0,
                y1: cy_low,
                x2: cx + 5.0,
                y2: cy_low,
                stroke: Color::from(&line.color),
                stroke_width: 1.0,
                stroke_dasharray: None,
            });
            scene.add(Primitive::Line {
                x1: cx - 5.0,
                y1: cy_high,
                x2: cx + 5.0,
                y2: cy_high,
                stroke: Color::from(&line.color),
                stroke_width: 1.0,
                stroke_dasharray: None,
            });
        }
    }

    if interactive_group {
        scene.add(Primitive::GroupEnd);
    }
}

fn add_series(series: &SeriesPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let points: Vec<(f64, f64)> = series
        .values
        .iter()
        .enumerate()
        .map(|(i, &y)| (computed.map_x(i as f64), computed.map_y(y)))
        .collect();

    match series.style {
        SeriesStyle::Line => {
            if points.len() >= 2 {
                scene.add(Primitive::Path(Box::new(PathData {
                    d: build_path(&points),
                    fill: None,
                    stroke: Color::from(&series.color),
                    stroke_width: series.stroke_width,
                    opacity: None,
                    stroke_dasharray: None,
                })));
            }
        }
        SeriesStyle::Point => {
            for (x, y) in points {
                scene.add(Primitive::Circle {
                    cx: x,
                    cy: y,
                    r: series.point_radius,
                    fill: Color::from(&series.color),
                    fill_opacity: None,
                    stroke: None,
                    stroke_width: None,
                });
            }
        }
        SeriesStyle::Both => {
            if points.len() >= 2 {
                scene.add(Primitive::Path(Box::new(PathData {
                    d: build_path(&points),
                    fill: None,
                    stroke: Color::from(&series.color),
                    stroke_width: series.stroke_width,
                    opacity: None,
                    stroke_dasharray: None,
                })));
            }
            for (x, y) in points {
                scene.add(Primitive::Circle {
                    cx: x,
                    cy: y,
                    r: series.point_radius,
                    fill: Color::from(&series.color),
                    fill_opacity: None,
                    stroke: None,
                    stroke_width: None,
                });
            }
        }
    }
}

fn add_bar(bar: &BarPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let mut flat_i: usize = 0;
    for (i, group) in bar.groups.iter().enumerate() {
        let group_x = i as f64 + 1.0;
        let total_width = bar.width;

        if bar.stacked {
            let mut y_accum = 0.0;
            for (j, bar_val) in group.bars.iter().enumerate() {
                let x0 = computed.map_x(group_x - total_width / 2.0);
                let x1 = computed.map_x(group_x + total_width / 2.0);
                let y0 = computed.map_y(y_accum);
                let y1 = computed.map_y(y_accum + bar_val.value);

                let series_label = bar
                    .legend_label
                    .as_ref()
                    .and_then(|ll| ll.get(j))
                    .map(|s| s.as_str())
                    .unwrap_or(&group.label);
                let tip = tooltip(
                    bar.show_tooltips || computed.interactive,
                    &bar.tooltip_labels,
                    flat_i,
                    || format!("{} {}: {:.2}", group.label, series_label, bar_val.value),
                );
                let extra = if computed.interactive {
                    Some(format!(
                        "class=\"tt\" data-group=\"{}\" data-x=\"{}\" data-y=\"{:.4}\"",
                        series_label, group.label, bar_val.value
                    ))
                } else {
                    None
                };
                if tip.is_some() || extra.is_some() {
                    scene.add(Primitive::GroupStart {
                        transform: None,
                        title: tip,
                        extra_attrs: extra,
                    });
                }
                scene.add(Primitive::Rect {
                    x: x0,
                    y: y1.min(y0),
                    width: (x1 - x0).abs(),
                    height: (y0 - y1).abs(),
                    fill: Color::from(&bar_val.color),
                    stroke: None,
                    stroke_width: None,
                    opacity: None,
                });
                if bar.show_tooltips || computed.interactive {
                    scene.add(Primitive::GroupEnd);
                }

                y_accum += bar_val.value;
                flat_i += 1;
            }
        } else {
            let n = group.bars.len();
            let single_width = total_width / n as f64;

            for (j, bar_val) in group.bars.iter().enumerate() {
                let x = group_x - total_width / 2.0 + single_width * (j as f64 + 0.5);
                let x0 = computed.map_x(x - single_width / 2.0);
                let x1 = computed.map_x(x + single_width / 2.0);
                let y0 = computed.map_y(0.0);
                let y1 = computed.map_y(bar_val.value);

                // In simple mode (1 bar per group), identify by the group's own label.
                // In grouped mode (n>1 bars per group), identify by the series legend label.
                let series_label = if n == 1 {
                    group.label.as_str()
                } else {
                    bar.legend_label
                        .as_ref()
                        .and_then(|ll| ll.get(j))
                        .map(|s| s.as_str())
                        .unwrap_or(&group.label)
                };
                let tip = tooltip(
                    bar.show_tooltips || computed.interactive,
                    &bar.tooltip_labels,
                    flat_i,
                    || format!("{} {}: {:.2}", group.label, series_label, bar_val.value),
                );
                let extra = if computed.interactive {
                    Some(format!(
                        "class=\"tt\" data-group=\"{}\" data-x=\"{}\" data-y=\"{:.4}\"",
                        series_label, group.label, bar_val.value
                    ))
                } else {
                    None
                };
                if tip.is_some() || extra.is_some() {
                    scene.add(Primitive::GroupStart {
                        transform: None,
                        title: tip,
                        extra_attrs: extra,
                    });
                }
                scene.add(Primitive::Rect {
                    x: x0,
                    y: y1.min(y0),
                    width: (x1 - x0).abs(),
                    height: (y0 - y1).abs(),
                    fill: Color::from(&bar_val.color),
                    stroke: None,
                    stroke_width: None,
                    opacity: None,
                });
                if bar.show_tooltips || computed.interactive {
                    scene.add(Primitive::GroupEnd);
                }
                flat_i += 1;
            }
        }
    }
}

fn add_histogram(hist: &Histogram, scene: &mut Scene, computed: &ComputedLayout) {
    // Precomputed path
    if let Some((edges, counts)) = &hist.precomputed {
        let max_count = counts.iter().cloned().fold(0.0_f64, f64::max).max(1.0);
        let norm = if hist.normalize { 1.0 / max_count } else { 1.0 };
        for (i, count) in counts.iter().enumerate() {
            if i + 1 >= edges.len() {
                break;
            }
            if *count == 0.0 {
                continue;
            }
            let x0 = computed.map_x(edges[i]);
            let x1 = computed.map_x(edges[i + 1]);
            let y0 = computed.map_y(0.0);
            let y1 = computed.map_y(count * norm);
            let tip = tooltip(hist.show_tooltips, &hist.tooltip_labels, i, || {
                format!("[{:.2}, {:.2}): {:.2}", edges[i], edges[i + 1], count)
            });
            if let Some(ref t) = tip {
                scene.add(Primitive::GroupStart {
                    transform: None,
                    title: Some(t.clone()),
                    extra_attrs: None,
                });
            }
            scene.add(Primitive::Rect {
                x: x0,
                y: y1.min(y0),
                width: (x1 - x0).abs(),
                height: (y0 - y1).abs(),
                fill: Color::from(&hist.color),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });
            if tip.is_some() {
                scene.add(Primitive::GroupEnd);
            }
        }
        return;
    }

    // fold is basically a fancy for loop
    let range: (f64, f64) = hist.range.unwrap_or_else(|| {
        let min: f64 = hist.data.iter().cloned().fold(f64::INFINITY, f64::min);
        let max: f64 = hist.data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
        (min, max)
    });

    let bin_width: f64 = (range.1 - range.0) / hist.bins as f64;
    let mut counts: Vec<usize> = vec![0; hist.bins];

    for &value in &hist.data {
        if value < range.0 || value > range.1 {
            continue;
        }
        let bin: usize = ((value - range.0) / bin_width).floor() as usize;
        let bin: usize = if bin == hist.bins { bin - 1 } else { bin };
        counts[bin] += 1;
    }

    let max_count: f64 = *counts.iter().max().unwrap_or(&1) as f64;
    let norm: f64 = if hist.normalize { 1.0 / max_count } else { 1.0 };

    for (i, count) in counts.iter().enumerate() {
        if *count == 0 {
            continue;
        }
        let x = range.0 + i as f64 * bin_width;
        let height = *count as f64 * norm;

        let x0 = computed.map_x(x);
        let x1 = computed.map_x(x + bin_width);
        let y0 = computed.map_y(0.0);
        let y1 = computed.map_y(height);

        let rect_width = (x1 - x0).abs();
        let rect_height = (y0 - y1).abs();

        let tip = tooltip(hist.show_tooltips, &hist.tooltip_labels, i, || {
            format!("[{:.2}, {:.2}): {}", x, x + bin_width, count)
        });
        if let Some(ref t) = tip {
            scene.add(Primitive::GroupStart {
                transform: None,
                title: Some(t.clone()),
                extra_attrs: None,
            });
        }
        scene.add(Primitive::Rect {
            x: x0,
            y: y1.min(y0),
            width: rect_width,
            height: rect_height,
            fill: Color::from(&hist.color),
            stroke: None,
            stroke_width: None,
            opacity: None,
        });
        if tip.is_some() {
            scene.add(Primitive::GroupEnd);
        }
    }
}

fn add_histogram2d(hist2d: &Histogram2D, scene: &mut Scene, computed: &ComputedLayout) {
    let max_count = hist2d.bins.iter().flatten().copied().max().unwrap_or(1) as f64;
    let log_scale = hist2d.log_count;
    let log_max = (max_count + 1.0).log10();

    let x_bin_width = (hist2d.x_range.1 - hist2d.x_range.0) / hist2d.bins_x as f64;
    let y_bin_height = (hist2d.y_range.1 - hist2d.y_range.0) / hist2d.bins_y as f64;

    // let cmap = hist2d.color_map.clone();
    // for (i, row) in hist2d.data.iter().enumerate() {
    //     for (j, &value) in row.iter().enumerate() {
    //         let color = cmap.map(norm(value));

    //         // let x = computed.map_x(j as f64);
    //         let x0 = computed.map_x(j as f64);
    //         let x1 = computed.map_x(j as f64 + 1.0);
    //         let y0 = computed.map_y(i as f64 + 1.0);
    //         let y1 = computed.map_y(i as f64);
    //         scene.add(Primitive::Rect {
    //             x: x0,
    //             y: y0,
    //             width: (x1-x0).abs()*0.99,
    //             height: (y1-y0).abs()*0.99,
    //             fill: color.into(),
    //             stroke: None,
    //             stroke_width: None,
    //         });

    let cmap = hist2d.color_map.clone();
    for (row_idx, row) in hist2d.bins.iter().enumerate() {
        for (col_idx, &count) in row.iter().enumerate() {
            if count == 0 {
                continue;
            }

            let x0 = hist2d.x_range.0 + col_idx as f64 * x_bin_width;
            let y0 = hist2d.y_range.0 + row_idx as f64 * y_bin_height;
            let x1 = x0 + x_bin_width;
            let y1 = y0 + y_bin_height;
            let norm = if log_scale {
                ((count as f64 + 1.0).log10() / log_max).clamp(0.0, 1.0)
            } else {
                (count as f64 / max_count).clamp(0.0, 1.0)
            };
            let color = cmap.map(norm);

            scene.add(Primitive::Rect {
                x: computed.map_x(x0),
                y: computed.map_y(y1), // y1 is the bottom, SVG coords go down
                width: computed.map_x(x1) - computed.map_x(x0),
                height: computed.map_y(y0) - computed.map_y(y1),
                fill: color.into(),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });
        }
    }

    if hist2d.show_correlation {
        let corr =
            pearson_corr(&hist2d.data).expect("hist2d correlation requires at least 2 data points");
        scene.add(Primitive::Text {
            x: computed.width - 120.0,
            y: computed.margin_top + 20.0,
            content: format!("r = {:.2}", corr),
            size: computed.body_size,
            anchor: TextAnchor::End,
            rotate: None,
            bold: false,
            color: None,
        });
    }
}

fn add_boxplot(boxplot: &BoxPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let theme = &computed.theme;

    for (i, group) in boxplot.groups.iter().enumerate() {
        if group.values.is_empty() {
            continue;
        }

        let color = boxplot
            .group_colors
            .as_ref()
            .and_then(|c| c.get(i).map(|s| s.as_str()))
            .unwrap_or(&boxplot.color);

        let mut sorted = group.values.clone();
        sorted.sort_by(|a, b| a.total_cmp(b));

        let q1 = percentile(&sorted, 25.0); // Q1
        let q2 = percentile(&sorted, 50.0); // median
        let q3 = percentile(&sorted, 75.0); // Q3
        let iqr = q3 - q1;
        let lower_whisker = sorted
            .iter()
            .cloned()
            .filter(|v| *v >= q1 - 1.5 * iqr)
            .fold(f64::INFINITY, f64::min);
        let upper_whisker = sorted
            .iter()
            .cloned()
            .filter(|v| *v <= q3 + 1.5 * iqr)
            .fold(f64::NEG_INFINITY, f64::max);

        let x = i as f64 + 1.0;
        let w = boxplot.width / 2.0;

        let x0 = computed.map_x(x - w);
        let x1 = computed.map_x(x + w);
        let yq1 = computed.map_y(q1);
        let yq3 = computed.map_y(q3);
        let ymed = computed.map_y(q2);
        let ylow = computed.map_y(lower_whisker);
        let yhigh = computed.map_y(upper_whisker);
        let xmid = computed.map_x(x);

        // Box
        scene.add(Primitive::Rect {
            x: x0,
            y: yq3.min(yq1),
            width: (x1 - x0).abs(),
            height: (yq1 - yq3).abs(),
            fill: Color::from(color),
            stroke: None,
            stroke_width: None,
            opacity: None,
        });

        // Median line
        scene.add(Primitive::Line {
            x1: x0,
            y1: ymed,
            x2: x1,
            y2: ymed,
            stroke: Color::from(&theme.box_median),
            stroke_width: 1.5,
            stroke_dasharray: None,
        });

        // Whiskers
        scene.add(Primitive::Line {
            x1: xmid,
            y1: ylow,
            x2: xmid,
            y2: yq1,
            stroke: Color::from(color),
            stroke_width: 1.0,
            stroke_dasharray: None,
        });
        scene.add(Primitive::Line {
            x1: xmid,
            y1: yq3,
            x2: xmid,
            y2: yhigh,
            stroke: Color::from(color),
            stroke_width: 1.0,
            stroke_dasharray: None,
        });

        // Whisker caps
        for &y in &[ylow, yhigh] {
            scene.add(Primitive::Line {
                x1: computed.map_x(x - w / 2.0),
                x2: computed.map_x(x + w / 2.0),
                y1: y,
                y2: y,
                stroke: Color::from(color),
                stroke_width: 1.0,
                stroke_dasharray: None,
            });
        }
    }

    // Overlay strip/swarm points after boxes
    if let Some(ref style) = boxplot.overlay {
        for (i, group) in boxplot.groups.iter().enumerate() {
            add_strip_points(
                &group.values,
                (i + 1) as f64,
                style,
                &boxplot.overlay_color,
                None,
                None, // no per-point shapes for box overlay
                boxplot.overlay_size,
                boxplot.overlay_seed.wrapping_add(i as u64),
                None,
                None,
                false,
                None,
                &group.label,
                0,
                scene,
                computed,
            );
        }
    }
}

fn add_violin(violin: &ViolinPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let theme = &computed.theme;

    for (i, group) in violin.groups.iter().enumerate() {
        if group.values.is_empty() {
            continue;
        }
        let color = violin
            .group_colors
            .as_ref()
            .and_then(|c| c.get(i).map(|s| s.as_str()))
            .unwrap_or(&violin.color);
        let x_center = computed.map_x((i + 1) as f64);

        // Compute KDE with auto or manual bandwidth
        let h = violin
            .bandwidth
            .unwrap_or_else(|| render_utils::silverman_bandwidth(&group.values));
        let kde = render_utils::simple_kde(&group.values, h, violin.kde_samples);
        if kde.is_empty() {
            continue;
        }

        // Normalize
        let max_density = kde
            .iter()
            .map(|(_, y)| *y)
            .fold(f64::NEG_INFINITY, f64::max);
        let scale = violin.width / max_density;

        let mut path_data = String::with_capacity(kde.len() * 32);
        {
            let mut rb = ryu::Buffer::new();
            for (j, (y, d)) in kde.iter().enumerate() {
                let dy = computed.map_y(*y);
                let dx = x_center - d * scale;
                path_data.push(if j == 0 { 'M' } else { 'L' });
                path_data.push(' ');
                path_data.push_str(rb.format(round2(dx)));
                path_data.push(' ');
                path_data.push_str(rb.format(round2(dy)));
                path_data.push(' ');
            }
            for (y, d) in kde.iter().rev() {
                let dy = computed.map_y(*y);
                let dx = x_center + d * scale;
                path_data.push_str("L ");
                path_data.push_str(rb.format(round2(dx)));
                path_data.push(' ');
                path_data.push_str(rb.format(round2(dy)));
                path_data.push(' ');
            }
        }
        path_data.push('Z');

        scene.add(Primitive::Path(Box::new(PathData {
            d: path_data,
            fill: Some(Color::from(color)),
            stroke: Color::from(&theme.violin_border),
            stroke_width: 0.5,
            opacity: None,
            stroke_dasharray: None,
        })));
    }

    // Overlay strip/swarm points after violin shapes
    if let Some(ref style) = violin.overlay {
        for (i, group) in violin.groups.iter().enumerate() {
            add_strip_points(
                &group.values,
                (i + 1) as f64,
                style,
                &violin.overlay_color,
                None,
                None, // no per-point shapes for violin overlay
                violin.overlay_size,
                violin.overlay_seed.wrapping_add(i as u64),
                None,
                None,
                false,
                None,
                &group.label,
                0,
                scene,
                computed,
            );
        }
    }
}

fn add_pie(pie: &PiePlot, scene: &mut Scene, computed: &ComputedLayout) {
    let theme = &computed.theme;

    let total: f64 = pie.slices.iter().map(|s| s.value).sum();

    let has_outside = matches!(
        pie.label_position,
        PieLabelPosition::Outside | PieLabelPosition::Auto
    );

    let leader_gap = 30.0;
    let pad = 5.0;

    // Size radius from vertical space; canvas was already widened in render_pie
    let radius = if has_outside {
        computed.plot_height() / 2.0 - pad
    } else {
        computed.plot_width().min(computed.plot_height()) / 2.0 - 10.0
    };

    // Center pie in the plot area (width may have been adjusted by render_pie)
    let cx = computed.margin_left + computed.plot_width() / 2.0;
    let cy = computed.margin_top + computed.plot_height() / 2.0;
    let inner_radius = pie.inner_radius;
    let inside_label_radius = (radius + inner_radius) / 2.0;
    let mut angle = 0.0;

    // Collect outside labels for anti-overlap pass
    struct OutsideLabel {
        content: String,
        right_side: bool,
        // Fixed: radial segment from edge to elbow
        edge_x: f64,
        edge_y: f64,
        elbow_x: f64,
        elbow_y: f64,
        // Text position (y will be nudged)
        text_x: f64,
        text_y: f64,
    }
    let mut outside_labels: Vec<OutsideLabel> = Vec::new();

    for (slice_i, slice) in pie.slices.iter().enumerate() {
        let frac = slice.value / total;
        let sweep = frac * std::f64::consts::TAU;
        let end_angle = angle + sweep;

        let x1 = cx + radius * angle.cos();
        let y1 = cy + radius * angle.sin();
        let x2 = cx + radius * end_angle.cos();
        let y2 = cy + radius * end_angle.sin();

        let large_arc = if sweep > std::f64::consts::PI { 1 } else { 0 };

        let path_data = if inner_radius == 0.0 {
            format!(
                "M{cx},{cy} L{x1},{y1} A{r},{r} 0 {large_arc},1 {x2},{y2} Z",
                r = radius
            )
        } else {
            let ix1 = cx + inner_radius * end_angle.cos();
            let iy1 = cy + inner_radius * end_angle.sin();
            let ix2 = cx + inner_radius * angle.cos();
            let iy2 = cy + inner_radius * angle.sin();
            format!(
                "M{x1},{y1} A{r},{r} 0 {large_arc},1 {x2},{y2} L{ix1},{iy1} A{ir},{ir} 0 {large_arc},0 {ix2},{iy2} Z",
                r = radius,
                ir = inner_radius
            )
        };

        let tip = tooltip(pie.show_tooltips, &pie.tooltip_labels, slice_i, || {
            format!(
                "{}: {:.2} ({:.1}%)",
                slice.label,
                slice.value,
                slice.value / total * 100.0
            )
        });
        if let Some(ref t) = tip {
            scene.add(Primitive::GroupStart {
                transform: None,
                title: Some(t.clone()),
                extra_attrs: None,
            });
        }
        scene.add(Primitive::Path(Box::new(PathData {
            d: path_data,
            fill: Some(Color::from(&slice.color)),
            stroke: Color::from(&slice.color),
            stroke_width: 1.0,
            opacity: None,
            stroke_dasharray: None,
        })));
        if tip.is_some() {
            scene.add(Primitive::GroupEnd);
        }

        // Build label text
        let label_text = if pie.show_percent {
            let pct = frac * 100.0;
            if slice.label.is_empty() {
                format!("{:.1}%", pct)
            } else {
                format!("{} ({:.1}%)", slice.label, pct)
            }
        } else {
            slice.label.clone()
        };

        // Determine placement
        let place_inside = match pie.label_position {
            PieLabelPosition::None => {
                angle = end_angle;
                continue;
            }
            PieLabelPosition::Inside => true,
            PieLabelPosition::Outside => false,
            PieLabelPosition::Auto => frac >= pie.min_label_fraction,
        };

        let mid_angle = angle + sweep / 2.0;

        if place_inside {
            let label_x = cx + inside_label_radius * mid_angle.cos();
            let label_y = cy + inside_label_radius * mid_angle.sin();
            scene.add(Primitive::Text {
                x: label_x,
                y: label_y,
                content: label_text,
                size: computed.body_size,
                anchor: TextAnchor::Middle,
                rotate: None,
                bold: false,
                color: None,
            });
        } else {
            let right_side = mid_angle.cos() >= 0.0;
            let edge_x = cx + (radius + 5.0) * mid_angle.cos();
            let edge_y = cy + (radius + 5.0) * mid_angle.sin();
            let elbow_x = cx + (radius + 20.0) * mid_angle.cos();
            let elbow_y = cy + (radius + 20.0) * mid_angle.sin();
            // Text extends horizontally from the elbow
            let text_x = if right_side {
                cx + radius + leader_gap
            } else {
                cx - radius - leader_gap
            };
            let text_y = elbow_y;

            outside_labels.push(OutsideLabel {
                content: label_text,
                right_side,
                edge_x,
                edge_y,
                elbow_x,
                elbow_y,
                text_x,
                text_y,
            });
        }

        angle = end_angle;
    }

    // Anti-overlap: process right and left sides independently
    let min_gap = computed.body_size as f64 + 2.0;
    for side in [true, false] {
        let mut indices: Vec<usize> = outside_labels
            .iter()
            .enumerate()
            .filter(|(_, l)| l.right_side == side)
            .map(|(i, _)| i)
            .collect();
        indices.sort_by(|a, b| {
            outside_labels[*a]
                .text_y
                .total_cmp(&outside_labels[*b].text_y)
        });
        for j in 1..indices.len() {
            let prev_y = outside_labels[indices[j - 1]].text_y;
            if outside_labels[indices[j]].text_y - prev_y < min_gap {
                outside_labels[indices[j]].text_y = prev_y + min_gap;
            }
        }
    }

    // Render outside labels with two-segment leader lines
    for label in &outside_labels {
        // Segment 1: radial line from pie edge to elbow
        scene.add(Primitive::Line {
            x1: label.edge_x,
            y1: label.edge_y,
            x2: label.elbow_x,
            y2: label.elbow_y,
            stroke: Color::from(&theme.pie_leader),
            stroke_width: 1.0,
            stroke_dasharray: None,
        });
        // Segment 2: connector from elbow to text position (tracks nudged y)
        scene.add(Primitive::Line {
            x1: label.elbow_x,
            y1: label.elbow_y,
            x2: label.text_x,
            y2: label.text_y,
            stroke: Color::from(&theme.pie_leader),
            stroke_width: 1.0,
            stroke_dasharray: None,
        });
        let anchor = if label.right_side {
            TextAnchor::Start
        } else {
            TextAnchor::End
        };
        scene.add(Primitive::Text {
            x: label.text_x,
            y: label.text_y,
            content: label.content.clone(),
            size: computed.body_size,
            anchor,
            rotate: None,
            bold: false,
            color: None,
        });
    }
}

fn add_heatmap(heatmap: &Heatmap, scene: &mut Scene, computed: &ComputedLayout) {
    let rows = heatmap.data.len();
    let cols = heatmap.data.first().map_or(0, |row| row.len());
    if rows == 0 || cols == 0 {
        return;
    }

    let mut min = f64::INFINITY;
    let mut max = f64::NEG_INFINITY;
    for &v in heatmap.data.iter().flatten() {
        if v < min {
            min = v;
        }
        if v > max {
            max = v;
        }
    }
    let norm = |v: f64| (v - min) / (max - min + f64::EPSILON);

    let cmap = heatmap.color_map.clone();
    let total = rows * cols;

    let (x_lo, x_hi) = heatmap.x_range.unwrap_or((0.5, cols as f64 + 0.5));
    let (y_lo, y_hi) = heatmap.y_range.unwrap_or((0.5, rows as f64 + 0.5));
    let x_step = (x_hi - x_lo) / cols as f64;
    let y_step = (y_hi - y_lo) / rows as f64;

    // Build rect data across rows.
    // full_w / full_h stored separately so value-overlay centering is independent of cell_size.
    struct CellData {
        x: f64,
        y: f64,
        w: f64,
        h: f64,
        full_w: f64,
        full_h: f64,
        fill: Color,
    }
    let cell_data: Vec<CellData> = heatmap
        .data
        .iter()
        .enumerate()
        .flat_map(|(i, row)| {
            let cmap = cmap.clone();
            row.iter().enumerate().map(move |(j, &value)| {
                let x0 = computed.map_x(x_lo + j as f64 * x_step);
                let x1 = computed.map_x(x_lo + (j + 1) as f64 * x_step);
                let y0 = computed.map_y(y_lo + (i + 1) as f64 * y_step);
                let y1 = computed.map_y(y_lo + i as f64 * y_step);
                let full_w = (x1 - x0).abs();
                let full_h = (y1 - y0).abs();
                // At cell_size < 1.0 leave a deliberate gap (factor * natural size).
                // At cell_size = 1.0 add a 0.5 px overlap so SVG anti-aliasing hairlines
                // between adjacent rects are hidden — same trick used by the colorbar.
                let (dw, dh) = if heatmap.cell_size >= 1.0 {
                    (full_w + 0.5, full_h + 0.5)
                } else {
                    (full_w * heatmap.cell_size, full_h * heatmap.cell_size)
                };
                CellData {
                    x: x0,
                    y: y0,
                    w: dw,
                    h: dh,
                    full_w,
                    full_h,
                    fill: Color::from(cmap.map(norm(value))),
                }
            })
        })
        .collect();

    let use_tooltips = heatmap.show_tooltips || heatmap.tooltip_labels.is_some();

    if use_tooltips {
        for (idx, cd) in cell_data.iter().enumerate() {
            let row_i = idx / cols;
            let col_i = idx % cols;
            let value = heatmap.data[row_i][col_i];
            let row_label = heatmap
                .row_labels
                .as_ref()
                .and_then(|v| v.get(row_i))
                .map(|s| s.as_str())
                .unwrap_or("");
            let col_label = heatmap
                .col_labels
                .as_ref()
                .and_then(|v| v.get(col_i))
                .map(|s| s.as_str())
                .unwrap_or("");
            let tip = tooltip(heatmap.show_tooltips, &heatmap.tooltip_labels, idx, || {
                format!("{}, {}: {:.2}", row_label, col_label, value)
            });
            if let Some(ref t) = tip {
                scene.add(Primitive::GroupStart {
                    transform: None,
                    title: Some(t.clone()),
                    extra_attrs: None,
                });
            }
            scene.add(Primitive::Rect {
                x: cd.x,
                y: cd.y,
                width: cd.w,
                height: cd.h,
                fill: cd.fill.clone(),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });
            if tip.is_some() {
                scene.add(Primitive::GroupEnd);
            }
        }
    } else {
        let mut xs = Vec::with_capacity(total);
        let mut ys = Vec::with_capacity(total);
        let mut ws = Vec::with_capacity(total);
        let mut hs = Vec::with_capacity(total);
        let mut fills = Vec::with_capacity(total);
        for cd in &cell_data {
            xs.push(cd.x);
            ys.push(cd.y);
            ws.push(cd.w);
            hs.push(cd.h);
            fills.push(cd.fill.clone());
        }
        scene.add(Primitive::RectBatch {
            x: xs,
            y: ys,
            w: ws,
            h: hs,
            fills,
        });
    }

    if heatmap.show_values {
        for (idx, cd) in cell_data.iter().enumerate() {
            let i = idx / cols;
            let j = idx % cols;
            let _ = (i, j);
            scene.add(Primitive::Text {
                x: cd.x + cd.full_w / 2.0,
                y: cd.y + cd.full_h / 2.0,
                content: format!("{:.2}", heatmap.data[idx / cols][idx % cols]),
                size: computed.body_size,
                anchor: TextAnchor::Middle,
                rotate: None,
                bold: false,
                color: None,
            });
        }
    }
}

fn add_brickplot(brickplot: &BrickPlot, scene: &mut Scene, computed: &ComputedLayout) {
    // Use expanded strigars when available, otherwise raw sequences
    let rows: &Vec<String> = if let Some(ref exp) = brickplot.strigar_exp {
        exp
    } else {
        &brickplot.sequences
    };

    let num_rows = rows.len();
    if num_rows == 0 {
        return;
    }

    let has_variable_width = brickplot.motif_lengths.is_some();

    // Resolve the base offset for a given row index (per-row + global x_offset + x_origin).
    let row_offset = |i: usize| -> f64 {
        let per_row = if let Some(ref offsets) = brickplot.x_offsets {
            offsets
                .get(i)
                .copied()
                .flatten()
                .unwrap_or(brickplot.x_offset)
        } else {
            brickplot.x_offset
        };
        per_row + brickplot.x_origin
    };

    // Compute total row width (in data units) for each row: left_flank + STR + right_flank.
    let str_width = |i: usize| -> f64 {
        rows[i]
            .chars()
            .map(|ch| {
                if let Some(ref ml) = brickplot.motif_lengths {
                    *ml.get(&ch).unwrap_or(&1) as f64
                } else {
                    1.0
                }
            })
            .sum()
    };
    let left_len = |i: usize| -> f64 {
        brickplot
            .left_flanks
            .as_ref()
            .and_then(|f| f.get(i))
            .map(|s| s.chars().count() as f64)
            .unwrap_or(0.0)
    };
    let right_len = |i: usize| -> f64 {
        brickplot
            .right_flanks
            .as_ref()
            .and_then(|f| f.get(i))
            .map(|s| s.chars().count() as f64)
            .unwrap_or(0.0)
    };

    // Right-anchor: compute a per-row shift so all trailing edges line up.
    let right_align_shift: Vec<f64> = if brickplot.anchor == BrickAnchor::Right {
        let right_edges: Vec<f64> = (0..num_rows).map(|i| str_width(i) + right_len(i)).collect();
        let max_right = right_edges
            .iter()
            .cloned()
            .fold(f64::NEG_INFINITY, f64::max);
        right_edges.iter().map(|&re| max_right - re).collect()
    } else {
        vec![0.0; num_rows]
    };

    // DNA colours for left/right flanks (standard bioinformatics convention).
    let dna_color = |ch: char| -> &'static str {
        match ch {
            'A' | 'a' => "rgb(0,150,0)",
            'C' | 'c' => "rgb(0,0,255)",
            'G' | 'g' => "rgb(209,113,5)",
            'T' | 't' => "rgb(255,0,0)",
            _ => "rgb(180,180,180)",
        }
    };

    // Helper: draw one brick rect. `yr` is the y-flipped row index for pixel mapping.
    let draw_brick =
        |scene: &mut Scene, x_start: f64, width: f64, yr: usize, eff_offset: f64, fill: Color| {
            let x0 = computed.map_x(x_start - eff_offset);
            let x1 = computed.map_x(x_start + width - eff_offset);
            let y0 = computed.map_y(yr as f64 + 1.0);
            let y1 = computed.map_y(yr as f64);
            scene.add(Primitive::Rect {
                x: x0,
                y: y0,
                width: (x1 - x0).abs() * 0.95,
                height: (y1 - y0).abs() * 0.95,
                fill,
                stroke: None,
                stroke_width: None,
                opacity: None,
            });
        };

    // Pass 1: brick rects. Row 0 renders at the TOP of the plot (y-flip via yr).
    for i in 0..num_rows {
        let yr = num_rows - 1 - i;
        let eff_offset = row_offset(i) - right_align_shift[i];
        let ll = left_len(i);
        let sw = str_width(i);

        // 1a. Left flank (negative data positions relative to STR start).
        if let Some(ref flanks) = brickplot.left_flanks {
            if let Some(flank) = flanks.get(i) {
                for (k, ch) in flank.chars().enumerate() {
                    let x_start = -(ll) + k as f64;
                    draw_brick(
                        scene,
                        x_start,
                        1.0,
                        yr,
                        eff_offset,
                        Color::from(dna_color(ch)),
                    );
                }
            }
        }

        // 1b. STR bricks.
        let row = &rows[i];
        let template = brickplot
            .template
            .as_ref()
            .expect("BrickPlot rendered without template");
        let mut x_pos: f64 = 0.0;
        for (j, value) in row.chars().enumerate() {
            let width = if let Some(ref ml) = brickplot.motif_lengths {
                *ml.get(&value).unwrap_or(&1) as f64
            } else {
                1.0
            };
            let x_start = if has_variable_width { x_pos } else { j as f64 };
            let color = template
                .get(&value)
                .expect("BrickPlot value not found in template colormap");
            draw_brick(
                scene,
                x_start,
                width,
                yr,
                eff_offset,
                Color::from(color.as_str()),
            );
            x_pos += width;
        }

        // 1c. Right flank.
        if let Some(ref flanks) = brickplot.right_flanks {
            if let Some(flank) = flanks.get(i) {
                for (k, ch) in flank.chars().enumerate() {
                    let x_start = sw + k as f64;
                    draw_brick(
                        scene,
                        x_start,
                        1.0,
                        yr,
                        eff_offset,
                        Color::from(dna_color(ch)),
                    );
                }
            }
        }
    }

    // Pass 2: show_values — character labels centred inside STR bricks.
    if brickplot.show_values {
        for i in 0..num_rows {
            let yr = num_rows - 1 - i;
            let eff_offset = row_offset(i) - right_align_shift[i];
            let row = &rows[i];
            let mut x_pos: f64 = 0.0;
            for (j, value) in row.chars().enumerate() {
                let width = if let Some(ref ml) = brickplot.motif_lengths {
                    *ml.get(&value).unwrap_or(&1) as f64
                } else {
                    1.0
                };
                let x_start = if has_variable_width { x_pos } else { j as f64 };
                let x0 = computed.map_x(x_start - eff_offset);
                let x1 = computed.map_x(x_start + width - eff_offset);
                let y0 = computed.map_y(yr as f64 + 1.0);
                let y1 = computed.map_y(yr as f64);
                scene.add(Primitive::Text {
                    x: x0 + ((x1 - x0).abs() / 2.0),
                    y: y0 + ((y1 - y0).abs() / 2.0),
                    content: format!("{}", value),
                    size: computed.body_size,
                    anchor: TextAnchor::Middle,
                    rotate: None,
                    bold: false,
                    color: None,
                });
                x_pos += width;
            }
        }
    }
}

/// Render per-block notation labels for a BrickPlot.
/// Must be called AFTER ClipEnd so labels that sit above the plot area are not clipped.
fn add_brickplot_notations(brickplot: &BrickPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let notations = match brickplot.notations.as_ref() {
        Some(n) => n,
        None => return,
    };
    let motifs_map = match brickplot.motifs.as_ref() {
        Some(m) => m,
        None => return,
    };

    let rows: &Vec<String> = if let Some(ref exp) = brickplot.strigar_exp {
        exp
    } else {
        &brickplot.sequences
    };
    let num_rows = rows.len();
    if num_rows == 0 {
        return;
    }

    let row_offset = |i: usize| -> f64 {
        let per_row = if let Some(ref offsets) = brickplot.x_offsets {
            offsets
                .get(i)
                .copied()
                .flatten()
                .unwrap_or(brickplot.x_offset)
        } else {
            brickplot.x_offset
        };
        per_row + brickplot.x_origin
    };

    // Right-anchor shift (same logic as add_brickplot).
    let str_width = |i: usize| -> f64 {
        rows[i]
            .chars()
            .map(|ch| {
                if let Some(ref ml) = brickplot.motif_lengths {
                    *ml.get(&ch).unwrap_or(&1) as f64
                } else {
                    1.0
                }
            })
            .sum()
    };
    let right_len = |i: usize| -> f64 {
        brickplot
            .right_flanks
            .as_ref()
            .and_then(|f| f.get(i))
            .map(|s| s.chars().count() as f64)
            .unwrap_or(0.0)
    };
    let right_align_shift: Vec<f64> = if brickplot.anchor == BrickAnchor::Right {
        let right_edges: Vec<f64> = (0..num_rows).map(|i| str_width(i) + right_len(i)).collect();
        let max_right = right_edges
            .iter()
            .cloned()
            .fold(f64::NEG_INFINITY, f64::max);
        right_edges.iter().map(|&re| max_right - re).collect()
    } else {
        vec![0.0; num_rows]
    };

    const N_TIERS: usize = 4;
    let font_px = computed.body_size as f64;
    let label_size = (font_px * 0.85) as u32;
    let line_h = font_px * 1.1;

    for (i, notation_opt) in notations.iter().enumerate() {
        if notation_opt.is_none() {
            continue;
        }
        if i >= num_rows {
            continue;
        }

        let yr = num_rows - 1 - i;
        let eff_offset = row_offset(i) - right_align_shift[i];
        let y_top_px = computed.map_y((yr + 1) as f64);

        let row = &rows[i];

        let letter_width = |ch: char| -> f64 {
            if let Some(ref ml) = brickplot.motif_lengths {
                *ml.get(&ch).unwrap_or(&1) as f64
            } else {
                1.0
            }
        };

        struct Run {
            letter: char,
            count: usize,
            x_start: f64,
            x_end: f64,
        }
        let mut runs: Vec<Run> = Vec::new();
        let mut cum_x: f64 = 0.0;
        let mut run_letter: Option<char> = None;
        let mut run_start_x: f64 = 0.0;
        let mut run_count: usize = 0;

        for ch in row.chars() {
            let w = letter_width(ch);
            if Some(ch) == run_letter {
                run_count += 1;
            } else {
                if let Some(rl) = run_letter {
                    runs.push(Run {
                        letter: rl,
                        count: run_count,
                        x_start: run_start_x,
                        x_end: cum_x,
                    });
                }
                run_letter = Some(ch);
                run_count = 1;
                run_start_x = cum_x;
            }
            cum_x += w;
        }
        if let Some(rl) = run_letter {
            runs.push(Run {
                letter: rl,
                count: run_count,
                x_start: run_start_x,
                x_end: cum_x,
            });
        }

        // Density check: if all label text laid end-to-end exceeds 2× the plot width,
        // the locus is too complex for per-block labels (e.g. SCA31 expansions with
        // hundreds of copies). Suppress individual labels and show a single fallback.
        let plot_px = computed.plot_width();
        let labelable: Vec<String> = runs
            .iter()
            .filter(|r| r.letter != '@')
            .filter_map(|r| {
                motifs_map
                    .get(&r.letter)
                    .map(|k| format!("({}){}", k, r.count))
            })
            .collect();
        let total_label_px: f64 = labelable
            .iter()
            .map(|l| l.len() as f64 * font_px * 0.56)
            .sum();

        if total_label_px > plot_px * 2.0 {
            let cx = computed.margin_left + plot_px / 2.0;
            scene.add(Primitive::Text {
                x: cx,
                y: y_top_px - 2.0 - 0.5 * line_h,
                content: "complex structure: see TSV".to_string(),
                size: label_size,
                anchor: TextAnchor::Middle,
                rotate: None,
                bold: false,
                color: None,
            });
            continue;
        }

        let plot_left_px = computed.margin_left;
        let plot_right_px = computed.width - computed.margin_right;
        let mut last_right: [f64; N_TIERS] = [f64::NEG_INFINITY; N_TIERS];

        for run in &runs {
            if run.letter == '@' {
                continue;
            }
            let kmer = match motifs_map.get(&run.letter) {
                Some(k) => k.as_str(),
                None => continue,
            };
            let label = format!("({}){}", kmer, run.count);
            let center_px = computed.map_x((run.x_start + run.x_end) / 2.0 - eff_offset);
            let text_half_w = label.len() as f64 * font_px * 0.28;

            // Clamp so the label never overflows onto y-axis content or past the right edge.
            let clamped_center = center_px
                .max(plot_left_px + text_half_w + 2.0)
                .min(plot_right_px - text_half_w - 2.0);

            let left_px = clamped_center - text_half_w;
            let right_px = clamped_center + text_half_w;

            let chosen = (0..N_TIERS).find(|&t| left_px > last_right[t]).unwrap_or(0);
            last_right[chosen] = right_px;

            let y_text = y_top_px - 2.0 - (chosen as f64 + 0.5) * line_h;
            scene.add(Primitive::Text {
                x: clamped_center,
                y: y_text,
                content: label,
                size: label_size,
                anchor: TextAnchor::Middle,
                rotate: None,
                bold: false,
                color: None,
            });
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn add_strip_points(
    values: &[f64],
    x_center_data: f64,
    style: &StripStyle,
    color: &str,
    point_colors: Option<&[String]>,
    point_shapes: Option<&[MarkerShape]>,
    point_size: f64,
    seed: u64,
    fill_opacity: Option<f64>,
    stroke_width: Option<f64>,
    show_tooltips: bool,
    tooltip_labels: Option<&[String]>,
    group_label: &str,
    label_offset: usize,
    scene: &mut Scene,
    computed: &ComputedLayout,
) {
    // Resolve the fill color for point index `j`, falling back to the group color.
    let resolve_color = |j: usize| -> &str {
        point_colors
            .and_then(|c| c.get(j).map(|s| s.as_str()))
            .unwrap_or(color)
    };
    // Resolve the marker shape for point index `j`, falling back to Circle.
    let resolve_shape = |j: usize| -> MarkerShape {
        point_shapes
            .and_then(|s| s.get(j).copied())
            .unwrap_or(MarkerShape::Circle)
    };
    let draw_point = |j: usize, cx: f64, cy: f64, scene: &mut Scene| {
        let fill_color = resolve_color(j);
        let stroke_col = stroke_width.map(|_| Color::from(fill_color));
        draw_marker(
            scene,
            resolve_shape(j),
            cx,
            cy,
            point_size,
            fill_color,
            fill_opacity,
            stroke_col,
            stroke_width,
        );
    };
    let tooltip_labels_opt: Option<Vec<String>> = tooltip_labels.map(|s| s.to_vec());
    let strip_extra = |v: f64| -> Option<String> {
        if computed.interactive {
            Some(format!(
                "class=\"tt\" data-group=\"{}\" data-y=\"{v}\"",
                group_label
            ))
        } else {
            None
        }
    };
    match style {
        StripStyle::Center => {
            let cx = computed.map_x(x_center_data);
            for (j, &v) in values.iter().enumerate() {
                let tip = tooltip(
                    show_tooltips || computed.interactive,
                    &tooltip_labels_opt,
                    label_offset + j,
                    || format!("{}: {:.2}", group_label, v),
                );
                let extra = strip_extra(v);
                if tip.is_some() || extra.is_some() {
                    scene.add(Primitive::GroupStart {
                        transform: None,
                        title: tip.clone(),
                        extra_attrs: extra,
                    });
                }
                draw_point(j, cx, computed.map_y(v), scene);
                if tip.is_some() || computed.interactive {
                    scene.add(Primitive::GroupEnd);
                }
            }
        }
        StripStyle::Strip { jitter } => {
            // Inline xorshift64 — no external dependency, same deterministic behaviour
            // as the seeded SmallRng it replaces. XOR with golden-ratio constant so
            // seed=0 doesn't produce an all-zero state.
            let mut rng_state = seed ^ 0x9e3779b97f4a7c15u64;
            for (j, &v) in values.iter().enumerate() {
                rng_state ^= rng_state << 13;
                rng_state ^= rng_state >> 7;
                rng_state ^= rng_state << 17;
                let rand_val = (rng_state >> 11) as f64 * (1.0 / (1u64 << 53) as f64);
                let offset: f64 = (rand_val - 0.5) * jitter;
                let cx = computed.map_x(x_center_data + offset);
                let tip = tooltip(
                    show_tooltips || computed.interactive,
                    &tooltip_labels_opt,
                    label_offset + j,
                    || format!("{}: {:.2}", group_label, v),
                );
                let extra = strip_extra(v);
                if tip.is_some() || extra.is_some() {
                    scene.add(Primitive::GroupStart {
                        transform: None,
                        title: tip.clone(),
                        extra_attrs: extra,
                    });
                }
                draw_point(j, cx, computed.map_y(v), scene);
                if tip.is_some() || computed.interactive {
                    scene.add(Primitive::GroupEnd);
                }
            }
        }
        StripStyle::Swarm => {
            let y_screen: Vec<f64> = values.iter().map(|&v| computed.map_y(v)).collect();
            let x_offsets = render_utils::beeswarm_positions(&y_screen, point_size);
            let cx_center = computed.map_x(x_center_data);
            for (j, &v) in values.iter().enumerate() {
                let cx = cx_center + x_offsets[j];
                let tip = tooltip(
                    show_tooltips || computed.interactive,
                    &tooltip_labels_opt,
                    label_offset + j,
                    || format!("{}: {:.2}", group_label, v),
                );
                let extra = strip_extra(v);
                if tip.is_some() || extra.is_some() {
                    scene.add(Primitive::GroupStart {
                        transform: None,
                        title: tip.clone(),
                        extra_attrs: extra,
                    });
                }
                draw_point(j, cx, computed.map_y(v), scene);
                if tip.is_some() || computed.interactive {
                    scene.add(Primitive::GroupEnd);
                }
            }
        }
    }
}

fn add_strip(strip: &StripPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let mut label_offset: usize = 0;
    for (i, group) in strip.groups.iter().enumerate() {
        let color = strip
            .group_colors
            .as_ref()
            .and_then(|c| c.get(i).map(|s| s.as_str()))
            .unwrap_or(&strip.color);
        add_strip_points(
            &group.values,
            (i + 1) as f64,
            &strip.style,
            color,
            group.point_colors.as_deref(),
            group.point_shapes.as_deref(),
            strip.point_size,
            strip.seed.wrapping_add(i as u64),
            strip.marker_opacity,
            strip.marker_stroke_width,
            strip.show_tooltips,
            strip.tooltip_labels.as_deref(),
            &group.label,
            label_offset,
            scene,
            computed,
        );
        label_offset += group.values.len();
    }
}

// ── Shared 3D box / grid / axes infrastructure ─────────────────────────────
// Used by both Scatter3D and Surface3D.

use crate::plot::plot3d::{Box3DConfig, DataRanges3D};
use crate::render::projection::Projection3D;

/// Draw the 3D open-box wireframe, back-pane fills, grid lines, tick marks,
/// and axis labels. Returns the `Projection3D` so the caller can project
/// its data consistently.
fn draw_3d_box(
    ranges: DataRanges3D,
    cfg: &Box3DConfig,
    scene: &mut Scene,
    computed: &ComputedLayout,
) -> Projection3D {
    let (x_min, x_max) = ranges.x;
    let (y_min, y_max) = ranges.y;
    let (z_min, z_max) = ranges.z;

    let plot_w = computed.plot_width();
    let plot_h = computed.plot_height();
    let plot_size = plot_w.min(plot_h);
    let plot_cx = computed.margin_left + plot_w / 2.0;
    let plot_cy = computed.margin_top + plot_h / 2.0;

    // Find the front corner using only the rotation matrix (no full projection needed)
    let (fc_x, fc_y) = cfg.view.front_bottom_corner();

    // Flip axis ranges so data-min is always at the open front corner
    let x_range = if fc_x > 0.0 {
        (x_max, x_min)
    } else {
        (x_min, x_max)
    };
    let y_range = if fc_y < 0.0 {
        (y_max, y_min)
    } else {
        (y_min, y_max)
    };

    let proj = Projection3D::new(
        cfg.view,
        x_range,
        y_range,
        (z_min, z_max),
        plot_cx,
        plot_cy,
        plot_size,
    );

    let view_dir = proj.view_direction();
    let grid_n = cfg.grid_lines;

    // ── Box edges ──────────────────────────────────────────────────────
    #[derive(Clone, Copy)]
    struct Edge {
        a: [f64; 3],
        b: [f64; 3],
    }

    let corners: [[f64; 3]; 8] = [
        [-0.5, -0.5, -0.5],
        [0.5, -0.5, -0.5],
        [0.5, 0.5, -0.5],
        [-0.5, 0.5, -0.5],
        [-0.5, -0.5, 0.5],
        [0.5, -0.5, 0.5],
        [0.5, 0.5, 0.5],
        [-0.5, 0.5, 0.5],
    ];

    let edge_indices: [(usize, usize); 12] = [
        (0, 1),
        (1, 2),
        (2, 3),
        (3, 0),
        (4, 5),
        (5, 6),
        (6, 7),
        (7, 4),
        (0, 4),
        (1, 5),
        (2, 6),
        (3, 7),
    ];

    let face_normals: [[f64; 3]; 6] = [
        [0.0, 0.0, -1.0],
        [0.0, 0.0, 1.0],
        [-1.0, 0.0, 0.0],
        [1.0, 0.0, 0.0],
        [0.0, -1.0, 0.0],
        [0.0, 1.0, 0.0],
    ];
    let face_edges: [&[usize]; 6] = [
        &[0, 1, 2, 3],
        &[4, 5, 6, 7],
        &[3, 7, 8, 11],
        &[1, 5, 9, 10],
        &[0, 4, 8, 9],
        &[2, 6, 10, 11],
    ];

    let face_front: [bool; 6] = std::array::from_fn(|i| {
        let n = &face_normals[i];
        n[0] * view_dir[0] + n[1] * view_dir[1] + n[2] * view_dir[2] > 0.0
    });

    let edges: [Edge; 12] = std::array::from_fn(|i| {
        let (a, b) = edge_indices[i];
        Edge {
            a: corners[a],
            b: corners[b],
        }
    });

    // Open-box style (like matplotlib): only draw edges bordering at least
    // one back-facing face. The "open corner" (all-front faces) is hidden.
    let mut edge_has_back = [false; 12];
    let mut edge_has_front = [false; 12];
    for (fi, fe) in face_edges.iter().enumerate() {
        for &ei in *fe {
            if face_front[fi] {
                edge_has_front[ei] = true;
            } else {
                edge_has_back[ei] = true;
            }
        }
    }

    let theme = &computed.theme;
    let silhouette_color = Color::from(theme.axis_color.as_str());
    let back_edge_color = Color::from(theme.grid_color.as_str());

    if cfg.show_box {
        for (i, edge) in edges.iter().enumerate() {
            if !edge_has_back[i] {
                continue;
            }
            let (x1, y1, _) = proj.project_normalized(edge.a[0], edge.a[1], edge.a[2]);
            let (x2, y2, _) = proj.project_normalized(edge.b[0], edge.b[1], edge.b[2]);
            let is_silhouette = edge_has_front[i];
            scene.add(Primitive::Line {
                x1: round2(x1),
                y1: round2(y1),
                x2: round2(x2),
                y2: round2(y2),
                stroke: if is_silhouette {
                    silhouette_color.clone()
                } else {
                    back_edge_color.clone()
                },
                stroke_width: if is_silhouette { 1.0 } else { 0.5 },
                stroke_dasharray: None,
            });
        }
    }

    // ── Back-pane fills ─────────────────────────────────────────────────
    let face_corners: [[usize; 4]; 6] = [
        [0, 1, 2, 3],
        [4, 5, 6, 7],
        [0, 3, 7, 4],
        [1, 2, 6, 5],
        [0, 1, 5, 4],
        [3, 2, 6, 7],
    ];
    // Derive a subtle pane fill from the grid color with low opacity
    let pane_fill = Color::from(theme.grid_color.as_str());
    for (fi, fc) in face_corners.iter().enumerate() {
        if face_front[fi] {
            continue;
        }
        let pts: Vec<(f64, f64)> = fc
            .iter()
            .map(|&ci| {
                let (sx, sy, _) =
                    proj.project_normalized(corners[ci][0], corners[ci][1], corners[ci][2]);
                (sx, sy)
            })
            .collect();
        let mut d = build_path(&pts);
        d.push('Z');
        scene.add(Primitive::Path(Box::new(PathData {
            d,
            fill: Some(pane_fill.clone()),
            stroke: Color::None,
            stroke_width: 0.0,
            opacity: Some(0.15),
            stroke_dasharray: None,
        })));
    }

    // ── Grid lines on back-facing walls ────────────────────────────────
    if cfg.show_grid && grid_n > 0 {
        let grid_color = Color::from(theme.grid_color.as_str());
        // Top face (+z, index 1) is omitted — always front-facing at positive elevation.
        type EndpointFn = fn(f64) -> ([f64; 3], [f64; 3]);
        let grid_faces: [(usize, EndpointFn, EndpointFn); 5] = [
            (
                0,
                |t| ([-0.5, t, -0.5], [0.5, t, -0.5]),
                |t| ([t, -0.5, -0.5], [t, 0.5, -0.5]),
            ),
            (
                2,
                |t| ([-0.5, t, -0.5], [-0.5, t, 0.5]),
                |t| ([-0.5, -0.5, t], [-0.5, 0.5, t]),
            ),
            (
                3,
                |t| ([0.5, t, -0.5], [0.5, t, 0.5]),
                |t| ([0.5, -0.5, t], [0.5, 0.5, t]),
            ),
            (
                4,
                |t| ([t, -0.5, -0.5], [t, -0.5, 0.5]),
                |t| ([-0.5, -0.5, t], [0.5, -0.5, t]),
            ),
            (
                5,
                |t| ([t, 0.5, -0.5], [t, 0.5, 0.5]),
                |t| ([-0.5, 0.5, t], [0.5, 0.5, t]),
            ),
        ];
        for i in 0..=grid_n {
            let t = i as f64 / grid_n as f64 - 0.5;
            for &(fi, line_a, line_b) in &grid_faces {
                if face_front[fi] {
                    continue;
                }
                for line_fn in [line_a, line_b] {
                    let (a, b) = line_fn(t);
                    let (x1, y1, _) = proj.project_normalized(a[0], a[1], a[2]);
                    let (x2, y2, _) = proj.project_normalized(b[0], b[1], b[2]);
                    scene.add(Primitive::Line {
                        x1: round2(x1),
                        y1: round2(y1),
                        x2: round2(x2),
                        y2: round2(y2),
                        stroke: grid_color.clone(),
                        stroke_width: 0.5,
                        stroke_dasharray: None,
                    });
                }
            }
        }
    }

    // ── Tick marks and labels ──────────────────────────────────────────
    let tick_color = Color::from(theme.tick_color.as_str());
    let body_size = computed.body_size;
    let tick_size = body_size.saturating_sub(2).max(8);

    let screen_dir = |ax: f64, ay: f64, az: f64, bx: f64, by: f64, bz: f64| -> (f64, f64) {
        let (sx1, sy1, _) = proj.project_normalized(ax, ay, az);
        let (sx2, sy2, _) = proj.project_normalized(bx, by, bz);
        let dx = sx2 - sx1;
        let dy = sy2 - sy1;
        let len = (dx * dx + dy * dy).sqrt().max(1e-9);
        (dx / len, dy / len)
    };
    let perp_vec = |ax: f64, ay: f64, az: f64, px: f64, py: f64, pz: f64| -> (f64, f64) {
        let (sx, sy, _) = proj.project_normalized(ax, ay, az);
        let (ox, oy, _) = proj.project_normalized(ax + px, ay + py, az + pz);
        let rdx = ox - sx;
        let rdy = oy - sy;
        let len = (rdx * rdx + rdy * rdy).sqrt().max(1e-9);
        (rdx / len, rdy / len)
    };
    let anchor_for = |dx: f64| -> TextAnchor {
        if dx < -0.3 {
            TextAnchor::End
        } else if dx > 0.3 {
            TextAnchor::Start
        } else {
            TextAnchor::Middle
        }
    };
    let angle_deg = |dx: f64, dy: f64| -> f64 {
        let a = dy.atan2(dx).to_degrees();
        if a > 90.0 {
            a - 180.0
        } else if a < -90.0 {
            a + 180.0
        } else {
            a
        }
    };

    let tick_len = 6.0_f64;
    let label_gap = 10.0_f64;
    let axis_label_gap = 42.0_f64;

    // X-axis ticks
    let x_ticks = render_utils::generate_ticks(x_min, x_max, grid_n.max(3));
    {
        let perp_sign = if fc_y < 0.0 { -0.1 } else { 0.1 };
        let (ndx, ndy) = perp_vec(0.0, fc_y, -0.5, 0.0, perp_sign, 0.0);
        let (edx, edy) = screen_dir(-0.5, fc_y, -0.5, 0.5, fc_y, -0.5);
        for &tick_val in &x_ticks {
            let t = (tick_val - x_range.0) / (x_range.1 - x_range.0) - 0.5;
            if t.abs() > 0.501 {
                continue;
            }
            let (sx, sy, _) = proj.project_normalized(t, fc_y, -0.5);
            scene.add(Primitive::Line {
                x1: round2(sx),
                y1: round2(sy),
                x2: round2(sx + ndx * tick_len),
                y2: round2(sy + ndy * tick_len),
                stroke: tick_color.clone(),
                stroke_width: 0.8,
                stroke_dasharray: None,
            });
            let lx = sx + ndx * (tick_len + label_gap);
            let ly = sy + ndy * (tick_len + label_gap);
            scene.add(Primitive::Text {
                x: round2(lx),
                y: round2(ly + 3.0),
                content: TickFormat::Auto.format(tick_val),
                size: tick_size,
                anchor: TextAnchor::Middle,
                rotate: Some(angle_deg(edx, edy)),
                bold: false,
                color: None,
            });
        }
        if let Some(ref label) = cfg.x_label {
            let (mx, my, _) = proj.project_normalized(0.0, fc_y, -0.5);
            scene.add(Primitive::Text {
                x: round2(mx + ndx * axis_label_gap),
                y: round2(my + ndy * axis_label_gap + 4.0),
                content: label.to_string(),
                size: body_size,
                anchor: TextAnchor::Middle,
                rotate: Some(angle_deg(edx, edy)),
                bold: true,
                color: None,
            });
        }
    }

    // Y-axis ticks
    let y_ticks = render_utils::generate_ticks(y_min, y_max, grid_n.max(3));
    {
        let perp_sign = if fc_x < 0.0 { -0.1 } else { 0.1 };
        let (ndx, ndy) = perp_vec(fc_x, 0.0, -0.5, perp_sign, 0.0, 0.0);
        let (edx, edy) = screen_dir(fc_x, -0.5, -0.5, fc_x, 0.5, -0.5);
        for &tick_val in &y_ticks {
            let t = (tick_val - y_range.0) / (y_range.1 - y_range.0) - 0.5;
            if t.abs() > 0.501 {
                continue;
            }
            let (sx, sy, _) = proj.project_normalized(fc_x, t, -0.5);
            scene.add(Primitive::Line {
                x1: round2(sx),
                y1: round2(sy),
                x2: round2(sx + ndx * tick_len),
                y2: round2(sy + ndy * tick_len),
                stroke: tick_color.clone(),
                stroke_width: 0.8,
                stroke_dasharray: None,
            });
            let lx = sx + ndx * (tick_len + label_gap);
            let ly = sy + ndy * (tick_len + label_gap);
            scene.add(Primitive::Text {
                x: round2(lx),
                y: round2(ly + 3.0),
                content: TickFormat::Auto.format(tick_val),
                size: tick_size,
                anchor: TextAnchor::Middle,
                rotate: Some(angle_deg(edx, edy)),
                bold: false,
                color: None,
            });
        }
        if let Some(ref label) = cfg.y_label {
            let (mx, my, _) = proj.project_normalized(fc_x, 0.0, -0.5);
            scene.add(Primitive::Text {
                x: round2(mx + ndx * axis_label_gap),
                y: round2(my + ndy * axis_label_gap + 4.0),
                content: label.to_string(),
                size: body_size,
                anchor: TextAnchor::Middle,
                rotate: Some(angle_deg(edx, edy)),
                bold: true,
                color: None,
            });
        }
    }

    // Z-axis ticks
    let z_ticks = render_utils::generate_ticks(z_min, z_max, grid_n.max(3));
    {
        let vert_edges: [(usize, usize, usize); 4] = [(0, 4, 8), (1, 5, 9), (2, 6, 10), (3, 7, 11)];
        let z_right = cfg
            .z_axis_right
            .unwrap_or_else(|| cfg.view.auto_z_axis_right());
        let mut best_edge = (0usize, 4usize);
        let mut best_sx = if z_right {
            f64::NEG_INFINITY
        } else {
            f64::INFINITY
        };
        for &(a, b, ei) in &vert_edges {
            if !edge_has_back[ei] {
                continue;
            }
            let mid_x = (corners[a][0] + corners[b][0]) / 2.0;
            let mid_y = (corners[a][1] + corners[b][1]) / 2.0;
            let mid_z = (corners[a][2] + corners[b][2]) / 2.0;
            let (sx, _, _) = proj.project_normalized(mid_x, mid_y, mid_z);
            let better = if z_right { sx > best_sx } else { sx < best_sx };
            if better {
                best_sx = sx;
                best_edge = (a, b);
            }
        }
        let edge_x = corners[best_edge.0][0];
        let edge_y = corners[best_edge.0][1];
        let perp_x = if edge_x < 0.0 { -0.1 } else { 0.1 };
        let perp_y = if edge_y < 0.0 { -0.1 } else { 0.1 };
        let (ndx, ndy) = perp_vec(edge_x, edge_y, 0.0, perp_x, perp_y, 0.0);
        let (zdx, zdy) = screen_dir(edge_x, edge_y, -0.5, edge_x, edge_y, 0.5);
        for &tick_val in &z_ticks {
            let t = (tick_val - z_min) / (z_max - z_min) - 0.5;
            if t.abs() > 0.501 {
                continue;
            }
            let (sx, sy, _) = proj.project_normalized(edge_x, edge_y, t);
            scene.add(Primitive::Line {
                x1: round2(sx),
                y1: round2(sy),
                x2: round2(sx + ndx * tick_len),
                y2: round2(sy + ndy * tick_len),
                stroke: tick_color.clone(),
                stroke_width: 0.8,
                stroke_dasharray: None,
            });
            scene.add(Primitive::Text {
                x: round2(sx + ndx * (tick_len + label_gap)),
                y: round2(sy + ndy * (tick_len + label_gap) + 3.0),
                content: TickFormat::Auto.format(tick_val),
                size: tick_size,
                anchor: anchor_for(ndx),
                rotate: Some(angle_deg(zdx, zdy)),
                bold: false,
                color: None,
            });
        }
        if let Some(ref label) = cfg.z_label {
            let (mx, my, _) = proj.project_normalized(edge_x, edge_y, 0.0);
            scene.add(Primitive::Text {
                x: round2(mx + ndx * (axis_label_gap + 6.0)),
                y: round2(my + ndy * (axis_label_gap + 6.0) + 4.0),
                content: label.to_string(),
                size: body_size,
                anchor: TextAnchor::Middle,
                rotate: Some(angle_deg(zdx, zdy)),
                bold: true,
                color: None,
            });
        }
    }

    proj
}

fn add_scatter3d(s: &Scatter3DPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let ranges = match s.data_ranges() {
        Some(r) => r,
        None => return,
    };
    let (z_min, z_max) = ranges.z;

    let proj = draw_3d_box(ranges, &s.box3d, scene, computed);

    // ── Data points (depth-sorted, back to front) ──────────────────────
    let z_span = (z_max - z_min).max(f64::EPSILON);
    let has_z_cmap = s.z_colormap.is_some();

    struct ProjectedPoint {
        sx: f64,
        sy: f64,
        depth: f64,
        idx: usize,
    }

    let mut projected: Vec<ProjectedPoint> = s
        .data
        .iter()
        .enumerate()
        .map(|(i, p)| {
            let (sx, sy, depth) = proj.project(p.x, p.y, p.z);
            ProjectedPoint {
                sx,
                sy,
                depth,
                idx: i,
            }
        })
        .collect();

    // Sort back-to-front (largest depth first)
    projected.sort_by(|a, b| {
        b.depth
            .partial_cmp(&a.depth)
            .unwrap_or(std::cmp::Ordering::Equal)
    });

    // Compute depth range for shading
    let (depth_min, depth_max) = if s.depth_shade {
        let dmin = projected
            .iter()
            .map(|p| p.depth)
            .fold(f64::INFINITY, f64::min);
        let dmax = projected
            .iter()
            .map(|p| p.depth)
            .fold(f64::NEG_INFINITY, f64::max);
        (dmin, dmax)
    } else {
        (0.0, 1.0)
    };
    let depth_span = (depth_max - depth_min).max(1e-12);

    // Hoist loop-invariant values
    let stroke_color = s.marker_stroke_width.map(|_| Color::from("#333333"));
    let base_opacity = s.marker_opacity.unwrap_or(1.0);

    for pp in &projected {
        let i = pp.idx;
        let pt = &s.data[i];
        if !pt.x.is_finite() || !pt.y.is_finite() || !pt.z.is_finite() {
            continue;
        }
        let point_size = s
            .sizes
            .as_ref()
            .and_then(|v| v.get(i).copied())
            .unwrap_or(s.size);

        // Use map_rgb fast path when available, fall back to string for Custom colormaps
        let owned_color: String;
        let rgb_buf: [u8; 7]; // "#rrggbb"
        let fill_ref: &str = if has_z_cmap {
            let norm = (s.data[i].z - z_min) / z_span;
            let cmap = s.z_colormap.as_ref().unwrap();
            if let Some((r, g, b)) = cmap.map_rgb(norm) {
                const HEX: &[u8; 16] = b"0123456789abcdef";
                rgb_buf = [
                    b'#',
                    HEX[(r >> 4) as usize],
                    HEX[(r & 0xf) as usize],
                    HEX[(g >> 4) as usize],
                    HEX[(g & 0xf) as usize],
                    HEX[(b >> 4) as usize],
                    HEX[(b & 0xf) as usize],
                ];
                std::str::from_utf8(&rgb_buf).unwrap()
            } else {
                owned_color = cmap.map(norm);
                &owned_color
            }
        } else if let Some(ref colors) = s.colors {
            colors.get(i).map(|c| c.as_str()).unwrap_or(&s.color)
        } else {
            &s.color
        };

        let opacity = if s.depth_shade {
            let t = (pp.depth - depth_min) / depth_span;
            Some(base_opacity * (1.0 - t * 0.7))
        } else {
            s.marker_opacity
        };

        draw_marker(
            scene,
            s.marker,
            round2(pp.sx),
            round2(pp.sy),
            point_size,
            fill_ref,
            opacity,
            stroke_color.clone(),
            s.marker_stroke_width,
        );
    }
}

fn add_surface3d(s: &Surface3DPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let ranges = match s.data_ranges() {
        Some(r) => r,
        None => return,
    };
    let (z_min, z_max) = ranges.z;

    let proj = draw_3d_box(ranges, &s.box3d, scene, computed);

    let nrows = s.nrows();
    let ncols = s.ncols();
    let z_span = (z_max - z_min).max(f64::EPSILON);
    let has_cmap = s.z_colormap.is_some();

    // Build quad faces from grid, project, and compute depth + color
    struct Face {
        pts: [(f64, f64); 4], // screen coords
        depth: f64,
        avg_z: f64,
    }

    let mut faces: Vec<Face> = Vec::with_capacity((nrows - 1) * (ncols - 1));

    for i in 0..nrows - 1 {
        for j in 0..ncols - 1 {
            let corners_data = [
                (s.x_at(j), s.y_at(i), s.z_data[i][j]),
                (s.x_at(j + 1), s.y_at(i), s.z_data[i][j + 1]),
                (s.x_at(j + 1), s.y_at(i + 1), s.z_data[i + 1][j + 1]),
                (s.x_at(j), s.y_at(i + 1), s.z_data[i + 1][j]),
            ];

            // Skip faces with any NaN coordinate
            if corners_data
                .iter()
                .any(|c| !c.0.is_finite() || !c.1.is_finite() || !c.2.is_finite())
            {
                continue;
            }

            let mut total_depth = 0.0;
            let mut total_z = 0.0;
            let mut pts = [(0.0, 0.0); 4];
            for (k, &(x, y, z)) in corners_data.iter().enumerate() {
                let (sx, sy, d) = proj.project(x, y, z);
                pts[k] = (round2(sx), round2(sy));
                total_depth += d;
                total_z += z;
            }

            faces.push(Face {
                pts,
                depth: total_depth / 4.0,
                avg_z: total_z / 4.0,
            });
        }
    }

    // Sort back-to-front (painter's algorithm)
    faces.sort_by(|a, b| {
        b.depth
            .partial_cmp(&a.depth)
            .unwrap_or(std::cmp::Ordering::Equal)
    });

    // Pre-compute wireframe stroke
    let wire_stroke = if s.show_wireframe {
        Color::from(s.wireframe_color.as_str())
    } else {
        Color::None
    };
    let wire_width = if s.show_wireframe {
        s.wireframe_width
    } else {
        0.0
    };
    let opacity = if s.alpha < 1.0 { Some(s.alpha) } else { None };
    // Hoist uniform fill color for the no-cmap path
    let base_fill = if !has_cmap {
        Some(Color::from(s.color.as_str()))
    } else {
        None
    };

    for face in &faces {
        let fill = if has_cmap {
            let norm = (face.avg_z - z_min) / z_span;
            let cmap = s.z_colormap.as_ref().unwrap();
            if let Some((r, g, b)) = cmap.map_rgb(norm) {
                Color::Rgb(r, g, b)
            } else {
                Color::from(cmap.map(norm).as_str())
            }
        } else {
            base_fill.clone().unwrap()
        };

        let mut d = build_path(&face.pts);
        d.push('Z');

        scene.add(Primitive::Path(Box::new(PathData {
            d,
            fill: Some(fill),
            stroke: wire_stroke.clone(),
            stroke_width: wire_width,
            opacity,
            stroke_dasharray: None,
        })));
    }
}

fn add_forest(forest: &ForestPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let n = forest.rows.len();
    if n == 0 {
        return;
    }

    let max_weight = forest
        .rows
        .iter()
        .filter_map(|r| r.weight)
        .fold(0.0_f64, f64::max);

    // Null reference line (vertical dashed line at null_value)
    if forest.show_null_line {
        if let Some(nv) = forest.null_value {
            let x_px = computed.map_x(nv);
            scene.add(Primitive::Line {
                x1: x_px,
                y1: computed.map_y(0.5),
                x2: x_px,
                y2: computed.map_y(n as f64 + 0.5),
                stroke: Color::from("#999999"),
                stroke_width: 1.0,
                stroke_dasharray: Some("4,3".into()),
            });
        }
    }

    for (i, row) in forest.rows.iter().enumerate() {
        // row[0] at top = largest y value
        let y_data = (n - i) as f64;
        let y_px = computed.map_y(y_data);

        let color_str = row.color.as_deref().unwrap_or(&forest.color);
        let color = Color::from(color_str);

        let x_lower = computed.map_x(row.ci_lower);
        let x_upper = computed.map_x(row.ci_upper);
        let est_px = computed.map_x(row.estimate);

        // Marker half-width, scaled by weight when present.
        // Clamped to 15% of base size so the smallest study is still visible.
        let marker_half_w = if let Some(w) = row.weight {
            if max_weight > 0.0 {
                let scaled = forest.marker_size * (w / max_weight).sqrt();
                scaled.max(forest.marker_size * 0.15).max(1.5)
            } else {
                forest.marker_size
            }
        } else {
            forest.marker_size
        };

        // CI whisker — one continuous line from ci_lower to ci_upper.
        // Drawn first so the marker sits on top.
        scene.add(Primitive::Line {
            x1: x_lower,
            y1: y_px,
            x2: x_upper,
            y2: y_px,
            stroke: color.clone(),
            stroke_width: forest.whisker_width,
            stroke_dasharray: None,
        });

        // End caps
        let cap = forest.cap_size;
        if cap > 0.0 {
            scene.add(Primitive::Line {
                x1: x_lower,
                y1: y_px - cap,
                x2: x_lower,
                y2: y_px + cap,
                stroke: color.clone(),
                stroke_width: forest.whisker_width,
                stroke_dasharray: None,
            });
            scene.add(Primitive::Line {
                x1: x_upper,
                y1: y_px - cap,
                x2: x_upper,
                y2: y_px + cap,
                stroke: color.clone(),
                stroke_width: forest.whisker_width,
                stroke_dasharray: None,
            });
        }

        // Point estimate marker — filled square centered on the whisker.
        let mh = marker_half_w * 2.0;
        scene.add(Primitive::Rect {
            x: est_px - marker_half_w,
            y: y_px - marker_half_w,
            width: mh,
            height: mh,
            fill: color,
            stroke: None,
            stroke_width: None,
            opacity: None,
        });
    }
}

/// Render a single forest plot with the given layout.
pub fn render_forest(forest: &ForestPlot, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);
    add_axes_and_grid(&mut scene, &computed, layout);
    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
    add_forest(forest, &mut scene, &computed);
    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);
    scene
}

// ── LollipopPlot ──────────────────────────────────────────────────────────────

fn add_lollipop(
    lp: &crate::plot::lollipop::LollipopPlot,
    scene: &mut Scene,
    computed: &ComputedLayout,
) {
    if lp.points.is_empty() {
        return;
    }

    // Compute data x extents (points + domains) for the baseline line span.
    let mut x_min = f64::INFINITY;
    let mut x_max = f64::NEG_INFINITY;
    for p in &lp.points {
        x_min = x_min.min(p.x);
        x_max = x_max.max(p.x);
    }
    for d in &lp.domains {
        x_min = x_min.min(d.x_start);
        x_max = x_max.max(d.x_end);
    }

    // 1. Domain rectangles (drawn first, behind everything).
    for domain in &lp.domains {
        let x_left = computed
            .map_x(domain.x_start)
            .min(computed.map_x(domain.x_end));
        let x_right = computed
            .map_x(domain.x_start)
            .max(computed.map_x(domain.x_end));
        let y_top = computed
            .map_y(lp.baseline)
            .min(computed.map_y(lp.baseline - lp.domain_height));
        let y_bot = computed
            .map_y(lp.baseline)
            .max(computed.map_y(lp.baseline - lp.domain_height));
        let width = x_right - x_left;
        let height = y_bot - y_top;

        scene.add(Primitive::Rect {
            x: x_left,
            y: y_top,
            width,
            height,
            fill: Color::from(domain.color.as_str()),
            stroke: None,
            stroke_width: None,
            opacity: Some(domain.opacity),
        });

        if let Some(ref label) = domain.label {
            scene.add(Primitive::Text {
                x: x_left + width / 2.0,
                y: y_top + height / 2.0 + computed.body_size as f64 * 0.35,
                content: label.clone(),
                size: (computed.body_size as f64 * 0.75) as u32,
                anchor: TextAnchor::Middle,
                bold: false,
                rotate: None,
                color: None,
            });
        }
    }

    // 2. Baseline horizontal line.
    if lp.show_baseline && x_min.is_finite() {
        let baseline_px = computed.map_y(lp.baseline);
        scene.add(Primitive::Line {
            x1: computed.map_x(x_min),
            y1: baseline_px,
            x2: computed.map_x(x_max),
            y2: baseline_px,
            stroke: Color::from(lp.baseline_color.as_str()),
            stroke_width: lp.baseline_width,
            stroke_dasharray: lp.baseline_dash.clone(),
        });
    }

    // 3. Stems and dots.
    for point in &lp.points {
        let x_px = computed.map_x(point.x);
        let y_px = computed.map_y(point.y);
        let base_px = computed.map_y(lp.baseline);
        let color_str = point.color.as_deref().unwrap_or(&lp.color);
        let color = Color::from(color_str);

        // Stem.
        scene.add(Primitive::Line {
            x1: x_px,
            y1: base_px,
            x2: x_px,
            y2: y_px,
            stroke: color.clone(),
            stroke_width: lp.stem_width,
            stroke_dasharray: None,
        });

        // Dot.
        let stroke_color = lp
            .dot_stroke
            .as_deref()
            .map(Color::from)
            .unwrap_or_else(|| color.clone());
        scene.add(Primitive::Circle {
            cx: x_px,
            cy: y_px,
            r: lp.dot_radius,
            fill: color,
            fill_opacity: None,
            stroke: Some(stroke_color),
            stroke_width: Some(lp.dot_stroke_width),
        });

        // Per-point label.
        if let Some(ref label) = point.label {
            let label_offset = lp.dot_radius + 4.0;
            let label_y = if point.y >= lp.baseline {
                y_px - label_offset
            } else {
                y_px + label_offset + computed.body_size as f64
            };
            scene.add(Primitive::Text {
                x: x_px,
                y: label_y,
                content: label.clone(),
                size: (computed.body_size as f64 * 0.80) as u32,
                anchor: TextAnchor::Middle,
                bold: false,
                rotate: None,
                color: None,
            });
        }
    }
}

/// Render a single lollipop plot with the given layout.
pub fn render_lollipop(lp: &crate::plot::lollipop::LollipopPlot, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);
    add_axes_and_grid(&mut scene, &computed, layout);
    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
    add_lollipop(lp, &mut scene, &computed);
    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);
    scene
}

// ── SurvivalPlot (Kaplan-Meier) ───────────────────────────────────────────────

fn add_survival(
    sp: &crate::plot::survival::SurvivalPlot,
    scene: &mut Scene,
    computed: &ComputedLayout,
) {
    use crate::plot::survival::{censoring_levels, km_curve};
    use crate::render::palette::Palette;

    if sp.groups.is_empty() {
        return;
    }

    let cat10 = Palette::category10();
    let t_max = sp
        .groups
        .iter()
        .flat_map(|g| g.times.iter().copied())
        .fold(0.0_f64, f64::max);
    if t_max <= 0.0 {
        return;
    }

    let x_end_px = computed.map_x(t_max);

    for (i, group) in sp.groups.iter().enumerate() {
        if group.times.is_empty() {
            continue;
        }

        let color_str: &str = group
            .color
            .as_deref()
            .or_else(|| {
                sp.group_colors
                    .as_ref()
                    .and_then(|c| c.get(i).map(|s| s.as_str()))
            })
            .unwrap_or_else(|| {
                if sp.groups.len() > 1 {
                    &cat10[i]
                } else {
                    &sp.color
                }
            });
        let color = Color::from(color_str);

        let km = km_curve(&group.times, &group.events);

        // ── Confidence band ───────────────────────────────────────────────
        if sp.show_ci && km.len() > 1 {
            // Trace upper boundary forward, lower backward, close.
            let mut upper: Vec<(f64, f64)> = vec![(computed.map_x(0.0), computed.map_y(1.0))];
            for pt in km.iter().skip(1) {
                let prev_y = upper.last().unwrap().1;
                upper.push((computed.map_x(pt.t), prev_y));
                upper.push((computed.map_x(pt.t), computed.map_y(pt.hi)));
            }
            upper.push((x_end_px, upper.last().unwrap().1));

            let mut lower: Vec<(f64, f64)> = vec![(computed.map_x(0.0), computed.map_y(1.0))];
            for pt in km.iter().skip(1) {
                let prev_y = lower.last().unwrap().1;
                lower.push((computed.map_x(pt.t), prev_y));
                lower.push((computed.map_x(pt.t), computed.map_y(pt.lo)));
            }
            lower.push((x_end_px, lower.last().unwrap().1));

            let mut d = format!("M {},{}", round2(upper[0].0), round2(upper[0].1));
            for &(x, y) in upper.iter().skip(1) {
                d.push_str(&format!(" L {},{}", round2(x), round2(y)));
            }
            for &(x, y) in lower.iter().rev() {
                d.push_str(&format!(" L {},{}", round2(x), round2(y)));
            }
            d.push_str(" Z");

            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: Some(color.clone()),
                stroke: color.clone(),
                stroke_width: 0.0,
                opacity: Some(sp.ci_alpha),
                stroke_dasharray: None,
            })));
        }

        // ── Step function line ────────────────────────────────────────────
        let mut d = format!(
            "M {},{}",
            round2(computed.map_x(0.0)),
            round2(computed.map_y(1.0))
        );
        let mut prev_s = 1.0_f64;
        for pt in km.iter().skip(1) {
            d.push_str(&format!(
                " H {} V {}",
                round2(computed.map_x(pt.t)),
                round2(computed.map_y(prev_s))
            ));
            d.push_str(&format!(" V {}", round2(computed.map_y(pt.s))));
            prev_s = pt.s;
        }
        d.push_str(&format!(" H {}", round2(x_end_px)));

        scene.add(Primitive::Path(Box::new(PathData {
            d,
            fill: None,
            stroke: color.clone(),
            stroke_width: sp.line_width,
            opacity: None,
            stroke_dasharray: None,
        })));

        // ── Censoring tick marks ──────────────────────────────────────────
        if sp.show_censoring {
            let ticks = censoring_levels(&group.times, &group.events, &km);
            let half = sp.censoring_size;
            for (t, s) in ticks {
                let cx = computed.map_x(t);
                let cy = computed.map_y(s);
                scene.add(Primitive::Line {
                    x1: cx,
                    y1: cy - half,
                    x2: cx,
                    y2: cy + half,
                    stroke: color.clone(),
                    stroke_width: sp.line_width,
                    stroke_dasharray: None,
                });
            }
        }
    }

    // ── p-value annotation ────────────────────────────────────────────────
    if let Some(ref txt) = sp.pvalue_text {
        let x = computed.margin_left + computed.plot_width() - 8.0;
        let y = computed.margin_top + computed.body_size as f64 * 1.5;
        scene.add(Primitive::Text {
            x,
            y,
            content: txt.clone(),
            size: computed.body_size,
            anchor: TextAnchor::End,
            bold: false,
            rotate: None,
            color: None,
        });
    }
}

/// Render a single Kaplan-Meier survival plot.
pub fn render_survival(sp: &crate::plot::survival::SurvivalPlot, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);
    add_axes_and_grid(&mut scene, &computed, layout);
    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
    add_survival(sp, &mut scene, &computed);
    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);
    scene
}

// ── RocPlot ───────────────────────────────────────────────────────────────────

fn add_roc(roc: &RocPlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::plot::roc::{compute_group, RocPoint};
    use crate::render::palette::Palette;

    // Draw diagonal reference line
    if roc.show_diagonal {
        scene.add(Primitive::Line {
            x1: computed.map_x(0.0),
            y1: computed.map_y(0.0),
            x2: computed.map_x(1.0),
            y2: computed.map_y(1.0),
            stroke: Color::from(&roc.diagonal_color),
            stroke_width: computed.axis_stroke_width,
            stroke_dasharray: Some(roc.diagonal_dasharray.clone()),
        });
    }

    let cat10 = Palette::category10();
    let n_groups = roc.groups.len();

    for (i, group) in roc.groups.iter().enumerate() {
        let color_str: &str = group.color.as_deref().unwrap_or_else(|| {
            if n_groups == 1 {
                &roc.color
            } else {
                &cat10[i % cat10.len()]
            }
        });
        let color = Color::from(color_str);

        let rc = compute_group(group);
        if rc.points.is_empty() {
            continue;
        }

        // ── CI band (approximate: shift the whole curve up/down by CI delta) ─
        if group.show_ci && rc.ci_lo.is_finite() && rc.ci_hi.is_finite() {
            let delta_up = rc.ci_hi - rc.auc;
            let delta_dn = rc.auc - rc.ci_lo;

            let map_upper = |pt: &RocPoint| -> (f64, f64) {
                (
                    computed.map_x(pt.fpr),
                    computed.map_y((pt.tpr + delta_up).min(1.0)),
                )
            };
            let map_lower = |pt: &RocPoint| -> (f64, f64) {
                (
                    computed.map_x(pt.fpr),
                    computed.map_y((pt.tpr - delta_dn).max(0.0)),
                )
            };

            let upper: Vec<(f64, f64)> = rc.points.iter().map(map_upper).collect();
            let lower: Vec<(f64, f64)> = rc.points.iter().map(map_lower).collect();

            let mut d = format!("M {},{}", round2(upper[0].0), round2(upper[0].1));
            for &(x, y) in upper.iter().skip(1) {
                d.push_str(&format!(" L {},{}", round2(x), round2(y)));
            }
            for &(x, y) in lower.iter().rev() {
                d.push_str(&format!(" L {},{}", round2(x), round2(y)));
            }
            d.push_str(" Z");

            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: Some(color.clone()),
                stroke: color.clone(),
                stroke_width: 0.0,
                opacity: Some(group.ci_alpha),
                stroke_dasharray: None,
            })));
        }

        // ── ROC curve path ────────────────────────────────────────────────────
        let pts: Vec<(f64, f64)> = rc
            .points
            .iter()
            .map(|pt| (computed.map_x(pt.fpr), computed.map_y(pt.tpr)))
            .collect();

        if !pts.is_empty() {
            let mut d = format!("M {},{}", round2(pts[0].0), round2(pts[0].1));
            for &(x, y) in pts.iter().skip(1) {
                d.push_str(&format!(" L {},{}", round2(x), round2(y)));
            }
            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: None,
                stroke: color.clone(),
                stroke_width: group.line_width,
                opacity: None,
                stroke_dasharray: group.dasharray.clone(),
            })));
        }

        // ── Optimal point marker ──────────────────────────────────────────────
        if let Some(opt_idx) = rc.optimal_idx {
            if let Some(opt_pt) = rc.points.get(opt_idx) {
                let cx = computed.map_x(opt_pt.fpr);
                let cy = computed.map_y(opt_pt.tpr);
                scene.add(Primitive::Circle {
                    cx,
                    cy,
                    r: 5.0 * computed.axis_stroke_width,
                    fill: color.clone(),
                    fill_opacity: None,
                    stroke: Some(Color::from("white")),
                    stroke_width: Some(computed.axis_stroke_width),
                });
            }
        }
    }
}

/// Render a single ROC plot.
pub fn render_roc(roc: RocPlot, layout: Layout) -> Scene {
    let plots = vec![crate::render::plots::Plot::Roc(roc)];
    render_multiple(plots, layout)
}

// ── PrPlot ────────────────────────────────────────────────────────────────────

fn add_pr(pr: &PrPlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::plot::pr::{compute_pr_group, PrPoint};
    use crate::render::palette::Palette;

    let cat10 = Palette::category10();
    let n_groups = pr.groups.len();

    // Compute all groups first so we know each group's prevalence for the baseline.
    let computed_groups: Vec<_> = pr.groups.iter().map(compute_pr_group).collect();

    // Draw baseline: horizontal dashed line at the average prevalence of all groups.
    if pr.show_baseline {
        // Use the first group's prevalence (or mean if multiple).
        let prevalence = if computed_groups.is_empty() {
            0.5
        } else {
            computed_groups.iter().map(|g| g.prevalence).sum::<f64>() / computed_groups.len() as f64
        };
        scene.add(Primitive::Line {
            x1: computed.map_x(0.0),
            y1: computed.map_y(prevalence),
            x2: computed.map_x(1.0),
            y2: computed.map_y(prevalence),
            stroke: Color::from(&pr.baseline_color),
            stroke_width: computed.axis_stroke_width,
            stroke_dasharray: Some(pr.baseline_dasharray.clone()),
        });
    }

    for (i, (group, rc)) in pr.groups.iter().zip(computed_groups.iter()).enumerate() {
        let color_str: &str = group.color.as_deref().unwrap_or_else(|| {
            if n_groups == 1 {
                &pr.color
            } else {
                &cat10[i % cat10.len()]
            }
        });
        let color = Color::from(color_str);

        if rc.points.is_empty() {
            continue;
        }

        // ── PR curve path ─────────────────────────────────────────────────────
        let pts: Vec<(f64, f64)> = rc
            .points
            .iter()
            .map(|pt: &PrPoint| (computed.map_x(pt.recall), computed.map_y(pt.precision)))
            .collect();

        if !pts.is_empty() {
            let mut d = format!("M {},{}", round2(pts[0].0), round2(pts[0].1));
            for &(x, y) in pts.iter().skip(1) {
                d.push_str(&format!(" L {},{}", round2(x), round2(y)));
            }
            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: None,
                stroke: color.clone(),
                stroke_width: group.line_width,
                opacity: None,
                stroke_dasharray: group.dasharray.clone(),
            })));
        }

        // ── Optimal F1 point marker ───────────────────────────────────────────
        if let Some(opt_idx) = rc.optimal_idx {
            if let Some(opt_pt) = rc.points.get(opt_idx) {
                let cx = computed.map_x(opt_pt.recall);
                let cy = computed.map_y(opt_pt.precision);
                scene.add(Primitive::Circle {
                    cx,
                    cy,
                    r: 5.0 * computed.axis_stroke_width,
                    fill: color.clone(),
                    fill_opacity: None,
                    stroke: Some(Color::from("white")),
                    stroke_width: Some(computed.axis_stroke_width),
                });
            }
        }
    }
}

/// Render a single Precision-Recall plot.
pub fn render_pr(pr: PrPlot, layout: Layout) -> Scene {
    let plots = vec![crate::render::plots::Plot::Pr(pr)];
    render_multiple(plots, layout)
}

// ── SlopePlot ─────────────────────────────────────────────────────────────────

fn add_slope(sp: &crate::plot::slope::SlopePlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::plot::slope::SlopeValueFormat;

    let n = sp.points.len();
    if n == 0 {
        return;
    }

    for (i, pt) in sp.points.iter().enumerate() {
        // points[0] at top → y_data = n, points[n-1] at bottom → y_data = 1
        let y_data = (n - i) as f64;
        let py = computed.map_y(y_data);
        let px_before = computed.map_x(pt.before);
        let px_after = computed.map_x(pt.after);

        // Pick color for this row
        let color_str: &str = if let Some(ref gc) = sp.group_colors {
            gc.get(i).map(|s| s.as_str()).unwrap_or(&sp.color)
        } else if sp.color_by_direction {
            if pt.after > pt.before {
                &sp.color_up
            } else if pt.after < pt.before {
                &sp.color_down
            } else {
                &sp.color_flat
            }
        } else {
            &sp.color
        };
        let color = Color::from(color_str);

        // Connecting line — rendered as a Path so we can apply line_opacity
        let d = format!(
            "M {},{} L {},{}",
            round2(px_before),
            round2(py),
            round2(px_after),
            round2(py)
        );
        scene.add(Primitive::Path(Box::new(PathData {
            d,
            fill: None,
            stroke: color.clone(),
            stroke_width: sp.line_width,
            opacity: Some(sp.line_opacity),
            stroke_dasharray: None,
        })));

        // Before dot
        scene.add(Primitive::Circle {
            cx: px_before,
            cy: py,
            r: sp.dot_radius,
            fill: color.clone(),
            fill_opacity: if sp.dot_opacity < 1.0 {
                Some(sp.dot_opacity)
            } else {
                None
            },
            stroke: Some(Color::from("white")),
            stroke_width: Some(1.5),
        });

        // After dot
        scene.add(Primitive::Circle {
            cx: px_after,
            cy: py,
            r: sp.dot_radius,
            fill: color.clone(),
            fill_opacity: if sp.dot_opacity < 1.0 {
                Some(sp.dot_opacity)
            } else {
                None
            },
            stroke: Some(Color::from("white")),
            stroke_width: Some(1.5),
        });

        // Value labels
        if sp.show_values {
            let fmt_val = |v: f64| -> String {
                match &sp.value_format {
                    SlopeValueFormat::Auto => {
                        if v.fract().abs() < 1e-9 {
                            format!("{:.0}", v)
                        } else {
                            let s = format!("{:.2}", v);
                            let s = s.trim_end_matches('0');
                            let s = s.trim_end_matches('.');
                            s.to_string()
                        }
                    }
                    SlopeValueFormat::Fixed(prec) => format!("{:.*}", prec, v),
                    SlopeValueFormat::Integer => format!("{:.0}", v),
                }
            };

            // Labels go on the outer side of each dot (away from the connecting line).
            // Whichever dot is further left gets anchor=End (label extends leftward);
            // whichever is further right gets anchor=Start (label extends rightward).
            let label_y = py + computed.body_size as f64 * 0.35;
            let label_size = (computed.body_size as f64 * 0.75) as u32;
            let gap = sp.dot_radius + 3.0;
            let (before_x, before_anchor, after_x, after_anchor) = if px_before <= px_after {
                // Increase: before is the left dot, after is the right dot
                (
                    px_before - gap,
                    TextAnchor::End,
                    px_after + gap,
                    TextAnchor::Start,
                )
            } else {
                // Decrease: before is the right dot, after is the left dot
                (
                    px_before + gap,
                    TextAnchor::Start,
                    px_after - gap,
                    TextAnchor::End,
                )
            };
            scene.add(Primitive::Text {
                x: before_x,
                y: label_y,
                content: fmt_val(pt.before),
                size: label_size,
                anchor: before_anchor,
                bold: false,
                rotate: None,
                color: None,
            });
            scene.add(Primitive::Text {
                x: after_x,
                y: label_y,
                content: fmt_val(pt.after),
                size: label_size,
                anchor: after_anchor,
                bold: false,
                rotate: None,
                color: None,
            });
        }
    }

    // Column header labels (before_label / after_label) drawn above the plot area
    if let Some(ref bl) = sp.before_label {
        let n_pts = sp.points.len() as f64;
        let mean_before_px = if n_pts > 0.0 {
            computed.map_x(sp.points.iter().map(|p| p.before).sum::<f64>() / n_pts)
        } else {
            computed.map_x(0.0)
        };
        scene.add(Primitive::Text {
            x: mean_before_px,
            y: computed.margin_top - 8.0,
            content: bl.clone(),
            size: (computed.label_size as f64 * 0.85) as u32,
            anchor: TextAnchor::Middle,
            bold: true,
            rotate: None,
            color: None,
        });
    }
    if let Some(ref al) = sp.after_label {
        let n_pts = sp.points.len() as f64;
        let mean_after_px = if n_pts > 0.0 {
            computed.map_x(sp.points.iter().map(|p| p.after).sum::<f64>() / n_pts)
        } else {
            computed.map_x(0.0)
        };
        scene.add(Primitive::Text {
            x: mean_after_px,
            y: computed.margin_top - 8.0,
            content: al.clone(),
            size: (computed.label_size as f64 * 0.85) as u32,
            anchor: TextAnchor::Middle,
            bold: true,
            rotate: None,
            color: None,
        });
    }
}

/// Render a single slope / dumbbell chart with the given layout.
pub fn render_slope(sp: SlopePlot, layout: Layout) -> Scene {
    let plots = vec![crate::render::plots::Plot::Slope(sp)];
    render_multiple(plots, layout)
}

// ── VennPlot ───────────────────────────────────────────────────────────────────

/// Render a Venn diagram with the given layout.
pub fn render_venn(vp: VennPlot, layout: Layout) -> Scene {
    let plots = vec![crate::render::plots::Plot::Venn(vp)];
    render_multiple(plots, layout)
}

// ── ParallelPlot ───────────────────────────────────────────────────────────────

/// Render a parallel coordinates plot with the given layout.
pub fn render_parallel(pp: ParallelPlot, layout: Layout) -> Scene {
    let plots = vec![crate::render::plots::Plot::Parallel(pp)];
    render_multiple(plots, layout)
}

fn add_parallel(pp: &ParallelPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let n_axes = pp.axis_names.len();
    if n_axes < 2 {
        return;
    }
    if pp.rows.is_empty() {
        return;
    }

    // ── Per-axis normalisation (computed first — needed to estimate label widths) ─
    let mut axis_min = vec![f64::INFINITY; n_axes];
    let mut axis_max = vec![f64::NEG_INFINITY; n_axes];
    for row in &pp.rows {
        for (ai, &v) in row.values.iter().enumerate().take(n_axes) {
            if v < axis_min[ai] {
                axis_min[ai] = v;
            }
            if v > axis_max[ai] {
                axis_max[ai] = v;
            }
        }
    }
    for ai in 0..n_axes {
        if (axis_max[ai] - axis_min[ai]).abs() < 1e-12 {
            axis_min[ai] -= 1.0;
            axis_max[ai] += 1.0;
        }
    }
    let global_lo = axis_min.iter().cloned().fold(f64::INFINITY, f64::min);
    let global_hi = axis_max.iter().cloned().fold(f64::NEG_INFINITY, f64::max);

    // ── Adaptive h_inset to prevent horizontal tick-label collision ────────────
    // The facing pair (second-to-last axis labels going right, last axis labels
    // going left) is the tightest constraint. Required spacing:
    //   W_right + W_left + 12 (6px offset each side) + MIN_GAP <= axis_step
    // Strategy: first reduce h_inset toward 0 to widen inter-axis space; if that
    // still isn't enough, scale the tick font size down proportionally.
    const CHAR_W: f64 = 0.62; // character width as fraction of font size (em)
    const MIN_GAP: f64 = 10.0; // minimum horizontal clearance between facing labels
    const LABEL_OFF: f64 = 12.0; // combined 6px tick-to-label offset on both sides

    let nominal_tick_size = (computed.body_size as f64 * 0.85) as u32;

    // Estimate max formatted-value char count for axis i (uses extreme tick values).
    let label_chars = |i: usize| -> f64 {
        let (lo, hi) = if pp.normalize {
            (axis_min[i], axis_max[i])
        } else {
            (global_lo, global_hi)
        };
        format_tick_value(lo).len().max(format_tick_value(hi).len()) as f64
    };

    let base_h_inset = 30.0_f64;
    let avail_w = (computed.width - computed.margin_left - computed.margin_right).max(1.0);

    let (h_inset, tick_size) = if n_axes > 1 {
        let chars_r = label_chars(n_axes - 2); // second-to-last: labels go right
        let chars_l = label_chars(n_axes - 1); // last: labels go left
        let req_step =
            (chars_r + chars_l) * nominal_tick_size as f64 * CHAR_W + LABEL_OFF + MIN_GAP;

        let step_at_base = (avail_w - 2.0 * base_h_inset) / (n_axes - 1) as f64;
        let step_at_zero = avail_w / (n_axes - 1) as f64;

        if req_step <= step_at_base {
            // Existing inset is already sufficient.
            (base_h_inset, nominal_tick_size)
        } else if req_step <= step_at_zero {
            // Shrink h_inset so that axis_step exactly meets the requirement.
            let h = (avail_w - req_step * (n_axes - 1) as f64) / 2.0;
            (h.max(0.0), nominal_tick_size)
        } else {
            // Even h_inset = 0 isn't enough; scale the font down to fit.
            let ts = ((step_at_zero - LABEL_OFF - MIN_GAP) / ((chars_r + chars_l) * CHAR_W))
                .max(6.0) as u32;
            (0.0_f64, ts)
        }
    } else {
        (base_h_inset, nominal_tick_size)
    };

    // ── Layout geometry ────────────────────────────────────────────────────────
    // v_inset reserves space so axis name labels (drawn at plot_top - 8) sit
    // comfortably below the title rather than crowding it.
    let v_inset = computed.body_size as f64 + 10.0;
    let plot_left = computed.margin_left + h_inset;
    let plot_right = computed.width - computed.margin_right - h_inset;
    let plot_top = computed.margin_top + v_inset;
    let plot_bottom = computed.height - computed.margin_bottom;
    let plot_w = (plot_right - plot_left).max(1.0);
    let plot_h = (plot_bottom - plot_top).max(1.0);

    // Pixel x-position for axis index i
    let axis_x = |i: usize| -> f64 {
        if n_axes == 1 {
            plot_left + plot_w * 0.5
        } else {
            plot_left + plot_w * (i as f64) / ((n_axes - 1) as f64)
        }
    };

    // Map a value on axis `ai` to a pixel y-position.
    // Inversion flips the direction so high values appear at the bottom.
    let map_val = |ai: usize, v: f64| -> f64 {
        let (lo, hi) = if pp.normalize {
            (axis_min[ai], axis_max[ai])
        } else {
            (global_lo, global_hi)
        };
        let t = (v - lo) / (hi - lo);
        let t = if pp.is_inverted(ai) { t } else { 1.0 - t };
        plot_top + t * plot_h
    };

    // ── Optional axis background bands ────────────────────────────────────────
    if pp.show_axis_bands {
        let slot_w = if n_axes > 1 {
            plot_w / (n_axes as f64 - 1.0)
        } else {
            plot_w
        };
        let band_w = (slot_w * 0.5).min(40.0);
        for i in 0..n_axes {
            let ax = axis_x(i);
            scene.add(Primitive::Rect {
                x: ax - band_w * 0.5,
                y: plot_top,
                width: band_w,
                height: plot_h,
                fill: Color::from("#f5f5f5"),
                stroke: None,
                stroke_width: None,
                opacity: Some(1.0),
            });
        }
    }

    const AXIS_COLOR: &str = "#555555";
    const INVERTED_COLOR: &str = "#d46000"; // orange — unmissable on any background

    // ── Axis lines ─────────────────────────────────────────────────────────────
    for i in 0..n_axes {
        let ax = axis_x(i);
        let color = if pp.is_inverted(i) {
            INVERTED_COLOR
        } else {
            AXIS_COLOR
        };
        scene.add(Primitive::Line {
            x1: ax,
            y1: plot_top,
            x2: ax,
            y2: plot_bottom,
            stroke: Color::from(color),
            stroke_width: 1.5,
            stroke_dasharray: None,
        });
    }

    // ── Axis labels (column names) ─────────────────────────────────────────────
    // First and last axis labels use Start/End anchor to stay within the plot area.
    // Helper closure — recomputes anchor each call (TextAnchor is not Copy/Clone).
    let label_anchor = |i: usize| -> TextAnchor {
        if i == 0 {
            TextAnchor::Start
        } else if i == n_axes - 1 {
            TextAnchor::End
        } else {
            TextAnchor::Middle
        }
    };
    let label_x = |i: usize| -> f64 { axis_x(i) };

    let label_size = computed.body_size;
    for (i, name) in pp.axis_names.iter().enumerate() {
        let label_color = if pp.is_inverted(i) {
            Some(Color::from(INVERTED_COLOR))
        } else {
            None
        };
        scene.add(Primitive::Text {
            x: label_x(i),
            y: plot_top - 8.0,
            content: name.clone(),
            size: label_size,
            anchor: label_anchor(i),
            rotate: None,
            bold: true,
            color: label_color,
        });

        // Inverted-axis indicator: bold "▼" at the top of the axis line (inside the
        // plot area), in orange.  Orange axis line + orange label + ▼ symbol gives
        // triple visual reinforcement that cannot be missed even when polylines overlap.
        if pp.is_inverted(i) {
            let sym_x = axis_x(i);
            // Place the symbol just inside the top of the axis, offset so the glyph
            // baseline sits a little below plot_top (making it clearly "on the axis").
            let sym_y = plot_top + label_size as f64 * 1.0;
            let sym_size = label_size + 2;
            scene.add(Primitive::Text {
                x: sym_x,
                y: sym_y,
                content: "▼".to_string(),
                size: sym_size,
                anchor: TextAnchor::Middle,
                rotate: None,
                bold: true,
                color: Some(Color::from(INVERTED_COLOR)),
            });
        }
    }

    // ── Tick marks & value labels ──────────────────────────────────────────────
    // `tick_size` was computed above (with possible font-size reduction to prevent
    // horizontal label collision on densely-spaced axes).
    if pp.show_axis_ticks {
        // Cap tick count so labels never overlap vertically.
        let min_px_per_tick = tick_size as f64 * 2.2;
        let max_ticks_by_space = (plot_h / min_px_per_tick) as usize;
        let n_ticks = pp.axis_ticks.min(max_ticks_by_space).max(1);

        for i in 0..n_axes {
            let ax = axis_x(i);
            let (lo, hi) = if pp.normalize {
                (axis_min[i], axis_max[i])
            } else {
                (global_lo, global_hi)
            };
            let is_last = i == n_axes - 1;
            // Rightmost axis: ticks point left; all others: ticks point right
            let tick_dx = if is_last { -4.0_f64 } else { 4.0_f64 };
            let label_x = if is_last { ax - 6.0 } else { ax + 6.0 };
            let tick_color = if pp.is_inverted(i) {
                INVERTED_COLOR
            } else {
                AXIS_COLOR
            };

            for t in 0..=n_ticks {
                let frac = t as f64 / n_ticks as f64;
                let val = lo + frac * (hi - lo);
                let py = map_val(i, val);
                scene.add(Primitive::Line {
                    x1: ax,
                    y1: py,
                    x2: ax + tick_dx,
                    y2: py,
                    stroke: Color::from(tick_color),
                    stroke_width: 1.0,
                    stroke_dasharray: None,
                });
                scene.add(Primitive::Text {
                    x: label_x,
                    y: py + tick_size as f64 * 0.35,
                    content: format_tick_value(val),
                    size: tick_size,
                    anchor: if is_last {
                        TextAnchor::End
                    } else {
                        TextAnchor::Start
                    },
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }
        }
    }

    // ── Group color mapping ────────────────────────────────────────────────────
    let groups = pp.groups();
    let has_groups = !groups.is_empty();
    let group_idx = |g: &str| -> usize { groups.iter().position(|x| x == g).unwrap_or(0) };

    // ── Build path string (straight or bezier) ─────────────────────────────────
    let build_path = |pts: &[(f64, f64)]| -> String {
        let mut d = String::new();
        if pts.is_empty() {
            return d;
        }
        let _ = write!(d, "M {:.2},{:.2}", pts[0].0, pts[0].1);
        if pp.curved {
            for w in pts.windows(2) {
                let (x0, y0) = w[0];
                let (x1, y1) = w[1];
                let mx = (x0 + x1) * 0.5;
                // Cubic bezier: control points have mid x, start/end y respectively
                let _ = write!(
                    d,
                    " C {:.2},{:.2} {:.2},{:.2} {:.2},{:.2}",
                    mx, y0, mx, y1, x1, y1
                );
            }
        } else {
            for &(px, py) in &pts[1..] {
                let _ = write!(d, " L {:.2},{:.2}", px, py);
            }
        }
        d
    };

    // ── Individual polylines ────────────────────────────────────────────────────
    for row in &pp.rows {
        if row.values.len() < n_axes {
            continue;
        }
        let color_str = if has_groups {
            if let Some(ref g) = row.group {
                pp.color_for_group_idx(group_idx(g))
            } else {
                pp.color.clone()
            }
        } else {
            pp.color.clone()
        };

        let pts: Vec<(f64, f64)> = row
            .values
            .iter()
            .enumerate()
            .take(n_axes)
            .map(|(ai, &v)| (axis_x(ai), map_val(ai, v)))
            .collect();

        scene.add(Primitive::Path(Box::new(PathData {
            d: build_path(&pts),
            fill: None,
            stroke: Color::from(color_str.as_str()),
            stroke_width: pp.stroke_width,
            opacity: Some(pp.opacity),
            stroke_dasharray: None,
        })));
    }

    // ── Group mean lines ────────────────────────────────────────────────────────
    if pp.show_mean && has_groups {
        for (gi, g) in groups.iter().enumerate() {
            let group_rows: Vec<&ParallelRow> = pp
                .rows
                .iter()
                .filter(|r| r.group.as_deref() == Some(g.as_str()) && r.values.len() >= n_axes)
                .collect();
            if group_rows.is_empty() {
                continue;
            }

            let means: Vec<f64> = (0..n_axes)
                .map(|ai| {
                    let sum: f64 = group_rows.iter().map(|r| r.values[ai]).sum();
                    sum / group_rows.len() as f64
                })
                .collect();

            let pts: Vec<(f64, f64)> = means
                .iter()
                .enumerate()
                .map(|(ai, &v)| (axis_x(ai), map_val(ai, v)))
                .collect();

            let color_str = pp.color_for_group_idx(gi);
            scene.add(Primitive::Path(Box::new(PathData {
                d: build_path(&pts),
                fill: None,
                stroke: Color::from(color_str.as_str()),
                stroke_width: pp.mean_stroke_width,
                opacity: Some(1.0),
                stroke_dasharray: None,
            })));
        }
    }
}

/// Format a tick value compactly.
fn format_tick_value(val: f64) -> String {
    if val == 0.0 {
        return "0".to_string();
    }
    if val.abs() >= 10_000.0 || (val.abs() < 0.01 && val != 0.0) {
        format!("{val:.2e}")
    } else {
        let s = format!("{val:.3}");
        s.trim_end_matches('0').trim_end_matches('.').to_string()
    }
}

/// Build a rotated-ellipse SVG path by sampling 72 points.
fn ellipse_path(cx: f64, cy: f64, rx: f64, ry: f64, angle_deg: f64) -> String {
    let theta = angle_deg.to_radians();
    let (cos_t, sin_t) = (theta.cos(), theta.sin());
    let pts: Vec<(f64, f64)> = (0..=72)
        .map(|i| {
            let a = i as f64 * std::f64::consts::TAU / 72.0;
            let lx = rx * a.cos();
            let ly = ry * a.sin();
            (cx + lx * cos_t - ly * sin_t, cy + lx * sin_t + ly * cos_t)
        })
        .collect();
    let mut d = format!("M {},{}", round2(pts[0].0), round2(pts[0].1));
    for p in &pts[1..] {
        d.push_str(&format!(" L {},{}", round2(p.0), round2(p.1)));
    }
    d.push_str(" Z");
    d
}

fn point_in_ellipse(px: f64, py: f64, cx: f64, cy: f64, rx: f64, ry: f64, angle_deg: f64) -> bool {
    let theta = angle_deg.to_radians();
    let dx = px - cx;
    let dy = py - cy;
    let lx = dx * theta.cos() + dy * theta.sin();
    let ly = -dx * theta.sin() + dy * theta.cos();
    (lx / rx).powi(2) + (ly / ry).powi(2) <= 1.0
}

fn point_in_circle(px: f64, py: f64, cx: f64, cy: f64, r: f64) -> bool {
    let dx = px - cx;
    let dy = py - cy;
    dx * dx + dy * dy <= r * r
}

/// Lens area between two circles separated by distance `d`.
fn lens_area(r1: f64, r2: f64, d: f64) -> f64 {
    if d <= (r1 - r2).abs() {
        return std::f64::consts::PI * r1.min(r2).powi(2);
    }
    if d >= r1 + r2 {
        return 0.0;
    }
    let a1 = ((d * d + r1 * r1 - r2 * r2) / (2.0 * d * r1))
        .clamp(-1.0, 1.0)
        .acos();
    let a2 = ((d * d + r2 * r2 - r1 * r1) / (2.0 * d * r2))
        .clamp(-1.0, 1.0)
        .acos();
    let tri = 0.5
        * ((r1 + r2 + d) * (-d + r1 + r2) * (d - r1 + r2) * (d + r1 - r2))
            .max(0.0)
            .sqrt();
    r1 * r1 * a1 + r2 * r2 * a2 - tri
}

/// Binary-search for the center-to-center distance that produces `target_overlap` area.
fn solve_distance_2set(r1: f64, r2: f64, target_overlap: f64) -> f64 {
    let (mut lo, mut hi) = ((r1 - r2).abs(), r1 + r2);
    // Clamp target so it's achievable
    let max_area = std::f64::consts::PI * r1.min(r2).powi(2);
    let target = target_overlap.clamp(0.0, max_area);
    for _ in 0..64 {
        let mid = (lo + hi) / 2.0;
        if lens_area(r1, r2, mid) > target {
            lo = mid;
        } else {
            hi = mid;
        }
    }
    (lo + hi) / 2.0
}

/// Enumerate the bitmask for a point given circle/ellipse geometry.
/// `shapes` entries: (cx, cy, rx, ry, angle_deg); rx==ry means circle.
fn point_bitmask(px: f64, py: f64, shapes: &[(f64, f64, f64, f64, f64)]) -> u8 {
    let mut mask = 0u8;
    for (i, &(cx, cy, rx, ry, angle)) in shapes.iter().enumerate() {
        if (rx - ry).abs() < 1e-9 {
            if point_in_circle(px, py, cx, cy, rx) {
                mask |= 1 << i;
            }
        } else {
            if point_in_ellipse(px, py, cx, cy, rx, ry, angle) {
                mask |= 1 << i;
            }
        }
    }
    mask
}

/// Sample an 80×80 grid and return the centroid for each bitmask region.
/// Returns `HashMap<bitmask, (x, y)>`.
fn region_centroids(
    shapes: &[(f64, f64, f64, f64, f64)],
    x0: f64,
    y0: f64,
    x1: f64,
    y1: f64,
    min_samples: usize,
) -> std::collections::HashMap<u8, (f64, f64)> {
    let steps = 80usize;
    let mut sums: std::collections::HashMap<u8, (f64, f64, usize)> =
        std::collections::HashMap::new();
    for iy in 0..steps {
        let py = y0 + (iy as f64 + 0.5) / steps as f64 * (y1 - y0);
        for ix in 0..steps {
            let px = x0 + (ix as f64 + 0.5) / steps as f64 * (x1 - x0);
            let mask = point_bitmask(px, py, shapes);
            if mask != 0 {
                let e = sums.entry(mask).or_insert((0.0, 0.0, 0));
                e.0 += px;
                e.1 += py;
                e.2 += 1;
            }
        }
    }
    sums.into_iter()
        .filter(|(_, (_, _, cnt))| *cnt >= min_samples)
        .map(|(mask, (sx, sy, cnt))| (mask, (sx / cnt as f64, sy / cnt as f64)))
        .collect()
}

/// Draw coloured set-indicator dots centered above an inline region label.
/// One dot per "in" set, arranged horizontally, placed just above `first_text_y`.
fn draw_inline_indicators(
    vp: &VennPlot,
    scene: &mut Scene,
    mask: u8,
    lx: f64,
    ly: f64,
    label_size: u32,
    n: usize,
) {
    if !vp.show_set_indicators || (!vp.show_counts && !vp.show_percentages) {
        return;
    }
    let in_sets: Vec<usize> = (0..n).filter(|&i| mask & (1u8 << i) != 0).collect();
    if in_sets.is_empty() {
        return;
    }

    let dot_r = (label_size as f64 * 0.38).max(2.5_f64);
    let dot_stride = dot_r * 2.6;
    let total_dots_w = (in_sets.len() - 1) as f64 * dot_stride + dot_r * 2.0;
    let dot_start_x = lx - total_dots_w / 2.0 + dot_r;
    // Place dots so their bottom edge clears the cap-height of the first text line.
    // In SVG, text `y` is the baseline; cap height ≈ 0.72 × font-size above baseline.
    let first_text_y = if vp.show_counts {
        ly - label_size as f64 * 0.6
    } else {
        ly
    };
    let dot_cy = first_text_y - label_size as f64 * 0.72 - dot_r - 2.0;

    for (k, &set_i) in in_sets.iter().enumerate() {
        let color = vp.color_for(set_i);
        scene.add(Primitive::Circle {
            cx: dot_start_x + k as f64 * dot_stride,
            cy: dot_cy,
            r: dot_r,
            fill: Color::from(color.as_str()),
            fill_opacity: None,
            stroke: Some(Color::from("#ffffff")),
            stroke_width: Some(0.6),
        });
    }
}

#[allow(clippy::too_many_arguments)]
fn add_venn(vp: &VennPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let n = vp.sets.len();
    if n == 0 || n > 4 {
        return;
    }

    let cx = computed.margin_left + computed.plot_width() / 2.0;
    let cy = computed.margin_top + computed.plot_height() / 2.0;
    let avail = computed.plot_width().min(computed.plot_height()) / 2.0;

    // Each shape: (cx, cy, rx, ry, angle_deg)
    // For circles: rx == ry, angle == 0
    let shapes: Vec<(f64, f64, f64, f64, f64)> = match n {
        2 => {
            if vp.proportional {
                // Proportional: circle areas ∝ set sizes
                let sz: Vec<f64> = vp
                    .sets
                    .iter()
                    .map(|s| {
                        s.size
                            .unwrap_or_else(|| s.elements.as_deref().map_or(0, |e| e.len()))
                            as f64
                    })
                    .collect();
                let max_sz = sz.iter().cloned().fold(0.0f64, f64::max).max(1.0);
                let scale = avail / (max_sz / std::f64::consts::PI).sqrt() / 1.5;
                let r0 = (sz[0] / std::f64::consts::PI).sqrt() * scale;
                let r1 = (sz[1] / std::f64::consts::PI).sqrt() * scale;
                // Overlap size from region_sizes; convert to approximate area
                let regions = vp.region_sizes();
                let overlap = regions.get(&0b11).copied().unwrap_or(0) as f64;
                let overlap_area = overlap / max_sz * std::f64::consts::PI * r0.min(r1).powi(2);
                let d2 = solve_distance_2set(r0, r1, overlap_area);
                vec![
                    (cx - d2 / 2.0, cy, r0, r0, 0.0),
                    (cx + d2 / 2.0, cy, r1, r1, 0.0),
                ]
            } else {
                // Classic: equal circles, ~40% overlap
                let r = avail * 0.55;
                vec![(cx - r * 0.5, cy, r, r, 0.0), (cx + r * 0.5, cy, r, r, 0.0)]
            }
        }
        3 => {
            if vp.proportional {
                let sz: Vec<f64> = vp
                    .sets
                    .iter()
                    .map(|s| {
                        s.size
                            .unwrap_or_else(|| s.elements.as_deref().map_or(0, |e| e.len()))
                            as f64
                    })
                    .collect();
                let max_sz = sz.iter().cloned().fold(0.0f64, f64::max).max(1.0);
                let scale = avail * 0.9 / ((max_sz / std::f64::consts::PI).sqrt() * 2.0);
                let r: Vec<f64> = sz
                    .iter()
                    .map(|&s| (s / std::f64::consts::PI).sqrt() * scale)
                    .collect();
                let regions = vp.region_sizes();
                // Get pairwise inclusive overlaps (bitmask 0b011, 0b101, 0b110)
                let ov_ab = regions
                    .iter()
                    .filter(|(&m, _)| m & 0b011 == 0b011)
                    .map(|(_, &v)| v)
                    .sum::<usize>() as f64;
                let ov_ac = regions
                    .iter()
                    .filter(|(&m, _)| m & 0b101 == 0b101)
                    .map(|(_, &v)| v)
                    .sum::<usize>() as f64;
                let ov_bc = regions
                    .iter()
                    .filter(|(&m, _)| m & 0b110 == 0b110)
                    .map(|(_, &v)| v)
                    .sum::<usize>() as f64;
                // Convert to area (approximate)
                let area = |ov: f64, ri: f64, rj: f64| -> f64 {
                    ov / max_sz.max(1.0) * std::f64::consts::PI * ri.min(rj).powi(2)
                };
                let d_ab = solve_distance_2set(r[0], r[1], area(ov_ab, r[0], r[1]));
                let d_ac = solve_distance_2set(r[0], r[2], area(ov_ac, r[0], r[2]));
                let d_bc = solve_distance_2set(r[1], r[2], area(ov_bc, r[1], r[2]));
                // Clamp distances so circles always visually overlap
                let d_ab = d_ab.min((r[0] + r[1]) * 0.9);
                let d_ac = d_ac.min((r[0] + r[2]) * 0.9);
                let d_bc = d_bc.min((r[1] + r[2]) * 0.9);
                // Place A at top, B at bottom-left, C at bottom-right via trilateration
                let ay = cy - d_ab.max(d_ac) * 0.4;
                let ax = cx;
                // B: at distance d_ab from A, placed lower-left
                let bx = ax - d_ab * (60.0f64.to_radians().sin()) / 2.0 * 1.2;
                let by_raw = ay + (d_ab * d_ab - (bx - ax).powi(2)).max(0.0).sqrt();
                let by = by_raw.min(cy + avail * 0.8);
                // C: intersect circles at A (d_ac) and B (d_bc)
                let ab_dist = ((bx - ax).powi(2) + (by - ay).powi(2)).sqrt().max(1e-9);
                let cos_c = ((d_ac * d_ac + ab_dist * ab_dist - d_bc * d_bc)
                    / (2.0 * d_ac * ab_dist))
                    .clamp(-1.0, 1.0);
                let angle_ab = (by - ay).atan2(bx - ax);
                let angle_c = angle_ab - cos_c.acos();
                let ccx = ax + d_ac * angle_c.cos();
                let ccy = ay + d_ac * angle_c.sin();
                vec![
                    (ax, ay, r[0], r[0], 0.0),
                    (bx, by, r[1], r[1], 0.0),
                    (ccx, ccy, r[2], r[2], 0.0),
                ]
            } else {
                // Classic equilateral triangle arrangement
                let r = avail * 0.55;
                let offset = r * 0.55;
                let sin60 = (60.0f64.to_radians()).sin();
                let cos60 = (60.0f64.to_radians()).cos();
                vec![
                    (cx, cy - offset, r, r, 0.0),
                    (cx - offset * sin60, cy + offset * cos60, r, r, 0.0),
                    (cx + offset * sin60, cy + offset * cos60, r, r, 0.0),
                ]
            }
        }
        4 => {
            // Standard symmetric 4-set arrangement with rotated ellipses
            let rx = avail * 0.72;
            let ry = avail * 0.44;
            vec![
                (cx - avail * 0.20, cy - avail * 0.05, rx, ry, 45.0),
                (cx + avail * 0.20, cy - avail * 0.05, rx, ry, 135.0),
                (cx - avail * 0.20, cy + avail * 0.05, rx, ry, 135.0),
                (cx + avail * 0.20, cy + avail * 0.05, rx, ry, 45.0),
            ]
        }
        _ => return,
    };

    // Draw filled shapes (fill first, then stroke outlines)
    for (i, &(scx, scy, srx, sry, angle)) in shapes.iter().enumerate() {
        let color = vp.color_for(i);
        let c = Color::from(color.as_str());
        let is_circle = (srx - sry).abs() < 1e-9;
        if is_circle {
            scene.add(Primitive::Circle {
                cx: scx,
                cy: scy,
                r: srx,
                fill: c.clone(),
                fill_opacity: Some(vp.fill_opacity),
                stroke: Some(c),
                stroke_width: Some(vp.stroke_width),
            });
        } else {
            let d = ellipse_path(scx, scy, srx, sry, angle);
            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: Some(c.clone()),
                stroke: c,
                stroke_width: vp.stroke_width,
                opacity: Some(vp.fill_opacity),
                stroke_dasharray: None,
            })));
        }
    }

    // Compute region sizes and total
    let region_map = vp.region_sizes();
    let total: usize = region_map.values().sum::<usize>().max(1);

    // Sample grid for centroid placement
    let x0 = computed.margin_left;
    let y0 = computed.margin_top;
    let x1 = computed.margin_left + computed.plot_width();
    let y1 = computed.margin_top + computed.plot_height();
    // 4-set has 15 crowded regions — require more grid samples and use a smaller font.
    // In leader-lines mode use the same quality threshold: rejected centroids need
    // to be accurate so the march-outward algorithm finds the right exit direction.
    let min_samples = if n == 4 { 15 } else { 5 };
    let centroids = region_centroids(&shapes, x0, y0, x1, y1, min_samples);

    let total_masks = 1u8 << n;
    let label_size = if n == 4 {
        ((computed.body_size as f64 * 0.78) as u32).max(8)
    } else {
        computed.body_size
    };

    if vp.leader_lines {
        // ── Leader-lines mode ────────────────────────────────────────────────
        // Phase 1: run inline collision avoidance, same as the else branch.
        // Accepted labels are drawn inline; rejected labels get leader lines.

        use std::f64::consts::TAU;

        let min_label_dist = if n == 4 {
            label_size as f64 * 3.2
        } else {
            label_size as f64 * 2.0
        };
        let mut masks_ordered: Vec<u8> = (1..total_masks).collect();
        masks_ordered.sort_by_key(|&m| std::cmp::Reverse(region_map.get(&m).copied().unwrap_or(0)));

        let mut placed: Vec<(f64, f64)> = Vec::new();
        // (mask, centroid_x, centroid_y)
        let mut rejected: Vec<(u8, f64, f64)> = Vec::new();

        for &mask in &masks_ordered {
            let Some(&(lx, ly)) = centroids.get(&mask) else {
                continue;
            };
            let blocked = placed.iter().any(|&(px, py)| {
                let dx = lx - px;
                let dy = ly - py;
                (dx * dx + dy * dy).sqrt() < min_label_dist
            });
            if blocked {
                rejected.push((mask, lx, ly));
                continue;
            }
            placed.push((lx, ly));

            // Draw inline label (identical to else branch).
            let count = region_map.get(&mask).copied().unwrap_or(0);
            let count_str = count.to_string();
            let pct_str = format!("({:.1}%)", count as f64 / total as f64 * 100.0);

            // Set-indicator dots above the text block.
            draw_inline_indicators(vp, scene, mask, lx, ly, label_size, n);

            match (vp.show_counts, vp.show_percentages) {
                (true, true) => {
                    scene.add(Primitive::Text {
                        x: lx,
                        y: ly - label_size as f64 * 0.6,
                        content: count_str,
                        size: label_size,
                        anchor: TextAnchor::Middle,
                        rotate: None,
                        bold: false,
                        color: None,
                    });
                    scene.add(Primitive::Text {
                        x: lx,
                        y: ly + label_size as f64 * 0.8,
                        content: pct_str,
                        size: label_size.saturating_sub(2),
                        anchor: TextAnchor::Middle,
                        rotate: None,
                        bold: false,
                        color: None,
                    });
                }
                (true, false) => {
                    scene.add(Primitive::Text {
                        x: lx,
                        y: ly,
                        content: count_str,
                        size: label_size,
                        anchor: TextAnchor::Middle,
                        rotate: None,
                        bold: false,
                        color: None,
                    });
                }
                (false, true) => {
                    scene.add(Primitive::Text {
                        x: lx,
                        y: ly,
                        content: pct_str,
                        size: label_size,
                        anchor: TextAnchor::Middle,
                        rotate: None,
                        bold: false,
                        color: None,
                    });
                }
                (false, false) => {}
            }
        }

        // Phase 2: for each rejected region, march outward from its centroid
        // until we leave all shapes, then add a small gap — this lands us just
        // outside the nearest shape boundary, keeping labels tight to the diagram.
        // Entry: (mask, centroid_xy, anchor_xy, initial_angle)
        #[allow(clippy::type_complexity)]
        let mut leader_entries: Vec<(u8, (f64, f64), (f64, f64), f64)> = Vec::new();

        for &(mask, cen_x, cen_y) in &rejected {
            let raw_dx = cen_x - cx;
            let raw_dy = cen_y - cy;
            let dist = (raw_dx * raw_dx + raw_dy * raw_dy).sqrt();
            let (dir_x, dir_y) = if dist > 1e-9 {
                (raw_dx / dist, raw_dy / dist)
            } else {
                // Centroid is at the diagram centre; distribute radially.
                let a = leader_entries.len() as f64 * TAU / rejected.len().max(1) as f64;
                (a.cos(), a.sin())
            };

            // March outward in small steps until outside all shapes.
            let step = 3.0_f64;
            let mut mx = cen_x;
            let mut my = cen_y;
            for _ in 0..400 {
                mx += dir_x * step;
                my += dir_y * step;
                if point_bitmask(mx, my, &shapes) == 0 {
                    break;
                }
            }
            // Add a gap so the label anchor sits clear of the shape boundary.
            let anchor_x = mx + dir_x * 10.0;
            let anchor_y = my + dir_y * 10.0;
            let angle = (anchor_y - cy).atan2(anchor_x - cx);

            leader_entries.push((mask, (cen_x, cen_y), (anchor_x, anchor_y), angle));
        }

        if leader_entries.is_empty() {
            // Nothing to do.
        } else {
            // Phase 3: sort by angle and run a spreading pass so leader-line labels
            // don't crowd each other.
            leader_entries
                .sort_by(|a, b| a.3.partial_cmp(&b.3).unwrap_or(std::cmp::Ordering::Equal));
            let mut angles: Vec<f64> = leader_entries.iter().map(|e| e.3).collect();

            // Use a common radius: the max anchor distance from the diagram centre.
            // This puts all leader labels on a consistent outer ring.
            let label_r = leader_entries
                .iter()
                .map(|(_, _, (ax, ay), _)| ((ax - cx).powi(2) + (ay - cy).powi(2)).sqrt())
                .fold(0.0_f64, f64::max);

            let min_gap = 0.28_f64; // ~16 degrees
            for _ in 0..5 {
                let m = angles.len();
                if m < 2 {
                    break;
                }
                for i in 0..m {
                    let j = (i + 1) % m;
                    let raw_gap = if j == 0 {
                        angles[0] + TAU - angles[m - 1]
                    } else {
                        angles[j] - angles[i]
                    };
                    if raw_gap < min_gap {
                        let push = (min_gap - raw_gap) / 2.0;
                        if j == 0 {
                            angles[m - 1] += push;
                            angles[0] -= push;
                        } else {
                            angles[i] -= push;
                            angles[j] += push;
                        }
                    }
                }
            }

            // Phase 4: compute final label positions, clamp to canvas with edge margin.
            let edge_margin = 8.0_f64;
            let left_bound = computed.margin_left + edge_margin;
            let right_bound = computed.margin_left + computed.plot_width() - edge_margin;
            let top_bound = computed.margin_top + edge_margin;
            let bottom_bound = computed.margin_top + computed.plot_height() - edge_margin;

            for (idx, (mask, (cen_x, cen_y), _, _)) in leader_entries.iter().enumerate() {
                let angle = angles[idx];

                // Project label onto the shared outer ring, then clamp.
                let lx = (cx + label_r * angle.cos()).clamp(left_bound, right_bound);
                let ly = (cy + label_r * angle.sin()).clamp(top_bound, bottom_bound);

                // Thin grey leader line from centroid to just short of the label.
                let ldx = lx - cen_x;
                let ldy = ly - cen_y;
                let ldist = (ldx * ldx + ldy * ldy).sqrt().max(1.0);
                let (lnx, lny) = (ldx / ldist, ldy / ldist);

                scene.add(Primitive::Line {
                    x1: *cen_x,
                    y1: *cen_y,
                    x2: lx - lnx * 6.0,
                    y2: ly - lny * 6.0,
                    stroke: Color::from("#aaaaaa"),
                    stroke_width: 1.0,
                    stroke_dasharray: None,
                });

                // Per-set indicator dots + count text, side-anchored.
                let in_sets: Vec<usize> = (0..n).filter(|&i| mask & (1u8 << i) != 0).collect();
                let count = region_map.get(mask).copied().unwrap_or(0);
                let count_str = count.to_string();
                let pct_str = format!("({:.1}%)", count as f64 / total as f64 * 100.0);

                let dot_r = (label_size as f64 * 0.42).max(3.0);
                let dot_stride = dot_r * 2.6;
                let total_dots_w = if vp.show_set_indicators {
                    in_sets.len() as f64 * dot_stride
                } else {
                    0.0
                };
                let count_text_w = count_str.len() as f64 * label_size as f64 * 0.62;
                let text_gap = if vp.show_set_indicators { 4.0 } else { 0.0 };

                let nx = lnx; // reuse: direction from centroid toward label
                let group_start_x = if nx > 0.15 {
                    lx
                } else if nx < -0.15 {
                    lx - total_dots_w - text_gap - count_text_w
                } else {
                    lx - (total_dots_w + text_gap + count_text_w) / 2.0
                };

                if vp.show_set_indicators {
                    for (k, &set_i) in in_sets.iter().enumerate() {
                        let color = vp.color_for(set_i);
                        let dot_cx = group_start_x + dot_r + k as f64 * dot_stride;
                        scene.add(Primitive::Circle {
                            cx: dot_cx,
                            cy: ly,
                            r: dot_r,
                            fill: Color::from(color.as_str()),
                            fill_opacity: None,
                            stroke: Some(Color::from("#ffffff")),
                            stroke_width: Some(0.8),
                        });
                    }
                }

                let text_x = group_start_x + total_dots_w + text_gap;
                let text_y = ly + label_size as f64 * 0.35;

                if vp.show_counts {
                    scene.add(Primitive::Text {
                        x: text_x,
                        y: text_y,
                        content: count_str,
                        size: label_size,
                        anchor: TextAnchor::Start,
                        rotate: None,
                        bold: false,
                        color: None,
                    });
                }
                if vp.show_percentages {
                    scene.add(Primitive::Text {
                        x: text_x,
                        y: text_y + label_size as f64 * 1.3,
                        content: pct_str,
                        size: label_size.saturating_sub(2),
                        anchor: TextAnchor::Start,
                        rotate: None,
                        bold: false,
                        color: None,
                    });
                }
            }
        }
    } else {
        // ── Inline collision-avoidance mode (default) ────────────────────────
        // For 4-set diagrams the central intersection regions are geometrically tiny and
        // their centroids cluster together.  We use greedy collision avoidance: sort
        // regions by size (largest first) and skip any label whose centroid is within
        // `min_label_dist` pixels of an already-placed label.
        let min_label_dist = if n == 4 { label_size as f64 * 3.2 } else { 0.0 };
        let mut masks_ordered: Vec<u8> = (1..total_masks).collect();
        if n == 4 {
            masks_ordered
                .sort_by_key(|&m| std::cmp::Reverse(region_map.get(&m).copied().unwrap_or(0)));
        }
        let mut placed: Vec<(f64, f64)> = Vec::new();

        for mask in masks_ordered {
            let count = region_map.get(&mask).copied().unwrap_or(0);
            if let Some(&(lx, ly)) = centroids.get(&mask) {
                // Collision check: skip if too close to any already-placed label.
                if min_label_dist > 0.0 {
                    let blocked = placed.iter().any(|&(px, py)| {
                        let dx = lx - px;
                        let dy = ly - py;
                        (dx * dx + dy * dy).sqrt() < min_label_dist
                    });
                    if blocked {
                        continue;
                    }
                }
                placed.push((lx, ly));

                let count_str = count.to_string();
                let pct_str = format!("({:.1}%)", count as f64 / total as f64 * 100.0);

                // Set-indicator dots above the text block.
                draw_inline_indicators(vp, scene, mask, lx, ly, label_size, n);

                match (vp.show_counts, vp.show_percentages) {
                    (true, true) => {
                        scene.add(Primitive::Text {
                            x: lx,
                            y: ly - label_size as f64 * 0.6,
                            content: count_str,
                            size: label_size,
                            anchor: TextAnchor::Middle,
                            rotate: None,
                            bold: false,
                            color: None,
                        });
                        scene.add(Primitive::Text {
                            x: lx,
                            y: ly + label_size as f64 * 0.8,
                            content: pct_str,
                            size: label_size.saturating_sub(2),
                            anchor: TextAnchor::Middle,
                            rotate: None,
                            bold: false,
                            color: None,
                        });
                    }
                    (true, false) => {
                        scene.add(Primitive::Text {
                            x: lx,
                            y: ly,
                            content: count_str,
                            size: label_size,
                            anchor: TextAnchor::Middle,
                            rotate: None,
                            bold: false,
                            color: None,
                        });
                    }
                    (false, true) => {
                        scene.add(Primitive::Text {
                            x: lx,
                            y: ly,
                            content: pct_str,
                            size: label_size,
                            anchor: TextAnchor::Middle,
                            rotate: None,
                            bold: false,
                            color: None,
                        });
                    }
                    (false, false) => {}
                }
            }
        }
    }

    // Set name labels — placed outside each shape, in the direction away from the
    // diagram centre.  For circles the boundary is trivial; for rotated ellipses we
    // find the parametric point that maximises the dot product with the outward direction.
    if vp.show_set_labels {
        let label_size_big = ((computed.body_size as f64 * 1.1) as u32).max(computed.body_size);
        let label_margin = label_size_big as f64 + 6.0;

        for (i, set) in vp.sets.iter().enumerate() {
            let (ecx, ecy, rx, ry, angle_deg) = shapes[i];

            // Outward direction from diagram centre → shape centre.
            // For 2-set the centres are exactly horizontal; add a slight upward bias so
            // labels end up above the circles rather than directly to the side.
            let (raw_dx, raw_dy) = (ecx - cx, ecy - cy);
            let (raw_dx, raw_dy) = if n == 2 {
                // Bias: 70 % horizontal + 30 % upward
                (raw_dx, raw_dy - raw_dx.abs() * 0.45)
            } else {
                (raw_dx, raw_dy)
            };
            let dist = (raw_dx * raw_dx + raw_dy * raw_dy).sqrt();
            let (nx, ny) = if dist > 1e-9 {
                (raw_dx / dist, raw_dy / dist)
            } else {
                // Centred shape — distribute directions evenly
                let angle = i as f64 * std::f64::consts::TAU / n as f64;
                (angle.cos(), -angle.sin())
            };

            // Boundary point of the shape in direction (nx, ny)
            let (bx, by) = if (rx - ry).abs() < 1e-9 {
                // Circle
                (ecx + nx * rx, ecy + ny * ry)
            } else {
                // Rotated ellipse: maximise (x-ecx)*nx + (y-ecy)*ny over the ellipse.
                // In local (un-rotated) frame: a = nx·cos θ + ny·sin θ, b = -nx·sin θ + ny·cos θ
                // optimum t = atan2(ry·b, rx·a)
                let theta = angle_deg.to_radians();
                let a = nx * theta.cos() + ny * theta.sin();
                let b = -nx * theta.sin() + ny * theta.cos();
                let t = (ry * b).atan2(rx * a);
                let bx = ecx + rx * t.cos() * theta.cos() - ry * t.sin() * theta.sin();
                let by = ecy + rx * t.cos() * theta.sin() + ry * t.sin() * theta.cos();
                (bx, by)
            };

            let label_x = bx + nx * label_margin;
            // Small baseline adjustment so the text visual centre aligns with the point
            let label_y = by + ny * label_margin + label_size_big as f64 * 0.35;

            let anchor = if nx < -0.25 {
                TextAnchor::End
            } else if nx > 0.25 {
                TextAnchor::Start
            } else {
                TextAnchor::Middle
            };

            scene.add(Primitive::Text {
                x: label_x,
                y: label_y,
                content: set.label.clone(),
                size: label_size_big,
                anchor,
                rotate: None,
                bold: true,
                color: None,
            });
        }
    }

    // ── Proportional stress display ───────────────────────────────────────────
    // Stress (venneuler formula) measures how accurately the visual circle/ellipse
    // areas represent the target region sizes.  0 = perfect; >0.2 = poor.
    if vp.proportional && vp.show_loss {
        let mut grid_counts: std::collections::HashMap<u8, usize> =
            std::collections::HashMap::new();
        for iy in 0..80usize {
            let py = y0 + (iy as f64 + 0.5) / 80.0 * (y1 - y0);
            for ix in 0..80usize {
                let px = x0 + (ix as f64 + 0.5) / 80.0 * (x1 - x0);
                let m = point_bitmask(px, py, &shapes);
                if m != 0 {
                    *grid_counts.entry(m).or_insert(0) += 1;
                }
            }
        }
        let total_grid = grid_counts.values().sum::<usize>().max(1) as f64;
        let total_target = region_map.values().sum::<usize>().max(1) as f64;

        // venneuler stress: sqrt(Σ(aᵢ−tᵢ)² / Σtᵢ²)
        let mut sq_num = 0.0_f64;
        let mut sq_den = 0.0_f64;
        for mask in 1..total_masks {
            let ai = grid_counts.get(&mask).copied().unwrap_or(0) as f64 / total_grid;
            let ti = region_map.get(&mask).copied().unwrap_or(0) as f64 / total_target;
            sq_num += (ai - ti).powi(2);
            sq_den += ti.powi(2);
        }
        let stress = if sq_den > 1e-12 {
            (sq_num / sq_den).sqrt()
        } else {
            0.0
        };

        // Render as a small info box in the bottom-right corner of the plot area.
        let text_size = 10u32;
        let label_text = "Layout stress";
        let value_text = format!("{stress:.3}");
        let pad_x = 7.0_f64;
        let pad_y = 5.0_f64;
        let row_h = text_size as f64 * 1.4;
        let box_w = (label_text.len().max(value_text.len()) as f64 * text_size as f64 * 0.62
            + pad_x * 2.0)
            .max(90.0_f64);
        let box_h = row_h * 2.0 + pad_y * 2.0;
        let box_x = computed.margin_left + computed.plot_width() - 8.0 - box_w;
        let box_y = computed.margin_top + computed.plot_height() - 8.0 - box_h;

        // Background + border
        scene.add(Primitive::Rect {
            x: box_x,
            y: box_y,
            width: box_w,
            height: box_h,
            fill: Color::from("#ffffff"),
            stroke: Some(Color::from("#cccccc")),
            stroke_width: Some(0.8),
            opacity: Some(0.92),
        });
        // Thin header strip
        scene.add(Primitive::Rect {
            x: box_x,
            y: box_y,
            width: box_w,
            height: row_h + pad_y,
            fill: Color::from("#f0f0f0"),
            stroke: None,
            stroke_width: None,
            opacity: Some(0.92),
        });

        // "Layout stress" label (header row)
        scene.add(Primitive::Text {
            x: box_x + pad_x,
            y: box_y + pad_y + text_size as f64 * 0.75,
            content: label_text.to_string(),
            size: text_size,
            anchor: TextAnchor::Start,
            rotate: None,
            bold: true,
            color: None,
        });
        // Stress value (value row)
        scene.add(Primitive::Text {
            x: box_x + pad_x,
            y: box_y + pad_y + row_h + text_size as f64 * 0.75,
            content: value_text,
            size: text_size,
            anchor: TextAnchor::Start,
            rotate: None,
            bold: false,
            color: None,
        });
    }
}

// ── RaincloudPlot ─────────────────────────────────────────────────────────────

fn add_raincloud(rp: &RaincloudPlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::render::palette::Palette;

    let n = rp.groups.len();
    if n == 0 {
        return;
    }

    let cat10 = Palette::category10();

    for (i, group) in rp.groups.iter().enumerate() {
        if group.values.is_empty() {
            continue;
        }

        let color = rp
            .group_colors
            .as_ref()
            .and_then(|c| c.get(i).map(|s| s.as_str()))
            .unwrap_or_else(|| {
                if rp.groups.len() > 1 {
                    // Per-group palette colours for multi-group plots
                    &cat10[i]
                } else {
                    &rp.color
                }
            });

        // Sign convention: cloud_sign > 0 → cloud extends to the right of center
        let cloud_sign: f64 = if rp.flip { -1.0 } else { 1.0 };
        let rain_sign: f64 = -cloud_sign;

        let group_x = (i + 1) as f64;
        let cloud_cx = group_x + cloud_sign * rp.cloud_offset;
        let rain_cx = group_x + rain_sign * rp.rain_offset;

        // ── CLOUD (half-violin) ───────────────────────────────────────────────
        if rp.show_cloud {
            let h = rp.bandwidth.unwrap_or_else(|| {
                render_utils::silverman_bandwidth(&group.values) * rp.bandwidth_scale
            });
            let all_kde = render_utils::simple_kde(&group.values, h, rp.kde_samples);
            let data_min = group.values.iter().cloned().fold(f64::INFINITY, f64::min);
            let data_max = group
                .values
                .iter()
                .cloned()
                .fold(f64::NEG_INFINITY, f64::max);
            let kde: Vec<(f64, f64)> = all_kde
                .into_iter()
                .filter(|(y, _)| *y >= data_min && *y <= data_max)
                .collect();
            if !kde.is_empty() {
                let max_density = kde
                    .iter()
                    .map(|(_, d)| *d)
                    .fold(f64::NEG_INFINITY, f64::max);
                if max_density > 0.0 {
                    let scale = rp.cloud_width / max_density;
                    let spine_px = computed.map_x(cloud_cx);

                    // Build the half-violin path:
                    // go from bottom to top along the KDE outline (spine + width),
                    // then back down along the spine.
                    let mut path_data = String::with_capacity(kde.len() * 32);
                    {
                        let mut rb = ryu::Buffer::new();
                        // Trace outer edge (spine + density offset in cloud_sign direction)
                        for (j, (y_val, density)) in kde.iter().enumerate() {
                            let py = computed.map_y(*y_val);
                            let px = spine_px + cloud_sign * density * scale;
                            path_data.push(if j == 0 { 'M' } else { 'L' });
                            path_data.push(' ');
                            path_data.push_str(rb.format(round2(px)));
                            path_data.push(' ');
                            path_data.push_str(rb.format(round2(py)));
                            path_data.push(' ');
                        }
                        // Return along the spine (density = 0, same x = spine_px)
                        for (y_val, _) in kde.iter().rev() {
                            let py = computed.map_y(*y_val);
                            path_data.push_str("L ");
                            path_data.push_str(rb.format(round2(spine_px)));
                            path_data.push(' ');
                            path_data.push_str(rb.format(round2(py)));
                            path_data.push(' ');
                        }
                    }
                    path_data.push('Z');

                    scene.add(Primitive::Path(Box::new(PathData {
                        d: path_data,
                        fill: Some(Color::from(color)),
                        stroke: Color::from(color),
                        stroke_width: 0.5,
                        opacity: Some(rp.cloud_alpha),
                        stroke_dasharray: None,
                    })));
                }
            }
        }

        // ── BOX-AND-WHISKER ───────────────────────────────────────────────────
        if rp.show_box {
            let mut sorted = group.values.clone();
            sorted.sort_by(|a, b| a.total_cmp(b));

            let q1 = percentile(&sorted, 25.0);
            let q2 = percentile(&sorted, 50.0);
            let q3 = percentile(&sorted, 75.0);
            let iqr = q3 - q1;
            let lower_w = sorted
                .iter()
                .cloned()
                .filter(|v| *v >= q1 - 1.5 * iqr)
                .fold(f64::INFINITY, f64::min);
            let upper_w = sorted
                .iter()
                .cloned()
                .filter(|v| *v <= q3 + 1.5 * iqr)
                .fold(f64::NEG_INFINITY, f64::max);

            // Box half-width in pixels
            let box_half_px = computed.plot_width() / n as f64 * rp.box_width / 2.0;

            let xmid = computed.map_x(group_x);
            let x0 = xmid - box_half_px;
            let x1 = xmid + box_half_px;
            let yq1 = computed.map_y(q1);
            let yq3 = computed.map_y(q3);
            let ymed = computed.map_y(q2);
            let ylow = computed.map_y(lower_w);
            let yhigh = computed.map_y(upper_w);

            // IQR box
            scene.add(Primitive::Rect {
                x: x0,
                y: yq3.min(yq1),
                width: (x1 - x0).abs(),
                height: (yq1 - yq3).abs(),
                fill: Color::from(color),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });

            // Median line (white so it stands out on the coloured box)
            scene.add(Primitive::Line {
                x1: x0,
                y1: ymed,
                x2: x1,
                y2: ymed,
                stroke: Color::from(&computed.theme.box_median),
                stroke_width: 1.5,
                stroke_dasharray: None,
            });

            // Lower whisker
            scene.add(Primitive::Line {
                x1: xmid,
                y1: ylow,
                x2: xmid,
                y2: yq1,
                stroke: Color::from(color),
                stroke_width: 1.0,
                stroke_dasharray: None,
            });
            // Upper whisker
            scene.add(Primitive::Line {
                x1: xmid,
                y1: yq3,
                x2: xmid,
                y2: yhigh,
                stroke: Color::from(color),
                stroke_width: 1.0,
                stroke_dasharray: None,
            });

            // Whisker caps
            let cap_half = box_half_px * 0.5;
            for &y in &[ylow, yhigh] {
                scene.add(Primitive::Line {
                    x1: xmid - cap_half,
                    y1: y,
                    x2: xmid + cap_half,
                    y2: y,
                    stroke: Color::from(color),
                    stroke_width: 1.0,
                    stroke_dasharray: None,
                });
            }
        }

        // ── RAIN (jittered points) ────────────────────────────────────────────
        if rp.show_rain {
            let style = StripStyle::Strip {
                jitter: rp.rain_jitter,
            };
            add_strip_points(
                &group.values,
                rain_cx,
                &style,
                color,
                None,
                None, // no per-point shapes for raincloud rain
                rp.rain_size,
                rp.seed.wrapping_add(i as u64 * 1000),
                Some(rp.rain_alpha),
                None,
                false,
                None,
                &group.label,
                0,
                scene,
                computed,
            );
        }
    }
}

// ── Clustermap ────────────────────────────────────────────────────────────────

fn euclidean_dist_matrix(data: &[Vec<f64>]) -> Vec<Vec<f64>> {
    let n = data.len();
    let mut dm = vec![vec![0.0f64; n]; n];
    for i in 0..n {
        for j in (i + 1)..n {
            let d = data[i]
                .iter()
                .zip(&data[j])
                .map(|(&a, &b)| (a - b) * (a - b))
                .sum::<f64>()
                .sqrt();
            dm[i][j] = d;
            dm[j][i] = d;
        }
    }
    dm
}

fn apply_normalization(data: Vec<Vec<f64>>, norm: &ClustermapNorm) -> Vec<Vec<f64>> {
    match norm {
        ClustermapNorm::None => data,
        ClustermapNorm::RowZScore => data
            .into_iter()
            .map(|row| {
                let n = row.len() as f64;
                if n == 0.0 {
                    return row;
                }
                let mean = row.iter().sum::<f64>() / n;
                let std = (row.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / n).sqrt();
                if std < f64::EPSILON {
                    row.iter().map(|_| 0.0).collect()
                } else {
                    row.iter().map(|&v| (v - mean) / std).collect()
                }
            })
            .collect(),
        ClustermapNorm::ColZScore => {
            if data.is_empty() {
                return data;
            }
            let n_cols = data[0].len();
            let n_rows = data.len();
            let mut result = data.clone();
            for c in 0..n_cols {
                let col: Vec<f64> = data.iter().map(|r| r[c]).collect();
                let mean = col.iter().sum::<f64>() / n_rows as f64;
                let std =
                    (col.iter().map(|&v| (v - mean).powi(2)).sum::<f64>() / n_rows as f64).sqrt();
                for r in 0..n_rows {
                    result[r][c] = if std < f64::EPSILON {
                        0.0
                    } else {
                        (data[r][c] - mean) / std
                    };
                }
            }
            result
        }
    }
}

/// Draw a rectangular-style phylogram dendrogram hanging leftward from the
/// heatmap (row dendrogram). Root is at the far left, leaves touch the heatmap.
/// Node x-positions are proportional to accumulated branch lengths (UPGMA distances).
#[allow(clippy::too_many_arguments)]
fn draw_row_dendrogram(
    nodes: &[crate::plot::phylo::PhyloNode],
    root: usize,
    scene: &mut Scene,
    _ml: f64,
    hm_x: f64,
    hm_y: f64,
    row_dend_w: f64,
    cell_h: f64,
    branch_color: &str,
) {
    use crate::plot::phylo::post_order_dfs;

    let n_nodes = nodes.len();
    if n_nodes == 0 {
        return;
    }
    let post_order = post_order_dfs(root, nodes);

    // Assign leaf positions (0 = top)
    let mut pos = vec![0.0f64; n_nodes];
    let mut leaf_counter = 0usize;
    for &id in &post_order {
        if nodes[id].children.is_empty() {
            pos[id] = leaf_counter as f64;
            leaf_counter += 1;
        } else {
            let sum: f64 = nodes[id].children.iter().map(|&c| pos[c]).sum();
            pos[id] = sum / nodes[id].children.len() as f64;
        }
    }
    let n_leaves = leaf_counter;
    if n_leaves == 0 {
        return;
    }

    // Phylogram: accumulated branch lengths from root (root=0, leaves=max_dist)
    let mut acc_dist = vec![0.0f64; n_nodes];
    let mut stack = vec![root];
    while let Some(id) = stack.pop() {
        for &c in &nodes[id].children {
            acc_dist[c] = acc_dist[id] + nodes[c].branch_length;
            stack.push(c);
        }
    }
    // Equal-stem spacing: rank INTERNAL nodes by unique acc_dist, evenly spaced
    // from 0 to just-before-1.0. Leaves always sit at 1.0 (touching heatmap).
    // This prevents zero-branch-length internal nodes from collapsing onto the
    // leaf edge (which happens when all merges in a subtree occur at distance 0).
    let mut internal_unique: Vec<f64> = (0..n_nodes)
        .filter(|&id| !nodes[id].children.is_empty())
        .map(|id| acc_dist[id])
        .collect();
    internal_unique.sort_by(|a, b| a.partial_cmp(b).unwrap());
    internal_unique.dedup_by(|a, b| (*a - *b).abs() < 1e-10);
    let n_int = internal_unique.len();
    if n_int == 0 {
        return;
    }

    let row_dend_draw_w = (row_dend_w - 10.0).max(1.0);
    let color = Color::from(branch_color);

    // px: root at left edge, leaves at right edge; internal nodes evenly between
    let px = |id: usize| -> f64 {
        let d_frac = if nodes[id].children.is_empty() {
            1.0
        } else {
            let rank = internal_unique
                .iter()
                .position(|&u| (u - acc_dist[id]).abs() < 1e-10)
                .unwrap_or(0);
            rank as f64 / n_int as f64
        };
        hm_x - row_dend_draw_w + d_frac * row_dend_draw_w
    };
    let py = |id: usize| -> f64 { hm_y + (pos[id] + 0.5) * cell_h };

    for &id in &post_order {
        if nodes[id].children.is_empty() {
            continue;
        }
        let children = &nodes[id].children;

        let py_self = py(id);
        let px_self = px(id);

        let py_min = children
            .iter()
            .map(|&c| py(c))
            .fold(f64::INFINITY, f64::min);
        let py_max = children
            .iter()
            .map(|&c| py(c))
            .fold(f64::NEG_INFINITY, f64::max);

        // Vertical connector at px_self spanning all children
        scene.add(Primitive::Line {
            x1: px_self,
            y1: py_min,
            x2: px_self,
            y2: py_max,
            stroke: color.clone(),
            stroke_width: 1.0,
            stroke_dasharray: None,
        });

        // Horizontal elbow to each child
        for &c in children {
            let px_c = px(c);
            let py_c = py(c);
            scene.add(Primitive::Line {
                x1: px_self,
                y1: py_c,
                x2: px_c,
                y2: py_c,
                stroke: color.clone(),
                stroke_width: 1.0,
                stroke_dasharray: None,
            });
        }
        let _ = py_self; // pos of internal node not directly drawn in rectangular style
    }
}

/// Draw a rectangular-style phylogram dendrogram hanging downward from the
/// top margin (column dendrogram). Root is at top, leaves touch the heatmap.
/// Node y-positions are proportional to accumulated branch lengths (UPGMA distances).
#[allow(clippy::too_many_arguments)]
fn draw_col_dendrogram(
    nodes: &[crate::plot::phylo::PhyloNode],
    root: usize,
    scene: &mut Scene,
    hm_x: f64,
    mt: f64,
    col_dend_h: f64,
    cell_w: f64,
    branch_color: &str,
) {
    use crate::plot::phylo::post_order_dfs;

    let n_nodes = nodes.len();
    if n_nodes == 0 {
        return;
    }
    let post_order = post_order_dfs(root, nodes);

    // Assign leaf positions (0 = leftmost column)
    let mut pos = vec![0.0f64; n_nodes];
    let mut leaf_counter = 0usize;
    for &id in &post_order {
        if nodes[id].children.is_empty() {
            pos[id] = leaf_counter as f64;
            leaf_counter += 1;
        } else {
            let sum: f64 = nodes[id].children.iter().map(|&c| pos[c]).sum();
            pos[id] = sum / nodes[id].children.len() as f64;
        }
    }
    let n_leaves = leaf_counter;
    if n_leaves == 0 {
        return;
    }

    // Phylogram: accumulated branch lengths from root (root=0, leaves=max_dist)
    let mut acc_dist = vec![0.0f64; n_nodes];
    let mut stack = vec![root];
    while let Some(id) = stack.pop() {
        for &c in &nodes[id].children {
            acc_dist[c] = acc_dist[id] + nodes[c].branch_length;
            stack.push(c);
        }
    }
    // Equal-stem spacing: rank INTERNAL nodes by unique acc_dist, evenly spaced
    // from 0 to just-before-1.0. Leaves always sit at 1.0 (touching heatmap).
    let mut internal_unique: Vec<f64> = (0..n_nodes)
        .filter(|&id| !nodes[id].children.is_empty())
        .map(|id| acc_dist[id])
        .collect();
    internal_unique.sort_by(|a, b| a.partial_cmp(b).unwrap());
    internal_unique.dedup_by(|a, b| (*a - *b).abs() < 1e-10);
    let n_int = internal_unique.len();
    if n_int == 0 {
        return;
    }

    let col_dend_draw_h = (col_dend_h - 5.0).max(1.0);
    let color = Color::from(branch_color);

    // py: root at top, leaves at bottom; internal nodes evenly between
    let py = |id: usize| -> f64 {
        let d_frac = if nodes[id].children.is_empty() {
            1.0
        } else {
            let rank = internal_unique
                .iter()
                .position(|&u| (u - acc_dist[id]).abs() < 1e-10)
                .unwrap_or(0);
            rank as f64 / n_int as f64
        };
        mt + 5.0 + d_frac * col_dend_draw_h
    };
    let px = |id: usize| -> f64 { hm_x + (pos[id] + 0.5) * cell_w };

    for &id in &post_order {
        if nodes[id].children.is_empty() {
            continue;
        }
        let children = &nodes[id].children;

        let py_self = py(id);

        let px_min = children
            .iter()
            .map(|&c| px(c))
            .fold(f64::INFINITY, f64::min);
        let px_max = children
            .iter()
            .map(|&c| px(c))
            .fold(f64::NEG_INFINITY, f64::max);

        // Horizontal connector at py_self spanning all children
        scene.add(Primitive::Line {
            x1: px_min,
            y1: py_self,
            x2: px_max,
            y2: py_self,
            stroke: color.clone(),
            stroke_width: 1.0,
            stroke_dasharray: None,
        });

        // Vertical elbow to each child
        for &c in children {
            let px_c = px(c);
            let py_c = py(c);
            scene.add(Primitive::Line {
                x1: px_c,
                y1: py_self,
                x2: px_c,
                y2: py_c,
                stroke: color.clone(),
                stroke_width: 1.0,
                stroke_dasharray: None,
            });
        }
    }
}

fn add_clustermap(cm: &Clustermap, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::plot::phylo::post_order_dfs;

    let n_rows = cm.data.len();
    let n_cols = cm.data.first().map_or(0, |r| r.len());
    if n_rows == 0 || n_cols == 0 {
        return;
    }

    // ── Step 1: Build / obtain trees ─────────────────────────────────────────

    // Generate default labels if none provided
    let default_row_labels: Vec<String> = (0..n_rows).map(|i| i.to_string()).collect();
    let default_col_labels: Vec<String> = (0..n_cols).map(|i| i.to_string()).collect();
    let row_label_strs: Vec<&str> = cm
        .row_labels
        .as_ref()
        .unwrap_or(&default_row_labels)
        .iter()
        .map(|s| s.as_str())
        .collect();
    let col_label_strs: Vec<&str> = cm
        .col_labels
        .as_ref()
        .unwrap_or(&default_col_labels)
        .iter()
        .map(|s| s.as_str())
        .collect();

    let row_tree: Option<(Vec<crate::plot::phylo::PhyloNode>, usize)> =
        if let Some(ref tree) = cm.row_tree {
            Some((tree.nodes.clone(), tree.root))
        } else if cm.cluster_rows && n_rows >= 2 {
            let dist = euclidean_dist_matrix(&cm.data);
            Some(render_utils::upgma(&row_label_strs, &dist))
        } else {
            None
        };

    let col_tree: Option<(Vec<crate::plot::phylo::PhyloNode>, usize)> =
        if let Some(ref tree) = cm.col_tree {
            Some((tree.nodes.clone(), tree.root))
        } else if cm.cluster_cols && n_cols >= 2 {
            let transposed: Vec<Vec<f64>> = (0..n_cols)
                .map(|j| (0..n_rows).map(|i| cm.data[i][j]).collect())
                .collect();
            let dist = euclidean_dist_matrix(&transposed);
            Some(render_utils::upgma(&col_label_strs, &dist))
        } else {
            None
        };

    // ── Step 2: Leaf order → data permutation ────────────────────────────────

    let row_perm: Vec<usize> = if let Some((ref nodes, root)) = row_tree {
        let labels = cm.row_labels.as_ref().unwrap_or(&default_row_labels);
        let label_to_idx: HashMap<&str, usize> = labels
            .iter()
            .enumerate()
            .map(|(i, s)| (s.as_str(), i))
            .collect();
        post_order_dfs(root, nodes)
            .into_iter()
            .filter(|&id| nodes[id].children.is_empty())
            .filter_map(|id| {
                nodes[id]
                    .label
                    .as_deref()
                    .and_then(|l| label_to_idx.get(l).copied())
            })
            .collect()
    } else {
        (0..n_rows).collect()
    };

    let col_perm: Vec<usize> = if let Some((ref nodes, root)) = col_tree {
        let labels = cm.col_labels.as_ref().unwrap_or(&default_col_labels);
        let label_to_idx: HashMap<&str, usize> = labels
            .iter()
            .enumerate()
            .map(|(i, s)| (s.as_str(), i))
            .collect();
        post_order_dfs(root, nodes)
            .into_iter()
            .filter(|&id| nodes[id].children.is_empty())
            .filter_map(|id| {
                nodes[id]
                    .label
                    .as_deref()
                    .and_then(|l| label_to_idx.get(l).copied())
            })
            .collect()
    } else {
        (0..n_cols).collect()
    };

    // Reorder data matrix
    let data: Vec<Vec<f64>> = row_perm
        .iter()
        .map(|&r| col_perm.iter().map(|&c| cm.data[r][c]).collect())
        .collect();

    // Reorder labels
    let row_labels_ord: Option<Vec<String>> = cm
        .row_labels
        .as_ref()
        .map(|l| row_perm.iter().map(|&i| l[i].clone()).collect());
    let col_labels_ord: Option<Vec<String>> = cm
        .col_labels
        .as_ref()
        .map(|l| col_perm.iter().map(|&i| l[i].clone()).collect());

    // ── Step 3: Normalization ─────────────────────────────────────────────────
    let data = apply_normalization(data, &cm.normalization);

    let mut v_min = f64::INFINITY;
    let mut v_max = f64::NEG_INFINITY;
    for &v in data.iter().flatten() {
        if v < v_min {
            v_min = v;
        }
        if v > v_max {
            v_max = v;
        }
    }
    let norm_val =
        |v: f64| -> f64 { ((v - v_min) / (v_max - v_min + f64::EPSILON)).clamp(0.0, 1.0) };

    // ── Step 4: Pixel layout ──────────────────────────────────────────────────
    let ml = computed.margin_left;
    let mt = computed.margin_top;
    let pw = computed.plot_width();
    let ph = computed.plot_height();

    let row_dend_w = if row_tree.is_some() {
        cm.row_dendrogram_width
    } else {
        0.0
    };
    let col_dend_h = if col_tree.is_some() {
        cm.col_dendrogram_height
    } else {
        0.0
    };
    let row_annot_w: f64 = cm.row_annotations.iter().map(|t| t.width + 4.0).sum();
    let col_annot_h: f64 = cm.col_annotations.iter().map(|t| t.width + 4.0).sum();

    let row_label_w = row_labels_ord
        .as_ref()
        .map(|l| {
            let max_chars = l.iter().map(|s| s.len()).max().unwrap_or(4);
            (max_chars as f64 * 7.0 + 10.0).clamp(30.0, 200.0)
        })
        .unwrap_or(0.0);
    let col_label_h = if col_labels_ord.is_some() { 80.0 } else { 0.0 };

    let hm_x = ml + row_dend_w + row_annot_w;
    let hm_y = mt + col_dend_h + col_annot_h;
    let hm_w = (pw - row_dend_w - row_annot_w - row_label_w).max(10.0);
    let hm_h = (ph - col_dend_h - col_annot_h - col_label_h).max(10.0);

    let n_rows_ord = row_perm.len().max(1);
    let n_cols_ord = col_perm.len().max(1);
    let cell_w = hm_w / n_cols_ord as f64;
    let cell_h = hm_h / n_rows_ord as f64;

    // ── Step 5: Row dendrogram ────────────────────────────────────────────────
    if let Some((ref nodes, root)) = row_tree {
        // Pass ml + row_dend_w (right edge of dendrogram panel) not hm_x
        // (which includes annotation width). This keeps all elbow lines
        // strictly within the dendrogram panel — none enter the annotation boxes.
        draw_row_dendrogram(
            nodes,
            root,
            scene,
            ml,
            ml + row_dend_w,
            hm_y,
            row_dend_w,
            cell_h,
            &cm.branch_color,
        );
    }

    // ── Step 6: Column dendrogram ─────────────────────────────────────────────
    if let Some((ref nodes, root)) = col_tree {
        draw_col_dendrogram(
            nodes,
            root,
            scene,
            hm_x,
            mt,
            col_dend_h,
            cell_w,
            &cm.branch_color,
        );
    }

    // ── Step 7: Annotation tracks ─────────────────────────────────────────────

    // Row annotations (between row dendrogram and heatmap body)
    let mut x_cursor = ml + row_dend_w;
    for track in &cm.row_annotations {
        if let Some(ref label) = track.label {
            // anchor End + rotate(-90°): the text's "end" (anchor) is the
            // *top* of the downward-rendering text. Placing y just below
            // hm_y + hm_h keeps 4 px of padding from the last annotation
            // cell and the label hangs down into the col-label margin, clear
            // of the dendrogram and all annotation boxes above.
            scene.add(Primitive::Text {
                x: x_cursor + track.width / 2.0,
                y: hm_y + hm_h + 4.0,
                content: label.clone(),
                size: computed.body_size,
                anchor: TextAnchor::End,
                rotate: Some(-90.0),
                bold: false,
                color: None,
            });
        }
        for (k, &orig_row) in row_perm.iter().enumerate() {
            let color_str = track
                .colors
                .get(orig_row)
                .map(|s| s.as_str())
                .unwrap_or("#cccccc");
            let y = hm_y + k as f64 * cell_h;
            scene.add(Primitive::Rect {
                x: x_cursor,
                y,
                width: track.width,
                height: cell_h * 0.99,
                fill: Color::from(color_str),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });
        }
        x_cursor += track.width + 4.0;
    }

    // Col annotations (between col dendrogram and heatmap body)
    let mut y_cursor = mt + col_dend_h;
    for track in &cm.col_annotations {
        if let Some(ref label) = track.label {
            scene.add(Primitive::Text {
                x: hm_x - 4.0,
                y: y_cursor + track.width / 2.0,
                content: label.clone(),
                size: computed.body_size,
                anchor: TextAnchor::End,
                rotate: None,
                bold: false,
                color: None,
            });
        }
        for (k, &orig_col) in col_perm.iter().enumerate() {
            let color_str = track
                .colors
                .get(orig_col)
                .map(|s| s.as_str())
                .unwrap_or("#cccccc");
            let x = hm_x + k as f64 * cell_w;
            scene.add(Primitive::Rect {
                x,
                y: y_cursor,
                width: cell_w * 0.99,
                height: track.width,
                fill: Color::from(color_str),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });
        }
        y_cursor += track.width + 4.0;
    }

    // ── Step 8: Heatmap body ──────────────────────────────────────────────────
    let n_cells = data.iter().map(|r| r.len()).sum::<usize>();
    let use_batch = !cm.show_tooltips;

    if use_batch {
        let mut xs = Vec::with_capacity(n_cells);
        let mut ys = Vec::with_capacity(n_cells);
        let mut ws = Vec::with_capacity(n_cells);
        let mut hs = Vec::with_capacity(n_cells);
        let mut fills = Vec::with_capacity(n_cells);
        for (row_k, row) in data.iter().enumerate() {
            for (col_k, &value) in row.iter().enumerate() {
                xs.push(hm_x + col_k as f64 * cell_w);
                ys.push(hm_y + row_k as f64 * cell_h);
                ws.push(cell_w * 0.99);
                hs.push(cell_h * 0.99);
                fills.push(Color::from(cm.color_map.map(norm_val(value))));
            }
        }
        scene.add(Primitive::RectBatch {
            x: xs,
            y: ys,
            w: ws,
            h: hs,
            fills,
        });
    } else {
        for (row_k, row) in data.iter().enumerate() {
            for (col_k, &value) in row.iter().enumerate() {
                let x = hm_x + col_k as f64 * cell_w;
                let y = hm_y + row_k as f64 * cell_h;
                let orig_row = row_perm.get(row_k).copied().unwrap_or(row_k);
                let orig_col = col_perm.get(col_k).copied().unwrap_or(col_k);
                let row_lbl = row_labels_ord
                    .as_ref()
                    .and_then(|l| l.get(row_k))
                    .map(|s| s.as_str())
                    .unwrap_or("");
                let col_lbl = col_labels_ord
                    .as_ref()
                    .and_then(|l| l.get(col_k))
                    .map(|s| s.as_str())
                    .unwrap_or("");
                let tip = if cm.show_tooltips {
                    Some(format!(
                        "{}, {}: {:.2}",
                        row_lbl, col_lbl, cm.data[orig_row][orig_col]
                    ))
                } else {
                    None
                };
                if let Some(ref t) = tip {
                    scene.add(Primitive::GroupStart {
                        transform: None,
                        title: Some(t.clone()),
                        extra_attrs: None,
                    });
                }
                scene.add(Primitive::Rect {
                    x,
                    y,
                    width: cell_w * 0.99,
                    height: cell_h * 0.99,
                    fill: Color::from(cm.color_map.map(norm_val(value))),
                    stroke: None,
                    stroke_width: None,
                    opacity: None,
                });
                if tip.is_some() {
                    scene.add(Primitive::GroupEnd);
                }
            }
        }
    }

    if cm.show_values {
        for (row_k, row) in data.iter().enumerate() {
            for (col_k, &value) in row.iter().enumerate() {
                scene.add(Primitive::Text {
                    x: hm_x + (col_k as f64 + 0.5) * cell_w,
                    y: hm_y + (row_k as f64 + 0.5) * cell_h + computed.body_size as f64 * 0.35,
                    content: format!("{:.2}", value),
                    size: computed.body_size,
                    anchor: TextAnchor::Middle,
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }
        }
    }

    // ── Step 9: Axis labels ───────────────────────────────────────────────────
    let ts = computed.body_size;

    if let Some(ref labels) = row_labels_ord {
        for (k, label) in labels.iter().enumerate() {
            scene.add(Primitive::Text {
                x: hm_x + hm_w + 6.0,
                y: hm_y + (k as f64 + 0.5) * cell_h + ts as f64 * 0.35,
                content: label.clone(),
                size: ts,
                anchor: TextAnchor::Start,
                rotate: None,
                bold: false,
                color: None,
            });
        }
    }

    if let Some(ref labels) = col_labels_ord {
        for (k, label) in labels.iter().enumerate() {
            scene.add(Primitive::Text {
                x: hm_x + (k as f64 + 0.5) * cell_w,
                y: hm_y + hm_h + 6.0,
                content: label.clone(),
                size: ts,
                anchor: TextAnchor::End,
                rotate: Some(-45.0),
                bold: false,
                color: None,
            });
        }
    }
}

fn add_waterfall(waterfall: &WaterfallPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let mut running = 0.0_f64;
    let mut prev_connection_y: Option<f64> = None;
    let half = waterfall.bar_width / 2.0;

    for (i, bar) in waterfall.bars.iter().enumerate() {
        let x_center = (i + 1) as f64;
        let x0 = computed.map_x(x_center - half);
        let x1 = computed.map_x(x_center + half);

        let (base, top, color) = match bar.kind {
            WaterfallKind::Delta => {
                let base = running;
                running += bar.value;
                let color = if bar.value >= 0.0 {
                    waterfall.color_positive.clone()
                } else {
                    waterfall.color_negative.clone()
                };
                (base, running, color)
            }
            WaterfallKind::Total => (0.0, running, waterfall.color_total.clone()),
            WaterfallKind::Difference { from, to } => {
                let color = if to >= from {
                    waterfall.color_positive.clone()
                } else {
                    waterfall.color_negative.clone()
                };
                // Running total is intentionally unchanged.
                (from, to, color)
            }
        };

        // Connector: dashed horizontal line from previous bar's right edge to this bar's left edge
        if waterfall.show_connectors {
            if let Some(py) = prev_connection_y {
                let prev_x_right = computed.map_x(i as f64 + half);
                scene.add(Primitive::Line {
                    x1: prev_x_right,
                    y1: py,
                    x2: x0,
                    y2: py,
                    stroke: "gray".into(),
                    stroke_width: 1.0,
                    stroke_dasharray: Some("4,3".into()),
                });
            }
        }

        // Bar rect
        let y_screen_lo = computed.map_y(base.min(top));
        let y_screen_hi = computed.map_y(base.max(top));
        let bar_height = (y_screen_lo - y_screen_hi).abs();
        if bar_height > 0.0 {
            // Use top-base so Total bars show the running total, not the stored 0.0
            let tip = tooltip(
                waterfall.show_tooltips,
                &waterfall.tooltip_labels,
                i,
                || format!("{}: {:.2}", bar.label, top - base),
            );
            if let Some(ref t) = tip {
                scene.add(Primitive::GroupStart {
                    transform: None,
                    title: Some(t.clone()),
                    extra_attrs: None,
                });
            }
            scene.add(Primitive::Rect {
                x: x0,
                y: y_screen_hi,
                width: (x1 - x0).abs(),
                height: bar_height,
                fill: color.into(),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });
            if tip.is_some() {
                scene.add(Primitive::GroupEnd);
            }
        }

        // Value label
        if waterfall.show_values {
            let (display, label_y) = match bar.kind {
                WaterfallKind::Delta => {
                    let s = if bar.value >= 0.0 {
                        format!("+{:.2}", bar.value)
                    } else {
                        format!("{:.2}", bar.value)
                    };
                    let ly = if bar.value >= 0.0 {
                        y_screen_hi - 5.0
                    } else {
                        y_screen_lo + 15.0
                    };
                    (s, ly)
                }
                WaterfallKind::Total => {
                    let s = format!("{:.2}", running);
                    let ly = if running >= 0.0 {
                        y_screen_hi - 5.0
                    } else {
                        y_screen_lo + 15.0
                    };
                    (s, ly)
                }
                WaterfallKind::Difference { from, to } => {
                    let diff = to - from;
                    let s = if diff >= 0.0 {
                        format!("+{:.2}", diff)
                    } else {
                        format!("{:.2}", diff)
                    };
                    let ly = if diff >= 0.0 {
                        y_screen_hi - 5.0
                    } else {
                        y_screen_lo + 15.0
                    };
                    (s, ly)
                }
            };
            scene.add(Primitive::Text {
                x: (x0 + x1) / 2.0,
                y: label_y,
                content: display,
                size: computed.body_size,
                anchor: TextAnchor::Middle,
                rotate: None,
                bold: false,
                color: None,
            });
        }

        // For Difference bars the running total didn't change, so the next
        // connector should come from the `to` edge of this bar.
        prev_connection_y = Some(match bar.kind {
            WaterfallKind::Difference { to, .. } => computed.map_y(to),
            _ => computed.map_y(running),
        });
    }
}

fn render_legend_entry(
    entry: &LegendEntry,
    scene: &mut Scene,
    legend_x: f64,
    cur_y: f64,
    computed: &ComputedLayout,
) {
    // Swatch center: rect top is cur_y - 1, height swatch_size → center at cur_y + swatch_size/2 - 1.
    // Text baseline must be placed so the cap midpoint (baseline - body_size * 0.35)
    // lands at swatch_cy.  All other swatches center on the same point.
    let swatch_cy = cur_y + computed.legend_swatch_size / 2.0 - 1.0;
    let text_baseline = swatch_cy + computed.body_size as f64 * 0.35;
    scene.add(Primitive::Text {
        x: legend_x + computed.legend_text_x,
        y: text_baseline,
        content: entry.label.clone(),
        anchor: TextAnchor::Start,
        size: computed.body_size,
        rotate: None,
        bold: false,
        color: None,
    });
    match entry.shape {
        LegendShape::Rect => scene.add(Primitive::Rect {
            x: legend_x + computed.legend_swatch_x,
            y: cur_y - computed.axis_stroke_width,
            width: computed.legend_swatch_size,
            height: computed.legend_swatch_size,
            fill: Color::from(&entry.color),
            stroke: None,
            stroke_width: None,
            opacity: None,
        }),
        LegendShape::Line => scene.add(Primitive::Line {
            x1: legend_x + computed.legend_swatch_x,
            y1: swatch_cy,
            x2: legend_x + computed.legend_swatch_x + computed.legend_swatch_size,
            y2: swatch_cy,
            stroke: Color::from(&entry.color),
            stroke_width: computed.axis_stroke_width * 2.0,
            stroke_dasharray: entry.dasharray.clone(),
        }),
        LegendShape::Circle => scene.add(Primitive::Circle {
            cx: legend_x + computed.legend_swatch_x + computed.legend_swatch_r,
            cy: swatch_cy,
            r: computed.legend_swatch_r,
            fill: Color::from(&entry.color),
            fill_opacity: None,
            stroke: None,
            stroke_width: None,
        }),
        LegendShape::Marker(marker) => {
            draw_marker(
                scene,
                marker,
                legend_x + computed.legend_swatch_x + computed.legend_swatch_r,
                swatch_cy,
                computed.legend_swatch_r,
                &entry.color,
                None,
                None,
                None,
            );
        }
        LegendShape::CircleSize(r) => {
            let draw_r = r.min(computed.legend_swatch_half);
            scene.add(Primitive::Circle {
                cx: legend_x + computed.legend_swatch_x + computed.legend_swatch_r,
                cy: swatch_cy,
                r: draw_r,
                fill: Color::from(&entry.color),
                fill_opacity: None,
                stroke: None,
                stroke_width: None,
            });
        }
    }
}

/// Draw the stats box (pre-formatted text lines) at the position specified in `layout`.
///
/// Returns `Some((x, y, height))` of the rendered box, or `None` when no entries
/// are set (so the caller can detect whether space was consumed).
fn add_stats_box(
    layout: &Layout,
    scene: &mut Scene,
    computed: &ComputedLayout,
) -> Option<(f64, f64, f64)> {
    if layout.stats_entries.is_empty() && layout.stats_title.is_none() {
        return None;
    }

    let theme = &computed.theme;
    let line_height = computed.legend_line_height;
    let padding = computed.legend_padding;
    let body_size = computed.body_size;
    let s = computed.axis_stroke_width; // scale factor

    // Compute box dimensions from content
    let title_rows = if layout.stats_title.is_some() { 1 } else { 0 };
    let n_rows = title_rows + layout.stats_entries.len();
    let box_height = n_rows as f64 * line_height + padding * 2.0;

    let max_chars = layout
        .stats_entries
        .iter()
        .map(|e| e.len())
        .chain(layout.stats_title.iter().map(|t| t.len()))
        .max()
        .unwrap_or(8) as f64;
    let box_width = (max_chars * body_size as f64 * 0.6 + 20.0 * s).max(80.0 * s);

    let plot_left = computed.margin_left;
    let plot_right = computed.width - computed.margin_right;
    let plot_top = computed.margin_top;
    let plot_bottom = computed.height - computed.margin_bottom;
    let plot_cx = (plot_left + plot_right) / 2.0;
    let right_x = computed.width - computed.margin_right + computed.y2_axis_width + 10.0;
    let left_x = padding; // anchor at left canvas edge within reserved OutsideLeft margin
    let inset = computed.legend_inset;

    let (box_x, box_y) = match computed.stats_position {
        LegendPosition::InsideTopRight => (
            plot_right - inset - box_width + 5.0,
            plot_top + inset + padding,
        ),
        LegendPosition::InsideTopLeft => (
            plot_left + inset + computed.tick_label_margin + 5.0,
            plot_top + inset + padding,
        ),
        LegendPosition::InsideBottomRight => (
            plot_right - inset - box_width + 5.0,
            plot_bottom - inset - box_height + padding,
        ),
        LegendPosition::InsideBottomLeft => (
            plot_left + inset + computed.tick_label_margin + 5.0,
            plot_bottom - inset - box_height + padding,
        ),
        LegendPosition::InsideTopCenter => {
            (plot_cx - box_width / 2.0 + 5.0, plot_top + inset + padding)
        }
        LegendPosition::InsideBottomCenter => (
            plot_cx - box_width / 2.0 + 5.0,
            plot_bottom - inset - box_height + padding,
        ),
        LegendPosition::OutsideRightTop => (right_x, plot_top),
        LegendPosition::OutsideRightMiddle => {
            (right_x, (plot_top + plot_bottom) / 2.0 - box_height / 2.0)
        }
        LegendPosition::OutsideRightBottom => (right_x, plot_bottom - box_height),
        LegendPosition::OutsideLeftTop => (left_x, plot_top),
        LegendPosition::OutsideLeftMiddle => {
            (left_x, (plot_top + plot_bottom) / 2.0 - box_height / 2.0)
        }
        LegendPosition::OutsideLeftBottom => (left_x, plot_bottom - box_height),
        LegendPosition::OutsideTopLeft => (plot_left, padding + 10.0),
        LegendPosition::OutsideTopCenter => (plot_cx - box_width / 2.0, padding + 10.0),
        LegendPosition::OutsideTopRight => (plot_right - box_width, padding + 10.0),
        LegendPosition::OutsideBottomLeft => (
            plot_left,
            computed.height - computed.margin_bottom + padding + 10.0,
        ),
        LegendPosition::OutsideBottomCenter => (
            plot_cx - box_width / 2.0,
            computed.height - computed.margin_bottom + padding + 10.0,
        ),
        LegendPosition::OutsideBottomRight => (
            plot_right - box_width,
            computed.height - computed.margin_bottom + padding + 10.0,
        ),
        LegendPosition::OutsideBottomColumns => (
            plot_left,
            computed.height - computed.margin_bottom + padding + 10.0,
        ),
        LegendPosition::Custom(x, y) => (x, y),
        LegendPosition::DataCoords(x, y) => (computed.map_x(x), computed.map_y(y)),
    };

    if layout.stats_box {
        // Background
        scene.add(Primitive::Rect {
            x: box_x - padding + 5.0,
            y: box_y - padding,
            width: box_width,
            height: box_height,
            fill: Color::from(&theme.legend_bg),
            stroke: None,
            stroke_width: None,
            opacity: None,
        });
        // Border
        scene.add(Primitive::Rect {
            x: box_x - padding + 5.0,
            y: box_y - padding,
            width: box_width,
            height: box_height,
            fill: "none".into(),
            stroke: Some(Color::from(&theme.legend_border)),
            stroke_width: Some(computed.axis_stroke_width),
            opacity: None,
        });
    }

    let mut cur_y = box_y;

    if let Some(ref title) = layout.stats_title {
        scene.add(Primitive::Text {
            x: box_x + box_width / 2.0,
            y: cur_y + 5.0,
            content: title.clone(),
            anchor: TextAnchor::Middle,
            size: body_size,
            rotate: None,
            bold: true,
            color: None,
        });
        cur_y += line_height;
    }

    for entry in &layout.stats_entries {
        scene.add(Primitive::Text {
            x: box_x + 5.0 * s,
            y: cur_y + body_size as f64 * 0.8,
            content: entry.clone(),
            anchor: TextAnchor::Start,
            size: body_size,
            rotate: None,
            bold: false,
            color: None,
        });
        cur_y += line_height;
    }

    Some((box_x, box_y, box_height))
}

/// Like [`add_legend`] but places the legend box at an explicit y coordinate
/// for stacking multiple legend sections vertically (used by DicePlot).
/// Note: `legend_wrap` is not applied here — DicePlot labels are short categorical values.
fn add_legend_at(legend: &Legend, scene: &mut Scene, computed: &ComputedLayout, y_start: f64) {
    let theme = &computed.theme;
    let legend_width = computed.legend_width;
    let legend_padding = computed.legend_padding;
    let line_height = computed.legend_line_height;
    let legend_x = computed.width - computed.margin_right + computed.y2_axis_width + 10.0;
    let legend_y = y_start;

    // Cap entries to fit within the canvas height (minimum 10 shown before capping).
    let avail_height = (computed.height - legend_y - legend_padding * 2.0).max(line_height);
    let max_entries = ((avail_height / line_height).floor() as usize).max(10);
    let n_total = legend.entries.len();
    let overflow = if n_total > max_entries {
        n_total - max_entries.saturating_sub(1)
    } else {
        0
    };
    let entries_to_show = if overflow > 0 {
        max_entries.saturating_sub(1)
    } else {
        n_total
    };

    let legend_height = (entries_to_show + if overflow > 0 { 1 } else { 0 }) as f64 * line_height
        + legend_padding * 2.0;
    // Widen box if the "+N more" text would overflow it (text starts 18px from legend_x).
    let overflow_label = if overflow > 0 {
        format!("… (+{overflow} more)")
    } else {
        String::new()
    };
    let box_width = if overflow > 0 {
        let min_w = overflow_label.chars().count() as f64 * 7.5 + 18.0 + legend_padding;
        legend_width.max(min_w)
    } else {
        legend_width
    };

    if legend.show_box {
        scene.add(Primitive::Rect {
            x: legend_x - legend_padding + 5.0,
            y: legend_y - legend_padding,
            width: box_width,
            height: legend_height,
            fill: Color::from(&theme.legend_bg),
            stroke: None,
            stroke_width: None,
            opacity: None,
        });
        scene.add(Primitive::Rect {
            x: legend_x - legend_padding + 5.0,
            y: legend_y - legend_padding,
            width: box_width,
            height: legend_height,
            fill: "none".into(),
            stroke: Some(Color::from(&theme.legend_border)),
            stroke_width: Some(1.0),
            opacity: None,
        });
    }

    let mut cur_y = legend_y;
    for entry in legend.entries.iter().take(entries_to_show) {
        let swatch_x = legend_x;
        let swatch_y = cur_y;
        match entry.shape {
            LegendShape::Circle | LegendShape::CircleSize(_) => {
                let r = if let LegendShape::CircleSize(r) = entry.shape {
                    r
                } else {
                    5.0
                };
                scene.add(Primitive::Circle {
                    cx: swatch_x + 5.0,
                    cy: swatch_y + line_height / 2.0 - 2.0,
                    r,
                    fill: entry.color.clone().into(),
                    fill_opacity: None,
                    stroke: None,
                    stroke_width: None,
                });
            }
            _ => {
                scene.add(Primitive::Rect {
                    x: swatch_x,
                    y: swatch_y,
                    width: 12.0,
                    height: 12.0,
                    fill: entry.color.clone().into(),
                    stroke: None,
                    stroke_width: None,
                    opacity: None,
                });
            }
        }
        scene.add(Primitive::Text {
            x: swatch_x + 18.0,
            y: swatch_y + computed.body_size as f64 * 0.8,
            content: entry.label.clone(),
            size: computed.body_size,
            anchor: TextAnchor::Start,
            rotate: None,
            bold: false,
            color: None,
        });
        cur_y += line_height;
    }
    if overflow > 0 {
        scene.add(Primitive::Text {
            x: legend_x + 18.0,
            y: cur_y + computed.body_size as f64 * 0.8,
            content: format!("… (+{overflow} more)"),
            size: computed.body_size,
            anchor: TextAnchor::Start,
            rotate: None,
            bold: false,
            color: None,
        });
    }
}

fn add_legend_with_offset(
    legend: &Legend,
    scene: &mut Scene,
    computed: &ComputedLayout,
    y_offset: f64,
) {
    // Multi-column bottom layout: flow all entries into columns, no capping.
    if matches!(
        computed.legend_position,
        LegendPosition::OutsideBottomColumns
    ) {
        let n_cols = computed.legend_col_count.max(1);
        let line_height = computed.legend_line_height;
        let legend_padding = computed.legend_padding;
        let plot_left = computed.margin_left;
        let plot_right = computed.width - computed.margin_right;
        // Legend sits in the reserved band below the x-axis content.
        // The x-axis labels end at (height - legend_bottom_extra); add a 5px gap.
        let n_entries = legend.entries.len().max(1);
        let n_rows = n_entries.div_ceil(n_cols);
        let legend_y = computed.height - computed.legend_bottom_extra + 5.0;
        let avail_w = plot_right - plot_left;
        let col_w = avail_w / n_cols as f64;
        let theme = &computed.theme;

        if legend.show_box {
            let box_h = n_rows as f64 * line_height + legend_padding * 2.0;
            scene.add(Primitive::Rect {
                x: plot_left - legend_padding + 5.0,
                y: legend_y - legend_padding,
                width: avail_w + legend_padding,
                height: box_h,
                fill: Color::from(&theme.legend_bg),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });
            scene.add(Primitive::Rect {
                x: plot_left - legend_padding + 5.0,
                y: legend_y - legend_padding,
                width: avail_w + legend_padding,
                height: box_h,
                fill: "none".into(),
                stroke: Some(Color::from(&theme.legend_border)),
                stroke_width: Some(computed.axis_stroke_width),
                opacity: None,
            });
        }

        for (i, entry) in legend.entries.iter().enumerate() {
            let col = i % n_cols;
            let row = i / n_cols;
            let ex = plot_left + col as f64 * col_w;
            let ey = legend_y + row as f64 * line_height;
            render_legend_entry(entry, scene, ex, ey, computed);
        }
        return;
    }

    let theme = &computed.theme;

    let legend_width = computed.legend_width;
    let legend_padding = computed.legend_padding;
    let line_height = computed.legend_line_height;

    // Height depends on groups (each group adds a title row) + optional top title.
    // Between consecutive groups an extra half line-height gap is added so the
    // visual separation between groups is larger than between a title and its members.
    let n_groups = legend.groups.as_ref().map_or(0, |g| g.len());
    let group_gap = line_height * 0.5;
    let wrap_lines = |text: &str| -> usize {
        if let Some(max_chars) = computed.legend_wrap {
            render_utils::wrap_text(text, max_chars).len()
        } else {
            1
        }
    };
    let entry_rows = if let Some(ref groups) = legend.groups {
        groups
            .iter()
            .map(|g| {
                let title_lines = wrap_lines(&g.title);
                let entry_lines: usize = g.entries.iter().map(|e| wrap_lines(&e.label)).sum();
                title_lines + entry_lines
            })
            .sum::<usize>()
    } else {
        legend
            .entries
            .iter()
            .map(|e| wrap_lines(&e.label))
            .sum::<usize>()
    };
    let title_rows = if let Some(ref t) = legend.title {
        wrap_lines(t)
    } else {
        0
    };
    let inter_group_extra = if n_groups > 1 {
        (n_groups - 1) as f64 * group_gap
    } else {
        0.0
    };
    let computed_height =
        (entry_rows + title_rows) as f64 * line_height + inter_group_extra + legend_padding * 2.0;
    let legend_height = computed.legend_height_override.unwrap_or(computed_height);

    let plot_left = computed.margin_left;
    let plot_right = computed.width - computed.margin_right;
    let plot_top = computed.margin_top;
    let plot_bottom = computed.height - computed.margin_bottom;
    let plot_cx = (plot_left + plot_right) / 2.0;
    let right_x = computed.width - computed.margin_right + computed.y2_axis_width + 10.0;
    // OutsideLeft: anchor near the canvas left edge within the reserved margin.
    // margin_left was grown by effective_legend_width, so the y-axis content starts
    // at plot_left. The legend sits in [0, effective_legend_width]; legend_padding inset
    // from the canvas edge keeps it from touching the border.
    let left_x = legend_padding;
    let inset = computed.legend_inset;

    let (legend_x, legend_y) = match computed.legend_position {
        // Inside (overlay, inset from axes).
        // Box edges: top = legend_y - legend_padding, left = legend_x - 5,
        //            right = legend_x - 5 + legend_width.
        // We want each box edge to be exactly `inset` from the plot boundary, so:
        //   legend_y = plot_edge + inset + legend_padding  (top-aligned)
        //   legend_y = plot_edge - inset - legend_height + legend_padding  (bottom-aligned)
        //   legend_x = plot_edge + inset + 5  (left-aligned)
        //   legend_x = plot_edge - inset - legend_width + 5  (right-aligned)
        //
        // For left-anchored variants, add tick_label_margin so the legend clears the y-axis
        // tick labels even when the 0.6-ratio heuristic underestimates their rendered width.
        LegendPosition::InsideTopRight => (
            plot_right - inset - legend_width + 5.0,
            plot_top + inset + legend_padding,
        ),
        LegendPosition::InsideTopLeft => (
            plot_left + inset + computed.tick_label_margin + 5.0,
            plot_top + inset + legend_padding,
        ),
        LegendPosition::InsideBottomRight => (
            plot_right - inset - legend_width + 5.0,
            plot_bottom - inset - legend_height + legend_padding,
        ),
        LegendPosition::InsideBottomLeft => (
            plot_left + inset + computed.tick_label_margin + 5.0,
            plot_bottom - inset - legend_height + legend_padding,
        ),
        LegendPosition::InsideTopCenter => (
            plot_cx - legend_width / 2.0 + 5.0,
            plot_top + inset + legend_padding,
        ),
        LegendPosition::InsideBottomCenter => (
            plot_cx - legend_width / 2.0 + 5.0,
            plot_bottom - inset - legend_height + legend_padding,
        ),
        // Outside Right
        LegendPosition::OutsideRightTop => (right_x, plot_top),
        LegendPosition::OutsideRightMiddle => (
            right_x,
            (plot_top + plot_bottom) / 2.0 - legend_height / 2.0,
        ),
        LegendPosition::OutsideRightBottom => (right_x, plot_bottom - legend_height),
        // Outside Left
        LegendPosition::OutsideLeftTop => (left_x, plot_top),
        LegendPosition::OutsideLeftMiddle => {
            (left_x, (plot_top + plot_bottom) / 2.0 - legend_height / 2.0)
        }
        LegendPosition::OutsideLeftBottom => (left_x, plot_bottom - legend_height),
        // Outside Top — legend_y = legend_padding + 10 so box top = 10px from canvas top edge.
        LegendPosition::OutsideTopLeft => (plot_left, legend_padding + 10.0),
        LegendPosition::OutsideTopCenter => (plot_cx - legend_width / 2.0, legend_padding + 10.0),
        LegendPosition::OutsideTopRight => (plot_right - legend_width, legend_padding + 10.0),
        // Outside Bottom — anchor legend box to canvas bottom, above the axis content.
        LegendPosition::OutsideBottomLeft => {
            (plot_left, computed.height - legend_height + legend_padding)
        }
        LegendPosition::OutsideBottomCenter => (
            plot_cx - legend_width / 2.0,
            computed.height - legend_height + legend_padding,
        ),
        LegendPosition::OutsideBottomRight => (
            plot_right - legend_width,
            computed.height - legend_height + legend_padding,
        ),
        // OutsideBottomColumns: handled by early return above; unreachable here.
        LegendPosition::OutsideBottomColumns => {
            (plot_left, computed.height - legend_height + legend_padding)
        }
        // Custom — absolute canvas pixel coordinates
        LegendPosition::Custom(x, y) => (x, y),
        // DataCoords — mapped through ComputedLayout
        LegendPosition::DataCoords(x, y) => (computed.map_x(x), computed.map_y(y)),
    };
    let legend_y = legend_y + y_offset;

    // Cap entries to fit within the canvas height (minimum 10 shown before capping).
    let avail_height_entries = (computed.height - legend_y - legend_padding * 2.0).max(line_height);
    let max_entries_display = ((avail_height_entries / line_height).floor() as usize).max(10);

    // Pre-compute overflow for flat entries so box width can be widened to fit "… (+N more)".
    let flat_overflow = if legend.groups.is_none() {
        let n = legend.entries.len();
        if n > max_entries_display {
            n - max_entries_display.saturating_sub(1)
        } else {
            0
        }
    } else {
        0
    };
    let box_width = if flat_overflow > 0 {
        let overflow_text = format!("… (+{flat_overflow} more)");
        let min_w =
            overflow_text.chars().count() as f64 * 7.5 + computed.legend_text_x + legend_padding;
        legend_width.max(min_w)
    } else {
        legend_width
    };

    // If entries are capped, shrink the bounding box height to match what's actually rendered.
    let legend_height = if flat_overflow > 0 && computed.legend_height_override.is_none() {
        (title_rows + max_entries_display) as f64 * line_height + legend_padding * 2.0
    } else {
        legend_height
    };

    if legend.show_box {
        scene.add(Primitive::Rect {
            x: legend_x - legend_padding + 5.0,
            y: legend_y - legend_padding,
            width: box_width,
            height: legend_height,
            fill: Color::from(&theme.legend_bg),
            stroke: None,
            stroke_width: None,
            opacity: None,
        });
        scene.add(Primitive::Rect {
            x: legend_x - legend_padding + 5.0,
            y: legend_y - legend_padding,
            width: box_width,
            height: legend_height,
            fill: "none".into(),
            stroke: Some(Color::from(&theme.legend_border)),
            stroke_width: Some(computed.axis_stroke_width),
            opacity: None,
        });
    }

    let mut cur_y = legend_y;
    let wrap_max = computed.legend_wrap;
    let text_baseline_offset =
        computed.legend_swatch_size / 2.0 - 1.0 + computed.body_size as f64 * 0.35;

    // Optional top title
    if let Some(ref title) = legend.title {
        let lines = render_utils::wrap_or_single(title, wrap_max);
        for line in &lines {
            scene.add(Primitive::Text {
                x: legend_x + legend_width / 2.0,
                y: cur_y + 5.0,
                content: line.clone(),
                anchor: TextAnchor::Middle,
                size: computed.body_size,
                rotate: None,
                bold: true,
                color: None,
            });
            cur_y += line_height;
        }
    }

    // Render a single legend entry with optional label wrapping.
    let render_entry = |entry: &LegendEntry, scene: &mut Scene, cur_y: &mut f64| {
        if computed.interactive {
            let grp_attr = format!(
                r#"class="legend-entry" data-group="{lbl}""#,
                lbl = entry.label
            );
            scene.add(Primitive::GroupStart {
                transform: None,
                title: None,
                extra_attrs: Some(grp_attr),
            });
        }
        let lines = render_utils::wrap_or_single(&entry.label, wrap_max);
        let mut first = entry.clone();
        first.label = lines[0].clone();
        render_legend_entry(&first, scene, legend_x, *cur_y, computed);
        *cur_y += line_height;
        for line in &lines[1..] {
            scene.add(Primitive::Text {
                x: legend_x + computed.legend_text_x,
                y: *cur_y + text_baseline_offset,
                content: line.clone(),
                anchor: TextAnchor::Start,
                size: computed.body_size,
                rotate: None,
                bold: false,
                color: None,
            });
            *cur_y += line_height;
        }
        if computed.interactive {
            scene.add(Primitive::GroupEnd);
        }
    };

    // max_entries_display was already computed above after legend_y was resolved.

    if let Some(ref groups) = legend.groups {
        for (i, group) in groups.iter().enumerate() {
            if i > 0 {
                cur_y += group_gap;
            }
            // Group title (bold, start-anchored)
            let title_lines = render_utils::wrap_or_single(&group.title, wrap_max);
            for line in &title_lines {
                scene.add(Primitive::Text {
                    x: legend_x + 5.0,
                    y: cur_y + 5.0,
                    content: line.clone(),
                    anchor: TextAnchor::Start,
                    size: computed.body_size,
                    rotate: None,
                    bold: true,
                    color: None,
                });
                cur_y += line_height;
            }
            for entry in &group.entries {
                render_entry(entry, scene, &mut cur_y);
            }
        }
    } else {
        let overflow = flat_overflow;
        let entries_to_show = if overflow > 0 {
            max_entries_display.saturating_sub(1)
        } else {
            legend.entries.len()
        };
        for entry in legend.entries.iter().take(entries_to_show) {
            render_entry(entry, scene, &mut cur_y);
        }
        if overflow > 0 {
            scene.add(Primitive::Text {
                x: legend_x + computed.legend_text_x,
                y: cur_y + text_baseline_offset,
                content: format!("… (+{overflow} more)"),
                anchor: TextAnchor::Start,
                size: computed.body_size,
                rotate: None,
                bold: false,
                color: None,
            });
        }
    }
}

fn add_colorbar_at(
    info: &ColorBarInfo,
    scene: &mut Scene,
    computed: &ComputedLayout,
    bar_x: f64,
    bar_y: f64,
    bar_height: f64,
) {
    let theme = &computed.theme;
    let bar_width = computed.colorbar_bar_width;

    let num_slices = 50;
    let slice_height = bar_height / num_slices as f64;

    // Draw stacked rects (top = high value, bottom = low value)
    for i in 0..num_slices {
        let t = 1.0 - (i as f64 / (num_slices - 1) as f64); // top is high
        let value = info.min_value + t * (info.max_value - info.min_value);
        let color = (info.map_fn)(value);
        let y = bar_y + i as f64 * slice_height;

        scene.add(Primitive::Rect {
            x: bar_x,
            y,
            width: bar_width,
            height: slice_height + 0.5, // slight overlap to prevent gaps
            fill: color.into(),
            stroke: None,
            stroke_width: None,
            opacity: None,
        });
    }

    // Border around the bar
    scene.add(Primitive::Rect {
        x: bar_x,
        y: bar_y,
        width: bar_width,
        height: bar_height,
        fill: "none".into(),
        stroke: Some(Color::from(&theme.colorbar_border)),
        stroke_width: Some(computed.axis_stroke_width),
        opacity: None,
    });

    // Tick marks and labels — use custom tick_labels if provided, else auto-generate
    let range = info.max_value - info.min_value;
    let auto_ticks: Vec<(f64, String)>;
    let tick_entries: &[(f64, String)] = if let Some(ref tl) = info.tick_labels {
        tl.as_slice()
    } else {
        let raw = render_utils::generate_ticks(info.min_value, info.max_value, 5);
        auto_ticks = raw
            .into_iter()
            .filter(|t| *t >= info.min_value && *t <= info.max_value)
            .map(|t| (t, computed.colorbar_tick_format.format(t)))
            .collect();
        auto_ticks.as_slice()
    };
    for (pos, label) in tick_entries {
        if *pos < info.min_value || *pos > info.max_value {
            continue;
        }
        let frac = (pos - info.min_value) / range;
        let y = bar_y + bar_height - frac * bar_height; // invert: high values at top

        // tick mark
        scene.add(Primitive::Line {
            x1: bar_x + bar_width,
            y1: y,
            x2: bar_x + bar_width + computed.tick_mark_major * 0.8,
            y2: y,
            stroke: Color::from(&theme.colorbar_border),
            stroke_width: computed.axis_stroke_width,
            stroke_dasharray: None,
        });

        // tick label
        scene.add(Primitive::Text {
            x: bar_x + bar_width + computed.tick_mark_major,
            y: y + 4.0,
            content: label.clone(),
            size: computed.tick_size,
            anchor: TextAnchor::Start,
            rotate: None,
            bold: false,
            color: None,
        });
    }

    // Label rotated -90° to the left of the bar (reads bottom-to-top).
    // Left placement keeps it away from tick labels and avoids right-edge clipping.
    if let Some(ref label) = info.label {
        let label_x = bar_x - computed.tick_size as f64 * 0.5 - 4.0;
        let label_y = bar_y + bar_height / 2.0;
        scene.add(Primitive::Text {
            x: label_x,
            y: label_y,
            content: label.clone(),
            size: computed.tick_size,
            anchor: TextAnchor::Middle,
            rotate: Some(-90.0),
            bold: false,
            color: None,
        });
    }
}

fn add_colorbar(info: &ColorBarInfo, scene: &mut Scene, computed: &ComputedLayout) {
    let bar_x = computed.width - computed.colorbar_x_inset;
    let bar_y = computed.margin_top + computed.plot_height() * 0.1;
    let bar_height = computed.plot_height() * 0.8;
    add_colorbar_at(info, scene, computed, bar_x, bar_y, bar_height);
}

fn add_volcano(vp: &VolcanoPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let floor = vp.floor();

    // Draw threshold lines (behind points)
    let threshold_color = "#888888";
    let plot_left = computed.margin_left;
    let plot_right = computed.width - computed.margin_right;
    let plot_top = computed.margin_top;
    let plot_bottom = computed.height - computed.margin_bottom;

    // Horizontal significance line at -log10(p_cutoff)
    let y_sig = -vp.p_cutoff.log10();
    if y_sig >= computed.y_range.0 && y_sig <= computed.y_range.1 {
        let sy = computed.map_y(y_sig);
        if computed.interactive {
            scene.add(Primitive::GroupStart {
                transform: None,
                title: None,
                extra_attrs: Some(r#"class="kuva-threshold""#.to_string()),
            });
        }
        scene.add(Primitive::Line {
            x1: plot_left,
            y1: sy,
            x2: plot_right,
            y2: sy,
            stroke: threshold_color.into(),
            stroke_width: 1.0,
            stroke_dasharray: Some("4 4".into()),
        });
        if computed.interactive {
            scene.add(Primitive::GroupEnd);
        }
    }

    // Vertical fc cutoff lines at ±fc_cutoff
    for &fc_val in &[-vp.fc_cutoff, vp.fc_cutoff] {
        if fc_val >= computed.x_range.0 && fc_val <= computed.x_range.1 {
            let sx = computed.map_x(fc_val);
            if computed.interactive {
                scene.add(Primitive::GroupStart {
                    transform: None,
                    title: None,
                    extra_attrs: Some(r#"class="kuva-threshold""#.to_string()),
                });
            }
            scene.add(Primitive::Line {
                x1: sx,
                y1: plot_top,
                x2: sx,
                y2: plot_bottom,
                stroke: threshold_color.into(),
                stroke_width: 1.0,
                stroke_dasharray: Some("4 4".into()),
            });
            if computed.interactive {
                scene.add(Primitive::GroupEnd);
            }
        }
    }

    // Draw points: NS first, then Down, then Up
    for pass in 0..3u8 {
        for (pi, p) in vp.points.iter().enumerate() {
            let is_up = p.log2fc >= vp.fc_cutoff && p.pvalue <= vp.p_cutoff;
            let is_down = p.log2fc <= -vp.fc_cutoff && p.pvalue <= vp.p_cutoff;
            let color = match (pass, is_up, is_down) {
                (0, false, false) => &vp.color_ns,
                (1, false, true) => &vp.color_down,
                (2, true, false) => &vp.color_up,
                _ => continue,
            };
            let y_val = -(p.pvalue.max(floor)).log10();
            let cx = computed.map_x(p.log2fc);
            let cy = computed.map_y(y_val);
            let tip = tooltip(
                vp.show_tooltips || computed.interactive,
                &vp.tooltip_labels,
                pi,
                || format!("{}\nlog2FC={:.2}\np={:.2e}", p.name, p.log2fc, p.pvalue),
            );
            let volcano_extra = if computed.interactive {
                let group = match pass {
                    0 => "NS",
                    1 => "Down",
                    _ => "Up",
                };
                Some(format!(
                    "class=\"tt\" data-logfc=\"{lfc}\" data-pvalue=\"{pv}\" data-group=\"{group}\"",
                    lfc = p.log2fc,
                    pv = p.pvalue
                ))
            } else {
                None
            };
            if tip.is_some() || volcano_extra.is_some() {
                scene.add(Primitive::GroupStart {
                    transform: None,
                    title: tip.clone(),
                    extra_attrs: volcano_extra,
                });
            }
            scene.add(Primitive::Circle {
                cx,
                cy,
                r: vp.point_size,
                fill: Color::from(color.as_str()),
                fill_opacity: None,
                stroke: None,
                stroke_width: None,
            });
            if tip.is_some() || computed.interactive {
                scene.add(Primitive::GroupEnd);
            }
        }
    }

    // Draw labels if label_top > 0
    if vp.label_top == 0 {
        return;
    }

    // Collect significant points, sort by pvalue ascending, take top N
    let mut sig_points: Vec<(f64, f64, &str)> = vp
        .points
        .iter()
        .filter(|p| p.pvalue <= vp.p_cutoff)
        .map(|p| {
            let y_val = -(p.pvalue.max(floor)).log10();
            (
                computed.map_x(p.log2fc),
                computed.map_y(y_val),
                p.name.as_str(),
            )
        })
        .collect();
    // Sort by pvalue ascending = highest -log10(p) = smallest cy
    sig_points.sort_by(|a, b| a.1.total_cmp(&b.1));
    sig_points.truncate(vp.label_top);

    match vp.label_style {
        LabelStyle::Exact => {
            for (cx, cy, name) in &sig_points {
                scene.add(Primitive::Text {
                    x: *cx,
                    y: cy - vp.point_size - 2.0,
                    content: name.to_string(),
                    size: computed.body_size,
                    anchor: TextAnchor::Middle,
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }
        }
        LabelStyle::Nudge => {
            // Build label positions: initially just above each point
            let mut labels: Vec<(f64, f64, String)> = sig_points
                .iter()
                .map(|(cx, cy, name)| (*cx, cy - vp.point_size - 2.0, name.to_string()))
                .collect();

            // Sort by cx (x screen position, left to right)
            labels.sort_by(|a, b| a.0.total_cmp(&b.0));

            // Greedy vertical nudge: push y up when adjacent labels are too close
            let min_gap = computed.body_size as f64 + 2.0;
            for j in 1..labels.len() {
                let prev_y = labels[j - 1].1;
                let curr_y = labels[j].1;
                if (prev_y - curr_y).abs() < min_gap {
                    labels[j].1 = prev_y - min_gap;
                }
            }

            for (cx, label_y, name) in &labels {
                scene.add(Primitive::Text {
                    x: *cx,
                    y: *label_y,
                    content: name.clone(),
                    size: computed.body_size,
                    anchor: TextAnchor::Middle,
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }
        }
        LabelStyle::Arrow { offset_x, offset_y } => {
            for (cx, cy, name) in &sig_points {
                let text_x = cx + offset_x;
                let text_y = cy - offset_y;

                // Leader line from text toward point, stopping short
                let dx = cx - text_x;
                let dy = cy - text_y;
                let len = (dx * dx + dy * dy).sqrt();
                if len > vp.point_size + 3.0 {
                    let scale = (len - vp.point_size - 3.0) / len;
                    let end_x = text_x + dx * scale;
                    let end_y = text_y + dy * scale;
                    scene.add(Primitive::Line {
                        x1: text_x,
                        y1: text_y,
                        x2: end_x,
                        y2: end_y,
                        stroke: "#666666".into(),
                        stroke_width: 0.8,
                        stroke_dasharray: None,
                    });
                }

                let anchor = if offset_x >= 0.0 {
                    TextAnchor::Start
                } else {
                    TextAnchor::End
                };
                scene.add(Primitive::Text {
                    x: text_x,
                    y: text_y,
                    content: name.to_string(),
                    size: computed.body_size,
                    anchor,
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }
        }
    }
}

fn add_manhattan(mp: &ManhattanPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let floor = mp.floor();
    let plot_left = computed.margin_left;
    let plot_right = computed.width - computed.margin_right;
    let plot_top = computed.margin_top;
    let plot_bottom = computed.height - computed.margin_bottom;

    // 1. Threshold lines (genome-wide and suggestive)
    let gw_y = mp.genome_wide;
    if gw_y >= computed.y_range.0 && gw_y <= computed.y_range.1 {
        let sy = computed.map_y(gw_y);
        scene.add(Primitive::Line {
            x1: plot_left,
            y1: sy,
            x2: plot_right,
            y2: sy,
            stroke: "#cc3333".into(),
            stroke_width: 1.0,
            stroke_dasharray: Some("4 4".into()),
        });
    }
    let sg_y = mp.suggestive;
    if sg_y >= computed.y_range.0 && sg_y <= computed.y_range.1 {
        let sy = computed.map_y(sg_y);
        scene.add(Primitive::Line {
            x1: plot_left,
            y1: sy,
            x2: plot_right,
            y2: sy,
            stroke: "#888888".into(),
            stroke_width: 1.0,
            stroke_dasharray: Some("4 4".into()),
        });
    }

    // 2. Chromosome divider lines (faint verticals between chromosomes)
    for span in mp.spans.iter().skip(1) {
        let sx = computed.map_x(span.x_start);
        if sx >= plot_left && sx <= plot_right {
            scene.add(Primitive::Line {
                x1: sx,
                y1: plot_top,
                x2: sx,
                y2: plot_bottom,
                stroke: Color::from(&computed.theme.grid_color),
                stroke_width: 0.5,
                stroke_dasharray: None,
            });
        }
    }

    // 3. Draw points, one chromosome at a time for correct coloring.
    // Points are clamped to their chromosome's pixel band so they don't bleed over dividers.
    // Pre-bucket points by chromosome so each span lookup is O(1) instead of O(n).
    let mut by_chr: HashMap<&str, Vec<usize>> = HashMap::new();
    for (idx, p) in mp.points.iter().enumerate() {
        by_chr.entry(p.chromosome.as_str()).or_default().push(idx);
    }
    for (span_idx, span) in mp.spans.iter().enumerate() {
        let color = if let Some(ref pal) = mp.palette {
            pal[span_idx].to_string()
        } else if span_idx % 2 == 0 {
            mp.color_a.clone()
        } else {
            mp.color_b.clone()
        };
        let band_left = computed.map_x(span.x_start).max(plot_left);
        let band_right = computed.map_x(span.x_end).min(plot_right);
        for &idx in by_chr
            .get(span.name.as_str())
            .map(|v| v.as_slice())
            .unwrap_or(&[])
        {
            let p = &mp.points[idx];
            let y_val = -(p.pvalue.max(floor)).log10();
            let cx = computed.map_x(p.x).clamp(band_left, band_right);
            let cy = computed.map_y(y_val);
            let tip = tooltip(mp.show_tooltips, &mp.tooltip_labels, idx, || {
                let name = p.label.as_deref().unwrap_or("");
                format!("{}\n{}:{:.0}\np={:.2e}", name, p.chromosome, p.x, p.pvalue)
            });
            if let Some(ref t) = tip {
                scene.add(Primitive::GroupStart {
                    transform: None,
                    title: Some(t.clone()),
                    extra_attrs: None,
                });
            }
            scene.add(Primitive::Circle {
                cx,
                cy,
                r: mp.point_size,
                fill: Color::from(&color),
                fill_opacity: None,
                stroke: None,
                stroke_width: None,
            });
            if tip.is_some() {
                scene.add(Primitive::GroupEnd);
            }
        }
    }

    // 4. Chromosome labels — drawn by add_manhattan_chr_labels (called after ClipEnd).

    // 5. Top-hit labels
    if mp.label_top == 0 {
        return;
    }

    // Collect all points, sort by screen y ascending (most significant = smallest y = top)
    // No genome-wide threshold filter: label the top-N most significant regardless.
    let mut sig_points: Vec<(f64, f64, String)> = mp
        .points
        .iter()
        .map(|p| {
            let y_val = -(p.pvalue.max(floor)).log10();
            let label = p.label.clone().unwrap_or_else(|| p.chromosome.clone());
            (computed.map_x(p.x), computed.map_y(y_val), label)
        })
        .collect();
    sig_points.sort_by(|a, b| a.1.total_cmp(&b.1));
    sig_points.truncate(mp.label_top);

    match mp.label_style {
        LabelStyle::Exact => {
            for (cx, cy, name) in &sig_points {
                scene.add(Primitive::Text {
                    x: *cx,
                    y: cy - mp.point_size - 2.0,
                    content: name.clone(),
                    size: computed.body_size,
                    anchor: TextAnchor::Middle,
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }
        }
        LabelStyle::Nudge => {
            let mut labels: Vec<(f64, f64, String)> = sig_points
                .iter()
                .map(|(cx, cy, name)| (*cx, cy - mp.point_size - 2.0, name.clone()))
                .collect();
            labels.sort_by(|a, b| a.0.total_cmp(&b.0));
            let min_gap = computed.body_size as f64 + 2.0;
            for j in 1..labels.len() {
                let prev_y = labels[j - 1].1;
                if (prev_y - labels[j].1).abs() < min_gap {
                    labels[j].1 = prev_y - min_gap;
                }
            }
            for (cx, label_y, name) in &labels {
                scene.add(Primitive::Text {
                    x: *cx,
                    y: *label_y,
                    content: name.clone(),
                    size: computed.body_size,
                    anchor: TextAnchor::Middle,
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }
        }
        LabelStyle::Arrow { offset_x, offset_y } => {
            for (cx, cy, name) in &sig_points {
                let text_x = cx + offset_x;
                let text_y = cy - offset_y;
                let dx = cx - text_x;
                let dy = cy - text_y;
                let len = (dx * dx + dy * dy).sqrt();
                if len > mp.point_size + 3.0 {
                    let scale = (len - mp.point_size - 3.0) / len;
                    let end_x = text_x + dx * scale;
                    let end_y = text_y + dy * scale;
                    scene.add(Primitive::Line {
                        x1: text_x,
                        y1: text_y,
                        x2: end_x,
                        y2: end_y,
                        stroke: "#666666".into(),
                        stroke_width: 0.8,
                        stroke_dasharray: None,
                    });
                }
                let anchor = if offset_x >= 0.0 {
                    TextAnchor::Start
                } else {
                    TextAnchor::End
                };
                scene.add(Primitive::Text {
                    x: text_x,
                    y: text_y,
                    content: name.clone(),
                    size: computed.body_size,
                    anchor,
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }
        }
    }
}

pub fn render_volcano(vp: &VolcanoPlot, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);
    add_axes_and_grid(&mut scene, &computed, layout);
    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
    add_volcano(vp, &mut scene, &computed);
    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);
    scene
}

/// Draw chromosome name labels below the plot area.
/// Must be called OUTSIDE any active clip-path group, since labels sit below
/// the data-area boundary and would otherwise be invisible.
fn add_manhattan_chr_labels(mp: &ManhattanPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let plot_left = computed.margin_left;
    let plot_right = computed.width - computed.margin_right;
    let label_y = computed.height - computed.margin_bottom + 5.0 + computed.tick_size as f64;
    let min_label_px = 6.0_f64;
    for span in &mp.spans {
        let band_px = (computed.map_x(span.x_end) - computed.map_x(span.x_start)).abs();
        let mid_x = computed.map_x((span.x_start + span.x_end) / 2.0);
        if mid_x >= plot_left && mid_x <= plot_right && band_px >= min_label_px {
            let (anchor, rotate) = match computed.x_tick_rotate {
                Some(angle) => (TextAnchor::End, Some(angle)),
                None => (TextAnchor::Middle, None),
            };
            scene.add(Primitive::Text {
                x: mid_x,
                y: label_y,
                content: span.name.clone(),
                size: computed.tick_size,
                anchor,
                rotate,
                bold: false,
                color: None,
            });
        }
    }
}

pub fn render_manhattan(mp: &ManhattanPlot, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);
    add_axes_and_grid(&mut scene, &computed, layout);
    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
    add_manhattan(mp, &mut scene, &computed);
    add_manhattan_chr_labels(mp, &mut scene, &computed);
    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);
    scene
}

/// render_scatter
pub fn render_scatter(scatter: &ScatterPlot, layout: Layout) -> Scene {
    let computed = ComputedLayout::from_layout(&layout);

    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    add_axes_and_grid(&mut scene, &computed, &layout);
    add_labels_and_title(&mut scene, &computed, &layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    add_scatter(scatter, &mut scene, &computed);

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    scene
}

// render_line
pub fn render_line(line: &LinePlot, layout: Layout) -> Scene {
    let computed = ComputedLayout::from_layout(&layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    add_axes_and_grid(&mut scene, &computed, &layout);
    add_labels_and_title(&mut scene, &computed, &layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    add_line(line, &mut scene, &computed);

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    scene
}

// render_bar
pub fn render_bar(bar: &BarPlot, layout: Layout) -> Scene {
    let computed = ComputedLayout::from_layout(&layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    add_axes_and_grid(&mut scene, &computed, &layout);
    add_labels_and_title(&mut scene, &computed, &layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    add_bar(bar, &mut scene, &computed);

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    scene
}

// render_bar_categories
pub fn render_bar_categories(bar: &BarPlot, layout: Layout) -> Scene {
    let computed = ComputedLayout::from_layout(&layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    add_axes_and_grid(&mut scene, &computed, &layout);
    add_labels_and_title(&mut scene, &computed, &layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    add_bar(bar, &mut scene, &computed);

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    scene
}

// render_histogram
pub fn render_histogram(hist: &Histogram, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    add_axes_and_grid(&mut scene, &computed, layout);
    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    add_histogram(hist, &mut scene, &computed);

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    scene
}

// render_boxplot
pub fn render_boxplot(boxplot: &BoxPlot, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    add_axes_and_grid(&mut scene, &computed, layout);
    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    add_boxplot(boxplot, &mut scene, &computed);

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    scene
}

// render_violinplot
pub fn render_violin(violin: &ViolinPlot, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    add_axes_and_grid(&mut scene, &computed, layout);
    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    add_violin(violin, &mut scene, &computed);

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    scene
}

pub fn render_pie(pie: &PiePlot, layout: &Layout) -> Scene {
    let mut computed = ComputedLayout::from_layout(layout);

    // Widen canvas for outside pie labels before rendering title/labels
    let has_outside = matches!(
        pie.label_position,
        PieLabelPosition::Outside | PieLabelPosition::Auto
    );
    if has_outside {
        let total: f64 = pie.slices.iter().map(|s| s.value).sum();
        let char_width = computed.body_size as f64 * 0.6;
        let max_label_px = pie
            .slices
            .iter()
            .map(|slice| {
                let frac = slice.value / total;
                let place_inside = match pie.label_position {
                    PieLabelPosition::None | PieLabelPosition::Inside => true,
                    PieLabelPosition::Outside => false,
                    PieLabelPosition::Auto => frac >= pie.min_label_fraction,
                };
                if place_inside {
                    return 0.0;
                }
                let label_text = if pie.show_percent {
                    let pct = frac * 100.0;
                    if slice.label.is_empty() {
                        format!("{:.1}%", pct)
                    } else {
                        format!("{} ({:.1}%)", slice.label, pct)
                    }
                } else {
                    slice.label.clone()
                };
                label_text.len() as f64 * char_width
            })
            .fold(0.0f64, f64::max);

        let leader_gap = 30.0;
        let pad = 5.0;
        // Extra gap so outside labels don't crowd the legend or the pie edge.
        let safety = 20.0;
        let radius = computed.plot_height() / 2.0 - pad;
        let needed_half = radius + leader_gap + max_label_px + pad + safety;
        let needed_plot_width = needed_half * 2.0;
        if needed_plot_width > computed.plot_width() {
            computed.width = needed_plot_width + computed.margin_left + computed.margin_right;
            computed.recompute_transforms();
        }
    }

    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    // add_axes_and_grid(&mut scene, &computed, &layout);
    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    add_pie(pie, &mut scene, &computed);

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    scene
}

// render_brickplot
pub fn render_brickplot(brickplot: &BrickPlot, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    // add_axes_and_grid(&mut scene, &computed, &layout);
    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    add_brickplot(brickplot, &mut scene, &computed);

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    scene
}

fn add_density(dp: &DensityPlot, computed: &ComputedLayout, scene: &mut Scene) {
    use render_utils::{silverman_bandwidth, simple_kde, simple_kde_reflect};

    // Determine the (x, y) curve points
    let curve: Vec<(f64, f64)> = if let Some((xs, ys)) = &dp.precomputed {
        xs.iter().copied().zip(ys.iter().copied()).collect()
    } else {
        if dp.data.len() < 2 {
            return;
        }
        let bw = dp
            .bandwidth
            .unwrap_or_else(|| silverman_bandwidth(&dp.data));
        let n = dp.data.len() as f64;
        let norm = 1.0 / (n * bw * (2.0 * std::f64::consts::PI).sqrt());

        let raw = if dp.x_lo.is_some() || dp.x_hi.is_some() {
            // Bounded evaluation with boundary reflection. For any active bound,
            // data points near that boundary are mirrored across it so the curve
            // terminates smoothly rather than cutting off mid-peak. Normalising
            // by the original n preserves the density integral over [lo, hi].
            let data_min = dp.data.iter().cloned().fold(f64::INFINITY, f64::min);
            let data_max = dp.data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
            let lo = dp.x_lo.unwrap_or(data_min - 3.0 * bw);
            let hi = dp.x_hi.unwrap_or(data_max + 3.0 * bw);
            simple_kde_reflect(
                &dp.data,
                bw,
                dp.kde_samples,
                lo,
                hi,
                dp.x_lo.is_some(),
                dp.x_hi.is_some(),
            )
        } else {
            simple_kde(&dp.data, bw, dp.kde_samples)
        };

        raw.into_iter().map(|(x, y)| (x, y * norm)).collect()
    };

    if curve.is_empty() {
        return;
    }

    // Map data coords to pixel coords
    let pts: Vec<(f64, f64)> = curve
        .iter()
        .map(|&(x, y)| (computed.map_x(x), computed.map_y(y)))
        .collect();

    // Build the SVG path string
    let mut path = String::with_capacity(pts.len() * 16);
    let mut rb = ryu::Buffer::new();
    for (i, &(px, py)) in pts.iter().enumerate() {
        path.push(if i == 0 { 'M' } else { 'L' });
        path.push(' ');
        path.push_str(rb.format(round2(px)));
        path.push(' ');
        path.push_str(rb.format(round2(py)));
        path.push(' ');
    }

    // Emit filled area first (below the outline)
    if dp.filled {
        let y_baseline = computed.map_y(0.0);
        let &(last_px, _) = pts.last().unwrap();
        let &(first_px, _) = pts.first().unwrap();
        let fill_path = format!(
            "{} L {} {} L {} {} Z",
            path.trim_end(),
            round2(last_px),
            round2(y_baseline),
            round2(first_px),
            round2(y_baseline),
        );
        scene.add(Primitive::Path(Box::new(PathData {
            d: fill_path,
            fill: Some(Color::from(&dp.color)),
            stroke: Color::from("none"),
            stroke_width: 0.0,
            opacity: Some(dp.opacity),
            stroke_dasharray: None,
        })));
    }

    // Emit the outline
    scene.add(Primitive::Path(Box::new(PathData {
        d: path.trim_end().to_string(),
        fill: None,
        stroke: Color::from(&dp.color),
        stroke_width: dp.stroke_width,
        opacity: None,
        stroke_dasharray: dp.line_dash.clone(),
    })));
}

fn add_ecdf(ep: &crate::plot::ecdf::EcdfPlot, computed: &ComputedLayout, scene: &mut Scene) {
    use crate::render::palette::Palette;
    use render_utils::{silverman_bandwidth, simple_kde};

    if ep.groups.is_empty() {
        return;
    }

    let cat10 = Palette::category10();

    // ── Percentile reference lines (drawn first, under everything) ────────────
    let plot_x1 = computed.margin_left;
    let plot_x2 = computed.margin_left + computed.plot_width();
    for &p in &ep.percentile_lines {
        let y_val = if ep.complementary { 1.0 - p } else { p };
        let py = computed.map_y(y_val);
        scene.add(Primitive::Line {
            x1: plot_x1,
            y1: py,
            x2: plot_x2,
            y2: py,
            stroke: Color::from("#888888"),
            stroke_width: 0.8,
            stroke_dasharray: Some("4,4".into()),
        });
        // Small percentage label at right edge
        let pct_str = format!("{}%", (p * 100.0).round() as u32);
        scene.add(Primitive::Text {
            x: plot_x2 + 3.0,
            y: py + computed.tick_size as f64 * 0.4,
            content: pct_str,
            size: computed.tick_size.saturating_sub(2).max(7),
            anchor: TextAnchor::Start,
            rotate: None,
            bold: false,
            color: Some(Color::from("#888888")),
        });
    }

    for (i, group) in ep.groups.iter().enumerate() {
        if group.data.is_empty() {
            continue;
        }

        // Color resolution: explicit → single-group default → palette
        let color_str = group.color.as_deref().unwrap_or_else(|| {
            if ep.groups.len() == 1 {
                &ep.color
            } else {
                &cat10[i % cat10.len()]
            }
        });
        let color = Color::from(color_str);

        let mut sorted = group.data.clone();
        sorted.sort_by(|a, b| a.total_cmp(b));
        let n = sorted.len();

        // ── Confidence band (DKW 95%) ─────────────────────────────────────
        if ep.show_confidence_band && n >= 2 {
            let eps = ((2.0_f64.ln() - 0.05_f64.ln()) / (2.0 * n as f64)).sqrt();

            // Build upper and lower step-function point lists
            let mut upper: Vec<(f64, f64)> = Vec::with_capacity(n * 2 + 2);
            let mut lower: Vec<(f64, f64)> = Vec::with_capacity(n * 2 + 2);

            // Starting point before first jump
            let px0 = computed.map_x(sorted[0]);
            let (uy0, ly0) = if ep.complementary {
                (
                    computed.map_y(1.0_f64.min(1.0 + eps)),
                    computed.map_y(0.0_f64.max(1.0 - eps)),
                )
            } else {
                (computed.map_y(eps.min(1.0)), computed.map_y(0.0))
            };
            upper.push((px0, uy0));
            lower.push((px0, ly0));

            for (idx, &x) in sorted.iter().enumerate() {
                let f = (idx + 1) as f64 / n as f64;
                let (y_upper, y_lower) = if ep.complementary {
                    ((1.0 - f + eps).min(1.0), (1.0 - f - eps).max(0.0))
                } else {
                    ((f + eps).min(1.0), (f - eps).max(0.0))
                };
                let px = computed.map_x(x);
                // Horizontal then vertical (step)
                upper.push((px, upper.last().unwrap().1));
                upper.push((px, computed.map_y(y_upper)));
                lower.push((px, lower.last().unwrap().1));
                lower.push((px, computed.map_y(y_lower)));
            }

            // Build closed polygon: upper forward + lower reversed
            let mut d = format!("M {},{}", round2(upper[0].0), round2(upper[0].1));
            for &(x, y) in upper.iter().skip(1) {
                d.push_str(&format!(" L {},{}", round2(x), round2(y)));
            }
            for &(x, y) in lower.iter().rev() {
                d.push_str(&format!(" L {},{}", round2(x), round2(y)));
            }
            d.push_str(" Z");

            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: Some(color.clone()),
                stroke: color.clone(),
                stroke_width: 0.0,
                opacity: Some(ep.band_alpha),
                stroke_dasharray: None,
            })));
        }

        // ── ECDF curve ────────────────────────────────────────────────────
        if ep.smooth && n >= 2 {
            // KDE-integrated smooth CDF
            let bw = silverman_bandwidth(&sorted);
            let norm = 1.0 / (n as f64 * bw * (2.0 * std::f64::consts::PI).sqrt());
            let kde_pts = simple_kde(&sorted, bw, ep.smooth_samples);
            let pdf: Vec<(f64, f64)> = kde_pts.into_iter().map(|(x, y)| (x, y * norm)).collect();

            // Trapezoidal integration to get CDF
            let m = pdf.len();
            let mut cdf = vec![0.0f64; m];
            for j in 1..m {
                let dx = pdf[j].0 - pdf[j - 1].0;
                cdf[j] = cdf[j - 1] + 0.5 * (pdf[j].1 + pdf[j - 1].1) * dx;
            }
            let total = cdf[m - 1].max(1e-12);

            let pts: Vec<(f64, f64)> = pdf
                .iter()
                .zip(cdf.iter())
                .map(|(&(x, _), &c)| {
                    let y = (c / total).clamp(0.0, 1.0);
                    let y = if ep.complementary { 1.0 - y } else { y };
                    (computed.map_x(x), computed.map_y(y))
                })
                .collect();

            if !pts.is_empty() {
                let mut d = format!("M {},{}", round2(pts[0].0), round2(pts[0].1));
                for &(px, py) in pts.iter().skip(1) {
                    d.push_str(&format!(" L {},{}", round2(px), round2(py)));
                }
                scene.add(Primitive::Path(Box::new(PathData {
                    d,
                    fill: None,
                    stroke: color.clone(),
                    stroke_width: ep.stroke_width,
                    opacity: None,
                    stroke_dasharray: ep.line_dash.clone(),
                })));
            }
        } else {
            // Right-continuous step function
            let y_start = if ep.complementary { 1.0 } else { 0.0 };
            let mut d = format!(
                "M {},{}",
                round2(computed.map_x(sorted[0])),
                round2(computed.map_y(y_start))
            );

            for (idx, &x) in sorted.iter().enumerate() {
                let y_after = (idx + 1) as f64 / n as f64;
                let y_val = if ep.complementary {
                    1.0 - y_after
                } else {
                    y_after
                };
                if idx > 0 {
                    d.push_str(&format!(" H {}", round2(computed.map_x(x))));
                }
                d.push_str(&format!(" V {}", round2(computed.map_y(y_val))));
            }

            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: None,
                stroke: color.clone(),
                stroke_width: ep.stroke_width,
                opacity: None,
                stroke_dasharray: ep.line_dash.clone(),
            })));
        }

        // ── Markers at step endpoints ─────────────────────────────────────
        if ep.show_markers {
            for (idx, &x) in sorted.iter().enumerate() {
                let y_val = (idx + 1) as f64 / n as f64;
                let y_val = if ep.complementary { 1.0 - y_val } else { y_val };
                scene.add(Primitive::Circle {
                    cx: computed.map_x(x),
                    cy: computed.map_y(y_val),
                    r: ep.marker_size,
                    fill: color.clone(),
                    fill_opacity: None,
                    stroke: None,
                    stroke_width: None,
                });
            }
        }

        // ── Rug ticks at the bottom of the plot area (inside clip) ────────
        if ep.show_rug {
            // Draw upward from the x-axis line; each group offset so they stack
            // without fully overlapping.
            let y_bottom = computed.map_y(0.0);
            let rug_offset = i as f64 * (ep.rug_height + 1.5);
            for &x in &sorted {
                let px = computed.map_x(x);
                scene.add(Primitive::Line {
                    x1: px,
                    y1: y_bottom - rug_offset,
                    x2: px,
                    y2: y_bottom - rug_offset - ep.rug_height,
                    stroke: color.clone(),
                    stroke_width: 0.8,
                    stroke_dasharray: None,
                });
            }
        }
    }
}

fn add_qqplot(qp: &crate::plot::qq::QQPlot, computed: &ComputedLayout, scene: &mut Scene) {
    use crate::plot::qq::QQMode;
    use crate::render::palette::Palette;
    use crate::render::render_utils::{percentile, probit};

    if qp.groups.is_empty() {
        return;
    }

    let cat10 = Palette::category10();

    // Helper: resolve per-group color
    let resolve_color = |group: &crate::plot::qq::QQGroup, idx: usize| -> Color {
        let s = group.color.clone().unwrap_or_else(|| {
            if qp.groups.len() == 1 {
                qp.color.clone()
            } else {
                cat10[idx % cat10.len()].to_string()
            }
        });
        Color::from(s.as_str())
    };

    match &qp.mode {
        QQMode::Normal => {
            for (gi, group) in qp.groups.iter().enumerate() {
                let color = resolve_color(group, gi);
                let mut sorted = group.data.clone();
                sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
                let n = sorted.len();
                if n == 0 {
                    continue;
                }

                // Theoretical quantiles via Hazen plotting positions
                let theoretical: Vec<f64> = (1..=n)
                    .map(|k| probit((k as f64 - 0.5) / n as f64))
                    .collect();

                // Reference line: robust Q1-Q3 line (same as R's qqline)
                if qp.show_reference_line {
                    let q1_th = probit(0.25_f64);
                    let q3_th = probit(0.75_f64);
                    let q1_s = percentile(&sorted, 25.0);
                    let q3_s = percentile(&sorted, 75.0);
                    if (q3_th - q1_th).abs() > 1e-12 {
                        let slope = (q3_s - q1_s) / (q3_th - q1_th);
                        let intercept = q1_s - slope * q1_th;
                        let x0 = theoretical[0];
                        let x1 = *theoretical.last().unwrap();
                        let ref_color = Color::from("#999999");
                        scene.add(Primitive::Line {
                            x1: computed.map_x(x0),
                            y1: computed.map_y(slope * x0 + intercept),
                            x2: computed.map_x(x1),
                            y2: computed.map_y(slope * x1 + intercept),
                            stroke: ref_color,
                            stroke_width: qp.stroke_width,
                            stroke_dasharray: Some("5,3".into()),
                        });
                    }
                }

                // Scatter points
                let fill_op = qp.fill_opacity;
                for k in 0..n {
                    scene.add(Primitive::Circle {
                        cx: computed.map_x(theoretical[k]),
                        cy: computed.map_y(sorted[k]),
                        r: qp.marker_size,
                        fill: color.clone(),
                        fill_opacity: fill_op,
                        stroke: None,
                        stroke_width: None,
                    });
                }
            }
        }

        QQMode::Genomic => {
            // Collect all valid p-values across all groups for CI band sizing
            let first_n = qp
                .groups
                .first()
                .map(|g| g.data.iter().filter(|&&p| p > 0.0 && p <= 1.0).count())
                .unwrap_or(0);

            // CI band around y=x diagonal (using first group's n)
            if qp.show_ci_band && first_n > 1 {
                let n = first_n;
                // Downsample for SVG efficiency: max 500 points
                let step = (n / 500).max(1);
                let mut upper_pts: Vec<(f64, f64)> = Vec::new();
                let mut lower_pts: Vec<(f64, f64)> = Vec::new();

                for idx in (0..n).step_by(step) {
                    let i = idx + 1; // 1-indexed rank
                    let expected_p = (i as f64 - 0.5) / n as f64;
                    let x_val = -expected_p.log10();

                    let mean = i as f64 / (n as f64 + 1.0);
                    let var = (i as f64 * (n - i + 1) as f64)
                        / ((n as f64 + 1.0).powi(2) * (n as f64 + 2.0));
                    let se = var.sqrt();

                    let lower_p = (mean - 1.96 * se).max(1e-300);
                    let upper_p = (mean + 1.96 * se).min(1.0 - 1e-10);
                    // -log10 flips direction: smaller p → larger -log10
                    let y_upper = -lower_p.log10();
                    let y_lower = -upper_p.log10();

                    upper_pts.push((computed.map_x(x_val), computed.map_y(y_upper)));
                    lower_pts.push((computed.map_x(x_val), computed.map_y(y_lower)));
                }

                if upper_pts.len() >= 2 {
                    let mut d = format!("M {},{}", round2(upper_pts[0].0), round2(upper_pts[0].1));
                    for &(x, y) in upper_pts.iter().skip(1) {
                        d.push_str(&format!(" L {},{}", round2(x), round2(y)));
                    }
                    for &(x, y) in lower_pts.iter().rev() {
                        d.push_str(&format!(" L {},{}", round2(x), round2(y)));
                    }
                    d.push_str(" Z");

                    // Use first group's color for the band, or gray if multi-group
                    let band_color = if qp.groups.len() == 1 {
                        resolve_color(&qp.groups[0], 0)
                    } else {
                        Color::from("#aaaaaa")
                    };
                    scene.add(Primitive::Path(Box::new(PathData {
                        d,
                        fill: Some(band_color),
                        stroke: Color::from("none"),
                        stroke_width: 0.0,
                        opacity: Some(qp.ci_alpha),
                        stroke_dasharray: None,
                    })));
                }
            }

            // Reference diagonal y = x
            if qp.show_reference_line {
                let max_x = qp
                    .groups
                    .iter()
                    .flat_map(|g| g.data.iter())
                    .filter(|&&p| p > 0.0 && p <= 1.0)
                    .map(|&p| -p.log10())
                    .fold(0.0_f64, f64::max);
                let max_n = qp
                    .groups
                    .iter()
                    .map(|g| g.data.iter().filter(|&&p| p > 0.0 && p <= 1.0).count())
                    .max()
                    .unwrap_or(1);
                let max_exp = if max_n > 0 {
                    -(0.5 / max_n as f64).log10()
                } else {
                    1.0
                };
                let diag_max = max_x.max(max_exp);
                scene.add(Primitive::Line {
                    x1: computed.map_x(0.0),
                    y1: computed.map_y(0.0),
                    x2: computed.map_x(diag_max),
                    y2: computed.map_y(diag_max),
                    stroke: Color::from("#999999"),
                    stroke_width: qp.stroke_width,
                    stroke_dasharray: Some("5,3".into()),
                });
            }

            for (gi, group) in qp.groups.iter().enumerate() {
                let color = resolve_color(group, gi);

                // Filter and sort p-values ascending
                let mut pvals: Vec<f64> = group
                    .data
                    .iter()
                    .copied()
                    .filter(|&p| p > 0.0 && p <= 1.0)
                    .collect();
                pvals.sort_by(|a, b| a.partial_cmp(b).unwrap());
                let n = pvals.len();
                if n == 0 {
                    continue;
                }

                // Scatter points
                let fill_op = qp.fill_opacity;
                for (k, &pval) in pvals.iter().enumerate() {
                    let expected_p = (k as f64 + 0.5) / n as f64;
                    let x_val = -expected_p.log10();
                    let y_val = -pval.log10();
                    scene.add(Primitive::Circle {
                        cx: computed.map_x(x_val),
                        cy: computed.map_y(y_val),
                        r: qp.marker_size,
                        fill: color.clone(),
                        fill_opacity: fill_op,
                        stroke: None,
                        stroke_width: None,
                    });
                }

                // Genomic inflation factor λ
                if qp.show_lambda && !pvals.is_empty() {
                    // λ = median(χ²₁ observed) / 0.4549  where χ²₁ from p-val = probit(1-p/2)²
                    let mut chi2: Vec<f64> = pvals
                        .iter()
                        .map(|&p| {
                            let z = probit(1.0 - (p / 2.0).min(1.0 - 1e-15));
                            z * z
                        })
                        .collect();
                    chi2.sort_by(|a, b| a.partial_cmp(b).unwrap());
                    let lambda = percentile(&chi2, 50.0) / 0.4549;

                    let label_x = computed.margin_left + computed.plot_width() * 0.05;
                    let body_px = computed.body_size as f64;
                    let label_y = computed.margin_top + body_px * 1.5 + gi as f64 * (body_px + 4.0);
                    let lambda_label = if qp.groups.len() > 1 {
                        format!("{} λ = {:.3}", group.label, lambda)
                    } else {
                        format!("λ = {:.3}", lambda)
                    };
                    scene.add(Primitive::Text {
                        x: label_x,
                        y: label_y,
                        content: lambda_label,
                        size: computed.body_size,
                        color: Some(color.clone()),
                        anchor: TextAnchor::Start,
                        bold: false,
                        rotate: None,
                    });
                }
            }
        }
    }
}

fn add_ridgeline(rp: &RidgelinePlot, computed: &ComputedLayout, scene: &mut Scene) {
    use crate::render::palette::Palette;
    use render_utils::{silverman_bandwidth, simple_kde};

    let fallback = Palette::category10();
    let n = rp.groups.len();
    if n == 0 {
        return;
    }

    // pixels per 1 data unit on y axis
    let cell_h_px = (computed.map_y(0.0) - computed.map_y(1.0)).abs();
    let ridge_h_px = cell_h_px * (1.0 + rp.overlap);

    for (i, group) in rp.groups.iter().enumerate() {
        if group.values.len() < 2 {
            continue;
        }

        let color = group
            .color
            .as_deref()
            .unwrap_or_else(|| &fallback[i % fallback.len()]);

        let bw = rp
            .bandwidth
            .unwrap_or_else(|| silverman_bandwidth(&group.values));

        let raw = simple_kde(&group.values, bw, rp.kde_samples);
        let max_d = raw.iter().map(|&(_, d)| d).fold(0.0_f64, f64::max);
        if max_d == 0.0 {
            continue;
        }

        // group 0 = top = largest y-data value = N
        let y_center_data = (n - i) as f64;
        let y_center_px = computed.map_y(y_center_data);

        let scale = if rp.normalize {
            let nf = group.values.len() as f64;
            let norm_factor = 1.0 / (nf * bw * (2.0 * std::f64::consts::PI).sqrt());
            let max_normed = max_d * norm_factor;
            if max_normed > 0.0 {
                ridge_h_px / max_normed
            } else {
                0.0
            }
        } else {
            ridge_h_px / max_d
        };

        // Map KDE points to pixel space
        let pts: Vec<(f64, f64)> = raw
            .iter()
            .map(|&(x, d)| {
                let normed = if rp.normalize {
                    let nf = group.values.len() as f64;
                    d / (nf * bw * (2.0 * std::f64::consts::PI).sqrt())
                } else {
                    d
                };
                (computed.map_x(x), y_center_px - normed * scale)
            })
            .collect();

        if pts.is_empty() {
            continue;
        }

        let mut rb = ryu::Buffer::new();

        // Baseline: full-width horizontal rule at the group's zero-density level.
        // Uses the theme axis color (not the group color) so it reads as a neutral
        // reference guide — no color clash or stroke-width mismatch with the ridge
        // outline where they meet.  Drawn first so the fill sits on top of it.
        if rp.show_baseline {
            scene.add(Primitive::Line {
                x1: computed.margin_left,
                y1: y_center_px,
                x2: computed.width - computed.margin_right,
                y2: y_center_px,
                stroke: Color::from(&computed.theme.axis_color),
                stroke_width: computed.axis_stroke_width * 0.5,
                stroke_dasharray: None,
            });
        }

        // Build outline path string
        let mut outline = String::with_capacity(pts.len() * 16);
        for (j, &(px, py)) in pts.iter().enumerate() {
            outline.push(if j == 0 { 'M' } else { 'L' });
            outline.push(' ');
            outline.push_str(rb.format(round2(px)));
            outline.push(' ');
            outline.push_str(rb.format(round2(py)));
            outline.push(' ');
        }
        let outline = outline.trim_end().to_string();

        // Emit filled area (closed path) — below outline
        if rp.filled {
            let first_px = pts.first().unwrap().0;
            let last_px = pts.last().unwrap().0;
            let s_last_px = rb.format(round2(last_px)).to_string();
            let s_y_center = rb.format(round2(y_center_px)).to_string();
            let s_first_px = rb.format(round2(first_px)).to_string();
            let fill_path = format!(
                "{} L {} {} L {} {} Z",
                outline, s_last_px, s_y_center, s_first_px, s_y_center,
            );
            scene.add(Primitive::Path(Box::new(PathData {
                d: fill_path,
                fill: Some(Color::from(color)),
                stroke: Color::from("none"),
                stroke_width: 0.0,
                opacity: Some(rp.opacity),
                stroke_dasharray: None,
            })));
        }

        // Emit outline
        scene.add(Primitive::Path(Box::new(PathData {
            d: outline,
            fill: None,
            stroke: Color::from(color),
            stroke_width: rp.stroke_width,
            opacity: None,
            stroke_dasharray: rp.line_dash.clone(),
        })));
    }
}

pub fn render_waterfall(waterfall: &WaterfallPlot, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);
    add_axes_and_grid(&mut scene, &computed, layout);
    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
    add_waterfall(waterfall, &mut scene, &computed);
    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);
    scene
}

// render_strip
pub fn render_strip(strip: &StripPlot, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);
    add_axes_and_grid(&mut scene, &computed, layout);
    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);
    add_strip(strip, &mut scene, &computed);
    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);
    scene
}

fn add_dot_plot(dp: &DotPlot, scene: &mut Scene, computed: &ComputedLayout) {
    const EPSILON: f64 = f64::EPSILON;

    let (size_min, size_max) = dp.size_range.unwrap_or_else(|| dp.size_extent());
    let (color_min, color_max) = dp.color_range.unwrap_or_else(|| dp.color_extent());

    let n_x = dp.x_categories.len() as f64;
    let n_y = dp.y_categories.len() as f64;
    let n_y_usize = dp.y_categories.len();

    let cell_w = if n_x > 0.0 {
        computed.plot_width() / n_x
    } else {
        1.0
    };
    let cell_h = if n_y > 0.0 {
        computed.plot_height() / n_y
    } else {
        1.0
    };
    // Cap effective max radius so circles never bleed outside their grid cell
    let effective_max_r = dp.max_radius.min((cell_w.min(cell_h) / 2.0) * 0.9);

    for (dpi, pt) in dp.points.iter().enumerate() {
        let xi = dp.x_categories.iter().position(|c| c == &pt.x_cat);
        let yi = dp.y_categories.iter().position(|c| c == &pt.y_cat);

        let (xi, yi) = match (xi, yi) {
            (Some(xi), Some(yi)) => (xi, yi),
            _ => continue,
        };

        // y is REVERSED: y_cat[0] is rendered at the top (map_y maps larger values to top)
        let cx = computed.map_x(xi as f64 + 1.0);
        let cy = computed.map_y((n_y_usize - yi) as f64);

        let norm_size = (pt.size - size_min) / (size_max - size_min + EPSILON);
        let norm_color = (pt.color - color_min) / (color_max - color_min + EPSILON);

        let r = dp.min_radius + norm_size.clamp(0.0, 1.0) * (effective_max_r - dp.min_radius);
        let fill = dp.color_map.map(norm_color.clamp(0.0, 1.0));

        let tip = tooltip(dp.show_tooltips, &dp.tooltip_labels, dpi, || {
            format!("{}, {}: size={:.2}", pt.x_cat, pt.y_cat, pt.size)
        });
        if let Some(ref t) = tip {
            scene.add(Primitive::GroupStart {
                transform: None,
                title: Some(t.clone()),
                extra_attrs: None,
            });
        }
        scene.add(Primitive::Circle {
            cx,
            cy,
            r,
            fill: fill.into(),
            fill_opacity: None,
            stroke: None,
            stroke_width: None,
        });
        if tip.is_some() {
            scene.add(Primitive::GroupEnd);
        }
    }
}

fn add_diceplot(dp: &DicePlot, scene: &mut Scene, computed: &ComputedLayout) {
    const EPSILON: f64 = f64::EPSILON;

    let n_x = dp.x_categories.len();
    let n_y = dp.y_categories.len();
    if n_x == 0 || n_y == 0 {
        return;
    }

    // Detect which input modes are present across all points.
    let categorical_mode = dp.points.iter().any(|p| !p.dot_colors.is_empty());
    let per_dot_mode = dp
        .points
        .iter()
        .any(|p| !p.dot_fills.is_empty() || !p.dot_sizes.is_empty());
    let tile_mode = dp
        .points
        .iter()
        .any(|p| !p.present.is_empty() || p.fill.is_some() || p.size.is_some());

    debug_assert!(
        [categorical_mode, per_dot_mode, tile_mode]
            .iter()
            .filter(|&&v| v)
            .count()
            <= 1,
        "DicePlot: mixing with_records / with_dot_data / with_points on the same plot \
         is not supported and will produce unpredictable output. Use a single input mode."
    );

    let per_dot_mode = !categorical_mode && per_dot_mode;

    // ── Square grid with equal spacing ──────────────────────────────────────────
    // cell_sq = min(cell_w, cell_h) so both horizontal and vertical gaps are equal.
    // The grid is then centred within the plot area.
    let cell_sq = {
        let cw = computed.plot_width() / n_x as f64;
        let ch = computed.plot_height() / n_y as f64;
        cw.min(ch)
    };
    let tile_sq = cell_sq * dp.cell_width.min(dp.cell_height); // square tile

    let grid_total_w = n_x as f64 * cell_sq;
    let grid_total_h = n_y as f64 * cell_sq;
    let grid_x0 = computed.margin_left + (computed.plot_width() - grid_total_w) / 2.0;
    let grid_y0 = computed.margin_top + (computed.plot_height() - grid_total_h) / 2.0;

    // ── Pip geometry ────────────────────────────────────────────────────────────
    // Each tile is divided into a 3×3 sub-grid. Pips are centred in their sub-cell.
    // Radius is capped so the pip never crosses the sub-cell boundary.
    let sub = tile_sq / 3.0; // sub-cell side length
    let pip_scale = 0.85_f64;
    let max_pip_r = sub * 0.5 * pip_scale; // fits inside sub-cell with a margin
    let base_r = if dp.dot_radius > 0.0 {
        dp.dot_radius
    } else {
        max_pip_r
    };

    let grid_positions = dp.dot_grid_positions();

    let has_size = dp.points.iter().any(|p| p.size.is_some())
        || dp
            .points
            .iter()
            .any(|p| p.dot_sizes.iter().any(|v| v.is_some()));
    let has_fill = dp.points.iter().any(|p| p.fill.is_some());
    let (fill_min, fill_max) = dp.fill_range.unwrap_or_else(|| dp.fill_extent());
    let (size_min, size_max) = dp.size_range.unwrap_or_else(|| dp.size_extent());

    // ── Category axis drawing (add_axes_and_grid is skipped for DicePlot) ───────
    {
        let theme = &computed.theme;
        let ax = Color::from(&theme.axis_color);
        let aw = computed.axis_stroke_width;
        let tl = computed.tick_mark_major;
        let tlm = computed.tick_label_margin;
        let ts = computed.tick_size;

        // Bottom and left axis lines
        scene.add(Primitive::Line {
            x1: grid_x0,
            y1: grid_y0 + grid_total_h,
            x2: grid_x0 + grid_total_w,
            y2: grid_y0 + grid_total_h,
            stroke: ax.clone(),
            stroke_width: aw,
            stroke_dasharray: None,
        });
        scene.add(Primitive::Line {
            x1: grid_x0,
            y1: grid_y0,
            x2: grid_x0,
            y2: grid_y0 + grid_total_h,
            stroke: ax.clone(),
            stroke_width: aw,
            stroke_dasharray: None,
        });

        for (xi, label) in dp.x_categories.iter().enumerate() {
            let tx = grid_x0 + (xi as f64 + 0.5) * cell_sq;
            let ty = grid_y0 + grid_total_h;
            scene.add(Primitive::Line {
                x1: tx,
                y1: ty,
                x2: tx,
                y2: ty + tl,
                stroke: ax.clone(),
                stroke_width: aw,
                stroke_dasharray: None,
            });
            scene.add(Primitive::Text {
                x: tx,
                y: ty + tl + tlm + ts as f64 * 0.7,
                content: label.clone(),
                size: ts,
                anchor: TextAnchor::Middle,
                rotate: None,
                bold: false,
                color: None,
            });
        }

        for (yi, label) in dp.y_categories.iter().enumerate() {
            let ty = grid_y0 + (yi as f64 + 0.5) * cell_sq;
            scene.add(Primitive::Line {
                x1: grid_x0 - tl,
                y1: ty,
                x2: grid_x0,
                y2: ty,
                stroke: ax.clone(),
                stroke_width: aw,
                stroke_dasharray: None,
            });
            scene.add(Primitive::Text {
                x: grid_x0 - tl - tlm,
                y: ty + ts as f64 * 0.35,
                content: label.clone(),
                size: ts,
                anchor: TextAnchor::End,
                rotate: None,
                bold: false,
                color: None,
            });
        }
    }

    // ── Tiles and pips ───────────────────────────────────────────────────────────
    for pt in &dp.points {
        let xi = dp.x_categories.iter().position(|c| c == &pt.x_cat);
        let yi = dp.y_categories.iter().position(|c| c == &pt.y_cat);
        let (xi, yi) = match (xi, yi) {
            (Some(xi), Some(yi)) => (xi, yi),
            _ => continue,
        };

        // Tile centre — yi=0 maps to the top row of the grid
        let cx = grid_x0 + (xi as f64 + 0.5) * cell_sq;
        let cy = grid_y0 + (yi as f64 + 0.5) * cell_sq;

        let theme = &computed.theme;
        let (tile_fill, tile_stroke, tile_stroke_w): (Color, Option<Color>, Option<f64>) =
            if categorical_mode || per_dot_mode {
                (
                    Color::from(&theme.legend_bg),
                    Some(Color::from(&theme.axis_color)),
                    Some(0.8_f64),
                )
            } else if has_fill {
                let color: Color = if let Some(v) = pt.fill {
                    let norm = (v - fill_min) / (fill_max - fill_min + EPSILON);
                    dp.color_map.map(norm.clamp(0.0, 1.0)).into()
                } else {
                    "#e8e8e8".into()
                };
                (color, Some("#cccccc".into()), Some(0.5_f64))
            } else {
                ("#e8e8e8".into(), Some("#cccccc".into()), Some(0.5_f64))
            };

        scene.add(Primitive::Rect {
            x: cx - tile_sq / 2.0,
            y: cy - tile_sq / 2.0,
            width: tile_sq,
            height: tile_sq,
            fill: tile_fill,
            stroke: tile_stroke,
            stroke_width: tile_stroke_w,
            opacity: None,
        });

        // Optional 3×3 sub-grid lines inside each tile
        if dp.grid_lines {
            let gl: Color = "#aaaaaa".into();
            for i in 1..3_usize {
                let frac = i as f64 / 3.0;
                scene.add(Primitive::Line {
                    x1: cx - tile_sq / 2.0 + frac * tile_sq,
                    y1: cy - tile_sq / 2.0,
                    x2: cx - tile_sq / 2.0 + frac * tile_sq,
                    y2: cy + tile_sq / 2.0,
                    stroke: gl.clone(),
                    stroke_width: 0.4,
                    stroke_dasharray: None,
                });
                scene.add(Primitive::Line {
                    x1: cx - tile_sq / 2.0,
                    y1: cy - tile_sq / 2.0 + frac * tile_sq,
                    x2: cx + tile_sq / 2.0,
                    y2: cy - tile_sq / 2.0 + frac * tile_sq,
                    stroke: gl.clone(),
                    stroke_width: 0.4,
                    stroke_dasharray: None,
                });
            }
        }

        let cell_dot_r = if has_size && !categorical_mode && !per_dot_mode {
            if let Some(s) = pt.size {
                let norm = (s - size_min) / (size_max - size_min + EPSILON);
                base_r * (0.25 + 0.75 * norm.clamp(0.0, 1.0))
            } else {
                base_r * 0.25
            }
        } else {
            base_r
        };

        for k in 0..dp.ndots {
            // Pip centre: centred within its 3×3 sub-cell
            let (h_idx, v_idx) = match grid_positions.get(k) {
                Some(&p) => p,
                None => continue,
            };
            let dot_cx = cx + (h_idx as f64 - 1.0) * sub;
            let dot_cy = cy + (v_idx as f64 - 1.0) * sub;

            if categorical_mode {
                if let Some(color) = pt.dot_colors.get(k).and_then(|c| c.as_deref()) {
                    scene.add(Primitive::Circle {
                        cx: dot_cx,
                        cy: dot_cy,
                        r: cell_dot_r,
                        fill: color.into(),
                        fill_opacity: None,
                        stroke: None,
                        stroke_width: None,
                    });
                }
            } else if per_dot_mode {
                if let Some(fill_v) = pt.dot_fills.get(k).and_then(|v| *v) {
                    let fill_norm = (fill_v - fill_min) / (fill_max - fill_min + EPSILON);
                    let fill_color: Color = dp.color_map.map(fill_norm.clamp(0.0, 1.0)).into();
                    let dot_r = if let Some(size_v) = pt.dot_sizes.get(k).and_then(|v| *v) {
                        let size_norm = (size_v - size_min) / (size_max - size_min + EPSILON);
                        base_r * (0.25 + 0.75 * size_norm.clamp(0.0, 1.0))
                    } else {
                        base_r * 0.25
                    };
                    scene.add(Primitive::Circle {
                        cx: dot_cx,
                        cy: dot_cy,
                        r: dot_r,
                        fill: fill_color,
                        fill_opacity: None,
                        stroke: None,
                        stroke_width: None,
                    });
                }
            } else if pt.present.contains(&k) {
                scene.add(Primitive::Circle {
                    cx: dot_cx,
                    cy: dot_cy,
                    r: cell_dot_r,
                    fill: "#222222".into(),
                    fill_opacity: None,
                    stroke: None,
                    stroke_width: None,
                });
            } else {
                let r = cell_dot_r * 0.6;
                let d = format!(
                    "M {},{} A {},{} 0 1,0 {},{} A {},{} 0 1,0 {},{} Z",
                    dot_cx - r,
                    dot_cy,
                    r,
                    r,
                    dot_cx + r,
                    dot_cy,
                    r,
                    r,
                    dot_cx - r,
                    dot_cy,
                );
                scene.add(Primitive::Path(Box::new(PathData {
                    d,
                    fill: Some("none".into()),
                    stroke: "#999999".into(),
                    stroke_width: 0.8,
                    opacity: Some(0.6),
                    stroke_dasharray: None,
                })));
            }
        }
    }
}

fn add_dice_position_legend(
    dp: &DicePlot,
    title: &str,
    scene: &mut Scene,
    computed: &ComputedLayout,
    y_start: f64,
) -> f64 {
    let theme = &computed.theme;
    let legend_padding = 10.0;
    let legend_width = computed.legend_width;
    let legend_x = computed.width - computed.margin_right + computed.y2_axis_width + 10.0;

    // Big-die layout: a 3×3 grid where each cell has a pip area + label area.
    // die_cell_w scales with the longest category label so text is never clipped.
    let max_cat_len = dp
        .category_labels
        .iter()
        .map(|l| l.len())
        .max()
        .unwrap_or(3);
    let die_cell_w = (max_cat_len as f64 * 5.5 + 10.0).max(24.0_f64);
    let die_cell_pip_h = 18.0_f64; // height reserved for the pip
    let label_area_h = 14.0_f64; // height reserved for the label below each pip
    let row_h = die_cell_pip_h + label_area_h;
    let die_w = 3.0 * die_cell_w;
    let die_h = 3.0 * row_h;
    let pip_r = 4.5_f64;
    let label_size = (computed.body_size as i32 - 1).max(9) as u32;

    let title_h = computed.body_size as f64 + 8.0;
    let box_height = legend_padding * 2.0 + title_h + die_h;

    // Background + border
    scene.add(Primitive::Rect {
        x: legend_x - legend_padding + 5.0,
        y: y_start - legend_padding,
        width: legend_width,
        height: box_height,
        fill: Color::from(&theme.legend_bg),
        stroke: None,
        stroke_width: None,
        opacity: None,
    });
    scene.add(Primitive::Rect {
        x: legend_x - legend_padding + 5.0,
        y: y_start - legend_padding,
        width: legend_width,
        height: box_height,
        fill: "none".into(),
        stroke: Some(Color::from(&theme.legend_border)),
        stroke_width: Some(1.0),
        opacity: None,
    });

    // Title
    scene.add(Primitive::Text {
        x: legend_x + legend_width * 0.5 - legend_padding,
        y: y_start + computed.body_size as f64 * 0.85,
        content: title.to_string(),
        size: computed.body_size,
        anchor: TextAnchor::Middle,
        rotate: None,
        bold: true,
        color: None,
    });

    // Centre the die face horizontally within the legend box
    let die_x = legend_x + (legend_width - legend_padding * 2.0 - die_w) / 2.0;
    let die_y = y_start + title_h;

    // Die face border — encompasses all pip + label rows
    scene.add(Primitive::Rect {
        x: die_x,
        y: die_y,
        width: die_w,
        height: die_h,
        fill: Color::from(&theme.legend_bg),
        stroke: Some(Color::from(&theme.axis_color)),
        stroke_width: Some(0.8),
        opacity: None,
    });

    // Internal 3×3 grid lines inside the die face
    let grid_color: Color = Color::from(&theme.axis_color);
    for i in 1..3_usize {
        // Vertical lines
        scene.add(Primitive::Line {
            x1: die_x + i as f64 * die_cell_w,
            y1: die_y,
            x2: die_x + i as f64 * die_cell_w,
            y2: die_y + die_h,
            stroke: grid_color.clone(),
            stroke_width: 0.4,
            stroke_dasharray: Some("3,3".to_string()),
        });
        // Horizontal lines
        scene.add(Primitive::Line {
            x1: die_x,
            y1: die_y + i as f64 * row_h,
            x2: die_x + die_w,
            y2: die_y + i as f64 * row_h,
            stroke: grid_color.clone(),
            stroke_width: 0.4,
            stroke_dasharray: Some("3,3".to_string()),
        });
    }

    // Pips + labels: each pip occupies the upper portion of its cell; label below it
    for (k, (grid_row, grid_col)) in dp.dot_grid_positions().iter().enumerate() {
        let pip_cx = die_x + *grid_col as f64 * die_cell_w + die_cell_w / 2.0;
        let pip_cy = die_y + *grid_row as f64 * row_h + die_cell_pip_h / 2.0;

        scene.add(Primitive::Circle {
            cx: pip_cx,
            cy: pip_cy,
            r: pip_r,
            fill: Color::from(&theme.text_color),
            fill_opacity: None,
            stroke: None,
            stroke_width: None,
        });

        let label = dp.category_labels.get(k).map(|s| s.as_str()).unwrap_or("");
        scene.add(Primitive::Text {
            x: pip_cx,
            y: pip_cy + die_cell_pip_h / 2.0 + label_area_h * 0.8,
            content: label.to_string(),
            size: label_size,
            anchor: TextAnchor::Middle,
            rotate: None,
            bold: false,
            color: None,
        });
    }

    y_start + box_height
}

fn add_dice_size_legend_section(
    dp: &DicePlot,
    title: &str,
    scene: &mut Scene,
    computed: &ComputedLayout,
    y_start: f64,
) -> f64 {
    let theme = &computed.theme;
    let legend_padding = 10.0;
    let line_height = computed.legend_line_height;
    let legend_width = computed.legend_width;
    let legend_x = computed.width - computed.margin_right + computed.y2_axis_width + 10.0;

    let (size_min, size_max) = dp.size_range.unwrap_or_else(|| dp.size_extent());
    let n_rows = 3_usize;
    let pcts: [f64; 3] = [0.25, 0.50, 1.0];
    let box_height = (1 + n_rows) as f64 * line_height + legend_padding * 2.0;

    scene.add(Primitive::Rect {
        x: legend_x - legend_padding + 5.0,
        y: y_start - legend_padding,
        width: legend_width,
        height: box_height,
        fill: Color::from(&theme.legend_bg),
        stroke: None,
        stroke_width: None,
        opacity: None,
    });
    scene.add(Primitive::Rect {
        x: legend_x - legend_padding + 5.0,
        y: y_start - legend_padding,
        width: legend_width,
        height: box_height,
        fill: "none".into(),
        stroke: Some(Color::from(&theme.legend_border)),
        stroke_width: Some(1.0),
        opacity: None,
    });
    scene.add(Primitive::Text {
        x: legend_x + legend_width * 0.5 - legend_padding,
        y: y_start + computed.body_size as f64 * 0.8,
        content: title.to_string(),
        size: computed.body_size,
        anchor: TextAnchor::Middle,
        rotate: None,
        bold: true,
        color: None,
    });

    // base_r must match the actual plot pip radius (tile_sq/6 * pip_scale)
    let cell_sq = {
        let cw = computed.plot_width() / dp.x_categories.len().max(1) as f64;
        let ch = computed.plot_height() / dp.y_categories.len().max(1) as f64;
        cw.min(ch)
    };
    let tile_sq = cell_sq * dp.cell_width.min(dp.cell_height);
    let base_r = if dp.dot_radius > 0.0 {
        dp.dot_radius
    } else {
        tile_sq / 6.0 * 0.85
    };

    let swatch_cx = legend_x + 5.0 + 10.0;
    let mut row_y = y_start + line_height;
    for &pct in &pcts {
        let r = (base_r * (0.25 + 0.75 * pct)).clamp(2.0, 8.0);
        let circle_cy = row_y + line_height * 0.5 - 2.0;
        scene.add(Primitive::Circle {
            cx: swatch_cx,
            cy: circle_cy,
            r,
            fill: "#444444".into(),
            fill_opacity: None,
            stroke: None,
            stroke_width: None,
        });
        let value = size_min + pct * (size_max - size_min);
        scene.add(Primitive::Text {
            x: swatch_cx + 14.0,
            y: circle_cy + computed.body_size as f64 / 3.0,
            content: format!("{:.1}", value),
            size: computed.body_size,
            anchor: TextAnchor::Start,
            rotate: None,
            bold: false,
            color: None,
        });
        row_y += line_height;
    }
    y_start + box_height
}

/// Returns true if a colorbar was drawn (so the caller can skip the generic add_colorbar).
fn add_dice_legends(dp: &DicePlot, scene: &mut Scene, computed: &ComputedLayout) -> bool {
    let mut y = computed.margin_top;

    if let Some(ref title) = dp.position_legend_label {
        y = add_dice_position_legend(dp, title, scene, computed, y);
        y += 8.0;
    }

    if !dp.dot_legend.is_empty() {
        let entries: Vec<LegendEntry> = dp
            .dot_legend
            .iter()
            .map(|(label, color)| LegendEntry {
                label: label.clone(),
                color: color.clone(),
                shape: LegendShape::Circle,
                dasharray: None,
            })
            .collect();
        let legend = Legend {
            title: None,
            entries,
            groups: None,
            position: computed.legend_position,
            show_box: true,
        };
        add_legend_at(&legend, scene, computed, y);
        y += legend.entries.len() as f64 * computed.legend_line_height + 28.0;
    }

    if let Some(ref title) = dp.size_legend_label {
        y = add_dice_size_legend_section(dp, title, scene, computed, y);
        y += 8.0;
    }

    // Colorbar (fill legend) — drawn below the other dice legends when there is enough
    // vertical room, or beside them (starting at margin_top) when the remaining height
    // would make the bar too short to be useful.
    if dp.fill_legend_label.is_some() {
        let (fill_min, fill_max) = dp.fill_range.unwrap_or_else(|| dp.fill_extent());
        let cmap = dp.color_map.clone();
        let info = ColorBarInfo {
            map_fn: std::sync::Arc::new(move |t| {
                let norm = (t - fill_min) / (fill_max - fill_min + f64::EPSILON);
                cmap.map(norm.clamp(0.0, 1.0))
            }),
            min_value: fill_min,
            max_value: fill_max,
            label: dp.fill_legend_label.clone(),
            tick_labels: None,
        };
        let bar_x = computed.width - computed.colorbar_x_inset;
        // If the remaining height after stacking the other legend boxes is less than
        // 120px, place the colorbar beside them (same y-start as the legend stack)
        // rather than below them. The two columns are already at different x positions
        // within the right margin so they don't overlap.
        let available_below = computed.height - y - 28.0;
        let bar_y = if available_below < 120.0 {
            computed.margin_top + 8.0
        } else {
            y + 8.0
        };
        let bar_height = (computed.height - bar_y - 20.0).max(60.0);
        add_colorbar_at(&info, scene, computed, bar_x, bar_y, bar_height);
        return true;
    }

    false
}

/// Draw DotPlot size legend (top) and colorbar (bottom) stacked in the same right-margin column.
fn add_dot_stacked_legends(
    size_title: &str,
    size_entries: &[LegendEntry],
    info: &ColorBarInfo,
    scene: &mut Scene,
    computed: &ComputedLayout,
) {
    let theme = &computed.theme;
    let legend_x = computed.width - computed.margin_right + computed.y2_axis_width + 10.0;
    let legend_width = computed.legend_width;
    let line_height = computed.legend_line_height;
    let legend_padding = computed.legend_padding;

    // --- Size legend (top) ---
    // Title text sits above the box; the box starts below the title baseline
    // so the background rect doesn't paint over the text.
    let title_y = computed.margin_top + computed.tick_size as f64;
    // box visual top = box_top - legend_padding; we want this >= title_y + 4
    let box_top = title_y + legend_padding + 4.0;
    let size_legend_height = size_entries.len() as f64 * line_height + legend_padding * 2.0;

    // Background (drawn before title so title text sits on top)
    scene.add(Primitive::Rect {
        x: legend_x - legend_padding + 5.0,
        y: box_top - legend_padding,
        width: legend_width,
        height: size_legend_height,
        fill: Color::from(&theme.legend_bg),
        stroke: None,
        stroke_width: None,
        opacity: None,
    });
    // Border
    scene.add(Primitive::Rect {
        x: legend_x - legend_padding + 5.0,
        y: box_top - legend_padding,
        width: legend_width,
        height: size_legend_height,
        fill: "none".into(),
        stroke: Some(Color::from(&theme.legend_border)),
        stroke_width: Some(computed.axis_stroke_width),
        opacity: None,
    });
    // Title drawn after the rects so it paints on top of them
    scene.add(Primitive::Text {
        x: legend_x + legend_width * 0.5 - legend_padding,
        y: title_y,
        content: size_title.to_string(),
        size: computed.tick_size,
        anchor: TextAnchor::Middle,
        rotate: None,
        bold: false,
        color: None,
    });

    let mut legend_y = box_top;
    for entry in size_entries {
        let swatch_cy = legend_y + computed.legend_swatch_size / 2.0 - 1.0;
        let text_baseline = swatch_cy + computed.body_size as f64 * 0.35;
        scene.add(Primitive::Text {
            x: legend_x + computed.legend_text_x,
            y: text_baseline,
            content: entry.label.clone(),
            size: computed.body_size,
            anchor: TextAnchor::Start,
            rotate: None,
            bold: false,
            color: None,
        });
        if let LegendShape::CircleSize(r) = entry.shape {
            scene.add(Primitive::Circle {
                cx: legend_x + computed.legend_swatch_x + computed.legend_swatch_r,
                cy: swatch_cy,
                r: r.min(computed.legend_swatch_half),
                fill: Color::from(&entry.color),
                fill_opacity: None,
                stroke: None,
                stroke_width: None,
            });
        }
        legend_y += line_height;
    }

    // --- Colorbar (bottom) ---
    let gap = 15.0;
    let bar_x = legend_x;
    let bar_width = computed.colorbar_bar_width;
    let colorbar_top = box_top - legend_padding + size_legend_height + gap;

    let bar_y = colorbar_top;
    let bar_height = (computed.height - computed.margin_bottom - bar_y - gap).max(50.0);

    // Gradient slices
    let num_slices = 50;
    let slice_height = bar_height / num_slices as f64;
    for i in 0..num_slices {
        let t = 1.0 - (i as f64 / (num_slices - 1) as f64);
        let value = info.min_value + t * (info.max_value - info.min_value);
        let color = (info.map_fn)(value);
        let y = bar_y + i as f64 * slice_height;
        scene.add(Primitive::Rect {
            x: bar_x,
            y,
            width: bar_width,
            height: slice_height + 0.5,
            fill: color.into(),
            stroke: None,
            stroke_width: None,
            opacity: None,
        });
    }
    // Border
    scene.add(Primitive::Rect {
        x: bar_x,
        y: bar_y,
        width: bar_width,
        height: bar_height,
        fill: "none".into(),
        stroke: Some(Color::from(&theme.colorbar_border)),
        stroke_width: Some(computed.axis_stroke_width),
        opacity: None,
    });

    // Tick marks and labels
    let ticks = render_utils::generate_ticks(info.min_value, info.max_value, 5);
    let range = info.max_value - info.min_value;
    for tick in &ticks {
        if *tick < info.min_value || *tick > info.max_value {
            continue;
        }
        let frac = (tick - info.min_value) / range;
        let y = bar_y + bar_height - frac * bar_height;
        scene.add(Primitive::Line {
            x1: bar_x + bar_width,
            y1: y,
            x2: bar_x + bar_width + computed.tick_mark_major * 0.8,
            y2: y,
            stroke: Color::from(&theme.colorbar_border),
            stroke_width: computed.axis_stroke_width,
            stroke_dasharray: None,
        });
        scene.add(Primitive::Text {
            x: bar_x + bar_width + computed.tick_mark_major,
            y: y + 4.0,
            content: computed.colorbar_tick_format.format(*tick),
            size: computed.tick_size,
            anchor: TextAnchor::Start,
            rotate: None,
            bold: false,
            color: None,
        });
    }

    // Label rotated -90° to the left of the bar (same convention as add_colorbar).
    if let Some(ref label) = info.label {
        let label_x = bar_x - computed.tick_size as f64 * 0.5 - 4.0;
        let label_y = bar_y + bar_height / 2.0;
        scene.add(Primitive::Text {
            x: label_x,
            y: label_y,
            content: label.clone(),
            size: computed.tick_size,
            anchor: TextAnchor::Middle,
            rotate: Some(-90.0),
            bold: false,
            color: None,
        });
    }
}

/// Collect legend entries from a slice of plots.
#[allow(clippy::collapsible_match)]
pub fn collect_legend_entries(plots: &[Plot]) -> Vec<LegendEntry> {
    let mut entries = Vec::new();
    for plot in plots {
        match plot {
            Plot::Bar(barplot) => {
                if let Some(label) = barplot.legend_label.clone() {
                    for (i, barval) in barplot
                        .groups
                        .first()
                        .expect("BarPlot legend requires at least one group")
                        .bars
                        .iter()
                        .enumerate()
                    {
                        entries.push(LegendEntry {
                            label: label
                                .get(i)
                                .expect("BarPlot legend label count does not match bar count")
                                .to_string(),
                            color: barval.color.clone(),
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Line(line) => {
                if let Some(label) = &line.legend_label {
                    entries.push(LegendEntry {
                        label: label.clone(),
                        color: line.color.clone(),
                        shape: LegendShape::Line,
                        dasharray: line.line_style.dasharray(),
                    });
                }
            }
            Plot::Scatter(scatter) => {
                if let Some(label) = &scatter.legend_label {
                    entries.push(LegendEntry {
                        label: label.clone(),
                        color: scatter.color.clone(),
                        shape: LegendShape::Marker(scatter.marker),
                        dasharray: None,
                    });
                }
            }
            Plot::Series(series) => {
                if let Some(label) = &series.legend_label {
                    entries.push(LegendEntry {
                        label: label.clone(),
                        color: series.color.clone(),
                        shape: LegendShape::Circle,
                        dasharray: None,
                    });
                }
            }
            Plot::Brick(brickplot) => {
                let labels = brickplot
                    .template
                    .as_ref()
                    .expect("BrickPlot legend requires a template colormap");
                let motifs = brickplot.motifs.as_ref();
                // Sort by global letter (A = most frequent first, then B, C, …).
                // '@' (gap) goes last.
                let mut sorted_labels: Vec<(&char, &String)> = labels.iter().collect();
                sorted_labels.sort_by(|(a, _), (b, _)| match (*a, *b) {
                    ('@', '@') => std::cmp::Ordering::Equal,
                    ('@', _) => std::cmp::Ordering::Greater,
                    (_, '@') => std::cmp::Ordering::Less,
                    _ => a.cmp(b),
                });
                for (letter, color) in sorted_labels {
                    let base_label = if let Some(m) = motifs {
                        m.get(letter).cloned().unwrap_or(letter.to_string())
                    } else {
                        letter.to_string()
                    };
                    let label = if brickplot.mark_primary && *letter == 'A' {
                        format!("{}*", base_label)
                    } else {
                        base_label
                    };
                    entries.push(LegendEntry {
                        label,
                        color: color.clone(),
                        shape: LegendShape::Rect,
                        dasharray: None,
                    })
                }
            }
            Plot::Box(boxplot) => {
                if let Some(label) = &boxplot.legend_label {
                    entries.push(LegendEntry {
                        label: label.clone(),
                        color: boxplot.color.clone(),
                        shape: LegendShape::Rect,
                        dasharray: None,
                    });
                }
            }
            Plot::Violin(violin) => {
                if let Some(label) = &violin.legend_label {
                    entries.push(LegendEntry {
                        label: label.clone(),
                        color: violin.color.clone(),
                        shape: LegendShape::Rect,
                        dasharray: None,
                    });
                }
            }
            Plot::Histogram(hist) => {
                if let Some(label) = &hist.legend_label {
                    entries.push(LegendEntry {
                        label: label.clone(),
                        color: hist.color.clone(),
                        shape: LegendShape::Rect,
                        dasharray: None,
                    });
                }
            }
            Plot::Waterfall(wp) => {
                if let Some(ref label) = wp.legend_label {
                    entries.push(LegendEntry {
                        label: label.clone(),
                        color: wp.color_positive.clone(),
                        shape: LegendShape::Rect,
                        dasharray: None,
                    });
                }
            }
            Plot::Strip(sp) => {
                if sp.legend_label.is_some() {
                    if let Some(ref colors) = sp.group_colors {
                        // Per-group legend entries
                        for (i, group) in sp.groups.iter().enumerate() {
                            let color = colors.get(i).cloned().unwrap_or_else(|| sp.color.clone());
                            entries.push(LegendEntry {
                                label: group.label.clone(),
                                color,
                                shape: LegendShape::Circle,
                                dasharray: None,
                            });
                        }
                    } else if let Some(ref label) = sp.legend_label {
                        if !label.is_empty() {
                            entries.push(LegendEntry {
                                label: label.clone(),
                                color: sp.color.clone(),
                                shape: LegendShape::Circle,
                                dasharray: None,
                            });
                        }
                    }
                }
            }
            Plot::Raincloud(rp) => {
                if rp.legend_label.is_some() {
                    use crate::render::palette::Palette;
                    let cat10 = Palette::category10();
                    for (i, group) in rp.groups.iter().enumerate() {
                        let color = rp
                            .group_colors
                            .as_ref()
                            .and_then(|c| c.get(i).cloned())
                            .unwrap_or_else(|| {
                                if rp.groups.len() > 1 {
                                    cat10[i].to_string()
                                } else {
                                    rp.color.clone()
                                }
                            });
                        entries.push(LegendEntry {
                            label: group.label.clone(),
                            color,
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Lollipop(lp) => {
                if let Some(ref label) = lp.legend_label {
                    entries.push(LegendEntry {
                        label: label.clone(),
                        color: lp.color.clone(),
                        shape: LegendShape::Circle,
                        dasharray: None,
                    });
                }
            }
            Plot::Slope(sp) => {
                if sp.legend_label.is_some() {
                    if sp.color_by_direction {
                        entries.push(LegendEntry {
                            label: "Increase".into(),
                            color: sp.color_up.clone(),
                            shape: LegendShape::Circle,
                            dasharray: None,
                        });
                        entries.push(LegendEntry {
                            label: "Decrease".into(),
                            color: sp.color_down.clone(),
                            shape: LegendShape::Circle,
                            dasharray: None,
                        });
                    } else if let Some(ref gc) = sp.group_colors {
                        // Per-group: one entry per row
                        for (i, pt) in sp.points.iter().enumerate() {
                            let color = gc.get(i).cloned().unwrap_or_else(|| sp.color.clone());
                            entries.push(LegendEntry {
                                label: pt.label.clone(),
                                color,
                                shape: LegendShape::Circle,
                                dasharray: None,
                            });
                        }
                    } else {
                        // Uniform: one entry
                        if let Some(ref label) = sp.legend_label {
                            entries.push(LegendEntry {
                                label: label.clone(),
                                color: sp.color.clone(),
                                shape: LegendShape::Circle,
                                dasharray: None,
                            });
                        }
                    }
                }
            }
            Plot::Survival(sp) => {
                if sp.legend_label.is_some() {
                    use crate::render::palette::Palette;
                    let cat10 = Palette::category10();
                    for (i, group) in sp.groups.iter().enumerate() {
                        let color = group
                            .color
                            .clone()
                            .or_else(|| sp.group_colors.as_ref().and_then(|c| c.get(i).cloned()))
                            .unwrap_or_else(|| {
                                if sp.groups.len() > 1 {
                                    cat10[i].to_string()
                                } else {
                                    sp.color.clone()
                                }
                            });
                        entries.push(LegendEntry {
                            label: group.label.clone(),
                            color,
                            shape: LegendShape::Line,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Heatmap(heatmap) => {
                if let Some(label) = &heatmap.legend_label {
                    entries.push(LegendEntry {
                        label: label.clone(),
                        color: "gray".into(),
                        shape: LegendShape::Rect,
                        dasharray: None,
                    });
                }
            }
            Plot::Pie(pie) => {
                if pie.legend_label.is_some() {
                    let total: f64 = pie.slices.iter().map(|s| s.value).sum();
                    for slice in &pie.slices {
                        let label = if pie.show_percent {
                            let pct = slice.value / total * 100.0;
                            if slice.label.is_empty() {
                                format!("{:.1}%", pct)
                            } else {
                                format!("{} ({:.1}%)", slice.label, pct)
                            }
                        } else {
                            slice.label.clone()
                        };
                        entries.push(LegendEntry {
                            label,
                            color: slice.color.clone(),
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Volcano(vp) => {
                if vp.legend_label.is_some() {
                    entries.push(LegendEntry {
                        label: "Up".into(),
                        color: vp.color_up.clone(),
                        shape: LegendShape::Circle,
                        dasharray: None,
                    });
                    entries.push(LegendEntry {
                        label: "Down".into(),
                        color: vp.color_down.clone(),
                        shape: LegendShape::Circle,
                        dasharray: None,
                    });
                    entries.push(LegendEntry {
                        label: "NS".into(),
                        color: vp.color_ns.clone(),
                        shape: LegendShape::Circle,
                        dasharray: None,
                    });
                }
            }
            Plot::Manhattan(mp) => {
                if mp.legend_label.is_some() {
                    entries.push(LegendEntry {
                        label: "Genome-wide".into(),
                        color: "#cc3333".into(),
                        shape: LegendShape::Line,
                        dasharray: Some("4 4".into()),
                    });
                    entries.push(LegendEntry {
                        label: "Suggestive".into(),
                        color: "#888888".into(),
                        shape: LegendShape::Line,
                        dasharray: Some("4 4".into()),
                    });
                }
            }
            Plot::DotPlot(dp) => {
                if dp.size_label.is_some() {
                    let (size_min, size_max) = dp.size_range.unwrap_or_else(|| dp.size_extent());
                    for &pct in &[0.25_f64, 0.50, 0.75, 1.0] {
                        let value_at_pct = size_min + pct * (size_max - size_min);
                        let radius_at_pct = dp.max_radius * pct;
                        entries.push(LegendEntry {
                            label: format!("{:.1}", value_at_pct),
                            color: "#444444".into(),
                            shape: LegendShape::CircleSize(radius_at_pct),
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::StackedArea(sa) => {
                for k in 0..sa.series.len() {
                    if let Some(Some(ref label)) = sa.labels.get(k) {
                        entries.push(LegendEntry {
                            label: label.clone(),
                            color: sa.resolve_color(k).to_string(),
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Candlestick(cp) => {
                if let Some(ref label) = cp.legend_label {
                    entries.push(LegendEntry {
                        label: label.clone(),
                        color: cp.color_up.clone(),
                        shape: LegendShape::Rect,
                        dasharray: None,
                    });
                }
            }
            Plot::Chord(chord) => {
                if chord.legend_label.is_some() {
                    use crate::render::palette::Palette;
                    let fallback = Palette::category10();
                    let n = chord.n_nodes();
                    for i in 0..n {
                        let color = if let Some(c) = chord.colors.get(i) {
                            if !c.is_empty() {
                                c.clone()
                            } else {
                                fallback[i % fallback.len()].to_string()
                            }
                        } else {
                            fallback[i % fallback.len()].to_string()
                        };
                        let label = if let Some(l) = chord.labels.get(i) {
                            l.clone()
                        } else {
                            format!("{i}")
                        };
                        entries.push(LegendEntry {
                            label,
                            color,
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Sankey(s) => {
                if s.legend_label.is_some() {
                    use crate::render::palette::Palette;
                    let fallback = Palette::category10();
                    for (i, node) in s.nodes.iter().enumerate() {
                        let color = node
                            .color
                            .clone()
                            .unwrap_or_else(|| fallback[i % fallback.len()].to_string());
                        entries.push(LegendEntry {
                            label: node.label.clone(),
                            color,
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Network(net) => {
                if net.legend_label.is_some() {
                    use crate::render::palette::Palette;
                    let fallback = Palette::category10();
                    // One entry per unique group.
                    let mut seen: Vec<String> = Vec::new();
                    let mut gi = 0usize;
                    for node in &net.nodes {
                        if let Some(ref g) = node.group {
                            if !seen.contains(g) {
                                let color = fallback[gi % fallback.len()].to_string();
                                entries.push(LegendEntry {
                                    label: g.clone(),
                                    color,
                                    shape: LegendShape::Circle,
                                    dasharray: None,
                                });
                                seen.push(g.clone());
                                gi += 1;
                            }
                        }
                    }
                    // If no groups, one entry per node.
                    if seen.is_empty() {
                        for (i, node) in net.nodes.iter().enumerate() {
                            let color = node
                                .color
                                .clone()
                                .unwrap_or_else(|| fallback[i % fallback.len()].to_string());
                            entries.push(LegendEntry {
                                label: node.label.clone(),
                                color,
                                shape: LegendShape::Circle,
                                dasharray: None,
                            });
                        }
                    }
                }
            }
            Plot::Contour(cp) => {
                if let Some(ref label) = cp.legend_label {
                    if !cp.filled {
                        let line_color = cp
                            .line_color
                            .clone()
                            .unwrap_or_else(|| cp.color_map.map(0.5));
                        entries.push(LegendEntry {
                            label: label.clone(),
                            color: line_color,
                            shape: LegendShape::Line,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::PhyloTree(t) => {
                if t.legend_label.is_some() {
                    for (node_id, color) in &t.clade_colors {
                        let label = t.nodes[*node_id]
                            .label
                            .clone()
                            .unwrap_or_else(|| format!("Node {}", node_id));
                        entries.push(LegendEntry {
                            label,
                            color: color.clone(),
                            shape: LegendShape::Line,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Synteny(sp) => {
                if sp.legend_label.is_some() {
                    use crate::render::palette::Palette;
                    let fallback = Palette::category10();
                    for (i, seq) in sp.sequences.iter().enumerate() {
                        let color = seq
                            .color
                            .clone()
                            .unwrap_or_else(|| fallback[i % fallback.len()].to_string());
                        entries.push(LegendEntry {
                            label: seq.label.clone(),
                            color,
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Density(dp) => {
                if let Some(ref label) = dp.legend_label {
                    entries.push(LegendEntry {
                        label: label.clone(),
                        color: dp.color.clone(),
                        shape: LegendShape::Line,
                        dasharray: dp.line_dash.clone(),
                    });
                }
            }
            Plot::Ridgeline(rp) => {
                if rp.show_legend {
                    use crate::render::palette::Palette;
                    let fallback = Palette::category10();
                    for (i, group) in rp.groups.iter().enumerate() {
                        let color = group
                            .color
                            .clone()
                            .unwrap_or_else(|| fallback[i % fallback.len()].to_string());
                        entries.push(LegendEntry {
                            label: group.label.clone(),
                            color,
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Polar(pp) => {
                if pp.show_legend {
                    use crate::render::palette::Palette;
                    let fallback = Palette::category10();
                    for (i, series) in pp.series.iter().enumerate() {
                        if let Some(ref label) = series.label {
                            let color = series
                                .color
                                .clone()
                                .unwrap_or_else(|| fallback[i % fallback.len()].to_string());
                            entries.push(LegendEntry {
                                label: label.clone(),
                                color,
                                shape: LegendShape::Circle,
                                dasharray: None,
                            });
                        }
                    }
                }
            }
            Plot::Ternary(tp) => {
                if tp.show_legend {
                    use crate::render::palette::Palette;
                    let fallback = Palette::category10();
                    let groups = tp.unique_groups();
                    for (i, group) in groups.iter().enumerate() {
                        entries.push(LegendEntry {
                            label: group.clone(),
                            color: fallback[i % fallback.len()].to_string(),
                            shape: LegendShape::Circle,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Scatter3D(s) => {
                if let Some(ref label) = s.legend_label {
                    entries.push(LegendEntry {
                        label: label.clone(),
                        color: s.color.clone(),
                        shape: LegendShape::Marker(s.marker),
                        dasharray: None,
                    });
                }
            }
            Plot::Surface3D(s) => {
                if let Some(ref label) = s.legend_label {
                    entries.push(LegendEntry {
                        label: label.clone(),
                        color: s.color.clone(),
                        shape: LegendShape::Rect,
                        dasharray: None,
                    });
                }
            }
            Plot::DicePlot(dp) => {
                for (label, color) in &dp.dot_legend {
                    entries.push(LegendEntry {
                        label: label.clone(),
                        color: color.clone(),
                        shape: LegendShape::Circle,
                        dasharray: None,
                    });
                }
            }
            Plot::Roc(roc) => {
                if roc.legend_label.is_some() {
                    let cat10 = crate::render::palette::Palette::category10();
                    for (i, group) in roc.groups.iter().enumerate() {
                        let color = group.color.clone().unwrap_or_else(|| {
                            if roc.groups.len() == 1 {
                                roc.color.clone()
                            } else {
                                cat10[i % cat10.len()].to_string()
                            }
                        });
                        let computed_g = crate::plot::roc::compute_group(group);
                        let auc_str = if group.show_auc_label {
                            format!("  (AUC = {:.3})", computed_g.auc)
                        } else {
                            String::new()
                        };
                        entries.push(LegendEntry {
                            label: format!("{}{}", group.label, auc_str),
                            color,
                            shape: LegendShape::Line,
                            dasharray: group.dasharray.clone(),
                        });
                    }
                }
            }
            Plot::Pr(pr) => {
                if pr.legend_label.is_some() {
                    let cat10 = crate::render::palette::Palette::category10();
                    for (i, group) in pr.groups.iter().enumerate() {
                        let color = group.color.clone().unwrap_or_else(|| {
                            if pr.groups.len() == 1 {
                                pr.color.clone()
                            } else {
                                cat10[i % cat10.len()].to_string()
                            }
                        });
                        let computed_g = crate::plot::pr::compute_pr_group(group);
                        let auc_str = if group.show_auc_label {
                            format!("  (AUC-PR = {:.3})", computed_g.auc)
                        } else {
                            String::new()
                        };
                        entries.push(LegendEntry {
                            label: format!("{}{}", group.label, auc_str),
                            color,
                            shape: LegendShape::Line,
                            dasharray: group.dasharray.clone(),
                        });
                    }
                }
            }
            Plot::Joint(jp) => {
                use crate::render::palette::Palette;
                let cat10 = Palette::category10();
                for (gi, group) in jp.groups.iter().enumerate() {
                    if let Some(ref lbl) = group.scatter.legend_label {
                        let color =
                            if group.scatter.color == "black" && group.scatter.colors.is_none() {
                                cat10[gi % cat10.len()].to_string()
                            } else {
                                group.scatter.color.clone()
                            };
                        entries.push(LegendEntry {
                            label: lbl.clone(),
                            color,
                            shape: LegendShape::Marker(group.scatter.marker),
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Venn(vp) => {
                if vp.legend_label.is_some() {
                    use crate::render::palette::Palette;
                    let _pal = Palette::category10();
                    for (i, set) in vp.sets.iter().enumerate() {
                        let color = vp.color_for(i);
                        entries.push(LegendEntry {
                            label: set.label.clone(),
                            color,
                            shape: LegendShape::Circle,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Parallel(pp) => {
                if pp.legend_label.is_some() {
                    let groups = pp.groups();
                    if groups.is_empty() {
                        entries.push(LegendEntry {
                            label: pp.color.clone(),
                            color: pp.color.clone(),
                            shape: LegendShape::Line,
                            dasharray: None,
                        });
                    } else {
                        for (i, g) in groups.iter().enumerate() {
                            entries.push(LegendEntry {
                                label: g.clone(),
                                color: pp.color_for_group_idx(i),
                                shape: LegendShape::Line,
                                dasharray: None,
                            });
                        }
                    }
                }
            }
            Plot::Mosaic(mp) => {
                if mp.legend_label.is_some() {
                    let rows = mp.effective_row_order();
                    for (i, row) in rows.iter().enumerate() {
                        entries.push(LegendEntry {
                            label: row.clone(),
                            color: mp.color_for_row_idx(i),
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Ecdf(ep) => {
                if ep.legend_label.is_some() {
                    let cat10 = crate::render::palette::Palette::category10();
                    for (i, group) in ep.groups.iter().enumerate() {
                        let color = group.color.clone().unwrap_or_else(|| {
                            if ep.groups.len() == 1 {
                                ep.color.clone()
                            } else {
                                cat10[i % cat10.len()].to_string()
                            }
                        });
                        entries.push(LegendEntry {
                            label: group.label.clone(),
                            color,
                            shape: LegendShape::Line,
                            dasharray: ep.line_dash.clone(),
                        });
                    }
                }
            }
            Plot::QQ(qp) => {
                if qp.legend_label.is_some() {
                    let cat10 = crate::render::palette::Palette::category10();
                    for (i, group) in qp.groups.iter().enumerate() {
                        let color = group.color.clone().unwrap_or_else(|| {
                            if qp.groups.len() == 1 {
                                qp.color.clone()
                            } else {
                                cat10[i % cat10.len()].to_string()
                            }
                        });
                        entries.push(LegendEntry {
                            label: group.label.clone(),
                            color,
                            shape: LegendShape::Circle,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Streamgraph(sg) => {
                if sg.legend_label.is_some() {
                    for k in 0..sg.series.len() {
                        if let Some(Some(ref label)) = sg.labels.get(k) {
                            entries.push(LegendEntry {
                                label: label.clone(),
                                color: sg.resolve_color(k).to_string(),
                                shape: LegendShape::Rect,
                                dasharray: None,
                            });
                        }
                    }
                }
            }
            Plot::Radar(rp) => {
                if rp.show_legend {
                    use crate::render::palette::Palette;
                    let pal = Palette::category10();
                    for (i, s) in rp.series.iter().enumerate() {
                        if let Some(ref lbl) = s.label {
                            let color = s.color.clone().unwrap_or_else(|| pal[i].to_string());
                            entries.push(LegendEntry {
                                label: lbl.clone(),
                                color,
                                shape: LegendShape::Line,
                                dasharray: s.dasharray.clone(),
                            });
                        }
                    }
                    for ref_poly in &rp.references {
                        if let Some(ref lbl) = ref_poly.label {
                            let color = ref_poly
                                .color
                                .clone()
                                .unwrap_or_else(|| "#999999".to_string());
                            entries.push(LegendEntry {
                                label: lbl.clone(),
                                color,
                                shape: LegendShape::Line,
                                dasharray: Some("6,3".to_string()),
                            });
                        }
                    }
                }
            }
            Plot::Bump(bp) => {
                if bp.legend {
                    use crate::render::palette::Palette;
                    let cat10 = Palette::category10();
                    let series = bp.resolved_series();
                    for (i, s) in series.iter().enumerate() {
                        let color = s.color.clone().unwrap_or_else(|| cat10[i].to_string());
                        entries.push(LegendEntry {
                            label: s.name.clone(),
                            color,
                            shape: LegendShape::Line,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Funnel(fp) => {
                if fp.legend_label.is_some() {
                    use crate::render::palette::Palette;
                    let cat10 = Palette::category10();
                    let all_stages = {
                        let mut v: Vec<(usize, &FunnelStage)> =
                            fp.stages.iter().enumerate().collect();
                        if let Some(ref mir) = fp.mirror {
                            for (i, s) in mir.iter().enumerate() {
                                if !v.iter().any(|(_, ls)| ls.label == s.label) {
                                    v.push((i, s));
                                }
                            }
                        }
                        v
                    };
                    for (i, s) in all_stages {
                        let color = s.color.clone().unwrap_or_else(|| match fp.color_mode {
                            FunnelColorMode::Uniform => cat10[0].to_string(),
                            FunnelColorMode::ByStage | FunnelColorMode::Gradient => {
                                cat10[i % 10].to_string()
                            }
                        });
                        entries.push(LegendEntry {
                            label: s.label.clone(),
                            color,
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Rose(rp) => {
                if rp.legend_label.is_some() {
                    use crate::render::palette::Palette;
                    let cat10 = Palette::category10();
                    for (i, s) in rp.series.iter().enumerate() {
                        let color = s.color.clone().unwrap_or_else(|| cat10[i % 10].to_string());
                        entries.push(LegendEntry {
                            label: s.name.clone(),
                            color,
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Pyramid(pp) => {
                if pp.show_legend {
                    use crate::render::palette::Palette;
                    let cat10 = Palette::category10();
                    if pp.series.len() <= 1 {
                        // Single-series: one entry per side
                        let left_color = pp
                            .series
                            .first()
                            .and_then(|s| s.color.clone())
                            .unwrap_or_else(|| pp.left_color.clone());
                        let right_color = pp
                            .series
                            .first()
                            .and_then(|s| s.color.clone())
                            .unwrap_or_else(|| pp.right_color.clone());
                        entries.push(LegendEntry {
                            label: pp.left_label.clone(),
                            color: left_color,
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                        entries.push(LegendEntry {
                            label: pp.right_label.clone(),
                            color: right_color,
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                    } else {
                        // Multi-series: one entry per series
                        for (i, s) in pp.series.iter().enumerate() {
                            let color = s
                                .color
                                .clone()
                                .unwrap_or_else(|| cat10[i % cat10.len()].to_string());
                            entries.push(LegendEntry {
                                label: s.label.clone(),
                                color,
                                shape: LegendShape::Rect,
                                dasharray: None,
                            });
                        }
                    }
                }
            }
            Plot::Waffle(wp) => {
                if wp.legend_label.is_some() {
                    let total_val: f64 = wp.categories.iter().map(|c| c.value).sum();
                    let n_cells = wp.rows * wp.cols;
                    let cell_counts = waffle_largest_remainder(
                        &wp.categories.iter().map(|c| c.value).collect::<Vec<_>>(),
                        n_cells,
                    );
                    for (i, cat) in wp.categories.iter().enumerate() {
                        let label = waffle_legend_label(cat, i, total_val, &cell_counts, wp);
                        entries.push(LegendEntry {
                            label,
                            color: cat.color.clone(),
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Horizon(hp) => {
                if hp.show_legend {
                    for s in &hp.series {
                        entries.push(LegendEntry {
                            label: s.label.clone(),
                            color: s.pos_color.clone(),
                            shape: LegendShape::Rect,
                            dasharray: None,
                        });
                    }
                }
            }
            Plot::Text(_) => {}
            Plot::LegendPlot(_) => {}
            _ => {}
        }
    }
    entries
}

/// Render legend entries at an arbitrary (x, y) position on a scene.
///
/// `groups` takes priority over `entries` when `Some`. `title` adds a bold header row.
/// `show_box` controls whether the background and border rects are drawn.
/// Note: `legend_wrap` is not applied here — Figure shared legends are typically short.
#[allow(clippy::too_many_arguments)]
pub fn render_legend_at(
    entries: &[LegendEntry],
    groups: Option<&[LegendGroup]>,
    title: Option<&str>,
    show_box: bool,
    scene: &mut Scene,
    x: f64,
    y: f64,
    width: f64,
    body_size: u32,
    theme: &Theme,
) {
    let legend_padding = 10.0;
    let line_height = 18.0;

    let entry_rows = if let Some(groups) = groups {
        groups.iter().map(|g| g.entries.len() + 1).sum::<usize>()
    } else {
        entries.len()
    };
    let title_rows = if title.is_some() { 1 } else { 0 };
    let legend_height = (entry_rows + title_rows) as f64 * line_height + legend_padding * 2.0;

    if show_box {
        // Background
        scene.add(Primitive::Rect {
            x: x - legend_padding + 5.0,
            y: y - legend_padding,
            width,
            height: legend_height,
            fill: Color::from(&theme.legend_bg),
            stroke: None,
            stroke_width: None,
            opacity: None,
        });
        // Border
        scene.add(Primitive::Rect {
            x: x - legend_padding + 5.0,
            y: y - legend_padding,
            width,
            height: legend_height,
            fill: "none".into(),
            stroke: Some(Color::from(&theme.legend_border)),
            stroke_width: Some(1.0),
            opacity: None,
        });
    }

    // Synthesise a minimal ComputedLayout for render_legend_entry
    let mut cur_y = y;

    if let Some(t) = title {
        scene.add(Primitive::Text {
            x: x + width / 2.0,
            y: cur_y + 5.0,
            content: t.to_string(),
            anchor: TextAnchor::Middle,
            size: body_size,
            rotate: None,
            bold: true,
            color: None,
        });
        cur_y += line_height;
    }

    // Helper: inline entry rendering (avoids needing a full ComputedLayout)
    let render_entry = |entry: &LegendEntry, scene: &mut Scene, cur_y: f64| {
        let swatch_cy = cur_y + 5.0;
        let text_baseline = swatch_cy + body_size as f64 * 0.35;
        scene.add(Primitive::Text {
            x: x + 25.0,
            y: text_baseline,
            content: entry.label.clone(),
            anchor: TextAnchor::Start,
            size: body_size,
            rotate: None,
            bold: false,
            color: None,
        });
        match entry.shape {
            LegendShape::Rect => scene.add(Primitive::Rect {
                x: x + 5.0,
                y: cur_y - 1.0,
                width: 12.0,
                height: 12.0,
                fill: Color::from(&entry.color),
                stroke: None,
                stroke_width: None,
                opacity: None,
            }),
            LegendShape::Line => scene.add(Primitive::Line {
                x1: x + 5.0,
                y1: swatch_cy,
                x2: x + 5.0 + 12.0,
                y2: swatch_cy,
                stroke: Color::from(&entry.color),
                stroke_width: 2.0,
                stroke_dasharray: entry.dasharray.clone(),
            }),
            LegendShape::Circle => scene.add(Primitive::Circle {
                cx: x + 5.0 + 6.0,
                cy: swatch_cy,
                r: 5.0,
                fill: Color::from(&entry.color),
                fill_opacity: None,
                stroke: None,
                stroke_width: None,
            }),
            LegendShape::Marker(marker) => {
                draw_marker(
                    scene,
                    marker,
                    x + 5.0 + 6.0,
                    swatch_cy,
                    5.0,
                    &entry.color,
                    None,
                    None,
                    None,
                );
            }
            LegendShape::CircleSize(r) => {
                let swatch_half = 8.0;
                let draw_r = r.min(swatch_half);
                scene.add(Primitive::Circle {
                    cx: x + 5.0 + 6.0,
                    cy: swatch_cy,
                    r: draw_r,
                    fill: Color::from(&entry.color),
                    fill_opacity: None,
                    stroke: None,
                    stroke_width: None,
                });
            }
        }
    };

    if let Some(groups) = groups {
        for group in groups {
            scene.add(Primitive::Text {
                x: x + 5.0,
                y: cur_y + 5.0,
                content: group.title.clone(),
                anchor: TextAnchor::Start,
                size: body_size,
                rotate: None,
                bold: true,
                color: None,
            });
            cur_y += line_height;
            for entry in &group.entries {
                render_entry(entry, scene, cur_y);
                cur_y += line_height;
            }
        }
    } else {
        for entry in entries {
            render_entry(entry, scene, cur_y);
            cur_y += line_height;
        }
    }
}

fn add_upset(up: &UpSetPlot, scene: &mut Scene, computed: &ComputedLayout) {
    if up.set_names.is_empty() {
        return;
    }
    let sorted = up.sorted_intersections();
    if sorted.is_empty() {
        return;
    }

    let n_sets = up.set_names.len();
    let n_cols = sorted.len();

    let theme = &computed.theme;
    let pl = computed.margin_left;
    let pr = computed.width - computed.margin_right;
    let pt = computed.margin_top;
    let pb = computed.height - computed.margin_bottom;
    let pw = pr - pl;
    let ph = pb - pt;

    let tick_size = computed.tick_size as f64;
    let label_size = computed.label_size as f64;

    // Left panel layout (left → right): [bar_area][count_gap][name_area]
    let max_name_len = up.set_names.iter().map(|n| n.len()).max().unwrap_or(0);
    let name_area = (max_name_len as f64 * tick_size * 0.6 + 10.0).clamp(40.0, 120.0);
    let bar_area = if up.show_set_sizes {
        (pw * 0.18).clamp(50.0, 150.0)
    } else {
        0.0
    };
    // Reserve a fixed zone for count labels so they never overlap the set names.
    let count_gap = if up.show_counts && up.show_set_sizes {
        28.0
    } else {
        0.0
    };
    let left_panel_w = bar_area + count_gap + name_area;

    // Top panel: intersection size bars (55 % of plot height).
    let inter_bar_h = ph * 0.55;

    // Dot-matrix region.
    let mat_l = pl + left_panel_w;
    let mat_t = pt + inter_bar_h;
    let mat_r = pr;
    let mat_b = pb;

    let dot_col_w = if n_cols > 0 {
        (mat_r - mat_l) / n_cols as f64
    } else {
        1.0
    };
    let dot_row_h = if n_sets > 0 {
        (mat_b - mat_t) / n_sets as f64
    } else {
        1.0
    };
    let dot_r = (dot_col_w.min(dot_row_h) * 0.35).clamp(3.0, 12.0);
    let bar_half_w = (dot_col_w * 0.3).max(3.0);

    let max_inter = sorted.iter().map(|i| i.count).max().unwrap_or(1) as f64;
    let max_set = up.set_sizes.iter().copied().max().unwrap_or(1) as f64;

    // ── Set-size bars (left panel, left portion) ────────────────────────────
    if up.show_set_sizes {
        let bar_x_start = pl;
        let bar_x_end = pl + bar_area;
        let bar_half_h = (dot_row_h * 0.25).clamp(3.0, 12.0);

        // Axis line (right edge of bar area).
        scene.add(Primitive::Line {
            x1: bar_x_end,
            y1: mat_t,
            x2: bar_x_end,
            y2: mat_b,
            stroke: Color::from(&theme.axis_color),
            stroke_width: 1.0,
            stroke_dasharray: None,
        });
        // Baseline.
        scene.add(Primitive::Line {
            x1: bar_x_start,
            y1: mat_b,
            x2: bar_x_end,
            y2: mat_b,
            stroke: Color::from(&theme.axis_color),
            stroke_width: 1.0,
            stroke_dasharray: None,
        });

        for (j, &size) in up.set_sizes.iter().enumerate() {
            let cy = mat_t + (j as f64 + 0.5) * dot_row_h;
            let bar_w = size as f64 / max_set * bar_area;

            // Bars grow leftward from the right edge (zero baseline at bar_x_end).
            scene.add(Primitive::Rect {
                x: bar_x_end - bar_w,
                y: cy - bar_half_h,
                width: bar_w,
                height: bar_half_h * 2.0,
                fill: Color::from(&up.bar_color),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });

            if up.show_counts {
                // Fixed position in the count_gap zone — never encroaches on name_area.
                scene.add(Primitive::Text {
                    x: pl + bar_area + 3.0,
                    y: cy + tick_size * 0.35,
                    content: format!("{}", size),
                    size: computed.tick_size,
                    anchor: TextAnchor::Start,
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }
        }

        // "Set size" axis label.
        scene.add(Primitive::Text {
            x: bar_x_start + bar_area / 2.0,
            y: mat_t - tick_size - 4.0,
            content: "Set size".to_string(),
            size: computed.label_size,
            anchor: TextAnchor::Middle,
            rotate: None,
            bold: false,
            color: None,
        });
    }

    // ── Set names (right portion of left panel) ──────────────────────────────
    let name_x = mat_l - 5.0; // right-aligned just before dot matrix
    for (j, name) in up.set_names.iter().enumerate() {
        let cy = mat_t + (j as f64 + 0.5) * dot_row_h;
        scene.add(Primitive::Text {
            x: name_x,
            y: cy + tick_size * 0.35,
            content: name.clone(),
            size: computed.tick_size,
            anchor: TextAnchor::End,
            rotate: None,
            bold: false,
            color: None,
        });
    }

    // ── Intersection-size bars (top panel) ───────────────────────────────────
    let bar_y_max = pt + inter_bar_h - 5.0; // baseline
    let bar_y_min = pt + tick_size + 2.0; // top of tallest bar
    let bar_h_range = (bar_y_max - bar_y_min).max(1.0);

    // Left axis line for intersection bars.
    scene.add(Primitive::Line {
        x1: mat_l,
        y1: bar_y_min,
        x2: mat_l,
        y2: bar_y_max,
        stroke: Color::from(&theme.axis_color),
        stroke_width: 1.0,
        stroke_dasharray: None,
    });
    // Baseline.
    scene.add(Primitive::Line {
        x1: mat_l,
        y1: bar_y_max,
        x2: mat_r,
        y2: bar_y_max,
        stroke: Color::from(&theme.axis_color),
        stroke_width: 1.0,
        stroke_dasharray: None,
    });

    // Y-axis ticks for intersection bars.
    let n_yticks = 4;
    for ti in 0..=n_yticks {
        let frac = ti as f64 / n_yticks as f64;
        let val = (max_inter * frac).round() as usize;
        let y = bar_y_max - frac * bar_h_range;

        scene.add(Primitive::Line {
            x1: mat_l - 4.0,
            y1: y,
            x2: mat_l,
            y2: y,
            stroke: Color::from(&theme.tick_color),
            stroke_width: 1.0,
            stroke_dasharray: None,
        });
        scene.add(Primitive::Text {
            x: mat_l - 7.0,
            y: y + tick_size * 0.35,
            content: format!("{}", val),
            size: computed.tick_size,
            anchor: TextAnchor::End,
            rotate: None,
            bold: false,
            color: None,
        });
    }

    // "Intersection size" Y-axis label (rotated, left of the tick labels).
    scene.add(Primitive::Text {
        x: mat_l - 7.0 - tick_size * 2.5 - label_size * 0.5,
        y: (bar_y_min + bar_y_max) / 2.0,
        content: "Intersection size".to_string(),
        size: computed.label_size,
        anchor: TextAnchor::Middle,
        rotate: Some(-90.0),
        bold: false,
        color: None,
    });

    // Intersection bars.
    for (i, inter) in sorted.iter().enumerate() {
        let cx = mat_l + (i as f64 + 0.5) * dot_col_w;
        let bar_h = (inter.count as f64 / max_inter * bar_h_range).max(0.0);
        let bar_x = cx - bar_half_w;
        let bar_y = bar_y_max - bar_h;

        scene.add(Primitive::Rect {
            x: bar_x,
            y: bar_y,
            width: bar_half_w * 2.0,
            height: bar_h,
            fill: Color::from(&up.bar_color),
            stroke: None,
            stroke_width: None,
            opacity: None,
        });

        // Suppress count label when the column is too narrow to show it without overlap.
        // Each digit needs ~tick_size * 0.6 px; two-digit numbers need ~1.2 * tick_size.
        let min_col_for_label = computed.tick_size as f64 * 1.5;
        if up.show_counts && bar_h > 0.0 && dot_col_w >= min_col_for_label {
            scene.add(Primitive::Text {
                x: cx,
                y: bar_y - 2.0,
                content: format!("{}", inter.count),
                size: computed.tick_size,
                anchor: TextAnchor::Middle,
                rotate: None,
                bold: false,
                color: None,
            });
        }
    }

    // ── Dot matrix ───────────────────────────────────────────────────────────
    // Light horizontal separator lines.
    for j in 0..=n_sets {
        let y = mat_t + j as f64 * dot_row_h;
        scene.add(Primitive::Line {
            x1: mat_l,
            y1: y,
            x2: mat_r,
            y2: y,
            stroke: Color::from(&theme.grid_color),
            stroke_width: 0.5,
            stroke_dasharray: None,
        });
    }

    for (i, inter) in sorted.iter().enumerate() {
        let cx = mat_l + (i as f64 + 0.5) * dot_col_w;

        let filled_rows: Vec<usize> = (0..n_sets)
            .filter(|&j| inter.mask & (1u64 << j) != 0)
            .collect();

        // Connector line between the topmost and bottommost filled dots.
        if filled_rows.len() >= 2 {
            let top_j = *filled_rows
                .first()
                .expect("filled_rows.len() >= 2 guarantees first");
            let bot_j = *filled_rows
                .last()
                .expect("filled_rows.len() >= 2 guarantees last");
            let top_cy = mat_t + (top_j as f64 + 0.5) * dot_row_h;
            let bot_cy = mat_t + (bot_j as f64 + 0.5) * dot_row_h;
            scene.add(Primitive::Line {
                x1: cx,
                y1: top_cy,
                x2: cx,
                y2: bot_cy,
                stroke: Color::from(&up.dot_color),
                stroke_width: (dot_r * 0.5).max(2.0),
                stroke_dasharray: None,
            });
        }

        for j in 0..n_sets {
            let cy = mat_t + (j as f64 + 0.5) * dot_row_h;
            let filled = inter.mask & (1u64 << j) != 0;
            let fill = if filled {
                up.dot_color.clone()
            } else {
                up.dot_empty_color.clone()
            };
            scene.add(Primitive::Circle {
                cx,
                cy,
                r: dot_r,
                fill: fill.into(),
                fill_opacity: None,
                stroke: None,
                stroke_width: None,
            });
        }
    }
}

fn add_stacked_area(sa: &StackedAreaPlot, scene: &mut Scene, computed: &ComputedLayout) {
    if sa.x.is_empty() || sa.series.is_empty() {
        return;
    }
    let n = sa.x.len();

    // Precompute per-column totals for normalisation
    let totals: Vec<f64> = if sa.normalized {
        (0..n)
            .map(|i| {
                sa.series
                    .iter()
                    .map(|s| s.get(i).copied().unwrap_or(0.0))
                    .sum::<f64>()
            })
            .collect()
    } else {
        vec![1.0; n]
    };

    let scale = if sa.normalized { 100.0 } else { 1.0 };

    // cumulative baseline per column (grows as we draw each series)
    let mut lower: Vec<f64> = vec![0.0; n];

    for k in 0..sa.series.len() {
        let series = &sa.series[k];
        let color = sa.resolve_color(k).to_string();

        // Compute upper edge for this series
        let upper: Vec<f64> = (0..n)
            .map(|i| {
                let raw = series.get(i).copied().unwrap_or(0.0);
                let t = totals[i].max(f64::EPSILON);
                lower[i] + raw / t * scale
            })
            .collect();

        let mut path = String::with_capacity(n * 32);
        {
            let mut rb = ryu::Buffer::new();
            for (i, &x) in sa.x.iter().enumerate() {
                let sx = computed.map_x(x);
                let sy = computed.map_y(upper[i]);
                path.push(if i == 0 { 'M' } else { 'L' });
                path.push(' ');
                path.push_str(rb.format(round2(sx)));
                path.push(' ');
                path.push_str(rb.format(round2(sy)));
                path.push(' ');
            }
            for i in (0..n).rev() {
                let sx = computed.map_x(sa.x[i]);
                let sy = computed.map_y(lower[i]);
                path.push_str("L ");
                path.push_str(rb.format(round2(sx)));
                path.push(' ');
                path.push_str(rb.format(round2(sy)));
                path.push(' ');
            }
        }
        path.push('Z');

        scene.add(Primitive::Path(Box::new(PathData {
            d: path,
            fill: Some(Color::from(&color)),
            stroke: "none".into(),
            stroke_width: 0.0,
            opacity: Some(sa.fill_opacity),
            stroke_dasharray: None,
        })));

        if sa.show_strokes {
            let mut stroke_path = String::with_capacity(n * 16);
            let mut rb = ryu::Buffer::new();
            for (i, &x) in sa.x.iter().enumerate() {
                let sx = computed.map_x(x);
                let sy = computed.map_y(upper[i]);
                stroke_path.push(if i == 0 { 'M' } else { 'L' });
                stroke_path.push(' ');
                stroke_path.push_str(rb.format(round2(sx)));
                stroke_path.push(' ');
                stroke_path.push_str(rb.format(round2(sy)));
                stroke_path.push(' ');
            }
            scene.add(Primitive::Path(Box::new(PathData {
                d: stroke_path,
                fill: None,
                stroke: color.into(),
                stroke_width: sa.stroke_width,
                opacity: None,
                stroke_dasharray: None,
            })));
        }

        // Advance lower to current upper for the next series
        lower[..n].copy_from_slice(&upper[..n]);
    }
}

// ── Streamgraph ───────────────────────────────────────────────────────────────

/// Build a Catmull-Rom smooth closed SVG path for a stream band.
///
/// `upper_px` and `lower_px` are parallel slices of (pixel_x, pixel_y) pairs,
/// where `upper_px` goes left→right and `lower_px` goes left→right.
/// The returned path travels upper left→right then lower right→left and closes.
fn stream_band_path(upper_px: &[(f64, f64)], lower_px: &[(f64, f64)]) -> String {
    let n = upper_px.len();
    if n == 0 {
        return String::new();
    }

    let mut rb = ryu::Buffer::new();
    let mut path = String::with_capacity(n * 60);

    // Helper: append Catmull-Rom cubic bezier segment to path
    // P0=prev, P1=curr, P2=next, P3=after — produces one C command from P1→P2
    let append_cr = |path: &mut String,
                     p0: (f64, f64),
                     p1: (f64, f64),
                     p2: (f64, f64),
                     p3: (f64, f64),
                     rb: &mut ryu::Buffer| {
        let cp1x = p1.0 + (p2.0 - p0.0) / 6.0;
        let cp1y = p1.1 + (p2.1 - p0.1) / 6.0;
        let cp2x = p2.0 - (p3.0 - p1.0) / 6.0;
        let cp2y = p2.1 - (p3.1 - p1.1) / 6.0;
        path.push_str("C ");
        path.push_str(rb.format(round2(cp1x)));
        path.push(' ');
        path.push_str(rb.format(round2(cp1y)));
        path.push(' ');
        path.push_str(rb.format(round2(cp2x)));
        path.push(' ');
        path.push_str(rb.format(round2(cp2y)));
        path.push(' ');
        path.push_str(rb.format(round2(p2.0)));
        path.push(' ');
        path.push_str(rb.format(round2(p2.1)));
        path.push(' ');
    };

    // Move to first upper point
    path.push_str("M ");
    path.push_str(rb.format(round2(upper_px[0].0)));
    path.push(' ');
    path.push_str(rb.format(round2(upper_px[0].1)));
    path.push(' ');

    if n == 1 {
        // Single point — degenerate; just close
        path.push('Z');
        return path;
    }

    // Forward along upper edge
    for i in 0..n - 1 {
        let p0 = if i == 0 { upper_px[0] } else { upper_px[i - 1] };
        let p1 = upper_px[i];
        let p2 = upper_px[i + 1];
        let p3 = if i + 2 < n {
            upper_px[i + 2]
        } else {
            upper_px[n - 1]
        };
        append_cr(&mut path, p0, p1, p2, p3, &mut rb);
    }

    // Connect to last lower point
    path.push_str("L ");
    path.push_str(rb.format(round2(lower_px[n - 1].0)));
    path.push(' ');
    path.push_str(rb.format(round2(lower_px[n - 1].1)));
    path.push(' ');

    // Backward along lower edge
    for i in (0..n - 1).rev() {
        let p0 = if i + 2 < n {
            lower_px[i + 2]
        } else {
            lower_px[n - 1]
        };
        let p1 = lower_px[i + 1];
        let p2 = lower_px[i];
        let p3 = if i == 0 { lower_px[0] } else { lower_px[i - 1] };
        append_cr(&mut path, p0, p1, p2, p3, &mut rb);
    }

    path.push('Z');
    path
}

/// Build a Catmull-Rom stroke path (upper edge only, open).
fn stream_stroke_path(pts: &[(f64, f64)]) -> String {
    let n = pts.len();
    if n == 0 {
        return String::new();
    }

    let mut rb = ryu::Buffer::new();
    let mut path = String::with_capacity(n * 30);

    path.push_str("M ");
    path.push_str(rb.format(round2(pts[0].0)));
    path.push(' ');
    path.push_str(rb.format(round2(pts[0].1)));
    path.push(' ');

    for i in 0..n - 1 {
        let p0 = if i == 0 { pts[0] } else { pts[i - 1] };
        let p1 = pts[i];
        let p2 = pts[i + 1];
        let p3 = if i + 2 < n { pts[i + 2] } else { pts[n - 1] };
        let cp1x = p1.0 + (p2.0 - p0.0) / 6.0;
        let cp1y = p1.1 + (p2.1 - p0.1) / 6.0;
        let cp2x = p2.0 - (p3.0 - p1.0) / 6.0;
        let cp2y = p2.1 - (p3.1 - p1.1) / 6.0;
        path.push_str("C ");
        path.push_str(rb.format(round2(cp1x)));
        path.push(' ');
        path.push_str(rb.format(round2(cp1y)));
        path.push(' ');
        path.push_str(rb.format(round2(cp2x)));
        path.push(' ');
        path.push_str(rb.format(round2(cp2y)));
        path.push(' ');
        path.push_str(rb.format(round2(p2.0)));
        path.push(' ');
        path.push_str(rb.format(round2(p2.1)));
        path.push(' ');
    }
    path
}

fn add_streamgraph(
    sg: &crate::plot::streamgraph::StreamgraphPlot,
    scene: &mut Scene,
    computed: &ComputedLayout,
) {
    let geom = match sg.compute_geometry() {
        Some(g) => g,
        None => return,
    };

    let n_pts = sg.x.len();
    let n_streams = geom.render_order.len();

    for k in 0..n_streams {
        let orig_idx = geom.render_order[k];
        let color = sg.resolve_color(orig_idx).to_string();

        // Build pixel-space point arrays
        let upper_px: Vec<(f64, f64)> = (0..n_pts)
            .map(|i| (computed.map_x(sg.x[i]), computed.map_y(geom.uppers[k][i])))
            .collect();
        let lower_px: Vec<(f64, f64)> = (0..n_pts)
            .map(|i| (computed.map_x(sg.x[i]), computed.map_y(geom.lowers[k][i])))
            .collect();

        // Filled band
        let path_d = if sg.smooth {
            stream_band_path(&upper_px, &lower_px)
        } else {
            // Linear path
            let mut d = String::with_capacity(n_pts * 32);
            let mut rb = ryu::Buffer::new();
            for (i, &(px, py)) in upper_px.iter().enumerate() {
                d.push(if i == 0 { 'M' } else { 'L' });
                d.push(' ');
                d.push_str(rb.format(round2(px)));
                d.push(' ');
                d.push_str(rb.format(round2(py)));
                d.push(' ');
            }
            for &(px, py) in lower_px.iter().rev() {
                d.push_str("L ");
                d.push_str(rb.format(round2(px)));
                d.push(' ');
                d.push_str(rb.format(round2(py)));
                d.push(' ');
            }
            d.push('Z');
            d
        };

        scene.add(Primitive::Path(Box::new(PathData {
            d: path_d,
            fill: Some(Color::from(&color)),
            stroke: "none".into(),
            stroke_width: 0.0,
            opacity: Some(sg.fill_opacity),
            stroke_dasharray: None,
        })));

        // Optional inter-stream stroke along the upper edge
        if sg.stroke_between {
            let stroke_d = if sg.smooth {
                stream_stroke_path(&upper_px)
            } else {
                let mut d = String::with_capacity(n_pts * 20);
                let mut rb = ryu::Buffer::new();
                for (i, &(px, py)) in upper_px.iter().enumerate() {
                    d.push(if i == 0 { 'M' } else { 'L' });
                    d.push(' ');
                    d.push_str(rb.format(round2(px)));
                    d.push(' ');
                    d.push_str(rb.format(round2(py)));
                    d.push(' ');
                }
                d
            };
            scene.add(Primitive::Path(Box::new(PathData {
                d: stroke_d,
                fill: None,
                stroke: "white".into(),
                stroke_width: sg.stroke_width,
                opacity: None,
                stroke_dasharray: None,
            })));
        }
    }

    // Inline stream labels — drawn on top after all fills
    if sg.show_labels {
        for k in 0..n_streams {
            let orig_idx = geom.render_order[k];
            let label = match sg.labels.get(orig_idx).and_then(|l| l.as_ref()) {
                Some(l) => l.clone(),
                None => continue,
            };

            let font_size = computed.body_size as f64;
            // Estimated half-width of the label (middle-anchored).
            let half_text_w = label.len() as f64 * font_size * 0.60 / 2.0 + 4.0;
            let plot_left_px = computed.margin_left;
            let plot_right_px = computed.width - computed.margin_right;

            // Minimum band height (px) for the label to fit vertically.
            let min_h = (font_size * 1.3 + 4.0).max(sg.min_label_height);

            // Exclude the first and last data points (stream has no fill there),
            // and restrict to the inner 80 % of the x range so labels are never
            // placed near the tapering edges where the band may shift away
            // from the label y level.
            let inner_lo = (n_pts as f64 * 0.10).ceil() as usize;
            let inner_hi = (n_pts as f64 * 0.90).floor() as usize;
            let search_start = inner_lo.max(if n_pts > 2 { 1 } else { 0 });
            let search_end = inner_hi.min(if n_pts > 2 { n_pts - 1 } else { n_pts });

            // Pick the index with the tallest band in that window.
            let max_idx_opt = (search_start..search_end).max_by(|&a, &b| {
                let ha = geom.uppers[k][a] - geom.lowers[k][a];
                let hb = geom.uppers[k][b] - geom.lowers[k][b];
                ha.partial_cmp(&hb).unwrap_or(std::cmp::Ordering::Equal)
            });
            let max_idx = match max_idx_opt {
                Some(i) => i,
                None => continue,
            };

            let lower_y = computed.map_y(geom.lowers[k][max_idx]);
            let upper_y = computed.map_y(geom.uppers[k][max_idx]);
            let height_px = (lower_y - upper_y).abs();
            if height_px < min_h {
                continue;
            }

            // Place label at that x, then clamp so it stays within plot bounds.
            let raw_x = computed.map_x(sg.x[max_idx]);
            let mid_x = raw_x
                .max(plot_left_px + half_text_w)
                .min(plot_right_px - half_text_w);
            let mid_y = (lower_y + upper_y) / 2.0 + font_size * 0.35;

            // Choose text color: white for dark fills, dark for light
            let txt_color = choose_label_color(sg.resolve_color(orig_idx));

            scene.add(Primitive::Text {
                x: mid_x,
                y: mid_y,
                content: label,
                size: computed.body_size,
                color: Some(Color::from(txt_color)),
                anchor: TextAnchor::Middle,
                bold: false,
                rotate: None,
            });
        }
    }
}

/// Return a contrasting text color (white or near-black) for a CSS fill color.
fn choose_label_color(css: &str) -> &'static str {
    // Parse approximate luminance from well-known colors; default to white
    let dark_fills = [
        "steelblue",
        "cornflowerblue",
        "mediumpurple",
        "orchid",
        "peru",
        "tomato",
        "coral",
        "goldenrod",
        "mediumseagreen",
        "lightslategray",
    ];
    if dark_fills.contains(&css) {
        "white"
    } else if css.starts_with('#') && css.len() == 7 {
        // Rough luminance from hex
        let r = u8::from_str_radix(&css[1..3], 16).unwrap_or(128) as f64;
        let g = u8::from_str_radix(&css[3..5], 16).unwrap_or(128) as f64;
        let b = u8::from_str_radix(&css[5..7], 16).unwrap_or(128) as f64;
        let lum = 0.299 * r + 0.587 * g + 0.114 * b;
        if lum < 140.0 {
            "white"
        } else {
            "#333333"
        }
    } else {
        "white"
    }
}

fn add_candlestick(cp: &CandlestickPlot, scene: &mut Scene, computed: &ComputedLayout) {
    if cp.candles.is_empty() {
        return;
    }

    let continuous = cp.candles.iter().any(|c| c.x.is_some());
    let n = cp.candles.len();

    // Compute slot pixel width for body sizing
    let slot_px = if continuous {
        if n > 1 {
            // Use average spacing between consecutive candles
            let xs: Vec<f64> = cp.candles.iter().filter_map(|c| c.x).collect();
            if xs.len() > 1 {
                let span = xs[xs.len() - 1] - xs[0];
                let avg_spacing = span / (xs.len() - 1) as f64;
                computed.map_x(computed.x_range.0 + avg_spacing)
                    - computed.map_x(computed.x_range.0)
            } else {
                computed.plot_width()
            }
        } else {
            computed.plot_width()
        }
    } else {
        // categorical: width of one slot = map_x(1.5) - map_x(0.5)
        computed.map_x(1.5) - computed.map_x(0.5)
    };
    let body_w = slot_px * cp.candle_width;

    // Price panel bottom pixel coordinate
    let price_bottom_px = if cp.show_volume {
        computed.margin_top + computed.plot_height() * (1.0 - cp.volume_ratio) - 4.0
    } else {
        computed.margin_top + computed.plot_height()
    };

    let y_min = computed.y_range.0;
    let y_max = computed.y_range.1;
    let map_y_price = |v: f64| -> f64 {
        let t = (y_max - v) / (y_max - y_min);
        computed.margin_top + t * (price_bottom_px - computed.margin_top)
    };

    let candle_color = |c: &CandleDataPoint| -> &str {
        if c.close > c.open {
            &cp.color_up
        } else if c.close < c.open {
            &cp.color_down
        } else {
            &cp.color_doji
        }
    };

    for (i, candle) in cp.candles.iter().enumerate() {
        let x_val = if continuous {
            candle.x.unwrap_or(i as f64 + 1.0)
        } else {
            i as f64 + 1.0
        };
        let x_center = computed.map_x(x_val);
        let color = candle_color(candle).to_string();

        let tip = tooltip(cp.show_tooltips, &cp.tooltip_labels, i, || {
            format!(
                "{}\nO:{:.2} H:{:.2} L:{:.2} C:{:.2}",
                candle.label, candle.open, candle.high, candle.low, candle.close
            )
        });
        if let Some(ref t) = tip {
            scene.add(Primitive::GroupStart {
                transform: None,
                title: Some(t.clone()),
                extra_attrs: None,
            });
        }

        // Wick
        scene.add(Primitive::Line {
            x1: x_center,
            y1: map_y_price(candle.high),
            x2: x_center,
            y2: map_y_price(candle.low),
            stroke: Color::from(&color),
            stroke_width: cp.wick_width,
            stroke_dasharray: None,
        });

        // Body
        let body_top = map_y_price(candle.open.max(candle.close));
        let body_bottom = map_y_price(candle.open.min(candle.close));
        let body_h = (body_bottom - body_top).max(1.0);
        scene.add(Primitive::Rect {
            x: x_center - body_w / 2.0,
            y: body_top,
            width: body_w,
            height: body_h,
            fill: Color::from(&color),
            stroke: Some(Color::from(&color)),
            stroke_width: Some(0.5),
            opacity: None,
        });

        if tip.is_some() {
            scene.add(Primitive::GroupEnd);
        }
    }

    // Volume panel
    if cp.show_volume {
        let vol_panel_top = price_bottom_px + 4.0;
        let vol_panel_bottom = computed.margin_top + computed.plot_height() - 2.0;
        let vol_panel_h = vol_panel_bottom - vol_panel_top;

        let vol_max = cp
            .candles
            .iter()
            .filter_map(|c| c.volume)
            .fold(0.0_f64, f64::max);

        if vol_max > 0.0 {
            for (i, candle) in cp.candles.iter().enumerate() {
                if let Some(vol) = candle.volume {
                    let x_val = if continuous {
                        candle.x.unwrap_or(i as f64 + 1.0)
                    } else {
                        i as f64 + 1.0
                    };
                    let x_center = computed.map_x(x_val);
                    let color = candle_color(candle).to_string();
                    let bar_h = (vol / vol_max) * vol_panel_h;
                    scene.add(Primitive::Rect {
                        x: x_center - body_w / 2.0,
                        y: vol_panel_bottom - bar_h,
                        width: body_w,
                        height: bar_h,
                        fill: color.into(),
                        stroke: None,
                        stroke_width: None,
                        opacity: Some(0.5),
                    });
                }
            }
        }
    }
}

// ── Contour / Marching-Squares ─────────────────────────────────────────────

/// Build the SVG path string for one iso-level using marching squares.
/// Emits individual "M x1 y1 L x2 y2" segments — no stitching needed.
///
/// Grid convention: z[row][col], x_coords[col], y_coords[row].
/// Cell (col, row) has corners TL=(col,row) TR=(col+1,row) BR=(col+1,row+1) BL=(col,row+1).
fn contour_path(
    z: &[Vec<f64>],
    x_coords: &[f64],
    y_coords: &[f64],
    t: f64,
    computed: &ComputedLayout,
) -> String {
    let rows = z.len();
    if rows < 2 {
        return String::new();
    }
    let cols = z[0].len();
    if cols < 2 {
        return String::new();
    }

    let mut d = String::new();

    // Crossing on the horizontal edge between corner (col, row) and (col+1, row).
    // x interpolates between x_coords[col] and x_coords[col+1]; y is fixed at y_coords[row].
    let h = |col: usize, row: usize| -> (f64, f64) {
        let va = z[row][col];
        let vb = z[row][col + 1];
        let frac = if (vb - va).abs() < 1e-12 {
            0.5
        } else {
            ((t - va) / (vb - va)).clamp(0.0, 1.0)
        };
        let wx = x_coords[col] + frac * (x_coords[col + 1] - x_coords[col]);
        (computed.map_x(wx), computed.map_y(y_coords[row]))
    };

    // Crossing on the vertical edge between corner (col, row) and (col, row+1).
    // x is fixed at x_coords[col]; y interpolates between y_coords[row] and y_coords[row+1].
    let v = |col: usize, row: usize| -> (f64, f64) {
        let va = z[row][col];
        let vb = z[row + 1][col];
        let frac = if (vb - va).abs() < 1e-12 {
            0.5
        } else {
            ((t - va) / (vb - va)).clamp(0.0, 1.0)
        };
        let wy = y_coords[row] + frac * (y_coords[row + 1] - y_coords[row]);
        (computed.map_x(x_coords[col]), computed.map_y(wy))
    };

    let mut seg = |p1: (f64, f64), p2: (f64, f64)| {
        let _ = write!(d, "M{:.2} {:.2} L{:.2} {:.2} ", p1.0, p1.1, p2.0, p2.1);
    };

    for row in 0..rows - 1 {
        for col in 0..cols - 1 {
            let tl = z[row][col];
            let tr = z[row][col + 1];
            let br = z[row + 1][col + 1];
            let bl = z[row + 1][col];

            // Marching squares case: TL=8, TR=4, BR=2, BL=1
            let case = ((tl >= t) as u8) * 8
                + ((tr >= t) as u8) * 4
                + ((br >= t) as u8) * 2
                + ((bl >= t) as u8);

            // Canonical edge names for this cell:
            //   top    = h(col, row)       between TL and TR
            //   bottom = h(col, row+1)     between BL and BR
            //   left   = v(col, row)       between TL and BL
            //   right  = v(col+1, row)     between TR and BR
            let avg = (tl + tr + br + bl) / 4.0;

            match case {
                0 | 15 => {}
                1 => seg(v(col, row), h(col, row + 1)), // left → bottom
                2 => seg(h(col, row + 1), v(col + 1, row)), // bottom → right
                3 => seg(v(col, row), v(col + 1, row)), // left → right
                4 => seg(h(col, row), v(col + 1, row)), // top → right
                5 => {
                    if avg >= t {
                        seg(h(col, row), v(col, row)); // top → left
                        seg(h(col, row + 1), v(col + 1, row)); // bottom → right
                    } else {
                        seg(h(col, row), v(col + 1, row)); // top → right
                        seg(h(col, row + 1), v(col, row)); // bottom → left
                    }
                }
                6 => seg(h(col, row), h(col, row + 1)), // top → bottom
                7 => seg(h(col, row), v(col, row)),     // top → left
                8 => seg(h(col, row), v(col, row)),     // top → left
                9 => seg(h(col, row), h(col, row + 1)), // top → bottom
                10 => {
                    if avg >= t {
                        seg(h(col, row), v(col + 1, row)); // top → right
                        seg(h(col, row + 1), v(col, row)); // bottom → left
                    } else {
                        seg(h(col, row), v(col, row)); // top → left
                        seg(h(col, row + 1), v(col + 1, row)); // bottom → right
                    }
                }
                11 => seg(h(col, row), v(col + 1, row)), // top → right
                12 => seg(v(col, row), v(col + 1, row)), // left → right
                13 => seg(v(col + 1, row), h(col, row + 1)), // right → bottom
                14 => seg(v(col, row), h(col, row + 1)), // left → bottom
                _ => {}
            }
        }
    }

    d
}

/// Build the SVG path string for the filled region {z >= t}, one closed polygon per cell piece.
#[allow(non_snake_case)]
/// Uses the per-cell marching-squares polygon table so boundaries follow the iso-line exactly.
fn contour_fill_path(
    z: &[Vec<f64>],
    x_coords: &[f64],
    y_coords: &[f64],
    t: f64,
    computed: &ComputedLayout,
) -> String {
    let rows = z.len();
    if rows < 2 {
        return String::new();
    }
    let cols = z[0].len();
    if cols < 2 {
        return String::new();
    }

    let mut d = String::new();

    // Horizontal edge crossing between corner (col, row) and (col+1, row).
    let h = |col: usize, row: usize| -> (f64, f64) {
        let va = z[row][col];
        let vb = z[row][col + 1];
        let frac = if (vb - va).abs() < 1e-12 {
            0.5
        } else {
            ((t - va) / (vb - va)).clamp(0.0, 1.0)
        };
        let wx = x_coords[col] + frac * (x_coords[col + 1] - x_coords[col]);
        (computed.map_x(wx), computed.map_y(y_coords[row]))
    };

    // Vertical edge crossing between corner (col, row) and (col, row+1).
    let v = |col: usize, row: usize| -> (f64, f64) {
        let va = z[row][col];
        let vb = z[row + 1][col];
        let frac = if (vb - va).abs() < 1e-12 {
            0.5
        } else {
            ((t - va) / (vb - va)).clamp(0.0, 1.0)
        };
        let wy = y_coords[row] + frac * (y_coords[row + 1] - y_coords[row]);
        (computed.map_x(x_coords[col]), computed.map_y(wy))
    };

    let mut poly = |verts: &[(f64, f64)]| {
        if verts.len() < 3 {
            return;
        }
        let _ = write!(d, "M{:.2} {:.2}", verts[0].0, verts[0].1);
        for &(x, y) in &verts[1..] {
            let _ = write!(d, " L{:.2} {:.2}", x, y);
        }
        d.push_str(" Z ");
    };

    for row in 0..rows - 1 {
        for col in 0..cols - 1 {
            let tl = z[row][col];
            let tr = z[row][col + 1];
            let br = z[row + 1][col + 1];
            let bl = z[row + 1][col];

            // Marching squares case: TL=8, TR=4, BR=2, BL=1.
            let case = ((tl >= t) as u8) * 8
                + ((tr >= t) as u8) * 4
                + ((br >= t) as u8) * 2
                + ((bl >= t) as u8);

            // Pixel corners.
            let tl_p = (computed.map_x(x_coords[col]), computed.map_y(y_coords[row]));
            let tr_p = (
                computed.map_x(x_coords[col + 1]),
                computed.map_y(y_coords[row]),
            );
            let br_p = (
                computed.map_x(x_coords[col + 1]),
                computed.map_y(y_coords[row + 1]),
            );
            let bl_p = (
                computed.map_x(x_coords[col]),
                computed.map_y(y_coords[row + 1]),
            );

            // Edge crossings.
            //   T = top edge   h(col,   row)   between TL and TR
            //   B = bot edge   h(col,   row+1) between BL and BR
            //   L = left edge  v(col,   row)   between TL and BL
            //   R = right edge v(col+1, row)   between TR and BR
            let avg = (tl + tr + br + bl) / 4.0;

            match case {
                0 => {}
                // One corner above — triangle.
                1 => {
                    let (L, B) = (v(col, row), h(col, row + 1));
                    poly(&[bl_p, L, B]);
                }
                2 => {
                    let (B, R) = (h(col, row + 1), v(col + 1, row));
                    poly(&[br_p, B, R]);
                }
                4 => {
                    let (T, R) = (h(col, row), v(col + 1, row));
                    poly(&[tr_p, T, R]);
                }
                8 => {
                    let (T, L) = (h(col, row), v(col, row));
                    poly(&[tl_p, T, L]);
                }
                // Two adjacent corners above — trapezoid/quad.
                3 => {
                    let (L, R) = (v(col, row), v(col + 1, row));
                    poly(&[bl_p, br_p, R, L]);
                }
                6 => {
                    let (T, B) = (h(col, row), h(col, row + 1));
                    poly(&[T, tr_p, br_p, B]);
                }
                9 => {
                    let (T, B) = (h(col, row), h(col, row + 1));
                    poly(&[tl_p, T, B, bl_p]);
                }
                12 => {
                    let (L, R) = (v(col, row), v(col + 1, row));
                    poly(&[tl_p, tr_p, R, L]);
                }
                // Two diagonal corners above — saddle: resolve with cell average.
                5 => {
                    let (T, B, L, R) = (h(col, row), h(col, row + 1), v(col, row), v(col + 1, row));
                    if avg >= t {
                        // Connected region (hourglass through centre): one hexagon.
                        poly(&[bl_p, L, T, tr_p, R, B]);
                    } else {
                        // Two separate triangles.
                        poly(&[bl_p, L, B]);
                        poly(&[tr_p, T, R]);
                    }
                }
                10 => {
                    let (T, B, L, R) = (h(col, row), h(col, row + 1), v(col, row), v(col + 1, row));
                    if avg >= t {
                        poly(&[tl_p, T, R, br_p, B, L]);
                    } else {
                        poly(&[tl_p, T, L]);
                        poly(&[br_p, B, R]);
                    }
                }
                // Three corners above — pentagon wrapping the single below corner.
                7 => {
                    let (T, L) = (h(col, row), v(col, row));
                    poly(&[T, tr_p, br_p, bl_p, L]);
                }
                11 => {
                    let (T, R) = (h(col, row), v(col + 1, row));
                    poly(&[T, tl_p, bl_p, br_p, R]);
                }
                13 => {
                    let (R, B) = (v(col + 1, row), h(col, row + 1));
                    poly(&[tl_p, tr_p, R, B, bl_p]);
                }
                14 => {
                    let (L, B) = (v(col, row), h(col, row + 1));
                    poly(&[L, tl_p, tr_p, br_p, B]);
                }
                // All four above — full cell.
                15 => {
                    poly(&[tl_p, tr_p, br_p, bl_p]);
                }
                _ => {}
            }
        }
    }

    d
}

fn add_contour(cp: &ContourPlot, scene: &mut Scene, computed: &ComputedLayout) {
    if cp.z.is_empty() || cp.x_coords.len() < 2 || cp.y_coords.len() < 2 {
        return;
    }

    let levels = cp.effective_levels();
    if levels.is_empty() {
        return;
    }

    let (z_min, z_max) = cp.z_range();
    let z_span = z_max - z_min + f64::EPSILON;

    let level_color = |level: f64| -> String {
        let norm = (level - z_min) / z_span;
        cp.color_map.map(norm.clamp(0.0, 1.0))
    };

    if cp.filled {
        // Painter's algorithm with per-cell marching-squares polygons.
        // 1. Fill the entire grid extent with the minimum colormap colour (base layer).
        let x0_d = cp.x_coords.iter().cloned().fold(f64::INFINITY, f64::min);
        let x1_d = cp
            .x_coords
            .iter()
            .cloned()
            .fold(f64::NEG_INFINITY, f64::max);
        let y0_d = cp.y_coords.iter().cloned().fold(f64::INFINITY, f64::min);
        let y1_d = cp
            .y_coords
            .iter()
            .cloned()
            .fold(f64::NEG_INFINITY, f64::max);
        let px0 = computed.map_x(x0_d).min(computed.map_x(x1_d));
        let px1 = computed.map_x(x0_d).max(computed.map_x(x1_d));
        let py0 = computed.map_y(y0_d).min(computed.map_y(y1_d));
        let py1 = computed.map_y(y0_d).max(computed.map_y(y1_d));
        scene.add(Primitive::Rect {
            x: px0,
            y: py0,
            width: px1 - px0,
            height: py1 - py0,
            fill: cp.color_map.map(0.0).into(),
            stroke: None,
            stroke_width: None,
            opacity: None,
        });

        // 2. For each level (lowest → highest), fill the {z ≥ level} region.
        //    Higher levels overdraw lower ones, leaving the correct colour per band.
        for &lvl in &levels {
            let color = level_color(lvl);
            let d = contour_fill_path(&cp.z, &cp.x_coords, &cp.y_coords, lvl, computed);
            if d.is_empty() {
                continue;
            }
            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: Some(color.into()),
                stroke: "none".into(),
                stroke_width: 0.0,
                opacity: None,
                stroke_dasharray: None,
            })));
        }

        // 3. Draw iso-lines on top.
        for &lvl in &levels {
            let stroke = cp.line_color.clone().unwrap_or_else(|| "black".to_string());
            let d = contour_path(&cp.z, &cp.x_coords, &cp.y_coords, lvl, computed);
            if d.is_empty() {
                continue;
            }
            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: None,
                stroke: stroke.into(),
                stroke_width: cp.line_width,
                opacity: None,
                stroke_dasharray: None,
            })));
        }
    } else {
        // Lines-only mode: one path per level, colored by the colormap (or fixed line_color).
        for &lvl in &levels {
            let stroke = cp.line_color.clone().unwrap_or_else(|| level_color(lvl));
            let d = contour_path(&cp.z, &cp.x_coords, &cp.y_coords, lvl, computed);
            if d.is_empty() {
                continue;
            }
            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: None,
                stroke: stroke.into(),
                stroke_width: cp.line_width,
                opacity: None,
                stroke_dasharray: None,
            })));
        }
    }
}

fn add_chord(chord: &ChordPlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::render::palette::Palette;
    use std::f64::consts::TAU;

    let n = chord.n_nodes();
    if n == 0 {
        return;
    }

    // Fallback palette
    let fallback = Palette::category10();
    let node_color = |i: usize| -> String {
        if let Some(c) = chord.colors.get(i) {
            if !c.is_empty() {
                return c.clone();
            }
        }
        fallback[i % fallback.len()].to_string()
    };

    // Geometry
    let label_margin = computed.body_size as f64 * 2.5;
    let outer_r =
        (computed.plot_width().min(computed.plot_height()) / 2.0 - label_margin).max(10.0);
    let inner_r = outer_r * chord.pad_fraction;
    let cx = computed.margin_left + computed.plot_width() / 2.0;
    let cy = computed.margin_top + computed.plot_height() / 2.0;

    // Row totals and grand total
    let row_total: Vec<f64> = chord.matrix.iter().map(|row| row.iter().sum()).collect();
    let grand_total: f64 = row_total.iter().sum();
    if grand_total <= 0.0 {
        return;
    }

    let gap_rad = chord.gap_degrees.to_radians();
    let usable = TAU - n as f64 * gap_rad;

    // Start angle for each node arc
    let mut node_start = Vec::with_capacity(n);
    let mut node_span = Vec::with_capacity(n);
    let mut angle = -std::f64::consts::FRAC_PI_2; // start at top
    for &total in &row_total {
        node_start.push(angle);
        let span = if grand_total > 0.0 {
            (total / grand_total) * usable
        } else {
            usable / n as f64
        };
        node_span.push(span);
        angle += span + gap_rad;
    }

    // Helper: SVG arc flag
    let la = |sweep: f64| if sweep > std::f64::consts::PI { 1 } else { 0 };

    // ── Draw outer arc segments (donut slices) ──
    for i in 0..n {
        let a0 = node_start[i];
        let a1 = a0 + node_span[i];
        let x1o = cx + outer_r * a0.cos();
        let y1o = cy + outer_r * a0.sin();
        let x2o = cx + outer_r * a1.cos();
        let y2o = cy + outer_r * a1.sin();
        let x2i = cx + inner_r * a1.cos();
        let y2i = cy + inner_r * a1.sin();
        let x1i = cx + inner_r * a0.cos();
        let y1i = cy + inner_r * a0.sin();
        let laf = la(node_span[i]);

        let d = format!(
            "M {x1o} {y1o} A {outer_r} {outer_r} 0 {laf} 1 {x2o} {y2o} \
             L {x2i} {y2i} A {inner_r} {inner_r} 0 {laf} 0 {x1i} {y1i} Z"
        );
        let color = node_color(i);
        scene.add(Primitive::Path(Box::new(PathData {
            d,
            fill: Some(color.into()),
            stroke: "none".into(),
            stroke_width: 0.0,
            opacity: None,
            stroke_dasharray: None,
        })));
    }

    // ── Draw labels ──
    // Place labels just outside the arc. Anchor to Start (right side) or End
    // (left side) so text always extends away from the arc regardless of length.
    let label_gap = outer_r + computed.body_size as f64 * 1.6;
    for i in 0..n {
        let mid = node_start[i] + node_span[i] / 2.0;
        let lx = cx + label_gap * mid.cos();
        let ly = cy + label_gap * mid.sin() + computed.body_size as f64 * 0.35;

        let label = if let Some(l) = chord.labels.get(i) {
            l.clone()
        } else {
            format!("{i}")
        };
        let anchor = if mid.cos() >= 0.0 {
            TextAnchor::Start
        } else {
            TextAnchor::End
        };

        scene.add(Primitive::Text {
            x: lx,
            y: ly,
            content: label,
            size: computed.body_size,
            anchor,
            rotate: None,
            bold: false,
            color: None,
        });
    }

    // ── Draw ribbons ──
    // Sub-arc cursor: for each node, track how much inner arc has been consumed.
    // Allocate sub-arc widths for each cell [i][j]
    // We need full forward pass to build all ribbon endpoints before drawing.
    // Store as: sub_start[i][j] = angle where node i's sub-arc for ribbon (i,j) starts
    let mut sub_start = vec![vec![0.0f64; n]; n];
    {
        // Reset cursors
        let mut cursors = node_start.clone();
        #[allow(clippy::needless_range_loop)]
        for i in 0..n {
            for j in 0..n {
                sub_start[i][j] = cursors[i];
                let flow = chord
                    .matrix
                    .get(i)
                    .and_then(|r| r.get(j))
                    .copied()
                    .unwrap_or(0.0);
                if grand_total > 0.0 {
                    cursors[i] += (flow / grand_total) * usable;
                }
            }
        }
    }

    #[allow(clippy::needless_range_loop)]
    for i in 0..n {
        for j in 0..=i {
            let flow_ij = chord
                .matrix
                .get(i)
                .and_then(|r| r.get(j))
                .copied()
                .unwrap_or(0.0);
            let flow_ji = chord
                .matrix
                .get(j)
                .and_then(|r| r.get(i))
                .copied()
                .unwrap_or(0.0);

            // Self-loops
            if i == j {
                if flow_ij <= 0.0 {
                    continue;
                }
                let a0 = sub_start[i][j];
                let span = if grand_total > 0.0 {
                    (flow_ij / grand_total) * usable
                } else {
                    0.0
                };
                if span <= 0.0 {
                    continue;
                }
                let a1 = a0 + span;
                let laf = la(span);
                let x1 = cx + inner_r * a0.cos();
                let y1 = cy + inner_r * a0.sin();
                let x2 = cx + inner_r * a1.cos();
                let y2 = cy + inner_r * a1.sin();
                let d = format!(
                    "M {x1} {y1} A {inner_r} {inner_r} 0 {laf} 1 {x2} {y2} \
                     C {cx} {cy} {cx} {cy} {x1} {y1} Z"
                );
                scene.add(Primitive::Path(Box::new(PathData {
                    d,
                    fill: Some(node_color(i).into()),
                    stroke: "none".into(),
                    stroke_width: 0.0,
                    opacity: Some(chord.ribbon_opacity),
                    stroke_dasharray: None,
                })));
                continue;
            }

            if flow_ij <= 0.0 && flow_ji <= 0.0 {
                continue;
            }

            // Node i sub-arc for ribbon (i→j)
            let a_i0 = sub_start[i][j];
            let span_i = if grand_total > 0.0 {
                (flow_ij / grand_total) * usable
            } else {
                0.0
            };
            let a_i1 = a_i0 + span_i;

            // Node j sub-arc for ribbon (j→i)
            let a_j0 = sub_start[j][i];
            let span_j = if grand_total > 0.0 {
                (flow_ji / grand_total) * usable
            } else {
                0.0
            };
            let a_j1 = a_j0 + span_j;

            let xi1 = cx + inner_r * a_i0.cos();
            let yi1 = cy + inner_r * a_i0.sin();
            let xi2 = cx + inner_r * a_i1.cos();
            let yi2 = cy + inner_r * a_i1.sin();
            let xj1 = cx + inner_r * a_j0.cos();
            let yj1 = cy + inner_r * a_j0.sin();
            let xj2 = cx + inner_r * a_j1.cos();
            let yj2 = cy + inner_r * a_j1.sin();

            let laf_i = la(span_i);
            let laf_j = la(span_j);

            let d = format!(
                "M {xi1} {yi1} \
                 A {inner_r} {inner_r} 0 {laf_i} 1 {xi2} {yi2} \
                 C {cx} {cy} {cx} {cy} {xj1} {yj1} \
                 A {inner_r} {inner_r} 0 {laf_j} 1 {xj2} {yj2} \
                 C {cx} {cy} {cx} {cy} {xi1} {yi1} Z"
            );

            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: Some(node_color(i).into()),
                stroke: "none".into(),
                stroke_width: 0.0,
                opacity: Some(chord.ribbon_opacity),
                stroke_dasharray: None,
            })));
        }
    }
}
// Legend for ChordPlot is handled by collect_legend_entries + render_multiple.

fn wompwomp_default_colors() -> Vec<String> {
    [
        "#D55E00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#E69F00", "#CC79A7", "#666666",
        "#AD7700", "#1C91D4", "#007756", "#D5C711", "#005685", "#A04700", "#B14380", "#4D4D4D",
        "#FFBE2D", "#80C7EF", "#00F6B3", "#F4EB71", "#06A5FF", "#FF8320", "#D99BBD", "#8C8C8C",
        "#FFCB57", "#9AD2F2", "#2CFFC6", "#F6EF8E", "#38B7FF", "#FF9B4D", "#E0AFCA", "#A3A3A3",
        "#8A5F00", "#1674A9", "#005F45", "#AA9F0D", "#00446B", "#803800", "#8D3666", "#3D3D3D",
    ]
    .into_iter()
    .map(str::to_string)
    .collect()
}

fn sankey_palette_colors(sankey: &SankeyPlot) -> Vec<String> {
    if let Some(colors) = &sankey.palette {
        return colors.clone();
    }
    match sankey.node_coloring {
        SankeyNodeColoring::Left => wompwomp_default_colors(),
        SankeyNodeColoring::Label => {
            let fallback = Palette::category10();
            fallback.colors().to_vec()
        }
    }
}

fn resolve_label_colors(sankey: &SankeyPlot, palette: &[String]) -> Vec<String> {
    let mut label_colors: HashMap<&str, String> = HashMap::new();
    let mut next_color = 0usize;
    for node in &sankey.nodes {
        label_colors.entry(&node.label).or_insert_with(|| {
            node.color.clone().unwrap_or_else(|| {
                let color = palette[next_color % palette.len()].clone();
                next_color += 1;
                color
            })
        });
    }
    sankey
        .nodes
        .iter()
        .map(|node| {
            node.color.clone().unwrap_or_else(|| {
                label_colors
                    .get(node.label.as_str())
                    .cloned()
                    .unwrap_or_else(|| palette[0].clone())
            })
        })
        .collect()
}

fn display_axes(sankey: &SankeyPlot, nodes_in_col: &[Vec<usize>]) -> Vec<Option<usize>> {
    nodes_in_col
        .iter()
        .map(|members| members.first().and_then(|&idx| sankey.nodes[idx].column))
        .collect()
}

fn left_pair_weights(
    sankey: &SankeyPlot,
    left_members: &[usize],
    right_members: &[usize],
    left_axis: Option<usize>,
    right_axis: Option<usize>,
) -> HashMap<(usize, usize), f64> {
    let mut weights = HashMap::new();
    if !sankey.alluvia.is_empty() {
        if let (Some(left_axis), Some(right_axis)) = (left_axis, right_axis) {
            for alluvium in &sankey.alluvia {
                if left_axis < alluvium.nodes.len() && right_axis < alluvium.nodes.len() {
                    let src = alluvium.nodes[left_axis];
                    let dst = alluvium.nodes[right_axis];
                    if left_members.contains(&src) && right_members.contains(&dst) {
                        *weights.entry((src, dst)).or_insert(0.0) += alluvium.value;
                    }
                }
            }
        }
        return weights;
    }

    for link in &sankey.links {
        if left_members.contains(&link.source) && right_members.contains(&link.target) {
            *weights.entry((link.source, link.target)).or_insert(0.0) += link.value;
        }
    }
    weights
}

fn resolve_left_colors(
    sankey: &SankeyPlot,
    nodes_in_col: &[Vec<usize>],
    palette: &[String],
) -> Vec<String> {
    let mut colors: Vec<Option<String>> = vec![None; sankey.nodes.len()];
    for (idx, node) in sankey.nodes.iter().enumerate() {
        if let Some(color) = &node.color {
            colors[idx] = Some(color.clone());
        }
    }
    if nodes_in_col.is_empty() {
        return colors
            .into_iter()
            .map(|c| c.unwrap_or_else(|| palette[0].clone()))
            .collect();
    }

    let mut next_color_idx = 0usize;
    for &node_idx in &nodes_in_col[0] {
        if colors[node_idx].is_none() {
            colors[node_idx] = Some(palette[next_color_idx % palette.len()].clone());
            next_color_idx += 1;
        }
    }

    let axes = display_axes(sankey, nodes_in_col);
    for col_idx in 1..nodes_in_col.len() {
        let pair_weights = left_pair_weights(
            sankey,
            &nodes_in_col[col_idx - 1],
            &nodes_in_col[col_idx],
            axes[col_idx - 1],
            axes[col_idx],
        );
        for &node_idx in &nodes_in_col[col_idx] {
            if colors[node_idx].is_some() {
                continue;
            }
            let mut total = 0.0;
            let mut best_parent = None;
            let mut best_weight = f64::NEG_INFINITY;
            for &parent_idx in &nodes_in_col[col_idx - 1] {
                let weight = pair_weights
                    .get(&(parent_idx, node_idx))
                    .copied()
                    .unwrap_or(0.0);
                total += weight;
                if weight > best_weight {
                    best_weight = weight;
                    best_parent = Some(parent_idx);
                }
            }
            let share = if total > 0.0 {
                best_weight / total
            } else {
                0.0
            };
            if share > sankey.left_color_cutoff {
                if let Some(parent_idx) = best_parent {
                    if let Some(parent_color) = &colors[parent_idx] {
                        colors[node_idx] = Some(parent_color.clone());
                    }
                }
            }
            if colors[node_idx].is_none() {
                colors[node_idx] = Some(palette[next_color_idx % palette.len()].clone());
                next_color_idx += 1;
            }
        }
    }

    colors
        .into_iter()
        .map(|c| c.unwrap_or_else(|| palette[0].clone()))
        .collect()
}

fn resolve_sankey_node_colors(sankey: &SankeyPlot, nodes_in_col: &[Vec<usize>]) -> Vec<String> {
    let palette = sankey_palette_colors(sankey);
    match sankey.node_coloring {
        SankeyNodeColoring::Label => resolve_label_colors(sankey, &palette),
        SankeyNodeColoring::Left => resolve_left_colors(sankey, nodes_in_col, &palette),
    }
}

pub fn render_chord(chord: &ChordPlot, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    add_chord(chord, &mut scene, &computed);

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    scene
}

fn add_sankey(sankey: &SankeyPlot, scene: &mut Scene, computed: &ComputedLayout) {
    if sankey.nodes.is_empty() || sankey.links.is_empty() {
        return;
    }

    let n = sankey.nodes.len();

    // ── Step 1: Column assignment ──
    let mut col: Vec<Option<usize>> = sankey.nodes.iter().map(|nd| nd.column).collect();

    // Seed column 0 for nodes with no incoming links (pure sources).
    let mut has_incoming = vec![false; n];
    for link in &sankey.links {
        has_incoming[link.target] = true;
    }
    for i in 0..n {
        if col[i].is_none() && !has_incoming[i] {
            col[i] = Some(0);
        }
    }

    // Iteratively propagate: col[tgt] = max(col[tgt], col[src] + 1)
    let mut changed = true;
    while changed {
        changed = false;
        for link in &sankey.links {
            let src = link.source;
            let tgt = link.target;
            if let Some(sc) = col[src] {
                let new_tgt_col = sc + 1;
                if col[tgt].is_none_or(|tc| tc < new_tgt_col) {
                    col[tgt] = Some(new_tgt_col);
                    changed = true;
                }
            }
        }
    }

    // Assign any remaining unresolved nodes (cycle members) to max_col + 1.
    let max_assigned = col.iter().flatten().copied().max().unwrap_or(0);
    for c in col.iter_mut() {
        if c.is_none() {
            *c = Some(max_assigned + 1);
        }
    }
    let mut col: Vec<usize> = col
        .into_iter()
        .map(|c| c.expect("all Sankey node columns assigned by BFS"))
        .collect();
    let n_cols = col.iter().copied().max().unwrap_or(0) + 1;

    // ── Step 2: Node flow totals ──
    let mut out_flow = vec![0.0_f64; n];
    let mut in_flow = vec![0.0_f64; n];
    for link in &sankey.links {
        out_flow[link.source] += link.value;
        in_flow[link.target] += link.value;
    }
    let node_flow: Vec<f64> = (0..n).map(|i| out_flow[i].max(in_flow[i])).collect();

    // ── Step 3: Vertical layout per column ──
    let plot_h = computed.plot_height();
    let plot_w = computed.plot_width();

    // nodes_in_col[c] = ordered list of node indices in column c
    let mut nodes_in_col: Vec<Vec<usize>> = vec![vec![]; n_cols];
    for i in 0..n {
        nodes_in_col[col[i]].push(i);
    }
    if sankey.node_order != SankeyNodeOrder::Input {
        let node_sort_keys: Vec<String> = if let Some(axis_names) = sankey.axis_names.as_ref() {
            sankey
                .nodes
                .iter()
                .map(|node| {
                    let axis_name = node
                        .column
                        .and_then(|col_idx| axis_names.get(col_idx))
                        .cloned()
                        .unwrap_or_else(|| node.id.clone());
                    format!("{axis_name}~~{}", node.label)
                })
                .collect()
        } else {
            sankey.nodes.iter().map(|node| node.id.clone()).collect()
        };
        let ordered = optimize_sankey_alluvial_order(
            &col,
            &nodes_in_col,
            &sankey.alluvia,
            &sankey.links,
            sankey.node_order_seed,
            Some(&node_sort_keys),
            sankey.node_order == SankeyNodeOrder::Neighbornet,
        );
        col = ordered.col;
        nodes_in_col = ordered.nodes_in_col;
    }

    let node_colors = resolve_sankey_node_colors(sankey, &nodes_in_col);
    let node_color = |i: usize| -> String { node_colors[i].clone() };

    let mut node_y = vec![0.0_f64; n];
    let mut node_h = vec![0.0_f64; n];

    for members in &nodes_in_col {
        if members.is_empty() {
            continue;
        }
        let m = members.len();
        let total_gap = (m - 1) as f64 * sankey.node_gap;
        let usable_h = (plot_h - total_gap).max(1.0);
        let total_col_flow: f64 = members.iter().map(|&i| node_flow[i]).sum();
        if total_col_flow <= 0.0 {
            continue;
        }

        let total_h: f64 = members
            .iter()
            .map(|&i| (node_flow[i] / total_col_flow) * usable_h)
            .sum();
        // Center the column vertically
        let start_y = computed.margin_top + (plot_h - total_h - total_gap) / 2.0;

        let mut cursor_y = start_y;
        for &i in members {
            let h = (node_flow[i] / total_col_flow) * usable_h;
            node_h[i] = h;
            node_y[i] = cursor_y;
            cursor_y += h + sankey.node_gap;
        }
    }

    // ── Step 4: Horizontal layout ──
    // Reserve pixels on the right for last-column node labels so they don't
    // overflow into the legend/margin area. The reserve is split evenly across
    // columns so node spacing remains uniform.
    let last_col_label_reserve = 85.0_f64;
    let col_w = ((plot_w - last_col_label_reserve) / n_cols as f64).max(10.0);
    let node_x: Vec<f64> = (0..n)
        .map(|i| computed.margin_left + col[i] as f64 * col_w + (col_w - sankey.node_width) / 2.0)
        .collect();

    // ── Step 5: Draw node rectangles (labels deferred to Step 7, after ribbons) ──
    let max_col = col.iter().copied().max().unwrap_or(0);
    for i in 0..n {
        scene.add(Primitive::Rect {
            x: node_x[i],
            y: node_y[i],
            width: sankey.node_width,
            height: node_h[i].max(1.0),
            fill: node_color(i).into(),
            stroke: None,
            stroke_width: None,
            opacity: None,
        });
    }

    // ── Step 6: Draw ribbons ──
    // Cursors tracking how far down each node's in/out flow has been consumed.
    let mut out_cursor = node_y.clone();
    let mut in_cursor = node_y.clone();

    // Pre-compute each node's position within its column for O(1) sort lookups.
    let mut pos_in_col = vec![0usize; n];
    for members in &nodes_in_col {
        for (p, &i) in members.iter().enumerate() {
            pos_in_col[i] = p;
        }
    }

    // Sort by (target_col, target_pos_in_col, source_pos_in_col) so that
    // ribbons entering the same node stack top-to-bottom in source-column
    // order, eliminating unnecessary in-node crossings.
    let mut link_order: Vec<usize> = (0..sankey.links.len()).collect();
    link_order.sort_by_key(|&li| {
        let src = sankey.links[li].source;
        let tgt = sankey.links[li].target;
        (col[tgt], pos_in_col[tgt], pos_in_col[src])
    });

    for (link_i, &li) in link_order.iter().enumerate() {
        let link = &sankey.links[li];
        let src = link.source;
        let tgt = link.target;
        if link.value <= 0.0 {
            continue;
        }
        if out_flow[src] <= 0.0 || in_flow[tgt] <= 0.0 {
            continue;
        }

        let link_h_out = (link.value / out_flow[src]) * node_h[src];
        let link_h_in = (link.value / in_flow[tgt]) * node_h[tgt];

        let x_src = node_x[src] + sankey.node_width;
        let x_tgt = node_x[tgt];
        let cx_mid = (x_src + x_tgt) / 2.0;

        let y_src_top = out_cursor[src];
        let y_src_bot = y_src_top + link_h_out;
        let y_tgt_top = in_cursor[tgt];
        let y_tgt_bot = y_tgt_top + link_h_in;

        out_cursor[src] += link_h_out;
        in_cursor[tgt] += link_h_in;

        let d = format!(
            "M {x_src} {y_src_top} \
             C {cx_mid} {y_src_top} {cx_mid} {y_tgt_top} {x_tgt} {y_tgt_top} \
             L {x_tgt} {y_tgt_bot} \
             C {cx_mid} {y_tgt_bot} {cx_mid} {y_src_bot} {x_src} {y_src_bot} Z"
        );

        let fill = match &sankey.link_color {
            SankeyLinkColor::Source => node_color(src),
            SankeyLinkColor::PerLink => link.color.clone().unwrap_or_else(|| node_color(src)),
            SankeyLinkColor::Gradient => {
                let grad_id = format!("grad_{link_i}");
                let src_color = node_color(src);
                let tgt_color = node_color(tgt);
                scene.defs.push(format!(
                    r#"<linearGradient id="{grad_id}" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="{src_color}"/><stop offset="100%" stop-color="{tgt_color}"/></linearGradient>"#
                ));
                format!("url(#{grad_id})")
            }
        };

        scene.add(Primitive::Path(Box::new(PathData {
            d,
            fill: Some(fill.into()),
            stroke: "none".into(),
            stroke_width: 0.0,
            opacity: Some(sankey.link_opacity),
            stroke_dasharray: None,
        })));

        // ── Flow label ──
        if sankey.flow_labels || sankey.flow_percent {
            // Preferred: place label at the midpoint of the horizontal clear zone
            // between the source node's label text right-extent and the target bar's
            // left edge.  Fallback: if no clear zone exists (long labels in tight
            // layouts), use t=0.65 so the label is still visible — it may overlap
            // the source node label slightly, but hiding it entirely is worse.
            let ts = computed.tick_size as f64;
            let bs = computed.body_size as f64;
            let edge_margin = 4.0_f64;

            let src_label_right = if col[src] == 0 {
                x_src + edge_margin // source label is on the LEFT; right is clear
            } else {
                let chars = sankey.nodes[src].label.chars().count() as f64;
                x_src + 6.0 + chars * bs * 0.6 + edge_margin
            };
            let clear_end = x_tgt - edge_margin;

            let t = if clear_end - src_label_right >= ts {
                // Clear zone exists: place at its midpoint.
                ((src_label_right + clear_end) / 2.0 - x_src) / (x_tgt - x_src)
            } else {
                // No clear zone: fall back to 65% toward target.
                0.65
            }
            .clamp(0.05, 0.95);

            let yw_src = (1.0 - t) * (1.0 - t) * (1.0 + 2.0 * t);
            let yw_tgt = t * t * (3.0 - 2.0 * t);
            let y_top_at_t = yw_src * y_src_top + yw_tgt * y_tgt_top;
            let y_bot_at_t = yw_src * y_src_bot + yw_tgt * y_tgt_bot;
            let ribbon_h_at_t = y_bot_at_t - y_top_at_t;
            if ribbon_h_at_t >= sankey.flow_label_min_height {
                let label_x = x_src + t * (x_tgt - x_src);
                let label_y = (y_top_at_t + y_bot_at_t) / 2.0 + ts * 0.35;
                let text = if sankey.flow_percent {
                    format!("{:.1}%", (link.value / out_flow[src]) * 100.0)
                } else {
                    let formatted = sankey.flow_label_format.format(link.value);
                    match &sankey.flow_label_unit {
                        Some(unit) => format!("{formatted} {unit}"),
                        None => formatted,
                    }
                };
                scene.add(Primitive::Text {
                    x: label_x,
                    y: label_y,
                    content: text,
                    size: computed.tick_size,
                    anchor: TextAnchor::Middle,
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }
        }
    }

    // ── Step 7: Draw node labels (above ribbons so text is never obscured) ──
    for i in 0..n {
        let (lx, anchor) = if col[i] == 0 {
            (node_x[i] - 6.0, TextAnchor::End)
        } else {
            (node_x[i] + sankey.node_width + 6.0, TextAnchor::Start)
        };
        let _ = max_col;
        scene.add(Primitive::Text {
            x: lx,
            y: node_y[i] + node_h[i] / 2.0 + computed.body_size as f64 * 0.35,
            content: sankey.nodes[i].label.clone(),
            size: computed.body_size,
            anchor,
            rotate: None,
            bold: false,
            color: None,
        });
    }
}

pub fn render_sankey(sankey: &SankeyPlot, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    add_sankey(sankey, &mut scene, &computed);

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    scene
}

/// Parse inline markdown markup into a list of styled spans.
///
/// Supported markers (non-nesting):
/// - `**bold**`
/// - `*italic*`
/// - `__underline__`
///
/// Unmatched or empty markers are emitted as plain text.
fn parse_inline_markup(text: &str) -> Vec<TextSpan> {
    let chars: Vec<char> = text.chars().collect();
    let n = chars.len();
    let mut spans: Vec<TextSpan> = Vec::new();
    let mut i = 0;
    let mut plain = String::new();

    let flush = |plain: &mut String, spans: &mut Vec<TextSpan>| {
        if !plain.is_empty() {
            spans.push(TextSpan::plain(std::mem::take(plain)));
        }
    };

    while i < n {
        if chars[i] == '*' && i + 1 < n && chars[i + 1] == '*' {
            // Bold: **...**
            flush(&mut plain, &mut spans);
            i += 2;
            let start = i;
            while i < n {
                if chars[i] == '*' && i + 1 < n && chars[i + 1] == '*' {
                    let inner: String = chars[start..i].iter().collect();
                    if !inner.is_empty() {
                        spans.push(TextSpan {
                            text: inner,
                            bold: true,
                            italic: false,
                            underline: false,
                        });
                    }
                    i += 2;
                    break;
                }
                i += 1;
            }
        } else if chars[i] == '*' {
            // Italic: *...*
            flush(&mut plain, &mut spans);
            i += 1;
            let start = i;
            while i < n {
                if chars[i] == '*' && (i + 1 >= n || chars[i + 1] != '*') {
                    let inner: String = chars[start..i].iter().collect();
                    if !inner.is_empty() {
                        spans.push(TextSpan {
                            text: inner,
                            bold: false,
                            italic: true,
                            underline: false,
                        });
                    }
                    i += 1;
                    break;
                }
                i += 1;
            }
        } else if chars[i] == '_' && i + 1 < n && chars[i + 1] == '_' {
            // Underline: __...__
            flush(&mut plain, &mut spans);
            i += 2;
            let start = i;
            while i < n {
                if chars[i] == '_' && i + 1 < n && chars[i + 1] == '_' {
                    let inner: String = chars[start..i].iter().collect();
                    if !inner.is_empty() {
                        spans.push(TextSpan {
                            text: inner,
                            bold: false,
                            italic: false,
                            underline: true,
                        });
                    }
                    i += 2;
                    break;
                }
                i += 1;
            }
        } else {
            plain.push(chars[i]);
            i += 1;
        }
    }
    flush(&mut plain, &mut spans);
    spans
}

/// Explode spans into tagged words, then wrap into lines of at most `max_chars`.
/// Words are never split mid-word; a word that would overflow is moved to the next line.
fn wrap_rich_spans(spans: &[TextSpan], max_chars: usize) -> Vec<Vec<TextSpan>> {
    // Explode into (bold, italic, underline, word) tuples
    let mut words: Vec<(bool, bool, bool, String)> = Vec::new();
    for span in spans {
        for word in span.text.split_whitespace() {
            words.push((span.bold, span.italic, span.underline, word.to_string()));
        }
    }

    // Pack words onto lines
    let mut lines: Vec<Vec<(bool, bool, bool, String)>> = Vec::new();
    let mut cur: Vec<(bool, bool, bool, String)> = Vec::new();
    let mut cur_len = 0usize;

    for (bold, italic, underline, word) in words {
        let wlen = word.chars().count();
        let sep = if cur.is_empty() { 0 } else { 1 };
        if cur_len + sep + wlen > max_chars && !cur.is_empty() {
            lines.push(std::mem::take(&mut cur));
            cur_len = 0;
        }
        cur_len += if cur.is_empty() { wlen } else { wlen + 1 };
        cur.push((bold, italic, underline, word));
    }
    if !cur.is_empty() {
        lines.push(cur);
    }

    // Re-assemble each line of tagged words into spans
    lines
        .into_iter()
        .map(|line_words| {
            let mut line_spans: Vec<TextSpan> = Vec::new();
            for (i, (bold, italic, underline, word)) in line_words.into_iter().enumerate() {
                // Try to merge with the last span if styles match
                if let Some(last) = line_spans.last_mut() {
                    if last.bold == bold && last.italic == italic && last.underline == underline {
                        last.text.push(' ');
                        last.text.push_str(&word);
                        continue;
                    }
                }
                // New span: prefix with a space for every word after the first
                let text = if i == 0 { word } else { format!(" {}", word) };
                line_spans.push(TextSpan {
                    text,
                    bold,
                    italic,
                    underline,
                });
            }
            line_spans
        })
        .collect()
}

/// Render a [`LegendPlot`] — a standalone legend grid with no axes or data.
fn add_legend_plot(lp: &LegendPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let line_height = computed.legend_line_height;
    let legend_padding = computed.legend_padding;
    let theme = &computed.theme;

    let plot_left = computed.margin_left;
    let plot_right = computed.width - computed.margin_right;
    let plot_top = computed.margin_top;
    let avail_w = plot_right - plot_left;

    let n_entries = lp.entries.len();

    // Auto-compute columns if not set, then bump up columns to fit within cell height.
    let mut n_cols = lp.cols.unwrap_or_else(|| {
        let max_chars = lp.entries.iter().map(|e| e.label.len()).max().unwrap_or(8) as f64;
        let char_px = computed.body_size as f64 * 0.68;
        let col_w = 18.0 + max_chars * char_px + 20.0;
        ((avail_w / col_w).floor() as usize).max(1)
    });
    let mut n_rows = n_entries.div_ceil(n_cols);

    // Title row consumes one line_height; account for it when checking fit.
    let title_h = if lp.title.is_some() { line_height } else { 0.0 };
    let avail_h = computed.height - plot_top - title_h - legend_padding * 2.0;
    // Increase columns until all rows fit within the cell, without splitting entries
    // across more columns than there are entries.
    while n_cols < n_entries && (n_rows as f64 * line_height) > avail_h {
        n_cols += 1;
        n_rows = n_entries.div_ceil(n_cols);
    }

    // Optional title
    let mut cur_y = plot_top;
    if let Some(ref title) = lp.title {
        scene.add(Primitive::Text {
            x: plot_left + avail_w / 2.0,
            y: cur_y + 5.0,
            content: title.clone(),
            size: computed.body_size,
            anchor: TextAnchor::Middle,
            rotate: None,
            bold: true,
            color: None,
        });
        cur_y += line_height;
    }

    let legend_y = cur_y;
    let col_w = avail_w / n_cols as f64;

    if lp.show_box && n_entries > 0 {
        let box_h = n_rows as f64 * line_height + legend_padding * 2.0;
        scene.add(Primitive::Rect {
            x: plot_left - legend_padding + 5.0,
            y: legend_y - legend_padding,
            width: avail_w + legend_padding,
            height: box_h,
            fill: Color::from(&theme.legend_bg),
            stroke: None,
            stroke_width: None,
            opacity: None,
        });
        scene.add(Primitive::Rect {
            x: plot_left - legend_padding + 5.0,
            y: legend_y - legend_padding,
            width: avail_w + legend_padding,
            height: box_h,
            fill: "none".into(),
            stroke: Some(Color::from(&theme.legend_border)),
            stroke_width: Some(computed.axis_stroke_width),
            opacity: None,
        });
    }

    for (i, entry) in lp.entries.iter().enumerate() {
        let col = i % n_cols;
        let row = i / n_cols;
        let ex = plot_left + col as f64 * col_w;
        let ey = legend_y + row as f64 * line_height;
        render_legend_entry(entry, scene, ex, ey, computed);
    }
}

/// Render a [`TextPlot`] — word-wrapped rich text with optional title, background, and border.
fn add_text_plot(tp: &TextPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let px = computed.margin_left;
    let py = computed.margin_top;
    let pw = computed.plot_width();
    // Extend to full cell bottom so the box always contains all text.
    let full_h = computed.height - py;
    let padding = tp.padding;

    if let Some(ref bg) = tp.background {
        scene.add(Primitive::Rect {
            x: px,
            y: py,
            width: pw,
            height: full_h,
            fill: Color::from(bg.as_str()),
            stroke: None,
            stroke_width: None,
            opacity: None,
        });
    }

    if tp.border_width > 0.0 {
        scene.add(Primitive::Rect {
            x: px,
            y: py,
            width: pw,
            height: full_h,
            fill: Color::None,
            stroke: Some(Color::from(tp.border_color.as_deref().unwrap_or("#cccccc"))),
            stroke_width: Some(tp.border_width),
            opacity: None,
        });
    }

    let font_size = tp.font_size.unwrap_or(computed.body_size);
    let line_height = font_size as f64 * 1.55;
    let avail_w = (pw - 2.0 * padding).max(20.0);
    let char_w = font_size as f64 * 0.55;
    let max_chars = ((avail_w / char_w) as usize).max(1);

    let (anchor, text_x) = match tp.text_align {
        TextAlign::Left => (TextAnchor::Start, px + padding),
        TextAlign::Center => (TextAnchor::Middle, px + pw / 2.0),
        TextAlign::Right => (TextAnchor::End, px + pw - padding),
    };

    let text_color = tp.text_color.as_deref().map(Color::from);
    let mut cy = py + padding + font_size as f64;

    // Optional TextPlot title (always bold, slightly larger)
    if let Some(ref t) = tp.title {
        scene.add(Primitive::Text {
            x: text_x,
            y: cy,
            content: t.clone(),
            size: font_size + 2,
            anchor,
            rotate: None,
            bold: true,
            color: text_color.clone(),
        });
        cy += line_height * 1.5;
    }

    for raw in tp.body.lines() {
        // Horizontal rule
        if raw.trim() == "---" {
            let rule_y = cy - font_size as f64 * 0.4;
            scene.add(Primitive::Line {
                x1: px + padding,
                y1: rule_y,
                x2: px + pw - padding,
                y2: rule_y,
                stroke: Color::from("#cccccc"),
                stroke_width: 1.0,
                stroke_dasharray: None,
            });
            cy += line_height * 0.5;
            continue;
        }

        // Headings — bold Text at a larger size (no inline markup)
        let (heading_text, size_bump) = if let Some(s) = raw.strip_prefix("## ") {
            (Some(s), 2u32)
        } else if let Some(s) = raw.strip_prefix("# ") {
            (Some(s), 4u32)
        } else {
            (None, 0u32)
        };

        if let Some(text) = heading_text {
            if text.trim().is_empty() {
                cy += line_height * 0.5;
                continue;
            }
            let fs = font_size + size_bump;
            let lh = fs as f64 * 1.55;
            let mc = ((avail_w / (fs as f64 * 0.55)) as usize).max(1);
            // Wrap heading words (plain Text, always bold)
            let words: Vec<&str> = text.split_whitespace().collect();
            let mut cur = String::new();
            for word in words {
                let sep = if cur.is_empty() { 0 } else { 1 };
                if cur.len() + sep + word.len() > mc && !cur.is_empty() {
                    scene.add(Primitive::Text {
                        x: text_x,
                        y: cy,
                        content: std::mem::take(&mut cur),
                        size: fs,
                        anchor,
                        rotate: None,
                        bold: true,
                        color: text_color.clone(),
                    });
                    cy += lh;
                }
                if !cur.is_empty() {
                    cur.push(' ');
                }
                cur.push_str(word);
            }
            if !cur.is_empty() {
                scene.add(Primitive::Text {
                    x: text_x,
                    y: cy,
                    content: cur,
                    size: fs,
                    anchor,
                    rotate: None,
                    bold: true,
                    color: text_color.clone(),
                });
                cy += lh;
            }
            cy += lh * 0.2;
            continue;
        }

        // Blank line — paragraph gap
        if raw.trim().is_empty() {
            cy += line_height * 0.5;
            continue;
        }

        // Body line — parse inline markup, word-wrap, emit RichText
        let spans = parse_inline_markup(raw);
        let wrapped_lines = wrap_rich_spans(&spans, max_chars);
        for line_spans in wrapped_lines {
            scene.add(Primitive::RichText {
                x: text_x,
                y: cy,
                spans: line_spans,
                size: font_size,
                anchor,
                color: text_color.clone(),
            });
            cy += line_height;
        }
    }
}

/// this should be the default renderer.
/// TODO: make an alias of this for single plots, that vectorises
pub fn render_multiple(plots: Vec<Plot>, layout: Layout) -> Scene {
    // Auto-assign palette colors to single-color plot types
    let mut plots = plots;
    let mut layout = layout;
    if let Some(ref palette) = layout.palette {
        let mut color_idx = 0;
        for plot in plots.iter_mut() {
            match plot {
                Plot::Scatter(_)
                | Plot::Line(_)
                | Plot::Series(_)
                | Plot::Histogram(_)
                | Plot::Box(_)
                | Plot::Violin(_)
                | Plot::Band(_)
                | Plot::Strip(_)
                | Plot::Density(_)
                | Plot::Forest(_)
                | Plot::Scatter3D(_)
                | Plot::Surface3D(_)
                | Plot::Raincloud(_)
                | Plot::Lollipop(_)
                | Plot::Survival(_)
                | Plot::Slope(_)
                | Plot::Ecdf(_)
                | Plot::QQ(_) => {
                    plot.set_color(&palette[color_idx]);
                    color_idx += 1;
                }
                // Manhattan uses per-chromosome coloring; skip palette auto-cycling.
                _ => {}
            }
        }
    }

    // Resolve any pending adjacency matrices in network plots so edges
    // are available for rendering and legend collection.
    for plot in plots.iter_mut() {
        if let Plot::Network(ref mut net) = plot {
            net.resolve_matrix();
        }
    }

    let mut computed = ComputedLayout::from_layout(&layout);

    // Pie canvas-widening: when a Pie with outside labels is present, ensure the
    // canvas is wide enough to fit both the leader-line labels AND any legend in the
    // right margin. Replicates the same logic used in render_pie().
    for plot in plots.iter() {
        if let Plot::Pie(pie) = plot {
            let has_outside = matches!(
                pie.label_position,
                PieLabelPosition::Outside | PieLabelPosition::Auto
            );
            if !has_outside {
                break;
            }
            let total: f64 = pie.slices.iter().map(|s| s.value).sum();
            if total <= 0.0 {
                break;
            }
            let char_width = computed.body_size as f64 * 0.6;
            let max_label_px = pie
                .slices
                .iter()
                .map(|slice| {
                    let frac = slice.value / total;
                    let place_inside = match pie.label_position {
                        PieLabelPosition::None | PieLabelPosition::Inside => true,
                        PieLabelPosition::Outside => false,
                        PieLabelPosition::Auto => frac >= pie.min_label_fraction,
                    };
                    if place_inside {
                        return 0.0_f64;
                    }
                    let label_text = if pie.show_percent {
                        let pct = frac * 100.0;
                        if slice.label.is_empty() {
                            format!("{:.1}%", pct)
                        } else {
                            format!("{} ({:.1}%)", slice.label, pct)
                        }
                    } else {
                        slice.label.clone()
                    };
                    label_text.len() as f64 * char_width
                })
                .fold(0.0_f64, f64::max);
            let leader_gap = 30.0;
            let pad = 5.0;
            let safety = 20.0;
            let radius = computed.plot_height() / 2.0 - pad;
            let needed_half = radius + leader_gap + max_label_px + pad + safety;
            let needed_plot_width = needed_half * 2.0;
            if layout.width.is_none() && needed_plot_width > computed.plot_width() {
                computed.width = needed_plot_width + computed.margin_left + computed.margin_right;
                computed.recompute_transforms();
            }
            break; // only one pie per render_multiple call
        }
    }

    // Auto-shrink canvas width for height-limited DicePlot (before scene is created).
    // When cell_sq is determined by height, the grid is narrower than the full plot
    // area.  The ideal canvas width is margin_left + grid_width + gap + right_margin.
    if let Some(Plot::DicePlot(dp)) = plots.iter().find(|p| matches!(p, Plot::DicePlot(_))) {
        let nx = dp.x_categories.len().max(1);
        let ny = dp.y_categories.len().max(1);
        let cw0 = computed.plot_width() / nx as f64;
        let ch0 = computed.plot_height() / ny as f64;
        if ch0 < cw0 {
            let cell_sq = ch0;
            let gw = nx as f64 * cell_sq;
            let ideal_width = computed.margin_left + gw + 12.0 + computed.margin_right;
            if ideal_width < computed.width {
                computed.width = ideal_width;
                computed.recompute_transforms();
            }
        }
    }

    let capacity_hint: usize = plots
        .iter()
        .map(|p| p.estimated_primitives())
        .sum::<usize>()
        + 64;
    let mut scene = Scene::with_capacity(computed.width, computed.height, capacity_hint);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    // Propagate interactivity into the scene so the SVG backend can inject
    // CSS / JS / UI.  For pixel-space plots (no axis) axis_meta is left None.
    scene.interactive = computed.interactive;
    if computed.interactive {
        let skip_axes_for_meta = plots.iter().all(|p| {
            matches!(
                p,
                Plot::Pie(_)
                    | Plot::UpSet(_)
                    | Plot::Chord(_)
                    | Plot::Sankey(_)
                    | Plot::PhyloTree(_)
                    | Plot::Synteny(_)
                    | Plot::Polar(_)
                    | Plot::Ternary(_)
                    | Plot::Scatter3D(_)
                    | Plot::Surface3D(_)
                    | Plot::Clustermap(_)
                    | Plot::Joint(_)
                    | Plot::Venn(_)
                    | Plot::Parallel(_)
                    | Plot::Mosaic(_)
                    | Plot::Network(_)
                    | Plot::Radar(_)
                    | Plot::Treemap(_)
                    | Plot::Sunburst(_)
                    | Plot::Funnel(_)
                    | Plot::Rose(_)
                    | Plot::Calendar(_)
                    | Plot::Waffle(_)
                    | Plot::Text(_)
                    | Plot::LegendPlot(_)
            )
        });
        if !skip_axes_for_meta {
            scene.axis_meta = Some(AxisMeta {
                x_min: computed.x_range.0,
                x_max: computed.x_range.1,
                y_min: computed.y_range.0,
                y_max: computed.y_range.1,
                plot_left: computed.margin_left,
                plot_top: computed.margin_top,
                plot_right: computed.width - computed.margin_right,
                plot_bottom: computed.height - computed.margin_bottom,
                log_x: computed.log_x,
                log_y: computed.log_y,
            });
        }
    }

    let skip_axes = plots.iter().all(|p| {
        matches!(
            p,
            Plot::Pie(_)
                | Plot::UpSet(_)
                | Plot::Chord(_)
                | Plot::Sankey(_)
                | Plot::PhyloTree(_)
                | Plot::Synteny(_)
                | Plot::Polar(_)
                | Plot::Ternary(_)
                | Plot::DicePlot(_)
                | Plot::Scatter3D(_)
                | Plot::Surface3D(_)
                | Plot::Clustermap(_)
                | Plot::Joint(_)
                | Plot::Venn(_)
                | Plot::Parallel(_)
                | Plot::Mosaic(_)
                | Plot::Network(_)
                | Plot::Radar(_)
                | Plot::Treemap(_)
                | Plot::Sunburst(_)
                | Plot::Funnel(_)
                | Plot::Rose(_)
                | Plot::Calendar(_)
                | Plot::Waffle(_)
                | Plot::Text(_)
                | Plot::LegendPlot(_)
        )
    });
    if !skip_axes {
        add_axes_and_grid(&mut scene, &computed, &layout);
    }

    // For DicePlot: precompute the actual grid extents so that axis labels and
    // the right-margin legend start flush with the grid rather than the full
    // canvas margin.
    if let Some(Plot::DicePlot(dp)) = plots.iter().find(|p| matches!(p, Plot::DicePlot(_))) {
        let nx = dp.x_categories.len().max(1);
        let ny = dp.y_categories.len().max(1);
        let cw0 = computed.plot_width() / nx as f64;
        let ch0 = computed.plot_height() / ny as f64;
        let cell_sq = cw0.min(ch0);
        let gw = nx as f64 * cell_sq;
        let gh = ny as f64 * cell_sq;

        // ── Canvas auto-shrink + legend flush with grid right edge ───────────────
        // When height-limited there is unused horizontal space.  Shrink the canvas
        // to exactly fit (margin_left + grid + gap + right-content), then solve
        // analytically for margin_right so the legend lands gap px right of grid.
        //
        // Derivation (height-limited, gw constant):
        //   legend_x  = W - R + y2w + 10
        //   grid_right = margin_left + (plot_w - gw)/2 + gw
        //              = margin_left + (W - margin_left - R + gw)/2
        //   Set legend_x = grid_right + gap, solve for R:
        //   R = W - margin_left - gw + 2·y2w + 20 - 2·gap
        if ch0 < cw0 {
            let gap = 12.0_f64;
            let new_mr =
                (computed.width - computed.margin_left - gw + 2.0 * computed.y2_axis_width + 20.0
                    - 2.0 * gap)
                    .max(computed.legend_width + 10.0); // ensure box stays on canvas
            computed.margin_right = new_mr;
        }

        // Recompute grid origin with the (possibly updated) margin_right
        let gx0 = computed.margin_left + (computed.plot_width() - gw) / 2.0;
        let gy0 = computed.margin_top + (computed.plot_height() - gh) / 2.0;
        let grid_bottom = gy0 + gh;

        // ── Colorbar flush with grid right edge (height-limited only) ────────────
        // bar_x = width - colorbar_x_inset; solve for x_inset that puts bar at gx0+gw+gap
        if ch0 < cw0 {
            let gap = 12.0_f64;
            computed.colorbar_x_inset = (computed.width - gx0 - gw - gap).max(0.0);
        }

        // ── Axis label positions relative to grid ────────────────────────────────
        let tl = computed.tick_mark_major;
        let tlm = computed.tick_label_margin;
        let ts = computed.tick_size as f64;
        let ls = computed.label_size as f64;

        // x-label: centred on grid width, just below the tick labels
        let x_label_y = grid_bottom + tl + tlm + ts * 0.85 + 6.0 + ls * 0.5;
        computed.dice_x_label_pos = Some((gx0 + gw / 2.0, x_label_y));

        // y-label: centred on grid height, just left of the tick labels
        let max_y_px = dp.y_categories.iter().map(|s| s.len()).max().unwrap_or(4) as f64 * ts * 0.6;
        let y_label_x = (gx0 - tl - tlm - max_y_px - 6.0 - ls * 0.5).max(ls * 0.5 + 4.0);
        computed.dice_y_label_pos = Some((y_label_x, gy0 + gh / 2.0));
    }

    // JointPlot draws its own x/y labels centred on the scatter axis inside add_jointplot;
    // suppress them here so add_labels_and_title doesn't draw a second misaligned copy.
    if plots.iter().any(|p| matches!(p, Plot::Joint(_))) {
        layout.x_label = None;
        layout.y_label = None;
    }

    add_labels_and_title(&mut scene, &computed, &layout);

    if !skip_axes {
        let clip_id = next_clip_id();
        // Push the <clipPath> definition into the scene's <defs> block.
        // The data elements are then wrapped in <g clip-path="url(#id)">.
        // Using scene.defs rather than an inline <defs> keeps the first <rect>
        // in the element stream as a real data rect (not the clip rect), which
        // preserves compatibility with tests that scan for rect elements.
        let clip_def = format!(
            r#"<clipPath id="{id}"><rect x="{x}" y="{y}" width="{w}" height="{h}"/></clipPath>"#,
            id = clip_id,
            x = round2(computed.margin_left),
            y = round2(computed.margin_top),
            w = round2(computed.plot_width()),
            h = round2(computed.plot_height()),
        );
        scene.defs.push(clip_def);
        scene.elements.push(Primitive::ClipStart {
            x: computed.margin_left,
            y: computed.margin_top,
            width: computed.plot_width(),
            height: computed.plot_height(),
            id: clip_id,
        });
    }

    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    // for each plot, plot it
    for plot in plots.iter() {
        match plot {
            Plot::Scatter(s) => {
                add_scatter(s, &mut scene, &computed);
            }
            Plot::Line(l) => {
                add_line(l, &mut scene, &computed);
            }
            Plot::Series(s) => {
                add_series(s, &mut scene, &computed);
            }
            Plot::Bar(b) => {
                add_bar(b, &mut scene, &computed);
            }
            Plot::Histogram(h) => {
                add_histogram(h, &mut scene, &computed);
            }
            Plot::Histogram2d(h) => {
                add_histogram2d(h, &mut scene, &computed);
            }
            Plot::Box(b) => {
                add_boxplot(b, &mut scene, &computed);
            }
            Plot::Violin(v) => {
                add_violin(v, &mut scene, &computed);
            }
            Plot::Pie(p) => {
                add_pie(p, &mut scene, &computed);
            }
            Plot::Heatmap(h) => {
                add_heatmap(h, &mut scene, &computed);
            }
            Plot::Brick(b) => {
                add_brickplot(b, &mut scene, &computed);
            }
            Plot::Band(b) => {
                add_band(b, &mut scene, &computed);
            }
            Plot::Waterfall(w) => {
                add_waterfall(w, &mut scene, &computed);
            }
            Plot::Strip(s) => {
                add_strip(s, &mut scene, &computed);
            }
            Plot::Volcano(v) => {
                add_volcano(v, &mut scene, &computed);
            }
            Plot::Manhattan(m) => {
                add_manhattan(m, &mut scene, &computed);
            }
            Plot::DotPlot(d) => {
                add_dot_plot(d, &mut scene, &computed);
            }
            Plot::UpSet(u) => {
                add_upset(u, &mut scene, &computed);
            }
            Plot::StackedArea(sa) => {
                add_stacked_area(sa, &mut scene, &computed);
            }
            Plot::Candlestick(cp) => {
                add_candlestick(cp, &mut scene, &computed);
            }
            Plot::Contour(cp) => {
                add_contour(cp, &mut scene, &computed);
            }
            Plot::Chord(c) => {
                add_chord(c, &mut scene, &computed);
            }
            Plot::Sankey(s) => {
                add_sankey(s, &mut scene, &computed);
            }
            Plot::PhyloTree(t) => {
                add_phylo_tree(t, &mut scene, &computed);
            }
            Plot::Synteny(s) => {
                add_synteny(s, &mut scene, &computed);
            }
            Plot::Density(d) => {
                add_density(d, &computed, &mut scene);
            }
            Plot::Ridgeline(rp) => {
                add_ridgeline(rp, &computed, &mut scene);
            }
            Plot::Polar(pp) => {
                add_polar(pp, &mut scene, &computed);
            }
            Plot::Ternary(tp) => {
                add_ternary(tp, &mut scene, &computed);
            }
            Plot::DicePlot(d) => {
                add_diceplot(d, &mut scene, &computed);
            }
            Plot::Forest(f) => {
                add_forest(f, &mut scene, &computed);
            }
            Plot::Scatter3D(s) => {
                add_scatter3d(s, &mut scene, &computed);
            }
            Plot::Surface3D(s) => {
                add_surface3d(s, &mut scene, &computed);
            }
            Plot::Clustermap(c) => {
                add_clustermap(c, &mut scene, &computed);
            }
            Plot::Joint(jp) => {
                let title_h = if layout.title.is_some() { 35.0 } else { 0.0 };
                add_jointplot(
                    jp,
                    &mut scene,
                    &computed,
                    title_h,
                    computed.legend_width,
                    layout.show_legend,
                    false,
                );
            }
            Plot::Raincloud(r) => {
                add_raincloud(r, &mut scene, &computed);
            }
            Plot::Lollipop(lp) => {
                add_lollipop(lp, &mut scene, &computed);
            }
            Plot::Survival(sp) => {
                add_survival(sp, &mut scene, &computed);
            }
            Plot::Roc(r) => {
                add_roc(r, &mut scene, &computed);
            }
            Plot::Pr(r) => {
                add_pr(r, &mut scene, &computed);
            }
            Plot::Slope(s) => {
                add_slope(s, &mut scene, &computed);
            }
            Plot::Venn(v) => {
                add_venn(v, &mut scene, &computed);
            }
            Plot::Parallel(p) => {
                add_parallel(p, &mut scene, &computed);
            }
            Plot::Mosaic(mp) => {
                add_mosaic(mp, &mut scene, &computed);
            }
            Plot::Ecdf(ep) => {
                add_ecdf(ep, &computed, &mut scene);
            }
            Plot::QQ(qp) => {
                add_qqplot(qp, &computed, &mut scene);
            }
            Plot::Network(n) => {
                add_network(n, &mut scene, &computed);
            }
            Plot::Streamgraph(sg) => {
                add_streamgraph(sg, &mut scene, &computed);
            }
            Plot::Radar(rp) => {
                add_radar(rp, &mut scene, &computed);
            }
            Plot::Hexbin(hb) => {
                add_hexbin(hb, &mut scene, &computed);
            }
            Plot::Treemap(tm) => {
                add_treemap(tm, &mut scene, &computed);
            }
            Plot::Sunburst(sb) => {
                add_sunburst(sb, &mut scene, &computed);
            }
            Plot::Bump(bp) => {
                add_bump(bp, &mut scene, &computed, &layout);
            }
            Plot::Funnel(fp) => {
                add_funnel(fp, &mut scene, &computed);
            }
            Plot::Rose(rp) => {
                add_rose(rp, &mut scene, &computed);
            }
            Plot::Calendar(cp) => {
                add_calendar(cp, &mut scene, &computed);
            }
            Plot::Pyramid(pp) => {
                add_pyramid(pp, &mut scene, &computed);
            }
            Plot::Waffle(wp) => {
                add_waffle(wp, &mut scene, &computed);
            }
            Plot::Horizon(hp) => {
                add_horizon(hp, &mut scene, &computed);
            }
            Plot::Gantt(gp) => {
                add_gantt(gp, &mut scene, &computed);
            }
            Plot::Text(tp) => {
                add_text_plot(tp, &mut scene, &computed);
            }
            Plot::LegendPlot(lp) => {
                add_legend_plot(lp, &mut scene, &computed);
            }
        }
    }

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    if !skip_axes {
        scene.elements.push(Primitive::ClipEnd);
    }

    // Manhattan chromosome labels must be drawn after ClipEnd (they sit below the clip rect)
    for plot in plots.iter() {
        if let Plot::Manhattan(m) = plot {
            add_manhattan_chr_labels(m, &mut scene, &computed);
        }
    }

    // BrickPlot notation labels sit above the plot area clip rect — emit after ClipEnd.
    for plot in plots.iter() {
        if let Plot::Brick(bp) = plot {
            add_brickplot_notations(bp, &mut scene, &computed);
        }
    }

    // HorizonPlot row annotations sit in the right margin — emit after ClipEnd.
    for plot in plots.iter() {
        if let Plot::Horizon(hp) = plot {
            add_horizon_annots(hp, &mut scene, &computed);
        }
    }

    // GanttPlot milestone labels and outside-bar task labels — emit after ClipEnd.
    for plot in plots.iter() {
        if let Plot::Gantt(gp) = plot {
            add_gantt_labels(gp, &mut scene, &computed);
        }
    }

    // Check for DotPlot stacked legend (size legend + colorbar in one column)
    let dot_stacked = plots.iter().find_map(|p| {
        if let Plot::DotPlot(dp) = p {
            if dp.size_label.is_some() && dp.color_legend_label.is_some() {
                p.colorbar_info().map(|info| (dp, info))
            } else {
                None
            }
        } else {
            None
        }
    });

    if let Some((dp, info)) = dot_stacked {
        // Build size entries inline to avoid needing Clone on LegendEntry
        let (size_min, size_max) = dp.size_range.unwrap_or_else(|| dp.size_extent());
        let mut size_entries = Vec::new();
        for &pct in &[0.25_f64, 0.50, 0.75, 1.0] {
            let value_at_pct = size_min + pct * (size_max - size_min);
            let radius_at_pct = dp.max_radius * pct;
            size_entries.push(LegendEntry {
                label: format!("{:.1}", value_at_pct),
                color: "#444444".into(),
                shape: LegendShape::CircleSize(radius_at_pct),
                dasharray: None,
            });
        }
        let title = dp.size_label.as_deref().unwrap_or("");
        add_dot_stacked_legends(title, &size_entries, &info, &mut scene, &computed);
    } else {
        // Check if any DicePlot has its own stacked legend (position + colour sections).
        let dice_has_legend = plots.iter().any(|p| {
            if let Plot::DicePlot(dp) = p {
                dp.position_legend_label.is_some()
                    || !dp.dot_legend.is_empty()
                    || dp.size_legend_label.is_some()
            } else {
                false
            }
        });

        let mut dice_colorbar_drawn = false;
        if dice_has_legend {
            if layout.show_legend {
                for plot in plots.iter() {
                    if let Plot::DicePlot(dp) = plot {
                        if dp.position_legend_label.is_some()
                            || !dp.dot_legend.is_empty()
                            || dp.size_legend_label.is_some()
                        {
                            dice_colorbar_drawn = add_dice_legends(dp, &mut scene, &computed);
                            break;
                        }
                    }
                }
            }
        } else {
            let (entries, groups) = if let Some(ref grps) = layout.legend_groups {
                (Vec::new(), Some(grps.clone()))
            } else {
                let e = layout
                    .legend_entries
                    .clone()
                    .unwrap_or_else(|| collect_legend_entries(&plots));
                (e, None)
            };
            let has_legend = layout.show_legend && (!entries.is_empty() || groups.is_some());

            // Draw stats box first; capture its height for collision avoidance.
            let stats_result = add_stats_box(&layout, &mut scene, &computed);

            if has_legend {
                let legend = Legend {
                    title: layout.legend_title.clone(),
                    entries,
                    groups,
                    position: layout.legend_position,
                    show_box: layout.legend_box,
                };
                // Collision avoidance: if stats box was rendered at the same position,
                // shift the legend downward so it sits below the stats box.
                let y_offset = if let Some((_, _stats_y, stats_h)) = stats_result {
                    let positions_match = std::mem::discriminant(&layout.stats_position)
                        == std::mem::discriminant(&layout.legend_position);
                    if positions_match {
                        stats_h + 8.0
                    } else {
                        0.0
                    }
                } else {
                    0.0
                };
                add_legend_with_offset(&legend, &mut scene, &computed, y_offset);
            }
        }

        if layout.show_colorbar && !dice_colorbar_drawn {
            // Hexbin and Treemap colorbars must be drawn after ClipEnd
            // (colorbar_info returns None for them)
            let mut special_cb_drawn = false;
            for plot in plots.iter() {
                if let Plot::Hexbin(hb) = plot {
                    add_hexbin_colorbar(hb, &mut scene, &computed);
                    special_cb_drawn = true;
                    break;
                }
            }
            if !special_cb_drawn {
                for plot in plots.iter() {
                    if let Plot::Treemap(tm) = plot {
                        add_treemap_colorbar(tm, &mut scene, &computed);
                        special_cb_drawn = true;
                        break;
                    }
                }
            }
            if !special_cb_drawn {
                for plot in plots.iter() {
                    if let Plot::Sunburst(sb) = plot {
                        add_sunburst_colorbar(sb, &mut scene, &computed);
                        special_cb_drawn = true;
                        break;
                    }
                }
            }
            if !special_cb_drawn {
                for plot in plots.iter() {
                    if let Some(info) = plot.colorbar_info() {
                        add_colorbar(&info, &mut scene, &computed);
                        break; // one colorbar per figure
                    }
                }
            }
        }
    }

    scene
}

/// Render two groups of plots on a shared x-axis with independent left (primary) and right (secondary) y-axes.
pub fn render_twin_y(primary: Vec<Plot>, secondary: Vec<Plot>, layout: Layout) -> Scene {
    let mut primary = primary;
    let mut secondary = secondary;
    if let Some(ref palette) = layout.palette {
        let mut color_idx = 0;
        for plot in primary.iter_mut().chain(secondary.iter_mut()) {
            match plot {
                Plot::Scatter(_)
                | Plot::Line(_)
                | Plot::Series(_)
                | Plot::Histogram(_)
                | Plot::Box(_)
                | Plot::Violin(_)
                | Plot::Band(_)
                | Plot::Strip(_)
                | Plot::Density(_)
                | Plot::Ecdf(_)
                | Plot::QQ(_) => {
                    plot.set_color(&palette[color_idx]);
                    color_idx += 1;
                }
                _ => {}
            }
        }
    }

    let computed = ComputedLayout::from_layout(&layout);
    let computed_y2 = computed.for_y2();
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    add_axes_and_grid(&mut scene, &computed, &layout);
    add_y2_axis(&mut scene, &computed, &layout);
    add_labels_and_title(&mut scene, &computed, &layout);

    let clip_id_twin = next_clip_id();
    let clip_def_twin = format!(
        r#"<clipPath id="{id}"><rect x="{x}" y="{y}" width="{w}" height="{h}"/></clipPath>"#,
        id = clip_id_twin,
        x = round2(computed.margin_left),
        y = round2(computed.margin_top),
        w = round2(computed.plot_width()),
        h = round2(computed.plot_height()),
    );
    scene.defs.push(clip_def_twin);
    scene.elements.push(Primitive::ClipStart {
        x: computed.margin_left,
        y: computed.margin_top,
        width: computed.plot_width(),
        height: computed.plot_height(),
        id: clip_id_twin,
    });

    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    for plot in primary.iter() {
        match plot {
            Plot::Scatter(s) => add_scatter(s, &mut scene, &computed),
            Plot::Line(l) => add_line(l, &mut scene, &computed),
            Plot::Series(s) => add_series(s, &mut scene, &computed),
            Plot::Band(b) => add_band(b, &mut scene, &computed),
            Plot::Bar(b) => add_bar(b, &mut scene, &computed),
            Plot::Histogram(h) => add_histogram(h, &mut scene, &computed),
            Plot::Box(b) => add_boxplot(b, &mut scene, &computed),
            Plot::Violin(v) => add_violin(v, &mut scene, &computed),
            Plot::Strip(s) => add_strip(s, &mut scene, &computed),
            Plot::Density(d) => add_density(d, &computed, &mut scene),
            Plot::StackedArea(s) => add_stacked_area(s, &mut scene, &computed),
            Plot::Waterfall(w) => add_waterfall(w, &mut scene, &computed),
            Plot::Candlestick(c) => add_candlestick(c, &mut scene, &computed),
            Plot::Raincloud(r) => add_raincloud(r, &mut scene, &computed),
            Plot::Lollipop(lp) => add_lollipop(lp, &mut scene, &computed),
            Plot::Survival(sp) => add_survival(sp, &mut scene, &computed),
            Plot::Ecdf(ep) => add_ecdf(ep, &computed, &mut scene),
            Plot::QQ(qp) => add_qqplot(qp, &computed, &mut scene),
            Plot::Streamgraph(sg) => add_streamgraph(sg, &mut scene, &computed),
            _ => {}
        }
    }
    for plot in secondary.iter() {
        match plot {
            Plot::Scatter(s) => add_scatter(s, &mut scene, &computed_y2),
            Plot::Line(l) => add_line(l, &mut scene, &computed_y2),
            Plot::Series(s) => add_series(s, &mut scene, &computed_y2),
            Plot::Band(b) => add_band(b, &mut scene, &computed_y2),
            Plot::Bar(b) => add_bar(b, &mut scene, &computed_y2),
            Plot::Histogram(h) => add_histogram(h, &mut scene, &computed_y2),
            Plot::Box(b) => add_boxplot(b, &mut scene, &computed_y2),
            Plot::Violin(v) => add_violin(v, &mut scene, &computed_y2),
            Plot::Strip(s) => add_strip(s, &mut scene, &computed_y2),
            Plot::Density(d) => add_density(d, &computed_y2, &mut scene),
            Plot::StackedArea(s) => add_stacked_area(s, &mut scene, &computed_y2),
            Plot::Streamgraph(sg) => add_streamgraph(sg, &mut scene, &computed_y2),
            Plot::Waterfall(w) => add_waterfall(w, &mut scene, &computed_y2),
            Plot::Candlestick(c) => add_candlestick(c, &mut scene, &computed_y2),
            Plot::Raincloud(r) => add_raincloud(r, &mut scene, &computed_y2),
            Plot::Lollipop(lp) => add_lollipop(lp, &mut scene, &computed_y2),
            Plot::Survival(sp) => add_survival(sp, &mut scene, &computed_y2),
            Plot::Ecdf(ep) => add_ecdf(ep, &computed_y2, &mut scene),
            Plot::QQ(qp) => add_qqplot(qp, &computed_y2, &mut scene),
            _ => {}
        }
    }

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    scene.elements.push(Primitive::ClipEnd);

    let mut all_plots_for_legend: Vec<Plot> = primary;
    all_plots_for_legend.extend(secondary);
    let (entries, groups) = if let Some(ref grps) = layout.legend_groups {
        (Vec::new(), Some(grps.clone()))
    } else {
        let e = layout
            .legend_entries
            .clone()
            .unwrap_or_else(|| collect_legend_entries(&all_plots_for_legend));
        (e, None)
    };

    let stats_result = add_stats_box(&layout, &mut scene, &computed);

    if layout.show_legend && (!entries.is_empty() || groups.is_some()) {
        let legend = Legend {
            title: layout.legend_title.clone(),
            entries,
            groups,
            position: layout.legend_position,
            show_box: layout.legend_box,
        };
        let y_offset = if let Some((_, _stats_y, stats_h)) = stats_result {
            let positions_match = std::mem::discriminant(&layout.stats_position)
                == std::mem::discriminant(&layout.legend_position);
            if positions_match {
                stats_h + 8.0
            } else {
                0.0
            }
        } else {
            0.0
        };
        add_legend_with_offset(&legend, &mut scene, &computed, y_offset);
    }

    scene
}

// ── Phylogenetic tree ─────────────────────────────────────────────────────────

fn add_phylo_tree(tree: &PhyloTree, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::plot::phylo::post_order_dfs;
    use std::f64::consts::PI;

    let n_nodes = tree.nodes.len();
    if n_nodes == 0 {
        return;
    }

    // ── Step 1: post-order DFS to assign leaf positions ──────────────────────
    let post_order = post_order_dfs(tree.root, &tree.nodes);

    let mut pos: Vec<f64> = vec![0.0; n_nodes];
    let mut leaf_counter = 0usize;
    for &id in &post_order {
        if tree.nodes[id].children.is_empty() {
            pos[id] = leaf_counter as f64;
            leaf_counter += 1;
        } else {
            // mean of children positions
            let sum: f64 = tree.nodes[id].children.iter().map(|&c| pos[c]).sum();
            pos[id] = sum / tree.nodes[id].children.len() as f64;
        }
    }
    let n_leaves = leaf_counter;
    if n_leaves == 0 {
        return;
    }

    // Collect ordered leaves for label rendering
    let leaves: Vec<usize> = post_order
        .iter()
        .copied()
        .filter(|&id| tree.nodes[id].children.is_empty())
        .collect();

    // ── Step 2: depth (x) positions ──────────────────────────────────────────
    let depth: Vec<f64>;
    let max_depth_f: f64;

    if tree.phylogram {
        // Phylogram: accumulate branch lengths (BFS from root)
        let mut acc = vec![0.0f64; n_nodes];
        let mut queue = std::collections::VecDeque::new();
        queue.push_back(tree.root);
        while let Some(id) = queue.pop_front() {
            for &child in &tree.nodes[id].children {
                acc[child] = acc[id] + tree.nodes[child].branch_length;
                queue.push_back(child);
            }
        }
        let max_len = leaves.iter().map(|&l| acc[l]).fold(0.0_f64, f64::max);
        max_depth_f = if max_len > 0.0 { max_len } else { 1.0 };
        depth = acc;
    } else {
        // Cladogram: leaves align at max depth
        let mut subtree_depth = vec![0usize; n_nodes];
        for &id in &post_order {
            if tree.nodes[id].children.is_empty() {
                subtree_depth[id] = 0;
            } else {
                subtree_depth[id] = tree.nodes[id]
                    .children
                    .iter()
                    .map(|&c| subtree_depth[c] + 1)
                    .max()
                    .unwrap_or(0);
            }
        }
        let max_depth = subtree_depth[tree.root];
        max_depth_f = max_depth as f64;
        depth = (0..n_nodes)
            .map(|i| (max_depth - subtree_depth[i]) as f64)
            .collect();
    }

    // ── Step 3: pixel mapping ─────────────────────────────────────────────────
    let pw = computed.plot_width();
    let ph = computed.plot_height();
    let ml = computed.margin_left;
    let mt = computed.margin_top;

    // Reserve pixels for leaf labels and general padding so labels don't clip.
    let max_label_chars = tree
        .nodes
        .iter()
        .filter(|n| n.children.is_empty())
        .filter_map(|n| n.label.as_ref())
        .map(|l| l.len())
        .max()
        .unwrap_or(6);
    let label_pad = ((max_label_chars as f64) * 7.0 + 20.0).clamp(70.0, 200.0);
    let edge_pad = 25.0_f64;

    // Effective rendering area — leaves land inside this box; labels overflow into the reserved strip.
    let (eff_ml, eff_mt, eff_pw, eff_ph) = match tree.branch_style {
        TreeBranchStyle::Circular => {
            // Compute the largest radius that keeps all labels on-canvas.
            // Horizontal: leaf at angle 0 or π needs (max_r + label_gap + label_text_width + edge_pad) ≤ pw/2
            // Vertical:   leaf at angle ±π/2 needs (max_r + half_line + edge_pad) ≤ ph/2
            let label_gap = 8.0_f64;
            let half_line = 7.0_f64; // half a 14px text line — labels don't extend much beyond center
            let char_w = 7.0_f64;
            let h_clear = edge_pad + label_gap + max_label_chars as f64 * char_w;
            let v_clear = edge_pad + half_line;
            let max_r = (pw / 2.0 - h_clear).min(ph / 2.0 - v_clear).max(10.0);
            let cx = ml + pw / 2.0;
            let cy = mt + ph / 2.0;
            (cx - max_r, cy - max_r, 2.0 * max_r, 2.0 * max_r)
        }
        _ => match tree.orientation {
            TreeOrientation::Left => (
                ml + edge_pad,
                mt + edge_pad,
                (pw - label_pad - edge_pad).max(50.0),
                (ph - 2.0 * edge_pad).max(50.0),
            ),
            TreeOrientation::Right => (
                ml + label_pad,
                mt + edge_pad,
                (pw - label_pad - edge_pad).max(50.0),
                (ph - 2.0 * edge_pad).max(50.0),
            ),
            TreeOrientation::Top => (
                ml + edge_pad,
                mt + edge_pad,
                (pw - 2.0 * edge_pad).max(50.0),
                (ph - label_pad - edge_pad).max(50.0),
            ),
            TreeOrientation::Bottom => (
                ml + edge_pad,
                mt + label_pad,
                (pw - 2.0 * edge_pad).max(50.0),
                (ph - label_pad - edge_pad).max(50.0),
            ),
        },
    };

    let d_frac = |i: usize| -> f64 {
        if max_depth_f > 0.0 {
            depth[i] / max_depth_f
        } else {
            0.0
        }
    };
    let p_frac = |i: usize| -> f64 { (pos[i] + 0.5) / n_leaves as f64 };

    let (px, py, r_arr, theta_arr): (Vec<f64>, Vec<f64>, Vec<f64>, Vec<f64>) =
        if tree.branch_style == TreeBranchStyle::Circular {
            let cx = eff_ml + eff_pw / 2.0;
            let cy = eff_mt + eff_ph / 2.0;
            let max_r = eff_pw.min(eff_ph) * 0.5;
            let mut pxv = vec![0.0; n_nodes];
            let mut pyv = vec![0.0; n_nodes];
            let mut rv = vec![0.0; n_nodes];
            let mut tv = vec![0.0; n_nodes];
            for i in 0..n_nodes {
                let r = d_frac(i) * max_r;
                let theta = p_frac(i) * 2.0 * PI;
                rv[i] = r;
                tv[i] = theta;
                pxv[i] = cx + r * theta.cos();
                pyv[i] = cy + r * theta.sin();
            }
            (pxv, pyv, rv, tv)
        } else {
            let mut pxv = vec![0.0; n_nodes];
            let mut pyv = vec![0.0; n_nodes];
            for i in 0..n_nodes {
                let df = d_frac(i);
                let pf = p_frac(i);
                let (x, y) = match tree.orientation {
                    TreeOrientation::Left => (eff_ml + df * eff_pw, eff_mt + pf * eff_ph),
                    TreeOrientation::Right => (eff_ml + (1.0 - df) * eff_pw, eff_mt + pf * eff_ph),
                    TreeOrientation::Top => (eff_ml + pf * eff_pw, eff_mt + df * eff_ph),
                    TreeOrientation::Bottom => (eff_ml + pf * eff_pw, eff_mt + (1.0 - df) * eff_ph),
                };
                pxv[i] = x;
                pyv[i] = y;
            }
            (pxv, pyv, vec![0.0; n_nodes], vec![0.0; n_nodes])
        };

    // ── Step 4: clade color lookup ────────────────────────────────────────────
    let mut node_color: Vec<String> = vec![tree.branch_color.clone(); n_nodes];
    for &(clade_root, ref color) in &tree.clade_colors {
        let mut stack = vec![clade_root];
        while let Some(id) = stack.pop() {
            if id < n_nodes {
                node_color[id] = color.clone();
                for &child in &tree.nodes[id].children {
                    stack.push(child);
                }
            }
        }
    }

    // ── Step 5: draw branches ─────────────────────────────────────────────────
    let sw = 1.5_f64;

    match tree.branch_style {
        TreeBranchStyle::Slanted => {
            for i in 0..n_nodes {
                if let Some(p) = tree.nodes[i].parent {
                    scene.elements.push(Primitive::Line {
                        x1: px[p],
                        y1: py[p],
                        x2: px[i],
                        y2: py[i],
                        stroke: Color::from(&node_color[i]),
                        stroke_width: sw,
                        stroke_dasharray: None,
                    });
                }
            }
        }
        TreeBranchStyle::Rectangular => {
            let horiz = matches!(
                tree.orientation,
                TreeOrientation::Left | TreeOrientation::Right
            );
            // Arms
            for i in 0..n_nodes {
                if let Some(p) = tree.nodes[i].parent {
                    if horiz {
                        scene.elements.push(Primitive::Line {
                            x1: px[p],
                            y1: py[i],
                            x2: px[i],
                            y2: py[i],
                            stroke: Color::from(&node_color[i]),
                            stroke_width: sw,
                            stroke_dasharray: None,
                        });
                    } else {
                        scene.elements.push(Primitive::Line {
                            x1: px[i],
                            y1: py[p],
                            x2: px[i],
                            y2: py[i],
                            stroke: Color::from(&node_color[i]),
                            stroke_width: sw,
                            stroke_dasharray: None,
                        });
                    }
                }
            }
            // Spines
            for i in 0..n_nodes {
                let children = &tree.nodes[i].children;
                if children.is_empty() {
                    continue;
                }
                if horiz {
                    let y_min = children
                        .iter()
                        .map(|&c| py[c])
                        .fold(f64::INFINITY, f64::min);
                    let y_max = children
                        .iter()
                        .map(|&c| py[c])
                        .fold(f64::NEG_INFINITY, f64::max);
                    scene.elements.push(Primitive::Line {
                        x1: px[i],
                        y1: y_min,
                        x2: px[i],
                        y2: y_max,
                        stroke: Color::from(&node_color[i]),
                        stroke_width: sw,
                        stroke_dasharray: None,
                    });
                } else {
                    let x_min = children
                        .iter()
                        .map(|&c| px[c])
                        .fold(f64::INFINITY, f64::min);
                    let x_max = children
                        .iter()
                        .map(|&c| px[c])
                        .fold(f64::NEG_INFINITY, f64::max);
                    scene.elements.push(Primitive::Line {
                        x1: x_min,
                        y1: py[i],
                        x2: x_max,
                        y2: py[i],
                        stroke: Color::from(&node_color[i]),
                        stroke_width: sw,
                        stroke_dasharray: None,
                    });
                }
            }
        }
        TreeBranchStyle::Circular => {
            let cx = eff_ml + eff_pw / 2.0;
            let cy = eff_mt + eff_ph / 2.0;

            // Radial arms: at child's theta, from parent radius to child radius
            for i in 0..n_nodes {
                if let Some(p) = tree.nodes[i].parent {
                    let theta_c = theta_arr[i];
                    let r_p = r_arr[p];
                    let x1 = cx + r_p * theta_c.cos();
                    let y1 = cy + r_p * theta_c.sin();
                    scene.elements.push(Primitive::Line {
                        x1,
                        y1,
                        x2: px[i],
                        y2: py[i],
                        stroke: Color::from(&node_color[i]),
                        stroke_width: sw,
                        stroke_dasharray: None,
                    });
                }
            }
            // Arc spines at each internal node's radius
            for i in 0..n_nodes {
                let children = &tree.nodes[i].children;
                if children.is_empty() {
                    continue;
                }
                let r_i = r_arr[i];
                if r_i < 1.0 {
                    continue;
                }

                let thetas: Vec<f64> = children.iter().map(|&c| theta_arr[c]).collect();
                let theta_min = thetas.iter().cloned().fold(f64::INFINITY, f64::min);
                let theta_max = thetas.iter().cloned().fold(f64::NEG_INFINITY, f64::max);

                let x_start = cx + r_i * theta_min.cos();
                let y_start = cy + r_i * theta_min.sin();
                let x_end = cx + r_i * theta_max.cos();
                let y_end = cy + r_i * theta_max.sin();

                let arc_span = theta_max - theta_min;
                let large_arc = if arc_span > PI { 1 } else { 0 };

                let d = format!(
                    "M {:.3} {:.3} A {:.3} {:.3} 0 {} 1 {:.3} {:.3}",
                    x_start, y_start, r_i, r_i, large_arc, x_end, y_end
                );
                scene.elements.push(Primitive::Path(Box::new(PathData {
                    d,
                    fill: None,
                    stroke: Color::from(&node_color[i]),
                    stroke_width: sw,
                    opacity: None,
                    stroke_dasharray: None,
                })));
            }
        }
    }

    // ── Step 6: root marker ───────────────────────────────────────────────────
    scene.elements.push(Primitive::Circle {
        cx: px[tree.root],
        cy: py[tree.root],
        r: 3.0,
        fill: Color::from(&tree.branch_color),
        fill_opacity: None,
        stroke: None,
        stroke_width: None,
    });

    // ── Step 7: leaf labels ───────────────────────────────────────────────────
    for &leaf in &leaves {
        if let Some(ref label) = tree.nodes[leaf].label {
            let (lx, ly, anchor, rotate) = match tree.branch_style {
                TreeBranchStyle::Circular => {
                    let theta = theta_arr[leaf];
                    let offset = 8.0;
                    let lx = px[leaf] + offset * theta.cos();
                    let ly = py[leaf] + offset * theta.sin() + 4.0;
                    let anc = if theta.cos() >= 0.0 {
                        TextAnchor::Start
                    } else {
                        TextAnchor::End
                    };
                    (lx, ly, anc, None)
                }
                _ => match tree.orientation {
                    TreeOrientation::Left => (px[leaf] + 6.0, py[leaf], TextAnchor::Start, None),
                    TreeOrientation::Right => (px[leaf] - 6.0, py[leaf], TextAnchor::End, None),
                    TreeOrientation::Top => {
                        (px[leaf], py[leaf] + 6.0, TextAnchor::Start, Some(90.0))
                    }
                    TreeOrientation::Bottom => {
                        (px[leaf], py[leaf] - 6.0, TextAnchor::End, Some(90.0))
                    }
                },
            };
            scene.elements.push(Primitive::Text {
                x: lx,
                y: ly,
                content: label.clone(),
                size: 11,
                anchor,
                rotate,
                bold: false,
                color: None,
            });
        }
    }

    // ── Step 8: support values ────────────────────────────────────────────────
    if let Some(threshold) = tree.support_threshold {
        for i in 0..n_nodes {
            if tree.nodes[i].children.is_empty() {
                continue;
            }
            if let Some(support) = tree.nodes[i].support {
                if support >= threshold {
                    scene.elements.push(Primitive::Text {
                        x: px[i] + 2.0,
                        y: py[i] - 2.0,
                        content: format!("{}", support as u32),
                        size: 10,
                        anchor: TextAnchor::Start,
                        rotate: None,
                        bold: false,
                        color: None,
                    });
                }
            }
        }
    }
}

pub fn render_phylo_tree(tree: &PhyloTree, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    add_phylo_tree(tree, &mut scene, &computed);

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    scene
}

fn add_synteny(synteny: &SyntenyPlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::render::palette::Palette;

    if synteny.sequences.is_empty() {
        return;
    }

    let pw = computed.plot_width();
    let ph = computed.plot_height();
    let ml = computed.margin_left;
    let mt = computed.margin_top;
    let n = synteny.sequences.len();

    // Reserve left margin for bar labels
    let max_label_chars = synteny
        .sequences
        .iter()
        .map(|s| s.label.len())
        .max()
        .unwrap_or(0);
    let label_pad = (max_label_chars as f64 * 7.0 + 15.0).clamp(60.0, 160.0);
    let edge_pad = 20.0;

    let bar_x_left = ml + label_pad + edge_pad;
    let bar_x_right = ml + pw - edge_pad;
    let bar_px_width = bar_x_right - bar_x_left;
    let bar_h = synteny.bar_height;

    // Global max for shared scale
    let global_max = synteny
        .sequences
        .iter()
        .map(|s| s.length)
        .fold(0.0_f64, f64::max);

    // Evenly distribute bars top-to-bottom
    let gap = if n > 1 {
        ((ph - 2.0 * edge_pad - n as f64 * bar_h) / (n - 1) as f64).max(bar_h * 1.5)
    } else {
        0.0
    };
    let bar_top: Vec<f64> = (0..n)
        .map(|i| mt + edge_pad + i as f64 * (bar_h + gap))
        .collect();

    // X-coordinate mapping
    let x_of = |seq_idx: usize, pos: f64| -> f64 {
        let raw = if synteny.shared_scale && global_max > 0.0 {
            bar_x_left + (pos / global_max) * bar_px_width
        } else {
            let len = synteny.sequences[seq_idx].length;
            bar_x_left
                + if len > 0.0 {
                    (pos / len) * bar_px_width
                } else {
                    0.0
                }
        };
        raw.clamp(bar_x_left, bar_x_right)
    };

    let fallback = Palette::category10();

    // Step 1 — Draw ribbons (before bars, so bars overlay them)
    for (block_idx, block) in synteny.blocks.iter().enumerate() {
        // Ensure r1 = upper row, r2 = lower row
        let (r1, s1_lo, s1_hi, r2, s2_lo, s2_hi) = if block.seq1 <= block.seq2 {
            (
                block.seq1,
                block.start1,
                block.end1,
                block.seq2,
                block.start2,
                block.end2,
            )
        } else {
            (
                block.seq2,
                block.start2,
                block.end2,
                block.seq1,
                block.start1,
                block.end1,
            )
        };

        if r1 >= n || r2 >= n {
            continue;
        }

        let x1_s = x_of(r1, s1_lo);
        let x1_e = x_of(r1, s1_hi);
        let x2_s = x_of(r2, s2_lo);
        let x2_e = x_of(r2, s2_hi);

        let y1_bot = bar_top[r1] + bar_h;
        let y2_top = bar_top[r2];
        let y_mid = (y1_bot + y2_top) / 2.0;

        // Determine strand: if seq1 > seq2 and strand is Reverse, the swap above
        // inverts meaning, so we preserve the original strand on the (possibly swapped) pair.
        let is_inverted = block.strand == Strand::Reverse;

        let color = block
            .color
            .clone()
            .unwrap_or_else(|| fallback[block_idx % fallback.len()].to_string());

        let d = if !is_inverted {
            // Forward: parallel Bézier sides
            format!(
                "M {x1_s} {y1_bot} C {x1_s} {y_mid} {x2_s} {y_mid} {x2_s} {y2_top} \
                 L {x2_e} {y2_top} C {x2_e} {y_mid} {x1_e} {y_mid} {x1_e} {y1_bot} Z",
                x1_s = x1_s,
                y1_bot = y1_bot,
                y_mid = y_mid,
                x2_s = x2_s,
                y2_top = y2_top,
                x2_e = x2_e,
                x1_e = x1_e
            )
        } else {
            // Inverted: self-intersecting path — SVG nonzero fill rule fills the crossing
            format!(
                "M {x1_s} {y1_bot} C {x1_s} {y_mid} {x2_e} {y_mid} {x2_e} {y2_top} \
                 L {x2_s} {y2_top} C {x2_s} {y_mid} {x1_e} {y_mid} {x1_e} {y1_bot} Z",
                x1_s = x1_s,
                y1_bot = y1_bot,
                y_mid = y_mid,
                x2_e = x2_e,
                y2_top = y2_top,
                x2_s = x2_s,
                x1_e = x1_e
            )
        };

        scene.elements.push(Primitive::Path(Box::new(PathData {
            d,
            fill: Some(Color::from(&color)),
            stroke: color.into(),
            stroke_width: 0.3,
            opacity: Some(synteny.block_opacity),
            stroke_dasharray: None,
        })));
    }

    // Step 2 — Draw sequence bars (on top of ribbons)
    for (i, seq) in synteny.sequences.iter().enumerate() {
        let bar_color = seq.color.clone().unwrap_or_else(|| "#555555".to_string());
        let x_right = if synteny.shared_scale && global_max > 0.0 {
            bar_x_left + (seq.length / global_max) * bar_px_width
        } else {
            bar_x_right
        };
        scene.elements.push(Primitive::Rect {
            x: bar_x_left,
            y: bar_top[i],
            width: (x_right - bar_x_left).max(0.0),
            height: bar_h,
            fill: bar_color.into(),
            stroke: None,
            stroke_width: None,
            opacity: None,
        });
    }

    // Step 3 — Bar labels (right-anchored, flush to left of bars)
    for (i, seq) in synteny.sequences.iter().enumerate() {
        scene.elements.push(Primitive::Text {
            x: bar_x_left - 6.0,
            y: bar_top[i] + bar_h / 2.0 + 4.0,
            content: seq.label.clone(),
            size: computed.body_size,
            anchor: TextAnchor::End,
            rotate: None,
            bold: false,
            color: None,
        });
    }
}

pub fn render_synteny(synteny: &SyntenyPlot, layout: &Layout) -> Scene {
    let computed = ComputedLayout::from_layout(layout);
    let mut scene = Scene::new(computed.width, computed.height);
    scene.font_family = computed.font_family.clone();
    apply_theme(&mut scene, &computed.theme);

    add_labels_and_title(&mut scene, &computed, layout);
    add_shaded_regions(&layout.shaded_regions, &mut scene, &computed);

    add_synteny(synteny, &mut scene, &computed);

    add_reference_lines(&layout.reference_lines, &mut scene, &computed);
    add_text_annotations(&layout.annotations, &mut scene, &computed);

    scene
}

// ── Polar Plot ────────────────────────────────────────────────────────────────

fn add_polar(pp: &PolarPlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::render::palette::Palette;

    let pw = computed.plot_width();
    let ph = computed.plot_height();
    let ml = computed.margin_left;
    let mt = computed.margin_top;

    // Center of the polar circle
    let cx = ml + pw / 2.0;
    let cy = mt + ph / 2.0;

    // Available radius (leave a margin for labels)
    let label_pad = 30.0_f64;
    let avail_r = (pw / 2.0 - label_pad).min(ph / 2.0 - label_pad).max(20.0);

    let grid_color = Color::from(&*computed.theme.grid_color);
    let axis_color = Color::from(&*computed.theme.axis_color);
    let stroke_w = computed.axis_stroke_width;
    let tick_sz = computed.tick_size;

    // Determine r_min / r_max
    let r_min = pp.r_min.unwrap_or(0.0);
    let r_max = pp.r_max.unwrap_or_else(|| pp.r_max_auto());
    // Guard: range must be positive.
    let r_range = (r_max - r_min).max(f64::EPSILON);

    let n_rings = pp.r_grid_lines.unwrap_or(4).max(1);
    let n_div = pp.theta_divisions.max(2);

    // Helper: convert (r_data, theta_deg) → (px, py)
    // Points with r_data < r_min are clamped to the centre.
    let theta_to_px = |r_data: f64, theta_deg: f64| -> (f64, f64) {
        let r_frac = (r_data - r_min).max(0.0) / r_range;
        let display_angle = pp.theta_start + theta_deg * if pp.clockwise { 1.0 } else { -1.0 };
        // svg_angle: angle from east axis in standard math (CCW positive)
        let svg_angle = (90.0 - display_angle).to_radians();
        let px = cx + r_frac * avail_r * svg_angle.cos();
        let py = cy - r_frac * avail_r * svg_angle.sin(); // SVG y is down
        (round2(px), round2(py))
    };

    if pp.show_grid {
        // Labels are collected here and emitted after all geometry so that
        // spoke/ring lines never paint over text.
        let mut label_prims: Vec<Primitive> = Vec::new();

        // ── Concentric grid circles ───────────────────────────────────────────
        for i in 1..=n_rings {
            let r = avail_r * (i as f64) / (n_rings as f64);
            let is_outer = i == n_rings;
            let (stroke, dasharray) = if is_outer {
                (axis_color.clone(), None)
            } else {
                (grid_color.clone(), Some("4,4".to_string()))
            };
            let mut d = String::new();
            let _ = write!(
                d,
                "M {},{} A {},{},0,1,0,{},{} A {},{},0,1,0,{},{} Z",
                round2(cx - r),
                round2(cy),
                round2(r),
                round2(r),
                round2(cx + r),
                round2(cy),
                round2(r),
                round2(r),
                round2(cx - r),
                round2(cy),
            );
            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: None,
                stroke,
                stroke_width: stroke_w,
                opacity: None,
                stroke_dasharray: dasharray,
            })));

            // R-value label: placed at the midpoint angle between the 0° spoke
            // and the first clockwise spoke so it never overlaps the 0° theta label.
            if pp.show_r_labels {
                let r_val = r_min + r_range * (i as f64) / (n_rings as f64);
                let label = if r_val.fract() == 0.0 {
                    format!("{}", r_val as i64)
                } else {
                    format!("{:.2}", r_val)
                };
                let mid_deg = computed
                    .polar_r_label_angle
                    .unwrap_or(360.0 / (n_div as f64 * 2.0));
                let display_angle =
                    pp.theta_start + mid_deg * if pp.clockwise { 1.0 } else { -1.0 };
                let svg_angle = (90.0 - display_angle).to_radians();
                let lx = round2(cx + r * svg_angle.cos() + 2.0);
                let ly = round2(cy - r * svg_angle.sin() - 2.0);
                label_prims.push(Primitive::Text {
                    x: lx,
                    y: ly,
                    content: label,
                    size: tick_sz,
                    anchor: TextAnchor::Start,
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }
        }

        // Centre label — only when r_min != 0 so readers know what the origin represents.
        if pp.show_r_labels && r_min != 0.0 {
            let label = if r_min.fract() == 0.0 {
                format!("{}", r_min as i64)
            } else {
                format!("{:.2}", r_min)
            };
            label_prims.push(Primitive::Text {
                x: round2(cx + 4.0),
                y: round2(cy - 2.0),
                content: label,
                size: tick_sz,
                anchor: TextAnchor::Start,
                rotate: None,
                bold: false,
                color: None,
            });
        }

        // ── Spoke lines ───────────────────────────────────────────────────────
        let label_gap = 10.0_f64; // fixed pixel gap beyond outer ring
        let ts = tick_sz as f64;
        for i in 0..n_div {
            let theta_deg = i as f64 * 360.0 / n_div as f64;
            let (x2, y2) = theta_to_px(r_max, theta_deg);
            scene.add(Primitive::Line {
                x1: round2(cx),
                y1: round2(cy),
                x2,
                y2,
                stroke: grid_color.clone(),
                stroke_width: stroke_w,
                stroke_dasharray: None,
            });

            // Spoke angle label — computed directly (not via theta_to_px, which
            // clamps to the data range) so the label always lands outside the ring.
            let display_angle = pp.theta_start + theta_deg * if pp.clockwise { 1.0 } else { -1.0 };
            let svg_angle = (90.0 - display_angle).to_radians();
            let cos_a = svg_angle.cos();
            let sin_a = svg_angle.sin();
            let label_r = avail_r + label_gap;
            let lx = cx + label_r * cos_a;
            let ly_raw = cy - label_r * sin_a;

            // Direction-aware vertical alignment.
            // SVG text y is the baseline; text renders above it.
            // Labels above centre (sin_a > 0) naturally clear the ring —
            //   shift the baseline up a little so the text body sits clear.
            // Labels below centre (sin_a < 0) need the baseline pushed further
            //   down so the text body (which grows upward) clears the ring.
            // Horizontal labels get a mid-height nudge.
            let ly = if sin_a > 0.15 {
                ly_raw - ts * 0.2 // above centre: nudge up
            } else if sin_a < -0.15 {
                ly_raw + ts * 0.8 // below centre: shift baseline down by ~cap-height
            } else {
                ly_raw + ts * 0.35 // horizontal: small centering nudge
            };

            let anchor = if cos_a < -0.1 {
                TextAnchor::End
            } else if cos_a > 0.1 {
                TextAnchor::Start
            } else {
                TextAnchor::Middle
            };
            label_prims.push(Primitive::Text {
                x: round2(lx),
                y: round2(ly),
                content: computed.x_tick_format.format(theta_deg),
                size: tick_sz,
                anchor,
                rotate: None,
                bold: false,
                color: None,
            });
        }

        // Emit all labels after geometry so lines never overdraw text.
        for prim in label_prims {
            scene.add(prim);
        }
    }

    // ── Data series ───────────────────────────────────────────────────────────
    let palette = Palette::category10();
    let mut global_pt_idx: usize = 0;
    for (si, series) in pp.series.iter().enumerate() {
        if series.r.is_empty() {
            continue;
        }
        let color_str = series
            .color
            .clone()
            .unwrap_or_else(|| palette[si % palette.len()].to_string());
        let color = Color::from(&*color_str);

        let pts: Vec<(f64, f64)> = series
            .r
            .iter()
            .zip(series.theta.iter())
            .map(|(&r_val, &t_val)| theta_to_px(r_val, t_val))
            .collect();

        match series.mode {
            PolarMode::Scatter => {
                let r_dot = series.marker_size;
                let stroke = series.marker_stroke_width.map(|_| color.clone());
                for (j, (&(px, py), (&r_val, &theta_val))) in pts
                    .iter()
                    .zip(series.r.iter().zip(series.theta.iter()))
                    .enumerate()
                {
                    let tip = tooltip(
                        pp.show_tooltips,
                        &pp.tooltip_labels,
                        global_pt_idx + j,
                        || format!("r={:.2}, θ={:.1}°", r_val, theta_val),
                    );
                    if let Some(ref t) = tip {
                        scene.add(Primitive::GroupStart {
                            transform: None,
                            title: Some(t.clone()),
                            extra_attrs: None,
                        });
                    }
                    scene.add(Primitive::Circle {
                        cx: px,
                        cy: py,
                        r: r_dot,
                        fill: color.clone(),
                        fill_opacity: series.marker_opacity,
                        stroke: stroke.clone(),
                        stroke_width: series.marker_stroke_width,
                    });
                    if tip.is_some() {
                        scene.add(Primitive::GroupEnd);
                    }
                }
            }
            PolarMode::Line => {
                if pts.len() < 2 {
                    global_pt_idx += series.r.len();
                    continue;
                }
                let path_d = build_path(&pts);
                scene.add(Primitive::Path(Box::new(PathData {
                    d: path_d,
                    fill: None,
                    stroke: color,
                    stroke_width: series.stroke_width,
                    opacity: None,
                    stroke_dasharray: series.line_dash.clone(),
                })));
            }
        }
        global_pt_idx += series.r.len();
    }
}

// ── Ternary Plot ──────────────────────────────────────────────────────────────

fn add_ternary(tp: &TernaryPlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::render::palette::Palette;

    let pw = computed.plot_width();
    let ph = computed.plot_height();
    let ml = computed.margin_left;
    let mt = computed.margin_top;

    let grid_color = Color::from(&*computed.theme.grid_color);
    let axis_color = Color::from(&*computed.theme.axis_color);
    let _text_color = Color::from(&*computed.theme.text_color);
    let stroke_w = computed.axis_stroke_width;
    let tick_sz = computed.tick_size;
    let body_sz = computed.body_size;

    // Triangle geometry — equilateral, anchored so top vertex has clearance from title.
    // edge_pad_top: space above top vertex for its corner label.
    // edge_pad_bot: space below base for bottom corner labels + tick labels.
    // edge_pad_side: space either side for left/right tick labels.
    let edge_pad_top = tick_sz as f64 * 2.5;
    let edge_pad_bot = tick_sz as f64 * 3.5;
    let edge_pad_side = tick_sz as f64 * 5.0;
    let avail_w = pw - 2.0 * edge_pad_side;
    let avail_h = ph - edge_pad_top - edge_pad_bot;
    // side such that the triangle fits both width and height constraints
    let side_from_h = avail_h * 2.0 / 3.0_f64.sqrt();
    let side = side_from_h.min(avail_w).max(20.0);
    let tri_h = side * 3.0_f64.sqrt() / 2.0;

    let cx = ml + pw / 2.0;
    // Anchor top vertex at mt + edge_pad_top; derive cy from that.
    let cy = mt + edge_pad_top + 2.0 * tri_h / 3.0;

    // Vertices: A = top, B = bottom-left, C = bottom-right
    let va = (round2(cx), round2(cy - tri_h * 2.0 / 3.0));
    let vb = (round2(cx - side / 2.0), round2(cy + tri_h / 3.0));
    let vc = (round2(cx + side / 2.0), round2(cy + tri_h / 3.0));

    // Barycentric → pixel
    let bary_to_px = |a: f64, b: f64, c: f64| -> (f64, f64) {
        let sum = a + b + c;
        let (na, nb, nc) = if sum > 1e-10 {
            (a / sum, b / sum, c / sum)
        } else {
            (1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0)
        };
        (
            round2(na * va.0 + nb * vb.0 + nc * vc.0),
            round2(na * va.1 + nb * vb.1 + nc * vc.1),
        )
    };

    let n = tp.grid_lines.max(1);

    // ── Grid lines ────────────────────────────────────────────────────────────
    if tp.show_grid {
        for ki in 1..n {
            let k = ki as f64 / n as f64;
            let one_minus_k = 1.0 - k;

            // A = k line: from (k, 1-k, 0) to (k, 0, 1-k)
            let (ax1, ay1) = bary_to_px(k, one_minus_k, 0.0);
            let (ax2, ay2) = bary_to_px(k, 0.0, one_minus_k);
            let mut d = String::new();
            let _ = write!(d, "M {ax1},{ay1} L {ax2},{ay2}");
            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: None,
                stroke: grid_color.clone(),
                stroke_width: stroke_w,
                opacity: None,
                stroke_dasharray: Some("3,3".to_string()),
            })));

            // B = k line: from (1-k, k, 0) to (0, k, 1-k)
            let (bx1, by1) = bary_to_px(one_minus_k, k, 0.0);
            let (bx2, by2) = bary_to_px(0.0, k, one_minus_k);
            let mut d2 = String::new();
            let _ = write!(d2, "M {bx1},{by1} L {bx2},{by2}");
            scene.add(Primitive::Path(Box::new(PathData {
                d: d2,
                fill: None,
                stroke: grid_color.clone(),
                stroke_width: stroke_w,
                opacity: None,
                stroke_dasharray: Some("3,3".to_string()),
            })));

            // C = k line: from (1-k, 0, k) to (0, 1-k, k)
            let (ccx1, ccy1) = bary_to_px(one_minus_k, 0.0, k);
            let (ccx2, ccy2) = bary_to_px(0.0, one_minus_k, k);
            let mut d3 = String::new();
            let _ = write!(d3, "M {ccx1},{ccy1} L {ccx2},{ccy2}");
            scene.add(Primitive::Path(Box::new(PathData {
                d: d3,
                fill: None,
                stroke: grid_color.clone(),
                stroke_width: stroke_w,
                opacity: None,
                stroke_dasharray: Some("3,3".to_string()),
            })));
        }
    }

    // ── Triangle outline ──────────────────────────────────────────────────────
    let outline = format!(
        "M {},{} L {},{} L {},{} Z",
        va.0, va.1, vb.0, vb.1, vc.0, vc.1
    );
    scene.add(Primitive::Path(Box::new(PathData {
        d: outline,
        fill: None,
        stroke: axis_color.clone(),
        stroke_width: stroke_w,
        opacity: None,
        stroke_dasharray: None,
    })));

    // ── Tick labels on edges ──────────────────────────────────────────────────
    if tp.show_percentages {
        for ki in 0..=n {
            let k = ki as f64 / n as f64;
            let pct = (k * 100.0).round() as i32;
            let label = format!("{}%", pct);

            // A-axis: left side (AB edge), A=k, reads 0%→100% bottom-to-top (CCW).
            // Point on AB edge: A=k, B=1-k, C=0.  Labels to the left (End anchor).
            let (ax, ay) = bary_to_px(k, 1.0 - k, 0.0);
            scene.add(Primitive::Text {
                x: round2(ax - 8.0),
                y: round2(ay + 4.0),
                content: label.clone(),
                size: tick_sz,
                anchor: TextAnchor::End,
                rotate: None,
                bold: false,
                color: None,
            });

            // C-axis: right side (CA edge), C=k, reads 0%→100% top-to-bottom (CCW).
            // Point on CA edge: A=1-k, B=0, C=k.  Labels to the right (Start anchor).
            let (ccx, ccy) = bary_to_px(1.0 - k, 0.0, k);
            scene.add(Primitive::Text {
                x: round2(ccx + 8.0),
                y: round2(ccy + 4.0),
                content: label.clone(),
                size: tick_sz,
                anchor: TextAnchor::Start,
                rotate: None,
                bold: false,
                color: None,
            });

            // B-axis: bottom (BC edge), B=k, reads 0%→100% right-to-left (CCW).
            // Point on BC edge: A=0, B=k, C=1-k.  Labels below (Middle anchor).
            let (bx, by) = bary_to_px(0.0, k, 1.0 - k);
            scene.add(Primitive::Text {
                x: round2(bx),
                y: round2(by + 16.0),
                content: label,
                size: tick_sz,
                anchor: TextAnchor::Middle,
                rotate: None,
                bold: false,
                color: None,
            });
        }
    }

    // ── Corner labels ─────────────────────────────────────────────────────────
    // Push well clear of the 0% / 100% tick labels that appear at each vertex.
    let cl_h = body_sz as f64 * 2.2; // vertical clearance from vertex
    let cl_w = body_sz as f64 * 1.8; // horizontal clearance from vertex
                                     // A = top — centred above the top vertex
    scene.add(Primitive::Text {
        x: va.0,
        y: round2(va.1 - cl_h),
        content: tp.corner_labels[0].clone(),
        size: body_sz,
        anchor: TextAnchor::Middle,
        rotate: None,
        bold: true,
        color: None,
    });
    // B = bottom-left
    scene.add(Primitive::Text {
        x: round2(vb.0 - cl_w),
        y: round2(vb.1 + cl_h),
        content: tp.corner_labels[1].clone(),
        size: body_sz,
        anchor: TextAnchor::End,
        rotate: None,
        bold: true,
        color: None,
    });
    // C = bottom-right
    scene.add(Primitive::Text {
        x: round2(vc.0 + cl_w),
        y: round2(vc.1 + cl_h),
        content: tp.corner_labels[2].clone(),
        size: body_sz,
        anchor: TextAnchor::Start,
        rotate: None,
        bold: true,
        color: None,
    });

    // ── Data points ───────────────────────────────────────────────────────────
    if tp.points.is_empty() {
        return;
    }

    let palette = Palette::category10();
    let groups = tp.unique_groups();

    for (tpi, pt) in tp.points.iter().enumerate() {
        let (a, b, c) = if tp.normalize {
            let s = pt.a + pt.b + pt.c;
            if s > 1e-10 {
                (pt.a / s, pt.b / s, pt.c / s)
            } else {
                (1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0)
            }
        } else {
            (pt.a, pt.b, pt.c)
        };

        let color_str = if let Some(ref g) = pt.group {
            let idx = groups.iter().position(|x| x == g).unwrap_or(0);
            palette[idx % palette.len()].to_string()
        } else {
            palette[0].to_string()
        };
        let color = Color::from(&*color_str);

        let stroke = tp.marker_stroke_width.map(|_| color.clone());
        let (px, py) = bary_to_px(a, b, c);
        let tip = tooltip(tp.show_tooltips, &tp.tooltip_labels, tpi, || {
            format!("A={:.2}, B={:.2}, C={:.2}", pt.a, pt.b, pt.c)
        });
        if let Some(ref t) = tip {
            scene.add(Primitive::GroupStart {
                transform: None,
                title: Some(t.clone()),
                extra_attrs: None,
            });
        }
        scene.add(Primitive::Circle {
            cx: px,
            cy: py,
            r: tp.marker_size,
            fill: color,
            fill_opacity: tp.marker_opacity,
            stroke,
            stroke_width: tp.marker_stroke_width,
        });
        if tip.is_some() {
            scene.add(Primitive::GroupEnd);
        }
    }
}

// ── JointPlot ─────────────────────────────────────────────────────────────

/// Compute histogram bin counts for `data` over the range `[lo, hi]` with `n_bins` bins.
/// Returns `(bin_start, bin_end, count)` for every bin.
fn joint_histogram_bins(data: &[f64], lo: f64, hi: f64, n_bins: usize) -> Vec<(f64, f64, usize)> {
    let n = n_bins.max(1);
    let span = hi - lo;
    let bw = if span > 1e-12 { span / n as f64 } else { 1.0 };
    let mut counts = vec![0usize; n];
    for &v in data {
        if v < lo || v > hi {
            continue;
        }
        let idx = ((v - lo) / bw).floor() as usize;
        counts[idx.min(n - 1)] += 1;
    }
    (0..n)
        .map(|i| (lo + i as f64 * bw, lo + (i + 1) as f64 * bw, counts[i]))
        .collect()
}

/// Draw a top marginal (histogram or density) for a `JointPlot`.
/// `panel_bottom` is the y-coordinate (master scene) of the bottom of the top panel.
/// `panel_h` is the pixel height of the top panel.
/// `scatter_computed` provides the x coordinate mapping.
fn joint_draw_top_marginal(
    jp: &crate::plot::jointplot::JointPlot,
    scatter_computed: &ComputedLayout,
    panel_bottom: f64,
    panel_h: f64,
    scene: &mut Scene,
) {
    use crate::plot::jointplot::MarginalType;
    use crate::render::palette::Palette;
    use render_utils::{silverman_bandwidth, simple_kde};

    let (x_lo, x_hi) = scatter_computed.x_range;
    let usable_h = panel_h * 0.90;

    for (gi, group) in jp.groups.iter().enumerate() {
        let color_str = if group.scatter.color == "black" {
            Palette::category10()[gi % 10].to_string()
        } else {
            group.scatter.color.clone()
        };
        let xs = group.x_values();

        match &jp.marginal_type {
            MarginalType::Histogram => {
                let bins = joint_histogram_bins(&xs, x_lo, x_hi, jp.bins);
                let max_c = bins.iter().map(|b| b.2).max().unwrap_or(1).max(1) as f64;
                for (b0, b1, count) in &bins {
                    if *count == 0 {
                        continue;
                    }
                    let px0 = scatter_computed.map_x(*b0);
                    let px1 = scatter_computed.map_x(*b1);
                    let bar_h = (*count as f64 / max_c) * usable_h;
                    scene.add(Primitive::Rect {
                        x: px0 + 0.5,
                        y: panel_bottom - bar_h,
                        width: (px1 - px0 - 1.0).max(1.0),
                        height: bar_h,
                        fill: Color::from(&*color_str),
                        stroke: None,
                        stroke_width: None,
                        opacity: Some(jp.marginal_alpha),
                    });
                }
            }
            MarginalType::Density => {
                if xs.is_empty() {
                    continue;
                }
                let bw = jp.bandwidth.unwrap_or_else(|| silverman_bandwidth(&xs));
                let pts = simple_kde(&xs, bw, 200);
                // Clip pts to [x_lo, x_hi] and scale density to panel height
                let in_range: Vec<_> = pts
                    .iter()
                    .filter(|(x, _)| *x >= x_lo && *x <= x_hi)
                    .collect();
                if in_range.len() < 2 {
                    continue;
                }
                let max_d = in_range.iter().map(|(_, d)| *d).fold(0.0_f64, f64::max);
                if max_d < 1e-12 {
                    continue;
                }
                // Build filled polygon path: bottom-left → polyline top → bottom-right → close
                let first_x = scatter_computed.map_x(in_range[0].0);
                let last_x = scatter_computed.map_x(in_range[in_range.len() - 1].0);
                let mut path = format!("M {first_x:.1} {panel_bottom:.1}");
                for (x, d) in &in_range {
                    let px = scatter_computed.map_x(*x);
                    let py = panel_bottom - (d / max_d) * usable_h;
                    path.push_str(&format!(" L {px:.1} {py:.1}"));
                }
                path.push_str(&format!(" L {last_x:.1} {panel_bottom:.1} Z"));
                scene.add(Primitive::Path(Box::new(PathData {
                    d: path,
                    fill: Some(Color::from(&*color_str)),
                    stroke: Color::from(&*color_str),
                    stroke_width: 1.5,
                    opacity: Some(jp.marginal_alpha),
                    stroke_dasharray: None,
                })));
            }
        }
    }

    // Separator line at bottom of top panel
    let x_lo_px = scatter_computed.map_x(x_lo);
    let x_hi_px = scatter_computed.map_x(x_hi);
    scene.add(Primitive::Line {
        x1: x_lo_px,
        y1: panel_bottom,
        x2: x_hi_px,
        y2: panel_bottom,
        stroke: Color::from("#cccccc"),
        stroke_width: 1.0,
        stroke_dasharray: None,
    });
}

/// Draw a right marginal (horizontal histogram or density) for a `JointPlot`.
/// `panel_left` is the x-coordinate (master scene) of the left edge of the right panel.
/// `panel_w` is the pixel width of the right panel.
/// `scatter_offset_y` is the vertical offset of the scatter sub-scene in master coords.
/// `scatter_computed` provides the y coordinate mapping.
fn joint_draw_right_marginal(
    jp: &crate::plot::jointplot::JointPlot,
    scatter_computed: &ComputedLayout,
    scatter_offset_y: f64,
    panel_left: f64,
    panel_w: f64,
    scene: &mut Scene,
) {
    use crate::plot::jointplot::MarginalType;
    use crate::render::palette::Palette;
    use render_utils::{silverman_bandwidth, simple_kde};

    let (y_lo, y_hi) = scatter_computed.y_range;
    let usable_w = panel_w * 0.90;

    for (gi, group) in jp.groups.iter().enumerate() {
        let color_str = if group.scatter.color == "black" {
            Palette::category10()[gi % 10].to_string()
        } else {
            group.scatter.color.clone()
        };
        let ys = group.y_values();

        match &jp.marginal_type {
            MarginalType::Histogram => {
                let bins = joint_histogram_bins(&ys, y_lo, y_hi, jp.bins);
                let max_c = bins.iter().map(|b| b.2).max().unwrap_or(1).max(1) as f64;
                for (b0, b1, count) in &bins {
                    if *count == 0 {
                        continue;
                    }
                    // map_y inverts: higher y → smaller pixel y (higher on screen)
                    let py_bottom = scatter_offset_y + scatter_computed.map_y(*b0);
                    let py_top = scatter_offset_y + scatter_computed.map_y(*b1);
                    let bar_w = (*count as f64 / max_c) * usable_w;
                    let bar_h = (py_bottom - py_top - 1.0).max(1.0);
                    scene.add(Primitive::Rect {
                        x: panel_left,
                        y: py_top + 0.5,
                        width: bar_w,
                        height: bar_h,
                        fill: Color::from(&*color_str),
                        stroke: None,
                        stroke_width: None,
                        opacity: Some(jp.marginal_alpha),
                    });
                }
            }
            MarginalType::Density => {
                if ys.is_empty() {
                    continue;
                }
                let bw = jp.bandwidth.unwrap_or_else(|| silverman_bandwidth(&ys));
                let pts = simple_kde(&ys, bw, 200);
                let in_range: Vec<_> = pts
                    .iter()
                    .filter(|(y, _)| *y >= y_lo && *y <= y_hi)
                    .collect();
                if in_range.len() < 2 {
                    continue;
                }
                let max_d = in_range.iter().map(|(_, d)| *d).fold(0.0_f64, f64::max);
                if max_d < 1e-12 {
                    continue;
                }
                // Horizontal density: y-axis is data axis, x-axis is density
                let first_py = scatter_offset_y + scatter_computed.map_y(in_range[0].0);
                let last_py =
                    scatter_offset_y + scatter_computed.map_y(in_range[in_range.len() - 1].0);
                let mut path = format!("M {panel_left:.1} {first_py:.1}");
                for (y, d) in &in_range {
                    let py = scatter_offset_y + scatter_computed.map_y(*y);
                    let px = panel_left + (d / max_d) * usable_w;
                    path.push_str(&format!(" L {px:.1} {py:.1}"));
                }
                path.push_str(&format!(" L {panel_left:.1} {last_py:.1} Z"));
                scene.add(Primitive::Path(Box::new(PathData {
                    d: path,
                    fill: Some(Color::from(&*color_str)),
                    stroke: Color::from(&*color_str),
                    stroke_width: 1.5,
                    opacity: Some(jp.marginal_alpha),
                    stroke_dasharray: None,
                })));
            }
        }
    }

    // Separator line at left edge of right panel
    let y_lo_px = scatter_offset_y + scatter_computed.map_y(y_lo);
    let y_hi_px = scatter_offset_y + scatter_computed.map_y(y_hi);
    scene.add(Primitive::Line {
        x1: panel_left,
        y1: y_hi_px,
        x2: panel_left,
        y2: y_lo_px,
        stroke: Color::from("#cccccc"),
        stroke_width: 1.0,
        stroke_dasharray: None,
    });
}

/// Inner drawing routine for `JointPlot` — populates an existing `scene` using
/// `computed.width` / `computed.height` as the total canvas and `title_offset_y`
/// as vertical space already consumed by a title drawn outside this call.
///
/// Used by both `render_jointplot` (standalone) and the `render_multiple`
/// dispatch for `Plot::Joint` (Figure grids).
fn add_jointplot(
    jp: &crate::plot::jointplot::JointPlot,
    scene: &mut Scene,
    computed: &ComputedLayout,
    title_offset_y: f64,
    legend_width: f64,
    show_legend: bool,
    draw_scatter_labels: bool,
) {
    use crate::render::palette::Palette;

    let width = computed.width;
    let height = computed.height;

    let top_h = if jp.show_top { jp.marginal_size } else { 0.0 };
    let right_w = if jp.show_right { jp.marginal_size } else { 0.0 };
    let top_gap = if jp.show_top { jp.marginal_gap } else { 0.0 };
    let right_gap = if jp.show_right { jp.marginal_gap } else { 0.0 };

    let (x_min, x_max) = jp.x_range();
    let (y_min, y_max) = jp.y_range();

    let has_legend = jp.groups.iter().any(|g| g.scatter.legend_label.is_some());
    let legend_after_right = has_legend && jp.show_right;
    let legend_in_scatter = (show_legend || has_legend) && !jp.show_right;

    // In figure context the cell width is fixed, so we must carve space for every
    // component upfront: scatter | right_gap | right_marginal | legend_gap | legend.
    // In standalone context render_jointplot already extended scene_width, so no
    // reservation is needed here.
    let legend_reserve = if legend_after_right && !draw_scatter_labels {
        legend_width + 10.0
    } else {
        0.0
    };

    let scatter_canvas_w = width - right_w - right_gap - legend_reserve;
    let scatter_canvas_h = height - title_offset_y - top_h - top_gap;
    let scatter_offset_y = title_offset_y + top_h + top_gap;

    let scatter_plots: Vec<Plot> = jp
        .groups
        .iter()
        .enumerate()
        .map(|(gi, g)| {
            let mut sp = g.scatter.clone();
            if sp.color == "black" && g.scatter.colors.is_none() {
                sp.color = Palette::category10()[gi % 10].to_string();
            }
            Plot::Scatter(sp)
        })
        .collect();

    let build_scatter_layout = || {
        let mut sl = Layout::new((x_min, x_max), (y_min, y_max))
            .with_width(scatter_canvas_w)
            .with_height(scatter_canvas_h)
            .with_theme(computed.theme.clone());
        if draw_scatter_labels {
            if let Some(ref xl) = jp.x_label {
                sl = sl.with_x_label(xl.clone());
            }
            if let Some(ref yl) = jp.y_label {
                sl = sl.with_y_label(yl.clone());
            }
        }
        if legend_in_scatter {
            sl.show_legend = true;
        }
        sl
    };

    let scatter_computed = ComputedLayout::from_layout(&build_scatter_layout());
    let scatter_scene = render_multiple(scatter_plots, build_scatter_layout());

    let data_right = scatter_computed.margin_left + scatter_computed.plot_width();

    // In figure context, draw x/y labels ourselves centred on the scatter axis.
    // (The outer render_multiple clears layout.x/y_label so add_labels_and_title skips them.)
    if !draw_scatter_labels {
        let ls = scatter_computed.label_size as f64;
        if let Some(ref xl) = jp.x_label {
            scene.add(Primitive::Text {
                x: scatter_computed.margin_left + scatter_computed.plot_width() / 2.0,
                y: computed.height - ls * 0.5,
                content: xl.clone(),
                size: scatter_computed.label_size,
                anchor: TextAnchor::Middle,
                rotate: None,
                bold: false,
                color: None,
            });
        }
        if let Some(ref yl) = jp.y_label {
            let yl_x = (scatter_computed.margin_left
                - 8.0
                - scatter_computed.y_tick_label_px
                - 5.0
                - ls * 0.5)
                .max(ls * 0.5 + 8.0);
            scene.add(Primitive::Text {
                x: yl_x,
                y: scatter_offset_y + scatter_canvas_h / 2.0,
                content: yl.clone(),
                size: scatter_computed.label_size,
                anchor: TextAnchor::Middle,
                rotate: Some(-90.0),
                bold: false,
                color: None,
            });
        }
    }

    // Insert scatter sub-scene via SVG translate group
    scene.add(Primitive::GroupStart {
        transform: Some(format!("translate(0,{scatter_offset_y:.1})")),
        title: None,
        extra_attrs: None,
    });
    for elem in scatter_scene.elements {
        scene.elements.push(elem);
    }
    for def in scatter_scene.defs {
        scene.defs.push(def);
    }
    scene.add(Primitive::GroupEnd);

    // Top marginal panel
    if jp.show_top {
        let panel_bottom = scatter_offset_y;
        joint_draw_top_marginal(jp, &scatter_computed, panel_bottom, top_h, scene);
    }

    // Right marginal panel
    if jp.show_right {
        let panel_left = data_right + right_gap;
        joint_draw_right_marginal(
            jp,
            &scatter_computed,
            scatter_offset_y,
            panel_left,
            right_w,
            scene,
        );

        // Draw legend to the right of the marginal panel when suppressed from scatter sub-scene
        if legend_after_right {
            let legend_x = panel_left + right_w + 10.0;
            let line_h = scatter_computed.legend_line_height;
            let pad = scatter_computed.legend_padding;
            let bs = scatter_computed.body_size as f64;
            let mut cur_y = scatter_offset_y + scatter_computed.margin_top + 10.0;

            let entries: Vec<(String, String)> = jp
                .groups
                .iter()
                .enumerate()
                .filter_map(|(gi, g)| {
                    g.scatter.legend_label.as_ref().map(|lbl| {
                        let col = if g.scatter.color == "black" && g.scatter.colors.is_none() {
                            Palette::category10()[gi % 10].to_string()
                        } else {
                            g.scatter.color.clone()
                        };
                        (lbl.clone(), col)
                    })
                })
                .collect();

            if !entries.is_empty() {
                let box_h = entries.len() as f64 * line_h + pad * 2.0;
                let legend_bg = &computed.theme.legend_bg;
                let legend_border = &computed.theme.legend_border;
                scene.add(Primitive::Rect {
                    x: legend_x - pad + 5.0,
                    y: cur_y - pad,
                    width: legend_width,
                    height: box_h,
                    fill: Color::from(&**legend_bg),
                    stroke: None,
                    stroke_width: None,
                    opacity: None,
                });
                scene.add(Primitive::Rect {
                    x: legend_x - pad + 5.0,
                    y: cur_y - pad,
                    width: legend_width,
                    height: box_h,
                    fill: "none".into(),
                    stroke: Some(Color::from(&**legend_border)),
                    stroke_width: Some(1.0),
                    opacity: None,
                });
                for (lbl, col) in entries {
                    scene.add(Primitive::Circle {
                        cx: legend_x + 5.0,
                        cy: cur_y + line_h / 2.0 - 2.0,
                        r: 5.0,
                        fill: Color::from(&*col),
                        fill_opacity: None,
                        stroke: None,
                        stroke_width: None,
                    });
                    scene.add(Primitive::Text {
                        x: legend_x + 18.0,
                        y: cur_y + bs * 0.8,
                        content: lbl,
                        size: scatter_computed.body_size,
                        anchor: TextAnchor::Start,
                        rotate: None,
                        bold: false,
                        color: None,
                    });
                    cur_y += line_h;
                }
            }
        }
    }
}

/// Render a scatter plot with marginal distribution panels on the top and/or right edges.
///
/// # Example
/// ```rust,no_run
/// use kuva::prelude::*;
/// let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
/// let y = vec![2.0, 3.5, 2.5, 4.0, 3.0];
/// let joint = JointPlot::new()
///     .with_xy(x, y)
///     .with_x_label("Feature A")
///     .with_y_label("Feature B");
/// let layout = Layout::new((0.5, 5.5), (1.5, 4.5)).with_title("Joint Plot");
/// let scene = render_jointplot(joint, layout);
/// ```
pub fn render_jointplot(jp: crate::plot::jointplot::JointPlot, layout: Layout) -> Scene {
    let width = layout.width.unwrap_or(500.0);
    let height = layout.height.unwrap_or(500.0);
    let title_h = if layout.title.is_some() {
        35.0_f64
    } else {
        0.0
    };

    let has_legend = jp.groups.iter().any(|g| g.scatter.legend_label.is_some());
    let legend_after_right = has_legend && jp.show_right;
    let legend_extra_w = if legend_after_right {
        layout.legend_width + 20.0
    } else {
        0.0
    };
    let scene_width = width + legend_extra_w;

    let mut scene = Scene::new(scene_width, height);
    if let Some(ref font) = layout.font_family {
        scene.font_family = Some(font.clone());
    }
    scene.background_color = Some(layout.theme.background.clone());
    scene.text_color = Some(layout.theme.text_color.clone());

    // Title
    if let Some(ref t) = layout.title {
        scene.add(Primitive::Text {
            x: scene_width / 2.0,
            y: title_h * 0.7,
            content: t.clone(),
            size: layout.title_size,
            anchor: TextAnchor::Middle,
            rotate: None,
            bold: false,
            color: None,
        });
    }

    // Build a ComputedLayout using width/height so add_jointplot can access theme etc.
    let stub_layout = Layout::new((0.0, 1.0), (0.0, 1.0))
        .with_width(width)
        .with_height(height)
        .with_theme(layout.theme.clone());
    let computed = ComputedLayout::from_layout(&stub_layout);

    add_jointplot(
        &jp,
        &mut scene,
        &computed,
        title_h,
        layout.legend_width,
        layout.show_legend,
        true,
    );

    scene
}

// ── MosaicPlot ────────────────────────────────────────────────────────────────

/// Render a mosaic (Marimekko) chart with the given layout.
pub fn render_mosaic(mp: MosaicPlot, layout: Layout) -> Scene {
    let plots = vec![crate::render::plots::Plot::Mosaic(mp)];
    render_multiple(plots, layout)
}

fn add_mosaic(mp: &MosaicPlot, scene: &mut Scene, computed: &ComputedLayout) {
    if mp.cells.is_empty() {
        return;
    }

    let col_order = mp.effective_col_order();
    let row_order = mp.effective_row_order();
    let n_cols = col_order.len();
    let n_rows = row_order.len();
    if n_cols == 0 || n_rows == 0 {
        return;
    }

    let gap = mp.gap;
    let body_size = computed.body_size;
    let label_size = (body_size as f64 * 0.85).round() as u32;

    // Reserve space for the custom y-axis drawn to the left of margin_left.
    let y_axis_w = body_size as f64 * 3.8;
    // Reserve space for column name labels below the plot area.
    let x_label_h = body_size as f64 * 2.2;

    let area_left = computed.margin_left + y_axis_w;
    let area_right = computed.width - computed.margin_right;
    let area_top = computed.margin_top;
    let area_bottom = computed.height - computed.margin_bottom - x_label_h;

    let pw = (area_right - area_left).max(1.0);
    let ph = (area_bottom - area_top).max(1.0);

    // ── Compute column totals and grand total ──────────────────────────────
    let col_totals: Vec<f64> = col_order.iter().map(|c| mp.col_total(c)).collect();
    let grand_total: f64 = col_totals.iter().sum();
    if grand_total <= 0.0 {
        return;
    }

    // ── Column widths and x-start positions ────────────────────────────────
    let usable_w = pw - (n_cols as f64 - 1.0) * gap;
    let col_widths: Vec<f64> = col_totals
        .iter()
        .map(|&ct| (ct / grand_total) * usable_w)
        .collect();
    let mut col_x_starts = Vec::with_capacity(n_cols);
    let mut cur_x = area_left;
    for &w in &col_widths {
        col_x_starts.push(cur_x);
        cur_x += w + gap;
    }

    let theme = &computed.theme;
    let axis_color: Color = Color::from(&theme.text_color);

    // ── Draw columns ────────────────────────────────────────────────────────
    for (ci, col_name) in col_order.iter().enumerate() {
        let col_total = col_totals[ci];
        if col_total <= 0.0 {
            continue;
        }
        let col_w = col_widths[ci];
        let col_x = col_x_starts[ci];

        // For normalize=true, column always fills ph.
        // For normalize=false, column height is proportional to col_total/grand_total.
        let col_h = if mp.normalize {
            ph
        } else {
            (col_total / grand_total) * ph
        };
        let usable_col_h = col_h - (n_rows as f64 - 1.0) * gap;

        // Segments are stacked from area_bottom upward.
        let col_y_bottom = area_bottom;

        let mut seg_y = col_y_bottom;
        for (ri, row_name) in row_order.iter().enumerate().rev() {
            let val = mp.cell_value(col_name, row_name);
            let seg_h = if col_total > 0.0 {
                (val / col_total) * usable_col_h
            } else {
                0.0
            };
            if seg_h <= 0.0 {
                // Still move y up by zero gap to keep separation correct
                if ri + 1 < n_rows {
                    seg_y -= gap;
                }
                continue;
            }
            let seg_top = seg_y - seg_h;
            let color = mp.color_for_row_idx(ri);

            scene.add(Primitive::Rect {
                x: col_x,
                y: seg_top,
                width: col_w,
                height: seg_h,
                fill: Color::from(color.as_str()),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });

            // Label inside cell
            let show_label = mp.show_percents || mp.show_values;
            if show_label && seg_h >= mp.min_label_height && col_w >= mp.min_label_width {
                let label = if mp.show_percents && mp.show_values {
                    format!("{:.1}%\n{}", val / col_total * 100.0, val)
                } else if mp.show_percents {
                    let pct = val / col_total * 100.0;
                    format!("{:.1}%", pct)
                } else {
                    format!("{}", val)
                };
                // Only show if text fits width
                if label.len() as f64 * label_size as f64 * 0.62 < col_w * 0.9 {
                    let cx = col_x + col_w / 2.0;
                    let cy = seg_top + seg_h / 2.0 + label_size as f64 * 0.35;
                    scene.add(Primitive::Text {
                        x: cx,
                        y: cy,
                        content: label,
                        size: label_size,
                        anchor: TextAnchor::Middle,
                        rotate: None,
                        bold: false,
                        color: Some(Color::from("white")),
                    });
                }
            }

            // Move up past this segment + gap (gap between segments)
            seg_y = seg_top;
            if ri > 0 {
                seg_y -= gap;
            }
        }

        // Column name label below plot area, centered on column
        let cx = col_x + col_w / 2.0;
        let label_y = area_bottom + body_size as f64 * 1.5;
        scene.add(Primitive::Text {
            x: cx,
            y: label_y,
            content: col_name.clone(),
            size: body_size,
            anchor: TextAnchor::Middle,
            rotate: None,
            bold: false,
            color: None,
        });
    }

    // ── Y-axis ────────────────────────────────────────────────────────────
    // Vertical line at area_left, covering the full plot height.
    let axis_x = area_left;
    scene.add(Primitive::Line {
        x1: axis_x,
        y1: area_top,
        x2: axis_x,
        y2: area_bottom,
        stroke: axis_color.clone(),
        stroke_width: computed.axis_line_width,
        stroke_dasharray: None,
    });

    // 5 ticks at 0%, 25%, 50%, 75%, 100%
    let tick_fracs = [0.0, 0.25, 0.50, 0.75, 1.0];
    let tick_len = computed.tick_mark_major;
    for &frac in &tick_fracs {
        let ty = area_bottom - frac * ph;
        // Tick mark going left
        scene.add(Primitive::Line {
            x1: axis_x,
            y1: ty,
            x2: axis_x - tick_len,
            y2: ty,
            stroke: axis_color.clone(),
            stroke_width: computed.tick_stroke_width,
            stroke_dasharray: None,
        });
        // Tick label
        let pct_label = format!("{}%", (frac * 100.0) as u32);
        scene.add(Primitive::Text {
            x: axis_x - tick_len - computed.tick_label_margin,
            y: ty + computed.tick_size as f64 * 0.35,
            content: pct_label,
            size: computed.tick_size,
            anchor: TextAnchor::End,
            rotate: None,
            bold: false,
            color: None,
        });
    }
}

// ── Network / graph diagram ───────────────────────────────────────────────

fn add_network(net: &NetworkPlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::render::palette::Palette;
    use std::collections::HashSet;

    if net.nodes.is_empty() {
        return;
    }

    let positions = net.compute_positions();
    let font_size = net.label_size.unwrap_or(computed.body_size);

    // Padding: account for node radius, labels, and self-loops.
    let max_label_px = if net.show_labels {
        net.nodes
            .iter()
            .map(|n| n.label.chars().count() as f64 * 0.6 * font_size as f64 + 4.0)
            .fold(0.0_f64, f64::max)
    } else {
        0.0
    };
    let r_max = net
        .nodes
        .iter()
        .map(|n| n.size.unwrap_or(net.node_radius))
        .fold(0.0_f64, f64::max);

    let plot_w = computed.plot_width();
    let plot_h = computed.plot_height();
    let base_pad = r_max + 4.0;

    let inset = 0.05;
    let label_overhang = r_max + 4.0 + max_label_px;
    let pad_right_extra = (label_overhang - inset * plot_w).max(0.0);

    let ox = computed.margin_left + base_pad;
    let oy = computed.margin_top + base_pad;
    let pw = (plot_w - 2.0 * base_pad - pad_right_extra).max(1.0);
    let ph = (plot_h - 2.0 * base_pad).max(1.0);
    let px: Vec<f64> = positions
        .iter()
        .map(|(x, _)| ox + (inset + x * (1.0 - 2.0 * inset)) * pw)
        .collect();
    let py: Vec<f64> = positions
        .iter()
        .map(|(_, y)| oy + (inset + y * (1.0 - 2.0 * inset)) * ph)
        .collect();

    let loop_r = (r_max * 10.0).min(pw.min(ph) * 0.15);
    let edge_label_size = font_size.saturating_sub(2).max(8);

    // Arrowhead length for a given stroke width.
    let arr_len = |stroke_w: f64| stroke_w * 2.5 + 3.0;

    // Arrowhead triangle: tip at (tip_x, tip_y), pointing along (ux, uy).
    let arrowhead = |scene: &mut Scene,
                     tip_x: f64,
                     tip_y: f64,
                     ux: f64,
                     uy: f64,
                     stroke_w: f64,
                     color: &str| {
        let size = arr_len(stroke_w);
        let base_x = tip_x - ux * size;
        let base_y = tip_y - uy * size;
        let perp_x = -uy;
        let perp_y = ux;
        let half_w = size * 0.4;
        let d = format!(
            "M {:.2} {:.2} L {:.2} {:.2} L {:.2} {:.2} Z",
            tip_x,
            tip_y,
            base_x + perp_x * half_w,
            base_y + perp_y * half_w,
            base_x - perp_x * half_w,
            base_y - perp_y * half_w
        );
        scene.add(Primitive::Path(Box::new(PathData {
            d,
            fill: Some(color.into()),
            stroke: "none".into(),
            stroke_width: 0.0,
            opacity: None,
            stroke_dasharray: None,
        })));
    };

    let fallback = Palette::category10();

    // Build group→colour map.  Groups always get palette colors;
    // per-node explicit colors are a node-level override only.
    let mut group_map: Vec<(String, String)> = Vec::new();
    {
        let mut gi = 0usize;
        for node in &net.nodes {
            if let Some(ref g) = node.group {
                if !group_map.iter().any(|(gn, _)| gn == g) {
                    group_map.push((g.clone(), fallback[gi % fallback.len()].to_string()));
                    gi += 1;
                }
            }
        }
    }

    let get_color = |i: usize| -> String {
        if let Some(ref c) = net.nodes[i].color {
            return c.clone();
        }
        if let Some(ref g) = net.nodes[i].group {
            if let Some(pos) = group_map.iter().position(|(gn, _)| gn == g) {
                return group_map[pos].1.clone();
            }
        }
        fallback[i % fallback.len()].to_string()
    };

    // Weight range for mapping to stroke width.
    let (w_min, w_max) = if net.edges.is_empty() {
        (0.0, 0.0)
    } else {
        let wn = net
            .edges
            .iter()
            .map(|e| e.weight)
            .fold(f64::INFINITY, f64::min);
        let wx = net
            .edges
            .iter()
            .map(|e| e.weight)
            .fold(f64::NEG_INFINITY, f64::max);
        (wn, wx)
    };
    let w_range = (w_max - w_min).max(1e-9);

    let min_stroke = 1.0;
    let max_stroke = 5.0;

    // Graph centre (for orienting self-loops outward).
    let cx_graph = px.iter().sum::<f64>() / px.len() as f64;
    let cy_graph = py.iter().sum::<f64>() / py.len() as f64;

    // Detect antiparallel edge pairs so we can curve them.
    let antiparallel: HashSet<(usize, usize)> = {
        let mut set = HashSet::new();
        let edge_set: HashSet<(usize, usize)> = net
            .edges
            .iter()
            .filter(|e| e.source != e.target)
            .map(|e| (e.source, e.target))
            .collect();
        for &(s, t) in &edge_set {
            if edge_set.contains(&(t, s)) {
                set.insert((s, t));
            }
        }
        set
    };

    // ── Draw edges ────────────────────────────────────────────────────
    for edge in &net.edges {
        let (si, ti) = (edge.source, edge.target);
        let stroke_w = if (w_max - w_min).abs() < 1e-9 {
            2.0
        } else {
            min_stroke + (edge.weight - w_min) / w_range * (max_stroke - min_stroke)
        };
        let opacity = net.edge_opacity;
        let edge_color = edge.color.clone().unwrap_or_else(|| "#888888".to_string());

        // Wrap line + arrowhead in a group so opacity applies uniformly.
        scene.add(Primitive::GroupStart {
            transform: None,
            title: None,
            extra_attrs: Some(format!("opacity=\"{}\"", opacity)),
        });

        if si == ti {
            // Self-loop: cubic-bezier arc pointing outward from graph centre.
            let r = net.nodes[si].size.unwrap_or(net.node_radius);
            let nx = px[si];
            let ny = py[si];

            let out_dx = nx - cx_graph;
            let out_dy = ny - cy_graph;
            let out_len = (out_dx * out_dx + out_dy * out_dy).sqrt();
            let (out_ux, out_uy) = if out_len < 1e-4 {
                (0.0, -1.0)
            } else {
                (out_dx / out_len, out_dy / out_len)
            };

            let perp_x = -out_uy;
            let perp_y = out_ux;

            let sx = nx + out_ux * r + perp_x * r * 0.5;
            let sy = ny + out_uy * r + perp_y * r * 0.5;
            let ex = nx + out_ux * r - perp_x * r * 0.5;
            let ey = ny + out_uy * r - perp_y * r * 0.5;

            let cp1x = nx + out_ux * (r + loop_r * 1.5) + perp_x * loop_r;
            let cp1y = ny + out_uy * (r + loop_r * 1.5) + perp_y * loop_r;
            let cp2x = nx + out_ux * (r + loop_r * 1.5) - perp_x * loop_r;
            let cp2y = ny + out_uy * (r + loop_r * 1.5) - perp_y * loop_r;

            let d = format!(
                "M {:.2} {:.2} C {:.2} {:.2} {:.2} {:.2} {:.2} {:.2}",
                sx, sy, cp1x, cp1y, cp2x, cp2y, ex, ey,
            );
            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: None,
                stroke: edge_color.clone().into(),
                stroke_width: stroke_w,
                opacity: None,
                stroke_dasharray: None,
            })));
            if net.directed {
                let tdx = ex - cp2x;
                let tdy = ey - cp2y;
                let tlen = (tdx * tdx + tdy * tdy).sqrt().max(1e-6);
                arrowhead(
                    &mut *scene,
                    ex,
                    ey,
                    tdx / tlen,
                    tdy / tlen,
                    stroke_w,
                    &edge_color,
                );
            }
            // Edge label for self-loop
            if let Some(ref lbl) = edge.label {
                let lx = (cp1x + cp2x) / 2.0;
                let ly = (cp1y + cp2y) / 2.0;
                scene.add(Primitive::Text {
                    x: round2(lx),
                    y: round2(ly),
                    content: lbl.clone(),
                    size: edge_label_size,
                    anchor: TextAnchor::Middle,
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }
            scene.add(Primitive::GroupEnd);
            continue;
        }

        let (x1, y1) = (px[si], py[si]);
        let (x2, y2) = (px[ti], py[ti]);

        let dx = x2 - x1;
        let dy = y2 - y1;
        let dist = (dx * dx + dy * dy).sqrt().max(1e-6);
        let ux = dx / dist;
        let uy = dy / dist;
        let r_src = net.nodes[si].size.unwrap_or(net.node_radius)
            * net.nodes[si].shape.circumradius_factor();
        let r_tgt = net.nodes[ti].size.unwrap_or(net.node_radius)
            * net.nodes[ti].shape.circumradius_factor();

        let is_antiparallel = net.directed && antiparallel.contains(&(si, ti));
        let curve_offset = if is_antiparallel { dist * 0.15 } else { 0.0 };

        if curve_offset > 0.0 {
            // Curved edge via quadratic bezier to separate antiparallel pair.
            let perp_x = -uy;
            let perp_y = ux;
            let mx = (x1 + x2) / 2.0 + perp_x * curve_offset;
            let my = (y1 + y2) / 2.0 + perp_y * curve_offset;

            // Shorten start/end to node boundaries (approximate for curve)
            let lx1 = x1 + ux * r_src;
            let ly1 = y1 + uy * r_src;
            let lx2 = x2 - ux * r_tgt;
            let ly2 = y2 - uy * r_tgt;

            if net.directed {
                let arr_size = arr_len(stroke_w);
                // Direction at endpoint: tangent of quadratic bezier at t=1 is (end - control)
                let tdx = lx2 - mx;
                let tdy = ly2 - my;
                let tlen = (tdx * tdx + tdy * tdy).sqrt().max(1e-6);
                let tux = tdx / tlen;
                let tuy = tdy / tlen;
                let lx2_short = lx2 - tux * arr_size;
                let ly2_short = ly2 - tuy * arr_size;

                let d = format!(
                    "M {:.2} {:.2} Q {:.2} {:.2} {:.2} {:.2}",
                    lx1, ly1, mx, my, lx2_short, ly2_short
                );
                scene.add(Primitive::Path(Box::new(PathData {
                    d,
                    fill: None,
                    stroke: edge_color.clone().into(),
                    stroke_width: stroke_w,
                    opacity: None,
                    stroke_dasharray: None,
                })));

                arrowhead(&mut *scene, lx2, ly2, tux, tuy, stroke_w, &edge_color);
            } else {
                let d = format!(
                    "M {:.2} {:.2} Q {:.2} {:.2} {:.2} {:.2}",
                    lx1, ly1, mx, my, lx2, ly2
                );
                scene.add(Primitive::Path(Box::new(PathData {
                    d,
                    fill: None,
                    stroke: edge_color.clone().into(),
                    stroke_width: stroke_w,
                    opacity: None,
                    stroke_dasharray: None,
                })));
            }

            // Edge label at curve midpoint, offset further into the curve's bulge
            // (the control point direction) so it clears the edge line.
            if let Some(ref lbl) = edge.label {
                let elx = (lx1 + 2.0 * mx + lx2) / 4.0 + perp_x * font_size as f64 * 0.6;
                let ely = (ly1 + 2.0 * my + ly2) / 4.0 + perp_y * font_size as f64 * 0.6;
                scene.add(Primitive::Text {
                    x: round2(elx),
                    y: round2(ely),
                    content: lbl.clone(),
                    size: edge_label_size,
                    anchor: TextAnchor::Middle,
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }
        } else {
            // Straight edge
            let lx1 = x1 + ux * r_src;
            let ly1 = y1 + uy * r_src;
            let lx2 = x2 - ux * r_tgt;
            let ly2 = y2 - uy * r_tgt;

            if net.directed {
                let arr_size = arr_len(stroke_w);
                let lx2_short = lx2 - ux * arr_size;
                let ly2_short = ly2 - uy * arr_size;

                scene.add(Primitive::Line {
                    x1: round2(lx1),
                    y1: round2(ly1),
                    x2: round2(lx2_short),
                    y2: round2(ly2_short),
                    stroke: edge_color.clone().into(),
                    stroke_width: stroke_w,
                    stroke_dasharray: None,
                });
                arrowhead(&mut *scene, lx2, ly2, ux, uy, stroke_w, &edge_color);
            } else {
                scene.add(Primitive::Line {
                    x1: round2(lx1),
                    y1: round2(ly1),
                    x2: round2(lx2),
                    y2: round2(ly2),
                    stroke: edge_color.clone().into(),
                    stroke_width: stroke_w,
                    stroke_dasharray: None,
                });
            }

            // Edge label at midpoint, offset perpendicular to the edge
            if let Some(ref lbl) = edge.label {
                let perp_x = -uy;
                let perp_y = ux;
                let elx = (lx1 + lx2) / 2.0 + perp_x * font_size as f64 * 0.6;
                let ely = (ly1 + ly2) / 2.0 + perp_y * font_size as f64 * 0.6;
                scene.add(Primitive::Text {
                    x: round2(elx),
                    y: round2(ely),
                    content: lbl.clone(),
                    size: edge_label_size,
                    anchor: TextAnchor::Middle,
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }
        }
        scene.add(Primitive::GroupEnd);
    }

    // ── Draw nodes ────────────────────────────────────────────────────
    for (i, node) in net.nodes.iter().enumerate() {
        let r = node.size.unwrap_or(net.node_radius);
        let color = get_color(i);
        match node.shape {
            NodeShape::Circle => {
                scene.add(Primitive::Circle {
                    cx: round2(px[i]),
                    cy: round2(py[i]),
                    r,
                    fill: color.into(),
                    fill_opacity: None,
                    stroke: Some("#ffffff".into()),
                    stroke_width: Some(1.5),
                });
            }
            NodeShape::Square => {
                scene.add(Primitive::Rect {
                    x: round2(px[i] - r),
                    y: round2(py[i] - r),
                    width: r * 2.0,
                    height: r * 2.0,
                    fill: color.into(),
                    stroke: Some("#ffffff".into()),
                    stroke_width: Some(1.5),
                    opacity: None,
                });
            }
            NodeShape::Diamond => {
                let d = format!(
                    "M {:.2} {:.2} L {:.2} {:.2} L {:.2} {:.2} L {:.2} {:.2} Z",
                    px[i],
                    py[i] - r * 1.2,
                    px[i] + r * 1.2,
                    py[i],
                    px[i],
                    py[i] + r * 1.2,
                    px[i] - r * 1.2,
                    py[i],
                );
                scene.add(Primitive::Path(Box::new(PathData {
                    d,
                    fill: Some(color.into()),
                    stroke: "#ffffff".into(),
                    stroke_width: 1.5,
                    opacity: None,
                    stroke_dasharray: None,
                })));
            }
            NodeShape::Triangle => {
                let h = r * 1.4;
                let d = format!(
                    "M {:.2} {:.2} L {:.2} {:.2} L {:.2} {:.2} Z",
                    px[i],
                    py[i] - h,
                    px[i] + h * 0.87,
                    py[i] + h * 0.5,
                    px[i] - h * 0.87,
                    py[i] + h * 0.5,
                );
                scene.add(Primitive::Path(Box::new(PathData {
                    d,
                    fill: Some(color.into()),
                    stroke: "#ffffff".into(),
                    stroke_width: 1.5,
                    opacity: None,
                    stroke_dasharray: None,
                })));
            }
        }
    }

    // ── Draw labels (with optional repulsion) ─────────────────────────
    if net.show_labels {
        let n = px.len();
        // Place each label in the direction away from the graph centroid so
        // labels radiate outward and rarely land on top of other edges.
        let cx_c = if n > 0 {
            px.iter().sum::<f64>() / n as f64
        } else {
            0.0
        };
        let cy_c = if n > 0 {
            py.iter().sum::<f64>() / n as f64
        } else {
            0.0
        };

        // Tuple: (anchor_x, center_y, text, approx_half_width, TextAnchor)
        // anchor_x meaning:
        //   Start  → left edge of text
        //   End    → right edge of text
        //   Middle → centre of text
        let mut labels: Vec<(f64, f64, String, f64, TextAnchor)> = net
            .nodes
            .iter()
            .enumerate()
            .map(|(i, node)| {
                let r = node.size.unwrap_or(net.node_radius);
                let lw = node.label.chars().count() as f64 * 0.6 * font_size as f64;
                let gap = r + 4.0;
                // Unit vector from centroid to node.
                let fdx = px[i] - cx_c;
                let fdy = py[i] - cy_c;
                let fmag = (fdx * fdx + fdy * fdy).sqrt().max(1e-6);
                let fux = fdx / fmag;
                let fuy = fdy / fmag;
                // Choose anchor and x position based on horizontal direction.
                let (lx, anchor) = if fux > 0.25 {
                    (px[i] + fux * gap, TextAnchor::Start)
                } else if fux < -0.25 {
                    (px[i] + fux * gap, TextAnchor::End)
                } else {
                    (px[i], TextAnchor::Middle)
                };
                // Vertical: shift in the outward y direction; apply baseline offset.
                let ly = py[i] + fuy * gap + font_size as f64 * 0.35;
                (lx, ly, node.label.clone(), lw, anchor)
            })
            .collect();

        if net.repel_labels && labels.len() > 1 {
            let lh = font_size as f64;
            // Convert to center-x for repulsion arithmetic, then convert back.
            let center_x = |l: &(f64, f64, String, f64, TextAnchor)| match l.4 {
                TextAnchor::Start => l.0 + l.3 / 2.0,
                TextAnchor::End => l.0 - l.3 / 2.0,
                TextAnchor::Middle => l.0,
            };
            for _ in 0..50 {
                let mut moved = false;
                for i in 0..labels.len() {
                    for j in (i + 1)..labels.len() {
                        let cx_i = center_x(&labels[i]);
                        let cx_j = center_x(&labels[j]);
                        let dx = cx_j - cx_i;
                        let dy = labels[j].1 - labels[i].1;
                        let overlap_x = (labels[i].3 + labels[j].3) / 2.0 - dx.abs();
                        let overlap_y = lh - dy.abs();
                        if overlap_x > 0.0 && overlap_y > 0.0 {
                            let push = 0.5;
                            if overlap_x < overlap_y {
                                let sign = if dx >= 0.0 { 1.0 } else { -1.0 };
                                labels[i].0 -= sign * overlap_x * push;
                                labels[j].0 += sign * overlap_x * push;
                            } else {
                                let sign = if dy >= 0.0 { 1.0 } else { -1.0 };
                                labels[i].1 -= sign * overlap_y * push;
                                labels[j].1 += sign * overlap_y * push;
                            }
                            moved = true;
                        }
                    }
                }
                if !moved {
                    break;
                }
            }
            // Clamp to plot bounds, accounting for anchor and label width.
            let x_max = ox + pw + pad_right_extra;
            let y_max = oy + ph;
            for l in labels.iter_mut() {
                l.0 = match l.4 {
                    TextAnchor::Start => l.0.clamp(ox, (x_max - l.3).max(ox)),
                    TextAnchor::End => l.0.clamp(ox + l.3, x_max),
                    TextAnchor::Middle => {
                        l.0.clamp(ox + l.3 / 2.0, (x_max - l.3 / 2.0).max(ox + l.3 / 2.0))
                    }
                };
                l.1 = l.1.clamp(oy + font_size as f64, y_max);
            }
        }

        for (lx, ly, text, _lw, anchor) in &labels {
            scene.add(Primitive::Text {
                x: round2(*lx),
                y: round2(*ly),
                content: text.clone(),
                size: font_size,
                anchor: *anchor,
                rotate: None,
                bold: false,
                color: None,
            });
        }
    }
}

// ── Radar / spider chart ──────────────────────────────────────────────────────

fn add_radar(rp: &crate::plot::radar::RadarPlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::render::palette::Palette;
    use std::f64::consts::PI;

    let n = rp.axes.len();
    if n < 3 {
        return;
    }
    if rp.series.is_empty() && rp.references.is_empty() {
        return;
    }

    let pal = Palette::category10();
    let theme = &computed.theme;

    // ── Layout ────────────────────────────────────────────────────────────────
    let plot_w = computed.plot_width();
    let plot_h = computed.height - computed.margin_top - computed.margin_bottom;
    let cx = computed.margin_left + plot_w / 2.0;
    let cy = computed.margin_top + plot_h / 2.0;
    let radius = (plot_w.min(plot_h) / 2.0) * 0.65;

    // ── Per-axis data min/max ─────────────────────────────────────────────────
    let mut axis_min = vec![f64::INFINITY; n];
    let mut axis_max = vec![f64::NEG_INFINITY; n];
    for s in &rp.series {
        for (i, &v) in s.values.iter().enumerate().take(n) {
            axis_min[i] = axis_min[i].min(v);
            axis_max[i] = axis_max[i].max(v);
        }
        if let Some(errs) = &s.errors {
            for (i, (&v, &e)) in s.values.iter().zip(errs.iter()).enumerate().take(n) {
                axis_min[i] = axis_min[i].min(v - e);
                axis_max[i] = axis_max[i].max(v + e);
            }
        }
    }
    for i in 0..n {
        if !axis_min[i].is_finite() {
            axis_min[i] = 0.0;
        }
        if !axis_max[i].is_finite() {
            axis_max[i] = 1.0;
        }
        if axis_min[i] >= axis_max[i] {
            axis_max[i] = axis_min[i] + 1.0;
        }
    }

    let (shared_min, shared_max) = if let Some((lo, hi)) = rp.range {
        (lo, hi)
    } else {
        let all_min = axis_min.iter().cloned().fold(f64::INFINITY, f64::min);
        let all_max = axis_max.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
        (all_min.min(0.0), all_max)
    };
    let shared_span = (shared_max - shared_min).max(f64::EPSILON);

    // frac: map a value on axis `ax` to [0,1] radius fraction, respecting
    // per-axis ranges, normalize, and inversion.
    let frac = |value: f64, ax: usize| -> f64 {
        let (lo, hi) = if let Some(Some((alo, ahi))) = rp.axis_ranges.get(ax) {
            (*alo, *ahi)
        } else if rp.normalize {
            (axis_min[ax], axis_max[ax])
        } else {
            (shared_min, shared_max)
        };
        let span = (hi - lo).max(f64::EPSILON);
        let f = ((value - lo) / span).clamp(0.0, 1.0);
        if rp.inverted_axes.get(ax).copied().unwrap_or(false) {
            1.0 - f
        } else {
            f
        }
    };

    let start_rad = rp.start_angle_deg.to_radians();
    let angle = |i: usize| -> f64 { start_rad + (i as f64 * 2.0 * PI / n as f64) };

    let axis_px = |i: usize, fr: f64| -> (f64, f64) {
        let th = angle(i);
        (
            round2(cx + fr * radius * th.cos()),
            round2(cy + fr * radius * th.sin()),
        )
    };

    // ── Grid ─────────────────────────────────────────────────────────────────
    if rp.show_grid {
        let grid_color = &theme.grid_color;
        let grid_sw = computed.grid_stroke_width;

        // Radial axis lines
        for i in 0..n {
            let (ox, oy) = axis_px(i, 1.0);
            scene.add(Primitive::Line {
                x1: round2(cx),
                y1: round2(cy),
                x2: ox,
                y2: oy,
                stroke: Color::from(grid_color.as_str()),
                stroke_width: grid_sw,
                stroke_dasharray: None,
            });
        }

        // Concentric rings (polygon or circular)
        for k in 1..=rp.grid_lines {
            let fr = k as f64 / rp.grid_lines as f64;

            let ring_d = if rp.circular_grid {
                let r = round2(fr * radius);
                let cxr = round2(cx - r);
                let cxl = round2(cx + r);
                let cy2 = round2(cy);
                format!(
                    "M {},{} A {},{},0,1,0,{},{} A {},{},0,1,0,{},{} Z",
                    cxr, cy2, r, r, cxl, cy2, r, r, cxr, cy2
                )
            } else {
                let pts: Vec<(f64, f64)> = (0..n).map(|i| axis_px(i, fr)).collect();
                radar_polygon_path(&pts)
            };

            scene.add(Primitive::Path(Box::new(PathData {
                d: ring_d,
                fill: None,
                stroke: Color::from(grid_color.as_str()),
                stroke_width: grid_sw,
                opacity: None,
                stroke_dasharray: Some("4,3".to_string()),
            })));

            // Ring value label next to axis 0
            let label_val = if rp.normalize {
                format!("{:.0}%", fr * 100.0)
            } else {
                let v = shared_min + fr * shared_span;
                if v == v.round() && v.abs() < 1e6 {
                    format!("{:.0}", v)
                } else {
                    format!("{:.2}", v)
                }
            };
            let (lx, ly) = axis_px(0, fr);
            let th0 = angle(0);
            // Offset label slightly perpendicular (left of axis) so it clears the ring line
            let perp_off_x = -th0.sin() * 4.0 + 3.0;
            let perp_off_y = th0.cos() * 4.0;
            scene.add(Primitive::Text {
                x: round2(lx + perp_off_x),
                y: round2(ly + perp_off_y),
                content: label_val,
                size: (computed.tick_size as f64 * 0.8) as u32,
                anchor: TextAnchor::Start,
                rotate: None,
                bold: false,
                color: Some(Color::from(theme.tick_color.as_str())),
            });
        }

        // Axis tick marks
        if rp.axis_ticks {
            let tick_len = 5.0;
            for i in 0..n {
                let th = angle(i);
                let (perp_x, perp_y) = (-th.sin(), th.cos());
                for k in 1..=rp.grid_lines {
                    let fr = k as f64 / rp.grid_lines as f64;
                    let (px, py) = axis_px(i, fr);
                    scene.add(Primitive::Line {
                        x1: round2(px - perp_x * tick_len / 2.0),
                        y1: round2(py - perp_y * tick_len / 2.0),
                        x2: round2(px + perp_x * tick_len / 2.0),
                        y2: round2(py + perp_y * tick_len / 2.0),
                        stroke: Color::from(theme.tick_color.as_str()),
                        stroke_width: 1.0,
                        stroke_dasharray: None,
                    });
                }
            }
        }
    }

    // ── Axis labels ───────────────────────────────────────────────────────────
    let label_r = 1.18;
    let label_size = computed.tick_size;
    let line_h = label_size as f64 * 1.2;

    for i in 0..n {
        let th = angle(i);
        let lx = cx + label_r * radius * th.cos();
        let ly = cy + label_r * radius * th.sin();
        let anchor = if th.cos() > 0.2 {
            TextAnchor::Start
        } else if th.cos() < -0.2 {
            TextAnchor::End
        } else {
            TextAnchor::Middle
        };
        // Vertical baseline adjustment: bottom labels shift down, top labels align at baseline
        let base_dy = if th.sin() > 0.2 {
            label_size as f64 * 0.9
        } else if th.sin() < -0.2 {
            0.0
        } else {
            label_size as f64 * 0.45
        };

        let lines = radar_wrap_label(&rp.axes[i], 12);
        // Vertical placement of the text block:
        //   Top labels (sin < −0.2): last line at ly (away from chart), earlier lines above.
        //   Bottom labels (sin > 0.2): first line at ly+base_dy, subsequent lines below.
        //   Side labels: block centred at ly+base_dy.
        let start_y = if th.sin() < -0.2 {
            // Anchor the BOTTOM of the block at ly so multi-line goes upward, not into chart.
            round2(ly + base_dy - (lines.len() as f64 - 1.0) * line_h)
        } else if th.sin() > 0.2 {
            round2(ly + base_dy)
        } else {
            round2(ly + base_dy - (lines.len() as f64 - 1.0) * line_h / 2.0)
        };

        for (li, line) in lines.iter().enumerate() {
            scene.add(Primitive::Text {
                x: round2(lx),
                y: round2(start_y + li as f64 * line_h),
                content: line.clone(),
                size: label_size,
                anchor,
                rotate: None,
                bold: false,
                color: None,
            });
        }
    }

    // ── Reference polygons (drawn before series so they stay behind) ──────────
    for ref_poly in &rp.references {
        let ref_color = ref_poly.color.as_deref().unwrap_or("#999999");
        let pts: Vec<(f64, f64)> = ref_poly
            .values
            .iter()
            .enumerate()
            .take(n)
            .map(|(i, &v)| axis_px(i, frac(v, i)))
            .collect();
        if pts.len() < 3 {
            continue;
        }
        scene.add(Primitive::Path(Box::new(PathData {
            d: radar_polygon_path(&pts),
            fill: None,
            stroke: Color::from(ref_color),
            stroke_width: rp.stroke_width * 0.8,
            opacity: None,
            stroke_dasharray: Some("6,3".to_string()),
        })));
    }

    // ── Series polygons ───────────────────────────────────────────────────────
    for (si, series) in rp.series.iter().enumerate() {
        let color = series.color.clone().unwrap_or_else(|| pal[si].to_string());

        // Error band (shaded region between value±error)
        if let Some(errors) = &series.errors {
            let outer: Vec<(f64, f64)> = series
                .values
                .iter()
                .enumerate()
                .take(n)
                .map(|(i, &v)| axis_px(i, frac(v + errors.get(i).copied().unwrap_or(0.0), i)))
                .collect();
            let inner: Vec<(f64, f64)> = series
                .values
                .iter()
                .enumerate()
                .take(n)
                .map(|(i, &v)| axis_px(i, frac(v - errors.get(i).copied().unwrap_or(0.0), i)))
                .collect();
            if outer.len() >= 3 && inner.len() >= 3 {
                scene.add(Primitive::Path(Box::new(PathData {
                    d: radar_band_path(&outer, &inner),
                    fill: Some(Color::from(color.as_str())),
                    stroke: Color::from(color.as_str()),
                    stroke_width: 0.5,
                    opacity: Some((rp.opacity * 0.6).max(0.1)),
                    stroke_dasharray: None,
                })));
            }
        }

        let pts: Vec<(f64, f64)> = series
            .values
            .iter()
            .enumerate()
            .take(n)
            .map(|(i, &v)| axis_px(i, frac(v, i)))
            .collect();

        if pts.len() < 3 {
            continue;
        }
        let path = radar_polygon_path(&pts);

        if rp.filled {
            scene.add(Primitive::Path(Box::new(PathData {
                d: path.clone(),
                fill: Some(Color::from(color.as_str())),
                stroke: Color::from(color.as_str()),
                stroke_width: rp.stroke_width,
                opacity: Some(rp.opacity),
                stroke_dasharray: series.dasharray.clone(),
            })));
        } else {
            scene.add(Primitive::Path(Box::new(PathData {
                d: path,
                fill: None,
                stroke: Color::from(color.as_str()),
                stroke_width: rp.stroke_width,
                opacity: None,
                stroke_dasharray: series.dasharray.clone(),
            })));
        }

        if let Some(r) = rp.dot_size {
            for &(px, py) in &pts {
                scene.add(Primitive::Circle {
                    cx: px,
                    cy: py,
                    r,
                    fill: Color::from(color.as_str()),
                    fill_opacity: None,
                    stroke: None,
                    stroke_width: None,
                });
            }
        }
    }

    // ── Vertex value labels with per-axis 1-D collision resolution ────────────
    //
    // All labels for axis i lie along the same radial direction θ, so collision
    // is purely 1-D (distance along the axis).  We collect each label's natural
    // radial position (fr * radius + base_offset), sort, then iteratively push
    // adjacent labels apart until no two overlap, before rendering.
    if rp.vertex_labels {
        let label_sz = (label_size as f64 * 0.75) as u32;
        let min_gap = label_sz as f64 * 1.4; // minimum px between label baselines
        let base_off = 9.0_f64; // initial clearance past the vertex

        // Collect (series_idx, natural_radial_px, formatted_value) per axis.
        let mut axis_items: Vec<Vec<(usize, f64, String)>> = vec![Vec::new(); n];
        for (si, series) in rp.series.iter().enumerate() {
            for (i, &v) in series.values.iter().enumerate().take(n) {
                let radial = frac(v, i) * radius + base_off;
                axis_items[i].push((si, radial, radar_fmt_value(v)));
            }
        }

        // 1-D push-apart: sort by radial position, then spread overlapping pairs.
        for items in axis_items.iter_mut() {
            items.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
            for _ in 0..30 {
                let mut moved = false;
                for j in 1..items.len() {
                    let gap = items[j].1 - items[j - 1].1;
                    if gap < min_gap {
                        let push = (min_gap - gap) / 2.0;
                        items[j - 1].1 -= push;
                        items[j].1 += push;
                        moved = true;
                    }
                }
                if !moved {
                    break;
                }
            }
            // Clamp: don't push any label closer to centre than base_off.
            for item in items.iter_mut() {
                item.1 = item.1.max(base_off);
            }
        }

        // Render resolved labels.
        for (i, items) in axis_items.iter().enumerate() {
            let th = angle(i);
            let anchor = if th.cos() > 0.1 {
                TextAnchor::Start
            } else if th.cos() < -0.1 {
                TextAnchor::End
            } else {
                TextAnchor::Middle
            };
            for &(si, radial, ref text) in items {
                let color = rp.series[si]
                    .color
                    .clone()
                    .unwrap_or_else(|| pal[si].to_string());
                scene.add(Primitive::Text {
                    x: round2(cx + radial * th.cos()),
                    y: round2(cy + radial * th.sin() + label_sz as f64 * 0.35),
                    content: text.clone(),
                    size: label_sz,
                    anchor,
                    rotate: None,
                    bold: false,
                    color: Some(Color::from(color.as_str())),
                });
            }
        }
    }
}

/// Wrap a long label at word boundaries to at most `max_chars` per line.
fn radar_wrap_label(s: &str, max_chars: usize) -> Vec<String> {
    if s.len() <= max_chars {
        return vec![s.to_string()];
    }
    let words: Vec<&str> = s.split_whitespace().collect();
    if words.len() < 2 {
        return vec![s.to_string()];
    }
    let mut lines: Vec<String> = Vec::new();
    let mut current = String::new();
    for word in &words {
        if current.is_empty() {
            current = word.to_string();
        } else if current.len() + 1 + word.len() <= max_chars {
            current.push(' ');
            current.push_str(word);
        } else {
            lines.push(current);
            current = word.to_string();
        }
    }
    if !current.is_empty() {
        lines.push(current);
    }
    lines
}

/// Format a vertex value as a compact string.
fn radar_fmt_value(v: f64) -> String {
    if v == v.round() && v.abs() < 1e4 {
        format!("{:.0}", v)
    } else if v.abs() < 10.0 {
        format!("{:.2}", v)
    } else {
        format!("{:.1}", v)
    }
}

/// Build a closed SVG polygon path from pixel points.
fn radar_polygon_path(pts: &[(f64, f64)]) -> String {
    if pts.is_empty() {
        return String::new();
    }
    let mut d = format!("M {} {}", pts[0].0, pts[0].1);
    for &(x, y) in &pts[1..] {
        d.push_str(&format!(" L {} {}", x, y));
    }
    d.push_str(" Z");
    d
}

/// Build a band path tracing `outer` forward then `inner` in reverse,
/// forming the filled region between two concentric polygons.
fn radar_band_path(outer: &[(f64, f64)], inner: &[(f64, f64)]) -> String {
    if outer.is_empty() || inner.is_empty() {
        return String::new();
    }
    let mut d = format!("M {} {}", outer[0].0, outer[0].1);
    for &(x, y) in &outer[1..] {
        d.push_str(&format!(" L {} {}", x, y));
    }
    for &(x, y) in inner.iter().rev() {
        d.push_str(&format!(" L {} {}", x, y));
    }
    d.push_str(" Z");
    d
}

// ── Hexbin renderer ───────────────────────────────────────────────────────────

/// Cube-coordinate rounding for axial hex coordinates.
fn hexbin_cube_round(fq: f64, fr: f64) -> (i32, i32) {
    let fs = -fq - fr;
    let q = fq.round();
    let r = fr.round();
    let s = fs.round();
    let dq = (q - fq).abs();
    let dr = (r - fr).abs();
    let ds = (s - fs).abs();
    if dq > dr && dq > ds {
        ((-r - s) as i32, r as i32)
    } else if dr > ds {
        (q as i32, (-q - s) as i32)
    } else {
        (q as i32, r as i32)
    }
}

/// Build an SVG path string for a regular hexagon centred at `(cx, cy)` with
/// circumradius `s`.  `flat_top = true` produces flat-top orientation; `false`
/// produces pointy-top.
fn hexbin_hex_path(cx: f64, cy: f64, s: f64, flat_top: bool) -> String {
    use std::f64::consts::PI;
    let start = if flat_top { 0.0_f64 } else { PI / 6.0 };
    let mut pts = [(0.0_f64, 0.0_f64); 6];
    for (i, pt) in pts.iter_mut().enumerate() {
        let a = start + i as f64 * PI / 3.0;
        *pt = (round2(cx + s * a.cos()), round2(cy + s * a.sin()));
    }
    let mut d = format!("M {} {}", pts[0].0, pts[0].1);
    for &(x, y) in &pts[1..] {
        d.push_str(&format!(" L {} {}", x, y));
    }
    d.push_str(" Z");
    d
}

/// Bin and aggregate hexbin data in pixel space, returning `(q,r) → aggregated_value` pairs.
/// Used by both `add_hexbin` (for drawing) and `add_hexbin_colorbar` (for the colorbar).
fn hexbin_bin_values(hb: &HexbinPlot, computed: &ComputedLayout) -> Vec<((i32, i32), f64)> {
    use std::collections::HashMap;

    let plot_left = computed.margin_left;
    let plot_right = computed.width - computed.margin_right;
    let plot_top = computed.margin_top;
    let plot_bottom = computed.height - computed.margin_bottom;
    let plot_w = plot_right - plot_left;
    let plot_h = plot_bottom - plot_top;
    if plot_w <= 0.0 || plot_h <= 0.0 {
        return vec![];
    }

    let n_x = hb.n_bins.max(2) as f64;
    let s_px = hb.bin_size.unwrap_or(if hb.flat_top {
        plot_w / (n_x * 1.5)
    } else {
        plot_w / (n_x * 3_f64.sqrt())
    });
    if s_px <= 0.0 {
        return vec![];
    }

    let mut bins: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
    for (idx, (&xi, &yi)) in hb.x.iter().zip(hb.y.iter()).enumerate() {
        if let Some((lo, hi)) = hb.x_range {
            if xi < lo || xi > hi {
                continue;
            }
        }
        if let Some((lo, hi)) = hb.y_range {
            if yi < lo || yi > hi {
                continue;
            }
        }
        let px = computed.map_x(xi);
        let py = computed.map_y(yi);
        if px < plot_left - s_px || px > plot_right + s_px {
            continue;
        }
        if py < plot_top - s_px || py > plot_bottom + s_px {
            continue;
        }
        let hx = (px - plot_left) / s_px;
        let hy = (py - plot_top) / s_px;
        let (q, r) = if hb.flat_top {
            hexbin_cube_round(2.0 / 3.0 * hx, -1.0 / 3.0 * hx + 3_f64.sqrt() / 3.0 * hy)
        } else {
            hexbin_cube_round(3_f64.sqrt() / 3.0 * hx - 1.0 / 3.0 * hy, 2.0 / 3.0 * hy)
        };
        bins.entry((q, r)).or_default().push(idx);
    }
    if bins.is_empty() {
        return vec![];
    }

    let total_pts = hb.x.len() as f64;
    let min_count = hb.min_count.max(1);
    let mut result: Vec<((i32, i32), f64)> = bins
        .iter()
        .filter(|(_, pts)| pts.len() >= min_count)
        .map(|(&key, pts)| {
            let val = match &hb.z_reduce {
                ZReduce::Count => {
                    if hb.normalize {
                        pts.len() as f64 / total_pts
                    } else {
                        pts.len() as f64
                    }
                }
                ZReduce::Mean => {
                    hb.z.as_ref()
                        .map(|z| pts.iter().map(|&i| z[i]).sum::<f64>() / pts.len() as f64)
                        .unwrap_or(pts.len() as f64)
                }
                ZReduce::Sum => {
                    hb.z.as_ref()
                        .map(|z| pts.iter().map(|&i| z[i]).sum::<f64>())
                        .unwrap_or(pts.len() as f64)
                }
                ZReduce::Min => {
                    hb.z.as_ref()
                        .map(|z| pts.iter().map(|&i| z[i]).fold(f64::INFINITY, f64::min))
                        .unwrap_or(pts.len() as f64)
                }
                ZReduce::Max => {
                    hb.z.as_ref()
                        .map(|z| pts.iter().map(|&i| z[i]).fold(f64::NEG_INFINITY, f64::max))
                        .unwrap_or(pts.len() as f64)
                }
                ZReduce::Median => {
                    hb.z.as_ref()
                        .map(|z| {
                            let mut vals: Vec<f64> = pts.iter().map(|&i| z[i]).collect();
                            vals.sort_by(|a, b| a.total_cmp(b));
                            let mid = vals.len() / 2;
                            if vals.len().is_multiple_of(2) {
                                (vals[mid - 1] + vals[mid]) / 2.0
                            } else {
                                vals[mid]
                            }
                        })
                        .unwrap_or(pts.len() as f64)
                }
            };
            (key, val)
        })
        .collect();
    result.sort_by_key(|&((q, r), _)| (q, r));
    result
}

/// Draw the hexbin colorbar.  Must be called AFTER `ClipEnd`.
fn add_hexbin_colorbar(hb: &HexbinPlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::plot::legend::ColorBarInfo;
    use std::sync::Arc;

    if !hb.show_colorbar {
        return;
    }

    let bin_vals = hexbin_bin_values(hb, computed);
    if bin_vals.is_empty() {
        return;
    }

    let (v_min_raw, v_max_raw) = bin_vals
        .iter()
        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), (_, v)| {
            (lo.min(*v), hi.max(*v))
        });
    let (v_min, v_max) = hb.color_range.unwrap_or((v_min_raw, v_max_raw));
    let cmap = hb.color_map.clone();

    let cb_label = hb
        .colorbar_label
        .clone()
        .unwrap_or_else(|| match hb.z_reduce {
            ZReduce::Count if hb.normalize => "Density".to_string(),
            ZReduce::Count => "Count".to_string(),
            ZReduce::Mean => "Mean".to_string(),
            ZReduce::Sum => "Sum".to_string(),
            ZReduce::Median => "Median".to_string(),
            ZReduce::Min => "Min".to_string(),
            ZReduce::Max => "Max".to_string(),
        });

    type MapFn = Arc<dyn Fn(f64) -> String + Send + Sync>;
    #[allow(clippy::type_complexity)]
    let (map_min, map_max, cb_map_fn, tick_labels): (
        f64,
        f64,
        MapFn,
        Option<Vec<(f64, String)>>,
    ) = if hb.log_color {
        let log_max = (v_max - v_min + 1.0).max(1.0).log10().max(f64::EPSILON);
        let mut ticks = vec![(0.0_f64, "0".to_string())];
        let mut k = 0u32;
        loop {
            let count = 10_f64.powi(k as i32);
            if count > v_max - v_min {
                break;
            }
            ticks.push(((count + 1.0).log10(), format!("{}", count as u64)));
            k += 1;
        }
        ticks.push((log_max, format!("{}", (v_max - v_min) as u64)));
        ticks.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-9);
        let lmax = log_max;
        (
            0.0,
            lmax,
            Arc::new(move |t: f64| cmap.map((t / lmax).clamp(0.0, 1.0))),
            Some(ticks),
        )
    } else {
        let span = (v_max - v_min).max(f64::EPSILON);
        let cmin = v_min;
        (
            v_min,
            v_max,
            Arc::new(move |t: f64| cmap.map(((t - cmin) / span).clamp(0.0, 1.0))),
            None,
        )
    };

    let cb_info = ColorBarInfo {
        map_fn: cb_map_fn,
        min_value: map_min,
        max_value: map_max,
        label: Some(cb_label),
        tick_labels,
    };
    add_colorbar(&cb_info, scene, computed);
}

fn add_hexbin(hb: &HexbinPlot, scene: &mut Scene, computed: &ComputedLayout) {
    if hb.x.is_empty() {
        return;
    }

    let plot_left = computed.margin_left;
    let plot_right = computed.width - computed.margin_right;
    let plot_top = computed.margin_top;
    let plot_bottom = computed.height - computed.margin_bottom;
    let plot_w = plot_right - plot_left;
    let plot_h = plot_bottom - plot_top;
    if plot_w <= 0.0 || plot_h <= 0.0 {
        return;
    }

    // ── Hex circumradius in pixels ────────────────────────────────────────────
    let n_x = hb.n_bins.max(2) as f64;
    let s_px = hb.bin_size.unwrap_or(if hb.flat_top {
        plot_w / (n_x * 1.5)
    } else {
        plot_w / (n_x * 3_f64.sqrt())
    });
    if s_px <= 0.0 {
        return;
    }

    let bin_vals = hexbin_bin_values(hb, computed);

    if bin_vals.is_empty() {
        return;
    }

    if bin_vals.is_empty() {
        return;
    }

    // ── Colour scale ──────────────────────────────────────────────────────────
    let (v_min_raw, v_max_raw) = bin_vals
        .iter()
        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), (_, v)| {
            (lo.min(*v), hi.max(*v))
        });
    let (v_min, v_max) = hb.color_range.unwrap_or((v_min_raw, v_max_raw));
    let v_span = (v_max - v_min).max(f64::EPSILON);
    let log_max = if hb.log_color {
        (v_max - v_min + 1.0).max(1.0).log10().max(f64::EPSILON)
    } else {
        1.0
    };
    let color_for = |v: f64| -> String {
        let norm = if hb.log_color {
            ((v - v_min + 1.0).max(1.0).log10() / log_max).clamp(0.0, 1.0)
        } else {
            ((v - v_min) / v_span).clamp(0.0, 1.0)
        };
        hb.color_map.map(norm)
    };

    // ── Draw hexagons ─────────────────────────────────────────────────────────
    for &((q, r), val) in &bin_vals {
        let (cx, cy) = if hb.flat_top {
            (
                plot_left + s_px * (1.5 * q as f64),
                plot_top + s_px * (3_f64.sqrt() / 2.0 * q as f64 + 3_f64.sqrt() * r as f64),
            )
        } else {
            (
                plot_left + s_px * (3_f64.sqrt() * q as f64 + 3_f64.sqrt() / 2.0 * r as f64),
                plot_top + s_px * (1.5 * r as f64),
            )
        };

        if cx < plot_left - 2.0 * s_px || cx > plot_right + 2.0 * s_px {
            continue;
        }
        if cy < plot_top - 2.0 * s_px || cy > plot_bottom + 2.0 * s_px {
            continue;
        }

        let fill_color = color_for(val);
        let (stroke_color, stroke_w) = if let Some(ref sc) = hb.stroke_color {
            (sc.as_str().to_string(), hb.stroke_width)
        } else {
            (fill_color.clone(), 0.0)
        };

        scene.add(Primitive::Path(Box::new(PathData {
            d: hexbin_hex_path(cx, cy, s_px, hb.flat_top),
            fill: Some(Color::from(fill_color.as_str())),
            stroke: Color::from(stroke_color.as_str()),
            stroke_width: stroke_w,
            opacity: None,
            stroke_dasharray: None,
        })));
    }
    // Colorbar is drawn after ClipEnd by add_hexbin_colorbar in render_multiple.
}

// ══════════════════════════════════════════════════════════════════════════════
// TreemapPlot rendering
// ══════════════════════════════════════════════════════════════════════════════

/// Axis-aligned rectangle used internally by the treemap layout algorithms.
#[derive(Clone, Copy, Debug)]
struct TmRect {
    x: f64,
    y: f64,
    w: f64,
    h: f64,
}

impl TmRect {
    #[inline]
    fn area(self) -> f64 {
        self.w * self.h
    }
}

/// A single resolved tile ready for rendering.
struct Tile {
    label: String,
    value: f64,
    /// Color value from `color_values` (leaf depth-first index).  `None` for inner nodes.
    color_value: Option<f64>,
    /// Color inherited from the nearest root ancestor (for `ByParent` mode).
    inherited_color: Option<String>,
    /// Explicit CSS color from `TreemapNode::color` (for `Explicit` mode).
    explicit_color: Option<String>,
    rect: TmRect,
    depth: usize,
    /// Breadcrumb path used for the SVG tooltip.
    path: String,
    /// `true` if this tile has no children (or `max_depth` was reached).
    is_leaf: bool,
}

/// Squarify worst-aspect-ratio metric (Bruls et al. 2000).
/// `row` contains pixel-area values; `w` is the strip width (shorter side).
#[inline]
fn worst_ratio(row: &[f64], w: f64) -> f64 {
    let s: f64 = row.iter().sum();
    if s <= f64::EPSILON || w <= f64::EPSILON {
        return f64::MAX;
    }
    let max_v = row.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
    let min_v = row.iter().cloned().fold(f64::INFINITY, f64::min);
    if min_v <= f64::EPSILON {
        return f64::MAX;
    }
    ((w * w * max_v) / (s * s)).max((s * s) / (w * w * min_v))
}

/// Padding at `depth`, halving at each level (floor at 1 px).
#[inline]
fn tm_effective_padding(base: f64, depth: usize) -> f64 {
    (base / (1u64 << depth.min(10)) as f64).max(1.0)
}

/// Layout via the squarified algorithm.
/// Returns `(original_index, TmRect)` pairs for all items with positive area.
fn run_squarify(pixel_areas: &[f64], rect: TmRect) -> Vec<(usize, TmRect)> {
    if rect.w <= 0.0 || rect.h <= 0.0 {
        return vec![];
    }
    // Sort descending, keeping original indices
    let mut order: Vec<usize> = (0..pixel_areas.len())
        .filter(|&i| pixel_areas[i] > f64::EPSILON)
        .collect();
    order.sort_by(|&a, &b| {
        pixel_areas[b]
            .partial_cmp(&pixel_areas[a])
            .unwrap_or(std::cmp::Ordering::Equal)
    });

    let mut result = vec![
        TmRect {
            x: 0.0,
            y: 0.0,
            w: 0.0,
            h: 0.0
        };
        pixel_areas.len()
    ];
    squarify_recursive(&order, pixel_areas, rect, &mut result);
    order.iter().map(|&i| (i, result[i])).collect()
}

fn squarify_recursive(order: &[usize], areas: &[f64], rect: TmRect, result: &mut Vec<TmRect>) {
    if order.is_empty() || rect.w <= 0.0 || rect.h <= 0.0 {
        return;
    }
    if order.len() == 1 {
        result[order[0]] = rect;
        return;
    }

    let w = rect.w.min(rect.h); // shorter side → strip dimension

    // Build current row by greedily adding items while aspect ratio improves
    let mut row_len = 1;
    let mut row_vals: Vec<f64> = vec![areas[order[0]]];
    let mut prev_ratio = worst_ratio(&row_vals, w);

    for k in 1..order.len() {
        let mut candidate = row_vals.clone();
        candidate.push(areas[order[k]]);
        let new_ratio = worst_ratio(&candidate, w);
        if new_ratio <= prev_ratio {
            row_vals = candidate;
            row_len += 1;
            prev_ratio = new_ratio;
        } else {
            break;
        }
    }

    // Lay out the row as a strip
    let row_sum: f64 = row_vals.iter().sum();
    let strip_size = if rect.w >= rect.h {
        row_sum / rect.w
    } else {
        row_sum / rect.h
    };
    let strip_size = strip_size.max(0.0);

    let mut offset = 0.0_f64;
    for &idx in order[..row_len].iter() {
        let frac = if row_sum > f64::EPSILON {
            areas[idx] / row_sum
        } else {
            1.0 / row_len as f64
        };
        result[idx] = if rect.w >= rect.h {
            let rw = (frac * rect.w).max(0.0);
            let r = TmRect {
                x: rect.x + offset,
                y: rect.y,
                w: rw,
                h: strip_size,
            };
            offset += rw;
            r
        } else {
            let rh = (frac * rect.h).max(0.0);
            let r = TmRect {
                x: rect.x,
                y: rect.y + offset,
                w: strip_size,
                h: rh,
            };
            offset += rh;
            r
        };
    }

    // Remaining rectangle after the strip
    let remaining = if rect.w >= rect.h {
        TmRect {
            x: rect.x,
            y: rect.y + strip_size,
            w: rect.w,
            h: (rect.h - strip_size).max(0.0),
        }
    } else {
        TmRect {
            x: rect.x + strip_size,
            y: rect.y,
            w: (rect.w - strip_size).max(0.0),
            h: rect.h,
        }
    };

    squarify_recursive(&order[row_len..], areas, remaining, result);
}

/// Layout via alternating horizontal/vertical slice-and-dice.
fn run_slicedice(pixel_areas: &[f64], rect: TmRect, depth: usize) -> Vec<(usize, TmRect)> {
    if rect.w <= 0.0 || rect.h <= 0.0 {
        return vec![];
    }
    let total: f64 = pixel_areas.iter().sum();
    if total <= f64::EPSILON {
        return vec![];
    }

    let horizontal = depth.is_multiple_of(2);
    let mut offset = 0.0_f64;

    pixel_areas
        .iter()
        .enumerate()
        .filter(|(_, &a)| a > f64::EPSILON)
        .map(|(i, &a)| {
            let frac = a / total;
            let r = if horizontal {
                let w = (frac * rect.w).max(0.0);
                let r = TmRect {
                    x: rect.x + offset,
                    y: rect.y,
                    w,
                    h: rect.h,
                };
                offset += w;
                r
            } else {
                let h = (frac * rect.h).max(0.0);
                let r = TmRect {
                    x: rect.x,
                    y: rect.y + offset,
                    w: rect.w,
                    h,
                };
                offset += h;
                r
            };
            (i, r)
        })
        .collect()
}

/// Layout via balanced binary splits.
fn run_binary(pixel_areas: &[f64], rect: TmRect, depth: usize) -> Vec<(usize, TmRect)> {
    if rect.w <= 0.0 || rect.h <= 0.0 {
        return vec![];
    }
    let mut order: Vec<usize> = (0..pixel_areas.len())
        .filter(|&i| pixel_areas[i] > f64::EPSILON)
        .collect();
    if order.is_empty() {
        return vec![];
    }
    order.sort_by(|&a, &b| {
        pixel_areas[b]
            .partial_cmp(&pixel_areas[a])
            .unwrap_or(std::cmp::Ordering::Equal)
    });

    let mut result = vec![
        TmRect {
            x: 0.0,
            y: 0.0,
            w: 0.0,
            h: 0.0
        };
        pixel_areas.len()
    ];
    binary_recursive(&order, pixel_areas, rect, depth, &mut result);
    order.iter().map(|&i| (i, result[i])).collect()
}

fn binary_recursive(
    order: &[usize],
    areas: &[f64],
    rect: TmRect,
    depth: usize,
    result: &mut Vec<TmRect>,
) {
    if order.is_empty() || rect.w <= 0.0 || rect.h <= 0.0 {
        return;
    }
    if order.len() == 1 {
        result[order[0]] = rect;
        return;
    }

    let total: f64 = order.iter().map(|&i| areas[i]).sum();
    // Find split minimising |left_sum - right_sum|
    let mut best_diff = f64::MAX;
    let mut split = 1;
    let mut cum = 0.0_f64;
    for k in 0..order.len() - 1 {
        cum += areas[order[k]];
        let diff = (cum - (total - cum)).abs();
        if diff < best_diff {
            best_diff = diff;
            split = k + 1;
        }
    }

    let left_sum: f64 = order[..split].iter().map(|&i| areas[i]).sum();
    let horizontal = depth.is_multiple_of(2);
    let (left_rect, right_rect) = if horizontal {
        let lw = (left_sum / total * rect.w).max(0.0);
        (
            TmRect {
                x: rect.x,
                y: rect.y,
                w: lw,
                h: rect.h,
            },
            TmRect {
                x: rect.x + lw,
                y: rect.y,
                w: (rect.w - lw).max(0.0),
                h: rect.h,
            },
        )
    } else {
        let lh = (left_sum / total * rect.h).max(0.0);
        (
            TmRect {
                x: rect.x,
                y: rect.y,
                w: rect.w,
                h: lh,
            },
            TmRect {
                x: rect.x,
                y: rect.y + lh,
                w: rect.w,
                h: (rect.h - lh).max(0.0),
            },
        )
    };

    binary_recursive(&order[..split], areas, left_rect, depth + 1, result);
    binary_recursive(&order[split..], areas, right_rect, depth + 1, result);
}

/// Dispatch to the chosen layout algorithm.
fn tm_layout(
    pixel_areas: &[f64],
    rect: TmRect,
    depth: usize,
    algo: &TreemapLayout,
) -> Vec<(usize, TmRect)> {
    match algo {
        TreemapLayout::Squarify => run_squarify(pixel_areas, rect),
        TreemapLayout::SliceDice => run_slicedice(pixel_areas, rect, depth),
        TreemapLayout::Binary => run_binary(pixel_areas, rect, depth),
    }
}

/// Recursively collect tiles for `nodes` into `tiles`, tracking `leaf_idx` for `color_values`.
#[allow(clippy::too_many_arguments)]
fn collect_treemap_tiles(
    nodes: &[TreemapNode],
    rect: TmRect,
    depth: usize,
    tm: &TreemapPlot,
    inherited_color: Option<&str>,
    path_prefix: &str,
    leaf_idx: &mut usize,
    tiles: &mut Vec<Tile>,
) {
    let font_size = 12.0_f64;

    let active: Vec<&TreemapNode> = nodes
        .iter()
        .filter(|n| n.resolved_value() > f64::EPSILON)
        .collect();
    if active.is_empty() || rect.w <= 0.0 || rect.h <= 0.0 {
        return;
    }

    let total: f64 = active.iter().map(|n| n.resolved_value()).sum();
    let pixel_areas: Vec<f64> = active
        .iter()
        .map(|n| n.resolved_value() / total * rect.area())
        .collect();

    let rects = tm_layout(&pixel_areas, rect, depth, &tm.layout_algo);

    for (i, tile_rect) in rects {
        let node = active[i];
        let max_depth_reached = tm.max_depth.map(|md| depth >= md).unwrap_or(false);
        let is_leaf = node.children.is_empty() || max_depth_reached;

        let color_value = if node.children.is_empty() {
            let cv = tm
                .color_values
                .as_ref()
                .and_then(|cv| cv.get(*leaf_idx).copied());
            *leaf_idx += 1;
            cv
        } else {
            None
        };

        let path = if path_prefix.is_empty() {
            node.label.clone()
        } else {
            format!("{} > {}", path_prefix, node.label)
        };

        tiles.push(Tile {
            label: node.label.clone(),
            value: node.resolved_value(),
            color_value,
            inherited_color: inherited_color.map(|s| s.to_string()),
            explicit_color: node.color.clone(),
            rect: tile_rect,
            depth,
            path: path.clone(),
            is_leaf,
        });

        if !node.children.is_empty() && !max_depth_reached {
            let pad = tm_effective_padding(tm.padding, depth);
            let label_reserve = if tm.show_parent_labels {
                font_size * 1.4
            } else {
                0.0
            };
            let child_rect = TmRect {
                x: tile_rect.x + pad,
                y: tile_rect.y + pad + label_reserve,
                w: (tile_rect.w - 2.0 * pad).max(0.0),
                h: (tile_rect.h - 2.0 * pad - label_reserve).max(0.0),
            };
            if child_rect.w > 0.0 && child_rect.h > 0.0 {
                collect_treemap_tiles(
                    &node.children,
                    child_rect,
                    depth + 1,
                    tm,
                    inherited_color,
                    &path,
                    leaf_idx,
                    tiles,
                );
            }
        }
    }
}

/// Collect all leaf node values (depth-first) for colorbar range computation.
fn treemap_leaf_values(roots: &[TreemapNode]) -> Vec<f64> {
    fn recurse(nodes: &[TreemapNode], out: &mut Vec<f64>) {
        for n in nodes {
            if n.children.is_empty() {
                out.push(n.resolved_value());
            } else {
                recurse(&n.children, out);
            }
        }
    }
    let mut out = Vec::new();
    recurse(roots, &mut out);
    out
}

fn compute_treemap_value_range(tm: &TreemapPlot) -> (f64, f64) {
    if let Some(range) = tm.color_range {
        return range;
    }
    let vals = if let Some(ref cv) = tm.color_values {
        cv.clone()
    } else {
        treemap_leaf_values(&tm.roots)
    };
    if vals.is_empty() {
        return (0.0, 1.0);
    }
    let lo = vals.iter().cloned().fold(f64::INFINITY, f64::min);
    let hi = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
    (lo, hi)
}

/// Render a [`TreemapPlot`].  Must be added to `skip_axes` so no axes are drawn.
fn add_treemap(tm: &TreemapPlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::render::palette::Palette;

    let plot_rect = TmRect {
        x: computed.margin_left,
        y: computed.margin_top,
        w: (computed.width - computed.margin_left - computed.margin_right).max(0.0),
        h: (computed.height - computed.margin_top - computed.margin_bottom).max(0.0),
    };
    if plot_rect.w <= 0.0 || plot_rect.h <= 0.0 {
        return;
    }

    let cat10 = Palette::category10();

    // ── Build all tiles ───────────────────────────────────────────────────────
    let active_roots: Vec<&TreemapNode> = tm
        .roots
        .iter()
        .filter(|n| n.resolved_value() > f64::EPSILON)
        .collect();
    if active_roots.is_empty() {
        return;
    }

    let total_root: f64 = active_roots.iter().map(|n| n.resolved_value()).sum();
    let root_areas: Vec<f64> = active_roots
        .iter()
        .map(|n| n.resolved_value() / total_root * plot_rect.area())
        .collect();

    let root_rects = tm_layout(&root_areas, plot_rect, 0, &tm.layout_algo);

    let mut tiles: Vec<Tile> = Vec::new();
    let mut leaf_idx = 0usize;

    for (i, root_rect) in root_rects {
        let node = active_roots[i];
        let root_color = cat10[i % cat10.len()].to_string();
        let max_depth_reached = tm.max_depth.map(|md| md == 0).unwrap_or(false);
        let is_leaf = node.children.is_empty() || max_depth_reached;

        let color_value = if node.children.is_empty() {
            let cv = tm
                .color_values
                .as_ref()
                .and_then(|cv| cv.get(leaf_idx).copied());
            leaf_idx += 1;
            cv
        } else {
            None
        };

        let path = node.label.clone();
        tiles.push(Tile {
            label: node.label.clone(),
            value: node.resolved_value(),
            color_value,
            inherited_color: Some(root_color.clone()),
            explicit_color: node.color.clone(),
            rect: root_rect,
            depth: 0,
            path: path.clone(),
            is_leaf,
        });

        if !node.children.is_empty() && !max_depth_reached {
            let pad = tm_effective_padding(tm.padding, 0);
            let label_reserve = if tm.show_parent_labels {
                12.0 * 1.4
            } else {
                0.0
            };
            let child_rect = TmRect {
                x: root_rect.x + pad,
                y: root_rect.y + pad + label_reserve,
                w: (root_rect.w - 2.0 * pad).max(0.0),
                h: (root_rect.h - 2.0 * pad - label_reserve).max(0.0),
            };
            if child_rect.w > 0.0 && child_rect.h > 0.0 {
                collect_treemap_tiles(
                    &node.children,
                    child_rect,
                    1,
                    tm,
                    Some(&root_color),
                    &path,
                    &mut leaf_idx,
                    &mut tiles,
                );
            }
        }
    }

    // ── Colour range for ByValue mode ─────────────────────────────────────────
    let (v_min, v_max) = if matches!(tm.color_mode, TreemapColorMode::ByValue(_)) {
        compute_treemap_value_range(tm)
    } else {
        (0.0, 1.0)
    };
    let v_span = (v_max - v_min).max(f64::EPSILON);

    // ── Render tiles ──────────────────────────────────────────────────────────
    let font_size = 12u32;
    for tile in &tiles {
        let fill_color = match &tm.color_mode {
            TreemapColorMode::ByParent => tile
                .inherited_color
                .as_deref()
                .unwrap_or("#888888")
                .to_string(),
            TreemapColorMode::ByValue(cmap) => {
                if tile.is_leaf {
                    let raw = tile.color_value.unwrap_or(tile.value);
                    let norm = ((raw - v_min) / v_span).clamp(0.0, 1.0);
                    cmap.map(norm)
                } else {
                    "#e0e0e0".to_string()
                }
            }
            TreemapColorMode::Explicit => tile
                .explicit_color
                .as_deref()
                .unwrap_or("#888888")
                .to_string(),
        };

        let stroke_w = if tile.depth == 0 {
            tm.root_border_width
        } else {
            tm.border_width
        };

        if tm.show_tooltips {
            let tooltip_text = format!("{}\n{:.4}", tile.path, tile.value);
            scene.add(Primitive::GroupStart {
                transform: None,
                title: Some(tooltip_text),
                extra_attrs: None,
            });
        }

        scene.add(Primitive::Rect {
            x: round2(tile.rect.x),
            y: round2(tile.rect.y),
            width: round2(tile.rect.w.max(0.0)),
            height: round2(tile.rect.h.max(0.0)),
            fill: Color::from(fill_color.as_str()),
            stroke: Some(Color::from("#ffffff")),
            stroke_width: Some(stroke_w),
            opacity: None,
        });

        // ── Label ─────────────────────────────────────────────────────────────
        let area = tile.rect.area();
        if area >= tm.min_label_area && tile.rect.w.min(tile.rect.h) >= font_size as f64 * 1.5 {
            let is_parent_label = !tile.is_leaf;
            let show_lbl = if is_parent_label {
                tm.show_parent_labels
            } else {
                tm.show_labels
            };

            if show_lbl {
                let char_w_est = font_size as f64 * 0.55;
                let max_chars = ((tile.rect.w * 0.88) / char_w_est).floor() as usize;
                let label = if max_chars > 2 && tile.label.chars().count() > max_chars {
                    let truncated: String = tile
                        .label
                        .chars()
                        .take(max_chars.saturating_sub(1))
                        .collect();
                    format!("{}…", truncated)
                } else {
                    tile.label.clone()
                };

                let (lx, ly, anchor, bold) = if is_parent_label {
                    (
                        tile.rect.x + 4.0,
                        tile.rect.y + font_size as f64 + 2.0,
                        TextAnchor::Start,
                        true,
                    )
                } else {
                    (
                        tile.rect.x + tile.rect.w * 0.5,
                        tile.rect.y + tile.rect.h * 0.5 + font_size as f64 * 0.35,
                        TextAnchor::Middle,
                        false,
                    )
                };

                scene.add(Primitive::Text {
                    x: round2(lx),
                    y: round2(ly),
                    content: label,
                    size: font_size,
                    anchor,
                    rotate: None,
                    bold,
                    color: Some(Color::from("#ffffff")),
                });
            }
        }

        if tm.show_tooltips {
            scene.add(Primitive::GroupEnd);
        }
    }
    // Colorbar drawn after ClipEnd by add_treemap_colorbar in render_multiple.
}

/// Draw the treemap colorbar.  Must be called AFTER `ClipEnd`.
fn add_treemap_colorbar(tm: &TreemapPlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::plot::legend::ColorBarInfo;
    use std::sync::Arc;

    let cmap = match &tm.color_mode {
        TreemapColorMode::ByValue(cmap) => cmap.clone(),
        _ => return,
    };
    if !tm.show_colorbar {
        return;
    }

    let (v_min, v_max) = compute_treemap_value_range(tm);
    let span = (v_max - v_min).max(f64::EPSILON);
    let cmin = v_min;

    let label = tm
        .colorbar_label
        .clone()
        .unwrap_or_else(|| "Value".to_string());

    let cb_info = ColorBarInfo {
        map_fn: Arc::new(move |t: f64| cmap.map(((t - cmin) / span).clamp(0.0, 1.0))),
        min_value: v_min,
        max_value: v_max,
        label: Some(label),
        tick_labels: None,
    };
    add_colorbar(&cb_info, scene, computed);
}

/// Render a single [`TreemapPlot`] to a [`Scene`].
pub fn render_treemap(tm: TreemapPlot, layout: Layout) -> Scene {
    let plots = vec![Plot::Treemap(tm)];
    render_multiple(plots, layout)
}

// ─────────────────────────────────────────────────────────────────────────────
// Sunburst rendering
// ─────────────────────────────────────────────────────────────────────────────

/// One arc in the sunburst.
struct SbArc {
    label: String,
    value: f64,
    color_value: Option<f64>,
    inherited_color: Option<String>,
    explicit_color: Option<String>,
    /// Start angle in compass degrees (0 = north, clockwise).
    start_deg: f64,
    /// Sweep in degrees (positive = clockwise).
    sweep_deg: f64,
    r_inner: f64,
    r_outer: f64,
    path: String,
    is_leaf: bool,
}

/// Convert compass degrees to SVG radians: 0° → north (top), clockwise.
#[inline]
fn compass_rad(deg: f64) -> f64 {
    (deg - 90.0_f64).to_radians()
}

/// Point on circle at compass angle `deg_compass`, radius `r`, centred at (cx, cy).
#[inline]
fn arc_point(cx: f64, cy: f64, r: f64, deg_compass: f64) -> (f64, f64) {
    let a = compass_rad(deg_compass);
    (cx + r * a.cos(), cy + r * a.sin())
}

/// Build the SVG `d` string for a sunburst arc sector.
fn sunburst_arc_path(
    cx: f64,
    cy: f64,
    r_inner: f64,
    r_outer: f64,
    start_deg: f64,
    sweep_deg: f64,
) -> String {
    let end_deg = start_deg + sweep_deg;

    // Full circle: draw as two halves to avoid SVG arc degeneration
    if sweep_deg.abs() >= 359.9 {
        let (ox1, oy1) = arc_point(cx, cy, r_outer, start_deg);
        let (ox2, oy2) = arc_point(cx, cy, r_outer, start_deg + 180.0);
        if r_inner <= 0.5 {
            return format!(
                "M{cx},{cy} L{ox1},{oy1} A{ro},{ro} 0 1,1 {ox2},{oy2} A{ro},{ro} 0 1,1 {ox1},{oy1} Z",
                ro = r_outer, cx = round2(cx), cy = round2(cy),
                ox1 = round2(ox1), oy1 = round2(oy1),
                ox2 = round2(ox2), oy2 = round2(oy2),
            );
        } else {
            let (ix1, iy1) = arc_point(cx, cy, r_inner, start_deg);
            let (ix2, iy2) = arc_point(cx, cy, r_inner, start_deg + 180.0);
            return format!(
                "M{ox1},{oy1} A{ro},{ro} 0 1,1 {ox2},{oy2} A{ro},{ro} 0 1,1 {ox1},{oy1} \
                 M{ix1},{iy1} A{ri},{ri} 0 1,0 {ix2},{iy2} A{ri},{ri} 0 1,0 {ix1},{iy1} Z",
                ro = r_outer,
                ri = r_inner,
                ox1 = round2(ox1),
                oy1 = round2(oy1),
                ox2 = round2(ox2),
                oy2 = round2(oy2),
                ix1 = round2(ix1),
                iy1 = round2(iy1),
                ix2 = round2(ix2),
                iy2 = round2(iy2),
            );
        }
    }

    let large_arc = if sweep_deg.abs() > 180.0 { 1 } else { 0 };

    let (ox1, oy1) = arc_point(cx, cy, r_outer, start_deg);
    let (ox2, oy2) = arc_point(cx, cy, r_outer, end_deg);

    if r_inner <= 0.5 {
        // Wedge to center
        format!(
            "M{cx},{cy} L{ox1},{oy1} A{ro},{ro} 0 {la},1 {ox2},{oy2} Z",
            ro = r_outer,
            la = large_arc,
            cx = round2(cx),
            cy = round2(cy),
            ox1 = round2(ox1),
            oy1 = round2(oy1),
            ox2 = round2(ox2),
            oy2 = round2(oy2),
        )
    } else {
        // Annulus sector
        let (ix1, iy1) = arc_point(cx, cy, r_inner, start_deg);
        let (ix2, iy2) = arc_point(cx, cy, r_inner, end_deg);
        format!(
            "M{ox1},{oy1} A{ro},{ro} 0 {la},1 {ox2},{oy2} L{ix2},{iy2} A{ri},{ri} 0 {la},0 {ix1},{iy1} Z",
            ro = r_outer, ri = r_inner, la = large_arc,
            ox1 = round2(ox1), oy1 = round2(oy1),
            ox2 = round2(ox2), oy2 = round2(oy2),
            ix1 = round2(ix1), iy1 = round2(iy1),
            ix2 = round2(ix2), iy2 = round2(iy2),
        )
    }
}

/// Collect all arcs across all depth levels.
fn build_sunburst_arcs(sb: &SunburstPlot, avail_r: f64) -> Vec<SbArc> {
    use crate::render::palette::Palette;
    let cat10 = Palette::category10();

    let active_roots: Vec<&TreemapNode> = sb
        .roots
        .iter()
        .filter(|n| n.resolved_value() > f64::EPSILON)
        .collect();
    if active_roots.is_empty() {
        return vec![];
    }

    // Determine number of rings to draw
    let max_tree_depth = sb.max_tree_depth();
    let n_rings = if let Some(md) = sb.max_depth {
        (md + 1).min(max_tree_depth + 1)
    } else {
        max_tree_depth + 1
    };
    let n_rings = n_rings.max(1);

    let ring_w_total = (1.0 - sb.inner_radius_frac) * avail_r;
    let ring_w = ring_w_total / n_rings as f64;

    let total_root: f64 = active_roots.iter().map(|n| n.resolved_value()).sum();

    let cv_slice: &[f64] = sb.color_values.as_deref().unwrap_or(&[]);

    let mut arcs: Vec<SbArc> = Vec::new();

    // Process one depth level at a time, BFS-style, collecting nodes at that depth.
    // We do this iteratively by passing arcs from the previous depth.

    struct PendingNode<'a> {
        node: &'a TreemapNode,
        start_deg: f64,
        sweep_deg: f64,
        inherited_color: Option<String>,
    }

    let mut pending: Vec<PendingNode> = Vec::new();
    let mut leaf_idx = 0usize;

    // Seed with root level
    let mut cursor = sb.start_angle_deg;
    for (i, node) in active_roots.iter().enumerate() {
        let val = node.resolved_value();
        let sweep = val / total_root * 360.0;
        let root_color = cat10[i % cat10.len()].to_string();
        pending.push(PendingNode {
            node,
            start_deg: cursor,
            sweep_deg: sweep,
            inherited_color: Some(root_color),
        });
        cursor += sweep;
    }

    // Process each pending node level by level
    let mut next_pending: Vec<PendingNode> = Vec::new();
    let mut current_depth = 0usize;

    while !pending.is_empty() {
        if let Some(md) = sb.max_depth {
            if current_depth > md {
                break;
            }
        }

        let r_inner = sb.inner_radius_frac * avail_r + current_depth as f64 * ring_w;
        let r_outer = (r_inner + ring_w - sb.ring_gap).max(r_inner + 1.0);

        for pn in pending.drain(..) {
            let node = pn.node;
            let is_leaf = node.children.is_empty()
                || sb.max_depth.map(|md| current_depth >= md).unwrap_or(false);

            let color_value = if node.children.is_empty() {
                let cv = cv_slice.get(leaf_idx).copied();
                leaf_idx += 1;
                cv
            } else {
                None
            };

            let path = node.label.clone();

            arcs.push(SbArc {
                label: node.label.clone(),
                value: node.resolved_value(),
                color_value,
                inherited_color: pn.inherited_color.clone(),
                explicit_color: node.color.clone(),
                start_deg: pn.start_deg,
                sweep_deg: pn.sweep_deg,
                r_inner,
                r_outer,
                path,
                is_leaf,
            });

            if !node.children.is_empty() && !is_leaf {
                let child_total: f64 = node.children.iter().map(|c| c.resolved_value()).sum();
                if child_total > f64::EPSILON {
                    let mut child_cursor = pn.start_deg;
                    for child in &node.children {
                        let cv = child.resolved_value();
                        if cv <= f64::EPSILON {
                            continue;
                        }
                        let child_sweep = cv / child_total * pn.sweep_deg;
                        next_pending.push(PendingNode {
                            node: child,
                            start_deg: child_cursor,
                            sweep_deg: child_sweep,
                            inherited_color: pn.inherited_color.clone(),
                        });
                        child_cursor += child_sweep;
                    }
                }
            }
        }

        std::mem::swap(&mut pending, &mut next_pending);
        current_depth += 1;
    }

    arcs
}

/// Collect leaf values from sunburst for colorbar range computation.
fn sunburst_leaf_values(sb: &SunburstPlot) -> Vec<f64> {
    if let Some(ref cv) = sb.color_values {
        return cv.clone();
    }
    fn collect(nodes: &[TreemapNode], out: &mut Vec<f64>) {
        for n in nodes {
            if n.children.is_empty() {
                out.push(n.resolved_value());
            } else {
                collect(&n.children, out);
            }
        }
    }
    let mut out = Vec::new();
    collect(&sb.roots, &mut out);
    out
}

fn compute_sunburst_value_range(sb: &SunburstPlot) -> (f64, f64) {
    if let Some((lo, hi)) = sb.color_range {
        return (lo, hi);
    }
    let vals = sunburst_leaf_values(sb);
    if vals.is_empty() {
        return (0.0, 1.0);
    }
    let lo = vals.iter().cloned().fold(f64::INFINITY, f64::min);
    let hi = vals.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
    (lo, hi)
}

/// Render a [`SunburstPlot`].  Must be added to `skip_axes` so no axes are drawn.
fn add_sunburst(sb: &SunburstPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let pw = computed.width - computed.margin_left - computed.margin_right;
    let ph = computed.height - computed.margin_top - computed.margin_bottom;
    if pw <= 0.0 || ph <= 0.0 {
        return;
    }

    let cx = computed.margin_left + pw / 2.0;
    let cy = computed.margin_top + ph / 2.0;
    let avail_r = pw.min(ph) / 2.0 - 4.0;
    if avail_r <= 0.0 {
        return;
    }

    let arcs = build_sunburst_arcs(sb, avail_r);
    if arcs.is_empty() {
        return;
    }

    // Colour range for ByValue mode
    let (v_min, v_max) = if matches!(sb.color_mode, SunburstColorMode::ByValue(_)) {
        compute_sunburst_value_range(sb)
    } else {
        (0.0, 1.0)
    };
    let v_span = (v_max - v_min).max(f64::EPSILON);

    for arc in &arcs {
        let fill_color = match &sb.color_mode {
            SunburstColorMode::ByParent => arc
                .inherited_color
                .as_deref()
                .unwrap_or("#888888")
                .to_string(),
            SunburstColorMode::ByValue(cmap) => {
                if arc.is_leaf {
                    let raw = arc.color_value.unwrap_or(arc.value);
                    let norm = ((raw - v_min) / v_span).clamp(0.0, 1.0);
                    cmap.map(norm)
                } else {
                    "#e0e0e0".to_string()
                }
            }
            SunburstColorMode::Explicit => arc
                .explicit_color
                .as_deref()
                .unwrap_or("#888888")
                .to_string(),
        };

        if sb.show_tooltips {
            let tip = format!("{}\n{:.4}", arc.path, arc.value);
            scene.add(Primitive::GroupStart {
                transform: None,
                title: Some(tip),
                extra_attrs: None,
            });
        }

        let path_d = sunburst_arc_path(
            cx,
            cy,
            arc.r_inner,
            arc.r_outer,
            arc.start_deg,
            arc.sweep_deg,
        );
        scene.add(Primitive::Path(Box::new(PathData {
            d: path_d,
            fill: Some(Color::from(fill_color.as_str())),
            stroke: Color::from("#ffffff"),
            stroke_width: 0.8,
            opacity: None,
            stroke_dasharray: None,
        })));

        // Label
        if sb.show_labels && arc.sweep_deg >= sb.min_label_angle {
            let mid_deg = arc.start_deg + arc.sweep_deg / 2.0;
            let r_mid = (arc.r_inner + arc.r_outer) / 2.0;
            let (lx, ly) = arc_point(cx, cy, r_mid, mid_deg);

            let font_size = 11u32;
            let char_w_est = font_size as f64 * 0.55;
            // Available arc width at midpoint
            let arc_len = arc.sweep_deg.to_radians() * r_mid;
            let max_chars = ((arc_len * 0.88) / char_w_est).floor().max(0.0) as usize;
            let label = if max_chars > 2 && arc.label.chars().count() > max_chars {
                let truncated: String = arc
                    .label
                    .chars()
                    .take(max_chars.saturating_sub(1))
                    .collect();
                format!("{}…", truncated)
            } else {
                arc.label.clone()
            };

            let rotate = if sb.rotate_labels {
                // Follow the arc tangent; flip labels in the bottom half so they read left-to-right
                let rotate_deg = if mid_deg > 90.0 && mid_deg < 270.0 {
                    mid_deg - 180.0
                } else {
                    mid_deg
                };
                Some(rotate_deg - 90.0)
            } else {
                None
            };

            scene.add(Primitive::Text {
                x: round2(lx),
                y: round2(ly),
                content: label,
                size: font_size,
                anchor: TextAnchor::Middle,
                rotate,
                bold: false,
                color: Some(Color::from("#ffffff")),
            });
        }

        if sb.show_tooltips {
            scene.add(Primitive::GroupEnd);
        }
    }
    // Colorbar drawn after ClipEnd by add_sunburst_colorbar in render_multiple.
}

/// Draw the sunburst colorbar.  Must be called AFTER `ClipEnd`.
fn add_sunburst_colorbar(sb: &SunburstPlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::plot::legend::ColorBarInfo;
    use std::sync::Arc;

    let cmap = match &sb.color_mode {
        SunburstColorMode::ByValue(cmap) => cmap.clone(),
        _ => return,
    };
    if !sb.show_colorbar {
        return;
    }

    let (v_min, v_max) = compute_sunburst_value_range(sb);
    let span = (v_max - v_min).max(f64::EPSILON);
    let cmin = v_min;

    let label = sb
        .colorbar_label
        .clone()
        .unwrap_or_else(|| "Value".to_string());

    let cb_info = ColorBarInfo {
        map_fn: Arc::new(move |t: f64| cmap.map(((t - cmin) / span).clamp(0.0, 1.0))),
        min_value: v_min,
        max_value: v_max,
        label: Some(label),
        tick_labels: None,
    };
    add_colorbar(&cb_info, scene, computed);
}

/// Render a single [`SunburstPlot`] to a [`Scene`].
pub fn render_sunburst(sb: SunburstPlot, layout: Layout) -> Scene {
    let plots = vec![Plot::Sunburst(sb)];
    render_multiple(plots, layout)
}

// ─────────────────────────────────────────────────────────────────────────────
// Bump chart rendering
// ─────────────────────────────────────────────────────────────────────────────

/// Separate overlapping endpoint labels with a spring-relaxation pass.
fn nudge_bump_labels(positions: &mut [(usize, f64)], min_gap: f64) {
    let n = positions.len();
    for _ in 0..20 {
        let mut changed = false;
        for i in 1..n {
            let gap = positions[i].1 - positions[i - 1].1;
            if gap < min_gap {
                let push = (min_gap - gap) / 2.0;
                positions[i - 1].1 -= push;
                positions[i].1 += push;
                changed = true;
            }
        }
        if !changed {
            break;
        }
    }
}

fn add_bump(bp: &BumpPlot, scene: &mut Scene, computed: &ComputedLayout, layout: &Layout) {
    use crate::render::palette::Palette;

    let series = bp.resolved_series();
    let n = series.len();
    let n_time = bp.n_time_points();
    if n == 0 || n_time == 0 {
        return;
    }

    let cat10 = Palette::category10();
    let highlight = bp.highlight.as_deref();
    let _ = layout; // reserved for future use

    // Resolve colors for every series
    let colors: Vec<String> = series
        .iter()
        .enumerate()
        .map(|(i, s)| s.color.clone().unwrap_or_else(|| cat10[i].to_string()))
        .collect();

    // Draw order: non-highlighted behind, highlighted on top
    let mut draw_order: Vec<usize> = (0..n).collect();
    if let Some(hl) = highlight {
        draw_order.sort_by_key(|&i| if series[i].name == hl { 1 } else { 0 });
    }

    for &si in &draw_order {
        let s = &series[si];
        let color = &colors[si];
        let is_highlighted = highlight.is_none_or(|hl| s.name == hl);
        let opacity = if highlight.is_some() && !is_highlighted {
            0.2
        } else {
            1.0
        };
        let sw = if is_highlighted && highlight.is_some() {
            bp.stroke_width * 1.6
        } else {
            bp.stroke_width
        };

        // ── Connecting curves ─────────────────────────────────────────────
        let mut prev: Option<(f64, f64)> = None;
        for t in 0..n_time {
            let rank_opt = s.ranks.get(t).and_then(|r| *r);
            let x_data = (t + 1) as f64;
            if let Some(r) = rank_opt {
                let y_data = n as f64 + 1.0 - r;
                let px = computed.map_x(x_data);
                let py = computed.map_y(y_data);
                if let Some((ppx, ppy)) = prev {
                    let path_d = match bp.curve_style {
                        CurveStyle::Sigmoid => {
                            let mx = (ppx + px) / 2.0;
                            format!("M {ppx:.2},{ppy:.2} C {mx:.2},{ppy:.2} {mx:.2},{py:.2} {px:.2},{py:.2}")
                        }
                        CurveStyle::Straight => {
                            format!("M {ppx:.2},{ppy:.2} L {px:.2},{py:.2}")
                        }
                    };
                    scene.add(Primitive::Path(Box::new(PathData {
                        d: path_d,
                        fill: None,
                        stroke: Color::from(color.as_str()),
                        stroke_width: sw,
                        opacity: Some(opacity),
                        stroke_dasharray: None,
                    })));
                }
                prev = Some((px, py));
            } else {
                prev = None; // gap in series
            }
        }

        // ── Dots ─────────────────────────────────────────────────────────
        for t in 0..n_time {
            let rank_opt = s.ranks.get(t).and_then(|r| *r);
            let x_data = (t + 1) as f64;
            if let Some(r) = rank_opt {
                let y_data = n as f64 + 1.0 - r;
                let px = computed.map_x(x_data);
                let py = computed.map_y(y_data);
                scene.add(Primitive::Circle {
                    cx: px,
                    cy: py,
                    r: bp.dot_radius,
                    fill: Color::from(color.as_str()),
                    fill_opacity: Some(opacity),
                    stroke: Some(Color::from("#ffffff")),
                    stroke_width: Some(bp.stroke_width * 0.5),
                });
                if bp.show_rank_labels {
                    let label = if (r - r.round()).abs() < f64::EPSILON * 10.0 {
                        format!("{}", r as i64)
                    } else {
                        format!("{:.1}", r)
                    };
                    let font_sz = ((bp.dot_radius * 1.1) as u32)
                        .max(7)
                        .min(computed.body_size);
                    scene.add(Primitive::Text {
                        x: px,
                        y: py + font_sz as f64 * 0.35,
                        content: label,
                        size: font_sz,
                        anchor: TextAnchor::Middle,
                        rotate: None,
                        bold: false,
                        color: Some(Color::from("#ffffff")),
                    });
                }
            }
        }
    }

    // ── Endpoint labels ────────────────────────────────────────────────────
    if bp.show_series_labels {
        let label_gap = bp.dot_radius + 5.0;
        let font_h = computed.body_size as f64;

        // Helper: build (series_idx, pixel_y) for a specific time index
        let positions_at = |t: usize| -> Vec<(usize, f64)> {
            let mut pos: Vec<(usize, f64)> = series
                .iter()
                .enumerate()
                .filter_map(|(si, s)| {
                    let r = s.ranks.get(t).and_then(|r| *r)?;
                    let y_data = n as f64 + 1.0 - r;
                    let py = computed.map_y(y_data);
                    Some((si, py))
                })
                .collect();
            pos.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
            nudge_bump_labels(&mut pos, font_h * 1.1);
            pos
        };

        // Left labels (time point 0)
        let left_px = computed.map_x(1.0);
        for (si, py) in positions_at(0) {
            let s = &series[si];
            let is_highlighted = highlight.is_none_or(|hl| s.name == hl);
            let color = if highlight.is_some() && !is_highlighted {
                "#bbbbbb".to_string()
            } else {
                colors[si].clone()
            };
            scene.add(Primitive::Text {
                x: left_px - label_gap,
                y: py + font_h * 0.35,
                content: s.name.clone(),
                size: computed.body_size,
                anchor: TextAnchor::End,
                rotate: None,
                bold: is_highlighted && highlight.is_some(),
                color: Some(Color::from(color.as_str())),
            });
        }

        // Right labels (last time point)
        let last_t = n_time.saturating_sub(1);
        let right_px = computed.map_x(n_time as f64);
        for (si, py) in positions_at(last_t) {
            let s = &series[si];
            let is_highlighted = highlight.is_none_or(|hl| s.name == hl);
            let color = if highlight.is_some() && !is_highlighted {
                "#bbbbbb".to_string()
            } else {
                colors[si].clone()
            };
            scene.add(Primitive::Text {
                x: right_px + label_gap,
                y: py + font_h * 0.35,
                content: s.name.clone(),
                size: computed.body_size,
                anchor: TextAnchor::Start,
                rotate: None,
                bold: is_highlighted && highlight.is_some(),
                color: Some(Color::from(color.as_str())),
            });
        }
    }
}

/// Render a single [`BumpPlot`] to a [`Scene`].
pub fn render_bump(bp: BumpPlot, layout: Layout) -> Scene {
    let plots = vec![Plot::Bump(bp)];
    render_multiple(plots, layout)
}

// ── FunnelPlot rendering ──────────────────────────────────────────────────────

/// Darken a hex color by `factor` (0.0 = black, 1.0 = original).
fn darken_hex(hex: &str, factor: f64) -> String {
    fn parse_comp(s: &str, start: usize) -> u8 {
        u8::from_str_radix(&s[start..start + 2], 16).unwrap_or(128)
    }
    let hex = hex.trim_start_matches('#');
    if hex.len() < 6 {
        return format!("#{}", hex);
    }
    let r = (parse_comp(hex, 0) as f64 * factor)
        .round()
        .clamp(0.0, 255.0) as u8;
    let g = (parse_comp(hex, 2) as f64 * factor)
        .round()
        .clamp(0.0, 255.0) as u8;
    let b = (parse_comp(hex, 4) as f64 * factor)
        .round()
        .clamp(0.0, 255.0) as u8;
    format!("#{:02X}{:02X}{:02X}", r, g, b)
}

fn resolve_stage_color(
    stage: &FunnelStage,
    idx: usize,
    n: usize,
    color_mode: &FunnelColorMode,
    base_color: &str,
) -> String {
    if let Some(ref c) = stage.color {
        return c.clone();
    }
    match color_mode {
        FunnelColorMode::Uniform => base_color.to_string(),
        FunnelColorMode::ByStage => {
            use crate::render::palette::Palette;
            Palette::category10()[idx % 10].to_string()
        }
        FunnelColorMode::Gradient => {
            let factor = if n <= 1 {
                1.0
            } else {
                1.0 - 0.55 * (idx as f64 / (n - 1) as f64)
            };
            darken_hex(base_color, factor)
        }
    }
}

/// Render a [`FunnelPlot`] onto the scene.  Must be in `skip_axes` list.
fn add_funnel(fp: &FunnelPlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::render::palette::Palette;

    if fp.stages.is_empty() {
        return;
    }

    let pw = computed.width - computed.margin_left - computed.margin_right;
    let ph = computed.height - computed.margin_top - computed.margin_bottom;
    if pw <= 0.0 || ph <= 0.0 {
        return;
    }

    let ox = computed.margin_left;
    let oy = computed.margin_top;

    let base_color = Palette::category10()[0].to_string();
    let is_vertical = matches!(fp.orientation, FunnelOrientation::Vertical);
    let is_mirror = fp.mirror.is_some();

    // ── Resolve max value across both sides ──────────────────────────────────
    let max_val = fp.max_value();
    if max_val <= f64::EPSILON {
        return;
    }

    let n = fp.stages.len();
    let font_size: u32 = 11;

    if is_vertical {
        // ── Vertical funnel ───────────────────────────────────────────────────
        let gap = fp.stage_gap;
        let total_gap = gap * (n.saturating_sub(1)) as f64;
        let bar_h = ((ph - total_gap) / n as f64).max(4.0);

        // Reserve horizontal space for stage label text so the widest bar's
        // left edge never collides with the left margin.
        let max_left_chars = fp.stages.iter().map(|s| s.label.len()).max().unwrap_or(0);
        let left_label_w = max_left_chars as f64 * 7.5 + 14.0;

        let (max_bar_w, center_x) = if is_mirror {
            // Mirror: reserve left (main labels) and right (mirror labels)
            let max_right_chars = fp
                .mirror
                .as_ref()
                .and_then(|m| m.iter().map(|s| s.label.len()).max())
                .unwrap_or(0);
            let right_label_w = max_right_chars as f64 * 7.5 + 14.0;
            let avail = (pw - left_label_w - right_label_w).max(40.0);
            let half = avail / 2.0 - 4.0;
            let cx = ox + left_label_w + avail / 2.0;
            (half, cx)
        } else {
            let avail = (pw - left_label_w).max(40.0);
            (avail, ox + left_label_w + avail / 2.0)
        };

        // Side labels for mirror mode
        if is_mirror {
            if let Some(ref ll) = fp.left_label {
                scene.add(Primitive::Text {
                    x: ox + pw / 4.0,
                    y: oy - 6.0,
                    content: ll.clone(),
                    size: font_size + 1,
                    anchor: TextAnchor::Middle,
                    rotate: None,
                    bold: true,
                    color: None,
                });
            }
            if let Some(ref rl) = fp.right_label {
                scene.add(Primitive::Text {
                    x: ox + 3.0 * pw / 4.0,
                    y: oy - 6.0,
                    content: rl.clone(),
                    size: font_size + 1,
                    anchor: TextAnchor::Middle,
                    rotate: None,
                    bold: true,
                    color: None,
                });
            }
        }

        // Draw center divider line for mirror mode
        if is_mirror {
            scene.add(Primitive::Line {
                x1: center_x,
                y1: oy,
                x2: center_x,
                y2: oy + ph,
                stroke: Color::from("#cccccc"),
                stroke_width: 1.0,
                stroke_dasharray: Some("4,3".to_string()),
            });
        }

        for (i, stage) in fp.stages.iter().enumerate() {
            let bar_y = oy + i as f64 * (bar_h + gap);
            let frac = stage.value / max_val;
            let half_w = frac * max_bar_w / 2.0;
            let color = resolve_stage_color(stage, i, n, &fp.color_mode, &base_color);

            // Left bar (or centered bar in standard mode)
            let (bar_x, bar_w) = if is_mirror {
                (center_x - half_w, half_w)
            } else {
                (center_x - frac * max_bar_w / 2.0, frac * max_bar_w)
            };

            scene.add(Primitive::Rect {
                x: bar_x,
                y: bar_y,
                width: bar_w,
                height: bar_h,
                fill: Color::from(color.as_str()),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });

            // Connector (trapezoid) between this bar and the next
            if fp.show_connectors && i + 1 < n {
                let next_frac = fp.stages[i + 1].value / max_val;
                let next_half_w = next_frac * max_bar_w / 2.0;
                let cy0 = bar_y + bar_h;
                let cy1 = bar_y + bar_h + gap;

                let (lx0, rx0, lx1, rx1) = if is_mirror {
                    (
                        center_x - half_w,
                        center_x,
                        center_x - next_half_w,
                        center_x,
                    )
                } else {
                    (
                        center_x - half_w,
                        center_x + half_w,
                        center_x - next_half_w,
                        center_x + next_half_w,
                    )
                };

                let d = format!(
                    "M {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} Z",
                    lx0, cy0, rx0, cy0, rx1, cy1, lx1, cy1
                );
                scene.add(Primitive::Path(Box::new(PathData {
                    d,
                    fill: Some(Color::from(color.as_str())),
                    stroke: Color::from("none"),
                    stroke_width: 0.0,
                    opacity: Some(fp.connector_opacity),
                    stroke_dasharray: None,
                })));

                // Conversion rate label in connector area
                if fp.show_conversion && gap >= 10.0 {
                    let rate = if stage.value > f64::EPSILON {
                        fp.stages[i + 1].value / stage.value * 100.0
                    } else {
                        0.0
                    };
                    let mid_y = cy0 + gap / 2.0;
                    let mid_x = if is_mirror {
                        center_x - (half_w + next_half_w) / 4.0
                    } else {
                        center_x
                    };
                    scene.add(Primitive::Text {
                        x: mid_x,
                        y: mid_y + 4.0,
                        content: format!("{:.1}%", rate),
                        size: font_size - 1,
                        anchor: TextAnchor::Middle,
                        rotate: None,
                        bold: false,
                        color: Some(Color::from("#555555")),
                    });
                }
            }

            // Value label
            if fp.show_values {
                let label = if fp.show_percents {
                    let pct = stage.value / fp.stages[0].value * 100.0;
                    format!("{:.0} ({:.1}%)", stage.value, pct)
                } else {
                    format!("{:.0}", stage.value)
                };

                // Place inside bar if wide enough, else to the right
                let text_fits = bar_w > 60.0 && bar_h > (font_size as f64 + 2.0);
                let (lx, anchor, color_text) = if text_fits {
                    (
                        bar_x + bar_w / 2.0,
                        TextAnchor::Middle,
                        Color::from("#ffffff"),
                    )
                } else {
                    let rx = if is_mirror {
                        bar_x
                    } else {
                        bar_x + bar_w + 4.0
                    };
                    let anc = if is_mirror {
                        TextAnchor::End
                    } else {
                        TextAnchor::Start
                    };
                    (rx, anc, Color::from("#333333"))
                };
                scene.add(Primitive::Text {
                    x: lx,
                    y: bar_y + bar_h / 2.0 + font_size as f64 * 0.35,
                    content: label,
                    size: font_size,
                    anchor,
                    rotate: None,
                    bold: false,
                    color: Some(color_text),
                });
            }

            // Stage label (always on left of bar for vertical, right edge if mirror left)
            {
                let (lx, anchor) = if is_mirror {
                    (center_x - max_bar_w / 2.0 - 6.0, TextAnchor::End)
                } else {
                    (bar_x - 6.0, TextAnchor::End)
                };
                scene.add(Primitive::Text {
                    x: lx,
                    y: bar_y + bar_h / 2.0 + font_size as f64 * 0.35,
                    content: stage.label.clone(),
                    size: font_size,
                    anchor,
                    rotate: None,
                    bold: false,
                    color: None,
                });
            }

            // Mirror side rendering
            if let Some(ref mirror_stages) = fp.mirror {
                if let Some(ms) = mirror_stages.get(i) {
                    let m_frac = ms.value / max_val;
                    let m_half_w = m_frac * max_bar_w / 2.0;
                    let m_color = resolve_stage_color(
                        ms,
                        i,
                        mirror_stages.len(),
                        &fp.color_mode,
                        &base_color,
                    );

                    scene.add(Primitive::Rect {
                        x: center_x,
                        y: bar_y,
                        width: m_half_w,
                        height: bar_h,
                        fill: Color::from(m_color.as_str()),
                        stroke: None,
                        stroke_width: None,
                        opacity: None,
                    });

                    // Mirror connector
                    if fp.show_connectors && i + 1 < mirror_stages.len() {
                        let next_m_frac = mirror_stages[i + 1].value / max_val;
                        let next_m_half_w = next_m_frac * max_bar_w / 2.0;
                        let cy0 = bar_y + bar_h;
                        let cy1 = bar_y + bar_h + gap;
                        let d = format!(
                            "M {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} Z",
                            center_x,
                            cy0,
                            center_x + m_half_w,
                            cy0,
                            center_x + next_m_half_w,
                            cy1,
                            center_x,
                            cy1,
                        );
                        scene.add(Primitive::Path(Box::new(PathData {
                            d,
                            fill: Some(Color::from(m_color.as_str())),
                            stroke: Color::from("none"),
                            stroke_width: 0.0,
                            opacity: Some(fp.connector_opacity),
                            stroke_dasharray: None,
                        })));

                        if fp.show_conversion && gap >= 10.0 {
                            let rate = if ms.value > f64::EPSILON {
                                mirror_stages[i + 1].value / ms.value * 100.0
                            } else {
                                0.0
                            };
                            let mid_y = cy0 + gap / 2.0;
                            let mid_x = center_x + (m_half_w + next_m_half_w) / 4.0;
                            scene.add(Primitive::Text {
                                x: mid_x,
                                y: mid_y + 4.0,
                                content: format!("{:.1}%", rate),
                                size: font_size - 1,
                                anchor: TextAnchor::Middle,
                                rotate: None,
                                bold: false,
                                color: Some(Color::from("#555555")),
                            });
                        }
                    }

                    // Mirror value label
                    if fp.show_values {
                        let label = if fp.show_percents {
                            let pct = if mirror_stages[0].value > f64::EPSILON {
                                ms.value / mirror_stages[0].value * 100.0
                            } else {
                                0.0
                            };
                            format!("{:.0} ({:.1}%)", ms.value, pct)
                        } else {
                            format!("{:.0}", ms.value)
                        };
                        let m_bar_w = m_half_w;
                        let text_fits = m_bar_w > 60.0 && bar_h > (font_size as f64 + 2.0);
                        let (lx, anchor, color_text) = if text_fits {
                            (
                                center_x + m_bar_w / 2.0,
                                TextAnchor::Middle,
                                Color::from("#ffffff"),
                            )
                        } else {
                            (
                                center_x + m_bar_w + 4.0,
                                TextAnchor::Start,
                                Color::from("#333333"),
                            )
                        };
                        scene.add(Primitive::Text {
                            x: lx,
                            y: bar_y + bar_h / 2.0 + font_size as f64 * 0.35,
                            content: label,
                            size: font_size,
                            anchor,
                            rotate: None,
                            bold: false,
                            color: Some(color_text),
                        });
                    }

                    // Mirror stage label (right side)
                    scene.add(Primitive::Text {
                        x: center_x + max_bar_w / 2.0 + 6.0,
                        y: bar_y + bar_h / 2.0 + font_size as f64 * 0.35,
                        content: ms.label.clone(),
                        size: font_size,
                        anchor: TextAnchor::Start,
                        rotate: None,
                        bold: false,
                        color: None,
                    });
                }
            }
        }
    } else {
        // ── Horizontal funnel ─────────────────────────────────────────────────
        let gap = fp.stage_gap;
        let total_gap = gap * (n.saturating_sub(1)) as f64;
        let bar_w = ((pw - total_gap) / n as f64).max(4.0);
        let max_bar_h = if is_mirror { ph / 2.0 - 4.0 } else { ph };
        let center_y = oy + ph / 2.0;

        // Side labels for mirror mode
        if is_mirror {
            if let Some(ref ll) = fp.left_label {
                scene.add(Primitive::Text {
                    x: ox - 8.0,
                    y: oy + ph / 4.0,
                    content: ll.clone(),
                    size: font_size + 1,
                    anchor: TextAnchor::End,
                    rotate: Some(-90.0),
                    bold: true,
                    color: None,
                });
            }
            if let Some(ref rl) = fp.right_label {
                scene.add(Primitive::Text {
                    x: ox - 8.0,
                    y: oy + 3.0 * ph / 4.0,
                    content: rl.clone(),
                    size: font_size + 1,
                    anchor: TextAnchor::End,
                    rotate: Some(-90.0),
                    bold: true,
                    color: None,
                });
            }
        }

        // Draw center divider line for mirror mode
        if is_mirror {
            scene.add(Primitive::Line {
                x1: ox,
                y1: center_y,
                x2: ox + pw,
                y2: center_y,
                stroke: Color::from("#cccccc"),
                stroke_width: 1.0,
                stroke_dasharray: Some("4,3".to_string()),
            });
        }

        for (i, stage) in fp.stages.iter().enumerate() {
            let bar_x = ox + i as f64 * (bar_w + gap);
            let frac = stage.value / max_val;
            let half_h = frac * max_bar_h / 2.0;
            let color = resolve_stage_color(stage, i, n, &fp.color_mode, &base_color);

            let (bar_y, actual_bar_h) = if is_mirror {
                (center_y - half_h, half_h)
            } else {
                (center_y - frac * max_bar_h / 2.0, frac * max_bar_h)
            };

            scene.add(Primitive::Rect {
                x: bar_x,
                y: bar_y,
                width: bar_w,
                height: actual_bar_h,
                fill: Color::from(color.as_str()),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });

            // Connector between adjacent bars
            if fp.show_connectors && i + 1 < n {
                let next_frac = fp.stages[i + 1].value / max_val;
                let next_half_h = next_frac * max_bar_h / 2.0;
                let cx0 = bar_x + bar_w;
                let cx1 = bar_x + bar_w + gap;

                let (ty0, by0, ty1, by1) = if is_mirror {
                    (
                        center_y - half_h,
                        center_y,
                        center_y - next_half_h,
                        center_y,
                    )
                } else {
                    (
                        center_y - half_h,
                        center_y + half_h,
                        center_y - next_half_h,
                        center_y + next_half_h,
                    )
                };

                let d = format!(
                    "M {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} Z",
                    cx0, ty0, cx0, by0, cx1, by1, cx1, ty1
                );
                scene.add(Primitive::Path(Box::new(PathData {
                    d,
                    fill: Some(Color::from(color.as_str())),
                    stroke: Color::from("none"),
                    stroke_width: 0.0,
                    opacity: Some(fp.connector_opacity),
                    stroke_dasharray: None,
                })));

                if fp.show_conversion && gap >= 10.0 {
                    let rate = if stage.value > f64::EPSILON {
                        fp.stages[i + 1].value / stage.value * 100.0
                    } else {
                        0.0
                    };
                    let mid_x = cx0 + gap / 2.0;
                    let mid_y = if is_mirror {
                        center_y - (half_h + next_half_h) / 4.0
                    } else {
                        center_y
                    };
                    scene.add(Primitive::Text {
                        x: mid_x,
                        y: mid_y + 4.0,
                        content: format!("{:.1}%", rate),
                        size: font_size - 1,
                        anchor: TextAnchor::Middle,
                        rotate: Some(-90.0),
                        bold: false,
                        color: Some(Color::from("#555555")),
                    });
                }
            }

            // Value label (horizontal: inside bar if tall enough, else above)
            if fp.show_values {
                let label = if fp.show_percents {
                    let pct = stage.value / fp.stages[0].value * 100.0;
                    format!("{:.0} ({:.1}%)", stage.value, pct)
                } else {
                    format!("{:.0}", stage.value)
                };
                let text_fits = actual_bar_h > 20.0 && bar_w > 30.0;
                let (lx, ly, anchor, color_text) = if text_fits {
                    (
                        bar_x + bar_w / 2.0,
                        bar_y + actual_bar_h / 2.0 + font_size as f64 * 0.35,
                        TextAnchor::Middle,
                        Color::from("#ffffff"),
                    )
                } else {
                    (
                        bar_x + bar_w / 2.0,
                        bar_y - 4.0,
                        TextAnchor::Middle,
                        Color::from("#333333"),
                    )
                };
                scene.add(Primitive::Text {
                    x: lx,
                    y: ly,
                    content: label,
                    size: font_size,
                    anchor,
                    rotate: None,
                    bold: false,
                    color: Some(color_text),
                });
            }

            // Stage label below bar
            scene.add(Primitive::Text {
                x: bar_x + bar_w / 2.0,
                y: oy + ph + 14.0,
                content: stage.label.clone(),
                size: font_size,
                anchor: TextAnchor::Middle,
                rotate: None,
                bold: false,
                color: None,
            });

            // Mirror side
            if let Some(ref mirror_stages) = fp.mirror {
                if let Some(ms) = mirror_stages.get(i) {
                    let m_frac = ms.value / max_val;
                    let m_half_h = m_frac * max_bar_h / 2.0;
                    let m_color = resolve_stage_color(
                        ms,
                        i,
                        mirror_stages.len(),
                        &fp.color_mode,
                        &base_color,
                    );

                    scene.add(Primitive::Rect {
                        x: bar_x,
                        y: center_y,
                        width: bar_w,
                        height: m_half_h,
                        fill: Color::from(m_color.as_str()),
                        stroke: None,
                        stroke_width: None,
                        opacity: None,
                    });

                    if fp.show_connectors && i + 1 < mirror_stages.len() {
                        let next_m_frac = mirror_stages[i + 1].value / max_val;
                        let next_m_half_h = next_m_frac * max_bar_h / 2.0;
                        let cx0 = bar_x + bar_w;
                        let cx1 = bar_x + bar_w + gap;
                        let d = format!(
                            "M {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} L {:.2},{:.2} Z",
                            cx0,
                            center_y,
                            cx0,
                            center_y + m_half_h,
                            cx1,
                            center_y + next_m_half_h,
                            cx1,
                            center_y,
                        );
                        scene.add(Primitive::Path(Box::new(PathData {
                            d,
                            fill: Some(Color::from(m_color.as_str())),
                            stroke: Color::from("none"),
                            stroke_width: 0.0,
                            opacity: Some(fp.connector_opacity),
                            stroke_dasharray: None,
                        })));
                    }

                    if fp.show_values {
                        let label = if fp.show_percents {
                            let pct = if mirror_stages[0].value > f64::EPSILON {
                                ms.value / mirror_stages[0].value * 100.0
                            } else {
                                0.0
                            };
                            format!("{:.0} ({:.1}%)", ms.value, pct)
                        } else {
                            format!("{:.0}", ms.value)
                        };
                        let text_fits = m_half_h > 20.0 && bar_w > 30.0;
                        let (lx, ly, anchor, color_text) = if text_fits {
                            (
                                bar_x + bar_w / 2.0,
                                center_y + m_half_h / 2.0 + font_size as f64 * 0.35,
                                TextAnchor::Middle,
                                Color::from("#ffffff"),
                            )
                        } else {
                            (
                                bar_x + bar_w / 2.0,
                                center_y + m_half_h + 14.0,
                                TextAnchor::Middle,
                                Color::from("#333333"),
                            )
                        };
                        scene.add(Primitive::Text {
                            x: lx,
                            y: ly,
                            content: label,
                            size: font_size,
                            anchor,
                            rotate: None,
                            bold: false,
                            color: Some(color_text),
                        });
                    }
                }
            }
        }
    }
}

/// Render a single [`FunnelPlot`] to a [`Scene`].
pub fn render_funnel(fp: FunnelPlot, layout: Layout) -> Scene {
    let plots = vec![Plot::Funnel(fp)];
    render_multiple(plots, layout)
}

// ─────────────────────────────────────────────────────────────────────────────
// Rose plot helpers
// ─────────────────────────────────────────────────────────────────────────────

/// Compass angle (0 = north, clockwise) → SVG pixel (x, y).
fn compass_xy(cx: f64, cy: f64, r: f64, deg: f64) -> (f64, f64) {
    let rad = deg.to_radians();
    (cx + r * rad.sin(), cy - r * rad.cos())
}

/// Encode a cumulative value as a pixel radius, respecting the inner hole and
/// the encoding mode.
///
/// * `Area`   — `r = sqrt(base² + frac*(max²-base²))`
/// * `Radius` — `r = base + frac*(max_r-base)`
fn rose_r(cum: f64, max_val: f64, max_r: f64, base_r: f64, enc: &RoseEncoding) -> f64 {
    if max_val <= f64::EPSILON || cum <= 0.0 {
        return base_r;
    }
    let frac = (cum / max_val).clamp(0.0, 1.0);
    match enc {
        RoseEncoding::Area => (base_r * base_r + frac * (max_r * max_r - base_r * base_r)).sqrt(),
        RoseEncoding::Radius => base_r + frac * (max_r - base_r),
    }
}

/// SVG path string for an annular sector (wedge).
///
/// `a1`/`a2` are compass degrees (clockwise from north).
/// When `r_inner < 0.5`, draws a pie slice from the centre.
fn rose_wedge(cx: f64, cy: f64, r_inner: f64, r_outer: f64, a1: f64, a2: f64, cw: bool) -> String {
    let (ox1, oy1) = compass_xy(cx, cy, r_outer, a1);
    let (ox2, oy2) = compass_xy(cx, cy, r_outer, a2);
    let mut span = if cw { a2 - a1 } else { a1 - a2 };
    while span < 0.0 {
        span += 360.0;
    }
    while span >= 360.0 {
        span -= 360.0;
    }
    let la = if span > 180.0 { 1 } else { 0 };
    let s_out = if cw { 1 } else { 0 };
    let s_in = 1 - s_out;
    if r_inner < 0.5 {
        format!(
            "M {cx:.2},{cy:.2} L {ox1:.2},{oy1:.2} \
             A {r_outer:.2},{r_outer:.2} 0 {la},{s_out} {ox2:.2},{oy2:.2} Z"
        )
    } else {
        let (ix1, iy1) = compass_xy(cx, cy, r_inner, a1);
        let (ix2, iy2) = compass_xy(cx, cy, r_inner, a2);
        format!(
            "M {ox1:.2},{oy1:.2} \
             A {r_outer:.2},{r_outer:.2} 0 {la},{s_out} {ox2:.2},{oy2:.2} \
             L {ix2:.2},{iy2:.2} \
             A {r_inner:.2},{r_inner:.2} 0 {la},{s_in} {ix1:.2},{iy1:.2} Z"
        )
    }
}

/// Edge angles (a1, a2) for sector `idx` in stacked/single mode.
fn rose_sector_angles(idx: usize, n: usize, start: f64, cw: bool, gap: f64) -> (f64, f64) {
    let sd = 360.0 / n as f64;
    let d = if cw { 1.0 } else { -1.0 };
    (
        start + d * (idx as f64 * sd + gap / 2.0),
        start + d * ((idx + 1) as f64 * sd - gap / 2.0),
    )
}

/// Centre angle of sector `idx`.
fn rose_center_angle(idx: usize, n: usize, start: f64, cw: bool) -> f64 {
    let sd = 360.0 / n as f64;
    let d = if cw { 1.0 } else { -1.0 };
    start + d * (idx as f64 + 0.5) * sd
}

/// Edge angles for sub-wedge `ji` within sector `si` in grouped mode.
fn rose_sub_angles(
    si: usize,
    ji: usize,
    n: usize,
    ns: usize,
    start: f64,
    cw: bool,
    gap: f64,
) -> (f64, f64) {
    let sd = 360.0 / n as f64;
    let d = if cw { 1.0 } else { -1.0 };
    let usable = sd - gap;
    let sub_d = usable / ns as f64;
    let sub_gap = (sub_d * 0.08).clamp(0.3_f64, 1.5_f64);
    let sector_start = start + d * (si as f64 * sd + gap / 2.0);
    (
        sector_start + d * (ji as f64 * sub_d + sub_gap / 2.0),
        sector_start + d * ((ji + 1) as f64 * sub_d - sub_gap / 2.0),
    )
}

/// Format a grid ring value compactly.
fn rose_fmt(v: f64) -> String {
    if v <= 0.0 {
        return "0".to_string();
    }
    if v >= 1_000_000.0 {
        return format!("{:.1}M", v / 1_000_000.0);
    }
    if v >= 1_000.0 {
        return format!("{:.1}k", v / 1_000.0);
    }
    if v == v.floor() {
        return format!("{:.0}", v);
    }
    if v < 10.0 {
        return format!("{:.1}", v);
    }
    format!("{:.0}", v)
}

/// Render a [`RosePlot`] onto the scene. Pixel-space; must be in skip_axes list.
fn add_rose(rp: &RosePlot, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::render::palette::Palette;

    let pw = computed.width - computed.margin_left - computed.margin_right;
    let ph = computed.height - computed.margin_top - computed.margin_bottom;
    if pw <= 0.0 || ph <= 0.0 {
        return;
    }

    let cx = computed.margin_left + pw / 2.0;
    let cy = computed.margin_top + ph / 2.0;

    let n = rp.n_sectors();
    if n == 0 {
        return;
    }

    let label_margin = if rp.show_labels { 34.0 } else { 8.0 };
    let max_r = (pw.min(ph) / 2.0 - label_margin).max(10.0);
    let base_r = rp.inner_radius * max_r;
    let max_total = rp.max_total();
    let cat10 = Palette::category10();

    // ── Grid rings ───────────────────────────────────────────────────────────
    if rp.show_grid && rp.grid_lines > 0 {
        for k in 1..=rp.grid_lines {
            let gr = max_r * k as f64 / rp.grid_lines as f64;
            let d = format!(
                "M {:.2},{:.2} A {gr:.2},{gr:.2} 0 1,0 {:.2},{:.2} \
                 A {gr:.2},{gr:.2} 0 1,0 {:.2},{:.2} Z",
                cx - gr,
                cy,
                cx + gr,
                cy,
                cx - gr,
                cy
            );
            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: None,
                stroke: Color::from("#cccccc"),
                stroke_width: 0.7,
                opacity: None,
                stroke_dasharray: Some("3,3".to_string()),
            })));

            if max_total > f64::EPSILON {
                let ring_val = match rp.encoding {
                    RoseEncoding::Area => {
                        let denom = max_r * max_r - base_r * base_r;
                        if denom > f64::EPSILON {
                            (gr * gr - base_r * base_r).max(0.0) / denom * max_total
                        } else {
                            0.0
                        }
                    }
                    RoseEncoding::Radius => {
                        let denom = max_r - base_r;
                        if denom > f64::EPSILON {
                            (gr - base_r).max(0.0) / denom * max_total
                        } else {
                            0.0
                        }
                    }
                };
                let (lx, ly) = compass_xy(cx, cy, gr, rp.start_angle);
                scene.add(Primitive::Text {
                    x: lx + 3.0,
                    y: ly - 2.0,
                    content: rose_fmt(ring_val),
                    size: 8,
                    anchor: TextAnchor::Start,
                    rotate: None,
                    bold: false,
                    color: Some(Color::from("#aaaaaa")),
                });
            }
        }
    }

    // ── Spokes ───────────────────────────────────────────────────────────────
    if rp.show_spokes {
        for i in 0..n {
            let a = rose_center_angle(i, n, rp.start_angle, rp.clockwise);
            let (sx, sy) = compass_xy(cx, cy, max_r, a);
            let inner_r = if base_r > 0.5 { base_r } else { 0.0 };
            let (bx, by) = compass_xy(cx, cy, inner_r, a);
            scene.add(Primitive::Line {
                x1: bx,
                y1: by,
                x2: sx,
                y2: sy,
                stroke: Color::from("#dddddd"),
                stroke_width: 0.5,
                stroke_dasharray: None,
            });
        }
    }

    // ── Wedges ───────────────────────────────────────────────────────────────
    if max_total > f64::EPSILON {
        match &rp.mode {
            RoseMode::Stacked => {
                for i in 0..n {
                    let (a1, a2) = rose_sector_angles(i, n, rp.start_angle, rp.clockwise, rp.gap);
                    let mut cum = 0.0_f64;
                    let mut last_r_inn = base_r;
                    for (j, series) in rp.series.iter().enumerate() {
                        let val = series.values.get(i).copied().unwrap_or(0.0).max(0.0);
                        let r_inn = rose_r(cum, max_total, max_r, base_r, &rp.encoding);
                        cum += val;
                        let r_out = rose_r(cum, max_total, max_r, base_r, &rp.encoding);
                        if r_out <= r_inn + 0.5 {
                            continue;
                        }
                        last_r_inn = r_inn;
                        let color = series
                            .color
                            .clone()
                            .unwrap_or_else(|| cat10[j % 10].to_string());
                        let d = rose_wedge(cx, cy, r_inn, r_out, a1, a2, rp.clockwise);
                        scene.add(Primitive::Path(Box::new(PathData {
                            d,
                            fill: Some(Color::from(color.as_str())),
                            stroke: Color::from("#ffffff"),
                            stroke_width: 0.5,
                            opacity: Some(0.75),
                            stroke_dasharray: None,
                        })));
                    }
                    if rp.show_values && cum > f64::EPSILON {
                        let r_out_tip = rose_r(cum, max_total, max_r, base_r, &rp.encoding);
                        let ac = rose_center_angle(i, n, rp.start_angle, rp.clockwise);
                        let (lx, ly) = if rp.show_labels && r_out_tip - last_r_inn > 16.0 {
                            // Place inside the wedge tip to avoid colliding with sector labels
                            compass_xy(cx, cy, r_out_tip - 8.0, ac)
                        } else {
                            compass_xy(cx, cy, r_out_tip + 8.0, ac)
                        };
                        scene.add(Primitive::Text {
                            x: lx,
                            y: ly + 4.0,
                            content: rose_fmt(cum),
                            size: 9,
                            anchor: TextAnchor::Middle,
                            rotate: None,
                            bold: false,
                            color: None,
                        });
                    }
                }
            }
            RoseMode::Grouped => {
                let ns = rp.series.len();
                if ns == 0 {
                    return;
                }
                for i in 0..n {
                    for (j, series) in rp.series.iter().enumerate() {
                        let val = series.values.get(i).copied().unwrap_or(0.0).max(0.0);
                        let r_out = rose_r(val, max_total, max_r, base_r, &rp.encoding);
                        if r_out <= base_r + 0.5 {
                            continue;
                        }
                        let (a1, a2) =
                            rose_sub_angles(i, j, n, ns, rp.start_angle, rp.clockwise, rp.gap);
                        let color = series
                            .color
                            .clone()
                            .unwrap_or_else(|| cat10[j % 10].to_string());
                        let d = rose_wedge(cx, cy, base_r, r_out, a1, a2, rp.clockwise);
                        scene.add(Primitive::Path(Box::new(PathData {
                            d,
                            fill: Some(Color::from(color.as_str())),
                            stroke: Color::from("#ffffff"),
                            stroke_width: 0.5,
                            opacity: Some(0.75),
                            stroke_dasharray: None,
                        })));
                    }
                    if rp.show_values {
                        let max_val = rp
                            .series
                            .iter()
                            .map(|s| s.values.get(i).copied().unwrap_or(0.0))
                            .fold(0.0_f64, f64::max);
                        if max_val > f64::EPSILON {
                            let r_out_tip = rose_r(max_val, max_total, max_r, base_r, &rp.encoding);
                            let ac = rose_center_angle(i, n, rp.start_angle, rp.clockwise);
                            let (lx, ly) = if rp.show_labels && r_out_tip - base_r > 16.0 {
                                compass_xy(cx, cy, r_out_tip - 8.0, ac)
                            } else {
                                compass_xy(cx, cy, r_out_tip + 8.0, ac)
                            };
                            scene.add(Primitive::Text {
                                x: lx,
                                y: ly + 4.0,
                                content: rose_fmt(max_val),
                                size: 9,
                                anchor: TextAnchor::Middle,
                                rotate: None,
                                bold: false,
                                color: None,
                            });
                        }
                    }
                }
            }
        }
    }

    // ── Outer ring border ────────────────────────────────────────────────────
    {
        let d = format!(
            "M {:.2},{:.2} A {max_r:.2},{max_r:.2} 0 1,0 {:.2},{:.2} \
             A {max_r:.2},{max_r:.2} 0 1,0 {:.2},{:.2} Z",
            cx - max_r,
            cy,
            cx + max_r,
            cy,
            cx - max_r,
            cy
        );
        scene.add(Primitive::Path(Box::new(PathData {
            d,
            fill: None,
            stroke: Color::from("#aaaaaa"),
            stroke_width: 0.8,
            opacity: None,
            stroke_dasharray: None,
        })));
    }

    // ── Inner hole border (donut) ────────────────────────────────────────────
    if base_r > 0.5 {
        let d = format!(
            "M {:.2},{:.2} A {base_r:.2},{base_r:.2} 0 1,0 {:.2},{:.2} \
             A {base_r:.2},{base_r:.2} 0 1,0 {:.2},{:.2} Z",
            cx - base_r,
            cy,
            cx + base_r,
            cy,
            cx - base_r,
            cy
        );
        scene.add(Primitive::Path(Box::new(PathData {
            d,
            fill: None,
            stroke: Color::from("#aaaaaa"),
            stroke_width: 0.8,
            opacity: None,
            stroke_dasharray: None,
        })));
    }

    // ── Sector labels ────────────────────────────────────────────────────────
    if rp.show_labels {
        for i in 0..n {
            let label = rp
                .labels
                .get(i)
                .cloned()
                .unwrap_or_else(|| (i + 1).to_string());
            let ac = rose_center_angle(i, n, rp.start_angle, rp.clockwise);
            let (lx, ly) = compass_xy(cx, cy, max_r + 16.0, ac);
            scene.add(Primitive::Text {
                x: lx,
                y: ly + 4.0,
                content: label,
                size: 11,
                anchor: TextAnchor::Middle,
                rotate: None,
                bold: false,
                color: None,
            });
        }
    }
}

/// Render a single [`RosePlot`] to a [`Scene`].
pub fn render_rose(rp: RosePlot, layout: Layout) -> Scene {
    let plots = vec![Plot::Rose(rp)];
    render_multiple(plots, layout)
}

// ── CalendarPlot renderer ─────────────────────────────────────────────────────

const CALENDAR_TIP_JS: &str = r#"(function(){
  var svg=document.currentScript?document.currentScript.closest('svg'):document.querySelector('svg');
  if(!svg)return;
  var tip=document.createElementNS('http://www.w3.org/2000/svg','g');
  tip.setAttribute('id','cal-tip');
  tip.setAttribute('pointer-events','none');
  tip.setAttribute('style','display:none');
  var bg=document.createElementNS('http://www.w3.org/2000/svg','rect');
  bg.setAttribute('rx','4');
  bg.setAttribute('fill','#1a1a1a');
  bg.setAttribute('fill-opacity','0.88');
  tip.appendChild(bg);
  var lines=[];
  for(var i=0;i<3;i++){
    var t=document.createElementNS('http://www.w3.org/2000/svg','text');
    t.setAttribute('fill','white');
    t.setAttribute('font-size','11');
    t.setAttribute('font-family','sans-serif');
    tip.appendChild(t);
    lines.push(t);
  }
  svg.appendChild(tip);
  function showTip(e,el){
    var date=el.getAttribute('data-date')||'';
    var val=el.getAttribute('data-val')||'';
    var agg=el.getAttribute('data-agg')||'';
    var svgRect=svg.getBoundingClientRect();
    var vbw=svg.viewBox&&svg.viewBox.baseVal.width?svg.viewBox.baseVal.width:svgRect.width;
    var vbh=svg.viewBox&&svg.viewBox.baseVal.height?svg.viewBox.baseVal.height:svgRect.height;
    var sx=vbw/svgRect.width;
    var sy=vbh/svgRect.height;
    var px=(e.clientX-svgRect.left)*sx+12;
    var py=(e.clientY-svgRect.top)*sy-8;
    var ls=[date,val];
    if(agg)ls.push(agg);
    var maxLen=0;
    ls.forEach(function(l){if(l.length>maxLen)maxLen=l.length;});
    var lh=16,pad=8;
    var w=Math.max(maxLen*6.3+pad*2,80);
    var h=ls.length*lh+pad*2;
    if(px+w>vbw-5)px=px-w-24;
    if(py-h<5)py=py+h+10;
    bg.setAttribute('x',px);bg.setAttribute('y',py-h);
    bg.setAttribute('width',w);bg.setAttribute('height',h);
    ls.forEach(function(line,i){
      var t=lines[i];
      t.setAttribute('x',px+pad);
      t.setAttribute('y',py-h+pad+lh*(i+0.75));
      t.textContent=line;
      t.setAttribute('font-weight',i===0?'600':'normal');
      t.setAttribute('style','');
    });
    for(var i=ls.length;i<lines.length;i++){lines[i].textContent='';}
    tip.setAttribute('style','display:block');
  }
  function hideTip(){tip.setAttribute('style','display:none');}
  var days=svg.querySelectorAll('.cal-day');
  days.forEach(function(el){
    el.addEventListener('mouseover',function(e){showTip(e,el);});
    el.addEventListener('mousemove',function(e){showTip(e,el);});
    el.addEventListener('mouseout',hideTip);
  });
})();"#;

fn add_calendar(cp: &CalendarPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let agg_data = cp.aggregate();
    let periods = cp.detect_periods();
    if periods.is_empty() {
        return;
    }

    let sunday_start = matches!(cp.week_start, WeekStart::Sunday);
    // Reserve left margin wide enough for the longest period label (or day-of-week labels).
    let max_label_len = periods
        .iter()
        .map(|(l, _, _)| l.chars().count())
        .max()
        .unwrap_or(4);
    let day_label_w: f64 = if cp.show_day_labels {
        (max_label_len as f64 * 7.5).ceil().max(28.0)
    } else {
        (max_label_len as f64 * 7.5).ceil().max(32.0)
    };
    let month_label_h: f64 = if cp.show_month_labels { 16.0 } else { 0.0 };
    let period_label_h: f64 = 16.0;
    let period_gap = 14.0;
    let np = periods.len() as f64;
    let legend_h = if cp.show_legend { 50.0 } else { 0.0 };

    // Maximum columns across all periods (so all rows have the same width)
    let max_cols: u32 = periods
        .iter()
        .map(|(_, start, end)| {
            let sdow = if sunday_start {
                (dow_mon0(start.0, start.1, start.2) + 1) % 7 // sun0 from mon0
            } else {
                dow_mon0(start.0, start.1, start.2)
            };
            period_max_cols(*start, *end, sdow)
        })
        .max()
        .unwrap_or(53)
        .max(1);

    // Scale cell_size down if the natural size would overflow the canvas so the
    // calendar fits its cell the same way every other plot scales to its bounds.
    let margin = 16.0; // 8 px each side
    let avail_w = (computed.width - day_label_w - margin).max(1.0);
    let avail_h_per_period = ((computed.height
        - np * (period_label_h + month_label_h)
        - (np - 1.0) * period_gap
        - legend_h
        - margin)
        / np)
        .max(1.0);
    let max_pitch_w = avail_w / max_cols as f64;
    let max_pitch_h = avail_h_per_period / 7.0;
    let effective_cell_size = cp
        .cell_size
        .min((max_pitch_w.min(max_pitch_h) - cp.cell_gap).max(1.0));

    let pitch = effective_cell_size + cp.cell_gap;
    let grid_h = 7.0 * pitch;

    // Compute value range for color mapping.
    // Always floor at 0: calendar values are non-negative activity counts, and
    // 0 already has a distinct color (missing_color / zero_color).  Flooring at
    // the data minimum would suppress any value below it (e.g. a count of 1
    // when the data minimum is 2 would clamp to the same color as 2).
    // Users can override with `with_value_range()`.
    let (v_min, v_max) = if let Some(r) = cp.value_range {
        r
    } else {
        let mut mx = f64::NEG_INFINITY;
        for &v in agg_data.values() {
            mx = mx.max(v);
        }
        if !mx.is_finite() {
            mx = 1.0;
        }
        (0.0, mx)
    };
    let v_range = (v_max - v_min).max(f64::EPSILON);

    let grid_w = max_cols as f64 * pitch;
    let total_content_w = day_label_w + grid_w;
    let total_content_h =
        np * (period_label_h + month_label_h + grid_h) + (np - 1.0) * period_gap + legend_h;

    // Centre within the canvas
    let ox = ((computed.width - total_content_w) / 2.0).max(8.0);
    let oy = ((computed.height - total_content_h) / 2.0).max(8.0);
    let grid_x = ox + day_label_w; // pixel x of column 0

    let month_abbr = [
        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
    ];
    let dow_labels_mon = ["Mon", "", "Wed", "", "Fri", "", "Sun"];
    let dow_labels_sun = ["Sun", "", "Tue", "", "Thu", "", "Sat"];
    let dow_labels: &[&str] = if sunday_start {
        &dow_labels_sun
    } else {
        &dow_labels_mon
    };
    let text_color = computed.theme.text_color.as_str();
    let sep_color = "#c0c0c0";

    for (pi, (label, period_start, period_end)) in periods.iter().enumerate() {
        let period_top = oy + pi as f64 * (period_label_h + month_label_h + grid_h + period_gap);
        let grid_y = period_top + period_label_h + month_label_h;

        // day-of-week of the period's first day (row index in the chosen coordinate system)
        let start_dow: u32 = if sunday_start {
            // sun0: 0=Sun..6=Sat; reuse mon0 and convert
            (dow_mon0(period_start.0, period_start.1, period_start.2) + 1) % 7
        } else {
            dow_mon0(period_start.0, period_start.1, period_start.2)
        };

        // ── Period label (year / "FY2023/24" / …) ────────────────────────────
        scene.add(Primitive::Text {
            x: grid_x - 4.0,
            y: period_top + period_label_h * 0.78,
            content: label.clone(),
            size: 11,
            anchor: TextAnchor::End,
            rotate: None,
            bold: true,
            color: None,
        });

        // ── Day-of-week labels ────────────────────────────────────────────────
        if cp.show_day_labels {
            for (ri, &lbl) in dow_labels.iter().enumerate() {
                if lbl.is_empty() {
                    continue;
                }
                scene.add(Primitive::Text {
                    x: grid_x - 4.0,
                    y: grid_y + ri as f64 * pitch + pitch * 0.75,
                    content: lbl.to_string(),
                    size: 9,
                    anchor: TextAnchor::End,
                    rotate: None,
                    bold: false,
                    color: Some(Color::from(text_color)),
                });
            }
        }

        // ── Iterate every day in the period ───────────────────────────────────
        // (month_label, col, row) for the first occurrence of each "YYYY-MM"
        let mut month_entries: Vec<(String, u32, u32)> = Vec::new();
        let mut seen_months: std::collections::HashSet<String> = std::collections::HashSet::new();

        let start_jd = to_jd(period_start.0, period_start.1, period_start.2);
        let end_jd = to_jd(period_end.0, period_end.1, period_end.2);

        for jd in start_jd..=end_jd {
            let (y, m, d) = from_jd(jd);
            let date_triple = (y, m, d);
            let (col, row) = period_grid_pos(date_triple, *period_start, start_dow);
            if col >= max_cols {
                continue;
            } // safety cap

            // Track first occurrence of each month (for labels + separators)
            let month_key = format!("{y}-{m:02}");
            if seen_months.insert(month_key) {
                month_entries.push((month_abbr[(m - 1) as usize].to_string(), col, row));
            }

            let date_str = format!("{y}-{m:02}-{d:02}");
            let px = grid_x + col as f64 * pitch;
            let py = grid_y + row as f64 * pitch;

            let (fill_color, tip_val) = if let Some(&v) = agg_data.get(&date_str) {
                let fill = if v == 0.0 {
                    cp.zero_color
                        .as_deref()
                        .unwrap_or(&cp.missing_color)
                        .to_string()
                } else {
                    let norm = ((v - v_min) / v_range).clamp(0.0, 1.0);
                    cp.color_map.map(norm)
                };
                (fill, format_val(v, &cp.aggregation))
            } else {
                (cp.missing_color.clone(), "no data".to_string())
            };

            let extra = format!(r#"class="cal-day" data-date="{date_str}" data-val="{tip_val}""#);
            scene.add(Primitive::GroupStart {
                transform: None,
                title: None,
                extra_attrs: Some(extra),
            });
            scene.add(Primitive::Rect {
                x: px,
                y: py,
                width: cp.cell_size,
                height: cp.cell_size,
                fill: Color::from(fill_color),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });
            scene.add(Primitive::GroupEnd);
        }

        // ── Month labels ──────────────────────────────────────────────────────
        if cp.show_month_labels {
            for (abbr, col, _) in &month_entries {
                scene.add(Primitive::Text {
                    x: grid_x + *col as f64 * pitch,
                    y: grid_y - 4.0,
                    content: abbr.clone(),
                    size: 9,
                    anchor: TextAnchor::Start,
                    rotate: None,
                    bold: false,
                    color: Some(Color::from(text_color)),
                });
            }
        }

        // ── Month separator stepped paths (skip the first month) ──────────────
        for (_, col, row) in month_entries.iter().skip(1) {
            let col = *col;
            let row = *row;
            // sep_near = left edge of new month's first column
            // sep_far  = right edge of new month's first column
            // When row > 0 the new month starts mid-column: the previous month's
            // last day (e.g. Nov 30) occupies rows 0..row-1 of this column, so
            // the boundary traces right→down→left→down (step goes LEFT).
            let sep_near = grid_x + col as f64 * pitch;
            let sep_far = sep_near + pitch;
            let top_y = grid_y;
            let mid_y = grid_y + row as f64 * pitch;
            let bot_y = grid_y + 7.0 * pitch;
            let d = if row == 0 {
                // Starts on the first weekday of the column → straight vertical line
                format!("M {sep_near} {top_y} L {sep_near} {bot_y}")
            } else {
                // Step goes RIGHT→DOWN→LEFT→DOWN:
                // top section at sep_far (right edge, bordering prev-month's last day)
                // bottom section at sep_near (left edge, bordering full prev-month columns)
                format!("M {sep_far} {top_y} L {sep_far} {mid_y} L {sep_near} {mid_y} L {sep_near} {bot_y}")
            };
            scene.add(Primitive::Path(Box::new(PathData {
                d,
                fill: None,
                stroke: Color::from(sep_color),
                stroke_width: 1.0,
                opacity: None,
                stroke_dasharray: None,
            })));
        }
    }

    // ── Inline color legend ───────────────────────────────────────────────────
    if cp.show_legend {
        let legend_y =
            oy + np * (period_label_h + month_label_h + grid_h) + (np - 1.0) * period_gap + 10.0;
        let bar_w = grid_w.min(160.0);
        let bar_h = 10.0;
        let bar_x = grid_x + (grid_w - bar_w) / 2.0;
        let n_stops = 40usize;
        let rw = bar_w / n_stops as f64;
        for i in 0..n_stops {
            let t = i as f64 / (n_stops - 1) as f64;
            let color = cp.color_map.map(t);
            let rx = bar_x + (i as f64 * rw).floor();
            scene.add(Primitive::Rect {
                x: rx,
                y: legend_y,
                width: rw.ceil(),
                height: bar_h,
                fill: Color::from(color),
                stroke: None,
                stroke_width: None,
                opacity: None,
            });
        }
        scene.add(Primitive::Text {
            x: bar_x,
            y: legend_y + bar_h + 11.0,
            content: format_val_short(v_min),
            size: 9,
            anchor: TextAnchor::Start,
            rotate: None,
            bold: false,
            color: None,
        });
        scene.add(Primitive::Text {
            x: bar_x + bar_w,
            y: legend_y + bar_h + 11.0,
            content: format_val_short(v_max),
            size: 9,
            anchor: TextAnchor::End,
            rotate: None,
            bold: false,
            color: None,
        });
        if let Some(ref lbl) = cp.legend_label {
            scene.add(Primitive::Text {
                x: bar_x + bar_w / 2.0,
                y: legend_y + bar_h + 24.0,
                content: lbl.clone(),
                size: 10,
                anchor: TextAnchor::Middle,
                rotate: None,
                bold: false,
                color: None,
            });
        }
    }

    if !scene.scripts.iter().any(|s| s.contains("cal-tip")) {
        scene.scripts.push(CALENDAR_TIP_JS.to_string());
    }
}

fn format_val(v: f64, agg: &CalendarAgg) -> String {
    match agg {
        CalendarAgg::Count => format!("{} event{}", v as u64, if v as u64 == 1 { "" } else { "s" }),
        _ => format_val_short(v),
    }
}

fn format_val_short(v: f64) -> String {
    if v.fract() == 0.0 && v.abs() < 1e9 {
        format!("{}", v as i64)
    } else if v.abs() < 0.01 || v.abs() >= 1e5 {
        format!("{:.2e}", v)
    } else {
        format!("{:.2}", v)
    }
}

/// Render a single [`CalendarPlot`] to a [`Scene`].
pub fn render_calendar(cp: CalendarPlot, layout: Layout) -> Scene {
    let periods = cp.detect_periods();
    let (nat_w, nat_h) = cp.natural_size_for_periods(&periods);
    let layout = if layout.width.is_none() && layout.height.is_none() {
        layout.with_width(nat_w).with_height(nat_h)
    } else {
        layout
    };
    let plots = vec![Plot::Calendar(cp)];
    render_multiple(plots, layout)
}

// ─────────────────────────────────────────────────────────────────────────────
//  PopulationPyramid
// ─────────────────────────────────────────────────────────────────────────────

/// Render a [`PopulationPyramid`] onto the scene.
///
/// Uses the standard axis system: y-axis is categorical (age groups), x-axis is
/// symmetric around 0 with absolute-value tick labels.
fn add_pyramid(pp: &PopulationPyramid, scene: &mut Scene, computed: &ComputedLayout) {
    use crate::render::palette::Palette;

    if pp.series.is_empty() {
        return;
    }
    let n_groups = pp.n_groups();
    if n_groups == 0 {
        return;
    }

    let n_series = pp.series.len();
    let cat10 = Palette::category10();

    // Normalisation denominator: when normalize=true, values are expressed as
    // percent of total population. We divide raw values by (total / 100).
    let denom = if pp.normalize {
        pp.total_population().max(1e-10) / 100.0
    } else {
        1.0
    };

    let is_grouped = matches!(pp.mode, PyramidMode::Grouped);
    let group_gap = pp.group_gap;
    let bar_gap = pp.bar_gap;

    // Fraction of each age-group row height occupied by bars
    let slot_frac = 1.0 - group_gap;

    // Height in data coords of a single series sub-band
    let sub_h = if is_grouped && n_series > 1 {
        (slot_frac - (n_series as f64 - 1.0) * bar_gap) / n_series as f64
    } else {
        slot_frac
    };

    // Pixel x of the centre divider (x_data = 0)
    let center_x = computed.map_x(0.0);

    for (j, series) in pp.series.iter().enumerate() {
        // Resolve bar color for this series
        let series_color = series
            .color
            .clone()
            .unwrap_or_else(|| cat10[j % cat10.len()].to_string());

        let opacity = match pp.mode {
            PyramidMode::Overlap => Some(series.opacity),
            PyramidMode::Grouped => None,
        };

        for (i, (_, left_raw, right_raw)) in series.groups.iter().enumerate() {
            let left_val = left_raw / denom;
            let right_val = right_raw / denom;

            // y_center for this age group (group 0 at the bottom → y=1)
            let y_center = i as f64 + 1.0;

            // Sub-band y extent (data coords)
            let (y_bot, y_top) = if is_grouped && n_series > 1 {
                let bot = y_center - slot_frac / 2.0 + j as f64 * (sub_h + bar_gap);
                (bot, bot + sub_h)
            } else {
                (y_center - slot_frac / 2.0, y_center + slot_frac / 2.0)
            };

            // Map to pixel coords (map_y inverts: larger data y → smaller pixel y)
            let px_y_top = computed.map_y(y_top);
            let px_y_bot = computed.map_y(y_bot);
            let px_rect_y = px_y_top.min(px_y_bot);
            let px_rect_h = (px_y_top - px_y_bot).abs();

            // Left bar
            if left_val > 0.0 {
                let left_color = if n_series == 1 {
                    series
                        .color
                        .as_deref()
                        .unwrap_or(&pp.left_color)
                        .to_string()
                } else {
                    series_color.clone()
                };
                let px_x_left = computed.map_x(-left_val);
                let bar_x = px_x_left.min(center_x);
                let bar_w = (center_x - px_x_left).abs();
                scene.add(Primitive::Rect {
                    x: bar_x,
                    y: px_rect_y,
                    width: bar_w,
                    height: px_rect_h,
                    fill: Color::from(left_color.as_str()),
                    stroke: None,
                    stroke_width: None,
                    opacity,
                });

                if pp.show_values && px_rect_h >= 10.0 && bar_w >= 16.0 {
                    let mid_y = px_rect_y + px_rect_h / 2.0 + 4.0;
                    let label = if pp.normalize {
                        format!("{:.1}%", left_val)
                    } else {
                        format!("{:.0}", left_val)
                    };
                    scene.add(Primitive::Text {
                        x: bar_x + 4.0,
                        y: mid_y,
                        content: label,
                        size: 9,
                        anchor: TextAnchor::Start,
                        rotate: None,
                        bold: false,
                        color: Some(Color::from("#ffffff")),
                    });
                }
            }

            // Right bar
            if right_val > 0.0 {
                let right_color = if n_series == 1 {
                    series
                        .color
                        .as_deref()
                        .unwrap_or(&pp.right_color)
                        .to_string()
                } else {
                    series_color.clone()
                };
                let px_x_right = computed.map_x(right_val);
                let bar_x = center_x.min(px_x_right);
                let bar_w = (px_x_right - center_x).abs();
                scene.add(Primitive::Rect {
                    x: bar_x,
                    y: px_rect_y,
                    width: bar_w,
                    height: px_rect_h,
                    fill: Color::from(right_color.as_str()),
                    stroke: None,
                    stroke_width: None,
                    opacity,
                });

                if pp.show_values && px_rect_h >= 10.0 && bar_w >= 16.0 {
                    let mid_y = px_rect_y + px_rect_h / 2.0 + 4.0;
                    let label = if pp.normalize {
                        format!("{:.1}%", right_val)
                    } else {
                        format!("{:.0}", right_val)
                    };
                    scene.add(Primitive::Text {
                        x: bar_x + bar_w - 4.0,
                        y: mid_y,
                        content: label,
                        size: 9,
                        anchor: TextAnchor::End,
                        rotate: None,
                        bold: false,
                        color: Some(Color::from("#ffffff")),
                    });
                }
            }
        }
    }

    // Side labels above the left and right halves
    let ox = computed.margin_left;
    let pw = computed.width - computed.margin_left - computed.margin_right;
    let top = computed.margin_top - 6.0;

    if !pp.left_label.is_empty() {
        scene.add(Primitive::Text {
            x: ox + pw / 4.0,
            y: top,
            content: pp.left_label.clone(),
            size: 12,
            anchor: TextAnchor::Middle,
            rotate: None,
            bold: true,
            color: None,
        });
    }
    if !pp.right_label.is_empty() {
        scene.add(Primitive::Text {
            x: ox + 3.0 * pw / 4.0,
            y: top,
            content: pp.right_label.clone(),
            size: 12,
            anchor: TextAnchor::Middle,
            rotate: None,
            bold: true,
            color: None,
        });
    }

    // Centre divider line
    scene.add(Primitive::Line {
        x1: center_x,
        y1: computed.margin_top,
        x2: center_x,
        y2: computed.height - computed.margin_bottom,
        stroke: Color::from("#aaaaaa"),
        stroke_width: 1.0,
        stroke_dasharray: None,
    });
}

/// Render a single [`PopulationPyramid`] to a [`Scene`].
pub fn render_pyramid(pp: PopulationPyramid, layout: Layout) -> Scene {
    let plots = vec![Plot::Pyramid(pp)];
    render_multiple(plots, layout)
}

// ─── WafflePlot ──────────────────────────────────────────────────────────────

/// Allocate `total_cells` across `values` using the Largest Remainder
/// (Hamilton) method.  Guarantees the returned counts sum exactly to
/// `total_cells`.  Returns a `Vec<usize>` parallel to `values`.
pub fn waffle_largest_remainder(values: &[f64], total_cells: usize) -> Vec<usize> {
    if values.is_empty() || total_cells == 0 {
        return vec![0; values.len()];
    }
    let total: f64 = values.iter().sum();
    if total <= 0.0 {
        return vec![0; values.len()];
    }
    let exact: Vec<f64> = values
        .iter()
        .map(|v| v / total * total_cells as f64)
        .collect();
    let mut floored: Vec<usize> = exact.iter().map(|v| *v as usize).collect();
    let allocated: usize = floored.iter().sum();
    let remainder = total_cells.saturating_sub(allocated);
    // Collect (index, fractional part) pairs; break ties by index for determinism
    let mut fracs: Vec<(usize, f64)> = exact
        .iter()
        .zip(floored.iter())
        .enumerate()
        .map(|(i, (e, f))| (i, e - *f as f64))
        .collect();
    fracs.sort_by(|a, b| {
        b.1.partial_cmp(&a.1)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then(a.0.cmp(&b.0))
    });
    for i in 0..remainder {
        floored[fracs[i].0] += 1;
    }
    floored
}

/// Build the display label for a waffle legend entry, respecting
/// `show_percents` and `show_counts` flags.
pub fn waffle_legend_label(
    cat: &WaffleCategory,
    cat_idx: usize,
    total_val: f64,
    cell_counts: &[usize],
    wp: &WafflePlot,
) -> String {
    let mut label = cat.label.clone();
    let has_pct = wp.show_percents && total_val > 0.0;
    let has_count = wp.show_counts;
    match (has_pct, has_count) {
        (true, true) => {
            let pct = cat.value / total_val * 100.0;
            label = format!("{} ({} cells, {:.1}%)", label, cell_counts[cat_idx], pct);
        }
        (true, false) => {
            let pct = cat.value / total_val * 100.0;
            label = format!("{} ({:.1}%)", label, pct);
        }
        (false, true) => {
            label = format!("{} ({} cells)", label, cell_counts[cat_idx]);
        }
        (false, false) => {}
    }
    label
}

fn add_waffle(wp: &WafflePlot, scene: &mut Scene, computed: &ComputedLayout) {
    let n_cells = wp.rows * wp.cols;
    if n_cells == 0 {
        return;
    }

    // Assign cells using Largest Remainder rounding
    let values: Vec<f64> = wp.categories.iter().map(|c| c.value).collect();
    let cell_counts = waffle_largest_remainder(&values, n_cells);

    // Build per-cell category index (None = background/empty)
    let mut assignments: Vec<Option<usize>> = Vec::with_capacity(n_cells);
    for (cat_idx, &count) in cell_counts.iter().enumerate() {
        for _ in 0..count {
            assignments.push(Some(cat_idx));
        }
    }
    // Pad with empty cells if needed (should be rare due to LR, but be safe)
    while assignments.len() < n_cells {
        assignments.push(None);
    }

    // Compute cell pixel size — choose the largest size that keeps cells square
    // and fits the full grid inside the plot area
    let plot_w = computed.plot_width();
    let plot_h = computed.plot_height();
    if plot_w <= 0.0 || plot_h <= 0.0 {
        return;
    }

    let cell_px = (plot_w / wp.cols as f64).min(plot_h / wp.rows as f64);
    let grid_w = cell_px * wp.cols as f64;
    let grid_h = cell_px * wp.rows as f64;

    // Center grid inside plot area
    let x0 = computed.margin_left + (plot_w - grid_w) * 0.5;
    let y0 = computed.margin_top + (plot_h - grid_h) * 0.5;

    // Half-gap applied to each side of a cell
    let pad = cell_px * wp.gap * 0.5;
    let inner = cell_px - 2.0 * pad;
    if inner <= 0.0 {
        return;
    }

    for (cell_idx, assignment) in assignments.iter().enumerate().take(n_cells) {
        // Map linear cell index → (grid_row, grid_col)
        let (row, col) = match wp.fill_order {
            FillOrder::RowMajorTopLeft => (cell_idx / wp.cols, cell_idx % wp.cols),
            FillOrder::RowMajorBottomLeft => (wp.rows - 1 - cell_idx / wp.cols, cell_idx % wp.cols),
            FillOrder::ColMajorTopLeft => (cell_idx % wp.rows, cell_idx / wp.rows),
            FillOrder::ColMajorBottomLeft => (wp.rows - 1 - cell_idx % wp.rows, cell_idx / wp.rows),
        };

        let cx = x0 + col as f64 * cell_px + cell_px * 0.5;
        let cy = y0 + row as f64 * cell_px + cell_px * 0.5;

        let fill = Color::from(match assignment {
            Some(i) => wp.categories[*i].color.as_str(),
            None => wp.empty_color.as_str(),
        });

        match wp.shape {
            CellShape::Square => {
                scene.add(Primitive::Rect {
                    x: cx - cell_px * 0.5 + pad,
                    y: cy - cell_px * 0.5 + pad,
                    width: inner,
                    height: inner,
                    fill,
                    stroke: None,
                    stroke_width: None,
                    opacity: None,
                });
            }
            CellShape::Circle => {
                scene.add(Primitive::Circle {
                    cx,
                    cy,
                    r: inner * 0.5,
                    fill,
                    fill_opacity: None,
                    stroke: None,
                    stroke_width: None,
                });
            }
        }
    }

    // Optional unit annotation below the grid
    if let Some(ref label) = wp.unit_label {
        let label_y = y0 + grid_h + computed.body_size as f64 + 4.0;
        let label_x = x0 + grid_w * 0.5;
        scene.add(Primitive::Text {
            x: label_x,
            y: label_y,
            content: label.clone(),
            size: computed.body_size,
            anchor: TextAnchor::Middle,
            rotate: None,
            bold: false,
            color: Some(Color::from("#888888")),
        });
    }
}

/// Render a single [`WafflePlot`] to a [`Scene`].
pub fn render_waffle(wp: WafflePlot, layout: Layout) -> Scene {
    let plots = vec![Plot::Waffle(wp)];
    render_multiple(plots, layout)
}

/// Parse a CSS hex color `#RRGGBB` or `#RGB` into `(r, g, b)` bytes.
/// Falls back to `(66, 146, 198)` (default blue) on parse failure.
fn parse_hex_color(hex: &str) -> (u8, u8, u8) {
    let h = hex.trim_start_matches('#');
    match h.len() {
        6 => {
            let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(66);
            let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(146);
            let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(198);
            (r, g, b)
        }
        3 => {
            let r = u8::from_str_radix(&h[0..1], 16)
                .unwrap_or(4)
                .wrapping_mul(17);
            let g = u8::from_str_radix(&h[1..2], 16)
                .unwrap_or(9)
                .wrapping_mul(17);
            let b = u8::from_str_radix(&h[2..3], 16)
                .unwrap_or(12)
                .wrapping_mul(17);
            (r, g, b)
        }
        _ => (66, 146, 198),
    }
}

/// Render a horizon chart.  Not pixel-space: uses standard x-axis, row-based y positions.
fn add_horizon(hp: &HorizonPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let n = hp.series.len();
    if n == 0 {
        return;
    }
    if hp.n_bands == 0 {
        return;
    }

    // pixel height of one data-unit on the y axis (one category slot)
    let cell_h_px = (computed.map_y(0.0) - computed.map_y(1.0)).abs();

    // Shared band widths (derived once across all series)
    let pos_bw = hp.pos_band_width();
    let neg_bw = hp.neg_band_width();

    let mut rb = ryu::Buffer::new();

    for (i, series) in hp.series.iter().enumerate() {
        if series.x.is_empty() || series.y.is_empty() {
            continue;
        }
        let pts = series.x.len().min(series.y.len());

        // Series 0 = top → data y = N; series n-1 = bottom → data y = 1
        let y_center_data = (n - i) as f64;
        let y_center_px = computed.map_y(y_center_data);

        // Bottom of this row = baseline for filled areas
        let row_baseline_px = y_center_px + cell_h_px * 0.5;
        let row_h_px = cell_h_px;

        // --- Positive bands ---
        let (pr, pg, pb) = parse_hex_color(&series.pos_color);
        for band in 1..=hp.n_bands {
            let band_lo = (band - 1) as f64 * pos_bw;
            let band_hi = band as f64 * pos_bw;

            // Opacity: lightest band has alpha 1/n_bands, darkest has alpha 1.0
            let alpha = band as f64 / hp.n_bands as f64;
            let color_str = format!("rgb({},{},{})", pr, pg, pb);

            // Build path
            let mut has_fill = false;
            let mut path = String::with_capacity(pts * 24);

            // Check if any point has non-zero height in this band before building path
            let any_pos = (0..pts).any(|j| {
                let raw_v = (series.y[j] - hp.baseline).max(0.0);
                (raw_v - band_lo) > 1e-12
            });
            if !any_pos {
                continue;
            }

            for j in 0..pts {
                let raw_v = (series.y[j] - hp.baseline).max(0.0);
                let in_band = (raw_v - band_lo).clamp(0.0, pos_bw).min(band_hi - band_lo);
                let px_h = if pos_bw > 0.0 {
                    in_band / pos_bw * row_h_px
                } else {
                    0.0
                };

                let sx = round2(computed.map_x(series.x[j]));
                let sy = round2(row_baseline_px - px_h);

                if !has_fill {
                    path.push('M');
                    has_fill = true;
                } else {
                    path.push('L');
                }
                path.push(' ');
                path.push_str(rb.format(sx));
                path.push(' ');
                path.push_str(rb.format(sy));
                path.push(' ');
            }

            if !has_fill {
                continue;
            }

            // Close via baseline
            let last_x = round2(computed.map_x(series.x[pts - 1]));
            let first_x = round2(computed.map_x(series.x[0]));
            let base_y = round2(row_baseline_px);
            {
                let s_last_x = rb.format(last_x).to_string();
                let s_base_y1 = rb.format(base_y).to_string();
                let s_first_x = rb.format(first_x).to_string();
                let s_base_y2 = rb.format(base_y).to_string();
                path.push_str(&format!(
                    "L {} {} L {} {} Z",
                    s_last_x, s_base_y1, s_first_x, s_base_y2
                ));
            }

            scene.add(Primitive::Path(Box::new(PathData {
                d: path,
                fill: Some(Color::from(color_str.as_str())),
                stroke: Color::from("none"),
                stroke_width: 0.0,
                opacity: Some(alpha),
                stroke_dasharray: None,
            })));
        }

        // --- Negative bands ---
        let has_negatives = series.y.iter().any(|&v| v < hp.baseline);
        if has_negatives {
            let (nr, ng, nb) = parse_hex_color(&series.neg_color);
            for band in 1..=hp.n_bands {
                let band_lo = (band - 1) as f64 * neg_bw;

                let alpha = band as f64 / hp.n_bands as f64;
                let color_str = format!("rgb({},{},{})", nr, ng, nb);

                let mut has_fill = false;
                let mut path = String::with_capacity(pts * 24);

                // Check if any point has non-zero height in this band
                let any_neg = (0..pts).any(|j| {
                    let raw_v = (hp.baseline - series.y[j]).max(0.0);
                    (raw_v - band_lo) > 1e-12
                });
                if !any_neg {
                    continue;
                }

                for j in 0..pts {
                    let raw_v = (hp.baseline - series.y[j]).max(0.0);
                    let in_band = (raw_v - band_lo).clamp(0.0, neg_bw);
                    let px_h = if neg_bw > 0.0 {
                        in_band / neg_bw * row_h_px
                    } else {
                        0.0
                    };

                    let sx = round2(computed.map_x(series.x[j]));
                    let sy = round2(row_baseline_px - px_h);

                    if !has_fill {
                        path.push('M');
                        has_fill = true;
                    } else {
                        path.push('L');
                    }
                    path.push(' ');
                    path.push_str(rb.format(sx));
                    path.push(' ');
                    path.push_str(rb.format(sy));
                    path.push(' ');
                }

                if !has_fill {
                    continue;
                }

                let last_x = round2(computed.map_x(series.x[pts - 1]));
                let first_x = round2(computed.map_x(series.x[0]));
                let base_y = round2(row_baseline_px);
                {
                    let s_last_x = rb.format(last_x).to_string();
                    let s_base_y1 = rb.format(base_y).to_string();
                    let s_first_x = rb.format(first_x).to_string();
                    let s_base_y2 = rb.format(base_y).to_string();
                    path.push_str(&format!(
                        "L {} {} L {} {} Z",
                        s_last_x, s_base_y1, s_first_x, s_base_y2
                    ));
                }

                scene.add(Primitive::Path(Box::new(PathData {
                    d: path,
                    fill: Some(Color::from(color_str.as_str())),
                    stroke: Color::from("none"),
                    stroke_width: 0.0,
                    opacity: Some(alpha),
                    stroke_dasharray: None,
                })));
            }
        }

        // Row separator line
        let sep_y = round2(row_baseline_px);
        scene.add(Primitive::Line {
            x1: computed.margin_left,
            y1: sep_y,
            x2: computed.width - computed.margin_right,
            y2: sep_y,
            stroke: Color::from(&computed.theme.axis_color),
            stroke_width: computed.axis_stroke_width * 0.5,
            stroke_dasharray: None,
        });
    }
}

/// Draw HorizonPlot row-end value/sign annotations.
///
/// Must be called AFTER `ClipEnd` so text in the right margin is not clipped.
fn add_horizon_annots(hp: &HorizonPlot, scene: &mut Scene, computed: &ComputedLayout) {
    if !hp.show_value_labels {
        return;
    }
    let n = hp.series.len();
    if n == 0 {
        return;
    }

    let cell_h_px = (computed.map_y(0.0) - computed.map_y(1.0)).abs();
    let pos_bw = hp.pos_band_width();
    let neg_bw = hp.neg_band_width();
    let annot_x = computed.width - computed.margin_right + 6.0;
    let font_size = computed.tick_size;
    let axis_color = Color::from(&computed.theme.axis_color);

    for (i, series) in hp.series.iter().enumerate() {
        if series.x.is_empty() || series.y.is_empty() {
            continue;
        }

        let y_center_data = (n - i) as f64;
        let y_center_px = computed.map_y(y_center_data);
        let row_baseline_px = y_center_px + cell_h_px * 0.5;
        let row_h_px = cell_h_px;

        let pos_scale = pos_bw * hp.n_bands as f64;
        let has_pos = series.y.iter().any(|&v| v > hp.baseline + 1e-12);
        let has_neg = series.y.iter().any(|&v| v < hp.baseline - 1e-12);

        let (pos_y, neg_y) = if has_pos && has_neg {
            (
                row_baseline_px - row_h_px * 0.78,
                row_baseline_px - row_h_px * 0.22,
            )
        } else {
            (
                row_baseline_px - row_h_px * 0.5,
                row_baseline_px - row_h_px * 0.5,
            )
        };

        if has_pos {
            let val_str = TickFormat::Auto.format(pos_scale);
            if hp.show_sign_colors {
                let (pr, pg, pb) = parse_hex_color(&series.pos_color);
                let sign_color = Color::Rgb(pr, pg, pb);
                scene.add(Primitive::Text {
                    x: annot_x,
                    y: pos_y,
                    content: "+".to_string(),
                    size: font_size,
                    anchor: TextAnchor::Start,
                    rotate: None,
                    bold: false,
                    color: Some(sign_color),
                });
                let char_w = font_size as f64 * 0.65;
                scene.add(Primitive::Text {
                    x: annot_x + char_w,
                    y: pos_y,
                    content: val_str,
                    size: font_size,
                    anchor: TextAnchor::Start,
                    rotate: None,
                    bold: false,
                    color: Some(axis_color.clone()),
                });
            } else {
                scene.add(Primitive::Text {
                    x: annot_x,
                    y: pos_y,
                    content: format!("+{}", val_str),
                    size: font_size,
                    anchor: TextAnchor::Start,
                    rotate: None,
                    bold: false,
                    color: Some(axis_color.clone()),
                });
            }
        }

        if has_neg {
            let neg_scale = neg_bw * hp.n_bands as f64;
            let val_str = TickFormat::Auto.format(neg_scale);
            if hp.show_sign_colors {
                let (nr, ng, nb) = parse_hex_color(&series.neg_color);
                let sign_color = Color::Rgb(nr, ng, nb);
                scene.add(Primitive::Text {
                    x: annot_x,
                    y: neg_y,
                    content: "-".to_string(),
                    size: font_size,
                    anchor: TextAnchor::Start,
                    rotate: None,
                    bold: false,
                    color: Some(sign_color),
                });
                let char_w = font_size as f64 * 0.65;
                scene.add(Primitive::Text {
                    x: annot_x + char_w,
                    y: neg_y,
                    content: val_str,
                    size: font_size,
                    anchor: TextAnchor::Start,
                    rotate: None,
                    bold: false,
                    color: Some(axis_color.clone()),
                });
            } else {
                scene.add(Primitive::Text {
                    x: annot_x,
                    y: neg_y,
                    content: format!("-{}", val_str),
                    size: font_size,
                    anchor: TextAnchor::Start,
                    rotate: None,
                    bold: false,
                    color: Some(axis_color.clone()),
                });
            }
        }
    }
}

/// Render a single [`HorizonPlot`] to a [`Scene`].
pub fn render_horizon(hp: HorizonPlot, layout: Layout) -> Scene {
    let plots = vec![Plot::Horizon(hp)];
    render_multiple(plots, layout)
}

// ── GanttPlot ─────────────────────────────────────────────────────────────────

fn add_gantt(gp: &GanttPlot, scene: &mut Scene, computed: &ComputedLayout) {
    let display_rows = gp.ordered_display_rows();
    let n = display_rows.len();
    if n == 0 {
        return;
    }

    let cat10 = Palette::category10();

    // Build group → color-index map (named groups only)
    let groups = gp.effective_group_order();
    let group_color_idx: std::collections::HashMap<String, usize> = groups
        .iter()
        .flatten()
        .enumerate()
        .map(|(ci, name)| (name.clone(), ci))
        .collect();

    // pixel height of one y-axis unit (one category slot)
    let cell_h_px = (computed.map_y(0.0) - computed.map_y(1.0)).abs();
    let bar_h = cell_h_px * gp.bar_height_frac;
    let has_groups = groups.iter().any(|g| g.is_some());

    // Plot area x bounds (for group header bands)
    let plot_left = computed.map_x(computed.x_range.0);
    let plot_right = computed.map_x(computed.x_range.1);

    for (row_i, row) in display_rows.iter().enumerate() {
        // row_i=0 is top → y_data = n, row_i=n-1 is bottom → y_data = 1
        let y_data = (n - row_i) as f64;
        let y_center = computed.map_y(y_data);

        match row {
            GanttDisplayRow::GroupHeader(group_name) => {
                // Background band spanning full plot width
                let band_y = y_center - cell_h_px * 0.5;
                scene.add(Primitive::Rect {
                    x: plot_left,
                    y: band_y,
                    width: (plot_right - plot_left).max(0.0),
                    height: cell_h_px,
                    fill: Color::from(&gp.group_bg),
                    stroke: None,
                    stroke_width: None,
                    opacity: None,
                });
                // Group label (drawn by the y-axis tick label; nothing extra needed here)
                let _ = group_name;
            }
            GanttDisplayRow::Task(task_idx) => {
                let task = &gp.tasks[*task_idx];

                // Resolve color
                let color_str = if let Some(ref c) = task.color {
                    c.as_str().to_string()
                } else if has_groups {
                    if let Some(ref g) = task.group {
                        let idx = group_color_idx.get(g).copied().unwrap_or(0);
                        cat10[idx % cat10.len()].to_string()
                    } else {
                        gp.color.clone()
                    }
                } else {
                    gp.color.clone()
                };
                let bar_color = Color::from(color_str.as_str());

                if task.is_milestone {
                    // Diamond marker (rotated square) at task.start
                    let cx = computed.map_x(task.start);
                    let s = gp.milestone_size;
                    // Path: M cx,cy-s  L cx+s,cy  L cx,cy+s  L cx-s,cy  Z
                    let d = format!(
                        "M {},{} L {},{} L {},{} L {},{} Z",
                        round2(cx),
                        round2(y_center - s),
                        round2(cx + s),
                        round2(y_center),
                        round2(cx),
                        round2(y_center + s),
                        round2(cx - s),
                        round2(y_center),
                    );
                    scene.add(Primitive::Path(Box::new(PathData {
                        d,
                        fill: Some(bar_color.clone()),
                        stroke: bar_color.clone(),
                        stroke_width: 1.0,
                        opacity: None,
                        stroke_dasharray: None,
                    })));
                    // Milestone label drawn post-clip via add_gantt_labels
                } else {
                    // Horizontal task bar
                    let x_start = computed.map_x(task.start);
                    let x_end = computed.map_x(task.end);
                    let bar_width = (x_end - x_start).max(2.0);
                    let bar_y = y_center - bar_h * 0.5;

                    scene.add(Primitive::Rect {
                        x: x_start,
                        y: bar_y,
                        width: bar_width,
                        height: bar_h,
                        fill: bar_color.clone(),
                        stroke: None,
                        stroke_width: None,
                        opacity: Some(0.85),
                    });

                    // Progress fill (darker inner rect)
                    if let Some(frac) = task.progress {
                        let prog_w = bar_width * frac;
                        if prog_w > 0.0 {
                            scene.add(Primitive::Rect {
                                x: x_start,
                                y: bar_y,
                                width: prog_w,
                                height: bar_h,
                                fill: bar_color.clone(),
                                stroke: None,
                                stroke_width: None,
                                opacity: Some(1.0),
                            });
                            // Progress stripe boundary
                            scene.add(Primitive::Line {
                                x1: x_start + prog_w,
                                y1: bar_y,
                                x2: x_start + prog_w,
                                y2: bar_y + bar_h,
                                stroke: Color::from("rgba(0,0,0,0.25)"),
                                stroke_width: 1.0,
                                stroke_dasharray: None,
                            });
                        }
                    }

                    // Bar border
                    scene.add(Primitive::Rect {
                        x: x_start,
                        y: bar_y,
                        width: bar_width,
                        height: bar_h,
                        fill: Color::from("none"),
                        stroke: Some(Color::from("rgba(0,0,0,0.18)")),
                        stroke_width: Some(0.8),
                        opacity: None,
                    });

                    // Inside label (white, clipped to plot area — fine because it's inside the bar)
                    if gp.show_labels {
                        let label_size = 11u32;
                        let est_text_w = task.label.len() as f64 * label_size as f64 * 0.55;
                        if bar_width >= gp.label_min_width && bar_width > est_text_w + 6.0 {
                            scene.add(Primitive::Text {
                                x: x_start + bar_width * 0.5,
                                y: y_center + label_size as f64 * 0.35,
                                content: task.label.clone(),
                                size: label_size,
                                anchor: TextAnchor::Middle,
                                rotate: None,
                                bold: false,
                                color: Some(Color::from("white")),
                            });
                        }
                        // Outside labels drawn post-clip via add_gantt_labels
                    }
                }
            }
        }
    }

    // Now line (vertical dashed)
    if let Some(now) = gp.now_line {
        let nx = computed.map_x(now);
        scene.add(Primitive::Line {
            x1: nx,
            y1: computed.map_y(n as f64 + 0.5),
            x2: nx,
            y2: computed.map_y(0.5),
            stroke: Color::from("#cc3333"),
            stroke_width: 1.5,
            stroke_dasharray: Some("5,3".into()),
        });
    }
}

/// Draws milestone labels and outside-bar task labels post-clip so they are
/// never truncated by the plot-area clip rect.
fn add_gantt_labels(gp: &GanttPlot, scene: &mut Scene, computed: &ComputedLayout) {
    if !gp.show_labels {
        return;
    }
    let display_rows = gp.ordered_display_rows();
    let n = display_rows.len();
    if n == 0 {
        return;
    }

    let cat10 = Palette::category10();
    let groups = gp.effective_group_order();
    let group_color_idx: std::collections::HashMap<String, usize> = groups
        .iter()
        .flatten()
        .enumerate()
        .map(|(ci, name)| (name.clone(), ci))
        .collect();
    let has_groups = groups.iter().any(|g| g.is_some());

    let label_size = 11u32;
    let cell_h_px = (computed.map_y(0.0) - computed.map_y(1.0)).abs();
    let _ = cell_h_px; // used indirectly via bar_h check below

    for (row_i, row) in display_rows.iter().enumerate() {
        let y_data = (n - row_i) as f64;
        let y_center = computed.map_y(y_data);

        if let GanttDisplayRow::Task(task_idx) = row {
            let task = &gp.tasks[*task_idx];

            let color_str = if let Some(ref c) = task.color {
                c.as_str().to_string()
            } else if has_groups {
                if let Some(ref g) = task.group {
                    let idx = group_color_idx.get(g).copied().unwrap_or(0);
                    cat10[idx % cat10.len()].to_string()
                } else {
                    gp.color.clone()
                }
            } else {
                gp.color.clone()
            };

            if task.is_milestone {
                let cx = computed.map_x(task.start);
                let s = gp.milestone_size;
                let text_color = Color::from(color_str.as_str());
                scene.add(Primitive::Text {
                    x: cx + s + 5.0,
                    y: y_center + label_size as f64 * 0.35,
                    content: task.label.clone(),
                    size: label_size,
                    anchor: TextAnchor::Start,
                    rotate: None,
                    bold: true,
                    color: Some(text_color),
                });
            } else {
                let x_start = computed.map_x(task.start);
                let x_end = computed.map_x(task.end);
                let bar_width = (x_end - x_start).max(2.0);
                let est_text_w = task.label.len() as f64 * label_size as f64 * 0.55;
                // Only draw outside label when bar is too short for inside label
                if !(bar_width >= gp.label_min_width && bar_width > est_text_w + 6.0) {
                    scene.add(Primitive::Text {
                        x: x_end + 5.0,
                        y: y_center + label_size as f64 * 0.35,
                        content: task.label.clone(),
                        size: label_size,
                        anchor: TextAnchor::Start,
                        rotate: None,
                        bold: false,
                        color: None,
                    });
                }
            }
        }
    }
}

/// Render a single [`GanttPlot`] to a [`Scene`].
pub fn render_gantt(gp: GanttPlot, layout: Layout) -> Scene {
    let plots = vec![Plot::Gantt(gp)];
    render_multiple(plots, layout)
}
