use iced::widget::canvas::{self, Event, Frame, Geometry, Path, Stroke, Text};
use iced::{Color, Point, Rectangle, Renderer, Theme};
use iced::mouse;

use crate::app::Message;
use crate::protocol::{AmpPoint, ClPoint, EisPoint, LsvPoint};

const MARGIN_L: f32 = 55.0;
const MARGIN_R: f32 = 15.0;
const MARGIN_T: f32 = 15.0;
const MARGIN_B: f32 = 25.0;

const COL_MAG: Color = Color { r: 0.3, g: 0.85, b: 1.0, a: 1.0 };
const COL_PH: Color = Color { r: 1.0, g: 0.55, b: 0.2, a: 1.0 };
const COL_NYQ: Color = Color { r: 0.4, g: 1.0, b: 0.4, a: 1.0 };
const COL_LSV: Color = Color { r: 1.0, g: 0.8, b: 0.2, a: 1.0 };
const COL_AMP: Color = Color { r: 0.6, g: 0.6, b: 1.0, a: 1.0 };
const COL_CL_FREE: Color = Color { r: 0.2, g: 1.0, b: 0.5, a: 1.0 };
const COL_CL_TOTAL: Color = Color { r: 1.0, g: 0.6, b: 0.2, a: 1.0 };
const COL_GRID: Color = Color { r: 0.25, g: 0.25, b: 0.28, a: 1.0 };
const COL_AXIS: Color = Color { r: 0.6, g: 0.6, b: 0.6, a: 1.0 };
const COL_DIM: Color = Color { r: 0.4, g: 0.4, b: 0.4, a: 1.0 };
const COL_REF: Color = Color { r: 0.5, g: 0.5, b: 0.5, a: 0.5 };
const COL_FIT: Color = Color { r: 0.7, g: 0.3, b: 0.9, a: 0.5 };
const COL_FIT_PT: Color = Color { r: 0.9, g: 0.4, b: 0.9, a: 0.9 };

const ZOOM_FACTOR: f32 = 1.15;
const DRAG_ZOOM_RATE: f32 = 200.0;

/* ---- View range ---- */

#[derive(Clone, Copy)]
struct Vr {
    lo: f32,
    hi: f32,
}

impl Vr {
    fn new(lo: f32, hi: f32) -> Self { Self { lo, hi } }
    fn span(&self) -> f32 { self.hi - self.lo }

    fn zoom_at(&mut self, factor: f32, anchor_frac: f32) {
        let anchor = self.lo + anchor_frac * self.span();
        let new_span = self.span() / factor;
        self.lo = anchor - anchor_frac * new_span;
        self.hi = anchor + (1.0 - anchor_frac) * new_span;
    }

    fn pan_frac(&mut self, frac: f32) {
        let d = frac * self.span();
        self.lo -= d;
        self.hi -= d;
    }
}

fn screen_frac(pos: f32, lo: f32, hi: f32) -> f32 {
    if (hi - lo).abs() < 1e-6 { 0.5 } else { (pos - lo) / (hi - lo) }
}

/* ---- Drawing helpers ---- */

fn lerp(v: f32, lo: f32, hi: f32, out_lo: f32, out_hi: f32) -> f32 {
    if (hi - lo).abs() < 1e-10 { return (out_lo + out_hi) / 2.0; }
    out_lo + (v - lo) / (hi - lo) * (out_hi - out_lo)
}

fn nice_step(range: f32, target_ticks: usize) -> f32 {
    if range.abs() < 1e-10 { return 1.0; }
    let rough = range / target_ticks as f32;
    let mag = 10f32.powf(rough.abs().log10().floor());
    let norm = rough.abs() / mag;
    let s = if norm < 1.5 { 1.0 } else if norm < 3.5 { 2.0 } else if norm < 7.5 { 5.0 } else { 10.0 };
    s * mag
}

fn dl(frame: &mut Frame, a: Point, b: Point, color: Color, width: f32) {
    frame.stroke(&Path::line(a, b), Stroke::default().with_color(color).with_width(width));
}

fn dt(frame: &mut Frame, pos: Point, txt: &str, color: Color, size: f32) {
    frame.fill_text(Text {
        content: txt.to_string(), position: pos, color, size: size.into(),
        ..Text::default()
    });
}

fn pt_ok(p: &Point) -> bool { p.x.is_finite() && p.y.is_finite() }

fn draw_polyline(frame: &mut Frame, pts: &[Point], color: Color, width: f32) {
    let good: Vec<Point> = pts.iter().copied().filter(pt_ok).collect();
    if good.len() < 2 { return; }
    let path = Path::new(|b| {
        b.move_to(good[0]);
        for p in &good[1..] { b.line_to(*p); }
    });
    frame.stroke(&path, Stroke::default().with_color(color).with_width(width));
}

fn draw_dots(frame: &mut Frame, pts: &[Point], color: Color, r: f32) {
    for p in pts.iter().filter(|p| pt_ok(p)) {
        frame.fill(&Path::circle(*p, r), color);
    }
}

/* ---- Nyquist circle fit (Kåsa algebraic method) ---- */

struct CircleFit {
    cx: f32,
    cy: f32,
    r: f32,
    rs: f32,
    rp: f32,
}

fn kasa_fit(pts: &[(f64, f64)]) -> Option<(f64, f64, f64)> {
    if pts.len() < 3 { return None; }
    let n = pts.len() as f64;
    let (mut sx, mut sy) = (0.0_f64, 0.0_f64);
    let (mut sx2, mut sy2, mut sxy) = (0.0, 0.0, 0.0);
    let (mut sx3, mut sy3, mut sx2y, mut sxy2) = (0.0, 0.0, 0.0, 0.0);

    for &(x, y) in pts {
        sx += x; sy += y;
        let x2 = x * x; let y2 = y * y; let xy = x * y;
        sx2 += x2; sy2 += y2; sxy += xy;
        sx3 += x2 * x; sy3 += y2 * y; sx2y += x2 * y; sxy2 += x * y2;
    }

    let (a00, a01, a02) = (sx2, sxy, sx);
    let (a10, a11, a12) = (sxy, sy2, sy);
    let (a20, a21, a22) = (sx, sy, n);
    let (r0, r1, r2) = (sx3 + sxy2, sx2y + sy3, sx2 + sy2);

    let det = a00 * (a11 * a22 - a12 * a21)
            - a01 * (a10 * a22 - a12 * a20)
            + a02 * (a10 * a21 - a11 * a20);
    if det.abs() < 1e-20 { return None; }

    let a = (r0 * (a11 * a22 - a12 * a21)
           - a01 * (r1 * a22 - a12 * r2)
           + a02 * (r1 * a21 - a11 * r2)) / det;
    let b = (a00 * (r1 * a22 - a12 * r2)
           - r0 * (a10 * a22 - a12 * a20)
           + a02 * (a10 * r2 - r1 * a20)) / det;
    let c = (a00 * (a11 * r2 - r1 * a21)
           - a01 * (a10 * r2 - r1 * a20)
           + r0 * (a10 * a21 - a11 * a20)) / det;

    let cx = a / 2.0;
    let cy = b / 2.0;
    let r_sq = c + cx * cx + cy * cy;
    if r_sq <= 0.0 { return None; }
    Some((cx, cy, r_sq.sqrt()))
}

