1use egui::{Align2, FontId, Pos2, Rect, Sense, Stroke, Ui, vec2};
14use facett_core::clip::{ClipKind, ClipPayload, CopySource};
15use facett_core::{Elm, FacetCaps, Semantics, a11y_node, theme};
16use serde::{Deserialize, Serialize};
17
18use crate::palette;
19
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ChartKind {
24 #[default]
26 Line,
27 Bar,
29 Scatter,
31}
32
33impl ChartKind {
34 pub fn as_str(self) -> &'static str {
36 match self {
37 ChartKind::Line => "line",
38 ChartKind::Bar => "bar",
39 ChartKind::Scatter => "scatter",
40 }
41 }
42}
43
44#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46pub struct Series {
47 pub label: String,
48 pub points: Vec<(f64, f64)>,
49}
50
51impl Series {
52 pub fn new(label: impl Into<String>, points: Vec<(f64, f64)>) -> Self {
53 Self { label: label.into(), points }
54 }
55}
56
57#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
61pub struct ChartState {
62 pub kind: ChartKind,
63 pub hover: Option<(usize, usize)>,
65}
66
67#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum ChartMsg {
72 SetKind(ChartKind),
74 Hover(Option<(usize, usize)>),
76}
77
78#[derive(Clone, Debug)]
80pub enum ChartEffect {}
81
82pub struct ChartView {
84 pub title: String,
85 pub series: Vec<Series>,
86 pub state: ChartState,
87}
88
89impl ChartView {
90 pub fn new(title: impl Into<String>, series: Vec<Series>) -> Self {
92 Self { title: title.into(), series, state: ChartState::default() }
93 }
94
95 pub fn with_kind(mut self, kind: ChartKind) -> Self {
97 self.state.kind = kind;
98 self
99 }
100
101 pub fn state(&self) -> &ChartState {
103 &self.state
104 }
105
106 pub fn update(&mut self, msg: ChartMsg) -> Vec<ChartEffect> {
108 match msg {
109 ChartMsg::SetKind(k) => self.state.kind = k,
110 ChartMsg::Hover(h) => self.state.hover = h,
111 }
112 Vec::new()
113 }
114
115 fn max_len(&self) -> usize {
117 self.series.iter().map(|s| s.points.len()).max().unwrap_or(0)
118 }
119
120 pub fn x_bounds(&self) -> Option<(f64, f64)> {
124 fold_bounds(self.series.iter().flat_map(|s| s.points.iter().map(|p| p.0)))
125 }
126
127 pub fn y_bounds(&self) -> Option<(f64, f64)> {
130 let mut b = fold_bounds(self.series.iter().flat_map(|s| s.points.iter().map(|p| p.1)))?;
131 if self.state.kind == ChartKind::Bar {
132 b.0 = b.0.min(0.0);
133 b.1 = b.1.max(0.0);
134 }
135 Some(b)
136 }
137
138 fn to_csv(&self) -> String {
140 let mut s = String::from("series,x,y\n");
141 for ser in &self.series {
142 for (x, y) in &ser.points {
143 s.push_str(&format!("{},{},{}\n", ser.label, x, y));
144 }
145 }
146 s
147 }
148}
149
150impl CopySource for ChartView {
152 fn copy_kinds(&self) -> &[ClipKind] {
153 &[ClipKind::Text]
154 }
155 fn copy_payload(&self) -> Option<ClipPayload> {
156 let csv = self.to_csv();
157 (self.series.iter().any(|s| !s.points.is_empty())).then_some(ClipPayload::Text(csv))
158 }
159}
160
161impl ChartView {
162 fn render(&self, ui: &mut Ui) -> Vec<ChartMsg> {
166 let th = theme(ui);
167 let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
168 let painter = ui.painter_at(rect);
169 painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
170
171 let (Some((xmin, xmax)), Some((ymin, ymax))) = (self.x_bounds(), self.y_bounds()) else {
172 #[cfg(feature = "testmatrix")]
173 facett_core::testmatrix::emit(
174 "facett-plotters::ChartView::ui",
175 "ui_render",
176 self.series.is_empty() || self.max_len() == 0,
177 &format!("drew=empty_hint kind={} series={}", self.state.kind.as_str(), self.series.len()),
178 );
179 painter.text(rect.center(), Align2::CENTER_CENTER, "no data", FontId::proportional(13.0), th.text_dim);
180 return hover_msg(self.state.hover, None);
181 };
182
183 let plot = Rect::from_min_max(
185 Pos2::new(rect.left() + 44.0, rect.top() + 10.0),
186 Pos2::new(rect.right() - 12.0, rect.bottom() - 24.0),
187 );
188 let yspan = (ymax - ymin).max(1e-9);
189 let y_of = |v: f64| plot.bottom() - ((v - ymin) / yspan) as f32 * plot.height();
190
191 painter.line_segment([plot.left_bottom(), plot.right_bottom()], Stroke::new(1.0_f32, th.text_dim));
193 painter.line_segment([plot.left_top(), plot.left_bottom()], Stroke::new(1.0_f32, th.text_dim));
194 for i in 0..=4 {
195 let t = i as f64 / 4.0;
196 let v = ymin + t * (ymax - ymin);
197 let y = y_of(v);
198 painter.line_segment(
199 [Pos2::new(plot.left(), y), Pos2::new(plot.right(), y)],
200 Stroke::new(0.5_f32, th.text_dim.gamma_multiply(0.4)),
201 );
202 painter.text(
203 Pos2::new(plot.left() - 4.0, y),
204 Align2::RIGHT_CENTER,
205 format!("{v:.1}"),
206 FontId::proportional(9.0),
207 th.text_dim,
208 );
209 }
210
211 let hover = resp.hover_pos();
212 let base = ui.id().with("chart");
213 let mut new_hover: Option<(usize, usize)> = None;
214
215 match self.state.kind {
216 ChartKind::Bar => self.paint_bars(&painter, ui, base, plot, &y_of, hover, &mut new_hover, &th),
217 ChartKind::Line | ChartKind::Scatter => {
218 let xspan = (xmax - xmin).max(1e-9);
219 let x_of = |v: f64| plot.left() + ((v - xmin) / xspan) as f32 * plot.width();
220 self.paint_xy(&painter, ui, base, plot, &x_of, &y_of, hover, &mut new_hover, &th);
221 painter.text(plot.left_bottom() + vec2(0.0, 12.0), Align2::LEFT_CENTER, format!("{xmin:.1}"), FontId::proportional(9.0), th.text_dim);
223 painter.text(plot.right_bottom() + vec2(0.0, 12.0), Align2::RIGHT_CENTER, format!("{xmax:.1}"), FontId::proportional(9.0), th.text_dim);
224 }
225 }
226
227 self.paint_legend(&painter, plot, &th);
229
230 if let Some(hp) = hover
232 && let Some((si, pi)) = self.state.hover
233 && let Some((x, y)) = self.series.get(si).and_then(|s| s.points.get(pi))
234 {
235 let label = &self.series[si].label;
236 crate::boxplot::paint_tooltip(&painter, &th, hp, &format!("{label}\nx {x:.3}\ny {y:.3}"));
237 }
238
239 #[cfg(feature = "testmatrix")]
240 facett_core::testmatrix::emit(
241 "facett-plotters::ChartView::ui",
242 "ui_render",
243 !self.series.is_empty() && self.max_len() > 0,
244 &format!("kind={} series={} points={}", self.state.kind.as_str(), self.series.len(), self.max_len()),
245 );
246 hover_msg(self.state.hover, new_hover)
247 }
248
249 #[allow(clippy::too_many_arguments)]
251 fn paint_xy(
252 &self,
253 painter: &egui::Painter,
254 ui: &mut Ui,
255 base: egui::Id,
256 plot: Rect,
257 x_of: &dyn Fn(f64) -> f32,
258 y_of: &dyn Fn(f64) -> f32,
259 hover: Option<Pos2>,
260 new_hover: &mut Option<(usize, usize)>,
261 th: &facett_core::Theme,
262 ) {
263 let scatter = self.state.kind == ChartKind::Scatter;
264 for (si, ser) in self.series.iter().enumerate() {
265 let color = palette::series_color(th, si);
266 let pts: Vec<Pos2> = ser.points.iter().map(|&(x, y)| Pos2::new(x_of(x), y_of(y))).collect();
267 if !scatter && pts.len() >= 2 {
268 for w in pts.windows(2) {
269 painter.line_segment([w[0], w[1]], Stroke::new(2.0_f32, color));
270 }
271 }
272 for (pi, &p) in pts.iter().enumerate() {
273 if scatter {
274 painter.circle_filled(p, 4.0, color);
275 } else {
276 painter.circle_filled(p, 3.0, color);
277 }
278 if let Some(hp) = hover && p.distance(hp) <= 8.0 {
279 *new_hover = Some((si, pi));
280 }
281 }
282 if let Some(first) = pts.first() {
285 let last = *pts.last().unwrap();
286 let node = Rect::from_two_pos(*first, last).expand(4.0).intersect(plot);
287 a11y_node(
288 ui,
289 base,
290 &ser.label,
291 Sense::hover(),
292 node,
293 Semantics::image(ser.label.clone()).value(ser.points.len() as f64),
294 );
295 }
296 }
297 }
298
299 #[allow(clippy::too_many_arguments)]
301 fn paint_bars(
302 &self,
303 painter: &egui::Painter,
304 ui: &mut Ui,
305 base: egui::Id,
306 plot: Rect,
307 y_of: &dyn Fn(f64) -> f32,
308 hover: Option<Pos2>,
309 new_hover: &mut Option<(usize, usize)>,
310 th: &facett_core::Theme,
311 ) {
312 let cats = self.max_len();
313 let n = self.series.len();
314 if cats == 0 || n == 0 {
315 return;
316 }
317 let slot = plot.width() / cats as f32;
318 let group_w = (slot * 0.8).min(slot - 2.0).max(1.0);
319 let bar_w = (group_w / n as f32).max(1.0);
320 let base_y = y_of(0.0);
321 for ci in 0..cats {
322 let cx = plot.left() + slot * (ci as f32 + 0.5);
323 for (si, ser) in self.series.iter().enumerate() {
324 let Some(&(_, y)) = ser.points.get(ci) else { continue };
325 let color = palette::series_color(th, si);
326 let left = cx - group_w * 0.5 + bar_w * si as f32;
327 let top = y_of(y);
328 let r = Rect::from_min_max(
329 Pos2::new(left + 1.0, top.min(base_y)),
330 Pos2::new(left + bar_w - 1.0, top.max(base_y)),
331 );
332 painter.rect_filled(r, egui::CornerRadius::same(1), color);
333 a11y_node(
334 ui,
335 base,
336 format!("{} · cat {ci}", ser.label),
337 Sense::hover(),
338 r,
339 Semantics::image(format!("{} · {ci}", ser.label)).value(y),
340 );
341 if let Some(hp) = hover && r.contains(hp) {
342 *new_hover = Some((si, ci));
343 }
344 }
345 }
346 }
347
348 fn paint_legend(&self, painter: &egui::Painter, plot: Rect, th: &facett_core::Theme) {
351 if self.series.len() < 2 {
352 return; }
354 let mut y = plot.top() + 6.0;
355 let x = plot.right() - 120.0;
356 for (si, ser) in self.series.iter().enumerate() {
357 let color = palette::series_color(th, si);
358 let sw = Rect::from_min_size(Pos2::new(x, y), vec2(10.0, 10.0));
359 painter.rect_filled(sw, egui::CornerRadius::same(2), color);
360 painter.text(Pos2::new(x + 14.0, y + 5.0), Align2::LEFT_CENTER, &ser.label, FontId::proportional(10.0), th.text);
361 y += 15.0;
362 }
363 }
364}
365
366fn fold_bounds(it: impl Iterator<Item = f64>) -> Option<(f64, f64)> {
368 let mut r: Option<(f64, f64)> = None;
369 for x in it {
370 if x.is_finite() {
371 r = Some(match r {
372 None => (x, x),
373 Some((lo, hi)) => (lo.min(x), hi.max(x)),
374 });
375 }
376 }
377 r
378}
379
380fn hover_msg(current: Option<(usize, usize)>, next: Option<(usize, usize)>) -> Vec<ChartMsg> {
382 if current != next { vec![ChartMsg::Hover(next)] } else { Vec::new() }
383}
384
385impl Elm for ChartView {
386 type Model = ChartState;
387 type Msg = ChartMsg;
388 type Effect = ChartEffect;
389
390 fn title(&self) -> &str {
391 &self.title
392 }
393 fn state(&self) -> &ChartState {
394 &self.state
395 }
396 fn update(&mut self, msg: ChartMsg) -> Vec<ChartEffect> {
397 ChartView::update(self, msg)
398 }
399 fn view(&self, ui: &mut Ui) -> Vec<ChartMsg> {
400 self.render(ui)
401 }
402}
403
404facett_core::impl_facet_via_elm!(ChartView, custom_state_json, {
409 fn caps(&self) -> FacetCaps {
410 FacetCaps::NONE.themeable().copyable().resizable()
411 }
412 fn copy(&mut self) -> Option<String> {
413 Some(self.to_csv())
414 }
415 fn kind(&self) -> &'static str {
416 "plotters-chart"
417 }
418 fn update_json(&mut self, msg_json: &str) {
419 if let Ok(msg) = serde_json::from_str::<ChartMsg>(msg_json) {
420 let _ = <Self as facett_core::Elm>::update(self, msg);
421 }
422 }
423 fn state_json(&self) -> serde_json::Value {
424 let (xmin, xmax) = self.x_bounds().unzip();
425 let (ymin, ymax) = self.y_bounds().unzip();
426 serde_json::json!({
427 "kind": self.state.kind.as_str(),
428 "series_count": self.series.len(),
429 "hover": self.state.hover,
430 "x_bounds": [xmin, xmax],
431 "y_bounds": [ymin, ymax],
432 "series": self.series.iter().map(|s| serde_json::json!({
433 "label": s.label, "points": s.points.len(),
434 })).collect::<Vec<_>>(),
435 })
436 }
437});
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 fn sample() -> ChartView {
444 ChartView::new(
445 "Sales",
446 vec![
447 Series::new("north", vec![(0.0, 1.0), (1.0, 3.0), (2.0, 2.0)]),
448 Series::new("south", vec![(0.0, 2.0), (1.0, 1.0), (2.0, 4.0)]),
449 ],
450 )
451 }
452
453 #[test]
454 fn x_and_y_bounds_span_all_series() {
455 let v = sample();
456 assert_eq!(v.x_bounds(), Some((0.0, 2.0)));
457 assert_eq!(v.y_bounds(), Some((1.0, 4.0)));
458 }
459
460 #[test]
461 fn bar_bounds_include_zero_baseline() {
462 let v = ChartView::new("t", vec![Series::new("a", vec![(0.0, 5.0), (1.0, 9.0)])])
463 .with_kind(ChartKind::Bar);
464 assert_eq!(v.y_bounds(), Some((0.0, 9.0)));
466 }
467
468 #[test]
469 fn bounds_ignore_nonfinite_and_empty() {
470 assert_eq!(ChartView::new("t", vec![]).x_bounds(), None);
471 let v = ChartView::new("t", vec![Series::new("a", vec![(f64::NAN, 1.0), (2.0, f64::INFINITY)])]);
472 assert_eq!(v.x_bounds(), Some((2.0, 2.0)));
473 assert_eq!(v.y_bounds(), Some((1.0, 1.0)));
474 }
475
476 #[test]
477 fn csv_has_header_and_a_row_per_point() {
478 let v = sample();
479 let csv = v.to_csv();
480 assert!(csv.starts_with("series,x,y\n"));
481 assert_eq!(csv.lines().count(), 7); }
483
484 #[test]
485 fn typed_copy_is_the_dataset_csv() {
486 use facett_core::clip::{ClipKind, CopySource};
487 let v = sample();
488 let p = v.copy_payload().expect("non-empty chart copies");
489 assert_eq!(p.kind(), ClipKind::Text);
490 assert_eq!(p.as_text(), v.to_csv());
491 assert!(ChartView::new("t", vec![Series::new("a", vec![])]).copy_payload().is_none());
493 }
494
495 #[test]
497 fn switching_kind_is_observable() {
498 let mut v = sample();
499 assert_eq!(v.state().kind, ChartKind::Line);
500 let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::SetKind(ChartKind::Bar)]);
501 assert_eq!(snap.kind, ChartKind::Bar, "the kind switch is observable in the snapshot");
502 let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::SetKind(ChartKind::Scatter)]);
503 assert_eq!(snap.kind, ChartKind::Scatter);
504 }
505
506 #[test]
507 fn hover_transitions_are_observable() {
508 let mut v = sample();
509 let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::Hover(Some((1, 2)))]);
510 assert_eq!(snap.hover, Some((1, 2)));
511 let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::Hover(None)]);
512 assert_eq!(snap.hover, None);
513 }
514
515 #[test]
516 fn drive_produces_no_effects() {
517 let mut v = sample();
518 let fx = facett_core::harness::drive(
519 &mut v,
520 [ChartMsg::SetKind(ChartKind::Bar), ChartMsg::Hover(Some((0, 0)))],
521 );
522 assert!(fx.is_empty(), "the chart does no I/O");
523 }
524
525 #[test]
526 fn msg_round_trips_through_json() {
527 let m: ChartMsg = serde_json::from_str(r#"{"set_kind":"scatter"}"#).unwrap();
529 assert_eq!(m, ChartMsg::SetKind(ChartKind::Scatter));
530 let m: ChartMsg = serde_json::from_str(r#"{"hover":[0,1]}"#).unwrap();
531 assert_eq!(m, ChartMsg::Hover(Some((0, 1))));
532 let m: ChartMsg = serde_json::from_str(r#"{"hover":null}"#).unwrap();
533 assert_eq!(m, ChartMsg::Hover(None));
534 }
535
536 #[test]
537 fn state_serde_round_trips() {
538 let st = ChartState { kind: ChartKind::Bar, hover: Some((2, 3)) };
539 let back: ChartState = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
540 assert_eq!(st, back);
541 }
542}