Charting (plot + plotters + pop progress) — design
Class:
design· Function axis:f0the charting subsystem (the whole) +f1PlotView(line/bars overegui_plot) ·f2the plotters-backed statistical charts ·f3ProgressView(labelled progress). Strict: this file states only the shape we run. Every why and every rejected shape lives inplot-reasoning.md; the how-to isplot-guide.md. The internal component loop (Model/Msg/update/pureview) each of these implements is a separate function — seeelm-design.md.Facetis the host-facing side the macro writes from thatElmimpl — seefacet-design.md.
f0 — the charting subsystem (the whole)
Three sibling crates cover "put numbers on the screen": facett-plot (interactive line/bar charts wrapping [egui_plot]), facett-plotters (the five statistical chart types egui_plot has no widget for — box, violin, heatmap, candlestick, treemap), and facett-pop (a labelled progress viewer — not a chart of data, but the same "N numbers, drawn, observable" shape). All three are workspace members (Cargo.toml:3: facett-plot, facett-plotters, facett-pop) and all three are Elm components bridged to Facet via impl_facet_via_elm!.
| crate | gives | state IS | Effect |
facett-plot |
PlotView — line or bar chart of named (x,y) series |
PlotState — kind/series/viewport/hidden (facett-plot/src/lib.rs:90) |
uninhabited (lib.rs:134) |
facett-plotters |
BoxPlotView, ViolinView, HeatmapView, CandlestickView, TreemapView |
per-view {Name}State { hover } |
uninhabited, per view |
facett-pop |
ProgressView — phase + labelled done/total rows |
the view struct itself (facett-pop/src/lib.rs:73, state(&self) -> &ProgressView returns self at lib.rs:207) |
uninhabited (lib.rs:66) |
Every view in the subsystem declares the same capability set — FacetCaps::NONE.themeable().copyable().resizable() (caps.rs:44 for NONE; .themeable() caps.rs:94, .resizable() caps.rs:98, .copyable() caps.rs:76) — and none declares .scalable()/.selectable(), even where a view holds a zoom viewport (facett-plot/src/lib.rs:405-409) or a hovered index (every facett-plotters view). Every view is Form 3 of the bridge macro (impl_facet_via_elm!(View, custom_state_json, { … }), elm.rs:131): each publishes a richer state_json than plain serde(state()) — counts, roll-ups, or derived summaries — so the macro's default is suppressed in every one of the seven views.
Reuse map (what the charting subsystem uses): facett-plot depends on egui_plot; facett-plotters paints straight on egui::Painter with no chart-library dependency (facett-plotters/src/lib.rs:34-39 — the module list is boxplot, candlestick, heatmap, ramp, treemap, violin, no plotters crate); facett-pop depends on plain egui widgets (ProgressBar, ScrollArea). All three depend on facett-core for Elm, the macro, theme, a11y_node/Semantics/stable_id, and the clip::CopySource trait. Every view is used by whatever FacetDeck (facet-design.md f4) hosts it, and is drivable headless by the facett-core::harness seam (elm-design.md f3) exactly like every other Elm component.
f1 — PlotView (line / bar over egui_plot)
PlotView (facett-plot/src/lib.rs:137) wraps [egui_plot] for the two chart kinds it already does well: a line chart and a bar chart of named (x, y) series.
State — PlotState (lib.rs:91): kind: ChartKind (Line/Bars, lib.rs:34), series: Vec<Series> (lib.rs:51, each a name + Vec<(f64, f64)>), viewport: Option<Viewport> (lib.rs:62, None = auto-fit), hidden: Vec<String> (series names toggled off, keyed by name not index).
Msg (lib.rs:115): SetKind, SetViewport(Viewport), ResetViewport, ToggleSeries(String), ShowAllSeries. update (lib.rs:183) is the sole mutation path: SetViewport normalises reversed min/max pairs and widens a zero-span window by a relative epsilon (lib.rs:196-198) so a degenerate zoom never collapses the plot.
View (lib.rs:253) paints a legend row (a visibility checkbox per series + a "Fit" reset button) then the egui_plot::Plot itself; it pushes the model's viewport into the widget only when it differs from the widget's current bounds by more than a relative epsilon (Viewport::approx_eq, lib.rs:79), and mirrors a live drag/scroll/pinch gesture back out as one Msg::SetViewport per frame (lib.rs:314-321) — egui_plot owns the continuous gesture in its own widget memory, so this per-frame mirror is the observable form of it. An idle, gesture-free frame never emits SetViewport (lib.rs:315-316), so auto-fit stays auto-fit until something actually drags/zooms.
Copy — CopySource (lib.rs:234): the chart exports as a TSV rectangle (series\tx\ty, one row per point, all series regardless of visibility, to_tsv lib.rs:221).
state_json (lib.rs:380, custom form): kind (tag string), series[] as {name, points: <count>, visible} (points is a count, not the raw array), viewport, hidden. portable_state/load_state (lib.rs:414, 422) round-trip kind + viewport + hidden — never the series data, which the host owns.
f2 — the plotters-backed statistical charts
facett-plotters covers the five chart types egui_plot has no widget for. Each is a self-contained module (boxplot.rs, candlestick.rs, heatmap.rs, treemap.rs, violin.rs) sharing one shape:
- a pure, independently-tested data→geometry function (no painter, no
eguidependency in its signature); - a
{Name}State { hover: … }(FC-3) holding only the pointer's last hit-test result; - a
{Name}Msg::Hover(Option<…>)(FC-2), the only input; - an uninhabited
{Name}Effect(FC-8); - a
render/view(FC-9) that paints from&self, hit-tests the current pointer position against geometry computed fresh, but paints its tooltip from the persistedstate.hover(last frame's), and returns aHoverMsgonly when the hit-tested index actually changed (hover_msg, one instance per module, e.g.boxplot.rs:301); CopySource(clip.rs:131) exporting the underlying dataset as CSV text via ato_csv;caps() = FacetCaps::NONE.themeable().copyable().resizable()and a customstate_json(impl_facet_via_elm!(View, custom_state_json, { … })).
| view | pure geometry fn | hover key | per-row/cell CSV |
BoxPlotView (boxplot.rs:106) |
Quartiles::compute/from_sorted (boxplot.rs:29,44) |
Option<usize> (group index) |
group,min,q1,median,q3,max,outliers (boxplot.rs:157) |
CandlestickView (candlestick.rs:96) |
OhlcLayout (candlestick.rs:37: x_center,y_of,bar_at_x) |
Option<usize> (bar index) |
label,open,high,low,close (candlestick.rs:133) |
HeatmapView (heatmap.rs:35) |
cell_rect (heatmap.rs:51) |
Option<(usize,usize)> (row,col) |
one header row of col_labels + one row per row_labels (heatmap.rs:119) |
TreemapView (treemap.rs:101) |
squarify (treemap.rs:31) |
Option<usize> (tile's original index) |
label,value (treemap.rs:136) |
ViolinView (violin.rs:98) |
kde (violin.rs:40) |
Option<(usize, f64)> (group index, cursor value) |
group,n,median,peak_density (violin.rs:149) |
BoxPlotView. Quartiles::from_sorted (boxplot.rs:44) computes q1/median/q3 by linear-interpolated order statistics ("type-7", percentile_sorted boxplot.rs:71), the 1.5·IQR fences (boxplot.rs:51-52), and the outlier set beyond them. The whisker ends snap to the most extreme in-fence data point (boxplot.rs:54-55), not the fence value itself.
CandlestickView. Ohlc (candlestick.rs:15) is one label/open/high/low/close bar; bullish() (candlestick.rs:28) is close >= open. OhlcLayout (candlestick.rs:37) is pure price→pixel geometry: x_center(i), y_of(price), bar_at_x(x). A doji (open == close) body is given a minimum 1px height so it still renders (candlestick.rs:210-214).
HeatmapView. data: Vec<f64> is row-major (data[r * cols + c], heatmap.rs:39, get at heatmap.rs:98). cell_rect (heatmap.rs:51) is the pure grid-tiling geometry. Colour comes from a Ramp (f2 below) — sequential by default, diverging when HeatmapView::diverging is set (heatmap.rs:92) — plus a 48-step gradient legend painted alongside the grid (heatmap.rs:207-223).
TreemapView. Tile { label, value } clamps negative weights to 0.0 at construction (treemap.rs:24). squarify (treemap.rs:31) is a deterministic recursive slice-and-dice: at each step it takes a prefix of items summing to ~half the remaining weight, splits the current rect along its longer side in that proportion, and recurses on each half (layout_slice, treemap.rs:45-78) — zero/negative-weight tiles are dropped before laying out. Tile fill colour comes from a sequential Ramp normalised over the tile values (treemap.rs:161,187); label text picks black or white by background luminance (contrast_text, treemap.rs:282).
ViolinView. kde (violin.rs:40) is a Gaussian-kernel density estimate sampled on an evenly spaced grid, bandwidth from silverman_bandwidth (violin.rs:27: 1.06·σ·n^(-1/5), floored at a positive fallback for degenerate samples). Density { grid, dens, peak } (violin.rs:15) is the pure result; the view mirrors it into a filled polygon (right edge down, left edge up, violin.rs:206-214) and overlays the group's median as a horizontal line (violin.rs:236-244). The KDE + the median (Quartiles::compute(vals)?.median, violin.rs:130) are combined per group in densities() (violin.rs:125).
ramp.rs — theme-derived colour. Ramp { stops: [Color32; 3] } (ramp.rs:16) is built purely from a Theme: sequential (ramp.rs:24) is bg→accent→glow, diverging (ramp.rs:33) is accent→text_dim→glow. Ramp::at(t) (ramp.rs:38) piecewise-lerps across the two halves; normalize(v, min, max) (ramp.rs:63) maps a value into 0..=1, returning 0.5 for a degenerate (zero-span) range rather than dividing by zero. No chart in this crate hardcodes a palette — every fill comes from a Ramp built from the active Theme, or (boxplot/violin group colours) from facett_core::hash_color(name) (facett-core/src/lib.rs:910).
f3 — ProgressView (labelled progress, pop)
ProgressView (facett-pop/src/lib.rs:73) is a phase label plus a list of ProgressItem { label, done, total } (lib.rs:26) rows — "N things, each done/total, under a phase". Unlike the chart views it is its own Model: Elm::state(&self) returns self directly (lib.rs:206-208), since the whole struct is already Serialize + Deserialize + Clone + PartialEq (lib.rs:72).
Msg (lib.rs:47): SetPhase(String), SetItems(Vec<ProgressItem>), Upsert { label, done, total } (insert-or-update by label), Advance { label, delta } (saturating, clamped to the row's total), Remove(label), Clear. update (lib.rs:104) is the sole mutation path — there is no canvas gesture, so this Msg vocabulary is the data-feed surface a host or a headless driver writes progress through.
Derived reads: done()/total() sum every row (lib.rs:83-88); fraction() (lib.rs:89) divides them; is_idle() (lib.rs:97) is true when every row has done >= total (an empty set is idle) — a typed quiescence flag a caller polls instead of parsing the free-text phase.
View (lib.rs:143) paints the phase header + an aggregate egui::ProgressBar, then one ProgressBar per row inside a ScrollArea, each keyed by facett_core::stable_id(base_id, &it.label) (facett-core/src/a11y.rs:95, lib.rs:156) and carrying an explicit Semantics::new(WidgetType::ProgressIndicator, label).value(fraction) AccessKit node (lib.rs:162-166) — a robot driver finds a specific row by label and reads its numeric progress. view returns no Msgs (lib.rs:144, 177): facett-pop is a fed display with no interaction to translate.
Copy — CopySource (lib.rs:182): as_text() (lib.rs:129) renders the phase header
- one
label: done / totalline per row.
state_json (lib.rs:229, custom form): phase, done, total, fraction, idle, items[] as {label, done, total}.