From a134cf6f8dee085c54b349293e1c83d67a3e5345 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:27:41 -0800 Subject: [PATCH 1/4] Graphs: Graph Type is a dialog of its own with type buttons and a page per axis Object > Graph > Type... and a double click on a graph tool open Graph Type (dialog `graphType`) instead of the generic parameter dialog. A menu at the top shows Graph Options, Value Axis (Left Axis and Right Axis with the value axis on both sides) or Category Axis. Graph Options has a button per graph type, Value Axis with Separate Scales, the Style checkboxes and the options of the type picked. The axis pages hold Tick Values with Override Calculated Values, Tick Marks and Add Labels. Override Calculated Values starts from the values the axis would be calculated with for the options shown; the engine exposes them as `graph_type_subject` and `graph_calculated_axes`. With series picked by Group Selection, the type and value axis go to those series. OK changes the graph and series the dialog opened on, and with nothing changed it adds no step of history. Docs: docs/mcp.md, docs/ui-parity.md and the tool.options text. --- crates/engine/src/cmd/graph.rs | 2 + crates/engine/src/cmd/graph/edit.rs | 24 +- crates/engine/src/cmd/graph/generate.rs | 131 ++- crates/engine/src/cmd/graph/tests.rs | 52 + crates/engine/src/lib.rs | 1 + crates/mcp/src/ui_catalog.rs | 2 +- crates/ui-egui/src/dialogs.rs | 4 + crates/ui-egui/src/dialogs/command.rs | 59 - crates/ui-egui/src/dialogs/graph_type.rs | 1314 ++++++++++++++++++++++ crates/ui-egui/src/i18n/bg.tsv | 34 + crates/ui-egui/src/i18n/bs.tsv | 34 + crates/ui-egui/src/i18n/ca.tsv | 34 + crates/ui-egui/src/i18n/cs.tsv | 34 + crates/ui-egui/src/i18n/da.tsv | 34 + crates/ui-egui/src/i18n/de.tsv | 34 + crates/ui-egui/src/i18n/el.tsv | 34 + crates/ui-egui/src/i18n/es.tsv | 34 + crates/ui-egui/src/i18n/fi.tsv | 34 + crates/ui-egui/src/i18n/fr.tsv | 34 + crates/ui-egui/src/i18n/gu.tsv | 34 + crates/ui-egui/src/i18n/hi.tsv | 34 + crates/ui-egui/src/i18n/hu.tsv | 34 + crates/ui-egui/src/i18n/id.tsv | 34 + crates/ui-egui/src/i18n/it.tsv | 34 + crates/ui-egui/src/i18n/ja.tsv | 34 + crates/ui-egui/src/i18n/ka.tsv | 34 + crates/ui-egui/src/i18n/ko.tsv | 34 + crates/ui-egui/src/i18n/nl.tsv | 34 + crates/ui-egui/src/i18n/pl.tsv | 34 + crates/ui-egui/src/i18n/pt-br.tsv | 34 + crates/ui-egui/src/i18n/ru.tsv | 34 + crates/ui-egui/src/i18n/sv.tsv | 34 + crates/ui-egui/src/i18n/tr.tsv | 34 + crates/ui-egui/src/i18n/uk.tsv | 34 + crates/ui-egui/src/i18n/vi.tsv | 34 + crates/ui-egui/src/i18n/zh-hans.tsv | 34 + crates/ui-egui/src/i18n/zh-hant.tsv | 34 + crates/ui-egui/src/menus/invoke.rs | 4 +- crates/ui-egui/src/menus/ui_commands.rs | 2 +- crates/ui-egui/src/toolbar/tests.rs | 4 +- docs/mcp.md | 25 +- docs/ui-parity.md | 1 + 42 files changed, 2474 insertions(+), 103 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/graph_type.rs diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index da23bd4f04..fe33e30036 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -40,6 +40,8 @@ pub use commands::specs; pub(crate) use data::apply_csv; #[cfg(test)] use data::{fmt_value, parse_csv, to_csv}; +pub use edit::{GraphTypeSubject, graph_type_subject}; +pub use generate::calculated_axes; #[cfg(test)] use generate::generate; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/edit.rs b/crates/engine/src/cmd/graph/edit.rs index c7f6d52ea8..283c46e956 100644 --- a/crates/engine/src/cmd/graph/edit.rs +++ b/crates/engine/src/cmd/graph/edit.rs @@ -1,6 +1,6 @@ use super::axis::{MAX_TICK_MARKS, count_given, one_line}; use super::data::apply_data; -use super::generate::{children_bounds, generate}; +use super::generate::{calculated_axes, children_bounds, generate}; use super::series::{picked_series, reselect_series}; use super::{ Affine, Document, EngineError, GraphKind, GraphSpec, Node, NodeId, Rect, Result, Session, TickLength, Value, ValueAxisSide, bad, f64_req, json, @@ -97,6 +97,28 @@ 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)) } +/// What `graph.setType` without `id` or `seriesIndexes` acts on, for the Graph Type dialog. +pub struct GraphTypeSubject { + /// The selected graph, or the graph that holds the selection. + pub id: NodeId, + /// The series picked in it with Group Selection, in order; empty when the type goes to the whole graph. + pub series: Vec, + /// The least value, the greatest and the number of divisions each value axis is drawn with while its tick values + /// are calculated: the left (or only) axis, then the right axis' own scale (the left one's without Separate Scales). + pub calculated: [(f64, f64, usize); 2], +} + +/// The graph and series that `graph.setType {}` reads its options from, as [`GraphTypeSubject`]. An error without a +/// selected graph, as the command gives. +pub fn graph_type_subject(s: &Session) -> Result { + const C: &str = "graph.setType"; + let p = json!({}); + let id = target(s, &p, C)?; + let spec = spec_of(s, id)?; + let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES); + Ok(GraphTypeSubject { id, series: picked_series(s, &p, id, count, C)?, calculated: calculated_axes(&spec) }) +} + 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(); diff --git a/crates/engine/src/cmd/graph/generate.rs b/crates/engine/src/cmd/graph/generate.rs index fe8ffb98f8..c31934f999 100644 --- a/crates/engine/src/cmd/graph/generate.rs +++ b/crates/engine/src/cmd/graph/generate.rs @@ -38,6 +38,101 @@ fn nth(list: &[Option], s: usize) -> Option<&T> { list.get(s).and_then(Option::as_ref) } +/// The data a value range is read from: each series' type, the cells (a blank is `None`), which series use the right +/// axis' own scale, and the number of categories. +struct Values<'a> { + kinds: &'a [GraphKind], + cells: &'a [Vec>], + on_right: &'a [bool], + ncat: usize, +} + +impl Values<'_> { + fn cell(&self, c: usize, s: usize) -> Option { + self.cells.get(c).and_then(|r| r.get(s)).copied().flatten() + } + + fn axis(&self, s: usize) -> usize { + usize::from(self.on_right.get(s).copied().unwrap_or(false)) + } + + /// The value range per axis (stacked graphs stack positives and negatives separately), and a scatter's x range. + fn range(&self, 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(c, a, (lo, hi)); + } + } else { + for s in (0..self.kinds.len()).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(&self, c: usize, a: usize, (lo, hi): (&mut f64, &mut f64)) { + let val = |s: usize| self.cell(c, s).unwrap_or(0.0); + let on = |s: &usize, kinds: &[GraphKind]| self.axis(*s) == a && self.kinds.get(*s).is_some_and(|k| kinds.contains(k)); + // 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..self.kinds.len()).filter(|s| on(s, &stack)); + *hi = hi.max(members().map(|s| val(s).max(0.0)).sum()); + *lo = lo.min(members().map(|s| val(s).min(0.0)).sum()); + } + let single = |s: &usize| on(s, &[GraphKind::Column, GraphKind::Bar, GraphKind::Line]); + for v in (0..self.kinds.len()).filter(single).filter_map(|s| self.cell(c, s)) { + *hi = hi.max(v); + *lo = lo.min(v); + } + } +} + +/// The tick values `g`'s value axes are drawn with while they are calculated from the data (Graph Type › Tick Values +/// without Override Calculated Values): the least value, the greatest and the number of divisions of the left (or +/// only) axis, then of the right axis' own scale, which is the left one's unless Separate Scales gives the series on +/// the right axis a scale of their own. A pie graph has no value axis and gives the axis of a graph without data. A +/// single bound the graph has (only a minimum or only a maximum) is calculated with, as the axis is drawn. +pub fn calculated_axes(g: &GraphSpec) -> [(f64, f64, usize); 2] { + // Adding 0 makes a negative zero, which a nice axis around 0 can start at, a plain 0. + let divisions = |(lo, hi, step): (f64, f64, f64)| (lo + 0.0, hi + 0.0, ((hi - lo) / step).round().clamp(1.0, 100.0) as usize); + // The bounds the axis is calculated with: all but a pair that overrides the calculation. + let partial = |min: Option, max: Option| match (min, max) { + (Some(a), Some(b)) if b > a && (b - a).is_finite() => (None, None), + bounds => bounds, + }; + let (min, max) = partial(g.axis_min, g.axis_max); + if g.kind == GraphKind::Radar { + let top = g.rows.iter().flatten().copied().fold(0.0, f64::max); + return [divisions(value_axis(g.ticks, min, max, 0.0, top)); 2]; + } + let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES); + let kinds: Vec = (0..nser).map(|s| g.series_kind(s)).collect(); + let (scatter, horizontal) = (g.kind == GraphKind::Scatter, matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar)); + let separate = g.separate_scales && g.value_axis == ValueAxisSide::Both && !horizontal && !scatter; + let on_right: Vec = (0..nser).map(|s| separate && g.right_series.contains(&s)).collect(); + let cells = if g.kind == GraphKind::Pie { vec![] } else { g.cells() }; + let ncat = g.rows.len().clamp(1, vectorcraft_doc::MAX_GRAPH_CATEGORIES); + let (lo, hi, ..) = Values { kinds: &kinds, cells: &cells, on_right: &on_right, ncat }.range(scatter); + let left = divisions(value_axis(g.ticks, min, max, lo[0], hi[0])); + let used = |right: bool| on_right.contains(&right); + if !used(true) { + return [left; 2]; + } + let (right_min, right_max) = partial(g.right_axis_min, g.right_axis_max); + let right = divisions(value_axis(g.right_ticks, right_min, right_max, lo[1], hi[1])); + if used(false) { [left, right] } else { [right; 2] } +} + /// 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, @@ -526,41 +621,7 @@ impl<'a> Plot<'a> { /// 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); - } + Values { kinds: &self.kinds, cells: &self.cells, on_right: &geo.on_right, ncat: self.ncat }.range(scatter) } /// The value axes with ticks and labels, the legend's start after them, and the axis lines. diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 0a788c4330..da176ea49c 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -3848,6 +3848,58 @@ fn group_selected_series_go_on_the_chosen_axis() { assert!(spec(&s, id).right_series.is_empty()); } +/// The Graph Type dialog's subject: the selected graph, the series picked in it, and the tick values each value axis +/// is drawn with while they are calculated, which are the ones the graph's value labels show. +#[test] +fn graph_type_subject_gives_the_picked_series_and_the_calculated_tick_values() { + let mut s = Session::new(); + assert!(super::graph_type_subject(&s).is_err(), "no document"); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + assert!(super::graph_type_subject(&s).is_err(), "no graph selected"); + // Q1 3, 2; Q2 5, 4; Q3 -1, 6. + let id = graph(&mut s, "column"); + let subject = super::graph_type_subject(&s).unwrap(); + assert_eq!((subject.id, subject.series), (id, vec![])); + assert_eq!(subject.calculated, [(-2.0, 6.0, 4); 2]); + let shown = |s: &Session| value_labels(s, id).into_iter().map(|(t, _)| t).collect::>(); + assert_eq!(shown(&s), ["-2", "0", "2", "4", "6"]); + // Overriding with them draws the same axis, and the calculated values stay the data's. + s.execute("graph.setType", &json!({"axisMin": -2, "axisMax": 6, "ticks": 4})).unwrap(); + assert_eq!(shown(&s), ["-2", "0", "2", "4", "6"]); + s.execute("graph.setType", &json!({"axisMin": 0, "axisMax": 100, "ticks": 0})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated[0], (-2.0, 6.0, 4)); + // A single bound is calculated with, as the axis is drawn. + s.execute("graph.setType", &json!({"axisMin": -10, "axisMax": null})).unwrap(); + let (lo, hi, _) = super::graph_type_subject(&s).unwrap().calculated[0]; + let labels = shown(&s); + assert!(lo <= -10.0, "{lo}"); + assert_eq!((labels.first().and_then(|t| t.parse::().ok()), labels.last().and_then(|t| t.parse::().ok())), (Some(lo), Some(hi))); + s.execute("graph.setType", &json!({"axisMin": null, "axisMax": null})).unwrap(); + // Stacked columns stack their positive and their negative values: Q2 reaches 9, Q3 from -1 to 6. + s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated[0], (-2.0, 10.0, 6)); + assert_eq!(shown(&s), ["-2", "0", "2", "4", "6", "8", "10"]); + // A radar graph's axis starts at 0, and a pie graph has none. + s.execute("graph.setType", &json!({"type": "radar"})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated, [(0.0, 6.0, 3); 2]); + s.execute("graph.setType", &json!({"type": "pie"})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated, [(0.0, 1.0, 5); 2]); + // Series picked with Group Selection. + s.execute("graph.setType", &json!({"type": "column"})).unwrap(); + let picked = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id; + s.execute("select.set", &json!({"ids": [picked.0]})).unwrap(); + let subject = super::graph_type_subject(&s).unwrap(); + assert_eq!((subject.id, subject.series), (id, vec![1])); + // Separate Scales: the right axis has the tick values of its own series. + let mut s = Session::new(); + let id = two_scale_graph(&mut s); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated, [(0.0, 1000.0, 5); 2]); + s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap(); + s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap(); + assert_eq!(super::graph_type_subject(&s).unwrap().calculated, [(0.0, 10.0, 5), (0.0, 1000.0, 5)]); + assert_eq!(spec(&s, id).right_series, [1]); +} + #[test] fn bar_graphs_keep_their_value_axis_along_the_bottom() { let mut s = Session::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4b47966463..9c097c190b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -29,6 +29,7 @@ use vectorcraft_tools::{PaintDefaults, Tool}; pub use cmd::clipboard::{Clipboard, SourceLayer}; pub use cmd::create::constrain_click; pub use cmd::distortcmds::perspective_click; +pub use cmd::graph::{GraphTypeSubject, calculated_axes as graph_calculated_axes, graph_type_subject}; pub use cmd::params::{check_keys, check_params}; pub use cmd::pathops::auto_simplify_level; pub use cmd::rasterfx::{export_pdf, flatten_raster_effects}; diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index e801df4a7a..d08a38ae8a 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -937,7 +937,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "tool.options", "Tool Options…", "", - "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil, paintbrush and smooth, the same at any zoom; pt for blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), cursorSwatchPreview (on: the active fill or stroke shows above the pointer between the color swatches before and after it; Left and Right without modifiers step it through the color swatches whether it is on or off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", + "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x, y and preview (on initially), previews one there and accepts it as one Undo step; Cancel rolls back the preview), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph, or for the series picked in it with Group Selection (dialog `graphType`, fields the options graph.setType {} returns under their names, with legend, pieLegend, piePosition, pieSort, firstRowInFront, firstColumnInFront and filledLines held for every type; overrideValues and rightOverrideValues, Override Calculated Values of the left or only value axis and of the right one: while one is on OK sends axisMin, axisMax and ticks, or rightAxisMin, rightAxisMax and rightTicks, filled with the calculated values where the graph has none, and one turned off sends null, null and 0; __page: options|valueAxis|rightAxis|categoryAxis, the page shown, options when the type picked has no such page; OK runs graph.setType, and with nothing changed closes without a step of history; an overridden value axis whose maximum is not more than its minimum is an error and the dialog stays open; an error without a selected graph), typeOnPath|verticalTypeOnPath → Type on a Path Options for the selected type on a path (dialog `command` running type.pathOptions; error `nothing selected` without a selection, and the command's error when no type on a path is selected),symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (px for pencil, paintbrush and smooth, the same at any zoom; pt for blobBrush), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), livePreview (pencil, on: off draws a thin line while the stroke is drawn, and the path's fill and stroke on release), size (pt), angle (°, −180–180; 0) and roundness (%, 1–100; 100) of the eraser's tip; OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), livePaintBucket → Live Paint Bucket Options (dialog `livePaintBucketOptions`, fields paintFills (on) and paintStrokes (off), cursorSwatchPreview (on: the active fill or stroke shows above the pointer between the color swatches before and after it; Left and Right without modifiers step it through the color swatches whether it is on or off), highlight (on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the face highlight's width, 1–20 px; 4; an edge's highlight is half as wide); OK runs tool.setOption {tool: \"livePaintBucket\", values}; a color with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values}; a highlightColor with an alpha part (#rrggbbaa or #rgba) is an error and OK changes nothing), objectsOnPath → Objects on Path Tool Options (dialog `objectsOnPathOptions`, fields pivot (0–8, row by row from the top left; 4, the center) and rotate (°, −360–360; 0); Reset puts 4 and 0 back; Save runs tool.setOption {tool: \"objectsOnPath\", values}: the objects attached next use them, groups attached before keep theirs)", ), ( "ui.colorGuideOptions", diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 5afad21b0d..0a11a16afa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -56,6 +56,7 @@ pub mod freehand; mod gradient_stop; pub mod graph_data; pub mod graph_design; +pub mod graph_type; pub mod graphic_style_options; pub mod halftone; pub mod hyphenation; @@ -339,6 +340,7 @@ registry! { GraphDesign: [graph_design::DESIGN] => graph_design::DESIGN_SPEC, GraphColumn: [graph_design::COLUMN] => graph_design::COLUMN_SPEC, GraphMarker: [graph_design::MARKER] => graph_design::MARKER_SPEC, + GraphType: [graph_type::KIND] => graph_type::SPEC, GradientStop: ["gradientStop"] => gradient_stop::SPEC, ColorPicker: [color_picker::KIND] => color_picker::SPEC, GraphicStyleOptions: [graphic_style_options::KIND] => graphic_style_options::SPEC, @@ -514,6 +516,8 @@ fn hidden_controls(d: &Dialog) -> &'static [&'static str] { Some(DialogKind::FileInfo) => &["__keyword"], // The preset name being typed. Some(DialogKind::FlattenTransparency) => &["__presetName"], + // The page shown. + Some(DialogKind::GraphType) => &[graph_type::PAGE], // The Resolution menu's Other. Some(DialogKind::RasterOptions | DialogKind::RasterFormatOptions | DialogKind::PsdOptions) => &["__otherPpi"], // The category shown, the search text and the three settings kept outside the preferences. diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 6656b45bf5..4e43ad904c 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -1,8 +1,6 @@ //! The generic parameter dialog (`ui.paramDialog`): edits a command's parameters (`__command`, //! headed `__label`) and runs it on OK, with the fixed ones in `__params` (not shown) added. -use std::sync::LazyLock; - use serde_json::{Value, json}; use super::{DialogSpec, form}; @@ -173,12 +171,6 @@ fn choices(command: &str, key: &str) -> Option { ("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE), ("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN), (crate::addons::cutting::WEED, "direction") => Some(crate::addons::cutting::WEED_DIRECTIONS), - ("graph.setType", "type") => Some(GRAPH_KINDS.as_slice()), - ("graph.setType", "valueAxis") => Some(GRAPH_VALUE_AXIS), - ("graph.setType", "tickLength" | "rightTickLength" | "categoryTickLength") => Some(GRAPH_TICK_LENGTH), - ("graph.setType", "pieLegend") => Some(PIE_LEGEND), - ("graph.setType", "piePosition") => Some(PIE_POSITION), - ("graph.setType", "pieSort") => Some(PIE_SORT), (crate::addons::image::REMOVE_BACKGROUND | crate::addons::image::COLOR_MASK, "background") => Some(crate::addons::image::BACKGROUNDS), (crate::addons::image::REMOVE_BACKGROUND | crate::addons::image::COLOR_MASK, "match") => Some(crate::addons::image::MATCHES), _ => None, @@ -211,21 +203,6 @@ const FIRST_BASELINE: form::Choices = /// Area Type Options › Align (vertical alignment of the lines in each row/column). const VERTICAL_ALIGN: form::Choices = &[("Top", "top"), ("Center", "center"), ("Bottom", "bottom"), ("Justify", "justify")]; -/// Graph Type › Type: the nine graph kinds, in the Graph tool group's order. -static GRAPH_KINDS: LazyLock> = - LazyLock::new(|| vectorcraft_doc::GraphKind::ALL.iter().map(|k| (k.label(), k.id())).collect()); - -/// Graph Type › Value Axis (series picked on both axes show none, and OK leaves each where it is). -const GRAPH_VALUE_AXIS: form::Choices = &[("On Left Side", "left"), ("On Right Side", "right"), ("On Both Sides", "both")]; - -/// Graph Type › Legend, Position and Sort for pie graphs. -const PIE_LEGEND: form::Choices = &[("No Legend", "none"), ("Standard Legend", "standard"), ("Legends in Wedges", "wedges")]; -const PIE_POSITION: form::Choices = &[("Ratio", "ratio"), ("Even", "even"), ("Stacked", "stacked")]; -const PIE_SORT: form::Choices = &[("All", "all"), ("First", "first"), ("None", "none")]; - -/// Graph Type › Tick Marks › Length. -const GRAPH_TICK_LENGTH: form::Choices = &[("None", "none"), ("Short", "short"), ("Full Width", "full")]; - /// Closes before running, so a dialog the command opens stays open. A previewed one keeps its /// preview as the one undo step. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { @@ -402,42 +379,6 @@ mod tests { assert!(p[0] == p[1] && p[1] == p[2] && p[0] < 128, "red in grayscale: {p:?}"); } - /// Graph Type › Value Axis is a dropdown of the values `graph.setType` takes, and OK puts the - /// value axis on the side picked. - #[test] - fn graph_type_value_axis_is_a_dropdown_of_its_sides() { - let doc = vectorcraft_engine::find_command("graph.setType").unwrap().params; - let values: Vec<&str> = choices("graph.setType", "valueAxis").unwrap().iter().map(|(_, v)| *v).collect(); - assert!(doc.contains(&format!("valueAxis?: {}", values.join("|"))), "{values:?}"); - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); - app.run("graph.create", json!({"type": "column", "x": 0, "y": 0, "width": 200, "height": 150})).unwrap(); - crate::menus::graph_dialog(&mut app, "graph.setType").unwrap(); - assert_eq!(app.ui.dialog.as_ref().unwrap().str("valueAxis"), "left"); - app.ui.dialog.as_mut().unwrap().fields.insert("valueAxis".into(), json!("both")); - crate::dialogs::confirm(&mut app).unwrap(); - assert_eq!(app.session.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "both"); - } - - /// Graph Type › Type is a dropdown of the nine graph kinds (#2175), and OK changes the graph's - /// type and keeps its data. - #[test] - fn graph_type_is_a_dropdown_of_the_graph_kinds() { - let kinds: Vec<(&str, &str)> = vectorcraft_doc::GraphKind::ALL.iter().map(|k| (k.label(), k.id())).collect(); - assert_eq!(choices("graph.setType", "type").unwrap().to_vec(), kinds); - let mut app = VectorcraftApp::new(Session::new(), Default::default()); - app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); - app.run("graph.create", json!({"type": "column", "x": 0, "y": 0, "width": 200, "height": 150, "csv": ",a,b\nQ1,1,2\nQ2,3,4"})).unwrap(); - let data = app.session.execute("graph.setData", &json!({})).unwrap(); - assert_eq!(data["rows"], json!([[1.0, 2.0], [3.0, 4.0]])); - crate::menus::graph_dialog(&mut app, "graph.setType").unwrap(); - assert_eq!(app.ui.dialog.as_ref().unwrap().str("type"), "column"); - app.ui.dialog.as_mut().unwrap().fields.insert("type".into(), json!("stackedBar")); - crate::dialogs::confirm(&mut app).unwrap(); - assert_eq!(app.session.execute("graph.setType", &json!({})).unwrap()["type"], "stackedBar"); - assert_eq!(app.session.execute("graph.setData", &json!({})).unwrap(), data); - } - /// A document with a rectangle made a 2 x 2 Grid Repeat and selected, its Repeat Options /// dialog open. fn grid_options_app() -> VectorcraftApp { diff --git a/crates/ui-egui/src/dialogs/graph_type.rs b/crates/ui-egui/src/dialogs/graph_type.rs new file mode 100644 index 0000000000..14bc371e74 --- /dev/null +++ b/crates/ui-egui/src/dialogs/graph_type.rs @@ -0,0 +1,1314 @@ +//! Object › Graph › Type…: Graph Type, for the selected graph or for the series picked in it with +//! Group Selection. +//! +//! A menu at the top shows one page at a time. Graph Options has the Type buttons, the Value Axis +//! side with Separate Scales, the Style checkboxes and the options of the type picked: Column or +//! Bar Width and Cluster Width, the data point and line checkboxes, or a pie graph's Legend, +//! Position and Sort. A page per axis follows for the axes the type has: Value Axis (Left Axis and +//! Right Axis while the value axis is on both sides) with Tick Values, Tick Marks and Add Labels, +//! and Category Axis with Tick Marks. OK runs `graph.setType`, and with nothing changed it closes +//! without a step of history. +//! +//! Fields: the options `graph.setType {}` returns under the command's names for them, with +//! `legend`, `pieLegend`, `piePosition`, `pieSort`, `firstRowInFront`, `firstColumnInFront` and +//! `filledLines` held for every type, and `type` or `valueAxis` null while the picked series +//! differ in it (OK then leaves each series as it is); `overrideValues` and `rightOverrideValues` (Override +//! Calculated Values of the left or only value axis and of the right one: while one is on, OK +//! sends `axisMin`, `axisMax` and `ticks`, or `rightAxisMin`, `rightAxisMax` and `rightTicks`); and +//! `__page`, the page shown (`options`, `valueAxis`, `rightAxis` or `categoryAxis`). + +use std::ops::RangeInclusive; + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::GraphKind; + +use super::{DialogSpec, form, run_and_close}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Graph Type. +pub const KIND: &str = "graphType"; + +/// The field that holds the page shown. +pub(super) const PAGE: &str = "__page"; + +const COMMAND: &str = "graph.setType"; + +/// The graph the dialog opened on, the series picked with Group Selection, the graph's own type and +/// value axis side, each series' own type, the series on the right axis, the tick values each value axis is calculated with for the +/// options the dialog shows and the options they were calculated for, and the parameters OK would +/// have sent as the dialog opened. +const GRAPH_ID: &str = "__graphId"; +const SERIES: &str = "__series"; +const GRAPH: &str = "__graph"; +const GRAPH_AXIS: &str = "__graphAxis"; +const SERIES_KINDS: &str = "__seriesKinds"; +const RIGHT_SERIES: &str = "__rightSeries"; +const CALCULATED: &str = "__calculated"; +const CALCULATED_FOR: &str = "__calculatedFor"; +const OPENED: &str = "__opened"; + +const LABEL_W: f32 = 170.0; +const FIELD_W: f32 = 160.0; +const NUMBER_W: f32 = 72.0; +const TYPE_BUTTON: f32 = 34.0; + +/// The widths `graph.setType` takes for columns, bars and clusters, in percent. +const WIDTHS: RangeInclusive = 1.0..=1000.0; +/// The divisions it takes for a value axis, and the tick marks it takes per division. +const DIVISIONS: RangeInclusive = 1.0..=100.0; +const TICK_MARKS: RangeInclusive = 1.0..=20.0; +/// The characters it keeps of a prefix or suffix. +const AFFIX_CHARS: usize = 64; +/// The divisions it draws an overridden value axis in while the axis has no count of its own. +const DEFAULT_DIVISIONS: usize = 5; + +/// The types whose value axis goes on a side, those whose right axis takes a scale of its own, and +/// those with a category axis. +const SIDED: [GraphKind; 5] = [GraphKind::Column, GraphKind::StackedColumn, GraphKind::Line, GraphKind::Area, GraphKind::Scatter]; +const SCALED: [GraphKind; 4] = [GraphKind::Column, GraphKind::StackedColumn, GraphKind::Line, GraphKind::Area]; +const CATEGORIES: [GraphKind; 6] = + [GraphKind::Column, GraphKind::StackedColumn, GraphKind::Bar, GraphKind::StackedBar, GraphKind::Line, GraphKind::Area]; +/// The types drawn as columns or bars, and those drawn as data points and lines. +const COLUMNS: [GraphKind; 4] = [GraphKind::Column, GraphKind::StackedColumn, GraphKind::Bar, GraphKind::StackedBar]; +const LINES: [GraphKind; 3] = [GraphKind::Line, GraphKind::Scatter, GraphKind::Radar]; + +/// Value Axis for the whole graph and for picked series (a series goes on one axis), Tick Marks › +/// Length, and a pie graph's Legend, Position and Sort, each as (value, label). +const SIDES: [(&str, &str); 3] = [("left", "On Left Side"), ("right", "On Right Side"), ("both", "On Both Sides")]; +const SERIES_SIDES: [(&str, &str); 2] = [("left", "On Left Side"), ("right", "On Right Side")]; +const TICK_LENGTHS: [(&str, &str); 3] = [("none", "None"), ("short", "Short"), ("full", "Full Width")]; +const PIE_LEGENDS: [(&str, &str); 3] = [("none", "No Legend"), ("standard", "Standard Legend"), ("wedges", "Legends in Wedges")]; +const PIE_POSITIONS: [(&str, &str); 3] = [("ratio", "Ratio"), ("even", "Even"), ("stacked", "Stacked")]; +const PIE_SORTS: [(&str, &str); 3] = [("all", "All"), ("first", "First"), ("none", "None")]; + +/// The options OK always sends. `valueAxis` is left out while the picked series are on both axes, +/// and the legend and the tick values follow the type and Override Calculated Values. +const SENT: [&str; 24] = [ + "type", + "valueAxis", + "separateScales", + "dropShadow", + "legendAcrossTop", + "firstRowInFront", + "firstColumnInFront", + "columnWidth", + "clusterWidth", + "markPoints", + "connectPoints", + "edgeToEdge", + "filledLines", + "tickLength", + "tickMarks", + "rightTickLength", + "rightTickMarks", + "categoryTickLength", + "categoryTickMarks", + "ticksBetweenLabels", + "prefix", + "suffix", + "rightPrefix", + "rightSuffix", +]; + +/// The fields of one value axis: its place in the calculated tick values, Override Calculated +/// Values as the dialog shows it and as it opened, the tick values, the tick marks and the labels. +struct Axis { + index: usize, + overridden: &'static str, + opened_overridden: &'static str, + min: &'static str, + max: &'static str, + ticks: &'static str, + length: &'static str, + marks: &'static str, + prefix: &'static str, + suffix: &'static str, +} + +const LEFT: Axis = Axis { + index: 0, + overridden: "overrideValues", + opened_overridden: "__override", + min: "axisMin", + max: "axisMax", + ticks: "ticks", + length: "tickLength", + marks: "tickMarks", + prefix: "prefix", + suffix: "suffix", +}; + +const RIGHT: Axis = Axis { + index: 1, + overridden: "rightOverrideValues", + opened_overridden: "__rightOverride", + min: "rightAxisMin", + max: "rightAxisMax", + ticks: "rightTicks", + length: "rightTickLength", + marks: "rightTickMarks", + prefix: "rightPrefix", + suffix: "rightSuffix", +}; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Graph Type").into(), + body, + confirm, + ok_enabled: Some(|_, d| problem(d).is_none()), + min_width: 440.0, + ..DialogSpec::FORM +}; + +/// Open Graph Type on the options of the selected graph, or of the series picked in it with Group +/// Selection. An error without a selected graph. +pub fn open(app: &mut VectorcraftApp) -> Result { + let queried = app.session.execute(COMMAND, &json!({})).map_err(|e| e.to_string())?; + let subject = vectorcraft_engine::graph_type_subject(&app.session).map_err(|e| e.to_string())?; + let st = app.session.active().ok_or("no document open")?; + let spec = st.doc.node(subject.id).and_then(|n| n.extras().graph.as_deref()).ok_or("select a graph")?; + let mut fields = queried.as_object().cloned().unwrap_or_default(); + // The options the query leaves out for the graph's types: the dialog shows each one once the + // type picked uses it. + let pie_legend = match (spec.legend, spec.pie_legend_in_wedges) { + (false, _) => "none", + (true, true) => "wedges", + (true, false) => "standard", + }; + for (key, value) in [ + ("firstRowInFront", json!(spec.first_row_in_front)), + ("firstColumnInFront", json!(spec.first_column_in_front)), + ("filledLines", json!(spec.filled_lines)), + ("legend", json!(spec.legend)), + ("pieLegend", json!(pie_legend)), + ("piePosition", json!(spec.pie_position.id())), + ("pieSort", json!(spec.pie_sort.id())), + ] { + fields.entry(key).or_insert(value); + } + // Picked series of different types show no type, and OK leaves each one's type as it is. + let mut picked_kinds = subject.series.iter().map(|i| spec.series_kind(*i)); + if picked_kinds.next().is_some_and(|first| picked_kinds.any(|kind| kind != first)) { + fields.insert("type".into(), Value::Null); + } + let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES); + let series_kinds: Vec> = (0..count).map(|i| spec.series_kinds.get(i).copied().flatten().map(GraphKind::id)).collect(); + let calculated: Vec = subject.calculated.iter().map(|(lo, hi, divisions)| json!([lo, hi, divisions])).collect(); + for (axis, min, max) in [(&LEFT, spec.axis_min, spec.axis_max), (&RIGHT, spec.right_axis_min, spec.right_axis_max)] { + let overridden = json!(min.is_some() && max.is_some()); + fields.insert(axis.overridden.into(), overridden.clone()); + fields.insert(axis.opened_overridden.into(), overridden); + } + fields.insert(GRAPH_ID.into(), json!(subject.id.0)); + fields.insert(SERIES.into(), json!(subject.series)); + fields.insert(GRAPH.into(), json!(spec.kind.id())); + fields.insert(GRAPH_AXIS.into(), json!(spec.value_axis.id())); + fields.insert(SERIES_KINDS.into(), json!(series_kinds)); + fields.insert(RIGHT_SERIES.into(), json!(spec.right_series.iter().filter(|i| **i < count).collect::>())); + fields.insert(CALCULATED.into(), json!(calculated)); + fields.insert(PAGE.into(), json!("options")); + let mut d = Dialog { kind: KIND.into(), fields }; + let key = pending(&d); + d.fields.insert(CALCULATED_FOR.into(), key); + settle(&mut d); + let opened = params(&d); + d.fields.insert(OPENED.into(), opened); + app.ui.dialog = Some(d); + Ok(json!({ "dialog": KIND })) +} + +/// The series the type and the value axis go to: those picked with Group Selection. None: the +/// whole graph. +fn picked(d: &Dialog) -> Vec { + let indexes = d.fields.get(SERIES).and_then(Value::as_array); + indexes.map(|a| a.iter().filter_map(|v| v.as_u64().and_then(|i| usize::try_from(i).ok())).collect()).unwrap_or_default() +} + +/// The type the Type buttons show. +fn chosen(d: &Dialog) -> Option { + d.fields.get("type").and_then(Value::as_str).and_then(GraphKind::parse) +} + +/// The graph's own type as OK leaves it: the type picked, or with series picked the type the graph +/// has. +fn graph_kind(d: &Dialog) -> GraphKind { + let own = d.fields.get(GRAPH).and_then(Value::as_str).and_then(GraphKind::parse).unwrap_or_default(); + if picked(d).is_empty() { chosen(d).unwrap_or(own) } else { own } +} + +/// The type each series draws as once OK is pressed: the type picked for the picked series (their +/// own while they differ and none is picked), its own type where that combines with the graph's, +/// else the graph's. +fn series_kinds(d: &Dialog) -> Vec { + let (graph, picked, chosen) = (graph_kind(d), picked(d), chosen(d)); + let own: Vec> = d + .fields + .get(SERIES_KINDS) + .and_then(Value::as_array) + .map(|a| a.iter().map(|v| v.as_str().and_then(GraphKind::parse)).collect()) + .unwrap_or_default(); + (0..own.len().max(1)) + .map(|i| { + let kept = own.get(i).copied().flatten(); + let kind = if picked.contains(&i) { chosen.or(kept) } else { kept }; + kind.filter(|k| k.combines_with(graph)).unwrap_or(graph) + }) + .collect() +} + +/// Whether a series draws as one of `kinds` once OK is pressed. +fn draws(d: &Dialog, kinds: &[GraphKind]) -> bool { + series_kinds(d).iter().any(|k| kinds.contains(k)) +} + +/// The side or sides the graph's value axis is on once OK is pressed. With series picked, Value +/// Axis is theirs and the graph keeps its own. +fn graph_axis(d: &Dialog) -> String { + d.str(if picked(d).is_empty() { "valueAxis" } else { GRAPH_AXIS }) +} + +/// Whether the graph draws a value axis on both sides. +fn both_sides(d: &Dialog) -> bool { + SIDED.contains(&graph_kind(d)) && graph_axis(d) == "both" +} + +/// Whether the right value axis has a scale of its own: Separate Scales with the value axis on +/// both sides of a type that takes one. +fn separate(d: &Dialog) -> bool { + d.bool("separateScales") && both_sides(d) && SCALED.contains(&graph_kind(d)) +} + +/// Whether the graph draws a legend beside or above the plot. +fn legend_shown(d: &Dialog) -> bool { + if graph_kind(d) == GraphKind::Pie { d.str("pieLegend") == "standard" } else { d.bool("legend") } +} + +/// The pages the menu lists for the type picked, as (id, name): Graph Options, a page per value +/// axis side (none for a pie graph) and Category Axis for the types that have one. +fn pages(d: &Dialog) -> Vec<(&'static str, &'static str)> { + let kind = graph_kind(d); + let mut pages = vec![("options", tl!("Graph Options"))]; + if kind != GraphKind::Pie { + if both_sides(d) { + pages.extend([("valueAxis", tl!("Left Axis")), ("rightAxis", tl!("Right Axis"))]); + } else { + pages.push(("valueAxis", tl!("Value Axis"))); + } + } + if CATEGORIES.contains(&kind) { + pages.push(("categoryAxis", tl!("Category Axis"))); + } + pages +} + +/// The least value, the greatest and the divisions `axis` is drawn with while its tick values are +/// calculated. +fn calculated(d: &Dialog, axis: &Axis) -> (f64, f64, usize) { + let of = |i: usize| d.fields.get(CALCULATED)?.get(axis.index)?.get(i)?.as_f64(); + let divisions = of(2).filter(|n| *n >= 1.0).map_or(DEFAULT_DIVISIONS, |n| n.min(*DIVISIONS.end()) as usize); + (of(0).unwrap_or(0.0), of(1).unwrap_or(1.0), divisions) +} + +/// The series on the right value axis once OK is pressed: the graph's, with the picked series moved +/// to the side picked for them. +fn right_series(d: &Dialog) -> Vec { + let mut right: Vec = d + .fields + .get(RIGHT_SERIES) + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(|v| v.as_u64().and_then(|i| usize::try_from(i).ok())).collect()) + .unwrap_or_default(); + let picked = picked(d); + match d.fields.get("valueAxis").and_then(Value::as_str) { + Some("right") => right.extend(picked.into_iter().filter(|i| !right.contains(i)).collect::>()), + Some("left") => right.retain(|i| !picked.contains(i)), + _ => {} + } + right +} + +/// Whether the right axis' own scale (`right`) or the left one draws a value axis: the left scale +/// draws both sides without Separate Scales, and with it each scale needs a series on its side (the +/// other side's scale draws both sides when one side has none). +fn scale_used(d: &Dialog, right: bool) -> bool { + if !separate(d) { + return !right; + } + let on_right = right_series(d); + let count = series_kinds(d).len(); + (0..count).any(|i| on_right.contains(&i) == right) +} + +/// The options the calculated tick values depend on, as the dialog shows them: the graph's type, +/// each series' type, the value axis side, Separate Scales, and the side picked series go on. +fn pending(d: &Dialog) -> Value { + let kinds: Vec<&str> = series_kinds(d).into_iter().map(GraphKind::id).collect(); + let side = d.fields.get("valueAxis").cloned().unwrap_or(Value::Null); + json!([graph_kind(d).id(), kinds, graph_axis(d), d.bool("separateScales"), side]) +} + +/// Calculate the tick values again when the options they depend on changed since they were last +/// calculated: the graph with the type, the value axis side and Separate Scales the dialog shows, and +/// the picked series on the type and side picked for them. +fn recalculate(app: &VectorcraftApp, d: &mut Dialog) { + let key = pending(d); + if d.fields.get(CALCULATED_FOR) == Some(&key) { + return; + } + let id = d.fields.get(GRAPH_ID).and_then(Value::as_u64).map(vectorcraft_doc::NodeId); + let Some(spec) = id.and_then(|id| app.session.active()?.doc.node(id)?.extras().graph.as_deref().cloned()) else { + return; + }; + let mut spec = spec; + let graph = graph_kind(d); + spec.series_kinds = series_kinds(d).into_iter().map(|kind| (kind != graph).then_some(kind)).collect(); + spec.kind = graph; + if let Some(side) = vectorcraft_doc::ValueAxisSide::parse(&graph_axis(d)) { + spec.value_axis = side; + } + spec.separate_scales = d.bool("separateScales"); + spec.right_series = right_series(d); + let calculated: Vec = + vectorcraft_engine::graph_calculated_axes(&spec).iter().map(|(lo, hi, divisions)| json!([lo, hi, divisions])).collect(); + let before = d.fields.insert(CALCULATED.into(), json!(calculated)); + d.fields.insert(CALCULATED_FOR.into(), key); + // An overridden axis that still holds the values calculated before takes the new ones. + for axis in [&LEFT, &RIGHT] { + let was = |i: usize| before.as_ref()?.get(axis.index)?.get(i)?.as_f64(); + let keys = [axis.min, axis.max, axis.ticks]; + if d.bool(axis.overridden) && keys.iter().enumerate().all(|(i, key)| number(d, key).is_some_and(|v| Some(v) == was(i))) { + for key in keys { + d.fields.remove(key); + } + } + } +} + +/// Field `key` as a number. `None` when it is missing, null or not a number. +fn number(d: &Dialog, key: &str) -> Option { + d.fields.get(key).and_then(Value::as_f64) +} + +/// The least and greatest value `axis` shows: its own, else the calculated ones. +fn bounds(d: &Dialog, axis: &Axis) -> (f64, f64) { + let (lo, hi, _) = calculated(d, axis); + (number(d, axis.min).unwrap_or(lo), number(d, axis.max).unwrap_or(hi)) +} + +/// The divisions `axis` shows: its own count, else the count it is drawn in. +fn divisions(d: &Dialog, axis: &Axis) -> usize { + let own = number(d, axis.ticks).filter(|n| *n >= 1.0).map(|n| n.min(*DIVISIONS.end()) as usize); + let both = number(d, axis.min).is_some() && number(d, axis.max).is_some(); + own.unwrap_or(if both { DEFAULT_DIVISIONS } else { calculated(d, axis).2 }) +} + +/// Give each axis whose Override Calculated Values is on the tick values it lacks: the calculated +/// ones, which the fields then show and OK sends. +fn settle(d: &mut Dialog) { + for axis in [&LEFT, &RIGHT] { + if !d.bool(axis.overridden) { + continue; + } + let ((lo, hi), count) = (bounds(d, axis), divisions(d, axis)); + for (key, value) in [(axis.min, json!(lo)), (axis.max, json!(hi))] { + if number(d, key).is_none() { + d.fields.insert(key.into(), value); + } + } + if number(d, axis.ticks).is_none_or(|n| n < 1.0) { + d.fields.insert(axis.ticks.into(), json!(count)); + } + } +} + +/// Why OK can't apply the dialog: an overridden value axis whose maximum is not more than its +/// minimum, which `graph.setType` would draw with calculated values instead. +fn problem(d: &Dialog) -> Option<&'static str> { + let out_of_order = |axis: &Axis| { + let (lo, hi) = bounds(d, axis); + d.bool(axis.overridden) && !(hi > lo && (hi - lo).is_finite()) + }; + let refused = graph_kind(d) != GraphKind::Pie && [&LEFT, &RIGHT].into_iter().any(|axis| scale_used(d, axis.index == 1) && out_of_order(axis)); + refused.then(|| tl!("Maximum must be more than Minimum.")) +} + +/// `graph.setType`'s parameters for the dialog as it is. The type and the value axis go to the +/// series picked with Group Selection, as the command reads the selection. +fn params(d: &Dialog) -> Value { + let mut d = d.clone(); + settle(&mut d); + let mut p = Map::new(); + let send = |p: &mut Map, key: &str| { + if let Some(v) = d.fields.get(key).filter(|v| !v.is_null()) { + p.insert(key.into(), v.clone()); + } + }; + for key in SENT { + send(&mut p, key); + } + // A pie graph's Legend menu stands for the legend checkbox. + let legend: &[&str] = if graph_kind(&d) == GraphKind::Pie { &["pieLegend", "piePosition", "pieSort"] } else { &["legend"] }; + for key in legend { + send(&mut p, key); + } + for axis in [&LEFT, &RIGHT] { + if d.bool(axis.overridden) { + for key in [axis.min, axis.max, axis.ticks] { + send(&mut p, key); + } + } else if d.bool(axis.opened_overridden) { + // Turned off: the tick values are calculated again. + p.extend([(axis.min.into(), Value::Null), (axis.max.into(), Value::Null), (axis.ticks.into(), json!(0))]); + } + } + Value::Object(p) +} + +/// Whether two sets of parameters hold the same values, a count being the same as the number a +/// field gives for it. +fn same(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => x.as_f64() == y.as_f64(), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| same(v, w))), + _ => a == b, + } +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut d = d.clone(); + recalculate(app, &mut d); + if let Some(why) = problem(&d) { + return Err(why.into()); + } + let mut p = params(&d); + // Nothing changed: no step of history. + if d.fields.get(OPENED).is_some_and(|opened| same(opened, &p)) { + app.ui.dialog = None; + return Ok(Value::Null); + } + aim(app, &d, &mut p); + run_and_close(app, COMMAND, p) +} + +/// Point `p` at the graph and the series the dialog opened on when the selection no longer holds +/// them: `id`, and `seriesIndexes` while series are picked and a type or a value axis goes to them. +/// While the selection still holds them, the command reads it and keeps picked series selected. +fn aim(app: &VectorcraftApp, d: &Dialog, p: &mut Value) { + let Some(id) = d.fields.get(GRAPH_ID).and_then(Value::as_u64) else { + return; + }; + let now = vectorcraft_engine::graph_type_subject(&app.session).ok(); + if now.is_some_and(|now| now.id.0 == id && now.series == picked(d)) { + return; + } + let Some(p) = p.as_object_mut() else { + return; + }; + p.insert("id".into(), json!(id)); + let picked = picked(d); + // Picked series of different types on both axes send neither, and the other options go to the + // whole graph. + if !picked.is_empty() && (p.contains_key("type") || p.contains_key("valueAxis")) { + p.insert("seriesIndexes".into(), json!(picked)); + } +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + recalculate(app, d); + settle(d); + let pages = pages(d); + let (page, shown) = pages.iter().find(|(id, _)| *id == d.str(PAGE)).or(pages.first()).copied().unwrap_or(("options", "")); + let names: Vec<&str> = pages.iter().map(|(_, name)| *name).collect(); + let pick = widgets::dropdown_names_named(ui, "graph-type-page", Some(tl!("Graph Type")), shown, &names, FIELD_W); + if let Some((id, _)) = pick.and_then(|i| pages.get(i)) { + d.fields.insert(PAGE.into(), json!(id)); + } + ui.add_space(8.0); + match page { + "valueAxis" => value_page(ui, d, false), + "rightAxis" => value_page(ui, d, true), + "categoryAxis" => category_page(ui, d), + _ => options_page(ui, d), + } + if let Some(why) = problem(d) { + ui.add_space(6.0); + ui.colored_label(Tokens::get(ui.ctx()).error, why); + } + false +} + +/// `text` with the colon of a field label. +fn colon(text: &str) -> String { + format!("{text}:") +} + +/// A checkbox bound to `d.fields[key]`, dimmed while the type picked has no use for it. +fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, enabled: bool) { + let on = d.bool(key); + if widgets::check(ui, label, on, enabled) { + d.fields.insert(key.into(), json!(!on)); + } +} + +/// A dropdown row bound to the string `d.fields[key]`; `options` are (value, label). A value that +/// is not among them, such as the null of series on both value axes, shows a blank field. +fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[(&str, &str)]) { + widgets::label_row(ui, label, LABEL_W, |ui| { + let current = d.fields.get(key).and_then(Value::as_str).unwrap_or_default(); + let shown = options.iter().find(|(value, _)| *value == current).map_or("", |(_, label)| *label); + let labels: Vec<&str> = options.iter().map(|(_, label)| *label).collect(); + if let Some((value, _)) = widgets::dropdown(ui, ("graph-type", key), shown, &labels, FIELD_W).and_then(|i| options.get(i)) { + d.fields.insert(key.into(), json!(value)); + } + }); +} + +/// A number row bound to `d.fields[key]`, kept within `range` and rounded to `decimals` places. +fn ranged(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, shown: f64, (range, suffix, decimals): (RangeInclusive, &str, usize)) { + widgets::label_row(ui, label, LABEL_W, |ui| { + if let Some(v) = widgets::range_field(ui, ("graph-type", key), shown, range, suffix, decimals, NUMBER_W) { + d.fields.insert(key.into(), json!(v)); + } + }); +} + +/// A text row bound to `d.fields[key]` that takes the characters `graph.setType` keeps. +fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { + widgets::label_row(ui, label, LABEL_W, |ui| { + form::text_edit_limited(ui, d, key, FIELD_W, AFFIX_CHARS); + }); +} + +fn options_page(ui: &mut egui::Ui, d: &mut Dialog) { + widgets::subheader(ui, tl!("Type")); + type_row(ui, d); + ui.add_space(4.0); + let kind = graph_kind(d); + ui.add_enabled_ui(SIDED.contains(&kind), |ui| { + let sides: &[(&str, &str)] = if picked(d).is_empty() { &SIDES } else { &SERIES_SIDES }; + choice(ui, d, "valueAxis", &colon(tl!("Value Axis")), sides); + }); + let scaled = both_sides(d) && SCALED.contains(&kind); + check(ui, d, "separateScales", tl!("Separate Scales"), scaled); + ui.add_space(8.0); + widgets::subheader(ui, tl!("Style")); + let columns = draws(d, &COLUMNS); + egui::Grid::new("graph-type-style").num_columns(2).spacing([24.0, 6.0]).show(ui, |ui| { + check(ui, d, "dropShadow", tl!("Add Drop Shadow"), true); + check(ui, d, "firstRowInFront", tl!("First Row in Front"), columns); + ui.end_row(); + check(ui, d, "legendAcrossTop", tl!("Add Legend Across Top"), legend_shown(d)); + check(ui, d, "firstColumnInFront", tl!("First Column in Front"), columns); + ui.end_row(); + // A pie graph has its Legend menu among its options. + if kind != GraphKind::Pie { + check(ui, d, "legend", tl!("Legend"), true); + ui.end_row(); + } + }); + type_options(ui, d, kind); +} + +/// The Options of the type picked: widths for columns and bars, data points and lines for line, +/// scatter and radar series, and Legend, Position and Sort for a pie graph. An area graph has none. +fn type_options(ui: &mut egui::Ui, d: &mut Dialog, kind: GraphKind) { + let (columns, lines, pie) = (draws(d, &COLUMNS), draws(d, &LINES), kind == GraphKind::Pie); + if !(columns || lines || pie) { + return; + } + ui.add_space(8.0); + widgets::subheader(ui, tl!("Options")); + if columns { + let width = if matches!(kind, GraphKind::Bar | GraphKind::StackedBar) { tl!("Bar Width") } else { tl!("Column Width") }; + for (key, label) in [("columnWidth", width), ("clusterWidth", tl!("Cluster Width"))] { + let shown = number(d, key).unwrap_or(100.0); + ranged(ui, d, key, &colon(label), shown, (WIDTHS, "%", 2)); + } + } + if lines { + // Edge-to-Edge Lines is for line series, and Draw Filled Lines for connected data points. + let (line, connected) = (draws(d, &[GraphKind::Line]), d.bool("connectPoints")); + egui::Grid::new("graph-type-lines").num_columns(2).spacing([24.0, 6.0]).show(ui, |ui| { + check(ui, d, "markPoints", tl!("Mark Data Points"), true); + check(ui, d, "edgeToEdge", tl!("Edge-to-Edge Lines"), line); + ui.end_row(); + check(ui, d, "connectPoints", tl!("Connect Data Points"), true); + check(ui, d, "filledLines", tl!("Draw Filled Lines"), connected); + ui.end_row(); + }); + } + if pie { + choice(ui, d, "pieLegend", &colon(tl!("Legend")), &PIE_LEGENDS); + choice(ui, d, "piePosition", &colon(tl!("Position")), &PIE_POSITIONS); + choice(ui, d, "pieSort", &colon(tl!("Sort")), &PIE_SORTS); + } +} + +/// The page of a value axis: the tick values of the scale it shows, and the tick marks and labels +/// of its side. An only value axis on the right side shows the left scale with the right side's +/// tick marks and labels, as the graph draws it. +fn value_page(ui: &mut egui::Ui, d: &mut Dialog, right: bool) { + let scale = if right { &RIGHT } else { &LEFT }; + let side = if right || (SIDED.contains(&graph_kind(d)) && graph_axis(d) == "right") { &RIGHT } else { &LEFT }; + widgets::subheader(ui, tl!("Tick Values")); + // Without Separate Scales the right axis shows the left axis' tick values, and with it a side + // without series shows the other side's. + let own_scale = scale_used(d, right); + check(ui, d, scale.overridden, tl!("Override Calculated Values"), own_scale); + ui.add_enabled_ui(own_scale && d.bool(scale.overridden), |ui| { + let ((lo, hi), count) = (bounds(d, scale), divisions(d, scale)); + for (key, label, shown) in [(scale.min, tl!("Minimum:"), lo), (scale.max, tl!("Maximum:"), hi)] { + widgets::label_row(ui, label, LABEL_W, |ui| { + if let Some(v) = widgets::plain_field(ui, ("graph-type", key), shown, "", 4, NUMBER_W) { + d.fields.insert(key.into(), json!(v)); + } + }); + } + ranged(ui, d, scale.ticks, &colon(tl!("Divisions")), count as f64, (DIVISIONS, "", 0)); + }); + ui.add_space(8.0); + tick_marks(ui, d, side.length, side.marks); + ui.add_space(8.0); + widgets::subheader(ui, tl!("Add Labels")); + text(ui, d, side.prefix, tl!("Prefix:")); + text(ui, d, side.suffix, &colon(tl!("Suffix"))); +} + +fn category_page(ui: &mut egui::Ui, d: &mut Dialog) { + tick_marks(ui, d, "categoryTickLength", "categoryTickMarks"); + let drawn = d.str("categoryTickLength") != "none"; + check(ui, d, "ticksBetweenLabels", tl!("Draw Tick Marks Between Labels"), drawn); +} + +/// Tick Marks: the length, and the tick marks per division while there are tick marks to draw. +fn tick_marks(ui: &mut egui::Ui, d: &mut Dialog, length: &str, marks: &str) { + widgets::subheader(ui, tl!("Tick Marks")); + choice(ui, d, length, &colon(tl!("Length")), &TICK_LENGTHS); + let shown = number(d, marks).unwrap_or(1.0); + ui.add_enabled_ui(d.str(length) != "none", |ui| { + ranged(ui, d, marks, &colon(tl!("Tick Marks per Division")), shown, (TICK_MARKS, "", 0)); + }); +} + +/// The nine Type buttons. With series picked, the types that cannot go in the graph are dimmed. +fn type_row(ui: &mut egui::Ui, d: &mut Dialog) { + let own = (!picked(d).is_empty()).then(|| graph_kind(d)); + let current = chosen(d); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for kind in GraphKind::ALL { + let enabled = own.is_none_or(|graph| kind.combines_with(graph)); + if type_button(ui, kind, current == Some(kind), enabled) { + d.fields.insert("type".into(), json!(kind.id())); + } + } + }); +} + +/// The name of a graph type, in the UI language. +fn kind_name(kind: GraphKind) -> &'static str { + match kind { + GraphKind::Column => tl!("Column Graph"), + GraphKind::StackedColumn => tl!("Stacked Column Graph"), + GraphKind::Bar => tl!("Bar Graph"), + GraphKind::StackedBar => tl!("Stacked Bar Graph"), + GraphKind::Line => tl!("Line Graph"), + GraphKind::Area => tl!("Area Graph"), + GraphKind::Scatter => tl!("Scatter Graph"), + GraphKind::Pie => tl!("Pie Graph"), + GraphKind::Radar => tl!("Radar Graph"), + } +} + +/// A Type button: the type's picture, pressed while it is the type picked. Whether it was clicked. +fn type_button(ui: &mut egui::Ui, kind: GraphKind, selected: bool, enabled: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let sense = if enabled { egui::Sense::click() } else { egui::Sense::hover() }; + let (rect, resp) = ui.allocate_exact_size(egui::Vec2::splat(TYPE_BUTTON), sense); + let fill = if selected { + t.tool_active + } else if enabled && resp.hovered() { + t.hover + } else { + egui::Color32::TRANSPARENT + }; + ui.painter().rect_filled(rect, egui::CornerRadius::same(5), fill); + let ink = if !enabled { + t.text_disabled + } else if selected { + t.text_strong + } else { + t.icon + }; + paint_kind(ui.painter(), rect.shrink(7.0), kind, ink); + let name = kind_name(kind); + resp.widget_info(|| widgets::icon_button_info(enabled, selected, name)); + resp.on_hover_text(name).clicked() +} + +/// Draw the picture of graph type `kind` in `r` with `ink`. +fn paint_kind(p: &egui::Painter, r: egui::Rect, kind: GraphKind, ink: egui::Color32) { + let at = |x: f32, y: f32| egui::pos2(r.left() + x * r.width(), r.top() + y * r.height()); + let faint = ink.gamma_multiply(0.45); + let block = |from: (f32, f32), to: (f32, f32), color: egui::Color32| { + p.rect_filled(egui::Rect::from_two_pos(at(from.0, from.1), at(to.0, to.1)), 0.0, color); + }; + let stroke = egui::Stroke::new(1.5, ink); + // Three columns or bars: where each one starts across the picture, and how long it is. + let marks = [(0.04, 0.55), (0.37, 0.95), (0.7, 0.75)]; + match kind { + GraphKind::Column | GraphKind::StackedColumn => { + for (x, len) in marks { + block((x, 1.0 - len), (x + 0.26, 1.0), ink); + if kind == GraphKind::StackedColumn { + block((x, 1.0 - len), (x + 0.26, 1.0 - len * 0.55), faint); + } + } + } + GraphKind::Bar | GraphKind::StackedBar => { + for (y, len) in marks { + block((0.0, y), (len, y + 0.26), ink); + if kind == GraphKind::StackedBar { + block((len * 0.55, y), (len, y + 0.26), faint); + } + } + } + GraphKind::Line | GraphKind::Area | GraphKind::Scatter => paint_points(p, &at, kind, ink), + GraphKind::Pie => { + let (centre, radius) = (r.center(), r.width().min(r.height()) / 2.0); + p.circle_stroke(centre, radius - 0.75, stroke); + let arc = (0..=8).map(|i| { + let a = -std::f32::consts::FRAC_PI_2 + std::f32::consts::FRAC_PI_2 * 1.3 * i as f32 / 8.0; + centre + egui::vec2(a.cos(), a.sin()) * radius + }); + let wedge: Vec = std::iter::once(centre).chain(arc).collect(); + p.add(egui::Shape::convex_polygon(wedge, ink, egui::Stroke::NONE)); + } + GraphKind::Radar => { + let (centre, radius) = (r.center(), r.width().min(r.height()) / 2.0); + let ring = |reach: [f32; 5]| -> Vec { + let corner = |(i, len): (usize, &f32)| { + let a = -std::f32::consts::FRAC_PI_2 + std::f32::consts::TAU * i as f32 / 5.0; + centre + egui::vec2(a.cos(), a.sin()) * radius * *len + }; + reach.iter().enumerate().map(corner).collect() + }; + p.add(egui::Shape::closed_line(ring([1.0; 5]), egui::Stroke::new(1.0, faint))); + p.add(egui::Shape::closed_line(ring([0.85, 0.5, 0.75, 0.45, 0.7]), stroke)); + } + } +} + +/// The pictures of the line, area and scatter types: four data points joined by a line, filled +/// down to the baseline, or on their own with a second row of points. +fn paint_points(p: &egui::Painter, at: &dyn Fn(f32, f32) -> egui::Pos2, kind: GraphKind, ink: egui::Color32) { + let points = [(0.05, 0.7), (0.35, 0.3), (0.65, 0.5), (0.95, 0.1)]; + match kind { + GraphKind::Area => { + // A band under each segment: the area under the line is not convex as a whole. + for pair in points.windows(2) { + if let [(x0, y0), (x1, y1)] = *pair { + let band = vec![at(x0, 1.0), at(x0, y0), at(x1, y1), at(x1, 1.0)]; + p.add(egui::Shape::convex_polygon(band, ink, egui::Stroke::NONE)); + } + } + } + GraphKind::Scatter => { + for (x, y) in points.into_iter().chain([(0.2, 0.25), (0.5, 0.85), (0.8, 0.6)]) { + p.circle_filled(at(x, y), 1.8, ink); + } + } + _ => { + p.add(egui::Shape::line(points.iter().map(|(x, y)| at(*x, *y)).collect(), egui::Stroke::new(1.5, ink))); + for (x, y) in points { + p.circle_filled(at(x, y), 2.0, ink); + } + } + } +} + +#[cfg(test)] +mod tests { + use egui::accesskit::Role; + use egui_kittest::kittest::{NodeT, Queryable}; + use vectorcraft_engine::Session; + + use super::*; + + type Harness = egui_kittest::Harness<'static, VectorcraftApp>; + + /// A 400 by 400 document with a selected graph of type `kind`: series a and b over Q1 (1, 2) + /// and Q2 (3, 4). + fn app_with_graph(kind: &str) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.run("graph.create", json!({"type": kind, "x": 0, "y": 0, "width": 200, "height": 150, "csv": ",a,b\nQ1,1,2\nQ2,3,4"})).unwrap(); + app + } + + /// The open dialog drawn in a kittest harness. + fn harness(app: VectorcraftApp) -> Harness { + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + crate::dialogs::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(5); + h + } + + fn query(app: &mut VectorcraftApp) -> Value { + app.session.execute(COMMAND, &json!({})).unwrap() + } + + fn set(app: &mut VectorcraftApp, key: &str, value: Value) { + app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), value); + } + + fn steps(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() + } + + /// Clicks the Type button named `name`. + fn pick_type(h: &mut Harness, name: &str) { + h.get_by_role_and_label(Role::Button, name).click(); + h.run_steps(3); + } + + /// Object › Graph › Type… opens the dialog on the selected graph's options, on its Graph + /// Options page, and passes kittest's check that every input has a name on each page. + #[test] + fn the_graph_menu_opens_graph_type_on_the_selected_graph() { + let mut app = app_with_graph("column"); + crate::menus::invoke(&mut app, COMMAND, json!({})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.kind.as_str(), d.str("type"), d.str("valueAxis"), d.str(PAGE)), (KIND, "column".into(), "left".into(), "options".into())); + assert!(picked(d).is_empty() && !d.bool("overrideValues") && !d.bool("rightOverrideValues")); + for key in ["legend", "pieLegend", "piePosition", "pieSort", "firstRowInFront", "firstColumnInFront", "filledLines"] { + assert!(d.fields.contains_key(key), "{key} is held for every type"); + } + set(&mut app, "valueAxis", json!("both")); + let mut h = harness(app); + let _ = h.get_by_role_and_label(Role::ComboBox, "Graph Type"); + let _ = h.get_by_role_and_label(Role::Button, "Pie Graph"); + // The right axis shows the left axis' tick values until Separate Scales gives it its own. + for (page, own_scale) in [("valueAxis", true), ("rightAxis", false)] { + set(h.state_mut(), PAGE, json!(page)); + h.run_steps(3); + h.check_accessibility(); + let overridden = h.get_by_role_and_label(Role::CheckBox, "Override Calculated Values"); + assert_eq!(overridden.accesskit_node().is_disabled(), !own_scale, "{page}"); + let _ = h.get_by_role_and_label(Role::TextInput, "Prefix:"); + } + set(h.state_mut(), PAGE, json!("categoryAxis")); + h.run_steps(3); + h.check_accessibility(); + assert!(h.get_by_role_and_label(Role::CheckBox, "Draw Tick Marks Between Labels").accesskit_node().is_disabled(), "no tick marks to place"); + // Without a selected graph the menu item opens no dialog. + let app = h.state_mut(); + app.ui.dialog = None; + app.run("select.set", json!({"ids": []})).unwrap(); + assert!(open(app).is_err()); + assert!(app.ui.dialog.is_none()); + } + + /// A Type button changes the graph's type and keeps its data. + #[test] + fn a_type_button_changes_the_graph_type_and_keeps_its_data() { + let mut app = app_with_graph("column"); + let data = app.session.execute("graph.setData", &json!({})).unwrap(); + assert_eq!(data["rows"], json!([[1.0, 2.0], [3.0, 4.0]])); + open(&mut app).unwrap(); + let mut h = harness(app); + for kind in GraphKind::ALL { + pick_type(&mut h, kind_name(kind)); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().str("type"), kind.id()); + } + pick_type(&mut h, "Stacked Bar Graph"); + let app = h.state_mut(); + let before = steps(app); + crate::dialogs::confirm(app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(query(app)["type"], "stackedBar"); + assert_eq!(app.session.execute("graph.setData", &json!({})).unwrap(), data); + assert_eq!(steps(app), before + 1, "one step of history"); + } + + /// The menus list the values `graph.setType` takes, and Value Axis puts the value axis on the + /// side picked. + #[test] + fn the_menus_list_the_values_the_command_takes() { + let doc = vectorcraft_engine::find_command(COMMAND).unwrap().params; + let lists: [(&str, &[(&str, &str)]); 4] = + [("valueAxis", &SIDES), ("tickLength", &TICK_LENGTHS), ("pieLegend", &PIE_LEGENDS), ("pieSort", &PIE_SORTS)]; + for (key, options) in lists { + let values: Vec<&str> = options.iter().map(|(value, _)| *value).collect(); + assert!(doc.contains(&format!("{key}?: {}", values.join("|"))), "{key}: {values:?}"); + } + assert!(doc.contains("piePosition?: ratio|even|stacked")); + let mut app = app_with_graph("column"); + open(&mut app).unwrap(); + let mut h = harness(app); + assert!(h.get_by_role_and_label(Role::CheckBox, "Separate Scales").accesskit_node().is_disabled(), "one side: one scale"); + h.get_by_role_and_label(Role::ComboBox, "Value Axis:").click(); + h.run_steps(3); + h.get_by_label("On Both Sides").click(); + h.run_steps(3); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().str("valueAxis"), "both"); + assert!(!h.get_by_role_and_label(Role::CheckBox, "Separate Scales").accesskit_node().is_disabled()); + let app = h.state_mut(); + crate::dialogs::confirm(app).unwrap(); + assert_eq!(query(app)["valueAxis"], "both"); + } + + /// The Options group shows the options of the type picked: widths for columns and bars, the + /// data point and line checkboxes for line, scatter and radar graphs, the three menus for a + /// pie graph, and none for an area graph. + #[test] + fn the_options_follow_the_type_picked() { + let mut app = app_with_graph("column"); + open(&mut app).unwrap(); + let mut h = harness(app); + let field = |h: &Harness, name: &str| h.query_by_role_and_label(Role::TextInput, name).is_some(); + let checkbox = |h: &Harness, name: &str| h.query_by_role_and_label(Role::CheckBox, name).is_some(); + let menu = |h: &Harness, name: &str| h.query_by_role_and_label(Role::ComboBox, name).is_some(); + let dimmed = |h: &Harness, name: &str| h.get_by_role_and_label(Role::CheckBox, name).accesskit_node().is_disabled(); + assert!(field(&h, "Column Width:") && field(&h, "Cluster Width:") && !checkbox(&h, "Mark Data Points") && !menu(&h, "Legend:")); + assert!(!dimmed(&h, "First Row in Front") && !dimmed(&h, "First Column in Front") && checkbox(&h, "Legend")); + pick_type(&mut h, "Bar Graph"); + assert!(field(&h, "Bar Width:") && !field(&h, "Column Width:") && field(&h, "Cluster Width:")); + assert!( + h.get_by_role_and_label(Role::ComboBox, "Value Axis:").accesskit_node().is_disabled(), + "a bar graph's value axis stays along the bottom" + ); + pick_type(&mut h, "Line Graph"); + assert!(!field(&h, "Cluster Width:") && dimmed(&h, "First Row in Front") && dimmed(&h, "First Column in Front")); + for name in ["Mark Data Points", "Connect Data Points", "Edge-to-Edge Lines", "Draw Filled Lines"] { + assert!(checkbox(&h, name) && !dimmed(&h, name), "{name}"); + } + // Draw Filled Lines is for connected data points, and Edge-to-Edge Lines for line graphs. + set(h.state_mut(), "connectPoints", json!(false)); + h.run_steps(3); + assert!(dimmed(&h, "Draw Filled Lines")); + for name in ["Scatter Graph", "Radar Graph"] { + pick_type(&mut h, name); + assert!(checkbox(&h, "Mark Data Points") && dimmed(&h, "Edge-to-Edge Lines"), "{name}"); + } + pick_type(&mut h, "Pie Graph"); + assert!(menu(&h, "Legend:") && menu(&h, "Position:") && menu(&h, "Sort:") && !checkbox(&h, "Legend") && !checkbox(&h, "Mark Data Points")); + assert!(h.get_by_role_and_label(Role::ComboBox, "Value Axis:").accesskit_node().is_disabled(), "a pie graph has no value axis"); + // Add Legend Across Top is for a legend outside the wedges. + assert!(!dimmed(&h, "Add Legend Across Top")); + set(h.state_mut(), "pieLegend", json!("wedges")); + h.run_steps(3); + assert!(dimmed(&h, "Add Legend Across Top")); + pick_type(&mut h, "Area Graph"); + assert!(!field(&h, "Column Width:") && !checkbox(&h, "Mark Data Points") && !menu(&h, "Legend:") && h.query_by_label("Options").is_none()); + } + + /// The menu at the top lists a page per axis of the type picked, and a second value axis page + /// while the value axis is on both sides. + #[test] + fn the_menu_lists_a_page_per_axis_of_the_type() { + let mut app = app_with_graph("column"); + open(&mut app).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + let ids = |d: &Dialog| pages(d).iter().map(|(id, _)| *id).collect::>(); + let names = |d: &Dialog| pages(d).iter().map(|(_, name)| *name).collect::>(); + for kind in ["column", "stackedColumn", "bar", "stackedBar", "line", "area"] { + d.fields.insert("type".into(), json!(kind)); + assert_eq!(ids(&d), ["options", "valueAxis", "categoryAxis"], "{kind}"); + } + assert_eq!(names(&d), ["Graph Options", "Value Axis", "Category Axis"]); + for kind in ["scatter", "radar"] { + d.fields.insert("type".into(), json!(kind)); + assert_eq!(ids(&d), ["options", "valueAxis"], "{kind}"); + } + d.fields.insert("type".into(), json!("pie")); + assert_eq!(ids(&d), ["options"]); + d.fields.insert("valueAxis".into(), json!("both")); + for kind in ["column", "stackedColumn", "line", "area"] { + d.fields.insert("type".into(), json!(kind)); + assert_eq!(ids(&d), ["options", "valueAxis", "rightAxis", "categoryAxis"], "{kind}"); + assert_eq!(names(&d), ["Graph Options", "Left Axis", "Right Axis", "Category Axis"], "{kind}"); + } + d.fields.insert("type".into(), json!("scatter")); + assert_eq!(ids(&d), ["options", "valueAxis", "rightAxis"]); + // A bar or radar graph's value axis has no sides, and a pie graph has no value axis. + for (kind, pages) in + [("bar", vec!["options", "valueAxis", "categoryAxis"]), ("radar", vec!["options", "valueAxis"]), ("pie", vec!["options"])] + { + d.fields.insert("type".into(), json!(kind)); + assert_eq!(ids(&d), pages, "{kind}"); + } + // A page the type picked lacks shows Graph Options. + d.fields.insert(PAGE.into(), json!("rightAxis")); + app.ui.dialog = Some(d); + let h = harness(app); + assert!(h.query_by_role_and_label(Role::ComboBox, "Legend:").is_some()); + } + + /// Override Calculated Values starts from the tick values the axis is drawn with, OK sends + /// them, and turning it off again gives the axis its calculated values back. Left off, it + /// leaves a count the graph has alone. + #[test] + fn override_calculated_values_starts_from_the_calculated_tick_values() { + let mut app = app_with_graph("column"); + open(&mut app).unwrap(); + assert_eq!(calculated(app.ui.dialog.as_ref().unwrap(), &LEFT), (0.0, 4.0, 4)); + set(&mut app, PAGE, json!("valueAxis")); + let mut h = harness(app); + let dimmed = |h: &Harness, name: &str| h.get_by_role_and_label(Role::TextInput, name).accesskit_node().is_disabled(); + assert!(dimmed(&h, "Minimum:") && dimmed(&h, "Maximum:") && dimmed(&h, "Divisions:")); + h.get_by_role_and_label(Role::CheckBox, "Override Calculated Values").click(); + h.run_steps(3); + assert!(!dimmed(&h, "Minimum:") && !dimmed(&h, "Maximum:") && !dimmed(&h, "Divisions:")); + let app = h.state_mut(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((number(d, "axisMin"), number(d, "axisMax"), number(d, "ticks")), (Some(0.0), Some(4.0), Some(4.0))); + let before = steps(app); + crate::dialogs::confirm(app).unwrap(); + let q = query(app); + assert_eq!((q["axisMin"].as_f64(), q["axisMax"].as_f64(), q["ticks"].as_u64()), (Some(0.0), Some(4.0), Some(4))); + assert_eq!(steps(app), before + 1); + // It opens on, with the graph's values, and off it sends the calculated values back. + open(app).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().bool("overrideValues")); + set(app, "axisMax", json!(8.0)); + crate::dialogs::confirm(app).unwrap(); + assert_eq!(query(app)["axisMax"], 8.0); + open(app).unwrap(); + set(app, "overrideValues", json!(false)); + crate::dialogs::confirm(app).unwrap(); + let q = query(app); + assert_eq!((q["axisMin"].clone(), q["axisMax"].clone(), q["ticks"].clone()), (Value::Null, Value::Null, json!(0))); + // Left off, a count the graph has stays, whatever else OK changes. + app.run(COMMAND, json!({"ticks": 7})).unwrap(); + open(app).unwrap(); + set(app, "dropShadow", json!(true)); + crate::dialogs::confirm(app).unwrap(); + let q = query(app); + assert_eq!((q["ticks"].clone(), q["dropShadow"].clone(), q["axisMin"].clone()), (json!(7), json!(true), Value::Null)); + } + + /// An overridden value axis whose maximum is not more than its minimum is refused: the dialog + /// stays open and the graph is not changed. The right axis counts while it has a scale of its + /// own. + #[test] + fn an_overridden_axis_needs_a_maximum_above_its_minimum() { + let mut app = app_with_graph("column"); + open(&mut app).unwrap(); + for (key, value) in [("overrideValues", json!(true)), ("axisMin", json!(10)), ("axisMax", json!(10))] { + set(&mut app, key, value); + } + let before = steps(&app); + assert_eq!(problem(app.ui.dialog.as_ref().unwrap()), Some("Maximum must be more than Minimum.")); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some(), "the dialog stays open"); + assert_eq!((steps(&app), query(&mut app)["axisMax"].clone()), (before, Value::Null)); + // A range too wide to divide is refused too. + set(&mut app, "axisMin", json!(-1.7e308)); + set(&mut app, "axisMax", json!(1.7e308)); + assert!(problem(app.ui.dialog.as_ref().unwrap()).is_some()); + set(&mut app, "axisMin", json!(10)); + set(&mut app, "axisMax", json!(20)); + crate::dialogs::confirm(&mut app).unwrap(); + let q = query(&mut app); + assert_eq!((q["axisMin"].as_f64(), q["axisMax"].as_f64()), (Some(10.0), Some(20.0))); + // The right axis shows the left scale until Separate Scales gives it one. + open(&mut app).unwrap(); + for (key, value) in [("rightOverrideValues", json!(true)), ("rightAxisMin", json!(5)), ("rightAxisMax", json!(1))] { + set(&mut app, key, value); + } + assert_eq!(problem(app.ui.dialog.as_ref().unwrap()), None); + set(&mut app, "valueAxis", json!("both")); + set(&mut app, "separateScales", json!(true)); + // A scale without series on its side draws no axis of its own. + assert_eq!(problem(app.ui.dialog.as_ref().unwrap()), None); + set(&mut app, RIGHT_SERIES, json!([1])); + assert!(problem(app.ui.dialog.as_ref().unwrap()).is_some()); + // A pie graph has no value axis to refuse. + set(&mut app, "type", json!("pie")); + assert_eq!(problem(app.ui.dialog.as_ref().unwrap()), None); + } + + /// OK with nothing changed closes the dialog without a step of history, on every page and + /// for every type. + #[test] + fn ok_with_nothing_changed_adds_no_step_of_history() { + for kind in GraphKind::ALL { + let mut app = app_with_graph(kind.id()); + app.run(COMMAND, json!({"valueAxis": "both", "axisMin": 0, "axisMax": 10})).unwrap(); + let before = steps(&app); + open(&mut app).unwrap(); + let mut h = harness(app); + for page in ["valueAxis", "rightAxis", "categoryAxis", "options"] { + set(h.state_mut(), PAGE, json!(page)); + h.run_steps(3); + } + let app = h.state_mut(); + assert_eq!(crate::dialogs::confirm(app), Ok(Value::Null), "{kind:?}"); + assert!(app.ui.dialog.is_none()); + assert_eq!(steps(app), before, "{kind:?}"); + } + } + + /// With series picked by Group Selection, the type and the value axis go to those series: the + /// types that cannot go in the graph are dimmed, Value Axis offers one side, and the other + /// options still go to the whole graph. + #[test] + fn picked_series_take_the_type_and_the_value_axis() { + let mut app = app_with_graph("column"); + let (graph, series) = { + let st = app.session.active().unwrap(); + let graph = st.selection.objects[0]; + (graph, st.doc.node(graph).unwrap().children().unwrap().iter().find(|c| c.extras().series_index == Some(1)).unwrap().id) + }; + app.run("select.set", json!({"ids": [series.0]})).unwrap(); + open(&mut app).unwrap(); + assert_eq!(picked(app.ui.dialog.as_ref().unwrap()), [1]); + let mut h = harness(app); + for name in ["Bar Graph", "Stacked Bar Graph", "Scatter Graph", "Pie Graph", "Radar Graph"] { + assert!(h.get_by_role_and_label(Role::Button, name).accesskit_node().is_disabled(), "{name}"); + pick_type(&mut h, name); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().str("type"), "column", "{name}"); + } + h.get_by_role_and_label(Role::ComboBox, "Value Axis:").click(); + h.run_steps(3); + assert!(h.query_by_label("On Both Sides").is_none(), "a series goes on one axis"); + h.get_by_label("On Right Side").click(); + h.run_steps(3); + pick_type(&mut h, "Line Graph"); + // The picked line series brings the line options beside the columns'. + assert!(h.query_by_role_and_label(Role::CheckBox, "Mark Data Points").is_some()); + assert!(h.query_by_role_and_label(Role::TextInput, "Column Width:").is_some()); + let app = h.state_mut(); + set(app, "columnWidth", json!(50.0)); + crate::dialogs::confirm(app).unwrap(); + // The series stays picked, on its own type and axis. + let q = query(app); + assert_eq!((q["type"].clone(), q["valueAxis"].clone(), q["columnWidth"].clone()), (json!("line"), json!("right"), json!(50.0))); + let whole = app.session.execute(COMMAND, &json!({"id": graph.0})).unwrap(); + assert_eq!((whole["type"].clone(), whole["valueAxis"].clone()), (json!("column"), json!("left"))); + // Series on both value axes open with no side, and OK leaves each where it is. + let both: Vec = { + let st = app.session.active().unwrap(); + st.doc.node(graph).unwrap().children().unwrap().iter().filter(|c| c.extras().series_index.is_some()).map(|c| c.id.0).collect() + }; + app.run("select.set", json!({"ids": both})).unwrap(); + open(app).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.fields["valueAxis"].clone(), d.fields["type"].clone()), (Value::Null, Value::Null)); + assert_eq!(series_kinds(d), [GraphKind::Column, GraphKind::Line]); + set(app, "dropShadow", json!(true)); + crate::dialogs::confirm(app).unwrap(); + let st = app.session.active().unwrap(); + let spec = st.doc.node(graph).unwrap().extras().graph.as_deref().unwrap(); + assert_eq!((spec.right_series.clone(), spec.drop_shadow), (vec![1], true)); + assert_eq!(spec.series_kinds, [None, Some(GraphKind::Line)], "each series keeps its type"); + } + + /// `ui.dialog.set` takes the page shown and refuses the fields that hold what OK acts on. + #[test] + fn the_page_is_a_control_and_the_subject_is_protected() { + let mut app = app_with_graph("line"); + open(&mut app).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(crate::dialogs::accepts_field(d, PAGE) && crate::dialogs::accepts_field(d, "overrideValues")); + for field in + [GRAPH_ID, SERIES, GRAPH, GRAPH_AXIS, SERIES_KINDS, RIGHT_SERIES, CALCULATED, CALCULATED_FOR, OPENED, "__override", "__rightOverride"] + { + assert!(crate::dialogs::protected_field(d, field), "{field}"); + } + } + + /// OK changes the graph and the series the dialog opened on, even when the selection changed + /// while it was open. + #[test] + fn ok_changes_the_graph_the_dialog_opened_on() { + let mut app = app_with_graph("column"); + let first = app.session.active().unwrap().selection.objects[0]; + app.run("graph.create", json!({"type": "column", "x": 0, "y": 200, "width": 200, "height": 150, "csv": ",a,b\nQ1,1,2\nQ2,3,4"})).unwrap(); + let second = app.session.active().unwrap().selection.objects[0]; + app.run("select.set", json!({"ids": [first.0]})).unwrap(); + open(&mut app).unwrap(); + set(&mut app, "type", json!("line")); + app.run("select.set", json!({"ids": [second.0]})).unwrap(); + crate::dialogs::confirm(&mut app).unwrap(); + let kind = + |app: &mut VectorcraftApp, id: vectorcraft_doc::NodeId| app.session.execute(COMMAND, &json!({"id": id.0})).unwrap()["type"].clone(); + assert_eq!((kind(&mut app, first), kind(&mut app, second)), (json!("line"), json!("column"))); + // Picked series keep the type picked for them when the selection moves to the whole graph. + let series = { + let st = app.session.active().unwrap(); + st.doc.node(first).unwrap().children().unwrap().iter().find(|c| c.extras().series_index == Some(1)).unwrap().id + }; + app.run("select.set", json!({"ids": [series.0]})).unwrap(); + open(&mut app).unwrap(); + set(&mut app, "type", json!("area")); + app.run("select.set", json!({"ids": [first.0]})).unwrap(); + crate::dialogs::confirm(&mut app).unwrap(); + let st = app.session.active().unwrap(); + let spec = st.doc.node(first).unwrap().extras().graph.as_deref().unwrap(); + assert_eq!((spec.kind, spec.series_kind(0), spec.series_kind(1)), (GraphKind::Line, GraphKind::Line, GraphKind::Area)); + } + + /// Override Calculated Values starts from the tick values of the type picked in the dialog, not + /// of the type the graph had when it opened. + #[test] + fn override_starts_from_the_tick_values_of_the_type_picked() { + let mut app = app_with_graph("column"); + open(&mut app).unwrap(); + assert_eq!(calculated(app.ui.dialog.as_ref().unwrap(), &LEFT).1, 4.0); + // Stacked, Q2 reaches 3 + 4 = 7. + set(&mut app, "type", json!("stackedColumn")); + set(&mut app, "overrideValues", json!(true)); + crate::dialogs::confirm(&mut app).unwrap(); + let q = query(&mut app); + assert_eq!(q["type"], "stackedColumn"); + assert!(q["axisMax"].as_f64().is_some_and(|max| max >= 7.0), "{}", q["axisMax"]); + let st = app.session.active().unwrap(); + let spec = st.doc.node(st.selection.objects[0]).unwrap().extras().graph.as_deref().unwrap(); + // The calculated values do not depend on the override the graph now has. + let stacked = vectorcraft_engine::graph_calculated_axes(spec)[0]; + let sent = |q: &Value| (q["axisMin"].as_f64(), q["axisMax"].as_f64(), q["ticks"].as_u64()); + assert_eq!(sent(&q), (Some(stacked.0), Some(stacked.1), Some(stacked.2 as u64))); + // Turned on first, the values filled for the column graph follow the type picked after it. + let mut app = app_with_graph("column"); + open(&mut app).unwrap(); + set(&mut app, "overrideValues", json!(true)); + settle(app.ui.dialog.as_mut().unwrap()); + assert_eq!(number(app.ui.dialog.as_ref().unwrap(), "axisMax"), Some(4.0)); + set(&mut app, "type", json!("stackedColumn")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(sent(&query(&mut app)), (Some(stacked.0), Some(stacked.1), Some(stacked.2 as u64))); + // Values typed in stay. + open(&mut app).unwrap(); + set(&mut app, "axisMax", json!(50.0)); + set(&mut app, "type", json!("column")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(query(&mut app)["axisMax"], 50.0); + } + + /// The limits the fields keep to are the ones `graph.setType` keeps to. + #[test] + fn the_fields_keep_to_the_command_s_limits() { + let mut app = app_with_graph("column"); + let long = "x".repeat(AFFIX_CHARS + 6); + app.run(COMMAND, json!({"tickMarks": 99, "columnWidth": 5000, "clusterWidth": 0, "prefix": long, "axisMin": 0, "axisMax": 1, "ticks": 500})) + .unwrap(); + let q = query(&mut app); + assert_eq!(q["tickMarks"].as_f64(), Some(*TICK_MARKS.end())); + assert_eq!((q["columnWidth"].as_f64(), q["clusterWidth"].as_f64()), (Some(*WIDTHS.end()), Some(*WIDTHS.start()))); + assert_eq!(q["prefix"].as_str().map(str::len), Some(AFFIX_CHARS)); + assert_eq!(q["ticks"].as_f64(), Some(*DIVISIONS.end())); + } +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 28944173dd..a335c9f424 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1359,6 +1359,40 @@ font-technology Variable Променливи Graph Диаграма Graph Data Данни за диаграма Graph Type Тип диаграма + Graph Options Опции на диаграмата + Left Axis Лява ос + Right Axis Дясна ос + Category Axis Ос на категориите + Separate Scales Отделни скали + Add Drop Shadow Добавяне на падаща сянка + Add Legend Across Top Добавяне на легенда отгоре + First Row in Front Първият ред отпред + First Column in Front Първата колона отпред + Legend Легенда + Column Width Ширина на колоната + Bar Width Ширина на стълба + Cluster Width Ширина на групата + Mark Data Points Маркиране на точките от данни + Connect Data Points Свързване на точките от данни + Edge-to-Edge Lines Линии от край до край + Draw Filled Lines Чертане на запълнени линии + Tick Values Стойности на отметките + Override Calculated Values Замяна на изчислените стойности + Divisions Деления + Tick Marks Отметки + Tick Marks per Division Отметки на деление + Draw Tick Marks Between Labels Чертане на отметки между етикетите + Add Labels Добавяне на етикети + Maximum must be more than Minimum. Максимумът трябва да е по-голям от минимума. + Column Graph Колонна диаграма + Stacked Column Graph Колонна диаграма с натрупване + Bar Graph Стълбчеста диаграма + Stacked Bar Graph Стълбчеста диаграма с натрупване + Line Graph Линейна диаграма + Area Graph Площна диаграма + Scatter Graph Диаграма на разсейване + Pie Graph Кръгова диаграма + Radar Graph Радарна диаграма Graph Design Дизайн на диаграма Graph Marker Маркер на диаграма New Design Нов дизайн diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 01b90e5148..dbe13b1475 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3546,6 +3546,40 @@ theme Appearance Izgled Create Graph Kreiraj grafikon Graph Data Podaci grafikona Graph Type Vrsta grafikona + Graph Options Opcije grafikona + Left Axis Lijeva osa + Right Axis Desna osa + Category Axis Osa kategorija + Separate Scales Odvojene skale + Add Drop Shadow Dodaj sjenu + Add Legend Across Top Dodaj legendu na vrh + First Row in Front Prvi red naprijed + First Column in Front Prva kolona naprijed + Legend Legenda + Column Width Širina stupca + Bar Width Širina trake + Cluster Width Širina grupe + Mark Data Points Označi tačke podataka + Connect Data Points Poveži tačke podataka + Edge-to-Edge Lines Linije od ivice do ivice + Draw Filled Lines Crtaj ispunjene linije + Tick Values Vrijednosti crtica + Override Calculated Values Zamijeni izračunate vrijednosti + Divisions Podioci + Tick Marks Crtice + Tick Marks per Division Crtica po podioku + Draw Tick Marks Between Labels Crtaj crtice između oznaka + Add Labels Dodaj oznake + Maximum must be more than Minimum. Maksimum mora biti veći od minimuma. + Column Graph Stupčasti grafikon + Stacked Column Graph Naslagani stupčasti grafikon + Bar Graph Trakasti grafikon + Stacked Bar Graph Naslagani trakasti grafikon + Line Graph Linijski grafikon + Area Graph Površinski grafikon + Scatter Graph Raspršeni grafikon + Pie Graph Tortni grafikon + Radar Graph Radarski grafikon Vector Halftone Vektorski polutonovi spot spot Text Tekst diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 4ddd1d5605..b388182f3e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1891,6 +1891,40 @@ digits Malayalam Malaiàlam Graph Design Disseny del gràfic Graph Marker Marcador del gràfic Graph Type Tipus de gràfic + Graph Options Opcions del gràfic + Left Axis Eix esquerre + Right Axis Eix dret + Category Axis Eix de categories + Separate Scales Escales separades + Add Drop Shadow Afegeix una ombra paral·lela + Add Legend Across Top Afegeix la llegenda a la part superior + First Row in Front Primera fila al davant + First Column in Front Primera columna al davant + Legend Llegenda + Column Width Amplada de columna + Bar Width Amplada de barra + Cluster Width Amplada del grup + Mark Data Points Marca els punts de dades + Connect Data Points Connecta els punts de dades + Edge-to-Edge Lines Línies de vora a vora + Draw Filled Lines Dibuixa línies emplenades + Tick Values Valors de les marques + Override Calculated Values Substitueix els valors calculats + Divisions Divisions + Tick Marks Marques de graduació + Tick Marks per Division Marques per divisió + Draw Tick Marks Between Labels Dibuixa les marques entre les etiquetes + Add Labels Afegeix etiquetes + Maximum must be more than Minimum. El màxim ha de ser més gran que el mínim. + Column Graph Gràfic de columnes + Stacked Column Graph Gràfic de columnes apilades + Bar Graph Gràfic de barres + Stacked Bar Graph Gràfic de barres apilades + Line Graph Gràfic de línies + Area Graph Gràfic d’àrea + Scatter Graph Gràfic de dispersió + Pie Graph Gràfic de sectors + Radar Graph Gràfic de radar Graphic Gràfic Graphic Pen Ploma gràfica Graphic Pen… Ploma gràfica… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 211982f086..96f8e70e0d 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3600,6 +3600,40 @@ theme Appearance Motiv Create Graph Vytvořit graf Graph Data Data grafu Graph Type Typ grafu + Graph Options Možnosti grafu + Left Axis Levá osa + Right Axis Pravá osa + Category Axis Osa kategorií + Separate Scales Samostatná měřítka + Add Drop Shadow Přidat vržený stín + Add Legend Across Top Přidat legendu nahoru + First Row in Front První řádek vpředu + First Column in Front První sloupec vpředu + Legend Legenda + Column Width Šířka sloupce + Bar Width Šířka pruhu + Cluster Width Šířka skupiny + Mark Data Points Označit datové body + Connect Data Points Spojit datové body + Edge-to-Edge Lines Čáry od okraje k okraji + Draw Filled Lines Kreslit vyplněné čáry + Tick Values Hodnoty značek + Override Calculated Values Přepsat vypočítané hodnoty + Divisions Dílky + Tick Marks Značky + Tick Marks per Division Značek na dílek + Draw Tick Marks Between Labels Kreslit značky mezi popisky + Add Labels Přidat popisky + Maximum must be more than Minimum. Maximum musí být větší než minimum. + Column Graph Sloupcový graf + Stacked Column Graph Skládaný sloupcový graf + Bar Graph Pruhový graf + Stacked Bar Graph Skládaný pruhový graf + Line Graph Spojnicový graf + Area Graph Plošný graf + Scatter Graph Bodový graf + Pie Graph Výsečový graf + Radar Graph Paprskový graf Vector Halftone Vektorový polotón spot přímá Text Text diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index b3de0ae89a..428f8fbbff 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3331,6 +3331,40 @@ theme Appearance Udseende Create Graph Opret graf Graph Data Grafdata Graph Type Graftype + Graph Options Grafindstillinger + Left Axis Venstre akse + Right Axis Højre akse + Category Axis Kategoriakse + Separate Scales Separate skalaer + Add Drop Shadow Tilføj slagskygge + Add Legend Across Top Tilføj forklaring øverst + First Row in Front Første række forrest + First Column in Front Første kolonne forrest + Legend Forklaring + Column Width Søjlebredde + Bar Width Bjælkebredde + Cluster Width Gruppebredde + Mark Data Points Markér datapunkter + Connect Data Points Forbind datapunkter + Edge-to-Edge Lines Linjer fra kant til kant + Draw Filled Lines Tegn udfyldte linjer + Tick Values Afmærkningsværdier + Override Calculated Values Tilsidesæt beregnede værdier + Divisions Inddelinger + Tick Marks Afmærkninger + Tick Marks per Division Afmærkninger pr. inddeling + Draw Tick Marks Between Labels Tegn afmærkninger mellem etiketter + Add Labels Tilføj etiketter + Maximum must be more than Minimum. Maksimum skal være større end minimum. + Column Graph Søjlediagram + Stacked Column Graph Stablet søjlediagram + Bar Graph Bjælkediagram + Stacked Bar Graph Stablet bjælkediagram + Line Graph Linjediagram + Area Graph Områdediagram + Scatter Graph Spredningsdiagram + Pie Graph Cirkeldiagram + Radar Graph Radardiagram Vector Halftone Vektorraster spot spot Text Tekst diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index d0610144ef..489bf32d94 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1404,6 +1404,40 @@ file-filter Templates Vorlagen Graph Diagramm Graph Data Diagrammdaten Graph Type Diagrammtyp + Graph Options Diagrammoptionen + Left Axis Linke Achse + Right Axis Rechte Achse + Category Axis Kategorieachse + Separate Scales Getrennte Skalen + Add Drop Shadow Schlagschatten hinzufügen + Add Legend Across Top Legende oben hinzufügen + First Row in Front Erste Zeile vorne + First Column in Front Erste Spalte vorne + Legend Legende + Column Width Säulenbreite + Bar Width Balkenbreite + Cluster Width Gruppenbreite + Mark Data Points Datenpunkte markieren + Connect Data Points Datenpunkte verbinden + Edge-to-Edge Lines Linien von Kante zu Kante + Draw Filled Lines Gefüllte Linien zeichnen + Tick Values Teilstrichwerte + Override Calculated Values Berechnete Werte überschreiben + Divisions Teilungen + Tick Marks Teilstriche + Tick Marks per Division Teilstriche pro Teilung + Draw Tick Marks Between Labels Teilstriche zwischen Beschriftungen zeichnen + Add Labels Beschriftungen hinzufügen + Maximum must be more than Minimum. Das Maximum muss größer als das Minimum sein. + Column Graph Säulendiagramm + Stacked Column Graph Gestapeltes Säulendiagramm + Bar Graph Balkendiagramm + Stacked Bar Graph Gestapeltes Balkendiagramm + Line Graph Liniendiagramm + Area Graph Flächendiagramm + Scatter Graph Streudiagramm + Pie Graph Kreisdiagramm + Radar Graph Netzdiagramm Graph Design Diagrammdesign Graph Marker Diagrammmarkierung New Design Neues Design diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d03f8c6958..1f67f30bb3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1334,6 +1334,40 @@ font-technology Variable Μεταβλητές Graph Γράφημα Graph Data Δεδομένα γραφήματος Graph Type Τύπος γραφήματος + Graph Options Επιλογές γραφήματος + Left Axis Αριστερός άξονας + Right Axis Δεξιός άξονας + Category Axis Άξονας κατηγοριών + Separate Scales Ξεχωριστές κλίμακες + Add Drop Shadow Προσθήκη εξωτερικής σκιάς + Add Legend Across Top Προσθήκη υπομνήματος στο επάνω μέρος + First Row in Front Πρώτη γραμμή μπροστά + First Column in Front Πρώτη στήλη μπροστά + Legend Υπόμνημα + Column Width Πλάτος στήλης + Bar Width Πλάτος ράβδου + Cluster Width Πλάτος ομάδας + Mark Data Points Σήμανση σημείων δεδομένων + Connect Data Points Σύνδεση σημείων δεδομένων + Edge-to-Edge Lines Γραμμές από άκρη σε άκρη + Draw Filled Lines Σχεδίαση γεμισμένων γραμμών + Tick Values Τιμές υποδιαιρέσεων + Override Calculated Values Αντικατάσταση υπολογισμένων τιμών + Divisions Διαιρέσεις + Tick Marks Σημάδια υποδιαίρεσης + Tick Marks per Division Σημάδια ανά διαίρεση + Draw Tick Marks Between Labels Σχεδίαση σημαδιών ανάμεσα στις ετικέτες + Add Labels Προσθήκη ετικετών + Maximum must be more than Minimum. Το μέγιστο πρέπει να είναι μεγαλύτερο από το ελάχιστο. + Column Graph Γράφημα στηλών + Stacked Column Graph Γράφημα στοιβαγμένων στηλών + Bar Graph Ραβδόγραμμα + Stacked Bar Graph Στοιβαγμένο ραβδόγραμμα + Line Graph Γραμμικό γράφημα + Area Graph Γράφημα περιοχής + Scatter Graph Διάγραμμα διασποράς + Pie Graph Κυκλικό γράφημα + Radar Graph Αραχνοειδές γράφημα Graph Design Σχεδίαση γραφήματος Graph Marker Δείκτης γραφήματος New Design Νέο σχέδιο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3e8bd23752..b50fa85739 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1413,6 +1413,40 @@ font-technology Variable Variables Graph Gráfico Graph Data Datos del gráfico Graph Type Tipo de gráfico + Graph Options Opciones de gráfico + Left Axis Eje izquierdo + Right Axis Eje derecho + Category Axis Eje de categorías + Separate Scales Escalas separadas + Add Drop Shadow Añadir sombra paralela + Add Legend Across Top Añadir leyenda en la parte superior + First Row in Front Primera fila delante + First Column in Front Primera columna delante + Legend Leyenda + Column Width Anchura de columna + Bar Width Anchura de barra + Cluster Width Anchura de grupo + Mark Data Points Marcar puntos de datos + Connect Data Points Conectar puntos de datos + Edge-to-Edge Lines Líneas de borde a borde + Draw Filled Lines Dibujar líneas rellenas + Tick Values Valores de marcas + Override Calculated Values Sustituir valores calculados + Divisions Divisiones + Tick Marks Marcas de graduación + Tick Marks per Division Marcas por división + Draw Tick Marks Between Labels Dibujar marcas entre etiquetas + Add Labels Añadir etiquetas + Maximum must be more than Minimum. El máximo debe ser mayor que el mínimo. + Column Graph Gráfico de columnas + Stacked Column Graph Gráfico de columnas apiladas + Bar Graph Gráfico de barras + Stacked Bar Graph Gráfico de barras apiladas + Line Graph Gráfico de líneas + Area Graph Gráfico de áreas + Scatter Graph Gráfico de dispersión + Pie Graph Gráfico circular + Radar Graph Gráfico de radar Graph Design Diseño de gráfico Graph Marker Marcador de gráfico New Design Nuevo diseño diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index d408220f47..d6cf919251 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3538,6 +3538,40 @@ theme Appearance Ulkoasu Create Graph Luo kaavio Graph Data Kaavion tiedot Graph Type Kaavion tyyppi + Graph Options Kaavion asetukset + Left Axis Vasen akseli + Right Axis Oikea akseli + Category Axis Luokka-akseli + Separate Scales Erilliset asteikot + Add Drop Shadow Lisää pudotusvarjo + Add Legend Across Top Lisää selite ylös + First Row in Front Ensimmäinen rivi edessä + First Column in Front Ensimmäinen sarake edessä + Legend Selite + Column Width Pylvään leveys + Bar Width Palkin leveys + Cluster Width Ryhmän leveys + Mark Data Points Merkitse arvopisteet + Connect Data Points Yhdistä arvopisteet + Edge-to-Edge Lines Viivat reunasta reunaan + Draw Filled Lines Piirrä täytetyt viivat + Tick Values Asteikon arvot + Override Calculated Values Ohita lasketut arvot + Divisions Jaot + Tick Marks Asteikkomerkit + Tick Marks per Division Asteikkomerkkejä jakoa kohden + Draw Tick Marks Between Labels Piirrä asteikkomerkit nimien väliin + Add Labels Lisää nimet + Maximum must be more than Minimum. Suurimman on oltava pienintä suurempi. + Column Graph Pylväskaavio + Stacked Column Graph Pinottu pylväskaavio + Bar Graph Palkkikaavio + Stacked Bar Graph Pinottu palkkikaavio + Line Graph Viivakaavio + Area Graph Aluekaavio + Scatter Graph Hajontakaavio + Pie Graph Ympyräkaavio + Radar Graph Säteittäinen kaavio Vector Halftone Vektorirasteri spot spotti Text Teksti diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 318bbbeb44..58239c33f0 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1407,6 +1407,40 @@ font-technology Variable Variables Graph Graphe Graph Data Données du graphe Graph Type Type de graphe + Graph Options Options du graphe + Left Axis Axe gauche + Right Axis Axe droit + Category Axis Axe des catégories + Separate Scales Échelles distinctes + Add Drop Shadow Ajouter une ombre portée + Add Legend Across Top Ajouter la légende en haut + First Row in Front Première ligne au premier plan + First Column in Front Première colonne au premier plan + Legend Légende + Column Width Largeur des colonnes + Bar Width Largeur des barres + Cluster Width Largeur des groupes + Mark Data Points Marquer les points de données + Connect Data Points Relier les points de données + Edge-to-Edge Lines Lignes bord à bord + Draw Filled Lines Tracer des lignes pleines + Tick Values Valeurs des graduations + Override Calculated Values Remplacer les valeurs calculées + Divisions Divisions + Tick Marks Graduations + Tick Marks per Division Graduations par division + Draw Tick Marks Between Labels Tracer les graduations entre les étiquettes + Add Labels Ajouter des étiquettes + Maximum must be more than Minimum. Le maximum doit être supérieur au minimum. + Column Graph Graphe à colonnes + Stacked Column Graph Graphe à colonnes empilées + Bar Graph Graphe à barres + Stacked Bar Graph Graphe à barres empilées + Line Graph Graphe linéaire + Area Graph Graphe de zones + Scatter Graph Graphe de dispersion + Pie Graph Graphe à secteurs + Radar Graph Graphe radar Graph Design Conception de graphe Graph Marker Marqueur de graphe New Design Nouvelle conception diff --git a/crates/ui-egui/src/i18n/gu.tsv b/crates/ui-egui/src/i18n/gu.tsv index 716d851655..6489e8b8ce 100644 --- a/crates/ui-egui/src/i18n/gu.tsv +++ b/crates/ui-egui/src/i18n/gu.tsv @@ -1533,6 +1533,40 @@ Graph Design ગ્રાફ ડિઝાઇન Graph Marker ગ્રાફ માર્કર Graph Type ગ્રાફ પ્રકાર + Graph Options ગ્રાફ વિકલ્પો + Left Axis ડાબી અક્ષ + Right Axis જમણી અક્ષ + Category Axis શ્રેણી અક્ષ + Separate Scales અલગ સ્કેલ + Add Drop Shadow ડ્રોપ શેડો ઉમેરો + Add Legend Across Top ઉપર લેજન્ડ ઉમેરો + First Row in Front પ્રથમ પંક્તિ આગળ + First Column in Front પ્રથમ કૉલમ આગળ + Legend લેજન્ડ + Column Width કૉલમ પહોળાઈ + Bar Width બાર પહોળાઈ + Cluster Width જૂથ પહોળાઈ + Mark Data Points ડેટા પોઇન્ટ ચિહ્નિત કરો + Connect Data Points ડેટા પોઇન્ટ જોડો + Edge-to-Edge Lines કિનારીથી કિનારી સુધી રેખાઓ + Draw Filled Lines ભરેલી રેખાઓ દોરો + Tick Values ટિક મૂલ્યો + Override Calculated Values ગણતરી કરેલાં મૂલ્યો બદલો + Divisions વિભાગો + Tick Marks ટિક ચિહ્નો + Tick Marks per Division વિભાગ દીઠ ટિક ચિહ્નો + Draw Tick Marks Between Labels લેબલ વચ્ચે ટિક ચિહ્નો દોરો + Add Labels લેબલ ઉમેરો + Maximum must be more than Minimum. મહત્તમ ન્યૂનતમ કરતાં વધુ હોવું જોઈએ. + Column Graph કૉલમ ગ્રાફ + Stacked Column Graph સ્ટેક્ડ કૉલમ ગ્રાફ + Bar Graph બાર ગ્રાફ + Stacked Bar Graph સ્ટેક્ડ બાર ગ્રાફ + Line Graph રેખા ગ્રાફ + Area Graph ક્ષેત્ર ગ્રાફ + Scatter Graph સ્કેટર ગ્રાફ + Pie Graph પાઇ ગ્રાફ + Radar Graph રડાર ગ્રાફ Graphic Pen… ગ્રાફિક પેન… Graphic Style ગ્રાફિક સ્ટાઇલ Graphic Style Libraries ગ્રાફિક સ્ટાઇલ લાઇબ્રેરીઓ diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 78c0a9a7eb..d3685c8b9a 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2101,6 +2101,40 @@ antiAlias Sharp शार्प Graph Design ग्राफ़ डिजाइन Graph Marker ग्राफ़ मार्कर Graph Type ग्राफ़ टाइप + Graph Options ग्राफ़ विकल्प + Left Axis बायाँ एक्सिस + Right Axis दायाँ एक्सिस + Category Axis श्रेणी एक्सिस + Separate Scales अलग स्केल + Add Drop Shadow ड्रॉप शैडो जोड़ें + Add Legend Across Top ऊपर लेजेंड जोड़ें + First Row in Front पहली पंक्ति आगे + First Column in Front पहला कॉलम आगे + Legend लेजेंड + Column Width कॉलम चौड़ाई + Bar Width बार चौड़ाई + Cluster Width समूह चौड़ाई + Mark Data Points डेटा पॉइंट चिह्नित करें + Connect Data Points डेटा पॉइंट जोड़ें + Edge-to-Edge Lines किनारे से किनारे तक रेखाएँ + Draw Filled Lines भरी हुई रेखाएँ बनाएँ + Tick Values टिक मान + Override Calculated Values परिकलित मानों को बदलें + Divisions विभाजन + Tick Marks टिक चिह्न + Tick Marks per Division प्रति विभाजन टिक चिह्न + Draw Tick Marks Between Labels लेबलों के बीच टिक चिह्न बनाएँ + Add Labels लेबल जोड़ें + Maximum must be more than Minimum. अधिकतम, न्यूनतम से अधिक होना चाहिए। + Column Graph कॉलम ग्राफ़ + Stacked Column Graph स्टैक्ड कॉलम ग्राफ़ + Bar Graph बार ग्राफ़ + Stacked Bar Graph स्टैक्ड बार ग्राफ़ + Line Graph लाइन ग्राफ़ + Area Graph एरिया ग्राफ़ + Scatter Graph स्कैटर ग्राफ़ + Pie Graph पाई ग्राफ़ + Radar Graph रडार ग्राफ़ Graphic ग्राफिक Graphic Pen ग्राफ़िक पेन Graphic Pen… ग्राफ़िक पेन… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 7f2f03afb2..e656bfb5a3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1759,6 +1759,40 @@ file-filter Templates Sablonok Graph Diagram Graph Data Diagramadatok Graph Type Diagramtípus + Graph Options Diagrambeállítások + Left Axis Bal tengely + Right Axis Jobb tengely + Category Axis Kategóriatengely + Separate Scales Külön skálák + Add Drop Shadow Vetett árnyék hozzáadása + Add Legend Across Top Jelmagyarázat felül + First Row in Front Első sor elöl + First Column in Front Első oszlop elöl + Legend Jelmagyarázat + Column Width Oszlopszélesség + Bar Width Sávszélesség + Cluster Width Csoportszélesség + Mark Data Points Adatpontok jelölése + Connect Data Points Adatpontok összekötése + Edge-to-Edge Lines Széltől szélig érő vonalak + Draw Filled Lines Kitöltött vonalak rajzolása + Tick Values Osztásértékek + Override Calculated Values Számított értékek felülírása + Divisions Osztások + Tick Marks Osztásjelek + Tick Marks per Division Osztásjelek osztásonként + Draw Tick Marks Between Labels Osztásjelek a címkék között + Add Labels Címkék hozzáadása + Maximum must be more than Minimum. A maximumnak nagyobbnak kell lennie a minimumnál. + Column Graph Oszlopdiagram + Stacked Column Graph Halmozott oszlopdiagram + Bar Graph Sávdiagram + Stacked Bar Graph Halmozott sávdiagram + Line Graph Vonaldiagram + Area Graph Területdiagram + Scatter Graph Pontdiagram + Pie Graph Kördiagram + Radar Graph Sugárdiagram Select Segments Szakaszok kijelölése Artistic Művészi Film Grain… Filmszemcse… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c7c6b956db..897825f1d9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3331,6 +3331,40 @@ theme Appearance Tampilan Create Graph Buat grafik Graph Data Data grafik Graph Type Jenis grafik + Graph Options Opsi grafik + Left Axis Sumbu kiri + Right Axis Sumbu kanan + Category Axis Sumbu kategori + Separate Scales Skala terpisah + Add Drop Shadow Tambahkan bayangan jatuh + Add Legend Across Top Tambahkan legenda di atas + First Row in Front Baris pertama di depan + First Column in Front Kolom pertama di depan + Legend Legenda + Column Width Lebar kolom + Bar Width Lebar batang + Cluster Width Lebar kelompok + Mark Data Points Tandai titik data + Connect Data Points Hubungkan titik data + Edge-to-Edge Lines Garis dari tepi ke tepi + Draw Filled Lines Gambar garis terisi + Tick Values Nilai tanda skala + Override Calculated Values Timpa nilai terhitung + Divisions Pembagian + Tick Marks Tanda skala + Tick Marks per Division Tanda skala per pembagian + Draw Tick Marks Between Labels Gambar tanda skala di antara label + Add Labels Tambahkan label + Maximum must be more than Minimum. Maksimum harus lebih besar dari minimum. + Column Graph Grafik kolom + Stacked Column Graph Grafik kolom bertumpuk + Bar Graph Grafik batang + Stacked Bar Graph Grafik batang bertumpuk + Line Graph Grafik garis + Area Graph Grafik area + Scatter Graph Grafik sebar + Pie Graph Grafik pai + Radar Graph Grafik radar Vector Halftone Halftone vektor spot spot Text Teks diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f832a5baf3..83a454af1b 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1434,6 +1434,40 @@ file-filter Templates Modelli Graph Grafico Graph Data Dati grafico Graph Type Tipo di grafico + Graph Options Opzioni grafico + Left Axis Asse sinistro + Right Axis Asse destro + Category Axis Asse delle categorie + Separate Scales Scale separate + Add Drop Shadow Aggiungi ombra esterna + Add Legend Across Top Aggiungi legenda in alto + First Row in Front Prima riga in primo piano + First Column in Front Prima colonna in primo piano + Legend Legenda + Column Width Larghezza colonne + Bar Width Larghezza barre + Cluster Width Larghezza gruppi + Mark Data Points Contrassegna punti dati + Connect Data Points Collega punti dati + Edge-to-Edge Lines Linee da bordo a bordo + Draw Filled Lines Disegna linee piene + Tick Values Valori delle tacche + Override Calculated Values Sostituisci valori calcolati + Divisions Divisioni + Tick Marks Tacche + Tick Marks per Division Tacche per divisione + Draw Tick Marks Between Labels Disegna tacche tra le etichette + Add Labels Aggiungi etichette + Maximum must be more than Minimum. Il massimo deve essere maggiore del minimo. + Column Graph Grafico a colonne + Stacked Column Graph Grafico a colonne in pila + Bar Graph Grafico a barre + Stacked Bar Graph Grafico a barre in pila + Line Graph Grafico a linee + Area Graph Grafico ad area + Scatter Graph Grafico a dispersione + Pie Graph Grafico a torta + Radar Graph Grafico radar Graph Design Design del grafico Graph Marker Indicatore del grafico New Design Nuovo design diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 3abb388f49..aa79ddaf31 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3836,6 +3836,40 @@ file-filter Templates テンプレート Gradient (. or Shift+.) グラデーション (. または Shift+.) Graph Data グラフデータ Graph Type グラフの種類 + Graph Options グラフオプション + Left Axis 左軸 + Right Axis 右軸 + Category Axis 項目軸 + Separate Scales 目盛りを別にする + Add Drop Shadow ドロップシャドウを追加 + Add Legend Across Top 凡例を上に表示 + First Row in Front 最初の行を前面に + First Column in Front 最初の列を前面に + Legend 凡例 + Column Width 棒の幅 + Bar Width 横棒の幅 + Cluster Width グループの幅 + Mark Data Points データポイントにマーカーを表示 + Connect Data Points データポイントを線でつなぐ + Edge-to-Edge Lines 端から端までの線 + Draw Filled Lines 線を塗りつぶして描く + Tick Values 目盛りの値 + Override Calculated Values 計算値を上書き + Divisions 分割数 + Tick Marks 目盛り + Tick Marks per Division 分割ごとの目盛り + Draw Tick Marks Between Labels ラベルの間に目盛りを描く + Add Labels ラベルを追加 + Maximum must be more than Minimum. 最大値は最小値より大きくしてください。 + Column Graph 棒グラフ + Stacked Column Graph 積み上げ棒グラフ + Bar Graph 横向き棒グラフ + Stacked Bar Graph 横向き積み上げ棒グラフ + Line Graph 折れ線グラフ + Area Graph 面グラフ + Scatter Graph 散布図 + Pie Graph 円グラフ + Radar Graph レーダーチャート Graph Design グラフのデザイン Graph Marker グラフのマーカー New Design 新規デザイン diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 55f726dc38..8d68d5db9f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3177,6 +3177,40 @@ theme Appearance იერსახე Create Graph გრაფიკის შექმნა Graph Data გრაფიკის მონაცემები Graph Type გრაფიკის ტიპი + Graph Options გრაფიკის პარამეტრები + Left Axis მარცხენა ღერძი + Right Axis მარჯვენა ღერძი + Category Axis კატეგორიების ღერძი + Separate Scales ცალკე სკალები + Add Drop Shadow ჩრდილის დამატება + Add Legend Across Top ლეგენდის დამატება ზემოთ + First Row in Front პირველი მწკრივი წინ + First Column in Front პირველი სვეტი წინ + Legend ლეგენდა + Column Width სვეტის სიგანე + Bar Width ზოლის სიგანე + Cluster Width ჯგუფის სიგანე + Mark Data Points მონაცემთა წერტილების მონიშვნა + Connect Data Points მონაცემთა წერტილების შეერთება + Edge-to-Edge Lines ხაზები კიდიდან კიდემდე + Draw Filled Lines შევსებული ხაზების დახატვა + Tick Values დანაყოფების მნიშვნელობები + Override Calculated Values გამოთვლილი მნიშვნელობების შეცვლა + Divisions დანაყოფები + Tick Marks დანაყოფის ნიშნები + Tick Marks per Division ნიშნები თითო დანაყოფზე + Draw Tick Marks Between Labels ნიშნების დახატვა წარწერებს შორის + Add Labels წარწერების დამატება + Maximum must be more than Minimum. მაქსიმუმი მინიმუმზე მეტი უნდა იყოს. + Column Graph სვეტოვანი გრაფიკი + Stacked Column Graph დაწყობილი სვეტოვანი გრაფიკი + Bar Graph ზოლოვანი გრაფიკი + Stacked Bar Graph დაწყობილი ზოლოვანი გრაფიკი + Line Graph ხაზოვანი გრაფიკი + Area Graph არეების გრაფიკი + Scatter Graph გაფანტული გრაფიკი + Pie Graph წრიული გრაფიკი + Radar Graph რადარული გრაფიკი Vector Halftone ვექტორული ნახევარტონი spot სპოტ Text ტექსტი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index abe2b4bc28..6a10e0bc69 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3824,6 +3824,40 @@ file-filter Templates 템플릿 Gradient (. or Shift+.) 그레이디언트 (. 또는 Shift+.) Graph Data 그래프 데이터 Graph Type 그래프 유형 + Graph Options 그래프 옵션 + Left Axis 왼쪽 축 + Right Axis 오른쪽 축 + Category Axis 범주 축 + Separate Scales 별도 눈금 + Add Drop Shadow 드롭 섀도 추가 + Add Legend Across Top 위쪽에 범례 추가 + First Row in Front 첫 행을 앞에 + First Column in Front 첫 열을 앞에 + Legend 범례 + Column Width 세로 막대 폭 + Bar Width 가로 막대 폭 + Cluster Width 묶음 폭 + Mark Data Points 데이터 요소 표시 + Connect Data Points 데이터 요소 연결 + Edge-to-Edge Lines 가장자리 간 선 + Draw Filled Lines 채워진 선 그리기 + Tick Values 눈금 값 + Override Calculated Values 계산된 값 재정의 + Divisions 구간 + Tick Marks 눈금 표시 + Tick Marks per Division 구간당 눈금 표시 + Draw Tick Marks Between Labels 레이블 사이에 눈금 표시 그리기 + Add Labels 레이블 추가 + Maximum must be more than Minimum. 최대값은 최소값보다 커야 합니다. + Column Graph 세로 막대 그래프 + Stacked Column Graph 누적 세로 막대 그래프 + Bar Graph 가로 막대 그래프 + Stacked Bar Graph 누적 가로 막대 그래프 + Line Graph 선 그래프 + Area Graph 영역 그래프 + Scatter Graph 분산형 그래프 + Pie Graph 원형 그래프 + Radar Graph 방사형 그래프 Graph Design 그래프 디자인 Graph Marker 그래프 마커 New Design 새 디자인 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ed281382a4..468b22ec1b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3849,6 +3849,40 @@ theme Appearance Vormgeving Create Graph Diagram maken Graph Data Diagramgegevens Graph Type Diagramtype + Graph Options Diagramopties + Left Axis Linkeras + Right Axis Rechteras + Category Axis Categorie-as + Separate Scales Aparte schalen + Add Drop Shadow Slagschaduw toevoegen + Add Legend Across Top Legenda bovenaan toevoegen + First Row in Front Eerste rij vooraan + First Column in Front Eerste kolom vooraan + Legend Legenda + Column Width Kolombreedte + Bar Width Staafbreedte + Cluster Width Groepbreedte + Mark Data Points Gegevenspunten markeren + Connect Data Points Gegevenspunten verbinden + Edge-to-Edge Lines Lijnen van rand tot rand + Draw Filled Lines Gevulde lijnen tekenen + Tick Values Maatstreepwaarden + Override Calculated Values Berekende waarden negeren + Divisions Verdelingen + Tick Marks Maatstreepjes + Tick Marks per Division Maatstreepjes per verdeling + Draw Tick Marks Between Labels Maatstreepjes tussen labels tekenen + Add Labels Labels toevoegen + Maximum must be more than Minimum. Het maximum moet groter zijn dan het minimum. + Column Graph Kolomdiagram + Stacked Column Graph Gestapeld kolomdiagram + Bar Graph Staafdiagram + Stacked Bar Graph Gestapeld staafdiagram + Line Graph Lijndiagram + Area Graph Vlakdiagram + Scatter Graph Spreidingsdiagram + Pie Graph Cirkeldiagram + Radar Graph Radardiagram Vector Halftone Vectorhalftoon spot spot Text Tekst diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 08e96e6a35..421dc8329f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3479,6 +3479,40 @@ theme Appearance Wygląd Create Graph Utwórz wykres Graph Data Dane wykresu Graph Type Typ wykresu + Graph Options Opcje wykresu + Left Axis Lewa oś + Right Axis Prawa oś + Category Axis Oś kategorii + Separate Scales Oddzielne skale + Add Drop Shadow Dodaj cień + Add Legend Across Top Dodaj legendę u góry + First Row in Front Pierwszy wiersz z przodu + First Column in Front Pierwsza kolumna z przodu + Legend Legenda + Column Width Szerokość kolumny + Bar Width Szerokość słupka + Cluster Width Szerokość grupy + Mark Data Points Oznacz punkty danych + Connect Data Points Połącz punkty danych + Edge-to-Edge Lines Linie od krawędzi do krawędzi + Draw Filled Lines Rysuj wypełnione linie + Tick Values Wartości podziałki + Override Calculated Values Zastąp obliczone wartości + Divisions Podziały + Tick Marks Znaczniki podziałki + Tick Marks per Division Znaczniki na podział + Draw Tick Marks Between Labels Rysuj znaczniki między etykietami + Add Labels Dodaj etykiety + Maximum must be more than Minimum. Maksimum musi być większe niż minimum. + Column Graph Wykres kolumnowy + Stacked Column Graph Skumulowany wykres kolumnowy + Bar Graph Wykres słupkowy + Stacked Bar Graph Skumulowany wykres słupkowy + Line Graph Wykres liniowy + Area Graph Wykres warstwowy + Scatter Graph Wykres punktowy + Pie Graph Wykres kołowy + Radar Graph Wykres radarowy Vector Halftone Półton wektorowy spot dodatkowy Text Tekst diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 79c4d9dc8e..9b537c9a19 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4943,6 +4943,40 @@ axis Both Ambos Gradient (. or Shift+.) Gradiente (. ou Shift+.) Graph Data Dados do Gráfico Graph Type Tipo de Gráfico + Graph Options Opções do Gráfico + Left Axis Eixo Esquerdo + Right Axis Eixo Direito + Category Axis Eixo de Categorias + Separate Scales Escalas Separadas + Add Drop Shadow Adicionar Sombra Projetada + Add Legend Across Top Adicionar Legenda na Parte Superior + First Row in Front Primeira Linha na Frente + First Column in Front Primeira Coluna na Frente + Legend Legenda + Column Width Largura da Coluna + Bar Width Largura da Barra + Cluster Width Largura do Grupo + Mark Data Points Marcar Pontos de Dados + Connect Data Points Conectar Pontos de Dados + Edge-to-Edge Lines Linhas de Borda a Borda + Draw Filled Lines Desenhar Linhas Preenchidas + Tick Values Valores das Marcas + Override Calculated Values Substituir Valores Calculados + Divisions Divisões + Tick Marks Marcas de Escala + Tick Marks per Division Marcas por Divisão + Draw Tick Marks Between Labels Desenhar Marcas entre Rótulos + Add Labels Adicionar Rótulos + Maximum must be more than Minimum. O máximo deve ser maior que o mínimo. + Column Graph Gráfico de Colunas + Stacked Column Graph Gráfico de Colunas Empilhadas + Bar Graph Gráfico de Barras + Stacked Bar Graph Gráfico de Barras Empilhadas + Line Graph Gráfico de Linhas + Area Graph Gráfico de Áreas + Scatter Graph Gráfico de Dispersão + Pie Graph Gráfico de Pizza + Radar Graph Gráfico de Radar New Design Novo Design Delete Design Excluir Design Paste Design Colar Design diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 9f608618e7..69c377b12d 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1423,6 +1423,40 @@ file-filter Templates Шаблоны Graph Диаграмма Graph Data Данные диаграммы Graph Type Тип диаграммы + Graph Options Параметры диаграммы + Left Axis Левая ось + Right Axis Правая ось + Category Axis Ось категорий + Separate Scales Раздельные шкалы + Add Drop Shadow Добавить тень + Add Legend Across Top Добавить легенду сверху + First Row in Front Первая строка спереди + First Column in Front Первый столбец спереди + Legend Легенда + Column Width Ширина столбца + Bar Width Ширина полосы + Cluster Width Ширина группы + Mark Data Points Отмечать точки данных + Connect Data Points Соединять точки данных + Edge-to-Edge Lines Линии от края до края + Draw Filled Lines Рисовать заполненные линии + Tick Values Значения делений + Override Calculated Values Заменить вычисленные значения + Divisions Деления + Tick Marks Метки делений + Tick Marks per Division Меток на деление + Draw Tick Marks Between Labels Рисовать метки между подписями + Add Labels Добавить подписи + Maximum must be more than Minimum. Максимум должен быть больше минимума. + Column Graph Столбчатая диаграмма + Stacked Column Graph Столбчатая диаграмма с накоплением + Bar Graph Линейчатая диаграмма + Stacked Bar Graph Линейчатая диаграмма с накоплением + Line Graph График + Area Graph Диаграмма с областями + Scatter Graph Точечная диаграмма + Pie Graph Круговая диаграмма + Radar Graph Лепестковая диаграмма Graph Design Дизайн диаграммы Graph Marker Маркер диаграммы New Design Новый дизайн diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 6156ca23d5..92837a92b6 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1372,6 +1372,40 @@ font-technology Variable Variabla Graph Diagram Graph Data Diagramdata Graph Type Diagramtyp + Graph Options Diagramalternativ + Left Axis Vänster axel + Right Axis Höger axel + Category Axis Kategoriaxel + Separate Scales Separata skalor + Add Drop Shadow Lägg till slagskugga + Add Legend Across Top Lägg till förklaring överst + First Row in Front Första raden längst fram + First Column in Front Första kolumnen längst fram + Legend Förklaring + Column Width Stapelbredd + Bar Width Liggande stapelbredd + Cluster Width Gruppbredd + Mark Data Points Markera datapunkter + Connect Data Points Förbind datapunkter + Edge-to-Edge Lines Linjer från kant till kant + Draw Filled Lines Rita fyllda linjer + Tick Values Skalstrecksvärden + Override Calculated Values Åsidosätt beräknade värden + Divisions Indelningar + Tick Marks Skalstreck + Tick Marks per Division Skalstreck per indelning + Draw Tick Marks Between Labels Rita skalstreck mellan etiketter + Add Labels Lägg till etiketter + Maximum must be more than Minimum. Maximum måste vara större än minimum. + Column Graph Stapeldiagram + Stacked Column Graph Staplat stapeldiagram + Bar Graph Liggande stapeldiagram + Stacked Bar Graph Staplat liggande stapeldiagram + Line Graph Linjediagram + Area Graph Ytdiagram + Scatter Graph Punktdiagram + Pie Graph Cirkeldiagram + Radar Graph Polärdiagram Graph Design Diagramdesign Graph Marker Diagrammarkör New Design Ny design diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 7120d44707..a3104e828f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1346,6 +1346,40 @@ font-technology Variable Değişken Graph Grafik Graph Data Grafik Verileri Graph Type Grafik Türü + Graph Options Grafik Seçenekleri + Left Axis Sol Eksen + Right Axis Sağ Eksen + Category Axis Kategori Ekseni + Separate Scales Ayrı Ölçekler + Add Drop Shadow Alt Gölge Ekle + Add Legend Across Top Göstergeyi Üste Ekle + First Row in Front İlk Satır Önde + First Column in Front İlk Sütun Önde + Legend Gösterge + Column Width Sütun Genişliği + Bar Width Çubuk Genişliği + Cluster Width Küme Genişliği + Mark Data Points Veri Noktalarını İşaretle + Connect Data Points Veri Noktalarını Bağla + Edge-to-Edge Lines Kenardan Kenara Çizgiler + Draw Filled Lines Dolgulu Çizgiler Çiz + Tick Values Onay İşareti Değerleri + Override Calculated Values Hesaplanan Değerleri Geçersiz Kıl + Divisions Bölümler + Tick Marks Ölçek İşaretleri + Tick Marks per Division Bölüm Başına Ölçek İşareti + Draw Tick Marks Between Labels Etiketler Arasına Ölçek İşaretleri Çiz + Add Labels Etiket Ekle + Maximum must be more than Minimum. En çok, en azdan büyük olmalıdır. + Column Graph Sütun Grafik + Stacked Column Graph Yığılmış Sütun Grafik + Bar Graph Çubuk Grafik + Stacked Bar Graph Yığılmış Çubuk Grafik + Line Graph Çizgi Grafik + Area Graph Alan Grafik + Scatter Graph Dağılım Grafik + Pie Graph Pasta Grafik + Radar Graph Radar Grafik Graph Design Grafik Tasarımı Graph Marker Grafik İşaretleyici New Design Yeni Tasarım diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 84f296b0a6..521149139c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1428,6 +1428,40 @@ file-filter Templates Шаблони Graph Діаграма Graph Data Дані діаграми Graph Type Тип діаграми + Graph Options Параметри діаграми + Left Axis Ліва вісь + Right Axis Права вісь + Category Axis Вісь категорій + Separate Scales Окремі шкали + Add Drop Shadow Додати тінь + Add Legend Across Top Додати легенду вгорі + First Row in Front Перший рядок попереду + First Column in Front Перший стовпець попереду + Legend Легенда + Column Width Ширина стовпця + Bar Width Ширина смуги + Cluster Width Ширина групи + Mark Data Points Позначати точки даних + Connect Data Points З'єднувати точки даних + Edge-to-Edge Lines Лінії від краю до краю + Draw Filled Lines Малювати заповнені лінії + Tick Values Значення поділок + Override Calculated Values Замінити обчислені значення + Divisions Поділки + Tick Marks Позначки поділок + Tick Marks per Division Позначок на поділку + Draw Tick Marks Between Labels Малювати позначки між підписами + Add Labels Додати підписи + Maximum must be more than Minimum. Максимум має бути більшим за мінімум. + Column Graph Стовпчикова діаграма + Stacked Column Graph Стовпчикова діаграма з накопиченням + Bar Graph Лінійчата діаграма + Stacked Bar Graph Лінійчата діаграма з накопиченням + Line Graph Графік + Area Graph Діаграма з областями + Scatter Graph Точкова діаграма + Pie Graph Кругова діаграма + Radar Graph Пелюсткова діаграма Graph Design Дизайн діаграми Graph Marker Маркер діаграми New Design Новий дизайн diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index e8b9e5758f..39409effc1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1679,6 +1679,40 @@ file-filter Templates Mẫu Chop Design Cắt thiết kế Scale Design Co giãn thiết kế Graph Type Loại biểu đồ + Graph Options Tùy chọn biểu đồ + Left Axis Trục trái + Right Axis Trục phải + Category Axis Trục danh mục + Separate Scales Thang đo riêng + Add Drop Shadow Thêm đổ bóng + Add Legend Across Top Thêm chú giải ở trên cùng + First Row in Front Hàng đầu tiên ở trước + First Column in Front Cột đầu tiên ở trước + Legend Chú giải + Column Width Độ rộng cột + Bar Width Độ rộng thanh + Cluster Width Độ rộng nhóm + Mark Data Points Đánh dấu điểm dữ liệu + Connect Data Points Nối các điểm dữ liệu + Edge-to-Edge Lines Đường từ mép đến mép + Draw Filled Lines Vẽ đường tô đầy + Tick Values Giá trị vạch chia + Override Calculated Values Ghi đè giá trị đã tính + Divisions Số khoảng chia + Tick Marks Vạch chia + Tick Marks per Division Vạch chia mỗi khoảng + Draw Tick Marks Between Labels Vẽ vạch chia giữa các nhãn + Add Labels Thêm nhãn + Maximum must be more than Minimum. Giá trị tối đa phải lớn hơn giá trị tối thiểu. + Column Graph Biểu đồ cột + Stacked Column Graph Biểu đồ cột chồng + Bar Graph Biểu đồ thanh + Stacked Bar Graph Biểu đồ thanh chồng + Line Graph Biểu đồ đường + Area Graph Biểu đồ vùng + Scatter Graph Biểu đồ phân tán + Pie Graph Biểu đồ tròn + Radar Graph Biểu đồ radar Graphic Đồ họa Graphic Style Kiểu đồ họa Graphic Style Libraries Thư viện kiểu đồ họa diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 719968086f..29f2b424eb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1364,6 +1364,40 @@ file-filter Templates 模板 Graph 图表 Graph Data 图表数据 Graph Type 图表类型 + Graph Options 图表选项 + Left Axis 左轴 + Right Axis 右轴 + Category Axis 类别轴 + Separate Scales 分开刻度 + Add Drop Shadow 添加投影 + Add Legend Across Top 在顶部添加图例 + First Row in Front 第一行在前 + First Column in Front 第一列在前 + Legend 图例 + Column Width 列宽 + Bar Width 条形宽度 + Cluster Width 簇宽度 + Mark Data Points 标记数据点 + Connect Data Points 连接数据点 + Edge-to-Edge Lines 边到边线条 + Draw Filled Lines 绘制填充线 + Tick Values 刻度值 + Override Calculated Values 覆盖计算值 + Divisions 刻度数 + Tick Marks 刻度线 + Tick Marks per Division 每个刻度的刻度线数 + Draw Tick Marks Between Labels 在标签之间绘制刻度线 + Add Labels 添加标签 + Maximum must be more than Minimum. 最大值必须大于最小值。 + Column Graph 柱形图 + Stacked Column Graph 堆积柱形图 + Bar Graph 条形图 + Stacked Bar Graph 堆积条形图 + Line Graph 折线图 + Area Graph 面积图 + Scatter Graph 散点图 + Pie Graph 饼图 + Radar Graph 雷达图 Graph Design 图表设计 Graph Marker 图表标记 New Design 新建设计 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 121a8b8c02..018e2ff788 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1399,6 +1399,40 @@ file-filter Templates 範本 Graph 圖表 Graph Data 圖表資料 Graph Type 圖表類型 + Graph Options 圖表選項 + Left Axis 左軸 + Right Axis 右軸 + Category Axis 類別軸 + Separate Scales 分開刻度 + Add Drop Shadow 加入投影 + Add Legend Across Top 在頂端加入圖例 + First Row in Front 第一列在前 + First Column in Front 第一欄在前 + Legend 圖例 + Column Width 直條寬度 + Bar Width 橫條寬度 + Cluster Width 群組寬度 + Mark Data Points 標示資料點 + Connect Data Points 連接資料點 + Edge-to-Edge Lines 邊到邊線條 + Draw Filled Lines 繪製填色線條 + Tick Values 刻度值 + Override Calculated Values 覆寫計算值 + Divisions 刻度數 + Tick Marks 刻度記號 + Tick Marks per Division 每個刻度的記號數 + Draw Tick Marks Between Labels 在標籤之間繪製刻度記號 + Add Labels 加入標籤 + Maximum must be more than Minimum. 最大值必須大於最小值。 + Column Graph 直條圖 + Stacked Column Graph 堆疊直條圖 + Bar Graph 橫條圖 + Stacked Bar Graph 堆疊橫條圖 + Line Graph 折線圖 + Area Graph 區域圖 + Scatter Graph 散佈圖 + Pie Graph 圓形圖 + Radar Graph 雷達圖 Graph Design 圖表設計 Graph Marker 圖表標記 New Design 新增設計 diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 3aab5e1e44..3f89c73020 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -131,9 +131,7 @@ pub(crate) fn graph_dialog(app: &mut VectorcraftApp, id: &str) -> Result Date: Sat, 14 Nov 2026 10:48:08 -0800 Subject: [PATCH 2/4] Graphs: a value axis side without its own scale shows the scale drawn on it With Separate Scales off, or with no series on a side, the axis page shows the tick values of the scale the graph draws on that side instead of the bounds kept for it, and its Override Calculated Values is dimmed. --- crates/ui-egui/src/dialogs/graph_type.rs | 41 ++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/graph_type.rs b/crates/ui-egui/src/dialogs/graph_type.rs index 14bc371e74..7563a1f42c 100644 --- a/crates/ui-egui/src/dialogs/graph_type.rs +++ b/crates/ui-egui/src/dialogs/graph_type.rs @@ -656,9 +656,22 @@ fn value_page(ui: &mut egui::Ui, d: &mut Dialog, right: bool) { // Without Separate Scales the right axis shows the left axis' tick values, and with it a side // without series shows the other side's. let own_scale = scale_used(d, right); - check(ui, d, scale.overridden, tl!("Override Calculated Values"), own_scale); + // A side without a scale of its own shows the tick values of the scale drawn on it. + let drawn = if own_scale { + scale + } else if right { + &LEFT + } else { + &RIGHT + }; + check(ui, d, drawn.overridden, tl!("Override Calculated Values"), own_scale); ui.add_enabled_ui(own_scale && d.bool(scale.overridden), |ui| { - let ((lo, hi), count) = (bounds(d, scale), divisions(d, scale)); + let ((lo, hi), count) = if own_scale || d.bool(drawn.overridden) { + (bounds(d, drawn), divisions(d, drawn)) + } else { + let (lo, hi, count) = calculated(d, drawn); + ((lo, hi), count) + }; for (key, label, shown) in [(scale.min, tl!("Minimum:"), lo), (scale.max, tl!("Maximum:"), hi)] { widgets::label_row(ui, label, LABEL_W, |ui| { if let Some(v) = widgets::plain_field(ui, ("graph-type", key), shown, "", 4, NUMBER_W) { @@ -1230,6 +1243,30 @@ mod tests { } } + /// A value axis side without a scale of its own shows the tick values of the scale drawn on it, + /// not bounds kept for it. + #[test] + fn a_side_without_its_own_scale_shows_the_scale_drawn_on_it() { + let mut app = app_with_graph("column"); + app.run(COMMAND, json!({"valueAxis": "both", "rightAxisMin": 100, "rightAxisMax": 200})).unwrap(); + open(&mut app).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().bool("rightOverrideValues")); + set(&mut app, PAGE, json!("rightAxis")); + let mut h = harness(app); + let shown = |h: &Harness, name: &str| { + let node = h.get_by_role_and_label(Role::TextInput, name); + node.accesskit_node().value().as_deref().and_then(|v| v.trim().parse::().ok()) + }; + assert_eq!((shown(&h, "Minimum:"), shown(&h, "Maximum:")), (Some(0.0), Some(4.0)), "the left scale, calculated"); + let overridden = h.get_by_role_and_label(Role::CheckBox, "Override Calculated Values"); + assert!(overridden.accesskit_node().is_disabled()); + // With Separate Scales and a series on the right axis, the side shows its own bounds. + set(h.state_mut(), "separateScales", json!(true)); + set(h.state_mut(), RIGHT_SERIES, json!([1])); + h.run_steps(3); + assert_eq!((shown(&h, "Minimum:"), shown(&h, "Maximum:")), (Some(100.0), Some(200.0))); + } + /// OK changes the graph and the series the dialog opened on, even when the selection changed /// while it was open. #[test] From 5ab24e23bf4c85da69f27347e7f2db62d4f56ef4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 10:56:48 -0800 Subject: [PATCH 3/4] Graphs: an axis value that is not a number reaches graph.setType from Graph Type Override Calculated Values fills only the axis values that are missing, so a value that is not a number is sent as it is and the command refuses it instead of the dialog putting a calculated value in its place. The right axis page without a scale of its own is described in docs/mcp.md. --- crates/ui-egui/src/dialogs/graph_type.rs | 21 +++++++++++++++++++-- docs/mcp.md | 6 ++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/graph_type.rs b/crates/ui-egui/src/dialogs/graph_type.rs index 7563a1f42c..107b98357f 100644 --- a/crates/ui-egui/src/dialogs/graph_type.rs +++ b/crates/ui-egui/src/dialogs/graph_type.rs @@ -412,12 +412,14 @@ fn settle(d: &mut Dialog) { continue; } let ((lo, hi), count) = (bounds(d, axis), divisions(d, axis)); + // A value that is not a number stays, and `graph.setType` refuses it. + let missing = |d: &Dialog, key: &str| d.fields.get(key).is_none_or(Value::is_null); for (key, value) in [(axis.min, json!(lo)), (axis.max, json!(hi))] { - if number(d, key).is_none() { + if missing(d, key) { d.fields.insert(key.into(), value); } } - if number(d, axis.ticks).is_none_or(|n| n < 1.0) { + if missing(d, axis.ticks) || number(d, axis.ticks).is_some_and(|n| n < 1.0) { d.fields.insert(axis.ticks.into(), json!(count)); } } @@ -1148,6 +1150,21 @@ mod tests { assert_eq!(problem(app.ui.dialog.as_ref().unwrap()), None); set(&mut app, RIGHT_SERIES, json!([1])); assert!(problem(app.ui.dialog.as_ref().unwrap()).is_some()); + // A value that is not a number is not replaced by a calculated one: the command refuses it. + open(&mut app).unwrap(); + set(&mut app, "axisMax", json!("ten")); + settle(app.ui.dialog.as_mut().unwrap()); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["axisMax"], "ten"); + let before = steps(&app); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert_eq!((steps(&app), query(&mut app)["axisMax"].as_f64()), (before, Some(20.0))); + open(&mut app).unwrap(); + for (key, value) in [("rightOverrideValues", json!(true)), ("rightAxisMin", json!(5)), ("rightAxisMax", json!(1))] { + set(&mut app, key, value); + } + set(&mut app, "valueAxis", json!("both")); + set(&mut app, "separateScales", json!(true)); + set(&mut app, RIGHT_SERIES, json!([1])); // A pie graph has no value axis to refuse. set(&mut app, "type", json!("pie")); assert_eq!(problem(app.ui.dialog.as_ref().unwrap()), None); diff --git a/docs/mcp.md b/docs/mcp.md index f241010b04..f9c901f2da 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10144,8 +10144,10 @@ the whole graph, as the command applies them; `type` or `valueAxis` is `null` wh OK then leaves each series as it is. OK runs `graph.setType` with the fields: `legend` for every type but pie, and the three pie options for a pie graph. When the selection changed while the dialog was open, it also sends `id`, and `seriesIndexes` while series are picked and a `type` or `valueAxis` goes to them, so it changes the graph and the series -the dialog opened on. With Separate Scales, a value axis side that no series is on draws the other side's scale, and -its Override Calculated Values is dimmed. With nothing changed it closes without a step of history. An overridden value +the dialog opened on. A value axis side without a scale of its own (the right side without Separate Scales, or with +Separate Scales a side that no series is on) shows the tick values of the scale drawn on it, and its Override Calculated +Values is dimmed. A value of `axisMin`, `axisMax` or `ticks` (or of the right axis' three) that is not a number is sent +as it is, and `graph.setType` refuses it. With nothing changed it closes without a step of history. An overridden value axis whose maximum is not more than its minimum is the error "Maximum must be more than Minimum." and the dialog stays open. The types the dialog dims for picked series (those that cannot go in the graph), the options it dims for a type and the pages it lists are VectorCraft's own rules. From 38edd52b3ca55fae8da38ef270c65518e0dfef10 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 20:02:17 -0800 Subject: [PATCH 4/4] Graph Type: keep the dialog to its document and current data - OK closes without a change when another document became active while the dialog was open, since graph ids repeat between documents. - The calculated tick values are recalculated when the document's revision changes, so a data edit made while the dialog is open gives Override Calculated Values the new values. - Each overridden Minimum, Maximum or Divisions that still holds the value calculated before takes the new calculated value on its own, so a value typed beside it stays, and a value the graph had when the dialog opened is kept. - With Override Calculated Values off, the dimmed Minimum, Maximum and Divisions fields show the calculated values that OK applies. --- crates/ui-egui/src/dialogs/graph_type.rs | 170 +++++++++++++++++++++-- 1 file changed, 155 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/dialogs/graph_type.rs b/crates/ui-egui/src/dialogs/graph_type.rs index 107b98357f..083adfacb6 100644 --- a/crates/ui-egui/src/dialogs/graph_type.rs +++ b/crates/ui-egui/src/dialogs/graph_type.rs @@ -35,10 +35,11 @@ pub(super) const PAGE: &str = "__page"; const COMMAND: &str = "graph.setType"; -/// The graph the dialog opened on, the series picked with Group Selection, the graph's own type and +/// The document and the graph the dialog opened on, the series picked with Group Selection, the graph's own type and /// value axis side, each series' own type, the series on the right axis, the tick values each value axis is calculated with for the -/// options the dialog shows and the options they were calculated for, and the parameters OK would +/// options the dialog shows and the options and document revision they were calculated for, and the parameters OK would /// have sent as the dialog opened. +const DOC: &str = "__doc"; const GRAPH_ID: &str = "__graphId"; const SERIES: &str = "__series"; const GRAPH: &str = "__graph"; @@ -168,6 +169,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { let queried = app.session.execute(COMMAND, &json!({})).map_err(|e| e.to_string())?; let subject = vectorcraft_engine::graph_type_subject(&app.session).map_err(|e| e.to_string())?; let st = app.session.active().ok_or("no document open")?; + let uid = st.uid; let spec = st.doc.node(subject.id).and_then(|n| n.extras().graph.as_deref()).ok_or("select a graph")?; let mut fields = queried.as_object().cloned().unwrap_or_default(); // The options the query leaves out for the graph's types: the dialog shows each one once the @@ -201,6 +203,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { fields.insert(axis.overridden.into(), overridden.clone()); fields.insert(axis.opened_overridden.into(), overridden); } + fields.insert(DOC.into(), json!(uid)); fields.insert(GRAPH_ID.into(), json!(subject.id.0)); fields.insert(SERIES.into(), json!(subject.series)); fields.insert(GRAPH.into(), json!(spec.kind.id())); @@ -210,7 +213,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { fields.insert(CALCULATED.into(), json!(calculated)); fields.insert(PAGE.into(), json!("options")); let mut d = Dialog { kind: KIND.into(), fields }; - let key = pending(&d); + let key = calculated_for(app, &d); d.fields.insert(CALCULATED_FOR.into(), key); settle(&mut d); let opened = params(&d); @@ -349,11 +352,26 @@ fn pending(d: &Dialog) -> Value { json!([graph_kind(d).id(), kinds, graph_axis(d), d.bool("separateScales"), side]) } -/// Calculate the tick values again when the options they depend on changed since they were last -/// calculated: the graph with the type, the value axis side and Separate Scales the dialog shows, and -/// the picked series on the type and side picked for them. +/// Whether the active document is the one the dialog opened on. +fn on_its_document(app: &VectorcraftApp, d: &Dialog) -> bool { + app.session.active().is_some_and(|st| d.fields.get(DOC).and_then(Value::as_u64) == Some(st.uid)) +} + +/// What the calculated tick values were calculated for: the options they depend on and the +/// revision of the active document, which an edit of the graph's data changes. +fn calculated_for(app: &VectorcraftApp, d: &Dialog) -> Value { + json!([pending(d), app.session.active().map(|st| st.revision)]) +} + +/// Calculate the tick values again when the options they depend on or the document changed since +/// they were last calculated: the graph with the type, the value axis side and Separate Scales the +/// dialog shows, and the picked series on the type and side picked for them. Nothing while another +/// document is active. fn recalculate(app: &VectorcraftApp, d: &mut Dialog) { - let key = pending(d); + if !on_its_document(app, d) { + return; + } + let key = calculated_for(app, d); if d.fields.get(CALCULATED_FOR) == Some(&key) { return; } @@ -374,14 +392,23 @@ fn recalculate(app: &VectorcraftApp, d: &mut Dialog) { vectorcraft_engine::graph_calculated_axes(&spec).iter().map(|(lo, hi, divisions)| json!([lo, hi, divisions])).collect(); let before = d.fields.insert(CALCULATED.into(), json!(calculated)); d.fields.insert(CALCULATED_FOR.into(), key); - // An overridden axis that still holds the values calculated before takes the new ones. + // Each tick value of an overridden axis that still holds the value calculated before takes the + // new one, unless it is the value the graph had when the dialog opened. for axis in [&LEFT, &RIGHT] { + if !d.bool(axis.overridden) { + continue; + } let was = |i: usize| before.as_ref()?.get(axis.index)?.get(i)?.as_f64(); - let keys = [axis.min, axis.max, axis.ticks]; - if d.bool(axis.overridden) && keys.iter().enumerate().all(|(i, key)| number(d, key).is_some_and(|v| Some(v) == was(i))) { - for key in keys { - d.fields.remove(key); - } + let saved = |key: &str| d.fields.get(OPENED)?.get(key)?.as_f64().filter(|_| d.bool(axis.opened_overridden)); + let (lo, hi, divisions) = self::calculated(d, axis); + let replaced: Vec<(&str, Value)> = [(axis.min, json!(lo)), (axis.max, json!(hi)), (axis.ticks, json!(divisions))] + .into_iter() + .enumerate() + .filter(|(i, (key, _))| number(d, key).is_some_and(|v| Some(v) == was(*i) && Some(v) != saved(key))) + .map(|(_, field)| field) + .collect(); + for (key, value) in replaced { + d.fields.insert(key.into(), value); } } } @@ -479,6 +506,11 @@ fn same(a: &Value, b: &Value) -> bool { } fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + // Another document is active: its graphs have ids of their own, so OK closes without a change. + if !on_its_document(app, d) { + app.ui.dialog = None; + return Ok(Value::Null); + } let mut d = d.clone(); recalculate(app, &mut d); if let Some(why) = problem(&d) { @@ -658,7 +690,8 @@ fn value_page(ui: &mut egui::Ui, d: &mut Dialog, right: bool) { // Without Separate Scales the right axis shows the left axis' tick values, and with it a side // without series shows the other side's. let own_scale = scale_used(d, right); - // A side without a scale of its own shows the tick values of the scale drawn on it. + // A side without a scale of its own shows the tick values of the scale drawn on it, and with + // Override Calculated Values off a scale shows the values it is calculated with. let drawn = if own_scale { scale } else if right { @@ -668,7 +701,7 @@ fn value_page(ui: &mut egui::Ui, d: &mut Dialog, right: bool) { }; check(ui, d, drawn.overridden, tl!("Override Calculated Values"), own_scale); ui.add_enabled_ui(own_scale && d.bool(scale.overridden), |ui| { - let ((lo, hi), count) = if own_scale || d.bool(drawn.overridden) { + let ((lo, hi), count) = if d.bool(drawn.overridden) { (bounds(d, drawn), divisions(d, drawn)) } else { let (lo, hi, count) = calculated(d, drawn); @@ -1258,6 +1291,7 @@ mod tests { { assert!(crate::dialogs::protected_field(d, field), "{field}"); } + assert!(crate::dialogs::protected_field(d, DOC)); } /// A value axis side without a scale of its own shows the tick values of the scale drawn on it, @@ -1365,4 +1399,110 @@ mod tests { assert_eq!(q["prefix"].as_str().map(str::len), Some(AFFIX_CHARS)); assert_eq!(q["ticks"].as_f64(), Some(*DIVISIONS.end())); } + + /// With Override Calculated Values turned off, the dimmed fields show the calculated tick + /// values OK gives the axis, not values typed while it was on. + #[test] + fn the_dimmed_fields_show_the_calculated_tick_values() { + let mut app = app_with_graph("column"); + open(&mut app).unwrap(); + set(&mut app, PAGE, json!("valueAxis")); + set(&mut app, "overrideValues", json!(true)); + set(&mut app, "axisMax", json!(50.0)); + set(&mut app, "ticks", json!(10)); + set(&mut app, "overrideValues", json!(false)); + let h = harness(app); + let shown = |name: &str| { + let node = h.get_by_role_and_label(Role::TextInput, name); + node.accesskit_node().value().as_deref().and_then(|v| v.trim().parse::().ok()) + }; + assert_eq!((shown("Minimum:"), shown("Maximum:"), shown("Divisions:")), (Some(0.0), Some(4.0), Some(4.0))); + } + + /// The tick values are calculated again when the graph's data changes while the dialog is open. + #[test] + fn a_data_change_calculates_the_tick_values_again() { + let mut app = app_with_graph("column"); + open(&mut app).unwrap(); + assert_eq!(calculated(app.ui.dialog.as_ref().unwrap(), &LEFT).1, 4.0); + app.session.execute("graph.setData", &json!({"csv": ",a,b\nQ1,1,2\nQ2,3,40"})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + recalculate(&app, &mut d); + assert!(calculated(&d, &LEFT).1 >= 40.0, "{:?}", calculated(&d, &LEFT)); + // Turned on now, Override Calculated Values starts from the new values. + d.fields.insert("overrideValues".into(), json!(true)); + app.ui.dialog = Some(d); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(query(&mut app)["axisMax"].as_f64().is_some_and(|max| max >= 40.0)); + // Tick values the graph has keep, even when they are the calculated ones. + let mut app = app_with_graph("column"); + app.run(COMMAND, json!({"axisMin": 0, "axisMax": 4, "ticks": 4})).unwrap(); + open(&mut app).unwrap(); + app.session.execute("graph.setData", &json!({"csv": ",a,b\nQ1,1,2\nQ2,3,40"})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + recalculate(&app, &mut d); + settle(&mut d); + app.ui.dialog = Some(d); + let before = steps(&app); + assert_eq!(crate::dialogs::confirm(&mut app), Ok(Value::Null)); + assert_eq!((steps(&app), query(&mut app)["axisMax"].as_f64()), (before, Some(4.0))); + // A value typed in stays, and the values filled beside it are calculated again. + let mut app = app_with_graph("column"); + open(&mut app).unwrap(); + set(&mut app, "overrideValues", json!(true)); + settle(app.ui.dialog.as_mut().unwrap()); + set(&mut app, "axisMax", json!(50.0)); + app.session.execute("graph.setData", &json!({"csv": ",a,b\nQ1,-30,2\nQ2,3,4"})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + recalculate(&app, &mut d); + let (lo, _, divisions) = calculated(&d, &LEFT); + assert!(lo < 0.0, "{lo}"); + app.ui.dialog = Some(d); + crate::dialogs::confirm(&mut app).unwrap(); + let q = query(&mut app); + assert_eq!((q["axisMin"].as_f64(), q["axisMax"].as_f64(), q["ticks"].as_u64()), (Some(lo), Some(50.0), Some(divisions as u64))); + // With both bounds typed in, the divisions filled are calculated again, not the count an + // axis without one is drawn in. + let mut app = app_with_graph("column"); + open(&mut app).unwrap(); + set(&mut app, "overrideValues", json!(true)); + settle(app.ui.dialog.as_mut().unwrap()); + set(&mut app, "axisMin", json!(-100.0)); + set(&mut app, "axisMax", json!(100.0)); + app.session.execute("graph.setData", &json!({"csv": ",a,b\nQ1,-30,2\nQ2,3,4"})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + recalculate(&app, &mut d); + settle(&mut d); + assert_eq!(number(&d, "ticks"), Some(calculated(&d, &LEFT).2 as f64)); + // Each value the graph had keeps, beside one typed in. + let mut app = app_with_graph("column"); + app.run(COMMAND, json!({"axisMin": 0, "axisMax": 4, "ticks": 4})).unwrap(); + open(&mut app).unwrap(); + set(&mut app, "axisMax", json!(50.0)); + app.session.execute("graph.setData", &json!({"csv": ",a,b\nQ1,-30,2\nQ2,3,4"})).unwrap(); + let mut d = app.ui.dialog.clone().unwrap(); + recalculate(&app, &mut d); + app.ui.dialog = Some(d); + crate::dialogs::confirm(&mut app).unwrap(); + let q = query(&mut app); + assert_eq!((q["axisMin"].as_f64(), q["axisMax"].as_f64(), q["ticks"].as_u64()), (Some(0.0), Some(50.0), Some(4))); + } + + /// OK changes nothing while another document is active, even one with a graph of the same id. + #[test] + fn ok_changes_nothing_in_another_document() { + let mut app = app_with_graph("column"); + let first = app.session.active().unwrap().selection.objects[0]; + open(&mut app).unwrap(); + set(&mut app, "type", json!("line")); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.run("graph.create", json!({"type": "column", "x": 0, "y": 0, "width": 200, "height": 150, "csv": ",a,b\nQ1,1,2\nQ2,3,4"})).unwrap(); + let second = app.session.active().unwrap().selection.objects[0]; + assert_eq!(first, second, "the same id in both documents"); + let before = steps(&app); + assert_eq!(crate::dialogs::confirm(&mut app), Ok(Value::Null)); + assert!(app.ui.dialog.is_none()); + assert_eq!(steps(&app), before); + assert_eq!(app.session.execute(COMMAND, &json!({"id": second.0})).unwrap()["type"], "column"); + } }