Skip to main content

ravenrs_sheet/chart/render/
legend.rs

1use super::frame::Bx;
2use super::trend::supports_trendline;
3use super::{
4    Chart, ChartData, ChartKind, Ctx, DOWN, HAlign, LABEL_SIZE, MAX_CATS, MAX_SERIES, MAX_TRENDLINES, Prim, Rgba, SeriesData, TEXT, Theme, UP,
5    VAlign, auto_name, series_color,
6};
7use crate::model::LegendPos;
8
9/// How a legend entry's swatch is drawn.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Swatch {
12    Box,
13    Line,
14    LineMarker,
15    Marker,
16}
17
18/// The kind series `i` is drawn as (per-series kinds only apply to combo charts); the same rule
19/// as `series_plot_kind`, applied to the already resolved series kind.
20pub(super) fn plot_kind(chart: &Chart, s: &SeriesData, i: usize) -> ChartKind {
21    crate::chart::resolve::plot_kind(chart.kind, Some(s.kind), i)
22}
23
24/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst).
25pub(super) fn point_color(data: &ChartData, j: usize) -> Rgba {
26    match (j, data.series.first()) {
27        (0, Some(s)) => s.color,
28        _ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)),
29    }
30}
31
32fn first_len(data: &ChartData) -> usize {
33    data.series.first().map(|s| s.values.len()).unwrap_or(0)
34}
35
36pub(super) fn cat_label(data: &ChartData, j: usize) -> String {
37    data.categories.get(j).cloned().unwrap_or_else(|| (j + 1).to_string())
38}
39
40/// Legend entries (text, colour, swatch) `render` draws for this chart; empty when hidden.
41pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swatch)> {
42    if chart.legend == LegendPos::None {
43        return vec![];
44    }
45    match chart.kind {
46        // Pie, doughnut (Excel's default `varyColors=1`, so every ring repeats the category colours
47        // and the legend names categories rather than series), treemap and sunburst.
48        ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => {
49            let n = first_len(data).min(MAX_CATS);
50            (0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect()
51        }
52        ChartKind::Histogram | ChartKind::Funnel | ChartKind::Stock => vec![],
53        ChartKind::Waterfall => {
54            if data.series.is_empty() {
55                return vec![];
56            }
57            let total = data.series.first().map(|s| s.color).unwrap_or(TEXT);
58            vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)]
59        }
60        _ => {
61            let series = data.series.iter().take(MAX_SERIES);
62            let mut out: Vec<(String, Rgba, Swatch)> = series
63                .clone()
64                .enumerate()
65                .map(|(i, s)| {
66                    let sw = match plot_kind(chart, s, i) {
67                        ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line,
68                        ChartKind::LineMarkers => Swatch::LineMarker,
69                        ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker,
70                        _ => Swatch::Box,
71                    };
72                    (s.name.clone(), s.color, sw)
73                })
74                .collect();
75            // Trendlines follow the series, named "Linear (Sales)" unless given a name.
76            for (_, s) in series.enumerate().filter(|(i, s)| supports_trendline(plot_kind(chart, s, *i))) {
77                for t in s.trendlines.iter().take(MAX_TRENDLINES) {
78                    out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line));
79                }
80            }
81            out
82        }
83    }
84}
85
86// ---------------------------------------------------------------- legend
87
88fn swatch_w(sw: Swatch, s: f32) -> f32 {
89    if sw == Swatch::Box { s } else { s * 2.0 }
90}
91
92pub(super) fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) {
93    match sw {
94        Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None),
95        Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0),
96        Swatch::LineMarker => {
97            ctx.hline(x, x + 2.0 * s, yc, color, 2.0);
98            ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.35, fill: Some(color), stroke: None });
99        }
100        Swatch::Marker => ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.4, fill: Some(color), stroke: None }),
101    }
102}
103
104pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx {
105    let size = LABEL_SIZE;
106    let row_h = size * 1.6;
107    let s = size * 0.8;
108    let gap = 4.0;
109    let spacing = size * 1.3;
110    let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size);
111    match pos {
112        LegendPos::None => area,
113        LegendPos::Top | LegendPos::Bottom => {
114            let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1);
115            let mut rows: Vec<Vec<(usize, f32)>> = vec![vec![]];
116            let mut cur = 0.0f32;
117            for (i, e) in entries.iter().enumerate() {
118                let iw = item_w(ctx, e).min(area.w);
119                let need = if cur > 0.0 { spacing + iw } else { iw };
120                if cur > 0.0 && cur + need > area.w {
121                    if rows.len() >= max_rows {
122                        break;
123                    }
124                    rows.push(vec![]);
125                    cur = 0.0;
126                }
127                cur += if cur > 0.0 { spacing + iw } else { iw };
128                if let Some(r) = rows.last_mut() {
129                    r.push((i, iw));
130                }
131            }
132            let lh = rows.len() as f32 * row_h;
133            let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y };
134            for (ri, row) in rows.iter().enumerate() {
135                let total: f32 = row.iter().map(|(_, w)| w).sum::<f32>() + spacing * row.len().saturating_sub(1) as f32;
136                let mut x = area.x + (area.w - total).max(0.0) / 2.0;
137                let yc = y0 + ri as f32 * row_h + row_h / 2.0;
138                for &(i, iw) in row {
139                    if let Some(e) = entries.get(i) {
140                        draw_swatch(ctx, e.2, x, yc, s, e.1);
141                        let sx = swatch_w(e.2, s) + gap;
142                        let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5);
143                        ctx.text(x + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle));
144                    }
145                    x += iw + spacing;
146                }
147            }
148            let used = lh + 4.0;
149            if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } }
150        }
151        LegendPos::Left | LegendPos::Right => {
152            let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35);
153            let max_rows = ((area.h / row_h).floor() as usize).max(1);
154            let n = entries.len().min(max_rows);
155            let lh = n as f32 * row_h;
156            let y0 = area.y + (area.h - lh).max(0.0) / 2.0;
157            let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x };
158            for (i, e) in entries.iter().take(n).enumerate() {
159                let yc = y0 + i as f32 * row_h + row_h / 2.0;
160                draw_swatch(ctx, e.2, x0, yc, s, e.1);
161                let sx = swatch_w(e.2, s) + gap;
162                let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5);
163                ctx.text(x0 + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle));
164            }
165            let used = colw + 8.0;
166            if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } }
167        }
168    }
169}