diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 8d72ce335..c7a7c39b5 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -27,7 +27,10 @@ use super::draw2::replace_with_pieces; use super::edit::{outermost, selected_roots}; use super::paint::paint_from; use super::pathops::{Beside, shape_node}; -use super::*; +use super::{ + CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, ids_param, + number_or, point_param, str_param, switch_or, +}; mod live_paint; mod shape_builder; diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index f9b224ce8..207182d40 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, Paint, PathData, Point, + Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, id_param, ids_json, + is_live_paint, json, number_or, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or, +}; /// `options` as stored on a Live Paint group: none for the defaults. fn stored_gap_options(options: GapOptions) -> Option> { diff --git a/crates/engine/src/cmd/buildcmds/shape_builder.rs b/crates/engine/src/cmd/buildcmds/shape_builder.rs index 07d4949a0..068146a2a 100644 --- a/crates/engine/src/cmd/buildcmds/shape_builder.rs +++ b/crates/engine/src/cmd/buildcmds/shape_builder.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, SubPath, Touched, Value, b, bad, check_numbers, + f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_roots, replace_with_pieces, root_ids, + shape_node, switch_or, +}; pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result { const C: &str = "shapeBuilder.merge"; diff --git a/crates/engine/src/cmd/buildcmds/trace.rs b/crates/engine/src/cmd/buildcmds/trace.rs index 2eb4bcfcc..c1e66d27c 100644 --- a/crates/engine/src/cmd/buildcmds/trace.rs +++ b/crates/engine/src/cmd/buildcmds/trace.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Appearance, AppearanceItem, Arc, Beside, Color, Document, EngineError, LineCap, LineJoin, LiveShape, Node, NodeId, NodeKind, Paint, Point, + Result, Session, StrokeLayer, TRACE_NAME, TraceView, Value, bad, fill_only, ids_json, json, root_ids, shape_node, str_param, switch_or, tr, +}; // ---------- Image Trace ---------- diff --git a/crates/engine/src/cmd/distortcmds.rs b/crates/engine/src/cmd/distortcmds.rs index 674d44362..9b008500a 100644 --- a/crates/engine/src/cmd/distortcmds.rs +++ b/crates/engine/src/cmd/distortcmds.rs @@ -14,7 +14,10 @@ use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Pl use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with}; use super::edit::{duplicate_in, outermost, selected_roots}; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param, + ids_param, matrix_param, number_or, ok, point_indices, point_param, str_param, switch_or, targets, +}; use crate::EngineError; mod perspective; diff --git a/crates/engine/src/cmd/distortcmds/perspective.rs b/crates/engine/src/cmd/distortcmds/perspective.rs index a655e3e3f..848177027 100644 --- a/crates/engine/src/cmd/distortcmds/perspective.rs +++ b/crates/engine/src/cmd/distortcmds/perspective.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Affine, Arc, Document, EngineError, Homography, NodeId, NodeKind, PerspectiveGrid, Plane, Point, Result, Session, Value, bad, check_numbers, + check_switches, collect_points, duplicate_in, finite_numbers, id_param, ids_param, json, matrix_param, number_or, ok, outermost, persp, + point_param, project_node, selected_roots, str_param, switch_or, +}; // ---------- perspective grid ---------- diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 5b884b3ee..dba46b480 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -17,7 +17,10 @@ use vectorcraft_tools::draw2::{eraser_area, eraser_tip}; use super::create::{add_look, insert_look}; use super::newart::NewArt; use super::pathops::{Beside, remove_each}; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, check_numbers, cmd, f64_or, has_anchors, has_doc, has_selection, id_param, index, number_or, ok, str_param, + switch_or, +}; use crate::EngineError; mod anchors; diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index 540627fe0..25a403cfb 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -1,5 +1,8 @@ use super::common::{MAX_INPUT, path_kind, path_mut, point_entry, usize_opt, usize_req, within_input_bound}; -use super::*; +use super::{ + Anchor, BTreeSet, Beside, Color, Document, EngineError, Node, NodeId, NodeKind, Paint, PathData, Point, Result, Selection, Session, Value, Vec2, + add_look, bad, check_numbers, f64_or, id_param, json, number_or, ok, po, str_param, switch_or, +}; // ---------- Curvature ---------- diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 84337aa27..95959f35f 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -2,7 +2,7 @@ use super::anchors::{convert_anchor, curvature, curvature_edit, remove_anchor, r use super::cut::{erase_region, erase_segments, knife, split}; use super::freehand::{blob, freehand}; use super::smooth::{join_scrub, smooth_region}; -use super::*; +use super::{CommandSpec, cmd, has_anchors, has_doc, has_selection}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/draw2/common.rs b/crates/engine/src/cmd/draw2/common.rs index db5d7e372..2812b2fdc 100644 --- a/crates/engine/src/cmd/draw2/common.rs +++ b/crates/engine/src/cmd/draw2/common.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Appearance, Arc, Beside, Cap, Color, Document, EngineError, FillRule, Join, Node, NodeId, NodeKind, Paint, PathData, Point, PressureProfile, + Rect, Result, Selection, Session, SubPath, Value, Vec2, bad, index, json, po, +}; // ---------- helpers ---------- diff --git a/crates/engine/src/cmd/draw2/cut.rs b/crates/engine/src/cmd/draw2/cut.rs index 69306ce8f..4747ef783 100644 --- a/crates/engine/src/cmd/draw2/cut.rs +++ b/crates/engine/src/cmd/draw2/cut.rs @@ -1,7 +1,10 @@ use super::common::{ dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, replace_with_pieces, sibling_with, target_paths, usize_opt, }; -use super::*; +use super::{ + Anchor, AnchorKind, Beside, BezPath, BoolOp, Cap, EngineError, FillRule, Join, NodeId, ParamCurve, PathData, Point, Result, Session, SubPath, + Value, bad, eraser_area, eraser_tip, f64_or, fill_contains, id_param, json, number_or, po, +}; /// Split a filled path into connected pieces (each outer contour with its holes). pub(in super::super) fn components(pd: &PathData) -> Vec { diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index e6df1a60b..aeda2106d 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -2,7 +2,10 @@ use super::common::{ brush_region, path_kind, path_mut, path_of, points_param, poly_bounds, samples_param, stroke_paint, stroke_pressure, subpath_len, target_paths, within_input_bound, }; -use super::*; +use super::{ + Anchor, Appearance, BTreeSet, BoolOp, Color, EngineError, FillRule, NewArt, NodeId, NodeKind, Paint, PathData, Point, PressureProfile, Result, + Selection, Session, SimplifyOptions, SubPath, Value, bad, id_param, insert_look, json, number_or, po, remove_each, str_param, switch_or, +}; // ---------- Pencil / Paintbrush ---------- diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index e20665447..fd783aa4f 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -1,5 +1,8 @@ use super::common::{dist_to_polyline, ids_json, path_mut, path_of, points_param, poly_bounds, target_paths}; -use super::*; +use super::{ + Anchor, BezPath, CubicBez, NodeId, ParamCurve, ParamCurveNearest, PathData, Point, Result, Session, SimplifyOptions, SubPath, Value, json, + number_or, po, +}; // ---------- Smooth ---------- diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index b9b0c8bf2..c7ddd8bc5 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -38,7 +38,7 @@ use vectorcraft_render::effects; use super::edit::roots_of; use super::menucmds::{MAX_PIXELS, isolated_doc, unique_key}; use super::pathops::{node_path, outline_strokes, outline_strokes_under, separate, shape_node}; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, str_param, targets, unique_name}; mod analysis; mod apply; diff --git a/crates/engine/src/cmd/flatten/analysis.rs b/crates/engine/src/cmd/flatten/analysis.rs index c90ff8aa5..cad3b9384 100644 --- a/crates/engine/src/cmd/flatten/analysis.rs +++ b/crates/engine/src/cmd/flatten/analysis.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeSet, Document, Effect, ImageBlob, Knockout, Node, NodeKind, Rect, effects, outline_strokes}; // ---------- plain art ---------- diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index 04923b6d4..884351dea 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -1,7 +1,11 @@ use super::analysis::{Scratch, outlined_text, overlapping, reach}; use super::compose::{Builder, CLEAR, Composer, Elem, Fill, Kind, WHITE}; use super::options::FlattenOptions; -use super::*; +use super::{ + Affine, Appearance, AppearanceItem, Arc, BlendMode, Color, ColorMode, Document, EngineError, FillLayer, FillRule, ImageBlob, ImageObject, Inks, + MAX_PIXELS, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, effects, isolated_doc, outline_strokes_under, planes_cmyk, po, separate, + shape_node, shapes, unique_key, +}; // ---------- plan ---------- diff --git a/crates/engine/src/cmd/flatten/commands.rs b/crates/engine/src/cmd/flatten/commands.rs index d252c2ff4..a2edde1f0 100644 --- a/crates/engine/src/cmd/flatten/commands.rs +++ b/crates/engine/src/cmd/flatten/commands.rs @@ -1,7 +1,7 @@ use super::options::flatten; use super::presets::{presets_delete, presets_export, presets_import, presets_list, presets_save}; use super::report::preview; -use super::*; +use super::{CommandSpec, always, cmd, has_doc}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/flatten/compose.rs b/crates/engine/src/cmd/flatten/compose.rs index ac8fcf41c..059890534 100644 --- a/crates/engine/src/cmd/flatten/compose.rs +++ b/crates/engine/src/cmd/flatten/compose.rs @@ -1,5 +1,5 @@ use super::analysis::{has_effects, masked, reach}; -use super::*; +use super::{BlendMode, Color, FillRule, Knockout, Node, NodeKind, Paint, PathData, Rect, cmyk_planes, composite, effects, node_path, po, shapes}; // ---------- the compositor ---------- diff --git a/crates/engine/src/cmd/flatten/options.rs b/crates/engine/src/cmd/flatten/options.rs index 242f9f259..57331660a 100644 --- a/crates/engine/src/cmd/flatten/options.rs +++ b/crates/engine/src/cmd/flatten/options.rs @@ -2,7 +2,7 @@ use super::analysis::draws_image; use super::apply::{apply, kept_work, plan}; use super::presets::{FlattenerPreset, saved_preset}; use super::report::target_roots; -use super::*; +use super::{BTreeSet, Deserialize, EngineError, Node, Result, Serialize, Session, Value, bad, json, str_param}; /// Object → Flatten Transparency settings: a preset's, adjusted by the command's params. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/engine/src/cmd/flatten/presets.rs b/crates/engine/src/cmd/flatten/presets.rs index 6e92f4ac5..28b5dfd10 100644 --- a/crates/engine/src/cmd/flatten/presets.rs +++ b/crates/engine/src/cmd/flatten/presets.rs @@ -1,5 +1,5 @@ use super::options::FlattenOptions; -use super::*; +use super::{Deserialize, Result, Serialize, Session, Value, bad, bool_or, json, str_param, unique_name}; // ---------- presets ---------- diff --git a/crates/engine/src/cmd/flatten/report.rs b/crates/engine/src/cmd/flatten/report.rs index 125281d5c..3cac1ffe8 100644 --- a/crates/engine/src/cmd/flatten/report.rs +++ b/crates/engine/src/cmd/flatten/report.rs @@ -1,6 +1,9 @@ use super::apply::{kept_work, opaque, plan, stroked}; use super::options::FlattenOptions; -use super::*; +use super::{ + Appearance, Color, Deserialize, Document, Node, NodeId, NodeKind, Paint, PathData, Rect, Result, Serialize, Session, Value, bad, isolated_doc, + json, roots_of, shape_node, str_param, targets, +}; /// The top-level objects among `ids` (or the selection), in paint order: layers stand for their /// contents; hidden and locked objects and clipping paths are left alone. diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index acefd6102..ac04dc94c 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -10,7 +10,7 @@ use vectorcraft_geom::{Affine, Rect}; use super::appearance::{edit_items, edits_stroke, index_param, item_target}; use super::gradient::{StopOwner, item_paint_bounds, run_paint_mut}; -use super::*; +use super::{CommandSpec, Result, Session, bad, cmd, color_value, has_doc, index, number_or, point_indices, point_param, str_param, switch_or}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 4ae431bd7..b0d3f92ef 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -17,7 +17,7 @@ use vectorcraft_doc::{ use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes}; use super::typecmd::refresh_bounds; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, f64_req, has_doc, has_selection, id_param, ok, str_param}; mod axis; mod columns; diff --git a/crates/engine/src/cmd/graph/axis.rs b/crates/engine/src/cmd/graph/axis.rs index aa9198163..52e49dbee 100644 --- a/crates/engine/src/cmd/graph/axis.rs +++ b/crates/engine/src/cmd/graph/axis.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Value; pub(super) const LABEL_SIZE: f64 = 9.0; diff --git a/crates/engine/src/cmd/graph/columns.rs b/crates/engine/src/cmd/graph/columns.rs index 19a0e71e7..34addd90b 100644 --- a/crates/engine/src/cmd/graph/columns.rs +++ b/crates/engine/src/cmd/graph/columns.rs @@ -4,7 +4,10 @@ use super::edit::{regenerate, spec_of, target}; use super::generate::generate_with; use super::markers::{design_frame, fit_affine}; use super::series::{picked_series, reselect_series}; -use super::*; +use super::{ + Affine, Arc, ColumnDesign, ColumnType, Document, FillRule, Fractions, GraphSpec, Node, NodeKind, Point, Rect, Result, Session, TextObject, Value, + bad, json, refresh_bounds, shapes, +}; /// Most objects a graph's column designs may add (each column, repeated copy and legend swatch draws a copy). const COLUMN_BUDGET: usize = 200_000; diff --git a/crates/engine/src/cmd/graph/commands.rs b/crates/engine/src/cmd/graph/commands.rs index fc799b9a3..5df961ee3 100644 --- a/crates/engine/src/cmd/graph/commands.rs +++ b/crates/engine/src/cmd/graph/commands.rs @@ -3,7 +3,7 @@ use super::data::set_data; use super::designs::design; use super::edit::{create, set_type}; use super::markers::set_marker; -use super::*; +use super::{CommandSpec, cmd, has_doc, has_selection}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/graph/data.rs b/crates/engine/src/cmd/graph/data.rs index f991a7d93..e81f24feb 100644 --- a/crates/engine/src/cmd/graph/data.rs +++ b/crates/engine/src/cmd/graph/data.rs @@ -1,6 +1,6 @@ use super::edit::{regenerate, spec_of, target}; use super::series::{switch_xy, transpose}; -use super::*; +use super::{Document, EngineError, GraphKind, GraphSpec, NodeId, Result, Session, Value, bad, bool_or, json, str_param}; // ---------- data ---------- diff --git a/crates/engine/src/cmd/graph/designs.rs b/crates/engine/src/cmd/graph/designs.rs index 716810b07..1009bcceb 100644 --- a/crates/engine/src/cmd/graph/designs.rs +++ b/crates/engine/src/cmd/graph/designs.rs @@ -1,5 +1,5 @@ use super::edit::regenerate; -use super::*; +use super::{Arc, Document, GraphDesign, GraphSpec, Result, Session, Value, bad, json, ok, str_param}; /// The document's graph design names, in order. pub(super) fn design_names(d: &Document) -> Vec { diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index d6204c687..525f43721 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -2,7 +2,10 @@ 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::*; +use super::{ + Affine, Document, EngineError, GraphKind, GraphSpec, Node, NodeId, Rect, Result, Session, TickLength, Value, ValueAxisSide, bad, bool_or, f64_or, + f64_req, id_param, json, 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). @@ -74,6 +77,60 @@ pub(super) fn spec_of(s: &Session, id: NodeId) -> Result { 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 = 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 = 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 = (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 { const C: &str = "graph.setType"; let id = target(s, p, C)?; @@ -127,57 +184,7 @@ pub(super) fn set_type(s: &mut Session, p: &Value) -> Result { "pieSort", ]; if !keys.iter().any(|k| p.get(*k).is_some()) { - // With series picked, the type and value axis they share, so the dialog's OK keeps them. - let kinds: Vec = 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 = 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 = (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())); - } - return Ok(fields); + 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 = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] }; diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index 336b0857d..8efdc2df9 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -6,7 +6,9 @@ use super::labels::{ }; use super::markers::{MARKER_BUDGET, marker_cost, marker_designs}; use super::series::series_marks; -use super::*; +use super::{ + Arc, BezPath, Color, Document, GraphKind, GraphSpec, Justify, Node, NodeKind, Paint, PathData, Point, Rect, TickLength, ValueAxisSide, shapes, +}; /// Build the graph's children: with each series' column design (Object › Graph › Column) when the document has it, /// and with plain columns for all when the designs would draw more than COLUMN_BUDGET objects. @@ -20,584 +22,892 @@ pub(super) fn generate(d: &mut Document, g: &GraphSpec) -> Vec> { /// [`generate`] with the column designs `column_looks`; `None` when they would draw more than COLUMN_BUDGET objects. pub(super) fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option]) -> Option>> { - let mark = |i: usize, series_fill: bool, stroke: Paint, width: f64| series_marks(g, i, series_fill, stroke, width); - // Each series' marker design (Object › Graph › Marker), when the document has it; default squares for all - // when the designs would draw more than MARKER_BUDGET objects. - let mut designs = marker_designs(d, g); - if marker_cost(g, &designs) > MARKER_BUDGET { - designs.clear(); - } - let design = |s: usize| designs.get(s).and_then(Option::as_ref); - let column_look = |s: usize| column_looks.get(s).and_then(Option::as_ref); - let mut b = Gen { d, out: vec![], styles: &g.label_styles, columns: Allowance::new() }; - // The labels' sizes (9 pt unless formatted), which their spacing follows. - let (vs, cs, ls) = - (label_size(&g.label_styles, Role::Value), label_size(&g.label_styles, Role::Category), label_size(&g.label_styles, Role::Legend)); - 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]; - // A blank cell (no value) is `None`: columns, bars and stacked segments leave it out, lines and scatter points - // break around it and the value axis ignores it. Area, pie and radar draw it as 0, which is what it adds to them. - let cells = g.cells(); - let cell = |c: usize, s: usize| cells.get(c).and_then(|r| r.get(s)).copied().flatten(); - let val = |c: usize, s: usize| cell(c, s).unwrap_or(0.0); - // Each series' own type (Combine different graph types); one type for the whole graph unless a series has its own. - let kinds: Vec = (0..nser).map(|s| g.series_kind(s)).collect(); - let kind = |s: usize| kinds.get(s).copied().unwrap_or(g.kind); - // Series drawn as columns or bars, which column designs apply to. - let columnar = |s: usize| matches!(kind(s), GraphKind::Column | GraphKind::StackedColumn | GraphKind::Bar | GraphKind::StackedBar); - let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar); - // Where the art right of the plot ends (the right value axis' labels), so the legend starts after it. - let mut right_edge = r.x1; - - // Labels drawn over the series: a pie's Legends in Wedges (in the Legend group) and stacked pies' names. - let mut wedge_labels = vec![]; - let mut on_top = vec![]; - // Draw Filled Lines, up to FILLED_LINE_BUDGET data points (the outlines are rebuilt on every change). - let filled = g.filled_lines && g.rows.len().saturating_mul(nser) <= FILLED_LINE_BUDGET; - // Where columns and bars reach (past the plot when their widths are over 100%), so the legend keeps clear. - let mut bars_reach: Option = None; - // Add Drop Shadow: the columns, bars, lines and wedges to cast one. - let mut shadowed: Vec<(usize, Arc)> = vec![]; + let mut p = Plot::new(d, g, column_looks); match g.kind { - GraphKind::Pie => { - // One pie per row, a wedge per series, clockwise from 12 o'clock. Position: side by side at one size - // (Even) or sized by their totals (Ratio, by area), or stacked on one centre, the largest at the back. - let totals: Vec = (0..ncat).map(|c| (0..nser).map(|s| val(c, s).max(0.0)).sum()).collect(); - let biggest = totals.iter().copied().fold(0.0, f64::max); - let stacked = g.pie_position == vectorcraft_doc::PiePosition::Stacked; - let w = if stacked { r.width() } else { r.width() / ncat as f64 }; - let full = (w.min(r.height()) / 2.0 * 0.9).max(1.0); - let radius = |total: f64| match g.pie_position { - vectorcraft_doc::PiePosition::Even => full, - _ if biggest > 0.0 && biggest.is_finite() => (full * (total / biggest).sqrt()).max(1.0), - _ => full, - }; - // Sort: data order, largest first in each pie, or the first pie's largest-first order everywhere. - let order = |c: usize| { - let mut o: Vec = (0..nser).collect(); - let by = match g.pie_sort { - vectorcraft_doc::PieSort::None => return o, - vectorcraft_doc::PieSort::All => c, - vectorcraft_doc::PieSort::First => 0, - }; - o.sort_by(|a, b| val(by, *b).max(0.0).total_cmp(&val(by, *a).max(0.0))); - o - }; - let mut pies: Vec = (0..ncat).collect(); - if stacked { - pies.sort_by(|a, b| totals.get(*b).copied().unwrap_or(0.0).total_cmp(&totals.get(*a).copied().unwrap_or(0.0))); + GraphKind::Pie => p.pie(), + GraphKind::Radar => p.radar(), + _ => p.cartesian(), + } + let labels = p.legend(); + p.finish(labels) +} + +/// The `s`th entry of a per-series list of optional designs. +fn nth(list: &[Option], s: usize) -> Option<&T> { + list.get(s).and_then(Option::as_ref) +} + +/// One graph being generated: its data, the nodes drawn so far and what the legend and shadows still need. +struct Plot<'a> { + g: &'a GraphSpec, + b: Gen<'a>, + column_looks: &'a [Option], + /// Each series' marker design (Object › Graph › Marker), when the document has it; default squares for all + /// when the designs would draw more than MARKER_BUDGET objects. + designs: Vec>>, + r: Rect, + nser: usize, + ncat: usize, + /// The labels' sizes (9 pt unless formatted), which their spacing follows. + vs: f64, + cs: f64, + ls: f64, + /// A blank cell (no value) is `None`: columns, bars and stacked segments leave it out, lines and scatter points + /// break around it and the value axis ignores it. Area, pie and radar draw it as 0, which is what it adds to them. + cells: Vec>>, + /// Each series' own type (Combine different graph types); one type for the whole graph unless a series has its own. + kinds: Vec, + horizontal: bool, + /// Where the art right of the plot ends (the right value axis' labels), so the legend starts after it. + right_edge: f64, + /// Labels drawn over the series: a pie's Legends in Wedges (in the Legend group) and stacked pies' names. + wedge_labels: Vec>, + on_top: Vec>, + /// Draw Filled Lines, up to FILLED_LINE_BUDGET data points (the outlines are rebuilt on every change). + filled: bool, + /// Where columns and bars reach (past the plot when their widths are over 100%), so the legend keeps clear. + bars_reach: Option, + /// Add Drop Shadow: the columns, bars, lines and wedges to cast one. + shadowed: Vec<(usize, Arc)>, + series: Vec>>, +} + +/// A pie (or the ring of a stacked pie) to draw. +#[derive(Clone, Copy)] +struct Ring { + centre: Point, + rad: f64, + hole: f64, +} + +/// The geometry of a plot with axes: the value scales and where the categories sit. +struct Geo { + r: Rect, + horizontal: bool, + ncat: usize, + /// Which series use the right axis' own scale. + on_right: Vec, + left: (f64, f64, f64), + right: (f64, f64, f64), + cat_w: f64, + label_edges: bool, + left_side: bool, + right_side: bool, +} + +impl Geo { + fn axis(&self, s: usize) -> usize { + usize::from(self.on_right.get(s).copied().unwrap_or(false)) + } + + fn scale(&self, a: usize) -> (f64, f64, f64) { + if a == 1 { self.right } else { self.left } + } + + /// Value → coordinate along value axis `a` (y for columns/lines, x for bars). + fn vpos(&self, a: usize, v: f64) -> f64 { + let (lo, hi, _) = self.scale(a); + let t = (v - lo) / (hi - lo); + if self.horizontal { self.r.x0 + t * self.r.width() } else { self.r.y1 - t * self.r.height() } + } + + /// That axis' zero (or the end nearest it). + fn zero(&self, a: usize) -> f64 { + let (lo, hi, _) = self.scale(a); + self.vpos(a, 0.0f64.clamp(lo, hi)) + } + + /// Plot points per unit of value along value axis `a`. + fn per_unit(&self, a: usize) -> f64 { + let (lo, hi, _) = self.scale(a); + (if self.horizontal { self.r.width() } else { self.r.height() }) / (hi - lo) + } + + fn cat_start(&self, c: usize) -> f64 { + if self.horizontal { self.r.y0 + c as f64 * self.cat_w } else { self.r.x0 + c as f64 * self.cat_w } + } + + fn mid(&self, c: usize, edges: bool) -> f64 { + let r = self.r; + if edges && self.ncat > 1 { r.x0 + r.width() * c as f64 / (self.ncat - 1) as f64 } else { self.cat_start(c) + self.cat_w / 2.0 } + } + + fn cat_mid(&self, c: usize) -> f64 { + self.mid(c, self.label_edges) + } + + /// The ends of a value tick mark at coordinate `q` of axis `a`. + fn tick_ends(&self, len: TickLength, a: usize, q: f64) -> Option<(Point, Point)> { + let r = self.r; + match (len, self.horizontal, a == 1) { + (TickLength::None, ..) => None, + (TickLength::Short, true, _) => Some((Point::new(q, r.y1), Point::new(q, r.y1 + 4.0))), + (TickLength::Short, false, false) => Some((Point::new(r.x0 - 4.0, q), Point::new(r.x0, q))), + (TickLength::Short, false, true) => Some((Point::new(r.x1, q), Point::new(r.x1 + 4.0, q))), + (TickLength::Full, true, _) => Some((Point::new(q, r.y0), Point::new(q, r.y1))), + (TickLength::Full, false, _) => Some((Point::new(r.x0, q), Point::new(r.x1, q))), + } + } +} + +/// How columns (or bars) and stacks share a category's width. +struct Cluster { + /// Columns of one category side by side; stacked columns take one slot of that cluster, or, with no plain + /// columns, a width of their own. + columns: Vec, + stacks: Vec, + /// One stack per value axis that has stacked series, each in a slot of its own. + stack_axes: Vec, + own_width: bool, + /// The widths past 100% make the columns (or clusters) overlap. + col_frac: f64, + clus_frac: f64, + cluster: f64, + slot: f64, + bar: f64, +} + +impl Cluster { + /// Where a stack on axis `ax` starts across the category `c` (starting at `c0`), and how wide it is. + fn place(&self, geo: &Geo, c: usize, c0: f64, ax: usize) -> (f64, f64) { + if self.own_width { + // Up to the category's width, or past it when the widths are set past 100%. + let cat_w = geo.cat_w; + let w = (cat_w * self.col_frac * self.clus_frac * 1.2).min(cat_w * (self.col_frac * self.clus_frac).max(1.0)); + (geo.cat_start(c) + (cat_w - w) / 2.0, w) + } else { + let i = self.columns.len() + self.stack_axes.iter().position(|a| *a == ax).unwrap_or(0); + (c0 + self.slot * i as f64 + (self.slot - self.bar) / 2.0, self.bar) + } + } +} + +/// A column, bar or stacked segment to draw. +struct Bar { + s: usize, + rect: Rect, + value: f64, + /// It grows towards the axis' start. + down: bool, + per_unit: f64, +} + +fn bar_rect(horizontal: bool, a: f64, w: f64, v0: f64, v1: f64) -> Rect { + if horizontal { Rect::new(v0.min(v1), a, v0.max(v1), a + w) } else { Rect::new(a, v0.min(v1), a + w, v0.max(v1)) } +} + +/// White text on a dark wedge. +fn is_dark(fill: &Paint) -> bool { + fill.color().is_some_and(|c| { + let [r, g, b] = c.to_rgb(); + 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5 + }) +} + +fn dir(a: f64) -> vectorcraft_geom::Vec2 { + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) +} + +/// A wedge of `ring` from angle `a0` over `sweep`; a ring segment when the ring has a hole. +fn wedge_path(ring: Ring, a0: f64, sweep: f64) -> BezPath { + let Ring { centre, rad, hole } = ring; + let mut bp = BezPath::new(); + let arc = vectorcraft_geom::kurbo::Arc::new(centre, (rad, rad), a0, sweep, 0.0); + if hole > 0.0 { + // A ring segment: out along the outer arc, back along the inner one. + bp.move_to(centre + dir(a0) * rad); + arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); + bp.line_to(centre + dir(a0 + sweep) * hole); + let back = vectorcraft_geom::kurbo::Arc::new(centre, (hole, hole), a0 + sweep, -sweep, 0.0); + back.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); + } else { + bp.move_to(centre); + bp.line_to(centre + dir(a0) * rad); + arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); + } + bp.close_path(); + bp +} + +/// The radius of the pie with `total`: one size (Even) or sized by the totals (Ratio, by area). +fn pie_radius(g: &GraphSpec, full: f64, biggest: f64, total: f64) -> f64 { + match g.pie_position { + vectorcraft_doc::PiePosition::Even => full, + _ if biggest > 0.0 && biggest.is_finite() => (full * (total / biggest).sqrt()).max(1.0), + _ => full, + } +} + +impl<'a> Plot<'a> { + fn new(d: &'a mut Document, g: &'a GraphSpec, column_looks: &'a [Option]) -> Self { + let mut designs = marker_designs(d, g); + if marker_cost(g, &designs) > MARKER_BUDGET { + designs.clear(); + } + let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).max(1); + let kinds: Vec = (0..nser).map(|s| g.series_kind(s)).collect(); + Plot { + g, + b: Gen { d, out: vec![], styles: &g.label_styles, columns: Allowance::new() }, + column_looks, + designs, + r: g.rect, + nser, + ncat: g.rows.len().max(1), + vs: label_size(&g.label_styles, Role::Value), + cs: label_size(&g.label_styles, Role::Category), + ls: label_size(&g.label_styles, Role::Legend), + cells: g.cells(), + kinds, + horizontal: matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar), + right_edge: g.rect.x1, + wedge_labels: vec![], + on_top: vec![], + filled: g.filled_lines && g.rows.len().saturating_mul(nser) <= FILLED_LINE_BUDGET, + bars_reach: None, + shadowed: vec![], + series: vec![vec![]; nser], + } + } + + fn cell(&self, c: usize, s: usize) -> Option { + self.cells.get(c).and_then(|r| r.get(s)).copied().flatten() + } + + fn val(&self, c: usize, s: usize) -> f64 { + self.cell(c, s).unwrap_or(0.0) + } + + fn kind(&self, s: usize) -> GraphKind { + self.kinds.get(s).copied().unwrap_or(self.g.kind) + } + + /// Series drawn as columns or bars, which column designs apply to. + fn columnar(&self, s: usize) -> bool { + matches!(self.kind(s), GraphKind::Column | GraphKind::StackedColumn | GraphKind::Bar | GraphKind::StackedBar) + } + + /// The series drawn as `kind`. + fn series_of(&self, kind: GraphKind) -> Vec { + (0..self.nser).filter(|s| self.kind(*s) == kind).collect() + } + + fn mark(&self, i: usize, series_fill: bool, stroke: Paint, width: f64) -> (Paint, Paint, f64) { + series_marks(self.g, i, series_fill, stroke, width) + } + + fn series_label(&self, i: usize) -> String { + self.g.series.get(i).cloned().unwrap_or_default() + } + + fn push_series(&mut self, s: usize, n: Arc) { + if let Some(items) = self.series.get_mut(s) { + items.push(n); + } + } + + /// Add `n` to series `s` and, for the drop shadow, to the shadowed marks. + fn push_shadowed(&mut self, s: usize, n: Arc) { + self.shadowed.push((s, n.clone())); + self.push_series(s, n); + } + + fn reach(&mut self, rect: Rect) { + self.bars_reach = Some(self.bars_reach.map_or(rect, |r| r.union(rect))); + } + + // ---- Pie ---- + + /// One pie per row, a wedge per series, clockwise from 12 o'clock. Position: side by side at one size + /// (Even) or sized by their totals (Ratio, by area), or stacked on one centre, the largest at the back. + fn pie(&mut self) { + let (g, r, ncat, nser) = (self.g, self.r, self.ncat, self.nser); + let totals: Vec = (0..ncat).map(|c| (0..nser).map(|s| self.val(c, s).max(0.0)).sum()).collect(); + let biggest = totals.iter().copied().fold(0.0, f64::max); + let stacked = g.pie_position == vectorcraft_doc::PiePosition::Stacked; + let w = if stacked { r.width() } else { r.width() / ncat as f64 }; + let full = (w.min(r.height()) / 2.0 * 0.9).max(1.0); + let radius = |total: f64| pie_radius(g, full, biggest, total); + let mut pies: Vec = (0..ncat).collect(); + if stacked { + pies.sort_by(|a, b| totals.get(*b).copied().unwrap_or(0.0).total_cmp(&totals.get(*a).copied().unwrap_or(0.0))); + } + // Stacked pies share a centre: each one but the smallest is drawn as the ring outside the next smaller + // one, so a pie never covers another (a pie as big as the next one has no ring and is left out). + let inner: Vec = if stacked { + (0..pies.len()).map(|k| pies.get(k + 1).map_or(0.0, |c| radius(totals.get(*c).copied().unwrap_or(0.0)))).collect() + } else { + vec![0.0; pies.len()] + }; + for (k, c) in pies.into_iter().enumerate() { + let total = totals.get(c).copied().unwrap_or(0.0); + let rad = radius(total); + let hole = inner.get(k).copied().unwrap_or(0.0); + if hole >= rad - 1e-9 { + continue; } - // Stacked pies share a centre: each one but the smallest is drawn as the ring outside the next smaller - // one, so a pie never covers another (a pie as big as the next one has no ring and is left out). - let inner: Vec = if stacked { - (0..pies.len()).map(|k| pies.get(k + 1).map_or(0.0, |c| radius(totals.get(*c).copied().unwrap_or(0.0)))).collect() - } else { - vec![0.0; pies.len()] - }; - for (k, c) in pies.into_iter().enumerate() { - let total = totals.get(c).copied().unwrap_or(0.0); - let rad = radius(total); - let hole = inner.get(k).copied().unwrap_or(0.0); - if hole >= rad - 1e-9 { - continue; - } - let centre = if stacked { r.center() } else { Point::new(r.x0 + w * (c as f64 + 0.5), r.y0 + r.height() / 2.0) }; - let mut a0 = -std::f64::consts::FRAC_PI_2; - for s in order(c) { - 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(); - let dir = |a: f64| vectorcraft_geom::Vec2::new(a.cos(), a.sin()); - let arc = vectorcraft_geom::kurbo::Arc::new(centre, (rad, rad), a0, sweep, 0.0); - if hole > 0.0 { - // A ring segment: out along the outer arc, back along the inner one. - bp.move_to(centre + dir(a0) * rad); - arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); - bp.line_to(centre + dir(a0 + sweep) * hole); - let back = vectorcraft_geom::kurbo::Arc::new(centre, (hole, hole), a0 + sweep, -sweep, 0.0); - back.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); - } else { - bp.move_to(centre); - bp.line_to(centre + dir(a0) * rad); - arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p)); - } - bp.close_path(); - let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.5); - // White text on a dark wedge. - let dark = fill.color().is_some_and(|c| { - let [r, g, b] = c.to_rgb(); - 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5 - }); - let n = b.path(PathData::from_bezpath(&bp), fill, stroke, sw); - shadowed.push((s, n.clone())); - if let Some(items) = series.get_mut(s) { - items.push(n); - } - // Legends in Wedges: the series label inside its wedge, centred two thirds of the way out. - if g.legend && g.pie_legend_in_wedges { - let label = series_label(s); - if !blank_label(&label) { - // Two thirds of the way out (of the ring, for a stacked pie). - let mid = a0 + sweep / 2.0; - let at = centre + dir(mid) * (hole + (rad - hole) * 0.62); - let t = b.label(Role::Legend, Point::new(at.x, at.y + ls * 0.35), &label, Justify::Center, true); - // White on a dark wedge, unless the legend labels were given a colour of their own. - let own_colour = g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK)); - wedge_labels.push(if dark && !own_colour { recolour_text(&t, Color::WHITE) } else { t }); - } - } - a0 += sweep; - } - if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) { - if stacked { - // Just above its own outline, all its lines, and drawn over the pies. - let lines = cat.split('|').count() as f64; - let at = Point::new(centre.x, centre.y - rad - cs * 0.4 - (lines - 1.0) * cs * 1.2); - let t = b.label(Role::Category, at, cat, Justify::Center, false); - on_top.push(t); - } else { - let t = b.label(Role::Category, Point::new(centre.x, centre.y + rad + cs * 1.6), cat, Justify::Center, false); - b.out.push(t); - } - } + let centre = if stacked { r.center() } else { Point::new(r.x0 + w * (c as f64 + 0.5), r.y0 + r.height() / 2.0) }; + let ring = Ring { centre, rad, hole }; + self.pie_wedges(c, ring, total); + self.pie_name(c, ring, stacked); + } + } + + /// Sort: data order, largest first in each pie, or the first pie's largest-first order everywhere. + fn pie_order(&self, c: usize) -> Vec { + let mut o: Vec = (0..self.nser).collect(); + let by = match self.g.pie_sort { + vectorcraft_doc::PieSort::None => return o, + vectorcraft_doc::PieSort::All => c, + vectorcraft_doc::PieSort::First => 0, + }; + o.sort_by(|a, b| self.val(by, *b).max(0.0).total_cmp(&self.val(by, *a).max(0.0))); + o + } + + fn pie_wedges(&mut self, c: usize, ring: Ring, total: f64) { + let mut a0 = -std::f64::consts::FRAC_PI_2; + for s in self.pie_order(c) { + let v = self.val(c, s).max(0.0); + if total <= 0.0 || v <= 0.0 { + continue; + } + let sweep = v / total * std::f64::consts::TAU; + let bp = wedge_path(ring, a0, sweep); + let (fill, stroke, sw) = self.mark(s, true, Paint::solid(Color::WHITE), 0.5); + let dark = is_dark(&fill); + let n = self.b.path(PathData::from_bezpath(&bp), fill, stroke, sw); + self.push_shadowed(s, n); + // Legends in Wedges: the series label inside its wedge, centred two thirds of the way out. + if self.g.legend && self.g.pie_legend_in_wedges { + self.wedge_label(s, ring, a0 + sweep / 2.0, dark); + } + a0 += sweep; + } + } + + fn wedge_label(&mut self, s: usize, ring: Ring, mid: f64, dark: bool) { + let label = self.series_label(s); + if blank_label(&label) { + return; + } + // Two thirds of the way out (of the ring, for a stacked pie). + let at = ring.centre + dir(mid) * (ring.hole + (ring.rad - ring.hole) * 0.62); + let t = self.b.label(Role::Legend, Point::new(at.x, at.y + self.ls * 0.35), &label, Justify::Center, true); + // White on a dark wedge, unless the legend labels were given a colour of their own. + let own_colour = self.g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK)); + self.wedge_labels.push(if dark && !own_colour { recolour_text(&t, Color::WHITE) } else { t }); + } + + /// The pie's category name. + fn pie_name(&mut self, c: usize, ring: Ring, stacked: bool) { + let Some(cat) = self.g.categories.get(c).filter(|c| !c.is_empty()) else { return }; + let (centre, rad, cs) = (ring.centre, ring.rad, self.cs); + if stacked { + // Just above its own outline, all its lines, and drawn over the pies. + let lines = cat.split('|').count() as f64; + let at = Point::new(centre.x, centre.y - rad - cs * 0.4 - (lines - 1.0) * cs * 1.2); + let t = self.b.label(Role::Category, at, cat, Justify::Center, false); + self.on_top.push(t); + } else { + let t = self.b.label(Role::Category, Point::new(centre.x, centre.y + rad + cs * 1.6), cat, Justify::Center, false); + self.b.out.push(t); + } + } + + // ---- Radar ---- + + fn radar(&mut self) { + let (g, r, ncat, cs) = (self.g, self.r, self.ncat, self.cs); + let centre = r.center(); + let rad = (r.width().min(r.height()) / 2.0).max(1.0); + let (lo, hi, step) = value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max)); + 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(self.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() * (cs * 1.2); + axes.push(self.b.label(Role::Category, Point::new(p.x, p.y + cs * 0.35), cat, Justify::Center, true)); } } - GraphKind::Radar => { - let centre = r.center(); - let rad = (r.width().min(r.height()) / 2.0).max(1.0); - let (lo, hi, step) = value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max)); - 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() * (cs * 1.2); - axes.push(b.label(Role::Category, Point::new(p.x, p.y + cs * 0.35), cat, Justify::Center, true)); - } - } - for v in tick_values(lo, hi, step).into_iter().skip(1) { - 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)); - } - 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(); - if g.connect_points && pts.len() > 1 { - let n = b.data_line(g, s, polyline(&pts, true), filled); - shadowed.push((s, n.clone())); - items.push(n); - } - if g.mark_points { - for p in pts { - let paint = mark(s, true, Paint::None, 0.0); - items.push(b.marker(design(s), Rect::from_center_size(p, (4.0, 4.0)), paint)); - } - } - } + for v in tick_values(lo, hi, step).into_iter().skip(1) { + let ring: Vec = (0..ncat).map(|c| at(c, v)).collect(); + axes.push(self.b.path(polyline(&ring, true), Paint::None, Paint::solid(Color::rgb(0.6, 0.6, 0.6)), 0.25)); } - _ => { - let scatter = g.kind == GraphKind::Scatter; - // Separate Scales: with the value axis on both sides, the series assigned to the right axis get a scale - // of their own there (axis 1); every other series, and every series without separate scales, uses axis 0. - let separate = g.separate_scales && g.value_axis == ValueAxisSide::Both && !horizontal && !scatter; - let mut on_right = vec![false; nser]; - if separate { - for i in &g.right_series { - if let Some(x) = on_right.get_mut(*i) { - *x = true; - } - } + let ax = self.b.group("Axes", axes); + self.b.out.push(ax); + for s in 0..self.series.len() { + let pts: Vec = (0..ncat).map(|c| at(c, self.val(c, s))).collect(); + if g.connect_points && pts.len() > 1 { + let n = self.b.data_line(g, s, polyline(&pts, true), self.filled); + self.push_shadowed(s, n); } - let axis = |s: usize| usize::from(on_right.get(s).copied().unwrap_or(false)); - // Value range per axis (stacked graphs stack positives and negatives separately). - let (mut lo, mut hi) = ([0.0f64; 2], [0.0f64; 2]); - let (mut xlo, mut xhi) = (f64::MAX, f64::MIN); - for c in 0..ncat { - if !scatter { - for (a, (lo, hi)) in lo.iter_mut().zip(hi.iter_mut()).enumerate() { - // Stacked columns (or bars) stack together, and so do areas; other series count one value each. - for stack in [[GraphKind::StackedColumn, GraphKind::StackedBar], [GraphKind::Area, GraphKind::Area]] { - let members = || (0..nser).filter(|s| axis(*s) == a && stack.contains(&kind(*s))); - *hi = hi.max(members().map(|s| val(c, s).max(0.0)).sum()); - *lo = lo.min(members().map(|s| val(c, s).min(0.0)).sum()); - } - let single = |s: &usize| axis(*s) == a && matches!(kind(*s), GraphKind::Column | GraphKind::Bar | GraphKind::Line); - for v in (0..nser).filter(single).filter_map(|s| cell(c, s)) { - *hi = hi.max(v); - *lo = lo.min(v); - } - } - } else { - for s in (0..nser).step_by(2) { - if let (Some(y), Some(x)) = (cell(c, s), cell(c, s + 1)) { - hi[0] = hi[0].max(y); - lo[0] = lo[0].min(y); - xlo = xlo.min(x); - xhi = xhi.max(x); - } - } - } - } - // An axis with no series of its own shows the other one's scale. - let left = value_axis(g.ticks, g.axis_min, g.axis_max, lo[0], hi[0]); - let used = |a: usize| (0..nser).any(|s| axis(s) == a); - let (left, right) = if !separate || !used(1) { - (left, left) - } else { - let right = value_axis(g.right_ticks, g.right_axis_min, g.right_axis_max, lo[1], hi[1]); - if used(0) { (left, right) } else { (right, right) } - }; - let scale = |a: usize| if a == 1 { right } else { left }; - // Value → coordinate along value axis `a` (y for columns/lines, x for bars), and that axis' zero (or the - // end nearest it). - let vpos = |a: usize, v: f64| { - let (lo, hi, _) = scale(a); - let t = (v - lo) / (hi - lo); - if horizontal { r.x0 + t * r.width() } else { r.y1 - t * r.height() } - }; - let zero = |a: usize| { - let (lo, hi, _) = scale(a); - vpos(a, 0.0f64.clamp(lo, hi)) - }; - // Plot points per unit of value along value axis `a`. - let per_unit = |a: usize| { - let (lo, hi, _) = scale(a); - (if horizontal { r.width() } else { r.height() }) / (hi - lo) - }; - let mut axes = vec![]; - // Value axes with ticks and labels: along the bottom for bar graphs, else on the chosen sides (the right - // one shows the right scale). - let (on_left_side, on_right_side) = - if horizontal { (true, false) } else { (g.value_axis != ValueAxisSide::Right, g.value_axis != ValueAxisSide::Left) }; - // Add Labels text per axis, kept to one short line whatever the file holds. - let affix = |s: &str| one_line(s); - let affixes = [(affix(&g.prefix), affix(&g.suffix)), (affix(&g.right_prefix), affix(&g.right_suffix))]; - // Each label's tick mark, then the extra tick marks of its division (Tick Marks per division). - let value_ticks = |b: &mut Gen, axes: &mut Vec>, a: usize, v: f64, step: f64, hi: f64| { - let (len, marks) = if a == 1 { (g.right_tick_length, g.right_tick_marks) } else { (g.tick_length, g.tick_marks) }; - let n = marks.clamp(1, MAX_TICK_MARKS); - for k in 0..n { - let w = v + step * k as f64 / n as f64; - if k > 0 && w >= hi - step * 1e-9 { - break; - } - let q = vpos(a, w); - // A full-length tick on the zero line, or repeating the left side's on one shared scale, adds nothing. - let repeat = a == 1 && on_left_side && g.tick_length == TickLength::Full && right == left; - let len = if len == TickLength::Full && ((q - zero(a)).abs() < 1e-9 || repeat) { TickLength::None } else { len }; - let ends = match (len, horizontal, a == 1) { - (TickLength::None, ..) => None, - (TickLength::Short, true, _) => Some((Point::new(q, r.y1), Point::new(q, r.y1 + 4.0))), - (TickLength::Short, false, false) => Some((Point::new(r.x0 - 4.0, q), Point::new(r.x0, q))), - (TickLength::Short, false, true) => Some((Point::new(r.x1, q), Point::new(r.x1 + 4.0, q))), - (TickLength::Full, true, _) => Some((Point::new(q, r.y0), Point::new(q, r.y1))), - (TickLength::Full, false, _) => Some((Point::new(r.x0, q), Point::new(r.x1, q))), - }; - if let Some((p0, p1)) = ends { - axes.push(b.line(p0, p1)); - } - if k == 0 { - // The label goes right after its own tick mark. - let (prefix, suffix) = affixes.get(a).map_or(("", ""), |(p, s)| (p.as_str(), s.as_str())); - let label = format!("{prefix}{}{suffix}", fmt_value(v)); - let t = if horizontal { - b.text(Role::Value, Point::new(q, r.y1 + 4.0 + vs * 1.1), &label, Justify::Center) - } else if a == 1 { - b.text(Role::Value, Point::new(r.x1 + 6.0, q + vs * 0.35), &label, Justify::Left) - } else { - b.text(Role::Value, Point::new(r.x0 - 6.0, q + vs * 0.35), &label, Justify::Right) - }; - axes.push(t); - } - } - }; - if on_left_side { - let (lo, hi, step) = left; - for v in tick_values(lo, hi, step) { - value_ticks(&mut b, &mut axes, 0, v, step, hi); - } - } - if on_right_side { - let (lo, hi, step) = right; - for v in tick_values(lo, hi, step) { - value_ticks(&mut b, &mut axes, 1, v, step, hi); - } - } - // The legend starts after the value labels right of the plot: the right axis' (14 points on), or a bar - // graph's last bottom label running past the plot (6 points on; a short number stays within the gap). - for t in &axes { - if matches!(t.kind, NodeKind::Text(_)) - && let Some(bb) = t.geometric_bounds() - && bb.x1 > r.x1 - { - right_edge = right_edge.max(if bb.x0 > r.x1 { bb.x1 } else { bb.x1 - 8.0 }); - } - } - if horizontal { - axes.push(b.line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1))); - axes.push(b.line(Point::new(zero(0), r.y0), Point::new(zero(0), r.y1))); - } else { - if on_left_side { - axes.push(b.line(Point::new(r.x0, r.y0), Point::new(r.x0, r.y1))); - } - if on_right_side { - axes.push(b.line(Point::new(r.x1, r.y0), Point::new(r.x1, r.y1))); - } - axes.push(b.line(Point::new(r.x0, zero(0)), Point::new(r.x1, zero(0)))); - // The right scale's zero, where its columns start, when it isn't the left one's. - if (zero(1) - zero(0)).abs() > 1e-9 { - axes.push(b.line(Point::new(r.x0, zero(1)), Point::new(r.x1, zero(1)))); - } - } - // 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 }; - // Area bands always span the plot; lines only with Edge-to-Edge Lines. Category labels sit where every - // series puts its points: at the edges when all of them span the plot, else at the category centres. - let spans = |k: GraphKind| k == GraphKind::Area || (k == GraphKind::Line && g.edge_to_edge); - let mid = |c: usize, edges: bool| { - if edges && ncat > 1 { r.x0 + r.width() * c as f64 / (ncat - 1) as f64 } else { cat_start(c) + cat_w / 2.0 } - }; - let label_edges = kinds.iter().all(|k| spans(*k)); - let cat_mid = |c: usize| mid(c, label_edges); - 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.label(Role::Category, Point::new(r.x0 - 6.0, cat_mid(c) + cs * 0.35), cat, Justify::Right, true) - } else { - b.label(Role::Category, Point::new(cat_mid(c), r.y1 + cs * 1.4), cat, Justify::Center, false) - }; - axes.push(t); - } - } - // Category tick marks (none by default): at the labels, or between them (at the category edges), - // with the extra tick marks of each division between those. - let len = g.category_tick_length; - if len != TickLength::None { - let shown = ncat.min(MAX_CATEGORY_TICKS); - let base: Vec = match (g.ticks_between_labels, label_edges) { - // Labels at the plot's edges: halfway between neighbouring labels. - (true, true) => (1..shown).map(|c| (cat_mid(c - 1) + cat_mid(c)) / 2.0).collect(), - (true, false) => (0..=shown).map(cat_start).collect(), - (false, _) => (0..shown).map(cat_mid).collect(), - }; - // Fewer per division on a long axis, so the cap still ticks all of it. - let n = g.category_tick_marks.clamp(1, MAX_TICK_MARKS).min(MAX_CATEGORY_TICKS / base.len().max(1)).max(1); - let at = base.iter().enumerate().flat_map(|(i, q)| { - let next = base.get(i + 1).copied(); - (0..n).filter_map(move |k| if k == 0 { Some(*q) } else { next.map(|x| q + (x - q) * k as f64 / n as f64) }) - }); - // A full-length tick on a drawn axis line adds nothing. - let on_axis_line = |q: f64| { - if horizontal { - (q - r.y1).abs() < 1e-9 - } else { - ((q - r.x0).abs() < 1e-9 && on_left_side) || ((q - r.x1).abs() < 1e-9 && on_right_side) - } - }; - for q in at.take(MAX_CATEGORY_TICKS) { - if len == TickLength::Full && on_axis_line(q) { - continue; - } - let (p0, p1) = match (len, horizontal) { - (TickLength::Full, false) => (Point::new(q, r.y0), Point::new(q, r.y1)), - (TickLength::Full, true) => (Point::new(r.x0, q), Point::new(r.x1, q)), - (_, false) => (Point::new(q, r.y1), Point::new(q, r.y1 + 4.0)), - (_, true) => (Point::new(r.x0 - 4.0, q), Point::new(r.x0, q)), - }; - axes.push(b.line(p0, p1)); - } - } - } else { - // Scatter: a horizontal value axis too. - // Like the value axis: finite, and a counted (capped) list of ticks, so data near f64::MAX can't loop forever. - let x_lo = if xlo == f64::MAX { 0.0 } else { xlo.min(0.0) }; - let x_hi = if xhi == f64::MIN { 1.0 } else { xhi }; - let (xl, xh, xs) = value_axis(g.ticks, None, None, x_lo, x_hi); - for x in tick_values(xl, xh, xs) { - 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(Role::Value, Point::new(q, r.y1 + 4.0 + vs * 1.1), &fmt_value(x), Justify::Center)); - } - for (si, s) in (0..nser).step_by(2).enumerate() { - let pts: Vec> = (0..ncat) - .map(|c| match (cell(c, s), cell(c, s + 1)) { - (Some(y), Some(x)) => Some(Point::new(r.x0 + (x - xl) / (xh - xl) * r.width(), vpos(0, y))), - _ => None, - }) - .collect(); - if g.connect_points { - for run in runs(&pts) { - let n = b.data_line(g, si, polyline(&run, false), filled); - shadowed.push((si, n.clone())); - series[si].push(n); - } - } - if g.mark_points { - for p in pts.iter().flatten() { - let paint = mark(si, true, Paint::None, 0.0); - let n = b.marker(design(si), Rect::from_center_size(*p, (5.0, 5.0)), paint); - series[si].push(n); - } - } - } - } - let ax = b.group("Axes", axes); - b.out.push(ax); - // Columns (or bars) of one category side by side; stacked columns take one slot of that cluster, or, with - // no plain columns, a width of their own. - let columns: Vec = (0..nser).filter(|s| matches!(kind(*s), GraphKind::Column | GraphKind::Bar)).collect(); - let stacks: Vec = (0..nser).filter(|s| matches!(kind(*s), GraphKind::StackedColumn | GraphKind::StackedBar)).collect(); - // One stack per value axis that has stacked series, each in a slot of its own. - let stack_axes: Vec = (0..2).filter(|a| stacks.iter().any(|s| axis(*s) == *a)).collect(); - let own_width = columns.is_empty() && stack_axes.len() < 2; - let slots = columns.len() + stack_axes.len(); - // Widths past 100% make the columns (or clusters) overlap. - let cluster = cat_w * (g.cluster_width / 100.0).clamp(0.01, 10.0); - let slot = cluster / slots.max(1) as f64; - let bar = slot * (g.column_width / 100.0).clamp(0.01, 10.0); - let bar_rect = |a: f64, w: f64, v0: f64, v1: f64| { - if horizontal { Rect::new(v0.min(v1), a, v0.max(v1), a + w) } else { Rect::new(a, v0.min(v1), a + w, v0.max(v1)) } - }; - // First Row in Front: the first category drawn last, over the next. - let cats: Vec = if g.first_row_in_front { (0..ncat).rev().collect() } else { (0..ncat).collect() }; - for c in cats { - let c0 = cat_start(c) + (cat_w - cluster) / 2.0; - for (i, &s) in columns.iter().enumerate() { - let Some(v) = cell(c, s) else { continue }; - let a = c0 + slot * i as f64 + (slot - bar) / 2.0; - let (fill, stroke, w) = mark(s, true, Paint::None, 0.0); - let (v0, v1) = (zero(axis(s)), vpos(axis(s), v)); - let rect = bar_rect(a, bar, v0, v1); - bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect))); - // A column drawn with a design casts no shadow. - let n = match column_look(s).and_then(|c| column_art(b.d, c, (rect, v), (horizontal, v1 < v0), per_unit(axis(s)), &mut b.columns)) - { - Some(n) => { - // A design can reach past its column (Uniformly Scaled), so the legend keeps clear of it too. - if let Some(art) = n.geometric_bounds() { - bars_reach = Some(bars_reach.map_or(art, |r| r.union(art))); - } - n - } - None => { - let n = b.path(shapes::rectangle(rect), fill, stroke, w); - shadowed.push((s, n.clone())); - n - } - }; - if let Some(items) = series.get_mut(s) { - items.push(n); - } - } - let place = |ax: usize| { - if own_width { - // Up to the category's width, or past it when the widths are set past 100%. - let (col, clus) = ((g.column_width / 100.0).clamp(0.01, 10.0), (g.cluster_width / 100.0).clamp(0.01, 10.0)); - let w = (cat_w * col * clus * 1.2).min(cat_w * (col * clus).max(1.0)); - (cat_start(c) + (cat_w - w) / 2.0, w) - } else { - let i = columns.len() + stack_axes.iter().position(|a| *a == ax).unwrap_or(0); - (c0 + slot * i as f64 + (slot - bar) / 2.0, bar) - } - }; - // Each axis stacks its own series. - let (mut pos, mut neg) = ([0.0f64; 2], [0.0f64; 2]); - for &s in &stacks { - let Some(v) = cell(c, s) else { continue }; - let ax = axis(s); - let (a, w) = place(ax); - let Some(base) = (if v >= 0.0 { pos.get_mut(ax) } else { neg.get_mut(ax) }) else { continue }; - let (v0, v1) = (vpos(ax, *base), vpos(ax, *base + v)); - *base += v; - let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.25); - let rect = bar_rect(a, w, v0, v1); - bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect))); - let n = match column_look(s).and_then(|c| column_art(b.d, c, (rect, v), (horizontal, v1 < v0), per_unit(ax), &mut b.columns)) { - Some(n) => { - if let Some(art) = n.geometric_bounds() { - bars_reach = Some(bars_reach.map_or(art, |r| r.union(art))); - } - n - } - None => { - let n = b.path(shapes::rectangle(rect), fill, stroke, sw); - // A segment of 0 has no area to shade. - if v != 0.0 { - shadowed.push((s, n.clone())); - } - n - } - }; - if let Some(items) = series.get_mut(s) { - items.push(n); - } - } - } - // Cumulative area bands, each from the previous area total on its axis up to its own. - let mut belows: [Vec; 2] = [vec![0.0; ncat], vec![0.0; ncat]]; - for s in (0..nser).filter(|s| kind(*s) == GraphKind::Area) { - let a = axis(s); - let Some(below) = belows.get_mut(a) else { continue }; - let above: Vec = below.iter().enumerate().map(|(c, b)| b + val(c, s)).collect(); - // Mixed with series at the category centres, areas take their points there too. - let at = |c: usize, v: &[f64]| Point::new(mid(c, label_edges), vpos(a, v.get(c).copied().unwrap_or(0.0))); - let mut pts: Vec = (0..ncat).map(|c| at(c, &above)).collect(); - pts.extend((0..ncat).rev().map(|c| at(c, below))); - let (fill, stroke, w) = mark(s, true, Paint::solid(Color::WHITE), 0.25); - let n = b.path(polyline(&pts, true), fill, stroke, w); - if let Some(items) = series.get_mut(s) { - items.push(n); - } - *below = above; - } - for s in (0..nser).filter(|s| kind(*s) == GraphKind::Line) { - let pts: Vec> = - (0..ncat).map(|c| cell(c, s).map(|v| Point::new(mid(c, g.edge_to_edge && label_edges), vpos(axis(s), v)))).collect(); - if g.connect_points { - for run in runs(&pts) { - let n = b.data_line(g, s, polyline(&run, false), filled); - shadowed.push((s, n.clone())); - if let Some(items) = series.get_mut(s) { - items.push(n); - } - } - } - if g.mark_points { - for p in pts.iter().flatten() { - let paint = mark(s, true, Paint::None, 0.0); - let n = b.marker(design(s), Rect::from_center_size(*p, (5.0, 5.0)), paint); - if let Some(items) = series.get_mut(s) { - items.push(n); - } - } + if g.mark_points { + for p in pts { + let paint = self.mark(s, true, Paint::None, 0.0); + let n = self.b.marker(nth(&self.designs, s), Rect::from_center_size(p, (4.0, 4.0)), paint); + self.push_series(s, n); } } } } - // 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![]; - let legend_on = g.legend && !g.series.is_empty() && !(g.kind == GraphKind::Pie && g.pie_legend_in_wedges); - if legend_on && g.legend_across_top { - // Add Legend Across Top: swatch and label after swatch and label, in rows as wide as the plot, the last row - // just above it. - let entries: Vec<(usize, String)> = (0..legend_series) - .map(|s| (s, if g.kind == GraphKind::Scatter { series_label(s * 2) } else { series_label(s) })) - .filter(|(_, l)| !blank_label(l)) - .collect(); + + // ---- Columns, bars, lines, areas and scatter ---- + + fn cartesian(&mut self) { + let g = self.g; + let scatter = g.kind == GraphKind::Scatter; + // Separate Scales: with the value axis on both sides, the series assigned to the right axis get a scale + // of their own there (axis 1); every other series, and every series without separate scales, uses axis 0. + let separate = g.separate_scales && g.value_axis == ValueAxisSide::Both && !self.horizontal && !scatter; + let mut on_right = vec![false; self.nser]; + if separate { + for i in &g.right_series { + if let Some(x) = on_right.get_mut(*i) { + *x = true; + } + } + } + let mut geo = self.geo(on_right); + let (lo, hi, xlo, xhi) = self.value_range(&geo, scatter); + // An axis with no series of its own shows the other one's scale. + let left = value_axis(g.ticks, g.axis_min, g.axis_max, lo[0], hi[0]); + let used = |a: usize| (0..self.nser).any(|s| geo.axis(s) == a); + let (left, right) = if !separate || !used(1) { + (left, left) + } else { + let right = value_axis(g.right_ticks, g.right_axis_min, g.right_axis_max, lo[1], hi[1]); + if used(0) { (left, right) } else { (right, right) } + }; + (geo.left, geo.right) = (left, right); + let mut axes = vec![]; + self.value_axes(&geo, &mut axes); + if scatter { + self.scatter(&geo, (xlo, xhi), &mut axes); + } else { + self.category_axis(&geo, &mut axes); + } + let ax = self.b.group("Axes", axes); + self.b.out.push(ax); + self.columns(&geo); + self.areas(&geo); + self.lines(&geo); + } + + /// The plot's geometry, its value scales still to come. + fn geo(&self, on_right: Vec) -> Geo { + let (g, r) = (self.g, self.r); + // Value axes with ticks and labels: along the bottom for bar graphs, else on the chosen sides (the right + // one shows the right scale). + let (left_side, right_side) = + if self.horizontal { (true, false) } else { (g.value_axis != ValueAxisSide::Right, g.value_axis != ValueAxisSide::Left) }; + let span = if self.horizontal { r.height() } else { r.width() }; + // Area bands always span the plot; lines only with Edge-to-Edge Lines. Category labels sit where every + // series puts its points: at the edges when all of them span the plot, else at the category centres. + let spans = |k: GraphKind| k == GraphKind::Area || (k == GraphKind::Line && g.edge_to_edge); + Geo { + r, + horizontal: self.horizontal, + ncat: self.ncat, + on_right, + left: (0.0, 1.0, 1.0), + right: (0.0, 1.0, 1.0), + cat_w: span / self.ncat as f64, + label_edges: self.kinds.iter().all(|k| spans(*k)), + left_side, + right_side, + } + } + + /// The value range per axis (stacked graphs stack positives and negatives separately), and a scatter's x range. + fn value_range(&self, geo: &Geo, scatter: bool) -> ([f64; 2], [f64; 2], f64, f64) { + let (mut lo, mut hi) = ([0.0f64; 2], [0.0f64; 2]); + let (mut xlo, mut xhi) = (f64::MAX, f64::MIN); + for c in 0..self.ncat { + if !scatter { + for (a, (lo, hi)) in lo.iter_mut().zip(hi.iter_mut()).enumerate() { + self.extend_range(geo, c, a, (lo, hi)); + } + } else { + for s in (0..self.nser).step_by(2) { + if let (Some(y), Some(x)) = (self.cell(c, s), self.cell(c, s + 1)) { + hi[0] = hi[0].max(y); + lo[0] = lo[0].min(y); + xlo = xlo.min(x); + xhi = xhi.max(x); + } + } + } + } + (lo, hi, xlo, xhi) + } + + /// Widen the range of axis `a` by category `c`'s values. + fn extend_range(&self, geo: &Geo, c: usize, a: usize, (lo, hi): (&mut f64, &mut f64)) { + let nser = self.nser; + // Stacked columns (or bars) stack together, and so do areas; other series count one value each. + for stack in [[GraphKind::StackedColumn, GraphKind::StackedBar], [GraphKind::Area, GraphKind::Area]] { + let members = || (0..nser).filter(|s| geo.axis(*s) == a && stack.contains(&self.kind(*s))); + *hi = hi.max(members().map(|s| self.val(c, s).max(0.0)).sum()); + *lo = lo.min(members().map(|s| self.val(c, s).min(0.0)).sum()); + } + let single = |s: &usize| geo.axis(*s) == a && matches!(self.kind(*s), GraphKind::Column | GraphKind::Bar | GraphKind::Line); + for v in (0..nser).filter(single).filter_map(|s| self.cell(c, s)) { + *hi = hi.max(v); + *lo = lo.min(v); + } + } + + /// The value axes with ticks and labels, the legend's start after them, and the axis lines. + fn value_axes(&mut self, geo: &Geo, axes: &mut Vec>) { + let r = self.r; + if geo.left_side { + let (lo, hi, step) = geo.left; + for v in tick_values(lo, hi, step) { + self.value_tick(geo, axes, 0, v, (step, hi)); + } + } + if geo.right_side { + let (lo, hi, step) = geo.right; + for v in tick_values(lo, hi, step) { + self.value_tick(geo, axes, 1, v, (step, hi)); + } + } + // The legend starts after the value labels right of the plot: the right axis' (14 points on), or a bar + // graph's last bottom label running past the plot (6 points on; a short number stays within the gap). + for t in axes.iter() { + if matches!(t.kind, NodeKind::Text(_)) + && let Some(bb) = t.geometric_bounds() + && bb.x1 > r.x1 + { + self.right_edge = self.right_edge.max(if bb.x0 > r.x1 { bb.x1 } else { bb.x1 - 8.0 }); + } + } + if geo.horizontal { + axes.push(self.b.line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1))); + axes.push(self.b.line(Point::new(geo.zero(0), r.y0), Point::new(geo.zero(0), r.y1))); + } else { + if geo.left_side { + axes.push(self.b.line(Point::new(r.x0, r.y0), Point::new(r.x0, r.y1))); + } + if geo.right_side { + axes.push(self.b.line(Point::new(r.x1, r.y0), Point::new(r.x1, r.y1))); + } + axes.push(self.b.line(Point::new(r.x0, geo.zero(0)), Point::new(r.x1, geo.zero(0)))); + // The right scale's zero, where its columns start, when it isn't the left one's. + if (geo.zero(1) - geo.zero(0)).abs() > 1e-9 { + axes.push(self.b.line(Point::new(r.x0, geo.zero(1)), Point::new(r.x1, geo.zero(1)))); + } + } + } + + /// Each label's tick mark, then the extra tick marks of its division (Tick Marks per division). + fn value_tick(&mut self, geo: &Geo, axes: &mut Vec>, a: usize, v: f64, (step, hi): (f64, f64)) { + let g = self.g; + let (len, marks) = if a == 1 { (g.right_tick_length, g.right_tick_marks) } else { (g.tick_length, g.tick_marks) }; + let n = marks.clamp(1, MAX_TICK_MARKS); + for k in 0..n { + let w = v + step * k as f64 / n as f64; + if k > 0 && w >= hi - step * 1e-9 { + break; + } + let q = geo.vpos(a, w); + // A full-length tick on the zero line, or repeating the left side's on one shared scale, adds nothing. + let repeat = a == 1 && geo.left_side && g.tick_length == TickLength::Full && geo.right == geo.left; + let len = if len == TickLength::Full && ((q - geo.zero(a)).abs() < 1e-9 || repeat) { TickLength::None } else { len }; + if let Some((p0, p1)) = geo.tick_ends(len, a, q) { + axes.push(self.b.line(p0, p1)); + } + if k == 0 { + axes.push(self.value_label(geo, a, q, v)); + } + } + } + + /// The label after a tick mark: with the Add Labels text of its axis, kept to one short line whatever the file holds. + fn value_label(&mut self, geo: &Geo, a: usize, q: f64, v: f64) -> Arc { + let (r, vs) = (self.r, self.vs); + let (prefix, suffix) = if a == 1 { (&self.g.right_prefix, &self.g.right_suffix) } else { (&self.g.prefix, &self.g.suffix) }; + let label = format!("{}{}{}", one_line(prefix), fmt_value(v), one_line(suffix)); + if geo.horizontal { + self.b.text(Role::Value, Point::new(q, r.y1 + 4.0 + vs * 1.1), &label, Justify::Center) + } else if a == 1 { + self.b.text(Role::Value, Point::new(r.x1 + 6.0, q + vs * 0.35), &label, Justify::Left) + } else { + self.b.text(Role::Value, Point::new(r.x0 - 6.0, q + vs * 0.35), &label, Justify::Right) + } + } + + /// Category labels and tick marks. + fn category_axis(&mut self, geo: &Geo, axes: &mut Vec>) { + let (r, cs) = (self.r, self.cs); + for c in 0..self.ncat { + if let Some(cat) = self.g.categories.get(c).filter(|c| !c.is_empty()) { + let t = if geo.horizontal { + self.b.label(Role::Category, Point::new(r.x0 - 6.0, geo.cat_mid(c) + cs * 0.35), cat, Justify::Right, true) + } else { + self.b.label(Role::Category, Point::new(geo.cat_mid(c), r.y1 + cs * 1.4), cat, Justify::Center, false) + }; + axes.push(t); + } + } + // Category tick marks (none by default): at the labels, or between them (at the category edges), + // with the extra tick marks of each division between those. + let len = self.g.category_tick_length; + if len == TickLength::None { + return; + } + let at = self.category_tick_positions(geo); + // A full-length tick on a drawn axis line adds nothing. + let on_axis_line = |q: f64| { + if geo.horizontal { + (q - r.y1).abs() < 1e-9 + } else { + ((q - r.x0).abs() < 1e-9 && geo.left_side) || ((q - r.x1).abs() < 1e-9 && geo.right_side) + } + }; + for q in at.into_iter().take(MAX_CATEGORY_TICKS) { + if len == TickLength::Full && on_axis_line(q) { + continue; + } + let (p0, p1) = match (len, geo.horizontal) { + (TickLength::Full, false) => (Point::new(q, r.y0), Point::new(q, r.y1)), + (TickLength::Full, true) => (Point::new(r.x0, q), Point::new(r.x1, q)), + (_, false) => (Point::new(q, r.y1), Point::new(q, r.y1 + 4.0)), + (_, true) => (Point::new(r.x0 - 4.0, q), Point::new(r.x0, q)), + }; + axes.push(self.b.line(p0, p1)); + } + } + + /// Where the category tick marks go: at the labels, or between them, each division with its extra marks. + fn category_tick_positions(&self, geo: &Geo) -> Vec { + let shown = self.ncat.min(MAX_CATEGORY_TICKS); + let base: Vec = match (self.g.ticks_between_labels, geo.label_edges) { + // Labels at the plot's edges: halfway between neighbouring labels. + (true, true) => (1..shown).map(|c| (geo.cat_mid(c - 1) + geo.cat_mid(c)) / 2.0).collect(), + (true, false) => (0..=shown).map(|c| geo.cat_start(c)).collect(), + (false, _) => (0..shown).map(|c| geo.cat_mid(c)).collect(), + }; + // Fewer per division on a long axis, so the cap still ticks all of it. + let n = self.g.category_tick_marks.clamp(1, MAX_TICK_MARKS).min(MAX_CATEGORY_TICKS / base.len().max(1)).max(1); + base.iter() + .enumerate() + .flat_map(|(i, q)| { + let next = base.get(i + 1).copied(); + (0..n).filter_map(move |k| if k == 0 { Some(*q) } else { next.map(|x| q + (x - q) * k as f64 / n as f64) }) + }) + .collect() + } + + /// Scatter: a horizontal value axis too, and the series' points. + fn scatter(&mut self, geo: &Geo, (xlo, xhi): (f64, f64), axes: &mut Vec>) { + let (g, r, vs) = (self.g, self.r, self.vs); + // Like the value axis: finite, and a counted (capped) list of ticks, so data near f64::MAX can't loop forever. + let x_lo = if xlo == f64::MAX { 0.0 } else { xlo.min(0.0) }; + let x_hi = if xhi == f64::MIN { 1.0 } else { xhi }; + let (xl, xh, xs) = value_axis(g.ticks, None, None, x_lo, x_hi); + for x in tick_values(xl, xh, xs) { + let q = r.x0 + (x - xl) / (xh - xl) * r.width(); + axes.push(self.b.line(Point::new(q, r.y1), Point::new(q, r.y1 + 4.0))); + axes.push(self.b.text(Role::Value, Point::new(q, r.y1 + 4.0 + vs * 1.1), &fmt_value(x), Justify::Center)); + } + for (si, s) in (0..self.nser).step_by(2).enumerate() { + let pts: Vec> = (0..self.ncat) + .map(|c| match (self.cell(c, s), self.cell(c, s + 1)) { + (Some(y), Some(x)) => Some(Point::new(r.x0 + (x - xl) / (xh - xl) * r.width(), geo.vpos(0, y))), + _ => None, + }) + .collect(); + if g.connect_points { + for run in runs(&pts) { + let n = self.b.data_line(g, si, polyline(&run, false), self.filled); + self.shadowed.push((si, n.clone())); + self.series[si].push(n); + } + } + self.point_markers(si, &pts); + } + } + + /// A marker on each point of series `s`, when Mark Data Points is on. + fn point_markers(&mut self, s: usize, pts: &[Option]) { + if !self.g.mark_points { + return; + } + for p in pts.iter().flatten() { + let paint = self.mark(s, true, Paint::None, 0.0); + let n = self.b.marker(nth(&self.designs, s), Rect::from_center_size(*p, (5.0, 5.0)), paint); + self.push_series(s, n); + } + } + + /// A column, bar or stacked segment: with its column design when it has one (which casts no shadow), else a plain + /// rectangle in `paint`, shadowed when `shadow`. + fn bar_node(&mut self, bar: Bar, paint: (Paint, Paint, f64), shadow: bool) -> Arc { + let Bar { s, rect, value, down, per_unit } = bar; + self.reach(rect); + let horizontal = self.horizontal; + let art = nth(self.column_looks, s).and_then(|c| column_art(self.b.d, c, (rect, value), (horizontal, down), per_unit, &mut self.b.columns)); + match art { + Some(n) => { + // A design can reach past its column (Uniformly Scaled), so the legend keeps clear of it too. + if let Some(art) = n.geometric_bounds() { + self.reach(art); + } + n + } + None => { + let (fill, stroke, w) = paint; + let n = self.b.path(shapes::rectangle(rect), fill, stroke, w); + if shadow { + self.shadowed.push((s, n.clone())); + } + n + } + } + } + + /// Columns (or bars) of one category side by side, and the stacks. + fn columns(&mut self, geo: &Geo) { + let g = self.g; + let nser = self.nser; + let columns: Vec = (0..nser).filter(|s| matches!(self.kind(*s), GraphKind::Column | GraphKind::Bar)).collect(); + let stacks: Vec = (0..nser).filter(|s| matches!(self.kind(*s), GraphKind::StackedColumn | GraphKind::StackedBar)).collect(); + let stack_axes: Vec = (0..2).filter(|a| stacks.iter().any(|s| geo.axis(*s) == *a)).collect(); + let col_frac = (g.column_width / 100.0).clamp(0.01, 10.0); + let clus_frac = (g.cluster_width / 100.0).clamp(0.01, 10.0); + let cluster = geo.cat_w * (g.cluster_width / 100.0).clamp(0.01, 10.0); + let slot = cluster / (columns.len() + stack_axes.len()).max(1) as f64; + let cl = Cluster { + own_width: columns.is_empty() && stack_axes.len() < 2, + columns, + stacks, + stack_axes, + col_frac, + clus_frac, + cluster, + slot, + bar: slot * col_frac, + }; + // First Row in Front: the first category drawn last, over the next. + let cats: Vec = if g.first_row_in_front { (0..self.ncat).rev().collect() } else { (0..self.ncat).collect() }; + for c in cats { + let c0 = geo.cat_start(c) + (geo.cat_w - cl.cluster) / 2.0; + self.cluster_columns(geo, &cl, c, c0); + self.cluster_stacks(geo, &cl, c, c0); + } + } + + fn cluster_columns(&mut self, geo: &Geo, cl: &Cluster, c: usize, c0: f64) { + for (i, &s) in cl.columns.iter().enumerate() { + let Some(v) = self.cell(c, s) else { continue }; + let a = c0 + cl.slot * i as f64 + (cl.slot - cl.bar) / 2.0; + let paint = self.mark(s, true, Paint::None, 0.0); + let (v0, v1) = (geo.zero(geo.axis(s)), geo.vpos(geo.axis(s), v)); + let rect = bar_rect(geo.horizontal, a, cl.bar, v0, v1); + let n = self.bar_node(Bar { s, rect, value: v, down: v1 < v0, per_unit: geo.per_unit(geo.axis(s)) }, paint, true); + self.push_series(s, n); + } + } + + fn cluster_stacks(&mut self, geo: &Geo, cl: &Cluster, c: usize, c0: f64) { + // Each axis stacks its own series. + let (mut pos, mut neg) = ([0.0f64; 2], [0.0f64; 2]); + for &s in &cl.stacks { + let Some(v) = self.cell(c, s) else { continue }; + let ax = geo.axis(s); + let (a, w) = cl.place(geo, c, c0, ax); + let Some(base) = (if v >= 0.0 { pos.get_mut(ax) } else { neg.get_mut(ax) }) else { continue }; + let (v0, v1) = (geo.vpos(ax, *base), geo.vpos(ax, *base + v)); + *base += v; + let paint = self.mark(s, true, Paint::solid(Color::WHITE), 0.25); + let rect = bar_rect(geo.horizontal, a, w, v0, v1); + // A segment of 0 has no area to shade. + let n = self.bar_node(Bar { s, rect, value: v, down: v1 < v0, per_unit: geo.per_unit(ax) }, paint, v != 0.0); + self.push_series(s, n); + } + } + + /// Cumulative area bands, each from the previous area total on its axis up to its own. + fn areas(&mut self, geo: &Geo) { + let ncat = self.ncat; + let mut belows: [Vec; 2] = [vec![0.0; ncat], vec![0.0; ncat]]; + for s in self.series_of(GraphKind::Area) { + let a = geo.axis(s); + let Some(below) = belows.get_mut(a) else { continue }; + let above: Vec = below.iter().enumerate().map(|(c, b)| b + self.val(c, s)).collect(); + // Mixed with series at the category centres, areas take their points there too. + let at = |c: usize, v: &[f64]| Point::new(geo.cat_mid(c), geo.vpos(a, v.get(c).copied().unwrap_or(0.0))); + let mut pts: Vec = (0..ncat).map(|c| at(c, &above)).collect(); + pts.extend((0..ncat).rev().map(|c| at(c, below))); + let (fill, stroke, w) = self.mark(s, true, Paint::solid(Color::WHITE), 0.25); + let n = self.b.path(polyline(&pts, true), fill, stroke, w); + self.push_series(s, n); + *below = above; + } + } + + fn lines(&mut self, geo: &Geo) { + let g = self.g; + for s in self.series_of(GraphKind::Line) { + let pts: Vec> = (0..self.ncat) + .map(|c| self.cell(c, s).map(|v| Point::new(geo.mid(c, g.edge_to_edge && geo.label_edges), geo.vpos(geo.axis(s), v)))) + .collect(); + if g.connect_points { + for run in runs(&pts) { + let n = self.b.data_line(g, s, polyline(&run, false), self.filled); + self.push_shadowed(s, n); + } + } + self.point_markers(s, &pts); + } + } + + // ---- Legend and layering ---- + + /// Legend: a swatch + label per series, right of the plot; the swatch joins its series group. Returns the labels. + fn legend(&mut self) -> Vec> { + let g = self.g; + let legend_on = g.legend && !g.series.is_empty() && !(g.kind == GraphKind::Pie && g.pie_legend_in_wedges); + if !legend_on { + return vec![]; + } + if g.legend_across_top { self.legend_across_top() } else { self.legend_beside() } + } + + fn legend_series(&self) -> usize { + if self.g.kind == GraphKind::Scatter { self.nser.div_ceil(2) } else { self.nser } + } + + fn legend_text(&self, s: usize) -> String { + if self.g.kind == GraphKind::Scatter { self.series_label(s * 2) } else { self.series_label(s) } + } + + /// The swatch of series `s` at `(x, y)`: series drawn with markers show their marker design in the legend, and + /// series drawn with a column design that design. + fn legend_swatch(&mut self, s: usize, (x, y): (f64, f64)) { + let marked = matches!(self.g.kind, GraphKind::Scatter | GraphKind::Radar) || self.kind(s) == GraphKind::Line; + let paint = self.mark(s, true, Paint::None, 0.0); + let column = nth(self.column_looks, s).filter(|_| self.columnar(s)); + let swatch = self.b.swatch(column, nth(&self.designs, s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint); + self.push_series(s, swatch); + } + + fn legend_label(&mut self, (x, y): (f64, f64), label: &str) -> Arc { + let ls = self.ls; + self.b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), label, Justify::Left, false) + } + + /// Add Legend Across Top: swatch and label after swatch and label, in rows as wide as the plot, the last row + /// just above it. + fn legend_across_top(&mut self) -> Vec> { + let (g, r, ls) = (self.g, self.r, self.ls); + let entries: Vec<(usize, String)> = (0..self.legend_series()).map(|s| (s, self.legend_text(s))).filter(|(_, l)| !blank_label(l)).collect(); let mut rows: Vec> = vec![vec![]]; let mut used = 0.0; for (s, label) in entries { @@ -614,89 +924,94 @@ pub(super) fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Opt } let height = |row: &[(usize, String, f64)]| ls * 1.8 * row.iter().map(|(_, l, _)| l.split('|').count()).max().unwrap_or(1) as f64; // Above the plot and anything drawn over it (radar and stacked-pie labels). - let top = b.out.iter().chain(on_top.iter()).filter_map(|n| n.geometric_bounds()).map(|bb| bb.y0).fold(r.y0, f64::min); - let top = bars_reach.map_or(top, |b| top.min(b.y0)); + let top = self.b.out.iter().chain(self.on_top.iter()).filter_map(|n| n.geometric_bounds()).map(|bb| bb.y0).fold(r.y0, f64::min); + let top = self.bars_reach.map_or(top, |b| top.min(b.y0)); let mut y = top - 8.0 - rows.iter().map(|row| height(row)).sum::(); + let mut labels = vec![]; for row in &rows { let mut x = r.x0; for (s, label, w) in row { - let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(*s) == GraphKind::Line; - let paint = mark(*s, true, Paint::None, 0.0); - let swatch = - b.swatch(column_look(*s).filter(|_| columnar(*s)), design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint); - if let Some(items) = series.get_mut(*s) { - items.push(swatch); - } - labels.push(b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), label, Justify::Left, false)); + self.legend_swatch(*s, (x, y)); + labels.push(self.legend_label((x, y), label)); x += w; } y += height(row); } - } else if legend_on { - let x = bars_reach.map_or(right_edge, |b| right_edge.max(b.x1)) + 14.0; + labels + } + + fn legend_beside(&mut self) -> Vec> { + let (r, ls) = (self.r, self.ls); + let x = self.bars_reach.map_or(self.right_edge, |b| self.right_edge.max(b.x1)) + 14.0; // Each row as tall as its label's lines (a `|` in a label starts a new line). let mut y = r.y0; - 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) }; + let mut labels = vec![]; + for s in 0..self.legend_series() { + let label = self.legend_text(s); let row_y = y; if blank_label(&label) { y += ls * 1.8; continue; } y += ls * 1.8 * label.split('|').count() as f64; - let y = row_y; - // Series drawn with markers show their marker design in the legend, and series drawn with a column design - // that design. - let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(s) == GraphKind::Line; - let paint = mark(s, true, Paint::None, 0.0); - items.push(b.swatch(column_look(s).filter(|_| columnar(s)), design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint)); - labels.push(b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), &label, Justify::Left, false)); + self.legend_swatch(s, (x, row_y)); + labels.push(self.legend_label((x, row_y), &label)); + } + labels + } + + /// Areas at the back, lines and points in front of columns; one graph type keeps the series order. + fn depth(&self, s: usize) -> u8 { + match self.kind(s) { + GraphKind::Area => 0, + GraphKind::Line => 2, + _ => 1, } } - // Areas at the back, lines and points in front of columns; one graph type keeps the series order. - let depth = |s: usize| match kind(s) { - GraphKind::Area => 0, - GraphKind::Line => 2, - _ => 1, - }; - // Add Drop Shadow: a translucent black copy of each column, bar, line and wedge, a little down and right, just - // behind the series of its layer (areas, columns, lines), so a layer in front doesn't hide the shadows of the one - // behind. Left out past SHADOW_BUDGET marks. - if !g.drop_shadow || shadowed.len() > SHADOW_BUDGET { - shadowed.clear(); - } - let mut series: Vec<(usize, Vec>)> = series.into_iter().enumerate().collect(); - // First Column in Front: the first series drawn last in its layer, over the others. - if g.first_column_in_front { - series.reverse(); - } - series.sort_by_key(|(s, _)| depth(*s)); - let mut layer = None; - for (s, items) in series { - if items.is_empty() { - continue; + + /// Add Drop Shadow: a translucent black copy of each column, bar, line and wedge, a little down and right, just + /// behind the series of its layer (areas, columns, lines), so a layer in front doesn't hide the shadows of the one + /// behind. Left out past SHADOW_BUDGET marks. + fn finish(mut self, mut labels: Vec>) -> Option>> { + let g = self.g; + if !g.drop_shadow || self.shadowed.len() > SHADOW_BUDGET { + self.shadowed.clear(); } - if layer != Some(depth(s)) { - layer = Some(depth(s)); - let copies: Vec> = shadowed.iter().filter(|(t, _)| depth(*t) == depth(s)).map(|(_, n)| shadow_of(b.d, n)).collect(); - if !copies.is_empty() { - let mut grp = Node::group(b.d.alloc_id(), copies); - grp.name = Some("Drop Shadow".into()); - grp.opacity = SHADOW_OPACITY; - b.out.push(Arc::new(grp)); + let mut series: Vec<(usize, Vec>)> = std::mem::take(&mut self.series).into_iter().enumerate().collect(); + // First Column in Front: the first series drawn last in its layer, over the others. + if g.first_column_in_front { + series.reverse(); + } + series.sort_by_key(|(s, _)| self.depth(*s)); + let mut layer = None; + for (s, items) in series { + if items.is_empty() { + continue; } + let depth = self.depth(s); + if layer != Some(depth) { + layer = Some(depth); + let casting: Vec> = self.shadowed.iter().filter(|(t, _)| self.depth(*t) == depth).map(|(_, n)| n.clone()).collect(); + let copies: Vec> = casting.iter().map(|n| shadow_of(self.b.d, n)).collect(); + if !copies.is_empty() { + let mut grp = Node::group(self.b.d.alloc_id(), copies); + grp.name = Some("Drop Shadow".into()); + grp.opacity = SHADOW_OPACITY; + self.b.out.push(Arc::new(grp)); + } + } + let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1)); + let grp = self.b.series(&name, s, items); + self.b.out.push(grp); } - let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1)); - let grp = b.series(&name, s, items); - b.out.push(grp); + self.b.out.append(&mut self.on_top); + labels.append(&mut self.wedge_labels); + if !labels.is_empty() { + let grp = self.b.group("Legend", labels); + self.b.out.push(grp); + } + (!self.b.columns.short).then_some(self.b.out) } - b.out.append(&mut on_top); - labels.append(&mut wedge_labels); - if !labels.is_empty() { - let grp = b.group("Legend", labels); - b.out.push(grp); - } - (!b.columns.short).then_some(b.out) } pub(super) fn children_bounds(children: &[Arc]) -> Option { diff --git a/crates/engine/src/cmd/graph/labels.rs b/crates/engine/src/cmd/graph/labels.rs index 9e53c8ddd..664356499 100644 --- a/crates/engine/src/cmd/graph/labels.rs +++ b/crates/engine/src/cmd/graph/labels.rs @@ -2,7 +2,10 @@ use super::axis::LABEL_SIZE; use super::columns::{Allowance, ColumnArt}; use super::markers::fit_design; use super::series::{default_series_paint, series_marks}; -use super::*; +use super::{ + Affine, Appearance, Arc, BezPath, CharStyle, Color, Document, GraphSpec, Justify, Node, NodeId, NodeKind, Paint, PathData, Point, Rect, + TextObject, refresh_bounds, shapes, +}; pub(super) struct Gen<'a> { pub(super) d: &'a mut Document, diff --git a/crates/engine/src/cmd/graph/markers.rs b/crates/engine/src/cmd/graph/markers.rs index 91da6088d..95a738ff7 100644 --- a/crates/engine/src/cmd/graph/markers.rs +++ b/crates/engine/src/cmd/graph/markers.rs @@ -1,7 +1,7 @@ use super::designs::design_names; use super::edit::{regenerate, spec_of, target}; use super::series::{picked_series, reselect_series}; -use super::*; +use super::{Affine, Arc, Document, GraphSpec, Node, NodeKind, Rect, Result, Session, Value, bad, json}; /// Most objects a graph's marker designs may add (each data point and legend swatch draws a copy of its design). pub(super) const MARKER_BUDGET: usize = 200_000; diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 56ed7eee5..0f035b55c 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -1,5 +1,5 @@ use super::labels::{Role, label_style}; -use super::*; +use super::{CharStyle, Color, Document, GraphKind, GraphSpec, HashMap, Node, NodeId, NodeKind, Paint, Result, Session, Value, bad}; // ---------- generation ---------- diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 97e4b3c2d..a2c37a09a 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -14,7 +14,10 @@ use vectorcraft_render::effects; use super::edit::{duplicate_in, outermost, selected_roots, selected_roots_to_transform}; use super::opacitymask::percent; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_multi, has_selection, + has_selection_or_guides, id_param, ids_param, matrix_param, ok, point_param, str_param, targets, +}; use crate::{EngineError, LastTransform}; mod align; diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index 7a0c073d9..873dc08ba 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -1,5 +1,8 @@ use super::transform::transform_patterns; -use super::*; +use super::{ + Affine, Document, EngineError, NodeId, OrientedBox, Point, Rect, Result, Scaling, Session, Value, Vec2, bad, check_numbers, check_switches, + effects, has_multi, ok, selected_roots, str_param, +}; impl Session { /// The bounds of `ids` the Transform panel, the bounding box, Align and Distribute measure: diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index b60c11f4c..48061fbe3 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, id_param, json, ok, selected_roots}; pub(super) enum Arrange { Front, diff --git a/crates/engine/src/cmd/object/bounds.rs b/crates/engine/src/cmd/object/bounds.rs index 073fda3dd..2e0747170 100644 --- a/crates/engine/src/cmd/object/bounds.rs +++ b/crates/engine/src/cmd/object/bounds.rs @@ -1,6 +1,9 @@ use super::spans::Spans; use super::transform::{TRANSFORM_SWITCHES, about, apply_transform, scaling_options, transforms_objects}; -use super::*; +use super::{ + Affine, Document, EngineError, NodeId, OrientedBox, Point, Rect, Result, Scaling, Session, Value, bool_or, check_numbers, check_switches, + effects, f64_or, json, selected_roots_to_transform, +}; pub(super) fn set_bounds(s: &mut Session, p: &Value) -> Result { match bounds_transform(s, p, selected_roots_to_transform(s)?, 0.0)? { diff --git a/crates/engine/src/cmd/object/commands.rs b/crates/engine/src/cmd/object/commands.rs index 1ac0b1102..4bf072749 100644 --- a/crates/engine/src/cmd/object/commands.rs +++ b/crates/engine/src/cmd/object/commands.rs @@ -5,7 +5,7 @@ use super::compound::{clip_edit, clip_make, clip_release, compound_make, compoun use super::props::set_props; use super::shape::{expand_shape, set_live_shape}; use super::transform::{move_cmd, nudge, reflect, rotate, scale, shear, transform, transform_again}; -use super::*; +use super::{CommandSpec, cmd, has_doc, has_multi, has_selection, has_selection_or_guides}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/object/compound.rs b/crates/engine/src/cmd/object/compound.rs index 78f850d49..fcff1d56a 100644 --- a/crates/engine/src/cmd/object/compound.rs +++ b/crates/engine/src/cmd/object/compound.rs @@ -1,4 +1,6 @@ -use super::*; +use super::{ + Appearance, Arc, Document, EngineError, FillRule, Node, NodeId, NodeKind, Paint, PathData, Result, Session, Value, json, ok, selected_roots, +}; pub(super) fn compound_make(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; diff --git a/crates/engine/src/cmd/object/props.rs b/crates/engine/src/cmd/object/props.rs index 7f566ef4f..716181b1e 100644 --- a/crates/engine/src/cmd/object/props.rs +++ b/crates/engine/src/cmd/object/props.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BlendMode, EngineError, Knockout, Node, Result, Session, Value, bad, check_numbers, check_switches, ok, percent, str_param, targets}; /// The most data entries `object.setProps` takes at once. const MAX_DATA: usize = 256; diff --git a/crates/engine/src/cmd/object/shape.rs b/crates/engine/src/cmd/object/shape.rs index e29591b5a..c035a163d 100644 --- a/crates/engine/src/cmd/object/shape.rs +++ b/crates/engine/src/cmd/object/shape.rs @@ -1,5 +1,8 @@ use super::spans::Span; -use super::*; +use super::{ + BTreeSet, CornerKind, EngineError, LiveCorners, LiveShape, NodeId, NodeKind, Result, Session, Value, bad, check_numbers, check_switches, + convert_radii_relative, ok, selected_roots, set_corners, targets, +}; /// The largest scale of 0 or more at which `spans`, with the margins that scale grown by `k`, /// reach across `size` together. Returns 1 when their geometric boxes reach 1e-9 or less across diff --git a/crates/engine/src/cmd/object/spans.rs b/crates/engine/src/cmd/object/spans.rs index 48bc9f28d..57fe9aeb2 100644 --- a/crates/engine/src/cmd/object/spans.rs +++ b/crates/engine/src/cmd/object/spans.rs @@ -1,5 +1,5 @@ use super::shape::scale_across; -use super::*; +use super::{Affine, Document, Node, NodeId, NodeKind, OrientedBox, Point, Rect}; /// One axis of an object that [`set_bounds`] sizes, square to its frame: the geometric box runs /// from `lo` to `hi`, and the visual box reaches past each end (before, after) by a margin that diff --git a/crates/engine/src/cmd/object/transform.rs b/crates/engine/src/cmd/object/transform.rs index ecb6b3a11..007ffae4f 100644 --- a/crates/engine/src/cmd/object/transform.rs +++ b/crates/engine/src/cmd/object/transform.rs @@ -1,5 +1,9 @@ use super::align::choice; -use super::*; +use super::{ + Affine, Arc, Document, EngineError, LastTransform, Node, NodeId, NodeKind, Point, Result, Scaling, Session, Value, Vec2, bad, bool_or, + check_numbers, check_switches, duplicate_in, effects, f64_or, f64_req, ids_param, json, matrix_param, ok, outermost, point_param, + selected_roots_to_transform, +}; fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { if let Some(o) = point_param(p, "origin") { diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 4de797583..ccf8a1a70 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -9,7 +9,10 @@ use vectorcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2}; use super::create::anchor_from_json; use super::draw2::usize_opt; use super::pathops::Beside; -use super::*; +use super::{ + CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_anchors, has_doc, has_selection, + id_param, ids_param, index, number_or, ok, str_param, switch_or, targets, +}; use crate::EngineError; pub fn specs() -> Vec { diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 8f3e0f117..7589cab65 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -14,7 +14,7 @@ use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, PathfinderOp}; use super::edit::selected_roots; -use super::*; +use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, has_multi, has_selection, str_param}; pub fn specs() -> Vec { vec![ diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index 67c480133..579dda124 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -8,7 +8,10 @@ use vectorcraft_doc::{Node, NodeKind, Scaling}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::edit::{outermost, selected_roots}; -use super::*; +use super::{ + CommandSpec, NodeId, Result, Session, always, bad, bool_or, check_numbers, check_switches, checked_ids_param, cmd, color_value, f64_or, + find_command, finite_numbers, has_doc, index, +}; use crate::EngineError; pub fn specs() -> Vec { diff --git a/crates/engine/tests/it/pen_endpoint_picking.rs b/crates/engine/tests/it/pen_endpoint_picking.rs index b2e77e52e..49eb7feef 100644 --- a/crates/engine/tests/it/pen_endpoint_picking.rs +++ b/crates/engine/tests/it/pen_endpoint_picking.rs @@ -303,59 +303,63 @@ fn another_path_join_keeps_snapped_target_when_raw_pointer_misses() { fn snapped_only_continue(preselected: bool) { for first in [true, false] { - let mut s = session(); - let mut prefs = s.prefs.clone(); - prefs.gridline_every = 100.0; - prefs.grid_subdivisions = 1; - s.apply_prefs(prefs); - assert_eq!(s.doc().unwrap().doc.grid.spacing, 100.0); - assert_eq!(s.doc().unwrap().doc.grid.subdivisions, 1); - let id = line(&mut s, (100.0, 300.0), (200.0, 300.0)); - let original = node(&s, id).clone(); - s.execute("select.set", &json!({"ids": if preselected { vec![id.0] } else { vec![] }})).unwrap(); - let v = ViewInfo { snap_to_grid: true, ..view() }; - s.select_tool("pen", v).unwrap(); - let at = if first { (63.0, 303.0) } else { (237.0, 303.0) }; - let end = if first { Point::new(100.0, 300.0) } else { Point::new(200.0, 300.0) }; - assert_eq!(s.cursor(end, Mods::default(), v), Cursor::PenContinue, "the endpoint is eligible"); - assert_eq!(s.cursor(Point::new(at.0, at.1), Mods::default(), v), Cursor::Pen, "the raw pointer misses both ends"); - let journal_start = s.journal.len(); - let before_continue = s.doc().unwrap().history.undo.len(); - gesture(&mut s, v, &[at], Mods::default()); - - assert_eq!(path_ids(&s), vec![id], "snapped-only continuation must keep one original path: selected={preselected}, first={first}"); - assert_eq!(s.doc().unwrap().selection.objects, vec![id]); - let expected = if first { [(200.0, 300.0), (100.0, 300.0)] } else { [(100.0, 300.0), (200.0, 300.0)] }; - assert_eq!(points(&s, id), expected, "continue from the snapped end: selected={preselected}, first={first}"); - assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create")); - assert!(!s.in_interaction() && !s.tool_busy()); - assert_eq!(s.doc().unwrap().history.undo.len(), before_continue + usize::from(first)); - let continued = node(&s, id).clone(); - if !first { - assert_eq!(continued, original, "continuing from the last end does not edit the path"); - } - - let before_append = s.doc().unwrap().history.undo.len(); - let next = if first { (0.0, 300.0) } else { (300.0, 300.0) }; - gesture(&mut s, v, &[next], Mods::default()); - assert_eq!(path_ids(&s), vec![id]); - assert_eq!(s.doc().unwrap().selection.objects, vec![id]); - assert_eq!(points(&s, id), [expected[0], expected[1], next]); - assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create")); - assert!(!s.in_interaction() && !s.tool_busy()); - assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); - let extended = node(&s, id).clone(); - s.execute("edit.undo", &json!({})).unwrap(); - assert_eq!(path_ids(&s), vec![id]); - assert_eq!(node(&s, id), &continued); - assert_eq!(s.doc().unwrap().history.undo.len(), before_append); - s.execute("edit.redo", &json!({})).unwrap(); - assert_eq!(path_ids(&s), vec![id]); - assert_eq!(node(&s, id), &extended); - assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); + snapped_only_case(preselected, first); } } +fn snapped_only_case(preselected: bool, first: bool) { + let mut s = session(); + let mut prefs = s.prefs.clone(); + prefs.gridline_every = 100.0; + prefs.grid_subdivisions = 1; + s.apply_prefs(prefs); + assert_eq!(s.doc().unwrap().doc.grid.spacing, 100.0); + assert_eq!(s.doc().unwrap().doc.grid.subdivisions, 1); + let id = line(&mut s, (100.0, 300.0), (200.0, 300.0)); + let original = node(&s, id).clone(); + s.execute("select.set", &json!({"ids": if preselected { vec![id.0] } else { vec![] }})).unwrap(); + let v = ViewInfo { snap_to_grid: true, ..view() }; + s.select_tool("pen", v).unwrap(); + let at = if first { (63.0, 303.0) } else { (237.0, 303.0) }; + let end = if first { Point::new(100.0, 300.0) } else { Point::new(200.0, 300.0) }; + assert_eq!(s.cursor(end, Mods::default(), v), Cursor::PenContinue, "the endpoint is eligible"); + assert_eq!(s.cursor(Point::new(at.0, at.1), Mods::default(), v), Cursor::Pen, "the raw pointer misses both ends"); + let journal_start = s.journal.len(); + let before_continue = s.doc().unwrap().history.undo.len(); + gesture(&mut s, v, &[at], Mods::default()); + + assert_eq!(path_ids(&s), vec![id], "snapped-only continuation must keep one original path: selected={preselected}, first={first}"); + assert_eq!(s.doc().unwrap().selection.objects, vec![id]); + let expected = if first { [(200.0, 300.0), (100.0, 300.0)] } else { [(100.0, 300.0), (200.0, 300.0)] }; + assert_eq!(points(&s, id), expected, "continue from the snapped end: selected={preselected}, first={first}"); + assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create")); + assert!(!s.in_interaction() && !s.tool_busy()); + assert_eq!(s.doc().unwrap().history.undo.len(), before_continue + usize::from(first)); + let continued = node(&s, id).clone(); + if !first { + assert_eq!(continued, original, "continuing from the last end does not edit the path"); + } + + let before_append = s.doc().unwrap().history.undo.len(); + let next = if first { (0.0, 300.0) } else { (300.0, 300.0) }; + gesture(&mut s, v, &[next], Mods::default()); + assert_eq!(path_ids(&s), vec![id]); + assert_eq!(s.doc().unwrap().selection.objects, vec![id]); + assert_eq!(points(&s, id), [expected[0], expected[1], next]); + assert!(!s.journal.since(journal_start).iter().any(|(command, _)| command == "path.create")); + assert!(!s.in_interaction() && !s.tool_busy()); + assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); + let extended = node(&s, id).clone(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(path_ids(&s), vec![id]); + assert_eq!(node(&s, id), &continued); + assert_eq!(s.doc().unwrap().history.undo.len(), before_append); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(path_ids(&s), vec![id]); + assert_eq!(node(&s, id), &extended); + assert_eq!(s.doc().unwrap().history.undo.len(), before_append + 1); +} + #[test] fn selected_snapped_only_endpoint_continues_when_raw_pointer_misses() { snapped_only_continue(true);