Skip to main content

densa_chart/
painter.rs

1use densa_core::{Color, Point, Rect};
2use densa_draw::Canvas;
3use densa_fmt::FmtBuf;
4use densa_text::{Font, GlyphCache, GlyphMap, SUBPIXEL_STEPS};
5
6use crate::{AxisKind, ChartPalette, Plot, Ticks};
7
8/// Bytes a tick label may take.
9const LABEL_BYTES: usize = 32;
10
11/// Every character a tick label can use.
12const LABEL_CHARS: &str = "0123456789-.:";
13
14/// Characters an x label is given room for (a date, and a gap to the next one).
15const X_LABEL_ROOM: f32 = 11.0;
16
17/// Characters an x label below a second is given room for (`HH:MM:SS.fff` and a gap).
18const X_FINE_LABEL_ROOM: f32 = 15.0;
19
20/// Written at the end of a category name cut to its room.
21const ELLIPSIS: char = '\u{2026}';
22
23/// Line heights between two y ticks, at least.
24const Y_TICK_LINES: f32 = 2.5;
25
26/// How many times more ticks a crosshair's value is written for: one digit finer.
27const FINER: usize = 10;
28
29/// Draws what is around a chart's series: gridlines at the ticks, the axis edges and the
30/// tick labels, right of the plot for `y` and under it for `x`, and a crosshair over it.
31///
32/// Owns the label font's character map; glyphs come from the canvas's shared cache.
33/// Painting allocates nothing once the label characters are cached (see
34/// [`prewarm`](Self::prewarm)); the characters of category names are cached the first time
35/// they are drawn.
36#[derive(Debug)]
37pub struct ChartPainter {
38    glyphs: GlyphMap,
39    palette: ChartPalette,
40    label: FmtBuf<LABEL_BYTES>,
41    label_chars: f32,
42}
43
44impl ChartPainter {
45    /// A painter writing labels in `font` at `px` pixels per em, with room for `y` labels
46    /// of 8 characters.
47    pub fn new(font: Font, px: f32, palette: ChartPalette) -> Self {
48        Self {
49            glyphs: GlyphMap::new(font, px),
50            palette,
51            label: FmtBuf::new(),
52            label_chars: 8.0,
53        }
54    }
55
56    /// The painter with room for `y` labels of `chars` characters.
57    #[must_use]
58    pub fn with_label_chars(mut self, chars: u16) -> Self {
59        self.label_chars = f32::from(chars.max(1));
60        self
61    }
62
63    /// The colors this painter draws with.
64    pub fn palette(&self) -> &ChartPalette {
65        &self.palette
66    }
67
68    /// Draws with `palette` from now on.
69    pub fn set_palette(&mut self, palette: ChartPalette) {
70        self.palette = palette;
71    }
72
73    /// Rasterizes every character a label can use into `cache` at every subpixel position
74    /// now, so frames never rasterize (or allocate) for labels. Returns how many glyphs could
75    /// not be cached.
76    pub fn prewarm(&mut self, cache: &mut GlyphCache) -> usize {
77        let mut missing = 0;
78        for ch in LABEL_CHARS.chars() {
79            let Some(mapped) = self.glyphs.get(ch) else {
80                continue;
81            };
82            for bin in 0..SUBPIXEL_STEPS {
83                let cached = cache.get(self.glyphs.font(), mapped.id, self.glyphs.px(), bin);
84                missing += usize::from(cached.is_err());
85            }
86        }
87        missing
88    }
89
90    /// The area of `bounds` left for the plot once the `y` labels (right) and, if
91    /// `x_labels`, the `x` labels (below) have their room; edges on whole pixels. Charts
92    /// stacked on one time axis label `x` under the lowest only.
93    pub fn plot_area(&mut self, bounds: Rect, x_labels: bool) -> Rect {
94        let right = (self.char_width() * self.label_chars + 2.0 * self.pad()).round();
95        let below = if x_labels {
96            (self.glyphs.line_metrics().line_height() + self.pad()).round()
97        } else {
98            0.0
99        };
100        Rect::from_edges(
101            bounds.x0.round(),
102            bounds.y0.round(),
103            (bounds.x1 - right).round(),
104            (bounds.y1 - below).round(),
105        )
106    }
107
108    /// Draws the gridlines, the axis edges and the `y` labels of `plot`, and its `x` labels
109    /// if `x_labels`. Call it before the series so they draw over the grid. A
110    /// [`Category`](AxisKind::Category) axis gets no `x` labels here: see
111    /// [`paint_axes_named`](Self::paint_axes_named).
112    pub fn paint_axes(&mut self, canvas: &mut Canvas<'_>, plot: &Plot, x_labels: bool) {
113        self.paint_axes_named(canvas, plot, x_labels, &|_| None);
114    }
115
116    /// [`paint_axes`](Self::paint_axes), with the places of a
117    /// [`Category`](AxisKind::Category) `x` axis labelled by `names` (`None`: no label
118    /// there). A name longer than the room between two labels is cut, ending in `…`. Other
119    /// axes ignore `names`.
120    pub fn paint_axes_named<'n>(
121        &mut self,
122        canvas: &mut Canvas<'_>,
123        plot: &Plot,
124        x_labels: bool,
125        names: &dyn Fn(usize) -> Option<&'n str>,
126    ) {
127        let area = plot.area();
128        if area.is_empty() {
129            return;
130        }
131        let metrics = self.glyphs.line_metrics();
132        let pad = self.pad();
133        let palette = self.palette;
134
135        let ticks = self.y_ticks(plot, 1);
136        for value in ticks.clone() {
137            // Lossless enough: pixels.
138            let y = plot.y_at(value).round() as f32;
139            if !(area.y0..area.y1).contains(&y) {
140                continue;
141            }
142            canvas.fill_rect(Rect::new(area.x0, y, area.width(), 1.0), palette.grid);
143            self.label.clear();
144            if ticks.write_label(value, &mut self.label).is_ok() {
145                let baseline = (y + (metrics.ascent + metrics.descent) / 2.0).round();
146                let at = Point::new(area.x1 + pad, baseline);
147                draw_text(
148                    &mut self.glyphs,
149                    canvas,
150                    self.label.as_str(),
151                    at,
152                    palette.label,
153                );
154            }
155        }
156
157        let ticks = self.x_ticks(plot, 1);
158        let category = plot.x().kind() == AxisKind::Category;
159        // Lossless enough: pixels. A category name may take the room up to the next one's.
160        let room = (ticks.step() / plot.x().span()) as f32 * area.width() - self.char_width();
161        for value in ticks.clone() {
162            // Lossless enough: pixels.
163            let x = plot.x_at(value).round() as f32;
164            if !(area.x0..area.x1).contains(&x) {
165                continue;
166            }
167            canvas.fill_rect(Rect::new(x, area.y0, 1.0, area.height()), palette.grid);
168            if category {
169                if let Some(name) = place(value).and_then(names).filter(|_| x_labels) {
170                    // Half the room each side of the tick, less where the plot ends: a label
171                    // pushed in from an edge would reach into the next one's room. (Without
172                    // room, nothing fits and nothing is drawn.)
173                    let before = (room / 2.0).min(x - area.x0);
174                    let after = (room / 2.0).min(area.x1 - x);
175                    let fitted = fit(&mut self.glyphs, name, before + after);
176                    let left = (x - fitted.width / 2.0)
177                        .clamp(x - before, (x + after - fitted.width).max(x - before));
178                    let at = Point::new(left.round(), (area.y1 + pad + metrics.ascent).round());
179                    draw_fit(&mut self.glyphs, canvas, name, fitted, at, palette.label);
180                }
181                continue;
182            }
183            self.label.clear();
184            if x_labels && ticks.write_label(value, &mut self.label).is_ok() {
185                let width = self.glyphs.measure(self.label.as_str());
186                let left = (x - width / 2.0).clamp(area.x0, (area.x1 - width).max(area.x0));
187                let at = Point::new(left.round(), (area.y1 + pad + metrics.ascent).round());
188                draw_text(
189                    &mut self.glyphs,
190                    canvas,
191                    self.label.as_str(),
192                    at,
193                    palette.label,
194                );
195            }
196        }
197
198        let right = Rect::new(area.x1, area.y0, 1.0, area.height() + 1.0);
199        canvas.fill_rect(right, palette.axis);
200        canvas.fill_rect(Rect::new(area.x0, area.y1, area.width(), 1.0), palette.axis);
201    }
202
203    /// Draws a crosshair at `at` over `plot`: a line down the plot through `at`, and, if
204    /// `at` is within the plot, a line across it with the `y` there in a box right of the
205    /// plot; with `x_label`, the `x` there in a box under the plot. The values are written
206    /// finer than the tick labels. For stacked plots, give each one the same `at` and label
207    /// `x` under the lowest. A [`Category`](AxisKind::Category) axis gets no `x` value
208    /// here: see [`paint_crosshair_named`](Self::paint_crosshair_named).
209    pub fn paint_crosshair(
210        &mut self,
211        canvas: &mut Canvas<'_>,
212        plot: &Plot,
213        at: Point,
214        x_label: bool,
215    ) {
216        self.paint_crosshair_named(canvas, plot, at, x_label, &|_| None);
217    }
218
219    /// [`paint_crosshair`](Self::paint_crosshair), with the `x` of a
220    /// [`Category`](AxisKind::Category) axis written as the name `names` gives the nearest
221    /// place (`None`: nothing written). Other axes ignore `names`.
222    pub fn paint_crosshair_named<'n>(
223        &mut self,
224        canvas: &mut Canvas<'_>,
225        plot: &Plot,
226        at: Point,
227        x_label: bool,
228        names: &dyn Fn(usize) -> Option<&'n str>,
229    ) {
230        let area = plot.area();
231        let x = at.x.round();
232        if area.is_empty() || !(area.x0..area.x1).contains(&x) {
233            return;
234        }
235        let metrics = self.glyphs.line_metrics();
236        let (pad, line) = (self.pad(), metrics.line_height().round());
237        let palette = self.palette;
238        canvas.fill_rect(Rect::new(x, area.y0, 1.0, area.height()), palette.crosshair);
239
240        let y = at.y.round();
241        if (area.y0..area.y1).contains(&y) {
242            canvas.fill_rect(Rect::new(area.x0, y, area.width(), 1.0), palette.crosshair);
243            let fine = self.y_ticks(plot, FINER);
244            self.label.clear();
245            if fine.write_label(plot.y_value(y), &mut self.label).is_ok() {
246                let width = self.glyphs.measure(self.label.as_str());
247                let top = (y - line / 2.0).round();
248                let marker = Rect::new(area.x1 + 1.0, top, width + 2.0 * pad, line);
249                canvas.fill_rect(marker, palette.marker);
250                let baseline = (y + (metrics.ascent + metrics.descent) / 2.0).round();
251                let at = Point::new(area.x1 + 1.0 + pad, baseline);
252                draw_text(
253                    &mut self.glyphs,
254                    canvas,
255                    self.label.as_str(),
256                    at,
257                    palette.marker_text,
258                );
259            }
260        }
261
262        let category = plot.x().kind() == AxisKind::Category;
263        if let Some(name) = place(plot.x_value(x).round())
264            .and_then(names)
265            .filter(|_| x_label && category)
266        {
267            let fitted = fit(&mut self.glyphs, name, (area.width() - 2.0 * pad).max(0.0));
268            let width = fitted.width + 2.0 * pad;
269            let left = (x - width / 2.0)
270                .clamp(area.x0, (area.x1 - width).max(area.x0))
271                .round();
272            let marker = Rect::new(left, area.y1 + 1.0, width, pad + line);
273            canvas.fill_rect(marker, palette.marker);
274            let at = Point::new(left + pad, (area.y1 + pad + metrics.ascent).round());
275            draw_fit(
276                &mut self.glyphs,
277                canvas,
278                name,
279                fitted,
280                at,
281                palette.marker_text,
282            );
283        } else if x_label && !category {
284            let fine = self.x_ticks(plot, FINER);
285            self.label.clear();
286            if fine.write_label(plot.x_value(x), &mut self.label).is_ok() {
287                let width = self.glyphs.measure(self.label.as_str()) + 2.0 * pad;
288                let left = (x - width / 2.0)
289                    .clamp(area.x0, (area.x1 - width).max(area.x0))
290                    .round();
291                let marker = Rect::new(left, area.y1 + 1.0, width, pad + line);
292                canvas.fill_rect(marker, palette.marker);
293                let at = Point::new(left + pad, (area.y1 + pad + metrics.ascent).round());
294                draw_text(
295                    &mut self.glyphs,
296                    canvas,
297                    self.label.as_str(),
298                    at,
299                    palette.marker_text,
300                );
301            }
302        }
303    }
304
305    /// The height of the line a title takes above a plot: leave it out of the bounds given
306    /// to [`plot_area`](Self::plot_area) for a plot with a title.
307    pub fn title_height(&self) -> f32 {
308        self.glyphs.line_metrics().line_height().round()
309    }
310
311    /// Writes `title` on the line of [`title_height`](Self::title_height) above `plot`,
312    /// from its left edge, cut off where the plot ends: above the plot, it hides none of
313    /// it. An empty title draws nothing.
314    pub fn paint_title(&mut self, canvas: &mut Canvas<'_>, plot: &Plot, title: &str) {
315        let area = plot.area();
316        if area.is_empty() {
317            return;
318        }
319        let line = self.title_height();
320        let top = area.y0 - line;
321        let outside = canvas.state();
322        canvas.clip(Rect::new(area.x0, top, area.width(), line));
323        let ascent = self.glyphs.line_metrics().ascent.round();
324        let at = Point::new(area.x0, top + ascent);
325        draw_text(&mut self.glyphs, canvas, title, at, self.palette.label);
326        canvas.restore(outside);
327    }
328
329    /// The `y` ticks `plot` has room for, `finer` times as many.
330    fn y_ticks(&self, plot: &Plot, finer: usize) -> Ticks {
331        let line = self.glyphs.line_metrics().line_height();
332        // Lossless enough: a whole number of ticks, saturating for absurd heights.
333        let most = (plot.area().height() / (line * Y_TICK_LINES)).floor() as usize;
334        plot.y().ticks(most.saturating_mul(finer))
335    }
336
337    /// The `x` ticks `plot` has room for, `finer` times as many: labels of times below a
338    /// second are longer, and get more room.
339    fn x_ticks(&mut self, plot: &Plot, finer: usize) -> Ticks {
340        let (width, char_width) = (plot.area().width(), self.char_width());
341        // Lossless enough: a whole number of ticks, saturating for absurd widths.
342        let most =
343            |room: f32| ((width / (char_width * room)).floor() as usize).saturating_mul(finer);
344        let ticks = plot.x().ticks(most(X_LABEL_ROOM));
345        if plot.x().kind() == AxisKind::Time && ticks.step() < 1.0 {
346            return plot.x().ticks(most(X_FINE_LABEL_ROOM));
347        }
348        ticks
349    }
350
351    /// The advance of a digit.
352    fn char_width(&mut self) -> f32 {
353        let px = self.glyphs.px();
354        self.glyphs.get('0').map_or(px * 0.6, |glyph| glyph.advance)
355    }
356
357    /// Space between the plot and its labels.
358    fn pad(&self) -> f32 {
359        (self.glyphs.px() * 0.5).round()
360    }
361}
362
363/// The category place at `value`, a whole number (a tick, or a rounded `x`), if it is one.
364fn place(value: f64) -> Option<usize> {
365    // Lossless: a whole number in usize's range.
366    (value >= 0.0 && value < usize::MAX as f64).then_some(value as usize)
367}
368
369/// How much of a text fits a room: see [`fit`].
370#[derive(Clone, Copy)]
371struct Fitted {
372    /// Where the part drawn ends, in bytes.
373    end: usize,
374    /// The width drawn, `…` included.
375    width: f32,
376    /// Whether the text was cut, ending in `…`.
377    cut: bool,
378}
379
380/// How much of `text` fits in `room` pixels: all of it, or as much as fits with `…` after
381/// it (nothing at all if not even `…` fits). Control characters (line breaks, tabs) take no
382/// room and are not drawn. Reads no further than the room holds, however long the text.
383fn fit(glyphs: &mut GlyphMap, text: &str, room: f32) -> Fitted {
384    let ellipsis = glyphs.get(ELLIPSIS).map_or(0.0, |glyph| glyph.advance);
385    let none = Fitted {
386        end: 0,
387        width: 0.0,
388        cut: false,
389    };
390    // The longest start that leaves room for `…` after it.
391    let mut cut = (ellipsis <= room).then_some(Fitted {
392        cut: true,
393        width: ellipsis,
394        ..none
395    });
396    let mut width = 0.0;
397    for (at, ch) in text.char_indices() {
398        let advance = match glyphs.get(ch) {
399            Some(mapped) if !ch.is_control() => mapped.advance,
400            _ => 0.0,
401        };
402        if width + advance > room {
403            return cut.unwrap_or(none);
404        }
405        width += advance;
406        if width + ellipsis <= room {
407            let end = at + ch.len_utf8();
408            cut = Some(Fitted {
409                end,
410                width: width + ellipsis,
411                cut: true,
412            });
413        }
414    }
415    Fitted {
416        end: text.len(),
417        width,
418        cut: false,
419    }
420}
421
422/// Draws what `fitted` keeps of `text` from `at` (`y` is the baseline), and `…` if it was cut.
423fn draw_fit(
424    glyphs: &mut GlyphMap,
425    canvas: &mut Canvas<'_>,
426    text: &str,
427    fitted: Fitted,
428    at: Point,
429    color: Color,
430) {
431    let mut pen = at.x;
432    let kept = text[..fitted.end].chars().filter(|ch| !ch.is_control());
433    let ellipsis = fitted.cut.then_some(ELLIPSIS);
434    for ch in kept.chain(ellipsis) {
435        let Some(mapped) = glyphs.get(ch) else {
436            continue;
437        };
438        canvas.draw_glyph(
439            glyphs.font(),
440            mapped.id,
441            glyphs.px(),
442            Point::new(pen, at.y),
443            color,
444        );
445        pen += mapped.advance;
446    }
447}
448
449/// Draws `text` with its pen starting at `at` (`y` is the baseline).
450fn draw_text(glyphs: &mut GlyphMap, canvas: &mut Canvas<'_>, text: &str, at: Point, color: Color) {
451    let mut pen = at.x;
452    for ch in text.chars() {
453        let Some(mapped) = glyphs.get(ch) else {
454            continue;
455        };
456        let at = Point::new(pen, at.y);
457        canvas.draw_glyph(glyphs.font(), mapped.id, glyphs.px(), at, color);
458        pen += mapped.advance;
459    }
460}