mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Graphs: Object › Graph › Column draws columns with graph designs
Object › Graph › Column… was a menu stub, so a graph's columns and bars were always plain rectangles. The reference app fills them with a graph design, vertically scaled, uniformly scaled, repeated with a value for each copy, or stretched at a guide line. graph.column (Column…) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a design saved by Object › Graph › Design. Vertically Scaled stretches the design to each column's length at the column's width. Uniformly Scaled scales it both ways to that length and centers it on the column. Repeating stacks a copy for each Each Design Represents value out from the zero line, and For Fractions chops the last copy at the value or scales it down. Sliding keeps the part above the design's horizontal guide line in proportion and stretches the part below. The design and its options are saved with the graph, and the graph follows the design when it is renamed or deleted. Columns drawn with a design cast no drop shadow. A marker or legend design made of one path is now drawn in a group of its own. Painting a series with Group Selection then leaves the design's paint alone, as it already did for designs of several objects. Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
@@ -196,6 +196,57 @@ macro_rules! ids {
|
||||
ids!(PiePosition { Even => "even", Ratio => "ratio", Stacked => "stacked" });
|
||||
ids!(PieSort { None => "none", All => "all", First => "first" });
|
||||
|
||||
/// Object › Graph › Column › Column Type: how a column design fills a column or bar. Vertically Scaled stretches it
|
||||
/// along the value axis to the column's length at the column's width; Uniformly Scaled scales it both ways to that
|
||||
/// length, centered on the column; Repeating stacks copies that each stand for [`ColumnDesign::each`]; Sliding scales the
|
||||
/// part above its guide line (in a bar, right of it) in proportion to the column's width and stretches the rest.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ColumnType {
|
||||
#[default]
|
||||
Vertical,
|
||||
Uniform,
|
||||
Repeating,
|
||||
Sliding,
|
||||
}
|
||||
|
||||
/// Object › Graph › Column › For Fractions: a repeating design's last copy, for the part of a value less than one
|
||||
/// design, is cut off (Chop) or scaled down along the value axis (Scale).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Fractions {
|
||||
#[default]
|
||||
Chop,
|
||||
Scale,
|
||||
}
|
||||
|
||||
ids!(ColumnType { Vertical => "vertical", Uniform => "uniform", Repeating => "repeating", Sliding => "sliding" });
|
||||
ids!(Fractions { Chop => "chop", Scale => "scale" });
|
||||
|
||||
/// Object › Graph › Column: the graph design a series' columns or bars are drawn with, and how it fills them.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ColumnDesign {
|
||||
pub design: String,
|
||||
#[serde(skip_serializing_if = "ColumnType::is_default")]
|
||||
pub column_type: ColumnType,
|
||||
/// Repeating: the value one copy of the design stands for (Each Design Represents).
|
||||
pub each: f64,
|
||||
#[serde(skip_serializing_if = "Fractions::is_default")]
|
||||
pub fractions: Fractions,
|
||||
}
|
||||
|
||||
impl ColumnDesign {
|
||||
/// Each Design Represents for a new column design.
|
||||
pub const EACH: f64 = 10.0;
|
||||
}
|
||||
|
||||
impl Default for ColumnDesign {
|
||||
fn default() -> Self {
|
||||
Self { design: String::new(), column_type: ColumnType::Vertical, each: Self::EACH, fractions: Fractions::Chop }
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph Type › Value Axis: which side of the plot the value axis is drawn on.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -345,6 +396,11 @@ pub struct GraphSpec {
|
||||
/// series index, for line, scatter and radar series; `None` and missing entries draw the default square.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub series_markers: Vec<Option<String>>,
|
||||
/// Object › Graph › Column: the graph design each series' columns or bars (and legend swatch) are drawn with, by
|
||||
/// series index, for column, stacked column, bar and stacked bar series; `None` and missing entries draw plain
|
||||
/// columns.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub series_columns: Vec<Option<ColumnDesign>>,
|
||||
/// Graph Type › Tick Marks for the value axis (the left one, or the bottom one of bar graphs) and the right one:
|
||||
/// length, and tick marks per division (0 and 1 = one, at the labels).
|
||||
#[serde(skip_serializing_if = "TickLength::is_short")]
|
||||
@@ -479,6 +535,7 @@ impl Default for GraphSpec {
|
||||
right_axis_min: None,
|
||||
right_axis_max: None,
|
||||
series_markers: vec![],
|
||||
series_columns: vec![],
|
||||
label_styles: LabelStyles::default(),
|
||||
first_row_in_front: false,
|
||||
first_column_in_front: false,
|
||||
|
||||
@@ -91,8 +91,8 @@ pub use assets::ExportAsset;
|
||||
pub use corners::LiveCorners;
|
||||
pub use depth::MAX_DEPTH;
|
||||
pub use graph::{
|
||||
GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength,
|
||||
ValueAxisSide,
|
||||
ColumnDesign, ColumnType, Fractions, GraphDesign, GraphKind, GraphSpec, LabelStyles, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition,
|
||||
PieSort, SeriesPaint, TickLength, ValueAxisSide,
|
||||
};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
|
||||
+713
-27
@@ -11,9 +11,10 @@ use std::sync::Arc;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, CharStyle, Document, GraphDesign, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind, TextObject, TickLength, ValueAxisSide,
|
||||
Appearance, CharStyle, ColumnDesign, ColumnType, Document, Fractions, GraphDesign, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind,
|
||||
TextObject, TickLength, ValueAxisSide,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
|
||||
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::*;
|
||||
@@ -56,6 +57,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
design
|
||||
),
|
||||
cmd!(
|
||||
"graph.column",
|
||||
"Column…",
|
||||
["Object", "Graph"],
|
||||
None,
|
||||
"{id?, seriesIndexes?: [index], design?: name|null, columnType?: vertical|uniform|repeating|sliding, each?: value (more than 0), fractions?: chop|scale} draw the columns or bars and the legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, its backmost object as its frame and its guides left out: vertical stretches it along the value axis to each column's length at the column's width, uniform scales it both ways to that length, centered on the column, repeating stacks copies out from the zero line that each stand for `each` and cuts off the last one where the value ends (chop) or scales it down to fit (scale) (vertical when one copy would be longer than the canvas), and sliding scales the part above its horizontal guide line (in bar graphs, right of a vertical one) in proportion to the column's width and stretches the part below to fill the rest (vertical without a guide, or in a column too short for the part above); the design keeps its own orientation for negative values, and columns drawn with a design cast no drop shadow; null = plain columns; options left out keep the series' shared ones, else vertical, 10 and chop. Column, stacked column, bar and stacked bar series. No design → {designs: [names], design: the selected series' design or null, columnType, each, fractions}",
|
||||
has_selection,
|
||||
set_column
|
||||
),
|
||||
cmd!(
|
||||
"graph.marker",
|
||||
"Marker…",
|
||||
@@ -311,6 +321,8 @@ struct Gen<'a> {
|
||||
out: Vec<Arc<Node>>,
|
||||
/// The labels' kept character styles.
|
||||
styles: &'a vectorcraft_doc::LabelStyles,
|
||||
/// What the column designs may still add.
|
||||
columns: Allowance,
|
||||
}
|
||||
|
||||
/// What a graph label shows, for its kept character style.
|
||||
@@ -397,10 +409,26 @@ impl Gen<'_> {
|
||||
/// A data point's marker filling `cell`: the series' marker design, else the default square in the series' paint.
|
||||
fn marker(&mut self, design: Option<&Arc<Node>>, cell: Rect, (fill, stroke, w): (Paint, Paint, f64)) -> Arc<Node> {
|
||||
match design.and_then(|a| fit_design(self.d, a, cell)) {
|
||||
Some(n) => n,
|
||||
Some(n) => self.design_copy(n),
|
||||
None => self.path(shapes::rectangle(cell), fill, stroke, w),
|
||||
}
|
||||
}
|
||||
/// A legend swatch filling `cell`: the series' column design, else its [`Self::marker`].
|
||||
fn swatch(&mut self, column: Option<&ColumnArt>, marker: Option<&Arc<Node>>, cell: Rect, paint: (Paint, Paint, f64)) -> Arc<Node> {
|
||||
let fitted = match column {
|
||||
Some(c) if self.columns.take(c.art.count()) => fit_design(self.d, &c.art, cell),
|
||||
_ => None,
|
||||
};
|
||||
match fitted {
|
||||
Some(n) => self.design_copy(n),
|
||||
None => self.marker(marker, cell, paint),
|
||||
}
|
||||
}
|
||||
/// A copy of design art drawn in a series: a single path gets a group of its own, as painting a series paints the
|
||||
/// paths directly inside its group and the design keeps its own paint.
|
||||
fn design_copy(&mut self, n: Arc<Node>) -> Arc<Node> {
|
||||
if matches!(n.kind, NodeKind::Path { .. }) { Arc::new(Node::group(self.d.alloc_id(), vec![n])) } else { n }
|
||||
}
|
||||
/// A series group. Empty series are omitted by the caller; the index is stored on the group
|
||||
/// so later paint edits find the series without counting siblings.
|
||||
fn series(&mut self, name: &str, index: usize, children: Vec<Arc<Node>>) -> Arc<Node> {
|
||||
@@ -495,24 +523,260 @@ fn marker_cost(g: &GraphSpec, designs: &[Option<Arc<Node>>]) -> usize {
|
||||
designs.iter().flatten().map(|a| a.count().saturating_mul(points)).fold(0, usize::saturating_add)
|
||||
}
|
||||
|
||||
/// The frame graph design `art` is drawn around: its backmost object's bounds (the whole art's when the design is a
|
||||
/// single object). `None` for art with no area.
|
||||
fn design_frame(art: &Node) -> Option<Rect> {
|
||||
let usable = |b: &Rect| b.width() > 1e-9 && b.height() > 1e-9 && b.width().is_finite() && b.height().is_finite();
|
||||
let group = matches!(art.kind, NodeKind::Group { .. });
|
||||
let backmost = art.children().filter(|_| group).and_then(|c| c.first()).and_then(|c| c.geometric_bounds()).filter(usable);
|
||||
backmost.or_else(|| art.geometric_bounds().filter(usable))
|
||||
}
|
||||
|
||||
/// Graph design `art` scaled into `cell` (Object › Graph › Marker): its backmost object, the rectangle a design is
|
||||
/// drawn around, fills the cell (the whole art does when the design is a single object), strokes scaling with it.
|
||||
/// Fresh ids. `None` for art with no area.
|
||||
fn fit_design(d: &mut Document, art: &Node, cell: Rect) -> Option<Arc<Node>> {
|
||||
let usable = |b: &Rect| b.width() > 1e-9 && b.height() > 1e-9 && b.width().is_finite() && b.height().is_finite();
|
||||
let group = matches!(art.kind, NodeKind::Group { .. });
|
||||
let backmost = art.children().filter(|_| group).and_then(|c| c.first()).and_then(|c| c.geometric_bounds()).filter(usable);
|
||||
let frame = backmost.or_else(|| art.geometric_bounds().filter(usable))?;
|
||||
let a = Affine::translate(cell.origin().to_vec2())
|
||||
* Affine::scale_non_uniform(cell.width() / frame.width(), cell.height() / frame.height())
|
||||
* Affine::translate(-frame.origin().to_vec2());
|
||||
let a = fit_affine(design_frame(art)?, cell);
|
||||
// Strokes, effects and pattern tiles scale with the art.
|
||||
let art = super::place::transformed(art.clone(), a);
|
||||
Some(Arc::new(d.reid(&art)))
|
||||
}
|
||||
|
||||
/// Build the graph's children.
|
||||
/// The map that puts a design's frame `frame` on `cell` ([`fit_design`]).
|
||||
fn fit_affine(frame: Rect, cell: Rect) -> Affine {
|
||||
Affine::translate(cell.origin().to_vec2())
|
||||
* Affine::scale_non_uniform(cell.width() / frame.width(), cell.height() / frame.height())
|
||||
* Affine::translate(-frame.origin().to_vec2())
|
||||
}
|
||||
|
||||
/// Most objects a graph's column designs may add (each column, repeated copy and legend swatch draws a copy).
|
||||
const COLUMN_BUDGET: usize = 200_000;
|
||||
|
||||
/// A series' column design (Object › Graph › Column): its options, its art without guide lines, and the bounds of
|
||||
/// those guides.
|
||||
struct ColumnArt {
|
||||
options: ColumnDesign,
|
||||
art: Node,
|
||||
guides: Vec<Rect>,
|
||||
}
|
||||
|
||||
/// `art` without the paths made into guides, at any depth.
|
||||
fn without_guides(art: &Node) -> Node {
|
||||
let mut n = art.clone();
|
||||
if let Some(ch) = n.children_mut() {
|
||||
let old = std::mem::take(ch);
|
||||
*ch = old.iter().filter(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. })).map(|c| Arc::new(without_guides(c))).collect();
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Each series' column design, when the document has the design.
|
||||
fn column_designs(d: &Document, g: &GraphSpec) -> Vec<Option<ColumnArt>> {
|
||||
let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES);
|
||||
(0..nser)
|
||||
.map(|s| {
|
||||
let options = g.series_columns.get(s)?.as_ref()?;
|
||||
let art = &d.graph_designs.iter().find(|x| x.name == options.design)?.art;
|
||||
let mut guides = vec![];
|
||||
art.walk(&mut |n| {
|
||||
if matches!(n.kind, NodeKind::Path { guide: true, .. })
|
||||
&& let Some(b) = n.geometric_bounds()
|
||||
{
|
||||
guides.push(b);
|
||||
}
|
||||
});
|
||||
Some(ColumnArt { options: options.clone(), art: without_guides(art), guides })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The value one copy of a repeating design stands for, when the design repeats and the value is usable.
|
||||
fn repeat_each(c: &ColumnDesign) -> Option<f64> {
|
||||
(c.column_type == ColumnType::Repeating && c.each.is_finite() && c.each > 0.0).then_some(c.each)
|
||||
}
|
||||
|
||||
/// The objects a graph's column designs may still add (out of [`COLUMN_BUDGET`]); `short` once a copy needed more than
|
||||
/// was left, and the graph is then drawn with plain columns.
|
||||
struct Allowance {
|
||||
left: usize,
|
||||
short: bool,
|
||||
}
|
||||
|
||||
impl Allowance {
|
||||
fn new() -> Self {
|
||||
Self { left: COLUMN_BUDGET, short: false }
|
||||
}
|
||||
/// Spend `n` objects, when that many are left.
|
||||
fn take(&mut self, n: usize) -> bool {
|
||||
match self.left.checked_sub(n) {
|
||||
Some(rest) => {
|
||||
self.left = rest;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.short = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Column or bar `rect` drawn with column design `c` (Object › Graph › Column), as a group of fresh art, its copies
|
||||
/// spent from `left`. `far_low`: the value end of the column is at the rect's lower coordinate (the top of a column above
|
||||
/// zero, the left end of a bar below zero), so repeating copies stack from the other end. `per_unit`: plot points per
|
||||
/// unit of value. `None` for art with no area, for a column of no length and when `left` runs out, which are drawn plain.
|
||||
fn column_art(
|
||||
d: &mut Document,
|
||||
c: &ColumnArt,
|
||||
rect: Rect,
|
||||
(horizontal, far_low): (bool, bool),
|
||||
per_unit: f64,
|
||||
left: &mut Allowance,
|
||||
) -> Option<Arc<Node>> {
|
||||
let frame = design_frame(&c.art)?;
|
||||
let length = |r: Rect| if horizontal { r.width() } else { r.height() };
|
||||
let thickness = |r: Rect| if horizontal { r.height() } else { r.width() };
|
||||
if length(rect) <= 1e-9 || !length(rect).is_finite() {
|
||||
return None;
|
||||
}
|
||||
let cost = c.art.count();
|
||||
// A copy of the design fitted to `cell`; and a copy moved by `a` and clipped to what it paints, cut at `at` (an x when
|
||||
// `along_x`, else a y) with the side below `at` kept when `keep_low` (two objects more).
|
||||
let fit = |d: &mut Document, cell: Rect, left: &mut Allowance| if left.take(cost) { fit_design(d, &c.art, cell) } else { None };
|
||||
let clipped = |d: &mut Document, a: Affine, (along_x, keep_low, at): (bool, bool, f64), left: &mut Allowance| {
|
||||
let art = left.take(cost + 2).then(|| d.reid(&super::place::transformed(c.art.clone(), a)))?;
|
||||
// The box of the copy and of each object in it that isn't a group or layer, as the brush art past a brushed
|
||||
// stroke counts only in its own object's box.
|
||||
let mut b = vectorcraft_render::painted_bounds(d, &art);
|
||||
art.walk(&mut |n| {
|
||||
if !matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) {
|
||||
b = vectorcraft_geom::union_opt(b, vectorcraft_render::painted_bounds(d, n));
|
||||
}
|
||||
});
|
||||
let b = b.or_else(|| art.geometric_bounds())?;
|
||||
let keep = match (along_x, keep_low) {
|
||||
(true, true) => Rect::new(b.x0, b.y0, at.max(b.x0), b.y1),
|
||||
(true, false) => Rect::new(at.min(b.x1), b.y0, b.x1, b.y1),
|
||||
(false, true) => Rect::new(b.x0, b.y0, b.x1, at.max(b.y0)),
|
||||
(false, false) => Rect::new(b.x0, at.min(b.y1), b.x1, b.y1),
|
||||
};
|
||||
Some(Arc::new(super::rasterfx::clip_group(d, shapes::rectangle(keep), FillRule::NonZero, art)))
|
||||
};
|
||||
// The part of the column from `a` to `b` points out from its zero end.
|
||||
let piece = |a: f64, b: f64| match (horizontal, far_low) {
|
||||
(true, true) => Rect::new(rect.x1 - b, rect.y0, rect.x1 - a, rect.y1),
|
||||
(true, false) => Rect::new(rect.x0 + a, rect.y0, rect.x0 + b, rect.y1),
|
||||
(false, true) => Rect::new(rect.x0, rect.y1 - b, rect.x1, rect.y1 - a),
|
||||
(false, false) => Rect::new(rect.x0, rect.y0 + a, rect.x1, rect.y0 + b),
|
||||
};
|
||||
let mut parts = vec![];
|
||||
match c.options.column_type {
|
||||
ColumnType::Uniform => {
|
||||
// Scaled both ways to the column's length, centered across it.
|
||||
let t = thickness(frame) * length(rect) / length(frame) / 2.0;
|
||||
let cell = if horizontal {
|
||||
Rect::new(rect.x0, rect.center().y - t, rect.x1, rect.center().y + t)
|
||||
} else {
|
||||
Rect::new(rect.center().x - t, rect.y0, rect.center().x + t, rect.y1)
|
||||
};
|
||||
parts.push(fit(d, cell, left)?);
|
||||
}
|
||||
ColumnType::Repeating => {
|
||||
// When the value per copy is unusable, too small to draw or makes one copy longer than the canvas, the design
|
||||
// is drawn vertically scaled.
|
||||
let Some(unit) = repeat_each(&c.options).map(|e| e * per_unit).filter(|u| u.is_finite() && *u > 1e-9 && *u <= crate::MAX_COORD) else {
|
||||
parts.push(fit(d, rect, left)?);
|
||||
return Some(Arc::new(Node::group(d.alloc_id(), parts)));
|
||||
};
|
||||
// The whole copies, and the points of the column left for the last one (none under a millionth of a point).
|
||||
let whole = ((length(rect) + 1e-6) / unit).floor();
|
||||
let rest = length(rect) - whole * unit;
|
||||
// Each copy is spent from `left`, which stops the loop.
|
||||
for i in 0..whole as usize {
|
||||
parts.push(fit(d, piece(i as f64 * unit, (i + 1) as f64 * unit), left)?);
|
||||
}
|
||||
if rest > 1e-6 {
|
||||
let start = whole * unit;
|
||||
match c.options.fractions {
|
||||
Fractions::Scale => parts.push(fit(d, piece(start, start + rest), left)?),
|
||||
Fractions::Chop => {
|
||||
// The last copy cut off where the column ends, and nowhere else.
|
||||
let cut = match (horizontal, far_low) {
|
||||
(true, true) => (true, false, rect.x0),
|
||||
(true, false) => (true, true, rect.x1),
|
||||
(false, true) => (false, false, rect.y0),
|
||||
(false, false) => (false, true, rect.y1),
|
||||
};
|
||||
parts.push(clipped(d, fit_affine(frame, piece(start, start + unit)), cut, left)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ColumnType::Sliding => match slide(frame, &c.guides, rect, horizontal) {
|
||||
Some((kept, stretched, at)) => {
|
||||
// Each copy cut at the guide, on its own side.
|
||||
parts.push(clipped(d, kept, (horizontal, !horizontal, at), left)?);
|
||||
parts.push(clipped(d, stretched, (horizontal, horizontal, at), left)?);
|
||||
}
|
||||
None => parts.push(fit(d, rect, left)?),
|
||||
},
|
||||
ColumnType::Vertical => parts.push(fit(d, rect, left)?),
|
||||
}
|
||||
Some(Arc::new(Node::group(d.alloc_id(), parts)))
|
||||
}
|
||||
|
||||
/// Sliding design with frame `frame` in column `rect`, at the column's width: the map of the part above the first
|
||||
/// horizontal guide in `guides` inside the frame (in a bar, right of the first vertical one), scaled in proportion and
|
||||
/// put at the rect's top (in a bar, its right end), the value end for values above zero; the map of the part below (left
|
||||
/// of) it, stretched or compressed to fill the rest; and where the guide lands (y, or x in a bar). Both maps put the guide there. `None` without such a guide, or when the column is too
|
||||
/// short for the part kept in proportion.
|
||||
fn slide(frame: Rect, guides: &[Rect], rect: Rect, horizontal: bool) -> Option<(Affine, Affine, f64)> {
|
||||
// A horizontal line (in a bar, a vertical one): longer than it is across, and at most a hundredth of the frame across.
|
||||
let inside = |g: &&Rect| {
|
||||
if horizontal {
|
||||
g.width() < g.height() && g.width() <= frame.width() / 100.0 && g.center().x > frame.x0 && g.center().x < frame.x1
|
||||
} else {
|
||||
g.height() < g.width() && g.height() <= frame.height() / 100.0 && g.center().y > frame.y0 && g.center().y < frame.y1
|
||||
}
|
||||
};
|
||||
let g = guides.iter().find(inside)?.center();
|
||||
if horizontal {
|
||||
let s = rect.height() / frame.height();
|
||||
let (kept, stretched) = (frame.x1 - g.x, g.x - frame.x0);
|
||||
let rest = rect.width() - kept * s;
|
||||
if !(rest.is_finite() && rest > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let k = rest / stretched;
|
||||
let kept_map = Affine::new([s, 0.0, 0.0, s, rect.x1 - frame.x1 * s, rect.y0 - frame.y0 * s]);
|
||||
let stretched_map = Affine::new([k, 0.0, 0.0, s, rect.x0 - frame.x0 * k, rect.y0 - frame.y0 * s]);
|
||||
Some((kept_map, stretched_map, rect.x1 - kept * s))
|
||||
} else {
|
||||
let s = rect.width() / frame.width();
|
||||
let (kept, stretched) = (g.y - frame.y0, frame.y1 - g.y);
|
||||
let rest = rect.height() - kept * s;
|
||||
if !(rest.is_finite() && rest > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let k = rest / stretched;
|
||||
let kept_map = Affine::new([s, 0.0, 0.0, s, rect.x0 - frame.x0 * s, rect.y0 - frame.y0 * s]);
|
||||
let stretched_map = Affine::new([s, 0.0, 0.0, k, rect.x0 - frame.x0 * s, rect.y1 - frame.y1 * k]);
|
||||
Some((kept_map, stretched_map, rect.y0 + kept * s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the graph's children: with each series' column design (Object › Graph › Column) when the document has it,
|
||||
/// and with plain columns for all when the designs would draw more than COLUMN_BUDGET objects.
|
||||
fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let column_looks = column_designs(d, g);
|
||||
match generate_with(d, g, &column_looks) {
|
||||
Some(out) => out,
|
||||
None => generate_with(d, g, &[]).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`generate`] with the column designs `column_looks`; `None` when they would draw more than COLUMN_BUDGET objects.
|
||||
fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option<ColumnArt>]) -> Option<Vec<Arc<Node>>> {
|
||||
let mark = |i: usize, series_fill: bool, stroke: Paint, width: f64| series_marks(g, i, series_fill, stroke, width);
|
||||
// Each series' marker design (Object › Graph › Marker), when the document has it; default squares for all
|
||||
// when the designs would draw more than MARKER_BUDGET objects.
|
||||
@@ -521,7 +785,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
designs.clear();
|
||||
}
|
||||
let design = |s: usize| designs.get(s).and_then(Option::as_ref);
|
||||
let mut b = Gen { d, out: vec![], styles: &g.label_styles };
|
||||
let column_look = |s: usize| column_looks.get(s).and_then(Option::as_ref);
|
||||
let mut b = Gen { d, out: vec![], styles: &g.label_styles, columns: Allowance::new() };
|
||||
// The labels' sizes (9 pt unless formatted), which their spacing follows.
|
||||
let (vs, cs, ls) =
|
||||
(label_size(&g.label_styles, Role::Value), label_size(&g.label_styles, Role::Category), label_size(&g.label_styles, Role::Legend));
|
||||
@@ -538,6 +803,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
// Each series' own type (Combine different graph types); one type for the whole graph unless a series has its own.
|
||||
let kinds: Vec<GraphKind> = (0..nser).map(|s| g.series_kind(s)).collect();
|
||||
let kind = |s: usize| kinds.get(s).copied().unwrap_or(g.kind);
|
||||
// Series drawn as columns or bars, which column designs apply to.
|
||||
let columnar = |s: usize| matches!(kind(s), GraphKind::Column | GraphKind::StackedColumn | GraphKind::Bar | GraphKind::StackedBar);
|
||||
let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar);
|
||||
// Where the art right of the plot ends (the right value axis' labels), so the legend starts after it.
|
||||
let mut right_edge = r.x1;
|
||||
@@ -759,6 +1026,11 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let (lo, hi, _) = scale(a);
|
||||
vpos(a, 0.0f64.clamp(lo, hi))
|
||||
};
|
||||
// Plot points per unit of value along value axis `a`.
|
||||
let per_unit = |a: usize| {
|
||||
let (lo, hi, _) = scale(a);
|
||||
(if horizontal { r.width() } else { r.height() }) / (hi - lo)
|
||||
};
|
||||
let mut axes = vec![];
|
||||
// Value axes with ticks and labels: along the bottom for bar graphs, else on the chosen sides (the right
|
||||
// one shows the right scale).
|
||||
@@ -964,10 +1236,24 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let Some(v) = cell(c, s) else { continue };
|
||||
let a = c0 + slot * i as f64 + (slot - bar) / 2.0;
|
||||
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
|
||||
let rect = bar_rect(a, bar, zero(axis(s)), vpos(axis(s), v));
|
||||
let (v0, v1) = (zero(axis(s)), vpos(axis(s), v));
|
||||
let rect = bar_rect(a, bar, v0, v1);
|
||||
bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect)));
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, w);
|
||||
shadowed.push((s, n.clone()));
|
||||
// A column drawn with a design casts no shadow.
|
||||
let n = match column_look(s).and_then(|c| column_art(b.d, c, rect, (horizontal, v1 < v0), per_unit(axis(s)), &mut b.columns)) {
|
||||
Some(n) => {
|
||||
// A design can reach past its column (Uniformly Scaled), so the legend keeps clear of it too.
|
||||
if let Some(art) = n.geometric_bounds() {
|
||||
bars_reach = Some(bars_reach.map_or(art, |r| r.union(art)));
|
||||
}
|
||||
n
|
||||
}
|
||||
None => {
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, w);
|
||||
shadowed.push((s, n.clone()));
|
||||
n
|
||||
}
|
||||
};
|
||||
if let Some(items) = series.get_mut(s) {
|
||||
items.push(n);
|
||||
}
|
||||
@@ -995,11 +1281,22 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
|
||||
let rect = bar_rect(a, w, v0, v1);
|
||||
bars_reach = Some(bars_reach.map_or(rect, |r| r.union(rect)));
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, sw);
|
||||
// A segment of 0 has no area to shade.
|
||||
if v != 0.0 {
|
||||
shadowed.push((s, n.clone()));
|
||||
}
|
||||
let n = match column_look(s).and_then(|c| column_art(b.d, c, rect, (horizontal, v1 < v0), per_unit(ax), &mut b.columns)) {
|
||||
Some(n) => {
|
||||
if let Some(art) = n.geometric_bounds() {
|
||||
bars_reach = Some(bars_reach.map_or(art, |r| r.union(art)));
|
||||
}
|
||||
n
|
||||
}
|
||||
None => {
|
||||
let n = b.path(shapes::rectangle(rect), fill, stroke, sw);
|
||||
// A segment of 0 has no area to shade.
|
||||
if v != 0.0 {
|
||||
shadowed.push((s, n.clone()));
|
||||
}
|
||||
n
|
||||
}
|
||||
};
|
||||
if let Some(items) = series.get_mut(s) {
|
||||
items.push(n);
|
||||
}
|
||||
@@ -1081,7 +1378,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
for (s, label, w) in row {
|
||||
let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(*s) == GraphKind::Line;
|
||||
let paint = mark(*s, true, Paint::None, 0.0);
|
||||
let swatch = b.marker(design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint);
|
||||
let swatch =
|
||||
b.swatch(column_look(*s).filter(|_| columnar(*s)), design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint);
|
||||
if let Some(items) = series.get_mut(*s) {
|
||||
items.push(swatch);
|
||||
}
|
||||
@@ -1103,10 +1401,11 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
}
|
||||
y += ls * 1.8 * label.split('|').count() as f64;
|
||||
let y = row_y;
|
||||
// Series drawn with markers show their marker design in the legend.
|
||||
// Series drawn with markers show their marker design in the legend, and series drawn with a column design
|
||||
// that design.
|
||||
let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(s) == GraphKind::Line;
|
||||
let paint = mark(s, true, Paint::None, 0.0);
|
||||
items.push(b.marker(design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint));
|
||||
items.push(b.swatch(column_look(s).filter(|_| columnar(s)), design(s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint));
|
||||
labels.push(b.label(Role::Legend, Point::new(x + 12.0, y + 7.5 + (ls - LABEL_SIZE) * 0.35), &label, Justify::Left, false));
|
||||
}
|
||||
}
|
||||
@@ -1153,7 +1452,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
||||
let grp = b.group("Legend", labels);
|
||||
b.out.push(grp);
|
||||
}
|
||||
b.out
|
||||
(!b.columns.short).then_some(b.out)
|
||||
}
|
||||
|
||||
fn children_bounds(children: &[Arc<Node>]) -> Option<Rect> {
|
||||
@@ -1499,11 +1798,16 @@ fn design_names(d: &Document) -> Vec<String> {
|
||||
d.graph_designs.iter().map(|x| x.name.clone()).collect()
|
||||
}
|
||||
|
||||
/// Whether graph `g` draws a series with design `name`, as its marker or its columns.
|
||||
fn draws_with(g: &GraphSpec, name: &str) -> bool {
|
||||
g.series_markers.iter().flatten().any(|m| m == name) || g.series_columns.iter().flatten().any(|c| c.design == name)
|
||||
}
|
||||
|
||||
/// Regenerate the graphs whose series use design `name` (it was added or removed).
|
||||
fn refresh_graphs_using(d: &mut Document, name: &str) -> Result<()> {
|
||||
let mut ids = vec![];
|
||||
d.walk(|n| {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| m == name)) {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| draws_with(g, name)) {
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
@@ -1521,6 +1825,7 @@ fn unused_designs(d: &Document) -> Vec<String> {
|
||||
d.walk(|n| {
|
||||
if let Some(g) = n.extras().graph.as_deref() {
|
||||
used.extend(g.series_markers.iter().flatten().cloned());
|
||||
used.extend(g.series_columns.iter().flatten().map(|c| c.design.clone()));
|
||||
}
|
||||
});
|
||||
d.graph_designs.iter().map(|x| x.name.clone()).filter(|n| !used.contains(n)).collect()
|
||||
@@ -1594,7 +1899,7 @@ fn design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut graphs = vec![];
|
||||
d.walk(|n| {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| *m == old)) {
|
||||
if n.extras().graph.as_deref().is_some_and(|g| draws_with(g, &old)) {
|
||||
graphs.push(n.id);
|
||||
}
|
||||
});
|
||||
@@ -1603,6 +1908,9 @@ fn design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for m in g.series_markers.iter_mut().flatten().filter(|m| **m == old) {
|
||||
*m = new.clone();
|
||||
}
|
||||
for c in g.series_columns.iter_mut().flatten().filter(|c| c.design == old) {
|
||||
c.design = new.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1693,6 +2001,74 @@ fn set_marker(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn set_column(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graph.column";
|
||||
let id = target(s, p, C)?;
|
||||
let mut spec = spec_of(s, id)?;
|
||||
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES);
|
||||
let picked = picked_series(s, p, id, count, C)?;
|
||||
let names = design_names(&s.doc()?.doc);
|
||||
let all: Vec<usize> = if picked.is_empty() { (0..count).collect() } else { picked.clone() };
|
||||
// The column design the picked series (all of them with none picked) share; a design deleted since doesn't count,
|
||||
// as those series draw plain columns.
|
||||
let used: Vec<Option<&ColumnDesign>> =
|
||||
all.iter().map(|i| spec.series_columns.get(*i).and_then(Option::as_ref).filter(|c| names.contains(&c.design))).collect();
|
||||
let shared = used.first().copied().flatten().filter(|c| used.iter().all(|x| *x == Some(*c))).cloned();
|
||||
let Some(v) = p.get("design") else {
|
||||
let options = shared.clone().unwrap_or_default();
|
||||
return Ok(json!({
|
||||
"designs": names,
|
||||
"design": shared.map(|c| c.design),
|
||||
"columnType": options.column_type.id(),
|
||||
"each": options.each,
|
||||
"fractions": options.fractions.id(),
|
||||
}));
|
||||
};
|
||||
let design = match v {
|
||||
Value::Null => None,
|
||||
Value::String(n) if names.contains(n) => Some(n.clone()),
|
||||
Value::String(n) => return Err(bad(C, format!("no design named `{n}`"))),
|
||||
_ => return Err(bad(C, "`design` is a design name, or null for plain columns")),
|
||||
};
|
||||
let mut options = shared.unwrap_or_default();
|
||||
if let Some(t) = p.get("columnType") {
|
||||
options.column_type =
|
||||
t.as_str().and_then(ColumnType::parse).ok_or_else(|| bad(C, "`columnType` is vertical, uniform, repeating or sliding"))?;
|
||||
}
|
||||
if let Some(e) = p.get("each") {
|
||||
options.each =
|
||||
e.as_f64().filter(|e| e.is_finite() && *e > 0.0).ok_or_else(|| bad(C, "`each` is the value one design stands for, more than 0"))?;
|
||||
}
|
||||
if let Some(f) = p.get("fractions") {
|
||||
options.fractions = f.as_str().and_then(Fractions::parse).ok_or_else(|| bad(C, "`fractions` is chop or scale"))?;
|
||||
}
|
||||
let reselect: Vec<u32> = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] };
|
||||
spec.series_columns.truncate(count);
|
||||
spec.series_columns.resize(count, None);
|
||||
for i in all {
|
||||
if let Some(c) = spec.series_columns.get_mut(i) {
|
||||
*c = design.clone().map(|design| ColumnDesign { design, ..options.clone() });
|
||||
}
|
||||
}
|
||||
while spec.series_columns.last().is_some_and(Option::is_none) {
|
||||
spec.series_columns.pop();
|
||||
}
|
||||
// Drawn once on a copy of the document, to refuse designs past the budget before the edit.
|
||||
let mut probe = (*s.doc()?.doc).clone();
|
||||
let looks = column_designs(&probe, &spec);
|
||||
if generate_with(&mut probe, &spec, &looks).is_none() {
|
||||
return Err(bad(C, "the design is too big to draw in every column of this graph"));
|
||||
}
|
||||
s.edit("Graph Column", |d, sel| {
|
||||
regenerate(d, id, spec)?;
|
||||
if !reselect.is_empty() {
|
||||
reselect_series(d, sel, id, &reselect);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id.0 }))
|
||||
}
|
||||
|
||||
fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "graph.setType";
|
||||
let id = target(s, p, C)?;
|
||||
@@ -1920,7 +2296,7 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{CharStyle, GraphKind, GraphSpec, NodeKind};
|
||||
use vectorcraft_doc::{CharStyle, ColumnDesign, ColumnType, Fractions, GraphKind, GraphSpec, NodeKind};
|
||||
|
||||
use crate::{NodeId, Session};
|
||||
|
||||
@@ -3365,6 +3741,16 @@ mod tests {
|
||||
assert!(!painted.contains(&Some("#ff0000".into())), "{painted:?}");
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert_eq!(design_fill(&s), painted);
|
||||
// A design of one path too.
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [r], "color": "#00f"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Square"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.marker", &json!({"design": "Square"})).unwrap();
|
||||
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
|
||||
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
|
||||
assert_eq!(design_fill(&s), [Some("#0000ff".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3414,6 +3800,306 @@ mod tests {
|
||||
assert!(s.execute("graph.design", &json!({"delete": ["Nope"]})).is_err());
|
||||
}
|
||||
|
||||
/// A column design: a `w` × `h` rectangle at the back, a `w`-wide ellipse over its top `top` points and a rectangle
|
||||
/// over the rest, with a horizontal guide line between them when `guide`, saved as `name`.
|
||||
fn save_column_design(s: &mut Session, name: &str, (w, h, top): (f64, f64, f64), guide: bool) {
|
||||
let shape = |s: &mut Session, kind: &str, x: f64, y: f64, w: f64, h: f64| {
|
||||
s.execute(kind, &json!({"x": 600.0 + x, "y": 600.0 + y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let mut ids = vec![shape(s, "shape.rectangle", 0.0, 0.0, w, h), shape(s, "shape.ellipse", 0.0, 0.0, w, top)];
|
||||
ids.push(shape(s, "shape.rectangle", w / 4.0, top, w / 2.0, h - top));
|
||||
if guide {
|
||||
let line =
|
||||
s.execute("shape.line", &json!({"x1": 600, "y1": 600.0 + top, "x2": 600.0 + w, "y2": 600.0 + top})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [line]})).unwrap();
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
ids.push(line);
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": name})).unwrap();
|
||||
}
|
||||
|
||||
/// A column graph of one series with `values`, its value axis 0 to 50 over 200 points (4 points a unit), the zero
|
||||
/// line at y = 300.
|
||||
fn one_series(s: &mut Session, ty: &str, values: &[f64]) -> NodeId {
|
||||
let rows: Vec<Vec<f64>> = values.iter().map(|v| vec![*v]).collect();
|
||||
let p = json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "series": ["A"], "rows": rows});
|
||||
let id = NodeId(s.execute("graph.create", &p).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("graph.setType", &json!({"id": id.0, "axisMin": 0, "axisMax": 50})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// The bounds of the backmost object (the frame) of each copy of a design inside `n`, a column drawn with it.
|
||||
fn frames(n: &vectorcraft_doc::Node) -> Vec<vectorcraft_geom::Rect> {
|
||||
n.children().unwrap().iter().filter_map(|c| c.children()?.first()?.geometric_bounds()).collect()
|
||||
}
|
||||
|
||||
fn near(a: vectorcraft_geom::Rect, b: [f64; 4]) -> bool {
|
||||
[a.x0 - b[0], a.y0 - b[1], a.x1 - b[2], a.y1 - b[3]].iter().all(|d| d.abs() < 1e-6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_repeating_column_design_stacks_a_copy_per_ten_and_chops_or_scales_the_last() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = one_series(&mut s, "column", &[35.0]);
|
||||
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
|
||||
assert!(near(plain, [plain.x0, 160.0, plain.x1, 300.0]), "{plain:?}");
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 10})).unwrap();
|
||||
// 35 at 10 a copy: three copies of 40 points up from the zero line, and a fourth cut off at the value.
|
||||
let column = series_parts(&s, id, 0)[0].clone();
|
||||
let parts = column.children().unwrap();
|
||||
assert_eq!(parts.len(), 4, "3.5 designs");
|
||||
let at = |y0: f64, y1: f64| [plain.x0, y0, plain.x1, y1];
|
||||
let copies = frames(&column);
|
||||
assert!(near(copies[0], at(260.0, 300.0)) && near(copies[1], at(220.0, 260.0)) && near(copies[2], at(180.0, 220.0)), "{copies:?}");
|
||||
let NodeKind::Group { clip: true, children } = &parts[3].kind else { panic!("the last copy is clipped: {:?}", parts[3].kind) };
|
||||
assert!(near(frames(&parts[3])[0], at(140.0, 180.0)), "a whole copy");
|
||||
let (clip, copy) =
|
||||
(children[0].geometric_bounds().unwrap(), vectorcraft_render::painted_bounds(&s.doc().unwrap().doc, &children[1]).unwrap());
|
||||
assert_eq!(clip.y0, 160.0, "cut off at the value");
|
||||
// Its sides and its foot, strokes included, are not cut.
|
||||
assert!(clip.x0 <= copy.x0 && clip.x1 >= copy.x1 && clip.y1 >= copy.y1 && copy.x0 < plain.x0, "{clip:?} {copy:?}");
|
||||
// Nor is the brush art of a brushed path inside a group of the design.
|
||||
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
|
||||
let square = |x0: f64, x1: f64| vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(x0, 0.0, x1, 10.0));
|
||||
let frame = vectorcraft_doc::Node::path(d.alloc_id(), square(0.0, 10.0), vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0));
|
||||
let mut look = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::BLACK), 1.0);
|
||||
for item in &mut look.items {
|
||||
if let vectorcraft_doc::AppearanceItem::Stroke(st) = item {
|
||||
st.brush = Some("Wide".into());
|
||||
}
|
||||
}
|
||||
let brushed = vectorcraft_doc::Node::path(d.alloc_id(), square(4.0, 6.0), look);
|
||||
let inner = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(brushed)]);
|
||||
let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(inner)]);
|
||||
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Brushed".into(), art: std::sync::Arc::new(art) });
|
||||
let options = ColumnDesign { design: "Brushed".into(), column_type: ColumnType::Repeating, ..ColumnDesign::default() };
|
||||
let g = GraphSpec {
|
||||
rows: vec![vec![35.0]],
|
||||
axis_min: Some(0.0),
|
||||
axis_max: Some(50.0),
|
||||
series_columns: vec![Some(options)],
|
||||
..GraphSpec::default()
|
||||
};
|
||||
let out = super::generate(&mut d, &g);
|
||||
let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone();
|
||||
let last = column.children().unwrap().last().unwrap().clone();
|
||||
let NodeKind::Group { clip: true, children } = &last.kind else { panic!("the last copy is clipped: {:?}", last.kind) };
|
||||
let clip = children[0].geometric_bounds().unwrap();
|
||||
let mut reach = None;
|
||||
children[1].walk(&mut |n| {
|
||||
if vectorcraft_brush::has_brush(n) {
|
||||
reach = vectorcraft_render::painted_bounds(&d, n);
|
||||
}
|
||||
});
|
||||
let reach = reach.unwrap();
|
||||
assert!(clip.x0 <= reach.x0 && clip.x1 >= reach.x1 && clip.y1 >= reach.y1, "{clip:?} {reach:?}");
|
||||
// Scale: the last copy half as tall instead.
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "fractions": "scale"})).unwrap();
|
||||
let column = series_parts(&s, id, 0)[0].clone();
|
||||
let copies = frames(&column);
|
||||
assert_eq!(copies.len(), 4);
|
||||
assert!(near(copies[3], at(160.0, 180.0)), "{copies:?}");
|
||||
let c = &spec(&s, id).series_columns[0];
|
||||
assert_eq!((c.as_ref().map(|c| (c.column_type, c.each, c.fractions))), Some((ColumnType::Repeating, 10.0, Fractions::Scale)));
|
||||
// A bar graph stacks them along its bars, and a negative value stacks them the other way from the zero line.
|
||||
let bars = one_series(&mut s, "bar", &[-15.0]);
|
||||
s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": bars.0, "design": "Coin", "columnType": "repeating", "each": 10, "fractions": "scale"})).unwrap();
|
||||
let bar = series_parts(&s, bars, 0)[0].clone();
|
||||
let copies = frames(&bar);
|
||||
// The value axis runs from -50 to 50 over 300 points: 3 points a unit, the zero line at x = 250.
|
||||
let (zero, unit) = (250.0, 3.0);
|
||||
assert!((copies[0].x1 - zero).abs() < 1e-6 && (copies[0].width() - 10.0 * unit).abs() < 1e-6, "{copies:?}");
|
||||
assert!((copies[1].x1 - copies[0].x0).abs() < 1e-6 && (copies[1].width() - 5.0 * unit).abs() < 1e-6, "{copies:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sliding_column_design_keeps_the_part_above_its_guide_and_stretches_the_rest() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
// 10 wide and 20 tall, the guide 5 points from the top.
|
||||
save_column_design(&mut s, "Pencil", (10.0, 20.0, 5.0), true);
|
||||
let id = one_series(&mut s, "column", &[35.0, 20.0]);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "columnWidth": 20})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "sliding"})).unwrap();
|
||||
let parts = series_parts(&s, id, 0);
|
||||
for (column, value_end) in parts.iter().zip([160.0, 220.0]) {
|
||||
// The columns are 24 points wide (2.4 times the design): two copies, each clipped at the guide, which lands
|
||||
// 12 points (2.4 times 5) below the value end.
|
||||
let halves = column.children().unwrap();
|
||||
assert_eq!(halves.len(), 2, "{halves:?}");
|
||||
let half = |i: usize| {
|
||||
let NodeKind::Group { clip: true, children } = &halves[i].kind else { panic!("a clip group: {:?}", halves[i].kind) };
|
||||
(children[0].geometric_bounds().unwrap(), children[1].children().unwrap().to_vec())
|
||||
};
|
||||
let ((above, kept), (below, stretched)) = (half(0), half(1));
|
||||
let at = value_end + 12.0;
|
||||
assert!((above.y1 - at).abs() < 1e-6 && (below.y0 - at).abs() < 1e-6, "{above:?} {below:?}");
|
||||
assert_eq!(kept.len(), 3, "the guide is not drawn");
|
||||
// Above the guide the design keeps its proportions: the 10 × 5 ellipse is 24 × 12 at the value end.
|
||||
let top = kept[1].geometric_bounds().unwrap();
|
||||
assert!((top.y0 - value_end).abs() < 1e-6 && (top.width() - 24.0).abs() < 1e-6 && (top.height() - 12.0).abs() < 1e-6, "{top:?}");
|
||||
// Below it the design is stretched down to the zero line.
|
||||
let (frame, body) = (stretched[0].geometric_bounds().unwrap(), stretched[2].geometric_bounds().unwrap());
|
||||
assert!((frame.width() - 24.0).abs() < 1e-6 && (frame.y1 - 300.0).abs() < 1e-6, "{frame:?}");
|
||||
assert!((body.y0 - at).abs() < 1e-6 && (body.y1 - 300.0).abs() < 1e-6, "{body:?}");
|
||||
}
|
||||
// Vertically Scaled stretches the whole design, the top part included.
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Pencil", "columnType": "vertical"})).unwrap();
|
||||
let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec();
|
||||
assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6);
|
||||
// Without a guide a sliding design is scaled vertically too.
|
||||
save_column_design(&mut s, "Plain", (10.0, 20.0, 5.0), false);
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Plain", "columnType": "sliding"})).unwrap();
|
||||
let art = series_parts(&s, id, 0)[0].children().unwrap()[0].children().unwrap().to_vec();
|
||||
assert!((art[1].geometric_bounds().unwrap().height() - 140.0 / 4.0).abs() < 1e-6);
|
||||
// Guides that are not a horizontal line, a 2 × 2 square and a vertical line 0.1 points long, made guides before
|
||||
// the line, are passed over.
|
||||
let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap();
|
||||
let mut ids = vec![
|
||||
id_of(s.execute("shape.rectangle", &json!({"x": 700, "y": 600, "width": 10, "height": 20})).unwrap()),
|
||||
id_of(s.execute("shape.ellipse", &json!({"x": 700, "y": 600, "width": 10, "height": 5})).unwrap()),
|
||||
];
|
||||
let square = id_of(s.execute("shape.rectangle", &json!({"x": 704, "y": 611, "width": 2, "height": 2})).unwrap());
|
||||
let tick = id_of(s.execute("shape.line", &json!({"x1": 702, "y1": 615, "x2": 702, "y2": 615.1})).unwrap());
|
||||
let line = id_of(s.execute("shape.line", &json!({"x1": 700, "y1": 605, "x2": 710, "y2": 605})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [square, tick, line]})).unwrap();
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
ids.extend([square, tick, line]);
|
||||
s.execute("select.set", &json!({"ids": ids})).unwrap();
|
||||
s.execute("graph.design", &json!({"new": "Boxed"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Boxed", "columnType": "sliding"})).unwrap();
|
||||
let halves = series_parts(&s, id, 0)[0].children().unwrap().to_vec();
|
||||
let above = halves[0].children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!((above.y1 - (160.0 + 12.0)).abs() < 1e-6, "cut at the line, not at the square or the short vertical line: {above:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniformly_scaled_column_designs_keep_their_proportions_and_cast_no_shadow() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = one_series(&mut s, "column", &[35.0, 20.0]);
|
||||
s.execute("graph.setType", &json!({"id": id.0, "dropShadow": true})).unwrap();
|
||||
let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": "Coin", "columnType": "uniform"})).unwrap();
|
||||
for (column, length) in series_parts(&s, id, 0).iter().zip([140.0, 80.0]) {
|
||||
let frame = frames(column)[0];
|
||||
assert!((frame.width() - length).abs() < 1e-6 && (frame.height() - length).abs() < 1e-6, "{frame:?}");
|
||||
assert!((frame.center().x - column.geometric_bounds().unwrap().center().x).abs() < 1e-6);
|
||||
}
|
||||
assert!((frames(&series_parts(&s, id, 0)[0])[0].center().x - plain.center().x).abs() < 1e-6, "centered on the column");
|
||||
// A design twice as wide as it is tall reaches past the plot, and the legend starts right of it.
|
||||
save_column_design(&mut s, "Strip", (20.0, 10.0, 4.0), false);
|
||||
let wide = one_series(&mut s, "column", &[20.0, 35.0]);
|
||||
s.execute("graph.column", &json!({"id": wide.0, "design": "Strip", "columnType": "uniform"})).unwrap();
|
||||
let parts = series_parts(&s, wide, 0);
|
||||
let (reach, legend) = (frames(&parts[1])[0], parts.last().unwrap().geometric_bounds().unwrap());
|
||||
assert!(reach.x1 > 400.0 && legend.x0 > reach.x1, "{reach:?} {legend:?}");
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow")), "no shadow for designed columns");
|
||||
// The legend swatch shows the design, its frame filling the 8-point square.
|
||||
let swatch = series_parts(&s, id, 0).last().unwrap().clone();
|
||||
let frame = swatch.children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!((frame.width() - 8.0).abs() < 1e-6 && (frame.height() - 8.0).abs() < 1e-6, "{frame:?}");
|
||||
// Painting the series leaves the design's paint alone.
|
||||
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
|
||||
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
|
||||
let mut red = false;
|
||||
s.doc().unwrap().doc.node(id).unwrap().walk(&mut |n| red |= n.appearance.fill_paint().color().is_some_and(|c| c.to_hex() == "#ff0000"));
|
||||
assert!(!red);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_designs_survive_save_and_open_and_follow_their_design() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = graph(&mut s, "stackedColumn");
|
||||
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
|
||||
// Every series takes the design; only the stacked column series draws it.
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"});
|
||||
s.execute("graph.column", &p).unwrap();
|
||||
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })), "columns and swatch");
|
||||
assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })), "the line series");
|
||||
let v = s.execute("graph.column", &json!({"id": id.0, "seriesIndexes": [0]})).unwrap();
|
||||
assert_eq!(v, json!({"designs": ["Coin"], "design": "Coin", "columnType": "repeating", "each": 2.5, "fractions": "scale"}));
|
||||
let path = vectorcraft_testkit::temp_dir("graph-column-designs").join("columns.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 2.5, fractions: Fractions::Scale };
|
||||
assert_eq!(spec(&s, id).series_columns, [Some(options.clone()), Some(options)]);
|
||||
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesColumns").is_none());
|
||||
// Renamed, the graph draws it still; in use, it isn't unused; deleted, the columns are plain again.
|
||||
s.execute("graph.design", &json!({"rename": "Coin", "to": "Token"})).unwrap();
|
||||
assert!(spec(&s, id).series_columns.iter().flatten().all(|c| c.design == "Token"));
|
||||
assert_eq!(s.execute("graph.design", &json!({"selectUnused": true})).unwrap()["unused"], json!([]));
|
||||
s.execute("graph.design", &json!({"delete": "Token"})).unwrap();
|
||||
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
assert!(s.execute("graph.column", &json!({"id": id.0})).unwrap()["design"].is_null());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Group { .. })));
|
||||
// Null goes back to plain columns.
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.column", &json!({"id": id.0, "design": null})).unwrap();
|
||||
assert!(spec(&s, id).series_columns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_column_params_are_checked_and_designs_too_big_are_drawn_plain() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
save_column_design(&mut s, "Coin", (10.0, 10.0, 4.0), false);
|
||||
let id = one_series(&mut s, "column", &[35.0]);
|
||||
for p in [
|
||||
json!({"design": "Nope"}),
|
||||
json!({"design": 3}),
|
||||
json!({"design": "Coin", "columnType": "diagonal"}),
|
||||
json!({"design": "Coin", "each": 0}),
|
||||
json!({"design": "Coin", "each": -1}),
|
||||
json!({"design": "Coin", "each": "10"}),
|
||||
json!({"design": "Coin", "fractions": "round"}),
|
||||
] {
|
||||
let mut p = p;
|
||||
p["id"] = json!(id.0);
|
||||
assert!(s.execute("graph.column", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(spec(&s, id).series_columns.is_empty());
|
||||
// 350,000 copies of a three-object design are past the budget: refused, and a file asking for it draws plain columns.
|
||||
let p = json!({"id": id.0, "design": "Coin", "columnType": "repeating", "each": 0.0001});
|
||||
assert!(s.execute("graph.column", &p).is_err());
|
||||
let mut g = spec(&s, id);
|
||||
let options = ColumnDesign { design: "Coin".into(), column_type: ColumnType::Repeating, each: 0.0001, fractions: Fractions::Chop };
|
||||
g.series_columns = vec![Some(options.clone())];
|
||||
let mut d = (*s.doc().unwrap().doc).clone();
|
||||
let art = super::generate(&mut d, &g);
|
||||
let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
|
||||
assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
|
||||
// Columns of 0 on a value axis from 10,000 to 10,001 run 2,000,000 points from the clamped zero line: a copy each
|
||||
// 0.1 is past the budget too.
|
||||
let far = one_series(&mut s, "column", &[0.0, 0.0]);
|
||||
s.execute("graph.setType", &json!({"id": far.0, "axisMin": 10_000, "axisMax": 10_001})).unwrap();
|
||||
assert!(s.execute("graph.column", &json!({"id": far.0, "design": "Coin", "columnType": "repeating", "each": 0.1})).is_err());
|
||||
assert!(spec(&s, far).series_columns.is_empty());
|
||||
// A file's unusable value per copy draws the design vertically scaled, and so does a copy longer than the canvas:
|
||||
// 35 at 1,000,000,000 a copy, 4,000,000,000 points a copy.
|
||||
for (each, fractions) in [(-1.0, Fractions::Chop), (1e9, Fractions::Scale), (1e9, Fractions::Chop)] {
|
||||
g.series_columns = vec![Some(ColumnDesign { each, fractions, ..options.clone() })];
|
||||
let art = super::generate(&mut d, &g);
|
||||
let s0 = art.iter().find(|n| n.extras().series_index == Some(0)).unwrap();
|
||||
let column = s0.children().unwrap()[0].clone();
|
||||
assert_eq!(frames(&column).len(), 1, "{each} {fractions:?}");
|
||||
assert!((frames(&column)[0].height() - 140.0).abs() < 1e-6, "{each} {fractions:?}");
|
||||
assert!(!matches!(column.children().unwrap()[0].kind, NodeKind::Group { clip: true, .. }), "{each} {fractions:?}: not clipped");
|
||||
}
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, ty: &str, csv: &str) -> NodeId {
|
||||
NodeId(
|
||||
s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
|
||||
@@ -3566,6 +3566,8 @@ fn art_by_name_survives(what: &str, doc: Document) {
|
||||
("graph.design", json!({"paste": "deep"})),
|
||||
("graph.create", json!({"type": "line", "x": 10, "y": 10, "width": 80, "height": 60})),
|
||||
("graph.marker", json!({"design": "deep"})),
|
||||
("graph.create", json!({"type": "column", "x": 10, "y": 10, "width": 80, "height": 60})),
|
||||
("graph.column", json!({"design": "deep", "columnType": "repeating", "each": 1})),
|
||||
("graph.design", json!({"new": "again"})),
|
||||
("edit.copy", json!({})),
|
||||
("edit.paste", json!({})),
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
//! Object › Graph › Design… and Marker…: the document's graph designs in a list.
|
||||
//! Object › Graph › Design…, Column… and Marker…: the document's graph designs in a list.
|
||||
//!
|
||||
//! Graph Design: the designs, a preview of the selected one, and New Design (the selected art as a
|
||||
//! design named "New Design"), Rename (to the name typed), Delete Design (the selected designs),
|
||||
//! Paste Design (a copy of the selected design into the document, selected, to edit) and Select
|
||||
//! Unused (the designs no graph draws with), each run at once by `graph.design`; Close closes.
|
||||
//! Column: the design the selected graph series draw their columns or bars with, or None (plain
|
||||
//! columns), a preview of it, Column Type, and for a repeating design Each Design Represents and
|
||||
//! For Fractions; OK runs `graph.column`.
|
||||
//! Marker: the design the selected graph series draw their data points with, or None (the default
|
||||
//! square marker); OK runs `graph.marker`.
|
||||
//!
|
||||
//! Fields: `selected` (the selected designs' names; Marker: one name, null for None) and, in Graph
|
||||
//! Design, `name` (the name Rename gives).
|
||||
//! Fields: `selected` (the selected designs' names; Column and Marker: one name, null for None),
|
||||
//! in Graph Design `name` (the name Rename gives), and in Column `columnType`, `each` and
|
||||
//! `fractions`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -18,6 +22,7 @@ use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kinds.
|
||||
pub const DESIGN: &str = "graphDesign";
|
||||
pub const COLUMN: &str = "graphColumn";
|
||||
pub const MARKER: &str = "graphMarker";
|
||||
|
||||
/// The preview's size.
|
||||
@@ -35,6 +40,22 @@ pub(super) const DESIGN_SPEC: DialogSpec = DialogSpec {
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
pub(super) const COLUMN_SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Graph Column").into(),
|
||||
body: column_body,
|
||||
confirm: |app, d| {
|
||||
let p = json!({
|
||||
"design": d.fields.get("selected").and_then(Value::as_str),
|
||||
"columnType": d.str("columnType"),
|
||||
"each": d.f64("each", vectorcraft_doc::ColumnDesign::EACH),
|
||||
"fractions": d.str("fractions"),
|
||||
});
|
||||
run_and_close(app, "graph.column", p)
|
||||
},
|
||||
min_width: 420.0,
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
pub(super) const MARKER_SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Graph Marker").into(),
|
||||
body: marker_body,
|
||||
@@ -52,6 +73,15 @@ pub fn open_design(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Open Column for the selected graph series, on the design and options they draw their columns with (None for plain
|
||||
/// columns).
|
||||
pub fn open_column(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let v = app.session.execute("graph.column", &json!({})).map_err(|e| e.to_string())?;
|
||||
let fields = json!({ "selected": v["design"].as_str(), "columnType": v["columnType"], "each": v["each"], "fractions": v["fractions"] });
|
||||
app.ui.dialog = Some(Dialog::new(COLUMN, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Open Marker for the selected graph series, on the design they draw with (None for the default).
|
||||
pub fn open_marker(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let v = app.session.execute("graph.marker", &json!({})).map_err(|e| e.to_string())?;
|
||||
@@ -190,25 +220,66 @@ fn design_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> b
|
||||
false
|
||||
}
|
||||
|
||||
fn marker_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let names = names(app).unwrap_or_default();
|
||||
// A list as wide as Graph Design's, not the whole window.
|
||||
ui.set_width(220.0);
|
||||
/// The list of Column and Marker: None, then the designs `names`; a click picks one into `selected` (null for None).
|
||||
fn one_design_list(ui: &mut egui::Ui, d: &mut Dialog, names: &[String], salt: &str) {
|
||||
widgets::list_box(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("graph-markers").min_scrolled_height(160.0).max_height(160.0).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt(salt).min_scrolled_height(160.0).max_height(160.0).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.set_min_height(160.0);
|
||||
let current = d.fields.get("selected").and_then(Value::as_str).map(str::to_string);
|
||||
if ui.selectable_label(current.is_none(), tl!("None")).clicked() {
|
||||
d.fields.insert("selected".into(), Value::Null);
|
||||
}
|
||||
for n in &names {
|
||||
for n in names {
|
||||
if ui.selectable_label(current.as_deref() == Some(n.as_str()), n.as_str()).clicked() {
|
||||
d.fields.insert("selected".into(), json!(n));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn column_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let names = names(app).unwrap_or_default();
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(220.0);
|
||||
one_design_list(ui, d, &names, "graph-columns");
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
let (r, _) = ui.allocate_exact_size(egui::vec2(PREVIEW, PREVIEW), egui::Sense::hover());
|
||||
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, egui::Color32::from_gray(160)), egui::StrokeKind::Inside);
|
||||
if let Some(n) = d.fields.get("selected").and_then(Value::as_str).filter(|n| names.iter().any(|x| x == n)) {
|
||||
preview(ui, app, n, r.shrink(4.0));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
const LABEL_W: f32 = 150.0;
|
||||
let types =
|
||||
[("vertical", tl!("Vertically Scaled")), ("uniform", tl!("Uniformly Scaled")), ("repeating", tl!("Repeating")), ("sliding", tl!("Sliding"))];
|
||||
form::choice(ui, d, "columnType", tl!("Column Type:"), (LABEL_W, 160.0), &types);
|
||||
// Each Design Represents and For Fractions are for repeating designs.
|
||||
ui.add_enabled_ui(d.str("columnType") == "repeating", |ui| {
|
||||
widgets::label_row(ui, tl!("Each Design Represents:"), LABEL_W, |ui| {
|
||||
let each = d.f64("each", vectorcraft_doc::ColumnDesign::EACH);
|
||||
if let Some(v) = widgets::range_field(ui, ("graph-column", "each"), each, EACH_RANGE, "", 4, 72.0) {
|
||||
d.fields.insert("each".into(), json!(v));
|
||||
}
|
||||
});
|
||||
let fractions = [("chop", tl!("Chop Design")), ("scale", tl!("Scale Design"))];
|
||||
form::choice(ui, d, "fractions", tl!("For Fractions:"), (LABEL_W, 160.0), &fractions);
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
/// The values Each Design Represents takes in the dialog (`graph.column` takes any value more than 0).
|
||||
const EACH_RANGE: std::ops::RangeInclusive<f64> = 0.0001..=1_000_000_000.0;
|
||||
|
||||
fn marker_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let names = names(app).unwrap_or_default();
|
||||
// A list as wide as Graph Design's, not the whole window.
|
||||
ui.set_width(220.0);
|
||||
one_design_list(ui, d, &names, "graph-markers");
|
||||
false
|
||||
}
|
||||
|
||||
@@ -276,4 +347,34 @@ mod tests {
|
||||
(super::MARKER_SPEC.confirm)(&mut app, &d).unwrap();
|
||||
assert_eq!(app.session.execute("graph.marker", &json!({})).unwrap()["design"], json!(null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_opens_from_the_graph_menu_and_applies_the_design_and_its_options() {
|
||||
let mut app = app_with_design();
|
||||
let g = app.run("graph.create", json!({"type": "column", "x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [g]})).unwrap();
|
||||
crate::menus::invoke(&mut app, "graph.column", json!({}));
|
||||
let mut d: Dialog = app.ui.dialog.clone().unwrap();
|
||||
assert_eq!(d.kind, super::COLUMN);
|
||||
assert_eq!(
|
||||
(d.fields["selected"].clone(), d.str("columnType"), d.f64("each", 0.0), d.str("fractions")),
|
||||
(json!(null), "vertical".into(), 10.0, "chop".into())
|
||||
);
|
||||
frame(&mut app);
|
||||
assert!(app.ui.dialog.is_some());
|
||||
d.fields.insert("selected".into(), json!("New Design"));
|
||||
d.fields.insert("columnType".into(), json!("repeating"));
|
||||
d.fields.insert("each".into(), json!(0.5));
|
||||
d.fields.insert("fractions".into(), json!("scale"));
|
||||
(super::COLUMN_SPEC.confirm)(&mut app, &d).unwrap();
|
||||
let v = app.session.execute("graph.column", &json!({})).unwrap();
|
||||
assert_eq!(v, json!({"designs": ["New Design"], "design": "New Design", "columnType": "repeating", "each": 0.5, "fractions": "scale"}));
|
||||
// It opens again on what the series draw with, and None goes back to plain columns.
|
||||
super::open_column(&mut app).unwrap();
|
||||
let mut d: Dialog = app.ui.dialog.take().unwrap();
|
||||
assert_eq!((d.str("selected"), d.str("columnType"), d.f64("each", 0.0)), ("New Design".into(), "repeating".into(), 0.5));
|
||||
d.fields.insert("selected".into(), json!(null));
|
||||
(super::COLUMN_SPEC.confirm)(&mut app, &d).unwrap();
|
||||
assert_eq!(app.session.execute("graph.column", &json!({})).unwrap()["design"], json!(null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,7 @@ registry! {
|
||||
NewColorGroup: [new_color_group::KIND] => new_color_group::SPEC,
|
||||
GraphData: [graph_data::KIND] => graph_data::SPEC,
|
||||
GraphDesign: [graph_design::DESIGN] => graph_design::DESIGN_SPEC,
|
||||
GraphColumn: [graph_design::COLUMN] => graph_design::COLUMN_SPEC,
|
||||
GraphMarker: [graph_design::MARKER] => graph_design::MARKER_SPEC,
|
||||
GradientStop: ["gradientStop"] => gradient_stop::SPEC,
|
||||
ColorPicker: [color_picker::KIND] => color_picker::SPEC,
|
||||
|
||||
@@ -1214,6 +1214,16 @@
|
||||
Delete Design Изтриване на дизайн
|
||||
Paste Design Поставяне на дизайн
|
||||
Select Unused Избиране на неизползваните
|
||||
Graph Column Колона на диаграма
|
||||
Column Type: Тип колона:
|
||||
Vertically Scaled Мащабиране по вертикала
|
||||
Uniformly Scaled Пропорционално мащабиране
|
||||
Repeating Повтаряне
|
||||
Sliding Плъзгане
|
||||
Each Design Represents: Всеки дизайн представлява:
|
||||
For Fractions: За дробни части:
|
||||
Chop Design Отрязване на дизайна
|
||||
Scale Design Мащабиране на дизайна
|
||||
Import Data… Импортиране на данни…
|
||||
Transpose Транспониране
|
||||
Switch x/y Размяна на x/y
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -284,6 +284,16 @@
|
||||
Design… Návrh…
|
||||
Graph Design Návrh grafu
|
||||
Graph Marker Značka grafu
|
||||
Graph Column Sloupec grafu
|
||||
Column Type: Typ sloupce:
|
||||
Vertically Scaled Svislá změna velikosti
|
||||
Uniformly Scaled Rovnoměrná změna velikosti
|
||||
Repeating Opakování
|
||||
Sliding Posouvání
|
||||
Each Design Represents: Každý návrh představuje:
|
||||
For Fractions: Pro zlomky:
|
||||
Chop Design Oříznout návrh
|
||||
Scale Design Změnit velikost návrhu
|
||||
Column… Sloupec…
|
||||
Marker… Značka…
|
||||
Collect for Export Shromáždit pro export
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1232,6 +1232,16 @@ file-filter Templates Vorlagen
|
||||
Delete Design Design löschen
|
||||
Paste Design Design einfügen
|
||||
Select Unused Nicht verwendete auswählen
|
||||
Graph Column Diagrammsäule
|
||||
Column Type: Säulentyp:
|
||||
Vertically Scaled Vertikal skaliert
|
||||
Uniformly Scaled Gleichmäßig skaliert
|
||||
Repeating Wiederholend
|
||||
Sliding Gleitend
|
||||
Each Design Represents: Jedes Design entspricht:
|
||||
For Fractions: Für Bruchteile:
|
||||
Chop Design Design abschneiden
|
||||
Scale Design Design skalieren
|
||||
Import Data… Daten importieren…
|
||||
Transpose Transponieren
|
||||
Switch x/y x/y vertauschen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1177,6 +1177,16 @@
|
||||
Delete Design Διαγραφή σχεδίου
|
||||
Paste Design Επικόλληση σχεδίου
|
||||
Select Unused Επιλογή μη χρησιμοποιημένων
|
||||
Graph Column Στήλη γραφήματος
|
||||
Column Type: Τύπος στήλης:
|
||||
Vertically Scaled Κλιμακωμένο κατακόρυφα
|
||||
Uniformly Scaled Κλιμακωμένο ομοιόμορφα
|
||||
Repeating Επαναλαμβανόμενο
|
||||
Sliding Ολισθαίνον
|
||||
Each Design Represents: Κάθε σχέδιο αντιστοιχεί σε:
|
||||
For Fractions: Για κλάσματα:
|
||||
Chop Design Περικοπή σχεδίου
|
||||
Scale Design Κλιμάκωση σχεδίου
|
||||
Import Data… Εισαγωγή δεδομένων…
|
||||
Transpose Αντιμετάθεση
|
||||
Switch x/y Εναλλαγή x/y
|
||||
|
||||
|
@@ -1239,6 +1239,16 @@
|
||||
Delete Design Eliminar diseño
|
||||
Paste Design Pegar diseño
|
||||
Select Unused Seleccionar no utilizados
|
||||
Graph Column Columna de gráfico
|
||||
Column Type: Tipo de columna:
|
||||
Vertically Scaled Escalado vertical
|
||||
Uniformly Scaled Escalado uniforme
|
||||
Repeating Repetición
|
||||
Sliding Deslizante
|
||||
Each Design Represents: Cada diseño representa:
|
||||
For Fractions: Para fracciones:
|
||||
Chop Design Recortar diseño
|
||||
Scale Design Escalar diseño
|
||||
Import Data… Importar datos…
|
||||
Transpose Transponer
|
||||
Switch x/y Intercambiar x/y
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -20,6 +20,16 @@
|
||||
Delete Design Poista malli
|
||||
Paste Design Liitä malli
|
||||
Select Unused Valitse käyttämättömät
|
||||
Graph Column Kaavion pylväs
|
||||
Column Type: Pylvään tyyppi:
|
||||
Vertically Scaled Skaalattu pystysuunnassa
|
||||
Uniformly Scaled Skaalattu tasaisesti
|
||||
Repeating Toistuva
|
||||
Sliding Liukuva
|
||||
Each Design Represents: Kukin malli vastaa:
|
||||
For Fractions: Murto-osille:
|
||||
Chop Design Leikkaa malli
|
||||
Scale Design Skaalaa malli
|
||||
Glowing Edges… Hehkuvat reunat…
|
||||
About VectorCraft Tietoja VectorCraftista
|
||||
Align to Path Tasaa polkuun
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1238,6 +1238,16 @@
|
||||
Delete Design Supprimer la conception
|
||||
Paste Design Coller la conception
|
||||
Select Unused Sélectionner les éléments inutilisés
|
||||
Graph Column Colonne de graphe
|
||||
Column Type: Type de colonne :
|
||||
Vertically Scaled Mise à l’échelle verticale
|
||||
Uniformly Scaled Mise à l’échelle uniforme
|
||||
Repeating Répétition
|
||||
Sliding Glissement
|
||||
Each Design Represents: Chaque conception représente :
|
||||
For Fractions: Pour les fractions :
|
||||
Chop Design Rogner la conception
|
||||
Scale Design Mettre la conception à l’échelle
|
||||
Import Data… Importer des données…
|
||||
Transpose Transposer
|
||||
Switch x/y Permuter x/y
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1564,6 +1564,16 @@ file-filter Templates Sablonok
|
||||
Delete Design Minta törlése
|
||||
Paste Design Minta beillesztése
|
||||
Select Unused Nem használtak kijelölése
|
||||
Graph Column Diagramoszlop
|
||||
Column Type: Oszloptípus:
|
||||
Vertically Scaled Függőleges méretezés
|
||||
Uniformly Scaled Egyenletes méretezés
|
||||
Repeating Ismétlődő
|
||||
Sliding Csúszó
|
||||
Each Design Represents: Egy minta értéke:
|
||||
For Fractions: Törtrészeknél:
|
||||
Chop Design Minta levágása
|
||||
Scale Design Minta méretezése
|
||||
Import Data… Adatok importálása…
|
||||
Transpose Transzponálás
|
||||
Switch x/y x/y felcserélése
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1248,6 +1248,16 @@ file-filter Templates Modelli
|
||||
Delete Design Elimina design
|
||||
Paste Design Incolla design
|
||||
Select Unused Seleziona non utilizzati
|
||||
Graph Column Colonna del grafico
|
||||
Column Type: Tipo di colonna:
|
||||
Vertically Scaled Ridimensionato verticalmente
|
||||
Uniformly Scaled Ridimensionato uniformemente
|
||||
Repeating Ripetuto
|
||||
Sliding Scorrevole
|
||||
Each Design Represents: Ogni design rappresenta:
|
||||
For Fractions: Per le frazioni:
|
||||
Chop Design Taglia design
|
||||
Scale Design Ridimensiona design
|
||||
Import Data… Importa dati…
|
||||
Transpose Trasponi
|
||||
Switch x/y Scambia x/y
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3231,6 +3231,16 @@ file-filter Templates テンプレート
|
||||
Delete Design デザインを削除
|
||||
Paste Design デザインをペースト
|
||||
Select Unused 未使用を選択
|
||||
Graph Column グラフの棒
|
||||
Column Type: 棒の種類:
|
||||
Vertically Scaled 垂直方向に拡大・縮小
|
||||
Uniformly Scaled 縦横比を保持して拡大・縮小
|
||||
Repeating 繰り返し
|
||||
Sliding スライド
|
||||
Each Design Represents: 各デザインの値:
|
||||
For Fractions: 端数の処理:
|
||||
Chop Design デザインを切り取る
|
||||
Scale Design デザインを拡大・縮小
|
||||
Import Data… データを読み込み…
|
||||
Transpose 行と列を入れ替え
|
||||
Switch x/y X/Y を入れ替え
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3224,6 +3224,16 @@ file-filter Templates 템플릿
|
||||
Delete Design 디자인 삭제
|
||||
Paste Design 디자인 붙여넣기
|
||||
Select Unused 사용하지 않는 항목 선택
|
||||
Graph Column 그래프 막대
|
||||
Column Type: 막대 유형:
|
||||
Vertically Scaled 세로로 크기 조절
|
||||
Uniformly Scaled 균일하게 크기 조절
|
||||
Repeating 반복
|
||||
Sliding 슬라이드
|
||||
Each Design Represents: 디자인당 값:
|
||||
For Fractions: 나머지 처리:
|
||||
Chop Design 디자인 자르기
|
||||
Scale Design 디자인 크기 조절
|
||||
Import Data… 데이터 가져오기…
|
||||
Transpose 행/열 바꾸기
|
||||
Switch x/y x/y 전환
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -245,6 +245,16 @@
|
||||
Design… Wzór…
|
||||
Graph Design Wzór wykresu
|
||||
Graph Marker Znacznik wykresu
|
||||
Graph Column Kolumna wykresu
|
||||
Column Type: Typ kolumny:
|
||||
Vertically Scaled Skalowanie w pionie
|
||||
Uniformly Scaled Skalowanie jednolite
|
||||
Repeating Powtarzanie
|
||||
Sliding Przesuwanie
|
||||
Each Design Represents: Każdy wzór oznacza:
|
||||
For Fractions: Dla ułamków:
|
||||
Chop Design Przytnij wzór
|
||||
Scale Design Skaluj wzór
|
||||
Column… Kolumna…
|
||||
Marker… Znacznik…
|
||||
Collect for Export Zbierz do eksportu
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4040,6 +4040,16 @@ axis Both Ambos
|
||||
Delete Design Excluir Design
|
||||
Paste Design Colar Design
|
||||
Select Unused Selecionar Não Utilizados
|
||||
Graph Column Coluna do Gráfico
|
||||
Column Type: Tipo de Coluna:
|
||||
Vertically Scaled Em Escala Vertical
|
||||
Uniformly Scaled Em Escala Uniforme
|
||||
Repeating Repetido
|
||||
Sliding Deslizante
|
||||
Each Design Represents: Cada Design Representa:
|
||||
For Fractions: Para Frações:
|
||||
Chop Design Cortar Design
|
||||
Scale Design Redimensionar Design
|
||||
Import Data… Importar Dados…
|
||||
Transpose Transpor
|
||||
Switch x/y Alternar x/y
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1246,6 +1246,16 @@ file-filter Templates Шаблоны
|
||||
Delete Design Удалить дизайн
|
||||
Paste Design Вставить дизайн
|
||||
Select Unused Выбрать неиспользуемые
|
||||
Graph Column Столбец диаграммы
|
||||
Column Type: Тип столбца:
|
||||
Vertically Scaled Масштабирование по вертикали
|
||||
Uniformly Scaled Равномерное масштабирование
|
||||
Repeating Повторение
|
||||
Sliding Скольжение
|
||||
Each Design Represents: Каждый дизайн означает:
|
||||
For Fractions: Для дробных частей:
|
||||
Chop Design Обрезать дизайн
|
||||
Scale Design Масштабировать дизайн
|
||||
Import Data… Импорт данных…
|
||||
Transpose Транспонировать
|
||||
Switch x/y Поменять x/y
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1216,6 +1216,16 @@
|
||||
Delete Design Radera design
|
||||
Paste Design Klistra in design
|
||||
Select Unused Markera oanvända
|
||||
Graph Column Diagramkolumn
|
||||
Column Type: Kolumntyp:
|
||||
Vertically Scaled Vertikalt skalad
|
||||
Uniformly Scaled Likformigt skalad
|
||||
Repeating Upprepad
|
||||
Sliding Glidande
|
||||
Each Design Represents: Varje design motsvarar:
|
||||
For Fractions: För bråkdelar:
|
||||
Chop Design Kapa design
|
||||
Scale Design Skala design
|
||||
Import Data… Importera data…
|
||||
Transpose Transponera
|
||||
Switch x/y Växla x/y
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -1242,6 +1242,16 @@ file-filter Templates Шаблони
|
||||
Delete Design Видалити дизайн
|
||||
Paste Design Вставити дизайн
|
||||
Select Unused Вибрати невикористані
|
||||
Graph Column Стовпець діаграми
|
||||
Column Type: Тип стовпця:
|
||||
Vertically Scaled Масштабування по вертикалі
|
||||
Uniformly Scaled Рівномірне масштабування
|
||||
Repeating Повторення
|
||||
Sliding Ковзання
|
||||
Each Design Represents: Кожен дизайн означає:
|
||||
For Fractions: Для дробових частин:
|
||||
Chop Design Обрізати дизайн
|
||||
Scale Design Масштабувати дизайн
|
||||
Import Data… Імпорт даних…
|
||||
Transpose Транспонувати
|
||||
Switch x/y Поміняти x/y
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 722 and column 40.
|
@@ -1425,9 +1425,19 @@ file-filter Templates Mẫu
|
||||
Grainy Dots Chấm hạt
|
||||
Grain… Hạt…
|
||||
Graph Biểu đồ
|
||||
Graph Column Cột biểu đồ
|
||||
Graph Data Dữ liệu biểu đồ
|
||||
Graph Design Thiết kế biểu đồ
|
||||
Graph Marker Ký hiệu biểu đồ
|
||||
Column Type: Loại cột:
|
||||
Vertically Scaled Co giãn theo chiều dọc
|
||||
Uniformly Scaled Co giãn đều
|
||||
Repeating Lặp lại nhiều lần
|
||||
Sliding Trượt
|
||||
Each Design Represents: Mỗi thiết kế biểu thị:
|
||||
For Fractions: Với phần lẻ:
|
||||
Chop Design Cắt thiết kế
|
||||
Scale Design Co giãn thiết kế
|
||||
Graph Type Loại biểu đồ
|
||||
Graphic Đồ họa
|
||||
Graphic Style Kiểu đồ họa
|
||||
|
||||
|
@@ -1195,6 +1195,16 @@ file-filter Templates 模板
|
||||
Delete Design 删除设计
|
||||
Paste Design 粘贴设计
|
||||
Select Unused 选择未使用项
|
||||
Graph Column 图表柱形
|
||||
Column Type: 柱形类型:
|
||||
Vertically Scaled 垂直缩放
|
||||
Uniformly Scaled 等比缩放
|
||||
Repeating 重复
|
||||
Sliding 滑动
|
||||
Each Design Represents: 每个设计表示:
|
||||
For Fractions: 对于小数部分:
|
||||
Chop Design 截断设计
|
||||
Scale Design 缩放设计
|
||||
Import Data… 导入数据…
|
||||
Transpose 转置
|
||||
Switch x/y 切换 x/y
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1219,6 +1219,16 @@ file-filter Templates 範本
|
||||
Delete Design 刪除設計
|
||||
Paste Design 貼上設計
|
||||
Select Unused 選取未使用項目
|
||||
Graph Column 圖表長條
|
||||
Column Type: 長條類型:
|
||||
Vertically Scaled 垂直縮放
|
||||
Uniformly Scaled 等比例縮放
|
||||
Repeating 重複
|
||||
Sliding 滑動
|
||||
Each Design Represents: 每個設計代表:
|
||||
For Fractions: 小數部分:
|
||||
Chop Design 截斷設計
|
||||
Scale Design 縮放設計
|
||||
Import Data… 匯入資料…
|
||||
Transpose 轉置
|
||||
Switch x/y 切換 x/y
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2618,7 +2618,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
c("Type…", "graph.setType"),
|
||||
c("Data…", "graph.setData"),
|
||||
c("Design…", "graph.design"),
|
||||
todo("Column…"),
|
||||
c("Column…", "graph.column"),
|
||||
c("Marker…", "graph.marker"),
|
||||
],
|
||||
),
|
||||
@@ -3425,9 +3425,13 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Object → Graph → Design… / Marker…: the document's graph designs.
|
||||
if matches!(id, "graph.design" | "graph.marker") && p.as_object().is_none_or(|o| o.is_empty()) {
|
||||
let r = if id == "graph.design" { crate::dialogs::graph_design::open_design(app) } else { crate::dialogs::graph_design::open_marker(app) };
|
||||
// Object → Graph → Design… / Column… / Marker…: the document's graph designs.
|
||||
if matches!(id, "graph.design" | "graph.column" | "graph.marker") && p.as_object().is_none_or(|o| o.is_empty()) {
|
||||
let r = match id {
|
||||
"graph.design" => crate::dialogs::graph_design::open_design(app),
|
||||
"graph.column" => crate::dialogs::graph_design::open_column(app),
|
||||
_ => crate::dialogs::graph_design::open_marker(app),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
}
|
||||
|
||||
+1
-1
@@ -351,7 +351,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
||||
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
|
||||
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). |
|
||||
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
|
||||
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
|
||||
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. |
|
||||
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
|
||||
| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. |
|
||||
|
||||
|
||||
@@ -506,7 +506,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). A link toggle behind Corner Radius (the `singleCornerRadius` preference) switches the panels to a field for each corner of a live rectangle: Top Left, Top Right, Bottom Right and Bottom Left. Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply. With Direct Selection a click on a widget (no drag) selects its corner, as a click on the corner's anchor does, so the next drag on it rounds that corner alone; Shift-click toggles it (#1239). While a corner widget is dragged with the Selection tool the bounding box hides, so the rounding shows against the path alone (#1229). A round corner next to a curved side curves smoothly instead of sticking out of the shape (#1547, fixes #1512).
|
||||
View › Hide Edges hides the Live Corners widgets, the bounding box and the type widget, and the selection tools then do not answer a press on them or change the cursor over them.
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Average and Join in one step, with a corner or smooth choice for the joined anchor, is missing ([gap 1](gaps.md)).
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. Column designs remain open. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485).
|
||||
- **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Editing type:** double-clicking type with a selection tool (Selection or Direct Selection) switches to the Type tool with the caret where it was clicked; pressing Escape finishes editing and switches back to the selection tool used before double-clicking it (when in isolation mode, the first Escape exits text editing while preserving isolation mode, and a second Escape exits isolation).
|
||||
- **Editing type:** double-clicking type with a selection tool (Selection or Direct Selection) switches to the Type tool with the caret where it was clicked.
|
||||
|
||||
Reference in New Issue
Block a user