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

nordisk/facett

facett-cfd/src/lib.rs

//! **facett-cfd** — a Cumulative Flow Diagram facet for ordning.
//!
//! Stacked **status areas over time** — the single most informative flow report:
//! band widths show how many cases are in each status, a widening band is a
//! growing bottleneck, the top slope is throughput, and the horizontal distance
//! across bands approximates cycle time. Fed by the daily status snapshots that
//! the event log makes free.
//!
//! Design: `ordning/.nornir/widgets/facett-cfd.md`. Built in ordning;
//! **destined for facett**. Pure egui + chrono; deterministic.

use chrono::NaiveDate;
use egui::{Color32, Pos2, Sense, Stroke, Ui, Vec2};
use serde::{Deserialize, Serialize};

/// **fluid** — a cheap gatling-accelerated 1-D dye-advection fluid (the *fluid in
/// pipes* the system-map draws). Additive to this crate's original Cumulative Flow
/// Diagram: it is the crate's first **gatling** consumer and a reusable accelerated
/// fluid API. See [`fluid::FluidField`].
pub mod fluid;
pub use fluid::FluidField;

/// **pipeflow** — *real liquid* in a pipe graph (additive to [`fluid`], which is left
/// untouched). Gravity-driven, capacity-limited 1-D free-surface flow constrained to the
/// tubes: it **falls** toward gravity as the host rotates the view, **stays inside the
/// pipe** by construction, **conserves volume** structurally, and **pools in the
/// junction tanks**. See [`pipeflow::PipeFlow`] and
/// `facett/.nornir/systemmap-fluid-design.md`.
pub mod pipeflow;
pub use pipeflow::PipeFlow;

/// One status band (drawn bottom→top in declaration order).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Band {
    pub id: String,
    pub label: String,
    /// RGB; if `None`, a default ramp is used by index.
    pub color: Option<[u8; 3]>,
}

impl Band {
    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
        Self { id: id.into(), label: label.into(), color: None }
    }
    pub fn rgb(mut self, r: u8, g: u8, b: u8) -> Self {
        self.color = Some([r, g, b]);
        self
    }
}

/// A daily snapshot: how many cases were in each status on this date.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Snapshot {
    pub date: NaiveDate,
    /// Count per band id. Missing bands count as 0.
    pub counts: std::collections::BTreeMap<String, usize>,
}

pub struct Cfd {
    title: String,
    bands: Vec<Band>,
    series: Vec<Snapshot>, // chronological
}

impl Cfd {
    pub fn new(title: impl Into<String>, bands: Vec<Band>) -> Self {
        Self { title: title.into(), bands, series: Vec::new() }
    }

    /// Set the snapshot series; sorted by date defensively.
    pub fn set_series(&mut self, mut series: Vec<Snapshot>) {
        series.sort_by_key(|s| s.date);
        self.series = series;
    }

    fn band_color(&self, idx: usize) -> Color32 {
        if let Some([r, g, b]) = self.bands[idx].color {
            return Color32::from_rgb(r, g, b);
        }
        // default sequential ramp (cool→warm), spread across the band count.
        let n = self.bands.len().max(1) as f32;
        let t = idx as f32 / n;
        Color32::from_rgb(
            (70.0 + 170.0 * t) as u8,
            (150.0 - 40.0 * t) as u8,
            (200.0 - 150.0 * t) as u8,
        )
    }

    fn max_total(&self) -> usize {
        self.series
            .iter()
            .map(|s| s.counts.values().sum::<usize>())
            .max()
            .unwrap_or(1)
            .max(1)
    }

    /// The **shared demo fixture** — a three-band flow with a three-snapshot
    /// series (so the cumulative stacked-area geometry has real slopes and the
    /// `<2 snapshots` placeholder branch is NOT taken). Consumed by the robot and
    /// matrix tests (L5: one fixture, not one per test file).
    pub fn local() -> Self {
        use std::collections::BTreeMap;
        fn d(y: i32, m: u32, day: u32) -> NaiveDate {
            NaiveDate::from_ymd_opt(y, m, day).unwrap()
        }
        fn snap(date: NaiveDate, todo: usize, doing: usize, done: usize) -> Snapshot {
            let mut counts = BTreeMap::new();
            counts.insert("todo".to_string(), todo);
            counts.insert("doing".to_string(), doing);
            counts.insert("done".to_string(), done);
            Snapshot { date, counts }
        }
        let mut c = Self::new(
            "Flow",
            vec![Band::new("todo", "To do"), Band::new("doing", "Doing"), Band::new("done", "Done")],
        );
        c.set_series(vec![
            snap(d(2026, 6, 1), 8, 1, 0),
            snap(d(2026, 6, 3), 5, 2, 2),
            snap(d(2026, 6, 5), 3, 3, 4),
        ]);
        c
    }

