mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
M12.6: Graphs — the nine graph tools (Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie, Radar), Object → Graph → Type… and Data… (CSV), series groups with legend, regenerate in place keeping moves/scales
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
13d0aef57f
commit
1d493c7f06
10 files changed
+919
-3
No files matched your search
+2
-1
@@ -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 `<pattern>`/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 |
|
||||
|
||||
@@ -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<Self> {
|
||||
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<String>,
|
||||
pub categories: Vec<String>,
|
||||
pub rows: Vec<Vec<f64>>,
|
||||
/// 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<f64>,
|
||||
pub axis_max: Option<f64>,
|
||||
/// 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<Rect>,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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<crate::text::TextWrap>,
|
||||
/// Graph object: the group's children are generated from this spec (Object → Graph).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub graph: Option<Box<crate::graph::GraphSpec>>,
|
||||
pub kind: NodeKind,
|
||||
}
|
||||
|
||||
@@ -309,6 +312,7 @@ impl Node {
|
||||
mask: None,
|
||||
trace: None,
|
||||
wrap: None,
|
||||
graph: None,
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
mask: None,
|
||||
trace: None,
|
||||
wrap: None,
|
||||
graph: None,
|
||||
kind,
|
||||
..n.clone()
|
||||
})
|
||||
|
||||
@@ -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<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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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<Box<dyn Tool>> {
|
||||
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<Point>,
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -873,7 +873,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||
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!({})) {
|
||||
|
||||
Reference in new issue
Block a user