Cascade mark-level animation over the chart policy instead of replacing it - #331
Conversation
…ng it Fixes #329. `Animation.to_spec()` emits every field, and both the Python merge in `_attach_keys` and the client's `_resolvedAnimation` are plain dict spreads. So a mark-level `xy.animation(duration=90)` was a complete spec spread over the chart's, resetting `match`, `easing`, `enter`, `update`, and `interpolate` to their defaults. Chart-level `match="key"` silently became index matching — no error, no fallback string — and the `match='key' requires key=` guard stopped firing in that configuration because it reads the same clobbered dict. `animation()` now records the caller's exact argument names behind sentinel defaults, so "set to the default" is distinguishable from "not set": a mark that passes `match="index"` against a chart's `match="key"` still wins. `to_override_spec()` returns just those fields. Direct `Animation(...)` construction is public too and falls back to diffing the defaults, which is right except for that same explicit-default case. The cascade resolves once, in Python, over a defaults base: {**defaults, **chart_spec, **mark_override} and a trace that carries an override ships the complete resolved policy. Shipping the sparse override instead would break a mark-level animation on a chart with no chart-level one — the client would read `undefined` for duration and easing. Resolving here means the client's spread stays a no-op rather than a second merge needing its own copy of the defaults. A trace with no override still ships no animation dict, and a chart with neither ships none, which is what keeps an unanimated chart static. Verified with a 59-combination cascade matrix (6 chart specs x 10 mark overrides including bools and None), each replaying the client's exact spread and checking all 8 fields plus whether identity planes ship. Identity-plane shipping tracks the resolved `match`, so this composes with #330: key matching that starts working again also gets its planes back. Three tests asserted the old behavior as a literal and now assert the cascade. test_mark_animation_spec_clobbers_chart_level_fields, added in #330 to pin the bug, becomes the regression test for the fix. Verified: 2578 passed / 4 skipped; ruff check and format clean; ty at the 14-diagnostic baseline with an identical diagnostic set; public API verification OK.
📝 WalkthroughWalkthroughMark-level animation policies now cascade field-by-field over chart-level settings. Explicit fields, including default-valued ones, are tracked; Python resolves the complete effective policy while trace payloads retain sparse overrides. Tests and documentation cover inheritance, key matching, defaults, and disabling. ChangesAnimation cascade resolution
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant ChartAnimation
participant MarkAnimation
participant ApplyMarkTransitionMetadata
participant Trace
ChartAnimation->>ApplyMarkTransitionMetadata: provide chart animation spec
MarkAnimation->>ApplyMarkTransitionMetadata: provide sparse override spec
ApplyMarkTransitionMetadata->>ApplyMarkTransitionMetadata: merge defaults, chart spec, and mark override
ApplyMarkTransitionMetadata->>Trace: assign resolved policy and sparse trace animation
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
🧹 Nitpick comments (1)
python/xy/components.py (1)
445-464: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value
_animation_defaults()caches a mutable dict by reference.
lru_cache(maxsize=1)means every caller gets the exact same dict object back. Current call sites only spread it ({**_animation_defaults(), ...}) or read individual keys, so it's safe today, but any future code that mutates the returned dict in place (e.g.defaults.pop(...)) would silently corrupt the single cached instance for the process lifetime, affecting every subsequent chart. Consider returning a copy (or wrapping inMappingProxyType) to make the cache immutable by construction.🛡️ Defensive fix
`@lru_cache`(maxsize=1) -def _animation_defaults() -> dict[str, Any]: +def _animation_defaults() -> Mapping[str, Any]: """The complete default policy, and the base every cascade starts from.""" - return Animation().to_spec() + return MappingProxyType(Animation().to_spec())🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@python/xy/components.py` around lines 445 - 464, Update _animation_defaults so callers cannot mutate the single dictionary stored by its lru_cache entry; return a fresh copy or an immutable mapping while preserving the existing default values and lookup behavior used by to_override_spec.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@python/xy/components.py`:
- Around line 445-464: Update _animation_defaults so callers cannot mutate the
single dictionary stored by its lru_cache entry; return a fresh copy or an
immutable mapping while preserving the existing default values and lookup
behavior used by to_override_spec.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: dd2381eb-5c24-41f6-93fd-210648786cce
📒 Files selected for processing (6)
CHANGELOG.mddocs/components/marks.mddocs/styling/animations.mdpython/xy/components.pyspec/design/animation.mdtests/test_animation.py
Fixes #329.
The bug
Animation.to_spec()emits every field, and both the Python merge in_attach_keysand the client's_resolvedAnimationare plain dict spreads. A mark-levelxy.animation(duration=90)was therefore a complete spec spread over the chart's, resetting everything the caller did not mention:Two consequences, both silent:
match="key"stopped working the moment any mark carried its ownanimation=. No error, noanimation_fallback— the chart just index-matched.match='key' requires key=guard stopped firing, because it reads the same clobbered dict.The fix
animation()records the caller's exact argument names behind sentinel defaults, so "set to the default" is distinguishable from "not set" — a mark passingmatch="index"against a chart'smatch="key"still wins.to_override_spec()returns only those fields.Animation(...)is public and directly constructible too; it falls back to diffing against the defaults, which is right except for that same explicit-default case.The cascade resolves once, in Python, over a defaults base:
{**_animation_defaults(), **(chart_spec or {}), **(mark_override or {})}A trace carrying an override ships the complete resolved policy. Shipping the sparse override instead would break a mark-level animation on a chart with no chart-level one — the client would read
undefinedfor duration and easing. Resolving here keeps the client's spread a no-op instead of a second merge needing its own copy of the defaults. A trace with no override still ships no animation dict, and a chart with neither ships none, which is what keeps an unanimated chart static.No JS change was needed.
Verification
True/False/None), each replaying the client's exact spread and checking all 8 fields plus whether identity planes ship. 0 wrong.on_start/on_endnever leak into the spec or count as overrides; all five construction-time validations still raise.pytest tests/— 2578 passed, 4 skippedruff@0.15.8 check/format --checkcleantyat the 14-diagnostic baseline with an identical diagnostic set (an earlier draft added 9;Animation(**passed)andtuple()over a heterogeneous dict were the cause)scripts/check_public_api.pyOKComposes with #330
Identity-plane shipping is derived from the resolved
match, so key matching that starts working again automatically gets its planes back. Confirmed in the matrix:should_shipis computed from the expected resolved config and matched against the payload for all 59 combinations.Test changes
Three tests asserted the old behavior as a literal:
test_mark_animation_spec_clobbers_chart_level_fields— added in Ship animation identity planes only when the client can key-match #330 to pin the bug, now inverted intotest_mark_animation_cascades_over_chart_level_fields.test_mark_animation_overrides_chart_defaultsandtest_disabled_mark_does_not_require_chart_level_key_matching_identityassertedtrace["animation"] == {"enabled": False}; they now assert the resolved policy.New: explicit-default override, cascade-restores-the-guard, mark-animation-alone-is-complete, and
to_override_specunit coverage.Summary by CodeRabbit
Bug Fixes
Documentation