Files
gridcraft/crates/chart/src/render/frame.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

255 lines
11 KiB
Rust

use super::{AXIS, AXIS_TITLE_SIZE, Chart, ChartData, Ctx, DARK_TEXT, Fmt, GRID, HAlign, LABEL_SIZE, PI, Prim, Rgba, Scale, TEXT, VAlign, WHITE};
#[derive(Clone, Copy, Debug)]
pub(super) struct Bx {
pub(super) x: f32,
pub(super) y: f32,
pub(super) w: f32,
pub(super) h: f32,
}
impl Bx {
pub(super) fn right(&self) -> f32 {
self.x + self.w
}
pub(super) fn bottom(&self) -> f32 {
self.y + self.h
}
pub(super) fn ok(&self) -> bool {
self.w.is_finite() && self.h.is_finite() && self.w > 4.0 && self.h > 4.0
}
pub(super) fn cx(&self) -> f32 {
self.x + self.w / 2.0
}
pub(super) fn cy(&self) -> f32 {
self.y + self.h / 2.0
}
}
/// Text colour readable on `fill`.
pub(super) fn on(fill: Rgba) -> Rgba {
let l = 0.299 * fill[0] as f32 + 0.587 * fill[1] as f32 + 0.114 * fill[2] as f32;
if l < 150.0 { WHITE } else { DARK_TEXT }
}
pub(super) fn chart_title(chart: &Chart, data: &ChartData) -> Option<String> {
match &chart.title {
Some(t) => Some(t.trim().to_string()).filter(|t| !t.is_empty()),
None if data.series.len() == 1 => data.series.first().map(|s| s.name.clone()).filter(|t| !t.is_empty()),
None => None,
}
}
pub(super) fn axis_titles(ctx: &mut Ctx, chart: &Chart, mut area: Bx) -> Bx {
let lh = AXIS_TITLE_SIZE * 1.4 + 2.0;
if let Some(t) = chart.x_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, AXIS_TITLE_SIZE, area.w);
ctx.text(area.cx(), area.bottom(), &t, AXIS_TITLE_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
area.h -= lh;
}
if let Some(t) = chart.y_title.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, AXIS_TITLE_SIZE, area.h);
if !t.is_empty() {
ctx.out.push(Prim::Text {
x: area.x,
y: area.cy(),
text: t,
size: AXIS_TITLE_SIZE,
color: TEXT,
bold: false,
align: HAlign::Center,
valign: VAlign::Top,
rotation: -PI / 2.0,
});
}
area.x += lh;
area.w -= lh;
}
area
}
// ---------------------------------------------------------------- axes frame
pub(super) enum CatAxis<'a> {
/// Category labels and, for a multi-level axis, the outer levels (nearest the labels first;
/// one entry per category, non-empty where a group starts).
Labels(&'a [String], &'a [Vec<String>]),
/// A data table supplies the labels below a vertical chart. Its stub must clear the plot.
Table(&'a [String], f32),
Numeric(Scale, &'a Fmt),
}
/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every
/// non-empty entry (and at the first category).
pub(super) fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> {
let mut starts: Vec<usize> = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect();
starts.push(n);
starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
}
pub(super) struct ValAxis<'a> {
pub(super) scale: Scale,
pub(super) fmt: &'a Fmt,
}
impl ValAxis<'_> {
fn labels(&self) -> Vec<(f64, String)> {
self.scale.ticks().into_iter().map(|v| (v, self.fmt.format(v, Some(self.scale.step)))).collect()
}
}
/// Draws gridlines, axis labels and the category axis line; returns the plot rectangle.
pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis>, horizontal: bool, grid: bool) -> Bx {
let ls = LABEL_SIZE;
let lh = ls * 1.4;
let ylabels = y.labels();
let stub = match &cat {
CatAxis::Table(_, width) => *width,
_ => 0.0,
};
let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max);
if !horizontal {
let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4).max(stub);
let y2labels = y2.map(ValAxis::labels);
let right = match (&y2labels, &cat) {
(Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3),
(None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),
_ => 4.0,
};
let levels = match &cat {
CatAxis::Labels(_, o) => o.len() as f32,
CatAxis::Numeric(..) | CatAxis::Table(..) => 0.0,
};
let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels };
if !plot.ok() {
return plot;
}
for (v, t) in &ylabels {
let yy = plot.bottom() - y.scale.frac(*v) * plot.h;
if grid {
ctx.hline(plot.x, plot.right(), yy, GRID, 0.75);
}
ctx.text(plot.x - 4.0, yy, t, ls, TEXT, (HAlign::Right, VAlign::Middle));
}
if let (Some(a), Some(l)) = (y2, &y2labels) {
for (v, t) in l {
let yy = plot.bottom() - a.scale.frac(*v) * plot.h;
ctx.text(plot.right() + 4.0, yy, t, ls, TEXT, (HAlign::Left, VAlign::Middle));
}
}
let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };
let y0 = plot.bottom() - y.scale.frac(zero) * plot.h;
ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75);
match cat {
CatAxis::Table(..) => {}
CatAxis::Labels(l, outer) => {
let n = l.len().max(1);
let cw = plot.w / n as f32;
let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);
let k = (((lw + 6.0) / cw.max(1e-3)).ceil() as usize).clamp(1, n);
for (j, t) in l.iter().enumerate().step_by(k) {
let t = ctx.fit(t, ls, cw * k as f32 - 2.0);
ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
// Outer levels, one row each below the labels: a group's name centred under its
// categories, and a separator at each group boundary down through the rows.
for (i, level) in outer.iter().enumerate() {
let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0);
for (first, end) in level_groups(level, n) {
let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw);
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, ls, x1 - x0 - 2.0);
ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
if first > 0 {
ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75);
}
}
ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75);
}
}
CatAxis::Numeric(s, f) => {
let ticks = s.ticks();
let labels: Vec<String> = ticks.iter().map(|v| f.format(*v, Some(s.step))).collect();
let lw = labels.iter().map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);
let slot = plot.w / ticks.len().max(1) as f32;
let k = (((lw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1);
for (v, t) in ticks.iter().zip(&labels).step_by(k) {
let xx = plot.x + s.frac(*v) * plot.w;
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
if s.min <= 0.0 && s.max >= 0.0 {
let xx = plot.x + s.frac(0.0) * plot.w;
ctx.vline(xx, plot.y, plot.bottom(), AXIS, 0.75);
}
}
}
plot
} else {
let (labels, outer): (Vec<String>, &[Vec<String>]) = match cat {
CatAxis::Labels(l, o) => (l.to_vec(), o),
CatAxis::Table(l, _) => (l.to_vec(), &[]),
CatAxis::Numeric(..) => (vec![], &[]),
};
let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35);
// Outer levels stand in columns left of the labels, each as wide as its longest name.
let level_w: Vec<f32> =
outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect();
let levels_w: f32 = level_w.iter().sum();
let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0;
let left = (lw + levels_w).max(stub);
let plot = Bx { x: area.x + left, y: area.y + 2.0, w: area.w - left - right, h: area.h - lh - 6.0 };
if !plot.ok() {
return plot;
}
let vw = maxw(ctx, &ylabels);
let slot = plot.w / ylabels.len().max(1) as f32;
let k = (((vw + 6.0) / slot.max(1e-3)).ceil() as usize).max(1);
for (i, (v, t)) in ylabels.iter().enumerate() {
let xx = plot.x + y.scale.frac(*v) * plot.w;
if grid {
ctx.vline(xx, plot.y, plot.bottom(), GRID, 0.75);
}
if i % k == 0 {
ctx.text(xx, plot.bottom() + 3.0, t, ls, TEXT, (HAlign::Center, VAlign::Top));
}
}
let zero = if y.scale.min <= 0.0 && y.scale.max >= 0.0 { 0.0 } else { y.scale.min };
let x0 = plot.x + y.scale.frac(zero) * plot.w;
ctx.vline(x0, plot.y, plot.bottom(), AXIS, 0.75);
let n = labels.len().max(1);
let ch = plot.h / n as f32;
let k = ((lh / ch.max(1e-3)).ceil() as usize).clamp(1, n);
for (j, t) in labels.iter().enumerate().step_by(k) {
let t = ctx.fit(t, ls, lw - 6.0);
ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, (HAlign::Right, VAlign::Middle));
}
// Outer levels: a group's name centred beside its categories, separators at the
// boundaries running right to the axis.
let mut x1 = plot.x - lw;
for (level, w) in outer.iter().zip(&level_w) {
let x0 = x1 - w;
for (first, end) in level_groups(level, n) {
let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch);
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
let t = ctx.fit(t, ls, w - 4.0);
ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, (HAlign::Center, VAlign::Middle));
}
if first > 0 {
ctx.hline(x0, plot.x, y1, AXIS, 0.75);
}
}
ctx.hline(x0, plot.x, plot.y, AXIS, 0.75);
x1 = x0;
}
plot
}
}
pub(super) fn value_range(it: impl Iterator<Item = f64>) -> Option<(f64, f64)> {
it.fold(None, |acc, v| match acc {
None => Some((v, v)),
Some((a, b)) => Some((a.min(v), b.max(v))),
})
}