1use ravenrs_core::{Color, Point, Rect};
5use serde::{Deserialize, Serialize};
6
7use crate::vector::{self, PathSegment, Shape};
8
9#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12#[non_exhaustive]
13#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
14pub enum ChartKind {
15 #[default]
17 Bar,
18 Line,
20 Area,
22 Pie,
24 Doughnut,
26 Scatter,
28 Sparkline,
30 SparkArea,
32 Radar,
34 RadialBar,
36 Candlestick,
39}
40
41#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
43#[non_exhaustive]
44pub enum LabelAlign {
45 #[default]
47 Left,
48 Center,
50 Right,
52}
53
54pub const PALETTE: [Color; 12] = [
56 Color::rgba(0.247, 0.341, 0.859, 1.0),
57 Color::rgba(0.086, 0.651, 0.588, 1.0),
58 Color::rgba(0.961, 0.620, 0.043, 1.0),
59 Color::rgba(0.863, 0.208, 0.271, 1.0),
60 Color::rgba(0.545, 0.361, 0.965, 1.0),
61 Color::rgba(0.055, 0.647, 0.914, 1.0),
62 Color::rgba(0.518, 0.800, 0.086, 1.0),
63 Color::rgba(0.925, 0.282, 0.600, 1.0),
64 Color::rgba(0.471, 0.333, 0.282, 1.0),
65 Color::rgba(0.392, 0.455, 0.545, 1.0),
66 Color::rgba(0.706, 0.325, 0.035, 1.0),
67 Color::rgba(0.051, 0.380, 0.490, 1.0),
68];
69
70#[derive(Clone, Debug, PartialEq)]
72pub struct SeriesData {
73 pub name: String,
75 pub color: Color,
77 pub values: Vec<f64>,
79}
80
81#[derive(Clone, Debug, Default, PartialEq)]
83pub struct ChartData {
84 pub categories: Vec<String>,
86 pub positions: Vec<f64>,
88 pub series: Vec<SeriesData>,
90}
91
92#[derive(Clone, Debug, PartialEq)]
94pub struct ChartLabel {
95 pub text: String,
97 pub rect: Rect,
99 pub align: LabelAlign,
101 pub size: f32,
103 pub bold: bool,
105 pub color: Color,
107}
108
109#[derive(Clone, Debug, Default, PartialEq)]
111pub struct ChartDrawing {
112 pub shapes: Vec<Shape>,
114 pub labels: Vec<ChartLabel>,
116}
117
118#[allow(missing_debug_implementations, reason = "holds a closure")]
120pub struct ChartOptions<'a> {
121 pub kind: ChartKind,
123 pub title: &'a str,
125 pub legend: bool,
127 pub size: f32,
129 pub ink: Color,
131 pub muted: Color,
133 pub grid: Color,
135 pub format: &'a dyn Fn(f64) -> String,
137}
138
139#[derive(Clone, Copy, Debug)]
141pub struct ChartStyle<'a> {
142 pub palette: &'a [Color],
145 pub separator: Color,
148 pub bar_radius: f32,
150 pub max_bar_width: f32,
152}
153
154impl Default for ChartStyle<'_> {
155 fn default() -> Self {
156 Self { palette: &[], separator: Color::WHITE, bar_radius: 0.0, max_bar_width: f32::INFINITY }
157 }
158}
159
160const CHAR_WIDTH: f32 = 0.56;
163const SLICE_LABEL_MIN: f64 = 0.05;
165const TICKS: usize = 5;
166const SCATTER_DOT: f32 = 2.5;
168const GAP: f32 = 6.0;
169
170pub fn nice_ticks(min: f64, max: f64, wanted: usize) -> (f64, f64, usize) {
172 let (min, max) = if (max - min).abs() < f64::EPSILON {
173 if min == 0.0 { (0.0, 1.0) } else { (min.min(0.0), max.max(0.0)) }
174 } else {
175 (min, max)
176 };
177 let rough = (max - min) / wanted.max(1) as f64;
178 let magnitude = 10f64.powf(rough.log10().floor());
179 let residual = rough / magnitude;
180 let step = magnitude
181 * if residual <= 1.0 {
182 1.0
183 } else if residual <= 2.0 {
184 2.0
185 } else if residual <= 2.5 {
186 2.5
187 } else if residual <= 5.0 {
188 5.0
189 } else {
190 10.0
191 };
192 let low = (min / step).floor() * step;
193 let high = (max / step).ceil() * step;
194 (low, step, ((high - low) / step).round() as usize + 1)
195}
196
197fn label(text: impl Into<String>, rect: Rect, align: LabelAlign, size: f32, color: Color) -> ChartLabel {
198 ChartLabel { text: text.into(), rect, align, size, bold: false, color }
199}
200
201fn with_alpha(color: Color, alpha: f32) -> Color {
202 Color::rgba(color.r, color.g, color.b, alpha)
203}
204
205fn rectangle(rect: Rect, fill: Color) -> Shape {
206 Shape::filled(vector::rectangle(rect, 0.0), fill)
207}
208
209fn dot(center: Point, radius: f32, fill: Color) -> Shape {
210 let diameter = radius * 2.0;
211 Shape::filled(vector::ellipse(Rect::new(center.x - radius, center.y - radius, diameter, diameter)), fill)
212}
213
214pub(crate) fn arc(path: &mut Vec<PathSegment>, center: Point, radius: f32, from: f32, to: f32) {
216 let segments = ((to - from).abs() / std::f32::consts::FRAC_PI_2).ceil().max(1.0) as usize;
217 let step = (to - from) / segments as f32;
218 let handle = 4.0 / 3.0 * (step / 4.0).tan() * radius;
219 let at = |angle: f32| Point::new(center.x + radius * angle.cos(), center.y + radius * angle.sin());
220 for index in 0..segments {
221 let a = from + step * index as f32;
222 let b = a + step;
223 let (start, end) = (at(a), at(b));
224 path.push(PathSegment::Cubic(
225 Point::new(start.x - handle * a.sin(), start.y + handle * a.cos()),
226 Point::new(end.x + handle * b.sin(), end.y - handle * b.cos()),
227 end,
228 ));
229 }
230}
231
232pub fn draw(data: &ChartData, rect: Rect, options: &ChartOptions<'_>) -> ChartDrawing {
234 draw_styled(data, rect, options, &ChartStyle::default())
235}
236
237#[derive(Clone, Copy, Debug, PartialEq)]
239pub(crate) struct Place {
240 pub(crate) series: usize,
241 pub(crate) index: usize,
242 pub(crate) at: Point,
243 pub(crate) color: Color,
244}
245
246pub fn draw_styled(
248 data: &ChartData,
249 rect: Rect,
250 options: &ChartOptions<'_>,
251 style: &ChartStyle<'_>,
252) -> ChartDrawing {
253 draw_placed(data, rect, options, style, &mut Vec::new())
254}
255
256pub(crate) fn draw_placed(
258 data: &ChartData,
259 rect: Rect,
260 options: &ChartOptions<'_>,
261 style: &ChartStyle<'_>,
262 places: &mut Vec<Place>,
263) -> ChartDrawing {
264 if matches!(options.kind, ChartKind::Radar | ChartKind::RadialBar | ChartKind::Candlestick) {
265 return plain_plot(data, rect, options, style);
266 }
267 let mut drawing = ChartDrawing::default();
268 if matches!(options.kind, ChartKind::Sparkline | ChartKind::SparkArea) {
269 sparkline(data, rect, options, style, places, &mut drawing);
270 return drawing;
271 }
272 let mut area = rect;
273 if !options.title.trim().is_empty() {
274 let height = options.size * 2.0;
275 let mut title = label(
276 options.title,
277 Rect::new(area.x(), area.y(), area.width(), height),
278 LabelAlign::Left,
279 options.size * 1.25,
280 options.ink,
281 );
282 title.bold = true;
283 drawing.labels.push(title);
284 area = Rect::new(area.x(), area.y() + height, area.width(), (area.height() - height).max(0.0));
285 }
286 let round = matches!(options.kind, ChartKind::Pie | ChartKind::Doughnut);
287 let entries: Vec<(String, Color)> = if round {
288 let values = data.series.first().map(|s| s.values.as_slice());
290 data.categories
291 .iter()
292 .enumerate()
293 .filter(|(i, _)| values.is_none_or(|v| v.get(*i).is_some_and(|value| *value > 0.0)))
294 .map(|(i, name)| (name.clone(), slice_color(style, i)))
295 .collect()
296 } else if data.series.len() > 1 {
297 data.series.iter().map(|s| (s.name.clone(), s.color)).collect()
298 } else {
299 Vec::new()
300 };
301 if options.legend && !entries.is_empty() {
302 area = legend(&entries, area, options, &mut drawing);
303 }
304 if round {
305 circle(data, area, options, style, &mut drawing);
306 } else {
307 cartesian(data, area, options, style, places, &mut drawing);
308 }
309 drawing
310}
311
312fn plain_plot(
314 data: &ChartData,
315 rect: Rect,
316 options: &ChartOptions<'_>,
317 style: &ChartStyle<'_>,
318) -> ChartDrawing {
319 let plot_style = crate::plot::PlotStyle {
320 colors: if style.palette.is_empty() { PALETTE.to_vec() } else { style.palette.to_vec() },
321 ink: options.ink,
322 muted: options.muted,
323 grid: options.grid,
324 surface: style.separator,
325 size: options.size * 0.85,
326 value_axis: options.kind == ChartKind::Candlestick,
327 ..crate::plot::PlotStyle::new(options.kind)
328 };
329 let plot = crate::plot::draw(data, rect, &plot_style);
330 ChartDrawing { shapes: plot.layers.iter().map(vector::Layer::to_shape).collect(), labels: plot.labels }
331}
332
333fn slice_color(style: &ChartStyle<'_>, index: usize) -> Color {
334 let palette = if style.palette.is_empty() { &PALETTE[..] } else { style.palette };
335 palette[index % palette.len()]
336}
337
338fn legend(
340 entries: &[(String, Color)],
341 area: Rect,
342 options: &ChartOptions<'_>,
343 drawing: &mut ChartDrawing,
344) -> Rect {
345 let size = options.size * 0.85;
346 let line = size * 1.6;
347 let swatch = size * 0.75;
348 let widths: Vec<f32> = entries
349 .iter()
350 .map(|(name, _)| swatch + 4.0 + name.chars().count() as f32 * size * CHAR_WIDTH + 10.0)
351 .collect();
352 let (mut lines, mut used) = (1, 0.0);
354 for width in &widths {
355 if used + width > area.width() && used > 0.0 {
356 lines += 1;
357 used = 0.0;
358 }
359 used += width;
360 }
361 let room = ((area.height() / 2.0 / line).floor() as usize).max(1);
363 let lines = lines.min(room);
364 let top = area.max_y() - line * lines as f32;
365 let (mut x, mut row) = (area.x(), 0);
366 for ((name, color), width) in entries.iter().zip(&widths) {
367 if x + width > area.max_x() && x > area.x() {
368 x = area.x();
369 row += 1;
370 if row >= lines {
371 break;
372 }
373 }
374 let y = top + row as f32 * line;
375 drawing.shapes.push(rectangle(Rect::new(x, y + (line - swatch) / 2.0, swatch, swatch), *color));
376 drawing.labels.push(label(
377 name.clone(),
378 Rect::new(x + swatch + 4.0, y, (width - swatch - 4.0).max(1.0), line),
379 LabelAlign::Left,
380 size,
381 options.muted,
382 ));
383 x += width;
384 }
385 Rect::new(area.x(), area.y(), area.width(), (top - area.y() - GAP).max(0.0))
386}
387
388fn circle(
389 data: &ChartData,
390 area: Rect,
391 options: &ChartOptions<'_>,
392 style: &ChartStyle<'_>,
393 drawing: &mut ChartDrawing,
394) {
395 let Some(series) = data.series.first() else { return };
396 let total: f64 = series.values.iter().filter(|v| **v > 0.0).sum();
397 if total <= 0.0 || area.width() <= 0.0 || area.height() <= 0.0 {
398 return;
399 }
400 let center = area.center();
401 let radius = area.width().min(area.height()) / 2.0 - 1.0;
402 let hole = if options.kind == ChartKind::Doughnut { radius * 0.58 } else { 0.0 };
403 let mut angle = -std::f32::consts::FRAC_PI_2;
404 for (index, value) in series.values.iter().enumerate() {
405 if *value <= 0.0 {
406 continue;
407 }
408 let share = *value / total;
409 let end = angle + share as f32 * std::f32::consts::TAU;
410 let at = |r: f32, a: f32| Point::new(center.x + r * a.cos(), center.y + r * a.sin());
411 let mut path = vec![PathSegment::Move(at(radius, angle))];
412 arc(&mut path, center, radius, angle, end);
413 if hole > 0.0 {
414 path.push(PathSegment::Line(at(hole, end)));
415 arc(&mut path, center, hole, end, angle);
416 } else {
417 path.push(PathSegment::Line(center));
418 }
419 path.push(PathSegment::Close);
420 drawing.shapes.push(Shape {
421 path,
422 fill: Some(slice_color(style, index)),
423 stroke: Some((1.0, style.separator)),
424 });
425 if share >= SLICE_LABEL_MIN {
426 let middle = (angle + end) / 2.0;
427 let position = at(if hole > 0.0 { (radius + hole) / 2.0 } else { radius * 0.66 }, middle);
428 let size = options.size * 0.85;
429 drawing.labels.push(label(
430 format!("{:.0}%", share * 100.0),
431 Rect::new(position.x - 30.0, position.y - size, 60.0, size * 2.0),
432 LabelAlign::Center,
433 size,
434 Color::WHITE,
435 ));
436 }
437 angle = end;
438 }
439}
440
441fn cartesian(
442 data: &ChartData,
443 area: Rect,
444 options: &ChartOptions<'_>,
445 style: &ChartStyle<'_>,
446 places: &mut Vec<Place>,
447 drawing: &mut ChartDrawing,
448) {
449 let values = data.series.iter().flat_map(|s| s.values.iter().copied()).filter(|v| v.is_finite());
450 let (mut min, mut max) =
451 values.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), v| (a.min(v), b.max(v)));
452 if !min.is_finite() || data.categories.is_empty() {
453 return;
454 }
455 if matches!(options.kind, ChartKind::Bar | ChartKind::Area) {
457 min = min.min(0.0);
458 max = max.max(0.0);
459 }
460 let (low, step, ticks) = nice_ticks(min, max, TICKS);
461 let high = low + step * (ticks.saturating_sub(1)) as f64;
462 let span = (high - low).max(f64::EPSILON);
463 let size = options.size * 0.85;
464 let axis_labels: Vec<String> = (0..ticks).map(|i| (options.format)(low + step * i as f64)).collect();
465 let axis_width =
466 axis_labels.iter().map(|t| t.chars().count()).max().unwrap_or(0) as f32 * size * CHAR_WIDTH + GAP;
467 let bottom_room = size * 1.8;
468 let plot = Rect::new(
469 area.x() + axis_width,
470 area.y() + size / 2.0,
471 (area.width() - axis_width).max(1.0),
472 (area.height() - bottom_room - size / 2.0).max(1.0),
473 );
474 let y_of = |value: f64| plot.max_y() - ((value - low) / span) as f32 * plot.height();
475 for (index, text) in axis_labels.iter().enumerate() {
476 let y = y_of(low + step * index as f64);
477 drawing.shapes.push(rectangle(Rect::new(plot.x(), y - 0.25, plot.width(), 0.5), options.grid));
478 drawing.labels.push(label(
479 text.clone(),
480 Rect::new(area.x(), y - size, axis_width - GAP / 2.0, size * 2.0),
481 LabelAlign::Right,
482 size,
483 options.muted,
484 ));
485 }
486 let count = data.categories.len();
487 let band = plot.width() / count as f32;
488 let longest =
490 data.categories.iter().map(|c| c.chars().count()).max().unwrap_or(1) as f32 * size * CHAR_WIDTH + 4.0;
491 let every = (longest / band.max(1.0)).ceil().max(1.0) as usize;
492 if options.kind != ChartKind::Scatter {
493 for (index, name) in data.categories.iter().enumerate().step_by(every) {
494 let width = band * every as f32;
495 let center = plot.x() + band * (index as f32 + 0.5);
496 drawing.labels.push(label(
497 name.clone(),
498 Rect::new(center - width / 2.0, plot.max_y() + 2.0, width, bottom_room - 2.0),
499 LabelAlign::Center,
500 size,
501 options.muted,
502 ));
503 }
504 }
505 match options.kind {
506 ChartKind::Bar => {
507 let series_count = data.series.len().max(1) as f32;
508 let bar = (band * 0.72 / series_count).min(style.max_bar_width);
509 let group = bar * series_count;
510 let zero = y_of(0.0_f64.clamp(low, high));
511 for (s, series) in data.series.iter().enumerate() {
512 for (index, value) in series.values.iter().enumerate() {
513 if !value.is_finite() {
514 continue;
515 }
516 let x = plot.x() + band * index as f32 + (band - group) / 2.0 + bar * s as f32;
517 let y = y_of(*value);
518 let (top, height) = if y < zero { (y, zero - y) } else { (zero, y - zero) };
519 let rect = Rect::new(x, top, (bar - 0.5).max(0.5), height.max(0.25));
520 drawing
521 .shapes
522 .push(Shape::filled(vector::rectangle(rect, style.bar_radius), series.color));
523 }
524 }
525 }
526 ChartKind::Scatter => {
527 let (x_low, x_step, x_ticks) = {
528 let (a, b) = data
529 .positions
530 .iter()
531 .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), v| (a.min(*v), b.max(*v)));
532 nice_ticks(if a.is_finite() { a } else { 0.0 }, if b.is_finite() { b } else { 1.0 }, TICKS)
533 };
534 let x_span = (x_step * x_ticks.saturating_sub(1) as f64).max(f64::EPSILON);
535 let x_of = |value: f64| {
537 plot.x()
538 + SCATTER_DOT
539 + ((value - x_low) / x_span) as f32 * (plot.width() - 2.0 * SCATTER_DOT)
540 };
541 for index in 0..x_ticks {
542 let value = x_low + x_step * index as f64;
543 drawing.labels.push(label(
544 (options.format)(value),
545 Rect::new(x_of(value) - 40.0, plot.max_y() + 2.0, 80.0, bottom_room - 2.0),
546 LabelAlign::Center,
547 size,
548 options.muted,
549 ));
550 }
551 for (s, series) in data.series.iter().enumerate() {
552 for (index, (position, value)) in data.positions.iter().zip(&series.values).enumerate() {
553 if value.is_finite() {
554 let at = Point::new(x_of(*position), y_of(*value));
555 drawing.shapes.push(dot(at, SCATTER_DOT, series.color));
556 places.push(Place { series: s, index, at, color: series.color });
557 }
558 }
559 }
560 }
561 _ => {
562 let filled = options.kind == ChartKind::Area;
563 for series in &data.series {
564 let points: Vec<Point> = series
565 .values
566 .iter()
567 .enumerate()
568 .filter(|(_, v)| v.is_finite())
569 .map(|(i, v)| Point::new(plot.x() + band * (i as f32 + 0.5), y_of(*v)))
570 .collect();
571 if filled && let (Some(first), Some(last)) = (points.first(), points.last()) {
572 let zero = y_of(0.0_f64.clamp(low, high));
573 let mut path = vec![PathSegment::Move(Point::new(first.x, zero))];
574 path.extend(points.iter().map(|p| PathSegment::Line(*p)));
575 path.push(PathSegment::Line(Point::new(last.x, zero)));
576 path.push(PathSegment::Close);
577 drawing.shapes.push(Shape {
578 path,
579 fill: Some(with_alpha(series.color, 0.25)),
580 stroke: None,
581 });
582 }
583 drawing.shapes.push(polyline(&points, 1.75, series.color));
584 if band > 8.0 {
585 drawing.shapes.extend(points.iter().map(|p| dot(*p, 2.25, series.color)));
586 }
587 }
588 }
589 }
590}
591
592fn polyline(points: &[Point], width: f32, color: Color) -> Shape {
593 let path = points
594 .iter()
595 .enumerate()
596 .map(|(i, p)| if i == 0 { PathSegment::Move(*p) } else { PathSegment::Line(*p) })
597 .collect();
598 Shape { path, fill: None, stroke: Some((width, color)) }
599}
600
601fn smooth(points: &[Point]) -> Vec<PathSegment> {
603 let last = points.len().saturating_sub(1);
604 (0..last)
605 .map(|i| {
606 let (p0, p1) = (points[i.saturating_sub(1)], points[i]);
607 let (p2, p3) = (points[i + 1], points[(i + 2).min(last)]);
608 PathSegment::Cubic(
609 Point::new(p1.x + (p2.x - p0.x) / 6.0, p1.y + (p2.y - p0.y) / 6.0),
610 Point::new(p2.x - (p3.x - p1.x) / 6.0, p2.y - (p3.y - p1.y) / 6.0),
611 p2,
612 )
613 })
614 .collect()
615}
616
617fn sparkline(
618 data: &ChartData,
619 rect: Rect,
620 options: &ChartOptions<'_>,
621 style: &ChartStyle<'_>,
622 places: &mut Vec<Place>,
623 drawing: &mut ChartDrawing,
624) {
625 let Some(series) = data.series.first() else { return };
626 let values: Vec<f64> = series.values.iter().copied().filter(|v| v.is_finite()).collect();
627 if values.is_empty() {
628 return;
629 }
630 let (min, max) =
631 values.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), v| (a.min(*v), b.max(*v)));
632 let span = (max - min).max(f64::EPSILON);
633 let filled = options.kind == ChartKind::SparkArea;
634 let pad = if filled { 6.0 } else { 2.5 };
636 let (width, height) = ((rect.width() - 2.0 * pad).max(1.0), (rect.height() - 2.0 * pad).max(1.0));
637 let last = values.len().saturating_sub(1).max(1) as f32;
638 let points: Vec<Point> = values
639 .iter()
640 .enumerate()
641 .map(|(i, v)| {
642 Point::new(
643 rect.x() + pad + width * i as f32 / last,
644 rect.y() + pad + height * (1.0 - ((*v - min) / span) as f32),
645 )
646 })
647 .collect();
648 let (Some(first), Some(end)) = (points.first().copied(), points.last().copied()) else { return };
649 let kept = series.values.iter().enumerate().filter(|(_, v)| v.is_finite()).map(|(index, _)| index);
650 places.extend(kept.zip(&points).map(|(index, at)| Place {
651 series: 0,
652 index,
653 at: *at,
654 color: series.color,
655 }));
656 if filled {
657 let curve = smooth(&points);
658 let mut area = vec![PathSegment::Move(Point::new(first.x, rect.max_y())), PathSegment::Line(first)];
659 area.extend(curve.iter().copied());
660 area.extend([PathSegment::Line(Point::new(end.x, rect.max_y())), PathSegment::Close]);
661 drawing.shapes.push(Shape::filled(area, with_alpha(series.color, 0.2)));
662 let mut line = vec![PathSegment::Move(first)];
663 line.extend(curve);
664 drawing.shapes.push(Shape { path: line, fill: None, stroke: Some((2.0, series.color)) });
665 let mut last = dot(end, 4.0, series.color);
666 last.stroke = Some((2.0, style.separator));
667 drawing.shapes.push(last);
668 } else {
669 drawing.shapes.push(polyline(&points, 1.25, series.color));
670 drawing.shapes.push(dot(end, 2.0, series.color));
671 }
672}
673
674#[cfg(test)]
675mod tests {
676 use super::*;
677
678 fn plain(value: f64) -> String {
679 format!("{value}")
680 }
681
682 fn options<'a>(kind: ChartKind, format: &'a dyn Fn(f64) -> String) -> ChartOptions<'a> {
683 ChartOptions {
684 kind,
685 title: "",
686 legend: true,
687 size: 10.0,
688 ink: Color::BLACK,
689 muted: Color::BLACK,
690 grid: Color::BLACK,
691 format,
692 }
693 }
694
695 fn data(values: &[f64]) -> ChartData {
696 ChartData {
697 categories: (0..values.len()).map(|i| format!("C{i}")).collect(),
698 positions: (0..values.len()).map(|i| i as f64).collect(),
699 series: vec![SeriesData { name: "S".into(), color: PALETTE[0], values: values.to_vec() }],
700 }
701 }
702
703 const AREA: Rect =
704 Rect { origin: Point { x: 10.0, y: 20.0 }, size: ravenrs_core::Size { width: 400.0, height: 200.0 } };
705
706 fn bounds(segment: &PathSegment) -> Vec<Point> {
707 match segment {
708 PathSegment::Move(p) | PathSegment::Line(p) => vec![*p],
709 PathSegment::Cubic(_, _, p) => vec![*p],
710 _ => Vec::new(),
711 }
712 }
713
714 #[test]
715 fn ticks_are_round_numbers_that_cover_the_values() {
716 assert_eq!(nice_ticks(0.0, 100.0, 5), (0.0, 20.0, 6));
717 assert_eq!(nice_ticks(0.0, 87.0, 5), (0.0, 20.0, 6));
718 assert_eq!(nice_ticks(0.0, 4.2, 5), (0.0, 1.0, 6));
719 let (low, step, count) = nice_ticks(-30.0, 70.0, 5);
720 assert!(low <= -30.0 && low + step * (count - 1) as f64 >= 70.0);
721 assert_eq!(nice_ticks(0.0, 0.0, 5), (0.0, 0.2, 6));
722 let (low, step, count) = nice_ticks(5.0, 5.0, 5);
723 assert!(low <= 5.0 && low + step * (count - 1) as f64 >= 5.0 && step > 0.0);
724 }
725
726 #[test]
727 fn every_kind_stays_inside_its_box() {
728 let format = plain;
729 for kind in [
730 ChartKind::Bar,
731 ChartKind::Line,
732 ChartKind::Area,
733 ChartKind::Pie,
734 ChartKind::Doughnut,
735 ChartKind::Scatter,
736 ChartKind::Sparkline,
737 ChartKind::SparkArea,
738 ] {
739 let drawing = draw(&data(&[3.0, 9.0, 4.0, 12.0]), AREA, &options(kind, &format));
740 assert!(!drawing.shapes.is_empty(), "{kind:?}");
741 for shape in &drawing.shapes {
742 for point in shape.path.iter().flat_map(bounds) {
743 assert!(
744 point.x >= AREA.x() - 1.0
745 && point.x <= AREA.max_x() + 1.0
746 && point.y >= AREA.y() - 1.0
747 && point.y <= AREA.max_y() + 1.0,
748 "{kind:?} {point:?}"
749 );
750 }
751 }
752 }
753 }
754
755 #[test]
756 fn bars_are_as_tall_as_their_values_say() {
757 let format = plain;
758 let drawing = draw(&data(&[50.0, 100.0]), AREA, &options(ChartKind::Bar, &format));
759 let tall = |shape: &Shape| match (shape.path.first(), shape.path.get(2)) {
760 (Some(PathSegment::Move(a)), Some(PathSegment::Line(b))) => b.y - a.y,
761 _ => 0.0,
762 };
763 let colored: Vec<f32> =
764 drawing.shapes.iter().filter(|s| s.fill == Some(PALETTE[0])).map(tall).collect();
765 assert_eq!(colored.len(), 2);
766 assert!((colored[1] / colored[0] - 2.0).abs() < 0.01, "{colored:?}");
767 }
768
769 #[test]
770 fn negative_bars_go_below_zero() {
771 let format = plain;
772 let drawing = draw(&data(&[-50.0, 100.0]), AREA, &options(ChartKind::Bar, &format));
773 assert!(drawing.labels.iter().any(|l| l.text == "-50"), "{:?}", drawing.labels);
774 }
775
776 #[test]
777 fn slices_are_labeled_by_share_and_tiny_ones_are_not() {
778 let format = plain;
779 let drawing = draw(&data(&[75.0, 24.0, 1.0]), AREA, &options(ChartKind::Pie, &format));
780 let texts: Vec<&str> = drawing.labels.iter().map(|l| l.text.as_str()).collect();
781 assert!(texts.contains(&"75%") && texts.contains(&"24%"), "{texts:?}");
782 assert!(!texts.contains(&"1%"), "{texts:?}");
783 assert!(texts.contains(&"C2"), "the legend still names it: {texts:?}");
784 }
785
786 #[test]
787 fn the_legend_names_every_category_when_there_is_room() {
788 let format = plain;
789 let drawing =
790 draw(&data(&[1.0; 10]), Rect::new(0.0, 0.0, 240.0, 230.0), &options(ChartKind::Pie, &format));
791 for index in 0..10 {
792 let name = format!("C{index}");
793 assert!(drawing.labels.iter().any(|l| l.text == name), "{name}");
794 }
795 }
796
797 #[test]
798 fn ten_slices_have_ten_colors() {
799 let unique: Vec<_> =
800 PALETTE[..10].iter().map(|c| (c.r.to_bits(), c.g.to_bits(), c.b.to_bits())).collect();
801 let set: std::collections::HashSet<_> = unique.iter().collect();
802 assert_eq!(set.len(), 10);
803 }
804
805 #[test]
806 fn nothing_to_draw_draws_nothing() {
807 let format = plain;
808 for kind in
809 [ChartKind::Bar, ChartKind::Line, ChartKind::Pie, ChartKind::Scatter, ChartKind::Sparkline]
810 {
811 let drawing = draw(&ChartData::default(), AREA, &options(kind, &format));
812 assert!(drawing.shapes.is_empty(), "{kind:?}");
813 }
814 assert!(
815 draw(&data(&[0.0, 0.0]), AREA, &options(ChartKind::Pie, &format))
816 .labels
817 .iter()
818 .all(|l| !l.text.contains('%'))
819 );
820 }
821
822 #[test]
823 fn a_sparkline_has_no_text() {
824 let format = plain;
825 assert!(draw(&data(&[1.0, 2.0]), AREA, &options(ChartKind::Sparkline, &format)).labels.is_empty());
826 assert_eq!(draw(&data(&[1.0]), AREA, &options(ChartKind::Sparkline, &format)).shapes.len(), 2);
827 }
828
829 #[test]
830 fn a_spark_area_is_a_curve_over_a_fill_and_has_no_text() {
831 let format = plain;
832 let drawing = draw(&data(&[1.0, 4.0, 2.0]), AREA, &options(ChartKind::SparkArea, &format));
833 assert!(drawing.labels.is_empty());
834 assert_eq!(drawing.shapes.len(), 3, "area, line and last dot");
835 assert!(drawing.shapes[1].path.iter().any(|s| matches!(s, PathSegment::Cubic(..))));
836 assert_eq!(drawing.shapes[2].stroke, Some((2.0, Color::WHITE)));
837 }
838
839 #[test]
840 fn bars_stop_growing_at_their_widest() {
841 let format = plain;
842 let narrow = ChartStyle { max_bar_width: 20.0, ..ChartStyle::default() };
843 let drawing = draw_styled(&data(&[5.0]), AREA, &options(ChartKind::Bar, &format), &narrow);
844 let bar = drawing.shapes.iter().find(|s| s.fill == Some(PALETTE[0])).expect("a bar");
845 let xs: Vec<f32> = bar.path.iter().flat_map(bounds).map(|p| p.x).collect();
846 let width = xs.iter().copied().fold(f32::MIN, f32::max) - xs.iter().copied().fold(f32::MAX, f32::min);
847 assert!((width - 19.5).abs() < 0.01, "{width}");
848 }
849
850 #[test]
851 fn slices_use_the_colors_they_are_given() {
852 let format = plain;
853 let colors = [Color::BLACK];
854 let given = ChartStyle { palette: &colors, separator: Color::BLACK, ..ChartStyle::default() };
855 let drawing = draw_styled(&data(&[1.0, 2.0]), AREA, &options(ChartKind::Pie, &format), &given);
856 assert!(drawing.shapes.iter().all(|s| s.fill == Some(Color::BLACK)));
857 }
858
859 #[test]
860 fn labels_are_thinned_when_categories_do_not_fit() {
861 let format = plain;
862 let many = data(&vec![1.0; 200]);
863 let drawing = draw(&many, AREA, &options(ChartKind::Line, &format));
864 let categories = drawing.labels.iter().filter(|l| l.text.starts_with('C')).count();
865 assert!(categories > 1 && categories < 60, "{categories}");
866 }
867}