    /// Append one snapshot — the headless/robot counterpart of the host feeding a
    /// new day's counts. Re-sorts defensively, exactly like `set_series`.
    pub fn push_snapshot(&mut self, snap: Snapshot) {
        self.series.push(snap);
        self.series.sort_by_key(|s| s.date);
    }

    /// Drop every snapshot (the empty-state / placeholder branch).
    pub fn clear_series(&mut self) {
        self.series.clear();
    }

    // ── facett Facet contract ────────────────────────────────────────────────

    pub fn title(&self) -> &str {
        &self.title
    }

    pub fn state_json(&self) -> serde_json::Value {
        let latest = self.series.last().map(|s| {
            serde_json::json!({
                "date": s.date.to_string(),
                "counts": s.counts,
                "total": s.counts.values().sum::<usize>(),
            })
        });
        serde_json::json!({
            "title": self.title,
            "bands": self.bands.iter().map(|b| &b.id).collect::<Vec<_>>(),
            "snapshots": self.series.len(),
            "max_total": self.max_total(),
            "range": self.series.first().zip(self.series.last())
                .map(|(a, z)| serde_json::json!([a.date.to_string(), z.date.to_string()])),
            "latest": latest,
            // The stable UI ERROR CODE for the degraded/empty state (facett_core::errcode):
            // present so tests + consumers react to the CODE, not a matched string.
            "error_code": if self.series.len() < 2 {
                serde_json::json!("facet-cfd-2")
            } else {
                serde_json::Value::Null
            },
        })
    }

    pub fn ui(&mut self, ui: &mut Ui) {
        ui.heading(&self.title);
        ui.separator();
        if self.series.len() < 2 {
            // The degraded/empty state carries the stable UI error code facet-cfd-2
            // (facett_core::errcode) on its a11y atom so tests + consumers react to the
            // CODE, not a matched string.
            let resp = ui.label("Not enough data yet — need ≥2 daily snapshots.");
            resp.widget_info(|| {
                facett_core::a11y::Semantics::error("Not enough data yet — need ≥2 daily snapshots.")
                    .error_code("facet-cfd-2")
                    .widget_info()
            });
            return;
        }

        let n = self.series.len();
        let maxt = self.max_total() as f32;

        let width = ui.available_width().max(360.0);
        let height = 300.0_f32;
        let (rect, _r) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, 4.0, Color32::from_rgb(22, 24, 30));

        let pad = 28.0;
        let plot_l = rect.min.x + pad;
        let plot_r = rect.max.x - 8.0;
        let plot_t = rect.min.y + 8.0;
        let plot_b = rect.max.y - pad;
        let plot_w = plot_r - plot_l;
        let plot_h = plot_b - plot_t;

        let x_at = |i: usize| plot_l + (i as f32 / (n - 1) as f32) * plot_w;
        let y_at = |v: f32| plot_b - (v / maxt) * plot_h;

        // Precompute cumulative tops per snapshot per band (bottom→top).
        // tops[i][k] = cumulative count of bands[0..=k] at snapshot i.
        let mut tops: Vec<Vec<f32>> = Vec::with_capacity(n);
        for s in &self.series {
            let mut acc = 0.0;
            let mut row = Vec::with_capacity(self.bands.len());
            for b in &self.bands {
                acc += *s.counts.get(&b.id).unwrap_or(&0) as f32;
                row.push(acc);
            }
            tops.push(row);
        }

