Skip to content

Defaults for aesthetics#142

Merged
teunbrand merged 20 commits intoposit-dev:mainfrom
teunbrand:default_aesthetics
Feb 24, 2026
Merged

Defaults for aesthetics#142
teunbrand merged 20 commits intoposit-dev:mainfrom
teunbrand:default_aesthetics

Conversation

@teunbrand
Copy link
Copy Markdown
Collaborator

This PR aims to fix #76.

Briefly, it replaces the GeomAesthetics struct with a DefaultAesthetics struct that evokes ggplot2::Geom$default_aesthetics energy and gives it a few methods. The point of this is that we control the defaults, and don't rely on the writer's defaults. The defaults are woven into layers, giving precedence to SETTING and MAPPING clauses.

I've not necessarily mirrorred ggplot2's aesthetic default here. I've often opted for a semitransparent fill, because I believe it makes it easier to see if you're (accidentially or on purpose) overplotting things. Most outlines have a linewidth of 1, whereas line/path has a linewidth of 1.5, which I justify because the line is the layer, rather than a component of the layer.

Setting this as draft for some polishing.

teunbrand and others added 6 commits February 19, 2026 11:54
Restructure GeomAesthetics to prepare for centralized default aesthetic values.
Each geom now defines all aesthetic metadata (required, optional, delayed) in a
single defaults field, enabling future control over visual appearance defaults.

Key changes:
- Rename GeomAesthetics to DefaultAesthetics with unified defaults field
- Add Required, Null, Delayed variants to DefaultAestheticValue enum
- Derive supported/required lists via helper methods instead of separate fields
- Update all 22 geom implementations with new structure
- Fix semantic bugs where MAPPING validation incorrectly included Delayed aesthetics

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Implement Step 4 of the plan: writer now applies default aesthetic values
from each geom's DefaultAesthetics when building encodings. Defaults are
applied with lowest priority (after MAPPING and SETTING).

Key changes:
- Add logic in build_layer_encoding() to iterate over geom defaults
- Skip if encoding already set by MAPPING or SETTING
- Convert DefaultAestheticValue to AestheticValue via to_aesthetic_value()
- Reuse existing build_encoding_channel() for unit conversions
- Skip Required, Null, and Delayed variants (no literal defaults to apply)

Precedence order: MAPPING > SETTING > defaults (geom)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Remove redundant hardcoded defaults from BoxplotRenderer and PolygonRenderer.
These defaults are now handled by the centralized default aesthetics system
in build_layer_encoding().

Changes:
- BoxplotRenderer: removed default_stroke, default_fill, default_linewidth
- PolygonRenderer: removed hardcoded fill and stroke ("#888888")

Encoding-level properties (from geom defaults) override mark-level properties,
so the hardcoded values were redundant. All defaults now come from each geom's
DefaultAesthetics definition.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add unit and integration tests to verify default aesthetic behavior:

Unit tests (types.rs):
- test_default_aesthetics_methods: Comprehensive test of all helper methods
  (get, names, supported, required, is_supported, contains, is_required)
  on a DefaultAesthetics instance with various value types

Integration tests (vegalite/mod.rs):
- test_default_aesthetics_applied: Verify defaults appear in Vega-Lite output
  (stroke="black", opacity=1.0)
- test_setting_overrides_default: Verify SETTING takes precedence over defaults
- test_mapping_overrides_default: Verify MAPPING takes precedence over defaults
- test_null_defaults_not_applied: Verify Null variants don't create encodings

