Merge pull request #971 from IHAGI-c/fix/graph-value-axes

Graph Type: value axis on the left, right or both sides; separate scales
This commit is contained in:
BFlat 2026-10-10 22:35:33 +07:00 committed by GitHub
commit 6794385731
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 501 additions and 66 deletions

File diff suppressed because one or more lines are too long

View File

@ -80,6 +80,32 @@ impl GraphKind {
}
}
/// Graph Type › Value Axis: which side of the plot the value axis is drawn on.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ValueAxisSide {
#[default]
Left,
Right,
Both,
}
impl ValueAxisSide {
pub fn id(self) -> &'static str {
match self {
ValueAxisSide::Left => "left",
ValueAxisSide::Right => "right",
ValueAxisSide::Both => "both",
}
}
pub fn parse(s: &str) -> Option<Self> {
[ValueAxisSide::Left, ValueAxisSide::Right, ValueAxisSide::Both].into_iter().find(|k| k.id().eq_ignore_ascii_case(s))
}
fn is_left(&self) -> bool {
*self == ValueAxisSide::Left
}
}
/// The fill and stroke captured from one series' generated marks. An absent paint keeps whatever
/// the generator draws for that part (greyscale for a series fill, the part's own stroke otherwise).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@ -127,6 +153,24 @@ pub struct GraphSpec {
pub ticks: usize,
pub axis_min: Option<f64>,
pub axis_max: Option<f64>,
/// Graph Type › Value Axis side. Column, stacked column, line, area and scatter graphs (the bar graphs' value axis
/// runs along the bottom and stays there).
#[serde(skip_serializing_if = "ValueAxisSide::is_left")]
pub value_axis: ValueAxisSide,
/// Graph Type › Separate Scales: with the value axis on both sides, the series on the right axis
/// ([`Self::right_series`]) get a scale of their own there.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub separate_scales: bool,
/// Indexes of the series assigned to the right value axis.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub right_series: Vec<usize>,
/// The right value axis' tick values, used with separate scales: tick count (0 = automatic) and min/max override.
#[serde(skip_serializing_if = "is_zero")]
pub right_ticks: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub right_axis_min: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub right_axis_max: Option<f64>,
/// Line, scatter and radar graphs: mark data points, connect them.
pub mark_points: bool,
pub connect_points: bool,
@ -140,6 +184,10 @@ pub struct GraphSpec {
pub placed: Option<Rect>,
}
fn is_zero(n: &usize) -> bool {
*n == 0
}
/// Edge-to-Edge Lines for a graph saved before the option existed: on, as it was drawn then.
fn edge_to_edge_before_the_option() -> bool {
true
@ -189,6 +237,12 @@ impl Default for GraphSpec {
ticks: 0,
axis_min: None,
axis_max: None,
value_axis: ValueAxisSide::Left,
separate_scales: false,
right_series: vec![],
right_ticks: 0,
right_axis_min: None,
right_axis_max: None,
mark_points: true,
connect_points: true,
edge_to_edge: false,

View File

@ -65,7 +65,7 @@ pub use appearance::{
};
pub use assets::ExportAsset;
pub use corners::LiveCorners;
pub use graph::{GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint};
pub use graph::{GraphKind, GraphSpec, MAX_GRAPH_CATEGORIES, MAX_GRAPH_SERIES, SeriesPaint, ValueAxisSide};
pub use hit::{Hit, HitKind};
pub use links::{LinkInfo, PlacementOptions};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};

View File

@ -10,7 +10,7 @@ use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Document, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind, TextObject};
use vectorcraft_doc::{Appearance, CharStyle, Document, GraphKind, GraphSpec, Justify, Node, NodeId, NodeKind, TextObject, ValueAxisSide};
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, shapes};
use super::typecmd::refresh_bounds;
@ -41,7 +41,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?} change the graph type and options; with `seriesIndexes`, or (no `id`) with only series selected with Group Selection, `type` goes to those series only (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); 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?} 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
),
@ -217,20 +217,20 @@ fn nice_axis(lo: f64, hi: f64, ticks: usize) -> (f64, f64, f64) {
((lo / step).floor() * step, (hi / step).ceil() * step, step)
}
/// The value axis: with both `axis_min` and `axis_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(g: &GraphSpec, lo: f64, hi: f64) -> (f64, f64, f64) {
match (g.axis_min, g.axis_max) {
/// 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 g.ticks == 0 { 5 } else { g.ticks.clamp(1, 100) } as f64;
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(g.axis_min.unwrap_or(lo), g.axis_max.unwrap_or(hi), g.ticks), nice_axis(lo, hi, g.ticks)]
[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))
@ -327,6 +327,8 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
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;
match g.kind {
GraphKind::Pie => {
@ -363,7 +365,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
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, 0.0, g.rows.iter().flatten().copied().fold(0.0, f64::max));
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))
@ -397,58 +399,112 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
}
}
_ => {
// Value range (stacked graphs stack positives and negatives separately).
let (mut lo, mut hi) = (0.0f64, 0.0f64);
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 {
// 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| 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());
}
for v in (0..nser).filter(|s| matches!(kind(*s), GraphKind::Column | GraphKind::Bar | GraphKind::Line)).filter_map(|s| cell(c, s))
{
hi = hi.max(v);
lo = lo.min(v);
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 = hi.max(y);
lo = lo.min(y);
hi[0] = hi[0].max(y);
lo[0] = lo[0].min(y);
xlo = xlo.min(x);
xhi = xhi.max(x);
}
}
}
}
let (lo, hi, step) = value_axis(g, lo, hi);
// Value → coordinate along the value axis (y for columns/lines, x for bars).
let vpos = |v: f64| {
// 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 axis with ticks and labels.
for v in tick_values(lo, hi, step) {
let q = vpos(v);
if horizontal {
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(v), Justify::Center));
} else {
axes.push(b.line(Point::new(r.x0 - 4.0, q), Point::new(r.x0, q)));
axes.push(b.text(Point::new(r.x0 - 6.0, q + LABEL_SIZE * 0.35), &fmt_value(v), Justify::Right));
// 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) };
if on_left_side {
let (lo, hi, step) = left;
for v in tick_values(lo, hi, step) {
let q = vpos(0, v);
if horizontal {
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(v), Justify::Center));
} else {
axes.push(b.line(Point::new(r.x0 - 4.0, q), Point::new(r.x0, q)));
axes.push(b.text(Point::new(r.x0 - 6.0, q + LABEL_SIZE * 0.35), &fmt_value(v), Justify::Right));
}
}
}
if on_right_side {
let (lo, hi, step) = right;
for v in tick_values(lo, hi, step) {
let q = vpos(1, v);
axes.push(b.line(Point::new(r.x1, q), Point::new(r.x1 + 4.0, q)));
let t = b.text(Point::new(r.x1 + 6.0, q + LABEL_SIZE * 0.35), &fmt_value(v), Justify::Left);
right_edge = t.geometric_bounds().map_or(right_edge, |bb| right_edge.max(bb.x1));
axes.push(t);
}
}
if horizontal {
axes.push(b.line(Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)));
axes.push(b.line(Point::new(vpos(0.0f64.clamp(lo, hi)), r.y0), Point::new(vpos(0.0f64.clamp(lo, hi)), r.y1)));
axes.push(b.line(Point::new(zero(0), r.y0), Point::new(zero(0), r.y1)));
} else {
axes.push(b.line(Point::new(r.x0, r.y0), Point::new(r.x0, r.y1)));
axes.push(b.line(Point::new(r.x0, vpos(0.0f64.clamp(lo, hi))), Point::new(r.x1, vpos(0.0f64.clamp(lo, hi)))));
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() };
@ -486,7 +542,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
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(y))),
(Some(y), Some(x)) => Some(Point::new(r.x0 + (x - xl) / (xh - xl) * r.width(), vpos(0, y))),
_ => None,
})
.collect();
@ -510,7 +566,10 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
// 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();
let slots = columns.len() + usize::from(!stacks.is_empty());
// 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);
@ -523,22 +582,28 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
let Some(v) = cell(c, s) else { continue };
let a = c0 + slot * i as f64 + (slot - bar) / 2.0;
let (fill, stroke, w) = mark(s, true, Paint::None, 0.0);
let n = b.path(shapes::rectangle(bar_rect(a, bar, vpos(0.0f64.clamp(lo, hi)), vpos(v))), fill, stroke, w);
let n = b.path(shapes::rectangle(bar_rect(a, bar, zero(axis(s)), vpos(axis(s), v))), fill, stroke, w);
if let Some(items) = series.get_mut(s) {
items.push(n);
}
}
let (a, w) = if columns.is_empty() {
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 {
(c0 + slot * columns.len() as f64 + (slot - bar) / 2.0, bar)
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)
}
};
let (mut pos, mut neg) = (0.0, 0.0);
// 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 base = if v >= 0.0 { &mut pos } else { &mut neg };
let (v0, v1) = (vpos(*base), vpos(*base + v));
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);
@ -547,24 +612,26 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
}
}
}
// Cumulative area bands, each from the previous area total up to its own.
let mut below: Vec<f64> = vec![0.0; ncat];
// 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(v.get(c).copied().unwrap_or(0.0)));
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)));
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;
*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(v)))).collect();
(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);
@ -590,7 +657,7 @@ fn generate(d: &mut Document, g: &GraphSpec) -> Vec<Arc<Node>> {
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() {
let x = r.x1 + 14.0;
let x = right_edge + 14.0;
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() {
@ -715,7 +782,7 @@ 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).max(1);
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 };
@ -791,6 +858,10 @@ fn set_data(s: &mut Session, p: &Value) -> Result<Value> {
// 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 }))
}
@ -802,11 +873,15 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
// 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();
let picked: Vec<usize> = match p.get("seriesIndexes") {
Some(v) => {
let a = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(C, "`seriesIndexes` is a non-empty list of series indexes"))?;
if p.get("type").is_none() {
return Err(bad(C, "`seriesIndexes` needs a `type`"));
if str_param(p, "type").is_none() && str_param(p, "valueAxis").is_none() {
return Err(bad(C, "`seriesIndexes` needs a `type` or a `valueAxis`"));
}
a.iter()
.map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < count).ok_or_else(|| bad(C, format!("no series {i}"))))
@ -835,15 +910,28 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
"ticks",
"axisMin",
"axisMax",
"valueAxis",
"separateScales",
"rightTicks",
"rightAxisMin",
"rightAxisMax",
];
if !keys.iter().any(|k| p.get(*k).is_some()) {
// With series picked, the type they share, so the dialog's OK keeps it.
// 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,
};
return Ok(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,
"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,
}));
}
// Series picked with Group Selection stay selected through the regeneration (their groups are new nodes).
@ -857,7 +945,7 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
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 {
for &i in &picked {
if let Some(k) = spec.series_kinds.get_mut(i) {
*k = (kind != spec.kind).then_some(kind);
}
@ -867,6 +955,30 @@ fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
}
}
}
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);
spec.right_ticks = p.get("rightTicks").and_then(Value::as_u64).map_or(spec.right_ticks, |t| t.min(100) as usize);
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);
@ -1576,4 +1688,216 @@ mod tests {
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");
}
}

