Skip to main content

frus_widgets/
chart.rs

1//! The "charts" domain: self-painted, themed **data views**.
2//!
3//! - [`BarChart`]: a series of `(label, value)` as vertical bars scaled to the largest value,
4//!   the value above, the label below, a baseline.
5//! - [`LineChart`]: the same series drawn as a **polyline** (segments joining the points, round
6//!   markers), to read a trend rather than compare magnitudes.
7//!
8//! Both are purely **self-painted** (no children) and not generic over `Msg` (like
9//! [`crate::Icon`]): they are data views, not controls.
10
11use frus_core::{Color, Path, Point, Rect, ResolvedTextStyle, Scene};
12use frus_layout::{Dimension, Style};
13
14use crate::interaction::Status;
15use crate::theme::Theme;
16use crate::widget::Widget;
17
18/// Default height of the chart (logical px).
19const DEFAULT_HEIGHT: f32 = 200.0;
20/// Band reserved for the category labels below the baseline.
21const X_LABEL_H: f32 = 22.0;
22/// Font size of the values (above the bars) and of the labels (below).
23const VALUE_SIZE: f32 = 12.0;
24const LABEL_SIZE: f32 = 12.0;
25/// Font size of the share (`%`) written inside a stratum in 100% mode (milestone 227).
26const STRATA_LABEL_SIZE: f32 = 11.0;
27/// Fraction of a bar's "slot" actually taken by the bar (the rest = the spacing).
28const BAR_FILL: f32 = 0.6;
29/// Width of the left margin reserved for the y-axis ticks (when there is an axis).
30const Y_AXIS_W: f32 = 34.0;
31/// Font size of the y-axis ticks.
32const AXIS_SIZE: f32 = 11.0;
33
34// A chart's marks — axis ticks, value labels, the legend, the tooltip — are drawn with
35// [`ResolvedTextStyle::exact`] and so do **not** follow the reader's font size. They are
36// annotations pinned to a plot area of a height the caller fixed: type that grew would run
37// out of the plot rather than move it. Making the plot itself answer to the reader is a
38// chart-layout change and is recorded in milestone 406 rather than smuggled in here.
39
40/// A bar chart.
41///
42/// ```
43/// use frus_widgets::BarChart;
44/// let chart: BarChart = BarChart::new([("Mon", 3.0), ("Tue", 5.0), ("Wed", 2.0)]).height(160.0);
45/// ```
46pub struct BarChart<Msg = crate::callback::Callback> {
47    values: Vec<(String, f32)>,
48    /// Colour of the bars; `None` = the theme's `primary`.
49    color: Option<Color>,
50    height: f32,
51    /// Number of y-axis divisions (grid lines + ticks); `0` = no axis.
52    grid: usize,
53    /// Name of the main series (for the legend); `None` = anonymous.
54    name: Option<String>,
55    /// **Extra** series `(name, colour, values)` — bars **grouped** by category.
56    extra: Vec<(String, Color, Vec<f32>)>,
57    /// Show a legend (one swatch + name per series)?
58    legend: bool,
59    /// Indices of **hidden** series (not drawn, dimmed in the legend) — milestone 215.
60    hidden: Vec<usize>,
61    /// Message emitted on a click on a legend entry (the series index) — milestone 215.
62    on_legend: Option<Box<dyn Fn(usize) -> Msg>>,
63    /// Stack the series (cumulative bars) rather than group them? — milestone 216.
64    stacked: bool,
65    /// Message emitted on a click on a **bar** `(category, series)` — milestone 222.
66    on_point: Option<Box<dyn Fn(usize, usize) -> Msg>>,
67    /// **Pinned** bar/stratum `(category, series)`, highlighted by a persistent ring —
68    /// milestone 223.
69    selected: Option<(usize, usize)>,
70    /// **100%** stacking: each column is normalised to its own total (proportions) — milestone 224.
71    normalized: bool,
72}
73
74impl<Msg> BarChart<Msg> {
75    /// Creates a chart from a series of `(label, value)`.
76    pub fn new(data: impl IntoIterator<Item = (impl Into<String>, f32)>) -> Self {
77        Self {
78            values: data
79                .into_iter()
80                .map(|(l, v)| (l.into(), v.max(0.0)))
81                .collect(),
82            color: None,
83            height: DEFAULT_HEIGHT,
84            grid: 0,
85            name: None,
86            extra: Vec::new(),
87            legend: false,
88            hidden: Vec::new(),
89            on_legend: None,
90            stacked: false,
91            on_point: None,
92            selected: None,
93            normalized: false,
94        }
95    }
96
97    /// Overrides the colour of the bars (default: the theme's `primary`).
98    pub fn color(mut self, color: Color) -> Self {
99        self.color = Some(color);
100        self
101    }
102
103    /// Height of the chart in logical pixels (200 by default).
104    pub fn height(mut self, height: f32) -> Self {
105        self.height = height.max(X_LABEL_H + VALUE_SIZE + 8.0);
106        self
107    }
108
109    /// Adds a **y-axis**: `divisions` horizontal grid lines with their ticks (`0..max`) in a
110    /// left margin. `0` (the default) = no axis.
111    pub fn grid(mut self, divisions: usize) -> Self {
112        self.grid = divisions;
113        self
114    }
115
116    /// Names the **main** series (displayed in the legend).
117    pub fn name(mut self, name: impl Into<String>) -> Self {
118        self.name = Some(name.into());
119        self
120    }
121
122    /// Adds an **extra series** `(name, colour, values)`, drawn as bars **grouped** side by
123    /// side within each category. Every series shares the scale and the axis.
124    pub fn series(
125        mut self,
126        name: impl Into<String>,
127        color: Color,
128        values: impl IntoIterator<Item = f32>,
129    ) -> Self {
130        self.extra.push((
131            name.into(),
132            color,
133            values.into_iter().map(|v| v.max(0.0)).collect(),
134        ));
135        self
136    }
137
138    /// Shows a **legend** (colour swatch + name) for each series. Off by default.
139    pub fn legend(mut self, legend: bool) -> Self {
140        self.legend = legend;
141        self
142    }
143
144    /// **Hides** the series at the given indices (not drawn, dimmed in the legend) — milestone 215.
145    pub fn hidden(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
146        self.hidden = indices.into_iter().collect();
147        self
148    }
149
150    /// Makes the **legend clickable**: `on_legend(index)` on a click on an entry — milestone 215.
151    pub fn on_legend<R: crate::callback::IntoMsg<Msg>>(
152        mut self,
153        on_legend: impl Fn(usize) -> R + 'static,
154    ) -> Self {
155        self.on_legend = Some(Box::new(crate::callback::handler1(on_legend)));
156        self
157    }
158
159    /// **Stacks** the series: one bar per category, segmented by series (cumulative bars),
160    /// instead of bars grouped side by side (milestone 212). Off by default.
161    pub fn stacked(mut self, stacked: bool) -> Self {
162        self.stacked = stacked;
163        self
164    }
165
166    /// Makes the **bars clickable**: `on_point(category, series)` on a click on a visible bar
167    /// (or stacked stratum). None by default — milestone 222.
168    pub fn on_point<R: crate::callback::IntoMsg<Msg>>(
169        mut self,
170        on_point: impl Fn(usize, usize) -> R + 'static,
171    ) -> Self {
172        self.on_point = Some(Box::new(crate::callback::handler2(on_point)));
173        self
174    }
175
176    /// **Pins** a bar/stratum `(category, series)`: it gets a persistent accent ring
177    /// (highlighting the current selection, as a clicked detail). `None` = nothing — milestone 223.
178    pub fn selected(mut self, selected: Option<(usize, usize)>) -> Self {
179        self.selected = selected;
180        self
181    }
182
183    /// Normalises the stacking to **100%**: each column fills the whole height, each stratum
184    /// taking its **share** of the category's total (rather than its absolute value). Only has
185    /// an effect in multi-series stacked mode. Off by default — milestone 224.
186    pub fn normalized(mut self, normalized: bool) -> Self {
187        self.normalized = normalized;
188        self
189    }
190
191    /// The largest value across **all** the series (at least 1, for a stable scale).
192    fn max_value(&self) -> f32 {
193        let primary = self.values.iter().map(|(_, v)| *v);
194        let extra = self.extra.iter().flat_map(|(_, _, vs)| vs.iter().copied());
195        primary.chain(extra).fold(0.0, f32::max).max(1.0)
196    }
197
198    /// Total (of the **visible** series) of category `i` — the denominator of 100% stacking.
199    fn category_total(&self, i: usize) -> f32 {
200        let base = if self.hidden.contains(&0) {
201            0.0
202        } else {
203            self.values.get(i).map(|(_, v)| *v).unwrap_or(0.0)
204        };
205        let rest: f32 = self
206            .extra
207            .iter()
208            .enumerate()
209            .filter(|(j, _)| !self.hidden.contains(&(j + 1)))
210            .map(|(_, (_, _, vs))| vs.get(i).copied().unwrap_or(0.0))
211            .sum();
212        (base + rest).max(1e-6)
213    }
214
215    /// The largest **sum** of the series per category (the scale in stacked mode).
216    fn stacked_max(&self) -> f32 {
217        let n = self.values.len();
218        (0..n)
219            .map(|i| {
220                self.values[i].1
221                    + self
222                        .extra
223                        .iter()
224                        .map(|(_, _, vs)| vs.get(i).copied().unwrap_or(0.0))
225                        .sum::<f32>()
226            })
227            .fold(0.0, f32::max)
228            .max(1.0)
229    }
230
231    /// Should the legend be drawn (enabled **and** at least one named series)?
232    fn has_legend(&self) -> bool {
233        self.legend && (self.name.is_some() || !self.extra.is_empty())
234    }
235
236    /// Names of every series (the main one then the extras) — for the legend / for routing.
237    fn series_names(&self) -> Vec<&str> {
238        let mut names = vec![self.name.as_deref().unwrap_or("Series 1")];
239        names.extend(self.extra.iter().map(|(n, _, _)| n.as_str()));
240        names
241    }
242}
243
244/// Formats a value: as an integer if it is one, otherwise with one decimal.
245fn format_value(v: f32) -> String {
246    if (v.fract()).abs() < 1e-6 {
247        format!("{}", v as i64)
248    } else {
249        format!("{v:.1}")
250    }
251}
252
253/// Formats a measure for the tooltip: the raw value, followed by its **share** (`%`) of the total
254/// when a 100%-stacking denominator is supplied (milestone 226). `None` = the value alone.
255fn format_measure(value: f32, percent_of: Option<f32>) -> String {
256    match percent_of {
257        Some(total) if total > 0.0 => {
258            format!(
259                "{} ({}%)",
260                format_value(value),
261                (value / total * 100.0).round() as i64
262            )
263        }
264        _ => format_value(value),
265    }
266}
267
268/// Width of the axis margin if `divisions > 0`, otherwise `0` (shared by BarChart / LineChart).
269fn axis_width(divisions: usize) -> f32 {
270    if divisions > 0 {
271        Y_AXIS_W
272    } else {
273        0.0
274    }
275}
276
277/// Draws the **y-axis**: `divisions` horizontal grid lines from `plot_left` to
278/// `plot_left + plot_w`, spread between the baseline and the top of the plot area, each labelled
279/// with its value (`0..max`), right-aligned in the left margin. Shared by both charts — the grid
280/// reads behind the bars or the curve.
281#[allow(clippy::too_many_arguments)]
282fn draw_grid(
283    scene: &mut Scene,
284    theme: &Theme,
285    plot_left: f32,
286    plot_w: f32,
287    plot_top: f32,
288    baseline_y: f32,
289    max: f32,
290    divisions: usize,
291    percent: bool,
292    opacity: f32,
293) {
294    if divisions == 0 {
295        return;
296    }
297    let plot_h = baseline_y - plot_top;
298    for i in 0..=divisions {
299        let t = i as f32 / divisions as f32;
300        let y = baseline_y - plot_h * t;
301        // A grid line (except i == 0: that is the baseline, already drawn by the chart).
302        if i > 0 {
303            scene.fill_rect(
304                Rect::new(plot_left, y, plot_w, 1.0),
305                theme.scheme.outline_variant.fade(opacity * 0.6),
306            );
307        }
308        // A tick: the value (or the percentage in 100% mode), right-aligned in the margin.
309        let label = if percent {
310            format!("{}%", (t * 100.0).round() as i64)
311        } else {
312            format_value(max * t)
313        };
314        let lw = frus_text::measure(&label, AXIS_SIZE).width;
315        scene.text(
316            Point::new(plot_left - 6.0 - lw, y - AXIS_SIZE * 0.5),
317            label,
318            &ResolvedTextStyle::exact(AXIS_SIZE),
319            theme.muted.fade(opacity),
320        );
321    }
322}
323
324/// Draws a **legend** (colour swatch + name, from left to right) in the top band.
325/// Shared by BarChart / LineChart (milestone 209/212).
326fn draw_legend(
327    scene: &mut Scene,
328    theme: &Theme,
329    left: f32,
330    top: f32,
331    series: &[(Color, &str)],
332    o: f32,
333) {
334    let mut x = left;
335    let sy = top + (LEGEND_H - LEGEND_SWATCH) * 0.5;
336    for (color, name) in series {
337        scene.draw_rect(
338            Rect::new(x, sy, LEGEND_SWATCH, LEGEND_SWATCH),
339            color.fade(o),
340            2.0,
341            0.0,
342            Color::TRANSPARENT,
343        );
344        x += LEGEND_SWATCH + 5.0;
345        scene.text(
346            Point::new(x, top + (LEGEND_H - LEGEND_SIZE) * 0.5),
347            (*name).to_string(),
348            &ResolvedTextStyle::exact(LEGEND_SIZE),
349            theme.muted.fade(o),
350        );
351        x += frus_text::measure(name, LEGEND_SIZE).width + 16.0;
352    }
353}
354
355/// Which **legend entry** contains the local point `(x, y)`? Rebuilds [`draw_legend`]'s layout to
356/// route a click to the series index. Shared (milestone 215).
357fn legend_hit(local_x: f32, local_y: f32, plot_left: f32, names: &[&str]) -> Option<usize> {
358    if !(0.0..=LEGEND_H).contains(&local_y) {
359        return None;
360    }
361    let mut x = plot_left;
362    for (i, name) in names.iter().enumerate() {
363        let entry_w = LEGEND_SWATCH + 5.0 + frus_text::measure(name, LEGEND_SIZE).width;
364        if local_x >= x && local_x <= x + entry_w {
365            return Some(i);
366        }
367        x += entry_w + 16.0;
368    }
369    None
370}
371
372/// Draws a hover **tooltip**: a vertical guide at `gx`, then a box listing `lines` (each line: an
373/// optional swatch + text). The box is sized to the longest label, placed to the right of the guide
374/// (flipped to the left if it overflows), anchored at the top of the plot area.
375/// Shared by BarChart / LineChart (milestone 211/212).
376#[allow(clippy::too_many_arguments)]
377fn draw_tooltip(
378    scene: &mut Scene,
379    theme: &Theme,
380    bounds: Rect,
381    gx: f32,
382    plot_top: f32,
383    baseline_y: f32,
384    lines: &[(Option<Color>, String)],
385    o: f32,
386) {
387    scene.fill_rect(
388        Rect::new(gx - 0.5, plot_top, 1.0, baseline_y - plot_top),
389        theme.scheme.outline_variant.fade(o * 0.8),
390    );
391    let pad = 8.0;
392    let line_h = TOOLTIP_SIZE + 5.0;
393    let dot = 7.0;
394    let text_w = lines
395        .iter()
396        .map(|(c, t)| {
397            let lead = if c.is_some() { dot + 5.0 } else { 0.0 };
398            lead + frus_text::measure(t, TOOLTIP_SIZE).width
399        })
400        .fold(0.0, f32::max);
401    let bw = text_w + pad * 2.0;
402    let bh = lines.len() as f32 * line_h + pad * 2.0 - 3.0;
403    let mut bx = gx + 12.0;
404    if bx + bw > bounds.x + bounds.width {
405        bx = gx - 12.0 - bw;
406    }
407    bx = bx.max(bounds.x);
408    let by = plot_top.max(bounds.y);
409    scene.draw_rect(
410        Rect::new(bx, by, bw, bh),
411        theme.surface.fade(o),
412        6.0,
413        1.0,
414        theme.scheme.outline_variant.fade(o),
415    );
416    let mut ty = by + pad;
417    for (c, t) in lines {
418        let mut tx = bx + pad;
419        if let Some(col) = c {
420            scene.fill_path(
421                &Path::circle(
422                    Point::new(tx + dot * 0.5, ty + TOOLTIP_SIZE * 0.5),
423                    dot * 0.5,
424                ),
425                col.fade(o),
426            );
427            tx += dot + 5.0;
428        }
429        scene.text(
430            Point::new(tx, ty),
431            t.clone(),
432            &ResolvedTextStyle::exact(TOOLTIP_SIZE),
433            theme.on_surface.fade(o),
434        );
435        ty += line_h;
436    }
437}
438
439/// Is the local point `(x, y)` inside the **plot area**? If so, returns `Cursor::Default` (which
440/// turns on pointer tracking for the tooltip without changing the cursor's shape — a chart is not
441/// clickable), otherwise `None`. Shared by BarChart / LineChart (milestone 211/212).
442fn chart_plot_hit(
443    local_x: f32,
444    local_y: f32,
445    width: f32,
446    height: f32,
447    plot_left: f32,
448    plot_top: f32,
449) -> Option<crate::interaction::Cursor> {
450    let baseline_y = height - X_LABEL_H;
451    if local_x >= plot_left && local_x <= width && local_y >= plot_top && local_y <= baseline_y {
452        Some(crate::interaction::Cursor::Default)
453    } else {
454        None
455    }
456}
457
458impl<Msg> Widget<Msg> for BarChart<Msg> {
459    /// It asks to **fill the width it is offered** rather than declaring one — see
460    /// [`Widget::fill_axes`]. A `width: 100%` resolves against the parent's *resolved*
461    /// width, which a parent that shrink-wraps does not have yet.
462    fn style(&self) -> Style {
463        Style {
464            height: Dimension::Length(self.height),
465            ..Default::default()
466        }
467    }
468
469    /// The width it was **offered**, not the width its parent came out at.
470    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
471        crate::widget::FillAxes::WIDTH
472    }
473
474    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
475        &[]
476    }
477
478    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
479        let n = self.values.len();
480        if n == 0 {
481            return;
482        }
483        let o = status.opacity;
484        let accent = self.color.unwrap_or(theme.primary);
485        let single = self.extra.is_empty();
486        let stacked = self.stacked && !single;
487        // **100%** stacking (milestone 224): each column is normalised to its own total (proportions).
488        let normalized = self.normalized && stacked;
489        // When stacked, the scale must hold the cumulative **total** per category.
490        let max = if stacked {
491            self.stacked_max()
492        } else {
493            self.max_value()
494        };
495
496        // Plot area: below the band of values, above the category labels; a left margin for the
497        // axis, and — where there is one — a legend band right at the top.
498        let legend_h = if self.has_legend() { LEGEND_H } else { 0.0 };
499        let baseline_y = bounds.y + bounds.height - X_LABEL_H;
500        let plot_top = bounds.y + legend_h + VALUE_SIZE + 6.0;
501        let plot_h = (baseline_y - plot_top).max(1.0);
502        let axis_w = axis_width(self.grid);
503        let plot_left = bounds.x + axis_w;
504        let plot_w = bounds.width - axis_w;
505        let slot = plot_w / n as f32;
506
507        // Horizontal grid + ticks (behind the bars); in 100% mode the axis is in percentages.
508        draw_grid(
509            scene, theme, plot_left, plot_w, plot_top, baseline_y, max, self.grid, normalized, o,
510        );
511        // Baseline (the x-axis).
512        scene.fill_rect(
513            Rect::new(plot_left, baseline_y, plot_w, 1.5),
514            theme.scheme.outline_variant.fade(o),
515        );
516
517        // Every series: the main one then the extras (grouped bars).
518        let primary: Vec<f32> = self.values.iter().map(|(_, v)| *v).collect();
519        let mut series: Vec<(Color, &str, &[f32])> = vec![(
520            accent,
521            self.name.as_deref().unwrap_or("Series 1"),
522            primary.as_slice(),
523        )];
524        for (name, color, vals) in &self.extra {
525            series.push((*color, name.as_str(), vals.as_slice()));
526        }
527        let s = series.len();
528
529        // Each category: either a group of `s` bars side by side (milestone 212), or — when
530        // stacked — a single bar segmented by series (cumulative bars, milestone 216).
531        let group_w = slot * BAR_FILL;
532        let bar_w = group_w / s as f32;
533        let inner = if s == 1 { 1.0 } else { 0.86 };
534        // Rect of the pinned bar/stratum, captured to draw its ring afterwards (milestone 223).
535        let mut sel_rect: Option<Rect> = None;
536        for i in 0..n {
537            let cx = plot_left + slot * (i as f32 + 0.5);
538            if stacked {
539                // Segments stacked from the bottom up (hidden series do not count). In 100% mode
540                // the denominator is the category's total (a full column), otherwise the scale.
541                let sbx = cx - group_w * 0.5;
542                let denom = if normalized {
543                    self.category_total(i)
544                } else {
545                    max
546                };
547                let mut lower = 0.0_f32;
548                for (j, (color, _, vals)) in series.iter().enumerate() {
549                    if self.hidden.contains(&j) {
550                        continue;
551                    }
552                    let value = vals.get(i).copied().unwrap_or(0.0);
553                    let y_bottom = baseline_y - (lower / denom) * plot_h;
554                    let y_top = baseline_y - ((lower + value) / denom) * plot_h;
555                    let rect = Rect::new(sbx, y_top, group_w, y_bottom - y_top);
556                    scene.draw_rect(rect, color.fade(o), 0.0, 0.0, Color::TRANSPARENT);
557                    if self.selected == Some((i, j)) {
558                        sel_rect = Some(rect);
559                    }
560                    // Label centred in the stratum if it is tall enough to hold it: the share (%)
561                    // in 100% mode (milestone 227), the raw value in absolute stacked mode
562                    // (milestone 229). Text legible over a saturated background.
563                    let seg_h = y_bottom - y_top;
564                    if seg_h >= STRATA_LABEL_SIZE + 4.0 {
565                        let label = if normalized {
566                            format!("{}%", (value / denom * 100.0).round() as i64)
567                        } else {
568                            format_value(value)
569                        };
570                        let lw = frus_text::measure(&label, STRATA_LABEL_SIZE).width;
571                        scene.text(
572                            Point::new(
573                                cx - lw * 0.5,
574                                (y_top + y_bottom) * 0.5 - STRATA_LABEL_SIZE * 0.5,
575                            ),
576                            label,
577                            &ResolvedTextStyle::exact(STRATA_LABEL_SIZE),
578                            theme.on_primary.fade(o * 0.95),
579                        );
580                    }
581                    lower += value;
582                }
583                // The column's total above the topmost stratum (**absolute** stacking: parity with
584                // the value on plain bars; in 100% mode the column is full) — milestone 228.
585                if !normalized && lower > 0.0 {
586                    let vs = format_value(lower);
587                    let vw = frus_text::measure(&vs, VALUE_SIZE).width;
588                    let top_y = baseline_y - (lower / denom) * plot_h;
589                    scene.text(
590                        Point::new(cx - vw * 0.5, top_y - VALUE_SIZE - 2.0),
591                        vs,
592                        &ResolvedTextStyle::exact(VALUE_SIZE),
593                        theme.on_surface.fade(o),
594                    );
595                }
596            } else {
597                let group_left = cx - group_w * 0.5;
598                for (j, (color, _, vals)) in series.iter().enumerate() {
599                    if self.hidden.contains(&j) {
600                        continue;
601                    }
602                    let value = vals.get(i).copied().unwrap_or(0.0);
603                    let h = (value / max) * plot_h;
604                    let draw_w = bar_w * inner;
605                    let bx = group_left + j as f32 * bar_w + (bar_w - draw_w) * 0.5;
606                    let rect = Rect::new(bx, baseline_y - h, draw_w, h);
607                    scene.draw_rect(rect, color.fade(o), 4.0, 0.0, Color::TRANSPARENT);
608                    if self.selected == Some((i, j)) {
609                        sel_rect = Some(rect);
610                    }
611                    // The value above (single series only, to avoid the clutter).
612                    if single {
613                        let vs = format_value(value);
614                        let vw = frus_text::measure(&vs, VALUE_SIZE).width;
615                        scene.text(
616                            Point::new(cx - vw * 0.5, baseline_y - h - VALUE_SIZE - 2.0),
617                            vs,
618                            &ResolvedTextStyle::exact(VALUE_SIZE),
619                            theme.on_surface.fade(o),
620                        );
621                    }
622                }
623            }
624            // Category label below the baseline.
625            let label = &self.values[i].0;
626            let lw = frus_text::measure(label, LABEL_SIZE).width;
627            scene.text(
628                Point::new(cx - lw * 0.5, baseline_y + 4.0),
629                label.clone(),
630                &ResolvedTextStyle::exact(LABEL_SIZE),
631                theme.muted.fade(o),
632            );
633        }
634
635        // Pinned bar/stratum (milestone 223): a persistent accent ring, slightly inflated around
636        // the rect, in a contrasting colour (independent of hover).
637        if let Some(r) = sel_rect {
638            scene.draw_rect(
639                Rect::new(r.x - 2.5, r.y - 2.5, r.width + 5.0, r.height + 5.0),
640                Color::TRANSPARENT,
641                5.0,
642                2.0,
643                theme.on_surface.fade(o),
644            );
645        }
646
647        // Legend (the top band), shared; hidden series are dimmed in it.
648        if self.has_legend() {
649            let entries: Vec<(Color, &str)> = series
650                .iter()
651                .enumerate()
652                .map(|(i, (c, name, _))| {
653                    (
654                        if self.hidden.contains(&i) {
655                            c.fade(0.35)
656                        } else {
657                            *c
658                        },
659                        *name,
660                    )
661                })
662                .collect();
663            draw_legend(scene, theme, plot_left, bounds.y, &entries, o);
664        }
665
666        // Hover tooltip (milestone 212): the nearest category + the value of each visible series.
667        if let Some(hc) = status.hover_cursor {
668            let lx = hc.x - bounds.x;
669            let hi =
670                (((lx - plot_left) / slot - 0.5).round() as i64).clamp(0, n as i64 - 1) as usize;
671            let gx = plot_left + slot * (hi as f32 + 0.5);
672            let mut lines: Vec<(Option<Color>, String)> = vec![(None, self.values[hi].0.clone())];
673            // In 100% mode each measure is followed by its share of the hovered category's total (milestone 226).
674            let percent_of = if normalized {
675                Some(self.category_total(hi))
676            } else {
677                None
678            };
679            for (j, (color, name, vals)) in series.iter().enumerate() {
680                if self.hidden.contains(&j) {
681                    continue;
682                }
683                let value = vals.get(hi).copied().unwrap_or(0.0);
684                let vtxt = format_measure(value, percent_of);
685                let txt = if single {
686                    vtxt
687                } else {
688                    format!("{}  {}", name, vtxt)
689                };
690                lines.push((Some(*color), txt));
691            }
692            draw_tooltip(scene, theme, bounds, gx, plot_top, baseline_y, &lines, o);
693        }
694    }
695
696    fn cursor_icon(
697        &self,
698        local_x: f32,
699        local_y: f32,
700        width: f32,
701        height: f32,
702    ) -> Option<crate::interaction::Cursor> {
703        if self.values.is_empty() {
704            return None;
705        }
706        let legend_h = if self.has_legend() { LEGEND_H } else { 0.0 };
707        let plot_top = legend_h + VALUE_SIZE + 6.0;
708        chart_plot_hit(
709            local_x,
710            local_y,
711            width,
712            height,
713            axis_width(self.grid),
714            plot_top,
715        )
716    }
717
718    fn positional_click(&self, local_x: f32, local_y: f32, width: f32, height: f32) -> Option<Msg> {
719        // 1) A click on a legend entry → on_legend(index) (milestone 215).
720        if let Some(f) = &self.on_legend {
721            if self.has_legend() {
722                if let Some(idx) = legend_hit(
723                    local_x,
724                    local_y,
725                    axis_width(self.grid),
726                    &self.series_names(),
727                ) {
728                    return Some(f(idx));
729                }
730            }
731        }
732        // 2) A click on a **bar** (or a stacked stratum) → on_point(category, series) (milestone 222).
733        // The geometry matches the paint: each rect is rebuilt and tested for containment.
734        let f = self.on_point.as_ref()?;
735        let n = self.values.len();
736        if n == 0 {
737            return None;
738        }
739        let single = self.extra.is_empty();
740        let stacked = self.stacked && !single;
741        let max = if stacked {
742            self.stacked_max()
743        } else {
744            self.max_value()
745        };
746        let legend_h = if self.has_legend() { LEGEND_H } else { 0.0 };
747        let baseline_y = height - X_LABEL_H;
748        let plot_top = legend_h + VALUE_SIZE + 6.0;
749        let plot_h = (baseline_y - plot_top).max(1.0);
750        let plot_left = axis_width(self.grid);
751        let slot = (width - plot_left) / n as f32;
752        let s = 1 + self.extra.len();
753        let group_w = slot * BAR_FILL;
754        let bar_w = group_w / s as f32;
755        let inner = if s == 1 { 1.0 } else { 0.86 };
756        let primary: Vec<f32> = self.values.iter().map(|(_, v)| *v).collect();
757        let value_at = |j: usize, i: usize| -> f32 {
758            if j == 0 {
759                primary.get(i).copied().unwrap_or(0.0)
760            } else {
761                self.extra[j - 1].2.get(i).copied().unwrap_or(0.0)
762            }
763        };
764        for i in 0..n {
765            let cx = plot_left + slot * (i as f32 + 0.5);
766            if stacked {
767                let sbx = cx - group_w * 0.5;
768                let mut lower = 0.0_f32;
769                for j in 0..s {
770                    if self.hidden.contains(&j) {
771                        continue;
772                    }
773                    let value = value_at(j, i);
774                    let y_bottom = baseline_y - (lower / max) * plot_h;
775                    let y_top = baseline_y - ((lower + value) / max) * plot_h;
776                    if local_x >= sbx
777                        && local_x <= sbx + group_w
778                        && local_y >= y_top
779                        && local_y <= y_bottom
780                    {
781                        return Some(f(i, j));
782                    }
783                    lower += value;
784                }
785            } else {
786                let group_left = cx - group_w * 0.5;
787                for j in 0..s {
788                    if self.hidden.contains(&j) {
789                        continue;
790                    }
791                    let h = (value_at(j, i) / max) * plot_h;
792                    let draw_w = bar_w * inner;
793                    let bx = group_left + j as f32 * bar_w + (bar_w - draw_w) * 0.5;
794                    if local_x >= bx
795                        && local_x <= bx + draw_w
796                        && local_y >= baseline_y - h
797                        && local_y <= baseline_y
798                    {
799                        return Some(f(i, j));
800                    }
801                }
802            }
803        }
804        None
805    }
806
807    fn on_click(&self) -> Option<Msg> {
808        None
809    }
810}
811
812/// Radius (px) of the round markers placed on each point of a [`LineChart`].
813const MARKER_R: f32 = 3.5;
814/// Tolerance radius (px) for clicking a point (milestone 221).
815const POINT_HIT_R: f32 = 12.0;
816/// (Relative) opacity of the area filled under the curve.
817const AREA_ALPHA: f32 = 0.16;
818/// (Relative) opacity of the bands of a **stacked area** chart (stronger, so each stratum can
819/// be read).
820const STACK_ALPHA: f32 = 0.55;
821/// Height of the legend band (above the plot area) when it is shown.
822const LEGEND_H: f32 = 20.0;
823/// Side of the colour swatch of a legend entry.
824const LEGEND_SWATCH: f32 = 10.0;
825/// Font size of the legend entries.
826const LEGEND_SIZE: f32 = 12.0;
827/// Font size of a tooltip's content.
828const TOOLTIP_SIZE: f32 = 12.0;
829/// Speed (cycles per second) of the animated pulsing halo (milestone 217).
830const PULSE_SPEED: f32 = 1.6;
831/// Growth (px) of the pulsing halo's radius over one cycle.
832const PULSE_GROW: f32 = 10.0;
833/// Thickness (px) of the polyline's stroke.
834const LINE_W: f32 = 2.0;
835
836/// A **line** chart: the same `(label, value)` series as a [`BarChart`], but joined into a
837/// polyline (segments + markers) so that a **trend** can be read.
838///
839/// ```
840/// use frus_widgets::LineChart;
841/// let chart: LineChart = LineChart::new([("Mon", 3.0), ("Tue", 5.0), ("Wed", 2.0)]).height(160.0);
842/// ```
843pub struct LineChart<Msg = crate::callback::Callback> {
844    values: Vec<(String, f32)>,
845    /// Colour of the stroke and of the markers; `None` = the theme's `primary`.
846    color: Option<Color>,
847    height: f32,
848    /// Number of y-axis divisions (grid lines + ticks); `0` = no axis.
849    grid: usize,
850    /// Fill the area under the curve (a flat wash, the stroke's colour dimmed)?
851    fill: bool,
852    /// Name of the main series (for the legend); `None` = anonymous.
853    name: Option<String>,
854    /// **Extra** series `(name, colour, values)`, aligned by index onto the main series'
855    /// categories.
856    extra: Vec<(String, Color, Vec<f32>)>,
857    /// Show a legend (one swatch + name per series)?
858    legend: bool,
859    /// Stack the series (cumulative areas) rather than overlay them?
860    stacked: bool,
861    /// Indices of **hidden** series (not drawn, dimmed in the legend) — milestone 215.
862    hidden: Vec<usize>,
863    /// Message emitted on a click on a legend entry (the series index) — milestone 215.
864    on_legend: Option<Box<dyn Fn(usize) -> Msg>>,
865    /// Animate a **pulsing halo** on the hovered point (continuous repaint) — milestone 217.
866    animated: bool,
867    /// Message emitted on a click on a **point** `(category, series)` — milestone 221.
868    on_point: Option<Box<dyn Fn(usize, usize) -> Msg>>,
869    /// **Pinned** point `(category, series)`, highlighted by a persistent halo + ring —
870    /// milestone 223.
871    selected: Option<(usize, usize)>,
872    /// **100%** stacking: each category is normalised to its own total (proportions) — milestone 224.
873    normalized: bool,
874}
875
876impl<Msg> LineChart<Msg> {
877    /// Creates a line chart from a series of `(label, value)`.
878    pub fn new(data: impl IntoIterator<Item = (impl Into<String>, f32)>) -> Self {
879        Self {
880            values: data
881                .into_iter()
882                .map(|(l, v)| (l.into(), v.max(0.0)))
883                .collect(),
884            color: None,
885            height: DEFAULT_HEIGHT,
886            grid: 0,
887            fill: false,
888            name: None,
889            extra: Vec::new(),
890            legend: false,
891            stacked: false,
892            hidden: Vec::new(),
893            on_legend: None,
894            animated: false,
895            on_point: None,
896            selected: None,
897            normalized: false,
898        }
899    }
900
901    /// Overrides the colour of the stroke (default: the theme's `primary`).
902    pub fn color(mut self, color: Color) -> Self {
903        self.color = Some(color);
904        self
905    }
906
907    /// Height of the chart in logical pixels (200 by default).
908    pub fn height(mut self, height: f32) -> Self {
909        self.height = height.max(X_LABEL_H + VALUE_SIZE + 8.0);
910        self
911    }
912
913    /// Adds a **y-axis**: `divisions` horizontal grid lines with their ticks (`0..max`) in a
914    /// left margin. `0` (the default) = no axis.
915    pub fn grid(mut self, divisions: usize) -> Self {
916        self.grid = divisions;
917        self
918    }
919
920    /// Fills the **area** under the curve (the stroke's colour heavily dimmed), to emphasise
921    /// the volume rather than the trend alone. Off by default.
922    pub fn area(mut self, fill: bool) -> Self {
923        self.fill = fill;
924        self
925    }
926
927    /// Names the **main** series (displayed in the legend).
928    pub fn name(mut self, name: impl Into<String>) -> Self {
929        self.name = Some(name.into());
930        self
931    }
932
933    /// Adds an **extra series** `(name, colour, values)`, aligned by index onto the main series'
934    /// categories. Every series shares the scale and the axis.
935    pub fn series(
936        mut self,
937        name: impl Into<String>,
938        color: Color,
939        values: impl IntoIterator<Item = f32>,
940    ) -> Self {
941        self.extra.push((
942            name.into(),
943            color,
944            values.into_iter().map(|v| v.max(0.0)).collect(),
945        ));
946        self
947    }
948
949    /// Shows a **legend** (colour swatch + name) for each named series. Off by default.
950    pub fn legend(mut self, legend: bool) -> Self {
951        self.legend = legend;
952        self
953    }
954
955    /// **Stacks** the series: each area is cumulated above the previous ones (cumulative areas),
956    /// so a total and its composition can be read. Implies filling the bands. Off by default.
957    pub fn stacked(mut self, stacked: bool) -> Self {
958        self.stacked = stacked;
959        self
960    }
961
962    /// **Hides** the series at the given indices (not drawn, dimmed in the legend) — milestone 215.
963    pub fn hidden(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
964        self.hidden = indices.into_iter().collect();
965        self
966    }
967
968    /// Makes the **legend clickable**: `on_legend(index)` on a click on an entry — milestone 215.
969    pub fn on_legend<R: crate::callback::IntoMsg<Msg>>(
970        mut self,
971        on_legend: impl Fn(usize) -> R + 'static,
972    ) -> Self {
973        self.on_legend = Some(Box::new(crate::callback::handler1(on_legend)));
974        self
975    }
976
977    /// Animates a **pulsing halo** (growing then fading) on the hovered point. Asks for a
978    /// continuous repaint for as long as the chart is displayed. Off by default — milestone 217.
979    pub fn animated(mut self, animated: bool) -> Self {
980        self.animated = animated;
981        self
982    }
983
984    /// Makes the **points clickable**: `on_point(category, series)` on a click near a marker
985    /// (of the visible series). None by default — milestone 221.
986    pub fn on_point<R: crate::callback::IntoMsg<Msg>>(
987        mut self,
988        on_point: impl Fn(usize, usize) -> R + 'static,
989    ) -> Self {
990        self.on_point = Some(Box::new(crate::callback::handler2(on_point)));
991        self
992    }
993
994    /// **Pins** a point `(category, series)`: it gets a persistent halo + accent ring
995    /// (highlighting the current selection, as a clicked detail). `None` = nothing — milestone 223.
996    pub fn selected(mut self, selected: Option<(usize, usize)>) -> Self {
997        self.selected = selected;
998        self
999    }
1000
1001    /// Normalises the stacking to **100%**: each category fills the whole height, each stratum
1002    /// taking its **share** of the total. Only has an effect on multi-series stacked areas.
1003    /// Off by default — milestone 224.
1004    pub fn normalized(mut self, normalized: bool) -> Self {
1005        self.normalized = normalized;
1006        self
1007    }
1008
1009    /// Total (of the **visible** series) of category `i` — the denominator of 100% stacking.
1010    fn category_total(&self, i: usize) -> f32 {
1011        let base = if self.hidden.contains(&0) {
1012            0.0
1013        } else {
1014            self.values.get(i).map(|(_, v)| *v).unwrap_or(0.0)
1015        };
1016        let rest: f32 = self
1017            .extra
1018            .iter()
1019            .enumerate()
1020            .filter(|(j, _)| !self.hidden.contains(&(j + 1)))
1021            .map(|(_, (_, _, vs))| vs.get(i).copied().unwrap_or(0.0))
1022            .sum();
1023        (base + rest).max(1e-6)
1024    }
1025
1026    /// Names of every series (the main one then the extras).
1027    fn series_names(&self) -> Vec<&str> {
1028        let mut names = vec![self.name.as_deref().unwrap_or("Series 1")];
1029        names.extend(self.extra.iter().map(|(n, _, _)| n.as_str()));
1030        names
1031    }
1032
1033    /// The largest value across **all** the series (at least 1, for a stable scale).
1034    fn max_value(&self) -> f32 {
1035        let primary = self.values.iter().map(|(_, v)| *v);
1036        let extra = self.extra.iter().flat_map(|(_, _, vs)| vs.iter().copied());
1037        primary.chain(extra).fold(0.0, f32::max).max(1.0)
1038    }
1039
1040    /// The largest **sum** of the series per category (the scale in stacked mode).
1041    fn stacked_max(&self) -> f32 {
1042        let n = self.values.len();
1043        (0..n)
1044            .map(|i| {
1045                let base = self.values[i].1;
1046                let rest: f32 = self
1047                    .extra
1048                    .iter()
1049                    .map(|(_, _, vs)| vs.get(i).copied().unwrap_or(0.0))
1050                    .sum();
1051                base + rest
1052            })
1053            .fold(0.0, f32::max)
1054            .max(1.0)
1055    }
1056
1057    /// Should the legend be drawn (enabled **and** at least one named series)?
1058    fn has_legend(&self) -> bool {
1059        self.legend && (self.name.is_some() || !self.extra.is_empty())
1060    }
1061}
1062
1063impl<Msg> Widget<Msg> for LineChart<Msg> {
1064    /// It asks to **fill the width it is offered** rather than declaring one — see
1065    /// [`Widget::fill_axes`]. A `width: 100%` resolves against the parent's *resolved*
1066    /// width, which a parent that shrink-wraps does not have yet.
1067    fn style(&self) -> Style {
1068        Style {
1069            height: Dimension::Length(self.height),
1070            ..Default::default()
1071        }
1072    }
1073
1074    /// The width it was **offered**, not the width its parent came out at.
1075    fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
1076        crate::widget::FillAxes::WIDTH
1077    }
1078
1079    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1080        &[]
1081    }
1082
1083    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1084        let n = self.values.len();
1085        if n == 0 {
1086            return;
1087        }
1088        let o = status.opacity;
1089        let accent = self.color.unwrap_or(theme.primary);
1090        let single = self.extra.is_empty();
1091        let stacked = self.stacked && !single;
1092        // **100%** stacking (milestone 224): each category is normalised to its own total (proportions).
1093        let normalized = self.normalized && stacked;
1094        // When stacked, the scale must hold the cumulative **total** per category.
1095        let max = if stacked {
1096            self.stacked_max()
1097        } else {
1098            self.max_value()
1099        };
1100
1101        // Geometry shared with the BarChart: the band of values at the top (value labels, single
1102        // series only), the category labels at the bottom, a left margin for the axis, and — where
1103        // there is one — a legend band right at the top.
1104        let legend_h = if self.has_legend() { LEGEND_H } else { 0.0 };
1105        let baseline_y = bounds.y + bounds.height - X_LABEL_H;
1106        let plot_top = bounds.y + legend_h + VALUE_SIZE + 6.0;
1107        let plot_h = (baseline_y - plot_top).max(1.0);
1108        let axis_w = axis_width(self.grid);
1109        let plot_left = bounds.x + axis_w;
1110        let plot_w = bounds.width - axis_w;
1111        let slot = plot_w / n as f32;
1112
1113        // Horizontal grid + ticks (behind the curves); in 100% mode the axis is in percentages.
1114        draw_grid(
1115            scene, theme, plot_left, plot_w, plot_top, baseline_y, max, self.grid, normalized, o,
1116        );
1117
1118        // Baseline (the x-axis).
1119        scene.fill_rect(
1120            Rect::new(plot_left, baseline_y, plot_w, 1.5),
1121            theme.scheme.outline_variant.fade(o),
1122        );
1123
1124        // Every series to draw: the main one then the extras, aligned by index.
1125        let primary: Vec<f32> = self.values.iter().map(|(_, v)| *v).collect();
1126        let mut series: Vec<(Color, &str, &[f32])> = vec![(
1127            accent,
1128            self.name.as_deref().unwrap_or("Series 1"),
1129            primary.as_slice(),
1130        )];
1131        for (name, color, vals) in &self.extra {
1132            series.push((*color, name.as_str(), vals.as_slice()));
1133        }
1134
1135        // Coordinates of a value (index, value) → a screen point.
1136        let pt = |i: usize, v: f32| {
1137            Point::new(
1138                plot_left + slot * (i as f32 + 0.5),
1139                baseline_y - (v / max) * plot_h,
1140            )
1141        };
1142        // Point of a stacked **cumulative** value: in 100% mode the denominator is the category's
1143        // total (a full-height band), otherwise the global scale (milestone 224).
1144        let spt = |i: usize, cum: f32| {
1145            let denom = if normalized {
1146                self.category_total(i)
1147            } else {
1148                max
1149            };
1150            Point::new(
1151                plot_left + slot * (i as f32 + 0.5),
1152                baseline_y - (cum / denom) * plot_h,
1153            )
1154        };
1155
1156        if stacked {
1157            // **Cumulative** areas: each series is a band between its lower and upper cumulative
1158            // values, from the bottom up; the stroke follows the upper edge.
1159            let mut lower = vec![0.0_f32; n];
1160            for (j, (color, _, vals)) in series.iter().enumerate() {
1161                if self.hidden.contains(&j) {
1162                    continue;
1163                }
1164                let upper: Vec<f32> = (0..n)
1165                    .map(|i| lower[i] + vals.get(i).copied().unwrap_or(0.0))
1166                    .collect();
1167                if n >= 2 {
1168                    // The band: lower edge (left→right) then upper edge (right→left).
1169                    let mut band = Path::new().move_to(spt(0, lower[0]));
1170                    for (i, low) in lower.iter().enumerate().take(n).skip(1) {
1171                        band = band.line_to(spt(i, *low));
1172                    }
1173                    for i in (0..n).rev() {
1174                        band = band.line_to(spt(i, upper[i]));
1175                    }
1176                    scene.fill_path(&band, color.fade(o * STACK_ALPHA));
1177                    // Stroke of the upper edge.
1178                    let mut line = Path::new().move_to(spt(0, upper[0]));
1179                    for (i, high) in upper.iter().enumerate().take(n).skip(1) {
1180                        line = line.line_to(spt(i, *high));
1181                    }
1182                    scene.stroke_path(&line, color.fade(o), LINE_W);
1183                }
1184                // The value (or the % share) at the centre of the band on each category, where the
1185                // band is thick enough — parity with the bar strata (milestones 227/229) —
1186                // milestone 230.
1187                for i in 0..n {
1188                    let value = vals.get(i).copied().unwrap_or(0.0);
1189                    if value <= 0.0 {
1190                        continue;
1191                    }
1192                    let y_lo = spt(i, lower[i]).y;
1193                    let y_hi = spt(i, upper[i]).y;
1194                    if y_lo - y_hi >= STRATA_LABEL_SIZE + 4.0 {
1195                        let label = if normalized {
1196                            format!(
1197                                "{}%",
1198                                (value / self.category_total(i) * 100.0).round() as i64
1199                            )
1200                        } else {
1201                            format_value(value)
1202                        };
1203                        let lw = frus_text::measure(&label, STRATA_LABEL_SIZE).width;
1204                        let px = plot_left + slot * (i as f32 + 0.5);
1205                        // Clamps the label to the plot area (edge categories would overflow otherwise).
1206                        let lx = (px - lw * 0.5)
1207                            .clamp(plot_left, (plot_left + plot_w - lw).max(plot_left));
1208                        scene.text(
1209                            Point::new(lx, (y_lo + y_hi) * 0.5 - STRATA_LABEL_SIZE * 0.5),
1210                            label,
1211                            &ResolvedTextStyle::exact(STRATA_LABEL_SIZE),
1212                            theme.on_primary.fade(o * 0.95),
1213                        );
1214                    }
1215                }
1216                lower = upper;
1217            }
1218        } else {
1219            for (j, (color, _, vals)) in series.iter().enumerate() {
1220                if self.hidden.contains(&j) {
1221                    continue;
1222                }
1223                let points: Vec<Point> = (0..n.min(vals.len())).map(|i| pt(i, vals[i])).collect();
1224                // Area under the curve (single series only, a closed non-zero path).
1225                if single && self.fill && points.len() >= 2 {
1226                    let mut area = Path::new().move_to(Point::new(points[0].x, baseline_y));
1227                    for p in &points {
1228                        area = area.line_to(*p);
1229                    }
1230                    area = area.line_to(Point::new(points[points.len() - 1].x, baseline_y));
1231                    scene.fill_path(&area, color.fade(o * AREA_ALPHA));
1232                }
1233                // Polyligne.
1234                if points.len() >= 2 {
1235                    let mut line = Path::new().move_to(points[0]);
1236                    for p in &points[1..] {
1237                        line = line.line_to(*p);
1238                    }
1239                    scene.stroke_path(&line, color.fade(o), LINE_W);
1240                }
1241                // Markers, and — for a single series — the value above each point.
1242                for (i, p) in points.iter().enumerate() {
1243                    scene.fill_path(&Path::circle(*p, MARKER_R), color.fade(o));
1244                    if single {
1245                        let vs = format_value(vals[i]);
1246                        let vw = frus_text::measure(&vs, VALUE_SIZE).width;
1247                        scene.text(
1248                            Point::new(p.x - vw * 0.5, p.y - MARKER_R - VALUE_SIZE - 2.0),
1249                            vs,
1250                            &ResolvedTextStyle::exact(VALUE_SIZE),
1251                            theme.on_surface.fade(o),
1252                        );
1253                    }
1254                }
1255            }
1256        }
1257
1258        // Pinned point (milestone 223): a persistent halo + accent ring on the selected marker
1259        // (not when stacked, and not on a hidden series), independent of hover.
1260        if let Some((sc, ss)) = self.selected {
1261            if !stacked && ss < series.len() && !self.hidden.contains(&ss) && sc < n {
1262                let (color, _, vals) = series[ss];
1263                if let Some(&v) = vals.get(sc) {
1264                    let p = pt(sc, v);
1265                    scene.fill_path(&Path::circle(p, MARKER_R + 6.0), color.fade(o * 0.22));
1266                    scene.stroke_path(&Path::circle(p, MARKER_R + 3.0), color.fade(o), 2.0);
1267                }
1268            }
1269        }
1270
1271        // Category labels (once, below the baseline).
1272        for (i, (label, _)) in self.values.iter().enumerate() {
1273            let lw = frus_text::measure(label, LABEL_SIZE).width;
1274            scene.text(
1275                Point::new(
1276                    plot_left + slot * (i as f32 + 0.5) - lw * 0.5,
1277                    baseline_y + 4.0,
1278                ),
1279                label.clone(),
1280                &ResolvedTextStyle::exact(LABEL_SIZE),
1281                theme.muted.fade(o),
1282            );
1283        }
1284
1285        // Legend (the top band), shared; hidden series are dimmed in it.
1286        if self.has_legend() {
1287            let entries: Vec<(Color, &str)> = series
1288                .iter()
1289                .enumerate()
1290                .map(|(i, (c, n, _))| {
1291                    (
1292                        if self.hidden.contains(&i) {
1293                            c.fade(0.35)
1294                        } else {
1295                            *c
1296                        },
1297                        *n,
1298                    )
1299                })
1300                .collect();
1301            draw_legend(scene, theme, plot_left, bounds.y, &entries, o);
1302        }
1303
1304        // Sub-region tooltip (milestone 211): when the pointer hovers the plot area
1305        // (`hover_cursor`, set by the shell through `cursor_icon`), the nearest category is brought
1306        // forward, each visible series' marker is accented, and their values are listed.
1307        if let Some(hc) = status.hover_cursor {
1308            let lx = hc.x - bounds.x;
1309            let hi =
1310                (((lx - plot_left) / slot - 0.5).round() as i64).clamp(0, n as i64 - 1) as usize;
1311            let gx = plot_left + slot * (hi as f32 + 0.5);
1312            let mut lines: Vec<(Option<Color>, String)> = vec![(None, self.values[hi].0.clone())];
1313            // In 100% mode each measure is followed by its share of the hovered category's total (milestone 226).
1314            let percent_of = if normalized {
1315                Some(self.category_total(hi))
1316            } else {
1317                None
1318            };
1319            for (j, (color, name, vals)) in series.iter().enumerate() {
1320                if self.hidden.contains(&j) || hi >= vals.len() {
1321                    continue;
1322                }
1323                // The marker accented at the value (not when stacked: an individual height means
1324                // nothing on a cumulative stratum).
1325                if !stacked {
1326                    let py = baseline_y - (vals[hi] / max) * plot_h;
1327                    // Animated pulsing halo (milestone 217): grows then fades under the marker.
1328                    if self.animated {
1329                        let phase = (status.time * PULSE_SPEED).fract();
1330                        let r = (MARKER_R + 2.0) + phase * PULSE_GROW;
1331                        scene.fill_path(
1332                            &Path::circle(Point::new(gx, py), r),
1333                            color.fade(o * (1.0 - phase) * 0.4),
1334                        );
1335                    }
1336                    scene.fill_path(
1337                        &Path::circle(Point::new(gx, py), MARKER_R + 2.0),
1338                        color.fade(o),
1339                    );
1340                }
1341                let vtxt = format_measure(vals[hi], percent_of);
1342                let txt = if single {
1343                    vtxt
1344                } else {
1345                    format!("{}  {}", name, vtxt)
1346                };
1347                lines.push((Some(*color), txt));
1348            }
1349            draw_tooltip(scene, theme, bounds, gx, plot_top, baseline_y, &lines, o);
1350        }
1351    }
1352
1353    fn cursor_icon(
1354        &self,
1355        local_x: f32,
1356        local_y: f32,
1357        width: f32,
1358        height: f32,
1359    ) -> Option<crate::interaction::Cursor> {
1360        if self.values.is_empty() {
1361            return None;
1362        }
1363        let legend_h = if self.has_legend() { LEGEND_H } else { 0.0 };
1364        let plot_top = legend_h + VALUE_SIZE + 6.0;
1365        chart_plot_hit(
1366            local_x,
1367            local_y,
1368            width,
1369            height,
1370            axis_width(self.grid),
1371            plot_top,
1372        )
1373    }
1374
1375    fn positional_click(&self, local_x: f32, local_y: f32, width: f32, height: f32) -> Option<Msg> {
1376        // 1) A click on a legend entry → on_legend(index) (milestone 215).
1377        if let Some(f) = &self.on_legend {
1378            if self.has_legend() {
1379                if let Some(idx) = legend_hit(
1380                    local_x,
1381                    local_y,
1382                    axis_width(self.grid),
1383                    &self.series_names(),
1384                ) {
1385                    return Some(f(idx));
1386                }
1387            }
1388        }
1389        // 2) A click on a **point** → on_point(category, series) (milestone 221). Not in stacked
1390        // mode, where individual markers do not exist. The geometry matches the paint.
1391        let f = self.on_point.as_ref()?;
1392        let n = self.values.len();
1393        if n == 0 || (self.stacked && !self.extra.is_empty()) {
1394            return None;
1395        }
1396        let legend_h = if self.has_legend() { LEGEND_H } else { 0.0 };
1397        let baseline_y = height - X_LABEL_H;
1398        let plot_top = legend_h + VALUE_SIZE + 6.0;
1399        let plot_h = (baseline_y - plot_top).max(1.0);
1400        let plot_left = axis_width(self.grid);
1401        let slot = (width - plot_left) / n as f32;
1402        let max = self.max_value();
1403        let primary: Vec<f32> = self.values.iter().map(|(_, v)| *v).collect();
1404        for j in 0..(1 + self.extra.len()) {
1405            if self.hidden.contains(&j) {
1406                continue;
1407            }
1408            let vals: &[f32] = if j == 0 {
1409                &primary
1410            } else {
1411                &self.extra[j - 1].2
1412            };
1413            for (i, &v) in vals.iter().enumerate().take(n) {
1414                let px = plot_left + slot * (i as f32 + 0.5);
1415                let py = baseline_y - (v / max) * plot_h;
1416                if (local_x - px).hypot(local_y - py) <= POINT_HIT_R {
1417                    return Some(f(i, j));
1418                }
1419            }
1420        }
1421        None
1422    }
1423
1424    fn continuous(&self) -> bool {
1425        // Continuous repaint while the pulsing halo is on (milestone 217).
1426        self.animated
1427    }
1428
1429    fn on_click(&self) -> Option<Msg> {
1430        None
1431    }
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436    use super::*;
1437    use frus_core::Primitive;
1438
1439    fn paint_chart(chart: &BarChart, w: f32, h: f32) -> Vec<Primitive> {
1440        let mut scene = Scene::new();
1441        Widget::<crate::callback::Callback>::paint(
1442            chart,
1443            Rect::new(0.0, 0.0, w, h),
1444            Status::default(),
1445            &Theme::default(),
1446            &mut scene,
1447        );
1448        scene.primitives().to_vec()
1449    }
1450
1451    #[test]
1452    fn value_formatting() {
1453        assert_eq!(format_value(3.0), "3");
1454        assert_eq!(format_value(2.5), "2.5");
1455    }
1456
1457    #[test]
1458    fn empty_series_paints_nothing() {
1459        assert!(paint_chart(&BarChart::new(Vec::<(String, f32)>::new()), 300.0, 200.0).is_empty());
1460    }
1461
1462    #[test]
1463    fn bars_scale_to_the_max_value() {
1464        // Three bars: the largest value gives the tallest bar.
1465        let chart = BarChart::new([("A", 2.0), ("B", 6.0), ("C", 4.0)]);
1466        let prims = paint_chart(&chart, 300.0, 200.0);
1467        // Rects = the baseline + 3 bars.
1468        let bar_heights: Vec<f32> = prims
1469            .iter()
1470            .filter_map(|p| match p {
1471                // Bars are taller than 2 (the baseline is 1.5 tall).
1472                Primitive::Rect { rect, .. } if rect.height > 2.0 => Some(rect.height),
1473                _ => None,
1474            })
1475            .collect();
1476        assert_eq!(bar_heights.len(), 3, "one bar per value");
1477        // B (6) is the tallest; A (2) the shortest; proportional.
1478        let max_h = bar_heights.iter().cloned().fold(0.0_f32, f32::max);
1479        let min_h = bar_heights.iter().cloned().fold(f32::MAX, f32::min);
1480        assert!(
1481            max_h > min_h * 2.5,
1482            "6 is three times 2: {max_h} vs {min_h}"
1483        );
1484        // Values and labels drawn.
1485        let has_text = |t: &str| {
1486            prims
1487                .iter()
1488                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
1489        };
1490        assert!(
1491            has_text("6") && has_text("2") && has_text("4"),
1492            "values displayed"
1493        );
1494        assert!(
1495            has_text("A") && has_text("B") && has_text("C"),
1496            "labels displayed"
1497        );
1498    }
1499
1500    #[test]
1501    fn grouped_series_draw_a_bar_per_series_and_a_legend() {
1502        let chart = BarChart::new([("A", 4.0), ("B", 8.0), ("C", 6.0)])
1503            .name("This year")
1504            .series("Last year", Color::rgb8(200, 120, 80), [3.0, 7.0, 5.0])
1505            .legend(true);
1506        let prims = paint_chart(&chart, 320.0, 220.0);
1507        // 3 categories × 2 series = 6 bars (tall ones; > the baseline and the swatches).
1508        let bars = prims
1509            .iter()
1510            .filter(|p| matches!(p, Primitive::Rect { rect, .. } if rect.height > 15.0))
1511            .count();
1512        assert_eq!(bars, 6, "one bar per (category, series)");
1513        // Two legend swatches (~10×10).
1514        let swatches = prims
1515            .iter()
1516            .filter(|p| matches!(p, Primitive::Rect { rect, .. }
1517                if (rect.width - LEGEND_SWATCH).abs() < 0.5 && (rect.height - LEGEND_SWATCH).abs() < 0.5))
1518            .count();
1519        assert_eq!(swatches, 2, "one swatch per series");
1520        let has_text = |t: &str| {
1521            prims
1522                .iter()
1523                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
1524        };
1525        assert!(
1526            has_text("This year") && has_text("Last year"),
1527            "series names in the legend"
1528        );
1529    }
1530
1531    #[test]
1532    fn stacked_bars_share_one_column_per_category() {
1533        let chart = BarChart::new([("A", 2.0), ("B", 4.0)])
1534            .series("x", Color::rgb8(1, 2, 3), [3.0, 1.0])
1535            .stacked(true);
1536        // Stacked scale: the largest total = max(2+3, 4+1) = 5.
1537        assert_eq!(chart.stacked_max(), 5.0);
1538        let prims = paint_chart(&chart, 300.0, 200.0);
1539        let seg_widths: Vec<f32> = prims
1540            .iter()
1541            .filter_map(|p| match p {
1542                Primitive::Rect { rect, .. } if rect.height > 2.0 => Some(rect.width),
1543                _ => None,
1544            })
1545            .collect();
1546        // 2 categories × 2 series = 4 segments, all at the group's full width (one column).
1547        assert_eq!(seg_widths.len(), 4, "one segment per (category, series)");
1548        let group_w = (300.0 / 2.0) * BAR_FILL;
1549        assert!(
1550            seg_widths.iter().all(|w| (w - group_w).abs() < 0.5),
1551            "stacked segments = full width, got {seg_widths:?}"
1552        );
1553    }
1554
1555    #[test]
1556    fn normalized_stacked_bars_fill_each_column() {
1557        // 100% mode: each column fills the whole height, whatever its raw sum.
1558        let make = |norm: bool| {
1559            BarChart::new([("A", 2.0), ("B", 4.0)])
1560                .series("x", Color::rgb8(1, 2, 3), [3.0, 4.0])
1561                .stacked(true)
1562                .normalized(norm)
1563        };
1564        // Cumulative height of column A's segments (the left half: x < 150).
1565        let col_a = |chart: &BarChart| {
1566            paint_chart(chart, 300.0, 200.0)
1567                .iter()
1568                .filter_map(|p| match p {
1569                    Primitive::Rect { rect, .. } if rect.height > 2.0 && rect.x < 150.0 => {
1570                        Some(rect.height)
1571                    }
1572                    _ => None,
1573                })
1574                .sum::<f32>()
1575        };
1576        let plot_h = (200.0 - X_LABEL_H) - (VALUE_SIZE + 6.0); // 160
1577
1578        // Column A (total 5): full in 100% mode...
1579        assert!(
1580            (col_a(&make(true)) - plot_h).abs() < 1.0,
1581            "column A full in 100% mode, got {}",
1582            col_a(&make(true))
1583        );
1584        // ...but partial in absolute mode (the largest total, 8, is in B).
1585        assert!(
1586            col_a(&make(false)) < plot_h - 20.0,
1587            "column A partial in absolute mode, got {}",
1588            col_a(&make(false))
1589        );
1590        // In 100% mode the axis shows a percentage.
1591        let prims = paint_chart(&make(true).grid(4), 300.0, 200.0);
1592        assert!(
1593            prims
1594                .iter()
1595                .any(|p| matches!(p, Primitive::Text { text, .. } if text == "100%")),
1596            "the axis in percentages"
1597        );
1598    }
1599
1600    #[test]
1601    fn normalized_bar_tooltip_shows_percentages() {
1602        // Two stacked series; in 100% mode the hover tooltip adds each series' share (%).
1603        let make = |norm: bool| {
1604            BarChart::new([("A", 2.0), ("B", 6.0)])
1605                .series("x", Color::rgb8(1, 2, 3), [2.0, 2.0])
1606                .stacked(true)
1607                .normalized(norm)
1608        };
1609        let tooltip_texts = |chart: &BarChart| -> Vec<String> {
1610            let mut scene = Scene::new();
1611            // Hovering category A (x ~ the centre of the 1st column).
1612            let status = Status {
1613                hover_cursor: Some(Point::new(75.0, 90.0)),
1614                ..Default::default()
1615            };
1616            Widget::<crate::callback::Callback>::paint(
1617                chart,
1618                Rect::new(0.0, 0.0, 300.0, 200.0),
1619                status,
1620                &Theme::default(),
1621                &mut scene,
1622            );
1623            scene
1624                .primitives()
1625                .iter()
1626                .filter_map(|p| match p {
1627                    Primitive::Text { text, .. } => Some(text.clone()),
1628                    _ => None,
1629                })
1630                .collect()
1631        };
1632        // Category A: both series are worth 2 → 50% each.
1633        let norm = tooltip_texts(&make(true));
1634        assert!(
1635            norm.iter().any(|t| t.contains("(50%)")),
1636            "a % share in the 100%-mode tooltip, got {norm:?}"
1637        );
1638        // In absolute mode: no mention of a percentage.
1639        let abs = tooltip_texts(&make(false));
1640        assert!(
1641            !abs.iter().any(|t| t.contains('%')),
1642            "no % in absolute mode, got {abs:?}"
1643        );
1644    }
1645
1646    #[test]
1647    fn normalized_bars_label_each_strata_with_its_percentage() {
1648        // In 100% mode every stratum tall enough carries its share (%) at its centre.
1649        let make = |norm: bool| {
1650            BarChart::new([("A", 2.0), ("B", 6.0)])
1651                .series("x", Color::rgb8(1, 2, 3), [2.0, 2.0])
1652                .stacked(true)
1653                .normalized(norm)
1654        };
1655        // Without an axis (so no % ticks), any text ending in "%" is a stratum label.
1656        let pct_labels = |chart: &BarChart| {
1657            paint_chart(chart, 300.0, 260.0)
1658                .iter()
1659                .filter(|p| matches!(p, Primitive::Text { text, .. } if text.ends_with('%')))
1660                .count()
1661        };
1662        // 2 categories × 2 visible series = 4 labelled strata.
1663        assert_eq!(pct_labels(&make(true)), 4, "one % share per stratum");
1664        assert_eq!(pct_labels(&make(false)), 0, "no % in absolute mode");
1665    }
1666
1667    #[test]
1668    fn stacked_absolute_bars_show_the_column_total() {
1669        let make = || {
1670            BarChart::new([("A", 2.0), ("B", 4.0)]).series("x", Color::rgb8(1, 2, 3), [3.0, 1.0])
1671        };
1672        // Absolute stacking: each column's total (A = 2+3 = 5, B = 4+1 = 5) is written above it.
1673        let abs = paint_chart(&make().stacked(true), 300.0, 220.0);
1674        let count5 = abs
1675            .iter()
1676            .filter(|p| matches!(p, Primitive::Text { text, .. } if text == "5"))
1677            .count();
1678        assert_eq!(count5, 2, "one total per column (5 and 5)");
1679        // In 100% mode: no raw total (full columns, shares in %).
1680        let norm = paint_chart(&make().stacked(true).normalized(true), 300.0, 220.0);
1681        assert!(
1682            !norm
1683                .iter()
1684                .any(|p| matches!(p, Primitive::Text { text, .. } if text == "5")),
1685            "no raw total in 100% mode"
1686        );
1687    }
1688
1689    #[test]
1690    fn stacked_absolute_bars_label_each_strata_with_its_value() {
1691        // Absolute stacking: every stratum tall enough carries its raw value (parity with the % in 100% mode).
1692        let chart = BarChart::new([("A", 3.0), ("B", 5.0)])
1693            .series("x", Color::rgb8(1, 2, 3), [4.0, 6.0])
1694            .stacked(true);
1695        let prims = paint_chart(&chart, 300.0, 260.0);
1696        let has = |t: &str| {
1697            prims
1698                .iter()
1699                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
1700        };
1701        // Stratum values: A = 3 and 4, B = 5 and 6.
1702        assert!(
1703            has("3") && has("4") && has("6"),
1704            "every stratum carries its value"
1705        );
1706        // The column total (A = 7, B = 11) stays at the top (milestone 228).
1707        assert!(has("7") && has("11"), "column total kept");
1708    }
1709
1710    #[test]
1711    fn hovering_bars_shows_a_tooltip_guide() {
1712        use crate::interaction::Cursor;
1713        let chart = BarChart::new([("A", 2.0), ("B", 6.0), ("C", 4.0)]);
1714        let guides = |status: Status| {
1715            let mut scene = Scene::new();
1716            Widget::<crate::callback::Callback>::paint(
1717                &chart,
1718                Rect::new(0.0, 0.0, 300.0, 220.0),
1719                status,
1720                &Theme::default(),
1721                &mut scene,
1722            );
1723            scene
1724                .primitives()
1725                .iter()
1726                .filter(|p| matches!(p, Primitive::Rect { rect, .. } if rect.width <= 1.5 && rect.height > 50.0))
1727                .count()
1728        };
1729        let hovering = Status {
1730            hover_cursor: Some(Point::new(150.0, 100.0)),
1731            ..Default::default()
1732        };
1733        assert_eq!(guides(hovering), 1, "one guide when the area is hovered");
1734        assert_eq!(guides(Status::default()), 0, "no tooltip without hover");
1735        assert_eq!(
1736            Widget::<crate::callback::Callback>::cursor_icon(&chart, 150.0, 100.0, 300.0, 220.0),
1737            Some(Cursor::Default)
1738        );
1739        assert_eq!(
1740            Widget::<crate::callback::Callback>::cursor_icon(&chart, 150.0, 5.0, 300.0, 220.0),
1741            None
1742        );
1743    }
1744
1745    #[test]
1746    fn clicking_a_bar_emits_category_and_series() {
1747        // Two grouped series: 2 bars per category. The geometry (width 300, height 200, no axis
1748        // and no legend) matches the paint.
1749        let chart = BarChart::<(usize, usize)>::new([("A", 2.0), ("B", 6.0)])
1750            .series("x", Color::rgb8(1, 2, 3), [4.0, 1.0])
1751            .on_point(|c, s| (c, s));
1752        let baseline_y = 200.0 - X_LABEL_H;
1753        let plot_h = baseline_y - (VALUE_SIZE + 6.0);
1754        let slot = 300.0 / 2.0;
1755        let group_w = slot * BAR_FILL;
1756        let bar_w = group_w / 2.0;
1757        // Category A (i=0), extra series (j=1), value 4 (max = 6).
1758        let cx = slot * 0.5;
1759        let group_left = cx - group_w * 0.5;
1760        let bx = group_left + bar_w + (bar_w - bar_w * 0.86) * 0.5;
1761        let bar_cx = bx + bar_w * 0.86 * 0.5;
1762        let mid_y = baseline_y - (4.0 / 6.0) * plot_h * 0.5;
1763        assert_eq!(
1764            Widget::<(usize, usize)>::positional_click(&chart, bar_cx, mid_y, 300.0, 200.0),
1765            Some((0, 1)),
1766            "a click in the middle of category A's 2nd bar"
1767        );
1768        // Above the bar (empty space): no message.
1769        assert_eq!(
1770            Widget::<(usize, usize)>::positional_click(&chart, bar_cx, 2.0, 300.0, 200.0),
1771            None
1772        );
1773        // Stacked stratum: a click in the column returns the stratum that was hit.
1774        let stacked = BarChart::<(usize, usize)>::new([("A", 2.0), ("B", 6.0)])
1775            .series("x", Color::rgb8(1, 2, 3), [4.0, 1.0])
1776            .stacked(true)
1777            .on_point(|c, s| (c, s));
1778        // Category A: stratum 0 (value 2) at the bottom, stratum 1 (value 4) above; max total = 6.
1779        let low_y = baseline_y - (1.0 / 6.0) * plot_h; // inside the bottom stratum (0..2)
1780        assert_eq!(
1781            Widget::<(usize, usize)>::positional_click(&stacked, cx, low_y, 300.0, 200.0),
1782            Some((0, 0)),
1783            "a click low in the column = stratum 0"
1784        );
1785        // A hidden extra series: its bar is no longer clickable.
1786        let hidden = BarChart::<(usize, usize)>::new([("A", 2.0), ("B", 6.0)])
1787            .series("x", Color::rgb8(1, 2, 3), [4.0, 1.0])
1788            .on_point(|c, s| (c, s))
1789            .hidden([1]);
1790        assert_eq!(
1791            Widget::<(usize, usize)>::positional_click(&hidden, bar_cx, mid_y, 300.0, 200.0),
1792            None,
1793            "a hidden series' bar: no click"
1794        );
1795    }
1796
1797    #[test]
1798    fn selected_bar_draws_a_persistent_ring() {
1799        // The pinned bar gets a **stroked** rect (ordinary bars have a 0-width border).
1800        let rings = |chart: &BarChart| {
1801            paint_chart(chart, 300.0, 200.0)
1802                .iter()
1803                .filter(
1804                    |p| matches!(p, Primitive::Rect { border_width, .. } if *border_width > 0.5),
1805                )
1806                .count()
1807        };
1808        assert_eq!(
1809            rings(&BarChart::new([("A", 2.0), ("B", 6.0)])),
1810            0,
1811            "no ring without a selection"
1812        );
1813        assert_eq!(
1814            rings(&BarChart::new([("A", 2.0), ("B", 6.0)]).selected(Some((1, 0)))),
1815            1,
1816            "one ring on the pinned bar"
1817        );
1818        // A pinned hidden series: its bar is not drawn, so there is no ring.
1819        let hidden = BarChart::new([("A", 2.0), ("B", 6.0)])
1820            .series("x", Color::rgb8(1, 2, 3), [3.0, 1.0])
1821            .hidden([1])
1822            .selected(Some((0, 1)));
1823        assert_eq!(rings(&hidden), 0, "no ring on a hidden series");
1824    }
1825
1826    fn paint_line(chart: &LineChart, w: f32, h: f32) -> Vec<Primitive> {
1827        let mut scene = Scene::new();
1828        Widget::<crate::callback::Callback>::paint(
1829            chart,
1830            Rect::new(0.0, 0.0, w, h),
1831            Status::default(),
1832            &Theme::default(),
1833            &mut scene,
1834        );
1835        scene.primitives().to_vec()
1836    }
1837
1838    #[test]
1839    fn line_empty_series_paints_nothing() {
1840        assert!(paint_line(&LineChart::new(Vec::<(String, f32)>::new()), 300.0, 200.0).is_empty());
1841    }
1842
1843    #[test]
1844    fn line_connects_all_points() {
1845        let chart = LineChart::new([("A", 2.0), ("B", 6.0), ("C", 4.0)]);
1846        let prims = paint_line(&chart, 300.0, 200.0);
1847        // One stroked polyline (a path with a stroke, no fill).
1848        let polyline = prims.iter().find_map(|p| match p {
1849            Primitive::Path {
1850                path,
1851                stroke: Some(_),
1852                fill: None,
1853                ..
1854            } => Some(path),
1855            _ => None,
1856        });
1857        let polyline = polyline.expect("a stroked polyline");
1858        // move_to + 2 line_to for three points.
1859        let segments = polyline
1860            .verbs()
1861            .iter()
1862            .filter(|v| matches!(v, frus_core::PathVerb::LineTo(_)))
1863            .count();
1864        assert_eq!(segments, 2, "deux segments relient trois points");
1865        // One marker (a filled path) per point.
1866        let markers = prims
1867            .iter()
1868            .filter(|p| {
1869                matches!(
1870                    p,
1871                    Primitive::Path {
1872                        fill: Some(_),
1873                        stroke: None,
1874                        ..
1875                    }
1876                )
1877            })
1878            .count();
1879        assert_eq!(markers, 3, "one marker per point");
1880        // Values and labels drawn.
1881        let has_text = |t: &str| {
1882            prims
1883                .iter()
1884                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
1885        };
1886        assert!(
1887            has_text("6") && has_text("2") && has_text("4"),
1888            "values displayed"
1889        );
1890        assert!(
1891            has_text("A") && has_text("B") && has_text("C"),
1892            "labels displayed"
1893        );
1894    }
1895
1896    #[test]
1897    fn hovering_the_plot_shows_a_tooltip_guide() {
1898        use crate::interaction::Cursor;
1899        let chart = LineChart::new([("A", 2.0), ("B", 6.0), ("C", 4.0)]);
1900        // Thin, tall vertical rects = the tooltip's guide.
1901        let guides = |status: Status| {
1902            let mut scene = Scene::new();
1903            Widget::<crate::callback::Callback>::paint(
1904                &chart,
1905                Rect::new(0.0, 0.0, 300.0, 220.0),
1906                status,
1907                &Theme::default(),
1908                &mut scene,
1909            );
1910            scene
1911                .primitives()
1912                .iter()
1913                .filter(|p| {
1914                    matches!(p, Primitive::Rect { rect, .. }
1915                    if rect.width <= 1.5 && rect.height > 50.0)
1916                })
1917                .count()
1918        };
1919        let hovering = Status {
1920            hover_cursor: Some(Point::new(150.0, 100.0)),
1921            ..Default::default()
1922        };
1923        assert_eq!(
1924            guides(hovering),
1925            1,
1926            "one vertical guide when the area is hovered"
1927        );
1928        assert_eq!(guides(Status::default()), 0, "no tooltip without hover");
1929        // `cursor_icon` turns tracking on over the plot area (Default), not outside it.
1930        assert_eq!(
1931            Widget::<crate::callback::Callback>::cursor_icon(&chart, 150.0, 100.0, 300.0, 220.0),
1932            Some(Cursor::Default)
1933        );
1934        assert_eq!(
1935            Widget::<crate::callback::Callback>::cursor_icon(&chart, 150.0, 5.0, 300.0, 220.0),
1936            None,
1937            "above the area"
1938        );
1939    }
1940
1941    #[test]
1942    fn multi_series_draws_each_line_and_a_legend() {
1943        let chart = LineChart::new([("A", 2.0), ("B", 6.0), ("C", 4.0)])
1944            .name("Sales")
1945            .series(
1946                "Costs",
1947                frus_core::Color::rgb8(200, 80, 80),
1948                [1.0, 3.0, 2.0],
1949            )
1950            .legend(true);
1951        let prims = paint_line(&chart, 300.0, 220.0);
1952        // Two polylines (one per series).
1953        let polylines = prims
1954            .iter()
1955            .filter(|p| {
1956                matches!(
1957                    p,
1958                    Primitive::Path {
1959                        stroke: Some(_),
1960                        fill: None,
1961                        ..
1962                    }
1963                )
1964            })
1965            .count();
1966        assert_eq!(polylines, 2, "one polyline per series");
1967        // Two legend swatches (~10x10).
1968        let swatches = prims
1969            .iter()
1970            .filter(|p| matches!(p, Primitive::Rect { rect, .. }
1971                if (rect.width - LEGEND_SWATCH).abs() < 0.5 && (rect.height - LEGEND_SWATCH).abs() < 0.5))
1972            .count();
1973        assert_eq!(swatches, 2, "one swatch per series");
1974        // The series names appear in the legend.
1975        let has_text = |t: &str| {
1976            prims
1977                .iter()
1978                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
1979        };
1980        assert!(
1981            has_text("Sales") && has_text("Costs"),
1982            "series names in the legend"
1983        );
1984    }
1985
1986    #[test]
1987    fn legend_click_emits_the_series_index() {
1988        let chart = LineChart::<usize>::new([("A", 1.0)])
1989            .name("Sales")
1990            .series("Costs", Color::rgb8(1, 2, 3), [2.0])
1991            .legend(true)
1992            .on_legend(|i| i);
1993        // A click on the 1st entry (its swatch near x=0, y inside the legend band).
1994        assert_eq!(
1995            Widget::<usize>::positional_click(&chart, 5.0, 10.0, 300.0, 200.0),
1996            Some(0)
1997        );
1998        // 2nd entry: right after the 1st (swatch + space + "Sales" + gap).
1999        let after_first =
2000            LEGEND_SWATCH + 5.0 + frus_text::measure("Sales", LEGEND_SIZE).width + 16.0;
2001        assert_eq!(
2002            Widget::<usize>::positional_click(&chart, after_first + 4.0, 10.0, 300.0, 200.0),
2003            Some(1)
2004        );
2005        // Outside the band (y too low): no legend click.
2006        assert_eq!(
2007            Widget::<usize>::positional_click(&chart, 5.0, 100.0, 300.0, 200.0),
2008            None
2009        );
2010        // Without `on_legend` the legend is not clickable.
2011        let plain = LineChart::<usize>::new([("A", 1.0)])
2012            .name("Sales")
2013            .series("Costs", Color::rgb8(1, 2, 3), [2.0])
2014            .legend(true);
2015        assert_eq!(
2016            Widget::<usize>::positional_click(&plain, 5.0, 10.0, 300.0, 200.0),
2017            None
2018        );
2019    }
2020
2021    #[test]
2022    fn animated_pulse_adds_a_halo_and_requests_continuous_repaint() {
2023        let animated = LineChart::new([("A", 2.0), ("B", 6.0)]).animated(true);
2024        let plain = LineChart::new([("A", 2.0), ("B", 6.0)]);
2025        assert!(
2026            Widget::<crate::callback::Callback>::continuous(&animated),
2027            "animated => a continuous repaint"
2028        );
2029        assert!(
2030            !Widget::<crate::callback::Callback>::continuous(&plain),
2031            "static => no continuous repaint"
2032        );
2033        // Filled circles (markers/halo: filled paths with no straight segment) on hover.
2034        let circles = |chart: &LineChart, t: f32| {
2035            let mut scene = Scene::new();
2036            let status = Status {
2037                hover_cursor: Some(Point::new(150.0, 100.0)),
2038                time: t,
2039                ..Default::default()
2040            };
2041            Widget::<crate::callback::Callback>::paint(
2042                chart,
2043                Rect::new(0.0, 0.0, 300.0, 200.0),
2044                status,
2045                &Theme::default(),
2046                &mut scene,
2047            );
2048            scene
2049                .primitives()
2050                .iter()
2051                .filter(|p| {
2052                    matches!(p, Primitive::Path { fill: Some(_), stroke: None, path, .. }
2053                    if !path.verbs().iter().any(|v| matches!(v, frus_core::PathVerb::LineTo(_))))
2054                })
2055                .count()
2056        };
2057        // The animated halo adds a circle the static chart does not have (at the same hover).
2058        assert!(
2059            circles(&animated, 0.1) > circles(&plain, 0.1),
2060            "the animated halo adds a circle"
2061        );
2062    }
2063
2064    #[test]
2065    fn clicking_a_point_emits_category_and_series() {
2066        let chart = LineChart::<(usize, usize)>::new([("A", 2.0), ("B", 6.0)])
2067            .series("x", Color::rgb8(1, 2, 3), [4.0, 1.0])
2068            .on_point(|c, s| (c, s));
2069        // The geometry (width 300, height 200, no axis and no legend) — matches the paint.
2070        let baseline_y = 200.0 - X_LABEL_H;
2071        let plot_h = baseline_y - (VALUE_SIZE + 6.0);
2072        let slot = 300.0 / 2.0;
2073        let px = slot * 0.5; // category A (i = 0)
2074        let py_primary = baseline_y - (2.0 / 6.0) * plot_h; // series 0, value 2 (max = 6)
2075        assert_eq!(
2076            Widget::<(usize, usize)>::positional_click(&chart, px, py_primary, 300.0, 200.0),
2077            Some((0, 0)),
2078            "a click on point A of the main series"
2079        );
2080        // Far from every marker: no message.
2081        assert_eq!(
2082            Widget::<(usize, usize)>::positional_click(&chart, slot, 5.0, 300.0, 200.0),
2083            None
2084        );
2085        // A hidden main series: its point is no longer clickable.
2086        let hidden = LineChart::<(usize, usize)>::new([("A", 2.0), ("B", 6.0)])
2087            .series("x", Color::rgb8(1, 2, 3), [4.0, 1.0])
2088            .on_point(|c, s| (c, s))
2089            .hidden([0]);
2090        assert_eq!(
2091            Widget::<(usize, usize)>::positional_click(&hidden, px, py_primary, 300.0, 200.0),
2092            None,
2093            "a hidden series' point: no click"
2094        );
2095    }
2096
2097    #[test]
2098    fn selected_point_draws_a_persistent_ring() {
2099        // A ring (a **stroked** circle, no straight segment) appears on the pinned point, without hover.
2100        let rings = |chart: &LineChart| {
2101            paint_line(chart, 300.0, 200.0)
2102                .iter()
2103                .filter(|p| {
2104                    matches!(p, Primitive::Path { stroke: Some(_), fill: None, path, .. }
2105                    if !path.verbs().iter().any(|v| matches!(v, frus_core::PathVerb::LineTo(_))))
2106                })
2107                .count()
2108        };
2109        assert_eq!(
2110            rings(&LineChart::new([("A", 2.0), ("B", 6.0)])),
2111            0,
2112            "no ring without a selection"
2113        );
2114        assert_eq!(
2115            rings(&LineChart::new([("A", 2.0), ("B", 6.0)]).selected(Some((0, 0)))),
2116            1,
2117            "one ring on the pinned point"
2118        );
2119        // A pinned hidden series: no ring.
2120        let hidden = LineChart::new([("A", 2.0), ("B", 6.0)])
2121            .series("x", Color::rgb8(1, 2, 3), [3.0, 1.0])
2122            .hidden([0])
2123            .selected(Some((0, 0)));
2124        assert_eq!(rings(&hidden), 0, "no ring on a hidden series");
2125    }
2126
2127    #[test]
2128    fn hidden_series_is_not_drawn() {
2129        let strokes = |chart: &LineChart| {
2130            paint_line(chart, 300.0, 200.0)
2131                .iter()
2132                .filter(|p| {
2133                    matches!(
2134                        p,
2135                        Primitive::Path {
2136                            stroke: Some(_),
2137                            fill: None,
2138                            ..
2139                        }
2140                    )
2141                })
2142                .count()
2143        };
2144        let both = LineChart::new([("A", 2.0), ("B", 6.0)]).series(
2145            "x",
2146            Color::rgb8(200, 80, 80),
2147            [3.0, 1.0],
2148        );
2149        assert_eq!(strokes(&both), 2, "two visible series = two lines");
2150        let hidden = LineChart::new([("A", 2.0), ("B", 6.0)])
2151            .series("x", Color::rgb8(200, 80, 80), [3.0, 1.0])
2152            .hidden([1]);
2153        assert_eq!(strokes(&hidden), 1, "the hidden series is not drawn");
2154    }
2155
2156    #[test]
2157    fn stacked_areas_fill_a_band_per_series() {
2158        let make = || {
2159            LineChart::new([("A", 2.0), ("B", 4.0)]).series(
2160                "x",
2161                Color::rgb8(200, 80, 80),
2162                [3.0, 1.0],
2163            )
2164        };
2165        // The stacked scale takes the largest total per category: max(2+3, 4+1) = 5.
2166        assert_eq!(make().stacked(true).stacked_max(), 5.0);
2167        let bands = |chart: &LineChart| {
2168            paint_line(chart, 300.0, 200.0)
2169                .iter()
2170                .filter(|p| {
2171                    matches!(p, Primitive::Path { fill: Some(_), stroke: None, path, .. }
2172                    if path.verbs().iter().any(|v| matches!(v, frus_core::PathVerb::LineTo(_))))
2173                })
2174                .count()
2175        };
2176        assert_eq!(
2177            bands(&make().stacked(true)),
2178            2,
2179            "one cumulative band per series"
2180        );
2181        assert_eq!(
2182            bands(&make()),
2183            0,
2184            "without stacking: no band (multi-series with no area)"
2185        );
2186    }
2187
2188    #[test]
2189    fn normalized_stacked_areas_fill_to_the_top() {
2190        let make = |norm: bool| {
2191            LineChart::new([("A", 2.0), ("B", 4.0)])
2192                .series("x", Color::rgb8(1, 2, 3), [3.0, 4.0])
2193                .stacked(true)
2194                .normalized(norm)
2195        };
2196        // The y coordinates of the **upper** edge's stroke (the last band drawn = the last visible series).
2197        let top_line_ys = |chart: &LineChart| -> Vec<f32> {
2198            let prims = paint_line(chart, 300.0, 200.0);
2199            let path = prims
2200                .iter()
2201                .rev()
2202                .find_map(|p| match p {
2203                    Primitive::Path {
2204                        stroke: Some(_),
2205                        fill: None,
2206                        path,
2207                        ..
2208                    } if path
2209                        .verbs()
2210                        .iter()
2211                        .any(|v| matches!(v, frus_core::PathVerb::LineTo(_))) =>
2212                    {
2213                        Some(path)
2214                    }
2215                    _ => None,
2216                })
2217                .expect("an upper-edge stroke");
2218            path.verbs()
2219                .iter()
2220                .filter_map(|v| match v {
2221                    frus_core::PathVerb::MoveTo(p) | frus_core::PathVerb::LineTo(p) => Some(p.y),
2222                    _ => None,
2223                })
2224                .collect()
2225        };
2226        let plot_top = VALUE_SIZE + 6.0; // 18 (no legend and no axis)
2227
2228        // 100% mode: the upper edge is flat, at 100% (plot_top) on every category.
2229        let ys = top_line_ys(&make(true));
2230        assert!(
2231            ys.iter().all(|y| (y - plot_top).abs() < 1.0),
2232            "upper edge at 100% everywhere, got {ys:?}"
2233        );
2234        // Absolute mode: the upper edge follows the totals (not all at plot_top).
2235        let ys_abs = top_line_ys(&make(false));
2236        assert!(
2237            ys_abs.iter().any(|y| (y - plot_top).abs() > 5.0),
2238            "the upper edge follows the totals, got {ys_abs:?}"
2239        );
2240    }
2241
2242    #[test]
2243    fn normalized_line_tooltip_shows_percentages() {
2244        // Stacked areas; in 100% mode the hover tooltip adds each series' share (%).
2245        let make = |norm: bool| {
2246            LineChart::new([("A", 2.0), ("B", 6.0)])
2247                .series("x", Color::rgb8(1, 2, 3), [2.0, 2.0])
2248                .stacked(true)
2249                .normalized(norm)
2250        };
2251        let tooltip_texts = |chart: &LineChart| -> Vec<String> {
2252            let mut scene = Scene::new();
2253            let status = Status {
2254                hover_cursor: Some(Point::new(75.0, 90.0)),
2255                ..Default::default()
2256            };
2257            Widget::<crate::callback::Callback>::paint(
2258                chart,
2259                Rect::new(0.0, 0.0, 300.0, 200.0),
2260                status,
2261                &Theme::default(),
2262                &mut scene,
2263            );
2264            scene
2265                .primitives()
2266                .iter()
2267                .filter_map(|p| match p {
2268                    Primitive::Text { text, .. } => Some(text.clone()),
2269                    _ => None,
2270                })
2271                .collect()
2272        };
2273        let norm = tooltip_texts(&make(true));
2274        assert!(
2275            norm.iter().any(|t| t.contains("(50%)")),
2276            "a % share in the 100%-mode tooltip, got {norm:?}"
2277        );
2278        let abs = tooltip_texts(&make(false));
2279        assert!(
2280            !abs.iter().any(|t| t.contains('%')),
2281            "no % in absolute mode, got {abs:?}"
2282        );
2283    }
2284
2285    #[test]
2286    fn stacked_areas_label_each_band_with_value_or_percentage() {
2287        // Absolute mode: every band thick enough carries its value on each category.
2288        let abs = LineChart::new([("A", 3.0), ("B", 5.0)])
2289            .series("x", Color::rgb8(1, 2, 3), [4.0, 6.0])
2290            .stacked(true);
2291        let prims = paint_line(&abs, 300.0, 260.0);
2292        let has = |t: &str| {
2293            prims
2294                .iter()
2295                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
2296        };
2297        // Band values: series 0 = 3 and 5; series x = 4 and 6.
2298        assert!(
2299            has("3") && has("4") && has("5") && has("6"),
2300            "band values displayed"
2301        );
2302        // 100% mode: shares in %.
2303        let norm = LineChart::new([("A", 2.0), ("B", 6.0)])
2304            .series("x", Color::rgb8(1, 2, 3), [2.0, 2.0])
2305            .stacked(true)
2306            .normalized(true);
2307        let np = paint_line(&norm, 300.0, 260.0);
2308        assert!(
2309            np.iter()
2310                .any(|p| matches!(p, Primitive::Text { text, .. } if text.ends_with('%'))),
2311            "% shares displayed inside the bands"
2312        );
2313    }
2314
2315    #[test]
2316    fn max_value_spans_all_series() {
2317        // The scale spans the extra series (max 9 > the main series' max of 6).
2318        let chart = LineChart::<crate::callback::Callback>::new([("A", 2.0), ("B", 6.0)]).series(
2319            "x",
2320            Color::rgb8(1, 2, 3),
2321            [9.0, 1.0],
2322        );
2323        assert_eq!(chart.max_value(), 9.0);
2324    }
2325
2326    #[test]
2327    fn area_fills_a_polygon_under_the_curve() {
2328        // A **filled** path (no stroke) made of straight segments = the area; without `.area`
2329        // only the markers (circles, with no `LineTo`) are filled.
2330        let filled_polygons = |chart: &LineChart| {
2331            paint_line(chart, 300.0, 200.0)
2332                .iter()
2333                .filter(|p| match p {
2334                    Primitive::Path {
2335                        fill: Some(_),
2336                        stroke: None,
2337                        path,
2338                        ..
2339                    } => path
2340                        .verbs()
2341                        .iter()
2342                        .any(|v| matches!(v, frus_core::PathVerb::LineTo(_))),
2343                    _ => false,
2344                })
2345                .count()
2346        };
2347        assert_eq!(
2348            filled_polygons(&LineChart::new([("A", 2.0), ("B", 6.0)])),
2349            0,
2350            "no area by default"
2351        );
2352        assert_eq!(
2353            filled_polygons(&LineChart::new([("A", 2.0), ("B", 6.0)]).area(true)),
2354            1,
2355            "one filled area under the curve"
2356        );
2357    }
2358
2359    #[test]
2360    fn grid_draws_horizontal_lines_and_axis_labels() {
2361        // A series with a max of 8 and four divisions → ticks 0, 2, 4, 6, 8.
2362        let chart = LineChart::new([("A", 2.0), ("B", 8.0)]).grid(4);
2363        let prims = paint_line(&chart, 300.0, 200.0);
2364        // Thin horizontal lines (height ~1): 4 grid lines + the baseline (1.5).
2365        let thin_lines = prims
2366            .iter()
2367            .filter(|p| matches!(p, Primitive::Rect { rect, .. } if rect.height <= 1.6))
2368            .count();
2369        assert!(
2370            thin_lines >= 5,
2371            "4 grid lines + the baseline, got {thin_lines}"
2372        );
2373        let has_text = |t: &str| {
2374            prims
2375                .iter()
2376                .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
2377        };
2378        // Axis ticks: 0 (the baseline) and 8 (the top) at least.
2379        assert!(has_text("0") && has_text("8"), "y-axis ticks");
2380    }
2381
2382    #[test]
2383    fn no_grid_by_default_keeps_full_width() {
2384        // Without a grid, no "0" tick is drawn (the original behaviour).
2385        let chart = LineChart::new([("A", 2.0), ("B", 8.0)]);
2386        let prims = paint_line(&chart, 300.0, 200.0);
2387        let has_zero = prims
2388            .iter()
2389            .any(|p| matches!(p, Primitive::Text { text, .. } if text == "0"));
2390        assert!(!has_zero, "no axis by default");
2391    }
2392}