Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-plotters/src/heatmap.rs

//! **Heatmap** — a `rows × cols` matrix painted as a colour grid using a
//! theme-derived [`Ramp`](crate::ramp::Ramp), with a gradient legend. The
//! cell-layout math ([`cell_rect`]) is a pure function with tests so the grid
//! geometry can be verified headlessly.

use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::{Elm, FacetCaps, Semantics, a11y_node, theme};
use serde::{Deserialize, Serialize};

use crate::boxplot::paint_tooltip;
use crate::ramp::{Ramp, normalize};

/// Persistent, observable interaction state (FC-3): the `(row, col)` cell the
/// pointer last hovered. Serialized so it round-trips headlessly.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct HeatmapState {
    /// The hovered cell coordinate `(row, col)`, or `None`.
    pub hover: Option<(usize, usize)>,
}

/// FC-2 input surface. One input: the hovered cell changing (hit-tested purely in
/// [`HeatmapView::render`], applied in [`HeatmapView::update`]).
#[derive(Clone, Debug, PartialEq)]
pub enum HeatmapMsg {
    /// Set (or clear, with `None`) the hovered `(row, col)` cell.
    Hover(Option<(usize, usize)>),
}

/// FC-8: the heatmap does no I/O.
#[derive(Clone, Debug)]
pub enum HeatmapEffect {}

/// A dense matrix in row-major order with optional axis labels.
pub struct HeatmapView {
    pub title: String,
    pub rows: usize,
    pub cols: usize,
    /// Row-major: `data[r * cols + c]`.
    pub data: Vec<f64>,
    pub row_labels: Vec<String>,
    pub col_labels: Vec<String>,
    /// Use the diverging ramp (around a midpoint) instead of sequential.
    pub diverging: bool,
    /// Observable interaction state (FC-3).
    pub state: HeatmapState,
}

/// The pixel rect of cell `(row, col)` inside `grid`, given `rows × cols`.
/// Pure geometry — no painter needed, so it's directly testable.
pub fn cell_rect(grid: Rect, rows: usize, cols: usize, row: usize, col: usize) -> Rect {
    let cw = grid.width() / cols.max(1) as f32;
    let ch = grid.height() / rows.max(1) as f32;
    let x = grid.left() + cw * col as f32;
    let y = grid.top() + ch * row as f32;
    Rect::from_min_size(Pos2::new(x, y), vec2(cw, ch))
}

impl HeatmapView {
    pub fn new(title: impl Into<String>, rows: usize, cols: usize, data: Vec<f64>) -> Self {
        Self {
            title: title.into(),
            rows,
            cols,
            data,
            row_labels: Vec::new(),
            col_labels: Vec::new(),
            diverging: false,
            state: HeatmapState::default(),
        }
    }

    /// FC-3: read the complete observable interaction state.
    pub fn state(&self) -> &HeatmapState {
        &self.state
    }

    /// FC-2/FC-8: the single mutation path. Apply one [`HeatmapMsg`]; no effects.
    pub fn update(&mut self, msg: HeatmapMsg) -> Vec<HeatmapEffect> {
        match msg {
            HeatmapMsg::Hover(h) => self.state.hover = h,
        }
        Vec::new()
    }

    pub fn with_labels(mut self, rows: Vec<String>, cols: Vec<String>) -> Self {
        self.row_labels = rows;
        self.col_labels = cols;
        self
    }

    pub fn diverging(mut self) -> Self {
        self.diverging = true;
        self
    }

    /// Value at `(row, col)`, or `None` if out of bounds / ragged data.
    pub fn get(&self, row: usize, col: usize) -> Option<f64> {
        if row >= self.rows || col >= self.cols {
            return None;
        }
        self.data.get(row * self.cols + col).copied()
    }

    /// `(min, max)` over the finite cells.
    pub fn value_range(&self) -> Option<(f64, f64)> {
        let mut r: Option<(f64, f64)> = None;
        for &x in &self.data {
            if x.is_finite() {
                r = Some(match r {
                    None => (x, x),
                    Some((lo, hi)) => (lo.min(x), hi.max(x)),
                });
            }
        }
        r
    }

    fn to_csv(&self) -> String {
        let mut s = String::new();
        if !self.col_labels.is_empty() {
            s.push_str(&format!(",{}\n", self.col_labels.join(",")));
        }
        for r in 0..self.rows {
            if let Some(lbl) = self.row_labels.get(r) {
                s.push_str(lbl);
            } else {
                s.push_str(&format!("r{r}"));
            }
            for c in 0..self.cols {
                s.push(',');
                s.push_str(&format!("{}", self.get(r, c).unwrap_or(f64::NAN)));
            }
            s.push('\n');
        }
        s
    }
}

// ── typed copy (§16) — the chart dataset as CSV Text ──────────────────
impl CopySource for HeatmapView {
    fn copy_kinds(&self) -> &[ClipKind] {
        &[ClipKind::Text]
    }
    fn copy_payload(&self) -> Option<ClipPayload> {
        let csv = self.to_csv();
        (!csv.is_empty()).then_some(ClipPayload::Text(csv))
    }
}

