Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-plotters/src/chart.rs

//! **Line · bar · scatter** — the everyday XY chart, one themeable Elm [`Facet`].
//! Data goes in as the simplest possible model — a `Vec` of named series, each a
//! `Vec<(x, y)>` — and everything else (axis bounds, gridlines, the legend, the
//! hover tooltip) is *derived*. The chart kind is a single field in the model, so
//! flipping line↔bar↔scatter is one `Msg` and is fully observable headlessly.
//!
//! Series are colour-coded from the [categorical palette](crate::palette) in fixed
//! slot order (a small validated set, consistent across light/dark themes) — never
//! a per-string hash — so identity reads the same every render and the colours stay
//! CVD-separable. The data→geometry math ([`ChartView::x_bounds`] /
//! [`ChartView::y_bounds`]) is pure and unit-tested with no GPU.

use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::{Elm, FacetCaps, Semantics, a11y_node, theme};
use serde::{Deserialize, Serialize};

use crate::palette;

/// Which mark the chart draws. One field in the model — switching is one [`ChartMsg`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChartKind {
    /// Points joined by a 2px polyline per series (change over an ordered x).
    #[default]
    Line,
    /// Grouped vertical bars, one cluster per category (point index), anchored to 0.
    Bar,
    /// Unconnected markers at each (x, y) — identity + correlation, no ordering.
    Scatter,
}

impl ChartKind {
    /// Stable lowercase tag (matches the serde representation) for `state_json`.
    pub fn as_str(self) -> &'static str {
        match self {
            ChartKind::Line => "line",
            ChartKind::Bar => "bar",
            ChartKind::Scatter => "scatter",
        }
    }
}

/// One named data series: a label (for legend + a11y) and its `(x, y)` points.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Series {
    pub label: String,
    pub points: Vec<(f64, f64)>,
}

impl Series {
    pub fn new(label: impl Into<String>, points: Vec<(f64, f64)>) -> Self {
        Self { label: label.into(), points }
    }
}

/// Persistent, observable interaction state (FC-3): the current chart kind and
/// which `(series, point)` the pointer last hovered. Serialized so it round-trips
/// through the headless JSON seam.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ChartState {
    pub kind: ChartKind,
    /// `(series index, point index)` under the pointer, or `None`.
    pub hover: Option<(usize, usize)>,
}

/// FC-2 input surface. Externally-tagged serde so a robot posts JSON:
/// `{"set_kind":"bar"}` · `{"hover":[0,1]}` · `{"hover":null}`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChartMsg {
    /// Switch the drawn mark (line ↔ bar ↔ scatter).
    SetKind(ChartKind),
    /// Set (or clear) the hovered `(series, point)`.
    Hover(Option<(usize, usize)>),
}

/// FC-8: the chart does no I/O — an uninhabited effect.
#[derive(Clone, Debug)]
pub enum ChartEffect {}

/// A line/bar/scatter chart over a set of named series.
pub struct ChartView {
    pub title: String,
    pub series: Vec<Series>,
    pub state: ChartState,
}

impl ChartView {
    /// A new chart (defaults to [`ChartKind::Line`]).
    pub fn new(title: impl Into<String>, series: Vec<Series>) -> Self {
        Self { title: title.into(), series, state: ChartState::default() }
    }

    /// Start on a specific chart kind.
    pub fn with_kind(mut self, kind: ChartKind) -> Self {
        self.state.kind = kind;
        self
    }

    /// FC-3: read the complete observable interaction state.
    pub fn state(&self) -> &ChartState {
        &self.state
    }

    /// FC-2/FC-8: the single mutation path. Apply one [`ChartMsg`]; no effects.
    pub fn update(&mut self, msg: ChartMsg) -> Vec<ChartEffect> {
        match msg {
            ChartMsg::SetKind(k) => self.state.kind = k,
            ChartMsg::Hover(h) => self.state.hover = h,
        }
        Vec::new()
    }

    /// The widest category count across all series (bar clusters / x tick count).
    fn max_len(&self) -> usize {
        self.series.iter().map(|s| s.points.len()).max().unwrap_or(0)
    }

