Skip to content

fix(ui): spring the org chart's folds and stop drags sticking to the cursor - #46

Merged
yyyyaaa merged 1 commit into
mainfrom
phat/org-chart-fixes
Sep 26, 2026
Merged

yyyyaaa merged 1 commit into
mainfrom
phat/org-chart-fixes

Conversation

@yyyyaaa

@yyyyaaa yyyyaaa commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Folding and unfolding large teams

  • Cards memoise apart from their position, so a fold no longer re-renders every card (folding a 4-person team used to cost as much as unfolding 390).
  • Cards and wires share one spring (a CSS linear() curve, ~0.4s, slight overshoot). New reports slide out from under their manager; folded reports tuck back beneath it, then unmount.
  • Cards, wires, and anchors keep a stable DOM order, so running transitions are never cancelled by React moving nodes.
  • The person you fold, unfold, or drop onto stays put on screen while the chart reflows around them. After such an edit the view stops auto-fitting (as it already did after a pan or zoom); the Fit button still re-frames.
  • Each card mounts its actions menu on first click, removing about a third of the cost of mounting hundreds of cards.

Drags that stuck to the cursor

  • A drag only listened on the card, so a release lost to a context menu, another window, or the card being moved in the DOM left the press alive, and the card followed the cursor the next time it crossed it.
  • Drags now listen on the window and end on release, Escape, window blur, or any move with no button held. Canvas pans end the same way.

Shared canvas (workspace-kit/canvas)

  • reveal accepts a box in canvas units, contentOrigin lets fitting frame shifted content, and hold() stops auto-fitting without moving the view. All are inert for the other canvases.

Storybook

  • New 391-person StressTest story whose host saves each move after 300ms.

Measurements

Production Storybook build, 391-person stress story (headless Chrome):

Action Before After
Unfold the CEO (390 cards) 110–170ms block 110–125ms block, then ~9ms frames
Fold/unfold a manager 75–90ms block 60–100ms block, then ~17ms frames
Fold the whole org instant (no animation) ~100ms block, then ~30fps slide

At 14 and 201 people both builds stay at ~60fps.

Test plan

  • pnpm check
  • pnpm build:registry
  • SMOKE_CASE=org-chart pnpm --filter @constructive-io/registry smoke:install
  • Existing Org Chart keyboard and reparent test passes
  • Frame timings on the StressTest, LargeOrganization, and FoldedTeams stories; the toggled card stays in place
  • Automated test for a lost release ending the drag (not added yet)
  • Manual check of the stuck-drag scenario in a browser

Generated with Devin

…cursor

Folding and unfolding re-rendered every card and animated left/top on a
CSS ease, so large teams stalled and new reports popped in. Cards now
memoise apart from their position, and cards and wires share one spring
(a CSS linear() curve), so new reports slide out from under their manager
and folded ones tuck back beneath it. Cards and wires keep a stable DOM
order so running transitions are never cancelled. The folded or dropped-on
person holds still on screen while the chart reflows around them, and each
card mounts its actions menu on first click.

A drag listened only on the card, so a release lost to a context menu,
another window, or the card moving in the DOM left the card following the
cursor. Drags now listen on the window, end on blur, Escape, or any move
with no button held, and canvas pans end the same way.

Adds a 391-person StressTest story for folding and dragging at scale.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@tenki-reviewer

tenki-reviewer Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 1 medium

💬 Inline comments (1)

🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 Gate SVG marching-dash <animate> behind reduced motion (org-chart-wires.tsx:52) — The drag-preview wire's marching-dashes animation runs unconditionally: the <animate attributeName="stroke-dashoffset" ... repeatCount="indefinite" /> at packages/ui/src/components/org-chart/org-chart-wires.tsx:52 has no prefers-reduced-motion guard, unlike every other transition in the chart which uses springTransition's motion-reduce:transition-none.

The change rebuilds the org chart's motion layer: a new spring utility drives CSS linear() easing and settle-time estimates, the scene module now tracks layout/leaving state, and node/wire rendering gains keyed stable DOM order, memoized comparisons, and useId-based SVG marker ids. The shared canvas picks up pan/zoom gesture refinements (pointer bitmask, hold() auto-fit suppression, reveal/fit coordinate handling), and a stress story exercises rapid drag/reparent flows.

Files Change
packages/ui/src/components/org-chart/org-chart-spring.ts, org-chart-scene.ts New spring/easing math and scene layout/leave lifecycle driving the animated transitions.
packages/ui/src/components/org-chart/org-chart-node.tsx, org-chart-wires.tsx Memoized node cards with enter/exit transitions and SVG wires with stable keys, marker ids, and drag-preview marching dashes.
packages/ui/src/components/org-chart/org-chart.tsx Orchestrates selection, reveal-on-select, fold/reparent flows, and drag-drop against the new scene.
packages/ui/src/components/workspace-kit/canvas.tsx Pan/zoom gesture, auto-fit suppression (hold()), and reveal() coordinate conversion updates shared with the org chart.
packages/ui/registry.json, packages/ui/src/stories/OrgChart.stories.tsx Registry output refresh and a stress story covering rapid fold/reparent interactions.

Reviewed commit: 0ff6b49

@yyyyaaa
yyyyaaa merged commit ba23a3d into main Sep 26, 2026
6 checks passed

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR reworks the Org Chart's animated scene (spring-based transitions, enter/leave choreography, marching-dash drag-preview wires) and refines the pan/zoom canvas gesture handling it sits on.

Key findings

Comment on lines 171 to +174
React.useEffect(() => {
if (!revealRequest) return;
const node = findNode(layerRef.current, revealRequest.id);
if (node) reveal(node);
}, [layout, reveal, revealRequest]);
const person = revealRequest ? layout.byId.get(revealRequest.id) : undefined;
if (person) reveal({ ...person, x: person.x + shift.x, y: person.y + shift.y });
}, [layout, reveal, revealRequest, shift]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Stale revealRequest re-reveals on every shift change

The reveal effect (packages/ui/src/components/org-chart/org-chart.tsx:171-174) re-runs whenever layout, reveal, revealRequest, or shift changes, but revealRequest is never cleared after being consumed — the only write is setRevealRequest({ id }) at line 191. The PR adds shift to the dependency array, and shift changes on every fold or drop, so long after a 'select and reveal' the next unrelated fold re-invokes reveal for that old person and yanks the viewport back to them, fighting a user who has since panned away and silently defeating the hold() no-camera-jump guarantee that toggle and reparent rely on.

📋 Prompt for AI Agents

In packages/ui/src/components/org-chart/org-chart.tsx lines 171-174, make the reveal effect one-shot: after resolving the person, call setRevealRequest(null) (or compare a stored generation/token so the same request is not re-applied when the shift or layout dependencies change later) so each request fires exactly once. Without this, the newly added shift dependency re-triggers reveal for a stale person on every fold or drop that shifts the layout, re-panning the camera despite hold().

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.

1 participant