From b397c9bf0bfd6168510da739bc9f414fdae9fd1e Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Wed, 11 Mar 2026 14:40:55 +0100 Subject: [PATCH] Fix #184 --- src/plot/main.rs | 130 ++++++++++++++++++++++++++++++++++++++++++++++ src/reader/mod.rs | 72 +++++++++++++++++++++++++ 2 files changed, 202 insertions(+) diff --git a/src/plot/main.rs b/src/plot/main.rs index d7d63a26..08eb3d53 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -169,6 +169,7 @@ impl Plot { /// - Layer aesthetics /// - Layer remappings /// - Scale aesthetics + /// - Label keys pub fn transform_aesthetics_to_internal(&mut self) { let ctx = self.get_aesthetic_context(); @@ -187,6 +188,19 @@ impl Plot { scale.aesthetic = internal.to_string(); } } + + // Transform label keys + if let Some(labels) = &mut self.labels { + let mut transformed = HashMap::new(); + for (key, value) in labels.labels.drain() { + let internal_key = ctx + .map_user_to_internal(&key) + .map(|s| s.to_string()) + .unwrap_or(key); + transformed.insert(internal_key, value); + } + labels.labels = transformed; + } } /// Check if the spec has any layers @@ -716,4 +730,120 @@ mod tests { "Non-color aesthetic should keep its name" ); } + + // ======================================== + // Label Transformation Tests + // ======================================== + + #[test] + fn test_label_transform_with_default_project() { + // LABEL x/y with default cartesian should transform to pos1/pos2 + use crate::plot::projection::{Coord, Projection}; + + let mut spec = Plot::new(); + spec.project = Some(Projection { + coord: Coord::cartesian(), + aesthetics: vec!["x".to_string(), "y".to_string()], + properties: HashMap::new(), + }); + spec.labels = Some(Labels { + labels: HashMap::from([ + ("x".to_string(), "X Axis".to_string()), + ("y".to_string(), "Y Axis".to_string()), + ]), + }); + + spec.initialize_aesthetic_context(); + spec.transform_aesthetics_to_internal(); + + let labels = spec.labels.as_ref().unwrap(); + assert_eq!(labels.labels.get("pos1"), Some(&"X Axis".to_string())); + assert_eq!(labels.labels.get("pos2"), Some(&"Y Axis".to_string())); + assert!(labels.labels.get("x").is_none()); + assert!(labels.labels.get("y").is_none()); + } + + #[test] + fn test_label_transform_with_flipped_project() { + // LABEL x/y with PROJECT y, x TO cartesian should swap the mappings + use crate::plot::projection::{Coord, Projection}; + + let mut spec = Plot::new(); + // PROJECT y, x TO cartesian means y maps to pos1, x maps to pos2 + spec.project = Some(Projection { + coord: Coord::cartesian(), + aesthetics: vec!["y".to_string(), "x".to_string()], + properties: HashMap::new(), + }); + spec.labels = Some(Labels { + labels: HashMap::from([ + ("x".to_string(), "Category".to_string()), + ("y".to_string(), "Value".to_string()), + ]), + }); + + spec.initialize_aesthetic_context(); + spec.transform_aesthetics_to_internal(); + + let labels = spec.labels.as_ref().unwrap(); + // x maps to pos2 (second positional), y maps to pos1 (first positional) + assert_eq!(labels.labels.get("pos1"), Some(&"Value".to_string())); + assert_eq!(labels.labels.get("pos2"), Some(&"Category".to_string())); + } + + #[test] + fn test_label_transform_with_polar_project() { + // LABEL theta/radius with polar should transform to pos1/pos2 + use crate::plot::projection::{Coord, Projection}; + + let mut spec = Plot::new(); + spec.project = Some(Projection { + coord: Coord::polar(), + aesthetics: vec!["theta".to_string(), "radius".to_string()], + properties: HashMap::new(), + }); + spec.labels = Some(Labels { + labels: HashMap::from([ + ("theta".to_string(), "Angle".to_string()), + ("radius".to_string(), "Distance".to_string()), + ]), + }); + + spec.initialize_aesthetic_context(); + spec.transform_aesthetics_to_internal(); + + let labels = spec.labels.as_ref().unwrap(); + assert_eq!(labels.labels.get("pos1"), Some(&"Angle".to_string())); + assert_eq!(labels.labels.get("pos2"), Some(&"Distance".to_string())); + } + + #[test] + fn test_label_transform_preserves_non_positional() { + // LABEL title/color should be preserved unchanged + use crate::plot::projection::{Coord, Projection}; + + let mut spec = Plot::new(); + spec.project = Some(Projection { + coord: Coord::cartesian(), + aesthetics: vec!["x".to_string(), "y".to_string()], + properties: HashMap::new(), + }); + spec.labels = Some(Labels { + labels: HashMap::from([ + ("title".to_string(), "My Chart".to_string()), + ("color".to_string(), "Category".to_string()), + ("x".to_string(), "X Axis".to_string()), + ]), + }); + + spec.initialize_aesthetic_context(); + spec.transform_aesthetics_to_internal(); + + let labels = spec.labels.as_ref().unwrap(); + // Non-positional labels should remain unchanged + assert_eq!(labels.labels.get("title"), Some(&"My Chart".to_string())); + assert_eq!(labels.labels.get("color"), Some(&"Category".to_string())); + // Positional label should be transformed + assert_eq!(labels.labels.get("pos1"), Some(&"X Axis".to_string())); + } } diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 186c0fe4..9aa066d2 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -1044,4 +1044,76 @@ mod tests { "Identity position should not have xOffset encoding" ); } + + #[test] + fn test_label_with_flipped_project() { + // End-to-end test: LABEL x/y with PROJECT y, x TO cartesian + // Labels should be correctly applied to the flipped axes + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES (1, 10), (2, 20)) AS t(x, y) + VISUALISE + DRAW bar MAPPING x AS y, y AS x + PROJECT y, x TO cartesian + LABEL x => 'Value', y => 'Category' + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = json["layer"].as_array().unwrap().first().unwrap(); + let encoding = &layer["encoding"]; + + // With PROJECT y, x TO cartesian: + // - y is pos1 (first positional), renders to VL x-axis in cartesian + // - x is pos2 (second positional), renders to VL y-axis in cartesian + // So LABEL y => 'Category' should appear on VL x-axis, LABEL x => 'Value' on VL y-axis + let x_title = encoding["x"]["title"].as_str(); + let y_title = encoding["y"]["title"].as_str(); + + assert_eq!( + x_title, + Some("Category"), + "x-axis should have 'Category' title (from LABEL y). Got encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + assert_eq!( + y_title, + Some("Value"), + "y-axis should have 'Value' title (from LABEL x). Got encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + } + + #[test] + fn test_label_with_polar_project() { + // End-to-end test: LABEL theta/radius with PROJECT TO polar + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE value AS theta, category AS fill + DRAW bar + PROJECT TO polar + LABEL theta => 'Angle', radius => 'Distance' + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + let layer = json["layer"].as_array().unwrap().first().unwrap(); + let encoding = &layer["encoding"]; + + // Verify theta encoding has the label + let theta_title = encoding["theta"]["title"].as_str(); + assert_eq!( + theta_title, + Some("Angle"), + "theta encoding should have 'Angle' title. Got encoding: {}", + serde_json::to_string_pretty(encoding).unwrap() + ); + } }