/// Fit the dominant Nyquist semicircle, trimming first-arc points from the
/// low-frequency end before falling back to outlier removal within each subset.
fn fit_nyquist_circle(points: &[EisPoint]) -> Option<CircleFit> {
    let all: Vec<(f64, f64)> = points.iter()
        .filter(|p| p.z_real.is_finite() && p.z_imag.is_finite())
        .map(|p| (p.z_real as f64, -p.z_imag as f64))
        .collect();
    if all.len() < 4 { return None; }

    let min_pts = 4.max(all.len() / 3);
    let mut best: Option<CircleFit> = None;
    let mut best_score = f64::MAX;

    for start in 0..all.len() {
        let mut pts: Vec<(f64, f64)> = all[start..].to_vec();
        while pts.len() >= min_pts {
            let (cx, cy, r) = match kasa_fit(&pts) {
                Some(v) => v,
                None => break,
            };
            let disc = r * r - cy * cy;
            if disc > 0.0 {
                let sd = disc.sqrt();
                let rs = cx - sd;
                let rp = 2.0 * sd;
                if rp > 0.0 {
                    let avg_err: f64 = pts.iter()
                        .map(|&(x, y)| {
                            ((x - cx).powi(2) + (y - cy).powi(2)).sqrt() - r
                        })
                        .map(|e| e.abs())
                        .sum::<f64>() / (pts.len() as f64 * r);
                    let coverage = pts.len() as f64 / all.len() as f64;
                    let score = avg_err / coverage;
                    if score < best_score {
                        best_score = score;
                        best = Some(CircleFit {
                            cx: cx as f32, cy: cy as f32, r: r as f32,
                            rs: rs as f32, rp: rp as f32,
                        });
                    }
                    break;
                }
            }
            let worst = pts.iter().enumerate()
                .map(|(i, &(x, y))| {
                    let d = ((x - cx).powi(2) + (y - cy).powi(2)).sqrt();
                    (i, (d - r).abs())
                })
                .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
                .map(|(i, _)| i);
            match worst {
                Some(i) => { pts.remove(i); }
                None => break,
            }
        }
    }
    best
}

/* ---- Bode ---- */

#[derive(Default)]
pub struct BodeState {
    freq: Option<Vr>,
    mag_y: Option<Vr>,
    ph_y: Option<Vr>,
    left_drag: Option<(Point, Vr, Vr, Vr)>,
    right_drag: Option<(Point, Vr, Vr, Vr)>,
}

pub struct BodePlot<'a> {
    pub points: &'a [EisPoint],
    pub reference: Option<&'a [EisPoint]>,
}

impl BodePlot<'_> {
    fn auto_freq(&self) -> Option<Vr> {
        if self.points.is_empty() { return None; }
        let valid = self.points.iter().filter(|p| p.freq_hz > 0.0);
        let lo = valid.clone().map(|p| p.freq_hz.log10()).fold(f32::INFINITY, f32::min);
        let hi = valid.map(|p| p.freq_hz.log10()).fold(f32::NEG_INFINITY, f32::max);
        if !lo.is_finite() || !hi.is_finite() { return None; }
        let pad = (hi - lo).max(0.1) * 0.05;
        Some(Vr::new(lo - pad, hi + pad))
    }

    fn auto_mag(&self, freq: &Vr) -> Vr {
        let vis: Vec<_> = self.points.iter()
            .filter(|p| p.freq_hz > 0.0 && p.mag_ohms.is_finite())
            .filter(|p| { let lf = p.freq_hz.log10(); lf >= freq.lo && lf <= freq.hi })
            .collect();
        let pts: Vec<_> = if vis.is_empty() {
            self.points.iter().filter(|p| p.mag_ohms.is_finite()).collect()
        } else { vis };
        if pts.is_empty() { return Vr::new(0.0, 1.0); }
        let (lo, hi) = pts.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
            (lo.min(p.mag_ohms), hi.max(p.mag_ohms))
        });
        let pad = (hi - lo).max(1.0) * 0.12;
        Vr::new(lo - pad, hi + pad)
    }

    fn auto_phase(&self, freq: &Vr) -> Vr {
        let vis: Vec<_> = self.points.iter()
            .filter(|p| p.freq_hz > 0.0 && p.phase_deg.is_finite())
            .filter(|p| { let lf = p.freq_hz.log10(); lf >= freq.lo && lf <= freq.hi })
            .collect();
        let pts: Vec<_> = if vis.is_empty() {
            self.points.iter().filter(|p| p.phase_deg.is_finite()).collect()
        } else { vis };
        if pts.is_empty() { return Vr::new(-90.0, 0.0); }
        let (lo, hi) = pts.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
            (lo.min(p.phase_deg), hi.max(p.phase_deg))
        });
        let pad = (hi - lo).max(0.1) * 0.25;
        Vr::new(lo - pad, hi + pad)
    }

    fn effective_ranges(&self, state: &BodeState) -> (Vr, Vr, Vr) {
        let freq = state.freq.unwrap_or_else(|| self.auto_freq().unwrap_or(Vr::new(3.0, 5.3)));
        let mag = state.mag_y.unwrap_or_else(|| self.auto_mag(&freq));
        let ph = state.ph_y.unwrap_or_else(|| self.auto_phase(&freq));
        (freq, mag, ph)
    }
}

impl<'a> canvas::Program<Message> for BodePlot<'a> {
    type State = BodeState;

