// Copyright (C) 2026 alto555
// SPDX-License-Identifier: GPL-2.0-only

//! Rasteriser. Depends on `wave-core` and nothing else.
//!
//! That dependency list is the point: this crate cannot see a file, a reader,
//! an index or a GUI toolkit.  It is handed `&dyn Curve` and a pixel budget
//! and produces pixels.  If it ever needs `wave-io`, the layering has broken.
//!
//! It is also where the reduction API gets its real test: the renderer asks
//! for exactly as many buckets as it has pixel columns, and draws min/max
//! envelopes, so cost is set by the window size and never by the file size.

pub mod font;
pub mod png;

use wave_core::{Curve, Poll, Span, Unit, XScale};

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Color(pub u8, pub u8, pub u8);

pub const BG: Color = Color(0x15, 0x17, 0x1c);
pub const PANEL: Color = Color(0x1b, 0x1e, 0x25);
pub const GRID: Color = Color(0x2b, 0x30, 0x3a);
pub const GRID_MINOR: Color = Color(0x22, 0x26, 0x2e);
pub const AXIS: Color = Color(0x6b, 0x74, 0x84);
pub const TEXT: Color = Color(0xc8, 0xcf, 0xda);
pub const TEXT_DIM: Color = Color(0x8a, 0x93, 0xa2);
pub const CURSOR: Color = Color(0xf2, 0xc0, 0x4a);

/// Blend towards the panel background.
fn dim(c: Color, t: f64) -> Color {
    let m = |a: u8, b: u8| ((a as f64) * (1.0 - t) + (b as f64) * t) as u8;
    Color(m(c.0, PANEL.0), m(c.1, PANEL.1), m(c.2, PANEL.2))
}

/// Trace colours: distinguishable in order, and none of them the cursor's.
pub const PALETTE: [Color; 8] = [
    Color(0x5c, 0xc8, 0xf2),
    Color(0xf2, 0x7a, 0x7a),
    Color(0x86, 0xd9, 0x7a),
    Color(0xc4, 0x92, 0xf0),
    Color(0xf0, 0xa8, 0x5c),
    Color(0x5c, 0xe0, 0xc8),
    Color(0xd9, 0xd0, 0x6a),
    Color(0x9a, 0xa8, 0xf5),
];

pub struct Canvas {
    pub w: usize,
    pub h: usize,
    pub px: Vec<u8>,
}

impl Canvas {
    pub fn new(w: usize, h: usize, bg: Color) -> Canvas {
        let mut px = Vec::with_capacity(w * h * 3);
        for _ in 0..w * h {
            px.push(bg.0);
            px.push(bg.1);
            px.push(bg.2);
        }
        Canvas { w, h, px }
    }

    /// Blend `c` over the existing pixel with coverage `a` (0..=255).
    #[inline]
    pub fn blend(&mut self, x: i64, y: i64, c: Color, a: u8) {
        if a == 0 || x < 0 || y < 0 || x >= self.w as i64 || y >= self.h as i64 {
            return;
        }
        if a == 255 {
            self.set(x, y, c);
            return;
        }
        let i = (y as usize * self.w + x as usize) * 3;
        let t = a as u32;
        let m = |dst: u8, src: u8| (((src as u32) * t + (dst as u32) * (255 - t)) / 255) as u8;
        self.px[i] = m(self.px[i], c.0);
        self.px[i + 1] = m(self.px[i + 1], c.1);
        self.px[i + 2] = m(self.px[i + 2], c.2);
    }

    #[inline]
    pub fn set(&mut self, x: i64, y: i64, c: Color) {
        if x < 0 || y < 0 || x >= self.w as i64 || y >= self.h as i64 {
            return;
        }
        let i = (y as usize * self.w + x as usize) * 3;
        self.px[i] = c.0;
        self.px[i + 1] = c.1;
        self.px[i + 2] = c.2;
    }

    pub fn fill_rect(&mut self, x: i64, y: i64, w: i64, h: i64, c: Color) {
        for yy in y..y + h {
            for xx in x..x + w {
                self.set(xx, yy, c);
            }
        }
    }

    pub fn hline(&mut self, x0: i64, x1: i64, y: i64, c: Color) {
        for x in x0.min(x1)..=x0.max(x1) {
            self.set(x, y, c);
        }
    }

    pub fn vline(&mut self, x: i64, y0: i64, y1: i64, c: Color) {
        for y in y0.min(y1)..=y0.max(y1) {
            self.set(x, y, c);
        }
    }

    pub fn dashed_vline(&mut self, x: i64, y0: i64, y1: i64, c: Color, on: i64, off: i64) {
        let mut y = y0.min(y1);
        let end = y0.max(y1);
        while y <= end {
            for k in 0..on {
                if y + k <= end {
                    self.set(x, y + k, c);
                }
            }
            y += on + off;
        }
    }

