Skip to main content

facett_plotters/
chart.rs

1//! **Line · bar · scatter** — the everyday XY chart, one themeable Elm [`Facet`].
2//! Data goes in as the simplest possible model — a `Vec` of named series, each a
3//! `Vec<(x, y)>` — and everything else (axis bounds, gridlines, the legend, the
4//! hover tooltip) is *derived*. The chart kind is a single field in the model, so
5//! flipping line↔bar↔scatter is one `Msg` and is fully observable headlessly.
6//!
7//! Series are colour-coded from the [categorical palette](crate::palette) in fixed
8//! slot order (a small validated set, consistent across light/dark themes) — never
9//! a per-string hash — so identity reads the same every render and the colours stay
10//! CVD-separable. The data→geometry math ([`ChartView::x_bounds`] /
11//! [`ChartView::y_bounds`]) is pure and unit-tested with no GPU.
12
13use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
14use facett_core::clip::{ClipKind, ClipPayload, CopySource};
15use facett_core::{Elm, FacetCaps, Semantics, a11y_node, theme};
16use serde::{Deserialize, Serialize};
17
18use crate::palette;
19
20/// Which mark the chart draws. One field in the model — switching is one [`ChartMsg`].
21#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ChartKind {
24    /// Points joined by a 2px polyline per series (change over an ordered x).
25    #[default]
26    Line,
27    /// Grouped vertical bars, one cluster per category (point index), anchored to 0.
28    Bar,
29    /// Unconnected markers at each (x, y) — identity + correlation, no ordering.
30    Scatter,
31}
32
33impl ChartKind {
34    /// Stable lowercase tag (matches the serde representation) for `state_json`.
35    pub fn as_str(self) -> &'static str {
36        match self {
37            ChartKind::Line => "line",
38            ChartKind::Bar => "bar",
39            ChartKind::Scatter => "scatter",
40        }
41    }
42}
43
44/// One named data series: a label (for legend + a11y) and its `(x, y)` points.
45#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46pub struct Series {
47    pub label: String,
48    pub points: Vec<(f64, f64)>,
49}
50
51impl Series {
52    pub fn new(label: impl Into<String>, points: Vec<(f64, f64)>) -> Self {
53        Self { label: label.into(), points }
54    }
55}
56
57/// Persistent, observable interaction state (FC-3): the current chart kind and
58/// which `(series, point)` the pointer last hovered. Serialized so it round-trips
59/// through the headless JSON seam.
60#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
61pub struct ChartState {
62    pub kind: ChartKind,
63    /// `(series index, point index)` under the pointer, or `None`.
64    pub hover: Option<(usize, usize)>,
65}
66
67/// FC-2 input surface. Externally-tagged serde so a robot posts JSON:
68/// `{"set_kind":"bar"}` · `{"hover":[0,1]}` · `{"hover":null}`.
69#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum ChartMsg {
72    /// Switch the drawn mark (line ↔ bar ↔ scatter).
73    SetKind(ChartKind),
74    /// Set (or clear) the hovered `(series, point)`.
75    Hover(Option<(usize, usize)>),
76}
77
78/// FC-8: the chart does no I/O — an uninhabited effect.
79#[derive(Clone, Debug)]
80pub enum ChartEffect {}
81
82/// A line/bar/scatter chart over a set of named series.
83pub struct ChartView {
84    pub title: String,
85    pub series: Vec<Series>,
86    pub state: ChartState,
87}
88
89impl ChartView {
90    /// A new chart (defaults to [`ChartKind::Line`]).
91    pub fn new(title: impl Into<String>, series: Vec<Series>) -> Self {
92        Self { title: title.into(), series, state: ChartState::default() }
93    }
94
95    /// Start on a specific chart kind.
96    pub fn with_kind(mut self, kind: ChartKind) -> Self {
97        self.state.kind = kind;
98        self
99    }
100
101    /// FC-3: read the complete observable interaction state.
102    pub fn state(&self) -> &ChartState {
103        &self.state
104    }
105
106    /// FC-2/FC-8: the single mutation path. Apply one [`ChartMsg`]; no effects.
107    pub fn update(&mut self, msg: ChartMsg) -> Vec<ChartEffect> {
108        match msg {
109            ChartMsg::SetKind(k) => self.state.kind = k,
110            ChartMsg::Hover(h) => self.state.hover = h,
111        }
112        Vec::new()
113    }
114
115    /// The widest category count across all series (bar clusters / x tick count).
116    fn max_len(&self) -> usize {
117        self.series.iter().map(|s| s.points.len()).max().unwrap_or(0)
118    }
119
120    /// The x extent across every finite point. `None` when there is no data.
121    /// Bar charts ignore this (they lay out on category index), but it is still a
122    /// pure, testable read of the input.
123    pub fn x_bounds(&self) -> Option<(f64, f64)> {
124        fold_bounds(self.series.iter().flat_map(|s| s.points.iter().map(|p| p.0)))
125    }
126
127    /// The y extent across every finite point. For [`ChartKind::Bar`] the range is
128    /// extended to include the `0` baseline so bar heights read honestly.
129    pub fn y_bounds(&self) -> Option<(f64, f64)> {
130        let mut b = fold_bounds(self.series.iter().flat_map(|s| s.points.iter().map(|p| p.1)))?;
131        if self.state.kind == ChartKind::Bar {
132            b.0 = b.0.min(0.0);
133            b.1 = b.1.max(0.0);
134        }
135        Some(b)
136    }
137
138    /// CSV of every point (the `copy()` payload): `series,x,y`.
139    fn to_csv(&self) -> String {
140        let mut s = String::from("series,x,y\n");
141        for ser in &self.series {
142            for (x, y) in &ser.points {
143                s.push_str(&format!("{},{},{}\n", ser.label, x, y));
144            }
145        }
146        s
147    }
148}
149
150// ── typed copy (§16) — the chart dataset as CSV Text ──────────────────────────
151impl CopySource for ChartView {
152    fn copy_kinds(&self) -> &[ClipKind] {
153        &[ClipKind::Text]
154    }
155    fn copy_payload(&self) -> Option<ClipPayload> {
156        let csv = self.to_csv();
157        (self.series.iter().any(|s| !s.points.is_empty())).then_some(ClipPayload::Text(csv))
158    }
159}
160
161impl ChartView {
162    /// FC-9 pure view: paints axes/gridlines, the series (per the stored kind),
163    /// legend, and a tooltip from the *persisted* hover — and **returns** the
164    /// hovered-point change as a `Msg`. It never mutates `self`.
165    fn render(&self, ui: &mut Ui) -> Vec<ChartMsg> {
166        let th = theme(ui);
167        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
168        let painter = ui.painter_at(rect);
169        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
170
171        let (Some((xmin, xmax)), Some((ymin, ymax))) = (self.x_bounds(), self.y_bounds()) else {
172            #[cfg(feature = "testmatrix")]
173            facett_core::testmatrix::emit(
174                "facett-plotters::ChartView::ui",
175                "ui_render",
176                self.series.is_empty() || self.max_len() == 0,
177                &format!("drew=empty_hint kind={} series={}", self.state.kind.as_str(), self.series.len()),
178            );
179            painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
180            return hover_msg(self.state.hover, None);
181        };
182
183        // Plot area — leave a left gutter for y labels and a bottom gutter for x.
184        let plot = Rect::from_min_max(
185            Pos2::new(rect.left() + 44.0, rect.top() + 10.0),
186            Pos2::new(rect.right() - 12.0, rect.bottom() - 24.0),
187        );
188        let yspan = (ymax - ymin).max(1e-9);
189        let y_of = |v: f64| plot.bottom() - ((v - ymin) / yspan) as f32 * plot.height();
190
191        // ── axes + horizontal gridlines with value ticks ─────────────────────
192        painter.line_segment([plot.left_bottom(), plot.right_bottom()], Stroke::new(1.0_f32, th.text_dim));
193        painter.line_segment([plot.left_top(), plot.left_bottom()], Stroke::new(1.0_f32, th.text_dim));
194        for i in 0..=4 {
195            let t = i as f64 / 4.0;
196            let v = ymin + t * (ymax - ymin);
197            let y = y_of(v);
198            painter.line_segment(
199                [Pos2::new(plot.left(), y), Pos2::new(plot.right(), y)],
200                Stroke::new(0.5_f32, th.text_dim.gamma_multiply(0.4)),
201            );
202            painter.text(
203                Pos2::new(plot.left() - 4.0, y),
204                Align2::RIGHT_CENTER,
205                format!("{v:.1}"),
206                FontId::proportional(9.0),
207                th.text_dim,
208            );
209        }
210
211        let hover = resp.hover_pos();
212        let base = ui.id().with("chart");
213        let mut new_hover: Option<(usize, usize)> = None;
214
215        match self.state.kind {
216            ChartKind::Bar => self.paint_bars(&painter, ui, base, plot, &y_of, hover, &mut new_hover, &th),
217            ChartKind::Line | ChartKind::Scatter => {
218                let xspan = (xmax - xmin).max(1e-9);
219                let x_of = |v: f64| plot.left() + ((v - xmin) / xspan) as f32 * plot.width();
220                self.paint_xy(&painter, ui, base, plot, &x_of, &y_of, hover, &mut new_hover, &th);
221                // x axis end labels.
222                painter.text(plot.left_bottom() + vec2(0.0, 12.0), Align2::LEFT_CENTER, format!("{xmin:.1}"), FontId::proportional(9.0), th.text_dim);
223                painter.text(plot.right_bottom() + vec2(0.0, 12.0), Align2::RIGHT_CENTER, format!("{xmax:.1}"), FontId::proportional(9.0), th.text_dim);
224            }
225        }
226
227        // ── legend (top-right) ───────────────────────────────────────────────
228        self.paint_legend(&painter, plot, &th);
229
230        // ── tooltip from the PERSISTED hover (keeps render pure) ──────────────
231        if let Some(hp) = hover
232            && let Some((si, pi)) = self.state.hover
233            && let Some((x, y)) = self.series.get(si).and_then(|s| s.points.get(pi))
234        {
235            let label = &self.series[si].label;
236            crate::boxplot::paint_tooltip(&painter, &th, hp, &format!("{label}\nx {x:.3}\ny {y:.3}"));
237        }
238
239        #[cfg(feature = "testmatrix")]
240        facett_core::testmatrix::emit(
241            "facett-plotters::ChartView::ui",
242            "ui_render",
243            !self.series.is_empty() && self.max_len() > 0,
244            &format!("kind={} series={} points={}", self.state.kind.as_str(), self.series.len(), self.max_len()),
245        );
246        hover_msg(self.state.hover, new_hover)
247    }
248
249    /// Line/scatter marks in data space. `_ui`/`base` carry the a11y tree.
250    #[allow(clippy::too_many_arguments)]
251    fn paint_xy(
252        &self,
253        painter: &egui::Painter,
254        ui: &mut Ui,
255        base: egui::Id,
256        plot: Rect,
257        x_of: &dyn Fn(f64) -> f32,
258        y_of: &dyn Fn(f64) -> f32,
259        hover: Option<Pos2>,
260        new_hover: &mut Option<(usize, usize)>,
261        th: &facett_core::Theme,
262    ) {
263        let scatter = self.state.kind == ChartKind::Scatter;
264        for (si, ser) in self.series.iter().enumerate() {
265            let color = palette::series_color(th, si);
266            let pts: Vec<Pos2> = ser.points.iter().map(|&(x, y)| Pos2::new(x_of(x), y_of(y))).collect();
267            if !scatter && pts.len() >= 2 {
268                for w in pts.windows(2) {
269                    painter.line_segment([w[0], w[1]], Stroke::new(2.0_f32, color));
270                }
271            }
272            for (pi, &p) in pts.iter().enumerate() {
273                if scatter {
274                    painter.circle_filled(p, 4.0, color);
275                } else {
276                    painter.circle_filled(p, 3.0, color);
277                }
278                if let Some(hp) = hover && p.distance(hp) <= 8.0 {
279                    *new_hover = Some((si, pi));
280                }
281            }
282            // FC-4: one queryable node per series over its centroid, carrying the
283            // point count as numeric_value; keyed on the (stable) series label.
284            if let Some(first) = pts.first() {
285                let last = *pts.last().unwrap();
286                let node = Rect::from_two_pos(*first, last).expand(4.0).intersect(plot);
287                a11y_node(
288                    ui,
289                    base,
290                    &ser.label,
291                    Sense::hover(),
292                    node,
293                    Semantics::image(ser.label.clone()).value(ser.points.len() as f64),
294                );
295            }
296        }
297    }
298
299    /// Grouped bars: one cluster per category (point index), one bar per series.
300    #[allow(clippy::too_many_arguments)]
301    fn paint_bars(
302        &self,
303        painter: &egui::Painter,
304        ui: &mut Ui,
305        base: egui::Id,
306        plot: Rect,
307        y_of: &dyn Fn(f64) -> f32,
308        hover: Option<Pos2>,
309        new_hover: &mut Option<(usize, usize)>,
310        th: &facett_core::Theme,
311    ) {
312        let cats = self.max_len();
313        let n = self.series.len();
314        if cats == 0 || n == 0 {
315            return;
316        }
317        let slot = plot.width() / cats as f32;
318        let group_w = (slot * 0.8).min(slot - 2.0).max(1.0);
319        let bar_w = (group_w / n as f32).max(1.0);
320        let base_y = y_of(0.0);
321        for ci in 0..cats {
322            let cx = plot.left() + slot * (ci as f32 + 0.5);
323            for (si, ser) in self.series.iter().enumerate() {
324                let Some(&(_, y)) = ser.points.get(ci) else { continue };
325                let color = palette::series_color(th, si);
326                let left = cx - group_w * 0.5 + bar_w * si as f32;
327                let top = y_of(y);
328                let r = Rect::from_min_max(
329                    Pos2::new(left + 1.0, top.min(base_y)),
330                    Pos2::new(left + bar_w - 1.0, top.max(base_y)),
331                );
332                painter.rect_filled(r, egui::CornerRadius::same(1), color);
333                a11y_node(
334                    ui,
335                    base,
336                    format!("{} · cat {ci}", ser.label),
337                    Sense::hover(),
338                    r,
339                    Semantics::image(format!("{} · {ci}", ser.label)).value(y),
340                );
341                if let Some(hp) = hover && r.contains(hp) {
342                    *new_hover = Some((si, ci));
343                }
344            }
345        }
346    }
347
348    /// Legend: one swatch + label per series, top-right, in slot order. Text wears
349    /// the theme's ink; only the swatch carries the series colour (dataviz rule).
350    fn paint_legend(&self, painter: &egui::Painter, plot: Rect, th: &facett_core::Theme) {
351        if self.series.len() < 2 {
352            return; // a single series is named by the title — no legend box.
353        }
354        let mut y = plot.top() + 6.0;
355        let x = plot.right() - 120.0;
356        for (si, ser) in self.series.iter().enumerate() {
357            let color = palette::series_color(th, si);
358            let sw = Rect::from_min_size(Pos2::new(x, y), vec2(10.0, 10.0));
359            painter.rect_filled(sw, egui::CornerRadius::same(2), color);
360            painter.text(Pos2::new(x + 14.0, y + 5.0), Align2::LEFT_CENTER, &ser.label, FontId::proportional(10.0), th.text);
361            y += 15.0;
362        }
363    }
364}
365
366/// Fold an iterator of scalars into `(min, max)` over finite values only.
367fn fold_bounds(it: impl Iterator<Item = f64>) -> Option<(f64, f64)> {
368    let mut r: Option<(f64, f64)> = None;
369    for x in it {
370        if x.is_finite() {
371            r = Some(match r {
372                None => (x, x),
373                Some((lo, hi)) => (lo.min(x), hi.max(x)),
374            });
375        }
376    }
377    r
378}
379
380/// Emit a `Hover` message only when the hovered point actually changed.
381fn hover_msg(current: Option<(usize, usize)>, next: Option<(usize, usize)>) -> Vec<ChartMsg> {
382    if current != next { vec![ChartMsg::Hover(next)] } else { Vec::new() }
383}
384
385impl Elm for ChartView {
386    type Model = ChartState;
387    type Msg = ChartMsg;
388    type Effect = ChartEffect;
389
390    fn title(&self) -> &str {
391        &self.title
392    }
393    fn state(&self) -> &ChartState {
394        &self.state
395    }
396    fn update(&mut self, msg: ChartMsg) -> Vec<ChartEffect> {
397        ChartView::update(self, msg)
398    }
399    fn view(&self, ui: &mut Ui) -> Vec<ChartMsg> {
400        self.render(ui)
401    }
402}
403
404// Form 3 (`custom_state_json`): the chart publishes a RICHER state than the raw
405// model — derived axis bounds + a per-series summary a robot reads without a GPU —
406// and routes `update_json` straight through `update` (so `{"set_kind":"bar"}`
407// drives it headlessly).
408facett_core::impl_facet_via_elm!(ChartView, custom_state_json, {
409    fn caps(&self) -> FacetCaps {
410        FacetCaps::NONE.themeable().copyable().resizable()
411    }
412    fn copy(&mut self) -> Option<String> {
413        Some(self.to_csv())
414    }
415    fn kind(&self) -> &'static str {
416        "plotters-chart"
417    }
418    fn update_json(&mut self, msg_json: &str) {
419        if let Ok(msg) = serde_json::from_str::<ChartMsg>(msg_json) {
420            let _ = <Self as facett_core::Elm>::update(self, msg);
421        }
422    }
423    fn state_json(&self) -> serde_json::Value {
424        let (xmin, xmax) = self.x_bounds().unzip();
425        let (ymin, ymax) = self.y_bounds().unzip();
426        serde_json::json!({
427            "kind": self.state.kind.as_str(),
428            "series_count": self.series.len(),
429            "hover": self.state.hover,
430            "x_bounds": [xmin, xmax],
431            "y_bounds": [ymin, ymax],
432            "series": self.series.iter().map(|s| serde_json::json!({
433                "label": s.label, "points": s.points.len(),
434            })).collect::<Vec<_>>(),
435        })
436    }
437});
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    fn sample() -> ChartView {
444        ChartView::new(
445            "Sales",
446            vec![
447                Series::new("north", vec![(0.0, 1.0), (1.0, 3.0), (2.0, 2.0)]),
448                Series::new("south", vec![(0.0, 2.0), (1.0, 1.0), (2.0, 4.0)]),
449            ],
450        )
451    }
452
453    #[test]
454    fn x_and_y_bounds_span_all_series() {
455        let v = sample();
456        assert_eq!(v.x_bounds(), Some((0.0, 2.0)));
457        assert_eq!(v.y_bounds(), Some((1.0, 4.0)));
458    }
459
460    #[test]
461    fn bar_bounds_include_zero_baseline() {
462        let v = ChartView::new("t", vec![Series::new("a", vec![(0.0, 5.0), (1.0, 9.0)])])
463            .with_kind(ChartKind::Bar);
464        // min extends down to the 0 baseline even though the data starts at 5.
465        assert_eq!(v.y_bounds(), Some((0.0, 9.0)));
466    }
467
468    #[test]
469    fn bounds_ignore_nonfinite_and_empty() {
470        assert_eq!(ChartView::new("t", vec![]).x_bounds(), None);
471        let v = ChartView::new("t", vec![Series::new("a", vec![(f64::NAN, 1.0), (2.0, f64::INFINITY)])]);
472        assert_eq!(v.x_bounds(), Some((2.0, 2.0)));
473        assert_eq!(v.y_bounds(), Some((1.0, 1.0)));
474    }
475
476    #[test]
477    fn csv_has_header_and_a_row_per_point() {
478        let v = sample();
479        let csv = v.to_csv();
480        assert!(csv.starts_with("series,x,y\n"));
481        assert_eq!(csv.lines().count(), 7); // header + 6 points
482    }
483
484    #[test]
485    fn typed_copy_is_the_dataset_csv() {
486        use facett_core::clip::{ClipKind, CopySource};
487        let v = sample();
488        let p = v.copy_payload().expect("non-empty chart copies");
489        assert_eq!(p.kind(), ClipKind::Text);
490        assert_eq!(p.as_text(), v.to_csv());
491        // an all-empty chart yields nothing.
492        assert!(ChartView::new("t", vec![Series::new("a", vec![])]).copy_payload().is_none());
493    }
494
495    // ── Elm (FC-2/FC-3) harness properties ────────────────────────────────────
496    #[test]
497    fn switching_kind_is_observable() {
498        let mut v = sample();
499        assert_eq!(v.state().kind, ChartKind::Line);
500        let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::SetKind(ChartKind::Bar)]);
501        assert_eq!(snap.kind, ChartKind::Bar, "the kind switch is observable in the snapshot");
502        let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::SetKind(ChartKind::Scatter)]);
503        assert_eq!(snap.kind, ChartKind::Scatter);
504    }
505
506    #[test]
507    fn hover_transitions_are_observable() {
508        let mut v = sample();
509        let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::Hover(Some((1, 2)))]);
510        assert_eq!(snap.hover, Some((1, 2)));
511        let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::Hover(None)]);
512        assert_eq!(snap.hover, None);
513    }
514
515    #[test]
516    fn drive_produces_no_effects() {
517        let mut v = sample();
518        let fx = facett_core::harness::drive(
519            &mut v,
520            [ChartMsg::SetKind(ChartKind::Bar), ChartMsg::Hover(Some((0, 0)))],
521        );
522        assert!(fx.is_empty(), "the chart does no I/O");
523    }
524
525    #[test]
526    fn msg_round_trips_through_json() {
527        // the exact JSON shapes a robot posts through update_json.
528        let m: ChartMsg = serde_json::from_str(r#"{"set_kind":"scatter"}"#).unwrap();
529        assert_eq!(m, ChartMsg::SetKind(ChartKind::Scatter));
530        let m: ChartMsg = serde_json::from_str(r#"{"hover":[0,1]}"#).unwrap();
531        assert_eq!(m, ChartMsg::Hover(Some((0, 1))));
532        let m: ChartMsg = serde_json::from_str(r#"{"hover":null}"#).unwrap();
533        assert_eq!(m, ChartMsg::Hover(None));
534    }
535
536    #[test]
537    fn state_serde_round_trips() {
538        let st = ChartState { kind: ChartKind::Bar, hover: Some((2, 3)) };
539        let back: ChartState = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
540        assert_eq!(st, back);
541    }
542}