    /// The x extent across every finite point. `None` when there is no data.
    /// Bar charts ignore this (they lay out on category index), but it is still a
    /// pure, testable read of the input.
    pub fn x_bounds(&self) -> Option<(f64, f64)> {
        fold_bounds(self.series.iter().flat_map(|s| s.points.iter().map(|p| p.0)))
    }

    /// The y extent across every finite point. For [`ChartKind::Bar`] the range is
    /// extended to include the `0` baseline so bar heights read honestly.
    pub fn y_bounds(&self) -> Option<(f64, f64)> {
        let mut b = fold_bounds(self.series.iter().flat_map(|s| s.points.iter().map(|p| p.1)))?;
        if self.state.kind == ChartKind::Bar {
            b.0 = b.0.min(0.0);
            b.1 = b.1.max(0.0);
        }
        Some(b)
    }

    /// CSV of every point (the `copy()` payload): `series,x,y`.
    fn to_csv(&self) -> String {
        let mut s = String::from("series,x,y\n");
        for ser in &self.series {
            for (x, y) in &ser.points {
                s.push_str(&format!("{},{},{}\n", ser.label, x, y));
            }
        }
        s
    }
}

// ── typed copy (§16) — the chart dataset as CSV Text ──────────────────────────
impl CopySource for ChartView {
    fn copy_kinds(&self) -> &[ClipKind] {
        &[ClipKind::Text]
    }
    fn copy_payload(&self) -> Option<ClipPayload> {
        let csv = self.to_csv();
        (self.series.iter().any(|s| !s.points.is_empty())).then_some(ClipPayload::Text(csv))
    }
}

impl ChartView {
    /// FC-9 pure view: paints axes/gridlines, the series (per the stored kind),
    /// legend, and a tooltip from the *persisted* hover — and **returns** the
    /// hovered-point change as a `Msg`. It never mutates `self`.
    fn render(&self, ui: &mut Ui) -> Vec<ChartMsg> {
        let th = theme(ui);
        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);

        let (Some((xmin, xmax)), Some((ymin, ymax))) = (self.x_bounds(), self.y_bounds()) else {
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit(
                "facett-plotters::ChartView::ui",
                "ui_render",
                self.series.is_empty() || self.max_len() == 0,
                &format!("drew=empty_hint kind={} series={}", self.state.kind.as_str(), self.series.len()),
            );
            painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
            return hover_msg(self.state.hover, None);
        };

        // Plot area — leave a left gutter for y labels and a bottom gutter for x.
        let plot = Rect::from_min_max(
            Pos2::new(rect.left() + 44.0, rect.top() + 10.0),
            Pos2::new(rect.right() - 12.0, rect.bottom() - 24.0),
        );
        let yspan = (ymax - ymin).max(1e-9);
        let y_of = |v: f64| plot.bottom() - ((v - ymin) / yspan) as f32 * plot.height();

        // ── axes + horizontal gridlines with value ticks ─────────────────────
        painter.line_segment([plot.left_bottom(), plot.right_bottom()], Stroke::new(1.0_f32, th.text_dim));
        painter.line_segment([plot.left_top(), plot.left_bottom()], Stroke::new(1.0_f32, th.text_dim));
        for i in 0..=4 {
            let t = i as f64 / 4.0;
            let v = ymin + t * (ymax - ymin);
            let y = y_of(v);
            painter.line_segment(
                [Pos2::new(plot.left(), y), Pos2::new(plot.right(), y)],
                Stroke::new(0.5_f32, th.text_dim.gamma_multiply(0.4)),
            );
            painter.text(
                Pos2::new(plot.left() - 4.0, y),
                Align2::RIGHT_CENTER,
                format!("{v:.1}"),
                FontId::proportional(9.0),
                th.text_dim,
            );
        }

        let hover = resp.hover_pos();
        let base = ui.id().with("chart");
        let mut new_hover: Option<(usize, usize)> = None;