    fn update(
        &self, state: &mut BodeState, event: Event,
        bounds: Rectangle, cursor: mouse::Cursor,
    ) -> (canvas::event::Status, Option<Message>) {
        if let Event::Mouse(mouse::Event::ButtonReleased(_)) = &event {
            let was_right = state.right_drag.is_some();
            if was_right {
                if let Some(pos) = cursor.position_in(bounds) {
                    let (start, _, _, _) = state.right_drag.unwrap();
                    let dist = ((pos.x - start.x).powi(2) + (pos.y - start.y).powi(2)).sqrt();
                    if dist < 3.0 {
                        state.freq = None;
                        state.mag_y = None;
                        state.ph_y = None;
                    }
                }
            }
            state.left_drag = None;
            state.right_drag = None;
            if was_right || state.left_drag.is_some() {
                return (canvas::event::Status::Captured, None);
            }
        }

        let Some(pos) = cursor.position_in(bounds) else {
            return (canvas::event::Status::Ignored, None);
        };
        let xl = MARGIN_L;
        let xr = bounds.width - MARGIN_R;

        match event {
            Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
                let dy = match delta {
                    mouse::ScrollDelta::Lines { y, .. } => y,
                    mouse::ScrollDelta::Pixels { y, .. } => y / 40.0,
                };
                let factor = ZOOM_FACTOR.powf(dy);
                let frac = screen_frac(pos.x, xl, xr);
                let (mut freq, _, _) = self.effective_ranges(state);
                freq.zoom_at(factor, frac);
                state.freq = Some(freq);
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
                let (freq, mag, ph) = self.effective_ranges(state);
                state.left_drag = Some((pos, freq, mag, ph));
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
                let (freq, mag, ph) = self.effective_ranges(state);
                state.right_drag = Some((pos, freq, mag, ph));
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
                if let Some((start, sf, sm, sp)) = state.left_drag {
                    let dx = (pos.x - start.x) / (xr - xl);
                    let dy = (pos.y - start.y) / (bounds.height - MARGIN_T - MARGIN_B);
                    let mut freq = sf; freq.pan_frac(dx);
                    let mut mag = sm; mag.pan_frac(-dy);
                    let mut ph = sp; ph.pan_frac(-dy);
                    state.freq = Some(freq);
                    state.mag_y = Some(mag);
                    state.ph_y = Some(ph);
                    return (canvas::event::Status::Captured, None);
                }
                if let Some((start, sf, sm, sp)) = state.right_drag {
                    let dx = pos.x - start.x;
                    let dy = pos.y - start.y;
                    let xf = 2.0_f32.powf(dx / DRAG_ZOOM_RATE);
                    let yf = 2.0_f32.powf(-dy / DRAG_ZOOM_RATE);
                    let frac_x = screen_frac(start.x, xl, xr);
                    let mut freq = sf; freq.zoom_at(xf, frac_x);
                    let mut mag = sm; mag.zoom_at(yf, 0.5);
                    let mut ph = sp; ph.zoom_at(yf, 0.5);
                    state.freq = Some(freq);
                    state.mag_y = Some(mag);
                    state.ph_y = Some(ph);
                    return (canvas::event::Status::Captured, None);
                }
                (canvas::event::Status::Ignored, None)
            }
            _ => (canvas::event::Status::Ignored, None),
        }
    }

    fn draw(
        &self, state: &BodeState, renderer: &Renderer, _theme: &Theme,
        bounds: Rectangle, cursor: mouse::Cursor,
    ) -> Vec<Geometry> {
        let mut frame = Frame::new(renderer, bounds.size());
        let (w, h) = (bounds.width, bounds.height);

        if self.points.is_empty() {
            dt(&mut frame, Point::new(w / 2.0 - 25.0, h / 2.0), "No data", COL_DIM, 13.0);
            return vec![frame.into_geometry()];
        }

        let split = (h * 0.55).floor();
        let xl = MARGIN_L;
        let xr = w - MARGIN_R;

        let fv = state.freq.unwrap_or_else(|| self.auto_freq().unwrap());
        let mv = state.mag_y.unwrap_or_else(|| self.auto_mag(&fv));
        let pv = state.ph_y.unwrap_or_else(|| self.auto_phase(&fv));

        // freq grid
        let d0 = fv.lo.floor() as i32;
        let d1 = fv.hi.ceil() as i32;
        for d in d0..=d1 {
            let x = lerp(d as f32, fv.lo, fv.hi, xl, xr);
            if x < xl || x > xr { continue; }
            dl(&mut frame, Point::new(x, MARGIN_T), Point::new(x, h - MARGIN_B), COL_GRID, 1.0);
            let hz = 10f32.powi(d);
            let label = if hz >= 1000.0 { format!("{}k", hz as u32 / 1000) } else { format!("{}", hz as u32) };
            dt(&mut frame, Point::new(x - 8.0, split - 2.0), &label, COL_DIM, 9.0);
        }

        dl(&mut frame, Point::new(xl, split), Point::new(xr, split), COL_AXIS, 1.0);

        // magnitude
        let mag_top = MARGIN_T;
        let mag_bot = split - 14.0;
        let m_step = nice_step(mv.span(), 4);
        if m_step > 0.0 {
            let mut mg = (mv.lo / m_step).ceil() * m_step;
            while mg <= mv.hi {
                let y = lerp(mg, mv.hi, mv.lo, mag_top, mag_bot);
                dl(&mut frame, Point::new(xl, y), Point::new(xr, y), COL_GRID, 0.5);
                dt(&mut frame, Point::new(2.0, y - 5.0), &format!("{:.0}", mg), COL_MAG, 9.0);
                mg += m_step;
            }
        }
        dt(&mut frame, Point::new(2.0, mag_top - 2.0), "|Z|", COL_MAG, 10.0);

        if let Some(rpts) = self.reference {
            let rm: Vec<Point> = rpts.iter().map(|p| Point::new(
                lerp(p.freq_hz.log10(), fv.lo, fv.hi, xl, xr),
                lerp(p.mag_ohms, mv.hi, mv.lo, mag_top, mag_bot),
            )).collect();
            draw_polyline(&mut frame, &rm, COL_REF, 1.5);
        }

        let mag_pts: Vec<Point> = self.points.iter().map(|p| Point::new(
            lerp(p.freq_hz.log10(), fv.lo, fv.hi, xl, xr),
            lerp(p.mag_ohms, mv.hi, mv.lo, mag_top, mag_bot),
        )).collect();
        draw_polyline(&mut frame, &mag_pts, COL_MAG, 2.0);
        draw_dots(&mut frame, &mag_pts, COL_MAG, 2.5);

        // phase
        let ph_top = split + 12.0;
        let ph_bot = h - MARGIN_B;
        let p_step = nice_step(pv.span(), 3);
        if p_step > 0.0 {
            let mut pg = (pv.lo / p_step).ceil() * p_step;
            while pg <= pv.hi {
                let y = lerp(pg, pv.hi, pv.lo, ph_top, ph_bot);
                dl(&mut frame, Point::new(xl, y), Point::new(xr, y), COL_GRID, 0.5);
                dt(&mut frame, Point::new(2.0, y - 5.0), &format!("{:.1}", pg), COL_PH, 9.0);
                pg += p_step;
            }
        }
        dt(&mut frame, Point::new(2.0, ph_top - 2.0), "Phase", COL_PH, 10.0);

        if let Some(rpts) = self.reference {
            let rp: Vec<Point> = rpts.iter().map(|p| Point::new(
                lerp(p.freq_hz.log10(), fv.lo, fv.hi, xl, xr),
                lerp(p.phase_deg, pv.hi, pv.lo, ph_top, ph_bot),
            )).collect();
            draw_polyline(&mut frame, &rp, COL_REF, 1.5);
        }

        let ph_pts: Vec<Point> = self.points.iter().map(|p| Point::new(
            lerp(p.freq_hz.log10(), fv.lo, fv.hi, xl, xr),
            lerp(p.phase_deg, pv.hi, pv.lo, ph_top, ph_bot),
        )).collect();
        draw_polyline(&mut frame, &ph_pts, COL_PH, 2.0);
        draw_dots(&mut frame, &ph_pts, COL_PH, 2.5);

        // crosshair
        if let Some(pos) = cursor.position_in(bounds) {
            if pos.x >= xl && pos.x <= xr && pos.y >= MARGIN_T && pos.y <= h - MARGIN_B {
                let lf = lerp(pos.x, xl, xr, fv.lo, fv.hi);
                let hz = 10f32.powf(lf);
                let label = if hz >= 1000.0 { format!("{:.1}kHz", hz / 1000.0) } else { format!("{:.0}Hz", hz) };
                dl(&mut frame, Point::new(pos.x, MARGIN_T), Point::new(pos.x, h - MARGIN_B),
                   Color { a: 0.3, ..COL_AXIS }, 1.0);
                dt(&mut frame, Point::new(pos.x + 4.0, MARGIN_T + 2.0), &label, COL_AXIS, 10.0);
            }
        }

        vec![frame.into_geometry()]
    }
}

/* ---- Nyquist ---- */

#[derive(Default)]
pub struct NyquistState {
    xv: Option<Vr>,
    yv: Option<Vr>,
    left_drag: Option<(Point, Vr, Vr)>,
    right_drag: Option<(Point, Vr, Vr)>,
}

pub struct NyquistPlot<'a> {
    pub points: &'a [EisPoint],
    pub reference: Option<&'a [EisPoint]>,
}

