Skip to content

Commit c6c7a2f

Browse files
authored
Polish polygon / path layers (#103)
* implement polygon * path writing logic as function * simplify path rendering * add docs * for consistency, also put bar logic in function * fix lazily copied sentences * clarify comment * include polygon as link * remove outdated tests * add linetype aesthetic * Also include linetype in docs
1 parent 1d4101f commit c6c7a2f

5 files changed

Lines changed: 166 additions & 153 deletions

File tree

doc/syntax/index.qmd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ There are many different layers to choose from when visualising your data. Some
2020
- [`path`](layer/path.qmd) is like `line` above but does not sort the data but plot it according to its own order
2121
- [`area`](layer/area.qmd) is used to display series as an area chart.
2222
- [`ribbon`](layer/ribbon.qmd) is used to display series extrema.
23+
- [`polygon`](layer/polygon.qmd) is used to display arbitrary shapes as polygons.
2324
- [`bar`](layer/bar.qmd) creates a bar chart, optionally calculating y from the number of records in each bar
2425
- [`histogram`](layer/histogram.qmd) bins the data along the x axis and produces a bar for each bin showing the number of records in it
2526
- [`boxplot`](layer/boxplot.qmd) displays continuous variables as 5-number summaries

doc/syntax/layer/path.qmd

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ title: "Path"
44

55
> Layers are declared with the [`DRAW` clause](../clause/draw.qmd). Read the documentation for this clause for a thorough description of how to use it.
66
7-
The path layer is used to create lineplots, but contrary to the [line layer](line.qmd) the data will not be connected along the x-axis. Instead records are connected in the order they appear in the data. Because of this the ordering is quite important and you may want to use the [`ORDER BY`](../clause/draw.qmd#order-by) clause to ensure data comes out of the back-end in the desired order. Lines are divided due to their grouping, which is the combination of the discrete mapped aesthetics and the columns specified in the layers [`PARTITION BY`](../clause/draw.qmd#partition-by).
7+
The path layer is used to create lineplots, but contrary to the [line layer](line.qmd) the data will not be connected along the x-axis. Instead records are connected in the order they appear in the data. Lines are divided due to their grouping, which is the combination of the discrete mapped aesthetics and the columns specified in the layers [`PARTITION BY`](../clause/draw.qmd#partition-by).
88

99
## Aesthetics
1010
The following aesthetics are recognised by the path layer.
@@ -27,4 +27,53 @@ The line layer does not transform its data but passes it through unchanged
2727

2828
## Examples
2929

30-
TBD
30+
```{ggsql}
31+
#| code-fold: true
32+
#| code-summary: "Create example data"
33+
CREATE TABLE df AS
34+
SELECT * FROM (VALUES
35+
(1.0, 1.0, 'A'),
36+
(2.0, 1.0, 'A'),
37+
(1.0, 3.0, 'A'),
38+
(3.0, 1.0, 'B'),
39+
(2.0, 3.0, 'B'),
40+
(3.0, 3.0, 'B'),
41+
) AS t(x, y, id)
42+
```
43+
44+
Simple example path.
45+
46+
```{ggsql}
47+
VISUALISE x, y FROM df
48+
DRAW path
49+
```
50+
51+
Contrary to `line` drawings, `path` is not forced to follow the order along the axis.
52+
53+
```{ggsql}
54+
VISUALISE x, y FROM df
55+
DRAW path MAPPING 'Path' AS colour
56+
DRAW line MAPPING 'Line' AS colour
57+
```
58+
59+
Groups of individual paths can be declared via `PARTITION BY`.
60+
61+
```{ggsql}
62+
VISUALISE x, y FROM df
63+
DRAW path PARTITION BY id
64+
```
65+
66+
Invoking a group through discrete aesthetics works as well.
67+
68+
```{ggsql}
69+
VISUALISE x, y FROM df
70+
DRAW path MAPPING id AS colour
71+
```
72+
73+
Compared to polygons, paths don't close their shapes and fill their interiors.
74+
75+
```{ggsql}
76+
VISUALISE x, y FROM df
77+
DRAW polygon MAPPING 'Polygon' AS stroke
78+
DRAW path MAPPING 'Path' AS stroke
79+
```

doc/syntax/layer/polygon.qmd

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
title: "Polygon"
3+
---
4+
5+
> Layers are declared with the [`DRAW` clause](../clause/draw.qmd). Read the documentation for this clause for a thorough description of how to use it.
6+
7+
Polygons can be used to draw arbitrary closed shapes based on an ordered sequence of x,y-coordinates. They are similar to [paths](path.qmd), but close the shapes and fill the interior.
8+
9+
## Aesthetics
10+
The following aesthetics are recognised by the polygon layer.
11+
12+
### Required
13+
* `x` Position along the x-axis.
14+
* `y` Position along the y-axis.
15+
16+
### Optional
17+
* `stroke` The colour of the contour lines.
18+
* `fill` The colour of the inner area.
19+
* `colour` Shorthand for setting `stroke` and `fill` simultaneously.
20+
* `opacity` The opacity of colours.
21+
* `linewidth` The width of the contour lines.
22+
* `linetype` The dash pattern of the contour line.
23+
24+
## Settings
25+
The polygon layer has no additional settings
26+
27+
## Data transformation
28+
The polygon layer does not transform its data but passes it through unchanged
29+
30+
## Examples
31+
32+
```{ggsql}
33+
#| code-fold: true
34+
#| code-summary: "Create example data"
35+
CREATE TABLE df AS
36+
SELECT * FROM (VALUES
37+
(1.0, 1.0, 'A'),
38+
(1.0, 3.0, 'A'),
39+
(2.0, 1.0, 'A'),
40+
(2.0, 3.0, 'B'),
41+
(3.0, 1.0, 'B'),
42+
(3.0, 3.0, 'B'),
43+
) AS t(x, y, id)
44+
```
45+
46+
Simple example polygon.
47+
48+
```{ggsql}
49+
VISUALISE x, y FROM df
50+
DRAW polygon
51+
```
52+
53+
Groups of individual polygons can be declared via `PARTITION BY`.
54+
55+
```{ggsql}
56+
VISUALISE x, y FROM df
57+
DRAW polygon PARTITION BY id
58+
```
59+
60+
Invoking a group through discrete aesthetics works as well.
61+
62+
```{ggsql}
63+
VISUALISE x, y FROM df
64+
DRAW polygon MAPPING id AS colour
65+
```

src/plot/layer/geom/polygon.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@ impl GeomTrait for Polygon {
1313

1414
fn aesthetics(&self) -> GeomAesthetics {
1515
GeomAesthetics {
16-
supported: &["x", "y", "fill", "stroke", "opacity"],
16+
supported: &[
17+
"x",
18+
"y",
19+
"fill",
20+
"stroke",
21+
"opacity",
22+
"linewidth",
23+
"linetype",
24+
],
1725
required: &["x", "y"],
1826
hidden: &[],
1927
}

src/writer/vegalite.rs

Lines changed: 40 additions & 150 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,7 @@ impl VegaLiteWriter {
523523
GeomType::Area => "area",
524524
GeomType::Tile => "rect",
525525
GeomType::Ribbon => "area",
526+
GeomType::Polygon => "line",
526527
GeomType::Histogram => "bar",
527528
GeomType::Density => "area",
528529
GeomType::Boxplot => "boxplot",
@@ -1652,24 +1653,6 @@ impl Writer for VegaLiteWriter {
16521653
"mark": self.geom_to_mark(&layer.geom)
16531654
});
16541655

1655-
// For Bar geom, set mark with width parameter
1656-
if layer.geom.geom_type() == GeomType::Bar {
1657-
use crate::plot::ParameterValue;
1658-
let width = layer
1659-
.parameters
1660-
.get("width")
1661-
.and_then(|p| match p {
1662-
ParameterValue::Number(n) => Some(*n),
1663-
_ => None,
1664-
})
1665-
.unwrap_or(0.9);
1666-
layer_spec["mark"] = json!({
1667-
"type": "bar",
1668-
"width": {"band": width},
1669-
"clip": true
1670-
});
1671-
}
1672-
16731656
// Build transform array for this layer
16741657
// Always starts with a filter to select this layer's data from unified dataset
16751658
let mut transforms: Vec<Value> = Vec::new();
@@ -1685,21 +1668,6 @@ impl Writer for VegaLiteWriter {
16851668
}));
16861669
}
16871670

1688-
// Add window transform for Path geoms to preserve data order
1689-
// (Line geom uses Vega-Lite's default x-axis sorting)
1690-
if layer.geom.geom_type() == GeomType::Path {
1691-
let mut window_transform = json!({
1692-
"window": [{"op": "row_number", "as": naming::ORDER_COLUMN}]
1693-
});
1694-
1695-
// Add groupby if partition_by is present (restarts numbering per group)
1696-
if !layer.partition_by.is_empty() {
1697-
window_transform["groupby"] = json!(layer.partition_by);
1698-
}
1699-
1700-
transforms.push(window_transform);
1701-
}
1702-
17031671
// Set transform array on layer spec
17041672
layer_spec["transform"] = json!(transforms);
17051673

@@ -1772,21 +1740,13 @@ impl Writer for VegaLiteWriter {
17721740
encoding.insert("detail".to_string(), detail);
17731741
}
17741742

1775-
// Add order encoding for Path geoms (preserves data order instead of x-axis sorting)
1776-
if layer.geom.geom_type() == GeomType::Path {
1777-
encoding.insert(
1778-
"order".to_string(),
1779-
json!({
1780-
"field": naming::ORDER_COLUMN,
1781-
"type": "quantitative"
1782-
}),
1783-
);
1784-
}
1785-
17861743
// Handle geom-specific encoding transformations
17871744
match layer.geom.geom_type() {
1745+
GeomType::Bar => layer_spec = render_bar(layer_spec, layer),
1746+
GeomType::Path => render_path(&mut encoding),
17881747
GeomType::Ribbon => render_ribbon(&mut encoding),
17891748
GeomType::Area => render_area(&mut encoding, layer)?,
1749+
GeomType::Polygon => layer_spec = render_polygon(layer_spec, &mut encoding),
17901750
_ => {}
17911751
}
17921752

@@ -1910,6 +1870,42 @@ impl Writer for VegaLiteWriter {
19101870
}
19111871
}
19121872

1873+
fn render_bar(mut spec: Value, layer: &Layer) -> Value {
1874+
let width = match layer.parameters.get("width") {
1875+
Some(ParameterValue::Number(w)) => *w,
1876+
_ => 0.9,
1877+
};
1878+
spec["mark"] = json!({
1879+
"type": "bar",
1880+
"width": {"band": width},
1881+
"clip": true
1882+
});
1883+
spec
1884+
}
1885+
1886+
fn render_path(encoding: &mut Map<String, Value>) {
1887+
// Use the natural data order
1888+
encoding.insert("order".to_string(), json!({"value": Value::Null}));
1889+
}
1890+
1891+
fn render_polygon(mut spec: Value, encoding: &mut Map<String, Value>) -> Value {
1892+
// Polygon needs both `fill` and `stroke` independently, but map_aesthetic_name()
1893+
// converts fill → color (which works for most geoms). For closed line marks,
1894+
// we need actual `fill` and `stroke` channels, so we undo the mapping here.
1895+
if let Some(color) = encoding.remove("color") {
1896+
encoding.insert("fill".to_string(), color);
1897+
}
1898+
// Use the natural data order
1899+
encoding.insert("order".to_string(), json!({"value": Value::Null}));
1900+
spec["mark"] = json!({
1901+
"type": "line",
1902+
"interpolate": "linear-closed", // This closes the path
1903+
"fill": "#888888", // default values
1904+
"stroke": "#888888"
1905+
});
1906+
spec
1907+
}
1908+
19131909
fn render_ribbon(encoding: &mut Map<String, Value>) {
19141910
if let Some(ymax) = encoding.remove("ymax") {
19151911
encoding.insert("y".to_string(), ymax);
@@ -4608,112 +4604,6 @@ mod tests {
46084604
);
46094605
}
46104606

4611-
// ========================================
4612-
// Path Geom Order Preservation Tests
4613-
// ========================================
4614-
4615-
#[test]
4616-
fn test_path_geom_has_order_encoding_and_transform() {
4617-
let writer = VegaLiteWriter::new();
4618-
4619-
let mut spec = Plot::new();
4620-
let mut layer = Layer::new(Geom::path());
4621-
layer.mappings.insert(
4622-
"x".to_string(),
4623-
AestheticValue::standard_column("lon".to_string()),
4624-
);
4625-
layer.mappings.insert(
4626-
"y".to_string(),
4627-
AestheticValue::standard_column("lat".to_string()),
4628-
);
4629-
spec.layers.push(layer);
4630-
4631-
let df = df! {
4632-
"lon" => &[1.0, 2.0, 3.0],
4633-
"lat" => &[4.0, 5.0, 6.0],
4634-
}
4635-
.unwrap();
4636-
4637-
let json_str = writer.write(&spec, &wrap_data(df)).unwrap();
4638-
let vl_spec: Value = serde_json::from_str(&json_str).unwrap();
4639-
4640-
// Path layer should have transforms array
4641-
// First transform is filter (for unified data), second is window
4642-
let layer_spec = &vl_spec["layer"][0];
4643-
let transforms = layer_spec["transform"]
4644-
.as_array()
4645-
.expect("Should have transforms");
4646-
assert!(
4647-
transforms.len() >= 2,
4648-
"Path should have at least 2 transforms (filter + window)"
4649-
);
4650-
4651-
// First transform should be filter
4652-
assert!(
4653-
transforms[0].get("filter").is_some(),
4654-
"First transform should be filter"
4655-
);
4656-
4657-
// Second transform should be window with row_number
4658-
let window_transform = &transforms[1];
4659-
assert_eq!(window_transform["window"][0]["op"], "row_number");
4660-
assert_eq!(window_transform["window"][0]["as"], "__ggsql_order__");
4661-
4662-
// Path should have order encoding
4663-
let encoding = &layer_spec["encoding"];
4664-
assert!(
4665-
encoding.get("order").is_some(),
4666-
"Path geom should have order encoding"
4667-
);
4668-
assert_eq!(encoding["order"]["field"], "__ggsql_order__");
4669-
assert_eq!(encoding["order"]["type"], "quantitative");
4670-
}
4671-
4672-
#[test]
4673-
fn test_path_geom_with_partition_by() {
4674-
let writer = VegaLiteWriter::new();
4675-
4676-
let mut spec = Plot::new();
4677-
let mut layer = Layer::new(Geom::path());
4678-
layer.mappings.insert(
4679-
"x".to_string(),
4680-
AestheticValue::standard_column("lon".to_string()),
4681-
);
4682-
layer.mappings.insert(
4683-
"y".to_string(),
4684-
AestheticValue::standard_column("lat".to_string()),
4685-
);
4686-
layer.partition_by = vec!["trip_id".to_string()];
4687-
spec.layers.push(layer);
4688-
4689-
let df = df! {
4690-
"lon" => &[1.0, 2.0, 3.0],
4691-
"lat" => &[4.0, 5.0, 6.0],
4692-
"trip_id" => &["A", "A", "B"],
4693-
}
4694-
.unwrap();
4695-
4696-
let json_str = writer.write(&spec, &wrap_data(df)).unwrap();
4697-
let vl_spec: Value = serde_json::from_str(&json_str).unwrap();
4698-
4699-
// Path layer has transforms: filter first, then window
4700-
let transforms = vl_spec["layer"][0]["transform"]
4701-
.as_array()
4702-
.expect("Should have transforms");
4703-
assert!(
4704-
transforms.len() >= 2,
4705-
"Should have at least filter + window transforms"
4706-
);
4707-
4708-
// Window transform (second) should have groupby for partition
4709-
let window_transform = &transforms[1];
4710-
assert_eq!(
4711-
window_transform["groupby"],
4712-
json!(["trip_id"]),
4713-
"Window transform should have groupby for partition_by columns"
4714-
);
4715-
}
4716-
47174607
#[test]
47184608
fn test_line_geom_no_order_encoding() {
47194609
let writer = VegaLiteWriter::new();

0 commit comments

Comments
 (0)