Skip to main content

rust_ppm/plot/
canvas.rs

1use crate::{Image, Pixel};
2
3use super::Axes;
4use super::raster::{Bounds, draw_axis_arrows, draw_box, draw_line, draw_marker, draw_thick_line};
5use super::style::{LineStyle, MarkerStyle};
6use super::text::{self, GLYPH_HEIGHT};
7
8const TICK_LENGTH: usize = 4;
9const TEXT_GAP: usize = 2;
10
11enum Series {
12    Markers(Vec<(f64, f64)>, MarkerStyle),
13    Line(Vec<(f64, f64)>, LineStyle),
14}
15
16impl Series {
17    fn points(&self) -> &[(f64, f64)] {
18        match self {
19            Self::Markers(points, _) | Self::Line(points, _) => points,
20        }
21    }
22}
23
24/// An image-backed plotting surface.
25///
26/// `Canvas` keeps the rendered image, the current axis configuration, and any
27/// series added to it. It is the lower-level incremental API for custom plotting
28/// workflows while `Plot` provides a simpler higher-level builder interface.
29pub struct Canvas {
30    /// The current axis configuration used to map data coordinates to pixels.
31    pub axes: Axes,
32    /// The rasterized plot image.
33    pub image: Image,
34    inset_x: usize,
35    inset_y: usize,
36    render_scale: usize,
37    series: Vec<Series>,
38}
39
40impl Canvas {
41    /// Creates a new canvas with a given pixel size and axis configuration.
42    pub fn new(width: usize, height: usize, axes: Axes) -> Self {
43        Self::with_inset(width, height, axes, 0, 0)
44    }
45
46    /// Creates a new canvas with a custom inset margin around the drawing bounds.
47    pub fn with_inset(
48        width: usize,
49        height: usize,
50        axes: Axes,
51        inset_x: usize,
52        inset_y: usize,
53    ) -> Self {
54        Self {
55            axes,
56            image: Image::new_white(width, height),
57            inset_x,
58            inset_y,
59            render_scale: 1,
60            series: Vec::new(),
61        }
62    }
63
64    /// Returns the canvas width in pixels.
65    pub fn width(&self) -> usize {
66        self.image.width
67    }
68
69    /// Returns the canvas height in pixels.
70    pub fn height(&self) -> usize {
71        self.image.height
72    }
73
74    /// Returns the horizontal and vertical inset values in pixels.
75    pub fn insets(&self) -> (usize, usize) {
76        (self.inset_x, self.inset_y)
77    }
78
79    /// Updates the inset margins used by the plot area.
80    pub fn set_inset(&mut self, inset_x: usize, inset_y: usize) {
81        self.inset_x = inset_x;
82        self.inset_y = inset_y;
83    }
84
85    /// Sets the render scale used for higher-resolution output.
86    pub fn set_render_scale(&mut self, scale: usize) {
87        assert!(scale > 0, "render scale must be greater than zero");
88        self.render_scale = scale;
89        self.redraw();
90    }
91
92    /// Draws the axes, labels, and ticks using the current axes configuration.
93    pub fn render(&mut self) {
94        let Some(bounds) = self.bounds() else {
95            return;
96        };
97        self.draw_axes(bounds);
98    }
99
100    /// Adds a scatter series with a default marker style and explicit marker size.
101    pub fn scatter(&mut self, points: &[(f64, f64)], size: usize) {
102        self.markers(points, MarkerStyle::new().size(size));
103    }
104
105    /// Adds a marker-based series using a custom marker style.
106    pub fn markers(&mut self, points: &[(f64, f64)], style: MarkerStyle) {
107        self.series.push(Series::Markers(points.to_vec(), style));
108        if self.refresh_auto_limits() {
109            self.redraw();
110        } else {
111            self.draw_markers(points, style);
112        }
113    }
114
115    /// Adds a line series with a default width.
116    pub fn plot(&mut self, points: &[(f64, f64)], line_width: usize) {
117        self.line(points, LineStyle::new().width(line_width));
118    }
119
120    /// Adds a line series using a custom style.
121    pub fn line(&mut self, points: &[(f64, f64)], style: LineStyle) {
122        self.series.push(Series::Line(points.to_vec(), style));
123        if self.refresh_auto_limits() {
124            self.redraw();
125        } else {
126            self.draw_line_series(points, style);
127        }
128    }
129
130    /// Sets the x-axis limits and redraws the canvas.
131    pub fn set_xlim(&mut self, xlim: (f64, f64)) {
132        self.axes.set_xlim(xlim);
133        self.redraw();
134    }
135
136    /// Sets the y-axis limits and redraws the canvas.
137    pub fn set_ylim(&mut self, ylim: (f64, f64)) {
138        self.axes.set_ylim(ylim);
139        self.redraw();
140    }
141
142    /// Re-enables automatic x-axis scaling for data-driven limits.
143    pub fn use_auto_xlim(&mut self) {
144        self.axes.use_auto_xlim();
145        self.refresh_auto_limits();
146        self.redraw();
147    }
148
149    /// Re-enables automatic y-axis scaling for data-driven limits.
150    pub fn use_auto_ylim(&mut self) {
151        self.axes.use_auto_ylim();
152        self.refresh_auto_limits();
153        self.redraw();
154    }
155
156    fn draw_markers(&mut self, points: &[(f64, f64)], style: MarkerStyle) {
157        let Some(bounds) = self.bounds() else {
158            return;
159        };
160
161        for &(x, y) in points {
162            if !self.contains(x, y) {
163                continue;
164            }
165
166            if let Some(pixel) = self.map_point((x, y), bounds) {
167                draw_marker(&mut self.image, pixel, style.size, bounds, style.color);
168            }
169        }
170    }
171
172    fn draw_line_series(&mut self, points: &[(f64, f64)], style: LineStyle) {
173        let Some(bounds) = self.bounds() else {
174            return;
175        };
176
177        let mut previous = None;
178        for &(x, y) in points {
179            if !self.contains(x, y) {
180                previous = None;
181                continue;
182            }
183
184            if let Some(pixel) = self.map_point((x, y), bounds) {
185                if let Some(start) = previous {
186                    draw_thick_line(
187                        &mut self.image,
188                        start,
189                        pixel,
190                        style.width,
191                        bounds,
192                        style.color,
193                    );
194                } else {
195                    draw_marker(&mut self.image, pixel, style.width, bounds, style.color);
196                }
197                previous = Some(pixel);
198            }
199        }
200    }
201
202    fn refresh_auto_limits(&mut self) -> bool {
203        let limits = data_limits(self.series.iter().flat_map(|series| series.points()));
204        let (xlim, ylim) = limits.unzip();
205        self.axes.update_auto_limits(xlim, ylim)
206    }
207
208    fn redraw(&mut self) {
209        self.image = Image::new_white(self.width(), self.height());
210        self.render();
211
212        let series = std::mem::take(&mut self.series);
213        for item in &series {
214            match item {
215                Series::Markers(points, style) => self.draw_markers(points, *style),
216                Series::Line(points, style) => self.draw_line_series(points, *style),
217            }
218        }
219        self.series = series;
220    }
221
222    /// Consumes the canvas and returns the rendered image.
223    pub fn into_image(self) -> Image {
224        self.image
225    }
226
227    fn bounds(&self) -> Option<Bounds> {
228        Bounds::from_insets(self.width(), self.height(), self.inset_x, self.inset_y)
229    }
230
231    fn contains(&self, x: f64, y: f64) -> bool {
232        x >= self.axes.xlim.0
233            && x <= self.axes.xlim.1
234            && y >= self.axes.ylim.0
235            && y <= self.axes.ylim.1
236    }
237
238    fn map_point(&self, point: (f64, f64), bounds: Bounds) -> Option<(usize, usize)> {
239        Some((
240            map_x(point.0, self.axes.xlim, bounds)?,
241            map_y(point.1, self.axes.ylim, bounds)?,
242        ))
243    }
244
245    fn draw_axes(&mut self, bounds: Bounds) {
246        draw_box(&mut self.image, bounds, Pixel::BLACK);
247
248        let y_axis_x = if contains_zero(self.axes.xlim) {
249            map_x(0.0, self.axes.xlim, bounds)
250        } else {
251            Some(bounds.left + (bounds.right - bounds.left) / 2)
252        };
253        let x_axis_y = if contains_zero(self.axes.ylim) {
254            map_y(0.0, self.axes.ylim, bounds)
255        } else {
256            Some(bounds.top + (bounds.bottom - bounds.top) / 2)
257        };
258
259        if let Some(x) = y_axis_x {
260            for y in bounds.top..=bounds.bottom {
261                self.image.set_pixel(x, y, Pixel::BLACK);
262            }
263        }
264        if let Some(y) = x_axis_y {
265            for x in bounds.left..=bounds.right {
266                self.image.set_pixel(x, y, Pixel::BLACK);
267            }
268        }
269
270        self.draw_ticks(bounds);
271
272        if let (Some(y_axis_x), Some(x_axis_y)) = (y_axis_x, x_axis_y) {
273            draw_axis_arrows(&mut self.image, y_axis_x, x_axis_y, bounds, Pixel::BLACK);
274        }
275
276        self.draw_labels(bounds);
277    }
278
279    fn draw_ticks(&mut self, bounds: Bounds) {
280        let bottom_margin = self.height() - 1 - bounds.bottom;
281        let tick_length = TICK_LENGTH * self.render_scale;
282        let text_gap = TEXT_GAP * self.render_scale;
283        let glyph_height = GLYPH_HEIGHT * self.render_scale;
284
285        for &tick in &self.axes.xticks {
286            let Some(x) = map_x(tick, self.axes.xlim, bounds) else {
287                continue;
288            };
289            let tick_end = (bounds.bottom + tick_length).min(self.height() - 1);
290            draw_line(
291                &mut self.image,
292                (x, bounds.bottom),
293                (x, tick_end),
294                Pixel::BLACK,
295            );
296
297            if bottom_margin >= tick_length + text_gap + glyph_height {
298                let label = format_tick(tick);
299                let (label_width, _) = text::measure(&label, self.render_scale);
300                let label_x = centered_start(x, label_width, self.width());
301                text::draw(
302                    &mut self.image,
303                    (label_x, bounds.bottom + tick_length + text_gap),
304                    &label,
305                    self.render_scale,
306                    Pixel::BLACK,
307                );
308            }
309        }
310
311        for &tick in &self.axes.yticks {
312            let Some(y) = map_y(tick, self.axes.ylim, bounds) else {
313                continue;
314            };
315            let tick_start = bounds.left.saturating_sub(tick_length);
316            draw_line(
317                &mut self.image,
318                (tick_start, y),
319                (bounds.left, y),
320                Pixel::BLACK,
321            );
322
323            let label = format_tick(tick);
324            let (label_width, label_height) = text::measure(&label, self.render_scale);
325            if self.height() >= label_height && bounds.left >= tick_length + text_gap + label_width
326            {
327                let label_x = tick_start - text_gap - label_width;
328                let label_y = y
329                    .saturating_sub(label_height / 2)
330                    .min(self.height() - label_height);
331                text::draw(
332                    &mut self.image,
333                    (label_x, label_y),
334                    &label,
335                    self.render_scale,
336                    Pixel::BLACK,
337                );
338            }
339        }
340    }
341
342    fn draw_labels(&mut self, bounds: Bounds) {
343        let bottom_margin = self.height() - 1 - bounds.bottom;
344        let tick_length = TICK_LENGTH * self.render_scale;
345        let text_gap = TEXT_GAP * self.render_scale;
346        let glyph_height = GLYPH_HEIGHT * self.render_scale;
347
348        if !self.axes.title.is_empty() && bounds.top >= glyph_height + text_gap {
349            let (width, _) = text::measure(&self.axes.title, self.render_scale);
350            let x = centered_start((bounds.left + bounds.right) / 2, width, self.width());
351            text::draw(
352                &mut self.image,
353                (x, bounds.top - glyph_height - text_gap),
354                &self.axes.title,
355                self.render_scale,
356                Pixel::BLACK,
357            );
358        }
359
360        let xlabel_y = bounds.bottom + tick_length + text_gap + glyph_height + text_gap;
361        if !self.axes.xlabel.is_empty() && bottom_margin >= xlabel_y - bounds.bottom + glyph_height
362        {
363            let (width, _) = text::measure(&self.axes.xlabel, self.render_scale);
364            let x = centered_start((bounds.left + bounds.right) / 2, width, self.width());
365            text::draw(
366                &mut self.image,
367                (x, xlabel_y),
368                &self.axes.xlabel,
369                self.render_scale,
370                Pixel::BLACK,
371            );
372        }
373
374        let max_tick_width = self
375            .axes
376            .yticks
377            .iter()
378            .map(|&tick| text::measure(&format_tick(tick), self.render_scale).0)
379            .max()
380            .unwrap_or(0);
381        let required_left = tick_length + text_gap + max_tick_width + text_gap + glyph_height;
382        if !self.axes.ylabel.is_empty() && bounds.left >= required_left {
383            let (rotated_height, _) = text::measure(&self.axes.ylabel, self.render_scale);
384            let x = bounds.left - required_left;
385            let y = centered_start(
386                (bounds.top + bounds.bottom) / 2,
387                rotated_height,
388                self.height(),
389            );
390            text::draw_rotated_counterclockwise(
391                &mut self.image,
392                (x, y),
393                &self.axes.ylabel,
394                self.render_scale,
395                Pixel::BLACK,
396            );
397        }
398    }
399}
400
401fn map_x(value: f64, limits: (f64, f64), bounds: Bounds) -> Option<usize> {
402    map_value(value, limits, bounds.width()).map(|pixel| bounds.left + pixel)
403}
404
405fn map_y(value: f64, limits: (f64, f64), bounds: Bounds) -> Option<usize> {
406    map_value(value, limits, bounds.height()).map(|pixel| bounds.bottom - pixel)
407}
408
409fn map_value(value: f64, limits: (f64, f64), size: usize) -> Option<usize> {
410    if limits.1 <= limits.0 || size == 0 || value < limits.0 || value > limits.1 {
411        return None;
412    }
413    let scaled = (value - limits.0) / (limits.1 - limits.0);
414    Some((scaled * (size - 1) as f64).round() as usize)
415}
416
417fn contains_zero(limits: (f64, f64)) -> bool {
418    limits.0 <= 0.0 && 0.0 <= limits.1
419}
420
421fn centered_start(center: usize, length: usize, extent: usize) -> usize {
422    center
423        .saturating_sub(length / 2)
424        .min(extent.saturating_sub(length))
425}
426
427fn format_tick(value: f64) -> String {
428    if value == 0.0 {
429        return "0".to_owned();
430    }
431
432    let magnitude = value.abs();
433    if !(0.001..10_000.0).contains(&magnitude) {
434        return format!("{value:.1e}");
435    }
436
437    format!("{value:.2}")
438        .trim_end_matches('0')
439        .trim_end_matches('.')
440        .to_owned()
441}
442
443fn data_limits<'a>(
444    points: impl Iterator<Item = &'a (f64, f64)>,
445) -> Option<((f64, f64), (f64, f64))> {
446    let mut limits: Option<((f64, f64), (f64, f64))> = None;
447
448    for &(x, y) in points.filter(|(x, y)| x.is_finite() && y.is_finite()) {
449        limits = Some(match limits {
450            None => ((x, x), (y, y)),
451            Some(((xmin, xmax), (ymin, ymax))) => {
452                ((xmin.min(x), xmax.max(x)), (ymin.min(y), ymax.max(y)))
453            }
454        });
455    }
456
457    limits.map(|(xlim, ylim)| (expand_degenerate(xlim), expand_degenerate(ylim)))
458}
459
460fn expand_degenerate(limits: (f64, f64)) -> (f64, f64) {
461    if limits.0 != limits.1 {
462        return limits;
463    }
464
465    let padding = (limits.0.abs() * 0.05).max(1.0);
466    (limits.0 - padding, limits.1 + padding)
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn coordinates_start_at_bottom_left() {
475        let bounds = Bounds::from_insets(7, 7, 0, 0).unwrap();
476        assert_eq!(map_x(-2.0, (-2.0, 4.0), bounds), Some(0));
477        assert_eq!(map_x(4.0, (-2.0, 4.0), bounds), Some(6));
478        assert_eq!(map_y(-2.0, (-2.0, 16.0), bounds), Some(6));
479        assert_eq!(map_y(16.0, (-2.0, 16.0), bounds), Some(0));
480    }
481
482    #[test]
483    fn canvas_respects_horizontal_and_vertical_insets() {
484        let mut canvas =
485            Canvas::with_inset(11, 11, Axes::from_limits((0.0, 1.0), (0.0, 1.0)), 2, 3);
486        canvas.render();
487
488        assert_eq!(canvas.image.get_pixel(0, 0), Some(&Pixel::WHITE));
489        assert_eq!(canvas.image.get_pixel(2, 3), Some(&Pixel::BLACK));
490        assert_eq!(canvas.image.get_pixel(8, 7), Some(&Pixel::BLACK));
491    }
492
493    #[test]
494    fn axes_render_origin() {
495        let mut canvas = Canvas::new(7, 7, Axes::from_limits((0.0, 2.0), (0.0, 2.0)));
496        canvas.render();
497
498        assert_eq!(canvas.image.get_pixel(0, 3), Some(&Pixel::BLACK));
499        assert_eq!(canvas.image.get_pixel(3, 6), Some(&Pixel::BLACK));
500    }
501
502    #[test]
503    fn labels_and_tick_values_render_inside_margins() {
504        let axes = Axes::from_limits((0.0, 1.0), (0.0, 1.0))
505            .with_labels("time", "value")
506            .with_title("demo");
507        let mut canvas = Canvas::with_inset(128, 128, axes, 40, 32);
508        canvas.render();
509
510        let width = canvas.width();
511        let margin_has_text = canvas
512            .image
513            .pixels()
514            .iter()
515            .enumerate()
516            .any(|(index, pixel)| {
517                let x = index % width;
518                let y = index / width;
519                *pixel == Pixel::BLACK && (x < 36 || !(28..=100).contains(&y))
520            });
521        assert!(margin_has_text);
522    }
523
524    #[test]
525    fn tiny_canvas_does_not_panic_while_laying_out_ticks() {
526        let mut canvas =
527            Canvas::with_inset(64, 1, Axes::from_limits((0.0, 1.0), (0.0, 1.0)), 20, 0);
528
529        canvas.render();
530        assert_eq!(canvas.height(), 1);
531    }
532
533    #[test]
534    fn axes_automatically_fit_all_added_series() {
535        let mut canvas = Canvas::new(11, 11, Axes::new());
536        canvas.render();
537        canvas.scatter(&[(0.0, 0.0)], 1);
538        canvas.scatter(&[(3.0, 4.0)], 1);
539
540        assert_eq!(canvas.axes.xlim, (0.0, 3.0));
541        assert_eq!(canvas.axes.ylim, (0.0, 4.0));
542        assert_eq!(canvas.image.get_pixel(0, 10), Some(&Pixel::rgb(255, 0, 0)));
543        assert_eq!(canvas.image.get_pixel(10, 0), Some(&Pixel::rgb(255, 0, 0)));
544    }
545
546    #[test]
547    fn manual_limits_override_automatic_limits_per_axis() {
548        let axes = Axes::new().with_xlim((-10.0, 10.0));
549        let mut canvas = Canvas::new(11, 11, axes);
550        canvas.scatter(&[(2.0, 3.0), (4.0, 7.0)], 1);
551
552        assert_eq!(canvas.axes.xlim, (-10.0, 10.0));
553        assert_eq!(canvas.axes.ylim, (3.0, 7.0));
554    }
555}