    pub fn dashed_hline(&mut self, x0: i64, x1: i64, y: i64, c: Color, on: i64, off: i64) {
        let mut x = x0.min(x1);
        let end = x0.max(x1);
        while x <= end {
            for k in 0..on {
                if x + k <= end {
                    self.set(x + k, y, c);
                }
            }
            x += on + off;
        }
    }

    pub fn line(&mut self, x0: i64, y0: i64, x1: i64, y1: i64, c: Color) {
        let dx = (x1 - x0).abs();
        let dy = -(y1 - y0).abs();
        let sx = if x0 < x1 { 1 } else { -1 };
        let sy = if y0 < y1 { 1 } else { -1 };
        let (mut x, mut y) = (x0, y0);
        let mut err = dx + dy;
        loop {
            self.set(x, y, c);
            if x == x1 && y == y1 {
                break;
            }
            let e2 = 2 * err;
            if e2 >= dy {
                err += dy;
                x += sx;
            }
            if e2 <= dx {
                err += dx;
                y += sy;
            }
        }
    }

    pub fn text(&mut self, x: i64, y: i64, s: &str, c: Color) -> i64 {
        let mut cx = x;
        for ch in s.chars() {
            let b = ch as u32;
            if b >= font::FIRST as u32 && b <= font::LAST as u32 {
                let f = font::current();
                let g = f.glyph(b as u8);
                for row in 0..f.ch {
                    for col in 0..f.cw {
                        self.blend(cx + col as i64, y + row as i64, c, g[row * f.cw + col]);
                    }
                }
            }
            cx += font::cw();
        }
        cx
    }

    /// Text rotated 90 degrees counter-clockwise, for Y axis titles.
    pub fn vtext(&mut self, x: i64, y: i64, s: &str, c: Color) {
        let mut cy = y;
        for ch in s.chars() {
            let b = ch as u32;
            if b >= font::FIRST as u32 && b <= font::LAST as u32 {
                let f = font::current();
                let g = f.glyph(b as u8);
                for row in 0..f.ch {
                    for col in 0..f.cw {
                        self.blend(x + row as i64, cy - col as i64, c, g[row * f.cw + col]);
                    }
                }
            }
            cy -= font::cw();
        }
    }

    pub fn text_width(s: &str) -> i64 {
        s.chars().count() as i64 * font::cw()
    }

    /// Copy another canvas in at (x, y).
    pub fn blit(&mut self, src: &Canvas, x: i64, y: i64) {
        for sy in 0..src.h {
            let dy = y + sy as i64;
            if dy < 0 || dy >= self.h as i64 {
                continue;
            }
            for sx in 0..src.w {
                let dx = x + sx as i64;
                if dx < 0 || dx >= self.w as i64 {
                    continue;
                }
                let si = (sy * src.w + sx) * 3;
                let di = (dy as usize * self.w + dx as usize) * 3;
                self.px[di] = src.px[si];
                self.px[di + 1] = src.px[si + 1];
                self.px[di + 2] = src.px[si + 2];
            }
        }
    }

    pub fn to_png(&self) -> Vec<u8> {
        png::encode_rgb(self.w, self.h, &self.px)
    }
}

// ------------------------------------------------------------------ scales

#[derive(Clone, Copy)]
struct Map {
    lo: f64,
    hi: f64,
    p0: f64,
    p1: f64,
    log: bool,
}

impl Map {
    fn to_px(&self, v: f64) -> f64 {
        let t = if self.log {
            let (a, b, x) = (self.lo.max(1e-300).log10(), self.hi.max(1e-300).log10(), v.max(1e-300).log10());
            if b > a {
                (x - a) / (b - a)
            } else {
                0.0
            }
        } else if self.hi > self.lo {
            (v - self.lo) / (self.hi - self.lo)
        } else {
            0.0
        };
        self.p0 + t * (self.p1 - self.p0)
    }
}

/// Engineering notation: what a designer reads, not 1.0e-05.
pub fn fmt_eng(v: f64, unit: Unit) -> String {
    if v == 0.0 {
        return format!("0{}", unit_suffix(unit));
    }
    if !v.is_finite() {
        return "-".into();
    }
    const PREFIX: [(&str, f64); 11] = [
        ("T", 1e12),
        ("G", 1e9),
        ("M", 1e6),
        ("k", 1e3),
        ("", 1e0),
        ("m", 1e-3),
        ("u", 1e-6),
        ("n", 1e-9),
        ("p", 1e-12),
        ("f", 1e-15),
        ("a", 1e-18),
    ];
    let a = v.abs();
    for (p, m) in PREFIX.iter() {
        if a >= *m * 0.999 {
            let s = v / m;
            let txt = if s.abs() >= 100.0 {
                format!("{:.0}", s)
            } else if s.abs() >= 10.0 {
                format!("{:.1}", s)
            } else {
                format!("{:.2}", s)
            };
            let txt = trim_zeros(&txt);
            return format!("{}{}{}", txt, p, unit_suffix(unit));
        }
    }
    format!("{:.2e}", v)
}