impl HeatmapView {
    /// FC-9 pure view: paints the grid + legend + hover tooltip (from the
    /// *stored* hover cell) and **returns** the hovered-cell change as a `Msg`.
    fn render(&self, ui: &mut Ui) -> Vec<HeatmapMsg> {
        let th = theme(ui);
        let ramp = if self.diverging { Ramp::diverging(&th) } else { Ramp::sequential(&th) };
        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
        let Some((vmin, vmax)) = self.value_range() else {
            // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit(
                "facett-plotters::HeatmapView::ui",
                "ui_render",
                self.data.is_empty(),
                &format!("drew=empty_hint rows={} cols={} cells={}", self.rows, self.cols, self.data.len()),
            );
            painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
            return hover_msg(self.state.hover, None);
        };
        // Reserve a strip on the right for the legend.
        let legend_w = 56.0;
        let grid = rect.shrink2(vec2(8.0, 8.0));
        let grid = Rect::from_min_max(grid.min, Pos2::new(grid.right() - legend_w, grid.bottom()));
        let hover = resp.hover_pos();
        let mut new_hover: Option<(usize, usize)> = None;
        let base = ui.id().with("heatmap");

        for r in 0..self.rows {
            for c in 0..self.cols {
                let Some(v) = self.get(r, c) else { continue };
                let cr = cell_rect(grid, self.rows, self.cols, r, c);
                let color = ramp.at(normalize(v, vmin, vmax));
                painter.rect_filled(cr.shrink(0.5), egui::CornerRadius::ZERO, color);
                let rl = self.row_labels.get(r).cloned().unwrap_or_else(|| format!("r{r}"));
                let cl = self.col_labels.get(c).cloned().unwrap_or_else(|| format!("c{c}"));
                // AccessKit node over each cell (FC-4): role=Image, label =
                // "row × col" (unique per cell), numeric_value = the cell value.
                // Keyed on the (r,c) coordinate (stable, FC-5).
                a11y_node(
                    ui,
                    base,
                    (r, c),
                    Sense::hover(),
                    cr,
                    Semantics::image(format!("{rl} × {cl}")).value(v),
                );
                if let Some(hp) = hover && cr.contains(hp) {
                    new_hover = Some((r, c));
                }
            }
        }

        // Legend gradient (top = max, bottom = min).
        let lx = rect.right() - legend_w + 14.0;
        let lrect = Rect::from_min_max(Pos2::new(lx, grid.top()), Pos2::new(lx + 16.0, grid.bottom()));
        let steps = 48;
        for i in 0..steps {
            let t = i as f32 / (steps - 1) as f32;
            let y0 = lrect.bottom() - lrect.height() * (i as f32 / steps as f32);
            let y1 = lrect.bottom() - lrect.height() * ((i + 1) as f32 / steps as f32);
            painter.rect_filled(
                Rect::from_min_max(Pos2::new(lrect.left(), y1), Pos2::new(lrect.right(), y0)),
                egui::CornerRadius::ZERO,
                ramp.at(t),
            );
        }
        painter.rect_stroke(lrect, egui::CornerRadius::ZERO, Stroke::new(1.0_f32, th.panel_stroke), egui::StrokeKind::Outside);
        painter.text(Pos2::new(lrect.right() + 4.0, lrect.top()), Align2::LEFT_CENTER, format!("{vmax:.2}"), FontId::proportional(10.0), th.text_dim);
        painter.text(Pos2::new(lrect.right() + 4.0, lrect.bottom()), Align2::LEFT_CENTER, format!("{vmin:.2}"), FontId::proportional(10.0), th.text_dim);

        // Tooltip painted last (on top) from the *persisted* hover cell (set by
        // `update` last frame), keeping `render` a pure function of `&self`.
        if let Some(hp) = hover
            && let Some((r, c)) = self.state.hover
            && let Some(v) = self.get(r, c)
        {
            let rl = self.row_labels.get(r).cloned().unwrap_or_else(|| format!("r{r}"));
            let cl = self.col_labels.get(c).cloned().unwrap_or_else(|| format!("c{c}"));
            paint_tooltip(&painter, &th, hp, &format!("{rl} × {cl}\n{v:.4}"));
        }
        // ── render-lane emit: this Facet::ui path RAN ─────────────────────────
        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-plotters::HeatmapView::ui",
            "ui_render",
            !self.data.is_empty() && self.rows * self.cols <= self.data.len(),
            &format!("rows={} cols={} cells={}", self.rows, self.cols, self.data.len()),
        );
        hover_msg(self.state.hover, new_hover)
    }
}

/// Emit a `Hover` message only when the hovered cell actually changed.
fn hover_msg(current: Option<(usize, usize)>, next: Option<(usize, usize)>) -> Vec<HeatmapMsg> {
    if current != next { vec![HeatmapMsg::Hover(next)] } else { Vec::new() }
}

