Skip to main content

teksilo_data/
series_pattern.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`SeriesPattern`] — the non-colour channel that identifies a chart series.
5//!
6//! A chart that tells its series apart by colour and nothing else fails WCAG
7//! 1.4.1 (Use of Color), *whatever* palette it uses. A CVD-safe palette like
8//! Okabe–Ito answers a different question — whether the colours are
9//! distinguishable from one another — and does not answer this one: a reader
10//! with monochrome vision, a monochrome printout, a display in bright sun, or a
11//! forced-colours setting has no colour channel at all. It also does not answer
12//! the wrap-around problem, where a ninth series repeats the first's colour
13//! exactly.
14//!
15//! So every series carries a second, orthogonal identity: a **pattern**. One
16//! value drives all three renderings a chart needs, so a series looks like
17//! *itself* whether it is drawn as a line, a bar, a slice, or a legend swatch:
18//!
19//! | | line | marker | filled area |
20//! | --- | --- | --- | --- |
21//! | [`Solid`](SeriesPattern::Solid) | solid | circle | plain |
22//! | [`Dashed`](SeriesPattern::Dashed) | long dash | square | 45° hatch |
23//! | [`Dotted`](SeriesPattern::Dotted) | dotted | triangle | back-hatch |
24//! | [`DashDot`](SeriesPattern::DashDot) | dash-dot | diamond | cross-hatch |
25//! | [`ShortDash`](SeriesPattern::ShortDash) | short dash | cross | horizontal |
26//! | [`WideDash`](SeriesPattern::WideDash) | wide dash | plus | vertical |
27//!
28//! Six patterns against the theme palette's eight colours means the pair
29//! `(colour, pattern)` does not repeat until the 24th series — where colour
30//! alone repeated at the 9th.
31//!
32//! A series with no explicit pattern is assigned one from its position by
33//! [`SeriesPattern::for_index`], so the channel exists without any application
34//! code. Whether a chart *draws* it is the chart's decision (the stock charts
35//! draw it once more than one series is visible, since a single-series chart
36//! has nothing to disambiguate).
37
38/// The non-colour visual channel identifying one chart series.
39///
40/// See the [module docs](self) for the rendering table and the reasoning.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
42pub enum SeriesPattern {
43    /// Unbroken line, round marker, plain fill.
44    #[default]
45    Solid,
46    /// Long dash, square marker, forward (45°) hatch.
47    Dashed,
48    /// Dotted line, triangular marker, back (135°) hatch.
49    Dotted,
50    /// Dash-dot line, diamond marker, cross-hatch.
51    DashDot,
52    /// Short dash, ×-shaped marker, horizontal hatch.
53    ShortDash,
54    /// Wide-spaced dash, +-shaped marker, vertical hatch.
55    WideDash,
56}
57
58/// The marker glyph drawn at a line chart's data points, and next to a series
59/// in a legend. Shape, not colour — that is the whole point.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub enum SeriesMarker {
62    Circle,
63    Square,
64    Triangle,
65    Diamond,
66    Cross,
67    Plus,
68}
69
70/// How a filled region (a bar, an area, a pie slice) carries its series'
71/// pattern. `None` is a plain fill; the rest are line hatches at the named
72/// angle, drawn in a contrasting tone over the fill.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub enum SeriesHatch {
75    /// No hatch — a plain fill.
76    None,
77    /// Parallel lines rising to the right (45°).
78    Forward,
79    /// Parallel lines falling to the right (135°).
80    Backward,
81    /// Both diagonals.
82    Cross,
83    /// Parallel horizontal lines.
84    Horizontal,
85    /// Parallel vertical lines.
86    Vertical,
87}
88
89impl SeriesPattern {
90    /// Every pattern, in assignment order. The order is the cycle
91    /// [`for_index`](Self::for_index) walks.
92    pub const ALL: [SeriesPattern; 6] = [
93        SeriesPattern::Solid,
94        SeriesPattern::Dashed,
95        SeriesPattern::Dotted,
96        SeriesPattern::DashDot,
97        SeriesPattern::ShortDash,
98        SeriesPattern::WideDash,
99    ];
100
101    /// The pattern a series at `index` gets when it declares none.
102    ///
103    /// Wraps, like [`ChartPalette::color_for`](../teksilo_charts/palette) does
104    /// with colours — but at a different period (6 against the theme palette's
105    /// 8), so the wrap points do not coincide and `(colour, pattern)` stays
106    /// unique far longer than either channel alone.
107    pub fn for_index(index: usize) -> Self {
108        Self::ALL[index % Self::ALL.len()]
109    }
110
111    /// The dash pattern for a stroked line, as `(dash, gap)` in logical
112    /// pixels, or `None` for an unbroken line.
113    ///
114    /// Scaled by `line_width` so a 1 dp line and a 4 dp line read as the same
115    /// pattern rather than the thick one looking almost solid.
116    pub fn dash(self, line_width: f32) -> Option<(f32, f32)> {
117        let w = line_width.max(0.5);
118        match self {
119            Self::Solid => None,
120            Self::Dashed => Some((w * 4.0, w * 2.5)),
121            Self::Dotted => Some((w * 0.9, w * 1.8)),
122            // Approximated as a dash: the canvas dash model is a single
123            // (on, off) pair, so a true dash-dot would need a 4-element
124            // pattern. Kept visually distinct from the others by length.
125            Self::DashDot => Some((w * 6.0, w * 2.0)),
126            Self::ShortDash => Some((w * 2.0, w * 1.5)),
127            Self::WideDash => Some((w * 3.0, w * 5.0)),
128        }
129    }
130
131    /// The marker glyph for this pattern.
132    pub fn marker(self) -> SeriesMarker {
133        match self {
134            Self::Solid => SeriesMarker::Circle,
135            Self::Dashed => SeriesMarker::Square,
136            Self::Dotted => SeriesMarker::Triangle,
137            Self::DashDot => SeriesMarker::Diamond,
138            Self::ShortDash => SeriesMarker::Cross,
139            Self::WideDash => SeriesMarker::Plus,
140        }
141    }
142
143    /// The hatch for a filled region carrying this pattern.
144    pub fn hatch(self) -> SeriesHatch {
145        match self {
146            Self::Solid => SeriesHatch::None,
147            Self::Dashed => SeriesHatch::Forward,
148            Self::Dotted => SeriesHatch::Backward,
149            Self::DashDot => SeriesHatch::Cross,
150            Self::ShortDash => SeriesHatch::Horizontal,
151            Self::WideDash => SeriesHatch::Vertical,
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn the_first_six_series_get_six_distinct_patterns() {
162        let assigned: Vec<SeriesPattern> = (0..6).map(SeriesPattern::for_index).collect();
163        let unique: std::collections::HashSet<_> = assigned.iter().collect();
164        assert_eq!(
165            unique.len(),
166            6,
167            "each of the first six series must be distinguishable without colour"
168        );
169    }
170
171    #[test]
172    fn the_pattern_cycle_does_not_share_a_period_with_the_palette() {
173        // The theme palette holds 8 colours and wraps there; patterns wrap at
174        // 6. Coinciding periods would put series 0 and series 8 on the same
175        // colour AND the same pattern, which is the wrap bug the second
176        // channel exists to fix.
177        assert_ne!(
178            SeriesPattern::for_index(0),
179            SeriesPattern::for_index(8),
180            "a ninth series must not repeat the first in both channels"
181        );
182    }
183
184    #[test]
185    fn every_pattern_carries_all_three_renderings() {
186        // A pattern that produced a distinct dash but a shared marker would
187        // leave point-only and bar charts colour-only.
188        let dashes: std::collections::HashSet<_> = SeriesPattern::ALL
189            .iter()
190            .map(|p| p.dash(2.0).map(|(d, g)| (d.to_bits(), g.to_bits())))
191            .collect();
192        let markers: std::collections::HashSet<_> =
193            SeriesPattern::ALL.iter().map(|p| p.marker()).collect();
194        let hatches: std::collections::HashSet<_> =
195            SeriesPattern::ALL.iter().map(|p| p.hatch()).collect();
196        assert_eq!(dashes.len(), 6, "line dashes must all differ");
197        assert_eq!(markers.len(), 6, "markers must all differ");
198        assert_eq!(hatches.len(), 6, "hatches must all differ");
199    }
200
201    #[test]
202    fn dashes_scale_with_the_line_width() {
203        // A 4 dp line dashed on a 1 dp scale reads as very nearly solid.
204        let thin = SeriesPattern::Dashed.dash(1.0).unwrap();
205        let thick = SeriesPattern::Dashed.dash(4.0).unwrap();
206        assert!(thick.0 > thin.0 && thick.1 > thin.1);
207    }
208}