impl NyquistPlot<'_> {
    fn auto_view(&self) -> Option<(Vr, Vr)> {
        let valid: Vec<_> = self.points.iter()
            .filter(|p| p.z_real.is_finite() && p.z_imag.is_finite())
            .collect();
        if valid.is_empty() { return None; }
        let (re_min, re_max) = valid.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
            (lo.min(p.z_real), hi.max(p.z_real))
        });
        let (ni_min, ni_max) = valid.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
            (lo.min(-p.z_imag), hi.max(-p.z_imag))
        });
        let re_span = (re_max - re_min).max(1.0);
        let ni_span = (ni_max - ni_min).max(1.0);
        let span = re_span.max(ni_span) * 1.3;
        let re_c = (re_min + re_max) / 2.0;
        let ni_c = (ni_min + ni_max) / 2.0;
        Some((Vr::new(re_c - span / 2.0, re_c + span / 2.0),
              Vr::new(ni_c - span / 2.0, ni_c + span / 2.0)))
    }

    fn effective_ranges(&self, state: &NyquistState) -> (Vr, Vr) {
        let auto = self.auto_view().unwrap_or((Vr::new(0.0, 1.0), Vr::new(0.0, 1.0)));
        let xv = state.xv.unwrap_or(auto.0);
        let yv = state.yv.unwrap_or(auto.1);
        (xv, yv)
    }
}

impl<'a> canvas::Program<Message> for NyquistPlot<'a> {
    type State = NyquistState;

    fn update(
        &self, state: &mut NyquistState, event: Event,
        bounds: Rectangle, cursor: mouse::Cursor,
    ) -> (canvas::event::Status, Option<Message>) {
        if let Event::Mouse(mouse::Event::ButtonReleased(_)) = &event {
            let was_right = state.right_drag.is_some();
            if was_right {
                if let Some(pos) = cursor.position_in(bounds) {
                    let (start, _, _) = state.right_drag.unwrap();
                    let dist = ((pos.x - start.x).powi(2) + (pos.y - start.y).powi(2)).sqrt();
                    if dist < 3.0 {
                        state.xv = None;
                        state.yv = None;
                    }
                }
            }
            state.left_drag = None;
            state.right_drag = None;
            if was_right || state.left_drag.is_some() {
                return (canvas::event::Status::Captured, None);
            }
        }

        let Some(pos) = cursor.position_in(bounds) else {
            return (canvas::event::Status::Ignored, None);
        };
        let xl = MARGIN_L;
        let xr = bounds.width - MARGIN_R;
        let yt = MARGIN_T;
        let yb = bounds.height - MARGIN_B;

        match event {
            Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
                let dy = match delta {
                    mouse::ScrollDelta::Lines { y, .. } => y,
                    mouse::ScrollDelta::Pixels { y, .. } => y / 40.0,
                };
                let factor = ZOOM_FACTOR.powf(dy);
                let fx = screen_frac(pos.x, xl, xr);
                let fy = screen_frac(pos.y, yt, yb);
                let (mut xv, mut yv) = self.effective_ranges(state);
                xv.zoom_at(factor, fx);
                yv.zoom_at(factor, 1.0 - fy);
                state.xv = Some(xv);
                state.yv = Some(yv);
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
                let (xv, yv) = self.effective_ranges(state);
                state.left_drag = Some((pos, xv, yv));
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
                let (xv, yv) = self.effective_ranges(state);
                state.right_drag = Some((pos, xv, yv));
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
                if let Some((start, sx, sy)) = state.left_drag {
                    let dx = (pos.x - start.x) / (xr - xl);
                    let dy = (pos.y - start.y) / (yb - yt);
                    let mut xv = sx; xv.pan_frac(dx);
                    let mut yv = sy; yv.pan_frac(-dy);
                    state.xv = Some(xv);
                    state.yv = Some(yv);
                    return (canvas::event::Status::Captured, None);
                }
                if let Some((start, sx, sy)) = state.right_drag {
                    let dx = pos.x - start.x;
                    let dy = pos.y - start.y;
                    let xf = 2.0_f32.powf(dx / DRAG_ZOOM_RATE);
                    let yf = 2.0_f32.powf(-dy / DRAG_ZOOM_RATE);
                    let frac_x = screen_frac(start.x, xl, xr);
                    let frac_y = 1.0 - screen_frac(start.y, yt, yb);
                    let mut xv = sx; xv.zoom_at(xf, frac_x);
                    let mut yv = sy; yv.zoom_at(yf, frac_y);
                    state.xv = Some(xv);
                    state.yv = Some(yv);
                    return (canvas::event::Status::Captured, None);
                }
                (canvas::event::Status::Ignored, None)
            }
            _ => (canvas::event::Status::Ignored, None),
        }
    }

    fn draw(
        &self, state: &NyquistState, renderer: &Renderer, _theme: &Theme,
        bounds: Rectangle, cursor: mouse::Cursor,
    ) -> Vec<Geometry> {
        let mut frame = Frame::new(renderer, bounds.size());
        let (w, h) = (bounds.width, bounds.height);

        if self.points.is_empty() {
            dt(&mut frame, Point::new(w / 2.0 - 25.0, h / 2.0), "No data", COL_DIM, 13.0);
            return vec![frame.into_geometry()];
        }

        let xl = MARGIN_L;
        let xr = w - MARGIN_R;
        let yt = MARGIN_T;
        let yb = h - MARGIN_B;

        let (xv, yv) = self.effective_ranges(state);

        // grid
        let x_step = nice_step(xv.span(), 4);
        if x_step > 0.0 {
            let mut g = (xv.lo / x_step).ceil() * x_step;
            while g <= xv.hi {
                let x = lerp(g, xv.lo, xv.hi, xl, xr);
                dl(&mut frame, Point::new(x, yt), Point::new(x, yb), COL_GRID, 0.5);
                dt(&mut frame, Point::new(x - 10.0, yb + 3.0), &format!("{:.0}", g), COL_DIM, 9.0);
                g += x_step;
            }
        }
        let y_step = nice_step(yv.span(), 4);
        if y_step > 0.0 {
            let mut g = (yv.lo / y_step).ceil() * y_step;
            while g <= yv.hi {
                let y = lerp(g, yv.hi, yv.lo, yt, yb);
                dl(&mut frame, Point::new(xl, y), Point::new(xr, y), COL_GRID, 0.5);
                dt(&mut frame, Point::new(2.0, y - 5.0), &format!("{:.0}", g), COL_DIM, 9.0);
                g += y_step;
            }
        }

        let zy = lerp(0.0, yv.hi, yv.lo, yt, yb);
        if zy > yt && zy < yb {
            dl(&mut frame, Point::new(xl, zy), Point::new(xr, zy), COL_AXIS, 1.0);
        }

        dt(&mut frame, Point::new(2.0, yt - 2.0), "-Z''", COL_NYQ, 10.0);
        dt(&mut frame, Point::new((xl + xr) / 2.0 - 12.0, yb + 3.0), "Z'", COL_NYQ, 10.0);

        if let Some(rpts) = self.reference {
            let rp: Vec<Point> = rpts.iter().map(|p| Point::new(
                lerp(p.z_real, xv.lo, xv.hi, xl, xr),
                lerp(-p.z_imag, yv.hi, yv.lo, yt, yb),
            )).collect();
            draw_polyline(&mut frame, &rp, COL_REF, 1.5);
            draw_dots(&mut frame, &rp, COL_REF, 2.0);
        }

        let pts: Vec<Point> = self.points.iter().map(|p| Point::new(
            lerp(p.z_real, xv.lo, xv.hi, xl, xr),
            lerp(-p.z_imag, yv.hi, yv.lo, yt, yb),
        )).collect();
        draw_polyline(&mut frame, &pts, COL_NYQ, 2.0);
        draw_dots(&mut frame, &pts, COL_NYQ, 3.0);

        if let Some(fit) = fit_nyquist_circle(self.points) {
            let theta_r = (-fit.cy).atan2((fit.r * fit.r - fit.cy * fit.cy).sqrt());
            let mut theta_l = (-fit.cy).atan2(-(fit.r * fit.r - fit.cy * fit.cy).sqrt());
            if theta_l < theta_r { theta_l += std::f32::consts::TAU; }

            let n_arc = 120;
            let arc_pts: Vec<Point> = (0..=n_arc).map(|i| {
                let t = theta_r + (theta_l - theta_r) * i as f32 / n_arc as f32;
                let x = fit.cx + fit.r * t.cos();
                let y = fit.cy + fit.r * t.sin();
                Point::new(
                    lerp(x, xv.lo, xv.hi, xl, xr),
                    lerp(y, yv.hi, yv.lo, yt, yb),
                )
            }).collect();
            draw_polyline(&mut frame, &arc_pts, COL_FIT, 1.5);

            let y0_scr = lerp(0.0, yv.hi, yv.lo, yt, yb);
            let rs_scr = Point::new(lerp(fit.rs, xv.lo, xv.hi, xl, xr), y0_scr);
            let rp_scr = Point::new(lerp(fit.rs + fit.rp, xv.lo, xv.hi, xl, xr), y0_scr);
            frame.fill(&Path::circle(rs_scr, 5.0), COL_FIT_PT);
            frame.fill(&Path::circle(rp_scr, 5.0), COL_FIT_PT);

            dt(&mut frame, Point::new(rs_scr.x, rs_scr.y + 6.0),
               &format!("Rs={:.0}", fit.rs), COL_FIT_PT, 10.0);
            dt(&mut frame, Point::new(rp_scr.x - 30.0, rp_scr.y + 6.0),
               &format!("Rp={:.0}", fit.rp), COL_FIT_PT, 10.0);
        }

        if let Some(pos) = cursor.position_in(bounds) {
            if pos.x >= xl && pos.x <= xr && pos.y >= yt && pos.y <= yb {
                let re = lerp(pos.x, xl, xr, xv.lo, xv.hi);
                let nim = lerp(pos.y, yt, yb, yv.hi, yv.lo);
                dl(&mut frame, Point::new(pos.x, yt), Point::new(pos.x, yb),
                   Color { a: 0.3, ..COL_AXIS }, 1.0);
                dl(&mut frame, Point::new(xl, pos.y), Point::new(xr, pos.y),
                   Color { a: 0.3, ..COL_AXIS }, 1.0);
                dt(&mut frame, Point::new(pos.x + 4.0, pos.y - 14.0),
                   &format!("{:.1}, {:.1}", re, nim), COL_AXIS, 10.0);
            }
        }

        vec![frame.into_geometry()]
    }
}

