facett-plot/src/lib.rs
//! **facett-plot** — a **chart** component (line / bars) wrapping
//! [`egui_plot`]. A [`Facet`]: hand it named series of `(x, y)` points and it
//! draws an interactive plot (zoom/pan + per-series visibility toggles). For the
//! warehouse bar charts, bench trends, etc.
//!
//! Two halves, like the rest of facett:
//! - [`PlotState`] — the pure, serializable observable state (kind + series +
//! the zoom **viewport** + which series are **hidden**), round-trippable so the
//! headless [`Elm`](facett_core::Elm) harness can snapshot it.
//! - [`PlotView`] — the [`Facet`](facett_core::Facet) that paints it. The FC-9 pure
//! [`view`](PlotView::view) reads the state and **returns** the [`Msg`]s a frame's
//! interactions produced (drag/scroll → viewport, legend checkbox → visibility,
//! Fit → reset); the FC-2 [`update`](PlotView::update) is the **only** path that
//! mutates the state. So a headless `Vec<Msg>` reproduces the live transition.
//!
//! ## Residual impurity (honest)
//! `egui_plot` owns the *live* continuous pan/zoom gesture in its own widget
//! memory. The view mirrors the resulting bounds back into [`PlotState::viewport`]
//! as a per-frame [`Msg::SetViewport`] — so a **continuous** drag shows up as a
//! *stream* of discrete `SetViewport` messages, one per frame, rather than a single
//! atomic gesture. Programmatic control ([`Msg::SetViewport`] / [`Msg::ResetViewport`]
//! from a robot/CLI/host toolbar) is exact and fully headless-testable; the
//! frame-by-frame drag mirror is the one place the discrete-`Msg` model is a lossy
//! approximation of a mouse gesture.
use egui_plot::PlotBounds;
use facett_core::clip::{ClipKind, ClipPayload, CopySource};
use facett_core::FacetCaps;
use serde::{Deserialize, Serialize};
/// Line chart or bar chart.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChartKind {
Line,
Bars,
}
impl ChartKind {
/// The stable state_json tag (`"line"` / `"bars"`).
pub fn tag(self) -> &'static str {
match self {
ChartKind::Line => "line",
ChartKind::Bars => "bars",
}
}
}
/// One named series of `(x, y)` points.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Series {
/// Legend name (also the stable visibility key).
pub name: String,
/// The `(x, y)` points.
pub points: Vec<(f64, f64)>,
}
/// An explicit zoom/pan window in **plot coordinates** — the observable form of
/// what would otherwise be hidden inside `egui_plot`'s widget memory (FC-3). `None`
/// on [`PlotState::viewport`] means "auto-fit to the data".
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Viewport {
pub x_min: f64,
pub x_max: f64,
pub y_min: f64,
pub y_max: f64,
}
impl Viewport {
fn to_bounds(self) -> PlotBounds {
PlotBounds::from_min_max([self.x_min, self.y_min], [self.x_max, self.y_max])
}
fn from_bounds(b: &PlotBounds) -> Self {
let (mn, mx) = (b.min(), b.max());
Self { x_min: mn[0], y_min: mn[1], x_max: mx[0], y_max: mx[1] }
}
/// Same window to within a small relative epsilon (avoids a 1-frame push/echo
/// oscillation between the model and the widget).
fn approx_eq(&self, o: &Viewport) -> bool {
let close = |a: f64, b: f64| (a - b).abs() <= 1e-6 * (1.0 + a.abs().max(b.abs()));
close(self.x_min, o.x_min) && close(self.x_max, o.x_max) && close(self.y_min, o.y_min) && close(self.y_max, o.y_max)
}
}
/// **The complete observable state (FC-1 / FC-3)** of a [`PlotView`], in one
/// serializable, round-trippable struct: the chart [`kind`](ChartKind), the named
/// [`series`](Series), the optional zoom [`viewport`](Viewport), and the set of
/// hidden series names. [`PlotView::state`] hands back a `&PlotState`; the headless
/// [`facett_core::harness`] snapshots it after a `Vec<Msg>`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PlotState {
/// Line vs bars.
pub kind: ChartKind,
/// Named `(x, y)` series (all of them — visibility is tracked in `hidden`).
pub series: Vec<Series>,
/// Explicit zoom/pan window; `None` = auto-fit to the data.
pub viewport: Option<Viewport>,
/// Series names the user toggled off (stable keys, not indices).
pub hidden: Vec<String>,
}
impl PlotState {
fn is_hidden(&self, name: &str) -> bool {
self.hidden.iter().any(|h| h == name)
}
fn has_series(&self, name: &str) -> bool {
self.series.iter().any(|s| s.name == name)
}
}
/// A robot-/CLI-addressable control message (FC-2) — the named boundary a headless
/// driver or a host toolbar drives the view through, the same effect the canvas
/// gestures produce. Applied by [`PlotView::update`].
#[derive(Clone, Debug, PartialEq)]
pub enum Msg {
/// Switch between a line chart and a bar chart.
SetKind(ChartKind),
/// Pan/zoom to an explicit window (bounds are normalised so `min < max`). The
/// live drag/scroll gesture emits this each frame; a robot/CLI can emit it
/// directly.
SetViewport(Viewport),
/// Drop the zoom override — back to auto-fit.
ResetViewport,
/// Toggle whether the named series is drawn (no-op if the name is absent).
ToggleSeries(String),
/// Make every series visible again (clears `hidden`).
ShowAllSeries,
}
/// Side work as data (FC-8). The plot does no I/O — every [`Msg`] mutates only the
/// in-memory [`PlotState`] — so this is uninhabited on purpose: the type-checked
/// statement that [`PlotView::update`] never asks the host to do anything.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
/// A chart: named series of `(x, y)` points, plus the observable view state.
pub struct PlotView {
/// Tab/panel title.
pub title: String,
/// All observable state (FC-3).
pub state: PlotState,
}
impl PlotView {
fn from_series(title: impl Into<String>, kind: ChartKind, series: Vec<(String, Vec<(f64, f64)>)>) -> Self {
let series = series.into_iter().map(|(name, points)| Series { name, points }).collect();
Self { title: title.into(), state: PlotState { kind, series, viewport: None, hidden: Vec::new() } }
}
/// A **line** chart of the given named series.
pub fn line(title: impl Into<String>, series: Vec<(String, Vec<(f64, f64)>)>) -> Self {
Self::from_series(title, ChartKind::Line, series)
}
/// A **bar** chart of the given named series.
pub fn bars(title: impl Into<String>, series: Vec<(String, Vec<(f64, f64)>)>) -> Self {
Self::from_series(title, ChartKind::Bars, series)
}
/// The chart [`kind`](ChartKind) (line / bars).
pub fn kind(&self) -> ChartKind {
self.state.kind
}
/// The named series (all of them, regardless of visibility).
pub fn series(&self) -> &[Series] {
&self.state.series
}
/// `true` if the named series is currently hidden.
pub fn is_hidden(&self, name: &str) -> bool {
self.state.is_hidden(name)
}
/// The active zoom window if one is set (else `None` = auto-fit).
pub fn viewport(&self) -> Option<Viewport> {
self.state.viewport
}
/// **FC-2 / FC-8** — the single mutation path. Apply one [`Msg`]; returns the
/// (always empty) [`Effect`]s the host should run. The canvas gestures route
/// their [`Msg`]s here too, so live == headless.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::SetKind(k) => self.state.kind = k,
Msg::SetViewport(mut vp) => {
if vp.x_min > vp.x_max {
std::mem::swap(&mut vp.x_min, &mut vp.x_max);
}
if vp.y_min > vp.y_max {
std::mem::swap(&mut vp.y_min, &mut vp.y_max);
}
// Guard against a degenerate zero-span window (a representable
// relative nudge — `f64::EPSILON` would round away at larger
// magnitudes).
let widen = |lo: f64, hi: f64| if hi > lo { hi } else { lo + lo.abs().max(1.0) * 1e-9 };
vp.x_max = widen(vp.x_min, vp.x_max);
vp.y_max = widen(vp.y_min, vp.y_max);
self.state.viewport = Some(vp);
}
Msg::ResetViewport => self.state.viewport = None,
Msg::ToggleSeries(name) => {
if self.state.has_series(&name) {
if let Some(i) = self.state.hidden.iter().position(|h| *h == name) {
self.state.hidden.remove(i);
} else {
self.state.hidden.push(name);
}
}
}
Msg::ShowAllSeries => self.state.hidden.clear(),
}
Vec::new()
}
}
impl PlotView {
/// The chart data as a TSV rectangle (`series \t x \t y`, header first) — the
/// spreadsheet-pasteable view of every point across every series (all series,
/// regardless of visibility).
pub fn to_tsv(&self) -> String {
let mut out = String::from("series\tx\ty");
for s in &self.state.series {
for &(x, y) in &s.points {
out.push('\n');
out.push_str(&format!("{}\t{x}\t{y}", s.name));
}
}
out
}
}
// ── typed copy (§16) — read-only: the chart points as a TSV rectangle ─────────
impl CopySource for PlotView {
fn copy_kinds(&self) -> &[ClipKind] {
&[ClipKind::Rows, ClipKind::Text]
}
fn copy_payload(&self) -> Option<ClipPayload> {
if self.state.series.iter().all(|s| s.points.is_empty()) {
return None;
}
Some(ClipPayload::Rows(self.to_tsv()))
}
}
impl PlotView {
/// **FC-9 pure view** — paints the legend + interactive plot and **returns** the
/// [`Msg`]s this frame's interactions produced (Fit button, per-series
/// visibility checkboxes, drag/scroll → viewport). It does **not** mutate
/// `self`; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge
/// applies the returned messages through [`update`](Self::update).
pub fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
use egui_plot::{Bar, BarChart, Line, Plot, PlotPoints};
let mut msgs: Vec<Msg> = Vec::new();
// ── legend / toolbar: per-series visibility + a Fit reset ─────────────────
ui.horizontal_wrapped(|ui| {
for s in &self.state.series {
let mut visible = !self.state.is_hidden(&s.name);
// A pure checkbox: reads `visible`, and if the user flips it we emit a
// toggle Msg (we never write the model here).
if ui.checkbox(&mut visible, &s.name).changed() {
msgs.push(Msg::ToggleSeries(s.name.clone()));
}
}
if self.state.viewport.is_some() && ui.button("⤢ Fit").on_hover_text("Reset the zoom window").clicked() {
msgs.push(Msg::ResetViewport);
}
});
// ── the plot itself ───────────────────────────────────────────────────────
let vp = self.state.viewport;
let kind = self.state.kind;
let resp = Plot::new(self.title.as_str())
.allow_drag(true)
.allow_zoom(true)
.allow_scroll(true)
.show(ui, |pui| {
// If the model holds an explicit viewport that differs from what the
// widget currently shows, push it (honors a headless `SetViewport`
// or a `load_state` restore). In the steady state after a live drag
// the model already equals the widget bounds, so nothing is pushed
// and the gesture stays live.
if let Some(vp) = vp {
let cur = Viewport::from_bounds(&pui.plot_bounds());
if !vp.approx_eq(&cur) {
pui.set_plot_bounds(vp.to_bounds());
}
}
for s in &self.state.series {
if self.state.is_hidden(&s.name) {
continue;
}
match kind {
ChartKind::Line => {
let points: Vec<[f64; 2]> = s.points.iter().map(|&(x, y)| [x, y]).collect();
pui.line(Line::new(s.name.clone(), PlotPoints::from(points)));
}
ChartKind::Bars => {
let bars: Vec<Bar> = s.points.iter().map(|&(x, y)| Bar::new(x, y)).collect();
pui.bar_chart(BarChart::new(s.name.clone(), bars));
}
}
}
});
// ── mirror the live pan/zoom gesture back into the model as a Msg ─────────
// `egui_plot` owns the continuous gesture; we capture the resulting bounds
// ONLY when the user actually dragged or scroll/pinch-zoomed over the plot,
// so a mere hover (or an untouched auto-fit frame) never turns the viewport
// explicit. A continuous drag therefore emits one `SetViewport` per frame —
// the honest, lossy discretisation of a mouse gesture.
let (scroll, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.zoom_delta()));
let gesture = resp.response.dragged() || (resp.response.hovered() && (scroll.abs() > 0.5 || (zoom - 1.0).abs() > 1e-4));
if gesture {
let got = Viewport::from_bounds(resp.transform.bounds());
if self.state.viewport.map(|v| !v.approx_eq(&got)).unwrap_or(true) {
msgs.push(Msg::SetViewport(got));
}
}
// ── render-lane emit: this pure-view path RAN ─────────────────────────
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-plot::PlotView::view",
"ui_render",
// OK = the chart drew the geometry it declares: an empty chart has no
// series, and any VISIBLE series the view holds is plotted (finite
// coords — no NaN egui_plot would drop).
self.state
.series
.iter()
.filter(|s| !self.state.is_hidden(&s.name))
.all(|s| s.points.iter().all(|&(x, y)| x.is_finite() && y.is_finite())),
&format!(
"series={} visible={} points={}",
self.state.series.len(),
self.state.series.iter().filter(|s| !self.state.is_hidden(&s.name)).count(),
self.state.series.iter().map(|s| s.points.len()).sum::<usize>(),
),
);
msgs
}
}
// ── FC-2 / FC-3 / FC-8 / FC-9: the canonical Elm split ────────────────────────
impl facett_core::Elm for PlotView {
type Model = PlotState;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &PlotState {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
PlotView::update(self, msg)
}
fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
PlotView::view(self, ui)
}
}
// The bridge macro writes `impl Facet for PlotView` from the `Elm` impl: `title`,
// the FC-9 `ui` loop (`for m in view(ui) { update(m) }`). **Form 3**
// (`custom_state_json`) because the observable `state_json` is RICHER than plain
// `serde(state())`: it reports each series' point **count** (not the raw point
// array) plus a `visible` flag and the viewport — the exact shape the deck / demo
// mega-matrix read (`kind`, `series[i].points` == a count). Caps/copy are spliced
// into the SAME impl block and preserved bit-for-bit (compliance matrix pins them).
facett_core::impl_facet_via_elm!(PlotView, custom_state_json, {
fn copy(&mut self) -> Option<String> {
self.copy_payload().map(|p| p.as_text())
}
fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"kind": self.state.kind.tag(),
"series": self
.state
.series
.iter()
.map(|s| serde_json::json!({
"name": s.name,
"points": s.points.len(),
"visible": !self.state.is_hidden(&s.name),
}))
.collect::<Vec<_>>(),
"viewport": self.state.viewport.map(|v| serde_json::json!({
"x_min": v.x_min, "x_max": v.x_max, "y_min": v.y_min, "y_max": v.y_max,
})),
"hidden": self.state.hidden,
})
}
/// `egui_plot::Plot` paints its grid/axes/background/labels from the active
/// egui `Visuals`, which [`facett_core::set_theme`] derives from the `Theme`,
/// so the chart frame follows the palette. (Per-series colours are still
/// egui_plot's auto-assigned defaults.) The plot owns zoom/pan and resizes with
/// its host rect. Caps are preserved exactly (the compliance matrix pins them):
/// **themeable + copyable + resizable** — NOT `scalable`/`selectable`, even
/// though the viewport now lives in the model.
fn caps(&self) -> FacetCaps {
FacetCaps::NONE.themeable().resizable().copyable()
}
/// The portable subset a same-kind sibling can adopt: the zoom window, the
/// hidden-series set, and the chart kind (NOT the series data, which the host
/// owns).
fn portable_state(&self) -> Option<serde_json::Value> {
Some(serde_json::json!({
"kind": self.state.kind.tag(),
"viewport": self.state.viewport.map(|v| [v.x_min, v.x_max, v.y_min, v.y_max]),
"hidden": self.state.hidden,
}))
}
fn load_state(&mut self, state: &serde_json::Value) -> bool {
let mut changed = false;
if let Some(k) = state.get("kind").and_then(|v| v.as_str()) {
self.state.kind = match k {
"bars" => ChartKind::Bars,
_ => ChartKind::Line,
};
changed = true;
}
if let Some(w) = state.get("viewport") {
self.state.viewport = w.as_array().and_then(|a| {
Some(Viewport {
x_min: a.first()?.as_f64()?,
x_max: a.get(1)?.as_f64()?,
y_min: a.get(2)?.as_f64()?,
y_max: a.get(3)?.as_f64()?,
})
});
changed = true;
}
if let Some(h) = state.get("hidden").and_then(|v| v.as_array()) {
self.state.hidden = h.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
changed = true;
}
changed
}
});
#[cfg(test)]
mod tests {
use super::*;
// `Elm` for `state()`; `Facet` for `state_json`/`portable_state`.
use facett_core::{Elm, Facet};
fn demo() -> PlotView {
PlotView::line(
"trend",
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)]),
],
)
}
// ── state_json preserves the observable shape the demo mega-matrix reads ─────
#[test]
fn state_json_reports_series_kind_and_point_counts() {
let v = PlotView::line("trend", vec![("ops/s".into(), vec![(0.0, 1.0), (1.0, 2.0), (2.0, 4.0)])]);
let j = Facet::state_json(&v);
assert_eq!(j["kind"], "line");
// `series` is an array whose length is the series count …
assert_eq!(j["series"].as_array().unwrap().len(), 1);
// … and each entry's `points` is a COUNT (not the raw point array).
assert_eq!(j["series"][0]["points"], 3);
assert_eq!(j["series"][0]["visible"], true);
assert_eq!(j["viewport"], serde_json::Value::Null, "no zoom override by default");
}
// ── caps are preserved bit-for-bit (the compliance matrix pins them) ─────────
#[test]
fn caps_are_themeable_copyable_resizable_only() {
let c = demo().caps();
assert!(c.themeable && c.copyable && c.resizable);
assert!(!c.scalable && !c.selectable && !c.searchable, "plot does NOT declare scalable/selectable");
}
#[test]
fn typed_copy_is_a_tsv_of_all_points() {
let mut v = PlotView::line("trend", vec![("ops/s".into(), vec![(0.0, 1.0), (1.0, 2.0)])]);
let p = v.copy_payload().expect("a series with points copies");
assert_eq!(p.kind(), ClipKind::Rows);
assert_eq!(p.as_text(), "series\tx\ty\nops/s\t0\t1\nops/s\t1\t2");
assert_eq!(<PlotView as Facet>::copy(&mut v), Some(p.as_text()));
assert!(PlotView::line("empty", vec![]).copy_payload().is_none());
// Copy ignores visibility — hidden series still export.
v.update(Msg::ToggleSeries("ops/s".into()));
assert_eq!(<PlotView as Facet>::copy(&mut v), Some(p.as_text()), "hidden series still copy");
}
// ── FC-2: update() is the single mutation path; assert the transitions ───────
#[test]
fn msg_setkind_switches_line_and_bars() {
let mut v = demo();
assert_eq!(v.kind(), ChartKind::Line);
v.update(Msg::SetKind(ChartKind::Bars));
assert_eq!(v.kind(), ChartKind::Bars);
assert_eq!(Facet::state_json(&v)["kind"], "bars");
}
#[test]
fn msg_setviewport_normalises_reversed_and_zero_span_bounds() {
let mut v = demo();
assert!(v.viewport().is_none());
// Reversed x/y bounds get ordered.
v.update(Msg::SetViewport(Viewport { x_min: 5.0, x_max: 1.0, y_min: 9.0, y_max: 2.0 }));
let vp = v.viewport().expect("viewport set");
assert!(vp.x_min < vp.x_max && (vp.x_min - 1.0).abs() < 1e-9, "reversed x ordered");
assert!(vp.y_min < vp.y_max && (vp.y_min - 2.0).abs() < 1e-9, "reversed y ordered");
// A zero-span window is nudged to a non-empty span.
v.update(Msg::SetViewport(Viewport { x_min: 3.0, x_max: 3.0, y_min: 4.0, y_max: 4.0 }));
let vp = v.viewport().unwrap();
assert!(vp.x_max > vp.x_min && vp.y_max > vp.y_min, "zero-span window widened");
// Reset drops the override.
v.update(Msg::ResetViewport);
assert!(v.viewport().is_none(), "reset -> auto-fit");
}
#[test]
fn msg_toggleseries_hides_and_shows_by_name() {
let mut v = demo();
assert!(!v.is_hidden("ops/s"));
v.update(Msg::ToggleSeries("ops/s".into()));
assert!(v.is_hidden("ops/s"), "toggled off");
assert_eq!(Facet::state_json(&v)["series"][0]["visible"], false);
v.update(Msg::ToggleSeries("ops/s".into()));
assert!(!v.is_hidden("ops/s"), "toggled back on");
// Unknown series is a no-op.
v.update(Msg::ToggleSeries("nope".into()));
assert!(v.state.hidden.is_empty(), "toggling an absent series does nothing");
// ShowAllSeries clears everything.
v.update(Msg::ToggleSeries("ops/s".into()));
v.update(Msg::ToggleSeries("errors".into()));
assert_eq!(v.state.hidden.len(), 2);
v.update(Msg::ShowAllSeries);
assert!(v.state.hidden.is_empty(), "ShowAllSeries reveals every series");
}
// ── FC-2 → FC-3 as a headless property: feed a Vec<Msg> through the harness ──
#[test]
fn harness_snapshot_drives_kind_zoom_and_visibility() {
use facett_core::harness;
let mut v = demo();
let snap = harness::snapshot(
&mut v,
[
Msg::SetKind(ChartKind::Bars),
Msg::SetViewport(Viewport { x_min: 0.0, x_max: 2.0, y_min: 0.0, y_max: 5.0 }),
Msg::ToggleSeries("errors".into()),
],
);
assert_eq!(snap.kind, ChartKind::Bars, "kind switch is observable");
assert_eq!(snap.viewport, Some(Viewport { x_min: 0.0, x_max: 2.0, y_min: 0.0, y_max: 5.0 }), "zoom window observable");
assert_eq!(snap.hidden, vec!["errors".to_string()], "visibility observable");
// The snapshot is a clone of the live state — same transition, headlessly.
assert_eq!(&snap, v.state());
}
#[test]
fn drive_reports_no_effects_and_state_round_trips() {
use facett_core::harness;
let mut v = demo();
// FC-8: the plot does no I/O, so the effect stream is always empty.
let effects = harness::drive(&mut v, [Msg::SetKind(ChartKind::Bars), Msg::ToggleSeries("ops/s".into())]);
assert!(effects.is_empty(), "FC-8: plot emits no Effects");
// FC-3: the observable Model round-trips through serde.
let json = serde_json::to_value(v.state()).unwrap();
let back: PlotState = serde_json::from_value(json).unwrap();
assert_eq!(&back, v.state(), "serde(state) -> state round-trips");
}
// ── FC-9: the macro-generated `ui` bridge (view -> update loop) renders
// headlessly (no display / GPU) without panicking and draws geometry. This
// also exercises the live drag-readback path (no gesture -> viewport stays
// None, so an idle render never turns the viewport explicit). ─────────────
#[test]
fn headless_render_draws_and_keeps_state() {
use facett_core::harness;
let mut v = demo();
let report = harness::headless_render(&mut v);
assert!(report.drew(), "the plot tessellated geometry");
assert_eq!(report.state["kind"], "line");
assert_eq!(report.state["series"].as_array().unwrap().len(), 2);
// An idle frame (no pointer gesture) must not fabricate a viewport.
assert!(v.viewport().is_none(), "an idle render does not turn the viewport explicit");
assert!(v.state.hidden.is_empty());
}
#[test]
fn portable_state_round_trips_kind_viewport_and_hidden() {
let mut a = demo();
a.update(Msg::SetKind(ChartKind::Bars));
a.update(Msg::SetViewport(Viewport { x_min: 1.0, x_max: 3.0, y_min: 0.0, y_max: 9.0 }));
a.update(Msg::ToggleSeries("errors".into()));
let portable = a.portable_state().unwrap();
let mut b = demo();
assert!(b.load_state(&portable));
assert_eq!(b.kind(), ChartKind::Bars);
let vp = b.viewport().unwrap();
assert!((vp.x_min - 1.0).abs() < 1e-9 && (vp.y_max - 9.0).abs() < 1e-9);
assert!(b.is_hidden("errors"));
}
}