Files
deckcraft/crates/model/src/chart.rs
T
Магда М.Г. 38b9e12cd1 Charts: axis number formats, automatic major unit, hidden axes
Value axes showed raw numbers ("800000") with a fixed ~5-interval scale and
fixed label gutters, deleted axes were still drawn, and data labels ignored
their number format, so Russian business charts read "516600000" where
PowerPoint shows "516 600 000 ₽".

- New deckcraft_model::numfmt: Excel number format codes (sections for
  positive/negative/zero, quoted and escaped literals, _x padding, *x fill,
  [$sym-lcid] currency, [colour]/conditions dropped, 0/#/? placeholders,
  grouping with ',' or the locale's literal space, trailing-comma scaling,
  %, General) with digit grouping and decimal mark from the chart's
  language (c:lang: ru-RU -> "800 000", "12,5%").
- Reading: each value axis' numFmt, c:delete, scaling min/max and
  majorUnit, for the primary and a combo chart's secondary axis; data
  labels' numFmt (series, then group, else the source data's formatCode);
  the chart's c:lang. The primary value axis is the one on the left
  (bottom) and the secondary the one on the right/top or crossing at max,
  whatever the plot groups' order.
- Scale (Chart::axis_scale): fixed bounds and unit where given, else the
  data plus 5% headroom rounded out to the smallest 1-2-5 unit that leaves
  as many intervals as the axis has room for labels, as PowerPoint does
  (0-800 000 by 100 000, 0-7 000 by 1 000 on the sample deck).
- Rendering: labels in the axis format, gutters sized to the longest label,
  deleted axes draw no labels and take no room, data labels in their own
  format.
2026-10-13 04:16:07 -04:00

