Skip to content

Commit b134dba

Browse files
committed
apply last'ish changes from old PR
1 parent f84b5a9 commit b134dba

8 files changed

Lines changed: 272 additions & 28 deletions

File tree

src/plot/layer/geom/area.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
//! Area geom implementation
22
33
use crate::plot::{types::DefaultAestheticValue, DefaultParam, DefaultParamValue};
4+
use crate::{naming, Mappings};
45

5-
use super::{DefaultAesthetics, GeomTrait, GeomType};
6+
use super::{DefaultAesthetics, GeomTrait, GeomType, StatResult};
67

78
/// Area geom - filled area charts
89
#[derive(Debug, Clone, Copy)]
@@ -37,6 +38,29 @@ impl GeomTrait for Area {
3738
default: DefaultParamValue::String("stack"),
3839
}]
3940
}
41+
42+
fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
43+
true
44+
}
45+
46+
fn apply_stat_transform(
47+
&self,
48+
query: &str,
49+
_schema: &crate::plot::Schema,
50+
_aesthetics: &Mappings,
51+
_group_by: &[String],
52+
_parameters: &std::collections::HashMap<String, crate::plot::ParameterValue>,
53+
_execute_query: &dyn Fn(&str) -> crate::Result<polars::prelude::DataFrame>,
54+
) -> crate::Result<StatResult> {
55+
// Area geom needs ordering by pos1 (domain axis) for proper rendering
56+
let order_col = naming::aesthetic_column("pos1");
57+
Ok(StatResult::Transformed {
58+
query: format!("{} ORDER BY \"{}\"", query, order_col),
59+
stat_columns: vec![],
60+
dummy_columns: vec![],
61+
consumed_aesthetics: vec![],
62+
})
63+
}
4064
}
4165

4266
impl std::fmt::Display for Area {

src/plot/layer/geom/line.rs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
//! Line geom implementation
22
3-
use super::{DefaultAesthetics, DefaultParam, DefaultParamValue, GeomTrait, GeomType};
3+
use super::{DefaultAesthetics, GeomTrait, GeomType, StatResult};
44
use crate::plot::types::DefaultAestheticValue;
5+
use crate::{naming, Mappings};
56

67
/// Line geom - line charts with connected points
78
#[derive(Debug, Clone, Copy)]
@@ -25,11 +26,27 @@ impl GeomTrait for Line {
2526
}
2627
}
2728

28-
fn default_params(&self) -> &'static [DefaultParam] {
29-
&[DefaultParam {
30-
name: "position",
31-
default: DefaultParamValue::String("identity"),
32-
}]
29+
fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
30+
true
31+
}
32+
33+
fn apply_stat_transform(
34+
&self,
35+
query: &str,
36+
_schema: &crate::plot::Schema,
37+
_aesthetics: &Mappings,
38+
_group_by: &[String],
39+
_parameters: &std::collections::HashMap<String, crate::plot::ParameterValue>,
40+
_execute_query: &dyn Fn(&str) -> crate::Result<polars::prelude::DataFrame>,
41+
) -> crate::Result<StatResult> {
42+
// Line geom needs ordering by pos1 (domain axis) for proper rendering
43+
let order_col = naming::aesthetic_column("pos1");
44+
Ok(StatResult::Transformed {
45+
query: format!("{} ORDER BY \"{}\"", query, order_col),
46+
stat_columns: vec![],
47+
dummy_columns: vec![],
48+
consumed_aesthetics: vec![],
49+
})
3350
}
3451
}
3552

src/plot/layer/geom/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,11 +221,14 @@ pub trait GeomTrait: std::fmt::Debug + std::fmt::Display + Send + Sync {
221221
/// Returns valid parameter names for SETTING clause.
222222
///
223223
/// Combines supported aesthetics with non-aesthetic parameters.
224+
/// Includes "orientation" for all geoms (explicit override for auto-detection).
224225
fn valid_settings(&self) -> Vec<&'static str> {
225226
let mut valid: Vec<&'static str> = self.aesthetics().supported();
226227
for param in self.default_params() {
227228
valid.push(param.name);
228229
}
230+
// All geoms accept orientation parameter for explicit override
231+
valid.push("orientation");
229232
valid
230233
}
231234
}

src/plot/layer/geom/ribbon.rs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
//! Ribbon geom implementation
22
3-
use super::{DefaultAesthetics, DefaultParam, DefaultParamValue, GeomTrait, GeomType};
3+
use super::{DefaultAesthetics, GeomTrait, GeomType, StatResult};
44
use crate::plot::types::DefaultAestheticValue;
5+
use crate::{naming, Mappings};
56

67
/// Ribbon geom - confidence bands and ranges
78
#[derive(Debug, Clone, Copy)]
@@ -27,11 +28,27 @@ impl GeomTrait for Ribbon {
2728
}
2829
}
2930

