Skip to main content

facett_plotters/
ramp.rs

1//! **Theme-derived colour ramps.** No hardcoded palette: a sequential ramp is
2//! built from the theme's `bg` → `accent` → `glow`, and a diverging ramp from
3//! `accent` (low) through a neutral midpoint to `glow` (high). Charts call
4//! [`Ramp::at`] with a normalised `t ∈ 0..=1` to map a scalar to a colour.
5//!
6//! Keeping this purely a function of [`facett_core::Theme`] means every chart
7//! re-skins automatically when the theme switches — the requirement that no
8//! component bakes in its own colours.
9
10use egui::Color32;
11use facett_core::Theme;
12
13/// A colour ramp sampled at `t ∈ 0..=1`. Built from theme colours so it follows
14/// the active palette.
15#[derive(Clone, Copy, Debug)]
16pub struct Ramp {
17    /// Control colours, low → high. We lerp between the nearest two.
18    stops: [Color32; 3],
19}
20
21impl Ramp {
22    /// **Sequential** low→high ramp: a dim theme ground → `accent` → `glow`.
23    /// Good for magnitudes (heatmaps, treemap weights).
24    pub fn sequential(th: &Theme) -> Self {
25        // Start from a darkened accent so the low end reads as "cool/quiet"
26        // rather than the panel background (which would vanish on the canvas).
27        let low = lerp_color(th.bg, th.accent, 0.25);
28        Self { stops: [low, th.accent, th.glow] }
29    }
30
31    /// **Diverging** ramp: `accent` (low) → a neutral mid (theme `text_dim`) →
32    /// `glow` (high). Good for signed values around a midpoint.
33    pub fn diverging(th: &Theme) -> Self {
34        Self { stops: [th.accent, th.text_dim, th.glow] }
35    }
36
37    /// Sample the ramp at `t`, clamped to `0..=1`.
38    pub fn at(&self, t: f32) -> Color32 {
39        let t = t.clamp(0.0, 1.0);
40        if t <= 0.5 {
41            lerp_color(self.stops[0], self.stops[1], t * 2.0)
42        } else {
43            lerp_color(self.stops[1], self.stops[2], (t - 0.5) * 2.0)
44        }
45    }
46
47    /// The control stops, for a legend gradient.
48    pub fn stops(&self) -> [Color32; 3] {
49        self.stops
50    }
51}
52
53/// Linear interpolation between two colours in sRGB space (cheap, good enough
54/// for chart fills). `t ∈ 0..=1`.
55pub fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
56    let t = t.clamp(0.0, 1.0);
57    let l = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round().clamp(0.0, 255.0) as u8;
58    Color32::from_rgba_unmultiplied(l(a.r(), b.r()), l(a.g(), b.g()), l(a.b(), b.b()), l(a.a(), b.a()))
59}
60
61/// Normalise `v` into `0..=1` over `[min, max]`. Degenerate ranges map to `0.5`
62/// (a flat mid-tone) rather than dividing by zero.
63pub fn normalize(v: f64, min: f64, max: f64) -> f32 {
64    if !(max > min) {
65        return 0.5;
66    }
67    (((v - min) / (max - min)) as f32).clamp(0.0, 1.0)
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn lerp_endpoints_and_mid() {
76        let a = Color32::from_rgb(0, 0, 0);
77        let b = Color32::from_rgb(100, 200, 50);
78        assert_eq!(lerp_color(a, b, 0.0), a);
79        assert_eq!(lerp_color(a, b, 1.0), b);
80        let m = lerp_color(a, b, 0.5);
81        assert_eq!((m.r(), m.g(), m.b()), (50, 100, 25));
82    }
83
84    #[test]
85    fn ramp_hits_its_stops() {
86        let r = Ramp::sequential(&Theme::default());
87        let [lo, mid, hi] = r.stops();
88        assert_eq!(r.at(0.0), lo);
89        assert_eq!(r.at(0.5), mid);
90        assert_eq!(r.at(1.0), hi);
91    }
92
93    #[test]
94    fn ramp_clamps_out_of_range() {
95        let r = Ramp::diverging(&Theme::sci_fi());
96        assert_eq!(r.at(-3.0), r.at(0.0));
97        assert_eq!(r.at(9.0), r.at(1.0));
98    }
99
100    #[test]
101    fn normalize_handles_degenerate_range() {
102        assert_eq!(normalize(5.0, 5.0, 5.0), 0.5);
103        assert_eq!(normalize(0.0, 0.0, 10.0), 0.0);
104        assert_eq!(normalize(10.0, 0.0, 10.0), 1.0);
105        assert_eq!(normalize(2.5, 0.0, 10.0), 0.25);
106        // out of band clamps
107        assert_eq!(normalize(-1.0, 0.0, 10.0), 0.0);
108        assert_eq!(normalize(99.0, 0.0, 10.0), 1.0);
109    }
110}