Skip to content

A datum that renders as several leaves cannot be stated once: states on a group is never resolved #135

Description

@toomingos

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

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions