facett-plotters/src/chart.rs
//! **Line · bar · scatter** — the everyday XY chart, one themeable Elm [`Facet`].
//! Data goes in as the simplest possible model — a `Vec` of named series, each a
//! `Vec<(x, y)>` — and everything else (axis bounds, gridlines, the legend, the
//! hover tooltip) is *derived*. The chart kind is a single field in the model, so
//! flipping line↔bar↔scatter is one `Msg` and is fully observable headlessly.
//!
//! Series are colour-coded from the [categorical palette](crate::palette) in fixed
//! slot order (a small validated set, consistent across light/dark themes) — never
//! a per-string hash — so identity reads the same every render and the colours stay
//! CVD-separable. The data→geometry math ([`ChartView::x_bounds`] /
//! [`ChartView::y_bounds`]) is pure and unit-tested with no GPU.
use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::{Elm, FacetCaps, Semantics, a11y_node, theme};
use serde::{Deserialize, Serialize};
use crate::palette;
/// Which mark the chart draws. One field in the model — switching is one [`ChartMsg`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChartKind {
/// Points joined by a 2px polyline per series (change over an ordered x).
#[default]
Line,
/// Grouped vertical bars, one cluster per category (point index), anchored to 0.
Bar,
/// Unconnected markers at each (x, y) — identity + correlation, no ordering.
Scatter,
}
impl ChartKind {
/// Stable lowercase tag (matches the serde representation) for `state_json`.
pub fn as_str(self) -> &'static str {
match self {
ChartKind::Line => "line",
ChartKind::Bar => "bar",
ChartKind::Scatter => "scatter",
}
}
}
/// One named data series: a label (for legend + a11y) and its `(x, y)` points.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Series {
pub label: String,
pub points: Vec<(f64, f64)>,
}
impl Series {
pub fn new(label: impl Into<String>, points: Vec<(f64, f64)>) -> Self {
Self { label: label.into(), points }
}
}
/// Persistent, observable interaction state (FC-3): the current chart kind and
/// which `(series, point)` the pointer last hovered. Serialized so it round-trips
/// through the headless JSON seam.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ChartState {
pub kind: ChartKind,
/// `(series index, point index)` under the pointer, or `None`.
pub hover: Option<(usize, usize)>,
}
/// FC-2 input surface. Externally-tagged serde so a robot posts JSON:
/// `{"set_kind":"bar"}` · `{"hover":[0,1]}` · `{"hover":null}`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChartMsg {
/// Switch the drawn mark (line ↔ bar ↔ scatter).
SetKind(ChartKind),
/// Set (or clear) the hovered `(series, point)`.
Hover(Option<(usize, usize)>),
}
/// FC-8: the chart does no I/O — an uninhabited effect.
#[derive(Clone, Debug)]
pub enum ChartEffect {}
/// A line/bar/scatter chart over a set of named series.
pub struct ChartView {
pub title: String,
pub series: Vec<Series>,
pub state: ChartState,
}
impl ChartView {
/// A new chart (defaults to [`ChartKind::Line`]).
pub fn new(title: impl Into<String>, series: Vec<Series>) -> Self {
Self { title: title.into(), series, state: ChartState::default() }
}
/// Start on a specific chart kind.
pub fn with_kind(mut self, kind: ChartKind) -> Self {
self.state.kind = kind;
self
}
/// FC-3: read the complete observable interaction state.
pub fn state(&self) -> &ChartState {
&self.state
}
/// FC-2/FC-8: the single mutation path. Apply one [`ChartMsg`]; no effects.
pub fn update(&mut self, msg: ChartMsg) -> Vec<ChartEffect> {
match msg {
ChartMsg::SetKind(k) => self.state.kind = k,
ChartMsg::Hover(h) => self.state.hover = h,
}
Vec::new()
}
/// The widest category count across all series (bar clusters / x tick count).
fn max_len(&self) -> usize {
self.series.iter().map(|s| s.points.len()).max().unwrap_or(0)
}
/// The x extent across every finite point. `None` when there is no data.
/// Bar charts ignore this (they lay out on category index), but it is still a
/// pure, testable read of the input.
pub fn x_bounds(&self) -> Option<(f64, f64)> {
fold_bounds(self.series.iter().flat_map(|s| s.points.iter().map(|p| p.0)))
}
/// The y extent across every finite point. For [`ChartKind::Bar`] the range is
/// extended to include the `0` baseline so bar heights read honestly.
pub fn y_bounds(&self) -> Option<(f64, f64)> {
let mut b = fold_bounds(self.series.iter().flat_map(|s| s.points.iter().map(|p| p.1)))?;
if self.state.kind == ChartKind::Bar {
b.0 = b.0.min(0.0);
b.1 = b.1.max(0.0);
}
Some(b)
}
/// CSV of every point (the `copy()` payload): `series,x,y`.
fn to_csv(&self) -> String {
let mut s = String::from("series,x,y\n");
for ser in &self.series {
for (x, y) in &ser.points {
s.push_str(&format!("{},{},{}\n", ser.label, x, y));
}
}
s
}
}
// ── typed copy (§16) — the chart dataset as CSV Text ──────────────────────────
impl CopySource for ChartView {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
let csv = self.to_csv();
(self.series.iter().any(|s| !s.points.is_empty())).then_some(ClipPayload::Text(csv))
}
}
impl ChartView {
/// FC-9 pure view: paints axes/gridlines, the series (per the stored kind),
/// legend, and a tooltip from the *persisted* hover — and **returns** the
/// hovered-point change as a `Msg`. It never mutates `self`.
fn render(&self, ui: &mut Ui) -> Vec<ChartMsg> {
let th = theme(ui);
let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
let (Some((xmin, xmax)), Some((ymin, ymax))) = (self.x_bounds(), self.y_bounds()) else {
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-plotters::ChartView::ui",
"ui_render",
self.series.is_empty() || self.max_len() == 0,
&format!("drew=empty_hint kind={} series={}", self.state.kind.as_str(), self.series.len()),
);
painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
return hover_msg(self.state.hover, None);
};
// Plot area — leave a left gutter for y labels and a bottom gutter for x.
let plot = Rect::from_min_max(
Pos2::new(rect.left() + 44.0, rect.top() + 10.0),
Pos2::new(rect.right() - 12.0, rect.bottom() - 24.0),
);
let yspan = (ymax - ymin).max(1e-9);
let y_of = |v: f64| plot.bottom() - ((v - ymin) / yspan) as f32 * plot.height();
// ── axes + horizontal gridlines with value ticks ─────────────────────
painter.line_segment([plot.left_bottom(), plot.right_bottom()], Stroke::new(1.0_f32, th.text_dim));
painter.line_segment([plot.left_top(), plot.left_bottom()], Stroke::new(1.0_f32, th.text_dim));
for i in 0..=4 {
let t = i as f64 / 4.0;
let v = ymin + t * (ymax - ymin);
let y = y_of(v);
painter.line_segment(
[Pos2::new(plot.left(), y), Pos2::new(plot.right(), y)],
Stroke::new(0.5_f32, th.text_dim.gamma_multiply(0.4)),
);
painter.text(
Pos2::new(plot.left() - 4.0, y),
Align2::RIGHT_CENTER,
format!("{v:.1}"),
FontId::proportional(9.0),
th.text_dim,
);
}
let hover = resp.hover_pos();
let base = ui.id().with("chart");
let mut new_hover: Option<(usize, usize)> = None;
match self.state.kind {
ChartKind::Bar => self.paint_bars(&painter, ui, base, plot, &y_of, hover, &mut new_hover, &th),
ChartKind::Line | ChartKind::Scatter => {
let xspan = (xmax - xmin).max(1e-9);
let x_of = |v: f64| plot.left() + ((v - xmin) / xspan) as f32 * plot.width();
self.paint_xy(&painter, ui, base, plot, &x_of, &y_of, hover, &mut new_hover, &th);
// x axis end labels.
painter.text(plot.left_bottom() + vec2(0.0, 12.0), Align2::LEFT_CENTER, format!("{xmin:.1}"), FontId::proportional(9.0), th.text_dim);
painter.text(plot.right_bottom() + vec2(0.0, 12.0), Align2::RIGHT_CENTER, format!("{xmax:.1}"), FontId::proportional(9.0), th.text_dim);
}
}
// ── legend (top-right) ───────────────────────────────────────────────
self.paint_legend(&painter, plot, &th);
// ── tooltip from the PERSISTED hover (keeps render pure) ──────────────
if let Some(hp) = hover
&& let Some((si, pi)) = self.state.hover
&& let Some((x, y)) = self.series.get(si).and_then(|s| s.points.get(pi))
{
let label = &self.series[si].label;
crate::boxplot::paint_tooltip(&painter, &th, hp, &format!("{label}\nx {x:.3}\ny {y:.3}"));
}
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-plotters::ChartView::ui",
"ui_render",
!self.series.is_empty() && self.max_len() > 0,
&format!("kind={} series={} points={}", self.state.kind.as_str(), self.series.len(), self.max_len()),
);
hover_msg(self.state.hover, new_hover)
}
/// Line/scatter marks in data space. `_ui`/`base` carry the a11y tree.
#[allow(clippy::too_many_arguments)]
fn paint_xy(
&self,
painter: &egui::Painter,
ui: &mut Ui,
base: egui::Id,
plot: Rect,
x_of: &dyn Fn(f64) -> f32,
y_of: &dyn Fn(f64) -> f32,
hover: Option<Pos2>,
new_hover: &mut Option<(usize, usize)>,
th: &facett_core::Theme,
) {
let scatter = self.state.kind == ChartKind::Scatter;
for (si, ser) in self.series.iter().enumerate() {
let color = palette::series_color(th, si);
let pts: Vec<Pos2> = ser.points.iter().map(|&(x, y)| Pos2::new(x_of(x), y_of(y))).collect();
if !scatter && pts.len() >= 2 {
for w in pts.windows(2) {
painter.line_segment([w[0], w[1]], Stroke::new(2.0_f32, color));
}
}
for (pi, &p) in pts.iter().enumerate() {
if scatter {
painter.circle_filled(p, 4.0, color);
} else {
painter.circle_filled(p, 3.0, color);
}
if let Some(hp) = hover && p.distance(hp) <= 8.0 {
*new_hover = Some((si, pi));
}
}
// FC-4: one queryable node per series over its centroid, carrying the
// point count as numeric_value; keyed on the (stable) series label.
if let Some(first) = pts.first() {
let last = *pts.last().unwrap();
let node = Rect::from_two_pos(*first, last).expand(4.0).intersect(plot);
a11y_node(
ui,
base,
&ser.label,
Sense::hover(),
node,
Semantics::image(ser.label.clone()).value(ser.points.len() as f64),
);
}
}
}
/// Grouped bars: one cluster per category (point index), one bar per series.
#[allow(clippy::too_many_arguments)]
fn paint_bars(
&self,
painter: &egui::Painter,
ui: &mut Ui,
base: egui::Id,
plot: Rect,
y_of: &dyn Fn(f64) -> f32,
hover: Option<Pos2>,
new_hover: &mut Option<(usize, usize)>,
th: &facett_core::Theme,
) {
let cats = self.max_len();
let n = self.series.len();
if cats == 0 || n == 0 {
return;
}
let slot = plot.width() / cats as f32;
let group_w = (slot * 0.8).min(slot - 2.0).max(1.0);
let bar_w = (group_w / n as f32).max(1.0);
let base_y = y_of(0.0);
for ci in 0..cats {
let cx = plot.left() + slot * (ci as f32 + 0.5);
for (si, ser) in self.series.iter().enumerate() {
let Some(&(_, y)) = ser.points.get(ci) else { continue };
let color = palette::series_color(th, si);
let left = cx - group_w * 0.5 + bar_w * si as f32;
let top = y_of(y);
let r = Rect::from_min_max(
Pos2::new(left + 1.0, top.min(base_y)),
Pos2::new(left + bar_w - 1.0, top.max(base_y)),
);
painter.rect_filled(r, egui::CornerRadius::same(1), color);
a11y_node(
ui,
base,
format!("{} · cat {ci}", ser.label),
Sense::hover(),
r,
Semantics::image(format!("{} · {ci}", ser.label)).value(y),
);
if let Some(hp) = hover && r.contains(hp) {
*new_hover = Some((si, ci));
}
}
}
}
/// Legend: one swatch + label per series, top-right, in slot order. Text wears
/// the theme's ink; only the swatch carries the series colour (dataviz rule).
fn paint_legend(&self, painter: &egui::Painter, plot: Rect, th: &facett_core::Theme) {
if self.series.len() < 2 {
return; // a single series is named by the title — no legend box.
}
let mut y = plot.top() + 6.0;
let x = plot.right() - 120.0;
for (si, ser) in self.series.iter().enumerate() {
let color = palette::series_color(th, si);
let sw = Rect::from_min_size(Pos2::new(x, y), vec2(10.0, 10.0));
painter.rect_filled(sw, egui::CornerRadius::same(2), color);
painter.text(Pos2::new(x + 14.0, y + 5.0), Align2::LEFT_CENTER, &ser.label, FontId::proportional(10.0), th.text);
y += 15.0;
}
}
}
/// Fold an iterator of scalars into `(min, max)` over finite values only.
fn fold_bounds(it: impl Iterator<Item = f64>) -> Option<(f64, f64)> {
let mut r: Option<(f64, f64)> = None;
for x in it {
if x.is_finite() {
r = Some(match r {
None => (x, x),
Some((lo, hi)) => (lo.min(x), hi.max(x)),
});
}
}
r
}
/// Emit a `Hover` message only when the hovered point actually changed.
fn hover_msg(current: Option<(usize, usize)>, next: Option<(usize, usize)>) -> Vec<ChartMsg> {
if current != next { vec![ChartMsg::Hover(next)] } else { Vec::new() }
}
impl Elm for ChartView {
type Model = ChartState;
type Msg = ChartMsg;
type Effect = ChartEffect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &ChartState {
&self.state
}
fn update(&mut self, msg: ChartMsg) -> Vec<ChartEffect> {
ChartView::update(self, msg)
}
fn view(&self, ui: &mut Ui) -> Vec<ChartMsg> {
self.render(ui)
}
}
// Form 3 (`custom_state_json`): the chart publishes a RICHER state than the raw
// model — derived axis bounds + a per-series summary a robot reads without a GPU —
// and routes `update_json` straight through `update` (so `{"set_kind":"bar"}`
// drives it headlessly).
facett_core::impl_facet_via_elm!(ChartView, custom_state_json, {
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().copyable().resizable()
}
fn copy(&mut self) -> Option<String> {
Some(self.to_csv())
}
fn kind(&self) -> &'static str {
"plotters-chart"
}
fn update_json(&mut self, msg_json: &str) {
if let Ok(msg) = serde_json::from_str::<ChartMsg>(msg_json) {
let _ = <Self as facett_core::Elm>::update(self, msg);
}
}
fn state_json(&self) -> serde_json::Value {
let (xmin, xmax) = self.x_bounds().unzip();
let (ymin, ymax) = self.y_bounds().unzip();
serde_json::json!({
"kind": self.state.kind.as_str(),
"series_count": self.series.len(),
"hover": self.state.hover,
"x_bounds": [xmin, xmax],
"y_bounds": [ymin, ymax],
"series": self.series.iter().map(|s| serde_json::json!({
"label": s.label, "points": s.points.len(),
})).collect::<Vec<_>>(),
})
}
});
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> ChartView {
ChartView::new(
"Sales",
vec![
Series::new("north", vec![(0.0, 1.0), (1.0, 3.0), (2.0, 2.0)]),
Series::new("south", vec![(0.0, 2.0), (1.0, 1.0), (2.0, 4.0)]),
],
)
}
#[test]
fn x_and_y_bounds_span_all_series() {
let v = sample();
assert_eq!(v.x_bounds(), Some((0.0, 2.0)));
assert_eq!(v.y_bounds(), Some((1.0, 4.0)));
}
#[test]
fn bar_bounds_include_zero_baseline() {
let v = ChartView::new("t", vec![Series::new("a", vec![(0.0, 5.0), (1.0, 9.0)])])
.with_kind(ChartKind::Bar);
// min extends down to the 0 baseline even though the data starts at 5.
assert_eq!(v.y_bounds(), Some((0.0, 9.0)));
}
#[test]
fn bounds_ignore_nonfinite_and_empty() {
assert_eq!(ChartView::new("t", vec![]).x_bounds(), None);
let v = ChartView::new("t", vec![Series::new("a", vec![(f64::NAN, 1.0), (2.0, f64::INFINITY)])]);
assert_eq!(v.x_bounds(), Some((2.0, 2.0)));
assert_eq!(v.y_bounds(), Some((1.0, 1.0)));
}
#[test]
fn csv_has_header_and_a_row_per_point() {
let v = sample();
let csv = v.to_csv();
assert!(csv.starts_with("series,x,y\n"));
assert_eq!(csv.lines().count(), 7); // header + 6 points
}
#[test]
fn typed_copy_is_the_dataset_csv() {
use facett_core::clip::{ClipKind, CopySource};
let v = sample();
let p = v.copy_payload().expect("non-empty chart copies");
assert_eq!(p.kind(), ClipKind::Text);
assert_eq!(p.as_text(), v.to_csv());
// an all-empty chart yields nothing.
assert!(ChartView::new("t", vec![Series::new("a", vec![])]).copy_payload().is_none());
}
// ── Elm (FC-2/FC-3) harness properties ────────────────────────────────────
#[test]
fn switching_kind_is_observable() {
let mut v = sample();
assert_eq!(v.state().kind, ChartKind::Line);
let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::SetKind(ChartKind::Bar)]);
assert_eq!(snap.kind, ChartKind::Bar, "the kind switch is observable in the snapshot");
let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::SetKind(ChartKind::Scatter)]);
assert_eq!(snap.kind, ChartKind::Scatter);
}
#[test]
fn hover_transitions_are_observable() {
let mut v = sample();
let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::Hover(Some((1, 2)))]);
assert_eq!(snap.hover, Some((1, 2)));
let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::Hover(None)]);
assert_eq!(snap.hover, None);
}
#[test]
fn drive_produces_no_effects() {
let mut v = sample();
let fx = facett_core::harness::drive(
&mut v,
[ChartMsg::SetKind(ChartKind::Bar), ChartMsg::Hover(Some((0, 0)))],
);
assert!(fx.is_empty(), "the chart does no I/O");
}
#[test]
fn msg_round_trips_through_json() {
// the exact JSON shapes a robot posts through update_json.
let m: ChartMsg = serde_json::from_str(r#"{"set_kind":"scatter"}"#).unwrap();
assert_eq!(m, ChartMsg::SetKind(ChartKind::Scatter));
let m: ChartMsg = serde_json::from_str(r#"{"hover":[0,1]}"#).unwrap();
assert_eq!(m, ChartMsg::Hover(Some((0, 1))));
let m: ChartMsg = serde_json::from_str(r#"{"hover":null}"#).unwrap();
assert_eq!(m, ChartMsg::Hover(None));
}
#[test]
fn state_serde_round_trips() {
let st = ChartState { kind: ChartKind::Bar, hover: Some((2, 3)) };
let back: ChartState = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
assert_eq!(st, back);
}
}