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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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"