mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-02 07:06:48 -06:00
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-01 | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. |
|
||||
| 2026-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). |
|
||||
| 2026-10-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) |
|
||||
| 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
|
||||
@@ -250,6 +251,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | minor | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. |
|
||||
| 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) |
|
||||
| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) |
|
||||
| 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories |
|
||||
|
||||
@@ -14,6 +14,7 @@ mod frame;
|
||||
mod kinds;
|
||||
mod legend;
|
||||
mod lines;
|
||||
mod table;
|
||||
mod trend;
|
||||
|
||||
use cartesian::{cartesian, scatter};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Chart Element",
|
||||
["Chart Design", "Chart Layouts"],
|
||||
None,
|
||||
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
|
||||
"{chart?, element: title|legend|dataLabels|dataTable|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; dataTable: dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
|
||||
has_doc,
|
||||
add_element
|
||||
),
|
||||
|
||||
@@ -43,6 +43,7 @@ pub(super) fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
"dataLabels" => c.data_labels = on,
|
||||
"dataTable" => crate::cmd::insert::update_data_table(c, p, Some(on))?,
|
||||
"gridlines" => c.gridlines = on,
|
||||
"axisTitles" => {
|
||||
c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into()));
|
||||
|
||||
@@ -424,6 +424,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
series,
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 1,
|
||||
x_title: None,
|
||||
|
||||
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Chart Properties",
|
||||
["Chart Design"],
|
||||
None,
|
||||
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, dataTable?: bool, dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool (data tables: column/bar/line/area and compatible combo charts), quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
has_doc,
|
||||
chart_set
|
||||
),
|
||||
@@ -836,6 +836,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
series,
|
||||
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
x_title: None,
|
||||
@@ -883,7 +884,11 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
|
||||
if let Some(t) = str_param(p, "type") {
|
||||
c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));
|
||||
if !c.supports_data_table() {
|
||||
c.data_table = None;
|
||||
}
|
||||
}
|
||||
update_data_table(c, p, bool_param(p, "dataTable"))?;
|
||||
if let Some(method) = quartiles {
|
||||
for series in &mut c.series {
|
||||
series.quartiles = method;
|
||||
@@ -935,6 +940,32 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared by Chart Properties and Add Chart Element; no separate UI state.
|
||||
pub(super) fn update_data_table(c: &mut Chart, p: &Json, on: Option<bool>) -> Result<()> {
|
||||
if p.get("dataTable").is_some_and(|v| !v.is_boolean()) {
|
||||
return Err(bad("chart", "`dataTable` must be a boolean"));
|
||||
}
|
||||
if on == Some(false) {
|
||||
c.data_table = None;
|
||||
return Ok(());
|
||||
}
|
||||
let fields = ["dataTableKeys", "dataTableHorizontalBorder", "dataTableVerticalBorder", "dataTableOutline"];
|
||||
if on == Some(true) || fields.iter().any(|k| p.get(k).is_some()) {
|
||||
if !c.supports_data_table() {
|
||||
return Err(bad("chart", "data tables need a column, bar, line, area or compatible combo chart"));
|
||||
}
|
||||
let table = c.data_table.get_or_insert_with(Default::default);
|
||||
for (key, value) in
|
||||
fields.into_iter().zip([&mut table.show_keys, &mut table.horizontal_border, &mut table.vertical_border, &mut table.outline])
|
||||
{
|
||||
if let Some(v) = p.get(key) {
|
||||
*value = v.as_bool().ok_or_else(|| bad("chart", format!("`{key}` must be a boolean")))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ci) = find_chart(s, p)?;
|
||||
edit(s, |cx| {
|
||||
@@ -1794,6 +1825,56 @@ mod tests {
|
||||
assert_eq!(values, ["Sheet1!$C$700000:$C$700010"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_data_table_commands_undo_and_follow_live_values() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "Sales"], ["Q1", 10], ["Q2", 20]]})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "A1:B3", "type": "column"})).unwrap();
|
||||
s.execute("chart.addElement", json!({"element": "dataTable", "dataTableKeys": false})).unwrap();
|
||||
let get = |s: &Session| s.active().unwrap().wb.active().unwrap().charts[0].clone();
|
||||
let table = get(&s).data_table.unwrap();
|
||||
assert!(!table.show_keys && table.horizontal_border && table.vertical_border && table.outline);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(get(&s).data_table, None);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(get(&s).data_table, Some(table));
|
||||
s.execute("chart.set", json!({"dataTableKeys": true, "dataTableHorizontalBorder": false})).unwrap();
|
||||
assert!(get(&s).data_table.as_ref().unwrap().show_keys);
|
||||
assert!(!get(&s).data_table.as_ref().unwrap().horizontal_border);
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "=12*3"})).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
assert_eq!(gridcraft_chart::resolve(&d.wb, 0, &get(&s)).series[0].values[0], Some(36.0));
|
||||
s.execute("chart.addElement", json!({"element": "dataTable", "on": false})).unwrap();
|
||||
assert_eq!(get(&s).data_table, None);
|
||||
// Old JSON documents have no table field.
|
||||
let mut json = serde_json::to_value(get(&s)).unwrap();
|
||||
json.as_object_mut().unwrap().remove("data_table");
|
||||
assert_eq!(serde_json::from_value::<Chart>(json).unwrap().data_table, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_data_table_refuses_unsupported_types_without_changing_history() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Q", "Sales"], ["Q1", 10], ["Q2", 20]]})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "A1:B3", "type": "pie"})).unwrap();
|
||||
let before = s.active().unwrap().wb.clone();
|
||||
let history = s.execute("history.list", json!({})).unwrap();
|
||||
for params in [json!({"dataTable": true}), json!({"dataTableKeys": true}), json!({"dataTable": "yes"})] {
|
||||
assert!(s.execute("chart.set", params).is_err());
|
||||
assert_eq!(s.active().unwrap().wb, before);
|
||||
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
|
||||
}
|
||||
assert!(s.execute("chart.addElement", json!({"element": "dataTable"})).is_err());
|
||||
s.execute("chart.set", json!({"type": "line", "dataTable": true})).unwrap();
|
||||
assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_some());
|
||||
s.execute("chart.set", json!({"type": "scatter"})).unwrap();
|
||||
assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_none());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manage_rules_delete_then_move() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -260,6 +260,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
series,
|
||||
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
x_title: None,
|
||||
|
||||
@@ -46,7 +46,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
"freeze": sh.freeze,
|
||||
"merges": sh.merges.iter().map(|m| m.a1()).collect::<Vec<_>>(),
|
||||
"tables": sh.tables.iter().map(|t| json!({"name": t.name, "range": t.range.a1(), "style": t.style, "totals": t.totals_row, "columns": t.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "dataTable": c.data_table.as_ref().map(|t| json!({"showKeys": t.show_keys, "horizontalBorder": t.horizontal_border, "verticalBorder": t.vertical_border, "outline": t.outline})), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"images": sh.images.len(),
|
||||
"shapes": sh.shapes.len(),
|
||||
"conditionalFormats": sh.cond_formats.len(),
|
||||
|
||||
@@ -65,6 +65,7 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
series,
|
||||
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
x_title: None,
|
||||
|
||||
@@ -16,6 +16,25 @@ fn contains(hay: &[u8], needle: &str) -> bool {
|
||||
hay.windows(needle.len()).any(|w| w == needle.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_data_table_prints_formatted_values_and_follows_recalculation() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "Sales"], ["Q1", 1234], ["Q2", 5678]]})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "B2:B3", "format": "#,##0.00"})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "A1:B3", "at": "D2", "type": "column"})).unwrap();
|
||||
let (off, pages) = pdf_of(&mut s, json!({"fitToPage": true}));
|
||||
assert_eq!(pages, 1);
|
||||
let count = |bytes: &[u8], text: &str| String::from_utf8_lossy(bytes).matches(text).count();
|
||||
s.execute("chart.set", json!({"dataTable": true})).unwrap();
|
||||
let (on, _) = pdf_of(&mut s, json!({"fitToPage": true}));
|
||||
assert_eq!(count(&on, "(1,234.00) Tj"), count(&off, "(1,234.00) Tj") + 1);
|
||||
assert_eq!(count(&on, "(5,678.00) Tj"), count(&off, "(5,678.00) Tj") + 1);
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "=1000+111"})).unwrap();
|
||||
let (updated, _) = pdf_of(&mut s, json!({"fitToPage": true}));
|
||||
assert!(!contains(&updated, "(1,234.00) Tj"));
|
||||
assert!(count(&updated, "(1,111.00) Tj") >= 2, "cell and chart data table both print the new value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_export_keeps_safe_links_on_their_printed_pages() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -540,6 +540,22 @@ impl Trendline {
|
||||
}
|
||||
}
|
||||
|
||||
/// A chart's data table (`c:dTable`), independent of the chart legend.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ChartDataTable {
|
||||
pub show_keys: bool,
|
||||
pub horizontal_border: bool,
|
||||
pub vertical_border: bool,
|
||||
pub outline: bool,
|
||||
}
|
||||
|
||||
impl Default for ChartDataTable {
|
||||
fn default() -> Self {
|
||||
Self { show_keys: true, horizontal_border: true, vertical_border: true, outline: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Chart {
|
||||
pub id: u32,
|
||||
@@ -549,6 +565,8 @@ pub struct Chart {
|
||||
pub series: Vec<Series>,
|
||||
pub legend: LegendPos,
|
||||
pub data_labels: bool,
|
||||
#[serde(default)]
|
||||
pub data_table: Option<ChartDataTable>,
|
||||
pub gridlines: bool,
|
||||
pub style: u32,
|
||||
pub x_title: Option<String>,
|
||||
@@ -562,6 +580,30 @@ pub struct Chart {
|
||||
pub pivot: Option<String>,
|
||||
}
|
||||
|
||||
impl Chart {
|
||||
/// Data tables belong to category-axis column, bar, line and area charts.
|
||||
pub fn supports_data_table(&self) -> bool {
|
||||
let eligible = |kind| {
|
||||
matches!(
|
||||
kind,
|
||||
ChartKind::ColumnClustered
|
||||
| ChartKind::ColumnStacked
|
||||
| ChartKind::ColumnStacked100
|
||||
| ChartKind::BarClustered
|
||||
| ChartKind::BarStacked
|
||||
| ChartKind::BarStacked100
|
||||
| ChartKind::Line
|
||||
| ChartKind::LineMarkers
|
||||
| ChartKind::LineStacked
|
||||
| ChartKind::Area
|
||||
| ChartKind::AreaStacked
|
||||
| ChartKind::AreaStacked100
|
||||
)
|
||||
};
|
||||
eligible(self.kind) || (self.kind == ChartKind::Combo && self.series.iter().all(|s| eligible(s.kind.unwrap_or(ChartKind::Line))))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Image {
|
||||
pub id: u32,
|
||||
|
||||
@@ -241,6 +241,28 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.collapsing("Data Table", |ui| {
|
||||
let supported = chart.supports_data_table();
|
||||
let mut on = chart.data_table.is_some();
|
||||
if ui.add_enabled(supported, egui::Checkbox::new(&mut on, "Show Data Table")).changed() {
|
||||
app.run_or_alert("chart.set", json!({"chart": id, "dataTable": on}));
|
||||
}
|
||||
if !supported {
|
||||
ui.label("Available for column, bar, line, area and compatible combo charts.");
|
||||
}
|
||||
if let Some(table) = &chart.data_table {
|
||||
for (label, key, mut value) in [
|
||||
("Show Legend Keys", "dataTableKeys", table.show_keys),
|
||||
("Horizontal Borders", "dataTableHorizontalBorder", table.horizontal_border),
|
||||
("Vertical Borders", "dataTableVerticalBorder", table.vertical_border),
|
||||
("Outline", "dataTableOutline", table.outline),
|
||||
] {
|
||||
if ui.checkbox(&mut value, label).changed() {
|
||||
app.run_or_alert("chart.set", json!({"chart": id, key: value}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.collapsing("Series", |ui| {
|
||||
let data = app.session.active().map(|d| gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &chart));
|
||||
for (i, s) in chart.series.iter().enumerate() {
|
||||
|
||||
@@ -126,6 +126,9 @@ pub(super) fn chart_design(app: &mut SheetApp, ui: &mut Ui) {
|
||||
("-", "", json!(null)),
|
||||
("Data Labels: Show", "chart.set", json!({"chart": id, "dataLabels": true})),
|
||||
("Data Labels: None", "chart.set", json!({"chart": id, "dataLabels": false})),
|
||||
("Data Table: With Legend Keys", "chart.addElement", json!({"chart": id, "element": "dataTable", "dataTableKeys": true})),
|
||||
("Data Table: No Legend Keys", "chart.addElement", json!({"chart": id, "element": "dataTable", "dataTableKeys": false})),
|
||||
("Data Table: None", "chart.addElement", json!({"chart": id, "element": "dataTable", "on": false})),
|
||||
("Gridlines: Show", "chart.set", json!({"chart": id, "gridlines": true})),
|
||||
("Gridlines: None", "chart.set", json!({"chart": id, "gridlines": false})),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn format_chart_pane_controls_the_engine_data_table() {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", 10, 30], ["Q2", 30, 10]]})).unwrap();
|
||||
let id = session.execute("insert.chart", json!({"range": "A1:C3", "type": "column", "at": "E3"})).unwrap()["chart"].as_u64().unwrap() as u32;
|
||||
let mut h = crate::support::harness(session, 4);
|
||||
h.state_mut().selected_chart = Some(id);
|
||||
h.state_mut().grid.pane = Some("formatChart".into());
|
||||
h.run_steps(2);
|
||||
h.get_by_role_and_label(Role::DisclosureTriangle, "Data Table").click();
|
||||
h.run_steps(2);
|
||||
let table = |h: &crate::common::Harness| h.state().session.active().unwrap().wb.active().unwrap().charts[0].data_table.clone();
|
||||
assert_eq!(table(&h), None);
|
||||
h.get_by_label("Show Data Table").click();
|
||||
h.run_steps(2);
|
||||
assert!(table(&h).unwrap().show_keys);
|
||||
h.get_by_label("Show Legend Keys").click();
|
||||
h.run_steps(2);
|
||||
assert!(!table(&h).unwrap().show_keys);
|
||||
h.get_by_label("Horizontal Borders").click();
|
||||
h.run_steps(2);
|
||||
assert!(!table(&h).unwrap().horizontal_border);
|
||||
h.get_by_label("Show Data Table").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(table(&h), None);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ mod borders;
|
||||
mod cell_editor_caret;
|
||||
mod cell_pictures;
|
||||
mod cf_rule_pick;
|
||||
mod chart_data_table;
|
||||
mod chart_resize;
|
||||
mod chart_source_layering;
|
||||
mod clipboard;
|
||||
|
||||
@@ -268,6 +268,12 @@ pub fn read_chart(root: &El) -> Option<Chart> {
|
||||
series,
|
||||
legend,
|
||||
data_labels,
|
||||
data_table: plot.child("dTable").map(|t| gridcraft_model::ChartDataTable {
|
||||
show_keys: bool_val(t, "showKeys"),
|
||||
horizontal_border: bool_val(t, "showHorzBorder"),
|
||||
vertical_border: bool_val(t, "showVertBorder"),
|
||||
outline: bool_val(t, "showOutline"),
|
||||
}),
|
||||
gridlines: val_axes.iter().any(|a| a.child("majorGridlines").is_some()),
|
||||
style: root.child_val("style").and_then(|v| v.parse().ok()).unwrap_or(2),
|
||||
x_title: x_ax.and_then(|a| a.child("title")).and_then(rich_title),
|
||||
@@ -866,6 +872,16 @@ pub fn write_chart(wb: &Workbook, si: usize, ch: &Chart, book_name: &str) -> Str
|
||||
s.push_str(&axis_xml(&Ax { cat: true, id: 3, cross: 4, pos: "b", delete: true, grid: false, title: None, crosses_max: false }));
|
||||
s.push_str(&axis_xml(&Ax { cat: false, id: 4, cross: 3, pos: "r", delete: false, grid: false, title: None, crosses_max: true }));
|
||||
}
|
||||
if let Some(t) = &ch.data_table {
|
||||
let _ = write!(
|
||||
s,
|
||||
"<c:dTable><c:showHorzBorder val=\"{}\"/><c:showVertBorder val=\"{}\"/><c:showOutline val=\"{}\"/><c:showKeys val=\"{}\"/></c:dTable>",
|
||||
u8::from(t.horizontal_border),
|
||||
u8::from(t.vertical_border),
|
||||
u8::from(t.outline),
|
||||
u8::from(t.show_keys)
|
||||
);
|
||||
}
|
||||
s.push_str("</c:plotArea>");
|
||||
let pos = match ch.legend {
|
||||
LegendPos::None => None,
|
||||
|
||||
@@ -130,6 +130,7 @@ pub fn read_chartex(root: &El) -> Option<Chart> {
|
||||
series,
|
||||
legend,
|
||||
data_labels,
|
||||
data_table: None,
|
||||
gridlines: plot.kids("axis").any(|a| a.child("majorGridlines").is_some()),
|
||||
style: 2,
|
||||
x_title: axis_title("0"),
|
||||
|
||||
@@ -5,6 +5,7 @@ mod cellmetadata;
|
||||
mod chart_book_names;
|
||||
mod chart_bool_default;
|
||||
mod chart_combo_kinds;
|
||||
mod chart_data_table;
|
||||
mod chart_ref_offsets;
|
||||
mod csv_tests;
|
||||
mod encrypted_tests;
|
||||
|
||||
@@ -75,6 +75,7 @@ fn chart_names_are_written_qualified() {
|
||||
],
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 2,
|
||||
x_title: None,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Synthetic DrawingML only: table flags, default-valued booleans and full XLSX round trips.
|
||||
|
||||
use crate::{chart::read_chart, read_xlsx, write_xlsx, xml::parse};
|
||||
use gridcraft_model::ChartDataTable;
|
||||
|
||||
fn chart(table: &str) -> gridcraft_model::Chart {
|
||||
let xml = format!(
|
||||
"<c:chartSpace xmlns:c=\"http://schemas.openxmlformats.org/drawingml/2006/chart\"><c:chart><c:plotArea>\
|
||||
<c:barChart><c:barDir val=\"col\"/><c:grouping val=\"clustered\"/><c:ser><c:idx val=\"0\"/><c:order val=\"0\"/>\
|
||||
<c:val><c:numLit><c:formatCode>General</c:formatCode><c:ptCount val=\"1\"/><c:pt idx=\"0\"><c:v>12</c:v></c:pt></c:numLit></c:val>\
|
||||
</c:ser><c:axId val=\"1\"/><c:axId val=\"2\"/></c:barChart><c:catAx/><c:valAx/>{table}</c:plotArea></c:chart></c:chartSpace>"
|
||||
);
|
||||
read_chart(&parse(xml.as_bytes()).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_table_boolean_defaults_and_absence() {
|
||||
assert_eq!(chart("").data_table, None);
|
||||
assert_eq!(
|
||||
chart("<c:dTable/>").data_table,
|
||||
Some(ChartDataTable { show_keys: false, horizontal_border: false, vertical_border: false, outline: false })
|
||||
);
|
||||
assert_eq!(
|
||||
chart("<c:dTable><c:showKeys/><c:showHorzBorder/><c:showVertBorder/><c:showOutline/></c:dTable>").data_table,
|
||||
Some(ChartDataTable::default())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_data_table_flag_combination_survives_xlsx_save_reopen() {
|
||||
for flags in 0..16u8 {
|
||||
let settings =
|
||||
ChartDataTable { show_keys: flags & 1 != 0, horizontal_border: flags & 2 != 0, vertical_border: flags & 4 != 0, outline: flags & 8 != 0 };
|
||||
let mut c = chart("");
|
||||
c.id = 1;
|
||||
c.data_table = Some(settings.clone());
|
||||
let mut wb = gridcraft_model::Workbook::new();
|
||||
wb.sheet_mut(0).unwrap().charts.push(c);
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
let (back, _) = read_xlsx(&bytes).unwrap();
|
||||
assert_eq!(back.sheets[0].charts[0].data_table.as_ref(), Some(&settings), "flags {flags}");
|
||||
let bytes = write_xlsx(&back).unwrap();
|
||||
let (again, _) = read_xlsx(&bytes).unwrap();
|
||||
assert_eq!(again.sheets[0].charts[0].data_table.as_ref(), Some(&settings));
|
||||
}
|
||||
}
|
||||
@@ -548,6 +548,7 @@ fn chartex_parts_as_excel_expects_them() {
|
||||
}],
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 2,
|
||||
x_title: None,
|
||||
|
||||
@@ -41,6 +41,7 @@ fn literal_category_with_separators_survives_save() {
|
||||
series,
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 2,
|
||||
x_title: None,
|
||||
|
||||
@@ -41,6 +41,7 @@ fn workbook(categories: &str, values: &str) -> Workbook {
|
||||
}],
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 2,
|
||||
x_title: None,
|
||||
|
||||
@@ -407,6 +407,7 @@ fn sample() -> Workbook {
|
||||
}],
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: true,
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 2,
|
||||
x_title: Some("Region".into()),
|
||||
|
||||
@@ -29,6 +29,7 @@ fn smooth_line_setting_round_trips() {
|
||||
series: vec![base_series.clone()],
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 2,
|
||||
x_title: None,
|
||||
@@ -47,6 +48,7 @@ fn smooth_line_setting_round_trips() {
|
||||
series: vec![smooth_series],
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 2,
|
||||
x_title: None,
|
||||
|
||||
@@ -53,6 +53,7 @@ fn chart(id: u32, kind: ChartKind, trendlines: Vec<Trendline>) -> Chart {
|
||||
}],
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 2,
|
||||
x_title: None,
|
||||
|
||||
+17
-2
@@ -1,6 +1,6 @@
|
||||
# Chart parity
|
||||
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-29 · **Change:** minor (selected chart source outlines stay beneath floating objects) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-11-01 · **Change:** minor (chart data tables, legend keys and borders, native XLSX round-trip and print/PDF; earlier: selected chart source outlines stay beneath floating objects) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Excel's chart types and chart elements against GridCraft. Part of
|
||||
[target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated).
|
||||
@@ -49,7 +49,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate
|
||||
| Trendlines | linear, exponential, logarithmic, polynomial, power, moving average, equation and R² | all six types, several per series, name, forward/backward forecast, set intercept, equation and R² label, line colour/width/dash; read, written and drawn on 2-D column, bar, line, area, scatter and bubble series; `chart.addElement` `trendline` adds/removes them. No Format Trendline pane, label position/number format not kept |
|
||||
| Error bars | fixed, %, standard deviation/error, custom | **missing** |
|
||||
| Up/down bars, high-low lines, drop lines, series lines | yes | **missing** |
|
||||
| Data table under the chart | yes | **missing** |
|
||||
| Data table under the chart | yes | yes for column, bar, line, area and compatible combo charts: live formatted values, blank cells, grouped category headers, optional legend keys, horizontal/vertical borders and outline; native `c:dTable` XLSX round-trip; shared UI/print/PDF rendering |
|
||||
| Per-series / per-point fill, line, marker, effects | Format pane for every element | series colour only; Format Chart pane covers chart-level options |
|
||||
| Chart styles and colour palettes | ~16 styles × colour sets | style number stored, own palettes |
|
||||
| Chart sheets | yes | **kept, not editable**: not shown, and written back unchanged in their place on save (#727); left out when a sheet they plot is deleted or renamed. No F11 / Move Chart > New sheet |
|
||||
@@ -62,6 +62,20 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate
|
||||
| PivotCharts | yes | Insert ▸ PivotChart (`insert.pivotChart`): bound to a PivotTable's report, rebuilt on refresh and layout changes, `c:pivotSource` round trip; no field buttons, innermost category level only |
|
||||
| Animation on data change | yes (Windows) | no |
|
||||
|
||||
Data tables are controlled by `chart.addElement {element: "dataTable", on: true,
|
||||
dataTableKeys: true}` or `chart.set {dataTable: true}`. `chart.set` also accepts
|
||||
`dataTableKeys`, `dataTableHorizontalBorder`, `dataTableVerticalBorder` and `dataTableOutline`.
|
||||
Changing to an unsupported chart kind removes the table (Undo restores it). The chart legend
|
||||
is independent. Bar charts keep their vertical category axis; other supported charts use the
|
||||
table headers instead of repeating category-axis labels. Individual labels and values that
|
||||
exceed a cell's width are shortened with an ellipsis. Tables are bounded to 128 categories,
|
||||
32 series and half the available chart height; if they cannot fit, a “Data table needs more
|
||||
space” notice replaces the table. Enlarge the chart or reduce its source range. Table-specific
|
||||
font/fill styling and exact Excel layout fidelity are not implemented; Excel-side verification
|
||||
has not been performed.
|
||||
|
||||
Format reference: [Microsoft's DrawingML data table documentation](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.drawing.charts.datatable?view=openxml-3.0.1).
|
||||
|
||||
## XLSX round-trip
|
||||
|
||||
Charts are read from and written to DrawingML chart parts, but only the fields the model holds
|
||||
@@ -91,6 +105,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | minor | Chart data tables for column, bar, line, area and compatible combo charts: command/UI controls, formatted source values, blanks, grouped category headers, legend keys, independent borders, shared print/PDF rendering and native c:dTable import/export. Dense tables show a space notice; no readiness score change. |
|
||||
| 2026-10-29 | minor | Selected charts highlight their source cells beneath floating objects, so the blue outline no longer crosses the chart; uncovered source cells remain highlighted (#2861) |
|
||||
| 2026-10-24 | minor | 100% Stacked Area: inserted from the ribbon and All Charts, drawn as shares of each category's total, read and written as `percentStacked` |
|
||||
| 2026-10-18 | trivial | Stale notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip (PRs #158, #161) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** trivial (XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
|
||||
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
|
||||
@@ -145,6 +145,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | minor | Chart data tables preserve showKeys, showHorzBorder, showVertBorder and showOutline through native c:dTable XLSX import/export; synthetic tests cover all 16 flag combinations and boolean defaults. Excel-side verification remains outstanding. |
|
||||
| 2026-11-01 | trivial | XLSX selects the first visible workbook view as one unit and bounds imported/exported tab ratios; isolated attributes and sheet-edit round trips covered, with firstSheet left to #2264 (#3057) |
|
||||
| 2026-10-31 | minor | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
|
||||
| 2026-10-31 | minor | XLSX open reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)` export wrapper as the formula inside when GridCraft calculates every function in it (plain, shared and array formulas, whole or nested; Google-only functions and unparseable, over-long or over-deep inner text keep the wrapper and the cached value) and keeps the cached value; saving writes the real formula with Excel's `_xlfn.` prefixes (#2871) |
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (§13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (§4: chart data tables implemented with XLSX round-trip and print/PDF; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
|
||||
using GridCraft for real work first). This is the work list: pick from the top, check the open
|
||||
@@ -44,6 +44,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
|
||||
### 4. Charts lose options on round trip; shallow chart model (F, FF)
|
||||
- **Missing:** axis scaling, label options, error bars, per-point formatting.
|
||||
- **Added:** data tables beneath column, bar, line, area and compatible combo charts, with legend keys, border controls, live source values, native XLSX round-trip and print/PDF.
|
||||
- **Evidence:** `Chart` holds only title, legend position, two flags and axis titles
|
||||
(`crates/model/src/features.rs`); PRs #158, #161 open; charts can't be resized (#190). Smooth lines (#62), Switch Row/Column (#116) and trendlines (read, written, drawn) landed. Multi-level category axes (year beside month) are read, drawn and written back.
|
||||
(`crates/model/src/features.rs`); all 2-D kinds survive XLSX round trip (#158, #161); charts
|
||||
@@ -185,6 +186,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | minor | §4: chart data tables implemented for column, bar, line, area and compatible combo charts, with legend keys and border settings, live source values, XLSX round-trip and print/PDF; other chart gaps remain. |
|
||||
| 2026-10-31 | minor | §13: Solver Options (Max Evaluations, Integer Optimality and the Evolutionary options), saved with the model |
|
||||
| 2026-10-31 | minor | §13: Solver Load/Save Model (`data.solverSaveModel`, `data.solverLoadModel`) and Reset All (`data.solverReset`), with buttons in the Solver Parameters dialog |
|
||||
| 2026-10-24 | minor | §8: PivotTable Enable show details: `pivot.options` `enableShowDetails` turns Show Details off for a PivotTable, read and written in XLSX as `enableDrill` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Target-app parity: GridCraft vs Microsoft Excel
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (charts: data tables with XLSX round-trip and print/PDF; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
|
||||
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
|
||||
@@ -85,9 +85,9 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
|
||||
| Cell formatting, styles, conditional formatting | 10 | ~95% | ~80% | 8–14 | Full number-format language, borders, 47 cell styles, 60 table styles, all CF rule kinds including data bars, colour scales and icon sets. Gaps: font weights beyond regular/bold (#168), system fonts list (#4), themes |
|
||||
| Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Advanced Filter copy-to preserves cells outside its result and rejects worksheet overflow (#1031). Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables |
|
||||
| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, values on rows or columns, date grouping, show-values-as, refresh, calculated fields, XLSX round-trip. Missing: slicers, timelines, calculated items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel |
|
||||
| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) |
|
||||
| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag, axis titles and chart data tables (legend keys, borders, XLSX round-trip and print/PDF); no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) |
|
||||
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. Paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, ISO B4/B5/B6 stay reachable by name, and Japanese Postcard (はがき), Japanese Double Postcard (往復はがき), the chou/kaku envelopes and A6 are available. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
|
||||
| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Kinds survive XLSX round trip (#158, #161). [chart-parity.md](chart-parity.md) |
|
||||
| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag, axis titles and chart data tables (legend keys, borders, XLSX round-trip and print/PDF); no axis scaling, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Kinds survive XLSX round trip (#158, #161). [chart-parity.md](chart-parity.md) |
|
||||
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
|
||||
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (13 of 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer and Limits reports; Load/Save Model, Reset All and Solver Options; no Sensitivity report), Forecast Sheet. Missing: Analyze Data |
|
||||
| Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) |
|
||||
@@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | minor | Charts: data tables for column, bar, line, area and compatible combo charts, with legend keys and borders; live source values, native XLSX round-trip and shared print/PDF rendering. Readiness estimates unchanged. |
|
||||
| 2026-10-31 | minor | The Insert Function dialog is named as in Excel (it was called Formula Builder); no score change |
|
||||
| 2026-10-31 | minor | What-if and analysis: Solver Options (no score change) |
|
||||
| 2026-10-31 | minor | What-if and analysis: Solver Load/Save Model and Reset All; Forecast Sheet moved from Missing to the list of tools, as gaps.md §13 records (no score change) |
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Chart Design and Format Chart Area: data tables, legend keys and borders; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
|
||||
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
|
||||
@@ -196,6 +196,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | minor | Chart Design › Add Chart Element offers Data Table with/no legend keys and None; Format Chart Area has a Data Table section with visibility, legend keys, horizontal/vertical borders and outline. Both use engine commands. |
|
||||
| 2026-10-31 | minor | Esc in a File-page text field leaves the field and cancels editing; the next Esc returns to the sheet (#3035) |
|
||||
| 2026-10-31 | minor | Recently Used lists the functions most recently entered in a formula from any source, kept with the preferences; the Insert Function dialog is titled as in Excel, its category list (Most Recently Used, All, each category) works, and its category column follows the widest name shown |
|
||||
| 2026-10-31 | minor | Formulas › Function Library: Insert Function, the AutoSum split button and three columns of labelled small buttons with category colours; category menus end with Insert Function…; More Functions cascades; Home › Editing shares the AutoSum menu; MR key tip for Recently Used |
|
||||
|
||||
Reference in New Issue
Block a user