mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 18:46:57 -06:00
Clean-room PowerPoint-class presentation engine in pure Rust: - geom: units, transforms and 150+ re-derived preset shapes with adjust handles - color: theme slots and colour transforms - model: presentations, masters, 11 layouts, placeholder inheritance, themes, text, tables, charts, transitions, animations, text edit operations - fonts: font DB with craft-fonts, system fonts and open substitutes - text: shaping, line breaking, bullets/numbering, alignment, autofit, caret hit tests - render: vello_cpu slides with fills, gradients, pictures, effects, tables, charts - format: native .slidecraft (zip + JSON) - engine: ~200 commands, undo/redo, clipboard, pointer tools, keys, sample deck - mcp: MCP server (headless or bridged to the app); slidecraft-cli Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
291 lines
9.0 KiB
Rust
291 lines
9.0 KiB
Rust
//! Charts: an embedded data grid plus type and element options.
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::style::{ColorRef, Fill, Line};
|
||
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub enum ChartType {
|
||
#[default]
|
||
Column,
|
||
StackedColumn,
|
||
PercentColumn,
|
||
Bar,
|
||
StackedBar,
|
||
PercentBar,
|
||
Line,
|
||
LineMarkers,
|
||
StackedLine,
|
||
Area,
|
||
StackedArea,
|
||
Pie,
|
||
Doughnut,
|
||
Scatter,
|
||
Bubble,
|
||
Radar,
|
||
FilledRadar,
|
||
Stock,
|
||
Surface,
|
||
Histogram,
|
||
Pareto,
|
||
BoxWhisker,
|
||
Waterfall,
|
||
Funnel,
|
||
Treemap,
|
||
Sunburst,
|
||
Combo,
|
||
}
|
||
|
||
impl ChartType {
|
||
pub const MAIN: [ChartType; 12] = [
|
||
ChartType::Column,
|
||
ChartType::Bar,
|
||
ChartType::Line,
|
||
ChartType::Area,
|
||
ChartType::Pie,
|
||
ChartType::Doughnut,
|
||
ChartType::Scatter,
|
||
ChartType::Radar,
|
||
ChartType::Waterfall,
|
||
ChartType::Funnel,
|
||
ChartType::Histogram,
|
||
ChartType::Treemap,
|
||
];
|
||
pub fn label(self) -> &'static str {
|
||
match self {
|
||
ChartType::Column => "Clustered Column",
|
||
ChartType::StackedColumn => "Stacked Column",
|
||
ChartType::PercentColumn => "100% Stacked Column",
|
||
ChartType::Bar => "Clustered Bar",
|
||
ChartType::StackedBar => "Stacked Bar",
|
||
ChartType::PercentBar => "100% Stacked Bar",
|
||
ChartType::Line => "Line",
|
||
ChartType::LineMarkers => "Line with Markers",
|
||
ChartType::StackedLine => "Stacked Line",
|
||
ChartType::Area => "Area",
|
||
ChartType::StackedArea => "Stacked Area",
|
||
ChartType::Pie => "Pie",
|
||
ChartType::Doughnut => "Doughnut",
|
||
ChartType::Scatter => "Scatter",
|
||
ChartType::Bubble => "Bubble",
|
||
ChartType::Radar => "Radar",
|
||
ChartType::FilledRadar => "Filled Radar",
|
||
ChartType::Stock => "Stock",
|
||
ChartType::Surface => "Surface",
|
||
ChartType::Histogram => "Histogram",
|
||
ChartType::Pareto => "Pareto",
|
||
ChartType::BoxWhisker => "Box and Whisker",
|
||
ChartType::Waterfall => "Waterfall",
|
||
ChartType::Funnel => "Funnel",
|
||
ChartType::Treemap => "Treemap",
|
||
ChartType::Sunburst => "Sunburst",
|
||
ChartType::Combo => "Combo",
|
||
}
|
||
}
|
||
pub fn is_bar_like(self) -> bool {
|
||
matches!(
|
||
self,
|
||
ChartType::Column
|
||
| ChartType::StackedColumn
|
||
| ChartType::PercentColumn
|
||
| ChartType::Bar
|
||
| ChartType::StackedBar
|
||
| ChartType::PercentBar
|
||
| ChartType::Histogram
|
||
| ChartType::Pareto
|
||
| ChartType::Waterfall
|
||
)
|
||
}
|
||
pub fn horizontal(self) -> bool {
|
||
matches!(self, ChartType::Bar | ChartType::StackedBar | ChartType::PercentBar | ChartType::Funnel)
|
||
}
|
||
pub fn stacked(self) -> bool {
|
||
matches!(
|
||
self,
|
||
ChartType::StackedColumn
|
||
| ChartType::StackedBar
|
||
| ChartType::StackedLine
|
||
| ChartType::StackedArea
|
||
| ChartType::PercentColumn
|
||
| ChartType::PercentBar
|
||
)
|
||
}
|
||
pub fn percent(self) -> bool {
|
||
matches!(self, ChartType::PercentColumn | ChartType::PercentBar)
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct Series {
|
||
pub name: String,
|
||
pub values: Vec<Option<f64>>,
|
||
/// X values for scatter/bubble.
|
||
pub x_values: Vec<Option<f64>>,
|
||
pub sizes: Vec<Option<f64>>,
|
||
pub fill: Option<Fill>,
|
||
pub line: Option<Line>,
|
||
/// Per-point fill overrides (pie slices).
|
||
pub point_fills: Vec<(usize, Fill)>,
|
||
pub data_labels: Option<bool>,
|
||
pub smooth: bool,
|
||
pub marker: Option<String>,
|
||
/// Combo charts: this series' own type.
|
||
pub kind: Option<ChartType>,
|
||
pub secondary_axis: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
pub struct Chart {
|
||
pub kind: ChartType,
|
||
pub title: Option<String>,
|
||
pub categories: Vec<String>,
|
||
pub series: Vec<Series>,
|
||
pub legend: Option<String>,
|
||
pub data_labels: bool,
|
||
pub gridlines: bool,
|
||
pub axis_titles: (Option<String>, Option<String>),
|
||
/// Chart style number (1–48) and colour palette (`colorful1`, `monochrome3`…).
|
||
pub style: u32,
|
||
pub palette: String,
|
||
pub plot_fill: Option<Fill>,
|
||
pub chart_fill: Option<Fill>,
|
||
pub text_color: Option<ColorRef>,
|
||
pub hole_size: f64,
|
||
pub gap_width: f64,
|
||
pub overlap: f64,
|
||
pub vary_colors: bool,
|
||
pub y_min: Option<f64>,
|
||
pub y_max: Option<f64>,
|
||
/// Original chart XML part kept for round-trip of unmodelled options.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub raw: Option<String>,
|
||
}
|
||
|
||
impl Default for Chart {
|
||
fn default() -> Self {
|
||
Chart {
|
||
kind: ChartType::Column,
|
||
title: Some("Chart Title".into()),
|
||
categories: vec![],
|
||
series: vec![],
|
||
legend: Some("b".into()),
|
||
data_labels: false,
|
||
gridlines: true,
|
||
axis_titles: (None, None),
|
||
style: 2,
|
||
palette: "colorful1".into(),
|
||
plot_fill: None,
|
||
chart_fill: None,
|
||
text_color: None,
|
||
hole_size: 0.5,
|
||
gap_width: 2.19,
|
||
overlap: -0.27,
|
||
vary_colors: false,
|
||
y_min: None,
|
||
y_max: None,
|
||
raw: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Chart {
|
||
/// The sample data a new chart starts with (our own numbers).
|
||
pub fn sample(kind: ChartType) -> Self {
|
||
let cats = ["Q1", "Q2", "Q3", "Q4"];
|
||
let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]];
|
||
let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() };
|
||
let n_series =
|
||
if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 };
|
||
for s in 0..n_series {
|
||
c.series.push(Series {
|
||
name: format!("Series {}", s + 1),
|
||
values: data.iter().map(|row| row.get(s).copied()).collect(),
|
||
x_values: if kind == ChartType::Scatter { (1..=4).map(|x| Some(x as f64)).collect() } else { vec![] },
|
||
..Default::default()
|
||
});
|
||
}
|
||
c.vary_colors = n_series == 1;
|
||
c
|
||
}
|
||
/// Value range across series (stacked sums for stacked types).
|
||
pub fn value_range(&self) -> (f64, f64) {
|
||
let mut lo: f64 = 0.0;
|
||
let mut hi: f64 = 0.0;
|
||
let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0));
|
||
if self.kind.stacked() {
|
||
for i in 0..n {
|
||
let (mut p, mut m) = (0.0, 0.0);
|
||
for s in &self.series {
|
||
let v = s.values.get(i).copied().flatten().unwrap_or(0.0);
|
||
if v >= 0.0 { p += v } else { m += v }
|
||
}
|
||
hi = hi.max(p);
|
||
lo = lo.min(m);
|
||
}
|
||
} else {
|
||
for s in &self.series {
|
||
for v in s.values.iter().flatten() {
|
||
if v.is_finite() {
|
||
hi = hi.max(*v);
|
||
lo = lo.min(*v);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if self.kind.percent() {
|
||
return (0.0, 1.0);
|
||
}
|
||
(self.y_min.unwrap_or(lo), self.y_max.unwrap_or(hi))
|
||
}
|
||
}
|
||
|
||
/// "Nice" axis scale: (min, max, major step) covering [lo, hi].
|
||
pub fn nice_scale(lo: f64, hi: f64) -> (f64, f64, f64) {
|
||
let (lo, hi) = if lo.is_finite() && hi.is_finite() { (lo.min(hi), lo.max(hi)) } else { (0.0, 1.0) };
|
||
let span = if hi - lo < 1e-12 { hi.abs().max(1.0) } else { hi - lo };
|
||
let raw = span / 5.0;
|
||
let mag = 10f64.powf(raw.log10().floor());
|
||
let norm = raw / mag;
|
||
let step = if norm <= 1.0 {
|
||
1.0
|
||
} else if norm <= 2.0 {
|
||
2.0
|
||
} else if norm <= 2.5 {
|
||
2.5
|
||
} else if norm <= 5.0 {
|
||
5.0
|
||
} else {
|
||
10.0
|
||
} * mag;
|
||
let min = (lo / step).floor() * step;
|
||
let max = (hi / step).ceil() * step;
|
||
let max = if (max - min).abs() < 1e-12 { min + step } else { max };
|
||
(min, max, step)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn nice_scale_covers() {
|
||
let (a, b, s) = nice_scale(0.0, 5.1);
|
||
assert!(a <= 0.0 && b >= 5.1 && s > 0.0);
|
||
let (a, b, _) = nice_scale(f64::NAN, 3.0);
|
||
assert!(a.is_finite() && b.is_finite());
|
||
let (a, b, _) = nice_scale(7.0, 7.0);
|
||
assert!(b > a);
|
||
}
|
||
|
||
#[test]
|
||
fn 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);
|
||
}
|
||
}
|