Files
deckcraft/crates/model/src/chart.rs
T
Dylan M. TaylorandClaude Opus 5.5 fc1f475e50 fix: restore compile and behaviour in Sonar refactors
- fonts: char::is_alphanumeric takes &self, so filter needs a closure
- h264: keep the mvd accumulator as i32 (prefix is usize)
- model: annotate the non-stacked range accumulators as f64
- engine: chart.options still clears the raw chart XML for upDownBars
  and quartiles; smartart goes back to the glob import (cmd! macro)
- web: keep the original viewport meta (behaviour change)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:35:02 +00:00

1500 lines
62 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,
/// A filled (choropleth) map of regions coloured by value, kept and drawn from its data; it
/// is not offered in the chart menus (PowerPoint needs an online map service to make one).
RegionMap,
Combo,
}
impl ChartType {
/// The Office 2016 (`chartEx`) types, offered after [`ChartType::MAIN`] in chart menus.
pub const OFFICE_2016: [ChartType; 7] = [
ChartType::Waterfall,
ChartType::Funnel,
ChartType::Histogram,
ChartType::Pareto,
ChartType::BoxWhisker,
ChartType::Treemap,
ChartType::Sunburst,
];
/// Whether the type is saved as a `chartEx` part (with a plain chart as the fallback).
pub fn is_chartex(self) -> bool {
Self::OFFICE_2016.contains(&self) || self == ChartType::RegionMap
}
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::RegionMap => "Map",
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)
}
}
/// `c:trendlineType`: the six trendlines DrawingML charts store.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TrendlineType {
Exp,
#[default]
Linear,
Log,
MovingAvg,
Poly,
Power,
}
impl TrendlineType {
pub fn xml(self) -> &'static str {
match self {
TrendlineType::Exp => "exp",
TrendlineType::Linear => "linear",
TrendlineType::Log => "log",
TrendlineType::MovingAvg => "movingAvg",
TrendlineType::Poly => "poly",
TrendlineType::Power => "power",
}
}
pub fn from_xml(s: &str) -> Option<Self> {
Some(match s {
"exp" => TrendlineType::Exp,
"linear" => TrendlineType::Linear,
"log" => TrendlineType::Log,
"movingAvg" => TrendlineType::MovingAvg,
"poly" => TrendlineType::Poly,
"power" => TrendlineType::Power,
_ => return None,
})
}
}
/// One `c:trendline` on a series. Polynomials use `order` (2–6); moving averages use `period`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Trendline {
pub kind: TrendlineType,
/// `c:name`, when the file sets one.
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Polynomial order (`c:order`).
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<u32>,
/// Moving-average period (`c:period`).
#[serde(skip_serializing_if = "Option::is_none")]
pub period: Option<u32>,
/// Forecast forward and backward, in category units (`c:forward`, `c:backward`).
#[serde(skip_serializing_if = "Option::is_none")]
pub forward: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub backward: Option<f64>,
/// Cross the value axis at this value (`c:intercept`).
#[serde(skip_serializing_if = "Option::is_none")]
pub intercept: Option<f64>,
/// Show the equation (`c:dispEq`).
pub disp_eq: bool,
/// Show R² (`c:dispRSqr`).
pub disp_r_sqr: bool,
}
impl Default for Trendline {
fn default() -> Self {
Trendline {
kind: TrendlineType::Linear,
name: None,
order: None,
period: None,
forward: None,
backward: None,
intercept: None,
disp_eq: false,
disp_r_sqr: false,
}
}
}
#[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>,
/// Trendlines (`c:trendline`), in file order.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub trendlines: Vec<Trendline>,
/// `c:errBars` on the values (y bars; a bar chart's run along its value axis).
#[serde(skip_serializing_if = "Option::is_none")]
pub error_bars: Option<ErrorBars>,
}
/// Error bars (`c:errBars`): how far each point's bar reaches below and above it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ErrorBars {
/// `both`, `plus` or `minus`.
pub bar_type: String,
/// `fixedVal`, `percentage`, `stdDev`, `stdErr` or `cust`.
pub val_type: String,
pub value: f64,
/// `cust`: the per-point plus and minus amounts.
pub plus: Vec<Option<f64>>,
pub minus: Vec<Option<f64>>,
pub end_cap: bool,
}
impl Default for ErrorBars {
fn default() -> Self {
ErrorBars { bar_type: "both".into(), val_type: "fixedVal".into(), value: 1.0, plus: vec![], minus: vec![], end_cap: true }
}
}
/// Count, mean and sample standard deviation of a series' finite values (for error bars).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SeriesStats {
pub n: f64,
pub mean: f64,
pub sd: f64,
}
impl SeriesStats {
pub fn of(values: &[Option<f64>]) -> Self {
let vals = || values.iter().flatten().copied().filter(|v| v.is_finite());
let n = vals().count() as f64;
let mean = vals().sum::<f64>() / n.max(1.0);
let sd = if n > 1.0 { (vals().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0)).sqrt() } else { 0.0 };
SeriesStats { n, mean, sd }
}
}
impl ErrorBars {
/// Where point `i` (value `v`) of a series with `values` has its bar: (low, high) in data units.
/// Standard deviation bars stand around the series' mean, as Excel draws them; the others
/// around the point. `None` for an amount that isn't a finite number.
pub fn range(&self, values: &[Option<f64>], i: usize, v: f64) -> Option<(f64, f64)> {
self.range_in(&SeriesStats::of(values), i, v)
}
/// [`ErrorBars::range`] with the series' statistics worked out once ([`SeriesStats::of`]), so
/// drawing every point of a long series stays linear.
pub fn range_in(&self, stats: &SeriesStats, i: usize, v: f64) -> Option<(f64, f64)> {
let SeriesStats { n, mean, sd } = *stats;
let (centre, minus, plus) = match self.val_type.as_str() {
"percentage" => (v, (v * self.value / 100.0).abs(), (v * self.value / 100.0).abs()),
"stdDev" => (mean, sd * self.value, sd * self.value),
"stdErr" => (v, sd / n.max(1.0).sqrt(), sd / n.max(1.0).sqrt()),
"cust" => (v, self.minus.get(i).copied().flatten().unwrap_or(0.0), self.plus.get(i).copied().flatten().unwrap_or(0.0)),
_ => (v, self.value, self.value),
};
let (lo, hi) = match self.bar_type.as_str() {
"plus" => (centre, centre + plus),
"minus" => (centre - minus, centre),
_ => (centre - minus, centre + plus),
};
(lo.is_finite() && hi.is_finite()).then_some((lo, hi))
}
}
#[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,
/// Text size in points of the axis labels and legend (from their `c:txPr`, else the chart's);
/// `None`: the file gives none.
#[serde(skip_serializing_if = "Option::is_none")]
pub text_size: Option<f64>,
/// Title text size in points (its rich text or `c:txPr`).
#[serde(skip_serializing_if = "Option::is_none")]
pub title_size: Option<f64>,
pub data_labels: bool,
pub gridlines: bool,
/// `c:minorGridlines` on the value axis: five to a major unit, between the major ones.
pub minor_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>,
/// `c:scaling/c:logBase`: the value axis is logarithmic in this base (2–1000).
pub y_log_base: 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,
/// The category axis runs backwards (`c:orientation val="maxMin"`): right to left, or on a
/// horizontal bar chart top to bottom (the default puts the first category at the bottom).
pub cat_reversed: 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>,
/// A waterfall's total points (`cx:subtotals`): bars that stand on the axis and restart
/// the running total, instead of floating.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub subtotals: Vec<usize>,
/// Outer category levels of a treemap or sunburst, innermost first (the leaves' parents,
/// then theirs…), each with one label per category. A blank label continues the one above it.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub category_levels: Vec<Vec<String>>,
/// Box and whisker quartiles by the inclusive method (QUARTILE.INC) instead of exclusive.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub inclusive_quartiles: bool,
/// A stock chart's up/down bars (`c:upDownBars`): open-high-low-close, the box between
/// the first and last series, hollow when the price rose.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub up_down_bars: bool,
/// A histogram's bin count (`cx:binCount`); with neither this nor `bin_size` the bins are
/// automatic (Scott's rule, as Excel does).
#[serde(skip_serializing_if = "Option::is_none")]
pub bin_count: Option<u32>,
/// A histogram's bin width (`cx:binSize`).
#[serde(skip_serializing_if = "Option::is_none")]
pub bin_size: Option<f64>,
/// Original chart XML part kept for round-trip of unmodelled options.
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
/// Package parts the original chart still points at (embedded workbook, styles, drawings).
/// The PPTX writer clears this before comparing modelled state. Native saves keep it too.
#[serde(skip_serializing_if = "ChartKept::is_empty")]
pub kept: ChartKept,
}
/// One relationship of a chart part, with the target string as it was written.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChartRel {
pub id: String,
pub rel_type: String,
pub target: String,
/// Absolute name of the part an internal relationship reaches (matches a `ChartPart::name`
/// up to case); empty for external ones. The writer renames kept parts and uses this to
/// point the relationship at the new name.
pub part: String,
pub external: bool,
}
/// A part reached from a chart (the workbook, a drawing, an image it uses).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChartPart {
/// Absolute part name (`ppt/embeddings/Workbook.xlsx`).
pub name: String,
pub content_type: String,
/// Runtime bytes; older native files stored these inline in the manifest.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub bytes: Vec<u8>,
/// Native ZIP member while encoding/decoding. The native reader consumes this reference.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native_entry: Option<String>,
}
/// Sidecar for an unchanged chart: relationships and the parts they reach.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ChartKept {
pub rels: Vec<ChartRel>,
pub parts: Vec<ChartPart>,
}
impl ChartKept {
fn is_empty(&self) -> bool {
self.rels.is_empty() && self.parts.is_empty()
}
}
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,
text_size: None,
title_size: None,
data_labels: false,
gridlines: true,
minor_gridlines: false,
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_log_base: None,
y_format: None,
y_hidden: false,
cat_reversed: false,
y2_min: None,
y2_max: None,
y2_major: None,
y2_format: None,
y2_hidden: false,
lang: None,
subtotals: vec![],
category_levels: vec![],
inclusive_quartiles: false,
up_down_bars: false,
bin_count: None,
bin_size: None,
raw: None,
kept: ChartKept::default(),
}
}
}
impl Chart {
fn sample_treemap_sunburst(kind: ChartType) -> Self {
let mut c = Chart {
kind,
categories: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(ToString::to_string).collect(),
category_levels: vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(ToString::to_string).collect()],
vary_colors: true,
..Default::default()
};
let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0];
c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() });
c
}
fn sample_observation_chart(kind: ChartType) -> Self {
let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0];
let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 };
let categories = if kind == ChartType::BoxWhisker {
(0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect()
} else {
vec![]
};
let mut c =
Chart { kind, categories, gridlines: true, legend: (kind == ChartType::BoxWhisker).then(|| "b".to_string()), ..Default::default() };
for s in 0..n_series {
c.series.push(Series {
name: format!("Series {}", s + 1),
values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(),
..Default::default()
});
}
c
}
fn sample_stock() -> Self {
let mut c = Chart {
kind: ChartType::Stock,
categories: ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(ToString::to_string).collect(),
..Default::default()
};
let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])];
for (name, v) in hlc {
c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() });
}
c
}
/// The sample data a new chart starts with (our own numbers).
pub fn sample(kind: ChartType) -> Self {
if matches!(kind, ChartType::Treemap | ChartType::Sunburst) {
return Self::sample_treemap_sunburst(kind);
}
if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) {
return Self::sample_observation_chart(kind);
}
if kind == ChartType::Stock {
return Self::sample_stock();
}
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(ToString::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;
if kind == ChartType::Combo {
// Columns with the last series as a line, as PowerPoint's combo starts.
for (i, s) in c.series.iter_mut().enumerate() {
s.kind = Some(if i + 1 == n_series { ChartType::Line } else { ChartType::Column });
}
}
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 (lo, hi) = self.plotted_extent(secondary)?;
let (lo, hi) = (lo.min(0.0), hi.max(0.0));
Some(if secondary { (lo, hi) } else { (self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi)) })
}
/// The smallest and largest value plotted against one value axis: plain values as they are,
/// stacked series as running sums from zero, 100 % series as 0–1. `(0, 0)` without finite
/// values; `None` when no series uses the axis.
fn plotted_extent(&self, secondary: bool) -> Option<(f64, f64)> {
if self.kind == ChartType::Waterfall {
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 (!secondary && self.kind != ChartType::Combo).then_some((0.0, 0.0));
}
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
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, mut stacked) = (0.0, 0.0, false);
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() {
stacked = true;
if v >= 0.0 { p += v } else { m += v }
} else {
hi = hi.max(v);
lo = lo.min(v);
}
}
if stacked {
hi = hi.max(p);
lo = lo.min(m);
}
}
if !percent {
for s in &ser {
if self.series_kind(s).stacked() {
continue;
}
for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) {
hi = hi.max(y);
lo = lo.min(y);
}
}
}
if percent {
lo = lo.min(0.0);
hi = hi.max(1.0);
}
Some(if lo <= hi { (lo, hi) } else { (0.0, 0.0) })
}
/// A waterfall's bars as (from, to) on the value axis: each point floats from the running
/// total, a total point stands on zero and sets it.
pub fn waterfall_bars(&self) -> Vec<(f64, f64)> {
let Some(s) = self.series.first() else { return vec![] };
let mut run = 0.0;
s.values
.iter()
.enumerate()
.map(|(i, v)| {
let v = v.unwrap_or(0.0);
if self.subtotals.contains(&i) {
run = v;
(0.0, v)
} else {
let from = run;
run += v;
(from, run)
}
})
.collect()
}
/// A value axis' scale — (min, max, major unit) — with at most `max_intervals` intervals:
/// the bounds and unit the chart fixes, else Office's automatic ones ([`axis_scale`](fn@axis_scale)).
/// `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.plotted_extent(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 logarithmic value axis' scale in exponents — (min, max, unit), so its labels are
/// `base^min`, `base^(min+unit)`… — covering the positive values: whole powers, as PowerPoint
/// and LibreOffice draw them. `None` when the axis isn't logarithmic or nothing is positive.
pub fn log_axis_scale(&self, max_intervals: usize) -> Option<(f64, f64, f64)> {
let b = self.y_log_base.filter(|b| b.is_finite() && *b >= 2.0)?;
let vals = self.series.iter().filter(|s| !self.on_secondary(s)).flat_map(|s| s.values.iter().flatten().copied());
let (lo, hi) = vals
.filter(|v| v.is_finite() && *v > 0.0)
.fold(None, |r: Option<(f64, f64)>, v| Some(r.map_or((v, v), |(a, z)| (a.min(v), z.max(v)))))?;
let e = |v: f64| v.log(b);
let lo = self.y_min.filter(|v| *v > 0.0).map_or(e(lo).floor(), e);
let hi = self.y_max.filter(|v| *v > 0.0).map_or(e(hi).ceil(), e).max(lo + 1.0);
let unit = ((hi - lo) / max_intervals.max(1) as f64).ceil().max(1.0);
Some((lo, lo + ((hi - lo) / unit).ceil() * unit, unit))
}
/// 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 };
// A 100 % stacked chart's value axis counts in percent unless the file says otherwise.
let code = match code.as_deref() {
None | Some("General") if !secondary && self.kind.percent() => "0%",
c => c.unwrap_or("General"),
};
crate::numfmt::format(code, 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())
}
fn waterfall_value_range(&self) -> (f64, f64) {
let mut lo: f64 = 0.0;
let mut hi: f64 = 0.0;
for (a, b) in self.waterfall_bars() {
lo = lo.min(a.min(b));
hi = hi.max(a.max(b));
}
(lo, hi)
}
fn stacked_value_range(&self, n: usize) -> (f64, f64) {
let mut lo: f64 = 0.0;
let mut hi: f64 = 0.0;
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);
}
(lo, hi)
}
/// Value range across series (stacked sums for stacked types).
pub fn value_range(&self) -> (f64, f64) {
if self.kind == ChartType::Waterfall {
return self.waterfall_value_range();
}
let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0));
let (mut lo, mut hi) = if self.kind.stacked() {
self.stacked_value_range(n)
} else {
let mut l: f64 = 0.0;
let mut h: f64 = 0.0;
for s in &self.series {
for v in s.values.iter().flatten() {
if v.is_finite() {
h = h.max(*v);
l = l.min(*v);
}
}
}
(l, h)
};
if self.kind.percent() {
return (0.0, 1.0);
}
if !self.kind.stacked() {
for s in &self.series {
for y in s.trendline_ys(crate::trendline::series_is_scatter(self.series_kind(s))) {
hi = hi.max(y);
lo = lo.min(y);
}
}
}
(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 Office's automatic limits, rounded out to the smallest
/// 1-2-5 unit giving at most `max_intervals` intervals:
///
/// - **Maximum:** `hi + (hi − lo)/20` (with `headroom`), rounded up to the unit.
/// - **Minimum:** zero when every value is ≥ 0 and `lo` is below 5/6 of `hi`; otherwise
/// `lo − (hi − lo)/20`, rounded down to the unit, so values bunched high get an axis that starts
/// near them.
/// - All-negative values mirror this: the axis ends at zero when `hi` is above 5/6 of `lo`.
/// Mixed signs get headroom at both ends.
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) };
// Office's automatic limits ("How chart axis limits are determined", as Jon Peltier
// summarises it): 5 % of the data range past the data, and zero when the values don't
// sit within the top sixth of their largest magnitude. A single value is measured from zero.
let flat = hi - lo < 1e-12;
let pad = if !headroom {
0.0
} else if flat {
hi.abs() / 20.0
} else {
(hi - lo) / 20.0
};
let auto_lo = if lo >= 0.0 && (flat || lo < hi * 5.0 / 6.0) { 0.0 } else { lo - pad };
let auto_hi = if hi <= 0.0 && (flat || hi > lo * 5.0 / 6.0) { 0.0 } else { hi + pad };
let mut lo = fmin.unwrap_or(auto_lo);
let mut hi = fmax.unwrap_or(auto_hi);
if hi - lo < 1e-12 {
// A fixed limit on the far side of the data: the automatic end gives way.
match (fmin, fmax) {
(None, Some(_)) => lo = if hi > 0.0 { 0.0 } else { hi - hi.abs().max(1.0) },
(Some(_), None) if lo < 0.0 => hi = 0.0,
_ => hi = lo + lo.abs().max(1.0),
}
}
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 log_axes_run_over_whole_powers() {
let mut c = Chart::sample(ChartType::Line);
c.series.truncate(1);
c.series[0].values = vec![Some(1.0), Some(10.0), Some(1000.0), Some(50000.0)];
assert_eq!(c.log_axis_scale(8), None);
c.y_log_base = Some(10.0);
assert_eq!(c.log_axis_scale(8), Some((0.0, 5.0, 1.0)));
assert_eq!(c.log_axis_scale(2), Some((0.0, 6.0, 3.0)));
}
#[test]
fn error_bar_ranges() {
let vals = [Some(3.0), Some(5.0), Some(2.0), Some(4.0)];
let mut e = ErrorBars::default();
assert_eq!(e.range(&vals, 0, 3.0), Some((2.0, 4.0)));
e.val_type = "percentage".into();
e.value = 20.0;
assert_eq!(e.range(&vals, 1, 5.0), Some((4.0, 6.0)));
e.bar_type = "plus".into();
assert_eq!(e.range(&vals, 1, 5.0), Some((5.0, 6.0)));
e = ErrorBars { val_type: "cust".into(), plus: vec![Some(0.5)], minus: vec![Some(1.5)], ..Default::default() };
assert_eq!(e.range(&vals, 0, 3.0), Some((1.5, 3.5)));
e = ErrorBars { val_type: "stdDev".into(), ..Default::default() };
let (lo, hi) = e.range(&vals, 0, 3.0).unwrap();
assert!((lo + hi - 7.0).abs() < 1e-9 && (hi - lo - 2.0 * 1.2909944487358056).abs() < 1e-9);
}
#[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}");
}
}
fn column(values: &[f64]) -> Chart {
let series = Series { name: "Sales".into(), values: values.iter().map(|v| Some(*v)).collect(), ..Default::default() };
Chart { categories: (0..values.len()).map(|i| format!("C{i}")).collect(), series: vec![series], ..Default::default() }
}
/// The primary axis of a chart with room for ten intervals.
fn scale(c: &Chart) -> (f64, f64, f64) {
c.axis_scale(false, 10).unwrap_or_default()
}
/// Office's documented automatic limits (#144), with room for ten intervals.
#[test]
fn automatic_axis_matches_office() {
// #144: 42.1 / 18.7 / 6.3 → 0–45 in steps of 5, not 0–50 in steps of 10.
assert_eq!(scale(&column(&[42.1, 18.7, 6.3])), (0.0, 45.0, 5.0));
// PowerPoint's default chart data (max 5): 0–6 in steps of 1.
let mut c = column(&[4.3, 2.5, 3.5, 4.5]);
c.series.push(Series { values: [2.4, 4.4, 1.8, 2.8].map(Some).to_vec(), ..Default::default() });
c.series.push(Series { values: [2.0, 2.0, 3.0, 5.0].map(Some).to_vec(), ..Default::default() });
assert_eq!(scale(&c), (0.0, 6.0, 1.0));
// A maximum of 100 (or 10) gets 5 % headroom, which rounds up to the next major unit.
assert_eq!(scale(&column(&[100.0, 40.0])), (0.0, 120.0, 20.0));
assert_eq!(scale(&column(&[10.0, 3.0])), (0.0, 12.0, 2.0));
// Peltier's example: 2.95 + 2.95/20 ≈ 3.1 rounds up to 3.5.
assert_eq!(scale(&column(&[2.95, 0.0])), (0.0, 3.5, 0.5));
}
#[test]
fn automatic_axis_minimum() {
// Below 5/6 of the maximum the axis starts at zero; at or above it, a unit below
// min − (max − min)/20.
assert_eq!(scale(&column(&[59.0, 49.0])).0, 0.0);
assert_eq!(scale(&column(&[59.0, 50.0])), (48.0, 60.0, 2.0));
assert_eq!(scale(&column(&[85.0, 92.0, 97.0])), (84.0, 98.0, 2.0));
// All-negative data mirrors it: the axis ends at zero, or near the data.
assert_eq!(scale(&column(&[-42.1, -18.7, -6.3])), (-45.0, 0.0, 5.0));
assert_eq!(scale(&column(&[-59.0, -49.0])).1, 0.0);
assert_eq!(scale(&column(&[-59.0, -50.0])), (-60.0, -48.0, 2.0));
// Mixed signs get headroom at both ends.
assert_eq!(scale(&column(&[42.1, -18.7])), (-30.0, 50.0, 10.0));
// Stacked bars build up from zero, so their axis keeps it.
let mut s = column(&[85.0, 92.0]);
s.kind = ChartType::StackedColumn;
assert_eq!(scale(&s).0, 0.0);
// A combo chart's secondary axis follows the same rule.
let mut c = Chart::sample(ChartType::Combo);
c.series.truncate(2);
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(7_000.0), Some(7_400.0)];
c.series[1].secondary_axis = true;
assert_eq!(c.axis_scale(false, 10), Some((0.0, 350_000.0, 50_000.0)));
assert_eq!(c.axis_scale(true, 10), Some((6_950.0, 7_450.0, 50.0)));
}
#[test]
fn fixed_axis_limits_are_kept() {
let mut c = column(&[42.1, 18.7, 6.3]);
c.y_max = Some(50.0);
assert_eq!(scale(&c), (0.0, 50.0, 5.0));
c.y_min = Some(10.0);
c.y_max = None;
assert_eq!(scale(&c), (10.0, 45.0, 5.0));
// A fixed limit beyond the data: the automatic end gives way to it.
let mut c = column(&[85.0, 92.0, 97.0]);
c.y_max = Some(50.0);
assert_eq!(scale(&c), (0.0, 50.0, 5.0));
let mut c = column(&[-50.0, -48.0]);
c.y_min = Some(-10.0);
assert_eq!(scale(&c), (-10.0, 0.0, 1.0));
}
#[test]
fn percent_axis_is_zero_to_one() {
let mut p = column(&[42.1, 18.7, 6.3]);
p.kind = ChartType::PercentColumn;
assert_eq!(scale(&p), (0.0, 1.0, 0.1));
assert_eq!(p.axis_scale(false, 5), Some((0.0, 1.0, 0.2)));
}
#[test]
fn degenerate_axes_are_finite() {
for (a, b) in [(0.0, 0.0), (7.0, 7.0), (-7.0, -7.0), (f64::NAN, 3.0), (f64::INFINITY, 1.0), (-f64::MAX, f64::MAX), (0.0, 1e-320)] {
let (min, max, step) = scale(&column(&[a, b]));
assert!(min.is_finite() && max > min && step > 0.0, "{a} {b}: {min} {max} {step}");
}
let (min, max, _) = scale(&column(&[7.0, 7.0]));
assert!(min == 0.0 && max > 7.0);
assert_eq!(scale(&column(&[])), (0.0, 1.0, 0.1));
for (fmin, fmax) in [(Some(5.0), Some(5.0)), (Some(5.0), Some(1.0)), (Some(f64::NAN), Some(f64::INFINITY)), (Some(-1e300), None)] {
let (a, b, s) = axis_scale(0.0, 1.0, fmin, fmax, None, 10, true);
assert!(a.is_finite() && b.is_finite() && b > a && s > 0.0, "{fmin:?} {fmax:?}: {a} {b} {s}");
}
}
#[test]
fn general_labels_keep_every_digit_the_data_has() {
// #324: labels were rounded to one decimal ("100000.0" for 99999.99).
for kind in [ChartType::Column, ChartType::Pie, ChartType::Doughnut, ChartType::Line, ChartType::Scatter] {
let c = Chart::sample(kind);
let s = &c.series[0];
for (v, want) in
[(1220.82, "1220.82"), (99999.99, "99999.99"), (1500.05, "1500.05"), (1500.0, "1500"), (1234.567, "1234.567"), (999.99, "999.99")]
{
assert_eq!(c.data_label(s, v), want, "{kind:?}");
}
}
// Small ranges keep distinct value-axis ticks (0.123 used to give 0, 0.0, 0.1, 0.1…).
let c = Chart::sample(ChartType::Column);
let ticks: Vec<String> = (0..=5).map(|i| c.axis_label(false, f64::from(i) * 0.025)).collect();
assert_eq!(ticks, ["0", "0.025", "0.05", "0.075", "0.1", "0.125"]);
}
#[test]
fn a_forecast_grows_the_value_axis() {
let mut c = Chart::sample(ChartType::Line);
c.series.truncate(1);
c.series[0].values = vec![Some(3.0), Some(5.0), Some(7.0), Some(9.0)];
let before = c.value_range().1;
c.series[0].trendlines.push(Trendline { forward: Some(1.0), ..Default::default() });
let after = c.value_range().1;
assert!(after > before && after >= 11.0 - 1e-6, "{before} {after}");
}
#[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 full_percent_axes_count_in_percent() {
let mut c = Chart::sample(ChartType::PercentColumn);
assert_eq!(c.axis_label(false, 0.5), "50%");
c.y_format = Some("0.0%".into());
assert_eq!(c.axis_label(false, 0.5), "50.0%");
let c = Chart::sample(ChartType::Column);
assert_eq!(c.axis_label(false, 0.5), "0.5");
}
#[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);
}
}
/// One histogram bin: `[lo, hi]` (the first bin is closed on both sides, later ones open on the
/// left, as Excel bins) and how many observations fall in it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Bin {
pub lo: f64,
pub hi: f64,
pub count: usize,
}
/// Bins a histogram has at most (a hostile bin size cannot make millions).
pub const MAX_BINS: usize = 1000;
/// A box and whisker summary of one group: quartiles by Excel's exclusive method, whiskers at
/// the furthest points within 1.5 IQR of the box, and the points beyond them.
#[derive(Clone, Debug, PartialEq)]
pub struct BoxStats {
pub low: f64,
pub q1: f64,
pub median: f64,
pub q3: f64,
pub high: f64,
pub mean: f64,
pub outliers: Vec<f64>,
}
impl Chart {
/// The first series' finite observations.
fn observations(&self) -> Vec<f64> {
self.series.first().map(|s| s.values.iter().flatten().copied().filter(|v| v.is_finite()).collect()).unwrap_or_default()
}
/// The histogram's bins over the first series: by `bin_size`, else `bin_count`, else
/// Scott's rule (3.49·σ·n^(-1/3)). Empty without observations.
pub fn histogram_bins(&self) -> Vec<Bin> {
let mut v = self.observations();
if v.is_empty() {
return vec![];
}
v.sort_by(f64::total_cmp);
let (lo, hi) = (v[0], v[v.len() - 1]);
let span = hi - lo;
let n = v.len() as f64;
let width = if let Some(w) = self.bin_size.filter(|w| w.is_finite() && *w > 0.0) {
w
} else if let Some(k) = self.bin_count.filter(|k| *k > 0) {
span / f64::from(k)
} else {
let mean = v.iter().sum::<f64>() / n;
let sd = (v.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0).max(1.0)).sqrt();
3.49 * sd / n.cbrt()
};
let fixed = self.bin_count.filter(|k| *k > 0).filter(|_| self.bin_size.filter(|w| w.is_finite() && *w > 0.0).is_none());
let count = if span <= 0.0 || !width.is_finite() || width <= 0.0 {
1
} else if let Some(k) = fixed {
(k as usize).clamp(1, MAX_BINS)
} else {
((span / width).ceil() as usize).clamp(1, MAX_BINS)
};
let width = if count == 1 && (span <= 0.0 || !width.is_finite() || width <= 0.0) { span.max(1.0) } else { width.max(span / MAX_BINS as f64) };
let mut bins: Vec<Bin> = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect();
for x in v {
// (lo, hi] bins, the first one also holding the minimum.
let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1);
if let Some(b) = bins.get_mut(i) {
b.count += 1;
}
}
bins
}
/// A histogram (or Pareto) as the chart that draws it: a column chart of the bin counts with
/// touching columns, labelled by bin range. A Pareto sorts the bins by count and adds the
/// cumulative percentage as a line on a 0–100 % secondary axis.
pub fn histogram_view(&self) -> Chart {
let mut bins = self.histogram_bins();
let pareto = self.kind == ChartType::Pareto;
if pareto {
bins.sort_by_key(|b| std::cmp::Reverse(b.count));
}
let fmt = |v: f64| {
let r = (v * 100.0).round() / 100.0;
if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") }
};
let first_lo = bins.iter().map(|b| b.lo).fold(f64::INFINITY, f64::min);
let mut v = Chart {
kind: if pareto { ChartType::Combo } else { ChartType::Column },
categories: bins
.iter()
.map(|b| if b.lo <= first_lo { format!("[{}, {}]", fmt(b.lo), fmt(b.hi)) } else { format!("({}, {}]", fmt(b.lo), fmt(b.hi)) })
.collect(),
series: vec![],
gap_width: 0.0,
raw: None,
subtotals: vec![],
..self.clone()
};
let name = self.series.first().map(|s| s.name.clone()).unwrap_or_default();
let base = self.series.first().cloned().unwrap_or_default();
v.series.push(Series {
name,
values: bins.iter().map(|b| Some(b.count as f64)).collect(),
kind: pareto.then_some(ChartType::Column),
number_format: None,
..base
});
if pareto {
let total = bins.iter().map(|b| b.count).sum::<usize>().max(1) as f64;
let mut run = 0.0;
v.series.push(Series {
name: "Cumulative %".into(),
values: bins
.iter()
.map(|b| {
run += b.count as f64;
Some(run / total)
})
.collect(),
kind: Some(ChartType::Line),
secondary_axis: true,
..Default::default()
});
v.y2_min = Some(0.0);
v.y2_max = Some(1.0);
v.y2_major = Some(0.2);
v.y2_format = Some("0%".into());
}
v.vary_colors = false;
v
}
/// The box chart's groups (distinct category labels in order of appearance; one unnamed
/// group without categories) and, per series, each group's summary (None when it has no
/// points).
pub fn box_groups(&self) -> (Vec<String>, Vec<Vec<Option<BoxStats>>>) {
let mut groups: Vec<String> = Vec::new();
for c in &self.categories {
if !groups.contains(c) {
groups.push(c.clone());
}
}
if groups.is_empty() {
groups.push(String::new());
}
let stats = self
.series
.iter()
.map(|s| {
groups
.iter()
.map(|g| {
let pts: Vec<f64> = s
.values
.iter()
.enumerate()
.filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g))
.filter_map(|(_, v)| *v)
.filter(|v| v.is_finite())
.collect();
box_stats_by(pts, self.inclusive_quartiles)
})
.collect()
})
.collect();
(groups, stats)
}
}
/// Excel's QUARTILE.EXC position `p·(n+1)` (1-based) on sorted data, clamped to the ends.
fn quantile_exc(v: &[f64], p: f64) -> f64 {
let n = v.len();
let pos = (p * (n as f64 + 1.0)).clamp(1.0, n as f64);
let i = pos.floor() as usize;
let frac = pos - i as f64;
let a = v[i.saturating_sub(1).min(n - 1)];
let b = v[i.min(n - 1)];
a + (b - a) * frac
}
/// Excel's QUARTILE.INC position `p·(n-1)` (0-based) on sorted data.
fn quantile_inc(v: &[f64], p: f64) -> f64 {
let n = v.len();
let pos = (p * (n as f64 - 1.0)).clamp(0.0, (n - 1) as f64);
let i = pos.floor() as usize;
let a = v[i.min(n - 1)];
let b = v[(i + 1).min(n - 1)];
a + (b - a) * (pos - i as f64)
}
/// The summary of one group's points, with exclusive quartiles.
pub fn box_stats(pts: Vec<f64>) -> Option<BoxStats> {
box_stats_by(pts, false)
}
/// The summary of one group's points; `inclusive` picks QUARTILE.INC over QUARTILE.EXC.
pub fn box_stats_by(mut pts: Vec<f64>, inclusive: bool) -> Option<BoxStats> {
if pts.is_empty() {
return None;
}
pts.sort_by(f64::total_cmp);
let q = |p: f64| if inclusive { quantile_inc(&pts, p) } else { quantile_exc(&pts, p) };
let (q1, median, q3) = (q(0.25), q(0.5), q(0.75));
let iqr = q3 - q1;
let (lo_fence, hi_fence) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr);
let inside: Vec<f64> = pts.iter().copied().filter(|v| *v >= lo_fence && *v <= hi_fence).collect();
let low = inside.first().copied().unwrap_or(q1).min(q1);
let high = inside.last().copied().unwrap_or(q3).max(q3);
let mean = pts.iter().sum::<f64>() / pts.len() as f64;
let outliers = pts.iter().copied().filter(|v| *v < lo_fence || *v > hi_fence).collect();
Some(BoxStats { low, q1, median, q3, high, mean, outliers })
}
/// A node of a treemap or sunburst hierarchy: its label, the total of the leaves under it, and
/// its children (none for a leaf, which names its category index).
#[derive(Clone, Debug, PartialEq)]
pub struct TreeNode {
pub name: String,
pub value: f64,
pub leaf: Option<usize>,
pub children: Vec<TreeNode>,
}
/// Levels of hierarchy a chart may have (a hostile file cannot nest deeper).
pub const MAX_LEVELS: usize = 16;
impl Chart {
/// The first series as a hierarchy: top-level nodes, each holding the consecutive
/// categories that share its label, down to the leaves. Without outer levels every
/// category is a top-level leaf. Negative and missing values count as zero.
pub fn category_tree(&self) -> Vec<TreeNode> {
let Some(s) = self.series.first() else { return vec![] };
let n = self.categories.len().max(s.values.len());
let levels: Vec<&Vec<String>> = self.category_levels.iter().take(MAX_LEVELS).collect();
// Each leaf's path from the top level down, blanks filled from the row above.
let mut last: Vec<String> = vec![String::new(); levels.len()];
let paths: Vec<Vec<String>> = (0..n)
.map(|i| {
let mut path = Vec::with_capacity(levels.len() + 1);
for (d, lv) in levels.iter().enumerate().rev() {
let l = lv.get(i).map(|x| x.trim()).unwrap_or("");
if !l.is_empty() {
last[d] = l.to_string();
// A new outer label starts fresh inner groups.
for inner in last.iter_mut().take(d) {
inner.clear();
}
}
path.push(last[d].clone());
}
path.push(self.categories.get(i).cloned().unwrap_or_default());
path
})
.collect();
let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0);
fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec<TreeNode> {
let mut out: Vec<TreeNode> = Vec::new();
let mut i = 0;
while i < rows.len() {
let (idx, path) = rows[i];
if path.len() <= 1 {
out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] });
i += 1;
continue;
}
let head = &path[0];
let mut j = i;
while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head {
j += 1;
}
let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect();
let children = build(&sub, value);
out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children });
i = j;
}
out
}
let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect();
build(&rows, &value)
}
}
#[cfg(test)]
mod chartex_tests {
use super::*;
fn obs(v: &[f64]) -> Chart {
Chart {
kind: ChartType::Histogram,
series: vec![Series { values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() }],
..Default::default()
}
}
#[test]
fn histogram_bins_by_count_size_and_scott() {
let mut c = obs(&[1.0, 2.0, 2.0, 3.0, 4.0, 5.0, 9.0]);
c.bin_count = Some(4);
let b = c.histogram_bins();
assert_eq!(b.len(), 4);
assert_eq!((b[0].lo, b[0].hi), (1.0, 3.0));
// (lo, hi] bins with the minimum in the first: 1, 2, 2, 3 | 4, 5 | - | 9.
assert_eq!(b.iter().map(|b| b.count).collect::<Vec<_>>(), vec![4, 2, 0, 1]);
c.bin_count = None;
c.bin_size = Some(5.0);
assert_eq!(c.histogram_bins().iter().map(|b| b.count).collect::<Vec<_>>(), vec![6, 1]);
c.bin_size = None;
let auto = c.histogram_bins();
assert!(!auto.is_empty() && auto.iter().map(|b| b.count).sum::<usize>() == 7);
// A tiny bin size is capped; one value gives one bin.
c.bin_size = Some(1e-12);
assert!(c.histogram_bins().len() <= MAX_BINS);
assert_eq!(obs(&[3.0]).histogram_bins(), vec![Bin { lo: 3.0, hi: 4.0, count: 1 }]);
assert!(obs(&[]).histogram_bins().is_empty());
}
#[test]
fn histogram_fixed_bin_count_is_exact_despite_float_rounding() {
let mut c = obs(&[0.0, 1.0, 2.1]);
c.bin_count = Some(7);
assert_eq!(c.histogram_bins().len(), 7);
}
#[test]
fn pareto_view_sorts_bins_and_adds_the_cumulative_line() {
let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]);
c.kind = ChartType::Pareto;
c.bin_count = Some(3);
let v = c.histogram_view();
assert_eq!(v.kind, ChartType::Combo);
assert_eq!(v.series[0].values, vec![Some(3.0), Some(2.0), Some(1.0)]);
let cum = &v.series[1];
assert!(cum.secondary_axis && cum.kind == Some(ChartType::Line));
assert_eq!(cum.values.last(), Some(&Some(1.0)));
assert_eq!(v.categories[0], "(3.67, 6.33]");
}
#[test]
fn box_stats_use_exclusive_quartiles_and_fence_outliers() {
let b = box_stats(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0]).unwrap();
// QUARTILE.EXC on 8 points: positions 2.25, 4.5, 6.75.
assert_eq!((b.q1, b.median, b.q3), (2.25, 4.5, 6.75));
assert_eq!(b.outliers, vec![100.0]);
assert_eq!((b.low, b.high), (1.0, 7.0));
assert!(box_stats(vec![]).is_none());
let one = box_stats(vec![5.0]).unwrap();
assert_eq!((one.low, one.q1, one.median, one.q3, one.high), (5.0, 5.0, 5.0, 5.0, 5.0));
}
#[test]
fn inclusive_quartiles_follow_quartile_inc() {
let b = box_stats_by(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 100.0], true).unwrap();
// QUARTILE.INC on 8 points: positions 1.75, 3.5, 5.25 (0-based).
assert_eq!((b.q1, b.median, b.q3), (2.75, 4.5, 6.25));
}
#[test]
fn category_tree_groups_runs_and_fills_blank_parents() {
let mut c = Chart::sample(ChartType::Treemap);
let t = c.category_tree();
assert_eq!(t.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["Q1", "Q2", "Q3"]);
assert_eq!(t[0].children.len(), 3);
assert_eq!(t[0].children[1].leaf, Some(1));
assert!((t[0].value - (4.2 + 2.8 + 3.6)).abs() < 1e-9);
// Blank parents continue the label above; two outer levels nest.
c.category_levels = vec![
vec!["a".into(), "".into(), "b".into(), "".into(), "c".into(), "".into(), "".into(), "".into()],
vec!["H1".into(), "".into(), "".into(), "".into(), "H2".into(), "".into(), "".into(), "".into()],
];
let t = c.category_tree();
assert_eq!(t.len(), 2);
assert_eq!(t[0].children.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["a", "b"]);
assert_eq!(t[1].children[0].children.len(), 4);
// No levels: every category is a leaf.
c.category_levels.clear();
assert!(c.category_tree().iter().all(|n| n.leaf.is_some()));
}
#[test]
fn box_groups_follow_category_labels() {
let c = Chart::sample(ChartType::BoxWhisker);
let (groups, stats) = c.box_groups();
assert_eq!(groups, vec!["Group A".to_string(), "Group B".to_string()]);
assert_eq!(stats.len(), 2);
assert!(stats.iter().all(|s| s.len() == 2 && s.iter().all(Option::is_some)));
}
}