mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(relationships): add 3d galaxy view
This commit is contained in:
1 parent
d04eddc37c
commit
5141ee4e63
10 files changed
+1209
-9
No files matched your search
@@ -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": {
|
||||
|
||||
Generated
+8
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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交流関係ギャラクシーグラフ"
|
||||
}
|
||||
}
|
||||
@@ -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 互动关系星系图"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user