mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(relationships): polish galaxy node interactions
This commit is contained in:
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>
|
||||
@@ -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)'),
|
||||
|
||||
Reference in new issue
Block a user