Skip to content

[Bug] globalZIndex clone in the interactive layer loses its parent transform, so its bounds break picking #2109

Description

@g1f9

Version

@visactor/vrender-core 1.1.5 (also present in @visactor/vrender 1.1.5)

Link to Minimal Reproduction

https://gist.github.com/g1f9/1e280122cbdcb20da77d04d57d06f89e

Single self-contained HTML file (loads vrender from unpkg). Save it and open in a browser — it prints the result to the page, no build setup needed.

Steps to Reproduce

  1. Put a graphic inside a group that carries a transform (a translate is enough — in practice this is any chart, where marks live under a translated region group).
  2. Give that graphic globalZIndex so vrender hoists it into the _builtin_interactive layer.
  3. Render, then inspect the clone's bounds and try to pick the graphic at its real stage coordinates.
const stage = VRender.createStage({ container: 'app', width: 500, height: 400, autoRender: false });

const root = VRender.createGroup({ x: 100, y: 80 });   // parent transform
stage.defaultLayer.add(root);

const marks = VRender.createGroup({});
const covering = VRender.createGroup({});
root.add(marks);
root.add(covering);      // same zIndex, visited first when picking

const circle = VRender.createCircle({ x: 150, y: 100, radius: 12, fill: 'red', globalZIndex: 100 });
marks.add(circle);
covering.add(VRender.createRect({ x: 0, y: 0, width: 300, height: 200, fill: 'rgba(80,140,255,.55)' }));

stage.render();

circle.globalAABBBounds                  // (238, 168) ~ (262, 192)   correct
circle.interactiveGraphic.globalAABBBounds  // (138,  88) ~ (162, 112)   short by exactly (100, 80)
stage.getLayer('_builtin_interactive').AABBBounds  // (138, 88) ~ (162, 112)

stage.pick(250, 180)   // -> rect, expected circle

Current Behavior

The clone that InteractiveDrawItemInterceptorContribution.beforeSetInteractive puts into the interactive layer's shadow root keeps only globalZIndex / zIndex:

https://github.com/VisActor/VRender/blob/develop/packages/vrender-core/src/render/contributions/render/draw-interceptor.ts

interactiveGraphic = graphic.clone();
graphic.interactiveGraphic = interactiveGraphic;
interactiveGraphic.baseGraphic = graphic;
interactiveGraphic.setAttributes({ globalZIndex: 0, zIndex: graphic.attribute.globalZIndex }, false, { skipUpdateCallback: true });
this.getShadowRoot(interactiveLayer).add(interactiveGraphic);

The clone never receives the transform its original inherits from its ancestors. Drawing is unaffected, because beforeDrawInteractive renders the baseGraphic and re-applies the matrix by hand:

context.setTransformFromMatrix(baseGraphic.parent.globalTransMatrix, true);
baseGraphic.isContainer ? drawContribution.renderGroup(baseGraphic, ...) : drawContribution.renderItem(baseGraphic, drawContext);

But the clone's own geometry is never corrected, so its globalAABBBounds — and therefore the bounds of the shadow root, of _interactive_group, and of the whole _builtin_interactive layer — are off by the ancestor transform.

Picking then reads those bounds. DefaultPickService.pickGroup gates traversal on them:

const insideGroup = group.AABBBounds.containsPoint(newPoint);
if (!insideGroup && !group.stage.camera) return result;

So whenever the real pointer position falls outside the shifted bounds, the entire interactive layer is skipped and the hoisted graphic is unreachable, even though it is painted on top. Graphics that happen to fall inside the shifted box are still picked correctly, which makes this look intermittent — in a chart, points near one edge of the plot area stop responding to hover while the rest work.

Expected Behavior

A graphic hoisted with globalZIndex should stay pickable at the coordinates where it is actually drawn, regardless of any transform on its ancestors.

Possible fix

Give the clone the transform its original inherits, e.g. in beforeSetInteractive:

interactiveGraphic.setAttributes({
  globalZIndex: 0,
  zIndex: graphic.attribute.globalZIndex,
  postMatrix: graphic.parent ? graphic.parent.globalTransMatrix.clone() : undefined,
}, false, { skipUpdateCallback: true });

postMatrix composes outside the local transform (Graphic.doUpdateLocalMatrix), so this reproduces exactly what the ancestor chain would contribute. The reproduction above applies this at runtime and the result flips from rect to circle, with the clone's bounds becoming identical to the original's. Rendering is unchanged since the draw path uses baseGraphic.

Two side notes, in case they help triage:

  • The stale bounds also mean the interactive layer computes its dirty region from the wrong box, which we suspect is behind some repaint artifacts we have seen when toggling globalZIndex on hover.
  • We are currently shipping this as a local prototype patch. Happy to send a PR if the approach looks right.

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