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

nordisk/facett

.nornir/plot-guide.md

Charting (plot + plotters + pop progress) — guide

Class: guide · audience: a techie wiring up or driving a chart / progress component. The what is in plot-design.md; the why in plot-reasoning.md. This file is the workflow.

f0 — the end-to-end workflow

Every view in this subsystem (PlotView, the five facett-plotters views, ProgressView) is an Elm component, so the same four moves put a chart on screen and under headless test:

  1. Build it with its constructor (PlotView::line, BoxPlotView::new, …) from your data.
  2. Host it — push it into a FacetDeck (see facet-guide.md), or call its view/render/ui directly inside your own egui::Ui.
  3. Feed it — every observable change is a Msg; apply it through update (or let the macro-generated Facet::ui loop do it for you each frame).
  4. Assert it headless — facett_core::harness::{drive, snapshot, snapshot_json, headless_render} drives a Vec<Msg> script and reads back the Model/state_json, no window needed.

facett-demo wires all seven views this way: facett-demo/src/lib.rs:504-534 builds a ProgressView, a PlotView, and one of each facett-plotters view from synthetic fleet data, and registers each under a LEGEND entry (lib.rs:125-131) so the deck's "pop", "plot", "boxplot", "violin", "heatmap", "treemap", "candles" tabs each show one.

f1 — using PlotView

use facett_plot::{PlotView, Msg, ChartKind, Viewport};

let mut plot = PlotView::line("throughput", vec![
    ("ops/s".into(), vec![(0.0, 1.0), (1.0, 2.0), (2.0, 4.0)]),
    ("errors".into(), vec![(0.0, 0.0), (1.0, 1.0)]),
]);

// Host it: inside an egui frame, the macro-written `Facet::ui` runs
// `for m in plot.view(ui) { plot.update(m); }` for you.

// Or drive it directly (headless or from a CLI/robot):
plot.update(Msg::SetKind(ChartKind::Bars));
plot.update(Msg::SetViewport(Viewport { x_min: 0.0, x_max: 2.0, y_min: 0.0, y_max: 5.0 }));
plot.update(Msg::ToggleSeries("errors".into()));

let tsv = plot.to_tsv();      // spreadsheet-pasteable dump of every series

Switching to a bar chart of the same data is PlotView::bars(title, series) instead of ::line. ResetViewport drops back to auto-fit; ShowAllSeries clears every hidden name.

f2 — using the plotters charts

All five share the same {Name}Msg::Hover(Option<…>) shape, so the workflow is identical across them — only the constructor and the hover key type change:

use facett_plotters::{BoxPlotView, BoxPlotMsg};

let mut bp = BoxPlotView::new("scores", vec![
    ("team-a".into(), vec![62.0, 71.0, 88.0, 91.0, 55.0]),
    ("team-b".into(), vec![70.0, 74.0, 76.0, 80.0]),
]);
bp.update(BoxPlotMsg::Hover(Some(0)));   // as a robot/host would after a hit-test
let summaries = bp.summaries();          // Vec<(String, Quartiles)> — the five-number stats
let csv = bp.copy_payload();             // the same dataset, as CSV text

The other four follow the same pattern with their own Msg/state shape:

view constructor hover Msg payload
ViolinView ViolinView::new(title, groups) ViolinMsg::Hover(Option<(usize, f64)>)
HeatmapView HeatmapView::new(title, rows, cols, data) (.with_labels(...), .diverging()) HeatmapMsg::Hover(Option<(usize, usize)>)
TreemapView TreemapView::new(title, tiles) TreemapMsg::Hover(Option<usize>)
CandlestickView CandlestickView::new(title, bars) CandlestickMsg::Hover(Option<usize>)

Each view's data→geometry math is a plain function you can call without any egui context — useful for a numeric-only test or a CLI report: Quartiles::compute(&values), facett_plotters::violin::kde(&values, bins), facett_plotters::treemap::squarify(area, &weights), facett_plotters::heatmap::cell_rect(grid, rows, cols, r, c), OhlcLayout::new(plot, price_min, price_max, count).

f3 — using ProgressView

ProgressView has no canvas gestures — its Msg vocabulary is the feed a loader writes progress through:

use facett_pop::{ProgressView, Msg};

let mut pop = ProgressView::new("ingest");
pop.update(Msg::SetPhase("loading".into()));
pop.update(Msg::Upsert { label: "Person".into(), done: 0, total: 100 });
pop.update(Msg::Advance { label: "Person".into(), delta: 30 });   // 0 -> 30, clamped to total
pop.update(Msg::Upsert { label: "Company".into(), done: 10, total: 10 });

assert!(!pop.is_idle());   // Person is still short of its total
pop.update(Msg::Advance { label: "Person".into(), delta: 999 }); // clamps to 100
assert!(pop.is_idle());    // every row is done() >= total() — poll this instead of a sleep

is_idle() is the typed quiescence check: poll it instead of a fixed-delay sleep when a test needs to wait for a background load to finish.

Headless harness seam (the whole point)

Every view here is driven the same way through facett_core::harness, no window/GPU:

use facett_core::harness;

let snap = harness::snapshot(&mut pop, [
    Msg::SetPhase("loading".into()),
    Msg::Upsert { label: "Person".into(), done: 50, total: 100 },
]);
assert_eq!(snap.phase, "loading");

let effects = harness::drive(&mut plot, [Msg::SetKind(ChartKind::Bars)]);
assert!(effects.is_empty());   // FC-8: none of these views do I/O

assert!(harness::headless_render(&mut bp).drew());   // paints off-screen, asserts geometry landed

state_json() (via facett_core::Facet) is the introspection surface a deck / robot reads without touching Msg at all — every view's custom state_json (kind, counts, roll-ups) is what facett-demo's $APP_STATE check reads per tab (facett-demo/src/lib.rs:1397).

Test commands, one crate at a time, no extra feature flags needed (testmatrix is optional and off by default in all three):

cargo test -p facett-plot
cargo test -p facett-plotters
cargo test -p facett-pop