perf(relationships): reduce galaxy route and render cost

This commit is contained in:
digua committed 2026-06-30 22:02:44 +08:00
1 parent fb0a92d82b
commit 506c07c4a1
3 files changed
+181 -42

No files matched your search

@@ -1,16 +1,16 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import type { PeopleRelationshipGraphNode, PeopleRelationshipsGraphData } from '@openchatlab/shared-types'
import {
buildRelationshipGalaxy3DScene,
shouldRenderRelationshipGalaxy3DLabel,
type RelationshipGalaxy3DEdge,
type RelationshipGalaxy3DNode,
type RelationshipGalaxy3DScene,
} from '../relationship-galaxy-3d-scene'
import { buildRelationshipGalaxy3DEdgeCurvePoints } from '../relationship-galaxy-3d-edge-path'
import { buildRelationshipVisibleLabelKeys } from '../relationship-galaxy-connections'
import {
applyRelationshipGalaxy3DSafeArea,
buildRelationshipGalaxy3DFitCameraPose,
@@ -68,7 +68,8 @@ const emit = defineEmits<{
const canvasRoot = ref<HTMLElement | null>(null)
const labels = shallowRef<VisibleLabel[]>([])
const hoveredKey = ref<string | null>(null)
const sceneModel = computed(() => buildRelationshipGalaxy3DScene(props.graph, { selectedKey: props.selectedKey }))
const sceneModel = shallowRef<RelationshipGalaxy3DScene>(buildRelationshipGalaxy3DScene(props.graph))
const selectedVisibleLabelKeys = shallowRef<Set<string> | null>(null)
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
@@ -116,6 +117,9 @@ function scenePosition(node: RelationshipGalaxy3DNode, model: RelationshipGalaxy
function renderGraph(shouldFit = false) {
if (!scene || !camera || !renderer) return
const model = buildRelationshipGalaxy3DScene(props.graph)
sceneModel.value = model
updateSelectedVisibleLabelKeys()
clearGroup(edgeGroup)
clearGroup(nodeGroup)
nodeObjects.clear()
@@ -125,7 +129,6 @@ function renderGraph(shouldFit = false) {
labelFrame = 0
labels.value = []
const model = sceneModel.value
for (const edge of model.edges) {
if (!neighborKeysOf.has(edge.edge.sourceKey)) neighborKeysOf.set(edge.edge.sourceKey, new Set())
if (!neighborKeysOf.has(edge.edge.targetKey)) neighborKeysOf.set(edge.edge.targetKey, new Set())
@@ -149,6 +152,12 @@ function renderGraph(shouldFit = false) {
resolvePendingFocus()
}
function updateSelectedVisibleLabelKeys() {
selectedVisibleLabelKeys.value = props.selectedKey
? buildRelationshipVisibleLabelKeys(props.graph, props.selectedKey)
: null
}
function addEdgeLayer(model: RelationshipGalaxy3DScene, bucket: 'dim' | 'normal' | 'highlight') {
const edges = model.edges.filter((edge) => {
if (bucket === 'highlight') return edge.highlighted
@@ -180,6 +189,8 @@ function addThinEdgePaths(
const color = new THREE.Color()
const edgeTint = new THREE.Color(0xc4a16a)
const positions: number[] = []
const colors: number[] = []
for (const edge of edges) {
const source = scenePosition(edge.source, model)
@@ -189,20 +200,26 @@ function addThinEdgePaths(
color.lerp(edgeTint, bucket === 'highlight' ? 0.18 : bucket === 'normal' ? 0.38 : 0.5)
color.multiplyScalar(bucket === 'highlight' ? 0.95 : 0.48)
const geometry = new THREE.BufferGeometry()
geometry.setFromPoints(points)
geometry.setAttribute('color', new THREE.Float32BufferAttribute(buildEdgeCurveColors(color, points.length), 3))
const line = new THREE.Line(geometry, material)
line.frustumCulled = false
edgeGroup.add(line)
for (let index = 0; index < points.length - 1; index += 1) {
const current = points[index]
const next = points[index + 1]
positions.push(current.x, current.y, current.z, next.x, next.y, next.z)
colors.push(color.r, color.g, color.b, color.r, color.g, color.b)
}
}
}
function buildEdgeCurveColors(color: THREE.Color, pointCount: number): number[] {
const colors: number[] = []
for (let i = 0; i < pointCount; i++) colors.push(color.r, color.g, color.b)
return colors
if (positions.length === 0) {
material.dispose()
return
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))
const line = new THREE.LineSegments(geometry, material)
line.frustumCulled = false
edgeGroup.add(line)
}
function groupEdgesByWidth(edges: RelationshipGalaxy3DEdge[], bucket: 'dim' | 'normal' | 'highlight') {
@@ -270,28 +287,32 @@ function updateAnimation() {
graphGroup.rotation.y = Math.sin(elapsedMs / 25_000) * autoDrift
graphGroup.rotation.x = Math.cos(elapsedMs / 30_000) * autoDrift * 0.4
starGroup.rotation.y = elapsedMs / 200_000
const activeKey = hoveredKey.value || props.selectedKey
for (const object of nodeObjects.values()) {
const t = elapsedMs / 1000 + object.phase
const motionScale = object.sceneNode.state === 'selected' ? 0.3 : 0.8
const isSelected = object.sceneNode.key === props.selectedKey
const isActive = object.sceneNode.key === activeKey
const isActiveNeighbor = Boolean(activeKey && neighborKeysOf.get(activeKey)?.has(object.sceneNode.key))
const motionScale = isSelected ? 0.3 : 0.8
const hoverScale = hoveredKey.value === object.sceneNode.key ? 1.28 : 1
const neighborScale = hoveredKey.value && neighborKeysOf.get(hoveredKey.value)?.has(object.sceneNode.key) ? 1.08 : 1
const selectedScale = isSelected ? 1.22 : 1
const neighborScale = isActiveNeighbor ? 1.08 : 1
object.group.position.set(
object.basePosition.x + Math.sin(t * 0.3) * 2.0 * motionScale,
object.basePosition.y + Math.cos(t * 0.25) * 1.5 * motionScale,
object.basePosition.z + Math.sin(t * 0.2) * 4.0 * motionScale
)
object.group.scale.setScalar((1 + Math.sin(t * 0.8) * 0.006) * hoverScale * neighborScale)
object.group.scale.setScalar((1 + Math.sin(t * 0.8) * 0.006) * hoverScale * selectedScale * neighborScale)
const activeKey = hoveredKey.value || props.selectedKey
const material = object.core.material
let opacity = object.sceneNode.opacity
if (activeKey && hoveredKey.value) {
if (object.sceneNode.key === activeKey) {
if (activeKey) {
if (isActive) {
opacity = 1
} else if (neighborKeysOf.get(activeKey)?.has(object.sceneNode.key)) {
} else if (isActiveNeighbor) {
opacity = 0.85
} else {
opacity = 0.1
@@ -331,7 +352,8 @@ function updateLabels() {
for (const object of nodeObjects.values()) {
const selected = object.sceneNode.key === selectedKey
const selectedNeighbor = Boolean(selectedKey && selectedNeighborKeys?.has(object.sceneNode.key))
if (!shouldRenderRelationshipGalaxy3DLabel(object.sceneNode, selectedKey ?? null, selectedNeighbor)) continue
const labelTier = getDynamicLabelTier(object.sceneNode, selectedKey ?? null)
if (labelTier === 0) continue
object.group.getWorldPosition(tmpWorldPosition)
const projected = tmpWorldPosition.clone().project(camera)
@@ -348,17 +370,27 @@ function updateLabels() {
y: y + object.sceneNode.radius + 8,
opacity: selected ? 1 : Math.max(0.42, object.core.material.opacity),
selected,
emphasis: getLabelEmphasis(object.sceneNode, selectedNeighbor),
emphasis: getLabelEmphasis(object.sceneNode, selectedNeighbor, labelTier),
})
}
labels.value = nextLabels
}
function getLabelEmphasis(sceneNode: RelationshipGalaxy3DNode, selectedNeighbor = false): VisibleLabel['emphasis'] {
if (sceneNode.state === 'selected' || sceneNode.node.kind === 'owner' || sceneNode.node.rank <= 5) return 'major'
function getDynamicLabelTier(sceneNode: RelationshipGalaxy3DNode, selectedKey: string | null): 0 | 1 | 2 {
if (!selectedKey) return sceneNode.labelTier
if (!selectedVisibleLabelKeys.value?.has(sceneNode.key)) return 0
return sceneNode.key === selectedKey || sceneNode.node.kind === 'owner' ? 2 : 1
}
function getLabelEmphasis(
sceneNode: RelationshipGalaxy3DNode,
selectedNeighbor = false,
labelTier = sceneNode.labelTier
): VisibleLabel['emphasis'] {
if (labelTier === 2 || sceneNode.node.kind === 'owner' || sceneNode.node.rank <= 5) return 'major'
if (selectedNeighbor) return 'medium'
if (sceneNode.node.rank <= 30 || sceneNode.labelTier === 2) return 'medium'
if (sceneNode.node.rank <= 30) return 'medium'
return 'minor'
}
@@ -736,13 +768,32 @@ onMounted(async () => {
})
watch(
() => [props.graph.nodes, props.graph.edges, props.selectedKey, props.privacyMode, props.safeInsetRight],
() => props.graph,
() => {
renderGraph(false)
},
{ flush: 'post' }
)
watch(
() => props.selectedKey,
() => {
updateSelectedVisibleLabelKeys()
labelFrame = 1
updateLabels()
},
{ flush: 'post' }
)
watch(
() => props.privacyMode,
() => {
labelFrame = 1
updateLabels()
},
{ flush: 'post' }
)
onBeforeUnmount(() => {
if (animationFrame) cancelAnimationFrame(animationFrame)
resizeObserver?.disconnect()
+101 -12
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { CONTACTS_TIME_RANGE_PRESETS } from '@openchatlab/shared-types'
import type {
@@ -25,14 +25,16 @@ import {
resolveRelationshipGalaxyPollingAction,
shouldShowFocusConnectionsAction,
} from './relationship-galaxy-state'
import RelationshipGalaxyCanvas from './components/RelationshipGalaxyCanvas.vue'
import RelationshipGalaxyThreeCanvas from './components/RelationshipGalaxyThreeCanvas.vue'
type GalaxyCanvasInstance = {
focusNode: (key: string) => boolean
fitView: () => void
}
type GalaxyViewMode = '3d' | '2d'
type RelationshipGraphResponseWithGraph = Pick<
PeopleRelationshipsGraphResponse,
'algorithmVersion' | 'cache' | 'timeRange' | 'graph'
>
const EMPTY_GRAPH: PeopleRelationshipsGraphData = {
nodes: [],
@@ -42,6 +44,10 @@ const EMPTY_GRAPH: PeopleRelationshipsGraphData = {
const POLL_INTERVAL_MS = 1400
const RELATIONSHIP_DETAIL_PANEL_SAFE_INSET_RIGHT = 392
const RelationshipGalaxyThreeCanvas = defineAsyncComponent(
() => import('./components/RelationshipGalaxyThreeCanvas.vue')
)
const RelationshipGalaxyCanvas = defineAsyncComponent(() => import('./components/RelationshipGalaxyCanvas.vue'))
const { t, locale } = useI18n()
const dataService = useDataService()
@@ -70,6 +76,8 @@ const graphRequestId = ref(0)
const neighborhoodRequestId = ref(0)
const canvasRef = ref<GalaxyCanvasInstance | 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
@@ -223,6 +231,87 @@ function formatTaskProgress(nextTask: PeopleRelationshipsTaskState | null): stri
})
}
function applyGraphResponse(next: PeopleRelationshipsGraphResponse) {
const nextGraphContentKey = buildGraphContentKey(next, graphScope.value)
const previous = graphResponse.value
graphResponse.value =
previous && graphContentKey === nextGraphContentKey
? {
...next,
graph: previous.graph,
}
: next
graphContentKey = nextGraphContentKey
}
function applyNeighborhoodResponse(next: PeopleRelationshipsNeighborhoodResponse) {
const nextGraphContentKey = buildGraphContentKey(next, `neighborhood:${next.contact?.key ?? ''}`)
const previous = neighborhoodResponse.value
neighborhoodResponse.value =
previous && neighborhoodGraphContentKey === nextGraphContentKey
? {
...next,
graph: previous.graph,
}
: next
neighborhoodGraphContentKey = nextGraphContentKey
}
function clearNeighborhoodResponse() {
neighborhoodResponse.value = null
neighborhoodGraphContentKey = null
}
function buildGraphContentKey(response: RelationshipGraphResponseWithGraph, context: string): string {
const graph = response.graph
let hash = 2166136261
hash = hashString(hash, context)
hash = hashString(hash, response.algorithmVersion)
hash = hashString(hash, response.cache.signature ?? '')
hash = hashNumber(hash, response.timeRange.startTs ?? 0)
hash = hashString(hash, response.timeRange.preset)
for (const node of graph.nodes) {
hash = hashString(hash, node.key)
hash = hashString(hash, node.pool)
hash = hashString(hash, node.friendSource ?? '')
hash = hashNumber(hash, node.rank)
hash = hashNumber(hash, Math.round(node.score * 1_000_000))
hash = hashNumber(hash, node.labelVisibility)
hash = hashNumber(hash, Math.round(node.x * 100))
hash = hashNumber(hash, Math.round(node.y * 100))
hash = hashNumber(hash, Math.round(node.size * 100))
}
for (const edge of graph.edges) {
hash = hashString(hash, edge.id)
hash = hashString(hash, edge.sourceKey)
hash = hashString(hash, edge.targetKey)
hash = hashNumber(hash, Math.round(edge.weight * 1_000_000))
hash = hashNumber(hash, edge.visibility)
}
for (const community of graph.communities) {
hash = hashString(hash, community.id)
hash = hashNumber(hash, community.size)
hash = hashString(hash, community.color)
}
return `${context}:${graph.nodes.length}:${graph.edges.length}:${graph.communities.length}:${hash >>> 0}`
}
function hashString(hash: number, value: string): number {
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return hash
}
function hashNumber(hash: number, value: number): number {
return hashString(hash, String(value))
}
function stopPolling() {
if (!pollTimer) return
clearInterval(pollTimer)
@@ -258,7 +347,7 @@ async function loadGraph(options: { silent?: boolean; preserveNeighborhood?: boo
if (!options.silent) isLoading.value = true
if (!options.preserveNeighborhood) {
cancelNeighborhoodLoad()
neighborhoodResponse.value = null
clearNeighborhoodResponse()
}
loadError.value = ''
@@ -271,7 +360,7 @@ async function loadGraph(options: { silent?: boolean; preserveNeighborhood?: boo
})
if (requestId !== graphRequestId.value) return
graphResponse.value = next
applyGraphResponse(next)
searchResultsQuery.value = debouncedSearchQuery.value.trim()
if (selectedKey.value && !activeGraph.value.nodes.some((node) => node.key === selectedKey.value)) {
selectedKey.value = null
@@ -305,10 +394,10 @@ async function recomputeRelationships() {
graphScope: graphScope.value,
query: debouncedSearchQuery.value.trim() || undefined,
})
graphResponse.value = next
applyGraphResponse(next)
searchResultsQuery.value = debouncedSearchQuery.value.trim()
cancelNeighborhoodLoad()
neighborhoodResponse.value = null
clearNeighborhoodResponse()
selectedKey.value = null
canvasSelectedKey.value = null
isDetailPanelOpen.value = false
@@ -370,7 +459,7 @@ async function loadNeighborhood(key: string) {
})
if (requestId !== neighborhoodRequestId.value) return
neighborhoodResponse.value = next
applyNeighborhoodResponse(next)
selectedKey.value = next.contact?.key ?? key
canvasSelectedKey.value = selectedKey.value
syncPolling(next.task)
@@ -408,7 +497,7 @@ async function selectSearchResult(result: PeopleRelationshipsSearchResult) {
}
cancelNeighborhoodLoad()
neighborhoodResponse.value = null
clearNeighborhoodResponse()
await nextTick()
canvasRef.value?.focusNode(result.key)
}
@@ -442,7 +531,7 @@ function handleThreeCanvasFallback() {
function backToPanorama() {
const key = selectedKey.value
cancelNeighborhoodLoad()
neighborhoodResponse.value = null
clearNeighborhoodResponse()
if (!key) return
if (!graphResponse.value?.graph.nodes.some((node) => node.key === key)) {
selectedKey.value = null
@@ -477,7 +566,7 @@ watch(timeRangePreset, () => {
canvasSelectedKey.value = null
isDetailPanelOpen.value = false
cancelNeighborhoodLoad()
neighborhoodResponse.value = null
clearNeighborhoodResponse()
void loadGraph()
})
@@ -486,7 +575,7 @@ watch(graphScope, () => {
canvasSelectedKey.value = null
isDetailPanelOpen.value = false
cancelNeighborhoodLoad()
neighborhoodResponse.value = null
clearNeighborhoodResponse()
void loadGraph()
})
-1
View File
@@ -36,7 +36,6 @@ function preloadCriticalRoutes() {
import('@/pages/group-chat/index.vue')
import('@/pages/private-chat/index.vue')
import('@/pages/people/contacts/index.vue')
import('@/pages/people/relationships/index.vue')
})
}