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

nordisk/facett

facet-wrapped/src/charts.rs

//! **Charts, grids, calendars and cards** — the data views added to the registry on
//! 2026-09-28, each with a data seam (a registry entry without one is a brochure; see
//! `registry.rs`).
//!
//! Payload contracts, one per component, all small and all semantic (no colours):
//!
//! | name | payload |
//! |---|---|
//! | `grid` | the `table` contract: `{ columns, rows }` (array or object rows); cells keep their JSON type, so numbers sort as numbers |
//! | `boxplot`, `violin` | `{ groups: [{ name, values: [f64] }] }` or `{ groups: { name: [f64] } }` |
//! | `heatmap` | `{ rows: [label], cols: [label], data: [[f64]] }` |
//! | `treemap` | `{ tiles: [{ label, value }] }` |
//! | `candlestick` | `{ bars: [{ label, open, high, low, close }] }` |
//! | `cfd` | `{ bands: [{ id, label }], snapshots: [{ date: "YYYY-MM-DD", counts: { id: n } }] }` |
//! | `calendar` | `{ year, month, events: [{ date, label, kind: deadline\|frist\|absence\|appointment, case? }] }` |
//! | `card` | `{ id?, fields: [{ label, value, kind? }] }` — `kind` is `text\|number\|enum\|user\|date\|tags\|link\|progress`, inferred when absent |
//!
//! Every builder returns `None` when the payload holds nothing renderable, which the
//! registry turns into a typed refusal. None of them falls back to a fixture.

use facett_core::Facet;
use serde_json::Value;

use crate::registry::{DATA, Entry, Registry, UI};

/// Register the chart/grid/calendar/card family.
pub(crate) fn register(r: &mut Registry) {
    r.register(
        Entry::new("grid", "The Arrow-backed flagship grid: typed columns, sort, selection, frozen header.", || {
            Box::new(facett_grid::Grid::new("grid", facett_grid::GridData::new(Vec::new())))
        })
        .in_group(DATA)
        .with_data(grid),
    );
    r.register(
        Entry::new("boxplot", "Box plots: quartiles, whiskers and outliers per group.", || {
            Box::new(facett_plotters::BoxPlotView::new("boxplot", Vec::new()))
        })
        .in_group(DATA)
        .with_data(boxplot),
    );
    r.register(
        Entry::new("violin", "Violin plots: the value density per group.", || {
            Box::new(facett_plotters::ViolinView::new("violin", Vec::new()))
        })
        .in_group(DATA)
        .with_data(violin),
    );
    r.register(
        Entry::new("heatmap", "A labelled heatmap over a row × column grid of values.", || {
            Box::new(facett_plotters::HeatmapView::new("heatmap", 0, 0, Vec::new()))
        })
        .in_group(DATA)
        .with_data(heatmap),
    );
    r.register(
        Entry::new("treemap", "A squarified treemap: area is value.", || {
            Box::new(facett_plotters::TreemapView::new("treemap", Vec::new()))
        })
        .in_group(DATA)
        .with_data(treemap),
    );
    r.register(
        Entry::new("candlestick", "Open/high/low/close bars.", || {
            Box::new(facett_plotters::CandlestickView::new("candlestick", Vec::new()))
        })
        .in_group(DATA)
        .with_data(candlestick),
    );
    r.register(
        Entry::new("cfd", "A cumulative flow diagram: stacked status counts over time.", || {
            Box::new(facett_cfd::Cfd::local())
        })
        .in_group(DATA)
        .with_data(cfd),
    );
    r.register(
        Entry::new("calendar", "A Monday-first month grid with marked days (deadlines, absence).", || {
            Box::new(facett_calendar::Calendar::local())
        })
        .in_group(UI)
        .with_data(calendar),
    );
    r.register(
        Entry::new("card", "A record card: a typed field grid (text, enum, user, date, tags, link, progress).", || {
            Box::new(facett_card::CardView::new("card", facett_card::Card::new("card")))
        })
        .in_group(UI)
        .with_data(card),
    );
}

