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, th.text_dim));
193        painter.line_segment([plot.left_top(), plot.left_bottom()], Stroke::new(1.0, 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, 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            if let Some((si, pi)) = self.state.hover {
233                if let Some((x, y)) = self.series.get(si).and_then(|s| s.points.get(pi)) {
234                    let label = &self.series[si].label;
235                    crate::boxplot::paint_tooltip(&painter, &th, hp, &format!("{label}\nx {x:.3}\ny {y:.3}"));
236                }
237            }
238        }
239
240        #[cfg(feature = "testmatrix")]
241        facett_core::testmatrix::emit(
242            "facett-plotters::ChartView::ui",
243            "ui_render",
244            !self.series.is_empty() && self.max_len() > 0,
245            &format!("kind={} series={} points={}", self.state.kind.as_str(), self.series.len(), self.max_len()),
246        );
247        hover_msg(self.state.hover, new_hover)
248    }
249
250    /// Line/scatter marks in data space. `_ui`/`base` carry the a11y tree.
251    #[allow(clippy::too_many_arguments)]
252    fn paint_xy(
253        &self,
254        painter: &egui::Painter,
255        ui: &mut Ui,
256        base: egui::Id,
257        plot: Rect,
258        x_of: &dyn Fn(f64) -> f32,
259        y_of: &dyn Fn(f64) -> f32,
260        hover: Option<Pos2>,
261        new_hover: &mut Option<(usize, usize)>,
262        th: &facett_core::Theme,
263    ) {
264        let scatter = self.state.kind == ChartKind::Scatter;
265        for (si, ser) in self.series.iter().enumerate() {
266            let color = palette::series_color(th, si);
267            let pts: Vec<Pos2> = ser.points.iter().map(|&(x, y)| Pos2::new(x_of(x), y_of(y))).collect();
268            if !scatter && pts.len() >= 2 {
269                for w in pts.windows(2) {
270                    painter.line_segment([w[0], w[1]], Stroke::new(2.0, color));
271                }
272            }
273            for (pi, &p) in pts.iter().enumerate() {
274                if scatter {
275                    painter.circle_filled(p, 4.0, color);
276                } else {
277                    painter.circle_filled(p, 3.0, color);
278                }
279                if let Some(hp) = hover {
280                    if p.distance(hp) <= 8.0 {
281                        *new_hover = Some((si, pi));
282                    }
283                }
284            }
285            // FC-4: one queryable node per series over its centroid, carrying the
286            // point count as numeric_value; keyed on the (stable) series label.
287            if let Some(first) = pts.first() {
288                let last = *pts.last().unwrap();
289                let node = Rect::from_two_pos(*first, last).expand(4.0).intersect(plot);
290                a11y_node(
291                    ui,
292                    base,
293                    &ser.label,
294                    Sense::hover(),
295                    node,
296                    Semantics::image(ser.label.clone()).value(ser.points.len() as f64),
297                );
298            }
299        }
300    }
301
302    /// Grouped bars: one cluster per category (point index), one bar per series.
303    #[allow(clippy::too_many_arguments)]
304    fn paint_bars(
305        &self,
306        painter: &egui::Painter,
307        ui: &mut Ui,
308        base: egui::Id,
309        plot: Rect,
310        y_of: &dyn Fn(f64) -> f32,
311        hover: Option<Pos2>,
312        new_hover: &mut Option<(usize, usize)>,
313        th: &facett_core::Theme,
314    ) {
315        let cats = self.max_len();
316        let n = self.series.len();
317        if cats == 0 || n == 0 {
318            return;
319        }
320        let slot = plot.width() / cats as f32;
321        let group_w = (slot * 0.8).min(slot - 2.0).max(1.0);
322        let bar_w = (group_w / n as f32).max(1.0);
323        let base_y = y_of(0.0);
324        for ci in 0..cats {
325            let cx = plot.left() + slot * (ci as f32 + 0.5);
326            for (si, ser) in self.series.iter().enumerate() {
327                let Some(&(_, y)) = ser.points.get(ci) else { continue };
328                let color = palette::series_color(th, si);
329                let left = cx - group_w * 0.5 + bar_w * si as f32;
330                let top = y_of(y);
331                let r = Rect::from_min_max(
332                    Pos2::new(left + 1.0, top.min(base_y)),
333                    Pos2::new(left + bar_w - 1.0, top.max(base_y)),
334                );
335                painter.rect_filled(r, egui::CornerRadius::same(1), color);
336                a11y_node(
337                    ui,
338                    base,
339                    format!("{} · cat {ci}", ser.label),
340                    Sense::hover(),
341                    r,
342                    Semantics::image(format!("{} · {ci}", ser.label)).value(y),
343                );
344                if let Some(hp) = hover {
345                    if r.contains(hp) {
346                        *new_hover = Some((si, ci));
347                    }
348                }
349            }
350        }
351    }
352
353    /// Legend: one swatch + label per series, top-right, in slot order. Text wears
354    /// the theme's ink; only the swatch carries the series colour (dataviz rule).
355    fn paint_legend(&self, painter: &egui::Painter, plot: Rect, th: &facett_core::Theme) {
356        if self.series.len() < 2 {
357            return; // a single series is named by the title — no legend box.
358        }
359        let mut y = plot.top() + 6.0;
360        let x = plot.right() - 120.0;
361        for (si, ser) in self.series.iter().enumerate() {
362            let color = palette::series_color(th, si);
363            let sw = Rect::from_min_size(Pos2::new(x, y), vec2(10.0, 10.0));
364            painter.rect_filled(sw, egui::CornerRadius::same(2), color);
365            painter.text(Pos2::new(x + 14.0, y + 5.0), Align2::LEFT_CENTER, &ser.label, FontId::proportional(10.0), th.text);
366            y += 15.0;
367        }
368    }
369}
370
371/// Fold an iterator of scalars into `(min, max)` over finite values only.
372fn fold_bounds(it: impl Iterator<Item = f64>) -> Option<(f64, f64)> {
373    let mut r: Option<(f64, f64)> = None;
374    for x in it {
375        if x.is_finite() {
376            r = Some(match r {
377                None => (x, x),
378                Some((lo, hi)) => (lo.min(x), hi.max(x)),
379            });
380        }
381    }
382    r
383}
384
385/// Emit a `Hover` message only when the hovered point actually changed.
386fn hover_msg(current: Option<(usize, usize)>, next: Option<(usize, usize)>) -> Vec<ChartMsg> {
387    if current != next { vec![ChartMsg::Hover(next)] } else { Vec::new() }
388}
389
390impl Elm for ChartView {
391    type Model = ChartState;
392    type Msg = ChartMsg;
393    type Effect = ChartEffect;
394
395    fn title(&self) -> &str {
396        &self.title
397    }
398    fn state(&self) -> &ChartState {
399        &self.state
400    }
401    fn update(&mut self, msg: ChartMsg) -> Vec<ChartEffect> {
402        ChartView::update(self, msg)
403    }
404    fn view(&self, ui: &mut Ui) -> Vec<ChartMsg> {
405        self.render(ui)
406    }
407}
408
409// Form 3 (`custom_state_json`): the chart publishes a RICHER state than the raw
410// model — derived axis bounds + a per-series summary a robot reads without a GPU —
411// and routes `update_json` straight through `update` (so `{"set_kind":"bar"}`
412// drives it headlessly).
413facett_core::impl_facet_via_elm!(ChartView, custom_state_json, {
414    fn caps(&self) -> FacetCaps {
415        FacetCaps::NONE.themeable().copyable().resizable()
416    }
417    fn copy(&mut self) -> Option<String> {
418        Some(self.to_csv())
419    }
420    fn kind(&self) -> &'static str {
421        "plotters-chart"
422    }
423    fn update_json(&mut self, msg_json: &str) {
424        if let Ok(msg) = serde_json::from_str::<ChartMsg>(msg_json) {
425            let _ = <Self as facett_core::Elm>::update(self, msg);
426        }
427    }
428    fn state_json(&self) -> serde_json::Value {
429        let (xmin, xmax) = self.x_bounds().unzip();
430        let (ymin, ymax) = self.y_bounds().unzip();
431        serde_json::json!({
432            "kind": self.state.kind.as_str(),
433            "series_count": self.series.len(),
434            "hover": self.state.hover,
435            "x_bounds": [xmin, xmax],
436            "y_bounds": [ymin, ymax],
437            "series": self.series.iter().map(|s| serde_json::json!({
438                "label": s.label, "points": s.points.len(),
439            })).collect::<Vec<_>>(),
440        })
441    }
442});
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    fn sample() -> ChartView {
449        ChartView::new(
450            "Sales",
451            vec![
452                Series::new("north", vec![(0.0, 1.0), (1.0, 3.0), (2.0, 2.0)]),
453                Series::new("south", vec![(0.0, 2.0), (1.0, 1.0), (2.0, 4.0)]),
454            ],
455        )
456    }
457
458    #[test]
459    fn x_and_y_bounds_span_all_series() {
460        let v = sample();
461        assert_eq!(v.x_bounds(), Some((0.0, 2.0)));
462        assert_eq!(v.y_bounds(), Some((1.0, 4.0)));
463    }
464
465    #[test]
466    fn bar_bounds_include_zero_baseline() {
467        let v = ChartView::new("t", vec![Series::new("a", vec![(0.0, 5.0), (1.0, 9.0)])])
468            .with_kind(ChartKind::Bar);
469        // min extends down to the 0 baseline even though the data starts at 5.
470        assert_eq!(v.y_bounds(), Some((0.0, 9.0)));
471    }
472
473    #[test]
474    fn bounds_ignore_nonfinite_and_empty() {
475        assert_eq!(ChartView::new("t", vec![]).x_bounds(), None);
476        let v = ChartView::new("t", vec![Series::new("a", vec![(f64::NAN, 1.0), (2.0, f64::INFINITY)])]);
477        assert_eq!(v.x_bounds(), Some((2.0, 2.0)));
478        assert_eq!(v.y_bounds(), Some((1.0, 1.0)));
479    }
480
481    #[test]
482    fn csv_has_header_and_a_row_per_point() {
483        let v = sample();
484        let csv = v.to_csv();
485        assert!(csv.starts_with("series,x,y\n"));
486        assert_eq!(csv.lines().count(), 7); // header + 6 points
487    }
488
489    #[test]
490    fn typed_copy_is_the_dataset_csv() {
491        use facett_core::clip::{ClipKind, CopySource};
492        let v = sample();
493        let p = v.copy_payload().expect("non-empty chart copies");
494        assert_eq!(p.kind(), ClipKind::Text);
495        assert_eq!(p.as_text(), v.to_csv());
496        // an all-empty chart yields nothing.
497        assert!(ChartView::new("t", vec![Series::new("a", vec![])]).copy_payload().is_none());
498    }
499
500    // ── Elm (FC-2/FC-3) harness properties ────────────────────────────────────
501    #[test]
502    fn switching_kind_is_observable() {
503        let mut v = sample();
504        assert_eq!(v.state().kind, ChartKind::Line);
505        let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::SetKind(ChartKind::Bar)]);
506        assert_eq!(snap.kind, ChartKind::Bar, "the kind switch is observable in the snapshot");
507        let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::SetKind(ChartKind::Scatter)]);
508        assert_eq!(snap.kind, ChartKind::Scatter);
509    }
510
511    #[test]
512    fn hover_transitions_are_observable() {
513        let mut v = sample();
514        let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::Hover(Some((1, 2)))]);
515        assert_eq!(snap.hover, Some((1, 2)));
516        let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::Hover(None)]);
517        assert_eq!(snap.hover, None);
518    }
519
520    #[test]
521    fn drive_produces_no_effects() {
522        let mut v = sample();
523        let fx = facett_core::harness::drive(
524            &mut v,
525            [ChartMsg::SetKind(ChartKind::Bar), ChartMsg::Hover(Some((0, 0)))],
526        );
527        assert!(fx.is_empty(), "the chart does no I/O");
528    }
529
530    #[test]
531    fn msg_round_trips_through_json() {
532        // the exact JSON shapes a robot posts through update_json.
533        let m: ChartMsg = serde_json::from_str(r#"{"set_kind":"scatter"}"#).unwrap();
534        assert_eq!(m, ChartMsg::SetKind(ChartKind::Scatter));
535        let m: ChartMsg = serde_json::from_str(r#"{"hover":[0,1]}"#).unwrap();
536        assert_eq!(m, ChartMsg::Hover(Some((0, 1))));
537        let m: ChartMsg = serde_json::from_str(r#"{"hover":null}"#).unwrap();
538        assert_eq!(m, ChartMsg::Hover(None));
539    }
540
541    #[test]
542    fn state_serde_round_trips() {
543        let st = ChartState { kind: ChartKind::Bar, hover: Some((2, 3)) };
544        let back: ChartState = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
545        assert_eq!(st, back);
546    }
547}