colorimetry_plot/
chart.rs

1// Copyright (c) 2025, Harbers Bik LLC
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3//
4//! A two-dimensional chart implementation for plotting data in a Cartesian coordinate system.
5//!
6//! `XYChart` provides a flexible and extensible charting system that supports:
7//! - X and Y axes with customizable ranges
8//! - Tick marks and labels on both axes
9//! - Grid lines for visual reference
10//! - Multiple rendering layers (axes, plot, annotations)
11//! - Coordinate transformations between world and plot coordinates
12//! - Various plot elements like lines, shapes, images, and annotations
13//! - Automatic margin management and view box calculations
14//! - SVG rendering with clipping support
15//!
16//! # Layers
17//! The chart uses three main layers:
18//! - **axes**: Contains axis elements, labels, and descriptions (unclipped)
19//! - **plot**: Contains the main plot data (clipped to plot area)
20//! - **annotations**: Contains annotations and labels (unclipped, top layer)
21//!
22//! # Coordinate Systems
23//! - **World coordinates**: The actual data coordinate system defined by x_range and y_range
24//! - **Plot coordinates**: SVG coordinate system with (0,0) at top-left of plot area
25mod delegate;
26
27mod chromaticity;
28pub use chromaticity::XYChromaticity;
29
30mod range;
31pub use range::{ScaleRange, ScaleRangeIterator, ScaleRangeWithStep, ScaleValue};
32
33use std::{collections::HashMap, ops::RangeBounds, rc::Rc};
34
35use svg::{
36    node::element::{path::Data, ClipPath, Definitions, Group, Image, Line, Path, Text, SVG},
37    Node,
38};
39
40use crate::{
41    layer::Layer, new_id, rendable::Rendable, round_to_default_precision, view::ViewParameters,
42    StyleAttr,
43};
44
45pub type CoordinateTransform = Rc<dyn Fn((f64, f64)) -> (f64, f64)>;
46
47/// An XYChart is a two-dimensional chart that can plot data
48/// in a Cartesian coordinate system, with x and y axes.
49/// It supports various features such as
50/// adding ticks, labels, grid lines, and annotations.
51/// It can also render images and shapes on the plot area.
52/// It is designed to be flexible and extensible,
53/// allowing users to customize the appearance and behavior of the chart.
54#[derive(Clone)]
55pub struct XYChart {
56    // inputs
57    pub id: Option<String>,  // unique id for the chart
58    pub x_range: ScaleRange, // min, max for x axis
59    pub y_range: ScaleRange, // min, max for y axis
60    pub plot_width: u32,     // width and height of the true plot area,
61    pub plot_height: u32,    // excluding axis and margins
62
63    /// Transformation from world/data coordinates to plot (SVG) coordinates.
64    pub to_plot: CoordinateTransform,
65
66    /// Transformation from plot (SVG) coordinates to world/data coordinates.
67    pub to_world: CoordinateTransform,
68
69    /// Parameters controlling the SVG view box and viewport.
70    pub view_parameters: ViewParameters,
71
72    /// Layers for rendering chart elements (axes, grid, data, etc.).
73    pub layers: HashMap<&'static str, Layer>,
74
75    /// Clip paths used for masking chart elements.
76    pub clip_paths: Vec<ClipPath>,
77
78    /// Margins around the plot area: [top, right, bottom, left].
79    pub margins: [i32; 4],
80}
81
82impl XYChart {
83    /// Separation between annotation and the annotated element, in pixels.
84    pub const ANNOTATE_SEP: i32 = 2;
85
86    /// Height of axis labels, in pixels.
87    pub const LABEL_HEIGHT: i32 = 16;
88
89    /// Height of axis descriptions, in pixels.
90    pub const DESCRIPTION_HEIGHT: i32 = 20;
91
92    /// Separation between axis description and axis, in pixels.
93    pub const DESCRIPTION_SEP: i32 = 20;
94
95    /// Offset for axis descriptions, in pixels.
96    pub const DESCRIPTION_OFFSET: i32 = Self::LABEL_HEIGHT + Self::DESCRIPTION_SEP;
97
98    /// Creates a new `XYChart` with the specified plot size and axis ranges.
99    ///
100    /// # Arguments
101    /// * `plot_width_and_height` - Tuple of plot width and height in pixels.
102    /// * `ranges` - Tuple of x and y axis ranges.
103    pub fn new(
104        plot_width_and_height: (u32, u32),
105        ranges: (impl RangeBounds<f64>, impl RangeBounds<f64>),
106    ) -> XYChart {
107        let (plot_width, plot_height) = plot_width_and_height;
108        let (x_range, y_range) = (ScaleRange::new(ranges.0), ScaleRange::new(ranges.1));
109
110        // output coordinates with 0.1px precision
111        let to_plot = Rc::new(move |xy: (f64, f64)| {
112            let w = plot_width as f64;
113            let h = plot_height as f64;
114            world_to_plot_coordinates(xy.0, xy.1, &x_range, &y_range, w, h)
115        });
116
117        let to_world = Rc::new(move |xy: (f64, f64)| {
118            let w = plot_width as f64;
119            let h = plot_height as f64;
120            plot_to_world_coordinates(xy.0, xy.1, &x_range, &y_range, w, h)
121        });
122
123        // plot area path, which is the rectangle of the plot area
124        let path = to_path(
125            [
126                (0f64, 0f64),
127                (plot_width as f64, 0f64),
128                (plot_width as f64, plot_height as f64),
129                (0f64, plot_height as f64),
130            ],
131            true,
132        );
133
134        // create the layers
135        let mut layers = HashMap::new();
136
137        // use first the path to get an unclipped rectangle on the axes layer.
138        // can not use plot area path, because it is clipped, which clips the border.
139        let mut axes_layer = Layer::new();
140        axes_layer.assign("class", "axes");
141
142        let plot_area = path.clone().set("class", "plot-area");
143        axes_layer.append(plot_area);
144        layers.insert("axes", axes_layer);
145
146        // set up the clip path for the plot area
147        let mut clip_paths = Vec::new();
148
149        // use unique id for the clip path
150        let clip_id = new_id();
151        clip_paths.push(
152            ClipPath::new()
153                .set("id", format!("clip-{clip_id}"))
154                .add(path.clone()),
155        );
156
157        // create the clipped plot layer
158        // everything drawn on the plot layer will be clipped to this path.
159        let mut plot_layer = Layer::new();
160        plot_layer.assign("clip-path", format!("url(#clip-{clip_id})"));
161        // plot_layer.append(path);
162
163        layers.insert("plot", plot_layer);
164
165        let mut annotations_layer = Layer::new();
166        annotations_layer.assign("class", "annotations");
167        layers.insert("annotations", annotations_layer);
168
169        let view_box = ViewParameters::new(0, 0, plot_width, plot_height, plot_width, plot_height);
170        XYChart {
171            id: None,
172            view_parameters: view_box,
173            plot_height,
174            plot_width,
175            x_range,
176            y_range,
177            layers,
178            clip_paths,
179            margins: [0i32; 4], // top, right, bottom, left
180            to_plot,
181            to_world,
182        }
183    }
184
185    pub fn set_id(mut self, id: impl Into<String>) -> Self {
186        self.id = Some(id.into());
187        self
188    }
189
190    /// Adds tick marks to both axes of the chart.
191    ///
192    /// Tick marks are drawn at regular intervals along the x and y axes, with the specified length and style.
193    /// The method automatically updates the chart margins if the tick length exceeds the current margin.
194    ///
195    /// # Arguments
196    /// * `x_step` - Interval between tick marks on the x-axis.
197    /// * `y_step` - Interval between tick marks on the y-axis.
198    /// * `length` - Length of each tick mark in pixels.
199    /// * `style_attr` - Style attributes for the tick marks.
200    ///
201    /// # Returns
202    /// Returns the updated chart with tick marks added.
203    pub fn ticks(
204        mut self,
205        x_step: f64,
206        y_step: f64,
207        length: i32,
208        style_attr: Option<StyleAttr>,
209    ) -> Self {
210        let mut data = Data::new();
211        let to_plot = self.to_plot.clone();
212        for x in self.x_range.iter_with_step(x_step) {
213            let (px, py) = to_plot((x, self.y_range.start));
214            data = data.move_to((px, py)).line_to((px, py + length as f64));
215
216            let (px, py) = to_plot((x, self.y_range.end));
217            data = data.move_to((px, py)).line_to((px, py - length as f64));
218        }
219        for y in self.y_range.iter_with_step(y_step) {
220            let (px, py) = to_plot((self.x_range.start, y));
221            data = data.move_to((px, py)).line_to((px - length as f64, py));
222
223            let (px, py) = to_plot((self.x_range.end, y));
224            data = data.move_to((px, py)).line_to((px + length as f64, py));
225        }
226        // Extend view coordinates if required
227        if length > 0 {
228            self.margins.iter_mut().for_each(|v| {
229                if *v < length {
230                    *v = length;
231                }
232            });
233            self.update_view();
234        }
235        self.draw_data("axes", data, style_attr)
236    }
237
238    /// Adds x-axis labels to the axes layer of the chart.
239    ///
240    /// The labels are positioned along the x-axis at intervals specified by `step`.
241    /// The `offset` parameter controls the vertical distance from the axis to each label,
242    /// which is useful for preventing overlap with the axis or other elements.
243    ///
244    /// # Arguments
245    /// * `step` - The interval between labels along the x-axis.
246    /// * `offset` - The offset from the axis to the label, in pixels.
247    pub fn x_labels(mut self, step: f64, offset: usize, style_attr: Option<StyleAttr>) -> Self {
248        let range_with_step = ScaleRangeWithStep::new(self.x_range, step);
249        let y = self.y_range.start;
250        let to_plot = self.to_plot.clone();
251        let mut x_labels = Group::new();
252        for x in range_with_step.iter() {
253            let display_value = format!("{}", ScaleValue(x, step));
254            let (px, py) = to_plot((x, y));
255            let txt = Text::new(display_value)
256                .set("x", px)
257                .set("y", py + offset as f64)
258                .set("text-anchor", "middle")
259                .set("dominant-baseline", "text-before-edge");
260            x_labels.append(txt);
261        }
262        style_attr.unwrap_or_default().assign(&mut x_labels);
263        self.layers.get_mut("axes").unwrap().append(x_labels);
264        self.margins[2] = self.margins[2].max(Self::LABEL_HEIGHT + offset as i32);
265        self.update_view();
266        self
267    }
268
269    /// Adds y-axis labels to the axes layer of the chart.
270    ///
271    /// The labels are rotated 90 degrees counter-clockwise and positioned to the left of the axis.
272    /// The `offset` parameter controls the distance from the axis to each label, which is useful for long labels that might otherwise overlap the axis.
273    ///
274    /// # Arguments
275    /// * `step` - The interval between labels.
276    /// * `offset` - The offset from the axis to the label, in pixels.
277    pub fn y_labels(mut self, step: f64, offset: usize, style_attr: Option<StyleAttr>) -> Self {
278        let range_with_step = ScaleRangeWithStep::new(self.y_range, step);
279        let x = self.x_range.start;
280        let to_plot = self.to_plot.clone();
281        let mut y_labels = Group::new();
282        for y in range_with_step.iter() {
283            let display_value = format!("{}", ScaleValue(y, step));
284            let (px, py) = to_plot((x, y));
285            let txt = Text::new(display_value)
286                .set("x", px - offset as f64)
287                .set("y", py)
288                .set("text-anchor", "middle")
289                .set("dominant-baseline", "text-after-edge")
290                .set(
291                    "transform",
292                    format!("rotate(-90, {}, {})", px - offset as f64, py),
293                );
294            y_labels.append(txt);
295        }
296        style_attr.unwrap_or_default().assign(&mut y_labels);
297        self.layers.get_mut("axes").unwrap().append(y_labels);
298        self.margins[3] = self.margins[3].max(Self::LABEL_HEIGHT + offset as i32);
299        self.update_view();
300        self
301    }
302
303    /// Add an x-axis description, positioned below the axis, onto the axes layer.
304    pub fn x_axis_description(mut self, description: &str, style_attr: Option<StyleAttr>) -> Self {
305        let x_middle = (self.x_range.start + self.x_range.end) / 2.0;
306        let y = self.y_range.start;
307        let (px, py) = (self.to_plot)((x_middle, y));
308        let mut text = Text::new(description)
309            .set("x", px)
310            .set("y", py + Self::DESCRIPTION_OFFSET as f64)
311            .set("text-anchor", "middle")
312            .set("dominant-baseline", "text-before-edge");
313        style_attr.unwrap_or_default().assign(&mut text);
314        self.layers.get_mut("axes").unwrap().append(text);
315        self.margins[0] = self.margins[0].max(Self::DESCRIPTION_HEIGHT + Self::DESCRIPTION_OFFSET);
316        self.update_view();
317        self
318    }
319
320    /// Add a y-axis description, positioned to the left of the axis, onto the axes layer.
321    /// The description is centered vertically along the y-axis.
322    pub fn y_axis_description(mut self, description: &str, style_attr: Option<StyleAttr>) -> Self {
323        let y_middle = (self.y_range.start + self.y_range.end) / 2.0;
324        let x = self.x_range.start;
325        let (px, py) = (self.to_plot)((x, y_middle));
326        let mut text = Text::new(description)
327            .set("x", px - Self::DESCRIPTION_OFFSET as f64)
328            .set("y", py)
329            .set("text-anchor", "middle")
330            .set("dominant-baseline", "text-after-edge")
331            .set(
332                "transform",
333                format!(
334                    "rotate(-90, {}, {})",
335                    px - Self::DESCRIPTION_OFFSET as f64,
336                    py
337                ),
338            );
339        style_attr.unwrap_or_default().assign(&mut text);
340        self.layers.get_mut("axes").unwrap().append(text);
341        self.margins[3] = self.margins[3].max(Self::DESCRIPTION_HEIGHT + Self::DESCRIPTION_OFFSET);
342        self.update_view();
343        self
344    }
345
346    /// Draw a grid on the plot area, using the specified step sizes for x and y axes.
347    /// The grid lines are drawn as paths on the plot layer.
348    /// The grid can be placed before or after other object on the plot layer, by the order of the method calls.
349    pub fn plot_grid(self, x_step: f64, y_step: f64, style_attr: Option<StyleAttr>) -> Self {
350        let mut data = Data::new();
351        let on_canvas = self.to_plot.clone();
352        for x in self.x_range.iter_with_step(x_step) {
353            data = data
354                .move_to(on_canvas((x, self.y_range.start)))
355                .line_to(on_canvas((x, self.y_range.end)));
356        }
357        for y in self.y_range.iter_with_step(y_step) {
358            data = data
359                .move_to(on_canvas((self.x_range.start, y)))
360                .line_to(on_canvas((self.x_range.end, y)));
361        }
362        self.draw_data("plot", data, style_attr)
363    }
364
365    /// Plot an image onto the plot layer.
366    /// The image will be scaled to fit the plot area.
367    ///
368    /// The image is clipped to the plot area, so it will not extend beyond the plot boundaries.
369    ///
370    /// # Arguments
371    /// * `image` - The image to plot, which can be any type that implements `Into<Image>`.
372    /// * `style_attr` - Style attributes to apply to the image, such as width, height, and class.
373    pub fn plot_image(mut self, image: impl Into<Image>, style_attr: Option<StyleAttr>) -> Self {
374        let mut image: Image = image.into();
375        style_attr.unwrap_or_default().assign(&mut image);
376        self.layers.get_mut("plot").unwrap().append(image);
377        self
378    }
379
380    /// Annotate a point on the chart with a circle, a line, and text, using the annotations layer,
381    /// which is on top of all the other layers, and is unconstrained by and clip paths.
382    pub fn label_pin(
383        mut self,
384        cxy: (f64, f64),
385        r: f64,
386        angle_and_length: (i32, i32),
387        text: impl AsRef<str>,
388        style_attr: Option<StyleAttr>,
389    ) -> Self {
390        let (angle, len) = angle_and_length;
391        let angle = 360 - ((angle + 360) % 360);
392        let (cx, cy) = cxy;
393        let (cx, cy) = (self.to_plot)((cx, cy));
394        let dx = len as f64 * (angle as f64).to_radians().cos();
395        let dy = len as f64 * (angle as f64).to_radians().sin();
396        let circle = svg::node::element::Circle::new()
397            .set("cx", cx)
398            .set("cy", cy)
399            .set("r", r);
400        let line = Line::new()
401            .set("x1", cx)
402            .set("y1", cy)
403            .set("x2", cx + dx)
404            .set("y2", cy + dy);
405
406        let dxt = (len + Self::ANNOTATE_SEP) as f64 * (angle as f64).to_radians().cos();
407        let dyt = (len + Self::ANNOTATE_SEP) as f64 * (angle as f64).to_radians().sin();
408        let mut text = Text::new(text.as_ref())
409            .set("x", cx + dxt)
410            .set("y", cy + dyt);
411
412        match angle {
413            0..55 | 305..=360 => {
414                // easat
415                text.assign("text-anchor", "start");
416                text.assign("dominant-baseline", "middle");
417            }
418            55..125 => {
419                // north
420                text.assign("text-anchor", "middle");
421                text.assign("dominant-baseline", "text-before-edge");
422            }
423            125..235 => {
424                // west
425                text.assign("text-anchor", "end");
426                text.assign("dominant-baseline", "middle");
427            }
428            _ => {
429                // south
430                text.assign("text-anchor", "middle");
431                text.assign("dominant-baseline", "text-after-edge");
432            }
433        }
434
435        let mut group = Group::new().add(circle).add(line).add(text);
436        style_attr.unwrap_or_default().assign(&mut group);
437        self.layers.get_mut("annotations").unwrap().append(group);
438        self
439    }
440
441    /// Draw a Path onto the selected layer, without
442    /// using any scaling.
443    pub fn draw_path(mut self, layer: &str, mut path: Path, style_attr: Option<StyleAttr>) -> Self {
444        if let Some(layer) = self.layers.get_mut(layer) {
445            style_attr.unwrap_or_default().assign(&mut path);
446            layer.append(path);
447        } else {
448            panic!("unknown layer");
449        }
450        self
451    }
452
453    /// Add Data, composed of e.g. move_to and line_to operations, to a specified layer.
454    /// This is low level convenience method for drawing paths with data.
455    pub fn draw_data(self, layer: &str, data: Data, style_attr: Option<StyleAttr>) -> Self {
456        let path = Path::new().set("d", data);
457        self.draw_path(layer, path, style_attr)
458    }
459
460    /// Add a line to the chart, for a set of world coordinates, as specified by x and y ranges, from an iterator,
461    /// onto the plot layer.
462    pub fn plot_poly_line(
463        self,
464        data: impl IntoIterator<Item = (f64, f64)>,
465        style_attr: Option<StyleAttr>,
466    ) -> Self {
467        let on_canvas = self.to_plot.clone();
468        let iter_canvas = data.into_iter().map(|xy| (on_canvas)(xy));
469        self.draw_path("plot", to_path(iter_canvas, false), style_attr)
470    }
471
472    /// Plot a shape from a set of coordinates from an iterator.
473    /// It will close the path.
474    pub fn plot_shape(
475        self,
476        data: impl IntoIterator<Item = (f64, f64)>,
477        style_attr: Option<StyleAttr>,
478    ) -> Self {
479        let on_canvas = self.to_plot.clone();
480        let iter_canvas = data.into_iter().map(|xy| (on_canvas)(xy));
481        self.draw_path("plot", to_path(iter_canvas, true), style_attr)
482    }
483
484    /// Updates the chart's view parameters and recalculates the view box.
485    pub fn update_view(&mut self) {
486        let vx = -(self.margins[3]);
487        let vy = -(self.margins[0]);
488        // add margins[3] twice because of the left shift
489        let vw = self.plot_width + self.margins[1] as u32 + 2 * self.margins[3] as u32;
490        // add margins[0] twice because of the top shift
491        let vh = self.plot_height + 2 * self.margins[0] as u32 + self.margins[2] as u32;
492
493        self.view_parameters.set_view_box(vx, vy, vw, vh);
494        self.set_width(vw);
495        self.set_height(vh);
496    }
497}
498
499pub(super) fn to_path(data: impl IntoIterator<Item = (f64, f64)>, close: bool) -> Path {
500    let mut path_data = Data::new();
501    for xy in data {
502        if path_data.is_empty() {
503            path_data = path_data.move_to(xy);
504        } else {
505            path_data = path_data.line_to(xy);
506        }
507    }
508    if close {
509        path_data = path_data.close();
510    }
511    Path::new()
512        //  .set("id", id.to_string())
513        .set("d", path_data.clone())
514}
515
516/// Converts world/data coordinates to plot (SVG) coordinates.
517///
518/// # Arguments
519/// * `xy` - Tuple of (x, y) in world coordinates.
520///
521/// # Returns
522/// Tuple of (x, y) in plot coordinates.
523fn world_to_plot_coordinates(
524    x: f64,
525    y: f64,
526    x_range: &ScaleRange,
527    y_range: &ScaleRange,
528    w: f64,
529    h: f64,
530) -> (f64, f64) {
531    let x_canvas = x_range.scale(x) * w;
532    let y_canvas = h - (y_range.scale(y) * h);
533    (
534        round_to_default_precision(x_canvas),
535        round_to_default_precision(y_canvas),
536    )
537}
538
539/// Converts plot (SVG) coordinates to world/data coordinates.
540///
541/// # Arguments
542/// * `xy` - Tuple of (x, y) in plot coordinates.
543///
544/// # Returns
545/// Tuple of (x, y) in world coordinates.
546pub fn plot_to_world_coordinates(
547    x: f64,
548    y: f64,
549    x_range: &ScaleRange,
550    y_range: &ScaleRange,
551    w: f64,
552    h: f64,
553) -> (f64, f64) {
554    let x_world = x_range.unscale(x / w);
555    let y_world = y_range.unscale_descent(y / h);
556    (x_world, y_world)
557}
558#[test]
559fn test_plot_to_world_coordinates() {
560    use approx::assert_abs_diff_eq;
561    let x_range = ScaleRange::new(0.0..=1.0);
562    let y_range = ScaleRange::new(0.0..=1.0);
563    let (x, y) = plot_to_world_coordinates(100.0, 200.0, &x_range, &y_range, 400.0, 300.0);
564    assert_abs_diff_eq!(x, 0.25, epsilon = 1e-10);
565    assert_abs_diff_eq!(y, 1.0 / 3.0, epsilon = 1e-10);
566}
567
568impl From<XYChart> for SVG {
569    fn from(chart: XYChart) -> Self {
570        chart.render()
571    }
572}
573
574impl Rendable for XYChart {
575    // required parameters
576    fn view_parameters(&self) -> ViewParameters {
577        self.view_parameters.clone()
578    }
579
580    fn set_view_parameters(&mut self, view_box: ViewParameters) {
581        self.view_parameters = view_box;
582    }
583
584    fn render(&self) -> SVG {
585        let mut defs = Definitions::new();
586        for clip in self.clip_paths.iter() {
587            defs.append(clip.clone());
588        }
589
590        let mut svg = SVG::new();
591        svg = svg
592            .set("xmlns", "http://www.w3.org/2000/svg")
593            .set("xmlns:xlink", "http://www.w3.org/1999/xlink")
594            .set("version", "1.1")
595            .set("class", "chart"); // full chart area
596        if let Some(id) = &self.id {
597            svg = svg.set("id", id.as_str());
598        }
599
600        svg.set("width", self.width())
601            .set("height", self.height())
602            .set("viewBox", self.view_parameters().view_box_str())
603            .add(defs)
604            .add(self.layers.get("axes").unwrap().clone())
605            .add(self.layers.get("plot").unwrap().clone())
606            .add(self.layers.get("annotations").unwrap().clone())
607    }
608}