View File

@ -40,6 +40,7 @@ fn choices(command: &str, key: &str) -> Option<form::Choices> {
("text.areaOptions", "fit") => Some(AREA_FIT),
("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE),
("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN),
("graph.setType", "valueAxis") => Some(VALUE_AXIS),
_ => None,
}
}
@ -57,6 +58,9 @@ const FIRST_BASELINE: form::Choices =
/// Area Type Options › Align (vertical alignment of the lines in each row/column).
const VERTICAL_ALIGN: form::Choices = &[("Top", "top"), ("Center", "center"), ("Bottom", "bottom"), ("Justify", "justify")];
/// Graph Type › Value Axis (series picked on both axes show none, and OK leaves each where it is).
const VALUE_AXIS: form::Choices = &[("On Left Side", "left"), ("On Right Side", "right"), ("On Both Sides", "both")];
/// Closes before running, so a dialog the command opens stays open.
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let cmd = d.str("__command");
@ -113,4 +117,21 @@ mod tests {
let NodeKind::Text(t) = &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") };
assert_eq!(t.area.vertical_align, VerticalAlign::Center);
}
/// Graph Type › Value Axis is a dropdown of the values `graph.setType` takes, and OK puts the
/// value axis on the side picked.
#[test]
fn graph_type_value_axis_is_a_dropdown_of_its_sides() {
let doc = vectorcraft_engine::find_command("graph.setType").unwrap().params;
let values: Vec<&str> = choices("graph.setType", "valueAxis").unwrap().iter().map(|(_, v)| *v).collect();
assert!(doc.contains(&format!("valueAxis?: {}", values.join("|"))), "{values:?}");
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
app.run("graph.create", json!({"type": "column", "x": 0, "y": 0, "width": 200, "height": 150})).unwrap();
crate::menus::graph_dialog(&mut app, "graph.setType").unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("valueAxis"), "left");
app.ui.dialog.as_mut().unwrap().fields.insert("valueAxis".into(), json!("both"));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(app.session.execute("graph.setType", &json!({})).unwrap()["valueAxis"], "both");
}
}