fn title_of(v: &Value, fallback: &str) -> String {
    v.get("title")
        .and_then(Value::as_str)
        .filter(|s| !s.trim().is_empty())
        .unwrap_or(fallback)
        .to_string()
}

fn num(v: Option<&Value>) -> Option<f64> {
    v.and_then(|x| x.as_f64().or_else(|| x.as_str().and_then(|s| s.trim().parse().ok())))
        .filter(|f| f.is_finite())
}

fn text(v: Option<&Value>) -> Option<String> {
    match v? {
        Value::String(s) => Some(s.clone()),
        Value::Null => None,
        other => Some(other.to_string()),
    }
}

// ── grid ────────────────────────────────────────────────────────────────────────

/// **`grid`** — [`facett_grid::Grid`]. A column is `Int` when every present cell is an
/// integer, `Float` when every present cell is a number, `Bool` when every one is a
/// bool, else `Text`. A missing or `null` cell is a null, not a zero.
#[must_use]
pub fn grid(v: &Value) -> Option<Box<dyn Facet>> {
    let rows = v.get("rows")?.as_array()?;
    let columns: Vec<String> = match v.get("columns").and_then(Value::as_array) {
        Some(c) => c.iter().filter_map(|x| text(Some(x))).collect(),
        None => match rows.first() {
            Some(Value::Object(m)) => m.keys().cloned().collect(),
            _ => return None,
        },
    };
    if columns.is_empty() || rows.is_empty() {
        return None;
    }
    let cell = |r: &Value, i: usize, name: &str| -> Value {
        match r {
            Value::Array(a) => a.get(i).cloned().unwrap_or(Value::Null),
            Value::Object(m) => m.get(name).cloned().unwrap_or(Value::Null),
            _ => Value::Null,
        }
    };
    let mut named = Vec::with_capacity(columns.len());
    for (i, name) in columns.iter().enumerate() {
        let cells: Vec<Value> = rows.iter().map(|r| cell(r, i, name)).collect();
        let present = || cells.iter().filter(|c| !c.is_null());
        let nulls: Vec<bool> = cells.iter().map(Value::is_null).collect();
        let col = if present().all(|c| c.is_i64()) {
            facett_grid::Column::Int { v: cells.iter().map(|c| c.as_i64().unwrap_or(0)).collect(), nulls }
        } else if present().all(Value::is_number) {
            facett_grid::Column::Float { v: cells.iter().map(|c| c.as_f64().unwrap_or(0.0)).collect(), nulls }
        } else if present().all(Value::is_boolean) {
            facett_grid::Column::Bool { v: cells.iter().map(|c| c.as_bool().unwrap_or(false)).collect(), nulls }
        } else {
            facett_grid::Column::Text { v: cells.iter().map(|c| text(Some(c)).unwrap_or_default()).collect(), nulls }
        };
        named.push((name.clone(), col));
    }
    Some(Box::new(facett_grid::Grid::new(title_of(v, "grid"), facett_grid::GridData::new(named))))
}

// ── plotters ────────────────────────────────────────────────────────────────────

/// `groups` as `[{ name, values }]` or `{ name: [values] }`; groups with no finite
/// value are dropped.
fn groups_of(v: &Value) -> Vec<(String, Vec<f64>)> {
    let vals = |a: &Value| -> Vec<f64> {
        a.as_array().map(|xs| xs.iter().filter_map(|x| num(Some(x))).collect()).unwrap_or_default()
    };
    let out: Vec<(String, Vec<f64>)> = match v.get("groups") {
        Some(Value::Array(gs)) => gs
            .iter()
            .filter_map(|g| Some((text(g.get("name"))?, vals(g.get("values")?))))
            .collect(),
        Some(Value::Object(m)) => m.iter().map(|(k, a)| (k.clone(), vals(a))).collect(),
        _ => Vec::new(),
    };
    out.into_iter().filter(|(_, xs)| !xs.is_empty()).collect()
}

/// **`boxplot`** — [`facett_plotters::BoxPlotView`].
#[must_use]
pub fn boxplot(v: &Value) -> Option<Box<dyn Facet>> {
    let g = groups_of(v);
    (!g.is_empty()).then(|| Box::new(facett_plotters::BoxPlotView::new(title_of(v, "boxplot"), g)) as Box<dyn Facet>)
}

