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
+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);
}
}