Skip to content

Decoration for polar panels#418

Open
teunbrand wants to merge 37 commits intoposit-dev:mainfrom
teunbrand:polar_panel_decor
Open

Decoration for polar panels#418
teunbrand wants to merge 37 commits intoposit-dev:mainfrom
teunbrand:polar_panel_decor

Conversation

@teunbrand
Copy link
Copy Markdown
Collaborator

@teunbrand teunbrand commented May 1, 2026

This PR aims to fix #156.

In addition to putting panel background, gridlines and axes for angle and radius aesthetics, it also introduces the radar = <null/true/false> setting.

The decoration is not complete, and in particular misses the FACET ... SETTING free => 'angle'/'radius' case. Vegalite's infrastructure around scale sharing between panels (or lack thereof) does not help us at all in the polar case. I suggest we should fix the cartesian cases first before we demand parity for polar coordinates.

There are some crude solutions around the use of the theme, which we can refine once we actually have a proper theme system in place.

This is a large PR on the one hand because we cannot use pre-existing infrastructure from vegalite, so we have to artisinally draw everything ourselves. On the other hand, this also pioneers some ProjectionRenderer abstractions that I hope will pay off later when implementing spatial projections.

teunbrand and others added 30 commits April 27, 2026 11:59
Replace CoordKind match arms throughout the Vega-Lite writer with a
ProjectionRenderer trait. Each projection type (cartesian, polar) now
owns its channel mapping and spec transformation logic, making it
straightforward to add map projections in the future.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reduces boilerplate at call sites that use default aesthetics and
empty properties.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
background_layers() and foreground_layers() let projections prepend/append
VL layers around the data layers (e.g. grid lines, axis ticks). Both
receive resolved scales and the theme config so implementations can
derive decoration from break positions and style tokens.

Also moves apply_project_transforms and apply_panel_decor from free
functions into default methods on the trait (apply_transforms,
apply_panel_decor), removing the redundant renderer construction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Decoration layers inserted by apply_panel_decor() now get
"description": "background" or "foreground" automatically. Tests use a
new data_layer() helper that filters these out by index, so they remain
stable regardless of whether a projection adds decoration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Non-arc marks in polar projection (point, line) now compute x/y in the
same pixel coordinate space arc marks use: center at (width/2, height/2)
with outerRadius = min(width,height)/2. Encodings use scale:null so
Vega-Lite treats values as raw positions. Also filters null position
values via isValid(), since scale:null bypasses VL's implicit null
handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extracts five VL expression builders (expr_normalize_radius,
expr_normalize_theta, expr_polar_x, expr_polar_y, expr_polar_radius)
that are now used by data-layer transforms, arc mark radius ranges,
and decoration layers. Introduces POLAR_OUTER const for the normalised
outer radius. Also extracts polar_properties() from the inline parsing
that was duplicated in apply_polar_project.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements background decoration layers for polar projections: a filled
panel arc, concentric grid rings at radius breaks, and radial grid
spokes at theta breaks. Moves numeric break/domain extraction to
Scale methods for reuse across the codebase.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Draws axis line, tick marks, and labels along the start angle for
the radius (pos1) scale. Ticks are centered on full circles and
extend outward on partial arcs. Fixes operator precedence in
expr_polar_x/y by parenthesising the radius expression.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Draws axis arc along the outer edge, radial tick marks at each theta
break, and centered text labels beyond the ticks. Label alignment
uses center/middle for now — per-datum alignment needs a different
approach in Vega-Lite.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ment

Vega-Lite text marks only support a single align/baseline per layer.
Bucket breaks by their computed (align, baseline) in Rust, tag each
data row with an _ab field, and emit a sub-layer per unique tag that
filters on it and sets the correct mark properties.

Also fix clippy warnings (unused variable, unused import, unused mut).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces separate apply_transforms() and apply_panel_decor() calls with
one apply_projection() method. Moves faceting before projection so
decoration layers work correctly in faceted specs. Renames the unclear
apply() to transform_layers().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…pers

PolarProjection now holds a PolarPanel with pre-computed angular range,
radius bounds, and VL expression strings (signal-based for non-faceted,
literal pixels for faceted). Expression helpers are methods on PolarPanel,
replacing the free functions. All private methods read from self.panel
instead of taking Projection parameters.

