mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
677 lines
31 KiB
Rust
677 lines
31 KiB
Rust
//! Graphs: the nine graph tools and Object → Graph (Type…, Data…).
|
||
//!
|
||
//! A graph is a group carrying a [`GraphSpec`]; its children (axes, labels, one group per series
|
||
//! with that series' legend swatch, like Illustrator's group-selectable series) are generated from
|
||
//! the spec. Editing the data or the type regenerates them in place, keeping any move/scale the
|
||
//! user applied to the graph since it was generated.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::{Appearance, CharStyle, Document, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind, TextObject};
|
||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
|
||
|
||
use super::typecmd::refresh_bounds;
|
||
use super::*;
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"graph.create",
|
||
"Create Graph",
|
||
[],
|
||
None,
|
||
"{type: column|stackedColumn|bar|stackedBar|line|area|scatter|pie|radar, x, y, width, height, series?: [..], categories?: [..], rows?: [[..]], csv?} → {id}",
|
||
has_doc,
|
||
create
|
||
),
|
||
cmd!(
|
||
"graph.setData",
|
||
"Data…",
|
||
["Object", "Graph"],
|
||
None,
|
||
"{id?, series?, categories?, rows?, csv?: first row = series labels (first cell empty), then one row per category: label, values…} replace the graph's data; no data → the current {csv, series, categories, rows}",
|
||
has_selection,
|
||
set_data
|
||
),
|
||
cmd!(
|
||
"graph.setType",
|
||
"Type…",
|
||
["Object", "Graph"],
|
||
None,
|
||
"{id?, type?, columnWidth?: %, clusterWidth?: %, legend?: bool, markPoints?: bool, connectPoints?: bool, ticks?: n, axisMin?, axisMax?} change the graph type and options; no options → the current ones",
|
||
has_selection,
|
||
set_type
|
||
),
|
||
]
|
||
}
|
||
|
||
// ---------- data ----------
|
||
|
||
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<f64>>) {
|
||
let rows: Vec<Vec<String>> = csv
|
||
.lines()
|
||
.map(|l| l.trim_end_matches('\r'))
|
||
.filter(|l| !l.trim().is_empty())
|
||
.map(|l| {
|
||
let sep = if l.contains('\t') { '\t' } else { ',' };
|
||
l.split(sep).map(|c| c.trim().trim_matches('"').to_string()).collect()
|
||
})
|
||
.collect();
|
||
let Some(first) = rows.first() else { return (vec![], vec![], vec![]) };
|
||
// A header row has a non-numeric cell after the first one (or an empty first cell).
|
||
let header = first.first().is_some_and(|c| c.is_empty()) || first.iter().skip(1).any(|c| c.parse::<f64>().is_err());
|
||
let series = if header { first.iter().skip(1).cloned().collect() } else { vec![] };
|
||
let mut categories = vec![];
|
||
let mut values = vec![];
|
||
for r in rows.iter().skip(header as usize) {
|
||
let labelled = r.first().is_some_and(|c| c.parse::<f64>().is_err());
|
||
categories.push(if labelled { r[0].clone() } else { String::new() });
|
||
values.push(r.iter().skip(labelled as usize).map(|c| c.parse::<f64>().unwrap_or(0.0)).collect());
|
||
}
|
||
if categories.iter().all(String::is_empty) {
|
||
categories.clear();
|
||
}
|
||
(series, categories, values)
|
||
}
|
||
|
||
fn to_csv(g: &GraphSpec) -> String {
|
||
let mut out = String::new();
|
||
if !g.series.is_empty() {
|
||
out.push(',');
|
||
out.push_str(&g.series.join(","));
|
||
out.push('\n');
|
||
}
|
||
for (i, r) in g.rows.iter().enumerate() {
|
||
if let Some(c) = g.categories.get(i) {
|
||
out.push_str(c);
|
||
out.push(',');
|
||
}
|
||
out.push_str(&r.iter().map(|v| fmt_value(*v)).collect::<Vec<_>>().join(","));
|
||
out.push('\n');
|
||
}
|
||
out
|
||
}
|
||
|
||
fn apply_data(g: &mut GraphSpec, p: &Value) -> bool {
|
||
let mut changed = false;
|
||
if let Some(csv) = str_param(p, "csv") {
|
||
let (s, c, r) = parse_csv(csv);
|
||
g.series = s;
|
||
g.categories = c;
|
||
g.rows = r;
|
||
changed = true;
|
||
}
|
||
let strings =
|
||
|v: &Value| v.as_array().map(|a| a.iter().map(|x| x.as_str().map(str::to_string).unwrap_or_else(|| x.to_string())).collect::<Vec<_>>());
|
||
if let Some(s) = p.get("series").and_then(strings) {
|
||
g.series = s;
|
||
changed = true;
|
||
}
|
||
if let Some(c) = p.get("categories").and_then(strings) {
|
||
g.categories = c;
|
||
changed = true;
|
||
}
|
||
if let Some(rows) = p.get("rows").and_then(Value::as_array) {
|
||
g.rows = rows.iter().map(|r| r.as_array().map(|a| a.iter().map(|v| v.as_f64().unwrap_or(0.0)).collect()).unwrap_or_default()).collect();
|
||
changed = true;
|
||
}
|
||
g.rows.retain(|r| !r.is_empty());
|
||
changed
|
||
}
|
||
|
||
fn fmt_value(v: f64) -> String {
|
||
if (v - v.round()).abs() < 1e-9 {
|
||
format!("{}", v.round() as i64)
|
||
} else {
|
||
format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string()
|
||
}
|
||
}
|
||
|
||
// ---------- generation ----------
|
||
|
||
/// Series colours: black, then greys (Illustrator's graphs start in greyscale too).
|
||
fn series_paint(i: usize) -> Paint {
|
||
const LEVELS: [f32; 8] = [0.0, 0.55, 0.8, 0.3, 0.68, 0.15, 0.9, 0.42];
|
||
let l = LEVELS[i % LEVELS.len()];
|
||
Paint::solid(Color::rgb(l, l, l))
|
||
}
|
||
|
||
const LABEL_SIZE: f64 = 9.0;
|
||
|
||
/// A "nice" axis: (min, max, step).
|
||
fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) {
|
||
let (lo, hi) = if (hi - lo).abs() < 1e-12 { (lo.min(0.0), lo.max(0.0) + 1.0) } else { (lo, hi) };
|
||
let n = if ticks == 0 { 5 } else { ticks.clamp(1, 100) } as f64;
|
||
let raw = (hi - lo) / n;
|
||
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 - 1e-12).unwrap_or(10.0 * mag);
|
||
((lo / step).floor() * step, (hi / step).ceil() * step, step)
|
||
}
|
||
|
||
struct Gen<'a> {
|
||
d: &'a mut Document,
|
||
out: Vec<Arc<Node>>,
|
||
}
|
||
|
||
impl Gen<'_> {
|
||
fn path(&mut self, pd: PathData, fill: Paint, stroke: Paint, w: f64) -> Arc<Node> {
|
||
Arc::new(Node::path(self.d.alloc_id(), pd, Appearance::basic(fill, stroke, w)))
|
||
}
|
||
fn line(&mut self, a: Point, b: Point) -> Arc<Node> {
|
||
let mut bp = BezPath::new();
|
||
bp.move_to(a);
|
||
bp.line_to(b);
|
||
self.path(PathData::from_bezpath(&bp), Paint::None, Paint::solid(Color::BLACK), 0.5)
|
||
}
|
||
fn text(&mut self, at: Point, s: &str, justify: Justify) -> Arc<Node> {
|
||
let style = CharStyle { size: LABEL_SIZE, fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
|
||
let mut t = TextObject::point(at, s, style);
|
||
t.para.justify = justify;
|
||
refresh_bounds(&mut t);
|
||
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
|
||
}
|
||
fn group(&mut self, name: &str, children: Vec<Arc<Node>>) -> Arc<Node> {
|
||
let mut g = Node::group(self.d.alloc_id(), children);
|
||
g.name = Some(name.into());
|
||
Arc::new(g)
|
||
}
|
||
}
|
||
|
||
fn polyline(pts: &[Point], closed: bool) -> PathData {
|
||
let mut bp = BezPath::new();
|
||
for (i, p) in pts.iter().enumerate() {
|
||
if i == 0 {
|
||
bp.move_to(*p);
|
||
} else {
|
||
bp.line_to(*p);
|
||
}
|
||
}
|
||
if closed {
|
||
bp.close_path();
|
||
}
|
||
PathData::from_bezpath(&bp)
|
||
}
|
||
|
||
fn marker(p: Point, s: f64) -> PathData {
|
||
shapes::rectangle(Rect::from_center_size(p, (s, s)))
|
||
}
|
||
|
||
/// Build the graph's children.
|
||
fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||
let mut b = Gen { d, out: vec![] };
|
||
let r = g.rect;
|
||
let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).max(1);
|
||
let ncat = g.rows.len().max(1);
|
||
let series_label = |i: usize| g.series.get(i).cloned().unwrap_or_default();
|
||
let mut series: Vec<Vec<Arc<Node>>> = vec![vec![]; nser];
|
||
let val = |c: usize, s: usize| g.rows.get(c).and_then(|r| r.get(s)).copied().unwrap_or(0.0);
|
||
let stacked = matches!(g.kind, GraphKind::StackedColumn | GraphKind::StackedBar | GraphKind::Area);
|
||
let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar);
|
||
|
||
match g.kind {
|
||
GraphKind::Pie => {
|
||
// One pie per row, side by side; a wedge per series, clockwise from 12 o'clock.
|
||
let w = r.width() / ncat as f64;
|
||
let rad = (w.min(r.height()) / 2.0 * 0.9).max(1.0);
|
||
for c in 0..ncat {
|
||
let centre = Point::new(r.x0 + w * (c as f64 + 0.5), r.y0 + r.height() / 2.0);
|
||
let total: f64 = (0..nser).map(|s| val(c, s).max(0.0)).sum();
|
||
let mut a0 = -std::f64::consts::FRAC_PI_2;
|
||
for (s, items) in series.iter_mut().enumerate() {
|
||
let v = val(c, s).max(0.0);
|
||
if total <= 0.0 || v <= 0.0 {
|
||
continue;
|
||
}
|
||
let sweep = v / total * std::f64::consts::TAU;
|
||
let mut bp = BezPath::new();
|
||
bp.move_to(centre);
|
||
let arc = vectorcraft_geom::kurbo::Arc::new(centre, (rad, rad), a0, sweep, 0.0);
|
||
bp.line_to(centre + vectorcraft_geom::Vec2::new(a0.cos(), a0.sin()) * rad);
|
||
arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p));
|
||
bp.close_path();
|
||
let n = b.path(PathData::from_bezpath(&bp), series_paint(s), Paint::solid(Color::WHITE), 0.5);
|
||
items.push(n);
|
||
a0 += sweep;
|
||
}
|
||
if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) {
|
||
let t = b.text(Point::new(centre.x, centre.y + rad + LABEL_SIZE * 1.6), cat, Justify::Center);
|
||
b.out.push(t);
|
||
}
|
||
}
|
||
}
|
||
GraphKind::Radar => {
|
||
let centre = r.center();
|
||
let rad = (r.width().min(r.height()) / 2.0).max(1.0);
|
||
let hi = g.axis_max.unwrap_or_else(|| g.rows.iter().flatten().copied().fold(0.0, f64::max));
|
||
let (lo, hi, step) = nice_axis(g.axis_min.unwrap_or(0.0), hi, g.ticks);
|
||
let at = |c: usize, v: f64| {
|
||
let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * c as f64 / ncat as f64;
|
||
centre + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) * (rad * ((v - lo) / (hi - lo)).clamp(0.0, 1.0))
|
||
};
|
||
let mut axes = vec![];
|
||
for c in 0..ncat {
|
||
axes.push(b.line(centre, at(c, hi)));
|
||
if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) {
|
||
let p = at(c, hi) + (at(c, hi) - centre).normalize() * (LABEL_SIZE * 1.2);
|
||
axes.push(b.text(Point::new(p.x, p.y + LABEL_SIZE * 0.35), cat, Justify::Center));
|
||
}
|
||
}
|
||
let mut v = lo + step;
|
||
while v <= hi + step * 1e-6 {
|
||
let ring: Vec<Point> = (0..ncat).map(|c| at(c, v)).collect();
|
||
axes.push(b.path(polyline(&ring, true), Paint::None, Paint::solid(Color::rgb(0.6, 0.6, 0.6)), 0.25));
|
||
v += step;
|
||
}
|
||
let ax = b.group("Axes", axes);
|
||
b.out.push(ax);
|
||
for (s, items) in series.iter_mut().enumerate() {
|
||
let pts: Vec<Point> = (0..ncat).map(|c| at(c, val(c, s))).collect();
|
||
items.push(b.path(polyline(&pts, true), Paint::None, series_paint(s), 1.0));
|
||
for p in pts {
|
||
items.push(b.path(marker(p, 4.0), series_paint(s), Paint::None, 0.0));
|
||
}
|
||
}
|
||
}
|
||
_ => {
|
||
// Value range (stacked graphs stack positives and negatives separately).
|
||
let (mut lo, mut hi) = (0.0f64, 0.0f64);
|
||
let scatter = g.kind == GraphKind::Scatter;
|
||
let (mut xlo, mut xhi) = (f64::MAX, f64::MIN);
|
||
for c in 0..ncat {
|
||
if stacked {
|
||
let pos: f64 = (0..nser).map(|s| val(c, s).max(0.0)).sum();
|
||
let neg: f64 = (0..nser).map(|s| val(c, s).min(0.0)).sum();
|
||
hi = hi.max(pos);
|
||
lo = lo.min(neg);
|
||
} else if scatter {
|
||
for s in (0..nser).step_by(2) {
|
||
hi = hi.max(val(c, s));
|
||
lo = lo.min(val(c, s));
|
||
xlo = xlo.min(val(c, s + 1));
|
||
xhi = xhi.max(val(c, s + 1));
|
||
}
|
||
} else {
|
||
for s in 0..nser {
|
||
hi = hi.max(val(c, s));
|
||
lo = lo.min(val(c, s));
|
||
}
|
||
}
|
||
}
|
||
let (lo, hi, step) = nice_axis(g.axis_min.unwrap_or(lo), g.axis_max.unwrap_or(hi), g.ticks);
|
||
// Value → coordinate along the value axis (y for columns/lines, x for bars).
|
||
let vpos = |v: f64| {
|
||
let t = (v - lo) / (hi - lo);
|
||
if horizontal { r.x0 + t * r.width() } else { r.y1 - t * r.height() }
|
||
};
|
||
let mut axes = vec![];
|
||
// Value axis with ticks and labels.
|
||
let mut v = lo;
|
||
while v <= hi + step * 1e-6 {
|
||
let q = vpos(v);
|
||
if horizontal {
|
||
axes.push(b.line(Point::new(q, r.y1), Point::new(q, r.y1 + 4.0)));
|
||
axes.push(b.text(Point::new(q, r.y1 + 4.0 + LABEL_SIZE * 1.1), &fmt_value(v), Justify::Center));
|
||
} else {
|
||
axes.push(b.line(Point::new(r.x0 - 4.0, q), Point::new(r.x0, q)));
|
||
axes.push(b.text(Point::new(r.x0 - 6.0, q + LABEL_SIZE * 0.35), &fmt_value(v), Justify::Right));
|
||
}
|
||
v += step;
|
||
}
|
||
if horizontal {
|
||
axes.push(b.line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)));
|
||
axes.push(b.line(Point::new(vpos(0.0f64.clamp(lo, hi)), r.y0), Point::new(vpos(0.0f64.clamp(lo, hi)), r.y1)));
|
||
} else {
|
||
axes.push(b.line(Point::new(r.x0, r.y0), Point::new(r.x0, r.y1)));
|
||
axes.push(b.line(Point::new(r.x0, vpos(0.0f64.clamp(lo, hi))), Point::new(r.x1, vpos(0.0f64.clamp(lo, hi)))));
|
||
}
|
||
// Category positions.
|
||
let span = if horizontal { r.height() } else { r.width() };
|
||
let cat_w = span / ncat as f64;
|
||
let cat_start = |c: usize| if horizontal { r.y0 + c as f64 * cat_w } else { r.x0 + c as f64 * cat_w };
|
||
let points_mode = matches!(g.kind, GraphKind::Line | GraphKind::Area);
|
||
let cat_mid = |c: usize| {
|
||
if points_mode && ncat > 1 { r.x0 + r.width() * c as f64 / (ncat - 1) as f64 } else { cat_start(c) + cat_w / 2.0 }
|
||
};
|
||
if !scatter {
|
||
for c in 0..ncat {
|
||
if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) {
|
||
let t = if horizontal {
|
||
b.text(Point::new(r.x0 - 6.0, cat_mid(c) + LABEL_SIZE * 0.35), cat, Justify::Right)
|
||
} else {
|
||
b.text(Point::new(cat_mid(c), r.y1 + LABEL_SIZE * 1.4), cat, Justify::Center)
|
||
};
|
||
axes.push(t);
|
||
}
|
||
}
|
||
} else {
|
||
// Scatter: a horizontal value axis too.
|
||
let (xl, xh, xs) = nice_axis(if xlo == f64::MAX { 0.0 } else { xlo.min(0.0) }, if xhi == f64::MIN { 1.0 } else { xhi }, g.ticks);
|
||
let mut x = xl;
|
||
while x <= xh + xs * 1e-6 {
|
||
let q = r.x0 + (x - xl) / (xh - xl) * r.width();
|
||
axes.push(b.line(Point::new(q, r.y1), Point::new(q, r.y1 + 4.0)));
|
||
axes.push(b.text(Point::new(q, r.y1 + 4.0 + LABEL_SIZE * 1.1), &fmt_value(x), Justify::Center));
|
||
x += xs;
|
||
}
|
||
for (si, s) in (0..nser).step_by(2).enumerate() {
|
||
let pts: Vec<Point> =
|
||
(0..ncat).map(|c| Point::new(r.x0 + (val(c, s + 1) - xl) / (xh - xl) * r.width(), vpos(val(c, s)))).collect();
|
||
if g.connect_points && pts.len() > 1 {
|
||
series[si].push(b.path(polyline(&pts, false), Paint::None, series_paint(si), 1.0));
|
||
}
|
||
if g.mark_points {
|
||
for p in &pts {
|
||
series[si].push(b.path(marker(*p, 5.0), series_paint(si), Paint::None, 0.0));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let ax = b.group("Axes", axes);
|
||
b.out.push(ax);
|
||
match g.kind {
|
||
GraphKind::Column | GraphKind::Bar => {
|
||
let cluster = cat_w * (g.cluster_width / 100.0).clamp(0.01, 1.0);
|
||
let slot = cluster / nser as f64;
|
||
let bar = slot * (g.column_width / 100.0).clamp(0.01, 1.0);
|
||
for c in 0..ncat {
|
||
let c0 = cat_start(c) + (cat_w - cluster) / 2.0;
|
||
for (s, items) in series.iter_mut().enumerate() {
|
||
let a = c0 + slot * s as f64 + (slot - bar) / 2.0;
|
||
let (v0, v1) = (vpos(0.0f64.clamp(lo, hi)), vpos(val(c, s)));
|
||
let rect = if horizontal {
|
||
Rect::new(v0.min(v1), a, v0.max(v1), a + bar)
|
||
} else {
|
||
Rect::new(a, v0.min(v1), a + bar, v0.max(v1))
|
||
};
|
||
items.push(b.path(shapes::rectangle(rect), series_paint(s), Paint::None, 0.0));
|
||
}
|
||
}
|
||
}
|
||
GraphKind::StackedColumn | GraphKind::StackedBar => {
|
||
let bar = cat_w * (g.column_width / 100.0).clamp(0.01, 1.0) * (g.cluster_width / 100.0).clamp(0.01, 1.0) * 1.2;
|
||
let bar = bar.min(cat_w);
|
||
for c in 0..ncat {
|
||
let a = cat_start(c) + (cat_w - bar) / 2.0;
|
||
let (mut pos, mut neg) = (0.0, 0.0);
|
||
for (s, items) in series.iter_mut().enumerate() {
|
||
let v = val(c, s);
|
||
let base = if v >= 0.0 { &mut pos } else { &mut neg };
|
||
let (v0, v1) = (vpos(*base), vpos(*base + v));
|
||
*base += v;
|
||
let rect = if horizontal {
|
||
Rect::new(v0.min(v1), a, v0.max(v1), a + bar)
|
||
} else {
|
||
Rect::new(a, v0.min(v1), a + bar, v0.max(v1))
|
||
};
|
||
items.push(b.path(shapes::rectangle(rect), series_paint(s), Paint::solid(Color::WHITE), 0.25));
|
||
}
|
||
}
|
||
}
|
||
GraphKind::Line => {
|
||
for (s, items) in series.iter_mut().enumerate() {
|
||
let pts: Vec<Point> = (0..ncat).map(|c| Point::new(cat_mid(c), vpos(val(c, s)))).collect();
|
||
if g.connect_points && pts.len() > 1 {
|
||
items.push(b.path(polyline(&pts, false), Paint::None, series_paint(s), 1.0));
|
||
}
|
||
if g.mark_points {
|
||
for p in &pts {
|
||
items.push(b.path(marker(*p, 5.0), series_paint(s), Paint::None, 0.0));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
GraphKind::Area => {
|
||
// Cumulative bands, each from the previous total up to its own.
|
||
let mut below: Vec<f64> = vec![0.0; ncat];
|
||
for (s, items) in series.iter_mut().enumerate() {
|
||
let above: Vec<f64> = (0..ncat).map(|c| below[c] + val(c, s)).collect();
|
||
let mut pts: Vec<Point> = (0..ncat).map(|c| Point::new(cat_mid(c), vpos(above[c]))).collect();
|
||
pts.extend((0..ncat).rev().map(|c| Point::new(cat_mid(c), vpos(below[c]))));
|
||
items.push(b.path(polyline(&pts, true), series_paint(s), Paint::solid(Color::WHITE), 0.25));
|
||
below = above;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
// Legend: a swatch + label per series, right of the plot; the swatch joins its series group.
|
||
let legend_series = if g.kind == GraphKind::Scatter { nser.div_ceil(2) } else { nser };
|
||
let mut labels = vec![];
|
||
if g.legend && !g.series.is_empty() {
|
||
let x = r.x1 + 14.0;
|
||
for (s, items) in series.iter_mut().enumerate().take(legend_series) {
|
||
let label = if g.kind == GraphKind::Scatter { series_label(s * 2) } else { series_label(s) };
|
||
if label.is_empty() {
|
||
continue;
|
||
}
|
||
let y = r.y0 + s as f64 * (LABEL_SIZE * 1.8);
|
||
items.push(b.path(shapes::rectangle(Rect::new(x, y, x + 8.0, y + 8.0)), series_paint(s), Paint::None, 0.0));
|
||
labels.push(b.text(Point::new(x + 12.0, y + 7.5), &label, Justify::Left));
|
||
}
|
||
}
|
||
for (s, items) in series.into_iter().enumerate() {
|
||
if !items.is_empty() {
|
||
let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1));
|
||
let grp = b.group(&name, items);
|
||
b.out.push(grp);
|
||
}
|
||
}
|
||
if !labels.is_empty() {
|
||
let grp = b.group("Legend", labels);
|
||
b.out.push(grp);
|
||
}
|
||
b.out
|
||
}
|
||
|
||
fn children_bounds(children: &[Arc<Node>]) -> Option<Rect> {
|
||
children.iter().filter_map(|c| c.geometric_bounds()).reduce(|a, b| a.union(b))
|
||
}
|
||
|
||
/// Regenerate graph `id` from `spec` (adjusting the plot rect for any move/scale since the last
|
||
/// generation, which is recorded in the spec's `placed` bounds).
|
||
fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> {
|
||
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
|
||
if let (Some(placed), Some(now)) = (n.graph.as_ref().and_then(|g| g.placed), n.children().and_then(|c| children_bounds(c)))
|
||
&& placed.width() > 1e-9
|
||
&& placed.height() > 1e-9
|
||
&& (placed.x0 - now.x0).abs() + (placed.y0 - now.y0).abs() + (placed.x1 - now.x1).abs() + (placed.y1 - now.y1).abs() > 1e-6
|
||
{
|
||
let a = Affine::translate(now.origin().to_vec2())
|
||
* Affine::scale_non_uniform(now.width() / placed.width(), now.height() / placed.height())
|
||
* Affine::translate(-placed.origin().to_vec2());
|
||
spec.rect = a.transform_rect_bbox(spec.rect);
|
||
}
|
||
let children = generate(d, &spec);
|
||
spec.placed = children_bounds(&children);
|
||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||
if let Some(ch) = n.children_mut() {
|
||
*ch = children;
|
||
}
|
||
n.graph = Some(Box::new(spec));
|
||
Ok(())
|
||
}
|
||
|
||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "graph.create";
|
||
let kind =
|
||
str_param(p, "type").map(|t| GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))).transpose()?.unwrap_or_default();
|
||
let (x, y) = (f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||
let (w, h) = (f64_or(p, "width", 200.0).abs().max(1.0), f64_or(p, "height", 150.0).abs().max(1.0));
|
||
let mut spec = GraphSpec { kind, rect: Rect::new(x, y, x + w, y + h), ..GraphSpec::default() };
|
||
if !apply_data(&mut spec, p) {
|
||
// Starter data so the new graph shows something (edit it with Object → Graph → Data…).
|
||
spec.series = vec!["Series 1".into(), "Series 2".into()];
|
||
spec.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()];
|
||
spec.rows = vec![vec![3.0, 2.0], vec![5.0, 4.0], vec![4.0, 6.0], vec![7.0, 5.0]];
|
||
}
|
||
let parent = s.doc()?.insertion_parent();
|
||
let id = s.edit("Graph", |d, sel| {
|
||
let gid = d.alloc_id();
|
||
let mut g = Node::group(gid, vec![]);
|
||
g.name = Some(format!("{} Graph", kind.label()));
|
||
d.insert(parent, usize::MAX, g)?;
|
||
regenerate(d, gid, spec)?;
|
||
sel.set([gid]);
|
||
Ok(gid)
|
||
})?;
|
||
Ok(json!({ "id": id.0 }))
|
||
}
|
||
|
||
/// The selected graph (or the graph containing the selection).
|
||
fn target(s: &Session, p: &Value, cmd: &str) -> Result<NodeId> {
|
||
let st = s.doc()?;
|
||
let start = id_param(p, "id").into_iter().chain(st.selection.objects.iter().copied());
|
||
for id in start {
|
||
let mut cur = Some(id);
|
||
while let Some(c) = cur {
|
||
if st.doc.node(c).is_some_and(|n| n.graph.is_some()) {
|
||
return Ok(c);
|
||
}
|
||
cur = st.doc.parent_of(c);
|
||
}
|
||
}
|
||
Err(bad(cmd, "select a graph"))
|
||
}
|
||
|
||
fn spec_of(s: &Session, id: NodeId) -> Result<GraphSpec> {
|
||
s.doc()?.doc.node(id).and_then(|n| n.graph.as_deref().cloned()).ok_or(EngineError::NoNode(id))
|
||
}
|
||
|
||
fn set_data(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let id = target(s, p, "graph.setData")?;
|
||
let mut spec = spec_of(s, id)?;
|
||
if !apply_data(&mut spec, p) {
|
||
return Ok(json!({ "csv": to_csv(&spec), "series": spec.series, "categories": spec.categories, "rows": spec.rows }));
|
||
}
|
||
s.edit("Graph Data", |d, _| regenerate(d, id, spec))?;
|
||
Ok(json!({ "id": id.0 }))
|
||
}
|
||
|
||
fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "graph.setType";
|
||
let id = target(s, p, C)?;
|
||
let mut spec = spec_of(s, id)?;
|
||
let keys = ["type", "columnWidth", "clusterWidth", "legend", "markPoints", "connectPoints", "ticks", "axisMin", "axisMax"];
|
||
if !keys.iter().any(|k| p.get(*k).is_some()) {
|
||
return Ok(json!({
|
||
"type": spec.kind.id(), "columnWidth": spec.column_width, "clusterWidth": spec.cluster_width, "legend": spec.legend,
|
||
"markPoints": spec.mark_points, "connectPoints": spec.connect_points, "ticks": spec.ticks,
|
||
}));
|
||
}
|
||
if let Some(t) = str_param(p, "type") {
|
||
spec.kind = GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))?;
|
||
}
|
||
spec.column_width = f64_or(p, "columnWidth", spec.column_width).clamp(1.0, 1000.0);
|
||
spec.cluster_width = f64_or(p, "clusterWidth", spec.cluster_width).clamp(1.0, 100.0);
|
||
spec.legend = bool_or(p, "legend", spec.legend);
|
||
spec.mark_points = bool_or(p, "markPoints", spec.mark_points);
|
||
spec.connect_points = bool_or(p, "connectPoints", spec.connect_points);
|
||
spec.ticks = p.get("ticks").and_then(Value::as_u64).map_or(spec.ticks, |t| t.min(100) as usize);
|
||
if let Some(v) = p.get("axisMin") {
|
||
spec.axis_min = v.as_f64();
|
||
}
|
||
if let Some(v) = p.get("axisMax") {
|
||
spec.axis_max = v.as_f64();
|
||
}
|
||
let label = format!("{} Graph", spec.kind.label());
|
||
s.edit("Graph Type", |d, _| {
|
||
regenerate(d, id, spec)?;
|
||
if let Some(n) = d.node_mut(id)
|
||
&& n.name.as_deref().is_some_and(|nm| nm.ends_with(" Graph"))
|
||
{
|
||
n.name = Some(label);
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({ "id": id.0 }))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use serde_json::json;
|
||
use vectorcraft_doc::NodeKind;
|
||
|
||
use crate::{NodeId, Session};
|
||
|
||
fn graph(s: &mut Session, ty: &str) -> NodeId {
|
||
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
|
||
NodeId(
|
||
s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||
.as_u64()
|
||
.unwrap(),
|
||
)
|
||
}
|
||
|
||
fn group<'a>(n: &'a vectorcraft_doc::Node, name: &str) -> &'a vectorcraft_doc::Node {
|
||
n.children().unwrap().iter().find(|c| c.name.as_deref() == Some(name)).unwrap_or_else(|| panic!("no {name}"))
|
||
}
|
||
|
||
#[test]
|
||
fn every_graph_type_generates_series_groups() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||
for ty in ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"] {
|
||
let id = graph(&mut s, ty);
|
||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||
assert!(n.graph.is_some(), "{ty}");
|
||
if ty != "scatter" {
|
||
assert!(!group(&n, "2024").children().unwrap().is_empty(), "{ty}");
|
||
assert!(!group(&n, "2025").children().unwrap().is_empty(), "{ty}");
|
||
}
|
||
assert!(n.geometric_bounds().unwrap().width() > 100.0, "{ty}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn column_heights_follow_data_and_data_edits_regenerate_in_place() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||
let id = graph(&mut s, "column");
|
||
let heights = |s: &Session| -> Vec<f64> {
|
||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||
// Bars only (the legend swatch is the 8 × 8 square at the end).
|
||
let g = group(&n, "2025");
|
||
let ch = g.children().unwrap();
|
||
ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap().height()).collect()
|
||
};
|
||
let h = heights(&s);
|
||
assert!((h[2] / h[0] - 3.0).abs() < 1e-6, "{h:?}");
|
||
// Move the graph, then change the data: it stays where it was moved.
|
||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
|
||
let x0 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
|
||
let v = s.execute("graph.setData", &json!({})).unwrap();
|
||
assert!(v["csv"].as_str().unwrap().contains("Q2,5,4"));
|
||
s.execute("graph.setData", &json!({"csv": ",2024,2025\nQ1,3,2\nQ2,5,8\nQ3,-1,6"})).unwrap();
|
||
let h2 = heights(&s);
|
||
assert!((h2[1] / h2[0] - 4.0).abs() < 1e-6, "{h2:?}");
|
||
assert!((s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 - x0).abs() < 1.0);
|
||
// Type change keeps the data; undo restores the columns.
|
||
s.execute("graph.setType", &json!({"type": "pie"})).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], json!("column"));
|
||
// Selecting a bar inside still targets the graph.
|
||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||
let bar = group(&n, "2024").children().unwrap()[0].id;
|
||
s.execute("select.set", &json!({"ids": [bar.0]})).unwrap();
|
||
assert!(s.execute("graph.setData", &json!({})).is_ok());
|
||
assert!(matches!(n.kind, NodeKind::Group { .. }));
|
||
}
|
||
|
||
#[test]
|
||
fn csv_parsing() {
|
||
let (s, c, r) = super::parse_csv("\t2024\t2025\nQ1\t1\t2\nQ2\t3\t4\n");
|
||
assert_eq!(s, vec!["2024", "2025"]);
|
||
assert_eq!(c, vec!["Q1", "Q2"]);
|
||
assert_eq!(r, vec![vec![1.0, 2.0], vec![3.0, 4.0]]);
|
||
let (s, c, r) = super::parse_csv("1,2\n3,4");
|
||
assert!(s.is_empty() && c.is_empty());
|
||
assert_eq!(r.len(), 2);
|
||
assert_eq!(super::nice_axis(0.0, 7.0, 0), (0.0, 8.0, 2.0));
|
||
}
|
||
}
|