Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions src/components/universe/graph-canvas.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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 <CameraControls makeDefault /> 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
Expand Down Expand Up @@ -616,6 +652,16 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas
const [hoveredCardNode, setHoveredCardNode] = useState<ApiNode | null>(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 <CameraInteractionTracker/>) 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
Expand Down Expand Up @@ -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])

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -899,6 +948,7 @@ export function GraphCanvas({ nodes, edges, schemas, onNodeSelect }: GraphCanvas
truckSpeed={1}
dollyToCursor
/>
<CameraInteractionTracker onChange={handleCameraInteractingChange} />
<CameraSync camRef={cameraRef} targetRef={camAnim} />
<EffectComposer>
<Bloom
Expand Down
71 changes: 65 additions & 6 deletions src/graph-viz-kit/GraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { Graph, GraphEdge, ViewState } from "./types";
import { edgeKey, isStructuralEdge } from "./types";
import { NodeDetailPanel } from "./NodeDetailPanel";
import { PulseLayer } from "./PulseLayer";
import { useLabelPlacement } from "./useLabelPlacement";
import { getSchemaIcon } from "@/lib/schema-icons";

export interface Pulse {
Expand Down Expand Up @@ -60,6 +61,10 @@ interface GraphViewProps {
/** Called when the user clicks the ✕ close button anchored to the selected
* node — typically wired to the same handler as the "Reset view" pill. */
onResetView?: () => 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();
Expand Down Expand Up @@ -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<THREE.InstancedMesh>(null);
const linesRef = useRef<THREE.LineSegments>(null);
const highlightLinesRef = useRef<THREE.LineSegments>(null);
Expand Down Expand Up @@ -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<Map<number, HTMLDivElement | null>>(new Map());
useLabelPlacement({
positionsRef: currentPos,
registryRef: labelRegistryRef,
enabled: !minimap,
});

// Resize buffers when nodeCount grows (streaming support)
const prevNodeCount = useRef(nodeCount);
const buffersGrewRef = useRef(false);
Expand Down Expand Up @@ -1628,6 +1645,9 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima


const handlePointerOver = (e: ThreeEvent<PointerEvent>) => {
// 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();
Expand All @@ -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 (
Expand Down Expand Up @@ -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)"
Expand Down Expand Up @@ -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,
}}
>
<div
ref={(el) => {
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,
}}
>
<div style={{
color: isExpandedProxy ? "rgba(100,220,255,0.95)" : labelColor,
fontSize: isExpandedProxy ? 14 : labelSize,
Expand Down Expand Up @@ -1985,6 +2043,7 @@ export function GraphView({ graph, viewState, onNodeClick, onHoverChange, minima
</div>
);
})()}
</div>
</Html>
</group>
);
Expand Down
Loading
Loading