Summary
A mark that emits several SVG leaves per datum (a candlestick's upper wick, lower
wick and body; anything with a similar internal composition) has no way to express
"this datum is dimmed" once. Every leaf resolves its own state and runs its own
tween. Wrapping the leaves in a group and putting states on the group does not
help, because mark-state.js never resolves state for a group node.
I am not reporting a rendering bug — the output is correct. The cost is per-hover
animation work, and the absence of any way for a consumer to opt out of it.
Version
@tanstack/charts@0.16.0
What happens
dist/mark-state.js:17, the single path used by the motion, SVG, canvas and native
renderers:
const state = node.kind === "group" ? node.states : void 0;
...
const resolved = node.kind !== "group" && nodeDefinitions && nodeData && candidates.length
? resolveNodeState(node, candidates, nodeData, nodeDefinitions, focus, pointer)
: { node };
A group's states only supply definitions and points to its descendants; the group
itself falls through to { node }, unresolved. So a group cannot carry the dim.
The descendants then each resolve anyway, because ownership strips key prefixes —
sceneKeyOwnedPoints walks while (candidate.includes(":")) back through the last
: (dist/scene-point-ownership-internal.js:40-58), so candles:5:body matches
point candles:5. Regrouped or not, three leaves resolve three times and three
tweens run.
Why it matters
Measured on a 1000-candle candlestick, hovering to dim the non-hovered series:
|
dim latency |
hand-written SVG (one <g opacity> per candle, CSS transition) |
75.5 ms |
same chart on @tanstack/charts (three stateful leaves per candle) |
243 ms |
~3.2×, on the same scene, same data, same 150 ms intended transition. The difference
is the number of state resolutions and WAAPI tweens: ~1000 against ~3000.
Both sides settle to the same visual result and the same number of visually-dimmed
elements (3009 vs 2999 — a group's opacity composes onto its leaves, so the rendered
element counts match). The gap is entirely the work done to get there.
What would help, in preference order
- Resolve
states on a group node. If node.kind === "group" resolved like any
other node, a consumer could wrap the leaves of one datum and dim them in one tween.
The composed-opacity semantics already do the right thing at paint time.
- An opt-out from the prefix-stripping ownership fallback — a way to say that
candles:5:body should not inherit candles:5's state, so a consumer can put
the state exactly where they want it and nowhere else.
Either one is enough. (1) is the smaller change for a consumer to adopt.
What we are not asking for
We are not asking for the leaves to be merged or the mark to emit different SVG. The
composition is ours and it is correct.
Workaround, and why we rejected it
The only mechanism that gives one tween per datum is to bake style.opacity on a
per-candle <g> ourselves: drop the mark states, plumb hover through React state,
re-render, and hand-write a replacement fade for the 150 ms tween that dies with them.
That is a second dim implementation running alongside the engine's, with focus,
tooltip and reveal blast radius. We would rather wait for the engine to be able to
say it.
Summary
A mark that emits several SVG leaves per datum (a candlestick's upper wick, lower
wick and body; anything with a similar internal composition) has no way to express
"this datum is dimmed" once. Every leaf resolves its own state and runs its own
tween. Wrapping the leaves in a group and putting
stateson the group does nothelp, because
mark-state.jsnever resolves state for a group node.I am not reporting a rendering bug — the output is correct. The cost is per-hover
animation work, and the absence of any way for a consumer to opt out of it.
Version
@tanstack/charts@0.16.0What happens
dist/mark-state.js:17, the single path used by the motion, SVG, canvas and nativerenderers:
A group's
statesonly supply definitions and points to its descendants; the groupitself falls through to
{ node }, unresolved. So a group cannot carry the dim.The descendants then each resolve anyway, because ownership strips key prefixes —
sceneKeyOwnedPointswalkswhile (candidate.includes(":"))back through the last:(dist/scene-point-ownership-internal.js:40-58), socandles:5:bodymatchespoint
candles:5. Regrouped or not, three leaves resolve three times and threetweens run.
Why it matters
Measured on a 1000-candle candlestick, hovering to dim the non-hovered series:
<g opacity>per candle, CSS transition)@tanstack/charts(three stateful leaves per candle)~3.2×, on the same scene, same data, same 150 ms intended transition. The difference
is the number of state resolutions and WAAPI tweens: ~1000 against ~3000.
Both sides settle to the same visual result and the same number of visually-dimmed
elements (3009 vs 2999 — a group's opacity composes onto its leaves, so the rendered
element counts match). The gap is entirely the work done to get there.
What would help, in preference order
stateson a group node. Ifnode.kind === "group"resolved like anyother node, a consumer could wrap the leaves of one datum and dim them in one tween.
The composed-opacity semantics already do the right thing at paint time.
candles:5:bodyshould not inheritcandles:5's state, so a consumer can putthe state exactly where they want it and nowhere else.
Either one is enough. (1) is the smaller change for a consumer to adopt.
What we are not asking for
We are not asking for the leaves to be merged or the mark to emit different SVG. The
composition is ours and it is correct.
Workaround, and why we rejected it
The only mechanism that gives one tween per datum is to bake
style.opacityon aper-candle
<g>ourselves: drop the mark states, plumb hover through React state,re-render, and hand-write a replacement fade for the 150 ms tween that dies with them.
That is a second dim implementation running alongside the engine's, with focus,
tooltip and reveal blast radius. We would rather wait for the engine to be able to
say it.