        match self.state.kind {
            ChartKind::Bar => self.paint_bars(&painter, ui, base, plot, &y_of, hover, &mut new_hover, &th),
            ChartKind::Line | ChartKind::Scatter => {
                let xspan = (xmax - xmin).max(1e-9);
                let x_of = |v: f64| plot.left() + ((v - xmin) / xspan) as f32 * plot.width();
                self.paint_xy(&painter, ui, base, plot, &x_of, &y_of, hover, &mut new_hover, &th);
                // x axis end labels.
                painter.text(plot.left_bottom() + vec2(0.0, 12.0), Align2::LEFT_CENTER, format!("{xmin:.1}"), FontId::proportional(9.0), th.text_dim);
                painter.text(plot.right_bottom() + vec2(0.0, 12.0), Align2::RIGHT_CENTER, format!("{xmax:.1}"), FontId::proportional(9.0), th.text_dim);
            }
        }

        // ── legend (top-right) ───────────────────────────────────────────────
        self.paint_legend(&painter, plot, &th);

        // ── tooltip from the PERSISTED hover (keeps render pure) ──────────────
        if let Some(hp) = hover
            && let Some((si, pi)) = self.state.hover
            && let Some((x, y)) = self.series.get(si).and_then(|s| s.points.get(pi))
        {
            let label = &self.series[si].label;
            crate::boxplot::paint_tooltip(&painter, &th, hp, &format!("{label}\nx {x:.3}\ny {y:.3}"));
        }

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-plotters::ChartView::ui",
            "ui_render",
            !self.series.is_empty() && self.max_len() > 0,
            &format!("kind={} series={} points={}", self.state.kind.as_str(), self.series.len(), self.max_len()),
        );
        hover_msg(self.state.hover, new_hover)
    }

    /// Line/scatter marks in data space. `_ui`/`base` carry the a11y tree.
    #[allow(clippy::too_many_arguments)]
    fn paint_xy(
        &self,
        painter: &egui::Painter,
        ui: &mut Ui,
        base: egui::Id,
        plot: Rect,
        x_of: &dyn Fn(f64) -> f32,
        y_of: &dyn Fn(f64) -> f32,
        hover: Option<Pos2>,
        new_hover: &mut Option<(usize, usize)>,
        th: &facett_core::Theme,
    ) {
        let scatter = self.state.kind == ChartKind::Scatter;
        for (si, ser) in self.series.iter().enumerate() {
            let color = palette::series_color(th, si);
            let pts: Vec<Pos2> = ser.points.iter().map(|&(x, y)| Pos2::new(x_of(x), y_of(y))).collect();
            if !scatter && pts.len() >= 2 {
                for w in pts.windows(2) {
                    painter.line_segment([w[0], w[1]], Stroke::new(2.0_f32, color));
                }
            }
            for (pi, &p) in pts.iter().enumerate() {
                if scatter {
                    painter.circle_filled(p, 4.0, color);
                } else {
                    painter.circle_filled(p, 3.0, color);
                }
                if let Some(hp) = hover && p.distance(hp) <= 8.0 {
                    *new_hover = Some((si, pi));
                }
            }
            // FC-4: one queryable node per series over its centroid, carrying the
            // point count as numeric_value; keyed on the (stable) series label.
            if let Some(first) = pts.first() {
                let last = *pts.last().unwrap();
                let node = Rect::from_two_pos(*first, last).expand(4.0).intersect(plot);
                a11y_node(
                    ui,
                    base,
                    &ser.label,
                    Sense::hover(),
                    node,
                    Semantics::image(ser.label.clone()).value(ser.points.len() as f64),
                );
            }
        }
    }

    /// Grouped bars: one cluster per category (point index), one bar per series.
    #[allow(clippy::too_many_arguments)]
    fn paint_bars(
        &self,
        painter: &egui::Painter,
        ui: &mut Ui,
        base: egui::Id,
        plot: Rect,
        y_of: &dyn Fn(f64) -> f32,
        hover: Option<Pos2>,
        new_hover: &mut Option<(usize, usize)>,
        th: &facett_core::Theme,
    ) {
        let cats = self.max_len();
        let n = self.series.len();
        if cats == 0 || n == 0 {
            return;
        }
        let slot = plot.width() / cats as f32;
        let group_w = (slot * 0.8).min(slot - 2.0).max(1.0);
        let bar_w = (group_w / n as f32).max(1.0);
        let base_y = y_of(0.0);
        for ci in 0..cats {
            let cx = plot.left() + slot * (ci as f32 + 0.5);
            for (si, ser) in self.series.iter().enumerate() {
                let Some(&(_, y)) = ser.points.get(ci) else { continue };
                let color = palette::series_color(th, si);
                let left = cx - group_w * 0.5 + bar_w * si as f32;
                let top = y_of(y);
                let r = Rect::from_min_max(
                    Pos2::new(left + 1.0, top.min(base_y)),
                    Pos2::new(left + bar_w - 1.0, top.max(base_y)),
                );
                painter.rect_filled(r, egui::CornerRadius::same(1), color);
                a11y_node(
                    ui,
                    base,
                    format!("{} · cat {ci}", ser.label),
                    Sense::hover(),
                    r,
                    Semantics::image(format!("{} · {ci}", ser.label)).value(y),
                );
                if let Some(hp) = hover && r.contains(hp) {
                    *new_hover = Some((si, ci));
                }
            }
        }
    }

    /// Legend: one swatch + label per series, top-right, in slot order. Text wears
    /// the theme's ink; only the swatch carries the series colour (dataviz rule).
    fn paint_legend(&self, painter: &egui::Painter, plot: Rect, th: &facett_core::Theme) {
        if self.series.len() < 2 {
            return; // a single series is named by the title — no legend box.
        }
        let mut y = plot.top() + 6.0;
        let x = plot.right() - 120.0;
        for (si, ser) in self.series.iter().enumerate() {
            let color = palette::series_color(th, si);
            let sw = Rect::from_min_size(Pos2::new(x, y), vec2(10.0, 10.0));
            painter.rect_filled(sw, egui::CornerRadius::same(2), color);
            painter.text(Pos2::new(x + 14.0, y + 5.0), Align2::LEFT_CENTER, &ser.label, FontId::proportional(10.0), th.text);
            y += 15.0;
        }
    }
}

