diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index 96cbf56edf..259c34ca2d 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -511,9 +511,8 @@ pub struct GraphSpec { /// 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, - /// Bounds of the art other than type when it was last generated. Moves and scales of the graph group are measured - /// on these when present, so a label edit that resizes its type is not taken for one; graphs saved without them - /// are measured on [`Self::placed`]. + /// Bounds of the art other than type when it was last generated ([`art_bounds`]). Moves and scales of the graph + /// group are measured on these when present; graphs saved without them are measured on [`Self::placed`]. #[serde(skip_serializing_if = "Option::is_none")] pub placed_art: Option, } @@ -591,6 +590,32 @@ impl GraphSpec { } } +/// The geometric bounds of a graph's art `children` leaving out their type: the plot, axes, tick marks and legend +/// swatches without the labels. None when there is nothing else. [`GraphSpec::placed_art`] keeps these. +pub fn art_bounds(children: &[std::sync::Arc]) -> Option { + children.iter().filter_map(|c| art_bounds_of(c).0).reduce(|a, b| a.union(b)) +} + +/// The geometric bounds of `n` leaving out the type in it, and whether there was type to leave out. A group without type +/// gives its own bounds, so a clipping group gives the bounds of its clip. +fn art_bounds_of(n: &crate::Node) -> (Option, bool) { + if matches!(n.kind, crate::NodeKind::Text(_)) { + return (None, true); + } + let Some(children) = n.children() else { return (n.geometric_bounds(), false) }; + let mut bounds: Option = None; + let mut had_type = false; + for c in children { + let (b, t) = art_bounds_of(c); + had_type |= t; + bounds = match (bounds, b) { + (Some(a), Some(b)) => Some(a.union(b)), + (a, b) => a.or(b), + }; + } + if had_type { (bounds, true) } else { (n.geometric_bounds(), false) } +} + impl Default for GraphSpec { fn default() -> Self { Self { diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 20332742bd..d495840092 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -40,6 +40,7 @@ pub use commands::specs; pub(crate) use data::apply_csv; #[cfg(test)] use data::{fmt_value, parse_csv, to_csv}; +pub(crate) use edit::{graphs_holding, keep_placed}; #[cfg(test)] use generate::generate; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index bdfd13beeb..e1c5c336b3 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -1,6 +1,8 @@ +use vectorcraft_doc::graph::art_bounds; + use super::axis::{MAX_TICK_MARKS, count_given, one_line}; use super::data::apply_data; -use super::generate::{art_bounds, children_bounds, generate}; +use super::generate::{children_bounds, generate}; use super::series::{picked_series, reselect_series}; use super::{ Affine, Document, EngineError, GraphKind, GraphSpec, Node, NodeId, Rect, Result, Session, TickLength, Value, ValueAxisSide, bad, f64_req, json, @@ -17,17 +19,62 @@ fn bound_param(p: &Value, key: &str, cmd: &str) -> Result>> { } } +/// The bounds a redraw of graph `n` measures a move or scale on, as kept at the last generation and as they are now: +/// those of its art without type ([`GraphSpec::placed_art`]), or those of all its art when the graph keeps no such +/// bounds with a width and a height, or has no art without type now. +fn measured(n: &Node) -> Option<(Rect, Rect)> { + let kept = n.extras().graph.as_deref()?; + let c = n.children()?; + let art = kept.placed_art.filter(|p| p.width() > 1e-9 && p.height() > 1e-9).zip(art_bounds(c)); + art.or_else(|| kept.placed.zip(children_bounds(c))) +} + +/// `r` carried by the change from bounds `from` to bounds `to`: each edge stays at the same place relative to them. +/// `None` when `from` has no width or no height. +fn carried(r: Rect, from: Rect, to: Rect) -> Option { + if from.width() <= 1e-9 || from.height() <= 1e-9 { + return None; + } + let (sx, sy) = (to.width() / from.width(), to.height() / from.height()); + Some(Rect::new(to.x0 + (r.x0 - from.x0) * sx, to.y0 + (r.y0 - from.y0) * sy, to.x0 + (r.x1 - from.x0) * sx, to.y0 + (r.y1 - from.y0) * sy)) +} + +/// The graphs holding objects `ids`, each with the bounds of its art without type and of all its art. Give them to +/// [`keep_placed`] after an edit that changes a graph's art without moving the graph. +pub(crate) fn graphs_holding(d: &Document, ids: &[NodeId]) -> Vec<(NodeId, Option, Option)> { + let mut out: Vec<(NodeId, Option, Option)> = vec![]; + for id in ids { + let Some(chain) = d.ancestry(*id) else { continue }; + let holder = chain.iter().rev().skip(1).copied().find(|a| d.node(*a).is_some_and(|n| n.extras().graph.is_some())); + let Some(gid) = holder.filter(|g| !out.iter().any(|(o, ..)| o == g)) else { continue }; + let children = d.node(gid).and_then(Node::children); + out.push((gid, children.and_then(|c| art_bounds(c)), children.and_then(|c| children_bounds(c)))); + } + out +} + +/// After an edit that changed the art of `graphs` (from [`graphs_holding`]) without moving them, set the bounds each +/// keeps so that its next redraw measures the same move or scale as it would have before the edit. +pub(crate) fn keep_placed(d: &mut Document, graphs: &[(NodeId, Option, Option)]) { + for &(gid, art_was, whole_was) in graphs { + let Some(n) = d.node(gid) else { continue }; + let children = n.children(); + let (art_now, whole_now) = (children.and_then(|c| art_bounds(c)), children.and_then(|c| children_bounds(c))); + let Some(spec) = d.node_mut(gid).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { continue }; + if let (Some(placed), Some(was), Some(now)) = (spec.placed_art, art_was, art_now) { + spec.placed_art = carried(now, was, placed).or(spec.placed_art); + } + if let (Some(placed), Some(was), Some(now)) = (spec.placed, whole_was, whole_now) { + spec.placed = carried(now, was, placed).or(spec.placed); + } + } +} + /// 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_art` bounds, or `placed` in graphs saved without them). pub(super) fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> { let n = d.node(id).ok_or(EngineError::NoNode(id))?; - let kept = n.extras().graph.as_deref(); - let measured = match (kept.and_then(|g| g.placed_art), n.children()) { - (Some(placed), Some(c)) => art_bounds(c).map(|now| (placed, now)), - (None, Some(c)) => kept.and_then(|g| g.placed).zip(children_bounds(c)), - (_, None) => None, - }; - if let Some((placed, now)) = measured + if let Some((placed, now)) = measured(n) && 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 diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index 759c442acd..69510652b9 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -1022,29 +1022,3 @@ impl<'a> Plot<'a> { pub(super) fn children_bounds(children: &[Arc]) -> Option { children.iter().filter_map(|c| c.geometric_bounds()).reduce(|a, b| a.union(b)) } - -/// The geometric bounds of `children` leaving out their type: the plot, axes, tick marks and legend swatches without -/// the labels. None when there is nothing else. -pub(super) fn art_bounds(children: &[Arc]) -> Option { - children.iter().filter_map(|c| art_bounds_of(c).0).reduce(|a, b| a.union(b)) -} - -/// The geometric bounds of `n` leaving out the type in it, and whether there was type to leave out. A group without type -/// gives its own bounds, so a clipping group gives the bounds of its clip. -fn art_bounds_of(n: &Node) -> (Option, bool) { - if matches!(n.kind, NodeKind::Text(_)) { - return (None, true); - } - let Some(children) = n.children() else { return (n.geometric_bounds(), false) }; - let mut bounds: Option = None; - let mut had_type = false; - for c in children { - let (b, t) = art_bounds_of(c); - had_type |= t; - bounds = match (bounds, b) { - (Some(a), Some(b)) => Some(a.union(b)), - (a, b) => a.or(b), - }; - } - if had_type { (bounds, true) } else { (n.geometric_bounds(), false) } -} diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 04572d10bd..2e021ac568 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -5716,11 +5716,86 @@ fn moving_one_label_leaves_the_plot_in_place() { s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); let id = graph(&mut s, "column"); let rect = spec(&s, id).rect; - let l = text_id(&s, id, "Legend", "2025"); - s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); - s.execute("object.move", &json!({"dx": 120, "dy": 60})).unwrap(); + for (grp, text) in [("Legend", "2025"), ("Axes", "Q2")] { + let l = text_id(&s, id, grp, text); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 120, "dy": 60})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); + assert_eq!(spec(&s, id).rect, rect, "after moving {text}"); + } +} + +/// Label edits leave the plot in place in graphs whose legend swatches follow the size of the labels at the next +/// drawing: a legend across the top, a legend after a right value axis, and legend labels of several lines. A move of +/// the graph made after them is still followed. +#[test] +fn restyling_the_labels_of_a_graph_whose_legend_follows_them_leaves_the_plot_in_place() { + let csv = ",North|East|Coast,South|West,Inland|Hills|Lakes|Plains\nQ1,3,2,4\nQ2,5,4,1\nQ3,-1,6,2"; + for options in [json!({"legendAcrossTop": true}), json!({"valueAxis": "right"}), json!({"valueAxis": "both"}), json!({})] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("graph.setType", &options).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let rect = spec(&s, id).rect; + for size in [16, 5] { + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("text.setStyle", &json!({"size": size})).unwrap(); + for _ in 0..2 { + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + assert_eq!(spec(&s, id).rect, rect, "{options}: labels at {size} pt"); + } + } + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 30, "dy": 20})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let moved = spec(&s, id).rect; + assert!((moved.x0 - (rect.x0 + 30.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 20.0)).abs() < 1e-6, "{options}: {moved:?}"); + assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{options}: {moved:?}"); + } +} + +/// Outlining a graph's labels leaves the plot where it is when the graph is drawn again, and a move of the graph made +/// before them is still followed. +#[test] +fn outlining_a_graphs_labels_leaves_the_plot_in_place() { + let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let rect = spec(&s, id).rect; + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + s.execute("select.set", &json!({"ids": [text_id(&s, id, "Axes", "Q2").0, text_id(&s, id, "Legend", "2025").0]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + assert_eq!(spec(&s, id).rect, rect, "after outlining labels"); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 30, "dy": 15})).unwrap(); + s.execute("select.set", &json!({"ids": [text_id(&s, id, "Axes", "Q1").0, text_id(&s, id, "Legend", "2024").0]})).unwrap(); + s.execute("type.createOutlines", &json!({})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap(); + let moved = spec(&s, id).rect; + assert!((moved.x0 - (rect.x0 + 30.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 15.0)).abs() < 1e-6, "{moved:?}"); + assert!((moved.width() - rect.width()).abs() < 1e-6 && (moved.height() - rect.height()).abs() < 1e-6, "{moved:?}"); +} + +/// A graph whose kept bounds of its art without type have no width follows a move with the bounds of all of its art. +#[test] +fn a_graph_whose_art_without_type_kept_no_width_still_follows_a_move() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id = graph(&mut s, "column"); + let rect = spec(&s, id).rect; + let mut flat = spec(&s, id); + let art = flat.placed_art.unwrap(); + flat.placed_art = Some(vectorcraft_geom::Rect::new(art.x0, art.y0, art.x0, art.y1)); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().extras_mut().graph = Some(Box::new(flat)); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 25, "dy": 5})).unwrap(); s.execute("graph.setData", &json!({"id": id.0, "csv": ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6"})).unwrap(); - assert_eq!(spec(&s, id).rect, rect); + let moved = spec(&s, id).rect; + assert!((moved.x0 - (rect.x0 + 25.0)).abs() < 1e-6 && (moved.y0 - (rect.y0 + 5.0)).abs() < 1e-6, "{moved:?}"); } /// A graph saved before it kept the bounds of its art without type follows a move made since with its old bounds. @@ -6659,3 +6734,42 @@ fn a_graph_opened_from_ai_keeps_its_data_for_editing() { assert!(notes.iter().any(|w| w.as_str().is_some_and(|w| w.starts_with("graphs are written as their art"))), "{name}: {r}"); } } + +/// A graph opened from a `.ai` keeps the bounds of its art without type, so moving one of its labels on its own +/// leaves the plot where it is at the first redraw. +#[test] +fn a_graph_opened_from_ai_measures_moves_without_its_labels() { + let graph = "%_Gs\n%_100 200 300 100 Gb\n%_5 0 0 14 0 1 0 0 6 44 Gy\n%_10 2 0 0 90 80 0 Gd\n\ + %_1 () Ga\n%_0 13 0 0 0 0 0 () GA\n%_2 () Ga\n%_0 14 0 0 0 0 0 () GA\n%_4 () Ga\n%_0 14 0 0 0 0 0 () GA\n\ + %_2 2 1 1 Gz\n%_() Gc (East) Gc (Q1) Gc 5 Gc GC\n\ + u\nu\n0.6 0 1 0 k\n110 100 m\n110 150 L\n130 150 L\n130 100 L\nf\nU\n%_2 1 0 0 Go\n\ + u\n0 0 0 1 K\n100 200 m\n100 100 L\n300 100 L\nS\nU\n\ + 0 To\n1 0 0 1 100 220 0 Tp\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(Sales) Tx\nTO\nU\n%_GS\n"; + let text = format!( + "%!PS-Adobe-3.0\n%%Creator: a script\n%%BoundingBox: 0 0 400 400\n%AI5_FileFormat 3\n%AI3_Cropmarks: 0 0 400 400\n%%EndComments\n\ + %%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n%AI5_BeginLayer\n1 1 1 1 0 0 0 79 128 255 Lb\n(Graph) Ln\n0 A\n{graph}LB\n\ + %AI5_EndLayer--\n%%PageTrailer\n%%Trailer\n%%EOF\n" + ); + let dir = vectorcraft_testkit::temp_dir("graph-ai-label"); + let path = dir.join("graph.ai"); + std::fs::write(&path, text).unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": path})).unwrap(); + let (mut graphs, mut labels) = (vec![], vec![]); + s.doc().unwrap().doc.walk(|n| { + if n.extras().graph.is_some() { + graphs.push(n.id); + } + if matches!(&n.kind, NodeKind::Text(t) if t.plain_text() == "Sales") { + labels.push(n.id); + } + }); + assert_eq!((graphs.len(), labels.len()), (1, 1)); + let (id, label) = (graphs[0], labels[0]); + let before = spec(&s, id); + assert!(before.placed_art.is_some()); + s.execute("select.set", &json!({"ids": [label.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 80, "dy": -40})).unwrap(); + s.execute("graph.setData", &json!({"id": id.0, "csv": ",East\nQ1,8\nQ2,2"})).unwrap(); + assert_eq!(spec(&s, id).rect, before.rect); +} diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index b3fab4a2bc..2e7f88991d 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -601,6 +601,8 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { if texts.is_empty() { return Err(EngineError::Other("Create Outlines: select text objects".into())); } + // Outlining a graph's labels does not move or scale its plot at the next redraw. + let graphs = super::graph::graphs_holding(d, &texts); let before = (!super::dimension::reached(d, &roots).is_empty()).then(|| d.clone()); let ended = super::dimension::end_links(d, &roots); let mut new_sel: Vec = roots.iter().copied().filter(|r| !texts.contains(r)).collect(); @@ -728,6 +730,7 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { } // Outlined frames of threaded text leave their threads with the text they show. super::threads::leave_threads(d, &threaded); + super::graph::keep_placed(d, &graphs); sel.set(new_sel); Ok(out) })?; diff --git a/crates/eps/src/import/ai/read/graph.rs b/crates/eps/src/import/ai/read/graph.rs index 759eb4e1cd..9fb60408c7 100644 --- a/crates/eps/src/import/ai/read/graph.rs +++ b/crates/eps/src/import/ai/read/graph.rs @@ -631,6 +631,7 @@ impl<'a> Reader<'a> { let Some(paints) = series_paints(art, &tags, spec.kind == GraphKind::Line) else { return false }; spec.series_paints = paints; spec.placed = Some(placed); + spec.placed_art = art.children().and_then(|c| vectorcraft_doc::graph::art_bounds(c)); tag_series(art, &tags); art.extras_mut().graph = Some(Box::new(spec)); true diff --git a/docs/mcp.md b/docs/mcp.md index eb60457600..3951a73b00 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -850,6 +850,13 @@ kind takes them when the graph is drawn again. The graph saves the paragraph att `categoriesPara` and `legendPara` in its label styles, and still sets each label's alignment for its place. The legend leaves room for a legend label's stored indents, spacing and leading. +When a graph is drawn again, its plot moves and scales with any move or scale of the graph made since it was last +drawn. The graph saves the bounds of its art other than type as `placedArt` (and the bounds of all of its art as +`placed`), and measures the move or scale on those. Changing the size, indents or spacing of the labels, moving a label +on its own and `type.createOutlines` on labels do not move or scale the plot. A graph opened from a `.ai` file saves +`placedArt` too. A graph saved without `placedArt`, or with a `placedArt` that has no width or height, is measured on +`placed`. + The `appearance.*` commands, `graphicStyle.apply` and `graphicStyle.redefine`, and the paint, stroke, gradient and transparency commands given an `item` (or the Appearance panel's active row) store a graph label's object rows for its kind of label: the paint, weight, opacity and blend mode of its topmost visible fill and stroke row, their order and