504 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
/// The number format of the series' data labels (Excel code): the labels' own, else the
/// source data's.
#[serde(skip_serializing_if = "Option::is_none")]
pub number_format: Option<String>,
}
#[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>,
/// The value axis' major unit, when the chart fixes it.
pub y_major: Option<f64>,
/// The value axis' number format (an Excel format code such as `#,##0` or `0.0%`).
pub y_format: Option<String>,
/// The value axis is deleted (`c:delete`): no labels, though gridlines may stay.
pub y_hidden: bool,
/// A combo chart's secondary value axis: bounds, major unit and number format.
pub y2_min: Option<f64>,
pub y2_max: Option<f64>,
pub y2_major: Option<f64>,
pub y2_format: Option<String>,
pub y2_hidden: bool,
/// The chart's language (`c:lang`, e.g. `ru-RU`): digit grouping and decimal mark.
pub lang: Option<String>,
/// 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,
y_major: None,
y_format: None,
y_hidden: false,
y2_min: None,
y2_max: None,
y2_major: None,
y2_format: None,
y2_hidden: false,
lang: 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)) })
}
/// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals:
/// the bounds and unit the chart fixes, else PowerPoint's automatic ones (the data plus 5 %
/// headroom, rounded out to a 1-2-5 unit). `None` when no series uses the axis.
pub fn axis_scale(&self, secondary: bool, max_intervals: usize) -> Option<(f64, f64, f64)> {
let (lo, hi) = self.axis_range(secondary)?;
let (fmin, fmax, major) = if secondary { (self.y2_min, self.y2_max, self.y2_major) } else { (self.y_min, self.y_max, self.y_major) };
let full_percent = !secondary && self.kind.percent();
Some(axis_scale(lo, hi, fmin, fmax, major, max_intervals, !full_percent))
}
/// A value axis label in the axis' number format and the chart's language.
pub fn axis_label(&self, secondary: bool, v: f64) -> String {
let code = if secondary { &self.y2_format } else { &self.y_format };
crate::numfmt::format(code.as_deref().unwrap_or("General"), v, self.lang.as_deref())
}
/// A data label of series `s`: its number format, else its axis', in the chart's language.
pub fn data_label(&self, s: &Series, v: f64) -> String {
let axis = if self.on_secondary(s) { &self.y2_format } else { &self.y_format };
let code = s.number_format.as_ref().or(axis.as_ref());
crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref())
}
/// 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))
}
}
/// The (min, max, major unit) of an axis over the data range [lo, hi]: `fmin`/`fmax`/`major`
/// where the chart fixes them; otherwise the range takes in zero, gets 5 % headroom (`headroom`)
/// and rounds out to the smallest 1-2-5 unit giving at most `max_intervals` intervals.
pub fn axis_scale(
lo: f64,
hi: f64,
fmin: Option<f64>,
fmax: Option<f64>,
major: Option<f64>,
max_intervals: usize,
headroom: bool,
) -> (f64, f64, f64) {
// Values past ±1e100 aren't axis values; clamping keeps the arithmetic finite.
let fin = |v: Option<f64>| v.filter(|v| v.is_finite()).map(|v| v.clamp(-1e100, 1e100));
let (fmin, fmax) = (fin(fmin), fin(fmax));
let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi).clamp(-1e100, 1e100), lo.max(hi).clamp(-1e100, 1e100)) } else { (0.0, 1.0) };
let mut lo = fmin.unwrap_or(lo.min(0.0));
let mut hi = fmax.unwrap_or(hi.max(lo));
if hi - lo < 1e-12 {
hi = lo + lo.abs().max(1.0);
}
let span = hi - lo;
if headroom && fmax.is_none() && hi > 0.0 {
hi += span * 0.05;
}
if headroom && fmin.is_none() && lo < 0.0 {
lo -= span * 0.05;
}
let n = max_intervals.clamp(1, 50) as f64;
let step = match fin(major).filter(|m| *m > 0.0 && (hi - lo) / m <= 1000.0) {
Some(m) => m,
None => {
let mut step = 10f64.powf(((hi - lo) / n).log10().floor());
let mut k = 0;
// 1, 2, 5, 10, 20… × the magnitude until the intervals fit.
while ((hi / step).ceil() - (lo / step).floor()) > n && k < 30 {
step *= if matches!(k % 3, 1) { 2.5 } else { 2.0 };
k += 1;
}
step
}
};
let min = fmin.unwrap_or((lo / step + 1e-9).floor() * step);
let max = fmax.unwrap_or((hi / step - 1e-9).ceil() * step).max(min + step);
(min, max, step)
}
/// "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 automatic_axis_scales_like_powerpoint() {
// Weekly spend up to ~715 000 on a plot with room for 9 intervals: 0–800 000 by 100 000.
assert_eq!(axis_scale(0.0, 715_000.0, None, None, None, 9, true), (0.0, 800_000.0, 100_000.0));
// Clicks up to 6 600: 0–7 000 by 1 000.
assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 9, true), (0.0, 7_000.0, 1_000.0));
// Fewer intervals fit on a short axis.
assert_eq!(axis_scale(0.0, 6_600.0, None, None, None, 4, true), (0.0, 8_000.0, 2_000.0));
// Negative data gets room below; fixed bounds and unit are kept.
let (min, max, _) = axis_scale(-30.0, 100.0, None, None, None, 8, true);
assert!(min <= -30.0 && max >= 105.0);
assert_eq!(axis_scale(0.0, 715_000.0, Some(100_000.0), Some(750_000.0), Some(50_000.0), 9, true), (100_000.0, 750_000.0, 50_000.0));
// 100 % stacked stays at 0–1 without headroom.
assert_eq!(axis_scale(0.0, 1.0, None, None, None, 5, false), (0.0, 1.0, 0.2));
// Nonsense never loops or divides by zero.
for (lo, hi) in [(f64::NAN, 1.0), (5.0, 5.0), (0.0, 0.0), (-1e300, 1e300)] {
let (a, b, s) = axis_scale(lo, hi, None, None, Some(0.0), 0, true);
assert!(a.is_finite() && b.is_finite() && s > 0.0 && b > a, "{lo} {hi}: {a} {b} {s}");
}
}
#[test]
fn axis_labels_use_the_format_and_language() {
let mut c = Chart::sample(ChartType::Column);
c.y_format = Some(r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#.into());
c.lang = Some("ru-RU".into());
assert_eq!(c.axis_label(false, 800_000.0), "800\u{a0}000 ₽");
assert_eq!(c.axis_label(false, 0.0), "- ₽");
// No format: General.
assert_eq!(c.axis_label(true, 2.5), "2,5");
c.lang = None;
assert_eq!(c.axis_label(true, 2.5), "2.5");
// Data labels: the series' format, else the axis'.
c.lang = Some("ru-RU".into());
c.series[0].number_format = Some(r#"#,##0\ "₽""#.into());
assert_eq!(c.data_label(&c.series[0], 516_600_000.0), "516\u{a0}600\u{a0}000 ₽");
assert_eq!(c.data_label(&c.series[1], 0.0), "- ₽");
}
#[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);
}
}