vectorcraft/crates/engine/src/cmd/graph.rs

3490 lines
186 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: the nine graph tools and Object → Graph (Type…, Data…).
//!
//! A graph is a group carrying a [`GraphSpec`]; its children (axes, labels, one group per series
//! with that series' legend swatch, like Illustrator's group-selectable series) are generated from
//! the spec. Editing the data or the type regenerates them in place, keeping any move/scale the
//! user applied to the graph since it was generated.
use std::collections::HashMap;
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,
};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
use super::typecmd::refresh_bounds;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"graph.create",
"Create Graph",
[],
None,
"{type: column|stackedColumn|bar|stackedBar|line|area|scatter|pie|radar, x, y, width, height, series?: [..], categories?: [..], rows?: [[number|null]], csv?} → {id}; an empty CSV cell or a null is a blank value, a number in straight quotes is a label",
has_doc,
create
),
cmd!(
"graph.setData",
"Data…",
["Object", "Graph"],
None,
"{id?, series?, categories?, rows?, csv?: first row = series labels (first cell empty), then one row per category: label, values…; an empty cell or a null is a blank value, a quoted number a label, a `|` in a label a line break; transpose?: true swaps rows and columns (Transpose row/column), switchXY?: true swaps each scatter series' y and x columns (Switch x/y)} replace the graph's data; no data → the current {csv, series, categories, rows}",
has_selection,
set_data
),
cmd!(
"graph.setType",
"Type…",
["Object", "Graph"],
None,
"{id?, type?, seriesIndexes?: [index], columnWidth?: %, clusterWidth?: %, legend?: bool, markPoints?: bool, connectPoints?: bool, edgeToEdge?: bool (line graphs: true runs the lines across the whole plot, false puts the points at the centres of their categories), ticks?: n, axisMin?, axisMax?, valueAxis?: left|right|both (the bar graphs' value axis stays along the bottom), separateScales?: bool, rightTicks?: n, rightAxisMin?, rightAxisMax?, tickLength?: none|short|full, tickMarks?: n per division, rightTickLength?, rightTickMarks?, categoryTickLength?: none|short|full, categoryTickMarks?: n, ticksBetweenLabels?: bool, prefix?, suffix?, rightPrefix?, rightSuffix?: text around the value axis numbers, dropShadow?: bool (Add Drop Shadow: a translucent black shadow down and right of the columns, bars, lines and pie wedges), legendAcrossTop?: bool (Add Legend Across Top: the legend in rows above the plot), pieLegend?: none|standard|wedges (Legends in Wedges: the series labels inside their wedges), piePosition?: ratio|even|stacked (several pies sized by their totals, at one size (the default), or stacked), pieSort?: all|first|none (wedges largest first in each pie, in the first pie's order, or in data order (the default)); the query lists the pie options for pie graphs} change the graph type and options; with `seriesIndexes`, or (no `id`) with only series selected with Group Selection, `type` goes to those series only and `valueAxis` (left|right; both is refused) puts them on that value axis, while the other options still apply to the whole graph (Combine different graph types: column, stacked column, line and area mix, and so do bar and stacked bar; a series given the graph's type follows the graph again) (axisMin and axisMax together override the calculated value axis: exactly that range in `ticks` divisions, 5 when 0; with the value axis on both sides, separateScales gives the series on the right axis a scale of their own, set the same way with rightTicks, rightAxisMin and rightAxisMax); no options → the current ones",
has_selection,
set_type
),
cmd!(
"graph.design",
"Design…",
["Object", "Graph"],
None,
"{new?: name|true (New Design: the selected art becomes a graph design, named \"New Design\" when true; put a rectangle at the back to set the size it draws at), rename?: name, to?: new name (the graphs drawing it follow), paste?: name (Paste Design: a copy of the design's art into the document, selected, to edit and save again), delete?: name|[names] (graphs drawing them go back to their default marks)}; no params or selectUnused → {designs: [names], unused: [the names no graph draws with]}",
has_doc,
design
),
cmd!(
"graph.marker",
"Marker…",
["Object", "Graph"],
None,
"{id?, seriesIndexes?: [index], design?: name|null} draw the data points and legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, scaled so its backmost object fills the default marker's square; null = the default marker. Line, scatter and radar series. No design → {designs: [names], design: the selected series' design or null}",
has_selection,
set_marker
),
]
}
// ---------- data ----------
/// One CSV cell: its text, and whether it was in straight quotes (a quoted number is a label, not a value).
struct Cell {
text: String,
quoted: bool,
}
impl Cell {
fn number(&self) -> Option<f64> {
if self.quoted { None } else { self.text.parse::<f64>().ok().filter(|v| v.is_finite()) }
}
}
fn parse_csv(csv: &str) -> (Vec<String>, Vec<String>, Vec<Vec<Option<f64>>>) {
let rows: Vec<Vec<Cell>> = csv
.lines()
.map(|l| l.trim_end_matches('\r'))
.filter(|l| !l.trim().is_empty())
.map(|l| {
let sep = if l.contains('\t') { '\t' } else { ',' };
l.split(sep)
.map(|c| {
let c = c.trim();
let quoted = c.len() >= 2 && c.starts_with('"') && c.ends_with('"');
Cell { text: c.trim_matches('"').to_string(), quoted }
})
.collect()
})
.collect();
let Some(first) = rows.first() else { return (vec![], vec![], vec![]) };
// A label is quoted or doesn't read as a number; an empty unquoted cell is a blank value.
let label = |c: &Cell| c.quoted || (c.number().is_none() && !c.text.is_empty());
// A header row has an empty first cell or a label after it, or is a label followed only by empty cells (`Year,,`).
// All-empty series names mean no legend.
let empty = |c: &Cell| c.text.is_empty() && !c.quoted;
let header = first.first().is_some_and(empty)
|| first.iter().skip(1).any(label)
|| (first.len() > 1 && first.first().is_some_and(label) && first.iter().skip(1).all(empty));
let mut series: Vec<String> = if header { first.iter().skip(1).map(|c| c.text.clone()).collect() } else { vec![] };
if series.iter().all(String::is_empty) {
series.clear();
}
let data = rows.get(usize::from(header)..).unwrap_or_default();
// The first column holds the category labels if any row starts with one; then it does for every row, so a
// row with an empty label doesn't shift its values one place left.
let labelled = data.iter().any(|r| r.first().is_some_and(label));
let mut categories = vec![];
let mut values = vec![];
for r in data {
if labelled {
categories.push(r.first().map(|c| c.text.clone()).unwrap_or_default());
}
values.push(r.iter().skip(labelled as usize).map(Cell::number).collect());
}
(series, categories, values)
}
fn to_csv(g: &GraphSpec) -> String {
// Always a header row (empty first cell), so a first data row starting with a blank isn't read as one.
let cells = g.cells();
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(g.series.len());
let mut out = String::from(",");
out.push_str(&(0..width).map(|s| g.series.get(s).cloned().unwrap_or_default()).collect::<Vec<_>>().join(","));
out.push('\n');
let labelled = !g.categories.is_empty();
for (i, r) in cells.iter().enumerate() {
if labelled {
// An empty label, one that reads as a number or one with spaces around it goes in quotes, so it reads back
// as the same label.
let c = g.categories.get(i).map(String::as_str).unwrap_or_default();
if c.trim().is_empty() || c.trim().parse::<f64>().is_ok() || c != c.trim() {
out.push_str(&format!("\"{c}\""));
} else {
out.push_str(c);
}
out.push(',');
}
out.push_str(&r.iter().map(|v| v.map(fmt_value).unwrap_or_default()).collect::<Vec<_>>().join(","));
out.push('\n');
}
out
}
fn apply_data(g: &mut GraphSpec, p: &Value) -> bool {
let mut changed = false;
if let Some(csv) = str_param(p, "csv") {
let (s, c, r) = parse_csv(csv);
g.series = s;
g.categories = c;
g.set_cells(r);
changed = true;
}
let strings =
|v: &Value| v.as_array().map(|a| a.iter().map(|x| x.as_str().map(str::to_string).unwrap_or_else(|| x.to_string())).collect::<Vec<_>>());
if let Some(s) = p.get("series").and_then(strings) {
g.series = s;
changed = true;
}
if let Some(c) = p.get("categories").and_then(strings) {
g.categories = c;
changed = true;
}
if let Some(rows) = p.get("rows").and_then(Value::as_array) {
// `null` (or anything that isn't a finite number) is a blank cell.
g.set_cells(
rows.iter().map(|r| r.as_array().map(|a| a.iter().map(|v| v.as_f64().filter(|v| v.is_finite())).collect()).unwrap_or_default()).collect(),
);
changed = true;
}
drop_empty_rows(g);
changed
}
/// Drop rows with neither a value nor a label; a label-only row stays, its values blank.
fn drop_empty_rows(g: &mut GraphSpec) {
let mut cells = g.cells();
let keep: Vec<bool> = (0..cells.len())
.map(|c| cells.get(c).is_some_and(|r| r.iter().any(Option::is_some)) || g.categories.get(c).is_some_and(|l| !l.is_empty()))
.collect();
if keep.contains(&false) || cells.len() < g.rows.len() {
let mut k = keep.iter();
cells.retain(|_| k.next().copied().unwrap_or(false));
let mut k = keep.iter();
g.categories.retain(|_| k.next().copied().unwrap_or(true));
g.set_cells(cells);
}
}
fn fmt_value(v: f64) -> String {
if (v - v.round()).abs() < 1e-9 {
format!("{}", v.round() as i64)
} else {
format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string()
}
}
// ---------- generation ----------
/// Series colours: black, then greys (graphs start in greyscale).
fn default_series_color(i: usize) -> Color {
const LEVELS: [f32; 8] = [0.0, 0.55, 0.8, 0.3, 0.68, 0.15, 0.9, 0.42];
let l = LEVELS[i % LEVELS.len()];
Color::rgb(l, l, l)
}
fn default_series_paint(i: usize) -> Paint {
Paint::solid(default_series_color(i))
}
/// Fill and stroke for one generated mark of series `i`. A stored fill is used only where the
/// generator paints with the series colour (`series_fill`). A stored stroke replaces that part's
/// own stroke, so a column's stroke stays a stroke and a line's markers do not repaint its line.
fn series_marks(g: &GraphSpec, i: usize, series_fill: bool, stroke: Paint, width: f64) -> (Paint, Paint, f64) {
let slot = g.series_paints.get(i);
let fill = if series_fill { slot.and_then(|p| p.fill.clone()).unwrap_or_else(|| default_series_paint(i)) } else { Paint::None };
let stroke = slot.and_then(|p| p.stroke.clone()).unwrap_or(stroke);
let width = if slot.is_some_and(|p| p.stroke.is_some()) { slot.and_then(|p| p.stroke_width).unwrap_or(width) } else { width };
(fill, stroke, width)
}
const LABEL_SIZE: f64 = 9.0;
/// Most tick marks per division (Graph Type › Tick Marks), and most category tick marks a graph draws.
const MAX_TICK_MARKS: usize = 20;
const MAX_CATEGORY_TICKS: usize = 10_000;
/// Longest axis label prefix or suffix (Graph Type › Add Labels).
const MAX_AFFIX: usize = 64;
/// An axis label prefix or suffix: one line (no control characters or line separators), at most [`MAX_AFFIX`] long.
fn one_line(s: &str) -> String {
s.chars().filter(|c| !c.is_control() && !matches!(c, '\u{2028}' | '\u{2029}')).take(MAX_AFFIX).collect()
}
/// A count parameter: a whole number, also as the float a dialog sends back (3.0), at most `max`.
fn count_param(p: &Value, key: &str, max: u64) -> Option<usize> {
let v = p.get(key)?;
let n = v.as_u64().or_else(|| v.as_f64().filter(|x| x.is_finite() && *x >= 0.0).map(|x| x.round().min(max as f64) as u64))?;
usize::try_from(n.min(max)).ok()
}
/// A "nice" axis: (min, max, step).
fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) {
let (lo, hi) = if (hi - lo).abs() < 1e-12 { (lo.min(0.0), lo.max(0.0) + 1.0) } else { (lo, hi) };
let n = if ticks == 0 { 5 } else { ticks.clamp(1, 100) } as f64;
let raw = (hi - lo) / n;
let mag = 10f64.powf(raw.log10().floor());
let step = [1.0, 2.0, 2.5, 5.0, 10.0].iter().map(|m| m * mag).find(|s| *s >= raw - 1e-12).unwrap_or(10.0 * mag);
((lo / step).floor() * step, (hi / step).ceil() * step, step)
}
/// A value axis: with both `min` and `max` set (Graph Type › Tick Values › Override Calculated Values), exactly that
/// range split into `ticks` divisions (5 when automatic); otherwise a nice axis around the data, using whichever bound
/// was given.
fn value_axis(ticks: usize, axis_min: Option<f64>, axis_max: Option<f64>, lo: f64, hi: f64) -> (f64, f64, f64) {
match (axis_min, axis_max) {
(Some(min), Some(max)) if max > min && (max - min).is_finite() => {
let n = if ticks == 0 { 5 } else { ticks.clamp(1, 100) } as f64;
(min, max, (max - min) / n)
}
_ => {
// An override too wide for f64 (or NaN) gives a non-finite axis, which would reach `clamp` with NaN bounds
// and panic: fall back to the data, then to 0..1.
let finite = |(a, b, s): (f64, f64, f64)| a.is_finite() && b.is_finite() && s.is_finite() && b > a && s > 0.0;
[nice_axis(axis_min.unwrap_or(lo), axis_max.unwrap_or(hi), ticks), nice_axis(lo, hi, ticks)]
.into_iter()
.find(|&a| finite(a))
.unwrap_or((0.0, 1.0, 0.2))
}
}
}
/// The tick values from `lo` to `hi` every `step`, both ends included: counted, not accumulated, so the last tick
/// lands on `hi` and a step too small for the range (or not finite) can't loop forever. At most 1000 ticks.
fn tick_values(lo: f64, hi: f64, step: f64) -> Vec<f64> {
let n = ((hi - lo) / step).round();
if !(lo.is_finite() && step.is_finite() && step > 0.0 && n.is_finite() && n >= 0.0) {
return vec![];
}
(0..=(n as usize).min(1000)).map(|i| lo + i as f64 * step).collect()
}
struct Gen<'a> {
d: &'a mut Document,
out: Vec<Arc<Node>>,
}
impl Gen<'_> {
fn path(&mut self, pd: PathData, fill: Paint, stroke: Paint, w: f64) -> Arc<Node> {
Arc::new(Node::path(self.d.alloc_id(), pd, Appearance::basic(fill, stroke, w)))
}
fn line(&mut self, a: Point, b: Point) -> Arc<Node> {
let mut bp = BezPath::new();
bp.move_to(a);
bp.line_to(b);
self.path(PathData::from_bezpath(&bp), Paint::None, Paint::solid(Color::BLACK), 0.5)
}
fn text(&mut self, at: Point, s: &str, justify: Justify) -> Arc<Node> {
let style = CharStyle { size: LABEL_SIZE, fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
let mut t = TextObject::point(at, s, style);
t.para.justify = justify;
refresh_bounds(&mut t);
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
}
/// A category or series label: a `|` in it starts a new line (Add graph labels). `middle`: the lines are centred
/// on `at` (labels beside an axis or in a wedge) instead of running down from it.
fn label(&mut self, at: Point, s: &str, justify: Justify, middle: bool) -> Arc<Node> {
let lines = s.split('|').count();
let at = if middle { Point::new(at.x, at.y - (lines - 1) as f64 * LABEL_SIZE * 1.2 / 2.0) } else { at };
self.text(at, &s.replace('|', "\n"), justify)
}
fn group(&mut self, name: &str, children: Vec<Arc<Node>>) -> Arc<Node> {
let mut g = Node::group(self.d.alloc_id(), children);
g.name = Some(name.into());
Arc::new(g)
}
/// 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,
None => self.path(shapes::rectangle(cell), fill, stroke, w),
}
}
/// 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> {
let mut g = Node::group(self.d.alloc_id(), children);
g.name = Some(name.into());
g.series_index = u32::try_from(index).ok();
Arc::new(g)
}
}
/// Add Drop Shadow: how far the shadow sits down and right of its mark, and how dark it is.
const SHADOW_OFFSET: f64 = 2.0;
const SHADOW_OPACITY: f32 = 0.35;
/// Most marks a graph draws shadows for (each is a copy).
const SHADOW_BUDGET: usize = 200_000;
/// Mark `n`'s shadow: a black copy moved down and right, its fill and stroke (whichever it has) black.
fn shadow_of(d: &mut Document, n: &Node) -> Arc<Node> {
// Generated marks carry only paint and a stroke width, so that's all a shadow keeps.
let mut c = n.clone();
c.transform(Affine::translate((SHADOW_OFFSET, SHADOW_OFFSET)), false);
let fill = if c.appearance.fill_paint().is_none() { Paint::None } else { Paint::solid(Color::BLACK) };
let stroke = if c.appearance.stroke_paint().is_none() { Paint::None } else { Paint::solid(Color::BLACK) };
c.appearance = Appearance::basic(fill, stroke, c.appearance.stroke_width()).into();
Arc::new(d.reid(&c))
}
/// How wide label `s` is set (its widest line), without adding it to a document.
fn label_width(s: &str) -> f64 {
let style = CharStyle { size: LABEL_SIZE, fill: Paint::solid(Color::BLACK), ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, &s.replace('|', "\n"), style);
refresh_bounds(&mut t);
Node::new(NodeId(0), NodeKind::Text(Box::new(t))).geometric_bounds().map_or(0.0, |b| b.width())
}
/// A label with nothing to show (empty, or only `|` line breaks).
fn blank_label(s: &str) -> bool {
s.chars().all(|c| c == '|')
}
/// Text node `t` with its characters painted `colour`.
fn recolour_text(t: &Arc<Node>, colour: Color) -> Arc<Node> {
let mut n = (**t).clone();
if let NodeKind::Text(text) = &mut n.kind {
for run in &mut text.runs {
run.style.fill = Paint::solid(colour);
}
}
Arc::new(n)
}
/// The stretches of two or more points between blank cells, which a line connects.
fn runs(pts: &[Option<Point>]) -> Vec<Vec<Point>> {
pts.split(Option::is_none).map(|r| r.iter().flatten().copied().collect::<Vec<_>>()).filter(|r| r.len() > 1).collect()
}
fn polyline(pts: &[Point], closed: bool) -> PathData {
let mut bp = BezPath::new();
for (i, p) in pts.iter().enumerate() {
if i == 0 {
bp.move_to(*p);
} else {
bp.line_to(*p);
}
}
if closed {
bp.close_path();
}
PathData::from_bezpath(&bp)
}
/// Most objects a graph's marker designs may add (each data point and legend swatch draws a copy of its design).
const MARKER_BUDGET: usize = 200_000;
/// Each series' marker design art, when the document has the design.
fn marker_designs(d: &Document, g: &GraphSpec) -> Vec<Option<Arc<Node>>> {
let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES);
(0..nser)
.map(|s| {
let name = g.series_markers.get(s)?.as_deref()?;
d.graph_designs.iter().find(|x| x.name == name).map(|x| x.art.clone())
})
.collect()
}
/// How many objects the marker designs `designs` add to graph `g`: a copy per data point and legend swatch.
fn marker_cost(g: &GraphSpec, designs: &[Option<Arc<Node>>]) -> usize {
let points = g.rows.len().min(vectorcraft_doc::MAX_GRAPH_CATEGORIES) + 1;
designs.iter().flatten().map(|a| a.count().saturating_mul(points)).fold(0, usize::saturating_add)
}
/// 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());
// 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.
fn generate(d: &mut Document, g: &GraphSpec) -> 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.
let mut designs = marker_designs(d, g);
if marker_cost(g, &designs) > MARKER_BUDGET {
designs.clear();
}
let design = |s: usize| designs.get(s).and_then(Option::as_ref);
let mut b = Gen { d, out: vec![] };
let r = g.rect;
let nser = g.rows.iter().map(Vec::len).max().unwrap_or(0).max(1);
let ncat = g.rows.len().max(1);
let series_label = |i: usize| g.series.get(i).cloned().unwrap_or_default();
let mut series: Vec<Vec<Arc<Node>>> = vec![vec![]; nser];
// A blank cell (no value) is `None`: columns, bars and stacked segments leave it out, lines and scatter points
// break around it and the value axis ignores it. Area, pie and radar draw it as 0, which is what it adds to them.
let cells = g.cells();
let cell = |c: usize, s: usize| cells.get(c).and_then(|r| r.get(s)).copied().flatten();
let val = |c: usize, s: usize| cell(c, s).unwrap_or(0.0);
// Each series' own type (Combine different graph types); one type for the whole graph unless a series has its own.
let kinds: Vec<GraphKind> = (0..nser).map(|s| g.series_kind(s)).collect();
let kind = |s: usize| kinds.get(s).copied().unwrap_or(g.kind);
let horizontal = matches!(g.kind, GraphKind::Bar | GraphKind::StackedBar);
// Where the art right of the plot ends (the right value axis' labels), so the legend starts after it.
let mut right_edge = r.x1;
// Labels drawn over the series: a pie's Legends in Wedges (in the Legend group) and stacked pies' names.
let mut wedge_labels = vec![];
let mut on_top = vec![];
// Add Drop Shadow: the columns, bars, lines and wedges to cast one.
let mut shadowed: Vec<(usize, Arc<Node>)> = vec![];
match g.kind {
GraphKind::Pie => {
// One pie per row, a wedge per series, clockwise from 12 o'clock. Position: side by side at one size
// (Even) or sized by their totals (Ratio, by area), or stacked on one centre, the largest at the back.
let totals: Vec<f64> = (0..ncat).map(|c| (0..nser).map(|s| val(c, s).max(0.0)).sum()).collect();
let biggest = totals.iter().copied().fold(0.0, f64::max);
let stacked = g.pie_position == vectorcraft_doc::PiePosition::Stacked;
let w = if stacked { r.width() } else { r.width() / ncat as f64 };
let full = (w.min(r.height()) / 2.0 * 0.9).max(1.0);
let radius = |total: f64| match g.pie_position {
vectorcraft_doc::PiePosition::Even => full,
_ if biggest > 0.0 && biggest.is_finite() => (full * (total / biggest).sqrt()).max(1.0),
_ => full,
};
// Sort: data order, largest first in each pie, or the first pie's largest-first order everywhere.
let order = |c: usize| {
let mut o: Vec<usize> = (0..nser).collect();
let by = match g.pie_sort {
vectorcraft_doc::PieSort::None => return o,
vectorcraft_doc::PieSort::All => c,
vectorcraft_doc::PieSort::First => 0,
};
o.sort_by(|a, b| val(by, *b).max(0.0).total_cmp(&val(by, *a).max(0.0)));
o
};
let mut pies: Vec<usize> = (0..ncat).collect();
if stacked {
pies.sort_by(|a, b| totals.get(*b).copied().unwrap_or(0.0).total_cmp(&totals.get(*a).copied().unwrap_or(0.0)));
}
// Stacked pies share a centre: each one but the smallest is drawn as the ring outside the next smaller
// one, so a pie never covers another (a pie as big as the next one has no ring and is left out).
let inner: Vec<f64> = if stacked {
(0..pies.len()).map(|k| pies.get(k + 1).map_or(0.0, |c| radius(totals.get(*c).copied().unwrap_or(0.0)))).collect()
} else {
vec![0.0; pies.len()]
};
for (k, c) in pies.into_iter().enumerate() {
let total = totals.get(c).copied().unwrap_or(0.0);
let rad = radius(total);
let hole = inner.get(k).copied().unwrap_or(0.0);
if hole >= rad - 1e-9 {
continue;
}
let centre = if stacked { r.center() } else { Point::new(r.x0 + w * (c as f64 + 0.5), r.y0 + r.height() / 2.0) };
let mut a0 = -std::f64::consts::FRAC_PI_2;
for s in order(c) {
let v = val(c, s).max(0.0);
if total <= 0.0 || v <= 0.0 {
continue;
}
let sweep = v / total * std::f64::consts::TAU;
let mut bp = BezPath::new();
let dir = |a: f64| vectorcraft_geom::Vec2::new(a.cos(), a.sin());
let arc = vectorcraft_geom::kurbo::Arc::new(centre, (rad, rad), a0, sweep, 0.0);
if hole > 0.0 {
// A ring segment: out along the outer arc, back along the inner one.
bp.move_to(centre + dir(a0) * rad);
arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p));
bp.line_to(centre + dir(a0 + sweep) * hole);
let back = vectorcraft_geom::kurbo::Arc::new(centre, (hole, hole), a0 + sweep, -sweep, 0.0);
back.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p));
} else {
bp.move_to(centre);
bp.line_to(centre + dir(a0) * rad);
arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p));
}
bp.close_path();
let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.5);
// White text on a dark wedge.
let dark = fill.color().is_some_and(|c| {
let [r, g, b] = c.to_rgb();
0.2126 * r + 0.7152 * g + 0.0722 * b < 0.5
});
let n = b.path(PathData::from_bezpath(&bp), fill, stroke, sw);
shadowed.push((s, n.clone()));
if let Some(items) = series.get_mut(s) {
items.push(n);
}
// Legends in Wedges: the series label inside its wedge, centred two thirds of the way out.
if g.legend && g.pie_legend_in_wedges {
let label = series_label(s);
if !blank_label(&label) {
// Two thirds of the way out (of the ring, for a stacked pie).
let mid = a0 + sweep / 2.0;
let at = centre + dir(mid) * (hole + (rad - hole) * 0.62);
let t = b.label(Point::new(at.x, at.y + LABEL_SIZE * 0.35), &label, Justify::Center, true);
wedge_labels.push(if dark { recolour_text(&t, Color::WHITE) } else { t });
}
}
a0 += sweep;
}
if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) {
if stacked {
// Just above its own outline, all its lines, and drawn over the pies.
let lines = cat.split('|').count() as f64;
let at = Point::new(centre.x, centre.y - rad - LABEL_SIZE * 0.4 - (lines - 1.0) * LABEL_SIZE * 1.2);
let t = b.label(at, cat, Justify::Center, false);
on_top.push(t);
} else {
let t = b.label(Point::new(centre.x, centre.y + rad + LABEL_SIZE * 1.6), cat, Justify::Center, false);
b.out.push(t);
}
}
}
}
GraphKind::Radar => {
let centre = r.center();
let rad = (r.width().min(r.height()) / 2.0).max(1.0);
let (lo, hi, step) = value_axis(g.ticks, g.axis_min, g.axis_max, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max));
let at = |c: usize, v: f64| {
let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::TAU * c as f64 / ncat as f64;
centre + vectorcraft_geom::Vec2::new(a.cos(), a.sin()) * (rad * ((v - lo) / (hi - lo)).clamp(0.0, 1.0))
};
let mut axes = vec![];
for c in 0..ncat {
axes.push(b.line(centre, at(c, hi)));
if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) {
let p = at(c, hi) + (at(c, hi) - centre).normalize() * (LABEL_SIZE * 1.2);
axes.push(b.label(Point::new(p.x, p.y + LABEL_SIZE * 0.35), cat, Justify::Center, true));
}
}
for v in tick_values(lo, hi, step).into_iter().skip(1) {
let ring: Vec<Point> = (0..ncat).map(|c| at(c, v)).collect();
axes.push(b.path(polyline(&ring, true), Paint::None, Paint::solid(Color::rgb(0.6, 0.6, 0.6)), 0.25));
}
let ax = b.group("Axes", axes);
b.out.push(ax);
for (s, items) in series.iter_mut().enumerate() {
let pts: Vec<Point> = (0..ncat).map(|c| at(c, val(c, s))).collect();
if g.connect_points && pts.len() > 1 {
let (fill, stroke, w) = mark(s, false, default_series_paint(s), 1.0);
let n = b.path(polyline(&pts, true), fill, stroke, w);
shadowed.push((s, n.clone()));
items.push(n);
}
if g.mark_points {
for p in pts {
let paint = mark(s, true, Paint::None, 0.0);
items.push(b.marker(design(s), Rect::from_center_size(p, (4.0, 4.0)), paint));
}
}
}
}
_ => {
let scatter = g.kind == GraphKind::Scatter;
// Separate Scales: with the value axis on both sides, the series assigned to the right axis get a scale
// of their own there (axis 1); every other series, and every series without separate scales, uses axis 0.
let separate = g.separate_scales && g.value_axis == ValueAxisSide::Both && !horizontal && !scatter;
let mut on_right = vec![false; nser];
if separate {
for i in &g.right_series {
if let Some(x) = on_right.get_mut(*i) {
*x = true;
}
}
}
let axis = |s: usize| usize::from(on_right.get(s).copied().unwrap_or(false));
// Value range per axis (stacked graphs stack positives and negatives separately).
let (mut lo, mut hi) = ([0.0f64; 2], [0.0f64; 2]);
let (mut xlo, mut xhi) = (f64::MAX, f64::MIN);
for c in 0..ncat {
if !scatter {
for (a, (lo, hi)) in lo.iter_mut().zip(hi.iter_mut()).enumerate() {
// Stacked columns (or bars) stack together, and so do areas; other series count one value each.
for stack in [[GraphKind::StackedColumn, GraphKind::StackedBar], [GraphKind::Area, GraphKind::Area]] {
let members = || (0..nser).filter(|s| axis(*s) == a && stack.contains(&kind(*s)));
*hi = hi.max(members().map(|s| val(c, s).max(0.0)).sum());
*lo = lo.min(members().map(|s| val(c, s).min(0.0)).sum());
}
let single = |s: &usize| axis(*s) == a && matches!(kind(*s), GraphKind::Column | GraphKind::Bar | GraphKind::Line);
for v in (0..nser).filter(single).filter_map(|s| cell(c, s)) {
*hi = hi.max(v);
*lo = lo.min(v);
}
}
} else {
for s in (0..nser).step_by(2) {
if let (Some(y), Some(x)) = (cell(c, s), cell(c, s + 1)) {
hi[0] = hi[0].max(y);
lo[0] = lo[0].min(y);
xlo = xlo.min(x);
xhi = xhi.max(x);
}
}
}
}
// An axis with no series of its own shows the other one's scale.
let left = value_axis(g.ticks, g.axis_min, g.axis_max, lo[0], hi[0]);
let used = |a: usize| (0..nser).any(|s| axis(s) == a);
let (left, right) = if !separate || !used(1) {
(left, left)
} else {
let right = value_axis(g.right_ticks, g.right_axis_min, g.right_axis_max, lo[1], hi[1]);
if used(0) { (left, right) } else { (right, right) }
};
let scale = |a: usize| if a == 1 { right } else { left };
// Value → coordinate along value axis `a` (y for columns/lines, x for bars), and that axis' zero (or the
// end nearest it).
let vpos = |a: usize, v: f64| {
let (lo, hi, _) = scale(a);
let t = (v - lo) / (hi - lo);
if horizontal { r.x0 + t * r.width() } else { r.y1 - t * r.height() }
};
let zero = |a: usize| {
let (lo, hi, _) = scale(a);
vpos(a, 0.0f64.clamp(lo, hi))
};
let mut axes = vec![];
// Value axes with ticks and labels: along the bottom for bar graphs, else on the chosen sides (the right
// one shows the right scale).
let (on_left_side, on_right_side) =
if horizontal { (true, false) } else { (g.value_axis != ValueAxisSide::Right, g.value_axis != ValueAxisSide::Left) };
// Add Labels text per axis, kept to one short line whatever the file holds.
let affix = |s: &str| one_line(s);
let affixes = [(affix(&g.prefix), affix(&g.suffix)), (affix(&g.right_prefix), affix(&g.right_suffix))];
// Each label's tick mark, then the extra tick marks of its division (Tick Marks per division).
let value_ticks = |b: &mut Gen, axes: &mut Vec<Arc<Node>>, a: usize, v: f64, step: f64, hi: f64| {
let (len, marks) = if a == 1 { (g.right_tick_length, g.right_tick_marks) } else { (g.tick_length, g.tick_marks) };
let n = marks.clamp(1, MAX_TICK_MARKS);
for k in 0..n {
let w = v + step * k as f64 / n as f64;
if k > 0 && w >= hi - step * 1e-9 {
break;
}
let q = vpos(a, w);
// A full-length tick on the zero line, or repeating the left side's on one shared scale, adds nothing.
let repeat = a == 1 && on_left_side && g.tick_length == TickLength::Full && right == left;
let len = if len == TickLength::Full && ((q - zero(a)).abs() < 1e-9 || repeat) { TickLength::None } else { len };
let ends = match (len, horizontal, a == 1) {
(TickLength::None, ..) => None,
(TickLength::Short, true, _) => Some((Point::new(q, r.y1), Point::new(q, r.y1 + 4.0))),
(TickLength::Short, false, false) => Some((Point::new(r.x0 - 4.0, q), Point::new(r.x0, q))),
(TickLength::Short, false, true) => Some((Point::new(r.x1, q), Point::new(r.x1 + 4.0, q))),
(TickLength::Full, true, _) => Some((Point::new(q, r.y0), Point::new(q, r.y1))),
(TickLength::Full, false, _) => Some((Point::new(r.x0, q), Point::new(r.x1, q))),
};
if let Some((p0, p1)) = ends {
axes.push(b.line(p0, p1));
}
if k == 0 {
// The label goes right after its own tick mark.
let (prefix, suffix) = affixes.get(a).map_or(("", ""), |(p, s)| (p.as_str(), s.as_str()));
let label = format!("{prefix}{}{suffix}", fmt_value(v));
let t = if horizontal {
b.text(Point::new(q, r.y1 + 4.0 + LABEL_SIZE * 1.1), &label, Justify::Center)
} else if a == 1 {
b.text(Point::new(r.x1 + 6.0, q + LABEL_SIZE * 0.35), &label, Justify::Left)
} else {
b.text(Point::new(r.x0 - 6.0, q + LABEL_SIZE * 0.35), &label, Justify::Right)
};
axes.push(t);
}
}
};
if on_left_side {
let (lo, hi, step) = left;
for v in tick_values(lo, hi, step) {
value_ticks(&mut b, &mut axes, 0, v, step, hi);
}
}
if on_right_side {
let (lo, hi, step) = right;
for v in tick_values(lo, hi, step) {
value_ticks(&mut b, &mut axes, 1, v, step, hi);
}
}
// The legend starts after the value labels right of the plot: the right axis' (14 points on), or a bar
// graph's last bottom label running past the plot (6 points on; a short number stays within the gap).
for t in &axes {
if matches!(t.kind, NodeKind::Text(_))
&& let Some(bb) = t.geometric_bounds()
&& bb.x1 > r.x1
{
right_edge = right_edge.max(if bb.x0 > r.x1 { bb.x1 } else { bb.x1 - 8.0 });
}
}
if horizontal {
axes.push(b.line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)));
axes.push(b.line(Point::new(zero(0), r.y0), Point::new(zero(0), r.y1)));
} else {
if on_left_side {
axes.push(b.line(Point::new(r.x0, r.y0), Point::new(r.x0, r.y1)));
}
if on_right_side {
axes.push(b.line(Point::new(r.x1, r.y0), Point::new(r.x1, r.y1)));
}
axes.push(b.line(Point::new(r.x0, zero(0)), Point::new(r.x1, zero(0))));
// The right scale's zero, where its columns start, when it isn't the left one's.
if (zero(1) - zero(0)).abs() > 1e-9 {
axes.push(b.line(Point::new(r.x0, zero(1)), Point::new(r.x1, zero(1))));
}
}
// Category positions.
let span = if horizontal { r.height() } else { r.width() };
let cat_w = span / ncat as f64;
let cat_start = |c: usize| if horizontal { r.y0 + c as f64 * cat_w } else { r.x0 + c as f64 * cat_w };
// Area bands always span the plot; lines only with Edge-to-Edge Lines. Category labels sit where every
// series puts its points: at the edges when all of them span the plot, else at the category centres.
let spans = |k: GraphKind| k == GraphKind::Area || (k == GraphKind::Line && g.edge_to_edge);
let mid = |c: usize, edges: bool| {
if edges && ncat > 1 { r.x0 + r.width() * c as f64 / (ncat - 1) as f64 } else { cat_start(c) + cat_w / 2.0 }
};
let label_edges = kinds.iter().all(|k| spans(*k));
let cat_mid = |c: usize| mid(c, label_edges);
if !scatter {
for c in 0..ncat {
if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) {
let t = if horizontal {
b.label(Point::new(r.x0 - 6.0, cat_mid(c) + LABEL_SIZE * 0.35), cat, Justify::Right, true)
} else {
b.label(Point::new(cat_mid(c), r.y1 + LABEL_SIZE * 1.4), cat, Justify::Center, false)
};
axes.push(t);
}
}
// Category tick marks (none by default): at the labels, or between them (at the category edges),
// with the extra tick marks of each division between those.
let len = g.category_tick_length;
if len != TickLength::None {
let shown = ncat.min(MAX_CATEGORY_TICKS);
let base: Vec<f64> = match (g.ticks_between_labels, label_edges) {
// Labels at the plot's edges: halfway between neighbouring labels.
(true, true) => (1..shown).map(|c| (cat_mid(c - 1) + cat_mid(c)) / 2.0).collect(),
(true, false) => (0..=shown).map(cat_start).collect(),
(false, _) => (0..shown).map(cat_mid).collect(),
};
// Fewer per division on a long axis, so the cap still ticks all of it.
let n = g.category_tick_marks.clamp(1, MAX_TICK_MARKS).min(MAX_CATEGORY_TICKS / base.len().max(1)).max(1);
let at = base.iter().enumerate().flat_map(|(i, q)| {
let next = base.get(i + 1).copied();
(0..n).filter_map(move |k| if k == 0 { Some(*q) } else { next.map(|x| q + (x - q) * k as f64 / n as f64) })
});
// A full-length tick on a drawn axis line adds nothing.
let on_axis_line = |q: f64| {
if horizontal {
(q - r.y1).abs() < 1e-9
} else {
((q - r.x0).abs() < 1e-9 && on_left_side) || ((q - r.x1).abs() < 1e-9 && on_right_side)
}
};
for q in at.take(MAX_CATEGORY_TICKS) {
if len == TickLength::Full && on_axis_line(q) {
continue;
}
let (p0, p1) = match (len, horizontal) {
(TickLength::Full, false) => (Point::new(q, r.y0), Point::new(q, r.y1)),
(TickLength::Full, true) => (Point::new(r.x0, q), Point::new(r.x1, q)),
(_, false) => (Point::new(q, r.y1), Point::new(q, r.y1 + 4.0)),
(_, true) => (Point::new(r.x0 - 4.0, q), Point::new(r.x0, q)),
};
axes.push(b.line(p0, p1));
}
}
} else {
// Scatter: a horizontal value axis too.
let (xl, xh, xs) = nice_axis(if xlo == f64::MAX { 0.0 } else { xlo.min(0.0) }, if xhi == f64::MIN { 1.0 } else { xhi }, g.ticks);
let mut x = xl;
while x <= xh + xs * 1e-6 {
let q = r.x0 + (x - xl) / (xh - xl) * r.width();
axes.push(b.line(Point::new(q, r.y1), Point::new(q, r.y1 + 4.0)));
axes.push(b.text(Point::new(q, r.y1 + 4.0 + LABEL_SIZE * 1.1), &fmt_value(x), Justify::Center));
x += xs;
}
for (si, s) in (0..nser).step_by(2).enumerate() {
let pts: Vec<Option<Point>> = (0..ncat)
.map(|c| match (cell(c, s), cell(c, s + 1)) {
(Some(y), Some(x)) => Some(Point::new(r.x0 + (x - xl) / (xh - xl) * r.width(), vpos(0, y))),
_ => None,
})
.collect();
if g.connect_points {
for run in runs(&pts) {
let (fill, stroke, w) = mark(si, false, default_series_paint(si), 1.0);
let n = b.path(polyline(&run, false), fill, stroke, w);
shadowed.push((si, n.clone()));
series[si].push(n);
}
}
if g.mark_points {
for p in pts.iter().flatten() {
let paint = mark(si, true, Paint::None, 0.0);
let n = b.marker(design(si), Rect::from_center_size(*p, (5.0, 5.0)), paint);
series[si].push(n);
}
}
}
}
let ax = b.group("Axes", axes);
b.out.push(ax);
// Columns (or bars) of one category side by side; stacked columns take one slot of that cluster, or, with
// no plain columns, a width of their own.
let columns: Vec<usize> = (0..nser).filter(|s| matches!(kind(*s), GraphKind::Column | GraphKind::Bar)).collect();
let stacks: Vec<usize> = (0..nser).filter(|s| matches!(kind(*s), GraphKind::StackedColumn | GraphKind::StackedBar)).collect();
// One stack per value axis that has stacked series, each in a slot of its own.
let stack_axes: Vec<usize> = (0..2).filter(|a| stacks.iter().any(|s| axis(*s) == *a)).collect();
let own_width = columns.is_empty() && stack_axes.len() < 2;
let slots = columns.len() + stack_axes.len();
let cluster = cat_w * (g.cluster_width / 100.0).clamp(0.01, 1.0);
let slot = cluster / slots.max(1) as f64;
let bar = slot * (g.column_width / 100.0).clamp(0.01, 1.0);
let bar_rect = |a: f64, w: f64, v0: f64, v1: f64| {
if horizontal { Rect::new(v0.min(v1), a, v0.max(v1), a + w) } else { Rect::new(a, v0.min(v1), a + w, v0.max(v1)) }
};
for c in 0..ncat {
let c0 = cat_start(c) + (cat_w - cluster) / 2.0;
for (i, &s) in columns.iter().enumerate() {
let Some(v) = cell(c, s) else { continue };
let a = c0 + slot * i as f64 + (slot - bar) / 2.0;
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
let n = b.path(shapes::rectangle(bar_rect(a, bar, zero(axis(s)), vpos(axis(s), v))), fill, stroke, w);
shadowed.push((s, n.clone()));
if let Some(items) = series.get_mut(s) {
items.push(n);
}
}
let place = |ax: usize| {
if own_width {
let w = (cat_w * (g.column_width / 100.0).clamp(0.01, 1.0) * (g.cluster_width / 100.0).clamp(0.01, 1.0) * 1.2).min(cat_w);
(cat_start(c) + (cat_w - w) / 2.0, w)
} else {
let i = columns.len() + stack_axes.iter().position(|a| *a == ax).unwrap_or(0);
(c0 + slot * i as f64 + (slot - bar) / 2.0, bar)
}
};
// Each axis stacks its own series.
let (mut pos, mut neg) = ([0.0f64; 2], [0.0f64; 2]);
for &s in &stacks {
let Some(v) = cell(c, s) else { continue };
let ax = axis(s);
let (a, w) = place(ax);
let Some(base) = (if v >= 0.0 { pos.get_mut(ax) } else { neg.get_mut(ax) }) else { continue };
let (v0, v1) = (vpos(ax, *base), vpos(ax, *base + v));
*base += v;
let (fill, stroke, sw) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
let n = b.path(shapes::rectangle(bar_rect(a, w, v0, v1)), fill, stroke, sw);
// A segment of 0 has no area to shade.
if v != 0.0 {
shadowed.push((s, n.clone()));
}
if let Some(items) = series.get_mut(s) {
items.push(n);
}
}
}
// Cumulative area bands, each from the previous area total on its axis up to its own.
let mut belows: [Vec<f64>; 2] = [vec![0.0; ncat], vec![0.0; ncat]];
for s in (0..nser).filter(|s| kind(*s) == GraphKind::Area) {
let a = axis(s);
let Some(below) = belows.get_mut(a) else { continue };
let above: Vec<f64> = below.iter().enumerate().map(|(c, b)| b + val(c, s)).collect();
// Mixed with series at the category centres, areas take their points there too.
let at = |c: usize, v: &[f64]| Point::new(mid(c, label_edges), vpos(a, v.get(c).copied().unwrap_or(0.0)));
let mut pts: Vec<Point> = (0..ncat).map(|c| at(c, &above)).collect();
pts.extend((0..ncat).rev().map(|c| at(c, below)));
let (fill, stroke, w) = mark(s, true, Paint::solid(Color::WHITE), 0.25);
let n = b.path(polyline(&pts, true), fill, stroke, w);
if let Some(items) = series.get_mut(s) {
items.push(n);
}
*below = above;
}
for s in (0..nser).filter(|s| kind(*s) == GraphKind::Line) {
let pts: Vec<Option<Point>> =
(0..ncat).map(|c| cell(c, s).map(|v| Point::new(mid(c, g.edge_to_edge && label_edges), vpos(axis(s), v)))).collect();
if g.connect_points {
for run in runs(&pts) {
let (fill, stroke, w) = mark(s, false, default_series_paint(s), 1.0);
let n = b.path(polyline(&run, false), fill, stroke, w);
shadowed.push((s, n.clone()));
if let Some(items) = series.get_mut(s) {
items.push(n);
}
}
}
if g.mark_points {
for p in pts.iter().flatten() {
let paint = mark(s, true, Paint::None, 0.0);
let n = b.marker(design(s), Rect::from_center_size(*p, (5.0, 5.0)), paint);
if let Some(items) = series.get_mut(s) {
items.push(n);
}
}
}
}
}
}
// Legend: a swatch + label per series, right of the plot; the swatch joins its series group.
let legend_series = if g.kind == GraphKind::Scatter { nser.div_ceil(2) } else { nser };
let mut labels = vec![];
let legend_on = g.legend && !g.series.is_empty() && !(g.kind == GraphKind::Pie && g.pie_legend_in_wedges);
if legend_on && g.legend_across_top {
// Add Legend Across Top: swatch and label after swatch and label, in rows as wide as the plot, the last row
// just above it.
let entries: Vec<(usize, String)> = (0..legend_series)
.map(|s| (s, if g.kind == GraphKind::Scatter { series_label(s * 2) } else { series_label(s) }))
.filter(|(_, l)| !blank_label(l))
.collect();
let mut rows: Vec<Vec<(usize, String, f64)>> = vec![vec![]];
let mut used = 0.0;
for (s, label) in entries {
let w = 12.0 + label_width(&label) + 12.0;
// The gap after an entry may run past the plot's edge.
if used + w - 12.0 > r.width() && rows.last().is_some_and(|row| !row.is_empty()) {
rows.push(vec![]);
used = 0.0;
}
used += w;
if let Some(row) = rows.last_mut() {
row.push((s, label, w));
}
}
let height = |row: &[(usize, String, f64)]| LABEL_SIZE * 1.8 * row.iter().map(|(_, l, _)| l.split('|').count()).max().unwrap_or(1) as f64;
// Above the plot and anything drawn over it (radar and stacked-pie labels).
let top = b.out.iter().chain(on_top.iter()).filter_map(|n| n.geometric_bounds()).map(|bb| bb.y0).fold(r.y0, f64::min);
let mut y = top - 8.0 - rows.iter().map(|row| height(row)).sum::<f64>();
for row in &rows {
let mut x = r.x0;
for (s, label, w) in row {
let marked = matches!(g.kind, GraphKind::Scatter | GraphKind::Radar) || kind(*s) == GraphKind::Line;
let paint = mark(*s, true, Paint::None, 0.0);
let swatch = b.marker(design(*s).filter(|_| marked), Rect::new(x, y, x + 8.0, y + 8.0), paint);
if let Some(items) = series.get_mut(*s) {
items.push(swatch);
}
labels.push(b.label(Point::new(x + 12.0, y + 7.5), label, Justify::Left, false));
x += w;
}
y += height(row);
}
} else if legend_on {
let x = right_edge + 14.0;
// Each row as tall as its label's lines (a `|` in a label starts a new line).
let mut y = r.y0;
for (s, items) in series.iter_mut().enumerate().take(legend_series) {
let label = if g.kind == GraphKind::Scatter { series_label(s * 2) } else { series_label(s) };
let row_y = y;
if blank_label(&label) {
y += LABEL_SIZE * 1.8;
continue;
}
y += LABEL_SIZE * 1.8 * label.split('|').count() as f64;
let y = row_y;
// Series drawn with markers show their marker design in the legend.
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));
labels.push(b.label(Point::new(x + 12.0, y + 7.5), &label, Justify::Left, false));
}
}
// Areas at the back, lines and points in front of columns; one graph type keeps the series order.
let depth = |s: usize| match kind(s) {
GraphKind::Area => 0,
GraphKind::Line => 2,
_ => 1,
};
// Add Drop Shadow: a translucent black copy of each column, bar, line and wedge, a little down and right, just
// behind the series of its layer (areas, columns, lines), so a layer in front doesn't hide the shadows of the one
// behind. Left out past SHADOW_BUDGET marks.
if !g.drop_shadow || shadowed.len() > SHADOW_BUDGET {
shadowed.clear();
}
let mut series: Vec<(usize, Vec<Arc<Node>>)> = series.into_iter().enumerate().collect();
series.sort_by_key(|(s, _)| depth(*s));
let mut layer = None;
for (s, items) in series {
if items.is_empty() {
continue;
}
if layer != Some(depth(s)) {
layer = Some(depth(s));
let copies: Vec<Arc<Node>> = shadowed.iter().filter(|(t, _)| depth(*t) == depth(s)).map(|(_, n)| shadow_of(b.d, n)).collect();
if !copies.is_empty() {
let mut grp = Node::group(b.d.alloc_id(), copies);
grp.name = Some("Drop Shadow".into());
grp.opacity = SHADOW_OPACITY;
b.out.push(Arc::new(grp));
}
}
let name = g.series.get(s).cloned().filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series {}", s + 1));
let grp = b.series(&name, s, items);
b.out.push(grp);
}
b.out.append(&mut on_top);
labels.append(&mut wedge_labels);
if !labels.is_empty() {
let grp = b.group("Legend", labels);
b.out.push(grp);
}
b.out
}
fn children_bounds(children: &[Arc<Node>]) -> Option<Rect> {
children.iter().filter_map(|c| c.geometric_bounds()).reduce(|a, b| a.union(b))
}
/// Regenerate graph `id` from `spec` (adjusting the plot rect for any move/scale since the last
/// generation, which is recorded in the spec's `placed` bounds).
fn regenerate(d: &mut Document, id: NodeId, mut spec: GraphSpec) -> Result<()> {
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
if let (Some(placed), Some(now)) = (n.graph.as_ref().and_then(|g| g.placed), n.children().and_then(|c| children_bounds(c)))
&& placed.width() > 1e-9
&& placed.height() > 1e-9
&& (placed.x0 - now.x0).abs() + (placed.y0 - now.y0).abs() + (placed.x1 - now.x1).abs() + (placed.y1 - now.y1).abs() > 1e-6
{
let a = Affine::translate(now.origin().to_vec2())
* Affine::scale_non_uniform(now.width() / placed.width(), now.height() / placed.height())
* Affine::translate(-placed.origin().to_vec2());
spec.rect = a.transform_rect_bbox(spec.rect);
}
let children = generate(d, &spec);
spec.placed = children_bounds(&children);
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
if let Some(ch) = n.children_mut() {
*ch = children;
}
n.graph = Some(Box::new(spec));
Ok(())
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "graph.create";
let kind =
str_param(p, "type").map(|t| GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))).transpose()?.unwrap_or_default();
let (x, y) = (f64_req(p, "x", C)?, f64_req(p, "y", C)?);
let (w, h) = (f64_or(p, "width", 200.0).abs().max(1.0), f64_or(p, "height", 150.0).abs().max(1.0));
let mut spec = GraphSpec { kind, rect: Rect::new(x, y, x + w, y + h), ..GraphSpec::default() };
if !apply_data(&mut spec, p) {
// Starter data so the new graph shows something (edit it with Object → Graph → Data…).
spec.series = vec!["Series 1".into(), "Series 2".into()];
spec.categories = vec!["A".into(), "B".into(), "C".into(), "D".into()];
spec.rows = vec![vec![3.0, 2.0], vec![5.0, 4.0], vec![4.0, 6.0], vec![7.0, 5.0]];
}
let parent = s.doc()?.target_parent()?;
let id = s.edit("Graph", |d, sel| {
let gid = d.alloc_id();
let mut g = Node::group(gid, vec![]);
g.name = Some(format!("{} Graph", kind.label()));
d.insert(parent, usize::MAX, g)?;
regenerate(d, gid, spec)?;
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": id.0 }))
}
/// The selected graph (or the graph containing the selection).
fn target(s: &Session, p: &Value, cmd: &str) -> Result<NodeId> {
let st = s.doc()?;
let start = id_param(p, "id").into_iter().chain(st.selection.objects.iter().copied());
for id in start {
let mut cur = Some(id);
while let Some(c) = cur {
if st.doc.node(c).is_some_and(|n| n.graph.is_some()) {
return Ok(c);
}
cur = st.doc.parent_of(c);
}
}
Err(bad(cmd, "select a graph"))
}
fn spec_of(s: &Session, id: NodeId) -> Result<GraphSpec> {
s.doc()?.doc.node(id).and_then(|n| n.graph.as_deref().cloned()).ok_or(EngineError::NoNode(id))
}
/// Where a node inside a graph's generated series art belongs: the graph, the series group and
/// the series index stored on that group.
#[derive(Clone, Copy)]
struct SeriesRef {
graph: NodeId,
group: NodeId,
index: usize,
}
/// Every node in a graph's series groups (the groups too), found in one pass over the document.
/// Axes, the legend and groups whose index is past the graph's series (the index comes from the
/// file) are left out.
fn series_members(doc: &Document) -> HashMap<NodeId, SeriesRef> {
let mut out = HashMap::new();
doc.walk(|n| {
let Some(spec) = n.graph.as_deref() else { return };
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).clamp(1, vectorcraft_doc::MAX_GRAPH_SERIES);
for group in n.children().into_iter().flatten() {
let Some(index) = group.series_index.and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count) else { continue };
let r = SeriesRef { graph: n.id, group: group.id, index };
group.walk(&mut |m| {
out.insert(m.id, r);
});
}
});
out
}
/// Group Selection on a graph series targets its constituent marks and legend swatch, not a
/// group-level appearance which the generated art does not inherit. Other ids pass through.
pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let members = series_members(doc);
if members.is_empty() {
return ids.to_vec();
}
let mut out = Vec::new();
for id in ids {
// The series' own marks: the art of a marker design inside it keeps the design's paint (painting it would
// last only until the graph is drawn again), so edit the design to change it.
match members.get(id) {
Some(r) if r.group == *id => {
if let Some(group) = doc.node(*id) {
out.extend(group.children().into_iter().flatten().filter(|m| matches!(m.kind, NodeKind::Path { .. })).map(|m| m.id));
}
}
Some(r) if doc.parent_of(*id) != Some(r.group) => {}
_ => out.push(*id),
}
}
out.sort_unstable();
out.dedup();
out
}
/// Copy the paint just applied to generated series art into that series' fill or stroke. Called
/// inside the same document edit, so the visible paint and future regeneration undo together.
/// Gradients, patterns and swatch links are kept as [`Paint`], not reduced to a solid colour.
pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bool) {
let members = series_members(doc);
if members.is_empty() {
return;
}
let captures: Vec<_> = ids
.iter()
.filter_map(|id| {
// Only a series' own marks set its paint, not the art of a marker design.
let r = members.get(id).filter(|r| doc.parent_of(*id) == Some(r.group))?;
let node = doc.node(*id)?;
let paint = node.appearance.paint_at(None, fill)?.clone();
let width = (!fill).then(|| node.appearance.stroke_width());
Some((r.graph, r.index, paint, width))
})
.collect();
for (graph, index, paint, width) in captures {
let Some(spec) = doc.node_mut(graph).and_then(|n| n.graph.as_deref_mut()) else { continue };
// `index` is below the graph's series count (`series_members`).
if spec.series_paints.len() <= index {
spec.series_paints.resize(index + 1, vectorcraft_doc::SeriesPaint::default());
}
let Some(slot) = spec.series_paints.get_mut(index) else { continue };
if fill {
slot.fill = Some(paint);
} else {
slot.stroke = Some(paint);
slot.stroke_width = width;
}
}
}
/// Graph Data › Transpose row/column: categories become series and series categories.
fn transpose(g: &mut GraphSpec) {
let cells = g.cells();
let width = cells.iter().map(Vec::len).max().unwrap_or(0);
let flipped: Vec<Vec<Option<f64>>> = (0..width).map(|s| cells.iter().map(|row| row.get(s).copied().flatten()).collect()).collect();
std::mem::swap(&mut g.series, &mut g.categories);
g.set_cells(flipped);
}
/// Graph Data › Switch x/y (scatter graphs): each series' y and x columns trade places, with their labels.
fn switch_xy(g: &mut GraphSpec) {
let mut cells = g.cells();
// Labels for every column, so each pair's labels trade places with its data.
let width = cells.iter().map(Vec::len).max().unwrap_or(0).max(g.series.len());
g.series.resize(width, String::new());
for row in &mut cells {
for pair in row.as_chunks_mut::<2>().0 {
pair.swap(0, 1);
}
}
for pair in g.series.as_chunks_mut::<2>().0 {
pair.swap(0, 1);
}
while g.series.last().is_some_and(String::is_empty) {
g.series.pop();
}
g.set_cells(cells);
}
fn set_data(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "graph.setData";
let id = target(s, p, C)?;
let mut spec = spec_of(s, id)?;
let flip = bool_or(p, "transpose", false);
let swap = bool_or(p, "switchXY", false);
if swap && spec.kind != GraphKind::Scatter {
return Err(bad(C, "Switch x/y is for scatter graphs"));
}
// Transpose and Switch x/y act after any new data in the same call.
let changed = apply_data(&mut spec, p);
if flip {
// The categories become series: at most as many as a graph has series.
if spec.cells().len() > vectorcraft_doc::MAX_GRAPH_SERIES {
return Err(bad(
C,
format!("a graph has at most {} series, so it transposes with at most that many categories", vectorcraft_doc::MAX_GRAPH_SERIES),
));
}
transpose(&mut spec);
drop_empty_rows(&mut spec);
}
if swap {
switch_xy(&mut spec);
}
if !(changed || flip || swap) {
// Blank cells come back as null, so the rows can be edited and sent back as they are.
return Ok(json!({ "csv": to_csv(&spec), "series": spec.series, "categories": spec.categories, "rows": spec.cells() }));
}
// Series assigned to the right axis that the new data no longer has are dropped, so a series added later starts
// on the left.
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0);
spec.right_series.retain(|i| *i < count);
s.edit("Graph Data", |d, _| regenerate(d, id, spec))?;
Ok(json!({ "id": id.0 }))
}
/// The series a command on graph `id` (of `count` series) applies to: `seriesIndexes`, else, with no `id` param, a
/// selection made only of this graph's series (Group Selection). Empty: the whole graph.
fn picked_series(s: &Session, p: &Value, id: NodeId, count: usize, cmd: &str) -> Result<Vec<usize>> {
Ok(match p.get("seriesIndexes") {
Some(v) => {
let a = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`seriesIndexes` is a non-empty list of series indexes"))?;
a.iter()
.map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count).ok_or_else(|| bad(cmd, format!("no series {i}"))))
.collect::<Result<_>>()?
}
None if p.get("id").is_none() => {
let st = s.doc()?;
let members = series_members(&st.doc);
let picked: Option<Vec<usize>> = st.selection.objects.iter().map(|n| members.get(n).filter(|r| r.graph == id).map(|r| r.index)).collect();
let mut out = picked.unwrap_or_default();
out.sort_unstable();
out.dedup();
out
}
None => vec![],
})
}
/// Select the series groups `indexes` of graph `id` (they are new nodes after a regeneration).
fn reselect_series(d: &Document, sel: &mut vectorcraft_doc::Selection, id: NodeId, indexes: &[u32]) {
let groups: Vec<NodeId> = d
.node(id)
.and_then(Node::children)
.into_iter()
.flatten()
.filter(|c| c.series_index.is_some_and(|i| indexes.contains(&i)))
.map(|c| c.id)
.collect();
sel.set(groups);
}
/// The document's graph design names, in order.
fn design_names(d: &Document) -> Vec<String> {
d.graph_designs.iter().map(|x| x.name.clone()).collect()
}
/// 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.graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| m == name)) {
ids.push(n.id);
}
});
for id in ids {
if let Some(spec) = d.node(id).and_then(|n| n.graph.as_deref().cloned()) {
regenerate(d, id, spec)?;
}
}
Ok(())
}
/// The designs no graph's series draw with (Select Unused).
fn unused_designs(d: &Document) -> Vec<String> {
let mut used = std::collections::HashSet::new();
d.walk(|n| {
if let Some(g) = n.graph.as_deref() {
used.extend(g.series_markers.iter().flatten().cloned());
}
});
d.graph_designs.iter().map(|x| x.name.clone()).filter(|n| !used.contains(n)).collect()
}
/// Design names from param `key`: one name or a list of names.
fn names_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Vec<String>>> {
match p.get(key) {
None => Ok(None),
Some(Value::String(n)) => Ok(Some(vec![n.clone()])),
Some(Value::Array(a)) if !a.is_empty() => a
.iter()
.map(|v| v.as_str().map(str::to_string).ok_or_else(|| bad(cmd, format!("`{key}` is a design name or a list of them"))))
.collect::<Result<_>>()
.map(Some),
Some(_) => Err(bad(cmd, format!("`{key}` is a design name or a list of them"))),
}
}
fn design(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "graph.design";
for key in ["paste", "rename", "to"] {
if p.get(key).is_some_and(|v| !v.is_string()) {
return Err(bad(C, format!("`{key}` is a design name")));
}
}
let taken = |s: &Session, name: &str| s.doc().is_ok_and(|st| st.doc.graph_designs.iter().any(|x| x.name == name));
// New Design: the selected art, named `new` or "New Design" (numbered when that's taken).
if let Some(v) = p.get("new") {
let name = match v {
Value::String(n) => GraphDesign::clean_name(n).ok_or_else(|| bad(C, "a design needs a name"))?,
Value::Bool(true) | Value::Null => super::unique_name("New Design", |n| taken(s, n)),
_ => return Err(bad(C, "`new` is a design name, or true")),
};
if taken(s, &name) {
return Err(bad(C, format!("there is already a design named `{name}`")));
}
if s.doc()?.doc.graph_designs.len() >= GraphDesign::MAX {
return Err(bad(C, format!("a document keeps at most {} graph designs", GraphDesign::MAX)));
}
let art = super::brushsym::selection_art(s, p)?.ok_or_else(|| bad(C, "select the art for the design"))?;
if art.count() > GraphDesign::MAX_NODES {
return Err(bad(C, format!("a design holds at most {} objects", GraphDesign::MAX_NODES)));
}
// A graph saved as a design is kept as the art it draws.
let art = GraphDesign::plain_art(&art);
let saved = name.clone();
s.edit("New Design", |d, _| {
let art = d.reid(&art);
d.graph_designs.push(GraphDesign { name: saved.clone(), art: Arc::new(art) });
refresh_graphs_using(d, &saved)
})?;
return Ok(json!({ "name": name }));
}
// Rename: the graphs drawing the design follow it to its new name.
if let Some(old) = str_param(p, "rename") {
if !taken(s, old) {
return Err(bad(C, format!("no design named `{old}`")));
}
let to = str_param(p, "to").and_then(GraphDesign::clean_name).ok_or_else(|| bad(C, "`to` is the design's new name"))?;
if to == old {
return Ok(json!({ "name": to }));
}
if taken(s, &to) {
return Err(bad(C, format!("there is already a design named `{to}`")));
}
let (old, new) = (old.to_string(), to.clone());
s.edit("Rename Design", |d, _| {
if let Some(x) = d.graph_designs.iter_mut().find(|x| x.name == old) {
x.name = new.clone();
}
let mut graphs = vec![];
d.walk(|n| {
if n.graph.as_deref().is_some_and(|g| g.series_markers.iter().flatten().any(|m| *m == old)) {
graphs.push(n.id);
}
});
for id in graphs {
if let Some(g) = d.node_mut(id).and_then(|n| n.graph.as_deref_mut()) {
for m in g.series_markers.iter_mut().flatten().filter(|m| **m == old) {
*m = new.clone();
}
}
}
Ok(())
})?;
return Ok(json!({ "name": to }));
}
if let Some(name) = str_param(p, "paste") {
let art = s
.doc()?
.doc
.graph_designs
.iter()
.find(|x| x.name == name)
.map(|x| x.art.clone())
.ok_or_else(|| bad(C, format!("no design named `{name}`")))?;
let parent = s.doc()?.insertion_parent();
let id = s.edit("Paste Design", |d, sel| {
let n = d.reid(&art);
let id = n.id;
d.insert(parent, usize::MAX, n)?;
sel.set([id]);
Ok(id)
})?;
return Ok(json!({ "id": id.0 }));
}
if let Some(names) = names_param(p, "delete", C)? {
if let Some(n) = names.iter().find(|n| !taken(s, n)) {
return Err(bad(C, format!("no design named `{n}`")));
}
s.edit("Delete Design", |d, _| {
d.graph_designs.retain(|x| !names.contains(&x.name));
for name in &names {
refresh_graphs_using(d, name)?;
}
Ok(())
})?;
return ok();
}
// The list, with the designs no graph draws with (Select Unused picks those).
let d = &s.doc()?.doc;
Ok(json!({ "designs": design_names(d), "unused": unused_designs(d) }))
}
fn set_marker(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "graph.marker";
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 Some(v) = p.get("design") else {
// The design the picked series (all of them with none picked) share, or null.
let all: Vec<usize> = if picked.is_empty() { (0..count).collect() } else { picked };
// A design deleted since doesn't count: those series draw the default marker.
let used: Vec<Option<&String>> =
all.iter().map(|i| spec.series_markers.get(*i).and_then(Option::as_ref).filter(|n| names.contains(n))).collect();
let shared = used.first().copied().flatten().filter(|d| used.iter().all(|x| *x == Some(*d)));
return Ok(json!({ "designs": names, "design": shared }));
};
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 the default marker")),
};
let reselect: Vec<u32> = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] };
let targets: Vec<usize> = if picked.is_empty() { (0..count).collect() } else { picked };
spec.series_markers.truncate(count);
spec.series_markers.resize(count, None);
for i in targets {
if let Some(m) = spec.series_markers.get_mut(i) {
*m = design.clone();
}
}
while spec.series_markers.last().is_some_and(Option::is_none) {
spec.series_markers.pop();
}
if marker_cost(&spec, &marker_designs(&s.doc()?.doc, &spec)) > MARKER_BUDGET {
return Err(bad(C, "the design is too big to draw at every data point of this graph"));
}
s.edit("Graph Marker", |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)?;
let mut spec = spec_of(s, id)?;
// The series to retype: `seriesIndexes`, else, with no `id`, a selection made only of this graph's series (Group
// Selection). Anything else selected, the graph itself for one, is the whole graph.
let count = spec.rows.iter().map(Vec::len).max().unwrap_or(0).min(vectorcraft_doc::MAX_GRAPH_SERIES);
// A file's list of right-axis series, kept to series the graph has, at most once each.
spec.right_series.retain(|i| *i < count);
spec.right_series.sort_unstable();
spec.right_series.dedup();
if p.get("seriesIndexes").is_some() && str_param(p, "type").is_none() && str_param(p, "valueAxis").is_none() {
return Err(bad(C, "`seriesIndexes` needs a `type` or a `valueAxis`"));
}
let picked = picked_series(s, p, id, count, C)?;
let keys = [
"type",
"seriesIndexes",
"columnWidth",
"clusterWidth",
"legend",
"markPoints",
"connectPoints",
"edgeToEdge",
"ticks",
"axisMin",
"axisMax",
"valueAxis",
"separateScales",
"rightTicks",
"rightAxisMin",
"rightAxisMax",
"tickLength",
"tickMarks",
"rightTickLength",
"rightTickMarks",
"categoryTickLength",
"categoryTickMarks",
"ticksBetweenLabels",
"prefix",
"suffix",
"rightPrefix",
"rightSuffix",
"dropShadow",
"legendAcrossTop",
"pieLegend",
"piePosition",
"pieSort",
];
if !keys.iter().any(|k| p.get(*k).is_some()) {
// With series picked, the type and value axis they share, so the dialog's OK keeps them.
let kinds: Vec<GraphKind> = picked.iter().map(|i| spec.series_kind(*i)).collect();
let kind = kinds.first().copied().filter(|k| kinds.iter().all(|x| x == k)).unwrap_or(spec.kind);
// Series on both axes give no value axis (null), so OK leaves each where it is.
let sides: Vec<bool> = picked.iter().map(|i| spec.right_series.binary_search(i).is_ok()).collect();
let value_axis = match sides.first() {
None => Some(spec.value_axis.id()),
Some(r) if sides.iter().all(|x| x == r) => Some(if *r { "right" } else { "left" }),
Some(_) => None,
};
let mut fields = json!({
"type": kind.id(), "columnWidth": spec.column_width, "clusterWidth": spec.cluster_width, "legend": spec.legend,
"markPoints": spec.mark_points, "connectPoints": spec.connect_points, "edgeToEdge": spec.edge_to_edge, "ticks": spec.ticks,
"axisMin": spec.axis_min, "axisMax": spec.axis_max, "valueAxis": value_axis, "separateScales": spec.separate_scales,
"rightTicks": spec.right_ticks, "rightAxisMin": spec.right_axis_min, "rightAxisMax": spec.right_axis_max,
"tickLength": spec.tick_length.id(), "tickMarks": spec.tick_marks.clamp(1, MAX_TICK_MARKS), "rightTickLength": spec.right_tick_length.id(),
"rightTickMarks": spec.right_tick_marks.clamp(1, MAX_TICK_MARKS), "categoryTickLength": spec.category_tick_length.id(),
"categoryTickMarks": spec.category_tick_marks.clamp(1, MAX_TICK_MARKS), "ticksBetweenLabels": spec.ticks_between_labels,
"prefix": one_line(&spec.prefix), "suffix": one_line(&spec.suffix), "rightPrefix": one_line(&spec.right_prefix),
"rightSuffix": one_line(&spec.right_suffix), "dropShadow": spec.drop_shadow, "legendAcrossTop": spec.legend_across_top,
});
// Pie graphs: Legend, Position and Sort (their Legend stands for the legend checkbox).
if let Some(o) = fields.as_object_mut().filter(|_| spec.kind == GraphKind::Pie) {
o.remove("legend");
o.insert(
"pieLegend".into(),
json!(if !spec.legend {
"none"
} else if spec.pie_legend_in_wedges {
"wedges"
} else {
"standard"
}),
);
o.insert("piePosition".into(), json!(spec.pie_position.id()));
o.insert("pieSort".into(), json!(spec.pie_sort.id()));
}
return Ok(fields);
}
// Series picked with Group Selection stay selected through the regeneration (their groups are new nodes).
let reselect: Vec<u32> = if p.get("seriesIndexes").is_none() { picked.iter().filter_map(|i| u32::try_from(*i).ok()).collect() } else { vec![] };
if let Some(t) = str_param(p, "type") {
let kind = GraphKind::parse(t).ok_or_else(|| bad(C, format!("unknown graph type `{t}`")))?;
if picked.is_empty() {
spec.kind = kind;
} else {
if !kind.combines_with(spec.kind) {
return Err(bad(C, format!("a {} series can't go in a {} graph", kind.label(), spec.kind.label())));
}
spec.series_kinds.resize(count, None);
for &i in &picked {
if let Some(k) = spec.series_kinds.get_mut(i) {
*k = (kind != spec.kind).then_some(kind);
}
}
while spec.series_kinds.last().is_some_and(Option::is_none) {
spec.series_kinds.pop();
}
}
}
if let Some(v) = str_param(p, "valueAxis") {
let side = ValueAxisSide::parse(v).ok_or_else(|| bad(C, format!("unknown value axis `{v}` (left, right or both)")))?;
if picked.is_empty() {
spec.value_axis = side;
} else {
// A series goes on one axis.
if side == ValueAxisSide::Both {
return Err(bad(C, "a series goes on the left or the right value axis"));
}
spec.right_series.retain(|i| *i < count && !picked.contains(i));
if side == ValueAxisSide::Right {
spec.right_series.extend(&picked);
spec.right_series.sort_unstable();
}
}
}
spec.separate_scales = bool_or(p, "separateScales", spec.separate_scales);
for (key, slot) in [
("tickLength", &mut spec.tick_length),
("rightTickLength", &mut spec.right_tick_length),
("categoryTickLength", &mut spec.category_tick_length),
] {
if let Some(v) = str_param(p, key) {
*slot = TickLength::parse(v).ok_or_else(|| bad(C, format!("unknown {key} `{v}` (none, short or full)")))?;
}
}
for (key, slot) in
[("tickMarks", &mut spec.tick_marks), ("rightTickMarks", &mut spec.right_tick_marks), ("categoryTickMarks", &mut spec.category_tick_marks)]
{
*slot = count_param(p, key, MAX_TICK_MARKS as u64).map_or(*slot, |n| n.max(1));
}
spec.ticks_between_labels = bool_or(p, "ticksBetweenLabels", spec.ticks_between_labels);
if let Some(v) = str_param(p, "piePosition") {
spec.pie_position =
vectorcraft_doc::PiePosition::parse(v).ok_or_else(|| bad(C, format!("unknown piePosition `{v}` (even, ratio or stacked)")))?;
}
if let Some(v) = str_param(p, "pieSort") {
spec.pie_sort = vectorcraft_doc::PieSort::parse(v).ok_or_else(|| bad(C, format!("unknown pieSort `{v}` (none, all or first)")))?;
}
for (key, slot) in
[("prefix", &mut spec.prefix), ("suffix", &mut spec.suffix), ("rightPrefix", &mut spec.right_prefix), ("rightSuffix", &mut spec.right_suffix)]
{
if let Some(v) = str_param(p, key) {
// A label, not a paragraph: one line, at most MAX_AFFIX characters.
*slot = one_line(v);
}
}
spec.right_ticks = count_param(p, "rightTicks", 100).unwrap_or(spec.right_ticks);
if let Some(v) = p.get("rightAxisMin") {
spec.right_axis_min = v.as_f64();
}
if let Some(v) = p.get("rightAxisMax") {
spec.right_axis_max = v.as_f64();
}
spec.column_width = f64_or(p, "columnWidth", spec.column_width).clamp(1.0, 1000.0);
spec.cluster_width = f64_or(p, "clusterWidth", spec.cluster_width).clamp(1.0, 100.0);
spec.legend = bool_or(p, "legend", spec.legend);
spec.drop_shadow = bool_or(p, "dropShadow", spec.drop_shadow);
spec.legend_across_top = bool_or(p, "legendAcrossTop", spec.legend_across_top);
// After `legend`, which the Graph Type dialog sends too.
if let Some(v) = str_param(p, "pieLegend") {
(spec.legend, spec.pie_legend_in_wedges) = match v.to_ascii_lowercase().as_str() {
"none" => (false, false),
"standard" => (true, false),
"wedges" => (true, true),
_ => return Err(bad(C, format!("unknown pieLegend `{v}` (none, standard or wedges)"))),
};
}
spec.mark_points = bool_or(p, "markPoints", spec.mark_points);
spec.connect_points = bool_or(p, "connectPoints", spec.connect_points);
spec.edge_to_edge = bool_or(p, "edgeToEdge", spec.edge_to_edge);
spec.ticks = count_param(p, "ticks", 100).unwrap_or(spec.ticks);
if let Some(v) = p.get("axisMin") {
spec.axis_min = v.as_f64();
}
if let Some(v) = p.get("axisMax") {
spec.axis_max = v.as_f64();
}
let label = format!("{} Graph", spec.kind.label());
s.edit("Graph Type", |d, sel| {
regenerate(d, id, spec)?;
if !reselect.is_empty() {
reselect_series(d, sel, id, &reselect);
}
if let Some(n) = d.node_mut(id)
&& n.name.as_deref().is_some_and(|nm| nm.ends_with(" Graph"))
{
n.name = Some(label);
}
Ok(())
})?;
Ok(json!({ "id": id.0 }))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use vectorcraft_color::Paint;
use vectorcraft_doc::{GraphKind, GraphSpec, NodeKind};
use crate::{NodeId, Session};
fn graph(s: &mut Session, ty: &str) -> NodeId {
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
NodeId(
s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
)
}
fn group<'a>(n: &'a vectorcraft_doc::Node, name: &str) -> &'a vectorcraft_doc::Node {
n.children().unwrap().iter().find(|c| c.name.as_deref() == Some(name)).unwrap_or_else(|| panic!("no {name}"))
}
/// The generated group stored for series `index`, not a sibling that happens to share its name.
fn series(n: &vectorcraft_doc::Node, index: u32) -> &vectorcraft_doc::Node {
n.children().unwrap().iter().find(|c| c.series_index == Some(index)).unwrap_or_else(|| panic!("no series {index}"))
}
#[test]
fn every_graph_type_generates_series_groups() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
for ty in ["column", "stackedColumn", "bar", "stackedBar", "line", "area", "scatter", "pie", "radar"] {
let id = graph(&mut s, ty);
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(n.graph.is_some(), "{ty}");
if ty != "scatter" {
assert!(!group(&n, "2024").children().unwrap().is_empty(), "{ty}");
assert!(!group(&n, "2025").children().unwrap().is_empty(), "{ty}");
}
assert!(n.geometric_bounds().unwrap().width() > 100.0, "{ty}");
}
}
#[test]
fn column_heights_follow_data_and_data_edits_regenerate_in_place() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let heights = |s: &Session| -> Vec<f64> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
// Bars only (the legend swatch is the 8 × 8 square at the end).
let g = group(&n, "2025");
let ch = g.children().unwrap();
ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap().height()).collect()
};
let h = heights(&s);
assert!((h[2] / h[0] - 3.0).abs() < 1e-6, "{h:?}");
// Move the graph, then change the data: it stays where it was moved.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap();
let x0 = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0;
let v = s.execute("graph.setData", &json!({})).unwrap();
assert!(v["csv"].as_str().unwrap().contains("Q2,5,4"));
s.execute("graph.setData", &json!({"csv": ",2024,2025\nQ1,3,2\nQ2,5,8\nQ3,-1,6"})).unwrap();
let h2 = heights(&s);
assert!((h2[1] / h2[0] - 4.0).abs() < 1e-6, "{h2:?}");
assert!((s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 - x0).abs() < 1.0);
// Type change keeps the data; undo restores the columns.
s.execute("graph.setType", &json!({"type": "pie"})).unwrap();
assert_eq!(s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().kind, vectorcraft_doc::GraphKind::Pie);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], json!("column"));
// Selecting a bar inside still targets the graph.
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let bar = group(&n, "2024").children().unwrap()[0].id;
s.execute("select.set", &json!({"ids": [bar.0]})).unwrap();
assert!(s.execute("graph.setData", &json!({})).is_ok());
assert!(matches!(n.kind, NodeKind::Group { .. }));
}
/// The x of every marker of series `index`, left to right.
fn marker_xs(s: &Session, id: NodeId, index: u32) -> Vec<f64> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let mut xs: Vec<f64> = series(&n, index)
.children()
.unwrap()
.iter()
.map(|c| c.geometric_bounds().unwrap())
.filter(|b| (b.width() - 5.0).abs() < 1e-6)
.map(|b| b.center().x)
.collect();
xs.sort_by(f64::total_cmp);
xs
}
#[test]
fn edge_to_edge_lines_off_puts_line_points_at_category_centres() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
// 300 pt wide, three categories: centres at 150, 250, 350 (off by default); edges at 100 and 400.
let id = graph(&mut s, "line");
assert_eq!(marker_xs(&s, id, 0), vec![150.0, 250.0, 350.0]);
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["edgeToEdge"], json!(false));
s.execute("graph.setType", &json!({"edgeToEdge": true})).unwrap();
assert_eq!(marker_xs(&s, id, 0), vec![100.0, 250.0, 400.0]);
// A graph saved before the option existed keeps its edge-to-edge lines.
let old: GraphSpec = serde_json::from_value(json!({"kind": "line"})).unwrap();
assert!(old.edge_to_edge);
}
#[test]
fn radar_graphs_follow_mark_and_connect_data_points() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "radar");
let parts = |s: &Session| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap().len();
// The ring, three markers and the legend swatch.
assert_eq!(parts(&s), 5);
s.execute("graph.setType", &json!({"markPoints": false})).unwrap();
assert_eq!(parts(&s), 2);
s.execute("graph.setType", &json!({"markPoints": true, "connectPoints": false})).unwrap();
assert_eq!(parts(&s), 4);
}
#[test]
fn an_overridden_value_axis_is_used_exactly() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r = s.execute("graph.create", &json!({"type": "column", "x": 20, "y": 20, "width": 170, "height": 74, "series": ["a"], "categories": ["x", "y"], "rows": [[4839], [10488]]})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let tallest = |s: &Session| -> f64 {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let bars = series(&n, 0).children().unwrap();
bars.iter().map(|c| c.geometric_bounds().unwrap().height()).fold(0.0, f64::max)
};
// Calculated: a nice axis (0 to 12500 here), so the 10488 column falls short of 10488 / 12000 of the plot.
assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0);
s.execute("graph.setType", &json!({"axisMin": 0, "axisMax": 12000})).unwrap();
assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() < 1e-6, "{}", tallest(&s));
let o = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!((o["axisMin"].as_f64(), o["axisMax"].as_f64()), (Some(0.0), Some(12000.0)));
// One bound only keeps the calculated (nice) axis around it.
s.execute("graph.setType", &json!({"axisMax": null})).unwrap();
assert!((tallest(&s) - 74.0 * 10488.0 / 12000.0).abs() > 1.0);
}
#[test]
fn tick_values_end_on_the_maximum_and_never_run_away() {
// Counted, not accumulated: an offset range keeps its last tick.
let t = super::tick_values(1e10, 1e10 + 1.0, 0.01);
assert_eq!(t.len(), 101);
assert!((t[100] - (1e10 + 1.0)).abs() < 1e-3);
// A range too wide for f64, or a step too small to move, gives no ticks or a capped list.
assert!(super::tick_values(-1.7e308, 1.7e308, f64::INFINITY).is_empty());
assert!(super::tick_values(1e20, 1e20 + 16384.0, 163.84).len() <= 1001);
assert!(super::tick_values(0.0, 1.0, 0.0).is_empty());
// An override whose span overflows falls back to the calculated axis instead of hanging.
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
graph(&mut s, "column");
s.execute("graph.setType", &json!({"axisMin": -1.7e308, "axisMax": 1.7e308})).unwrap();
}
#[test]
fn group_selection_paint_becomes_the_series_colour_and_survives_regeneration() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for ty in GraphKind::ALL {
let id = graph(&mut s, ty.id());
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#ff0000"})).unwrap();
let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone();
let restored: GraphSpec = serde_json::from_value(serde_json::to_value(&spec).unwrap()).unwrap();
assert_eq!(spec, restored);
let red = Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0));
assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}");
let selected_group = group(s.doc().unwrap().doc.node(id).unwrap(), "2024");
assert!(selected_group.children().unwrap().iter().all(|n| n.appearance.fill_paint() == red), "{ty:?}");
s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap();
s.execute("graph.setType", &json!({"type": "line"})).unwrap();
let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap();
assert_eq!(spec.series_paints.first().and_then(|p| p.fill.clone()), Some(red.clone()), "{ty:?}");
let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap();
assert!(series.iter().any(|n| n.appearance.fill_paint() == red), "{ty:?}");
}
}
#[test]
fn painting_a_series_is_one_undoable_edit() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
s.execute("paint.setFill", &json!({"ids": [series_id.0], "color": "#e8573f"})).unwrap();
assert_eq!(
s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(),
"#e8573f"
);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints.is_empty());
assert_eq!(
group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].appearance.fill_paint().color(),
Some(super::default_series_color(0))
);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(
s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(),
"#e8573f"
);
}
#[test]
fn painting_one_bar_sets_the_colour_used_by_the_whole_series_on_rebuild() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id;
s.execute("paint.setFill", &json!({"ids": [bar.0], "color": "#12ab34"})).unwrap();
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let node = s.doc().unwrap().doc.node(id).unwrap();
let green = vectorcraft_color::Color::rgb(18.0 / 255.0, 171.0 / 255.0, 52.0 / 255.0);
assert_eq!(node.graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color), Some(green));
assert!(group(node, "2024").children().unwrap().iter().all(|n| n.appearance.fill_paint().color() == Some(green)));
}
/// A series index read from a file past the graph's series is not a series: painting its art
/// paints the art alone and stores nothing (no allocation sized by the file).
#[test]
fn a_series_index_past_the_graphs_series_is_ignored() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
s.edit("Junk", |d, _| {
d.node_mut(series_id).unwrap().series_index = Some(u32::MAX);
Ok(())
})
.unwrap();
let bar = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0].id;
s.execute("paint.setFill", &json!({"ids": [bar.0, series_id.0], "color": "#12ab34"})).unwrap();
let node = s.doc().unwrap().doc.node(id).unwrap();
assert!(node.graph.as_ref().unwrap().series_paints.is_empty());
assert_eq!(group(node, "2024").children().unwrap()[0].appearance.fill_paint().color().map(|c| c.to_hex()), Some("#12ab34".into()));
}
#[test]
fn painting_a_line_series_stroke_sets_its_persistent_colour() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "line");
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#3751c8"})).unwrap();
let blue = vectorcraft_color::Color::rgb(55.0 / 255.0, 81.0 / 255.0, 200.0 / 255.0);
assert_eq!(
s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].stroke.as_ref().and_then(Paint::color),
Some(blue)
);
assert!(s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints[0].fill.is_none());
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
assert!(
group(s.doc().unwrap().doc.node(id).unwrap(), "2024")
.children()
.unwrap()
.iter()
.any(|n| n.appearance.stroke_paint().color() == Some(blue))
);
}
#[test]
fn series_colours_survive_native_save_open_and_export() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let first = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
let second = group(s.doc().unwrap().doc.node(id).unwrap(), "2025").id;
s.execute("paint.setFill", &json!({"ids": [first.0], "color": "#f00"})).unwrap();
s.execute("paint.setFill", &json!({"ids": [second.0], "color": "#008080"})).unwrap();
let dir = vectorcraft_testkit::temp_dir("graph-palette");
let path = dir.join("palette.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let paints = &s.doc().unwrap().doc.node(id).unwrap().graph.as_ref().unwrap().series_paints;
assert_eq!(paints.iter().map(|p| p.fill.as_ref().and_then(Paint::color).unwrap().to_hex()).collect::<Vec<_>>(), vec!["#ff0000", "#008080"]);
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[3, 4, 5]]})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap();
let swatch = group(n, "2025").children().unwrap().last().unwrap();
assert_eq!(swatch.appearance.fill_paint().color().unwrap().to_hex(), "#008080");
let third = group(n, "Series 3").children().unwrap().first().unwrap();
assert_eq!(third.appearance.fill_paint().color().unwrap().to_hex(), "#cccccc");
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
let svg = svg["text"].as_str().unwrap();
assert!(svg.contains("#ff0000") && svg.contains("#008080"));
let png = dir.join("palette.png");
s.execute("document.export", &json!({"format": "png", "path": png})).unwrap();
let pixels = image::open(&png).unwrap().to_rgba8();
assert!(pixels.pixels().any(|p| p.0 == [255, 0, 0, 255]));
assert!(pixels.pixels().any(|p| p.0 == [0, 128, 128, 255]));
let old: GraphSpec = serde_json::from_value(json!({"rows": [[1.0]]})).unwrap();
assert!(old.series_paints.is_empty(), "older files keep the greyscale default");
}
#[test]
fn a_column_stroke_stays_a_stroke_and_a_marker_fill_does_not_recolour_its_line() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
s.execute("paint.setStroke", &json!({"ids": [series_id.0], "color": "#0000ff"})).unwrap();
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone();
let blue = Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0));
assert_eq!(spec.series_paints[0].stroke, Some(blue.clone()));
assert!(spec.series_paints[0].fill.is_none());
let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0];
assert_eq!(bar.appearance.fill_paint().color(), Some(super::default_series_color(0)));
assert_eq!(bar.appearance.stroke_paint(), blue);
let id = graph(&mut s, "line");
let marker = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[1].id;
s.execute("paint.setFill", &json!({"ids": [marker.0], "color": "#ff0000"})).unwrap();
s.execute("graph.setData", &json!({"rows": [[1, 2], [3, 4]]})).unwrap();
let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone();
assert!(spec.series_paints[0].stroke.is_none());
let series = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap();
assert_eq!(series[0].appearance.stroke_paint().color(), Some(super::default_series_color(0)));
assert_eq!(series[1].appearance.fill_paint().color(), Some(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)));
}
#[test]
fn a_gradient_fill_survives_regeneration() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
let series_id = group(s.doc().unwrap().doc.node(id).unwrap(), "2024").id;
s.execute(
"paint.setFill",
&json!({"ids": [series_id.0], "gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}}),
)
.unwrap();
s.execute("graph.setData", &json!({"rows": [[2, 3], [4, 5]]})).unwrap();
let spec = s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone();
assert!(matches!(spec.series_paints[0].fill, Some(Paint::Gradient(_))));
let bar = &group(s.doc().unwrap().doc.node(id).unwrap(), "2024").children().unwrap()[0];
assert!(matches!(bar.appearance.fill_paint(), Paint::Gradient(_)));
}
#[test]
fn a_series_named_legend_keeps_its_own_index_and_an_empty_series_is_omitted() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let csv = ",Legend,Axes\nQ1,3,2\nQ2,5,4";
let id = NodeId(s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "csv": csv})).unwrap()["id"].as_u64().unwrap());
let legend_id = {
let n = s.doc().unwrap().doc.node(id).unwrap();
let legend = series(n, 0);
assert_eq!(legend.name.as_deref(), Some("Legend"));
assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Legend") && c.series_index.is_none()));
assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Axes") && c.series_index.is_none()));
legend.id
};
s.execute("paint.setFill", &json!({"ids": [legend_id.0], "color": "#ff0000"})).unwrap();
s.execute("graph.setData", &json!({"csv": csv})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap();
assert_eq!(n.graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#ff0000");
assert!(n.graph.as_ref().unwrap().series_paints.get(1).is_none_or(|p| p.fill.is_none()));
assert_eq!(series(n, 0).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
assert_eq!(series(n, 1).name.as_deref(), Some("Axes"));
assert_ne!(series(n, 1).children().unwrap()[0].appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
let id = graph(&mut s, "pie");
s.execute("graph.setType", &json!({"id": id.0, "legend": false})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "csv": ",Keep,Drop\nQ1,4,0\nQ2,2,0"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap();
assert!(n.children().unwrap().iter().any(|c| c.name.as_deref() == Some("Keep")));
assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop")));
let keep = group(n, "Keep").id;
s.execute("paint.setFill", &json!({"ids": [keep.0], "color": "#00ff00"})).unwrap();
s.execute("graph.setData", &json!({"id": id.0, "rows": [[8, 0], [1, 0]]})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap();
assert_eq!(group(n, "Keep").series_index, Some(0));
assert_eq!(n.graph.as_ref().unwrap().series_paints[0].fill.as_ref().and_then(Paint::color).unwrap().to_hex(), "#00ff00");
assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop")));
}
#[test]
fn csv_parsing() {
let (s, c, r) = super::parse_csv("\t2024\t2025\nQ1\t1\t2\nQ2\t3\t4\n");
assert_eq!(s, vec!["2024", "2025"]);
assert_eq!(c, vec!["Q1", "Q2"]);
assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]);
let (s, c, r) = super::parse_csv("1,2\n3,4");
assert!(s.is_empty() && c.is_empty());
assert_eq!(r.len(), 2);
assert_eq!(super::nice_axis(0.0, 7.0, 0), (0.0, 8.0, 2.0));
}
#[test]
fn empty_cells_are_blank_and_quoted_numbers_are_labels() {
// Quoted years label the rows; empty and non-numeric cells are blank values, not zeros.
let (s, c, r) = super::parse_csv(",a,b\n\"2023\",1,\n\"2024\",,x\n\"2025\",3,4");
assert_eq!(s, vec!["a", "b"]);
assert_eq!(c, vec!["2023", "2024", "2025"]);
assert_eq!(r, vec![vec![Some(1.0), None], vec![None, None], vec![Some(3.0), Some(4.0)]]);
// Back to CSV: blanks stay empty and numeric labels keep their quotes, so it reads back the same.
let mut g = GraphSpec { series: s, categories: c, ..GraphSpec::default() };
g.set_cells(r);
assert_eq!(g.blanks, vec![[0, 1], [1, 0], [1, 1]]);
let csv = super::to_csv(&g);
assert!(csv.contains("\"2024\",,"), "{csv}");
let (s2, c2, r2) = super::parse_csv(&csv);
assert_eq!((s2, c2, r2), (g.series.clone(), g.categories.clone(), g.cells()));
}
#[test]
fn a_row_with_an_empty_label_keeps_its_values_in_place() {
// The label column is the first column of every row once any row has a label.
let (_, c, r) = super::parse_csv(",a,b\nQ1,1,2\n,3,4");
assert_eq!(c, vec!["Q1", ""]);
assert_eq!(r, vec![vec![Some(1.0), Some(2.0)], vec![Some(3.0), Some(4.0)]]);
// And a data window round trip keeps it (the empty label is written as "").
let mut g = GraphSpec { series: vec!["a".into(), "b".into()], categories: c, ..GraphSpec::default() };
g.set_cells(r);
assert_eq!(super::parse_csv(&super::to_csv(&g)).2, g.cells());
// Without series or labels, a first row starting with a blank is still data after a round trip.
let mut g = GraphSpec::default();
g.set_cells(vec![vec![None, Some(2.0)], vec![Some(3.0), Some(4.0)]]);
let (s, c, r) = super::parse_csv(&super::to_csv(&g));
assert!(s.is_empty() && c.is_empty());
assert_eq!(r, g.cells());
// A label with spaces around a number stays a label, spaces and all.
let mut g = GraphSpec { categories: vec!["2024 ".into()], ..GraphSpec::default() };
g.set_cells(vec![vec![Some(1.0)]]);
assert_eq!(super::parse_csv(&super::to_csv(&g)).1, vec!["2024 "]);
}
#[test]
fn blanks_read_back_as_null_and_year_header_rows_still_read_as_headers() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r =
s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": [[1, null], [2, 3]]})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
assert_eq!(s.execute("graph.setData", &json!({"id": id.0})).unwrap()["rows"], json!([[1.0, null], [2.0, 3.0]]));
// A label followed by empty cells is a header with no series names, as before blanks existed.
let (s, c, r) = super::parse_csv("Year,,\n2023,1,2");
assert!(s.is_empty());
assert_eq!(c, Vec::<String>::new());
assert_eq!(r, vec![vec![Some(2023.0), Some(1.0), Some(2.0)]]);
}
#[test]
fn a_huge_ragged_grid_is_capped() {
let g = GraphSpec { rows: vec![vec![0.0; vectorcraft_doc::MAX_GRAPH_SERIES + 10]; 3], ..GraphSpec::default() };
assert!(g.cells().iter().all(|r| r.len() == vectorcraft_doc::MAX_GRAPH_SERIES));
}
#[test]
fn a_label_only_row_stays_with_blank_values() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r =
s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "csv": ",a\nQ1,1\nQ2\nQ3,3"})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let spec = s.doc().unwrap().doc.node(id).unwrap().graph.clone().unwrap();
assert_eq!(spec.categories, vec!["Q1", "Q2", "Q3"]);
assert_eq!(spec.cells(), vec![vec![Some(1.0)], vec![None], vec![Some(3.0)]]);
}
#[test]
fn blank_cells_leave_out_their_columns_and_break_lines() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let rows = json!([[3, 1], [null, 2], [5, null], [4, 4]]);
let r = s
.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 400, "height": 200, "series": ["a", "b"], "rows": rows}))
.unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let count = |s: &Session, index: u32| series(&s.doc().unwrap().doc.node(id).unwrap().clone(), index).children().unwrap().len();
// Three columns each (one blank), plus the legend swatch.
assert_eq!((count(&s, 0), count(&s, 1)), (4, 4));
s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap();
assert_eq!((count(&s, 0), count(&s, 1)), (4, 4));
// Line: series a is 3, blank, 5, 4. The lone first point has nothing to connect to, so one line (5 to 4), three
// markers and the legend swatch.
s.execute("graph.setType", &json!({"type": "line"})).unwrap();
assert_eq!(count(&s, 0), 1 + 3 + 1);
// Series b is 1, 2, blank, 4: one line (1 to 2), three markers and the swatch.
assert_eq!(count(&s, 1), 1 + 3 + 1);
// A data window round trip keeps the blanks.
let csv = s.execute("graph.setData", &json!({})).unwrap()["csv"].as_str().unwrap().to_string();
assert_eq!(csv.lines().nth(2), Some(",2"), "{csv}");
let spec = s.doc().unwrap().doc.node(id).unwrap().graph.clone().unwrap();
assert_eq!(spec.cells()[1], vec![None, Some(2.0)]);
// In the file a blank is a 0 placeholder plus its place in `blanks`, so a version without blanks reads
// numbers (as zeros); a graph without blanks writes no `blanks` at all.
let v = serde_json::to_value(&spec).unwrap();
assert_eq!(v["rows"][1], json!([0.0, 2.0]));
assert_eq!(v["blanks"], json!([[1, 0], [2, 1]]));
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("blanks").is_none());
}
fn spec(s: &Session, id: NodeId) -> GraphSpec {
s.doc().unwrap().doc.node(id).unwrap().graph.as_deref().unwrap().clone()
}
/// Bounds of a series' marks, without its legend swatch (the last child).
fn marks(s: &Session, id: NodeId, index: u32) -> Vec<vectorcraft_geom::Rect> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let ch = series(&n, index).children().unwrap().to_vec();
ch[..ch.len() - 1].iter().map(|c| c.geometric_bounds().unwrap()).collect()
}
#[test]
fn a_series_given_its_own_type_is_drawn_as_that_type_in_front_of_the_columns() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let one_column = marks(&s, id, 0)[0].width();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
let g = spec(&s, id);
assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Column, GraphKind::Column, GraphKind::Line));
// The columns left take the whole cluster; the line runs through the category centres, one marker a value.
let cols = marks(&s, id, 0);
assert_eq!(cols.len(), 3);
assert!((cols[0].width() - 2.0 * one_column).abs() < 1e-6, "{} vs {one_column}", cols[0].width());
let line = marks(&s, id, 1);
assert_eq!(line.len(), 4, "a line and three markers");
assert!((line[1].center().x - cols[0].center().x).abs() < 1e-6);
// The line series is drawn after (in front of) the column series.
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let order: Vec<u32> = n.children().unwrap().iter().filter_map(|c| c.series_index).collect();
assert_eq!(order, [0, 1]);
s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "line"})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let order: Vec<u32> = n.children().unwrap().iter().filter_map(|c| c.series_index).collect();
assert_eq!(order, [1, 0]);
// Back to the graph's own type: nothing is stored for that series.
assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line)]);
// One undo per change.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).series_kinds, [Some(GraphKind::Line), Some(GraphKind::Line)]);
}
#[test]
fn group_selecting_a_series_retypes_only_that_series() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let grp = series(&n, 1).id;
s.execute("select.set", &json!({"ids": [grp.0]})).unwrap();
// The dialog opens on the series' type, and its OK (every option sent back) keeps it.
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column");
s.execute("graph.setType", &json!({"type": "area"})).unwrap();
let g = spec(&s, id);
assert_eq!((g.kind, g.series_kind(1)), (GraphKind::Column, GraphKind::Area));
let mut all = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!(all["type"], "area");
all["ticks"] = json!(4);
s.execute("graph.setType", &all).unwrap();
assert_eq!(spec(&s, id).series_kind(1), GraphKind::Area);
// The series stays selected through each change.
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id;
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]);
// A series and something outside it selected, or an explicit `id`: the whole graph.
s.execute("select.set", &json!({"ids": [grp.0, id.0]})).unwrap();
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["type"], "column");
s.execute("select.set", &json!({"ids": [grp.0]})).unwrap();
assert_eq!(s.execute("graph.setType", &json!({"id": id.0})).unwrap()["type"], "column");
// The whole graph selected: the graph's type changes, the series keeps its own.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.setType", &json!({"type": "line"})).unwrap();
let g = spec(&s, id);
assert_eq!((g.kind, g.series_kind(0), g.series_kind(1)), (GraphKind::Line, GraphKind::Line, GraphKind::Area));
}
#[test]
fn types_that_do_not_share_a_value_axis_are_refused_or_ignored() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
for ty in ["pie", "radar", "scatter", "bar"] {
assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": ty})).is_err(), "{ty}");
}
for bad in [json!([2]), json!([]), json!([-1]), json!(["x"]), json!([1e30]), json!({}), json!(1)] {
assert!(s.execute("graph.setType", &json!({"seriesIndexes": bad, "type": "line"})).is_err(), "{bad}");
}
assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1]})).is_err(), "no type");
assert!(spec(&s, id).series_kinds.is_empty());
// A series type the graph's new type can't take is drawn as the graph's type, and kept for when it can.
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
s.execute("graph.setType", &json!({"type": "pie"})).unwrap();
let g = spec(&s, id);
assert_eq!(g.series_kind(1), GraphKind::Pie);
s.execute("graph.setType", &json!({"type": "column"})).unwrap();
assert_eq!(spec(&s, id).series_kind(1), GraphKind::Line);
}
#[test]
fn stacked_columns_take_one_slot_and_the_value_axis_covers_their_total() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",a,b,c\nQ1,10,20,30\nQ2,10,20,30";
let id = NodeId(
s.execute("graph.create", &json!({"type": "stackedColumn", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line"})).unwrap();
let (a, b) = (marks(&s, id, 0), marks(&s, id, 1));
assert!((a[0].x0 - b[0].x0).abs() < 1e-6 && (a[0].y0 - b[0].y1).abs() < 1e-6, "b sits on a");
// a + b stack to 30, the line's highest value is 30: the axis ends at 30 and the stack reaches the top.
assert!((b[0].y0 - 0.0).abs() < 1e-6, "{:?}", b[0]);
// With a plain column too, the stack is the cluster's last slot, beside it.
s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "column"})).unwrap();
let (a, b) = (marks(&s, id, 0), marks(&s, id, 1));
assert!((a[0].width() - b[0].width()).abs() < 1e-6 && b[0].x0 > a[0].x1, "{:?} {:?}", a[0], b[0]);
}
#[test]
fn areas_mixed_with_columns_put_their_points_at_the_category_centres() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap();
let (area, cols) = (marks(&s, id, 0)[0], marks(&s, id, 1));
assert!((area.x0 - cols[0].center().x).abs() < 1e-6 && (area.x1 - cols[2].center().x).abs() < 1e-6, "{area:?}");
// The area is drawn behind the columns.
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let order: Vec<u32> = n.children().unwrap().iter().filter_map(|c| c.series_index).collect();
assert_eq!(order, [0, 1]);
// An area graph alone still spans the plot.
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "area"})).unwrap();
assert!((marks(&s, id, 0)[0].x0 - 100.0).abs() < 1e-6);
}
#[test]
fn bar_graphs_take_stacked_bar_series() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",a,b,c\nQ1,10,20,-5\nQ2,10,20,-5";
let id = NodeId(
s.execute("graph.create", &json!({"type": "bar", "x": 0, "y": 0, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
assert!(s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "column"})).is_err());
s.execute("graph.setType", &json!({"seriesIndexes": [1, 2], "type": "stackedBar"})).unwrap();
let (a, b, c) = (marks(&s, id, 0), marks(&s, id, 1), marks(&s, id, 2));
// b and c stack along x in the cluster's second slot, below a's bar; c, negative, runs left of the zero line.
assert!((a[0].height() - b[0].height()).abs() < 1e-6 && b[0].y0 > a[0].y1, "{:?} {:?}", a[0], b[0]);
assert!((b[0].y0 - c[0].y0).abs() < 1e-6 && (c[0].x1 - b[0].x0).abs() < 1e-6, "{:?} {:?}", b[0], c[0]);
}
#[test]
fn series_types_survive_save_and_open_and_older_files_have_none() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-kinds").join("kinds.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(spec(&s, id).series_kinds, [None, Some(GraphKind::Line)]);
let g: GraphSpec = serde_json::from_value(json!({"kind": "line", "rows": [[1.0, 2.0]]})).unwrap();
assert!(g.series_kinds.is_empty());
assert!(serde_json::to_value(&g).unwrap().get("seriesKinds").is_none());
// A stored type the graph can't take, or past its series, is drawn as the graph's type.
let g: GraphSpec = serde_json::from_value(json!({"kind": "pie", "seriesKinds": ["line", null, "column"], "rows": [[1.0]]})).unwrap();
assert_eq!((g.series_kind(0), g.series_kind(5)), (GraphKind::Pie, GraphKind::Pie));
}
/// The value axis labels: text and bounds of the texts in the Axes group that read as numbers.
fn value_labels(s: &Session, id: NodeId) -> Vec<(String, vectorcraft_geom::Rect)> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
group(&n, "Axes")
.children()
.unwrap()
.iter()
.filter_map(|c| match &c.kind {
NodeKind::Text(t) => Some((t.plain_text(), c.geometric_bounds().unwrap())),
_ => None,
})
.filter(|(t, _)| t.parse::<f64>().is_ok())
.collect()
}
fn two_scale_graph(s: &mut Session) -> NodeId {
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",a,b\nQ1,10,1000\nQ2,5,500";
NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
)
}
#[test]
fn the_value_axis_goes_on_the_left_the_right_or_both_sides() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
let left: Vec<String> = value_labels(&s, id).into_iter().inspect(|(_, b)| assert!(b.x1 < 100.0, "{b:?}")).map(|(t, _)| t).collect();
assert_eq!(left, ["0", "200", "400", "600", "800", "1000"]);
s.execute("graph.setType", &json!({"valueAxis": "right"})).unwrap();
let right = value_labels(&s, id);
assert!(right.iter().all(|(_, b)| b.x0 > 400.0), "{right:?}");
assert_eq!(right.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), left);
// Both sides, one scale: the same labels on each side, and the legend moves past the right ones.
s.execute("graph.setType", &json!({"valueAxis": "both"})).unwrap();
let both = value_labels(&s, id);
assert_eq!(both.len(), 12);
let edge = both.iter().map(|(_, b)| b.x1).fold(0.0, f64::max);
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let swatch = series(&n, 0).children().unwrap().last().unwrap().geometric_bounds().unwrap();
assert!(swatch.x0 > edge, "{swatch:?} vs {edge}");
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "both");
assert!(s.execute("graph.setType", &json!({"valueAxis": "top"})).is_err());
}
#[test]
fn separate_scales_give_the_right_axis_series_a_scale_of_their_own() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap();
// Assigned, but drawn on one scale until separate scales are on with both sides.
assert_eq!(spec(&s, id).right_series, [1]);
s.execute("graph.setType", &json!({"separateScales": true})).unwrap();
assert!((marks(&s, id, 0)[0].height() - 2.0).abs() < 1e-6, "a's 10 of 1000");
s.execute("graph.setType", &json!({"valueAxis": "both"})).unwrap();
// a's 10 and b's 1000 both reach the top: 0..10 on the left, 0..1000 on the right.
let (a, b) = (marks(&s, id, 0), marks(&s, id, 1));
assert!((a[0].y0 - 100.0).abs() < 1e-6 && (b[0].y0 - 100.0).abs() < 1e-6, "{:?} {:?}", a[0], b[0]);
let labels = value_labels(&s, id);
let side = |right: bool| labels.iter().filter(|(_, b)| (b.x0 > 400.0) == right).map(|(t, _)| t.clone()).collect::<Vec<_>>();
assert_eq!(side(false), ["0", "2", "4", "6", "8", "10"]);
assert_eq!(side(true), ["0", "200", "400", "600", "800", "1000"]);
// The right axis' own tick values.
s.execute("graph.setType", &json!({"rightAxisMin": 0, "rightAxisMax": 2000, "rightTicks": 4})).unwrap();
assert!((marks(&s, id, 1)[0].y0 - 200.0).abs() < 1e-6, "1000 of 2000");
assert!((marks(&s, id, 0)[0].y0 - 100.0).abs() < 1e-6, "the left axis is unchanged");
let v = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!((v["separateScales"].clone(), v["rightTicks"].clone(), v["rightAxisMax"].clone()), (json!(true), json!(4), json!(2000.0)));
// Stacked series stack on their own axis, each axis' stack in a slot of its own.
s.execute("graph.setType", &json!({"type": "stackedColumn"})).unwrap();
let (a, b) = (marks(&s, id, 0), marks(&s, id, 1));
assert!((a[0].y1 - 300.0).abs() < 1e-6 && (b[0].y1 - 300.0).abs() < 1e-6, "{:?} {:?}", a[0], b[0]);
assert!(b[0].x0 >= a[0].x1 - 1e-6 && (a[0].width() - b[0].width()).abs() < 1e-6, "side by side: {:?} {:?}", a[0], b[0]);
// A line on the right axis follows the right scale.
s.execute("graph.setType", &json!({"type": "column"})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
let line = marks(&s, id, 1);
assert!((line[1].center().y - 200.0).abs() < 1e-6 && (line[2].center().y - 250.0).abs() < 1e-6, "1000 and 500 of 2000: {line:?}");
}
#[test]
fn group_selected_series_go_on_the_chosen_axis() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id;
s.execute("select.set", &json!({"ids": [grp.0]})).unwrap();
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "left");
assert!(s.execute("graph.setType", &json!({"valueAxis": "both"})).is_err(), "a series takes one axis");
s.execute("graph.setType", &json!({"valueAxis": "right"})).unwrap();
let g = spec(&s, id);
assert_eq!((g.right_series.clone(), g.value_axis), (vec![1], vectorcraft_doc::ValueAxisSide::Left));
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "right");
s.execute("graph.setType", &json!({"valueAxis": "left"})).unwrap();
assert!(spec(&s, id).right_series.is_empty());
}
#[test]
fn bar_graphs_keep_their_value_axis_along_the_bottom() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "bar");
let before = (value_labels(&s, id), marks(&s, id, 0));
s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "seriesIndexes": [1]})).unwrap_err();
s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap();
assert_eq!((value_labels(&s, id), marks(&s, id, 0)), before);
}
#[test]
fn value_axis_options_survive_save_and_open_and_are_left_out_when_unset() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-axes").join("axes.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let g = spec(&s, id);
assert_eq!(
(g.value_axis, g.separate_scales, g.right_series, g.right_axis_max),
(vectorcraft_doc::ValueAxisSide::Both, true, vec![1], Some(2000.0))
);
let v = serde_json::to_value(GraphSpec::default()).unwrap();
for k in ["valueAxis", "separateScales", "rightSeries", "rightTicks", "rightAxisMin", "rightAxisMax"] {
assert!(v.get(k).is_none(), "{k}");
}
}
#[test]
fn the_dialog_round_trip_keeps_series_on_both_axes_where_they_are() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",a,b,c\nQ1,10,1000,3\nQ2,5,500,4";
let id = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap();
for side in ["both", "left"] {
s.execute("graph.setType", &json!({"valueAxis": side, "separateScales": true})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
s.execute("select.set", &json!({"ids": [series(&n, 0).id.0, series(&n, 1).id.0]})).unwrap();
let fields = s.execute("graph.setType", &json!({})).unwrap();
assert!(fields["valueAxis"].is_null(), "{side}: {fields}");
s.execute("graph.setType", &fields).unwrap();
let g = spec(&s, id);
assert_eq!((g.right_series, g.value_axis.id()), (vec![1], side), "{side}");
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
}
}
#[test]
fn an_axis_with_no_series_shows_the_other_scale() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap();
let labels = |s: &Session| {
let l = value_labels(s, id);
let side = |right: bool| l.iter().filter(|(_, b)| (b.x0 > 400.0) == right).map(|(t, _)| t.clone()).collect::<Vec<_>>();
(side(false), side(true))
};
let (left, right) = labels(&s);
assert_eq!(left, right);
assert_eq!(right.last().unwrap(), "1000");
// Every series on the right: the left axis shows the right scale.
s.execute("graph.setType", &json!({"seriesIndexes": [0, 1], "valueAxis": "right", "rightAxisMin": 0, "rightAxisMax": 2000})).unwrap();
let (left, right) = labels(&s);
assert_eq!(left, right);
assert_eq!(right.last().unwrap(), "2000");
}
#[test]
fn right_axis_columns_start_at_the_right_scales_zero() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let csv = ",a,b\nQ1,10,-500\nQ2,5,500";
let id = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
.as_u64()
.unwrap(),
);
s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true, "seriesIndexes": [1]})).unwrap_err();
s.execute("graph.setType", &json!({"valueAxis": "both", "separateScales": true})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap();
// Right scale -600..600 (zero at y 200), left 0..10 (zero at y 300): b's columns meet at the right zero.
let b = marks(&s, id, 1);
assert!((b[0].y0 - 200.0).abs() < 1e-6 && (b[1].y1 - 200.0).abs() < 1e-6, "{b:?}");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let at_200 = group(&n, "Axes")
.children()
.unwrap()
.iter()
.filter(|c| matches!(c.kind, NodeKind::Path { .. }))
.filter_map(|c| c.geometric_bounds())
.any(|bb| bb.height() < 1e-6 && (bb.y0 - 200.0).abs() < 1e-6 && (bb.width() - 300.0).abs() < 1e-6);
assert!(at_200, "a zero line for the right scale");
}
#[test]
fn data_without_a_right_axis_series_drops_its_assignment() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right"})).unwrap();
s.execute("graph.setData", &json!({"csv": ",a\nQ1,10\nQ2,5"})).unwrap();
assert!(spec(&s, id).right_series.is_empty());
s.execute("graph.setData", &json!({"csv": ",a,b\nQ1,10,1000\nQ2,5,500"})).unwrap();
assert!(spec(&s, id).right_series.is_empty(), "a new series starts on the left");
}
/// Bounds of the straight lines in the Axes group.
fn axis_lines(s: &Session, id: NodeId) -> Vec<vectorcraft_geom::Rect> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
group(&n, "Axes")
.children()
.unwrap()
.iter()
.filter(|c| matches!(c.kind, NodeKind::Path { .. }))
.filter_map(|c| c.geometric_bounds())
.collect()
}
fn axis_texts(s: &Session, id: NodeId) -> Vec<String> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
group(&n, "Axes")
.children()
.unwrap()
.iter()
.filter_map(|c| match &c.kind {
NodeKind::Text(t) => Some(t.plain_text()),
_ => None,
})
.collect()
}
#[test]
fn value_axis_tick_marks_take_a_length_and_a_count_per_division() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
let short = |s: &Session, x0: f64| axis_lines(s, id).iter().filter(|b| (b.x0 - x0).abs() < 1e-6 && (b.width() - 4.0).abs() < 1e-6).count();
let across = |s: &Session| axis_lines(s, id).iter().filter(|b| b.height() < 1e-6 && (b.width() - 300.0).abs() < 1e-6).count();
// Short (the default): one at each of the six labels; the zero line runs across.
assert_eq!((short(&s, 96.0), across(&s)), (6, 1));
s.execute("graph.setType", &json!({"tickLength": "full"})).unwrap();
// Across the plot at 200…1000; the one at 0 would be the zero line again.
assert_eq!((short(&s, 96.0), across(&s)), (0, 6));
s.execute("graph.setType", &json!({"tickLength": "none"})).unwrap();
assert_eq!((short(&s, 96.0), across(&s)), (0, 1));
// Two per division: one more halfway between labels, none past the last label.
s.execute("graph.setType", &json!({"tickLength": "short", "tickMarks": 2})).unwrap();
assert_eq!(short(&s, 96.0), 11);
assert!(axis_lines(&s, id).iter().any(|b| (b.x0 - 96.0).abs() < 1e-6 && (b.y0 - 280.0).abs() < 1e-6), "100 of 0..1000 sits at y 280");
assert_eq!(axis_texts(&s, id).iter().filter(|t| t.parse::<f64>().is_ok()).count(), 6, "labels only at divisions");
// The right axis has its own.
s.execute("graph.setType", &json!({"valueAxis": "both", "rightTickLength": "none"})).unwrap();
assert_eq!((short(&s, 96.0), short(&s, 400.0)), (11, 0));
s.execute("graph.setType", &json!({"rightTickLength": "short", "rightTickMarks": 4})).unwrap();
assert_eq!(short(&s, 400.0), 21);
let v = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!((v["tickLength"].clone(), v["tickMarks"].clone(), v["rightTickMarks"].clone()), (json!("short"), json!(2), json!(4)));
assert!(s.execute("graph.setType", &json!({"tickLength": "long"})).is_err());
s.execute("graph.setType", &json!({"tickMarks": 1_000_000})).unwrap();
assert_eq!(spec(&s, id).tick_marks, 20);
}
#[test]
fn category_tick_marks_go_at_or_between_the_labels() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
let below = |s: &Session| {
let mut xs: Vec<f64> = axis_lines(s, id)
.iter()
.filter(|b| b.width() < 1e-6 && (b.y0 - 300.0).abs() < 1e-6 && (b.height() - 4.0).abs() < 1e-6)
.map(|b| b.x0)
.collect();
xs.sort_by(f64::total_cmp);
xs
};
// None by default, as before.
assert!(below(&s).is_empty());
s.execute("graph.setType", &json!({"categoryTickLength": "short"})).unwrap();
assert_eq!(below(&s), [175.0, 325.0]);
s.execute("graph.setType", &json!({"ticksBetweenLabels": true})).unwrap();
assert_eq!(below(&s), [100.0, 250.0, 400.0]);
s.execute("graph.setType", &json!({"categoryTickMarks": 2})).unwrap();
assert_eq!(below(&s), [100.0, 175.0, 250.0, 325.0, 400.0]);
s.execute("graph.setType", &json!({"categoryTickLength": "full"})).unwrap();
let full = axis_lines(&s, id).iter().filter(|b| b.width() < 1e-6 && (b.height() - 200.0).abs() < 1e-6 && b.x0 > 100.0 + 1e-6).count();
assert_eq!(full, 4, "inside the plot: 175, 250, 325 and 400");
// Bar graphs: the category axis is the left one.
let bar = graph(&mut s, "bar");
s.execute("graph.setType", &json!({"id": bar.0, "categoryTickLength": "short", "ticksBetweenLabels": true})).unwrap();
let left = axis_lines(&s, bar).iter().filter(|b| b.height() < 1e-6 && (b.x0 - 96.0).abs() < 1e-6 && (b.width() - 4.0).abs() < 1e-6).count();
assert_eq!(left, 4, "three categories, four edges");
}
#[test]
fn value_axis_numbers_take_a_prefix_and_a_suffix() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
s.execute("graph.setType", &json!({"prefix": "$", "suffix": " M", "valueAxis": "both", "separateScales": true})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [1], "valueAxis": "right", "rightSuffix": "%"})).unwrap();
let texts = axis_texts(&s, id);
assert!(texts.contains(&"$10 M".to_string()) && texts.contains(&"$0 M".to_string()), "{texts:?}");
assert!(texts.contains(&"1000%".to_string()) && !texts.contains(&"$1000 M".to_string()), "{texts:?}");
// One short line: control characters dropped, the length capped.
s.execute("graph.setType", &json!({"prefix": format!("a\nb{}", "x".repeat(500))})).unwrap();
let p = spec(&s, id).prefix;
assert!(p.starts_with("ab") && p.chars().count() == 64, "{p}");
let v = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!((v["suffix"].clone(), v["rightSuffix"].clone()), (json!(" M"), json!("%")));
}
#[test]
fn tick_and_label_options_survive_save_and_open_and_are_left_out_when_unset() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
s.execute(
"graph.setType",
&json!({"tickLength": "full", "tickMarks": 3, "categoryTickLength": "short", "ticksBetweenLabels": true, "suffix": "%"}),
)
.unwrap();
let path = vectorcraft_testkit::temp_dir("graph-ticks").join("ticks.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let g = spec(&s, id);
assert_eq!(
(g.tick_length, g.tick_marks, g.category_tick_length, g.ticks_between_labels, g.suffix.as_str()),
(vectorcraft_doc::TickLength::Full, 3, vectorcraft_doc::TickLength::Short, true, "%")
);
let v = serde_json::to_value(GraphSpec::default()).unwrap();
for k in [
"tickLength",
"tickMarks",
"rightTickLength",
"rightTickMarks",
"categoryTickLength",
"categoryTickMarks",
"ticksBetweenLabels",
"prefix",
"suffix",
"rightPrefix",
"rightSuffix",
] {
assert!(v.get(k).is_none(), "{k}");
}
// A file's huge values draw within the caps.
let g: GraphSpec = serde_json::from_value(json!({"rows": [[1.0]], "tickMarks": 1_000_000_000u64, "prefix": "y".repeat(10_000)})).unwrap();
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let art = super::generate(&mut d, &g);
let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap().children().unwrap().to_vec();
assert!(axes.len() < 200, "{} axis parts", axes.len());
for t in axes.iter().filter_map(|n| match &n.kind {
NodeKind::Text(t) => Some(t.plain_text()),
_ => None,
}) {
assert!(t.chars().count() < 80, "{} characters", t.chars().count());
}
}
#[test]
fn between_labels_ticks_on_an_edge_to_edge_graph_fall_halfway_between_its_labels() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "area");
s.execute("graph.setType", &json!({"categoryTickLength": "short", "ticksBetweenLabels": true})).unwrap();
// Labels at 100, 250 and 400 (the plot's edges and middle): ticks at 175 and 325.
let mut xs: Vec<f64> = axis_lines(&s, id)
.iter()
.filter(|b| b.width() < 1e-6 && (b.y0 - 300.0).abs() < 1e-6 && (b.height() - 4.0).abs() < 1e-6)
.map(|b| b.x0)
.collect();
xs.sort_by(f64::total_cmp);
assert_eq!(xs, [175.0, 325.0]);
}
#[test]
fn bar_graph_value_ticks_run_up_the_plot_and_long_labels_push_the_legend() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "bar");
let legend_x = |s: &Session| {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
series(&n, 0).children().unwrap().last().unwrap().geometric_bounds().unwrap().x0
};
let before = legend_x(&s);
assert!((before - 414.0).abs() < 1e-6, "a short last label leaves the legend where it was: {before}");
s.execute("graph.setType", &json!({"tickLength": "full", "tickMarks": 2})).unwrap();
let up: Vec<_> = axis_lines(&s, id).into_iter().filter(|b| b.width() < 1e-6 && (b.height() - 200.0).abs() < 1e-6).collect();
// -2..6 in 2s: five labels and four halves; the one at 0 is the zero line already, the left end the axis.
assert_eq!(up.len(), 9, "{up:?}");
s.execute("graph.setType", &json!({"suffix": " million tonnes"})).unwrap();
let last = value_label_edge(&s, id);
assert!(legend_x(&s) >= last + 6.0 - 1e-6, "{} vs {last}", legend_x(&s));
}
/// The right end of the Axes group's texts.
fn value_label_edge(s: &Session, id: NodeId) -> f64 {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
group(&n, "Axes")
.children()
.unwrap()
.iter()
.filter(|c| matches!(c.kind, NodeKind::Text(_)))
.filter_map(|c| c.geometric_bounds())
.map(|b| b.x1)
.fold(f64::MIN, f64::max)
}
#[test]
fn counts_come_back_from_the_dialog_as_floats_and_are_capped() {
let mut s = Session::new();
let id = two_scale_graph(&mut s);
s.execute("graph.setType", &json!({"tickMarks": 3.0, "ticks": 4.0, "categoryTickMarks": 2.6})).unwrap();
let g = spec(&s, id);
assert_eq!((g.tick_marks, g.ticks, g.category_tick_marks), (3, 4, 3));
s.execute("graph.setType", &json!({"categoryTickMarks": 0, "rightTickMarks": 1e300, "rightTicks": -2.0})).unwrap();
let g = spec(&s, id);
assert_eq!((g.category_tick_marks, g.right_tick_marks, g.right_ticks), (1, 20, 0));
// A file's values come back as drawn.
let mut fields = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!(fields["rightTickMarks"], 20);
fields["tickLength"] = json!("full");
s.execute("graph.setType", &fields).unwrap();
assert_eq!(spec(&s, id).tick_length, vectorcraft_doc::TickLength::Full);
}
#[test]
fn a_long_category_axis_is_ticked_all_the_way_within_the_cap() {
let rows: Vec<Vec<f64>> = (0..5000).map(|i| vec![i as f64]).collect();
let g = GraphSpec { rows, category_tick_length: vectorcraft_doc::TickLength::Short, category_tick_marks: 20, ..GraphSpec::default() };
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let art = super::generate(&mut d, &g);
let axes = art.iter().find(|n| n.name.as_deref() == Some("Axes")).unwrap();
let ticks: Vec<f64> = axes
.children()
.unwrap()
.iter()
.filter_map(|c| c.geometric_bounds())
.filter(|b| b.width() < 1e-9 && (b.height() - 4.0).abs() < 1e-9)
.map(|b| b.x0)
.collect();
assert!(ticks.len() <= 10_000 && ticks.len() >= 5000, "{}", ticks.len());
assert!(ticks.iter().copied().fold(f64::MIN, f64::max) > 199.0, "the last category is ticked");
}
/// A graph design: a 10 × 10 rectangle at the back and a 20-wide ellipse over it, saved as `name`.
fn save_design(s: &mut Session, name: &str) {
let r = s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
let e = s.execute("shape.ellipse", &json!({"x": 595, "y": 600, "width": 20, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r, e]})).unwrap();
s.execute("graph.design", &json!({"new": name})).unwrap();
}
/// The marks of series `index` (legend swatch last) and their bounds.
fn series_parts(s: &Session, id: NodeId, index: u32) -> Vec<vectorcraft_doc::Node> {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
series(&n, index).children().unwrap().iter().map(|c| (**c).clone()).collect()
}
#[test]
fn a_marker_design_draws_each_data_point_sized_by_its_backmost_object() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"]));
let id = graph(&mut s, "line");
let before = series_parts(&s, id, 1);
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id;
s.execute("select.set", &json!({"ids": [grp.0]})).unwrap();
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
let after = series_parts(&s, id, 1);
assert_eq!(after.len(), before.len(), "a line, three markers and the swatch");
for (old, new) in before.iter().zip(&after).skip(1) {
let (o, n) = (old.geometric_bounds().unwrap(), new.geometric_bounds().unwrap());
assert!(matches!(new.kind, NodeKind::Group { .. }), "the design's art");
// The 10-point rectangle fills the marker's square, the 20-wide ellipse twice as wide.
assert!((n.center() - o.center()).hypot() < 1e-6, "{o:?} {n:?}");
assert!((n.width() - 2.0 * o.width()).abs() < 1e-6 && (n.height() - o.height()).abs() < 1e-6, "{o:?} {n:?}");
}
// The series stays selected; the other series keeps its squares; the query names the design.
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 1).id;
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [grp]);
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
assert_eq!(s.execute("graph.marker", &json!({})).unwrap()["design"], "Pill");
assert_eq!(spec(&s, id).series_markers, [None, Some("Pill".to_string())]);
// Back to the default square.
s.execute("graph.marker", &json!({"design": null})).unwrap();
assert!(spec(&s, id).series_markers.is_empty());
assert!(series_parts(&s, id, 1).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
assert!(s.execute("graph.marker", &json!({"design": "Nope"})).is_err());
assert!(s.execute("graph.marker", &json!({"design": 3})).is_err());
}
#[test]
fn marker_designs_apply_to_markers_not_to_columns() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"seriesIndexes": [1], "type": "line"})).unwrap();
// Every series (none picked): the line series' markers and swatch; the columns and their swatch stay.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
let line = series_parts(&s, id, 1);
assert!(line.iter().skip(1).all(|n| matches!(n.kind, NodeKind::Group { .. })), "markers and swatch");
let swatch = line.last().unwrap().geometric_bounds().unwrap();
assert!((swatch.width() - 16.0).abs() < 1e-6 && (swatch.height() - 8.0).abs() < 1e-6, "{swatch:?}");
// Scatter and radar graphs draw them too.
for ty in ["scatter", "radar"] {
let g = graph(&mut s, ty);
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
assert!(series_parts(&s, g, 0).iter().any(|n| matches!(n.kind, NodeKind::Group { .. })), "{ty}");
}
}
#[test]
fn graph_designs_are_saved_pasted_and_deleted() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
assert!(s.execute("graph.design", &json!({"new": "Pill"})).is_err(), "nothing selected");
save_design(&mut s, "Pill");
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
assert!(s.execute("graph.design", &json!({"new": "Pill"})).is_err(), "the name is taken");
assert!(s.execute("graph.design", &json!({"new": " "})).is_err(), "no name");
// Paste Design: a copy of the art, selected, with ids of its own.
let pasted = NodeId(s.execute("graph.design", &json!({"paste": "Pill"})).unwrap()["id"].as_u64().unwrap());
assert_eq!(s.doc().unwrap().selection.objects.to_vec(), [pasted]);
let art = s.doc().unwrap().doc.graph_designs[0].art.clone();
let copy = s.doc().unwrap().doc.node(pasted).unwrap().clone();
assert_ne!(copy.id, art.id);
assert_eq!(copy.geometric_bounds(), art.geometric_bounds());
assert!(s.execute("graph.design", &json!({"paste": "Nope"})).is_err());
// Deleting a design draws its graphs' default markers again; undo brings both back.
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
s.execute("graph.design", &json!({"delete": "Pill"})).unwrap();
assert!(s.doc().unwrap().doc.graph_designs.is_empty());
assert!(series_parts(&s, id, 0).iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
s.execute("edit.undo", &json!({})).unwrap();
assert!(series_parts(&s, id, 0).iter().skip(1).all(|n| matches!(n.kind, NodeKind::Group { .. })));
assert!(s.execute("graph.design", &json!({"delete": "Nope"})).is_err());
}
#[test]
fn designs_and_markers_survive_save_and_open() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": "Pill"})).unwrap();
let path = vectorcraft_testkit::temp_dir("graph-designs").join("designs.vectorcraft");
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!(["Pill"]));
assert_eq!(spec(&s, id).series_markers, [Some("Pill".to_string())]);
assert!(serde_json::to_value(GraphSpec::default()).unwrap().get("seriesMarkers").is_none());
// A design whose art has no area draws the default square.
let g = GraphSpec { kind: GraphKind::Line, series_markers: vec![Some("Dot".into())], ..GraphSpec::default() };
let mut d = vectorcraft_doc::Document::new(800.0, 800.0);
let dot = vectorcraft_doc::Node::group(d.alloc_id(), vec![]);
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dot".into(), art: std::sync::Arc::new(dot) });
let art = super::generate(&mut d, &g);
let s0 = art.iter().find(|n| n.series_index == Some(0)).unwrap();
assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
}
#[test]
fn a_graph_saved_as_a_design_is_plain_art_even_as_its_own_marker() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.design", &json!({"new": "Chart"})).unwrap();
let art = s.doc().unwrap().doc.graph_designs[1].art.clone();
let mut graphs = 0;
art.walk(&mut |n| graphs += usize::from(n.graph.is_some() || n.series_index.is_some()));
assert_eq!(graphs, 0, "no graph inside a design");
// The graph drawn with itself as a marker, then the design it used deleted: still one graph to edit.
s.execute("graph.marker", &json!({"id": id.0, "seriesIndexes": [1], "design": "Chart"})).unwrap();
s.execute("graph.design", &json!({"delete": "Pill"})).unwrap();
let mut found = 0;
s.doc().unwrap().doc.walk(|n| found += usize::from(n.graph.is_some()));
assert_eq!(found, 1);
// A deleted design reads as none in the query, so the dialog opens on the default marker.
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
s.execute("graph.marker", &json!({"seriesIndexes": [0], "design": null})).unwrap();
let v = s.execute("graph.marker", &json!({"seriesIndexes": [1, 0]})).unwrap();
assert!(v["design"].is_null(), "{v}");
}
#[test]
fn designs_too_big_for_a_graph_are_refused_and_drawn_as_squares() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
// 1,500 objects at each of 200 points is past the budget.
let ids: Vec<u64> = (0..1500)
.map(|i| s.execute("shape.rectangle", &json!({"x": i % 40, "y": i / 40, "width": 1, "height": 1})).unwrap()["id"].as_u64().unwrap())
.collect();
s.execute("select.set", &json!({"ids": ids})).unwrap();
s.execute("graph.design", &json!({"new": "Big"})).unwrap();
let rows: Vec<Vec<f64>> = (0..200).map(|i| vec![i as f64]).collect();
let id = NodeId(
s.execute("graph.create", &json!({"type": "line", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"]
.as_u64()
.unwrap(),
);
assert!(s.execute("graph.marker", &json!({"id": id.0, "design": "Big"})).is_err());
assert!(spec(&s, id).series_markers.is_empty());
// A file asking for it anyway draws the default squares.
let mut g = spec(&s, id);
g.series_markers = vec![Some("Big".into())];
let mut d = (*s.doc().unwrap().doc).clone();
let art = super::generate(&mut d, &g);
let s0 = art.iter().find(|n| n.series_index == Some(0)).unwrap();
assert!(s0.children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
// Too many objects for one design.
let ids: Vec<u64> = (0..2001)
.map(|i| s.execute("shape.rectangle", &json!({"x": i % 40, "y": i / 40, "width": 1, "height": 1})).unwrap()["id"].as_u64().unwrap())
.collect();
s.execute("select.set", &json!({"ids": ids})).unwrap();
assert!(s.execute("graph.design", &json!({"new": "Huge"})).is_err());
}
#[test]
fn a_files_designs_are_tidied() {
let mut d = vectorcraft_doc::Document::new(100.0, 100.0);
let art = |d: &mut vectorcraft_doc::Document| std::sync::Arc::new(vectorcraft_doc::Node::group(d.alloc_id(), vec![]));
let mut graphy = vectorcraft_doc::Node::group(d.alloc_id(), vec![]);
graphy.graph = Some(Box::new(GraphSpec::default()));
for name in [" A\u{7} ", "A", "", " ", &"x".repeat(100)] {
let a = art(&mut d);
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: name.into(), art: a });
}
d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "G".into(), art: std::sync::Arc::new(graphy) });
d.tidy_graph_designs();
let names: Vec<_> = d.graph_designs.iter().map(|x| x.name.clone()).collect();
assert_eq!(names, ["A".to_string(), "x".repeat(64), "G".to_string()]);
assert!(d.graph_designs[2].art.graph.is_none());
}
#[test]
fn painting_a_series_drawn_with_a_design_leaves_the_design_alone() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
save_design(&mut s, "Pill");
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"design": "Pill"})).unwrap();
let grp = series(s.doc().unwrap().doc.node(id).unwrap(), 0).id;
s.execute("paint.setFill", &json!({"ids": [grp.0], "color": "#f00"})).unwrap();
// The design's paths keep the design's paint, before and after the graph is drawn again.
let design_fill = |s: &Session| {
let parts = series_parts(s, id, 0);
let mut fills = vec![];
parts[1].walk(&mut |n| {
if let NodeKind::Path { .. } = n.kind {
fills.push(n.appearance.fill_paint().color().map(|c| c.to_hex()));
}
});
fills
};
let painted = design_fill(&s);
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);
}
#[test]
fn graph_design_params_are_names() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
for p in [json!({"new": 5}), json!({"paste": null}), json!({"delete": ["a"]})] {
assert!(s.execute("graph.design", &p).is_err(), "{p}");
}
}
#[test]
fn document_wide_passes_see_design_art() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({"ids": [r], "color": "#123456"})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("graph.design", &json!({"new": "Blue"})).unwrap();
s.execute("edit.clear", &json!({})).unwrap();
let mut seen = false;
s.doc().unwrap().doc.visit_paints(&mut |p| seen |= p.color().is_some_and(|c| c.to_hex() == "#123456"));
assert!(seen, "the colour is used by the design");
}
#[test]
fn new_design_rename_delete_and_select_unused() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
// New Design names it "New Design", numbered when that's taken.
assert_eq!(s.execute("graph.design", &json!({"new": true})).unwrap()["name"], "New Design");
assert_eq!(s.execute("graph.design", &json!({"new": true})).unwrap()["name"], "New Design 2");
let id = graph(&mut s, "line");
s.execute("graph.marker", &json!({"id": id.0, "design": "New Design"})).unwrap();
let v = s.execute("graph.design", &json!({"selectUnused": true})).unwrap();
assert_eq!(v["unused"], json!(["New Design 2"]));
// Renaming a design the graph draws with: the graph follows, still drawing it.
s.execute("graph.design", &json!({"rename": "New Design", "to": "Dot"})).unwrap();
assert_eq!(spec(&s, id).series_markers, [Some("Dot".to_string()), Some("Dot".to_string())]);
assert!(s.execute("graph.design", &json!({"rename": "Dot", "to": "New Design 2"})).is_err(), "taken");
assert!(s.execute("graph.design", &json!({"rename": "Nope", "to": "X"})).is_err());
// Delete several at once.
s.execute("graph.design", &json!({"delete": ["Dot", "New Design 2"]})).unwrap();
assert_eq!(s.execute("graph.design", &json!({})).unwrap()["designs"], json!([]));
assert!(s.execute("graph.design", &json!({"delete": ["Nope"]})).is_err());
}
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"]
.as_u64()
.unwrap(),
)
}
/// Where each wedge of series `index` starts: its angle from 12 o'clock, clockwise, in degrees (the legend
/// swatch, last, left out).
fn wedge_starts(s: &Session, id: NodeId, index: u32) -> Vec<f64> {
let parts = series_parts(s, id, index);
parts[..parts.len() - 1]
.iter()
.filter_map(|n| match &n.kind {
NodeKind::Path { path, .. } => {
let bp = path.to_bezpath();
let pts: Vec<_> = bp.elements().iter().filter_map(|e| e.end_point()).collect();
let (c, p) = (pts.first()?, pts.get(1)?);
let deg = (p.x - c.x).atan2(-(p.y - c.y)).to_degrees();
Some(if deg < -1e-6 { deg + 360.0 } else { deg })
}
_ => None,
})
.collect()
}
#[test]
fn a_bar_in_a_label_breaks_the_line() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "column", ",Total|Sales|2023,b\nQ1|first,3,2\nQ2,5,4");
let texts = axis_texts(&s, id);
assert!(texts.contains(&"Q1\nfirst".to_string()), "{texts:?}");
// The legend: a label of three lines, and the next row below all three.
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let legend: Vec<_> = group(&n, "Legend").children().unwrap().iter().map(|c| (**c).clone()).collect();
let NodeKind::Text(t) = &legend[0].kind else { panic!() };
assert_eq!(t.plain_text(), "Total\nSales\n2023");
let (a, b) = (
series_parts(&s, id, 0).last().unwrap().geometric_bounds().unwrap(),
series_parts(&s, id, 1).last().unwrap().geometric_bounds().unwrap(),
);
assert!((b.y0 - a.y0 - 3.0 * 9.0 * 1.8).abs() < 1e-6, "{a:?} {b:?}");
}
#[test]
fn transpose_swaps_rows_and_columns_and_switch_xy_swaps_scatter_pairs() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "column", ",a,b\nQ1,1,2\nQ2,3,");
let before = spec(&s, id);
s.execute("graph.setData", &json!({"transpose": true})).unwrap();
let g = spec(&s, id);
assert_eq!((g.series.clone(), g.categories.clone()), (vec!["Q1".to_string(), "Q2".into()], vec!["a".to_string(), "b".into()]));
assert_eq!(g.cells(), [[Some(1.0), Some(3.0)], [Some(2.0), None]]);
s.execute("graph.setData", &json!({"transpose": true})).unwrap();
assert_eq!(spec(&s, id).cells(), before.cells());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(spec(&s, id).cells(), g.cells());
assert!(s.execute("graph.setData", &json!({"switchXY": true})).is_err(), "scatter graphs only");
let sc = make(&mut s, "scatter", ",y1,x1,y2,x2\nP,1,10,2,20");
s.execute("graph.setData", &json!({"switchXY": true})).unwrap();
let g = spec(&s, sc);
assert_eq!(g.cells(), [[Some(10.0), Some(1.0), Some(20.0), Some(2.0)]]);
assert_eq!(g.series, ["x1", "y1", "x2", "y2"]);
}
#[test]
fn pie_wedges_sort_largest_first_in_each_pie_or_in_the_first_pies_order() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "pie", ",a,b,c\nP1,1,3,2\nP2,3,1,2");
// None (data order): a starts at 12 o'clock in both pies.
assert_eq!(wedge_starts(&s, id, 0).iter().map(|d| d.round()).collect::<Vec<_>>(), [0.0, 0.0]);
s.execute("graph.setType", &json!({"pieSort": "all"})).unwrap();
// P1: b (3) first; P2: a (3) first.
let (a, b) = (wedge_starts(&s, id, 0), wedge_starts(&s, id, 1));
assert!(b[0].abs() < 1e-6 && a[1].abs() < 1e-6, "{a:?} {b:?}");
s.execute("graph.setType", &json!({"pieSort": "first"})).unwrap();
// P1's order (b, c, a) in both: b first in P2 too.
let b = wedge_starts(&s, id, 1);
assert!(b.iter().all(|d| d.abs() < 1e-6), "{b:?}");
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["pieSort"], "first");
assert!(s.execute("graph.setType", &json!({"pieSort": "random"})).is_err());
}
#[test]
fn pies_sized_by_their_totals_side_by_side_or_stacked() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "pie", ",a,b\nSmall,5,5\nBig,20,20");
let pie = |s: &Session, c: usize| {
let wedges = |i: u32| {
let parts = series_parts(s, id, i);
parts[..parts.len() - 1].to_vec()
};
let rects: Vec<_> = (0..2).flat_map(wedges).filter_map(|n| n.geometric_bounds()).collect();
rects.into_iter().skip(c).step_by(2).reduce(|a, b| a.union(b)).unwrap()
};
let even = (pie(&s, 0), pie(&s, 1));
assert!((even.0.width() - even.1.width()).abs() < 1e-6);
s.execute("graph.setType", &json!({"piePosition": "ratio"})).unwrap();
let (small, big) = (pie(&s, 0), pie(&s, 1));
// Areas in proportion to the totals: 10 and 40, so radii 1 : 2.
assert!((big.width() / small.width() - 2.0).abs() < 1e-6, "{small:?} {big:?}");
assert!((big.width() - even.1.width()).abs() < 1e-6, "the biggest keeps the full size");
s.execute("graph.setType", &json!({"piePosition": "stacked"})).unwrap();
// Drawn largest first, so the big pie's wedges come first in each series.
let (big, small) = (pie(&s, 0), pie(&s, 1));
assert!((small.center() - big.center()).hypot() < 1e-6 && (big.width() / small.width() - 2.0).abs() < 1e-6);
// The big pie is drawn first, under the small one.
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let wedges: Vec<f64> = series(&n, 0).children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.width()).collect();
assert!(wedges[0] > wedges[1], "{wedges:?}");
}
#[test]
fn legends_in_wedges_put_the_series_labels_inside_the_pie() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "pie", ",North,South|East\nP,1,1");
s.execute("graph.setType", &json!({"pieLegend": "wedges"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let pie =
series(&n, 0).children().unwrap()[0].geometric_bounds().unwrap().union(series(&n, 1).children().unwrap()[0].geometric_bounds().unwrap());
let labels: Vec<_> = group(&n, "Legend").children().unwrap().iter().map(|c| (**c).clone()).collect();
assert_eq!(labels.len(), 2);
for l in &labels {
let b = l.geometric_bounds().unwrap();
assert!(pie.contains(b.center()), "{b:?} inside {pie:?}");
}
// No swatches beside the pie: each series group holds only its wedge.
assert_eq!((series(&n, 0).children().unwrap().len(), series(&n, 1).children().unwrap().len()), (1, 1));
let v = s.execute("graph.setType", &json!({})).unwrap();
assert_eq!((v["pieLegend"].clone(), v["piePosition"].clone()), (json!("wedges"), json!("even")));
assert!(v.get("legend").is_none(), "a pie's Legend stands for the checkbox");
// The labels are drawn over the wedges, and on the black first wedge in white.
let order: Vec<_> = n.children().unwrap().iter().map(|c| c.name.clone().unwrap_or_default()).collect();
assert_eq!(order.last().map(String::as_str), Some("Legend"), "{order:?}");
let colour = |l: &vectorcraft_doc::Node| match &l.kind {
NodeKind::Text(t) => t.runs[0].style.fill.color().unwrap().to_hex(),
_ => panic!(),
};
assert_eq!((colour(&labels[0]), colour(&labels[1])), ("#ffffff".to_string(), "#000000".to_string()));
// No Legend wins over a legend checkbox sent along (as the dialog does), and Standard comes back without wedges.
s.execute("graph.setType", &json!({"pieLegend": "none", "legend": true})).unwrap();
assert!(!spec(&s, id).legend);
s.execute("graph.setType", &json!({"legend": true})).unwrap();
assert!(!spec(&s, id).pie_legend_in_wedges);
assert!(s.execute("graph.setType", &json!({"pieLegend": "inside"})).is_err());
let path = vectorcraft_testkit::temp_dir("graph-pie").join("pie.vectorcraft");
s.execute("graph.setType", &json!({"pieLegend": "wedges", "piePosition": "stacked", "pieSort": "all"})).unwrap();
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let g = spec(&s, id);
assert_eq!(
(g.pie_legend_in_wedges, g.pie_position, g.pie_sort),
(true, vectorcraft_doc::PiePosition::Stacked, vectorcraft_doc::PieSort::All)
);
let v = serde_json::to_value(GraphSpec::default()).unwrap();
for k in ["pieLegendInWedges", "piePosition", "pieSort"] {
assert!(v.get(k).is_none(), "{k}");
}
}
#[test]
fn stacked_pies_are_rings_so_none_covers_another() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "pie", ",a,b\nSmall,5,5\nBig,20,20\nSame,20,20");
s.execute("graph.setType", &json!({"piePosition": "stacked"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
// Series b's wedges: of the two equal big pies one is left out, the other a ring outside the small pie
// (clear of the centre), and the small pie's whole.
let b: Vec<_> = series(&n, 1).children().unwrap().iter().map(|c| (**c).clone()).collect();
assert_eq!(b.len(), 3, "two wedges and the swatch");
let small = b[1].geometric_bounds().unwrap();
let NodeKind::Path { path, .. } = &b[0].kind else { panic!() };
let c = small.center();
let near: Vec<f64> = path.to_bezpath().elements().iter().filter_map(|e| e.end_point()).map(|p| (p - c).hypot()).collect();
let min = near.iter().copied().fold(f64::MAX, f64::min);
assert!(min > 1.0, "the ring keeps clear of the centre: {near:?}");
// The names are drawn over the pies: after every series group.
let kids = n.children().unwrap();
let last_series = kids.iter().rposition(|c| c.series_index.is_some()).unwrap();
let names: Vec<usize> = kids.iter().enumerate().filter(|(_, c)| matches!(c.kind, NodeKind::Text(_))).map(|(i, _)| i).collect();
assert_eq!(names.len(), 2, "of two equal pies, one has no ring and no name");
assert!(names.iter().all(|i| *i > last_series), "{names:?} after {last_series}");
}
#[test]
fn transpose_keeps_within_the_series_cap_and_drops_stale_axis_series() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let rows: Vec<Vec<f64>> = (0..300).map(|i| vec![i as f64]).collect();
let id = NodeId(
s.execute("graph.create", &json!({"type": "column", "x": 0, "y": 0, "width": 300, "height": 200, "rows": rows})).unwrap()["id"]
.as_u64()
.unwrap(),
);
assert!(s.execute("graph.setData", &json!({"transpose": true})).is_err(), "300 categories can't become series");
assert_eq!(spec(&s, id).cells().len(), 300);
let g = make(&mut s, "column", ",a,b,c\nQ1,1,2,3");
s.execute("graph.setType", &json!({"seriesIndexes": [2], "valueAxis": "right"})).unwrap();
s.execute("graph.setData", &json!({"transpose": true})).unwrap();
assert!(spec(&s, g).right_series.is_empty(), "one series left");
// An all-blank series becomes no category.
let h = make(&mut s, "column", ",a,b\nQ1,1,\nQ2,2,");
s.execute("graph.setData", &json!({"transpose": true})).unwrap();
assert_eq!(spec(&s, h).categories, ["a", "b"], "b keeps its label");
assert!(s.execute("graph.setData", &json!({"id": h.0, "transpose": false})).unwrap().get("csv").is_some(), "no change: the query");
}
#[test]
fn switch_xy_moves_the_labels_with_their_columns() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "scatter", ",y1,x1,y2\nP,1,10,2,20");
s.execute("graph.setData", &json!({"switchXY": true})).unwrap();
let g = spec(&s, id);
assert_eq!(g.series, ["x1", "y1", "", "y2"]);
assert_eq!(g.cells(), [[Some(10.0), Some(1.0), Some(20.0), Some(2.0)]]);
}
#[test]
fn a_label_of_only_bars_is_blank_and_non_pie_graphs_list_no_pie_options() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "column", ",||,b\nQ1,1,2");
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert_eq!(group(&n, "Legend").children().unwrap().len(), 1, "only b");
let v = s.execute("graph.setType", &json!({})).unwrap();
assert!(v.get("pieLegend").is_none() && v.get("legend").is_some());
// Bar and radar labels with a `|` centre their lines on the category.
for ty in ["bar", "radar"] {
let g = make(&mut s, ty, ",a\nOne|Two|Three,1\nQ2,2\nQ3,3");
assert!(axis_texts(&s, g).contains(&"One\nTwo\nThree".to_string()), "{ty}");
}
}
#[test]
fn drop_shadows_sit_behind_the_columns_lines_and_wedges() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let kids = n.children().unwrap();
let at = kids.iter().position(|c| c.name.as_deref() == Some("Drop Shadow")).unwrap();
assert!(kids.iter().position(|c| c.series_index.is_some()).unwrap() > at, "behind the series");
let shadow = &kids[at];
assert!((shadow.opacity - 0.35).abs() < 1e-6);
let copies: Vec<_> = shadow.children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect();
let cols: Vec<_> = (0..2).flat_map(|i| marks(&s, id, i)).collect();
assert_eq!(copies.len(), cols.len(), "one per column, none for the swatches");
for c in &cols {
assert!(copies.iter().any(|b| (b.x0 - c.x0 - 2.0).abs() < 1e-6 && (b.y0 - c.y0 - 2.0).abs() < 1e-6), "{c:?}");
}
let black = shadow.children().unwrap().iter().all(|c| c.appearance.fill_paint().color().map(|c| c.to_hex()) == Some("#000000".into()));
assert!(black);
// Lines cast one too (not their markers); pies one per wedge.
let line = graph(&mut s, "line");
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
let n = s.doc().unwrap().doc.node(line).unwrap().clone();
assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 2, "one per series line");
let pie = graph(&mut s, "pie");
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
let n = s.doc().unwrap().doc.node(pie).unwrap().clone();
assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 5, "the wedges with a value above 0");
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["dropShadow"], true);
s.execute("graph.setType", &json!({"dropShadow": false})).unwrap();
let n = s.doc().unwrap().doc.node(pie).unwrap().clone();
assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Drop Shadow")));
}
#[test]
fn the_legend_across_top_runs_in_rows_above_the_plot() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = graph(&mut s, "column");
s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap();
let swatch = |s: &Session, i: u32| series_parts(s, id, i).last().unwrap().geometric_bounds().unwrap();
let (a, b) = (swatch(&s, 0), swatch(&s, 1));
assert!(a.y1 <= 100.0 - 8.0 + 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && b.x0 > a.x1 && (a.x0 - 100.0).abs() < 1e-6, "{a:?} {b:?}");
assert_eq!(s.execute("graph.setType", &json!({})).unwrap()["legendAcrossTop"], true);
// Long labels wrap into rows within the plot's width, the last row just above it.
let labels: Vec<String> = (0..6).map(|i| format!("A rather long series name {i}")).collect();
let csv = format!(",{}\nQ1,1,2,3,4,5,6", labels.join(","));
s.execute("graph.setData", &json!({"id": id.0, "csv": csv})).unwrap();
let ys: Vec<f64> = (0..6).map(|i| swatch(&s, i).y0).collect();
assert!(ys.windows(2).any(|w| w[1] > w[0]), "a second row: {ys:?}");
assert!(ys.iter().all(|y| *y + 8.0 <= 100.0 - 8.0 + 1e-6), "{ys:?}");
let xs: Vec<f64> = (0..6).map(|i| swatch(&s, i).x1).collect();
assert!(xs.iter().all(|x| *x <= 400.0), "{xs:?}");
let path = vectorcraft_testkit::temp_dir("graph-top").join("top.vectorcraft");
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
s.execute("document.save", &json!({"path": path})).unwrap();
s.execute("document.open", &json!({"path": path})).unwrap();
let g = spec(&s, id);
assert!(g.legend_across_top && g.drop_shadow);
let v = serde_json::to_value(GraphSpec::default()).unwrap();
assert!(v.get("dropShadow").is_none() && v.get("legendAcrossTop").is_none());
}
#[test]
fn each_layer_keeps_its_shadows_just_behind_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "column", ",a,b,c\nQ1,3,2,1\nQ2,5,,2\nQ3,4,6,3");
s.execute("graph.setType", &json!({"seriesIndexes": [0], "type": "area"})).unwrap();
s.execute("graph.setType", &json!({"seriesIndexes": [2], "type": "line", "dropShadow": true})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let order: Vec<String> =
n.children().unwrap().iter().map(|c| c.series_index.map_or_else(|| c.name.clone().unwrap_or_default(), |i| format!("s{i}"))).collect();
// Areas cast none; the columns' shadows sit between the area and the columns, the line's between the columns
// and the line.
let at = |k: &str| order.iter().position(|o| o == k).unwrap();
let shadows: Vec<usize> = order.iter().enumerate().filter(|(_, o)| *o == "Drop Shadow").map(|(i, _)| i).collect();
assert_eq!(shadows.len(), 2, "{order:?}");
assert!(at("s0") < shadows[0] && shadows[0] < at("s1") && at("s1") < shadows[1] && shadows[1] < at("s2"), "{order:?}");
// b's blank leaves two columns; a line with a blank casts a shadow per run.
let line = make(&mut s, "line", ",a\nQ1,1\nQ2,\nQ3,3\nQ4,4");
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
let n = s.doc().unwrap().doc.node(line).unwrap().clone();
assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 1, "Q1 alone draws no line; Q3–Q4 one");
// Scatter and radar lines cast one too; a stacked segment of 0 doesn't.
for (ty, want) in [("scatter", 1), ("radar", 2)] {
let g = graph(&mut s, ty);
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
let n = s.doc().unwrap().doc.node(g).unwrap().clone();
assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), want, "{ty}");
}
let st = make(&mut s, "stackedColumn", ",a,b\nQ1,0,2");
s.execute("graph.setType", &json!({"dropShadow": true})).unwrap();
let n = s.doc().unwrap().doc.node(st).unwrap().clone();
assert_eq!(group(&n, "Drop Shadow").children().unwrap().len(), 1);
}
#[test]
fn the_legend_across_top_keeps_above_labels_over_the_plot() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
let id = make(&mut s, "radar", ",a,b\nTop|of|chart,1,2\nQ2,2,3\nQ3,3,1");
s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let axes_top = group(&n, "Axes").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y0).fold(f64::MAX, f64::min);
let legend_bottom =
group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).map(|b| b.y1).fold(f64::MIN, f64::max);
assert!(legend_bottom < axes_top, "{legend_bottom} above {axes_top}");
// No legend: nothing across the top either.
s.execute("graph.setType", &json!({"legend": false})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
assert!(n.children().unwrap().iter().all(|c| c.name.as_deref() != Some("Legend")));
// Labels of a row other than the last stay within the plot's width.
let labels: Vec<String> = (0..6).map(|i| format!("A rather long series name {i}")).collect();
let col = make(&mut s, "column", &format!(",{}\nQ1,1,2,3,4,5,6", labels.join(",")));
s.execute("graph.setType", &json!({"legendAcrossTop": true})).unwrap();
let n = s.doc().unwrap().doc.node(col).unwrap().clone();
let texts: Vec<_> = group(&n, "Legend").children().unwrap().iter().filter_map(|c| c.geometric_bounds()).collect();
let last_row = texts.iter().map(|b| b.y0).fold(f64::MIN, f64::max);
assert!(texts.iter().filter(|b| b.y0 < last_row - 1e-6).all(|b| b.x1 <= 400.0 + 1e-6), "{texts:?}");
}
}