1use frus_core::{Color, Path, Point, Rect, ResolvedTextStyle, Scene};
12use frus_layout::{Dimension, Style};
13
14use crate::interaction::Status;
15use crate::theme::Theme;
16use crate::widget::Widget;
17
18const DEFAULT_HEIGHT: f32 = 200.0;
20const X_LABEL_H: f32 = 22.0;
22const VALUE_SIZE: f32 = 12.0;
24const LABEL_SIZE: f32 = 12.0;
25const STRATA_LABEL_SIZE: f32 = 11.0;
27const BAR_FILL: f32 = 0.6;
29const Y_AXIS_W: f32 = 34.0;
31const AXIS_SIZE: f32 = 11.0;
33
34pub struct BarChart<Msg = crate::callback::Callback> {
47 values: Vec<(String, f32)>,
48 color: Option<Color>,
50 height: f32,
51 grid: usize,
53 name: Option<String>,
55 extra: Vec<(String, Color, Vec<f32>)>,
57 legend: bool,
59 hidden: Vec<usize>,
61 on_legend: Option<Box<dyn Fn(usize) -> Msg>>,
63 stacked: bool,
65 on_point: Option<Box<dyn Fn(usize, usize) -> Msg>>,
67 selected: Option<(usize, usize)>,
70 normalized: bool,
72}
73
74impl<Msg> BarChart<Msg> {
75 pub fn new(data: impl IntoIterator<Item = (impl Into<String>, f32)>) -> Self {
77 Self {
78 values: data
79 .into_iter()
80 .map(|(l, v)| (l.into(), v.max(0.0)))
81 .collect(),
82 color: None,
83 height: DEFAULT_HEIGHT,
84 grid: 0,
85 name: None,
86 extra: Vec::new(),
87 legend: false,
88 hidden: Vec::new(),
89 on_legend: None,
90 stacked: false,
91 on_point: None,
92 selected: None,
93 normalized: false,
94 }
95 }
96
97 pub fn color(mut self, color: Color) -> Self {
99 self.color = Some(color);
100 self
101 }
102
103 pub fn height(mut self, height: f32) -> Self {
105 self.height = height.max(X_LABEL_H + VALUE_SIZE + 8.0);
106 self
107 }
108
109 pub fn grid(mut self, divisions: usize) -> Self {
112 self.grid = divisions;
113 self
114 }
115
116 pub fn name(mut self, name: impl Into<String>) -> Self {
118 self.name = Some(name.into());
119 self
120 }
121
122 pub fn series(
125 mut self,
126 name: impl Into<String>,
127 color: Color,
128 values: impl IntoIterator<Item = f32>,
129 ) -> Self {
130 self.extra.push((
131 name.into(),
132 color,
133 values.into_iter().map(|v| v.max(0.0)).collect(),
134 ));
135 self
136 }
137
138 pub fn legend(mut self, legend: bool) -> Self {
140 self.legend = legend;
141 self
142 }
143
144 pub fn hidden(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
146 self.hidden = indices.into_iter().collect();
147 self
148 }
149
150 pub fn on_legend<R: crate::callback::IntoMsg<Msg>>(
152 mut self,
153 on_legend: impl Fn(usize) -> R + 'static,
154 ) -> Self {
155 self.on_legend = Some(Box::new(crate::callback::handler1(on_legend)));
156 self
157 }
158
159 pub fn stacked(mut self, stacked: bool) -> Self {
162 self.stacked = stacked;
163 self
164 }
165
166 pub fn on_point<R: crate::callback::IntoMsg<Msg>>(
169 mut self,
170 on_point: impl Fn(usize, usize) -> R + 'static,
171 ) -> Self {
172 self.on_point = Some(Box::new(crate::callback::handler2(on_point)));
173 self
174 }
175
176 pub fn selected(mut self, selected: Option<(usize, usize)>) -> Self {
179 self.selected = selected;
180 self
181 }
182
183 pub fn normalized(mut self, normalized: bool) -> Self {
187 self.normalized = normalized;
188 self
189 }
190
191 fn max_value(&self) -> f32 {
193 let primary = self.values.iter().map(|(_, v)| *v);
194 let extra = self.extra.iter().flat_map(|(_, _, vs)| vs.iter().copied());
195 primary.chain(extra).fold(0.0, f32::max).max(1.0)
196 }
197
198 fn category_total(&self, i: usize) -> f32 {
200 let base = if self.hidden.contains(&0) {
201 0.0
202 } else {
203 self.values.get(i).map(|(_, v)| *v).unwrap_or(0.0)
204 };
205 let rest: f32 = self
206 .extra
207 .iter()
208 .enumerate()
209 .filter(|(j, _)| !self.hidden.contains(&(j + 1)))
210 .map(|(_, (_, _, vs))| vs.get(i).copied().unwrap_or(0.0))
211 .sum();
212 (base + rest).max(1e-6)
213 }
214
215 fn stacked_max(&self) -> f32 {
217 let n = self.values.len();
218 (0..n)
219 .map(|i| {
220 self.values[i].1
221 + self
222 .extra
223 .iter()
224 .map(|(_, _, vs)| vs.get(i).copied().unwrap_or(0.0))
225 .sum::<f32>()
226 })
227 .fold(0.0, f32::max)
228 .max(1.0)
229 }
230
231 fn has_legend(&self) -> bool {
233 self.legend && (self.name.is_some() || !self.extra.is_empty())
234 }
235
236 fn series_names(&self) -> Vec<&str> {
238 let mut names = vec![self.name.as_deref().unwrap_or("Series 1")];
239 names.extend(self.extra.iter().map(|(n, _, _)| n.as_str()));
240 names
241 }
242}
243
244fn format_value(v: f32) -> String {
246 if (v.fract()).abs() < 1e-6 {
247 format!("{}", v as i64)
248 } else {
249 format!("{v:.1}")
250 }
251}
252
253fn format_measure(value: f32, percent_of: Option<f32>) -> String {
256 match percent_of {
257 Some(total) if total > 0.0 => {
258 format!(
259 "{} ({}%)",
260 format_value(value),
261 (value / total * 100.0).round() as i64
262 )
263 }
264 _ => format_value(value),
265 }
266}
267
268fn axis_width(divisions: usize) -> f32 {
270 if divisions > 0 {
271 Y_AXIS_W
272 } else {
273 0.0
274 }
275}
276
277#[allow(clippy::too_many_arguments)]
282fn draw_grid(
283 scene: &mut Scene,
284 theme: &Theme,
285 plot_left: f32,
286 plot_w: f32,
287 plot_top: f32,
288 baseline_y: f32,
289 max: f32,
290 divisions: usize,
291 percent: bool,
292 opacity: f32,
293) {
294 if divisions == 0 {
295 return;
296 }
297 let plot_h = baseline_y - plot_top;
298 for i in 0..=divisions {
299 let t = i as f32 / divisions as f32;
300 let y = baseline_y - plot_h * t;
301 if i > 0 {
303 scene.fill_rect(
304 Rect::new(plot_left, y, plot_w, 1.0),
305 theme.scheme.outline_variant.fade(opacity * 0.6),
306 );
307 }
308 let label = if percent {
310 format!("{}%", (t * 100.0).round() as i64)
311 } else {
312 format_value(max * t)
313 };
314 let lw = frus_text::measure(&label, AXIS_SIZE).width;
315 scene.text(
316 Point::new(plot_left - 6.0 - lw, y - AXIS_SIZE * 0.5),
317 label,
318 &ResolvedTextStyle::exact(AXIS_SIZE),
319 theme.muted.fade(opacity),
320 );
321 }
322}
323
324fn draw_legend(
327 scene: &mut Scene,
328 theme: &Theme,
329 left: f32,
330 top: f32,
331 series: &[(Color, &str)],
332 o: f32,
333) {
334 let mut x = left;
335 let sy = top + (LEGEND_H - LEGEND_SWATCH) * 0.5;
336 for (color, name) in series {
337 scene.draw_rect(
338 Rect::new(x, sy, LEGEND_SWATCH, LEGEND_SWATCH),
339 color.fade(o),
340 2.0,
341 0.0,
342 Color::TRANSPARENT,
343 );
344 x += LEGEND_SWATCH + 5.0;
345 scene.text(
346 Point::new(x, top + (LEGEND_H - LEGEND_SIZE) * 0.5),
347 (*name).to_string(),
348 &ResolvedTextStyle::exact(LEGEND_SIZE),
349 theme.muted.fade(o),
350 );
351 x += frus_text::measure(name, LEGEND_SIZE).width + 16.0;
352 }
353}
354
355fn legend_hit(local_x: f32, local_y: f32, plot_left: f32, names: &[&str]) -> Option<usize> {
358 if !(0.0..=LEGEND_H).contains(&local_y) {
359 return None;
360 }
361 let mut x = plot_left;
362 for (i, name) in names.iter().enumerate() {
363 let entry_w = LEGEND_SWATCH + 5.0 + frus_text::measure(name, LEGEND_SIZE).width;
364 if local_x >= x && local_x <= x + entry_w {
365 return Some(i);
366 }
367 x += entry_w + 16.0;
368 }
369 None
370}
371
372#[allow(clippy::too_many_arguments)]
377fn draw_tooltip(
378 scene: &mut Scene,
379 theme: &Theme,
380 bounds: Rect,
381 gx: f32,
382 plot_top: f32,
383 baseline_y: f32,
384 lines: &[(Option<Color>, String)],
385 o: f32,
386) {
387 scene.fill_rect(
388 Rect::new(gx - 0.5, plot_top, 1.0, baseline_y - plot_top),
389 theme.scheme.outline_variant.fade(o * 0.8),
390 );
391 let pad = 8.0;
392 let line_h = TOOLTIP_SIZE + 5.0;
393 let dot = 7.0;
394 let text_w = lines
395 .iter()
396 .map(|(c, t)| {
397 let lead = if c.is_some() { dot + 5.0 } else { 0.0 };
398 lead + frus_text::measure(t, TOOLTIP_SIZE).width
399 })
400 .fold(0.0, f32::max);
401 let bw = text_w + pad * 2.0;
402 let bh = lines.len() as f32 * line_h + pad * 2.0 - 3.0;
403 let mut bx = gx + 12.0;
404 if bx + bw > bounds.x + bounds.width {
405 bx = gx - 12.0 - bw;
406 }
407 bx = bx.max(bounds.x);
408 let by = plot_top.max(bounds.y);
409 scene.draw_rect(
410 Rect::new(bx, by, bw, bh),
411 theme.surface.fade(o),
412 6.0,
413 1.0,
414 theme.scheme.outline_variant.fade(o),
415 );
416 let mut ty = by + pad;
417 for (c, t) in lines {
418 let mut tx = bx + pad;
419 if let Some(col) = c {
420 scene.fill_path(
421 &Path::circle(
422 Point::new(tx + dot * 0.5, ty + TOOLTIP_SIZE * 0.5),
423 dot * 0.5,
424 ),
425 col.fade(o),
426 );
427 tx += dot + 5.0;
428 }
429 scene.text(
430 Point::new(tx, ty),
431 t.clone(),
432 &ResolvedTextStyle::exact(TOOLTIP_SIZE),
433 theme.on_surface.fade(o),
434 );
435 ty += line_h;
436 }
437}
438
439fn chart_plot_hit(
443 local_x: f32,
444 local_y: f32,
445 width: f32,
446 height: f32,
447 plot_left: f32,
448 plot_top: f32,
449) -> Option<crate::interaction::Cursor> {
450 let baseline_y = height - X_LABEL_H;
451 if local_x >= plot_left && local_x <= width && local_y >= plot_top && local_y <= baseline_y {
452 Some(crate::interaction::Cursor::Default)
453 } else {
454 None
455 }
456}
457
458impl<Msg> Widget<Msg> for BarChart<Msg> {
459 fn style(&self) -> Style {
463 Style {
464 height: Dimension::Length(self.height),
465 ..Default::default()
466 }
467 }
468
469 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
471 crate::widget::FillAxes::WIDTH
472 }
473
474 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
475 &[]
476 }
477
478 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
479 let n = self.values.len();
480 if n == 0 {
481 return;
482 }
483 let o = status.opacity;
484 let accent = self.color.unwrap_or(theme.primary);
485 let single = self.extra.is_empty();
486 let stacked = self.stacked && !single;
487 let normalized = self.normalized && stacked;
489 let max = if stacked {
491 self.stacked_max()
492 } else {
493 self.max_value()
494 };
495
496 let legend_h = if self.has_legend() { LEGEND_H } else { 0.0 };
499 let baseline_y = bounds.y + bounds.height - X_LABEL_H;
500 let plot_top = bounds.y + legend_h + VALUE_SIZE + 6.0;
501 let plot_h = (baseline_y - plot_top).max(1.0);
502 let axis_w = axis_width(self.grid);
503 let plot_left = bounds.x + axis_w;
504 let plot_w = bounds.width - axis_w;
505 let slot = plot_w / n as f32;
506
507 draw_grid(
509 scene, theme, plot_left, plot_w, plot_top, baseline_y, max, self.grid, normalized, o,
510 );
511 scene.fill_rect(
513 Rect::new(plot_left, baseline_y, plot_w, 1.5),
514 theme.scheme.outline_variant.fade(o),
515 );
516
517 let primary: Vec<f32> = self.values.iter().map(|(_, v)| *v).collect();
519 let mut series: Vec<(Color, &str, &[f32])> = vec![(
520 accent,
521 self.name.as_deref().unwrap_or("Series 1"),
522 primary.as_slice(),
523 )];
524 for (name, color, vals) in &self.extra {
525 series.push((*color, name.as_str(), vals.as_slice()));
526 }
527 let s = series.len();
528
529 let group_w = slot * BAR_FILL;
532 let bar_w = group_w / s as f32;
533 let inner = if s == 1 { 1.0 } else { 0.86 };
534 let mut sel_rect: Option<Rect> = None;
536 for i in 0..n {
537 let cx = plot_left + slot * (i as f32 + 0.5);
538 if stacked {
539 let sbx = cx - group_w * 0.5;
542 let denom = if normalized {
543 self.category_total(i)
544 } else {
545 max
546 };
547 let mut lower = 0.0_f32;
548 for (j, (color, _, vals)) in series.iter().enumerate() {
549 if self.hidden.contains(&j) {
550 continue;
551 }
552 let value = vals.get(i).copied().unwrap_or(0.0);
553 let y_bottom = baseline_y - (lower / denom) * plot_h;
554 let y_top = baseline_y - ((lower + value) / denom) * plot_h;
555 let rect = Rect::new(sbx, y_top, group_w, y_bottom - y_top);
556 scene.draw_rect(rect, color.fade(o), 0.0, 0.0, Color::TRANSPARENT);
557 if self.selected == Some((i, j)) {
558 sel_rect = Some(rect);
559 }
560 let seg_h = y_bottom - y_top;
564 if seg_h >= STRATA_LABEL_SIZE + 4.0 {
565 let label = if normalized {
566 format!("{}%", (value / denom * 100.0).round() as i64)
567 } else {
568 format_value(value)
569 };
570 let lw = frus_text::measure(&label, STRATA_LABEL_SIZE).width;
571 scene.text(
572 Point::new(
573 cx - lw * 0.5,
574 (y_top + y_bottom) * 0.5 - STRATA_LABEL_SIZE * 0.5,
575 ),
576 label,
577 &ResolvedTextStyle::exact(STRATA_LABEL_SIZE),
578 theme.on_primary.fade(o * 0.95),
579 );
580 }
581 lower += value;
582 }
583 if !normalized && lower > 0.0 {
586 let vs = format_value(lower);
587 let vw = frus_text::measure(&vs, VALUE_SIZE).width;
588 let top_y = baseline_y - (lower / denom) * plot_h;
589 scene.text(
590 Point::new(cx - vw * 0.5, top_y - VALUE_SIZE - 2.0),
591 vs,
592 &ResolvedTextStyle::exact(VALUE_SIZE),
593 theme.on_surface.fade(o),
594 );
595 }
596 } else {
597 let group_left = cx - group_w * 0.5;
598 for (j, (color, _, vals)) in series.iter().enumerate() {
599 if self.hidden.contains(&j) {
600 continue;
601 }
602 let value = vals.get(i).copied().unwrap_or(0.0);
603 let h = (value / max) * plot_h;
604 let draw_w = bar_w * inner;
605 let bx = group_left + j as f32 * bar_w + (bar_w - draw_w) * 0.5;
606 let rect = Rect::new(bx, baseline_y - h, draw_w, h);
607 scene.draw_rect(rect, color.fade(o), 4.0, 0.0, Color::TRANSPARENT);
608 if self.selected == Some((i, j)) {
609 sel_rect = Some(rect);
610 }
611 if single {
613 let vs = format_value(value);
614 let vw = frus_text::measure(&vs, VALUE_SIZE).width;
615 scene.text(
616 Point::new(cx - vw * 0.5, baseline_y - h - VALUE_SIZE - 2.0),
617 vs,
618 &ResolvedTextStyle::exact(VALUE_SIZE),
619 theme.on_surface.fade(o),
620 );
621 }
622 }
623 }
624 let label = &self.values[i].0;
626 let lw = frus_text::measure(label, LABEL_SIZE).width;
627 scene.text(
628 Point::new(cx - lw * 0.5, baseline_y + 4.0),
629 label.clone(),
630 &ResolvedTextStyle::exact(LABEL_SIZE),
631 theme.muted.fade(o),
632 );
633 }
634
635 if let Some(r) = sel_rect {
638 scene.draw_rect(
639 Rect::new(r.x - 2.5, r.y - 2.5, r.width + 5.0, r.height + 5.0),
640 Color::TRANSPARENT,
641 5.0,
642 2.0,
643 theme.on_surface.fade(o),
644 );
645 }
646
647 if self.has_legend() {
649 let entries: Vec<(Color, &str)> = series
650 .iter()
651 .enumerate()
652 .map(|(i, (c, name, _))| {
653 (
654 if self.hidden.contains(&i) {
655 c.fade(0.35)
656 } else {
657 *c
658 },
659 *name,
660 )
661 })
662 .collect();
663 draw_legend(scene, theme, plot_left, bounds.y, &entries, o);
664 }
665
666 if let Some(hc) = status.hover_cursor {
668 let lx = hc.x - bounds.x;
669 let hi =
670 (((lx - plot_left) / slot - 0.5).round() as i64).clamp(0, n as i64 - 1) as usize;
671 let gx = plot_left + slot * (hi as f32 + 0.5);
672 let mut lines: Vec<(Option<Color>, String)> = vec![(None, self.values[hi].0.clone())];
673 let percent_of = if normalized {
675 Some(self.category_total(hi))
676 } else {
677 None
678 };
679 for (j, (color, name, vals)) in series.iter().enumerate() {
680 if self.hidden.contains(&j) {
681 continue;
682 }
683 let value = vals.get(hi).copied().unwrap_or(0.0);
684 let vtxt = format_measure(value, percent_of);
685 let txt = if single {
686 vtxt
687 } else {
688 format!("{} {}", name, vtxt)
689 };
690 lines.push((Some(*color), txt));
691 }
692 draw_tooltip(scene, theme, bounds, gx, plot_top, baseline_y, &lines, o);
693 }
694 }
695
696 fn cursor_icon(
697 &self,
698 local_x: f32,
699 local_y: f32,
700 width: f32,
701 height: f32,
702 ) -> Option<crate::interaction::Cursor> {
703 if self.values.is_empty() {
704 return None;
705 }
706 let legend_h = if self.has_legend() { LEGEND_H } else { 0.0 };
707 let plot_top = legend_h + VALUE_SIZE + 6.0;
708 chart_plot_hit(
709 local_x,
710 local_y,
711 width,
712 height,
713 axis_width(self.grid),
714 plot_top,
715 )
716 }
717
718 fn positional_click(&self, local_x: f32, local_y: f32, width: f32, height: f32) -> Option<Msg> {
719 if let Some(f) = &self.on_legend {
721 if self.has_legend() {
722 if let Some(idx) = legend_hit(
723 local_x,
724 local_y,
725 axis_width(self.grid),
726 &self.series_names(),
727 ) {
728 return Some(f(idx));
729 }
730 }
731 }
732 let f = self.on_point.as_ref()?;
735 let n = self.values.len();
736 if n == 0 {
737 return None;
738 }
739 let single = self.extra.is_empty();
740 let stacked = self.stacked && !single;
741 let max = if stacked {
742 self.stacked_max()
743 } else {
744 self.max_value()
745 };
746 let legend_h = if self.has_legend() { LEGEND_H } else { 0.0 };
747 let baseline_y = height - X_LABEL_H;
748 let plot_top = legend_h + VALUE_SIZE + 6.0;
749 let plot_h = (baseline_y - plot_top).max(1.0);
750 let plot_left = axis_width(self.grid);
751 let slot = (width - plot_left) / n as f32;
752 let s = 1 + self.extra.len();
753 let group_w = slot * BAR_FILL;
754 let bar_w = group_w / s as f32;
755 let inner = if s == 1 { 1.0 } else { 0.86 };
756 let primary: Vec<f32> = self.values.iter().map(|(_, v)| *v).collect();
757 let value_at = |j: usize, i: usize| -> f32 {
758 if j == 0 {
759 primary.get(i).copied().unwrap_or(0.0)
760 } else {
761 self.extra[j - 1].2.get(i).copied().unwrap_or(0.0)
762 }
763 };
764 for i in 0..n {
765 let cx = plot_left + slot * (i as f32 + 0.5);
766 if stacked {
767 let sbx = cx - group_w * 0.5;
768 let mut lower = 0.0_f32;
769 for j in 0..s {
770 if self.hidden.contains(&j) {
771 continue;
772 }
773 let value = value_at(j, i);
774 let y_bottom = baseline_y - (lower / max) * plot_h;
775 let y_top = baseline_y - ((lower + value) / max) * plot_h;
776 if local_x >= sbx
777 && local_x <= sbx + group_w
778 && local_y >= y_top
779 && local_y <= y_bottom
780 {
781 return Some(f(i, j));
782 }
783 lower += value;
784 }
785 } else {
786 let group_left = cx - group_w * 0.5;
787 for j in 0..s {
788 if self.hidden.contains(&j) {
789 continue;
790 }
791 let h = (value_at(j, i) / max) * plot_h;
792 let draw_w = bar_w * inner;
793 let bx = group_left + j as f32 * bar_w + (bar_w - draw_w) * 0.5;
794 if local_x >= bx
795 && local_x <= bx + draw_w
796 && local_y >= baseline_y - h
797 && local_y <= baseline_y
798 {
799 return Some(f(i, j));
800 }
801 }
802 }
803 }
804 None
805 }
806
807 fn on_click(&self) -> Option<Msg> {
808 None
809 }
810}
811
812const MARKER_R: f32 = 3.5;
814const POINT_HIT_R: f32 = 12.0;
816const AREA_ALPHA: f32 = 0.16;
818const STACK_ALPHA: f32 = 0.55;
821const LEGEND_H: f32 = 20.0;
823const LEGEND_SWATCH: f32 = 10.0;
825const LEGEND_SIZE: f32 = 12.0;
827const TOOLTIP_SIZE: f32 = 12.0;
829const PULSE_SPEED: f32 = 1.6;
831const PULSE_GROW: f32 = 10.0;
833const LINE_W: f32 = 2.0;
835
836pub struct LineChart<Msg = crate::callback::Callback> {
844 values: Vec<(String, f32)>,
845 color: Option<Color>,
847 height: f32,
848 grid: usize,
850 fill: bool,
852 name: Option<String>,
854 extra: Vec<(String, Color, Vec<f32>)>,
857 legend: bool,
859 stacked: bool,
861 hidden: Vec<usize>,
863 on_legend: Option<Box<dyn Fn(usize) -> Msg>>,
865 animated: bool,
867 on_point: Option<Box<dyn Fn(usize, usize) -> Msg>>,
869 selected: Option<(usize, usize)>,
872 normalized: bool,
874}
875
876impl<Msg> LineChart<Msg> {
877 pub fn new(data: impl IntoIterator<Item = (impl Into<String>, f32)>) -> Self {
879 Self {
880 values: data
881 .into_iter()
882 .map(|(l, v)| (l.into(), v.max(0.0)))
883 .collect(),
884 color: None,
885 height: DEFAULT_HEIGHT,
886 grid: 0,
887 fill: false,
888 name: None,
889 extra: Vec::new(),
890 legend: false,
891 stacked: false,
892 hidden: Vec::new(),
893 on_legend: None,
894 animated: false,
895 on_point: None,
896 selected: None,
897 normalized: false,
898 }
899 }
900
901 pub fn color(mut self, color: Color) -> Self {
903 self.color = Some(color);
904 self
905 }
906
907 pub fn height(mut self, height: f32) -> Self {
909 self.height = height.max(X_LABEL_H + VALUE_SIZE + 8.0);
910 self
911 }
912
913 pub fn grid(mut self, divisions: usize) -> Self {
916 self.grid = divisions;
917 self
918 }
919
920 pub fn area(mut self, fill: bool) -> Self {
923 self.fill = fill;
924 self
925 }
926
927 pub fn name(mut self, name: impl Into<String>) -> Self {
929 self.name = Some(name.into());
930 self
931 }
932
933 pub fn series(
936 mut self,
937 name: impl Into<String>,
938 color: Color,
939 values: impl IntoIterator<Item = f32>,
940 ) -> Self {
941 self.extra.push((
942 name.into(),
943 color,
944 values.into_iter().map(|v| v.max(0.0)).collect(),
945 ));
946 self
947 }
948
949 pub fn legend(mut self, legend: bool) -> Self {
951 self.legend = legend;
952 self
953 }
954
955 pub fn stacked(mut self, stacked: bool) -> Self {
958 self.stacked = stacked;
959 self
960 }
961
962 pub fn hidden(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
964 self.hidden = indices.into_iter().collect();
965 self
966 }
967
968 pub fn on_legend<R: crate::callback::IntoMsg<Msg>>(
970 mut self,
971 on_legend: impl Fn(usize) -> R + 'static,
972 ) -> Self {
973 self.on_legend = Some(Box::new(crate::callback::handler1(on_legend)));
974 self
975 }
976
977 pub fn animated(mut self, animated: bool) -> Self {
980 self.animated = animated;
981 self
982 }
983
984 pub fn on_point<R: crate::callback::IntoMsg<Msg>>(
987 mut self,
988 on_point: impl Fn(usize, usize) -> R + 'static,
989 ) -> Self {
990 self.on_point = Some(Box::new(crate::callback::handler2(on_point)));
991 self
992 }
993
994 pub fn selected(mut self, selected: Option<(usize, usize)>) -> Self {
997 self.selected = selected;
998 self
999 }
1000
1001 pub fn normalized(mut self, normalized: bool) -> Self {
1005 self.normalized = normalized;
1006 self
1007 }
1008
1009 fn category_total(&self, i: usize) -> f32 {
1011 let base = if self.hidden.contains(&0) {
1012 0.0
1013 } else {
1014 self.values.get(i).map(|(_, v)| *v).unwrap_or(0.0)
1015 };
1016 let rest: f32 = self
1017 .extra
1018 .iter()
1019 .enumerate()
1020 .filter(|(j, _)| !self.hidden.contains(&(j + 1)))
1021 .map(|(_, (_, _, vs))| vs.get(i).copied().unwrap_or(0.0))
1022 .sum();
1023 (base + rest).max(1e-6)
1024 }
1025
1026 fn series_names(&self) -> Vec<&str> {
1028 let mut names = vec![self.name.as_deref().unwrap_or("Series 1")];
1029 names.extend(self.extra.iter().map(|(n, _, _)| n.as_str()));
1030 names
1031 }
1032
1033 fn max_value(&self) -> f32 {
1035 let primary = self.values.iter().map(|(_, v)| *v);
1036 let extra = self.extra.iter().flat_map(|(_, _, vs)| vs.iter().copied());
1037 primary.chain(extra).fold(0.0, f32::max).max(1.0)
1038 }
1039
1040 fn stacked_max(&self) -> f32 {
1042 let n = self.values.len();
1043 (0..n)
1044 .map(|i| {
1045 let base = self.values[i].1;
1046 let rest: f32 = self
1047 .extra
1048 .iter()
1049 .map(|(_, _, vs)| vs.get(i).copied().unwrap_or(0.0))
1050 .sum();
1051 base + rest
1052 })
1053 .fold(0.0, f32::max)
1054 .max(1.0)
1055 }
1056
1057 fn has_legend(&self) -> bool {
1059 self.legend && (self.name.is_some() || !self.extra.is_empty())
1060 }
1061}
1062
1063impl<Msg> Widget<Msg> for LineChart<Msg> {
1064 fn style(&self) -> Style {
1068 Style {
1069 height: Dimension::Length(self.height),
1070 ..Default::default()
1071 }
1072 }
1073
1074 fn fill_axes(&self, _theme: &Theme) -> crate::widget::FillAxes {
1076 crate::widget::FillAxes::WIDTH
1077 }
1078
1079 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1080 &[]
1081 }
1082
1083 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1084 let n = self.values.len();
1085 if n == 0 {
1086 return;
1087 }
1088 let o = status.opacity;
1089 let accent = self.color.unwrap_or(theme.primary);
1090 let single = self.extra.is_empty();
1091 let stacked = self.stacked && !single;
1092 let normalized = self.normalized && stacked;
1094 let max = if stacked {
1096 self.stacked_max()
1097 } else {
1098 self.max_value()
1099 };
1100
1101 let legend_h = if self.has_legend() { LEGEND_H } else { 0.0 };
1105 let baseline_y = bounds.y + bounds.height - X_LABEL_H;
1106 let plot_top = bounds.y + legend_h + VALUE_SIZE + 6.0;
1107 let plot_h = (baseline_y - plot_top).max(1.0);
1108 let axis_w = axis_width(self.grid);
1109 let plot_left = bounds.x + axis_w;
1110 let plot_w = bounds.width - axis_w;
1111 let slot = plot_w / n as f32;
1112
1113 draw_grid(
1115 scene, theme, plot_left, plot_w, plot_top, baseline_y, max, self.grid, normalized, o,
1116 );
1117
1118 scene.fill_rect(
1120 Rect::new(plot_left, baseline_y, plot_w, 1.5),
1121 theme.scheme.outline_variant.fade(o),
1122 );
1123
1124 let primary: Vec<f32> = self.values.iter().map(|(_, v)| *v).collect();
1126 let mut series: Vec<(Color, &str, &[f32])> = vec![(
1127 accent,
1128 self.name.as_deref().unwrap_or("Series 1"),
1129 primary.as_slice(),
1130 )];
1131 for (name, color, vals) in &self.extra {
1132 series.push((*color, name.as_str(), vals.as_slice()));
1133 }
1134
1135 let pt = |i: usize, v: f32| {
1137 Point::new(
1138 plot_left + slot * (i as f32 + 0.5),
1139 baseline_y - (v / max) * plot_h,
1140 )
1141 };
1142 let spt = |i: usize, cum: f32| {
1145 let denom = if normalized {
1146 self.category_total(i)
1147 } else {
1148 max
1149 };
1150 Point::new(
1151 plot_left + slot * (i as f32 + 0.5),
1152 baseline_y - (cum / denom) * plot_h,
1153 )
1154 };
1155
1156 if stacked {
1157 let mut lower = vec![0.0_f32; n];
1160 for (j, (color, _, vals)) in series.iter().enumerate() {
1161 if self.hidden.contains(&j) {
1162 continue;
1163 }
1164 let upper: Vec<f32> = (0..n)
1165 .map(|i| lower[i] + vals.get(i).copied().unwrap_or(0.0))
1166 .collect();
1167 if n >= 2 {
1168 let mut band = Path::new().move_to(spt(0, lower[0]));
1170 for (i, low) in lower.iter().enumerate().take(n).skip(1) {
1171 band = band.line_to(spt(i, *low));
1172 }
1173 for i in (0..n).rev() {
1174 band = band.line_to(spt(i, upper[i]));
1175 }
1176 scene.fill_path(&band, color.fade(o * STACK_ALPHA));
1177 let mut line = Path::new().move_to(spt(0, upper[0]));
1179 for (i, high) in upper.iter().enumerate().take(n).skip(1) {
1180 line = line.line_to(spt(i, *high));
1181 }
1182 scene.stroke_path(&line, color.fade(o), LINE_W);
1183 }
1184 for i in 0..n {
1188 let value = vals.get(i).copied().unwrap_or(0.0);
1189 if value <= 0.0 {
1190 continue;
1191 }
1192 let y_lo = spt(i, lower[i]).y;
1193 let y_hi = spt(i, upper[i]).y;
1194 if y_lo - y_hi >= STRATA_LABEL_SIZE + 4.0 {
1195 let label = if normalized {
1196 format!(
1197 "{}%",
1198 (value / self.category_total(i) * 100.0).round() as i64
1199 )
1200 } else {
1201 format_value(value)
1202 };
1203 let lw = frus_text::measure(&label, STRATA_LABEL_SIZE).width;
1204 let px = plot_left + slot * (i as f32 + 0.5);
1205 let lx = (px - lw * 0.5)
1207 .clamp(plot_left, (plot_left + plot_w - lw).max(plot_left));
1208 scene.text(
1209 Point::new(lx, (y_lo + y_hi) * 0.5 - STRATA_LABEL_SIZE * 0.5),
1210 label,
1211 &ResolvedTextStyle::exact(STRATA_LABEL_SIZE),
1212 theme.on_primary.fade(o * 0.95),
1213 );
1214 }
1215 }
1216 lower = upper;
1217 }
1218 } else {
1219 for (j, (color, _, vals)) in series.iter().enumerate() {
1220 if self.hidden.contains(&j) {
1221 continue;
1222 }
1223 let points: Vec<Point> = (0..n.min(vals.len())).map(|i| pt(i, vals[i])).collect();
1224 if single && self.fill && points.len() >= 2 {
1226 let mut area = Path::new().move_to(Point::new(points[0].x, baseline_y));
1227 for p in &points {
1228 area = area.line_to(*p);
1229 }
1230 area = area.line_to(Point::new(points[points.len() - 1].x, baseline_y));
1231 scene.fill_path(&area, color.fade(o * AREA_ALPHA));
1232 }
1233 if points.len() >= 2 {
1235 let mut line = Path::new().move_to(points[0]);
1236 for p in &points[1..] {
1237 line = line.line_to(*p);
1238 }
1239 scene.stroke_path(&line, color.fade(o), LINE_W);
1240 }
1241 for (i, p) in points.iter().enumerate() {
1243 scene.fill_path(&Path::circle(*p, MARKER_R), color.fade(o));
1244 if single {
1245 let vs = format_value(vals[i]);
1246 let vw = frus_text::measure(&vs, VALUE_SIZE).width;
1247 scene.text(
1248 Point::new(p.x - vw * 0.5, p.y - MARKER_R - VALUE_SIZE - 2.0),
1249 vs,
1250 &ResolvedTextStyle::exact(VALUE_SIZE),
1251 theme.on_surface.fade(o),
1252 );
1253 }
1254 }
1255 }
1256 }
1257
1258 if let Some((sc, ss)) = self.selected {
1261 if !stacked && ss < series.len() && !self.hidden.contains(&ss) && sc < n {
1262 let (color, _, vals) = series[ss];
1263 if let Some(&v) = vals.get(sc) {
1264 let p = pt(sc, v);
1265 scene.fill_path(&Path::circle(p, MARKER_R + 6.0), color.fade(o * 0.22));
1266 scene.stroke_path(&Path::circle(p, MARKER_R + 3.0), color.fade(o), 2.0);
1267 }
1268 }
1269 }
1270
1271 for (i, (label, _)) in self.values.iter().enumerate() {
1273 let lw = frus_text::measure(label, LABEL_SIZE).width;
1274 scene.text(
1275 Point::new(
1276 plot_left + slot * (i as f32 + 0.5) - lw * 0.5,
1277 baseline_y + 4.0,
1278 ),
1279 label.clone(),
1280 &ResolvedTextStyle::exact(LABEL_SIZE),
1281 theme.muted.fade(o),
1282 );
1283 }
1284
1285 if self.has_legend() {
1287 let entries: Vec<(Color, &str)> = series
1288 .iter()
1289 .enumerate()
1290 .map(|(i, (c, n, _))| {
1291 (
1292 if self.hidden.contains(&i) {
1293 c.fade(0.35)
1294 } else {
1295 *c
1296 },
1297 *n,
1298 )
1299 })
1300 .collect();
1301 draw_legend(scene, theme, plot_left, bounds.y, &entries, o);
1302 }
1303
1304 if let Some(hc) = status.hover_cursor {
1308 let lx = hc.x - bounds.x;
1309 let hi =
1310 (((lx - plot_left) / slot - 0.5).round() as i64).clamp(0, n as i64 - 1) as usize;
1311 let gx = plot_left + slot * (hi as f32 + 0.5);
1312 let mut lines: Vec<(Option<Color>, String)> = vec![(None, self.values[hi].0.clone())];
1313 let percent_of = if normalized {
1315 Some(self.category_total(hi))
1316 } else {
1317 None
1318 };
1319 for (j, (color, name, vals)) in series.iter().enumerate() {
1320 if self.hidden.contains(&j) || hi >= vals.len() {
1321 continue;
1322 }
1323 if !stacked {
1326 let py = baseline_y - (vals[hi] / max) * plot_h;
1327 if self.animated {
1329 let phase = (status.time * PULSE_SPEED).fract();
1330 let r = (MARKER_R + 2.0) + phase * PULSE_GROW;
1331 scene.fill_path(
1332 &Path::circle(Point::new(gx, py), r),
1333 color.fade(o * (1.0 - phase) * 0.4),
1334 );
1335 }
1336 scene.fill_path(
1337 &Path::circle(Point::new(gx, py), MARKER_R + 2.0),
1338 color.fade(o),
1339 );
1340 }
1341 let vtxt = format_measure(vals[hi], percent_of);
1342 let txt = if single {
1343 vtxt
1344 } else {
1345 format!("{} {}", name, vtxt)
1346 };
1347 lines.push((Some(*color), txt));
1348 }
1349 draw_tooltip(scene, theme, bounds, gx, plot_top, baseline_y, &lines, o);
1350 }
1351 }
1352
1353 fn cursor_icon(
1354 &self,
1355 local_x: f32,
1356 local_y: f32,
1357 width: f32,
1358 height: f32,
1359 ) -> Option<crate::interaction::Cursor> {
1360 if self.values.is_empty() {
1361 return None;
1362 }
1363 let legend_h = if self.has_legend() { LEGEND_H } else { 0.0 };
1364 let plot_top = legend_h + VALUE_SIZE + 6.0;
1365 chart_plot_hit(
1366 local_x,
1367 local_y,
1368 width,
1369 height,
1370 axis_width(self.grid),
1371 plot_top,
1372 )
1373 }
1374
1375 fn positional_click(&self, local_x: f32, local_y: f32, width: f32, height: f32) -> Option<Msg> {
1376 if let Some(f) = &self.on_legend {
1378 if self.has_legend() {
1379 if let Some(idx) = legend_hit(
1380 local_x,
1381 local_y,
1382 axis_width(self.grid),
1383 &self.series_names(),
1384 ) {
1385 return Some(f(idx));
1386 }
1387 }
1388 }
1389 let f = self.on_point.as_ref()?;
1392 let n = self.values.len();
1393 if n == 0 || (self.stacked && !self.extra.is_empty()) {
1394 return None;
1395 }
1396 let legend_h = if self.has_legend() { LEGEND_H } else { 0.0 };
1397 let baseline_y = height - X_LABEL_H;
1398 let plot_top = legend_h + VALUE_SIZE + 6.0;
1399 let plot_h = (baseline_y - plot_top).max(1.0);
1400 let plot_left = axis_width(self.grid);
1401 let slot = (width - plot_left) / n as f32;
1402 let max = self.max_value();
1403 let primary: Vec<f32> = self.values.iter().map(|(_, v)| *v).collect();
1404 for j in 0..(1 + self.extra.len()) {
1405 if self.hidden.contains(&j) {
1406 continue;
1407 }
1408 let vals: &[f32] = if j == 0 {
1409 &primary
1410 } else {
1411 &self.extra[j - 1].2
1412 };
1413 for (i, &v) in vals.iter().enumerate().take(n) {
1414 let px = plot_left + slot * (i as f32 + 0.5);
1415 let py = baseline_y - (v / max) * plot_h;
1416 if (local_x - px).hypot(local_y - py) <= POINT_HIT_R {
1417 return Some(f(i, j));
1418 }
1419 }
1420 }
1421 None
1422 }
1423
1424 fn continuous(&self) -> bool {
1425 self.animated
1427 }
1428
1429 fn on_click(&self) -> Option<Msg> {
1430 None
1431 }
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436 use super::*;
1437 use frus_core::Primitive;
1438
1439 fn paint_chart(chart: &BarChart, w: f32, h: f32) -> Vec<Primitive> {
1440 let mut scene = Scene::new();
1441 Widget::<crate::callback::Callback>::paint(
1442 chart,
1443 Rect::new(0.0, 0.0, w, h),
1444 Status::default(),
1445 &Theme::default(),
1446 &mut scene,
1447 );
1448 scene.primitives().to_vec()
1449 }
1450
1451 #[test]
1452 fn value_formatting() {
1453 assert_eq!(format_value(3.0), "3");
1454 assert_eq!(format_value(2.5), "2.5");
1455 }
1456
1457 #[test]
1458 fn empty_series_paints_nothing() {
1459 assert!(paint_chart(&BarChart::new(Vec::<(String, f32)>::new()), 300.0, 200.0).is_empty());
1460 }
1461
1462 #[test]
1463 fn bars_scale_to_the_max_value() {
1464 let chart = BarChart::new([("A", 2.0), ("B", 6.0), ("C", 4.0)]);
1466 let prims = paint_chart(&chart, 300.0, 200.0);
1467 let bar_heights: Vec<f32> = prims
1469 .iter()
1470 .filter_map(|p| match p {
1471 Primitive::Rect { rect, .. } if rect.height > 2.0 => Some(rect.height),
1473 _ => None,
1474 })
1475 .collect();
1476 assert_eq!(bar_heights.len(), 3, "one bar per value");
1477 let max_h = bar_heights.iter().cloned().fold(0.0_f32, f32::max);
1479 let min_h = bar_heights.iter().cloned().fold(f32::MAX, f32::min);
1480 assert!(
1481 max_h > min_h * 2.5,
1482 "6 is three times 2: {max_h} vs {min_h}"
1483 );
1484 let has_text = |t: &str| {
1486 prims
1487 .iter()
1488 .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
1489 };
1490 assert!(
1491 has_text("6") && has_text("2") && has_text("4"),
1492 "values displayed"
1493 );
1494 assert!(
1495 has_text("A") && has_text("B") && has_text("C"),
1496 "labels displayed"
1497 );
1498 }
1499
1500 #[test]
1501 fn grouped_series_draw_a_bar_per_series_and_a_legend() {
1502 let chart = BarChart::new([("A", 4.0), ("B", 8.0), ("C", 6.0)])
1503 .name("This year")
1504 .series("Last year", Color::rgb8(200, 120, 80), [3.0, 7.0, 5.0])
1505 .legend(true);
1506 let prims = paint_chart(&chart, 320.0, 220.0);
1507 let bars = prims
1509 .iter()
1510 .filter(|p| matches!(p, Primitive::Rect { rect, .. } if rect.height > 15.0))
1511 .count();
1512 assert_eq!(bars, 6, "one bar per (category, series)");
1513 let swatches = prims
1515 .iter()
1516 .filter(|p| matches!(p, Primitive::Rect { rect, .. }
1517 if (rect.width - LEGEND_SWATCH).abs() < 0.5 && (rect.height - LEGEND_SWATCH).abs() < 0.5))
1518 .count();
1519 assert_eq!(swatches, 2, "one swatch per series");
1520 let has_text = |t: &str| {
1521 prims
1522 .iter()
1523 .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
1524 };
1525 assert!(
1526 has_text("This year") && has_text("Last year"),
1527 "series names in the legend"
1528 );
1529 }
1530
1531 #[test]
1532 fn stacked_bars_share_one_column_per_category() {
1533 let chart = BarChart::new([("A", 2.0), ("B", 4.0)])
1534 .series("x", Color::rgb8(1, 2, 3), [3.0, 1.0])
1535 .stacked(true);
1536 assert_eq!(chart.stacked_max(), 5.0);
1538 let prims = paint_chart(&chart, 300.0, 200.0);
1539 let seg_widths: Vec<f32> = prims
1540 .iter()
1541 .filter_map(|p| match p {
1542 Primitive::Rect { rect, .. } if rect.height > 2.0 => Some(rect.width),
1543 _ => None,
1544 })
1545 .collect();
1546 assert_eq!(seg_widths.len(), 4, "one segment per (category, series)");
1548 let group_w = (300.0 / 2.0) * BAR_FILL;
1549 assert!(
1550 seg_widths.iter().all(|w| (w - group_w).abs() < 0.5),
1551 "stacked segments = full width, got {seg_widths:?}"
1552 );
1553 }
1554
1555 #[test]
1556 fn normalized_stacked_bars_fill_each_column() {
1557 let make = |norm: bool| {
1559 BarChart::new([("A", 2.0), ("B", 4.0)])
1560 .series("x", Color::rgb8(1, 2, 3), [3.0, 4.0])
1561 .stacked(true)
1562 .normalized(norm)
1563 };
1564 let col_a = |chart: &BarChart| {
1566 paint_chart(chart, 300.0, 200.0)
1567 .iter()
1568 .filter_map(|p| match p {
1569 Primitive::Rect { rect, .. } if rect.height > 2.0 && rect.x < 150.0 => {
1570 Some(rect.height)
1571 }
1572 _ => None,
1573 })
1574 .sum::<f32>()
1575 };
1576 let plot_h = (200.0 - X_LABEL_H) - (VALUE_SIZE + 6.0); assert!(
1580 (col_a(&make(true)) - plot_h).abs() < 1.0,
1581 "column A full in 100% mode, got {}",
1582 col_a(&make(true))
1583 );
1584 assert!(
1586 col_a(&make(false)) < plot_h - 20.0,
1587 "column A partial in absolute mode, got {}",
1588 col_a(&make(false))
1589 );
1590 let prims = paint_chart(&make(true).grid(4), 300.0, 200.0);
1592 assert!(
1593 prims
1594 .iter()
1595 .any(|p| matches!(p, Primitive::Text { text, .. } if text == "100%")),
1596 "the axis in percentages"
1597 );
1598 }
1599
1600 #[test]
1601 fn normalized_bar_tooltip_shows_percentages() {
1602 let make = |norm: bool| {
1604 BarChart::new([("A", 2.0), ("B", 6.0)])
1605 .series("x", Color::rgb8(1, 2, 3), [2.0, 2.0])
1606 .stacked(true)
1607 .normalized(norm)
1608 };
1609 let tooltip_texts = |chart: &BarChart| -> Vec<String> {
1610 let mut scene = Scene::new();
1611 let status = Status {
1613 hover_cursor: Some(Point::new(75.0, 90.0)),
1614 ..Default::default()
1615 };
1616 Widget::<crate::callback::Callback>::paint(
1617 chart,
1618 Rect::new(0.0, 0.0, 300.0, 200.0),
1619 status,
1620 &Theme::default(),
1621 &mut scene,
1622 );
1623 scene
1624 .primitives()
1625 .iter()
1626 .filter_map(|p| match p {
1627 Primitive::Text { text, .. } => Some(text.clone()),
1628 _ => None,
1629 })
1630 .collect()
1631 };
1632 let norm = tooltip_texts(&make(true));
1634 assert!(
1635 norm.iter().any(|t| t.contains("(50%)")),
1636 "a % share in the 100%-mode tooltip, got {norm:?}"
1637 );
1638 let abs = tooltip_texts(&make(false));
1640 assert!(
1641 !abs.iter().any(|t| t.contains('%')),
1642 "no % in absolute mode, got {abs:?}"
1643 );
1644 }
1645
1646 #[test]
1647 fn normalized_bars_label_each_strata_with_its_percentage() {
1648 let make = |norm: bool| {
1650 BarChart::new([("A", 2.0), ("B", 6.0)])
1651 .series("x", Color::rgb8(1, 2, 3), [2.0, 2.0])
1652 .stacked(true)
1653 .normalized(norm)
1654 };
1655 let pct_labels = |chart: &BarChart| {
1657 paint_chart(chart, 300.0, 260.0)
1658 .iter()
1659 .filter(|p| matches!(p, Primitive::Text { text, .. } if text.ends_with('%')))
1660 .count()
1661 };
1662 assert_eq!(pct_labels(&make(true)), 4, "one % share per stratum");
1664 assert_eq!(pct_labels(&make(false)), 0, "no % in absolute mode");
1665 }
1666
1667 #[test]
1668 fn stacked_absolute_bars_show_the_column_total() {
1669 let make = || {
1670 BarChart::new([("A", 2.0), ("B", 4.0)]).series("x", Color::rgb8(1, 2, 3), [3.0, 1.0])
1671 };
1672 let abs = paint_chart(&make().stacked(true), 300.0, 220.0);
1674 let count5 = abs
1675 .iter()
1676 .filter(|p| matches!(p, Primitive::Text { text, .. } if text == "5"))
1677 .count();
1678 assert_eq!(count5, 2, "one total per column (5 and 5)");
1679 let norm = paint_chart(&make().stacked(true).normalized(true), 300.0, 220.0);
1681 assert!(
1682 !norm
1683 .iter()
1684 .any(|p| matches!(p, Primitive::Text { text, .. } if text == "5")),
1685 "no raw total in 100% mode"
1686 );
1687 }
1688
1689 #[test]
1690 fn stacked_absolute_bars_label_each_strata_with_its_value() {
1691 let chart = BarChart::new([("A", 3.0), ("B", 5.0)])
1693 .series("x", Color::rgb8(1, 2, 3), [4.0, 6.0])
1694 .stacked(true);
1695 let prims = paint_chart(&chart, 300.0, 260.0);
1696 let has = |t: &str| {
1697 prims
1698 .iter()
1699 .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
1700 };
1701 assert!(
1703 has("3") && has("4") && has("6"),
1704 "every stratum carries its value"
1705 );
1706 assert!(has("7") && has("11"), "column total kept");
1708 }
1709
1710 #[test]
1711 fn hovering_bars_shows_a_tooltip_guide() {
1712 use crate::interaction::Cursor;
1713 let chart = BarChart::new([("A", 2.0), ("B", 6.0), ("C", 4.0)]);
1714 let guides = |status: Status| {
1715 let mut scene = Scene::new();
1716 Widget::<crate::callback::Callback>::paint(
1717 &chart,
1718 Rect::new(0.0, 0.0, 300.0, 220.0),
1719 status,
1720 &Theme::default(),
1721 &mut scene,
1722 );
1723 scene
1724 .primitives()
1725 .iter()
1726 .filter(|p| matches!(p, Primitive::Rect { rect, .. } if rect.width <= 1.5 && rect.height > 50.0))
1727 .count()
1728 };
1729 let hovering = Status {
1730 hover_cursor: Some(Point::new(150.0, 100.0)),
1731 ..Default::default()
1732 };
1733 assert_eq!(guides(hovering), 1, "one guide when the area is hovered");
1734 assert_eq!(guides(Status::default()), 0, "no tooltip without hover");
1735 assert_eq!(
1736 Widget::<crate::callback::Callback>::cursor_icon(&chart, 150.0, 100.0, 300.0, 220.0),
1737 Some(Cursor::Default)
1738 );
1739 assert_eq!(
1740 Widget::<crate::callback::Callback>::cursor_icon(&chart, 150.0, 5.0, 300.0, 220.0),
1741 None
1742 );
1743 }
1744
1745 #[test]
1746 fn clicking_a_bar_emits_category_and_series() {
1747 let chart = BarChart::<(usize, usize)>::new([("A", 2.0), ("B", 6.0)])
1750 .series("x", Color::rgb8(1, 2, 3), [4.0, 1.0])
1751 .on_point(|c, s| (c, s));
1752 let baseline_y = 200.0 - X_LABEL_H;
1753 let plot_h = baseline_y - (VALUE_SIZE + 6.0);
1754 let slot = 300.0 / 2.0;
1755 let group_w = slot * BAR_FILL;
1756 let bar_w = group_w / 2.0;
1757 let cx = slot * 0.5;
1759 let group_left = cx - group_w * 0.5;
1760 let bx = group_left + bar_w + (bar_w - bar_w * 0.86) * 0.5;
1761 let bar_cx = bx + bar_w * 0.86 * 0.5;
1762 let mid_y = baseline_y - (4.0 / 6.0) * plot_h * 0.5;
1763 assert_eq!(
1764 Widget::<(usize, usize)>::positional_click(&chart, bar_cx, mid_y, 300.0, 200.0),
1765 Some((0, 1)),
1766 "a click in the middle of category A's 2nd bar"
1767 );
1768 assert_eq!(
1770 Widget::<(usize, usize)>::positional_click(&chart, bar_cx, 2.0, 300.0, 200.0),
1771 None
1772 );
1773 let stacked = BarChart::<(usize, usize)>::new([("A", 2.0), ("B", 6.0)])
1775 .series("x", Color::rgb8(1, 2, 3), [4.0, 1.0])
1776 .stacked(true)
1777 .on_point(|c, s| (c, s));
1778 let low_y = baseline_y - (1.0 / 6.0) * plot_h; assert_eq!(
1781 Widget::<(usize, usize)>::positional_click(&stacked, cx, low_y, 300.0, 200.0),
1782 Some((0, 0)),
1783 "a click low in the column = stratum 0"
1784 );
1785 let hidden = BarChart::<(usize, usize)>::new([("A", 2.0), ("B", 6.0)])
1787 .series("x", Color::rgb8(1, 2, 3), [4.0, 1.0])
1788 .on_point(|c, s| (c, s))
1789 .hidden([1]);
1790 assert_eq!(
1791 Widget::<(usize, usize)>::positional_click(&hidden, bar_cx, mid_y, 300.0, 200.0),
1792 None,
1793 "a hidden series' bar: no click"
1794 );
1795 }
1796
1797 #[test]
1798 fn selected_bar_draws_a_persistent_ring() {
1799 let rings = |chart: &BarChart| {
1801 paint_chart(chart, 300.0, 200.0)
1802 .iter()
1803 .filter(
1804 |p| matches!(p, Primitive::Rect { border_width, .. } if *border_width > 0.5),
1805 )
1806 .count()
1807 };
1808 assert_eq!(
1809 rings(&BarChart::new([("A", 2.0), ("B", 6.0)])),
1810 0,
1811 "no ring without a selection"
1812 );
1813 assert_eq!(
1814 rings(&BarChart::new([("A", 2.0), ("B", 6.0)]).selected(Some((1, 0)))),
1815 1,
1816 "one ring on the pinned bar"
1817 );
1818 let hidden = BarChart::new([("A", 2.0), ("B", 6.0)])
1820 .series("x", Color::rgb8(1, 2, 3), [3.0, 1.0])
1821 .hidden([1])
1822 .selected(Some((0, 1)));
1823 assert_eq!(rings(&hidden), 0, "no ring on a hidden series");
1824 }
1825
1826 fn paint_line(chart: &LineChart, w: f32, h: f32) -> Vec<Primitive> {
1827 let mut scene = Scene::new();
1828 Widget::<crate::callback::Callback>::paint(
1829 chart,
1830 Rect::new(0.0, 0.0, w, h),
1831 Status::default(),
1832 &Theme::default(),
1833 &mut scene,
1834 );
1835 scene.primitives().to_vec()
1836 }
1837
1838 #[test]
1839 fn line_empty_series_paints_nothing() {
1840 assert!(paint_line(&LineChart::new(Vec::<(String, f32)>::new()), 300.0, 200.0).is_empty());
1841 }
1842
1843 #[test]
1844 fn line_connects_all_points() {
1845 let chart = LineChart::new([("A", 2.0), ("B", 6.0), ("C", 4.0)]);
1846 let prims = paint_line(&chart, 300.0, 200.0);
1847 let polyline = prims.iter().find_map(|p| match p {
1849 Primitive::Path {
1850 path,
1851 stroke: Some(_),
1852 fill: None,
1853 ..
1854 } => Some(path),
1855 _ => None,
1856 });
1857 let polyline = polyline.expect("a stroked polyline");
1858 let segments = polyline
1860 .verbs()
1861 .iter()
1862 .filter(|v| matches!(v, frus_core::PathVerb::LineTo(_)))
1863 .count();
1864 assert_eq!(segments, 2, "deux segments relient trois points");
1865 let markers = prims
1867 .iter()
1868 .filter(|p| {
1869 matches!(
1870 p,
1871 Primitive::Path {
1872 fill: Some(_),
1873 stroke: None,
1874 ..
1875 }
1876 )
1877 })
1878 .count();
1879 assert_eq!(markers, 3, "one marker per point");
1880 let has_text = |t: &str| {
1882 prims
1883 .iter()
1884 .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
1885 };
1886 assert!(
1887 has_text("6") && has_text("2") && has_text("4"),
1888 "values displayed"
1889 );
1890 assert!(
1891 has_text("A") && has_text("B") && has_text("C"),
1892 "labels displayed"
1893 );
1894 }
1895
1896 #[test]
1897 fn hovering_the_plot_shows_a_tooltip_guide() {
1898 use crate::interaction::Cursor;
1899 let chart = LineChart::new([("A", 2.0), ("B", 6.0), ("C", 4.0)]);
1900 let guides = |status: Status| {
1902 let mut scene = Scene::new();
1903 Widget::<crate::callback::Callback>::paint(
1904 &chart,
1905 Rect::new(0.0, 0.0, 300.0, 220.0),
1906 status,
1907 &Theme::default(),
1908 &mut scene,
1909 );
1910 scene
1911 .primitives()
1912 .iter()
1913 .filter(|p| {
1914 matches!(p, Primitive::Rect { rect, .. }
1915 if rect.width <= 1.5 && rect.height > 50.0)
1916 })
1917 .count()
1918 };
1919 let hovering = Status {
1920 hover_cursor: Some(Point::new(150.0, 100.0)),
1921 ..Default::default()
1922 };
1923 assert_eq!(
1924 guides(hovering),
1925 1,
1926 "one vertical guide when the area is hovered"
1927 );
1928 assert_eq!(guides(Status::default()), 0, "no tooltip without hover");
1929 assert_eq!(
1931 Widget::<crate::callback::Callback>::cursor_icon(&chart, 150.0, 100.0, 300.0, 220.0),
1932 Some(Cursor::Default)
1933 );
1934 assert_eq!(
1935 Widget::<crate::callback::Callback>::cursor_icon(&chart, 150.0, 5.0, 300.0, 220.0),
1936 None,
1937 "above the area"
1938 );
1939 }
1940
1941 #[test]
1942 fn multi_series_draws_each_line_and_a_legend() {
1943 let chart = LineChart::new([("A", 2.0), ("B", 6.0), ("C", 4.0)])
1944 .name("Sales")
1945 .series(
1946 "Costs",
1947 frus_core::Color::rgb8(200, 80, 80),
1948 [1.0, 3.0, 2.0],
1949 )
1950 .legend(true);
1951 let prims = paint_line(&chart, 300.0, 220.0);
1952 let polylines = prims
1954 .iter()
1955 .filter(|p| {
1956 matches!(
1957 p,
1958 Primitive::Path {
1959 stroke: Some(_),
1960 fill: None,
1961 ..
1962 }
1963 )
1964 })
1965 .count();
1966 assert_eq!(polylines, 2, "one polyline per series");
1967 let swatches = prims
1969 .iter()
1970 .filter(|p| matches!(p, Primitive::Rect { rect, .. }
1971 if (rect.width - LEGEND_SWATCH).abs() < 0.5 && (rect.height - LEGEND_SWATCH).abs() < 0.5))
1972 .count();
1973 assert_eq!(swatches, 2, "one swatch per series");
1974 let has_text = |t: &str| {
1976 prims
1977 .iter()
1978 .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
1979 };
1980 assert!(
1981 has_text("Sales") && has_text("Costs"),
1982 "series names in the legend"
1983 );
1984 }
1985
1986 #[test]
1987 fn legend_click_emits_the_series_index() {
1988 let chart = LineChart::<usize>::new([("A", 1.0)])
1989 .name("Sales")
1990 .series("Costs", Color::rgb8(1, 2, 3), [2.0])
1991 .legend(true)
1992 .on_legend(|i| i);
1993 assert_eq!(
1995 Widget::<usize>::positional_click(&chart, 5.0, 10.0, 300.0, 200.0),
1996 Some(0)
1997 );
1998 let after_first =
2000 LEGEND_SWATCH + 5.0 + frus_text::measure("Sales", LEGEND_SIZE).width + 16.0;
2001 assert_eq!(
2002 Widget::<usize>::positional_click(&chart, after_first + 4.0, 10.0, 300.0, 200.0),
2003 Some(1)
2004 );
2005 assert_eq!(
2007 Widget::<usize>::positional_click(&chart, 5.0, 100.0, 300.0, 200.0),
2008 None
2009 );
2010 let plain = LineChart::<usize>::new([("A", 1.0)])
2012 .name("Sales")
2013 .series("Costs", Color::rgb8(1, 2, 3), [2.0])
2014 .legend(true);
2015 assert_eq!(
2016 Widget::<usize>::positional_click(&plain, 5.0, 10.0, 300.0, 200.0),
2017 None
2018 );
2019 }
2020
2021 #[test]
2022 fn animated_pulse_adds_a_halo_and_requests_continuous_repaint() {
2023 let animated = LineChart::new([("A", 2.0), ("B", 6.0)]).animated(true);
2024 let plain = LineChart::new([("A", 2.0), ("B", 6.0)]);
2025 assert!(
2026 Widget::<crate::callback::Callback>::continuous(&animated),
2027 "animated => a continuous repaint"
2028 );
2029 assert!(
2030 !Widget::<crate::callback::Callback>::continuous(&plain),
2031 "static => no continuous repaint"
2032 );
2033 let circles = |chart: &LineChart, t: f32| {
2035 let mut scene = Scene::new();
2036 let status = Status {
2037 hover_cursor: Some(Point::new(150.0, 100.0)),
2038 time: t,
2039 ..Default::default()
2040 };
2041 Widget::<crate::callback::Callback>::paint(
2042 chart,
2043 Rect::new(0.0, 0.0, 300.0, 200.0),
2044 status,
2045 &Theme::default(),
2046 &mut scene,
2047 );
2048 scene
2049 .primitives()
2050 .iter()
2051 .filter(|p| {
2052 matches!(p, Primitive::Path { fill: Some(_), stroke: None, path, .. }
2053 if !path.verbs().iter().any(|v| matches!(v, frus_core::PathVerb::LineTo(_))))
2054 })
2055 .count()
2056 };
2057 assert!(
2059 circles(&animated, 0.1) > circles(&plain, 0.1),
2060 "the animated halo adds a circle"
2061 );
2062 }
2063
2064 #[test]
2065 fn clicking_a_point_emits_category_and_series() {
2066 let chart = LineChart::<(usize, usize)>::new([("A", 2.0), ("B", 6.0)])
2067 .series("x", Color::rgb8(1, 2, 3), [4.0, 1.0])
2068 .on_point(|c, s| (c, s));
2069 let baseline_y = 200.0 - X_LABEL_H;
2071 let plot_h = baseline_y - (VALUE_SIZE + 6.0);
2072 let slot = 300.0 / 2.0;
2073 let px = slot * 0.5; let py_primary = baseline_y - (2.0 / 6.0) * plot_h; assert_eq!(
2076 Widget::<(usize, usize)>::positional_click(&chart, px, py_primary, 300.0, 200.0),
2077 Some((0, 0)),
2078 "a click on point A of the main series"
2079 );
2080 assert_eq!(
2082 Widget::<(usize, usize)>::positional_click(&chart, slot, 5.0, 300.0, 200.0),
2083 None
2084 );
2085 let hidden = LineChart::<(usize, usize)>::new([("A", 2.0), ("B", 6.0)])
2087 .series("x", Color::rgb8(1, 2, 3), [4.0, 1.0])
2088 .on_point(|c, s| (c, s))
2089 .hidden([0]);
2090 assert_eq!(
2091 Widget::<(usize, usize)>::positional_click(&hidden, px, py_primary, 300.0, 200.0),
2092 None,
2093 "a hidden series' point: no click"
2094 );
2095 }
2096
2097 #[test]
2098 fn selected_point_draws_a_persistent_ring() {
2099 let rings = |chart: &LineChart| {
2101 paint_line(chart, 300.0, 200.0)
2102 .iter()
2103 .filter(|p| {
2104 matches!(p, Primitive::Path { stroke: Some(_), fill: None, path, .. }
2105 if !path.verbs().iter().any(|v| matches!(v, frus_core::PathVerb::LineTo(_))))
2106 })
2107 .count()
2108 };
2109 assert_eq!(
2110 rings(&LineChart::new([("A", 2.0), ("B", 6.0)])),
2111 0,
2112 "no ring without a selection"
2113 );
2114 assert_eq!(
2115 rings(&LineChart::new([("A", 2.0), ("B", 6.0)]).selected(Some((0, 0)))),
2116 1,
2117 "one ring on the pinned point"
2118 );
2119 let hidden = LineChart::new([("A", 2.0), ("B", 6.0)])
2121 .series("x", Color::rgb8(1, 2, 3), [3.0, 1.0])
2122 .hidden([0])
2123 .selected(Some((0, 0)));
2124 assert_eq!(rings(&hidden), 0, "no ring on a hidden series");
2125 }
2126
2127 #[test]
2128 fn hidden_series_is_not_drawn() {
2129 let strokes = |chart: &LineChart| {
2130 paint_line(chart, 300.0, 200.0)
2131 .iter()
2132 .filter(|p| {
2133 matches!(
2134 p,
2135 Primitive::Path {
2136 stroke: Some(_),
2137 fill: None,
2138 ..
2139 }
2140 )
2141 })
2142 .count()
2143 };
2144 let both = LineChart::new([("A", 2.0), ("B", 6.0)]).series(
2145 "x",
2146 Color::rgb8(200, 80, 80),
2147 [3.0, 1.0],
2148 );
2149 assert_eq!(strokes(&both), 2, "two visible series = two lines");
2150 let hidden = LineChart::new([("A", 2.0), ("B", 6.0)])
2151 .series("x", Color::rgb8(200, 80, 80), [3.0, 1.0])
2152 .hidden([1]);
2153 assert_eq!(strokes(&hidden), 1, "the hidden series is not drawn");
2154 }
2155
2156 #[test]
2157 fn stacked_areas_fill_a_band_per_series() {
2158 let make = || {
2159 LineChart::new([("A", 2.0), ("B", 4.0)]).series(
2160 "x",
2161 Color::rgb8(200, 80, 80),
2162 [3.0, 1.0],
2163 )
2164 };
2165 assert_eq!(make().stacked(true).stacked_max(), 5.0);
2167 let bands = |chart: &LineChart| {
2168 paint_line(chart, 300.0, 200.0)
2169 .iter()
2170 .filter(|p| {
2171 matches!(p, Primitive::Path { fill: Some(_), stroke: None, path, .. }
2172 if path.verbs().iter().any(|v| matches!(v, frus_core::PathVerb::LineTo(_))))
2173 })
2174 .count()
2175 };
2176 assert_eq!(
2177 bands(&make().stacked(true)),
2178 2,
2179 "one cumulative band per series"
2180 );
2181 assert_eq!(
2182 bands(&make()),
2183 0,
2184 "without stacking: no band (multi-series with no area)"
2185 );
2186 }
2187
2188 #[test]
2189 fn normalized_stacked_areas_fill_to_the_top() {
2190 let make = |norm: bool| {
2191 LineChart::new([("A", 2.0), ("B", 4.0)])
2192 .series("x", Color::rgb8(1, 2, 3), [3.0, 4.0])
2193 .stacked(true)
2194 .normalized(norm)
2195 };
2196 let top_line_ys = |chart: &LineChart| -> Vec<f32> {
2198 let prims = paint_line(chart, 300.0, 200.0);
2199 let path = prims
2200 .iter()
2201 .rev()
2202 .find_map(|p| match p {
2203 Primitive::Path {
2204 stroke: Some(_),
2205 fill: None,
2206 path,
2207 ..
2208 } if path
2209 .verbs()
2210 .iter()
2211 .any(|v| matches!(v, frus_core::PathVerb::LineTo(_))) =>
2212 {
2213 Some(path)
2214 }
2215 _ => None,
2216 })
2217 .expect("an upper-edge stroke");
2218 path.verbs()
2219 .iter()
2220 .filter_map(|v| match v {
2221 frus_core::PathVerb::MoveTo(p) | frus_core::PathVerb::LineTo(p) => Some(p.y),
2222 _ => None,
2223 })
2224 .collect()
2225 };
2226 let plot_top = VALUE_SIZE + 6.0; let ys = top_line_ys(&make(true));
2230 assert!(
2231 ys.iter().all(|y| (y - plot_top).abs() < 1.0),
2232 "upper edge at 100% everywhere, got {ys:?}"
2233 );
2234 let ys_abs = top_line_ys(&make(false));
2236 assert!(
2237 ys_abs.iter().any(|y| (y - plot_top).abs() > 5.0),
2238 "the upper edge follows the totals, got {ys_abs:?}"
2239 );
2240 }
2241
2242 #[test]
2243 fn normalized_line_tooltip_shows_percentages() {
2244 let make = |norm: bool| {
2246 LineChart::new([("A", 2.0), ("B", 6.0)])
2247 .series("x", Color::rgb8(1, 2, 3), [2.0, 2.0])
2248 .stacked(true)
2249 .normalized(norm)
2250 };
2251 let tooltip_texts = |chart: &LineChart| -> Vec<String> {
2252 let mut scene = Scene::new();
2253 let status = Status {
2254 hover_cursor: Some(Point::new(75.0, 90.0)),
2255 ..Default::default()
2256 };
2257 Widget::<crate::callback::Callback>::paint(
2258 chart,
2259 Rect::new(0.0, 0.0, 300.0, 200.0),
2260 status,
2261 &Theme::default(),
2262 &mut scene,
2263 );
2264 scene
2265 .primitives()
2266 .iter()
2267 .filter_map(|p| match p {
2268 Primitive::Text { text, .. } => Some(text.clone()),
2269 _ => None,
2270 })
2271 .collect()
2272 };
2273 let norm = tooltip_texts(&make(true));
2274 assert!(
2275 norm.iter().any(|t| t.contains("(50%)")),
2276 "a % share in the 100%-mode tooltip, got {norm:?}"
2277 );
2278 let abs = tooltip_texts(&make(false));
2279 assert!(
2280 !abs.iter().any(|t| t.contains('%')),
2281 "no % in absolute mode, got {abs:?}"
2282 );
2283 }
2284
2285 #[test]
2286 fn stacked_areas_label_each_band_with_value_or_percentage() {
2287 let abs = LineChart::new([("A", 3.0), ("B", 5.0)])
2289 .series("x", Color::rgb8(1, 2, 3), [4.0, 6.0])
2290 .stacked(true);
2291 let prims = paint_line(&abs, 300.0, 260.0);
2292 let has = |t: &str| {
2293 prims
2294 .iter()
2295 .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
2296 };
2297 assert!(
2299 has("3") && has("4") && has("5") && has("6"),
2300 "band values displayed"
2301 );
2302 let norm = LineChart::new([("A", 2.0), ("B", 6.0)])
2304 .series("x", Color::rgb8(1, 2, 3), [2.0, 2.0])
2305 .stacked(true)
2306 .normalized(true);
2307 let np = paint_line(&norm, 300.0, 260.0);
2308 assert!(
2309 np.iter()
2310 .any(|p| matches!(p, Primitive::Text { text, .. } if text.ends_with('%'))),
2311 "% shares displayed inside the bands"
2312 );
2313 }
2314
2315 #[test]
2316 fn max_value_spans_all_series() {
2317 let chart = LineChart::<crate::callback::Callback>::new([("A", 2.0), ("B", 6.0)]).series(
2319 "x",
2320 Color::rgb8(1, 2, 3),
2321 [9.0, 1.0],
2322 );
2323 assert_eq!(chart.max_value(), 9.0);
2324 }
2325
2326 #[test]
2327 fn area_fills_a_polygon_under_the_curve() {
2328 let filled_polygons = |chart: &LineChart| {
2331 paint_line(chart, 300.0, 200.0)
2332 .iter()
2333 .filter(|p| match p {
2334 Primitive::Path {
2335 fill: Some(_),
2336 stroke: None,
2337 path,
2338 ..
2339 } => path
2340 .verbs()
2341 .iter()
2342 .any(|v| matches!(v, frus_core::PathVerb::LineTo(_))),
2343 _ => false,
2344 })
2345 .count()
2346 };
2347 assert_eq!(
2348 filled_polygons(&LineChart::new([("A", 2.0), ("B", 6.0)])),
2349 0,
2350 "no area by default"
2351 );
2352 assert_eq!(
2353 filled_polygons(&LineChart::new([("A", 2.0), ("B", 6.0)]).area(true)),
2354 1,
2355 "one filled area under the curve"
2356 );
2357 }
2358
2359 #[test]
2360 fn grid_draws_horizontal_lines_and_axis_labels() {
2361 let chart = LineChart::new([("A", 2.0), ("B", 8.0)]).grid(4);
2363 let prims = paint_line(&chart, 300.0, 200.0);
2364 let thin_lines = prims
2366 .iter()
2367 .filter(|p| matches!(p, Primitive::Rect { rect, .. } if rect.height <= 1.6))
2368 .count();
2369 assert!(
2370 thin_lines >= 5,
2371 "4 grid lines + the baseline, got {thin_lines}"
2372 );
2373 let has_text = |t: &str| {
2374 prims
2375 .iter()
2376 .any(|p| matches!(p, Primitive::Text { text, .. } if text == t))
2377 };
2378 assert!(has_text("0") && has_text("8"), "y-axis ticks");
2380 }
2381
2382 #[test]
2383 fn no_grid_by_default_keeps_full_width() {
2384 let chart = LineChart::new([("A", 2.0), ("B", 8.0)]);
2386 let prims = paint_line(&chart, 300.0, 200.0);
2387 let has_zero = prims
2388 .iter()
2389 .any(|p| matches!(p, Primitive::Text { text, .. } if text == "0"));
2390 assert!(!has_zero, "no axis by default");
2391 }
2392}