Also adds get() helper method to DefaultAesthetics for cleaner test code.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Use existing linetype_to_stroke_dash() function to convert linetype
strings to Vega-Lite strokeDash arrays in two places:
- SETTING parameters (e.g., SETTING linetype => 'dashed')
- Default aesthetic values (e.g., line geom's default "solid")

Named patterns like "solid", "dashed", "dotted" are converted to
numeric arrays ([6,4] for dashed), while unrecognized patterns
pass through as strings.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
// 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"],
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: width was removed. It is already a parameter and width cannot be a vegalite encoding (vegalite uses size when mapped to data).

teunbrand and others added 4 commits February 19, 2026 16:54
Unify SETTING parameter and default aesthetic processing into a single
loop to eliminate code duplication and clarify precedence order.

Changes:
- Merge separate SETTING and defaults loops into unified loop
- Use build_encoding_channel() for both (eliminates conversion duplication)
- Make precedence explicit: SETTING > defaults (both checked per aesthetic)
- Remove linetype_to_stroke_dash import (now only used in encoding.rs)

Benefits:
- Single source of truth for SETTING/defaults precedence logic
- All conversions (size, linewidth, linetype) go through same code path
- Reduced code: ~40 lines → ~28 lines
- Prepares for future writers (ggplot2, plotters) to reuse precedence logic

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Add Layer::get_aesthetic_value() to centralize SETTING > defaults
precedence logic, preparing for multiple writers (ggplot2, plotters).

Changes:
- Add DefaultAestheticValue::to_parameter_value() to extract literal values
  (returns ParameterValue, not Option - uses Null for non-literals)
- Refactor to_aesthetic_value() to reuse to_parameter_value() (eliminates duplication)
- Add Layer::get_aesthetic_value() for SETTING > defaults resolution
- Update Vega-Lite writer to use get_aesthetic_value() (28 lines → 18 lines)

Benefits:
- Precedence logic centralized in Layer (not duplicated per writer)
- Future writers can reuse get_aesthetic_value() for consistent behavior
- Conversions (size, linewidth, linetype) remain writer-specific
- Cleaner, more maintainable code with simpler signatures

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@teunbrand teunbrand marked this pull request as ready for review February 20, 2026 09:32
@teunbrand teunbrand requested a review from thomasp85 February 20, 2026 09:32
Copy link
Copy Markdown
Collaborator

@thomasp85 thomasp85 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments and suggestions but I really like the approach taken here

Comment thread src/plot/layer/geom/bar.rs Outdated
Comment thread src/plot/layer/geom/boxplot.rs Outdated
Comment thread src/plot/layer/geom/text.rs
Comment thread src/writer/vegalite/mod.rs Outdated
teunbrand and others added 5 commits February 24, 2026 11:55
Aesthetic harmonization (MAPPING > SETTING > defaults) now happens
once during execution rather than per-writer. This creates a single
source of truth for aesthetic values.

Changes:
- Add resolved_aesthetics field to Layer (HashMap of resolved values)
- Add Layer::resolve_aesthetics() method called during execution
- Remove Layer::get_aesthetic_value() (logic now inlined)
- Simplify Vega-Lite writer to use resolved_aesthetics directly
- Add comprehensive tests for resolution logic

This ensures all writers handle aesthetics consistently and reduces
duplication across writer implementations.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Consolidate aesthetic resolution by removing the separate resolved_aesthetics
field and inserting SETTING/defaults directly into mappings as Literal values.
The key insight is timing: resolve_aesthetics() now runs AFTER query literals
have been converted to columns, ensuring proper distinction.

Changes:
- Remove resolved_aesthetics field from Layer struct
- Update resolve_aesthetics() to insert into mappings as AestheticValue::Literal
- Move resolve_aesthetics() call to after remappings (line 733)
- Simplify Vega-Lite writer to single loop over mappings
- Update all tests to check mappings instead of resolved_aesthetics
- Add comprehensive documentation explaining query literal vs SETTING/default distinction

Flow:
1. Query literals ('foo' AS color) → parsed as Literal in mappings
2. build_layer_select_list() → converts to SQL columns
3. update_mappings_for_aesthetic_columns() → Literal → Column in mappings
4. resolve_aesthetics() → adds SETTING/defaults as NEW Literals (key change)
5. Writer → Column gets scales, Literal renders as constant value

This ensures:
- Query literals can have scales applied (they're columns)
- SETTING/defaults remain constant values (they're Literals)
- Single source of truth for all aesthetic values
- Correct precedence: MAPPING > REMAPPING > SETTING > defaults

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Resolved conflicts in src/writer/vegalite/mod.rs:
- Kept all imports from both branches
- Merged test sections (aesthetic tests + facet tests)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@teunbrand
Copy link
Copy Markdown
Collaborator Author

Ok resolving the defaults/settings now feeds into the Layer.mapping near the end of the execution, so no extra logic is needed at the writer stage.

@teunbrand teunbrand mentioned this pull request Feb 24, 2026
@teunbrand teunbrand merged commit c1979cc into posit-dev:main Feb 24, 2026
4 checks passed
@teunbrand teunbrand deleted the default_aesthetics branch February 24, 2026 15:47
cpsievert added a commit that referenced this pull request Mar 4, 2026
After #142 and #151, metadata.columns returned internal aesthetic names
(pos1, pos2) and geom defaults (fill, stroke, opacity, etc.) instead of
the original SQL column names from the user's data.

Use label_name() which returns the original SQL column name (stored in
original_name) for Column entries and None for Literal entries, fixing
both issues in one filter_map.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Take control over defaults

2 participants