mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
A combo chart (columns plus lines, often with the lines on a second value axis) was read with each series' type but drawn as lines only, on one scale, so a spend-and-clicks chart showed the clicks as a flat line along the bottom and no columns. - Reading: a plot group whose value axis isn't the first group's is on the secondary axis (Series::secondary_axis, now set). - Model: Chart::series_kind, on_secondary and axis_range (per axis; a combo stacks only its stacked series). - Rendering: bar-like series are drawn as bars (clustered or stacked among themselves), the others as lines/areas over them, each against its own axis; the secondary axis' labels go on the right. Line series use their line's colour and width (where PowerPoint keeps them), also in the legend. Long category labels wrap onto two lines.
368 lines
12 KiB
Rust
368 lines
12 KiB
Rust
//! Charts: an embedded data grid plus type and element options.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::style::{ColorRef, Fill, Line};
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum ChartType {
|
||
#[default]
|
||
Column,
|
||
StackedColumn,
|
||
PercentColumn,
|
||
Bar,
|
||
StackedBar,
|
||
PercentBar,
|
||
Line,
|
||
LineMarkers,
|
||
StackedLine,
|
||
Area,
|
||
StackedArea,
|
||
Pie,
|
||
Doughnut,
|
||
Scatter,
|
||
Bubble,
|
||
Radar,
|
||
FilledRadar,
|
||
Stock,
|
||
Surface,
|
||
Histogram,
|
||
Pareto,
|
||
BoxWhisker,
|
||
Waterfall,
|
||
Funnel,
|
||
Treemap,
|
||
Sunburst,
|
||
Combo,
|
||
}
|
||
|
||
impl ChartType {
|
||
pub const MAIN: [ChartType; 12] = [
|
||
ChartType::Column,
|
||
ChartType::Bar,
|
||
ChartType::Line,
|
||
ChartType::Area,
|
||
ChartType::Pie,
|
||
ChartType::Doughnut,
|
||
ChartType::Scatter,
|
||
ChartType::Radar,
|
||
ChartType::Waterfall,
|
||
ChartType::Funnel,
|
||
ChartType::Histogram,
|
||
ChartType::Treemap,
|
||
];
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
ChartType::Column => "Clustered Column",
|
||
ChartType::StackedColumn => "Stacked Column",
|
||
ChartType::PercentColumn => "100% Stacked Column",
|
||
ChartType::Bar => "Clustered Bar",
|
||
ChartType::StackedBar => "Stacked Bar",
|
||
ChartType::PercentBar => "100% Stacked Bar",
|
||
ChartType::Line => "Line",
|
||
ChartType::LineMarkers => "Line with Markers",
|
||
ChartType::StackedLine => "Stacked Line",
|
||
ChartType::Area => "Area",
|
||
ChartType::StackedArea => "Stacked Area",
|
||
ChartType::Pie => "Pie",
|
||
ChartType::Doughnut => "Doughnut",
|
||
ChartType::Scatter => "Scatter",
|
||
ChartType::Bubble => "Bubble",
|
||
ChartType::Radar => "Radar",
|
||
ChartType::FilledRadar => "Filled Radar",
|
||
ChartType::Stock => "Stock",
|
||
ChartType::Surface => "Surface",
|
||
ChartType::Histogram => "Histogram",
|
||
ChartType::Pareto => "Pareto",
|
||
ChartType::BoxWhisker => "Box and Whisker",
|
||
ChartType::Waterfall => "Waterfall",
|
||
ChartType::Funnel => "Funnel",
|
||
ChartType::Treemap => "Treemap",
|
||
ChartType::Sunburst => "Sunburst",
|
||
ChartType::Combo => "Combo",
|
||
}
|
||
}
|
||
pub fn is_bar_like(self) -> bool {
|
||
matches!(
|
||
self,
|
||
ChartType::Column
|
||
| ChartType::StackedColumn
|
||
| ChartType::PercentColumn
|
||
| ChartType::Bar
|
||
| ChartType::StackedBar
|
||
| ChartType::PercentBar
|
||
| ChartType::Histogram
|
||
| ChartType::Pareto
|
||
| ChartType::Waterfall
|
||
)
|
||
}
|
||
pub fn horizontal(self) -> bool {
|
||
matches!(self, ChartType::Bar | ChartType::StackedBar | ChartType::PercentBar | ChartType::Funnel)
|
||
}
|
||
pub fn stacked(self) -> bool {
|
||
matches!(
|
||
self,
|
||
ChartType::StackedColumn
|
||
| ChartType::StackedBar
|
||
| ChartType::StackedLine
|
||
| ChartType::StackedArea
|
||
| ChartType::PercentColumn
|
||
| ChartType::PercentBar
|
||
)
|
||
}
|
||
pub fn percent(self) -> bool {
|
||
matches!(self, ChartType::PercentColumn | ChartType::PercentBar)
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct Series {
|
||
pub name: String,
|
||
pub values: Vec<Option<f64>>,
|
||
/// X values for scatter/bubble.
|
||
pub x_values: Vec<Option<f64>>,
|
||
pub sizes: Vec<Option<f64>>,
|
||
pub fill: Option<Fill>,
|
||
pub line: Option<Line>,
|
||
/// Per-point fill overrides (pie slices).
|
||
pub point_fills: Vec<(usize, Fill)>,
|
||
pub data_labels: Option<bool>,
|
||
pub smooth: bool,
|
||
pub marker: Option<String>,
|
||
/// Combo charts: this series' own type.
|
||
pub kind: Option<ChartType>,
|
||
pub secondary_axis: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct Chart {
|
||
pub kind: ChartType,
|
||
pub title: Option<String>,
|
||
pub categories: Vec<String>,
|
||
pub series: Vec<Series>,
|
||
pub legend: Option<String>,
|
||
/// `c:legend/c:overlay`: the legend is drawn over the plot instead of shrinking it.
|
||
pub legend_overlay: bool,
|
||
pub data_labels: bool,
|
||
pub gridlines: bool,
|
||
pub axis_titles: (Option<String>, Option<String>),
|
||
/// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…).
|
||
pub style: u32,
|
||
pub palette: String,
|
||
pub plot_fill: Option<Fill>,
|
||
pub chart_fill: Option<Fill>,
|
||
pub text_color: Option<ColorRef>,
|
||
pub hole_size: f64,
|
||
pub gap_width: f64,
|
||
pub overlap: f64,
|
||
pub vary_colors: bool,
|
||
pub y_min: Option<f64>,
|
||
pub y_max: Option<f64>,
|
||
/// Original chart XML part kept for round-trip of unmodelled options.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub raw: Option<String>,
|
||
}
|
||
|
||
impl Default for Chart {
|
||
fn default() -> Self {
|
||
Chart {
|
||
kind: ChartType::Column,
|
||
title: Some("Chart Title".into()),
|
||
categories: vec![],
|
||
series: vec![],
|
||
legend: Some("b".into()),
|
||
legend_overlay: false,
|
||
data_labels: false,
|
||
gridlines: true,
|
||
axis_titles: (None, None),
|
||
style: 2,
|
||
palette: "colorful1".into(),
|
||
plot_fill: None,
|
||
chart_fill: None,
|
||
text_color: None,
|
||
hole_size: 0.5,
|
||
gap_width: 2.19,
|
||
overlap: -0.27,
|
||
vary_colors: false,
|
||
y_min: None,
|
||
y_max: None,
|
||
raw: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Chart {
|
||
/// The sample data a new chart starts with (our own numbers).
|
||
pub fn sample(kind: ChartType) -> Self {
|
||
let cats = ["Q1", "Q2", "Q3", "Q4"];
|
||
let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]];
|
||
let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() };
|
||
let n_series =
|
||
if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 };
|
||
for s in 0..n_series {
|
||
c.series.push(Series {
|
||
name: format!("Series {}", s + 1),
|
||
values: data.iter().map(|row| row.get(s).copied()).collect(),
|
||
x_values: if kind == ChartType::Scatter { (1..=4).map(|x| Some(x as f64)).collect() } else { vec![] },
|
||
..Default::default()
|
||
});
|
||
}
|
||
c.vary_colors = n_series == 1;
|
||
c
|
||
}
|
||
/// A series' own chart type: its own in a combo chart (columns when it names none), else the
|
||
/// chart's.
|
||
pub fn series_kind(&self, s: &Series) -> ChartType {
|
||
match (self.kind, s.kind) {
|
||
(ChartType::Combo, Some(k)) if k != ChartType::Combo => k,
|
||
(ChartType::Combo, _) => ChartType::Column,
|
||
(k, _) => k,
|
||
}
|
||
}
|
||
|
||
/// Whether a series is plotted against the secondary value axis (combo charts only).
|
||
pub fn on_secondary(&self, s: &Series) -> bool {
|
||
self.kind == ChartType::Combo && s.secondary_axis
|
||
}
|
||
|
||
/// The value range of one value axis: the primary axis (`y_min`/`y_max` apply), or a combo
|
||
/// chart's secondary axis. A combo chart stacks only its stacked series. `None` when no series
|
||
/// uses that axis.
|
||
pub fn axis_range(&self, secondary: bool) -> Option<(f64, f64)> {
|
||
if self.kind != ChartType::Combo {
|
||
return (!secondary).then(|| self.value_range());
|
||
}
|
||
let ser: Vec<&Series> = self.series.iter().filter(|s| self.on_secondary(s) == secondary).collect();
|
||
if ser.is_empty() {
|
||
return None;
|
||
}
|
||
let (mut lo, mut hi) = (0.0_f64, 0.0_f64);
|
||
let n = ser.iter().map(|s| s.values.len()).max().unwrap_or(0);
|
||
let mut percent = false;
|
||
for i in 0..n {
|
||
let (mut p, mut m) = (0.0, 0.0);
|
||
for s in &ser {
|
||
let k = self.series_kind(s);
|
||
let Some(v) = s.values.get(i).copied().flatten().filter(|v| v.is_finite()) else { continue };
|
||
if k.percent() {
|
||
percent = true;
|
||
} else if k.stacked() {
|
||
if v >= 0.0 { p += v } else { m += v }
|
||
} else {
|
||
hi = hi.max(v);
|
||
lo = lo.min(v);
|
||
}
|
||
}
|
||
hi = hi.max(p);
|
||
lo = lo.min(m);
|
||
}
|
||
if percent {
|
||
hi = hi.max(1.0);
|
||
}
|
||
Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) })
|
||
}
|
||
|
||
/// Value range across series (stacked sums for stacked types).
|
||
pub fn value_range(&self) -> (f64, f64) {
|
||
let mut lo: f64 = 0.0;
|
||
let mut hi: f64 = 0.0;
|
||
let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0));
|
||
if self.kind.stacked() {
|
||
for i in 0..n {
|
||
let (mut p, mut m) = (0.0, 0.0);
|
||
for s in &self.series {
|
||
let v = s.values.get(i).copied().flatten().unwrap_or(0.0);
|
||
if v >= 0.0 { p += v } else { m += v }
|
||
}
|
||
hi = hi.max(p);
|
||
lo = lo.min(m);
|
||
}
|
||
} else {
|
||
for s in &self.series {
|
||
for v in s.values.iter().flatten() {
|
||
if v.is_finite() {
|
||
hi = hi.max(*v);
|
||
lo = lo.min(*v);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if self.kind.percent() {
|
||
return (0.0, 1.0);
|
||
}
|
||
(self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi))
|
||
}
|
||
}
|
||
|
||
/// "Nice" axis scale: (min, max, major step) covering [lo, hi].
|
||
pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
|
||
let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) };
|
||
let span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo };
|
||
let raw = span / 5.0;
|
||
let mag = 10f64.powf(raw.log10().floor());
|
||
let norm = raw / mag;
|
||
let step = if norm <= 1.0 {
|
||
1.0
|
||
} else if norm <= 2.0 {
|
||
2.0
|
||
} else if norm <= 2.5 {
|
||
2.5
|
||
} else if norm <= 5.0 {
|
||
5.0
|
||
} else {
|
||
10.0
|
||
} * mag;
|
||
let min = (lo / step).floor() * step;
|
||
let max = (hi / step).ceil() * step;
|
||
let max = if (max - min).abs() < 1e-12 { min + step } else { max };
|
||
(min, max, step)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn nice_scale_covers() {
|
||
let (a, b, s) = nice_scale(0.0, 5.1);
|
||
assert!(a <= 0.0 && b >= 5.1 && s > 0.0);
|
||
let (a, b, _) = nice_scale(f64::NAN, 3.0);
|
||
assert!(a.is_finite() && b.is_finite());
|
||
let (a, b, _) = nice_scale(7.0, 7.0);
|
||
assert!(b > a);
|
||
}
|
||
|
||
#[test]
|
||
fn combo_axis_ranges() {
|
||
// Spend in columns on the primary axis, clicks as a line on the secondary one.
|
||
let mut c = Chart::sample(ChartType::Combo);
|
||
c.series.truncate(2);
|
||
c.series[0].kind = Some(ChartType::Column);
|
||
c.series[0].values = vec![Some(200_000.0), Some(310_000.0)];
|
||
c.series[1].kind = Some(ChartType::Line);
|
||
c.series[1].values = vec![Some(6_000.0), Some(7_400.0)];
|
||
c.series[1].secondary_axis = true;
|
||
assert_eq!(c.axis_range(false), Some((0.0, 310_000.0)));
|
||
assert_eq!(c.axis_range(true), Some((0.0, 7_400.0)));
|
||
assert_eq!(c.series_kind(&c.series[1]), ChartType::Line);
|
||
// Without a secondary series there is no secondary axis; a non-combo chart never has one.
|
||
c.series[1].secondary_axis = false;
|
||
assert_eq!(c.axis_range(true), None);
|
||
let col = Chart::sample(ChartType::Column);
|
||
assert_eq!(col.axis_range(false), Some(col.value_range()));
|
||
assert_eq!(col.axis_range(true), None);
|
||
assert!(!col.on_secondary(&col.series[0]));
|
||
}
|
||
|
||
#[test]
|
||
fn sample_ranges() {
|
||
let c = Chart::sample(ChartType::StackedColumn);
|
||
let (_, hi) = c.value_range();
|
||
assert!(hi > 10.0);
|
||
assert_eq!(Chart::sample(ChartType::Pie).series.len(), 1);
|
||
}
|
||
}
|