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, th.text_dim));
193 painter.line_segment([plot.left_top(), plot.left_bottom()], Stroke::new(1.0, 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, 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 if let Some((si, pi)) = self.state.hover {
233 if let Some((x, y)) = self.series.get(si).and_then(|s| s.points.get(pi)) {
234 let label = &self.series[si].label;
235 crate::boxplot::paint_tooltip(&painter, &th, hp, &format!("{label}\nx {x:.3}\ny {y:.3}"));
236 }
237 }
238 }
239
240 #[cfg(feature = "testmatrix")]
241 facett_core::testmatrix::emit(
242 "facett-plotters::ChartView::ui",
243 "ui_render",
244 !self.series.is_empty() && self.max_len() > 0,
245 &format!("kind={} series={} points={}", self.state.kind.as_str(), self.series.len(), self.max_len()),
246 );
247 hover_msg(self.state.hover, new_hover)
248 }
249
250 #[allow(clippy::too_many_arguments)]
252 fn paint_xy(
253 &self,
254 painter: &egui::Painter,
255 ui: &mut Ui,
256 base: egui::Id,
257 plot: Rect,
258 x_of: &dyn Fn(f64) -> f32,
259 y_of: &dyn Fn(f64) -> f32,
260 hover: Option<Pos2>,
261 new_hover: &mut Option<(usize, usize)>,
262 th: &facett_core::Theme,
263 ) {
264 let scatter = self.state.kind == ChartKind::Scatter;
265 for (si, ser) in self.series.iter().enumerate() {
266 let color = palette::series_color(th, si);
267 let pts: Vec<Pos2> = ser.points.iter().map(|&(x, y)| Pos2::new(x_of(x), y_of(y))).collect();
268 if !scatter && pts.len() >= 2 {
269 for w in pts.windows(2) {
270 painter.line_segment([w[0], w[1]], Stroke::new(2.0, color));
271 }
272 }
273 for (pi, &p) in pts.iter().enumerate() {
274 if scatter {
275 painter.circle_filled(p, 4.0, color);
276 } else {
277 painter.circle_filled(p, 3.0, color);
278 }
279 if let Some(hp) = hover {
280 if p.distance(hp) <= 8.0 {
281 *new_hover = Some((si, pi));
282 }
283 }
284 }
285 if let Some(first) = pts.first() {
288 let last = *pts.last().unwrap();
289 let node = Rect::from_two_pos(*first, last).expand(4.0).intersect(plot);
290 a11y_node(
291 ui,
292 base,
293 &ser.label,
294 Sense::hover(),
295 node,
296 Semantics::image(ser.label.clone()).value(ser.points.len() as f64),
297 );
298 }
299 }
300 }
301
302 #[allow(clippy::too_many_arguments)]
304 fn paint_bars(
305 &self,
306 painter: &egui::Painter,
307 ui: &mut Ui,
308 base: egui::Id,
309 plot: Rect,
310 y_of: &dyn Fn(f64) -> f32,
311 hover: Option<Pos2>,
312 new_hover: &mut Option<(usize, usize)>,
313 th: &facett_core::Theme,
314 ) {
315 let cats = self.max_len();
316 let n = self.series.len();
317 if cats == 0 || n == 0 {
318 return;
319 }
320 let slot = plot.width() / cats as f32;
321 let group_w = (slot * 0.8).min(slot - 2.0).max(1.0);
322 let bar_w = (group_w / n as f32).max(1.0);
323 let base_y = y_of(0.0);
324 for ci in 0..cats {
325 let cx = plot.left() + slot * (ci as f32 + 0.5);
326 for (si, ser) in self.series.iter().enumerate() {
327 let Some(&(_, y)) = ser.points.get(ci) else { continue };
328 let color = palette::series_color(th, si);
329 let left = cx - group_w * 0.5 + bar_w * si as f32;
330 let top = y_of(y);
331 let r = Rect::from_min_max(
332 Pos2::new(left + 1.0, top.min(base_y)),
333 Pos2::new(left + bar_w - 1.0, top.max(base_y)),
334 );
335 painter.rect_filled(r, egui::CornerRadius::same(1), color);
336 a11y_node(
337 ui,
338 base,
339 format!("{} · cat {ci}", ser.label),
340 Sense::hover(),
341 r,
342 Semantics::image(format!("{} · {ci}", ser.label)).value(y),
343 );
344 if let Some(hp) = hover {
345 if r.contains(hp) {
346 *new_hover = Some((si, ci));
347 }
348 }
349 }
350 }
351 }
352
353 fn paint_legend(&self, painter: &egui::Painter, plot: Rect, th: &facett_core::Theme) {
356 if self.series.len() < 2 {
357 return; }
359 let mut y = plot.top() + 6.0;
360 let x = plot.right() - 120.0;
361 for (si, ser) in self.series.iter().enumerate() {
362 let color = palette::series_color(th, si);
363 let sw = Rect::from_min_size(Pos2::new(x, y), vec2(10.0, 10.0));
364 painter.rect_filled(sw, egui::CornerRadius::same(2), color);
365 painter.text(Pos2::new(x + 14.0, y + 5.0), Align2::LEFT_CENTER, &ser.label, FontId::proportional(10.0), th.text);
366 y += 15.0;
367 }
368 }
369}
370
371fn fold_bounds(it: impl Iterator<Item = f64>) -> Option<(f64, f64)> {
373 let mut r: Option<(f64, f64)> = None;
374 for x in it {
375 if x.is_finite() {
376 r = Some(match r {
377 None => (x, x),
378 Some((lo, hi)) => (lo.min(x), hi.max(x)),
379 });
380 }
381 }
382 r
383}
384
385fn hover_msg(current: Option<(usize, usize)>, next: Option<(usize, usize)>) -> Vec<ChartMsg> {
387 if current != next { vec![ChartMsg::Hover(next)] } else { Vec::new() }
388}
389
390impl Elm for ChartView {
391 type Model = ChartState;
392 type Msg = ChartMsg;
393 type Effect = ChartEffect;
394
395 fn title(&self) -> &str {
396 &self.title
397 }
398 fn state(&self) -> &ChartState {
399 &self.state
400 }
401 fn update(&mut self, msg: ChartMsg) -> Vec<ChartEffect> {
402 ChartView::update(self, msg)
403 }
404 fn view(&self, ui: &mut Ui) -> Vec<ChartMsg> {
405 self.render(ui)
406 }
407}
408
409facett_core::impl_facet_via_elm!(ChartView, custom_state_json, {
414 fn caps(&self) -> FacetCaps {
415 FacetCaps::NONE.themeable().copyable().resizable()
416 }
417 fn copy(&mut self) -> Option<String> {
418 Some(self.to_csv())
419 }
420 fn kind(&self) -> &'static str {
421 "plotters-chart"
422 }
423 fn update_json(&mut self, msg_json: &str) {
424 if let Ok(msg) = serde_json::from_str::<ChartMsg>(msg_json) {
425 let _ = <Self as facett_core::Elm>::update(self, msg);
426 }
427 }
428 fn state_json(&self) -> serde_json::Value {
429 let (xmin, xmax) = self.x_bounds().unzip();
430 let (ymin, ymax) = self.y_bounds().unzip();
431 serde_json::json!({
432 "kind": self.state.kind.as_str(),
433 "series_count": self.series.len(),
434 "hover": self.state.hover,
435 "x_bounds": [xmin, xmax],
436 "y_bounds": [ymin, ymax],
437 "series": self.series.iter().map(|s| serde_json::json!({
438 "label": s.label, "points": s.points.len(),
439 })).collect::<Vec<_>>(),
440 })
441 }
442});
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 fn sample() -> ChartView {
449 ChartView::new(
450 "Sales",
451 vec![
452 Series::new("north", vec![(0.0, 1.0), (1.0, 3.0), (2.0, 2.0)]),
453 Series::new("south", vec![(0.0, 2.0), (1.0, 1.0), (2.0, 4.0)]),
454 ],
455 )
456 }
457
458 #[test]
459 fn x_and_y_bounds_span_all_series() {
460 let v = sample();
461 assert_eq!(v.x_bounds(), Some((0.0, 2.0)));
462 assert_eq!(v.y_bounds(), Some((1.0, 4.0)));
463 }
464
465 #[test]
466 fn bar_bounds_include_zero_baseline() {
467 let v = ChartView::new("t", vec![Series::new("a", vec![(0.0, 5.0), (1.0, 9.0)])])
468 .with_kind(ChartKind::Bar);
469 assert_eq!(v.y_bounds(), Some((0.0, 9.0)));
471 }
472
473 #[test]
474 fn bounds_ignore_nonfinite_and_empty() {
475 assert_eq!(ChartView::new("t", vec![]).x_bounds(), None);
476 let v = ChartView::new("t", vec![Series::new("a", vec![(f64::NAN, 1.0), (2.0, f64::INFINITY)])]);
477 assert_eq!(v.x_bounds(), Some((2.0, 2.0)));
478 assert_eq!(v.y_bounds(), Some((1.0, 1.0)));
479 }
480
481 #[test]
482 fn csv_has_header_and_a_row_per_point() {
483 let v = sample();
484 let csv = v.to_csv();
485 assert!(csv.starts_with("series,x,y\n"));
486 assert_eq!(csv.lines().count(), 7); }
488
489 #[test]
490 fn typed_copy_is_the_dataset_csv() {
491 use facett_core::clip::{ClipKind, CopySource};
492 let v = sample();
493 let p = v.copy_payload().expect("non-empty chart copies");
494 assert_eq!(p.kind(), ClipKind::Text);
495 assert_eq!(p.as_text(), v.to_csv());
496 assert!(ChartView::new("t", vec![Series::new("a", vec![])]).copy_payload().is_none());
498 }
499
500 #[test]
502 fn switching_kind_is_observable() {
503 let mut v = sample();
504 assert_eq!(v.state().kind, ChartKind::Line);
505 let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::SetKind(ChartKind::Bar)]);
506 assert_eq!(snap.kind, ChartKind::Bar, "the kind switch is observable in the snapshot");
507 let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::SetKind(ChartKind::Scatter)]);
508 assert_eq!(snap.kind, ChartKind::Scatter);
509 }
510
511 #[test]
512 fn hover_transitions_are_observable() {
513 let mut v = sample();
514 let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::Hover(Some((1, 2)))]);
515 assert_eq!(snap.hover, Some((1, 2)));
516 let snap = facett_core::harness::snapshot(&mut v, [ChartMsg::Hover(None)]);
517 assert_eq!(snap.hover, None);
518 }
519
520 #[test]
521 fn drive_produces_no_effects() {
522 let mut v = sample();
523 let fx = facett_core::harness::drive(
524 &mut v,
525 [ChartMsg::SetKind(ChartKind::Bar), ChartMsg::Hover(Some((0, 0)))],
526 );
527 assert!(fx.is_empty(), "the chart does no I/O");
528 }
529
530 #[test]
531 fn msg_round_trips_through_json() {
532 let m: ChartMsg = serde_json::from_str(r#"{"set_kind":"scatter"}"#).unwrap();
534 assert_eq!(m, ChartMsg::SetKind(ChartKind::Scatter));
535 let m: ChartMsg = serde_json::from_str(r#"{"hover":[0,1]}"#).unwrap();
536 assert_eq!(m, ChartMsg::Hover(Some((0, 1))));
537 let m: ChartMsg = serde_json::from_str(r#"{"hover":null}"#).unwrap();
538 assert_eq!(m, ChartMsg::Hover(None));
539 }
540
541 #[test]
542 fn state_serde_round_trips() {
543 let st = ChartState { kind: ChartKind::Bar, hover: Some((2, 3)) };
544 let back: ChartState = serde_json::from_value(serde_json::to_value(&st).unwrap()).unwrap();
545 assert_eq!(st, back);
546 }
547}