Merge pull request #1115 from IHAGI-c/feat/graph-pie-data
Graph Data: Transpose and Switch x/y; | line breaks in labels; pie Legend, Position and Sort
This commit is contained in:
commit
b8aa326930
|
|
@ -133,6 +133,46 @@ impl crate::Document {
|
|||
}
|
||||
}
|
||||
|
||||
/// Graph Type › Position for pie graphs with several pies: the same size (Even), sized by their totals (Ratio), or
|
||||
/// stacked one on another, sized by their totals (Stacked).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PiePosition {
|
||||
#[default]
|
||||
Even,
|
||||
Ratio,
|
||||
Stacked,
|
||||
}
|
||||
|
||||
/// Graph Type › Sort for pie graphs: wedges in data order (None), largest first in each pie (All), or in the first
|
||||
/// pie's largest-first order in every pie (First).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PieSort {
|
||||
#[default]
|
||||
None,
|
||||
All,
|
||||
First,
|
||||
}
|
||||
|
||||
macro_rules! ids {
|
||||
($t:ty { $($v:ident => $id:literal),* }) => {
|
||||
impl $t {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self { $(Self::$v => $id),* }
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
[$(Self::$v),*].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
|
||||
}
|
||||
fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
ids!(PiePosition { Even => "even", Ratio => "ratio", Stacked => "stacked" });
|
||||
ids!(PieSort { None => "none", All => "all", First => "first" });
|
||||
|
||||
/// Graph Type › Value Axis: which side of the plot the value axis is drawn on.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
|
@ -253,6 +293,14 @@ pub struct GraphSpec {
|
|||
pub right_axis_min: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub right_axis_max: Option<f64>,
|
||||
/// Pie graphs: Legends in Wedges (the series labels inside their wedges instead of a legend beside the pies),
|
||||
/// Position and Sort.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub pie_legend_in_wedges: bool,
|
||||
#[serde(skip_serializing_if = "PiePosition::is_default")]
|
||||
pub pie_position: PiePosition,
|
||||
#[serde(skip_serializing_if = "PieSort::is_default")]
|
||||
pub pie_sort: PieSort,
|
||||
/// Object › Graph › Marker: the graph design each series' data points (and legend swatch) are drawn with, by
|
||||
/// series index, for line, scatter and radar series; `None` and missing entries draw the default square.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
|
|
@ -360,6 +408,9 @@ impl Default for GraphSpec {
|
|||
right_axis_min: None,
|
||||
right_axis_max: None,
|
||||
series_markers: vec![],
|
||||
pie_legend_in_wedges: false,
|
||||
pie_position: PiePosition::Even,
|
||||
pie_sort: PieSort::None,
|
||||
tick_length: TickLength::Short,
|
||||
tick_marks: 1,
|
||||
right_tick_length: TickLength::Short,
|
||||
|
|
|
|||
|
|
@ -69,7 +69,9 @@ pub use appearance::{
|
|||
};
|
||||
pub use assets::ExportAsset;
|
||||
pub use corners::LiveCorners;
|
||||
pub use graph::{GraphDesign, GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint, TickLength, ValueAxisSide};
|
||||
pub use graph::{
|
||||
GraphDesign, GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, PiePosition, PieSort, SeriesPaint, TickLength, ValueAxisSide,
|
||||
};
|
||||
pub use hit::{Hit, HitKind};
|
||||
pub use links::{LinkInfo, PlacementOptions};
|
||||
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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} replace the graph's data; no data → the current {csv, series, categories, rows}",
|
||||
"{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
|
||||
),
|
||||
|
|
@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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} 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",
|
||||
"{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, 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
|
||||
),
|
||||
|
|
@ -178,7 +178,12 @@ fn apply_data(g: &mut GraphSpec, p: &Value) -> bool {
|
|||
);
|
||||
changed = true;
|
||||
}
|
||||
// Drop rows with neither a value nor a label; a label-only row stays, its values blank.
|
||||
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()))
|
||||
|
|
@ -190,7 +195,6 @@ fn apply_data(g: &mut GraphSpec, p: &Value) -> bool {
|
|||
g.categories.retain(|_| k.next().copied().unwrap_or(true));
|
||||
g.set_cells(cells);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn fmt_value(v: f64) -> String {
|
||||
|
|
@ -307,6 +311,13 @@ impl Gen<'_> {
|
|||
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());
|
||||
|
|
@ -329,6 +340,22 @@ impl Gen<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
|
|
@ -413,35 +440,110 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
|||
// 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![];
|
||||
match g.kind {
|
||||
GraphKind::Pie => {
|
||||
// One pie per row, side by side; a wedge per series, clockwise from 12 o'clock.
|
||||
let w = r.width() / ncat as f64;
|
||||
let rad = (w.min(r.height()) / 2.0 * 0.9).max(1.0);
|
||||
for c in 0..ncat {
|
||||
let centre = Point::new(r.x0 + w * (c as f64 + 0.5), r.y0 + r.height() / 2.0);
|
||||
let total: f64 = (0..nser).map(|s| val(c, s).max(0.0)).sum();
|
||||
// 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, items) in series.iter_mut().enumerate() {
|
||||
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();
|
||||
bp.move_to(centre);
|
||||
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);
|
||||
bp.line_to(centre + vectorcraft_geom::Vec2::new(a0.cos(), a0.sin()) * rad);
|
||||
arc.to_cubic_beziers(0.1, |p1, p2, p| bp.curve_to(p1, p2, p));
|
||||
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, w) = mark(s, true, Paint::solid(Color::WHITE), 0.5);
|
||||
let n = b.path(PathData::from_bezpath(&bp), fill, stroke, w);
|
||||
items.push(n);
|
||||
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);
|
||||
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()) {
|
||||
let t = b.text(Point::new(centre.x, centre.y + rad + LABEL_SIZE * 1.6), cat, Justify::Center);
|
||||
b.out.push(t);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -458,7 +560,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
|||
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.text(Point::new(p.x, p.y + LABEL_SIZE * 0.35), cat, Justify::Center));
|
||||
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) {
|
||||
|
|
@ -646,9 +748,9 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
|||
for c in 0..ncat {
|
||||
if let Some(cat) = g.categories.get(c).filter(|c| !c.is_empty()) {
|
||||
let t = if horizontal {
|
||||
b.text(Point::new(r.x0 - 6.0, cat_mid(c) + LABEL_SIZE * 0.35), cat, Justify::Right)
|
||||
b.label(Point::new(r.x0 - 6.0, cat_mid(c) + LABEL_SIZE * 0.35), cat, Justify::Right, true)
|
||||
} else {
|
||||
b.text(Point::new(cat_mid(c), r.y1 + LABEL_SIZE * 1.4), cat, Justify::Center)
|
||||
b.label(Point::new(cat_mid(c), r.y1 + LABEL_SIZE * 1.4), cat, Justify::Center, false)
|
||||
};
|
||||
axes.push(t);
|
||||
}
|
||||
|
|
@ -819,19 +921,24 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
|||
// 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![];
|
||||
if g.legend && !g.series.is_empty() {
|
||||
if g.legend && !g.series.is_empty() && !(g.kind == GraphKind::Pie && g.pie_legend_in_wedges) {
|
||||
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) };
|
||||
if label.is_empty() {
|
||||
let row_y = y;
|
||||
if blank_label(&label) {
|
||||
y += LABEL_SIZE * 1.8;
|
||||
continue;
|
||||
}
|
||||
let y = r.y0 + s as f64 * (LABEL_SIZE * 1.8);
|
||||
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.text(Point::new(x + 12.0, y + 7.5), &label, Justify::Left));
|
||||
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.
|
||||
|
|
@ -850,6 +957,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
|
|||
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);
|
||||
|
|
@ -1020,10 +1129,61 @@ pub(crate) fn capture_series_paints(doc: &mut Document, ids: &[NodeId], fill: bo
|
|||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let id = target(s, p, "graph.setData")?;
|
||||
const C: &str = "graph.setData";
|
||||
let id = target(s, p, C)?;
|
||||
let mut spec = spec_of(s, id)?;
|
||||
if !apply_data(&mut spec, p) {
|
||||
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() }));
|
||||
}
|
||||
|
|
@ -1313,6 +1473,9 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"suffix",
|
||||
"rightPrefix",
|
||||
"rightSuffix",
|
||||
"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.
|
||||
|
|
@ -1325,7 +1488,7 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Some(r) if sides.iter().all(|x| x == r) => Some(if *r { "right" } else { "left" }),
|
||||
Some(_) => None,
|
||||
};
|
||||
return Ok(json!({
|
||||
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,
|
||||
|
|
@ -1335,7 +1498,24 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
"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),
|
||||
}));
|
||||
});
|
||||
// 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![] };
|
||||
|
|
@ -1390,6 +1570,13 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
*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)]
|
||||
{
|
||||
|
|
@ -1408,6 +1595,15 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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);
|
||||
// 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);
|
||||
|
|
@ -2827,4 +3023,245 @@ mod tests {
|
|||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ fn choices(command: &str, key: &str) -> Option<form::Choices> {
|
|||
("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN),
|
||||
("graph.setType", "valueAxis") => Some(GRAPH_VALUE_AXIS),
|
||||
("graph.setType", "tickLength" | "rightTickLength" | "categoryTickLength") => Some(GRAPH_TICK_LENGTH),
|
||||
("graph.setType", "pieLegend") => Some(PIE_LEGEND),
|
||||
("graph.setType", "piePosition") => Some(PIE_POSITION),
|
||||
("graph.setType", "pieSort") => Some(PIE_SORT),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +65,11 @@ const VERTICAL_ALIGN: form::Choices = &[("Top", "top"), ("Center", "center"), ("
|
|||
/// Graph Type › Value Axis (series picked on both axes show none, and OK leaves each where it is).
|
||||
const GRAPH_VALUE_AXIS: form::Choices = &[("On Left Side", "left"), ("On Right Side", "right"), ("On Both Sides", "both")];
|
||||
|
||||
/// Graph Type › Legend, Position and Sort for pie graphs.
|
||||
const PIE_LEGEND: form::Choices = &[("No Legend", "none"), ("Standard Legend", "standard"), ("Legends in Wedges", "wedges")];
|
||||
const PIE_POSITION: form::Choices = &[("Ratio", "ratio"), ("Even", "even"), ("Stacked", "stacked")];
|
||||
const PIE_SORT: form::Choices = &[("All", "all"), ("First", "first"), ("None", "none")];
|
||||
|
||||
/// Graph Type › Tick Marks › Length.
|
||||
const GRAPH_TICK_LENGTH: form::Choices = &[("None", "none"), ("Short", "short"), ("Full Width", "full")];
|
||||
|
||||
|
|
|
|||
|
|
@ -1110,6 +1110,12 @@
|
|||
Delete Design Design löschen
|
||||
Paste Design Design einfügen
|
||||
Select Unused Nicht verwendete auswählen
|
||||
No Legend Keine Legende
|
||||
Standard Legend Standardlegende
|
||||
Legends in Wedges Legenden in Segmenten
|
||||
Ratio Verhältnis
|
||||
Even Gleich
|
||||
Stacked Gestapelt
|
||||
Graphic Style Grafikstil
|
||||
Graphic Style Libraries Grafikstil-Bibliotheken
|
||||
Graphic Style Libraries Menu Menü „Grafikstil-Bibliotheken“
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1127,6 +1127,12 @@
|
|||
Delete Design Eliminar diseño
|
||||
Paste Design Pegar diseño
|
||||
Select Unused Seleccionar no utilizados
|
||||
No Legend Sin leyenda
|
||||
Standard Legend Leyenda estándar
|
||||
Legends in Wedges Leyendas en cuñas
|
||||
Ratio Proporción
|
||||
Even Igual
|
||||
Stacked Apilado
|
||||
Graphic Style Estilo gráfico
|
||||
Graphic Style Libraries Bibliotecas de estilos gráficos
|
||||
Graphic Style Libraries Menu Menú Bibliotecas de estilos gráficos
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1126,6 +1126,12 @@
|
|||
Delete Design Supprimer la conception
|
||||
Paste Design Coller la conception
|
||||
Select Unused Sélectionner les éléments inutilisés
|
||||
No Legend Aucune légende
|
||||
Standard Legend Légende standard
|
||||
Legends in Wedges Légendes dans les secteurs
|
||||
Ratio Proportionnel
|
||||
Even Égal
|
||||
Stacked Empilé
|
||||
Graphic Style Style graphique
|
||||
Graphic Style Libraries Bibliothèques de styles graphiques
|
||||
Graphic Style Libraries Menu Menu Bibliothèques de styles graphiques
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1126,6 +1126,12 @@
|
|||
Delete Design Elimina design
|
||||
Paste Design Incolla design
|
||||
Select Unused Seleziona non utilizzati
|
||||
No Legend Nessuna legenda
|
||||
Standard Legend Legenda standard
|
||||
Legends in Wedges Legende negli spicchi
|
||||
Ratio Proporzione
|
||||
Even Uguale
|
||||
Stacked In pila
|
||||
Graphic Style Stile grafico
|
||||
Graphic Style Libraries Librerie stili grafici
|
||||
Graphic Style Libraries Menu Menu Librerie stili grafici
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2990,6 +2990,12 @@
|
|||
Delete Design デザインを削除
|
||||
Paste Design デザインをペースト
|
||||
Select Unused 未使用を選択
|
||||
No Legend 凡例なし
|
||||
Standard Legend 標準の凡例
|
||||
Legends in Wedges くさび形内に凡例
|
||||
Ratio 比率
|
||||
Even 均等
|
||||
Stacked 積み重ね
|
||||
Graphic Style Libraries Menu グラフィックスタイルライブラリメニュー
|
||||
Graphics グラフィックス
|
||||
Greek & Cyrillic ギリシャ文字とキリル文字
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1124,6 +1124,12 @@
|
|||
Delete Design Удалить дизайн
|
||||
Paste Design Вставить дизайн
|
||||
Select Unused Выбрать неиспользуемые
|
||||
No Legend Без легенды
|
||||
Standard Legend Стандартная легенда
|
||||
Legends in Wedges Легенды в секторах
|
||||
Ratio Пропорционально
|
||||
Even Одинаково
|
||||
Stacked Стопкой
|
||||
Graphic Style Графический стиль
|
||||
Graphic Style Libraries Библиотеки графических стилей
|
||||
Graphic Style Libraries Menu Меню «Библиотеки графических стилей»
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1120,6 +1120,12 @@
|
|||
Delete Design Видалити дизайн
|
||||
Paste Design Вставити дизайн
|
||||
Select Unused Вибрати невикористані
|
||||
No Legend Без легенди
|
||||
Standard Legend Стандартна легенда
|
||||
Legends in Wedges Легенди в секторах
|
||||
Ratio Пропорційно
|
||||
Even Однаково
|
||||
Stacked Стосом
|
||||
Graphic Style Графічний стиль
|
||||
Graphic Style Libraries Бібліотеки графічних стилів
|
||||
Graphic Style Libraries Menu Меню "Бібліотеки графічних стилів"
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 13.
|
|
|
@ -1077,6 +1077,12 @@
|
|||
Delete Design 删除设计
|
||||
Paste Design 粘贴设计
|
||||
Select Unused 选择未使用项
|
||||
No Legend 无图例
|
||||
Standard Legend 标准图例
|
||||
Legends in Wedges 楔形中的图例
|
||||
Ratio 比例
|
||||
Even 平均
|
||||
Stacked 堆积
|
||||
Graphic Style 图形样式
|
||||
Graphic Style Libraries 图形样式库
|
||||
Graphic Style Libraries Menu 图形样式库菜单
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -1101,6 +1101,12 @@
|
|||
Delete Design 刪除設計
|
||||
Paste Design 貼上設計
|
||||
Select Unused 選取未使用項目
|
||||
No Legend 無圖例
|
||||
Standard Legend 標準圖例
|
||||
Legends in Wedges 楔形中的圖例
|
||||
Ratio 比例
|
||||
Even 平均
|
||||
Stacked 堆疊
|
||||
Graphic Style 繪圖樣式
|
||||
Graphic Style Libraries 繪圖樣式資料庫
|
||||
Graphic Style Libraries Menu 繪圖樣式資料庫選單
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -59,7 +59,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V
|
|||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; initial Revolve only; SVG Filters, remaining raster effects and 3D pending | 81–130 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
| M11 | Artboards & views (artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator done; multiple windows, presentation polish) | 🟡 | 15–25 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs done; graph Column designs pending | 6–10 |
|
||||
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (13 presets, 18 ms/1k² image; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `|` label line breaks and the pie options done; graph Column designs, Import Data and the data sheet's cell style pending | 6–10 |
|
||||
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 18–27 |
|
||||
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor (chords with Shift and a punctuation key fire as typed), workspaces, a custom title bar on Windows/Linux with a Home button, content-sized dialogs, menus that scroll when longer than the window and macOS native menu dismissal done (M14.8), and native macOS save sheets with a Format menu (#866); an experimental, unsupported Windows 7 x64 portable OpenGL build (not verified by maintainers); accessibility and broader Windows/Linux packaging/QA pending | 18–28 |
|
||||
| — | Interaction fidelity pass (every tool's modifiers, Properties panel per context, isolation, nuance) | ⬜ | 60–90 |
|
||||
|
|
@ -79,6 +79,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | M12: Graph Data's Transpose and Switch x/y, `|` line breaks in graph labels, pie Legend/Position/Sort. Scores/hours unchanged. |
|
||||
| 2026-10-11 | minor | M12: Object › Graph › Design… and Marker… (graph designs, marker designs); column designs remain. Scores/hours unchanged. |
|
||||
| 2026-10-12 | minor | M14: native macOS save Format menu (#866); filename regression tests, actual SVG save and PDF selection/cancel verified; estimates unchanged |
|
||||
| 2026-10-12 | minor | M6: compound paths inherit the backmost appearance; selected members read and change the compound fill rule (#1197); estimates unchanged |
|
||||
|
|
|
|||
|
|
@ -414,7 +414,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
|||
- **Live Corners:** drag a live rectangle's corner widgets (Selection or Direct Selection) to round all corners, with a radius readout; with Direct Selection, select corner anchors to show and round just those corners (#381). Each corner keeps its own radius and kind (Round, Inverted Round, Chamfer): Alt-click a widget to cycle the kind, double-click it for the Corners dialog, and the Properties and Transform radius fields set the selected corners (`object.setLiveShape {corners, radius, kind}`). Corners stay circular when the rectangle is scaled unevenly or along its own sides when rotated (the radius keeps its size, or scales by the mean scale with Scale Corners on); only a shear distorts them. On a rectangle that isn't square every corner is the same circle: a radius past half the shorter side stops there for all of them, the widgets sit on the corners as drawn and turn the edited corners red at that limit, CSS export writes the drawn radii, and rectangles saved stretched by older versions get circular corners when their corners are next set (#442). Live Corners work on any path's corners too, as on a rectangle's (#511): a star's, a live polygon's (which also shows them to the Selection tool, has a Corner Radius field in the Properties and Transform panels and keeps its radius as its sides change) and a pen path's, wherever two sides meet at an angle at an anchor that isn't smooth: curved sides too, trimmed to what the cut leaves of them (#1144). Direct Selection shows the widgets of every corner, or of the corners holding selected anchors; each corner is a circle tangent to both sides, drawn no larger than takes its cut halfway along its shorter side; a path keeps its uncut outline so its corners stay editable, and the same command, dialog, angle preference and View › Hide Corner Widget apply.
|
||||
- **Pen:** Alt pressed in the middle of dragging out an anchor splits its handles and keeps the curve already shaped into it (only the outgoing handle follows the pointer); Space held while dragging out an anchor moves it, handles and all. A press on the first anchor of the path being drawn closes it whatever the drag then does: the drag shapes the closing curve (symmetric handles, Alt breaks them) and the release closes the path wherever it ends (#501). Clicking the last anchor of the path being drawn retracts its outgoing handle (convert cursor), and dragging from it pulls out a new one on its own. Auto Add/Delete: over a selected path, a click on a segment adds an anchor and a click on an anchor deletes it (add and delete cursors); Shift held, or Preferences › General › Disable Auto Add/Delete, starts a new path instead. Alt held over a handle end or an anchor of a selected path (the one being drawn too) works as the Anchor Point tool, with its convert cursor: dragging a handle moves it alone, clicking a smooth anchor makes it a corner, dragging an anchor pulls out new symmetric handles, dragging a segment reshapes it; drawing then goes on. Cmd (Ctrl) held drags with the selection tool chosen last, Direct Selection until one is (handles, anchors and segments of the path being drawn or another), and the Pen then goes on drawing its path while that stays selected; the pointer shows that tool's while Cmd is held. The same holds for agents (`pointer_gesture` with `mods.cmd`), as the borrowing now lives in the engine (#494).
|
||||
- **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path).
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Column designs remain open.
|
||||
- **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Column designs, Import Data and the Graph Data sheet's cell style remain open.
|
||||
- **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View.
|
||||
- **Editing type:** double-clicking type with a selection tool switches to the Type tool with the caret where it was clicked.
|
||||
- **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool.
|
||||
|
|
@ -467,6 +467,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Graph Data's Transpose and Switch x/y, `|` line breaks in graph labels, and the pie graph options (Legends in Wedges, Position, Sort). Scores unchanged within rounding |
|
||||
| 2026-10-11 | minor | Graph designs and marker designs (Object › Graph › Design… and Marker…): menu stubs 27 → 25; the graphs row's missing items are column designs and Image Trace depth. Scores unchanged within rounding |
|
||||
| 2026-10-12 | minor | UI chrome: native macOS save sheets gain a Format menu (#866), with extension regression tests and actual save/cancel verification; score and hours unchanged (estimated) |
|
||||
| 2026-10-12 | minor | Compound-path appearance and fill-rule edits through directly selected members (#1197) verified with rendered holes and Undo/Redo; scores and hours unchanged |
|
||||
|
|
|
|||
Loading…
Reference in New Issue