mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-28 12:27:11 -06:00
* Insert › Chart: create and edit column, bar, line, pie, area, scatter and doughnut charts (#409) - Chart model (wordcraft_doc::chart::ChartSpec): type (clustered/stacked column and bar, line, area, pie, doughnut, scatter), categories, series, title, legend, data labels; sanitized (255 series, 4000 points, 100k cells, 255-char text, non-finite values dropped). - DOCX: charts with a model are written as word/charts/chartN.xml (c:chartSpace, ECMA-376 §21.2, data as c:strLit/c:numLit, no c:externalData) with relationship and content type. On read, a chart part becomes editable only when writing its model again gives the same markup; every other chart stays read-only and kept as before (#319). - Drawing reuses read/chart.rs (the model is drawn from its generated part); data labels (c:dLbls showVal) are now drawn for bar, line, area, scatter, pie and doughnut charts. - Commands: insert.chart, chart.type, chart.editData, chart.title, chart.legend, chart.dataLabels (one undo step each); picture.size redraws an editable chart at its size. - UI: Insert › Chart gallery with previews drawn in code, Chart Design contextual tab, Edit Data grid dialog; strings in all 9 catalogs; catalog 408/439. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #409: refresh catalog count and parity checklist after rebase --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2039 lines
86 KiB
Rust
2039 lines
86 KiB
Rust
//! Charts (ECMA-376 Part 1 §21.2): the cached data of a `c:chartSpace` drawn as items in points.
|
|
//!
|
|
//! Bar and column (clustered, stacked or 100% stacked), line, area, pie, doughnut and scatter
|
|
//! charts are drawn from the first supported chart type of the plot area, with their values as
|
|
//! data labels where `c:dLbls` shows them (`c:showVal`). Other chart types draw nothing. Text widths are estimated ([`CHAR_W`] em a character), so label places are approximate.
|
|
|
|
use std::f64::consts::{FRAC_PI_2, TAU};
|
|
|
|
use wordcraft_doc::graphic::{GraphicItem, PathSeg, TextAlign};
|
|
use wordcraft_doc::para::ShapeKind;
|
|
use wordcraft_doc::props::Rgb;
|
|
|
|
use super::drawing_color::{fill_colour, slot};
|
|
use crate::units::{int, measure};
|
|
use crate::xml::El;
|
|
|
|
/// Most series read from one chart.
|
|
const MAX_SERIES: usize = 256;
|
|
/// Most points read from one series.
|
|
const MAX_POINTS: usize = 10_000;
|
|
/// Most points read from one chart, all series together.
|
|
const MAX_CELLS: usize = 100_000;
|
|
/// Longest series name or category label kept.
|
|
const MAX_LABEL: usize = 200;
|
|
/// Most axis ticks drawn.
|
|
const MAX_TICKS: usize = 20;
|
|
/// Default text size, points.
|
|
const FONT: f32 = 9.0;
|
|
/// Average character width estimate, em (charts and diagrams measure text before shaping).
|
|
pub(super) const CHAR_W: f32 = 0.55;
|
|
/// Widest share of the chart a legend entry's text takes.
|
|
const LEGEND_SHARE: f32 = 0.4;
|
|
/// Most rows of a legend above or below the plot.
|
|
const MAX_LEGEND_ROWS: usize = 3;
|
|
const TITLE_SIZE: f32 = 14.0;
|
|
/// Space between the frame and the content, points.
|
|
const PAD: f32 = 6.0;
|
|
/// Line height as a multiple of the font size.
|
|
const LINE: f32 = 1.3;
|
|
/// Gap between a label and its axis, points.
|
|
const GAP: f32 = 4.0;
|
|
/// Default width of a line series, points (Word's 2.25 pt).
|
|
const LINE_W: f32 = 2.25;
|
|
const TEXT: Rgb = Rgb(0x59, 0x59, 0x59);
|
|
const GRID: Rgb = Rgb(0xD9, 0xD9, 0xD9);
|
|
const AXIS: Rgb = Rgb(0xBF, 0xBF, 0xBF);
|
|
|
|
/// Items for a chart part, sized `w` x `h` points. Empty when nothing supported is in it.
|
|
pub(crate) fn chart_items(space: &El, theme: &[Rgb], w: f32, h: f32) -> Vec<GraphicItem> {
|
|
let mut out = Vec::new();
|
|
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
|
|
return out;
|
|
}
|
|
let Some(chart) = Chart::parse(space, theme) else { return out };
|
|
backdrop(&mut out, space, theme, w, h);
|
|
let mut area = Rect { x: PAD, y: PAD, w: (w - 2.0 * PAD).max(1.0), h: (h - 2.0 * PAD).max(1.0) };
|
|
if let Some(t) = &chart.title {
|
|
let lh = TITLE_SIZE * LINE;
|
|
text(&mut out, Rect { x: area.x, y: area.y, w: area.w, h: lh }, t, TITLE_SIZE, TextAlign::Center, true);
|
|
area.y += lh + PAD;
|
|
area.h = (area.h - lh - PAD).max(1.0);
|
|
}
|
|
if let Some(pos) = chart.legend {
|
|
// Word lists bar series in the order they are drawn: reversed for clustered horizontal bars
|
|
// and for stacked columns with a vertical legend.
|
|
let vertical = matches!(pos, Pos::Left | Pos::Right);
|
|
let reverse = chart.kind == Kind::Bar && if chart.horizontal { !chart.stacked() } else { chart.stacked() && vertical };
|
|
let mut entries = chart.legend_entries();
|
|
if reverse {
|
|
entries.reverse();
|
|
}
|
|
area = legend(&mut out, &entries, pos, area, chart.text_size);
|
|
}
|
|
match chart.kind {
|
|
Kind::Pie | Kind::Doughnut => pie(&mut out, &chart, area),
|
|
_ => cartesian(&mut out, &chart, area),
|
|
}
|
|
out
|
|
}
|
|
|
|
/// A data label to draw once the chart is: the value at (`x`, `y`), centred there (`inside`, in
|
|
/// white on the fill) or just beside it (above, or right of a horizontal bar's end).
|
|
struct DataLabel {
|
|
x: f32,
|
|
y: f32,
|
|
text: String,
|
|
inside: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum Kind {
|
|
Bar,
|
|
Line,
|
|
Area,
|
|
Pie,
|
|
Doughnut,
|
|
Scatter,
|
|
}
|
|
|
|
impl Kind {
|
|
fn of(name: &str) -> Option<Kind> {
|
|
Some(match name {
|
|
"c:barChart" => Kind::Bar,
|
|
"c:lineChart" => Kind::Line,
|
|
"c:areaChart" => Kind::Area,
|
|
"c:pieChart" => Kind::Pie,
|
|
"c:doughnutChart" => Kind::Doughnut,
|
|
"c:scatterChart" => Kind::Scatter,
|
|
_ => return None,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum Grouping {
|
|
Clustered,
|
|
Stacked,
|
|
Percent,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum Pos {
|
|
Right,
|
|
Left,
|
|
Top,
|
|
Bottom,
|
|
}
|
|
|
|
/// One value axis (or category axis) as the file states it.
|
|
#[derive(Default)]
|
|
struct Axis {
|
|
deleted: bool,
|
|
grid: bool,
|
|
line: bool,
|
|
/// `c:crossBetween val="midCat"`: categories sit on the tick marks, not between them.
|
|
mid: bool,
|
|
min: Option<f64>,
|
|
max: Option<f64>,
|
|
/// Number format of the tick labels, when not linked to the source data.
|
|
fmt: Option<String>,
|
|
}
|
|
|
|
struct Series {
|
|
name: String,
|
|
color: Option<Rgb>,
|
|
line_w: f32,
|
|
/// Whether the series is drawn as a line (scatter series without one get markers).
|
|
line: bool,
|
|
values: Vec<Option<f64>>,
|
|
/// Scatter x values (index + 1 when the file has none).
|
|
xs: Vec<Option<f64>>,
|
|
/// Per-point colours from `c:dPt`.
|
|
points: Vec<Option<Rgb>>,
|
|
fmt: Option<String>,
|
|
/// Format code of the scatter x values' cache.
|
|
x_fmt: Option<String>,
|
|
/// Data labels on or off for this series (`c:dLbls` of its own), when it says.
|
|
labels: Option<bool>,
|
|
/// The values as the file has them, for data labels (`values` become shares in a 100%
|
|
/// stacked chart); empty when the series shows no labels.
|
|
raw: Vec<Option<f64>>,
|
|
}
|
|
|
|
struct Chart {
|
|
kind: Kind,
|
|
horizontal: bool,
|
|
grouping: Grouping,
|
|
/// Gap between groups as a fraction of a bar's width.
|
|
gap: f32,
|
|
/// Overlap of clustered bars, -1..1 (1 = all on top of each other).
|
|
overlap: f32,
|
|
/// Doughnut hole, fraction of the outer radius.
|
|
hole: f32,
|
|
first_angle: f64,
|
|
vary: bool,
|
|
/// Number of categories (scatter: points per series at most).
|
|
n: usize,
|
|
cats: Vec<String>,
|
|
series: Vec<Series>,
|
|
accents: Vec<Rgb>,
|
|
title: Option<String>,
|
|
legend: Option<Pos>,
|
|
/// Category axis (scatter: the x axis).
|
|
cat: Axis,
|
|
val: Axis,
|
|
text_size: f32,
|
|
val_fmt: String,
|
|
/// Serial days count from 1904 (`c:date1904`), not 1900.
|
|
date1904: bool,
|
|
/// Outline of the plot area, when the file draws one.
|
|
plot_border: Option<Rgb>,
|
|
}
|
|
|
|
impl Chart {
|
|
fn parse(space: &El, theme: &[Rgb]) -> Option<Chart> {
|
|
let chart = space.child("c:chart")?;
|
|
let plot = chart.child("c:plotArea")?;
|
|
let ty = plot.els().find(|e| Kind::of(&e.name).is_some())?;
|
|
let kind = Kind::of(&ty.name)?;
|
|
let scatter = kind == Kind::Scatter;
|
|
let date1904 = space.child("c:date1904").is_some_and(|d| d.attr("val").is_none_or(truthy));
|
|
let mut series = read_series(ty, scatter, theme);
|
|
if series.is_empty() {
|
|
return None;
|
|
}
|
|
let cats = ty.children("c:ser").find_map(|s| s.child("c:cat")).map(|c| labels(Some(c), MAX_POINTS, date1904)).unwrap_or_default();
|
|
let n = if matches!(kind, Kind::Pie | Kind::Doughnut) {
|
|
series.first().map_or(0, |s| s.values.len()).max(cats.len())
|
|
} else {
|
|
series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(cats.len())
|
|
};
|
|
// Every series is padded to `n` points, so keep only as many as the cell budget holds.
|
|
if let Some(keep) = MAX_CELLS.checked_div(n) {
|
|
series.truncate(keep.max(1));
|
|
}
|
|
let grouping = match ty.child("c:grouping").and_then(|g| g.attr("val")) {
|
|
Some("stacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Stacked,
|
|
Some("percentStacked") if matches!(kind, Kind::Bar | Kind::Area | Kind::Line) => Grouping::Percent,
|
|
_ => Grouping::Clustered,
|
|
};
|
|
let mut cats = cats;
|
|
cats.resize(n, String::new());
|
|
let type_labels = show_values(ty.child("c:dLbls"));
|
|
for s in &mut series {
|
|
s.values.resize(n, None);
|
|
s.xs.resize(n, None);
|
|
s.labels = Some(s.labels.or(type_labels).unwrap_or(false));
|
|
if s.labels == Some(true) {
|
|
s.raw = s.values.clone();
|
|
}
|
|
}
|
|
if grouping == Grouping::Percent {
|
|
to_percent(&mut series, n);
|
|
}
|
|
let (cat, val) = chart_axes(plot, scatter);
|
|
let val_fmt = if grouping == Grouping::Percent {
|
|
"0%".to_string()
|
|
} else {
|
|
val.fmt.clone().or_else(|| series.iter().find_map(|s| s.fmt.clone())).unwrap_or_else(|| "General".to_string())
|
|
};
|
|
let num = |n: &str| ty.child(n).and_then(|g| g.attr("val")).and_then(int);
|
|
Some(Chart {
|
|
kind,
|
|
horizontal: kind == Kind::Bar && ty.child("c:barDir").and_then(|d| d.attr("val")) == Some("bar"),
|
|
grouping,
|
|
gap: num("c:gapWidth").unwrap_or(150).clamp(0, 500) as f32 / 100.0,
|
|
overlap: num("c:overlap").unwrap_or(0).clamp(-100, 100) as f32 / 100.0,
|
|
hole: num("c:holeSize").unwrap_or(50).clamp(10, 90) as f32 / 100.0,
|
|
first_angle: num("c:firstSliceAng").unwrap_or(0).clamp(0, 360) as f64,
|
|
vary: matches!(kind, Kind::Pie | Kind::Doughnut) && ty.child("c:varyColors").and_then(|v| v.attr("val")).is_none_or(truthy),
|
|
n,
|
|
cats,
|
|
title: title_of(chart, &series),
|
|
series,
|
|
accents: (4..10).map(|i| slot(theme, i)).collect(),
|
|
legend: legend_pos(chart),
|
|
cat,
|
|
val,
|
|
text_size: text_size(space),
|
|
val_fmt,
|
|
date1904,
|
|
plot_border: plot.child("c:spPr").and_then(|p| p.child("a:ln")).and_then(|l| fill_colour(l, theme)),
|
|
})
|
|
}
|
|
|
|
fn stacked(&self) -> bool {
|
|
self.grouping != Grouping::Clustered
|
|
}
|
|
|
|
fn accent(&self, i: usize) -> Rgb {
|
|
self.accents.get(i % 6).copied().unwrap_or(Rgb::BLACK)
|
|
}
|
|
|
|
fn series_colour(&self, j: usize) -> Rgb {
|
|
self.series.get(j).and_then(|s| s.color).unwrap_or_else(|| self.accent(j))
|
|
}
|
|
|
|
/// Colour of point `i` of series `j`: its `c:dPt` override, else the varied accent (pie) or
|
|
/// the series colour.
|
|
fn point_colour(&self, j: usize, i: usize) -> Rgb {
|
|
if let Some(Some(c)) = self.series.get(j).and_then(|s| s.points.get(i)) {
|
|
return *c;
|
|
}
|
|
if self.vary { self.accent(i) } else { self.series_colour(j) }
|
|
}
|
|
|
|
fn legend_entries(&self) -> Vec<(String, Rgb)> {
|
|
if matches!(self.kind, Kind::Pie | Kind::Doughnut) {
|
|
return self
|
|
.cats
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, c)| (if c.is_empty() { (i + 1).to_string() } else { c.clone() }, self.point_colour(0, i)))
|
|
.collect();
|
|
}
|
|
self.series
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(j, s)| (if s.name.is_empty() { format!("Series {}", j + 1) } else { s.name.clone() }, self.series_colour(j)))
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// The series of a chart type element, within the chart's cell budget.
|
|
fn read_series(ty: &El, scatter: bool, theme: &[Rgb]) -> Vec<Series> {
|
|
let mut budget = MAX_CELLS;
|
|
let mut series = Vec::new();
|
|
for ser in ty.children("c:ser").take(MAX_SERIES) {
|
|
let s = Series::parse(ser, scatter, theme, budget.min(MAX_POINTS));
|
|
budget = budget.saturating_sub(s.values.len().max(s.xs.len()));
|
|
series.push(s);
|
|
}
|
|
series
|
|
}
|
|
|
|
/// Values as their share of each category's total (100% stacked charts).
|
|
fn to_percent(series: &mut [Series], n: usize) {
|
|
for i in 0..n {
|
|
let total: f64 = series.iter().map(|s| s.values.get(i).copied().flatten().map_or(0.0, f64::abs)).sum();
|
|
for s in series.iter_mut() {
|
|
if let Some(Some(v)) = s.values.get_mut(i) {
|
|
*v = if total > 0.0 { *v / total } else { 0.0 };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The category axis (scatter: the horizontal value axis) and the value axis of a plot area.
|
|
fn chart_axes(plot: &El, scatter: bool) -> (Axis, Axis) {
|
|
let vals_ax: Vec<&El> = plot.children("c:valAx").collect();
|
|
let horiz_pos = |a: &&El| matches!(a.child("c:axPos").and_then(|p| p.attr("val")), Some("b" | "t"));
|
|
let (val_el, x_el) = if scatter {
|
|
let x = vals_ax.iter().copied().find(horiz_pos).or_else(|| vals_ax.first().copied());
|
|
let y = vals_ax.iter().copied().find(|a| !horiz_pos(a)).or_else(|| vals_ax.iter().copied().find(|a| Some(*a) != x));
|
|
(y, x)
|
|
} else {
|
|
(vals_ax.first().copied(), None)
|
|
};
|
|
let cat_el = plot.els().find(|e| matches!(e.name.as_str(), "c:catAx" | "c:dateAx"));
|
|
(axis(if scatter { x_el } else { cat_el }), axis(val_el))
|
|
}
|
|
|
|
/// The chart's title. An empty `c:title` shows the series name (Word's automatic title of a
|
|
/// one-series chart) unless the automatic title is deleted.
|
|
fn title_of(chart: &El, series: &[Series]) -> Option<String> {
|
|
let auto_deleted = chart.child("c:autoTitleDeleted").and_then(|a| a.attr("val")).is_some_and(truthy);
|
|
let auto = if !auto_deleted && series.len() == 1 { series.first().map(|s| s.name.clone()).filter(|s| !s.is_empty()) } else { None };
|
|
chart.child("c:title").and_then(|t| title_text(t).or(auto))
|
|
}
|
|
|
|
/// Whether a `c:dLbls` shows the values (`None` when there's none, or it doesn't say).
|
|
fn show_values(d: Option<&El>) -> Option<bool> {
|
|
let d = d?;
|
|
if d.child("c:delete").and_then(|e| e.attr("val")).is_some_and(truthy) {
|
|
return Some(false);
|
|
}
|
|
d.child("c:showVal").map(|v| v.attr("val").is_none_or(truthy))
|
|
}
|
|
|
|
fn legend_pos(chart: &El) -> Option<Pos> {
|
|
chart.child("c:legend").map(|l| match l.child("c:legendPos").and_then(|p| p.attr("val")).unwrap_or("r") {
|
|
"l" => Pos::Left,
|
|
"t" | "tr" => Pos::Top,
|
|
"b" => Pos::Bottom,
|
|
_ => Pos::Right,
|
|
})
|
|
}
|
|
|
|
/// The chart's text size (its `c:txPr` default run size), points.
|
|
fn text_size(space: &El) -> f32 {
|
|
space
|
|
.child("c:txPr")
|
|
.and_then(|t| t.find("a:defRPr"))
|
|
.and_then(|d| d.attr("sz"))
|
|
.and_then(int)
|
|
.map_or(FONT, |v| (v as f32 / 100.0).clamp(6.0, 24.0))
|
|
}
|
|
|
|
impl Series {
|
|
fn parse(ser: &El, scatter: bool, theme: &[Rgb], cap: usize) -> Series {
|
|
let name = ser.child("c:tx").and_then(|t| t.find("c:v")).map(|v| label(v.text().trim())).unwrap_or_default();
|
|
let sp = ser.child("c:spPr");
|
|
let ln = sp.and_then(|p| p.child("a:ln"));
|
|
let marker = ser.child("c:marker").and_then(|m| m.child("c:spPr")).and_then(|p| fill_colour(p, theme));
|
|
let color = sp.and_then(|p| fill_colour(p, theme)).or_else(|| ln.and_then(|l| fill_colour(l, theme))).or(marker);
|
|
let line_w = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).map_or(LINE_W, |w| w.clamp(0.0, 20.0));
|
|
let line = ln.is_none_or(|l| l.child("a:noFill").is_none());
|
|
let (values, fmt) = numbers(ser.child(if scatter { "c:yVal" } else { "c:val" }), cap);
|
|
let (mut xs, x_fmt) = if scatter { numbers(ser.child("c:xVal"), cap) } else { (Vec::new(), None) };
|
|
if scatter && xs.is_empty() {
|
|
xs = (1..=values.len()).map(|i| Some(i as f64)).collect();
|
|
}
|
|
let mut points = vec![None; values.len()];
|
|
for dpt in ser.children("c:dPt") {
|
|
let i = dpt.child("c:idx").and_then(|e| e.attr("val")).and_then(int).and_then(|i| usize::try_from(i).ok());
|
|
let c = dpt.child("c:spPr").and_then(|p| fill_colour(p, theme));
|
|
if let Some(slot) = i.and_then(|i| points.get_mut(i)) {
|
|
*slot = c;
|
|
}
|
|
}
|
|
let labels = show_values(ser.child("c:dLbls"));
|
|
Series { name, color, line_w, line, values, xs, points, fmt, x_fmt, labels, raw: Vec::new() }
|
|
}
|
|
}
|
|
|
|
/// Category labels of a `c:cat` (string or number cache), capped. Numbers show in the cache's
|
|
/// format code (dates included); text and `General` numbers as they are stored.
|
|
fn labels(el: Option<&El>, cap: usize, date1904: bool) -> Vec<String> {
|
|
let Some(el) = el else { return Vec::new() };
|
|
let Some(cache) = el.find("c:strCache").or_else(|| el.find("c:numCache")).or_else(|| el.find("c:strLit")).or_else(|| el.find("c:numLit")) else {
|
|
return Vec::new();
|
|
};
|
|
let code = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty() && !f.eq_ignore_ascii_case("General"));
|
|
pts(cache, cap)
|
|
.into_iter()
|
|
.map(|p| {
|
|
let text = p.unwrap_or_default();
|
|
match (code.as_deref(), text.trim().parse::<f64>()) {
|
|
(Some(code), Ok(v)) if v.is_finite() => label(&category_text(v, code, 1.0, date1904)),
|
|
_ => label(&text),
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// A number in its format code: a date or time for date codes (serial days, from 1904 when
|
|
/// `date1904`), else as [`format_value`] with tick step `step`.
|
|
fn category_text(v: f64, code: &str, step: f64, date1904: bool) -> String {
|
|
match excel_stamp(v, date1904) {
|
|
Some(t) if is_date_code(code) => date_text(t, code),
|
|
_ => format_value(v, code, step),
|
|
}
|
|
}
|
|
|
|
/// Values of a `c:val`-style element (number cache or literal), capped; with its format code.
|
|
fn numbers(el: Option<&El>, cap: usize) -> (Vec<Option<f64>>, Option<String>) {
|
|
let Some(cache) = el.and_then(|e| e.find("c:numCache").or_else(|| e.find("c:numLit"))) else {
|
|
return (Vec::new(), None);
|
|
};
|
|
let fmt = cache.child("c:formatCode").map(|f| f.text()).filter(|f| !f.is_empty());
|
|
let vals = pts(cache, cap).into_iter().map(|p| p.and_then(|t| t.trim().parse::<f64>().ok()).filter(|v| v.is_finite())).collect();
|
|
(vals, fmt)
|
|
}
|
|
|
|
/// The `c:pt` texts of a cache by index. Indexes and counts from the file are capped.
|
|
fn pts(cache: &El, cap: usize) -> Vec<Option<String>> {
|
|
let declared = cache.child("c:ptCount").and_then(|c| c.attr("val")).and_then(int).unwrap_or(0).clamp(0, cap as i64) as usize;
|
|
let mut out: Vec<Option<String>> = vec![None; declared];
|
|
for p in cache.children("c:pt") {
|
|
let Some(i) = p.attr("idx").and_then(int).and_then(|i| usize::try_from(i).ok()) else { continue };
|
|
if i >= cap {
|
|
continue;
|
|
}
|
|
if out.len() <= i {
|
|
out.resize(i + 1, None);
|
|
}
|
|
if let Some(slot) = out.get_mut(i) {
|
|
*slot = p.child("c:v").map(|v| v.text());
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn axis(a: Option<&El>) -> Axis {
|
|
let Some(a) = a else { return Axis { deleted: true, ..Default::default() } };
|
|
let scaling = a.child("c:scaling");
|
|
let num =
|
|
|n: &str| scaling.and_then(|s| s.child(n)).and_then(|e| e.attr("val")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite());
|
|
Axis {
|
|
deleted: a.child("c:delete").and_then(|d| d.attr("val")).is_some_and(truthy),
|
|
grid: a.child("c:majorGridlines").is_some(),
|
|
line: a.child("c:spPr").and_then(|p| p.child("a:ln")).and_then(|l| l.child("a:noFill")).is_none(),
|
|
mid: a.child("c:crossBetween").and_then(|c| c.attr("val")) == Some("midCat"),
|
|
min: num("c:min"),
|
|
max: num("c:max"),
|
|
fmt: a.child("c:numFmt").filter(|f| f.attr("sourceLinked") != Some("1")).and_then(|f| f.attr("formatCode")).map(str::to_string),
|
|
}
|
|
}
|
|
|
|
fn title_text(t: &El) -> Option<String> {
|
|
let tx = t.child("c:tx")?;
|
|
let s = match tx.child("c:rich") {
|
|
Some(rich) => {
|
|
let mut s = String::new();
|
|
rich_into(rich, &mut s);
|
|
s
|
|
}
|
|
None => tx.find("c:v").map(|v| v.text()).unwrap_or_default(),
|
|
};
|
|
let s = label(s.trim());
|
|
(!s.is_empty()).then_some(s)
|
|
}
|
|
|
|
/// The `a:t` runs under an element, in order.
|
|
fn rich_into(e: &El, s: &mut String) {
|
|
for c in e.els() {
|
|
if c.name == "a:t" {
|
|
s.push_str(&c.text());
|
|
} else {
|
|
rich_into(c, s);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn truthy(v: &str) -> bool {
|
|
matches!(v.trim(), "1" | "true")
|
|
}
|
|
|
|
fn label(s: &str) -> String {
|
|
s.chars().take(MAX_LABEL).collect()
|
|
}
|
|
|
|
/// Chart area: the chart space's fill and border (white and borderless by default).
|
|
fn backdrop(out: &mut Vec<GraphicItem>, space: &El, theme: &[Rgb], w: f32, h: f32) {
|
|
let sp = space.child("c:spPr");
|
|
let fill = match sp {
|
|
Some(p) if p.child("a:noFill").is_some() => None,
|
|
Some(p) => fill_colour(p, theme).or(Some(Rgb::WHITE)),
|
|
None => Some(Rgb::WHITE),
|
|
};
|
|
let ln = sp.and_then(|p| p.child("a:ln"));
|
|
let stroke = ln.and_then(|l| fill_colour(l, theme));
|
|
let stroke_width = ln.and_then(|l| l.attr("w")).and_then(|v| measure(v, 12_700.0)).unwrap_or(0.75).clamp(0.0, 20.0);
|
|
if fill.is_some() || stroke.is_some() {
|
|
out.push(GraphicItem::Shape { rect: [0.0, 0.0, w, h], kind: ShapeKind::Rectangle, fill, stroke, stroke_width });
|
|
}
|
|
}
|
|
|
|
/// An axis scale: `lo` to `hi` in steps of `step`.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
struct Scale {
|
|
lo: f64,
|
|
hi: f64,
|
|
step: f64,
|
|
}
|
|
|
|
impl Scale {
|
|
fn ticks(&self) -> Vec<f64> {
|
|
let n = (((self.hi - self.lo) / self.step + 1e-9).floor().max(0.0) as usize).min(MAX_TICKS);
|
|
(0..=n).map(|k| self.lo + k as f64 * self.step).filter(|t| *t <= self.hi + 1e-9).collect()
|
|
}
|
|
}
|
|
|
|
/// A scale for data `dlo..dhi` (zero included when `zero`), as Excel and Word pick it: an end
|
|
/// away from zero sits 5% of the data span beyond the data (an end at zero stays there), rounded
|
|
/// out to a step of 1, 2, 2.5, 5 or 10 times a power of ten (about seven steps). `min`/`max`
|
|
/// from the file replace the ends.
|
|
fn nice_scale(dlo: f64, dhi: f64, zero: bool, min: Option<f64>, max: Option<f64>) -> Scale {
|
|
let (dlo, dhi) = if dhi > dlo { (dlo, dhi) } else { (dlo, dlo + 1.0) };
|
|
let span = dhi - dlo;
|
|
// With zero, the ends reach it: all-negative data still starts its bars at zero.
|
|
let (ends_lo, ends_hi) = if zero { (dlo.min(0.0), dhi.max(0.0)) } else { (dlo, dhi) };
|
|
let lo = min.unwrap_or(if ends_lo < 0.0 { ends_lo - 0.05 * span } else { ends_lo });
|
|
let hi = max.unwrap_or(if ends_hi > 0.0 { ends_hi + 0.05 * span } else { ends_hi });
|
|
if !(hi > lo && hi.is_finite() && lo.is_finite()) {
|
|
return Scale { lo: 0.0, hi: 1.0, step: 0.2 };
|
|
}
|
|
let raw = (hi - lo) / 7.5;
|
|
let mag = 10f64.powf(raw.log10().floor());
|
|
let step = [1.0, 2.0, 2.5, 5.0, 10.0].iter().map(|m| m * mag).find(|s| *s >= raw).unwrap_or(10.0 * mag);
|
|
let lo_r = if min.is_some() { lo } else { ((lo / step) + 1e-9).floor() * step };
|
|
let hi_r = if max.is_some() { hi } else { ((hi / step) - 1e-9).ceil() * step };
|
|
if !(step.is_finite() && step > 0.0 && lo_r.is_finite() && hi_r.is_finite() && hi_r > lo_r) {
|
|
return Scale { lo: 0.0, hi: 1.0, step: 0.2 };
|
|
}
|
|
Scale { lo: lo_r, hi: hi_r, step }
|
|
}
|
|
|
|
/// A piece of a format section: literal text, a number character (`#`, `0`, `,`, `.`), or the
|
|
/// exponent of a scientific format (`E+` when true, `E-` when false).
|
|
enum Tok {
|
|
Lit(String),
|
|
Num(char),
|
|
Exp(bool),
|
|
}
|
|
|
|
/// The sections of a format code (positive; negative; zero; text), split outside quotes.
|
|
fn sections(code: &str) -> Vec<&str> {
|
|
let mut out = Vec::new();
|
|
let (mut start, mut quoted, mut escaped) = (0, false, false);
|
|
for (i, c) in code.char_indices() {
|
|
match c {
|
|
_ if escaped => escaped = false,
|
|
'\\' if !quoted => escaped = true,
|
|
'"' => quoted = !quoted,
|
|
';' if !quoted => {
|
|
out.push(code.get(start..i).unwrap_or(""));
|
|
start = i + 1;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
out.push(code.get(start..).unwrap_or(""));
|
|
out
|
|
}
|
|
|
|
/// The first section of a format code.
|
|
fn first_section(code: &str) -> &str {
|
|
sections(code).first().copied().unwrap_or("")
|
|
}
|
|
|
|
/// The pieces of one format section. Quoted text, escapes and `[$€-407]` currency symbols are
|
|
/// literal; other brackets (`[Red]`) are dropped; `_x` (room for `x`) is a space; `*x` is dropped.
|
|
fn format_toks(section: &str) -> Vec<Tok> {
|
|
let mut toks = Vec::new();
|
|
let mut chars = section.chars().peekable();
|
|
while let Some(c) = chars.next() {
|
|
match c {
|
|
'"' => toks.push(Tok::Lit(chars.by_ref().take_while(|q| *q != '"').collect())),
|
|
'[' => {
|
|
let inner: String = chars.by_ref().take_while(|q| *q != ']').collect();
|
|
if let Some(sym) = currency(&inner) {
|
|
toks.push(Tok::Lit(sym.to_string()));
|
|
}
|
|
}
|
|
'\\' => toks.extend(chars.next().map(|n| Tok::Lit(n.to_string()))),
|
|
'_' => {
|
|
chars.next();
|
|
toks.push(Tok::Lit(" ".to_string()));
|
|
}
|
|
'*' => {
|
|
chars.next();
|
|
}
|
|
'E' | 'e' if matches!(chars.peek(), Some('+' | '-')) => toks.push(Tok::Exp(chars.next() == Some('+'))),
|
|
'#' | '0' | ',' | '.' => toks.push(Tok::Num(c)),
|
|
c => toks.push(Tok::Lit(c.to_string())),
|
|
}
|
|
}
|
|
toks
|
|
}
|
|
|
|
/// The symbol of a `$€-407` bracket content (`[$€-407]`), if it is one.
|
|
fn currency(inner: &str) -> Option<&str> {
|
|
inner.strip_prefix('$').map(|rest| rest.split('-').next().unwrap_or(""))
|
|
}
|
|
|
|
/// Tick text for `v` in a number format code (`#,##0`, `0.0`, `0%`, `0.00E+00`, and literal text
|
|
/// such as `"€"0.00` or `#,##0.00 [$€-407]`). A negative number takes the second section when
|
|
/// there is one, which shows it without a minus (`#,##0;(#,##0)`). `General` shows as many
|
|
/// decimals as the step needs.
|
|
fn format_value(v: f64, code: &str, step: f64) -> String {
|
|
let v = if v.abs() < step * 1e-9 { 0.0 } else { v };
|
|
let secs = sections(code);
|
|
match secs.get(1) {
|
|
Some(neg) if v < 0.0 => format_section(-v, neg, step),
|
|
_ => format_section(v, secs.first().copied().unwrap_or(""), step),
|
|
}
|
|
}
|
|
|
|
/// `v` in one section of a format code.
|
|
fn format_section(v: f64, section: &str, step: f64) -> String {
|
|
let toks = format_toks(section);
|
|
let is_num = |t: &Tok| matches!(t, Tok::Num(_) | Tok::Exp(_));
|
|
let lit = |r: &[Tok]| -> String {
|
|
r.iter()
|
|
.filter_map(|t| match t {
|
|
Tok::Lit(l) => Some(l.as_str()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
};
|
|
let (Some(s), Some(e)) = (toks.iter().position(is_num), toks.iter().rposition(is_num)) else { return general(v, step) };
|
|
let body = toks.get(s..=e).unwrap_or(&[]);
|
|
let (pre, suf) = (lit(toks.get(..s).unwrap_or(&[])), lit(toks.get(e + 1..).unwrap_or(&[])));
|
|
let digits = |r: &[Tok]| -> String { r.iter().filter_map(|t| if let Tok::Num(c) = t { Some(*c) } else { None }).collect() };
|
|
if let Some(k) = body.iter().position(|t| matches!(t, Tok::Exp(_))) {
|
|
let plus = matches!(body.get(k), Some(Tok::Exp(true)));
|
|
let mantissa = digits(body.get(..k).unwrap_or(&[]));
|
|
let exp_digits = digits(body.get(k + 1..).unwrap_or(&[])).len();
|
|
return format!("{pre}{}{suf}", scientific(v, decimals(&mantissa), exp_digits, plus));
|
|
}
|
|
let body = digits(body);
|
|
let pct = pre.contains('%') || suf.contains('%');
|
|
let v = if pct { v * 100.0 } else { v };
|
|
let digits = format!("{:.*}", decimals(&body), v.abs());
|
|
let (ip, fp) = match digits.split_once('.') {
|
|
Some((a, b)) => (a.to_string(), format!(".{b}")),
|
|
None => (digits.clone(), String::new()),
|
|
};
|
|
let mut grouped = String::new();
|
|
for (k, ch) in ip.chars().enumerate() {
|
|
if body.contains(',') && k > 0 && (ip.len() - k) % 3 == 0 {
|
|
grouped.push(',');
|
|
}
|
|
grouped.push(ch);
|
|
}
|
|
let sign = if v < 0.0 && digits.chars().any(|c| c != '0' && c != '.') { "-" } else { "" };
|
|
format!("{sign}{pre}{grouped}{fp}{suf}")
|
|
}
|
|
|
|
/// Decimal places of a number body (`#,##0.00` has 2).
|
|
fn decimals(body: &str) -> usize {
|
|
body.split_once('.').map_or(0, |(_, d)| d.chars().filter(|c| matches!(c, '0' | '#')).count()).min(12)
|
|
}
|
|
|
|
/// `v` with no number format (`General`): as many decimals as the tick step needs.
|
|
fn general(v: f64, step: f64) -> String {
|
|
let mut d = 0;
|
|
while d < 6 && (step * 10f64.powi(d)).fract().abs() > 1e-9 {
|
|
d += 1;
|
|
}
|
|
let s = format!("{:.*}", d as usize, v);
|
|
if s.trim_start_matches(['-', '0', '.']).is_empty() { s.trim_start_matches('-').to_string() } else { s }
|
|
}
|
|
|
|
/// `v` in scientific notation: `decimals` mantissa decimals, at least `exp_digits` exponent
|
|
/// digits, and a `+` on positive exponents when `plus` (`0.00E+00`: 1.23E+04).
|
|
fn scientific(v: f64, decimals: usize, exp_digits: usize, plus: bool) -> String {
|
|
let s = format!("{:.*e}", decimals, v.abs());
|
|
let (mantissa, exp) = s.split_once('e').unwrap_or((s.as_str(), "0"));
|
|
let exp: i32 = if v == 0.0 { 0 } else { exp.parse().unwrap_or(0) };
|
|
let sign = if exp < 0 {
|
|
"-"
|
|
} else if plus {
|
|
"+"
|
|
} else {
|
|
""
|
|
};
|
|
let minus = if v < 0.0 { "-" } else { "" };
|
|
format!("{minus}{mantissa}E{sign}{:0w$}", exp.unsigned_abs(), w = exp_digits.clamp(1, 4))
|
|
}
|
|
|
|
/// A moment as Excel shows a serial day number.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
struct Stamp {
|
|
y: i64,
|
|
m: u32,
|
|
d: u32,
|
|
/// Day of the week, 0 = Sunday.
|
|
weekday: u32,
|
|
/// Seconds into the day.
|
|
secs: u32,
|
|
}
|
|
|
|
/// The moment of Excel serial day `serial` as Excel shows it, from 0 ("January 0, 1900") to
|
|
/// 31 December 9999. In the 1900 system serial 60 is Excel's phantom 29 February 1900, and
|
|
/// serials before it count from 31 December 1899; in the 1904 system (`date1904`) serial 0 is
|
|
/// 1 January 1904. `None` outside that range.
|
|
fn excel_stamp(serial: f64, date1904: bool) -> Option<Stamp> {
|
|
let serial = if date1904 { serial + 1462.0 } else { serial };
|
|
if !(0.0..2_958_466.0).contains(&serial) {
|
|
return None;
|
|
}
|
|
let whole = serial.floor();
|
|
let secs = (((serial - whole) * 86_400.0).round() as u32).min(86_399);
|
|
let day = whole as i64;
|
|
let (y, m, d) = match day {
|
|
0 => (1900, 1, 0),
|
|
60 => (1900, 2, 29),
|
|
1..=59 => civil(day + 1),
|
|
_ => civil(day),
|
|
};
|
|
// Excel's weekdays count serial 1 as a Sunday.
|
|
Some(Stamp { y, m, d, weekday: (day - 1).rem_euclid(7) as u32, secs })
|
|
}
|
|
|
|
/// Calendar date (year, month, day) of day `n` counted from 30 December 1899 (Howard Hinnant's
|
|
/// civil-from-days algorithm).
|
|
fn civil(n: i64) -> (i64, u32, u32) {
|
|
let z = n - 25_569 + 719_468;
|
|
let era = z.div_euclid(146_097);
|
|
let doe = z.rem_euclid(146_097);
|
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
let mp = (5 * doy + 2) / 153;
|
|
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
|
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
|
|
(yoe + era * 400 + i64::from(m <= 2), m, d)
|
|
}
|
|
|
|
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
|
const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
|
|
/// Whether a format code (outside its quoted and bracketed parts) has date or time fields.
|
|
fn is_date_code(code: &str) -> bool {
|
|
format_toks_raw(code).any(|c| matches!(c.to_ascii_lowercase(), 'y' | 'd' | 'm' | 'h' | 's'))
|
|
}
|
|
|
|
/// The characters of a format code's first section outside quotes and brackets.
|
|
fn format_toks_raw(code: &str) -> impl Iterator<Item = char> + '_ {
|
|
let mut quoted = false;
|
|
let mut bracket = false;
|
|
first_section(code).chars().filter(move |c| match c {
|
|
'"' => {
|
|
quoted = !quoted;
|
|
false
|
|
}
|
|
'[' if !quoted => {
|
|
bracket = true;
|
|
false
|
|
}
|
|
']' if bracket => {
|
|
bracket = false;
|
|
false
|
|
}
|
|
_ => !quoted && !bracket,
|
|
})
|
|
}
|
|
|
|
/// A piece of a date format: literal text, a field (`y`, `m`, `d`, `h`, `s`, or `n` for minutes)
|
|
/// with its length, or AM/PM (`AM/PM` when true, `A/P` when false).
|
|
enum DTok {
|
|
Lit(String),
|
|
Field(char, usize),
|
|
AmPm(bool),
|
|
}
|
|
|
|
/// The pieces of a date format's first section. `m` or `mm` right after an hour or right before
|
|
/// a seconds field is minutes.
|
|
fn date_toks(code: &str) -> Vec<DTok> {
|
|
let section: Vec<char> = first_section(code).chars().collect();
|
|
let mut toks = Vec::new();
|
|
let mut i = 0;
|
|
while let Some(&c) = section.get(i) {
|
|
let at = i;
|
|
i += 1;
|
|
match c {
|
|
'"' => {
|
|
let lit: String = section.get(i..).unwrap_or(&[]).iter().take_while(|q| **q != '"').collect();
|
|
i += lit.chars().count() + 1;
|
|
toks.push(DTok::Lit(lit));
|
|
}
|
|
'[' => {
|
|
let inner: String = section.get(i..).unwrap_or(&[]).iter().take_while(|q| **q != ']').collect();
|
|
i += inner.chars().count() + 1;
|
|
toks.extend(currency(&inner).map(|s| DTok::Lit(s.to_string())));
|
|
}
|
|
'\\' | '_' | '*' => {
|
|
let next = section.get(i).copied();
|
|
i += 1;
|
|
match (c, next) {
|
|
('\\', Some(n)) => toks.push(DTok::Lit(n.to_string())),
|
|
('_', _) => toks.push(DTok::Lit(" ".to_string())),
|
|
_ => {}
|
|
}
|
|
}
|
|
'a' | 'A' if starts_ci(§ion, at, "AM/PM") => {
|
|
i += 4;
|
|
toks.push(DTok::AmPm(true));
|
|
}
|
|
'a' | 'A' if starts_ci(§ion, at, "A/P") => {
|
|
i += 2;
|
|
toks.push(DTok::AmPm(false));
|
|
}
|
|
_ if matches!(c.to_ascii_lowercase(), 'y' | 'm' | 'd' | 'h' | 's') => {
|
|
let mut n = 1;
|
|
while section.get(i).is_some_and(|q| q.eq_ignore_ascii_case(&c)) {
|
|
i += 1;
|
|
n += 1;
|
|
}
|
|
toks.push(DTok::Field(c.to_ascii_lowercase(), n));
|
|
}
|
|
c => toks.push(DTok::Lit(c.to_string())),
|
|
}
|
|
}
|
|
mark_minutes(&mut toks);
|
|
toks
|
|
}
|
|
|
|
/// Whether `chars` from `at` on start with `pat`, ignoring ASCII case.
|
|
fn starts_ci(chars: &[char], at: usize, pat: &str) -> bool {
|
|
pat.chars().enumerate().all(|(k, p)| chars.get(at + k).is_some_and(|c| c.eq_ignore_ascii_case(&p)))
|
|
}
|
|
|
|
/// Turns the `m`/`mm` fields that mean minutes (after an hour, or before seconds) into `n`.
|
|
fn mark_minutes(toks: &mut [DTok]) {
|
|
let fields: Vec<usize> = toks.iter().enumerate().filter(|(_, t)| matches!(t, DTok::Field(..))).map(|(k, _)| k).collect();
|
|
let kind = |k: Option<&usize>, toks: &[DTok]| match k.and_then(|k| toks.get(*k)) {
|
|
Some(DTok::Field(c, _)) => Some(*c),
|
|
_ => None,
|
|
};
|
|
for (j, k) in fields.iter().enumerate() {
|
|
let after_hour = j.checked_sub(1).and_then(|p| kind(fields.get(p), toks)) == Some('h');
|
|
let before_secs = kind(fields.get(j + 1), toks) == Some('s');
|
|
if let Some(DTok::Field(c @ 'm', n)) = toks.get_mut(*k)
|
|
&& *n <= 2
|
|
&& (after_hour || before_secs)
|
|
{
|
|
*c = 'n';
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A moment in a date format: day, month, year, weekday (`ddd`, `dddd`), hour, minute and
|
|
/// second fields replaced (12-hour with AM/PM); quoted text and currency kept, other brackets
|
|
/// dropped.
|
|
fn date_text(t: Stamp, code: &str) -> String {
|
|
let toks = date_toks(code);
|
|
let twelve = toks.iter().any(|k| matches!(k, DTok::AmPm(_)));
|
|
let hour = t.secs / 3600;
|
|
let mut out = String::new();
|
|
for tok in &toks {
|
|
match tok {
|
|
DTok::Lit(s) => out.push_str(s),
|
|
DTok::AmPm(long) => out.push_str(match (hour < 12, long) {
|
|
(true, true) => "AM",
|
|
(false, true) => "PM",
|
|
(true, false) => "A",
|
|
(false, false) => "P",
|
|
}),
|
|
DTok::Field(c, n) => out.push_str(&field_text(t, *c, *n, twelve)),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// One date or time field (`c` as in [`DTok::Field`], `n` letters long).
|
|
fn field_text(t: Stamp, c: char, n: usize, twelve: bool) -> String {
|
|
let month = MONTHS.get((t.m as usize).clamp(1, 12) - 1).copied().unwrap_or("");
|
|
let weekday = WEEKDAYS.get(t.weekday as usize).copied().unwrap_or("");
|
|
let pad = |v: u32| if n >= 2 { format!("{v:02}") } else { v.to_string() };
|
|
let hour = t.secs / 3600;
|
|
match c {
|
|
'y' if n <= 2 => format!("{:02}", t.y.rem_euclid(100)),
|
|
'y' => format!("{:04}", t.y),
|
|
'd' if n <= 2 => pad(t.d),
|
|
'd' if n == 3 => weekday.get(..3).unwrap_or(weekday).to_string(),
|
|
'd' => weekday.to_string(),
|
|
'm' if n <= 2 => pad(t.m),
|
|
'm' if n == 3 => month.get(..3).unwrap_or(month).to_string(),
|
|
'm' if n == 4 => month.to_string(),
|
|
'm' => month.get(..1).unwrap_or(month).to_string(),
|
|
'h' if twelve => pad(if hour.is_multiple_of(12) { 12 } else { hour % 12 }),
|
|
'h' => pad(hour),
|
|
'n' => pad(t.secs / 60 % 60),
|
|
_ => pad(t.secs % 60),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
struct Rect {
|
|
x: f32,
|
|
y: f32,
|
|
w: f32,
|
|
h: f32,
|
|
}
|
|
|
|
impl Rect {
|
|
fn right(self) -> f32 {
|
|
self.x + self.w
|
|
}
|
|
fn bottom(self) -> f32 {
|
|
self.y + self.h
|
|
}
|
|
fn arr(self) -> [f32; 4] {
|
|
[self.x, self.y, self.w, self.h]
|
|
}
|
|
}
|
|
|
|
/// Estimated text width, points: text is placed before it's shaped, at [`CHAR_W`] a character.
|
|
pub(super) fn text_w(s: &str, size: f32) -> f32 {
|
|
s.chars().count() as f32 * size * CHAR_W
|
|
}
|
|
|
|
/// `s`, cut short with an ellipsis to fit `width` points by the [`text_w`] estimate.
|
|
fn clip_text(s: &str, width: f32, size: f32) -> String {
|
|
let fit = (width / (size * CHAR_W)).floor().max(0.0) as usize;
|
|
if s.chars().count() <= fit {
|
|
return s.to_string();
|
|
}
|
|
let mut out: String = s.chars().take(fit.saturating_sub(1)).collect();
|
|
out.push('…');
|
|
out
|
|
}
|
|
|
|
fn widest(items: &[String], size: f32) -> f32 {
|
|
items.iter().map(|s| text_w(s, size)).fold(0.0, f32::max)
|
|
}
|
|
|
|
fn text(out: &mut Vec<GraphicItem>, rect: Rect, s: &str, size: f32, align: TextAlign, bold: bool) {
|
|
out.push(GraphicItem::Text { rect: rect.arr(), text: s.to_string(), size, color: TEXT, bold, align, font: None });
|
|
}
|
|
|
|
/// Text centred on `cx` with its top at `y`.
|
|
fn text_at(out: &mut Vec<GraphicItem>, cx: f32, y: f32, s: &str, size: f32) {
|
|
let w = text_w(s, size) + 4.0;
|
|
text(out, Rect { x: cx - w / 2.0, y, w, h: size * LINE }, s, size, TextAlign::Center, false);
|
|
}
|
|
|
|
fn fill(out: &mut Vec<GraphicItem>, segs: Vec<PathSeg>, color: Rgb) {
|
|
if !segs.is_empty() {
|
|
out.push(GraphicItem::Path { segs, fill: Some(color), stroke: None, stroke_width: 0.0 });
|
|
}
|
|
}
|
|
|
|
fn stroke(out: &mut Vec<GraphicItem>, segs: Vec<PathSeg>, color: Rgb, width: f32) {
|
|
if !segs.is_empty() {
|
|
out.push(GraphicItem::Path { segs, fill: None, stroke: Some(color), stroke_width: width });
|
|
}
|
|
}
|
|
|
|
fn swatch(out: &mut Vec<GraphicItem>, x: f32, y: f32, color: Rgb) {
|
|
out.push(GraphicItem::Shape { rect: [x, y, SWATCH, SWATCH], kind: ShapeKind::Rectangle, fill: Some(color), stroke: None, stroke_width: 0.0 });
|
|
}
|
|
|
|
fn seg(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec<PathSeg> {
|
|
vec![PathSeg::Move(x0, y0), PathSeg::Line(x1, y1)]
|
|
}
|
|
|
|
fn rect_segs(x0: f32, y0: f32, x1: f32, y1: f32) -> Vec<PathSeg> {
|
|
vec![PathSeg::Move(x0, y0), PathSeg::Line(x1, y0), PathSeg::Line(x1, y1), PathSeg::Line(x0, y1), PathSeg::Close]
|
|
}
|
|
|
|
/// Polyline pieces: a gap (`None`) starts a new sub-path.
|
|
fn runs(pts: impl Iterator<Item = Option<(f32, f32)>>) -> Vec<PathSeg> {
|
|
let mut segs = Vec::new();
|
|
let mut open = false;
|
|
for p in pts {
|
|
match p {
|
|
Some((x, y)) => {
|
|
segs.push(if open { PathSeg::Line(x, y) } else { PathSeg::Move(x, y) });
|
|
open = true;
|
|
}
|
|
None => open = false,
|
|
}
|
|
}
|
|
segs
|
|
}
|
|
|
|
/// Words of `s` in lines of at most `width` points (a word wider than that keeps its own line).
|
|
fn wrap(s: &str, width: f32, size: f32) -> Vec<String> {
|
|
let mut lines: Vec<String> = Vec::new();
|
|
let mut cur = String::new();
|
|
for word in s.split_whitespace() {
|
|
let joined = if cur.is_empty() { word.to_string() } else { format!("{cur} {word}") };
|
|
if cur.is_empty() || text_w(&joined, size) <= width {
|
|
cur = joined;
|
|
} else {
|
|
lines.push(std::mem::take(&mut cur));
|
|
cur = word.to_string();
|
|
}
|
|
}
|
|
if !cur.is_empty() {
|
|
lines.push(cur);
|
|
}
|
|
if lines.is_empty() {
|
|
lines.push(String::new());
|
|
}
|
|
lines.truncate(3);
|
|
lines
|
|
}
|
|
|
|
const SWATCH: f32 = 7.0;
|
|
/// Space between legend entries in a row, points.
|
|
const SEP: f32 = 10.0;
|
|
|
|
/// Legend in the given side of `area`; returns the area left for the plot.
|
|
fn legend(out: &mut Vec<GraphicItem>, entries: &[(String, Rgb)], pos: Pos, area: Rect, fs: f32) -> Rect {
|
|
if entries.is_empty() {
|
|
return area;
|
|
}
|
|
// Entry texts are cut to a share of the chart, so a long name can't push the legend out.
|
|
let entries: Vec<(String, Rgb)> = entries.iter().map(|(s, c)| (clip_text(s, area.w * LEGEND_SHARE, fs), *c)).collect();
|
|
match pos {
|
|
Pos::Right | Pos::Left => side_legend(out, &entries, pos == Pos::Left, area, fs),
|
|
Pos::Top | Pos::Bottom => row_legend(out, &entries, pos == Pos::Top, area, fs),
|
|
}
|
|
}
|
|
|
|
/// A legend in a column at the left or right of `area`, keeping only the entries that fit above
|
|
/// its bottom; returns the area left for the plot.
|
|
fn side_legend(out: &mut Vec<GraphicItem>, entries: &[(String, Rgb)], left: bool, area: Rect, fs: f32) -> Rect {
|
|
let lh = fs * LINE;
|
|
let entries = entries.get(..((area.h / lh).floor().max(0.0) as usize).min(entries.len())).unwrap_or(&[]);
|
|
if entries.is_empty() {
|
|
return area;
|
|
}
|
|
let tw = entries.iter().map(|(s, _)| text_w(s, fs)).fold(0.0, f32::max);
|
|
let block_w = SWATCH + GAP + tw;
|
|
let block_h = lh * entries.len() as f32;
|
|
let x = if left { area.x } else { area.right() - block_w };
|
|
let y0 = area.y + ((area.h - block_h) / 2.0).max(0.0);
|
|
for (k, (s, c)) in entries.iter().enumerate() {
|
|
let y = y0 + lh * k as f32;
|
|
swatch(out, x, y + (lh - SWATCH) / 2.0, *c);
|
|
text(out, Rect { x: x + SWATCH + GAP, y, w: tw + 2.0, h: lh }, s, fs, TextAlign::Left, false);
|
|
}
|
|
let used = block_w + PAD;
|
|
let mut area = area;
|
|
area.w = (area.w - used).max(1.0);
|
|
if left {
|
|
area.x += used;
|
|
}
|
|
area
|
|
}
|
|
|
|
/// A legend in centred rows the width of `area` above or below it (at most
|
|
/// [`MAX_LEGEND_ROWS`], and half its height); returns the area left for the plot.
|
|
fn row_legend(out: &mut Vec<GraphicItem>, entries: &[(String, Rgb)], top: bool, area: Rect, fs: f32) -> Rect {
|
|
let lh = fs * LINE;
|
|
let max_rows = MAX_LEGEND_ROWS.min((area.h / 2.0 / lh).floor().max(1.0) as usize);
|
|
let rows = legend_rows(entries, area.w, fs, max_rows);
|
|
let y0 = if top { area.y } else { area.bottom() - lh * rows.len() as f32 };
|
|
for (r, row) in rows.iter().enumerate() {
|
|
let total = row.iter().map(|(_, _, w)| SWATCH + GAP + w).sum::<f32>() + SEP * row.len().saturating_sub(1) as f32;
|
|
let mut x = area.x + ((area.w - total) / 2.0).max(0.0);
|
|
let y = y0 + lh * r as f32;
|
|
for (s, c, w) in row {
|
|
swatch(out, x, y + (lh - SWATCH) / 2.0, *c);
|
|
text(out, Rect { x: x + SWATCH + GAP, y, w: w + 2.0, h: lh }, s, fs, TextAlign::Left, false);
|
|
x += SWATCH + GAP + w + SEP;
|
|
}
|
|
}
|
|
let used = lh * rows.len() as f32 + PAD;
|
|
let mut area = area;
|
|
area.h = (area.h - used).max(1.0);
|
|
if top {
|
|
area.y += used;
|
|
}
|
|
area
|
|
}
|
|
|
|
/// Legend entries (text, colour, text width) packed into at most `max_rows` rows of `width`
|
|
/// points; the entries past the last row are left out.
|
|
fn legend_rows(entries: &[(String, Rgb)], width: f32, fs: f32, max_rows: usize) -> Vec<Vec<(String, Rgb, f32)>> {
|
|
let mut rows: Vec<Vec<(String, Rgb, f32)>> = Vec::new();
|
|
let mut row_w = 0.0;
|
|
for (s, c) in entries {
|
|
let w = text_w(s, fs);
|
|
let entry_w = SWATCH + GAP + w;
|
|
let fits = !rows.is_empty() && row_w + SEP + entry_w <= width;
|
|
if fits {
|
|
row_w += SEP + entry_w;
|
|
} else if rows.len() < max_rows {
|
|
rows.push(Vec::new());
|
|
row_w = entry_w;
|
|
} else {
|
|
break;
|
|
}
|
|
if let Some(row) = rows.last_mut() {
|
|
row.push((s.clone(), *c, w));
|
|
}
|
|
}
|
|
rows
|
|
}
|
|
|
|
/// The plot rectangle and the scales that map values and categories onto it.
|
|
struct Frame {
|
|
plot: Rect,
|
|
horiz: bool,
|
|
n: usize,
|
|
vs: Scale,
|
|
xs: Option<Scale>,
|
|
mid: bool,
|
|
}
|
|
|
|
impl Frame {
|
|
/// Width of one category band.
|
|
fn band(&self) -> f32 {
|
|
let len = if self.horiz { self.plot.h } else { self.plot.w };
|
|
len / self.n.max(1) as f32
|
|
}
|
|
|
|
/// Start of category `i`'s band: its left edge, or for bars its top edge (category 0 at the
|
|
/// bottom).
|
|
fn band_start(&self, i: usize) -> f32 {
|
|
let k = i as f32;
|
|
if self.horiz { self.plot.bottom() - (k + 1.0) * self.band() } else { self.plot.x + k * self.band() }
|
|
}
|
|
|
|
/// A value's position along the value direction: y for columns, x for bars.
|
|
fn val(&self, v: f64) -> f32 {
|
|
let t = ((v - self.vs.lo) / (self.vs.hi - self.vs.lo)).clamp(0.0, 1.0) as f32;
|
|
if self.horiz { self.plot.x + t * self.plot.w } else { self.plot.bottom() - t * self.plot.h }
|
|
}
|
|
|
|
/// x of category `i` for line and area points.
|
|
fn cat_x(&self, i: usize) -> f32 {
|
|
if !self.mid {
|
|
return self.plot.x + (i as f32 + 0.5) * self.band();
|
|
}
|
|
if self.n <= 1 { self.plot.x + self.plot.w / 2.0 } else { self.plot.x + self.plot.w * i as f32 / (self.n - 1) as f32 }
|
|
}
|
|
|
|
/// x of a scatter point.
|
|
fn scat_x(&self, x: f64) -> f32 {
|
|
match self.xs {
|
|
Some(s) => self.plot.x + ((x - s.lo) / (s.hi - s.lo)).clamp(0.0, 1.0) as f32 * self.plot.w,
|
|
None => self.plot.x,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The value scale and, for scatter charts, the x scale, with their ticks and tick texts.
|
|
struct Axes {
|
|
vs: Scale,
|
|
vticks: Vec<(f64, String)>,
|
|
xs: Option<Scale>,
|
|
xticks: Vec<(f64, String)>,
|
|
}
|
|
|
|
impl Axes {
|
|
fn of(c: &Chart) -> Axes {
|
|
let (dlo, dhi) = extent(c);
|
|
let (vmin, vmax) = if c.grouping == Grouping::Percent { (Some(0.0), Some(1.0)) } else { (c.val.min, c.val.max) };
|
|
// Lines and scatters don't need zero for a narrow range (1000..1050 stays on its own scale).
|
|
let zero = !matches!(c.kind, Kind::Line | Kind::Scatter) || !narrow(dlo, dhi);
|
|
let vs = nice_scale(dlo, dhi, zero, vmin, vmax);
|
|
let vticks = vs.ticks().into_iter().map(|t| (t, format_value(t, &c.val_fmt, vs.step))).collect();
|
|
let xs = (c.kind == Kind::Scatter).then(|| {
|
|
let (xl, xh) = x_extent(c);
|
|
nice_scale(xl, xh, false, c.cat.min, c.cat.max)
|
|
});
|
|
// Scatter x values show in the axis's own format, else their cache's (dates included).
|
|
let xfmt = c.cat.fmt.clone().or_else(|| c.series.iter().find_map(|s| s.x_fmt.clone())).unwrap_or_else(|| "General".to_string());
|
|
let xticks = xs.map(|s| s.ticks().into_iter().map(|t| (t, category_text(t, &xfmt, s.step, c.date1904))).collect()).unwrap_or_default();
|
|
Axes { vs, vticks, xs, xticks }
|
|
}
|
|
}
|
|
|
|
/// The room around the plot for axis labels, and the category labels as they'll be drawn.
|
|
struct Margins {
|
|
left: f32,
|
|
top: f32,
|
|
bottom: f32,
|
|
/// Category labels in lines (none for scatter charts or a deleted category axis).
|
|
wrapped: Vec<Vec<String>>,
|
|
/// Every `skip`-th category label is shown under a column chart.
|
|
skip: usize,
|
|
}
|
|
|
|
/// The margins the axis labels need around the plot in `area`. Category labels under a column
|
|
/// chart wrap to the band; labels that still don't fit show every k-th category.
|
|
fn axis_margins(c: &Chart, ax: &Axes, area: Rect) -> Margins {
|
|
let fs = c.text_size;
|
|
let lh = fs * LINE;
|
|
let scatter = c.kind == Kind::Scatter;
|
|
let (cat_on, val_on) = (!c.cat.deleted, !c.val.deleted);
|
|
let left = if c.horizontal {
|
|
if cat_on { widest(&c.cats, fs).min(area.w * 0.4) + GAP } else { 0.0 }
|
|
} else if val_on {
|
|
ax.vticks.iter().map(|(_, s)| text_w(s, fs)).fold(0.0, f32::max) + GAP
|
|
} else {
|
|
0.0
|
|
};
|
|
let band_w = (area.w - left).max(1.0) / c.n as f32;
|
|
let wrapped: Vec<Vec<String>> = if cat_on && !scatter {
|
|
let width = if c.horizontal { left - GAP } else { band_w };
|
|
c.cats.iter().map(|s| wrap(s, width, fs)).collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let widest_line = wrapped.iter().flatten().map(|s| text_w(s, fs)).fold(0.0, f32::max);
|
|
let skip = ((widest_line / band_w).ceil() as usize).max(1);
|
|
let label_lines = wrapped.iter().map(Vec::len).max().unwrap_or(1) as f32;
|
|
let bottom = if cat_on && !c.horizontal {
|
|
if scatter { lh + GAP } else { label_lines * lh + GAP }
|
|
} else if c.horizontal && val_on {
|
|
lh + GAP
|
|
} else {
|
|
0.0
|
|
};
|
|
let top = if !c.horizontal && val_on { lh / 2.0 } else { 0.0 };
|
|
Margins { left, top, bottom, wrapped, skip }
|
|
}
|
|
|
|
/// A bar, column, line, area or scatter chart in `area`: gridlines, series, axes, then labels.
|
|
fn cartesian(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
|
|
if c.n == 0 {
|
|
return;
|
|
}
|
|
let ax = Axes::of(c);
|
|
let m = axis_margins(c, &ax, area);
|
|
let plot = Rect { x: area.x + m.left, y: area.y + m.top, w: area.w - m.left, h: area.h - m.top - m.bottom };
|
|
if plot.w < 1.0 || plot.h < 1.0 {
|
|
return;
|
|
}
|
|
let g = Frame { plot, horiz: c.horizontal, n: c.n, vs: ax.vs, xs: ax.xs, mid: c.val.mid };
|
|
if c.val.grid {
|
|
draw_grid(out, &g, &ax.vticks);
|
|
}
|
|
let mut labels = Vec::new();
|
|
match c.kind {
|
|
Kind::Bar => bars(out, c, &g, &mut labels),
|
|
Kind::Line => lines(out, c, &g, &mut labels),
|
|
Kind::Area => areas(out, c, &g, &mut labels),
|
|
Kind::Scatter => draw_scatter(out, c, &g, &mut labels),
|
|
Kind::Pie | Kind::Doughnut => {}
|
|
}
|
|
draw_axes(out, c, &g);
|
|
draw_data_labels(out, &labels, c.text_size, g.horiz && c.kind == Kind::Bar);
|
|
if !c.val.deleted {
|
|
draw_value_labels(out, c, &g, &ax.vticks, Rect { w: m.left, ..area });
|
|
}
|
|
if !c.cat.deleted {
|
|
draw_category_labels(out, c, &g, &ax.xticks, &m, area);
|
|
}
|
|
}
|
|
|
|
/// Major gridlines at the value ticks.
|
|
fn draw_grid(out: &mut Vec<GraphicItem>, g: &Frame, ticks: &[(f64, String)]) {
|
|
let plot = g.plot;
|
|
for (t, _) in ticks {
|
|
let p = g.val(*t);
|
|
let segs = if g.horiz { seg(p, plot.y, p, plot.bottom()) } else { seg(plot.x, p, plot.right(), p) };
|
|
stroke(out, segs, GRID, 0.75);
|
|
}
|
|
}
|
|
|
|
/// The category axis line, the plot area's outline and the value axis line, as the file shows them.
|
|
fn draw_axes(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame) {
|
|
let plot = g.plot;
|
|
if !c.cat.deleted && c.cat.line {
|
|
let segs = if g.horiz { seg(plot.x, plot.y, plot.x, plot.bottom()) } else { seg(plot.x, plot.bottom(), plot.right(), plot.bottom()) };
|
|
stroke(out, segs, AXIS, 0.75);
|
|
}
|
|
if let Some(color) = c.plot_border {
|
|
stroke(out, rect_segs(plot.x, plot.y, plot.right(), plot.bottom()), color, 0.75);
|
|
}
|
|
if !c.val.deleted && c.val.line {
|
|
let segs = if g.horiz { seg(plot.x, plot.bottom(), plot.right(), plot.bottom()) } else { seg(plot.x, plot.y, plot.x, plot.bottom()) };
|
|
stroke(out, segs, AXIS, 0.75);
|
|
}
|
|
}
|
|
|
|
/// Value tick labels: under the plot for bars, else right-aligned in `margin` (the area's left
|
|
/// margin).
|
|
fn draw_value_labels(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, ticks: &[(f64, String)], margin: Rect) {
|
|
let (fs, lh) = (c.text_size, c.text_size * LINE);
|
|
for (t, s) in ticks {
|
|
let p = g.val(*t);
|
|
if g.horiz {
|
|
text_at(out, p, g.plot.bottom() + GAP / 2.0, s, fs);
|
|
} else {
|
|
text(out, Rect { x: margin.x, y: p - lh / 2.0, w: margin.w - GAP, h: lh }, s, fs, TextAlign::Right, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Category labels: scatter x ticks under the plot, bar labels in the left margin, column
|
|
/// labels under their bands (every `m.skip`-th).
|
|
fn draw_category_labels(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, xticks: &[(f64, String)], m: &Margins, area: Rect) {
|
|
let (fs, lh) = (c.text_size, c.text_size * LINE);
|
|
let plot = g.plot;
|
|
if c.kind == Kind::Scatter {
|
|
for (t, s) in xticks {
|
|
text_at(out, g.scat_x(*t), plot.bottom() + GAP / 2.0, s, fs);
|
|
}
|
|
} else if g.horiz {
|
|
for (i, lines) in m.wrapped.iter().enumerate() {
|
|
let top = g.band_start(i) + g.band() / 2.0 - lines.len() as f32 * lh / 2.0;
|
|
for (k, s) in lines.iter().enumerate() {
|
|
text(out, Rect { x: area.x, y: top + k as f32 * lh, w: m.left - GAP, h: lh }, s, fs, TextAlign::Right, false);
|
|
}
|
|
}
|
|
} else {
|
|
let band = g.band();
|
|
for (i, lines) in m.wrapped.iter().enumerate().filter(|(i, _)| i % m.skip == 0) {
|
|
for (k, s) in lines.iter().enumerate() {
|
|
let y = plot.bottom() + GAP / 2.0 + k as f32 * lh;
|
|
text(out, Rect { x: g.cat_x(i) - band / 2.0, y, w: band, h: lh }, s, fs, TextAlign::Center, false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether data `lo..hi` is a narrow band away from zero: its span is under a sixth of the end
|
|
/// farther from zero (the mirror image for negative data).
|
|
fn narrow(lo: f64, hi: f64) -> bool {
|
|
if lo > 0.0 {
|
|
(hi - lo) / hi < 1.0 / 6.0
|
|
} else if hi < 0.0 {
|
|
(hi - lo) / lo.abs() < 1.0 / 6.0
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Lowest and highest value of the plot; stacked series add up per category.
|
|
fn extent(c: &Chart) -> (f64, f64) {
|
|
let mut pts: Vec<f64> = Vec::new();
|
|
if c.stacked() {
|
|
for i in 0..c.n {
|
|
let (mut p, mut q) = (0.0, 0.0);
|
|
for s in &c.series {
|
|
match s.values.get(i).copied().flatten() {
|
|
Some(v) if v >= 0.0 => p += v,
|
|
Some(v) => q += v,
|
|
None => {}
|
|
}
|
|
}
|
|
pts.push(p);
|
|
pts.push(q);
|
|
}
|
|
} else {
|
|
pts.extend(c.series.iter().flat_map(|s| s.values.iter().flatten().copied()));
|
|
}
|
|
let lo = pts.iter().copied().fold(f64::INFINITY, f64::min);
|
|
let hi = pts.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
|
if lo.is_finite() && hi.is_finite() { (lo, hi) } else { (0.0, 0.0) }
|
|
}
|
|
|
|
/// Range of scatter x values (index + 1 where missing).
|
|
fn x_extent(c: &Chart) -> (f64, f64) {
|
|
let xs: Vec<f64> = c.series.iter().flat_map(|s| s.xs.iter().flatten().copied()).collect();
|
|
let lo = xs.iter().copied().fold(f64::INFINITY, f64::min);
|
|
let hi = xs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
|
|
if lo.is_finite() && hi.is_finite() { (lo, hi) } else { (0.0, 1.0) }
|
|
}
|
|
|
|
fn bars(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
|
|
let stacked = c.stacked();
|
|
let slots = if stacked { 1 } else { c.series.len().max(1) };
|
|
let ov = if stacked { 0.0 } else { c.overlap };
|
|
let ns = slots as f32;
|
|
let band = g.band();
|
|
let thick = band / (ns - (ns - 1.0) * ov + c.gap);
|
|
let group = thick * (ns - (ns - 1.0) * ov);
|
|
let mut pos = vec![0.0f64; g.n];
|
|
let mut neg = vec![0.0f64; g.n];
|
|
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
|
|
let slot = if stacked { 0 } else { j };
|
|
let slot = (if g.horiz { slots.saturating_sub(1 + slot) } else { slot }) as f32;
|
|
for i in 0..g.n {
|
|
let Some(v) = s.values.get(i).copied().flatten() else { continue };
|
|
let (v0, v1) = if !stacked {
|
|
(0.0, v)
|
|
} else if v >= 0.0 {
|
|
let Some(p) = pos.get_mut(i) else { continue };
|
|
let b = *p;
|
|
*p += v;
|
|
(b, *p)
|
|
} else {
|
|
let Some(q) = neg.get_mut(i) else { continue };
|
|
let b = *q;
|
|
*q += v;
|
|
(b, *q)
|
|
};
|
|
let a = g.band_start(i) + (band - group) / 2.0 + slot * thick * (1.0 - ov);
|
|
let (p0, p1) = (g.val(v0), g.val(v1));
|
|
let (p0, p1) = (p0.min(p1), p0.max(p1));
|
|
let segs = if g.horiz { rect_segs(p0, a, p1, a + thick) } else { rect_segs(a, p0, a + thick, p1) };
|
|
fill(out, segs, c.point_colour(j, i));
|
|
if let Some(text) = label_of(s, i) {
|
|
let mid = a + thick / 2.0;
|
|
let (x, y) = match (g.horiz, stacked) {
|
|
(true, true) => ((p0 + p1) / 2.0, mid),
|
|
(false, true) => (mid, (p0 + p1) / 2.0),
|
|
// Beyond the bar's end: right of a positive bar, above a positive column.
|
|
(true, false) => (if v >= 0.0 { p1 } else { p0 }, mid),
|
|
(false, false) => (mid, if v >= 0.0 { p0 } else { p1 }),
|
|
};
|
|
labels.push(DataLabel { x, y, text, inside: stacked });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether a series has any point to draw.
|
|
fn has_values(s: &Series) -> bool {
|
|
s.values.iter().any(Option::is_some)
|
|
}
|
|
|
|
/// Line series; stacked ones are drawn at the running total of the series up to them.
|
|
fn lines(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
|
|
let mut total = vec![0.0f64; g.n];
|
|
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
|
|
let pts: Vec<Option<(f32, f32)>> = (0..g.n)
|
|
.map(|i| {
|
|
let v = s.values.get(i).copied().flatten()?;
|
|
let y = match total.get_mut(i) {
|
|
Some(t) if c.stacked() => {
|
|
*t += v;
|
|
*t
|
|
}
|
|
_ => v,
|
|
};
|
|
Some((g.cat_x(i), g.val(y)))
|
|
})
|
|
.collect();
|
|
for (i, p) in pts.iter().enumerate() {
|
|
if let (Some((x, y)), Some(text)) = (p, label_of(s, i)) {
|
|
labels.push(DataLabel { x: *x, y: *y, text, inside: false });
|
|
}
|
|
}
|
|
stroke(out, runs(pts.into_iter()), c.series_colour(j), s.line_w);
|
|
}
|
|
}
|
|
|
|
fn areas(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
|
|
let mut base = vec![0.0f64; g.n];
|
|
for (j, s) in c.series.iter().enumerate().filter(|(_, s)| has_values(s)) {
|
|
let vals = (0..g.n).map(|i| s.values.get(i).copied().flatten().unwrap_or(0.0));
|
|
let top: Vec<f64> = base.iter().zip(vals).map(|(b, v)| b + v).collect();
|
|
let mut segs = Vec::with_capacity(2 * g.n + 1);
|
|
for (i, t) in top.iter().enumerate() {
|
|
let (x, y) = (g.cat_x(i), g.val(*t));
|
|
segs.push(if i == 0 { PathSeg::Move(x, y) } else { PathSeg::Line(x, y) });
|
|
}
|
|
for (i, b) in base.iter().enumerate().rev() {
|
|
segs.push(PathSeg::Line(g.cat_x(i), g.val(*b)));
|
|
}
|
|
segs.push(PathSeg::Close);
|
|
fill(out, segs, c.series_colour(j));
|
|
for (i, (t, b)) in top.iter().zip(&base).enumerate() {
|
|
if let Some(text) = label_of(s, i) {
|
|
labels.push(DataLabel { x: g.cat_x(i), y: g.val((t + b) / 2.0), text, inside: true });
|
|
}
|
|
}
|
|
if c.stacked() {
|
|
base = top;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Scatter series as lines, or as small markers when the series has no line.
|
|
fn draw_scatter(out: &mut Vec<GraphicItem>, c: &Chart, g: &Frame, labels: &mut Vec<DataLabel>) {
|
|
const MARKER: f32 = 6.0;
|
|
for (j, s) in c.series.iter().enumerate() {
|
|
let pts: Vec<Option<(f32, f32)>> =
|
|
s.xs.iter()
|
|
.zip(&s.values)
|
|
.map(|(x, y)| match (x, y) {
|
|
(Some(x), Some(y)) => Some((g.scat_x(*x), g.val(*y))),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
for (i, p) in pts.iter().enumerate() {
|
|
if let (Some((x, y)), Some(text)) = (p, label_of(s, i)) {
|
|
labels.push(DataLabel { x: *x, y: *y - MARKER / 2.0, text, inside: false });
|
|
}
|
|
}
|
|
if s.line {
|
|
stroke(out, runs(pts.iter().copied()), c.series_colour(j), s.line_w);
|
|
continue;
|
|
}
|
|
for (x, y) in pts.iter().flatten() {
|
|
let rect = [x - MARKER / 2.0, y - MARKER / 2.0, MARKER, MARKER];
|
|
out.push(GraphicItem::Shape { rect, kind: ShapeKind::Ellipse, fill: Some(c.series_colour(j)), stroke: None, stroke_width: 0.0 });
|
|
}
|
|
}
|
|
}
|
|
|
|
fn pie(out: &mut Vec<GraphicItem>, c: &Chart, area: Rect) {
|
|
let Some(s) = c.series.first() else { return };
|
|
let vals: Vec<f64> = s.values.iter().map(|v| v.unwrap_or(0.0).max(0.0)).collect();
|
|
let total: f64 = vals.iter().sum();
|
|
let r = (area.w.min(area.h) / 2.0 - 2.0) as f64;
|
|
if !(total > 0.0 && total.is_finite() && r >= 1.0) {
|
|
return;
|
|
}
|
|
let (cx, cy) = ((area.x + area.w / 2.0) as f64, (area.y + area.h / 2.0) as f64);
|
|
let inner = if c.kind == Kind::Doughnut { r * c.hole as f64 } else { 0.0 };
|
|
let mut a = c.first_angle.to_radians();
|
|
let mut labels = Vec::new();
|
|
for (i, v) in vals.iter().enumerate() {
|
|
if *v <= 0.0 {
|
|
continue;
|
|
}
|
|
let b = a + v / total * TAU;
|
|
let mut segs = Vec::new();
|
|
if inner > 0.0 {
|
|
segs.push(pt(polar(cx, cy, r, a)));
|
|
arc(&mut segs, (cx, cy, r), a, b);
|
|
segs.push(line_to(polar(cx, cy, inner, b)));
|
|
arc(&mut segs, (cx, cy, inner), b, a);
|
|
} else {
|
|
segs.push(pt((cx, cy)));
|
|
segs.push(line_to(polar(cx, cy, r, a)));
|
|
arc(&mut segs, (cx, cy, r), a, b);
|
|
}
|
|
segs.push(PathSeg::Close);
|
|
fill(out, segs, c.point_colour(0, i));
|
|
if let Some(text) = label_of(s, i) {
|
|
// Halfway across the ring (pie: two thirds out from the centre).
|
|
let at = if inner > 0.0 { (r + inner) / 2.0 } else { r * 0.65 };
|
|
let (x, y) = polar(cx, cy, at, (a + b) / 2.0);
|
|
labels.push(DataLabel { x: x as f32, y: y as f32, text, inside: true });
|
|
}
|
|
a = b;
|
|
}
|
|
draw_data_labels(out, &labels, c.text_size, false);
|
|
}
|
|
|
|
/// The data label of point `i` of series `s`, when it shows one: the value as the file has it,
|
|
/// in the series' number format.
|
|
fn label_of(s: &Series, i: usize) -> Option<String> {
|
|
if s.labels != Some(true) {
|
|
return None;
|
|
}
|
|
let v = s.raw.get(i).copied().flatten()?;
|
|
let fmt = s.fmt.as_deref().filter(|f| !f.eq_ignore_ascii_case("General"));
|
|
Some(match fmt {
|
|
Some(f) => format_value(v, f, 1.0),
|
|
None => general_value(v),
|
|
})
|
|
}
|
|
|
|
/// A value in the `General` format: whole numbers without decimals, others with up to six.
|
|
fn general_value(v: f64) -> String {
|
|
if v.fract() == 0.0 && v.abs() < 1e15 {
|
|
return format!("{v:.0}");
|
|
}
|
|
let s = format!("{v:.6}");
|
|
let s = s.trim_end_matches('0').trim_end_matches('.');
|
|
if s == "-0" { "0".to_string() } else { s.to_string() }
|
|
}
|
|
|
|
/// Data labels over the drawn chart. Beside labels sit above their point, or right of it for
|
|
/// horizontal bars (`beside_right`).
|
|
fn draw_data_labels(out: &mut Vec<GraphicItem>, labels: &[DataLabel], fs: f32, beside_right: bool) {
|
|
let lh = fs * LINE;
|
|
for l in labels {
|
|
let w = text_w(&l.text, fs) + 4.0;
|
|
let (rect, align) = if l.inside {
|
|
(Rect { x: l.x - w / 2.0, y: l.y - lh / 2.0, w, h: lh }, TextAlign::Center)
|
|
} else if beside_right {
|
|
(Rect { x: l.x + GAP / 2.0, y: l.y - lh / 2.0, w, h: lh }, TextAlign::Left)
|
|
} else {
|
|
(Rect { x: l.x - w / 2.0, y: l.y - lh, w, h: lh }, TextAlign::Center)
|
|
};
|
|
let color = if l.inside { Rgb::WHITE } else { TEXT };
|
|
out.push(GraphicItem::Text { rect: rect.arr(), text: l.text.clone(), size: fs, color, bold: false, align, font: None });
|
|
}
|
|
}
|
|
|
|
/// A point at clockwise angle `t` (radians from 12 o'clock) on a circle of radius `r`.
|
|
fn polar(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) {
|
|
(cx + r * t.sin(), cy - r * t.cos())
|
|
}
|
|
|
|
fn pt(p: (f64, f64)) -> PathSeg {
|
|
PathSeg::Move(p.0 as f32, p.1 as f32)
|
|
}
|
|
|
|
fn line_to(p: (f64, f64)) -> PathSeg {
|
|
PathSeg::Line(p.0 as f32, p.1 as f32)
|
|
}
|
|
|
|
/// Cubic segments along a circle from angle `a` to `b` (the current point is at `a`). Each piece
|
|
/// spans at most a quarter turn.
|
|
fn arc(segs: &mut Vec<PathSeg>, (cx, cy, r): (f64, f64, f64), a: f64, b: f64) {
|
|
let n = (((b - a).abs() / FRAC_PI_2).ceil() as usize).clamp(1, 16);
|
|
let d = (b - a) / n as f64;
|
|
let k = 4.0 / 3.0 * (d / 4.0).tan();
|
|
for j in 0..n {
|
|
let t0 = a + d * j as f64;
|
|
let t1 = t0 + d;
|
|
let p0 = polar(cx, cy, r, t0);
|
|
let p1 = polar(cx, cy, r, t1);
|
|
let c1 = (p0.0 + k * r * t0.cos(), p0.1 + k * r * t0.sin());
|
|
let c2 = (p1.0 - k * r * t1.cos(), p1.1 - k * r * t1.sin());
|
|
segs.push(PathSeg::Cubic(c1.0 as f32, c1.1 as f32, c2.0 as f32, c2.1 as f32, p1.0 as f32, p1.1 as f32));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use wordcraft_doc::THEME_COLORS;
|
|
|
|
const NS: &str =
|
|
r#"xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
|
|
|
|
fn space(plot: &str) -> El {
|
|
let x = format!(r#"<c:chartSpace {NS}><c:chart><c:plotArea>{plot}</c:plotArea></c:chart></c:chartSpace>"#);
|
|
crate::xml::parse(x.as_bytes()).unwrap()
|
|
}
|
|
|
|
fn theme() -> Vec<Rgb> {
|
|
THEME_COLORS.to_vec()
|
|
}
|
|
|
|
fn ser(i: usize, name: &str, color: Option<&str>, cats: &[&str], vals: &[&str]) -> String {
|
|
let fill = color.map_or(String::new(), |c| format!(r#"<c:spPr><a:solidFill><a:srgbClr val="{c}"/></a:solidFill></c:spPr>"#));
|
|
let cat_pts: String = cats.iter().enumerate().map(|(k, c)| format!(r#"<c:pt idx="{k}"><c:v>{c}</c:v></c:pt>"#)).collect();
|
|
let val_pts: String = vals.iter().enumerate().map(|(k, v)| format!(r#"<c:pt idx="{k}"><c:v>{v}</c:v></c:pt>"#)).collect();
|
|
format!(
|
|
r#"<c:ser><c:idx val="{i}"/><c:order val="{i}"/><c:tx><c:strRef><c:strCache><c:ptCount val="1"/><c:pt idx="0"><c:v>{name}</c:v></c:pt></c:strCache></c:strRef></c:tx>{fill}<c:cat><c:strRef><c:strCache><c:ptCount val="{nc}"/>{cat_pts}</c:strCache></c:strRef></c:cat><c:val><c:numRef><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="{nv}"/>{val_pts}</c:numCache></c:numRef></c:val></c:ser>"#,
|
|
nc = cats.len(),
|
|
nv = vals.len()
|
|
)
|
|
}
|
|
|
|
fn filled(items: &[GraphicItem]) -> Vec<(Rgb, f32, f32)> {
|
|
items
|
|
.iter()
|
|
.filter_map(|it| match it {
|
|
GraphicItem::Path { segs, fill: Some(c), .. } => {
|
|
let ys: Vec<f32> = segs
|
|
.iter()
|
|
.filter_map(|s| match s {
|
|
PathSeg::Move(_, y) | PathSeg::Line(_, y) => Some(*y),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
let lo = ys.iter().copied().fold(f32::INFINITY, f32::min);
|
|
let hi = ys.iter().copied().fold(f32::NEG_INFINITY, f32::max);
|
|
Some((*c, lo, hi))
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn clustered_columns_draw_one_bar_per_point() {
|
|
let plot = format!(
|
|
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{}{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/><c:majorGridlines/></c:valAx>"#,
|
|
ser(0, "North", Some("FF0000"), &["Q1", "Q2", "Q3"], &["4", "8", "2"]),
|
|
ser(1, "South", Some("00FF00"), &["Q1", "Q2", "Q3"], &["2", "6", "4"]),
|
|
);
|
|
let items = chart_items(&space(&plot), &theme(), 300.0, 200.0);
|
|
let bars = filled(&items);
|
|
assert_eq!(bars.len(), 6);
|
|
let heights: Vec<f32> = bars.iter().map(|(_, lo, hi)| hi - lo).collect();
|
|
// Scale 0..8: North Q2 is the full plot height, North Q1 half of it.
|
|
assert!((heights[1] / heights[0] - 2.0).abs() < 1e-3, "{heights:?}");
|
|
// South (second series) Q1 is 2 of 8, Q2 is 6 of 8.
|
|
assert!((heights[3] / heights[1] - 0.25).abs() < 1e-3, "{heights:?}");
|
|
assert!((heights[4] / heights[1] - 0.75).abs() < 1e-3, "{heights:?}");
|
|
assert_eq!(bars[0].0, Rgb(0xFF, 0, 0));
|
|
assert_eq!(bars[3].0, Rgb(0, 0xFF, 0));
|
|
assert!(items.iter().any(|it| matches!(it, GraphicItem::Text { text, .. } if text == "Q2")));
|
|
}
|
|
|
|
#[test]
|
|
fn data_labels_show_the_values() {
|
|
let texts = |items: &[GraphicItem]| -> Vec<String> {
|
|
items.iter().filter_map(|it| if let GraphicItem::Text { text, .. } = it { Some(text.clone()) } else { None }).collect()
|
|
};
|
|
let labels = r#"<c:dLbls><c:showLegendKey val="0"/><c:showVal val="1"/></c:dLbls>"#;
|
|
let plot = format!(
|
|
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{}{labels}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
|
|
ser(0, "North", None, &["Q1", "Q2"], &["4.25", "17"]),
|
|
);
|
|
let shown = texts(&chart_items(&space(&plot), &theme(), 300.0, 200.0));
|
|
assert!(shown.contains(&"4.25".to_string()) && shown.contains(&"17".to_string()), "{shown:?}");
|
|
let pie = format!(r#"<c:pieChart><c:varyColors val="1"/>{}{labels}</c:pieChart>"#, ser(0, "S", None, &["A", "B"], &["1", "3"]));
|
|
let shown = texts(&chart_items(&space(&pie), &theme(), 200.0, 150.0));
|
|
assert!(shown.contains(&"3".to_string()), "{shown:?}");
|
|
// Without `c:dLbls`, no values are written next to the points.
|
|
let plain = plot.replace(labels, "");
|
|
assert!(!texts(&chart_items(&space(&plain), &theme(), 300.0, 200.0)).contains(&"4.25".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn pie_draws_one_slice_per_point() {
|
|
let plot =
|
|
format!(r#"<c:pieChart><c:varyColors val="1"/>{}</c:pieChart>"#, ser(0, "Share", None, &["A", "B", "C", "D"], &["50", "25", "15", "10"]));
|
|
let items = chart_items(&space(&plot), &theme(), 200.0, 150.0);
|
|
let slices = filled(&items);
|
|
assert_eq!(slices.len(), 4);
|
|
assert_eq!(slices[0].0, theme()[4], "first slice is accent1");
|
|
}
|
|
|
|
#[test]
|
|
fn doughnut_slices_are_annuli() {
|
|
let plot = format!(
|
|
r#"<c:doughnutChart><c:varyColors val="1"/>{}<c:holeSize val="50"/></c:doughnutChart>"#,
|
|
ser(0, "S", None, &["A", "B"], &["1", "3"])
|
|
);
|
|
let items = chart_items(&space(&plot), &theme(), 200.0, 150.0);
|
|
assert_eq!(items.iter().filter(|it| matches!(it, GraphicItem::Path { fill: Some(_), .. })).count(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn nice_scale_rounds_to_round_steps() {
|
|
assert_eq!(nice_scale(0.0, 22.3, true, None, None), Scale { lo: 0.0, hi: 25.0, step: 5.0 });
|
|
assert_eq!(nice_scale(0.0, 100.0, true, None, None), Scale { lo: 0.0, hi: 120.0, step: 20.0 });
|
|
assert_eq!(nice_scale(0.0, 22.3, true, Some(0.0), Some(30.0)).hi, 30.0);
|
|
let flat = nice_scale(0.0, 0.0, true, None, None);
|
|
assert!(flat.lo == 0.0 && (flat.hi - 1.2).abs() < 1e-9 && flat.step == 0.2, "{flat:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn number_formats() {
|
|
assert_eq!(format_value(1234567.0, "#,##0", 1.0), "1,234,567");
|
|
assert_eq!(format_value(-1500.0, "#,##0", 500.0), "-1,500");
|
|
assert_eq!(format_value(0.25, "0%", 0.05), "25%");
|
|
assert_eq!(format_value(0.5, "General", 0.25), "0.50");
|
|
assert_eq!(format_value(10.0, "\"€\"0.00", 5.0), "€10.00");
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_values_never_panic() {
|
|
let bodies = [
|
|
format!(
|
|
r#"<c:barChart><c:barDir val="col"/><c:grouping val="stacked"/><c:gapWidth val="-9999999999999999999999"/><c:overlap val="NaN"/>{}<c:axId val="1"/></c:barChart><c:valAx><c:axId val="1"/><c:scaling><c:max val="1e400"/><c:min val="-1e308"/></c:scaling></c:valAx>"#,
|
|
r#"<c:ser><c:tx><c:v>x</c:v></c:tx><c:cat><c:strRef><c:strCache><c:ptCount val="99999999999"/><c:pt idx="18446744073709551615"><c:v>a</c:v></c:pt><c:pt idx="-3"><c:v>b</c:v></c:pt></c:strCache></c:strRef></c:cat><c:val><c:numRef><c:numCache><c:ptCount val="3"/><c:pt idx="0"><c:v>1e400</c:v></c:pt><c:pt idx="1"><c:v>NaN</c:v></c:pt><c:pt idx="2"><c:v>-1e308</c:v></c:pt><c:pt idx="99999"><c:v>5</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser><c:ser><c:val><c:numRef><c:numCache><c:pt idx="0"><c:v>1e308</c:v></c:pt><c:pt idx="0"><c:v>1e308</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser>"#
|
|
),
|
|
r#"<c:scatterChart><c:ser><c:xVal><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>-1e308</c:v></c:pt><c:pt idx="1"><c:v>1e308</c:v></c:pt></c:numLit></c:xVal><c:yVal><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:yVal></c:ser></c:scatterChart>"#.to_string(),
|
|
r#"<c:pieChart><c:ser><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>-4</c:v></c:pt><c:pt idx="1"><c:v>1e308</c:v></c:pt></c:numLit></c:val><c:dPt><c:idx val="9999999999999"/></c:dPt></c:ser><c:firstSliceAng val="-9"/></c:pieChart>"#.to_string(),
|
|
];
|
|
for body in &bodies {
|
|
let s = space(body);
|
|
for (w, h) in [(0.0, 10.0), (f32::NAN, 10.0), (-1.0, 1.0), (1e30, 1e30), (50.0, 40.0)] {
|
|
let _ = chart_items(&s, &theme(), w, h);
|
|
}
|
|
}
|
|
assert!(chart_items(&space(&bodies[0]), &theme(), f32::NAN, 10.0).is_empty());
|
|
assert!(chart_items(&El::default(), &theme(), 100.0, 100.0).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn unsupported_chart_types_draw_nothing() {
|
|
let plot = r#"<c:radarChart><c:ser><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser></c:radarChart>"#;
|
|
assert!(chart_items(&space(plot), &theme(), 100.0, 100.0).is_empty());
|
|
}
|
|
|
|
/// Chart items for a chart with a legend at `pos` (`r`, `b`…).
|
|
fn with_legend(plot: &str, pos: &str, w: f32, h: f32) -> Vec<GraphicItem> {
|
|
let x = format!(
|
|
r#"<c:chartSpace {NS}><c:chart><c:plotArea>{plot}</c:plotArea><c:legend><c:legendPos val="{pos}"/></c:legend></c:chart></c:chartSpace>"#
|
|
);
|
|
chart_items(&crate::xml::parse(x.as_bytes()).unwrap(), &theme(), w, h)
|
|
}
|
|
|
|
#[test]
|
|
fn series_are_capped_to_the_cell_budget() {
|
|
// 256 series each declaring 10,000 points: only the 10 that fit the cell budget are kept.
|
|
let sers: String = (0..256)
|
|
.map(|i| format!(r#"<c:ser><c:idx val="{i}"/><c:val><c:numRef><c:numCache><c:ptCount val="10000"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numCache></c:numRef></c:val></c:ser>"#))
|
|
.collect();
|
|
let plot = format!(r#"<c:lineChart><c:grouping val="standard"/>{sers}<c:axId val="1"/></c:lineChart>"#);
|
|
let items = chart_items(&space(&plot), &theme(), 300.0, 200.0);
|
|
let strokes = items.iter().filter(|it| matches!(it, GraphicItem::Path { fill: None, stroke: Some(_), .. })).count();
|
|
assert_eq!(strokes, MAX_CELLS / 10_000);
|
|
}
|
|
|
|
#[test]
|
|
fn series_without_values_draw_nothing() {
|
|
let plot = format!(
|
|
r#"<c:areaChart><c:grouping val="standard"/>{}{}<c:axId val="1"/></c:areaChart>"#,
|
|
ser(0, "A", Some("FF0000"), &["Q1", "Q2"], &["4", "8"]),
|
|
ser(1, "Empty", Some("00FF00"), &["Q1", "Q2"], &[]),
|
|
);
|
|
let fills = filled(&chart_items(&space(&plot), &theme(), 300.0, 200.0));
|
|
assert_eq!(fills.len(), 1);
|
|
assert_eq!(fills[0].0, Rgb(0xFF, 0, 0));
|
|
}
|
|
|
|
#[test]
|
|
fn value_scale_starts_bars_at_zero_for_negative_data() {
|
|
// All-negative data pads below only: the axis ends at zero, as positive data's starts there.
|
|
assert_eq!(nice_scale(-10.0, -2.0, true, None, None), Scale { lo: -12.0, hi: 0.0, step: 2.0 });
|
|
assert_eq!(nice_scale(-1050.0, -1000.0, false, None, None).hi, -1000.0);
|
|
}
|
|
|
|
#[test]
|
|
fn narrow_line_scales_leave_zero_out_but_bars_keep_it() {
|
|
assert!(narrow(1000.0, 1050.0) && narrow(-1050.0, -1000.0));
|
|
assert!(!narrow(10.0, 100.0) && !narrow(0.0, 1.0));
|
|
assert_eq!(nice_scale(1000.0, 1050.0, false, None, None).lo, 1000.0);
|
|
assert_eq!(nice_scale(1000.0, 1050.0, true, None, None).lo, 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn horizontal_bar_labels_wrap_in_a_column_of_forty_percent() {
|
|
let long = "A category label that is much too long for its column";
|
|
let plot = format!(
|
|
r#"<c:barChart><c:barDir val="bar"/><c:grouping val="clustered"/>{}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#,
|
|
ser(0, "S", Some("FF0000"), &[long, "Short"], &["4", "8"]),
|
|
);
|
|
let items = chart_items(&space(&plot), &theme(), 300.0, 200.0);
|
|
let labels: Vec<(&str, f32)> = items
|
|
.iter()
|
|
.filter_map(|it| match it {
|
|
GraphicItem::Text { rect, text, align: TextAlign::Right, .. } => Some((text.as_str(), rect[2])),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert!(labels.iter().all(|(_, w)| *w <= 0.4 * 300.0), "{labels:?}");
|
|
assert!(labels.iter().filter(|(t, _)| long.contains(t)).count() > 1, "wrapped: {labels:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn stacked_lines_add_up_like_areas() {
|
|
let stacked = format!(
|
|
r#"<c:lineChart><c:grouping val="stacked"/>{}{}<c:axId val="1"/></c:lineChart>"#,
|
|
ser(0, "A", Some("FF0000"), &["Q1", "Q2"], &["0", "10"]),
|
|
ser(1, "B", Some("00FF00"), &["Q1", "Q2"], &["0", "5"]),
|
|
);
|
|
let single = format!(
|
|
r#"<c:lineChart><c:grouping val="standard"/>{}<c:axId val="1"/></c:lineChart>"#,
|
|
ser(0, "T", Some("0000FF"), &["Q1", "Q2"], &["0", "15"])
|
|
);
|
|
// Where the last point of the line drawn in `colour` is.
|
|
let end_y = |items: &[GraphicItem], colour: Rgb| {
|
|
items.iter().find_map(|it| match it {
|
|
GraphicItem::Path { segs, stroke: Some(c), .. } if *c == colour => match segs.last() {
|
|
Some(PathSeg::Line(_, y)) => Some(*y),
|
|
_ => None,
|
|
},
|
|
_ => None,
|
|
})
|
|
};
|
|
let top = end_y(&chart_items(&space(&stacked), &theme(), 300.0, 200.0), Rgb(0, 0xFF, 0)).expect("stacked line");
|
|
let total = end_y(&chart_items(&space(&single), &theme(), 300.0, 200.0), Rgb(0, 0, 0xFF)).expect("single line");
|
|
assert!((top - total).abs() < 1e-3, "stacked {top} vs {total}");
|
|
}
|
|
|
|
#[test]
|
|
fn category_numbers_show_in_their_format() {
|
|
// Excel serial days: 45292 is 1 January 2024, 45323 is 1 February 2024.
|
|
let cat = |code: &str| -> Vec<String> {
|
|
let x = format!(
|
|
r#"<c:cat {NS}><c:numRef><c:numCache><c:formatCode>{code}</c:formatCode><c:ptCount val="2"/><c:pt idx="0"><c:v>45292</c:v></c:pt><c:pt idx="1"><c:v>45323</c:v></c:pt></c:numCache></c:numRef></c:cat>"#
|
|
);
|
|
labels(Some(&crate::xml::parse(x.as_bytes()).unwrap()), 10, false)
|
|
};
|
|
assert_eq!(cat("m/d/yyyy"), ["1/1/2024", "2/1/2024"]);
|
|
assert_eq!(cat("d/m/yyyy"), ["1/1/2024", "1/2/2024"]);
|
|
assert_eq!(cat("mmm-yy"), ["Jan-24", "Feb-24"]);
|
|
assert_eq!(cat("yyyy"), ["2024", "2024"]);
|
|
assert_eq!(cat("0.00"), ["45292.00", "45323.00"]);
|
|
let ymd = |v: f64| excel_stamp(v, false).map(|t| (t.y, t.m, t.d));
|
|
assert_eq!(ymd(61.0), Some((1900, 3, 1)));
|
|
assert_eq!(ymd(45292.0), Some((2024, 1, 1)));
|
|
}
|
|
|
|
#[test]
|
|
fn number_formats_keep_literal_text_and_currency() {
|
|
assert_eq!(format_value(1234.5, "#,##0.00 [$€-407]", 0.25), "1,234.50 €");
|
|
assert_eq!(format_value(5.0, "[Red]0", 1.0), "5");
|
|
assert_eq!(format_value(3.0, "\"Items: \"0", 1.0), "Items: 3");
|
|
assert_eq!(format_value(3.0, "0\" units\"", 1.0), "3 units");
|
|
}
|
|
|
|
#[test]
|
|
fn side_legend_keeps_only_the_entries_that_fit() {
|
|
let sers: String = (0..10).map(|i| ser(i, &format!("S{i}"), Some("FF0000"), &["Q1"], &["1"])).collect();
|
|
let plot = format!(
|
|
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{sers}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#
|
|
);
|
|
let items = with_legend(&plot, "r", 300.0, 60.0);
|
|
let swatches = items.iter().filter(|it| matches!(it, GraphicItem::Shape { rect, .. } if rect[2] == 7.0)).count();
|
|
assert!(swatches > 0 && swatches < 10, "{swatches} swatches");
|
|
let bottom =
|
|
items.iter().filter_map(|it| if let GraphicItem::Text { rect, .. } = it { Some(rect[1] + rect[3]) } else { None }).fold(0.0, f32::max);
|
|
assert!(bottom <= 60.0, "legend text passes the chart's bottom: {bottom}");
|
|
}
|
|
|
|
/// The swatches of a legend: their (x, y).
|
|
fn swatches(items: &[GraphicItem]) -> Vec<(f32, f32)> {
|
|
items
|
|
.iter()
|
|
.filter_map(|it| if let GraphicItem::Shape { rect, .. } = it { (rect[2] == SWATCH).then_some((rect[0], rect[1])) } else { None })
|
|
.collect()
|
|
}
|
|
|
|
/// A column chart of one category with a series per name.
|
|
fn columns(names: &[&str]) -> String {
|
|
let sers: String = names.iter().enumerate().map(|(i, n)| ser(i, n, Some("FF0000"), &["Q1"], &["1"])).collect();
|
|
format!(
|
|
r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{sers}<c:axId val="1"/><c:axId val="2"/></c:barChart><c:catAx><c:axId val="1"/></c:catAx><c:valAx><c:axId val="2"/></c:valAx>"#
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn bottom_legend_wraps_into_rows_inside_the_chart() {
|
|
let names: Vec<String> = (0..12).map(|i| format!("Region number {i}")).collect();
|
|
let names: Vec<&str> = names.iter().map(String::as_str).collect();
|
|
let items = with_legend(&columns(&names), "b", 200.0, 200.0);
|
|
let sw = swatches(&items);
|
|
let rows: std::collections::BTreeSet<i32> = sw.iter().map(|(_, y)| *y as i32).collect();
|
|
assert!(rows.len() > 1 && rows.len() <= MAX_LEGEND_ROWS, "rows {rows:?}");
|
|
assert!(sw.iter().all(|(x, _)| *x >= 0.0 && *x <= 200.0), "{sw:?}");
|
|
for it in &items {
|
|
if let GraphicItem::Text { rect, text, .. } = it {
|
|
assert!(rect[0] >= 0.0 && rect[0] + text_w(text, FONT) <= 200.0 + 1e-3, "{text:?} at {rect:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn long_legend_names_are_cut_to_a_share_of_the_chart() {
|
|
let long = "A very long series name that would push the legend past the left edge of the chart";
|
|
let items = with_legend(&columns(&[long, "Short"]), "r", 200.0, 150.0);
|
|
assert!(swatches(&items).iter().all(|(x, _)| *x >= 0.0), "{:?}", swatches(&items));
|
|
let cut = items.iter().find_map(|it| match it {
|
|
GraphicItem::Text { text, rect, .. } if text.ends_with('…') => Some((text.clone(), rect[2])),
|
|
_ => None,
|
|
});
|
|
let (text, w) = cut.expect("the long name is cut");
|
|
assert!(text_w(&text, FONT) <= 200.0 * LEGEND_SHARE && w <= 200.0 * LEGEND_SHARE + 2.0, "{text:?} {w}");
|
|
}
|
|
|
|
#[test]
|
|
fn scatter_dates_show_as_dates() {
|
|
let pts = |vals: &[&str]| -> String { vals.iter().enumerate().map(|(k, v)| format!(r#"<c:pt idx="{k}"><c:v>{v}</c:v></c:pt>"#)).collect() };
|
|
let plot = format!(
|
|
r#"<c:scatterChart><c:ser><c:idx val="0"/><c:xVal><c:numRef><c:numCache><c:formatCode>m/d/yyyy</c:formatCode><c:ptCount val="3"/>{}</c:numCache></c:numRef></c:xVal><c:yVal><c:numRef><c:numCache><c:formatCode>General</c:formatCode><c:ptCount val="3"/>{}</c:numCache></c:numRef></c:yVal></c:ser><c:axId val="1"/><c:axId val="2"/></c:scatterChart><c:valAx><c:axId val="1"/><c:axPos val="b"/></c:valAx><c:valAx><c:axId val="2"/><c:axPos val="l"/></c:valAx>"#,
|
|
pts(&["45292", "45323", "45352"]),
|
|
pts(&["1", "2", "3"])
|
|
);
|
|
let items = chart_items(&space(&plot), &theme(), 300.0, 200.0);
|
|
let texts: Vec<&str> =
|
|
items.iter().filter_map(|it| if let GraphicItem::Text { text, .. } = it { Some(text.as_str()) } else { None }).collect();
|
|
assert!(texts.iter().any(|t| t.ends_with("/2024")), "{texts:?}");
|
|
assert!(!texts.iter().any(|t| t.starts_with("45")), "serials shown: {texts:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn date1904_counts_from_1904() {
|
|
assert_eq!(category_text(0.0, "m/d/yyyy", 1.0, true), "1/1/1904");
|
|
assert_eq!(category_text(1462.0, "m/d/yyyy", 1.0, false), "1/1/1904");
|
|
let x = format!(
|
|
r#"<c:chartSpace {NS}><c:date1904 val="1"/><c:chart><c:plotArea><c:lineChart><c:ser><c:cat><c:numRef><c:numCache><c:formatCode>yyyy-mm-dd</c:formatCode><c:ptCount val="1"/><c:pt idx="0"><c:v>366</c:v></c:pt></c:numCache></c:numRef></c:cat><c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val></c:ser></c:lineChart><c:catAx/><c:valAx/></c:plotArea></c:chart></c:chartSpace>"#
|
|
);
|
|
let chart = Chart::parse(&crate::xml::parse(x.as_bytes()).unwrap(), &theme()).expect("chart");
|
|
assert_eq!(chart.cats, ["1905-01-01"]);
|
|
}
|
|
|
|
#[test]
|
|
fn date_codes_show_times_weekdays_and_early_serials() {
|
|
let t = |v: f64, code: &str| category_text(v, code, 1.0, false);
|
|
// 45292 is Monday 1 January 2024; 13:05:09 into it.
|
|
let at = 45292.0 + (13.0 * 3600.0 + 5.0 * 60.0 + 9.0) / 86_400.0;
|
|
assert_eq!(t(at, "hh:mm:ss"), "13:05:09");
|
|
assert_eq!(t(at, "h:mm AM/PM"), "1:05 PM");
|
|
assert_eq!(t(at, "m:ss"), "5:09");
|
|
assert_eq!(t(45292.0, "h:mm a/p"), "12:00 A");
|
|
assert_eq!(t(at, "mm/dd"), "01/01");
|
|
assert_eq!(t(45292.0, "dddd, mmmm d"), "Monday, January 1");
|
|
assert_eq!(t(45292.0, "ddd d mmm"), "Mon 1 Jan");
|
|
// Serials before March 1900, Excel's phantom 29 February included.
|
|
assert_eq!(t(0.0, "m/d/yyyy"), "1/0/1900");
|
|
assert_eq!(t(1.0, "m/d/yyyy"), "1/1/1900");
|
|
assert_eq!(t(59.0, "m/d/yyyy"), "2/28/1900");
|
|
assert_eq!(t(60.0, "m/d/yyyy"), "2/29/1900");
|
|
assert_eq!(t(61.0, "m/d/yyyy dddd"), "3/1/1900 Thursday");
|
|
// Outside the calendar: a number.
|
|
assert_eq!(t(-1.0, "m/d/yyyy"), "-1");
|
|
}
|
|
|
|
#[test]
|
|
fn negative_sections_and_scientific_numbers() {
|
|
assert_eq!(format_value(-5.0, "#,##0;(#,##0)", 1.0), "(5)");
|
|
assert_eq!(format_value(5.0, "#,##0;(#,##0)", 1.0), "5");
|
|
assert_eq!(format_value(-1500.0, "#,##0_);(#,##0)", 500.0), "(1,500)");
|
|
assert_eq!(format_value(-2.0, "0\";\"0;-0", 1.0), "-2");
|
|
assert_eq!(format_value(12345.0, "0.00E+00", 1.0), "1.23E+04");
|
|
assert_eq!(format_value(0.00012, "0.0E+0", 0.0001), "1.2E-4");
|
|
assert_eq!(format_value(0.0, "0.00E+00", 1.0), "0.00E+00");
|
|
assert_eq!(format_value(-12345.0, "0.0E-00", 1.0), "-1.2E04");
|
|
}
|
|
}
|