fix(relationships): polish galaxy node interactions

This commit is contained in:
digua committed 2026-07-01 01:06:36 +08:00
1 parent 0976818647
commit 33eab9b98c
5 files changed
+205 -36

No files matched your search

@@ -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<string, unknown> {
return typeof value === 'object' && value !== null
}
onMounted(async () => {
await nextTick()
await initCanvas()
@@ -753,6 +806,8 @@ onBeforeUnmount(() => {
defineExpose({
focusNode,
fitView,
captureView,
restoreView,
})
</script>
@@ -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<string, unknown> {
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,
})
</script>
@@ -856,7 +920,6 @@ defineExpose({
{{ item.text }}
</button>
</div>
<div class="relationship-galaxy-3d__crosshair pointer-events-none absolute left-1/2 top-1/2 z-10"></div>
</div>
</template>
@@ -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;
}
</style>
+35 -4
View File
@@ -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<GalaxyCanvasInstance | null>(null)
const savedPanoramaView = ref<unknown | null>(null)
let graphContentKey: string | null = null
let neighborhoodGraphContentKey: string | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
let searchTimer: ReturnType<typeof setTimeout> | 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
@@ -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('</script>'))
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'))
})
})
@@ -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)'),