fn trim_zeros(s: &str) -> String {
    if !s.contains('.') {
        return s.to_string();
    }
    let t = s.trim_end_matches('0').trim_end_matches('.');
    t.to_string()
}

fn unit_suffix(u: Unit) -> &'static str {
    match u {
        Unit::Ratio | Unit::Unknown => "",
        other => other.symbol(),
    }
}

/// Engineering notation with enough decimals to tell adjacent ticks apart.
///
/// Formatting each tick independently to 2 decimals produced axes reading
/// "50u 50u 50.1u 50.1u" once the view was narrow enough — the labels have to
/// be resolved against the tick *step*, not against their own magnitude.
pub fn fmt_eng_step(v: f64, unit: Unit, step: f64) -> String {
    if v == 0.0 {
        return format!("0{}", unit_suffix(unit));
    }
    if !v.is_finite() || step <= 0.0 {
        return fmt_eng(v, unit);
    }
    const PREFIX: [(&str, f64); 11] = [
        ("T", 1e12), ("G", 1e9), ("M", 1e6), ("k", 1e3), ("", 1e0),
        ("m", 1e-3), ("u", 1e-6), ("n", 1e-9), ("p", 1e-12),
        ("f", 1e-15), ("a", 1e-18),
    ];
    let a = v.abs();
    for (p, m) in PREFIX.iter() {
        if a >= *m * 0.999 {
            let scaled_step = step / m;
            let d = (-scaled_step.log10().floor()) as i32;
            let d = d.clamp(0, 9) as usize;
            let txt = format!("{:.*}", d, v / m);
            return format!("{}{}{}", trim_zeros(&txt), p, unit_suffix(unit));
        }
    }
    fmt_eng(v, unit)
}

fn nice_ticks(lo: f64, hi: f64, target: usize) -> Vec<f64> {
    if !(hi > lo) || target == 0 {
        return Vec::new();
    }
    let raw = (hi - lo) / target as f64;
    let mag = 10f64.powf(raw.log10().floor());
    let norm = raw / mag;
    let step = mag * if norm <= 1.0 {
        1.0
    } else if norm <= 2.0 {
        2.0
    } else if norm <= 5.0 {
        5.0
    } else {
        10.0
    };
    let mut out = Vec::new();
    let mut t = (lo / step).ceil() * step;
    while t <= hi + step * 1e-9 && out.len() < 200 {
        out.push(t);
        t += step;
    }
    out
}

fn tick_step(ticks: &[f64]) -> f64 {
    if ticks.len() >= 2 {
        (ticks[1] - ticks[0]).abs()
    } else {
        0.0
    }
}

/// (value, is_major) for a log axis: decades plus 2..9 subdivisions.
fn log_ticks(lo: f64, hi: f64) -> Vec<(f64, bool)> {
    let mut out = Vec::new();
    if !(hi > lo) || lo <= 0.0 {
        return out;
    }
    let d0 = lo.log10().floor() as i32;
    let d1 = hi.log10().ceil() as i32;
    for d in d0..=d1 {
        let base = 10f64.powi(d);
        for m in 1..10 {
            let v = base * m as f64;
            if v >= lo && v <= hi {
                out.push((v, m == 1));
            }
        }
    }
    out
}

// ------------------------------------------------------------------- model

pub struct Trace<'a> {
    pub curve: &'a dyn Curve,
    pub color: Color,
    /// Overrides the curve's own name in the legend.  With more than one file
    /// open, `v(out)` alone does not say which run it came from.
    pub label: Option<String>,
}

impl<'a> Trace<'a> {
    pub fn name(&self) -> &str {
        match &self.label {
            Some(l) => l.as_str(),
            None => self.curve.name(),
        }
    }
}

/// What a render produced, beyond the pixels.
///
/// The Y mapping is decided here (autoscale runs on the reduced data), so an
/// interactive caller cannot invert a mouse position back to a voltage
/// without being told the range that was actually used.
pub struct Rendered {
    pub canvas: Canvas,
    /// (low, high) data range each pane was drawn with.
    pub pane_y: Vec<(f64, f64)>,
}

