fix(supervisor): anchor 3D graph zoom to the pointer

This commit is contained in:
mesalogo
2026-10-11 20:18:19 +08:00
parent 8b10ed583e
commit ae46cccc20
+18 -4
View File
@@ -104,7 +104,7 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
if (target.node.id !== focusId) setFocusId(target.node.id)
}
const [hovered, setHovered] = useState<string>()
const camera = useRef<{ yaw: number; tilt: number; zoom: number; pan: number }>({ yaw: VIEWS.oblique.yaw, tilt: VIEWS.oblique.tilt, zoom: 1, pan: 0 })
const camera = useRef<{ yaw: number; tilt: number; zoom: number; pan: number; targetX: number; targetZ: number }>({ yaw: VIEWS.oblique.yaw, tilt: VIEWS.oblique.tilt, zoom: 1, pan: 0, targetX: 0, targetZ: 0 })
const animation = useRef<number | undefined>(undefined)
const cancelAnimation = () => { if (animation.current !== undefined) cancelAnimationFrame(animation.current); animation.current = undefined }
const engine = useRef<{ draw: () => void; update: (next: EngineState) => void; dispose: () => void; hit: (x: number, y: number) => { node?: StoryNode; cluster?: StoryCluster; link?: ExperienceLink } | undefined }>(undefined)
@@ -265,7 +265,7 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
}
const project = (point: THREE.Vector3) => { const v = point.clone().project(view3d); return { x: (v.x + 1) / 2 * width, y: (1 - v.y) / 2 * height, behind: v.z > 1 } }
const place = () => {
const { yaw, tilt, zoom, pan } = camera.current, ct = Math.cos(tilt), st = Math.sin(tilt), target = new THREE.Vector3(0, pan, 0)
const { yaw, tilt, zoom, pan, targetX, targetZ } = camera.current, ct = Math.cos(tilt), st = Math.sin(tilt), target = new THREE.Vector3(targetX, pan, targetZ)
view3d.position.copy(target).add(new THREE.Vector3(-Math.sin(yaw) * ct, st, Math.cos(yaw) * ct).multiplyScalar(DIST))
view3d.up.set(Math.sin(yaw) * st, ct, -Math.cos(yaw) * st)
view3d.lookAt(target)
@@ -325,7 +325,21 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
const zoom = (event: WheelEvent) => {
event.preventDefault()
cancelAnimation()
// Move the camera target so the point below the pointer stays fixed while zooming.
const target = new THREE.Vector3(camera.current.targetX, camera.current.pan, camera.current.targetZ)
const direction = new THREE.Vector3().subVectors(target, view3d.position).normalize()
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(direction, target)
const pointer = new THREE.Vector2(event.offsetX / width * 2 - 1, 1 - event.offsetY / height * 2)
raycaster.setFromCamera(pointer, view3d)
const point = raycaster.ray.intersectPlane(plane, new THREE.Vector3()) ?? target.clone()
camera.current.zoom = Math.max(0.5, Math.min(2.5, camera.current.zoom * Math.exp(-event.deltaY * 0.0012)))
place()
raycaster.setFromCamera(pointer, view3d)
const nextPoint = raycaster.ray.intersectPlane(plane, new THREE.Vector3()) ?? target.clone()
const shift = point.sub(nextPoint)
camera.current.targetX += shift.x
camera.current.pan += shift.y
camera.current.targetZ += shift.z
draw()
}
canvas.addEventListener('wheel', zoom, { passive: false })
@@ -359,7 +373,7 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
const step = (now: number) => {
started ??= now
const k = reduce ? 1 : Math.min(1, (now - started) / 480), e = k < 0.5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2
for (const key of ['yaw', 'tilt', 'zoom', 'pan'] as const) if (goal[key] !== undefined) camera.current[key] = from[key] + (goal[key]! - from[key]) * e
for (const key of ['yaw', 'tilt', 'zoom', 'pan', 'targetX', 'targetZ'] as const) if (goal[key] !== undefined) camera.current[key] = from[key] + (goal[key]! - from[key]) * e
engine.current?.draw()
animation.current = k < 1 ? requestAnimationFrame(step) : undefined
}
@@ -379,7 +393,7 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
animate({ yaw: angle - Math.PI / 2, tilt, pan: (u - 0.5) * HEIGHT - radius * Math.tan(tilt), zoom: Math.max(0.8, Math.min(1.4, camera.current.zoom)) })
})
useEffect(() => { focusCamera() }, [request, target, range, attempt, theme])
const choose = (name: keyof typeof VIEWS) => animate({ ...VIEWS[name], zoom: 1, pan: 0 })
const choose = (name: keyof typeof VIEWS) => animate({ ...VIEWS[name], zoom: 1, pan: 0, targetX: 0, targetZ: 0 })
const open = (node: StoryNode) => {
setRequest({ selection: { kind: 'story', id: node.id } })
if (node.level !== 'root' && node.level !== 'project' && node.level !== 'unassigned') onSelectStory(node.id)