/* ---- Voltammogram (LSV) ---- */

#[derive(Default)]
pub struct VoltammogramState {
    xv: Option<Vr>,
    yv: Option<Vr>,
    left_drag: Option<(Point, Vr, Vr)>,
    right_drag: Option<(Point, Vr, Vr)>,
}

pub struct VoltammogramPlot<'a> {
    pub points: &'a [LsvPoint],
    pub reference: Option<&'a [LsvPoint]>,
}

impl VoltammogramPlot<'_> {
    fn auto_view(&self) -> Option<(Vr, Vr)> {
        let valid: Vec<_> = self.points.iter()
            .filter(|p| p.v_mv.is_finite() && p.i_ua.is_finite())
            .collect();
        if valid.is_empty() { return None; }
        let (xlo, xhi) = valid.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
            (lo.min(p.v_mv), hi.max(p.v_mv))
        });
        let (ylo, yhi) = valid.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
            (lo.min(p.i_ua), hi.max(p.i_ua))
        });
        let xpad = (xhi - xlo).max(1.0) * 0.05;
        let ypad = (yhi - ylo).max(0.001) * 0.12;
        Some((Vr::new(xlo - xpad, xhi + xpad), Vr::new(ylo - ypad, yhi + ypad)))
    }

    fn effective_ranges(&self, state: &VoltammogramState) -> (Vr, Vr) {
        let auto = self.auto_view().unwrap_or((Vr::new(0.0, 500.0), Vr::new(-1.0, 1.0)));
        (state.xv.unwrap_or(auto.0), state.yv.unwrap_or(auto.1))
    }
}

impl<'a> canvas::Program<Message> for VoltammogramPlot<'a> {
    type State = VoltammogramState;