/// **`violin`** — [`facett_plotters::ViolinView`].
#[must_use]
pub fn violin(v: &Value) -> Option<Box<dyn Facet>> {
    let g = groups_of(v);
    (!g.is_empty()).then(|| Box::new(facett_plotters::ViolinView::new(title_of(v, "violin"), g)) as Box<dyn Facet>)
}

/// **`heatmap`** — [`facett_plotters::HeatmapView`]. `data` is row-major rows; the
/// column count is the widest row, short rows are padded with NaN (drawn as empty).
#[must_use]
pub fn heatmap(v: &Value) -> Option<Box<dyn Facet>> {
    let data = v.get("data")?.as_array()?;
    let rows: Vec<Vec<f64>> = data
        .iter()
        .filter_map(|r| r.as_array().map(|c| c.iter().map(|x| num(Some(x)).unwrap_or(f64::NAN)).collect()))
        .collect();
    let ncols = rows.iter().map(Vec::len).max().unwrap_or(0);
    if rows.is_empty() || ncols == 0 {
        return None;
    }
    let mut flat = Vec::with_capacity(rows.len() * ncols);
    for r in &rows {
        flat.extend(r.iter().copied());
        flat.extend(std::iter::repeat_n(f64::NAN, ncols - r.len()));
    }
    let labels = |k: &str, n: usize| -> Vec<String> {
        let given: Vec<String> =
            v.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(|x| text(Some(x))).collect()).unwrap_or_default();
        (0..n).map(|i| given.get(i).cloned().unwrap_or_else(|| (i + 1).to_string())).collect()
    };
    let h = facett_plotters::HeatmapView::new(title_of(v, "heatmap"), rows.len(), ncols, flat)
        .with_labels(labels("rows", rows.len()), labels("cols", ncols));
    Some(Box::new(h))
}

/// **`treemap`** — [`facett_plotters::TreemapView`]. Non-positive values are dropped
/// (a tile's area is its value).
#[must_use]
pub fn treemap(v: &Value) -> Option<Box<dyn Facet>> {
    let tiles: Vec<facett_plotters::Tile> = v
        .get("tiles")?
        .as_array()?
        .iter()
        .filter_map(|t| {
            let value = num(t.get("value"))?;
            (value > 0.0).then(|| facett_plotters::Tile::new(text(t.get("label")).unwrap_or_default(), value))
        })
        .collect();
    (!tiles.is_empty())
        .then(|| Box::new(facett_plotters::TreemapView::new(title_of(v, "treemap"), tiles)) as Box<dyn Facet>)
}

/// **`candlestick`** — [`facett_plotters::CandlestickView`]. A bar missing any of
/// open/high/low/close is dropped, never zero-filled.
#[must_use]
pub fn candlestick(v: &Value) -> Option<Box<dyn Facet>> {
    let bars: Vec<facett_plotters::Ohlc> = v
        .get("bars")?
        .as_array()?
        .iter()
        .filter_map(|b| {
            Some(facett_plotters::Ohlc::new(
                text(b.get("label")).unwrap_or_default(),
                num(b.get("open"))?,
                num(b.get("high"))?,
                num(b.get("low"))?,
                num(b.get("close"))?,
            ))
        })
        .collect();
    (!bars.is_empty())
        .then(|| Box::new(facett_plotters::CandlestickView::new(title_of(v, "candlestick"), bars)) as Box<dyn Facet>)
}

// ── cfd / calendar / card ───────────────────────────────────────────────────────

fn date_of(v: Option<&Value>) -> Option<chrono::NaiveDate> {
    chrono::NaiveDate::parse_from_str(v?.as_str()?.get(..10)?, "%Y-%m-%d").ok()
}