        // Draw each band as filled quads between consecutive snapshots.
        for (k, _band) in self.bands.iter().enumerate() {
            let color = self.band_color(k);
            for i in 0..(n - 1) {
                let x0 = x_at(i);
                let x1 = x_at(i + 1);
                let bot0 = if k == 0 { 0.0 } else { tops[i][k - 1] };
                let bot1 = if k == 0 { 0.0 } else { tops[i + 1][k - 1] };
                let top0 = tops[i][k];
                let top1 = tops[i + 1][k];
                let quad = vec![
                    Pos2::new(x0, y_at(bot0)),
                    Pos2::new(x0, y_at(top0)),
                    Pos2::new(x1, y_at(top1)),
                    Pos2::new(x1, y_at(bot1)),
                ];
                painter.add(egui::Shape::convex_polygon(quad, color, Stroke::NONE));
            }
        }

        // axes
        painter.line_segment([Pos2::new(plot_l, plot_t), Pos2::new(plot_l, plot_b)], Stroke::new(1.0_f32, Color32::from_rgb(60, 62, 70)));
        painter.line_segment([Pos2::new(plot_l, plot_b), Pos2::new(plot_r, plot_b)], Stroke::new(1.0_f32, Color32::from_rgb(60, 62, 70)));
        painter.text(Pos2::new(plot_l - 4.0, plot_t), egui::Align2::RIGHT_TOP, format!("{}", self.max_total()), egui::FontId::proportional(10.0), Color32::GRAY);
        painter.text(Pos2::new(plot_l, plot_b + 2.0), egui::Align2::LEFT_TOP, self.series.first().unwrap().date.to_string(), egui::FontId::proportional(9.0), Color32::GRAY);
        painter.text(Pos2::new(plot_r, plot_b + 2.0), egui::Align2::RIGHT_TOP, self.series.last().unwrap().date.to_string(), egui::FontId::proportional(9.0), Color32::GRAY);

        // legend
        ui.horizontal_wrapped(|ui| {
            for (k, b) in self.bands.iter().enumerate() {
                let (r, _resp) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover());
                ui.painter().rect_filled(r, 2.0, self.band_color(k));
                ui.label(egui::RichText::new(&b.label).small());
            }
        });
    }
}

