@@ -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