/// **`cfd`** — [`facett_cfd::Cfd`]. Needs at least one band and one dated snapshot.
#[must_use]
pub fn cfd(v: &Value) -> Option<Box<dyn Facet>> {
    let bands: Vec<facett_cfd::Band> = v
        .get("bands")?
        .as_array()?
        .iter()
        .filter_map(|b| {
            let id = text(b.get("id"))?;
            let label = text(b.get("label")).unwrap_or_else(|| id.clone());
            Some(facett_cfd::Band::new(id, label))
        })
        .collect();
    if bands.is_empty() {
        return None;
    }
    let mut c = facett_cfd::Cfd::new(title_of(v, "cfd"), bands);
    let mut n = 0;
    for s in v.get("snapshots").and_then(Value::as_array).into_iter().flatten() {
        let Some(date) = date_of(s.get("date")) else { continue };
        let counts = s
            .get("counts")
            .and_then(Value::as_object)
            .map(|m| m.iter().filter_map(|(k, x)| Some((k.clone(), x.as_u64()? as usize))).collect())
            .unwrap_or_default();
        c.push_snapshot(facett_cfd::Snapshot { date, counts });
        n += 1;
    }
    (n > 0).then(|| Box::new(c) as Box<dyn Facet>)
}

fn event_kind(s: &str) -> Option<facett_calendar::EventKind> {
    use facett_calendar::EventKind as K;
    Some(match s.trim().to_ascii_lowercase().as_str() {
        "deadline" => K::Deadline,
        "frist" => K::Frist,
        "absence" | "leave" => K::Absence,
        "appointment" | "meeting" => K::Appointment,
        _ => return None,
    })
}

/// **`calendar`** — [`facett_calendar::Calendar`]. The month shown is `year`/`month`,
/// else the first event's month. Events with an unknown `kind` are dropped (a kind is
/// a meaning, not a colour, and guessing one would mark a day as the wrong thing).
#[must_use]
pub fn calendar(v: &Value) -> Option<Box<dyn Facet>> {
    let events: Vec<facett_calendar::Event> = v
        .get("events")?
        .as_array()?
        .iter()
        .filter_map(|e| {
            Some(facett_calendar::Event {
                date: date_of(e.get("date"))?,
                label: text(e.get("label")).unwrap_or_default(),
                kind: event_kind(e.get("kind")?.as_str()?)?,
                case: text(e.get("case")),
            })
        })
        .collect();
    let first = events.first()?;
    use chrono::Datelike as _;
    let year = v.get("year").and_then(Value::as_i64).map_or(first.date.year(), |y| y as i32);
    let month = v.get("month").and_then(Value::as_u64).filter(|m| (1..=12).contains(m)).map_or(first.date.month(), |m| m as u32);
    let mut c = facett_calendar::Calendar::new(title_of(v, "calendar"), year, month);
    c.set_events(events);
    Some(Box::new(c))
}

fn field_value(f: &Value) -> Option<facett_card::FieldValue> {
    use facett_card::FieldValue as F;
    let val = f.get("value")?;
    let kind = f.get("kind").and_then(Value::as_str).unwrap_or("");
    Some(match (kind, val) {
        ("number", x) => F::Number(num(Some(x))?),
        ("progress", x) => F::Progress(num(Some(x))?.clamp(0.0, 1.0) as f32),
        ("user", x) => F::User(text(Some(x))?),
        ("date", x) => F::Date(text(Some(x))?),
        ("tags", Value::Array(a)) | ("", Value::Array(a)) => F::Tags(a.iter().filter_map(|t| text(Some(t))).collect()),
        ("enum", Value::Object(m)) | ("", Value::Object(m)) if m.contains_key("state") => F::Enum {
            state: text(m.get("state"))?,
            options: m.get("options").and_then(Value::as_array).map(|a| a.iter().filter_map(|t| text(Some(t))).collect()).unwrap_or_default(),
        },
        ("link", Value::Object(m)) | ("", Value::Object(m)) if m.contains_key("url") => F::Link {
            label: text(m.get("label")).or_else(|| text(m.get("url")))?,
            url: text(m.get("url"))?,
        },
        ("", Value::Number(n)) => F::Number(n.as_f64()?),
        ("" | "text", x) => F::Text(text(Some(x))?),
        _ => return None,
    })
}

