1use crate::app::ImprovApp;
20
21#[derive(Debug, Default, PartialEq)]
25pub struct ChartData {
26 pub x_title: String,
28 pub x_labels: Vec<String>,
30 pub series: Vec<Series>,
32}
33
34#[derive(Debug, PartialEq)]
35pub struct Series {
36 pub name: String,
38 pub points: Vec<Option<f64>>,
40}
41
42impl ChartData {
43 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 pub fn chart_series(&self) -> ChartData {
77 let Some(measure) = self.selected() else {
78 return ChartData::default();
79 };
80 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 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 let x_title = row_cats
99 .iter()
100 .filter_map(|c| self.category_name_pub(*c))
101 .collect::<Vec<_>>()
102 .join(" / ");
103 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
130fn 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
139pub 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 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 let y_of = |v: f64| -> f32 {
177 let t = (v - y_lo) / span;
178 plot.bottom() - t as f32 * plot.height()
179 };
180
181 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 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 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; 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 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 for (xi, y) in s.points.iter().enumerate() {
229 let Some(v) = y else { continue }; 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 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 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 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 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 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 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 assert_eq!(segs(&bars_lines) - segs(&bars_only), 4);
417 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 assert_eq!(
461 count(&shapes, |s| matches!(s, Shape::LineSegment { .. })),
462 5
463 );
464 }
465}