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}