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);
}
}