Files
gridcraft/crates/chart/src/render/legend.rs
T
campbellcodyandCodex b6c4500fa7 M7: add data tables beneath charts
Add command and UI controls for chart data tables, shared chart and PDF rendering, and native XLSX persistence. Cover live values, blanks, formatting, alignment, undo/redo, bounds and table flags with regression tests.

Co-Authored-By: Codex <noreply@openai.com>
2026-11-01 15:30:46 -05:00

166 lines
7.4 KiB
Rust

use super::frame::Bx;
use super::trend::supports_trendline;
use super::{
Chart, ChartData, ChartKind, Ctx, DOWN, HAlign, LABEL_SIZE, MAX_CATS, MAX_SERIES, MAX_TRENDLINES, Prim, Rgba, SeriesData, TEXT, Theme, UP,
VAlign, auto_name, series_color,
};
use gridcraft_model::LegendPos;
/// How a legend entry's swatch is drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Swatch {
Box,
Line,
LineMarker,
Marker,
}
/// The kind a series is drawn as (per-series kinds only apply to combo charts).
pub(super) fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind {
if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind }
}
/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst).
pub(super) fn point_color(data: &ChartData, j: usize) -> Rgba {
match (j, data.series.first()) {
(0, Some(s)) => s.color,
_ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)),
}
}
fn first_len(data: &ChartData) -> usize {
data.series.first().map(|s| s.values.len()).unwrap_or(0)
}
pub(super) fn cat_label(data: &ChartData, j: usize) -> String {
data.categories.get(j).cloned().unwrap_or_else(|| (j + 1).to_string())
}
/// Legend entries (text, colour, swatch) `render` draws for this chart; empty when hidden.
pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swatch)> {
if chart.legend == LegendPos::None {
return vec![];
}
match chart.kind {
ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => {
let n = first_len(data).min(MAX_CATS);
(0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect()
}
ChartKind::Histogram | ChartKind::Funnel | ChartKind::Stock => vec![],
ChartKind::Waterfall => {
if data.series.is_empty() {
return vec![];
}
let total = data.series.first().map(|s| s.color).unwrap_or(TEXT);
vec![("Increase".into(), UP, Swatch::Box), ("Decrease".into(), DOWN, Swatch::Box), ("Total".into(), total, Swatch::Box)]
}
_ => {
let series = data.series.iter().take(MAX_SERIES);
let mut out: Vec<(String, Rgba, Swatch)> = series
.clone()
.map(|s| {
let sw = match plot_kind(chart, s) {
ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line,
ChartKind::LineMarkers => Swatch::LineMarker,
ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker,
_ => Swatch::Box,
};
(s.name.clone(), s.color, sw)
})
.collect();
// Trendlines follow the series, named "Linear (Sales)" unless given a name.
for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) {
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line));
}
}
out
}
}
}
// ---------------------------------------------------------------- legend
fn swatch_w(sw: Swatch, s: f32) -> f32 {
if sw == Swatch::Box { s } else { s * 2.0 }
}
pub(super) fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) {
match sw {
Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None),
Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0),
Swatch::LineMarker => {
ctx.hline(x, x + 2.0 * s, yc, color, 2.0);
ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.35, fill: Some(color), stroke: None });
}
Swatch::Marker => ctx.out.push(Prim::Circle { cx: x + s, cy: yc, r: s * 0.4, fill: Some(color), stroke: None }),
}
}
pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos, area: Bx) -> Bx {
let size = LABEL_SIZE;
let row_h = size * 1.6;
let s = size * 0.8;
let gap = 4.0;
let spacing = size * 1.3;
let item_w = |ctx: &Ctx, e: &(String, Rgba, Swatch)| swatch_w(e.2, s) + gap + ctx.tw(&e.0, size);
match pos {
LegendPos::None => area,
LegendPos::Top | LegendPos::Bottom => {
let max_rows = ((area.h * 0.3 / row_h).floor() as usize).max(1);
let mut rows: Vec<Vec<(usize, f32)>> = vec![vec![]];
let mut cur = 0.0f32;
for (i, e) in entries.iter().enumerate() {
let iw = item_w(ctx, e).min(area.w);
let need = if cur > 0.0 { spacing + iw } else { iw };
if cur > 0.0 && cur + need > area.w {
if rows.len() >= max_rows {
break;
}
rows.push(vec![]);
cur = 0.0;
}
cur += if cur > 0.0 { spacing + iw } else { iw };
if let Some(r) = rows.last_mut() {
r.push((i, iw));
}
}
let lh = rows.len() as f32 * row_h;
let y0 = if pos == LegendPos::Bottom { area.bottom() - lh } else { area.y };
for (ri, row) in rows.iter().enumerate() {
let total: f32 = row.iter().map(|(_, w)| w).sum::<f32>() + spacing * row.len().saturating_sub(1) as f32;
let mut x = area.x + (area.w - total).max(0.0) / 2.0;
let yc = y0 + ri as f32 * row_h + row_h / 2.0;
for &(i, iw) in row {
if let Some(e) = entries.get(i) {
draw_swatch(ctx, e.2, x, yc, s, e.1);
let sx = swatch_w(e.2, s) + gap;
let t = ctx.fit(&e.0, size, (iw - sx).max(0.0) + 0.5);
ctx.text(x + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle));
}
x += iw + spacing;
}
}
let used = lh + 4.0;
if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } }
}
LegendPos::Left | LegendPos::Right => {
let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35);
let max_rows = ((area.h / row_h).floor() as usize).max(1);
let n = entries.len().min(max_rows);
let lh = n as f32 * row_h;
let y0 = area.y + (area.h - lh).max(0.0) / 2.0;
let x0 = if pos == LegendPos::Right { area.right() - colw } else { area.x };
for (i, e) in entries.iter().take(n).enumerate() {
let yc = y0 + i as f32 * row_h + row_h / 2.0;
draw_swatch(ctx, e.2, x0, yc, s, e.1);
let sx = swatch_w(e.2, s) + gap;
let t = ctx.fit(&e.0, size, (colw - sx).max(0.0) + 0.5);
ctx.text(x0 + sx, yc, &t, size, TEXT, (HAlign::Left, VAlign::Middle));
}
let used = colw + 8.0;
if pos == LegendPos::Right { Bx { w: area.w - used, ..area } } else { Bx { x: area.x + used, w: area.w - used, ..area } }
}
}
}