Skip to main content

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}