Skip to content

Commit 946e72e

Browse files
teunbrandclaude
andcommitted
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>
1 parent c59b150 commit 946e72e

1 file changed

Lines changed: 72 additions & 48 deletions

File tree

src/writer/vegalite/layer.rs

Lines changed: 72 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ impl GeomRenderer for LineRenderer {
441441
) -> Result<PreparedData> {
442442
// Continuous material aesthetics that can trigger segmentation
443443
// (linetype is always discrete and already handled via partition_by)
444-
let material_aesthetics = ["stroke", "linewidth"];
444+
let material_aesthetics: &[&'static str] = &["stroke", "linewidth"];
445445

446446
// Start with existing partition_by (includes discrete material aesthetics already)
447447
let partition_columns: Vec<String> = layer.partition_by.clone();
@@ -457,9 +457,9 @@ impl GeomRenderer for LineRenderer {
457457
};
458458

459459
// Check continuous material aesthetics (not in partition_by) for within-group variation
460-
let mut varying_aesthetics: Vec<&str> = Vec::new();
460+
let mut varying_aesthetics: Vec<&'static str> = Vec::new();
461461

462-
for aesthetic in material_aesthetics {
462+
for &aesthetic in material_aesthetics {
463463
if let Some(AestheticValue::Column { name: col, .. }) = layer.mappings.get(aesthetic) {
464464
// Skip if already in partition_by (discrete, already defines groups)
465465
if !layer.partition_by.contains(col) {
@@ -479,11 +479,9 @@ impl GeomRenderer for LineRenderer {
479479
dataframe_to_values_with_bins(df, binned_columns)?
480480
};
481481

482-
let needs_segmentation = !varying_aesthetics.is_empty();
483-
484482
Ok(PreparedData::Single {
485483
values,
486-
metadata: Box::new(needs_segmentation),
484+
metadata: Box::new(varying_aesthetics),
487485
})
488486
}
489487

@@ -516,20 +514,54 @@ impl GeomRenderer for LineRenderer {
516514
));
517515
};
518516

519-
let needs_segmentation = metadata.downcast_ref::<bool>() == Some(&true);
517+
// Get varying aesthetics from metadata
518+
let Some(varying_aesthetics) = metadata.downcast_ref::<Vec<&'static str>>() else {
519+
return Ok(vec![layer_spec]);
520+
};
521+
522+
// Handle varying linewidth: switch to trail mark and translate encodings
523+
if varying_aesthetics.contains(&"linewidth") {
524+
layer_spec["mark"] = json!({"type": "trail", "clip": true, "stroke": null});
525+
526+
// Translate line encodings to trail encodings
527+
if let Some(encoding_obj) = layer_spec.get_mut("encoding") {
528+
if let Some(encoding_map) = encoding_obj.as_object_mut() {
529+
// strokeWidth → size
530+
if let Some(stroke_width) = encoding_map.remove("strokeWidth") {
531+
encoding_map.insert("size".to_string(), stroke_width);
532+
}
533+
534+
// stroke → fill
535+
if let Some(stroke) = encoding_map.remove("stroke") {
536+
encoding_map.insert("fill".to_string(), stroke);
537+
}
538+
539+
// opacity → fillOpacity
540+
if let Some(opacity) = encoding_map.remove("opacity") {
541+
encoding_map.insert("fillOpacity".to_string(), opacity);
542+
}
543+
}
544+
}
545+
}
520546

521-
// Early return for standard line rendering
522-
if !needs_segmentation {
547+
// Handle varying stroke: apply segmentation
548+
if !varying_aesthetics.contains(&"stroke") {
549+
// Only linewidth varies, trail mark handles it natively
523550
return Ok(vec![layer_spec]);
524551
}
525552

526-
// Get position column names
527-
let x_col = naming::aesthetic_column("pos1");
528-
let y_col = naming::aesthetic_column("pos2");
553+
// Build list of fields to segment (always x/y, plus size if linewidth varies)
554+
let mut segment_fields = vec![
555+
("x", naming::aesthetic_column("pos1")),
556+
("y", naming::aesthetic_column("pos2")),
557+
];
558+
if varying_aesthetics.contains(&"linewidth") {
559+
segment_fields.push(("size", naming::aesthetic_column("linewidth")));
560+
}
529561

530562
// Segmented rendering using detail encoding:
531563
// 1. Create segment IDs (row_index serves as segment ID)
532-
// 2. Create next row's x/y values using window transform
564+
// 2. Create next row's values using window transform
533565
// 3. Flatten to create 2 rows per segment (point_index: 0=start, 1=end)
534566
// 4. Use calculate to pick current or next based on point_index
535567
// 5. Add segment ID to detail encoding
@@ -542,18 +574,16 @@ impl GeomRenderer for LineRenderer {
542574
.unwrap_or_default();
543575

544576
// Step 1 & 2: Window transform to get next row's values
545-
let window_ops = vec![
546-
json!({
547-
"op": "lead",
548-
"field": x_col,
549-
"as": format!("{}_next", x_col)
550-
}),
551-
json!({
552-
"op": "lead",
553-
"field": y_col,
554-
"as": format!("{}_next", y_col)
555-
}),
556-
];
577+
let window_ops: Vec<Value> = segment_fields
578+
.iter()
579+
.map(|(_, field)| {
580+
json!({
581+
"op": "lead",
582+
"field": field,
583+
"as": format!("{}_next", field)
584+
})
585+
})
586+
.collect();
557587

558588
let mut window_transform = json!({
559589
"window": window_ops,
@@ -567,8 +597,10 @@ impl GeomRenderer for LineRenderer {
567597
transforms.push(window_transform);
568598

569599
// Step 2b: Filter out last row in each group (no next point)
600+
// Check the first field (x) for null to detect end of segments
601+
let first_field = &segment_fields[0].1;
570602
transforms.push(json!({
571-
"filter": format!("datum.{}_next != null", x_col)
603+
"filter": format!("datum.{}_next != null", first_field)
572604
}));
573605

574606
// Step 3: Flatten to create 2 rows per segment
@@ -583,16 +615,13 @@ impl GeomRenderer for LineRenderer {
583615
"as": ["__point_index__"]
584616
}));
585617

586-
// Step 4: Calculate actual x/y based on point_index
587-
transforms.push(json!({
588-
"calculate": format!("datum.__point_index__ == 0 ? datum.{} : datum.{}_next", x_col, x_col),
589-
"as": format!("{}_final", x_col)
590-
}));
591-
592-
transforms.push(json!({
593-
"calculate": format!("datum.__point_index__ == 0 ? datum.{} : datum.{}_next", y_col, y_col),
594-
"as": format!("{}_final", y_col)
595-
}));
618+
// Step 4: Calculate actual field values based on point_index
619+
for (_, field) in &segment_fields {
620+
transforms.push(json!({
621+
"calculate": format!("datum.__point_index__ == 0 ? datum.{} : datum.{}_next", field, field),
622+
"as": format!("{}_final", field)
623+
}));
624+
}
596625

597626
// Step 5: Create segment ID (use original row_index)
598627
transforms.push(json!({
@@ -604,20 +633,15 @@ impl GeomRenderer for LineRenderer {
604633
// Don't set layer_spec["data"] - use the unified top-level dataset
605634
// The source filter transform will select the correct rows
606635

607-
// Update encodings to use final x/y and add segment_id to detail
636+
// Update encodings to use final field values and add segment_id to detail
608637
if let Some(encoding_obj) = layer_spec.get_mut("encoding") {
609638
if let Some(encoding_map) = encoding_obj.as_object_mut() {
610-
// Update x encoding to use x_final
611-
if let Some(x_enc) = encoding_map.get_mut("x") {
612-
if let Some(x_obj) = x_enc.as_object_mut() {
613-
x_obj.insert("field".to_string(), json!(format!("{}_final", x_col)));
614-
}
615-
}
616-
617-
// Update y encoding to use y_final
618-
if let Some(y_enc) = encoding_map.get_mut("y") {
619-
if let Some(y_obj) = y_enc.as_object_mut() {
620-
y_obj.insert("field".to_string(), json!(format!("{}_final", y_col)));
639+
// Update each field encoding to use _final
640+
for (encoding_name, field) in &segment_fields {
641+
if let Some(enc) = encoding_map.get_mut(*encoding_name) {
642+
if let Some(enc_obj) = enc.as_object_mut() {
643+
enc_obj.insert("field".to_string(), json!(format!("{}_final", field)));
644+
}
621645
}
622646
}
623647

0 commit comments

Comments
 (0)