Charting (plot + plotters + pop progress) — guide
Class:
guide· audience: a techie wiring up or driving a chart / progress component. The what is inplot-design.md; the why inplot-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:
- Build it with its constructor (
PlotView::line,BoxPlotView::new, …) from your data. - Host it — push it into a
FacetDeck(seefacet-guide.md), or call itsview/render/uidirectly inside your ownegui::Ui. - Feed it — every observable change is a
Msg; apply it throughupdate(or let the macro-generatedFacet::uiloop do it for you each frame). - Assert it headless —
facett_core::harness::{drive, snapshot, snapshot_json, headless_render}drives aVec<Msg>script and reads back theModel/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