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>
This commit is contained in:
campbellcody
2026-11-01 15:30:46 -05:00
co-authored by Codex
parent f5030432fc
commit b6c4500fa7
37 changed files with 560 additions and 18 deletions
+1
View File
@@ -14,6 +14,7 @@ mod frame;
mod kinds;
mod legend;
mod lines;
mod table;
mod trend;
use cartesian::{cartesian, scatter};
+4 -1
View File
@@ -208,7 +208,9 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
// Outer levels padded to the slots (forecast slots stay in the last group).
let outer: Vec<Vec<String>> =
data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect();
let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines);
let (area, table) = super::table::reserve(ctx, chart, data, area, ncat_slots);
let category_axis = super::table::category_axis(&table, &labels, &outer);
let plot = frame(ctx, area, category_axis, &y1, y2.as_ref(), horizontal, chart.gridlines);
if !plot.ok() {
return;
}
@@ -355,6 +357,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5));
}
draw_trend_labels(ctx, plot, &trend_labels);
super::table::draw(ctx, chart, data, plot, table.as_ref());
}
// ---------------------------------------------------------------- scatter & bubble
+13 -4
View File
@@ -74,12 +74,14 @@ 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).
fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> {
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()
@@ -101,9 +103,13 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
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);
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),
@@ -112,7 +118,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
};
let levels = match &cat {
CatAxis::Labels(_, o) => o.len() as f32,
CatAxis::Numeric(..) => 0.0,
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() {
@@ -135,6 +141,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
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;
@@ -181,6 +188,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
} 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);
@@ -189,7 +197,8 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
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 plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.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;
}
+1 -1
View File
@@ -85,7 +85,7 @@ fn swatch_w(sw: Swatch, s: f32) -> f32 {
if sw == Swatch::Box { s } else { s * 2.0 }
}
fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) {
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),
+107
View File
@@ -0,0 +1,107 @@
//! The chart data table uses the same primitives as the plot, including in print/PDF output.
use super::frame::{Bx, CatAxis, level_groups};
use super::legend::{Swatch, cat_label, draw_swatch, plot_kind};
use super::{AXIS, Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, TEXT, VAlign};
pub(super) struct Layout {
top: f32,
row_height: f32,
headers: usize,
stub: f32,
columns: usize,
}
pub(super) fn category_axis<'a>(layout: &Option<Layout>, labels: &'a [String], outer: &'a [Vec<String>]) -> CatAxis<'a> {
match layout {
Some(t) => CatAxis::Table(labels, t.stub),
None => CatAxis::Labels(labels, outer),
}
}
/// Reserve at most half the available height. A table that cannot fit is explicitly labelled
/// instead of dropping rows or allocating a series × point-sized matrix.
pub(super) fn reserve(ctx: &mut Ctx, chart: &Chart, data: &ChartData, mut area: Bx, columns: usize) -> (Bx, Option<Layout>) {
if chart.data_table.is_none() || !chart.supports_data_table() {
return (area, None);
}
let headers = data.category_levels.len() + 1;
let rows = headers + data.series.len();
let row_height = LABEL_SIZE * 1.4 + 4.0;
let height = row_height * rows as f32;
let key_width = if chart.data_table.as_ref().is_some_and(|t| t.show_keys) { 22.0 } else { 0.0 };
let names = data.series.iter().take(32).map(|s| ctx.tw(&s.name, LABEL_SIZE)).fold(0.0f32, f32::max);
let stub = (names + key_width + 10.0).clamp(24.0, (area.w * 0.3).max(24.0));
if columns == 0 || columns > 128 || data.series.len() > 32 || height > area.h * 0.5 || (area.w * 0.6) / (columns as f32) < 18.0 {
let message = ctx.fit("Data table needs more space", LABEL_SIZE, area.w);
ctx.text(area.cx(), area.bottom(), &message, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
area.h -= row_height;
return (area, None);
}
area.h -= height;
(area, Some(Layout { top: area.bottom(), row_height, headers, stub, columns }))
}
pub(super) fn draw(ctx: &mut Ctx, chart: &Chart, data: &ChartData, plot: Bx, layout: Option<&Layout>) {
let Some(layout) = layout else { return };
let Some(settings) = &chart.data_table else { return };
let x0 = plot.x - layout.stub;
let top = layout.top;
let rows = layout.headers + data.series.len();
let bottom = top + layout.row_height * rows as f32;
let cw = plot.w / layout.columns as f32;
// Outermost category level first, then the individual categories.
for (i, level) in data.category_levels.iter().rev().enumerate() {
for (first, end) in level_groups(level, layout.columns) {
let text = level.get(first).map(String::as_str).unwrap_or("");
let text = ctx.fit(text, LABEL_SIZE, (end - first) as f32 * cw - 4.0);
let x = plot.x + (first + end) as f32 * cw / 2.0;
ctx.text(x, top + (i as f32 + 0.5) * layout.row_height, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle));
}
}
let header_y = top + (layout.headers as f32 - 0.5) * layout.row_height;
let n = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0));
for j in 0..layout.columns {
let text = if j < n { cat_label(data, j) } else { String::new() };
let text = ctx.fit(&text, LABEL_SIZE, cw - 4.0);
ctx.text(plot.x + (j as f32 + 0.5) * cw, header_y, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle));
}
for (i, series) in data.series.iter().enumerate() {
let y = top + (layout.headers as f32 + i as f32 + 0.5) * layout.row_height;
let name_x = if settings.show_keys { x0 + 25.0 } else { x0 + 4.0 };
if settings.show_keys {
let swatch = match plot_kind(chart, series) {
ChartKind::Line | ChartKind::LineStacked => Swatch::Line,
ChartKind::LineMarkers => Swatch::LineMarker,
_ => Swatch::Box,
};
draw_swatch(ctx, swatch, x0 + 4.0, y, 7.0, series.color);
}
let name = ctx.fit(&series.name, LABEL_SIZE, plot.x - name_x - 4.0);
ctx.text(name_x, y, &name, LABEL_SIZE, TEXT, (HAlign::Left, VAlign::Middle));
let fmt = Fmt::new(&series.number_format);
for j in 0..layout.columns {
let value = series.values.get(j).copied().flatten().map(|v| fmt.format(v, None)).unwrap_or_default();
let text = ctx.fit(&value, LABEL_SIZE, cw - 4.0);
ctx.text(plot.x + (j as f32 + 0.5) * cw, y, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle));
}
}
if settings.horizontal_border {
for row in 1..rows {
ctx.hline(x0, plot.right(), top + row as f32 * layout.row_height, AXIS, 0.75);
}
}
if settings.vertical_border {
ctx.vline(plot.x, top, bottom, AXIS, 0.75);
// The outer headers span groups; category dividers begin at the innermost header.
for j in 1..layout.columns {
ctx.vline(plot.x + j as f32 * cw, top + (layout.headers - 1) as f32 * layout.row_height, bottom, AXIS, 0.75);
}
}
if settings.outline {
ctx.hline(x0, plot.right(), top, AXIS, 0.75);
ctx.hline(x0, plot.right(), bottom, AXIS, 0.75);
ctx.vline(x0, top, bottom, AXIS, 0.75);
ctx.vline(plot.right(), top, bottom, AXIS, 0.75);
}
}
+3
View File
@@ -5,6 +5,8 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl
use crate::*;
mod data_table;
const ALL_KINDS: [ChartKind; 26] = [
ChartKind::ColumnClustered,
ChartKind::ColumnStacked,
@@ -43,6 +45,7 @@ fn chart(kind: ChartKind) -> Chart {
series: vec![],
legend: LegendPos::Bottom,
data_labels: true,
data_table: None,
gridlines: true,
style: 0,
x_title: Some("Quarter".into()),
+121
View File
@@ -0,0 +1,121 @@
use super::*;
use gridcraft_model::ChartDataTable;
#[test]
fn tables_draw_source_values_for_every_supported_chart_kind() {
for kind in ALL_KINDS {
let mut c = chart(kind);
c.legend = LegendPos::None;
c.data_labels = false;
c.data_table = Some(ChartDataTable::default());
let supported = c.supports_data_table();
let d = sample(kind);
let prims = render(&c, &d, 900.0, 500.0, &ApproxMeasure);
assert_sane(&prims, 900.0, 500.0);
let labels = texts(&prims);
// Source amounts, including on 100% stacked charts, not shares or running totals.
if supported {
for value in ["$12,000", "-$4,000", "$38,000", "$61,870"] {
assert!(labels.contains(&value), "{kind:?}: missing {value}");
}
} else {
c.data_table = None;
assert_eq!(prims, render(&c, &d, 900.0, 500.0, &ApproxMeasure));
}
}
}
#[test]
fn table_columns_align_with_plot_and_keep_missing_values_blank() {
let mut c = chart(ChartKind::ColumnClustered);
c.title = Some(String::new());
c.x_title = None;
c.y_title = None;
c.legend = LegendPos::None;
c.data_labels = false;
c.data_table = Some(ChartDataTable::default());
let color = [1, 2, 3, 255];
let mut s = series("Rate", &[0.25, 0.0, 0.75], color, c.kind);
s.values[1] = None;
s.number_format = "0.0%".into();
let d = ChartData { categories: ["Jan", "Feb", "Mar"].map(String::from).to_vec(), series: vec![s], ..Default::default() };
let p = render(&c, &d, 600.0, 400.0, &ApproxMeasure);
let at = |text: &str| -> (f32, f32) {
p.iter()
.find_map(|p| match p {
Prim::Text { text: t, x, y, .. } if t == text => Some((*x, *y)),
_ => None,
})
.unwrap()
};
let (x1, y1) = at("25.0%");
let (x3, y3) = at("75.0%");
let header_y = at("Jan").1;
assert!(y1 > header_y && (y1 - y3).abs() < 0.01);
assert_eq!(at("Jan").0, x1);
assert_eq!(at("Mar").0, x3);
assert_eq!(texts(&p).iter().filter(|t| **t == "Jan").count(), 1, "the table replaces category-axis labels");
let centers: Vec<f32> = p
.iter()
.filter_map(|p| match p {
Prim::Rect { x, y, w, fill: Some(f), .. } if *f == color && *y < header_y => Some(x + w / 2.0),
_ => None,
})
.collect();
assert_eq!(centers.len(), 2);
assert!((centers[0] - x1).abs() < 0.01 && (centers[1] - x3).abs() < 0.01);
let cells: Vec<&str> = p
.iter()
.filter_map(|p| match p {
Prim::Text { y, text, .. } if (*y - y1).abs() < 0.01 => Some(text.as_str()),
_ => None,
})
.collect();
assert_eq!(cells, ["Rate", "25.0%", "75.0%"], "blank cells are not zeroes");
assert!(
p.iter().any(|p| matches!(p, Prim::Rect { y, fill: Some(f), .. } if *f == color && *y > header_y)),
"legend key independent of hidden chart legend"
);
c.data_table = Some(ChartDataTable { show_keys: false, ..Default::default() });
let off = render(&c, &d, 600.0, 400.0, &ApproxMeasure);
assert!(!off.iter().any(|p| matches!(p, Prim::Rect { y, fill: Some(f), .. } if *f == color && *y > header_y)));
}
#[test]
fn table_borders_are_independent_and_multilevel_headers_are_kept() {
let mut c = chart(ChartKind::Line);
c.legend = LegendPos::None;
c.data_labels = false;
let mut d = sample(c.kind);
d.category_levels = vec![vec!["2025".into(), "".into(), "".into(), "2026".into(), "".into(), "".into()]];
for (h, v, outline, want) in [(false, false, false, 0), (true, false, false, 4), (false, true, false, 6), (false, false, true, 4)] {
c.data_table = Some(ChartDataTable { horizontal_border: h, vertical_border: v, outline, show_keys: false });
let p = render(&c, &d, 900.0, 600.0, &ApproxMeasure);
let year_y = p
.iter()
.find_map(|p| match p {
Prim::Text { text, y, .. } if text == "2025" => Some(*y),
_ => None,
})
.unwrap();
assert!(texts(&p).contains(&"2026"));
let lines = p.iter().filter(|p| matches!(p, Prim::Line { pts, .. } if pts.iter().all(|p| p[1] > year_y - 9.0))).count();
assert_eq!(lines, want, "{h} {v} {outline}");
assert_sane(&p, 900.0, 600.0);
}
}
#[test]
fn dense_and_tiny_tables_are_bounded_and_explicit() {
let mut c = chart(ChartKind::ColumnClustered);
c.data_table = Some(ChartDataTable::default());
let mut d = sample(c.kind);
d.categories = (0..20_000).map(|i| i.to_string()).collect();
let p = render(&c, &d, 600.0, 400.0, &ApproxMeasure);
assert!(texts(&p).contains(&"Data table needs more space"));
assert_sane(&p, 600.0, 400.0);
d.categories.clear();
for (w, h) in [(1.0, 1.0), (10.0, 10.0), (40.0, 35.0), (200.0, 100.0)] {
assert_sane(&render(&c, &d, w, h, &ApproxMeasure), w, h);
}
}