Also adds is_faceted() to the ProjectionRenderer trait with a default
panel_size() that returns container sizing, letting the call site in
mod.rs delegate sizing entirely to the projection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Discrete and ordinal scales now synthesize numeric positions from their
categorical input ranges (breaks [1..n], domain [0.5, n+0.5]) so that
polar grid decoration can work uniformly across all scale types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tesian conversion

Extends convert_polar_to_cartesian for non-arc polar marks:
- Discrete theta/radius domains generate indexof() VL expressions
- radius2/theta2 channels converted to x2/y2 using primary domain
- Offset channels (radiusOffset/thetaOffset) normalized into polar
  space when they carry a scale domain, or applied as raw pixel
  displacements along the radial/tangential directions otherwise
- Discrete offsets narrowed by band fraction (0.9) to leave angular
  gaps between adjacent categories, matching VL band scale padding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds break_labels() to ScaleTypeTrait, returning (position, label)
pairs. Discrete and ordinal scales pair integer positions with
input-range category names; continuous scales format break values
as strings. Scale.break_labels() applies label_mapping overrides
on top (renaming or suppressing to empty string).

Polar radial and angular axes now use break_labels() so discrete
categories show their names instead of numeric positions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
indexof() returns -1 for values not in the domain array, which
previously produced position 0 (outside the synthesized domain).
Now maps to null so VL drops the row. Also escapes single quotes
in category names to prevent broken VL expressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Polar grid lines and axes are drawn as manual VL layers positioned from
the global scale domain. With free scales each panel has its own domain,
so the global positions would be wrong. Suppress them rather than render
misleading decorations.

Also adds Facet::is_free() and removes the free_scales plumbing from
EncodingContext/ScaleContext in favour of reading spec.facet directly.
get_projection_renderer() now takes Option<&Facet> instead of a bool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Nullable boolean `radar` setting on PROJECT TO polar. When null
(default), auto-detects from theta scale discreteness. When
explicitly true, validates that the angle scale is discrete.

Resolved after scale resolution in resolve_projection_properties()
so downstream code can read it as a plain boolean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When radar mode is active (discrete theta), panel background, grid
rings, and angular axis outline use straight-segment polygons instead
of circular arcs. Shared helpers: arc_ring, polygon_ring, theta_breaks.

Donut panels (inner > 0) trace outer vertices forward then inner
reversed so the fill rule leaves the centre hole empty.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
For partial circles (start != end - 360), polygon_ring now adds
vertices at the start and end angles and traces back through the
centre (or inner radius) to form a closed wedge shape.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The start angle bisects a polygon face, which sits at cos(half_span)
of the circumscribed radius. Scale the axis line, ticks, and labels
inward so they land on the polygon edge rather than beyond it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
In radar mode, theta offsets (e.g. jitter) now interpolate linearly
toward the adjacent spoke instead of following a circular arc. This
keeps displaced points inside the polygon panel boundaries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Partial-arc start/end vertices are now pulled inward by
cos(angle_to_nearest_break) so boundary faces are flush with
inter-break edges. The radial axis correction is extended from
full-circle-only to all radar panels. Theta offset lerp targets
are clamped to [start, end] so boundary spokes lerp toward the
panel edge.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
teunbrand and others added 7 commits May 1, 2026 10:03
Radar plots with only 1–2 categories degenerate into a line or single
axis, so suppress auto-detection and reject explicit `radar => true`
when the theta scale has ≤2 levels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Introduce AxisInfo (domain, breaks, labels, is_free) built from scales
at construction time. Zero-range domains normalize to None upfront,
eliminating downstream guards. Add is_full_circle and
angle_breaks_radians as derived fields on PolarContext.

This simplifies expr_normalize_radius/theta, all decoration methods
(grid_rings, grid_spokes, radial_axis, angular_axis, panel_arc),
convert_polar_to_cartesian, and polygon_ring — which no longer need
scale/domain/thetas parameters passed through call chains.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The polar projection never transforms the DataFrame — it only modifies
the VL spec. Drop the data parameter and Option<DataFrame> return from
transform_layers and apply_projection. Also fix 7 unnecessary mut
bindings in tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
encoding.rs escaped single quotes but not backslashes in label
remap expressions. Consolidate all three call sites to use the
existing escape_vega_string helper, which handles both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract categorical_numeric_breaks, categorical_numeric_domain, and
categorical_break_labels into scale_type/mod.rs. Both Discrete and
Ordinal now delegate to these instead of duplicating the logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@teunbrand teunbrand marked this pull request as ready for review May 1, 2026 14:36
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.

Manually draw axes, grid-lines etc for polar projection

1 participant