Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/plot/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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
Expand Down Expand Up @@ -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()));
}
}
72 changes: 72 additions & 0 deletions src/reader/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
}
}
Loading