    fn update(
        &self, state: &mut VoltammogramState, event: Event,
        bounds: Rectangle, cursor: mouse::Cursor,
    ) -> (canvas::event::Status, Option<Message>) {
        if let Event::Mouse(mouse::Event::ButtonReleased(_)) = &event {
            let was_right = state.right_drag.is_some();
            if was_right {
                if let Some(pos) = cursor.position_in(bounds) {
                    let (start, _, _) = state.right_drag.unwrap();
                    let dist = ((pos.x - start.x).powi(2) + (pos.y - start.y).powi(2)).sqrt();
                    if dist < 3.0 { state.xv = None; state.yv = None; }
                }
            }
            state.left_drag = None;
            state.right_drag = None;
            if was_right { return (canvas::event::Status::Captured, None); }
        }

        let Some(pos) = cursor.position_in(bounds) else {
            return (canvas::event::Status::Ignored, None);
        };
        let xl = MARGIN_L;
        let xr = bounds.width - MARGIN_R;
        let yt = MARGIN_T;
        let yb = bounds.height - MARGIN_B;

        match event {
            Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
                let dy = match delta {
                    mouse::ScrollDelta::Lines { y, .. } => y,
                    mouse::ScrollDelta::Pixels { y, .. } => y / 40.0,
                };
                let factor = ZOOM_FACTOR.powf(dy);
                let (mut xv, mut yv) = self.effective_ranges(state);
                xv.zoom_at(factor, screen_frac(pos.x, xl, xr));
                yv.zoom_at(factor, 1.0 - screen_frac(pos.y, yt, yb));
                state.xv = Some(xv); state.yv = Some(yv);
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
                let (xv, yv) = self.effective_ranges(state);
                state.left_drag = Some((pos, xv, yv));
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
                let (xv, yv) = self.effective_ranges(state);
                state.right_drag = Some((pos, xv, yv));
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
                if let Some((start, sx, sy)) = state.left_drag {
                    let dx = (pos.x - start.x) / (xr - xl);
                    let dy = (pos.y - start.y) / (yb - yt);
                    let mut xv = sx; xv.pan_frac(dx);
                    let mut yv = sy; yv.pan_frac(-dy);
                    state.xv = Some(xv); state.yv = Some(yv);
                    return (canvas::event::Status::Captured, None);
                }
                if let Some((start, sx, sy)) = state.right_drag {
                    let dx = pos.x - start.x;
                    let dy = pos.y - start.y;
                    let xf = 2.0_f32.powf(dx / DRAG_ZOOM_RATE);
                    let yf = 2.0_f32.powf(-dy / DRAG_ZOOM_RATE);
                    let mut xv = sx; xv.zoom_at(xf, screen_frac(start.x, xl, xr));
                    let mut yv = sy; yv.zoom_at(yf, 1.0 - screen_frac(start.y, yt, yb));
                    state.xv = Some(xv); state.yv = Some(yv);
                    return (canvas::event::Status::Captured, None);
                }
                (canvas::event::Status::Ignored, None)
            }
            _ => (canvas::event::Status::Ignored, None),
        }
    }

    fn draw(
        &self, state: &VoltammogramState, renderer: &Renderer, _theme: &Theme,
        bounds: Rectangle, cursor: mouse::Cursor,
    ) -> Vec<Geometry> {
        let mut frame = Frame::new(renderer, bounds.size());
        let (w, h) = (bounds.width, bounds.height);

        if self.points.is_empty() {
            dt(&mut frame, Point::new(w / 2.0 - 25.0, h / 2.0), "No data", COL_DIM, 13.0);
            return vec![frame.into_geometry()];
        }

        let xl = MARGIN_L;
        let xr = w - MARGIN_R;
        let yt = MARGIN_T;
        let yb = h - MARGIN_B;

        let (xv, yv) = self.effective_ranges(state);

        let x_step = nice_step(xv.span(), 5);
        if x_step > 0.0 {
            let mut g = (xv.lo / x_step).ceil() * x_step;
            while g <= xv.hi {
                let x = lerp(g, xv.lo, xv.hi, xl, xr);
                dl(&mut frame, Point::new(x, yt), Point::new(x, yb), COL_GRID, 0.5);
                dt(&mut frame, Point::new(x - 10.0, yb + 3.0), &format!("{:.0}", g), COL_DIM, 9.0);
                g += x_step;
            }
        }
        let y_step = nice_step(yv.span(), 4);
        if y_step > 0.0 {
            let mut g = (yv.lo / y_step).ceil() * y_step;
            while g <= yv.hi {
                let y = lerp(g, yv.hi, yv.lo, yt, yb);
                dl(&mut frame, Point::new(xl, y), Point::new(xr, y), COL_GRID, 0.5);
                dt(&mut frame, Point::new(2.0, y - 5.0), &format!("{:.1}", g), COL_LSV, 9.0);
                g += y_step;
            }
        }

        dt(&mut frame, Point::new(2.0, yt - 2.0), "I (uA)", COL_LSV, 10.0);
        dt(&mut frame, Point::new((xl + xr) / 2.0 - 15.0, yb + 3.0), "V (mV)", COL_LSV, 10.0);

        if let Some(rpts) = self.reference {
            let rp: Vec<Point> = rpts.iter().map(|p| Point::new(
                lerp(p.v_mv, xv.lo, xv.hi, xl, xr),
                lerp(p.i_ua, yv.hi, yv.lo, yt, yb),
            )).collect();
            draw_polyline(&mut frame, &rp, COL_REF, 1.5);
        }

        let pts: Vec<Point> = self.points.iter().map(|p| Point::new(
            lerp(p.v_mv, xv.lo, xv.hi, xl, xr),
            lerp(p.i_ua, yv.hi, yv.lo, yt, yb),
        )).collect();
        draw_polyline(&mut frame, &pts, COL_LSV, 2.0);
        draw_dots(&mut frame, &pts, COL_LSV, 2.5);

        if let Some(pos) = cursor.position_in(bounds) {
            if pos.x >= xl && pos.x <= xr && pos.y >= yt && pos.y <= yb {
                let v = lerp(pos.x, xl, xr, xv.lo, xv.hi);
                let i = lerp(pos.y, yt, yb, yv.hi, yv.lo);
                dl(&mut frame, Point::new(pos.x, yt), Point::new(pos.x, yb),
                   Color { a: 0.3, ..COL_AXIS }, 1.0);
                dl(&mut frame, Point::new(xl, pos.y), Point::new(xr, pos.y),
                   Color { a: 0.3, ..COL_AXIS }, 1.0);
                dt(&mut frame, Point::new(pos.x + 4.0, pos.y - 14.0),
                   &format!("{:.0}mV, {:.2}uA", v, i), COL_AXIS, 10.0);
            }
        }

        vec![frame.into_geometry()]
    }
}

/* ---- Amperogram ---- */

#[derive(Default)]
pub struct AmperogramState {
    xv: Option<Vr>,
    yv: Option<Vr>,
    left_drag: Option<(Point, Vr, Vr)>,
    right_drag: Option<(Point, Vr, Vr)>,
}

pub struct AmperogramPlot<'a> {
    pub points: &'a [AmpPoint],
    pub reference: Option<&'a [AmpPoint]>,
}

impl AmperogramPlot<'_> {
    fn auto_view(&self) -> Option<(Vr, Vr)> {
        let valid: Vec<_> = self.points.iter()
            .filter(|p| p.t_ms.is_finite() && p.i_ua.is_finite())
            .collect();
        if valid.is_empty() { return None; }
        let (xlo, xhi) = valid.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
            (lo.min(p.t_ms), hi.max(p.t_ms))
        });
        let (ylo, yhi) = valid.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
            (lo.min(p.i_ua), hi.max(p.i_ua))
        });
        let xpad = (xhi - xlo).max(100.0) * 0.05;
        let ypad = (yhi - ylo).max(0.001) * 0.12;
        Some((Vr::new(xlo - xpad, xhi + xpad), Vr::new(ylo - ypad, yhi + ypad)))
    }

    fn effective_ranges(&self, state: &AmperogramState) -> (Vr, Vr) {
        let auto = self.auto_view().unwrap_or((Vr::new(0.0, 1000.0), Vr::new(-1.0, 1.0)));
        (state.xv.unwrap_or(auto.0), state.yv.unwrap_or(auto.1))
    }
}

impl<'a> canvas::Program<Message> for AmperogramPlot<'a> {
    type State = AmperogramState;

