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