Files
vectorcraft/crates/engine/src/cmd/graph/edit.rs
T
dotCooCoo 6be88a24b4 Fix #785: commands refuse an id that isn't an object id instead of using the selection
Commands whose `id` or `ids` the shared check in Session::execute does not
read still parsed them with id_param/ids_param, which drop anything that is
not a non-negative integer, and then acted on the selection or the current
layer. `object.blend.make {"ids": 3}` blended the selection,
`object.envelope.makeWithWarp {"ids": [2, "x"]}` warped the selection,
`graph.setData {"id": "2"}` changed the selected graph and
`layer.newSublayer {"parent": "2"}` added a sublayer to the current layer.

- cmd::named_ids and cmd::named_id read `ids`/`id` as targets does: a value
  that is not an object id, or an unresolved step reference, is an error.
- The symbol and brush, Live Paint, Shape Builder and Image Trace, blend,
  envelope and mesh, perspective, Liquify, Puppet Warp and graph helpers use
  them in place of the lenient readers.
- layer.clippingMask.toggle checks `id`, and layer.newSublayer checks
  `parent`, with checked_id before falling back to the highlighted row or the
  current layer.
2026-11-02 14:32:49 +07:00

310 lines
14 KiB
Rust

use super::axis::{MAX_TICK_MARKS, count_param, one_line};
use super::data::apply_data;
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, bool_or, f64_or,
f64_req, json, named_id, str_param,
};
/// 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).
pub(super) 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.extras().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.extras_mut().graph = Some(Box::new(spec));
Ok(())
}
pub(super) 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()?.target_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 graph `id` names (or the graph containing it), else the selected graph (or the graph
/// containing the selection).
pub(super) fn target(s: &Session, p: &Value, cmd: &str) -> Result<NodeId> {
let st = s.doc()?;
let start = match named_id(p)? {
Some(id) => vec![id],
None => st.selection.objects.clone(),
};
for id in start {
let mut cur = Some(id);
while let Some(c) = cur {
if st.doc.node(c).is_some_and(|n| n.extras().graph.is_some()) {
return Ok(c);
}
cur = st.doc.parent_of(c);
}
}
Err(bad(cmd, "select a graph"))
}
pub(super) fn spec_of(s: &Session, id: NodeId) -> Result<GraphSpec> {
s.doc()?.doc.node(id).and_then(|n| n.extras().graph.as_deref().cloned()).ok_or(EngineError::NoNode(id))
}
fn query_type_options(spec: &GraphSpec, count: usize, picked: &[usize]) -> Value {
// With series picked, the type and value axis they share, so the dialog's OK keeps them.
let kinds: Vec<GraphKind> = picked.iter().map(|i| spec.series_kind(*i)).collect();
let kind = kinds.first().copied().filter(|k| kinds.iter().all(|x| x == k)).unwrap_or(spec.kind);
// Series on both axes give no value axis (null), so OK leaves each where it is.
let sides: Vec<bool> = picked.iter().map(|i| spec.right_series.binary_search(i).is_ok()).collect();
let value_axis = match sides.first() {
None => Some(spec.value_axis.id()),
Some(r) if sides.iter().all(|x| x == r) => Some(if *r { "right" } else { "left" }),
Some(_) => None,
};
let mut fields = json!({
"type": kind.id(), "columnWidth": spec.column_width, "clusterWidth": spec.cluster_width, "legend": spec.legend,
"markPoints": spec.mark_points, "connectPoints": spec.connect_points, "edgeToEdge": spec.edge_to_edge, "ticks": spec.ticks,
"axisMin": spec.axis_min, "axisMax": spec.axis_max, "valueAxis": value_axis, "separateScales": spec.separate_scales,
"rightTicks": spec.right_ticks, "rightAxisMin": spec.right_axis_min, "rightAxisMax": spec.right_axis_max,
"tickLength": spec.tick_length.id(), "tickMarks": spec.tick_marks.clamp(1, MAX_TICK_MARKS), "rightTickLength": spec.right_tick_length.id(),
"rightTickMarks": spec.right_tick_marks.clamp(1, MAX_TICK_MARKS), "categoryTickLength": spec.category_tick_length.id(),
"categoryTickMarks": spec.category_tick_marks.clamp(1, MAX_TICK_MARKS), "ticksBetweenLabels": spec.ticks_between_labels,
"prefix": one_line(&spec.prefix), "suffix": one_line(&spec.suffix), "rightPrefix": one_line(&spec.right_prefix),
"rightSuffix": one_line(&spec.right_suffix), "dropShadow": spec.drop_shadow, "legendAcrossTop": spec.legend_across_top,
"firstRowInFront": spec.first_row_in_front, "firstColumnInFront": spec.first_column_in_front, "filledLines": spec.filled_lines,
});
// Only the options this graph's types use: overlap order for columns and bars, filled lines for lines.
let kinds: Vec<GraphKind> = (0..count.max(1)).map(|i| spec.series_kind(i)).collect();
let has = |ks: &[GraphKind]| kinds.iter().any(|k| ks.contains(k));
if let Some(o) = fields.as_object_mut() {
if !has(&[GraphKind::Column, GraphKind::StackedColumn, GraphKind::Bar, GraphKind::StackedBar]) {
o.remove("firstRowInFront");
o.remove("firstColumnInFront");
}
if !has(&[GraphKind::Line, GraphKind::Scatter, GraphKind::Radar]) {
o.remove("filledLines");
}
}
// Pie graphs: Legend, Position and Sort (their Legend stands for the legend checkbox).
if let Some(o) = fields.as_object_mut().filter(|_| spec.kind == GraphKind::Pie) {
o.remove("legend");
o.insert(
"pieLegend".into(),
json!(if !spec.legend {
"none"
} else if spec.pie_legend_in_wedges {
"wedges"
} else {
"standard"
}),
);
o.insert("piePosition".into(), json!(spec.pie_position.id()));
o.insert("pieSort".into(), json!(spec.pie_sort.id()));
}
fields
}
pub(super) 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)?;
// The series to retype: `seriesIndexes`, else, with no `id`, a selection made only of this graph's series (Group
// Selection). Anything else selected, the graph itself for one, is the whole graph.
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES);
// A file's list of right-axis series, kept to series the graph has, at most once each.
spec.right_series.retain(|i| *i < count);
spec.right_series.sort_unstable();
spec.right_series.dedup();
if p.get("seriesIndexes").is_some() && str_param(p, "type").is_none() && str_param(p, "valueAxis").is_none() {
return Err(bad(C, "`seriesIndexes` needs a `type` or a `valueAxis`"));
}
let picked = picked_series(s, p, id, count, C)?;
let keys = [
"type",
"seriesIndexes",
"columnWidth",
"clusterWidth",
"legend",
"markPoints",
"connectPoints",
"edgeToEdge",
"ticks",
"axisMin",
"axisMax",
"valueAxis",
"separateScales",
"rightTicks",
"rightAxisMin",
"rightAxisMax",
"tickLength",
"tickMarks",
"rightTickLength",
"rightTickMarks",
"categoryTickLength",
"categoryTickMarks",
"ticksBetweenLabels",
"prefix",
"suffix",
"rightPrefix",
"rightSuffix",
"dropShadow",
"legendAcrossTop",
"firstRowInFront",
"firstColumnInFront",
"filledLines",
"pieLegend",
"piePosition",
"pieSort",
];
if !keys.iter().any(|k| p.get(*k).is_some()) {
return Ok(query_type_options(&spec, count, &picked));
}
// Series picked with Group Selection stay selected through the regeneration (their groups are new nodes).
let reselect: Vec<u32> = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] };
if let Some(t) = str_param(p, "type") {
let kind = GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))?;
if picked.is_empty() {
spec.kind = kind;
} else {
if !kind.combines_with(spec.kind) {
return Err(bad(C, format!("a {} series can't go in a {} graph", kind.label(), spec.kind.label())));
}
spec.series_kinds.resize(count, None);
for &i in &picked {
if let Some(k) = spec.series_kinds.get_mut(i) {
*k = (kind != spec.kind).then_some(kind);
}
}
while spec.series_kinds.last().is_some_and(Option::is_none) {
spec.series_kinds.pop();
}
}
}
if let Some(v) = str_param(p, "valueAxis") {
let side = ValueAxisSide::parse(v).ok_or_else(|| bad(C, format!("unknown value axis `{v}` (left, right or both)")))?;
if picked.is_empty() {
spec.value_axis = side;
} else {
// A series goes on one axis.
if side == ValueAxisSide::Both {
return Err(bad(C, "a series goes on the left or the right value axis"));
}
spec.right_series.retain(|i| *i < count && !picked.contains(i));
if side == ValueAxisSide::Right {
spec.right_series.extend(&picked);
spec.right_series.sort_unstable();
}
}
}
spec.separate_scales = bool_or(p, "separateScales", spec.separate_scales);
for (key, slot) in [
("tickLength", &mut spec.tick_length),
("rightTickLength", &mut spec.right_tick_length),
("categoryTickLength", &mut spec.category_tick_length),
] {
if let Some(v) = str_param(p, key) {
*slot = TickLength::parse(v).ok_or_else(|| bad(C, format!("unknown {key} `{v}` (none, short or full)")))?;
}
}
for (key, slot) in
[("tickMarks", &mut spec.tick_marks), ("rightTickMarks", &mut spec.right_tick_marks), ("categoryTickMarks", &mut spec.category_tick_marks)]
{
*slot = count_param(p, key, MAX_TICK_MARKS as u64).map_or(*slot, |n| n.max(1));
}
spec.ticks_between_labels = bool_or(p, "ticksBetweenLabels", spec.ticks_between_labels);
if let Some(v) = str_param(p, "piePosition") {
spec.pie_position =
vectorcraft_doc::PiePosition::parse(v).ok_or_else(|| bad(C, format!("unknown piePosition `{v}` (even, ratio or stacked)")))?;
}
if let Some(v) = str_param(p, "pieSort") {
spec.pie_sort = vectorcraft_doc::PieSort::parse(v).ok_or_else(|| bad(C, format!("unknown pieSort `{v}` (none, all or first)")))?;
}
for (key, slot) in
[("prefix", &mut spec.prefix), ("suffix", &mut spec.suffix), ("rightPrefix", &mut spec.right_prefix), ("rightSuffix", &mut spec.right_suffix)]
{
if let Some(v) = str_param(p, key) {
// A label, not a paragraph: one line, at most MAX_AFFIX characters.
*slot = one_line(v);
}
}
spec.right_ticks = count_param(p, "rightTicks", 100).unwrap_or(spec.right_ticks);
if let Some(v) = p.get("rightAxisMin") {
spec.right_axis_min = v.as_f64();
}
if let Some(v) = p.get("rightAxisMax") {
spec.right_axis_max = v.as_f64();
}
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, 1000.0);
spec.legend = bool_or(p, "legend", spec.legend);
spec.drop_shadow = bool_or(p, "dropShadow", spec.drop_shadow);
spec.first_row_in_front = bool_or(p, "firstRowInFront", spec.first_row_in_front);
spec.first_column_in_front = bool_or(p, "firstColumnInFront", spec.first_column_in_front);
spec.filled_lines = bool_or(p, "filledLines", spec.filled_lines);
spec.legend_across_top = bool_or(p, "legendAcrossTop", spec.legend_across_top);
// After `legend`, which the Graph Type dialog sends too.
if let Some(v) = str_param(p, "pieLegend") {
(spec.legend, spec.pie_legend_in_wedges) = match v.to_ascii_lowercase().as_str() {
"none" => (false, false),
"standard" => (true, false),
"wedges" => (true, true),
_ => return Err(bad(C, format!("unknown pieLegend `{v}` (none, standard or wedges)"))),
};
}
spec.mark_points = bool_or(p, "markPoints", spec.mark_points);
spec.connect_points = bool_or(p, "connectPoints", spec.connect_points);
spec.edge_to_edge = bool_or(p, "edgeToEdge", spec.edge_to_edge);
spec.ticks = count_param(p, "ticks", 100).unwrap_or(spec.ticks);
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, sel| {
regenerate(d, id, spec)?;
if !reselect.is_empty() {
reselect_series(d, sel, id, &reselect);
}
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 }))
}