Skip to main content

ironlab_ir/
style.rs

1//! Colours, line styles and marker styles.
2
3use std::borrow::Cow;
4
5use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8use crate::error::IrError;
9
10/// An sRGB colour with straight (non-premultiplied) alpha.
11///
12/// Each component lies in the range 0 to 1. In JSON a colour is the string
13/// `#rrggbb` when it is opaque, or `#rrggbbaa` otherwise, so the stored precision
14/// is eight bits per component.
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct Color {
17    /// The red component, from 0 to 1.
18    pub r: f32,
19    /// The green component, from 0 to 1.
20    pub g: f32,
21    /// The blue component, from 0 to 1.
22    pub b: f32,
23    /// The opacity, from 0 (transparent) to 1 (opaque).
24    pub a: f32,
25}
26
27impl Color {
28    /// Opaque black.
29    pub const BLACK: Color = Color::rgb(0.0, 0.0, 0.0);
30    /// Opaque white.
31    pub const WHITE: Color = Color::rgb(1.0, 1.0, 1.0);
32
33    /// Creates an opaque colour from red, green and blue components between 0 and 1.
34    pub const fn rgb(r: f32, g: f32, b: f32) -> Self {
35        Self { r, g, b, a: 1.0 }
36    }
37
38    /// Creates a colour from red, green, blue and alpha components between 0 and 1.
39    pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
40        Self { r, g, b, a }
41    }
42
43    /// Parses a colour from `#rrggbb` or `#rrggbbaa`, accepting upper- or lower-case
44    /// hexadecimal digits.
45    ///
46    /// Each two-digit byte `n` becomes the component `n as f32 / 255.0`, so that a
47    /// parsed colour compares equal to the same colour constructed from `n / 255`.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`IrError::InvalidColor`] when the string has any other form.
52    pub fn from_hex(hex: &str) -> Result<Self, IrError> {
53        let invalid = || IrError::InvalidColor(hex.to_owned());
54        let digits = hex.strip_prefix('#').ok_or_else(invalid)?.as_bytes();
55        if !matches!(digits.len(), 6 | 8) || !digits.iter().all(u8::is_ascii_hexdigit) {
56            return Err(invalid());
57        }
58        let component = |i: usize| {
59            let byte = (hex_value(digits[i]) << 4) | hex_value(digits[i + 1]);
60            f32::from(byte) / 255.0
61        };
62        let a = if digits.len() == 8 { component(6) } else { 1.0 };
63        Ok(Self::rgba(component(0), component(2), component(4), a))
64    }
65
66    /// Formats the colour as lower-case `#rrggbb` when it is opaque, or as
67    /// `#rrggbbaa` otherwise, rounding each component to the nearest of 256 levels.
68    pub fn to_hex(&self) -> String {
69        let [r, g, b, a] = [self.r, self.g, self.b, self.a].map(level);
70        if a == u8::MAX {
71            format!("#{r:02x}{g:02x}{b:02x}")
72        } else {
73            format!("#{r:02x}{g:02x}{b:02x}{a:02x}")
74        }
75    }
76}
77
78/// Returns the value of an ASCII hexadecimal digit.
79fn hex_value(digit: u8) -> u8 {
80    match digit {
81        b'0'..=b'9' => digit - b'0',
82        b'a'..=b'f' => digit - b'a' + 10,
83        _ => digit - b'A' + 10,
84    }
85}
86
87/// Rounds a colour component to the nearest of 256 levels, clamping it to the range
88/// 0 to 1 first; a NaN component becomes level 0.
89fn level(component: f32) -> u8 {
90    // The clamped, rounded value lies in 0..=255 (or is NaN, which casts to 0).
91    (component.clamp(0.0, 1.0) * 255.0).round() as u8
92}
93
94impl Default for Color {
95    fn default() -> Self {
96        Self::BLACK
97    }
98}
99
100impl Serialize for Color {
101    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
102        serializer.serialize_str(&self.to_hex())
103    }
104}
105
106impl<'de> Deserialize<'de> for Color {
107    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
108        let hex = String::deserialize(deserializer)?;
109        Color::from_hex(&hex).map_err(serde::de::Error::custom)
110    }
111}
112
113impl JsonSchema for Color {
114    fn schema_name() -> Cow<'static, str> {
115        "Color".into()
116    }
117
118    fn json_schema(_generator: &mut SchemaGenerator) -> Schema {
119        json_schema!({
120            "type": "string",
121            "pattern": "^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$",
122            "description": "An sRGB colour with straight alpha, written as #rrggbb when opaque or #rrggbbaa otherwise."
123        })
124    }
125}
126
127/// How a colour is chosen for a stroke, fill or marker.
128#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
129#[serde(tag = "type", rename_all = "snake_case")]
130pub enum ColorSpec {
131    /// The renderer chooses the colour: for series this is the next colour of the
132    /// axes colour order (Okabe–Ito), and inside a scatter marker it is the scatter
133    /// colour.
134    #[default]
135    Auto,
136    /// A fixed colour.
137    Rgba {
138        /// The colour to use.
139        color: Color,
140    },
141    /// Nothing is drawn.
142    None,
143    /// The colour is taken from the axes colormap, indexed by the data value
144    /// scaled into the axes colour limits.
145    Colormapped,
146}
147
148/// The style of a stroked line.
149#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
150pub struct LineStyle {
151    /// The line colour.
152    pub color: ColorSpec,
153    /// The line width in points.
154    pub width_pt: f64,
155    /// The dash pattern of the line.
156    pub dash: DashStyle,
157}
158
159impl Default for LineStyle {
160    fn default() -> Self {
161        Self {
162            color: ColorSpec::Auto,
163            width_pt: 0.75,
164            dash: DashStyle::Solid,
165        }
166    }
167}
168
169/// The dash pattern of a stroked line.
170#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
171#[serde(rename_all = "snake_case")]
172pub enum DashStyle {
173    /// A continuous line.
174    #[default]
175    Solid,
176    /// A line of dashes.
177    Dashed,
178    /// A line of dots.
179    Dotted,
180    /// A line of alternating dashes and dots.
181    DashDot,
182    /// No line is drawn.
183    None,
184}
185
186/// The style of the markers drawn at data points.
187#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
188pub struct MarkerStyle {
189    /// The marker shape.
190    pub shape: MarkerShape,
191    /// The marker size in points, measured as the width of the marker.
192    pub size_pt: f64,
193    /// The colour of the marker interior.
194    pub face: ColorSpec,
195    /// The colour of the marker outline.
196    pub edge: ColorSpec,
197}
198
199impl Default for MarkerStyle {
200    fn default() -> Self {
201        Self {
202            shape: MarkerShape::None,
203            size_pt: 4.0,
204            face: ColorSpec::None,
205            edge: ColorSpec::Auto,
206        }
207    }
208}
209
210/// The shape of a marker.
211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
212#[serde(rename_all = "snake_case")]
213pub enum MarkerShape {
214    /// No marker is drawn.
215    #[default]
216    None,
217    /// A circle.
218    Circle,
219    /// An axis-aligned square.
220    Square,
221    /// A square rotated by 45 degrees.
222    Diamond,
223    /// A triangle pointing upwards.
224    TriangleUp,
225    /// A triangle pointing downwards.
226    TriangleDown,
227    /// A plus sign.
228    Plus,
229    /// A diagonal cross.
230    Cross,
231    /// A small filled dot.
232    Point,
233}