/// **`card`** — [`facett_card::CardView`]. Fields whose value does not fit their
/// declared `kind` are dropped.
#[must_use]
pub fn card(v: &Value) -> Option<Box<dyn Facet>> {
    let title = title_of(v, "card");
    let mut c = facett_card::Card::new(title.clone());
    if let Some(id) = text(v.get("id")) {
        c = c.with_id(id);
    }
    for f in v.get("fields")?.as_array()? {
        if let (Some(label), Some(value)) = (text(f.get("label")), field_value(f)) {
            c = c.field(facett_card::CardField::new(label, value));
        }
    }
    (!c.fields.is_empty()).then(|| Box::new(facett_card::CardView::new(title, c)) as Box<dyn Facet>)
}

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

    #[test]
    fn grid_types_its_columns_from_the_cells() {
        let f = grid(&json!({
            "title": "people",
            "columns": ["name", "age", "score", "ok"],
            "rows": [["a", 30, 1.5, true], ["b", null, 2, false]]
        }))
        .expect("builds");
        assert_eq!(f.title(), "people");
        let st = f.state_json();
        assert!(st.to_string().contains("age"), "{st}");
        assert!(grid(&json!({ "columns": ["a"], "rows": [] })).is_none());
        assert!(grid(&json!({ "rows": [[1]] })).is_none(), "array rows need declared columns");
    }

    #[test]
    fn plotters_build_from_groups_tiles_bars_and_grids() {
        let g = json!({ "groups": [{ "name": "a", "values": [1, 2, 3, 9] }, { "name": "empty", "values": [] }] });
        assert!(boxplot(&g).is_some());
        assert!(violin(&json!({ "groups": { "x": [1.0, 2.0, 2.5] } })).is_some());
        assert!(boxplot(&json!({ "groups": [] })).is_none());
        assert!(heatmap(&json!({ "rows": ["r1"], "cols": ["c1", "c2"], "data": [[1, 2], [3]] })).is_some());
        assert!(heatmap(&json!({ "data": [] })).is_none());
        assert!(treemap(&json!({ "tiles": [{ "label": "a", "value": 3 }, { "label": "neg", "value": -1 }] })).is_some());
        assert!(treemap(&json!({ "tiles": [{ "label": "neg", "value": -1 }] })).is_none(), "no positive area");
        let bars = json!({ "bars": [{ "label": "d1", "open": 1, "high": 3, "low": 0.5, "close": 2 }, { "label": "bad", "open": 1 }] });
        assert!(candlestick(&bars).is_some());
        assert!(candlestick(&json!({ "bars": [{ "open": 1 }] })).is_none());
    }

    #[test]
    fn cfd_calendar_and_card_build_and_refuse() {
        let c = json!({
            "bands": [{ "id": "todo" }, { "id": "done", "label": "Done" }],
            "snapshots": [{ "date": "2026-09-01", "counts": { "todo": 3, "done": 1 } }, { "date": "not a date" }]
        });
        assert!(cfd(&c).is_some());
        assert!(cfd(&json!({ "bands": [{ "id": "a" }], "snapshots": [] })).is_none());
        let cal = json!({ "events": [{ "date": "2026-10-03", "label": "hearing", "kind": "appointment" }, { "date": "2026-10-04", "kind": "party" }] });
        let f = calendar(&cal).expect("builds");
        assert!(f.state_json().to_string().contains("2026"), "{}", f.state_json());
        assert!(calendar(&json!({ "events": [{ "date": "2026-10-04", "kind": "party" }] })).is_none(), "unknown kind only");
        let card_p = json!({ "title": "case 7", "fields": [
            { "label": "status", "value": { "state": "open", "options": ["open", "closed"] } },
            { "label": "owner", "value": "alice", "kind": "user" },
            { "label": "done", "value": 0.4, "kind": "progress" },
            { "label": "tags", "value": ["a", "b"] },
            { "label": "bad", "value": "x", "kind": "number" }
        ]});
        let f = card(&card_p).expect("builds");
        assert_eq!(f.title(), "case 7");
        assert!(card(&json!({ "fields": [] })).is_none());
    }
}