Files
vectorcraft/crates/engine/src/cmd/graph.rs
T

677 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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));
}
}