Skip to main content

ravenrs_sheet/chart/
render.rs

1//! Chart layout: title, legend, axes and every chart kind, drawn into [`Prim`]s.
2
3use std::f32::consts::{PI, TAU};
4
5use crate::model::{Chart, ChartKind, LineDash, QuartileMethod, Theme, TrendlineKind};
6
7use crate::chart::axis::{Fmt, Scale, clean, nice_scale};
8use crate::chart::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};
9use crate::chart::trend::{auto_name, fit_trendline, moving_average};
10use crate::chart::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign};
11
12mod cartesian;
13mod frame;
14mod kinds;
15mod legend;
16mod lines;
17mod table;
18mod trend;
19
20use cartesian::{cartesian, scatter};
21use frame::{Bx, axis_titles, chart_title};
22use kinds::{box_whisker, doughnut, funnel, histogram, pie, radar, stock, treemap, waterfall};
23use legend::draw_legend;
24pub use legend::{Swatch, legend_entries};
25pub use trend::supports_trendline;
26
27pub(crate) const TEXT: Rgba = [0x59, 0x59, 0x59, 0xFF];
28const DARK_TEXT: Rgba = [0x26, 0x26, 0x26, 0xFF];
29const GRID: Rgba = [0xD9, 0xD9, 0xD9, 0xFF];
30const AXIS: Rgba = [0xBF, 0xBF, 0xBF, 0xFF];
31const WHITE: Rgba = [0xFF, 0xFF, 0xFF, 0xFF];
32const STOCK: Rgba = [0x40, 0x40, 0x40, 0xFF];
33pub(crate) const UP: Rgba = [0x3E, 0x9B, 0x5F, 0xFF];
34pub(crate) const DOWN: Rgba = [0xD6, 0x4F, 0x45, 0xFF];
35
36const TITLE_SIZE: f32 = 14.0;
37const LABEL_SIZE: f32 = 9.0;
38const AXIS_TITLE_SIZE: f32 = 10.0;
39/// Most categories drawn by category-axis charts.
40const MAX_CATS: usize = 20_000;
41/// Lines with more points than this are decimated (min/max per pixel column).
42const MAX_LINE_PTS: usize = 2000;
43/// Most markers per series (after de-duplicating by pixel).
44const MAX_MARKERS: usize = 5000;
45/// Data labels are skipped when a series has more points than this.
46const MAX_LABELS: usize = 200;
47/// Most wedges a doughnut draws for the whole chart. The per-series and per-category limits do not
48/// bound their product: 255 series of 20,000 categories would be millions of wedges. Every drawable
49/// ring shares this budget, so the innermost ring keeps its largest slices and the outer rings are
50/// capped instead of the chart dropping rings wholesale.
51pub(crate) const MAX_WEDGES: usize = 2_048;
52
53struct Ctx<'a> {
54    out: Vec<Prim>,
55    m: &'a dyn Measure,
56}
57
58impl Ctx<'_> {
59    fn tw(&self, s: &str, size: f32) -> f32 {
60        let w = self.m.text_width(s, size, false);
61        if w.is_finite() { w.max(0.0) } else { 0.0 }
62    }
63    fn fit(&self, s: &str, size: f32, maxw: f32) -> String {
64        if self.tw(s, size) <= maxw {
65            return s.to_string();
66        }
67        let chars: Vec<char> = s.chars().collect();
68        let mut n = chars.len().min(400);
69        while n > 0 {
70            n -= 1;
71            let t: String = chars.iter().take(n).collect::<String>() + "…";
72            if self.tw(&t, size) <= maxw {
73                return if n == 0 { String::new() } else { t };
74            }
75        }
76        String::new()
77    }
78    fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, (align, valign): (HAlign, VAlign)) {
79        if s.is_empty() {
80            return;
81        }
82        self.out.push(Prim::Text { x, y, text: s.to_string(), size, color, bold: false, align, valign, rotation: 0.0 });
83    }
84    fn rect(&mut self, x: f32, y: f32, w: f32, h: f32, fill: Rgba, stroke: Option<(Rgba, f32)>) {
85        self.out.push(Prim::Rect { x, y, w, h, fill: Some(fill), stroke, radius: 0.0 });
86    }
87    fn line(&mut self, pts: Vec<[f32; 2]>, color: Rgba, width: f32) {
88        if pts.len() >= 2 {
89            self.out.push(Prim::Line { pts, color, width, dash: false });
90        }
91    }
92    fn hline(&mut self, x0: f32, x1: f32, y: f32, color: Rgba, width: f32) {
93        self.line(vec![[x0, y], [x1, y]], color, width);
94    }
95    fn vline(&mut self, x: f32, y0: f32, y1: f32, color: Rgba, width: f32) {
96        self.line(vec![[x, y0], [x, y1]], color, width);
97    }
98}
99
100fn sanitize_vals(v: &[Option<f64>]) -> Vec<Option<f64>> {
101    v.iter().take(MAX_POINTS).map(|x| x.filter(|n| n.is_finite()).map(clean)).collect()
102}
103
104fn sanitize(d: &ChartData) -> ChartData {
105    ChartData {
106        palette: d.palette.iter().take(MAX_POINTS).copied().collect(),
107        categories: d.categories.iter().take(MAX_POINTS).cloned().collect(),
108        category_levels: d.category_levels.iter().take(3).map(|l| l.iter().take(MAX_POINTS).cloned().collect()).collect(),
109        series: d
110            .series
111            .iter()
112            .take(MAX_SERIES)
113            .map(|s| SeriesData {
114                name: s.name.clone(),
115                values: sanitize_vals(&s.values),
116                x: s.x.as_deref().map(sanitize_vals),
117                sizes: s.sizes.as_deref().map(sanitize_vals),
118                color: s.color,
119                kind: s.kind,
120                secondary: s.secondary,
121                smooth: s.smooth,
122                trendlines: s.trendlines.iter().take(MAX_TRENDLINES).cloned().collect(),
123                number_format: s.number_format.clone(),
124            })
125            .collect(),
126    }
127}
128
129/// Lays out and draws a chart into a `w`×`h` box at the origin. Empty for a degenerate box.
130pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) -> Vec<Prim> {
131    if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 || w > 1e7 || h > 1e7 {
132        return vec![];
133    }
134    let data = sanitize(data);
135    let mut ctx = Ctx { out: Vec::new(), m };
136    ctx.out.push(Prim::Rect { x: 0.0, y: 0.0, w, h, fill: Some(WHITE), stroke: Some((GRID, 1.0)), radius: 0.0 });
137    let pad = (w.min(h) * 0.04).clamp(2.0, 10.0);
138    let mut area = Bx { x: pad, y: pad, w: w - 2.0 * pad, h: h - 2.0 * pad };
139    if let Some(t) = chart_title(chart, &data) {
140        let size = TITLE_SIZE.min(h * 0.12).max(4.0);
141        let t = ctx.fit(&t, size, area.w);
142        ctx.text(w / 2.0, area.y, &t, size, TEXT, (HAlign::Center, VAlign::Top));
143        let used = size * 1.3 + 4.0;
144        area.y += used;
145        area.h -= used;
146    }
147    let entries = legend_entries(chart, &data);
148    if !entries.is_empty() && area.ok() {
149        area = draw_legend(&mut ctx, &entries, chart.legend, area);
150    }
151    if area.ok() && !data.series.is_empty() {
152        draw_kind(&mut ctx, chart, &data, area);
153    }
154    finalize(ctx.out, w, h)
155}
156
157fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
158    use ChartKind::*;
159    match chart.kind {
160        Pie => pie(ctx, chart, data, area, 0.0, false),
161        Doughnut => doughnut(ctx, chart, data, area),
162        Sunburst => pie(ctx, chart, data, area, 0.2, true),
163        Treemap => treemap(ctx, chart, data, area),
164        Funnel => funnel(ctx, chart, data, area),
165        Radar => radar(ctx, chart, data, area),
166        _ => {
167            let area = axis_titles(ctx, chart, area);
168            if !area.ok() {
169                return;
170            }
171            match chart.kind {
172                BarClustered | BarStacked | BarStacked100 => cartesian(ctx, chart, data, area, true),
173                Scatter | ScatterLines | Bubble => scatter(ctx, chart, data, area),
174                Histogram => histogram(ctx, chart, data, area),
175                Waterfall => waterfall(ctx, chart, data, area),
176                BoxWhisker => box_whisker(ctx, chart, data, area),
177                Stock => stock(ctx, chart, data, area),
178                _ => cartesian(ctx, chart, data, area, false),
179            }
180        }
181    }
182}
183
184// ---------------------------------------------------------------- final safety pass
185
186fn fin(v: f32) -> bool {
187    v.is_finite()
188}
189
190fn clamp_stroke(s: Option<(Rgba, f32)>) -> Option<(Rgba, f32)> {
191    s.filter(|(_, w)| fin(*w) && *w > 0.0).map(|(c, w)| (c, w.min(50.0)))
192}
193
194/// Drops non-finite primitives and clamps every coordinate into the `w`×`h` box.
195pub(crate) fn finalize(prims: Vec<Prim>, w: f32, h: f32) -> Vec<Prim> {
196    let cx = |v: f32| v.clamp(0.0, w);
197    let cy = |v: f32| v.clamp(0.0, h);
198    let pts_ok = |pts: &[[f32; 2]]| pts.iter().all(|p| fin(p[0]) && fin(p[1]));
199    prims
200        .into_iter()
201        .filter_map(|p| match p {
202            Prim::Rect { x, y, w: rw, h: rh, fill, stroke, radius } => {
203                if !(fin(x) && fin(y) && fin(rw) && fin(rh)) {
204                    return None;
205                }
206                let (x0, x1) = (cx(x.min(x + rw)), cx(x.max(x + rw)));
207                let (y0, y1) = (cy(y.min(y + rh)), cy(y.max(y + rh)));
208                if x1 - x0 <= 0.0 || y1 - y0 <= 0.0 {
209                    return None;
210                }
211                let radius = if fin(radius) { radius.clamp(0.0, (x1 - x0).min(y1 - y0) / 2.0) } else { 0.0 };
212                Some(Prim::Rect { x: x0, y: y0, w: x1 - x0, h: y1 - y0, fill, stroke: clamp_stroke(stroke), radius })
213            }
214            Prim::Line { pts, color, width, dash } => {
215                if !pts_ok(&pts) || pts.len() < 2 || !fin(width) {
216                    return None;
217                }
218                let pts = pts.into_iter().map(|p| [cx(p[0]), cy(p[1])]).collect();
219                Some(Prim::Line { pts, color, width: width.clamp(0.1, 50.0), dash })
220            }
221            Prim::Polygon { pts, fill, stroke } => {
222                if !pts_ok(&pts) || pts.len() < 3 {
223                    return None;
224                }
225                let pts = pts.into_iter().map(|p| [cx(p[0]), cy(p[1])]).collect();
226                Some(Prim::Polygon { pts, fill, stroke: clamp_stroke(stroke) })
227            }
228            Prim::Wedge { cx: x, cy: y, r_outer, r_inner, a0, a1, fill, stroke } => {
229                if ![x, y, r_outer, r_inner, a0, a1].iter().all(|v| fin(*v)) {
230                    return None;
231                }
232                let (x, y) = (cx(x), cy(y));
233                let maxr = x.min(w - x).min(y).min(h - y);
234                let ro = r_outer.min(maxr);
235                if ro <= 0.0 {
236                    return None;
237                }
238                Some(Prim::Wedge { cx: x, cy: y, r_outer: ro, r_inner: r_inner.clamp(0.0, ro), a0, a1, fill, stroke: clamp_stroke(stroke) })
239            }
240            Prim::Circle { cx: x, cy: y, r, fill, stroke } => {
241                if !(fin(x) && fin(y) && fin(r)) {
242                    return None;
243                }
244                let (x, y) = (cx(x), cy(y));
245                let r = r.min(x.min(w - x).min(y).min(h - y));
246                if r <= 0.0 {
247                    return None;
248                }
249                Some(Prim::Circle { cx: x, cy: y, r, fill, stroke: clamp_stroke(stroke) })
250            }
251            Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {
252                if !(fin(x) && fin(y) && fin(size) && fin(rotation)) || text.is_empty() {
253                    return None;
254                }
255                Some(Prim::Text { x: cx(x), y: cy(y), text, size: size.clamp(1.0, 200.0), color, bold, align, valign, rotation })
256            }
257        })
258        .collect()
259}