From 1fb191571ba6c487246fa94ee4ce5a37abd22fcc Mon Sep 17 00:00:00 2001 From: Francisco Herrera Date: Wed, 4 Nov 2026 23:13:35 -0500 Subject: [PATCH] Charts: draw every doughnut series as a ring (#1827) * Charts: draw every doughnut series as a ring (#1827) * Charts: submit doughnut hole size and first slice angle when the drag ends (#1827) --------- Co-authored-by: alosslessdev --- crates/chart/src/render.rs | 9 +- crates/chart/src/render/kinds.rs | 145 +++++++- crates/chart/src/render/legend.rs | 2 + crates/chart/src/tests.rs | 319 +++++++++++++++++- crates/engine/src/cmd/forecast.rs | 2 + crates/engine/src/cmd/insert.rs | 10 +- crates/engine/src/cmd/pivot/charts.rs | 2 + crates/engine/src/tests.rs | 15 + crates/model/src/features.rs | 46 +++ crates/model/src/sheet.rs | 2 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/de.rs | 2 + crates/ui-egui/src/i18n/el.rs | 2 + crates/ui-egui/src/i18n/es.rs | 2 + crates/ui-egui/src/i18n/fr.rs | 2 + crates/ui-egui/src/i18n/it.rs | 2 + crates/ui-egui/src/i18n/ja.rs | 2 + crates/ui-egui/src/i18n/ko.rs | 2 + crates/ui-egui/src/i18n/nl.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt.rs | 2 + crates/ui-egui/src/i18n/ro.rs | 2 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/sr.rs | 2 + crates/ui-egui/src/i18n/tr.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 2 + crates/ui-egui/src/i18n/vi.rs | 2 + crates/ui-egui/src/i18n/zh.rs | 2 + crates/ui-egui/src/panes.rs | 33 ++ crates/xlsx/src/chart.rs | 16 +- crates/xlsx/src/chartex.rs | 2 + crates/xlsx/src/tests/chart_book_names.rs | 4 + crates/xlsx/src/tests/fixtures.rs | 46 +++ .../src/tests/literal_category_roundtrip.rs | 2 + .../tests/multilevel_category_roundtrip.rs | 2 + crates/xlsx/src/tests/roundtrip.rs | 6 + crates/xlsx/src/tests/smooth_roundtrip.rs | 4 + crates/xlsx/src/tests/trendline_roundtrip.rs | 2 + docs/chart-parity.md | 10 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 9 +- 41 files changed, 705 insertions(+), 21 deletions(-) diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index 7e50e78f6..2b46c5fbd 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -19,7 +19,7 @@ mod trend; use cartesian::{cartesian, scatter}; use frame::{Bx, axis_titles, chart_title}; -use kinds::{box_whisker, funnel, histogram, pie, radar, stock, treemap, waterfall}; +use kinds::{box_whisker, doughnut, funnel, histogram, pie, radar, stock, treemap, waterfall}; use legend::draw_legend; pub use legend::{Swatch, legend_entries}; pub use trend::supports_trendline; @@ -44,6 +44,11 @@ const MAX_LINE_PTS: usize = 2000; const MAX_MARKERS: usize = 5000; /// Data labels are skipped when a series has more points than this. const MAX_LABELS: usize = 200; +/// Most wedges a doughnut draws for the whole chart. The per-series and per-category limits do not +/// bound their product: 255 series of 20,000 categories would be millions of wedges. Every drawable +/// ring shares this budget, so the innermost ring keeps its largest slices and the outer rings are +/// capped instead of the chart dropping rings wholesale. +pub(crate) const MAX_WEDGES: usize = 2_048; struct Ctx<'a> { out: Vec, @@ -153,7 +158,7 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { use ChartKind::*; match chart.kind { Pie => pie(ctx, chart, data, area, 0.0, false), - Doughnut => pie(ctx, chart, data, area, 0.5, false), + Doughnut => doughnut(ctx, chart, data, area), Sunburst => pie(ctx, chart, data, area, 0.2, true), Treemap => treemap(ctx, chart, data, area), Funnel => funnel(ctx, chart, data, area), diff --git a/crates/chart/src/render/kinds.rs b/crates/chart/src/render/kinds.rs index 10286107b..77a58d0fe 100644 --- a/crates/chart/src/render/kinds.rs +++ b/crates/chart/src/render/kinds.rs @@ -1,8 +1,8 @@ use super::frame::{Bx, CatAxis, ValAxis, frame, on, value_range}; use super::legend::{cat_label, point_color}; use super::{ - AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, Prim, QuartileMethod, STOCK, SeriesData, - TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale, + AXIS, Chart, ChartData, Ctx, DOWN, Fmt, GRID, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_MARKERS, MAX_SERIES, MAX_WEDGES, Prim, + QuartileMethod, Rgba, STOCK, SeriesData, TAU, TEXT, UP, VAlign, WHITE, clean, nice_scale, }; // ---------------------------------------------------------------- histogram @@ -163,6 +163,66 @@ pub(super) fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { // ---------------------------------------------------------------- pie, doughnut, sunburst +/// At most `cap` of the largest values, in category order. When a ring has more categories than +/// its share of the wedge budget allows, the big slices survive rather than the first ones. +fn largest_slices(vals: &[f64], cap: usize) -> Vec<(usize, f64)> { + let mut idx: Vec = (0..vals.len()).collect(); + if idx.len() > cap { + // Partition descends by value, so the first `cap` entries are the largest ones. + idx.select_nth_unstable_by(cap - 1, |a, b| vals[*b].total_cmp(&vals[*a])); + idx.truncate(cap); + idx.sort_unstable(); + } + idx.into_iter().map(|j| (j, vals[j])).collect() +} + +/// One ring's geometry and separator stroke: the slices of a ring differ only in angle and colour. +struct Ring { + cx: f32, + cy: f32, + outer: f32, + inner: f32, + stroke: Option<(Rgba, f32)>, +} + +/// A wedge of `ring`, split at 12 o'clock when it crosses the end of the circle. +fn wedge(ctx: &mut Ctx, ring: &Ring, a0: f32, sweep: f32, fill: Rgba) { + let Ring { cx, cy, outer, inner, stroke } = *ring; + if sweep <= 1e-4 { + return; + } + // A slice that starts past 12 o'clock still has to be split against 12 o'clock, not against + // its unwrapped start: otherwise the half after TAU is drawn from 0 over its neighbours. + let a0 = a0.rem_euclid(TAU); + let a1 = a0 + sweep; + if a1 <= TAU { + ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0, a1, fill, stroke }); + return; + } + // The halves are filled without a stroke: each would outline its own radial edge at 12 + // o'clock, painting the white separator straight through the slice. + ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0, a1: TAU, fill, stroke: None }); + ctx.out.push(Prim::Wedge { cx, cy, r_outer: outer, r_inner: inner, a0: 0.0, a1: a1 - TAU, fill, stroke: None }); + if let Some((color, width)) = stroke { + // One outline for the whole slice: the outer and inner arcs run through 12 o'clock, and + // the two radial edges at the slice's own start and end close the path. + let arc = |r: f32, from: f32, to: f32| -> Vec<[f32; 2]> { + let steps = ((r.abs() * (to - from).abs() / 2.0).ceil() as usize).clamp(4, 512); + let at = |a: f32| [cx + r * a.sin(), cy - r * a.cos()]; + (0..=steps).map(|k| at(from + (to - from) * k as f32 / steps as f32)).collect() + }; + let mut pts = arc(outer, a0, a1); + if inner > 0.0 { + pts.extend(arc(inner, a1, a0)); + } else { + pts.push([cx, cy]); + } + let start = pts[0]; + pts.push(start); + ctx.out.push(Prim::Line { pts, color, width, dash: false }); + } +} + pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inner: f32, names: bool) { let Some(s) = data.series.first() else { return }; let vals: Vec = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect(); @@ -176,14 +236,15 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne return; } let ri = r * inner; + let ring = Ring { cx, cy, outer: r, inner: ri, stroke: Some((WHITE, 1.5)) }; let f = Fmt::new(&s.number_format); - let mut a = 0.0f32; + let mut a = (chart.first_slice_angle % 360) as f32 * TAU / 360.0; let mut labels = Vec::new(); for (j, v) in vals.iter().enumerate() { let sweep = (*v / total) as f32 * TAU; if sweep > 1e-4 { let fill = point_color(data, j); - ctx.out.push(Prim::Wedge { cx, cy, r_outer: r, r_inner: ri, a0: a, a1: (a + sweep).min(TAU), fill, stroke: Some((WHITE, 1.5)) }); + wedge(ctx, &ring, a, sweep, fill); if (chart.data_labels || names) && vals.len() <= MAX_LABELS { let t = if names { cat_label(data, j) } else { f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None) }; let mid = a + sweep / 2.0; @@ -201,6 +262,82 @@ pub(super) fn pie(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, inne } } +/// Excel's doughnut: every series is one concentric ring, the first series innermost (measured in +/// Excel: ring outer radii ≈0.83, 0.92 and 1.0 of the plot radius for series 1, 2 and 3). The hole +/// size and first slice angle come from the chart; each ring is scaled to its own total. +pub(super) fn doughnut(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) { + let nser = data.series.len().min(MAX_SERIES); + if nser == 0 { + return; + } + let (cx, cy) = (area.cx(), area.cy()); + let r = area.w.min(area.h) / 2.0 * 0.92; + if r < 2.0 { + return; + } + let hole = f32::from(chart.hole_size.clamp(10, 90)) / 100.0; + let ri = r * hole; + let thick = (r - ri) / nser as f32; + // A ring thinner than a pixel would vanish. Rather than suppressing the whole chart when there + // are more series than the annulus has pixels, keep the outer rings at one pixel each and drop + // the innermost series that no longer fit: Excel's order makes the first series the innermost, + // so the outermost rings are the ones worth keeping. + let band = thick.max(1.0); + let start = (chart.first_slice_angle % 360) as f32 * TAU / 360.0; + // Only rings with a pixel of band are drawn, and the wedge budget is shared across them in + // drawing order: the innermost ring (the first series) gets its slices first, and the outer + // rings are capped instead of the series × category product growing without bound. + let rings: Vec<(&SeriesData, f32, f32)> = data + .series + .iter() + .take(nser) + .enumerate() + .filter_map(|(si, s)| { + let outer = (r - (nser - 1 - si) as f32 * band).min(r); + let inner = (r - (nser - si) as f32 * band).max(ri); + (outer - inner >= 1.0).then_some((s, outer, inner)) + }) + .collect(); + let mut budget = MAX_WEDGES; + let mut labels = Vec::new(); + for (k, (s, outer, inner)) in rings.iter().enumerate() { + // The white separator is 1.5 units wide, wider than a one-pixel band: on a thin ring it + // would paint over the fill of this ring and its neighbour, so such rings keep only fill. + let stroke = if outer - inner > 1.0 { Some((WHITE, 1.5)) } else { None }; + let ring = Ring { cx, cy, outer: *outer, inner: *inner, stroke }; + let vals: Vec = s.values.iter().take(MAX_CATS).map(|v| v.map(f64::abs).unwrap_or(0.0)).collect(); + let cap = (budget / (rings.len() - k)).max(1); + let slices = largest_slices(&vals, cap); + let total: f64 = slices.iter().map(|(_, v)| *v).sum(); + if !(total.is_finite() && total > 0.0) { + continue; + } + let n = slices.len(); + budget = budget.saturating_sub(n); + let f = Fmt::new(&s.number_format); + let mut a = start; + for (j, v) in slices { + let sweep = (v / total) as f32 * TAU; + if sweep > 1e-4 { + let fill = point_color(data, j); + wedge(ctx, &ring, a, sweep, fill); + if chart.data_labels && n <= MAX_LABELS { + let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None); + let mid = a + sweep / 2.0; + let rr = (outer + inner) / 2.0; + if ctx.tw(&t, LABEL_SIZE) <= (sweep * rr).max(outer - inner) && sweep * rr >= LABEL_SIZE { + labels.push((cx + rr * mid.sin(), cy - rr * mid.cos(), t, on(fill))); + } + } + } + a += sweep; + } + } + for (x, y, t, c) in labels { + ctx.text(x, y, &t, LABEL_SIZE, c, (HAlign::Center, VAlign::Middle)); + } +} + // ---------------------------------------------------------------- treemap /// Worst aspect ratio of a row with the given sum, smallest and largest area. diff --git a/crates/chart/src/render/legend.rs b/crates/chart/src/render/legend.rs index 5ddb5a3cd..5b7f5d4d9 100644 --- a/crates/chart/src/render/legend.rs +++ b/crates/chart/src/render/legend.rs @@ -42,6 +42,8 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa return vec![]; } match chart.kind { + // Pie, doughnut (Excel's default `varyColors=1`, so every ring repeats the category colours + // and the legend names categories rather than series), treemap and sunburst. ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst => { let n = first_len(data).min(MAX_CATS); (0..n).map(|j| (cat_label(data, j), point_color(data, j), Swatch::Box)).collect() diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 6e423802c..11e2756ca 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -48,6 +48,8 @@ fn chart(kind: ChartKind) -> Chart { data_table: None, gridlines: true, style: 0, + hole_size: 75, + first_slice_angle: 0, x_title: Some("Quarter".into()), y_title: Some("Revenue".into()), source: None, @@ -149,6 +151,317 @@ fn every_kind_renders_sanely() { } } +#[test] +fn doughnut_draws_each_series_as_a_ring() { + let mut c = chart(ChartKind::Doughnut); + c.title = None; + c.legend = LegendPos::None; + c.data_labels = false; + c.hole_size = 25; + c.first_slice_angle = 0; + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: ["A", "B", "C"].iter().map(|s| s.to_string()).collect(), + series: vec![ + series("Inner", &[1.0, 2.0, 3.0], [20, 96, 130, 255], ChartKind::Doughnut), + series("Middle", &[3.0, 2.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut), + series("Outer", &[1.0, 1.0, 1.0], [89, 161, 79, 255], ChartKind::Doughnut), + ], + }; + let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure); + let wedges: Vec<(f32, f32, f32)> = prims + .iter() + .filter_map(|p| match p { + Prim::Wedge { r_outer, r_inner, a0, .. } => Some((*r_outer, *r_inner, *a0)), + _ => None, + }) + .collect(); + assert_eq!(wedges.len(), 9, "three rings of three slices"); + // Excel draws the first series as the innermost ring; the hole is 25% of the radius. + assert!(wedges[0].0 < wedges[3].0 && wedges[3].0 < wedges[6].0, "{wedges:?}"); + assert!(wedges[0].1 > 0.0, "hole: {wedges:?}"); +} + +#[test] +fn doughnut_with_many_series_still_draws_the_outer_rings() { + let mut c = chart(ChartKind::Doughnut); + c.title = None; + c.legend = LegendPos::None; + c.data_labels = false; + c.hole_size = 75; + // The annulus is a few dozen pixels tall and there are far more series than pixels, so every + // ring is thinner than 1 px. The chart used to draw nothing at all. + let many = ChartData { + category_levels: vec![], + palette: vec![], + categories: ["A", "B"].iter().map(|s| s.to_string()).collect(), + series: (0..255).map(|i| series(&format!("S{i}"), &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)).collect(), + }; + let single = ChartData { + category_levels: vec![], + palette: vec![], + categories: ["A", "B"].iter().map(|s| s.to_string()).collect(), + // Two series, so the title fallback (a lone series names the chart) does not shrink the plot. + series: vec![ + series("Only", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut), + series("Other", &[1.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut), + ], + }; + let (w, h) = (640.0, 400.0); + let radii = |d: &ChartData| -> Vec { + render(&c, d, w, h, &ApproxMeasure) + .iter() + .filter_map(|p| match p { + Prim::Wedge { r_outer, .. } => Some(*r_outer), + _ => None, + }) + .collect() + }; + let outer = radii(&many); + assert!(!outer.is_empty(), "a sub-pixel ring must not suppress the whole chart"); + let mut distinct = outer.clone(); + distinct.sort_by(f32::total_cmp); + distinct.dedup(); + assert!(distinct.len() > 8, "only {} rings drawn", distinct.len()); + assert!(distinct.len() < 255, "too many rings for the pixels available: {}", distinct.len()); + // The outermost ring still reaches the plot radius the single-ring chart uses. + let r = radii(&single).into_iter().fold(0.0f32, f32::max); + let drawn = distinct.last().copied().unwrap_or(0.0); + assert!((drawn - r).abs() < 0.01, "outermost ring {drawn} of {r}"); +} + +#[test] +fn doughnut_one_pixel_rings_skip_the_separator_stroke() { + let mut c = chart(ChartKind::Doughnut); + c.title = None; + c.legend = LegendPos::None; + c.data_labels = false; + c.hole_size = 75; + // 255 series over a 46-pixel annulus: every drawn ring is one pixel tall, narrower than the + // 1.5-pixel white separator, which would otherwise paint over both neighbouring rings. + let thin = ChartData { + category_levels: vec![], + palette: vec![], + categories: ["A", "B"].iter().map(|s| s.to_string()).collect(), + series: (0..255).map(|i| series(&format!("S{i}"), &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)).collect(), + }; + let strokes: Vec> = render(&c, &thin, 640.0, 400.0, &ApproxMeasure) + .iter() + .filter_map(|p| match p { + Prim::Wedge { stroke, .. } => Some(*stroke), + _ => None, + }) + .collect(); + assert!(!strokes.is_empty(), "the chart must still draw its rings"); + assert!(strokes.iter().all(Option::is_none), "a 1.5px stroke covers a 1px band: {strokes:?}"); + + // A ring with room for the separator keeps it. + let thick = ChartData { + category_levels: vec![], + palette: vec![], + categories: ["A", "B"].iter().map(|s| s.to_string()).collect(), + series: vec![ + series("Inner", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut), + series("Outer", &[1.0, 1.0], [221, 132, 82, 255], ChartKind::Doughnut), + ], + }; + let strokes: Vec> = render(&c, &thick, 640.0, 400.0, &ApproxMeasure) + .iter() + .filter_map(|p| match p { + Prim::Wedge { stroke, .. } => Some(*stroke), + _ => None, + }) + .collect(); + assert!(!strokes.is_empty()); + assert!(strokes.iter().all(Option::is_some), "wide rings keep their separators: {strokes:?}"); +} + +#[test] +fn doughnut_wedge_budget_bounds_many_series_and_categories() { + let mut c = chart(ChartKind::Doughnut); + c.title = None; + c.legend = LegendPos::None; + c.data_labels = false; + c.hole_size = 75; + c.first_slice_angle = 0; + // 20,000 categories across 80 series would be hundreds of thousands of wedges: the per-series + // limit does not bound the product. The first ten categories of every ring are the largest. + let cats = 20_000usize; + let values: Vec = (0..cats).map(|j| if j < 10 { 1000.0 } else { 1.0 }).collect(); + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: (0..cats).map(|j| format!("C{j}")).collect(), + series: (0..80) + .map(|i| SeriesData { name: format!("S{i}"), values: values.iter().map(|v| Some(*v)).collect(), ..SeriesData::default() }) + .collect(), + }; + let wedges: Vec<(f32, f32)> = render(&c, &d, 640.0, 400.0, &ApproxMeasure) + .iter() + .filter_map(|p| match p { + Prim::Wedge { r_outer, a0, a1, .. } => Some((*r_outer, *a1 - *a0)), + _ => None, + }) + .collect(); + assert!(!wedges.is_empty(), "the innermost ring must still be drawn"); + let mut radii: Vec = wedges.iter().map(|(r, _)| *r).collect(); + radii.sort_by(f32::total_cmp); + radii.dedup(); + assert!(radii.len() > 1, "every drawable ring is capped, not dropped"); + // A slice crossing 12 o'clock is split into two primitives, so each ring may add one. + let budget = crate::render::MAX_WEDGES + radii.len(); + assert!(wedges.len() <= budget, "{} wedges for {} rings", wedges.len(), radii.len()); + + // The first (innermost) ring keeps its largest slices and still fills the circle. + let inner: Vec = wedges.iter().filter(|(r, _)| *r == radii[0]).map(|(_, a)| *a).collect(); + assert!(inner.len() < cats, "the ring was not capped: {}", inner.len()); + assert_eq!(inner.iter().filter(|a| **a > 0.1).count(), 10, "largest slices went missing"); + let cover: f32 = inner.iter().sum(); + assert!((cover - std::f32::consts::TAU).abs() < 0.01, "first ring covers {cover}"); +} + +#[test] +fn doughnut_first_slice_angle_rotates_the_start() { + let mut c = chart(ChartKind::Doughnut); + c.title = None; + c.legend = LegendPos::None; + c.first_slice_angle = 90; + let d = ChartData { + category_levels: vec![], + palette: vec![], + categories: ["A", "B"].iter().map(|s| s.to_string()).collect(), + series: vec![series("Only", &[1.0, 1.0], [20, 96, 130, 255], ChartKind::Doughnut)], + }; + let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure); + let first = prims.iter().find_map(|p| match p { + Prim::Wedge { a0, .. } => Some(*a0), + _ => None, + }); + assert!(first.is_some_and(|a| (a - std::f32::consts::FRAC_PI_2).abs() < 0.01), "{first:?}"); +} + +/// Every slice must occupy exactly its angular share, even when the first slice angle pushes the +/// start past 12 o'clock (the split at TAU used to put whole slices over their neighbours). +#[test] +fn rotated_wedges_cover_each_slice_once_without_overlap() { + const TAU: f32 = std::f32::consts::TAU; + for kind in [ChartKind::Doughnut, ChartKind::Pie] { + for angle in [180u16, 270] { + let mut c = chart(kind); + c.title = None; + c.legend = LegendPos::None; + c.data_labels = false; + c.first_slice_angle = angle; + let d = ChartData { + category_levels: vec![], + palette: vec![[230, 126, 34, 255], [39, 174, 96, 255], [142, 68, 173, 255]], + categories: ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect(), + series: vec![series("Only", &[1.0; 4], [20, 96, 130, 255], kind)], + }; + let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure); + let wedges: Vec<(f32, f32)> = prims + .iter() + .filter_map(|p| match p { + Prim::Wedge { a0, a1, .. } => Some((*a0, *a1)), + _ => None, + }) + .collect(); + assert!(wedges.len() >= 4, "{kind:?} at {angle}: {wedges:?}"); + for (a0, a1) in &wedges { + assert!((0.0..TAU).contains(a0), "{kind:?} at {angle}: start {a0} outside [0, TAU)"); + assert!(*a1 > *a0 && *a1 <= TAU + 1e-4, "{kind:?} at {angle}: bad sweep {a0}..{a1}"); + } + // Sample away from the boundaries: a correct four-slice chart covers every angle once. + let steps = 1440; + for k in 0..steps { + let theta = (k as f32 + 0.5) * TAU / steps as f32; + let cover = wedges.iter().filter(|(a0, a1)| theta >= *a0 && theta < *a1).count(); + assert_eq!(cover, 1, "{kind:?} at {angle}: {theta} covered {cover} times by {wedges:?}"); + } + } + } +} + +/// A slice that crosses 12 o'clock is filled as two halves but outlined once: stroking each half +/// separately ran the white separator along the shared radial edge, straight through the slice. +#[test] +fn slice_crossing_twelve_has_no_internal_seam() { + const TAU: f32 = std::f32::consts::TAU; + let mut c = chart(ChartKind::Pie); + c.title = None; + c.legend = LegendPos::None; + c.data_labels = false; + c.first_slice_angle = 90; + let a = [230, 126, 34, 255]; + let b = [39, 174, 96, 255]; + let d = ChartData { + category_levels: vec![], + palette: vec![a, b], + categories: ["A", "B"].iter().map(|s| s.to_string()).collect(), + series: vec![series("Only", &[1.0, 1.0], a, ChartKind::Pie)], + }; + let (w, h) = (400u32, 400u32); + let prims = render(&c, &d, w as f32, h as f32, &ApproxMeasure); + // The second slice starts at 270° and wraps past 0°, so it is drawn as two unstroked halves. + let mut halves = 0; + let mut cx = 0.0; + let mut cy = 0.0; + let mut r = 0.0; + for p in &prims { + if let Prim::Wedge { cx: x, cy: y, r_outer, a0, a1, fill, stroke, .. } = p + && *fill == b + { + assert!(stroke.is_none(), "a split half must not stroke its shared edge: {p:?}"); + assert!(*a0 == 0.0 || *a1 == TAU, "the crossing slice is split at 12 o'clock: {p:?}"); + halves += 1; + (cx, cy, r) = (*x, *y, *r_outer); + } + } + assert_eq!(halves, 2, "one slice crosses 12 o'clock"); + // Exactly one outline covers the whole crossing slice, drawn as line primitives. + assert_eq!(prims.iter().filter(|p| matches!(p, Prim::Line { color, .. } if *color == [255, 255, 255, 255])).count(), 1, "{prims:?}"); + // No white pixel inside the slice along 12 o'clock, where the shared edge used to be stroked. + let img = rasterize(&prims, w, h, [255, 255, 255, 255]); + for t in [0.3f32, 0.5, 0.7] { + assert_eq!(pixel(&img, w, cx as u32, (cy - r * t) as u32), b, "white seam at {t} of the slice"); + } +} + +#[test] +fn doughnut_legend_names_categories_and_matches_every_ring() { + let mut c = chart(ChartKind::Doughnut); + c.title = None; + c.data_labels = false; + c.legend = LegendPos::Right; + c.first_slice_angle = 180; + let d = ChartData { + category_levels: vec![], + palette: vec![[230, 126, 34, 255], [39, 174, 96, 255], [142, 68, 173, 255]], + categories: ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect(), + series: vec![ + series("Outer", &[1.0; 4], [20, 96, 130, 255], ChartKind::Doughnut), + series("Inner", &[1.0; 4], [221, 132, 82, 255], ChartKind::Doughnut), + ], + }; + let entries = legend_entries(&c, &d); + assert_eq!(entries.iter().map(|(name, _, _)| name.as_str()).collect::>(), ["A", "B", "C", "D"]); + + let start = f32::from(c.first_slice_angle) * std::f32::consts::TAU / 360.0; + let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure); + let mut rings = Vec::new(); + for p in &prims { + let Prim::Wedge { r_outer, a0, a1, fill, .. } = p else { continue }; + rings.push(*r_outer); + let rel = ((a0 + a1) / 2.0 - start).rem_euclid(std::f32::consts::TAU); + let cat = ((rel / (std::f32::consts::TAU / 4.0)) as usize).min(3); + assert_eq!(*fill, entries[cat].1, "ring wedge at {a0}..{a1} does not match legend entry {}", entries[cat].0); + } + rings.sort_by(f32::total_cmp); + rings.dedup(); + assert_eq!(rings.len(), 2, "two series must draw two rings"); +} + #[test] fn legend_counts() { let d = sample(ChartKind::ColumnClustered); @@ -461,7 +774,11 @@ fn pie_starts_at_twelve_clockwise() { assert!(wedges.windows(2).all(|w| w[1].0 >= w[0].1 - 1e-4)); assert!((wedges[5].1 - std::f32::consts::TAU).abs() < 1e-3); assert!(wedges.iter().all(|w| w.2 == 0.0 && w.3.map(|s| s.0) == Some([255; 4]))); - let prims = render(&chart(ChartKind::Doughnut), &d, 400.0, 400.0, &ApproxMeasure); + let mut c = chart(ChartKind::Doughnut); + c.hole_size = 50; + let mut d = sample(ChartKind::Doughnut); + d.series.truncate(1); + let prims = render(&c, &d, 400.0, 400.0, &ApproxMeasure); let ok = prims.iter().any(|p| matches!(p, Prim::Wedge { r_outer, r_inner, .. } if (r_inner / r_outer - 0.5).abs() < 1e-3)); assert!(ok); } diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index 3c62e469f..d0941a9ca 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -427,6 +427,8 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { data_table: None, gridlines: true, style: 1, + hole_size: 75, + first_slice_angle: 0, x_title: None, y_title: None, source: None, diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 68fb46505..d832ecddf 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -99,7 +99,7 @@ pub fn specs() -> Vec { "Chart Properties", ["Chart Design"], None, - "{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?}", + "{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?, holeSize?, firstSliceAngle?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}", has_doc, chart_set ), @@ -850,6 +850,8 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> { data_table: None, gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel), style: 1, + hole_size: 75, + first_slice_angle: 0, x_title: None, y_title: None, source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())), @@ -926,6 +928,12 @@ fn chart_set(s: &mut Session, p: &Json) -> Result { if let Some(v) = u32_param(p, "style") { c.style = v.clamp(1, 16); } + if let Some(v) = u32_param(p, "holeSize") { + c.hole_size = v.clamp(10, 90) as u8; + } + if let Some(v) = u32_param(p, "firstSliceAngle") { + c.first_slice_angle = (v % 360) as u16; + } if let Some(t) = p.get("xTitle") { c.x_title = t.as_str().map(str::to_string); } diff --git a/crates/engine/src/cmd/pivot/charts.rs b/crates/engine/src/cmd/pivot/charts.rs index d7a3b73b1..5551c4d56 100644 --- a/crates/engine/src/cmd/pivot/charts.rs +++ b/crates/engine/src/cmd/pivot/charts.rs @@ -67,6 +67,8 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result { data_labels: false, data_table: None, gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel), + hole_size: 75, + first_slice_angle: 0, style: 1, x_title: None, y_title: None, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 90f6691bd..ab443bbe6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3237,6 +3237,21 @@ fn every_chart_kind_survives_xlsx() { } } +#[test] +fn chart_set_doughnut_hole_and_angle() { + let mut s = s(); + chart_data(&mut s); + s.execute("insert.chart", json!({"type": "doughnut", "range": "A1:B7"})).unwrap(); + s.execute("chart.set", json!({"holeSize": 5, "firstSliceAngle": 400})).unwrap(); + s.execute("chart.set", json!({"holeSize": 200})).unwrap(); + let c = s.doc().unwrap().wb.active().unwrap().charts[0].clone(); + assert_eq!((c.hole_size, c.first_slice_angle), (90, 40)); + s.execute("chart.set", json!({"holeSize": 25, "firstSliceAngle": 180})).unwrap(); + reopen(&mut s); + let c = &s.doc().unwrap().wb.active().unwrap().charts[0]; + assert_eq!((c.kind, c.hole_size, c.first_slice_angle), (gridcraft_model::ChartKind::Doughnut, 25, 180)); +} + #[test] fn scatter_of_two_columns_is_one_series_with_the_first_as_x() { let mut s = s(); diff --git a/crates/model/src/features.rs b/crates/model/src/features.rs index a36fcef1e..06e68839f 100644 --- a/crates/model/src/features.rs +++ b/crates/model/src/features.rs @@ -540,6 +540,12 @@ impl Trendline { } } +/// Charts saved before `hole_size` existed were drawn with a 50% hole. New charts and charts read +/// from XLSX set 75 explicitly (Excel's default), so only a missing JSON field keeps the old look. +fn default_hole_size() -> u8 { + 50 +} + /// A chart's data table (`c:dTable`), independent of the chart legend. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] @@ -569,6 +575,13 @@ pub struct Chart { pub data_table: Option, pub gridlines: bool, pub style: u32, + /// Doughnut hole size as a percentage of the plot radius (Excel's 10–90 range). New charts and + /// XLSX imports use Excel's 75; a document saved before this field existed reads as 50. + #[serde(default = "default_hole_size")] + pub hole_size: u8, + /// Pie/doughnut first slice angle in degrees clockwise from 12 o'clock (0–359). + #[serde(default)] + pub first_slice_angle: u16, pub x_title: Option, pub y_title: Option, /// Source range when created from a selection (for Switch Row/Column). @@ -1088,6 +1101,39 @@ mod trendline_forecast_tests { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chart_without_hole_size_keeps_the_legacy_hole() { + let chart = Chart { + id: 1, + kind: ChartKind::Doughnut, + anchor: Anchor::default(), + title: None, + series: vec![], + legend: LegendPos::Right, + data_labels: false, + data_table: None, + gridlines: false, + style: 1, + hole_size: 75, + first_slice_angle: 0, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }; + let mut json = serde_json::to_value(&chart).expect("serialize chart"); + json.as_object_mut().expect("chart object").remove("hole_size"); + let legacy: Chart = serde_json::from_value(json).expect("deserialize legacy chart"); + assert_eq!(legacy.hole_size, 50, "documents saved before hole_size existed keep the old 50% hole"); + assert_eq!(chart.hole_size, 75, "new charts keep Excel's 75% default"); + } +} + #[cfg(test)] mod equation_cache_tests { use super::{EquationCache, source_key}; diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 961c0d583..67ca1f13e 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -831,6 +831,8 @@ mod tests { data_table: None, gridlines: false, style: 0, + hole_size: 75, + first_slice_angle: 0, x_title: None, y_title: None, source: None, diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index b0c648946..58d308413 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -390,6 +390,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Legenda: dole", "Legend: Top" => "Legenda: nahoře", "Legend: Right" => "Legenda: vpravo", + "First slice angle" => "Úhel prvního výseku", + "Doughnut hole size" => "Velikost otvoru prstence", "Gridlines: None" => "Mřížka: žádná", "Gridlines: Show" => "Mřížka: zobrazit", "Chart\nTitle" => "Název\ngrafu", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 9bc5cb6ff..2e6ba713f 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -407,6 +407,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Legende: Unten", "Legend: Top" => "Legende: Oben", "Legend: Right" => "Legende: Rechts", + "First slice angle" => "Winkel des ersten Segments", + "Doughnut hole size" => "Größe des Ringlochs", "Gridlines: None" => "Gitternetzlinien: Keine", "Gridlines: Show" => "Gitternetzlinien: Anzeigen", "Chart\nTitle" => "Diagramm-\ntitel", diff --git a/crates/ui-egui/src/i18n/el.rs b/crates/ui-egui/src/i18n/el.rs index d21c631a0..218fa5a6f 100644 --- a/crates/ui-egui/src/i18n/el.rs +++ b/crates/ui-egui/src/i18n/el.rs @@ -353,6 +353,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Υπόμνημα: κάτω", "Legend: Top" => "Υπόμνημα: επάνω", "Legend: Right" => "Υπόμνημα: δεξιά", + "First slice angle" => "Γωνία πρώτης φέτας", + "Doughnut hole size" => "Μέγεθος οπής ντόνατ", "Gridlines: None" => "Γραμμές πλέγματος: καμία", "Gridlines: Show" => "Γραμμές πλέγματος: εμφάνιση", "Chart\nTitle" => "Τίτλος\nγραφήματος", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 98b2410dd..b6adcf774 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -394,6 +394,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Leyenda: abajo", "Legend: Top" => "Leyenda: arriba", "Legend: Right" => "Leyenda: derecha", + "First slice angle" => "Ángulo del primer sector", + "Doughnut hole size" => "Tamaño del orificio del anillo", "Gridlines: None" => "Líneas de cuadrícula: ninguna", "Gridlines: Show" => "Líneas de cuadrícula: mostrar", "Chart\nTitle" => "Título\ndel gráfico", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 1a9aa3bbe..e11ef4599 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -273,6 +273,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Légende : Bas", "Legend: Top" => "Légende : Haut", "Legend: Right" => "Légende : Droite", + "First slice angle" => "Angle du premier secteur", + "Doughnut hole size" => "Taille du trou de l'anneau", "Gridlines: None" => "Quadrillage : Aucun", "Gridlines: Show" => "Quadrillage : Afficher", "Chart\nTitle" => "Titre du\ngraphique", diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 2a79d4c7d..9a371e673 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -257,6 +257,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Legenda: in basso", "Legend: Top" => "Legenda: in alto", "Legend: Right" => "Legenda: a destra", + "First slice angle" => "Angolo della prima fetta", + "Doughnut hole size" => "Dimensione del foro della ciambella", "Gridlines: None" => "Griglia: nessuna", "Gridlines: Show" => "Griglia: mostra", "Chart\nTitle" => "Titolo\ndel grafico", diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 443c2be41..47fd0e9f3 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -266,6 +266,8 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Legend: Bottom", "凡例: 下"), ("Legend: Top", "凡例: 上"), ("Legend: Right", "凡例: 右"), + ("First slice angle", "最初の扇形の角度"), + ("Doughnut hole size", "ドーナツの穴のサイズ"), ("Gridlines: None", "目盛線: なし"), ("Gridlines: Show", "目盛線: 表示"), ("Chart\nTitle", "グラフ タイトル"), diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c0052af9a..906dea54f 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -258,6 +258,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "범례: 아래쪽", "Legend: Top" => "범례: 위쪽", "Legend: Right" => "범례: 오른쪽", + "First slice angle" => "첫째 조각의 각도", + "Doughnut hole size" => "도넛 구멍 크기", "Gridlines: None" => "눈금선: 없음", "Gridlines: Show" => "눈금선: 표시", "Chart\nTitle" => "차트\n제목", diff --git a/crates/ui-egui/src/i18n/nl.rs b/crates/ui-egui/src/i18n/nl.rs index abb1d08b4..b1d415211 100644 --- a/crates/ui-egui/src/i18n/nl.rs +++ b/crates/ui-egui/src/i18n/nl.rs @@ -264,6 +264,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Legenda: onder", "Legend: Top" => "Legenda: boven", "Legend: Right" => "Legenda: rechts", + "First slice angle" => "Hoek eerste punt", + "Doughnut hole size" => "Grootte van het donutgat", "Gridlines: None" => "Rasterlijnen: geen", "Gridlines: Show" => "Rasterlijnen: weergeven", "Chart\nTitle" => "Grafiek-\ntitel", diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index a2894dcd2..ad9c254c1 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -251,6 +251,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Legenda: u dołu", "Legend: Top" => "Legenda: u góry", "Legend: Right" => "Legenda: z prawej", + "First slice angle" => "Kąt pierwszego wycinka", + "Doughnut hole size" => "Rozmiar otworu pączka", "Gridlines: None" => "Linie siatki: brak", "Gridlines: Show" => "Linie siatki: pokaż", "Chart\nTitle" => "Tytuł\nwykresu", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 470909afe..f78ef4cf5 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -258,6 +258,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Legenda: Abaixo", "Legend: Top" => "Legenda: Acima", "Legend: Right" => "Legenda: À Direita", + "First slice angle" => "Ângulo da primeira fatia", + "Doughnut hole size" => "Tamanho do orifício da rosca", "Gridlines: None" => "Linhas de Grade: Nenhuma", "Gridlines: Show" => "Linhas de Grade: Exibir", "Chart\nTitle" => "Título\ndo Gráfico", diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 6b0c4f737..738fc554f 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -258,6 +258,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Legendă: jos", "Legend: Top" => "Legendă: sus", "Legend: Right" => "Legendă: dreapta", + "First slice angle" => "Unghiul primei felii", + "Doughnut hole size" => "Dimensiunea găurii gogoașei", "Gridlines: None" => "Linii de grilă: niciuna", "Gridlines: Show" => "Linii de grilă: afișare", "Chart\nTitle" => "Titlu\ndiagramă", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 2a37c8939..60141a853 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -260,6 +260,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Легенда: снизу", "Legend: Top" => "Легенда: сверху", "Legend: Right" => "Легенда: справа", + "First slice angle" => "Угол первого сектора", + "Doughnut hole size" => "Размер отверстия кольцевой диаграммы", "Gridlines: None" => "Линии сетки: нет", "Gridlines: Show" => "Линии сетки: показать", "Chart\nTitle" => "Название\nдиаграммы", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 39555f6df..d02c66bb8 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -263,6 +263,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Legenda: Dole", "Legend: Top" => "Legenda: Gore", "Legend: Right" => "Legenda: Desno", + "First slice angle" => "Ugao prve kriške", + "Doughnut hole size" => "Veličina rupe prstena", "Gridlines: None" => "Linije mreže: Ništa", "Gridlines: Show" => "Linije mreže: Prikaži", "Chart\nTitle" => "Naslov\ngrafikona", diff --git a/crates/ui-egui/src/i18n/tr.rs b/crates/ui-egui/src/i18n/tr.rs index d268c2e96..a58505848 100644 --- a/crates/ui-egui/src/i18n/tr.rs +++ b/crates/ui-egui/src/i18n/tr.rs @@ -367,6 +367,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Gösterge: Alt", "Legend: Top" => "Gösterge: Üst", "Legend: Right" => "Gösterge: Sağ", + "First slice angle" => "İlk dilim açısı", + "Doughnut hole size" => "Donut deliği boyutu", "Gridlines: None" => "Kılavuz Çizgileri: Yok", "Gridlines: Show" => "Kılavuz Çizgileri: Göster", "Chart\nTitle" => "Grafik\nBaşlığı", diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 82c2e9e5d..96070eaaf 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -253,6 +253,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Легенда: знизу", "Legend: Top" => "Легенда: згори", "Legend: Right" => "Легенда: праворуч", + "First slice angle" => "Кут першого сегмента", + "Doughnut hole size" => "Розмір отвору пончика", "Gridlines: None" => "Лінії сітки: немає", "Gridlines: Show" => "Лінії сітки: показати", "Chart\nTitle" => "Назва\nдіаграми", diff --git a/crates/ui-egui/src/i18n/vi.rs b/crates/ui-egui/src/i18n/vi.rs index 754d4c33a..7a188a08e 100644 --- a/crates/ui-egui/src/i18n/vi.rs +++ b/crates/ui-egui/src/i18n/vi.rs @@ -265,6 +265,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "Chú giải: Dưới", "Legend: Top" => "Chú giải: Trên", "Legend: Right" => "Chú giải: Phải", + "First slice angle" => "Góc lát đầu tiên", + "Doughnut hole size" => "Kích thước lỗ bánh donut", "Gridlines: None" => "Đường lưới: Không", "Gridlines: Show" => "Đường lưới: Hiện", "Chart\nTitle" => "Tiêu đề\nbiểu đồ", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 7707a0cc0..601e0a1bb 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -258,6 +258,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Legend: Bottom" => "图例:底部", "Legend: Top" => "图例:顶部", "Legend: Right" => "图例:右侧", + "First slice angle" => "第一扇区起始角度", + "Doughnut hole size" => "圆环图内径大小", "Gridlines: None" => "网格线:无", "Gridlines: Show" => "网格线:显示", "Chart\nTitle" => "图表\n标题", diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 9675d2a95..d9207cd07 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -178,6 +178,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { return; }; let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default(); + let lang = crate::i18n::current(ui.ctx()); ui.collapsing("Chart Title", |ui| { let field = egui::Id::unique(("gridcraft.chart_title", id)); if let Some(title) = crate::widgets::committed_text(ui, field, chart.title.as_deref().unwrap_or(""), None) { @@ -209,6 +210,20 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { } } }); + if matches!(chart.kind, gridcraft_chart::ChartKind::Pie | gridcraft_chart::ChartKind::Doughnut) { + let draft = egui::Id::unique(("gridcraft.chart_first_slice_draft", id)); + let current = u32::from(chart.first_slice_angle); + if let Some(angle) = chart_slider(ui, draft, current, 0..=359, lang.tr("First slice angle")) { + app.run_or_alert("chart.set", json!({"chart": id, "firstSliceAngle": angle})); + } + } + if chart.kind == gridcraft_chart::ChartKind::Doughnut { + let draft = egui::Id::unique(("gridcraft.chart_hole_size_draft", id)); + let current = u32::from(chart.hole_size); + if let Some(hole) = chart_slider(ui, draft, current, 10..=90, lang.tr("Doughnut hole size")) { + app.run_or_alert("chart.set", json!({"chart": id, "holeSize": hole})); + } + } }); ui.collapsing("Legend", |ui| { ui.horizontal(|ui| { @@ -339,3 +354,21 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { } }); } + +/// A chart slider that keeps the value being dragged in a per-chart draft and submits it once +/// through `chart.set` when the interaction ends, like the Size controls. Escape cancels, and a +/// value the chart already has is not sent again. +fn chart_slider(ui: &mut Ui, draft: egui::Id, value: u32, range: std::ops::RangeInclusive, label: &str) -> Option { + let mut current = ui.data_mut(|d| d.get_temp::(draft)).unwrap_or(value); + let r = ui.add(egui::Slider::new(&mut current, range).text(label)); + if r.drag_stopped() || r.clicked() || r.lost_focus() { + ui.data_mut(|d| d.remove::(draft)); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) || current == value { None } else { Some(current) } + } else if r.dragged() || r.has_focus() { + ui.data_mut(|d| d.insert_temp(draft, current)); + None + } else { + ui.data_mut(|d| d.remove::(draft)); + None + } +} diff --git a/crates/xlsx/src/chart.rs b/crates/xlsx/src/chart.rs index a408da5fa..fea91cec5 100644 --- a/crates/xlsx/src/chart.rs +++ b/crates/xlsx/src/chart.rs @@ -260,6 +260,10 @@ pub fn read_chart(root: &El) -> Option { _ => LegendPos::Right, }, }; + let doughnut = plot.child("doughnutChart"); + let hole_size = doughnut.and_then(|g| g.child_val("holeSize")).and_then(|v| v.trim().parse::().ok()).unwrap_or(75).clamp(10, 90); + let first_slice_angle = + plot.child("pieChart").or(doughnut).and_then(|g| g.child_val("firstSliceAng")).and_then(|v| v.trim().parse::().ok()).unwrap_or(0) % 360; Some(Chart { id: 0, kind, @@ -276,6 +280,8 @@ pub fn read_chart(root: &El) -> Option { }), 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), + hole_size, + first_slice_angle, x_title: x_ax.and_then(|a| a.child("title")).and_then(rich_title), y_title: y_ax.and_then(|a| a.child("title")).and_then(rich_title), source: None, @@ -678,10 +684,12 @@ fn group_xml(g: G, series: &[(usize, &Series)], ch: &Chart, axes: (u32, u32), wb "{sers}{dl}{ax}", if stacked { "stacked" } else { "standard" } ), - G::Pie => format!("{sers}{dl}"), - G::Doughnut => { - format!("{sers}{dl}") - } + G::Pie => format!("{sers}{dl}", ch.first_slice_angle % 360), + G::Doughnut => format!( + "{sers}{dl}", + ch.first_slice_angle % 360, + ch.hole_size.clamp(10, 90) + ), G::Area { grouping } => format!("{sers}{dl}{ax}"), G::Scatter { .. } => format!("{sers}{dl}{ax}"), G::Bubble => { diff --git a/crates/xlsx/src/chartex.rs b/crates/xlsx/src/chartex.rs index ce71d4f32..de54c5942 100644 --- a/crates/xlsx/src/chartex.rs +++ b/crates/xlsx/src/chartex.rs @@ -133,6 +133,8 @@ pub fn read_chartex(root: &El) -> Option { data_table: None, gridlines: plot.kids("axis").any(|a| a.child("majorGridlines").is_some()), style: 2, + hole_size: 75, + first_slice_angle: 0, x_title: axis_title("0"), y_title: axis_title("1"), source: None, diff --git a/crates/xlsx/src/tests/chart_book_names.rs b/crates/xlsx/src/tests/chart_book_names.rs index 10edf45a4..94d148da8 100644 --- a/crates/xlsx/src/tests/chart_book_names.rs +++ b/crates/xlsx/src/tests/chart_book_names.rs @@ -78,6 +78,8 @@ fn chart_names_are_written_qualified() { data_table: None, gridlines: true, style: 2, + hole_size: 75, + first_slice_angle: 0, x_title: None, y_title: None, source: None, @@ -157,6 +159,8 @@ fn chart_names_keep_spaces_around_a_quoted_sheet_name() { data_table: None, gridlines: true, style: 2, + hole_size: 75, + first_slice_angle: 0, x_title: None, y_title: None, source: None, diff --git a/crates/xlsx/src/tests/fixtures.rs b/crates/xlsx/src/tests/fixtures.rs index 4de0be3c3..dadfc096a 100644 --- a/crates/xlsx/src/tests/fixtures.rs +++ b/crates/xlsx/src/tests/fixtures.rs @@ -551,6 +551,8 @@ fn chartex_parts_as_excel_expects_them() { data_table: None, gridlines: true, style: 2, + hole_size: 75, + first_slice_angle: 0, x_title: None, y_title: None, source: None, @@ -586,6 +588,50 @@ fn chartex_parts_as_excel_expects_them() { assert_eq!((s.name.as_deref(), s.categories.as_deref(), s.values.as_str()), (Some("Data!$B$1"), Some("Data!$A$2:$A$5"), "Data!$B$2:$B$5")); } +#[test] +fn doughnut_writes_every_series_as_a_ring_with_hole_and_angle() { + use gridcraft_model::{Chart, ChartKind, LegendPos, Series}; + let ser = |name: &str, values: &str| Series { + name: Some(name.into()), + categories: Some("Data!$A$2:$A$4".into()), + values: values.into(), + ..Default::default() + }; + let mut wb = gridcraft_model::Workbook::new(); + let mut sheet = gridcraft_model::Sheet::new("Data"); + sheet.charts.push(Chart { + id: 1, + kind: ChartKind::Doughnut, + anchor: Default::default(), + title: None, + series: vec![ser("Outer", "Data!$B$2:$B$4"), ser("Inner", "Data!$C$2:$C$4")], + legend: LegendPos::Right, + data_labels: false, + data_table: None, + gridlines: false, + style: 2, + hole_size: 25, + first_slice_angle: 180, + x_title: None, + y_title: None, + source: None, + by_rows: false, + pivot: None, + }); + wb.sheets = vec![std::sync::Arc::new(sheet)]; + let bytes = crate::write_xlsx(&wb).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap(); + let mut cx = String::new(); + std::io::Read::read_to_string(&mut z.by_name("xl/charts/chart1.xml").unwrap(), &mut cx).unwrap(); + assert!(cx.contains(""), "{cx}"); + assert_eq!(cx.matches("").count(), 2, "one series per ring: {cx}"); + assert!(cx.contains(""), "{cx}"); + assert!(cx.contains(""), "{cx}"); + let (back, _) = crate::read_xlsx(&bytes).unwrap(); + let ch = &back.sheet(0).unwrap().charts[0]; + assert_eq!((ch.kind, ch.series.len(), ch.hole_size, ch.first_slice_angle), (ChartKind::Doughnut, 2, 25, 180)); +} + /// #377: a WordArt-style text box keeps its run size, bold, typeface, text fill and outline, its /// centring (horizontal and vertical) and its "no outline" shape line, through a round trip. #[test] diff --git a/crates/xlsx/src/tests/literal_category_roundtrip.rs b/crates/xlsx/src/tests/literal_category_roundtrip.rs index 3a6f9f712..a514fa6ce 100644 --- a/crates/xlsx/src/tests/literal_category_roundtrip.rs +++ b/crates/xlsx/src/tests/literal_category_roundtrip.rs @@ -44,6 +44,8 @@ fn literal_category_with_separators_survives_save() { data_table: None, gridlines: true, style: 2, + hole_size: 75, + first_slice_angle: 0, x_title: None, y_title: None, source: None, diff --git a/crates/xlsx/src/tests/multilevel_category_roundtrip.rs b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs index 7a375a438..f4c118f2b 100644 --- a/crates/xlsx/src/tests/multilevel_category_roundtrip.rs +++ b/crates/xlsx/src/tests/multilevel_category_roundtrip.rs @@ -44,6 +44,8 @@ fn workbook(categories: &str, values: &str) -> Workbook { data_table: None, gridlines: true, style: 2, + hole_size: 75, + first_slice_angle: 0, x_title: None, y_title: None, source: None, diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 4eb6c3cfe..e2072fe44 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -414,6 +414,8 @@ fn sample() -> Workbook { data_table: None, gridlines: true, style: 2, + hole_size: 75, + first_slice_angle: 0, x_title: Some("Region".into()), y_title: Some("Amount".into()), source: None, @@ -453,6 +455,9 @@ fn sample() -> Workbook { }); s.charts.push(combo); s.charts.push(Chart { kind: ChartKind::Waterfall, legend: LegendPos::None, title: None, ..base_chart.clone() }); + let mut doughnut = Chart { kind: ChartKind::Doughnut, hole_size: 25, first_slice_angle: 180, legend: LegendPos::Right, ..base_chart.clone() }; + doughnut.series.push(base_chart.series[0].clone()); + s.charts.push(doughnut); wb.sheets = vec![Arc::new(s)]; let mut s2 = Sheet::new("Hidden"); @@ -594,6 +599,7 @@ fn assert_drawing_and_chart_roundtrip(a: &Sheet, b: &Sheet) { assert_eq!(y.title, x.title); assert_eq!(y.legend, x.legend); assert_eq!(y.data_labels, x.data_labels); + assert_eq!((y.hole_size, y.first_slice_angle), (x.hole_size, x.first_slice_angle)); if !matches!(x.kind, ChartKind::Pie | ChartKind::Doughnut) { assert_eq!(y.gridlines, x.gridlines); assert_eq!((&y.x_title, &y.y_title), (&x.x_title, &x.y_title)); diff --git a/crates/xlsx/src/tests/smooth_roundtrip.rs b/crates/xlsx/src/tests/smooth_roundtrip.rs index 1a418bb51..96c346d31 100644 --- a/crates/xlsx/src/tests/smooth_roundtrip.rs +++ b/crates/xlsx/src/tests/smooth_roundtrip.rs @@ -32,6 +32,8 @@ fn smooth_line_setting_round_trips() { data_table: None, gridlines: true, style: 2, + hole_size: 75, + first_slice_angle: 0, x_title: None, y_title: None, source: None, @@ -51,6 +53,8 @@ fn smooth_line_setting_round_trips() { data_table: None, gridlines: true, style: 2, + hole_size: 75, + first_slice_angle: 0, x_title: None, y_title: None, source: None, diff --git a/crates/xlsx/src/tests/trendline_roundtrip.rs b/crates/xlsx/src/tests/trendline_roundtrip.rs index 2a1e19d05..f5bf7feae 100644 --- a/crates/xlsx/src/tests/trendline_roundtrip.rs +++ b/crates/xlsx/src/tests/trendline_roundtrip.rs @@ -56,6 +56,8 @@ fn chart(id: u32, kind: ChartKind, trendlines: Vec) -> Chart { data_table: None, gridlines: true, style: 2, + hole_size: 75, + first_slice_angle: 0, x_title: None, y_title: None, source: None, diff --git a/docs/chart-parity.md b/docs/chart-parity.md index be5a7fa52..311bd2635 100644 --- a/docs/chart-parity.md +++ b/docs/chart-parity.md @@ -1,13 +1,15 @@ # Chart parity -> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-11-02 · **Change:** minor (charts resize from eight handles; earlier: 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 +> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-11-02 · **Change:** minor (doughnut charts: every series is a concentric ring, the first series innermost; hole size and first slice angle kept; rotated slices cover their own sweep; legend names categories; earlier: charts resize from eight handles, 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). Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crates/chart/src` (layout and rendering), `crates/xlsx/src/chart.rs` (DrawingML chart parts), merged PRs #62, #116, -#158 and #161, and issue #190. +#158 and #161, and issue #190. Behaviour checked against Microsoft's +[Pareto](https://support.microsoft.com/en-us/excel/create-a-pareto-chart) and +[pie/doughnut](https://support.microsoft.com/en-us/excel/add-a-pie-chart) support pages. ## Chart types @@ -16,7 +18,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate | Column | clustered, stacked, 100% stacked, 3-D clustered/stacked/100%, 3-D column | 3 of 7 | no 3-D | | Bar | clustered, stacked, 100%, 3-D ×3 | 3 of 6 | no 3-D | | Line | line, stacked, 100% stacked, with markers ×3, 3-D line | 3 of 7 (line, with markers, stacked) | no 100% stacked; smoothed lines landed (#62) | -| Pie | pie, 3-D pie, pie of pie, bar of pie, doughnut | 2 of 5 (pie, doughnut) | no exploded, pie of pie, bar of pie | +| Pie | pie, 3-D pie, pie of pie, bar of pie, doughnut | 2 of 5 (pie, doughnut) | no exploded, pie of pie, bar of pie; doughnut draws every series as a concentric ring, first series innermost; its legend names categories, matching Excel's default vary-by-category colours | | Area | area, stacked, 100%, 3-D ×3 | 3 of 6 | no 3-D; 100% stacked drawn as shares of each category's total and kept as `percentStacked` in XLSX | | X Y (scatter) | markers, smooth lines (±markers), straight lines (±markers) | 2 of 5 | no smooth lines | | Bubble | bubble, 3-D bubble | 1 of 2 | | @@ -45,6 +47,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate | Gridlines | major/minor per axis, formatting | one on/off flag | | Axis options | min/max/units, log scale, reverse order, crossing point, display units, date axis, number format | **missing** (auto only) | | Multi-level category axis (a category range two or more columns wide beside the values: year beside month) | the outer levels grouped under the labels with separators | read (`multiLvlStrRef`), drawn (rows under the labels; columns beside them in bar charts; up to three outer levels), written back with Excel's level caches | +| Doughnut hole size, first slice angle | 10–90% hole (Excel default 75%), 0–359° first slice | Format Chart pane sliders; read from and written to `c:holeSize` / `c:firstSliceAng`; documents saved before the setting existed keep their old 50% hole | | Secondary axis | any series | line series in combo charts | | 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** | @@ -109,6 +112,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690). | 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) | +| 2026-10-18 | minor | Doughnut charts: every series is a ring (first series innermost), hole size and first slice angle modelled, editable and round-tripped; rotated slices split at 12 o'clock without overlap; legend names categories like Excel's default (#1823) | | 2026-10-16 | trivial | Series that refer to defined names (`[0]!Name`, `Sheet!Name`) resolve instead of showing `#NAME?`, and are saved qualified, as Excel needs (#1686) | | 2026-10-16 | minor | Box & whisker quartiles default to exclusive, honor each series on draw and XLSX round-trip; `chart.set {quartiles}` changes the method (#1453) | | 2026-10-13 | minor | Multi-level category axes: read, drawn (outer levels under or beside the labels), written back with level caches | diff --git a/docs/roadmap.md b/docs/roadmap.md index 62e1735f7..458af936a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -28,7 +28,7 @@ workflow, would make GridCraft pre-alpha. | Open a colleague's XLSX, edit, save, hand it back | partial, not blocking | Typical workbooks open and save with cells, styles, CF, validation, tables, names and pivots; unmodelled parts are dropped with a warning (gap #3); Excel-side repair prompts unverified (gap #1); ~100 MB files fail (#175) | 0 for alpha (gaps #1–#3 are beta gates) | | Format and present: number formats, styles, conditional formatting, print or PDF | yes | Full number-format language, 47 cell styles, all CF kinds, Page Break Preview, PDF export and printing. PDF can't carry non-Latin text (gap #5) | 0 | | Analyze: sort, filter, tables, PivotTable | yes | Multi-level sort, AutoFilter, tables with totals, PivotTables with field list, layouts, grouping, refresh and XLSX round-trip. Calculated fields done; slicers missing (gap #8) | 0 | -| Chart the data | partial, not blocking | Column, bar, line, pie, area, scatter, combo, histogram, box, waterfall, funnel, treemap, sunburst and stock charts are created, edited and survive save and reopen; trendlines are drawn; no axis options (gap #4) | 0 for alpha | +| Chart the data | partial, not blocking | Column, bar, line, pie, area, scatter, combo, histogram, box, waterfall, funnel, treemap, sunburst and stock charts are created, edited and survive save and reopen; doughnut charts draw every series as a ring (first series innermost) with editable hole size and first slice angle; trendlines are drawn; no axis options (gap #4) | 0 for alpha | | Import and export CSV | yes | Encoding and delimiter detection on read, CSV/TSV write; exports raw rather than displayed values (gap #19) | 0 | **Passes:** every core workflow works end to end on macOS with save and reopen; the two partial diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c18df8d40..9ac4ea015 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -86,10 +86,8 @@ 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, 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) | +| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. Doughnut charts draw every series as a concentric ring (first series innermost), with editable hole size and first slice angle. 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), except stock charts: only 3–4 series are a stock chart, and any other count is written and reimported as a line chart. [chart-parity.md](chart-parity.md) | | Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, page order, 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, 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 order, 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 (all 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer, Sensitivity and Limits reports; Load/Save Model, Reset All and Solver Options; no Population 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) | | Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes, form buttons, scroll bars and spin buttons, equations (LaTeX with a live preview, Office Math in xlsx, Symbol font in PDF). Missing: SmartArt, WordArt, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) | @@ -309,8 +307,9 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land - What-if: Goal Seek, scenarios, data tables. Spelling (system word list) and thesaurus. - Draw: pen, highlighter, eraser, ink strokes and Ink to Shape. Task panes: Comments (threaded replies, resolve), Watch Window, Selection, Format Chart. -- Charts: column, bar, line, area, pie, doughnut, scatter, bubble, radar, histogram, waterfall, - funnel, treemap, sunburst, box & whisker, stock and combo charts; sparklines. +- Charts: column, bar, line, area, pie, doughnut (one concentric ring per series), scatter, + bubble, radar, histogram, waterfall, funnel, treemap, sunburst, box & whisker, stock and combo + charts; sparklines. - UI (egui): Excel-style title bar with Quick Access Toolbar and AutoSave, the full ribbon with galleries (cell styles, table styles, colour palettes, conditional formatting menus), contextual Table/Chart Design tabs, formula bar with Name Box, virtualised grid (smooth