/// Fold an iterator of scalars into `(min, max)` over finite values only.
fn fold_bounds(it: impl Iterator<Item = f64>) -> Option<(f64, f64)> {
    let mut r: Option<(f64, f64)> = None;
    for x in it {
        if x.is_finite() {
            r = Some(match r {
                None => (x, x),
                Some((lo, hi)) => (lo.min(x), hi.max(x)),
            });
        }
    }
    r
}

/// Emit a `Hover` message only when the hovered point actually changed.
fn hover_msg(current: Option<(usize, usize)>, next: Option<(usize, usize)>) -> Vec<ChartMsg> {
    if current != next { vec![ChartMsg::Hover(next)] } else { Vec::new() }
}

impl Elm for ChartView {
    type Model = ChartState;
    type Msg = ChartMsg;
    type Effect = ChartEffect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &ChartState {
        &self.state
    }
    fn update(&mut self, msg: ChartMsg) -> Vec<ChartEffect> {
        ChartView::update(self, msg)
    }
    fn view(&self, ui: &mut Ui) -> Vec<ChartMsg> {
        self.render(ui)
    }
}

// Form 3 (`custom_state_json`): the chart publishes a RICHER state than the raw
// model — derived axis bounds + a per-series summary a robot reads without a GPU —
// and routes `update_json` straight through `update` (so `{"set_kind":"bar"}`
// drives it headlessly).
facett_core::impl_facet_via_elm!(ChartView, custom_state_json, {
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().copyable().resizable()
    }
    fn copy(&mut self) -> Option<String> {
        Some(self.to_csv())
    }
    fn kind(&self) -> &'static str {
        "plotters-chart"
    }
    fn update_json(&mut self, msg_json: &str) {
        if let Ok(msg) = serde_json::from_str::<ChartMsg>(msg_json) {
            let _ = <Self as facett_core::Elm>::update(self, msg);
        }
    }
    fn state_json(&self) -> serde_json::Value {
        let (xmin, xmax) = self.x_bounds().unzip();
        let (ymin, ymax) = self.y_bounds().unzip();
        serde_json::json!({
            "kind": self.state.kind.as_str(),
            "series_count": self.series.len(),
            "hover": self.state.hover,
            "x_bounds": [xmin, xmax],
            "y_bounds": [ymin, ymax],
            "series": self.series.iter().map(|s| serde_json::json!({
                "label": s.label, "points": s.points.len(),
            })).collect::<Vec<_>>(),
        })
    }
});

