colorimetry_plot/chart/chromaticity/
xy.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright (c) 2025, Harbers Bik LLC
3
4//! # Chromaticity XY Plot Module
5//!
6//! This module provides the [`XYChromaticity`] struct and related functionality for plotting chromaticity diagrams
7//! in the CIE xy color space. It enables visualization of color gamuts, spectral loci, Planckian loci, and
8//! white points, as well as the embedding of RGB gamut images within chromaticity plots.
9//!
10//! ## Features
11//! - Plot CIE xy chromaticity diagrams for any observer
12//! - Visualize spectral locus, Planckian locus, and standard illuminants
13//! - Annotate white points and color gamuts
14//! - Embed RGB gamut images as raster overlays
15//! - Full access to all [`XYChart`] methods via delegation
16//!
17//! This module is intended for scientific visualization and color science applications.
18
19mod gamut;
20use gamut::PngImageData;
21
22use std::ops::RangeBounds;
23
24use crate::{
25    chart::{ScaleRangeWithStep, XYChart},
26    delegate_xy_chart_methods,
27    rendable::Rendable,
28    StyleAttr,
29};
30use colorimetry::{
31    illuminant::{CieIlluminant, CCT},
32    observer::Observer,
33    rgb::RgbSpace,
34    xyz::XYZ,
35};
36use svg::{
37    node::element::{path::Data, Group, Path, Text, SVG},
38    Node,
39};
40
41#[derive(Clone)]
42pub struct XYChromaticity {
43    pub(crate) observer: Observer,
44    pub(crate) xy_chart: XYChart,
45}
46
47delegate_xy_chart_methods!(XYChromaticity, xy_chart);
48
49impl XYChromaticity {
50    pub const ANNOTATE_SEP: u32 = 2;
51
52    pub fn new(
53        width_and_height: (u32, u32),
54        ranges: (impl RangeBounds<f64>, impl RangeBounds<f64>),
55    ) -> XYChromaticity {
56        let xy_chart = XYChart::new(width_and_height, ranges);
57        let observer = Observer::default();
58        XYChromaticity { observer, xy_chart }
59    }
60    pub fn set_observer(mut self, observer: Observer) -> Self {
61        self.observer = observer;
62        self
63    }
64
65    pub fn plot_spectral_locus(self, style_attr: Option<StyleAttr>) -> Self {
66        let obs = self.observer;
67        let locus = obs.spectral_locus();
68        self.plot_shape(locus, style_attr)
69    }
70
71    /// Plots spectral locus ticks perpendicular to the spectral locus for
72    /// a range for wavelengths, specficied by a start wavelength and an end wavelength,
73    /// in `usize` units of nanometer, and with a `step` in nanometers.
74    ///
75    /// The `length` parameter specifies the length of the tick lines in pixels.
76    /// A positive `length` will draw the ticks pointing inwards, towards the white point,
77    /// and negative `length` will draw the ticks pointing outwards, away from the white point.
78    ///
79    /// Typically you will use this method multiple times to draw ticks for different ranges,
80    /// and different lengths, with the finest ticks plotted first, and the coarsest ticks plotted last,
81    /// plotting over the fine lines.
82    ///
83    /// The `style_attr` parameter allows you to specify the style of the ticks,
84    /// such as stroke color, width, and other SVG style attributes.
85    pub fn plot_spectral_locus_ticks(
86        self,
87        range: impl RangeBounds<usize>,
88        step: usize,
89        length: usize,
90        style_attr: Option<StyleAttr>,
91    ) -> Self {
92        let length = length as f64;
93        let this = self;
94        let locus = this.observer.spectral_locus();
95        let to_plot = this.xy_chart.to_plot.clone();
96        let mut data = Data::new();
97        for (xy, angle) in locus.iter_range_with_slope(range, step) {
98            let pxy1 = to_plot(xy);
99            data = data.move_to(pxy1);
100            let pxy2 = (pxy1.0 + length * angle.sin(), pxy1.1 + length * angle.cos());
101            data = data.line_to(pxy2);
102        }
103        this.draw_data("plot", data, style_attr)
104    }
105
106    /// Plots spectral locus labels for the specified range of wavelengths,
107    /// with a step size in nanometers.
108    ///
109    /// The labels are rotated to align with the spectral locus slope at each point.
110    /// The `distance` parameter specifies the distance from the spectral locus to the label in pixels.
111    ///
112    /// The `style_attr` parameter allows you to specify the style of the labels,
113    pub fn plot_spectral_locus_labels(
114        self,
115        range: impl RangeBounds<usize> + Clone,
116        step: usize,
117        distance: usize,
118        style_attr: Option<StyleAttr>,
119    ) -> Self {
120        let d = distance as f64;
121        let mut self_as_mut = self;
122        let locus = self_as_mut.observer.spectral_locus();
123        let range_f64 = (
124            range.start_bound().map(|&x| x as f64),
125            range.end_bound().map(|&x| x as f64),
126        );
127        let values: ScaleRangeWithStep = (range_f64, step as f64).into();
128        let to_plot = self_as_mut.xy_chart.to_plot.clone();
129        let mut group = Group::new();
130        for ((xy, angle), v) in locus.iter_range_with_slope(range, step).zip(values.iter()) {
131            let pxy1 = to_plot(xy);
132            let pxy2 = (pxy1.0 - d * angle.sin(), pxy1.1 - d * angle.cos());
133            let label = format!("{v:.0}");
134            let rotation_angle = -angle.to_degrees();
135            let text = Text::new(label)
136                .set("x", pxy2.0)
137                .set("y", pxy2.1)
138                .set("text-anchor", "middle")
139                .set("dominant-baseline", "before-edge")
140                .set(
141                    "transform",
142                    format!("rotate({:.3} {:.3} {:.3})", rotation_angle, pxy2.0, pxy2.1),
143                );
144            group.append(text);
145        }
146        style_attr.unwrap_or_default().assign(&mut group);
147        self_as_mut
148            .xy_chart
149            .layers
150            .get_mut("plot")
151            .unwrap()
152            .append(group);
153        self_as_mut
154    }
155
156    pub fn plot_planckian_locus(self, style_attr: Option<StyleAttr>) -> Self {
157        let locus = self.observer.planckian_locus();
158        self.plot_poly_line(locus, style_attr)
159    }
160
161    /// Calculates the slope angle of the Planckian locus at a given CCT.
162    /// Returns a tuple containing the chromaticity coordinates (x, y) and the slope angle in degrees.
163    /// The slope angle is the angle of the tangent to the Planckian locus in the xy chromatity chart at the specified CCT,
164    /// and points left with increasing CCT.
165    /// Angles in SVG are measured clockwise from the positive x-axis.
166    ///
167    /// # Arguments
168    /// * `cct` - The correlated color temperature in Kelvin.
169    /// # Returns
170    /// A tuple containing the chromaticity coordinates (x, y) and the slope angle in degrees.
171    ///
172    /// # Example
173    /// ```
174    /// use colorimetry_plot::chart::XYChromaticity;
175    /// let xy_chromaticity = XYChromaticity::new((800, 600), (0.0..=0.75, 0.0..=0.875));
176    /// let (xy, angle) = xy_chromaticity.planckian_xy_slope_angle(2300.0);
177    /// approx::assert_abs_diff_eq!(angle.to_degrees(), -178.0, epsilon = 0.5);
178    /// let (xy, angle) = xy_chromaticity.planckian_xy_slope_angle(6500.0);
179    /// approx::assert_abs_diff_eq!(angle.to_degrees(), -136.0, epsilon = 0.5); // Check if the angle is a finite number
180    /// ```
181    pub fn planckian_xy_slope_angle(&self, cct: f64) -> ((f64, f64), f64) {
182        // Get the XYZ coordinates and their derivatives at the given CCT
183        let xyz = self.observer.xyz_planckian_locus(cct);
184        let [x, y, z] = xyz.values();
185        let nom = x + y + z;
186        let [dxdt, dydt, dzdt] = self.observer.xyz_planckian_locus_slope(cct).values();
187        let dnom_dt = dxdt + dydt + dzdt;
188
189        // convert XYZ derivatives to xy derivatives using the quotient rule
190        // omit the division by nom^2, since we are only interested in the angle
191        let dx_chromaticity = dxdt * nom - x * dnom_dt; //  /(nom * nom);
192        let dy_chromaticity = dydt * nom - y * dnom_dt; // / (nom * nom);
193        let angle = dy_chromaticity.atan2(dx_chromaticity);
194        (xyz.chromaticity().to_tuple(), angle)
195    }
196
197    /// The iso-temperature lines are defined as the lines that are perpendicular to the slope of
198    /// the Planckian locus at a given CCT in the CIE 1960 (u,v) chromaticity diagram, using
199    /// the CIE 1931 standard observer.
200    /// # References
201    /// - CIE 015:2018, "Colorimetry, 4th Edition", Section 9.4
202    pub fn planckian_uvp_normal_angle(&self, cct: f64) -> ((f64, f64), f64) {
203        // Get the XYZ coordinates and their derivatives at the given CCT
204        let cct_observer = colorimetry::observer::Observer::Cie1931;
205        let xyz = cct_observer.xyz_planckian_locus(cct);
206        let [x, y, z] = xyz.values();
207        let sigma_t = x + 15.0 * y + 3.0 * z;
208        let [dxdt, dydt, dzdt] = cct_observer.xyz_planckian_locus_slope(cct).values();
209        let dsigma_dt = dxdt + 15.0 * dydt + 3.0 * dzdt;
210
211        // convert XYZ derivatives to xy derivatives using the quotient rule
212        // omit the division by sigma_t^2, since we are only interested in the angle
213        let du_dt = 4.0 * dxdt * sigma_t - 4.0 * x * dsigma_dt; // / (sigma_t * sigma_t)
214        let dv_dt = 9.0 * dydt * sigma_t - 9.0 * y * dsigma_dt; // / (sigma_t * sigma_t
215        let angle = dv_dt.atan2(du_dt);
216        (
217            xyz.chromaticity().to_tuple(),
218            angle + std::f64::consts::FRAC_PI_2,
219        )
220    }
221
222    /// Transform a (CCT, duv) pair to a plot point using the CIE 1931 observer.
223    ///
224    /// # Arguments
225    /// * `cct_duv` - A tuple containing the correlated color temperature (CCT) in Kelvin and the chromaticity deviation from the Planckian locus (duv).
226    ///
227    /// # Returns
228    /// A Result containing a tuple of the transformed chromaticity coordinates (x, y) in the plot space.
229    /// # Errors
230    /// Returns an error if the CCT is invalid or cannot be converted to XYZ coordinates.
231    pub fn cct_transform(&self, cct_duv: (f64, f64)) -> Result<(f64, f64), colorimetry::Error> {
232        let (cct, duv) = cct_duv;
233        let xyz: XYZ = CCT::new(cct, duv)?.try_into()?;
234        let xy = xyz.chromaticity().to_tuple();
235        Ok((self.xy_chart.to_plot)(xy))
236    }
237
238    /// Plots the ANSI C78.377-2017 step 7 Quadrangles in the plot,
239    /// filled with the color corresponding to the CCT and duv target.
240    pub fn plot_ansi_step7(mut self, rgb_space: RgbSpace, style_attr: Option<StyleAttr>) -> Self {
241        // ANSI C78.377-2017, Table 1, Basic Nominal CCT Specification.
242        const DATA: &[(&str, (i32, i32), f64)] = &[
243            ("2200", (2238, 102), 0.0000),
244            ("2500", (2460, 120), 0.0000),
245            ("2700", (2725, 145), 0.0000),
246            ("3000", (3045, 175), 0.0001),
247            ("3500", (3465, 245), 0.0005),
248            ("4000", (3985, 275), 0.0010),
249            ("4500", (4503, 243), 0.0015),
250            ("5000", (5029, 283), 0.0020),
251            ("5700", (5667, 355), 0.0025),
252            ("6500", (6532, 510), 0.0031),
253        ];
254
255        let duv = |cct: i32| {
256            if cct < 2780 {
257                0.0
258            } else {
259                let r = 1.0 / cct as f64;
260                57_700.0 * r * r - 44.6 * r + 0.00854
261            }
262        };
263        const DUV_TOLERANCE: f64 = 0.006; // tolerance for duv
264
265        let mut ansi = Group::new();
266        style_attr.unwrap_or_default().assign(&mut ansi);
267        for &(_, (cct, tol), duv_target) in DATA {
268            let mut data = Data::new();
269
270            let (px0, py0) = self
271                .cct_transform(((cct - tol) as f64, duv(cct - tol) - DUV_TOLERANCE))
272                .unwrap();
273            let (px1, py1) = self
274                .cct_transform(((cct - tol) as f64, duv(cct - tol) + DUV_TOLERANCE))
275                .unwrap();
276            let (px2, py2) = self
277                .cct_transform(((cct + tol) as f64, duv(cct + tol) + DUV_TOLERANCE))
278                .unwrap();
279            let (px3, py3) = self
280                .cct_transform(((cct + tol) as f64, duv(cct + tol) - DUV_TOLERANCE))
281                .unwrap();
282
283            data = data
284                .move_to((px0, py0))
285                .line_to((px1, py1))
286                .line_to((px2, py2))
287                .line_to((px3, py3))
288                .close();
289            let xyz: XYZ = CCT::new(cct as f64, duv_target)
290                .unwrap()
291                .try_into()
292                .unwrap();
293            let [r, g, b]: [u8; 3] = xyz.rgb(rgb_space).compress().into();
294            let path = Path::new()
295                .set("d", data.clone())
296                .set("style", format!("fill: rgb({r:.0}, {g:.0}, {b:.0})"));
297            ansi.append(path);
298        }
299        self.xy_chart.layers.get_mut("plot").unwrap().append(ansi);
300        self
301    }
302
303    /// Get the normal to the Planckian locus, which is perpendicular to the slope at the given CCT.
304    /// Returns a tuple containing the chromaticity coordinates (x, y) and the normal angle in radians.
305    pub fn planckian_xy_normal_angle(&self, cct: f64) -> ((f64, f64), f64) {
306        let (xy, slope_angle) = self.planckian_xy_slope_angle(cct);
307        (xy, slope_angle + std::f64::consts::FRAC_PI_2)
308    }
309
310    /// Plots the Planckian locus ticks at specified CCT values.
311    /// Typically, you will use this method several times to create a nice looking scale,
312    /// with different ranges and lengths.
313    /// The scale if very non-linear, and requires small steps for the lower CCT values,
314    /// and larger steps for the higher CCT values.
315    pub fn plot_planckian_locus_ticks(
316        self,
317        values: impl IntoIterator<Item = u32>,
318        length: usize,
319        style_attr: Option<StyleAttr>,
320    ) -> Self {
321        let mut data = Data::new();
322        let to_plot = self.xy_chart.to_plot.clone();
323        for cct in values {
324            let (xy, angle) = self.planckian_xy_normal_angle(cct as f64);
325            let (px, py) = to_plot(xy);
326            let pdx = length as f64 * angle.cos();
327            let pdy = length as f64 * angle.sin();
328            data = data
329                .move_to((px - pdx, py + pdy)) // top in plot
330                .line_to((px + pdx, py - pdy)); // bottom
331        }
332        self.draw_data("plot", data, style_attr)
333    }
334
335    /// Plots labels for the Planckian locus at specified CCT values, divided by 100.
336    /// The labels are rotated to align with the normal to the the Planckian locus at each point,
337    /// centered away from the white point.
338    pub fn plot_planckian_locus_labels(
339        mut self,
340        values: impl IntoIterator<Item = u32>,
341        distance: usize,
342        style_attr: Option<StyleAttr>,
343    ) -> Self {
344        let mut planckian_labels = Group::new();
345        let to_plot = self.xy_chart.to_plot.clone();
346        for cct in values {
347            let (xy, angle) = self.planckian_xy_normal_angle(cct as f64);
348            let (px, py) = to_plot(xy);
349            let pdx = distance as f64 * angle.cos();
350            let pdy = distance as f64 * angle.sin();
351
352            let px2 = px - pdx;
353            let py2 = py + pdy;
354            let text = Text::new(format!("{}", cct / 100))
355                .set("x", px2)
356                .set("y", py2)
357                .set("text-anchor", "end")
358                .set("dominant-baseline", "middle")
359                .set(
360                    "transform",
361                    format!("rotate({:.3} {px2:.3} {py2:.3}) ", -angle.to_degrees()),
362                );
363            planckian_labels.append(text);
364        }
365        style_attr.unwrap_or_default().assign(&mut planckian_labels);
366        self.xy_chart
367            .layers
368            .get_mut("plot")
369            .unwrap()
370            .append(planckian_labels);
371        self
372    }
373
374    /// Plots the sRGB gamut as an embeded image overlay in the plot layer.
375    /// At this point, only correct colors are shown in case the RGB space is sRGB.
376    /// For other color spaces, the colors are clipped to the sRGB gamut.
377    pub fn plot_rgb_gamut(self, rgb_space: RgbSpace, style_attr: Option<StyleAttr>) -> Self {
378        // **TODO**
379        // Include the color profiles in the embeded image data, for requested colors space.  This will allow
380        // the colors to be displayed correctly for displays that have a wide enough gamut, and
381        // browser which support SVG embeded PNG images with color profiles (such as recent versions
382        // of Chrome, Firefox, and Safari).
383        let gamut_fill = PngImageData::from_rgb_space(
384            self.observer,
385            rgb_space,
386            self.xy_chart.to_plot.clone(),
387            self.xy_chart.to_world.clone(),
388        );
389        self.plot_image(gamut_fill, style_attr)
390    }
391
392    /// Draw white points on the chromaticity diagram as an iterator of CieIlluminant, and i32 angle and length pairs.
393    #[allow(unused)]
394    pub fn annotate_white_points(
395        &mut self,
396        point: impl IntoIterator<Item = (CieIlluminant, (i32, i32))>,
397    ) -> &mut Self {
398        todo!()
399    }
400}
401
402/// Implements the XYChromaticity as a Rendable object, allowing it to be rendered as an SVG.
403impl Rendable for XYChromaticity {
404    fn render(&self) -> SVG {
405        self.xy_chart.render()
406    }
407
408    fn view_parameters(&self) -> crate::view::ViewParameters {
409        self.xy_chart.view_parameters()
410    }
411
412    fn set_view_parameters(&mut self, view_box: crate::view::ViewParameters) {
413        self.xy_chart.set_view_parameters(view_box);
414    }
415}