View File

@ -3932,3 +3932,7 @@ theme Appearance Erscheinungsbild
@msg the PSD image is too large to read ({_1} × {_2} pixels) das PSD-Bild ist zu groß zum Lesen ({_1} × {_2} Pixel)
@msg zipped 32-bit PSD image data with prediction can't be read ZIP-komprimierte 32-Bit-PSD-Bilddaten mit Vorhersage können nicht gelesen werden
@msg {depth}-bit PSD files in this colour mode can't be read {depth}-Bit-PSD-Dateien in diesem Farbmodus können nicht gelesen werden
Value Axis Werteachse
On Left Side Links
On Right Side Rechts
On Both Sides Auf beiden Seiten

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3976,3 +3976,7 @@ axis Both Ambos
Bottom Right Abajo a la derecha
Edge Borde
System Title Bar Barra de título del sistema
Value Axis Eje de valores
On Left Side En el lado izquierdo
On Right Side En el lado derecho
On Both Sides En ambos lados

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3988,3 +3988,7 @@ axis Both Les deux
effect Soft Doux
Edge Bord
System Title Bar Barre de titre du système
Value Axis Axe des valeurs
On Left Side À gauche
On Right Side À droite
On Both Sides Des deux côtés

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3978,3 +3978,7 @@ effect Soft Morbido
effect Regular Normale
Edge Bordo
System Title Bar Barra del titolo di sistema
Value Axis Asse dei valori
On Left Side Sul lato sinistro
On Right Side Sul lato destro
On Both Sides Su entrambi i lati

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3971,3 +3971,7 @@ effect Horizontal 水平
effect Vertical 垂直
Edge エッジ
System Title Bar システムのタイトルバー
Value Axis 数値軸
On Left Side 左側
On Right Side 右側
On Both Sides 両側

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3974,3 +3974,7 @@ axis Both Оба
Bottom Right Снизу справа
Edge Край
System Title Bar Системная строка заголовка
Value Axis Ось значений
On Left Side Слева
On Right Side Справа
On Both Sides С обеих сторон

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3959,3 +3959,7 @@ axis Both Обидві
Bottom Right Знизу праворуч
Edge Край
System Title Bar Системний рядок заголовка
Value Axis Вісь значень
On Left Side Ліворуч
On Right Side Праворуч
On Both Sides З обох боків

Can't render this file because it has a wrong number of fields in line 13.

View File

@ -3607,3 +3607,7 @@ effect Horizontal 水平
effect Vertical 垂直
Edge 边缘
System Title Bar 系统标题栏
Value Axis 数值轴
On Left Side 左侧
On Right Side 右侧
On Both Sides 两侧

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -3388,3 +3388,7 @@ axis Both 兩者
effect Soft 柔和
Edge 邊緣
System Title Bar 系統標題列
Value Axis 數值軸
On Left Side 左側
On Right Side 右側
On Both Sides 兩側

Can't render this file because it contains an unexpected character in line 6 and column 47.