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