feat(relationships): add 3d galaxy view

This commit is contained in:
digua committed 2026-06-30 14:41:46 +08:00
1 parent d04eddc37c
commit 5141ee4e63
10 files changed
+1209 -9

No files matched your search

+1
View File
@@ -53,6 +53,7 @@
"markdown-it": "^14.1.0",
"pixi-viewport": "^6.0.3",
"pixi.js": "^8.19.0",
"three": "^0.185.0",
"vue-i18n": "^11.2.8"
},
"devDependencies": {
+8
View File
@@ -29,6 +29,9 @@ importers:
pixi.js:
specifier: ^8.19.0
version: 8.19.0
three:
specifier: ^0.185.0
version: 0.185.0
vue-i18n:
specifier: ^11.2.8
version: 11.2.8(vue@3.5.27(typescript@5.9.3))
@@ -5521,6 +5524,9 @@ packages:
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
engines: {node: '>=20'}
three@0.185.0:
resolution: {integrity: sha512-+yRrcRO2iZa8uzvNNl0d7cL4huhgKgBvVJ0njcTe8xFqZ6DMAFZdCKDP91SEAuj25bNAj7k1QQdf+srZywVK6w==}
tiny-async-pool@1.3.0:
resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==}
@@ -11789,6 +11795,8 @@ snapshots:
dependencies:
real-require: 0.2.0
three@0.185.0: {}
tiny-async-pool@1.3.0:
dependencies:
semver: 5.7.2
+8 -2
View File
@@ -5,6 +5,10 @@
"search": "Search contacts",
"empty": "No relationship graph data",
"privacy": "Privacy mode",
"viewMode": {
"3d": "3D",
"2d": "2D"
},
"timeRange": {
"1y": "1 year",
"2y": "2 years",
@@ -69,9 +73,11 @@
"recomputeStarted": "Started computing interaction graph",
"recomputeFailed": "Recompute failed",
"loadFailed": "Failed to load interaction graph",
"neighborhoodFailed": "Failed to load neighborhood"
"neighborhoodFailed": "Failed to load neighborhood",
"threeUnavailable": "3D rendering is unavailable. Switched to 2D."
},
"canvas": {
"label": "Interaction galaxy graph"
"label": "Interaction galaxy graph",
"label3d": "3D interaction galaxy graph"
}
}
+8 -2
View File
@@ -5,6 +5,10 @@
"search": "連絡先を検索",
"empty": "関係グラフのデータがありません",
"privacy": "プライバシーモード",
"viewMode": {
"3d": "3D",
"2d": "2D"
},
"timeRange": {
"1y": "過去1年",
"2y": "過去2年",
@@ -69,9 +73,11 @@
"recomputeStarted": "交流関係のバックグラウンド計算を開始しました",
"recomputeFailed": "再計算に失敗しました",
"loadFailed": "交流関係の読み込みに失敗しました",
"neighborhoodFailed": "近傍の読み込みに失敗しました"
"neighborhoodFailed": "近傍の読み込みに失敗しました",
"threeUnavailable": "3Dレンダリングを利用できないため、2Dに切り替えました。"
},
"canvas": {
"label": "交流関係ギャラクシーグラフ"
"label": "交流関係ギャラクシーグラフ",
"label3d": "3D交流関係ギャラクシーグラフ"
}
}
+8 -2
View File
@@ -5,6 +5,10 @@
"search": "搜索联系人",
"empty": "暂无关系图数据",
"privacy": "隐私模式",
"viewMode": {
"3d": "3D",
"2d": "2D"
},
"timeRange": {
"1y": "近1年",
"2y": "近2年",
@@ -69,9 +73,11 @@
"recomputeStarted": "已开始后台计算互动关系",
"recomputeFailed": "重新计算失败",
"loadFailed": "互动关系加载失败",
"neighborhoodFailed": "邻域加载失败"
"neighborhoodFailed": "邻域加载失败",
"threeUnavailable": "3D 渲染不可用,已切换到 2D。"
},
"canvas": {
"label": "互动关系星系图"
"label": "互动关系星系图",
"label3d": "3D 互动关系星系图"
}
}
+8 -2
View File
@@ -5,6 +5,10 @@
"search": "搜尋聯絡人",
"empty": "暫無關係圖資料",
"privacy": "隱私模式",
"viewMode": {
"3d": "3D",
"2d": "2D"
},
"timeRange": {
"1y": "近1年",
"2y": "近2年",
@@ -69,9 +73,11 @@
"recomputeStarted": "已開始背景計算互動關係",
"recomputeFailed": "重新計算失敗",
"loadFailed": "互動關係載入失敗",
"neighborhoodFailed": "鄰域載入失敗"
"neighborhoodFailed": "鄰域載入失敗",
"threeUnavailable": "3D 渲染不可用,已切換到 2D。"
},
"canvas": {
"label": "互動關係星系圖"
"label": "互動關係星系圖",
"label3d": "3D 互動關係星系圖"
}
}
@@ -0,0 +1,729 @@
<script setup lang="ts">
import { computed, 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,
type RelationshipGalaxy3DNode,
type RelationshipGalaxy3DScene,
} from '../relationship-galaxy-3d-scene'
interface NodeObject {
group: THREE.Group
core: THREE.Sprite
glow: THREE.Sprite
sceneNode: RelationshipGalaxy3DNode
basePosition: THREE.Vector3
phase: number
}
interface VisibleLabel {
key: string
text: string
x: number
y: number
opacity: number
selected: boolean
}
interface CameraFlight {
startedAt: number
duration: number
fromPosition: THREE.Vector3
toPosition: THREE.Vector3
fromTarget: THREE.Vector3
toTarget: THREE.Vector3
}
const props = withDefaults(
defineProps<{
graph: PeopleRelationshipsGraphData
selectedKey?: string | null
privacyMode?: boolean
label: string
ownerLabel: string
}>(),
{
selectedKey: null,
privacyMode: false,
}
)
const emit = defineEmits<{
(event: 'select-node', node: PeopleRelationshipGraphNode): void
(event: 'fallback'): void
}>()
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 }))
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let controls: OrbitControls | null = null
let resizeObserver: ResizeObserver | null = null
let animationFrame = 0
let animationStartedAt = 0
let labelFrame = 0
let hasUserMovedCamera = false
let pendingFocusKey: string | null = null
let cameraFlight: CameraFlight | null = null
const graphGroup = new THREE.Group()
const edgeGroup = new THREE.Group()
const nodeGroup = new THREE.Group()
const starGroup = new THREE.Group()
const nodeObjects = new Map<string, NodeObject>()
const nodePickObjects: THREE.Object3D[] = []
const neighborKeysOf = new Map<string, Set<string>>()
const raycaster = new THREE.Raycaster()
const pointer = new THREE.Vector2()
const tmpWorldPosition = new THREE.Vector3()
function shortName(node: PeopleRelationshipGraphNode): string {
if (node.kind === 'owner') return props.ownerLabel
if (props.privacyMode) return `#${node.rank}`
return node.displayName || node.platformId || node.key
}
function getViewportSize(): { width: number; height: number } {
const rect = canvasRoot.value?.getBoundingClientRect()
return {
width: Math.max(1, Math.floor(rect?.width ?? 1)),
height: Math.max(1, Math.floor(rect?.height ?? 1)),
}
}
function scenePosition(node: RelationshipGalaxy3DNode, model: RelationshipGalaxy3DScene): THREE.Vector3 {
const centerX = (model.bounds.minX + model.bounds.maxX) / 2
const centerY = (model.bounds.minY + model.bounds.maxY) / 2
return new THREE.Vector3(node.x - centerX, -(node.y - centerY), node.z)
}
function renderGraph(shouldFit = false) {
if (!scene || !camera || !renderer) return
clearGroup(edgeGroup)
clearGroup(nodeGroup)
nodeObjects.clear()
nodePickObjects.length = 0
neighborKeysOf.clear()
hoveredKey.value = null
labelFrame = 0
labels.value = []
const model = sceneModel.value
for (const edge of props.graph.edges) {
if (!neighborKeysOf.has(edge.sourceKey)) neighborKeysOf.set(edge.sourceKey, new Set())
if (!neighborKeysOf.has(edge.targetKey)) neighborKeysOf.set(edge.targetKey, new Set())
neighborKeysOf.get(edge.sourceKey)!.add(edge.targetKey)
neighborKeysOf.get(edge.targetKey)!.add(edge.sourceKey)
}
addEdgeLayer(model, 'dim')
addEdgeLayer(model, 'normal')
addEdgeLayer(model, 'highlight')
for (const sceneNode of model.nodes) {
const basePosition = scenePosition(sceneNode, model)
const object = createNodeObject(sceneNode, basePosition)
nodeObjects.set(sceneNode.key, object)
nodePickObjects.push(object.core)
nodeGroup.add(object.group)
}
if (shouldFit || !hasUserMovedCamera) fitView()
resolvePendingFocus()
}
function addEdgeLayer(model: RelationshipGalaxy3DScene, bucket: 'dim' | 'normal' | 'highlight') {
const edges = model.edges.filter((edge) => {
if (bucket === 'highlight') return edge.highlighted
if (bucket === 'dim') return edge.alpha <= 0.05
return !edge.highlighted && edge.alpha > 0.05
})
if (edges.length === 0) return
const positions: number[] = []
const colors: number[] = []
const color = new THREE.Color()
for (const edge of edges) {
const source = scenePosition(edge.source, model)
const target = scenePosition(edge.target, model)
const points = makeWispyEdgePoints(source, target, edge.source.seed + edge.target.seed)
color.setHex(edge.color)
for (let i = 0; i < points.length - 1; i++) {
positions.push(points[i].x, points[i].y, points[i].z, points[i + 1].x, points[i + 1].y, points[i + 1].z)
colors.push(color.r, color.g, color.b, color.r, color.g, color.b)
}
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))
const material = new THREE.LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: bucket === 'highlight' ? 0.72 : bucket === 'normal' ? 0.2 : 0.055,
blending: THREE.AdditiveBlending,
depthWrite: false,
})
edgeGroup.add(new THREE.LineSegments(geometry, material))
}
function makeWispyEdgePoints(source: THREE.Vector3, target: THREE.Vector3, seed: number): THREE.Vector3[] {
const mid = source.clone().lerp(target, 0.5)
const distance = source.distanceTo(target)
const bend = (seed % 1) - 0.5
const control = mid.add(
new THREE.Vector3(
(target.y - source.y) * 0.035 * bend,
(source.x - target.x) * 0.035 * bend,
Math.min(160, distance * 0.12) * bend
)
)
const curve = new THREE.QuadraticBezierCurve3(source, control, target)
return curve.getPoints(7)
}
function createNodeObject(sceneNode: RelationshipGalaxy3DNode, basePosition: THREE.Vector3): NodeObject {
const group = new THREE.Group()
group.position.copy(basePosition)
const glow = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getRadialTexture('glow'),
color: sceneNode.color,
transparent: true,
opacity: 0.22 * sceneNode.glow,
blending: THREE.AdditiveBlending,
depthWrite: false,
})
)
glow.scale.setScalar(sceneNode.radius * (sceneNode.state === 'selected' ? 8.8 : 6.4))
const core = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getRadialTexture('core'),
color: sceneNode.color,
transparent: true,
opacity: sceneNode.opacity,
blending: THREE.AdditiveBlending,
depthWrite: false,
})
)
core.scale.setScalar(sceneNode.radius * 2.35)
core.userData.key = sceneNode.key
group.add(glow)
group.add(core)
if (sceneNode.state === 'selected') {
const ring = new THREE.Sprite(
new THREE.SpriteMaterial({
map: getRadialTexture('ring'),
color: 0xffffff,
transparent: true,
opacity: 0.85,
blending: THREE.AdditiveBlending,
depthWrite: false,
})
)
ring.scale.setScalar(sceneNode.radius * 4.4)
group.add(ring)
}
return {
group,
core,
glow,
sceneNode,
basePosition,
phase: sceneNode.seed * Math.PI * 2,
}
}
function updateAnimation() {
if (!renderer || !scene || !camera || !controls) return
const elapsedMs = performance.now() - animationStartedAt
updateCameraFlight()
const autoDrift = hasUserMovedCamera ? 0.007 : 0.018
graphGroup.rotation.y = Math.sin(elapsedMs / 18_000) * autoDrift
graphGroup.rotation.x = Math.cos(elapsedMs / 22_000) * autoDrift * 0.55
starGroup.rotation.y = elapsedMs / 120_000
for (const object of nodeObjects.values()) {
const t = elapsedMs / 1000 + object.phase
const motionScale = object.sceneNode.state === 'selected' ? 0.45 : 1
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
object.group.position.set(
object.basePosition.x + Math.sin(t * 0.48) * 3.2 * motionScale,
object.basePosition.y + Math.cos(t * 0.42) * 2.6 * motionScale,
object.basePosition.z + Math.sin(t * 0.36) * 7 * motionScale
)
object.group.scale.setScalar((1 + Math.sin(t * 1.2) * 0.035 * object.sceneNode.glow) * hoverScale * neighborScale)
const activeKey = hoveredKey.value || props.selectedKey
const material = object.core.material
const glowMaterial = object.glow.material
let opacity = object.sceneNode.opacity
let glowOpacity = 0.22 * object.sceneNode.glow
if (activeKey && hoveredKey.value) {
if (object.sceneNode.key === activeKey) {
opacity = 1
glowOpacity = 0.38
} else if (neighborKeysOf.get(activeKey)?.has(object.sceneNode.key)) {
opacity = 0.86
glowOpacity = 0.22
} else {
opacity = 0.11
glowOpacity = 0.035
}
}
material.opacity += (opacity - material.opacity) * 0.16
glowMaterial.opacity += (glowOpacity - glowMaterial.opacity) * 0.16
}
controls.update()
updateLabels()
renderer.render(scene, camera)
animationFrame = requestAnimationFrame(updateAnimation)
}
function updateCameraFlight() {
if (!controls || !camera || !cameraFlight) return
const progress = Math.min(1, (performance.now() - cameraFlight.startedAt) / cameraFlight.duration)
const eased = easeInOutCubic(progress)
camera.position.lerpVectors(cameraFlight.fromPosition, cameraFlight.toPosition, eased)
controls.target.lerpVectors(cameraFlight.fromTarget, cameraFlight.toTarget, eased)
if (progress >= 1) cameraFlight = null
}
function updateLabels() {
if (!renderer || !camera) return
labelFrame += 1
if (labelFrame % 2 !== 0) return
const { width, height } = getViewportSize()
const nextLabels: VisibleLabel[] = []
for (const object of nodeObjects.values()) {
if (object.sceneNode.labelTier === 0) continue
object.group.getWorldPosition(tmpWorldPosition)
const projected = tmpWorldPosition.clone().project(camera)
if (projected.z < -1 || projected.z > 1) continue
const x = (projected.x * 0.5 + 0.5) * width
const y = (-projected.y * 0.5 + 0.5) * height
if (x < -80 || x > width + 80 || y < -40 || y > height + 40) continue
const selected = object.sceneNode.key === props.selectedKey
nextLabels.push({
key: object.sceneNode.key,
text: shortName(object.sceneNode.node),
x,
y: y + object.sceneNode.radius + 8,
opacity: selected ? 1 : Math.max(0.42, object.core.material.opacity),
selected,
})
}
labels.value = nextLabels
}
async function initCanvas() {
const host = canvasRoot.value
if (!host || renderer) return
const size = getViewportSize()
scene = new THREE.Scene()
scene.fog = new THREE.FogExp2(0x04060b, 0.00018)
camera = new THREE.PerspectiveCamera(48, size.width / size.height, 1, 30_000)
camera.position.set(0, -600, 1500)
try {
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' })
} catch (error) {
console.warn('relationship galaxy 3d renderer unavailable', error)
emit('fallback')
return
}
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
renderer.setSize(size.width, size.height)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.domElement.className = 'h-full w-full'
host.appendChild(renderer.domElement)
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.055
controls.rotateSpeed = 0.34
controls.zoomSpeed = 0.72
controls.panSpeed = 0.58
controls.minDistance = 160
controls.maxDistance = 9000
controls.addEventListener('start', () => {
hasUserMovedCamera = true
cameraFlight = null
})
graphGroup.add(edgeGroup)
graphGroup.add(nodeGroup)
scene.add(starGroup)
scene.add(graphGroup)
addStarField()
renderer.domElement.addEventListener('pointermove', handlePointerMove)
renderer.domElement.addEventListener('pointerleave', handlePointerLeave)
renderer.domElement.addEventListener('click', handleClick)
resizeObserver = new ResizeObserver(resizeCanvas)
resizeObserver.observe(host)
renderGraph(true)
animationStartedAt = performance.now()
animationFrame = requestAnimationFrame(updateAnimation)
}
function addStarField() {
clearGroup(starGroup)
const positions: number[] = []
const colors: number[] = []
const color = new THREE.Color()
const count = 1600
for (let i = 0; i < count; i++) {
const seed = i * 97.317
const x = pseudoRandom(seed) * 9000 - 4500
const y = pseudoRandom(seed + 13.1) * 6000 - 3000
const z = pseudoRandom(seed + 29.7) * 3000 - 1500
positions.push(x, y, z)
const huePick = pseudoRandom(seed + 8.8)
if (huePick < 0.58) color.setHex(0xdbeafe)
else if (huePick < 0.8) color.setHex(0xfef3c7)
else color.setHex(0xa7f3d0)
const intensity = 0.38 + pseudoRandom(seed + 19.2) * 0.62
colors.push(color.r * intensity, color.g * intensity, color.b * intensity)
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))
const material = new THREE.PointsMaterial({
size: 2.5,
sizeAttenuation: true,
vertexColors: true,
transparent: true,
opacity: 0.82,
depthWrite: false,
})
starGroup.add(new THREE.Points(geometry, material))
}
function resizeCanvas() {
if (!renderer || !camera) return
const size = getViewportSize()
camera.aspect = size.width / size.height
camera.updateProjectionMatrix()
renderer.setSize(size.width, size.height)
}
function handlePointerMove(event: PointerEvent) {
if (!renderer || !camera) return
const rect = renderer.domElement.getBoundingClientRect()
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
raycaster.setFromCamera(pointer, camera)
const [hit] = raycaster.intersectObjects(nodePickObjects, false)
hoveredKey.value = typeof hit?.object.userData.key === 'string' ? hit.object.userData.key : null
}
function handlePointerLeave() {
hoveredKey.value = null
}
function handleClick() {
const key = hoveredKey.value
if (!key) return
const object = nodeObjects.get(key)
if (!object) return
emit('select-node', object.sceneNode.node)
}
function resolvePendingFocus() {
if (!pendingFocusKey) return
const key = pendingFocusKey
if (!nodeObjects.has(key)) {
pendingFocusKey = null
return
}
focusNode(key)
}
function focusNode(key: string): boolean {
if (!camera || !controls) {
pendingFocusKey = key
return false
}
const object = nodeObjects.get(key)
if (!object) {
pendingFocusKey = key
return false
}
pendingFocusKey = null
hasUserMovedCamera = true
const target = object.basePosition.clone()
const distance = Math.max(280, Math.min(900, sceneModel.value.bounds.width * 0.22))
startCameraFlight(target.clone().add(new THREE.Vector3(0, -distance * 0.42, distance)), target, 540)
return true
}
function fitView() {
if (!camera || !controls) return
hasUserMovedCamera = false
const model = sceneModel.value
const span = Math.max(model.bounds.width, model.bounds.height, model.bounds.depth, 900)
startCameraFlight(new THREE.Vector3(0, -span * 0.18, span * 1.16), new THREE.Vector3(0, 0, 0), 620)
}
function startCameraFlight(toPosition: THREE.Vector3, toTarget: THREE.Vector3, duration: number) {
if (!camera || !controls) return
cameraFlight = {
startedAt: performance.now(),
duration,
fromPosition: camera.position.clone(),
toPosition,
fromTarget: controls.target.clone(),
toTarget,
}
}
function clearGroup(group: THREE.Group) {
while (group.children.length > 0) {
const child = group.children.pop()
if (!child) continue
disposeObject(child)
}
}
function disposeObject(object: THREE.Object3D) {
object.traverse((child) => {
const mesh = child as THREE.Object3D & {
geometry?: THREE.BufferGeometry
material?: THREE.Material | THREE.Material[]
}
mesh.geometry?.dispose()
if (Array.isArray(mesh.material)) {
for (const material of mesh.material) material.dispose()
} else {
mesh.material?.dispose()
}
})
}
function easeInOutCubic(value: number): number {
return value < 0.5 ? 4 * value * value * value : 1 - Math.pow(-2 * value + 2, 3) / 2
}
function pseudoRandom(seed: number): number {
const value = Math.sin(seed * 12.9898) * 43758.5453
return value - Math.floor(value)
}
const textureCache = new Map<string, THREE.CanvasTexture>()
function getRadialTexture(kind: 'core' | 'glow' | 'ring'): THREE.CanvasTexture {
const cached = textureCache.get(kind)
if (cached) return cached
const canvas = document.createElement('canvas')
canvas.width = 128
canvas.height = 128
const context = canvas.getContext('2d')
if (!context) throw new Error('failed to create galaxy texture context')
if (kind === 'ring') {
context.strokeStyle = 'rgba(255,255,255,0.95)'
context.lineWidth = 7
context.shadowColor = 'rgba(255,255,255,0.8)'
context.shadowBlur = 12
context.beginPath()
context.arc(64, 64, 42, 0, Math.PI * 2)
context.stroke()
} else {
const gradient = context.createRadialGradient(64, 64, 0, 64, 64, 62)
if (kind === 'core') {
gradient.addColorStop(0, 'rgba(255,255,255,1)')
gradient.addColorStop(0.25, 'rgba(255,255,255,0.82)')
gradient.addColorStop(0.58, 'rgba(255,255,255,0.24)')
gradient.addColorStop(1, 'rgba(255,255,255,0)')
} else {
gradient.addColorStop(0, 'rgba(255,255,255,0.32)')
gradient.addColorStop(0.35, 'rgba(255,255,255,0.16)')
gradient.addColorStop(1, 'rgba(255,255,255,0)')
}
context.fillStyle = gradient
context.fillRect(0, 0, canvas.width, canvas.height)
}
const texture = new THREE.CanvasTexture(canvas)
texture.colorSpace = THREE.SRGBColorSpace
textureCache.set(kind, texture)
return texture
}
onMounted(async () => {
await nextTick()
await initCanvas()
})
watch(
() => [props.graph.nodes, props.graph.edges, props.selectedKey, props.privacyMode],
() => {
renderGraph(false)
},
{ flush: 'post' }
)
onBeforeUnmount(() => {
if (animationFrame) cancelAnimationFrame(animationFrame)
resizeObserver?.disconnect()
resizeObserver = null
if (renderer) {
renderer.domElement.removeEventListener('pointermove', handlePointerMove)
renderer.domElement.removeEventListener('pointerleave', handlePointerLeave)
renderer.domElement.removeEventListener('click', handleClick)
}
clearGroup(edgeGroup)
clearGroup(nodeGroup)
clearGroup(starGroup)
for (const texture of textureCache.values()) texture.dispose()
textureCache.clear()
graphGroup.clear()
scene?.clear()
controls?.dispose()
renderer?.dispose()
renderer?.domElement.remove()
renderer = null
scene = null
camera = null
controls = null
labels.value = []
})
defineExpose({
focusNode,
fitView,
})
</script>
<template>
<div
ref="canvasRoot"
class="relationship-galaxy-3d relative h-full w-full overflow-hidden"
role="img"
:aria-label="label"
>
<div class="pointer-events-none absolute inset-0 z-10 overflow-hidden">
<span
v-for="item in labels"
:key="item.key"
class="relationship-galaxy-3d__label"
:class="{ 'relationship-galaxy-3d__label--selected': item.selected }"
:style="{ left: `${item.x}px`, top: `${item.y}px`, opacity: item.opacity }"
>
{{ item.text }}
</span>
</div>
<div class="relationship-galaxy-3d__crosshair pointer-events-none absolute left-1/2 top-1/2 z-10"></div>
</div>
</template>
<style scoped>
.relationship-galaxy-3d {
background:
linear-gradient(180deg, rgba(9, 12, 20, 0.95), rgba(2, 4, 9, 1)),
radial-gradient(circle at 50% 45%, rgba(45, 58, 88, 0.18), transparent 58%);
}
.relationship-galaxy-3d__label {
position: absolute;
max-width: 132px;
transform: translate(-50%, 0);
color: rgba(226, 232, 240, 0.9);
font-size: 11px;
font-weight: 650;
line-height: 1.1;
overflow: hidden;
text-overflow: ellipsis;
text-shadow:
0 0 8px rgba(255, 255, 255, 0.5),
0 2px 8px rgba(0, 0, 0, 0.95);
white-space: nowrap;
will-change: transform, opacity;
}
.relationship-galaxy-3d__label--selected {
color: #fff;
font-size: 15px;
font-weight: 800;
text-shadow:
0 0 10px rgba(255, 255, 255, 0.86),
0 0 22px rgba(125, 211, 252, 0.5),
0 2px 10px rgba(0, 0, 0, 1);
}
.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(226, 232, 240, 0.45);
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>
+45 -1
View File
@@ -20,8 +20,13 @@ import PeopleSubnav from '../components/PeopleSubnav.vue'
import { buildRelationshipConnectionRanking } from './relationship-galaxy-connections'
import { shouldShowFocusConnectionsAction } from './relationship-galaxy-state'
import RelationshipGalaxyCanvas from './components/RelationshipGalaxyCanvas.vue'
import RelationshipGalaxyThreeCanvas from './components/RelationshipGalaxyThreeCanvas.vue'
type GalaxyCanvasInstance = InstanceType<typeof RelationshipGalaxyCanvas>
type GalaxyCanvasInstance = {
focusNode: (key: string) => boolean
fitView: () => void
}
type GalaxyViewMode = '3d' | '2d'
const EMPTY_GRAPH: PeopleRelationshipsGraphData = {
nodes: [],
@@ -45,6 +50,7 @@ const isLoading = ref(false)
const isRecomputing = ref(false)
const isLoadingNeighborhood = ref(false)
const privacyMode = ref(false)
const viewMode = ref<GalaxyViewMode>('3d')
const loadError = ref('')
const graphRequestId = ref(0)
const canvasRef = ref<GalaxyCanvasInstance | null>(null)
@@ -61,6 +67,16 @@ const timeRangeTabs = computed(() =>
value: preset,
}))
)
const viewModeTabs = computed(() => [
{
label: t('relationships.viewMode.3d'),
value: '3d' as const,
},
{
label: t('relationships.viewMode.2d'),
value: '2d' as const,
},
])
const activeGraph = computed(() => neighborhoodResponse.value?.graph ?? graphResponse.value?.graph ?? EMPTY_GRAPH)
const isNeighborhoodMode = computed(() => Boolean(neighborhoodResponse.value))
@@ -267,6 +283,12 @@ async function selectNode(node: PeopleRelationshipGraphNode) {
canvasRef.value?.focusNode(node.key)
}
function handleThreeCanvasFallback() {
if (viewMode.value !== '3d') return
viewMode.value = '2d'
toast.warn(t('relationships.toast.threeUnavailable'))
}
function backToPanorama() {
const key = selectedKey.value
neighborhoodResponse.value = null
@@ -301,6 +323,15 @@ watch(searchQuery, (value) => {
}, 260)
})
watch(viewMode, async () => {
await nextTick()
if (selectedKey.value) {
canvasRef.value?.focusNode(selectedKey.value)
return
}
canvasRef.value?.fitView()
})
onMounted(() => {
void loadGraph()
})
@@ -361,7 +392,19 @@ onBeforeUnmount(() => {
<div class="flex min-h-0 flex-1 overflow-hidden">
<main class="relative min-h-0 min-w-0 flex-1 overflow-hidden bg-[#05070d]">
<RelationshipGalaxyThreeCanvas
v-if="viewMode === '3d'"
ref="canvasRef"
:graph="activeGraph"
:selected-key="selectedKey"
:privacy-mode="privacyMode"
:label="t('relationships.canvas.label3d')"
:owner-label="t('relationships.owner.me')"
@fallback="handleThreeCanvasFallback"
@select-node="selectNode"
/>
<RelationshipGalaxyCanvas
v-else
ref="canvasRef"
:graph="activeGraph"
:selected-key="selectedKey"
@@ -373,6 +416,7 @@ onBeforeUnmount(() => {
<div class="absolute left-4 top-4 z-20 flex max-w-[calc(100%-2rem)] flex-wrap items-center gap-2">
<UTabs v-model="timeRangePreset" :items="timeRangeTabs" :content="false" size="xs" class="min-w-max gap-0" />
<UTabs v-model="viewMode" :items="viewModeTabs" :content="false" size="xs" class="min-w-max gap-0" />
<UButton
icon="i-lucide-scan-line"
color="neutral"
@@ -0,0 +1,130 @@
/**
* Run: pnpm test -- src/pages/people/relationships/relationship-galaxy-3d-scene.test.ts
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import type {
ChatPlatform,
PeopleRelationshipGraphEdge,
PeopleRelationshipGraphNode,
PeopleRelationshipsGraphData,
} from '@openchatlab/shared-types'
import { buildRelationshipGalaxy3DScene } from './relationship-galaxy-3d-scene'
function node(
overrides: Partial<PeopleRelationshipGraphNode> & { key: string; rank: number }
): PeopleRelationshipGraphNode {
return {
key: overrides.key,
kind: overrides.kind ?? 'contact',
platform: 'wechat' as ChatPlatform,
platformId: overrides.platformId ?? overrides.key,
sessionScoped: false,
displayName: overrides.displayName ?? overrides.key,
aliases: [],
avatar: null,
pool: overrides.pool ?? 'non_friend',
score: overrides.score ?? 0.5,
rank: overrides.rank,
communityId: overrides.communityId ?? 'community-a',
x: overrides.x ?? 0,
y: overrides.y ?? 0,
size: overrides.size ?? 6,
color: overrides.color ?? '#38bdf8',
labelVisibility: overrides.labelVisibility ?? 0,
lastInteractionTs: null,
privateMessageCount: 0,
groupMessageCount: 0,
commonGroupCount: 0,
searchText: overrides.searchText ?? overrides.key,
}
}
function edge(
overrides: Partial<PeopleRelationshipGraphEdge> & { sourceKey: string; targetKey: string }
): PeopleRelationshipGraphEdge {
return {
id: `${overrides.sourceKey}:${overrides.targetKey}`,
sourceKey: overrides.sourceKey,
targetKey: overrides.targetKey,
weight: overrides.weight ?? 0.5,
coOccurrenceCount: 1,
coOccurrenceRawScore: 1,
replyInteractionCount: 0,
repliesFromSourceToTarget: 0,
repliesFromTargetToSource: 0,
sourceGroupCount: 1,
sourceSessionIds: [],
lastInteractionTs: null,
visibility: overrides.visibility ?? 1,
}
}
test('derives stable shallow 3D depth from existing graph nodes', () => {
const graph: PeopleRelationshipsGraphData = {
nodes: [
node({ key: 'weixin:alice', rank: 1, score: 0.98, x: 10, y: 20, communityId: 'friends' }),
node({ key: 'weixin:bob', rank: 2, score: 0.68, x: 60, y: -20, communityId: 'friends' }),
node({ key: 'weixin:chen', rank: 35, score: 0.22, x: -80, y: 30, communityId: 'groupmates' }),
],
edges: [],
communities: [],
}
const scene = buildRelationshipGalaxy3DScene(graph)
const reversedScene = buildRelationshipGalaxy3DScene({ ...graph, nodes: [...graph.nodes].reverse() })
assert.equal(scene.nodes.length, graph.nodes.length)
assert.deepEqual(scene.nodes.map((item) => item.key).sort(), graph.nodes.map((item) => item.key).sort())
for (const item of scene.nodes) {
assert.ok(item.z >= -360 && item.z <= 360)
assert.ok(item.radius >= item.node.size)
}
const alice = scene.nodes.find((item) => item.key === 'weixin:alice')
const reversedAlice = reversedScene.nodes.find((item) => item.key === 'weixin:alice')
assert.equal(alice?.z, reversedAlice?.z)
})
test('highlights selected node neighbors and dims unrelated nodes', () => {
const graph: PeopleRelationshipsGraphData = {
nodes: [
node({ key: 'weixin:alice', rank: 1, score: 0.92 }),
node({ key: 'weixin:bob', rank: 2, score: 0.84 }),
node({ key: 'weixin:chen', rank: 3, score: 0.7 }),
],
edges: [edge({ sourceKey: 'weixin:alice', targetKey: 'weixin:bob', weight: 0.9, visibility: 2 })],
communities: [],
}
const scene = buildRelationshipGalaxy3DScene(graph, { selectedKey: 'weixin:alice' })
const alice = scene.nodes.find((item) => item.key === 'weixin:alice')
const bob = scene.nodes.find((item) => item.key === 'weixin:bob')
const chen = scene.nodes.find((item) => item.key === 'weixin:chen')
assert.equal(alice?.state, 'selected')
assert.equal(bob?.state, 'neighbor')
assert.equal(chen?.state, 'dimmed')
assert.ok(scene.edges[0].highlighted)
assert.ok(scene.edges[0].alpha > 0.3)
})
test('keeps labels sparse while preserving selected and high-visibility names', () => {
const graph: PeopleRelationshipsGraphData = {
nodes: [
node({ key: 'weixin:selected', rank: 18, labelVisibility: 0 }),
node({ key: 'weixin:important', rank: 20, labelVisibility: 2 }),
node({ key: 'weixin:quiet', rank: 240, labelVisibility: 0 }),
],
edges: [],
communities: [],
}
const scene = buildRelationshipGalaxy3DScene(graph, { selectedKey: 'weixin:selected' })
assert.equal(scene.nodes.find((item) => item.key === 'weixin:selected')?.labelTier, 2)
assert.equal(scene.nodes.find((item) => item.key === 'weixin:important')?.labelTier, 2)
assert.equal(scene.nodes.find((item) => item.key === 'weixin:quiet')?.labelTier, 0)
})
@@ -0,0 +1,264 @@
import type {
PeopleRelationshipGraphEdge,
PeopleRelationshipGraphNode,
PeopleRelationshipsGraphData,
} from '@openchatlab/shared-types'
export type RelationshipGalaxy3DNodeState = 'normal' | 'selected' | 'neighbor' | 'dimmed'
export interface RelationshipGalaxy3DNode {
key: string
node: PeopleRelationshipGraphNode
x: number
y: number
z: number
radius: number
color: number
state: RelationshipGalaxy3DNodeState
labelTier: 0 | 1 | 2
opacity: number
glow: number
seed: number
}
export interface RelationshipGalaxy3DEdge {
edge: PeopleRelationshipGraphEdge
source: RelationshipGalaxy3DNode
target: RelationshipGalaxy3DNode
color: number
alpha: number
width: number
highlighted: boolean
}
export interface RelationshipGalaxy3DScene {
nodes: RelationshipGalaxy3DNode[]
edges: RelationshipGalaxy3DEdge[]
selectedNeighborKeys: Set<string>
bounds: {
minX: number
maxX: number
minY: number
maxY: number
minZ: number
maxZ: number
width: number
height: number
depth: number
}
}
export interface RelationshipGalaxy3DSceneOptions {
selectedKey?: string | null
}
const DEFAULT_FRIEND_COLOR = 0x6ee7ff
const DEFAULT_GROUPMATE_COLOR = 0xfacc6b
const MAX_DEPTH = 360
export function buildRelationshipGalaxy3DScene(
graph: PeopleRelationshipsGraphData,
options: RelationshipGalaxy3DSceneOptions = {}
): RelationshipGalaxy3DScene {
const selectedKey = options.selectedKey ?? null
const selectedNeighborKeys = buildSelectedNeighborKeys(graph.edges, selectedKey)
const nodes = graph.nodes.map((node) => {
const state = resolveNodeState(node.key, selectedKey, selectedNeighborKeys)
const seed = hashToUnit(node.key)
const z = deriveNodeDepth(node, selectedKey, seed)
const radius = deriveNodeRadius(node, state)
const labelTier = deriveLabelTier(node, state, graph.nodes.length)
return {
key: node.key,
node,
x: node.x,
y: node.y,
z,
radius,
color: parseNodeColor(node),
state,
labelTier,
opacity: deriveNodeOpacity(state),
glow: deriveNodeGlow(node, state),
seed,
}
})
const nodeByKey = new Map(nodes.map((node) => [node.key, node]))
const edges = graph.edges.flatMap((edge): RelationshipGalaxy3DEdge[] => {
const source = nodeByKey.get(edge.sourceKey)
const target = nodeByKey.get(edge.targetKey)
if (!source || !target) return []
const highlighted = Boolean(selectedKey && (edge.sourceKey === selectedKey || edge.targetKey === selectedKey))
const dimmedBySelection = Boolean(selectedKey && !highlighted)
const alpha = dimmedBySelection
? 0.035
: highlighted
? 0.42 + Math.min(0.28, edge.weight * 0.28)
: edge.visibility === 2
? 0.18
: 0.075
return [
{
edge,
source,
target,
color: source.color,
alpha,
width: Math.min(2.2, Math.max(0.45, Math.log10(edge.weight + 1) * 1.35)),
highlighted,
},
]
})
return {
nodes,
edges,
selectedNeighborKeys,
bounds: deriveBounds(nodes),
}
}
function buildSelectedNeighborKeys(edges: PeopleRelationshipGraphEdge[], selectedKey: string | null): Set<string> {
const keys = new Set<string>()
if (!selectedKey) return keys
for (const edge of edges) {
if (edge.sourceKey === selectedKey) keys.add(edge.targetKey)
if (edge.targetKey === selectedKey) keys.add(edge.sourceKey)
}
return keys
}
function resolveNodeState(
key: string,
selectedKey: string | null,
selectedNeighborKeys: Set<string>
): RelationshipGalaxy3DNodeState {
if (!selectedKey) return 'normal'
if (key === selectedKey) return 'selected'
if (selectedNeighborKeys.has(key)) return 'neighbor'
return 'dimmed'
}
function deriveNodeDepth(node: PeopleRelationshipGraphNode, selectedKey: string | null, seed: number): number {
const communityDepth = hashToSignedUnit(node.communityId || 'default') * 150
const scoreLift = (Math.max(0, Math.min(1, node.score)) - 0.5) * 110
const rankLift = Math.max(0, 1 - (node.rank - 1) / 80) * 90
const selectedLift = node.key === selectedKey ? 90 : 0
const seededDepth = (seed - 0.5) * 120
return clamp(communityDepth + scoreLift + rankLift + selectedLift + seededDepth, -MAX_DEPTH, MAX_DEPTH)
}
function deriveNodeRadius(node: PeopleRelationshipGraphNode, state: RelationshipGalaxy3DNodeState): number {
const base = Math.max(node.size, node.kind === 'owner' ? 13 : 4.8)
if (state === 'selected') return base + 5
if (state === 'neighbor') return base + 1.8
return base
}
function deriveLabelTier(
node: PeopleRelationshipGraphNode,
state: RelationshipGalaxy3DNodeState,
totalNodes: number
): 0 | 1 | 2 {
if (state === 'selected') return 2
if (node.labelVisibility === 2) return 2
if (node.kind === 'owner') return 2
if (state === 'neighbor' && node.rank <= 60) return 1
if (node.labelVisibility === 1 && totalNodes <= 600) return 1
if (node.rank <= 12) return 1
return 0
}
function deriveNodeOpacity(state: RelationshipGalaxy3DNodeState): number {
if (state === 'selected') return 1
if (state === 'neighbor') return 0.92
if (state === 'dimmed') return 0.16
return 0.82
}
function deriveNodeGlow(node: PeopleRelationshipGraphNode, state: RelationshipGalaxy3DNodeState): number {
if (state === 'selected') return 1
if (node.kind === 'owner') return 0.88
if (state === 'neighbor') return 0.66
if (state === 'dimmed') return 0.12
if (node.rank <= 10) return 0.72
if (node.labelVisibility === 2) return 0.56
return 0.34
}
function deriveBounds(nodes: RelationshipGalaxy3DNode[]): RelationshipGalaxy3DScene['bounds'] {
if (nodes.length === 0) {
return {
minX: -500,
maxX: 500,
minY: -500,
maxY: 500,
minZ: -MAX_DEPTH,
maxZ: MAX_DEPTH,
width: 1000,
height: 1000,
depth: MAX_DEPTH * 2,
}
}
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
let minZ = Number.POSITIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (const node of nodes) {
minX = Math.min(minX, node.x)
maxX = Math.max(maxX, node.x)
minY = Math.min(minY, node.y)
maxY = Math.max(maxY, node.y)
minZ = Math.min(minZ, node.z)
maxZ = Math.max(maxZ, node.z)
}
return {
minX,
maxX,
minY,
maxY,
minZ,
maxZ,
width: Math.max(800, maxX - minX),
height: Math.max(800, maxY - minY),
depth: Math.max(400, maxZ - minZ),
}
}
function parseNodeColor(node: PeopleRelationshipGraphNode): number {
const fallback = node.pool === 'friend' || node.kind === 'owner' ? DEFAULT_FRIEND_COLOR : DEFAULT_GROUPMATE_COLOR
if (!node.color) return fallback
const normalized = node.color.startsWith('#') ? node.color.slice(1) : node.color
const parsed = Number.parseInt(normalized, 16)
return Number.isFinite(parsed) ? parsed : fallback
}
function hashToSignedUnit(value: string): number {
return hashToUnit(value) * 2 - 1
}
function hashToUnit(value: string): number {
let hash = 2166136261
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i)
hash = Math.imul(hash, 16777619)
}
return (hash >>> 0) / 0xffffffff
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}