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 <alosslessdev@users.noreply.github.com>
This commit is contained in:
Francisco Herrera
2026-11-05 05:13:35 +01:00
committed by GitHub
co-authored by alosslessdev
parent ede12f9da6
commit 1fb191571b
41 changed files with 705 additions and 21 deletions
+7 -2
View File
@@ -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<Prim>,
@@ -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),
+141 -4
View File
@@ -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<usize> = (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<f64> = 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<f64> = 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.
+2
View File
@@ -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()
+318 -1
View File
@@ -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<f32> {
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<Option<(Rgba, f32)>> = 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<Option<(Rgba, f32)>> = 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<f64> = (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<f32> = 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<f32> = 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::<Vec<_>>(), ["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);
}
+2
View File
@@ -427,6 +427,8 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
data_table: None,
gridlines: true,
style: 1,
hole_size: 75,
first_slice_angle: 0,
x_title: None,
y_title: None,
source: None,
+9 -1
View File
@@ -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?, 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<Json> {
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);
}
+2
View File
@@ -67,6 +67,8 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
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,
+15
View File
@@ -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();
+46
View File
@@ -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<ChartDataTable>,
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<String>,
pub y_title: Option<String>,
/// 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};
+2
View File
@@ -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,
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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γραφήματος",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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", "グラフ タイトル"),
+2
View File
@@ -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제목",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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ă",
+2
View File
@@ -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диаграммы",
+2
View File
@@ -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",
+2
View File
@@ -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ığı",
+2
View File
@@ -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діаграми",
+2
View File
@@ -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 đồ",
+2
View File
@@ -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标题",
+33
View File
@@ -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<u32>, label: &str) -> Option<u32> {
let mut current = ui.data_mut(|d| d.get_temp::<u32>(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::<u32>(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::<u32>(draft));
None
}
}
+12 -4
View File
@@ -260,6 +260,10 @@ pub fn read_chart(root: &El) -> Option<Chart> {
_ => LegendPos::Right,
},
};
let doughnut = plot.child("doughnutChart");
let hole_size = doughnut.and_then(|g| g.child_val("holeSize")).and_then(|v| v.trim().parse::<u8>().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::<u16>().ok()).unwrap_or(0) % 360;
Some(Chart {
id: 0,
kind,
@@ -276,6 +280,8 @@ pub fn read_chart(root: &El) -> Option<Chart> {
}),
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
"<c:lineChart><c:grouping val=\"{}\"/><c:varyColors val=\"0\"/>{sers}{dl}<c:marker val=\"1\"/>{ax}</c:lineChart>",
if stacked { "stacked" } else { "standard" }
),
G::Pie => format!("<c:pieChart><c:varyColors val=\"1\"/>{sers}{dl}<c:firstSliceAng val=\"0\"/></c:pieChart>"),
G::Doughnut => {
format!("<c:doughnutChart><c:varyColors val=\"1\"/>{sers}{dl}<c:firstSliceAng val=\"0\"/><c:holeSize val=\"50\"/></c:doughnutChart>")
}
G::Pie => format!("<c:pieChart><c:varyColors val=\"1\"/>{sers}{dl}<c:firstSliceAng val=\"{}\"/></c:pieChart>", ch.first_slice_angle % 360),
G::Doughnut => format!(
"<c:doughnutChart><c:varyColors val=\"1\"/>{sers}{dl}<c:firstSliceAng val=\"{}\"/><c:holeSize val=\"{}\"/></c:doughnutChart>",
ch.first_slice_angle % 360,
ch.hole_size.clamp(10, 90)
),
G::Area { grouping } => format!("<c:areaChart><c:grouping val=\"{grouping}\"/><c:varyColors val=\"0\"/>{sers}{dl}{ax}</c:areaChart>"),
G::Scatter { .. } => format!("<c:scatterChart><c:scatterStyle val=\"lineMarker\"/><c:varyColors val=\"0\"/>{sers}{dl}{ax}</c:scatterChart>"),
G::Bubble => {
+2
View File
@@ -133,6 +133,8 @@ pub fn read_chartex(root: &El) -> Option<Chart> {
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,
@@ -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,
+46
View File
@@ -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("<c:doughnutChart>"), "{cx}");
assert_eq!(cx.matches("<c:ser>").count(), 2, "one series per ring: {cx}");
assert!(cx.contains("<c:holeSize val=\"25\"/>"), "{cx}");
assert!(cx.contains("<c:firstSliceAng val=\"180\"/>"), "{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]
@@ -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,
@@ -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,
+6
View File
@@ -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));
@@ -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,
@@ -56,6 +56,8 @@ fn chart(id: u32, kind: ChartKind, trendlines: Vec<Trendline>) -> Chart {
data_table: None,
gridlines: true,
style: 2,
hole_size: 75,
first_slice_angle: 0,
x_title: None,
y_title: None,
source: None,
+7 -3
View File
@@ -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 |
+1 -1
View File
@@ -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
+4 -5
View File
@@ -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