diff --git a/crates/chart/src/lib.rs b/crates/chart/src/lib.rs index 083ff8b5d..697e2a7a7 100644 --- a/crates/chart/src/lib.rs +++ b/crates/chart/src/lib.rs @@ -124,6 +124,9 @@ impl Measure for ApproxMeasure { pub struct ChartData { pub categories: Vec, pub series: Vec, + /// 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, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/chart/src/render.rs b/crates/chart/src/render.rs index f2f79dc4b..0521ec290 100644 --- a/crates/chart/src/render.rs +++ b/crates/chart/src/render.rs @@ -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]) -> Vec> { 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 diff --git a/crates/chart/src/resolve.rs b/crates/chart/src/resolve.rs index 2c17eff22..e059103ca 100644 --- a/crates/chart/src/resolve.rs +++ b/crates/chart/src/resolve.rs @@ -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 } } diff --git a/crates/chart/src/tests.rs b/crates/chart/src/tests.rs index 6af1f4604..d5a461a3e 100644 --- a/crates/chart/src/tests.rs +++ b/crates/chart/src/tests.rs @@ -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()); @@ -455,7 +456,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:?}"); @@ -466,8 +471,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), @@ -498,7 +508,7 @@ fn thousands_of_points_are_decimated() { let vals: Vec = (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() @@ -523,7 +533,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 { @@ -540,7 +550,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); } @@ -550,7 +560,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(); @@ -627,7 +637,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); @@ -802,7 +812,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); @@ -826,7 +836,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()); } @@ -846,7 +856,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); @@ -861,6 +871,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)], }; @@ -881,7 +892,7 @@ fn treemap_and_histogram_shapes() { assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}"); let vals: Vec = (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); @@ -936,6 +947,48 @@ fn union_series_references() { } #[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 = legend_entries(&c, &d).iter().map(|e| e.1).collect(); + assert_eq!(legend, expected); + let fills: Vec = 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 = 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 = 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)); fn area_charts_draw_source_value_labels_only_when_enabled() { for kind in [ChartKind::Area, ChartKind::AreaStacked] { let d = sample(kind);