#[cfg(test)]
mod tests {
    use super::*;

    fn sample() -> ChartView {
        ChartView::new(
            "Sales",
            vec![
                Series::new("north", vec![(0.0, 1.0), (1.0, 3.0), (2.0, 2.0)]),
                Series::new("south", vec![(0.0, 2.0), (1.0, 1.0), (2.0, 4.0)]),
            ],
        )
    }

    #[test]
    fn x_and_y_bounds_span_all_series() {
        let v = sample();
        assert_eq!(v.x_bounds(), Some((0.0, 2.0)));
        assert_eq!(v.y_bounds(), Some((1.0, 4.0)));
    }

    #[test]
    fn bar_bounds_include_zero_baseline() {
        let v = ChartView::new("t", vec![Series::new("a", vec![(0.0, 5.0), (1.0, 9.0)])])
            .with_kind(ChartKind::Bar);
        // min extends down to the 0 baseline even though the data starts at 5.
        assert_eq!(v.y_bounds(), Some((0.0, 9.0)));
    }

    #[test]
    fn bounds_ignore_nonfinite_and_empty() {
        assert_eq!(ChartView::new("t", vec![]).x_bounds(), None);
        let v = ChartView::new("t", vec![Series::new("a", vec![(f64::NAN, 1.0), (2.0, f64::INFINITY)])]);
        assert_eq!(v.x_bounds(), Some((2.0, 2.0)));
        assert_eq!(v.y_bounds(), Some((1.0, 1.0)));
    }

    #[test]
    fn csv_has_header_and_a_row_per_point() {
        let v = sample();
        let csv = v.to_csv();
        assert!(csv.starts_with("series,x,y\n"));
        assert_eq!(csv.lines().count(), 7); // header + 6 points
    }

    #[test]
    fn typed_copy_is_the_dataset_csv() {
        use facett_core::clip::{ClipKind, CopySource};
        let v = sample();
        let p = v.copy_payload().expect("non-empty chart copies");
        assert_eq!(p.kind(), ClipKind::Text);
        assert_eq!(p.as_text(), v.to_csv());
        // an all-empty chart yields nothing.
        assert!(ChartView::new("t", vec![Series::new("a", vec![])]).copy_payload().is_none());
    }

    // ── Elm (FC-2/FC-3) harness properties ────────────────────────────────────
    #[test]
    fn switching_kind_is_observable() {
        let mut v = sample();
        assert_eq!(v.state().kind, ChartKind::Line);
        let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::SetKind(ChartKind::Bar)]);
        assert_eq!(snap.kind, ChartKind::Bar, "the kind switch is observable in the snapshot");
        let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::SetKind(ChartKind::Scatter)]);
        assert_eq!(snap.kind, ChartKind::Scatter);
    }

    #[test]
    fn hover_transitions_are_observable() {
        let mut v = sample();
        let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::Hover(Some((1, 2)))]);
        assert_eq!(snap.hover, Some((1, 2)));
        let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::Hover(None)]);
        assert_eq!(snap.hover, None);
    }

    #[test]
    fn drive_produces_no_effects() {
        let mut v = sample();
        let fx = facett_core::harness::drive(
            &mut v,
            [ChartMsg::SetKind(ChartKind::Bar), ChartMsg::Hover(Some((0, 0)))],
        );
        assert!(fx.is_empty(), "the chart does no I/O");
    }

    #[test]
    fn msg_round_trips_through_json() {
        // the exact JSON shapes a robot posts through update_json.
        let m: ChartMsg = serde_json::from_str(r#"{"set_kind":"scatter"}"#).unwrap();
        assert_eq!(m, ChartMsg::SetKind(ChartKind::Scatter));
        let m: ChartMsg = serde_json::from_str(r#"{"hover":[0,1]}"#).unwrap();
        assert_eq!(m, ChartMsg::Hover(Some((0, 1))));
        let m: ChartMsg = serde_json::from_str(r#"{"hover":null}"#).unwrap();
        assert_eq!(m, ChartMsg::Hover(None));
    }

    #[test]
    fn state_serde_round_trips() {
        let st = ChartState { kind: ChartKind::Bar, hover: Some((2, 3)) };
        let back: ChartState = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
        assert_eq!(st, back);
    }
}