30-
fn default_params(&self) -> &'static [DefaultParam] {
31-
&[DefaultParam {
32-
name: "position",
33-
default: DefaultParamValue::String("identity"),
34-
}]
31+
fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
32+
true
33+
}
34+
35+
fn apply_stat_transform(
36+
&self,
37+
query: &str,
38+
_schema: &crate::plot::Schema,
39+
_aesthetics: &Mappings,
40+
_group_by: &[String],
41+
_parameters: &std::collections::HashMap<String, crate::plot::ParameterValue>,
42+
_execute_query: &dyn Fn(&str) -> crate::Result<polars::prelude::DataFrame>,
43+
) -> crate::Result<StatResult> {
44+
// Ribbon geom needs ordering by pos1 (domain axis) for proper rendering
45+
let order_col = naming::aesthetic_column("pos1");
46+
Ok(StatResult::Transformed {
47+
query: format!("{} ORDER BY \"{}\"", query, order_col),
48+
stat_columns: vec![],
49+
dummy_columns: vec![],
50+
consumed_aesthetics: vec![],
51+
})
3552
}
3653
}
3754

src/plot/layer/geom/types.rs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//!
33
//! These types are used by all geom implementations and are shared across the module.
44
5+
use crate::plot::aesthetic::parse_positional;
56
use crate::{plot::types::DefaultAestheticValue, Mappings};
67

78
// Re-export shared types from the central location
@@ -27,16 +28,14 @@ impl DefaultAesthetics {
2728
}
2829

2930
/// Get supported aesthetic names (excludes Delayed, for MAPPING validation)
31+
///
32+
/// Returns the literal names from defaults. For bidirectional positional checking,
33+
/// use `is_supported()` which handles pos1/pos2 equivalence.
3034
pub fn supported(&self) -> Vec<&'static str> {
3135
self.defaults
3236
.iter()
33-
.filter_map(|(name, value)| {
34-
if !matches!(value, DefaultAestheticValue::Delayed) {
35-
Some(*name)
36-
} else {
37-
None
38-
}
39-
})
37+
.filter(|(_, value)| !matches!(value, DefaultAestheticValue::Delayed))
38+
.map(|(name, _)| *name)
4039
.collect()
4140
}
4241

@@ -55,10 +54,29 @@ impl DefaultAesthetics {
5554
}
5655

5756
/// Check if an aesthetic is supported (not Delayed)
57+
///
58+
/// Positional aesthetics are bidirectional: if pos1* is supported, pos2* is also
59+
/// considered supported (and vice versa).
5860
pub fn is_supported(&self, name: &str) -> bool {
59-
self.defaults
61+
// Check for direct match first
62+
let direct_match = self
63+
.defaults
6064
.iter()
61-
.any(|(n, value)| *n == name && !matches!(value, DefaultAestheticValue::Delayed))
65+
.any(|(n, value)| !matches!(value, DefaultAestheticValue::Delayed) && *n == name);
66+
if direct_match {
67+
return true;
68+
}
69+
70+
// Check for bidirectional positional match
71+
if let Some((slot, suffix)) = parse_positional(name) {
72+
let other_slot = if slot == 1 { 2 } else { 1 };
73+
let equivalent = format!("pos{}{}", other_slot, suffix);
74+
return self.defaults.iter().any(|(n, value)| {
75+
!matches!(value, DefaultAestheticValue::Delayed) && *n == equivalent
76+
});
77+
}
78+
79+
false
6280
}
6381

6482
/// Check if an aesthetic exists (including Delayed)