impl Elm for HeatmapView {
    type Model = HeatmapState;
    type Msg = HeatmapMsg;
    type Effect = HeatmapEffect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &HeatmapState {
        &self.state
    }
    fn update(&mut self, msg: HeatmapMsg) -> Vec<HeatmapEffect> {
        HeatmapView::update(self, msg)
    }
    fn view(&self, ui: &mut Ui) -> Vec<HeatmapMsg> {
        facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
        self.render(ui)
    }
}

// Form 3: richer derived `state_json` (shape + value range + ramp mode) preserved
// exactly, so the macro's default `serde(state())` is suppressed.
facett_core::impl_facet_via_elm!(HeatmapView, custom_state_json, {
    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().copyable().resizable()
    }
    fn copy(&mut self) -> Option<String> {
        Some(self.to_csv())
    }
    fn state_json(&self) -> serde_json::Value {
        let (mn, mx) = self.value_range().unwrap_or((0.0, 0.0));
        serde_json::json!({
            "kind": "heatmap",
            "rows": self.rows, "cols": self.cols,
            "cells": self.data.len(),
            "min": mn, "max": mx,
            "diverging": self.diverging,
        })
    }
});

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

    #[test]
    fn typed_copy_is_the_dataset_csv() {
        use facett_core::clip::{ClipKind, CopySource};
        let v = HeatmapView::new("h", 2, 2, vec![1.0, 2.0, 3.0, 4.0]);
        let p = v.copy_payload().expect("data copies");
        assert_eq!(p.kind(), ClipKind::Text);
        assert_eq!(p.as_text(), v.to_csv());
    }

    fn grid() -> Rect {
        Rect::from_min_size(Pos2::new(0.0, 0.0), vec2(100.0, 80.0))
    }

    #[test]
    fn cell_rects_tile_without_gaps() {
        let g = grid();
        let (rows, cols) = (4, 5);
        // first cell starts at origin
        let c00 = cell_rect(g, rows, cols, 0, 0);
        assert_eq!(c00.min, Pos2::new(0.0, 0.0));
        assert!((c00.width() - 20.0).abs() < 1e-4);
        assert!((c00.height() - 20.0).abs() < 1e-4);
        // adjacent column starts where the previous ends
        let c01 = cell_rect(g, rows, cols, 0, 1);
        assert!((c01.left() - c00.right()).abs() < 1e-4);
        // last cell ends at the grid's far corner
        let last = cell_rect(g, rows, cols, rows - 1, cols - 1);
        assert!((last.right() - g.right()).abs() < 1e-4);
        assert!((last.bottom() - g.bottom()).abs() < 1e-4);
    }

    #[test]
    fn get_bounds_and_row_major() {
        let h = HeatmapView::new("t", 2, 3, vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
        assert_eq!(h.get(0, 0), Some(0.0));
        assert_eq!(h.get(0, 2), Some(2.0));
        assert_eq!(h.get(1, 0), Some(3.0));
        assert_eq!(h.get(1, 2), Some(5.0));
        assert_eq!(h.get(2, 0), None); // row OOB
        assert_eq!(h.get(0, 3), None); // col OOB
    }

    #[test]
    fn value_range_ignores_nan() {
        let h = HeatmapView::new("t", 1, 3, vec![1.0, f64::NAN, 9.0]);
        assert_eq!(h.value_range(), Some((1.0, 9.0)));
    }

    #[test]
    fn csv_round_trips_shape() {
        let h = HeatmapView::new("t", 2, 2, vec![1.0, 2.0, 3.0, 4.0])
            .with_labels(vec!["a".into(), "b".into()], vec!["x".into(), "y".into()]);
        let csv = h.to_csv();
        assert!(csv.contains(",x,y\n"));
        assert!(csv.contains("a,1,2\n"));
        assert!(csv.contains("b,3,4\n"));
    }

    // ── Elm (FC-2/FC-3) harness properties ────────────────────────────────
    #[test]
    fn hover_transitions_are_observable() {
        let mut v = HeatmapView::new("t", 2, 2, vec![1.0, 2.0, 3.0, 4.0]);
        assert_eq!(v.state().hover, None);
        let snap = facett_core::harness::snapshot(&mut v, [HeatmapMsg::Hover(Some((1, 0)))]);
        assert_eq!(snap.hover, Some((1, 0)));
        let snap = facett_core::harness::snapshot(&mut v, [HeatmapMsg::Hover(None)]);
        assert_eq!(snap.hover, None);
    }

    #[test]
    fn drive_produces_no_effects() {
        let mut v = HeatmapView::new("t", 2, 2, vec![1.0, 2.0, 3.0, 4.0]);
        let effects = facett_core::harness::drive(&mut v, [HeatmapMsg::Hover(Some((0, 1))), HeatmapMsg::Hover(None)]);
        assert!(effects.is_empty());
    }

    #[test]
    fn state_serde_round_trips() {
        let st = HeatmapState { hover: Some((3, 2)) };
        let back: HeatmapState = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
        assert_eq!(st, back);
    }
}