    fn update(
        &self, state: &mut AmperogramState, event: Event,
        bounds: Rectangle, cursor: mouse::Cursor,
    ) -> (canvas::event::Status, Option<Message>) {
        if let Event::Mouse(mouse::Event::ButtonReleased(_)) = &event {
            let was_right = state.right_drag.is_some();
            if was_right {
                if let Some(pos) = cursor.position_in(bounds) {
                    let (start, _, _) = state.right_drag.unwrap();
                    let dist = ((pos.x - start.x).powi(2) + (pos.y - start.y).powi(2)).sqrt();
                    if dist < 3.0 { state.xv = None; state.yv = None; }
                }
            }
            state.left_drag = None;
            state.right_drag = None;
            if was_right { return (canvas::event::Status::Captured, None); }
        }

        let Some(pos) = cursor.position_in(bounds) else {
            return (canvas::event::Status::Ignored, None);
        };
        let xl = MARGIN_L;
        let xr = bounds.width - MARGIN_R;
        let yt = MARGIN_T;
        let yb = bounds.height - MARGIN_B;

        match event {
            Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
                let dy = match delta {
                    mouse::ScrollDelta::Lines { y, .. } => y,
                    mouse::ScrollDelta::Pixels { y, .. } => y / 40.0,
                };
                let factor = ZOOM_FACTOR.powf(dy);
                let (mut xv, mut yv) = self.effective_ranges(state);
                xv.zoom_at(factor, screen_frac(pos.x, xl, xr));
                yv.zoom_at(factor, 1.0 - screen_frac(pos.y, yt, yb));
                state.xv = Some(xv); state.yv = Some(yv);
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
                let (xv, yv) = self.effective_ranges(state);
                state.left_drag = Some((pos, xv, yv));
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
                let (xv, yv) = self.effective_ranges(state);
                state.right_drag = Some((pos, xv, yv));
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
                if let Some((start, sx, sy)) = state.left_drag {
                    let dx = (pos.x - start.x) / (xr - xl);
                    let dy = (pos.y - start.y) / (yb - yt);
                    let mut xv = sx; xv.pan_frac(dx);
                    let mut yv = sy; yv.pan_frac(-dy);
                    state.xv = Some(xv); state.yv = Some(yv);
                    return (canvas::event::Status::Captured, None);
                }
                if let Some((start, sx, sy)) = state.right_drag {
                    let dx = pos.x - start.x;
                    let dy = pos.y - start.y;
                    let xf = 2.0_f32.powf(dx / DRAG_ZOOM_RATE);
                    let yf = 2.0_f32.powf(-dy / DRAG_ZOOM_RATE);
                    let mut xv = sx; xv.zoom_at(xf, screen_frac(start.x, xl, xr));
                    let mut yv = sy; yv.zoom_at(yf, 1.0 - screen_frac(start.y, yt, yb));
                    state.xv = Some(xv); state.yv = Some(yv);
                    return (canvas::event::Status::Captured, None);
                }
                (canvas::event::Status::Ignored, None)
            }
            _ => (canvas::event::Status::Ignored, None),
        }
    }

    fn draw(
        &self, state: &AmperogramState, renderer: &Renderer, _theme: &Theme,
        bounds: Rectangle, cursor: mouse::Cursor,
    ) -> Vec<Geometry> {
        let mut frame = Frame::new(renderer, bounds.size());
        let (w, h) = (bounds.width, bounds.height);

        if self.points.is_empty() {
            dt(&mut frame, Point::new(w / 2.0 - 25.0, h / 2.0), "No data", COL_DIM, 13.0);
            return vec![frame.into_geometry()];
        }

        let xl = MARGIN_L;
        let xr = w - MARGIN_R;
        let yt = MARGIN_T;
        let yb = h - MARGIN_B;

        let (xv, yv) = self.effective_ranges(state);

        let x_step = nice_step(xv.span(), 5);
        if x_step > 0.0 {
            let mut g = (xv.lo / x_step).ceil() * x_step;
            while g <= xv.hi {
                let x = lerp(g, xv.lo, xv.hi, xl, xr);
                dl(&mut frame, Point::new(x, yt), Point::new(x, yb), COL_GRID, 0.5);
                dt(&mut frame, Point::new(x - 10.0, yb + 3.0), &format!("{:.0}", g), COL_DIM, 9.0);
                g += x_step;
            }
        }
        let y_step = nice_step(yv.span(), 4);
        if y_step > 0.0 {
            let mut g = (yv.lo / y_step).ceil() * y_step;
            while g <= yv.hi {
                let y = lerp(g, yv.hi, yv.lo, yt, yb);
                dl(&mut frame, Point::new(xl, y), Point::new(xr, y), COL_GRID, 0.5);
                dt(&mut frame, Point::new(2.0, y - 5.0), &format!("{:.1}", g), COL_AMP, 9.0);
                g += y_step;
            }
        }

        dt(&mut frame, Point::new(2.0, yt - 2.0), "I (uA)", COL_AMP, 10.0);
        dt(&mut frame, Point::new((xl + xr) / 2.0 - 15.0, yb + 3.0), "t (ms)", COL_AMP, 10.0);

        if let Some(rpts) = self.reference {
            let rp: Vec<Point> = rpts.iter().map(|p| Point::new(
                lerp(p.t_ms, xv.lo, xv.hi, xl, xr),
                lerp(p.i_ua, yv.hi, yv.lo, yt, yb),
            )).collect();
            draw_polyline(&mut frame, &rp, COL_REF, 1.5);
        }

        let pts: Vec<Point> = self.points.iter().map(|p| Point::new(
            lerp(p.t_ms, xv.lo, xv.hi, xl, xr),
            lerp(p.i_ua, yv.hi, yv.lo, yt, yb),
        )).collect();
        draw_polyline(&mut frame, &pts, COL_AMP, 2.0);
        draw_dots(&mut frame, &pts, COL_AMP, 2.5);

        if let Some(pos) = cursor.position_in(bounds) {
            if pos.x >= xl && pos.x <= xr && pos.y >= yt && pos.y <= yb {
                let t = lerp(pos.x, xl, xr, xv.lo, xv.hi);
                let i = lerp(pos.y, yt, yb, yv.hi, yv.lo);
                dl(&mut frame, Point::new(pos.x, yt), Point::new(pos.x, yb),
                   Color { a: 0.3, ..COL_AXIS }, 1.0);
                dl(&mut frame, Point::new(xl, pos.y), Point::new(xr, pos.y),
                   Color { a: 0.3, ..COL_AXIS }, 1.0);
                dt(&mut frame, Point::new(pos.x + 4.0, pos.y - 14.0),
                   &format!("{:.0}ms, {:.2}uA", t, i), COL_AXIS, 10.0);
            }
        }

        vec![frame.into_geometry()]
    }
}

/* ---- Chlorine (multi-step chronoamperometry) ---- */

#[derive(Default)]
pub struct ChlorineState {
    xv: Option<Vr>,
    yv: Option<Vr>,
    left_drag: Option<(Point, Vr, Vr)>,
    right_drag: Option<(Point, Vr, Vr)>,
}

pub struct ChlorinePlot<'a> {
    pub points: &'a [ClPoint],
    pub reference: Option<&'a [ClPoint]>,
}

impl ChlorinePlot<'_> {
    fn auto_view(&self) -> Option<(Vr, Vr)> {
        let valid: Vec<_> = self.points.iter()
            .filter(|p| p.t_ms.is_finite() && p.i_ua.is_finite())
            .collect();
        if valid.is_empty() { return None; }
        let (xlo, xhi) = valid.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
            (lo.min(p.t_ms), hi.max(p.t_ms))
        });
        let (ylo, yhi) = valid.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), p| {
            (lo.min(p.i_ua), hi.max(p.i_ua))
        });
        let xpad = (xhi - xlo).max(100.0) * 0.05;
        let ypad = (yhi - ylo).max(0.001) * 0.12;
        Some((Vr::new(xlo - xpad, xhi + xpad), Vr::new(ylo - ypad, yhi + ypad)))
    }

    fn effective_ranges(&self, state: &ChlorineState) -> (Vr, Vr) {
        let auto = self.auto_view().unwrap_or((Vr::new(0.0, 20000.0), Vr::new(-1.0, 1.0)));
        (state.xv.unwrap_or(auto.0), state.yv.unwrap_or(auto.1))
    }
}

impl<'a> canvas::Program<Message> for ChlorinePlot<'a> {
    type State = ChlorineState;

