Skip to content

Commit fcc07d1

Browse files
teunbrandclaude
andauthored
Variable width/color/opacity lines (#298)
* let claude manage writer details somewhere * Add automatic line segmentation for variable material aesthetics Automatically detects when material aesthetics (stroke, linetype) vary within partition groups and converts to segmented rendering. Uses Vega-Lite transforms (window, flatten, calculate) instead of data restructuring. Implementation uses efficient vectorized Polars operations for group boundary detection and preserves the unified dataset architecture with proper source filter integration. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Unify change detection logic for line segmentation and text RLE Extract common `find_change_starts()` function that finds row indices where any specified column changes value. Uses efficient slice-based comparison instead of shift() to avoid allocation and null handling. Both line segmentation and text font run-length encoding now use the same underlying implementation. Inline former `find_group_boundaries()` stub at its single call site. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Simplify line segmentation using partition_by for discrete/continuous detection Key changes: - Use layer.partition_by to distinguish discrete vs continuous material aesthetics - Discrete aesthetics (linetype, discrete stroke/linewidth) already in partition_by from execute phase - define groups naturally, no action needed - Continuous aesthetics (numeric stroke/linewidth) checked for within-group variation and trigger segmentation if they vary, but never added to partition columns - Remove linetype from material aesthetics check (always discrete, already handled) - Remove LineSegmentMetadata struct - use layer.partition_by directly in finalize() This leverages existing information computed during execute phase rather than re-inferring or passing through metadata. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Also let `PreparedData::Single` have metadata field * Add metadata to PreparedData::Single and use trail marks for varying linewidth Key changes: 1. Add metadata field to PreparedData::Single (matching Composite structure) 2. Refactor LineRenderer to use Single with metadata instead of Composite hack 3. Pass varying_aesthetics vector as metadata to communicate segmentation needs Trail mark support: 4. Use Vega-Lite trail mark when linewidth varies (native variable-width support) 5. Translate line encodings to trail: strokeWidth→size, stroke→fill, opacity→fillOpacity 6. Set stroke:null on trail mark to disable border Segmentation logic: 7. Only linewidth varies → trail mark, no segmentation 8. Only stroke varies → line mark, segmented 9. Both vary → trail mark, segmented (with varying width per segment) Implementation: 10. Build segment_fields vector: [x, y] + conditionally [size] 11. Loop over segment_fields to create window/calculate transforms and update encodings 12. Capture next row's size value in window transform for smooth width transitions Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Add opacity as material aesthetic requiring segmentation Treat opacity like stroke: varying opacity within partition groups triggers segmentation. This ensures each segment can have its own opacity value. Behavior: - Only opacity varies → line mark, segmented - Opacity + linewidth → trail mark, segmented - Opacity + stroke → line mark, segmented - All three → trail mark, segmented Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Unify line and path renderers into single PathRenderer Remove LineRenderer and consolidate all line/path rendering logic into PathRenderer. Both geom types were already functionally identical: - Both map to "line" mark in Vega-Lite - Both add order encoding to preserve data order - Both need identical material aesthetic handling Changes: - Remove LineRenderer struct - PathRenderer now handles both line and path geoms - Updated get_renderer() to return PathRenderer for both GeomType::Line and GeomType::Path - Updated documentation to reflect unified renderer This eliminates code duplication while maintaining all functionality. The actual sorting/ordering happens in the execute phase via SQL ORDER BY, and both geoms preserve that order through the __ggsql_row_index__ column. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Add tests for PathRenderer line segmentation and trail mark conversion. Tests cover: (1) metadata detection of varying material aesthetics, (2) trail mark conversion for varying linewidth, and (3) segmentation transforms for varying stroke/opacity. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * cargo fmt * pay obeisance to clippy * add examples --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent fd4e8c9 commit fcc07d1

6 files changed

Lines changed: 982 additions & 133 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,9 @@ The codebase includes connection string parsing and feature flags for additional
569569

570570
**Responsibility**: Convert DataFrame + Plot → output format (JSON, PNG, R code, etc.)
571571

572-
#### Writer Trait (`mod.rs`)
572+
**Internal Architecture**: For Vega-Lite writer implementation details (unified dataset system, layer rendering pipeline, GeomRenderer lifecycle), see [`src/writer/vegalite/CLAUDE.md`](src/writer/vegalite/CLAUDE.md).
573+
574+
#### Writer Trait
573575

574576
```rust
575577
pub trait Writer {
@@ -578,68 +580,13 @@ pub trait Writer {
578580
}
579581
```
580582

581-
#### Vega-Lite Writer (`vegalite.rs`)
582-
583-
**Current Production Writer** - Fully implemented and tested.
584-
585-
**Features**:
586-
587-
- Converts Plot → Vega-Lite JSON specification
588-
- Multi-layer composition support
589-
- Scale type → Vega field type mapping
590-
- Faceting (wrap and grid layouts)
591-
- Axis label customization
592-
- Inline data embedding
593-
594-
**Architecture**:
583+
#### Available Writers
595584

596-
```rust
597-
impl Writer for VegaLiteWriter {
598-
fn write(&self, df: &DataFrame, spec: &Plot) -> Result<String> {
599-
// 1. Convert DataFrame to JSON values
600-
let data_values = self.dataframe_to_json(df)?;
601-
602-
// 2. Build Vega-Lite spec
603-
let mut vl_spec = json!({
604-
"$schema": "https://vega.github.io/schema/vega-lite/v6.json",
605-
"data": {"values": data_values},
606-
"width": 600,
607-
"autosize": {"type": "fit", "contains": "padding"}
608-
});
609-
610-
// 3. Handle single vs multi-layer
611-
if spec.layers.len() == 1 {
612-
// Single layer: flat structure
613-
vl_spec["mark"] = self.geom_to_mark(&spec.layers[0].geom);
614-
vl_spec["encoding"] = self.build_encoding(&spec.layers[0], df, spec)?;
615-
} else {
616-
// Multi-layer: layered structure
617-
let layers: Vec<Value> = spec.layers.iter()
618-
.map(|layer| {
619-
let mut layer_spec = json!({
620-
"mark": self.geom_to_mark(&layer.geom),
621-
"encoding": self.build_encoding(layer, df, spec)?
622-
});
623-
// Apply axis labels to each layer
624-
apply_axis_labels(&mut layer_spec, &spec.labels);
625-
Ok(layer_spec)
626-
})
627-
.collect::<Result<Vec<_>>>()?;
628-
vl_spec["layer"] = json!(layers);
629-
}
630-
631-
// 4. Add faceting, title, etc.
632-
self.add_faceting(&mut vl_spec, spec)?;
633-
if let Some(labels) = &spec.labels {
634-
if let Some(title) = labels.labels.get("title") {
635-
vl_spec["title"] = json!(title);
636-
}
637-
}
638-
639-
Ok(serde_json::to_string_pretty(&vl_spec)?)
640-
}
641-
}
642-
```
585+
**VegaLiteWriter** (Production-ready):
586+
- Generates Vega-Lite v6 JSON specifications
587+
- Multi-layer composition with unified dataset architecture
588+
- Full support for scales, faceting, projections, and labels
589+
- Automatic geom-specific optimizations (segmentation, decomposition)
643590

644591
---
645592

doc/syntax/layer/type/line.qmd

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,10 @@ The following aesthetics are recognised by the line layer.
1414
* Secondary axis (e.g. `y`): The value of the dependent variable.
1515

1616
### Optional
17-
* `colour`/`stroke`: The colour of the line
18-
* `opacity`: The opacity of the line
19-
* `linewidth`: The width of the line
20-
* `linetype`: The type of line, i.e. the dashing pattern
17+
* `colour`/`stroke`: The colour of the line.
18+
* `opacity`: The opacity of the line.
19+
* `linewidth`: The width of the line. If `linewidth` varies within a group, `linetype` is incompatible.
20+
* `linetype`: The type of line, i.e. the dashing pattern.
2121

2222
## Settings
2323
* `position`: Position adjustment. One of `'identity'` (default), `'stack'`, `'dodge'`, or `'jitter'`
@@ -26,7 +26,9 @@ The following aesthetics are recognised by the line layer.
2626
* `'transposed'` to align the layer's primary axis with the coordinate system's second axis.
2727

2828
## Data transformation
29-
The line layer sorts the data along its primary axis.
29+
The line layer sorts the data along its primary axis.
30+
If the line has a variable `stroke` or `opacity` aesthetic within groups, the line is broken into segments.
31+
Each segment gets the property of the preceding datapoint, so the last datapoint in a group does not transfer these properties.
3032

3133
## Orientation
3234
Line plots are sorted and connected along their primary axis. Since the primary axis cannot be deduced from the mapping it must be specified using the `orientation` setting. If you wish to create a vertical line plot, you need to set `orientation => 'transposed'` to indicate that the primary layer axis follows the second axis of the coordinate system.
@@ -50,10 +52,41 @@ DRAW line
5052
PARTITION BY Month
5153
```
5254

53-
or split them with an aesthetic
55+
or split them with a *discrete* aesthetic.
56+
We can make a continuous variable (`Month`) discrete by using an ordinal scale.
5457

5558
```{ggsql}
5659
VISUALISE FROM ggsql:airquality
5760
DRAW line
58-
MAPPING Day AS x, Temp AS y, Month AS color
61+
MAPPING Day AS x, Temp AS y, Month AS stroke
62+
SCALE ordinal stroke
5963
```
64+
65+
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.
66+
67+
```{ggsql}
68+
WITH data(x, y, z) AS (VALUES
69+
(1, 2, 1),
70+
(2, 5, 3),
71+
(3, 3, 5),
72+
)
73+
VISUALISE x, y FROM data
74+
DRAW line
75+
MAPPING z AS stroke
76+
SCALE stroke TO ['red', 'green', 'blue']
77+
```
78+
79+
The `linewidth` aesthetic can vary point to point.
80+
81+
```{ggsql}
82+
WITH data(x, y, z) AS (VALUES
83+
(1, 2, 1),
84+
(2, 5, 3),
85+
(3, 3, 5),
86+
)
87+
88+
VISUALISE x, y FROM data
89+
DRAW line
90+
MAPPING z AS linewidth
91+
SCALE linewidth TO [0, 30]
92+
```

doc/syntax/layer/type/path.qmd

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,18 @@ The following aesthetics are recognised by the path layer.
1414
* Secondary axis (e.g. `y`): Position along the secondary axis.
1515

1616
### Optional
17-
* `colour`/`stroke`: The colour of the path
18-
* `opacity`: The opacity of the path
19-
* `linewidth`: The width of the path
20-
* `linetype`: The type of path, i.e. the dashing pattern
17+
* `colour`/`stroke`: The colour of the path.
18+
* `opacity`: The opacity of the path.
19+
* `linewidth`: The width of the path. If `linewidth` varies within a group, `linetype` is incompatible.
20+
* `linetype`: The type of path, i.e. the dashing pattern.
2121

2222
## Settings
2323
* `position`: Position adjustment. One of `'identity'` (default), `'stack'`, `'dodge'`, or `'jitter'`
2424

2525
## Data transformation
26-
The path layer does not transform its data but passes it through unchanged
26+
The path layer does not transform its data but passes it through unchanged.
27+
If the path has a variable `stroke` or `opacity` aesthetic within groups, the line is broken into segments.
28+
Each segment gets the property of the preceding datapoint, so the last datapoint in a group does not transfer these properties.
2729

2830
## Orientation
2931
The path layer has no orientation. The axes are treated symmetrically.
@@ -87,3 +89,33 @@ DRAW polygon
8789
DRAW path
8890
MAPPING 'Path' AS stroke
8991
```
92+
93+
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.
94+
95+
```{ggsql}
96+
WITH data(x, y, z) AS (VALUES
97+
(1, 2, 1),
98+
(3, 3, 3),
99+
(2, 5, 5),
100+
)
101+
102+
VISUALISE x, y FROM data
103+
DRAW path
104+
MAPPING z AS stroke
105+
SCALE stroke TO ['red', 'green', 'blue']
106+
```
107+
108+
The `linewidth` aesthetic can vary point to point.
109+
110+
```{ggsql}
111+
WITH data(x, y, z) AS (VALUES
112+
(1, 2, 1),
113+
(3, 3, 3),
114+
(2, 5, 5),
115+
)
116+
117+
VISUALISE x, y FROM data
118+
DRAW path
119+
MAPPING z AS linewidth
120+
SCALE linewidth TO [0, 30]
121+
```

0 commit comments

Comments
 (0)