src/reader/data.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,131 @@ mod duckdb_tests {
368368
assert!(dataframe.column("__ggsql_aes_pos1__").is_ok());
369369
assert!(dataframe.column("__ggsql_aes_pos2__").is_ok());
370370
}
371+
372+
#[test]
373+
fn test_ribbon_transposed_orientation() {
374+
use crate::naming;
375+
use crate::plot::layer::orientation::Orientation;
376+
377+
let reader =
378+
crate::reader::DuckDBReader::from_connection_string("duckdb://memory").unwrap();
379+
380+
// Ribbon with y as domain axis and xmin/xmax as value range (transposed)
381+
let query =
382+
"VISUALISE FROM ggsql:airquality DRAW ribbon MAPPING Day AS y, Temp AS xmax, 0.0 AS xmin";
383+
let result = crate::execute::prepare_data_with_reader(query, &reader);
384+
385+
// Debug: print the error if any
386+
if let Err(ref e) = result {
387+
eprintln!("Error: {:?}", e);
388+
}
389+
390+
let result = result.unwrap();
391+
392+
// Debug: print orientation and scales
393+
let layer = &result.specs[0].layers[0];
394+
eprintln!("Layer orientation: {:?}", layer.orientation);
395+
eprintln!(
396+
"Scales: {:?}",
397+
result.specs[0]
398+
.scales
399+
.iter()
400+
.map(|s| (&s.aesthetic, &s.scale_type))
401+
.collect::<Vec<_>>()
402+
);
403+
eprintln!(
404+
"Layer mappings: {:?}",
405+
layer.mappings.aesthetics.keys().collect::<Vec<_>>()
406+
);
407+
408+
// Check orientation was detected correctly
409+
assert_eq!(
410+
layer.orientation,
411+
Some(Orientation::Transposed),
412+
"Should detect Transposed orientation"
413+
);
414+
415+
let dataframe = result.data.get(&naming::layer_key(0)).unwrap();
416+
417+
// The flip-back restores user's original axis assignment:
418+
// After flip-back:
419+
// - pos2 = y (user's domain axis = Date/Day)
420+
// - pos1min = xmin (user's value range min = 0.0)
421+
// - pos1max = xmax (user's value range max = Temp)
422+
// The orientation flag tells the writer how to map to x/y.
423+
let cols: Vec<_> = dataframe.get_column_names().into_iter().collect();
424+
eprintln!("Columns: {:?}", cols);
425+
426+
assert!(
427+
dataframe.column("__ggsql_aes_pos2__").is_ok(),
428+
"Should have pos2 (domain axis), got columns: {:?}",
429+
cols
430+
);
431+
assert!(
432+
dataframe.column("__ggsql_aes_pos1min__").is_ok(),
433+
"Should have pos1min (value range min), got columns: {:?}",
434+
cols
435+
);
436+
assert!(
437+
dataframe.column("__ggsql_aes_pos1max__").is_ok(),
438+
"Should have pos1max (value range max), got columns: {:?}",
439+
cols
440+
);
441+
}
442+
443+
#[test]
444+
fn test_ribbon_transposed_vegalite_encoding() {
445+
use crate::reader::Reader;
446+
use crate::writer::{VegaLiteWriter, Writer};
447+
448+
let reader =
449+
crate::reader::DuckDBReader::from_connection_string("duckdb://memory").unwrap();
450+
451+
// Ribbon with y as domain axis and xmin/xmax as value range (transposed)
452+
let query =
453+
"VISUALISE FROM ggsql:airquality DRAW ribbon MAPPING Day AS y, Temp AS xmax, 0.0 AS xmin";
454+
let spec = reader.execute(query).unwrap();
455+
456+
let writer = VegaLiteWriter::new();
457+
let json_str = writer.render(&spec).unwrap();
458+
let vl_spec: serde_json::Value = serde_json::from_str(&json_str).unwrap();
459+
460+
// For transposed ribbon, the encoding should have:
461+
// - y: domain axis (Day)
462+
// - x: value range max (Temp via xmax)
463+
// - x2: value range min (0.0 via xmin)
464+
// The encoding is inside layer[0] since VegaLite uses layered structure
465+
let encoding = &vl_spec["layer"][0]["encoding"];
466+
assert!(
467+
encoding.get("y").is_some(),
468+
"Should have y encoding for domain axis"
469+
);
470+
assert!(
471+
encoding.get("x").is_some(),
472+
"Should have x encoding for value max"
473+
);
474+
assert!(
475+
encoding.get("x2").is_some(),
476+
"Should have x2 encoding for value min"
477+
);
478+
// Should NOT have ymax/ymin/xmax/xmin (these should be remapped to x/x2/y/y2)
479+
assert!(
480+
encoding.get("ymax").is_none(),
481+
"Should not have ymax encoding"
482+
);
483+
assert!(
484+
encoding.get("ymin").is_none(),
485+
"Should not have ymin encoding"
486+
);
487+
assert!(
488+
encoding.get("xmax").is_none(),
489+
"Should not have xmax encoding"
490+
);
491+
assert!(
492+
encoding.get("xmin").is_none(),
493+
"Should not have xmin encoding"
494+
);
495+
}
371496
}
372497

373498
#[cfg(feature = "builtin-data")]

src/writer/vegalite/data.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
//! including temporal type handling and binned data transformations.
55
66
use crate::plot::scale::ScaleTypeKind;
7+
8+
/// Column name for row index (used to preserve data order in Vega-Lite)
9+
pub(super) const ROW_INDEX_COLUMN: &str = "__ggsql_row_index__";
710
// ArrayElement is used for temporal parsing
811
#[allow(unused_imports)]
912
use crate::plot::ArrayElement;
@@ -389,7 +392,9 @@ pub(super) fn unify_datasets(datasets: &Map<String, Value>) -> Result<Vec<Value>
389392
// 2. For each dataset, for each row:
390393
// - Include all columns (null for missing)
391394
// - Add __ggsql_source__ field with dataset key
395+
// - Add __ggsql_row_index__ field for order preservation
392396
let mut unified = Vec::new();
397+
let mut row_index: usize = 0;
393398
for (key, values) in datasets {
394399
if let Some(arr) = values.as_array() {
395400
for row in arr {
@@ -405,6 +410,10 @@ pub(super) fn unify_datasets(datasets: &Map<String, Value>) -> Result<Vec<Value>
405410
// Add source identifier
406411
new_row.insert(naming::SOURCE_COLUMN.to_string(), json!(key));
407412

413+
// Add row index for order preservation (used by line/path/polygon geoms)
414+
new_row.insert(ROW_INDEX_COLUMN.to_string(), json!(row_index));
415+
row_index += 1;
416+
408417
unified.push(Value::Object(new_row));
409418
}
410419
}

0 commit comments

Comments
 (0)