From 7129c4ee482dd7e0c6e2d877d109c877a64f8234 Mon Sep 17 00:00:00 2001 From: Rassl Date: Fri, 22 May 2026 22:49:52 +0400 Subject: [PATCH] feat: enhance camera movement, dynamic labels --- src/components/universe/graph-canvas.tsx | 64 +++++- src/graph-viz-kit/GraphView.tsx | 71 ++++++- src/graph-viz-kit/OffscreenIndicators.tsx | 241 +++++++++++++++++++++- src/graph-viz-kit/useLabelPlacement.ts | 151 ++++++++++++++ 4 files changed, 505 insertions(+), 22 deletions(-) create mode 100644 src/graph-viz-kit/useLabelPlacement.ts diff --git a/src/components/universe/graph-canvas.tsx b/src/components/universe/graph-canvas.tsx index 874a4ef..520cb99 100644 --- a/src/components/universe/graph-canvas.tsx +++ b/src/components/universe/graph-canvas.tsx @@ -1,7 +1,7 @@ "use client" import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { Canvas, useFrame } from "@react-three/fiber" +import { Canvas, useFrame, useThree } from "@react-three/fiber" import { CameraControls } from "@react-three/drei" import { EffectComposer, Bloom } from "@react-three/postprocessing" import { Vector3 } from "three" @@ -387,7 +387,7 @@ interface CamTarget { lookZ: number } -function computeCamTarget(graph: Graph, nodeId: number): CamTarget { +function computeCamTarget(graph: Graph, nodeId: number, currentAzimuth: number): CamTarget { const p = graph.nodes[nodeId].position const treeKids = graph.childrenOf?.get(nodeId) ?? [] const kidPts = treeKids.map((nid) => graph.nodes[nid]?.position).filter(Boolean) @@ -399,10 +399,17 @@ function computeCamTarget(graph: Graph, nodeId: number): CamTarget { } const fovRad = (50 / 2) * (Math.PI / 180) const cameraHeight = Math.max(5, (maxRadius * 1.05) / Math.tan(fovRad)) + // Tiny in-plane offset so setLookAt's up-vector math (worldUp × forward) + // doesn't go degenerate when the camera ends up directly above the node. + // Direction follows the user's *current* camera azimuth so the final view + // preserves their orbit angle instead of snapping to a canonical +Z bias. + const offset = 0.1 + const ox = Math.sin(currentAzimuth) * offset + const oz = Math.cos(currentAzimuth) * offset return { - posX: p.x, + posX: p.x + ox, posY: p.y + cameraHeight, - posZ: p.z + 0.1, + posZ: p.z + oz, lookX: p.x, lookY: p.y, lookZ: p.z, @@ -418,6 +425,35 @@ function smoothstep(x: number) { return x * x * (3 - 2 * x) } +// Lives inside the R3F Canvas so it can read state.controls — which only +// becomes non-null after drei's has actually +// mounted and registered itself. A useEffect in the outer component would +// race the mount and miss the instance entirely. +function CameraInteractionTracker({ + onChange, +}: { + onChange: (active: boolean) => void +}) { + const controls = useThree((s) => s.controls) as + | (CameraControlsImpl & { + addEventListener: (t: string, fn: () => void) => void + removeEventListener: (t: string, fn: () => void) => void + }) + | null + useEffect(() => { + if (!controls) return + const onStart = () => onChange(true) + const onEnd = () => onChange(false) + controls.addEventListener("controlstart", onStart) + controls.addEventListener("controlend", onEnd) + return () => { + controls.removeEventListener("controlstart", onStart) + controls.removeEventListener("controlend", onEnd) + } + }, [controls, onChange]) + return null +} + // Drives the camera with GraphView's exact lerp formula so the camera // arrives in lockstep with the geometry inflation. Without this the camera // (CameraControls smoothDamp) and the nodes (smoothstep + delta/1.2 in @@ -616,6 +652,16 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas const [hoveredCardNode, setHoveredCardNode] = useState(null) const [cursor, setCursor] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) + // True while the user is actively dragging/rotating/dollying the camera. + // Suppresses hover firing on whatever nodes happen to sweep under the + // (otherwise stationary) cursor during the gesture. Wired via a tracker + // component inside the Canvas (see ) because + // an outer useEffect races drei's makeDefault registration. + const [cameraInteracting, setCameraInteracting] = useState(false) + const handleCameraInteractingChange = useCallback((v: boolean) => { + setCameraInteracting(v) + }, []) + // Debug overlay: world-fixed markers + per-frame camera/anchor crosshairs. // Gated entirely behind NEXT_PUBLIC_DEBUG_MARKERS so production builds carry // no debug UI. When the env is set, markers default on and a toggle button @@ -712,7 +758,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas if (rank === 0) { topIdx = idx; break } } if (topIdx < 0 || !graph.nodes[topIdx]) return - setCamTarget(computeCamTarget(graph, topIdx)) + setCamTarget(computeCamTarget(graph, topIdx, cameraRef.current?.azimuthAngle ?? 0)) lastPannedSearchTerm.current = searchTerm }, [searchTerm, topMatchRanks, graph, setCamTarget]) @@ -815,8 +861,10 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas // Camera dollies to look at selected. Anchor's world position is // fixed by rescaleAroundAnchor, so the camera target is constant // through the lerp — no drift like when both the camera and the - // anchor were moving in opposite directions. - setCamTarget(computeCamTarget(graph, nodeId)) + // anchor were moving in opposite directions. Capture the current + // orbit azimuth so the final view preserves it rather than snapping + // to a canonical orientation when the camera lands above the node. + setCamTarget(computeCamTarget(graph, nodeId, cameraRef.current?.azimuthAngle ?? 0)) // Consume any pending search-pan: if results haven't landed yet, a // later payload would otherwise yank the camera off the node the @@ -868,6 +916,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas searchTerm={searchTerm} nodeTypeIcons={nodeTypeIcons} onResetView={handleReset} + suppressHover={cameraInteracting} onGraphClick={() => { useGraphStore.getState().setSidebarSelectedNode(null) useGraphStore.getState().setHoveredNode(null) @@ -899,6 +948,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas truckSpeed={1} dollyToCursor /> + void; + /** When true (e.g. while the user is dragging/rotating the camera), pointer + * hover is ignored so nodes sweeping under a stationary cursor don't fire + * hover effects. Any existing hover is cleared on the rising edge. */ + suppressHover?: boolean; } const tmpObj = new THREE.Object3D(); @@ -437,7 +442,7 @@ function renderHighlightedLabel(label: string, term: string): React.ReactNode { } -export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minimap, whiteboardNodeId, onExitWhiteboard, onDetailNavigate, searchMatches, topMatchRanks, searchTerm, pulses, recentNodes, expandedClusterId, externalHoveredId, externalSelectedId, onGraphClick, nodeTypeIcons, onResetView }: GraphViewProps) { +export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minimap, whiteboardNodeId, onExitWhiteboard, onDetailNavigate, searchMatches, topMatchRanks, searchTerm, pulses, recentNodes, expandedClusterId, externalHoveredId, externalSelectedId, onGraphClick, nodeTypeIcons, onResetView, suppressHover }: GraphViewProps) { const meshRef = useRef(null); const linesRef = useRef(null); const highlightLinesRef = useRef(null); @@ -513,6 +518,18 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima const [labelPos, setLabelPos] = useState(() => new Float32Array(nodeCount * 3)); const labelAccum = useRef(0); + // Smart label placement: each visible label registers its DOM node via ref + // callback below. useLabelPlacement runs a ~15 Hz greedy de-overlap pass — + // it projects every entry to screen, sorts by priority, and writes the + // chosen offset back as CSS variables (--lbl-ex / --lbl-ey). Hysteresis is + // built in so the chosen slot is sticky across ticks. + const labelRegistryRef = useRef>(new Map()); + useLabelPlacement({ + positionsRef: currentPos, + registryRef: labelRegistryRef, + enabled: !minimap, + }); + // Resize buffers when nodeCount grows (streaming support) const prevNodeCount = useRef(nodeCount); const buffersGrewRef = useRef(false); @@ -1628,6 +1645,9 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima const handlePointerOver = (e: ThreeEvent) => { + // Camera drag/rotate sweeps nodes under a stationary cursor — those + // shouldn't count as deliberate hovers. + if (suppressHover) return; if (e.instanceId === undefined) return; if (visibleNodes && !visibleNodes.has(e.instanceId)) return; e.stopPropagation(); @@ -1640,6 +1660,16 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima document.body.style.cursor = "auto"; }; + // Clear any active hover the moment camera interaction starts, so the + // highlight on the previously-hovered node doesn't linger through the + // gesture. + useEffect(() => { + if (suppressHover && hovered !== null) { + setHovered(null); + document.body.style.cursor = "auto"; + } + }, [suppressHover, hovered]); + const edgeOpacity = viewState.mode === "overview" ? 0.08 : 0.3; return ( @@ -1847,6 +1877,20 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima : 12; const labelWeight = isHovered || isSelected || isExpandedProxy || isTopHit ? 700 : 500; + // Placement priority: hovered > selected > top-hit > expanded-proxy > + // search-match > recent > hover-neighbor > high-weight > base. Used + // by the label planner to decide which labels keep their default slot + // and which get pushed/displaced when boxes collide. + const placementPriority = isHovered ? 100 + : isSelected ? 90 + : isTopHit ? (topRank === 0 ? 85 : 80) + : isExpandedProxy ? 75 + : isSearchMatch ? 70 + : isRecentNode ? 65 + : isHoverNeighbor ? 60 + : isHighWeight ? 50 + : 10; + const iconColor = node.icon ? isHovered ? "rgb(255, 51, 51)" @@ -1886,13 +1930,27 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima pointerEvents: isExpandedProxy ? "auto" : "none", userSelect: "none", cursor: isExpandedProxy ? "pointer" : undefined, - transform: "translate(-50%, 20px)", - display: "flex", - flexDirection: "column", - alignItems: "center", - gap: 3, }} > +
{ + const reg = labelRegistryRef.current; + if (el) reg.set(i, el); + else reg.delete(i); + }} + data-priority={placementPriority} + style={{ + // Baseline = label centered below the anchor (-50% x, +20 y). + // --lbl-ex / --lbl-ey are written each tick by the placement + // planner to push the label into a non-colliding slot. + transform: "translate(calc(-50% + var(--lbl-ex, 0px)), calc(20px + var(--lbl-ey, 0px)))", + transition: "transform 180ms ease-out", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: 3, + }} + >
); })()} +
); diff --git a/src/graph-viz-kit/OffscreenIndicators.tsx b/src/graph-viz-kit/OffscreenIndicators.tsx index 31d20f4..fd2c871 100644 --- a/src/graph-viz-kit/OffscreenIndicators.tsx +++ b/src/graph-viz-kit/OffscreenIndicators.tsx @@ -15,7 +15,33 @@ interface Props { hovered?: number | null; } -type IndicatorDiv = HTMLDivElement & { __nodeId?: number }; +type IndicatorDiv = HTMLDivElement & { + __nodeId?: number; + __targetLeft?: number; + __targetTop?: number; + __currentLeft?: number; + __currentTop?: number; + // True between the frame the indicator becomes inactive and the frame it + // becomes active again — so we can snap-position on first appearance + // instead of sliding from wherever it sat last time. + __wasHidden?: boolean; +}; + +// How fast the indicator chases its target each frame. Higher = snappier, +// lower = calmer. Tuned to feel weighted but not laggy. +const POSITION_LERP_RATE = 5; + +// Dead zone (px) for target updates. If the freshly-projected clamp position +// is within this distance of the existing target, we keep the old target — +// so tiny camera nudges don't continuously re-aim the indicator. The lerp +// only fires when there's a real change to chase. +const MIN_TARGET_CHANGE = 3; + +// Throttle the projection + de-overlap pass. Camera moves at 60Hz but we +// only need to recompute indicator targets a few times per second — the lerp +// below keeps the visual motion smooth in between. Lower = calmer, fewer +// reshuffles. 0.12s ≈ ~8 ticks/sec. +const TARGET_UPDATE_INTERVAL = 0.12; export function OffscreenIndicators({ graph, viewState, onNodeClick, hovered = null }: Props) { const containerRef = useRef(null); @@ -163,15 +189,69 @@ export function OffscreenIndicators({ graph, viewState, onNodeClick, hovered = n }; }, []); - useFrame(() => { - const indicators = indicatorsRef.current; + const targetTickAccum = useRef(0); + const activeCountRef = useRef(0); + + useFrame((_, delta) => { + const indicators = indicatorsRef.current as IndicatorDiv[]; if (!indicators.length) return; - for (let i = 0; i < MAX_INDICATORS; i++) { - indicators[i].style.display = "none"; + // Mode flips off → hide everything immediately, regardless of throttle. + if (viewState.mode !== "subgraph") { + if (activeCountRef.current > 0) { + for (let i = 0; i < MAX_INDICATORS; i++) { + if (indicators[i].style.display !== "none") { + indicators[i].style.display = "none"; + indicators[i].__wasHidden = true; + } + } + activeCountRef.current = 0; + } + return; + } + + targetTickAccum.current += delta; + const updateTargets = targetTickAccum.current >= TARGET_UPDATE_INTERVAL; + + if (!updateTargets) { + // Lerp-only frame — chase last-computed targets without reprojecting. + const count = activeCountRef.current; + if (count > 0) { + const k = 1 - Math.exp(-POSITION_LERP_RATE * delta); + for (let i = 0; i < count; i++) { + const el = indicators[i]; + const tL = el.__targetLeft ?? 0; + const tT = el.__targetTop ?? 0; + const wasHidden = el.__wasHidden ?? true; + let cL: number; + let cT: number; + if (wasHidden || el.__currentLeft === undefined || el.__currentTop === undefined) { + cL = tL; + cT = tT; + } else { + cL = el.__currentLeft + (tL - el.__currentLeft) * k; + cT = el.__currentTop + (tT - el.__currentTop) * k; + } + el.__currentLeft = cL; + el.__currentTop = cT; + el.__wasHidden = false; + el.style.left = `${cL}px`; + el.style.top = `${cT}px`; + } + } + return; } - if (viewState.mode !== "subgraph") return; + targetTickAccum.current = 0; + + // Throttled tick: hide everything first; the loop below will re-mark + // active indicators as visible. + for (let i = 0; i < MAX_INDICATORS; i++) { + if (indicators[i].style.display !== "none") { + indicators[i].style.display = "none"; + indicators[i].__wasHidden = true; + } + } const w = size.width; const h = size.height; @@ -224,10 +304,35 @@ export function OffscreenIndicators({ graph, viewState, onNodeClick, hovered = n clampY = Math.max(MARGIN, Math.min(h - MARGIN, clampY)); const el = indicators[count]; - (el as IndicatorDiv).__nodeId = nodeId; + // Indicator pool slots get reassigned to different nodes as the view + // changes. When that happens, snap so the lerp doesn't slide from the + // old node's position into the new one's. + if (el.__nodeId !== nodeId) { + el.__wasHidden = true; + el.__nodeId = nodeId; + } el.style.display = "block"; - el.style.left = `${clampX}px`; - el.style.top = `${clampY}px`; + // Dead-zone gate: only commit a new target if the newly-projected clamp + // is meaningfully different from the existing target (or the indicator + // just became active). This keeps the indicator visually still under + // slow / tiny camera motion. + const prevTx = el.__targetLeft; + const prevTy = el.__targetTop; + const targetStale = el.__wasHidden + || prevTx === undefined + || prevTy === undefined + || Math.abs(clampX - prevTx) >= MIN_TARGET_CHANGE + || Math.abs(clampY - prevTy) >= MIN_TARGET_CHANGE; + const targetX = targetStale ? clampX : prevTx!; + const targetY = targetStale ? clampY : prevTy!; + // Write target position to style so the bbox measurement in the + // de-overlap pass below sees the target (not a previous lerped frame). + // Browser layout flushes synchronously, but paint happens after the + // final lerp write below — so the user never sees this intermediate. + el.style.left = `${targetX}px`; + el.style.top = `${targetY}px`; + el.__targetLeft = targetX; + el.__targetTop = targetY; // Rotate trail to point outward (toward the off-screen node) const trailEl = el.children[1] as HTMLElement; @@ -307,6 +412,124 @@ export function OffscreenIndicators({ graph, viewState, onNodeClick, hovered = n count++; } + + // De-overlap pass: indicators clamped to the same edge can stack on top of + // each other (e.g. two left-edge indicators with their relation labels + // colliding). Group active indicators by which edge they hug, sort along + // the edge tangent, and greedily push later boxes outward until they no + // longer overlap their predecessor. + if (count > 1) { + interface Active { + el: IndicatorDiv; + cx: number; + cy: number; + edge: "top" | "bottom" | "left" | "right"; + t0: number; + t1: number; + } + const groups: Record = { + top: [], bottom: [], left: [], right: [], + }; + for (let i = 0; i < count; i++) { + const el = indicators[i]; + const cx = el.__targetLeft ?? 0; + const cy = el.__targetTop ?? 0; + const onLeft = cx <= MARGIN + 0.5; + const onRight = cx >= w - MARGIN - 0.5; + const onTop = cy <= MARGIN + 0.5; + const onBottom = cy >= h - MARGIN - 0.5; + let edge: Active["edge"]; + if (onLeft && !onTop && !onBottom) edge = "left"; + else if (onRight && !onTop && !onBottom) edge = "right"; + else if (onTop && !onLeft && !onRight) edge = "top"; + else if (onBottom && !onLeft && !onRight) edge = "bottom"; + // Corners: stick with the vertical edge so labels (which extend + // horizontally) don't get pushed across the canvas. + else if (onLeft) edge = "left"; + else if (onRight) edge = "right"; + else if (onTop) edge = "top"; + else edge = "bottom"; + // The indicator div itself is 0×0 (children are all position:absolute, + // so they don't contribute to its content box). Measure the label + // child directly and union with the pip's 8px extent around the + // anchor — this is the indicator's actual visual footprint. + const anchorRect = el.getBoundingClientRect(); + const labelEl = el.children[2] as HTMLElement; + const labelRect = labelEl.getBoundingClientRect(); + const ax = anchorRect.left; + const ay = anchorRect.top; + const bboxLeft = Math.min(ax - 4, labelRect.left); + const bboxRight = Math.max(ax + 4, labelRect.right); + const bboxTop = Math.min(ay - 4, labelRect.top); + const bboxBottom = Math.max(ay + 4, labelRect.bottom); + const horizontal = edge === "top" || edge === "bottom"; + const t0 = horizontal ? bboxLeft : bboxTop; + const t1 = horizontal ? bboxRight : bboxBottom; + groups[edge].push({ el, cx, cy, edge, t0, t1 }); + } + + const GAP = 4; + for (const edge of ["top", "bottom", "left", "right"] as const) { + const arr = groups[edge]; + if (arr.length < 2) continue; + arr.sort((a, b) => a.t0 - b.t0); + const horizontal = edge === "top" || edge === "bottom"; + const maxC = horizontal ? w - MARGIN : h - MARGIN; + for (let i = 1; i < arr.length; i++) { + const prev = arr[i - 1]; + const curr = arr[i]; + const need = (prev.t1 + GAP) - curr.t0; + if (need <= 0) continue; + if (horizontal) { + const newCx = Math.min(curr.cx + need, maxC); + const delta = newCx - curr.cx; + if (delta <= 0) continue; + curr.el.style.left = `${newCx}px`; + curr.el.__targetLeft = newCx; + curr.cx = newCx; + curr.t0 += delta; + curr.t1 += delta; + } else { + const newCy = Math.min(curr.cy + need, maxC); + const delta = newCy - curr.cy; + if (delta <= 0) continue; + curr.el.style.top = `${newCy}px`; + curr.el.__targetTop = newCy; + curr.cy = newCy; + curr.t0 += delta; + curr.t1 += delta; + } + } + } + } + + activeCountRef.current = count; + + // Smooth chase: ease each indicator's *displayed* position toward its + // target. Lerp is computed from delta-time so the speed is frame-rate + // independent. On first appearance (or after being hidden), the indicator + // snaps to its target so it doesn't slide in from wherever it sat last. + const k = 1 - Math.exp(-POSITION_LERP_RATE * delta); + for (let i = 0; i < count; i++) { + const el = indicators[i]; + const tL = el.__targetLeft ?? 0; + const tT = el.__targetTop ?? 0; + const wasHidden = el.__wasHidden ?? true; + let cL: number; + let cT: number; + if (wasHidden || el.__currentLeft === undefined || el.__currentTop === undefined) { + cL = tL; + cT = tT; + } else { + cL = el.__currentLeft + (tL - el.__currentLeft) * k; + cT = el.__currentTop + (tT - el.__currentTop) * k; + } + el.__currentLeft = cL; + el.__currentTop = cT; + el.__wasHidden = false; + el.style.left = `${cL}px`; + el.style.top = `${cT}px`; + } }); return null; diff --git a/src/graph-viz-kit/useLabelPlacement.ts b/src/graph-viz-kit/useLabelPlacement.ts new file mode 100644 index 0000000..b8bf52c --- /dev/null +++ b/src/graph-viz-kit/useLabelPlacement.ts @@ -0,0 +1,151 @@ +import { useRef, type MutableRefObject } from "react"; +import { useFrame, useThree } from "@react-three/fiber"; +import * as THREE from "three"; + +// Center of the label box relative to the node anchor, in screen pixels. +// Default ("below"): the label baseline transform is translate(-50%, 20px), +// so without any extra offset the label center sits at (0, 20 + h/2). +export type LabelOffset = readonly [dx: number, dy: number]; + +const _v = new THREE.Vector3(); + +function candidatesFor(w: number, h: number): LabelOffset[] { + // Keep every candidate close to the anchor — at most ~half the label's + // own height/width offset from the dot. Far-displacement candidates + // (e.g. 2× height below) make labels visually disown their nodes, which + // is worse than mild residual overlap. + const v = 20 + h / 2; // baseline "below" — label sits below the dot + const hx = 16 + w / 2; // side — label sits to the side of the dot + return [ + [0, v], + [0, -v], + [hx, h / 2], + [-hx, h / 2], + [hx * 0.9, -h / 2], + [-hx * 0.9, -h / 2], + ]; +} + +function overlap( + ax: number, ay: number, aw: number, ah: number, + bx: number, by: number, bw: number, bh: number, + pad: number, +): boolean { + return !(ax + aw + pad <= bx || bx + bw + pad <= ax || ay + ah + pad <= by || by + bh + pad <= ay); +} + +interface Entry { + id: number; + el: HTMLDivElement; + sx: number; + sy: number; + w: number; + h: number; + priority: number; +} + +interface Placed { + x: number; + y: number; + w: number; + h: number; +} + +export function useLabelPlacement(opts: { + positionsRef: MutableRefObject; + registryRef: MutableRefObject>; + enabled: boolean; +}) { + const { positionsRef, registryRef, enabled } = opts; + const { camera, size } = useThree(); + const tickAccum = useRef(0); + // Last accepted offset per node (hysteresis). + const lastOffset = useRef(new Map()); + + useFrame((_, delta) => { + if (!enabled) return; + tickAccum.current += delta; + if (tickAccum.current < 0.066) return; + tickAccum.current = 0; + + const W = size.width; + const H = size.height; + const registry = registryRef.current; + const positions = positionsRef.current; + + // Pass 1: project + measure (read-only DOM phase to avoid layout thrash). + const entries: Entry[] = []; + for (const [id, el] of registry) { + if (!el) continue; + const i3 = id * 3; + if (i3 + 2 >= positions.length) continue; + _v.set(positions[i3], positions[i3 + 1], positions[i3 + 2]); + _v.project(camera); + if (_v.z > 1) continue; + const sx = ((_v.x + 1) / 2) * W; + const sy = ((-_v.y + 1) / 2) * H; + if (sx < -200 || sx > W + 200 || sy < -200 || sy > H + 200) continue; + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) continue; + const priority = parseFloat(el.dataset.priority ?? "0"); + entries.push({ id, el, sx, sy, w: rect.width, h: rect.height, priority }); + } + + // Sort by priority desc; ties broken by y so labels higher on screen win. + entries.sort((a, b) => (b.priority - a.priority) || (a.sy - b.sy)); + + const placed: Placed[] = []; + const pad = 2; + + for (const e of entries) { + const cands = candidatesFor(e.w, e.h); + + // Hysteresis: try previous offset first if it still fits. + const prev = lastOffset.current.get(e.id); + let chosen: LabelOffset | null = null; + const tryOffset = (dx: number, dy: number): boolean => { + const x = e.sx + dx - e.w / 2; + const y = e.sy + dy - e.h / 2; + for (const p of placed) { + if (overlap(x, y, e.w, e.h, p.x, p.y, p.w, p.h, pad)) return false; + } + return true; + }; + + if (prev && tryOffset(prev[0], prev[1])) { + chosen = prev; + } else { + for (const c of cands) { + if (tryOffset(c[0], c[1])) { chosen = c; break; } + } + } + + if (!chosen) chosen = cands[0]; // fallback to default + + const [dx, dy] = chosen; + lastOffset.current.set(e.id, chosen); + + placed.push({ x: e.sx + dx - e.w / 2, y: e.sy + dy - e.h / 2, w: e.w, h: e.h }); + + // Convert (dx, dy) — desired label center relative to anchor — + // into a delta from the baseline CSS transform translate(-50%, 20px). + // Baseline puts the label center at (0, 20 + h/2). Extra CSS shifts + // we apply are (--lbl-ex, --lbl-ey) on top of that. So: + // ex = dx + // ey = dy - (20 + h/2) + const ex = dx; + const ey = dy - (20 + e.h / 2); + + // Write-phase: imperative, no React re-render. + e.el.style.setProperty("--lbl-ex", `${ex.toFixed(1)}px`); + e.el.style.setProperty("--lbl-ey", `${ey.toFixed(1)}px`); + } + + // GC: drop hysteresis entries for nodes no longer registered. + if (lastOffset.current.size > registry.size * 2) { + for (const id of lastOffset.current.keys()) { + if (!registry.has(id) || !registry.get(id)) lastOffset.current.delete(id); + } + } + }); +}