Skip to main content

improv_gui/
chart.rs

1//! A read-only chart view of the FOCUSED matrix's measure.
2//!
3//! `chart_series` is a pure function of the app state (snapshot + the focused
4//! matrix's filters + its pivot/page); an axis filtered to nothing (or a page
5//! category with no pinnable item) charts nothing at all, exactly as the grid
6//! renders nothing. With several matrices on the canvas the chart follows the
7//! keyboard, as every other single-measure surface does — see
8//! `ImprovApp::focused`.
9//!
10//! The x-axis is the full Cartesian product of the ROW categories
11//! (labels are the joined tuple names, e.g. "2024Q1 / North"), and there is one
12//! series per full COLUMN tuple (series name = joined column tuple names). The
13//! single-category case is the 1-length-tuple case; a 1-D grid (no columns) is
14//! one unnamed series. No egui calls. `render_chart` just paints that
15//! `ChartData` with `egui::Painter` (grouped bars, optional line overlay).
16//! Non-numeric / Error cells are gaps (`None`), never a panic. The chart never
17//! mutates the model or engine.
18
19use crate::app::ImprovApp;
20
21/// The plotted data for the current measure/pivot: shared x-labels and one or
22/// more named series of y-values aligned to those labels. A `None` y is a gap
23/// (non-numeric / missing cell).
24#[derive(Debug, Default, PartialEq)]
25pub struct ChartData {
26    /// X-axis title: the joined ROW category names (e.g. "Time / Region").
27    pub x_title: String,
28    /// One label per x position: the joined row-tuple item names, in grid order.
29    pub x_labels: Vec<String>,
30    /// One series per full column tuple (a single unnamed series if 1-D).
31    pub series: Vec<Series>,
32}
33
34#[derive(Debug, PartialEq)]
35pub struct Series {
36    /// Joined column-tuple item names (empty for a 1-D grid's single series).
37    pub name: String,
38    /// y-value per x position; `None` is a gap.
39    pub points: Vec<Option<f64>>,
40}
41
42impl ChartData {
43    /// The numeric min/max over all plotted points, with 0 always included.
44    /// `(0.0, 1.0)` when there is no numeric data (an empty axis).
45    pub fn y_range(&self) -> (f64, f64) {
46        let (mut lo, mut hi) = (0.0f64, 0.0f64);
47        for s in &self.series {
48            for y in s.points.iter().flatten() {
49                lo = lo.min(*y);
50                hi = hi.max(*y);
51            }
52        }
53        if lo == hi {
54            (lo, hi + 1.0)
55        } else {
56            (lo, hi)
57        }
58    }
59}
60
61impl ImprovApp {
62    /// Build the chart data for the FOCUSED matrix's measure from the current
63    /// snapshot, honoring THAT matrix's active filters and pinned page items
64    /// (same visible-item set as its grid). The x-axis is the full Cartesian
65    /// product of the ROW categories (x-label = joined tuple item names, e.g.
66    /// "2024Q1 / North"); one series per full COLUMN tuple (series name = joined
67    /// column tuple names). The single-category case is just the 1-length-tuple
68    /// case. A 1-D grid (no column categories) is a single unnamed series.
69    /// Non-numeric / missing cells are gaps (`None`). Pure: no egui, no mutation.
70    ///
71    /// Empty [`ChartData`] (no x, no series) whenever nothing is addressable:
72    /// no measure on the focused matrix, an axis category filtered to zero
73    /// items, or a page category with no pinnable item. `render_chart` reports
74    /// that as "no data" — the chart never plots an under-specified key. Matches
75    /// `Matrix::grid_dims`.
76    pub fn chart_series(&self) -> ChartData {
77        let Some(measure) = self.selected() else {
78            return ChartData::default();
79        };
80        // No pinnable page item -> every key would omit that category. The grid
81        // renders nothing there; so does the chart.
82        let Some((row_cats, col_cats, pinned)) = self.chart_axes_pub() else {
83            return ChartData::default();
84        };
85        let values = self.values_for_pub(measure);
86
87        // Full row/column products (each element is a tuple of (ItemId, name)).
88        // No categories on an axis yields one empty tuple (scalar in that
89        // direction); a category filtered to nothing yields NO tuples and stays
90        // that way — a synthesized line's key would omit that category.
91        let row_tuples = self.axis_tuples_pub(&row_cats);
92        let col_tuples = self.axis_tuples_pub(&col_cats);
93        if row_tuples.is_empty() || col_tuples.is_empty() {
94            return ChartData::default();
95        }
96
97        // Axis title: the joined row-category names (e.g. "Time / Region").
98        let x_title = row_cats
99            .iter()
100            .filter_map(|c| self.category_name_pub(*c))
101            .collect::<Vec<_>>()
102            .join(" / ");
103        // x-label per row tuple: joined item names.
104        let x_labels: Vec<String> = row_tuples.iter().map(|t| join_names(t)).collect();
105
106        let series = col_tuples
107            .iter()
108            .map(|col_tuple| Series {
109                name: join_names(col_tuple),
110                points: row_tuples
111                    .iter()
112                    .map(|row_tuple| {
113                        let key = self.cell_key_multi_pub(
114                            &row_cats, row_tuple, &col_cats, col_tuple, &pinned,
115                        );
116                        values.get(&key).copied()
117                    })
118                    .collect(),
119            })
120            .collect();
121
122        ChartData {
123            x_title,
124            x_labels,
125            series,
126        }
127    }
128}
129
130/// Join a tuple's item names with " / " (empty tuple -> empty string).
131fn join_names(tuple: &[(improv_core_model::ItemId, String)]) -> String {
132    tuple
133        .iter()
134        .map(|(_, n)| n.as_str())
135        .collect::<Vec<_>>()
136        .join(" / ")
137}
138
139/// Paint `data` into the current `ui` as grouped bars (and, when `line` is set,
140/// an overlaid line per series). Read-only: draws with `egui::Painter` only.
141pub fn render_chart(ui: &mut egui::Ui, data: &ChartData, line: bool) {
142    use crate::theme::{BLACK, DARK_GRAY, LIGHT_GRAY, WHITE};
143    use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, Vec2};
144
145    if data.x_labels.is_empty() || data.series.is_empty() {
146        ui.weak("(no data to chart — select a numeric measure with items)");
147        return;
148    }
149
150    let desired = ui.available_size_before_wrap();
151    let (rect, _resp) = ui.allocate_exact_size(
152        Vec2::new(desired.x.max(240.0), desired.y.max(200.0)),
153        Sense::hover(),
154    );
155    let painter = ui.painter_at(rect);
156
157    let (y_lo, y_hi) = data.y_range();
158    let span = (y_hi - y_lo).max(f64::EPSILON);
159
160    // Plot area: leave margins for the y-scale (left) and x-labels (bottom).
161    let ml = 48.0;
162    let mb = 28.0;
163    let mt = 8.0;
164    let mr = 8.0;
165    let plot = Rect::from_min_max(
166        Pos2::new(rect.left() + ml, rect.top() + mt),
167        Pos2::new(rect.right() - mr, rect.bottom() - mb),
168    );
169
170    let visuals = ui.visuals();
171    let axis_stroke = Stroke::new(1.0_f32, visuals.weak_text_color());
172    let text_color = visuals.text_color();
173    let font = FontId::proportional(12.0);
174
175    // y for a data value (top = y_hi).
176    let y_of = |v: f64| -> f32 {
177        let t = (v - y_lo) / span;
178        plot.bottom() - t as f32 * plot.height()
179    };
180
181    // Axes.
182    painter.line_segment([plot.left_bottom(), plot.right_bottom()], axis_stroke);
183    painter.line_segment([plot.left_top(), plot.left_bottom()], axis_stroke);
184
185    // y-scale ticks (lo, mid, hi) with a light gridline and label.
186    for frac in [0.0, 0.5, 1.0] {
187        let v = y_lo + span * frac;
188        let y = y_of(v);
189        painter.line_segment(
190            [Pos2::new(plot.left(), y), Pos2::new(plot.right(), y)],
191            Stroke::new(0.5_f32, visuals.faint_bg_color),
192        );
193        painter.text(
194            Pos2::new(plot.left() - 4.0, y),
195            Align2::RIGHT_CENTER,
196            format!("{v:.0}"),
197            font.clone(),
198            text_color,
199        );
200    }
201    // Zero line, if 0 is strictly inside the range.
202    if y_lo < 0.0 && y_hi > 0.0 {
203        let y = y_of(0.0);
204        painter.line_segment(
205            [Pos2::new(plot.left(), y), Pos2::new(plot.right(), y)],
206            axis_stroke,
207        );
208    }
209
210    let n_x = data.x_labels.len();
211    let n_s = data.series.len();
212    let slot = plot.width() / n_x as f32; // per-x-group width
213    let pad = slot * 0.15;
214    let group_w = slot - 2.0 * pad;
215    let bar_w = (group_w / n_s as f32).max(1.0);
216
217    // The four NeXT grays, each bar, point and line outlined in black so a
218    // light-gray series stays visible on the light-gray panel.
219    // ponytail: four shades then repeats; add hatch patterns if charts with
220    // more than four series matter.
221    let palette = [BLACK, WHITE, DARK_GRAY, LIGHT_GRAY];
222    let outline = Stroke::new(1.0_f32, BLACK);
223    let zero_y = y_of(0.0f64.clamp(y_lo, y_hi));
224
225    for (si, s) in data.series.iter().enumerate() {
226        let color = palette[si % palette.len()];
227        // Bars.
228        for (xi, y) in s.points.iter().enumerate() {
229            let Some(v) = y else { continue }; // gap
230            let x0 = plot.left() + xi as f32 * slot + pad + si as f32 * bar_w;
231            let yv = y_of(*v);
232            let bar = Rect::from_min_max(
233                Pos2::new(x0, yv.min(zero_y)),
234                Pos2::new(x0 + bar_w - 1.0, yv.max(zero_y)),
235            );
236            painter.rect(bar, 0.0, color, outline);
237        }
238        // Optional line overlay through the bar centers, skipping gaps.
239        if line {
240            let mut prev: Option<Pos2> = None;
241            for (xi, y) in s.points.iter().enumerate() {
242                match y {
243                    Some(v) => {
244                        let cx =
245                            plot.left() + xi as f32 * slot + pad + si as f32 * bar_w + bar_w / 2.0;
246                        let p = Pos2::new(cx, y_of(*v));
247                        if let Some(pp) = prev {
248                            painter.line_segment([pp, p], Stroke::new(3.5_f32, BLACK));
249                            painter.line_segment([pp, p], Stroke::new(1.5_f32, color));
250                        }
251                        painter.circle(p, 2.5, color, outline);
252                        prev = Some(p);
253                    }
254                    None => prev = None,
255                }
256            }
257        }
258    }
259
260    // x-labels centered under each group.
261    for (xi, label) in data.x_labels.iter().enumerate() {
262        let cx = plot.left() + (xi as f32 + 0.5) * slot;
263        painter.text(
264            Pos2::new(cx, plot.bottom() + 3.0),
265            Align2::CENTER_TOP,
266            label,
267            font.clone(),
268            text_color,
269        );
270    }
271
272    // Axis title + a simple legend for multi-series charts.
273    if !data.x_title.is_empty() {
274        painter.text(
275            Pos2::new(plot.center().x, rect.bottom() - 2.0),
276            Align2::CENTER_BOTTOM,
277            &data.x_title,
278            font.clone(),
279            visuals.weak_text_color(),
280        );
281    }
282    if n_s > 1 {
283        let mut lx = plot.left() + 4.0;
284        let ly = plot.top() + 2.0;
285        for (si, s) in data.series.iter().enumerate() {
286            let color = palette[si % palette.len()];
287            painter.rect(
288                Rect::from_min_size(Pos2::new(lx, ly), Vec2::new(10.0, 10.0)),
289                0.0,
290                color,
291                outline,
292            );
293            let galley = painter.layout_no_wrap(s.name.clone(), font.clone(), text_color);
294            let w = galley.rect.width();
295            painter.galley(Pos2::new(lx + 14.0, ly - 1.0), galley, text_color);
296            lx += 14.0 + w + 12.0;
297        }
298    }
299}
300
301#[cfg(test)]
302#[cfg_attr(coverage_nightly, coverage(off))]
303mod tests {
304    use super::*;
305    use egui::epaint::Shape;
306
307    /// Paint `data` in one headless 600x400 frame; return every shape drawn.
308    fn paint(data: &ChartData, line: bool) -> Vec<Shape> {
309        let ctx = egui::Context::default();
310        let raw = egui::RawInput {
311            screen_rect: Some(egui::Rect::from_min_size(
312                egui::Pos2::ZERO,
313                egui::vec2(600.0, 400.0),
314            )),
315            ..Default::default()
316        };
317        let out = ctx.run(raw, |ctx| {
318            egui::CentralPanel::default().show(ctx, |ui| render_chart(ui, data, line));
319        });
320        fn flat(s: Shape, acc: &mut Vec<Shape>) {
321            match s {
322                Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, acc)),
323                s => acc.push(s),
324            }
325        }
326        let mut acc = Vec::new();
327        out.shapes.into_iter().for_each(|c| flat(c.shape, &mut acc));
328        acc
329    }
330
331    fn texts(shapes: &[Shape]) -> Vec<String> {
332        shapes
333            .iter()
334            .filter_map(|s| match s {
335                Shape::Text(t) => Some(t.galley.text().to_owned()),
336                _ => None,
337            })
338            .collect()
339    }
340
341    fn count(shapes: &[Shape], pred: impl Fn(&Shape) -> bool) -> usize {
342        shapes.iter().filter(|s| pred(s)).count()
343    }
344
345    /// A chart bar or legend swatch: a rect outlined 1px black (the panel's
346    /// own background rect is not).
347    fn is_bar(s: &Shape) -> bool {
348        use crate::theme::BLACK;
349        matches!(s, Shape::Rect(r) if r.stroke == egui::Stroke::new(1.0_f32, BLACK))
350    }
351
352    #[test]
353    fn y_range_of_no_or_flat_data_is_zero_to_one_above() {
354        assert_eq!(ChartData::default().y_range(), (0.0, 1.0));
355        let flat = ChartData {
356            series: vec![Series {
357                name: String::new(),
358                points: vec![Some(0.0), None],
359            }],
360            ..Default::default()
361        };
362        assert_eq!(flat.y_range(), (0.0, 1.0));
363        let mixed = ChartData {
364            series: vec![Series {
365                name: String::new(),
366                points: vec![Some(-2.0), Some(5.0)],
367            }],
368            ..Default::default()
369        };
370        assert_eq!(mixed.y_range(), (-2.0, 5.0));
371    }
372
373    #[test]
374    fn empty_chart_says_no_data_and_draws_no_bars() {
375        for data in [
376            ChartData::default(),
377            ChartData {
378                x_labels: vec!["a".into()],
379                ..Default::default()
380            },
381        ] {
382            let shapes = paint(&data, true);
383            assert!(texts(&shapes).iter().any(|t| t.contains("no data")));
384            assert_eq!(count(&shapes, is_bar), 0);
385        }
386    }
387
388    #[test]
389    fn two_series_with_gaps_and_negatives_draw_bars_lines_zero_and_legend() {
390        let data = ChartData {
391            x_title: "Time".into(),
392            x_labels: vec!["q1".into(), "q2".into(), "q3".into(), "q4".into()],
393            series: vec![
394                Series {
395                    name: "North".into(),
396                    points: vec![Some(1.0), None, Some(-2.0), Some(3.0)],
397                },
398                Series {
399                    name: "South".into(),
400                    points: vec![Some(4.0), Some(5.0), None, None],
401                },
402            ],
403        };
404        let bars_lines = paint(&data, true);
405        let bars_only = paint(&data, false);
406        let rects = |s: &[Shape]| count(s, is_bar);
407        let circles = |s: &[Shape]| count(s, |s| matches!(s, Shape::Circle(_)));
408        let segs = |s: &[Shape]| count(s, |s| matches!(s, Shape::LineSegment { .. }));
409        // 5 numeric points -> 5 bars, + 2 legend swatches (n_s > 1).
410        assert_eq!(rects(&bars_only), 7);
411        assert_eq!(rects(&bars_lines), 7);
412        assert_eq!(circles(&bars_only), 0);
413        assert_eq!(circles(&bars_lines), 5);
414        // Line overlay: North joins q3-q4 only (q2 gap breaks it), South q1-q2;
415        // each join is a black under-stroke plus a colored stroke.
416        assert_eq!(segs(&bars_lines) - segs(&bars_only), 4);
417        // 2 axes + 3 gridlines + the zero line (range straddles 0).
418        assert_eq!(segs(&bars_only), 6);
419        let t = texts(&bars_only);
420        for want in ["Time", "North", "South", "q1", "q4"] {
421            assert!(t.iter().any(|x| x == want), "{want} missing from {t:?}");
422        }
423    }
424
425    #[test]
426    fn one_positive_untitled_series_has_no_zero_line_or_legend() {
427        let data = ChartData {
428            x_title: String::new(),
429            x_labels: vec!["a".into(), "b".into()],
430            series: vec![Series {
431                name: "only".into(),
432                points: vec![Some(1.0), Some(2.0)],
433            }],
434        };
435        let shapes = paint(&data, false);
436        assert_eq!(count(&shapes, is_bar), 2);
437        assert_eq!(
438            count(&shapes, |s| matches!(s, Shape::LineSegment { .. })),
439            5
440        );
441        assert!(!texts(&shapes).iter().any(|t| t == "only"));
442    }
443
444    #[test]
445    fn no_measure_charts_nothing_and_all_negative_data_has_no_zero_line() {
446        let app = ImprovApp::load("").expect("in-memory model");
447        assert_eq!(app.chart_series(), ChartData::default());
448        let data = ChartData {
449            x_title: String::new(),
450            x_labels: vec!["a".into(), "b".into()],
451            series: vec![Series {
452                name: String::new(),
453                points: vec![Some(-1.0), Some(-2.0)],
454            }],
455        };
456        assert_eq!(data.y_range(), (-2.0, 0.0));
457        let shapes = paint(&data, false);
458        assert_eq!(count(&shapes, is_bar), 2);
459        // Axes + 3 gridlines; 0 is the top of the range, so no zero line.
460        assert_eq!(
461            count(&shapes, |s| matches!(s, Shape::LineSegment { .. })),
462            5
463        );
464    }
465}