// ── The host-facing `Facet` contract ───────────────────────────────────────────
//
// The inherent `title`/`state_json`/`ui` trio above already IS the contract; this
// thin adapter makes `Cfd` an object-safe `dyn Facet` so it composes into a
// `FacetDeck` and can be handed to `facett_core::testmatrix::probe_and_emit` for
// its robot matrix cell in ONE call.
impl facett_core::Facet for Cfd {
    fn title(&self) -> &str {
        Cfd::title(self)
    }
    fn ui(&mut self, ui: &mut Ui) {
        Cfd::ui(self, ui)
    }
    fn state_json(&self) -> serde_json::Value {
        Cfd::state_json(self)
    }
    /// STRUCTURAL severity (the Robot-UI gate signal): RED when there are fewer than
    /// 2 snapshots (the facet-cfd-2 degraded state — nothing to plot), Info otherwise.
    fn severity(&self) -> facett_core::Severity {
        if self.series.len() < 2 {
            facett_core::Severity::Error
        } else {
            facett_core::Severity::Info
        }
    }
    /// The **robot drive seam**: `{"snapshot":{"date":"2026-06-07","counts":{..}}}`
    /// appends a day, `"clear"` empties the series (the placeholder branch).
    fn update_json(&mut self, msg_json: &str) {
        let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
        if v == serde_json::json!("clear") {
            return self.clear_series();
        }
        let Some(s) = v.get("snapshot") else { return };
        let Some(date) = s.get("date").and_then(|d| d.as_str()).and_then(|d| d.parse::<NaiveDate>().ok())
        else {
            return;
        };
        let Some(counts) = s.get("counts").and_then(|c| c.as_object()) else { return };
        let counts = counts
            .iter()
            .filter_map(|(k, v)| v.as_u64().map(|n| (k.clone(), n as usize)))
            .collect();
        self.push_snapshot(Snapshot { date, counts });
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeMap;
    fn d(y: i32, m: u32, day: u32) -> NaiveDate {
        NaiveDate::from_ymd_opt(y, m, day).unwrap()
    }
    fn snap(date: NaiveDate, todo: usize, doing: usize, done: usize) -> Snapshot {
        let mut counts = BTreeMap::new();
        counts.insert("todo".into(), todo);
        counts.insert("doing".into(), doing);
        counts.insert("done".into(), done);
        Snapshot { date, counts }
    }
    fn cfd() -> Cfd {
        let mut c = Cfd::new("Flow", vec![Band::new("todo","To do"), Band::new("doing","Doing"), Band::new("done","Done")]);
        c.set_series(vec![
            snap(d(2026,6,3), 5, 2, 0),
            snap(d(2026,6,1), 8, 1, 0),   // out of order on purpose
            snap(d(2026,6,5), 3, 3, 4),
        ]);
        c
    }

    #[test]
    fn series_is_sorted_and_max_total_correct() {
        let c = cfd();
        assert_eq!(c.series.first().unwrap().date, d(2026,6,1)); // sorted
        // totals: 9, 7, 10 → max 10
        assert_eq!(c.max_total(), 10);
    }

    #[test]
    fn state_json_reports_latest_and_range() {
        let s = cfd().state_json();
        assert_eq!(s["snapshots"], 3);
        assert_eq!(s["max_total"], 10);
        assert_eq!(s["range"][0], "2026-06-01");
        assert_eq!(s["latest"]["total"], 10);
    }

    // ── band_color default cool→warm ramp ────────────────────────────────────

    #[test]
    fn band_color_uses_explicit_rgb_when_set() {
        let c = Cfd::new("x", vec![Band::new("a", "A").rgb(10, 20, 30), Band::new("b", "B")]);
        assert_eq!(c.band_color(0), Color32::from_rgb(10, 20, 30));
    }

    #[test]
    fn band_color_default_ramp_is_monotone_cool_to_warm() {
        // Three uncolored bands → the index ramp: red rises, blue falls.
        let c = Cfd::new("x", vec![Band::new("a", "A"), Band::new("b", "B"), Band::new("c", "C")]);
        let c0 = c.band_color(0);
        let c1 = c.band_color(1);
        let c2 = c.band_color(2);
        // index 0 → t=0 → (70, 150, 200)
        assert_eq!(c0, Color32::from_rgb(70, 150, 200));
        // red channel warms up, blue cools down as the index grows.
        assert!(c2.r() > c1.r() && c1.r() > c0.r(), "red ramps up with band index");
        assert!(c2.b() < c1.b() && c1.b() < c0.b(), "blue ramps down with band index");
    }

    // ── max_total empty-series clamp ─────────────────────────────────────────

    #[test]
    fn max_total_clamps_to_one_on_empty_series() {
        let c = Cfd::new("x", vec![Band::new("a", "A")]);
        assert_eq!(c.max_total(), 1, "empty series must clamp to 1 so y-scaling never divides by 0");
    }

    #[test]
    fn max_total_clamps_to_one_when_all_counts_zero() {
        let mut c = Cfd::new("x", vec![Band::new("a", "A")]);
        let mut z = BTreeMap::new();
        z.insert("a".to_string(), 0usize);
        c.set_series(vec![
            Snapshot { date: d(2026, 6, 1), counts: z.clone() },
            Snapshot { date: d(2026, 6, 2), counts: z },
        ]);
        assert_eq!(c.max_total(), 1);
    }

    // ── state_json with 0 / 1 snapshots (range=None, latest=None) ─────────────

    #[test]
    fn state_json_is_null_range_and_latest_with_zero_snapshots() {
        let c = Cfd::new("Flow", vec![Band::new("a", "A")]);
        let s = c.state_json();
        assert_eq!(s["snapshots"], 0);
        assert_eq!(s["max_total"], 1);
        assert!(s["range"].is_null(), "no snapshots → range is null");
        assert!(s["latest"].is_null(), "no snapshots → latest is null");
    }

    #[test]
    fn state_json_single_snapshot_has_latest_but_degenerate_range() {
        let mut c = Cfd::new("Flow", vec![Band::new("todo", "To do")]);
        let mut counts = BTreeMap::new();
        counts.insert("todo".to_string(), 4usize);
        c.set_series(vec![Snapshot { date: d(2026, 6, 1), counts }]);
        let s = c.state_json();
        assert_eq!(s["snapshots"], 1);
        // first.zip(last) → both the same single snapshot → range = [d, d].
        assert_eq!(s["range"][0], "2026-06-01");
        assert_eq!(s["range"][1], "2026-06-01");
        assert_eq!(s["latest"]["total"], 4);
    }
}