diff --git a/ROADMAP.md b/ROADMAP.md index 338d3dedc..ccab15c7f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -35,6 +35,7 @@ _Last updated: 2026-10-01._ - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools. - **Cutting:** Eraser, Scissors, Knife. - **Transform:** Rotate, Reflect, Scale, Shear, Reshape, Free Transform (distort/perspective). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. - **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View. - **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes. - **Geometry and effects:** @@ -66,7 +67,7 @@ _Last updated: 2026-10-01._ | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 24–38 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 8–15 | | M11 | Artboards & views (artboard panel/tool done, Trim View; print tiling, multiple windows, presentation polish) | 🟡 | 9–14 | -| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview) done; Graphs pending | 12–20 | +| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, regenerate in place) done; graph Design/Column/Marker designs pending | 8–14 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 8–12 | | M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces done; accessibility, Windows/Linux packaging pending | 12–20 | | — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 40–60 | diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs new file mode 100644 index 000000000..96bcf6b16 --- /dev/null +++ b/crates/doc/src/graph.rs @@ -0,0 +1,116 @@ +//! Graphs (Illustrator's graph tools): a group whose art is generated from a [`GraphSpec`]. + +use serde::{Deserialize, Serialize}; +use vectorcraft_geom::Rect; + +/// The nine graph types, in the Graph tool group's order. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GraphKind { + #[default] + Column, + StackedColumn, + Bar, + StackedBar, + Line, + Area, + Scatter, + Pie, + Radar, +} + +impl GraphKind { + pub const ALL: [GraphKind; 9] = [ + GraphKind::Column, + GraphKind::StackedColumn, + GraphKind::Bar, + GraphKind::StackedBar, + GraphKind::Line, + GraphKind::Area, + GraphKind::Scatter, + GraphKind::Pie, + GraphKind::Radar, + ]; + pub fn id(self) -> &'static str { + match self { + GraphKind::Column => "column", + GraphKind::StackedColumn => "stackedColumn", + GraphKind::Bar => "bar", + GraphKind::StackedBar => "stackedBar", + GraphKind::Line => "line", + GraphKind::Area => "area", + GraphKind::Scatter => "scatter", + GraphKind::Pie => "pie", + GraphKind::Radar => "radar", + } + } + /// Parse an id, also accepting the tool ids (`columnGraph`, `stackedBarGraph`…). + pub fn parse(s: &str) -> Option { + let s = s.strip_suffix("Graph").unwrap_or(s); + Self::ALL.into_iter().find(|k| k.id().eq_ignore_ascii_case(s)) + } + pub fn label(self) -> &'static str { + match self { + GraphKind::Column => "Column", + GraphKind::StackedColumn => "Stacked Column", + GraphKind::Bar => "Bar", + GraphKind::StackedBar => "Stacked Bar", + GraphKind::Line => "Line", + GraphKind::Area => "Area", + GraphKind::Scatter => "Scatter", + GraphKind::Pie => "Pie", + GraphKind::Radar => "Radar", + } + } +} + +/// Graph data and options. `rows` are categories (one per row of the Graph Data window), each +/// holding one value per series; `series` are the legend labels (the first row of the window). +/// Scatter graphs read each series as (y, x) column pairs, like Illustrator. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct GraphSpec { + pub kind: GraphKind, + /// The plot rectangle (the area the graph tool dragged), document points. + pub rect: Rect, + pub series: Vec, + pub categories: Vec, + pub rows: Vec>, + /// Column/bar width as a % of the available category width (90). + pub column_width: f64, + /// Cluster width as a % (80). + pub cluster_width: f64, + /// Legend on the right (Graph Type options: Add Legend Across Top = false). + pub legend: bool, + /// Value axis: tick count override (0 = automatic), min/max override. + pub ticks: usize, + pub axis_min: Option, + pub axis_max: Option, + /// Line graphs: mark data points, connect them, fill (area-like) the lines. + pub mark_points: bool, + pub connect_points: bool, + /// Bounds of the art when it was last generated (detects moves/scales of the graph group). + #[serde(skip_serializing_if = "Option::is_none")] + pub placed: Option, +} + +impl Default for GraphSpec { + fn default() -> Self { + Self { + kind: GraphKind::Column, + rect: Rect::new(0.0, 0.0, 200.0, 150.0), + series: vec![], + categories: vec![], + rows: vec![vec![1.0]], + column_width: 90.0, + cluster_width: 80.0, + legend: true, + ticks: 0, + axis_min: None, + axis_max: None, + mark_points: true, + connect_points: true, + placed: None, + } + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index e78be50d5..2fdca4a84 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -6,6 +6,7 @@ #![forbid(unsafe_code)] pub mod appearance; +pub mod graph; pub mod hit; pub mod live; pub mod node; @@ -30,6 +31,7 @@ pub(crate) mod skip { } pub use appearance::{Appearance, AppearanceItem, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile}; +pub use graph::{GraphKind, GraphSpec}; pub use hit::{Hit, HitKind}; pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask}; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 039de3767..d85a7929b 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -266,6 +266,9 @@ pub struct Node { /// Object → Text Wrap: area type below this object (in the same layer) flows around it. #[serde(default, skip_serializing_if = "Option::is_none")] pub wrap: Option, + /// Graph object: the group's children are generated from this spec (Object → Graph). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graph: Option>, pub kind: NodeKind, } @@ -309,6 +312,7 @@ impl Node { mask: None, trace: None, wrap: None, + graph: None, kind, } } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 816458de5..3b66df255 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -133,6 +133,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option { mask: None, trace: None, wrap: None, + graph: None, kind, ..n.clone() }) diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs new file mode 100644 index 000000000..c7ad8e230 --- /dev/null +++ b/crates/engine/src/cmd/graph.rs @@ -0,0 +1,676 @@ +//! 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 { + 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, Vec, Vec>) { + let rows: Vec> = 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::().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::().is_err()); + categories.push(if labelled { r[0].clone() } else { String::new() }); + values.push(r.iter().skip(labelled as usize).map(|c| c.parse::().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::>().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::>()); + 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>, +} + +impl Gen<'_> { + fn path(&mut self, pd: PathData, fill: Paint, stroke: Paint, w: f64) -> Arc { + Arc::new(Node::path(self.d.alloc_id(), pd, Appearance::basic(fill, stroke, w))) + } + fn line(&mut self, a: Point, b: Point) -> Arc { + 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 { + 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 { + 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> { + 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![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 = (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 = (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 = + (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 = (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 = vec![0.0; ncat]; + for (s, items) in series.iter_mut().enumerate() { + let above: Vec = (0..ncat).map(|c| below[c] + val(c, s)).collect(); + let mut pts: Vec = (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]) -> Option { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c51f067bd..5ed558bc9 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -14,6 +14,7 @@ mod edit; mod effectcmd; mod file; mod fonts; +pub(crate) mod graph; pub mod help; mod layer; mod live; @@ -161,6 +162,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(help::specs()); v.extend(threads::specs()); v.extend(textwrap::specs()); + v.extend(graph::specs()); v.extend(docmenu::specs()); v.extend(docinfo::specs()); v.extend(panelcmds::specs()); diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index b3d51ba52..0f99f7f1c 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -1,4 +1,4 @@ -//! Flare and Reshape tools. +//! Flare, Reshape and Graph tools. //! //! Flare: drag sets the centre and its size (click without dragging opens the Flare Tool Options), //! then a click sets the end point of the rings. Reshape: drag a point of a selected path; the @@ -14,6 +14,7 @@ pub fn create(id: &str) -> Option> { Some(match id { "flare" => Box::new(FlareTool::default()), "reshape" => Box::new(ReshapeTool::default()), + id if vectorcraft_doc::GraphKind::parse(id).is_some() && id.ends_with("Graph") => Box::new(GraphTool::new(id)), _ => return None, }) } @@ -214,6 +215,86 @@ impl Tool for ReshapeTool { } } +/// Graph tools: drag the plot rectangle (Shift = square, Alt = from the centre); a click opens the +/// size dialog. A new graph opens the Graph Data dialog. +pub struct GraphTool { + id: &'static str, + start: Option, + began: bool, +} + +impl GraphTool { + fn new(id: &str) -> Self { + let id = crate::catalog::tool_info(id).map(|t| t.id).unwrap_or("columnGraph"); + Self { id, start: None, began: false } + } + fn kind(&self) -> &'static str { + vectorcraft_doc::GraphKind::parse(self.id).unwrap_or_default().id() + } + fn params(&self, s: Point, p: Point, m: Mods) -> Value { + let mut d = p - s; + if m.shift { + let k = d.x.abs().max(d.y.abs()); + d = Vec2::new(k * d.x.signum(), k * d.y.signum()); + } + let r = if m.alt { vectorcraft_geom::Rect::from_points(s - d, s + d) } else { vectorcraft_geom::Rect::from_points(s, s + d) }; + json!({ "type": self.kind(), "x": r.x0, "y": r.y0, "width": r.width().max(1.0), "height": r.height().max(1.0) }) + } +} + +impl Tool for GraphTool { + fn id(&self) -> &'static str { + self.id + } + fn busy(&self) -> bool { + self.start.is_some() + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::Down => { + self.start = Some(ev.pos); + self.began = false; + vec![] + } + PointerKind::Drag => { + let Some(s) = self.start else { return vec![] }; + let mut out = vec![]; + if !self.began { + if ev.pos.distance(s) < cx.tol(3.0) { + return out; + } + self.began = true; + out.push(Action::Begin("Graph".into())); + } + out.push(Action::Preview("graph.create".into(), self.params(s, ev.pos, ev.mods))); + out + } + PointerKind::Up => { + let Some(s) = self.start.take() else { return vec![] }; + if std::mem::take(&mut self.began) { + vec![ + Action::Preview("graph.create".into(), self.params(s, ev.pos, ev.mods)), + Action::Commit, + Action::Dialog("graphData".into(), json!({})), + ] + } else { + vec![Action::Dialog("graph".into(), json!({ "x": s.x, "y": s.y, "type": self.kind() }))] + } + } + _ => vec![], + } + } + fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if key == ToolKey::Escape && self.start.take().is_some() && std::mem::take(&mut self.began) { + return vec![Action::Cancel]; + } + vec![] + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { + Cursor::Crosshair + } +} + #[cfg(test)] mod tests { use super::*; @@ -223,5 +304,10 @@ mod tests { assert_eq!(create("flare").unwrap().id(), "flare"); assert_eq!(create("reshape").unwrap().id(), "reshape"); assert!(create("pen").is_none()); + for g in + ["columnGraph", "stackedColumnGraph", "barGraph", "stackedBarGraph", "lineGraph", "areaGraph", "scatterGraph", "pieGraph", "radarGraph"] + { + assert_eq!(create(g).unwrap().id(), g); + } } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 09ae9ed91..4eafff12b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -17,6 +17,16 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) { "polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})), "star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})), "lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})), + // Graph tool click: the graph's size. + "graph" => Dialog::new( + "command", + json!({"__command": "graph.create", "__label": "Graph", "type": p.get("type").cloned().unwrap_or(json!("column")), "x": x, "y": y, "width": 200, "height": 150}), + ), + // After drawing a graph: Graph Data (CSV: header row of series, then category, values…). + "graphData" => match app.session.execute("graph.setData", &json!({})) { + Ok(v) => Dialog::new("command", json!({"__command": "graph.setData", "__label": "Graph Data", "csv": v["csv"]})), + Err(_) => return, + }, // Flare Tool Options (Center / Halo / Rays / Rings), applied through the generic command dialog. "flare" => Dialog::new( "command", @@ -80,6 +90,13 @@ fn effect_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool { changed = true; } } + Value::String(mut s) if s.contains('\n') => { + // Multi-line values (Graph Data CSV) get a text area. + if ui.add(egui::TextEdit::multiline(&mut s).desired_width(260.0).desired_rows(8).font(egui::TextStyle::Monospace)).changed() { + d.fields.insert(k, json!(s)); + changed = true; + } + } Value::String(mut s) => { if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() { d.fields.insert(k, json!(s)); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7fb0802d1..27643316a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -873,7 +873,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Fit to Selected Art", "artboard.fitToSelection"), ], ), - sub("Graph", vec![todo("Type…"), todo("Data…"), todo("Design…"), todo("Column…"), todo("Marker…")]), + sub("Graph", vec![c("Type…", "graph.setType"), c("Data…", "graph.setData"), todo("Design…"), todo("Column…"), todo("Marker…")]), ], ), ( @@ -1279,6 +1279,17 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } return; } + // Object → Graph → Type… / Data…: dialogs with the selected graph's current values. + if matches!(id, "graph.setType" | "graph.setData") && p.as_object().is_none_or(|o| o.is_empty()) { + match app.session.execute(id, &json!({})) { + Ok(v) => { + let (label, fields) = if id == "graph.setData" { ("Graph Data", json!({"csv": v["csv"]})) } else { ("Graph Type", v) }; + let _ = app.run("ui.paramDialog", json!({"command": id, "label": label, "params": fields})); + } + Err(e) => app.status(e.to_string()), + } + return; + } // Text Wrap Options: a dialog with the selected wrap object's current values. if id == "object.textWrap.options" && p.as_object().is_none_or(|o| o.is_empty()) { match app.session.execute(id, &json!({})) {