mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
perf(relationships): reduce galaxy route and render cost
This commit is contained in:
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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user