Chart: color pie, doughnut, treemap and sunburst points from the workbook theme (#579)

This commit is contained in:
MarekSpodBiedry
2026-10-12 08:36:14 +02:00
parent 3926d21aa9
commit 5fb854be9c
4 changed files with 77 additions and 14 deletions
+3
View File
@@ -124,6 +124,9 @@ impl Measure for ApproxMeasure {
pub struct ChartData {
pub categories: Vec<String>,
pub series: Vec<SeriesData>,
/// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme.
#[serde(default)]
pub palette: Vec<Rgba>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+2 -1
View File
@@ -129,7 +129,7 @@ fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind {
fn point_color(data: &ChartData, j: usize) -> Rgba {
match (j, data.series.first()) {
(0, Some(s)) => s.color,
_ => series_color(&Theme::default(), j),
_ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)),
}
}
@@ -190,6 +190,7 @@ fn sanitize_vals(v: &[Option<f64>]) -> Vec<Option<f64>> {
fn sanitize(d: &ChartData) -> ChartData {
ChartData {
palette: d.palette.iter().take(MAX_POINTS).copied().collect(),
categories: d.categories.iter().take(MAX_POINTS).cloned().collect(),
series: d
.series
+4 -1
View File
@@ -16,6 +16,8 @@ pub(crate) const MAX_TRENDLINES: usize = 16;
/// Tints used for series beyond the six theme accents: lighter, darker, lighter still…
const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2];
/// `series_color` repeats after this many colours (six accents × the tints).
pub(crate) const PALETTE_CYCLE: usize = 6 * CYCLE_TINTS.len();
/// Colour of the `i`-th series: theme accents 1–6, then tinted variations of them.
pub fn series_color(theme: &Theme, i: usize) -> Rgba {
@@ -239,5 +241,6 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
}
let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0);
let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect());
ChartData { categories, series }
let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE));
ChartData { categories, series, palette }
}
+68 -12
View File
@@ -74,6 +74,7 @@ fn sample(kind: ChartKind) -> ChartData {
let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2);
b.secondary = kind == ChartKind::Combo;
ChartData {
palette: vec![],
categories: cats.iter().map(|s| s.to_string()).collect(),
series: vec![
series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1),
@@ -362,7 +363,7 @@ fn scatter_with_text_x_values_numbers_the_points() {
let d = resolve(&wb, 0, &c);
assert_eq!(d.series[0].x, None, "{kind:?}");
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let empty = render(&c, &ChartData { categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
let empty = render(&c, &ChartData { palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points");
// Numeric X values are kept.
c.series[0].categories = Some("Sheet1!$C$1:$C$3".into());
@@ -445,7 +446,11 @@ fn pie_starts_at_twelve_clockwise() {
#[test]
fn negative_values_put_axis_below_zero() {
let c = chart(ChartKind::ColumnClustered);
let d = ChartData { categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] };
let d = ChartData {
palette: vec![],
categories: vec![],
series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)],
};
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
let t = texts(&prims);
assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}");
@@ -456,8 +461,13 @@ fn negative_values_put_axis_below_zero() {
fn edge_cases_never_panic() {
let m = ApproxMeasure;
let empty = ChartData::default();
let zeros = ChartData { categories: vec!["a".into(), "b".into()], series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)] };
let zeros = ChartData {
palette: vec![],
categories: vec!["a".into(), "b".into()],
series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)],
};
let weird = ChartData {
palette: vec![],
categories: vec![String::new(); 3],
series: vec![SeriesData {
name: "w".repeat(500),
@@ -488,7 +498,7 @@ fn thousands_of_points_are_decimated() {
let vals: Vec<f64> = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect();
let mut c = chart(ChartKind::Line);
c.data_labels = true;
let d = ChartData { categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] };
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] };
let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure);
let max_line = prims
.iter()
@@ -513,7 +523,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize {
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = smooth;
let d = ChartData { categories: cats.clone(), series: vec![s] };
let d = ChartData { palette: vec![], categories: cats.clone(), series: vec![s] };
render(&chart(ChartKind::Line), &d, 600.0, 400.0, m)
.iter()
.filter_map(|p| match p {
@@ -530,7 +540,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
// Rendering either way must stay finite and inside the canvas.
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = true;
let d = ChartData { categories: cats, series: vec![s] };
let d = ChartData { palette: vec![], categories: cats, series: vec![s] };
assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0);
}
@@ -540,7 +550,7 @@ fn smooth_line_splits_at_gaps() {
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
s.smooth = true;
s.values[1] = None; // gap between two data points
let d = ChartData { categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
let d = ChartData { palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m);
// Two separate runs (before/after the gap) → at least two line primitives.
let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count();
@@ -617,7 +627,7 @@ fn fuzz_random_data_and_sizes() {
})
.collect();
let ncat = (r.next() % 50) as usize;
let d = ChartData { categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series };
let d = ChartData { palette: vec![], categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series };
let mut c = chart(kind);
c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize];
c.data_labels = r.next().is_multiple_of(2);
@@ -792,7 +802,7 @@ fn line_chart_draws_trendline_forecast_and_labels() {
let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line);
s.trendlines =
vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }];
let d = ChartData { categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
let d = ChartData { palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
let mut c = chart(ChartKind::Line);
c.legend = LegendPos::None; // keeps legend swatches out of the line counts
let prims = render(&c, &d, 600.0, 400.0, &m);
@@ -816,7 +826,7 @@ fn line_chart_draws_trendline_forecast_and_labels() {
assert!(lines_of(&pie, tc).is_empty());
// One point: nothing to fit, nothing drawn.
s.values.truncate(1);
let one = ChartData { categories: vec!["a".into()], series: vec![s] };
let one = ChartData { palette: vec![], categories: vec!["a".into()], series: vec![s] };
assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty());
}
@@ -836,7 +846,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] },
TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] },
];
let d = ChartData { categories: vec![], series: vec![s] };
let d = ChartData { palette: vec![], categories: vec![], series: vec![s] };
let mut c = chart(ChartKind::Scatter);
c.legend = LegendPos::None;
let prims = render(&c, &d, 600.0, 400.0, &m);
@@ -851,6 +861,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
#[test]
fn treemap_and_histogram_shapes() {
let d = ChartData {
palette: vec![],
categories: vec!["a".into(), "b".into(), "c".into(), "d".into()],
series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)],
};
@@ -871,7 +882,7 @@ fn treemap_and_histogram_shapes() {
assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}");
let vals: Vec<f64> = (0..100).map(|i| i as f64).collect();
let d = ChartData { categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] };
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] };
let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure);
let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count();
assert_eq!(bins, 10);
@@ -902,3 +913,48 @@ fn union_series_references() {
c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000));
let _ = resolve(&wb, 0, &c);
}
#[test]
fn pie_points_follow_workbook_theme() {
let mut wb = Workbook::new();
wb.theme.colors[4] = 0x4F6D8A;
wb.theme.colors[5] = 0x8AA1B8;
let mut c = chart(ChartKind::Pie);
c.series = vec![Series {
name: None,
categories: None,
values: "{5,3}".into(),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
smooth: false,
trendlines: Vec::new(),
}];
let d = resolve(&wb, 0, &c);
let expected = vec![series_color(&wb.theme, 0), series_color(&wb.theme, 1)];
assert_eq!(expected, vec![[0x4F, 0x6D, 0x8A, 0xFF], [0x8A, 0xA1, 0xB8, 0xFF]]);
let legend: Vec<Rgba> = legend_entries(&c, &d).iter().map(|e| e.1).collect();
assert_eq!(legend, expected);
let fills: Vec<Rgba> = render(&c, &d, 480.0, 288.0, &ApproxMeasure)
.iter()
.filter_map(|p| match p {
Prim::Wedge { fill, .. } => Some(*fill),
_ => None,
})
.collect();
assert_eq!(fills, expected);
for kind in [ChartKind::Doughnut, ChartKind::Treemap, ChartKind::Sunburst] {
c.kind = kind;
let legend: Vec<Rgba> = legend_entries(&c, &d).iter().map(|e| e.1).collect();
assert_eq!(legend, expected, "{kind:?}");
}
// Past the 36-colour cycle the points keep following the workbook theme.
c.kind = ChartKind::Pie;
c.series[0].values = format!("{{{}}}", ["1"; 40].join(","));
let d = resolve(&wb, 0, &c);
let legend: Vec<Rgba> = legend_entries(&c, &d).iter().map(|e| e.1).collect();
assert_eq!(legend.len(), 40);
assert_eq!(legend.get(37), Some(&series_color(&wb.theme, 37)));
assert_ne!(series_color(&wb.theme, 37), series_color(&gridcraft_model::Theme::default(), 37));
}