use crate::array_util::as_str;
use crate::plot::aesthetic::{is_position_aesthetic, AestheticContext};
use crate::plot::scale::{linetype_to_stroke_dash, shape_to_svg_path, ScaleTypeKind};
use crate::plot::ParameterValue;
use crate::{AestheticValue, DataFrame, GgsqlError, Plot, Result};
use arrow::array::Array;
use arrow::datatypes::DataType;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
use super::{POINTS_TO_AREA, POINTS_TO_PIXELS};
fn is_free(aesthetic: &str, facet: Option<&crate::plot::Facet>) -> bool {
facet.is_some_and(|f| f.is_free(aesthetic))
}
pub(super) fn build_label_expr(
mappings: &HashMap<String, Option<String>>,
time_format: Option<&str>,
null_key: Option<&str>,
field_type: &str,
) -> String {
if mappings.is_empty() {
return "datum.label".to_string();
}
let is_numeric = field_type == "quantitative";
let comparison_expr = match time_format {
Some(fmt) => format!("utcFormat(datum.value, '{}')", fmt),
None if is_numeric => "datum.value".to_string(),
None => "datum.label".to_string(),
};
let mut parts: Vec<String> = mappings
.iter()
.map(|(from, to)| {
let from_escaped = super::escape_vega_string(from);
let condition = if null_key == Some(from.as_str()) {
"datum.label == null".to_string()
} else if is_numeric && time_format.is_none() {
format!("{} == {}", comparison_expr, from_escaped)
} else {
format!("{} == '{}'", comparison_expr, from_escaped)
};
match to {
Some(label) => {
let to_escaped = super::escape_vega_string(label);
format!("{} ? '{}'", condition, to_escaped)
}
None => {
format!("{} ? ''", condition)
}
}
})
.collect();
parts.push("datum.label".to_string());
parts.join(" : ")
}
pub(super) fn build_symbol_legend_label_mapping(
breaks: &[crate::plot::ArrayElement],
label_mapping: &HashMap<String, Option<String>>,
closed: &str,
) -> HashMap<String, Option<String>> {
let mut result = HashMap::new();
if breaks.len() < 2 {
return result;
}
let num_bins = breaks.len() - 1;
for i in 0..num_bins {
let lower = &breaks[i];
let upper = &breaks[i + 1];
let lower_str = lower.to_key_string();
let upper_str = upper.to_key_string();
let our_label = label_mapping.get(&lower_str).cloned().flatten();
let vl_label = if i == num_bins - 1 {
format!("≥ {}", lower_str)
} else {
format!("{} – {}", lower_str, upper_str)
};
let lower_suppressed = label_mapping.get(&lower_str) == Some(&None);
let upper_suppressed = label_mapping.get(&upper_str) == Some(&None);
let lower_label = our_label.clone().unwrap_or_else(|| lower_str.clone());
let upper_label = label_mapping
.get(&upper_str)
.cloned()
.flatten()
.unwrap_or_else(|| upper_str.clone());
let replacement = if i == 0 && lower_suppressed {
let symbol = if closed == "right" { "≤" } else { "<" };
Some(format!("{} {}", symbol, upper_label))
} else if i == num_bins - 1 && upper_suppressed {
let symbol = if closed == "right" { ">" } else { "≥" };
Some(format!("{} {}", symbol, lower_label))
} else {
Some(format!("{} – {}", lower_label, upper_label))
};
result.insert(vl_label, replacement);
}
result
}
pub(super) fn count_binned_legend_scales(spec: &Plot) -> usize {
spec.scales
.iter()
.filter(|scale| {
let is_binned = scale
.scale_type
.as_ref()
.map(|st| st.scale_type_kind() == ScaleTypeKind::Binned)
.unwrap_or(false);
let is_material_aesthetic = !is_position_aesthetic(&scale.aesthetic);
is_binned && is_material_aesthetic
})
.count()
}
pub(super) fn is_numeric_string_column(array: &arrow::array::ArrayRef) -> bool {
if let Ok(ca) = as_str(array) {
for i in 0..ca.len().min(5) {
if ca.is_null(i) {
continue;
}
if ca.value(i).parse::<f64>().is_err() {
return false;
}
}
true
} else {
false
}
}
pub(super) fn infer_field_type(df: &DataFrame, field: &str) -> String {
if let Ok(column) = df.column(field) {
match column.data_type() {
DataType::Int8
| DataType::Int16
| DataType::Int32
| DataType::Int64
| DataType::UInt8
| DataType::UInt16
| DataType::UInt32
| DataType::UInt64
| DataType::Float32
| DataType::Float64 => "quantitative",
DataType::Boolean => "nominal",
DataType::Utf8
if is_numeric_string_column(column) =>
{
"quantitative"
}
DataType::Date32 | DataType::Timestamp(_, _) | DataType::Time64(_) => "temporal",
_ => "nominal",
}
.to_string()
} else {
"nominal".to_string()
}
}
pub(super) fn determine_field_type_from_scale(
scale: &crate::plot::Scale,
inferred: &str,
_aesthetic: &str,
identity_scale: &mut bool,
) -> String {
if let Some(scale_type) = &scale.scale_type {
use crate::plot::ScaleTypeKind;
match scale_type.scale_type_kind() {
ScaleTypeKind::Continuous => "quantitative",
ScaleTypeKind::Discrete => "nominal",
ScaleTypeKind::Binned => "quantitative", ScaleTypeKind::Ordinal => "ordinal", ScaleTypeKind::Identity => {
*identity_scale = true;
inferred
}
}
.to_string()
} else {
inferred.to_string()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LegendStyle {
Gradient,
Symbol,
}
fn determine_legend_style(aesthetic: &str, spec: &Plot) -> LegendStyle {
let is_gradient_aesthetic = matches!(aesthetic, "fill" | "stroke");
if !is_gradient_aesthetic {
return LegendStyle::Symbol;
}
let binned_legend_count = count_binned_legend_scales(spec);
if binned_legend_count > 1 {
LegendStyle::Symbol
} else {
LegendStyle::Gradient
}
}
fn insert_axis_property(encoding: &mut Value, key: &str, value: Value) {
if encoding.get("axis").is_some_and(|v| v.is_null()) {
return;
}
let axis = encoding.get_mut("axis").and_then(|v| v.as_object_mut());
if let Some(axis_map) = axis {
axis_map.insert(key.to_string(), value);
} else {
encoding["axis"] = json!({ key: value });
}
}
fn insert_legend_property(encoding: &mut Value, key: &str, value: Value) {
if encoding.get("legend").is_some_and(|v| v.is_null()) {
return;
}
let legend = encoding.get_mut("legend").and_then(|v| v.as_object_mut());
if let Some(legend_map) = legend {
legend_map.insert(key.to_string(), value);
} else {
encoding["legend"] = json!({ key: value });
}
}
fn determine_field_type_for_aesthetic(
aesthetic: &str,
col: &str,
df: &DataFrame,
spec: &Plot,
identity_scale: &mut bool,
aesthetic_ctx: &AestheticContext,
) -> String {
let primary = aesthetic_ctx
.primary_internal_position(aesthetic)
.unwrap_or(aesthetic);
let inferred = infer_field_type(df, col);
if let Some(scale) = spec.find_scale(primary) {
if let Some(ref transform) = scale.transform {
if transform.is_temporal() {
return "temporal".to_string();
}
}
determine_field_type_from_scale(scale, &inferred, aesthetic, identity_scale)
} else {
inferred
}
}
fn apply_title_to_encoding(
encoding: &mut Value,
aesthetic: &str,
original_name: &Option<String>,
spec: &Plot,
titled_families: &mut HashSet<String>,
primary_aesthetics: &HashSet<String>,
aesthetic_ctx: &AestheticContext,
) {
let primary = aesthetic_ctx
.primary_internal_position(aesthetic)
.unwrap_or(aesthetic);
let is_primary = aesthetic == primary;
let primary_exists = primary_aesthetics.contains(primary);
if is_primary && !titled_families.contains(primary) {
let explicit_label = spec
.labels
.as_ref()
.and_then(|labels| labels.labels.get(primary));
if let Some(label_opt) = explicit_label {
match label_opt {
Some(label) => {
encoding["title"] = super::split_label_on_newlines(label);
}
None => {
encoding["title"] = Value::Null;
}
}
titled_families.insert(primary.to_string());
} else if let Some(orig) = original_name {
encoding["title"] = json!(orig);
titled_families.insert(primary.to_string());
}
} else if !is_primary && primary_exists {
encoding["title"] = Value::Null;
} else if !is_primary && !primary_exists && !titled_families.contains(primary) {
if let Some(ref labels) = spec.labels {
if let Some(label_opt) = labels.labels.get(primary) {
match label_opt {
Some(label) => {
encoding["title"] = super::split_label_on_newlines(label);
}
None => {
encoding["title"] = Value::Null;
}
}
titled_families.insert(primary.to_string());
}
}
}
}
struct ScaleContext<'a> {
aesthetic: &'a str,
is_binned_legend: bool,
#[allow(dead_code)]
spec: &'a Plot, }
fn build_scale_properties(
scale: &crate::plot::Scale,
ctx: &ScaleContext,
) -> (serde_json::Map<String, Value>, bool) {
use crate::plot::{OutputRange, ParameterValue};
let mut scale_obj = serde_json::Map::new();
let mut needs_gradient_legend = false;
let skip_domain = is_free(ctx.aesthetic, ctx.spec.facet.as_ref());
if !ctx.is_binned_legend && !skip_domain {
if let Some(ref domain_values) = scale.input_range {
let domain_json: Vec<Value> = domain_values.iter().map(|elem| elem.to_json()).collect();
scale_obj.insert("domain".to_string(), json!(domain_json));
}
}
if let Some(ref output_range) = scale.output_range {
match output_range {
OutputRange::Array(range_values) => {
let range_json: Vec<Value> = range_values
.iter()
.map(|elem| convert_range_element(elem, ctx.aesthetic))
.collect();
scale_obj.insert("range".to_string(), json!(range_json));
if matches!(ctx.aesthetic, "fill" | "stroke")
&& matches!(
scale.scale_type.as_ref().map(|st| st.scale_type_kind()),
Some(ScaleTypeKind::Continuous)
)
{
needs_gradient_legend = true;
}
}
OutputRange::Palette(palette_name) => {
scale_obj.insert("scheme".to_string(), json!(palette_name.to_lowercase()));
}
}
}
if let Some(ref transform) = scale.transform {
apply_transform_to_scale(&mut scale_obj, transform);
}
if ctx.is_binned_legend {
scale_obj.insert("type".to_string(), json!("threshold"));
if let Some(ParameterValue::Array(breaks)) = scale.properties.get("breaks") {
if breaks.len() > 2 {
let internal_breaks: Vec<Value> = breaks[1..breaks.len() - 1]
.iter()
.map(|e| e.to_json())
.collect();
scale_obj.insert("domain".to_string(), json!(internal_breaks));
}
}
}
if let Some(ParameterValue::Boolean(true)) = scale.properties.get("reverse") {
scale_obj.insert("reverse".to_string(), json!(true));
}
(scale_obj, needs_gradient_legend)
}
fn convert_range_element(elem: &crate::plot::ArrayElement, aesthetic: &str) -> Value {
use crate::plot::ArrayElement;
match elem {
ArrayElement::String(s) => {
if aesthetic == "shape" {
if let Some(svg_path) = shape_to_svg_path(s) {
return json!(svg_path);
}
} else if aesthetic == "linetype" {
if let Some(dash_array) = linetype_to_stroke_dash(s) {
return json!(dash_array);
}
}
json!(s)
}
ArrayElement::Number(n) => {
match aesthetic {
"size" => json!(n * n * POINTS_TO_AREA),
"linewidth" | "fontsize" => json!(n * POINTS_TO_PIXELS),
_ => json!(n),
}
}
other => other.to_json(),
}
}
fn apply_transform_to_scale(
scale_obj: &mut serde_json::Map<String, Value>,
transform: &crate::plot::scale::Transform,
) {
use crate::plot::scale::TransformKind;
match transform.transform_kind() {
TransformKind::Identity => {} TransformKind::Log10 => {
scale_obj.insert("type".to_string(), json!("log"));
scale_obj.insert("base".to_string(), json!(10));
scale_obj.insert("zero".to_string(), json!(false));
}
TransformKind::Log => {
scale_obj.insert("type".to_string(), json!("log"));
scale_obj.insert("base".to_string(), json!(std::f64::consts::E));
scale_obj.insert("zero".to_string(), json!(false));
}
TransformKind::Log2 => {
scale_obj.insert("type".to_string(), json!("log"));
scale_obj.insert("base".to_string(), json!(2));
scale_obj.insert("zero".to_string(), json!(false));
}
TransformKind::Sqrt => {
scale_obj.insert("type".to_string(), json!("sqrt"));
}
TransformKind::Square => {
scale_obj.insert("type".to_string(), json!("pow"));
scale_obj.insert("exponent".to_string(), json!(2));
}
TransformKind::Exp10 | TransformKind::Exp2 | TransformKind::Exp => {
eprintln!(
"Warning: {} transform has no native Vega-Lite equivalent, using linear scale",
transform.name()
);
}
TransformKind::Asinh | TransformKind::PseudoLog => {
scale_obj.insert("type".to_string(), json!("symlog"));
}
TransformKind::Date | TransformKind::DateTime | TransformKind::Time => {}
TransformKind::String | TransformKind::Bool => {}
TransformKind::Integer => {}
}
}
fn apply_reverse_legend(encoding: &mut Value, scale: &crate::plot::Scale, aesthetic: &str) {
use crate::plot::ParameterValue;
let Some(ParameterValue::Boolean(true)) = scale.properties.get("reverse") else {
return;
};
let Some(ref scale_type) = scale.scale_type else {
return;
};
let kind = scale_type.scale_type_kind();
if !matches!(kind, ScaleTypeKind::Discrete | ScaleTypeKind::Ordinal) {
return;
}
if is_position_aesthetic(aesthetic) {
return;
}
if let Some(ref domain) = scale.input_range {
let reversed_domain: Vec<Value> = domain.iter().rev().map(|e| e.to_json()).collect();
insert_legend_property(encoding, "values", json!(reversed_domain));
}
}
fn apply_breaks_to_encoding(
encoding: &mut Value,
scale: &crate::plot::Scale,
aesthetic: &str,
is_binned_legend: bool,
spec: &Plot,
) {
use crate::plot::ParameterValue;
let Some(ParameterValue::Array(breaks)) = scale.properties.get("breaks") else {
return;
};
let all_values: Vec<Value> = breaks.iter().map(|e| e.to_json()).collect();
if is_position_aesthetic(aesthetic) {
let axis_values: Vec<Value> = if let Some(ref label_mapping) = scale.label_mapping {
breaks
.iter()
.filter(|e| {
let key = e.to_key_string();
!matches!(label_mapping.get(&key), Some(None))
})
.map(|e| e.to_json())
.collect()
} else {
all_values
};
insert_axis_property(encoding, "values", json!(axis_values));
} else {
let legend_values = if is_binned_legend {
let legend_style = determine_legend_style(aesthetic, spec);
if legend_style == LegendStyle::Symbol && !all_values.is_empty() {
all_values[..all_values.len() - 1].to_vec()
} else {
all_values
}
} else {
all_values
};
insert_legend_property(encoding, "values", json!(legend_values));
}
}
fn apply_label_mapping_to_encoding(
encoding: &mut Value,
scale: &crate::plot::Scale,
aesthetic: &str,
is_binned_legend: bool,
spec: &Plot,
field_type: &str,
) {
use crate::plot::scale::TransformKind;
use crate::plot::ParameterValue;
let Some(ref label_mapping) = scale.label_mapping else {
return;
};
if label_mapping.is_empty() {
return;
}
let time_format = scale
.transform
.as_ref()
.and_then(|t| match t.transform_kind() {
TransformKind::Date => Some("%Y-%m-%d"),
TransformKind::DateTime => Some("%Y-%m-%dT%H:%M:%S"),
TransformKind::Time => Some("%H:%M:%S"),
_ => None,
});
let is_symbol =
is_binned_legend && determine_legend_style(aesthetic, spec) == LegendStyle::Symbol;
let breaks = match scale.properties.get("breaks") {
Some(ParameterValue::Array(b)) => Some(b.as_slice()),
_ => None,
};
let filtered_mapping = if let (true, Some(breaks)) = (is_symbol, breaks) {
let closed = match scale.properties.get("closed") {
Some(ParameterValue::String(s)) => s.as_str(),
_ => "left",
};
build_symbol_legend_label_mapping(breaks, label_mapping, closed)
} else {
label_mapping.clone()
};
let null_key = if is_binned_legend && !is_symbol {
breaks.and_then(|b| b.first().map(|e| e.to_key_string()))
} else {
None
};
let effective_field_type = if is_symbol { "nominal" } else { field_type };
let label_expr = build_label_expr(
&filtered_mapping,
time_format,
null_key.as_deref(),
effective_field_type,
);
if is_position_aesthetic(aesthetic) {
insert_axis_property(encoding, "labelExpr", json!(label_expr));
} else {
insert_legend_property(encoding, "labelExpr", json!(label_expr));
}
}
pub(super) struct EncodingContext<'a> {
pub df: &'a DataFrame,
pub spec: &'a Plot,
pub titled_families: &'a mut HashSet<String>,
pub primary_aesthetics: &'a HashSet<String>,
}
pub(super) fn build_encoding_channel(
aesthetic: &str,
value: &AestheticValue,
ctx: &mut EncodingContext,
) -> Result<Value> {
match value {
AestheticValue::Column {
name: col,
original_name,
is_dummy,
} => build_column_encoding(aesthetic, col, original_name, *is_dummy, true, ctx),
AestheticValue::AnnotationColumn { name: col } => {
build_column_encoding(aesthetic, col, &None, false, false, ctx)
}
AestheticValue::Literal(lit) => build_literal_encoding(aesthetic, lit),
}
}
fn build_column_encoding(
aesthetic: &str,
col: &str,
original_name: &Option<String>,
is_dummy: bool,
is_scaled: bool,
ctx: &mut EncodingContext,
) -> Result<Value> {
let aesthetic_ctx = ctx.spec.get_aesthetic_context();
let primary = aesthetic_ctx
.primary_internal_position(aesthetic)
.unwrap_or(aesthetic);
let mut identity_scale = !is_scaled;
let field_type = determine_field_type_for_aesthetic(
aesthetic,
col,
ctx.df,
ctx.spec,
&mut identity_scale,
&aesthetic_ctx,
);
let is_binned = ctx
.spec
.find_scale(primary)
.and_then(|s| s.scale_type.as_ref())
.map(|st| st.scale_type_kind() == ScaleTypeKind::Binned)
.unwrap_or(false);
let is_binned_legend = is_binned && !is_position_aesthetic(aesthetic);
let mut encoding = json!({
"field": col,
"type": field_type,
});
if is_binned && !is_binned_legend {
encoding["bin"] = json!("binned");
}
apply_title_to_encoding(
&mut encoding,
aesthetic,
original_name,
ctx.spec,
ctx.titled_families,
ctx.primary_aesthetics,
&aesthetic_ctx,
);
let (mut scale_obj, needs_gradient_legend) = if let Some(scale) = ctx.spec.find_scale(primary) {
let scale_ctx = ScaleContext {
aesthetic,
spec: ctx.spec,
is_binned_legend,
};
let (scale_obj, needs_gradient) = build_scale_properties(scale, &scale_ctx);
apply_reverse_legend(&mut encoding, scale, aesthetic);
apply_breaks_to_encoding(&mut encoding, scale, aesthetic, is_binned_legend, ctx.spec);
apply_label_mapping_to_encoding(
&mut encoding,
scale,
aesthetic,
is_binned_legend,
ctx.spec,
&field_type,
);
(scale_obj, needs_gradient)
} else {
(serde_json::Map::new(), false)
};
if aesthetic_ctx.is_primary_internal(aesthetic) {
scale_obj.insert("zero".to_string(), json!(false));
}
if identity_scale {
encoding["scale"] = Value::Null;
} else if !scale_obj.is_empty() {
encoding["scale"] = json!(scale_obj);
}
if needs_gradient_legend {
insert_legend_property(&mut encoding, "type", json!("gradient"));
}
if is_dummy {
encoding["axis"] = Value::Null;
}
Ok(encoding)
}
fn build_literal_encoding(aesthetic: &str, lit: &ParameterValue) -> Result<Value> {
let val = match lit {
ParameterValue::String(s) => {
let converted = match aesthetic {
"linetype" => linetype_to_stroke_dash(s).map(|arr| json!(arr)),
"shape" => shape_to_svg_path(s).map(|arr| json!(arr)),
_ => None,
};
converted.unwrap_or_else(|| json!(s))
}
ParameterValue::Number(n) => {
match aesthetic {
"size" => json!(n * n * POINTS_TO_AREA),
"linewidth" | "fontsize" => json!(n * POINTS_TO_PIXELS),
_ => json!(n),
}
}
ParameterValue::Array(_) => {
return Err(crate::GgsqlError::WriterError(format!(
"The `{aes}` SETTING must be scalar, not an array.",
aes = aesthetic
)))
}
_ => lit.to_json(),
};
Ok(json!({"value": val}))
}
pub(super) fn map_aesthetic_name(
aesthetic: &str,
_ctx: &crate::plot::AestheticContext,
renderer: &dyn super::projection::ProjectionRenderer,
) -> String {
if let Some(vl_channel) = renderer.map_position(aesthetic) {
return vl_channel;
}
match aesthetic {
"linetype" => "strokeDash".to_string(),
"linewidth" => "strokeWidth".to_string(),
"label" => "text".to_string(),
"fontsize" => "size".to_string(),
_ => aesthetic.to_string(),
}
}
pub type PositionChannels = (String, String, String, String, String, String);
pub struct RenderContext<'a> {
pub scales: &'a [crate::Scale],
pub channels: PositionChannels,
pub aesthetic_context: crate::plot::aesthetic::AestheticContext,
}
impl<'a> RenderContext<'a> {
pub fn new(
scales: &'a [crate::Scale],
renderer: &dyn super::projection::ProjectionRenderer,
aesthetic_context: crate::plot::aesthetic::AestheticContext,
) -> Self {
let pos1 = renderer.map_position("pos1").unwrap();
let pos1_end = renderer.map_position("pos1end").unwrap();
let pos2 = renderer.map_position("pos2").unwrap();
let pos2_end = renderer.map_position("pos2end").unwrap();
let (pos1_offset, pos2_offset) = renderer.offset_channels();
Self {
scales,
channels: (
pos1,
pos1_end,
pos1_offset.to_string(),
pos2,
pos2_end,
pos2_offset.to_string(),
),
aesthetic_context,
}
}
#[cfg(test)]
pub fn default_for_test() -> Self {
let renderer = super::projection::get_projection_renderer(None, None, &[]);
Self::new(
&[],
renderer.as_ref(),
crate::plot::aesthetic::AestheticContext::from_static(&["x", "y"], &[]),
)
}
pub fn find_scale(&self, aesthetic: &str) -> Option<&crate::Scale> {
self.scales.iter().find(|s| s.aesthetic == aesthetic)
}
pub fn get_extent(&self, aesthetic: &str) -> Result<(f64, f64)> {
use crate::plot::ArrayElement;
let display_aes = self.aesthetic_context.map_internal_to_user(aesthetic);
let scale = self.find_scale(aesthetic).ok_or_else(|| {
GgsqlError::ValidationError(format!(
"Cannot determine extent for aesthetic '{}': no scale found",
display_aes
))
})?;
if let Some(range) = &scale.input_range {
if range.len() >= 2 {
if let (ArrayElement::Number(min), ArrayElement::Number(max)) =
(&range[0], &range[1])
{
return Ok((*min, *max));
}
}
}
Err(GgsqlError::ValidationError(format!(
"Cannot determine extent for aesthetic '{}': scale has no valid numeric range",
display_aes
)))
}
}
pub(super) fn build_detail_encoding(partition_by: &[String]) -> Option<Value> {
if partition_by.is_empty() {
return None;
}
if partition_by.len() == 1 {
Some(json!({
"field": partition_by[0],
"type": "nominal"
}))
} else {
let details: Vec<Value> = partition_by
.iter()
.map(|col| {
json!({
"field": col,
"type": "nominal"
})
})
.collect();
Some(json!(details))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plot::Parameters;
#[test]
fn test_build_label_expr_temporal_uses_utc_format() {
let mut mappings = HashMap::new();
mappings.insert("2024-01-01".to_string(), Some("Jan 2024".to_string()));
let expr = build_label_expr(&mappings, Some("%Y-%m-%d"), None, "temporal");
assert!(
expr.contains("utcFormat("),
"temporal labelExpr should use utcFormat, got: {expr}"
);
assert!(
!expr.contains("timeFormat("),
"temporal labelExpr must not use timeFormat (local tz), got: {expr}"
);
assert!(
expr.contains("utcFormat(datum.value, '%Y-%m-%d') == '2024-01-01' ? 'Jan 2024'"),
"expected correct comparison expression, got: {expr}"
);
}
#[test]
fn test_build_label_expr_non_temporal_uses_datum_label() {
let mut mappings = HashMap::new();
mappings.insert("A".to_string(), Some("Alpha".to_string()));
let expr = build_label_expr(&mappings, None, None, "nominal");
assert!(
expr.contains("datum.label == 'A'"),
"non-temporal should use datum.label, got: {expr}"
);
assert!(
!expr.contains("utcFormat("),
"non-temporal should not use utcFormat, got: {expr}"
);
}
#[test]
fn test_build_label_expr_fallback() {
let mappings = HashMap::new();
let expr = build_label_expr(&mappings, Some("%Y-%m-%d"), None, "temporal");
assert_eq!(
expr, "datum.label",
"empty mappings should fall back to datum.label"
);
}
#[test]
fn test_build_label_expr_null_suppression() {
let mut mappings = HashMap::new();
mappings.insert("2024-06-01".to_string(), None);
let expr = build_label_expr(&mappings, Some("%Y-%m-%d"), None, "temporal");
assert!(
expr.contains("? ''"),
"None mapping should suppress label (empty string), got: {expr}"
);
}
#[test]
fn test_build_label_expr_quantitative_uses_datum_value() {
let mut mappings = HashMap::new();
mappings.insert("2020".to_string(), Some("2020.0".to_string()));
let expr = build_label_expr(&mappings, None, None, "quantitative");
assert!(
expr.contains("datum.value == 2020 ? '2020.0'"),
"quantitative should use datum.value with unquoted comparison, got: {expr}"
);
assert!(
!expr.contains("datum.label =="),
"quantitative should not use datum.label for comparison, got: {expr}"
);
}
#[test]
fn test_symbol_legend_label_expr_uses_datum_label() {
use crate::plot::ArrayElement;
let breaks = vec![
ArrayElement::Number(-20.0),
ArrayElement::Number(0.0),
ArrayElement::Number(20.0),
];
let mut label_mapping = HashMap::new();
label_mapping.insert("-20".to_string(), Some("cold".to_string()));
label_mapping.insert("0".to_string(), Some("hot".to_string()));
let symbol_mapping = build_symbol_legend_label_mapping(&breaks, &label_mapping, "left");
let expr = build_label_expr(&symbol_mapping, None, None, "nominal");
assert!(
expr.contains("datum.label =="),
"symbol legend labelExpr must use datum.label (string comparison), got: {expr}"
);
assert!(
!expr.contains("datum.value =="),
"symbol legend labelExpr must not use datum.value (keys contain en-dashes), got: {expr}"
);
}
#[test]
fn test_literal_shape_converts_to_svg_path() {
let lit = ParameterValue::String("square".to_string());
let result = build_literal_encoding("shape", &lit).unwrap();
let val = &result["value"];
assert!(val.is_string(), "expected SVG path string, got: {val}");
let path = val.as_str().unwrap();
assert!(
path.starts_with('M') && path.contains('Z'),
"expected SVG path with M and Z commands, got: {path}"
);
}
#[test]
fn test_literal_shape_unknown_passes_through() {
let lit = ParameterValue::String("nonexistent".to_string());
let result = build_literal_encoding("shape", &lit).unwrap();
assert_eq!(result, json!({"value": "nonexistent"}));
}
mod get_extent_translation_tests {
use super::*;
use crate::plot::aesthetic::AestheticContext;
use crate::plot::{ArrayElement, Scale};
use crate::writer::vegalite::projection::get_projection_renderer;
fn discrete_scale(aesthetic: &str) -> Scale {
Scale {
aesthetic: aesthetic.to_string(),
scale_type: None,
input_range: Some(vec![ArrayElement::String("A".to_string())]),
explicit_input_range: false,
output_range: None,
transform: None,
explicit_transform: false,
properties: Parameters::new(),
resolved: false,
label_mapping: None,
label_template: "{}".to_string(),
}
}
#[test]
fn no_scale_found_translates_pos1_to_x_under_cartesian() {
let scales: Vec<Scale> = vec![];
let ctx = RenderContext::new(
&scales,
get_projection_renderer(None, None, &[]).as_ref(),
AestheticContext::from_static(&["x", "y"], &[]),
);
let err = ctx.get_extent("pos1").unwrap_err().to_string();
assert_eq!(
err,
"Validation error: Cannot determine extent for aesthetic 'x': no scale found"
);
}
#[test]
fn no_scale_found_translates_pos1_to_angle_under_polar() {
let scales: Vec<Scale> = vec![];
let ctx = RenderContext::new(
&scales,
get_projection_renderer(None, None, &[]).as_ref(),
AestheticContext::from_static(&["angle", "radius"], &[]),
);
let err = ctx.get_extent("pos1").unwrap_err().to_string();
assert_eq!(
err,
"Validation error: Cannot determine extent for aesthetic 'angle': no scale found"
);
}
#[test]
fn no_numeric_range_translates_pos2_to_y_under_cartesian() {
let scales = vec![discrete_scale("pos2")];
let ctx = RenderContext::new(
&scales,
get_projection_renderer(None, None, &[]).as_ref(),
AestheticContext::from_static(&["x", "y"], &[]),
);
let err = ctx.get_extent("pos2").unwrap_err().to_string();
assert_eq!(
err,
"Validation error: Cannot determine extent for aesthetic 'y': scale has no valid numeric range"
);
}
}
}