pub struct Pane<'a> {
    pub traces: Vec<Trace<'a>>,
    /// Indices of the traces to emphasise.  A set, not one: removing a batch
    /// of traces means picking a batch first.
    pub selected: Vec<usize>,
    /// Fixed Y range, or `None` to autoscale from what is in view.
    pub y_range: Option<(f64, f64)>,
    /// Horizontal marker lines, in data units.
    pub h_cursors: Vec<f64>,
    /// Draw this pane as the one keyboard and click actions apply to.
    /// Without it, an empty current pane is invisible and the viewer appears
    /// to ignore commands.
    pub current: bool,
}

impl<'a> Pane<'a> {
    pub fn new(traces: Vec<Trace<'a>>) -> Pane<'a> {
        Pane {
            traces,
            selected: Vec::new(),
            y_range: None,
            h_cursors: Vec::new(),
            current: false,
        }
    }
}

pub struct PlotSpec<'a> {
    pub width: usize,
    pub height: usize,
    pub title: String,
    pub span: Span,
    pub x_scale: XScale,
    pub x_label: String,
    /// Unit of the X axis, so a cursor can label the frequency or time it
    /// sits on — the number you actually want when you park it on a crossing.
    pub x_unit: Unit,
    pub panes: Vec<Pane<'a>>,
    /// Draw the per-trace dy/slope labels.  With many traces they crowd the
    /// pane, and often all that is wanted is the c2-c1 delta above.
    pub show_deltas: bool,
    /// Vertical markers as (slot, x).  The slot travels with the value so
    /// that deleting cursor 1 does not silently relabel cursor 2 as "c1".
    pub cursors: Vec<(usize, f64)>,
}

// Margins scale with the text: the Y labels have to fit in ml(), and the X
// labels plus the axis title in mb().  Hard-coding them made a larger font
// overrun its own axis.
pub fn ml() -> i64 {
    font::cw() * 9 + 12
}
pub fn mr() -> i64 {
    // The last X tick label is centred on the right edge of the axis, so the
    // margin has to hold half of it — at a large font a fixed 18 px clipped
    // the "n" off "200n".
    font::cw() * 3
}
pub fn mt() -> i64 {
    font::ch() * 2
}
pub fn mb() -> i64 {
    font::ch() * 2 + 14
}
pub fn gap() -> i64 {
    12
}

/// Vertical bounds of pane `i`.
///
/// Exported so that click hit-testing uses the same arithmetic as drawing;
/// two copies of this formula would drift the moment either changed.
pub fn pane_bounds(height: usize, n_panes: usize, i: usize) -> (i64, i64) {
    let h = height as i64;
    let n = n_panes.max(1) as i64;
    let total_h = h - mt() - mb() - gap() * (n - 1);
    let pane_h = (total_h / n).max(20);
    let py0 = mt() + i as i64 * (pane_h + gap());
    (py0, py0 + pane_h)
}

/// Legend entry x-extents for a pane, in canvas coordinates, so a click on a
/// legend label can be resolved back to the trace it names.
pub fn legend_entries(names: &[String]) -> Vec<(i64, i64)> {
    let mut out = Vec::with_capacity(names.len());
    let mut lx = ml() + 8;
    for n in names {
        let end = lx + 14 + Canvas::text_width(n);
        out.push((lx, end));
        lx = end + 14;
    }
    out
}

/// Vertical extent of the legend row within a pane starting at `py0`.
pub fn legend_y(py0: i64) -> (i64, i64) {
    (py0 + 2, py0 + 4 + font::ch())
}

/// Data-area X bounds inside a canvas of this width.
pub fn plot_x_bounds(width: usize) -> (i64, i64) {
    (ml(), width as i64 - mr())
}

/// Pixel -> data X.  The inverse of what `render` uses, exported so that
/// zoom-about-pointer and cursor placement agree with what was drawn.
pub fn px_to_x(span: Span, scale: XScale, width: usize, px: i64) -> f64 {
    let (x0, x1) = plot_x_bounds(width);
    let t = if x1 > x0 {
        (px - x0) as f64 / (x1 - x0) as f64
    } else {
        0.0
    };
    match scale {
        XScale::Log => {
            let a = span.x0.max(1e-300).log10();
            let b = span.x1.max(1e-300).log10();
            10f64.powf(a + t * (b - a))
        }
        XScale::Linear => span.x0 + t * (span.x1 - span.x0),
    }
}

