model/colormap.rs
1//! Continuous colour ramps — the single source for every z→colour mapping.
2//!
3//! Separate from [`crate::preset::ColorCycle`] because the two answer different
4//! questions. A cycle hands out *discrete* colours by series index and wraps;
5//! a colormap is a *continuous* ramp sampled at a normalized position, and
6//! wrapping it would be meaningless. They sit side by side rather than merged.
7//!
8//! Everything that turns a z value into a colour goes through [`sample`] or the
9//! [`lut`] it builds: the CPU colourbar strip, the GPU lookup texture, and
10//! whatever picks per-level contour colours. One ramp definition, so a heatmap
11//! cell and the colourbar tick beside it cannot disagree about what a value
12//! looks like.
13//!
14//! No dependencies beyond [`Color`] — this module is pure data and arithmetic,
15//! which is what lets the renderer bake it into a texture and the raster path
16//! draw it directly.
17
18use crate::color::Color;
19
20/// Entries in the baked lookup table. Matches the GPU texture width the
21/// renderer uploads, so the CPU strip and the shader sample the same steps.
22pub const LUT_LEN: usize = 256;
23
24const fn rgb8(r: u8, g: u8, b: u8) -> Color {
25 Color::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0)
26}
27
28/// A continuous colour ramp.
29///
30/// The named ramps are fieldless so a host UI can list them in a dropdown and
31/// they cross the wasm boundary as plain integers, following
32/// [`crate::preset::ColorCycle`]. `Custom` carries its own stops for hosts that
33/// need a ramp figgy does not ship.
34#[derive(Debug, Clone, PartialEq, Default)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36pub enum ColorMap {
37 /// Perceptually uniform blue→green→yellow. The safe default for unsigned
38 /// magnitudes.
39 #[default]
40 Viridis,
41 /// Perceptually uniform black→purple→orange→white.
42 Magma,
43 /// High-contrast rainbow. Reads well for fine structure, poor for
44 /// judging magnitude — and unreadable in greyscale print.
45 Turbo,
46 /// Black→white. For print, and for overlaying contour lines on top.
47 GrayScale,
48 /// Diverging red→white→blue. For signed data around a meaningful zero;
49 /// set `axis.min = -axis.max` or the white midpoint lands off zero and the
50 /// ramp lies about the sign.
51 RdBu,
52 /// Host-supplied stops, sampled in order at evenly spaced positions.
53 ///
54 /// Fewer than two stops cannot define a ramp: [`sample`] and [`lut`] fall
55 /// back to fully transparent rather than inventing an endpoint, so a
56 /// malformed custom map draws nothing instead of drawing a lie.
57 Custom { stops: Vec<Color> },
58}
59
60// Ramp control points, sampled from the reference implementations at even
61// spacing. Eight stops per named ramp: enough that linear interpolation between
62// them stays visually smooth at 256 LUT entries, few enough to read here.
63
64const VIRIDIS: &[Color] = &[
65 rgb8(68, 1, 84),
66 rgb8(72, 40, 120),
67 rgb8(62, 74, 137),
68 rgb8(49, 104, 142),
69 rgb8(38, 130, 142),
70 rgb8(53, 183, 121),
71 rgb8(145, 213, 66),
72 rgb8(253, 231, 37),
73];
74
75const MAGMA: &[Color] = &[
76 rgb8(0, 0, 4),
77 rgb8(28, 16, 68),
78 rgb8(79, 18, 123),
79 rgb8(129, 37, 129),
80 rgb8(181, 54, 122),
81 rgb8(229, 80, 100),
82 rgb8(251, 143, 97),
83 rgb8(252, 253, 191),
84];
85
86const TURBO: &[Color] = &[
87 rgb8(48, 18, 59),
88 rgb8(70, 107, 227),
89 rgb8(40, 187, 236),
90 rgb8(60, 231, 160),
91 rgb8(163, 252, 60),
92 rgb8(248, 202, 47),
93 rgb8(238, 108, 26),
94 rgb8(122, 4, 3),
95];
96
97const GRAY_SCALE: &[Color] = &[Color::BLACK, Color::WHITE];
98
99const RD_BU: &[Color] = &[
100 rgb8(103, 0, 31),
101 rgb8(178, 24, 43),
102 rgb8(214, 96, 77),
103 rgb8(244, 165, 130),
104 rgb8(247, 247, 247),
105 rgb8(146, 197, 222),
106 rgb8(67, 147, 195),
107 rgb8(5, 48, 97),
108];
109
110impl ColorMap {
111 /// The ramp's control points, in ramp order.
112 pub fn stops(&self) -> &[Color] {
113 match self {
114 Self::Viridis => VIRIDIS,
115 Self::Magma => MAGMA,
116 Self::Turbo => TURBO,
117 Self::GrayScale => GRAY_SCALE,
118 Self::RdBu => RD_BU,
119 Self::Custom { stops } => stops,
120 }
121 }
122
123 /// Colour at normalized position `t`.
124 ///
125 /// `t` is clamped to `[0, 1]`, so a caller that has not normalized its z
126 /// range gets the endpoint colour rather than an out-of-range index. NaN
127 /// clamps to the low end — but a NaN z should be drawn with the
128 /// colourbar's `nan_color` instead of being pushed through here, because
129 /// "missing" and "smallest" are different facts.
130 pub fn sample(&self, t: f32) -> Color {
131 sample(self, t)
132 }
133
134 /// The ramp baked to [`LUT_LEN`] entries.
135 ///
136 /// A convenience for callers that want the ramp as a table — a legend image,
137 /// a texture upload of their own. figgy's own paths do **not** use it: the
138 /// colourbar strip and `field_columnar.wgsl` both evaluate
139 /// [`Self::sample`] over the same stops, which is what makes their colours
140 /// agree exactly instead of within a quantization step.
141 pub fn lut(&self) -> [Color; LUT_LEN] {
142 lut(self)
143 }
144}
145
146/// Colour at normalized position `t` — see [`ColorMap::sample`].
147pub fn sample(map: &ColorMap, t: f32) -> Color {
148 let stops = map.stops();
149 match stops.len() {
150 // A ramp needs two ends. Rather than invent one, draw nothing: a
151 // malformed `Custom` map is visibly absent instead of quietly wrong.
152 0 => Color::new(0.0, 0.0, 0.0, 0.0),
153 1 => stops[0],
154 n => {
155 // NaN fails both comparisons, so clamp explicitly to the low end.
156 let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
157 let scaled = t * (n - 1) as f32;
158 // The last segment owns t = 1: clamping the index there leaves the
159 // fraction measured against *that* segment's start, so the ramp
160 // reaches `stops[n - 1]` exactly instead of stopping one stop short.
161 let index = (scaled.floor() as usize).min(n - 2);
162 let frac = scaled - index as f32;
163 // A stop landed on exactly is returned as itself. `Color::lerp` is
164 // `a + (b - a) * t`, which at t = 1 lands an ulp off `b` — small,
165 // but it would put a colour at the colourbar's end that its axis
166 // label does not name, which is what this function must not do.
167 if frac <= 0.0 {
168 stops[index]
169 } else if frac >= 1.0 {
170 stops[index + 1]
171 } else {
172 stops[index].lerp(stops[index + 1], frac)
173 }
174 }
175 }
176}
177
178/// The ramp baked to [`LUT_LEN`] entries — see [`ColorMap::lut`].
179///
180/// A fixed array, not a `Vec`: baking the ramp is not allowed to allocate. The
181/// caller decides where the 4 KiB lives — straight into a `write_texture` for
182/// the GPU path, or a local for the colourbar strip — and neither one needs a
183/// heap block whose size was known at compile time.
184pub fn lut(map: &ColorMap) -> [Color; LUT_LEN] {
185 let last = (LUT_LEN - 1) as f32;
186 core::array::from_fn(|i| sample(map, i as f32 / last))
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn every_named_ramp_has_at_least_two_stops() {
195 for map in [
196 ColorMap::Viridis,
197 ColorMap::Magma,
198 ColorMap::Turbo,
199 ColorMap::GrayScale,
200 ColorMap::RdBu,
201 ] {
202 assert!(
203 map.stops().len() >= 2,
204 "{map:?} cannot define a ramp with {} stop(s)",
205 map.stops().len()
206 );
207 }
208 }
209
210 #[test]
211 fn endpoints_are_the_first_and_last_stop_exactly() {
212 // The colourbar draws its ends at t = 0 and t = 1 and its axis labels
213 // them with axis.min / axis.max, so an endpoint that interpolated even
214 // slightly would put a colour next to a number it is not.
215 for map in [ColorMap::Viridis, ColorMap::RdBu, ColorMap::GrayScale] {
216 let stops = map.stops();
217 assert_eq!(map.sample(0.0), stops[0]);
218 assert_eq!(map.sample(1.0), stops[stops.len() - 1]);
219 }
220 }
221
222 #[test]
223 fn out_of_range_and_nan_clamp_instead_of_panicking() {
224 let map = ColorMap::Viridis;
225 assert_eq!(map.sample(-5.0), map.sample(0.0));
226 assert_eq!(map.sample(5.0), map.sample(1.0));
227 assert_eq!(map.sample(f32::NAN), map.sample(0.0));
228 assert_eq!(map.sample(f32::INFINITY), map.sample(1.0));
229 assert_eq!(map.sample(f32::NEG_INFINITY), map.sample(0.0));
230 }
231
232 #[test]
233 fn grayscale_is_a_straight_ramp_from_black_to_white() {
234 let map = ColorMap::GrayScale;
235 let mid = map.sample(0.5);
236 assert!((mid.r - 0.5).abs() < 1e-6, "midpoint is {mid:?}");
237 assert!((mid.g - 0.5).abs() < 1e-6);
238 assert!((mid.b - 0.5).abs() < 1e-6);
239 assert_eq!(mid.a, 1.0);
240 }
241
242 #[test]
243 fn a_custom_ramp_with_too_few_stops_draws_nothing() {
244 let empty = ColorMap::Custom { stops: Vec::new() };
245 assert_eq!(empty.sample(0.5).a, 0.0, "no stops cannot be a ramp");
246
247 // One stop is a constant, not a ramp — but it is unambiguous, so it is
248 // honoured rather than blanked.
249 let single = ColorMap::Custom {
250 stops: vec![Color::new(0.2, 0.4, 0.6, 1.0)],
251 };
252 assert_eq!(single.sample(0.0), single.sample(1.0));
253 assert_eq!(single.sample(0.5).r, 0.2);
254 }
255
256 #[test]
257 fn a_custom_ramp_interpolates_its_stops_evenly() {
258 let map = ColorMap::Custom {
259 stops: vec![Color::BLACK, Color::WHITE, Color::BLACK],
260 };
261 assert_eq!(map.sample(0.0), Color::BLACK);
262 assert_eq!(map.sample(1.0), Color::BLACK);
263 let mid = map.sample(0.5);
264 assert!(
265 (mid.r - 1.0).abs() < 1e-6,
266 "the middle stop is white: {mid:?}"
267 );
268 let quarter = map.sample(0.25);
269 assert!((quarter.r - 0.5).abs() < 1e-6, "quarter is {quarter:?}");
270 }
271
272 #[test]
273 fn the_lut_is_the_ramp_at_lut_len_steps() {
274 let map = ColorMap::Magma;
275 let table = map.lut();
276 assert_eq!(table.len(), LUT_LEN);
277 assert_eq!(table[0], map.sample(0.0));
278 assert_eq!(table[LUT_LEN - 1], map.sample(1.0));
279 // The GPU texture and this table must be the same steps, which only
280 // holds if entry i is the ramp at i/(LUT_LEN-1).
281 let last = (LUT_LEN - 1) as f32;
282 for (i, entry) in table.iter().enumerate() {
283 assert_eq!(*entry, map.sample(i as f32 / last), "entry {i}");
284 }
285 }
286
287 #[test]
288 fn ramps_are_monotone_in_luminance_where_they_claim_to_be() {
289 // Viridis, Magma and GrayScale are perceptually ordered: a reader
290 // judges magnitude by brightness, so a dip would misreport the data.
291 // Turbo and RdBu make no such claim and are excluded on purpose.
292 for map in [ColorMap::Viridis, ColorMap::Magma, ColorMap::GrayScale] {
293 let luminance = |c: Color| 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
294 let table = map.lut();
295 for pair in table.windows(2) {
296 let (a, b) = (luminance(pair[0]), luminance(pair[1]));
297 assert!(b + 1e-4 >= a, "{map:?} luminance dips: {a} then {b}");
298 }
299 }
300 }
301}