mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-02 16:30:50 -06:00
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>
255 lines
11 KiB
Rust
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))),
|
|
})
|
|
}
|