Files
vectorcraft/crates/doc/src/graph.rs
T
dotCooCoo b9309f7985 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.
2026-10-17 15:14:43 +07:00

566 lines
23 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Graphs (Illustrator's graph tools): a group whose art is generated from a [`GraphSpec`].
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use vectorcraft_geom::Rect;
/// Most categories (rows) and series (columns) a graph's data grid holds: the grid is built in full for drawing and
/// for the data window, so a file can't make it allocate without bound.
pub const MAX_GRAPH_CATEGORIES: usize = 10_000;
pub const MAX_GRAPH_SERIES: usize = 256;
/// The nine graph types, in the Graph tool group's order.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum GraphKind {
#[default]
Column,
StackedColumn,
Bar,
StackedBar,
Line,
Area,
Scatter,
Pie,
Radar,
}
impl GraphKind {
pub const ALL: [GraphKind; 9] = [
GraphKind::Column,
GraphKind::StackedColumn,
GraphKind::Bar,
GraphKind::StackedBar,
GraphKind::Line,
GraphKind::Area,
GraphKind::Scatter,
GraphKind::Pie,
GraphKind::Radar,
];
pub fn id(self) -> &'static str {
match self {
GraphKind::Column => "column",
GraphKind::StackedColumn => "stackedColumn",
GraphKind::Bar => "bar",
GraphKind::StackedBar => "stackedBar",
GraphKind::Line => "line",
GraphKind::Area => "area",
GraphKind::Scatter => "scatter",
GraphKind::Pie => "pie",
GraphKind::Radar => "radar",
}
}
/// Parse an id, also accepting the tool ids (`columnGraph`, `stackedBarGraph`…).
pub fn parse(s: &str) -> Option<Self> {
let s = s.strip_suffix("Graph").unwrap_or(s);
Self::ALL.into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
}
pub fn label(self) -> &'static str {
match self {
GraphKind::Column => "Column",
GraphKind::StackedColumn => "Stacked Column",
GraphKind::Bar => "Bar",
GraphKind::StackedBar => "Stacked Bar",
GraphKind::Line => "Line",
GraphKind::Area => "Area",
GraphKind::Scatter => "Scatter",
GraphKind::Pie => "Pie",
GraphKind::Radar => "Radar",
}
}
/// Whether a series of this type can be drawn in a graph of type `graph` (Combine different graph types): the
/// column, stacked column, line and area types share a vertical value axis, the bar types a horizontal one.
/// Scatter, pie and radar lay their data out on their own and take no other types.
pub fn combines_with(self, graph: GraphKind) -> bool {
use GraphKind::*;
let vertical = |k| matches!(k, Column | StackedColumn | Line | Area);
let horizontal = |k| matches!(k, Bar | StackedBar);
self == graph || (vertical(self) && vertical(graph)) || (horizontal(self) && horizontal(graph))
}
}
/// A graph design (Object › Graph › Design…): named art that graphs draw in place of their default marks.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GraphDesign {
pub name: String,
pub art: std::sync::Arc<crate::Node>,
}
impl GraphDesign {
/// Most designs a document keeps, objects in one design's art, and characters in a design's name.
pub const MAX: usize = 1000;
pub const MAX_NODES: usize = 2000;
pub const MAX_NAME: usize = 64;
/// A design name cleaned up: trimmed, without control characters, at most [`Self::MAX_NAME`] long; `None` when
/// nothing is left.
pub fn clean_name(name: &str) -> Option<String> {
let n: String = name.trim().chars().filter(|c| !c.is_control()).take(Self::MAX_NAME).collect();
Some(n.trim().to_string()).filter(|n| !n.is_empty())
}
/// `art` as plain design art: a graph inside it becomes the plain group it draws as, so a marker never holds a
/// graph of its own (that its commands would then edit).
pub fn plain_art(art: &crate::Node) -> crate::Node {
let mut n = art.clone();
{
let mut e = n.extras_mut();
e.graph = None;
e.series_index = None;
}
if let Some(ch) = n.children_mut() {
let old = std::mem::take(ch);
*ch = old.iter().map(|c| std::sync::Arc::new(Self::plain_art(c))).collect();
}
n
}
}
impl crate::Document {
/// The graph designs a file gave, made safe to list and draw: at most [`GraphDesign::MAX`], each with a clean,
/// unique name, art of at most [`GraphDesign::MAX_NODES`] objects and no graph inside.
pub fn tidy_graph_designs(&mut self) {
let mut kept: Vec<GraphDesign> = vec![];
for d in std::mem::take(&mut self.graph_designs) {
if kept.len() >= GraphDesign::MAX {
break;
}
let Some(name) = GraphDesign::clean_name(&d.name) else { continue };
if kept.iter().any(|k| k.name == name) || d.art.count() > GraphDesign::MAX_NODES {
continue;
}
kept.push(GraphDesign { name, art: std::sync::Arc::new(GraphDesign::plain_art(&d.art)) });
}
self.graph_designs = kept;
}
}
/// The character style a graph's labels keep through regeneration once formatted (Format the text in graphs): the
/// value axis numbers, the category labels (category axis, radar spokes, pie names) and the legend labels (beside or
/// across the top, or in pie wedges). `None` draws the default 9 pt black.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct LabelStyles {
#[serde(skip_serializing_if = "Option::is_none")]
pub values: Option<crate::CharStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub categories: Option<crate::CharStyle>,
#[serde(skip_serializing_if = "Option::is_none")]
pub legend: Option<crate::CharStyle>,
}
impl LabelStyles {
fn is_default(&self) -> bool {
*self == Self::default()
}
}
/// Graph Type › Position for pie graphs with several pies: the same size (Even), sized by their totals (Ratio), or
/// stacked one on another, sized by their totals (Stacked).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PiePosition {
#[default]
Even,
Ratio,
Stacked,
}
/// Graph Type › Sort for pie graphs: wedges in data order (None), largest first in each pie (All), or in the first
/// pie's largest-first order in every pie (First).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PieSort {
#[default]
None,
All,
First,
}
macro_rules! ids {
($t:ty { $($v:ident => $id:literal),* }) => {
impl $t {
pub fn id(self) -> &'static str {
match self { $(Self::$v => $id),* }
}
pub fn parse(s: &str) -> Option<Self> {
[$(Self::$v),*].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
}
fn is_default(&self) -> bool {
*self == Self::default()
}
}
};
}
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")]
pub enum ValueAxisSide {
#[default]
Left,
Right,
Both,
}
impl ValueAxisSide {
pub fn id(self) -> &'static str {
match self {
ValueAxisSide::Left => "left",
ValueAxisSide::Right => "right",
ValueAxisSide::Both => "both",
}
}
pub fn parse(s: &str) -> Option<Self> {
[ValueAxisSide::Left, ValueAxisSide::Right, ValueAxisSide::Both].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
}
fn is_left(&self) -> bool {
*self == ValueAxisSide::Left
}
}
/// Graph Type › Tick Marks › Length: no tick marks, short ones outside the axis, or lines across the whole plot.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TickLength {
None,
#[default]
Short,
Full,
}
impl TickLength {
pub fn id(self) -> &'static str {
match self {
TickLength::None => "none",
TickLength::Short => "short",
TickLength::Full => "full",
}
}
pub fn parse(s: &str) -> Option<Self> {
[TickLength::None, TickLength::Short, TickLength::Full].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
}
fn is_short(&self) -> bool {
*self == TickLength::Short
}
fn is_none(&self) -> bool {
*self == TickLength::None
}
}
/// The fill and stroke captured from one series' generated marks. An absent paint keeps whatever
/// the generator draws for that part (greyscale for a series fill, the part's own stroke otherwise).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SeriesPaint {
#[serde(skip_serializing_if = "Option::is_none")]
pub fill: Option<vectorcraft_color::Paint>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke: Option<vectorcraft_color::Paint>,
/// Stroke weight in points, kept with [`Self::stroke`].
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke_width: Option<f64>,
}
/// Graph data and options. `rows` are categories (one per row of the Graph Data window), each
/// holding one value per series; `series` are the legend labels (the first row of the window).
/// Scatter graphs read each series as (y, x) column pairs, like Illustrator.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct GraphSpec {
/// Paint taken from the series' marks (Group Selection). One entry per series index; missing
/// entries keep the generator's own fill and stroke.
pub series_paints: Vec<SeriesPaint>,
pub kind: GraphKind,
/// Graph types chosen for single series (Combine different graph types), by series index; `None` and missing
/// entries draw as [`Self::kind`].
#[serde(skip_serializing_if = "Vec::is_empty")]
pub series_kinds: Vec<Option<GraphKind>>,
/// The plot rectangle (the area the graph tool dragged), document points.
pub rect: Rect,
pub series: Vec<String>,
pub categories: Vec<String>,
pub rows: Vec<Vec<f64>>,
/// Blank cells as `[category, series]`: their `rows` entry is a 0 placeholder, so a file still reads (as zeros)
/// in a version that doesn't know blanks. Cells past the end of a short row are blank too.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub blanks: Vec<[usize; 2]>,
/// Column/bar width as a % of the available category width (90); past 100 the columns overlap.
pub column_width: f64,
/// Cluster width as a % (80); past 100 the clusters overlap.
pub cluster_width: f64,
/// Legend on the right (Graph Type options: Add Legend Across Top = false).
pub legend: bool,
/// Value axis: tick count override (0 = automatic), min/max override.
pub ticks: usize,
pub axis_min: Option<f64>,
pub axis_max: Option<f64>,
/// Graph Type › Value Axis side. Column, stacked column, line, area and scatter graphs (the bar graphs' value axis
/// runs along the bottom and stays there).
#[serde(skip_serializing_if = "ValueAxisSide::is_left")]
pub value_axis: ValueAxisSide,
/// Graph Type › Separate Scales: with the value axis on both sides, the series on the right axis
/// ([`Self::right_series`]) get a scale of their own there.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub separate_scales: bool,
/// Indexes of the series assigned to the right value axis.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub right_series: Vec<usize>,
/// The right value axis' tick values, used with separate scales: tick count (0 = automatic) and min/max override.
#[serde(skip_serializing_if = "is_zero")]
pub right_ticks: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub right_axis_min: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub right_axis_max: Option<f64>,
/// The labels' character styles, kept from formatting them.
#[serde(skip_serializing_if = "LabelStyles::is_default")]
pub label_styles: LabelStyles,
/// Graph Type › First Row in Front (each series' first category drawn over the next when columns overlap) and
/// First Column in Front (the first series drawn over the others); Draw Filled Lines (lines as filled bands).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub first_row_in_front: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub first_column_in_front: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub filled_lines: bool,
/// Graph Type › Add Drop Shadow (behind columns, bars, lines and pie wedges) and Add Legend Across Top (the
/// legend in rows above the plot instead of a column on its right).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub drop_shadow: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub legend_across_top: bool,
/// Pie graphs: Legends in Wedges (the series labels inside their wedges instead of a legend beside the pies),
/// Position and Sort.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub pie_legend_in_wedges: bool,
#[serde(skip_serializing_if = "PiePosition::is_default")]
pub pie_position: PiePosition,
#[serde(skip_serializing_if = "PieSort::is_default")]
pub pie_sort: PieSort,
/// Object › Graph › Marker: the graph design each series' data points (and legend swatch) are drawn with, by
/// 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")]
pub tick_length: TickLength,
#[serde(skip_serializing_if = "at_most_one")]
pub tick_marks: usize,
#[serde(skip_serializing_if = "TickLength::is_short")]
pub right_tick_length: TickLength,
#[serde(skip_serializing_if = "at_most_one")]
pub right_tick_marks: usize,
/// Tick Marks for the category axis (none by default), and Draw Tick Marks Between Labels.
#[serde(skip_serializing_if = "TickLength::is_none")]
pub category_tick_length: TickLength,
#[serde(skip_serializing_if = "at_most_one")]
pub category_tick_marks: usize,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub ticks_between_labels: bool,
/// Graph Type › Add Labels: text before and after the value axes' numbers.
#[serde(skip_serializing_if = "String::is_empty")]
pub prefix: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub suffix: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub right_prefix: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub right_suffix: String,
/// Line, scatter and radar graphs: mark data points, connect them.
pub mark_points: bool,
pub connect_points: bool,
/// Line graphs: run the lines from edge to edge of the plot; off (the default for new graphs), the points sit
/// at the centres of their categories, like the columns of a column graph. Graphs saved before the option
/// existed were drawn edge to edge and keep it.
#[serde(default = "edge_to_edge_before_the_option")]
pub edge_to_edge: bool,
/// Bounds of the art when it was last generated (detects moves/scales of the graph group).
#[serde(skip_serializing_if = "Option::is_none")]
pub placed: Option<Rect>,
}
fn at_most_one(n: &usize) -> bool {
*n <= 1
}
fn is_zero(n: &usize) -> bool {
*n == 0
}
/// Edge-to-Edge Lines for a graph saved before the option existed: on, as it was drawn then.
fn edge_to_edge_before_the_option() -> bool {
true
}
impl GraphSpec {
/// The type series `s` is drawn as: its own type when it combines with the graph's, else the graph's.
pub fn series_kind(&self, s: usize) -> GraphKind {
self.series_kinds.get(s).copied().flatten().filter(|k| k.combines_with(self.kind)).unwrap_or(self.kind)
}
/// The data with blank cells as `None`, every row as long as the longest (at most [`MAX_GRAPH_CATEGORIES`] rows
/// of [`MAX_GRAPH_SERIES`] cells).
pub fn cells(&self) -> Vec<Vec<Option<f64>>> {
let width = self.rows.iter().map(Vec::len).max().unwrap_or(0).min(MAX_GRAPH_SERIES);
let blank: HashSet<[usize; 2]> = self.blanks.iter().copied().collect();
self.rows
.iter()
.take(MAX_GRAPH_CATEGORIES)
.enumerate()
.map(|(c, r)| (0..width).map(|s| r.get(s).copied().filter(|_| !blank.contains(&[c, s]))).collect())
.collect()
}
/// Store `cells`, a blank (`None`) as a 0 placeholder listed in `blanks`.
pub fn set_cells(&mut self, cells: Vec<Vec<Option<f64>>>) {
self.blanks =
cells.iter().enumerate().flat_map(|(c, r)| r.iter().enumerate().filter(|(_, v)| v.is_none()).map(move |(s, _)| [c, s])).collect();
self.rows = cells.into_iter().map(|r| r.into_iter().map(|v| v.unwrap_or(0.0)).collect()).collect();
}
/// CSV of this graph's data (header row with series names, then category + values), as Graph
/// Data and `graph.setData` use. Always writes a header so a blank first category isn't
/// misread as one.
pub fn to_csv(&self) -> String {
let cells = self.cells();
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(self.series.len());
let mut out = String::from(",");
// Quote a label holding a separator, quote or line break (inner quotes doubled), or one with
// surrounding spaces; `always` also quotes blank and numeric labels so they stay labels.
let field = |c: &str, always: bool| {
let special = c.contains([',', '\t', '"', '\n', '\r']) || c != c.trim();
if special || (always && (c.trim().is_empty() || c.trim().parse::<f64>().is_ok())) {
format!("\"{}\"", c.replace('"', "\"\""))
} else {
c.to_string()
}
};
out.push_str(&(0..width).map(|s| field(self.series.get(s).map(String::as_str).unwrap_or_default(), false)).collect::<Vec<_>>().join(","));
out.push('\n');
let labelled = !self.categories.is_empty();
for (i, r) in cells.iter().enumerate() {
if labelled {
out.push_str(&field(self.categories.get(i).map(String::as_str).unwrap_or_default(), true));
out.push(',');
}
out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::<Vec<_>>().join(","));
out.push('\n');
}
out
}
}
impl Default for GraphSpec {
fn default() -> Self {
Self {
series_paints: vec![],
kind: GraphKind::Column,
series_kinds: vec![],
rect: Rect::new(0.0, 0.0, 200.0, 150.0),
series: vec![],
categories: vec![],
rows: vec![vec![1.0]],
blanks: vec![],
column_width: 90.0,
cluster_width: 80.0,
legend: true,
ticks: 0,
axis_min: None,
axis_max: None,
value_axis: ValueAxisSide::Left,
separate_scales: false,
right_series: vec![],
right_ticks: 0,
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,
filled_lines: false,
drop_shadow: false,
legend_across_top: false,
pie_legend_in_wedges: false,
pie_position: PiePosition::Even,
pie_sort: PieSort::None,
tick_length: TickLength::Short,
tick_marks: 1,
right_tick_length: TickLength::Short,
right_tick_marks: 1,
category_tick_length: TickLength::None,
category_tick_marks: 1,
ticks_between_labels: false,
prefix: String::new(),
suffix: String::new(),
right_prefix: String::new(),
right_suffix: String::new(),
mark_points: true,
connect_points: true,
edge_to_edge: false,
placed: None,
}
}
}