From 44e886775d3dd4b569e627f6755dd1340e09a292 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 23 Feb 2026 13:22:18 +0100 Subject: [PATCH 01/28] initial rename of clause --- CLAUDE.md | 70 +++--- EXAMPLES.md | 32 +-- README.md | 4 +- doc/examples.qmd | 10 +- doc/ggsql.xml | 32 +-- ggsql-vscode/CHANGELOG.md | 2 +- ggsql-vscode/README.md | 2 +- ggsql-vscode/examples/sample.gsql | 16 +- ggsql-vscode/syntaxes/ggsql.tmLanguage.json | 22 +- src/doc/API.md | 2 +- src/parser/builder.rs | 246 +++++++++---------- src/plot/coord/mod.rs | 7 - src/plot/main.rs | 14 +- src/plot/mod.rs | 8 +- src/plot/project/mod.rs | 7 + src/plot/{coord => project}/types.rs | 18 +- src/writer/vegalite/mod.rs | 8 +- src/writer/vegalite/{coord.rs => project.rs} | 50 ++-- tree-sitter-ggsql/grammar.js | 30 +-- tree-sitter-ggsql/queries/highlights.scm | 2 +- tree-sitter-ggsql/test/corpus/basic.txt | 22 +- 21 files changed, 302 insertions(+), 302 deletions(-) delete mode 100644 src/plot/coord/mod.rs create mode 100644 src/plot/project/mod.rs rename src/plot/{coord => project}/types.rs (52%) rename src/writer/vegalite/{coord.rs => project.rs} (86%) diff --git a/CLAUDE.md b/CLAUDE.md index 0b6b02b1..8c651cb7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,20 +11,20 @@ SELECT date, revenue, region FROM sales WHERE year = 2024 VISUALISE date AS x, revenue AS y, region AS color DRAW line SCALE x VIA date -COORD cartesian SETTING ylim => [0, 100000] +PROJECT cartesian SETTING ylim => [0, 100000] LABEL title => 'Sales by Region', x => 'Date', y => 'Revenue' THEME minimal ``` **Statistics**: -- ~7,500 lines of Rust code (including COORD implementation) +- ~7,500 lines of Rust code (including PROJECT implementation) - 507-line Tree-sitter grammar (simplified, no external scanner) - Full bindings: Rust, C, Python, Node.js with tree-sitter integration - Syntax highlighting support via Tree-sitter queries - 916 total tests (174 parser tests, comprehensive builder and integration tests) - End-to-end working pipeline: SQL → Data → Visualization -- Coordinate transformations: Cartesian (xlim/ylim), Flip, Polar +- Projectinate transformations: Cartesian (xlim/ylim), Flip, Polar - VISUALISE FROM shorthand syntax with automatic SELECT injection --- @@ -257,7 +257,7 @@ For detailed API documentation, see [`src/doc/API.md`](src/doc/API.md). - Uses `tree-sitter-ggsql` grammar (507 lines, simplified approach) - Parses **full query** (SQL + VISUALISE) into concrete syntax tree (CST) -- Grammar supports: PLOT/TABLE/MAP types, DRAW/SCALE/FACET/COORD/LABEL/THEME clauses +- Grammar supports: PLOT/TABLE/MAP types, DRAW/SCALE/FACET/PROJECT/LABEL/THEME clauses - British and American spellings: `VISUALISE` / `VISUALIZE` - **SQL portion parsing**: Basic SQL structure (SELECT, WITH, CREATE, INSERT, subqueries) - **Recursive subquery support**: Fully recursive grammar for complex SQL @@ -303,7 +303,7 @@ pub struct Plot { pub layers: Vec, // DRAW clauses pub scales: Vec, // SCALE clauses pub facet: Option, // FACET clause - pub coord: Option, // COORD clause + pub project: Option, // PROJECT clause pub labels: Option, // LABEL clause pub theme: Option, // THEME clause } @@ -387,17 +387,17 @@ pub enum FacetScales { FreeY, // 'free_y' - independent y-axis, shared x-axis } -pub struct Coord { - pub coord_type: CoordType, +pub struct Project { + pub project_type: ProjectType, pub properties: HashMap, } -pub enum CoordType { - Cartesian, // Standard x/y coordinates - Polar, // Polar coordinates (pie charts, rose plots) +pub enum ProjectType { + Cartesian, // Standard x/y projectinates + Polar, // Polar projectinates (pie charts, rose plots) Flip, // Flipped Cartesian (swaps x and y) Fixed, // Fixed aspect ratio - Trans, // Transformed coordinates + Trans, // Transformed projectinates Map, // Map projections QuickMap, // Quick map approximation } @@ -809,7 +809,7 @@ The kernel includes enhanced support for Positron IDE: - Complete syntax highlighting for ggsql queries - SQL keyword support (SELECT, FROM, WHERE, JOIN, WITH, etc.) -- ggsql clause highlighting (VISUALISE, SCALE, COORD, FACET, LABEL, etc.) +- ggsql clause highlighting (VISUALISE, SCALE, PROJECT, FACET, LABEL, etc.) - Aesthetic highlighting (x, y, color, size, shape, etc.) - String and number literals - Comment support (`--` and `/* */`) @@ -852,7 +852,7 @@ When running in Positron IDE, the extension provides enhanced functionality: **Syntax Scopes**: -- `keyword.control.ggsql` - VISUALISE, DRAW, SCALE, COORD, etc. +- `keyword.control.ggsql` - VISUALISE, DRAW, SCALE, PROJECT, etc. - `keyword.other.sql` - SELECT, FROM, WHERE, etc. - `entity.name.function.geom.ggsql` - point, line, bar, etc. - `variable.parameter.aesthetic.ggsql` - x, y, color, size, etc. @@ -1178,7 +1178,7 @@ Where `` can be: | `DRAW` | ✅ Yes | Define layers | `DRAW line MAPPING date AS x, value AS y` | | `SCALE` | ✅ Yes | Configure scales | `SCALE x VIA date` | | `FACET` | ❌ No | Small multiples | `FACET WRAP region` | -| `COORD` | ❌ No | Coordinate system | `COORD cartesian SETTING xlim => [0,100]` | +| `PROJECT` | ❌ No | Projectinate system | `PROJECT cartesian SETTING xlim => [0,100]` | | `LABEL` | ❌ No | Text labels | `LABEL title => 'My Chart', x => 'Date'` | | `THEME` | ❌ No | Visual styling | `THEME minimal` | @@ -1346,7 +1346,7 @@ SCALE color FROM ['A', 'B'] TO ['red', 'blue'] SCALE color TO viridis ``` -**Note**: Cannot specify range in both SCALE and COORD for the same aesthetic (will error). +**Note**: Cannot specify range in both SCALE and PROJECT for the same aesthetic (will error). **Examples**: @@ -1393,25 +1393,25 @@ FACET WRAP region SETTING scales => 'free_y' FACET region BY category SETTING scales => 'fixed' ``` -### COORD Clause +### PROJECT Clause **Syntax**: ```sql --- With coordinate type -COORD [SETTING ] +-- With projectinate type +PROJECT [SETTING ] -- With properties only (defaults to cartesian) -COORD SETTING +PROJECT SETTING ``` -**Coordinate Types**: +**Projectinate Types**: -- **`cartesian`** - Standard x/y Cartesian coordinates (default) +- **`cartesian`** - Standard x/y Cartesian projectinates (default) - **`flip`** - Flipped Cartesian (swaps x and y axes) -- **`polar`** - Polar coordinates (for pie charts, rose plots) +- **`polar`** - Polar projectinates (for pie charts, rose plots) - **`fixed`** - Fixed aspect ratio -- **`trans`** - Transformed coordinates +- **`trans`** - Transformed projectinates - **`map`** - Map projections - **`quickmap`** - Quick approximation for maps @@ -1435,9 +1435,9 @@ COORD SETTING **Important Notes**: 1. **Axis limits auto-swap**: `xlim => [100, 0]` automatically becomes `[0, 100]` -2. **ggplot2 compatibility**: `coord_flip` preserves axis label names (labels stay with aesthetic names, not visual position) -3. **Range conflicts**: Error if same aesthetic has input range in both SCALE and COORD -4. **Multi-layer support**: All coordinate transforms apply to all layers +2. **ggplot2 compatibility**: `project_flip` preserves axis label names (labels stay with aesthetic names, not visual position) +3. **Range conflicts**: Error if same aesthetic has input range in both SCALE and PROJECT +4. **Multi-layer support**: All projectinate transforms apply to all layers **Status**: @@ -1450,29 +1450,29 @@ COORD SETTING ```sql -- Cartesian with axis limits -COORD cartesian SETTING xlim => [0, 100], ylim => [0, 50] +PROJECT cartesian SETTING xlim => [0, 100], ylim => [0, 50] -- Cartesian with aesthetic range -COORD cartesian SETTING color => O ['red', 'green', 'blue'] +PROJECT cartesian SETTING color => O ['red', 'green', 'blue'] -- Cartesian shorthand (type optional when using SETTING) -COORD SETTING xlim => [0, 100] +PROJECT SETTING xlim => [0, 100] --- Flip coordinates for horizontal bar chart -COORD flip +-- Flip projectinates for horizontal bar chart +PROJECT flip -- Flip with aesthetic range -COORD flip SETTING color => ['A', 'B', 'C'] +PROJECT flip SETTING color => ['A', 'B', 'C'] -- Polar for pie chart (theta defaults to y) -COORD polar +PROJECT polar -- Polar for rose plot (x maps to radius) -COORD polar SETTING theta => y +PROJECT polar SETTING theta => y -- Combined with other clauses DRAW bar MAPPING category AS x, value AS y -COORD cartesian SETTING xlim => [0, 100], ylim => [0, 200] +PROJECT cartesian SETTING xlim => [0, 100], ylim => [0, 200] LABEL x => 'Category', y => 'Count' ``` diff --git a/EXAMPLES.md b/EXAMPLES.md index 2e07eed4..952dcffb 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -7,7 +7,7 @@ This document provides a collection of basic examples demonstrating how to use g - [Basic Visualizations](#basic-visualizations) - [Multiple Layers](#multiple-layers) - [Scales and Transformations](#scales-and-transformations) -- [Coordinate Systems](#coordinate-systems) +- [Projections](#projections) - [Labels and Themes](#labels-and-themes) - [Faceting](#faceting) - [Common Table Expressions (CTEs)](#common-table-expressions-ctes) @@ -125,7 +125,7 @@ SCALE DISCRETE fill FROM ['A', 'B', 'C', 'D'] --- -## Coordinate Systems +## Projections ### Cartesian with Limits @@ -133,27 +133,27 @@ SCALE DISCRETE fill FROM ['A', 'B', 'C', 'D'] SELECT x, y FROM data VISUALISE x, y DRAW point -COORD cartesian SETTING xlim => [0, 100], ylim => [0, 50] +PROJECT cartesian SETTING xlim => [0, 100], ylim => [0, 50] ``` -### Flipped Coordinates (Horizontal Bar Chart) +### Flipped Projection (Horizontal Bar Chart) ```sql SELECT category, value FROM data ORDER BY value DESC VISUALISE category AS x, value AS y DRAW bar -COORD flip +PROJECT flip ``` -### Polar Coordinates (Pie Chart) +### Polar Projection (Pie Chart) ```sql SELECT category, SUM(value) as total FROM data GROUP BY category VISUALISE category AS x, total AS y DRAW bar -COORD polar +PROJECT polar ``` ### Polar with Theta Specification @@ -162,7 +162,7 @@ COORD polar SELECT category, value FROM data VISUALISE category AS x, value AS y DRAW bar -COORD polar SETTING theta => y +PROJECT polar SETTING theta => y ``` --- @@ -307,7 +307,7 @@ regional_totals AS ( ) VISUALISE region AS x, total AS y, region AS fill FROM regional_totals DRAW bar -COORD flip +PROJECT flip LABEL title => 'Total Revenue by Region', x => 'Region', y => 'Total Revenue ($)' @@ -374,7 +374,7 @@ SELECT * FROM ranked_products WHERE rank <= 5 VISUALISE product_name AS x, revenue AS y, category AS color DRAW bar FACET WRAP category SETTING scales => 'free_x' -COORD flip +PROJECT flip LABEL title => 'Top 5 Products per Category', x => 'Product', y => 'Revenue ($)' @@ -476,7 +476,7 @@ LABEL title => 'Temperature Trends', y => 'Temperature (°C)' ``` -### Categorical Analysis with Flipped Coordinates +### Categorical Analysis with Flipped Projection ```sql SELECT @@ -488,7 +488,7 @@ ORDER BY total_revenue DESC LIMIT 10 VISUALISE product_name AS x, total_revenue AS y, product_name AS fill DRAW bar -COORD flip SETTING color => ['red', 'orange', 'yellow', 'green', 'blue', +PROJECT flip SETTING color => ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet', 'pink', 'brown', 'gray'] LABEL title => 'Top 10 Products by Revenue', x => 'Product', @@ -510,7 +510,7 @@ DRAW point SCALE x SETTING type => 'date' SCALE DISCRETE color FROM ['A', 'B', 'C'] SCALE size SETTING limits => [0, 100] -COORD cartesian SETTING ylim => [0, 150] +PROJECT cartesian SETTING ylim => [0, 150] LABEL title => 'Measurement Distribution', x => 'Date', y => 'Value' @@ -529,7 +529,7 @@ VISUALISE x, y, category AS color DRAW point SETTING size => 5 DRAW text MAPPING label AS label SCALE color TO viridis -COORD cartesian SETTING xlim => [0, 100], ylim => [0, 100] +PROJECT cartesian SETTING xlim => [0, 100], ylim => [0, 100] LABEL title => 'Annotated Scatter Plot', x => 'X Axis', y => 'Y Axis' @@ -630,7 +630,7 @@ Draw Line 3. **Color Mappings**: Use `color` for continuous data and `fill` for categorical data in bars/areas. -4. **Coordinate Limits**: Set explicit limits with `COORD cartesian SETTING xlim => [min, max]` to control axis ranges. +4. **Coordinate Limits**: Set explicit limits with `PROJECT cartesian SETTING xlim => [min, max]` to control axis ranges. 5. **Faceting**: Use faceting to create small multiples when comparing across categories. @@ -640,7 +640,7 @@ Draw Line 8. **Labels**: Always provide meaningful titles and axis labels for clarity. -9. **Range Specification**: Use either SCALE or COORD for range/limit specification, but not both for the same aesthetic. +9. **Range Specification**: Use either SCALE or PROJECT for range/limit specification, but not both for the same aesthetic. --- diff --git a/README.md b/README.md index f1849a0f..9fdde0d9 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ Key grammar elements: - `DRAW [MAPPING] [SETTING] [FILTER]` - Define geometric layers (point, line, bar, etc.) - `SCALE SETTING` - Configure data-to-visual mappings - `FACET` - Create small multiples (WRAP for flowing layout, BY for grid) -- `COORD` - Coordinate transformations (cartesian, flip, polar) +- `PROJECT` - Coordinate transformations (cartesian, flip, polar) - `LABEL`, `THEME` - Styling and annotation ## Jupyter Kernel @@ -288,7 +288,7 @@ cargo run --package ggsql-jupyter -- --install The extension uses a TextMate grammar that highlights: - SQL keywords (SELECT, FROM, WHERE, JOIN, etc.) -- ggsql clauses (VISUALISE, DRAW, SCALE, COORD, FACET, etc.) +- ggsql clauses (VISUALISE, DRAW, SCALE, PROJECT, FACET, etc.) - Geometric objects (point, line, bar, area, etc.) - Aesthetics (x, y, color, size, shape, etc.) - Scale types (linear, log10, date, viridis, etc.) diff --git a/doc/examples.qmd b/doc/examples.qmd index acc0510b..1db5f92e 100644 --- a/doc/examples.qmd +++ b/doc/examples.qmd @@ -434,7 +434,7 @@ LABEL y => 'Value' ``` -## Coordinate Transformations +## Projections ### Flipped Coordinates (Horizontal Bar Chart) @@ -445,7 +445,7 @@ GROUP BY region ORDER BY total DESC VISUALISE region AS x, total AS y, region AS fill DRAW bar -COORD flip +PROJECT flip LABEL title => 'Total Revenue by Region', x => 'Region', @@ -459,7 +459,7 @@ SELECT x, y FROM 'data.csv' VISUALISE x, y DRAW point SETTING size => 4, color => 'blue' -COORD cartesian +PROJECT cartesian SETTING xlim => [0, 60], ylim => [0, 70] LABEL title => 'Scatter Plot with Custom Axis Limits', @@ -475,7 +475,7 @@ FROM 'sales.csv' GROUP BY category VISUALISE total AS y, category AS fill DRAW bar -COORD polar +PROJECT polar LABEL title => 'Revenue Distribution by Category' ``` @@ -656,7 +656,7 @@ regional_totals AS ( ) VISUALISE region AS x, total AS y, region AS fill FROM regional_totals DRAW bar -COORD flip +PROJECT flip LABEL title => 'Total Revenue by Region', x => 'Region', diff --git a/doc/ggsql.xml b/doc/ggsql.xml index 3ee645b7..bf36a918 100644 --- a/doc/ggsql.xml +++ b/doc/ggsql.xml @@ -91,7 +91,7 @@ DRAW SCALE - COORD + PROJECT FACET LABEL THEME @@ -199,8 +199,8 @@ manual - - + + cartesian polar flip @@ -245,7 +245,7 @@ - + xlim ylim ratio @@ -413,7 +413,7 @@ - + @@ -460,7 +460,7 @@ - + @@ -500,7 +500,7 @@ - + @@ -535,8 +535,8 @@ - - + + @@ -546,7 +546,7 @@ - + @@ -558,10 +558,10 @@ - + - + @@ -589,7 +589,7 @@ - + @@ -632,7 +632,7 @@ - + @@ -669,7 +669,7 @@ - + @@ -709,7 +709,7 @@ - + diff --git a/ggsql-vscode/CHANGELOG.md b/ggsql-vscode/CHANGELOG.md index acc57d1b..abdb5bcb 100644 --- a/ggsql-vscode/CHANGELOG.md +++ b/ggsql-vscode/CHANGELOG.md @@ -13,7 +13,7 @@ - VISUALISE/VISUALIZE AS statements - WITH clause with geom types (point, line, bar, area, histogram, etc.) - SCALE clause with scale types (linear, log10, date, viridis, etc.) - - COORD clause with coordinate types (cartesian, polar, flip) + - PROJECT clause with projection types (cartesian, polar, flip) - FACET clause (WRAP, BY with scale options) - LABEL clause (title, subtitle, axis labels, caption) - THEME clause (minimal, classic, dark, etc.) diff --git a/ggsql-vscode/README.md b/ggsql-vscode/README.md index 058798bd..46c92825 100644 --- a/ggsql-vscode/README.md +++ b/ggsql-vscode/README.md @@ -6,7 +6,7 @@ Syntax highlighting for ggsql - SQL with declarative visualization based on Gram - **Complete syntax highlighting** for ggsql queries - **SQL keyword support** (SELECT, FROM, WHERE, JOIN, WITH, etc.) -- **ggsql clause highlighting**: (SCALE, COORD, FACET, LABEL, etc.) +- **ggsql clause highlighting**: (SCALE, PROJECT, FACET, LABEL, etc.) - **Aesthetic highlighting** (x, y, color, size, shape, etc.) - **String and number literals** - **Comment support** (`--` and `/* */`) diff --git a/ggsql-vscode/examples/sample.gsql b/ggsql-vscode/examples/sample.gsql index f9978c1d..8bfed947 100644 --- a/ggsql-vscode/examples/sample.gsql +++ b/ggsql-vscode/examples/sample.gsql @@ -37,7 +37,7 @@ LABEL title => 'Sales Trends by Region', THEME classic -- ============================================================================ --- Example 3: Bar Chart with Coordinate Transformation +-- Example 3: Bar Chart with Projection -- ============================================================================ SELECT category, SUM(value) as total FROM products @@ -47,21 +47,21 @@ ORDER BY total DESC LIMIT 10 VISUALISE category AS x, total AS y, category AS fill DRAW bar -COORD flip +PROJECT flip LABEL title => 'Top 10 Product Categories', x => 'Category', y => 'Total Sales' THEME dark -- ============================================================================ --- Example 4: Polar Coordinates (Pie Chart) +-- Example 4: Polar Projection (Pie Chart) -- ============================================================================ SELECT region, COUNT(*) as count FROM customers GROUP BY region VISUALISE region AS x, count AS y, region AS fill DRAW bar -COORD polar SETTING theta => y +PROJECT polar SETTING theta => y LABEL title => 'Customer Distribution by Region' -- ============================================================================ @@ -102,7 +102,7 @@ DRAW line SETTING size => 2, color => 'blue' SCALE x SETTING type => 'date' SCALE y SETTING type => 'linear', limits => [0, 100] -COORD cartesian SETTING xlim => ['2024-01-01', '2024-12-31'] +PROJECT cartesian SETTING xlim => ['2024-01-01', '2024-12-31'] LABEL title => 'Temperature Range (Last 30 Days)', x => 'Date', y => 'Temperature (°C)', @@ -166,7 +166,7 @@ WHERE rating IS NOT NULL VISUALISE category AS x, price AS y DRAW boxplot DRAW violin SETTING opacity => 0.3 -COORD cartesian SETTING ylim => [0, 500] +PROJECT cartesian SETTING ylim => [0, 500] LABEL title => 'Price Distribution by Category', x => 'Product Category', y => 'Price ($)' @@ -190,7 +190,7 @@ DRAW text MAPPING product_name AS label SCALE color TO viridis SCALE size SETTING limits => [0, 10000] -COORD cartesian SETTING xlim => [0, 1000], ylim => [0, 5] +PROJECT cartesian SETTING xlim => [0, 1000], ylim => [0, 5] LABEL title => 'Featured Products: Price vs Rating', x => 'Price ($)', y => 'Customer Rating', @@ -211,7 +211,7 @@ LABEL title => 'Sales by Region' -- Second visualization: by category VISUALISE category AS x, total AS y, category AS fill FROM sales_summary DRAW bar -COORD flip +PROJECT flip LABEL title => 'Sales by Category' -- ============================================================================ diff --git a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json index 54ddfe74..2ce30ad4 100644 --- a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json +++ b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json @@ -10,7 +10,7 @@ { "include": "#draw-clause" }, { "include": "#scale-clause" }, { "include": "#facet-clause" }, - { "include": "#coord-clause" }, + { "include": "#project-clause" }, { "include": "#label-clause" }, { "include": "#theme-clause" }, { "include": "#sql-keywords" }, @@ -217,7 +217,7 @@ "beginCaptures": { "1": { "name": "keyword.other.ggsql" } }, - "end": "(?i)(?=\\b(DRAW|SCALE|COORD|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", + "end": "(?i)(?=\\b(DRAW|SCALE|PROJECT|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", "patterns": [ { "include": "#comments" }, { "include": "#strings" }, @@ -286,7 +286,7 @@ "beginCaptures": { "1": { "name": "keyword.other.ggsql" } }, - "end": "(?i)(?=\\b(DRAW|SCALE|COORD|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", + "end": "(?i)(?=\\b(DRAW|SCALE|PROJECT|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", "patterns": [ { "name": "support.type.geom.ggsql", @@ -300,7 +300,7 @@ "beginCaptures": { "1": { "name": "keyword.other.ggsql" } }, - "end": "(?i)(?=\\b(DRAW|SCALE|COORD|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", + "end": "(?i)(?=\\b(DRAW|SCALE|PROJECT|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", "patterns": [ { "name": "keyword.control.scale-modifier.ggsql", @@ -326,7 +326,7 @@ "beginCaptures": { "1": { "name": "keyword.other.ggsql" } }, - "end": "(?i)(?=\\b(DRAW|SCALE|COORD|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", + "end": "(?i)(?=\\b(DRAW|SCALE|PROJECT|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", "patterns": [ { "name": "keyword.other.ggsql", @@ -343,15 +343,15 @@ { "include": "#common-clause-patterns" } ] }, - "coord-clause": { - "begin": "(?i)\\b(COORD)\\b", + "project-clause": { + "begin": "(?i)\\b(PROJECT)\\b", "beginCaptures": { "1": { "name": "keyword.other.ggsql" } }, - "end": "(?i)(?=\\b(DRAW|SCALE|COORD|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", + "end": "(?i)(?=\\b(DRAW|SCALE|PROJECT|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", "patterns": [ { - "name": "support.type.coord.ggsql", + "name": "support.type.project.ggsql", "match": "\\b(cartesian|polar|flip|fixed|trans|map|quickmap)\\b" }, { @@ -366,7 +366,7 @@ "beginCaptures": { "1": { "name": "keyword.other.ggsql" } }, - "end": "(?i)(?=\\b(DRAW|SCALE|COORD|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", + "end": "(?i)(?=\\b(DRAW|SCALE|PROJECT|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", "patterns": [ { "name": "support.type.property.ggsql", @@ -380,7 +380,7 @@ "beginCaptures": { "1": { "name": "keyword.other.ggsql" } }, - "end": "(?i)(?=\\b(DRAW|SCALE|COORD|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", + "end": "(?i)(?=\\b(DRAW|SCALE|PROJECT|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", "patterns": [ { "name": "support.type.theme.ggsql", diff --git a/src/doc/API.md b/src/doc/API.md index 8cc962e6..ca93254f 100644 --- a/src/doc/API.md +++ b/src/doc/API.md @@ -89,7 +89,7 @@ Validate query syntax and semantics without executing SQL. This function combine - Syntax (parsing) - Required aesthetics for each geom type - Valid scale types (linear, log10, date, etc.) -- Valid coord types and properties +- Valid project types and properties - Valid geom types - Valid aesthetic names - Valid SETTING parameters diff --git a/src/parser/builder.rs b/src/parser/builder.rs index e6ff74e9..e0bcf6f1 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -21,7 +21,7 @@ use super::SourceTree; /// /// Returns (name_node, value_node) without any interpretation. /// Works for both patterns: -/// - `name => value` (SETTING, COORD, THEME, LABEL, RENAMING) +/// - `name => value` (SETTING, PROJECT, THEME, LABEL, RENAMING) /// - `value AS name` (MAPPING explicit_mapping) /// /// Caller is responsible for interpreting the nodes based on their context. @@ -271,8 +271,8 @@ fn build_visualise_statement(node: &Node, source: &SourceTree) -> Result { } } - // Validate no conflicts between SCALE and COORD input range specifications - validate_scale_coord_conflicts(&spec)?; + // Validate no conflicts between SCALE and PROJECT input range specifications + validate_scale_project_conflicts(&spec)?; Ok(spec) } @@ -293,8 +293,8 @@ fn process_viz_clause(node: &Node, source: &SourceTree, spec: &mut Plot) -> Resu "facet_clause" => { spec.facet = Some(build_facet(&child, source)?); } - "coord_clause" => { - spec.coord = Some(build_coord(&child, source)?); + "project_clause" => { + spec.project = Some(build_project(&child, source)?); } "label_clause" => { let new_labels = build_labels(&child, source)?; @@ -889,28 +889,28 @@ fn parse_facet_scales(node: &Node, source: &SourceTree) -> Result { } // ============================================================================ -// Coord Building +// Project Building // ============================================================================ -/// Build a Coord from a coord_clause node -fn build_coord(node: &Node, source: &SourceTree) -> Result { - let mut coord_type = CoordType::Cartesian; +/// Build a Project from a project_clause node +fn build_project(node: &Node, source: &SourceTree) -> Result { + let mut project_type = ProjectType::Cartesian; let mut properties = HashMap::new(); let mut cursor = node.walk(); for child in node.children(&mut cursor) { match child.kind() { - "COORD" | "SETTING" | "=>" | "," => continue, - "coord_type" => { - coord_type = parse_coord_type(&child, source)?; + "PROJECT" | "SETTING" | "=>" | "," => continue, + "project_type" => { + project_type = parse_project_type(&child, source)?; } - "coord_properties" => { - // Find all coord_property nodes - let query = "(coord_property) @prop"; + "project_properties" => { + // Find all project_property nodes + let query = "(project_property) @prop"; let prop_nodes = source.find_nodes(&child, query); for prop_node in prop_nodes { - let (prop_name, prop_value) = parse_single_coord_property(&prop_node, source)?; + let (prop_name, prop_value) = parse_single_project_property(&prop_node, source)?; properties.insert(prop_name, prop_value); } } @@ -918,22 +918,22 @@ fn build_coord(node: &Node, source: &SourceTree) -> Result { } } - // Validate properties for this coord type - validate_coord_properties(&coord_type, &properties)?; + // Validate properties for this project type + validate_project_properties(&project_type, &properties)?; - Ok(Coord { - coord_type, + Ok(Project { + project_type, properties, }) } -/// Parse a single coord_property node into (name, value) -fn parse_single_coord_property( +/// Parse a single project_property node into (name, value) +fn parse_single_project_property( node: &Node, source: &SourceTree, ) -> Result<(String, ParameterValue)> { // Extract name and value nodes using field-based queries - let (name_node, value_node) = extract_name_value_nodes(node, "coord property")?; + let (name_node, value_node) = extract_name_value_nodes(node, "project property")?; // Parse property name (can be a literal like 'xlim' or an aesthetic_name) let prop_name = source.get_text(&name_node); @@ -941,7 +941,7 @@ fn parse_single_coord_property( // Parse property value based on its type let prop_value = match value_node.kind() { "string" | "number" | "boolean" | "array" => { - parse_value_node(&value_node, source, "coord property")? + parse_value_node(&value_node, source, "project property")? } "identifier" => { // identifiers can be property values (e.g., theta => y) @@ -949,7 +949,7 @@ fn parse_single_coord_property( } _ => { return Err(GgsqlError::ParseError(format!( - "Invalid coord property value type: {}", + "Invalid project property value type: {}", value_node.kind() ))); } @@ -958,44 +958,44 @@ fn parse_single_coord_property( Ok((prop_name, prop_value)) } -/// Validate that properties are valid for the given coord type -fn validate_coord_properties( - coord_type: &CoordType, +/// Validate that properties are valid for the given project type +fn validate_project_properties( + project_type: &ProjectType, properties: &HashMap, ) -> Result<()> { for prop_name in properties.keys() { - let valid = match coord_type { - CoordType::Cartesian => { + let valid = match project_type { + ProjectType::Cartesian => { // Cartesian allows: xlim, ylim, aesthetic names // Not allowed: theta prop_name == "xlim" || prop_name == "ylim" || is_aesthetic_name(prop_name) } - CoordType::Flip => { + ProjectType::Flip => { // Flip allows: aesthetic names only // Not allowed: xlim, ylim, theta is_aesthetic_name(prop_name) } - CoordType::Polar => { + ProjectType::Polar => { // Polar allows: theta, aesthetic names // Not allowed: xlim, ylim prop_name == "theta" || is_aesthetic_name(prop_name) } _ => { - // Other coord types: allow all for now (future implementation) + // Other project types: allow all for now (future implementation) true } }; if !valid { - let valid_props = match coord_type { - CoordType::Cartesian => "xlim, ylim, ", - CoordType::Flip => "", - CoordType::Polar => "theta, ", + let valid_props = match project_type { + ProjectType::Cartesian => "xlim, ylim, ", + ProjectType::Flip => "", + ProjectType::Polar => "theta, ", _ => "", }; return Err(GgsqlError::ParseError(format!( - "Property '{}' not valid for {:?} coordinates. Valid properties: {}", - prop_name, coord_type, valid_props + "Property '{}' not valid for {:?} projection. Valid properties: {}", + prop_name, project_type, valid_props ))); } } @@ -1003,19 +1003,19 @@ fn validate_coord_properties( Ok(()) } -/// Parse coord type from a coord_type node -fn parse_coord_type(node: &Node, source: &SourceTree) -> Result { +/// Parse project type from a project_type node +fn parse_project_type(node: &Node, source: &SourceTree) -> Result { let text = source.get_text(node); match text.to_lowercase().as_str() { - "cartesian" => Ok(CoordType::Cartesian), - "polar" => Ok(CoordType::Polar), - "flip" => Ok(CoordType::Flip), - "fixed" => Ok(CoordType::Fixed), - "trans" => Ok(CoordType::Trans), - "map" => Ok(CoordType::Map), - "quickmap" => Ok(CoordType::QuickMap), + "cartesian" => Ok(ProjectType::Cartesian), + "polar" => Ok(ProjectType::Polar), + "flip" => Ok(ProjectType::Flip), + "fixed" => Ok(ProjectType::Fixed), + "trans" => Ok(ProjectType::Trans), + "map" => Ok(ProjectType::Map), + "quickmap" => Ok(ProjectType::QuickMap), _ => Err(GgsqlError::ParseError(format!( - "Unknown coord type: {}", + "Unknown project type: {}", text ))), } @@ -1107,11 +1107,11 @@ fn build_theme(node: &Node, source: &SourceTree) -> Result { // Validation & Utilities // ============================================================================ -/// Check for conflicts between SCALE input range and COORD aesthetic input range specifications -fn validate_scale_coord_conflicts(spec: &Plot) -> Result<()> { - if let Some(ref coord) = spec.coord { - // Get all aesthetic names that have input ranges in COORD - let coord_aesthetics: Vec = coord +/// Check for conflicts between SCALE input range and PROJECT aesthetic input range specifications +fn validate_scale_project_conflicts(spec: &Plot) -> Result<()> { + if let Some(ref project) = spec.project { + // Get all aesthetic names that have input ranges in PROJECT + let project_aesthetics: Vec = project .properties .keys() .filter(|k| is_aesthetic_name(k)) @@ -1119,11 +1119,11 @@ fn validate_scale_coord_conflicts(spec: &Plot) -> Result<()> { .collect(); // Check if any of these also have input range in SCALE - for aesthetic in coord_aesthetics { + for aesthetic in project_aesthetics { for scale in &spec.scales { if scale.aesthetic == aesthetic && scale.input_range.is_some() { return Err(GgsqlError::ParseError(format!( - "Input range for '{}' specified in both SCALE and COORD clauses. \ + "Input range for '{}' specified in both SCALE and PROJECT clauses. \ Please specify input range in only one location.", aesthetic ))); @@ -1203,15 +1203,15 @@ mod tests { } // ======================================== - // COORD Property Validation Tests + // PROJECT Property Validation Tests // ======================================== #[test] - fn test_coord_cartesian_valid_xlim() { + fn test_project_cartesian_valid_xlim() { let query = r#" VISUALISE DRAW point MAPPING x AS x, y AS y - COORD cartesian SETTING xlim => [0, 100] + PROJECT cartesian SETTING xlim => [0, 100] "#; let result = parse_test_query(query); @@ -1219,49 +1219,49 @@ mod tests { let specs = result.unwrap(); assert_eq!(specs.len(), 1); - let coord = specs[0].coord.as_ref().unwrap(); - assert_eq!(coord.coord_type, CoordType::Cartesian); - assert!(coord.properties.contains_key("xlim")); + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.project_type, ProjectType::Cartesian); + assert!(project.properties.contains_key("xlim")); } #[test] - fn test_coord_cartesian_valid_ylim() { + fn test_project_cartesian_valid_ylim() { let query = r#" VISUALISE DRAW point MAPPING x AS x, y AS y - COORD cartesian SETTING ylim => [-10, 50] + PROJECT cartesian SETTING ylim => [-10, 50] "#; let result = parse_test_query(query); assert!(result.is_ok()); let specs = result.unwrap(); - let coord = specs[0].coord.as_ref().unwrap(); - assert!(coord.properties.contains_key("ylim")); + let project = specs[0].project.as_ref().unwrap(); + assert!(project.properties.contains_key("ylim")); } #[test] - fn test_coord_cartesian_valid_aesthetic_input_range() { + fn test_project_cartesian_valid_aesthetic_input_range() { let query = r#" VISUALISE DRAW point MAPPING x AS x, y AS y, category AS color - COORD cartesian SETTING color => ['red', 'green', 'blue'] + PROJECT cartesian SETTING color => ['red', 'green', 'blue'] "#; let result = parse_test_query(query); assert!(result.is_ok()); let specs = result.unwrap(); - let coord = specs[0].coord.as_ref().unwrap(); - assert!(coord.properties.contains_key("color")); + let project = specs[0].project.as_ref().unwrap(); + assert!(project.properties.contains_key("color")); } #[test] - fn test_coord_cartesian_invalid_property_theta() { + fn test_project_cartesian_invalid_property_theta() { let query = r#" VISUALISE DRAW point MAPPING x AS x, y AS y - COORD cartesian SETTING theta => y + PROJECT cartesian SETTING theta => y "#; let result = parse_test_query(query); @@ -1273,28 +1273,28 @@ mod tests { } #[test] - fn test_coord_flip_valid_aesthetic_input_range() { + fn test_project_flip_valid_aesthetic_input_range() { let query = r#" VISUALISE DRAW bar MAPPING category AS x, value AS y, region AS color - COORD flip SETTING color => ['A', 'B', 'C'] + PROJECT flip SETTING color => ['A', 'B', 'C'] "#; let result = parse_test_query(query); assert!(result.is_ok()); let specs = result.unwrap(); - let coord = specs[0].coord.as_ref().unwrap(); - assert_eq!(coord.coord_type, CoordType::Flip); - assert!(coord.properties.contains_key("color")); + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.project_type, ProjectType::Flip); + assert!(project.properties.contains_key("color")); } #[test] - fn test_coord_flip_invalid_property_xlim() { + fn test_project_flip_invalid_property_xlim() { let query = r#" VISUALISE DRAW bar MAPPING category AS x, value AS y - COORD flip SETTING xlim => [0, 100] + PROJECT flip SETTING xlim => [0, 100] "#; let result = parse_test_query(query); @@ -1306,11 +1306,11 @@ mod tests { } #[test] - fn test_coord_flip_invalid_property_ylim() { + fn test_project_flip_invalid_property_ylim() { let query = r#" VISUALISE DRAW bar MAPPING category AS x, value AS y - COORD flip SETTING ylim => [0, 100] + PROJECT flip SETTING ylim => [0, 100] "#; let result = parse_test_query(query); @@ -1322,11 +1322,11 @@ mod tests { } #[test] - fn test_coord_flip_invalid_property_theta() { + fn test_project_flip_invalid_property_theta() { let query = r#" VISUALISE DRAW bar MAPPING category AS x, value AS y - COORD flip SETTING theta => y + PROJECT flip SETTING theta => y "#; let result = parse_test_query(query); @@ -1338,44 +1338,44 @@ mod tests { } #[test] - fn test_coord_polar_valid_theta() { + fn test_project_polar_valid_theta() { let query = r#" VISUALISE DRAW bar MAPPING category AS x, value AS y - COORD polar SETTING theta => y + PROJECT polar SETTING theta => y "#; let result = parse_test_query(query); assert!(result.is_ok()); let specs = result.unwrap(); - let coord = specs[0].coord.as_ref().unwrap(); - assert_eq!(coord.coord_type, CoordType::Polar); - assert!(coord.properties.contains_key("theta")); + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.project_type, ProjectType::Polar); + assert!(project.properties.contains_key("theta")); } #[test] - fn test_coord_polar_valid_aesthetic_input_range() { + fn test_project_polar_valid_aesthetic_input_range() { let query = r#" VISUALISE DRAW bar MAPPING category AS x, value AS y, region AS color - COORD polar SETTING color => ['North', 'South', 'East', 'West'] + PROJECT polar SETTING color => ['North', 'South', 'East', 'West'] "#; let result = parse_test_query(query); assert!(result.is_ok()); let specs = result.unwrap(); - let coord = specs[0].coord.as_ref().unwrap(); - assert!(coord.properties.contains_key("color")); + let project = specs[0].project.as_ref().unwrap(); + assert!(project.properties.contains_key("color")); } #[test] - fn test_coord_polar_invalid_property_xlim() { + fn test_project_polar_invalid_property_xlim() { let query = r#" VISUALISE DRAW bar MAPPING category AS x, value AS y - COORD polar SETTING xlim => [0, 100] + PROJECT polar SETTING xlim => [0, 100] "#; let result = parse_test_query(query); @@ -1387,11 +1387,11 @@ mod tests { } #[test] - fn test_coord_polar_invalid_property_ylim() { + fn test_project_polar_invalid_property_ylim() { let query = r#" VISUALISE DRAW bar MAPPING category AS x, value AS y - COORD polar SETTING ylim => [0, 100] + PROJECT polar SETTING ylim => [0, 100] "#; let result = parse_test_query(query); @@ -1403,16 +1403,16 @@ mod tests { } // ======================================== - // SCALE/COORD Input Range Conflict Tests + // SCALE/PROJECT Input Range Conflict Tests // ======================================== #[test] - fn test_scale_coord_conflict_x_input_range() { + fn test_scale_project_conflict_x_input_range() { let query = r#" VISUALISE DRAW point MAPPING x AS x, y AS y SCALE x FROM [0, 100] - COORD cartesian SETTING x => [0, 50] + PROJECT cartesian SETTING x => [0, 50] "#; let result = parse_test_query(query); @@ -1420,16 +1420,16 @@ mod tests { let err = result.unwrap_err(); assert!(err .to_string() - .contains("Input range for 'x' specified in both SCALE and COORD")); + .contains("Input range for 'x' specified in both SCALE and PROJECT")); } #[test] - fn test_scale_coord_conflict_color_input_range() { + fn test_scale_project_conflict_color_input_range() { let query = r#" VISUALISE DRAW point MAPPING x AS x, y AS y, category AS color SCALE color FROM ['A', 'B'] - COORD cartesian SETTING color => ['A', 'B', 'C'] + PROJECT cartesian SETTING color => ['A', 'B', 'C'] "#; let result = parse_test_query(query); @@ -1437,16 +1437,16 @@ mod tests { let err = result.unwrap_err(); assert!(err .to_string() - .contains("Input range for 'color' specified in both SCALE and COORD")); + .contains("Input range for 'color' specified in both SCALE and PROJECT")); } #[test] - fn test_scale_coord_no_conflict_different_aesthetics() { + fn test_scale_project_no_conflict_different_aesthetics() { let query = r#" VISUALISE DRAW point MAPPING x AS x, y AS y, category AS color SCALE color FROM ['A', 'B'] - COORD cartesian SETTING xlim => [0, 100] + PROJECT cartesian SETTING xlim => [0, 100] "#; let result = parse_test_query(query); @@ -1454,12 +1454,12 @@ mod tests { } #[test] - fn test_scale_coord_no_conflict_scale_without_input_range() { + fn test_scale_project_no_conflict_scale_without_input_range() { let query = r#" VISUALISE DRAW point MAPPING x AS x, y AS y SCALE CONTINUOUS x - COORD cartesian SETTING x => [0, 100] + PROJECT cartesian SETTING x => [0, 100] "#; let result = parse_test_query(query); @@ -1471,38 +1471,38 @@ mod tests { // ======================================== #[test] - fn test_coord_cartesian_multiple_properties() { + fn test_project_cartesian_multiple_properties() { let query = r#" VISUALISE DRAW point MAPPING x AS x, y AS y, category AS color - COORD cartesian SETTING xlim => [0, 100], ylim => [-10, 50], color => ['A', 'B'] + PROJECT cartesian SETTING xlim => [0, 100], ylim => [-10, 50], color => ['A', 'B'] "#; let result = parse_test_query(query); assert!(result.is_ok()); let specs = result.unwrap(); - let coord = specs[0].coord.as_ref().unwrap(); - assert!(coord.properties.contains_key("xlim")); - assert!(coord.properties.contains_key("ylim")); - assert!(coord.properties.contains_key("color")); + let project = specs[0].project.as_ref().unwrap(); + assert!(project.properties.contains_key("xlim")); + assert!(project.properties.contains_key("ylim")); + assert!(project.properties.contains_key("color")); } #[test] - fn test_coord_polar_theta_with_aesthetic() { + fn test_project_polar_theta_with_aesthetic() { let query = r#" VISUALISE DRAW bar MAPPING category AS x, value AS y, region AS color - COORD polar SETTING theta => y, color => ['North', 'South'] + PROJECT polar SETTING theta => y, color => ['North', 'South'] "#; let result = parse_test_query(query); assert!(result.is_ok()); let specs = result.unwrap(); - let coord = specs[0].coord.as_ref().unwrap(); - assert!(coord.properties.contains_key("theta")); - assert!(coord.properties.contains_key("color")); + let project = specs[0].project.as_ref().unwrap(); + assert!(project.properties.contains_key("theta")); + assert!(project.properties.contains_key("color")); } // ======================================== @@ -1514,7 +1514,7 @@ mod tests { let query = r#" visualise draw point MAPPING x AS x, y AS y - coord cartesian setting xlim => [0, 100] + project cartesian setting xlim => [0, 100] label title => 'Test Chart' "#; @@ -1527,7 +1527,7 @@ mod tests { assert_eq!(specs.len(), 1); assert!(specs[0].global_mappings.is_empty()); assert_eq!(specs[0].layers.len(), 1); - assert!(specs[0].coord.is_some()); + assert!(specs[0].project.is_some()); assert!(specs[0].labels.is_some()); } @@ -3393,7 +3393,7 @@ mod tests { #[test] fn test_parse_number_node() { // Test integers - let source = make_source("VISUALISE DRAW point COORD SETTING xlim => [0, 100]"); + let source = make_source("VISUALISE DRAW point PROJECT SETTING xlim => [0, 100]"); let root = source.root(); let numbers = source.find_nodes(&root, "(number) @n"); @@ -3402,7 +3402,7 @@ mod tests { assert_eq!(parse_number_node(&numbers[1], &source).unwrap(), 100.0); // Test floats - let source2 = make_source("VISUALISE DRAW point COORD SETTING ylim => [-10.5, 20.75]"); + let source2 = make_source("VISUALISE DRAW point PROJECT SETTING ylim => [-10.5, 20.75]"); let root2 = source2.root(); let numbers2 = source2.find_nodes(&root2, "(number) @n"); @@ -3425,7 +3425,7 @@ mod tests { assert!(matches!(parsed[2], ArrayElement::String(ref s) if s == "c")); // Test array of numbers - let source2 = make_source("VISUALISE DRAW point COORD SETTING xlim => [0, 50, 100]"); + let source2 = make_source("VISUALISE DRAW point PROJECT SETTING xlim => [0, 50, 100]"); let root2 = source2.root(); let array_node2 = source2.find_node(&root2, "(array) @arr").unwrap(); diff --git a/src/plot/coord/mod.rs b/src/plot/coord/mod.rs deleted file mode 100644 index b3b5a3ed..00000000 --- a/src/plot/coord/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Coordinate system types for ggsql visualization specifications -//! -//! This module defines coordinate system configuration and types. - -mod types; - -pub use types::{Coord, CoordType}; diff --git a/src/plot/main.rs b/src/plot/main.rs index c51ff708..c00a0fac 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -13,7 +13,7 @@ //! ├─ layers: Vec (1+ LayerNode, one per DRAW clause) //! ├─ scales: Vec (0+ ScaleNode, one per SCALE clause) //! ├─ facet: Option (optional, from FACET clause) -//! ├─ coord: Option (optional, from COORD clause) +//! ├─ project: Option (optional, from PROJECT clause) //! ├─ labels: Option (optional, merged from LABEL clauses) //! └─ theme: Option (optional, from THEME clause) //! ``` @@ -41,8 +41,8 @@ pub use super::layer::Layer; // Re-export Scale types from the scale module pub use super::scale::{Scale, ScaleType}; -// Re-export Coord types from the coord module -pub use super::coord::{Coord, CoordType}; +// Re-export Project types from the project module +pub use super::project::{Project, ProjectType}; // Re-export Facet types from the facet module pub use super::facet::{Facet, FacetScales}; @@ -60,8 +60,8 @@ pub struct Plot { pub scales: Vec, /// Faceting specification (from FACET clause) pub facet: Option, - /// Coordinate system (from COORD clause) - pub coord: Option, + /// Projection (from PROJECT clause) + pub project: Option, /// Text labels (merged from all LABEL clauses) pub labels: Option, /// Theme styling (from THEME clause) @@ -93,7 +93,7 @@ impl Plot { layers: Vec::new(), scales: Vec::new(), facet: None, - coord: None, + project: None, labels: None, theme: None, } @@ -107,7 +107,7 @@ impl Plot { layers: Vec::new(), scales: Vec::new(), facet: None, - coord: None, + project: None, labels: None, theme: None, } diff --git a/src/plot/mod.rs b/src/plot/mod.rs index 85227baa..069eee0b 100644 --- a/src/plot/mod.rs +++ b/src/plot/mod.rs @@ -2,7 +2,7 @@ //! //! This module contains all the types that represent a parsed ggsql visualization //! specification, including the main Plot struct, layers, geoms, scales, facets, -//! coordinates, and input types. +//! projections, and input types. //! //! # Architecture //! @@ -13,21 +13,21 @@ //! - `layer` - Layer struct and Geom subsystem //! - `scale` - Scale and Guide types //! - `facet` - Facet types for small multiples -//! - `coord` - Coordinate system types +//! - `project` - Projection types pub mod aesthetic; -pub mod coord; pub mod facet; pub mod layer; pub mod main; +pub mod project; pub mod scale; pub mod types; // Re-export all types for convenience pub use aesthetic::*; -pub use coord::*; pub use facet::*; pub use layer::*; pub use main::*; +pub use project::*; pub use scale::*; pub use types::*; diff --git a/src/plot/project/mod.rs b/src/plot/project/mod.rs new file mode 100644 index 00000000..5985226d --- /dev/null +++ b/src/plot/project/mod.rs @@ -0,0 +1,7 @@ +//! Projection types for ggsql visualization specifications +//! +//! This module defines projection configuration and types. + +mod types; + +pub use types::{Project, ProjectType}; diff --git a/src/plot/coord/types.rs b/src/plot/project/types.rs similarity index 52% rename from src/plot/coord/types.rs rename to src/plot/project/types.rs index 69ea572a..1720e089 100644 --- a/src/plot/coord/types.rs +++ b/src/plot/project/types.rs @@ -1,24 +1,24 @@ -//! Coordinate system types for ggsql visualization specifications +//! Projection types for ggsql visualization specifications //! -//! This module defines coordinate system configuration and types. +//! This module defines projection configuration and types. use serde::{Deserialize, Serialize}; use std::collections::HashMap; use super::super::types::ParameterValue; -/// Coordinate system (from COORD clause) +/// Projection (from PROJECT clause) #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Coord { - /// Coordinate system type - pub coord_type: CoordType, - /// Coordinate-specific options +pub struct Project { + /// Projection type + pub project_type: ProjectType, + /// Projection-specific options pub properties: HashMap, } -/// Coordinate system types +/// Projection types #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum CoordType { +pub enum ProjectType { Cartesian, Polar, Flip, diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index e7d72da3..b7abd3d4 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -20,10 +20,10 @@ //! // Can be rendered in browser with vega-embed //! ``` -mod coord; mod data; mod encoding; mod layer; +mod project; // ArrayElement is used in tests and for pattern matching; suppress unused import warning #[allow(unused_imports)] @@ -38,7 +38,7 @@ use serde_json::{json, Value}; use std::collections::HashMap; // Re-export submodule functions for use in write() -use coord::apply_coord_transforms; +use project::apply_project_transforms; use data::{collect_binned_columns, is_binned_aesthetic, unify_datasets}; use encoding::{ build_detail_encoding, build_encoding_channel, infer_field_type, map_aesthetic_name, @@ -491,9 +491,9 @@ impl Writer for VegaLiteWriter { )?; vl_spec["layer"] = json!(layers); - // 9. Apply coordinate transforms + // 9. Apply projection transforms let first_df = data.get(&layer_data_keys[0]).unwrap(); - apply_coord_transforms(spec, first_df, &mut vl_spec)?; + apply_project_transforms(spec, first_df, &mut vl_spec)?; // 10. Apply faceting if let Some(facet) = &spec.facet { diff --git a/src/writer/vegalite/coord.rs b/src/writer/vegalite/project.rs similarity index 86% rename from src/writer/vegalite/coord.rs rename to src/writer/vegalite/project.rs index 30b5a374..34259f0c 100644 --- a/src/writer/vegalite/coord.rs +++ b/src/writer/vegalite/project.rs @@ -1,37 +1,37 @@ -//! Coordinate system transformations for Vega-Lite writer +//! Projection transformations for Vega-Lite writer //! -//! This module handles coordinate system transformations (cartesian, flip, polar) -//! that modify the Vega-Lite spec structure based on the COORD clause. +//! This module handles projection transformations (cartesian, flip, polar) +//! that modify the Vega-Lite spec structure based on the PROJECT clause. use crate::plot::aesthetic::is_aesthetic_name; -use crate::plot::{Coord, CoordType, ParameterValue}; +use crate::plot::{ParameterValue, Project, ProjectType}; use crate::{DataFrame, GgsqlError, Plot, Result}; use serde_json::{json, Value}; -/// Apply coordinate transformations to the spec and data +/// Apply projection transformations to the spec and data /// Returns (possibly transformed DataFrame, possibly modified spec) -pub(super) fn apply_coord_transforms( +pub(super) fn apply_project_transforms( spec: &Plot, data: &DataFrame, vl_spec: &mut Value, ) -> Result> { - if let Some(ref coord) = spec.coord { - match coord.coord_type { - CoordType::Cartesian => { - apply_cartesian_coord(coord, vl_spec)?; + if let Some(ref project) = spec.project { + match project.project_type { + ProjectType::Cartesian => { + apply_cartesian_project(project, vl_spec)?; Ok(None) // No DataFrame transformation needed } - CoordType::Flip => { - apply_flip_coord(vl_spec)?; + ProjectType::Flip => { + apply_flip_project(vl_spec)?; Ok(None) // No DataFrame transformation needed } - CoordType::Polar => { + ProjectType::Polar => { // Polar requires DataFrame transformation for percentages - let transformed_df = apply_polar_coord(coord, spec, data, vl_spec)?; + let transformed_df = apply_polar_project(project, spec, data, vl_spec)?; Ok(Some(transformed_df)) } _ => { - // Other coord types not yet implemented + // Other project types not yet implemented Ok(None) } } @@ -40,10 +40,10 @@ pub(super) fn apply_coord_transforms( } } -/// Apply Cartesian coordinate properties (xlim, ylim, aesthetic domains) -fn apply_cartesian_coord(coord: &Coord, vl_spec: &mut Value) -> Result<()> { +/// Apply Cartesian projection properties (xlim, ylim, aesthetic domains) +fn apply_cartesian_project(project: &Project, vl_spec: &mut Value) -> Result<()> { // Apply xlim/ylim to scale domains - for (prop_name, prop_value) in &coord.properties { + for (prop_name, prop_value) in &project.properties { match prop_name.as_str() { "xlim" => { if let Some(limits) = extract_limits(prop_value)? { @@ -70,8 +70,8 @@ fn apply_cartesian_coord(coord: &Coord, vl_spec: &mut Value) -> Result<()> { Ok(()) } -/// Apply Flip coordinate transformation (swap x and y) -fn apply_flip_coord(vl_spec: &mut Value) -> Result<()> { +/// Apply Flip projection transformation (swap x and y) +fn apply_flip_project(vl_spec: &mut Value) -> Result<()> { if let Some(layers) = vl_spec.get_mut("layer") { if let Some(layers_arr) = layers.as_array_mut() { for layer in layers_arr { @@ -90,15 +90,15 @@ fn apply_flip_coord(vl_spec: &mut Value) -> Result<()> { Ok(()) } -/// Apply Polar coordinate transformation (bar->arc, point->arc with radius) -fn apply_polar_coord( - coord: &Coord, +/// Apply Polar projection transformation (bar->arc, point->arc with radius) +fn apply_polar_project( + project: &Project, spec: &Plot, data: &DataFrame, vl_spec: &mut Value, ) -> Result { // Get theta field (defaults to 'y') - let theta_field = coord + let theta_field = project .properties .get("theta") .and_then(|v| match v { @@ -179,7 +179,7 @@ fn convert_mark_to_polar(mark: &Value, _spec: &Plot) -> Result { })) } -/// Update encoding channels for polar coordinates +/// Update encoding channels for polar projection fn update_encoding_for_polar(encoding: &mut Value, theta_aesthetic: &str) -> Result<()> { let enc_obj = encoding .as_object_mut() diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index dfb18c53..ff576923 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -444,7 +444,7 @@ module.exports = grammar({ $.draw_clause, $.scale_clause, $.facet_clause, - $.coord_clause, + $.project_clause, $.label_clause, $.theme_clause, ), @@ -758,33 +758,33 @@ module.exports = grammar({ 'fixed', 'free', 'free_x', 'free_y' ), - // COORD clause - COORD [type] [SETTING prop => value, ...] - coord_clause: $ => seq( - caseInsensitive('COORD'), + // PROJECT clause - PROJECT [type] [SETTING prop => value, ...] + project_clause: $ => seq( + caseInsensitive('PROJECT'), choice( - // Type with optional SETTING: COORD polar SETTING theta => y - seq($.coord_type, optional(seq(caseInsensitive('SETTING'), $.coord_properties))), - // Just SETTING: COORD SETTING xlim => [0, 100] (defaults to cartesian) - seq(caseInsensitive('SETTING'), $.coord_properties) + // Type with optional SETTING: PROJECT polar SETTING theta => y + seq($.project_type, optional(seq(caseInsensitive('SETTING'), $.project_properties))), + // Just SETTING: PROJECT SETTING xlim => [0, 100] (defaults to cartesian) + seq(caseInsensitive('SETTING'), $.project_properties) ) ), - coord_type: $ => choice( + project_type: $ => choice( 'cartesian', 'polar', 'flip', 'fixed', 'trans', 'map', 'quickmap' ), - coord_properties: $ => seq( - $.coord_property, - repeat(seq(',', $.coord_property)) + project_properties: $ => seq( + $.project_property, + repeat(seq(',', $.project_property)) ), - coord_property: $ => seq( - field('name', $.coord_property_name), + project_property: $ => seq( + field('name', $.project_property_name), '=>', field('value', choice($.string, $.number, $.boolean, $.array, $.identifier)) ), - coord_property_name: $ => choice( + project_property_name: $ => choice( 'xlim', 'ylim', 'ratio', 'theta', 'clip', // Also allow aesthetic names as properties (for range specification) $.aesthetic_name diff --git a/tree-sitter-ggsql/queries/highlights.scm b/tree-sitter-ggsql/queries/highlights.scm index 168ad6b5..91f88f59 100644 --- a/tree-sitter-ggsql/queries/highlights.scm +++ b/tree-sitter-ggsql/queries/highlights.scm @@ -78,7 +78,7 @@ (scale_type_identifier) @type.builtin ; Property names -(coord_property_name) @property +(project_property_name) @property (theme_property_name) @property (label_type) @property diff --git a/tree-sitter-ggsql/test/corpus/basic.txt b/tree-sitter-ggsql/test/corpus/basic.txt index a2d14b9c..dee3603a 100644 --- a/tree-sitter-ggsql/test/corpus/basic.txt +++ b/tree-sitter-ggsql/test/corpus/basic.txt @@ -531,12 +531,12 @@ LABEL title => 'My Plot', x => 'X Axis', y => 'Y Axis' (string)))))) ================================================================================ -COORD cartesian with xlim +PROJECT cartesian with xlim ================================================================================ VISUALISE x, y DRAW point -COORD cartesian SETTING xlim => [0, 100] +PROJECT cartesian SETTING xlim => [0, 100] -------------------------------------------------------------------------------- @@ -557,11 +557,11 @@ COORD cartesian SETTING xlim => [0, 100] (draw_clause (geom_type))) (viz_clause - (coord_clause - (coord_type) - (coord_properties - (coord_property - (coord_property_name) + (project_clause + (project_type) + (project_properties + (project_property + (project_property_name) (array (array_element (number)) @@ -569,12 +569,12 @@ COORD cartesian SETTING xlim => [0, 100] (number))))))))) ================================================================================ -COORD flip +PROJECT flip ================================================================================ VISUALISE category AS x, value AS y DRAW bar -COORD flip +PROJECT flip -------------------------------------------------------------------------------- @@ -601,8 +601,8 @@ COORD flip (draw_clause (geom_type))) (viz_clause - (coord_clause - (coord_type))))) + (project_clause + (project_type))))) ================================================================================ VISUALISE FROM with CTE From 9d74e6881fb0c59242e2bd270eb8603617747ce2 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 23 Feb 2026 14:47:27 +0100 Subject: [PATCH 02/28] better internal naming --- src/parser/builder.rs | 67 +++++++++++++++++----------------- src/plot/main.rs | 8 ++-- src/plot/project/mod.rs | 2 +- src/plot/project/types.rs | 10 ++--- src/writer/vegalite/project.rs | 16 ++++---- tree-sitter-ggsql/grammar.js | 13 ++++++- 6 files changed, 63 insertions(+), 53 deletions(-) diff --git a/src/parser/builder.rs b/src/parser/builder.rs index e0bcf6f1..003e5b15 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -778,6 +778,7 @@ fn parse_scale_renaming_clause( "*" => "*".to_string(), "string" => parse_string_node(&name_node, source), "number" => source.get_text(&name_node), + "null_literal" => "null".to_string(), // null key for renaming null values _ => { return Err(GgsqlError::ParseError(format!( "Invalid 'from' type in scale renaming: {}", @@ -892,9 +893,9 @@ fn parse_facet_scales(node: &Node, source: &SourceTree) -> Result { // Project Building // ============================================================================ -/// Build a Project from a project_clause node -fn build_project(node: &Node, source: &SourceTree) -> Result { - let mut project_type = ProjectType::Cartesian; +/// Build a Projection from a project_clause node +fn build_project(node: &Node, source: &SourceTree) -> Result { + let mut coord = Coord::Cartesian; let mut properties = HashMap::new(); let mut cursor = node.walk(); @@ -902,7 +903,7 @@ fn build_project(node: &Node, source: &SourceTree) -> Result { match child.kind() { "PROJECT" | "SETTING" | "=>" | "," => continue, "project_type" => { - project_type = parse_project_type(&child, source)?; + coord = parse_coord(&child, source)?; } "project_properties" => { // Find all project_property nodes @@ -918,11 +919,11 @@ fn build_project(node: &Node, source: &SourceTree) -> Result { } } - // Validate properties for this project type - validate_project_properties(&project_type, &properties)?; + // Validate properties for this coord type + validate_project_properties(&coord, &properties)?; - Ok(Project { - project_type, + Ok(Projection { + coord, properties, }) } @@ -958,44 +959,44 @@ fn parse_single_project_property( Ok((prop_name, prop_value)) } -/// Validate that properties are valid for the given project type +/// Validate that properties are valid for the given coord type fn validate_project_properties( - project_type: &ProjectType, + coord: &Coord, properties: &HashMap, ) -> Result<()> { for prop_name in properties.keys() { - let valid = match project_type { - ProjectType::Cartesian => { + let valid = match coord { + Coord::Cartesian => { // Cartesian allows: xlim, ylim, aesthetic names // Not allowed: theta prop_name == "xlim" || prop_name == "ylim" || is_aesthetic_name(prop_name) } - ProjectType::Flip => { + Coord::Flip => { // Flip allows: aesthetic names only // Not allowed: xlim, ylim, theta is_aesthetic_name(prop_name) } - ProjectType::Polar => { + Coord::Polar => { // Polar allows: theta, aesthetic names // Not allowed: xlim, ylim prop_name == "theta" || is_aesthetic_name(prop_name) } _ => { - // Other project types: allow all for now (future implementation) + // Other coord types: allow all for now (future implementation) true } }; if !valid { - let valid_props = match project_type { - ProjectType::Cartesian => "xlim, ylim, ", - ProjectType::Flip => "", - ProjectType::Polar => "theta, ", + let valid_props = match coord { + Coord::Cartesian => "xlim, ylim, ", + Coord::Flip => "", + Coord::Polar => "theta, ", _ => "", }; return Err(GgsqlError::ParseError(format!( "Property '{}' not valid for {:?} projection. Valid properties: {}", - prop_name, project_type, valid_props + prop_name, coord, valid_props ))); } } @@ -1003,19 +1004,19 @@ fn validate_project_properties( Ok(()) } -/// Parse project type from a project_type node -fn parse_project_type(node: &Node, source: &SourceTree) -> Result { +/// Parse coord type from a project_type node +fn parse_coord(node: &Node, source: &SourceTree) -> Result { let text = source.get_text(node); match text.to_lowercase().as_str() { - "cartesian" => Ok(ProjectType::Cartesian), - "polar" => Ok(ProjectType::Polar), - "flip" => Ok(ProjectType::Flip), - "fixed" => Ok(ProjectType::Fixed), - "trans" => Ok(ProjectType::Trans), - "map" => Ok(ProjectType::Map), - "quickmap" => Ok(ProjectType::QuickMap), + "cartesian" => Ok(Coord::Cartesian), + "polar" => Ok(Coord::Polar), + "flip" => Ok(Coord::Flip), + "fixed" => Ok(Coord::Fixed), + "trans" => Ok(Coord::Trans), + "map" => Ok(Coord::Map), + "quickmap" => Ok(Coord::QuickMap), _ => Err(GgsqlError::ParseError(format!( - "Unknown project type: {}", + "Unknown coord type: {}", text ))), } @@ -1220,7 +1221,7 @@ mod tests { assert_eq!(specs.len(), 1); let project = specs[0].project.as_ref().unwrap(); - assert_eq!(project.project_type, ProjectType::Cartesian); + assert_eq!(project.coord, Coord::Cartesian); assert!(project.properties.contains_key("xlim")); } @@ -1285,7 +1286,7 @@ mod tests { let specs = result.unwrap(); let project = specs[0].project.as_ref().unwrap(); - assert_eq!(project.project_type, ProjectType::Flip); + assert_eq!(project.coord, Coord::Flip); assert!(project.properties.contains_key("color")); } @@ -1350,7 +1351,7 @@ mod tests { let specs = result.unwrap(); let project = specs[0].project.as_ref().unwrap(); - assert_eq!(project.project_type, ProjectType::Polar); + assert_eq!(project.coord, Coord::Polar); assert!(project.properties.contains_key("theta")); } diff --git a/src/plot/main.rs b/src/plot/main.rs index c00a0fac..b7effe72 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -13,7 +13,7 @@ //! ├─ layers: Vec (1+ LayerNode, one per DRAW clause) //! ├─ scales: Vec (0+ ScaleNode, one per SCALE clause) //! ├─ facet: Option (optional, from FACET clause) -//! ├─ project: Option (optional, from PROJECT clause) +//! ├─ project: Option (optional, from PROJECT clause) //! ├─ labels: Option (optional, merged from LABEL clauses) //! └─ theme: Option (optional, from THEME clause) //! ``` @@ -41,8 +41,8 @@ pub use super::layer::Layer; // Re-export Scale types from the scale module pub use super::scale::{Scale, ScaleType}; -// Re-export Project types from the project module -pub use super::project::{Project, ProjectType}; +// Re-export Projection types from the project module +pub use super::project::{Coord, Projection}; // Re-export Facet types from the facet module pub use super::facet::{Facet, FacetScales}; @@ -61,7 +61,7 @@ pub struct Plot { /// Faceting specification (from FACET clause) pub facet: Option, /// Projection (from PROJECT clause) - pub project: Option, + pub project: Option, /// Text labels (merged from all LABEL clauses) pub labels: Option, /// Theme styling (from THEME clause) diff --git a/src/plot/project/mod.rs b/src/plot/project/mod.rs index 5985226d..5f931c40 100644 --- a/src/plot/project/mod.rs +++ b/src/plot/project/mod.rs @@ -4,4 +4,4 @@ mod types; -pub use types::{Project, ProjectType}; +pub use types::{Coord, Projection}; diff --git a/src/plot/project/types.rs b/src/plot/project/types.rs index 1720e089..b8fd5645 100644 --- a/src/plot/project/types.rs +++ b/src/plot/project/types.rs @@ -9,16 +9,16 @@ use super::super::types::ParameterValue; /// Projection (from PROJECT clause) #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Project { - /// Projection type - pub project_type: ProjectType, +pub struct Projection { + /// Coordinate system type + pub coord: Coord, /// Projection-specific options pub properties: HashMap, } -/// Projection types +/// Coordinate system types #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum ProjectType { +pub enum Coord { Cartesian, Polar, Flip, diff --git a/src/writer/vegalite/project.rs b/src/writer/vegalite/project.rs index 34259f0c..e906a8fe 100644 --- a/src/writer/vegalite/project.rs +++ b/src/writer/vegalite/project.rs @@ -4,7 +4,7 @@ //! that modify the Vega-Lite spec structure based on the PROJECT clause. use crate::plot::aesthetic::is_aesthetic_name; -use crate::plot::{ParameterValue, Project, ProjectType}; +use crate::plot::{Coord, ParameterValue, Projection}; use crate::{DataFrame, GgsqlError, Plot, Result}; use serde_json::{json, Value}; @@ -16,22 +16,22 @@ pub(super) fn apply_project_transforms( vl_spec: &mut Value, ) -> Result> { if let Some(ref project) = spec.project { - match project.project_type { - ProjectType::Cartesian => { + match project.coord { + Coord::Cartesian => { apply_cartesian_project(project, vl_spec)?; Ok(None) // No DataFrame transformation needed } - ProjectType::Flip => { + Coord::Flip => { apply_flip_project(vl_spec)?; Ok(None) // No DataFrame transformation needed } - ProjectType::Polar => { + Coord::Polar => { // Polar requires DataFrame transformation for percentages let transformed_df = apply_polar_project(project, spec, data, vl_spec)?; Ok(Some(transformed_df)) } _ => { - // Other project types not yet implemented + // Other coord types not yet implemented Ok(None) } } @@ -41,7 +41,7 @@ pub(super) fn apply_project_transforms( } /// Apply Cartesian projection properties (xlim, ylim, aesthetic domains) -fn apply_cartesian_project(project: &Project, vl_spec: &mut Value) -> Result<()> { +fn apply_cartesian_project(project: &Projection, vl_spec: &mut Value) -> Result<()> { // Apply xlim/ylim to scale domains for (prop_name, prop_value) in &project.properties { match prop_name.as_str() { @@ -92,7 +92,7 @@ fn apply_flip_project(vl_spec: &mut Value) -> Result<()> { /// Apply Polar projection transformation (bar->arc, point->arc with radius) fn apply_polar_project( - project: &Project, + project: &Projection, spec: &Plot, data: &DataFrame, vl_spec: &mut Value, diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index ff576923..d1c3c77c 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -649,7 +649,9 @@ module.exports = grammar({ // Text aesthetics 'label', 'family', 'fontface', 'hjust', 'vjust', // Computed variables - 'offset' + 'offset', + // Facet aesthetics + 'panel' ), column_reference: $ => $.identifier, @@ -691,7 +693,8 @@ module.exports = grammar({ field('name', choice( '*', // Wildcard for template $.string, - $.number + $.number, + $.null_literal // NULL for renaming null values )), '=>', field('value', choice($.string, $.null_literal)) // String label or NULL to suppress @@ -743,6 +746,12 @@ module.exports = grammar({ alias(caseInsensitive('WRAP'), $.facet_wrap), $.facet_vars, optional(seq(caseInsensitive('SETTING'), caseInsensitive('scales'), '=>', $.facet_scales)) + ), + // FACET vars (shorthand for FACET WRAP vars) + seq( + caseInsensitive('FACET'), + $.facet_vars, + optional(seq(caseInsensitive('SETTING'), caseInsensitive('scales'), '=>', $.facet_scales)) ) ), From 1b0a410e070bcf71df92769cab19bb027c2a032b Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 23 Feb 2026 18:01:58 +0100 Subject: [PATCH 03/28] refactor to trait based design --- CLAUDE.md | 26 +- EXAMPLES.md | 12 +- ggsql-vscode/examples/sample.gsql | 7 +- src/parser/builder.rs | 186 ++-------- src/plot/main.rs | 4 +- src/plot/mod.rs | 6 +- src/plot/projection/coord/cartesian.rs | 126 +++++++ src/plot/projection/coord/flip.rs | 113 ++++++ src/plot/projection/coord/mod.rs | 343 ++++++++++++++++++ src/plot/projection/coord/polar.rs | 148 ++++++++ src/plot/{project => projection}/mod.rs | 4 +- src/plot/{project => projection}/types.rs | 15 +- src/writer/vegalite/mod.rs | 4 +- .../vegalite/{project.rs => projection.rs} | 98 +---- tree-sitter-ggsql/grammar.js | 4 +- tree-sitter-ggsql/test/corpus/basic.txt | 10 +- 16 files changed, 812 insertions(+), 294 deletions(-) create mode 100644 src/plot/projection/coord/cartesian.rs create mode 100644 src/plot/projection/coord/flip.rs create mode 100644 src/plot/projection/coord/mod.rs create mode 100644 src/plot/projection/coord/polar.rs rename src/plot/{project => projection}/mod.rs (60%) rename src/plot/{project => projection}/types.rs (66%) rename src/writer/vegalite/{project.rs => projection.rs} (69%) diff --git a/CLAUDE.md b/CLAUDE.md index e1485ffd..329ed4b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ SELECT date, revenue, region FROM sales WHERE year = 2024 VISUALISE date AS x, revenue AS y, region AS color DRAW line SCALE x VIA date -PROJECT cartesian SETTING ylim => [0, 100000] +SCALE y FROM [0, 100000] LABEL title => 'Sales by Region', x => 'Date', y => 'Revenue' THEME minimal ``` @@ -24,7 +24,7 @@ THEME minimal - Syntax highlighting support via Tree-sitter queries - 916 total tests (174 parser tests, comprehensive builder and integration tests) - End-to-end working pipeline: SQL → Data → Visualization -- Projectinate transformations: Cartesian (xlim/ylim), Flip, Polar +- Projectinate transformations: Cartesian, Flip, Polar - VISUALISE FROM shorthand syntax with automatic SELECT injection --- @@ -1180,7 +1180,7 @@ Where `` can be: | `DRAW` | ✅ Yes | Define layers | `DRAW line MAPPING date AS x, value AS y` | | `SCALE` | ✅ Yes | Configure scales | `SCALE x VIA date` | | `FACET` | ❌ No | Small multiples | `FACET region` | -| `PROJECT` | ❌ No | Coordinate system | `PROJECT cartesian SETTING xlim => [0,100]` | +| `PROJECT` | ❌ No | Coordinate system | `PROJECT flip` | | `LABEL` | ❌ No | Text labels | `LABEL title => 'My Chart', x => 'Date'` | | `THEME` | ❌ No | Visual styling | `THEME minimal` | @@ -1448,10 +1448,11 @@ PROJECT SETTING **Cartesian**: -- `xlim => [min, max]` - Set x-axis limits -- `ylim => [min, max]` - Set y-axis limits +- `ratio => ` - Set aspect ratio - ` => [values...]` - Set range for any aesthetic (color, fill, size, etc.) +Note: For axis limits, use `SCALE x FROM [min, max]` or `SCALE y FROM [min, max]` instead. + **Flip**: - ` => [values...]` - Set range for any aesthetic @@ -1463,7 +1464,7 @@ PROJECT SETTING **Important Notes**: -1. **Axis limits auto-swap**: `xlim => [100, 0]` automatically becomes `[0, 100]` +1. **Axis limits**: Use `SCALE x/y FROM [min, max]` to set axis limits (not PROJECT) 2. **ggplot2 compatibility**: `project_flip` preserves axis label names (labels stay with aesthetic names, not visual position) 3. **Range conflicts**: Error if same aesthetic has input range in both SCALE and PROJECT 4. **Multi-layer support**: All projectinate transforms apply to all layers @@ -1478,14 +1479,12 @@ PROJECT SETTING **Examples**: ```sql --- Cartesian with axis limits -PROJECT cartesian SETTING xlim => [0, 100], ylim => [0, 50] +-- Cartesian with axis limits (use SCALE, not PROJECT) +SCALE x FROM [0, 100] +SCALE y FROM [0, 50] -- Cartesian with aesthetic range -PROJECT cartesian SETTING color => O ['red', 'green', 'blue'] - --- Cartesian shorthand (type optional when using SETTING) -PROJECT SETTING xlim => [0, 100] +PROJECT cartesian SETTING color => ['red', 'green', 'blue'] -- Flip projectinates for horizontal bar chart PROJECT flip @@ -1501,7 +1500,8 @@ PROJECT polar SETTING theta => y -- Combined with other clauses DRAW bar MAPPING category AS x, value AS y -PROJECT cartesian SETTING xlim => [0, 100], ylim => [0, 200] +SCALE x FROM [0, 100] +SCALE y FROM [0, 200] LABEL x => 'Category', y => 'Count' ``` diff --git a/EXAMPLES.md b/EXAMPLES.md index 471c1c85..4e04dff8 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -127,13 +127,14 @@ SCALE DISCRETE fill FROM ['A', 'B', 'C', 'D'] ## Projections -### Cartesian with Limits +### Cartesian with Axis Limits ```sql SELECT x, y FROM data VISUALISE x, y DRAW point -PROJECT cartesian SETTING xlim => [0, 100], ylim => [0, 50] +SCALE x FROM [0, 100] +SCALE y FROM [0, 50] ``` ### Flipped Projection (Horizontal Bar Chart) @@ -510,7 +511,7 @@ DRAW point SCALE x SETTING type => 'date' SCALE DISCRETE color FROM ['A', 'B', 'C'] SCALE size SETTING limits => [0, 100] -PROJECT cartesian SETTING ylim => [0, 150] +SCALE y FROM [0, 150] LABEL title => 'Measurement Distribution', x => 'Date', y => 'Value' @@ -529,7 +530,8 @@ VISUALISE x, y, category AS color DRAW point SETTING size => 5 DRAW text MAPPING label AS label SCALE color TO viridis -PROJECT cartesian SETTING xlim => [0, 100], ylim => [0, 100] +SCALE x FROM [0, 100] +SCALE y FROM [0, 100] LABEL title => 'Annotated Scatter Plot', x => 'X Axis', y => 'Y Axis' @@ -630,7 +632,7 @@ Draw Line 3. **Color Mappings**: Use `color` for continuous data and `fill` for categorical data in bars/areas. -4. **Coordinate Limits**: Set explicit limits with `PROJECT cartesian SETTING xlim => [min, max]` to control axis ranges. +4. **Axis Limits**: Set explicit limits with `SCALE x FROM [min, max]` or `SCALE y FROM [min, max]` to control axis ranges. 5. **Faceting**: Use faceting to create small multiples when comparing across categories. diff --git a/ggsql-vscode/examples/sample.gsql b/ggsql-vscode/examples/sample.gsql index b01b4be0..5c0e5adf 100644 --- a/ggsql-vscode/examples/sample.gsql +++ b/ggsql-vscode/examples/sample.gsql @@ -101,8 +101,8 @@ DRAW line MAPPING avg_temp AS y SETTING size => 2, color => 'blue' SCALE x SETTING type => 'date' +SCALE x FROM ['2024-01-01', '2024-12-31'] SCALE y SETTING type => 'linear', limits => [0, 100] -PROJECT cartesian SETTING xlim => ['2024-01-01', '2024-12-31'] LABEL title => 'Temperature Range (Last 30 Days)', x => 'Date', y => 'Temperature (°C)', @@ -166,7 +166,7 @@ WHERE rating IS NOT NULL VISUALISE category AS x, price AS y DRAW boxplot DRAW violin SETTING opacity => 0.3 -PROJECT cartesian SETTING ylim => [0, 500] +SCALE y FROM [0, 500] LABEL title => 'Price Distribution by Category', x => 'Product Category', y => 'Price ($)' @@ -190,7 +190,8 @@ DRAW text MAPPING product_name AS label SCALE color TO viridis SCALE size SETTING limits => [0, 10000] -PROJECT cartesian SETTING xlim => [0, 1000], ylim => [0, 5] +SCALE x FROM [0, 1000] +SCALE y FROM [0, 5] LABEL title => 'Featured Products: Price vs Rating', x => 'Price ($)', y => 'Customer Rating', diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 50316dd6..1728ea1a 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -892,7 +892,7 @@ fn parse_facet_vars(node: &Node, source: &SourceTree) -> Result> { /// Build a Projection from a project_clause node fn build_project(node: &Node, source: &SourceTree) -> Result { - let mut coord = Coord::Cartesian; + let mut coord = Coord::cartesian(); let mut properties = HashMap::new(); let mut cursor = node.walk(); @@ -908,7 +908,8 @@ fn build_project(node: &Node, source: &SourceTree) -> Result { let prop_nodes = source.find_nodes(&child, query); for prop_node in prop_nodes { - let (prop_name, prop_value) = parse_single_project_property(&prop_node, source)?; + let (prop_name, prop_value) = + parse_single_project_property(&prop_node, source)?; properties.insert(prop_name, prop_value); } } @@ -919,10 +920,7 @@ fn build_project(node: &Node, source: &SourceTree) -> Result { // Validate properties for this coord type validate_project_properties(&coord, &properties)?; - Ok(Projection { - coord, - properties, - }) + Ok(Projection { coord, properties }) } /// Parse a single project_property node into (name, value) @@ -961,43 +959,9 @@ fn validate_project_properties( coord: &Coord, properties: &HashMap, ) -> Result<()> { - for prop_name in properties.keys() { - let valid = match coord { - Coord::Cartesian => { - // Cartesian allows: xlim, ylim, aesthetic names - // Not allowed: theta - prop_name == "xlim" || prop_name == "ylim" || is_aesthetic_name(prop_name) - } - Coord::Flip => { - // Flip allows: aesthetic names only - // Not allowed: xlim, ylim, theta - is_aesthetic_name(prop_name) - } - Coord::Polar => { - // Polar allows: theta, aesthetic names - // Not allowed: xlim, ylim - prop_name == "theta" || is_aesthetic_name(prop_name) - } - _ => { - // Other coord types: allow all for now (future implementation) - true - } - }; - - if !valid { - let valid_props = match coord { - Coord::Cartesian => "xlim, ylim, ", - Coord::Flip => "", - Coord::Polar => "theta, ", - _ => "", - }; - return Err(GgsqlError::ParseError(format!( - "Property '{}' not valid for {:?} projection. Valid properties: {}", - prop_name, coord, valid_props - ))); - } - } - + coord + .resolve_properties(properties) + .map_err(GgsqlError::ParseError)?; Ok(()) } @@ -1005,13 +969,9 @@ fn validate_project_properties( fn parse_coord(node: &Node, source: &SourceTree) -> Result { let text = source.get_text(node); match text.to_lowercase().as_str() { - "cartesian" => Ok(Coord::Cartesian), - "polar" => Ok(Coord::Polar), - "flip" => Ok(Coord::Flip), - "fixed" => Ok(Coord::Fixed), - "trans" => Ok(Coord::Trans), - "map" => Ok(Coord::Map), - "quickmap" => Ok(Coord::QuickMap), + "cartesian" => Ok(Coord::cartesian()), + "polar" => Ok(Coord::polar()), + "flip" => Ok(Coord::flip()), _ => Err(GgsqlError::ParseError(format!( "Unknown coord type: {}", text @@ -1204,40 +1164,6 @@ mod tests { // PROJECT Property Validation Tests // ======================================== - #[test] - fn test_project_cartesian_valid_xlim() { - let query = r#" - VISUALISE - DRAW point MAPPING x AS x, y AS y - PROJECT cartesian SETTING xlim => [0, 100] - "#; - - let result = parse_test_query(query); - assert!(result.is_ok(), "Parse failed: {:?}", result); - let specs = result.unwrap(); - assert_eq!(specs.len(), 1); - - let project = specs[0].project.as_ref().unwrap(); - assert_eq!(project.coord, Coord::Cartesian); - assert!(project.properties.contains_key("xlim")); - } - - #[test] - fn test_project_cartesian_valid_ylim() { - let query = r#" - VISUALISE - DRAW point MAPPING x AS x, y AS y - PROJECT cartesian SETTING ylim => [-10, 50] - "#; - - let result = parse_test_query(query); - assert!(result.is_ok()); - let specs = result.unwrap(); - - let project = specs[0].project.as_ref().unwrap(); - assert!(project.properties.contains_key("ylim")); - } - #[test] fn test_project_cartesian_valid_aesthetic_input_range() { let query = r#" @@ -1267,7 +1193,7 @@ mod tests { let err = result.unwrap_err(); assert!(err .to_string() - .contains("Property 'theta' not valid for Cartesian")); + .contains("Property 'theta' not valid for cartesian")); } #[test] @@ -1283,41 +1209,12 @@ mod tests { let specs = result.unwrap(); let project = specs[0].project.as_ref().unwrap(); - assert_eq!(project.coord, Coord::Flip); + assert_eq!(project.coord.coord_kind(), CoordKind::Flip); assert!(project.properties.contains_key("color")); } - #[test] - fn test_project_flip_invalid_property_xlim() { - let query = r#" - VISUALISE - DRAW bar MAPPING category AS x, value AS y - PROJECT flip SETTING xlim => [0, 100] - "#; - - let result = parse_test_query(query); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err - .to_string() - .contains("Property 'xlim' not valid for Flip")); - } - - #[test] - fn test_project_flip_invalid_property_ylim() { - let query = r#" - VISUALISE - DRAW bar MAPPING category AS x, value AS y - PROJECT flip SETTING ylim => [0, 100] - "#; - - let result = parse_test_query(query); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err - .to_string() - .contains("Property 'ylim' not valid for Flip")); - } + // xlim/ylim tests removed - these are no longer valid grammar tokens + // Use SCALE x/y FROM instead to set axis limits #[test] fn test_project_flip_invalid_property_theta() { @@ -1332,7 +1229,7 @@ mod tests { let err = result.unwrap_err(); assert!(err .to_string() - .contains("Property 'theta' not valid for Flip")); + .contains("Property 'theta' not valid for flip")); } #[test] @@ -1348,7 +1245,7 @@ mod tests { let specs = result.unwrap(); let project = specs[0].project.as_ref().unwrap(); - assert_eq!(project.coord, Coord::Polar); + assert_eq!(project.coord.coord_kind(), CoordKind::Polar); assert!(project.properties.contains_key("theta")); } @@ -1368,37 +1265,7 @@ mod tests { assert!(project.properties.contains_key("color")); } - #[test] - fn test_project_polar_invalid_property_xlim() { - let query = r#" - VISUALISE - DRAW bar MAPPING category AS x, value AS y - PROJECT polar SETTING xlim => [0, 100] - "#; - - let result = parse_test_query(query); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err - .to_string() - .contains("Property 'xlim' not valid for Polar")); - } - - #[test] - fn test_project_polar_invalid_property_ylim() { - let query = r#" - VISUALISE - DRAW bar MAPPING category AS x, value AS y - PROJECT polar SETTING ylim => [0, 100] - "#; - - let result = parse_test_query(query); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err - .to_string() - .contains("Property 'ylim' not valid for Polar")); - } + // xlim/ylim polar rejection tests removed - these are no longer valid grammar tokens // ======================================== // SCALE/PROJECT Input Range Conflict Tests @@ -1442,9 +1309,9 @@ mod tests { fn test_scale_project_no_conflict_different_aesthetics() { let query = r#" VISUALISE - DRAW point MAPPING x AS x, y AS y, category AS color + DRAW point MAPPING x AS x, y AS y, category AS color, region AS size SCALE color FROM ['A', 'B'] - PROJECT cartesian SETTING xlim => [0, 100] + PROJECT cartesian SETTING size => [5, 20] "#; let result = parse_test_query(query); @@ -1473,7 +1340,7 @@ mod tests { let query = r#" VISUALISE DRAW point MAPPING x AS x, y AS y, category AS color - PROJECT cartesian SETTING xlim => [0, 100], ylim => [-10, 50], color => ['A', 'B'] + PROJECT cartesian SETTING color => ['A', 'B'] "#; let result = parse_test_query(query); @@ -1481,8 +1348,9 @@ mod tests { let specs = result.unwrap(); let project = specs[0].project.as_ref().unwrap(); - assert!(project.properties.contains_key("xlim")); - assert!(project.properties.contains_key("ylim")); + // xlim/ylim removed - use SCALE x/y FROM instead + assert!(!project.properties.contains_key("xlim")); + assert!(!project.properties.contains_key("ylim")); assert!(project.properties.contains_key("color")); } @@ -1512,7 +1380,7 @@ mod tests { let query = r#" visualise draw point MAPPING x AS x, y AS y - project cartesian setting xlim => [0, 100] + project cartesian label title => 'Test Chart' "#; @@ -3391,7 +3259,7 @@ mod tests { #[test] fn test_parse_number_node() { // Test integers - let source = make_source("VISUALISE DRAW point PROJECT SETTING xlim => [0, 100]"); + let source = make_source("VISUALISE DRAW point SCALE x FROM [0, 100]"); let root = source.root(); let numbers = source.find_nodes(&root, "(number) @n"); @@ -3400,7 +3268,7 @@ mod tests { assert_eq!(parse_number_node(&numbers[1], &source).unwrap(), 100.0); // Test floats - let source2 = make_source("VISUALISE DRAW point PROJECT SETTING ylim => [-10.5, 20.75]"); + let source2 = make_source("VISUALISE DRAW point SCALE y FROM [-10.5, 20.75]"); let root2 = source2.root(); let numbers2 = source2.find_nodes(&root2, "(number) @n"); @@ -3423,7 +3291,7 @@ mod tests { assert!(matches!(parsed[2], ArrayElement::String(ref s) if s == "c")); // Test array of numbers - let source2 = make_source("VISUALISE DRAW point PROJECT SETTING xlim => [0, 50, 100]"); + let source2 = make_source("VISUALISE DRAW point SCALE x FROM [0, 50, 100]"); let root2 = source2.root(); let array_node2 = source2.find_node(&root2, "(array) @arr").unwrap(); diff --git a/src/plot/main.rs b/src/plot/main.rs index 9865279a..02262b2d 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -41,8 +41,8 @@ pub use super::layer::Layer; // Re-export Scale types from the scale module pub use super::scale::{Scale, ScaleType}; -// Re-export Projection types from the project module -pub use super::project::{Coord, Projection}; +// Re-export Projection types from the projection module +pub use super::projection::{Coord, Projection}; // Re-export Facet types from the facet module pub use super::facet::{Facet, FacetLayout}; diff --git a/src/plot/mod.rs b/src/plot/mod.rs index 069eee0b..d7b29785 100644 --- a/src/plot/mod.rs +++ b/src/plot/mod.rs @@ -13,13 +13,13 @@ //! - `layer` - Layer struct and Geom subsystem //! - `scale` - Scale and Guide types //! - `facet` - Facet types for small multiples -//! - `project` - Projection types +//! - `projection` - Projection types pub mod aesthetic; pub mod facet; pub mod layer; pub mod main; -pub mod project; +pub mod projection; pub mod scale; pub mod types; @@ -28,6 +28,6 @@ pub use aesthetic::*; pub use facet::*; pub use layer::*; pub use main::*; -pub use project::*; +pub use projection::*; pub use scale::*; pub use types::*; diff --git a/src/plot/projection/coord/cartesian.rs b/src/plot/projection/coord/cartesian.rs new file mode 100644 index 00000000..62aa9ca5 --- /dev/null +++ b/src/plot/projection/coord/cartesian.rs @@ -0,0 +1,126 @@ +//! Cartesian coordinate system implementation + +use super::{CoordKind, CoordTrait}; + +/// Cartesian coordinate system - standard x/y coordinates +#[derive(Debug, Clone, Copy)] +pub struct Cartesian; + +impl CoordTrait for Cartesian { + fn coord_kind(&self) -> CoordKind { + CoordKind::Cartesian + } + + fn name(&self) -> &'static str { + "cartesian" + } + + fn allowed_properties(&self) -> &'static [&'static str] { + &["ratio"] + } + + fn allows_aesthetic_properties(&self) -> bool { + true + } +} + +impl std::fmt::Display for Cartesian { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plot::{ArrayElement, ParameterValue}; + use std::collections::HashMap; + + #[test] + fn test_cartesian_properties() { + let cartesian = Cartesian; + assert_eq!(cartesian.coord_kind(), CoordKind::Cartesian); + assert_eq!(cartesian.name(), "cartesian"); + assert!(cartesian.allows_aesthetic_properties()); + } + + #[test] + fn test_cartesian_allowed_properties() { + let cartesian = Cartesian; + let allowed = cartesian.allowed_properties(); + assert!(allowed.contains(&"ratio")); + // xlim/ylim removed - use SCALE x/y FROM instead + assert!(!allowed.contains(&"xlim")); + assert!(!allowed.contains(&"ylim")); + } + + #[test] + fn test_cartesian_resolve_valid_properties() { + let cartesian = Cartesian; + let props = HashMap::new(); + // Empty properties should resolve successfully + let resolved = cartesian.resolve_properties(&props); + assert!(resolved.is_ok()); + } + + #[test] + fn test_cartesian_rejects_xlim() { + let cartesian = Cartesian; + let mut props = HashMap::new(); + props.insert( + "xlim".to_string(), + ParameterValue::Array(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]), + ); + + let resolved = cartesian.resolve_properties(&props); + assert!(resolved.is_err()); + let err = resolved.unwrap_err(); + assert!(err.contains("xlim")); + assert!(err.contains("not valid")); + } + + #[test] + fn test_cartesian_rejects_ylim() { + let cartesian = Cartesian; + let mut props = HashMap::new(); + props.insert( + "ylim".to_string(), + ParameterValue::Array(vec![ArrayElement::Number(0.0), ArrayElement::Number(50.0)]), + ); + + let resolved = cartesian.resolve_properties(&props); + assert!(resolved.is_err()); + let err = resolved.unwrap_err(); + assert!(err.contains("ylim")); + assert!(err.contains("not valid")); + } + + #[test] + fn test_cartesian_accepts_aesthetic_properties() { + let cartesian = Cartesian; + let mut props = HashMap::new(); + props.insert( + "color".to_string(), + ParameterValue::Array(vec![ + ArrayElement::String("red".to_string()), + ArrayElement::String("blue".to_string()), + ]), + ); + + let resolved = cartesian.resolve_properties(&props); + assert!(resolved.is_ok()); + } + + #[test] + fn test_cartesian_rejects_theta() { + let cartesian = Cartesian; + let mut props = HashMap::new(); + props.insert("theta".to_string(), ParameterValue::String("y".to_string())); + + let resolved = cartesian.resolve_properties(&props); + assert!(resolved.is_err()); + let err = resolved.unwrap_err(); + assert!(err.contains("theta")); + assert!(err.contains("not valid")); + } +} diff --git a/src/plot/projection/coord/flip.rs b/src/plot/projection/coord/flip.rs new file mode 100644 index 00000000..71798370 --- /dev/null +++ b/src/plot/projection/coord/flip.rs @@ -0,0 +1,113 @@ +//! Flip coordinate system implementation + +use super::{CoordKind, CoordTrait}; + +/// Flip coordinate system - swaps x and y axes +#[derive(Debug, Clone, Copy)] +pub struct Flip; + +impl CoordTrait for Flip { + fn coord_kind(&self) -> CoordKind { + CoordKind::Flip + } + + fn name(&self) -> &'static str { + "flip" + } + + fn allowed_properties(&self) -> &'static [&'static str] { + &[] // Flip only allows aesthetic properties + } + + fn allows_aesthetic_properties(&self) -> bool { + true + } +} + +impl std::fmt::Display for Flip { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plot::{ArrayElement, ParameterValue}; + use std::collections::HashMap; + + #[test] + fn test_flip_properties() { + let flip = Flip; + assert_eq!(flip.coord_kind(), CoordKind::Flip); + assert_eq!(flip.name(), "flip"); + assert!(flip.allows_aesthetic_properties()); + } + + #[test] + fn test_flip_no_specific_properties() { + let flip = Flip; + assert!(flip.allowed_properties().is_empty()); + } + + #[test] + fn test_flip_accepts_aesthetic_properties() { + let flip = Flip; + let mut props = HashMap::new(); + props.insert( + "color".to_string(), + ParameterValue::Array(vec![ + ArrayElement::String("red".to_string()), + ArrayElement::String("blue".to_string()), + ]), + ); + + let resolved = flip.resolve_properties(&props); + assert!(resolved.is_ok()); + } + + #[test] + fn test_flip_rejects_xlim() { + let flip = Flip; + let mut props = HashMap::new(); + props.insert( + "xlim".to_string(), + ParameterValue::Array(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]), + ); + + let resolved = flip.resolve_properties(&props); + assert!(resolved.is_err()); + let err = resolved.unwrap_err(); + assert!(err.contains("xlim")); + assert!(err.contains("not valid")); + } + + #[test] + fn test_flip_rejects_ylim() { + let flip = Flip; + let mut props = HashMap::new(); + props.insert( + "ylim".to_string(), + ParameterValue::Array(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]), + ); + + let resolved = flip.resolve_properties(&props); + assert!(resolved.is_err()); + let err = resolved.unwrap_err(); + assert!(err.contains("ylim")); + assert!(err.contains("not valid")); + } + + #[test] + fn test_flip_rejects_theta() { + let flip = Flip; + let mut props = HashMap::new(); + props.insert("theta".to_string(), ParameterValue::String("y".to_string())); + + let resolved = flip.resolve_properties(&props); + assert!(resolved.is_err()); + let err = resolved.unwrap_err(); + assert!(err.contains("theta")); + assert!(err.contains("not valid")); + } +} diff --git a/src/plot/projection/coord/mod.rs b/src/plot/projection/coord/mod.rs new file mode 100644 index 00000000..c74a7bd2 --- /dev/null +++ b/src/plot/projection/coord/mod.rs @@ -0,0 +1,343 @@ +//! Coordinate system trait and implementations +//! +//! This module provides a trait-based design for coordinate system types in ggsql. +//! Each coord type is implemented as its own struct, allowing for cleaner separation +//! of concerns and easier extensibility. +//! +//! # Architecture +//! +//! - `CoordKind`: Enum for pattern matching and serialization +//! - `CoordTrait`: Trait defining coord type behavior +//! - `Coord`: Wrapper struct holding an Arc +//! +//! # Example +//! +//! ```rust,ignore +//! use ggsql::plot::projection::{Coord, CoordKind}; +//! +//! let cartesian = Coord::cartesian(); +//! assert_eq!(cartesian.coord_kind(), CoordKind::Cartesian); +//! assert_eq!(cartesian.name(), "cartesian"); +//! ``` + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; + +use crate::plot::aesthetic::is_aesthetic_name; +use crate::plot::ParameterValue; + +// Coord type implementations +mod cartesian; +mod flip; +mod polar; + +// Re-export coord type structs +pub use cartesian::Cartesian; +pub use flip::Flip; +pub use polar::Polar; + +// ============================================================================= +// Coord Kind Enum +// ============================================================================= + +/// Enum of all coordinate system types for pattern matching and serialization +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CoordKind { + /// Standard x/y Cartesian coordinates (default) + Cartesian, + /// Flipped Cartesian (swaps x and y axes) + Flip, + /// Polar coordinates (for pie charts, rose plots) + Polar, +} + +impl CoordKind { + /// Get the canonical name for this coord kind + pub fn name(&self) -> &'static str { + match self { + CoordKind::Cartesian => "cartesian", + CoordKind::Flip => "flip", + CoordKind::Polar => "polar", + } + } +} + +// ============================================================================= +// Coord Trait +// ============================================================================= + +/// Trait defining coordinate system behavior. +/// +/// Each coord type implements this trait. The trait is intentionally minimal +/// and backend-agnostic - no Vega-Lite or other writer-specific details. +pub trait CoordTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { + /// Returns which coord type this is (for pattern matching) + fn coord_kind(&self) -> CoordKind; + + /// Canonical name for parsing and display + fn name(&self) -> &'static str; + + /// Returns list of allowed property names for SETTING clause. + /// Default: empty (no properties allowed). + fn allowed_properties(&self) -> &'static [&'static str] { + &[] + } + + /// Returns whether this coord type allows aesthetic names as properties. + /// Default: false. + fn allows_aesthetic_properties(&self) -> bool { + false + } + + /// Returns default value for a property, if any. + fn get_property_default(&self, _name: &str) -> Option { + None + } + + /// Resolve and validate properties. + /// Default implementation validates against allowed_properties + aesthetics. + fn resolve_properties( + &self, + properties: &HashMap, + ) -> Result, String> { + let allowed = self.allowed_properties(); + let allows_aesthetics = self.allows_aesthetic_properties(); + + // Check for unknown properties + for key in properties.keys() { + let is_allowed = + allowed.contains(&key.as_str()) || (allows_aesthetics && is_aesthetic_name(key)); + + if !is_allowed { + let valid_props = if allows_aesthetics { + if allowed.is_empty() { + "".to_string() + } else { + format!("{}, ", allowed.join(", ")) + } + } else if allowed.is_empty() { + "none".to_string() + } else { + allowed.join(", ") + }; + return Err(format!( + "Property '{}' not valid for {} projection. Valid properties: {}", + key, + self.name(), + valid_props + )); + } + } + + // Start with user properties, add defaults for missing ones + let mut resolved = properties.clone(); + for &prop_name in allowed { + if !resolved.contains_key(prop_name) { + if let Some(default) = self.get_property_default(prop_name) { + resolved.insert(prop_name.to_string(), default); + } + } + } + + Ok(resolved) + } +} + +// ============================================================================= +// Coord Wrapper Struct +// ============================================================================= + +/// Arc-wrapped coordinate system type. +/// +/// This provides a convenient interface for working with coord types while hiding +/// the complexity of trait objects. +#[derive(Clone)] +pub struct Coord(Arc); + +impl Coord { + /// Create a Cartesian coord type + pub fn cartesian() -> Self { + Self(Arc::new(Cartesian)) + } + + /// Create a Flip coord type + pub fn flip() -> Self { + Self(Arc::new(Flip)) + } + + /// Create a Polar coord type + pub fn polar() -> Self { + Self(Arc::new(Polar)) + } + + /// Create a Coord from a CoordKind + pub fn from_kind(kind: CoordKind) -> Self { + match kind { + CoordKind::Cartesian => Self::cartesian(), + CoordKind::Flip => Self::flip(), + CoordKind::Polar => Self::polar(), + } + } + + /// Get the coord type kind (for pattern matching) + pub fn coord_kind(&self) -> CoordKind { + self.0.coord_kind() + } + + /// Get the canonical name + pub fn name(&self) -> &'static str { + self.0.name() + } + + /// Returns list of allowed property names for SETTING clause. + pub fn allowed_properties(&self) -> &'static [&'static str] { + self.0.allowed_properties() + } + + /// Returns whether this coord type allows aesthetic names as properties. + pub fn allows_aesthetic_properties(&self) -> bool { + self.0.allows_aesthetic_properties() + } + + /// Returns default value for a property, if any. + pub fn get_property_default(&self, name: &str) -> Option { + self.0.get_property_default(name) + } + + /// Resolve and validate properties. + pub fn resolve_properties( + &self, + properties: &HashMap, + ) -> Result, String> { + self.0.resolve_properties(properties) + } +} + +impl std::fmt::Debug for Coord { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Coord({})", self.name()) + } +} + +impl std::fmt::Display for Coord { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name()) + } +} + +impl PartialEq for Coord { + fn eq(&self, other: &Self) -> bool { + self.coord_kind() == other.coord_kind() + } +} + +impl Eq for Coord {} + +impl std::hash::Hash for Coord { + fn hash(&self, state: &mut H) { + self.coord_kind().hash(state); + } +} + +// Implement Serialize by delegating to CoordKind +impl Serialize for Coord { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.coord_kind().serialize(serializer) + } +} + +// Implement Deserialize by delegating to CoordKind +impl<'de> Deserialize<'de> for Coord { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let kind = CoordKind::deserialize(deserializer)?; + Ok(Coord::from_kind(kind)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_coord_kind_name() { + assert_eq!(CoordKind::Cartesian.name(), "cartesian"); + assert_eq!(CoordKind::Flip.name(), "flip"); + assert_eq!(CoordKind::Polar.name(), "polar"); + } + + #[test] + fn test_coord_factory_methods() { + let cartesian = Coord::cartesian(); + assert_eq!(cartesian.coord_kind(), CoordKind::Cartesian); + assert_eq!(cartesian.name(), "cartesian"); + + let flip = Coord::flip(); + assert_eq!(flip.coord_kind(), CoordKind::Flip); + assert_eq!(flip.name(), "flip"); + + let polar = Coord::polar(); + assert_eq!(polar.coord_kind(), CoordKind::Polar); + assert_eq!(polar.name(), "polar"); + } + + #[test] + fn test_coord_from_kind() { + assert_eq!( + Coord::from_kind(CoordKind::Cartesian).coord_kind(), + CoordKind::Cartesian + ); + assert_eq!( + Coord::from_kind(CoordKind::Flip).coord_kind(), + CoordKind::Flip + ); + assert_eq!( + Coord::from_kind(CoordKind::Polar).coord_kind(), + CoordKind::Polar + ); + } + + #[test] + fn test_coord_equality() { + assert_eq!(Coord::cartesian(), Coord::cartesian()); + assert_eq!(Coord::flip(), Coord::flip()); + assert_eq!(Coord::polar(), Coord::polar()); + assert_ne!(Coord::cartesian(), Coord::flip()); + assert_ne!(Coord::cartesian(), Coord::polar()); + assert_ne!(Coord::flip(), Coord::polar()); + } + + #[test] + fn test_coord_serialization() { + let cartesian = Coord::cartesian(); + let json = serde_json::to_string(&cartesian).unwrap(); + assert_eq!(json, "\"cartesian\""); + + let flip = Coord::flip(); + let json = serde_json::to_string(&flip).unwrap(); + assert_eq!(json, "\"flip\""); + + let polar = Coord::polar(); + let json = serde_json::to_string(&polar).unwrap(); + assert_eq!(json, "\"polar\""); + } + + #[test] + fn test_coord_deserialization() { + let cartesian: Coord = serde_json::from_str("\"cartesian\"").unwrap(); + assert_eq!(cartesian.coord_kind(), CoordKind::Cartesian); + + let flip: Coord = serde_json::from_str("\"flip\"").unwrap(); + assert_eq!(flip.coord_kind(), CoordKind::Flip); + + let polar: Coord = serde_json::from_str("\"polar\"").unwrap(); + assert_eq!(polar.coord_kind(), CoordKind::Polar); + } +} diff --git a/src/plot/projection/coord/polar.rs b/src/plot/projection/coord/polar.rs new file mode 100644 index 00000000..1f0233c3 --- /dev/null +++ b/src/plot/projection/coord/polar.rs @@ -0,0 +1,148 @@ +//! Polar coordinate system implementation + +use super::{CoordKind, CoordTrait}; +use crate::plot::ParameterValue; + +/// Polar coordinate system - for pie charts, rose plots +#[derive(Debug, Clone, Copy)] +pub struct Polar; + +impl CoordTrait for Polar { + fn coord_kind(&self) -> CoordKind { + CoordKind::Polar + } + + fn name(&self) -> &'static str { + "polar" + } + + fn allowed_properties(&self) -> &'static [&'static str] { + &["theta"] + } + + fn allows_aesthetic_properties(&self) -> bool { + true + } + + fn get_property_default(&self, name: &str) -> Option { + match name { + "theta" => Some(ParameterValue::String("y".to_string())), + _ => None, + } + } +} + +impl std::fmt::Display for Polar { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plot::ArrayElement; + use std::collections::HashMap; + + #[test] + fn test_polar_properties() { + let polar = Polar; + assert_eq!(polar.coord_kind(), CoordKind::Polar); + assert_eq!(polar.name(), "polar"); + assert!(polar.allows_aesthetic_properties()); + } + + #[test] + fn test_polar_allowed_properties() { + let polar = Polar; + let allowed = polar.allowed_properties(); + assert!(allowed.contains(&"theta")); + assert_eq!(allowed.len(), 1); + } + + #[test] + fn test_polar_theta_default() { + let polar = Polar; + let default = polar.get_property_default("theta"); + assert!(default.is_some()); + assert_eq!(default.unwrap(), ParameterValue::String("y".to_string())); + } + + #[test] + fn test_polar_resolve_adds_theta_default() { + let polar = Polar; + let props = HashMap::new(); + + let resolved = polar.resolve_properties(&props); + assert!(resolved.is_ok()); + let resolved = resolved.unwrap(); + assert!(resolved.contains_key("theta")); + assert_eq!( + resolved.get("theta").unwrap(), + &ParameterValue::String("y".to_string()) + ); + } + + #[test] + fn test_polar_resolve_with_explicit_theta() { + let polar = Polar; + let mut props = HashMap::new(); + props.insert("theta".to_string(), ParameterValue::String("x".to_string())); + + let resolved = polar.resolve_properties(&props); + assert!(resolved.is_ok()); + let resolved = resolved.unwrap(); + assert_eq!( + resolved.get("theta").unwrap(), + &ParameterValue::String("x".to_string()) + ); + } + + #[test] + fn test_polar_accepts_aesthetic_properties() { + let polar = Polar; + let mut props = HashMap::new(); + props.insert( + "color".to_string(), + ParameterValue::Array(vec![ + ArrayElement::String("red".to_string()), + ArrayElement::String("blue".to_string()), + ]), + ); + + let resolved = polar.resolve_properties(&props); + assert!(resolved.is_ok()); + } + + #[test] + fn test_polar_rejects_xlim() { + let polar = Polar; + let mut props = HashMap::new(); + props.insert( + "xlim".to_string(), + ParameterValue::Array(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]), + ); + + let resolved = polar.resolve_properties(&props); + assert!(resolved.is_err()); + let err = resolved.unwrap_err(); + assert!(err.contains("xlim")); + assert!(err.contains("not valid")); + } + + #[test] + fn test_polar_rejects_ylim() { + let polar = Polar; + let mut props = HashMap::new(); + props.insert( + "ylim".to_string(), + ParameterValue::Array(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]), + ); + + let resolved = polar.resolve_properties(&props); + assert!(resolved.is_err()); + let err = resolved.unwrap_err(); + assert!(err.contains("ylim")); + assert!(err.contains("not valid")); + } +} diff --git a/src/plot/project/mod.rs b/src/plot/projection/mod.rs similarity index 60% rename from src/plot/project/mod.rs rename to src/plot/projection/mod.rs index 5f931c40..6b51a51e 100644 --- a/src/plot/project/mod.rs +++ b/src/plot/projection/mod.rs @@ -2,6 +2,8 @@ //! //! This module defines projection configuration and types. +pub mod coord; mod types; -pub use types::{Coord, Projection}; +pub use coord::{Coord, CoordKind, CoordTrait}; +pub use types::Projection; diff --git a/src/plot/project/types.rs b/src/plot/projection/types.rs similarity index 66% rename from src/plot/project/types.rs rename to src/plot/projection/types.rs index b8fd5645..65340e2c 100644 --- a/src/plot/project/types.rs +++ b/src/plot/projection/types.rs @@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use super::super::types::ParameterValue; +use super::coord::Coord; +use crate::plot::ParameterValue; /// Projection (from PROJECT clause) #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -15,15 +16,3 @@ pub struct Projection { /// Projection-specific options pub properties: HashMap, } - -/// Coordinate system types -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum Coord { - Cartesian, - Polar, - Flip, - Fixed, - Trans, - Map, - QuickMap, -} diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index e8d0c0fa..69f3be90 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -23,7 +23,7 @@ mod data; mod encoding; mod layer; -mod project; +mod projection; use crate::plot::ArrayElement; use crate::plot::{ParameterValue, Scale, ScaleTypeKind}; @@ -36,12 +36,12 @@ use serde_json::{json, Value}; use std::collections::HashMap; // Re-export submodule functions for use in write() -use project::apply_project_transforms; use data::{collect_binned_columns, is_binned_aesthetic, unify_datasets}; use encoding::{ build_detail_encoding, build_encoding_channel, infer_field_type, map_aesthetic_name, }; use layer::{geom_to_mark, get_renderer, validate_layer_columns, GeomRenderer, PreparedData}; +use projection::apply_project_transforms; /// Conversion factor from points to pixels (CSS standard: 96 DPI, 72 points/inch) /// 1 point = 96/72 pixels = 1.333 diff --git a/src/writer/vegalite/project.rs b/src/writer/vegalite/projection.rs similarity index 69% rename from src/writer/vegalite/project.rs rename to src/writer/vegalite/projection.rs index 1c668480..e68ea6ec 100644 --- a/src/writer/vegalite/project.rs +++ b/src/writer/vegalite/projection.rs @@ -4,7 +4,7 @@ //! that modify the Vega-Lite spec structure based on the PROJECT clause. use crate::plot::aesthetic::is_aesthetic_name; -use crate::plot::{Coord, ParameterValue, Projection}; +use crate::plot::{CoordKind, ParameterValue, Projection}; use crate::{DataFrame, GgsqlError, Plot, Result}; use serde_json::{json, Value}; @@ -19,67 +19,42 @@ pub(super) fn apply_project_transforms( free_y: bool, ) -> Result> { if let Some(ref project) = spec.project { - match project.coord { - Coord::Cartesian => { + match project.coord.coord_kind() { + CoordKind::Cartesian => { apply_cartesian_project(project, vl_spec, free_x, free_y)?; Ok(None) // No DataFrame transformation needed } - Coord::Flip => { + CoordKind::Flip => { apply_flip_project(vl_spec)?; Ok(None) // No DataFrame transformation needed } - Coord::Polar => { + CoordKind::Polar => { // Polar requires DataFrame transformation for percentages let transformed_df = apply_polar_project(project, spec, data, vl_spec)?; Ok(Some(transformed_df)) } - _ => { - // Other coord types not yet implemented - Ok(None) - } } } else { Ok(None) } } -/// Apply Cartesian projection properties (xlim, ylim, aesthetic domains) -/// Skip applying axis limits if facets have free scales for that axis +/// Apply Cartesian projection properties (aesthetic domains) +/// Note: xlim/ylim removed - use SCALE x/y FROM instead fn apply_cartesian_project( project: &Projection, vl_spec: &mut Value, - free_x: bool, - free_y: bool, + _free_x: bool, + _free_y: bool, ) -> Result<()> { - // Apply xlim/ylim to scale domains for (prop_name, prop_value) in &project.properties { - match prop_name.as_str() { - "xlim" => { - // Don't apply xlim if facets have free x scales - if !free_x { - if let Some(limits) = extract_limits(prop_value)? { - apply_axis_limits(vl_spec, "x", limits)?; - } - } - } - "ylim" => { - // Don't apply ylim if facets have free y scales - if !free_y { - if let Some(limits) = extract_limits(prop_value)? { - apply_axis_limits(vl_spec, "y", limits)?; - } - } - } - _ if is_aesthetic_name(prop_name) => { - // Aesthetic domain specification - if let Some(domain) = extract_input_range(prop_value)? { - apply_aesthetic_input_range(vl_spec, prop_name, domain)?; - } - } - _ => { - // ratio, clip - not yet implemented (TODO comments added by validation) + if is_aesthetic_name(prop_name) { + // Aesthetic domain specification + if let Some(domain) = extract_input_range(prop_value)? { + apply_aesthetic_input_range(vl_spec, prop_name, domain)?; } } + // ratio, clip - not yet implemented } Ok(()) @@ -230,33 +205,6 @@ fn update_encoding_for_polar(encoding: &mut Value, theta_aesthetic: &str) -> Res // Helper methods -fn extract_limits(value: &ParameterValue) -> Result> { - match value { - ParameterValue::Array(arr) => { - if arr.len() != 2 { - return Err(GgsqlError::WriterError(format!( - "xlim/ylim must be exactly 2 numbers, got {}", - arr.len() - ))); - } - let min = arr[0].to_f64().ok_or_else(|| { - GgsqlError::WriterError("xlim/ylim values must be numeric".to_string()) - })?; - let max = arr[1].to_f64().ok_or_else(|| { - GgsqlError::WriterError("xlim/ylim values must be numeric".to_string()) - })?; - - // Auto-swap if reversed - let (min, max) = if min > max { (max, min) } else { (min, max) }; - - Ok(Some((min, max))) - } - _ => Err(GgsqlError::WriterError( - "xlim/ylim must be an array".to_string(), - )), - } -} - fn extract_input_range(value: &ParameterValue) -> Result>> { match value { ParameterValue::Array(arr) => { @@ -267,24 +215,6 @@ fn extract_input_range(value: &ParameterValue) -> Result>> { } } -fn apply_axis_limits(vl_spec: &mut Value, axis: &str, limits: (f64, f64)) -> Result<()> { - let domain = json!([limits.0, limits.1]); - - if let Some(layers) = vl_spec.get_mut("layer") { - if let Some(layers_arr) = layers.as_array_mut() { - for layer in layers_arr { - if let Some(encoding) = layer.get_mut("encoding") { - if let Some(axis_enc) = encoding.get_mut(axis) { - axis_enc["scale"] = json!({"domain": domain}); - } - } - } - } - } - - Ok(()) -} - fn apply_aesthetic_input_range( vl_spec: &mut Value, aesthetic: &str, diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index 07ef06a5..becc3f24 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -761,7 +761,7 @@ module.exports = grammar({ ), project_type: $ => choice( - 'cartesian', 'polar', 'flip', 'fixed', 'trans', 'map', 'quickmap' + 'cartesian', 'polar', 'flip' ), project_properties: $ => seq( @@ -776,7 +776,7 @@ module.exports = grammar({ ), project_property_name: $ => choice( - 'xlim', 'ylim', 'ratio', 'theta', 'clip', + 'ratio', 'theta', 'clip', // Also allow aesthetic names as properties (for range specification) $.aesthetic_name ), diff --git a/tree-sitter-ggsql/test/corpus/basic.txt b/tree-sitter-ggsql/test/corpus/basic.txt index ef8d394d..1ef39a67 100644 --- a/tree-sitter-ggsql/test/corpus/basic.txt +++ b/tree-sitter-ggsql/test/corpus/basic.txt @@ -531,12 +531,12 @@ LABEL title => 'My Plot', x => 'X Axis', y => 'Y Axis' (string)))))) ================================================================================ -PROJECT cartesian with xlim +PROJECT cartesian with ratio ================================================================================ VISUALISE x, y DRAW point -PROJECT cartesian SETTING xlim => [0, 100] +PROJECT cartesian SETTING ratio => 1 -------------------------------------------------------------------------------- @@ -562,11 +562,7 @@ PROJECT cartesian SETTING xlim => [0, 100] (project_properties (project_property (project_property_name) - (array - (array_element - (number)) - (array_element - (number))))))))) + (number))))))) ================================================================================ PROJECT flip From c1ee3b2a42e94425291f683ae442bb73b5a5e83c Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 23 Feb 2026 21:47:10 +0100 Subject: [PATCH 04/28] remove hallucinated functionality --- CLAUDE.md | 35 ++--- EXAMPLES.md | 7 +- src/parser/builder.rs | 190 ------------------------- src/plot/projection/coord/cartesian.rs | 51 +------ src/plot/projection/coord/flip.rs | 63 +------- src/plot/projection/coord/mod.rs | 28 +--- src/plot/projection/coord/polar.rs | 47 +----- src/writer/vegalite/projection.rs | 53 +------ tree-sitter-ggsql/grammar.js | 4 +- 9 files changed, 35 insertions(+), 443 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 329ed4b4..f45a1c2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1348,8 +1348,6 @@ SCALE color FROM ['A', 'B'] TO ['red', 'blue'] SCALE color TO viridis ``` -**Note**: Cannot specify range in both SCALE and PROJECT for the same aesthetic (will error). - **Examples**: ```sql @@ -1448,26 +1446,24 @@ PROJECT SETTING **Cartesian**: -- `ratio => ` - Set aspect ratio -- ` => [values...]` - Set range for any aesthetic (color, fill, size, etc.) +- `ratio => ` - Set aspect ratio (not yet implemented) -Note: For axis limits, use `SCALE x FROM [min, max]` or `SCALE y FROM [min, max]` instead. +Note: For axis limits, use `SCALE x FROM [min, max]` or `SCALE y FROM [min, max]`. **Flip**: -- ` => [values...]` - Set range for any aesthetic +- No SETTING properties (just transforms the coordinate system) **Polar**: - `theta => ` - Which aesthetic maps to angle (defaults to `y`) -- ` => [values...]` - Set range for any aesthetic **Important Notes**: -1. **Axis limits**: Use `SCALE x/y FROM [min, max]` to set axis limits (not PROJECT) -2. **ggplot2 compatibility**: `project_flip` preserves axis label names (labels stay with aesthetic names, not visual position) -3. **Range conflicts**: Error if same aesthetic has input range in both SCALE and PROJECT -4. **Multi-layer support**: All projectinate transforms apply to all layers +1. **Axis limits**: Use `SCALE x/y FROM [min, max]` to set axis limits +2. **Aesthetic domains**: Use `SCALE FROM [...]` to set aesthetic domains +3. **ggplot2 compatibility**: `project_flip` preserves axis label names (labels stay with aesthetic names, not visual position) +4. **Multi-layer support**: All projection transforms apply to all layers **Status**: @@ -1479,29 +1475,20 @@ Note: For axis limits, use `SCALE x FROM [min, max]` or `SCALE y FROM [min, max] **Examples**: ```sql --- Cartesian with axis limits (use SCALE, not PROJECT) -SCALE x FROM [0, 100] -SCALE y FROM [0, 50] - --- Cartesian with aesthetic range -PROJECT cartesian SETTING color => ['red', 'green', 'blue'] - --- Flip projectinates for horizontal bar chart +-- Flip projection for horizontal bar chart PROJECT flip --- Flip with aesthetic range -PROJECT flip SETTING color => ['A', 'B', 'C'] - -- Polar for pie chart (theta defaults to y) PROJECT polar --- Polar for rose plot (x maps to radius) -PROJECT polar SETTING theta => y +-- Polar with explicit theta mapping +PROJECT polar SETTING theta => x -- Combined with other clauses DRAW bar MAPPING category AS x, value AS y SCALE x FROM [0, 100] SCALE y FROM [0, 200] +PROJECT flip LABEL x => 'Category', y => 'Count' ``` diff --git a/EXAMPLES.md b/EXAMPLES.md index 4e04dff8..c1aaf17d 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -489,8 +489,9 @@ ORDER BY total_revenue DESC LIMIT 10 VISUALISE product_name AS x, total_revenue AS y, product_name AS fill DRAW bar -PROJECT flip SETTING color => ['red', 'orange', 'yellow', 'green', 'blue', - 'indigo', 'violet', 'pink', 'brown', 'gray'] +PROJECT flip +SCALE fill TO ['red', 'orange', 'yellow', 'green', 'blue', + 'indigo', 'violet', 'pink', 'brown', 'gray'] LABEL title => 'Top 10 Products by Revenue', x => 'Product', y => 'Revenue ($)' @@ -642,7 +643,7 @@ Draw Line 8. **Labels**: Always provide meaningful titles and axis labels for clarity. -9. **Range Specification**: Use either SCALE or PROJECT for range/limit specification, but not both for the same aesthetic. +9. **Range Specification**: Use SCALE for all axis limits and aesthetic domain specifications. --- diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 1728ea1a..542cc3d0 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -3,7 +3,6 @@ //! Takes a tree-sitter parse tree and builds a typed Plot, //! handling all the node types defined in the grammar. -use crate::plot::aesthetic::is_aesthetic_name; use crate::plot::layer::geom::Geom; use crate::plot::scale::{color_to_hex, is_color_aesthetic, is_facet_aesthetic, Transform}; use crate::plot::*; @@ -271,9 +270,6 @@ fn build_visualise_statement(node: &Node, source: &SourceTree) -> Result { } } - // Validate no conflicts between SCALE and PROJECT input range specifications - validate_scale_project_conflicts(&spec)?; - Ok(spec) } @@ -1065,34 +1061,6 @@ fn build_theme(node: &Node, source: &SourceTree) -> Result { // Validation & Utilities // ============================================================================ -/// Check for conflicts between SCALE input range and PROJECT aesthetic input range specifications -fn validate_scale_project_conflicts(spec: &Plot) -> Result<()> { - if let Some(ref project) = spec.project { - // Get all aesthetic names that have input ranges in PROJECT - let project_aesthetics: Vec = project - .properties - .keys() - .filter(|k| is_aesthetic_name(k)) - .cloned() - .collect(); - - // Check if any of these also have input range in SCALE - for aesthetic in project_aesthetics { - for scale in &spec.scales { - if scale.aesthetic == aesthetic && scale.input_range.is_some() { - return Err(GgsqlError::ParseError(format!( - "Input range for '{}' specified in both SCALE and PROJECT clauses. \ - Please specify input range in only one location.", - aesthetic - ))); - } - } - } - } - - Ok(()) -} - /// Check if the last SQL statement in sql_portion is a SELECT statement fn check_last_statement_is_select(sql_portion_node: &Node, source: &SourceTree) -> bool { // Find all sql_statement nodes and get the last one (can use query for this) @@ -1164,22 +1132,6 @@ mod tests { // PROJECT Property Validation Tests // ======================================== - #[test] - fn test_project_cartesian_valid_aesthetic_input_range() { - let query = r#" - VISUALISE - DRAW point MAPPING x AS x, y AS y, category AS color - PROJECT cartesian SETTING color => ['red', 'green', 'blue'] - "#; - - let result = parse_test_query(query); - assert!(result.is_ok()); - let specs = result.unwrap(); - - let project = specs[0].project.as_ref().unwrap(); - assert!(project.properties.contains_key("color")); - } - #[test] fn test_project_cartesian_invalid_property_theta() { let query = r#" @@ -1196,26 +1148,6 @@ mod tests { .contains("Property 'theta' not valid for cartesian")); } - #[test] - fn test_project_flip_valid_aesthetic_input_range() { - let query = r#" - VISUALISE - DRAW bar MAPPING category AS x, value AS y, region AS color - PROJECT flip SETTING color => ['A', 'B', 'C'] - "#; - - let result = parse_test_query(query); - assert!(result.is_ok()); - let specs = result.unwrap(); - - let project = specs[0].project.as_ref().unwrap(); - assert_eq!(project.coord.coord_kind(), CoordKind::Flip); - assert!(project.properties.contains_key("color")); - } - - // xlim/ylim tests removed - these are no longer valid grammar tokens - // Use SCALE x/y FROM instead to set axis limits - #[test] fn test_project_flip_invalid_property_theta() { let query = r#" @@ -1249,128 +1181,6 @@ mod tests { assert!(project.properties.contains_key("theta")); } - #[test] - fn test_project_polar_valid_aesthetic_input_range() { - let query = r#" - VISUALISE - DRAW bar MAPPING category AS x, value AS y, region AS color - PROJECT polar SETTING color => ['North', 'South', 'East', 'West'] - "#; - - let result = parse_test_query(query); - assert!(result.is_ok()); - let specs = result.unwrap(); - - let project = specs[0].project.as_ref().unwrap(); - assert!(project.properties.contains_key("color")); - } - - // xlim/ylim polar rejection tests removed - these are no longer valid grammar tokens - - // ======================================== - // SCALE/PROJECT Input Range Conflict Tests - // ======================================== - - #[test] - fn test_scale_project_conflict_x_input_range() { - let query = r#" - VISUALISE - DRAW point MAPPING x AS x, y AS y - SCALE x FROM [0, 100] - PROJECT cartesian SETTING x => [0, 50] - "#; - - let result = parse_test_query(query); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err - .to_string() - .contains("Input range for 'x' specified in both SCALE and PROJECT")); - } - - #[test] - fn test_scale_project_conflict_color_input_range() { - let query = r#" - VISUALISE - DRAW point MAPPING x AS x, y AS y, category AS color - SCALE color FROM ['A', 'B'] - PROJECT cartesian SETTING color => ['A', 'B', 'C'] - "#; - - let result = parse_test_query(query); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err - .to_string() - .contains("Input range for 'color' specified in both SCALE and PROJECT")); - } - - #[test] - fn test_scale_project_no_conflict_different_aesthetics() { - let query = r#" - VISUALISE - DRAW point MAPPING x AS x, y AS y, category AS color, region AS size - SCALE color FROM ['A', 'B'] - PROJECT cartesian SETTING size => [5, 20] - "#; - - let result = parse_test_query(query); - assert!(result.is_ok()); - } - - #[test] - fn test_scale_project_no_conflict_scale_without_input_range() { - let query = r#" - VISUALISE - DRAW point MAPPING x AS x, y AS y - SCALE CONTINUOUS x - PROJECT cartesian SETTING x => [0, 100] - "#; - - let result = parse_test_query(query); - assert!(result.is_ok()); - } - - // ======================================== - // Multiple Properties Tests - // ======================================== - - #[test] - fn test_project_cartesian_multiple_properties() { - let query = r#" - VISUALISE - DRAW point MAPPING x AS x, y AS y, category AS color - PROJECT cartesian SETTING color => ['A', 'B'] - "#; - - let result = parse_test_query(query); - assert!(result.is_ok()); - let specs = result.unwrap(); - - let project = specs[0].project.as_ref().unwrap(); - // xlim/ylim removed - use SCALE x/y FROM instead - assert!(!project.properties.contains_key("xlim")); - assert!(!project.properties.contains_key("ylim")); - assert!(project.properties.contains_key("color")); - } - - #[test] - fn test_project_polar_theta_with_aesthetic() { - let query = r#" - VISUALISE - DRAW bar MAPPING category AS x, value AS y, region AS color - PROJECT polar SETTING theta => y, color => ['North', 'South'] - "#; - - let result = parse_test_query(query); - assert!(result.is_ok()); - let specs = result.unwrap(); - - let project = specs[0].project.as_ref().unwrap(); - assert!(project.properties.contains_key("theta")); - assert!(project.properties.contains_key("color")); - } - // ======================================== // Case Insensitive Keywords Tests // ======================================== diff --git a/src/plot/projection/coord/cartesian.rs b/src/plot/projection/coord/cartesian.rs index 62aa9ca5..f94e77f8 100644 --- a/src/plot/projection/coord/cartesian.rs +++ b/src/plot/projection/coord/cartesian.rs @@ -18,10 +18,6 @@ impl CoordTrait for Cartesian { fn allowed_properties(&self) -> &'static [&'static str] { &["ratio"] } - - fn allows_aesthetic_properties(&self) -> bool { - true - } } impl std::fmt::Display for Cartesian { @@ -33,7 +29,7 @@ impl std::fmt::Display for Cartesian { #[cfg(test)] mod tests { use super::*; - use crate::plot::{ArrayElement, ParameterValue}; + use crate::plot::ParameterValue; use std::collections::HashMap; #[test] @@ -41,7 +37,6 @@ mod tests { let cartesian = Cartesian; assert_eq!(cartesian.coord_kind(), CoordKind::Cartesian); assert_eq!(cartesian.name(), "cartesian"); - assert!(cartesian.allows_aesthetic_properties()); } #[test] @@ -49,9 +44,6 @@ mod tests { let cartesian = Cartesian; let allowed = cartesian.allowed_properties(); assert!(allowed.contains(&"ratio")); - // xlim/ylim removed - use SCALE x/y FROM instead - assert!(!allowed.contains(&"xlim")); - assert!(!allowed.contains(&"ylim")); } #[test] @@ -64,53 +56,18 @@ mod tests { } #[test] - fn test_cartesian_rejects_xlim() { - let cartesian = Cartesian; - let mut props = HashMap::new(); - props.insert( - "xlim".to_string(), - ParameterValue::Array(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]), - ); - - let resolved = cartesian.resolve_properties(&props); - assert!(resolved.is_err()); - let err = resolved.unwrap_err(); - assert!(err.contains("xlim")); - assert!(err.contains("not valid")); - } - - #[test] - fn test_cartesian_rejects_ylim() { + fn test_cartesian_rejects_unknown_property() { let cartesian = Cartesian; let mut props = HashMap::new(); - props.insert( - "ylim".to_string(), - ParameterValue::Array(vec![ArrayElement::Number(0.0), ArrayElement::Number(50.0)]), - ); + props.insert("unknown".to_string(), ParameterValue::String("value".to_string())); let resolved = cartesian.resolve_properties(&props); assert!(resolved.is_err()); let err = resolved.unwrap_err(); - assert!(err.contains("ylim")); + assert!(err.contains("unknown")); assert!(err.contains("not valid")); } - #[test] - fn test_cartesian_accepts_aesthetic_properties() { - let cartesian = Cartesian; - let mut props = HashMap::new(); - props.insert( - "color".to_string(), - ParameterValue::Array(vec![ - ArrayElement::String("red".to_string()), - ArrayElement::String("blue".to_string()), - ]), - ); - - let resolved = cartesian.resolve_properties(&props); - assert!(resolved.is_ok()); - } - #[test] fn test_cartesian_rejects_theta() { let cartesian = Cartesian; diff --git a/src/plot/projection/coord/flip.rs b/src/plot/projection/coord/flip.rs index 71798370..492bd980 100644 --- a/src/plot/projection/coord/flip.rs +++ b/src/plot/projection/coord/flip.rs @@ -15,13 +15,7 @@ impl CoordTrait for Flip { "flip" } - fn allowed_properties(&self) -> &'static [&'static str] { - &[] // Flip only allows aesthetic properties - } - - fn allows_aesthetic_properties(&self) -> bool { - true - } + // Flip has no SETTING properties } impl std::fmt::Display for Flip { @@ -33,7 +27,7 @@ impl std::fmt::Display for Flip { #[cfg(test)] mod tests { use super::*; - use crate::plot::{ArrayElement, ParameterValue}; + use crate::plot::ParameterValue; use std::collections::HashMap; #[test] @@ -41,65 +35,16 @@ mod tests { let flip = Flip; assert_eq!(flip.coord_kind(), CoordKind::Flip); assert_eq!(flip.name(), "flip"); - assert!(flip.allows_aesthetic_properties()); } #[test] - fn test_flip_no_specific_properties() { + fn test_flip_no_properties() { let flip = Flip; assert!(flip.allowed_properties().is_empty()); } #[test] - fn test_flip_accepts_aesthetic_properties() { - let flip = Flip; - let mut props = HashMap::new(); - props.insert( - "color".to_string(), - ParameterValue::Array(vec![ - ArrayElement::String("red".to_string()), - ArrayElement::String("blue".to_string()), - ]), - ); - - let resolved = flip.resolve_properties(&props); - assert!(resolved.is_ok()); - } - - #[test] - fn test_flip_rejects_xlim() { - let flip = Flip; - let mut props = HashMap::new(); - props.insert( - "xlim".to_string(), - ParameterValue::Array(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]), - ); - - let resolved = flip.resolve_properties(&props); - assert!(resolved.is_err()); - let err = resolved.unwrap_err(); - assert!(err.contains("xlim")); - assert!(err.contains("not valid")); - } - - #[test] - fn test_flip_rejects_ylim() { - let flip = Flip; - let mut props = HashMap::new(); - props.insert( - "ylim".to_string(), - ParameterValue::Array(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]), - ); - - let resolved = flip.resolve_properties(&props); - assert!(resolved.is_err()); - let err = resolved.unwrap_err(); - assert!(err.contains("ylim")); - assert!(err.contains("not valid")); - } - - #[test] - fn test_flip_rejects_theta() { + fn test_flip_rejects_any_property() { let flip = Flip; let mut props = HashMap::new(); props.insert("theta".to_string(), ParameterValue::String("y".to_string())); diff --git a/src/plot/projection/coord/mod.rs b/src/plot/projection/coord/mod.rs index c74a7bd2..42b2adaf 100644 --- a/src/plot/projection/coord/mod.rs +++ b/src/plot/projection/coord/mod.rs @@ -24,7 +24,6 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; -use crate::plot::aesthetic::is_aesthetic_name; use crate::plot::ParameterValue; // Coord type implementations @@ -85,39 +84,23 @@ pub trait CoordTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { &[] } - /// Returns whether this coord type allows aesthetic names as properties. - /// Default: false. - fn allows_aesthetic_properties(&self) -> bool { - false - } - /// Returns default value for a property, if any. fn get_property_default(&self, _name: &str) -> Option { None } /// Resolve and validate properties. - /// Default implementation validates against allowed_properties + aesthetics. + /// Default implementation validates against allowed_properties. fn resolve_properties( &self, properties: &HashMap, ) -> Result, String> { let allowed = self.allowed_properties(); - let allows_aesthetics = self.allows_aesthetic_properties(); // Check for unknown properties for key in properties.keys() { - let is_allowed = - allowed.contains(&key.as_str()) || (allows_aesthetics && is_aesthetic_name(key)); - - if !is_allowed { - let valid_props = if allows_aesthetics { - if allowed.is_empty() { - "".to_string() - } else { - format!("{}, ", allowed.join(", ")) - } - } else if allowed.is_empty() { + if !allowed.contains(&key.as_str()) { + let valid_props = if allowed.is_empty() { "none".to_string() } else { allowed.join(", ") @@ -196,11 +179,6 @@ impl Coord { self.0.allowed_properties() } - /// Returns whether this coord type allows aesthetic names as properties. - pub fn allows_aesthetic_properties(&self) -> bool { - self.0.allows_aesthetic_properties() - } - /// Returns default value for a property, if any. pub fn get_property_default(&self, name: &str) -> Option { self.0.get_property_default(name) diff --git a/src/plot/projection/coord/polar.rs b/src/plot/projection/coord/polar.rs index 1f0233c3..725e569d 100644 --- a/src/plot/projection/coord/polar.rs +++ b/src/plot/projection/coord/polar.rs @@ -20,10 +20,6 @@ impl CoordTrait for Polar { &["theta"] } - fn allows_aesthetic_properties(&self) -> bool { - true - } - fn get_property_default(&self, name: &str) -> Option { match name { "theta" => Some(ParameterValue::String("y".to_string())), @@ -41,7 +37,6 @@ impl std::fmt::Display for Polar { #[cfg(test)] mod tests { use super::*; - use crate::plot::ArrayElement; use std::collections::HashMap; #[test] @@ -49,7 +44,6 @@ mod tests { let polar = Polar; assert_eq!(polar.coord_kind(), CoordKind::Polar); assert_eq!(polar.name(), "polar"); - assert!(polar.allows_aesthetic_properties()); } #[test] @@ -99,50 +93,15 @@ mod tests { } #[test] - fn test_polar_accepts_aesthetic_properties() { - let polar = Polar; - let mut props = HashMap::new(); - props.insert( - "color".to_string(), - ParameterValue::Array(vec![ - ArrayElement::String("red".to_string()), - ArrayElement::String("blue".to_string()), - ]), - ); - - let resolved = polar.resolve_properties(&props); - assert!(resolved.is_ok()); - } - - #[test] - fn test_polar_rejects_xlim() { + fn test_polar_rejects_unknown_property() { let polar = Polar; let mut props = HashMap::new(); - props.insert( - "xlim".to_string(), - ParameterValue::Array(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]), - ); - - let resolved = polar.resolve_properties(&props); - assert!(resolved.is_err()); - let err = resolved.unwrap_err(); - assert!(err.contains("xlim")); - assert!(err.contains("not valid")); - } - - #[test] - fn test_polar_rejects_ylim() { - let polar = Polar; - let mut props = HashMap::new(); - props.insert( - "ylim".to_string(), - ParameterValue::Array(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]), - ); + props.insert("unknown".to_string(), ParameterValue::String("value".to_string())); let resolved = polar.resolve_properties(&props); assert!(resolved.is_err()); let err = resolved.unwrap_err(); - assert!(err.contains("ylim")); + assert!(err.contains("unknown")); assert!(err.contains("not valid")); } } diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index e68ea6ec..f890b27c 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -3,7 +3,6 @@ //! This module handles projection transformations (cartesian, flip, polar) //! that modify the Vega-Lite spec structure based on the PROJECT clause. -use crate::plot::aesthetic::is_aesthetic_name; use crate::plot::{CoordKind, ParameterValue, Projection}; use crate::{DataFrame, GgsqlError, Plot, Result}; use serde_json::{json, Value}; @@ -39,24 +38,15 @@ pub(super) fn apply_project_transforms( } } -/// Apply Cartesian projection properties (aesthetic domains) -/// Note: xlim/ylim removed - use SCALE x/y FROM instead +/// Apply Cartesian projection properties +/// Currently only ratio is supported (not yet implemented) fn apply_cartesian_project( - project: &Projection, - vl_spec: &mut Value, + _project: &Projection, + _vl_spec: &mut Value, _free_x: bool, _free_y: bool, ) -> Result<()> { - for (prop_name, prop_value) in &project.properties { - if is_aesthetic_name(prop_name) { - // Aesthetic domain specification - if let Some(domain) = extract_input_range(prop_value)? { - apply_aesthetic_input_range(vl_spec, prop_name, domain)?; - } - } - // ratio, clip - not yet implemented - } - + // ratio, clip - not yet implemented Ok(()) } @@ -203,36 +193,3 @@ fn update_encoding_for_polar(encoding: &mut Value, theta_aesthetic: &str) -> Res Ok(()) } -// Helper methods - -fn extract_input_range(value: &ParameterValue) -> Result>> { - match value { - ParameterValue::Array(arr) => { - let domain: Vec = arr.iter().map(|elem| elem.to_json()).collect(); - Ok(Some(domain)) - } - _ => Ok(None), - } -} - -fn apply_aesthetic_input_range( - vl_spec: &mut Value, - aesthetic: &str, - domain: Vec, -) -> Result<()> { - let domain_json = json!(domain); - - if let Some(layers) = vl_spec.get_mut("layer") { - if let Some(layers_arr) = layers.as_array_mut() { - for layer in layers_arr { - if let Some(encoding) = layer.get_mut("encoding") { - if let Some(aes_enc) = encoding.get_mut(aesthetic) { - aes_enc["scale"] = json!({"domain": domain_json}); - } - } - } - } - } - - Ok(()) -} diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index becc3f24..4fcde76f 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -776,9 +776,7 @@ module.exports = grammar({ ), project_property_name: $ => choice( - 'ratio', 'theta', 'clip', - // Also allow aesthetic names as properties (for range specification) - $.aesthetic_name + 'ratio', 'theta', 'clip' ), // LABEL clause (repeatable) From 474dcf785cf650055ae38c42131b8b56092e05bd Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 23 Feb 2026 22:02:09 +0100 Subject: [PATCH 05/28] implement clip functionality --- CLAUDE.md | 13 ++++++-- src/plot/projection/coord/cartesian.rs | 3 +- src/plot/projection/coord/flip.rs | 21 +++++++++--- src/plot/projection/coord/polar.rs | 5 +-- src/writer/vegalite/projection.rs | 46 ++++++++++++++++++-------- 5 files changed, 65 insertions(+), 23 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f45a1c2c..2047c67c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1442,7 +1442,11 @@ PROJECT SETTING - **`map`** - Map projections - **`quickmap`** - Quick approximation for maps -**Properties by Type**: +**Common Properties** (all projection types): + +- `clip => ` - Whether to clip marks outside the plot area (default: unset) + +**Type-Specific Properties**: **Cartesian**: @@ -1452,7 +1456,7 @@ Note: For axis limits, use `SCALE x FROM [min, max]` or `SCALE y FROM [min, max] **Flip**: -- No SETTING properties (just transforms the coordinate system) +- No additional properties **Polar**: @@ -1484,11 +1488,14 @@ PROJECT polar -- Polar with explicit theta mapping PROJECT polar SETTING theta => x +-- Clip marks to plot area +PROJECT cartesian SETTING clip => true + -- Combined with other clauses DRAW bar MAPPING category AS x, value AS y SCALE x FROM [0, 100] SCALE y FROM [0, 200] -PROJECT flip +PROJECT flip SETTING clip => true LABEL x => 'Category', y => 'Count' ``` diff --git a/src/plot/projection/coord/cartesian.rs b/src/plot/projection/coord/cartesian.rs index f94e77f8..d196aa90 100644 --- a/src/plot/projection/coord/cartesian.rs +++ b/src/plot/projection/coord/cartesian.rs @@ -16,7 +16,7 @@ impl CoordTrait for Cartesian { } fn allowed_properties(&self) -> &'static [&'static str] { - &["ratio"] + &["ratio", "clip"] } } @@ -44,6 +44,7 @@ mod tests { let cartesian = Cartesian; let allowed = cartesian.allowed_properties(); assert!(allowed.contains(&"ratio")); + assert!(allowed.contains(&"clip")); } #[test] diff --git a/src/plot/projection/coord/flip.rs b/src/plot/projection/coord/flip.rs index 492bd980..7912aee9 100644 --- a/src/plot/projection/coord/flip.rs +++ b/src/plot/projection/coord/flip.rs @@ -15,7 +15,9 @@ impl CoordTrait for Flip { "flip" } - // Flip has no SETTING properties + fn allowed_properties(&self) -> &'static [&'static str] { + &["clip"] + } } impl std::fmt::Display for Flip { @@ -38,13 +40,24 @@ mod tests { } #[test] - fn test_flip_no_properties() { + fn test_flip_allowed_properties() { + let flip = Flip; + let allowed = flip.allowed_properties(); + assert!(allowed.contains(&"clip")); + } + + #[test] + fn test_flip_accepts_clip() { let flip = Flip; - assert!(flip.allowed_properties().is_empty()); + let mut props = HashMap::new(); + props.insert("clip".to_string(), ParameterValue::Boolean(true)); + + let resolved = flip.resolve_properties(&props); + assert!(resolved.is_ok()); } #[test] - fn test_flip_rejects_any_property() { + fn test_flip_rejects_theta() { let flip = Flip; let mut props = HashMap::new(); props.insert("theta".to_string(), ParameterValue::String("y".to_string())); diff --git a/src/plot/projection/coord/polar.rs b/src/plot/projection/coord/polar.rs index 725e569d..e8a2ae53 100644 --- a/src/plot/projection/coord/polar.rs +++ b/src/plot/projection/coord/polar.rs @@ -17,7 +17,7 @@ impl CoordTrait for Polar { } fn allowed_properties(&self) -> &'static [&'static str] { - &["theta"] + &["theta", "clip"] } fn get_property_default(&self, name: &str) -> Option { @@ -51,7 +51,8 @@ mod tests { let polar = Polar; let allowed = polar.allowed_properties(); assert!(allowed.contains(&"theta")); - assert_eq!(allowed.len(), 1); + assert!(allowed.contains(&"clip")); + assert_eq!(allowed.len(), 2); } #[test] diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index f890b27c..1851c374 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -18,35 +18,59 @@ pub(super) fn apply_project_transforms( free_y: bool, ) -> Result> { if let Some(ref project) = spec.project { - match project.coord.coord_kind() { + // Apply coord-specific transformations + let result = match project.coord.coord_kind() { CoordKind::Cartesian => { apply_cartesian_project(project, vl_spec, free_x, free_y)?; - Ok(None) // No DataFrame transformation needed + None } CoordKind::Flip => { apply_flip_project(vl_spec)?; - Ok(None) // No DataFrame transformation needed + None } CoordKind::Polar => { - // Polar requires DataFrame transformation for percentages - let transformed_df = apply_polar_project(project, spec, data, vl_spec)?; - Ok(Some(transformed_df)) + Some(apply_polar_project(project, spec, data, vl_spec)?) } + }; + + // Apply clip setting (applies to all projection types) + if let Some(ParameterValue::Boolean(clip)) = project.properties.get("clip") { + apply_clip_to_layers(vl_spec, *clip); } + + Ok(result) } else { Ok(None) } } +/// Apply clip setting to all layers +fn apply_clip_to_layers(vl_spec: &mut Value, clip: bool) { + if let Some(layers) = vl_spec.get_mut("layer") { + if let Some(layers_arr) = layers.as_array_mut() { + for layer in layers_arr { + if let Some(mark) = layer.get_mut("mark") { + if mark.is_string() { + // Convert "point" to {"type": "point", "clip": ...} + let mark_type = mark.as_str().unwrap().to_string(); + *mark = json!({"type": mark_type, "clip": clip}); + } else if let Some(obj) = mark.as_object_mut() { + obj.insert("clip".to_string(), json!(clip)); + } + } + } + } + } +} + /// Apply Cartesian projection properties -/// Currently only ratio is supported (not yet implemented) fn apply_cartesian_project( _project: &Projection, _vl_spec: &mut Value, _free_x: bool, _free_y: bool, ) -> Result<()> { - // ratio, clip - not yet implemented + // ratio - not yet implemented Ok(()) } @@ -118,7 +142,6 @@ fn convert_geoms_to_polar(spec: &Plot, vl_spec: &mut Value, theta_field: &str) - } /// Convert a mark type to its polar equivalent -/// Preserves `clip: true` to ensure marks don't render outside plot bounds fn convert_mark_to_polar(mark: &Value, _spec: &Plot) -> Result { let mark_str = if mark.is_string() { mark.as_str().unwrap() @@ -153,10 +176,7 @@ fn convert_mark_to_polar(mark: &Value, _spec: &Plot) -> Result { } }; - Ok(json!({ - "type": polar_mark, - "clip": true - })) + Ok(json!(polar_mark)) } /// Update encoding channels for polar projection From 01676c81cb5224b57c2331713399be7d20b43044 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Tue, 24 Feb 2026 15:45:25 +0100 Subject: [PATCH 06/28] parameterise positional aesthetics --- src/execute/mod.rs | 41 +- src/execute/scale.rs | 86 +-- src/lib.rs | 44 +- src/naming.rs | 24 + src/parser/builder.rs | 62 +- src/parser/mod.rs | 23 +- src/plot/aesthetic.rs | 801 ++++++++++++++++++++---- src/plot/layer/geom/area.rs | 6 +- src/plot/layer/geom/arrow.rs | 10 +- src/plot/layer/geom/bar.rs | 22 +- src/plot/layer/geom/boxplot.rs | 46 +- src/plot/layer/geom/density.rs | 36 +- src/plot/layer/geom/errorbar.rs | 12 +- src/plot/layer/geom/histogram.rs | 20 +- src/plot/layer/geom/hline.rs | 4 +- src/plot/layer/geom/label.rs | 4 +- src/plot/layer/geom/line.rs | 4 +- src/plot/layer/geom/mod.rs | 8 +- src/plot/layer/geom/path.rs | 4 +- src/plot/layer/geom/point.rs | 6 +- src/plot/layer/geom/polygon.rs | 6 +- src/plot/layer/geom/ribbon.rs | 8 +- src/plot/layer/geom/segment.rs | 10 +- src/plot/layer/geom/smooth.rs | 4 +- src/plot/layer/geom/text.rs | 4 +- src/plot/layer/geom/tile.rs | 4 +- src/plot/layer/geom/types.rs | 4 +- src/plot/layer/geom/violin.rs | 38 +- src/plot/layer/geom/vline.rs | 4 +- src/plot/main.rs | 228 +++++-- src/plot/projection/coord/cartesian.rs | 4 + src/plot/projection/coord/flip.rs | 71 --- src/plot/projection/coord/mod.rs | 54 +- src/plot/projection/coord/polar.rs | 4 + src/plot/scale/mod.rs | 19 +- src/plot/scale/scale_type/continuous.rs | 3 +- src/plot/scale/scale_type/mod.rs | 32 +- src/plot/types.rs | 32 + src/reader/data.rs | 9 +- src/reader/mod.rs | 8 +- src/writer/vegalite/encoding.rs | 47 +- src/writer/vegalite/mod.rs | 85 ++- src/writer/vegalite/projection.rs | 30 +- tree-sitter-ggsql/grammar.js | 9 +- 44 files changed, 1381 insertions(+), 599 deletions(-) delete mode 100644 src/plot/projection/coord/flip.rs diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 3d29d087..1e0260fe 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -23,7 +23,7 @@ pub use schema::TypeInfo; use crate::naming; use crate::parser; -use crate::plot::aesthetic::{primary_aesthetic, ALL_POSITIONAL}; +use crate::plot::aesthetic::{is_positional_aesthetic, primary_aesthetic}; use crate::plot::facet::{resolve_properties as resolve_facet_properties, FacetDataContext}; use crate::plot::{AestheticValue, Layer, Scale, ScaleTypeKind, Schema}; use crate::{DataFrame, GgsqlError, Plot, Result}; @@ -684,7 +684,7 @@ fn add_discrete_columns_to_partition_by( // Skip positional aesthetics - these should not trigger auto-grouping. // Stats that need to group by positional aesthetics (like bar/histogram) // already handle this themselves via stat_consumed_aesthetics(). - if ALL_POSITIONAL.iter().any(|s| s == aesthetic) { + if is_positional_aesthetic(aesthetic) { continue; } @@ -955,6 +955,8 @@ pub fn prepare_data_with_reader(query: &str, reader: &R) -> Result = layer_df .get_column_names_str() .iter() .map(|s| s.to_string()) .collect(); - let x_col = naming::aesthetic_column("x"); - let y_col = naming::aesthetic_column("y"); + let x_col = naming::aesthetic_column("pos1"); + let y_col = naming::aesthetic_column("pos2"); assert!( col_names.contains(&x_col), "Should have '{}' column: {:?}", @@ -1408,14 +1410,14 @@ mod tests { // Should have 3 rows (3 unique categories: A, B, C) assert_eq!(layer_df.height(), 3); - // With new approach, columns are renamed to prefixed aesthetic names + // With new approach, columns are renamed to prefixed aesthetic names (using internal names) let col_names: Vec = layer_df .get_column_names_str() .iter() .map(|s| s.to_string()) .collect(); - let x_col = naming::aesthetic_column("x"); - let y_col = naming::aesthetic_column("y"); + let x_col = naming::aesthetic_column("pos1"); + let y_col = naming::aesthetic_column("pos2"); assert!( col_names.contains(&x_col), "Expected '{}' in {:?}", @@ -1483,16 +1485,16 @@ mod tests { let result = prepare_data_with_reader(query, &reader).unwrap(); let layer = &result.specs[0].layers[0]; - // Layer should have yend in mappings (added by default for bar) + // Layer should have pos2end in mappings (yend is transformed to pos2end) assert!( - layer.mappings.aesthetics.contains_key("yend"), - "Bar should have yend mapping for baseline: {:?}", + layer.mappings.aesthetics.contains_key("pos2end"), + "Bar should have pos2end mapping for baseline: {:?}", layer.mappings.aesthetics.keys().collect::>() ); - // The DataFrame should have the yend column with 0 values + // The DataFrame should have the pos2end column with 0 values let layer_df = result.data.get(&naming::layer_key(0)).unwrap(); - let yend_col = naming::aesthetic_column("yend"); + let yend_col = naming::aesthetic_column("pos2end"); assert!( layer_df.column(¥d_col).is_ok(), "DataFrame should have '{}' column: {:?}", @@ -1518,8 +1520,8 @@ mod tests { let result = prepare_data_with_reader(query, &reader).unwrap(); let spec = &result.specs[0]; - // Find the x scale - let x_scale = spec.find_scale("x").expect("x scale should exist"); + // Find the pos1 scale (x is transformed to pos1) + let x_scale = spec.find_scale("pos1").expect("pos1 scale should exist"); // Should be inferred as Continuous from numeric column assert_eq!( @@ -1546,8 +1548,8 @@ mod tests { let result = prepare_data_with_reader(query, &reader).unwrap(); let spec = &result.specs[0]; - // Find the x scale - let x_scale = spec.find_scale("x").expect("x scale should exist"); + // Find the pos1 scale (x is transformed to pos1) + let x_scale = spec.find_scale("pos1").expect("pos1 scale should exist"); // Should be inferred as Discrete from String column assert_eq!( @@ -1640,8 +1642,9 @@ mod tests { .map(|s| s.to_string()) .collect(); - let x_col = naming::aesthetic_column("x"); - let y_col = naming::aesthetic_column("y"); + // Use internal aesthetic names + let x_col = naming::aesthetic_column("pos1"); + let y_col = naming::aesthetic_column("pos2"); let stroke_col = naming::aesthetic_column("stroke"); assert!( diff --git a/src/execute/scale.rs b/src/execute/scale.rs index 3f034d85..b24d3af6 100644 --- a/src/execute/scale.rs +++ b/src/execute/scale.rs @@ -1283,28 +1283,33 @@ mod tests { #[test] fn test_get_aesthetic_family() { - // Test primary aesthetics include all family members - let x_family = get_aesthetic_family("x"); - assert!(x_family.contains(&"x")); - assert!(x_family.contains(&"xmin")); - assert!(x_family.contains(&"xmax")); - assert!(x_family.contains(&"xend")); - assert_eq!(x_family.len(), 4); - - let y_family = get_aesthetic_family("y"); - assert!(y_family.contains(&"y")); - assert!(y_family.contains(&"ymin")); - assert!(y_family.contains(&"ymax")); - assert!(y_family.contains(&"yend")); - assert_eq!(y_family.len(), 4); - - // Test non-family aesthetics return just themselves + // NOTE: get_aesthetic_family() now only handles internal names (pos1, pos2, etc.) + // and non-positional aesthetics. For user-facing families, use AestheticContext. + + // Test internal primary aesthetics include all family members + let pos1_family = get_aesthetic_family("pos1"); + assert!(pos1_family.iter().any(|s| s == "pos1")); + assert!(pos1_family.iter().any(|s| s == "pos1min")); + assert!(pos1_family.iter().any(|s| s == "pos1max")); + assert!(pos1_family.iter().any(|s| s == "pos1end")); + assert!(pos1_family.iter().any(|s| s == "pos1intercept")); + assert_eq!(pos1_family.len(), 5); // pos1, pos1min, pos1max, pos1end, pos1intercept + + let pos2_family = get_aesthetic_family("pos2"); + assert!(pos2_family.iter().any(|s| s == "pos2")); + assert!(pos2_family.iter().any(|s| s == "pos2min")); + assert!(pos2_family.iter().any(|s| s == "pos2max")); + assert!(pos2_family.iter().any(|s| s == "pos2end")); + assert!(pos2_family.iter().any(|s| s == "pos2intercept")); + assert_eq!(pos2_family.len(), 5); // pos2, pos2min, pos2max, pos2end, pos2intercept + + // Test non-positional aesthetics return just themselves let color_family = get_aesthetic_family("color"); assert_eq!(color_family, vec!["color"]); - // Test variant aesthetics return just themselves - let xmin_family = get_aesthetic_family("xmin"); - assert_eq!(xmin_family, vec!["xmin"]); + // Test internal variant aesthetics return just themselves + let pos1min_family = get_aesthetic_family("pos1min"); + assert_eq!(pos1min_family, vec!["pos1min"]); } #[test] @@ -1342,7 +1347,7 @@ mod tests { let mut spec = Plot::new(); // Disable expansion for predictable test values - let mut scale = crate::plot::Scale::new("x"); + let mut scale = crate::plot::Scale::new("pos1"); scale.properties.insert( "expand".to_string(), crate::plot::ParameterValue::Number(0.0), @@ -1350,7 +1355,7 @@ mod tests { spec.scales.push(scale); // Simulate post-merge state: mapping is in layer let layer = Layer::new(Geom::point()) - .with_aesthetic("x".to_string(), AestheticValue::standard_column("value")); + .with_aesthetic("pos1".to_string(), AestheticValue::standard_column("value")); spec.layers.push(layer); // Create data with numeric values @@ -1388,7 +1393,7 @@ mod tests { // Create a Plot with a scale that already has a range let mut spec = Plot::new(); - let mut scale = crate::plot::Scale::new("x"); + let mut scale = crate::plot::Scale::new("pos1"); scale.input_range = Some(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]); // Disable expansion for predictable test values scale.properties.insert( @@ -1398,7 +1403,7 @@ mod tests { spec.scales.push(scale); // Simulate post-merge state: mapping is in layer let layer = Layer::new(Geom::point()) - .with_aesthetic("x".to_string(), AestheticValue::standard_column("value")); + .with_aesthetic("pos1".to_string(), AestheticValue::standard_column("value")); spec.layers.push(layer); // Create data with different values @@ -1429,23 +1434,19 @@ mod tests { fn test_resolve_scales_from_aesthetic_family_input_range() { use polars::prelude::*; - // Create a Plot where "y" scale should get range from ymin and ymax columns + // Create a Plot where "pos2" scale should get range from pos2min and pos2max columns + // NOTE: After aesthetic transformation, all positional aesthetics use internal names let mut spec = Plot::new(); - // Disable expansion for predictable test values - let mut scale = crate::plot::Scale::new("y"); - scale.properties.insert( - "expand".to_string(), - crate::plot::ParameterValue::Number(0.0), - ); + let scale = crate::plot::Scale::new("pos2"); spec.scales.push(scale); - // Simulate post-merge state: mappings are in layer + // Simulate post-transformation state: mappings use internal names let layer = Layer::new(Geom::errorbar()) - .with_aesthetic("ymin".to_string(), AestheticValue::standard_column("low")) - .with_aesthetic("ymax".to_string(), AestheticValue::standard_column("high")); + .with_aesthetic("pos2min".to_string(), AestheticValue::standard_column("low")) + .with_aesthetic("pos2max".to_string(), AestheticValue::standard_column("high")); spec.layers.push(layer); - // Create data where ymin/ymax columns have different ranges + // Create data where pos2min/pos2max columns have different ranges let df = df! { "low" => &[5.0f64, 10.0, 15.0], "high" => &[20.0f64, 25.0, 30.0] @@ -1458,16 +1459,17 @@ mod tests { // Resolve scales resolve_scales(&mut spec, &mut data_map).unwrap(); - // Check that range was inferred from both ymin and ymax columns + // Check that range was inferred from both pos2min and pos2max columns let scale = &spec.scales[0]; assert!(scale.input_range.is_some()); let range = scale.input_range.as_ref().unwrap(); match (&range[0], &range[1]) { (ArrayElement::Number(min), ArrayElement::Number(max)) => { - // min should be 5.0 (from low column), max should be 30.0 (from high column) - assert_eq!(*min, 5.0); - assert_eq!(*max, 30.0); + // Range should cover at least 5.0 to 30.0 (from low and high columns) + // With default expansion, the actual range may be slightly wider + assert!(*min <= 5.0, "min should be at most 5.0, got {}", min); + assert!(*max >= 30.0, "max should be at least 30.0, got {}", max); } _ => panic!("Expected Number elements"), } @@ -1480,7 +1482,7 @@ mod tests { // Create a Plot with a scale that has [0, null] (explicit min, infer max) let mut spec = Plot::new(); - let mut scale = crate::plot::Scale::new("x"); + let mut scale = crate::plot::Scale::new("pos1"); scale.input_range = Some(vec![ArrayElement::Number(0.0), ArrayElement::Null]); // Disable expansion for predictable test values scale.properties.insert( @@ -1490,7 +1492,7 @@ mod tests { spec.scales.push(scale); // Simulate post-merge state: mapping is in layer let layer = Layer::new(Geom::point()) - .with_aesthetic("x".to_string(), AestheticValue::standard_column("value")); + .with_aesthetic("pos1".to_string(), AestheticValue::standard_column("value")); spec.layers.push(layer); // Create data with values 1-10 @@ -1524,7 +1526,7 @@ mod tests { // Create a Plot with a scale that has [null, 100] (infer min, explicit max) let mut spec = Plot::new(); - let mut scale = crate::plot::Scale::new("x"); + let mut scale = crate::plot::Scale::new("pos1"); scale.input_range = Some(vec![ArrayElement::Null, ArrayElement::Number(100.0)]); // Disable expansion for predictable test values scale.properties.insert( @@ -1534,7 +1536,7 @@ mod tests { spec.scales.push(scale); // Simulate post-merge state: mapping is in layer let layer = Layer::new(Geom::point()) - .with_aesthetic("x".to_string(), AestheticValue::standard_column("value")); + .with_aesthetic("pos1".to_string(), AestheticValue::standard_column("value")); spec.layers.push(layer); // Create data with values 1-10 diff --git a/src/lib.rs b/src/lib.rs index b81e6c74..aeaf7e2b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,7 +59,7 @@ pub use plot::{ // Re-export aesthetic classification utilities pub use plot::aesthetic::{ get_aesthetic_family, is_aesthetic_name, is_positional_aesthetic, is_primary_positional, - primary_aesthetic, AESTHETIC_FAMILIES, ALL_POSITIONAL, NON_POSITIONAL, PRIMARY_POSITIONAL, + primary_aesthetic, AestheticContext, NON_POSITIONAL, POSITIONAL_SUFFIXES, }; // Future modules - not yet implemented @@ -148,6 +148,10 @@ mod integration_tests { ); spec.layers.push(layer); + // Transform aesthetics from user-facing (x, y) to internal (pos1, pos2) + spec.initialize_aesthetic_context(); + spec.transform_aesthetics_to_internal(); + // Generate Vega-Lite JSON let writer = VegaLiteWriter::new(); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); @@ -204,6 +208,10 @@ mod integration_tests { ); spec.layers.push(layer); + // Transform aesthetics from user-facing (x, y) to internal (pos1, pos2) + spec.initialize_aesthetic_context(); + spec.transform_aesthetics_to_internal(); + // Generate Vega-Lite JSON let writer = VegaLiteWriter::new(); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); @@ -258,6 +266,10 @@ mod integration_tests { ); spec.layers.push(layer); + // Transform aesthetics from user-facing (x, y) to internal (pos1, pos2) + spec.initialize_aesthetic_context(); + spec.transform_aesthetics_to_internal(); + // Generate Vega-Lite JSON let writer = VegaLiteWriter::new(); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); @@ -310,6 +322,10 @@ mod integration_tests { ); spec.layers.push(layer); + // Transform aesthetics from user-facing (x, y) to internal (pos1, pos2) + spec.initialize_aesthetic_context(); + spec.transform_aesthetics_to_internal(); + let writer = VegaLiteWriter::new(); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); @@ -343,6 +359,10 @@ mod integration_tests { ); spec.layers.push(layer); + // Transform aesthetics from user-facing (x, y) to internal (pos1, pos2) + spec.initialize_aesthetic_context(); + spec.transform_aesthetics_to_internal(); + let writer = VegaLiteWriter::new(); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); @@ -397,6 +417,10 @@ mod integration_tests { ); spec.layers.push(layer); + // Transform aesthetics from user-facing (x, y) to internal (pos1, pos2) + spec.initialize_aesthetic_context(); + spec.transform_aesthetics_to_internal(); + let writer = VegaLiteWriter::new(); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); @@ -441,6 +465,10 @@ mod integration_tests { ); spec.layers.push(layer); + // Transform aesthetics from user-facing (x, y) to internal (pos1, pos2) + spec.initialize_aesthetic_context(); + spec.transform_aesthetics_to_internal(); + let writer = VegaLiteWriter::new(); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); @@ -495,6 +523,10 @@ mod integration_tests { ); spec.layers.push(layer); + // Transform aesthetics from user-facing (x, y) to internal (pos1, pos2) + spec.initialize_aesthetic_context(); + spec.transform_aesthetics_to_internal(); + let writer = VegaLiteWriter::new(); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap(); @@ -657,8 +689,9 @@ mod integration_tests { // With aesthetic-named columns, each layer gets its own data // Each layer should have its data with prefixed aesthetic-named columns - let x_col = naming::aesthetic_column("x"); - let y_col = naming::aesthetic_column("y"); + // Note: x and y are transformed to internal names pos1 and pos2 + let x_col = naming::aesthetic_column("pos1"); + let y_col = naming::aesthetic_column("pos2"); let stroke_col = naming::aesthetic_column("stroke"); for layer_idx in 0..4 { let layer_key = naming::layer_key(layer_idx); @@ -767,8 +800,9 @@ mod integration_tests { // Both layers have data (may be shared or separate depending on query dedup) // Verify layer 0 has the expected columns - let x_col = naming::aesthetic_column("x"); - let y_col = naming::aesthetic_column("y"); + // Note: x and y are transformed to internal names pos1 and pos2 + let x_col = naming::aesthetic_column("pos1"); + let y_col = naming::aesthetic_column("pos2"); let stroke_col = naming::aesthetic_column("stroke"); let layer_df = prepared.data.get(layer0_key).unwrap(); diff --git a/src/naming.rs b/src/naming.rs index f6fb0c1c..b80d5942 100644 --- a/src/naming.rs +++ b/src/naming.rs @@ -505,4 +505,28 @@ mod tests { assert_eq!(extract_aesthetic_name("__ggsql_stat_count"), None); assert_eq!(extract_aesthetic_name("__ggsql_const_color__"), None); } + + #[test] + fn test_bin_end_column_internal_positional() { + // Internal positional aesthetic columns (pos1, pos2, etc.) + // These are generated by the aesthetic transformation pipeline + assert_eq!( + bin_end_column("__ggsql_aes_pos1__"), + "__ggsql_aes_pos1end__" + ); + assert_eq!( + bin_end_column("__ggsql_aes_pos2__"), + "__ggsql_aes_pos2end__" + ); + + // Verify it works for any posN + assert_eq!( + bin_end_column("__ggsql_aes_pos3__"), + "__ggsql_aes_pos3end__" + ); + assert_eq!( + bin_end_column("__ggsql_aes_pos10__"), + "__ggsql_aes_pos10end__" + ); + } } diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 542cc3d0..74c412cb 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -270,6 +270,15 @@ fn build_visualise_statement(node: &Node, source: &SourceTree) -> Result { } } + // Initialize aesthetic context based on coord and facet + // This must happen after all clauses are processed (especially PROJECT and FACET) + spec.initialize_aesthetic_context(); + + // Transform all aesthetic keys from user-facing (x/y or theta/radius) to internal (pos1/pos2) + // This enables generic handling throughout the pipeline and must happen before merge + // since geom definitions use internal names for their supported/required aesthetics + spec.transform_aesthetics_to_internal(); + Ok(spec) } @@ -967,7 +976,6 @@ fn parse_coord(node: &Node, source: &SourceTree) -> Result { match text.to_lowercase().as_str() { "cartesian" => Ok(Coord::cartesian()), "polar" => Ok(Coord::polar()), - "flip" => Ok(Coord::flip()), _ => Err(GgsqlError::ParseError(format!( "Unknown coord type: {}", text @@ -1148,22 +1156,6 @@ mod tests { .contains("Property 'theta' not valid for cartesian")); } - #[test] - fn test_project_flip_invalid_property_theta() { - let query = r#" - VISUALISE - DRAW bar MAPPING category AS x, value AS y - PROJECT flip SETTING theta => y - "#; - - let result = parse_test_query(query); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err - .to_string() - .contains("Property 'theta' not valid for flip")); - } - #[test] fn test_project_polar_valid_theta() { let query = r#" @@ -1956,10 +1948,10 @@ mod tests { let specs = result.unwrap(); let layer = &specs[0].layers[0]; - // Check aesthetics + // Check aesthetics (x and y are transformed to pos1 and pos2) assert_eq!(layer.mappings.len(), 3); - assert!(layer.mappings.contains_key("x")); - assert!(layer.mappings.contains_key("y")); + assert!(layer.mappings.contains_key("pos1")); + assert!(layer.mappings.contains_key("pos2")); assert!(layer.mappings.contains_key("color")); // Check parameters @@ -2365,10 +2357,10 @@ mod tests { let specs = parse_test_query(query).unwrap(); - // Global mapping should have x and y + // Global mapping should have pos1 and pos2 (transformed from x and y) assert_eq!(specs[0].global_mappings.aesthetics.len(), 2); - assert!(specs[0].global_mappings.aesthetics.contains_key("x")); - assert!(specs[0].global_mappings.aesthetics.contains_key("y")); + assert!(specs[0].global_mappings.aesthetics.contains_key("pos1")); + assert!(specs[0].global_mappings.aesthetics.contains_key("pos2")); assert!(!specs[0].global_mappings.wildcard); // Line layer should have no layer-specific aesthetics @@ -2389,15 +2381,15 @@ mod tests { let specs = parse_test_query(query).unwrap(); - // Implicit x, y become explicit mappings at parse time + // Implicit x, y become explicit mappings at parse time, transformed to internal names assert_eq!(specs[0].global_mappings.aesthetics.len(), 2); - assert!(specs[0].global_mappings.aesthetics.contains_key("x")); - assert!(specs[0].global_mappings.aesthetics.contains_key("y")); + assert!(specs[0].global_mappings.aesthetics.contains_key("pos1")); + assert!(specs[0].global_mappings.aesthetics.contains_key("pos2")); - // Verify they map to columns of the same name - let x_val = specs[0].global_mappings.aesthetics.get("x").unwrap(); + // Verify they map to columns of the same name (column names are not transformed) + let x_val = specs[0].global_mappings.aesthetics.get("pos1").unwrap(); assert_eq!(x_val.column_name(), Some("x")); - let y_val = specs[0].global_mappings.aesthetics.get("y").unwrap(); + let y_val = specs[0].global_mappings.aesthetics.get("pos2").unwrap(); assert_eq!(y_val.column_name(), Some("y")); } @@ -2631,7 +2623,8 @@ mod tests { let specs = parse_test_query(query).unwrap(); let scales = &specs[0].scales; assert_eq!(scales.len(), 1); - assert_eq!(scales[0].aesthetic, "x"); + // Scale aesthetic x is transformed to pos1 + assert_eq!(scales[0].aesthetic, "pos1"); let input_range = scales[0].input_range.as_ref().unwrap(); assert_eq!(input_range.len(), 2); @@ -2725,7 +2718,8 @@ mod tests { let specs = parse_test_query(query).unwrap(); let scales = &specs[0].scales; assert_eq!(scales.len(), 1); - assert_eq!(scales[0].aesthetic, "x"); + // Scale aesthetic x is transformed to pos1 + assert_eq!(scales[0].aesthetic, "pos1"); assert!(scales[0].transform.is_some()); assert_eq!(scales[0].transform.as_ref().unwrap().name(), "date"); } @@ -2742,7 +2736,8 @@ mod tests { let specs = parse_test_query(query).unwrap(); let scales = &specs[0].scales; assert_eq!(scales.len(), 1); - assert_eq!(scales[0].aesthetic, "x"); + // Scale aesthetic x is transformed to pos1 + assert_eq!(scales[0].aesthetic, "pos1"); assert!(scales[0].transform.is_some()); assert_eq!(scales[0].transform.as_ref().unwrap().name(), "integer"); } @@ -2795,7 +2790,8 @@ mod tests { let specs = parse_test_query(query).unwrap(); let scales = &specs[0].scales; assert_eq!(scales.len(), 1); - assert_eq!(scales[0].aesthetic, "x"); + // Scale aesthetic is transformed to internal name + assert_eq!(scales[0].aesthetic, "pos1"); let label_mapping = scales[0].label_mapping.as_ref().unwrap(); assert_eq!(label_mapping.len(), 2); diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 87e4b6f8..96b90643 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -318,14 +318,16 @@ mod tests { let mapping = &specs[0].global_mappings; assert!(!mapping.wildcard); assert_eq!(mapping.aesthetics.len(), 2); - assert!(mapping.aesthetics.contains_key("x")); - assert!(mapping.aesthetics.contains_key("y")); + // After parsing, aesthetics are transformed to internal names + assert!(mapping.aesthetics.contains_key("pos1")); // x -> pos1 + assert!(mapping.aesthetics.contains_key("pos2")); // y -> pos2 + // Column names remain unchanged assert_eq!( - mapping.aesthetics.get("x").unwrap().column_name(), + mapping.aesthetics.get("pos1").unwrap().column_name(), Some("date") ); assert_eq!( - mapping.aesthetics.get("y").unwrap().column_name(), + mapping.aesthetics.get("pos2").unwrap().column_name(), Some("revenue") ); } @@ -342,13 +344,14 @@ mod tests { let mapping = &specs[0].global_mappings; assert!(!mapping.wildcard); assert_eq!(mapping.aesthetics.len(), 2); - // Implicit mappings are resolved at parse time: x -> x, y -> y + // Implicit mappings: x maps to column x, y maps to column y + // Aesthetic keys are transformed to internal names: x -> pos1, y -> pos2 assert_eq!( - mapping.aesthetics.get("x").unwrap().column_name(), + mapping.aesthetics.get("pos1").unwrap().column_name(), Some("x") ); assert_eq!( - mapping.aesthetics.get("y").unwrap().column_name(), + mapping.aesthetics.get("pos2").unwrap().column_name(), Some("y") ); } @@ -365,13 +368,13 @@ mod tests { let mapping = &specs[0].global_mappings; assert!(!mapping.wildcard); assert_eq!(mapping.aesthetics.len(), 3); - // Implicit x and y, explicit color + // Implicit x and y (transformed to pos1, pos2), explicit color assert_eq!( - mapping.aesthetics.get("x").unwrap().column_name(), + mapping.aesthetics.get("pos1").unwrap().column_name(), Some("x") ); assert_eq!( - mapping.aesthetics.get("y").unwrap().column_name(), + mapping.aesthetics.get("pos2").unwrap().column_name(), Some("y") ); assert_eq!( diff --git a/src/plot/aesthetic.rs b/src/plot/aesthetic.rs index c990db7a..8e97fe76 100644 --- a/src/plot/aesthetic.rs +++ b/src/plot/aesthetic.rs @@ -15,26 +15,27 @@ //! Some aesthetics belong to "families" where variants map to a primary aesthetic. //! For example, `xmin`, `xmax`, and `xend` all belong to the "x" family. //! This is used for scale resolution and label computation. +//! +//! # Internal vs User-Facing Aesthetics +//! +//! The pipeline uses internal positional aesthetic names (pos1, pos2, etc.) that are +//! transformed from user-facing names (x/y or theta/radius) early in the pipeline +//! and transformed back for output. This is handled by `AestheticContext`. -/// Primary positional aesthetics (x and y only) -pub const PRIMARY_POSITIONAL: &[&str] = &["x", "y"]; +// ============================================================================= +// Positional Suffixes (applied to primary names automatically) +// ============================================================================= -/// All positional aesthetics (primary + variants) -pub const ALL_POSITIONAL: &[&str] = &["x", "xmin", "xmax", "xend", "y", "ymin", "ymax", "yend"]; +/// Positional aesthetic suffixes - applied to primary names to create variant aesthetics +/// e.g., "x" + "min" = "xmin", "pos1" + "end" = "pos1end" +pub const POSITIONAL_SUFFIXES: &[&str] = &["min", "max", "end", "intercept"]; -/// Maps variant aesthetics to their primary aesthetic family. -/// -/// For example, `xmin`, `xmax`, and `xend` all belong to the "x" family. -/// When computing labels, all family members can contribute to the primary aesthetic's label, -/// with the first aesthetic encountered in a family setting the label. -pub const AESTHETIC_FAMILIES: &[(&str, &str)] = &[ - ("xmin", "x"), - ("xmax", "x"), - ("xend", "x"), - ("ymin", "y"), - ("ymax", "y"), - ("yend", "y"), -]; +/// Family size: primary + all suffixes (used for slicing family arrays) +const FAMILY_SIZE: usize = 1 + POSITIONAL_SUFFIXES.len(); + +// ============================================================================= +// Static Constants (for backward compatibility with existing code) +// ============================================================================= /// Facet aesthetics (for creating small multiples) /// @@ -70,10 +71,258 @@ pub const NON_POSITIONAL: &[&str] = &[ "vjust", ]; -/// Check if aesthetic is primary positional (x or y only) +// ============================================================================= +// AestheticContext - Comprehensive context for aesthetic operations +// ============================================================================= + +/// Comprehensive context for aesthetic operations. +/// +/// Pre-computes all mappings at creation time for efficient lookups. +/// Used to transform between user-facing aesthetic names (x/y or theta/radius) +/// and internal names (pos1/pos2). +/// +/// # Example +/// +/// ```ignore +/// use ggsql::plot::AestheticContext; +/// +/// // For cartesian coords +/// let ctx = AestheticContext::new(&["x", "y"], &[]); +/// assert_eq!(ctx.map_user_to_internal("x"), Some("pos1")); +/// assert_eq!(ctx.map_user_to_internal("ymin"), Some("pos2min")); +/// assert_eq!(ctx.map_internal_to_user("pos1"), Some("x")); +/// +/// // For polar coords +/// let ctx = AestheticContext::new(&["theta", "radius"], &[]); +/// assert_eq!(ctx.map_user_to_internal("theta"), Some("pos1")); +/// assert_eq!(ctx.map_user_to_internal("radius"), Some("pos2")); +/// assert_eq!(ctx.map_internal_to_user("pos1"), Some("theta")); +/// ``` +#[derive(Debug, Clone)] +pub struct AestheticContext { + /// User-facing positional names: ["x", "y"] or ["theta", "radius"] + user_positional: Vec<&'static str>, + /// All user positional (with suffixes): ["x", "xmin", "xmax", "xend", "y", ...] + all_user_positional: Vec, + /// Primary internal positional: ["pos1", "pos2", ...] + primary_internal: Vec, + /// All internal positional: ["pos1", "pos1min", ..., "pos2", ...] + all_internal_positional: Vec, + /// User-facing facet names: ["panel"] or ["row", "column"] + facet: Vec<&'static str>, + /// Non-positional aesthetics (static list) + non_positional: &'static [&'static str], +} + +impl AestheticContext { + /// Create context from coord's positional names and facet's aesthetic names. + /// + /// # Arguments + /// + /// * `positional_names` - Primary positional aesthetic names from coord (e.g., ["x", "y"]) + /// * `facet_names` - Aesthetic names from facet (e.g., ["panel"] or ["row", "column"]) + pub fn new(positional_names: &[&'static str], facet_names: &[&'static str]) -> Self { + let mut all_user = Vec::new(); + let mut primary_internal = Vec::new(); + let mut all_internal = Vec::new(); + + for (i, primary_name) in positional_names.iter().enumerate() { + let pos_num = i + 1; + let internal_base = format!("pos{}", pos_num); + primary_internal.push(internal_base.clone()); + + // Add primary first (e.g., "x", "pos1") + all_user.push((*primary_name).to_string()); + all_internal.push(internal_base.clone()); + + // Then add suffixed variants (e.g., "xmin", "pos1min") + for suffix in POSITIONAL_SUFFIXES { + all_user.push(format!("{}{}", primary_name, suffix)); + all_internal.push(format!("{}{}", internal_base, suffix)); + } + } + + Self { + user_positional: positional_names.to_vec(), + all_user_positional: all_user, + primary_internal, + all_internal_positional: all_internal, + facet: facet_names.to_vec(), + non_positional: NON_POSITIONAL, + } + } + + // === Mapping: User → Internal === + + /// Map user positional aesthetic to internal name. + /// + /// e.g., "x" → "pos1", "ymin" → "pos2min", "theta" → "pos1" + pub fn map_user_to_internal(&self, user_aesthetic: &str) -> Option<&str> { + self.all_user_positional + .iter() + .position(|u| u == user_aesthetic) + .map(|idx| self.all_internal_positional[idx].as_str()) + } + + // === Mapping: Internal → User === + + /// Map internal positional to user-facing name. + /// + /// e.g., "pos1" → "x", "pos2min" → "ymin" + pub fn map_internal_to_user(&self, internal_aesthetic: &str) -> Option<&str> { + self.all_internal_positional + .iter() + .position(|i| i == internal_aesthetic) + .map(|idx| self.all_user_positional[idx].as_str()) + } + + // === Checking (simple lookups in pre-computed lists) === + + /// Check if user aesthetic is a positional (x, y, xmin, theta, etc.) + pub fn is_user_positional(&self, name: &str) -> bool { + self.all_user_positional.iter().any(|s| s == name) + } + + /// Check if internal aesthetic is positional (pos1, pos1min, etc.) + pub fn is_internal_positional(&self, name: &str) -> bool { + self.all_internal_positional.iter().any(|s| s == name) + } + + /// Check if internal aesthetic is primary positional (pos1, pos2, ...) + pub fn is_primary_internal(&self, name: &str) -> bool { + self.primary_internal.iter().any(|s| s == name) + } + + /// Check if aesthetic is non-positional (color, size, etc.) + pub fn is_non_positional(&self, name: &str) -> bool { + self.non_positional.contains(&name) + } + + /// Check if name is a facet aesthetic + pub fn is_facet(&self, name: &str) -> bool { + self.facet.contains(&name) + } + + // === Aesthetic Families === + + /// Get the primary aesthetic for a family member. + /// + /// e.g., "pos1min" → "pos1", "pos2end" → "pos2" + /// Non-positional aesthetics return themselves. + pub fn primary_internal_aesthetic<'a>(&'a self, name: &'a str) -> Option<&'a str> { + // Check internal positional - find which primary it belongs to + for (i, primary) in self.primary_internal.iter().enumerate() { + let start = i * FAMILY_SIZE; + let end = start + FAMILY_SIZE; + if self.all_internal_positional[start..end] + .iter() + .any(|s| s == name) + { + return Some(primary.as_str()); + } + } + // Non-positional aesthetics are their own primary + if self.is_non_positional(name) { + return Some(name); + } + None + } + + /// Get the aesthetic family for a primary aesthetic. + /// + /// e.g., "pos1" → ["pos1", "pos1min", "pos1max", "pos1end"] + pub fn get_internal_family(&self, primary: &str) -> Option<&[String]> { + for (i, p) in self.primary_internal.iter().enumerate() { + if p == primary { + let start = i * FAMILY_SIZE; + let end = start + FAMILY_SIZE; + return Some(&self.all_internal_positional[start..end]); + } + } + None + } + + /// Get the user-facing family for a user primary aesthetic. + /// + /// e.g., "x" → ["x", "xmin", "xmax", "xend"] + pub fn get_user_family(&self, user_primary: &str) -> Option<&[String]> { + for (i, p) in self.user_positional.iter().enumerate() { + if *p == user_primary { + let start = i * FAMILY_SIZE; + let end = start + FAMILY_SIZE; + return Some(&self.all_user_positional[start..end]); + } + } + None + } + + /// Get the primary user-facing aesthetic for a user variant. + /// + /// e.g., "xmin" → "x", "thetamax" → "theta", "color" → "color" + /// Returns None if the aesthetic is not recognized. + pub fn primary_user_aesthetic<'a>(&'a self, name: &'a str) -> Option<&'a str> { + // Check user positional - find which primary it belongs to + for (i, primary) in self.user_positional.iter().enumerate() { + let start = i * FAMILY_SIZE; + let end = start + FAMILY_SIZE; + if self.all_user_positional[start..end] + .iter() + .any(|s| s == name) + { + return Some(primary); + } + } + // Non-positional aesthetics are their own primary + if self.is_non_positional(name) { + return Some(name); + } + None + } + + // === Accessors === + + /// Get all internal positional aesthetics (pos1, pos1min, ..., pos2, ...) + pub fn all_internal_positional(&self) -> &[String] { + &self.all_internal_positional + } + + /// Get primary internal positional aesthetics (pos1, pos2, ...) + pub fn primary_internal(&self) -> &[String] { + &self.primary_internal + } + + /// Get user positional aesthetics (x, y or theta, radius) + pub fn user_positional(&self) -> &[&'static str] { + &self.user_positional + } + + /// Get all user positional aesthetics with suffixes (x, xmin, xmax, xend, ...) + pub fn all_user_positional(&self) -> &[String] { + &self.all_user_positional + } + + /// Get facet aesthetics + pub fn facet(&self) -> &[&'static str] { + &self.facet + } + + /// Get non-positional aesthetics + pub fn non_positional(&self) -> &'static [&'static str] { + self.non_positional + } +} + +/// Check if aesthetic is a primary internal positional (pos1, pos2, etc.) +/// +/// This function works with **internal** aesthetic names after transformation. +/// For user-facing checks before transformation, use `AestheticContext::is_user_positional()`. #[inline] pub fn is_primary_positional(aesthetic: &str) -> bool { - PRIMARY_POSITIONAL.contains(&aesthetic) + // Check if it matches pattern: pos followed by digits only + if aesthetic.starts_with("pos") && aesthetic.len() > 3 { + return aesthetic[3..].chars().all(|c| c.is_ascii_digit()); + } + false } /// Check if aesthetic is a facet aesthetic (panel, row, column) @@ -85,64 +334,117 @@ pub fn is_facet_aesthetic(aesthetic: &str) -> bool { FACET_AESTHETICS.contains(&aesthetic) } -/// Check if aesthetic is positional (maps to axis, not legend) +/// Check if aesthetic is an internal positional (pos1, pos1min, pos2max, etc.) /// -/// Positional aesthetics include x, y, and their variants (xmin, xmax, ymin, ymax, xend, yend). -/// These aesthetics map to axis positions rather than legend entries. +/// This function works with **internal** aesthetic names after transformation. +/// Matches patterns like: pos1, pos2, pos1min, pos2max, pos1end, pos2intercept, etc. +/// +/// For user-facing checks before transformation, use `AestheticContext::is_user_positional()`. #[inline] pub fn is_positional_aesthetic(name: &str) -> bool { - ALL_POSITIONAL.contains(&name) + if !name.starts_with("pos") || name.len() <= 3 { + return false; + } + + // Check for primary: pos followed by only digits (pos1, pos2, pos10, etc.) + let after_pos = &name[3..]; + if after_pos.chars().all(|c| c.is_ascii_digit()) { + return true; + } + + // Check for variants: posN followed by a suffix + for suffix in POSITIONAL_SUFFIXES { + if let Some(base) = name.strip_suffix(suffix) { + if base.starts_with("pos") && base.len() > 3 { + let num_part = &base[3..]; + if num_part.chars().all(|c| c.is_ascii_digit()) { + return true; + } + } + } + } + + false } -/// Check if name is a recognized aesthetic +/// Check if name is a recognized aesthetic (internal or non-positional) /// -/// This includes all positional aesthetics plus visual aesthetics like color, size, shape, etc. +/// This function works with **internal** aesthetic names (pos1, pos2, etc.) and non-positional +/// aesthetics. For validating user-facing aesthetic names before transformation, use +/// `AestheticContext::is_user_positional()` or check against the grammar's aesthetic_name rule. #[inline] pub fn is_aesthetic_name(name: &str) -> bool { - is_positional_aesthetic(name) || NON_POSITIONAL.contains(&name) + is_positional_aesthetic(name) || NON_POSITIONAL.contains(&name) || FACET_AESTHETICS.contains(&name) } /// Get the primary aesthetic for a given aesthetic name. /// -/// Returns the primary family aesthetic if the input is a variant (e.g., "xmin" -> "x"), -/// or returns the aesthetic itself if it's already primary (e.g., "x" -> "x", "fill" -> "fill"). +/// This function works with **internal** aesthetic names (pos1, pos2, etc.) and non-positional +/// aesthetics. After aesthetic transformation, all positional aesthetics are in internal format. +/// +/// For internal positional variants: "pos1min" → "pos1", "pos2end" → "pos2" +/// For non-positional aesthetics: "color" → "color", "fill" → "fill" +/// +/// Note: For user-facing aesthetic families (before transformation), use +/// `AestheticContext::primary_user_aesthetic()` instead. #[inline] pub fn primary_aesthetic(aesthetic: &str) -> &str { - AESTHETIC_FAMILIES - .iter() - .find(|(variant, _)| *variant == aesthetic) - .map(|(_, primary)| *primary) - .unwrap_or(aesthetic) + // Handle internal positional variants (pos1min -> pos1, pos2end -> pos2, etc.) + if aesthetic.starts_with("pos") { + for suffix in POSITIONAL_SUFFIXES { + if aesthetic.ends_with(suffix) { + // Extract the base: pos1min -> pos1, pos2end -> pos2 + let base = &aesthetic[..aesthetic.len() - suffix.len()]; + // Verify it's a valid positional (pos followed by digits) + if base.len() > 3 && base[3..].chars().all(|c| c.is_ascii_digit()) { + // Return static str by leaking - this is acceptable for a small fixed set + // In practice this is only called with a limited set of aesthetics + return Box::leak(base.to_string().into_boxed_str()); + } + } + } + } + + // Non-positional aesthetics (and internal primaries) return themselves + aesthetic } /// Get all aesthetics in the same family as the given aesthetic. /// -/// For primary aesthetics like "x", returns all family members: `["x", "xmin", "xmax", "x2", "xend"]`. -/// For variant aesthetics like "xmin", returns just `["xmin"]` since scales should be -/// defined for primary aesthetics. -/// For non-family aesthetics like "color", returns just `["color"]`. +/// This function works with **internal** aesthetic names (pos1, pos2, etc.) and non-positional +/// aesthetics. After aesthetic transformation, all positional aesthetics are in internal format. +/// +/// For internal positional primary "pos1": returns `["pos1", "pos1min", "pos1max", "pos1end", "pos1intercept"]` +/// For internal positional variant "pos1min": returns just `["pos1min"]` (scales defined on primaries) +/// For non-positional aesthetics "color": returns just `["color"]` /// /// This is used by scale resolution to find all columns that contribute to a scale's -/// input range (e.g., both `ymin` and `ymax` columns contribute to the "y" scale). -pub fn get_aesthetic_family(aesthetic: &str) -> Vec<&str> { +/// input range (e.g., both `pos2min` and `pos2max` columns contribute to the "pos2" scale). +/// +/// Note: For user-facing aesthetic families (before transformation), use +/// `AestheticContext::get_user_family()` instead. +pub fn get_aesthetic_family(aesthetic: &str) -> Vec { // First, determine the primary aesthetic let primary = primary_aesthetic(aesthetic); // If aesthetic is not a primary (it's a variant), just return the aesthetic itself // since scales should be defined for primary aesthetics if primary != aesthetic { - return vec![aesthetic]; + return vec![aesthetic.to_string()]; } - // Collect primary + all variants that map to this primary - let mut family = vec![primary]; - for (variant, prim) in AESTHETIC_FAMILIES { - if *prim == primary { - family.push(*variant); + // Check if this is an internal positional (pos1, pos2, etc.) + if primary.starts_with("pos") && primary.len() > 3 && primary[3..].chars().all(|c| c.is_ascii_digit()) { + // Build the internal family: pos1 -> [pos1, pos1min, pos1max, pos1end, pos1intercept] + let mut family = vec![primary.to_string()]; + for suffix in POSITIONAL_SUFFIXES { + family.push(format!("{}{}", primary, suffix)); } + return family; } - family + // Non-positional aesthetics don't have families, just return themselves + vec![aesthetic.to_string()] } #[cfg(test)] @@ -151,10 +453,25 @@ mod tests { #[test] fn test_primary_positional() { - assert!(is_primary_positional("x")); - assert!(is_primary_positional("y")); - assert!(!is_primary_positional("xmin")); + // NOTE: is_primary_positional() now checks for internal names (pos1, pos2, etc.) + assert!(is_primary_positional("pos1")); + assert!(is_primary_positional("pos2")); + assert!(is_primary_positional("pos10")); // supports any number + + // Variants are not primary + assert!(!is_primary_positional("pos1min")); + assert!(!is_primary_positional("pos2max")); + + // User-facing names are NOT primary positional (handled by AestheticContext) + assert!(!is_primary_positional("x")); + assert!(!is_primary_positional("y")); + + // Non-positional assert!(!is_primary_positional("color")); + + // Edge cases + assert!(!is_primary_positional("pos")); // too short + assert!(!is_primary_positional("position")); // not a valid pattern } #[test] @@ -168,46 +485,52 @@ mod tests { #[test] fn test_positional_aesthetic() { - // Primary - assert!(is_positional_aesthetic("x")); - assert!(is_positional_aesthetic("y")); + // NOTE: is_positional_aesthetic() now checks for internal names (pos1, pos2, etc.) + // For user-facing checks, use AestheticContext::is_user_positional() + + // Primary internal + assert!(is_positional_aesthetic("pos1")); + assert!(is_positional_aesthetic("pos2")); + assert!(is_positional_aesthetic("pos10")); // supports any number // Variants - assert!(is_positional_aesthetic("xmin")); - assert!(is_positional_aesthetic("xmax")); - assert!(is_positional_aesthetic("ymin")); - assert!(is_positional_aesthetic("ymax")); - assert!(is_positional_aesthetic("xend")); - assert!(is_positional_aesthetic("yend")); + assert!(is_positional_aesthetic("pos1min")); + assert!(is_positional_aesthetic("pos1max")); + assert!(is_positional_aesthetic("pos2min")); + assert!(is_positional_aesthetic("pos2max")); + assert!(is_positional_aesthetic("pos1end")); + assert!(is_positional_aesthetic("pos2end")); + assert!(is_positional_aesthetic("pos1intercept")); + assert!(is_positional_aesthetic("pos2intercept")); + + // User-facing names are NOT positional (handled by AestheticContext) + assert!(!is_positional_aesthetic("x")); + assert!(!is_positional_aesthetic("y")); + assert!(!is_positional_aesthetic("xmin")); + assert!(!is_positional_aesthetic("theta")); // Non-positional assert!(!is_positional_aesthetic("color")); assert!(!is_positional_aesthetic("size")); assert!(!is_positional_aesthetic("fill")); - } - #[test] - fn test_all_positional_contents() { - assert!(ALL_POSITIONAL.contains(&"x")); - assert!(ALL_POSITIONAL.contains(&"y")); - assert!(ALL_POSITIONAL.contains(&"xmin")); - assert!(ALL_POSITIONAL.contains(&"xmax")); - assert!(ALL_POSITIONAL.contains(&"ymin")); - assert!(ALL_POSITIONAL.contains(&"ymax")); - assert!(ALL_POSITIONAL.contains(&"xend")); - assert!(ALL_POSITIONAL.contains(&"yend")); - assert_eq!(ALL_POSITIONAL.len(), 8); + // Edge cases + assert!(!is_positional_aesthetic("pos")); // too short + assert!(!is_positional_aesthetic("position")); // not a valid pattern } #[test] fn test_is_aesthetic_name() { - // Positional - assert!(is_aesthetic_name("x")); - assert!(is_aesthetic_name("y")); - assert!(is_aesthetic_name("xmin")); - assert!(is_aesthetic_name("yend")); + // NOTE: is_aesthetic_name() works with internal names and non-positional aesthetics + // For user-facing validation, use AestheticContext::is_user_positional() - // Visual + // Internal positional + assert!(is_aesthetic_name("pos1")); + assert!(is_aesthetic_name("pos2")); + assert!(is_aesthetic_name("pos1min")); + assert!(is_aesthetic_name("pos2end")); + + // Visual (non-positional) assert!(is_aesthetic_name("color")); assert!(is_aesthetic_name("colour")); assert!(is_aesthetic_name("fill")); @@ -225,52 +548,316 @@ mod tests { assert!(is_aesthetic_name("hjust")); assert!(is_aesthetic_name("vjust")); - // Not aesthetics + // Facet + assert!(is_aesthetic_name("panel")); + assert!(is_aesthetic_name("row")); + assert!(is_aesthetic_name("column")); + + // Not aesthetics (user-facing names are not recognized by this function) + assert!(!is_aesthetic_name("x")); + assert!(!is_aesthetic_name("y")); + assert!(!is_aesthetic_name("theta")); assert!(!is_aesthetic_name("foo")); assert!(!is_aesthetic_name("data")); - assert!(!is_aesthetic_name("z")); } #[test] fn test_primary_aesthetic() { - // Primary aesthetics return themselves - assert_eq!(primary_aesthetic("x"), "x"); - assert_eq!(primary_aesthetic("y"), "y"); + // NOTE: primary_aesthetic() now only handles internal names (pos1, pos2, etc.) + // and non-positional aesthetics. For user-facing families, use AestheticContext. + + // Internal positional primaries return themselves + assert_eq!(primary_aesthetic("pos1"), "pos1"); + assert_eq!(primary_aesthetic("pos2"), "pos2"); + + // Internal positional variants return their primary + assert_eq!(primary_aesthetic("pos1min"), "pos1"); + assert_eq!(primary_aesthetic("pos1max"), "pos1"); + assert_eq!(primary_aesthetic("pos1end"), "pos1"); + assert_eq!(primary_aesthetic("pos1intercept"), "pos1"); + assert_eq!(primary_aesthetic("pos2min"), "pos2"); + assert_eq!(primary_aesthetic("pos2max"), "pos2"); + assert_eq!(primary_aesthetic("pos2end"), "pos2"); + assert_eq!(primary_aesthetic("pos2intercept"), "pos2"); + + // Non-positional aesthetics return themselves assert_eq!(primary_aesthetic("color"), "color"); assert_eq!(primary_aesthetic("fill"), "fill"); + assert_eq!(primary_aesthetic("size"), "size"); - // Variants return their primary - assert_eq!(primary_aesthetic("xmin"), "x"); - assert_eq!(primary_aesthetic("xmax"), "x"); - assert_eq!(primary_aesthetic("xend"), "x"); - assert_eq!(primary_aesthetic("ymin"), "y"); - assert_eq!(primary_aesthetic("ymax"), "y"); - assert_eq!(primary_aesthetic("yend"), "y"); + // User-facing names without internal family handling return themselves + // (user-facing family resolution is handled by AestheticContext) + assert_eq!(primary_aesthetic("x"), "x"); + assert_eq!(primary_aesthetic("y"), "y"); + assert_eq!(primary_aesthetic("xmin"), "xmin"); + assert_eq!(primary_aesthetic("ymax"), "ymax"); } #[test] fn test_get_aesthetic_family() { - // Primary aesthetics return full family - let x_family = get_aesthetic_family("x"); - assert!(x_family.contains(&"x")); - assert!(x_family.contains(&"xmin")); - assert!(x_family.contains(&"xmax")); - assert!(x_family.contains(&"xend")); - assert_eq!(x_family.len(), 4); - - let y_family = get_aesthetic_family("y"); - assert!(y_family.contains(&"y")); - assert!(y_family.contains(&"ymin")); - assert!(y_family.contains(&"ymax")); - assert!(y_family.contains(&"yend")); - assert_eq!(y_family.len(), 4); - - // Variants return just themselves - assert_eq!(get_aesthetic_family("xmin"), vec!["xmin"]); - assert_eq!(get_aesthetic_family("ymax"), vec!["ymax"]); + // NOTE: get_aesthetic_family() now only handles internal names (pos1, pos2, etc.) + // and non-positional aesthetics. For user-facing families, use AestheticContext. + + // Internal positional primary returns full family + let pos1_family = get_aesthetic_family("pos1"); + assert!(pos1_family.iter().any(|s| s == "pos1")); + assert!(pos1_family.iter().any(|s| s == "pos1min")); + assert!(pos1_family.iter().any(|s| s == "pos1max")); + assert!(pos1_family.iter().any(|s| s == "pos1end")); + assert!(pos1_family.iter().any(|s| s == "pos1intercept")); + assert_eq!(pos1_family.len(), 5); + + let pos2_family = get_aesthetic_family("pos2"); + assert!(pos2_family.iter().any(|s| s == "pos2")); + assert!(pos2_family.iter().any(|s| s == "pos2min")); + assert!(pos2_family.iter().any(|s| s == "pos2max")); + assert!(pos2_family.iter().any(|s| s == "pos2end")); + assert!(pos2_family.iter().any(|s| s == "pos2intercept")); + assert_eq!(pos2_family.len(), 5); - // Non-family aesthetics return just themselves + // Internal positional variants return just themselves + assert_eq!(get_aesthetic_family("pos1min"), vec!["pos1min"]); + assert_eq!(get_aesthetic_family("pos2max"), vec!["pos2max"]); + + // Non-positional aesthetics return just themselves (no family) assert_eq!(get_aesthetic_family("color"), vec!["color"]); assert_eq!(get_aesthetic_family("fill"), vec!["fill"]); + + // User-facing names without families return just themselves + // (user-facing family resolution is handled by AestheticContext) + assert_eq!(get_aesthetic_family("x"), vec!["x"]); + assert_eq!(get_aesthetic_family("y"), vec!["y"]); + assert_eq!(get_aesthetic_family("xmin"), vec!["xmin"]); + } + + // ======================================================================== + // AestheticContext Tests + // ======================================================================== + + #[test] + fn test_aesthetic_context_cartesian() { + let ctx = AestheticContext::new(&["x", "y"], &[]); + + // User positional names + assert_eq!(ctx.user_positional(), &["x", "y"]); + + // All user positional (with suffixes) + let all_user: Vec<&str> = ctx.all_user_positional().iter().map(|s| s.as_str()).collect(); + assert!(all_user.contains(&"x")); + assert!(all_user.contains(&"xmin")); + assert!(all_user.contains(&"xmax")); + assert!(all_user.contains(&"xend")); + assert!(all_user.contains(&"xintercept")); + assert!(all_user.contains(&"y")); + assert!(all_user.contains(&"ymin")); + assert!(all_user.contains(&"ymax")); + assert!(all_user.contains(&"yend")); + assert!(all_user.contains(&"yintercept")); + + // Primary internal names + let primary: Vec<&str> = ctx.primary_internal().iter().map(|s| s.as_str()).collect(); + assert_eq!(primary, vec!["pos1", "pos2"]); + } + + #[test] + fn test_aesthetic_context_polar() { + let ctx = AestheticContext::new(&["theta", "radius"], &[]); + + // User positional names + assert_eq!(ctx.user_positional(), &["theta", "radius"]); + + // All user positional (with suffixes) + let all_user: Vec<&str> = ctx.all_user_positional().iter().map(|s| s.as_str()).collect(); + assert!(all_user.contains(&"theta")); + assert!(all_user.contains(&"thetamin")); + assert!(all_user.contains(&"thetamax")); + assert!(all_user.contains(&"thetaend")); + assert!(all_user.contains(&"thetaintercept")); + assert!(all_user.contains(&"radius")); + assert!(all_user.contains(&"radiusmin")); + assert!(all_user.contains(&"radiusmax")); + assert!(all_user.contains(&"radiusend")); + assert!(all_user.contains(&"radiusintercept")); + } + + #[test] + fn test_aesthetic_context_user_to_internal() { + let ctx = AestheticContext::new(&["x", "y"], &[]); + + // Primary aesthetics + assert_eq!(ctx.map_user_to_internal("x"), Some("pos1")); + assert_eq!(ctx.map_user_to_internal("y"), Some("pos2")); + + // Variants + assert_eq!(ctx.map_user_to_internal("xmin"), Some("pos1min")); + assert_eq!(ctx.map_user_to_internal("xmax"), Some("pos1max")); + assert_eq!(ctx.map_user_to_internal("xend"), Some("pos1end")); + assert_eq!(ctx.map_user_to_internal("ymin"), Some("pos2min")); + assert_eq!(ctx.map_user_to_internal("ymax"), Some("pos2max")); + assert_eq!(ctx.map_user_to_internal("yend"), Some("pos2end")); + + // Non-positional returns None + assert_eq!(ctx.map_user_to_internal("color"), None); + assert_eq!(ctx.map_user_to_internal("fill"), None); + } + + #[test] + fn test_aesthetic_context_internal_to_user() { + let ctx = AestheticContext::new(&["x", "y"], &[]); + + // Primary aesthetics + assert_eq!(ctx.map_internal_to_user("pos1"), Some("x")); + assert_eq!(ctx.map_internal_to_user("pos2"), Some("y")); + + // Variants + assert_eq!(ctx.map_internal_to_user("pos1min"), Some("xmin")); + assert_eq!(ctx.map_internal_to_user("pos1max"), Some("xmax")); + assert_eq!(ctx.map_internal_to_user("pos1end"), Some("xend")); + assert_eq!(ctx.map_internal_to_user("pos2min"), Some("ymin")); + assert_eq!(ctx.map_internal_to_user("pos2max"), Some("ymax")); + assert_eq!(ctx.map_internal_to_user("pos2end"), Some("yend")); + + // Unknown internal returns None + assert_eq!(ctx.map_internal_to_user("pos3"), None); + assert_eq!(ctx.map_internal_to_user("color"), None); + } + + #[test] + fn test_aesthetic_context_polar_mapping() { + let ctx = AestheticContext::new(&["theta", "radius"], &[]); + + // User to internal + assert_eq!(ctx.map_user_to_internal("theta"), Some("pos1")); + assert_eq!(ctx.map_user_to_internal("radius"), Some("pos2")); + assert_eq!(ctx.map_user_to_internal("thetaend"), Some("pos1end")); + assert_eq!(ctx.map_user_to_internal("radiusmin"), Some("pos2min")); + + // Internal to user + assert_eq!(ctx.map_internal_to_user("pos1"), Some("theta")); + assert_eq!(ctx.map_internal_to_user("pos2"), Some("radius")); + assert_eq!(ctx.map_internal_to_user("pos1end"), Some("thetaend")); + assert_eq!(ctx.map_internal_to_user("pos2min"), Some("radiusmin")); + } + + #[test] + fn test_aesthetic_context_is_checks() { + let ctx = AestheticContext::new(&["x", "y"], &[]); + + // User positional + assert!(ctx.is_user_positional("x")); + assert!(ctx.is_user_positional("ymin")); + assert!(!ctx.is_user_positional("color")); + assert!(!ctx.is_user_positional("pos1")); + + // Internal positional + assert!(ctx.is_internal_positional("pos1")); + assert!(ctx.is_internal_positional("pos2min")); + assert!(!ctx.is_internal_positional("x")); + assert!(!ctx.is_internal_positional("color")); + + // Primary internal + assert!(ctx.is_primary_internal("pos1")); + assert!(ctx.is_primary_internal("pos2")); + assert!(!ctx.is_primary_internal("pos1min")); + + // Non-positional + assert!(ctx.is_non_positional("color")); + assert!(ctx.is_non_positional("fill")); + assert!(!ctx.is_non_positional("x")); + assert!(!ctx.is_non_positional("pos1")); + } + + #[test] + fn test_aesthetic_context_with_facets() { + let ctx = AestheticContext::new(&["x", "y"], &["panel"]); + + assert!(ctx.is_facet("panel")); + assert!(!ctx.is_facet("row")); + assert_eq!(ctx.facet(), &["panel"]); + } + + #[test] + fn test_aesthetic_context_families() { + let ctx = AestheticContext::new(&["x", "y"], &[]); + + // Get internal family + let pos1_family = ctx.get_internal_family("pos1").unwrap(); + let pos1_strs: Vec<&str> = pos1_family.iter().map(|s| s.as_str()).collect(); + assert_eq!( + pos1_strs, + vec!["pos1", "pos1min", "pos1max", "pos1end", "pos1intercept"] + ); + + // Get user family + let x_family = ctx.get_user_family("x").unwrap(); + let x_strs: Vec<&str> = x_family.iter().map(|s| s.as_str()).collect(); + assert_eq!(x_strs, vec!["x", "xmin", "xmax", "xend", "xintercept"]); + + // Primary internal aesthetic + assert_eq!(ctx.primary_internal_aesthetic("pos1"), Some("pos1")); + assert_eq!(ctx.primary_internal_aesthetic("pos1min"), Some("pos1")); + assert_eq!(ctx.primary_internal_aesthetic("pos2end"), Some("pos2")); + assert_eq!(ctx.primary_internal_aesthetic("pos1intercept"), Some("pos1")); + assert_eq!(ctx.primary_internal_aesthetic("color"), Some("color")); + } + + #[test] + fn test_aesthetic_context_user_family_resolution() { + // Cartesian: user-facing families are x/y based + let cartesian = AestheticContext::new(&["x", "y"], &[]); + assert_eq!(cartesian.primary_user_aesthetic("x"), Some("x")); + assert_eq!(cartesian.primary_user_aesthetic("xmin"), Some("x")); + assert_eq!(cartesian.primary_user_aesthetic("xmax"), Some("x")); + assert_eq!(cartesian.primary_user_aesthetic("xend"), Some("x")); + assert_eq!(cartesian.primary_user_aesthetic("xintercept"), Some("x")); + assert_eq!(cartesian.primary_user_aesthetic("y"), Some("y")); + assert_eq!(cartesian.primary_user_aesthetic("ymin"), Some("y")); + assert_eq!(cartesian.primary_user_aesthetic("ymax"), Some("y")); + assert_eq!(cartesian.primary_user_aesthetic("color"), Some("color")); + + // Polar: user-facing families are theta/radius based + let polar = AestheticContext::new(&["theta", "radius"], &[]); + assert_eq!(polar.primary_user_aesthetic("theta"), Some("theta")); + assert_eq!(polar.primary_user_aesthetic("thetamin"), Some("theta")); + assert_eq!(polar.primary_user_aesthetic("thetamax"), Some("theta")); + assert_eq!(polar.primary_user_aesthetic("thetaend"), Some("theta")); + assert_eq!(polar.primary_user_aesthetic("radius"), Some("radius")); + assert_eq!(polar.primary_user_aesthetic("radiusmin"), Some("radius")); + assert_eq!(polar.primary_user_aesthetic("radiusmax"), Some("radius")); + assert_eq!(polar.primary_user_aesthetic("color"), Some("color")); + + // Polar doesn't know about cartesian aesthetics + assert_eq!(polar.primary_user_aesthetic("x"), None); + assert_eq!(polar.primary_user_aesthetic("xmin"), None); + } + + #[test] + fn test_aesthetic_context_polar_user_families() { + // Verify polar coords have correct user families + let ctx = AestheticContext::new(&["theta", "radius"], &[]); + + // Get user family for theta + let theta_family = ctx.get_user_family("theta").unwrap(); + let theta_strs: Vec<&str> = theta_family.iter().map(|s| s.as_str()).collect(); + assert_eq!( + theta_strs, + vec!["theta", "thetamin", "thetamax", "thetaend", "thetaintercept"] + ); + + // Get user family for radius + let radius_family = ctx.get_user_family("radius").unwrap(); + let radius_strs: Vec<&str> = radius_family.iter().map(|s| s.as_str()).collect(); + assert_eq!( + radius_strs, + vec!["radius", "radiusmin", "radiusmax", "radiusend", "radiusintercept"] + ); + + // But internal families are the same for all coords + let pos1_family = ctx.get_internal_family("pos1").unwrap(); + let pos1_strs: Vec<&str> = pos1_family.iter().map(|s| s.as_str()).collect(); + assert_eq!( + pos1_strs, + vec!["pos1", "pos1min", "pos1max", "pos1end", "pos1intercept"] + ); } } diff --git a/src/plot/layer/geom/area.rs b/src/plot/layer/geom/area.rs index f191bc16..79ca1b24 100644 --- a/src/plot/layer/geom/area.rs +++ b/src/plot/layer/geom/area.rs @@ -16,15 +16,15 @@ impl GeomTrait for Area { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { supported: &[ - "x", - "y", + "pos1", + "pos2", "fill", "stroke", "opacity", "linewidth", // "linetype", // vegalite doesn't support strokeDash ], - required: &["x", "y"], + required: &["pos1", "pos2"], hidden: &[], } } diff --git a/src/plot/layer/geom/arrow.rs b/src/plot/layer/geom/arrow.rs index 2baa5396..5ab67312 100644 --- a/src/plot/layer/geom/arrow.rs +++ b/src/plot/layer/geom/arrow.rs @@ -14,16 +14,16 @@ impl GeomTrait for Arrow { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { supported: &[ - "x", - "y", - "xend", - "yend", + "pos1", + "pos2", + "pos1end", + "pos2end", "stroke", "linetype", "linewidth", "opacity", ], - required: &["x", "y", "xend", "yend"], + required: &["pos1", "pos2", "pos1end", "pos2end"], hidden: &[], } } diff --git a/src/plot/layer/geom/bar.rs b/src/plot/layer/geom/bar.rs index 7ab7e2bf..2979ceb5 100644 --- a/src/plot/layer/geom/bar.rs +++ b/src/plot/layer/geom/bar.rs @@ -26,7 +26,7 @@ impl GeomTrait for Bar { // If x is missing: single bar showing total // If y is missing: stat computes COUNT or SUM(weight) // weight: optional, if mapped uses SUM(weight) instead of COUNT(*) - supported: &["x", "y", "weight", "fill", "stroke", "width", "opacity"], + supported: &["pos1", "pos2", "weight", "fill", "stroke", "width", "opacity"], required: &[], hidden: &[], } @@ -34,14 +34,14 @@ impl GeomTrait for Bar { fn default_remappings(&self) -> &'static [(&'static str, DefaultAestheticValue)] { &[ - ("y", DefaultAestheticValue::Column("count")), - ("x", DefaultAestheticValue::Column("x")), - ("yend", DefaultAestheticValue::Number(0.0)), + ("pos2", DefaultAestheticValue::Column("count")), + ("pos1", DefaultAestheticValue::Column("pos1")), + ("pos2end", DefaultAestheticValue::Number(0.0)), ] } fn valid_stat_columns(&self) -> &'static [&'static str] { - &["count", "x", "proportion"] + &["count", "pos1", "proportion"] } fn default_params(&self) -> &'static [DefaultParam] { @@ -52,7 +52,7 @@ impl GeomTrait for Bar { } fn stat_consumed_aesthetics(&self) -> &'static [&'static str] { - &["x", "y", "weight"] + &["pos1", "pos2", "weight"] } fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool { @@ -105,7 +105,7 @@ fn stat_bar_count( group_by: &[String], ) -> Result { // x is now optional - if not mapped, we'll use a dummy constant - let x_col = get_column_name(aesthetics, "x"); + let x_col = get_column_name(aesthetics, "pos1"); let use_dummy_x = x_col.is_none(); // Build column lookup set from pre-fetched schema @@ -113,7 +113,7 @@ fn stat_bar_count( // Check if y is mapped // Note: With upfront validation, if y is mapped to a column, that column must exist - if let Some(y_value) = aesthetics.get("y") { + if let Some(y_value) = aesthetics.get("pos2") { // y is a literal value - use identity (no transformation) if y_value.is_literal() { return Ok(StatResult::Identity); @@ -139,7 +139,7 @@ fn stat_bar_count( // Define stat column names let stat_count = naming::stat_column("count"); let stat_proportion = naming::stat_column("proportion"); - let stat_x = naming::stat_column("x"); + let stat_x = naming::stat_column("pos1"); let stat_dummy_value = naming::stat_column("dummy"); // Value used for dummy x let agg_expr = if let Some(weight_value) = aesthetics.get("weight") { @@ -230,11 +230,11 @@ fn stat_bar_count( ( query_str, vec![ - "x".to_string(), + "pos1".to_string(), "count".to_string(), "proportion".to_string(), ], - vec!["x".to_string()], + vec!["pos1".to_string()], vec!["weight".to_string()], ) } else { diff --git a/src/plot/layer/geom/boxplot.rs b/src/plot/layer/geom/boxplot.rs index 8a95ee85..23e3f77d 100644 --- a/src/plot/layer/geom/boxplot.rs +++ b/src/plot/layer/geom/boxplot.rs @@ -24,8 +24,8 @@ impl GeomTrait for Boxplot { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { supported: &[ - "x", - "y", + "pos1", + "pos2", "fill", "stroke", "opacity", @@ -34,14 +34,14 @@ impl GeomTrait for Boxplot { "size", "shape", ], - required: &["x", "y"], + required: &["pos1", "pos2"], // Internal aesthetics produced by stat transform - hidden: &["type", "y", "yend"], + hidden: &["type", "pos2", "pos2end"], } } fn stat_consumed_aesthetics(&self) -> &'static [&'static str] { - &["y"] + &["pos2"] } fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool { @@ -67,8 +67,8 @@ impl GeomTrait for Boxplot { fn default_remappings(&self) -> &'static [(&'static str, DefaultAestheticValue)] { &[ - ("y", DefaultAestheticValue::Column("value")), - ("yend", DefaultAestheticValue::Column("value2")), + ("pos2", DefaultAestheticValue::Column("value")), + ("pos2end", DefaultAestheticValue::Column("value2")), ("type", DefaultAestheticValue::Column("type")), ] } @@ -98,10 +98,10 @@ fn stat_boxplot( group_by: &[String], parameters: &HashMap, ) -> Result { - let y = get_column_name(aesthetics, "y").ok_or_else(|| { + let y = get_column_name(aesthetics, "pos2").ok_or_else(|| { GgsqlError::ValidationError("Boxplot requires 'y' aesthetic mapping".to_string()) })?; - let x = get_column_name(aesthetics, "x").ok_or_else(|| { + let x = get_column_name(aesthetics, "pos1").ok_or_else(|| { GgsqlError::ValidationError("Boxplot requires 'x' aesthetic mapping".to_string()) })?; @@ -153,7 +153,7 @@ fn stat_boxplot( "value2".to_string(), ], dummy_columns: vec![], - consumed_aesthetics: vec!["y".to_string()], + consumed_aesthetics: vec!["pos2".to_string()], }) } @@ -287,11 +287,11 @@ mod tests { fn create_basic_aesthetics() -> Mappings { let mut aesthetics = Mappings::new(); aesthetics.insert( - "x".to_string(), + "pos1".to_string(), AestheticValue::standard_column("category".to_string()), ); aesthetics.insert( - "y".to_string(), + "pos2".to_string(), AestheticValue::standard_column("value".to_string()), ); aesthetics @@ -324,8 +324,8 @@ mod tests { #[test] fn test_sql_compute_summary_custom_coef() { - let groups = vec!["x".to_string()]; - let result = boxplot_sql_compute_summary("q", &groups, "y", &2.5); + let groups = vec!["pos1".to_string()]; + let result = boxplot_sql_compute_summary("q", &groups, "pos2", &2.5); assert!(result.contains("2.5")); assert!(result.contains("GREATEST(q1 - 2.5 * (q3 - q1), min)")); assert!(result.contains("LEAST( q3 + 2.5 * (q3 - q1), max)")); @@ -422,10 +422,10 @@ mod tests { #[test] fn test_boxplot_sql_append_outliers_without_outliers() { - let groups = vec!["x".to_string()]; + let groups = vec!["pos1".to_string()]; let summary = "sum_query"; let raw = "raw_query"; - let result = boxplot_sql_append_outliers(summary, &groups, "y", raw, &false); + let result = boxplot_sql_append_outliers(summary, &groups, "pos2", raw, &false); // Should NOT include WITH or outliers CTE assert!(!result.contains("WITH")); @@ -547,8 +547,8 @@ mod tests { let boxplot = Boxplot; let aes = boxplot.aesthetics(); - assert!(aes.required.contains(&"x")); - assert!(aes.required.contains(&"y")); + assert!(aes.required.contains(&"pos1")); + assert!(aes.required.contains(&"pos2")); assert_eq!(aes.required.len(), 2); } @@ -557,8 +557,8 @@ mod tests { let boxplot = Boxplot; let aes = boxplot.aesthetics(); - assert!(aes.supported.contains(&"x")); - assert!(aes.supported.contains(&"y")); + assert!(aes.supported.contains(&"pos1")); + assert!(aes.supported.contains(&"pos2")); assert!(aes.supported.contains(&"fill")); assert!(aes.supported.contains(&"stroke")); assert!(aes.supported.contains(&"opacity")); @@ -599,8 +599,8 @@ mod tests { let remappings = boxplot.default_remappings(); assert_eq!(remappings.len(), 3); - assert!(remappings.contains(&("y", DefaultAestheticValue::Column("value")))); - assert!(remappings.contains(&("yend", DefaultAestheticValue::Column("value2")))); + assert!(remappings.contains(&("pos2", DefaultAestheticValue::Column("value")))); + assert!(remappings.contains(&("pos2end", DefaultAestheticValue::Column("value2")))); assert!(remappings.contains(&("type", DefaultAestheticValue::Column("type")))); } @@ -610,7 +610,7 @@ mod tests { let consumed = boxplot.stat_consumed_aesthetics(); assert_eq!(consumed.len(), 1); - assert_eq!(consumed[0], "y"); + assert_eq!(consumed[0], "pos2"); } #[test] diff --git a/src/plot/layer/geom/density.rs b/src/plot/layer/geom/density.rs index ff3c9e19..661c1442 100644 --- a/src/plot/layer/geom/density.rs +++ b/src/plot/layer/geom/density.rs @@ -27,7 +27,7 @@ impl GeomTrait for Density { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { supported: &[ - "x", + "pos1", "weight", "fill", "stroke", @@ -35,8 +35,8 @@ impl GeomTrait for Density { "linewidth", "linetype", ], - required: &["x"], - hidden: &["y"], + required: &["pos1"], + hidden: &["pos2"], } } @@ -67,17 +67,17 @@ impl GeomTrait for Density { fn default_remappings(&self) -> &'static [(&'static str, DefaultAestheticValue)] { &[ - ("x", DefaultAestheticValue::Column("x")), - ("y", DefaultAestheticValue::Column("density")), + ("pos1", DefaultAestheticValue::Column("pos1")), + ("pos2", DefaultAestheticValue::Column("density")), ] } fn valid_stat_columns(&self) -> &'static [&'static str] { - &["x", "density", "intensity"] + &["pos1", "density", "intensity"] } fn stat_consumed_aesthetics(&self) -> &'static [&'static str] { - &["x", "weight"] + &["pos1", "weight"] } fn apply_stat_transform( @@ -89,7 +89,7 @@ impl GeomTrait for Density { parameters: &std::collections::HashMap, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result { - stat_density(query, aesthetics, "x", group_by, parameters, execute_query) + stat_density(query, aesthetics, "pos1", group_by, parameters, execute_query) } } @@ -926,29 +926,29 @@ mod tests { println!("Number of rows: {}", df.height()); // After remapping, stat columns are renamed to aesthetic columns - // The stat transform produces: x, intensity, density - // With REMAPPING intensity AS y, we get: __ggsql_aes_x__, __ggsql_aes_y__ - // (y is mapped from intensity, not the default density) + // The stat transform produces: pos1, intensity, density + // With REMAPPING intensity AS y, we get: __ggsql_aes_pos1__, __ggsql_aes_pos2__ + // (pos2 is mapped from intensity, not the default density) let col_names: Vec<&str> = df.get_column_names().iter().map(|s| s.as_str()).collect(); - // Should have x and y aesthetics after remapping + // Should have pos1 and pos2 aesthetics after remapping (internal names) assert!( - col_names.contains(&"__ggsql_aes_x__"), - "Should have x aesthetic, got: {:?}", + col_names.contains(&"__ggsql_aes_pos1__"), + "Should have pos1 aesthetic, got: {:?}", col_names ); assert!( - col_names.contains(&"__ggsql_aes_y__"), - "Should have y aesthetic, got: {:?}", + col_names.contains(&"__ggsql_aes_pos2__"), + "Should have pos2 aesthetic, got: {:?}", col_names ); // Verify we have data assert!(df.height() > 0); - // Verify y values (from intensity) are non-negative - let y_col = df.column("__ggsql_aes_y__").expect("y aesthetic exists"); + // Verify pos2 values (from intensity) are non-negative + let y_col = df.column("__ggsql_aes_pos2__").expect("pos2 aesthetic exists"); let all_non_negative = y_col .f64() .expect("y is f64") diff --git a/src/plot/layer/geom/errorbar.rs b/src/plot/layer/geom/errorbar.rs index eb007d60..9dcd4698 100644 --- a/src/plot/layer/geom/errorbar.rs +++ b/src/plot/layer/geom/errorbar.rs @@ -14,12 +14,12 @@ impl GeomTrait for ErrorBar { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { supported: &[ - "x", - "y", - "ymin", - "ymax", - "xmin", - "xmax", + "pos1", + "pos2", + "pos2min", + "pos2max", + "pos1min", + "pos1max", "stroke", "linewidth", "opacity", diff --git a/src/plot/layer/geom/histogram.rs b/src/plot/layer/geom/histogram.rs index 2a4358ae..9d95ec66 100644 --- a/src/plot/layer/geom/histogram.rs +++ b/src/plot/layer/geom/histogram.rs @@ -21,19 +21,19 @@ impl GeomTrait for Histogram { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { - supported: &["x", "weight", "fill", "stroke", "opacity"], - required: &["x"], + supported: &["pos1", "weight", "fill", "stroke", "opacity"], + required: &["pos1"], // y and xend are produced by stat_histogram but not valid for manual MAPPING - hidden: &["y", "xend"], + hidden: &["pos2", "pos1end"], } } fn default_remappings(&self) -> &'static [(&'static str, DefaultAestheticValue)] { &[ - ("x", DefaultAestheticValue::Column("bin")), - ("xend", DefaultAestheticValue::Column("bin_end")), - ("y", DefaultAestheticValue::Column("count")), - ("yend", DefaultAestheticValue::Number(0.0)), + ("pos1", DefaultAestheticValue::Column("bin")), + ("pos1end", DefaultAestheticValue::Column("bin_end")), + ("pos2", DefaultAestheticValue::Column("count")), + ("pos2end", DefaultAestheticValue::Number(0.0)), ] } @@ -59,7 +59,7 @@ impl GeomTrait for Histogram { } fn stat_consumed_aesthetics(&self) -> &'static [&'static str] { - &["x"] + &["pos1"] } fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool { @@ -94,7 +94,7 @@ fn stat_histogram( execute_query: &dyn Fn(&str) -> Result, ) -> Result { // Get x column name from aesthetics - let x_col = get_column_name(aesthetics, "x").ok_or_else(|| { + let x_col = get_column_name(aesthetics, "pos1").ok_or_else(|| { GgsqlError::ValidationError("Histogram requires 'x' aesthetic mapping".to_string()) })?; @@ -244,7 +244,7 @@ fn stat_histogram( "density".to_string(), ], dummy_columns: vec![], - consumed_aesthetics: vec!["x".to_string(), "weight".to_string()], + consumed_aesthetics: vec!["pos1".to_string(), "weight".to_string()], }) } diff --git a/src/plot/layer/geom/hline.rs b/src/plot/layer/geom/hline.rs index 1843cc19..cc8ce032 100644 --- a/src/plot/layer/geom/hline.rs +++ b/src/plot/layer/geom/hline.rs @@ -13,8 +13,8 @@ impl GeomTrait for HLine { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { - supported: &["yintercept", "stroke", "linetype", "linewidth", "opacity"], - required: &["yintercept"], + supported: &["pos2intercept", "stroke", "linetype", "linewidth", "opacity"], + required: &["pos2intercept"], hidden: &[], } } diff --git a/src/plot/layer/geom/label.rs b/src/plot/layer/geom/label.rs index 8481898a..f12f92b9 100644 --- a/src/plot/layer/geom/label.rs +++ b/src/plot/layer/geom/label.rs @@ -14,10 +14,10 @@ impl GeomTrait for Label { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { supported: &[ - "x", "y", "label", "fill", "stroke", "size", "opacity", "family", "fontface", + "pos1", "pos2", "label", "fill", "stroke", "size", "opacity", "family", "fontface", "hjust", "vjust", ], - required: &["x", "y"], + required: &["pos1", "pos2"], hidden: &[], } } diff --git a/src/plot/layer/geom/line.rs b/src/plot/layer/geom/line.rs index f26d7494..4d959968 100644 --- a/src/plot/layer/geom/line.rs +++ b/src/plot/layer/geom/line.rs @@ -13,8 +13,8 @@ impl GeomTrait for Line { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { - supported: &["x", "y", "stroke", "linetype", "linewidth", "opacity"], - required: &["x", "y"], + supported: &["pos1", "pos2", "stroke", "linetype", "linewidth", "opacity"], + required: &["pos1", "pos2"], hidden: &[], } } diff --git a/src/plot/layer/geom/mod.rs b/src/plot/layer/geom/mod.rs index 0b34b245..dd506d67 100644 --- a/src/plot/layer/geom/mod.rs +++ b/src/plot/layer/geom/mod.rs @@ -17,7 +17,7 @@ //! //! let point = Geom::point(); //! assert_eq!(point.geom_type(), GeomType::Point); -//! assert!(point.aesthetics().required.contains(&"x")); +//! assert!(point.aesthetics().required.contains(&"pos1")); //! ``` use crate::{DataFrame, Mappings, Result}; @@ -54,7 +54,7 @@ mod vline; pub use types::{DefaultParam, DefaultParamValue, GeomAesthetics, StatResult}; // Re-export aesthetic family utilities from the central module -pub use crate::plot::aesthetic::{get_aesthetic_family, AESTHETIC_FAMILIES}; +pub use crate::plot::aesthetic::get_aesthetic_family; // Re-export geom structs for direct access if needed pub use abline::AbLine; @@ -506,8 +506,8 @@ mod tests { fn test_geom_aesthetics() { let point = Geom::point(); let aes = point.aesthetics(); - assert!(aes.required.contains(&"x")); - assert!(aes.required.contains(&"y")); + assert!(aes.required.contains(&"pos1")); + assert!(aes.required.contains(&"pos2")); } #[test] diff --git a/src/plot/layer/geom/path.rs b/src/plot/layer/geom/path.rs index f289032c..151c84fb 100644 --- a/src/plot/layer/geom/path.rs +++ b/src/plot/layer/geom/path.rs @@ -13,8 +13,8 @@ impl GeomTrait for Path { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { - supported: &["x", "y", "stroke", "linetype", "linewidth", "opacity"], - required: &["x", "y"], + supported: &["pos1", "pos2", "stroke", "linetype", "linewidth", "opacity"], + required: &["pos1", "pos2"], hidden: &[], } } diff --git a/src/plot/layer/geom/point.rs b/src/plot/layer/geom/point.rs index 0e925795..7adff7cf 100644 --- a/src/plot/layer/geom/point.rs +++ b/src/plot/layer/geom/point.rs @@ -14,8 +14,8 @@ impl GeomTrait for Point { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { supported: &[ - "x", - "y", + "pos1", + "pos2", "fill", "stroke", "size", @@ -23,7 +23,7 @@ impl GeomTrait for Point { "opacity", "linewidth", ], - required: &["x", "y"], + required: &["pos1", "pos2"], hidden: &[], } } diff --git a/src/plot/layer/geom/polygon.rs b/src/plot/layer/geom/polygon.rs index e232db34..a88bc02e 100644 --- a/src/plot/layer/geom/polygon.rs +++ b/src/plot/layer/geom/polygon.rs @@ -14,15 +14,15 @@ impl GeomTrait for Polygon { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { supported: &[ - "x", - "y", + "pos1", + "pos2", "fill", "stroke", "opacity", "linewidth", "linetype", ], - required: &["x", "y"], + required: &["pos1", "pos2"], hidden: &[], } } diff --git a/src/plot/layer/geom/ribbon.rs b/src/plot/layer/geom/ribbon.rs index c6d62073..a4d3eee0 100644 --- a/src/plot/layer/geom/ribbon.rs +++ b/src/plot/layer/geom/ribbon.rs @@ -14,16 +14,16 @@ impl GeomTrait for Ribbon { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { supported: &[ - "x", - "ymin", - "ymax", + "pos1", + "pos2min", + "pos2max", "fill", "stroke", "opacity", "linewidth", // "linetype" // vegalite doesn't support strokeDash ], - required: &["x", "ymin", "ymax"], + required: &["pos1", "pos2min", "pos2max"], hidden: &[], } } diff --git a/src/plot/layer/geom/segment.rs b/src/plot/layer/geom/segment.rs index 0c26cc02..6d662895 100644 --- a/src/plot/layer/geom/segment.rs +++ b/src/plot/layer/geom/segment.rs @@ -14,16 +14,16 @@ impl GeomTrait for Segment { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { supported: &[ - "x", - "y", - "xend", - "yend", + "pos1", + "pos2", + "pos1end", + "pos2end", "stroke", "linetype", "linewidth", "opacity", ], - required: &["x", "y", "xend", "yend"], + required: &["pos1", "pos2", "pos1end", "pos2end"], hidden: &[], } } diff --git a/src/plot/layer/geom/smooth.rs b/src/plot/layer/geom/smooth.rs index 06243523..929e3009 100644 --- a/src/plot/layer/geom/smooth.rs +++ b/src/plot/layer/geom/smooth.rs @@ -14,8 +14,8 @@ impl GeomTrait for Smooth { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { - supported: &["x", "y", "stroke", "linetype", "opacity"], - required: &["x", "y"], + supported: &["pos1", "pos2", "stroke", "linetype", "opacity"], + required: &["pos1", "pos2"], hidden: &[], } } diff --git a/src/plot/layer/geom/text.rs b/src/plot/layer/geom/text.rs index 7107f5c5..673d2979 100644 --- a/src/plot/layer/geom/text.rs +++ b/src/plot/layer/geom/text.rs @@ -14,10 +14,10 @@ impl GeomTrait for Text { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { supported: &[ - "x", "y", "label", "stroke", "size", "opacity", "family", "fontface", "hjust", + "pos1", "pos2", "label", "stroke", "size", "opacity", "family", "fontface", "hjust", "vjust", ], - required: &["x", "y"], + required: &["pos1", "pos2"], hidden: &[], } } diff --git a/src/plot/layer/geom/tile.rs b/src/plot/layer/geom/tile.rs index effe2a89..3b56cbab 100644 --- a/src/plot/layer/geom/tile.rs +++ b/src/plot/layer/geom/tile.rs @@ -13,8 +13,8 @@ impl GeomTrait for Tile { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { - supported: &["x", "y", "fill", "stroke", "width", "height", "opacity"], - required: &["x", "y"], + supported: &["pos1", "pos2", "fill", "stroke", "width", "height", "opacity"], + required: &["pos1", "pos2"], hidden: &[], } } diff --git a/src/plot/layer/geom/types.rs b/src/plot/layer/geom/types.rs index 2d4ecc97..099787ea 100644 --- a/src/plot/layer/geom/types.rs +++ b/src/plot/layer/geom/types.rs @@ -47,12 +47,12 @@ pub enum StatResult { Transformed { /// The transformed SQL query that produces the stat-computed columns query: String, - /// Names of stat-computed columns (e.g., ["count", "bin", "x"]) + /// Names of stat-computed columns (e.g., ["count", "bin", "pos1"]) /// These are semantic names that will be prefixed with __ggsql_stat__ /// and mapped to aesthetics via default_remappings or REMAPPING clause stat_columns: Vec, /// Names of stat columns that are dummy/placeholder values - /// (e.g., "x" when bar chart has no x mapped - produces a constant value) + /// (e.g., "pos1" when bar chart has no x mapped - produces a constant value) dummy_columns: Vec, /// Names of aesthetics consumed by this stat transform /// These aesthetics were used as input to the stat and should be removed diff --git a/src/plot/layer/geom/violin.rs b/src/plot/layer/geom/violin.rs index 0335009e..837089a2 100644 --- a/src/plot/layer/geom/violin.rs +++ b/src/plot/layer/geom/violin.rs @@ -22,8 +22,8 @@ impl GeomTrait for Violin { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { supported: &[ - "x", - "y", + "pos1", + "pos2", "weight", "fill", "stroke", @@ -31,7 +31,7 @@ impl GeomTrait for Violin { "linewidth", "linetype", ], - required: &["x", "y"], + required: &["pos1", "pos2"], hidden: &["offset"], } } @@ -59,17 +59,17 @@ impl GeomTrait for Violin { fn default_remappings(&self) -> &'static [(&'static str, DefaultAestheticValue)] { &[ - ("y", DefaultAestheticValue::Column("y")), + ("pos2", DefaultAestheticValue::Column("pos2")), ("offset", DefaultAestheticValue::Column("density")), ] } fn valid_stat_columns(&self) -> &'static [&'static str] { - &["y", "density", "intensity"] + &["pos2", "density", "intensity"] } fn stat_consumed_aesthetics(&self) -> &'static [&'static str] { - &["y", "weight"] + &["pos2", "weight"] } fn apply_stat_transform( @@ -99,14 +99,14 @@ fn stat_violin( execute: &dyn Fn(&str) -> crate::Result, ) -> Result { // Verify y exists - if get_column_name(aesthetics, "y").is_none() { + if get_column_name(aesthetics, "pos2").is_none() { return Err(GgsqlError::ValidationError( "Violin requires 'y' aesthetic mapping (continuous)".to_string(), )); } let mut group_by = group_by.to_vec(); - if let Some(x_col) = get_column_name(aesthetics, "x") { + if let Some(x_col) = get_column_name(aesthetics, "pos1") { // We want to ensure x is included as a grouping if !group_by.contains(&x_col) { group_by.push(x_col); @@ -120,7 +120,7 @@ fn stat_violin( super::density::stat_density( query, aesthetics, - "y", + "pos2", group_by.as_slice(), parameters, execute, @@ -139,11 +139,11 @@ mod tests { fn create_basic_aesthetics() -> Mappings { let mut aesthetics = Mappings::new(); aesthetics.insert( - "x".to_string(), + "pos1".to_string(), AestheticValue::standard_column("species".to_string()), ); aesthetics.insert( - "y".to_string(), + "pos2".to_string(), AestheticValue::standard_column("flipper_length".to_string()), ); aesthetics @@ -197,18 +197,18 @@ mod tests { .. } => { // Verify stat columns (includes intensity from density stat) - assert_eq!(stat_columns, vec!["y", "intensity", "density"]); + assert_eq!(stat_columns, vec!["pos2", "intensity", "density"]); // Verify consumed aesthetics - assert_eq!(consumed_aesthetics, vec!["y"]); + assert_eq!(consumed_aesthetics, vec!["pos2"]); // Execute the generated SQL and verify it works let df = execute(&stat_query).expect("Generated SQL should execute"); - // Should have columns: y, density, and species (the x grouping) + // Should have columns: pos2 (y), density, and species (the x grouping) let col_names: Vec<&str> = df.get_column_names().iter().map(|s| s.as_str()).collect(); - assert!(col_names.contains(&"__ggsql_stat_y")); + assert!(col_names.contains(&"__ggsql_stat_pos2")); assert!(col_names.contains(&"__ggsql_stat_density")); assert!(col_names.contains(&"species")); @@ -262,18 +262,18 @@ mod tests { .. } => { // Verify stat columns (includes intensity from density stat) - assert_eq!(stat_columns, vec!["y", "intensity", "density"]); + assert_eq!(stat_columns, vec!["pos2", "intensity", "density"]); // Verify consumed aesthetics - assert_eq!(consumed_aesthetics, vec!["y"]); + assert_eq!(consumed_aesthetics, vec!["pos2"]); // Execute the generated SQL and verify it works let df = execute(&stat_query).expect("Generated SQL should execute"); - // Should have columns: y, density, species (x), and island (color group) + // Should have columns: pos2 (y), density, species (x), and island (color group) let col_names: Vec<&str> = df.get_column_names().iter().map(|s| s.as_str()).collect(); - assert!(col_names.contains(&"__ggsql_stat_y")); + assert!(col_names.contains(&"__ggsql_stat_pos2")); assert!(col_names.contains(&"__ggsql_stat_density")); assert!(col_names.contains(&"species")); assert!(col_names.contains(&"island")); diff --git a/src/plot/layer/geom/vline.rs b/src/plot/layer/geom/vline.rs index 84ff2bba..a611037c 100644 --- a/src/plot/layer/geom/vline.rs +++ b/src/plot/layer/geom/vline.rs @@ -13,8 +13,8 @@ impl GeomTrait for VLine { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { - supported: &["xintercept", "stroke", "linetype", "linewidth", "opacity"], - required: &["xintercept"], + supported: &["pos1intercept", "stroke", "linetype", "linewidth", "opacity"], + required: &["pos1intercept"], hidden: &[], } } diff --git a/src/plot/main.rs b/src/plot/main.rs index 02262b2d..9a9d8ac0 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -22,6 +22,8 @@ use crate::naming; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use super::aesthetic::AestheticContext; + // Re-export input types pub use super::types::{ AestheticValue, ArrayElement, ColumnInfo, DataSource, DefaultAestheticValue, Mappings, @@ -48,7 +50,7 @@ pub use super::projection::{Coord, Projection}; pub use super::facet::{Facet, FacetLayout}; /// Complete ggsql visualization specification -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Plot { /// Global aesthetic mappings (from VISUALISE clause) pub global_mappings: Mappings, @@ -66,6 +68,10 @@ pub struct Plot { pub labels: Option, /// Theme styling (from THEME clause) pub theme: Option, + /// Aesthetic context for coordinate-specific aesthetic names + /// Computed from the coord type and facet, used for transformations + #[serde(skip)] + pub aesthetic_context: Option, } /// Text labels (from LABELS clause) @@ -84,6 +90,20 @@ pub struct Theme { pub properties: HashMap, } +// Manual PartialEq implementation (aesthetic_context is derived, not compared) +impl PartialEq for Plot { + fn eq(&self, other: &Self) -> bool { + self.global_mappings == other.global_mappings + && self.source == other.source + && self.layers == other.layers + && self.scales == other.scales + && self.facet == other.facet + && self.project == other.project + && self.labels == other.labels + && self.theme == other.theme + } +} + impl Plot { /// Create a new empty Plot pub fn new() -> Self { @@ -96,6 +116,7 @@ impl Plot { project: None, labels: None, theme: None, + aesthetic_context: None, } } @@ -110,6 +131,70 @@ impl Plot { project: None, labels: None, theme: None, + aesthetic_context: None, + } + } + + /// Get the aesthetic context, creating a default one if not set + pub fn get_aesthetic_context(&self) -> AestheticContext { + if let Some(ref ctx) = self.aesthetic_context { + ctx.clone() + } else { + // Create default based on coord (Cartesian if no project specified) + let positional_names = self + .project + .as_ref() + .map(|p| p.coord.positional_aesthetic_names()) + .unwrap_or(&["x", "y"]); + let facet_names = self + .facet + .as_ref() + .map(|f| f.layout.get_aesthetics()) + .unwrap_or_default(); + AestheticContext::new(positional_names, &facet_names) + } + } + + /// Set the aesthetic context based on the current coord and facet + pub fn initialize_aesthetic_context(&mut self) { + let positional_names = self + .project + .as_ref() + .map(|p| p.coord.positional_aesthetic_names()) + .unwrap_or(&["x", "y"]); + let facet_names = self + .facet + .as_ref() + .map(|f| f.layout.get_aesthetics()) + .unwrap_or_default(); + self.aesthetic_context = Some(AestheticContext::new(positional_names, &facet_names)); + } + + /// Transform all aesthetic keys from user-facing to internal names. + /// + /// This should be called after the Plot is fully built and the aesthetic context + /// is initialized. It transforms: + /// - Global mappings + /// - Layer aesthetics + /// - Layer remappings + /// - Scale aesthetics + pub fn transform_aesthetics_to_internal(&mut self) { + let ctx = self.get_aesthetic_context(); + + // Transform global mappings + self.global_mappings.transform_to_internal(&ctx); + + // Transform layer aesthetics and remappings + for layer in &mut self.layers { + layer.mappings.transform_to_internal(&ctx); + layer.remappings.transform_to_internal(&ctx); + } + + // Transform scale aesthetics + for scale in &mut self.scales { + if let Some(internal) = ctx.map_user_to_internal(&scale.aesthetic) { + scale.aesthetic = internal.to_string(); + } } } @@ -263,21 +348,22 @@ mod tests { #[test] fn test_layer_validation() { + // Use internal aesthetic names (pos1, pos2) as geoms expect internal names let valid_point = Layer::new(Geom::point()) - .with_aesthetic("x".to_string(), AestheticValue::standard_column("x")) - .with_aesthetic("y".to_string(), AestheticValue::standard_column("y")); + .with_aesthetic("pos1".to_string(), AestheticValue::standard_column("x")) + .with_aesthetic("pos2".to_string(), AestheticValue::standard_column("y")); assert!(valid_point.validate_required_aesthetics().is_ok()); let invalid_point = Layer::new(Geom::point()) - .with_aesthetic("x".to_string(), AestheticValue::standard_column("x")); + .with_aesthetic("pos1".to_string(), AestheticValue::standard_column("x")); assert!(invalid_point.validate_required_aesthetics().is_err()); let valid_ribbon = Layer::new(Geom::ribbon()) - .with_aesthetic("x".to_string(), AestheticValue::standard_column("x")) - .with_aesthetic("ymin".to_string(), AestheticValue::standard_column("ymin")) - .with_aesthetic("ymax".to_string(), AestheticValue::standard_column("ymax")); + .with_aesthetic("pos1".to_string(), AestheticValue::standard_column("x")) + .with_aesthetic("pos2min".to_string(), AestheticValue::standard_column("ymin")) + .with_aesthetic("pos2max".to_string(), AestheticValue::standard_column("ymax")); assert!(valid_ribbon.validate_required_aesthetics().is_ok()); } @@ -381,49 +467,52 @@ mod tests { fn test_geom_aesthetics() { // Point geom let point = Geom::point().aesthetics(); - assert!(point.supported.contains(&"x")); + assert!(point.supported.contains(&"pos1")); assert!(point.supported.contains(&"size")); assert!(point.supported.contains(&"shape")); assert!(!point.supported.contains(&"linetype")); - assert_eq!(point.required, &["x", "y"]); + assert_eq!(point.required, &["pos1", "pos2"]); // Line geom let line = Geom::line().aesthetics(); assert!(line.supported.contains(&"linetype")); assert!(line.supported.contains(&"linewidth")); assert!(!line.supported.contains(&"size")); - assert_eq!(line.required, &["x", "y"]); + assert_eq!(line.required, &["pos1", "pos2"]); - // Bar geom - optional x and y (stat decides aggregation) + // Bar geom - optional pos1 and pos2 (stat decides aggregation) let bar = Geom::bar().aesthetics(); assert!(bar.supported.contains(&"fill")); assert!(bar.supported.contains(&"width")); - assert!(bar.supported.contains(&"y")); // Bar accepts optional y - assert!(bar.supported.contains(&"x")); // Bar accepts optional x + assert!(bar.supported.contains(&"pos2")); // Bar accepts optional pos2 + assert!(bar.supported.contains(&"pos1")); // Bar accepts optional pos1 assert_eq!(bar.required, &[] as &[&str]); // No required aesthetics // Text geom let text = Geom::text().aesthetics(); assert!(text.supported.contains(&"label")); assert!(text.supported.contains(&"family")); - assert_eq!(text.required, &["x", "y"]); + assert_eq!(text.required, &["pos1", "pos2"]); - // Statistical geoms only require x - assert_eq!(Geom::histogram().aesthetics().required, &["x"]); - assert_eq!(Geom::density().aesthetics().required, &["x"]); + // Statistical geoms only require pos1 + assert_eq!(Geom::histogram().aesthetics().required, &["pos1"]); + assert_eq!(Geom::density().aesthetics().required, &["pos1"]); - // Ribbon requires ymin/ymax - assert_eq!(Geom::ribbon().aesthetics().required, &["x", "ymin", "ymax"]); + // Ribbon requires pos2min/pos2max + assert_eq!( + Geom::ribbon().aesthetics().required, + &["pos1", "pos2min", "pos2max"] + ); // Segment/arrow require endpoints assert_eq!( Geom::segment().aesthetics().required, - &["x", "y", "xend", "yend"] + &["pos1", "pos2", "pos1end", "pos2end"] ); - // Reference lines - assert_eq!(Geom::hline().aesthetics().required, &["yintercept"]); - assert_eq!(Geom::vline().aesthetics().required, &["xintercept"]); + // Reference lines (these are special - they use intercept aesthetics, not positional) + assert_eq!(Geom::hline().aesthetics().required, &["pos2intercept"]); + assert_eq!(Geom::vline().aesthetics().required, &["pos1intercept"]); assert_eq!( Geom::abline().aesthetics().required, &["slope", "intercept"] @@ -435,41 +524,49 @@ mod tests { #[test] fn test_aesthetic_family_primary_lookup() { - // Test that variant aesthetics map to their primary - assert_eq!(primary_aesthetic("x"), "x"); - assert_eq!(primary_aesthetic("xmin"), "x"); - assert_eq!(primary_aesthetic("xmax"), "x"); - assert_eq!(primary_aesthetic("xend"), "x"); - assert_eq!(primary_aesthetic("y"), "y"); - assert_eq!(primary_aesthetic("ymin"), "y"); - assert_eq!(primary_aesthetic("ymax"), "y"); - assert_eq!(primary_aesthetic("yend"), "y"); - - // Non-family aesthetics return themselves + // NOTE: primary_aesthetic() now only handles internal names (pos1, pos2, etc.) + // and non-positional aesthetics. For user-facing families, use AestheticContext. + + // Test that internal variant aesthetics map to their primary + assert_eq!(primary_aesthetic("pos1"), "pos1"); + assert_eq!(primary_aesthetic("pos1min"), "pos1"); + assert_eq!(primary_aesthetic("pos1max"), "pos1"); + assert_eq!(primary_aesthetic("pos1end"), "pos1"); + assert_eq!(primary_aesthetic("pos2"), "pos2"); + assert_eq!(primary_aesthetic("pos2min"), "pos2"); + assert_eq!(primary_aesthetic("pos2max"), "pos2"); + assert_eq!(primary_aesthetic("pos2end"), "pos2"); + + // Non-positional aesthetics return themselves assert_eq!(primary_aesthetic("color"), "color"); assert_eq!(primary_aesthetic("size"), "size"); assert_eq!(primary_aesthetic("fill"), "fill"); + + // User-facing names return themselves (family resolution via AestheticContext) + assert_eq!(primary_aesthetic("x"), "x"); + assert_eq!(primary_aesthetic("xmin"), "xmin"); } #[test] fn test_compute_labels_from_variant_aesthetics() { - // Test that variant aesthetics (xmin, xmax) can contribute to primary aesthetic labels + // Test that variant aesthetics (pos1min, pos1max) can contribute to primary aesthetic labels + // NOTE: After aesthetic transformation, all positional aesthetics use internal names let mut spec = Plot::new(); let layer = Layer::new(Geom::ribbon()) .with_aesthetic( - "xmin".to_string(), + "pos1min".to_string(), AestheticValue::standard_column("lower_bound"), ) .with_aesthetic( - "xmax".to_string(), + "pos1max".to_string(), AestheticValue::standard_column("upper_bound"), ) .with_aesthetic( - "ymin".to_string(), + "pos2min".to_string(), AestheticValue::standard_column("y_lower"), ) .with_aesthetic( - "ymax".to_string(), + "pos2max".to_string(), AestheticValue::standard_column("y_upper"), ); spec.layers.push(layer); @@ -478,78 +575,83 @@ mod tests { let labels = spec.labels.as_ref().unwrap(); // First variant encountered sets the label for the primary aesthetic - // Note: HashMap iteration order may vary, so we just check both x and y have labels + // Note: HashMap iteration order may vary, so we just check both pos1 and pos2 have labels assert!( - labels.labels.contains_key("x"), - "x label should be set from xmin or xmax" + labels.labels.contains_key("pos1"), + "pos1 label should be set from pos1min or pos1max" ); assert!( - labels.labels.contains_key("y"), - "y label should be set from ymin or ymax" + labels.labels.contains_key("pos2"), + "pos2 label should be set from pos2min or pos2max" ); } #[test] fn test_user_label_overrides_computed() { // Test that user-specified labels take precedence over computed labels + // NOTE: After aesthetic transformation, all positional aesthetics use internal names let mut spec = Plot::new(); let layer = Layer::new(Geom::ribbon()) .with_aesthetic( - "xmin".to_string(), + "pos1min".to_string(), AestheticValue::standard_column("lower_bound"), ) .with_aesthetic( - "xmax".to_string(), + "pos1max".to_string(), AestheticValue::standard_column("upper_bound"), ) .with_aesthetic( - "ymin".to_string(), + "pos2min".to_string(), AestheticValue::standard_column("y_lower"), ) .with_aesthetic( - "ymax".to_string(), + "pos2max".to_string(), AestheticValue::standard_column("y_upper"), ); spec.layers.push(layer); - // Pre-set a user label for x + // Pre-set a user label for pos1 let mut labels = Labels { labels: HashMap::new(), }; labels .labels - .insert("x".to_string(), "Custom X Label".to_string()); + .insert("pos1".to_string(), "Custom X Label".to_string()); spec.labels = Some(labels); spec.compute_aesthetic_labels(); let labels = spec.labels.as_ref().unwrap(); // User-specified label should be preserved - assert_eq!(labels.labels.get("x"), Some(&"Custom X Label".to_string())); - // y should still be computed from variants - assert!(labels.labels.contains_key("y")); + assert_eq!( + labels.labels.get("pos1"), + Some(&"Custom X Label".to_string()) + ); + // pos2 should still be computed from variants + assert!(labels.labels.contains_key("pos2")); } #[test] fn test_primary_aesthetic_sets_label_before_variants() { // Test that if both primary and variant are mapped, primary takes precedence + // NOTE: After aesthetic transformation, all positional aesthetics use internal names let mut spec = Plot::new(); let layer = Layer::new(Geom::point()) - .with_aesthetic("x".to_string(), AestheticValue::standard_column("date")) - .with_aesthetic("y".to_string(), AestheticValue::standard_column("value")); + .with_aesthetic("pos1".to_string(), AestheticValue::standard_column("date")) + .with_aesthetic("pos2".to_string(), AestheticValue::standard_column("value")); spec.layers.push(layer); - // Add a second layer with xmin + // Add a second layer with pos1min let layer2 = Layer::new(Geom::ribbon()) - .with_aesthetic("x".to_string(), AestheticValue::standard_column("date")) - .with_aesthetic("xmin".to_string(), AestheticValue::standard_column("lower")) - .with_aesthetic("xmax".to_string(), AestheticValue::standard_column("upper")) + .with_aesthetic("pos1".to_string(), AestheticValue::standard_column("date")) + .with_aesthetic("pos1min".to_string(), AestheticValue::standard_column("lower")) + .with_aesthetic("pos1max".to_string(), AestheticValue::standard_column("upper")) .with_aesthetic( - "ymin".to_string(), + "pos2min".to_string(), AestheticValue::standard_column("y_lower"), ) .with_aesthetic( - "ymax".to_string(), + "pos2max".to_string(), AestheticValue::standard_column("y_upper"), ); spec.layers.push(layer2); @@ -557,8 +659,8 @@ mod tests { spec.compute_aesthetic_labels(); let labels = spec.labels.as_ref().unwrap(); - // First layer's x mapping should win - assert_eq!(labels.labels.get("x"), Some(&"date".to_string())); + // First layer's pos1 mapping should win + assert_eq!(labels.labels.get("pos1"), Some(&"date".to_string())); } #[test] diff --git a/src/plot/projection/coord/cartesian.rs b/src/plot/projection/coord/cartesian.rs index d196aa90..c6f3d37c 100644 --- a/src/plot/projection/coord/cartesian.rs +++ b/src/plot/projection/coord/cartesian.rs @@ -15,6 +15,10 @@ impl CoordTrait for Cartesian { "cartesian" } + fn positional_aesthetic_names(&self) -> &'static [&'static str] { + &["x", "y"] + } + fn allowed_properties(&self) -> &'static [&'static str] { &["ratio", "clip"] } diff --git a/src/plot/projection/coord/flip.rs b/src/plot/projection/coord/flip.rs deleted file mode 100644 index 7912aee9..00000000 --- a/src/plot/projection/coord/flip.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Flip coordinate system implementation - -use super::{CoordKind, CoordTrait}; - -/// Flip coordinate system - swaps x and y axes -#[derive(Debug, Clone, Copy)] -pub struct Flip; - -impl CoordTrait for Flip { - fn coord_kind(&self) -> CoordKind { - CoordKind::Flip - } - - fn name(&self) -> &'static str { - "flip" - } - - fn allowed_properties(&self) -> &'static [&'static str] { - &["clip"] - } -} - -impl std::fmt::Display for Flip { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.name()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::plot::ParameterValue; - use std::collections::HashMap; - - #[test] - fn test_flip_properties() { - let flip = Flip; - assert_eq!(flip.coord_kind(), CoordKind::Flip); - assert_eq!(flip.name(), "flip"); - } - - #[test] - fn test_flip_allowed_properties() { - let flip = Flip; - let allowed = flip.allowed_properties(); - assert!(allowed.contains(&"clip")); - } - - #[test] - fn test_flip_accepts_clip() { - let flip = Flip; - let mut props = HashMap::new(); - props.insert("clip".to_string(), ParameterValue::Boolean(true)); - - let resolved = flip.resolve_properties(&props); - assert!(resolved.is_ok()); - } - - #[test] - fn test_flip_rejects_theta() { - let flip = Flip; - let mut props = HashMap::new(); - props.insert("theta".to_string(), ParameterValue::String("y".to_string())); - - let resolved = flip.resolve_properties(&props); - assert!(resolved.is_err()); - let err = resolved.unwrap_err(); - assert!(err.contains("theta")); - assert!(err.contains("not valid")); - } -} diff --git a/src/plot/projection/coord/mod.rs b/src/plot/projection/coord/mod.rs index 42b2adaf..962b89ad 100644 --- a/src/plot/projection/coord/mod.rs +++ b/src/plot/projection/coord/mod.rs @@ -28,12 +28,10 @@ use crate::plot::ParameterValue; // Coord type implementations mod cartesian; -mod flip; mod polar; // Re-export coord type structs pub use cartesian::Cartesian; -pub use flip::Flip; pub use polar::Polar; // ============================================================================= @@ -46,8 +44,6 @@ pub use polar::Polar; pub enum CoordKind { /// Standard x/y Cartesian coordinates (default) Cartesian, - /// Flipped Cartesian (swaps x and y axes) - Flip, /// Polar coordinates (for pie charts, rose plots) Polar, } @@ -57,7 +53,6 @@ impl CoordKind { pub fn name(&self) -> &'static str { match self { CoordKind::Cartesian => "cartesian", - CoordKind::Flip => "flip", CoordKind::Polar => "polar", } } @@ -78,6 +73,15 @@ pub trait CoordTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { /// Canonical name for parsing and display fn name(&self) -> &'static str; + /// Primary positional aesthetic names for this coord. + /// + /// Returns the user-facing positional aesthetic names. + /// e.g., ["x", "y"] for cartesian, ["theta", "radius"] for polar. + /// + /// These names are transformed to internal names (pos1, pos2, etc.) + /// early in the pipeline and transformed back for output. + fn positional_aesthetic_names(&self) -> &'static [&'static str]; + /// Returns list of allowed property names for SETTING clause. /// Default: empty (no properties allowed). fn allowed_properties(&self) -> &'static [&'static str] { @@ -145,11 +149,6 @@ impl Coord { Self(Arc::new(Cartesian)) } - /// Create a Flip coord type - pub fn flip() -> Self { - Self(Arc::new(Flip)) - } - /// Create a Polar coord type pub fn polar() -> Self { Self(Arc::new(Polar)) @@ -159,7 +158,6 @@ impl Coord { pub fn from_kind(kind: CoordKind) -> Self { match kind { CoordKind::Cartesian => Self::cartesian(), - CoordKind::Flip => Self::flip(), CoordKind::Polar => Self::polar(), } } @@ -174,6 +172,12 @@ impl Coord { self.0.name() } + /// Primary positional aesthetic names for this coord. + /// e.g., ["x", "y"] for cartesian, ["theta", "radius"] for polar. + pub fn positional_aesthetic_names(&self) -> &'static [&'static str] { + self.0.positional_aesthetic_names() + } + /// Returns list of allowed property names for SETTING clause. pub fn allowed_properties(&self) -> &'static [&'static str] { self.0.allowed_properties() @@ -247,7 +251,6 @@ mod tests { #[test] fn test_coord_kind_name() { assert_eq!(CoordKind::Cartesian.name(), "cartesian"); - assert_eq!(CoordKind::Flip.name(), "flip"); assert_eq!(CoordKind::Polar.name(), "polar"); } @@ -257,10 +260,6 @@ mod tests { assert_eq!(cartesian.coord_kind(), CoordKind::Cartesian); assert_eq!(cartesian.name(), "cartesian"); - let flip = Coord::flip(); - assert_eq!(flip.coord_kind(), CoordKind::Flip); - assert_eq!(flip.name(), "flip"); - let polar = Coord::polar(); assert_eq!(polar.coord_kind(), CoordKind::Polar); assert_eq!(polar.name(), "polar"); @@ -272,10 +271,6 @@ mod tests { Coord::from_kind(CoordKind::Cartesian).coord_kind(), CoordKind::Cartesian ); - assert_eq!( - Coord::from_kind(CoordKind::Flip).coord_kind(), - CoordKind::Flip - ); assert_eq!( Coord::from_kind(CoordKind::Polar).coord_kind(), CoordKind::Polar @@ -285,11 +280,8 @@ mod tests { #[test] fn test_coord_equality() { assert_eq!(Coord::cartesian(), Coord::cartesian()); - assert_eq!(Coord::flip(), Coord::flip()); assert_eq!(Coord::polar(), Coord::polar()); - assert_ne!(Coord::cartesian(), Coord::flip()); assert_ne!(Coord::cartesian(), Coord::polar()); - assert_ne!(Coord::flip(), Coord::polar()); } #[test] @@ -298,10 +290,6 @@ mod tests { let json = serde_json::to_string(&cartesian).unwrap(); assert_eq!(json, "\"cartesian\""); - let flip = Coord::flip(); - let json = serde_json::to_string(&flip).unwrap(); - assert_eq!(json, "\"flip\""); - let polar = Coord::polar(); let json = serde_json::to_string(&polar).unwrap(); assert_eq!(json, "\"polar\""); @@ -312,10 +300,16 @@ mod tests { let cartesian: Coord = serde_json::from_str("\"cartesian\"").unwrap(); assert_eq!(cartesian.coord_kind(), CoordKind::Cartesian); - let flip: Coord = serde_json::from_str("\"flip\"").unwrap(); - assert_eq!(flip.coord_kind(), CoordKind::Flip); - let polar: Coord = serde_json::from_str("\"polar\"").unwrap(); assert_eq!(polar.coord_kind(), CoordKind::Polar); } + + #[test] + fn test_positional_aesthetic_names() { + let cartesian = Coord::cartesian(); + assert_eq!(cartesian.positional_aesthetic_names(), &["x", "y"]); + + let polar = Coord::polar(); + assert_eq!(polar.positional_aesthetic_names(), &["theta", "radius"]); + } } diff --git a/src/plot/projection/coord/polar.rs b/src/plot/projection/coord/polar.rs index e8a2ae53..8c56b20b 100644 --- a/src/plot/projection/coord/polar.rs +++ b/src/plot/projection/coord/polar.rs @@ -16,6 +16,10 @@ impl CoordTrait for Polar { "polar" } + fn positional_aesthetic_names(&self) -> &'static [&'static str] { + &["theta", "radius"] + } + fn allowed_properties(&self) -> &'static [&'static str] { &["theta", "clip"] } diff --git a/src/plot/scale/mod.rs b/src/plot/scale/mod.rs index b381ac00..785296bd 100644 --- a/src/plot/scale/mod.rs +++ b/src/plot/scale/mod.rs @@ -12,7 +12,7 @@ pub mod transform; mod types; pub use crate::format::apply_label_template; -pub use crate::plot::aesthetic::is_facet_aesthetic; +pub use crate::plot::aesthetic::{is_facet_aesthetic, is_positional_aesthetic}; pub use crate::plot::types::{CastTargetType, SqlTypeNames}; pub use colour::{color_to_hex, gradient, interpolate_colors, is_color_aesthetic, ColorSpace}; pub use linetype::linetype_to_stroke_dash; @@ -42,18 +42,25 @@ use crate::plot::{ArrayElement, ArrayElementType}; /// an unmapped aesthetic should get a scale with type inference (Continuous/Discrete) /// or an Identity scale (pass-through, no transformation). pub fn gets_default_scale(aesthetic: &str) -> bool { + // Positional aesthetics (pos1, pos1min, pos2max, etc.) - checked dynamically + if is_positional_aesthetic(aesthetic) { + return true; + } + + // Facet aesthetics (panel, row, column) - checked dynamically + if is_facet_aesthetic(aesthetic) { + return true; + } + + // Non-positional visual aesthetics that get default scales matches!( aesthetic, - // Position aesthetics - "x" | "y" | "xmin" | "xmax" | "ymin" | "ymax" | "xend" | "yend" // Color aesthetics (color/colour/col already split to fill/stroke) - | "fill" | "stroke" + "fill" | "stroke" // Size aesthetics | "size" | "linewidth" // Other visual aesthetics | "opacity" | "shape" | "linetype" - // Facet aesthetics (need Discrete/Binned, not Identity) - | "panel" | "row" | "column" ) } diff --git a/src/plot/scale/scale_type/continuous.rs b/src/plot/scale/scale_type/continuous.rs index d06b125c..9f639d67 100644 --- a/src/plot/scale/scale_type/continuous.rs +++ b/src/plot/scale/scale_type/continuous.rs @@ -313,8 +313,9 @@ mod tests { #[test] fn test_pre_stat_transform_sql_default_oob_for_positional() { + // NOTE: After transformation, positional aesthetics use internal names (pos1, pos2, etc.) let continuous = Continuous; - let mut scale = Scale::new("x"); // positional aesthetic + let mut scale = Scale::new("pos1"); // positional aesthetic (internal name) scale.input_range = Some(vec![ArrayElement::Number(0.0), ArrayElement::Number(100.0)]); scale.explicit_input_range = true; // No oob property - should use default (keep for positional) diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index f46ee31c..fbfb2d16 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -2293,10 +2293,12 @@ mod tests { #[test] fn test_resolve_properties_defaults() { + // NOTE: After transformation, positional aesthetics use internal names (pos1, pos2, etc.) + // Continuous positional: default expand let props = HashMap::new(); let resolved = ScaleType::continuous() - .resolve_properties("x", &props) + .resolve_properties("pos1", &props) .unwrap(); assert!(resolved.contains_key("expand")); match resolved.get("expand") { @@ -2312,7 +2314,9 @@ mod tests { assert!(resolved.contains_key("oob")); // Binned: default oob is censor - let resolved = ScaleType::binned().resolve_properties("x", &props).unwrap(); + let resolved = ScaleType::binned() + .resolve_properties("pos1", &props) + .unwrap(); match resolved.get("oob") { Some(ParameterValue::String(s)) => assert_eq!(s, "censor"), _ => panic!("Expected oob to be 'censor'"), @@ -2328,10 +2332,12 @@ mod tests { #[test] fn test_resolve_properties_user_values_preserved() { + // NOTE: After transformation, positional aesthetics use internal names (pos1, pos2, etc.) + let mut props = HashMap::new(); props.insert("expand".to_string(), ParameterValue::Number(0.1)); let resolved = ScaleType::continuous() - .resolve_properties("x", &props) + .resolve_properties("pos1", &props) .unwrap(); match resolved.get("expand") { Some(ParameterValue::Number(n)) => assert!((n - 0.1).abs() < 1e-10), @@ -2340,7 +2346,9 @@ mod tests { // Binned supports expand props.insert("expand".to_string(), ParameterValue::Number(0.2)); - let resolved = ScaleType::binned().resolve_properties("x", &props).unwrap(); + let resolved = ScaleType::binned() + .resolve_properties("pos1", &props) + .unwrap(); match resolved.get("expand") { Some(ParameterValue::Number(n)) => assert!((n - 0.2).abs() < 1e-10), _ => panic!("Expected Number"), @@ -2359,13 +2367,17 @@ mod tests { #[test] fn test_expand_positional_vs_non_positional() { - use crate::plot::aesthetic::ALL_POSITIONAL; + // Internal positional aesthetics (after transformation) + let internal_positional = [ + "pos1", "pos1min", "pos1max", "pos1end", "pos1intercept", + "pos2", "pos2min", "pos2max", "pos2end", "pos2intercept", + ]; let mut props = HashMap::new(); props.insert("expand".to_string(), ParameterValue::Number(0.1)); // Positional aesthetics should allow expand - for aes in ALL_POSITIONAL.iter() { + for aes in internal_positional.iter() { assert!( ScaleType::continuous() .resolve_properties(aes, &props) @@ -2388,12 +2400,16 @@ mod tests { #[test] fn test_oob_defaults_by_aesthetic_type() { - use crate::plot::aesthetic::ALL_POSITIONAL; + // Internal positional aesthetics (after transformation) + let internal_positional = [ + "pos1", "pos1min", "pos1max", "pos1end", "pos1intercept", + "pos2", "pos2min", "pos2max", "pos2end", "pos2intercept", + ]; let props = HashMap::new(); // Positional aesthetics default to 'keep' - for aesthetic in ALL_POSITIONAL.iter() { + for aesthetic in internal_positional.iter() { let resolved = ScaleType::continuous() .resolve_properties(aesthetic, &props) .unwrap(); diff --git a/src/plot/types.rs b/src/plot/types.rs index ec1ce054..735e7c65 100644 --- a/src/plot/types.rs +++ b/src/plot/types.rs @@ -108,6 +108,38 @@ impl Mappings { pub fn len(&self) -> usize { self.aesthetics.len() } + + /// Transform aesthetic keys from user-facing to internal names. + /// + /// Uses the provided AestheticContext to map user-facing positional aesthetic names + /// (e.g., "x", "y", "theta", "radius") to internal names (e.g., "pos1", "pos2"). + /// Non-positional aesthetics (e.g., "color", "size") are left unchanged. + pub fn transform_to_internal(&mut self, ctx: &super::AestheticContext) { + let original_aesthetics = std::mem::take(&mut self.aesthetics); + for (aesthetic, value) in original_aesthetics { + let internal_name = ctx + .map_user_to_internal(&aesthetic) + .map(|s| s.to_string()) + .unwrap_or(aesthetic); + self.aesthetics.insert(internal_name, value); + } + } + + /// Transform aesthetic keys from internal to user-facing names. + /// + /// Uses the provided AestheticContext to map internal positional aesthetic names + /// (e.g., "pos1", "pos2") to user-facing names (e.g., "x", "y", "theta", "radius"). + /// Non-positional aesthetics (e.g., "color", "size") are left unchanged. + pub fn transform_to_user(&mut self, ctx: &super::AestheticContext) { + let original_aesthetics = std::mem::take(&mut self.aesthetics); + for (aesthetic, value) in original_aesthetics { + let user_name = ctx + .map_internal_to_user(&aesthetic) + .map(|s| s.to_string()) + .unwrap_or(aesthetic); + self.aesthetics.insert(user_name, value); + } + } } // ============================================================================= diff --git a/src/reader/data.rs b/src/reader/data.rs index 8bea4fbc..fac5639a 100644 --- a/src/reader/data.rs +++ b/src/reader/data.rs @@ -358,14 +358,15 @@ mod duckdb_tests { "SELECT * FROM ggsql:penguins VISUALISE DRAW point MAPPING bill_len AS x, bill_dep AS y"; let result = crate::execute::prepare_data_with_reader(query, &reader).unwrap(); let dataframe = result.data.get(&naming::layer_key(0)).unwrap(); - assert!(dataframe.column("__ggsql_aes_x__").is_ok()); - assert!(dataframe.column("__ggsql_aes_y__").is_ok()); + // Aesthetics are transformed to internal names (x -> pos1, y -> pos2) + assert!(dataframe.column("__ggsql_aes_pos1__").is_ok()); + assert!(dataframe.column("__ggsql_aes_pos2__").is_ok()); let query = "VISUALISE FROM ggsql:airquality DRAW point MAPPING Temp AS x, Ozone AS y"; let result = crate::execute::prepare_data_with_reader(query, &reader).unwrap(); let dataframe = result.data.get(&naming::layer_key(0)).unwrap(); - assert!(dataframe.column("__ggsql_aes_x__").is_ok()); - assert!(dataframe.column("__ggsql_aes_y__").is_ok()); + assert!(dataframe.column("__ggsql_aes_pos1__").is_ok()); + assert!(dataframe.column("__ggsql_aes_pos2__").is_ok()); } } diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 5565668c..2a151ef3 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -341,8 +341,9 @@ mod tests { let metadata = spec.metadata(); assert_eq!(metadata.rows, 3); assert_eq!(metadata.columns.len(), 2); - assert!(metadata.columns.contains(&"x".to_string())); - assert!(metadata.columns.contains(&"y".to_string())); + // Aesthetics are transformed to internal names (x -> pos1, y -> pos2) + assert!(metadata.columns.contains(&"pos1".to_string())); + assert!(metadata.columns.contains(&"pos2".to_string())); assert_eq!(metadata.layer_count, 1); } @@ -400,7 +401,8 @@ mod tests { let spec = reader.execute(query).unwrap(); assert_eq!(spec.metadata().rows, 3); - assert!(spec.metadata().columns.contains(&"x".to_string())); + // Aesthetics are transformed to internal names (x -> pos1) + assert!(spec.metadata().columns.contains(&"pos1".to_string())); let writer = VegaLiteWriter::new(); let result = writer.render(&spec).unwrap(); diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index d9c074db..67536236 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -421,7 +421,9 @@ fn build_scale_properties( // Check if we should skip domain due to facet free scales // When using free scales, Vega-Lite computes independent domains per facet panel. // Setting an explicit domain would override this behavior. - let skip_domain = (ctx.aesthetic == "x" && ctx.free_x) || (ctx.aesthetic == "y" && ctx.free_y); + // Note: aesthetics are in internal format (pos1, pos2) at this stage + let skip_domain = + (ctx.aesthetic == "pos1" && ctx.free_x) || (ctx.aesthetic == "pos2" && ctx.free_y); // Apply domain from input_range (FROM clause) // Skip for threshold scales - they use internal breaks as domain instead @@ -892,22 +894,45 @@ fn build_literal_encoding(aesthetic: &str, lit: &ParameterValue) -> Result String { - match aesthetic { +/// Map ggsql aesthetic name to Vega-Lite encoding channel name. +/// +/// Handles both internal positional aesthetics (pos1, pos2, etc.) and user-facing aesthetics. +/// For internal positional names, uses the AestheticContext to transform to user-facing names, +/// then applies Vega-Lite specific mappings (e.g., xend -> x2). +pub(super) fn map_aesthetic_name( + aesthetic: &str, + ctx: &crate::plot::AestheticContext, +) -> String { + // First, transform internal positional to user-facing using context + let user_name = ctx + .map_internal_to_user(aesthetic) + .map(|s| s.to_string()) + .unwrap_or_else(|| aesthetic.to_string()); + + // Then apply Vega-Lite specific mappings + match user_name.as_str() { // Position end aesthetics (ggplot2 style -> Vega-Lite style) - "xend" => "x2", - "yend" => "y2", + // Handle both cartesian (xend/yend) and polar (thetaend/radiusend) + name if name.ends_with("end") => { + // Convert xend -> x2, yend -> y2, thetaend -> theta2, etc. + let base = &name[..name.len() - 3]; + format!("{}2", base) + } + // Position intercept aesthetics for reference lines + name if name.ends_with("intercept") => { + // Keep as-is for now (Vega-Lite uses xintercept, yintercept style) + // Actually in Vega-Lite we need special handling for reference lines + name.to_string() + } // Line aesthetics - "linetype" => "strokeDash", - "linewidth" => "strokeWidth", + "linetype" => "strokeDash".to_string(), + "linewidth" => "strokeWidth".to_string(), // Text aesthetics - "label" => "text", + "label" => "text".to_string(), // All other aesthetics pass through directly // (fill and stroke map to Vega-Lite's separate fill/stroke channels) - _ => aesthetic, + _ => user_name, } - .to_string() } /// Build detail encoding from partition_by columns diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 69f3be90..d61989e0 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -29,8 +29,7 @@ use crate::plot::ArrayElement; use crate::plot::{ParameterValue, Scale, ScaleTypeKind}; use crate::writer::Writer; use crate::{ - is_primary_positional, naming, primary_aesthetic, AestheticValue, DataFrame, GgsqlError, Plot, - Result, + naming, primary_aesthetic, AestheticValue, DataFrame, GgsqlError, Plot, Result, }; use serde_json::{json, Value}; use std::collections::HashMap; @@ -241,6 +240,9 @@ fn build_layer_encoding( free_y, }; + // Get aesthetic context for name transformation + let aesthetic_ctx = spec.get_aesthetic_context(); + // Build encoding channels for each aesthetic mapping for (aesthetic, value) in &layer.mappings.aesthetics { // Skip facet aesthetics - they are handled via top-level facet structure, @@ -250,17 +252,17 @@ fn build_layer_encoding( continue; } - let channel_name = map_aesthetic_name(aesthetic); + let channel_name = map_aesthetic_name(aesthetic, &aesthetic_ctx); let channel_encoding = build_encoding_channel(aesthetic, value, &mut enc_ctx)?; encoding.insert(channel_name, channel_encoding); - // For binned positional aesthetics (x, y), add xend/yend channel with bin_end column + // For binned positional aesthetics (pos1, pos2), add end channel with bin_end column // This enables proper bin width rendering in Vega-Lite (maps to x2/y2 channels) - if is_primary_positional(aesthetic) && is_binned_aesthetic(aesthetic, spec) { + if aesthetic_ctx.is_primary_internal(aesthetic) && is_binned_aesthetic(aesthetic, spec) { if let AestheticValue::Column { name: col, .. } = value { let end_col = naming::bin_end_column(col); - let end_aesthetic = format!("{}end", aesthetic); // "xend" or "yend" - let end_channel = map_aesthetic_name(&end_aesthetic); // maps to "x2" or "y2" + let end_aesthetic = format!("{}end", aesthetic); // "pos1end" or "pos2end" + let end_channel = map_aesthetic_name(&end_aesthetic, &aesthetic_ctx); // maps to "x2" or "y2" encoding.insert(end_channel, json!({"field": end_col})); } } @@ -272,7 +274,7 @@ fn build_layer_encoding( let supported_aesthetics = layer.geom.aesthetics().supported; for (param_name, param_value) in &layer.parameters { if supported_aesthetics.contains(¶m_name.as_str()) { - let channel_name = map_aesthetic_name(param_name); + let channel_name = map_aesthetic_name(param_name, &aesthetic_ctx); // Only add if not already set by MAPPING (MAPPING takes precedence) if !encoding.contains_key(&channel_name) { // Convert size and linewidth from points to Vega-Lite units @@ -1084,6 +1086,12 @@ mod tests { data_map } + /// Helper to transform a spec's aesthetics to internal names (simulates what parser does) + fn transform_spec(spec: &mut Plot) { + spec.initialize_aesthetic_context(); + spec.transform_aesthetics_to_internal(); + } + #[test] fn test_geom_to_mark_mapping() { // All marks should be objects with type and clip: true @@ -1111,22 +1119,42 @@ mod tests { #[test] fn test_aesthetic_name_mapping() { - // Pass-through aesthetics (including fill and stroke for separate color control) - assert_eq!(map_aesthetic_name("x"), "x"); - assert_eq!(map_aesthetic_name("y"), "y"); - assert_eq!(map_aesthetic_name("color"), "color"); - assert_eq!(map_aesthetic_name("fill"), "fill"); - assert_eq!(map_aesthetic_name("stroke"), "stroke"); - assert_eq!(map_aesthetic_name("opacity"), "opacity"); - assert_eq!(map_aesthetic_name("size"), "size"); - assert_eq!(map_aesthetic_name("shape"), "shape"); - // Position end aesthetics (ggsql -> Vega-Lite) - assert_eq!(map_aesthetic_name("xend"), "x2"); - assert_eq!(map_aesthetic_name("yend"), "y2"); + use crate::plot::AestheticContext; + + // Test with cartesian context + let ctx = AestheticContext::new(&["x", "y"], &[]); + + // Internal positional names should map to user-facing names + assert_eq!(map_aesthetic_name("pos1", &ctx), "x"); + assert_eq!(map_aesthetic_name("pos2", &ctx), "y"); + assert_eq!(map_aesthetic_name("pos1end", &ctx), "x2"); + assert_eq!(map_aesthetic_name("pos2end", &ctx), "y2"); + + // User-facing names should pass through unchanged + assert_eq!(map_aesthetic_name("x", &ctx), "x"); + assert_eq!(map_aesthetic_name("y", &ctx), "y"); + assert_eq!(map_aesthetic_name("xend", &ctx), "x2"); + assert_eq!(map_aesthetic_name("yend", &ctx), "y2"); + + // Non-positional aesthetics pass through directly + assert_eq!(map_aesthetic_name("color", &ctx), "color"); + assert_eq!(map_aesthetic_name("fill", &ctx), "fill"); + assert_eq!(map_aesthetic_name("stroke", &ctx), "stroke"); + assert_eq!(map_aesthetic_name("opacity", &ctx), "opacity"); + assert_eq!(map_aesthetic_name("size", &ctx), "size"); + assert_eq!(map_aesthetic_name("shape", &ctx), "shape"); + // Other mapped aesthetics - assert_eq!(map_aesthetic_name("linetype"), "strokeDash"); - assert_eq!(map_aesthetic_name("linewidth"), "strokeWidth"); - assert_eq!(map_aesthetic_name("label"), "text"); + assert_eq!(map_aesthetic_name("linetype", &ctx), "strokeDash"); + assert_eq!(map_aesthetic_name("linewidth", &ctx), "strokeWidth"); + assert_eq!(map_aesthetic_name("label", &ctx), "text"); + + // Test with polar context + let polar_ctx = AestheticContext::new(&["theta", "radius"], &[]); + assert_eq!(map_aesthetic_name("pos1", &polar_ctx), "theta"); + assert_eq!(map_aesthetic_name("pos2", &polar_ctx), "radius"); + assert_eq!(map_aesthetic_name("pos1end", &polar_ctx), "theta2"); + assert_eq!(map_aesthetic_name("pos2end", &polar_ctx), "radius2"); } #[test] @@ -1140,7 +1168,7 @@ mod tests { fn test_simple_point_spec() { let writer = VegaLiteWriter::new(); - // Create a simple spec + // Create a simple spec with user-facing aesthetic names let mut spec = Plot::new(); let layer = Layer::new(Geom::point()) .with_aesthetic( @@ -1161,6 +1189,7 @@ mod tests { .unwrap(); // Generate Vega-Lite JSON + transform_spec(&mut spec); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); let vl_spec: Value = serde_json::from_str(&json_str).unwrap(); @@ -1205,6 +1234,7 @@ mod tests { } .unwrap(); + transform_spec(&mut spec); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); let vl_spec: Value = serde_json::from_str(&json_str).unwrap(); @@ -1239,6 +1269,7 @@ mod tests { } .unwrap(); + transform_spec(&mut spec); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); let vl_spec: Value = serde_json::from_str(&json_str).unwrap(); @@ -1260,6 +1291,7 @@ mod tests { AestheticValue::standard_column("nonexistent".to_string()), ); spec.layers.push(layer); + transform_spec(&mut spec); let df = df! { "x" => &[1, 2, 3], @@ -1367,6 +1399,7 @@ mod tests { } .unwrap(); + transform_spec(&mut spec); let json_str = writer.write(&spec, &wrap_data_for_layers(df, 2)).unwrap(); let vl_spec: Value = serde_json::from_str(&json_str).unwrap(); @@ -1475,6 +1508,7 @@ mod tests { } .unwrap(); + transform_spec(&mut spec); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); let vl_spec: Value = serde_json::from_str(&json_str).unwrap(); @@ -1858,6 +1892,7 @@ mod tests { } .unwrap(); + transform_spec(&mut spec); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); let vl_spec: Value = serde_json::from_str(&json_str).unwrap(); @@ -1933,6 +1968,7 @@ mod tests { } .unwrap(); + transform_spec(&mut spec); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); let vl_spec: Value = serde_json::from_str(&json_str).unwrap(); @@ -2012,6 +2048,7 @@ mod tests { } .unwrap(); + transform_spec(&mut spec); let json_str = writer.write(&spec, &wrap_data(df)).unwrap(); let vl_spec: Value = serde_json::from_str(&json_str).unwrap(); diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 1851c374..159f3a70 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -1,6 +1,6 @@ //! Projection transformations for Vega-Lite writer //! -//! This module handles projection transformations (cartesian, flip, polar) +//! This module handles projection transformations (cartesian, polar) //! that modify the Vega-Lite spec structure based on the PROJECT clause. use crate::plot::{CoordKind, ParameterValue, Projection}; @@ -24,13 +24,7 @@ pub(super) fn apply_project_transforms( apply_cartesian_project(project, vl_spec, free_x, free_y)?; None } - CoordKind::Flip => { - apply_flip_project(vl_spec)?; - None - } - CoordKind::Polar => { - Some(apply_polar_project(project, spec, data, vl_spec)?) - } + CoordKind::Polar => Some(apply_polar_project(project, spec, data, vl_spec)?), }; // Apply clip setting (applies to all projection types) @@ -74,26 +68,6 @@ fn apply_cartesian_project( Ok(()) } -/// Apply Flip projection transformation (swap x and y) -fn apply_flip_project(vl_spec: &mut Value) -> Result<()> { - if let Some(layers) = vl_spec.get_mut("layer") { - if let Some(layers_arr) = layers.as_array_mut() { - for layer in layers_arr { - if let Some(encoding) = layer.get_mut("encoding") { - if let Some(enc_obj) = encoding.as_object_mut() { - if let (Some(x), Some(y)) = (enc_obj.remove("x"), enc_obj.remove("y")) { - enc_obj.insert("x".to_string(), y); - enc_obj.insert("y".to_string(), x); - } - } - } - } - } - } - - Ok(()) -} - /// Apply Polar projection transformation (bar->arc, point->arc with radius) fn apply_polar_project( project: &Projection, diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index 4fcde76f..a3fcbe10 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -638,8 +638,13 @@ module.exports = grammar({ ), aesthetic_name: $ => choice( - // Position aesthetics + // Position aesthetics (cartesian) 'x', 'y', 'xmin', 'xmax', 'ymin', 'ymax', 'xend', 'yend', + // Position aesthetics (polar) + 'theta', 'radius', 'thetamin', 'thetamax', 'radiusmin', 'radiusmax', + 'thetaend', 'radiusend', + // Reference line intercepts + 'xintercept', 'yintercept', // Aggregation aesthetic (for bar charts) 'weight', // Color aesthetics @@ -761,7 +766,7 @@ module.exports = grammar({ ), project_type: $ => choice( - 'cartesian', 'polar', 'flip' + 'cartesian', 'polar' ), project_properties: $ => seq( From cea50491192f38349250a7955688639e67062193 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Tue, 24 Feb 2026 16:16:39 +0100 Subject: [PATCH 07/28] parameterise the free property of facet --- src/execute/mod.rs | 7 +- src/plot/facet/resolve.rs | 372 +++++++++++++++++++++++++++++-------- src/writer/vegalite/mod.rs | 158 ++++++++-------- 3 files changed, 380 insertions(+), 157 deletions(-) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 1e0260fe..b396902a 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -1159,6 +1159,11 @@ pub fn prepare_data_with_reader(query: &str, reader: &R) -> Result = + spec.get_aesthetic_context().user_positional().to_vec(); + if let Some(ref mut facet) = spec.facet { // Get the first layer's data for computing facet defaults let facet_df = data_map.get(&naming::layer_key(0)).ok_or_else(|| { @@ -1173,7 +1178,7 @@ pub fn prepare_data_with_reader(query: &str, reader: &R) -> Result i64 { /// 1. Skips if already resolved /// 2. Validates all properties are allowed for this layout /// 3. Validates property values: -/// - `free`: must be null, 'x', 'y', or ['x', 'y'] +/// - `free`: must be null, a valid positional aesthetic, or an array of them /// - `ncol`: positive integer -/// 4. Applies defaults for missing properties: +/// 4. Normalizes the `free` property to a boolean vector (position-indexed) +/// 5. Applies defaults for missing properties: /// - `ncol` (wrap only): computed from `context.num_levels` -/// 5. Sets `resolved = true` -pub fn resolve_properties(facet: &mut Facet, context: &FacetDataContext) -> Result<(), String> { +/// 6. Sets `resolved = true` +/// +/// # Arguments +/// +/// * `facet` - The facet to resolve +/// * `context` - Data context with unique values +/// * `positional_names` - Valid positional aesthetic names (e.g., ["x", "y"] or ["theta", "radius"]) +pub fn resolve_properties( + facet: &mut Facet, + context: &FacetDataContext, + positional_names: &[&str], +) -> Result<(), String> { // Skip if already resolved if facet.resolved { return Ok(()); @@ -118,11 +128,14 @@ pub fn resolve_properties(facet: &mut Facet, context: &FacetDataContext) -> Resu } // Step 2: Validate property values - validate_free_property(facet)?; + validate_free_property(facet, positional_names)?; validate_ncol_property(facet)?; validate_missing_property(facet)?; - // Step 3: Apply defaults for missing properties + // Step 3: Normalize free property to boolean vector + normalize_free_property(facet, positional_names); + + // Step 4: Apply defaults for missing properties apply_defaults(facet, context); // Mark as resolved @@ -135,9 +148,14 @@ pub fn resolve_properties(facet: &mut Facet, context: &FacetDataContext) -> Resu /// /// Accepts: /// - `null` (ParameterValue::Null) - shared scales (default when absent) -/// - `'x'` or `'y'` (strings) - independent scale for that axis only -/// - `['x', 'y']` or `['y', 'x']` (arrays) - independent scales for both axes -fn validate_free_property(facet: &Facet) -> Result<(), String> { +/// - A valid positional aesthetic name (string) - independent scale for that axis only +/// - An array of valid positional aesthetic names - independent scales for specified axes +/// +/// # Arguments +/// +/// * `facet` - The facet to validate +/// * `positional_names` - Valid positional aesthetic names (e.g., ["x", "y"] or ["theta", "radius"]) +fn validate_free_property(facet: &Facet, positional_names: &[&str]) -> Result<(), String> { if let Some(value) = facet.properties.get("free") { match value { ParameterValue::Null => { @@ -145,52 +163,124 @@ fn validate_free_property(facet: &Facet) -> Result<(), String> { Ok(()) } ParameterValue::String(s) => { - if !FREE_STRING_VALUES.contains(&s.as_str()) { + if !positional_names.contains(&s.as_str()) { return Err(format!( - "invalid 'free' value '{}'. Expected 'x', 'y', ['x', 'y'], or null", - s + "invalid 'free' value '{}'. Expected one of: {}, or null", + s, + format_options(positional_names) )); } Ok(()) } ParameterValue::Array(arr) => { - // Must be exactly ['x', 'y'] or ['y', 'x'] - if arr.len() != 2 { + // Validate each element is a valid positional name + if arr.is_empty() { + return Err("invalid 'free' array: cannot be empty".to_string()); + } + if arr.len() > positional_names.len() { return Err(format!( - "invalid 'free' array: expected ['x', 'y'], got {} elements", - arr.len() + "invalid 'free' array: too many elements ({} given, max {})", + arr.len(), + positional_names.len() )); } - let mut has_x = false; - let mut has_y = false; + + let mut seen = std::collections::HashSet::new(); for elem in arr { match elem { - crate::plot::ArrayElement::String(s) if s == "x" => has_x = true, - crate::plot::ArrayElement::String(s) if s == "y" => has_y = true, + crate::plot::ArrayElement::String(s) => { + if !positional_names.contains(&s.as_str()) { + return Err(format!( + "invalid 'free' array element '{}'. Expected one of: {}", + s, + format_options(positional_names) + )); + } + if !seen.insert(s.clone()) { + return Err(format!( + "invalid 'free' array: duplicate element '{}'", + s + )); + } + } _ => { - return Err( - "invalid 'free' array: elements must be 'x' or 'y'".to_string() - ); + return Err(format!( + "invalid 'free' array: elements must be strings. Expected: {}", + format_options(positional_names) + )); } } } - if !has_x || !has_y { - return Err( - "invalid 'free' array: expected ['x', 'y'] with both 'x' and 'y'" - .to_string(), - ); - } Ok(()) } - _ => Err( - "'free' must be null, a string ('x' or 'y'), or an array ['x', 'y']".to_string(), - ), + _ => Err(format!( + "'free' must be null, a string ({}), or an array of positional names", + format_options(positional_names) + )), } } else { Ok(()) } } +/// Format positional names for error messages +fn format_options(names: &[&str]) -> String { + names + .iter() + .map(|n| format!("'{}'", n)) + .collect::>() + .join(", ") +} + +/// Normalize free property to a boolean vector +/// +/// Transforms user-provided values to a boolean vector (position-indexed): +/// - User writes: `free => 'x'` → stored as: `free => [true, false]` +/// - User writes: `free => 'theta'` → stored as: `free => [true, false]` +/// - User writes: `free => ['x', 'y']` → stored as: `free => [true, true]` +/// - User writes: `free => null` or absent → stored as: `free => [false, false]` +/// +/// This allows the writer to use the vector directly without any parsing. +fn normalize_free_property(facet: &mut Facet, positional_names: &[&str]) { + let mut free_vec = vec![false; positional_names.len()]; + + if let Some(value) = facet.properties.get("free") { + match value { + ParameterValue::String(s) => { + // Single string -> set that position to true + if let Some(idx) = positional_names.iter().position(|n| *n == s.as_str()) { + free_vec[idx] = true; + } + } + ParameterValue::Array(arr) => { + // Array -> set each position to true + for elem in arr { + if let crate::plot::ArrayElement::String(s) = elem { + if let Some(idx) = positional_names.iter().position(|n| *n == s.as_str()) { + free_vec[idx] = true; + } + } + } + } + ParameterValue::Null => { + // Explicit null -> all false (already initialized) + } + _ => { + // Invalid type - should have been caught by validation + } + } + } + + // Store as boolean array + let bool_array: Vec = free_vec + .iter() + .map(|&b| crate::plot::ArrayElement::Boolean(b)) + .collect(); + facet + .properties + .insert("free".to_string(), ParameterValue::Array(bool_array)); +} + /// Validate ncol property value fn validate_ncol_property(facet: &Facet) -> Result<(), String> { if let Some(value) = facet.properties.get("ncol") { @@ -250,6 +340,11 @@ mod tests { use crate::plot::facet::FacetLayout; use polars::prelude::*; + /// Default positional names for cartesian coords + const CARTESIAN: &[&str] = &["x", "y"]; + /// Positional names for polar coords + const POLAR: &[&str] = &["theta", "radius"]; + fn make_wrap_facet() -> Facet { Facet::new(FacetLayout::Wrap { variables: vec!["category".to_string()], @@ -270,6 +365,21 @@ mod tests { } } + /// Helper to extract boolean values from normalized free property + fn get_free_bools(facet: &Facet) -> Option> { + facet.properties.get("free").and_then(|v| { + if let ParameterValue::Array(arr) = v { + Some( + arr.iter() + .map(|e| matches!(e, crate::plot::ArrayElement::Boolean(true))) + .collect(), + ) + } else { + None + } + }) + } + #[test] fn test_compute_default_ncol() { assert_eq!(compute_default_ncol(1), 1); @@ -288,11 +398,11 @@ mod tests { let mut facet = make_wrap_facet(); let context = make_context(5); - resolve_properties(&mut facet, &context).unwrap(); + resolve_properties(&mut facet, &context, CARTESIAN).unwrap(); assert!(facet.resolved); - // Note: absence of 'free' means fixed scales (no default inserted) - assert!(!facet.properties.contains_key("free")); + // After resolution, free is normalized to boolean array (all false = fixed) + assert_eq!(get_free_bools(&facet), Some(vec![false, false])); assert_eq!( facet.properties.get("ncol"), Some(&ParameterValue::Number(3.0)) @@ -314,10 +424,10 @@ mod tests { .insert("ncol".to_string(), ParameterValue::Number(2.0)); let context = make_context(10); - resolve_properties(&mut facet, &context).unwrap(); + resolve_properties(&mut facet, &context, CARTESIAN).unwrap(); - // free => ['x', 'y'] preserved - assert!(facet.properties.contains_key("free")); + // free => ['x', 'y'] is normalized to [true, true] + assert_eq!(get_free_bools(&facet), Some(vec![true, true])); assert_eq!( facet.properties.get("ncol"), Some(&ParameterValue::Number(2.0)) @@ -330,7 +440,7 @@ mod tests { facet.resolved = true; let context = make_context(5); - resolve_properties(&mut facet, &context).unwrap(); + resolve_properties(&mut facet, &context, CARTESIAN).unwrap(); // Should not have applied defaults since it was already resolved assert!(!facet.properties.contains_key("ncol")); @@ -345,7 +455,7 @@ mod tests { .insert("columns".to_string(), ParameterValue::Number(4.0)); let context = make_context(10); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_err()); let err = result.unwrap_err(); @@ -361,7 +471,7 @@ mod tests { .insert("ncol".to_string(), ParameterValue::Number(3.0)); let context = make_context(10); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_err()); let err = result.unwrap_err(); @@ -377,7 +487,7 @@ mod tests { .insert("unknown".to_string(), ParameterValue::Number(1.0)); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_err()); let err = result.unwrap_err(); @@ -393,7 +503,7 @@ mod tests { ); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_err()); let err = result.unwrap_err(); @@ -409,7 +519,7 @@ mod tests { .insert("ncol".to_string(), ParameterValue::Number(-1.0)); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_err()); let err = result.unwrap_err(); @@ -425,7 +535,7 @@ mod tests { .insert("ncol".to_string(), ParameterValue::Number(2.5)); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_err()); let err = result.unwrap_err(); @@ -438,12 +548,12 @@ mod tests { let mut facet = make_grid_facet(); let context = make_context(10); - resolve_properties(&mut facet, &context).unwrap(); + resolve_properties(&mut facet, &context, CARTESIAN).unwrap(); // Grid facets should not get ncol default assert!(!facet.properties.contains_key("ncol")); - // No free property by default (means fixed/shared scales) - assert!(!facet.properties.contains_key("free")); + // After resolution, free is normalized to boolean array (all false = fixed) + assert_eq!(get_free_bools(&facet), Some(vec![false, false])); assert!(facet.resolved); } @@ -494,7 +604,7 @@ mod tests { ); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_ok()); } @@ -507,7 +617,7 @@ mod tests { ); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_ok()); } @@ -520,7 +630,7 @@ mod tests { ); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_err()); let err = result.unwrap_err(); @@ -536,7 +646,7 @@ mod tests { .insert("missing".to_string(), ParameterValue::Number(1.0)); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_err()); let err = result.unwrap_err(); @@ -553,12 +663,12 @@ mod tests { ); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_ok()); } // ======================================== - // Free Property Tests + // Free Property Tests - Cartesian // ======================================== #[test] @@ -569,8 +679,10 @@ mod tests { .insert("free".to_string(), ParameterValue::String("x".to_string())); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_ok()); + // x is first positional -> [true, false] + assert_eq!(get_free_bools(&facet), Some(vec![true, false])); } #[test] @@ -581,8 +693,10 @@ mod tests { .insert("free".to_string(), ParameterValue::String("y".to_string())); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_ok()); + // y is second positional -> [false, true] + assert_eq!(get_free_bools(&facet), Some(vec![false, true])); } #[test] @@ -597,8 +711,10 @@ mod tests { ); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_ok()); + // Both -> [true, true] + assert_eq!(get_free_bools(&facet), Some(vec![true, true])); } #[test] @@ -613,8 +729,10 @@ mod tests { ); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_ok()); + // Order doesn't matter, both are set -> [true, true] + assert_eq!(get_free_bools(&facet), Some(vec![true, true])); } #[test] @@ -625,12 +743,15 @@ mod tests { .insert("free".to_string(), ParameterValue::Null); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_ok()); + // Explicit null -> [false, false] + assert_eq!(get_free_bools(&facet), Some(vec![false, false])); } #[test] - fn test_error_free_array_single_element() { + fn test_free_property_single_element_array_valid() { + // Single element arrays are now valid (e.g., free => ['x']) let mut facet = make_wrap_facet(); facet.properties.insert( "free".to_string(), @@ -638,17 +759,10 @@ mod tests { ); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); - - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.contains("free")); - // Single element fails both the length check (1 != 2) and the "both x and y" check - assert!( - err.contains("1 elements") || err.contains("both 'x' and 'y'"), - "Expected error about array length or missing elements, got: {}", - err - ); + let result = resolve_properties(&mut facet, &context, CARTESIAN); + assert!(result.is_ok()); + // Single element -> [true, false] + assert_eq!(get_free_bools(&facet), Some(vec![true, false])); } #[test] @@ -663,12 +777,12 @@ mod tests { ); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.contains("free")); - assert!(err.contains("'x' or 'y'")); + assert!(err.contains("'z'")); } #[test] @@ -679,10 +793,116 @@ mod tests { .insert("free".to_string(), ParameterValue::Number(1.0)); let context = make_context(5); - let result = resolve_properties(&mut facet, &context); + let result = resolve_properties(&mut facet, &context, CARTESIAN); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.contains("free")); } + + #[test] + fn test_error_free_duplicate_element() { + let mut facet = make_wrap_facet(); + facet.properties.insert( + "free".to_string(), + ParameterValue::Array(vec![ + crate::plot::ArrayElement::String("x".to_string()), + crate::plot::ArrayElement::String("x".to_string()), + ]), + ); + + let context = make_context(5); + let result = resolve_properties(&mut facet, &context, CARTESIAN); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("duplicate")); + } + + // ======================================== + // Free Property Tests - Polar + // ======================================== + + #[test] + fn test_free_property_theta_valid() { + let mut facet = make_wrap_facet(); + facet.properties.insert( + "free".to_string(), + ParameterValue::String("theta".to_string()), + ); + + let context = make_context(5); + let result = resolve_properties(&mut facet, &context, POLAR); + assert!(result.is_ok()); + // theta is first positional -> [true, false] + assert_eq!(get_free_bools(&facet), Some(vec![true, false])); + } + + #[test] + fn test_free_property_radius_valid() { + let mut facet = make_wrap_facet(); + facet.properties.insert( + "free".to_string(), + ParameterValue::String("radius".to_string()), + ); + + let context = make_context(5); + let result = resolve_properties(&mut facet, &context, POLAR); + assert!(result.is_ok()); + // radius is second positional -> [false, true] + assert_eq!(get_free_bools(&facet), Some(vec![false, true])); + } + + #[test] + fn test_free_property_polar_array_valid() { + let mut facet = make_wrap_facet(); + facet.properties.insert( + "free".to_string(), + ParameterValue::Array(vec![ + crate::plot::ArrayElement::String("theta".to_string()), + crate::plot::ArrayElement::String("radius".to_string()), + ]), + ); + + let context = make_context(5); + let result = resolve_properties(&mut facet, &context, POLAR); + assert!(result.is_ok()); + // Both -> [true, true] + assert_eq!(get_free_bools(&facet), Some(vec![true, true])); + } + + #[test] + fn test_error_cartesian_names_in_polar() { + // x/y should not be valid for polar coords + let mut facet = make_wrap_facet(); + facet + .properties + .insert("free".to_string(), ParameterValue::String("x".to_string())); + + let context = make_context(5); + let result = resolve_properties(&mut facet, &context, POLAR); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("'x'")); + assert!(err.contains("theta") || err.contains("radius")); + } + + #[test] + fn test_error_polar_names_in_cartesian() { + // theta/radius should not be valid for cartesian coords + let mut facet = make_wrap_facet(); + facet.properties.insert( + "free".to_string(), + ParameterValue::String("theta".to_string()), + ); + + let context = make_context(5); + let result = resolve_properties(&mut facet, &context, CARTESIAN); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("'theta'")); + assert!(err.contains("'x'") || err.contains("'y'")); + } } diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index d61989e0..c34336a8 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -490,65 +490,75 @@ fn apply_facet_ordering(facet_def: &mut Value, scale: Option<&Scale>) { } } +/// Extract free scales from facet properties as a boolean vector +/// +/// After facet resolution, the `free` property is normalized to a boolean array: +/// - `[true, false]` = free x/theta, fixed y/radius +/// - `[false, true]` = fixed x/theta, free y/radius +/// - `[true, true]` = both free +/// - `[false, false]` = both fixed (default) +/// +/// Returns (free_x, free_y) for Vega-Lite output (position-indexed). +fn get_free_scales(facet: Option<&crate::plot::Facet>) -> (bool, bool) { + let Some(facet) = facet else { + return (false, false); + }; + + let Some(ParameterValue::Array(arr)) = facet.properties.get("free") else { + return (false, false); + }; + + let free_pos1 = arr + .first() + .map(|e| matches!(e, crate::plot::ArrayElement::Boolean(true))) + .unwrap_or(false); + let free_pos2 = arr + .get(1) + .map(|e| matches!(e, crate::plot::ArrayElement::Boolean(true))) + .unwrap_or(false); + + (free_pos1, free_pos2) +} + /// Apply scale resolution to Vega-Lite spec based on facet free property /// -/// Maps ggsql free property to Vega-Lite resolve.scale configuration: -/// - absent or null: shared scales (Vega-Lite default, no resolve needed) -/// - 'x': independent x scale, shared y scale -/// - 'y': shared x scale, independent y scale -/// - ['x', 'y']: independent scales for both x and y +/// Maps ggsql free property (boolean array) to Vega-Lite resolve.scale configuration: +/// - `[false, false]`: shared scales (Vega-Lite default, no resolve needed) +/// - `[true, false]`: independent x/theta scale, shared y/radius scale +/// - `[false, true]`: shared x/theta scale, independent y/radius scale +/// - `[true, true]`: independent scales for both axes fn apply_facet_scale_resolution(vl_spec: &mut Value, properties: &HashMap) { - let Some(free_value) = properties.get("free") else { + let Some(ParameterValue::Array(arr)) = properties.get("free") else { // No free property means fixed/shared scales (Vega-Lite default) return; }; - match free_value { - ParameterValue::Null => { - // Explicit null means shared scales (same as default) - } - ParameterValue::String(s) => match s.as_str() { - "x" => { - vl_spec["resolve"] = json!({ - "scale": {"x": "independent"} - }); - } - "y" => { - vl_spec["resolve"] = json!({ - "scale": {"y": "independent"} - }); - } - _ => { - // Unknown value - resolution should have validated this - } - }, - ParameterValue::Array(arr) => { - // Array means both x and y are free (already validated to be ['x', 'y']) - let has_x = arr - .iter() - .any(|e| matches!(e, crate::plot::ArrayElement::String(s) if s == "x")); - let has_y = arr - .iter() - .any(|e| matches!(e, crate::plot::ArrayElement::String(s) if s == "y")); - - if has_x && has_y { - vl_spec["resolve"] = json!({ - "scale": {"x": "independent", "y": "independent"} - }); - } else if has_x { - vl_spec["resolve"] = json!({ - "scale": {"x": "independent"} - }); - } else if has_y { - vl_spec["resolve"] = json!({ - "scale": {"y": "independent"} - }); - } - } - _ => { - // Invalid type - resolution should have validated this - } + // Extract booleans from the array (position-indexed) + let free_x = arr + .first() + .map(|e| matches!(e, crate::plot::ArrayElement::Boolean(true))) + .unwrap_or(false); + let free_y = arr + .get(1) + .map(|e| matches!(e, crate::plot::ArrayElement::Boolean(true))) + .unwrap_or(false); + + // Apply resolve configuration to Vega-Lite spec + // Note: Vega-Lite uses x/y for output, regardless of coord system + if free_x && free_y { + vl_spec["resolve"] = json!({ + "scale": {"x": "independent", "y": "independent"} + }); + } else if free_x { + vl_spec["resolve"] = json!({ + "scale": {"x": "independent"} + }); + } else if free_y { + vl_spec["resolve"] = json!({ + "scale": {"y": "independent"} + }); } + // If neither is free, don't add resolve (Vega-Lite default is shared) } /// Apply label renaming to a facet definition via header.labelExpr @@ -931,28 +941,8 @@ impl Writer for VegaLiteWriter { // 2. Determine if facet free scales should omit x/y domains // When using free scales, Vega-Lite computes independent domains per facet panel. // We must not set explicit domains (from SCALE or COORD) as they would override this. - let (free_x, free_y) = if let Some(ref facet) = spec.facet { - match facet.properties.get("free") { - Some(ParameterValue::String(s)) => match s.as_str() { - "x" => (true, false), - "y" => (false, true), - _ => (false, false), - }, - Some(ParameterValue::Array(arr)) => { - let has_x = arr - .iter() - .any(|e| matches!(e, crate::plot::ArrayElement::String(s) if s == "x")); - let has_y = arr - .iter() - .any(|e| matches!(e, crate::plot::ArrayElement::String(s) if s == "y")); - (has_x, has_y) - } - // null or absent means fixed/shared scales - _ => (false, false), - } - } else { - (false, false) - }; + // The free property is normalized to a boolean array [pos1_free, pos2_free]. + let (free_x, free_y) = get_free_scales(spec.facet.as_ref()); // 3. Determine layer data keys let layer_data_keys: Vec = spec @@ -1843,7 +1833,7 @@ mod tests { #[test] fn test_facet_free_scales_omits_domain() { - // Test that FACET with free => ['x', 'y'] does not set explicit domains + // Test that FACET with free => [true, true] does not set explicit domains // This allows Vega-Lite to compute independent domains per facet panel use crate::plot::scale::Scale; use crate::plot::{ArrayElement, Facet, FacetLayout, ParameterValue}; @@ -1862,13 +1852,14 @@ mod tests { ); spec.layers.push(layer); - // Add facet with free => ['x', 'y'] + // Add facet with free => [true, true] (both x and y free) + // This is the normalized format after facet resolution let mut facet_properties = HashMap::new(); facet_properties.insert( "free".to_string(), ParameterValue::Array(vec![ - ArrayElement::String("x".to_string()), - ArrayElement::String("y".to_string()), + ArrayElement::Boolean(true), // pos1 (x) is free + ArrayElement::Boolean(true), // pos2 (y) is free ]), ); spec.facet = Some(Facet { @@ -1922,7 +1913,7 @@ mod tests { #[test] fn test_facet_free_y_only_omits_y_domain() { - // Test that FACET with free => 'y' omits y domain but keeps x domain + // Test that FACET with free => [false, true] omits y domain but keeps x domain use crate::plot::scale::Scale; use crate::plot::{ArrayElement, Facet, FacetLayout, ParameterValue}; @@ -1940,9 +1931,16 @@ mod tests { ); spec.layers.push(layer); - // Add facet with free => 'y' + // Add facet with free => [false, true] (only y is free) + // This is the normalized format after facet resolution let mut facet_properties = HashMap::new(); - facet_properties.insert("free".to_string(), ParameterValue::String("y".to_string())); + facet_properties.insert( + "free".to_string(), + ParameterValue::Array(vec![ + ArrayElement::Boolean(false), // pos1 (x) is fixed + ArrayElement::Boolean(true), // pos2 (y) is free + ]), + ); spec.facet = Some(Facet { layout: FacetLayout::Wrap { variables: vec!["category".to_string()], From 672be3fe840b5bf92505454490fa01a1a6793c87 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Wed, 25 Feb 2026 10:03:37 +0100 Subject: [PATCH 08/28] update facet aesthetic to normalised names --- src/execute/mod.rs | 174 ++++++++++------------ src/lib.rs | 15 +- src/parser/builder.rs | 5 +- src/plot/aesthetic.rs | 239 +++++++++++++++++++++++++++---- src/plot/facet/types.rs | 64 +++++++-- src/plot/main.rs | 16 +-- src/plot/scale/mod.rs | 6 +- src/plot/scale/scale_type/mod.rs | 2 +- src/writer/vegalite/mod.rs | 51 +++---- 9 files changed, 386 insertions(+), 186 deletions(-) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index b396902a..a30a7967 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -290,18 +290,19 @@ fn add_facet_mappings_to_layers( } let type_info = &layer_type_info[layer_idx]; - for (var, aesthetic) in facet.layout.get_aesthetic_mappings() { + // Use internal aesthetic names (facet1, facet2) since transformation has already occurred + for (var, aesthetic) in facet.layout.get_internal_aesthetic_mappings() { // Skip if layer already has this facet aesthetic mapped (from MAPPING or global) - if layer.mappings.aesthetics.contains_key(aesthetic) { + if layer.mappings.aesthetics.contains_key(&aesthetic) { continue; } // Only inject if the column exists in this layer's schema // (variables list is empty when inferred from layer mappings - no injection needed) if type_info.iter().any(|(col, _, _)| col == var) { - // Add mapping: variable → facet aesthetic + // Add mapping: variable → facet aesthetic (internal name) layer.mappings.aesthetics.insert( - aesthetic.to_string(), + aesthetic, AestheticValue::Column { name: var.to_string(), original_name: Some(var.to_string()), @@ -492,11 +493,11 @@ fn handle_missing_facet_columns( return Ok(()); } - // Get facet aesthetics from layout - let facet_aesthetics = facet.layout.get_aesthetics(); + // Get internal facet aesthetics from layout (facet1, facet2) + let facet_aesthetics = facet.layout.internal_facet_names(); // Process each facet aesthetic - for facet_aesthetic in facet_aesthetics { + for facet_aesthetic in &facet_aesthetics { // Get unique values from layers that HAVE the column let unique_values = match get_unique_facet_values( data_map, @@ -558,50 +559,44 @@ fn resolve_facet( use crate::plot::scale::is_facet_aesthetic; // Collect facet aesthetic mappings from all layers - let mut has_facet = false; - let mut has_row = false; - let mut has_column = false; + // After transformation: panel → facet1, row → facet1, column → facet2 + // If only facet1 exists → wrap layout (panel only) + // If facet1 AND facet2 exist → grid layout (row AND column) + let mut has_facet1 = false; + let mut has_facet2 = false; for layer in layers { for aesthetic in layer.mappings.aesthetics.keys() { if is_facet_aesthetic(aesthetic) { match aesthetic.as_str() { - "panel" => has_facet = true, - "row" => has_row = true, - "column" => has_column = true, + "facet1" => has_facet1 = true, + "facet2" => has_facet2 = true, _ => {} } } } } - // Validate: cannot mix Wrap (panel) with Grid (row/column) - if has_facet && (has_row || has_column) { + // Validate: Grid requires both facet1 and facet2 (row and column) + // Having only facet2 is an error (column without row) + if has_facet2 && !has_facet1 { return Err(GgsqlError::ValidationError( - "Cannot mix 'panel' aesthetic (Wrap layout) with 'row'/'column' aesthetics (Grid layout). \ - Use either 'panel' for Wrap or 'row'/'column' for Grid.".to_string() + "Grid facet layout requires both 'row' and 'column' aesthetics. Missing: 'row'".to_string() )); } - // Validate: Grid requires both row and column - if (has_row || has_column) && !(has_row && has_column) { - let missing = if has_row { "column" } else { "row" }; - return Err(GgsqlError::ValidationError(format!( - "Grid facet layout requires both 'row' and 'column' aesthetics. Missing: '{}'", - missing - ))); - } - // Determine inferred layout from layer mappings - let inferred_layout = if has_facet { - Some(FacetLayout::Wrap { - variables: vec![], // Empty - each layer has its own mapping - }) - } else if has_row && has_column { + // facet1 only → wrap layout (originally 'panel') + // facet1 AND facet2 → grid layout (originally 'row' AND 'column') + let inferred_layout = if has_facet1 && has_facet2 { Some(FacetLayout::Grid { row: vec![], // Empty - each layer has its own mapping column: vec![], // Empty - each layer has its own mapping }) + } else if has_facet1 { + Some(FacetLayout::Wrap { + variables: vec![], // Empty - each layer has its own mapping + }) } else { None }; @@ -615,19 +610,20 @@ fn resolve_facet( if let Some(ref facet) = existing_facet { let is_wrap = facet.is_wrap(); - if is_wrap && (has_row || has_column) { + // Wrap layout (FACET var) but layer has both facet1 AND facet2 (row/column) + // This indicates the layer was declared with Grid aesthetics + if is_wrap && has_facet2 { return Err(GgsqlError::ValidationError( "FACET clause uses Wrap layout, but layer mappings use 'row'/'column' (Grid layout). \ Remove FACET clause to infer Grid layout, or use 'panel' aesthetic instead.".to_string() )); } - if !is_wrap && has_facet { - return Err(GgsqlError::ValidationError( - "FACET clause uses Grid layout, but layer mappings use 'panel' aesthetic (Wrap layout). \ - Remove FACET clause to infer Wrap layout, or use 'row'/'column' aesthetics instead.".to_string() - )); - } + // Grid layout (FACET row BY col) but layer has only facet1 without facet2 + // This indicates the layer was declared with Wrap aesthetic (panel only) + // Note: Grid layout declared by user means they expect both row and column + // If layer only has facet1, it's compatible (will use only row mapping) + // This is actually okay - we don't need to error here // FACET clause exists and is compatible - use it (layer mappings will override columns) return Ok(Some(facet.clone())); @@ -764,10 +760,10 @@ fn collect_layer_required_columns(layer: &Layer, spec: &Plot) -> HashSet // Facet aesthetic columns (shared across all layers) // Only the aesthetic-prefixed columns are needed for Vega-Lite output. // The original variable names (e.g., "species") are not needed after - // the aesthetic columns (e.g., "__ggsql_aes_panel__") have been created. + // the aesthetic columns (e.g., "__ggsql_aes_facet1__") have been created. if let Some(ref facet) = spec.facet { - for aesthetic in facet.layout.get_aesthetics() { - required.insert(naming::aesthetic_column(aesthetic)); + for aesthetic in facet.layout.internal_facet_names() { + required.insert(naming::aesthetic_column(&aesthetic)); } } @@ -980,7 +976,7 @@ pub fn prepare_data_with_reader(query: &str, reader: &R) -> Result(query: &str, reader: &R) -> Result = facet .layout - .get_aesthetics() + .internal_facet_names() .iter() .map(|aes| naming::aesthetic_column(aes)) .collect(); @@ -1698,7 +1694,8 @@ mod tests { #[test] fn test_resolve_facet_infers_wrap_from_layer_mapping() { - let layers = vec![make_layer_with_mapping("panel", "region")]; + // Use internal name "facet1" since resolve_facet is called after transformation + let layers = vec![make_layer_with_mapping("facet1", "region")]; let result = resolve_facet(&layers, None).unwrap(); @@ -1711,13 +1708,14 @@ mod tests { #[test] fn test_resolve_facet_infers_grid_from_layer_mappings() { + // Use internal names "facet1" and "facet2" since resolve_facet is called after transformation let mut layer = Layer::new(Geom::point()); layer .mappings .aesthetics - .insert("row".to_string(), AestheticValue::standard_column("region")); + .insert("facet1".to_string(), AestheticValue::standard_column("region")); layer.mappings.aesthetics.insert( - "column".to_string(), + "facet2".to_string(), AestheticValue::standard_column("year"), ); let layers = vec![layer]; @@ -1731,39 +1729,22 @@ mod tests { assert!(facet.get_variables().is_empty()); } - #[test] - fn test_resolve_facet_error_mixed_wrap_and_grid() { - let mut layer = Layer::new(Geom::point()); - layer.mappings.aesthetics.insert( - "panel".to_string(), - AestheticValue::standard_column("region"), - ); - layer - .mappings - .aesthetics - .insert("row".to_string(), AestheticValue::standard_column("year")); - let layers = vec![layer]; - - let result = resolve_facet(&layers, None); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("Cannot mix")); - assert!(err.contains("panel")); - assert!(err.contains("row")); - } + // Note: With internal naming (facet1, facet2), mixing wrap and grid is no longer detectable + // at this level because panel→facet1 and row→facet1 map to the same internal name. + // The validation happens at the user-facing level during parsing, not here. + // This test is no longer applicable with internal naming. #[test] fn test_resolve_facet_error_incomplete_grid() { - // Only row, missing column - let layers = vec![make_layer_with_mapping("row", "region")]; + // Only facet2 without facet1 is an error (column without row) + let layers = vec![make_layer_with_mapping("facet2", "region")]; let result = resolve_facet(&layers, None); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("requires both")); - assert!(err.contains("column")); + assert!(err.contains("row")); } #[test] @@ -1784,13 +1765,15 @@ mod tests { #[test] fn test_resolve_facet_error_wrap_clause_with_grid_mapping() { + // When layer has both facet1 AND facet2 (grid), but FACET clause is Wrap + // This should error because the user declared grid aesthetics but FACET says wrap let mut layer = Layer::new(Geom::point()); layer.mappings.aesthetics.insert( - "row".to_string(), + "facet1".to_string(), AestheticValue::standard_column("category"), ); layer.mappings.aesthetics.insert( - "column".to_string(), + "facet2".to_string(), AestheticValue::standard_column("year"), ); let layers = vec![layer]; @@ -1804,25 +1787,12 @@ mod tests { assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!(err.contains("Wrap layout")); - assert!(err.contains("row")); + assert!(err.contains("row")); // mentions the user-facing name in error } - #[test] - fn test_resolve_facet_error_grid_clause_with_wrap_mapping() { - let layers = vec![make_layer_with_mapping("panel", "region")]; - - let existing_facet = Facet::new(FacetLayout::Grid { - row: vec!["region".to_string()], - column: vec!["year".to_string()], - }); - - let result = resolve_facet(&layers, Some(existing_facet)); - - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("Grid layout")); - assert!(err.contains("panel")); - } + // Note: With internal naming, grid clause with wrap mapping is allowed + // because facet1 (from panel) is compatible with grid layout (uses row only). + // The original test is no longer valid. #[test] fn test_resolve_facet_no_mappings_no_clause() { @@ -1835,8 +1805,8 @@ mod tests { #[test] fn test_resolve_facet_layer_override_compatible_with_clause() { - // Layer has panel mapping, FACET clause is Wrap - compatible - let layers = vec![make_layer_with_mapping("panel", "category")]; + // Layer has facet1 mapping (from panel), FACET clause is Wrap - compatible + let layers = vec![make_layer_with_mapping("facet1", "category")]; let existing_facet = Facet::new(FacetLayout::Wrap { variables: vec!["region".to_string()], @@ -1878,9 +1848,9 @@ mod tests { let facet = result.specs[0].facet.as_ref().unwrap(); assert!(facet.is_wrap()); - // Data should have panel aesthetic column + // Data should have facet1 aesthetic column (internal name for panel) let layer_df = result.data.get(&naming::layer_key(0)).unwrap(); - let facet_col = naming::aesthetic_column("panel"); + let facet_col = naming::aesthetic_column("facet1"); assert!( layer_df.column(&facet_col).is_ok(), "Should have '{}' column: {:?}", @@ -1919,10 +1889,10 @@ mod tests { let facet = result.specs[0].facet.as_ref().unwrap(); assert!(facet.is_grid()); - // Data should have row and column aesthetic columns + // Data should have facet1 (row) and facet2 (column) aesthetic columns (internal names) let layer_df = result.data.get(&naming::layer_key(0)).unwrap(); - let row_col = naming::aesthetic_column("row"); - let col_col = naming::aesthetic_column("column"); + let row_col = naming::aesthetic_column("facet1"); + let col_col = naming::aesthetic_column("facet2"); assert!( layer_df.column(&row_col).is_ok(), "Should have '{}' column", @@ -1991,7 +1961,8 @@ mod tests { // Should succeed - layer mapping overrides FACET clause let layer = &result.specs[0].layers[0]; - let facet_mapping = layer.mappings.aesthetics.get("panel").unwrap(); + // Use internal name "facet1" since transformation has occurred + let facet_mapping = layer.mappings.aesthetics.get("facet1").unwrap(); // Use label_name() which returns original column name before internal renaming assert_eq!( facet_mapping.label_name(), @@ -2059,11 +2030,12 @@ mod tests { "ref layer should be repeated for each facet panel (A and B)" ); - // The panel column should exist in the ref_data - let facet_col = naming::aesthetic_column("panel"); + // The facet column should exist in the ref_data (internal name facet1) + let facet_col = naming::aesthetic_column("facet1"); assert!( ref_df.column(&facet_col).is_ok(), - "ref data should have panel column after broadcast" + "ref data should have facet column after broadcast: {:?}", + ref_df.get_column_names_str() ); } diff --git a/src/lib.rs b/src/lib.rs index aeaf7e2b..b05f7882 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -727,21 +727,22 @@ mod integration_tests { stroke_col, col_names ); - // Facet aesthetic columns should be included (row and column for grid facet) - let row_col = naming::aesthetic_column("row"); - let column_col = naming::aesthetic_column("column"); + // Facet aesthetic columns should be included (facet1 and facet2 for grid facet) + // Note: row→facet1, column→facet2 after internal naming transformation + let facet1_col = naming::aesthetic_column("facet1"); + let facet2_col = naming::aesthetic_column("facet2"); assert!( - col_names.iter().any(|c| c.as_str() == row_col), + col_names.iter().any(|c| c.as_str() == facet1_col), "Layer {} should have '{}' facet column: {:?}", layer_idx, - row_col, + facet1_col, col_names ); assert!( - col_names.iter().any(|c| c.as_str() == column_col), + col_names.iter().any(|c| c.as_str() == facet2_col), "Layer {} should have '{}' facet column: {:?}", layer_idx, - column_col, + facet2_col, col_names ); } diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 74c412cb..59b2573a 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -4,7 +4,7 @@ //! handling all the node types defined in the grammar. use crate::plot::layer::geom::Geom; -use crate::plot::scale::{color_to_hex, is_color_aesthetic, is_facet_aesthetic, Transform}; +use crate::plot::scale::{color_to_hex, is_color_aesthetic, is_user_facet_aesthetic, Transform}; use crate::plot::*; use crate::{GgsqlError, Result}; use std::collections::HashMap; @@ -679,7 +679,8 @@ fn build_scale(node: &Node, source: &SourceTree) -> Result { } // Validate facet aesthetics cannot have output ranges (TO clause) - if is_facet_aesthetic(&aesthetic) && output_range.is_some() { + // Note: This check uses user-facing names since we're in the parser, before transformation + if is_user_facet_aesthetic(&aesthetic) && output_range.is_some() { return Err(GgsqlError::ValidationError(format!( "SCALE {}: facet variables cannot have output ranges (TO clause)", aesthetic diff --git a/src/plot/aesthetic.rs b/src/plot/aesthetic.rs index 8e97fe76..32a2e3f3 100644 --- a/src/plot/aesthetic.rs +++ b/src/plot/aesthetic.rs @@ -37,13 +37,17 @@ const FAMILY_SIZE: usize = 1 + POSITIONAL_SUFFIXES.len(); // Static Constants (for backward compatibility with existing code) // ============================================================================= -/// Facet aesthetics (for creating small multiples) +/// User-facing facet aesthetics (for creating small multiples) /// /// These aesthetics control faceting layout: /// - `panel`: Single variable faceting (wrap layout) /// - `row`: Row variable for grid faceting /// - `column`: Column variable for grid faceting -pub const FACET_AESTHETICS: &[&str] = &["panel", "row", "column"]; +/// +/// After aesthetic transformation, these become internal names: +/// - `panel` → `facet1` +/// - `row` → `facet1`, `column` → `facet2` +pub const USER_FACET_AESTHETICS: &[&str] = &["panel", "row", "column"]; /// Non-positional aesthetics (visual properties shown in legends or applied to marks) /// @@ -79,7 +83,8 @@ pub const NON_POSITIONAL: &[&str] = &[ /// /// Pre-computes all mappings at creation time for efficient lookups. /// Used to transform between user-facing aesthetic names (x/y or theta/radius) -/// and internal names (pos1/pos2). +/// and internal names (pos1/pos2), as well as facet aesthetics (panel/row/column) +/// to internal facet names (facet1/facet2). /// /// # Example /// @@ -97,6 +102,15 @@ pub const NON_POSITIONAL: &[&str] = &[ /// assert_eq!(ctx.map_user_to_internal("theta"), Some("pos1")); /// assert_eq!(ctx.map_user_to_internal("radius"), Some("pos2")); /// assert_eq!(ctx.map_internal_to_user("pos1"), Some("theta")); +/// +/// // With facets +/// let ctx = AestheticContext::new(&["x", "y"], &["panel"]); +/// assert_eq!(ctx.map_user_to_internal("panel"), Some("facet1")); +/// assert_eq!(ctx.map_internal_to_user("facet1"), Some("panel")); +/// +/// let ctx = AestheticContext::new(&["x", "y"], &["row", "column"]); +/// assert_eq!(ctx.map_user_to_internal("row"), Some("facet1")); +/// assert_eq!(ctx.map_user_to_internal("column"), Some("facet2")); /// ``` #[derive(Debug, Clone)] pub struct AestheticContext { @@ -109,7 +123,11 @@ pub struct AestheticContext { /// All internal positional: ["pos1", "pos1min", ..., "pos2", ...] all_internal_positional: Vec, /// User-facing facet names: ["panel"] or ["row", "column"] - facet: Vec<&'static str>, + user_facet: Vec<&'static str>, + /// All user facet names: ["panel"] or ["row", "column"] + all_user_facet: Vec, + /// All internal facet names: ["facet1"] or ["facet1", "facet2"] + all_internal_facet: Vec, /// Non-positional aesthetics (static list) non_positional: &'static [&'static str], } @@ -120,8 +138,10 @@ impl AestheticContext { /// # Arguments /// /// * `positional_names` - Primary positional aesthetic names from coord (e.g., ["x", "y"]) - /// * `facet_names` - Aesthetic names from facet (e.g., ["panel"] or ["row", "column"]) + /// * `facet_names` - User-facing facet aesthetic names from facet layout + /// (e.g., ["panel"] for wrap, ["row", "column"] for grid) pub fn new(positional_names: &[&'static str], facet_names: &[&'static str]) -> Self { + // Build positional mappings let mut all_user = Vec::new(); let mut primary_internal = Vec::new(); let mut all_internal = Vec::new(); @@ -142,38 +162,93 @@ impl AestheticContext { } } + // Build facet mappings for active facets (from FACET clause or layer mappings) + // These are used for internal→user mapping (to know which user name to show) + let mut all_user_facet = Vec::new(); + let mut all_internal_facet = Vec::new(); + + for (i, facet_name) in facet_names.iter().enumerate() { + let facet_num = i + 1; + all_user_facet.push((*facet_name).to_string()); + all_internal_facet.push(format!("facet{}", facet_num)); + } + Self { user_positional: positional_names.to_vec(), all_user_positional: all_user, primary_internal, all_internal_positional: all_internal, - facet: facet_names.to_vec(), + user_facet: facet_names.to_vec(), + all_user_facet, + all_internal_facet, non_positional: NON_POSITIONAL, } } // === Mapping: User → Internal === - /// Map user positional aesthetic to internal name. + /// Map user aesthetic (positional or facet) to internal name. /// - /// e.g., "x" → "pos1", "ymin" → "pos2min", "theta" → "pos1" + /// Positional: "x" → "pos1", "ymin" → "pos2min", "theta" → "pos1" + /// Facet: "panel" → "facet1", "row" → "facet1", "column" → "facet2" + /// + /// Note: Facet mappings work regardless of whether a FACET clause exists, + /// allowing layer-declared facet aesthetics to be transformed. pub fn map_user_to_internal(&self, user_aesthetic: &str) -> Option<&str> { - self.all_user_positional + // Check positional first + if let Some(idx) = self + .all_user_positional + .iter() + .position(|u| u == user_aesthetic) + { + return Some(self.all_internal_positional[idx].as_str()); + } + + // Check active facet (from FACET clause) + if let Some(idx) = self + .all_user_facet .iter() .position(|u| u == user_aesthetic) - .map(|idx| self.all_internal_positional[idx].as_str()) + { + return Some(self.all_internal_facet[idx].as_str()); + } + + // Always map user-facing facet names to internal names, + // even when no FACET clause exists (allows layer-declared facets) + // panel → facet1 (wrap layout) + // row → facet1, column → facet2 (grid layout) + match user_aesthetic { + "panel" => Some("facet1"), + "row" => Some("facet1"), + "column" => Some("facet2"), + _ => None, + } } // === Mapping: Internal → User === - /// Map internal positional to user-facing name. + /// Map internal aesthetic (positional or facet) to user-facing name. /// - /// e.g., "pos1" → "x", "pos2min" → "ymin" + /// Positional: "pos1" → "x", "pos2min" → "ymin" + /// Facet: "facet1" → "panel" (or "row"), "facet2" → "column" pub fn map_internal_to_user(&self, internal_aesthetic: &str) -> Option<&str> { - self.all_internal_positional + // Check positional first + if let Some(idx) = self + .all_internal_positional + .iter() + .position(|i| i == internal_aesthetic) + { + return Some(self.all_user_positional[idx].as_str()); + } + // Check facet + if let Some(idx) = self + .all_internal_facet .iter() .position(|i| i == internal_aesthetic) - .map(|idx| self.all_user_positional[idx].as_str()) + { + return Some(self.all_user_facet[idx].as_str()); + } + None } // === Checking (simple lookups in pre-computed lists) === @@ -198,9 +273,19 @@ impl AestheticContext { self.non_positional.contains(&name) } - /// Check if name is a facet aesthetic + /// Check if name is a user-facing facet aesthetic (panel, row, column) + pub fn is_user_facet(&self, name: &str) -> bool { + self.all_user_facet.iter().any(|f| f == name) + } + + /// Check if name is an internal facet aesthetic (facet1, facet2) + pub fn is_internal_facet(&self, name: &str) -> bool { + self.all_internal_facet.iter().any(|f| f == name) + } + + /// Check if name is a facet aesthetic (user or internal) pub fn is_facet(&self, name: &str) -> bool { - self.facet.contains(&name) + self.is_user_facet(name) || self.is_internal_facet(name) } // === Aesthetic Families === @@ -301,9 +386,19 @@ impl AestheticContext { &self.all_user_positional } - /// Get facet aesthetics - pub fn facet(&self) -> &[&'static str] { - &self.facet + /// Get user-facing facet aesthetics (panel, row, column) + pub fn user_facet(&self) -> &[&'static str] { + &self.user_facet + } + + /// Get all user facet aesthetics as Strings + pub fn all_user_facet(&self) -> &[String] { + &self.all_user_facet + } + + /// Get all internal facet aesthetics (facet1, facet2) + pub fn all_internal_facet(&self) -> &[String] { + &self.all_internal_facet } /// Get non-positional aesthetics @@ -325,13 +420,29 @@ pub fn is_primary_positional(aesthetic: &str) -> bool { false } -/// Check if aesthetic is a facet aesthetic (panel, row, column) +/// Check if aesthetic is a user-facing facet aesthetic (panel, row, column) +/// +/// Use this function for checks BEFORE aesthetic transformation. +/// For checks after transformation, use `is_facet_aesthetic`. +#[inline] +pub fn is_user_facet_aesthetic(aesthetic: &str) -> bool { + USER_FACET_AESTHETICS.contains(&aesthetic) +} + +/// Check if aesthetic is an internal facet aesthetic (facet1, facet2, etc.) /// /// Facet aesthetics control the creation of small multiples (faceted plots). /// They only support Discrete and Binned scale types, and cannot have output ranges (TO clause). +/// +/// This function works with **internal** aesthetic names after transformation. +/// For user-facing checks before transformation, use `is_user_facet_aesthetic`. #[inline] pub fn is_facet_aesthetic(aesthetic: &str) -> bool { - FACET_AESTHETICS.contains(&aesthetic) + // Check pattern: facet followed by digits only (facet1, facet2, etc.) + if aesthetic.starts_with("facet") && aesthetic.len() > 5 { + return aesthetic[5..].chars().all(|c| c.is_ascii_digit()); + } + false } /// Check if aesthetic is an internal positional (pos1, pos1min, pos2max, etc.) @@ -369,12 +480,15 @@ pub fn is_positional_aesthetic(name: &str) -> bool { /// Check if name is a recognized aesthetic (internal or non-positional) /// -/// This function works with **internal** aesthetic names (pos1, pos2, etc.) and non-positional +/// This function works with **internal** aesthetic names (pos1, pos2, facet1, etc.) and non-positional /// aesthetics. For validating user-facing aesthetic names before transformation, use /// `AestheticContext::is_user_positional()` or check against the grammar's aesthetic_name rule. #[inline] pub fn is_aesthetic_name(name: &str) -> bool { - is_positional_aesthetic(name) || NON_POSITIONAL.contains(&name) || FACET_AESTHETICS.contains(&name) + is_positional_aesthetic(name) + || is_facet_aesthetic(name) + || NON_POSITIONAL.contains(&name) + || USER_FACET_AESTHETICS.contains(&name) } /// Get the primary aesthetic for a given aesthetic name. @@ -476,11 +590,38 @@ mod tests { #[test] fn test_facet_aesthetic() { - assert!(is_facet_aesthetic("panel")); - assert!(is_facet_aesthetic("row")); - assert!(is_facet_aesthetic("column")); + // Internal facet aesthetics (after transformation) + assert!(is_facet_aesthetic("facet1")); + assert!(is_facet_aesthetic("facet2")); + assert!(is_facet_aesthetic("facet10")); // supports any number + assert!(!is_facet_aesthetic("facet")); // too short + assert!(!is_facet_aesthetic("facetx")); // not a number + + // User-facing names are NOT internal facet aesthetics + assert!(!is_facet_aesthetic("panel")); + assert!(!is_facet_aesthetic("row")); + assert!(!is_facet_aesthetic("column")); + + // Other aesthetics assert!(!is_facet_aesthetic("x")); assert!(!is_facet_aesthetic("color")); + assert!(!is_facet_aesthetic("pos1")); + } + + #[test] + fn test_user_facet_aesthetic() { + // User-facing facet aesthetics (before transformation) + assert!(is_user_facet_aesthetic("panel")); + assert!(is_user_facet_aesthetic("row")); + assert!(is_user_facet_aesthetic("column")); + + // Internal names are NOT user-facing + assert!(!is_user_facet_aesthetic("facet1")); + assert!(!is_user_facet_aesthetic("facet2")); + + // Other aesthetics + assert!(!is_user_facet_aesthetic("x")); + assert!(!is_user_facet_aesthetic("color")); } #[test] @@ -548,12 +689,14 @@ mod tests { assert!(is_aesthetic_name("hjust")); assert!(is_aesthetic_name("vjust")); - // Facet + // Facet (both user-facing and internal) assert!(is_aesthetic_name("panel")); assert!(is_aesthetic_name("row")); assert!(is_aesthetic_name("column")); + assert!(is_aesthetic_name("facet1")); + assert!(is_aesthetic_name("facet2")); - // Not aesthetics (user-facing names are not recognized by this function) + // Not aesthetics (user-facing positional names are not recognized by this function) assert!(!is_aesthetic_name("x")); assert!(!is_aesthetic_name("y")); assert!(!is_aesthetic_name("theta")); @@ -771,9 +914,43 @@ mod tests { fn test_aesthetic_context_with_facets() { let ctx = AestheticContext::new(&["x", "y"], &["panel"]); - assert!(ctx.is_facet("panel")); - assert!(!ctx.is_facet("row")); - assert_eq!(ctx.facet(), &["panel"]); + // Check user facet + assert!(ctx.is_user_facet("panel")); + assert!(!ctx.is_user_facet("row")); + assert_eq!(ctx.user_facet(), &["panel"]); + + // Check internal facet + assert!(ctx.is_internal_facet("facet1")); + assert!(!ctx.is_internal_facet("panel")); + + // Check mapping + assert_eq!(ctx.map_user_to_internal("panel"), Some("facet1")); + assert_eq!(ctx.map_internal_to_user("facet1"), Some("panel")); + + // Check combined is_facet + assert!(ctx.is_facet("panel")); // user + assert!(ctx.is_facet("facet1")); // internal + } + + #[test] + fn test_aesthetic_context_with_grid_facets() { + let ctx = AestheticContext::new(&["x", "y"], &["row", "column"]); + + // Check user facet + assert!(ctx.is_user_facet("row")); + assert!(ctx.is_user_facet("column")); + assert!(!ctx.is_user_facet("panel")); + assert_eq!(ctx.user_facet(), &["row", "column"]); + + // Check internal facet + assert!(ctx.is_internal_facet("facet1")); + assert!(ctx.is_internal_facet("facet2")); + + // Check mappings + assert_eq!(ctx.map_user_to_internal("row"), Some("facet1")); + assert_eq!(ctx.map_user_to_internal("column"), Some("facet2")); + assert_eq!(ctx.map_internal_to_user("facet1"), Some("row")); + assert_eq!(ctx.map_internal_to_user("facet2"), Some("column")); } #[test] diff --git a/src/plot/facet/types.rs b/src/plot/facet/types.rs index 6c29cfae..a2be1580 100644 --- a/src/plot/facet/types.rs +++ b/src/plot/facet/types.rs @@ -92,33 +92,77 @@ impl FacetLayout { matches!(self, FacetLayout::Grid { .. }) } - /// Get variable names mapped to their aesthetic names. + /// Get variable names mapped to their user-facing aesthetic names. /// /// Returns tuples of (column_name, aesthetic_name): /// - Wrap: [("region", "panel")] /// - Grid: [("region", "row"), ("year", "column")] + /// + /// Note: These are user-facing names. Use AestheticContext to transform + /// to internal names (facet1, facet2) after context initialization. pub fn get_aesthetic_mappings(&self) -> Vec<(&str, &'static str)> { + let user_names = self.user_facet_names(); match self { FacetLayout::Wrap { variables } => { - variables.iter().map(|v| (v.as_str(), "panel")).collect() + variables + .iter() + .map(|v| (v.as_str(), user_names[0])) + .collect() } FacetLayout::Grid { row, column } => { let mut result: Vec<(&str, &'static str)> = - row.iter().map(|v| (v.as_str(), "row")).collect(); - result.extend(column.iter().map(|v| (v.as_str(), "column"))); + row.iter().map(|v| (v.as_str(), user_names[0])).collect(); + result.extend(column.iter().map(|v| (v.as_str(), user_names[1]))); result } } } - /// Get the aesthetic names used by this layout. + /// Get the user-facing facet aesthetic names for this layout. + /// + /// Used by AestheticContext for user↔internal mapping: + /// - Wrap: ["panel"] → maps to "facet1" internally + /// - Grid: ["row", "column"] → maps to "facet1", "facet2" internally + pub fn user_facet_names(&self) -> &'static [&'static str] { + match self { + FacetLayout::Wrap { .. } => &["panel"], + FacetLayout::Grid { .. } => &["row", "column"], + } + } + + /// Get the internal facet aesthetic names for this layout. /// - /// - Wrap: ["panel"] - /// - Grid: ["row", "column"] - pub fn get_aesthetics(&self) -> Vec<&'static str> { + /// Returns: "facet1" for wrap, "facet1" and "facet2" for grid. + /// Use this after aesthetic transformation has occurred. + pub fn internal_facet_names(&self) -> Vec { match self { - FacetLayout::Wrap { .. } => vec!["panel"], - FacetLayout::Grid { .. } => vec!["row", "column"], + FacetLayout::Wrap { .. } => vec!["facet1".to_string()], + FacetLayout::Grid { .. } => vec!["facet1".to_string(), "facet2".to_string()], + } + } + + /// Get variable names mapped to their internal aesthetic names. + /// + /// Returns tuples of (column_name, internal_aesthetic_name): + /// - Wrap: [("region", "facet1")] + /// - Grid: [("region", "facet1"), ("year", "facet2")] + /// + /// Use this after aesthetic transformation has occurred. + pub fn get_internal_aesthetic_mappings(&self) -> Vec<(&str, String)> { + let internal_names = self.internal_facet_names(); + match self { + FacetLayout::Wrap { variables } => variables + .iter() + .map(|v| (v.as_str(), internal_names[0].clone())) + .collect(), + FacetLayout::Grid { row, column } => { + let mut result: Vec<(&str, String)> = row + .iter() + .map(|v| (v.as_str(), internal_names[0].clone())) + .collect(); + result.extend(column.iter().map(|v| (v.as_str(), internal_names[1].clone()))); + result + } } } } diff --git a/src/plot/main.rs b/src/plot/main.rs index 9a9d8ac0..178560be 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -146,12 +146,12 @@ impl Plot { .as_ref() .map(|p| p.coord.positional_aesthetic_names()) .unwrap_or(&["x", "y"]); - let facet_names = self + let facet_names: &[&'static str] = self .facet .as_ref() - .map(|f| f.layout.get_aesthetics()) - .unwrap_or_default(); - AestheticContext::new(positional_names, &facet_names) + .map(|f| f.layout.user_facet_names()) + .unwrap_or(&[]); + AestheticContext::new(positional_names, facet_names) } } @@ -162,12 +162,12 @@ impl Plot { .as_ref() .map(|p| p.coord.positional_aesthetic_names()) .unwrap_or(&["x", "y"]); - let facet_names = self + let facet_names: &[&'static str] = self .facet .as_ref() - .map(|f| f.layout.get_aesthetics()) - .unwrap_or_default(); - self.aesthetic_context = Some(AestheticContext::new(positional_names, &facet_names)); + .map(|f| f.layout.user_facet_names()) + .unwrap_or(&[]); + self.aesthetic_context = Some(AestheticContext::new(positional_names, facet_names)); } /// Transform all aesthetic keys from user-facing to internal names. diff --git a/src/plot/scale/mod.rs b/src/plot/scale/mod.rs index 785296bd..fb7203c7 100644 --- a/src/plot/scale/mod.rs +++ b/src/plot/scale/mod.rs @@ -12,7 +12,9 @@ pub mod transform; mod types; pub use crate::format::apply_label_template; -pub use crate::plot::aesthetic::{is_facet_aesthetic, is_positional_aesthetic}; +pub use crate::plot::aesthetic::{ + is_facet_aesthetic, is_positional_aesthetic, is_user_facet_aesthetic, +}; pub use crate::plot::types::{CastTargetType, SqlTypeNames}; pub use colour::{color_to_hex, gradient, interpolate_colors, is_color_aesthetic, ColorSpace}; pub use linetype::linetype_to_stroke_dash; @@ -47,7 +49,7 @@ pub fn gets_default_scale(aesthetic: &str) -> bool { return true; } - // Facet aesthetics (panel, row, column) - checked dynamically + // Facet aesthetics (facet1, facet2, etc.) - checked dynamically if is_facet_aesthetic(aesthetic) { return true; } diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index fbfb2d16..2a6c1474 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -1113,7 +1113,7 @@ impl ScaleType { /// - Numeric/temporal → Continuous /// - String/boolean → Discrete /// - /// For facet aesthetics (panel, row, column): + /// For facet aesthetics (facet1, facet2): /// - Numeric/temporal → Binned (not Continuous, since facets need discrete categories) /// - String/boolean → Discrete pub fn infer_for_aesthetic(dtype: &DataType, aesthetic: &str) -> Self { diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index c34336a8..45fbc7b1 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -248,7 +248,8 @@ fn build_layer_encoding( // Skip facet aesthetics - they are handled via top-level facet structure, // not as encoding channels. Adding them to encoding would create row-based // faceting instead of the intended wrap/grid layout. - if matches!(aesthetic.as_str(), "panel" | "row" | "column") { + // Check for internal facet names (facet1, facet2) since transformation has occurred. + if aesthetic_ctx.is_internal_facet(aesthetic) { continue; } @@ -309,7 +310,7 @@ fn build_layer_encoding( /// - FACET vars (wrap layout) /// - FACET rows BY columns (grid layout) /// - Moves layers into nested `spec` object -/// - Uses aesthetic column names (e.g., __ggsql_aes_panel__) +/// - Uses aesthetic column names (e.g., __ggsql_aes_facet1__) /// - Respects scale types (Binned facets use bin: "binned") /// - Scale resolution (scales property) /// - Label renaming (RENAMING clause) @@ -324,11 +325,11 @@ fn apply_faceting( match &facet.layout { FacetLayout::Wrap { variables: _ } => { - // Use the aesthetic column name for panel - let aes_col = naming::aesthetic_column("panel"); + // Use internal aesthetic column name (facet1) + let aes_col = naming::aesthetic_column("facet1"); - // Look up scale for "panel" aesthetic - let scale = scales.iter().find(|s| s.aesthetic == "panel"); + // Look up scale for internal "facet1" aesthetic + let scale = scales.iter().find(|s| s.aesthetic == "facet1"); // Build facet field definition with proper binned support let mut facet_def = build_facet_field_def(facet_df, &aes_col, scale); @@ -362,10 +363,11 @@ fn apply_faceting( FacetLayout::Grid { row: _, column: _ } => { let mut facet_spec = serde_json::Map::new(); - // Row facet: use aesthetic column "row" - let row_aes_col = naming::aesthetic_column("row"); + // Row facet: use internal aesthetic column "facet1" + // Vega-Lite requires "row" key in the facet object + let row_aes_col = naming::aesthetic_column("facet1"); if facet_df.column(&row_aes_col).is_ok() { - let row_scale = scales.iter().find(|s| s.aesthetic == "row"); + let row_scale = scales.iter().find(|s| s.aesthetic == "facet1"); let mut row_def = build_facet_field_def(facet_df, &row_aes_col, row_scale); let row_label_mapping = row_scale.and_then(|s| s.label_mapping.as_ref()); @@ -375,10 +377,11 @@ fn apply_faceting( facet_spec.insert("row".to_string(), row_def); } - // Column facet: use aesthetic column "column" - let col_aes_col = naming::aesthetic_column("column"); + // Column facet: use internal aesthetic column "facet2" + // Vega-Lite requires "column" key in the facet object + let col_aes_col = naming::aesthetic_column("facet2"); if facet_df.column(&col_aes_col).is_ok() { - let col_scale = scales.iter().find(|s| s.aesthetic == "column"); + let col_scale = scales.iter().find(|s| s.aesthetic == "facet2"); let mut col_def = build_facet_field_def(facet_df, &col_aes_col, col_scale); let col_label_mapping = col_scale.and_then(|s| s.label_mapping.as_ref()); @@ -1590,10 +1593,10 @@ mod tests { // Test that apply_facet_ordering uses input_range (FROM clause) for discrete scales use crate::plot::scale::Scale; - let mut facet_def = json!({"field": "__ggsql_aes_panel__", "type": "nominal"}); + let mut facet_def = json!({"field": "__ggsql_aes_facet1__", "type": "nominal"}); // Create a scale with input_range (simulating SCALE panel FROM ['A', 'B', 'C']) - let mut scale = Scale::new("panel"); + let mut scale = Scale::new("facet1"); scale.input_range = Some(vec![ ArrayElement::String("A".to_string()), ArrayElement::String("B".to_string()), @@ -1616,11 +1619,11 @@ mod tests { // This is the fix for the bug where null panels appear first use crate::plot::scale::Scale; - let mut facet_def = json!({"field": "__ggsql_aes_panel__", "type": "nominal"}); + let mut facet_def = json!({"field": "__ggsql_aes_facet1__", "type": "nominal"}); // Create a scale with input_range including null at the end // (simulating SCALE panel FROM ['Adelie', 'Gentoo', null]) - let mut scale = Scale::new("panel"); + let mut scale = Scale::new("facet1"); scale.input_range = Some(vec![ ArrayElement::String("Adelie".to_string()), ArrayElement::String("Gentoo".to_string()), @@ -1642,10 +1645,10 @@ mod tests { // Test that null at the beginning of input_range produces null first in sort use crate::plot::scale::Scale; - let mut facet_def = json!({"field": "__ggsql_aes_panel__", "type": "nominal"}); + let mut facet_def = json!({"field": "__ggsql_aes_facet1__", "type": "nominal"}); // Create a scale with null at the beginning - let mut scale = Scale::new("panel"); + let mut scale = Scale::new("facet1"); scale.input_range = Some(vec![ ArrayElement::Null, ArrayElement::String("Adelie".to_string()), @@ -1699,7 +1702,7 @@ mod tests { use crate::plot::{ParameterValue, ScaleType}; // Create a binned scale with breaks [0, 20, 40, 60] - let mut scale = Scale::new("panel"); + let mut scale = Scale::new("facet1"); scale.scale_type = Some(ScaleType::binned()); scale.properties.insert( "breaks".to_string(), @@ -1764,7 +1767,7 @@ mod tests { use crate::plot::scale::Scale; use crate::plot::{ParameterValue, ScaleType}; - let mut scale = Scale::new("panel"); + let mut scale = Scale::new("facet1"); scale.scale_type = Some(ScaleType::binned()); scale.properties.insert( "breaks".to_string(), @@ -1804,7 +1807,7 @@ mod tests { use crate::plot::scale::Scale; use crate::plot::{ParameterValue, ScaleType}; - let mut scale = Scale::new("panel"); + let mut scale = Scale::new("facet1"); scale.scale_type = Some(ScaleType::binned()); scale.properties.insert( "breaks".to_string(), @@ -1879,7 +1882,7 @@ mod tests { "x" => &[1, 2, 3], "y" => &[4, 5, 6], "category" => &["A", "A", "B"], - "__ggsql_aes_panel__" => &["A", "A", "B"], + "__ggsql_aes_facet1__" => &["A", "A", "B"], } .unwrap(); @@ -1962,7 +1965,7 @@ mod tests { "x" => &[1, 2, 3], "y" => &[4, 5, 6], "category" => &["A", "A", "B"], - "__ggsql_aes_panel__" => &["A", "A", "B"], + "__ggsql_aes_facet1__" => &["A", "A", "B"], } .unwrap(); @@ -2042,7 +2045,7 @@ mod tests { "x" => &[1, 2, 3], "y" => &[4, 5, 6], "category" => &["A", "A", "B"], - "__ggsql_aes_panel__" => &["A", "A", "B"], + "__ggsql_aes_facet1__" => &["A", "A", "B"], } .unwrap(); From 125c51f402d95f9426043cf59d2e5747982fb3e3 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Wed, 25 Feb 2026 12:48:50 +0100 Subject: [PATCH 09/28] smaller fixes plus implement start setting for polar --- CLAUDE.md | 73 ++++--- EXAMPLES.md | 18 +- doc/_quarto.yml | 3 + doc/examples.qmd | 37 +--- doc/examples_files/execute-results/html.json | 16 ++ ggsql-vscode/examples/sample.gsql | 6 +- src/execute/mod.rs | 6 +- src/parser/builder.rs | 199 ++++++++++++++++++- src/plot/aesthetic.rs | 54 ++--- src/plot/main.rs | 21 +- src/plot/projection/coord/polar.rs | 44 +++- src/plot/projection/types.rs | 13 ++ src/reader/mod.rs | 77 +++++++ src/writer/vegalite/mod.rs | 4 +- src/writer/vegalite/projection.rs | 65 ++++-- tree-sitter-ggsql/grammar.js | 27 ++- tree-sitter-ggsql/test/corpus/basic.txt | 162 +++++++++++++-- 17 files changed, 671 insertions(+), 154 deletions(-) create mode 100644 doc/examples_files/execute-results/html.json diff --git a/CLAUDE.md b/CLAUDE.md index 2047c67c..07ea0b0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1180,7 +1180,7 @@ Where `` can be: | `DRAW` | ✅ Yes | Define layers | `DRAW line MAPPING date AS x, value AS y` | | `SCALE` | ✅ Yes | Configure scales | `SCALE x VIA date` | | `FACET` | ❌ No | Small multiples | `FACET region` | -| `PROJECT` | ❌ No | Coordinate system | `PROJECT flip` | +| `PROJECT` | ❌ No | Coordinate system | `PROJECT TO cartesian` | | `LABEL` | ❌ No | Text labels | `LABEL title => 'My Chart', x => 'Date'` | | `THEME` | ❌ No | Visual styling | `THEME minimal` | @@ -1425,22 +1425,31 @@ FACET region BY category **Syntax**: ```sql --- With projectinate type -PROJECT [SETTING ] - --- With properties only (defaults to cartesian) -PROJECT SETTING +PROJECT [, ...] TO [SETTING ] ``` -**Projectinate Types**: +**Components**: + +- **Aesthetics** (optional): Comma-separated list of positional aesthetic names. If omitted, uses coord defaults. +- **TO**: Required keyword separating aesthetics from coord type. +- **coord_type**: Either `cartesian` or `polar`. +- **SETTING** (optional): Additional properties. + +**Coordinate Types**: + +| Coord Type | Default Aesthetics | Description | +|------------|-------------------|-------------| +| `cartesian` | `x`, `y` | Standard x/y Cartesian coordinates | +| `polar` | `theta`, `radius` | Polar coordinates (for pie charts, rose plots) | -- **`cartesian`** - Standard x/y Cartesian projectinates (default) -- **`flip`** - Flipped Cartesian (swaps x and y axes) -- **`polar`** - Polar projectinates (for pie charts, rose plots) -- **`fixed`** - Fixed aspect ratio -- **`trans`** - Transformed projectinates -- **`map`** - Map projections -- **`quickmap`** - Quick approximation for maps +**Flipping Axes**: + +To flip axes (for horizontal bar charts), swap the aesthetic names: + +```sql +-- Horizontal bar chart: swap x and y in PROJECT +PROJECT y, x TO cartesian +``` **Common Properties** (all projection types): @@ -1454,10 +1463,6 @@ PROJECT SETTING Note: For axis limits, use `SCALE x FROM [min, max]` or `SCALE y FROM [min, max]`. -**Flip**: - -- No additional properties - **Polar**: - `theta => ` - Which aesthetic maps to angle (defaults to `y`) @@ -1466,36 +1471,46 @@ Note: For axis limits, use `SCALE x FROM [min, max]` or `SCALE y FROM [min, max] 1. **Axis limits**: Use `SCALE x/y FROM [min, max]` to set axis limits 2. **Aesthetic domains**: Use `SCALE FROM [...]` to set aesthetic domains -3. **ggplot2 compatibility**: `project_flip` preserves axis label names (labels stay with aesthetic names, not visual position) +3. **Custom aesthetics**: User can define custom positional names (e.g., `PROJECT a, b TO cartesian`) 4. **Multi-layer support**: All projection transforms apply to all layers **Status**: - ✅ **Cartesian**: Fully implemented and tested -- ✅ **Flip**: Fully implemented and tested - ✅ **Polar**: Fully implemented and tested -- ❌ **Other types**: Not yet implemented **Examples**: ```sql --- Flip projection for horizontal bar chart -PROJECT flip +-- Default aesthetics (x, y for cartesian) +PROJECT TO cartesian + +-- Explicit aesthetics (same as defaults) +PROJECT x, y TO cartesian + +-- Flip projection for horizontal bar chart (swap x and y) +PROJECT y, x TO cartesian + +-- Custom aesthetic names +PROJECT myX, myY TO cartesian + +-- Polar for pie chart (using default theta/radius aesthetics) +PROJECT TO polar --- Polar for pie chart (theta defaults to y) -PROJECT polar +-- Polar with y/x aesthetics (y becomes theta, x becomes radius) +PROJECT y, x TO polar --- Polar with explicit theta mapping -PROJECT polar SETTING theta => x +-- Polar with start angle offset (3 o'clock position) +PROJECT y, x TO polar SETTING start => 90 -- Clip marks to plot area -PROJECT cartesian SETTING clip => true +PROJECT TO cartesian SETTING clip => true -- Combined with other clauses DRAW bar MAPPING category AS x, value AS y SCALE x FROM [0, 100] SCALE y FROM [0, 200] -PROJECT flip SETTING clip => true +PROJECT y, x TO cartesian SETTING clip => true LABEL x => 'Category', y => 'Count' ``` diff --git a/EXAMPLES.md b/EXAMPLES.md index c1aaf17d..de6b6f87 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -144,7 +144,7 @@ SELECT category, value FROM data ORDER BY value DESC VISUALISE category AS x, value AS y DRAW bar -PROJECT flip +PROJECT y, x TO cartesian ``` ### Polar Projection (Pie Chart) @@ -152,18 +152,18 @@ PROJECT flip ```sql SELECT category, SUM(value) as total FROM data GROUP BY category -VISUALISE category AS x, total AS y +VISUALISE total AS y, category AS fill DRAW bar -PROJECT polar +PROJECT y, x TO polar ``` -### Polar with Theta Specification +### Polar with Start Angle ```sql SELECT category, value FROM data -VISUALISE category AS x, value AS y +VISUALISE value AS y, category AS fill DRAW bar -PROJECT polar SETTING theta => y +PROJECT y, x TO polar SETTING start => 90 ``` --- @@ -308,7 +308,7 @@ regional_totals AS ( ) VISUALISE region AS x, total AS y, region AS fill FROM regional_totals DRAW bar -PROJECT flip +PROJECT y, x TO cartesian LABEL title => 'Total Revenue by Region', x => 'Region', y => 'Total Revenue ($)' @@ -375,7 +375,7 @@ SELECT * FROM ranked_products WHERE rank <= 5 VISUALISE product_name AS x, revenue AS y, category AS color DRAW bar FACET category SETTING free => 'x' -PROJECT flip +PROJECT y, x TO cartesian LABEL title => 'Top 5 Products per Category', x => 'Product', y => 'Revenue ($)' @@ -489,7 +489,7 @@ ORDER BY total_revenue DESC LIMIT 10 VISUALISE product_name AS x, total_revenue AS y, product_name AS fill DRAW bar -PROJECT flip +PROJECT y, x TO cartesian SCALE fill TO ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet', 'pink', 'brown', 'gray'] LABEL title => 'Top 10 Products by Revenue', diff --git a/doc/_quarto.yml b/doc/_quarto.yml index 2b4e8c92..a5801133 100644 --- a/doc/_quarto.yml +++ b/doc/_quarto.yml @@ -88,6 +88,9 @@ website: - section: Aesthetics contents: - auto: syntax/scale/aesthetic/* + - section: Coordinate systems + contents: + - auto: syntax/coord/* format: diff --git a/doc/examples.qmd b/doc/examples.qmd index 000896fb..1c1d3ffb 100644 --- a/doc/examples.qmd +++ b/doc/examples.qmd @@ -436,37 +436,6 @@ LABEL ## Projections -### Flipped Coordinates (Horizontal Bar Chart) - -```{ggsql} -SELECT region, SUM(revenue) as total -FROM 'sales.csv' -GROUP BY region -ORDER BY total DESC -VISUALISE region AS x, total AS y, region AS fill -DRAW bar -PROJECT flip -LABEL - title => 'Total Revenue by Region', - x => 'Region', - y => 'Total Revenue ($)' -``` - -### Cartesian with Axis Limits - -```{ggsql} -SELECT x, y FROM 'data.csv' -VISUALISE x, y -DRAW point - SETTING size => 4, color => 'blue' -PROJECT cartesian - SETTING xlim => [0, 60], ylim => [0, 70] -LABEL - title => 'Scatter Plot with Custom Axis Limits', - x => 'X', - y => 'Y' -``` - ### Pie Chart with Polar Coordinates ```{ggsql} @@ -475,8 +444,8 @@ FROM 'sales.csv' GROUP BY category VISUALISE total AS y, category AS fill DRAW bar -PROJECT polar -LABEL +PROJECT y, x TO polar +LABEL title => 'Revenue Distribution by Category' ``` @@ -656,7 +625,7 @@ regional_totals AS ( ) VISUALISE region AS x, total AS y, region AS fill FROM regional_totals DRAW bar -PROJECT flip +PROJECT y, x TO cartesian LABEL title => 'Total Revenue by Region', x => 'Region', diff --git a/doc/examples_files/execute-results/html.json b/doc/examples_files/execute-results/html.json new file mode 100644 index 00000000..23b610e9 --- /dev/null +++ b/doc/examples_files/execute-results/html.json @@ -0,0 +1,16 @@ +{ + "hash": "2d5afd753380715e5f581381e5858f0a", + "result": { + "engine": "jupyter", + "markdown": "---\ntitle: Examples\n---\n\nThis document demonstrates various ggsql features with runnable examples using CSV files.\n\n\n\n\n\n\n\n\n\n## Basic Visualizations\n\n### Simple Scatter Plot\n\n::: {#dc3ddd64 .cell execution_count=5}\n``` {.ggsql .cell-code}\nSELECT x, y FROM 'data.csv'\nVISUALISE x, y\nDRAW point\n```\n\n::: {.cell-output .cell-output-display execution_count=5}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n::: {#9c02433f .cell execution_count=6}\n``` {.ggsql .cell-code}\nVISUALISE bill_len AS x, bill_dep AS y, species AS color FROM ggsql:penguins\nDRAW point\n```\n\n::: {.cell-output .cell-output-display execution_count=6}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Line Chart with Date Scale\n\n::: {#e8ae49c3 .cell execution_count=7}\n``` {.ggsql .cell-code}\nSELECT sale_date, revenue FROM 'sales.csv'\nWHERE category = 'Electronics'\nVISUALISE sale_date AS x, revenue AS y\nDRAW line\nSCALE x VIA date\nLABEL \n title => 'Electronics Revenue Over Time', \n x => 'Date', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=7}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n::: {#a557d7df .cell execution_count=8}\n``` {.ggsql .cell-code}\nSELECT * FROM ggsql:airquality\nVISUALISE Date AS x\nDRAW line MAPPING Ozone AS y, 'Ozone' AS color\nDRAW line MAPPING Temp AS y, 'Temp' AS color\n```\n\n::: {.cell-output .cell-output-display execution_count=8}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Bar Chart by Category\n\n::: {#6d115eac .cell execution_count=9}\n``` {.ggsql .cell-code}\nSELECT category, SUM(revenue) as total\nFROM 'sales.csv'\nGROUP BY category\nVISUALISE category AS x, total AS y, category AS fill\nDRAW bar\nLABEL \n title => 'Total Revenue by Category', \n x => 'Category', \n y => 'Total Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=9}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Line chart with multiple lines with same aesthetics\n\n::: {#6879feb6 .cell execution_count=10}\n``` {.ggsql .cell-code}\nSELECT * FROM 'sales.csv'\nVISUALISE sale_date AS x, revenue AS y\nDRAW line\n PARTITION BY category\n```\n\n::: {.cell-output .cell-output-display execution_count=10}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Statistical Transformations\n\nStatistical transformations automatically compute aggregations for certain geom types.\n\n### Histogram\n\nWhen using `DRAW histogram`, ggsql automatically bins continuous data and counts occurrences. You only need to specify the x aesthetic:\n\n::: {#cfbc0caf .cell execution_count=11}\n``` {.ggsql .cell-code}\nSELECT revenue FROM 'sales.csv'\nVISUALISE revenue AS x\nDRAW histogram\nLABEL\n title => 'Revenue Distribution',\n x => 'Revenue ($)',\n y => 'Count'\n```\n\n::: {.cell-output .cell-output-display execution_count=11}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Bar with Automatic Count\n\nWhen using `DRAW bar` without a y aesthetic, ggsql automatically counts occurrences of each x value:\n\n::: {#4094c5ed .cell execution_count=12}\n``` {.ggsql .cell-code}\nSELECT category FROM 'sales.csv'\nVISUALISE category AS x\nDRAW bar\nLABEL\n title => 'Sales Count by Category',\n x => 'Category',\n y => 'Count'\n```\n\n::: {.cell-output .cell-output-display execution_count=12}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Bar with Weighted Count\n\nYou can also specify a weight aesthetic to sum values instead of counting:\n\n::: {#a7adf496 .cell execution_count=13}\n``` {.ggsql .cell-code}\nSELECT category, revenue FROM 'sales.csv'\nVISUALISE category AS x, revenue AS weight\nDRAW bar\nLABEL\n title => 'Total Revenue by Category',\n x => 'Category',\n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=13}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Histogram Settings\n\nControl histogram binning with `SETTING` options:\n\n**Custom number of bins:**\n\n::: {#d93c32c3 .cell execution_count=14}\n``` {.ggsql .cell-code}\nSELECT revenue FROM 'sales.csv'\nVISUALISE revenue AS x\nDRAW histogram \n SETTING bins => 10\nLABEL\n title => 'Revenue Distribution (10 bins)',\n x => 'Revenue ($)',\n y => 'Count'\n```\n\n::: {.cell-output .cell-output-display execution_count=14}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n**Explicit bin width** (overrides bins):\n\n::: {#48894ca8 .cell execution_count=15}\n``` {.ggsql .cell-code}\nSELECT revenue FROM 'sales.csv'\nVISUALISE revenue AS x\nDRAW histogram \n SETTING binwidth => 500\nLABEL\n title => 'Revenue Distribution (500 bin width)',\n x => 'Revenue ($)',\n y => 'Count'\n```\n\n::: {.cell-output .cell-output-display execution_count=15}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n**Left-closed intervals** (default is right-closed `(a, b]`):\n\n::: {#3fd2a40f .cell execution_count=16}\n``` {.ggsql .cell-code}\nSELECT revenue FROM 'sales.csv'\nVISUALISE revenue AS x\nDRAW histogram \n SETTING bins => 8, closed => 'left'\nLABEL\n title => 'Revenue Distribution (left-closed intervals)',\n x => 'Revenue ($)',\n y => 'Count'\n```\n\n::: {.cell-output .cell-output-display execution_count=16}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Histogram Remapping\n\nHistogram computes several statistics: `bin`, `bin_end`, `count`, and `density`. By default, `count` is mapped to `y`. Use `REMAPPING` to show density (proportion) instead:\n\n::: {#c46b448b .cell execution_count=17}\n``` {.ggsql .cell-code}\nSELECT revenue FROM 'sales.csv'\nVISUALISE revenue AS x\nDRAW histogram \n REMAPPING density AS y\nLABEL\n title => 'Revenue Density Distribution',\n x => 'Revenue ($)',\n y => 'Density'\n```\n\n::: {.cell-output .cell-output-display execution_count=17}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Bar Width Setting\n\nControl bar width with the `width` setting (0-1 scale, default 0.9):\n\n::: {#29d1dd7a .cell execution_count=18}\n``` {.ggsql .cell-code}\nSELECT category FROM 'sales.csv'\nVISUALISE category AS x\nDRAW bar \n SETTING width => 0.5\nLABEL\n title => 'Sales Count (Narrow Bars)',\n x => 'Category',\n y => 'Count'\n```\n\n::: {.cell-output .cell-output-display execution_count=18}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Bar Remapping\n\nBar computes `count` and `proportion` statistics. By default, `count` is mapped to `y`. Use `REMAPPING` to show proportions instead:\n\n::: {#ac33db33 .cell execution_count=19}\n``` {.ggsql .cell-code}\nSELECT category FROM 'sales.csv'\nVISUALISE category AS x\nDRAW bar \n REMAPPING proportion AS y\nLABEL\n title => 'Sales Proportion by Category',\n x => 'Category',\n y => 'Proportion'\n```\n\n::: {.cell-output .cell-output-display execution_count=19}\n```{=html}\n
\n\n```\n:::\n:::\n\n\nCombine with `weight` to show weighted proportions:\n\n::: {#e235be26 .cell execution_count=20}\n``` {.ggsql .cell-code}\nSELECT category, revenue FROM 'sales.csv'\nVISUALISE category AS x, revenue AS weight\nDRAW bar \n REMAPPING proportion AS y\nLABEL\n title => 'Revenue Share by Category',\n x => 'Category',\n y => 'Share of Total Revenue'\n```\n\n::: {.cell-output .cell-output-display execution_count=20}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Multiple Layers\n\n### Line with Points\n\n::: {#d0f97f81 .cell execution_count=21}\n``` {.ggsql .cell-code}\nSELECT date, value FROM 'timeseries.csv'\nVISUALISE date AS x, value AS y\nDRAW line \n SETTING color => 'blue'\nDRAW point \n SETTING size => 6, color => 'red'\nSCALE x VIA date\nLABEL \n title => 'Time Series with Points', \n x => 'Date', \n y => 'Value'\n```\n\n::: {.cell-output .cell-output-display execution_count=21}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Colored Lines by Category\n\n::: {#6bd7044c .cell execution_count=22}\n``` {.ggsql .cell-code}\nSELECT date, value, category FROM 'metrics.csv'\nVISUALISE date AS x, value AS y, category AS color\nDRAW line\nSCALE x VIA date\nLABEL \n title => 'Metrics by Category', \n x => 'Date', \n y => 'Value'\n```\n\n::: {.cell-output .cell-output-display execution_count=22}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Faceting\n\n### Facet by Region\n\n::: {#72b8a915 .cell execution_count=23}\n``` {.ggsql .cell-code}\nSELECT sale_date, revenue, region FROM 'sales.csv'\nWHERE category = 'Electronics'\nVISUALISE sale_date AS x, revenue AS y\nDRAW line\nSCALE x VIA date\nFACET region\nLABEL \n title => 'Electronics Sales by Region', \n x => 'Date', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=23}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Facet Grid\n\n::: {#4ef173d8 .cell execution_count=24}\n``` {.ggsql .cell-code}\nSELECT\n DATE_TRUNC('month', sale_date) as month,\n region,\n category,\n SUM(revenue) as total_revenue,\n SUM(quantity) * 100 as total_quantity_scaled\nFROM 'sales.csv'\nGROUP BY DATE_TRUNC('month', sale_date), region, category\nVISUALISE month AS x\nDRAW line \n MAPPING total_revenue AS y\n SETTING color => 'steelblue'\nDRAW point \n MAPPING total_revenue AS y\n SETTING size => 6, color => 'darkblue'\nDRAW line \n MAPPING total_quantity_scaled AS y\n SETTING color => 'coral'\nDRAW point \n MAPPING total_quantity_scaled AS y\n SETTING size => 6, color => 'orangered'\nSCALE x VIA date\nFACET region BY category\nLABEL \n title => 'Monthly Revenue and Quantity by Region and Category', \n x => 'Month', \n y => 'Value'\n```\n\n::: {.cell-output .cell-output-display execution_count=24}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Coordinate Transformations\n\n### Flipped Coordinates (Horizontal Bar Chart)\n\n::: {#b2ed1808 .cell execution_count=25}\n``` {.ggsql .cell-code}\nSELECT region, SUM(revenue) as total\nFROM 'sales.csv'\nGROUP BY region\nORDER BY total DESC\nVISUALISE region AS x, total AS y, region AS fill\nDRAW bar\nCOORD flip\nLABEL \n title => 'Total Revenue by Region', \n x => 'Region', \n y => 'Total Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=25}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Cartesian with Axis Limits\n\n::: {#d61101b0 .cell execution_count=26}\n``` {.ggsql .cell-code}\nSELECT x, y FROM 'data.csv'\nVISUALISE x, y\nDRAW point \n SETTING size => 4, color => 'blue'\nCOORD cartesian \n SETTING xlim => [0, 60], ylim => [0, 70]\nLABEL \n title => 'Scatter Plot with Custom Axis Limits', \n x => 'X', \n y => 'Y'\n```\n\n::: {.cell-output .cell-output-display execution_count=26}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Pie Chart with Polar Coordinates\n\n::: {#9ebf05d9 .cell execution_count=27}\n``` {.ggsql .cell-code}\nSELECT category, SUM(revenue) as total\nFROM 'sales.csv'\nGROUP BY category\nVISUALISE total AS y, category AS fill\nDRAW bar\nCOORD polar\nLABEL \n title => 'Revenue Distribution by Category'\n```\n\n::: {.cell-output .cell-output-display execution_count=27}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Constant Mappings\n\nConstants can be used in both the VISUALISE clause (global) and MAPPING clauses (per-layer) to set fixed aesthetic values.\n\n### Different Constants Per Layer\n\nEach layer can have its own constant value, creating a legend showing all values:\n\n::: {#fdbd18a1 .cell execution_count=28}\n``` {.ggsql .cell-code}\nWITH monthly AS (\n SELECT\n DATE_TRUNC('month', sale_date) as month,\n category,\n SUM(revenue) as revenue\n FROM 'sales.csv'\n GROUP BY DATE_TRUNC('month', sale_date), category\n)\nVISUALISE month AS x, revenue AS y\nDRAW line \n MAPPING 'Electronics' AS color FROM monthly \n FILTER category = 'Electronics'\nDRAW line \n MAPPING 'Clothing' AS color FROM monthly \n FILTER category = 'Clothing'\nDRAW line \n MAPPING 'Furniture' AS color FROM monthly \n FILTER category = 'Furniture'\nSCALE x VIA date\nLABEL \n title => 'Revenue by Category (Constant Colors)', \n x => 'Month', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=28}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Mixed Constants and Columns\n\nWhen mixing constant and column mappings for the same aesthetic, the axis/legend label uses the first non-constant column name:\n\n::: {#7babd600 .cell execution_count=29}\n``` {.ggsql .cell-code}\nSELECT date, value, category FROM 'metrics.csv'\nVISUALISE date AS x\nDRAW line \n MAPPING value AS y, category AS color\nDRAW point \n MAPPING 120 AS y \n SETTING size => 3, color => 'blue'\nSCALE x VIA date\nLABEL \n title => 'Metrics with Threshold Line', \n x => 'Date'\n```\n\n::: {.cell-output .cell-output-display execution_count=29}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Numeric Constants\n\nNumbers work as constants too:\n\n::: {#79bb020f .cell execution_count=30}\n``` {.ggsql .cell-code}\nSELECT x, y FROM 'data.csv'\nVISUALISE x, y\nDRAW point \n SETTING color => 'blue', size => 10\nDRAW point \n SETTING color => 'red', size => 5 \n FILTER y > 50\nLABEL \n title => 'Scatter Plot with Constant Sizes'\n```\n\n::: {.cell-output .cell-output-display execution_count=30}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Layer filtering\n\n### Filter one layer\n\n::: {#2045bb2b .cell execution_count=31}\n``` {.ggsql .cell-code}\nSELECT date, value FROM 'timeseries.csv'\nVISUALISE date AS x, value AS y\nDRAW line \n SETTING color => 'blue'\nDRAW point \n SETTING color => 'red', size => 6\n FILTER value < 130\nSCALE x VIA date\nLABEL \n title => 'Time Series with Points', \n x => 'Date', \n y => 'Value'\n```\n\n::: {.cell-output .cell-output-display execution_count=31}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Layer ordering\n\n### ORDER BY in a layer\n\nUse `ORDER BY` to ensure data is sorted correctly within a layer. This is especially important for line charts where the order of points affects the visual path:\n\n::: {#96352c0a .cell execution_count=32}\n``` {.ggsql .cell-code}\nWITH unordered_data AS (\n SELECT * FROM (VALUES\n (150.0, '2023-03-01'::DATE),\n (100.0, '2023-01-01'::DATE),\n (120.0, '2023-05-01'::DATE),\n (200.0, '2023-02-01'::DATE),\n (180.0, '2023-04-01'::DATE)\n ) AS t(value, date)\n)\nVISUALISE\nDRAW path \n MAPPING date AS x, value AS y FROM unordered_data \n ORDER BY date\nDRAW point \n MAPPING date AS x, value AS y FROM unordered_data \n SETTING size => 6, color => 'red'\nSCALE x VIA date\nLABEL \n title => 'Line Chart with ORDER BY', \n x => 'Date', \n y => 'Value'\n```\n\n::: {.cell-output .cell-output-display execution_count=32}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Combining FILTER and ORDER BY\n\nThe `ORDER BY` clause can be combined with `FILTER` and other layer options:\n\n::: {#8a48523a .cell execution_count=33}\n``` {.ggsql .cell-code}\nSELECT date, value, category FROM 'metrics.csv'\nVISUALISE\nDRAW path \n MAPPING date AS x, value AS y, category AS color \n FILTER category != 'Support' \n ORDER BY value\nDRAW point \n MAPPING date AS x, value AS y, category AS color \n SETTING size => 3\n FILTER category != 'Support' \nSCALE x VIA date\nLABEL \n title => 'Sales and Marketing Metrics (Ordered)', \n x => 'Date', \n y => 'Value'\n```\n\n::: {.cell-output .cell-output-display execution_count=33}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Common Table Expressions (CTEs)\n\n### Simple CTE with VISUALISE FROM\n\n::: {#d1d20794 .cell execution_count=34}\n``` {.ggsql .cell-code}\nWITH monthly_sales AS (\n SELECT\n DATE_TRUNC('month', sale_date) as month,\n SUM(revenue) as total_revenue\n FROM 'sales.csv'\n GROUP BY DATE_TRUNC('month', sale_date)\n)\nVISUALISE month AS x, total_revenue AS y FROM monthly_sales\nDRAW line\nDRAW point\nSCALE x VIA date\nLABEL \n title => 'Monthly Revenue Trends', \n x => 'Month', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=34}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Multiple CTEs\n\n::: {#8322cb49 .cell execution_count=35}\n``` {.ggsql .cell-code}\nWITH daily_sales AS (\n SELECT sale_date, region, SUM(revenue) as revenue\n FROM 'sales.csv'\n GROUP BY sale_date, region\n),\nregional_totals AS (\n SELECT region, SUM(revenue) as total\n FROM daily_sales\n GROUP BY region\n)\nVISUALISE region AS x, total AS y, region AS fill FROM regional_totals\nDRAW bar\nCOORD flip\nLABEL \n title => 'Total Revenue by Region', \n x => 'Region', \n y => 'Total Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=35}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Layer-Specific Data Sources (MAPPING FROM)\n\nLayers can pull data from different sources using `MAPPING FROM`. This enables overlaying data from different CTEs or tables.\n\n### Comparing Actuals vs Targets\n\nEach layer can reference a different CTE using `MAPPING ... FROM cte_name`:\n\n::: {#b9e15886 .cell execution_count=36}\n``` {.ggsql .cell-code}\nWITH actuals AS (\n SELECT\n DATE_TRUNC('month', sale_date) as month,\n SUM(revenue) as value\n FROM 'sales.csv'\n GROUP BY DATE_TRUNC('month', sale_date)\n),\ntargets AS (\n SELECT * FROM (VALUES\n ('2023-01-01'::DATE, 5000.0),\n ('2023-02-01'::DATE, 5500.0),\n ('2023-03-01'::DATE, 6000.0),\n ('2023-04-01'::DATE, 6500.0),\n ('2023-05-01'::DATE, 7000.0),\n ('2023-06-01'::DATE, 7500.0),\n ('2023-07-01'::DATE, 8000.0),\n ('2023-08-01'::DATE, 8500.0),\n ('2023-09-01'::DATE, 9000.0),\n ('2023-10-01'::DATE, 9500.0),\n ('2023-11-01'::DATE, 10000.0),\n ('2023-12-01'::DATE, 10500.0)\n ) AS t(month, value)\n)\nVISUALISE\nDRAW line \n MAPPING month AS x, value AS y, 'Actual' AS color FROM actuals\nDRAW point \n MAPPING month AS x, value AS y, 'Actual' AS color FROM actuals \n SETTING size => 6\nDRAW line \n MAPPING month AS x, value AS y, 'Target' AS color FROM targets\nSCALE x VIA date\nLABEL \n title => 'Revenue: Actual vs Target', \n x => 'Month', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=36}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### CTE Chain: Raw → Filtered → Aggregated\n\nCTEs can reference other CTEs, creating a data transformation pipeline:\n\n::: {#dc5c62ab .cell execution_count=37}\n``` {.ggsql .cell-code}\nWITH raw_data AS (\n SELECT sale_date, revenue, category, region\n FROM 'sales.csv'\n),\nelectronics_only AS (\n SELECT * FROM raw_data\n WHERE category = 'Electronics'\n),\nmonthly_electronics AS (\n SELECT\n DATE_TRUNC('month', sale_date) as month,\n region,\n SUM(revenue) as total\n FROM electronics_only\n GROUP BY DATE_TRUNC('month', sale_date), region\n)\nVISUALISE month AS x, total AS y, region AS color FROM monthly_electronics\nDRAW line\nDRAW point\nSCALE x VIA date\nLABEL \n title => 'Electronics Revenue by Region (CTE Chain)', \n x => 'Month', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=37}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Layer FROM with FILTER\n\nCombine `FROM` with `FILTER` to get filtered subsets from a CTE:\n\n::: {#9eeec490 .cell execution_count=38}\n``` {.ggsql .cell-code}\nWITH all_sales AS (\n SELECT\n DATE_TRUNC('month', sale_date) as month,\n category,\n SUM(revenue) as revenue\n FROM 'sales.csv'\n GROUP BY DATE_TRUNC('month', sale_date), category\n)\nVISUALISE\nDRAW line \n MAPPING month AS x, revenue AS y, 'All Categories' AS color FROM all_sales\nDRAW line \n MAPPING month AS x, revenue AS y, 'Electronics' AS color FROM all_sales \n FILTER category = 'Electronics'\nDRAW line \n MAPPING month AS x, revenue AS y, 'Clothing' AS color FROM all_sales \n FILTER category = 'Clothing'\nSCALE x VIA date\nLABEL \n title => 'Revenue by Category (Filtered Layers)', \n x => 'Month', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=38}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Advanced Examples\n\n### Complete Regional Sales Analysis\n\n::: {#1c1e23b5 .cell execution_count=39}\n``` {.ggsql .cell-code}\nSELECT\n sale_date,\n region,\n SUM(quantity) as total_quantity\nFROM 'sales.csv'\nWHERE sale_date >= '2023-01-01'\nGROUP BY sale_date, region\nORDER BY sale_date\nVISUALISE sale_date AS x, total_quantity AS y, region AS color\nDRAW line\nDRAW point\nSCALE x VIA date\nFACET region\nLABEL\n title => 'Sales Trends by Region', \n x => 'Date', \n y => 'Total Quantity'\n```\n\n::: {.cell-output .cell-output-display execution_count=39}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Multi-Category Analysis\n\n::: {#311c7f73 .cell execution_count=40}\n``` {.ggsql .cell-code}\nSELECT\n category,\n region,\n SUM(revenue) as total_revenue\nFROM 'sales.csv'\nGROUP BY category, region\nVISUALISE category AS x, total_revenue AS y, region AS fill\nDRAW bar\nLABEL \n title => 'Revenue by Category and Region', \n x => 'Category', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=40}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n", + "supporting": [ + "examples_files/figure-html" + ], + "filters": [], + "includes": { + "include-in-header": [ + "\n\n\n" + ] + } + } +} \ No newline at end of file diff --git a/ggsql-vscode/examples/sample.gsql b/ggsql-vscode/examples/sample.gsql index 5c0e5adf..859f3e03 100644 --- a/ggsql-vscode/examples/sample.gsql +++ b/ggsql-vscode/examples/sample.gsql @@ -47,7 +47,7 @@ ORDER BY total DESC LIMIT 10 VISUALISE category AS x, total AS y, category AS fill DRAW bar -PROJECT flip +PROJECT y, x TO cartesian LABEL title => 'Top 10 Product Categories', x => 'Category', y => 'Total Sales' @@ -61,7 +61,7 @@ FROM customers GROUP BY region VISUALISE region AS x, count AS y, region AS fill DRAW bar -PROJECT polar SETTING theta => y +PROJECT TO polar LABEL title => 'Customer Distribution by Region' -- ============================================================================ @@ -212,7 +212,7 @@ LABEL title => 'Sales by Region' -- Second visualization: by category VISUALISE category AS x, total AS y, category AS fill FROM sales_summary DRAW bar -PROJECT flip +PROJECT y, x TO cartesian LABEL title => 'Sales by Category' -- ============================================================================ diff --git a/src/execute/mod.rs b/src/execute/mod.rs index a30a7967..64ef8cc3 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -1157,8 +1157,10 @@ pub fn prepare_data_with_reader(query: &str, reader: &R) -> Result = + let positional_names: Vec = spec.get_aesthetic_context().user_positional().to_vec(); + // Convert to &str slice for resolve_facet_properties + let positional_refs: Vec<&str> = positional_names.iter().map(|s| s.as_str()).collect(); if let Some(ref mut facet) = spec.facet { // Get the first layer's data for computing facet defaults @@ -1174,7 +1176,7 @@ pub fn prepare_data_with_reader(query: &str, reader: &R) -> Result Result> { // ============================================================================ /// Build a Projection from a project_clause node +/// +/// Parses the new PROJECT syntax: +/// ```text +/// PROJECT [aesthetic, ...] TO coord_type [SETTING prop => value, ...] +/// ``` +/// +/// Aesthetics are optional and default to the coord's standard names. fn build_project(node: &Node, source: &SourceTree) -> Result { let mut coord = Coord::cartesian(); let mut properties = HashMap::new(); + let mut user_aesthetics: Option> = None; let mut cursor = node.walk(); for child in node.children(&mut cursor) { match child.kind() { - "PROJECT" | "SETTING" | "=>" | "," => continue, + "PROJECT" | "SETTING" | "TO" | "=>" | "," => continue, + "project_aesthetics" => { + user_aesthetics = Some(parse_project_aesthetics(&child, source)?); + } "project_type" => { coord = parse_coord(&child, source)?; } @@ -923,10 +934,71 @@ fn build_project(node: &Node, source: &SourceTree) -> Result { } } + // Resolve aesthetics: use provided or fall back to coord defaults + let aesthetics = if let Some(aes) = user_aesthetics { + // Validate aesthetic count matches coord requirements + let expected = coord.positional_aesthetic_names().len(); + if aes.len() != expected { + return Err(GgsqlError::ParseError(format!( + "PROJECT {} requires {} aesthetics, got {}", + coord.name(), + expected, + aes.len() + ))); + } + + // Validate no conflicts with non-positional or facet aesthetics + validate_positional_aesthetic_names(&aes)?; + + aes + } else { + // Use coord defaults - resolved immediately at build time + coord + .positional_aesthetic_names() + .iter() + .map(|s| s.to_string()) + .collect() + }; + // Validate properties for this coord type validate_project_properties(&coord, &properties)?; - Ok(Projection { coord, properties }) + Ok(Projection { + coord, + aesthetics, + properties, + }) +} + +/// Parse project aesthetics from a project_aesthetics node +fn parse_project_aesthetics(node: &Node, source: &SourceTree) -> Result> { + let query = "(identifier) @aes"; + Ok(source.find_texts(node, query)) +} + +/// Validate that positional aesthetic names don't conflict with reserved names +fn validate_positional_aesthetic_names(names: &[String]) -> Result<()> { + use crate::plot::aesthetic::{NON_POSITIONAL, USER_FACET_AESTHETICS}; + + for name in names { + // Check against non-positional aesthetics + if NON_POSITIONAL.contains(&name.as_str()) { + return Err(GgsqlError::ParseError(format!( + "PROJECT aesthetic '{}' conflicts with non-positional aesthetic. \ + Choose a different name.", + name + ))); + } + // Check against facet aesthetics + if USER_FACET_AESTHETICS.contains(&name.as_str()) { + return Err(GgsqlError::ParseError(format!( + "PROJECT aesthetic '{}' conflicts with facet aesthetic. \ + Choose a different name.", + name + ))); + } + } + Ok(()) } /// Parse a single project_property node into (name, value) @@ -1146,7 +1218,7 @@ mod tests { let query = r#" VISUALISE DRAW point MAPPING x AS x, y AS y - PROJECT cartesian SETTING theta => y + PROJECT TO cartesian SETTING theta => y "#; let result = parse_test_query(query); @@ -1162,7 +1234,7 @@ mod tests { let query = r#" VISUALISE DRAW bar MAPPING category AS x, value AS y - PROJECT polar SETTING theta => y + PROJECT TO polar SETTING theta => y "#; let result = parse_test_query(query); @@ -1174,6 +1246,123 @@ mod tests { assert!(project.properties.contains_key("theta")); } + #[test] + fn test_project_polar_with_start() { + let query = r#" + VISUALISE + DRAW bar MAPPING category AS x, value AS y + PROJECT TO polar SETTING start => 90 + "#; + + let result = parse_test_query(query); + assert!(result.is_ok()); + let specs = result.unwrap(); + + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.coord.coord_kind(), CoordKind::Polar); + assert!(project.properties.contains_key("start")); + assert_eq!( + project.properties.get("start"), + Some(&ParameterValue::Number(90.0)) + ); + } + + #[test] + fn test_project_explicit_aesthetics() { + let query = r#" + VISUALISE + DRAW point MAPPING x AS x, y AS y + PROJECT x, y TO cartesian + "#; + + let result = parse_test_query(query); + assert!(result.is_ok()); + let specs = result.unwrap(); + + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.coord.coord_kind(), CoordKind::Cartesian); + assert_eq!(project.aesthetics, vec!["x".to_string(), "y".to_string()]); + } + + #[test] + fn test_project_custom_aesthetics() { + // Use identifiers as custom positional aesthetics in PROJECT + // Note: Custom aesthetics in PROJECT don't need to match grammar's aesthetic_name + // since project_aesthetics uses identifier nodes, not aesthetic_name + let query = r#" + VISUALISE + DRAW point MAPPING x AS x, y AS y + PROJECT myX, myY TO cartesian + "#; + + let result = parse_test_query(query); + assert!(result.is_ok()); + let specs = result.unwrap(); + + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.aesthetics, vec!["myX".to_string(), "myY".to_string()]); + } + + #[test] + fn test_project_default_aesthetics_cartesian() { + let query = r#" + VISUALISE + DRAW point MAPPING x AS x, y AS y + PROJECT TO cartesian + "#; + + let result = parse_test_query(query); + assert!(result.is_ok()); + let specs = result.unwrap(); + + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.aesthetics, vec!["x".to_string(), "y".to_string()]); + } + + #[test] + fn test_project_default_aesthetics_polar() { + let query = r#" + VISUALISE + DRAW bar MAPPING category AS theta, value AS radius + PROJECT TO polar + "#; + + let result = parse_test_query(query); + assert!(result.is_ok()); + let specs = result.unwrap(); + + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.aesthetics, vec!["theta".to_string(), "radius".to_string()]); + } + + #[test] + fn test_project_wrong_aesthetic_count() { + let query = r#" + VISUALISE + DRAW point MAPPING x AS x + PROJECT x TO cartesian + "#; + + let result = parse_test_query(query); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("requires 2 aesthetics, got 1")); + } + + #[test] + fn test_project_conflicting_aesthetic_name() { + let query = r#" + VISUALISE + DRAW point MAPPING x AS x, y AS y + PROJECT color, fill TO cartesian + "#; + + let result = parse_test_query(query); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("conflicts with non-positional aesthetic")); + } + // ======================================== // Case Insensitive Keywords Tests // ======================================== @@ -1183,7 +1372,7 @@ mod tests { let query = r#" visualise draw point MAPPING x AS x, y AS y - project cartesian + project to cartesian label title => 'Test Chart' "#; diff --git a/src/plot/aesthetic.rs b/src/plot/aesthetic.rs index 32a2e3f3..8bf5e1f2 100644 --- a/src/plot/aesthetic.rs +++ b/src/plot/aesthetic.rs @@ -92,30 +92,30 @@ pub const NON_POSITIONAL: &[&str] = &[ /// use ggsql::plot::AestheticContext; /// /// // For cartesian coords -/// let ctx = AestheticContext::new(&["x", "y"], &[]); +/// let ctx = AestheticContext::from_static(&["x", "y"], &[]); /// assert_eq!(ctx.map_user_to_internal("x"), Some("pos1")); /// assert_eq!(ctx.map_user_to_internal("ymin"), Some("pos2min")); /// assert_eq!(ctx.map_internal_to_user("pos1"), Some("x")); /// /// // For polar coords -/// let ctx = AestheticContext::new(&["theta", "radius"], &[]); +/// let ctx = AestheticContext::from_static(&["theta", "radius"], &[]); /// assert_eq!(ctx.map_user_to_internal("theta"), Some("pos1")); /// assert_eq!(ctx.map_user_to_internal("radius"), Some("pos2")); /// assert_eq!(ctx.map_internal_to_user("pos1"), Some("theta")); /// /// // With facets -/// let ctx = AestheticContext::new(&["x", "y"], &["panel"]); +/// let ctx = AestheticContext::from_static(&["x", "y"], &["panel"]); /// assert_eq!(ctx.map_user_to_internal("panel"), Some("facet1")); /// assert_eq!(ctx.map_internal_to_user("facet1"), Some("panel")); /// -/// let ctx = AestheticContext::new(&["x", "y"], &["row", "column"]); +/// let ctx = AestheticContext::from_static(&["x", "y"], &["row", "column"]); /// assert_eq!(ctx.map_user_to_internal("row"), Some("facet1")); /// assert_eq!(ctx.map_user_to_internal("column"), Some("facet2")); /// ``` #[derive(Debug, Clone)] pub struct AestheticContext { - /// User-facing positional names: ["x", "y"] or ["theta", "radius"] - user_positional: Vec<&'static str>, + /// User-facing positional names: ["x", "y"] or ["theta", "radius"] or custom names + user_positional: Vec, /// All user positional (with suffixes): ["x", "xmin", "xmax", "xend", "y", ...] all_user_positional: Vec, /// Primary internal positional: ["pos1", "pos2", ...] @@ -137,10 +137,10 @@ impl AestheticContext { /// /// # Arguments /// - /// * `positional_names` - Primary positional aesthetic names from coord (e.g., ["x", "y"]) + /// * `positional_names` - Primary positional aesthetic names (e.g., ["x", "y"] or custom names) /// * `facet_names` - User-facing facet aesthetic names from facet layout /// (e.g., ["panel"] for wrap, ["row", "column"] for grid) - pub fn new(positional_names: &[&'static str], facet_names: &[&'static str]) -> Self { + pub fn new(positional_names: &[String], facet_names: &[&'static str]) -> Self { // Build positional mappings let mut all_user = Vec::new(); let mut primary_internal = Vec::new(); @@ -152,7 +152,7 @@ impl AestheticContext { primary_internal.push(internal_base.clone()); // Add primary first (e.g., "x", "pos1") - all_user.push((*primary_name).to_string()); + all_user.push(primary_name.clone()); all_internal.push(internal_base.clone()); // Then add suffixed variants (e.g., "xmin", "pos1min") @@ -185,6 +185,14 @@ impl AestheticContext { } } + /// Create context from static positional names and facet names. + /// + /// Convenience method for creating context from static string slices (e.g., from coord defaults). + pub fn from_static(positional_names: &[&'static str], facet_names: &[&'static str]) -> Self { + let owned_positional: Vec = positional_names.iter().map(|s| s.to_string()).collect(); + Self::new(&owned_positional, facet_names) + } + // === Mapping: User → Internal === /// Map user aesthetic (positional or facet) to internal name. @@ -376,8 +384,8 @@ impl AestheticContext { &self.primary_internal } - /// Get user positional aesthetics (x, y or theta, radius) - pub fn user_positional(&self) -> &[&'static str] { + /// Get user positional aesthetics (x, y or theta, radius or custom names) + pub fn user_positional(&self) -> &[String] { &self.user_positional } @@ -779,7 +787,7 @@ mod tests { #[test] fn test_aesthetic_context_cartesian() { - let ctx = AestheticContext::new(&["x", "y"], &[]); + let ctx = AestheticContext::from_static(&["x", "y"], &[]); // User positional names assert_eq!(ctx.user_positional(), &["x", "y"]); @@ -804,7 +812,7 @@ mod tests { #[test] fn test_aesthetic_context_polar() { - let ctx = AestheticContext::new(&["theta", "radius"], &[]); + let ctx = AestheticContext::from_static(&["theta", "radius"], &[]); // User positional names assert_eq!(ctx.user_positional(), &["theta", "radius"]); @@ -825,7 +833,7 @@ mod tests { #[test] fn test_aesthetic_context_user_to_internal() { - let ctx = AestheticContext::new(&["x", "y"], &[]); + let ctx = AestheticContext::from_static(&["x", "y"], &[]); // Primary aesthetics assert_eq!(ctx.map_user_to_internal("x"), Some("pos1")); @@ -846,7 +854,7 @@ mod tests { #[test] fn test_aesthetic_context_internal_to_user() { - let ctx = AestheticContext::new(&["x", "y"], &[]); + let ctx = AestheticContext::from_static(&["x", "y"], &[]); // Primary aesthetics assert_eq!(ctx.map_internal_to_user("pos1"), Some("x")); @@ -867,7 +875,7 @@ mod tests { #[test] fn test_aesthetic_context_polar_mapping() { - let ctx = AestheticContext::new(&["theta", "radius"], &[]); + let ctx = AestheticContext::from_static(&["theta", "radius"], &[]); // User to internal assert_eq!(ctx.map_user_to_internal("theta"), Some("pos1")); @@ -884,7 +892,7 @@ mod tests { #[test] fn test_aesthetic_context_is_checks() { - let ctx = AestheticContext::new(&["x", "y"], &[]); + let ctx = AestheticContext::from_static(&["x", "y"], &[]); // User positional assert!(ctx.is_user_positional("x")); @@ -912,7 +920,7 @@ mod tests { #[test] fn test_aesthetic_context_with_facets() { - let ctx = AestheticContext::new(&["x", "y"], &["panel"]); + let ctx = AestheticContext::from_static(&["x", "y"], &["panel"]); // Check user facet assert!(ctx.is_user_facet("panel")); @@ -934,7 +942,7 @@ mod tests { #[test] fn test_aesthetic_context_with_grid_facets() { - let ctx = AestheticContext::new(&["x", "y"], &["row", "column"]); + let ctx = AestheticContext::from_static(&["x", "y"], &["row", "column"]); // Check user facet assert!(ctx.is_user_facet("row")); @@ -955,7 +963,7 @@ mod tests { #[test] fn test_aesthetic_context_families() { - let ctx = AestheticContext::new(&["x", "y"], &[]); + let ctx = AestheticContext::from_static(&["x", "y"], &[]); // Get internal family let pos1_family = ctx.get_internal_family("pos1").unwrap(); @@ -981,7 +989,7 @@ mod tests { #[test] fn test_aesthetic_context_user_family_resolution() { // Cartesian: user-facing families are x/y based - let cartesian = AestheticContext::new(&["x", "y"], &[]); + let cartesian = AestheticContext::from_static(&["x", "y"], &[]); assert_eq!(cartesian.primary_user_aesthetic("x"), Some("x")); assert_eq!(cartesian.primary_user_aesthetic("xmin"), Some("x")); assert_eq!(cartesian.primary_user_aesthetic("xmax"), Some("x")); @@ -993,7 +1001,7 @@ mod tests { assert_eq!(cartesian.primary_user_aesthetic("color"), Some("color")); // Polar: user-facing families are theta/radius based - let polar = AestheticContext::new(&["theta", "radius"], &[]); + let polar = AestheticContext::from_static(&["theta", "radius"], &[]); assert_eq!(polar.primary_user_aesthetic("theta"), Some("theta")); assert_eq!(polar.primary_user_aesthetic("thetamin"), Some("theta")); assert_eq!(polar.primary_user_aesthetic("thetamax"), Some("theta")); @@ -1011,7 +1019,7 @@ mod tests { #[test] fn test_aesthetic_context_polar_user_families() { // Verify polar coords have correct user families - let ctx = AestheticContext::new(&["theta", "radius"], &[]); + let ctx = AestheticContext::from_static(&["theta", "radius"], &[]); // Get user family for theta let theta_family = ctx.get_user_family("theta").unwrap(); diff --git a/src/plot/main.rs b/src/plot/main.rs index 178560be..8124d579 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -140,12 +140,15 @@ impl Plot { if let Some(ref ctx) = self.aesthetic_context { ctx.clone() } else { - // Create default based on coord (Cartesian if no project specified) - let positional_names = self + // Create default based on project (use aesthetics if set, else defaults) + // If no project clause, use default cartesian names ["x", "y"] + let default_positional: Vec = + vec!["x".to_string(), "y".to_string()]; + let positional_names: &[String] = self .project .as_ref() - .map(|p| p.coord.positional_aesthetic_names()) - .unwrap_or(&["x", "y"]); + .map(|p| p.aesthetics.as_slice()) + .unwrap_or(&default_positional); let facet_names: &[&'static str] = self .facet .as_ref() @@ -157,11 +160,15 @@ impl Plot { /// Set the aesthetic context based on the current coord and facet pub fn initialize_aesthetic_context(&mut self) { - let positional_names = self + // Get positional names from project (already resolved at build time) + // If no project clause, use default ["x", "y"] + let default_positional: Vec = + vec!["x".to_string(), "y".to_string()]; + let positional_names: &[String] = self .project .as_ref() - .map(|p| p.coord.positional_aesthetic_names()) - .unwrap_or(&["x", "y"]); + .map(|p| p.aesthetics.as_slice()) + .unwrap_or(&default_positional); let facet_names: &[&'static str] = self .facet .as_ref() diff --git a/src/plot/projection/coord/polar.rs b/src/plot/projection/coord/polar.rs index 8c56b20b..51c27d1e 100644 --- a/src/plot/projection/coord/polar.rs +++ b/src/plot/projection/coord/polar.rs @@ -21,12 +21,13 @@ impl CoordTrait for Polar { } fn allowed_properties(&self) -> &'static [&'static str] { - &["theta", "clip"] + &["theta", "clip", "start"] } fn get_property_default(&self, name: &str) -> Option { match name { "theta" => Some(ParameterValue::String("y".to_string())), + "start" => Some(ParameterValue::Number(0.0)), // 0 degrees = 12 o'clock _ => None, } } @@ -56,7 +57,8 @@ mod tests { let allowed = polar.allowed_properties(); assert!(allowed.contains(&"theta")); assert!(allowed.contains(&"clip")); - assert_eq!(allowed.len(), 2); + assert!(allowed.contains(&"start")); + assert_eq!(allowed.len(), 3); } #[test] @@ -67,6 +69,14 @@ mod tests { assert_eq!(default.unwrap(), ParameterValue::String("y".to_string())); } + #[test] + fn test_polar_start_default() { + let polar = Polar; + let default = polar.get_property_default("start"); + assert!(default.is_some()); + assert_eq!(default.unwrap(), ParameterValue::Number(0.0)); + } + #[test] fn test_polar_resolve_adds_theta_default() { let polar = Polar; @@ -109,4 +119,34 @@ mod tests { assert!(err.contains("unknown")); assert!(err.contains("not valid")); } + + #[test] + fn test_polar_resolve_with_explicit_start() { + let polar = Polar; + let mut props = HashMap::new(); + props.insert("start".to_string(), ParameterValue::Number(90.0)); + + let resolved = polar.resolve_properties(&props); + assert!(resolved.is_ok()); + let resolved = resolved.unwrap(); + assert_eq!( + resolved.get("start").unwrap(), + &ParameterValue::Number(90.0) + ); + } + + #[test] + fn test_polar_resolve_adds_start_default() { + let polar = Polar; + let props = HashMap::new(); + + let resolved = polar.resolve_properties(&props); + assert!(resolved.is_ok()); + let resolved = resolved.unwrap(); + assert!(resolved.contains_key("start")); + assert_eq!( + resolved.get("start").unwrap(), + &ParameterValue::Number(0.0) + ); + } } diff --git a/src/plot/projection/types.rs b/src/plot/projection/types.rs index 65340e2c..53644de0 100644 --- a/src/plot/projection/types.rs +++ b/src/plot/projection/types.rs @@ -13,6 +13,19 @@ use crate::plot::ParameterValue; pub struct Projection { /// Coordinate system type pub coord: Coord, + /// Positional aesthetic names (resolved: explicit or coord defaults) + /// Always populated after building - never empty. + /// e.g., ["x", "y"] for cartesian, ["theta", "radius"] for polar, + /// or custom names like ["a", "b"] if user specifies them. + pub aesthetics: Vec, /// Projection-specific options pub properties: HashMap, } + +impl Projection { + /// Get the positional aesthetic names as string slices. + /// (aesthetics are always resolved at build time) + pub fn positional_names(&self) -> Vec<&str> { + self.aesthetics.iter().map(|s| s.as_str()).collect() + } +} diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 2a151ef3..45aabbea 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -383,6 +383,83 @@ mod tests { assert!(result.contains("layer")); } + #[test] + fn test_polar_project_with_start() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20), ('C', 30)) AS t(category, value) + VISUALISE value AS y, category AS fill + DRAW bar + PROJECT y, x TO polar SETTING start => 90 + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + // Parse the JSON to verify the theta scale range is set correctly + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // The encoding should have a theta channel with a scale range offset by 90 degrees + // 90 degrees = π/2 radians + let layer = json["layer"].as_array().unwrap().first().unwrap(); + let theta = &layer["encoding"]["theta"]; + assert!(theta.is_object(), "theta encoding should exist"); + + // Check that the scale has a range with the start offset + let scale = &theta["scale"]; + let range = scale["range"].as_array().unwrap(); + assert_eq!(range.len(), 2); + + // π/2 ≈ 1.5707963 + let start = range[0].as_f64().unwrap(); + assert!( + (start - std::f64::consts::FRAC_PI_2).abs() < 0.001, + "start should be π/2 (90 degrees), got {}", + start + ); + + // π/2 + 2π ≈ 7.8539816 + let end = range[1].as_f64().unwrap(); + let expected_end = std::f64::consts::FRAC_PI_2 + 2.0 * std::f64::consts::PI; + assert!( + (end - expected_end).abs() < 0.001, + "end should be π/2 + 2π, got {}", + end + ); + } + + #[test] + fn test_polar_project_default_start() { + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + let query = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20), ('C', 30)) AS t(category, value) + VISUALISE value AS y, category AS fill + DRAW bar + PROJECT y, x TO polar + "#; + + let spec = reader.execute(query).unwrap(); + let writer = VegaLiteWriter::new(); + let result = writer.render(&spec).unwrap(); + + // Parse the JSON + let json: serde_json::Value = serde_json::from_str(&result).unwrap(); + + // The theta encoding should NOT have a scale with range when start is 0 (default) + let layer = json["layer"].as_array().unwrap().first().unwrap(); + let theta = &layer["encoding"]["theta"]; + assert!(theta.is_object(), "theta encoding should exist"); + + // Either no scale, or no range in scale (since default is 0) + if let Some(scale) = theta.get("scale") { + assert!( + scale.get("range").is_none(), + "theta scale should not have range when start is 0" + ); + } + } + #[test] fn test_register_and_query() { use polars::prelude::*; diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 45fbc7b1..a210f5bc 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -1115,7 +1115,7 @@ mod tests { use crate::plot::AestheticContext; // Test with cartesian context - let ctx = AestheticContext::new(&["x", "y"], &[]); + let ctx = AestheticContext::from_static(&["x", "y"], &[]); // Internal positional names should map to user-facing names assert_eq!(map_aesthetic_name("pos1", &ctx), "x"); @@ -1143,7 +1143,7 @@ mod tests { assert_eq!(map_aesthetic_name("label", &ctx), "text"); // Test with polar context - let polar_ctx = AestheticContext::new(&["theta", "radius"], &[]); + let polar_ctx = AestheticContext::from_static(&["theta", "radius"], &[]); assert_eq!(map_aesthetic_name("pos1", &polar_ctx), "theta"); assert_eq!(map_aesthetic_name("pos2", &polar_ctx), "radius"); assert_eq!(map_aesthetic_name("pos1end", &polar_ctx), "theta2"); diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 159f3a70..4c395603 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -85,19 +85,33 @@ fn apply_polar_project( }) .unwrap_or_else(|| "y".to_string()); + // Get start angle in degrees (defaults to 0 = 12 o'clock) + let start_degrees = project + .properties + .get("start") + .and_then(|v| match v { + ParameterValue::Number(n) => Some(*n), + _ => None, + }) + .unwrap_or(0.0); + + // Convert degrees to radians for Vega-Lite + let start_radians = start_degrees * std::f64::consts::PI / 180.0; + // Convert geoms to polar equivalents - convert_geoms_to_polar(spec, vl_spec, &theta_field)?; + convert_geoms_to_polar(spec, vl_spec, &theta_field, start_radians)?; // No DataFrame transformation needed - Vega-Lite handles polar math Ok(data.clone()) } /// Convert geoms to polar equivalents (bar->arc, point->arc with radius) -fn convert_geoms_to_polar(spec: &Plot, vl_spec: &mut Value, theta_field: &str) -> Result<()> { - // Determine which aesthetic (x or y) maps to theta - // Default: y maps to theta (pie chart style) - let theta_aesthetic = theta_field; - +fn convert_geoms_to_polar( + spec: &Plot, + vl_spec: &mut Value, + theta_field: &str, + start_radians: f64, +) -> Result<()> { if let Some(layers) = vl_spec.get_mut("layer") { if let Some(layers_arr) = layers.as_array_mut() { for layer in layers_arr { @@ -105,7 +119,7 @@ fn convert_geoms_to_polar(spec: &Plot, vl_spec: &mut Value, theta_field: &str) - *mark = convert_mark_to_polar(mark, spec)?; if let Some(encoding) = layer.get_mut("encoding") { - update_encoding_for_polar(encoding, theta_aesthetic)?; + update_encoding_for_polar(encoding, theta_field, start_radians)?; } } } @@ -154,14 +168,22 @@ fn convert_mark_to_polar(mark: &Value, _spec: &Plot) -> Result { } /// Update encoding channels for polar projection -fn update_encoding_for_polar(encoding: &mut Value, theta_aesthetic: &str) -> Result<()> { +/// +/// Uses theta_field to determine which aesthetic maps to theta: +/// - If theta_field is "y" (default): y → theta, x → color (standard pie chart) +/// - If theta_field is "x": x → theta, y → radius +fn update_encoding_for_polar( + encoding: &mut Value, + theta_field: &str, + start_radians: f64, +) -> Result<()> { let enc_obj = encoding .as_object_mut() .ok_or_else(|| GgsqlError::WriterError("Encoding is not an object".to_string()))?; - // Map the theta aesthetic to theta channel - if theta_aesthetic == "y" { - // Standard pie chart: y -> theta, x -> color/category + // Map the theta field to theta channel based on theta property + if theta_field == "y" { + // Standard pie chart: y → theta, x → color/category if let Some(y_enc) = enc_obj.remove("y") { enc_obj.insert("theta".to_string(), y_enc); } @@ -174,8 +196,8 @@ fn update_encoding_for_polar(encoding: &mut Value, theta_aesthetic: &str) -> Res // If color is already mapped, just remove x from positional encoding enc_obj.remove("x"); } - } else if theta_aesthetic == "x" { - // Reversed: x -> theta, y -> radius + } else if theta_field == "x" { + // Reversed: x → theta, y → radius if let Some(x_enc) = enc_obj.remove("x") { enc_obj.insert("theta".to_string(), x_enc); } @@ -184,6 +206,23 @@ fn update_encoding_for_polar(encoding: &mut Value, theta_aesthetic: &str) -> Res } } + // Apply start angle offset to theta encoding if non-zero + if start_radians.abs() > f64::EPSILON { + if let Some(theta_enc) = enc_obj.get_mut("theta") { + if let Some(theta_obj) = theta_enc.as_object_mut() { + // Set the scale range to offset by the start angle + // Vega-Lite theta scale default is [0, 2π], we offset it + let end_radians = start_radians + 2.0 * std::f64::consts::PI; + theta_obj.insert( + "scale".to_string(), + json!({ + "range": [start_radians, end_radians] + }), + ); + } + } + } + Ok(()) } diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index a3fcbe10..43f5b5f9 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -754,15 +754,26 @@ module.exports = grammar({ repeat(seq(',', $.identifier)) ), - // PROJECT clause - PROJECT [type] [SETTING prop => value, ...] + // PROJECT clause - PROJECT [aesthetics] TO coord_type [SETTING prop => value, ...] + // Examples: + // PROJECT TO cartesian (defaults to x, y) + // PROJECT x, y TO cartesian (explicit aesthetics) + // PROJECT a, b TO cartesian (custom aesthetic names) + // PROJECT TO polar (defaults to theta, radius) + // PROJECT theta, radius TO polar (explicit aesthetics) + // PROJECT TO cartesian SETTING clip => true project_clause: $ => seq( caseInsensitive('PROJECT'), - choice( - // Type with optional SETTING: PROJECT polar SETTING theta => y - seq($.project_type, optional(seq(caseInsensitive('SETTING'), $.project_properties))), - // Just SETTING: PROJECT SETTING xlim => [0, 100] (defaults to cartesian) - seq(caseInsensitive('SETTING'), $.project_properties) - ) + optional($.project_aesthetics), + caseInsensitive('TO'), + $.project_type, + optional(seq(caseInsensitive('SETTING'), $.project_properties)) + ), + + // Optional list of positional aesthetic names for PROJECT clause + project_aesthetics: $ => seq( + $.identifier, + repeat(seq(',', $.identifier)) ), project_type: $ => choice( @@ -781,7 +792,7 @@ module.exports = grammar({ ), project_property_name: $ => choice( - 'ratio', 'theta', 'clip' + 'ratio', 'theta', 'clip', 'start' ), // LABEL clause (repeatable) diff --git a/tree-sitter-ggsql/test/corpus/basic.txt b/tree-sitter-ggsql/test/corpus/basic.txt index 1ef39a67..6f277258 100644 --- a/tree-sitter-ggsql/test/corpus/basic.txt +++ b/tree-sitter-ggsql/test/corpus/basic.txt @@ -531,12 +531,77 @@ LABEL title => 'My Plot', x => 'X Axis', y => 'Y Axis' (string)))))) ================================================================================ -PROJECT cartesian with ratio +PROJECT TO cartesian (default aesthetics) ================================================================================ VISUALISE x, y DRAW point -PROJECT cartesian SETTING ratio => 1 +PROJECT TO cartesian + +-------------------------------------------------------------------------------- + +(query + (visualise_statement + (visualise_keyword) + (global_mapping + (mapping_list + (mapping_element + (implicit_mapping + (identifier + (bare_identifier)))) + (mapping_element + (implicit_mapping + (identifier + (bare_identifier)))))) + (viz_clause + (draw_clause + (geom_type))) + (viz_clause + (project_clause + (project_type))))) + +================================================================================ +PROJECT x, y TO cartesian (explicit aesthetics) +================================================================================ + +VISUALISE x, y +DRAW point +PROJECT x, y TO cartesian + +-------------------------------------------------------------------------------- + +(query + (visualise_statement + (visualise_keyword) + (global_mapping + (mapping_list + (mapping_element + (implicit_mapping + (identifier + (bare_identifier)))) + (mapping_element + (implicit_mapping + (identifier + (bare_identifier)))))) + (viz_clause + (draw_clause + (geom_type))) + (viz_clause + (project_clause + (project_aesthetics + (identifier + (bare_identifier)) + (identifier + (bare_identifier))) + (project_type))))) + +================================================================================ +PROJECT TO cartesian with SETTING +================================================================================ + +VISUALISE x, y +DRAW point +PROJECT TO cartesian SETTING ratio => 1 -------------------------------------------------------------------------------- @@ -565,12 +630,42 @@ PROJECT cartesian SETTING ratio => 1 (number))))))) ================================================================================ -PROJECT flip +PROJECT TO polar (default aesthetics) +================================================================================ + +VISUALISE theta, radius +DRAW point +PROJECT TO polar + +-------------------------------------------------------------------------------- + +(query + (visualise_statement + (visualise_keyword) + (global_mapping + (mapping_list + (mapping_element + (implicit_mapping + (identifier + (bare_identifier)))) + (mapping_element + (implicit_mapping + (identifier + (bare_identifier)))))) + (viz_clause + (draw_clause + (geom_type))) + (viz_clause + (project_clause + (project_type))))) + +================================================================================ +PROJECT TO polar with SETTING start ================================================================================ -VISUALISE category AS x, value AS y +VISUALISE theta, radius DRAW bar -PROJECT flip +PROJECT TO polar SETTING start => 90 -------------------------------------------------------------------------------- @@ -580,24 +675,57 @@ PROJECT flip (global_mapping (mapping_list (mapping_element - (explicit_mapping - value: (mapping_value - (column_reference - (identifier - (bare_identifier)))) - name: (aesthetic_name))) + (implicit_mapping + (identifier + (bare_identifier)))) (mapping_element - (explicit_mapping - value: (mapping_value - (column_reference - (identifier - (bare_identifier)))) - name: (aesthetic_name))))) + (implicit_mapping + (identifier + (bare_identifier)))))) (viz_clause (draw_clause (geom_type))) (viz_clause (project_clause + (project_type) + (project_properties + (project_property + (project_property_name) + (number))))))) + +================================================================================ +PROJECT custom aesthetics TO cartesian +================================================================================ + +VISUALISE a, b +DRAW point +PROJECT a, b TO cartesian + +-------------------------------------------------------------------------------- + +(query + (visualise_statement + (visualise_keyword) + (global_mapping + (mapping_list + (mapping_element + (implicit_mapping + (identifier + (bare_identifier)))) + (mapping_element + (implicit_mapping + (identifier + (bare_identifier)))))) + (viz_clause + (draw_clause + (geom_type))) + (viz_clause + (project_clause + (project_aesthetics + (identifier + (bare_identifier)) + (identifier + (bare_identifier))) (project_type))))) ================================================================================ From 41f42782342623c7b0968e0b3e84087f00571ad4 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Wed, 25 Feb 2026 20:52:40 +0100 Subject: [PATCH 10/28] fix custom positional aesthetics --- src/reader/mod.rs | 115 ++++++++++++++++++++++++++++++ src/writer/vegalite/encoding.rs | 78 +++++++++++++------- src/writer/vegalite/mod.rs | 83 ++++++++++++--------- src/writer/vegalite/projection.rs | 101 ++++++++++---------------- tree-sitter-ggsql/grammar.js | 6 +- 5 files changed, 259 insertions(+), 124 deletions(-) diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 45aabbea..11144bd7 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -460,6 +460,121 @@ mod tests { } } + #[test] + fn test_polar_encoding_keys_independent_of_user_names() { + // This test verifies that polar projections always produce theta/radius encoding keys + // in Vega-Lite output, regardless of what positional names the user specified in PROJECT. + // This is critical because Vega-Lite expects specific channel names for polar marks. + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + + // Helper to check encoding keys + fn check_encoding_keys(json: &serde_json::Value, test_name: &str) { + let layer = json["layer"].as_array().unwrap().first().unwrap(); + assert!( + layer["encoding"].get("theta").is_some(), + "{} should produce theta encoding, got keys: {:?}", + test_name, + layer["encoding"].as_object().map(|o| o.keys().collect::>()) + ); + // Also verify no x or y keys exist (they should be mapped to theta/radius) + assert!( + layer["encoding"].get("x").is_none(), + "{} should NOT have x encoding in polar mode", + test_name + ); + assert!( + layer["encoding"].get("y").is_none(), + "{} should NOT have y encoding in polar mode", + test_name + ); + } + + // Test case 1: PROJECT y, x TO polar (y as pos1→theta, x as pos2→radius) + let query1 = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE value AS y, category AS fill + DRAW bar + PROJECT y, x TO polar + "#; + let spec1 = reader.execute(query1).unwrap(); + let writer = VegaLiteWriter::new(); + let result1 = writer.render(&spec1).unwrap(); + let json1: serde_json::Value = serde_json::from_str(&result1).unwrap(); + check_encoding_keys(&json1, "PROJECT y, x TO polar"); + + // Test case 2: PROJECT x, y TO polar (x as pos1→theta, y as pos2→radius) + let query2 = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE value AS x, category AS fill + DRAW bar + PROJECT x, y TO polar + "#; + let spec2 = reader.execute(query2).unwrap(); + let result2 = writer.render(&spec2).unwrap(); + let json2: serde_json::Value = serde_json::from_str(&result2).unwrap(); + check_encoding_keys(&json2, "PROJECT x, y TO polar"); + + // Test case 3: PROJECT TO polar (default theta/radius names) + let query3 = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE value AS theta, category AS fill + DRAW bar + PROJECT TO polar + "#; + let spec3 = reader.execute(query3).unwrap(); + let result3 = writer.render(&spec3).unwrap(); + let json3: serde_json::Value = serde_json::from_str(&result3).unwrap(); + check_encoding_keys(&json3, "PROJECT TO polar"); + + // Test case 4: PROJECT a, b TO polar (custom aesthetic names) + let query4 = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE value AS a, category AS fill + DRAW bar + PROJECT a, b TO polar + "#; + let spec4 = reader.execute(query4).unwrap(); + let result4 = writer.render(&spec4).unwrap(); + let json4: serde_json::Value = serde_json::from_str(&result4).unwrap(); + check_encoding_keys(&json4, "PROJECT a, b TO polar (custom names)"); + } + + #[test] + fn test_cartesian_encoding_keys_with_custom_names() { + // This test verifies that cartesian projections produce x/y encoding keys + // even when custom positional names are used in PROJECT. + let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); + + fn check_cartesian_keys(json: &serde_json::Value, test_name: &str) { + let layer = json["layer"].as_array().unwrap().first().unwrap(); + assert!( + layer["encoding"].get("x").is_some(), + "{} should produce x encoding, got keys: {:?}", + test_name, + layer["encoding"].as_object().map(|o| o.keys().collect::>()) + ); + // Verify no theta/radius keys exist + assert!( + layer["encoding"].get("theta").is_none(), + "{} should NOT have theta encoding in cartesian mode", + test_name + ); + } + + // Test case: PROJECT a, b TO cartesian (custom aesthetic names) + let query = r#" + SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value) + VISUALISE category AS a, value AS b + DRAW bar + PROJECT a, b TO cartesian + "#; + 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(); + check_cartesian_keys(&json, "PROJECT a, b TO cartesian (custom names)"); + } + #[test] fn test_register_and_query() { use polars::prelude::*; diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 67536236..0fadaa54 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -5,7 +5,7 @@ use crate::plot::aesthetic::is_positional_aesthetic; use crate::plot::scale::{linetype_to_stroke_dash, shape_to_svg_path, ScaleTypeKind}; -use crate::plot::ParameterValue; +use crate::plot::{CoordKind, ParameterValue}; use crate::{is_primary_positional, primary_aesthetic, AestheticValue, DataFrame, Plot, Result}; use polars::prelude::*; use serde_json::{json, Value}; @@ -896,34 +896,28 @@ fn build_literal_encoding(aesthetic: &str, lit: &ParameterValue) -> Result x2). +/// For internal positional aesthetics (pos1, pos2, etc.), maps directly to Vega-Lite +/// channel names based on coord type: +/// - Cartesian: pos1 → "x", pos2 → "y" +/// - Polar: pos1 → "theta", pos2 → "radius" +/// +/// This ensures correct Vega-Lite channel names regardless of what the user originally +/// called their positional aesthetics in the PROJECT clause. +/// +/// For non-positional aesthetics, applies Vega-Lite specific mappings (e.g., linetype → strokeDash). pub(super) fn map_aesthetic_name( aesthetic: &str, - ctx: &crate::plot::AestheticContext, + _ctx: &crate::plot::AestheticContext, + coord_kind: CoordKind, ) -> String { - // First, transform internal positional to user-facing using context - let user_name = ctx - .map_internal_to_user(aesthetic) - .map(|s| s.to_string()) - .unwrap_or_else(|| aesthetic.to_string()); - - // Then apply Vega-Lite specific mappings - match user_name.as_str() { - // Position end aesthetics (ggplot2 style -> Vega-Lite style) - // Handle both cartesian (xend/yend) and polar (thetaend/radiusend) - name if name.ends_with("end") => { - // Convert xend -> x2, yend -> y2, thetaend -> theta2, etc. - let base = &name[..name.len() - 3]; - format!("{}2", base) - } - // Position intercept aesthetics for reference lines - name if name.ends_with("intercept") => { - // Keep as-is for now (Vega-Lite uses xintercept, yintercept style) - // Actually in Vega-Lite we need special handling for reference lines - name.to_string() - } + // For internal positional aesthetics, map directly to Vega-Lite channel names + // based on coord type (ignoring user-facing names) + if let Some(vl_channel) = map_positional_to_vegalite(aesthetic, coord_kind) { + return vl_channel; + } + + // Non-positional aesthetics: apply Vega-Lite specific mappings + match aesthetic { // Line aesthetics "linetype" => "strokeDash".to_string(), "linewidth" => "strokeWidth".to_string(), @@ -931,7 +925,37 @@ pub(super) fn map_aesthetic_name( "label" => "text".to_string(), // All other aesthetics pass through directly // (fill and stroke map to Vega-Lite's separate fill/stroke channels) - _ => user_name, + _ => aesthetic.to_string(), + } +} + +/// Map internal positional aesthetic to Vega-Lite channel name based on coord type. +/// +/// Returns `Some(channel_name)` for internal positional aesthetics (pos1, pos2, etc.), +/// or `None` for non-positional aesthetics. +fn map_positional_to_vegalite(aesthetic: &str, coord_kind: CoordKind) -> Option { + let (primary, secondary) = match coord_kind { + CoordKind::Cartesian => ("x", "y"), + CoordKind::Polar => ("theta", "radius"), + }; + + // Match internal positional aesthetic patterns + match aesthetic { + // Primary positional + "pos1" => Some(primary.to_string()), + "pos2" => Some(secondary.to_string()), + // End variants (Vega-Lite uses x2/y2/theta2/radius2) + "pos1end" => Some(format!("{}2", primary)), + "pos2end" => Some(format!("{}2", secondary)), + // Min/Max variants + "pos1min" => Some(format!("{}min", primary)), + "pos1max" => Some(format!("{}max", primary)), + "pos2min" => Some(format!("{}min", secondary)), + "pos2max" => Some(format!("{}max", secondary)), + // Intercept variants (for reference lines) + "pos1intercept" => Some(format!("{}intercept", primary)), + "pos2intercept" => Some(format!("{}intercept", secondary)), + _ => None, } } diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index a210f5bc..c5906c92 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -26,7 +26,7 @@ mod layer; mod projection; use crate::plot::ArrayElement; -use crate::plot::{ParameterValue, Scale, ScaleTypeKind}; +use crate::plot::{CoordKind, ParameterValue, Scale, ScaleTypeKind}; use crate::writer::Writer; use crate::{ naming, primary_aesthetic, AestheticValue, DataFrame, GgsqlError, Plot, Result, @@ -141,6 +141,9 @@ fn prepare_layer_data( /// /// The `free_x` and `free_y` flags indicate whether facet free scales are enabled. /// When true, explicit domains should not be set for that axis. +/// +/// The `coord_kind` determines how internal positional aesthetics are mapped to +/// Vega-Lite encoding channel names. fn build_layers( spec: &Plot, data: &HashMap, @@ -149,6 +152,7 @@ fn build_layers( prepared_data: &[PreparedData], free_x: bool, free_y: bool, + coord_kind: CoordKind, ) -> Result> { let mut layers = Vec::new(); @@ -181,8 +185,8 @@ fn build_layers( // Set transform array on layer spec layer_spec["transform"] = json!(transforms); - // Build encoding for this layer (pass free scale flags) - let encoding = build_layer_encoding(layer, df, spec, free_x, free_y)?; + // Build encoding for this layer (pass free scale flags and coord kind) + let encoding = build_layer_encoding(layer, df, spec, free_x, free_y, coord_kind)?; layer_spec["encoding"] = Value::Object(encoding); // Apply geom-specific spec modifications via renderer @@ -208,12 +212,16 @@ fn build_layers( /// /// The `free_x` and `free_y` flags indicate whether facet free scales are enabled. /// When true, explicit domains should not be set for that axis. +/// +/// The `coord_kind` determines how internal positional aesthetics (pos1, pos2) are +/// mapped to Vega-Lite encoding channel names (x/y for cartesian, theta/radius for polar). fn build_layer_encoding( layer: &crate::plot::Layer, df: &DataFrame, spec: &Plot, free_x: bool, free_y: bool, + coord_kind: CoordKind, ) -> Result> { let mut encoding = serde_json::Map::new(); @@ -253,7 +261,7 @@ fn build_layer_encoding( continue; } - let channel_name = map_aesthetic_name(aesthetic, &aesthetic_ctx); + let channel_name = map_aesthetic_name(aesthetic, &aesthetic_ctx, coord_kind); let channel_encoding = build_encoding_channel(aesthetic, value, &mut enc_ctx)?; encoding.insert(channel_name, channel_encoding); @@ -263,7 +271,7 @@ fn build_layer_encoding( if let AestheticValue::Column { name: col, .. } = value { let end_col = naming::bin_end_column(col); let end_aesthetic = format!("{}end", aesthetic); // "pos1end" or "pos2end" - let end_channel = map_aesthetic_name(&end_aesthetic, &aesthetic_ctx); // maps to "x2" or "y2" + let end_channel = map_aesthetic_name(&end_aesthetic, &aesthetic_ctx, coord_kind); // maps to "x2" or "y2" (or theta2/radius2 for polar) encoding.insert(end_channel, json!({"field": end_col})); } } @@ -275,7 +283,7 @@ fn build_layer_encoding( let supported_aesthetics = layer.geom.aesthetics().supported; for (param_name, param_value) in &layer.parameters { if supported_aesthetics.contains(¶m_name.as_str()) { - let channel_name = map_aesthetic_name(param_name, &aesthetic_ctx); + let channel_name = map_aesthetic_name(param_name, &aesthetic_ctx, coord_kind); // Only add if not already set by MAPPING (MAPPING takes precedence) if !encoding.contains_key(&channel_name) { // Convert size and linewidth from points to Vega-Lite units @@ -996,7 +1004,14 @@ impl Writer for VegaLiteWriter { let unified_data = unify_datasets(&prep.datasets)?; vl_spec["data"] = json!({"values": unified_data}); - // 9. Build layers (pass free scale flags for domain handling) + // 9. Get coord kind (default to Cartesian if no project) + let coord_kind = spec + .project + .as_ref() + .map(|p| p.coord.coord_kind()) + .unwrap_or(CoordKind::Cartesian); + + // 10. Build layers (pass free scale flags and coord kind for domain handling) let layers = build_layers( spec, data, @@ -1005,6 +1020,7 @@ impl Writer for VegaLiteWriter { &prep.prepared, free_x, free_y, + coord_kind, )?; vl_spec["layer"] = json!(layers); @@ -1114,40 +1130,41 @@ mod tests { fn test_aesthetic_name_mapping() { use crate::plot::AestheticContext; - // Test with cartesian context + // Test with cartesian coord kind let ctx = AestheticContext::from_static(&["x", "y"], &[]); - // Internal positional names should map to user-facing names - assert_eq!(map_aesthetic_name("pos1", &ctx), "x"); - assert_eq!(map_aesthetic_name("pos2", &ctx), "y"); - assert_eq!(map_aesthetic_name("pos1end", &ctx), "x2"); - assert_eq!(map_aesthetic_name("pos2end", &ctx), "y2"); - - // User-facing names should pass through unchanged - assert_eq!(map_aesthetic_name("x", &ctx), "x"); - assert_eq!(map_aesthetic_name("y", &ctx), "y"); - assert_eq!(map_aesthetic_name("xend", &ctx), "x2"); - assert_eq!(map_aesthetic_name("yend", &ctx), "y2"); + // Internal positional names should map to Vega-Lite channel names based on coord kind + assert_eq!(map_aesthetic_name("pos1", &ctx, CoordKind::Cartesian), "x"); + assert_eq!(map_aesthetic_name("pos2", &ctx, CoordKind::Cartesian), "y"); + assert_eq!(map_aesthetic_name("pos1end", &ctx, CoordKind::Cartesian), "x2"); + assert_eq!(map_aesthetic_name("pos2end", &ctx, CoordKind::Cartesian), "y2"); // Non-positional aesthetics pass through directly - assert_eq!(map_aesthetic_name("color", &ctx), "color"); - assert_eq!(map_aesthetic_name("fill", &ctx), "fill"); - assert_eq!(map_aesthetic_name("stroke", &ctx), "stroke"); - assert_eq!(map_aesthetic_name("opacity", &ctx), "opacity"); - assert_eq!(map_aesthetic_name("size", &ctx), "size"); - assert_eq!(map_aesthetic_name("shape", &ctx), "shape"); + assert_eq!(map_aesthetic_name("color", &ctx, CoordKind::Cartesian), "color"); + assert_eq!(map_aesthetic_name("fill", &ctx, CoordKind::Cartesian), "fill"); + assert_eq!(map_aesthetic_name("stroke", &ctx, CoordKind::Cartesian), "stroke"); + assert_eq!(map_aesthetic_name("opacity", &ctx, CoordKind::Cartesian), "opacity"); + assert_eq!(map_aesthetic_name("size", &ctx, CoordKind::Cartesian), "size"); + assert_eq!(map_aesthetic_name("shape", &ctx, CoordKind::Cartesian), "shape"); // Other mapped aesthetics - assert_eq!(map_aesthetic_name("linetype", &ctx), "strokeDash"); - assert_eq!(map_aesthetic_name("linewidth", &ctx), "strokeWidth"); - assert_eq!(map_aesthetic_name("label", &ctx), "text"); + assert_eq!(map_aesthetic_name("linetype", &ctx, CoordKind::Cartesian), "strokeDash"); + assert_eq!(map_aesthetic_name("linewidth", &ctx, CoordKind::Cartesian), "strokeWidth"); + assert_eq!(map_aesthetic_name("label", &ctx, CoordKind::Cartesian), "text"); - // Test with polar context + // Test with polar coord kind - internal positional maps to theta/radius + // regardless of the context's user-facing names let polar_ctx = AestheticContext::from_static(&["theta", "radius"], &[]); - assert_eq!(map_aesthetic_name("pos1", &polar_ctx), "theta"); - assert_eq!(map_aesthetic_name("pos2", &polar_ctx), "radius"); - assert_eq!(map_aesthetic_name("pos1end", &polar_ctx), "theta2"); - assert_eq!(map_aesthetic_name("pos2end", &polar_ctx), "radius2"); + assert_eq!(map_aesthetic_name("pos1", &polar_ctx, CoordKind::Polar), "theta"); + assert_eq!(map_aesthetic_name("pos2", &polar_ctx, CoordKind::Polar), "radius"); + assert_eq!(map_aesthetic_name("pos1end", &polar_ctx, CoordKind::Polar), "theta2"); + assert_eq!(map_aesthetic_name("pos2end", &polar_ctx, CoordKind::Polar), "radius2"); + + // Even with custom positional names (e.g., PROJECT y, x TO polar), + // internal pos1/pos2 should still map to theta/radius for Vega-Lite + let custom_ctx = AestheticContext::from_static(&["y", "x"], &[]); + assert_eq!(map_aesthetic_name("pos1", &custom_ctx, CoordKind::Polar), "theta"); + assert_eq!(map_aesthetic_name("pos2", &custom_ctx, CoordKind::Polar), "radius"); } #[test] diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 4c395603..78f713ae 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -69,22 +69,17 @@ fn apply_cartesian_project( } /// Apply Polar projection transformation (bar->arc, point->arc with radius) +/// +/// Encoding channel names (theta/radius) are already set correctly by `map_aesthetic_name()` +/// based on coord kind. This function only: +/// 1. Converts mark types to polar equivalents (bar → arc) +/// 2. Applies start angle offset from PROJECT clause fn apply_polar_project( project: &Projection, spec: &Plot, data: &DataFrame, vl_spec: &mut Value, ) -> Result { - // Get theta field (defaults to 'y') - let theta_field = project - .properties - .get("theta") - .and_then(|v| match v { - ParameterValue::String(s) => Some(s.clone()), - _ => None, - }) - .unwrap_or_else(|| "y".to_string()); - // Get start angle in degrees (defaults to 0 = 12 o'clock) let start_degrees = project .properties @@ -98,18 +93,23 @@ fn apply_polar_project( // Convert degrees to radians for Vega-Lite let start_radians = start_degrees * std::f64::consts::PI / 180.0; - // Convert geoms to polar equivalents - convert_geoms_to_polar(spec, vl_spec, &theta_field, start_radians)?; + // Convert geoms to polar equivalents and apply start angle offset + convert_geoms_to_polar(spec, vl_spec, "", start_radians)?; // No DataFrame transformation needed - Vega-Lite handles polar math Ok(data.clone()) } -/// Convert geoms to polar equivalents (bar->arc, point->arc with radius) +/// Convert geoms to polar equivalents (bar->arc) and apply start angle offset +/// +/// Note: Encoding channel names (theta/radius) are already set correctly by +/// `map_aesthetic_name()` based on coord kind. This function only: +/// 1. Converts mark types to polar equivalents (bar → arc) +/// 2. Applies start angle offset from PROJECT clause fn convert_geoms_to_polar( spec: &Plot, vl_spec: &mut Value, - theta_field: &str, + _theta_field: &str, start_radians: f64, ) -> Result<()> { if let Some(layers) = vl_spec.get_mut("layer") { @@ -118,8 +118,9 @@ fn convert_geoms_to_polar( if let Some(mark) = layer.get_mut("mark") { *mark = convert_mark_to_polar(mark, spec)?; + // Apply start angle offset if present if let Some(encoding) = layer.get_mut("encoding") { - update_encoding_for_polar(encoding, theta_field, start_radians)?; + apply_polar_start_angle(encoding, start_radians)?; } } } @@ -167,59 +168,33 @@ fn convert_mark_to_polar(mark: &Value, _spec: &Plot) -> Result { Ok(json!(polar_mark)) } -/// Update encoding channels for polar projection +/// Apply start angle offset to theta encoding for polar projection /// -/// Uses theta_field to determine which aesthetic maps to theta: -/// - If theta_field is "y" (default): y → theta, x → color (standard pie chart) -/// - If theta_field is "x": x → theta, y → radius -fn update_encoding_for_polar( - encoding: &mut Value, - theta_field: &str, - start_radians: f64, -) -> Result<()> { +/// The encoding channels are already correctly named (theta/radius) by +/// `map_aesthetic_name()` based on coord kind. This function only applies +/// the optional start angle offset from the PROJECT clause. +fn apply_polar_start_angle(encoding: &mut Value, start_radians: f64) -> Result<()> { + // Skip if no start angle offset + if start_radians.abs() <= f64::EPSILON { + return Ok(()); + } + let enc_obj = encoding .as_object_mut() .ok_or_else(|| GgsqlError::WriterError("Encoding is not an object".to_string()))?; - // Map the theta field to theta channel based on theta property - if theta_field == "y" { - // Standard pie chart: y → theta, x → color/category - if let Some(y_enc) = enc_obj.remove("y") { - enc_obj.insert("theta".to_string(), y_enc); - } - // Map x to color if not already mapped, and remove x from positional encoding - if !enc_obj.contains_key("color") { - if let Some(x_enc) = enc_obj.remove("x") { - enc_obj.insert("color".to_string(), x_enc); - } - } else { - // If color is already mapped, just remove x from positional encoding - enc_obj.remove("x"); - } - } else if theta_field == "x" { - // Reversed: x → theta, y → radius - if let Some(x_enc) = enc_obj.remove("x") { - enc_obj.insert("theta".to_string(), x_enc); - } - if let Some(y_enc) = enc_obj.remove("y") { - enc_obj.insert("radius".to_string(), y_enc); - } - } - - // Apply start angle offset to theta encoding if non-zero - if start_radians.abs() > f64::EPSILON { - if let Some(theta_enc) = enc_obj.get_mut("theta") { - if let Some(theta_obj) = theta_enc.as_object_mut() { - // Set the scale range to offset by the start angle - // Vega-Lite theta scale default is [0, 2π], we offset it - let end_radians = start_radians + 2.0 * std::f64::consts::PI; - theta_obj.insert( - "scale".to_string(), - json!({ - "range": [start_radians, end_radians] - }), - ); - } + // Apply start angle offset to theta encoding + if let Some(theta_enc) = enc_obj.get_mut("theta") { + if let Some(theta_obj) = theta_enc.as_object_mut() { + // Set the scale range to offset by the start angle + // Vega-Lite theta scale default is [0, 2π], we offset it + let end_radians = start_radians + 2.0 * std::f64::consts::PI; + theta_obj.insert( + "scale".to_string(), + json!({ + "range": [start_radians, end_radians] + }), + ); } } diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index 43f5b5f9..57ee2da0 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -637,6 +637,8 @@ module.exports = grammar({ ')' ), + // Aesthetic name: either a known aesthetic or any identifier (for custom PROJECT aesthetics) + // Known aesthetics are listed first for syntax highlighting priority aesthetic_name: $ => choice( // Position aesthetics (cartesian) 'x', 'y', 'xmin', 'xmax', 'ymin', 'ymax', 'xend', 'yend', @@ -656,7 +658,9 @@ module.exports = grammar({ // Facet aesthetics 'panel', 'row', 'column', // Computed variables - 'offset' + 'offset', + // Allow any identifier for custom PROJECT aesthetics (e.g., PROJECT a, b TO polar) + $.identifier ), column_reference: $ => $.identifier, From d4730c1154f76404064dbb16813775de81914a46 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Wed, 25 Feb 2026 21:04:14 +0100 Subject: [PATCH 11/28] remove theta setting --- src/parser/builder.rs | 33 ------------------- src/plot/projection/coord/cartesian.rs | 12 ------- src/plot/projection/coord/polar.rs | 44 ++------------------------ src/writer/vegalite/projection.rs | 3 +- tree-sitter-ggsql/grammar.js | 2 +- 5 files changed, 4 insertions(+), 90 deletions(-) diff --git a/src/parser/builder.rs b/src/parser/builder.rs index b903a348..6d09ffab 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -1213,39 +1213,6 @@ mod tests { // PROJECT Property Validation Tests // ======================================== - #[test] - fn test_project_cartesian_invalid_property_theta() { - let query = r#" - VISUALISE - DRAW point MAPPING x AS x, y AS y - PROJECT TO cartesian SETTING theta => y - "#; - - let result = parse_test_query(query); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err - .to_string() - .contains("Property 'theta' not valid for cartesian")); - } - - #[test] - fn test_project_polar_valid_theta() { - let query = r#" - VISUALISE - DRAW bar MAPPING category AS x, value AS y - PROJECT TO polar SETTING theta => y - "#; - - let result = parse_test_query(query); - assert!(result.is_ok()); - let specs = result.unwrap(); - - let project = specs[0].project.as_ref().unwrap(); - assert_eq!(project.coord.coord_kind(), CoordKind::Polar); - assert!(project.properties.contains_key("theta")); - } - #[test] fn test_project_polar_with_start() { let query = r#" diff --git a/src/plot/projection/coord/cartesian.rs b/src/plot/projection/coord/cartesian.rs index c6f3d37c..f2117585 100644 --- a/src/plot/projection/coord/cartesian.rs +++ b/src/plot/projection/coord/cartesian.rs @@ -73,16 +73,4 @@ mod tests { assert!(err.contains("not valid")); } - #[test] - fn test_cartesian_rejects_theta() { - let cartesian = Cartesian; - let mut props = HashMap::new(); - props.insert("theta".to_string(), ParameterValue::String("y".to_string())); - - let resolved = cartesian.resolve_properties(&props); - assert!(resolved.is_err()); - let err = resolved.unwrap_err(); - assert!(err.contains("theta")); - assert!(err.contains("not valid")); - } } diff --git a/src/plot/projection/coord/polar.rs b/src/plot/projection/coord/polar.rs index 51c27d1e..a94679cd 100644 --- a/src/plot/projection/coord/polar.rs +++ b/src/plot/projection/coord/polar.rs @@ -21,12 +21,11 @@ impl CoordTrait for Polar { } fn allowed_properties(&self) -> &'static [&'static str] { - &["theta", "clip", "start"] + &["clip", "start"] } fn get_property_default(&self, name: &str) -> Option { match name { - "theta" => Some(ParameterValue::String("y".to_string())), "start" => Some(ParameterValue::Number(0.0)), // 0 degrees = 12 o'clock _ => None, } @@ -55,18 +54,9 @@ mod tests { fn test_polar_allowed_properties() { let polar = Polar; let allowed = polar.allowed_properties(); - assert!(allowed.contains(&"theta")); assert!(allowed.contains(&"clip")); assert!(allowed.contains(&"start")); - assert_eq!(allowed.len(), 3); - } - - #[test] - fn test_polar_theta_default() { - let polar = Polar; - let default = polar.get_property_default("theta"); - assert!(default.is_some()); - assert_eq!(default.unwrap(), ParameterValue::String("y".to_string())); + assert_eq!(allowed.len(), 2); } #[test] @@ -77,36 +67,6 @@ mod tests { assert_eq!(default.unwrap(), ParameterValue::Number(0.0)); } - #[test] - fn test_polar_resolve_adds_theta_default() { - let polar = Polar; - let props = HashMap::new(); - - let resolved = polar.resolve_properties(&props); - assert!(resolved.is_ok()); - let resolved = resolved.unwrap(); - assert!(resolved.contains_key("theta")); - assert_eq!( - resolved.get("theta").unwrap(), - &ParameterValue::String("y".to_string()) - ); - } - - #[test] - fn test_polar_resolve_with_explicit_theta() { - let polar = Polar; - let mut props = HashMap::new(); - props.insert("theta".to_string(), ParameterValue::String("x".to_string())); - - let resolved = polar.resolve_properties(&props); - assert!(resolved.is_ok()); - let resolved = resolved.unwrap(); - assert_eq!( - resolved.get("theta").unwrap(), - &ParameterValue::String("x".to_string()) - ); - } - #[test] fn test_polar_rejects_unknown_property() { let polar = Polar; diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 78f713ae..71116f65 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -94,7 +94,7 @@ fn apply_polar_project( let start_radians = start_degrees * std::f64::consts::PI / 180.0; // Convert geoms to polar equivalents and apply start angle offset - convert_geoms_to_polar(spec, vl_spec, "", start_radians)?; + convert_geoms_to_polar(spec, vl_spec, start_radians)?; // No DataFrame transformation needed - Vega-Lite handles polar math Ok(data.clone()) @@ -109,7 +109,6 @@ fn apply_polar_project( fn convert_geoms_to_polar( spec: &Plot, vl_spec: &mut Value, - _theta_field: &str, start_radians: f64, ) -> Result<()> { if let Some(layers) = vl_spec.get_mut("layer") { diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index 57ee2da0..f6a39cc0 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -796,7 +796,7 @@ module.exports = grammar({ ), project_property_name: $ => choice( - 'ratio', 'theta', 'clip', 'start' + 'ratio', 'clip', 'start' ), // LABEL clause (repeatable) From 9266427f6b4dee5f7186bc3ab87cb52a6055876f Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Wed, 25 Feb 2026 21:41:44 +0100 Subject: [PATCH 12/28] Add end property to polar --- src/plot/projection/coord/polar.rs | 45 ++++++++++++++++++- src/reader/mod.rs | 69 ++++++++++++++++++++++++++++++ src/writer/vegalite/projection.rs | 48 ++++++++++++++------- tree-sitter-ggsql/grammar.js | 2 +- 4 files changed, 145 insertions(+), 19 deletions(-) diff --git a/src/plot/projection/coord/polar.rs b/src/plot/projection/coord/polar.rs index a94679cd..1dbd00cb 100644 --- a/src/plot/projection/coord/polar.rs +++ b/src/plot/projection/coord/polar.rs @@ -21,7 +21,7 @@ impl CoordTrait for Polar { } fn allowed_properties(&self) -> &'static [&'static str] { - &["clip", "start"] + &["clip", "start", "end"] } fn get_property_default(&self, name: &str) -> Option { @@ -56,7 +56,8 @@ mod tests { let allowed = polar.allowed_properties(); assert!(allowed.contains(&"clip")); assert!(allowed.contains(&"start")); - assert_eq!(allowed.len(), 2); + assert!(allowed.contains(&"end")); + assert_eq!(allowed.len(), 3); } #[test] @@ -109,4 +110,44 @@ mod tests { &ParameterValue::Number(0.0) ); } + + #[test] + fn test_polar_resolve_with_explicit_end() { + let polar = Polar; + let mut props = HashMap::new(); + props.insert("end".to_string(), ParameterValue::Number(180.0)); + + let resolved = polar.resolve_properties(&props); + assert!(resolved.is_ok()); + let resolved = resolved.unwrap(); + assert_eq!( + resolved.get("end").unwrap(), + &ParameterValue::Number(180.0) + ); + // start should still get its default + assert_eq!( + resolved.get("start").unwrap(), + &ParameterValue::Number(0.0) + ); + } + + #[test] + fn test_polar_resolve_with_start_and_end() { + let polar = Polar; + let mut props = HashMap::new(); + props.insert("start".to_string(), ParameterValue::Number(-90.0)); + props.insert("end".to_string(), ParameterValue::Number(90.0)); + + let resolved = polar.resolve_properties(&props); + assert!(resolved.is_ok()); + let resolved = resolved.unwrap(); + assert_eq!( + resolved.get("start").unwrap(), + &ParameterValue::Number(-90.0) + ); + assert_eq!( + resolved.get("end").unwrap(), + &ParameterValue::Number(90.0) + ); + } } diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 11144bd7..47e5363e 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -460,6 +460,75 @@ mod tests { } } + #[test] + fn test_polar_project_with_end() { + 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 y, category AS fill + DRAW bar + PROJECT y, x TO polar SETTING start => -90, end => 90 + "#; + + 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 theta = &layer["encoding"]["theta"]; + let range = theta["scale"]["range"].as_array().unwrap(); + + // -90° = -π/2 ≈ -1.5708, 90° = π/2 ≈ 1.5708 + let start = range[0].as_f64().unwrap(); + let end = range[1].as_f64().unwrap(); + assert!( + (start - (-std::f64::consts::FRAC_PI_2)).abs() < 0.001, + "start should be -π/2 (-90 degrees), got {}", + start + ); + assert!( + (end - std::f64::consts::FRAC_PI_2).abs() < 0.001, + "end should be π/2 (90 degrees), got {}", + end + ); + } + + #[test] + fn test_polar_project_with_end_only() { + // Test using end without explicit start (start defaults to 0) + 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 y, category AS fill + DRAW bar + PROJECT y, x TO polar SETTING end => 180 + "#; + + 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 theta = &layer["encoding"]["theta"]; + let range = theta["scale"]["range"].as_array().unwrap(); + + // start=0 (default), end=180° = π + let start = range[0].as_f64().unwrap(); + let end = range[1].as_f64().unwrap(); + assert!( + start.abs() < 0.001, + "start should be 0 (default), got {}", + start + ); + assert!( + (end - std::f64::consts::PI).abs() < 0.001, + "end should be π (180 degrees), got {}", + end + ); + } + #[test] fn test_polar_encoding_keys_independent_of_user_names() { // This test verifies that polar projections always produce theta/radius encoding keys diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 71116f65..8099e70c 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -73,7 +73,7 @@ fn apply_cartesian_project( /// Encoding channel names (theta/radius) are already set correctly by `map_aesthetic_name()` /// based on coord kind. This function only: /// 1. Converts mark types to polar equivalents (bar → arc) -/// 2. Applies start angle offset from PROJECT clause +/// 2. Applies start/end angle range from PROJECT clause fn apply_polar_project( project: &Projection, spec: &Plot, @@ -90,26 +90,38 @@ fn apply_polar_project( }) .unwrap_or(0.0); + // Get end angle in degrees (defaults to start + 360 = full circle) + let end_degrees = project + .properties + .get("end") + .and_then(|v| match v { + ParameterValue::Number(n) => Some(*n), + _ => None, + }) + .unwrap_or(start_degrees + 360.0); + // Convert degrees to radians for Vega-Lite let start_radians = start_degrees * std::f64::consts::PI / 180.0; + let end_radians = end_degrees * std::f64::consts::PI / 180.0; - // Convert geoms to polar equivalents and apply start angle offset - convert_geoms_to_polar(spec, vl_spec, start_radians)?; + // Convert geoms to polar equivalents and apply angle range + convert_geoms_to_polar(spec, vl_spec, start_radians, end_radians)?; // No DataFrame transformation needed - Vega-Lite handles polar math Ok(data.clone()) } -/// Convert geoms to polar equivalents (bar->arc) and apply start angle offset +/// Convert geoms to polar equivalents (bar->arc) and apply angle range /// /// Note: Encoding channel names (theta/radius) are already set correctly by /// `map_aesthetic_name()` based on coord kind. This function only: /// 1. Converts mark types to polar equivalents (bar → arc) -/// 2. Applies start angle offset from PROJECT clause +/// 2. Applies start/end angle range from PROJECT clause fn convert_geoms_to_polar( spec: &Plot, vl_spec: &mut Value, start_radians: f64, + end_radians: f64, ) -> Result<()> { if let Some(layers) = vl_spec.get_mut("layer") { if let Some(layers_arr) = layers.as_array_mut() { @@ -117,9 +129,9 @@ fn convert_geoms_to_polar( if let Some(mark) = layer.get_mut("mark") { *mark = convert_mark_to_polar(mark, spec)?; - // Apply start angle offset if present + // Apply angle range if non-default if let Some(encoding) = layer.get_mut("encoding") { - apply_polar_start_angle(encoding, start_radians)?; + apply_polar_angle_range(encoding, start_radians, end_radians)?; } } } @@ -167,14 +179,20 @@ fn convert_mark_to_polar(mark: &Value, _spec: &Plot) -> Result { Ok(json!(polar_mark)) } -/// Apply start angle offset to theta encoding for polar projection +/// Apply angle range to theta encoding for polar projection /// /// The encoding channels are already correctly named (theta/radius) by /// `map_aesthetic_name()` based on coord kind. This function only applies -/// the optional start angle offset from the PROJECT clause. -fn apply_polar_start_angle(encoding: &mut Value, start_radians: f64) -> Result<()> { - // Skip if no start angle offset - if start_radians.abs() <= f64::EPSILON { +/// the optional start/end angle range from the PROJECT clause. +fn apply_polar_angle_range( + encoding: &mut Value, + start_radians: f64, + end_radians: f64, +) -> Result<()> { + // Skip if default range (0 to 2π) + let is_default = start_radians.abs() <= f64::EPSILON + && (end_radians - 2.0 * std::f64::consts::PI).abs() <= f64::EPSILON; + if is_default { return Ok(()); } @@ -182,12 +200,10 @@ fn apply_polar_start_angle(encoding: &mut Value, start_radians: f64) -> Result<( .as_object_mut() .ok_or_else(|| GgsqlError::WriterError("Encoding is not an object".to_string()))?; - // Apply start angle offset to theta encoding + // Apply angle range to theta encoding if let Some(theta_enc) = enc_obj.get_mut("theta") { if let Some(theta_obj) = theta_enc.as_object_mut() { - // Set the scale range to offset by the start angle - // Vega-Lite theta scale default is [0, 2π], we offset it - let end_radians = start_radians + 2.0 * std::f64::consts::PI; + // Set the scale range to the specified start/end angles theta_obj.insert( "scale".to_string(), json!({ diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index f6a39cc0..fb230842 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -796,7 +796,7 @@ module.exports = grammar({ ), project_property_name: $ => choice( - 'ratio', 'clip', 'start' + 'ratio', 'clip', 'start', 'end' ), // LABEL clause (repeatable) From 45864dd8f7379e5da8444cfc8103ad243c3558ff Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Wed, 25 Feb 2026 21:52:55 +0100 Subject: [PATCH 13/28] add inner property to polar coord --- src/plot/projection/coord/polar.rs | 20 ++++++++- src/reader/mod.rs | 66 ++++++++++++++++++++++++++++ src/writer/vegalite/projection.rs | 70 ++++++++++++++++++++++++++++-- tree-sitter-ggsql/grammar.js | 2 +- 4 files changed, 152 insertions(+), 6 deletions(-) diff --git a/src/plot/projection/coord/polar.rs b/src/plot/projection/coord/polar.rs index 1dbd00cb..441a668a 100644 --- a/src/plot/projection/coord/polar.rs +++ b/src/plot/projection/coord/polar.rs @@ -21,7 +21,7 @@ impl CoordTrait for Polar { } fn allowed_properties(&self) -> &'static [&'static str] { - &["clip", "start", "end"] + &["clip", "start", "end", "inner"] } fn get_property_default(&self, name: &str) -> Option { @@ -57,7 +57,8 @@ mod tests { assert!(allowed.contains(&"clip")); assert!(allowed.contains(&"start")); assert!(allowed.contains(&"end")); - assert_eq!(allowed.len(), 3); + assert!(allowed.contains(&"inner")); + assert_eq!(allowed.len(), 4); } #[test] @@ -150,4 +151,19 @@ mod tests { &ParameterValue::Number(90.0) ); } + + #[test] + fn test_polar_resolve_with_inner() { + let polar = Polar; + let mut props = HashMap::new(); + props.insert("inner".to_string(), ParameterValue::Number(0.5)); + + let resolved = polar.resolve_properties(&props); + assert!(resolved.is_ok()); + let resolved = resolved.unwrap(); + assert_eq!( + resolved.get("inner").unwrap(), + &ParameterValue::Number(0.5) + ); + } } diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 47e5363e..d23d980b 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -856,4 +856,70 @@ mod tests { label_expr ); } + + #[test] + fn test_polar_project_with_inner() { + 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 y, category AS fill + DRAW bar + PROJECT y, x TO polar SETTING inner => 0.5 + "#; + + 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(); + + // Check radius scale has range with expressions + let radius = &layer["encoding"]["radius"]; + assert!(radius["scale"]["range"].is_array()); + let range = radius["scale"]["range"].as_array().unwrap(); + + // First element should be inner proportion expression + assert!( + range[0]["expr"].as_str().unwrap().contains("0.5"), + "Inner radius expression should contain 0.5, got: {:?}", + range[0] + ); + + // Second element should be the outer radius expression + assert!( + range[1]["expr"].as_str().unwrap().contains("min(width,height)/2"), + "Outer radius expression should be min(width,height)/2, got: {:?}", + range[1] + ); + } + + #[test] + fn test_polar_project_inner_default() { + // Test that inner=0 (default) doesn't add scale range + 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 y, category AS fill + DRAW bar + PROJECT y, x TO polar + "#; + + 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(); + + // Radius encoding should not have scale.range when inner=0 + let radius = &layer["encoding"]["radius"]; + if let Some(scale) = radius.get("scale") { + assert!( + scale.get("range").is_none(), + "Radius scale should not have range when inner=0, got: {:?}", + scale + ); + } + } } diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 8099e70c..47eb0c24 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -74,6 +74,7 @@ fn apply_cartesian_project( /// based on coord kind. This function only: /// 1. Converts mark types to polar equivalents (bar → arc) /// 2. Applies start/end angle range from PROJECT clause +/// 3. Applies inner radius for donut charts fn apply_polar_project( project: &Projection, spec: &Plot, @@ -100,28 +101,40 @@ fn apply_polar_project( }) .unwrap_or(start_degrees + 360.0); + // Get inner radius proportion (0.0 to 1.0, defaults to 0 = full pie) + let inner = project + .properties + .get("inner") + .and_then(|v| match v { + ParameterValue::Number(n) => Some(*n), + _ => None, + }) + .unwrap_or(0.0); + // Convert degrees to radians for Vega-Lite let start_radians = start_degrees * std::f64::consts::PI / 180.0; let end_radians = end_degrees * std::f64::consts::PI / 180.0; - // Convert geoms to polar equivalents and apply angle range - convert_geoms_to_polar(spec, vl_spec, start_radians, end_radians)?; + // Convert geoms to polar equivalents and apply angle range + inner radius + convert_geoms_to_polar(spec, vl_spec, start_radians, end_radians, inner)?; // No DataFrame transformation needed - Vega-Lite handles polar math Ok(data.clone()) } -/// Convert geoms to polar equivalents (bar->arc) and apply angle range +/// Convert geoms to polar equivalents (bar->arc) and apply angle range + inner radius /// /// Note: Encoding channel names (theta/radius) are already set correctly by /// `map_aesthetic_name()` based on coord kind. This function only: /// 1. Converts mark types to polar equivalents (bar → arc) /// 2. Applies start/end angle range from PROJECT clause +/// 3. Applies inner radius for donut charts fn convert_geoms_to_polar( spec: &Plot, vl_spec: &mut Value, start_radians: f64, end_radians: f64, + inner: f64, ) -> Result<()> { if let Some(layers) = vl_spec.get_mut("layer") { if let Some(layers_arr) = layers.as_array_mut() { @@ -132,6 +145,7 @@ fn convert_geoms_to_polar( // Apply angle range if non-default if let Some(encoding) = layer.get_mut("encoding") { apply_polar_angle_range(encoding, start_radians, end_radians)?; + apply_polar_radius_range(encoding, inner)?; } } } @@ -216,3 +230,53 @@ fn apply_polar_angle_range( Ok(()) } +/// Apply inner radius to radius encoding for donut charts +/// +/// Sets the radius scale range using Vega-Lite expressions for proportional sizing. +/// The inner parameter (0.0 to 1.0) specifies the inner radius as a proportion +/// of the outer radius, creating a donut hole. +fn apply_polar_radius_range(encoding: &mut Value, inner: f64) -> Result<()> { + // Skip if no inner radius (full pie) + if inner <= f64::EPSILON { + return Ok(()); + } + + let enc_obj = encoding + .as_object_mut() + .ok_or_else(|| GgsqlError::WriterError("Encoding is not an object".to_string()))?; + + // Apply scale range to radius encoding + if let Some(radius_enc) = enc_obj.get_mut("radius") { + if let Some(radius_obj) = radius_enc.as_object_mut() { + // Use expressions for proportional sizing + // min(width,height)/2 is the default max radius in Vega-Lite + let inner_expr = format!("min(width,height)/2*{}", inner); + let outer_expr = "min(width,height)/2".to_string(); + + radius_obj.insert( + "scale".to_string(), + json!({ + "range": [{"expr": inner_expr}, {"expr": outer_expr}] + }), + ); + } + } + + // Also apply to radius2 if present (for arc marks) + if let Some(radius2_enc) = enc_obj.get_mut("radius2") { + if let Some(radius2_obj) = radius2_enc.as_object_mut() { + let inner_expr = format!("min(width,height)/2*{}", inner); + let outer_expr = "min(width,height)/2".to_string(); + + radius2_obj.insert( + "scale".to_string(), + json!({ + "range": [{"expr": inner_expr}, {"expr": outer_expr}] + }), + ); + } + } + + Ok(()) +} + diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index fb230842..5d2935c8 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -796,7 +796,7 @@ module.exports = grammar({ ), project_property_name: $ => choice( - 'ratio', 'clip', 'start', 'end' + 'ratio', 'clip', 'start', 'end', 'inner' ), // LABEL clause (repeatable) From 3aef3fb61e0c90f3be6c387049c4c9ddd4584fe1 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Thu, 26 Feb 2026 08:35:38 +0100 Subject: [PATCH 14/28] reformat etc --- doc/ggsql.xml | 8 - ggsql-vscode/syntaxes/ggsql.tmLanguage.json | 6 +- src/execute/mod.rs | 14 +- src/execute/scale.rs | 10 +- src/parser/builder.rs | 14 +- src/parser/mod.rs | 2 +- src/plot/aesthetic.rs | 50 +++-- src/plot/facet/types.rs | 16 +- src/plot/layer/geom/bar.rs | 4 +- src/plot/layer/geom/density.rs | 13 +- src/plot/layer/geom/hline.rs | 8 +- src/plot/layer/geom/text.rs | 4 +- src/plot/layer/geom/tile.rs | 4 +- src/plot/layer/geom/vline.rs | 8 +- src/plot/main.rs | 26 ++- src/plot/projection/coord/cartesian.rs | 6 +- src/plot/projection/coord/polar.rs | 30 +-- src/plot/scale/scale_type/mod.rs | 24 ++- src/reader/mod.rs | 13 +- src/writer/vegalite/encoding.rs | 51 +++-- src/writer/vegalite/mod.rs | 200 ++++++++++++-------- src/writer/vegalite/projection.rs | 13 +- 22 files changed, 331 insertions(+), 193 deletions(-) diff --git a/doc/ggsql.xml b/doc/ggsql.xml index b66b2c96..531aef80 100644 --- a/doc/ggsql.xml +++ b/doc/ggsql.xml @@ -222,14 +222,6 @@ void
- - - fixed - free - free_x - free_y - - type diff --git a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json index 9ae2045a..b8966255 100644 --- a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json +++ b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json @@ -332,13 +332,9 @@ }, "end": "(?i)(?=\\b(DRAW|SCALE|PROJECT|FACET|LABEL|THEME|VISUALISE|VISUALIZE|SELECT|WHERE|WITH)\\b)", "patterns": [ - { - "name": "constant.language.facet-scales.ggsql", - "match": "\\b(fixed|free|free_x|free_y)\\b" - }, { "name": "support.type.property.ggsql", - "match": "\\b(scales|ncol|columns|missing)\\b" + "match": "\\b(free|ncol|missing)\\b" }, { "name": "keyword.operator.wildcard.ggsql", diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 64ef8cc3..d0c450b7 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -581,7 +581,8 @@ fn resolve_facet( // Having only facet2 is an error (column without row) if has_facet2 && !has_facet1 { return Err(GgsqlError::ValidationError( - "Grid facet layout requires both 'row' and 'column' aesthetics. Missing: 'row'".to_string() + "Grid facet layout requires both 'row' and 'column' aesthetics. Missing: 'row'" + .to_string(), )); } @@ -1157,8 +1158,7 @@ pub fn prepare_data_with_reader(query: &str, reader: &R) -> Result = - spec.get_aesthetic_context().user_positional().to_vec(); + let positional_names: Vec = spec.get_aesthetic_context().user_positional().to_vec(); // Convert to &str slice for resolve_facet_properties let positional_refs: Vec<&str> = positional_names.iter().map(|s| s.as_str()).collect(); @@ -1712,10 +1712,10 @@ mod tests { fn test_resolve_facet_infers_grid_from_layer_mappings() { // Use internal names "facet1" and "facet2" since resolve_facet is called after transformation let mut layer = Layer::new(Geom::point()); - layer - .mappings - .aesthetics - .insert("facet1".to_string(), AestheticValue::standard_column("region")); + layer.mappings.aesthetics.insert( + "facet1".to_string(), + AestheticValue::standard_column("region"), + ); layer.mappings.aesthetics.insert( "facet2".to_string(), AestheticValue::standard_column("year"), diff --git a/src/execute/scale.rs b/src/execute/scale.rs index b24d3af6..999e9415 100644 --- a/src/execute/scale.rs +++ b/src/execute/scale.rs @@ -1442,8 +1442,14 @@ mod tests { spec.scales.push(scale); // Simulate post-transformation state: mappings use internal names let layer = Layer::new(Geom::errorbar()) - .with_aesthetic("pos2min".to_string(), AestheticValue::standard_column("low")) - .with_aesthetic("pos2max".to_string(), AestheticValue::standard_column("high")); + .with_aesthetic( + "pos2min".to_string(), + AestheticValue::standard_column("low"), + ) + .with_aesthetic( + "pos2max".to_string(), + AestheticValue::standard_column("high"), + ); spec.layers.push(layer); // Create data where pos2min/pos2max columns have different ranges diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 6d09ffab..a91830a2 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -1267,7 +1267,10 @@ mod tests { let specs = result.unwrap(); let project = specs[0].project.as_ref().unwrap(); - assert_eq!(project.aesthetics, vec!["myX".to_string(), "myY".to_string()]); + assert_eq!( + project.aesthetics, + vec!["myX".to_string(), "myY".to_string()] + ); } #[test] @@ -1299,7 +1302,10 @@ mod tests { let specs = result.unwrap(); let project = specs[0].project.as_ref().unwrap(); - assert_eq!(project.aesthetics, vec!["theta".to_string(), "radius".to_string()]); + assert_eq!( + project.aesthetics, + vec!["theta".to_string(), "radius".to_string()] + ); } #[test] @@ -1327,7 +1333,9 @@ mod tests { let result = parse_test_query(query); assert!(result.is_err()); let err = result.unwrap_err(); - assert!(err.to_string().contains("conflicts with non-positional aesthetic")); + assert!(err + .to_string() + .contains("conflicts with non-positional aesthetic")); } // ======================================== diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 96b90643..6c80d63a 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -321,7 +321,7 @@ mod tests { // After parsing, aesthetics are transformed to internal names assert!(mapping.aesthetics.contains_key("pos1")); // x -> pos1 assert!(mapping.aesthetics.contains_key("pos2")); // y -> pos2 - // Column names remain unchanged + // Column names remain unchanged assert_eq!( mapping.aesthetics.get("pos1").unwrap().column_name(), Some("date") diff --git a/src/plot/aesthetic.rs b/src/plot/aesthetic.rs index 8bf5e1f2..72bde683 100644 --- a/src/plot/aesthetic.rs +++ b/src/plot/aesthetic.rs @@ -189,7 +189,8 @@ impl AestheticContext { /// /// Convenience method for creating context from static string slices (e.g., from coord defaults). pub fn from_static(positional_names: &[&'static str], facet_names: &[&'static str]) -> Self { - let owned_positional: Vec = positional_names.iter().map(|s| s.to_string()).collect(); + let owned_positional: Vec = + positional_names.iter().map(|s| s.to_string()).collect(); Self::new(&owned_positional, facet_names) } @@ -213,11 +214,7 @@ impl AestheticContext { } // Check active facet (from FACET clause) - if let Some(idx) = self - .all_user_facet - .iter() - .position(|u| u == user_aesthetic) - { + if let Some(idx) = self.all_user_facet.iter().position(|u| u == user_aesthetic) { return Some(self.all_internal_facet[idx].as_str()); } @@ -514,9 +511,8 @@ pub fn primary_aesthetic(aesthetic: &str) -> &str { // Handle internal positional variants (pos1min -> pos1, pos2end -> pos2, etc.) if aesthetic.starts_with("pos") { for suffix in POSITIONAL_SUFFIXES { - if aesthetic.ends_with(suffix) { + if let Some(base) = aesthetic.strip_suffix(suffix) { // Extract the base: pos1min -> pos1, pos2end -> pos2 - let base = &aesthetic[..aesthetic.len() - suffix.len()]; // Verify it's a valid positional (pos followed by digits) if base.len() > 3 && base[3..].chars().all(|c| c.is_ascii_digit()) { // Return static str by leaking - this is acceptable for a small fixed set @@ -556,7 +552,10 @@ pub fn get_aesthetic_family(aesthetic: &str) -> Vec { } // Check if this is an internal positional (pos1, pos2, etc.) - if primary.starts_with("pos") && primary.len() > 3 && primary[3..].chars().all(|c| c.is_ascii_digit()) { + if primary.starts_with("pos") + && primary.len() > 3 + && primary[3..].chars().all(|c| c.is_ascii_digit()) + { // Build the internal family: pos1 -> [pos1, pos1min, pos1max, pos1end, pos1intercept] let mut family = vec![primary.to_string()]; for suffix in POSITIONAL_SUFFIXES { @@ -793,7 +792,11 @@ mod tests { assert_eq!(ctx.user_positional(), &["x", "y"]); // All user positional (with suffixes) - let all_user: Vec<&str> = ctx.all_user_positional().iter().map(|s| s.as_str()).collect(); + let all_user: Vec<&str> = ctx + .all_user_positional() + .iter() + .map(|s| s.as_str()) + .collect(); assert!(all_user.contains(&"x")); assert!(all_user.contains(&"xmin")); assert!(all_user.contains(&"xmax")); @@ -818,7 +821,11 @@ mod tests { assert_eq!(ctx.user_positional(), &["theta", "radius"]); // All user positional (with suffixes) - let all_user: Vec<&str> = ctx.all_user_positional().iter().map(|s| s.as_str()).collect(); + let all_user: Vec<&str> = ctx + .all_user_positional() + .iter() + .map(|s| s.as_str()) + .collect(); assert!(all_user.contains(&"theta")); assert!(all_user.contains(&"thetamin")); assert!(all_user.contains(&"thetamax")); @@ -982,7 +989,10 @@ mod tests { assert_eq!(ctx.primary_internal_aesthetic("pos1"), Some("pos1")); assert_eq!(ctx.primary_internal_aesthetic("pos1min"), Some("pos1")); assert_eq!(ctx.primary_internal_aesthetic("pos2end"), Some("pos2")); - assert_eq!(ctx.primary_internal_aesthetic("pos1intercept"), Some("pos1")); + assert_eq!( + ctx.primary_internal_aesthetic("pos1intercept"), + Some("pos1") + ); assert_eq!(ctx.primary_internal_aesthetic("color"), Some("color")); } @@ -1026,7 +1036,13 @@ mod tests { let theta_strs: Vec<&str> = theta_family.iter().map(|s| s.as_str()).collect(); assert_eq!( theta_strs, - vec!["theta", "thetamin", "thetamax", "thetaend", "thetaintercept"] + vec![ + "theta", + "thetamin", + "thetamax", + "thetaend", + "thetaintercept" + ] ); // Get user family for radius @@ -1034,7 +1050,13 @@ mod tests { let radius_strs: Vec<&str> = radius_family.iter().map(|s| s.as_str()).collect(); assert_eq!( radius_strs, - vec!["radius", "radiusmin", "radiusmax", "radiusend", "radiusintercept"] + vec![ + "radius", + "radiusmin", + "radiusmax", + "radiusend", + "radiusintercept" + ] ); // But internal families are the same for all coords diff --git a/src/plot/facet/types.rs b/src/plot/facet/types.rs index a2be1580..ce2cee5c 100644 --- a/src/plot/facet/types.rs +++ b/src/plot/facet/types.rs @@ -103,12 +103,10 @@ impl FacetLayout { pub fn get_aesthetic_mappings(&self) -> Vec<(&str, &'static str)> { let user_names = self.user_facet_names(); match self { - FacetLayout::Wrap { variables } => { - variables - .iter() - .map(|v| (v.as_str(), user_names[0])) - .collect() - } + FacetLayout::Wrap { variables } => variables + .iter() + .map(|v| (v.as_str(), user_names[0])) + .collect(), FacetLayout::Grid { row, column } => { let mut result: Vec<(&str, &'static str)> = row.iter().map(|v| (v.as_str(), user_names[0])).collect(); @@ -160,7 +158,11 @@ impl FacetLayout { .iter() .map(|v| (v.as_str(), internal_names[0].clone())) .collect(); - result.extend(column.iter().map(|v| (v.as_str(), internal_names[1].clone()))); + result.extend( + column + .iter() + .map(|v| (v.as_str(), internal_names[1].clone())), + ); result } } diff --git a/src/plot/layer/geom/bar.rs b/src/plot/layer/geom/bar.rs index 2979ceb5..a98da647 100644 --- a/src/plot/layer/geom/bar.rs +++ b/src/plot/layer/geom/bar.rs @@ -26,7 +26,9 @@ impl GeomTrait for Bar { // If x is missing: single bar showing total // If y is missing: stat computes COUNT or SUM(weight) // weight: optional, if mapped uses SUM(weight) instead of COUNT(*) - supported: &["pos1", "pos2", "weight", "fill", "stroke", "width", "opacity"], + supported: &[ + "pos1", "pos2", "weight", "fill", "stroke", "width", "opacity", + ], required: &[], hidden: &[], } diff --git a/src/plot/layer/geom/density.rs b/src/plot/layer/geom/density.rs index 661c1442..5559859a 100644 --- a/src/plot/layer/geom/density.rs +++ b/src/plot/layer/geom/density.rs @@ -89,7 +89,14 @@ impl GeomTrait for Density { parameters: &std::collections::HashMap, execute_query: &dyn Fn(&str) -> crate::Result, ) -> crate::Result { - stat_density(query, aesthetics, "pos1", group_by, parameters, execute_query) + stat_density( + query, + aesthetics, + "pos1", + group_by, + parameters, + execute_query, + ) } } @@ -948,7 +955,9 @@ mod tests { assert!(df.height() > 0); // Verify pos2 values (from intensity) are non-negative - let y_col = df.column("__ggsql_aes_pos2__").expect("pos2 aesthetic exists"); + let y_col = df + .column("__ggsql_aes_pos2__") + .expect("pos2 aesthetic exists"); let all_non_negative = y_col .f64() .expect("y is f64") diff --git a/src/plot/layer/geom/hline.rs b/src/plot/layer/geom/hline.rs index cc8ce032..f27b1227 100644 --- a/src/plot/layer/geom/hline.rs +++ b/src/plot/layer/geom/hline.rs @@ -13,7 +13,13 @@ impl GeomTrait for HLine { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { - supported: &["pos2intercept", "stroke", "linetype", "linewidth", "opacity"], + supported: &[ + "pos2intercept", + "stroke", + "linetype", + "linewidth", + "opacity", + ], required: &["pos2intercept"], hidden: &[], } diff --git a/src/plot/layer/geom/text.rs b/src/plot/layer/geom/text.rs index 673d2979..97eddaef 100644 --- a/src/plot/layer/geom/text.rs +++ b/src/plot/layer/geom/text.rs @@ -14,8 +14,8 @@ impl GeomTrait for Text { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { supported: &[ - "pos1", "pos2", "label", "stroke", "size", "opacity", "family", "fontface", "hjust", - "vjust", + "pos1", "pos2", "label", "stroke", "size", "opacity", "family", "fontface", + "hjust", "vjust", ], required: &["pos1", "pos2"], hidden: &[], diff --git a/src/plot/layer/geom/tile.rs b/src/plot/layer/geom/tile.rs index 3b56cbab..35f8ad31 100644 --- a/src/plot/layer/geom/tile.rs +++ b/src/plot/layer/geom/tile.rs @@ -13,7 +13,9 @@ impl GeomTrait for Tile { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { - supported: &["pos1", "pos2", "fill", "stroke", "width", "height", "opacity"], + supported: &[ + "pos1", "pos2", "fill", "stroke", "width", "height", "opacity", + ], required: &["pos1", "pos2"], hidden: &[], } diff --git a/src/plot/layer/geom/vline.rs b/src/plot/layer/geom/vline.rs index a611037c..2a3baaf3 100644 --- a/src/plot/layer/geom/vline.rs +++ b/src/plot/layer/geom/vline.rs @@ -13,7 +13,13 @@ impl GeomTrait for VLine { fn aesthetics(&self) -> GeomAesthetics { GeomAesthetics { - supported: &["pos1intercept", "stroke", "linetype", "linewidth", "opacity"], + supported: &[ + "pos1intercept", + "stroke", + "linetype", + "linewidth", + "opacity", + ], required: &["pos1intercept"], hidden: &[], } diff --git a/src/plot/main.rs b/src/plot/main.rs index 8124d579..066d8af2 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -142,8 +142,7 @@ impl Plot { } else { // Create default based on project (use aesthetics if set, else defaults) // If no project clause, use default cartesian names ["x", "y"] - let default_positional: Vec = - vec!["x".to_string(), "y".to_string()]; + let default_positional: Vec = vec!["x".to_string(), "y".to_string()]; let positional_names: &[String] = self .project .as_ref() @@ -162,8 +161,7 @@ impl Plot { pub fn initialize_aesthetic_context(&mut self) { // Get positional names from project (already resolved at build time) // If no project clause, use default ["x", "y"] - let default_positional: Vec = - vec!["x".to_string(), "y".to_string()]; + let default_positional: Vec = vec!["x".to_string(), "y".to_string()]; let positional_names: &[String] = self .project .as_ref() @@ -369,8 +367,14 @@ mod tests { let valid_ribbon = Layer::new(Geom::ribbon()) .with_aesthetic("pos1".to_string(), AestheticValue::standard_column("x")) - .with_aesthetic("pos2min".to_string(), AestheticValue::standard_column("ymin")) - .with_aesthetic("pos2max".to_string(), AestheticValue::standard_column("ymax")); + .with_aesthetic( + "pos2min".to_string(), + AestheticValue::standard_column("ymin"), + ) + .with_aesthetic( + "pos2max".to_string(), + AestheticValue::standard_column("ymax"), + ); assert!(valid_ribbon.validate_required_aesthetics().is_ok()); } @@ -651,8 +655,14 @@ mod tests { // Add a second layer with pos1min let layer2 = Layer::new(Geom::ribbon()) .with_aesthetic("pos1".to_string(), AestheticValue::standard_column("date")) - .with_aesthetic("pos1min".to_string(), AestheticValue::standard_column("lower")) - .with_aesthetic("pos1max".to_string(), AestheticValue::standard_column("upper")) + .with_aesthetic( + "pos1min".to_string(), + AestheticValue::standard_column("lower"), + ) + .with_aesthetic( + "pos1max".to_string(), + AestheticValue::standard_column("upper"), + ) .with_aesthetic( "pos2min".to_string(), AestheticValue::standard_column("y_lower"), diff --git a/src/plot/projection/coord/cartesian.rs b/src/plot/projection/coord/cartesian.rs index f2117585..239197f7 100644 --- a/src/plot/projection/coord/cartesian.rs +++ b/src/plot/projection/coord/cartesian.rs @@ -64,7 +64,10 @@ mod tests { fn test_cartesian_rejects_unknown_property() { let cartesian = Cartesian; let mut props = HashMap::new(); - props.insert("unknown".to_string(), ParameterValue::String("value".to_string())); + props.insert( + "unknown".to_string(), + ParameterValue::String("value".to_string()), + ); let resolved = cartesian.resolve_properties(&props); assert!(resolved.is_err()); @@ -72,5 +75,4 @@ mod tests { assert!(err.contains("unknown")); assert!(err.contains("not valid")); } - } diff --git a/src/plot/projection/coord/polar.rs b/src/plot/projection/coord/polar.rs index 441a668a..05c4fe52 100644 --- a/src/plot/projection/coord/polar.rs +++ b/src/plot/projection/coord/polar.rs @@ -73,7 +73,10 @@ mod tests { fn test_polar_rejects_unknown_property() { let polar = Polar; let mut props = HashMap::new(); - props.insert("unknown".to_string(), ParameterValue::String("value".to_string())); + props.insert( + "unknown".to_string(), + ParameterValue::String("value".to_string()), + ); let resolved = polar.resolve_properties(&props); assert!(resolved.is_err()); @@ -106,10 +109,7 @@ mod tests { assert!(resolved.is_ok()); let resolved = resolved.unwrap(); assert!(resolved.contains_key("start")); - assert_eq!( - resolved.get("start").unwrap(), - &ParameterValue::Number(0.0) - ); + assert_eq!(resolved.get("start").unwrap(), &ParameterValue::Number(0.0)); } #[test] @@ -121,15 +121,9 @@ mod tests { let resolved = polar.resolve_properties(&props); assert!(resolved.is_ok()); let resolved = resolved.unwrap(); - assert_eq!( - resolved.get("end").unwrap(), - &ParameterValue::Number(180.0) - ); + assert_eq!(resolved.get("end").unwrap(), &ParameterValue::Number(180.0)); // start should still get its default - assert_eq!( - resolved.get("start").unwrap(), - &ParameterValue::Number(0.0) - ); + assert_eq!(resolved.get("start").unwrap(), &ParameterValue::Number(0.0)); } #[test] @@ -146,10 +140,7 @@ mod tests { resolved.get("start").unwrap(), &ParameterValue::Number(-90.0) ); - assert_eq!( - resolved.get("end").unwrap(), - &ParameterValue::Number(90.0) - ); + assert_eq!(resolved.get("end").unwrap(), &ParameterValue::Number(90.0)); } #[test] @@ -161,9 +152,6 @@ mod tests { let resolved = polar.resolve_properties(&props); assert!(resolved.is_ok()); let resolved = resolved.unwrap(); - assert_eq!( - resolved.get("inner").unwrap(), - &ParameterValue::Number(0.5) - ); + assert_eq!(resolved.get("inner").unwrap(), &ParameterValue::Number(0.5)); } } diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index 2a6c1474..f2276b2c 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -2369,8 +2369,16 @@ mod tests { fn test_expand_positional_vs_non_positional() { // Internal positional aesthetics (after transformation) let internal_positional = [ - "pos1", "pos1min", "pos1max", "pos1end", "pos1intercept", - "pos2", "pos2min", "pos2max", "pos2end", "pos2intercept", + "pos1", + "pos1min", + "pos1max", + "pos1end", + "pos1intercept", + "pos2", + "pos2min", + "pos2max", + "pos2end", + "pos2intercept", ]; let mut props = HashMap::new(); @@ -2402,8 +2410,16 @@ mod tests { fn test_oob_defaults_by_aesthetic_type() { // Internal positional aesthetics (after transformation) let internal_positional = [ - "pos1", "pos1min", "pos1max", "pos1end", "pos1intercept", - "pos2", "pos2min", "pos2max", "pos2end", "pos2intercept", + "pos1", + "pos1min", + "pos1max", + "pos1end", + "pos1intercept", + "pos2", + "pos2min", + "pos2max", + "pos2end", + "pos2intercept", ]; let props = HashMap::new(); diff --git a/src/reader/mod.rs b/src/reader/mod.rs index d23d980b..58dd4287 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -543,7 +543,9 @@ mod tests { layer["encoding"].get("theta").is_some(), "{} should produce theta encoding, got keys: {:?}", test_name, - layer["encoding"].as_object().map(|o| o.keys().collect::>()) + layer["encoding"] + .as_object() + .map(|o| o.keys().collect::>()) ); // Also verify no x or y keys exist (they should be mapped to theta/radius) assert!( @@ -620,7 +622,9 @@ mod tests { layer["encoding"].get("x").is_some(), "{} should produce x encoding, got keys: {:?}", test_name, - layer["encoding"].as_object().map(|o| o.keys().collect::>()) + layer["encoding"] + .as_object() + .map(|o| o.keys().collect::>()) ); // Verify no theta/radius keys exist assert!( @@ -888,7 +892,10 @@ mod tests { // Second element should be the outer radius expression assert!( - range[1]["expr"].as_str().unwrap().contains("min(width,height)/2"), + range[1]["expr"] + .as_str() + .unwrap() + .contains("min(width,height)/2"), "Outer radius expression should be min(width,height)/2, got: {:?}", range[1] ); diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 0fadaa54..407f6e7e 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -13,6 +13,39 @@ use std::collections::{HashMap, HashSet}; use super::{POINTS_TO_AREA, POINTS_TO_PIXELS}; +/// Check if a positional aesthetic has free scales enabled. +/// +/// Maps aesthetic names to position indices: +/// - pos1, pos1min, pos1max, pos1end -> index 0 +/// - pos2, pos2min, pos2max, pos2end -> index 1 +/// - etc. +/// +/// Returns false for non-positional aesthetics or if no free_scales array is provided. +fn is_position_free_for_aesthetic( + aesthetic: &str, + free_scales: Option<&[crate::plot::ArrayElement]>, +) -> bool { + let Some(free_arr) = free_scales else { + return false; + }; + + // Extract position index from aesthetic name (pos1 -> 0, pos2 -> 1, etc.) + let pos_index = if aesthetic.starts_with("pos1") { + Some(0) + } else if aesthetic.starts_with("pos2") { + Some(1) + } else if aesthetic.starts_with("pos3") { + Some(2) + } else { + None + }; + + pos_index + .and_then(|idx| free_arr.get(idx)) + .map(|e| matches!(e, crate::plot::ArrayElement::Boolean(true))) + .unwrap_or(false) +} + /// Build a Vega-Lite labelExpr from label mappings /// /// Generates a conditional expression that renames or suppresses labels: @@ -400,10 +433,8 @@ struct ScaleContext<'a> { is_binned_legend: bool, #[allow(dead_code)] spec: &'a Plot, // Reserved for future use (e.g., multi-scale legend decisions) - /// Whether to skip domain for x axis (facet free scales) - free_x: bool, - /// Whether to skip domain for y axis (facet free scales) - free_y: bool, + /// Free scales array from facet (position-indexed booleans) + free_scales: Option<&'a [crate::plot::ArrayElement]>, } /// Build scale properties from SCALE clause @@ -422,8 +453,7 @@ fn build_scale_properties( // When using free scales, Vega-Lite computes independent domains per facet panel. // Setting an explicit domain would override this behavior. // Note: aesthetics are in internal format (pos1, pos2) at this stage - let skip_domain = - (ctx.aesthetic == "pos1" && ctx.free_x) || (ctx.aesthetic == "pos2" && ctx.free_y); + let skip_domain = is_position_free_for_aesthetic(ctx.aesthetic, ctx.free_scales); // Apply domain from input_range (FROM clause) // Skip for threshold scales - they use internal breaks as domain instead @@ -742,10 +772,8 @@ pub(super) struct EncodingContext<'a> { pub spec: &'a Plot, pub titled_families: &'a mut HashSet, pub primary_aesthetics: &'a HashSet, - /// Whether facet has free x scale (independent domains per panel) - pub free_x: bool, - /// Whether facet has free y scale (independent domains per panel) - pub free_y: bool, + /// Free scales array from facet (position-indexed booleans) + pub free_scales: Option<&'a [crate::plot::ArrayElement]>, } /// Build encoding channel from aesthetic mapping @@ -823,8 +851,7 @@ fn build_column_encoding( aesthetic, spec: ctx.spec, is_binned_legend, - free_x: ctx.free_x, - free_y: ctx.free_y, + free_scales: ctx.free_scales, }; let (scale_obj, needs_gradient) = build_scale_properties(scale, &scale_ctx); diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index c5906c92..dd1d642d 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -28,9 +28,7 @@ mod projection; use crate::plot::ArrayElement; use crate::plot::{CoordKind, ParameterValue, Scale, ScaleTypeKind}; use crate::writer::Writer; -use crate::{ - naming, primary_aesthetic, AestheticValue, DataFrame, GgsqlError, Plot, Result, -}; +use crate::{naming, primary_aesthetic, AestheticValue, DataFrame, GgsqlError, Plot, Result}; use serde_json::{json, Value}; use std::collections::HashMap; @@ -139,8 +137,8 @@ fn prepare_layer_data( /// - Applies geom-specific modifications via renderer /// - Finalizes layers (may expand composite geoms into multiple layers) /// -/// The `free_x` and `free_y` flags indicate whether facet free scales are enabled. -/// When true, explicit domains should not be set for that axis. +/// The `free_scales` array indicates which positional aesthetics have independent scales +/// per facet panel. When a position is free, explicit domains should not be set. /// /// The `coord_kind` determines how internal positional aesthetics are mapped to /// Vega-Lite encoding channel names. @@ -150,8 +148,7 @@ fn build_layers( layer_data_keys: &[String], layer_renderers: &[Box], prepared_data: &[PreparedData], - free_x: bool, - free_y: bool, + free_scales: Option<&[crate::plot::ArrayElement]>, coord_kind: CoordKind, ) -> Result> { let mut layers = Vec::new(); @@ -185,8 +182,8 @@ fn build_layers( // Set transform array on layer spec layer_spec["transform"] = json!(transforms); - // Build encoding for this layer (pass free scale flags and coord kind) - let encoding = build_layer_encoding(layer, df, spec, free_x, free_y, coord_kind)?; + // Build encoding for this layer (pass free scales and coord kind) + let encoding = build_layer_encoding(layer, df, spec, free_scales, coord_kind)?; layer_spec["encoding"] = Value::Object(encoding); // Apply geom-specific spec modifications via renderer @@ -210,8 +207,8 @@ fn build_layers( /// - Detail encoding for partition_by columns /// - Geom-specific encoding modifications via renderer /// -/// The `free_x` and `free_y` flags indicate whether facet free scales are enabled. -/// When true, explicit domains should not be set for that axis. +/// The `free_scales` array indicates which positional aesthetics have independent scales +/// per facet panel. When a position is free, explicit domains should not be set. /// /// The `coord_kind` determines how internal positional aesthetics (pos1, pos2) are /// mapped to Vega-Lite encoding channel names (x/y for cartesian, theta/radius for polar). @@ -219,8 +216,7 @@ fn build_layer_encoding( layer: &crate::plot::Layer, df: &DataFrame, spec: &Plot, - free_x: bool, - free_y: bool, + free_scales: Option<&[crate::plot::ArrayElement]>, coord_kind: CoordKind, ) -> Result> { let mut encoding = serde_json::Map::new(); @@ -244,8 +240,7 @@ fn build_layer_encoding( spec, titled_families: &mut titled_families, primary_aesthetics: &primary_aesthetics, - free_x, - free_y, + free_scales, }; // Get aesthetic context for name transformation @@ -328,6 +323,7 @@ fn apply_faceting( facet: &crate::plot::Facet, facet_df: &DataFrame, scales: &[Scale], + coord_kind: CoordKind, ) { use crate::plot::FacetLayout; @@ -363,7 +359,7 @@ fn apply_faceting( vl_spec.as_object_mut().unwrap().remove("layer"); // Apply scale resolution - apply_facet_scale_resolution(vl_spec, &facet.properties); + apply_facet_scale_resolution(vl_spec, &facet.properties, coord_kind); // Apply additional properties (columns for wrap) apply_facet_properties(vl_spec, &facet.properties, true); @@ -411,7 +407,7 @@ fn apply_faceting( vl_spec.as_object_mut().unwrap().remove("layer"); // Apply scale resolution - apply_facet_scale_resolution(vl_spec, &facet.properties); + apply_facet_scale_resolution(vl_spec, &facet.properties, coord_kind); // Apply additional properties (not columns for grid) apply_facet_properties(vl_spec, &facet.properties, false); @@ -504,69 +500,69 @@ fn apply_facet_ordering(facet_def: &mut Value, scale: Option<&Scale>) { /// Extract free scales from facet properties as a boolean vector /// /// After facet resolution, the `free` property is normalized to a boolean array: -/// - `[true, false]` = free x/theta, fixed y/radius -/// - `[false, true]` = fixed x/theta, free y/radius +/// - `[true, false]` = free pos1, fixed pos2 +/// - `[false, true]` = fixed pos1, free pos2 /// - `[true, true]` = both free /// - `[false, false]` = both fixed (default) /// -/// Returns (free_x, free_y) for Vega-Lite output (position-indexed). -fn get_free_scales(facet: Option<&crate::plot::Facet>) -> (bool, bool) { - let Some(facet) = facet else { - return (false, false); - }; - - let Some(ParameterValue::Array(arr)) = facet.properties.get("free") else { - return (false, false); - }; - - let free_pos1 = arr - .first() - .map(|e| matches!(e, crate::plot::ArrayElement::Boolean(true))) - .unwrap_or(false); - let free_pos2 = arr - .get(1) - .map(|e| matches!(e, crate::plot::ArrayElement::Boolean(true))) - .unwrap_or(false); - - (free_pos1, free_pos2) +/// Returns reference to the free scales array from facet properties. +fn get_free_scales(facet: Option<&crate::plot::Facet>) -> Option<&[crate::plot::ArrayElement]> { + let facet = facet?; + match facet.properties.get("free") { + Some(ParameterValue::Array(arr)) => Some(arr.as_slice()), + _ => None, + } } /// Apply scale resolution to Vega-Lite spec based on facet free property /// /// Maps ggsql free property (boolean array) to Vega-Lite resolve.scale configuration: /// - `[false, false]`: shared scales (Vega-Lite default, no resolve needed) -/// - `[true, false]`: independent x/theta scale, shared y/radius scale -/// - `[false, true]`: shared x/theta scale, independent y/radius scale +/// - `[true, false]`: independent pos1 scale (x or theta), shared pos2 scale +/// - `[false, true]`: shared pos1 scale, independent pos2 scale (y or radius) /// - `[true, true]`: independent scales for both axes -fn apply_facet_scale_resolution(vl_spec: &mut Value, properties: &HashMap) { +/// +/// The channel names depend on coord_kind: +/// - Cartesian: pos1 -> "x", pos2 -> "y" +/// - Polar: pos1 -> "theta", pos2 -> "radius" +fn apply_facet_scale_resolution( + vl_spec: &mut Value, + properties: &HashMap, + coord_kind: CoordKind, +) { let Some(ParameterValue::Array(arr)) = properties.get("free") else { // No free property means fixed/shared scales (Vega-Lite default) return; }; + // Determine channel names based on coord kind + let (pos1_channel, pos2_channel) = match coord_kind { + CoordKind::Cartesian => ("x", "y"), + CoordKind::Polar => ("theta", "radius"), + }; + // Extract booleans from the array (position-indexed) - let free_x = arr + let free_pos1 = arr .first() .map(|e| matches!(e, crate::plot::ArrayElement::Boolean(true))) .unwrap_or(false); - let free_y = arr + let free_pos2 = arr .get(1) .map(|e| matches!(e, crate::plot::ArrayElement::Boolean(true))) .unwrap_or(false); // Apply resolve configuration to Vega-Lite spec - // Note: Vega-Lite uses x/y for output, regardless of coord system - if free_x && free_y { + if free_pos1 && free_pos2 { vl_spec["resolve"] = json!({ - "scale": {"x": "independent", "y": "independent"} + "scale": {pos1_channel: "independent", pos2_channel: "independent"} }); - } else if free_x { + } else if free_pos1 { vl_spec["resolve"] = json!({ - "scale": {"x": "independent"} + "scale": {pos1_channel: "independent"} }); - } else if free_y { + } else if free_pos2 { vl_spec["resolve"] = json!({ - "scale": {"y": "independent"} + "scale": {pos2_channel: "independent"} }); } // If neither is free, don't add resolve (Vega-Lite default is shared) @@ -949,11 +945,11 @@ impl Writer for VegaLiteWriter { // 1. Validate spec self.validate(spec)?; - // 2. Determine if facet free scales should omit x/y domains + // 2. Get free scales array (if any) // When using free scales, Vega-Lite computes independent domains per facet panel. // We must not set explicit domains (from SCALE or COORD) as they would override this. - // The free property is normalized to a boolean array [pos1_free, pos2_free]. - let (free_x, free_y) = get_free_scales(spec.facet.as_ref()); + // The free property is a boolean array [pos1_free, pos2_free, ...]. + let free_scales = get_free_scales(spec.facet.as_ref()); // 3. Determine layer data keys let layer_data_keys: Vec = spec @@ -1011,27 +1007,26 @@ impl Writer for VegaLiteWriter { .map(|p| p.coord.coord_kind()) .unwrap_or(CoordKind::Cartesian); - // 10. Build layers (pass free scale flags and coord kind for domain handling) + // 10. Build layers (pass free scales and coord kind for domain handling) let layers = build_layers( spec, data, &layer_data_keys, &prep.renderers, &prep.prepared, - free_x, - free_y, + free_scales, coord_kind, )?; vl_spec["layer"] = json!(layers); - // 10. Apply projection transforms (pass free scale flags for domain handling) + // 10. Apply projection transforms let first_df = data.get(&layer_data_keys[0]).unwrap(); - apply_project_transforms(spec, first_df, &mut vl_spec, free_x, free_y)?; + apply_project_transforms(spec, first_df, &mut vl_spec)?; // 11. Apply faceting if let Some(facet) = &spec.facet { let facet_df = data.get(&layer_data_keys[0]).unwrap(); - apply_faceting(&mut vl_spec, facet, facet_df, &spec.scales); + apply_faceting(&mut vl_spec, facet, facet_df, &spec.scales, coord_kind); } // 12. Add default theme config (ggplot2-like gray theme) @@ -1136,35 +1131,86 @@ mod tests { // Internal positional names should map to Vega-Lite channel names based on coord kind assert_eq!(map_aesthetic_name("pos1", &ctx, CoordKind::Cartesian), "x"); assert_eq!(map_aesthetic_name("pos2", &ctx, CoordKind::Cartesian), "y"); - assert_eq!(map_aesthetic_name("pos1end", &ctx, CoordKind::Cartesian), "x2"); - assert_eq!(map_aesthetic_name("pos2end", &ctx, CoordKind::Cartesian), "y2"); + assert_eq!( + map_aesthetic_name("pos1end", &ctx, CoordKind::Cartesian), + "x2" + ); + assert_eq!( + map_aesthetic_name("pos2end", &ctx, CoordKind::Cartesian), + "y2" + ); // Non-positional aesthetics pass through directly - assert_eq!(map_aesthetic_name("color", &ctx, CoordKind::Cartesian), "color"); - assert_eq!(map_aesthetic_name("fill", &ctx, CoordKind::Cartesian), "fill"); - assert_eq!(map_aesthetic_name("stroke", &ctx, CoordKind::Cartesian), "stroke"); - assert_eq!(map_aesthetic_name("opacity", &ctx, CoordKind::Cartesian), "opacity"); - assert_eq!(map_aesthetic_name("size", &ctx, CoordKind::Cartesian), "size"); - assert_eq!(map_aesthetic_name("shape", &ctx, CoordKind::Cartesian), "shape"); + assert_eq!( + map_aesthetic_name("color", &ctx, CoordKind::Cartesian), + "color" + ); + assert_eq!( + map_aesthetic_name("fill", &ctx, CoordKind::Cartesian), + "fill" + ); + assert_eq!( + map_aesthetic_name("stroke", &ctx, CoordKind::Cartesian), + "stroke" + ); + assert_eq!( + map_aesthetic_name("opacity", &ctx, CoordKind::Cartesian), + "opacity" + ); + assert_eq!( + map_aesthetic_name("size", &ctx, CoordKind::Cartesian), + "size" + ); + assert_eq!( + map_aesthetic_name("shape", &ctx, CoordKind::Cartesian), + "shape" + ); // Other mapped aesthetics - assert_eq!(map_aesthetic_name("linetype", &ctx, CoordKind::Cartesian), "strokeDash"); - assert_eq!(map_aesthetic_name("linewidth", &ctx, CoordKind::Cartesian), "strokeWidth"); - assert_eq!(map_aesthetic_name("label", &ctx, CoordKind::Cartesian), "text"); + assert_eq!( + map_aesthetic_name("linetype", &ctx, CoordKind::Cartesian), + "strokeDash" + ); + assert_eq!( + map_aesthetic_name("linewidth", &ctx, CoordKind::Cartesian), + "strokeWidth" + ); + assert_eq!( + map_aesthetic_name("label", &ctx, CoordKind::Cartesian), + "text" + ); // Test with polar coord kind - internal positional maps to theta/radius // regardless of the context's user-facing names let polar_ctx = AestheticContext::from_static(&["theta", "radius"], &[]); - assert_eq!(map_aesthetic_name("pos1", &polar_ctx, CoordKind::Polar), "theta"); - assert_eq!(map_aesthetic_name("pos2", &polar_ctx, CoordKind::Polar), "radius"); - assert_eq!(map_aesthetic_name("pos1end", &polar_ctx, CoordKind::Polar), "theta2"); - assert_eq!(map_aesthetic_name("pos2end", &polar_ctx, CoordKind::Polar), "radius2"); + assert_eq!( + map_aesthetic_name("pos1", &polar_ctx, CoordKind::Polar), + "theta" + ); + assert_eq!( + map_aesthetic_name("pos2", &polar_ctx, CoordKind::Polar), + "radius" + ); + assert_eq!( + map_aesthetic_name("pos1end", &polar_ctx, CoordKind::Polar), + "theta2" + ); + assert_eq!( + map_aesthetic_name("pos2end", &polar_ctx, CoordKind::Polar), + "radius2" + ); // Even with custom positional names (e.g., PROJECT y, x TO polar), // internal pos1/pos2 should still map to theta/radius for Vega-Lite let custom_ctx = AestheticContext::from_static(&["y", "x"], &[]); - assert_eq!(map_aesthetic_name("pos1", &custom_ctx, CoordKind::Polar), "theta"); - assert_eq!(map_aesthetic_name("pos2", &custom_ctx, CoordKind::Polar), "radius"); + assert_eq!( + map_aesthetic_name("pos1", &custom_ctx, CoordKind::Polar), + "theta" + ); + assert_eq!( + map_aesthetic_name("pos2", &custom_ctx, CoordKind::Polar), + "radius" + ); } #[test] diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 47eb0c24..cb49722b 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -9,19 +9,16 @@ use serde_json::{json, Value}; /// Apply projection transformations to the spec and data /// Returns (possibly transformed DataFrame, possibly modified spec) -/// free_x/free_y indicate whether facets have independent scales (affects domain application) pub(super) fn apply_project_transforms( spec: &Plot, data: &DataFrame, vl_spec: &mut Value, - free_x: bool, - free_y: bool, ) -> Result> { if let Some(ref project) = spec.project { // Apply coord-specific transformations let result = match project.coord.coord_kind() { CoordKind::Cartesian => { - apply_cartesian_project(project, vl_spec, free_x, free_y)?; + apply_cartesian_project(project, vl_spec)?; None } CoordKind::Polar => Some(apply_polar_project(project, spec, data, vl_spec)?), @@ -58,12 +55,7 @@ fn apply_clip_to_layers(vl_spec: &mut Value, clip: bool) { } /// Apply Cartesian projection properties -fn apply_cartesian_project( - _project: &Projection, - _vl_spec: &mut Value, - _free_x: bool, - _free_y: bool, -) -> Result<()> { +fn apply_cartesian_project(_project: &Projection, _vl_spec: &mut Value) -> Result<()> { // ratio - not yet implemented Ok(()) } @@ -279,4 +271,3 @@ fn apply_polar_radius_range(encoding: &mut Value, inner: f64) -> Result<()> { Ok(()) } - From b012f07621efd9b049f03ff0db1135c664606594 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Thu, 26 Feb 2026 13:14:48 +0100 Subject: [PATCH 15/28] update docs --- doc/ggsql.xml | 3 - doc/syntax/clause/draw.qmd | 14 +-- doc/syntax/clause/facet.qmd | 12 ++- doc/syntax/clause/label.qmd | 2 +- doc/syntax/clause/project.qmd | 26 +++++ doc/syntax/clause/scale.qmd | 19 ++++ doc/syntax/clause/visualise.qmd | 9 +- doc/syntax/coord/cartesian.qmd | 40 +++++++ doc/syntax/coord/polar.qmd | 102 ++++++++++++++++++ doc/syntax/index.qmd | 22 ++++ .../{Z_facetting.qmd => Z_faceting.qmd} | 0 11 files changed, 234 insertions(+), 15 deletions(-) create mode 100644 doc/syntax/coord/cartesian.qmd create mode 100644 doc/syntax/coord/polar.qmd rename doc/syntax/scale/aesthetic/{Z_facetting.qmd => Z_faceting.qmd} (100%) diff --git a/doc/ggsql.xml b/doc/ggsql.xml index 531aef80..d244edd8 100644 --- a/doc/ggsql.xml +++ b/doc/ggsql.xml @@ -592,9 +592,6 @@ - - - diff --git a/doc/syntax/clause/draw.qmd b/doc/syntax/clause/draw.qmd index b502844f..0aab4cfd 100644 --- a/doc/syntax/clause/draw.qmd +++ b/doc/syntax/clause/draw.qmd @@ -7,7 +7,7 @@ title: "Create layers with `DRAW`" ## Clause syntax The `DRAW` clause takes a number of subclauses, all of them optional if the `VISUALISE` clause provides a global mapping and data source. -```sql +```ggsql DRAW MAPPING , ... FROM REMAPPING , ... @@ -20,7 +20,7 @@ DRAW The only required part is the layer type immediately following the `DRAW` clause, which specifies the type of layer to draw, e.g. `point` or `histogram`. It defines how the remaining settings are interpreted. The [main syntax page](../index.qmd#layers) has a list of all available layer types ### `MAPPING` -```sql +```ggsql MAPPING , ... FROM ``` The `MAPPINGS` clause define how data from the dataset are related to visual aesthetics or statistical properties. Multiple mappings can be provided by separating them with a comma. Mapped aesthetics are always scaled by their respective scale. This means that if you map the value 'red' to fill, then fill will not take the color red, but whatever the scale decides should represent the string 'red'. Layer mappings are merged with the global mapping from the `VISUALISE` clause with the one in the layer taking precedence. This means that it is not necessary to provide any mappings in the `DRAW` clause if sufficient global mappings are provided. @@ -54,7 +54,7 @@ A layer may use a data source different than the global data by appending a `FRO * *Filepath*: If a string is provided (single quoted), it is assumed to point to a file that can be read directly by the backend. ### `REMAPPING` -```sql +```ggsql REMAPPING , ... ``` @@ -63,7 +63,7 @@ Some layer types like histogram runs the data through a statistical transformati Remappings have to be explicit since the property name never coincide with an aesthetic. Further, remappings must always map to a visual aesthetic since the statistical properties have already been consumed. ### `SETTING` -```sql +```ggsql SETTING => , ... ``` @@ -73,14 +73,14 @@ The `SETTING` clause can be used for to different things: * *Setting aesthetics*: If you wish to set a specific aesthetic to a literal value, e.g. 'red' (as in the color red) then you can do so in the `SETTING` clause. Aesthetics that are set will not go through a scale but will use the provided value as-is. You cannot set an aesthetic to a column, only to a scalar literal value. ### `FILTER` -```sql +```ggsql FILTER ``` You may not want to use all data provided from the data source in the layer. You can limit the data to plot with the `FILTER` clause. The content of `condition` is used directly in a `WHERE` clause when querying the backend for the layer data, so whatever type of expression you database backend supports there will work. ### `PARTITION BY` -```sql +```ggsql PARTITION BY , ... ``` @@ -89,7 +89,7 @@ During drawing the records in the layer data are grouped by all the discrete dat Often the implicit grouping from the aesthetic mapping is enough, e.g. mapping a discrete value to colour will create one line per colour, but sometimes you need a grouping not reflected in the aesthetic mapping. In that case you can use the `PARTITION BY` clause to define data columns used for grouping in addition to the ones from the mapping. ### `ORDER BY` -```sql +```ggsql ORDER BY , ... ``` diff --git a/doc/syntax/clause/facet.qmd b/doc/syntax/clause/facet.qmd index c6b08b46..6945386a 100644 --- a/doc/syntax/clause/facet.qmd +++ b/doc/syntax/clause/facet.qmd @@ -15,9 +15,17 @@ FACET BY The first `column` is mandatory. It names a column in the layer data that will be used for splitting the data. If the layer data does not contain the column the behavior for that layer depends on the `missing` parameter of the facet. ### `BY` +```ggsql +BY +``` + The optional `BY` clause is used to define an additional column to split the data by. If it is missing the small multiples are laid out in a grid with the facet panels filling the cells in a row-wise fashion. If `BY` is present then the categories of the first `column` defines the rows of the grid and the categories of the second `column` the columns of the grid. Each multiple is then positioned according to that. ### `SETTING` +```ggsql +SETTING => , ... +``` + This clause behaves much like the `SETTINGS` clause in `DRAW`, in that it allows you to fine-tune specific behavior of the faceting. The following parameters exist: * `free`: Controls whether the positional scales are independent across the small multiples. Permissible values are: @@ -29,7 +37,7 @@ This clause behaves much like the `SETTINGS` clause in `DRAW`, in that it allows * `ncol`: The number of panel columns to use when faceting by a single variable. Default is 3 when fewer than 6 categories are present, 4 when fewer than 12 categeries are present and otherwise 5. When the `BY`-clause is used to set a second faceting variable, the `ncol` setting is not allowed. There is no `nrow` setting as this is derived from the number of panels and the `ncol` setting. ### Facet variables as aesthetics -When you apply faceting to a plot you are creating new aesthetics you can control. For 1-dimensional faceting (no `BY` clause) the aesthetic is called `panel` and for 2-dimensional faceting the aesthetics are called `row` and `column`. You can read more about these aesthetics in [their documentation](../scale/aesthetic/Z_facetting.qmd) +When you apply faceting to a plot you are creating new aesthetics you can control. For 1-dimensional faceting (no `BY` clause) the aesthetic is called `panel` and for 2-dimensional faceting the aesthetics are called `row` and `column`. You can read more about these aesthetics in [their documentation](../scale/aesthetic/Z_faceting.qmd) ### Customizing facet strip labels To customize facet strip labels (e.g., renaming categories), use the `RENAMING` clause on the facet scale: @@ -39,4 +47,4 @@ FACET region SCALE panel RENAMING 'N' => 'North', 'S' => 'South' ``` -See the [facet scale documentation](../scale/aesthetic/Z_facetting.qmd) for more details on label customization. +See the [facet scale documentation](../scale/aesthetic/Z_faceting.qmd) for more details on label customization. diff --git a/doc/syntax/clause/label.qmd b/doc/syntax/clause/label.qmd index 69415ca0..1770ff6e 100644 --- a/doc/syntax/clause/label.qmd +++ b/doc/syntax/clause/label.qmd @@ -7,7 +7,7 @@ The `LABEL` clause is one of the simpler clauses in ggsql an allow you to overri ## Clause syntax The `LABEL` clause takes one or more labeling settings. -```sql +```ggsql LABEL => , ... ``` diff --git a/doc/syntax/clause/project.qmd b/doc/syntax/clause/project.qmd index c4d6bcf7..427a716d 100644 --- a/doc/syntax/clause/project.qmd +++ b/doc/syntax/clause/project.qmd @@ -1,3 +1,29 @@ --- title: "Control the coordinate system with `PROJECT`" --- + +The `PROJECT` clause defines the projection of the plot, that is, how abstract positional aesthetics are translated (projected) onto the plane defined by the screen/paper where the plot is viewed on. + +## Clause syntax +The `PROJECT` syntax contains a number of subclauses + +```ggsql +PROJECT , ... TO + SETTING => , ... +``` + +The comma-separated list of `aesthetic` names are optional but allows you to define the names of the positional aesthetics in the plot. If omitted, the default aesthetic names of the coordinate system is used. The order given matters as the first name is used for the primary aesthetic, the second name for the secondary aesthetic and so on. For instance, using `PROJECT y, x TO cartesian` will flip the plot as anything mapped to `y` will now relate to the horizontal axis, and anything mapped to `x` will relate to the vertical axis. Note that it is not allowed to use the name of already established aesthetics as positional aesthetics, e.g. `PROJECT fill, stroke TO polar` is not allowed. + +### `TO` +```ggsql +TO , ... +``` + +The `TO` clause is required and is followed by the name of the coordinate system. The coordinate system provides default names for the positional aesthetics and is responsible for how to translate values mapped to these onto the plane defined by the screen or paper. + +### `SETTING` +```ggsql +SETTING => , ... +``` + +This clause behaves much like the `SETTINGS` clause in `DRAW`, in that it allows you to fine-tune specific behavior of the projection. The specific coordinate system defines it's own valid settings. Consult the [coord documentations](../index.qmd#coordinate-systems) to read more. diff --git a/doc/syntax/clause/scale.qmd b/doc/syntax/clause/scale.qmd index 25115710..7dbac50b 100644 --- a/doc/syntax/clause/scale.qmd +++ b/doc/syntax/clause/scale.qmd @@ -26,18 +26,37 @@ Read more about each type at their dedicated documentation. You do not have to s You *must* specify an aesthetic so that the scale knows which mapping it belongs to. For positional aesthetics you will provide the base name (`x` or `y`) even though you are mapping to e.g. `xmin`. Creating a scale for `colour` (or `color`) will create a scale for both fill and stroke colour based on the settings. ### `FROM` +```ggsql +FROM +``` + The `FROM` clause defines the input range of the scale, i.e. the values the scale translates from. If not provided, it will be deduced from the data as the range that covers all mapped data. For discrete scales the input range is defined as an array of all known values to the scale. Values from the data not present in the input range will be `null`'ed by the scale. For continuous and binned scales this is an array with two elements: the lower and upper boundaries of the scale. Either of these can be `null` in which case that value will be determined by the data (e.g. a range of `[0, null]` will go from 0 to the maximum value in the data). Identity scales do not have an input range. ### `TO` +```ggsql +TO +``` The `TO` clause defines the output range of the scale, i.e. what the data is translated to. It can either be an array of values or the name of a known palette. Read more under the documentation for the specific scales. ### `VIA` +```ggsql +VIA +``` + The `VIA` clause defines a transform which is applied to the data before mapping it to the output range. While transforms are often understood as mathematical transforms, in ggsql it also defines casting of input data. E.g. the `integer` transform cast all input to integer before mapping. Transforms also takes care of creating breaks that are meaningful for the specific transform, e.g. in the case of the log10 transform where breaks are created to fit the power of 10. Different transforms are available to different scale types. ### `SETTING` +```ggsql +SETTING => , ... +``` + This clause behaves much like the `SETTINGS` clause in `DRAW`, in that it allows you to fine-tune specific behavior of the scale. Permissible settings depends on the scale type and are documented there. ### `RENAMING` +```ggsql +RENAMING => , ... +``` + This clause works much like the `LABEL` clause but works on the break names of the scale. The general syntax is that you provide the name of the break on the left and what it should appear as on the right, e.g `'adelie' => 'Pygoscelis adeliae'`. The clause is understood as a look-up table in the sense that if you provide a renaming for a break that doesn't appear in the scale then nothing will happen and if a break exist but doesn't have a renaming defined it will go through unaltered. To suppress the label of a specific break you can rename it to `null`, e.g. `'adelie' => null`. This will not remove the break, only the label. #### Break formatting diff --git a/doc/syntax/clause/visualise.qmd b/doc/syntax/clause/visualise.qmd index 7c486bb5..aa6c12d6 100644 --- a/doc/syntax/clause/visualise.qmd +++ b/doc/syntax/clause/visualise.qmd @@ -7,7 +7,7 @@ The `VISUALISE` (or `VISUALIZE`) clause marks the beginning of a ggsql visualisa ## Clause syntax The `VISUALISE` clause is quite simple and doesn't take any additional required parameters. You can, however, use it to define global mappings and a global data source (if the earlier query didn't end in a `SELECT`). -```sql +```ggsql VISUALISE , ... FROM ``` @@ -36,7 +36,12 @@ A `property` is a value used by the statistical transformation done by the layer Layers only inherit the aesthetics and properties they support from the global mapping. The documentation for each layer type provides an overview of the aesthetics and properties available for them. -When specifying a global data source with `FROM ` the `data-source` can take one of two different forms: +### `FROM` +```ggsql +FROM +``` + +When specifying a global data source the `data-source` can take one of two different forms: * *Table/CTE*: If providing an unquoted identifier it is assumed that the data is available in the backend, either as a CTE defined in the pre-query, or as a proper table in the database. * *Filepath*: If a string is provided (single quoted), it is assumed to point to a file that can be read directly by the backend. diff --git a/doc/syntax/coord/cartesian.qmd b/doc/syntax/coord/cartesian.qmd new file mode 100644 index 00000000..66c9db6f --- /dev/null +++ b/doc/syntax/coord/cartesian.qmd @@ -0,0 +1,40 @@ +--- +title: Cartesian +--- + +The cartesian coordinate system is the most well-known and the default for ggsql. It maps the primary positional aesthetic along a horizontal axis and the secondary along a perpendicular vertical axis. + +## Default aesthetics +The cartesian coordinate system has the following default positional aesthetics which will be used if no others have been provided: + +* **Primary**: `x` +* **Secondary**: `y` + +Users can provide their own aesthetic names if needed, e.g. + +```ggsql +PROJECT p, q TO cartesian +``` + +## Settings +* `clip`: Should data be removed if it appears outside the bounds of the coordinate system. Defaults to `true` +* `ratio`: The aspect ratio between the steps on the vertical and horizontal axis. Defaults to `null` (no enforced aspect ratio) + +## Examples + +### Use custom positional aesthetic names + +```{ggsql} +VISUALISE bill_len AS p, bill_dep AS q FROM ggsql:penguins +DRAW point +PROJECT p, q TO cartesian +``` + +### Flip the x and y axes + +```{ggsql} +VISUALISE bill_len AS x, bill_dep AS y FROM ggsql:penguins +DRAW point +PROJECT y, x TO cartesian +``` + diff --git a/doc/syntax/coord/polar.qmd b/doc/syntax/coord/polar.qmd new file mode 100644 index 00000000..b2fd6085 --- /dev/null +++ b/doc/syntax/coord/polar.qmd @@ -0,0 +1,102 @@ +--- +title: Polar +--- + +The polar coordinate system interprets its primary aesthetic as the angular position relative to the center, and the secondary aesthetic as the distance from the center. It is most often used for pie-charts, radar plots + +## Default aesthetics +The polar coordinate system has the following default positional aesthetics which will be used if no others have been provided: + +* **Primary**: `theta` (angular position) +* **Secondary**: `radius` (distance from center) + +Users can provide their own aesthetic names if needed. For example, if using `x` and `y` aesthetics: + +```ggsql +PROJECT y, x TO polar +``` + +This maps `y` to theta (angle) and `x` to radius. This is useful when converting from a cartesian coordinate system without editing all the mappings. + +## Settings +* `clip`: Should data be removed if it appears outside the bounds of the coordinate system. Defaults to `true` +* `start`: The starting angle in degrees for the theta scale. Controls where "0" on the angular axis begins. Defaults to `0` (12 o'clock position). + - `0` = 12 o'clock position (top) + - `90` = 3 o'clock position (right) + - `-90` or `270` = 9 o'clock position (left) + - `180` = 6 o'clock position (bottom) +* `end`: The ending angle in degrees for the theta scale. Defaults to `start + 360` (a full circle). Use this with `start` to create partial polar plots like gauge charts or half-circle visualizations. +* `inner`: The inner radius as a proportion (0 to 1) of the outer radius. Defaults to `0` (no hole). Setting this creates a donut chart where the inner portion is empty. + - `0` = full pie (no hole) + - `0.3` = donut with 30% hole + - `0.5` = donut with 50% hole + +## Examples + +### Pie chart using theta/radius aesthetics +```{ggsql} +VISUALISE species AS fill FROM ggsql:penguins +DRAW bar +PROJECT TO polar +``` + +### Pie chart using x/y aesthetics +```{ggsql} +VISUALISE body_mass AS x, species AS y FROM ggsql:penguins +DRAW bar +PROJECT y, x TO polar +``` + +### Pie chart starting at 3 o'clock +```{ggsql} +VISUALISE species AS fill FROM ggsql:penguins +DRAW bar +PROJECT TO polar SETTING start => 90 +``` + +### Pie chart starting at 9 o'clock +```{ggsql} +VISUALISE species AS fill FROM ggsql:penguins +DRAW bar +PROJECT TO polar SETTING start => -90 +``` + +### Half-circle gauge chart +```{ggsql} +VISUALISE species AS fill FROM ggsql:penguins +DRAW bar +PROJECT TO polar SETTING start => -90, end => 90 +``` +This creates a gauge chart spanning from the 9 o'clock to 3 o'clock position (a 180° arc at the top). + +### Three-quarter pie chart +```{ggsql} +VISUALISE species AS fill FROM ggsql:penguins +DRAW bar +PROJECT TO polar SETTING end => 270 +``` +This creates a pie chart using only 270° (three-quarters of a circle), starting from 0° (12 o'clock) and ending at 270° (9 o'clock). + +### Donut chart with 50% hole +```{ggsql} +VISUALISE species AS fill FROM ggsql:penguins +DRAW bar +PROJECT TO polar SETTING inner => 0.5 +``` +This creates a donut chart where the inner 50% of the radius is empty, leaving a ring-shaped visualization. + +### Donut chart with 30% hole +```{ggsql} +VISUALISE species AS fill FROM ggsql:penguins +DRAW bar +PROJECT TO polar SETTING inner => 0.3 +``` +This creates a donut chart with a smaller hole (30% of the radius). + +### Half-circle donut chart +```{ggsql} +VISUALISE species AS fill FROM ggsql:penguins +DRAW bar +PROJECT TO polar SETTING start => -90, end => 90, inner => 0.5 +``` +This combines the `start`, `end`, and `inner` settings to create a half-circle donut chart (gauge style) spanning from 9 o'clock to 3 o'clock with a 50% hole. diff --git a/doc/syntax/index.qmd b/doc/syntax/index.qmd index cbcddd26..c5de2e56 100644 --- a/doc/syntax/index.qmd +++ b/doc/syntax/index.qmd @@ -28,5 +28,27 @@ There are many different layers to choose from when visualising your data. Some - [`boxplot`](layer/boxplot.qmd) displays continuous variables as 5-number summaries ## Scales +A scale is responsible for translating a data value to an aesthetic literal, e.g. a specific color for the fill aesthetic, or a radius in points for the size aesthetic. A scale is a combination of a specific aesthetic and a scale type + +### Aesthetics +- [Position](scale/aesthetic/0_position.qmd) aesthetics are those aesthetics realted to the spatial location of the data in the coordinate system. +- [Color](scale/aesthetic/1_color.qmd) aesthetics are related to the color of fill and stroke +- [`opacity`](scale/aesthetic/2_opacity.qmd) is the aesthetic that determines the opacity of the color +- [`linetype`](scale/aesthetic/linetype.qmd) governs the stroke pattern of strokes +- [`linewidth`](scale/aesthetic/linewidth.qmd) determines the width of strokes +- [`shape`](scale/aesthetic/shape.qmd) determines the shape of points +- [`size`](scale/aesthetic/size.qmd) governs the radius of points +- [Faceting](scale/aesthetic/Z_faceting.qmd) aesthetics are used to determine which facet panel the data belongs to + +### Scale types +- [`continuous`](scale/type/continuous.qmd) scales translates a continuous input to a continuous output +- [`discrete`](scale/type/discrete.qmd) scales translates discrete input to a discrete output +- [`binned`](scale/type/binned.qmd) scales translate continuous input to an ordered discrete output by binning the data +- [`ordinal`](scale/type/ordinal.qmd) scales translate discrete input to an ordered discrete output by enforcing an ordering to the input +- [`identity`](scale/type/identity.qmd) scales passes the data through unchanged ## Coordinate systems +The coordinate system defines how the abstract positional aesthetics are projected onto the screen or paper where the final plot appears. As such, it has great influence over the final look of the plot. + +- [`cartesian`](coord/cartesian.qmd) is the classic coordinate system consisting of two perpendicular axes, one being horizontal and one being vertical +- [`polar`](coord/polar.qmd) interprets the primary position as the angular location relative to the center and the secondary position as the distance (radius) from the center, and this creates a circular coordinate system diff --git a/doc/syntax/scale/aesthetic/Z_facetting.qmd b/doc/syntax/scale/aesthetic/Z_faceting.qmd similarity index 100% rename from doc/syntax/scale/aesthetic/Z_facetting.qmd rename to doc/syntax/scale/aesthetic/Z_faceting.qmd From fe19be617b6c825e14fc08450ace117f51f21456 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Thu, 26 Feb 2026 14:12:34 +0100 Subject: [PATCH 16/28] delete erroneous file commit --- doc/examples_files/execute-results/html.json | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 doc/examples_files/execute-results/html.json diff --git a/doc/examples_files/execute-results/html.json b/doc/examples_files/execute-results/html.json deleted file mode 100644 index 23b610e9..00000000 --- a/doc/examples_files/execute-results/html.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "hash": "2d5afd753380715e5f581381e5858f0a", - "result": { - "engine": "jupyter", - "markdown": "---\ntitle: Examples\n---\n\nThis document demonstrates various ggsql features with runnable examples using CSV files.\n\n\n\n\n\n\n\n\n\n## Basic Visualizations\n\n### Simple Scatter Plot\n\n::: {#dc3ddd64 .cell execution_count=5}\n``` {.ggsql .cell-code}\nSELECT x, y FROM 'data.csv'\nVISUALISE x, y\nDRAW point\n```\n\n::: {.cell-output .cell-output-display execution_count=5}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n::: {#9c02433f .cell execution_count=6}\n``` {.ggsql .cell-code}\nVISUALISE bill_len AS x, bill_dep AS y, species AS color FROM ggsql:penguins\nDRAW point\n```\n\n::: {.cell-output .cell-output-display execution_count=6}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Line Chart with Date Scale\n\n::: {#e8ae49c3 .cell execution_count=7}\n``` {.ggsql .cell-code}\nSELECT sale_date, revenue FROM 'sales.csv'\nWHERE category = 'Electronics'\nVISUALISE sale_date AS x, revenue AS y\nDRAW line\nSCALE x VIA date\nLABEL \n title => 'Electronics Revenue Over Time', \n x => 'Date', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=7}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n::: {#a557d7df .cell execution_count=8}\n``` {.ggsql .cell-code}\nSELECT * FROM ggsql:airquality\nVISUALISE Date AS x\nDRAW line MAPPING Ozone AS y, 'Ozone' AS color\nDRAW line MAPPING Temp AS y, 'Temp' AS color\n```\n\n::: {.cell-output .cell-output-display execution_count=8}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Bar Chart by Category\n\n::: {#6d115eac .cell execution_count=9}\n``` {.ggsql .cell-code}\nSELECT category, SUM(revenue) as total\nFROM 'sales.csv'\nGROUP BY category\nVISUALISE category AS x, total AS y, category AS fill\nDRAW bar\nLABEL \n title => 'Total Revenue by Category', \n x => 'Category', \n y => 'Total Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=9}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Line chart with multiple lines with same aesthetics\n\n::: {#6879feb6 .cell execution_count=10}\n``` {.ggsql .cell-code}\nSELECT * FROM 'sales.csv'\nVISUALISE sale_date AS x, revenue AS y\nDRAW line\n PARTITION BY category\n```\n\n::: {.cell-output .cell-output-display execution_count=10}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Statistical Transformations\n\nStatistical transformations automatically compute aggregations for certain geom types.\n\n### Histogram\n\nWhen using `DRAW histogram`, ggsql automatically bins continuous data and counts occurrences. You only need to specify the x aesthetic:\n\n::: {#cfbc0caf .cell execution_count=11}\n``` {.ggsql .cell-code}\nSELECT revenue FROM 'sales.csv'\nVISUALISE revenue AS x\nDRAW histogram\nLABEL\n title => 'Revenue Distribution',\n x => 'Revenue ($)',\n y => 'Count'\n```\n\n::: {.cell-output .cell-output-display execution_count=11}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Bar with Automatic Count\n\nWhen using `DRAW bar` without a y aesthetic, ggsql automatically counts occurrences of each x value:\n\n::: {#4094c5ed .cell execution_count=12}\n``` {.ggsql .cell-code}\nSELECT category FROM 'sales.csv'\nVISUALISE category AS x\nDRAW bar\nLABEL\n title => 'Sales Count by Category',\n x => 'Category',\n y => 'Count'\n```\n\n::: {.cell-output .cell-output-display execution_count=12}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Bar with Weighted Count\n\nYou can also specify a weight aesthetic to sum values instead of counting:\n\n::: {#a7adf496 .cell execution_count=13}\n``` {.ggsql .cell-code}\nSELECT category, revenue FROM 'sales.csv'\nVISUALISE category AS x, revenue AS weight\nDRAW bar\nLABEL\n title => 'Total Revenue by Category',\n x => 'Category',\n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=13}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Histogram Settings\n\nControl histogram binning with `SETTING` options:\n\n**Custom number of bins:**\n\n::: {#d93c32c3 .cell execution_count=14}\n``` {.ggsql .cell-code}\nSELECT revenue FROM 'sales.csv'\nVISUALISE revenue AS x\nDRAW histogram \n SETTING bins => 10\nLABEL\n title => 'Revenue Distribution (10 bins)',\n x => 'Revenue ($)',\n y => 'Count'\n```\n\n::: {.cell-output .cell-output-display execution_count=14}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n**Explicit bin width** (overrides bins):\n\n::: {#48894ca8 .cell execution_count=15}\n``` {.ggsql .cell-code}\nSELECT revenue FROM 'sales.csv'\nVISUALISE revenue AS x\nDRAW histogram \n SETTING binwidth => 500\nLABEL\n title => 'Revenue Distribution (500 bin width)',\n x => 'Revenue ($)',\n y => 'Count'\n```\n\n::: {.cell-output .cell-output-display execution_count=15}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n**Left-closed intervals** (default is right-closed `(a, b]`):\n\n::: {#3fd2a40f .cell execution_count=16}\n``` {.ggsql .cell-code}\nSELECT revenue FROM 'sales.csv'\nVISUALISE revenue AS x\nDRAW histogram \n SETTING bins => 8, closed => 'left'\nLABEL\n title => 'Revenue Distribution (left-closed intervals)',\n x => 'Revenue ($)',\n y => 'Count'\n```\n\n::: {.cell-output .cell-output-display execution_count=16}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Histogram Remapping\n\nHistogram computes several statistics: `bin`, `bin_end`, `count`, and `density`. By default, `count` is mapped to `y`. Use `REMAPPING` to show density (proportion) instead:\n\n::: {#c46b448b .cell execution_count=17}\n``` {.ggsql .cell-code}\nSELECT revenue FROM 'sales.csv'\nVISUALISE revenue AS x\nDRAW histogram \n REMAPPING density AS y\nLABEL\n title => 'Revenue Density Distribution',\n x => 'Revenue ($)',\n y => 'Density'\n```\n\n::: {.cell-output .cell-output-display execution_count=17}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Bar Width Setting\n\nControl bar width with the `width` setting (0-1 scale, default 0.9):\n\n::: {#29d1dd7a .cell execution_count=18}\n``` {.ggsql .cell-code}\nSELECT category FROM 'sales.csv'\nVISUALISE category AS x\nDRAW bar \n SETTING width => 0.5\nLABEL\n title => 'Sales Count (Narrow Bars)',\n x => 'Category',\n y => 'Count'\n```\n\n::: {.cell-output .cell-output-display execution_count=18}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Bar Remapping\n\nBar computes `count` and `proportion` statistics. By default, `count` is mapped to `y`. Use `REMAPPING` to show proportions instead:\n\n::: {#ac33db33 .cell execution_count=19}\n``` {.ggsql .cell-code}\nSELECT category FROM 'sales.csv'\nVISUALISE category AS x\nDRAW bar \n REMAPPING proportion AS y\nLABEL\n title => 'Sales Proportion by Category',\n x => 'Category',\n y => 'Proportion'\n```\n\n::: {.cell-output .cell-output-display execution_count=19}\n```{=html}\n
\n\n```\n:::\n:::\n\n\nCombine with `weight` to show weighted proportions:\n\n::: {#e235be26 .cell execution_count=20}\n``` {.ggsql .cell-code}\nSELECT category, revenue FROM 'sales.csv'\nVISUALISE category AS x, revenue AS weight\nDRAW bar \n REMAPPING proportion AS y\nLABEL\n title => 'Revenue Share by Category',\n x => 'Category',\n y => 'Share of Total Revenue'\n```\n\n::: {.cell-output .cell-output-display execution_count=20}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Multiple Layers\n\n### Line with Points\n\n::: {#d0f97f81 .cell execution_count=21}\n``` {.ggsql .cell-code}\nSELECT date, value FROM 'timeseries.csv'\nVISUALISE date AS x, value AS y\nDRAW line \n SETTING color => 'blue'\nDRAW point \n SETTING size => 6, color => 'red'\nSCALE x VIA date\nLABEL \n title => 'Time Series with Points', \n x => 'Date', \n y => 'Value'\n```\n\n::: {.cell-output .cell-output-display execution_count=21}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Colored Lines by Category\n\n::: {#6bd7044c .cell execution_count=22}\n``` {.ggsql .cell-code}\nSELECT date, value, category FROM 'metrics.csv'\nVISUALISE date AS x, value AS y, category AS color\nDRAW line\nSCALE x VIA date\nLABEL \n title => 'Metrics by Category', \n x => 'Date', \n y => 'Value'\n```\n\n::: {.cell-output .cell-output-display execution_count=22}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Faceting\n\n### Facet by Region\n\n::: {#72b8a915 .cell execution_count=23}\n``` {.ggsql .cell-code}\nSELECT sale_date, revenue, region FROM 'sales.csv'\nWHERE category = 'Electronics'\nVISUALISE sale_date AS x, revenue AS y\nDRAW line\nSCALE x VIA date\nFACET region\nLABEL \n title => 'Electronics Sales by Region', \n x => 'Date', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=23}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Facet Grid\n\n::: {#4ef173d8 .cell execution_count=24}\n``` {.ggsql .cell-code}\nSELECT\n DATE_TRUNC('month', sale_date) as month,\n region,\n category,\n SUM(revenue) as total_revenue,\n SUM(quantity) * 100 as total_quantity_scaled\nFROM 'sales.csv'\nGROUP BY DATE_TRUNC('month', sale_date), region, category\nVISUALISE month AS x\nDRAW line \n MAPPING total_revenue AS y\n SETTING color => 'steelblue'\nDRAW point \n MAPPING total_revenue AS y\n SETTING size => 6, color => 'darkblue'\nDRAW line \n MAPPING total_quantity_scaled AS y\n SETTING color => 'coral'\nDRAW point \n MAPPING total_quantity_scaled AS y\n SETTING size => 6, color => 'orangered'\nSCALE x VIA date\nFACET region BY category\nLABEL \n title => 'Monthly Revenue and Quantity by Region and Category', \n x => 'Month', \n y => 'Value'\n```\n\n::: {.cell-output .cell-output-display execution_count=24}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Coordinate Transformations\n\n### Flipped Coordinates (Horizontal Bar Chart)\n\n::: {#b2ed1808 .cell execution_count=25}\n``` {.ggsql .cell-code}\nSELECT region, SUM(revenue) as total\nFROM 'sales.csv'\nGROUP BY region\nORDER BY total DESC\nVISUALISE region AS x, total AS y, region AS fill\nDRAW bar\nCOORD flip\nLABEL \n title => 'Total Revenue by Region', \n x => 'Region', \n y => 'Total Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=25}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Cartesian with Axis Limits\n\n::: {#d61101b0 .cell execution_count=26}\n``` {.ggsql .cell-code}\nSELECT x, y FROM 'data.csv'\nVISUALISE x, y\nDRAW point \n SETTING size => 4, color => 'blue'\nCOORD cartesian \n SETTING xlim => [0, 60], ylim => [0, 70]\nLABEL \n title => 'Scatter Plot with Custom Axis Limits', \n x => 'X', \n y => 'Y'\n```\n\n::: {.cell-output .cell-output-display execution_count=26}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Pie Chart with Polar Coordinates\n\n::: {#9ebf05d9 .cell execution_count=27}\n``` {.ggsql .cell-code}\nSELECT category, SUM(revenue) as total\nFROM 'sales.csv'\nGROUP BY category\nVISUALISE total AS y, category AS fill\nDRAW bar\nCOORD polar\nLABEL \n title => 'Revenue Distribution by Category'\n```\n\n::: {.cell-output .cell-output-display execution_count=27}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Constant Mappings\n\nConstants can be used in both the VISUALISE clause (global) and MAPPING clauses (per-layer) to set fixed aesthetic values.\n\n### Different Constants Per Layer\n\nEach layer can have its own constant value, creating a legend showing all values:\n\n::: {#fdbd18a1 .cell execution_count=28}\n``` {.ggsql .cell-code}\nWITH monthly AS (\n SELECT\n DATE_TRUNC('month', sale_date) as month,\n category,\n SUM(revenue) as revenue\n FROM 'sales.csv'\n GROUP BY DATE_TRUNC('month', sale_date), category\n)\nVISUALISE month AS x, revenue AS y\nDRAW line \n MAPPING 'Electronics' AS color FROM monthly \n FILTER category = 'Electronics'\nDRAW line \n MAPPING 'Clothing' AS color FROM monthly \n FILTER category = 'Clothing'\nDRAW line \n MAPPING 'Furniture' AS color FROM monthly \n FILTER category = 'Furniture'\nSCALE x VIA date\nLABEL \n title => 'Revenue by Category (Constant Colors)', \n x => 'Month', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=28}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Mixed Constants and Columns\n\nWhen mixing constant and column mappings for the same aesthetic, the axis/legend label uses the first non-constant column name:\n\n::: {#7babd600 .cell execution_count=29}\n``` {.ggsql .cell-code}\nSELECT date, value, category FROM 'metrics.csv'\nVISUALISE date AS x\nDRAW line \n MAPPING value AS y, category AS color\nDRAW point \n MAPPING 120 AS y \n SETTING size => 3, color => 'blue'\nSCALE x VIA date\nLABEL \n title => 'Metrics with Threshold Line', \n x => 'Date'\n```\n\n::: {.cell-output .cell-output-display execution_count=29}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Numeric Constants\n\nNumbers work as constants too:\n\n::: {#79bb020f .cell execution_count=30}\n``` {.ggsql .cell-code}\nSELECT x, y FROM 'data.csv'\nVISUALISE x, y\nDRAW point \n SETTING color => 'blue', size => 10\nDRAW point \n SETTING color => 'red', size => 5 \n FILTER y > 50\nLABEL \n title => 'Scatter Plot with Constant Sizes'\n```\n\n::: {.cell-output .cell-output-display execution_count=30}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Layer filtering\n\n### Filter one layer\n\n::: {#2045bb2b .cell execution_count=31}\n``` {.ggsql .cell-code}\nSELECT date, value FROM 'timeseries.csv'\nVISUALISE date AS x, value AS y\nDRAW line \n SETTING color => 'blue'\nDRAW point \n SETTING color => 'red', size => 6\n FILTER value < 130\nSCALE x VIA date\nLABEL \n title => 'Time Series with Points', \n x => 'Date', \n y => 'Value'\n```\n\n::: {.cell-output .cell-output-display execution_count=31}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Layer ordering\n\n### ORDER BY in a layer\n\nUse `ORDER BY` to ensure data is sorted correctly within a layer. This is especially important for line charts where the order of points affects the visual path:\n\n::: {#96352c0a .cell execution_count=32}\n``` {.ggsql .cell-code}\nWITH unordered_data AS (\n SELECT * FROM (VALUES\n (150.0, '2023-03-01'::DATE),\n (100.0, '2023-01-01'::DATE),\n (120.0, '2023-05-01'::DATE),\n (200.0, '2023-02-01'::DATE),\n (180.0, '2023-04-01'::DATE)\n ) AS t(value, date)\n)\nVISUALISE\nDRAW path \n MAPPING date AS x, value AS y FROM unordered_data \n ORDER BY date\nDRAW point \n MAPPING date AS x, value AS y FROM unordered_data \n SETTING size => 6, color => 'red'\nSCALE x VIA date\nLABEL \n title => 'Line Chart with ORDER BY', \n x => 'Date', \n y => 'Value'\n```\n\n::: {.cell-output .cell-output-display execution_count=32}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Combining FILTER and ORDER BY\n\nThe `ORDER BY` clause can be combined with `FILTER` and other layer options:\n\n::: {#8a48523a .cell execution_count=33}\n``` {.ggsql .cell-code}\nSELECT date, value, category FROM 'metrics.csv'\nVISUALISE\nDRAW path \n MAPPING date AS x, value AS y, category AS color \n FILTER category != 'Support' \n ORDER BY value\nDRAW point \n MAPPING date AS x, value AS y, category AS color \n SETTING size => 3\n FILTER category != 'Support' \nSCALE x VIA date\nLABEL \n title => 'Sales and Marketing Metrics (Ordered)', \n x => 'Date', \n y => 'Value'\n```\n\n::: {.cell-output .cell-output-display execution_count=33}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Common Table Expressions (CTEs)\n\n### Simple CTE with VISUALISE FROM\n\n::: {#d1d20794 .cell execution_count=34}\n``` {.ggsql .cell-code}\nWITH monthly_sales AS (\n SELECT\n DATE_TRUNC('month', sale_date) as month,\n SUM(revenue) as total_revenue\n FROM 'sales.csv'\n GROUP BY DATE_TRUNC('month', sale_date)\n)\nVISUALISE month AS x, total_revenue AS y FROM monthly_sales\nDRAW line\nDRAW point\nSCALE x VIA date\nLABEL \n title => 'Monthly Revenue Trends', \n x => 'Month', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=34}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Multiple CTEs\n\n::: {#8322cb49 .cell execution_count=35}\n``` {.ggsql .cell-code}\nWITH daily_sales AS (\n SELECT sale_date, region, SUM(revenue) as revenue\n FROM 'sales.csv'\n GROUP BY sale_date, region\n),\nregional_totals AS (\n SELECT region, SUM(revenue) as total\n FROM daily_sales\n GROUP BY region\n)\nVISUALISE region AS x, total AS y, region AS fill FROM regional_totals\nDRAW bar\nCOORD flip\nLABEL \n title => 'Total Revenue by Region', \n x => 'Region', \n y => 'Total Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=35}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Layer-Specific Data Sources (MAPPING FROM)\n\nLayers can pull data from different sources using `MAPPING FROM`. This enables overlaying data from different CTEs or tables.\n\n### Comparing Actuals vs Targets\n\nEach layer can reference a different CTE using `MAPPING ... FROM cte_name`:\n\n::: {#b9e15886 .cell execution_count=36}\n``` {.ggsql .cell-code}\nWITH actuals AS (\n SELECT\n DATE_TRUNC('month', sale_date) as month,\n SUM(revenue) as value\n FROM 'sales.csv'\n GROUP BY DATE_TRUNC('month', sale_date)\n),\ntargets AS (\n SELECT * FROM (VALUES\n ('2023-01-01'::DATE, 5000.0),\n ('2023-02-01'::DATE, 5500.0),\n ('2023-03-01'::DATE, 6000.0),\n ('2023-04-01'::DATE, 6500.0),\n ('2023-05-01'::DATE, 7000.0),\n ('2023-06-01'::DATE, 7500.0),\n ('2023-07-01'::DATE, 8000.0),\n ('2023-08-01'::DATE, 8500.0),\n ('2023-09-01'::DATE, 9000.0),\n ('2023-10-01'::DATE, 9500.0),\n ('2023-11-01'::DATE, 10000.0),\n ('2023-12-01'::DATE, 10500.0)\n ) AS t(month, value)\n)\nVISUALISE\nDRAW line \n MAPPING month AS x, value AS y, 'Actual' AS color FROM actuals\nDRAW point \n MAPPING month AS x, value AS y, 'Actual' AS color FROM actuals \n SETTING size => 6\nDRAW line \n MAPPING month AS x, value AS y, 'Target' AS color FROM targets\nSCALE x VIA date\nLABEL \n title => 'Revenue: Actual vs Target', \n x => 'Month', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=36}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### CTE Chain: Raw → Filtered → Aggregated\n\nCTEs can reference other CTEs, creating a data transformation pipeline:\n\n::: {#dc5c62ab .cell execution_count=37}\n``` {.ggsql .cell-code}\nWITH raw_data AS (\n SELECT sale_date, revenue, category, region\n FROM 'sales.csv'\n),\nelectronics_only AS (\n SELECT * FROM raw_data\n WHERE category = 'Electronics'\n),\nmonthly_electronics AS (\n SELECT\n DATE_TRUNC('month', sale_date) as month,\n region,\n SUM(revenue) as total\n FROM electronics_only\n GROUP BY DATE_TRUNC('month', sale_date), region\n)\nVISUALISE month AS x, total AS y, region AS color FROM monthly_electronics\nDRAW line\nDRAW point\nSCALE x VIA date\nLABEL \n title => 'Electronics Revenue by Region (CTE Chain)', \n x => 'Month', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=37}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Layer FROM with FILTER\n\nCombine `FROM` with `FILTER` to get filtered subsets from a CTE:\n\n::: {#9eeec490 .cell execution_count=38}\n``` {.ggsql .cell-code}\nWITH all_sales AS (\n SELECT\n DATE_TRUNC('month', sale_date) as month,\n category,\n SUM(revenue) as revenue\n FROM 'sales.csv'\n GROUP BY DATE_TRUNC('month', sale_date), category\n)\nVISUALISE\nDRAW line \n MAPPING month AS x, revenue AS y, 'All Categories' AS color FROM all_sales\nDRAW line \n MAPPING month AS x, revenue AS y, 'Electronics' AS color FROM all_sales \n FILTER category = 'Electronics'\nDRAW line \n MAPPING month AS x, revenue AS y, 'Clothing' AS color FROM all_sales \n FILTER category = 'Clothing'\nSCALE x VIA date\nLABEL \n title => 'Revenue by Category (Filtered Layers)', \n x => 'Month', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=38}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n## Advanced Examples\n\n### Complete Regional Sales Analysis\n\n::: {#1c1e23b5 .cell execution_count=39}\n``` {.ggsql .cell-code}\nSELECT\n sale_date,\n region,\n SUM(quantity) as total_quantity\nFROM 'sales.csv'\nWHERE sale_date >= '2023-01-01'\nGROUP BY sale_date, region\nORDER BY sale_date\nVISUALISE sale_date AS x, total_quantity AS y, region AS color\nDRAW line\nDRAW point\nSCALE x VIA date\nFACET region\nLABEL\n title => 'Sales Trends by Region', \n x => 'Date', \n y => 'Total Quantity'\n```\n\n::: {.cell-output .cell-output-display execution_count=39}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n### Multi-Category Analysis\n\n::: {#311c7f73 .cell execution_count=40}\n``` {.ggsql .cell-code}\nSELECT\n category,\n region,\n SUM(revenue) as total_revenue\nFROM 'sales.csv'\nGROUP BY category, region\nVISUALISE category AS x, total_revenue AS y, region AS fill\nDRAW bar\nLABEL \n title => 'Revenue by Category and Region', \n x => 'Category', \n y => 'Revenue ($)'\n```\n\n::: {.cell-output .cell-output-display execution_count=40}\n```{=html}\n
\n\n```\n:::\n:::\n\n\n", - "supporting": [ - "examples_files/figure-html" - ], - "filters": [], - "includes": { - "include-in-header": [ - "\n\n\n" - ] - } - } -} \ No newline at end of file From 177d216079e70da1223036658e0b5082621757e3 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Fri, 27 Feb 2026 13:54:50 +0100 Subject: [PATCH 17/28] fixes based on code review --- src/execute/mod.rs | 9 --- src/execute/scale.rs | 5 +- src/lib.rs | 4 +- src/plot/aesthetic.rs | 96 ++++------------------------- src/plot/facet/resolve.rs | 2 - src/plot/main.rs | 49 +++++---------- src/plot/scale/scale_type/mod.rs | 4 -- src/plot/scale/transform/date.rs | 3 +- src/plot/scale/transform/integer.rs | 3 +- src/writer/vegalite/layer.rs | 18 +++--- 10 files changed, 45 insertions(+), 148 deletions(-) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 56fcbb55..eb6d2477 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -1732,11 +1732,6 @@ mod tests { assert!(facet.get_variables().is_empty()); } - // Note: With internal naming (facet1, facet2), mixing wrap and grid is no longer detectable - // at this level because panel→facet1 and row→facet1 map to the same internal name. - // The validation happens at the user-facing level during parsing, not here. - // This test is no longer applicable with internal naming. - #[test] fn test_resolve_facet_error_incomplete_grid() { // Only facet2 without facet1 is an error (column without row) @@ -1793,10 +1788,6 @@ mod tests { assert!(err.contains("row")); // mentions the user-facing name in error } - // Note: With internal naming, grid clause with wrap mapping is allowed - // because facet1 (from panel) is compatible with grid layout (uses row only). - // The original test is no longer valid. - #[test] fn test_resolve_facet_no_mappings_no_clause() { let layers = vec![Layer::new(Geom::point())]; diff --git a/src/execute/scale.rs b/src/execute/scale.rs index 999e9415..d74eda42 100644 --- a/src/execute/scale.rs +++ b/src/execute/scale.rs @@ -1283,8 +1283,8 @@ mod tests { #[test] fn test_get_aesthetic_family() { - // NOTE: get_aesthetic_family() now only handles internal names (pos1, pos2, etc.) - // and non-positional aesthetics. For user-facing families, use AestheticContext. + // Handles internal names (pos1, pos2, etc.) and non-positional aesthetics. + // For user-facing families, use AestheticContext. // Test internal primary aesthetics include all family members let pos1_family = get_aesthetic_family("pos1"); @@ -1435,7 +1435,6 @@ mod tests { use polars::prelude::*; // Create a Plot where "pos2" scale should get range from pos2min and pos2max columns - // NOTE: After aesthetic transformation, all positional aesthetics use internal names let mut spec = Plot::new(); let scale = crate::plot::Scale::new("pos2"); diff --git a/src/lib.rs b/src/lib.rs index b05f7882..6c70771c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,8 +58,8 @@ pub use plot::{ // Re-export aesthetic classification utilities pub use plot::aesthetic::{ - get_aesthetic_family, is_aesthetic_name, is_positional_aesthetic, is_primary_positional, - primary_aesthetic, AestheticContext, NON_POSITIONAL, POSITIONAL_SUFFIXES, + get_aesthetic_family, is_positional_aesthetic, is_primary_positional, primary_aesthetic, + AestheticContext, NON_POSITIONAL, POSITIONAL_SUFFIXES, }; // Future modules - not yet implemented diff --git a/src/plot/aesthetic.rs b/src/plot/aesthetic.rs index 72bde683..2c1224f9 100644 --- a/src/plot/aesthetic.rs +++ b/src/plot/aesthetic.rs @@ -124,8 +124,6 @@ pub struct AestheticContext { all_internal_positional: Vec, /// User-facing facet names: ["panel"] or ["row", "column"] user_facet: Vec<&'static str>, - /// All user facet names: ["panel"] or ["row", "column"] - all_user_facet: Vec, /// All internal facet names: ["facet1"] or ["facet1", "facet2"] all_internal_facet: Vec, /// Non-positional aesthetics (static list) @@ -162,16 +160,11 @@ impl AestheticContext { } } - // Build facet mappings for active facets (from FACET clause or layer mappings) + // Build internal facet names for active facets (from FACET clause or layer mappings) // These are used for internal→user mapping (to know which user name to show) - let mut all_user_facet = Vec::new(); - let mut all_internal_facet = Vec::new(); - - for (i, facet_name) in facet_names.iter().enumerate() { - let facet_num = i + 1; - all_user_facet.push((*facet_name).to_string()); - all_internal_facet.push(format!("facet{}", facet_num)); - } + let all_internal_facet: Vec = (1..=facet_names.len()) + .map(|i| format!("facet{}", i)) + .collect(); Self { user_positional: positional_names.to_vec(), @@ -179,7 +172,6 @@ impl AestheticContext { primary_internal, all_internal_positional: all_internal, user_facet: facet_names.to_vec(), - all_user_facet, all_internal_facet, non_positional: NON_POSITIONAL, } @@ -214,7 +206,7 @@ impl AestheticContext { } // Check active facet (from FACET clause) - if let Some(idx) = self.all_user_facet.iter().position(|u| u == user_aesthetic) { + if let Some(idx) = self.user_facet.iter().position(|u| *u == user_aesthetic) { return Some(self.all_internal_facet[idx].as_str()); } @@ -251,7 +243,7 @@ impl AestheticContext { .iter() .position(|i| i == internal_aesthetic) { - return Some(self.all_user_facet[idx].as_str()); + return Some(self.user_facet[idx]); } None } @@ -280,7 +272,7 @@ impl AestheticContext { /// Check if name is a user-facing facet aesthetic (panel, row, column) pub fn is_user_facet(&self, name: &str) -> bool { - self.all_user_facet.iter().any(|f| f == name) + self.user_facet.contains(&name) } /// Check if name is an internal facet aesthetic (facet1, facet2) @@ -396,11 +388,6 @@ impl AestheticContext { &self.user_facet } - /// Get all user facet aesthetics as Strings - pub fn all_user_facet(&self) -> &[String] { - &self.all_user_facet - } - /// Get all internal facet aesthetics (facet1, facet2) pub fn all_internal_facet(&self) -> &[String] { &self.all_internal_facet @@ -483,19 +470,6 @@ pub fn is_positional_aesthetic(name: &str) -> bool { false } -/// Check if name is a recognized aesthetic (internal or non-positional) -/// -/// This function works with **internal** aesthetic names (pos1, pos2, facet1, etc.) and non-positional -/// aesthetics. For validating user-facing aesthetic names before transformation, use -/// `AestheticContext::is_user_positional()` or check against the grammar's aesthetic_name rule. -#[inline] -pub fn is_aesthetic_name(name: &str) -> bool { - is_positional_aesthetic(name) - || is_facet_aesthetic(name) - || NON_POSITIONAL.contains(&name) - || USER_FACET_AESTHETICS.contains(&name) -} - /// Get the primary aesthetic for a given aesthetic name. /// /// This function works with **internal** aesthetic names (pos1, pos2, etc.) and non-positional @@ -574,7 +548,7 @@ mod tests { #[test] fn test_primary_positional() { - // NOTE: is_primary_positional() now checks for internal names (pos1, pos2, etc.) + // is_primary_positional() checks for internal names (pos1, pos2, etc.) assert!(is_primary_positional("pos1")); assert!(is_primary_positional("pos2")); assert!(is_primary_positional("pos10")); // supports any number @@ -633,7 +607,7 @@ mod tests { #[test] fn test_positional_aesthetic() { - // NOTE: is_positional_aesthetic() now checks for internal names (pos1, pos2, etc.) + // Checks internal positional names (pos1, pos2, etc. and variants) // For user-facing checks, use AestheticContext::is_user_positional() // Primary internal @@ -667,54 +641,10 @@ mod tests { assert!(!is_positional_aesthetic("position")); // not a valid pattern } - #[test] - fn test_is_aesthetic_name() { - // NOTE: is_aesthetic_name() works with internal names and non-positional aesthetics - // For user-facing validation, use AestheticContext::is_user_positional() - - // Internal positional - assert!(is_aesthetic_name("pos1")); - assert!(is_aesthetic_name("pos2")); - assert!(is_aesthetic_name("pos1min")); - assert!(is_aesthetic_name("pos2end")); - - // Visual (non-positional) - assert!(is_aesthetic_name("color")); - assert!(is_aesthetic_name("colour")); - assert!(is_aesthetic_name("fill")); - assert!(is_aesthetic_name("stroke")); - assert!(is_aesthetic_name("opacity")); - assert!(is_aesthetic_name("size")); - assert!(is_aesthetic_name("shape")); - assert!(is_aesthetic_name("linetype")); - assert!(is_aesthetic_name("linewidth")); - - // Text - assert!(is_aesthetic_name("label")); - assert!(is_aesthetic_name("family")); - assert!(is_aesthetic_name("fontface")); - assert!(is_aesthetic_name("hjust")); - assert!(is_aesthetic_name("vjust")); - - // Facet (both user-facing and internal) - assert!(is_aesthetic_name("panel")); - assert!(is_aesthetic_name("row")); - assert!(is_aesthetic_name("column")); - assert!(is_aesthetic_name("facet1")); - assert!(is_aesthetic_name("facet2")); - - // Not aesthetics (user-facing positional names are not recognized by this function) - assert!(!is_aesthetic_name("x")); - assert!(!is_aesthetic_name("y")); - assert!(!is_aesthetic_name("theta")); - assert!(!is_aesthetic_name("foo")); - assert!(!is_aesthetic_name("data")); - } - #[test] fn test_primary_aesthetic() { - // NOTE: primary_aesthetic() now only handles internal names (pos1, pos2, etc.) - // and non-positional aesthetics. For user-facing families, use AestheticContext. + // Handles internal names (pos1, pos2, etc.) and non-positional aesthetics. + // For user-facing families, use AestheticContext. // Internal positional primaries return themselves assert_eq!(primary_aesthetic("pos1"), "pos1"); @@ -745,8 +675,8 @@ mod tests { #[test] fn test_get_aesthetic_family() { - // NOTE: get_aesthetic_family() now only handles internal names (pos1, pos2, etc.) - // and non-positional aesthetics. For user-facing families, use AestheticContext. + // Handles internal names (pos1, pos2, etc.) and non-positional aesthetics. + // For user-facing families, use AestheticContext. // Internal positional primary returns full family let pos1_family = get_aesthetic_family("pos1"); diff --git a/src/plot/facet/resolve.rs b/src/plot/facet/resolve.rs index b012aa9e..614c3ff9 100644 --- a/src/plot/facet/resolve.rs +++ b/src/plot/facet/resolve.rs @@ -59,8 +59,6 @@ const GRID_ALLOWED: &[&str] = &["free", "missing"]; /// Valid values for the missing property const MISSING_VALUES: &[&str] = &["repeat", "null"]; -// Note: FREE_STRING_VALUES was removed - free property now accepts dynamic positional names - /// Compute smart default ncol for wrap facets based on number of levels /// /// Returns an optimal column count that creates a balanced grid: diff --git a/src/plot/main.rs b/src/plot/main.rs index 047be31d..faa088af 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -135,32 +135,8 @@ impl Plot { } } - /// Get the aesthetic context, creating a default one if not set - pub fn get_aesthetic_context(&self) -> AestheticContext { - if let Some(ref ctx) = self.aesthetic_context { - ctx.clone() - } else { - // Create default based on project (use aesthetics if set, else defaults) - // If no project clause, use default cartesian names ["x", "y"] - let default_positional: Vec = vec!["x".to_string(), "y".to_string()]; - let positional_names: &[String] = self - .project - .as_ref() - .map(|p| p.aesthetics.as_slice()) - .unwrap_or(&default_positional); - let facet_names: &[&'static str] = self - .facet - .as_ref() - .map(|f| f.layout.user_facet_names()) - .unwrap_or(&[]); - AestheticContext::new(positional_names, facet_names) - } - } - - /// Set the aesthetic context based on the current coord and facet - pub fn initialize_aesthetic_context(&mut self) { - // Get positional names from project (already resolved at build time) - // If no project clause, use default ["x", "y"] + /// Build an aesthetic context from current project and facet settings + fn build_aesthetic_context(&self) -> AestheticContext { let default_positional: Vec = vec!["x".to_string(), "y".to_string()]; let positional_names: &[String] = self .project @@ -172,7 +148,19 @@ impl Plot { .as_ref() .map(|f| f.layout.user_facet_names()) .unwrap_or(&[]); - self.aesthetic_context = Some(AestheticContext::new(positional_names, facet_names)); + AestheticContext::new(positional_names, facet_names) + } + + /// Get the aesthetic context, creating a default one if not set + pub fn get_aesthetic_context(&self) -> AestheticContext { + self.aesthetic_context + .clone() + .unwrap_or_else(|| self.build_aesthetic_context()) + } + + /// Set the aesthetic context based on the current coord and facet + pub fn initialize_aesthetic_context(&mut self) { + self.aesthetic_context = Some(self.build_aesthetic_context()); } /// Transform all aesthetic keys from user-facing to internal names. @@ -534,8 +522,8 @@ mod tests { #[test] fn test_aesthetic_family_primary_lookup() { - // NOTE: primary_aesthetic() now only handles internal names (pos1, pos2, etc.) - // and non-positional aesthetics. For user-facing families, use AestheticContext. + // Handles internal names (pos1, pos2, etc.) and non-positional aesthetics. + // For user-facing families, use AestheticContext. // Test that internal variant aesthetics map to their primary assert_eq!(primary_aesthetic("pos1"), "pos1"); @@ -560,7 +548,6 @@ mod tests { #[test] fn test_compute_labels_from_variant_aesthetics() { // Test that variant aesthetics (pos1min, pos1max) can contribute to primary aesthetic labels - // NOTE: After aesthetic transformation, all positional aesthetics use internal names let mut spec = Plot::new(); let layer = Layer::new(Geom::ribbon()) .with_aesthetic( @@ -599,7 +586,6 @@ mod tests { #[test] fn test_user_label_overrides_computed() { // Test that user-specified labels take precedence over computed labels - // NOTE: After aesthetic transformation, all positional aesthetics use internal names let mut spec = Plot::new(); let layer = Layer::new(Geom::ribbon()) .with_aesthetic( @@ -644,7 +630,6 @@ mod tests { #[test] fn test_primary_aesthetic_sets_label_before_variants() { // Test that if both primary and variant are mapped, primary takes precedence - // NOTE: After aesthetic transformation, all positional aesthetics use internal names let mut spec = Plot::new(); let layer = Layer::new(Geom::point()) .with_aesthetic("pos1".to_string(), AestheticValue::standard_column("date")) diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index f2276b2c..848ce32d 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -2293,8 +2293,6 @@ mod tests { #[test] fn test_resolve_properties_defaults() { - // NOTE: After transformation, positional aesthetics use internal names (pos1, pos2, etc.) - // Continuous positional: default expand let props = HashMap::new(); let resolved = ScaleType::continuous() @@ -2332,8 +2330,6 @@ mod tests { #[test] fn test_resolve_properties_user_values_preserved() { - // NOTE: After transformation, positional aesthetics use internal names (pos1, pos2, etc.) - let mut props = HashMap::new(); props.insert("expand".to_string(), ParameterValue::Number(0.1)); let resolved = ScaleType::continuous() diff --git a/src/plot/scale/transform/date.rs b/src/plot/scale/transform/date.rs index a968d861..622f3453 100644 --- a/src/plot/scale/transform/date.rs +++ b/src/plot/scale/transform/date.rs @@ -509,8 +509,7 @@ mod tests { #[test] fn test_date_interval_selection_airquality() { // airquality data: ~150 days, n=7 - // Previously: selected Week, generated ~22 breaks - // Now: should select Month (150/30 ≈ 5 breaks, within 20% of 7) + // Should select Month (150/30 ≈ 5 breaks, within 20% of 7) let (interval, step) = DateInterval::select(150.0, 7); // Month gives ~5 breaks (within tolerance of 7), or // Week with step would give ~5 breaks diff --git a/src/plot/scale/transform/integer.rs b/src/plot/scale/transform/integer.rs index 17b965e5..e84ab0e0 100644 --- a/src/plot/scale/transform/integer.rs +++ b/src/plot/scale/transform/integer.rs @@ -168,8 +168,7 @@ mod tests { fn test_integer_breaks_small_range_linear() { let t = Integer; // Test the problematic case: range 0-5 with n=5 - // Previously this would give [0, 1.25, 2.5, 3.75, 5] → rounded [0, 1, 3, 4, 5] - // Now it should give evenly spaced integers + // Should give evenly spaced integers, not [0, 1, 3, 4, 5] (missing 2) let breaks = t.calculate_breaks(0.0, 5.0, 5, false); for b in &breaks { assert_eq!(*b, b.round(), "Break {} should be integer", b); diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index ee2a225a..311b3381 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -327,7 +327,7 @@ impl GeomRenderer for ViolinRenderer { // Left side (- offset), sort by +y (bottom -> top) let calc_order = format!( "datum.__violin_offset > 0 ? -datum.{y} : datum.{y}", - y = naming::aesthetic_column("y") + y = naming::aesthetic_column("pos2") ); // Filter threshold to trim very low density regions (removes thin tails) @@ -475,9 +475,9 @@ impl BoxplotRenderer { ) -> Result<(HashMap>, Vec, bool)> { let type_col = naming::aesthetic_column("type"); let type_col = type_col.as_str(); - let value_col = naming::aesthetic_column("y"); + let value_col = naming::aesthetic_column("pos2"); let value_col = value_col.as_str(); - let value2_col = naming::aesthetic_column("yend"); + let value2_col = naming::aesthetic_column("pos2end"); let value2_col = value2_col.as_str(); // Find grouping columns (all columns except type, value, value2) @@ -543,22 +543,22 @@ impl BoxplotRenderer { ) -> Result> { let mut layers: Vec = Vec::new(); - let value_col = naming::aesthetic_column("y"); - let value2_col = naming::aesthetic_column("yend"); + let value_col = naming::aesthetic_column("pos2"); + let value2_col = naming::aesthetic_column("pos2end"); let x_col = layer .mappings - .get("x") + .get("pos1") .and_then(|x| x.column_name()) .ok_or_else(|| { - GgsqlError::WriterError("Failed to find column for 'x' aesthetic".to_string()) + GgsqlError::WriterError("Boxplot requires 'x' aesthetic mapping".to_string()) })?; let y_col = layer .mappings - .get("y") + .get("pos2") .and_then(|y| y.column_name()) .ok_or_else(|| { - GgsqlError::WriterError("Failed to find column for 'y' aesthetic".to_string()) + GgsqlError::WriterError("Boxplot requires 'y' aesthetic mapping".to_string()) })?; // Set orientation From 66da064e7de98d13a5e5deb3fce5f23fbde0fd69 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 2 Mar 2026 10:24:38 +0100 Subject: [PATCH 18/28] unify property default handling --- src/plot/layer/geom/types.rs | 19 ++----- src/plot/projection/coord/cartesian.rs | 23 ++++++--- src/plot/projection/coord/mod.rs | 34 +++++-------- src/plot/projection/coord/polar.rs | 55 ++++++++++++-------- src/plot/scale/scale_type/binned.rs | 67 ++++++++++++++----------- src/plot/scale/scale_type/continuous.rs | 47 ++++++++--------- src/plot/scale/scale_type/discrete.rs | 17 +++---- src/plot/scale/scale_type/mod.rs | 51 ++++++++++++------- src/plot/scale/scale_type/ordinal.rs | 17 +++---- src/plot/types.rs | 39 ++++++++++++++ 10 files changed, 218 insertions(+), 151 deletions(-) diff --git a/src/plot/layer/geom/types.rs b/src/plot/layer/geom/types.rs index 742fbe77..cb0632f4 100644 --- a/src/plot/layer/geom/types.rs +++ b/src/plot/layer/geom/types.rs @@ -4,6 +4,9 @@ use crate::{plot::types::DefaultAestheticValue, Mappings}; +// Re-export shared types from the central location +pub use crate::plot::types::{DefaultParam, DefaultParamValue}; + /// Default aesthetic values for a geom type /// /// This struct describes which aesthetics a geom supports, requires, and their default values. @@ -79,22 +82,6 @@ impl DefaultAesthetics { } } -/// Default value for a layer parameter -#[derive(Debug, Clone)] -pub enum DefaultParamValue { - String(&'static str), - Number(f64), - Boolean(bool), - Null, -} - -/// Layer parameter definition: name and default value -#[derive(Debug, Clone)] -pub struct DefaultParam { - pub name: &'static str, - pub default: DefaultParamValue, -} - /// Result of a statistical transformation /// /// Stat transforms like histogram and bar count produce new columns with computed values. diff --git a/src/plot/projection/coord/cartesian.rs b/src/plot/projection/coord/cartesian.rs index 239197f7..425026c0 100644 --- a/src/plot/projection/coord/cartesian.rs +++ b/src/plot/projection/coord/cartesian.rs @@ -1,6 +1,7 @@ //! Cartesian coordinate system implementation use super::{CoordKind, CoordTrait}; +use crate::plot::types::{DefaultParam, DefaultParamValue}; /// Cartesian coordinate system - standard x/y coordinates #[derive(Debug, Clone, Copy)] @@ -19,8 +20,17 @@ impl CoordTrait for Cartesian { &["x", "y"] } - fn allowed_properties(&self) -> &'static [&'static str] { - &["ratio", "clip"] + fn default_properties(&self) -> &'static [DefaultParam] { + &[ + DefaultParam { + name: "ratio", + default: DefaultParamValue::Null, + }, + DefaultParam { + name: "clip", + default: DefaultParamValue::Null, + }, + ] } } @@ -44,11 +54,12 @@ mod tests { } #[test] - fn test_cartesian_allowed_properties() { + fn test_cartesian_default_properties() { let cartesian = Cartesian; - let allowed = cartesian.allowed_properties(); - assert!(allowed.contains(&"ratio")); - assert!(allowed.contains(&"clip")); + let defaults = cartesian.default_properties(); + let names: Vec<&str> = defaults.iter().map(|p| p.name).collect(); + assert!(names.contains(&"ratio")); + assert!(names.contains(&"clip")); } #[test] diff --git a/src/plot/projection/coord/mod.rs b/src/plot/projection/coord/mod.rs index 962b89ad..10ff0efd 100644 --- a/src/plot/projection/coord/mod.rs +++ b/src/plot/projection/coord/mod.rs @@ -24,6 +24,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; +use crate::plot::types::DefaultParam; use crate::plot::ParameterValue; // Coord type implementations @@ -82,24 +83,20 @@ pub trait CoordTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { /// early in the pipeline and transformed back for output. fn positional_aesthetic_names(&self) -> &'static [&'static str]; - /// Returns list of allowed property names for SETTING clause. + /// Returns list of allowed properties with their default values. /// Default: empty (no properties allowed). - fn allowed_properties(&self) -> &'static [&'static str] { + fn default_properties(&self) -> &'static [DefaultParam] { &[] } - /// Returns default value for a property, if any. - fn get_property_default(&self, _name: &str) -> Option { - None - } - /// Resolve and validate properties. - /// Default implementation validates against allowed_properties. + /// Default implementation validates against default_properties. fn resolve_properties( &self, properties: &HashMap, ) -> Result, String> { - let allowed = self.allowed_properties(); + let defaults = self.default_properties(); + let allowed: Vec<&str> = defaults.iter().map(|p| p.name).collect(); // Check for unknown properties for key in properties.keys() { @@ -120,10 +117,10 @@ pub trait CoordTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { // Start with user properties, add defaults for missing ones let mut resolved = properties.clone(); - for &prop_name in allowed { - if !resolved.contains_key(prop_name) { - if let Some(default) = self.get_property_default(prop_name) { - resolved.insert(prop_name.to_string(), default); + for param in defaults { + if !resolved.contains_key(param.name) { + if let Some(default) = param.to_parameter_value() { + resolved.insert(param.name.to_string(), default); } } } @@ -178,14 +175,9 @@ impl Coord { self.0.positional_aesthetic_names() } - /// Returns list of allowed property names for SETTING clause. - pub fn allowed_properties(&self) -> &'static [&'static str] { - self.0.allowed_properties() - } - - /// Returns default value for a property, if any. - pub fn get_property_default(&self, name: &str) -> Option { - self.0.get_property_default(name) + /// Returns list of allowed properties with their default values. + pub fn default_properties(&self) -> &'static [DefaultParam] { + self.0.default_properties() } /// Resolve and validate properties. diff --git a/src/plot/projection/coord/polar.rs b/src/plot/projection/coord/polar.rs index 05c4fe52..5781e02d 100644 --- a/src/plot/projection/coord/polar.rs +++ b/src/plot/projection/coord/polar.rs @@ -1,7 +1,7 @@ //! Polar coordinate system implementation use super::{CoordKind, CoordTrait}; -use crate::plot::ParameterValue; +use crate::plot::types::{DefaultParam, DefaultParamValue}; /// Polar coordinate system - for pie charts, rose plots #[derive(Debug, Clone, Copy)] @@ -20,15 +20,25 @@ impl CoordTrait for Polar { &["theta", "radius"] } - fn allowed_properties(&self) -> &'static [&'static str] { - &["clip", "start", "end", "inner"] - } - - fn get_property_default(&self, name: &str) -> Option { - match name { - "start" => Some(ParameterValue::Number(0.0)), // 0 degrees = 12 o'clock - _ => None, - } + fn default_properties(&self) -> &'static [DefaultParam] { + &[ + DefaultParam { + name: "clip", + default: DefaultParamValue::Null, + }, + DefaultParam { + name: "start", + default: DefaultParamValue::Number(0.0), // 0 degrees = 12 o'clock + }, + DefaultParam { + name: "end", + default: DefaultParamValue::Null, + }, + DefaultParam { + name: "inner", + default: DefaultParamValue::Null, + }, + ] } } @@ -41,6 +51,7 @@ impl std::fmt::Display for Polar { #[cfg(test)] mod tests { use super::*; + use crate::plot::ParameterValue; use std::collections::HashMap; #[test] @@ -51,22 +62,26 @@ mod tests { } #[test] - fn test_polar_allowed_properties() { + fn test_polar_default_properties() { let polar = Polar; - let allowed = polar.allowed_properties(); - assert!(allowed.contains(&"clip")); - assert!(allowed.contains(&"start")); - assert!(allowed.contains(&"end")); - assert!(allowed.contains(&"inner")); - assert_eq!(allowed.len(), 4); + let defaults = polar.default_properties(); + let names: Vec<&str> = defaults.iter().map(|p| p.name).collect(); + assert!(names.contains(&"clip")); + assert!(names.contains(&"start")); + assert!(names.contains(&"end")); + assert!(names.contains(&"inner")); + assert_eq!(defaults.len(), 4); } #[test] fn test_polar_start_default() { let polar = Polar; - let default = polar.get_property_default("start"); - assert!(default.is_some()); - assert_eq!(default.unwrap(), ParameterValue::Number(0.0)); + let defaults = polar.default_properties(); + let start_param = defaults.iter().find(|p| p.name == "start").unwrap(); + assert!(matches!( + start_param.default, + DefaultParamValue::Number(0.0) + )); } #[test] diff --git a/src/plot/scale/scale_type/binned.rs b/src/plot/scale/scale_type/binned.rs index dd2ea38e..f8f6922c 100644 --- a/src/plot/scale/scale_type/binned.rs +++ b/src/plot/scale/scale_type/binned.rs @@ -6,8 +6,9 @@ use polars::prelude::DataType; use super::{ expand_numeric_range, resolve_common_steps, ScaleDataContext, ScaleTypeKind, ScaleTypeTrait, - TransformKind, OOB_SQUISH, + TransformKind, OOB_CENSOR, OOB_SQUISH, }; +use crate::plot::types::{DefaultParam, DefaultParamValue}; use crate::plot::{ArrayElement, ParameterValue}; use super::InputRange; @@ -145,30 +146,35 @@ impl ScaleTypeTrait for Binned { TransformKind::Identity } - fn allowed_properties(&self, aesthetic: &str) -> &'static [&'static str] { - if super::is_positional_aesthetic(aesthetic) { - &["expand", "oob", "reverse", "breaks", "pretty", "closed"] - } else { - &["oob", "reverse", "breaks", "pretty", "closed"] - } - } - - fn get_property_default(&self, aesthetic: &str, name: &str) -> Option { - match name { - "expand" if super::is_positional_aesthetic(aesthetic) => { - Some(ParameterValue::Number(super::DEFAULT_EXPAND_MULT)) - } - // Binned scales default to "censor" - "keep" is not valid for binned - "oob" => Some(ParameterValue::String(super::OOB_CENSOR.to_string())), - "reverse" => Some(ParameterValue::Boolean(false)), - "breaks" => Some(ParameterValue::Number( - super::super::breaks::DEFAULT_BREAK_COUNT as f64, - )), - "pretty" => Some(ParameterValue::Boolean(true)), + fn default_properties(&self) -> &'static [DefaultParam] { + &[ + DefaultParam { + name: "expand", + default: DefaultParamValue::Number(super::DEFAULT_EXPAND_MULT), + }, + // Binned scales always use "censor" - "keep" is not valid for binned + DefaultParam { + name: "oob", + default: DefaultParamValue::String(OOB_CENSOR), + }, + DefaultParam { + name: "reverse", + default: DefaultParamValue::Boolean(false), + }, + DefaultParam { + name: "breaks", + default: DefaultParamValue::Number(super::super::breaks::DEFAULT_BREAK_COUNT as f64), + }, + DefaultParam { + name: "pretty", + default: DefaultParamValue::Boolean(true), + }, // "left" means bins are [lower, upper), "right" means (lower, upper] - "closed" => Some(ParameterValue::String("left".to_string())), - _ => None, - } + DefaultParam { + name: "closed", + default: DefaultParamValue::String("left"), + }, + ] } fn default_output_range( @@ -944,15 +950,20 @@ mod tests { #[test] fn test_closed_property_default() { let binned = Binned; - let default = binned.get_property_default("x", "closed"); - assert_eq!(default, Some(ParameterValue::String("left".to_string()))); + let defaults = binned.default_properties(); + let closed_param = defaults.iter().find(|p| p.name == "closed").unwrap(); + assert!(matches!( + closed_param.default, + crate::plot::types::DefaultParamValue::String("left") + )); } #[test] fn test_closed_property_allowed() { let binned = Binned; - let allowed = binned.allowed_properties("x"); - assert!(allowed.contains(&"closed")); + let defaults = binned.default_properties(); + let names: Vec<&str> = defaults.iter().map(|p| p.name).collect(); + assert!(names.contains(&"closed")); } #[test] diff --git a/src/plot/scale/scale_type/continuous.rs b/src/plot/scale/scale_type/continuous.rs index 9f639d67..65bd8344 100644 --- a/src/plot/scale/scale_type/continuous.rs +++ b/src/plot/scale/scale_type/continuous.rs @@ -3,6 +3,7 @@ use polars::prelude::DataType; use super::{ScaleTypeKind, ScaleTypeTrait, SqlTypeNames, TransformKind, OOB_CENSOR, OOB_SQUISH}; +use crate::plot::types::{DefaultParam, DefaultParamValue}; use crate::plot::{ArrayElement, ParameterValue}; /// Continuous scale type - for continuous numeric data @@ -90,29 +91,29 @@ impl ScaleTypeTrait for Continuous { TransformKind::Identity } - fn allowed_properties(&self, aesthetic: &str) -> &'static [&'static str] { - if super::is_positional_aesthetic(aesthetic) { - &["expand", "oob", "reverse", "breaks", "pretty"] - } else { - &["oob", "reverse", "breaks", "pretty"] - } - } - - fn get_property_default(&self, aesthetic: &str, name: &str) -> Option { - match name { - "expand" if super::is_positional_aesthetic(aesthetic) => { - Some(ParameterValue::Number(super::DEFAULT_EXPAND_MULT)) - } - "oob" => Some(ParameterValue::String( - super::default_oob(aesthetic).to_string(), - )), - "reverse" => Some(ParameterValue::Boolean(false)), - "breaks" => Some(ParameterValue::Number( - super::super::breaks::DEFAULT_BREAK_COUNT as f64, - )), - "pretty" => Some(ParameterValue::Boolean(true)), - _ => None, - } + fn default_properties(&self) -> &'static [DefaultParam] { + &[ + DefaultParam { + name: "expand", + default: DefaultParamValue::Number(super::DEFAULT_EXPAND_MULT), + }, + DefaultParam { + name: "oob", + default: DefaultParamValue::Null, // varies by aesthetic + }, + DefaultParam { + name: "reverse", + default: DefaultParamValue::Boolean(false), + }, + DefaultParam { + name: "breaks", + default: DefaultParamValue::Number(super::super::breaks::DEFAULT_BREAK_COUNT as f64), + }, + DefaultParam { + name: "pretty", + default: DefaultParamValue::Boolean(true), + }, + ] } fn default_output_range( diff --git a/src/plot/scale/scale_type/discrete.rs b/src/plot/scale/scale_type/discrete.rs index 10fe57d8..e562e711 100644 --- a/src/plot/scale/scale_type/discrete.rs +++ b/src/plot/scale/scale_type/discrete.rs @@ -4,7 +4,8 @@ use polars::prelude::DataType; use super::super::transform::{Transform, TransformKind}; use super::{ScaleTypeKind, ScaleTypeTrait, SqlTypeNames}; -use crate::plot::{ArrayElement, ParameterValue}; +use crate::plot::types::{DefaultParam, DefaultParamValue}; +use crate::plot::ArrayElement; /// Discrete scale type - for categorical/discrete data #[derive(Debug, Clone, Copy)] @@ -55,16 +56,12 @@ impl ScaleTypeTrait for Discrete { true } - fn allowed_properties(&self, _aesthetic: &str) -> &'static [&'static str] { + fn default_properties(&self) -> &'static [DefaultParam] { // Discrete scales always censor OOB values (no OOB setting needed) - &["reverse"] - } - - fn get_property_default(&self, _aesthetic: &str, name: &str) -> Option { - match name { - "reverse" => Some(ParameterValue::Boolean(false)), - _ => None, - } + &[DefaultParam { + name: "reverse", + default: DefaultParamValue::Boolean(false), + }] } fn allowed_transforms(&self) -> &'static [TransformKind] { diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index 848ce32d..4931b02d 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -27,6 +27,7 @@ use std::sync::Arc; use super::transform::{Transform, TransformKind}; use crate::plot::aesthetic::{is_facet_aesthetic, is_positional_aesthetic}; +use crate::plot::types::{DefaultParam, DefaultParamValue}; use crate::plot::{ArrayElement, ColumnInfo, ParameterValue}; // Scale type implementations @@ -533,20 +534,17 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { Ok(None) // Default implementation: no default range } - /// Returns list of allowed property names for SETTING clause. - /// The aesthetic parameter allows different properties for different aesthetics. + /// Returns list of allowed properties with their default values. + /// + /// Properties that vary by aesthetic (like `expand` for positional-only, or `oob` + /// with aesthetic-dependent defaults) should use `DefaultParamValue::Null` as their + /// default value. The `resolve_properties()` method handles these special cases. + /// /// Default: empty (no properties allowed). - fn allowed_properties(&self, _aesthetic: &str) -> &'static [&'static str] { + fn default_properties(&self) -> &'static [DefaultParam] { &[] } - /// Returns default value for a property, if any. - /// Called by resolve_properties for allowed properties not in user input. - /// The aesthetic parameter allows different defaults for different aesthetics. - fn get_property_default(&self, _aesthetic: &str, _name: &str) -> Option { - None - } - /// Returns the list of transforms this scale type supports. /// Transforms determine how data values are mapped to visual space. /// @@ -620,14 +618,22 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { } /// Resolve and validate properties. NOT meant to be overridden by implementations. - /// - Validates all properties are in allowed_properties() - /// - Applies defaults via get_property_default() + /// - Validates all properties are in default_properties() + /// - Applies defaults, with special handling for aesthetic-dependent properties fn resolve_properties( &self, aesthetic: &str, properties: &HashMap, ) -> Result, String> { - let allowed = self.allowed_properties(aesthetic); + let defaults = self.default_properties(); + let is_positional = is_positional_aesthetic(aesthetic); + + // Build allowed list, excluding "expand" for non-positional aesthetics + let allowed: Vec<&str> = defaults + .iter() + .filter(|p| p.name != "expand" || is_positional) + .map(|p| p.name) + .collect(); // Check for unknown properties for key in properties.keys() { @@ -649,10 +655,21 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { // Start with user properties, add defaults for missing ones let mut resolved = properties.clone(); - for &prop_name in allowed { - if !resolved.contains_key(prop_name) { - if let Some(default) = self.get_property_default(aesthetic, prop_name) { - resolved.insert(prop_name.to_string(), default); + for param in defaults { + // Skip expand for non-positional aesthetics + if param.name == "expand" && !is_positional { + continue; + } + + if !resolved.contains_key(param.name) { + // Special case: oob default varies by aesthetic when marked as Null + if param.name == "oob" && matches!(param.default, DefaultParamValue::Null) { + resolved.insert( + "oob".to_string(), + ParameterValue::String(default_oob(aesthetic).to_string()), + ); + } else if let Some(default) = param.to_parameter_value() { + resolved.insert(param.name.to_string(), default); } } } diff --git a/src/plot/scale/scale_type/ordinal.rs b/src/plot/scale/scale_type/ordinal.rs index 6cba08b4..dfd19025 100644 --- a/src/plot/scale/scale_type/ordinal.rs +++ b/src/plot/scale/scale_type/ordinal.rs @@ -8,7 +8,8 @@ use polars::prelude::DataType; use super::super::transform::{Transform, TransformKind}; use super::{ScaleTypeKind, ScaleTypeTrait, SqlTypeNames}; -use crate::plot::{ArrayElement, ParameterValue}; +use crate::plot::types::{DefaultParam, DefaultParamValue}; +use crate::plot::ArrayElement; /// Ordinal scale type - for ordered categorical data with interpolated output #[derive(Debug, Clone, Copy)] @@ -137,16 +138,12 @@ impl ScaleTypeTrait for Ordinal { )) } - fn allowed_properties(&self, _aesthetic: &str) -> &'static [&'static str] { + fn default_properties(&self) -> &'static [DefaultParam] { // Ordinal scales always censor OOB values (no OOB setting needed) - &["reverse"] - } - - fn get_property_default(&self, _aesthetic: &str, name: &str) -> Option { - match name { - "reverse" => Some(ParameterValue::Boolean(false)), - _ => None, - } + &[DefaultParam { + name: "reverse", + default: DefaultParamValue::Boolean(false), + }] } fn default_output_range( diff --git a/src/plot/types.rs b/src/plot/types.rs index cf48998e..2c2ca167 100644 --- a/src/plot/types.rs +++ b/src/plot/types.rs @@ -907,6 +907,45 @@ impl SqlExpression { } } +// ============================================================================= +// Default Property Types (Shared by Coord, Scale, and Geom traits) +// ============================================================================= + +/// Default value for a property parameter +/// +/// Used by traits to declare both allowed property names and their default values +/// in a single declaration, avoiding the need to keep two separate implementations +/// in sync. +#[derive(Debug, Clone)] +pub enum DefaultParamValue { + String(&'static str), + Number(f64), + Boolean(bool), + Null, +} + +/// Property definition: name and default value +/// +/// Used by `CoordTrait`, `ScaleTypeTrait`, and `GeomTrait` to declare their +/// allowed properties and default values in a single place. +#[derive(Debug, Clone)] +pub struct DefaultParam { + pub name: &'static str, + pub default: DefaultParamValue, +} + +impl DefaultParam { + /// Convert the default value to a ParameterValue, if not Null + pub fn to_parameter_value(&self) -> Option { + match &self.default { + DefaultParamValue::String(s) => Some(ParameterValue::String(s.to_string())), + DefaultParamValue::Number(n) => Some(ParameterValue::Number(*n)), + DefaultParamValue::Boolean(b) => Some(ParameterValue::Boolean(*b)), + DefaultParamValue::Null => None, + } + } +} + #[cfg(test)] mod tests { use super::*; From 99bac9e4bae2de74836f5191204d2b279e9c6030 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 2 Mar 2026 10:36:33 +0100 Subject: [PATCH 19/28] Remove unneeded methods from kind enums --- src/plot/projection/coord/mod.rs | 16 -------------- src/plot/scale/scale_type/discrete.rs | 2 +- src/plot/scale/scale_type/mod.rs | 2 +- src/plot/scale/scale_type/ordinal.rs | 2 +- src/plot/scale/transform/mod.rs | 32 ++++++++++++--------------- 5 files changed, 17 insertions(+), 37 deletions(-) diff --git a/src/plot/projection/coord/mod.rs b/src/plot/projection/coord/mod.rs index 10ff0efd..c6ca9cb4 100644 --- a/src/plot/projection/coord/mod.rs +++ b/src/plot/projection/coord/mod.rs @@ -49,16 +49,6 @@ pub enum CoordKind { Polar, } -impl CoordKind { - /// Get the canonical name for this coord kind - pub fn name(&self) -> &'static str { - match self { - CoordKind::Cartesian => "cartesian", - CoordKind::Polar => "polar", - } - } -} - // ============================================================================= // Coord Trait // ============================================================================= @@ -240,12 +230,6 @@ impl<'de> Deserialize<'de> for Coord { mod tests { use super::*; - #[test] - fn test_coord_kind_name() { - assert_eq!(CoordKind::Cartesian.name(), "cartesian"); - assert_eq!(CoordKind::Polar.name(), "polar"); - } - #[test] fn test_coord_factory_methods() { let cartesian = Coord::cartesian(); diff --git a/src/plot/scale/scale_type/discrete.rs b/src/plot/scale/scale_type/discrete.rs index e562e711..f5204c83 100644 --- a/src/plot/scale/scale_type/discrete.rs +++ b/src/plot/scale/scale_type/discrete.rs @@ -107,7 +107,7 @@ impl ScaleTypeTrait for Discrete { self.name(), self.allowed_transforms() .iter() - .map(|k| k.name()) + .map(|k| k.to_string()) .collect::>() .join(", ") )); diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index 4931b02d..3fd3fa1a 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -608,7 +608,7 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { self.name(), self.allowed_transforms() .iter() - .map(|k| k.name()) + .map(|k| k.to_string()) .collect::>() .join(", ") )) diff --git a/src/plot/scale/scale_type/ordinal.rs b/src/plot/scale/scale_type/ordinal.rs index dfd19025..478896f2 100644 --- a/src/plot/scale/scale_type/ordinal.rs +++ b/src/plot/scale/scale_type/ordinal.rs @@ -118,7 +118,7 @@ impl ScaleTypeTrait for Ordinal { self.name(), self.allowed_transforms() .iter() - .map(|k| k.name()) + .map(|k| k.to_string()) .collect::>() .join(", ") )); diff --git a/src/plot/scale/transform/mod.rs b/src/plot/scale/transform/mod.rs index eb352b2c..ed173eb1 100644 --- a/src/plot/scale/transform/mod.rs +++ b/src/plot/scale/transform/mod.rs @@ -107,9 +107,18 @@ pub enum TransformKind { } impl TransformKind { - /// Returns the canonical name for this transform kind - pub fn name(&self) -> &'static str { - match self { + /// Returns true if this is a temporal transform + pub fn is_temporal(&self) -> bool { + matches!( + self, + TransformKind::Date | TransformKind::DateTime | TransformKind::Time + ) + } +} + +impl std::fmt::Display for TransformKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match self { TransformKind::Identity => "identity", TransformKind::Log10 => "log", TransformKind::Log2 => "log2", @@ -127,21 +136,8 @@ impl TransformKind { TransformKind::String => "string", TransformKind::Bool => "bool", TransformKind::Integer => "integer", - } - } - - /// Returns true if this is a temporal transform - pub fn is_temporal(&self) -> bool { - matches!( - self, - TransformKind::Date | TransformKind::DateTime | TransformKind::Time - ) - } -} - -impl std::fmt::Display for TransformKind { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.name()) + }; + write!(f, "{}", name) } } From 09cf750df98edd440a0c9ecea82df460426406a9 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 2 Mar 2026 11:09:09 +0100 Subject: [PATCH 20/28] simplify grammar --- tree-sitter-ggsql/grammar.js | 26 +++----------- tree-sitter-ggsql/test/corpus/basic.txt | 48 ++++++++++++++++++------- 2 files changed, 41 insertions(+), 33 deletions(-) diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index 5d2935c8..a05f0713 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -780,9 +780,7 @@ module.exports = grammar({ repeat(seq(',', $.identifier)) ), - project_type: $ => choice( - 'cartesian', 'polar' - ), + project_type: $ => $.identifier, project_properties: $ => seq( $.project_property, @@ -795,9 +793,7 @@ module.exports = grammar({ field('value', choice($.string, $.number, $.boolean, $.array, $.identifier)) ), - project_property_name: $ => choice( - 'ratio', 'clip', 'start', 'end', 'inner' - ), + project_property_name: $ => $.identifier, // LABEL clause (repeatable) label_clause: $ => seq( @@ -814,11 +810,7 @@ module.exports = grammar({ field('value', $.string) ), - label_type: $ => choice( - 'title', 'subtitle', 'x', 'y', 'caption', 'tag', - // Aesthetic names for legend titles - 'color', 'colour', 'fill', 'size', 'shape', 'linetype' - ), + label_type: $ => $.identifier, // THEME clause - THEME [name] [SETTING prop => value, ...] theme_clause: $ => choice( @@ -838,9 +830,7 @@ module.exports = grammar({ ) ), - theme_name: $ => choice( - 'minimal', 'classic', 'gray', 'grey', 'bw', 'dark', 'light', 'void' - ), + theme_name: $ => $.identifier, theme_property: $ => seq( field('name', $.theme_property_name), @@ -848,13 +838,7 @@ module.exports = grammar({ field('value', choice($.string, $.number, $.boolean)) ), - theme_property_name: $ => choice( - 'background', 'panel_background', 'panel_grid', 'panel_grid_major', - 'panel_grid_minor', 'text_size', 'text_family', 'title_size', - 'axis_text_size', 'axis_line', 'axis_line_width', 'panel_border', - 'plot_margin', 'panel_spacing', 'legend_background', 'legend_position', - 'legend_direction' - ), + theme_property_name: $ => $.identifier, // Basic tokens bare_identifier: $ => token(/[a-zA-Z_][a-zA-Z0-9_]*/), diff --git a/tree-sitter-ggsql/test/corpus/basic.txt b/tree-sitter-ggsql/test/corpus/basic.txt index 6f277258..3a9f080b 100644 --- a/tree-sitter-ggsql/test/corpus/basic.txt +++ b/tree-sitter-ggsql/test/corpus/basic.txt @@ -490,7 +490,9 @@ THEME minimal (bare_identifier))))) (viz_clause (theme_clause - (theme_name))))) + (theme_name + (identifier + (bare_identifier))))))) ================================================================================ Plot with labels @@ -521,13 +523,19 @@ LABEL title => 'My Plot', x => 'X Axis', y => 'Y Axis' (viz_clause (label_clause (label_assignment - (label_type) + (label_type + (identifier + (bare_identifier))) (string)) (label_assignment - (label_type) + (label_type + (identifier + (bare_identifier))) (string)) (label_assignment - (label_type) + (label_type + (identifier + (bare_identifier))) (string)))))) ================================================================================ @@ -558,7 +566,9 @@ PROJECT TO cartesian (geom_type))) (viz_clause (project_clause - (project_type))))) + (project_type + (identifier + (bare_identifier))))))) ================================================================================ PROJECT x, y TO cartesian (explicit aesthetics) @@ -593,7 +603,9 @@ PROJECT x, y TO cartesian (bare_identifier)) (identifier (bare_identifier))) - (project_type))))) + (project_type + (identifier + (bare_identifier))))))) ================================================================================ PROJECT TO cartesian with SETTING @@ -623,10 +635,14 @@ PROJECT TO cartesian SETTING ratio => 1 (geom_type))) (viz_clause (project_clause - (project_type) + (project_type + (identifier + (bare_identifier))) (project_properties (project_property - (project_property_name) + (project_property_name + (identifier + (bare_identifier))) (number))))))) ================================================================================ @@ -657,7 +673,9 @@ PROJECT TO polar (geom_type))) (viz_clause (project_clause - (project_type))))) + (project_type + (identifier + (bare_identifier))))))) ================================================================================ PROJECT TO polar with SETTING start @@ -687,10 +705,14 @@ PROJECT TO polar SETTING start => 90 (geom_type))) (viz_clause (project_clause - (project_type) + (project_type + (identifier + (bare_identifier))) (project_properties (project_property - (project_property_name) + (project_property_name + (identifier + (bare_identifier))) (number))))))) ================================================================================ @@ -726,7 +748,9 @@ PROJECT a, b TO cartesian (bare_identifier)) (identifier (bare_identifier))) - (project_type))))) + (project_type + (identifier + (bare_identifier))))))) ================================================================================ VISUALISE FROM with CTE From 07cc8e0fb6028942651274aefc17db22038fb960 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 2 Mar 2026 11:19:11 +0100 Subject: [PATCH 21/28] remove intercept suffix --- src/execute/scale.rs | 6 ++-- src/plot/aesthetic.rs | 58 ++++++-------------------------- src/plot/layer/geom/hline.rs | 2 +- src/plot/layer/geom/vline.rs | 2 +- src/plot/main.rs | 6 ++-- src/plot/scale/scale_type/mod.rs | 22 ++---------- src/writer/vegalite/encoding.rs | 3 -- tree-sitter-ggsql/grammar.js | 2 -- 8 files changed, 20 insertions(+), 81 deletions(-) diff --git a/src/execute/scale.rs b/src/execute/scale.rs index d74eda42..4b954bbe 100644 --- a/src/execute/scale.rs +++ b/src/execute/scale.rs @@ -1292,16 +1292,14 @@ mod tests { assert!(pos1_family.iter().any(|s| s == "pos1min")); assert!(pos1_family.iter().any(|s| s == "pos1max")); assert!(pos1_family.iter().any(|s| s == "pos1end")); - assert!(pos1_family.iter().any(|s| s == "pos1intercept")); - assert_eq!(pos1_family.len(), 5); // pos1, pos1min, pos1max, pos1end, pos1intercept + assert_eq!(pos1_family.len(), 4); // pos1, pos1min, pos1max, pos1end let pos2_family = get_aesthetic_family("pos2"); assert!(pos2_family.iter().any(|s| s == "pos2")); assert!(pos2_family.iter().any(|s| s == "pos2min")); assert!(pos2_family.iter().any(|s| s == "pos2max")); assert!(pos2_family.iter().any(|s| s == "pos2end")); - assert!(pos2_family.iter().any(|s| s == "pos2intercept")); - assert_eq!(pos2_family.len(), 5); // pos2, pos2min, pos2max, pos2end, pos2intercept + assert_eq!(pos2_family.len(), 4); // pos2, pos2min, pos2max, pos2end // Test non-positional aesthetics return just themselves let color_family = get_aesthetic_family("color"); diff --git a/src/plot/aesthetic.rs b/src/plot/aesthetic.rs index 2c1224f9..a868e501 100644 --- a/src/plot/aesthetic.rs +++ b/src/plot/aesthetic.rs @@ -28,7 +28,7 @@ /// Positional aesthetic suffixes - applied to primary names to create variant aesthetics /// e.g., "x" + "min" = "xmin", "pos1" + "end" = "pos1end" -pub const POSITIONAL_SUFFIXES: &[&str] = &["min", "max", "end", "intercept"]; +pub const POSITIONAL_SUFFIXES: &[&str] = &["min", "max", "end"]; /// Family size: primary + all suffixes (used for slicing family arrays) const FAMILY_SIZE: usize = 1 + POSITIONAL_SUFFIXES.len(); @@ -440,7 +440,7 @@ pub fn is_facet_aesthetic(aesthetic: &str) -> bool { /// Check if aesthetic is an internal positional (pos1, pos1min, pos2max, etc.) /// /// This function works with **internal** aesthetic names after transformation. -/// Matches patterns like: pos1, pos2, pos1min, pos2max, pos1end, pos2intercept, etc. +/// Matches patterns like: pos1, pos2, pos1min, pos2max, pos1end, etc. /// /// For user-facing checks before transformation, use `AestheticContext::is_user_positional()`. #[inline] @@ -506,7 +506,7 @@ pub fn primary_aesthetic(aesthetic: &str) -> &str { /// This function works with **internal** aesthetic names (pos1, pos2, etc.) and non-positional /// aesthetics. After aesthetic transformation, all positional aesthetics are in internal format. /// -/// For internal positional primary "pos1": returns `["pos1", "pos1min", "pos1max", "pos1end", "pos1intercept"]` +/// For internal positional primary "pos1": returns `["pos1", "pos1min", "pos1max", "pos1end"]` /// For internal positional variant "pos1min": returns just `["pos1min"]` (scales defined on primaries) /// For non-positional aesthetics "color": returns just `["color"]` /// @@ -530,7 +530,7 @@ pub fn get_aesthetic_family(aesthetic: &str) -> Vec { && primary.len() > 3 && primary[3..].chars().all(|c| c.is_ascii_digit()) { - // Build the internal family: pos1 -> [pos1, pos1min, pos1max, pos1end, pos1intercept] + // Build the internal family: pos1 -> [pos1, pos1min, pos1max, pos1end] let mut family = vec![primary.to_string()]; for suffix in POSITIONAL_SUFFIXES { family.push(format!("{}{}", primary, suffix)); @@ -622,8 +622,6 @@ mod tests { assert!(is_positional_aesthetic("pos2max")); assert!(is_positional_aesthetic("pos1end")); assert!(is_positional_aesthetic("pos2end")); - assert!(is_positional_aesthetic("pos1intercept")); - assert!(is_positional_aesthetic("pos2intercept")); // User-facing names are NOT positional (handled by AestheticContext) assert!(!is_positional_aesthetic("x")); @@ -654,11 +652,9 @@ mod tests { assert_eq!(primary_aesthetic("pos1min"), "pos1"); assert_eq!(primary_aesthetic("pos1max"), "pos1"); assert_eq!(primary_aesthetic("pos1end"), "pos1"); - assert_eq!(primary_aesthetic("pos1intercept"), "pos1"); assert_eq!(primary_aesthetic("pos2min"), "pos2"); assert_eq!(primary_aesthetic("pos2max"), "pos2"); assert_eq!(primary_aesthetic("pos2end"), "pos2"); - assert_eq!(primary_aesthetic("pos2intercept"), "pos2"); // Non-positional aesthetics return themselves assert_eq!(primary_aesthetic("color"), "color"); @@ -684,16 +680,14 @@ mod tests { assert!(pos1_family.iter().any(|s| s == "pos1min")); assert!(pos1_family.iter().any(|s| s == "pos1max")); assert!(pos1_family.iter().any(|s| s == "pos1end")); - assert!(pos1_family.iter().any(|s| s == "pos1intercept")); - assert_eq!(pos1_family.len(), 5); + assert_eq!(pos1_family.len(), 4); let pos2_family = get_aesthetic_family("pos2"); assert!(pos2_family.iter().any(|s| s == "pos2")); assert!(pos2_family.iter().any(|s| s == "pos2min")); assert!(pos2_family.iter().any(|s| s == "pos2max")); assert!(pos2_family.iter().any(|s| s == "pos2end")); - assert!(pos2_family.iter().any(|s| s == "pos2intercept")); - assert_eq!(pos2_family.len(), 5); + assert_eq!(pos2_family.len(), 4); // Internal positional variants return just themselves assert_eq!(get_aesthetic_family("pos1min"), vec!["pos1min"]); @@ -731,12 +725,10 @@ mod tests { assert!(all_user.contains(&"xmin")); assert!(all_user.contains(&"xmax")); assert!(all_user.contains(&"xend")); - assert!(all_user.contains(&"xintercept")); assert!(all_user.contains(&"y")); assert!(all_user.contains(&"ymin")); assert!(all_user.contains(&"ymax")); assert!(all_user.contains(&"yend")); - assert!(all_user.contains(&"yintercept")); // Primary internal names let primary: Vec<&str> = ctx.primary_internal().iter().map(|s| s.as_str()).collect(); @@ -760,12 +752,10 @@ mod tests { assert!(all_user.contains(&"thetamin")); assert!(all_user.contains(&"thetamax")); assert!(all_user.contains(&"thetaend")); - assert!(all_user.contains(&"thetaintercept")); assert!(all_user.contains(&"radius")); assert!(all_user.contains(&"radiusmin")); assert!(all_user.contains(&"radiusmax")); assert!(all_user.contains(&"radiusend")); - assert!(all_user.contains(&"radiusintercept")); } #[test] @@ -905,24 +895,17 @@ mod tests { // Get internal family let pos1_family = ctx.get_internal_family("pos1").unwrap(); let pos1_strs: Vec<&str> = pos1_family.iter().map(|s| s.as_str()).collect(); - assert_eq!( - pos1_strs, - vec!["pos1", "pos1min", "pos1max", "pos1end", "pos1intercept"] - ); + assert_eq!(pos1_strs, vec!["pos1", "pos1min", "pos1max", "pos1end"]); // Get user family let x_family = ctx.get_user_family("x").unwrap(); let x_strs: Vec<&str> = x_family.iter().map(|s| s.as_str()).collect(); - assert_eq!(x_strs, vec!["x", "xmin", "xmax", "xend", "xintercept"]); + assert_eq!(x_strs, vec!["x", "xmin", "xmax", "xend"]); // Primary internal aesthetic assert_eq!(ctx.primary_internal_aesthetic("pos1"), Some("pos1")); assert_eq!(ctx.primary_internal_aesthetic("pos1min"), Some("pos1")); assert_eq!(ctx.primary_internal_aesthetic("pos2end"), Some("pos2")); - assert_eq!( - ctx.primary_internal_aesthetic("pos1intercept"), - Some("pos1") - ); assert_eq!(ctx.primary_internal_aesthetic("color"), Some("color")); } @@ -934,7 +917,6 @@ mod tests { assert_eq!(cartesian.primary_user_aesthetic("xmin"), Some("x")); assert_eq!(cartesian.primary_user_aesthetic("xmax"), Some("x")); assert_eq!(cartesian.primary_user_aesthetic("xend"), Some("x")); - assert_eq!(cartesian.primary_user_aesthetic("xintercept"), Some("x")); assert_eq!(cartesian.primary_user_aesthetic("y"), Some("y")); assert_eq!(cartesian.primary_user_aesthetic("ymin"), Some("y")); assert_eq!(cartesian.primary_user_aesthetic("ymax"), Some("y")); @@ -964,37 +946,19 @@ mod tests { // Get user family for theta let theta_family = ctx.get_user_family("theta").unwrap(); let theta_strs: Vec<&str> = theta_family.iter().map(|s| s.as_str()).collect(); - assert_eq!( - theta_strs, - vec![ - "theta", - "thetamin", - "thetamax", - "thetaend", - "thetaintercept" - ] - ); + assert_eq!(theta_strs, vec!["theta", "thetamin", "thetamax", "thetaend"]); // Get user family for radius let radius_family = ctx.get_user_family("radius").unwrap(); let radius_strs: Vec<&str> = radius_family.iter().map(|s| s.as_str()).collect(); assert_eq!( radius_strs, - vec![ - "radius", - "radiusmin", - "radiusmax", - "radiusend", - "radiusintercept" - ] + vec!["radius", "radiusmin", "radiusmax", "radiusend"] ); // But internal families are the same for all coords let pos1_family = ctx.get_internal_family("pos1").unwrap(); let pos1_strs: Vec<&str> = pos1_family.iter().map(|s| s.as_str()).collect(); - assert_eq!( - pos1_strs, - vec!["pos1", "pos1min", "pos1max", "pos1end", "pos1intercept"] - ); + assert_eq!(pos1_strs, vec!["pos1", "pos1min", "pos1max", "pos1end"]); } } diff --git a/src/plot/layer/geom/hline.rs b/src/plot/layer/geom/hline.rs index 34a0ec9c..c9310dae 100644 --- a/src/plot/layer/geom/hline.rs +++ b/src/plot/layer/geom/hline.rs @@ -15,7 +15,7 @@ impl GeomTrait for HLine { fn aesthetics(&self) -> DefaultAesthetics { DefaultAesthetics { defaults: &[ - ("pos2intercept", DefaultAestheticValue::Required), + ("pos2", DefaultAestheticValue::Required), // y position for horizontal line ("stroke", DefaultAestheticValue::String("black")), ("linewidth", DefaultAestheticValue::Number(1.0)), ("opacity", DefaultAestheticValue::Number(1.0)), diff --git a/src/plot/layer/geom/vline.rs b/src/plot/layer/geom/vline.rs index a521e079..a8201404 100644 --- a/src/plot/layer/geom/vline.rs +++ b/src/plot/layer/geom/vline.rs @@ -15,7 +15,7 @@ impl GeomTrait for VLine { fn aesthetics(&self) -> DefaultAesthetics { DefaultAesthetics { defaults: &[ - ("pos1intercept", DefaultAestheticValue::Required), + ("pos1", DefaultAestheticValue::Required), // x position for vertical line ("stroke", DefaultAestheticValue::String("black")), ("linewidth", DefaultAestheticValue::Number(1.0)), ("opacity", DefaultAestheticValue::Number(1.0)), diff --git a/src/plot/main.rs b/src/plot/main.rs index faa088af..ed2b4cc5 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -508,9 +508,9 @@ mod tests { &["pos1", "pos2", "pos1end", "pos2end"] ); - // Reference lines (these are special - they use intercept aesthetics, not positional) - assert_eq!(Geom::hline().aesthetics().required(), &["pos2intercept"]); - assert_eq!(Geom::vline().aesthetics().required(), &["pos1intercept"]); + // Reference lines + assert_eq!(Geom::hline().aesthetics().required(), &["pos2"]); + assert_eq!(Geom::vline().aesthetics().required(), &["pos1"]); assert_eq!( Geom::abline().aesthetics().required(), &["slope", "intercept"] diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index 3fd3fa1a..288360a9 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -2382,16 +2382,7 @@ mod tests { fn test_expand_positional_vs_non_positional() { // Internal positional aesthetics (after transformation) let internal_positional = [ - "pos1", - "pos1min", - "pos1max", - "pos1end", - "pos1intercept", - "pos2", - "pos2min", - "pos2max", - "pos2end", - "pos2intercept", + "pos1", "pos1min", "pos1max", "pos1end", "pos2", "pos2min", "pos2max", "pos2end", ]; let mut props = HashMap::new(); @@ -2423,16 +2414,7 @@ mod tests { fn test_oob_defaults_by_aesthetic_type() { // Internal positional aesthetics (after transformation) let internal_positional = [ - "pos1", - "pos1min", - "pos1max", - "pos1end", - "pos1intercept", - "pos2", - "pos2min", - "pos2max", - "pos2end", - "pos2intercept", + "pos1", "pos1min", "pos1max", "pos1end", "pos2", "pos2min", "pos2max", "pos2end", ]; let props = HashMap::new(); diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 0efa1394..96f51cfb 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -981,9 +981,6 @@ fn map_positional_to_vegalite(aesthetic: &str, coord_kind: CoordKind) -> Option< "pos1max" => Some(format!("{}max", primary)), "pos2min" => Some(format!("{}min", secondary)), "pos2max" => Some(format!("{}max", secondary)), - // Intercept variants (for reference lines) - "pos1intercept" => Some(format!("{}intercept", primary)), - "pos2intercept" => Some(format!("{}intercept", secondary)), _ => None, } } diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index a05f0713..9302f195 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -645,8 +645,6 @@ module.exports = grammar({ // Position aesthetics (polar) 'theta', 'radius', 'thetamin', 'thetamax', 'radiusmin', 'radiusmax', 'thetaend', 'radiusend', - // Reference line intercepts - 'xintercept', 'yintercept', // Aggregation aesthetic (for bar charts) 'weight', // Color aesthetics From e8c28e8ed640f7fb79f8b7ef76998aaef724157a Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 2 Mar 2026 11:19:50 +0100 Subject: [PATCH 22/28] reformat --- src/plot/aesthetic.rs | 5 ++++- src/plot/layer/geom/hline.rs | 2 +- src/plot/layer/geom/vline.rs | 2 +- src/plot/scale/scale_type/binned.rs | 4 +++- src/plot/scale/scale_type/continuous.rs | 4 +++- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/plot/aesthetic.rs b/src/plot/aesthetic.rs index a868e501..52f60f04 100644 --- a/src/plot/aesthetic.rs +++ b/src/plot/aesthetic.rs @@ -946,7 +946,10 @@ mod tests { // Get user family for theta let theta_family = ctx.get_user_family("theta").unwrap(); let theta_strs: Vec<&str> = theta_family.iter().map(|s| s.as_str()).collect(); - assert_eq!(theta_strs, vec!["theta", "thetamin", "thetamax", "thetaend"]); + assert_eq!( + theta_strs, + vec!["theta", "thetamin", "thetamax", "thetaend"] + ); // Get user family for radius let radius_family = ctx.get_user_family("radius").unwrap(); diff --git a/src/plot/layer/geom/hline.rs b/src/plot/layer/geom/hline.rs index c9310dae..e3338c83 100644 --- a/src/plot/layer/geom/hline.rs +++ b/src/plot/layer/geom/hline.rs @@ -15,7 +15,7 @@ impl GeomTrait for HLine { fn aesthetics(&self) -> DefaultAesthetics { DefaultAesthetics { defaults: &[ - ("pos2", DefaultAestheticValue::Required), // y position for horizontal line + ("pos2", DefaultAestheticValue::Required), // y position for horizontal line ("stroke", DefaultAestheticValue::String("black")), ("linewidth", DefaultAestheticValue::Number(1.0)), ("opacity", DefaultAestheticValue::Number(1.0)), diff --git a/src/plot/layer/geom/vline.rs b/src/plot/layer/geom/vline.rs index a8201404..37ec2058 100644 --- a/src/plot/layer/geom/vline.rs +++ b/src/plot/layer/geom/vline.rs @@ -15,7 +15,7 @@ impl GeomTrait for VLine { fn aesthetics(&self) -> DefaultAesthetics { DefaultAesthetics { defaults: &[ - ("pos1", DefaultAestheticValue::Required), // x position for vertical line + ("pos1", DefaultAestheticValue::Required), // x position for vertical line ("stroke", DefaultAestheticValue::String("black")), ("linewidth", DefaultAestheticValue::Number(1.0)), ("opacity", DefaultAestheticValue::Number(1.0)), diff --git a/src/plot/scale/scale_type/binned.rs b/src/plot/scale/scale_type/binned.rs index f8f6922c..1a655eb4 100644 --- a/src/plot/scale/scale_type/binned.rs +++ b/src/plot/scale/scale_type/binned.rs @@ -163,7 +163,9 @@ impl ScaleTypeTrait for Binned { }, DefaultParam { name: "breaks", - default: DefaultParamValue::Number(super::super::breaks::DEFAULT_BREAK_COUNT as f64), + default: DefaultParamValue::Number( + super::super::breaks::DEFAULT_BREAK_COUNT as f64, + ), }, DefaultParam { name: "pretty", diff --git a/src/plot/scale/scale_type/continuous.rs b/src/plot/scale/scale_type/continuous.rs index 65bd8344..a45dd696 100644 --- a/src/plot/scale/scale_type/continuous.rs +++ b/src/plot/scale/scale_type/continuous.rs @@ -107,7 +107,9 @@ impl ScaleTypeTrait for Continuous { }, DefaultParam { name: "breaks", - default: DefaultParamValue::Number(super::super::breaks::DEFAULT_BREAK_COUNT as f64), + default: DefaultParamValue::Number( + super::super::breaks::DEFAULT_BREAK_COUNT as f64, + ), }, DefaultParam { name: "pretty", From 6fa967e8b99b6b8ca4157cc7ddfd4a9436d98a96 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 2 Mar 2026 14:10:15 +0100 Subject: [PATCH 23/28] improve doc based on comments --- doc/syntax/clause/project.qmd | 4 ++-- doc/syntax/coord/cartesian.qmd | 10 ++++++---- doc/syntax/coord/polar.qmd | 9 +-------- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/doc/syntax/clause/project.qmd b/doc/syntax/clause/project.qmd index 427a716d..f0493338 100644 --- a/doc/syntax/clause/project.qmd +++ b/doc/syntax/clause/project.qmd @@ -16,10 +16,10 @@ The comma-separated list of `aesthetic` names are optional but allows you to def ### `TO` ```ggsql -TO , ... +TO ``` -The `TO` clause is required and is followed by the name of the coordinate system. The coordinate system provides default names for the positional aesthetics and is responsible for how to translate values mapped to these onto the plane defined by the screen or paper. +The `TO` clause is required and is followed by the name of the [coordinate system](../index.qmd#coordinate-systems). The coordinate system provides default names for the positional aesthetics and is responsible for how to translate values mapped to these onto the plane defined by the screen or paper. ### `SETTING` ```ggsql diff --git a/doc/syntax/coord/cartesian.qmd b/doc/syntax/coord/cartesian.qmd index 66c9db6f..c807b88d 100644 --- a/doc/syntax/coord/cartesian.qmd +++ b/doc/syntax/coord/cartesian.qmd @@ -2,13 +2,13 @@ title: Cartesian --- -The cartesian coordinate system is the most well-known and the default for ggsql. It maps the primary positional aesthetic along a horizontal axis and the secondary along a perpendicular vertical axis. +The Cartesian coordinate system is the most well-known and the default for ggsql. It maps the primary positional aesthetic along a horizontal axis and the secondary along a perpendicular vertical axis. ## Default aesthetics -The cartesian coordinate system has the following default positional aesthetics which will be used if no others have been provided: +The Cartesian coordinate system has the following default positional aesthetics which will be used if no others have been provided: -* **Primary**: `x` -* **Secondary**: `y` +* **Primary**: `x` (horizontal position) +* **Secondary**: `y` (vertical position) Users can provide their own aesthetic names if needed, e.g. @@ -16,6 +16,8 @@ Users can provide their own aesthetic names if needed, e.g. PROJECT p, q TO cartesian ``` +assuming they do not try to use a name that is already being used by any facet or non-positional aesthetics (e.g. `PROJECT fill, panel TO cartesian` is not allowed). + ## Settings * `clip`: Should data be removed if it appears outside the bounds of the coordinate system. Defaults to `true` * `ratio`: The aspect ratio between the steps on the vertical and horizontal axis. Defaults to `null` (no enforced aspect ratio) diff --git a/doc/syntax/coord/polar.qmd b/doc/syntax/coord/polar.qmd index b2fd6085..ffa8ae51 100644 --- a/doc/syntax/coord/polar.qmd +++ b/doc/syntax/coord/polar.qmd @@ -2,7 +2,7 @@ title: Polar --- -The polar coordinate system interprets its primary aesthetic as the angular position relative to the center, and the secondary aesthetic as the distance from the center. It is most often used for pie-charts, radar plots +The polar coordinate system interprets its primary aesthetic as the angular position relative to the center, and the secondary aesthetic as the distance from the center. It is most often used for pie-charts and radar plots. ## Default aesthetics The polar coordinate system has the following default positional aesthetics which will be used if no others have been provided: @@ -40,13 +40,6 @@ DRAW bar PROJECT TO polar ``` -### Pie chart using x/y aesthetics -```{ggsql} -VISUALISE body_mass AS x, species AS y FROM ggsql:penguins -DRAW bar -PROJECT y, x TO polar -``` - ### Pie chart starting at 3 o'clock ```{ggsql} VISUALISE species AS fill FROM ggsql:penguins From 889b66c6f3536fa88b7f42f8f30fadb7af5b7406 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 2 Mar 2026 14:10:35 +0100 Subject: [PATCH 24/28] refactor AestheticContext --- src/execute/mod.rs | 17 +- src/execute/scale.rs | 136 ++++++-- src/lib.rs | 3 +- src/parser/builder.rs | 13 +- src/plot/aesthetic.rs | 573 +++++--------------------------- src/plot/layer/geom/mod.rs | 3 - src/plot/main.rs | 42 +-- src/plot/types.rs | 16 - src/writer/vegalite/data.rs | 13 +- src/writer/vegalite/encoding.rs | 32 +- src/writer/vegalite/mod.rs | 15 +- 11 files changed, 274 insertions(+), 589 deletions(-) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index eb6d2477..a8f17e07 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -23,7 +23,7 @@ pub use schema::TypeInfo; use crate::naming; use crate::parser; -use crate::plot::aesthetic::{is_positional_aesthetic, primary_aesthetic}; +use crate::plot::aesthetic::{is_positional_aesthetic, AestheticContext}; use crate::plot::facet::{resolve_properties as resolve_facet_properties, FacetDataContext}; use crate::plot::{AestheticValue, Layer, Scale, ScaleTypeKind, Schema}; use crate::{DataFrame, GgsqlError, Plot, Result}; @@ -657,6 +657,7 @@ fn add_discrete_columns_to_partition_by( layers: &mut [Layer], layer_schemas: &[Schema], scales: &[Scale], + aesthetic_ctx: &AestheticContext, ) { // Build a map of aesthetic -> scale for quick lookup let scale_map: HashMap<&str, &Scale> = @@ -698,8 +699,10 @@ fn add_discrete_columns_to_partition_by( // // Discrete and Binned scales produce categorical groupings. // Continuous scales don't group. Identity defers to column type. - let primary_aesthetic = primary_aesthetic(aesthetic); - let is_discrete = if let Some(scale) = scale_map.get(primary_aesthetic) { + let primary_aes = aesthetic_ctx + .primary_internal_positional(aesthetic) + .unwrap_or(aesthetic); + let is_discrete = if let Some(scale) = scale_map.get(primary_aes) { if let Some(ref scale_type) = scale.scale_type { match scale_type.scale_type_kind() { ScaleTypeKind::Discrete @@ -1033,7 +1036,13 @@ pub fn prepare_data_with_reader(query: &str, reader: &R) -> Result = HashSet::new(); // Collect from layer mappings and remappings // (global mappings have already been merged into layers at this point) for layer in &spec.layers { for aesthetic in layer.mappings.aesthetics.keys() { - let primary = primary_aesthetic(aesthetic); + let primary = aesthetic_ctx + .primary_internal_positional(aesthetic) + .unwrap_or(aesthetic); used_aesthetics.insert(primary.to_string()); } for aesthetic in layer.remappings.aesthetics.keys() { - let primary = primary_aesthetic(aesthetic); + let primary = aesthetic_ctx + .primary_internal_positional(aesthetic) + .unwrap_or(aesthetic); used_aesthetics.insert(primary.to_string()); } } @@ -69,12 +73,15 @@ pub fn create_missing_scales(spec: &mut Plot) { /// create_missing_scales() has already run, potentially adding new aesthetics /// that don't have corresponding scales. pub fn create_missing_scales_post_stat(spec: &mut Plot) { + let aesthetic_ctx = spec.get_aesthetic_context(); let mut current_aesthetics: HashSet = HashSet::new(); // Collect all aesthetics currently in layer mappings for layer in &spec.layers { for aesthetic in layer.mappings.aesthetics.keys() { - let primary = primary_aesthetic(aesthetic); + let primary = aesthetic_ctx + .primary_internal_positional(aesthetic) + .unwrap_or(aesthetic); current_aesthetics.insert(primary.to_string()); } } @@ -113,6 +120,8 @@ pub fn apply_post_stat_binning( spec: &Plot, data_map: &mut HashMap, ) -> Result<()> { + let aesthetic_ctx = spec.get_aesthetic_context(); + for scale in &spec.scales { // Only process Binned scales match &scale.scale_type { @@ -140,8 +149,12 @@ pub fn apply_post_stat_binning( }; // Find columns for this aesthetic across layers - let column_sources = - find_columns_for_aesthetic_with_sources(&spec.layers, &scale.aesthetic, data_map); + let column_sources = find_columns_for_aesthetic_with_sources( + &spec.layers, + &scale.aesthetic, + data_map, + &aesthetic_ctx, + ); // Apply binning to each column for (data_key, col_name) in column_sources { @@ -255,6 +268,8 @@ pub fn resolve_scale_types_and_transforms( ) -> Result<()> { use crate::plot::scale::coerce_dtypes; + let aesthetic_ctx = spec.get_aesthetic_context(); + for scale in &mut spec.scales { // Skip scales that already have explicit types (user specified) if let Some(scale_type) = &scale.scale_type { @@ -270,8 +285,12 @@ pub fn resolve_scale_types_and_transforms( } // Collect all dtypes for validation and transform inference - let all_dtypes = - collect_dtypes_for_aesthetic(&spec.layers, &scale.aesthetic, layer_type_info); + let all_dtypes = collect_dtypes_for_aesthetic( + &spec.layers, + &scale.aesthetic, + layer_type_info, + &aesthetic_ctx, + ); // Validate that explicit scale type is compatible with data type if !all_dtypes.is_empty() { @@ -311,8 +330,12 @@ pub fn resolve_scale_types_and_transforms( } // Collect all dtypes for this aesthetic across layers - let all_dtypes = - collect_dtypes_for_aesthetic(&spec.layers, &scale.aesthetic, layer_type_info); + let all_dtypes = collect_dtypes_for_aesthetic( + &spec.layers, + &scale.aesthetic, + layer_type_info, + &aesthetic_ctx, + ); if all_dtypes.is_empty() { continue; @@ -398,9 +421,13 @@ pub fn collect_dtypes_for_aesthetic( layers: &[Layer], aesthetic: &str, layer_type_info: &[Vec], + aesthetic_ctx: &AestheticContext, ) -> Vec { let mut dtypes = Vec::new(); - let aesthetics_to_check = get_aesthetic_family(aesthetic); + let aesthetics_to_check = aesthetic_ctx + .internal_positional_family(aesthetic) + .map(|f| f.to_vec()) + .unwrap_or_else(|| vec![aesthetic.to_string()]); for (layer_idx, layer) in layers.iter().enumerate() { if layer_idx >= layer_type_info.len() { @@ -436,6 +463,8 @@ pub fn collect_dtypes_for_aesthetic( pub fn apply_pre_stat_resolve(spec: &mut Plot, layer_schemas: &[Schema]) -> Result<()> { use crate::plot::scale::ScaleDataContext; + let aesthetic_ctx = spec.get_aesthetic_context(); + for scale in &mut spec.scales { // Only pre-resolve Binned scales let scale_type = match &scale.scale_type { @@ -444,8 +473,12 @@ pub fn apply_pre_stat_resolve(spec: &mut Plot, layer_schemas: &[Schema]) -> Resu }; // Find all ColumnInfos for this aesthetic from schemas - let column_infos = - find_schema_columns_for_aesthetic(&spec.layers, &scale.aesthetic, layer_schemas); + let column_infos = find_schema_columns_for_aesthetic( + &spec.layers, + &scale.aesthetic, + layer_schemas, + &aesthetic_ctx, + ); if column_infos.is_empty() { continue; @@ -478,9 +511,13 @@ pub fn find_schema_columns_for_aesthetic( layers: &[Layer], aesthetic: &str, layer_schemas: &[Schema], + aesthetic_ctx: &AestheticContext, ) -> Vec { let mut infos = Vec::new(); - let aesthetics_to_check = get_aesthetic_family(aesthetic); + let aesthetics_to_check = aesthetic_ctx + .internal_positional_family(aesthetic) + .map(|f| f.to_vec()) + .unwrap_or_else(|| vec![aesthetic.to_string()]); // Check each layer's mapping (global mappings already merged) for (layer_idx, layer) in layers.iter().enumerate() { @@ -825,8 +862,12 @@ pub fn coerce_aesthetic_columns( data_map: &mut HashMap, aesthetic: &str, target_type: ArrayElementType, + aesthetic_ctx: &AestheticContext, ) -> Result<()> { - let aesthetics_to_check = get_aesthetic_family(aesthetic); + let aesthetics_to_check = aesthetic_ctx + .internal_positional_family(aesthetic) + .map(|f| f.to_vec()) + .unwrap_or_else(|| vec![aesthetic.to_string()]); // Track which (data_key, column_name) pairs we've already coerced let mut coerced: HashSet<(String, String)> = HashSet::new(); @@ -882,6 +923,8 @@ pub fn coerce_aesthetic_columns( pub fn resolve_scales(spec: &mut Plot, data_map: &mut HashMap) -> Result<()> { use crate::plot::scale::ScaleDataContext; + let aesthetic_ctx = spec.get_aesthetic_context(); + for idx in 0..spec.scales.len() { // Clone aesthetic to avoid borrow issues with find_columns_for_aesthetic let aesthetic = spec.scales[idx].aesthetic.clone(); @@ -895,12 +938,19 @@ pub fn resolve_scales(spec: &mut Plot, data_map: &mut HashMap // Infer target type and coerce columns if needed // This enables e.g. SCALE DISCRETE color FROM [true, false] to coerce string "true"/"false" to boolean if let Some(target_type) = infer_scale_target_type(&spec.scales[idx]) { - coerce_aesthetic_columns(&spec.layers, data_map, &aesthetic, target_type)?; + coerce_aesthetic_columns( + &spec.layers, + data_map, + &aesthetic, + target_type, + &aesthetic_ctx, + )?; } // Find column references for this aesthetic (including family members) // NOTE: Must be called AFTER coercion so column types are correct - let column_refs = find_columns_for_aesthetic(&spec.layers, &aesthetic, data_map); + let column_refs = + find_columns_for_aesthetic(&spec.layers, &aesthetic, data_map, &aesthetic_ctx); if column_refs.is_empty() { continue; @@ -943,9 +993,13 @@ pub fn find_columns_for_aesthetic<'a>( layers: &[Layer], aesthetic: &str, data_map: &'a HashMap, + aesthetic_ctx: &AestheticContext, ) -> Vec<&'a Column> { let mut column_refs = Vec::new(); - let aesthetics_to_check = get_aesthetic_family(aesthetic); + let aesthetics_to_check = aesthetic_ctx + .internal_positional_family(aesthetic) + .map(|f| f.to_vec()) + .unwrap_or_else(|| vec![aesthetic.to_string()]); // Check each layer's mapping - every layer has its own data for (i, layer) in layers.iter().enumerate() { @@ -977,6 +1031,8 @@ pub fn find_columns_for_aesthetic<'a>( /// - The scale has an explicit input range, AND /// - NULL is not part of the explicit input range pub fn apply_scale_oob(spec: &Plot, data_map: &mut HashMap) -> Result<()> { + let aesthetic_ctx = spec.get_aesthetic_context(); + // First pass: apply OOB transformations (censor sets to NULL, squish clamps) for scale in &spec.scales { // Get oob mode: @@ -1003,8 +1059,12 @@ pub fn apply_scale_oob(spec: &Plot, data_map: &mut HashMap) - }; // Find all (data_key, column_name) pairs for this aesthetic - let column_sources = - find_columns_for_aesthetic_with_sources(&spec.layers, &scale.aesthetic, data_map); + let column_sources = find_columns_for_aesthetic_with_sources( + &spec.layers, + &scale.aesthetic, + data_map, + &aesthetic_ctx, + ); // Helper to check if element is numeric-like (Number, Date, DateTime, Time) fn is_numeric_element(elem: &ArrayElement) -> bool { @@ -1078,8 +1138,12 @@ pub fn apply_scale_oob(spec: &Plot, data_map: &mut HashMap) - continue; } - let column_sources = - find_columns_for_aesthetic_with_sources(&spec.layers, &scale.aesthetic, data_map); + let column_sources = find_columns_for_aesthetic_with_sources( + &spec.layers, + &scale.aesthetic, + data_map, + &aesthetic_ctx, + ); for (data_key, col_name) in column_sources { if let Some(df) = data_map.get(&data_key) { @@ -1102,9 +1166,13 @@ pub fn find_columns_for_aesthetic_with_sources( layers: &[Layer], aesthetic: &str, data_map: &HashMap, + aesthetic_ctx: &AestheticContext, ) -> Vec<(String, String)> { let mut results = Vec::new(); - let aesthetics_to_check = get_aesthetic_family(aesthetic); + let aesthetics_to_check = aesthetic_ctx + .internal_positional_family(aesthetic) + .map(|f| f.to_vec()) + .unwrap_or_else(|| vec![aesthetic.to_string()]); // Check each layer's mapping - every layer has its own data for (i, layer) in layers.iter().enumerate() { @@ -1282,32 +1350,30 @@ mod tests { use polars::prelude::DataType; #[test] - fn test_get_aesthetic_family() { - // Handles internal names (pos1, pos2, etc.) and non-positional aesthetics. - // For user-facing families, use AestheticContext. + fn test_aesthetic_context_internal_family() { + // Test using AestheticContext for internal family lookups + let ctx = AestheticContext::from_static(&["x", "y"], &[]); // Test internal primary aesthetics include all family members - let pos1_family = get_aesthetic_family("pos1"); + let pos1_family = ctx.internal_positional_family("pos1").unwrap(); assert!(pos1_family.iter().any(|s| s == "pos1")); assert!(pos1_family.iter().any(|s| s == "pos1min")); assert!(pos1_family.iter().any(|s| s == "pos1max")); assert!(pos1_family.iter().any(|s| s == "pos1end")); assert_eq!(pos1_family.len(), 4); // pos1, pos1min, pos1max, pos1end - let pos2_family = get_aesthetic_family("pos2"); + let pos2_family = ctx.internal_positional_family("pos2").unwrap(); assert!(pos2_family.iter().any(|s| s == "pos2")); assert!(pos2_family.iter().any(|s| s == "pos2min")); assert!(pos2_family.iter().any(|s| s == "pos2max")); assert!(pos2_family.iter().any(|s| s == "pos2end")); assert_eq!(pos2_family.len(), 4); // pos2, pos2min, pos2max, pos2end - // Test non-positional aesthetics return just themselves - let color_family = get_aesthetic_family("color"); - assert_eq!(color_family, vec!["color"]); + // Test non-positional aesthetics don't have internal family + assert!(ctx.internal_positional_family("color").is_none()); - // Test internal variant aesthetics return just themselves - let pos1min_family = get_aesthetic_family("pos1min"); - assert_eq!(pos1min_family, vec!["pos1min"]); + // Test internal variant aesthetics don't have internal family + assert!(ctx.internal_positional_family("pos1min").is_none()); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 6c70771c..6e3b8074 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -58,8 +58,7 @@ pub use plot::{ // Re-export aesthetic classification utilities pub use plot::aesthetic::{ - get_aesthetic_family, is_positional_aesthetic, is_primary_positional, primary_aesthetic, - AestheticContext, NON_POSITIONAL, POSITIONAL_SUFFIXES, + is_positional_aesthetic, AestheticContext, NON_POSITIONAL, POSITIONAL_SUFFIXES, }; // Future modules - not yet implemented diff --git a/src/parser/builder.rs b/src/parser/builder.rs index a91830a2..820ba89e 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -914,10 +914,11 @@ fn build_project(node: &Node, source: &SourceTree) -> Result { match child.kind() { "PROJECT" | "SETTING" | "TO" | "=>" | "," => continue, "project_aesthetics" => { - user_aesthetics = Some(parse_project_aesthetics(&child, source)?); + let query = "(identifier) @aes"; + user_aesthetics = Some(source.find_texts(&child, query)); } "project_type" => { - coord = parse_coord(&child, source)?; + coord = parse_coord_system(&child, source)?; } "project_properties" => { // Find all project_property nodes @@ -970,12 +971,6 @@ fn build_project(node: &Node, source: &SourceTree) -> Result { }) } -/// Parse project aesthetics from a project_aesthetics node -fn parse_project_aesthetics(node: &Node, source: &SourceTree) -> Result> { - let query = "(identifier) @aes"; - Ok(source.find_texts(node, query)) -} - /// Validate that positional aesthetic names don't conflict with reserved names fn validate_positional_aesthetic_names(names: &[String]) -> Result<()> { use crate::plot::aesthetic::{NON_POSITIONAL, USER_FACET_AESTHETICS}; @@ -1044,7 +1039,7 @@ fn validate_project_properties( } /// Parse coord type from a project_type node -fn parse_coord(node: &Node, source: &SourceTree) -> Result { +fn parse_coord_system(node: &Node, source: &SourceTree) -> Result { let text = source.get_text(node); match text.to_lowercase().as_str() { "cartesian" => Ok(Coord::cartesian()), diff --git a/src/plot/aesthetic.rs b/src/plot/aesthetic.rs index 52f60f04..b6253ed8 100644 --- a/src/plot/aesthetic.rs +++ b/src/plot/aesthetic.rs @@ -22,6 +22,8 @@ //! transformed from user-facing names (x/y or theta/radius) early in the pipeline //! and transformed back for output. This is handled by `AestheticContext`. +use std::collections::HashMap; + // ============================================================================= // Positional Suffixes (applied to primary names automatically) // ============================================================================= @@ -30,9 +32,6 @@ /// e.g., "x" + "min" = "xmin", "pos1" + "end" = "pos1end" pub const POSITIONAL_SUFFIXES: &[&str] = &["min", "max", "end"]; -/// Family size: primary + all suffixes (used for slicing family arrays) -const FAMILY_SIZE: usize = 1 + POSITIONAL_SUFFIXES.len(); - // ============================================================================= // Static Constants (for backward compatibility with existing code) // ============================================================================= @@ -81,7 +80,7 @@ pub const NON_POSITIONAL: &[&str] = &[ /// Comprehensive context for aesthetic operations. /// -/// Pre-computes all mappings at creation time for efficient lookups. +/// Uses HashMaps for efficient O(1) lookups between user-facing and internal aesthetic names. /// Used to transform between user-facing aesthetic names (x/y or theta/radius) /// and internal names (pos1/pos2), as well as facet aesthetics (panel/row/column) /// to internal facet names (facet1/facet2). @@ -95,18 +94,15 @@ pub const NON_POSITIONAL: &[&str] = &[ /// let ctx = AestheticContext::from_static(&["x", "y"], &[]); /// assert_eq!(ctx.map_user_to_internal("x"), Some("pos1")); /// assert_eq!(ctx.map_user_to_internal("ymin"), Some("pos2min")); -/// assert_eq!(ctx.map_internal_to_user("pos1"), Some("x")); /// /// // For polar coords /// let ctx = AestheticContext::from_static(&["theta", "radius"], &[]); /// assert_eq!(ctx.map_user_to_internal("theta"), Some("pos1")); /// assert_eq!(ctx.map_user_to_internal("radius"), Some("pos2")); -/// assert_eq!(ctx.map_internal_to_user("pos1"), Some("theta")); /// /// // With facets /// let ctx = AestheticContext::from_static(&["x", "y"], &["panel"]); /// assert_eq!(ctx.map_user_to_internal("panel"), Some("facet1")); -/// assert_eq!(ctx.map_internal_to_user("facet1"), Some("panel")); /// /// let ctx = AestheticContext::from_static(&["x", "y"], &["row", "column"]); /// assert_eq!(ctx.map_user_to_internal("row"), Some("facet1")); @@ -114,19 +110,22 @@ pub const NON_POSITIONAL: &[&str] = &[ /// ``` #[derive(Debug, Clone)] pub struct AestheticContext { - /// User-facing positional names: ["x", "y"] or ["theta", "radius"] or custom names - user_positional: Vec, - /// All user positional (with suffixes): ["x", "xmin", "xmax", "xend", "y", ...] - all_user_positional: Vec, - /// Primary internal positional: ["pos1", "pos2", ...] - primary_internal: Vec, - /// All internal positional: ["pos1", "pos1min", ..., "pos2", ...] - all_internal_positional: Vec, - /// User-facing facet names: ["panel"] or ["row", "column"] + // User → Internal mapping (O(1) lookups) + user_to_internal: HashMap, + + // Family lookups (internal names only) + internal_to_primary: HashMap, + primary_to_internal_family: HashMap>, + + // For iteration (ordered lists) + user_primaries: Vec, + internal_primaries: Vec, + + // Facet mappings user_facet: Vec<&'static str>, - /// All internal facet names: ["facet1"] or ["facet1", "facet2"] - all_internal_facet: Vec, - /// Non-positional aesthetics (static list) + internal_facet: Vec, + + // Non-positional (static reference) non_positional: &'static [&'static str], } @@ -139,40 +138,57 @@ impl AestheticContext { /// * `facet_names` - User-facing facet aesthetic names from facet layout /// (e.g., ["panel"] for wrap, ["row", "column"] for grid) pub fn new(positional_names: &[String], facet_names: &[&'static str]) -> Self { - // Build positional mappings - let mut all_user = Vec::new(); - let mut primary_internal = Vec::new(); - let mut all_internal = Vec::new(); + // Initialize all HashMaps and vectors + let mut user_to_internal = HashMap::new(); + let mut internal_to_primary = HashMap::new(); + let mut primary_to_internal_family = HashMap::new(); - for (i, primary_name) in positional_names.iter().enumerate() { + let mut user_primaries = Vec::new(); + let mut internal_primaries = Vec::new(); + + // Build positional mappings + for (i, user_primary) in positional_names.iter().enumerate() { let pos_num = i + 1; - let internal_base = format!("pos{}", pos_num); - primary_internal.push(internal_base.clone()); + let internal_primary = format!("pos{}", pos_num); - // Add primary first (e.g., "x", "pos1") - all_user.push(primary_name.clone()); - all_internal.push(internal_base.clone()); + // Track primaries + user_primaries.push(user_primary.clone()); + internal_primaries.push(internal_primary.clone()); - // Then add suffixed variants (e.g., "xmin", "pos1min") + // Build internal family + let mut internal_family = vec![internal_primary.clone()]; + + // Add primary to mappings + user_to_internal.insert(user_primary.clone(), internal_primary.clone()); + internal_to_primary.insert(internal_primary.clone(), internal_primary.clone()); + + // Add suffixed variants for suffix in POSITIONAL_SUFFIXES { - all_user.push(format!("{}{}", primary_name, suffix)); - all_internal.push(format!("{}{}", internal_base, suffix)); + let user_variant = format!("{}{}", user_primary, suffix); + let internal_variant = format!("{}{}", internal_primary, suffix); + + user_to_internal.insert(user_variant, internal_variant.clone()); + internal_to_primary.insert(internal_variant.clone(), internal_primary.clone()); + internal_family.push(internal_variant); } + + // Store internal family + primary_to_internal_family.insert(internal_primary, internal_family); } // Build internal facet names for active facets (from FACET clause or layer mappings) - // These are used for internal→user mapping (to know which user name to show) - let all_internal_facet: Vec = (1..=facet_names.len()) + let internal_facet: Vec = (1..=facet_names.len()) .map(|i| format!("facet{}", i)) .collect(); Self { - user_positional: positional_names.to_vec(), - all_user_positional: all_user, - primary_internal, - all_internal_positional: all_internal, + user_to_internal, + internal_to_primary, + primary_to_internal_family, + user_primaries, + internal_primaries, user_facet: facet_names.to_vec(), - all_internal_facet, + internal_facet, non_positional: NON_POSITIONAL, } } @@ -196,18 +212,14 @@ impl AestheticContext { /// Note: Facet mappings work regardless of whether a FACET clause exists, /// allowing layer-declared facet aesthetics to be transformed. pub fn map_user_to_internal(&self, user_aesthetic: &str) -> Option<&str> { - // Check positional first - if let Some(idx) = self - .all_user_positional - .iter() - .position(|u| u == user_aesthetic) - { - return Some(self.all_internal_positional[idx].as_str()); + // Check positional first (O(1) HashMap lookup) + if let Some(internal) = self.user_to_internal.get(user_aesthetic) { + return Some(internal.as_str()); } // Check active facet (from FACET clause) if let Some(idx) = self.user_facet.iter().position(|u| *u == user_aesthetic) { - return Some(self.all_internal_facet[idx].as_str()); + return Some(self.internal_facet[idx].as_str()); } // Always map user-facing facet names to internal names, @@ -222,47 +234,11 @@ impl AestheticContext { } } - // === Mapping: Internal → User === - - /// Map internal aesthetic (positional or facet) to user-facing name. - /// - /// Positional: "pos1" → "x", "pos2min" → "ymin" - /// Facet: "facet1" → "panel" (or "row"), "facet2" → "column" - pub fn map_internal_to_user(&self, internal_aesthetic: &str) -> Option<&str> { - // Check positional first - if let Some(idx) = self - .all_internal_positional - .iter() - .position(|i| i == internal_aesthetic) - { - return Some(self.all_user_positional[idx].as_str()); - } - // Check facet - if let Some(idx) = self - .all_internal_facet - .iter() - .position(|i| i == internal_aesthetic) - { - return Some(self.user_facet[idx]); - } - None - } - - // === Checking (simple lookups in pre-computed lists) === - - /// Check if user aesthetic is a positional (x, y, xmin, theta, etc.) - pub fn is_user_positional(&self, name: &str) -> bool { - self.all_user_positional.iter().any(|s| s == name) - } - - /// Check if internal aesthetic is positional (pos1, pos1min, etc.) - pub fn is_internal_positional(&self, name: &str) -> bool { - self.all_internal_positional.iter().any(|s| s == name) - } + // === Checking (O(1) HashMap lookups) === /// Check if internal aesthetic is primary positional (pos1, pos2, ...) pub fn is_primary_internal(&self, name: &str) -> bool { - self.primary_internal.iter().any(|s| s == name) + self.internal_primaries.iter().any(|s| s == name) } /// Check if aesthetic is non-positional (color, size, etc.) @@ -277,7 +253,7 @@ impl AestheticContext { /// Check if name is an internal facet aesthetic (facet1, facet2) pub fn is_internal_facet(&self, name: &str) -> bool { - self.all_internal_facet.iter().any(|f| f == name) + self.internal_facet.iter().any(|f| f == name) } /// Check if name is a facet aesthetic (user or internal) @@ -285,23 +261,16 @@ impl AestheticContext { self.is_user_facet(name) || self.is_internal_facet(name) } - // === Aesthetic Families === + // === Aesthetic Families (O(1) HashMap lookups) === - /// Get the primary aesthetic for a family member. + /// Get the primary aesthetic for an internal family member. /// /// e.g., "pos1min" → "pos1", "pos2end" → "pos2" /// Non-positional aesthetics return themselves. - pub fn primary_internal_aesthetic<'a>(&'a self, name: &'a str) -> Option<&'a str> { - // Check internal positional - find which primary it belongs to - for (i, primary) in self.primary_internal.iter().enumerate() { - let start = i * FAMILY_SIZE; - let end = start + FAMILY_SIZE; - if self.all_internal_positional[start..end] - .iter() - .any(|s| s == name) - { - return Some(primary.as_str()); - } + pub fn primary_internal_positional<'a>(&'a self, name: &'a str) -> Option<&'a str> { + // Check internal positional (O(1) lookup) + if let Some(primary) = self.internal_to_primary.get(name) { + return Some(primary.as_str()); } // Non-positional aesthetics are their own primary if self.is_non_positional(name) { @@ -310,106 +279,31 @@ impl AestheticContext { None } - /// Get the aesthetic family for a primary aesthetic. + /// Get the internal aesthetic family for a primary aesthetic. /// /// e.g., "pos1" → ["pos1", "pos1min", "pos1max", "pos1end"] - pub fn get_internal_family(&self, primary: &str) -> Option<&[String]> { - for (i, p) in self.primary_internal.iter().enumerate() { - if p == primary { - let start = i * FAMILY_SIZE; - let end = start + FAMILY_SIZE; - return Some(&self.all_internal_positional[start..end]); - } - } - None - } - - /// Get the user-facing family for a user primary aesthetic. - /// - /// e.g., "x" → ["x", "xmin", "xmax", "xend"] - pub fn get_user_family(&self, user_primary: &str) -> Option<&[String]> { - for (i, p) in self.user_positional.iter().enumerate() { - if *p == user_primary { - let start = i * FAMILY_SIZE; - let end = start + FAMILY_SIZE; - return Some(&self.all_user_positional[start..end]); - } - } - None - } - - /// Get the primary user-facing aesthetic for a user variant. - /// - /// e.g., "xmin" → "x", "thetamax" → "theta", "color" → "color" - /// Returns None if the aesthetic is not recognized. - pub fn primary_user_aesthetic<'a>(&'a self, name: &'a str) -> Option<&'a str> { - // Check user positional - find which primary it belongs to - for (i, primary) in self.user_positional.iter().enumerate() { - let start = i * FAMILY_SIZE; - let end = start + FAMILY_SIZE; - if self.all_user_positional[start..end] - .iter() - .any(|s| s == name) - { - return Some(primary); - } - } - // Non-positional aesthetics are their own primary - if self.is_non_positional(name) { - return Some(name); - } - None + pub fn internal_positional_family(&self, primary: &str) -> Option<&[String]> { + self.primary_to_internal_family + .get(primary) + .map(|v| v.as_slice()) } // === Accessors === - /// Get all internal positional aesthetics (pos1, pos1min, ..., pos2, ...) - pub fn all_internal_positional(&self) -> &[String] { - &self.all_internal_positional - } - /// Get primary internal positional aesthetics (pos1, pos2, ...) - pub fn primary_internal(&self) -> &[String] { - &self.primary_internal + pub fn internal_positional(&self) -> &[String] { + &self.internal_primaries } /// Get user positional aesthetics (x, y or theta, radius or custom names) pub fn user_positional(&self) -> &[String] { - &self.user_positional - } - - /// Get all user positional aesthetics with suffixes (x, xmin, xmax, xend, ...) - pub fn all_user_positional(&self) -> &[String] { - &self.all_user_positional + &self.user_primaries } /// Get user-facing facet aesthetics (panel, row, column) pub fn user_facet(&self) -> &[&'static str] { &self.user_facet } - - /// Get all internal facet aesthetics (facet1, facet2) - pub fn all_internal_facet(&self) -> &[String] { - &self.all_internal_facet - } - - /// Get non-positional aesthetics - pub fn non_positional(&self) -> &'static [&'static str] { - self.non_positional - } -} - -/// Check if aesthetic is a primary internal positional (pos1, pos2, etc.) -/// -/// This function works with **internal** aesthetic names after transformation. -/// For user-facing checks before transformation, use `AestheticContext::is_user_positional()`. -#[inline] -pub fn is_primary_positional(aesthetic: &str) -> bool { - // Check if it matches pattern: pos followed by digits only - if aesthetic.starts_with("pos") && aesthetic.len() > 3 { - return aesthetic[3..].chars().all(|c| c.is_ascii_digit()); - } - false } /// Check if aesthetic is a user-facing facet aesthetic (panel, row, column) @@ -470,105 +364,10 @@ pub fn is_positional_aesthetic(name: &str) -> bool { false } -/// Get the primary aesthetic for a given aesthetic name. -/// -/// This function works with **internal** aesthetic names (pos1, pos2, etc.) and non-positional -/// aesthetics. After aesthetic transformation, all positional aesthetics are in internal format. -/// -/// For internal positional variants: "pos1min" → "pos1", "pos2end" → "pos2" -/// For non-positional aesthetics: "color" → "color", "fill" → "fill" -/// -/// Note: For user-facing aesthetic families (before transformation), use -/// `AestheticContext::primary_user_aesthetic()` instead. -#[inline] -pub fn primary_aesthetic(aesthetic: &str) -> &str { - // Handle internal positional variants (pos1min -> pos1, pos2end -> pos2, etc.) - if aesthetic.starts_with("pos") { - for suffix in POSITIONAL_SUFFIXES { - if let Some(base) = aesthetic.strip_suffix(suffix) { - // Extract the base: pos1min -> pos1, pos2end -> pos2 - // Verify it's a valid positional (pos followed by digits) - if base.len() > 3 && base[3..].chars().all(|c| c.is_ascii_digit()) { - // Return static str by leaking - this is acceptable for a small fixed set - // In practice this is only called with a limited set of aesthetics - return Box::leak(base.to_string().into_boxed_str()); - } - } - } - } - - // Non-positional aesthetics (and internal primaries) return themselves - aesthetic -} - -/// Get all aesthetics in the same family as the given aesthetic. -/// -/// This function works with **internal** aesthetic names (pos1, pos2, etc.) and non-positional -/// aesthetics. After aesthetic transformation, all positional aesthetics are in internal format. -/// -/// For internal positional primary "pos1": returns `["pos1", "pos1min", "pos1max", "pos1end"]` -/// For internal positional variant "pos1min": returns just `["pos1min"]` (scales defined on primaries) -/// For non-positional aesthetics "color": returns just `["color"]` -/// -/// This is used by scale resolution to find all columns that contribute to a scale's -/// input range (e.g., both `pos2min` and `pos2max` columns contribute to the "pos2" scale). -/// -/// Note: For user-facing aesthetic families (before transformation), use -/// `AestheticContext::get_user_family()` instead. -pub fn get_aesthetic_family(aesthetic: &str) -> Vec { - // First, determine the primary aesthetic - let primary = primary_aesthetic(aesthetic); - - // If aesthetic is not a primary (it's a variant), just return the aesthetic itself - // since scales should be defined for primary aesthetics - if primary != aesthetic { - return vec![aesthetic.to_string()]; - } - - // Check if this is an internal positional (pos1, pos2, etc.) - if primary.starts_with("pos") - && primary.len() > 3 - && primary[3..].chars().all(|c| c.is_ascii_digit()) - { - // Build the internal family: pos1 -> [pos1, pos1min, pos1max, pos1end] - let mut family = vec![primary.to_string()]; - for suffix in POSITIONAL_SUFFIXES { - family.push(format!("{}{}", primary, suffix)); - } - return family; - } - - // Non-positional aesthetics don't have families, just return themselves - vec![aesthetic.to_string()] -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn test_primary_positional() { - // is_primary_positional() checks for internal names (pos1, pos2, etc.) - assert!(is_primary_positional("pos1")); - assert!(is_primary_positional("pos2")); - assert!(is_primary_positional("pos10")); // supports any number - - // Variants are not primary - assert!(!is_primary_positional("pos1min")); - assert!(!is_primary_positional("pos2max")); - - // User-facing names are NOT primary positional (handled by AestheticContext) - assert!(!is_primary_positional("x")); - assert!(!is_primary_positional("y")); - - // Non-positional - assert!(!is_primary_positional("color")); - - // Edge cases - assert!(!is_primary_positional("pos")); // too short - assert!(!is_primary_positional("position")); // not a valid pattern - } - #[test] fn test_facet_aesthetic() { // Internal facet aesthetics (after transformation) @@ -639,71 +438,6 @@ mod tests { assert!(!is_positional_aesthetic("position")); // not a valid pattern } - #[test] - fn test_primary_aesthetic() { - // Handles internal names (pos1, pos2, etc.) and non-positional aesthetics. - // For user-facing families, use AestheticContext. - - // Internal positional primaries return themselves - assert_eq!(primary_aesthetic("pos1"), "pos1"); - assert_eq!(primary_aesthetic("pos2"), "pos2"); - - // Internal positional variants return their primary - assert_eq!(primary_aesthetic("pos1min"), "pos1"); - assert_eq!(primary_aesthetic("pos1max"), "pos1"); - assert_eq!(primary_aesthetic("pos1end"), "pos1"); - assert_eq!(primary_aesthetic("pos2min"), "pos2"); - assert_eq!(primary_aesthetic("pos2max"), "pos2"); - assert_eq!(primary_aesthetic("pos2end"), "pos2"); - - // Non-positional aesthetics return themselves - assert_eq!(primary_aesthetic("color"), "color"); - assert_eq!(primary_aesthetic("fill"), "fill"); - assert_eq!(primary_aesthetic("size"), "size"); - - // User-facing names without internal family handling return themselves - // (user-facing family resolution is handled by AestheticContext) - assert_eq!(primary_aesthetic("x"), "x"); - assert_eq!(primary_aesthetic("y"), "y"); - assert_eq!(primary_aesthetic("xmin"), "xmin"); - assert_eq!(primary_aesthetic("ymax"), "ymax"); - } - - #[test] - fn test_get_aesthetic_family() { - // Handles internal names (pos1, pos2, etc.) and non-positional aesthetics. - // For user-facing families, use AestheticContext. - - // Internal positional primary returns full family - let pos1_family = get_aesthetic_family("pos1"); - assert!(pos1_family.iter().any(|s| s == "pos1")); - assert!(pos1_family.iter().any(|s| s == "pos1min")); - assert!(pos1_family.iter().any(|s| s == "pos1max")); - assert!(pos1_family.iter().any(|s| s == "pos1end")); - assert_eq!(pos1_family.len(), 4); - - let pos2_family = get_aesthetic_family("pos2"); - assert!(pos2_family.iter().any(|s| s == "pos2")); - assert!(pos2_family.iter().any(|s| s == "pos2min")); - assert!(pos2_family.iter().any(|s| s == "pos2max")); - assert!(pos2_family.iter().any(|s| s == "pos2end")); - assert_eq!(pos2_family.len(), 4); - - // Internal positional variants return just themselves - assert_eq!(get_aesthetic_family("pos1min"), vec!["pos1min"]); - assert_eq!(get_aesthetic_family("pos2max"), vec!["pos2max"]); - - // Non-positional aesthetics return just themselves (no family) - assert_eq!(get_aesthetic_family("color"), vec!["color"]); - assert_eq!(get_aesthetic_family("fill"), vec!["fill"]); - - // User-facing names without families return just themselves - // (user-facing family resolution is handled by AestheticContext) - assert_eq!(get_aesthetic_family("x"), vec!["x"]); - assert_eq!(get_aesthetic_family("y"), vec!["y"]); - assert_eq!(get_aesthetic_family("xmin"), vec!["xmin"]); - } - // ======================================================================== // AestheticContext Tests // ======================================================================== @@ -715,23 +449,12 @@ mod tests { // User positional names assert_eq!(ctx.user_positional(), &["x", "y"]); - // All user positional (with suffixes) - let all_user: Vec<&str> = ctx - .all_user_positional() + // Primary internal names + let primary: Vec<&str> = ctx + .internal_positional() .iter() .map(|s| s.as_str()) .collect(); - assert!(all_user.contains(&"x")); - assert!(all_user.contains(&"xmin")); - assert!(all_user.contains(&"xmax")); - assert!(all_user.contains(&"xend")); - assert!(all_user.contains(&"y")); - assert!(all_user.contains(&"ymin")); - assert!(all_user.contains(&"ymax")); - assert!(all_user.contains(&"yend")); - - // Primary internal names - let primary: Vec<&str> = ctx.primary_internal().iter().map(|s| s.as_str()).collect(); assert_eq!(primary, vec!["pos1", "pos2"]); } @@ -742,20 +465,13 @@ mod tests { // User positional names assert_eq!(ctx.user_positional(), &["theta", "radius"]); - // All user positional (with suffixes) - let all_user: Vec<&str> = ctx - .all_user_positional() + // Primary internal names + let primary: Vec<&str> = ctx + .internal_positional() .iter() .map(|s| s.as_str()) .collect(); - assert!(all_user.contains(&"theta")); - assert!(all_user.contains(&"thetamin")); - assert!(all_user.contains(&"thetamax")); - assert!(all_user.contains(&"thetaend")); - assert!(all_user.contains(&"radius")); - assert!(all_user.contains(&"radiusmin")); - assert!(all_user.contains(&"radiusmax")); - assert!(all_user.contains(&"radiusend")); + assert_eq!(primary, vec!["pos1", "pos2"]); } #[test] @@ -779,27 +495,6 @@ mod tests { assert_eq!(ctx.map_user_to_internal("fill"), None); } - #[test] - fn test_aesthetic_context_internal_to_user() { - let ctx = AestheticContext::from_static(&["x", "y"], &[]); - - // Primary aesthetics - assert_eq!(ctx.map_internal_to_user("pos1"), Some("x")); - assert_eq!(ctx.map_internal_to_user("pos2"), Some("y")); - - // Variants - assert_eq!(ctx.map_internal_to_user("pos1min"), Some("xmin")); - assert_eq!(ctx.map_internal_to_user("pos1max"), Some("xmax")); - assert_eq!(ctx.map_internal_to_user("pos1end"), Some("xend")); - assert_eq!(ctx.map_internal_to_user("pos2min"), Some("ymin")); - assert_eq!(ctx.map_internal_to_user("pos2max"), Some("ymax")); - assert_eq!(ctx.map_internal_to_user("pos2end"), Some("yend")); - - // Unknown internal returns None - assert_eq!(ctx.map_internal_to_user("pos3"), None); - assert_eq!(ctx.map_internal_to_user("color"), None); - } - #[test] fn test_aesthetic_context_polar_mapping() { let ctx = AestheticContext::from_static(&["theta", "radius"], &[]); @@ -809,40 +504,18 @@ mod tests { assert_eq!(ctx.map_user_to_internal("radius"), Some("pos2")); assert_eq!(ctx.map_user_to_internal("thetaend"), Some("pos1end")); assert_eq!(ctx.map_user_to_internal("radiusmin"), Some("pos2min")); - - // Internal to user - assert_eq!(ctx.map_internal_to_user("pos1"), Some("theta")); - assert_eq!(ctx.map_internal_to_user("pos2"), Some("radius")); - assert_eq!(ctx.map_internal_to_user("pos1end"), Some("thetaend")); - assert_eq!(ctx.map_internal_to_user("pos2min"), Some("radiusmin")); } #[test] - fn test_aesthetic_context_is_checks() { + fn test_aesthetic_context_is_primary_internal() { let ctx = AestheticContext::from_static(&["x", "y"], &[]); - // User positional - assert!(ctx.is_user_positional("x")); - assert!(ctx.is_user_positional("ymin")); - assert!(!ctx.is_user_positional("color")); - assert!(!ctx.is_user_positional("pos1")); - - // Internal positional - assert!(ctx.is_internal_positional("pos1")); - assert!(ctx.is_internal_positional("pos2min")); - assert!(!ctx.is_internal_positional("x")); - assert!(!ctx.is_internal_positional("color")); - // Primary internal assert!(ctx.is_primary_internal("pos1")); assert!(ctx.is_primary_internal("pos2")); assert!(!ctx.is_primary_internal("pos1min")); - - // Non-positional - assert!(ctx.is_non_positional("color")); - assert!(ctx.is_non_positional("fill")); - assert!(!ctx.is_non_positional("x")); - assert!(!ctx.is_non_positional("pos1")); + assert!(!ctx.is_primary_internal("x")); + assert!(!ctx.is_primary_internal("color")); } #[test] @@ -860,7 +533,6 @@ mod tests { // Check mapping assert_eq!(ctx.map_user_to_internal("panel"), Some("facet1")); - assert_eq!(ctx.map_internal_to_user("facet1"), Some("panel")); // Check combined is_facet assert!(ctx.is_facet("panel")); // user @@ -884,8 +556,6 @@ mod tests { // Check mappings assert_eq!(ctx.map_user_to_internal("row"), Some("facet1")); assert_eq!(ctx.map_user_to_internal("column"), Some("facet2")); - assert_eq!(ctx.map_internal_to_user("facet1"), Some("row")); - assert_eq!(ctx.map_internal_to_user("facet2"), Some("column")); } #[test] @@ -893,75 +563,14 @@ mod tests { let ctx = AestheticContext::from_static(&["x", "y"], &[]); // Get internal family - let pos1_family = ctx.get_internal_family("pos1").unwrap(); + let pos1_family = ctx.internal_positional_family("pos1").unwrap(); let pos1_strs: Vec<&str> = pos1_family.iter().map(|s| s.as_str()).collect(); assert_eq!(pos1_strs, vec!["pos1", "pos1min", "pos1max", "pos1end"]); - // Get user family - let x_family = ctx.get_user_family("x").unwrap(); - let x_strs: Vec<&str> = x_family.iter().map(|s| s.as_str()).collect(); - assert_eq!(x_strs, vec!["x", "xmin", "xmax", "xend"]); - // Primary internal aesthetic - assert_eq!(ctx.primary_internal_aesthetic("pos1"), Some("pos1")); - assert_eq!(ctx.primary_internal_aesthetic("pos1min"), Some("pos1")); - assert_eq!(ctx.primary_internal_aesthetic("pos2end"), Some("pos2")); - assert_eq!(ctx.primary_internal_aesthetic("color"), Some("color")); - } - - #[test] - fn test_aesthetic_context_user_family_resolution() { - // Cartesian: user-facing families are x/y based - let cartesian = AestheticContext::from_static(&["x", "y"], &[]); - assert_eq!(cartesian.primary_user_aesthetic("x"), Some("x")); - assert_eq!(cartesian.primary_user_aesthetic("xmin"), Some("x")); - assert_eq!(cartesian.primary_user_aesthetic("xmax"), Some("x")); - assert_eq!(cartesian.primary_user_aesthetic("xend"), Some("x")); - assert_eq!(cartesian.primary_user_aesthetic("y"), Some("y")); - assert_eq!(cartesian.primary_user_aesthetic("ymin"), Some("y")); - assert_eq!(cartesian.primary_user_aesthetic("ymax"), Some("y")); - assert_eq!(cartesian.primary_user_aesthetic("color"), Some("color")); - - // Polar: user-facing families are theta/radius based - let polar = AestheticContext::from_static(&["theta", "radius"], &[]); - assert_eq!(polar.primary_user_aesthetic("theta"), Some("theta")); - assert_eq!(polar.primary_user_aesthetic("thetamin"), Some("theta")); - assert_eq!(polar.primary_user_aesthetic("thetamax"), Some("theta")); - assert_eq!(polar.primary_user_aesthetic("thetaend"), Some("theta")); - assert_eq!(polar.primary_user_aesthetic("radius"), Some("radius")); - assert_eq!(polar.primary_user_aesthetic("radiusmin"), Some("radius")); - assert_eq!(polar.primary_user_aesthetic("radiusmax"), Some("radius")); - assert_eq!(polar.primary_user_aesthetic("color"), Some("color")); - - // Polar doesn't know about cartesian aesthetics - assert_eq!(polar.primary_user_aesthetic("x"), None); - assert_eq!(polar.primary_user_aesthetic("xmin"), None); - } - - #[test] - fn test_aesthetic_context_polar_user_families() { - // Verify polar coords have correct user families - let ctx = AestheticContext::from_static(&["theta", "radius"], &[]); - - // Get user family for theta - let theta_family = ctx.get_user_family("theta").unwrap(); - let theta_strs: Vec<&str> = theta_family.iter().map(|s| s.as_str()).collect(); - assert_eq!( - theta_strs, - vec!["theta", "thetamin", "thetamax", "thetaend"] - ); - - // Get user family for radius - let radius_family = ctx.get_user_family("radius").unwrap(); - let radius_strs: Vec<&str> = radius_family.iter().map(|s| s.as_str()).collect(); - assert_eq!( - radius_strs, - vec!["radius", "radiusmin", "radiusmax", "radiusend"] - ); - - // But internal families are the same for all coords - let pos1_family = ctx.get_internal_family("pos1").unwrap(); - let pos1_strs: Vec<&str> = pos1_family.iter().map(|s| s.as_str()).collect(); - assert_eq!(pos1_strs, vec!["pos1", "pos1min", "pos1max", "pos1end"]); + assert_eq!(ctx.primary_internal_positional("pos1"), Some("pos1")); + assert_eq!(ctx.primary_internal_positional("pos1min"), Some("pos1")); + assert_eq!(ctx.primary_internal_positional("pos2end"), Some("pos2")); + assert_eq!(ctx.primary_internal_positional("color"), Some("color")); } } diff --git a/src/plot/layer/geom/mod.rs b/src/plot/layer/geom/mod.rs index 518f2a46..c953c9f7 100644 --- a/src/plot/layer/geom/mod.rs +++ b/src/plot/layer/geom/mod.rs @@ -53,9 +53,6 @@ mod vline; // Re-export types pub use types::{DefaultAesthetics, DefaultParam, DefaultParamValue, StatResult}; -// Re-export aesthetic family utilities from the central module -pub use crate::plot::aesthetic::get_aesthetic_family; - // Re-export geom structs for direct access if needed pub use abline::AbLine; pub use area::Area; diff --git a/src/plot/main.rs b/src/plot/main.rs index ed2b4cc5..a211ac34 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -35,8 +35,6 @@ pub use super::layer::geom::{ DefaultAesthetics, DefaultParam, DefaultParamValue, Geom, GeomTrait, GeomType, StatResult, }; -use super::aesthetic::primary_aesthetic; - // Re-export Layer from the layer module pub use super::layer::Layer; @@ -221,6 +219,9 @@ impl Plot { /// - Primary aesthetics always take precedence over variants for labels /// - Variant aesthetics can still contribute labels when the primary doesn't exist pub fn compute_aesthetic_labels(&mut self) { + // Get aesthetic context before borrowing labels mutably + let aesthetic_ctx = self.get_aesthetic_context(); + // Ensure Labels struct exists if self.labels.is_none() { self.labels = Some(Labels { @@ -234,7 +235,9 @@ impl Plot { for primaries_only in [true, false] { for layer in &self.layers { for (aesthetic, value) in &layer.mappings.aesthetics { - let primary = primary_aesthetic(aesthetic); + let primary = aesthetic_ctx + .primary_internal_positional(aesthetic) + .unwrap_or(aesthetic); let is_primary = aesthetic == primary; // First pass: only primaries; second pass: only variants @@ -522,27 +525,28 @@ mod tests { #[test] fn test_aesthetic_family_primary_lookup() { - // Handles internal names (pos1, pos2, etc.) and non-positional aesthetics. - // For user-facing families, use AestheticContext. + // Test using AestheticContext for internal aesthetic family lookups + use crate::plot::aesthetic::AestheticContext; + let ctx = AestheticContext::from_static(&["x", "y"], &[]); // Test that internal variant aesthetics map to their primary - assert_eq!(primary_aesthetic("pos1"), "pos1"); - assert_eq!(primary_aesthetic("pos1min"), "pos1"); - assert_eq!(primary_aesthetic("pos1max"), "pos1"); - assert_eq!(primary_aesthetic("pos1end"), "pos1"); - assert_eq!(primary_aesthetic("pos2"), "pos2"); - assert_eq!(primary_aesthetic("pos2min"), "pos2"); - assert_eq!(primary_aesthetic("pos2max"), "pos2"); - assert_eq!(primary_aesthetic("pos2end"), "pos2"); + assert_eq!(ctx.primary_internal_positional("pos1"), Some("pos1")); + assert_eq!(ctx.primary_internal_positional("pos1min"), Some("pos1")); + assert_eq!(ctx.primary_internal_positional("pos1max"), Some("pos1")); + assert_eq!(ctx.primary_internal_positional("pos1end"), Some("pos1")); + assert_eq!(ctx.primary_internal_positional("pos2"), Some("pos2")); + assert_eq!(ctx.primary_internal_positional("pos2min"), Some("pos2")); + assert_eq!(ctx.primary_internal_positional("pos2max"), Some("pos2")); + assert_eq!(ctx.primary_internal_positional("pos2end"), Some("pos2")); // Non-positional aesthetics return themselves - assert_eq!(primary_aesthetic("color"), "color"); - assert_eq!(primary_aesthetic("size"), "size"); - assert_eq!(primary_aesthetic("fill"), "fill"); + assert_eq!(ctx.primary_internal_positional("color"), Some("color")); + assert_eq!(ctx.primary_internal_positional("size"), Some("size")); + assert_eq!(ctx.primary_internal_positional("fill"), Some("fill")); - // User-facing names return themselves (family resolution via AestheticContext) - assert_eq!(primary_aesthetic("x"), "x"); - assert_eq!(primary_aesthetic("xmin"), "xmin"); + // User-facing names are not recognized as internal aesthetics + assert_eq!(ctx.primary_internal_positional("x"), None); + assert_eq!(ctx.primary_internal_positional("xmin"), None); } #[test] diff --git a/src/plot/types.rs b/src/plot/types.rs index 2c2ca167..95bfc2d6 100644 --- a/src/plot/types.rs +++ b/src/plot/types.rs @@ -124,22 +124,6 @@ impl Mappings { self.aesthetics.insert(internal_name, value); } } - - /// Transform aesthetic keys from internal to user-facing names. - /// - /// Uses the provided AestheticContext to map internal positional aesthetic names - /// (e.g., "pos1", "pos2") to user-facing names (e.g., "x", "y", "theta", "radius"). - /// Non-positional aesthetics (e.g., "color", "size") are left unchanged. - pub fn transform_to_user(&mut self, ctx: &super::AestheticContext) { - let original_aesthetics = std::mem::take(&mut self.aesthetics); - for (aesthetic, value) in original_aesthetics { - let user_name = ctx - .map_internal_to_user(&aesthetic) - .map(|s| s.to_string()) - .unwrap_or(aesthetic); - self.aesthetics.insert(user_name, value); - } - } } // ============================================================================= diff --git a/src/writer/vegalite/data.rs b/src/writer/vegalite/data.rs index 12e3e3d4..4b1c89e3 100644 --- a/src/writer/vegalite/data.rs +++ b/src/writer/vegalite/data.rs @@ -8,10 +8,7 @@ use crate::plot::scale::ScaleTypeKind; #[allow(unused_imports)] use crate::plot::ArrayElement; use crate::plot::ParameterValue; -use crate::{ - is_primary_positional, naming, primary_aesthetic, AestheticValue, DataFrame, GgsqlError, Plot, - Result, -}; +use crate::{naming, AestheticValue, DataFrame, GgsqlError, Plot, Result}; use polars::prelude::*; use serde_json::{json, Map, Value}; use std::collections::HashMap; @@ -306,10 +303,11 @@ pub(super) fn format_temporal(value: f64, temporal_type: TemporalType) -> String /// in Vega-Lite for representing bin ranges. pub(super) fn collect_binned_columns(spec: &Plot) -> HashMap> { let mut binned_columns: HashMap> = HashMap::new(); + let aesthetic_ctx = spec.get_aesthetic_context(); for scale in &spec.scales { // Only x and y aesthetics support bin ranges (x2/y2) in Vega-Lite - if !is_primary_positional(&scale.aesthetic) { + if !aesthetic_ctx.is_primary_internal(&scale.aesthetic) { continue; } @@ -351,7 +349,10 @@ pub(super) fn collect_binned_columns(spec: &Plot) -> HashMap> { /// Check if an aesthetic has a binned scale in the spec. pub(super) fn is_binned_aesthetic(aesthetic: &str, spec: &Plot) -> bool { - let primary = primary_aesthetic(aesthetic); + let aesthetic_ctx = spec.get_aesthetic_context(); + let primary = aesthetic_ctx + .primary_internal_positional(aesthetic) + .unwrap_or(aesthetic); spec.find_scale(primary) .and_then(|s| s.scale_type.as_ref()) .map(|st| st.scale_type_kind() == ScaleTypeKind::Binned) diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 96f51cfb..82055564 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -3,10 +3,10 @@ //! This module handles building Vega-Lite encoding channels from ggsql aesthetic mappings, //! including type inference, scale properties, and title handling. -use crate::plot::aesthetic::is_positional_aesthetic; +use crate::plot::aesthetic::{is_positional_aesthetic, AestheticContext}; use crate::plot::scale::{linetype_to_stroke_dash, shape_to_svg_path, ScaleTypeKind}; use crate::plot::{CoordKind, ParameterValue}; -use crate::{is_primary_positional, primary_aesthetic, AestheticValue, DataFrame, Plot, Result}; +use crate::{AestheticValue, DataFrame, Plot, Result}; use polars::prelude::*; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; @@ -361,8 +361,11 @@ fn determine_field_type_for_aesthetic( df: &DataFrame, spec: &Plot, identity_scale: &mut bool, + aesthetic_ctx: &AestheticContext, ) -> String { - let primary = primary_aesthetic(aesthetic); + let primary = aesthetic_ctx + .primary_internal_positional(aesthetic) + .unwrap_or(aesthetic); let inferred = infer_field_type(df, col); if let Some(scale) = spec.find_scale(primary) { @@ -393,8 +396,11 @@ fn apply_title_to_encoding( spec: &Plot, titled_families: &mut HashSet, primary_aesthetics: &HashSet, + aesthetic_ctx: &AestheticContext, ) { - let primary = primary_aesthetic(aesthetic); + let primary = aesthetic_ctx + .primary_internal_positional(aesthetic) + .unwrap_or(aesthetic); let is_primary = aesthetic == primary; let primary_exists = primary_aesthetics.contains(primary); @@ -806,12 +812,21 @@ fn build_column_encoding( is_dummy: bool, ctx: &mut EncodingContext, ) -> Result { - let primary = primary_aesthetic(aesthetic); + let aesthetic_ctx = ctx.spec.get_aesthetic_context(); + let primary = aesthetic_ctx + .primary_internal_positional(aesthetic) + .unwrap_or(aesthetic); let mut identity_scale = false; // Determine field type from scale or infer from data - let field_type = - determine_field_type_for_aesthetic(aesthetic, col, ctx.df, ctx.spec, &mut identity_scale); + let field_type = determine_field_type_for_aesthetic( + aesthetic, + col, + ctx.df, + ctx.spec, + &mut identity_scale, + &aesthetic_ctx, + ); // Check if this aesthetic has a binned scale let is_binned = ctx @@ -843,6 +858,7 @@ fn build_column_encoding( ctx.spec, ctx.titled_families, ctx.primary_aesthetics, + &aesthetic_ctx, ); // Build scale properties @@ -876,7 +892,7 @@ fn build_column_encoding( }; // Position scales don't include zero by default - if is_primary_positional(aesthetic) { + if aesthetic_ctx.is_primary_internal(aesthetic) { scale_obj.insert("zero".to_string(), json!(false)); } diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index a93ae509..7e5c7ef6 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -28,7 +28,7 @@ mod projection; use crate::plot::ArrayElement; use crate::plot::{CoordKind, ParameterValue, Scale, ScaleTypeKind}; use crate::writer::Writer; -use crate::{naming, primary_aesthetic, AestheticValue, DataFrame, GgsqlError, Plot, Result}; +use crate::{naming, AestheticValue, DataFrame, GgsqlError, Plot, Result}; use serde_json::{json, Value}; use std::collections::HashMap; @@ -221,6 +221,9 @@ fn build_layer_encoding( ) -> Result> { let mut encoding = serde_json::Map::new(); + // Get aesthetic context for name transformation + let aesthetic_ctx = spec.get_aesthetic_context(); + // Track which aesthetic families have been titled to ensure only one title per family let mut titled_families: std::collections::HashSet = std::collections::HashSet::new(); @@ -230,7 +233,12 @@ fn build_layer_encoding( .mappings .aesthetics .keys() - .filter(|a| primary_aesthetic(a) == a.as_str()) + .filter(|a| { + aesthetic_ctx + .primary_internal_positional(a) + .map(|p| p == a.as_str()) + .unwrap_or(false) + }) .cloned() .collect(); @@ -243,9 +251,6 @@ fn build_layer_encoding( free_scales, }; - // Get aesthetic context for name transformation - let aesthetic_ctx = spec.get_aesthetic_context(); - // Build encoding channels for each aesthetic mapping // Mappings contains: // 1. Column references from MAPPING clause (apply scales) From 2c3a2a3d1b465e6383b3b87169d01d7a1277b97d Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 2 Mar 2026 22:25:11 +0100 Subject: [PATCH 25/28] add coord resolution --- doc/syntax/clause/project.qmd | 8 + src/parser/builder.rs | 115 ++++++++++ src/plot/projection/mod.rs | 2 + src/plot/projection/resolve.rs | 389 +++++++++++++++++++++++++++++++++ 4 files changed, 514 insertions(+) create mode 100644 src/plot/projection/resolve.rs diff --git a/doc/syntax/clause/project.qmd b/doc/syntax/clause/project.qmd index f0493338..d7753ce5 100644 --- a/doc/syntax/clause/project.qmd +++ b/doc/syntax/clause/project.qmd @@ -27,3 +27,11 @@ SETTING => , ... ``` This clause behaves much like the `SETTINGS` clause in `DRAW`, in that it allows you to fine-tune specific behavior of the projection. The specific coordinate system defines it's own valid settings. Consult the [coord documentations](../index.qmd#coordinate-systems) to read more. + +## Coordinate system inference +If you do not provide a `PROJECT` clause then the coordinate system will be picked for you based on the mappings in your query. The logic is as follows + +* If `x`, `y` or any of their variants are mapped to, a Cartesian coordinate system is used +* If `theta`, `radius` or any of their variants are mapped to, a polar coordinate system is used +* If none of the above applies, the plot defaults to a Cartesian coordinate system +* If multiple applies (e.g. mapping to both x and theta) an error is thrown diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 820ba89e..f7bc9cb1 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -4,6 +4,7 @@ //! handling all the node types defined in the grammar. use crate::plot::layer::geom::Geom; +use crate::plot::projection::resolve_coord; use crate::plot::scale::{color_to_hex, is_color_aesthetic, is_user_facet_aesthetic, Transform}; use crate::plot::*; use crate::{GgsqlError, Result}; @@ -270,6 +271,19 @@ fn build_visualise_statement(node: &Node, source: &SourceTree) -> Result { } } + // Resolve coord (infer from mappings if not explicit) + // This must happen after parsing but before initialize_aesthetic_context() + let layer_mappings: Vec<&Mappings> = spec.layers.iter().map(|l| &l.mappings).collect(); + if let Some(inferred) = resolve_coord( + spec.project.as_ref(), + &spec.global_mappings, + &layer_mappings, + ) + .map_err(GgsqlError::ParseError)? + { + spec.project = Some(inferred); + } + // Initialize aesthetic context based on coord and facet // This must happen after all clauses are processed (especially PROJECT and FACET) spec.initialize_aesthetic_context(); @@ -3309,4 +3323,105 @@ mod tests { let parsed2 = parse_literal_value(&literal_node2, &source2).unwrap(); assert!(matches!(parsed2, AestheticValue::Literal(ParameterValue::Number(n)) if n == 42.0)); } + + // ======================================== + // Coordinate System Inference Tests + // ======================================== + + #[test] + fn test_infer_cartesian_from_x_y_mappings() { + let query = "VISUALISE DRAW point MAPPING date AS x, value AS y"; + + let result = parse_test_query(query); + assert!(result.is_ok()); + let specs = result.unwrap(); + + // Should infer cartesian projection + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.coord.coord_kind(), CoordKind::Cartesian); + assert_eq!(project.aesthetics, vec!["x", "y"]); + } + + #[test] + fn test_infer_polar_from_theta_radius_mappings() { + let query = "VISUALISE DRAW bar MAPPING cat AS theta, val AS radius"; + + let result = parse_test_query(query); + assert!(result.is_ok()); + let specs = result.unwrap(); + + // Should infer polar projection + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.coord.coord_kind(), CoordKind::Polar); + assert_eq!(project.aesthetics, vec!["theta", "radius"]); + } + + #[test] + fn test_explicit_project_overrides_inference() { + // Explicitly use cartesian even though mappings use theta + let query = r#" + VISUALISE + DRAW bar MAPPING cat AS theta, val AS radius + PROJECT TO cartesian + "#; + + let result = parse_test_query(query); + assert!(result.is_ok()); + let specs = result.unwrap(); + + // Should use explicit cartesian despite polar-looking mappings + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.coord.coord_kind(), CoordKind::Cartesian); + } + + #[test] + fn test_conflicting_aesthetics_error() { + // Using both x and theta should error + let query = "VISUALISE DRAW point MAPPING a AS x, b AS theta"; + + let result = parse_test_query(query); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Conflicting")); + } + + #[test] + fn test_no_positional_keeps_default() { + // Only color mapping, no positional aesthetics + let query = "VISUALISE DRAW point MAPPING region AS color"; + + let result = parse_test_query(query); + assert!(result.is_ok()); + let specs = result.unwrap(); + + // Should have no explicit project (defaults will be used later) + // The resolve_coord returns None when no positional aesthetics found + assert!(specs[0].project.is_none()); + } + + #[test] + fn test_infer_from_global_mappings() { + let query = "VISUALISE date AS x, value AS y DRAW point"; + + let result = parse_test_query(query); + assert!(result.is_ok()); + let specs = result.unwrap(); + + // Should infer cartesian from global mappings + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.coord.coord_kind(), CoordKind::Cartesian); + } + + #[test] + fn test_infer_from_xmin_ymax_variants() { + let query = "VISUALISE DRAW ribbon MAPPING date AS x, lo AS ymin, hi AS ymax"; + + let result = parse_test_query(query); + assert!(result.is_ok()); + let specs = result.unwrap(); + + // Should infer cartesian from positional variants + let project = specs[0].project.as_ref().unwrap(); + assert_eq!(project.coord.coord_kind(), CoordKind::Cartesian); + } } diff --git a/src/plot/projection/mod.rs b/src/plot/projection/mod.rs index 6b51a51e..2baaabde 100644 --- a/src/plot/projection/mod.rs +++ b/src/plot/projection/mod.rs @@ -3,7 +3,9 @@ //! This module defines projection configuration and types. pub mod coord; +mod resolve; mod types; pub use coord::{Coord, CoordKind, CoordTrait}; +pub use resolve::resolve_coord; pub use types::Projection; diff --git a/src/plot/projection/resolve.rs b/src/plot/projection/resolve.rs new file mode 100644 index 00000000..ac78261f --- /dev/null +++ b/src/plot/projection/resolve.rs @@ -0,0 +1,389 @@ +//! Coordinate system resolution +//! +//! Resolves the default coordinate system by inspecting aesthetic mappings. + +use std::collections::HashMap; + +use super::coord::{Coord, CoordKind}; +use super::Projection; +use crate::plot::aesthetic::{NON_POSITIONAL, POSITIONAL_SUFFIXES}; +use crate::plot::Mappings; + +/// Cartesian primary aesthetic names +const CARTESIAN_PRIMARIES: &[&str] = &["x", "y"]; + +/// Polar primary aesthetic names +const POLAR_PRIMARIES: &[&str] = &["theta", "radius"]; + +/// Resolve coordinate system for a Plot +/// +/// If `project` is `Some`, returns `Ok(None)` (keep existing, no changes needed). +/// If `project` is `None`, infers coord from aesthetic mappings: +/// - x/y/xmin/xmax/ymin/ymax → Cartesian +/// - theta/radius/thetamin/... → Polar +/// - Both → Error +/// - Neither → Ok(None) (caller should use default Cartesian) +/// +/// Called early in the pipeline, before AestheticContext construction. +pub fn resolve_coord( + project: Option<&Projection>, + global_mappings: &Mappings, + layer_mappings: &[&Mappings], +) -> Result, String> { + // If project is explicitly specified, keep it as-is + if project.is_some() { + return Ok(None); + } + + // Collect all explicit aesthetic keys from global and layer mappings + let mut found_cartesian = false; + let mut found_polar = false; + + // Check global mappings + for aesthetic in global_mappings.aesthetics.keys() { + check_aesthetic(aesthetic, &mut found_cartesian, &mut found_polar); + } + + // Check layer mappings + for layer_map in layer_mappings { + for aesthetic in layer_map.aesthetics.keys() { + check_aesthetic(aesthetic, &mut found_cartesian, &mut found_polar); + } + } + + // Determine result + if found_cartesian && found_polar { + return Err( + "Conflicting aesthetics: cannot use both cartesian (x/y) and polar (theta/radius) \ + aesthetics in the same plot. Use PROJECT TO cartesian or PROJECT TO polar to \ + specify the coordinate system explicitly." + .to_string(), + ); + } + + if found_polar { + // Infer polar coordinate system + let coord = Coord::from_kind(CoordKind::Polar); + let aesthetics = coord + .positional_aesthetic_names() + .iter() + .map(|s| s.to_string()) + .collect(); + return Ok(Some(Projection { + coord, + aesthetics, + properties: HashMap::new(), + })); + } + + if found_cartesian { + // Infer cartesian coordinate system + let coord = Coord::from_kind(CoordKind::Cartesian); + let aesthetics = coord + .positional_aesthetic_names() + .iter() + .map(|s| s.to_string()) + .collect(); + return Ok(Some(Projection { + coord, + aesthetics, + properties: HashMap::new(), + })); + } + + // Neither found - return None (caller uses default) + Ok(None) +} + +/// Check if an aesthetic name indicates cartesian or polar coordinate system. +/// Updates the found flags accordingly. +fn check_aesthetic(aesthetic: &str, found_cartesian: &mut bool, found_polar: &mut bool) { + // Skip non-positional aesthetics (color, size, etc.) + if is_non_positional(aesthetic) { + return; + } + + // Strip positional suffix if present (xmin -> x, thetamax -> theta) + let primary = strip_positional_suffix(aesthetic); + + // Check against cartesian primaries + if CARTESIAN_PRIMARIES.contains(&primary) { + *found_cartesian = true; + } + + // Check against polar primaries + if POLAR_PRIMARIES.contains(&primary) { + *found_polar = true; + } +} + +/// Check if an aesthetic is non-positional (color, size, etc.) +fn is_non_positional(name: &str) -> bool { + NON_POSITIONAL.contains(&name) +} + +/// Strip positional suffix from an aesthetic name. +/// e.g., "xmin" -> "x", "thetamax" -> "theta", "y" -> "y" +fn strip_positional_suffix(name: &str) -> &str { + for suffix in POSITIONAL_SUFFIXES { + if let Some(base) = name.strip_suffix(suffix) { + return base; + } + } + name +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plot::AestheticValue; + + /// Helper to create Mappings with given aesthetic names + fn mappings_with(aesthetics: &[&str]) -> Mappings { + let mut m = Mappings::new(); + for aes in aesthetics { + m.insert(aes.to_string(), AestheticValue::standard_column("col")); + } + m + } + + // ======================================== + // Test: Explicit project is preserved + // ======================================== + + #[test] + fn test_resolve_keeps_explicit_project() { + let project = Projection { + coord: Coord::cartesian(), + aesthetics: vec!["x".to_string(), "y".to_string()], + properties: HashMap::new(), + }; + let global = mappings_with(&["theta", "radius"]); // Would infer polar + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(Some(&project), &global, &layers); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); // None means keep existing + } + + // ======================================== + // Test: Infer Cartesian + // ======================================== + + #[test] + fn test_infer_cartesian_from_x_y() { + let global = mappings_with(&["x", "y"]); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers); + assert!(result.is_ok()); + let inferred = result.unwrap(); + assert!(inferred.is_some()); + let proj = inferred.unwrap(); + assert_eq!(proj.coord.coord_kind(), CoordKind::Cartesian); + assert_eq!(proj.aesthetics, vec!["x", "y"]); + } + + #[test] + fn test_infer_cartesian_from_variants() { + let global = mappings_with(&["xmin", "ymax"]); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers); + assert!(result.is_ok()); + let inferred = result.unwrap(); + assert!(inferred.is_some()); + let proj = inferred.unwrap(); + assert_eq!(proj.coord.coord_kind(), CoordKind::Cartesian); + } + + #[test] + fn test_infer_cartesian_from_layer() { + let global = Mappings::new(); + let layer = mappings_with(&["x", "y"]); + let layers: Vec<&Mappings> = vec![&layer]; + + let result = resolve_coord(None, &global, &layers); + assert!(result.is_ok()); + let inferred = result.unwrap(); + assert!(inferred.is_some()); + let proj = inferred.unwrap(); + assert_eq!(proj.coord.coord_kind(), CoordKind::Cartesian); + } + + // ======================================== + // Test: Infer Polar + // ======================================== + + #[test] + fn test_infer_polar_from_theta_radius() { + let global = mappings_with(&["theta", "radius"]); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers); + assert!(result.is_ok()); + let inferred = result.unwrap(); + assert!(inferred.is_some()); + let proj = inferred.unwrap(); + assert_eq!(proj.coord.coord_kind(), CoordKind::Polar); + assert_eq!(proj.aesthetics, vec!["theta", "radius"]); + } + + #[test] + fn test_infer_polar_from_variants() { + let global = mappings_with(&["thetamin", "radiusmax"]); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers); + assert!(result.is_ok()); + let inferred = result.unwrap(); + assert!(inferred.is_some()); + let proj = inferred.unwrap(); + assert_eq!(proj.coord.coord_kind(), CoordKind::Polar); + } + + #[test] + fn test_infer_polar_from_layer() { + let global = Mappings::new(); + let layer = mappings_with(&["theta", "radius"]); + let layers: Vec<&Mappings> = vec![&layer]; + + let result = resolve_coord(None, &global, &layers); + assert!(result.is_ok()); + let inferred = result.unwrap(); + assert!(inferred.is_some()); + let proj = inferred.unwrap(); + assert_eq!(proj.coord.coord_kind(), CoordKind::Polar); + } + + // ======================================== + // Test: Non-positional aesthetics ignored + // ======================================== + + #[test] + fn test_ignore_non_positional() { + let global = mappings_with(&["color", "size", "fill", "opacity"]); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); // Neither cartesian nor polar + } + + #[test] + fn test_non_positional_with_cartesian() { + let global = mappings_with(&["x", "y", "color", "size"]); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers); + assert!(result.is_ok()); + let inferred = result.unwrap(); + assert!(inferred.is_some()); + let proj = inferred.unwrap(); + assert_eq!(proj.coord.coord_kind(), CoordKind::Cartesian); + } + + // ======================================== + // Test: Conflict error + // ======================================== + + #[test] + fn test_conflict_error() { + let global = mappings_with(&["x", "theta"]); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("Conflicting")); + assert!(err.contains("cartesian")); + assert!(err.contains("polar")); + } + + #[test] + fn test_conflict_across_global_and_layer() { + let global = mappings_with(&["x", "y"]); + let layer = mappings_with(&["theta"]); + let layers: Vec<&Mappings> = vec![&layer]; + + let result = resolve_coord(None, &global, &layers); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("Conflicting")); + } + + // ======================================== + // Test: Empty returns None (default) + // ======================================== + + #[test] + fn test_empty_returns_none() { + let global = Mappings::new(); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); + } + + // ======================================== + // Test: Wildcard doesn't affect inference + // ======================================== + + #[test] + fn test_wildcard_with_polar() { + let mut global = Mappings::with_wildcard(); + global.insert("theta", AestheticValue::standard_column("cat")); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers); + assert!(result.is_ok()); + let inferred = result.unwrap(); + assert!(inferred.is_some()); + let proj = inferred.unwrap(); + assert_eq!(proj.coord.coord_kind(), CoordKind::Polar); + } + + #[test] + fn test_wildcard_alone_returns_none() { + let global = Mappings::with_wildcard(); + let layers: Vec<&Mappings> = vec![]; + + let result = resolve_coord(None, &global, &layers); + assert!(result.is_ok()); + assert!(result.unwrap().is_none()); // Wildcard alone doesn't infer coord + } + + // ======================================== + // Test: Helper functions + // ======================================== + + #[test] + fn test_strip_positional_suffix() { + assert_eq!(strip_positional_suffix("x"), "x"); + assert_eq!(strip_positional_suffix("y"), "y"); + assert_eq!(strip_positional_suffix("xmin"), "x"); + assert_eq!(strip_positional_suffix("xmax"), "x"); + assert_eq!(strip_positional_suffix("xend"), "x"); + assert_eq!(strip_positional_suffix("ymin"), "y"); + assert_eq!(strip_positional_suffix("ymax"), "y"); + assert_eq!(strip_positional_suffix("theta"), "theta"); + assert_eq!(strip_positional_suffix("thetamin"), "theta"); + assert_eq!(strip_positional_suffix("radiusmax"), "radius"); + } + + #[test] + fn test_is_non_positional() { + assert!(is_non_positional("color")); + assert!(is_non_positional("colour")); + assert!(is_non_positional("fill")); + assert!(is_non_positional("size")); + assert!(is_non_positional("shape")); + assert!(is_non_positional("opacity")); + + assert!(!is_non_positional("x")); + assert!(!is_non_positional("y")); + assert!(!is_non_positional("theta")); + assert!(!is_non_positional("radius")); + } +} From 2e639af4335a953c9f4cf22e6e5f244530c9579f Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 2 Mar 2026 22:30:44 +0100 Subject: [PATCH 26/28] small simplification --- src/plot/projection/resolve.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/plot/projection/resolve.rs b/src/plot/projection/resolve.rs index ac78261f..56092c4a 100644 --- a/src/plot/projection/resolve.rs +++ b/src/plot/projection/resolve.rs @@ -99,7 +99,7 @@ pub fn resolve_coord( /// Updates the found flags accordingly. fn check_aesthetic(aesthetic: &str, found_cartesian: &mut bool, found_polar: &mut bool) { // Skip non-positional aesthetics (color, size, etc.) - if is_non_positional(aesthetic) { + if NON_POSITIONAL.contains(&name) { return; } @@ -117,11 +117,6 @@ fn check_aesthetic(aesthetic: &str, found_cartesian: &mut bool, found_polar: &mu } } -/// Check if an aesthetic is non-positional (color, size, etc.) -fn is_non_positional(name: &str) -> bool { - NON_POSITIONAL.contains(&name) -} - /// Strip positional suffix from an aesthetic name. /// e.g., "xmin" -> "x", "thetamax" -> "theta", "y" -> "y" fn strip_positional_suffix(name: &str) -> &str { From fe3df292c8473e5f3b4ad719f297a507700c3522 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 2 Mar 2026 22:33:50 +0100 Subject: [PATCH 27/28] make sure all the links are there --- doc/syntax/index.qmd | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/syntax/index.qmd b/doc/syntax/index.qmd index c5de2e56..1400280f 100644 --- a/doc/syntax/index.qmd +++ b/doc/syntax/index.qmd @@ -7,10 +7,10 @@ ggsql augments the standard SQL syntax with a number of new clauses to describe - [`VISUALISE`](clause/visualise.qmd) initiates the visualisation part of the query - [`DRAW`](clause/draw.qmd) adds a new layer to the visualisation -- `SCALE` specify how an aesthetic should be scaled -- `FACET` describes how data should be split into small multiples -- `PROJECT` is used for selecting the coordinate system to use -- `LABEL` is used to manually add titles to the plot or the various axes and legends +- [`SCALE`](clause/scale.qmd) specify how an aesthetic should be scaled +- [`FACET`](clause/facet.qmd) describes how data should be split into small multiples +- [`PROJECT`](clause/project.qmd) is used for selecting the coordinate system to use +- [`LABEL`](clause/label.qmd) is used to manually add titles to the plot or the various axes and legends ## Layers There are many different layers to choose from when visualising your data. Some are straightforward translations of your data into visual marks such as a point layer, while others perform more or less complicated calculations like e.g. the histogram layer. A layer is selected by providing the layer name after the `DRAW` clause From ffaea24c1f56f9e578d77cd2e5af5863f47a1e27 Mon Sep 17 00:00:00 2001 From: Thomas Lin Pedersen Date: Mon, 2 Mar 2026 22:41:58 +0100 Subject: [PATCH 28/28] fix my fix --- src/plot/projection/resolve.rs | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/plot/projection/resolve.rs b/src/plot/projection/resolve.rs index 56092c4a..54b0b646 100644 --- a/src/plot/projection/resolve.rs +++ b/src/plot/projection/resolve.rs @@ -99,7 +99,7 @@ pub fn resolve_coord( /// Updates the found flags accordingly. fn check_aesthetic(aesthetic: &str, found_cartesian: &mut bool, found_polar: &mut bool) { // Skip non-positional aesthetics (color, size, etc.) - if NON_POSITIONAL.contains(&name) { + if NON_POSITIONAL.contains(&aesthetic) { return; } @@ -366,19 +366,4 @@ mod tests { assert_eq!(strip_positional_suffix("thetamin"), "theta"); assert_eq!(strip_positional_suffix("radiusmax"), "radius"); } - - #[test] - fn test_is_non_positional() { - assert!(is_non_positional("color")); - assert!(is_non_positional("colour")); - assert!(is_non_positional("fill")); - assert!(is_non_positional("size")); - assert!(is_non_positional("shape")); - assert!(is_non_positional("opacity")); - - assert!(!is_non_positional("x")); - assert!(!is_non_positional("y")); - assert!(!is_non_positional("theta")); - assert!(!is_non_positional("radius")); - } }