/// Rasterise a full multi-pane plot.
pub fn render(spec: &PlotSpec) -> Rendered {
    let mut cv = Canvas::new(spec.width, spec.height, BG);
    let mut pane_y: Vec<(f64, f64)> = Vec::with_capacity(spec.panes.len());
    let w = spec.width as i64;
    let _h = spec.height as i64;
    let plot_x0 = ml();
    let plot_x1 = w - mr();
    let plot_w = (plot_x1 - plot_x0).max(1);

    cv.text(ml(), 8, &spec.title, TEXT);

    let n = spec.panes.len().max(1) as i64;
    let pane_h = {
        let (a, b) = pane_bounds(spec.height, spec.panes.len().max(1), 0);
        b - a
    };

    let xmap = Map {
        lo: spec.span.x0,
        hi: spec.span.x1,
        p0: plot_x0 as f64,
        p1: plot_x1 as f64,
        log: spec.x_scale == XScale::Log,
    };

    for (pi, pane) in spec.panes.iter().enumerate() {
        let (py0, py1) = pane_bounds(spec.height, spec.panes.len().max(1), pi);
        cv.fill_rect(plot_x0, py0, plot_w, pane_h, PANEL);

        // Reduce every trace once: one call per trace per repaint, with the
        // bucket count set by the pane width.
        let reduced: Vec<Option<wave_core::Reduced>> = pane
            .traces
            .iter()
            .map(|t| t.curve.reduce(spec.span, plot_w as usize * 2).ready())
            .collect();

        let (ylo, yhi) = pane.y_range.unwrap_or_else(|| {
            let mut lo = f64::INFINITY;
            let mut hi = f64::NEG_INFINITY;
            for r in reduced.iter().flatten() {
                if let Some((a, b)) = r.y_extent() {
                    lo = lo.min(a as f64);
                    hi = hi.max(b as f64);
                }
            }
            if !lo.is_finite() || !hi.is_finite() {
                (0.0, 1.0)
            } else if (hi - lo).abs() < 1e-30 {
                (lo - 0.5, hi + 0.5)
            } else {
                let pad = (hi - lo) * 0.06;
                // Extra headroom for the legend row, which is drawn inside the
                // pane: without it a trace at the top of its range runs under
                // its own label, and the taller the font the worse it gets.
                let ph = (py1 - py0).max(1) as f64;
                let lh = if pane.traces.is_empty() {
                    0.0
                } else {
                    (font::ch() + 6) as f64
                };
                let base = hi - lo + 2.0 * pad;
                let extra = if ph > lh { base * lh / (ph - lh) } else { 0.0 };
                (lo - pad, hi + pad + extra)
            }
        });
        pane_y.push((ylo, yhi));
        let ymap = Map {
            lo: ylo,
            hi: yhi,
            p0: py1 as f64,
            p1: py0 as f64,
            log: false,
        };

        let y_unit = pane
            .traces
            .first()
            .map(|t| t.curve.unit())
            .unwrap_or(Unit::Unknown);

        // Grid + Y ticks
        let yt = nice_ticks(ylo, yhi, 5);
        let ystep = tick_step(&yt);
        for t in yt.iter().copied() {
            let y = ymap.to_px(t).round() as i64;
            cv.hline(plot_x0, plot_x1 - 1, y, GRID);
            let lbl = fmt_eng_step(t, y_unit, ystep);
            cv.text(
                plot_x0 - 8 - Canvas::text_width(&lbl),
                y - font::ch() / 2,
                &lbl,
                TEXT_DIM,
            );
        }
        // X grid
        match spec.x_scale {
            XScale::Log => {
                for (v, major) in log_ticks(spec.span.x0, spec.span.x1) {
                    let x = xmap.to_px(v).round() as i64;
                    cv.vline(x, py0, py1 - 1, if major { GRID } else { GRID_MINOR });
                }
            }
            XScale::Linear => {
                for t in nice_ticks(spec.span.x0, spec.span.x1, 8) {
                    let x = xmap.to_px(t).round() as i64;
                    cv.vline(x, py0, py1 - 1, GRID);
                }
            }
        }

        for hc in &pane.h_cursors {
            let y = ymap.to_px(*hc).round() as i64;
            if y >= py0 && y < py1 {
                cv.dashed_hline(plot_x0, plot_x1 - 1, y, CURSOR, 6, 5);
                let lbl = fmt_eng(*hc, y_unit);
                cv.text(plot_x1 - 4 - Canvas::text_width(&lbl), y - 18, &lbl, CURSOR);
            }
        }

        // Traces.  The emphasised one is drawn last so it is not buried
        // under whatever happens to come after it.
        let order: Vec<usize> = (0..pane.traces.len())
            .filter(|i| !pane.selected.contains(i))
            .chain(pane.selected.iter().copied())
            .collect();
        for ti in order {
            let t = &pane.traces[ti];
            let r = match &reduced[ti] {
                Some(r) if !r.is_empty() => r,
                _ => continue,
            };
            // Pixel column for every bucket, computed up front: a bucket
            // covers the columns from its own x up to the next bucket's, and
            // drawing it only at its rounded x leaves gaps wherever the
            // bucket count does not divide the pixel count evenly.  Measured:
            // 54 dropped columns out of 1390 on a 6M-point full-span view.
            // Bin the reduction into pixel columns rather than letting a
            // bucket stretch across whichever columns it happens to land on.
            //
            // Asking for one bucket per column sounds right but is not: the
            // bucket X positions come from the data, so rounding leaves some
            // columns with two buckets and some with none.  Stretching a
            // bucket over its empty neighbour painted a transition twice and
            // produced edges two pixels thick next to one-pixel edges.
            // Over-sampling and folding down gives exactly one interval per
            // column, and time still comes from the data — which matters
            // because SPICE timesteps are not uniform, so column N is not
            // sample N.
            let ncols = (plot_x1 - plot_x0).max(1) as usize;
            let body = dim(t.color, 0.55);
            // A selected trace is drawn one pixel wider — thickness reads as
            // emphasis from across the room, where a legend plate does not.
            // Widening in both directions so that vertical edges thicken too,
            // not just the flat runs.
            let extra: i64 = if pane.selected.contains(&ti) { 1 } else { 0 };
            if r.len() >= ncols {
                let mut clo = vec![i64::MAX; ncols];
                let mut chi = vec![i64::MIN; ncols];
                for i in 0..r.len() {
                    let c = xmap.to_px(r.x[i]).round() as i64 - plot_x0;
                    if c < 0 || c >= ncols as i64 {
                        continue;
                    }
                    let c = c as usize;
                    let a = (ymap.to_px(r.min[i] as f64).round() as i64).clamp(py0, py1 - 1);
                    let b = (ymap.to_px(r.max[i] as f64).round() as i64).clamp(py0, py1 - 1);
                    clo[c] = clo[c].min(a.min(b));
                    chi[c] = chi[c].max(a.max(b));
                }
                // A column with no bucket holds the previous one rather than
                // interpolating: inventing a mid-value beside a fast edge is
                // exactly the half-height step this is meant to avoid.
                let mut prev_iv: Option<(i64, i64)> = None;
                for c in 0..ncols {
                    let (lo, hi) = if clo[c] == i64::MAX {
                        match prev_iv {
                            Some(p) => p,
                            None => continue,
                        }
                    } else {
                        (clo[c], chi[c])
                    };
                    let x = plot_x0 + c as i64;
                    let hi_t = (hi + extra).min(py1 - 1);
                    for dx in 0..=extra {
                        let xx = x + dx;
                        if xx >= plot_x1 {
                            continue;
                        }
                        if hi_t > lo + 1 {
                            cv.vline(xx, lo + 1, hi_t - 1, body);
                        }
                        cv.set(xx, lo, t.color);
                        cv.set(xx, hi_t, t.color);
                    }
                    // The signal is continuous, so consecutive envelopes that
                    // do not overlap must be joined or the trace shatters.
                    if let Some((plo, phi)) = prev_iv {
                        if lo > phi {
                            cv.vline(x, phi, lo, body);
                        } else if hi < plo {
                            cv.vline(x, hi, plo, body);
                        }
                    }
                    prev_iv = Some((lo, hi));
                }
            } else {
                // Fewer buckets than columns: real samples, several pixels
                // apart.  Scan-convert the polyline into per-column vertical
                // spans instead of stamping a Bresenham line.
                //
                // Bresenham walks one pixel at a time, so a steep segment
                // leaves a stair of partial runs across two or three columns
                // and the edge reads as thick and uneven.  Accumulating the y
                // range each segment covers *within each column* gives one
                // contiguous span per column — a clean steep line, and the
                // same rule the dense branch uses.
                let mut clo = vec![i64::MAX; ncols];
                let mut chi = vec![i64::MIN; ncols];
                let mut acc = |c: i64, y0: i64, y1: i64| {
                    if c >= 0 && c < ncols as i64 {
                        let c = c as usize;
                        clo[c] = clo[c].min(y0.min(y1));
                        chi[c] = chi[c].max(y0.max(y1));
                    }
                };
                let colof = |i: usize| xmap.to_px(r.x[i]).round() as i64 - plot_x0;
                let yof = |i: usize| {
                    (ymap.to_px(r.min[i] as f64).round() as i64).clamp(py0, py1 - 1)
                };
                for i in 0..r.len() {
                    let (ca, ya) = (colof(i), yof(i));
                    acc(ca, ya, ya);
                    if i + 1 < r.len() {
                        let (cb, yb) = (colof(i + 1), yof(i + 1));
                        if cb == ca {
                            acc(ca, ya, yb);
                        } else {
                            // Split the segment at column boundaries so each
                            // column gets exactly the range the line crosses
                            // inside it.
                            let (lo_c, hi_c) = (ca.min(cb), ca.max(cb));
                            let span = (cb - ca) as f64;
                            for c in lo_c..=hi_c {
                                let t0 = ((c - ca) as f64 / span).clamp(0.0, 1.0);
                                let t1 = (((c + 1) - ca) as f64 / span).clamp(0.0, 1.0);
                                let y0 = ya + ((yb - ya) as f64 * t0).round() as i64;
                                let y1 = ya + ((yb - ya) as f64 * t1).round() as i64;
                                acc(c, y0, y1);
                            }
                        }
                    }
                }
                let mut prev_iv: Option<(i64, i64)> = None;
                for c in 0..ncols {
                    if clo[c] == i64::MAX {
                        continue;
                    }
                    let (lo, hi) = (clo[c], chi[c]);
                    let x = plot_x0 + c as i64;
                    let hi_t = (hi + extra).min(py1 - 1);
                    for dx in 0..=extra {
                        let xx = x + dx;
                        if xx >= plot_x1 {
                            continue;
                        }
                        if hi_t > lo {
                            cv.vline(xx, lo, hi_t, t.color);
                        } else {
                            cv.set(xx, lo, t.color);
                        }
                    }
                    if let Some((plo, phi)) = prev_iv {
                        if lo > phi {
                            cv.vline(x, phi, lo, t.color);
                        } else if hi < plo {
                            cv.vline(x, hi, plo, t.color);
                        }
                    }
                    prev_iv = Some((lo, hi));
                }
            }
        }

        // Legend.  The selected entry gets a plate behind it, which is how a
        // click on a name reads as "this one is picked" rather than "this one
        // vanished".
        let names: Vec<String> = pane.traces.iter().map(|t| t.name().to_string()).collect();
        for (k, (x0, x1)) in legend_entries(&names).into_iter().enumerate() {
            let t = &pane.traces[k];
            if pane.selected.contains(&k) {
                cv.fill_rect(x0 - 3, py0 + 2, x1 - x0 + 6, font::ch() + 2, GRID);
            }
            cv.fill_rect(x0, py0 + 11, 10, 3, t.color);
            cv.text(x0 + 14, py0 + 4, &names[k], t.color);
        }

        // Cursors and readouts
        for (ci, cx) in spec.cursors.iter().map(|(i, x)| (*i, x)) {
            let x = xmap.to_px(*cx).round() as i64;
            if x < plot_x0 || x >= plot_x1 {
                continue;
            }
            cv.dashed_vline(x, py0, py1 - 1, CURSOR, 6, 5);
            // Cursor label and its per-trace values together at the bottom.
            // The label used to sit at the top of the pane, where a legend
            // with several traces ran straight through it.
            let mut ty = py1 - 4 - (pane.traces.len() as i64 + 1) * (font::ch() - 3);
            let tag = format!("c{} {}", ci + 1, fmt_eng(*cx, spec.x_unit));
            let tagx = if x + 4 + Canvas::text_width(&tag) < plot_x1 {
                x + 4
            } else {
                x - 4 - Canvas::text_width(&tag)
            };
            cv.text(tagx, ty, &tag, CURSOR);
            ty += font::ch() - 3;
            for t in pane.traces.iter() {
                if let Poll::Ready(Some(v)) = t.curve.value_at(*cx) {
                    let s = format!("{}", fmt_eng(v, t.curve.unit()));
                    let tx = if x + 6 + Canvas::text_width(&s) < plot_x1 {
                        x + 6
                    } else {
                        x - 6 - Canvas::text_width(&s)
                    };
                    cv.text(tx, ty, &s, t.color);
                    ty += font::ch() - 3;
                }
            }

        }

        // Per-pane cursor readout: one line per trace, in that trace's own
        // colour, at the top right.
        //
        // Per trace and colour-coded rather than one line for the pane: dy
        // belongs to a waveform, and with several in a pane the only
        // unambiguous way to say which is to match its colour.  A Bode pair
        // therefore reports dB/decade above and deg/decade below at once.
        if let (Some(a), Some(b)) = (
            spec.cursors.iter().find(|(i, _)| *i == 0).map(|(_, x)| *x),
            spec.cursors.iter().find(|(i, _)| *i == 1).map(|(_, x)| *x),
        ) {
            if !spec.show_deltas {
                // fall through to the frame without drawing the labels
            } else {
            // One row below the legend and cursor labels: a cursor parked
            // near the right edge puts its own label where this would land.
            let mut ry = py0 + 6 + font::ch();
            for t in pane.traces.iter() {
                if ry + font::ch() > py1 - 2 {
                    break;
                }
                if let (Poll::Ready(Some(y1)), Poll::Ready(Some(y2))) =
                    (t.curve.value_at(a), t.curve.value_at(b))
                {
                    let dy = y2 - y1;
                    let mut txt = format!("dy {}", fmt_eng(dy, t.curve.unit()));
                    match spec.x_scale {
                        XScale::Log => {
                            let dec = (b.max(1e-300) / a.max(1e-300)).log10();
                            if dec.abs() > 1e-12 {
                                txt.push_str(&format!(
                                    "   {}/dec",
                                    fmt_eng(dy / dec, t.curve.unit())
                                ));
                            }
                        }
                        XScale::Linear => {
                            if b != a {
                                txt.push_str(&format!(
                                    "   {}{}/{}",
                                    fmt_eng(dy / (b - a), Unit::Ratio),
                                    t.curve.unit().symbol(),
                                    spec.x_unit.symbol()
                                ));
                            }
                        }
                    }
                    cv.text(plot_x1 - 6 - Canvas::text_width(&txt), ry, &txt, t.color);
                    ry += font::ch() - 3;
                }
            }
            }
        }

        // Frame + Y axis title.  The current pane gets a brighter frame so
        // that "which pane am I typing at" is answerable by looking.
        // Highlight only when more than one pane exists — with a single pane
        // there is nothing to disambiguate and the colour is just noise.
        let frame = if pane.current && spec.panes.len() > 1 {
            CURSOR
        } else {
            AXIS
        };
        cv.hline(plot_x0, plot_x1 - 1, py0, frame);
        cv.hline(plot_x0, plot_x1 - 1, py1 - 1, frame);
        cv.vline(plot_x0, py0, py1 - 1, frame);
        cv.vline(plot_x1 - 1, py0, py1 - 1, frame);
        if pane.current && pane.traces.is_empty() {
            let msg = "empty pane - click a signal on the left to plot it";
            cv.text(
                (plot_x0 + plot_x1) / 2 - Canvas::text_width(msg) / 2,
                (py0 + py1) / 2 - 8,
                msg,
                TEXT_DIM,
            );
        }
        let yt = pane
            .traces
            .first()
            .map(|t| t.curve.unit().symbol().to_string())
            .unwrap_or_default();
        if !yt.is_empty() {
            cv.vtext(
                12,
                (py0 + py1) / 2 + Canvas::text_width(&yt) / 2,
                &yt,
                TEXT_DIM,
            );
        }
    }

    // Shared X axis under the bottom pane
    let axis_y = mt() + (n - 1) * (pane_h + gap()) + pane_h;
    match spec.x_scale {
        XScale::Log => {
            for (v, major) in log_ticks(spec.span.x0, spec.span.x1) {
                let x = xmap.to_px(v).round() as i64;
                cv.vline(x, axis_y, axis_y + if major { 6 } else { 3 }, AXIS);
                if major {
                    let lbl = fmt_eng(v, Unit::Ratio);
                    cv.text(x - Canvas::text_width(&lbl) / 2, axis_y + 6, &lbl, TEXT_DIM);
                }
            }
        }
        XScale::Linear => {
            let xt = nice_ticks(spec.span.x0, spec.span.x1, 8);
            let xstep = tick_step(&xt);
            for t in xt.iter().copied() {
                let x = xmap.to_px(t).round() as i64;
                cv.vline(x, axis_y, axis_y + 6, AXIS);
                let lbl = fmt_eng_step(t, Unit::Ratio, xstep);
                cv.text(x - Canvas::text_width(&lbl) / 2, axis_y + 9, &lbl, TEXT_DIM);
            }
        }
    }
    let xl = &spec.x_label;
    // Clear of the tick labels, which are a whole line tall at large sizes.
    cv.text(
        (plot_x0 + plot_x1) / 2 - Canvas::text_width(xl) / 2,
        axis_y + 8 + font::ch(),
        xl,
        TEXT,
    );

    // Cursor delta, the number a designer actually wants
    let c1 = spec.cursors.iter().find(|(i, _)| *i == 0).map(|(_, x)| *x);
    let c2 = spec.cursors.iter().find(|(i, _)| *i == 1).map(|(_, x)| *x);
    if let (Some(a), Some(b)) = (c1, c2) {
        let d = b - a;
        let s = format!("c2-c1 = {}", fmt_eng(d, spec.x_unit));
        cv.text(plot_x1 - Canvas::text_width(&s), 8, &s, CURSOR);
    }
    Rendered { canvas: cv, pane_y }
}
