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;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChartKind {
#[default]
Line,
Bar,
Scatter,
}
impl ChartKind {
pub fn as_str(self) -> &'static str {
match self {
ChartKind::Line => "line",
ChartKind::Bar => "bar",
ChartKind::Scatter => "scatter",
}
}
}
#[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 }
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ChartState {
pub kind: ChartKind,
pub hover: Option<(usize, usize)>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChartMsg {
SetKind(ChartKind),
Hover(Option<(usize, usize)>),
}
#[derive(Clone, Debug)]
pub enum ChartEffect {}
pub struct ChartView {
pub title: String,
pub series: Vec<Series>,
pub state: ChartState,
}
impl ChartView {
pub fn new(title: impl Into<String>, series: Vec<Series>) -> Self {
Self { title: title.into(), series, state: ChartState::default() }
}
pub fn with_kind(mut self, kind: ChartKind) -> Self {
self.state.kind = kind;
self
}
pub fn state(&self) -> &ChartState {
&self.state
}
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()
}
fn max_len(&self) -> usize {
self.series.iter().map(|s| s.points.len()).max().unwrap_or(0)
}
pub fn x_bounds(&self) -> Option<(f64, f64)> {
fold_bounds(self.series.iter().flat_map(|s| s.points.iter().map(|p| p.0)))
}
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)
}
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
}
}
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 {
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);
};
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();
painter.line_segment([plot.left_bottom(), plot.right_bottom()], Stroke::new(1.0, th.text_dim));
painter.line_segment([plot.left_top(), plot.left_bottom()], Stroke::new(1.0, 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, 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);
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);
}
}
self.paint_legend(&painter, plot, &th);
if let Some(hp) = hover {
if let Some((si, pi)) = self.state.hover {
if 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)
}
#[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, 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 {
if p.distance(hp) <= 8.0 {
*new_hover = Some((si, pi));
}
}
}
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),
);
}
}
}
#[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 {
if r.contains(hp) {
*new_hover = Some((si, ci));
}
}
}
}
}
fn paint_legend(&self, painter: &egui::Painter, plot: Rect, th: &facett_core::Theme) {
if self.series.len() < 2 {
return; }
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;
}
}
}
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
}
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)
}
}
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);
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); }
#[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());
assert!(ChartView::new("t", vec![Series::new("a", vec![])]).copy_payload().is_none());
}
#[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() {
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);
}
}