Skip to main content

ravenrs_graphics/
chart.rs

1//! Chart layout: turns aggregated numbers into shapes and labels. Pure logic,
2//! kept apart from the engine so it can be tested without rendering a report.
3
4use ravenrs_core::{Color, Point, Rect};
5use serde::{Deserialize, Serialize};
6
7use crate::vector::{self, PathSegment, Shape};
8
9/// The kind of a chart.
10#[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    /// Vertical bars, side by side per series.
16    #[default]
17    Bar,
18    /// Lines with a dot at each point.
19    Line,
20    /// Lines with the area under them filled.
21    Area,
22    /// Slices of a circle; uses the first series.
23    Pie,
24    /// Slices of a ring; uses the first series.
25    Doughnut,
26    /// Dots at numeric positions.
27    Scatter,
28    /// A small line without axes or text, to sit in a row of a table.
29    Sparkline,
30    /// A smoothed line over a filled area, without axes or text.
31    SparkArea,
32    /// Series as polygons over spokes, one spoke per category.
33    Radar,
34    /// One ring per category, as long as its value.
35    RadialBar,
36    /// What a price did in each category: four series, in the order open, high, low
37    /// and close.
38    Candlestick,
39}
40
41/// How a label sits in its box.
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
43#[non_exhaustive]
44pub enum LabelAlign {
45    /// Left.
46    #[default]
47    Left,
48    /// Centered.
49    Center,
50    /// Right.
51    Right,
52}
53
54/// The default series and slice colors.
55pub 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/// One series' numbers, one per category.
71#[derive(Clone, Debug, PartialEq)]
72pub struct SeriesData {
73    /// Its name in the legend.
74    pub name: String,
75    /// Its color.
76    pub color: Color,
77    /// One number per category.
78    pub values: Vec<f64>,
79}
80
81/// What a chart draws, aggregated from the rows.
82#[derive(Clone, Debug, Default, PartialEq)]
83pub struct ChartData {
84    /// What each point is about.
85    pub categories: Vec<String>,
86    /// Numeric positions of the categories, for scatter charts.
87    pub positions: Vec<f64>,
88    /// The numbers.
89    pub series: Vec<SeriesData>,
90}
91
92/// Text a chart prints.
93#[derive(Clone, Debug, PartialEq)]
94pub struct ChartLabel {
95    /// The text.
96    pub text: String,
97    /// The box it sits in, vertically centered.
98    pub rect: Rect,
99    /// How it sits in the box.
100    pub align: LabelAlign,
101    /// Font size.
102    pub size: f32,
103    /// Bold weight.
104    pub bold: bool,
105    /// Color.
106    pub color: Color,
107}
108
109/// What a chart draws.
110#[derive(Clone, Debug, Default, PartialEq)]
111pub struct ChartDrawing {
112    /// The shapes, back to front.
113    pub shapes: Vec<Shape>,
114    /// The text, to draw over the shapes.
115    pub labels: Vec<ChartLabel>,
116}
117
118/// The look of a chart.
119#[allow(missing_debug_implementations, reason = "holds a closure")]
120pub struct ChartOptions<'a> {
121    /// The kind of chart.
122    pub kind: ChartKind,
123    /// A title above it.
124    pub title: &'a str,
125    /// Whether to draw a legend when there is something to tell apart.
126    pub legend: bool,
127    /// The font size everything is sized from.
128    pub size: f32,
129    /// Title color.
130    pub ink: Color,
131    /// Axis and legend text color.
132    pub muted: Color,
133    /// Gridline color.
134    pub grid: Color,
135    /// How numbers are shown.
136    pub format: &'a dyn Fn(f64) -> String,
137}
138
139/// More about the look of a chart, for [`draw_styled`].
140#[derive(Clone, Copy, Debug)]
141pub struct ChartStyle<'a> {
142    /// Slice and legend colors of pie and doughnut charts, repeated as needed;
143    /// [`PALETTE`] when empty.
144    pub palette: &'a [Color],
145    /// What separates slices and rings the last dot of a [`ChartKind::SparkArea`]:
146    /// usually the color behind the chart.
147    pub separator: Color,
148    /// Corner radius of bars.
149    pub bar_radius: f32,
150    /// The widest a bar can be, so few bars in a wide chart stay bars.
151    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
160/// Roughly how wide a character is, as a share of the font size; enough to
161/// decide how many labels fit.
162const CHAR_WIDTH: f32 = 0.56;
163/// A slice smaller than this share of the circle has no percentage on it.
164const SLICE_LABEL_MIN: f64 = 0.05;
165const TICKS: usize = 5;
166/// The radius of a dot in a scatter chart.
167const SCATTER_DOT: f32 = 2.5;
168const GAP: f32 = 6.0;
169
170/// Round numbers for an axis: lower bound, step and how many ticks.
171pub 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
214/// Appends an arc around `center`, from one angle to another (radians, clockwise on the page).
215pub(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
232/// Lays a chart out inside `rect`, in the default [`ChartStyle`].
233pub fn draw(data: &ChartData, rect: Rect, options: &ChartOptions<'_>) -> ChartDrawing {
234    draw_styled(data, rect, options, &ChartStyle::default())
235}
236
237/// Where a dot of a scatter chart or a point of a sparkline landed.
238#[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
246/// Lays a chart out inside `rect`, in this style.
247pub 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
256/// The same as [`draw_styled`], saying also where the values of the small kinds landed.
257pub(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        // A category with nothing to show has no slice, so it has no place in the legend.
289        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
312/// A kind only [`crate::plot`] knows, in the shapes reports and exports write.
313fn 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
338/// Draws the legend at the bottom of `area`; returns what is left above it.
339fn 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    // How many lines the entries wrap to.
353    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    // A legend can take up to half the room; past that, the last entries are left out.
362    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    // Bars and areas start from zero, or their sizes would mislead.
456    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    // Only as many category labels as fit, evenly spaced.
489    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            // Inset by a dot's radius, so a dot at either end stays inside the plot.
536            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
601/// A curve through evenly spaced points (Catmull-Rom), without its starting move.
602fn 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    // Room for the last dot.
635    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}