From 33eab9b98cb1d57447ef34c592bd9a1a8e9b4d43 Mon Sep 17 00:00:00 2001 From: digua Date: Wed, 1 Jul 2026 00:32:56 +0800 Subject: [PATCH] fix(relationships): polish galaxy node interactions --- .../components/RelationshipGalaxyCanvas.vue | 55 +++++++++++ .../RelationshipGalaxyThreeCanvas.vue | 96 +++++++++++++------ src/pages/people/relationships/index.vue | 39 +++++++- ...lationship-galaxy-3d-canvas-source.test.ts | 26 +++++ .../relationship-galaxy-page-source.test.ts | 25 ++++- 5 files changed, 205 insertions(+), 36 deletions(-) diff --git a/src/pages/people/relationships/components/RelationshipGalaxyCanvas.vue b/src/pages/people/relationships/components/RelationshipGalaxyCanvas.vue index ba5a9c5c..7ba964b2 100644 --- a/src/pages/people/relationships/components/RelationshipGalaxyCanvas.vue +++ b/src/pages/people/relationships/components/RelationshipGalaxyCanvas.vue @@ -48,6 +48,13 @@ interface AnimatedNode { currentScale: number } +interface RelationshipGalaxy2DViewState { + kind: '2d' + center: { x: number; y: number } + scale: number + hasUserMovedViewport: boolean +} + const props = withDefaults( defineProps<{ graph: PeopleRelationshipsGraphData @@ -708,12 +715,58 @@ function focusNode(key: string): boolean { return true } +function captureView(): RelationshipGalaxy2DViewState | null { + if (!viewport) return null + return { + kind: '2d', + center: { x: viewport.center.x, y: viewport.center.y }, + scale: viewport.scaled, + hasUserMovedViewport, + } +} + +function restoreView(view: unknown): boolean { + if (!is2DViewState(view) || !viewport) return false + + pendingFocusKey = null + hasUserMovedViewport = view.hasUserMovedViewport + viewport.animate({ + position: view.center, + scale: view.scale, + time: 420, + ease: 'easeInOutSine', + }) + return true +} + function fitView() { if (!viewport) return hasUserMovedViewport = false renderGraph(true) } +function is2DViewState(value: unknown): value is RelationshipGalaxy2DViewState { + if (!isRecord(value)) return false + return ( + value.kind === '2d' && + is2DPoint(value.center) && + typeof value.scale === 'number' && + Number.isFinite(value.scale) && + typeof value.hasUserMovedViewport === 'boolean' + ) +} + +function is2DPoint(value: unknown): value is RelationshipGalaxy2DViewState['center'] { + if (!isRecord(value)) return false + return ( + typeof value.x === 'number' && Number.isFinite(value.x) && typeof value.y === 'number' && Number.isFinite(value.y) + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + onMounted(async () => { await nextTick() await initCanvas() @@ -753,6 +806,8 @@ onBeforeUnmount(() => { defineExpose({ focusNode, fitView, + captureView, + restoreView, }) diff --git a/src/pages/people/relationships/components/RelationshipGalaxyThreeCanvas.vue b/src/pages/people/relationships/components/RelationshipGalaxyThreeCanvas.vue index 02d93c9a..b9cf4cff 100644 --- a/src/pages/people/relationships/components/RelationshipGalaxyThreeCanvas.vue +++ b/src/pages/people/relationships/components/RelationshipGalaxyThreeCanvas.vue @@ -50,6 +50,13 @@ interface CameraFlight { toTarget: THREE.Vector3 } +interface RelationshipGalaxy3DCameraViewState { + kind: '3d' + position: RelationshipGalaxy3DCameraPose['position'] + target: RelationshipGalaxy3DCameraPose['position'] + hasUserMovedCamera: boolean +} + const props = withDefaults( defineProps<{ graph: PeopleRelationshipsGraphData @@ -372,7 +379,7 @@ function updateLabels() { for (const object of nodeObjects.values()) { const selected = object.sceneNode.key === selectedKey const selectedNeighbor = Boolean(selectedKey && selectedNeighborKeys?.has(object.sceneNode.key)) - const labelTier = getDynamicLabelTier(object.sceneNode, selectedKey ?? null) + const labelTier = getDynamicLabelTier(object.sceneNode, selectedKey ?? null, hoveredKey.value) if (labelTier === 0) continue object.group.getWorldPosition(tmpWorldPosition) @@ -397,7 +404,12 @@ function updateLabels() { labels.value = nextLabels } -function getDynamicLabelTier(sceneNode: RelationshipGalaxy3DNode, selectedKey: string | null): 0 | 1 | 2 { +function getDynamicLabelTier( + sceneNode: RelationshipGalaxy3DNode, + selectedKey: string | null, + hoveredKey: string | null +): 0 | 1 | 2 { + if (sceneNode.key === hoveredKey) return sceneNode.labelTier === 2 ? 2 : 1 if (!selectedKey) return sceneNode.labelTier if (!selectedVisibleLabelKeys.value?.has(sceneNode.key)) return 0 return sceneNode.key === selectedKey || sceneNode.node.kind === 'owner' ? 2 : 1 @@ -575,6 +587,30 @@ function focusNode(key: string): boolean { return true } +function captureView(): RelationshipGalaxy3DCameraViewState | null { + if (!camera || !controls) return null + return { + kind: '3d', + position: vectorToPose(camera.position), + target: vectorToPose(controls.target), + hasUserMovedCamera, + } +} + +function restoreView(view: unknown): boolean { + if (!isCameraViewState(view) || !camera || !controls) return false + + pendingFocusKey = null + hasUserMovedCamera = view.hasUserMovedCamera + applyCameraSafeAreaProjection() + startCameraFlight( + new THREE.Vector3(view.position.x, view.position.y, view.position.z), + new THREE.Vector3(view.target.x, view.target.y, view.target.z), + 620 + ) + return true +} + function fitView() { if (!camera || !controls) return @@ -624,6 +660,32 @@ function vectorToPose(vector: THREE.Vector3): RelationshipGalaxy3DCameraPose['po return { x: vector.x, y: vector.y, z: vector.z } } +function isCameraViewState(value: unknown): value is RelationshipGalaxy3DCameraViewState { + if (!isRecord(value)) return false + return ( + value.kind === '3d' && + isCameraVector(value.position) && + isCameraVector(value.target) && + typeof value.hasUserMovedCamera === 'boolean' + ) +} + +function isCameraVector(value: unknown): value is RelationshipGalaxy3DCameraPose['position'] { + if (!isRecord(value)) return false + return ( + typeof value.x === 'number' && + Number.isFinite(value.x) && + typeof value.y === 'number' && + Number.isFinite(value.y) && + typeof value.z === 'number' && + Number.isFinite(value.z) + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + function startCameraFlight(toPosition: THREE.Vector3, toTarget: THREE.Vector3, duration: number) { if (!camera || !controls) return cameraFlight = { @@ -825,6 +887,8 @@ onBeforeUnmount(() => { defineExpose({ focusNode, fitView, + captureView, + restoreView, }) @@ -856,7 +920,6 @@ defineExpose({ {{ item.text }} -
@@ -916,31 +979,4 @@ defineExpose({ font-size: 13px; font-weight: 900; } - -.relationship-galaxy-3d__crosshair { - height: 13px; - width: 13px; - transform: translate(-50%, -50%); -} - -.relationship-galaxy-3d__crosshair::before, -.relationship-galaxy-3d__crosshair::after { - position: absolute; - background: rgba(255, 214, 166, 0.42); - content: ''; -} - -.relationship-galaxy-3d__crosshair::before { - left: 6px; - top: 0; - height: 13px; - width: 1px; -} - -.relationship-galaxy-3d__crosshair::after { - left: 0; - top: 6px; - height: 1px; - width: 13px; -} diff --git a/src/pages/people/relationships/index.vue b/src/pages/people/relationships/index.vue index 424abb62..4526fc9d 100644 --- a/src/pages/people/relationships/index.vue +++ b/src/pages/people/relationships/index.vue @@ -30,6 +30,8 @@ import { maskRelationshipGalaxyPrivateText, relationshipGalaxyPrivateAvatarText type GalaxyCanvasInstance = { focusNode: (key: string) => boolean fitView: () => void + captureView: () => unknown | null + restoreView: (view: unknown) => boolean } type GalaxyViewMode = '3d' | '2d' type RelationshipGraphResponseWithGraph = Pick< @@ -76,12 +78,14 @@ const loadError = ref('') const graphRequestId = ref(0) const neighborhoodRequestId = ref(0) const canvasRef = ref(null) +const savedPanoramaView = ref(null) let graphContentKey: string | null = null let neighborhoodGraphContentKey: string | null = null let pollTimer: ReturnType | null = null let searchTimer: ReturnType | null = null let detailPanelMediaQuery: MediaQueryList | null = null +let isRestoringPanoramaView = false const numberFormatter = computed(() => new Intl.NumberFormat(locale.value)) @@ -235,6 +239,7 @@ function formatTaskProgress(nextTask: PeopleRelationshipsTaskState | null): stri function applyGraphResponse(next: PeopleRelationshipsGraphResponse) { const nextGraphContentKey = buildGraphContentKey(next, graphScope.value) + if (graphContentKey && graphContentKey !== nextGraphContentKey) clearSavedPanoramaView() const previous = graphResponse.value graphResponse.value = previous && graphContentKey === nextGraphContentKey @@ -326,6 +331,28 @@ function cancelNeighborhoodLoad() { loadingNeighborhoodKey.value = null } +function rememberPanoramaViewBeforeSelection() { + if (selectedKey.value || savedPanoramaView.value) return + savedPanoramaView.value = canvasRef.value?.captureView() ?? null +} + +function clearSavedPanoramaView() { + savedPanoramaView.value = null +} + +function restorePanoramaView() { + const view = savedPanoramaView.value + clearSavedPanoramaView() + + void nextTick(() => { + const restored = view ? canvasRef.value?.restoreView(view) === true : false + if (!restored) canvasRef.value?.fitView() + void nextTick(() => { + isRestoringPanoramaView = false + }) + }) +} + function syncPolling(nextTask: PeopleRelationshipsTaskState | undefined) { if (nextTask?.status === 'running') { if (!pollTimer) { @@ -484,6 +511,7 @@ async function focusSelectedConnections() { } async function selectSearchResult(result: PeopleRelationshipsSearchResult) { + rememberPanoramaViewBeforeSelection() isSearchResultsOpen.value = false if (!result.inCoreGraph) loadingNeighborhoodKey.value = result.key selectedKey.value = result.key @@ -505,6 +533,7 @@ async function selectSearchResult(result: PeopleRelationshipsSearchResult) { } async function selectNode(node: PeopleRelationshipGraphNode) { + rememberPanoramaViewBeforeSelection() const needsNeighborhood = neighborhoodResponse.value?.contact?.key !== node.key if (needsNeighborhood) loadingNeighborhoodKey.value = node.key selectedKey.value = node.key @@ -531,18 +560,17 @@ function handleThreeCanvasFallback() { } function backToPanorama() { + isRestoringPanoramaView = true cancelNeighborhoodLoad() clearNeighborhoodResponse() selectedKey.value = null canvasSelectedKey.value = null isDetailPanelOpen.value = false - void nextTick(() => { - canvasRef.value?.fitView() - }) + restorePanoramaView() } function closeDetailPanel() { - isDetailPanelOpen.value = false + backToPanorama() } function clearSearch() { @@ -560,6 +588,7 @@ function syncDetailPanelLayout() { } watch(timeRangePreset, () => { + clearSavedPanoramaView() selectedKey.value = null canvasSelectedKey.value = null isDetailPanelOpen.value = false @@ -569,6 +598,7 @@ watch(timeRangePreset, () => { }) watch(graphScope, () => { + clearSavedPanoramaView() selectedKey.value = null canvasSelectedKey.value = null isDetailPanelOpen.value = false @@ -597,6 +627,7 @@ watch(viewMode, async () => { watch(detailPanelSafeInsetRight, async () => { await nextTick() + if (isRestoringPanoramaView) return if (selectedKey.value) { canvasRef.value?.focusNode(selectedKey.value) return diff --git a/src/pages/people/relationships/relationship-galaxy-3d-canvas-source.test.ts b/src/pages/people/relationships/relationship-galaxy-3d-canvas-source.test.ts index 111a4367..61578282 100644 --- a/src/pages/people/relationships/relationship-galaxy-3d-canvas-source.test.ts +++ b/src/pages/people/relationships/relationship-galaxy-3d-canvas-source.test.ts @@ -75,4 +75,30 @@ describe('RelationshipGalaxyThreeCanvas scene wiring', () => { assert.ok(shortName.includes('maskRelationshipGalaxyPrivateText(name)')) assert.equal(shortName.includes('`#${node.rank}`'), false) }) + + it('shows a temporary 3D label when hovering nodes without persistent labels', () => { + const source = readCanvasSource() + const updateLabels = source.slice( + source.indexOf('function updateLabels'), + source.indexOf('function getDynamicLabelTier') + ) + const getDynamicLabelTier = source.slice( + source.indexOf('function getDynamicLabelTier'), + source.indexOf('function getLabelEmphasis') + ) + + assert.ok(updateLabels.includes('getDynamicLabelTier(object.sceneNode, selectedKey ?? null, hoveredKey.value)')) + assert.ok(getDynamicLabelTier.includes('hoveredKey: string | null')) + assert.ok(getDynamicLabelTier.includes('sceneNode.key === hoveredKey')) + }) + + it('exposes 3D camera capture and restore methods for returning to the previous panorama view', () => { + const source = readCanvasSource() + const exposed = source.slice(source.indexOf('defineExpose({'), source.indexOf('')) + + assert.ok(source.includes('function captureView()')) + assert.ok(source.includes('function restoreView(view: unknown): boolean')) + assert.ok(exposed.includes('captureView')) + assert.ok(exposed.includes('restoreView')) + }) }) diff --git a/src/pages/people/relationships/relationship-galaxy-page-source.test.ts b/src/pages/people/relationships/relationship-galaxy-page-source.test.ts index 5bc12fd5..59248e93 100644 --- a/src/pages/people/relationships/relationship-galaxy-page-source.test.ts +++ b/src/pages/people/relationships/relationship-galaxy-page-source.test.ts @@ -11,19 +11,40 @@ function readPageSource(): string { } describe('people relationships page source', () => { - it('clears canvas selection when returning to the panorama', () => { + it('restores the saved panorama view when returning from a selected node', () => { const source = readPageSource() const backToPanorama = source.slice( source.indexOf('function backToPanorama()'), source.indexOf('function closeDetailPanel()') ) + const closeDetailPanel = source.slice( + source.indexOf('function closeDetailPanel()'), + source.indexOf('function clearSearch()') + ) + const selectNode = source.slice( + source.indexOf('async function selectNode'), + source.indexOf('function handleThreeCanvasFallback') + ) assert.ok(backToPanorama.includes('selectedKey.value = null')) assert.ok(backToPanorama.includes('canvasSelectedKey.value = null')) assert.ok(backToPanorama.includes('isDetailPanelOpen.value = false')) assert.ok( + selectNode.includes('rememberPanoramaViewBeforeSelection()'), + 'selecting a node should capture the current panorama camera before focusing the node' + ) + assert.ok( + backToPanorama.includes('restorePanoramaView()'), + 'returning to panorama should restore the previous browsing view instead of forcing a new fit' + ) + assert.ok( + closeDetailPanel.includes('backToPanorama()'), + 'closing the detail panel should exit node focus and restore the panorama view' + ) + assert.equal( backToPanorama.includes('canvasRef.value?.fitView()'), - 'returning to panorama should fit the full graph instead of refocusing the selected node' + false, + 'returning to panorama should not always refit and lose the previous camera angle' ) assert.equal( backToPanorama.includes('canvasRef.value?.focusNode(selectedKey.value)'),