From 0be3574c325af7a76d5375911faca0f1298fe5e4 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 21 Oct 2026 15:22:24 +0200 Subject: [PATCH 01/21] add arrow parameter --- src/plot/layer/geom/segment.rs | 1 + src/plot/layer/geom/types.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/plot/layer/geom/segment.rs b/src/plot/layer/geom/segment.rs index 5a49e25cc..3750d6473 100644 --- a/src/plot/layer/geom/segment.rs +++ b/src/plot/layer/geom/segment.rs @@ -42,6 +42,7 @@ impl GeomTrait for Segment { constraint: ParamConstraint::string_option(POSITION_VALUES), }, super::types::AGGREGATE_PARAM, + super::types::ARROW_PARAM, ]; PARAMS } diff --git a/src/plot/layer/geom/types.rs b/src/plot/layer/geom/types.rs index 6dad13fdf..ee6d1d426 100644 --- a/src/plot/layer/geom/types.rs +++ b/src/plot/layer/geom/types.rs @@ -7,6 +7,9 @@ use crate::reader::SqlDialect; use crate::{naming, plot::types::DefaultAestheticValue, Mappings}; // Re-export shared types from the central location +use crate::plot::types::{ + ArrayConstraint, ArrayElementConstraint, StringConstraint, TypeConstraint, +}; pub use crate::plot::types::{DefaultParamValue, ParamConstraint, ParamDefinition}; // ============================================================================= @@ -45,6 +48,33 @@ pub const AGGREGATE_PARAM: ParamDefinition = ParamDefinition { constraint: ParamConstraint::string_or_string_array_unconstrained(), }; +/// Marker shape vocabulary for the `arrow` parameter. Names map to +/// hephaestus's built-in `arrow-*` shapes in the writer. +pub const ARROW_VALUES: &[&str] = &[ + "open", "closed", "stealth", "latex", "thin", "wedge", "fishtail", "fork", "feather", "bar", + "bracket", "cross", "dot", "square", "diamond", +]; + +/// Shared `arrow` parameter definition for geoms that draw lines or +/// segments. A single string sets the end marker only; an array +/// `[start, end]` sets both, with null skipping an end. +pub const ARROW_PARAM: ParamDefinition = ParamDefinition { + name: "arrow", + default: DefaultParamValue::Null, + constraint: ParamConstraint { + number: TypeConstraint::Forbidden, + string: TypeConstraint::Constrained(StringConstraint::one_of(ARROW_VALUES)), + boolean: TypeConstraint::Forbidden, + array: TypeConstraint::Constrained(ArrayConstraint { + element: ArrayElementConstraint::String(StringConstraint::one_of(ARROW_VALUES)), + min_len: Some(1), + max_len: Some(2), + allow_null_elements: true, + }), + allow_null: true, + }, +}; + /// Default aesthetic values for a geom type /// /// This struct describes which aesthetics a geom supports, requires, and their default values. From 2b65380e415eccd191d84a2c2c5d214c07703f20 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 21 Oct 2026 16:19:00 +0200 Subject: [PATCH 02/21] Pass arrows to hephaestus --- src/writer/hephaestus/geom/area.rs | 1 + src/writer/hephaestus/geom/line.rs | 1 + src/writer/hephaestus/geom/point.rs | 1 + src/writer/hephaestus/geom/polygon.rs | 1 + src/writer/hephaestus/geom/rect.rs | 1 + src/writer/hephaestus/geom/segment.rs | 1 + src/writer/hephaestus/wiring.rs | 26 ++++++++++++++++++++++++++ 7 files changed, 32 insertions(+) diff --git a/src/writer/hephaestus/geom/area.rs b/src/writer/hephaestus/geom/area.rs index c209590f0..81ba3b03f 100644 --- a/src/writer/hephaestus/geom/area.rs +++ b/src/writer/hephaestus/geom/area.rs @@ -95,6 +95,7 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { ), ], raw_strings: &[], + constant_strings: Vec::new(), raw_numbers: vec![], // A ribbon's two edges are both data, so both are outlined unconditionally. data_channels: if ribbon { diff --git a/src/writer/hephaestus/geom/line.rs b/src/writer/hephaestus/geom/line.rs index 50d88e93c..00c7c04bb 100644 --- a/src/writer/hephaestus/geom/line.rs +++ b/src/writer/hephaestus/geom/line.rs @@ -51,6 +51,7 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { ), ], raw_strings: &[], + constant_strings: Vec::new(), raw_numbers: vec![], data_channels: vec![], legend_key: LegendKind::Line, diff --git a/src/writer/hephaestus/geom/point.rs b/src/writer/hephaestus/geom/point.rs index e85e2e208..9c3ccf40d 100644 --- a/src/writer/hephaestus/geom/point.rs +++ b/src/writer/hephaestus/geom/point.rs @@ -37,6 +37,7 @@ pub fn spec(_ctx: &Ctx) -> GeomSpec { MaterialSpec::new("shape", "shape", RangeKind::Shape, MatDefault::None), ], raw_strings: &[], + constant_strings: Vec::new(), raw_numbers: vec![], data_channels: vec![], legend_key: LegendKind::Point, diff --git a/src/writer/hephaestus/geom/polygon.rs b/src/writer/hephaestus/geom/polygon.rs index 412841c54..a804ba3f1 100644 --- a/src/writer/hephaestus/geom/polygon.rs +++ b/src/writer/hephaestus/geom/polygon.rs @@ -42,6 +42,7 @@ pub fn spec(_ctx: &Ctx) -> GeomSpec { ), ], raw_strings: &[], + constant_strings: Vec::new(), raw_numbers: vec![], data_channels: vec![], legend_key: LegendKind::Rect, diff --git a/src/writer/hephaestus/geom/rect.rs b/src/writer/hephaestus/geom/rect.rs index 937903a8a..54f401c35 100644 --- a/src/writer/hephaestus/geom/rect.rs +++ b/src/writer/hephaestus/geom/rect.rs @@ -61,6 +61,7 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { ), ], raw_strings: &[], + constant_strings: Vec::new(), raw_numbers, data_channels, legend_key: LegendKind::Rect, diff --git a/src/writer/hephaestus/geom/segment.rs b/src/writer/hephaestus/geom/segment.rs index 4dc7243a1..55334e167 100644 --- a/src/writer/hephaestus/geom/segment.rs +++ b/src/writer/hephaestus/geom/segment.rs @@ -57,6 +57,7 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { positions, material: material(), raw_strings: &[], + constant_strings: super::super::wiring::arrow_markers(ctx.layer), raw_numbers, data_channels: vec![], legend_key: LegendKind::Line, diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index 35d3758f8..f31ffd7c0 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -169,6 +169,9 @@ pub struct GeomSpec { /// Unscaled string channels set from a mapped aesthetic (e.g. text labels): /// (hephaestus channel, ggsql aesthetic). pub raw_strings: &'static [(&'static str, &'static str)], + /// Constant string channels from layer parameters, scale-bypassing + /// (e.g. `start_marker`/`end_marker` from the `arrow` SETTING). + pub constant_strings: Vec<(&'static str, String)>, /// Constant panel-space channel values, scale-bypassing (e.g. a rule's /// 0..1 span, discrete-tile band edges): (hephaestus channel, value). /// @@ -208,6 +211,9 @@ where builder.set(*channel, Raw(column_to_strings(ctx.df, col)?)); } } + for (channel, value) in spec.constant_strings { + builder.set(channel, Raw(value)); + } for (channel, value) in &spec.raw_numbers { builder.set(*channel, Raw(vec![*value; ctx.df.height()])); } @@ -615,6 +621,26 @@ pub fn side_sign(layer: &Layer) -> Option { } } +/// The layer's `arrow` SETTING as endpoint marker channels: a bare string sets +/// the end marker, an array `[start, end]` sets each end with null skipping +/// it. Vocabulary names map to hephaestus's built-in `arrow-*` shapes. +pub fn arrow_markers(layer: &Layer) -> Vec<(&'static str, String)> { + let marker = |name: &String| format!("arrow-{name}"); + match layer.parameters.get("arrow") { + Some(ParameterValue::String(name)) => vec![("end_marker", marker(name))], + Some(ParameterValue::Array(a)) => { + let mut channels = Vec::new(); + for (channel, elem) in ["start_marker", "end_marker"].into_iter().zip(a) { + if let crate::plot::types::ArrayElement::String(s) = elem { + channels.push((channel, marker(s))); + } + } + channels + } + _ => Vec::new(), + } +} + /// The two edges of a banded mark, as offsets from the band centre: the full /// `±half` band for `side => 'both'`, else the centreline → `±half` half-band. /// Used for band fractions (box, median, violin edge) and for pt-sized marks From bd556671eb3b59a7d838cd68fbe9a0384184a9d8 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 21 Oct 2026 16:21:52 +0200 Subject: [PATCH 03/21] propagate to other geoms --- src/plot/layer/geom/line.rs | 1 + src/plot/layer/geom/path.rs | 13 ++++++++----- src/writer/hephaestus/geom/line.rs | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/plot/layer/geom/line.rs b/src/plot/layer/geom/line.rs index c75e669b9..314696787 100644 --- a/src/plot/layer/geom/line.rs +++ b/src/plot/layer/geom/line.rs @@ -43,6 +43,7 @@ impl GeomTrait for Line { constraint: ParamConstraint::string_option(ORIENTATION_VALUES), }, super::types::AGGREGATE_PARAM, + super::types::ARROW_PARAM, ]; PARAMS } diff --git a/src/plot/layer/geom/path.rs b/src/plot/layer/geom/path.rs index bdb26c5fe..705bb189d 100644 --- a/src/plot/layer/geom/path.rs +++ b/src/plot/layer/geom/path.rs @@ -33,11 +33,14 @@ impl GeomTrait for Path { } fn default_params(&self) -> &'static [ParamDefinition] { - const PARAMS: &[ParamDefinition] = &[ParamDefinition { - name: "position", - default: DefaultParamValue::String("identity"), - constraint: ParamConstraint::string_option(POSITION_VALUES), - }]; + const PARAMS: &[ParamDefinition] = &[ + ParamDefinition { + name: "position", + default: DefaultParamValue::String("identity"), + constraint: ParamConstraint::string_option(POSITION_VALUES), + }, + super::types::ARROW_PARAM, + ]; PARAMS } diff --git a/src/writer/hephaestus/geom/line.rs b/src/writer/hephaestus/geom/line.rs index 00c7c04bb..44903e250 100644 --- a/src/writer/hephaestus/geom/line.rs +++ b/src/writer/hephaestus/geom/line.rs @@ -51,7 +51,7 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { ), ], raw_strings: &[], - constant_strings: Vec::new(), + constant_strings: super::super::wiring::arrow_markers(ctx.layer), raw_numbers: vec![], data_channels: vec![], legend_key: LegendKind::Line, From 1fa03325f2fa2188b0000fde6eb915f193c40750 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 21 Oct 2026 16:33:03 +0200 Subject: [PATCH 04/21] add a few tests --- src/writer/hephaestus/mod.rs | 18 +++++++++ src/writer/hephaestus/wiring.rs | 69 +++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 32720e656..44e377487 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -216,6 +216,24 @@ mod tests { ); } + #[test] + fn renders_segment_arrows() { + assert_renders( + "SELECT 1 AS x, 2 AS y, 3 AS x2, 4 AS y2 \ + VISUALISE x AS pos1, y AS pos2 \ + DRAW segment MAPPING x2 AS pos1end, y2 AS pos2end \ + SETTING arrow => ['open', 'closed']", + ); + } + + #[test] + fn renders_line_arrow() { + assert_renders( + "SELECT x, y FROM (VALUES (1,2),(2,3),(3,1)) t(x,y) \ + VISUALISE x AS x, y AS y DRAW line SETTING arrow => 'closed'", + ); + } + #[test] fn renders_categorical_color_with_legend() { assert_renders( diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index f31ffd7c0..31d1ef66a 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -1115,3 +1115,72 @@ fn pin_constants( } key } + +#[cfg(test)] +mod tests { + use super::arrow_markers; + use crate::plot::layer::geom::Geom; + use crate::plot::types::{ArrayElement, ParameterValue}; + use crate::Layer; + + fn layer_with_arrow(value: Option) -> Layer { + let mut layer = Layer::new(Geom::segment()); + if let Some(value) = value { + layer.parameters.insert("arrow".into(), value); + } + layer + } + + fn strings(elems: &[Option<&str>]) -> ParameterValue { + ParameterValue::Array( + elems + .iter() + .map(|e| match e { + Some(s) => ArrayElement::String(s.to_string()), + None => ArrayElement::Null, + }) + .collect(), + ) + } + + #[test] + fn a_bare_string_sets_the_end_marker_only() { + let layer = layer_with_arrow(Some(ParameterValue::String("closed".into()))); + assert_eq!( + arrow_markers(&layer), + vec![("end_marker", "arrow-closed".to_string())] + ); + } + + #[test] + fn an_array_sets_start_then_end() { + let layer = layer_with_arrow(Some(strings(&[Some("open"), Some("closed")]))); + assert_eq!( + arrow_markers(&layer), + vec![ + ("start_marker", "arrow-open".to_string()), + ("end_marker", "arrow-closed".to_string()) + ] + ); + } + + #[test] + fn null_elements_skip_an_end() { + let start_only = layer_with_arrow(Some(strings(&[Some("open"), None]))); + assert_eq!( + arrow_markers(&start_only), + vec![("start_marker", "arrow-open".to_string())] + ); + let end_only = layer_with_arrow(Some(strings(&[None, Some("closed")]))); + assert_eq!( + arrow_markers(&end_only), + vec![("end_marker", "arrow-closed".to_string())] + ); + } + + #[test] + fn no_param_or_null_draws_no_markers() { + assert!(arrow_markers(&layer_with_arrow(None)).is_empty()); + assert!(arrow_markers(&layer_with_arrow(Some(ParameterValue::Null))).is_empty()); + } +} From 090501cda9ed620ae97f0c5ad00d2c0e89be4cb0 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 22 Oct 2026 10:21:42 +0200 Subject: [PATCH 05/21] Invert tail shapes --- src/writer/hephaestus/geom/area.rs | 2 +- src/writer/hephaestus/geom/line.rs | 2 +- src/writer/hephaestus/geom/point.rs | 2 +- src/writer/hephaestus/geom/polygon.rs | 2 +- src/writer/hephaestus/geom/rect.rs | 2 +- src/writer/hephaestus/geom/segment.rs | 2 +- src/writer/hephaestus/wiring.rs | 79 +++++++++++++++++++-------- 7 files changed, 62 insertions(+), 29 deletions(-) diff --git a/src/writer/hephaestus/geom/area.rs b/src/writer/hephaestus/geom/area.rs index 81ba3b03f..ca4aca829 100644 --- a/src/writer/hephaestus/geom/area.rs +++ b/src/writer/hephaestus/geom/area.rs @@ -95,7 +95,7 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { ), ], raw_strings: &[], - constant_strings: Vec::new(), + constants: Vec::new(), raw_numbers: vec![], // A ribbon's two edges are both data, so both are outlined unconditionally. data_channels: if ribbon { diff --git a/src/writer/hephaestus/geom/line.rs b/src/writer/hephaestus/geom/line.rs index 44903e250..dd1917323 100644 --- a/src/writer/hephaestus/geom/line.rs +++ b/src/writer/hephaestus/geom/line.rs @@ -51,7 +51,7 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { ), ], raw_strings: &[], - constant_strings: super::super::wiring::arrow_markers(ctx.layer), + constants: super::super::wiring::arrow_markers(ctx.layer), raw_numbers: vec![], data_channels: vec![], legend_key: LegendKind::Line, diff --git a/src/writer/hephaestus/geom/point.rs b/src/writer/hephaestus/geom/point.rs index 9c3ccf40d..fa86a44dd 100644 --- a/src/writer/hephaestus/geom/point.rs +++ b/src/writer/hephaestus/geom/point.rs @@ -37,7 +37,7 @@ pub fn spec(_ctx: &Ctx) -> GeomSpec { MaterialSpec::new("shape", "shape", RangeKind::Shape, MatDefault::None), ], raw_strings: &[], - constant_strings: Vec::new(), + constants: Vec::new(), raw_numbers: vec![], data_channels: vec![], legend_key: LegendKind::Point, diff --git a/src/writer/hephaestus/geom/polygon.rs b/src/writer/hephaestus/geom/polygon.rs index a804ba3f1..03d6564c3 100644 --- a/src/writer/hephaestus/geom/polygon.rs +++ b/src/writer/hephaestus/geom/polygon.rs @@ -42,7 +42,7 @@ pub fn spec(_ctx: &Ctx) -> GeomSpec { ), ], raw_strings: &[], - constant_strings: Vec::new(), + constants: Vec::new(), raw_numbers: vec![], data_channels: vec![], legend_key: LegendKind::Rect, diff --git a/src/writer/hephaestus/geom/rect.rs b/src/writer/hephaestus/geom/rect.rs index 54f401c35..f1cc9e5ec 100644 --- a/src/writer/hephaestus/geom/rect.rs +++ b/src/writer/hephaestus/geom/rect.rs @@ -61,7 +61,7 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { ), ], raw_strings: &[], - constant_strings: Vec::new(), + constants: Vec::new(), raw_numbers, data_channels, legend_key: LegendKind::Rect, diff --git a/src/writer/hephaestus/geom/segment.rs b/src/writer/hephaestus/geom/segment.rs index 55334e167..0a7ddeb22 100644 --- a/src/writer/hephaestus/geom/segment.rs +++ b/src/writer/hephaestus/geom/segment.rs @@ -57,7 +57,7 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { positions, material: material(), raw_strings: &[], - constant_strings: super::super::wiring::arrow_markers(ctx.layer), + constants: super::super::wiring::arrow_markers(ctx.layer), raw_numbers, data_channels: vec![], legend_key: LegendKind::Line, diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index 31d1ef66a..1bd0c8018 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -169,9 +169,9 @@ pub struct GeomSpec { /// Unscaled string channels set from a mapped aesthetic (e.g. text labels): /// (hephaestus channel, ggsql aesthetic). pub raw_strings: &'static [(&'static str, &'static str)], - /// Constant string channels from layer parameters, scale-bypassing - /// (e.g. `start_marker`/`end_marker` from the `arrow` SETTING). - pub constant_strings: Vec<(&'static str, String)>, + /// Constant channels from layer parameters, scale-bypassing (e.g. + /// `start_marker`/`end_marker` from the `arrow` SETTING). + pub constants: Vec<(&'static str, HValue)>, /// Constant panel-space channel values, scale-bypassing (e.g. a rule's /// 0..1 span, discrete-tile band edges): (hephaestus channel, value). /// @@ -211,7 +211,7 @@ where builder.set(*channel, Raw(column_to_strings(ctx.df, col)?)); } } - for (channel, value) in spec.constant_strings { + for (channel, value) in spec.constants { builder.set(channel, Raw(value)); } for (channel, value) in &spec.raw_numbers { @@ -621,24 +621,37 @@ pub fn side_sign(layer: &Layer) -> Option { } } +/// Tail-style shapes are built opening away from the line; inverting turns +/// them to trail it. +const INVERTED_ARROWS: &[&str] = &["fishtail", "fork", "feather"]; + /// The layer's `arrow` SETTING as endpoint marker channels: a bare string sets /// the end marker, an array `[start, end]` sets each end with null skipping /// it. Vocabulary names map to hephaestus's built-in `arrow-*` shapes. -pub fn arrow_markers(layer: &Layer) -> Vec<(&'static str, String)> { - let marker = |name: &String| format!("arrow-{name}"); +pub fn arrow_markers(layer: &Layer) -> Vec<(&'static str, HValue)> { + let mut channels = Vec::new(); + let mut push = |marker: &'static str, invert: &'static str, name: &String| { + channels.push((marker, HValue::String(format!("arrow-{name}").into()))); + if INVERTED_ARROWS.contains(&name.as_str()) { + channels.push((invert, HValue::Bool(true))); + } + }; match layer.parameters.get("arrow") { - Some(ParameterValue::String(name)) => vec![("end_marker", marker(name))], + Some(ParameterValue::String(name)) => push("end_marker", "end_marker_invert", name), Some(ParameterValue::Array(a)) => { - let mut channels = Vec::new(); - for (channel, elem) in ["start_marker", "end_marker"].into_iter().zip(a) { + let ends = [ + ("start_marker", "start_marker_invert"), + ("end_marker", "end_marker_invert"), + ]; + for ((marker, invert), elem) in ends.into_iter().zip(a) { if let crate::plot::types::ArrayElement::String(s) = elem { - channels.push((channel, marker(s))); + push(marker, invert, s); } } - channels } - _ => Vec::new(), + _ => {} } + channels } /// The two edges of a banded mark, as offsets from the band centre: the full @@ -1143,12 +1156,19 @@ mod tests { ) } + fn markers(layer: &Layer) -> Vec<(&'static str, String)> { + arrow_markers(layer) + .into_iter() + .map(|(ch, v)| (ch, format!("{v:?}"))) + .collect() + } + #[test] fn a_bare_string_sets_the_end_marker_only() { let layer = layer_with_arrow(Some(ParameterValue::String("closed".into()))); assert_eq!( - arrow_markers(&layer), - vec![("end_marker", "arrow-closed".to_string())] + markers(&layer), + vec![("end_marker", "String(\"arrow-closed\")".to_string())] ); } @@ -1156,10 +1176,10 @@ mod tests { fn an_array_sets_start_then_end() { let layer = layer_with_arrow(Some(strings(&[Some("open"), Some("closed")]))); assert_eq!( - arrow_markers(&layer), + markers(&layer), vec![ - ("start_marker", "arrow-open".to_string()), - ("end_marker", "arrow-closed".to_string()) + ("start_marker", "String(\"arrow-open\")".to_string()), + ("end_marker", "String(\"arrow-closed\")".to_string()) ] ); } @@ -1168,19 +1188,32 @@ mod tests { fn null_elements_skip_an_end() { let start_only = layer_with_arrow(Some(strings(&[Some("open"), None]))); assert_eq!( - arrow_markers(&start_only), - vec![("start_marker", "arrow-open".to_string())] + markers(&start_only), + vec![("start_marker", "String(\"arrow-open\")".to_string())] ); let end_only = layer_with_arrow(Some(strings(&[None, Some("closed")]))); assert_eq!( - arrow_markers(&end_only), - vec![("end_marker", "arrow-closed".to_string())] + markers(&end_only), + vec![("end_marker", "String(\"arrow-closed\")".to_string())] ); } #[test] fn no_param_or_null_draws_no_markers() { - assert!(arrow_markers(&layer_with_arrow(None)).is_empty()); - assert!(arrow_markers(&layer_with_arrow(Some(ParameterValue::Null))).is_empty()); + assert!(markers(&layer_with_arrow(None)).is_empty()); + assert!(markers(&layer_with_arrow(Some(ParameterValue::Null))).is_empty()); + } + + #[test] + fn tail_style_shapes_are_inverted() { + let layer = layer_with_arrow(Some(strings(&[Some("feather"), Some("closed")]))); + assert_eq!( + markers(&layer), + vec![ + ("start_marker", "String(\"arrow-feather\")".to_string()), + ("start_marker_invert", "Bool(true)".to_string()), + ("end_marker", "String(\"arrow-closed\")".to_string()) + ] + ); } } From 5561f4ecaadbf54f0db14e8bb65684ae475a318d Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 22 Oct 2026 10:34:17 +0200 Subject: [PATCH 06/21] add docs --- CHANGELOG.md | 5 +++++ doc/syntax/layer/type/line.qmd | 35 +++++++++++++++++++++---------- doc/syntax/layer/type/path.qmd | 19 +++++++++++++++++ doc/syntax/layer/type/segment.qmd | 27 +++++++++++++++++++++--- 4 files changed, 72 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82477a5fc..019a0278a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ ## [Unreleased] +### Added + +- The line, path and segment layers now have an `arrow` setting, which + can be used to display arrowheads at the ends of the lines. + ### Changed - The reader/dialect layer was refactored around a central dialect registry with diff --git a/doc/syntax/layer/type/line.qmd b/doc/syntax/layer/type/line.qmd index 898647151..a8f72c9fb 100644 --- a/doc/syntax/layer/type/line.qmd +++ b/doc/syntax/layer/type/line.qmd @@ -27,6 +27,17 @@ The following aesthetics are recognised by the line layer. * `aggregate` Aggregation functions to apply per group: * `null` apply no group aggregation (default). * A single string or an array of strings. See an overview of aggregation function in [the `DRAW` documentation](../../clause/draw.qmd#aggregate) and more information in the *Data transformation* section below. +* `arrow` Arrow names to display at line ends. Broadly, these come in the following variants: + * Arrow heads: `'open'`, `'closed'`, `'stealth'`, `'latex'`, `'thin'` and `'wedge'`. + * Arrow tails: `'fishtail'`, `'fork'`, and `'feather'`. + * Symmetric: `'bar'`, `'bracket'`, `'cross'`, `'dot'`, `'square'` and `'diamond'`. + + Arrows can be declared in the following ways: + * `null` (default) do not display an arrow. + * A single string giving an arrow name to use at the end of the line. + * A `[start, end]` array of names or `null` to specify arrows for the start and end of the line. + To only set an arrow at the start, you could use `['open', null]`. + ## Data transformation This layer supports aggregation through the `aggregate` setting. Aggregation groups are defined by `PARTITION BY`, all discrete mappings, but also the primary axis. Within each group, every numeric mapping is replaced in place by its aggregated value. Use a default like `'mean'` or target individual aesthetics with `':'`. See [the `DRAW` documentation](../../clause/draw.qmd#aggregate) for the full setting shape. @@ -71,11 +82,10 @@ DRAW line When `stroke` or `opacity` varies, the properties of the preceding datapoint carry over. In the case below, we don't see the blue of the last datapoint. ```{ggsql} -WITH data(x, y, z) AS (VALUES - (1, 2, 1), - (2, 5, 3), - (3, 3, 5), -) +-- Small example dataset +CREATE TEMP TABLE data AS +SELECT * FROM (VALUES (1,2,1), (2,5,3), (3,3,5)) t(x,y,z) + VISUALISE x, y FROM data DRAW line MAPPING z AS stroke @@ -85,18 +95,21 @@ SCALE stroke TO ('red', 'green', 'blue') The `linewidth` aesthetic can vary point to point. ```{ggsql} -WITH data(x, y, z) AS (VALUES - (1, 2, 1), - (2, 5, 3), - (3, 3, 5), -) - VISUALISE x, y FROM data DRAW line MAPPING z AS linewidth SCALE linewidth TO (0, 30) ``` +Arrows applied to the start and end of the line. + +```{ggsql} +VISUALISE x, y FROM data +DRAW line SETTING + arrow => ['closed', 'dot'], + linewidth => 5 +``` + Use aggregation to draw min and max lines from a set of observations on a single layer. Targeting `y` twice produces one summary row per function within the same group. A synthetic `aggregate` column tags each row with the different function names, that you can remap to colour the lines distinctly: ```{ggsql} diff --git a/doc/syntax/layer/type/path.qmd b/doc/syntax/layer/type/path.qmd index 8b64c884b..98ddbca3d 100644 --- a/doc/syntax/layer/type/path.qmd +++ b/doc/syntax/layer/type/path.qmd @@ -21,6 +21,16 @@ The following aesthetics are recognised by the path layer. ## Settings * `position`: Position adjustment. One of `'identity'` (default), `'stack'`, `'dodge'`, or `'jitter'` +* `arrow` Arrow names to display at line ends. Broadly, these come in the following variants: + * Arrow heads: `'open'`, `'closed'`, `'stealth'`, `'latex'`, `'thin'` and `'wedge'`. + * Arrow tails: `'fishtail'`, `'fork'`, and `'feather'`. + * Symmetric: `'bar'`, `'bracket'`, `'cross'`, `'dot'`, `'square'` and `'diamond'`. + + Arrows can be declared in the following ways: + * `null` (default) do not display an arrow. + * A single string giving an arrow name to use at the end of the line. + * A `[start, end]` array of names or `null` to specify arrows for the start and end of the line. + To only set an arrow at the start, you could use `['open', null]`. ## Data transformation The path layer does not transform its data but passes it through unchanged. @@ -90,6 +100,15 @@ DRAW path MAPPING 'Path' AS stroke ``` +Setting an arrow. + +```{ggsql} +VISUALISE x, y FROM df +DRAW path SETTING + arrow => 'closed', + linewidth => 5 +``` + When `stroke` or `opacity` varies, the properties of the preceding datapoint carry over. In the case below, we don't see the blue of the last datapoint. ```{ggsql} diff --git a/doc/syntax/layer/type/segment.qmd b/doc/syntax/layer/type/segment.qmd index f2aab57b4..d434541e8 100644 --- a/doc/syntax/layer/type/segment.qmd +++ b/doc/syntax/layer/type/segment.qmd @@ -24,7 +24,17 @@ For axis-aligned intervals where one coordinate is shared between the start and * `linetype`: The type of the line, i.e. the dashing pattern. ## Settings -* `position`: Position adjustment. One of `'identity'` (default), `'stack'`, `'dodge'`, or `'jitter'` +* `position`: Position adjustment. One of `'identity'` (default), `'stack'`, `'dodge'`, or `'jitter'`. +* `arrow` Arrow names to display at line ends. Broadly, these come in the following variants: + * Arrow heads: `'open'`, `'closed'`, `'stealth'`, `'latex'`, `'thin'` and `'wedge'`. + * Arrow tails: `'fishtail'`, `'fork'`, and `'feather'`. + * Symmetric: `'bar'`, `'bracket'`, `'cross'`, `'dot'`, `'square'` and `'diamond'`. + + Arrows can be declared in the following ways: + * `null` (default) do not display an arrow. + * A single string giving an arrow name to use at the end of the line. + * A `[start, end]` array of names or `null` to specify arrows for the start and end of the line. + To only set an arrow at the start, you could use `['open', null]`. * `aggregate` Aggregation functions to apply per group: * `null` apply no group aggregation (default). * A single string or an array of strings. See an overview of aggregation function in [the `DRAW` documentation](../../clause/draw.qmd#aggregate) and more information in the *Data transformation* section below. @@ -40,7 +50,8 @@ The segment layer has no orientations. The axes are treated symmetrically. Segments are useful when you have known start and end points of the data. For example in a graph ```{ggsql} -WITH edges(x, y, xend, yend, type) AS (VALUES +CREATE TEMP TABLE edges AS +SELECT * FROM (VALUES (0, 0, 1, 1, 'A'), (1, 1, 2, 1, 'A'), (2, 1, 3, 0, 'A'), @@ -49,12 +60,22 @@ WITH edges(x, y, xend, yend, type) AS (VALUES (2, 2, 3, 3, 'B'), (1, 1, 1, 2, 'C'), (2, 2, 2, 1, 'C') -) +) t(x, y, xend, yend, type); + VISUALISE x, y, xend, yend FROM edges DRAW segment MAPPING type AS stroke ``` +For directional graphs, it makes sense to use arrows to indicate the direction. + +```{ggsql} +VISUALISE x, y, xend, yend FROM edges +DRAW segment + MAPPING type AS stroke + SETTING arrow => ['fishtail', 'open'], linewidth => 5 +``` + Segments can also draw arbitrary diagonal connections between points. For example, marking a delta between two measurements: ```{ggsql} From a66166ef762dea7a7e3952b51da2d8afb2f8ddf8 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 22 Oct 2026 15:06:06 +0200 Subject: [PATCH 07/21] also set arrow sizes --- src/plot/layer/geom/line.rs | 4 ++ src/plot/layer/geom/path.rs | 6 +- src/plot/layer/geom/segment.rs | 6 +- src/plot/layer/geom/types.rs | 106 ++++++++++++++++++++++++++++++-- src/validate.rs | 9 +++ src/writer/hephaestus/wiring.rs | 28 ++++++++- 6 files changed, 151 insertions(+), 8 deletions(-) diff --git a/src/plot/layer/geom/line.rs b/src/plot/layer/geom/line.rs index 314696787..739461085 100644 --- a/src/plot/layer/geom/line.rs +++ b/src/plot/layer/geom/line.rs @@ -48,6 +48,10 @@ impl GeomTrait for Line { PARAMS } + fn setup_layer(&self, _mappings: &mut Mappings, parameters: &mut Parameters) -> Result<()> { + super::types::validate_arrow_param(parameters).map_err(crate::GgsqlError::ValidationError) + } + fn aggregate_domain_aesthetics(&self) -> Option<&'static [&'static str]> { Some(&["pos1"]) } diff --git a/src/plot/layer/geom/path.rs b/src/plot/layer/geom/path.rs index 705bb189d..25718017c 100644 --- a/src/plot/layer/geom/path.rs +++ b/src/plot/layer/geom/path.rs @@ -6,7 +6,7 @@ use super::{ DefaultParamValue, GeomTrait, GeomType, ParamConstraint, ParamDefinition, }; use crate::plot::projection::Projection; -use crate::plot::types::DefaultAestheticValue; +use crate::plot::types::{DefaultAestheticValue, Parameters}; use crate::reader::SqlDialect; use crate::{Mappings, Result}; @@ -44,6 +44,10 @@ impl GeomTrait for Path { PARAMS } + fn setup_layer(&self, _mappings: &mut Mappings, parameters: &mut Parameters) -> Result<()> { + super::types::validate_arrow_param(parameters).map_err(crate::GgsqlError::ValidationError) + } + fn apply_projection( &self, query: &str, diff --git a/src/plot/layer/geom/segment.rs b/src/plot/layer/geom/segment.rs index 3750d6473..d5c6964ab 100644 --- a/src/plot/layer/geom/segment.rs +++ b/src/plot/layer/geom/segment.rs @@ -6,7 +6,7 @@ use super::{ DefaultParamValue, GeomTrait, GeomType, ParamConstraint, ParamDefinition, }; use crate::plot::projection::Projection; -use crate::plot::types::{DefaultAestheticValue, ParameterValue}; +use crate::plot::types::{DefaultAestheticValue, ParameterValue, Parameters}; use crate::reader::SqlDialect; use crate::{naming, Mappings, Result}; @@ -47,6 +47,10 @@ impl GeomTrait for Segment { PARAMS } + fn setup_layer(&self, _mappings: &mut Mappings, parameters: &mut Parameters) -> Result<()> { + super::types::validate_arrow_param(parameters).map_err(crate::GgsqlError::ValidationError) + } + fn aggregate_domain_aesthetics(&self) -> Option<&'static [&'static str]> { Some(&[]) } diff --git a/src/plot/layer/geom/types.rs b/src/plot/layer/geom/types.rs index ee6d1d426..616277de5 100644 --- a/src/plot/layer/geom/types.rs +++ b/src/plot/layer/geom/types.rs @@ -8,7 +8,8 @@ use crate::{naming, plot::types::DefaultAestheticValue, Mappings}; // Re-export shared types from the central location use crate::plot::types::{ - ArrayConstraint, ArrayElementConstraint, StringConstraint, TypeConstraint, + ArrayConstraint, ArrayElement, ArrayElementConstraint, ParameterValue, Parameters, + StringConstraint, TypeConstraint, }; pub use crate::plot::types::{DefaultParamValue, ParamConstraint, ParamDefinition}; @@ -57,16 +58,19 @@ pub const ARROW_VALUES: &[&str] = &[ /// Shared `arrow` parameter definition for geoms that draw lines or /// segments. A single string sets the end marker only; an array -/// `[start, end]` sets both, with null skipping an end. +/// `[start, end]` sets both, with null skipping an end. Each entry is +/// `name` or `name:size` (size in points); the constraint checks the +/// structural shape only, per-entry vocabulary is validated by +/// [`validate_arrow_param`] (the `aggregate` pattern). pub const ARROW_PARAM: ParamDefinition = ParamDefinition { name: "arrow", default: DefaultParamValue::Null, constraint: ParamConstraint { number: TypeConstraint::Forbidden, - string: TypeConstraint::Constrained(StringConstraint::one_of(ARROW_VALUES)), + string: TypeConstraint::Any, boolean: TypeConstraint::Forbidden, array: TypeConstraint::Constrained(ArrayConstraint { - element: ArrayElementConstraint::String(StringConstraint::one_of(ARROW_VALUES)), + element: ArrayElementConstraint::String(StringConstraint::unconstrained()), min_len: Some(1), max_len: Some(2), allow_null_elements: true, @@ -75,6 +79,48 @@ pub const ARROW_PARAM: ParamDefinition = ParamDefinition { }, }; +/// Parse one `arrow` entry of the form `name` or `name:size` (size in points). +pub fn parse_arrow_entry(entry: &str) -> std::result::Result<(&str, Option), String> { + let (name, size) = match entry.split_once(':') { + Some((name, size)) => { + let size: f64 = size.parse().map_err(|_| { + format!("invalid arrow '{entry}'; expected 'name' or 'name:size', e.g. 'closed:6'") + })?; + (name, Some(size)) + } + None => (entry, None), + }; + if !ARROW_VALUES.contains(&name) { + return Err(format!( + "unknown arrow '{name}'; expected one of {}", + crate::util::or_list_quoted(ARROW_VALUES, '\'') + )); + } + if size.is_some_and(|s| !(s > 0.0 && s.is_finite())) { + return Err(format!( + "invalid arrow size in '{entry}'; size must be a positive number of points" + )); + } + Ok((name, size)) +} + +/// Per-entry `name[:size]` vocabulary for the `arrow` setting, which +/// [`ARROW_PARAM`]'s structural constraint can't express. +pub fn validate_arrow_param(parameters: &Parameters) -> std::result::Result<(), String> { + match parameters.get("arrow") { + Some(ParameterValue::String(entry)) => parse_arrow_entry(entry).map(|_| ()), + Some(ParameterValue::Array(elems)) => { + for elem in elems { + if let ArrayElement::String(entry) = elem { + parse_arrow_entry(entry)?; + } + } + Ok(()) + } + _ => Ok(()), + } +} + /// Default aesthetic values for a geom type /// /// This struct describes which aesthetics a geom supports, requires, and their default values. @@ -661,3 +707,55 @@ mod tests { assert!(aes_fill.supported().contains(&"color")); } } + +#[cfg(test)] +mod arrow_tests { + use super::{parse_arrow_entry, validate_arrow_param}; + use crate::plot::types::{ArrayElement, ParameterValue, Parameters}; + + fn params(value: ParameterValue) -> Parameters { + Parameters::from([("arrow".to_string(), value)]) + } + + #[test] + fn parses_a_bare_name() { + assert_eq!(parse_arrow_entry("closed"), Ok(("closed", None))); + } + + #[test] + fn parses_a_name_with_size() { + assert_eq!(parse_arrow_entry("closed:6"), Ok(("closed", Some(6.0)))); + } + + #[test] + fn rejects_an_unknown_name() { + assert!(parse_arrow_entry("closde").unwrap_err().contains("unknown arrow 'closde'")); + } + + #[test] + fn rejects_a_non_numeric_or_non_positive_size() { + assert!(parse_arrow_entry("closed:big").is_err()); + assert!(parse_arrow_entry("closed:-1").is_err()); + assert!(parse_arrow_entry("closed:0").is_err()); + } + + #[test] + fn validate_checks_every_array_entry() { + let good = params(ParameterValue::Array(vec![ + ArrayElement::Null, + ArrayElement::String("open:3".to_string()), + ])); + assert!(validate_arrow_param(&good).is_ok()); + let bad = params(ParameterValue::Array(vec![ + ArrayElement::String("open".to_string()), + ArrayElement::String("nope".to_string()), + ])); + assert!(validate_arrow_param(&bad).is_err()); + } + + #[test] + fn validate_passes_without_the_param() { + assert!(validate_arrow_param(&Parameters::new()).is_ok()); + assert!(validate_arrow_param(¶ms(ParameterValue::Null)).is_ok()); + } +} diff --git a/src/validate.rs b/src/validate.rs index a9aefcb4b..72ebac3b6 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -275,6 +275,15 @@ pub fn validate(query: &str) -> Result { location: None, }); } + // Same rationale as the aggregate check above: surface `arrow` + // vocabulary errors without executing. + if let Err(e) = crate::plot::layer::geom::types::validate_arrow_param(&layer.parameters) + { + errors.push(ValidationError { + message: format!("{}: {}", context, e), + location: None, + }); + } } } diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index 1bd0c8018..96903a35c 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -17,6 +17,7 @@ use super::channels::{ column_to_f64, column_to_strings, ChannelData, }; use super::scales::{map_linetype, parse_color, parse_font_weight, RangeKind}; +use crate::plot::layer::geom::types::parse_arrow_entry; use crate::plot::{ParameterValue, ScaleTypeKind}; use crate::{AestheticValue, DataFrame, GgsqlError, Layer, Plot, Result}; @@ -630,9 +631,20 @@ const INVERTED_ARROWS: &[&str] = &["fishtail", "fork", "feather"]; /// it. Vocabulary names map to hephaestus's built-in `arrow-*` shapes. pub fn arrow_markers(layer: &Layer) -> Vec<(&'static str, HValue)> { let mut channels = Vec::new(); - let mut push = |marker: &'static str, invert: &'static str, name: &String| { + let mut push = |marker: &'static str, invert: &'static str, entry: &String| { + // Invalid entries were rejected at execute/validate time; skip defensively. + let Ok((name, size)) = parse_arrow_entry(entry) else { + return; + }; channels.push((marker, HValue::String(format!("arrow-{name}").into()))); - if INVERTED_ARROWS.contains(&name.as_str()) { + if let Some(size) = size { + let size_channel = match marker { + "start_marker" => "start_marker_size", + _ => "end_marker_size", + }; + channels.push((size_channel, HValue::Number(size))); + } + if INVERTED_ARROWS.contains(&name) { channels.push((invert, HValue::Bool(true))); } }; @@ -1204,6 +1216,18 @@ mod tests { assert!(markers(&layer_with_arrow(Some(ParameterValue::Null))).is_empty()); } + #[test] + fn a_name_with_size_sets_the_size_channel() { + let layer = layer_with_arrow(Some(ParameterValue::String("closed:6".into()))); + assert_eq!( + markers(&layer), + vec![ + ("end_marker", "String(\"arrow-closed\")".to_string()), + ("end_marker_size", "Number(6.0)".to_string()) + ] + ); + } + #[test] fn tail_style_shapes_are_inverted() { let layer = layer_with_arrow(Some(strings(&[Some("feather"), Some("closed")]))); From 9906de039a14951b5be0cb202ccafcb966345017 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 22 Oct 2026 15:06:22 +0200 Subject: [PATCH 08/21] document arrow sizes --- doc/syntax/layer/type/line.qmd | 5 +++-- doc/syntax/layer/type/path.qmd | 5 +++-- doc/syntax/layer/type/segment.qmd | 4 +++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/doc/syntax/layer/type/line.qmd b/doc/syntax/layer/type/line.qmd index a8f72c9fb..0636638c7 100644 --- a/doc/syntax/layer/type/line.qmd +++ b/doc/syntax/layer/type/line.qmd @@ -32,6 +32,8 @@ The following aesthetics are recognised by the line layer. * Arrow tails: `'fishtail'`, `'fork'`, and `'feather'`. * Symmetric: `'bar'`, `'bracket'`, `'cross'`, `'dot'`, `'square'` and `'diamond'`. + Arrow names can optionally be followed by a `:`-separated numeric suffix to set their size in points, e.g. `'open:10'`. + By default, arrows are sized by tripling the `linewidth` value. Arrows can be declared in the following ways: * `null` (default) do not display an arrow. * A single string giving an arrow name to use at the end of the line. @@ -106,8 +108,7 @@ Arrows applied to the start and end of the line. ```{ggsql} VISUALISE x, y FROM data DRAW line SETTING - arrow => ['closed', 'dot'], - linewidth => 5 + arrow => ['dot:20', 'closed:15'] ``` Use aggregation to draw min and max lines from a set of observations on a single layer. Targeting `y` twice produces one summary row per function within the same group. A synthetic `aggregate` column tags each row with the different function names, that you can remap to colour the lines distinctly: diff --git a/doc/syntax/layer/type/path.qmd b/doc/syntax/layer/type/path.qmd index 98ddbca3d..45c36d6b4 100644 --- a/doc/syntax/layer/type/path.qmd +++ b/doc/syntax/layer/type/path.qmd @@ -26,6 +26,8 @@ The following aesthetics are recognised by the path layer. * Arrow tails: `'fishtail'`, `'fork'`, and `'feather'`. * Symmetric: `'bar'`, `'bracket'`, `'cross'`, `'dot'`, `'square'` and `'diamond'`. + Arrow names can optionally be followed by a `:`-separated numeric suffix to set their size in points, e.g. `'open:10'`. + By default, arrows are sized by tripling the `linewidth` value. Arrows can be declared in the following ways: * `null` (default) do not display an arrow. * A single string giving an arrow name to use at the end of the line. @@ -105,8 +107,7 @@ Setting an arrow. ```{ggsql} VISUALISE x, y FROM df DRAW path SETTING - arrow => 'closed', - linewidth => 5 + arrow => 'closed:15' ``` When `stroke` or `opacity` varies, the properties of the preceding datapoint carry over. In the case below, we don't see the blue of the last datapoint. diff --git a/doc/syntax/layer/type/segment.qmd b/doc/syntax/layer/type/segment.qmd index d434541e8..82ad6481c 100644 --- a/doc/syntax/layer/type/segment.qmd +++ b/doc/syntax/layer/type/segment.qmd @@ -30,6 +30,8 @@ For axis-aligned intervals where one coordinate is shared between the start and * Arrow tails: `'fishtail'`, `'fork'`, and `'feather'`. * Symmetric: `'bar'`, `'bracket'`, `'cross'`, `'dot'`, `'square'` and `'diamond'`. + Arrow names can optionally be followed by a `:`-separated numeric suffix to set their size in points, e.g. `'open:10'`. + By default, arrows are sized by tripling the `linewidth` value. Arrows can be declared in the following ways: * `null` (default) do not display an arrow. * A single string giving an arrow name to use at the end of the line. @@ -73,7 +75,7 @@ For directional graphs, it makes sense to use arrows to indicate the direction. VISUALISE x, y, xend, yend FROM edges DRAW segment MAPPING type AS stroke - SETTING arrow => ['fishtail', 'open'], linewidth => 5 + SETTING arrow => ['fishtail:10', 'open:10'] ``` Segments can also draw arbitrary diagonal connections between points. For example, marking a delta between two measurements: From 91e8289a504e722506f3376444a716da716c6e73 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 22 Oct 2026 15:46:30 +0200 Subject: [PATCH 09/21] remove vestigial arrow geom --- src/parser/builder.rs | 1 - src/plot/layer/geom/arrow.rs | 55 ------------------------------------ src/plot/layer/geom/mod.rs | 12 -------- src/writer/hephaestus/mod.rs | 16 +---------- tree-sitter-ggsql/grammar.js | 2 +- 5 files changed, 2 insertions(+), 84 deletions(-) delete mode 100644 src/plot/layer/geom/arrow.rs diff --git a/src/parser/builder.rs b/src/parser/builder.rs index dc7cb9bc4..968d05f55 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -648,7 +648,6 @@ fn parse_geom_type(text: &str) -> Result { "violin" => Ok(Geom::violin()), "text" => Ok(Geom::text()), "segment" => Ok(Geom::segment()), - "arrow" => Ok(Geom::arrow()), "rule" => Ok(Geom::rule()), "range" => Ok(Geom::range()), "spatial" => Ok(Geom::spatial()), diff --git a/src/plot/layer/geom/arrow.rs b/src/plot/layer/geom/arrow.rs deleted file mode 100644 index ccfb961b9..000000000 --- a/src/plot/layer/geom/arrow.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Arrow geom implementation - -use super::types::POSITION_VALUES; -use super::{ - DefaultAesthetics, DefaultParamValue, GeomTrait, GeomType, ParamConstraint, ParamDefinition, -}; -use crate::plot::types::DefaultAestheticValue; - -/// Arrow geom - line segments with arrowheads -#[derive(Debug, Clone, Copy)] -pub struct Arrow; - -impl GeomTrait for Arrow { - fn geom_type(&self) -> GeomType { - GeomType::Arrow - } - - fn aesthetics(&self) -> DefaultAesthetics { - DefaultAesthetics { - defaults: &[ - ("pos1", DefaultAestheticValue::Required), - ("pos2", DefaultAestheticValue::Required), - ("pos1end", DefaultAestheticValue::Required), - ("pos2end", DefaultAestheticValue::Required), - ("stroke", DefaultAestheticValue::String("black")), - ("linewidth", DefaultAestheticValue::Number(1.0)), - ("opacity", DefaultAestheticValue::Number(1.0)), - ("linetype", DefaultAestheticValue::String("solid")), - ("fill", DefaultAestheticValue::Null), - ], - } - } - - fn default_params(&self) -> &'static [ParamDefinition] { - const PARAMS: &[ParamDefinition] = &[ - ParamDefinition { - name: "position", - default: DefaultParamValue::String("identity"), - constraint: ParamConstraint::string_option(POSITION_VALUES), - }, - super::types::AGGREGATE_PARAM, - ]; - PARAMS - } - - fn aggregate_domain_aesthetics(&self) -> Option<&'static [&'static str]> { - Some(&[]) - } -} - -impl std::fmt::Display for Arrow { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "arrow") - } -} diff --git a/src/plot/layer/geom/mod.rs b/src/plot/layer/geom/mod.rs index 12af10f19..4f6cdc98a 100644 --- a/src/plot/layer/geom/mod.rs +++ b/src/plot/layer/geom/mod.rs @@ -30,7 +30,6 @@ pub mod types; // Geom implementations mod area; -mod arrow; mod bar; mod boxplot; mod density; @@ -57,7 +56,6 @@ pub use types::{ // Re-export geom structs for direct access if needed pub use area::Area; -pub use arrow::Arrow; pub use bar::Bar; pub use boxplot::Boxplot; pub use density::Density; @@ -100,7 +98,6 @@ pub enum GeomType { Violin, Text, Segment, - Arrow, Rule, Range, Spatial, @@ -124,7 +121,6 @@ impl std::fmt::Display for GeomType { GeomType::Violin => "violin", GeomType::Text => "text", GeomType::Segment => "segment", - GeomType::Arrow => "arrow", GeomType::Rule => "rule", GeomType::Range => "range", GeomType::Spatial => "spatial", @@ -739,11 +735,6 @@ impl Geom { Self(Arc::new(Segment)) } - /// Create an Arrow geom - pub fn arrow() -> Self { - Self(Arc::new(Arrow)) - } - /// Create an Rule geom pub fn rule() -> Self { Self(Arc::new(Rule)) @@ -777,7 +768,6 @@ impl Geom { GeomType::Violin => Self::violin(), GeomType::Text => Self::text(), GeomType::Segment => Self::segment(), - GeomType::Arrow => Self::arrow(), GeomType::Rule => Self::rule(), GeomType::Range => Self::range(), GeomType::Spatial => Self::spatial(), @@ -1050,7 +1040,6 @@ mod tests { GeomType::Violin, GeomType::Text, GeomType::Segment, - GeomType::Arrow, GeomType::Rule, GeomType::Range, GeomType::Spatial, @@ -1074,7 +1063,6 @@ mod tests { | GeomType::Violin | GeomType::Text | GeomType::Segment - | GeomType::Arrow | GeomType::Rule | GeomType::Range | GeomType::Spatial => {} diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index 44e377487..7c35901de 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -22,8 +22,7 @@ //! reinterpret. //! //! **Scope**: multi-layer plots under Cartesian, Polar, and Map projections, -//! with `FACET` faceting (Wrap/Grid, fixed + free scales); every geom except -//! `arrow`, which is a stub no writer implements; all scale types and +//! with `FACET` faceting (Wrap/Grid, fixed + free scales); all scale types and //! transforms, material aesthetics, plot and axis titles, and legends. //! //! Architecture — the abstractions and the invariants they keep — and the @@ -1305,19 +1304,6 @@ mod tests { assert_eq!(*r.start(), -0.5); assert_eq!(*r.end(), 10.5); } - - /// `arrow` is a stub in both writers, so the rejection lives in the shared - /// composition layer rather than in any one of them. - #[test] - fn rejects_unsupported_geom() { - let spec = spec_for( - "SELECT 0 AS x, 0 AS y, 1 AS xend, 1 AS yend \ - VISUALISE x AS x, y AS y, xend AS xend, yend AS yend DRAW arrow", - ); - let err = compose::validate_plot(spec.plot()).unwrap_err(); - assert!(matches!(err, GgsqlError::WriterError(_))); - assert!(err.to_string().contains("'arrow' geom"), "{err}"); - } } // SVG output is readable text, so these check directly that the breaks, labels diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index bdb404ec8..d1c8926b6 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -738,7 +738,7 @@ module.exports = grammar({ geom_type: $ => choice( 'point', 'line', 'path', 'bar', 'area', 'tile', 'polygon', 'ribbon', 'histogram', 'density', 'smooth', 'boxplot', 'violin', - 'text', 'label', 'segment', 'arrow', 'rule', 'range', + 'text', 'label', 'segment', 'rule', 'range', 'spatial' ), From 3b5a5d673ae0f79dc93760d45f1e0d976c65b81d Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 22 Oct 2026 16:17:43 +0200 Subject: [PATCH 10/21] formatting --- src/plot/layer/geom/types.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plot/layer/geom/types.rs b/src/plot/layer/geom/types.rs index 616277de5..faa6124c5 100644 --- a/src/plot/layer/geom/types.rs +++ b/src/plot/layer/geom/types.rs @@ -729,7 +729,9 @@ mod arrow_tests { #[test] fn rejects_an_unknown_name() { - assert!(parse_arrow_entry("closde").unwrap_err().contains("unknown arrow 'closde'")); + assert!(parse_arrow_entry("closde") + .unwrap_err() + .contains("unknown arrow 'closde'")); } #[test] From a9136495f657b2ce4ffe9141cb486b313b80c173 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 22 Oct 2026 17:04:56 +0200 Subject: [PATCH 11/21] deal with clippy's shenanigans --- src/writer/hephaestus/geom/mod.rs | 7 +++---- src/writer/vegalite/layer.rs | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/writer/hephaestus/geom/mod.rs b/src/writer/hephaestus/geom/mod.rs index d1cda8905..b64d986d3 100644 --- a/src/writer/hephaestus/geom/mod.rs +++ b/src/writer/hephaestus/geom/mod.rs @@ -22,7 +22,7 @@ use hephaestus::plot::{ use super::wiring::{build_and_add, Ctx}; use crate::plot::layer::geom::GeomType; -use crate::{GgsqlError, Result}; +use crate::Result; /// Build the layer's geom into `plot`: set its channels, bind them to the scales /// named in `ctx`, and record any legends on `ctx`. @@ -59,13 +59,12 @@ pub fn build_into_plot(plot: &mut HPlot, ctx: &Ctx) -> Result<()> { GeomType::Spatial => spatial::build(plot, ctx), GeomType::Boxplot => boxplot::build(plot, ctx), GeomType::Violin => violin::build(plot, ctx), - other => Err(GgsqlError::WriterError(format!( - "the plot renderer does not support the '{other}' geom yet" - ))), } } /// Geoms this writer can render (used by `validate`). +/// Accepts all currently existing `GeomType` variants. +/// New `GeomType` variants are unsupported until explicitly opted in here. pub fn is_supported(geom: GeomType) -> bool { matches!( geom, diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 91d4bfce9..105cc0efa 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -47,7 +47,6 @@ pub fn geom_to_mark(geom: &Geom) -> Value { GeomType::Rule => "rule", GeomType::Range => "rule", GeomType::Spatial => "geoshape", - _ => "point", // Default fallback }; json!({ "type": mark_type, From c0eb619a74aa6820f797990080c4fb164ab96f70 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 23 Oct 2026 09:05:10 +0200 Subject: [PATCH 12/21] clean up more stale mentions of the arrow geom type --- ggsql-vscode/syntaxes/ggsql.tmLanguage.json | 4 ++-- src/plot/CLAUDE.md | 2 +- src/writer/hephaestus/CLAUDE.md | 3 +-- tree-sitter-ggsql/queries/highlights.scm | 1 - 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json index bb2dbc82d..f761cbdcd 100644 --- a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json +++ b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json @@ -320,7 +320,7 @@ { "comment": "Geom types from grammar.js", "name": "support.type.geom.ggsql", - "match": "\\b(point|line|path|bar|col|area|tile|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|range|spatial)\\b" + "match": "\\b(point|line|path|bar|col|area|tile|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|rule|range|spatial)\\b" }, { "include": "#common-clause-patterns" } ] @@ -334,7 +334,7 @@ "patterns": [ { "name": "support.type.geom.ggsql", - "match": "\\b(point|line|path|bar|col|area|tile|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|range|spatial)\\b" + "match": "\\b(point|line|path|bar|col|area|tile|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|rule|range|spatial)\\b" }, { "include": "#common-clause-patterns" } ] diff --git a/src/plot/CLAUDE.md b/src/plot/CLAUDE.md index 3add30935..1bb83354d 100644 --- a/src/plot/CLAUDE.md +++ b/src/plot/CLAUDE.md @@ -46,7 +46,7 @@ plot/ `Layer` itself lives in `layer/mod.rs`. The two big substructures: -- **`layer/geom/`** — one `.rs` per geom plus `mod.rs` (registry) and `types.rs`. The current set: `area`, `arrow`, `bar`, `boxplot`, `density`, `histogram`, `line`, `path`, `point`, `polygon`, `range`, `ribbon`, `rule`, `segment`, `smooth`, `spatial`, `text`, `tile`, `violin`. Each implements `GeomTrait` (declares supported aesthetics, parameters, defaults, stat behaviour, and optional `apply_projection` for coord-specific transforms). User-facing semantics live in [`/doc/syntax/layer/type/`](../../doc/syntax/layer/type/). +- **`layer/geom/`** — one `.rs` per geom plus `mod.rs` (registry) and `types.rs`. The current set: `area`, `bar`, `boxplot`, `density`, `histogram`, `line`, `path`, `point`, `polygon`, `range`, `ribbon`, `rule`, `segment`, `smooth`, `spatial`, `text`, `tile`, `violin`. Each implements `GeomTrait` (declares supported aesthetics, parameters, defaults, stat behaviour, and optional `apply_projection` for coord-specific transforms). User-facing semantics live in [`/doc/syntax/layer/type/`](../../doc/syntax/layer/type/). - **`layer/position/`** — `identity`, `stack`, `dodge`, `jitter`. Each implements `PositionTrait`. Semantics: [`/doc/syntax/layer/position/`](../../doc/syntax/layer/position/). `layer/orientation.rs` decides whether a layer is "transposed" (horizontal bar vs vertical bar etc.) based on the mappings + geom type. diff --git a/src/writer/hephaestus/CLAUDE.md b/src/writer/hephaestus/CLAUDE.md index 3e7e716e7..22f1eb69e 100644 --- a/src/writer/hephaestus/CLAUDE.md +++ b/src/writer/hephaestus/CLAUDE.md @@ -150,8 +150,7 @@ compose::build_composition(&Plot, &HashMap) └─ view.validate() raster::pixels(spec, data, canvas, renderer) - ├─ compose::validate_plot ← rejects a zero-layer plot, and - │ the `arrow` stub no writer draws + ├─ compose::validate_plot ← rejects a zero-layer plot ├─ compose::build_composition ← the diagram above └─ raster::render_rgba8: HybridRenderer → straight-alpha RGBA8 buffer diff --git a/tree-sitter-ggsql/queries/highlights.scm b/tree-sitter-ggsql/queries/highlights.scm index e8319356d..4776e322f 100644 --- a/tree-sitter-ggsql/queries/highlights.scm +++ b/tree-sitter-ggsql/queries/highlights.scm @@ -23,7 +23,6 @@ "text" "label" "segment" - "arrow" "rule" "range" "spatial" From a9d0e85fc81af75c94b8f5e20d89dce1ebcd07e9 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 4 Nov 2026 16:30:41 +0100 Subject: [PATCH 13/21] swap out parameter and promote to aesthetics --- src/parser/builder.rs | 22 +++++ src/plot/aesthetic.rs | 4 + src/plot/layer/geom/line.rs | 7 +- src/plot/layer/geom/path.rs | 9 +- src/plot/layer/geom/segment.rs | 9 +- src/plot/layer/geom/types.rs | 81 ++--------------- src/validate.rs | 9 -- src/writer/hephaestus/mod.rs | 2 +- src/writer/hephaestus/wiring.rs | 106 +++++++++-------------- tree-sitter-ggsql/grammar.js | 2 + tree-sitter-ggsql/queries/highlights.scm | 4 + 11 files changed, 92 insertions(+), 163 deletions(-) diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 4959e8255..06131b543 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -2753,6 +2753,28 @@ mod tests { assert!(specs[0].layers[1].mappings.contains_key("color")); } + #[test] + fn test_arrow_aesthetic_mapping_parsing() { + let query = r#" + VISUALISE date AS x, revenue AS y + DRAW line MAPPING kind AS arrowstart + DRAW segment MAPPING kind AS arrow, x2 AS xend, y2 AS yend + "#; + + let specs = parse_test_query(query).unwrap(); + + // Arrow aesthetic names parse and pass through untransformed; the + // `arrow` alias split happens during execution, not parsing. + assert_eq!( + specs[0].layers[0].mappings.aesthetics["arrowstart"].column_name(), + Some("kind") + ); + assert_eq!( + specs[0].layers[1].mappings.aesthetics["arrow"].column_name(), + Some("kind") + ); + } + #[test] fn test_implicit_global_mapping_parsing() { let query = r#" diff --git a/src/plot/aesthetic.rs b/src/plot/aesthetic.rs index 16a98b852..01e98e04c 100644 --- a/src/plot/aesthetic.rs +++ b/src/plot/aesthetic.rs @@ -57,6 +57,7 @@ pub const USER_FACET_AESTHETICS: &[&str] = &["panel", "row", "column"]; /// These include: /// - Color aesthetics: color, colour, fill, stroke, opacity /// - Size/shape aesthetics: size, shape, linetype, linewidth +/// - Line-end marker aesthetics: arrow (alias), arrowstart, arrowend /// - Dimension aesthetics: width, height /// - Text aesthetics: label, typeface, fontweight, italic, hjust, vjust pub const MATERIAL_AESTHETICS: &[&str] = &[ @@ -69,6 +70,9 @@ pub const MATERIAL_AESTHETICS: &[&str] = &[ "shape", "linetype", "linewidth", + "arrow", + "arrowstart", + "arrowend", "width", "height", "label", diff --git a/src/plot/layer/geom/line.rs b/src/plot/layer/geom/line.rs index 3e18be4dd..d00414793 100644 --- a/src/plot/layer/geom/line.rs +++ b/src/plot/layer/geom/line.rs @@ -31,6 +31,8 @@ impl GeomTrait for Line { ("linewidth", DefaultAestheticValue::Number(1.5)), ("opacity", DefaultAestheticValue::Number(1.0)), ("linetype", DefaultAestheticValue::String("solid")), + ("arrowstart", DefaultAestheticValue::Null), + ("arrowend", DefaultAestheticValue::Null), ], } } @@ -45,15 +47,10 @@ impl GeomTrait for Line { super::types::LINECAP_PARAM, super::types::LINEJOIN_PARAM, super::types::AGGREGATE_PARAM, - super::types::ARROW_PARAM, ]; PARAMS } - fn setup_layer(&self, _mappings: &mut Mappings, parameters: &mut Parameters) -> Result<()> { - super::types::validate_arrow_param(parameters).map_err(crate::GgsqlError::ValidationError) - } - fn aggregate_domain_aesthetics(&self) -> Option<&'static [&'static str]> { Some(&["pos1"]) } diff --git a/src/plot/layer/geom/path.rs b/src/plot/layer/geom/path.rs index 532e36c63..c78504286 100644 --- a/src/plot/layer/geom/path.rs +++ b/src/plot/layer/geom/path.rs @@ -6,7 +6,7 @@ use super::{ DefaultParamValue, GeomTrait, GeomType, ParamConstraint, ParamDefinition, }; use crate::plot::projection::Projection; -use crate::plot::types::{DefaultAestheticValue, Parameters}; +use crate::plot::types::DefaultAestheticValue; use crate::reader::SqlDialect; use crate::{Mappings, Result}; @@ -28,6 +28,8 @@ impl GeomTrait for Path { ("linewidth", DefaultAestheticValue::Number(1.5)), ("opacity", DefaultAestheticValue::Number(1.0)), ("linetype", DefaultAestheticValue::String("solid")), + ("arrowstart", DefaultAestheticValue::Null), + ("arrowend", DefaultAestheticValue::Null), ], } } @@ -39,17 +41,12 @@ impl GeomTrait for Path { default: DefaultParamValue::String("identity"), constraint: ParamConstraint::string_option(POSITION_VALUES), }, - super::types::ARROW_PARAM, super::types::LINECAP_PARAM, super::types::LINEJOIN_PARAM, ]; PARAMS } - fn setup_layer(&self, _mappings: &mut Mappings, parameters: &mut Parameters) -> Result<()> { - super::types::validate_arrow_param(parameters).map_err(crate::GgsqlError::ValidationError) - } - fn apply_projection( &self, query: &str, diff --git a/src/plot/layer/geom/segment.rs b/src/plot/layer/geom/segment.rs index 9e2294d77..b200af150 100644 --- a/src/plot/layer/geom/segment.rs +++ b/src/plot/layer/geom/segment.rs @@ -6,7 +6,7 @@ use super::{ DefaultParamValue, GeomTrait, GeomType, ParamConstraint, ParamDefinition, }; use crate::plot::projection::Projection; -use crate::plot::types::{DefaultAestheticValue, ParameterValue, Parameters}; +use crate::plot::types::{DefaultAestheticValue, ParameterValue}; use crate::reader::SqlDialect; use crate::{naming, Mappings, Result}; @@ -30,6 +30,8 @@ impl GeomTrait for Segment { ("linewidth", DefaultAestheticValue::Number(1.0)), ("opacity", DefaultAestheticValue::Number(1.0)), ("linetype", DefaultAestheticValue::String("solid")), + ("arrowstart", DefaultAestheticValue::Null), + ("arrowend", DefaultAestheticValue::Null), ], } } @@ -43,15 +45,10 @@ impl GeomTrait for Segment { }, super::types::LINECAP_PARAM, super::types::AGGREGATE_PARAM, - super::types::ARROW_PARAM, ]; PARAMS } - fn setup_layer(&self, _mappings: &mut Mappings, parameters: &mut Parameters) -> Result<()> { - super::types::validate_arrow_param(parameters).map_err(crate::GgsqlError::ValidationError) - } - fn aggregate_domain_aesthetics(&self) -> Option<&'static [&'static str]> { Some(&[]) } diff --git a/src/plot/layer/geom/types.rs b/src/plot/layer/geom/types.rs index 71f171491..73e59822b 100644 --- a/src/plot/layer/geom/types.rs +++ b/src/plot/layer/geom/types.rs @@ -6,11 +6,6 @@ use crate::plot::aesthetic::parse_position; use crate::reader::SqlDialect; use crate::{naming, plot::types::DefaultAestheticValue, Mappings}; -// Re-export shared types from the central location -use crate::plot::types::{ - ArrayConstraint, ArrayElement, ArrayElementConstraint, ParameterValue, Parameters, - StringConstraint, TypeConstraint, -}; pub use crate::plot::types::{DefaultParamValue, ParamConstraint, ParamDefinition}; // ============================================================================= @@ -36,7 +31,10 @@ pub const SIDE_VALUES: &[&str] = &["both", "left", "top", "right", "bottom"]; /// /// Note: Spelling variants (`colour`/`col` → `color`) are handled separately at parse /// time by `normalise_aes_name()` in `parser/builder.rs`. -pub const AESTHETIC_ALIASES: &[(&str, &[&str])] = &[("color", &["stroke", "fill"])]; +pub const AESTHETIC_ALIASES: &[(&str, &[&str])] = &[ + ("color", &["stroke", "fill"]), + ("arrow", &["arrowstart", "arrowend"]), +]; /// Shared `aggregate` parameter definition. Every geom that opts into the /// Aggregate stat (i.e. `aggregate_domain_aesthetics()` returns `Some(_)`) @@ -49,36 +47,14 @@ pub const AGGREGATE_PARAM: ParamDefinition = ParamDefinition { constraint: ParamConstraint::string_or_string_array_unconstrained(), }; -/// Marker shape vocabulary for the `arrow` parameter. Names map to -/// hephaestus's built-in `arrow-*` shapes in the writer. +/// Marker shape vocabulary for the `arrowstart`/`arrowend` aesthetics and +/// their `arrow` alias. Names map to hephaestus's built-in `arrow-*` shapes +/// in the writer. pub const ARROW_VALUES: &[&str] = &[ "open", "closed", "stealth", "latex", "thin", "wedge", "fishtail", "fork", "feather", "bar", "bracket", "cross", "dot", "square", "diamond", ]; -/// Shared `arrow` parameter definition for geoms that draw lines or -/// segments. A single string sets the end marker only; an array -/// `[start, end]` sets both, with null skipping an end. Each entry is -/// `name` or `name:size` (size in points); the constraint checks the -/// structural shape only, per-entry vocabulary is validated by -/// [`validate_arrow_param`] (the `aggregate` pattern). -pub const ARROW_PARAM: ParamDefinition = ParamDefinition { - name: "arrow", - default: DefaultParamValue::Null, - constraint: ParamConstraint { - number: TypeConstraint::Forbidden, - string: TypeConstraint::Any, - boolean: TypeConstraint::Forbidden, - array: TypeConstraint::Constrained(ArrayConstraint { - element: ArrayElementConstraint::String(StringConstraint::unconstrained()), - min_len: Some(1), - max_len: Some(2), - allow_null_elements: true, - }), - allow_null: true, - }, -}; - /// Parse one `arrow` entry of the form `name` or `name:size` (size in points). pub fn parse_arrow_entry(entry: &str) -> std::result::Result<(&str, Option), String> { let (name, size) = match entry.split_once(':') { @@ -104,22 +80,6 @@ pub fn parse_arrow_entry(entry: &str) -> std::result::Result<(&str, Option) Ok((name, size)) } -/// Per-entry `name[:size]` vocabulary for the `arrow` setting, which -/// [`ARROW_PARAM`]'s structural constraint can't express. -pub fn validate_arrow_param(parameters: &Parameters) -> std::result::Result<(), String> { - match parameters.get("arrow") { - Some(ParameterValue::String(entry)) => parse_arrow_entry(entry).map(|_| ()), - Some(ParameterValue::Array(elems)) => { - for elem in elems { - if let ArrayElement::String(entry) = elem { - parse_arrow_entry(entry)?; - } - } - Ok(()) - } - _ => Ok(()), - } -} /// Stroke cap style values for the `linecap` parameter. pub const LINECAP_VALUES: &[&str] = &["butt", "round", "square"]; @@ -750,12 +710,7 @@ mod tests { #[cfg(test)] mod arrow_tests { - use super::{parse_arrow_entry, validate_arrow_param}; - use crate::plot::types::{ArrayElement, ParameterValue, Parameters}; - - fn params(value: ParameterValue) -> Parameters { - Parameters::from([("arrow".to_string(), value)]) - } + use super::parse_arrow_entry; #[test] fn parses_a_bare_name() { @@ -780,24 +735,4 @@ mod arrow_tests { assert!(parse_arrow_entry("closed:-1").is_err()); assert!(parse_arrow_entry("closed:0").is_err()); } - - #[test] - fn validate_checks_every_array_entry() { - let good = params(ParameterValue::Array(vec![ - ArrayElement::Null, - ArrayElement::String("open:3".to_string()), - ])); - assert!(validate_arrow_param(&good).is_ok()); - let bad = params(ParameterValue::Array(vec![ - ArrayElement::String("open".to_string()), - ArrayElement::String("nope".to_string()), - ])); - assert!(validate_arrow_param(&bad).is_err()); - } - - #[test] - fn validate_passes_without_the_param() { - assert!(validate_arrow_param(&Parameters::new()).is_ok()); - assert!(validate_arrow_param(¶ms(ParameterValue::Null)).is_ok()); - } } diff --git a/src/validate.rs b/src/validate.rs index 72ebac3b6..a9aefcb4b 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -275,15 +275,6 @@ pub fn validate(query: &str) -> Result { location: None, }); } - // Same rationale as the aggregate check above: surface `arrow` - // vocabulary errors without executing. - if let Err(e) = crate::plot::layer::geom::types::validate_arrow_param(&layer.parameters) - { - errors.push(ValidationError { - message: format!("{}: {}", context, e), - location: None, - }); - } } } diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index bdc3a29b0..bce198c50 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -223,7 +223,7 @@ mod tests { "SELECT 1 AS x, 2 AS y, 3 AS x2, 4 AS y2 \ VISUALISE x AS pos1, y AS pos2 \ DRAW segment MAPPING x2 AS pos1end, y2 AS pos2end \ - SETTING arrow => ['open', 'closed']", + SETTING arrowstart => 'open', arrowend => 'closed'", ); } diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index ae237f6aa..5e37a4005 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -626,42 +626,41 @@ pub fn side_sign(layer: &Layer) -> Option { /// them to trail it. const INVERTED_ARROWS: &[&str] = &["fishtail", "fork", "feather"]; -/// The layer's `arrow` SETTING as endpoint marker channels: a bare string sets -/// the end marker, an array `[start, end]` sets each end with null skipping -/// it. Vocabulary names map to hephaestus's built-in `arrow-*` shapes. +/// The layer's arrow aesthetics as endpoint marker channels: `arrowstart` +/// and `arrowend` literal values of the form `name` or `name:size` (the +/// `arrow` alias has already been split onto both by execution). Vocabulary +/// names map to hephaestus's built-in `arrow-*` shapes. pub fn arrow_markers(layer: &Layer) -> Vec<(&'static str, HValue)> { let mut channels = Vec::new(); - let mut push = |marker: &'static str, invert: &'static str, entry: &String| { - // Invalid entries were rejected at execute/validate time; skip defensively. + for (aesthetic, marker, size_channel, invert) in [ + ( + "arrowstart", + "start_marker", + "start_marker_size", + "start_marker_invert", + ), + ( + "arrowend", + "end_marker", + "end_marker_size", + "end_marker_invert", + ), + ] { + let Some(ParameterValue::String(entry)) = layer.get_literal(aesthetic) else { + continue; + }; + // Unknown names resolve to no marker, same as hephaestus treats + // unknown shape names. let Ok((name, size)) = parse_arrow_entry(entry) else { - return; + continue; }; channels.push((marker, HValue::String(format!("arrow-{name}").into()))); if let Some(size) = size { - let size_channel = match marker { - "start_marker" => "start_marker_size", - _ => "end_marker_size", - }; channels.push((size_channel, HValue::Number(size))); } if INVERTED_ARROWS.contains(&name) { channels.push((invert, HValue::Bool(true))); } - }; - match layer.parameters.get("arrow") { - Some(ParameterValue::String(name)) => push("end_marker", "end_marker_invert", name), - Some(ParameterValue::Array(a)) => { - let ends = [ - ("start_marker", "start_marker_invert"), - ("end_marker", "end_marker_invert"), - ]; - for ((marker, invert), elem) in ends.into_iter().zip(a) { - if let crate::plot::types::ArrayElement::String(s) = elem { - push(marker, invert, s); - } - } - } - _ => {} } channels } @@ -1162,29 +1161,22 @@ fn pin_constants( mod tests { use super::arrow_markers; use crate::plot::layer::geom::Geom; - use crate::plot::types::{ArrayElement, ParameterValue}; - use crate::Layer; + use crate::plot::types::ParameterValue; + use crate::{AestheticValue, Layer}; - fn layer_with_arrow(value: Option) -> Layer { + fn layer_with_arrows(start: Option<&str>, end: Option<&str>) -> Layer { let mut layer = Layer::new(Geom::segment()); - if let Some(value) = value { - layer.parameters.insert("arrow".into(), value); + for (aesthetic, value) in [("arrowstart", start), ("arrowend", end)] { + if let Some(entry) = value { + layer.mappings.insert( + aesthetic, + AestheticValue::Literal(ParameterValue::String(entry.into())), + ); + } } layer } - fn strings(elems: &[Option<&str>]) -> ParameterValue { - ParameterValue::Array( - elems - .iter() - .map(|e| match e { - Some(s) => ArrayElement::String(s.to_string()), - None => ArrayElement::Null, - }) - .collect(), - ) - } - fn markers(layer: &Layer) -> Vec<(&'static str, String)> { arrow_markers(layer) .into_iter() @@ -1193,17 +1185,8 @@ mod tests { } #[test] - fn a_bare_string_sets_the_end_marker_only() { - let layer = layer_with_arrow(Some(ParameterValue::String("closed".into()))); - assert_eq!( - markers(&layer), - vec![("end_marker", "String(\"arrow-closed\")".to_string())] - ); - } - - #[test] - fn an_array_sets_start_then_end() { - let layer = layer_with_arrow(Some(strings(&[Some("open"), Some("closed")]))); + fn each_end_gets_its_own_marker() { + let layer = layer_with_arrows(Some("open"), Some("closed")); assert_eq!( markers(&layer), vec![ @@ -1214,28 +1197,25 @@ mod tests { } #[test] - fn null_elements_skip_an_end() { - let start_only = layer_with_arrow(Some(strings(&[Some("open"), None]))); + fn a_single_end_can_be_set_on_its_own() { assert_eq!( - markers(&start_only), + markers(&layer_with_arrows(Some("open"), None)), vec![("start_marker", "String(\"arrow-open\")".to_string())] ); - let end_only = layer_with_arrow(Some(strings(&[None, Some("closed")]))); assert_eq!( - markers(&end_only), + markers(&layer_with_arrows(None, Some("closed"))), vec![("end_marker", "String(\"arrow-closed\")".to_string())] ); } #[test] - fn no_param_or_null_draws_no_markers() { - assert!(markers(&layer_with_arrow(None)).is_empty()); - assert!(markers(&layer_with_arrow(Some(ParameterValue::Null))).is_empty()); + fn no_mapping_draws_no_markers() { + assert!(markers(&layer_with_arrows(None, None)).is_empty()); } #[test] fn a_name_with_size_sets_the_size_channel() { - let layer = layer_with_arrow(Some(ParameterValue::String("closed:6".into()))); + let layer = layer_with_arrows(None, Some("closed:6")); assert_eq!( markers(&layer), vec![ @@ -1247,7 +1227,7 @@ mod tests { #[test] fn tail_style_shapes_are_inverted() { - let layer = layer_with_arrow(Some(strings(&[Some("feather"), Some("closed")]))); + let layer = layer_with_arrows(Some("feather"), Some("closed")); assert_eq!( markers(&layer), vec![ diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index d1c8926b6..eb401b95a 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -919,6 +919,8 @@ module.exports = grammar({ 'color', 'colour', 'fill', 'stroke', 'opacity', // Size and shape 'size', 'shape', 'linetype', 'linewidth', 'width', 'height', + // Line-end markers + 'arrow', 'arrowstart', 'arrowend', // Text aesthetics 'label', 'typeface', 'fontweight', 'italic', 'fontsize', 'hjust', 'vjust', 'rotation', // Specialty aesthetics, diff --git a/tree-sitter-ggsql/queries/highlights.scm b/tree-sitter-ggsql/queries/highlights.scm index 4776e322f..55127b4de 100644 --- a/tree-sitter-ggsql/queries/highlights.scm +++ b/tree-sitter-ggsql/queries/highlights.scm @@ -63,6 +63,10 @@ "linewidth" "width" "height" + ; Line-end markers + "arrow" + "arrowstart" + "arrowend" ; Text aesthetics "label" "typeface" From a594ac479c7b7819c43426469f88d7b34d271487 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 4 Nov 2026 16:46:09 +0100 Subject: [PATCH 14/21] setup arrow scale machinery --- src/execute/mod.rs | 60 +++++++++++++++++++++++++++ src/plot/scale/mod.rs | 2 + src/plot/scale/palettes.rs | 21 ++++++++-- src/plot/scale/scale_type/discrete.rs | 10 +++++ 4 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 1ef2118c7..e0bcaebbc 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -2197,6 +2197,66 @@ mod tests { ); } + #[cfg(feature = "duckdb")] + #[test] + fn test_arrow_mapping_resolves_to_discrete_palette() { + use crate::plot::scale::OutputRange; + use crate::plot::types::ArrayElement; + use crate::plot::ScaleType; + + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT 1 AS x, 1 AS y, 'a' AS kind + UNION ALL SELECT 2, 2, 'b' + VISUALISE x AS x, y AS y + DRAW line MAPPING kind AS arrowend + "#; + + let result = prepare_data_with_reader(query, &reader).unwrap(); + let scale = result.specs[0] + .find_scale("arrowend") + .expect("arrowend scale should exist"); + + assert_eq!(scale.scale_type, Some(ScaleType::discrete())); + let Some(OutputRange::Array(range)) = &scale.output_range else { + panic!("arrowend scale should resolve to the default arrow palette"); + }; + assert_eq!( + range.first(), + Some(&ArrayElement::String("closed".to_string())) + ); + } + + #[cfg(feature = "duckdb")] + #[test] + fn test_arrow_alias_groups_lines_by_arrow_value() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT 1 AS x, 1 AS y, 'a' AS kind + UNION ALL SELECT 2, 2, 'a' + UNION ALL SELECT 1, 2, 'b' + UNION ALL SELECT 2, 3, 'b' + VISUALISE x AS x, y AS y + DRAW line MAPPING kind AS arrow + "#; + + let result = prepare_data_with_reader(query, &reader).unwrap(); + let layer = &result.specs[0].layers[0]; + + // The alias split runs before the grouping pass, so the split + // mappings exist and the discrete arrow column lands in partition_by + // (stored under the aesthetic-prefixed names). + assert!(layer.mappings.contains_key("arrowstart")); + assert!(layer.mappings.contains_key("arrowend")); + assert!(!layer.mappings.contains_key("arrow")); + assert!(layer + .partition_by + .contains(&crate::naming::aesthetic_column("arrowstart"))); + assert!(layer + .partition_by + .contains(&crate::naming::aesthetic_column("arrowend"))); + } + #[cfg(feature = "duckdb")] #[test] fn test_visualise_from_cte() { diff --git a/src/plot/scale/mod.rs b/src/plot/scale/mod.rs index 9065818df..b192b23fd 100644 --- a/src/plot/scale/mod.rs +++ b/src/plot/scale/mod.rs @@ -66,6 +66,8 @@ pub fn gets_default_scale(aesthetic: &str) -> bool { | "width" | "height" // Other material aesthetics | "opacity" | "shape" | "linetype" + // Line-end markers + | "arrowstart" | "arrowend" ) } diff --git a/src/plot/scale/palettes.rs b/src/plot/scale/palettes.rs index e5faf0d34..ca188a84c 100644 --- a/src/plot/scale/palettes.rs +++ b/src/plot/scale/palettes.rs @@ -1978,6 +1978,12 @@ pub const LINETYPES: &[&str] = &[ "solid", "dashed", "dotted", "dotdash", "longdash", "twodash", ]; +/// Default arrow palette for the `arrowstart`/`arrowend` aesthetics: +/// visually distinct line-end markers, cycled over categories. +pub const ARROWS: &[&str] = &[ + "closed", "open", "stealth", "latex", "wedge", "dot", "diamond", "bar", +]; + // ============================================================================= // Lookup Functions // ============================================================================= @@ -2100,6 +2106,14 @@ pub fn get_linetype_palette(name: &str) -> Option<&'static [&'static str]> { } } +/// Look up an arrow palette by name. +pub fn get_arrow_palette(name: &str) -> Option<&'static [&'static str]> { + match name.to_lowercase().as_str() { + "categorical" | "default" => Some(ARROWS), + _ => None, + } +} + /// Look up a palette by aesthetic and name, returning as ArrayElements. /// /// This helper consolidates the aesthetic-based palette lookup with proper error handling, @@ -2107,7 +2121,7 @@ pub fn get_linetype_palette(name: &str) -> Option<&'static [&'static str]> { /// /// # Arguments /// -/// * `aesthetic` - The aesthetic type ("shape", "linetype", "color", "fill", "stroke") +/// * `aesthetic` - The aesthetic type ("shape", "linetype", "arrow", "color", "fill", "stroke") /// * `name` - The palette name to look up /// /// # Returns @@ -2126,10 +2140,11 @@ pub fn lookup_palette(aesthetic: &str, name: &str) -> Result, let palette = match aesthetic { "shape" => get_shape_palette(name), "linetype" => get_linetype_palette(name), + "arrow" | "arrowstart" | "arrowend" => get_arrow_palette(name), "color" | "fill" | "stroke" => get_color_palette(name), _ => { return Err( - "Palettes are only valid for color, fill, stroke, shape, and linetype aesthetics" + "Palettes are only valid for color, fill, stroke, shape, linetype, and arrow aesthetics" .to_string(), ); } @@ -2425,7 +2440,7 @@ mod tests { let err = result.unwrap_err(); assert_eq!( err, - "Palettes are only valid for color, fill, stroke, shape, and linetype aesthetics" + "Palettes are only valid for color, fill, stroke, shape, linetype, and arrow aesthetics" ); // Must not leak the internal aesthetic name or the palette name. assert!(!err.contains("pos1")); diff --git a/src/plot/scale/scale_type/discrete.rs b/src/plot/scale/scale_type/discrete.rs index f4d19a088..6975e7f53 100644 --- a/src/plot/scale/scale_type/discrete.rs +++ b/src/plot/scale/scale_type/discrete.rs @@ -181,6 +181,16 @@ impl ScaleTypeTrait for Discrete { .collect(), )) } + "arrowstart" | "arrowend" => { + let palette = palettes::get_arrow_palette("default") + .ok_or_else(|| "Default arrow palette not found".to_string())?; + Ok(Some( + palette + .iter() + .map(|s| ArrayElement::String(s.to_string())) + .collect(), + )) + } _ => Ok(None), } } From ede0d1059cc1509d0a730bc6bf5a2247892de039 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 4 Nov 2026 17:27:39 +0100 Subject: [PATCH 15/21] writer wiring for arrow aesthetics --- src/tests/e2e.rs | 20 +++ src/writer/hephaestus/compose.rs | 1 + src/writer/hephaestus/geom/line.rs | 16 ++- src/writer/hephaestus/geom/segment.rs | 16 ++- src/writer/hephaestus/mod.rs | 21 ++++ src/writer/hephaestus/scales.rs | 14 +++ src/writer/hephaestus/wiring.rs | 167 +++++++++++--------------- src/writer/vegalite/mod.rs | 6 + 8 files changed, 152 insertions(+), 109 deletions(-) diff --git a/src/tests/e2e.rs b/src/tests/e2e.rs index d18539fe5..254a802ed 100644 --- a/src/tests/e2e.rs +++ b/src/tests/e2e.rs @@ -39,6 +39,26 @@ fn test_execute_and_render() { assert!(result.contains("point")); } +#[test] +fn test_vegalite_ignores_mapped_arrow_aesthetics() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let spec = reader + .execute( + "SELECT * FROM (VALUES (1, 2, 'a'), (2, 3, 'a'), (1, 1, 'b'), (2, 2, 'b')) \ + AS t(x, y, kind) \ + VISUALISE x, y DRAW line MAPPING kind AS arrowend", + ) + .unwrap(); + + // The Vega-Lite writer has no arrow support: the aesthetic resolves a + // scale but must produce no encoding channel. (The prefixed data column + // and the `detail` grouping entry are fine — they split the lines.) + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + assert!(result.contains("line")); + assert!(!result.contains("\"arrowend\":")); +} + #[test] fn test_execute_metadata() { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); diff --git a/src/writer/hephaestus/compose.rs b/src/writer/hephaestus/compose.rs index 792042492..cf08c14f4 100644 --- a/src/writer/hephaestus/compose.rs +++ b/src/writer/hephaestus/compose.rs @@ -120,6 +120,7 @@ pub fn build_composition( "fill" | "stroke" => scales::RangeKind::Color, "shape" => scales::RangeKind::Shape, "linetype" => scales::RangeKind::Linetype, + "arrowstart" | "arrowend" => scales::RangeKind::Arrow, // The text geom's font aesthetics: a scale over them resolves a // range of family names / weights, not numbers. "typeface" => scales::RangeKind::Text, diff --git a/src/writer/hephaestus/geom/line.rs b/src/writer/hephaestus/geom/line.rs index 61ad7b8fc..8a4ed8513 100644 --- a/src/writer/hephaestus/geom/line.rs +++ b/src/writer/hephaestus/geom/line.rs @@ -8,8 +8,8 @@ use hephaestus::color::rgb8; use super::super::scales::RangeKind; use super::super::wiring::{ - arrow_markers, stroke_style_constants, Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, - PanelAxis, PositionSpec, + stroke_style_constants, Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, PanelAxis, + PositionSpec, }; use crate::plot::layer::geom::GeomType; @@ -50,12 +50,16 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { RangeKind::Linetype, MatDefault::None, ), + MaterialSpec::new( + "arrowstart", + "start_marker", + RangeKind::Arrow, + MatDefault::None, + ), + MaterialSpec::new("arrowend", "end_marker", RangeKind::Arrow, MatDefault::None), ], raw_strings: &[], - constants: stroke_style_constants(ctx.layer) - .into_iter() - .chain(arrow_markers(ctx.layer)) - .collect(), + constants: stroke_style_constants(ctx.layer), raw_numbers: vec![], data_channels: vec![], legend_key: LegendKind::Line, diff --git a/src/writer/hephaestus/geom/segment.rs b/src/writer/hephaestus/geom/segment.rs index 11f3d8519..67de56dea 100644 --- a/src/writer/hephaestus/geom/segment.rs +++ b/src/writer/hephaestus/geom/segment.rs @@ -13,8 +13,8 @@ use hephaestus::plot::{Plot as HPlot, SegmentGeom}; use super::super::channels::{aesthetic_column_name, column_to_channel, column_to_f64}; use super::super::scales::RangeKind; use super::super::wiring::{ - arrow_markers, constant_number, dodge_offsets, stroke_style_constants, wire_material, BandAxes, - Ctx, GeomSpec, LegendKind, MatDefault, MaterialSpec, PanelAxis, PositionSpec, + constant_number, dodge_offsets, stroke_style_constants, wire_material, BandAxes, Ctx, GeomSpec, + LegendKind, MatDefault, MaterialSpec, PanelAxis, PositionSpec, }; use super::hinge::{caps, hinge_points}; use crate::plot::layer::geom::GeomType; @@ -58,10 +58,7 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { positions, material: material(), raw_strings: &[], - constants: stroke_style_constants(ctx.layer) - .into_iter() - .chain(arrow_markers(ctx.layer)) - .collect(), + constants: stroke_style_constants(ctx.layer), raw_numbers, data_channels: vec![], legend_key: LegendKind::Line, @@ -97,6 +94,13 @@ fn material() -> Vec { RangeKind::Linetype, MatDefault::None, ), + MaterialSpec::new( + "arrowstart", + "start_marker", + RangeKind::Arrow, + MatDefault::None, + ), + MaterialSpec::new("arrowend", "end_marker", RangeKind::Arrow, MatDefault::None), ] } diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index bce198c50..c5306b784 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -235,6 +235,27 @@ mod tests { ); } + #[test] + fn renders_mapped_segment_arrows() { + assert_renders( + "SELECT x, y, x2, y2, kind FROM (VALUES \ + (1, 2, 3, 4, 'a'), (2, 1, 4, 3, 'b')) t(x, y, x2, y2, kind) \ + VISUALISE x AS pos1, y AS pos2 \ + DRAW segment MAPPING x2 AS pos1end, y2 AS pos2end, kind AS arrowend", + ); + } + + #[test] + fn renders_mapped_line_arrows_with_legend() { + assert_renders( + "SELECT x, y, kind FROM (VALUES \ + (1, 2, 'a'), (2, 3, 'a'), (3, 1, 'a'), \ + (1, 1, 'b'), (2, 2, 'b'), (3, 3, 'b')) t(x, y, kind) \ + VISUALISE x AS x, y AS y \ + DRAW line MAPPING kind AS arrow", + ); + } + #[test] fn renders_categorical_color_with_legend() { assert_renders( diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index 559512fab..d83e18222 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -17,6 +17,7 @@ use hephaestus::scales::value::{ use hephaestus::scales::Direction; use super::channels::NULL_CATEGORY; +use crate::plot::layer::geom::types::parse_arrow_entry; use crate::plot::scale::{linetype_to_stroke_dash, TransformKind as GTransform}; use crate::plot::{ArrayElement, OutputRange, ParameterValue, Scale as GScale, ScaleTypeKind}; @@ -46,6 +47,10 @@ pub enum RangeKind { FontWeight, /// Rotation in degrees, as ggsql resolves it → radians, hephaestus's unit. Angle, + /// Line-end marker: ggsql arrow names → hephaestus's `arrow-*` shapes. + /// A `name:size` entry's size does not survive scaling: the marker-size + /// channel is not part of the scale, so only the shape name is ranged. + Arrow, } /// Build a hephaestus scale from a resolved ggsql scale. `None` when ggsql @@ -353,6 +358,15 @@ fn apply_output_range(hs: HScale, kind: RangeKind, values: &[ArrayElement]) -> H .filter_map(|e| e.to_f64()) .map(f64::to_radians), ), + RangeKind::Arrow => hs.range_strings(values.into_iter().map(|e| { + let entry = e.to_key_string(); + match parse_arrow_entry(&entry) { + Ok((name, _)) => Arc::from(format!("arrow-{name}").as_str()), + // Unknown names pass through; hephaestus draws no marker for + // a shape it doesn't know, same as an unknown shape name. + Err(_) => Arc::from(entry.as_str()), + } + })), // Neither a position nor a boolean has an output range: the former maps to // a panel fraction, the latter is only ever a literal or identity value. RangeKind::Position | RangeKind::Bool => hs, diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index 5e37a4005..a809d11e1 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -418,6 +418,29 @@ pub fn wire_material( .collect(); builder.set(m.channel, Raw(radians)); } + // An identity arrow column holds ggsql arrow names; each row + // resolves to a shape (unknown → no marker) and, for tail + // shapes, its invert flag. A `name:size` entry's size is not + // honoured here — only literals carry sizes. + RangeKind::Arrow => { + let entries = column_to_strings(ctx.df, col)?; + let mut shapes = Vec::with_capacity(entries.len()); + let mut inverts = Vec::with_capacity(entries.len()); + let mut any_invert = false; + for entry in &entries { + let (shape, invert) = match map_arrow_entry(entry) { + Some((shape, _, invert)) => (shape, invert), + None => (String::new(), false), + }; + any_invert |= invert; + inverts.push(invert); + shapes.push(shape); + } + builder.set(m.channel, Raw(shapes)); + if any_invert { + builder.set(format!("{}_invert", m.channel), Raw(inverts)); + } + } _ => { builder.set(m.channel, Raw(column_to_f64(ctx.df, col)?)); } @@ -505,6 +528,19 @@ fn set_literal_channel( builder.set(channel, Raw(HValue::Linetype(map_linetype(s)))); true } + (RangeKind::Arrow, ParameterValue::String(s)) => { + let Some((shape, size, invert)) = map_arrow_entry(s) else { + return false; + }; + builder.set(channel, Raw(shape)); + if let Some(size) = size { + builder.set(format!("{channel}_size"), Raw(size)); + } + if invert { + builder.set(format!("{channel}_invert"), Raw(true)); + } + true + } (RangeKind::Number, ParameterValue::Number(n)) if n.is_finite() => { builder.set(channel, Raw(*n)); true @@ -626,43 +662,17 @@ pub fn side_sign(layer: &Layer) -> Option { /// them to trail it. const INVERTED_ARROWS: &[&str] = &["fishtail", "fork", "feather"]; -/// The layer's arrow aesthetics as endpoint marker channels: `arrowstart` -/// and `arrowend` literal values of the form `name` or `name:size` (the -/// `arrow` alias has already been split onto both by execution). Vocabulary -/// names map to hephaestus's built-in `arrow-*` shapes. -pub fn arrow_markers(layer: &Layer) -> Vec<(&'static str, HValue)> { - let mut channels = Vec::new(); - for (aesthetic, marker, size_channel, invert) in [ - ( - "arrowstart", - "start_marker", - "start_marker_size", - "start_marker_invert", - ), - ( - "arrowend", - "end_marker", - "end_marker_size", - "end_marker_invert", - ), - ] { - let Some(ParameterValue::String(entry)) = layer.get_literal(aesthetic) else { - continue; - }; - // Unknown names resolve to no marker, same as hephaestus treats - // unknown shape names. - let Ok((name, size)) = parse_arrow_entry(entry) else { - continue; - }; - channels.push((marker, HValue::String(format!("arrow-{name}").into()))); - if let Some(size) = size { - channels.push((size_channel, HValue::Number(size))); - } - if INVERTED_ARROWS.contains(&name) { - channels.push((invert, HValue::Bool(true))); - } - } - channels +/// Resolve an arrow entry (`name` or `name:size`) to hephaestus terms: the +/// `arrow-*` shape name, an explicit size in points, and whether the shape +/// needs inverting. Unknown names resolve to `None` — hephaestus draws no +/// marker for a shape it doesn't know, same as an unknown shape name. +fn map_arrow_entry(entry: &str) -> Option<(String, Option, bool)> { + let (name, size) = parse_arrow_entry(entry).ok()?; + Some(( + format!("arrow-{name}"), + size, + INVERTED_ARROWS.contains(&name), + )) } /// The layer's `linecap`/`linejoin` SETTINGs as constant `cap`/`join` channels. /// Validation has already rejected settings the geom doesn't declare, and the @@ -975,6 +985,17 @@ fn constant_material(ctx: &Ctx, aesthetic: &str, kind: RangeKind) -> Option { + let entry = col + .and_then(|c| column_to_strings(ctx.df, c).ok()) + .and_then(|v| v.first().cloned()) + .or_else(|| match literal { + Some(ParameterValue::String(s)) => Some(s.clone()), + _ => None, + })?; + let (shape, _, _) = map_arrow_entry(&entry)?; + Some(HValue::String(shape.into())) + } RangeKind::Bool => { if let Some(b) = col .and_then(|c| column_to_bool(ctx.df, c).ok()) @@ -1159,82 +1180,34 @@ fn pin_constants( #[cfg(test)] mod tests { - use super::arrow_markers; - use crate::plot::layer::geom::Geom; - use crate::plot::types::ParameterValue; - use crate::{AestheticValue, Layer}; - - fn layer_with_arrows(start: Option<&str>, end: Option<&str>) -> Layer { - let mut layer = Layer::new(Geom::segment()); - for (aesthetic, value) in [("arrowstart", start), ("arrowend", end)] { - if let Some(entry) = value { - layer.mappings.insert( - aesthetic, - AestheticValue::Literal(ParameterValue::String(entry.into())), - ); - } - } - layer - } - - fn markers(layer: &Layer) -> Vec<(&'static str, String)> { - arrow_markers(layer) - .into_iter() - .map(|(ch, v)| (ch, format!("{v:?}"))) - .collect() - } + use super::map_arrow_entry; #[test] - fn each_end_gets_its_own_marker() { - let layer = layer_with_arrows(Some("open"), Some("closed")); + fn a_bare_name_maps_to_the_prefixed_shape() { assert_eq!( - markers(&layer), - vec![ - ("start_marker", "String(\"arrow-open\")".to_string()), - ("end_marker", "String(\"arrow-closed\")".to_string()) - ] + map_arrow_entry("closed"), + Some(("arrow-closed".into(), None, false)) ); } #[test] - fn a_single_end_can_be_set_on_its_own() { - assert_eq!( - markers(&layer_with_arrows(Some("open"), None)), - vec![("start_marker", "String(\"arrow-open\")".to_string())] - ); + fn a_name_with_size_keeps_the_size() { assert_eq!( - markers(&layer_with_arrows(None, Some("closed"))), - vec![("end_marker", "String(\"arrow-closed\")".to_string())] + map_arrow_entry("closed:6"), + Some(("arrow-closed".into(), Some(6.0), false)) ); } #[test] - fn no_mapping_draws_no_markers() { - assert!(markers(&layer_with_arrows(None, None)).is_empty()); - } - - #[test] - fn a_name_with_size_sets_the_size_channel() { - let layer = layer_with_arrows(None, Some("closed:6")); + fn tail_style_shapes_need_inverting() { assert_eq!( - markers(&layer), - vec![ - ("end_marker", "String(\"arrow-closed\")".to_string()), - ("end_marker_size", "Number(6.0)".to_string()) - ] + map_arrow_entry("feather"), + Some(("arrow-feather".into(), None, true)) ); } #[test] - fn tail_style_shapes_are_inverted() { - let layer = layer_with_arrows(Some("feather"), Some("closed")); - assert_eq!( - markers(&layer), - vec![ - ("start_marker", "String(\"arrow-feather\")".to_string()), - ("start_marker_invert", "Bool(true)".to_string()), - ("end_marker", "String(\"arrow-closed\")".to_string()) - ] - ); + fn unknown_names_resolve_to_no_marker() { + assert_eq!(map_arrow_entry("closde"), None); } } diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 1419a6644..9dcf9a4f0 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -311,6 +311,12 @@ fn build_layer_encoding( continue; } + // Skip arrow aesthetics: Vega-Lite has no line-end marker channel, so + // a mapped arrow resolves a scale but produces no encoding. + if aesthetic == "arrowstart" || aesthetic == "arrowend" { + continue; + } + let mut channel_name = map_aesthetic_name(aesthetic, &aesthetic_ctx, projection); // Opacity is retargeted to the fill when fill is supported if channel_name == "opacity" && layer.mappings.contains_key("fill") { From 230539ba064ea1d3d8bee4084dbe7952c4599945 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 5 Nov 2026 12:41:29 +0100 Subject: [PATCH 16/21] Separate arrow sizes as individual aesthetics --- src/plot/aesthetic.rs | 6 ++- src/plot/layer/geom/line.rs | 2 + src/plot/layer/geom/path.rs | 2 + src/plot/layer/geom/segment.rs | 2 + src/plot/layer/geom/types.rs | 57 ++++++++++------------- src/plot/scale/mod.rs | 2 +- src/plot/scale/scale_type/continuous.rs | 6 +++ src/writer/hephaestus/compose.rs | 23 +-------- src/writer/hephaestus/geom/line.rs | 12 +++++ src/writer/hephaestus/geom/segment.rs | 12 +++++ src/writer/hephaestus/mod.rs | 20 ++++++++ src/writer/hephaestus/scales.rs | 59 ++++++++++++++++++++++-- src/writer/hephaestus/wiring.rs | 58 +++++++++-------------- src/writer/vegalite/mod.rs | 5 +- tree-sitter-ggsql/grammar.js | 1 + tree-sitter-ggsql/queries/highlights.scm | 3 ++ 16 files changed, 171 insertions(+), 99 deletions(-) diff --git a/src/plot/aesthetic.rs b/src/plot/aesthetic.rs index 01e98e04c..254e35eb8 100644 --- a/src/plot/aesthetic.rs +++ b/src/plot/aesthetic.rs @@ -57,7 +57,8 @@ pub const USER_FACET_AESTHETICS: &[&str] = &["panel", "row", "column"]; /// These include: /// - Color aesthetics: color, colour, fill, stroke, opacity /// - Size/shape aesthetics: size, shape, linetype, linewidth -/// - Line-end marker aesthetics: arrow (alias), arrowstart, arrowend +/// - Line-end marker aesthetics: arrow/arrowsize (aliases), arrowstart, +/// arrowend, arrowstartsize, arrowendsize /// - Dimension aesthetics: width, height /// - Text aesthetics: label, typeface, fontweight, italic, hjust, vjust pub const MATERIAL_AESTHETICS: &[&str] = &[ @@ -73,6 +74,9 @@ pub const MATERIAL_AESTHETICS: &[&str] = &[ "arrow", "arrowstart", "arrowend", + "arrowsize", + "arrowstartsize", + "arrowendsize", "width", "height", "label", diff --git a/src/plot/layer/geom/line.rs b/src/plot/layer/geom/line.rs index d00414793..7333eb0b3 100644 --- a/src/plot/layer/geom/line.rs +++ b/src/plot/layer/geom/line.rs @@ -33,6 +33,8 @@ impl GeomTrait for Line { ("linetype", DefaultAestheticValue::String("solid")), ("arrowstart", DefaultAestheticValue::Null), ("arrowend", DefaultAestheticValue::Null), + ("arrowstartsize", DefaultAestheticValue::Null), + ("arrowendsize", DefaultAestheticValue::Null), ], } } diff --git a/src/plot/layer/geom/path.rs b/src/plot/layer/geom/path.rs index c78504286..dbe6cb4dc 100644 --- a/src/plot/layer/geom/path.rs +++ b/src/plot/layer/geom/path.rs @@ -30,6 +30,8 @@ impl GeomTrait for Path { ("linetype", DefaultAestheticValue::String("solid")), ("arrowstart", DefaultAestheticValue::Null), ("arrowend", DefaultAestheticValue::Null), + ("arrowstartsize", DefaultAestheticValue::Null), + ("arrowendsize", DefaultAestheticValue::Null), ], } } diff --git a/src/plot/layer/geom/segment.rs b/src/plot/layer/geom/segment.rs index b200af150..3726dcb02 100644 --- a/src/plot/layer/geom/segment.rs +++ b/src/plot/layer/geom/segment.rs @@ -32,6 +32,8 @@ impl GeomTrait for Segment { ("linetype", DefaultAestheticValue::String("solid")), ("arrowstart", DefaultAestheticValue::Null), ("arrowend", DefaultAestheticValue::Null), + ("arrowstartsize", DefaultAestheticValue::Null), + ("arrowendsize", DefaultAestheticValue::Null), ], } } diff --git a/src/plot/layer/geom/types.rs b/src/plot/layer/geom/types.rs index 73e59822b..f289ddac6 100644 --- a/src/plot/layer/geom/types.rs +++ b/src/plot/layer/geom/types.rs @@ -34,6 +34,7 @@ pub const SIDE_VALUES: &[&str] = &["both", "left", "top", "right", "bottom"]; pub const AESTHETIC_ALIASES: &[(&str, &[&str])] = &[ ("color", &["stroke", "fill"]), ("arrow", &["arrowstart", "arrowend"]), + ("arrowsize", &["arrowstartsize", "arrowendsize"]), ]; /// Shared `aggregate` parameter definition. Every geom that opts into the @@ -55,29 +56,24 @@ pub const ARROW_VALUES: &[&str] = &[ "bracket", "cross", "dot", "square", "diamond", ]; -/// Parse one `arrow` entry of the form `name` or `name:size` (size in points). -pub fn parse_arrow_entry(entry: &str) -> std::result::Result<(&str, Option), String> { - let (name, size) = match entry.split_once(':') { - Some((name, size)) => { - let size: f64 = size.parse().map_err(|_| { - format!("invalid arrow '{entry}'; expected 'name' or 'name:size', e.g. 'closed:6'") - })?; - (name, Some(size)) - } - None => (entry, None), - }; - if !ARROW_VALUES.contains(&name) { - return Err(format!( - "unknown arrow '{name}'; expected one of {}", - crate::util::or_list_quoted(ARROW_VALUES, '\'') - )); +/// Parse one `arrow` entry into its shape name. Sizes are a separate +/// aesthetic (`arrowstartsize`/`arrowendsize`), so a `name:size` entry is +/// rejected with a pointer at them. +pub fn parse_arrow_name(entry: &str) -> std::result::Result<&str, String> { + if ARROW_VALUES.contains(&entry) { + return Ok(entry); } - if size.is_some_and(|s| !(s > 0.0 && s.is_finite())) { - return Err(format!( - "invalid arrow size in '{entry}'; size must be a positive number of points" - )); + if let Some((name, _)) = entry.split_once(':') { + if ARROW_VALUES.contains(&name) { + return Err(format!( + "invalid arrow '{entry}'; set the size with the arrowstartsize/arrowendsize aesthetic instead" + )); + } } - Ok((name, size)) + Err(format!( + "unknown arrow '{entry}'; expected one of {}", + crate::util::or_list_quoted(ARROW_VALUES, '\'') + )) } /// Stroke cap style values for the `linecap` parameter. @@ -710,29 +706,24 @@ mod tests { #[cfg(test)] mod arrow_tests { - use super::parse_arrow_entry; + use super::parse_arrow_name; #[test] fn parses_a_bare_name() { - assert_eq!(parse_arrow_entry("closed"), Ok(("closed", None))); - } - - #[test] - fn parses_a_name_with_size() { - assert_eq!(parse_arrow_entry("closed:6"), Ok(("closed", Some(6.0)))); + assert_eq!(parse_arrow_name("closed"), Ok("closed")); } #[test] fn rejects_an_unknown_name() { - assert!(parse_arrow_entry("closde") + assert!(parse_arrow_name("closde") .unwrap_err() .contains("unknown arrow 'closde'")); } #[test] - fn rejects_a_non_numeric_or_non_positive_size() { - assert!(parse_arrow_entry("closed:big").is_err()); - assert!(parse_arrow_entry("closed:-1").is_err()); - assert!(parse_arrow_entry("closed:0").is_err()); + fn rejects_a_name_with_size() { + assert!(parse_arrow_name("closed:6") + .unwrap_err() + .contains("arrowstartsize/arrowendsize")); } } diff --git a/src/plot/scale/mod.rs b/src/plot/scale/mod.rs index b192b23fd..f109b8f3e 100644 --- a/src/plot/scale/mod.rs +++ b/src/plot/scale/mod.rs @@ -61,7 +61,7 @@ pub fn gets_default_scale(aesthetic: &str) -> bool { // Color aesthetics (color/colour/col already split to fill/stroke) "fill" | "stroke" // Size aesthetics - | "size" | "linewidth" + | "size" | "linewidth" | "arrowstartsize" | "arrowendsize" // Dimension aesthetics | "width" | "height" // Other material aesthetics diff --git a/src/plot/scale/scale_type/continuous.rs b/src/plot/scale/scale_type/continuous.rs index 13e4bf322..5c26eaaab 100644 --- a/src/plot/scale/scale_type/continuous.rs +++ b/src/plot/scale/scale_type/continuous.rs @@ -173,6 +173,12 @@ impl ScaleTypeTrait for Continuous { ArrayElement::Number(1.0), ArrayElement::Number(6.0), ])), + // An arrowhead reads at a multiple of the linewidth, so its size + // range sits above the stroke's. + "arrowstartsize" | "arrowendsize" => Ok(Some(vec![ + ArrayElement::Number(2.0), + ArrayElement::Number(8.0), + ])), "fontsize" => Ok(Some(vec![ ArrayElement::Number(8.0), ArrayElement::Number(20.0), diff --git a/src/writer/hephaestus/compose.rs b/src/writer/hephaestus/compose.rs index cf08c14f4..0e658f8bc 100644 --- a/src/writer/hephaestus/compose.rs +++ b/src/writer/hephaestus/compose.rs @@ -11,7 +11,6 @@ use hephaestus::scales::chrome::AxisSide; use hephaestus::shape::ShapeRegistry; use super::projection::apply_projection; -use super::scales::build_scale; use super::wiring::Ctx; use super::{channels, facet, geom, projection, scales, wiring}; use crate::naming; @@ -116,27 +115,7 @@ pub fn build_composition( // Register the fixed (shared) scales once, globally. Every panel binds // its position channels to these names, giving fixed-scale faceting. for scale in &spec.scales { - let kind = match scale.aesthetic.as_str() { - "fill" | "stroke" => scales::RangeKind::Color, - "shape" => scales::RangeKind::Shape, - "linetype" => scales::RangeKind::Linetype, - "arrowstart" | "arrowend" => scales::RangeKind::Arrow, - // The text geom's font aesthetics: a scale over them resolves a - // range of family names / weights, not numbers. - "typeface" => scales::RangeKind::Text, - "fontweight" => scales::RangeKind::FontWeight, - "italic" => scales::RangeKind::Bool, - _ => { - if scale.aesthetic.starts_with("pos") { - scales::RangeKind::Position - } else { - scales::RangeKind::Number - } - } - }; - if let Some(hs) = build_scale(scale, kind) { - view.insert_scale(scale.aesthetic.clone(), hs); - } + scales::insert_scale(&mut view, scale); } // Frame a map to its bounding box. Marks, clip boundary and graticules diff --git a/src/writer/hephaestus/geom/line.rs b/src/writer/hephaestus/geom/line.rs index 8a4ed8513..b8fcb5755 100644 --- a/src/writer/hephaestus/geom/line.rs +++ b/src/writer/hephaestus/geom/line.rs @@ -57,6 +57,18 @@ pub fn spec(ctx: &Ctx) -> GeomSpec { MatDefault::None, ), MaterialSpec::new("arrowend", "end_marker", RangeKind::Arrow, MatDefault::None), + MaterialSpec::new( + "arrowstartsize", + "start_marker_size", + RangeKind::Number, + MatDefault::None, + ), + MaterialSpec::new( + "arrowendsize", + "end_marker_size", + RangeKind::Number, + MatDefault::None, + ), ], raw_strings: &[], constants: stroke_style_constants(ctx.layer), diff --git a/src/writer/hephaestus/geom/segment.rs b/src/writer/hephaestus/geom/segment.rs index 67de56dea..7cb6f9a48 100644 --- a/src/writer/hephaestus/geom/segment.rs +++ b/src/writer/hephaestus/geom/segment.rs @@ -101,6 +101,18 @@ fn material() -> Vec { MatDefault::None, ), MaterialSpec::new("arrowend", "end_marker", RangeKind::Arrow, MatDefault::None), + MaterialSpec::new( + "arrowstartsize", + "start_marker_size", + RangeKind::Number, + MatDefault::None, + ), + MaterialSpec::new( + "arrowendsize", + "end_marker_size", + RangeKind::Number, + MatDefault::None, + ), ] } diff --git a/src/writer/hephaestus/mod.rs b/src/writer/hephaestus/mod.rs index c5306b784..ce17f62e9 100644 --- a/src/writer/hephaestus/mod.rs +++ b/src/writer/hephaestus/mod.rs @@ -256,6 +256,26 @@ mod tests { ); } + #[test] + fn renders_mapped_line_arrows_with_mapped_sizes() { + assert_renders( + "SELECT x, y, kind, w FROM (VALUES \ + (1, 2, 'a', 3), (2, 3, 'a', 3), (3, 1, 'a', 3), \ + (1, 1, 'b', 8), (2, 2, 'b', 8), (3, 3, 'b', 8)) t(x, y, kind, w) \ + VISUALISE x AS x, y AS y \ + DRAW line MAPPING kind AS arrowend, w AS arrowendsize", + ); + } + + #[test] + fn renders_line_arrow_with_literal_size() { + assert_renders( + "SELECT x, y FROM (VALUES (1,2),(2,3),(3,1)) t(x,y) \ + VISUALISE x AS x, y AS y \ + DRAW line SETTING arrowend => 'closed', arrowendsize => 6", + ); + } + #[test] fn renders_categorical_color_with_legend() { assert_renders( diff --git a/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index d83e18222..cffbe5a65 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -11,13 +11,14 @@ use std::sync::Arc; use hephaestus::color::{rgba, Color}; use hephaestus::plot::geom::linetype::{dash, gap, pattern, solid}; use hephaestus::plot::scale::{self, Scale as HScale, TransformKind as HTransform}; +use hephaestus::plot::PlotComposition; use hephaestus::scales::value::{ Date as HDate, DateTime as HDateTime, LinetypeStep, Time as HTime, Value as HValue, }; use hephaestus::scales::Direction; use super::channels::NULL_CATEGORY; -use crate::plot::layer::geom::types::parse_arrow_entry; +use crate::plot::layer::geom::types::parse_arrow_name; use crate::plot::scale::{linetype_to_stroke_dash, TransformKind as GTransform}; use crate::plot::{ArrayElement, OutputRange, ParameterValue, Scale as GScale, ScaleTypeKind}; @@ -48,11 +49,35 @@ pub enum RangeKind { /// Rotation in degrees, as ggsql resolves it → radians, hephaestus's unit. Angle, /// Line-end marker: ggsql arrow names → hephaestus's `arrow-*` shapes. - /// A `name:size` entry's size does not survive scaling: the marker-size - /// channel is not part of the scale, so only the shape name is ranged. Arrow, } +/// Register a resolved ggsql scale on the composition under its aesthetic +/// name. +pub fn insert_scale(view: &mut PlotComposition, scale: &GScale) { + let kind = match scale.aesthetic.as_str() { + "fill" | "stroke" => RangeKind::Color, + "shape" => RangeKind::Shape, + "linetype" => RangeKind::Linetype, + "arrowstart" | "arrowend" => RangeKind::Arrow, + // The text geom's font aesthetics: a scale over them resolves a + // range of family names / weights, not numbers. + "typeface" => RangeKind::Text, + "fontweight" => RangeKind::FontWeight, + "italic" => RangeKind::Bool, + _ => { + if scale.aesthetic.starts_with("pos") { + RangeKind::Position + } else { + RangeKind::Number + } + } + }; + if let Some(hs) = build_scale(scale, kind) { + view.insert_scale(scale.aesthetic.clone(), hs); + } +} + /// Build a hephaestus scale from a resolved ggsql scale. `None` when ggsql /// resolved no scale type, so there is nothing to register. pub fn build_scale(scale: &GScale, kind: RangeKind) -> Option { @@ -360,8 +385,8 @@ fn apply_output_range(hs: HScale, kind: RangeKind, values: &[ArrayElement]) -> H ), RangeKind::Arrow => hs.range_strings(values.into_iter().map(|e| { let entry = e.to_key_string(); - match parse_arrow_entry(&entry) { - Ok((name, _)) => Arc::from(format!("arrow-{name}").as_str()), + match parse_arrow_name(&entry) { + Ok(name) => Arc::from(format!("arrow-{name}").as_str()), // Unknown names pass through; hephaestus draws no marker for // a shape it doesn't know, same as an unknown shape name. Err(_) => Arc::from(entry.as_str()), @@ -829,4 +854,28 @@ mod tests { assert_eq!(parse_font_weight("normal"), 400.0); assert_eq!(parse_font_weight("wingdings"), 400.0); } + + #[test] + fn a_reversed_arrow_scale_walks_its_palette_backwards() { + let mut scale = GScale::new("arrowend"); + scale.scale_type = Some(crate::plot::ScaleType::discrete()); + scale.input_range = Some(vec![ + ArrayElement::String("a".to_string()), + ArrayElement::String("b".to_string()), + ]); + scale.output_range = Some(OutputRange::Array(vec![ + ArrayElement::String("closed".to_string()), + ArrayElement::String("open".to_string()), + ])); + scale + .properties + .insert("reverse".to_string(), ParameterValue::Boolean(true)); + let shapes = build_scale(&scale, RangeKind::Arrow).expect("shape scale"); + let shape_of = |key: &str| match shapes.map(&HValue::String(Arc::from(key))) { + HValue::String(s) => s.to_string(), + other => panic!("expected a shape name, got {other:?}"), + }; + assert_eq!(shape_of("a"), "arrow-open"); + assert_eq!(shape_of("b"), "arrow-closed"); + } } diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index a809d11e1..98d34893c 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -17,7 +17,7 @@ use super::channels::{ column_to_f64, column_to_strings, ChannelData, }; use super::scales::{map_linetype, parse_color, parse_font_weight, RangeKind}; -use crate::plot::layer::geom::types::parse_arrow_entry; +use crate::plot::layer::geom::types::parse_arrow_name; use crate::plot::{ParameterValue, ScaleTypeKind}; use crate::{AestheticValue, DataFrame, GgsqlError, Layer, Plot, Result}; @@ -171,7 +171,7 @@ pub struct GeomSpec { /// (hephaestus channel, ggsql aesthetic). pub raw_strings: &'static [(&'static str, &'static str)], /// Constant channels from layer parameters, scale-bypassing (e.g. - /// `start_marker`/`end_marker` from the `arrow` SETTING). + /// `cap`/`join` from the `linecap`/`linejoin` SETTINGs). pub constants: Vec<(&'static str, HValue)>, /// Constant panel-space channel values, scale-bypassing (e.g. a rule's /// 0..1 span, discrete-tile band edges): (hephaestus channel, value). @@ -420,16 +420,15 @@ pub fn wire_material( } // An identity arrow column holds ggsql arrow names; each row // resolves to a shape (unknown → no marker) and, for tail - // shapes, its invert flag. A `name:size` entry's size is not - // honoured here — only literals carry sizes. + // shapes, its invert flag. RangeKind::Arrow => { let entries = column_to_strings(ctx.df, col)?; let mut shapes = Vec::with_capacity(entries.len()); let mut inverts = Vec::with_capacity(entries.len()); let mut any_invert = false; for entry in &entries { - let (shape, invert) = match map_arrow_entry(entry) { - Some((shape, _, invert)) => (shape, invert), + let (shape, invert) = match map_arrow_name(entry) { + Some((shape, invert)) => (shape, invert), None => (String::new(), false), }; any_invert |= invert; @@ -529,13 +528,10 @@ fn set_literal_channel( true } (RangeKind::Arrow, ParameterValue::String(s)) => { - let Some((shape, size, invert)) = map_arrow_entry(s) else { + let Some((shape, invert)) = map_arrow_name(s) else { return false; }; builder.set(channel, Raw(shape)); - if let Some(size) = size { - builder.set(format!("{channel}_size"), Raw(size)); - } if invert { builder.set(format!("{channel}_invert"), Raw(true)); } @@ -662,18 +658,15 @@ pub fn side_sign(layer: &Layer) -> Option { /// them to trail it. const INVERTED_ARROWS: &[&str] = &["fishtail", "fork", "feather"]; -/// Resolve an arrow entry (`name` or `name:size`) to hephaestus terms: the -/// `arrow-*` shape name, an explicit size in points, and whether the shape -/// needs inverting. Unknown names resolve to `None` — hephaestus draws no -/// marker for a shape it doesn't know, same as an unknown shape name. -fn map_arrow_entry(entry: &str) -> Option<(String, Option, bool)> { - let (name, size) = parse_arrow_entry(entry).ok()?; - Some(( - format!("arrow-{name}"), - size, - INVERTED_ARROWS.contains(&name), - )) +/// Resolve an arrow name to hephaestus terms: the `arrow-*` shape name and +/// whether the shape needs inverting. Unknown names resolve to `None` — +/// hephaestus draws no marker for a shape it doesn't know, same as an +/// unknown shape name. +fn map_arrow_name(name: &str) -> Option<(String, bool)> { + let name = parse_arrow_name(name).ok()?; + Some((format!("arrow-{name}"), INVERTED_ARROWS.contains(&name))) } + /// The layer's `linecap`/`linejoin` SETTINGs as constant `cap`/`join` channels. /// Validation has already rejected settings the geom doesn't declare, and the /// `Null` default doesn't match the `String` arm, so a set value alone decides. @@ -993,7 +986,7 @@ fn constant_material(ctx: &Ctx, aesthetic: &str, kind: RangeKind) -> Option Some(s.clone()), _ => None, })?; - let (shape, _, _) = map_arrow_entry(&entry)?; + let (shape, _) = map_arrow_name(&entry)?; Some(HValue::String(shape.into())) } RangeKind::Bool => { @@ -1180,34 +1173,27 @@ fn pin_constants( #[cfg(test)] mod tests { - use super::map_arrow_entry; + use super::map_arrow_name; #[test] fn a_bare_name_maps_to_the_prefixed_shape() { assert_eq!( - map_arrow_entry("closed"), - Some(("arrow-closed".into(), None, false)) - ); - } - - #[test] - fn a_name_with_size_keeps_the_size() { - assert_eq!( - map_arrow_entry("closed:6"), - Some(("arrow-closed".into(), Some(6.0), false)) + map_arrow_name("closed"), + Some(("arrow-closed".into(), false)) ); } #[test] fn tail_style_shapes_need_inverting() { assert_eq!( - map_arrow_entry("feather"), - Some(("arrow-feather".into(), None, true)) + map_arrow_name("feather"), + Some(("arrow-feather".into(), true)) ); } #[test] fn unknown_names_resolve_to_no_marker() { - assert_eq!(map_arrow_entry("closde"), None); + assert_eq!(map_arrow_name("closde"), None); + assert_eq!(map_arrow_name("closed:6"), None); } } diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 9dcf9a4f0..aaaaa0d17 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -313,7 +313,10 @@ fn build_layer_encoding( // Skip arrow aesthetics: Vega-Lite has no line-end marker channel, so // a mapped arrow resolves a scale but produces no encoding. - if aesthetic == "arrowstart" || aesthetic == "arrowend" { + if matches!( + aesthetic.as_str(), + "arrowstart" | "arrowend" | "arrowstartsize" | "arrowendsize" + ) { continue; } diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index eb401b95a..b30266b7a 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -921,6 +921,7 @@ module.exports = grammar({ 'size', 'shape', 'linetype', 'linewidth', 'width', 'height', // Line-end markers 'arrow', 'arrowstart', 'arrowend', + 'arrowsize', 'arrowstartsize', 'arrowendsize', // Text aesthetics 'label', 'typeface', 'fontweight', 'italic', 'fontsize', 'hjust', 'vjust', 'rotation', // Specialty aesthetics, diff --git a/tree-sitter-ggsql/queries/highlights.scm b/tree-sitter-ggsql/queries/highlights.scm index 55127b4de..7f4fd9d6c 100644 --- a/tree-sitter-ggsql/queries/highlights.scm +++ b/tree-sitter-ggsql/queries/highlights.scm @@ -67,6 +67,9 @@ "arrow" "arrowstart" "arrowend" + "arrowsize" + "arrowstartsize" + "arrowendsize" ; Text aesthetics "label" "typeface" From 0212891e75053598698d9080e8cc40436ae8a06f Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 5 Nov 2026 16:35:09 +0100 Subject: [PATCH 17/21] replace inversion mechanism with registered inverted shapes --- src/writer/hephaestus/compose.rs | 7 +- src/writer/hephaestus/wiring.rs | 114 +++++++++++++++++-------------- 2 files changed, 66 insertions(+), 55 deletions(-) diff --git a/src/writer/hephaestus/compose.rs b/src/writer/hephaestus/compose.rs index 0e658f8bc..e2a25a53a 100644 --- a/src/writer/hephaestus/compose.rs +++ b/src/writer/hephaestus/compose.rs @@ -8,7 +8,6 @@ use std::collections::HashMap; use hephaestus::plot::{scale, AspectMode, Plot as HPlot, PlotComposition}; use hephaestus::scales::chrome::AxisSide; -use hephaestus::shape::ShapeRegistry; use super::projection::apply_projection; use super::wiring::Ctx; @@ -90,7 +89,7 @@ pub fn build_composition( // The composition owns the shape registry backing composition-level legend // glyphs (point markers, line dashes). let mut view = PlotComposition::new(&composition) - .shape_registry(ShapeRegistry::with_builtins()) + .shape_registry(wiring::shape_registry()) .theme(wiring::ggsql_theme()); // LABEL title/subtitle/caption live on the composition rather than the @@ -180,8 +179,8 @@ pub fn build_composition( // bind channels and record legends into `legend_sink`. An empty panel // builds no geoms, so it cannot be the legend-capturing one. let panel_legends = (!legends_captured).then_some(&legend_sink); - let mut plot = HPlot::new(&composition, panel.id.as_str()) - .shape_registry(ShapeRegistry::with_builtins()); + let mut plot = + HPlot::new(&composition, panel.id.as_str()).shape_registry(wiring::shape_registry()); if !empty { for (layer, df) in &slices { let ctx = Ctx { diff --git a/src/writer/hephaestus/wiring.rs b/src/writer/hephaestus/wiring.rs index 98d34893c..7a8412e7a 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -11,6 +11,8 @@ use hephaestus::plot::theme::{Element, Length, RectElement, Theme}; use hephaestus::plot::Plot as HPlot; use hephaestus::scales::chrome::LegendSide; use hephaestus::scales::value::{DataColumn, Value as HValue}; +use hephaestus::shape::{Shape, ShapeRegistry, ShapeStyle}; +use hephaestus::{primitives, Point}; use super::channels::{ aesthetic_column_name, build_group_keys, column_to_bool, column_to_channel, column_to_colors, @@ -419,26 +421,14 @@ pub fn wire_material( builder.set(m.channel, Raw(radians)); } // An identity arrow column holds ggsql arrow names; each row - // resolves to a shape (unknown → no marker) and, for tail - // shapes, its invert flag. + // resolves to a shape (unknown → no marker). RangeKind::Arrow => { let entries = column_to_strings(ctx.df, col)?; - let mut shapes = Vec::with_capacity(entries.len()); - let mut inverts = Vec::with_capacity(entries.len()); - let mut any_invert = false; - for entry in &entries { - let (shape, invert) = match map_arrow_name(entry) { - Some((shape, invert)) => (shape, invert), - None => (String::new(), false), - }; - any_invert |= invert; - inverts.push(invert); - shapes.push(shape); - } + let shapes: Vec = entries + .iter() + .map(|entry| map_arrow_name(entry).unwrap_or_default()) + .collect(); builder.set(m.channel, Raw(shapes)); - if any_invert { - builder.set(format!("{}_invert", m.channel), Raw(inverts)); - } } _ => { builder.set(m.channel, Raw(column_to_f64(ctx.df, col)?)); @@ -528,13 +518,10 @@ fn set_literal_channel( true } (RangeKind::Arrow, ParameterValue::String(s)) => { - let Some((shape, invert)) = map_arrow_name(s) else { + let Some(shape) = map_arrow_name(s) else { return false; }; builder.set(channel, Raw(shape)); - if invert { - builder.set(format!("{channel}_invert"), Raw(true)); - } true } (RangeKind::Number, ParameterValue::Number(n)) if n.is_finite() => { @@ -654,17 +641,55 @@ pub fn side_sign(layer: &Layer) -> Option { } } -/// Tail-style shapes are built opening away from the line; inverting turns -/// them to trail it. -const INVERTED_ARROWS: &[&str] = &["fishtail", "fork", "feather"]; +/// The shape registry every ggsql plot renders against: hephaestus's +/// built-ins, with the tail-style arrows (`fishtail`, `fork`, `feather`) +/// re-registered x-mirrored. ggsql draws tails flaring outward from the +/// line end where the built-ins trail back along the line. Baking the +/// orientation into the registry means every path that resolves a name — +/// scale range, identity column, literal — agrees, with no invert flag to +/// keep in sync. +pub fn shape_registry() -> ShapeRegistry { + let stroke = |segments: &[(f64, f64, f64, f64)]| { + Shape::new( + segments + .iter() + .map(|&(x1, y1, x2, y2)| { + primitives::segment(Point::new(x1, y1), Point::new(x2, y2)) + }) + .collect(), + ShapeStyle::Stroke, + Point::ORIGIN, + ) + }; + let mut registry = ShapeRegistry::with_builtins(); + registry.insert( + "arrow-fishtail", + stroke(&[(0.0, 0.0, 1.0, 0.5), (0.0, 0.0, 1.0, -0.5)]), + ); + registry.insert( + "arrow-fork", + stroke(&[(0.0, 0.0, 0.7, 0.7), (0.0, 0.0, 0.7, -0.7)]), + ); + registry.insert( + "arrow-feather", + stroke(&[ + (0.0, 0.0, 0.4, 0.5), + (0.0, 0.0, 0.4, -0.5), + (0.3, 0.0, 0.7, 0.5), + (0.3, 0.0, 0.7, -0.5), + (0.6, 0.0, 1.0, 0.5), + (0.6, 0.0, 1.0, -0.5), + ]), + ); + registry +} -/// Resolve an arrow name to hephaestus terms: the `arrow-*` shape name and -/// whether the shape needs inverting. Unknown names resolve to `None` — -/// hephaestus draws no marker for a shape it doesn't know, same as an -/// unknown shape name. -fn map_arrow_name(name: &str) -> Option<(String, bool)> { +/// Resolve an arrow name to hephaestus's `arrow-*` shape name. Unknown +/// names resolve to `None` — hephaestus draws no marker for a shape it +/// doesn't know, same as an unknown shape name. +fn map_arrow_name(name: &str) -> Option { let name = parse_arrow_name(name).ok()?; - Some((format!("arrow-{name}"), INVERTED_ARROWS.contains(&name))) + Some(format!("arrow-{name}")) } /// The layer's `linecap`/`linejoin` SETTINGs as constant `cap`/`join` channels. @@ -986,7 +1011,7 @@ fn constant_material(ctx: &Ctx, aesthetic: &str, kind: RangeKind) -> Option Some(s.clone()), _ => None, })?; - let (shape, _) = map_arrow_name(&entry)?; + let shape = map_arrow_name(&entry)?; Some(HValue::String(shape.into())) } RangeKind::Bool => { @@ -1173,27 +1198,14 @@ fn pin_constants( #[cfg(test)] mod tests { - use super::map_arrow_name; + use super::shape_registry; #[test] - fn a_bare_name_maps_to_the_prefixed_shape() { - assert_eq!( - map_arrow_name("closed"), - Some(("arrow-closed".into(), false)) - ); - } - - #[test] - fn tail_style_shapes_need_inverting() { - assert_eq!( - map_arrow_name("feather"), - Some(("arrow-feather".into(), true)) - ); - } - - #[test] - fn unknown_names_resolve_to_no_marker() { - assert_eq!(map_arrow_name("closde"), None); - assert_eq!(map_arrow_name("closed:6"), None); + fn tail_style_shapes_are_mirrored_in_the_registry() { + let registry = shape_registry(); + for name in ["arrow-fishtail", "arrow-fork", "arrow-feather"] { + let bbox = registry.get(name).unwrap().bounding_box(); + assert_eq!(bbox.x0, 0.0, "{name} should flare outward from the anchor"); + } } } From 3d30e06e66f9c13f40f280efa9bb9e264b28a23b Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 9 Nov 2026 11:18:28 +0100 Subject: [PATCH 18/21] add scale type doc --- doc/syntax/scale/aesthetic/arrow.qmd | 109 +++++++++++ .../scale/aesthetic/examples/arrow_bar.svg | 4 + .../aesthetic/examples/arrow_bracket.svg | 4 + .../scale/aesthetic/examples/arrow_closed.svg | 4 + .../scale/aesthetic/examples/arrow_cross.svg | 4 + .../aesthetic/examples/arrow_diamond.svg | 4 + .../scale/aesthetic/examples/arrow_dot.svg | 4 + .../aesthetic/examples/arrow_feather.svg | 4 + .../aesthetic/examples/arrow_fishtail.svg | 4 + .../scale/aesthetic/examples/arrow_fork.svg | 4 + .../scale/aesthetic/examples/arrow_latex.svg | 4 + .../scale/aesthetic/examples/arrow_open.svg | 4 + .../scale/aesthetic/examples/arrow_square.svg | 4 + .../aesthetic/examples/arrow_stealth.svg | 4 + .../scale/aesthetic/examples/arrow_thin.svg | 4 + .../scale/aesthetic/examples/arrow_wedge.svg | 4 + .../scale/aesthetic/generate_examples.R | 169 ++++++++++++++++++ 17 files changed, 338 insertions(+) create mode 100644 doc/syntax/scale/aesthetic/arrow.qmd create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_bar.svg create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_bracket.svg create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_closed.svg create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_cross.svg create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_diamond.svg create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_dot.svg create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_feather.svg create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_fishtail.svg create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_fork.svg create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_latex.svg create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_open.svg create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_square.svg create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_stealth.svg create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_thin.svg create mode 100644 doc/syntax/scale/aesthetic/examples/arrow_wedge.svg diff --git a/doc/syntax/scale/aesthetic/arrow.qmd b/doc/syntax/scale/aesthetic/arrow.qmd new file mode 100644 index 000000000..3cc092977 --- /dev/null +++ b/doc/syntax/scale/aesthetic/arrow.qmd @@ -0,0 +1,109 @@ +--- +title: "Arrow" +--- + +Arrow aesthetics control the shape and size of arrows at the terminal vertices of lines, and apply to layers that draw lines: [line](../../layer/type/line.qmd), [path](../../layer/type/path.qmd) and [segment](../../layer/type/segment.qmd). +These can be useful if lines represent relations and can be mapped if different types of relations need to be reflected. + +There are separate aesthetics for arrows at the start and end of lines. +The `arrow` aesthetic is an alias to control `arrowstart` and `arrowend` shapes simultaneously. +The `arrowsize` aesthethic is ditto an alias to control `arrowstartsize` and `arrowendsize` sizes together. +The start and ends of lines are declared directly in segment layers, whereas line and path layers count the first and last observation in a group. + +## Literal values +Arrow shapes are specified using named patterns. + +| Name | Pattern | Description | +|------|---------|-------------| +| `open` | ![](examples/arrow_open.svg) | V-shape: two strokes converging on the tip | +| `closed` | ![](examples/arrow_closed.svg) | Fatter filled isosceles triangle | +| `stealth` | ![](examples/arrow_stealth.svg) | Dart/chevron as filled concave quadrilateral, thinner in the style of TikZ graphics | +| `latex` | ![](examples/arrow_latex.svg) | Dart/chevron as filled concave quadrilateral, fatter in the style of LaTeX | +| `thin` | ![](examples/arrow_thin.svg) | Thinner filled isosceles triangle | +| `wedge` | ![](examples/arrow_wedge.svg) | Asymmetric triangle | +| `fishtail` | ![](examples/arrow_fishtail.svg) | Inverted V-shape: two strokes diverging as tail | +| `fork` | ![](examples/arrow_fork.svg) | Wider inverted V-shape as tail | +| `feather` | ![](examples/arrow_feather.svg) | Stylised arrow fletching | +| `bar` | ![](examples/arrow_bar.svg) | Orthogonal line at tip | +| `bracket` | ![](examples/arrow_bracket.svg) | Bar with two right-angle protrusions at its ends | +| `cross` | ![](examples/arrow_cross.svg) | Square (Greek) cross angled at 45° from the line | +| `dot` | ![](examples/arrow_dot.svg) | Filled circle | +| `square` | ![](examples/arrow_square.svg) | Filled square | +| `diamond` | ![](examples/arrow_diamond.svg) | Filled diamond | + +You can use these names directly when setting the arrow of a line. +Note that the `arrow` setting is an aesthetic alias to set both the `arrowstart` and `arrowend` aesthetics. +You can separate the arrow shapes by using the more specific aesthetics. + +```ggsql +DRAW segment + SETTING arrowstart => 'open', arrowend => 'closed' +``` + +The `arrowsize` and `arrowstartsize`/`arrowendsize` aesthetics are given in bare numbers. +While individual shapes might deviate, the sizes are roughly in points. +Note that the `arrowsize` aesthetics are irrelevant when `arrow` has not been declared. +The following code sets the size of arrows to 20pt. + +```ggsql +DRAW segment + SETTING arrow => 'open', arrowsize => 20 +``` + +## Palettes + +```{ggsql} +#| code-fold: true +#| code-summary: "Create example data" +CREATE TEMP TABLE edges AS +SELECT * FROM (VALUES + (0, 0, 1, 1, 'Bottom'), + (1, 1, 2, 1, 'Bottom'), + (2, 1, 3, 0, 'Bottom'), + (0, 3, 1, 2, 'Top'), + (1, 2, 2, 2, 'Top'), + (2, 2, 3, 3, 'Top'), + (1, 1, 1, 2, 'Cross'), + (2, 2, 2, 1, 'Cross') +) t(x, y, xend, yend, type); +``` + +The default palette for arrows picks the 15 arrows listed above. + +```{ggsql} +VISUALISE x, y, xend, yend FROM edges +DRAW segment + MAPPING type AS arrowend + SETTING arrowsize => 10 +``` + +You can change the arrows by providing a manual palette with shape names as the `TO` clause in scales. + +```{ggsql} +VISUALISE x, y, xend, yend FROM edges +DRAW segment + MAPPING type AS arrowstart + SETTING arrowsize => 10 +SCALE arrow TO ['latex', 'cross', 'open'] +``` + +You can use different arrows for the start and end by using the specific `arrowstart` and `arrowend` scales for the aesthetics. + +```{ggsql} +VISUALISE x, y, xend, yend FROM edges +DRAW segment + MAPPING type AS arrow + SETTING arrowsize => 10 +SCALE arrowstart TO ['latex', 'cross', 'open'] +SCALE arrowend TO ['wedge', 'dot', 'feather'] +``` + +The `arrowsize` aesthetic takes numeric values and re-scales these to match the `TO` clause. + +```{ggsql} +VISUALISE x, y, xend, yend FROM edges +DRAW segment + MAPPING x AS arrowsize + SETTING arrow => 'closed' +SCALE arrowsize TO (5, 20) +``` diff --git a/doc/syntax/scale/aesthetic/examples/arrow_bar.svg b/doc/syntax/scale/aesthetic/examples/arrow_bar.svg new file mode 100644 index 000000000..ec47c1ddc --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_bar.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/examples/arrow_bracket.svg b/doc/syntax/scale/aesthetic/examples/arrow_bracket.svg new file mode 100644 index 000000000..756a8a913 --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_bracket.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/examples/arrow_closed.svg b/doc/syntax/scale/aesthetic/examples/arrow_closed.svg new file mode 100644 index 000000000..c7d972213 --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_closed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/examples/arrow_cross.svg b/doc/syntax/scale/aesthetic/examples/arrow_cross.svg new file mode 100644 index 000000000..556759fc7 --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_cross.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/examples/arrow_diamond.svg b/doc/syntax/scale/aesthetic/examples/arrow_diamond.svg new file mode 100644 index 000000000..44946f849 --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_diamond.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/examples/arrow_dot.svg b/doc/syntax/scale/aesthetic/examples/arrow_dot.svg new file mode 100644 index 000000000..0a00e47c9 --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_dot.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/examples/arrow_feather.svg b/doc/syntax/scale/aesthetic/examples/arrow_feather.svg new file mode 100644 index 000000000..5c95c29f8 --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_feather.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/examples/arrow_fishtail.svg b/doc/syntax/scale/aesthetic/examples/arrow_fishtail.svg new file mode 100644 index 000000000..e02c495ce --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_fishtail.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/examples/arrow_fork.svg b/doc/syntax/scale/aesthetic/examples/arrow_fork.svg new file mode 100644 index 000000000..e2469d5f8 --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_fork.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/examples/arrow_latex.svg b/doc/syntax/scale/aesthetic/examples/arrow_latex.svg new file mode 100644 index 000000000..373ccdb8c --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_latex.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/examples/arrow_open.svg b/doc/syntax/scale/aesthetic/examples/arrow_open.svg new file mode 100644 index 000000000..e7f867612 --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/examples/arrow_square.svg b/doc/syntax/scale/aesthetic/examples/arrow_square.svg new file mode 100644 index 000000000..fc5aa438e --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_square.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/examples/arrow_stealth.svg b/doc/syntax/scale/aesthetic/examples/arrow_stealth.svg new file mode 100644 index 000000000..f5814b3c3 --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_stealth.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/examples/arrow_thin.svg b/doc/syntax/scale/aesthetic/examples/arrow_thin.svg new file mode 100644 index 000000000..2b31fd4d7 --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_thin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/examples/arrow_wedge.svg b/doc/syntax/scale/aesthetic/examples/arrow_wedge.svg new file mode 100644 index 000000000..03f845823 --- /dev/null +++ b/doc/syntax/scale/aesthetic/examples/arrow_wedge.svg @@ -0,0 +1,4 @@ + + + + diff --git a/doc/syntax/scale/aesthetic/generate_examples.R b/doc/syntax/scale/aesthetic/generate_examples.R index 9ab782b86..17f04e2b1 100644 --- a/doc/syntax/scale/aesthetic/generate_examples.R +++ b/doc/syntax/scale/aesthetic/generate_examples.R @@ -757,6 +757,165 @@ generate_shape_svg <- function(name, size = 40, is_closed = TRUE) { svg } +# Arrow shapes for the arrowstart/arrowend aesthetics. +# Geometry mirrors hephaestus's arrow-* shapes (src/shape_builtin.rs): +# unit-length marker, tip at the origin, body extending into -x. `anchor` +# is the shape's anchor x: hephaestus trims the line back by the shape's +# extent beyond the anchor, lands the anchor on the trimmed endpoint, and +# the marker's rightmost point ends flush with the original line end. +# kind "stroke": list of segments c(x1, y1, x2, y2) or polylines (2-col matrix). +# kind "fill": a polygon (2-col matrix) or a circle. +ARROWS <- list( + open = list(anchor = 0, kind = "stroke", segments = list( + c(-1, 0.5, 0, 0), c(0, 0, -1, -0.5) + )), + closed = list(anchor = -1, kind = "fill", polygon = rbind( + c(0, 0), c(-1, 0.5), c(-1, -0.5) + )), + stealth = list(anchor = -0.4, kind = "fill", polygon = rbind( + c(0, 0), c(-1, 0.5), c(-0.4, 0), c(-1, -0.5) + )), + latex = list(anchor = -0.6, kind = "fill", polygon = rbind( + c(0, 0), c(-1, 0.35), c(-0.6, 0), c(-1, -0.35) + )), + thin = list(anchor = -1, kind = "fill", polygon = rbind( + c(0, 0), c(-1, 0.2), c(-1, -0.2) + )), + wedge = list(anchor = -1, kind = "fill", polygon = rbind( + c(0, 0), c(-1, 0.5), c(-1, 0) + )), + fishtail = list(anchor = 0, kind = "stroke", segments = list( + c(0, 0, -1, 0.5), c(0, 0, -1, -0.5) + )), + fork = list(anchor = 0, kind = "stroke", segments = list( + c(0, 0, -0.7, 0.7), c(0, 0, -0.7, -0.7) + )), + feather = list(anchor = 0, kind = "stroke", segments = unlist(lapply( + c(0, -0.3, -0.6), + function(ox) list(c(ox, 0, ox - 0.4, 0.5), c(ox, 0, ox - 0.4, -0.5)) + ), recursive = FALSE)), + bar = list(anchor = 0, kind = "stroke", segments = list( + c(0, -0.5, 0, 0.5) + )), + bracket = list(anchor = 0, kind = "stroke", polylines = list( + rbind(c(0.2, -0.5), c(0, -0.5), c(0, 0.5), c(0.2, 0.5)) + )), + cross = list(anchor = 0, kind = "stroke", segments = list( + c(-0.5, -0.5, 0.5, 0.5), c(-0.5, 0.5, 0.5, -0.5) + )), + dot = list(anchor = 0, kind = "fill", circle = list(cx = 0, cy = 0, r = 0.3)), + square = list(anchor = 0, kind = "fill", polygon = rbind( + c(-0.3, -0.3), c(0.3, -0.3), c(0.3, 0.3), c(-0.3, 0.3) + )), + diamond = list(anchor = 0, kind = "fill", polygon = rbind( + c(0, -0.4), c(0.4, 0), c(0, 0.4), c(-0.4, 0) + )) +) + +#' Generate an SVG showing a single arrow shape at the end of a line +#' @param name Arrow shape name (one of ARROWS) +#' @param width SVG width in pixels +#' @param height SVG height in pixels +#' @param size Marker length in pixels (unit scale of the shape geometry) +#' @param stroke_width Width of the line and stroke markers +#' @return SVG content as string +generate_arrow_svg <- function( + name, + width = 200, + height = 40, + size = 24, + stroke_width = 2.5 +) { + shape <- ARROWS[[name]] + y_mid <- height / 2 + x_end <- width - 30 + + # x-extent of the shape geometry, for the trim computation + xs <- c( + unlist(lapply(shape$segments, function(s) s[c(1, 3)])), + unlist(lapply(shape$polylines, function(p) p[, 1])), + if (!is.null(shape$polygon)) shape$polygon[, 1], + if (!is.null(shape$circle)) { + shape$circle$cx + c(-1, 1) * shape$circle$r + } + ) + x0 <- min(xs) + x1 <- max(xs) + + # hephaestus placement: the line is trimmed by the shape's extent beyond + # the anchor and the anchor lands on the trimmed endpoint, so the marker's + # rightmost point sits at the original line end. ggsql renders + # fishtail/fork/feather with hephaestus's invert flag (local x mirrored + # around the anchor), so the examples do the same. + inverted <- name %in% c("fishtail", "fork", "feather") + clip <- if (inverted) (shape$anchor - x0) * size else (x1 - shape$anchor) * size + x_anchor <- x_end - clip + + flip <- if (inverted) -1 else 1 + tx <- function(x) x_anchor + flip * (x - shape$anchor) * size + ty <- function(y) y_mid - y * size + + path_from_points <- function(pts, close) { + cmds <- paste0("M", tx(pts[1, 1]), ",", ty(pts[1, 2])) + if (nrow(pts) > 1) { + cmds <- paste0( + cmds, + " L", + paste(tx(pts[-1, 1]), ty(pts[-1, 2]), sep = ",", collapse = " L") + ) + } + if (close) paste0(cmds, " Z") else cmds + } + + if (shape$kind == "fill") { + if (!is.null(shape$circle)) { + marker <- sprintf( + '', + tx(shape$circle$cx), + ty(shape$circle$cy), + shape$circle$r * size + ) + } else { + marker <- sprintf( + '', + path_from_points(shape$polygon, close = TRUE) + ) + } + } else { + parts <- character(0) + for (seg in shape$segments) { + parts <- c( + parts, + sprintf("M%.1f,%.1f L%.1f,%.1f", tx(seg[1]), ty(seg[2]), tx(seg[3]), ty(seg[4])) + ) + } + for (pl in shape$polylines) { + parts <- c(parts, path_from_points(pl, close = FALSE)) + } + marker <- sprintf( + '', + paste(parts, collapse = " "), + stroke_width + ) + } + + svg <- sprintf( + ' + + %s +', + width, + height, + y_mid, + x_anchor, + y_mid, + stroke_width, + marker + ) + + svg +} + #' Generate sequential linetypes with evenly-spaced ink densities #' @param count Number of linetypes to generate #' @return List with names and dasharray values @@ -954,6 +1113,16 @@ main <- function() { } } + # Generate arrow SVGs + message("\n=== Arrow Shapes ===") + for (name in names(ARROWS)) { + svg_content <- generate_arrow_svg(name) + output_file <- file.path(output_dir, sprintf("arrow_%s.svg", name)) + writeLines(svg_content, output_file) + generated <- generated + 1 + message(sprintf("Generated: arrow_%s.svg", name)) + } + message(sprintf("\nGenerated %d SVG files total", generated)) if (length(missing) > 0) { From 89a842e1bbbba7cf99c666a13bfad6d20c2598b8 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 9 Nov 2026 11:19:12 +0100 Subject: [PATCH 19/21] change arrow examples in other docs --- doc/syntax/layer/type/line.qmd | 2 +- doc/syntax/layer/type/path.qmd | 2 +- doc/syntax/layer/type/segment.qmd | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/syntax/layer/type/line.qmd b/doc/syntax/layer/type/line.qmd index 7512d38bf..5c0272771 100644 --- a/doc/syntax/layer/type/line.qmd +++ b/doc/syntax/layer/type/line.qmd @@ -110,7 +110,7 @@ Arrows applied to the start and end of the line. ```{ggsql} VISUALISE x, y FROM data DRAW line SETTING - arrow => ['dot:20', 'closed:15'] + arrow => 'wedge' ``` Use aggregation to draw min and max lines from a set of observations on a single layer. Targeting `y` twice produces one summary row per function within the same group. A synthetic `aggregate` column tags each row with the different function names, that you can remap to colour the lines distinctly: diff --git a/doc/syntax/layer/type/path.qmd b/doc/syntax/layer/type/path.qmd index 9b308ff13..2c388c82e 100644 --- a/doc/syntax/layer/type/path.qmd +++ b/doc/syntax/layer/type/path.qmd @@ -109,7 +109,7 @@ Setting an arrow. ```{ggsql} VISUALISE x, y FROM df DRAW path SETTING - arrow => 'closed:15' + arrow => 'stealth' ``` Lines have various styling mechanics, inlcuding layer-level settings for joining and capping lines. diff --git a/doc/syntax/layer/type/segment.qmd b/doc/syntax/layer/type/segment.qmd index 5bf3d4587..50a35d588 100644 --- a/doc/syntax/layer/type/segment.qmd +++ b/doc/syntax/layer/type/segment.qmd @@ -76,7 +76,7 @@ For directional graphs, it makes sense to use arrows to indicate the direction. VISUALISE x, y, xend, yend FROM edges DRAW segment MAPPING type AS stroke - SETTING arrow => ['fishtail:10', 'open:10'] + SETTING arrow => 'open' ``` Segments can also draw arbitrary diagonal connections between points. For example, marking a delta between two measurements: From a4e0c91f9e14ac41fe069e134b3595b019d828b8 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 9 Nov 2026 16:40:06 +0100 Subject: [PATCH 20/21] debug: run tests single-threaded to locate segfault --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b47aeaa1f..2d5efc94f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -106,7 +106,7 @@ jobs: run: tree-sitter test - name: Run Rust tests - run: cargo test --lib --bins + run: cargo test --lib --bins -- --test-threads=1 - name: Install dbc CLI and SQLite ADBC driver run: | From 7f06723d686f9d34abe45e8d7239dae43b81a199 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 9 Nov 2026 17:06:00 +0100 Subject: [PATCH 21/21] debug: single-thread ADBC tests too --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2d5efc94f..ff4ac97b2 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -114,7 +114,7 @@ jobs: "$HOME/.local/bin/dbc" install sqlite - name: Run ADBC unit tests - run: cargo test --features "adbc sqlite" --lib + run: cargo test --features "adbc sqlite" --lib -- --test-threads=1 - name: Run ADBC SQLite equivalence tests run: cargo test --features "adbc sqlite" --lib -- --ignored equivalence