diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index b47aeaa1f..ff4ac97b2 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: | @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 941dc55bf..c96d27bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### 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. - Many layers now support `linecap` and `linejoin` settings for styling lines. - The Positron extension now registers a ggsql data importer, so dragging a csv/tsv/parquet/json file into Positron offers to generate the ggsql code diff --git a/doc/syntax/layer/type/line.qmd b/doc/syntax/layer/type/line.qmd index 04c9fc01a..5c0272771 100644 --- a/doc/syntax/layer/type/line.qmd +++ b/doc/syntax/layer/type/line.qmd @@ -29,6 +29,19 @@ 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'`. + + 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. + * 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. @@ -73,11 +86,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 @@ -87,18 +99,20 @@ 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 => '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: ```{ggsql} diff --git a/doc/syntax/layer/type/path.qmd b/doc/syntax/layer/type/path.qmd index 68f8a510c..2c388c82e 100644 --- a/doc/syntax/layer/type/path.qmd +++ b/doc/syntax/layer/type/path.qmd @@ -23,6 +23,18 @@ The following aesthetics are recognised by the path layer. * `linecap`: The style of line endings, including the ends of dashes in dashed lines. One of `'butt'` (default), `'round'`, or `'square'`. * `linejoin`: The style of corners where line segments meet. One of `'miter'`/`'mitre'` (default), `'round'`, or `'bevel'`. * `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'`. + + 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. + * 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. @@ -92,6 +104,14 @@ DRAW path MAPPING 'Path' AS stroke ``` +Setting an arrow. + +```{ggsql} +VISUALISE x, y FROM df +DRAW path SETTING + arrow => 'stealth' +``` + Lines have various styling mechanics, inlcuding layer-level settings for joining and capping lines. ```{ggsql} diff --git a/doc/syntax/layer/type/segment.qmd b/doc/syntax/layer/type/segment.qmd index 6835f1408..50a35d588 100644 --- a/doc/syntax/layer/type/segment.qmd +++ b/doc/syntax/layer/type/segment.qmd @@ -24,6 +24,18 @@ 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 +* `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'`. + + 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. + * 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]`. * `linecap`: The style of line endings, including the ends of dashes in dashed lines. One of `'butt'` (default), `'round'`, or `'square'`. * `position`: Position adjustment. One of `'identity'` (default), `'stack'`, `'dodge'`, or `'jitter'` * `aggregate` Aggregation functions to apply per group: @@ -41,7 +53,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'), @@ -50,12 +63,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 => 'open' +``` + Segments can also draw arbitrary diagonal connections between points. For example, marking a delta between two measurements: ```{ggsql} 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) { 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/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/parser/builder.rs b/src/parser/builder.rs index 59f7e2aaf..06131b543 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -643,7 +643,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()), @@ -2754,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/AGENTS.md b/src/plot/AGENTS.md index d17aa879c..2a10b71e4 100644 --- a/src/plot/AGENTS.md +++ b/src/plot/AGENTS.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/plot/aesthetic.rs b/src/plot/aesthetic.rs index 16a98b852..254e35eb8 100644 --- a/src/plot/aesthetic.rs +++ b/src/plot/aesthetic.rs @@ -57,6 +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/arrowsize (aliases), arrowstart, +/// arrowend, arrowstartsize, arrowendsize /// - Dimension aesthetics: width, height /// - Text aesthetics: label, typeface, fontweight, italic, hjust, vjust pub const MATERIAL_AESTHETICS: &[&str] = &[ @@ -69,6 +71,12 @@ pub const MATERIAL_AESTHETICS: &[&str] = &[ "shape", "linetype", "linewidth", + "arrow", + "arrowstart", + "arrowend", + "arrowsize", + "arrowstartsize", + "arrowendsize", "width", "height", "label", 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/line.rs b/src/plot/layer/geom/line.rs index c0ab23532..7333eb0b3 100644 --- a/src/plot/layer/geom/line.rs +++ b/src/plot/layer/geom/line.rs @@ -31,6 +31,10 @@ impl GeomTrait for Line { ("linewidth", DefaultAestheticValue::Number(1.5)), ("opacity", DefaultAestheticValue::Number(1.0)), ("linetype", DefaultAestheticValue::String("solid")), + ("arrowstart", DefaultAestheticValue::Null), + ("arrowend", DefaultAestheticValue::Null), + ("arrowstartsize", DefaultAestheticValue::Null), + ("arrowendsize", DefaultAestheticValue::Null), ], } } 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/plot/layer/geom/path.rs b/src/plot/layer/geom/path.rs index e65fcb3a8..dbe6cb4dc 100644 --- a/src/plot/layer/geom/path.rs +++ b/src/plot/layer/geom/path.rs @@ -28,6 +28,10 @@ impl GeomTrait for Path { ("linewidth", DefaultAestheticValue::Number(1.5)), ("opacity", DefaultAestheticValue::Number(1.0)), ("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 d0d987cbb..3726dcb02 100644 --- a/src/plot/layer/geom/segment.rs +++ b/src/plot/layer/geom/segment.rs @@ -30,6 +30,10 @@ impl GeomTrait for Segment { ("linewidth", DefaultAestheticValue::Number(1.0)), ("opacity", DefaultAestheticValue::Number(1.0)), ("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 74fe0c504..f289ddac6 100644 --- a/src/plot/layer/geom/types.rs +++ b/src/plot/layer/geom/types.rs @@ -6,7 +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 pub use crate::plot::types::{DefaultParamValue, ParamConstraint, ParamDefinition}; // ============================================================================= @@ -32,7 +31,11 @@ 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"]), + ("arrowsize", &["arrowstartsize", "arrowendsize"]), +]; /// Shared `aggregate` parameter definition. Every geom that opts into the /// Aggregate stat (i.e. `aggregate_domain_aesthetics()` returns `Some(_)`) @@ -45,6 +48,34 @@ pub const AGGREGATE_PARAM: ParamDefinition = ParamDefinition { constraint: ParamConstraint::string_or_string_array_unconstrained(), }; +/// 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", +]; + +/// 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 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" + )); + } + } + Err(format!( + "unknown arrow '{entry}'; expected one of {}", + crate::util::or_list_quoted(ARROW_VALUES, '\'') + )) +} + /// Stroke cap style values for the `linecap` parameter. pub const LINECAP_VALUES: &[&str] = &["butt", "round", "square"]; @@ -672,3 +703,27 @@ mod tests { assert!(aes_fill.supported().contains(&"color")); } } + +#[cfg(test)] +mod arrow_tests { + use super::parse_arrow_name; + + #[test] + fn parses_a_bare_name() { + assert_eq!(parse_arrow_name("closed"), Ok("closed")); + } + + #[test] + fn rejects_an_unknown_name() { + assert!(parse_arrow_name("closde") + .unwrap_err() + .contains("unknown arrow 'closde'")); + } + + #[test] + 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 9065818df..f109b8f3e 100644 --- a/src/plot/scale/mod.rs +++ b/src/plot/scale/mod.rs @@ -61,11 +61,13 @@ 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 | "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/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/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), } } 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/AGENTS.md b/src/writer/hephaestus/AGENTS.md index 6f63d30c1..797e0d616 100644 --- a/src/writer/hephaestus/AGENTS.md +++ b/src/writer/hephaestus/AGENTS.md @@ -154,8 +154,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/src/writer/hephaestus/compose.rs b/src/writer/hephaestus/compose.rs index 792042492..e2a25a53a 100644 --- a/src/writer/hephaestus/compose.rs +++ b/src/writer/hephaestus/compose.rs @@ -8,10 +8,8 @@ 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::scales::build_scale; use super::wiring::Ctx; use super::{channels, facet, geom, projection, scales, wiring}; use crate::naming; @@ -91,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 @@ -116,26 +114,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, - // 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 @@ -200,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/geom/line.rs b/src/writer/hephaestus/geom/line.rs index e5c99ecf5..b8fcb5755 100644 --- a/src/writer/hephaestus/geom/line.rs +++ b/src/writer/hephaestus/geom/line.rs @@ -50,6 +50,25 @@ 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), + 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/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/hephaestus/geom/segment.rs b/src/writer/hephaestus/geom/segment.rs index 3149b04b5..7cb6f9a48 100644 --- a/src/writer/hephaestus/geom/segment.rs +++ b/src/writer/hephaestus/geom/segment.rs @@ -94,6 +94,25 @@ 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), + 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 a206cc2fb..ce17f62e9 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 @@ -218,6 +217,65 @@ 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 arrowstart => 'open', arrowend => '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_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_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( @@ -1315,19 +1373,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/src/writer/hephaestus/scales.rs b/src/writer/hephaestus/scales.rs index 559512fab..cffbe5a65 100644 --- a/src/writer/hephaestus/scales.rs +++ b/src/writer/hephaestus/scales.rs @@ -11,12 +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_name; use crate::plot::scale::{linetype_to_stroke_dash, TransformKind as GTransform}; use crate::plot::{ArrayElement, OutputRange, ParameterValue, Scale as GScale, ScaleTypeKind}; @@ -46,6 +48,34 @@ 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. + 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 @@ -353,6 +383,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_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()), + } + })), // 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, @@ -815,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 9880268c3..7a8412e7a 100644 --- a/src/writer/hephaestus/wiring.rs +++ b/src/writer/hephaestus/wiring.rs @@ -11,12 +11,15 @@ 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, 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_name; use crate::plot::{ParameterValue, ScaleTypeKind}; use crate::{AestheticValue, DataFrame, GgsqlError, Layer, Plot, Result}; @@ -417,6 +420,16 @@ 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). + RangeKind::Arrow => { + let entries = column_to_strings(ctx.df, col)?; + let shapes: Vec = entries + .iter() + .map(|entry| map_arrow_name(entry).unwrap_or_default()) + .collect(); + builder.set(m.channel, Raw(shapes)); + } _ => { builder.set(m.channel, Raw(column_to_f64(ctx.df, col)?)); } @@ -504,6 +517,13 @@ fn set_literal_channel( builder.set(channel, Raw(HValue::Linetype(map_linetype(s)))); true } + (RangeKind::Arrow, ParameterValue::String(s)) => { + let Some(shape) = map_arrow_name(s) else { + return false; + }; + builder.set(channel, Raw(shape)); + true + } (RangeKind::Number, ParameterValue::Number(n)) if n.is_finite() => { builder.set(channel, Raw(*n)); true @@ -621,6 +641,57 @@ pub fn side_sign(layer: &Layer) -> Option { } } +/// 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'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}")) +} + /// 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. @@ -932,6 +1003,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_name(&entry)?; + Some(HValue::String(shape.into())) + } RangeKind::Bool => { if let Some(b) = col .and_then(|c| column_to_bool(ctx.df, c).ok()) @@ -1113,3 +1195,17 @@ fn pin_constants( } key } + +#[cfg(test)] +mod tests { + use super::shape_registry; + + #[test] + 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"); + } + } +} diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 89a1da029..2aaebb5bc 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, diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 1419a6644..aaaaa0d17 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -311,6 +311,15 @@ 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 matches!( + aesthetic.as_str(), + "arrowstart" | "arrowend" | "arrowstartsize" | "arrowendsize" + ) { + 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") { diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index bdb404ec8..b30266b7a 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' ), @@ -919,6 +919,9 @@ module.exports = grammar({ 'color', 'colour', 'fill', 'stroke', 'opacity', // Size and shape '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 e8319356d..7f4fd9d6c 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" @@ -64,6 +63,13 @@ "linewidth" "width" "height" + ; Line-end markers + "arrow" + "arrowstart" + "arrowend" + "arrowsize" + "arrowstartsize" + "arrowendsize" ; Text aesthetics "label" "typeface"