    fn update(
        &self, state: &mut ChlorineState, event: Event,
        bounds: Rectangle, cursor: mouse::Cursor,
    ) -> (canvas::event::Status, Option<Message>) {
        if let Event::Mouse(mouse::Event::ButtonReleased(_)) = &event {
            let was_right = state.right_drag.is_some();
            if was_right {
                if let Some(pos) = cursor.position_in(bounds) {
                    let (start, _, _) = state.right_drag.unwrap();
                    let dist = ((pos.x - start.x).powi(2) + (pos.y - start.y).powi(2)).sqrt();
                    if dist < 3.0 { state.xv = None; state.yv = None; }
                }
            }
            state.left_drag = None;
            state.right_drag = None;
            if was_right { return (canvas::event::Status::Captured, None); }
        }

        let Some(pos) = cursor.position_in(bounds) else {
            return (canvas::event::Status::Ignored, None);
        };
        let xl = MARGIN_L;
        let xr = bounds.width - MARGIN_R;
        let yt = MARGIN_T;
        let yb = bounds.height - MARGIN_B;

        match event {
            Event::Mouse(mouse::Event::WheelScrolled { delta }) => {
                let dy = match delta {
                    mouse::ScrollDelta::Lines { y, .. } => y,
                    mouse::ScrollDelta::Pixels { y, .. } => y / 40.0,
                };
                let factor = ZOOM_FACTOR.powf(dy);
                let (mut xv, mut yv) = self.effective_ranges(state);
                xv.zoom_at(factor, screen_frac(pos.x, xl, xr));
                yv.zoom_at(factor, 1.0 - screen_frac(pos.y, yt, yb));
                state.xv = Some(xv); state.yv = Some(yv);
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
                let (xv, yv) = self.effective_ranges(state);
                state.left_drag = Some((pos, xv, yv));
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
                let (xv, yv) = self.effective_ranges(state);
                state.right_drag = Some((pos, xv, yv));
                (canvas::event::Status::Captured, None)
            }
            Event::Mouse(mouse::Event::CursorMoved { .. }) => {
                if let Some((start, sx, sy)) = state.left_drag {
                    let dx = (pos.x - start.x) / (xr - xl);
                    let dy = (pos.y - start.y) / (yb - yt);
                    let mut xv = sx; xv.pan_frac(dx);
                    let mut yv = sy; yv.pan_frac(-dy);
                    state.xv = Some(xv); state.yv = Some(yv);
                    return (canvas::event::Status::Captured, None);
                }
                if let Some((start, sx, sy)) = state.right_drag {
                    let dx = pos.x - start.x;
                    let dy = pos.y - start.y;
                    let xf = 2.0_f32.powf(dx / DRAG_ZOOM_RATE);
                    let yf = 2.0_f32.powf(-dy / DRAG_ZOOM_RATE);
                    let mut xv = sx; xv.zoom_at(xf, screen_frac(start.x, xl, xr));
                    let mut yv = sy; yv.zoom_at(yf, 1.0 - screen_frac(start.y, yt, yb));
                    state.xv = Some(xv); state.yv = Some(yv);
                    return (canvas::event::Status::Captured, None);
                }
                (canvas::event::Status::Ignored, None)
            }
            _ => (canvas::event::Status::Ignored, None),
        }
    }

    fn draw(
        &self, state: &ChlorineState, renderer: &Renderer, _theme: &Theme,
        bounds: Rectangle, cursor: mouse::Cursor,
    ) -> Vec<Geometry> {
        let mut frame = Frame::new(renderer, bounds.size());
        let (w, h) = (bounds.width, bounds.height);

        if self.points.is_empty() {
            dt(&mut frame, Point::new(w / 2.0 - 25.0, h / 2.0), "No data", COL_DIM, 13.0);
            return vec![frame.into_geometry()];
        }

        let xl = MARGIN_L;
        let xr = w - MARGIN_R;
        let yt = MARGIN_T;
        let yb = h - MARGIN_B;

        let (xv, yv) = self.effective_ranges(state);

        let x_step = nice_step(xv.span(), 5);
        if x_step > 0.0 {
            let mut g = (xv.lo / x_step).ceil() * x_step;
            while g <= xv.hi {
                let x = lerp(g, xv.lo, xv.hi, xl, xr);
                dl(&mut frame, Point::new(x, yt), Point::new(x, yb), COL_GRID, 0.5);
                dt(&mut frame, Point::new(x - 10.0, yb + 3.0), &format!("{:.0}", g), COL_DIM, 9.0);
                g += x_step;
            }
        }
        let y_step = nice_step(yv.span(), 4);
        if y_step > 0.0 {
            let mut g = (yv.lo / y_step).ceil() * y_step;
            while g <= yv.hi {
                let y = lerp(g, yv.hi, yv.lo, yt, yb);
                dl(&mut frame, Point::new(xl, y), Point::new(xr, y), COL_GRID, 0.5);
                dt(&mut frame, Point::new(2.0, y - 5.0), &format!("{:.1}", g), COL_AXIS, 9.0);
                g += y_step;
            }
        }

        dt(&mut frame, Point::new(2.0, yt - 2.0), "I (uA)", COL_AXIS, 10.0);
        dt(&mut frame, Point::new((xl + xr) / 2.0 - 15.0, yb + 3.0), "t (ms)", COL_AXIS, 10.0);

        if let Some(rpts) = self.reference {
            let rf: Vec<Point> = rpts.iter()
                .filter(|p| p.phase == 1)
                .map(|p| Point::new(lerp(p.t_ms, xv.lo, xv.hi, xl, xr), lerp(p.i_ua, yv.hi, yv.lo, yt, yb)))
                .collect();
            let rt: Vec<Point> = rpts.iter()
                .filter(|p| p.phase == 2)
                .map(|p| Point::new(lerp(p.t_ms, xv.lo, xv.hi, xl, xr), lerp(p.i_ua, yv.hi, yv.lo, yt, yb)))
                .collect();
            draw_polyline(&mut frame, &rf, COL_REF, 1.5);
            draw_polyline(&mut frame, &rt, COL_REF, 1.5);
        }

        let free_pts: Vec<Point> = self.points.iter()
            .filter(|p| p.phase == 1)
            .map(|p| Point::new(lerp(p.t_ms, xv.lo, xv.hi, xl, xr), lerp(p.i_ua, yv.hi, yv.lo, yt, yb)))
            .collect();
        let total_pts: Vec<Point> = self.points.iter()
            .filter(|p| p.phase == 2)
            .map(|p| Point::new(lerp(p.t_ms, xv.lo, xv.hi, xl, xr), lerp(p.i_ua, yv.hi, yv.lo, yt, yb)))
            .collect();

        draw_polyline(&mut frame, &free_pts, COL_CL_FREE, 2.0);
        draw_dots(&mut frame, &free_pts, COL_CL_FREE, 3.0);
        draw_polyline(&mut frame, &total_pts, COL_CL_TOTAL, 2.0);
        draw_dots(&mut frame, &total_pts, COL_CL_TOTAL, 3.0);

        /* phase boundary line */
        if let (Some(last_free), Some(first_total)) = (
            self.points.iter().filter(|p| p.phase == 1).last(),
            self.points.iter().find(|p| p.phase == 2),
        ) {
            let boundary_t = (last_free.t_ms + first_total.t_ms) / 2.0;
            let bx = lerp(boundary_t, xv.lo, xv.hi, xl, xr);
            if bx > xl && bx < xr {
                dl(&mut frame, Point::new(bx, yt), Point::new(bx, yb), COL_DIM, 1.0);
            }
        }

        /* legend */
        dt(&mut frame, Point::new(xl + 5.0, yt + 2.0), "Free", COL_CL_FREE, 10.0);
        dt(&mut frame, Point::new(xl + 45.0, yt + 2.0), "Total", COL_CL_TOTAL, 10.0);

        if let Some(pos) = cursor.position_in(bounds) {
            if pos.x >= xl && pos.x <= xr && pos.y >= yt && pos.y <= yb {
                let t = lerp(pos.x, xl, xr, xv.lo, xv.hi);
                let i = lerp(pos.y, yt, yb, yv.hi, yv.lo);
                dl(&mut frame, Point::new(pos.x, yt), Point::new(pos.x, yb),
                   Color { a: 0.3, ..COL_AXIS }, 1.0);
                dl(&mut frame, Point::new(xl, pos.y), Point::new(xr, pos.y),
                   Color { a: 0.3, ..COL_AXIS }, 1.0);
                dt(&mut frame, Point::new(pos.x + 4.0, pos.y - 14.0),
                   &format!("{:.0}ms, {:.2}uA", t, i), COL_AXIS, 10.0);
            }
        }

        vec![frame.into_geometry()]
    }
}
