Skip to main content

densa_widgets/
chart.rs

1//! A chart widget: the panes of a [`ChartModel`] on one horizontal axis, following the
2//! newest data until the user pans or zooms.
3
4use densa_chart::{Axis, ChartModel, ChartPainter, ChartPalette, Plot};
5use densa_core::{Color, Point, Rect, Size};
6use densa_draw::Canvas;
7use densa_text::Font;
8use densa_ui::{
9    AccessNode, ActionKind, Event, EventCx, Handled, Key, KeyEvent, LayoutCx, Length, NamedKey,
10    Overflow, PaintCx, PointerButton, PointerEvent, PointerKind, Role, ScrollDelta, Style, Theme,
11    Widget,
12};
13
14use crate::paint::{luminance, outline};
15
16/// How much one wheel notch zooms.
17const ZOOM_STEP: f64 = 1.15;
18/// Logical pixels of touchpad scrolling that count as one wheel notch.
19const NOTCH: f32 = 48.0;
20/// The share of the view an arrow key pans.
21const KEY_PAN: f64 = 0.1;
22/// Room right of the newest data while following, as a share of the view.
23const LEAD: f64 = 0.03;
24/// Points a view shows at least, when the model gives their distance.
25const MIN_STEPS: f64 = 5.0;
26/// The narrowest view, as a share of the data, when the model gives no distance.
27const MIN_SHARE: f64 = 1e-4;
28/// How far, in logical pixels, a press may move and still count toward a double click.
29const CLICK_SLOP: f32 = 4.0;
30/// The narrowest view, as a share of the magnitude of `x`: an `f64` tells apart about a
31/// ten-thousandth of this, so views stay far wider than the values' precision.
32const PRECISION: f64 = 1e-12;
33/// Characters the `y` labels get room for, unless told otherwise.
34const LABEL_CHARS: u16 = 8;
35/// The widest view, in widths of the data.
36const MAX_SHARE: f64 = 2.0;
37
38/// What part of the horizontal axis shows.
39#[derive(Debug, Clone, Copy, PartialEq)]
40enum View {
41    /// The newest `span` of the data, moving with it.
42    Follow { span: f64 },
43    /// A range the user panned or zoomed to, kept as data arrives.
44    Fixed(Axis),
45}
46
47/// A press dragging the view: grabbed at `x` while `from` showed across `width` pixels.
48#[derive(Debug, Clone, Copy, PartialEq)]
49struct Pan {
50    x: f32,
51    from: Axis,
52    width: f32,
53}
54
55/// A chart of a [`ChartModel`]: its panes stacked top to bottom on one horizontal axis,
56/// labelled under the lowest, each scaled to what it shows.
57///
58/// It shows the newest `span` of the data and moves with it as data arrives. The user zooms
59/// with the wheel or the up and down keys (around the pointer, or at the newest data while
60/// following it), pans by dragging, with a sideways scroll or with the left and right keys,
61/// and goes back to the newest data with a double click, End, or by panning past it; Home
62/// goes to the oldest. A view the user moved stays over the data as it changes (data
63/// trimmed away moves it along). A crosshair follows the pointer with its values, written a
64/// digit finer than the labels ([`with_label_chars`](Self::with_label_chars) for room). It
65/// reports [`ActionKind::Changed`] when the user moved the view.
66///
67/// Painting allocates nothing once the label characters are cached; what the model's
68/// series cost is up to them (this crate's series functions allocate nothing). Change the
69/// data through [`model_mut`](Self::model_mut) from an [`Ui::update`](densa_ui::Ui::update)
70/// on the chart, which repaints it. Give it [`Chart::style`].
71#[derive(Debug)]
72pub struct Chart<M> {
73    model: M,
74    view: View,
75    /// The span shown at first: the widest view while the data is narrower.
76    initial: f64,
77    label_chars: u16,
78    /// Built at layout, at the theme's text size and colors.
79    painter: Option<ChartPainter>,
80    built: Option<(f32, ChartPalette)>,
81    pan: Option<Pan>,
82    /// Whether the last press dragged the pointer: the press after it starts another drag,
83    /// not a double click.
84    dragged: bool,
85    /// Where the pointer is over the chart, for the crosshair.
86    hover: Option<Point>,
87}
88
89impl<M: ChartModel> Chart<M> {
90    /// A chart of `model` showing its newest `span` of `x` (an hour is `3600.0` on a time
91    /// axis), following new data.
92    pub fn new(model: M, span: f64) -> Self {
93        let span = if span.is_finite() && span > 0.0 {
94            span
95        } else {
96            1.0
97        };
98        Self {
99            model,
100            view: View::Follow { span },
101            initial: span,
102            label_chars: LABEL_CHARS,
103            painter: None,
104            built: None,
105            pan: None,
106            dragged: false,
107            hover: None,
108        }
109    }
110
111    /// The chart with room for `y` labels of `chars` characters (8 unless told otherwise).
112    #[must_use]
113    pub fn with_label_chars(mut self, chars: u16) -> Self {
114        self.label_chars = chars;
115        self.built = None;
116        self
117    }
118
119    /// A chart filling its parent's free space, allowed to be small, and clipping.
120    pub fn style() -> Style {
121        Style::column()
122            .grow(1.0)
123            .min_size(Length::Px(0.0), Length::Px(0.0))
124            .overflow(Overflow::Clip)
125    }
126
127    /// The data.
128    pub fn model(&self) -> &M {
129        &self.model
130    }
131
132    /// The data, to change (from an [`Ui::update`](densa_ui::Ui::update) on the chart): the
133    /// chart repaints.
134    pub fn model_mut(&mut self, cx: &mut EventCx<'_>) -> &mut M {
135        cx.request_paint();
136        &mut self.model
137    }
138
139    /// What part of the horizontal axis shows, or `None` while there is no data.
140    pub fn x_axis(&self) -> Option<Axis> {
141        let (_, newest) = self.extent()?;
142        let kind = self.model.x_kind();
143        Some(match self.current() {
144            View::Follow { span } => {
145                let end = newest + span * LEAD;
146                Axis::new(end - span, end, kind)
147            }
148            View::Fixed(axis) => Axis::new(axis.start(), axis.end(), kind),
149        })
150    }
151
152    /// Whether the chart moves with new data.
153    pub fn is_following(&self) -> bool {
154        matches!(self.current(), View::Follow { .. })
155    }
156
157    /// The view, kept over the data as it is now.
158    fn current(&self) -> View {
159        match self.view {
160            View::Fixed(axis) => self.settle(axis.start(), axis.span()),
161            follow @ View::Follow { .. } => follow,
162        }
163    }
164
165    /// Shows the newest data again, as wide a part as now, and follows it.
166    pub fn follow(&mut self, cx: &mut EventCx<'_>) {
167        if let Some(x) = self.x_axis() {
168            self.view = View::Follow { span: x.span() };
169            cx.request_paint();
170        }
171    }
172
173    /// Shows `x` (within what the data allows), following new data if it reaches past the
174    /// newest.
175    pub fn show(&mut self, cx: &mut EventCx<'_>, x: Axis) {
176        let (low, high) = self.limits();
177        let span = x.span().clamp(low, high);
178        let middle = x.start() + x.span() / 2.0;
179        self.view = self.settle(middle - span / 2.0, span);
180        cx.request_paint();
181    }
182
183    /// The data's lowest and highest `x`, if it has any (finite, in order).
184    fn extent(&self) -> Option<(f64, f64)> {
185        self.model
186            .x_extent()
187            .filter(|(low, high)| low.is_finite() && high.is_finite() && low <= high)
188    }
189
190    /// The narrowest and widest views the data allows.
191    fn limits(&self) -> (f64, f64) {
192        let Some((oldest, newest)) = self.extent() else {
193            return (f64::MIN_POSITIVE, f64::MAX);
194        };
195        let width = newest - oldest;
196        let low = match self.model.x_step() {
197            Some(step) if step.is_finite() && step > 0.0 => step * MIN_STEPS,
198            _ => width * MIN_SHARE,
199        };
200        let magnitude = oldest.abs().max(newest.abs()).max(1.0);
201        let low = low.max(magnitude * PRECISION);
202        let high = (width * MAX_SHARE).max(self.initial).max(low * 2.0);
203        (low, high)
204    }
205
206    /// The view `span` wide from `start`, kept over the data: it ends no earlier than the
207    /// oldest, and follows once it reaches past the newest.
208    fn settle(&self, start: f64, span: f64) -> View {
209        let kind = self.model.x_kind();
210        let Some((oldest, newest)) = self.extent() else {
211            return View::Fixed(Axis::new(start, start + span, kind));
212        };
213        let start = start.max(oldest - span);
214        if start + span >= newest + span * LEAD {
215            return View::Follow { span };
216        }
217        View::Fixed(Axis::new(start, start + span, kind))
218    }
219
220    /// Zooms by `factor` (below 1 in) around canvas `x`, or around the newest data while
221    /// following. Whether the view changed.
222    fn zoom(&mut self, factor: f64, x: Option<f32>, rect: Rect) -> bool {
223        let Some(shown) = self.x_axis() else {
224            return false;
225        };
226        let (low, high) = self.limits();
227        let span = (shown.span() * factor).clamp(low, high);
228        if span == shown.span() || !span.is_finite() {
229            return false;
230        }
231        let area = self.x_area(rect);
232        let anchor = match (self.current(), x, area) {
233            (View::Fixed(_), Some(x), Some(area)) if (area.x0..area.x1).contains(&x) => {
234                Plot::new(area, shown, shown).x_value(x)
235            }
236            (View::Fixed(_), _, _) => shown.start() + shown.span() / 2.0,
237            (View::Follow { .. }, _, _) => {
238                self.view = View::Follow { span };
239                return true;
240            }
241        };
242        let zoomed = shown.zoomed(span / shown.span(), anchor);
243        self.view = self.settle(zoomed.start(), span);
244        true
245    }
246
247    /// Pans by `dx` canvas pixels (positive shows later data). Whether the view changed.
248    fn pan_by(&mut self, dx: f32, rect: Rect) -> bool {
249        let (Some(shown), Some(area)) = (self.x_axis(), self.x_area(rect)) else {
250            return false;
251        };
252        let delta = f64::from(dx) / f64::from(area.width()) * shown.span();
253        self.shift(shown, delta)
254    }
255
256    /// Shows `shown` moved by `delta`. Whether the view changed.
257    fn shift(&mut self, shown: Axis, delta: f64) -> bool {
258        if delta == 0.0 || !delta.is_finite() {
259            return false;
260        }
261        let before = self.view;
262        self.view = self.settle(shown.start() + delta, shown.span());
263        self.view != before
264    }
265
266    /// Where the plots are across `rect` (all panes share their left and right edges).
267    fn x_area(&mut self, rect: Rect) -> Option<Rect> {
268        let area = self.painter.as_mut()?.plot_area(rect, true);
269        (area.width() > 0.0).then_some(area)
270    }
271
272    /// The painter for text of `px` pixels in `palette`, or none if the bundled font cannot
273    /// be read.
274    fn build(&mut self, px: f32, palette: ChartPalette) {
275        let chars = self.label_chars;
276        self.painter = Font::bundled_mono()
277            .ok()
278            .map(|font| ChartPainter::new(font, px, palette).with_label_chars(chars));
279        self.built = Some((px, palette));
280    }
281
282    fn pointer(&mut self, cx: &mut EventCx<'_>, pointer: &PointerEvent) -> Handled {
283        let at = pointer.position;
284        let rect = cx.rect();
285        let moved = match pointer.kind {
286            PointerKind::Scroll(delta) => {
287                self.wheel(cx, delta, at, pointer.modifiers.shift(), rect)
288            }
289            PointerKind::Down(PointerButton::Primary) if pointer.clicks >= 2 && !self.dragged => {
290                self.pan = None;
291                let following = self.is_following();
292                self.follow(cx);
293                !following
294            }
295            PointerKind::Down(PointerButton::Primary) => {
296                self.dragged = false;
297                if let (Some(from), Some(area)) = (self.x_axis(), self.x_area(rect)) {
298                    let width = area.width();
299                    self.pan = Some(Pan {
300                        x: at.x,
301                        from,
302                        width,
303                    });
304                }
305                cx.request_paint();
306                return Handled::Yes;
307            }
308            PointerKind::Move => match self.pan {
309                Some(pan) => {
310                    self.hover = Some(at);
311                    self.dragged |= (at.x - pan.x).abs() > cx.px(CLICK_SLOP);
312                    let delta = f64::from(pan.x - at.x) / f64::from(pan.width) * pan.from.span();
313                    let before = self.view;
314                    self.view = self.settle(pan.from.start() + delta, pan.from.span());
315                    self.view != before
316                }
317                None => {
318                    self.hover = Some(at);
319                    cx.request_paint();
320                    return Handled::No;
321                }
322            },
323            PointerKind::Leave => {
324                self.hover = None;
325                cx.request_paint();
326                return Handled::No;
327            }
328            PointerKind::Up(_) | PointerKind::Cancel => {
329                let held = self.pan.take().is_some();
330                cx.request_paint();
331                return if held { Handled::Yes } else { Handled::No };
332            }
333            _ => return Handled::No,
334        };
335        self.moved(cx, moved)
336    }
337
338    /// A wheel zooms around the pointer; a sideways scroll, or one with Shift, pans.
339    fn wheel(
340        &mut self,
341        cx: &mut EventCx<'_>,
342        delta: ScrollDelta,
343        at: Point,
344        shift: bool,
345        rect: Rect,
346    ) -> bool {
347        let notch = cx.px(NOTCH);
348        let (zoom, dx) = match delta {
349            ScrollDelta::Lines(dx, dy) if shift && dx == 0.0 => (0.0, dy * notch),
350            ScrollDelta::Lines(dx, dy) => (dy, dx * notch),
351            ScrollDelta::Pixels(dx, dy) if shift && dx == 0.0 => (0.0, dy),
352            ScrollDelta::Pixels(dx, dy) => (dy / notch, dx),
353            _ => (0.0, 0.0),
354        };
355        let mut moved = false;
356        if zoom != 0.0 && zoom.is_finite() {
357            moved |= self.zoom(ZOOM_STEP.powf(f64::from(zoom)), Some(at.x), rect);
358        }
359        if dx != 0.0 && dx.is_finite() {
360            moved |= self.pan_by(dx, rect);
361        }
362        moved
363    }
364
365    fn key(&mut self, cx: &mut EventCx<'_>, key: &KeyEvent<'_>) -> Handled {
366        let m = key.modifiers;
367        if m.shift() || m.control() || m.alt() || m.logo() {
368            return Handled::No;
369        }
370        let rect = cx.rect();
371        let Some(shown) = self.x_axis() else {
372            return Handled::No;
373        };
374        let moved = match key.key {
375            Key::Named(NamedKey::ArrowLeft) => self.shift(shown, -shown.span() * KEY_PAN),
376            Key::Named(NamedKey::ArrowRight) => self.shift(shown, shown.span() * KEY_PAN),
377            Key::Named(NamedKey::ArrowUp) => self.zoom(1.0 / ZOOM_STEP, None, rect),
378            Key::Named(NamedKey::ArrowDown) => self.zoom(ZOOM_STEP, None, rect),
379            Key::Named(NamedKey::End) => {
380                let following = self.is_following();
381                self.follow(cx);
382                !following
383            }
384            Key::Named(NamedKey::Home) => {
385                let oldest = self.extent().map_or(shown.start(), |(oldest, _)| oldest);
386                let before = self.view;
387                self.view = self.settle(oldest, shown.span());
388                self.view != before
389            }
390            _ => return Handled::No,
391        };
392        self.moved(cx, moved);
393        Handled::Yes
394    }
395
396    /// Repaints and reports a view the user moved.
397    fn moved(&mut self, cx: &mut EventCx<'_>, moved: bool) -> Handled {
398        if moved {
399            cx.request_paint();
400            cx.emit(ActionKind::Changed);
401            Handled::Yes
402        } else {
403            Handled::No
404        }
405    }
406
407    /// Draws every pane, the crosshair over them, and the focus ring.
408    fn paint_chart(&mut self, cx: &PaintCx<'_>, canvas: &mut Canvas<'_>) {
409        let rect = cx.rect();
410        // Data may have moved under a view the user chose: keep it over the data.
411        self.view = self.current();
412        let x = self.x_axis();
413        let hover = self.hover.filter(|_| self.pan.is_none());
414        let inside = canvas.state();
415        canvas.clip(rect);
416        if let Some((_, palette)) = self.built {
417            canvas.fill_rect(rect, palette.background);
418        }
419        let Some(painter) = self.painter.as_mut() else {
420            canvas.restore(inside);
421            return;
422        };
423        if let Some(x) = x {
424            let panes = self.model.panes().max(1);
425            let weight = |pane| {
426                let weight = f64::from(self.model.pane_weight(pane));
427                if weight.is_finite() {
428                    weight.max(0.0)
429                } else {
430                    0.0
431                }
432            };
433            let total: f64 = (0..panes).map(weight).sum();
434            let (mut top, mut before) = (f64::from(rect.top()), 0.0);
435            for pane in 0..panes {
436                before += if total > 0.0 {
437                    weight(pane) / total
438                } else {
439                    // Lossless enough: a handful of panes.
440                    1.0 / panes as f64
441                };
442                let last = pane + 1 == panes;
443                // Lossless enough: pixels.
444                let bottom = f64::from(rect.top()) + f64::from(rect.height()) * before;
445                let bounds = Rect::from_edges(rect.left(), top as f32, rect.right(), bottom as f32);
446                top = bottom;
447                let title = self.model.pane_title(pane);
448                let bounds = if title.is_empty() {
449                    bounds
450                } else {
451                    // A pane shorter than the line keeps an empty plot, not an inverted one.
452                    let below = (bounds.y0 + painter.title_height()).min(bounds.y1);
453                    Rect::from_edges(bounds.x0, below, bounds.x1, bounds.y1)
454                };
455                let area = painter.plot_area(bounds, last);
456                let y = self
457                    .model
458                    .y_axis(pane, &x)
459                    .unwrap_or_else(|| Axis::number(0.0, 1.0));
460                let plot = Plot::new(area, x, y);
461                let model = &self.model;
462                let names = |index| model.category(index);
463                painter.paint_axes_named(canvas, &plot, last, &names);
464                model.paint(pane, &plot, canvas, painter.palette());
465                painter.paint_title(canvas, &plot, title);
466                if let Some(at) = hover {
467                    painter.paint_crosshair_named(canvas, &plot, at, last, &names);
468                }
469            }
470        }
471        if cx.is_focused() {
472            let line = cx.px(1.0).round().max(1.0);
473            outline(canvas, rect, line, cx.theme().focus);
474        }
475        canvas.restore(inside);
476    }
477}
478
479/// The chart's colors from the theme.
480fn palette(theme: &Theme) -> ChartPalette {
481    let mut palette = ChartPalette::new();
482    palette.background = theme.surface;
483    palette.grid = theme.text.with_alpha(0.06);
484    palette.axis = theme.border;
485    palette.label = theme.text_dim;
486    palette.line = theme.accent;
487    palette.crosshair = theme.text_dim.with_alpha(0.7);
488    palette.marker = theme.raised;
489    palette.marker_text = theme.text;
490    // Darker green and red on a light surface, as the grid has.
491    if luminance(theme.surface) > 0.5 {
492        palette.up = Color::srgb(0x1A7F37);
493        palette.down = Color::srgb(0xCF222E);
494    }
495    palette
496}
497
498impl<M: ChartModel + 'static> Widget for Chart<M> {
499    fn layout(&mut self, cx: &mut LayoutCx<'_>, _size: Size) {
500        let px = cx.px(cx.theme().font_size * 0.9).round();
501        let palette = palette(cx.theme());
502        if self.built != Some((px, palette)) {
503            self.build(px, palette);
504        }
505    }
506
507    fn paint(&mut self, cx: &PaintCx<'_>, canvas: &mut Canvas<'_>) {
508        self.model.prepare();
509        self.paint_chart(cx, canvas);
510    }
511
512    fn event(&mut self, cx: &mut EventCx<'_>, event: &Event<'_>) -> Handled {
513        match event {
514            Event::Pointer(pointer) => self.pointer(cx, pointer),
515            Event::Key(key) if key.pressed => self.key(cx, key),
516            Event::FocusGained | Event::FocusLost => {
517                cx.request_paint();
518                Handled::No
519            }
520            _ => Handled::No,
521        }
522    }
523
524    fn accepts_focus(&self) -> bool {
525        true
526    }
527
528    fn accessibility(&self, _cx: &PaintCx<'_>, node: &mut AccessNode) {
529        node.set_role(Role::Image);
530        let description = self.model.describe();
531        node.set_label(if description.is_empty() {
532            "Chart".to_owned()
533        } else {
534            description
535        });
536    }
537}