mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-09 20:09:57 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user