dspk-app-core 0.0.7

DSP App core: the workbench for any multichannel signal (open, view, process, write) and the plugin interface the domain plugins (electrophysiology: dspk-app-synapse) extend
Documentation
//! Plot colours. Renderers and plot overlays take a [`Palette`] instead of fixed colours, so plots
//! follow the app's light or dark theme (or stay dark when the user prefers dark plots).

use crate::ui::plot::canvas::Pixel;

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Palette {
    pub dark: bool,
    pub background: Pixel,
    pub grid: Pixel,
    pub baseline: Pixel,
    /// Scale bar, axis text.
    pub text: Pixel,
    /// Spike event ticks.
    pub marker: Pixel,
    /// Playhead.
    pub playhead: Pixel,
    /// One colour per channel, cycling.
    pub channels: [Pixel; 8],
    /// Sequential colour map stops for heatmaps, 0 → 1.
    pub heat: [(f32, Pixel); 5],
}

/// Categorical colours of sorted units (Tableau 10, readable on dark and light plots).
pub const UNIT_COLORS: [Pixel; 10] = [
    Pixel::rgb(78, 121, 167),
    Pixel::rgb(242, 142, 43),
    Pixel::rgb(225, 87, 89),
    Pixel::rgb(118, 183, 178),
    Pixel::rgb(89, 161, 79),
    Pixel::rgb(237, 201, 72),
    Pixel::rgb(176, 122, 161),
    Pixel::rgb(255, 157, 167),
    Pixel::rgb(156, 117, 95),
    Pixel::rgb(186, 176, 172),
];

/// How colourful unit colours are on dark plots (1 = as on light ones).
pub const DARK_CHROMA: f32 = 0.75;

/// `c` with `chroma` of its colourfulness: moved toward the grey of the same lightness (hue and
/// lightness kept).
fn muted(c: Pixel, chroma: f32) -> Pixel {
    let grey = 0.2126 * c.r as f32 + 0.7152 * c.g as f32 + 0.0722 * c.b as f32;
    let mix = |v: u8| (grey + (v as f32 - grey) * chroma).round().clamp(0.0, 255.0) as u8;
    Pixel::rgb(mix(c.r), mix(c.g), mix(c.b))
}

impl Palette {
    pub const DARK: Palette = Palette {
        dark: true,
        background: Pixel::rgb(9, 13, 19),
        grid: Pixel::rgb(22, 27, 34),
        baseline: Pixel::rgb(33, 38, 45),
        text: Pixel::rgb(139, 148, 158),
        marker: Pixel::rgb(250, 204, 21),
        playhead: Pixel::rgb(244, 63, 94),
        channels: [
            Pixel::rgb(56, 189, 248),
            Pixel::rgb(52, 211, 153),
            Pixel::rgb(168, 85, 247),
            Pixel::rgb(251, 191, 36),
            Pixel::rgb(244, 63, 94),
            Pixel::rgb(96, 165, 250),
            Pixel::rgb(249, 115, 22),
            Pixel::rgb(45, 212, 191),
        ],
        // navy → violet → orange → yellow
        heat: [
            (0.00, Pixel::rgb(9, 13, 19)),
            (0.25, Pixel::rgb(49, 36, 110)),
            (0.50, Pixel::rgb(150, 45, 120)),
            (0.75, Pixel::rgb(240, 110, 50)),
            (1.00, Pixel::rgb(252, 230, 90)),
        ],
    };

    pub const LIGHT: Palette = Palette {
        dark: false,
        background: Pixel::rgb(255, 255, 255),
        grid: Pixel::rgb(234, 238, 242),
        baseline: Pixel::rgb(208, 215, 222),
        text: Pixel::rgb(87, 96, 106),
        marker: Pixel::rgb(191, 135, 0),
        playhead: Pixel::rgb(207, 34, 46),
        channels: [
            Pixel::rgb(9, 105, 218),
            Pixel::rgb(26, 127, 55),
            Pixel::rgb(130, 80, 223),
            Pixel::rgb(154, 103, 0),
            Pixel::rgb(207, 34, 46),
            Pixel::rgb(5, 80, 174),
            Pixel::rgb(188, 76, 0),
            Pixel::rgb(27, 124, 131),
        ],
        // white → light blue → blue → dark blue
        heat: [
            (0.00, Pixel::rgb(255, 255, 255)),
            (0.25, Pixel::rgb(198, 219, 239)),
            (0.50, Pixel::rgb(107, 174, 214)),
            (0.75, Pixel::rgb(33, 113, 181)),
            (1.00, Pixel::rgb(8, 48, 107)),
        ],
    };

    pub fn of(dark: bool) -> Palette {
        if dark { Palette::DARK } else { Palette::LIGHT }
    }

    pub fn channel(&self, ch: usize) -> Pixel {
        self.channels[ch % self.channels.len()]
    }

    /// Colour of sorted unit `i` (cycling through [`UNIT_COLORS`]: the same hue in both themes,
    /// so a unit keeps its colour everywhere). On dark plots it is [`DARK_CHROMA`] as colourful:
    /// saturated colours glare on a dark background (design review: chroma −20–30 %).
    pub fn unit(&self, i: usize) -> Pixel {
        let c = UNIT_COLORS[i % UNIT_COLORS.len()];
        if self.dark { muted(c, DARK_CHROMA) } else { c }
    }

    /// Spikes no unit claims.
    pub fn unsorted(&self) -> Pixel {
        if self.dark { Pixel::rgb(120, 126, 134) } else { Pixel::rgb(150, 150, 150) }
    }

    /// Colour of `v` (0..1, clamped) on the heat map.
    pub fn heat(&self, v: f32) -> Pixel {
        let v = v.clamp(0.0, 1.0);
        let stops = &self.heat;
        let i = stops.iter().position(|s| s.0 >= v).unwrap_or(stops.len() - 1).max(1);
        let ((t0, c0), (t1, c1)) = (stops[i - 1], stops[i]);
        let f = (v - t0) / (t1 - t0);
        let mix = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * f) as u8;
        Pixel::rgb(mix(c0.r, c1.r), mix(c0.g, c1.g), mix(c0.b, c1.b))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn unit_colours_are_less_colourful_on_dark_plots() {
        let spread = |c: Pixel| c.r.max(c.g).max(c.b) as f32 - c.r.min(c.g).min(c.b) as f32;
        for i in 0..UNIT_COLORS.len() {
            let (light, dark) = (Palette::of(false).unit(i), Palette::of(true).unit(i));
            assert_eq!(light, UNIT_COLORS[i], "light plots keep the colours as they are");
            if spread(light) > 8.0 {
                let ratio = spread(dark) / spread(light);
                assert!((0.70..=0.80).contains(&ratio), "unit {i}: chroma ×{ratio}");
                // Same hue: the brightest channel stays the brightest
                let top = |c: Pixel| if c.r >= c.g && c.r >= c.b { 0 } else if c.g >= c.b { 1 } else { 2 };
                assert_eq!(top(dark), top(light), "unit {i}: hue kept");
            }
        }
    }

    #[test]
    fn test_heat_endpoints() {
        for p in [Palette::DARK, Palette::LIGHT] {
            assert_eq!(p.heat(0.0), p.heat[0].1);
            assert_eq!(p.heat(1.0), p.heat[4].1);
            assert_eq!(p.heat(2.0), p.heat(1.0));
        }
        assert_eq!(Palette::DARK.heat(0.0), Palette::DARK.background, "quiet reads as background");
    }
}