feat(relationships): refine galaxy depth and focus

This commit is contained in:
digua committed 2026-08-12 00:49:59 +08:00
1 parent bcaa19c27b
commit c13f885eee
13 files changed
+1779 -444

No files matched your search

@@ -21,6 +21,8 @@ import {
type GalaxyCanvasInstance = {
focusNode: (key: string) => boolean
fitView: () => void
captureView: () => unknown | null
restoreView: (view: unknown) => boolean
}
const props = defineProps<{
@@ -37,6 +39,7 @@ const isLoading = ref(false)
const loadError = ref('')
const webglUnavailable = ref(false)
const isWideLayout = ref(false)
const savedPanoramaView = ref<unknown | null>(null)
let requestId = 0
let detailMediaQuery: MediaQueryList | null = null
@@ -69,6 +72,7 @@ async function loadData() {
loadError.value = ''
webglUnavailable.value = false
selectedKey.value = null
savedPanoramaView.value = null
try {
const result = await dataService.getGroupRelationshipGalaxy(props.sessionId, props.timeFilter)
@@ -86,22 +90,32 @@ async function loadData() {
}
async function selectNode(node: RelationshipGalaxyRenderNode) {
rememberPanoramaView()
selectedKey.value = node.key
await nextTick()
canvasRef.value?.focusNode(node.key)
}
async function selectMember(member: GroupRelationshipGalaxyMemberDetail) {
rememberPanoramaView()
selectedKey.value = member.key
await nextTick()
canvasRef.value?.focusNode(member.key)
}
function clearSelection() {
async function clearSelection() {
if (!selectedKey.value) return
const panoramaView = savedPanoramaView.value
savedPanoramaView.value = null
selectedKey.value = null
await nextTick()
if (!panoramaView || canvasRef.value?.restoreView(panoramaView) !== true) {
canvasRef.value?.fitView()
}
}
async function resetView() {
savedPanoramaView.value = null
selectedKey.value = null
await nextTick()
canvasRef.value?.fitView()
@@ -110,6 +124,12 @@ async function resetView() {
function handleCanvasFallback() {
webglUnavailable.value = true
selectedKey.value = null
savedPanoramaView.value = null
}
function rememberPanoramaView() {
if (selectedKey.value || savedPanoramaView.value) return
savedPanoramaView.value = canvasRef.value?.captureView() ?? null
}
function syncDetailLayout() {
@@ -153,7 +173,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="relative h-full min-h-[420px] w-full overflow-hidden bg-[#050302] text-white">
<div class="dark relative h-full min-h-[420px] w-full overflow-hidden bg-[#03050c] text-white">
<LoadingState
v-if="displayState.content === 'loading'"
variant="page"
@@ -201,7 +221,6 @@ onBeforeUnmount(() => {
:selected-key="selectedKey"
:safe-inset-right="safeInsetRight"
:label="t('views.groupRelationshipGalaxy.canvasLabel')"
@clear-selection="clearSelection"
@fallback="handleCanvasFallback"
@select-node="selectNode"
/>
@@ -218,7 +237,7 @@ onBeforeUnmount(() => {
color="neutral"
variant="soft"
size="xs"
class="absolute left-4 top-4 z-20 border border-white/8 bg-black/25 text-white/75 backdrop-blur-md hover:bg-white/10 hover:text-white"
class="absolute left-4 top-4 z-20 rounded-xl border border-white/8 bg-[#070a12]/78 text-white/75 shadow-overlay backdrop-blur-xl hover:bg-white/10 hover:text-white"
:aria-label="t('views.groupRelationshipGalaxy.resetView')"
@click="resetView"
>
@@ -227,7 +246,7 @@ onBeforeUnmount(() => {
<aside
v-if="selectedMember"
class="dark absolute inset-x-3 bottom-3 z-20 flex max-h-[72vh] flex-col overflow-hidden rounded-2xl border border-white/8 bg-[#0b0c12]/88 text-white shadow-overlay backdrop-blur-xl md:inset-x-auto md:bottom-4 md:right-4 md:top-4 md:max-h-none md:w-[360px]"
class="absolute inset-x-3 bottom-3 z-20 flex max-h-[72vh] flex-col overflow-hidden rounded-2xl border border-white/8 bg-[#070a12]/84 text-white shadow-overlay backdrop-blur-2xl md:inset-x-auto md:bottom-4 md:right-4 md:top-4 md:max-h-none md:w-[360px]"
>
<UButton
icon="i-lucide-x"
@@ -260,14 +279,14 @@ onBeforeUnmount(() => {
</div>
</header>
<section class="grid shrink-0 grid-cols-3 gap-1.5">
<div class="rounded-xl border border-white/6 bg-white/3 px-2.5 py-2">
<section class="grid shrink-0 grid-cols-3 rounded-xl border border-white/8 bg-white/3 p-1">
<div class="min-w-0 px-2 py-1.5">
<p class="text-[9px] uppercase tracking-wider text-white/35">
{{ t('views.groupRelationshipGalaxy.messageCount') }}
</p>
<p class="mt-1 font-mono text-sm font-semibold">{{ formatNumber(selectedMember.messageCount) }}</p>
</div>
<div class="rounded-xl border border-white/6 bg-white/3 px-2.5 py-2">
<div class="min-w-0 border-l border-white/6 px-2 py-1.5">
<p class="text-[9px] uppercase tracking-wider text-white/35">
{{ t('views.groupRelationshipGalaxy.relationshipScore') }}
</p>
@@ -275,7 +294,7 @@ onBeforeUnmount(() => {
{{ formatScore(selectedMember.relationshipScore) }}
</p>
</div>
<div class="rounded-xl border border-white/6 bg-white/3 px-2.5 py-2">
<div class="min-w-0 border-l border-white/6 px-2 py-1.5">
<p class="text-[9px] uppercase tracking-wider text-white/35">
{{ t('views.groupRelationshipGalaxy.lastInteraction') }}
</p>
@@ -283,24 +302,24 @@ onBeforeUnmount(() => {
</div>
</section>
<section class="shrink-0">
<section class="shrink-0 border-t border-white/7 pt-3">
<h4 class="mb-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-white/40">
{{ t('views.groupRelationshipGalaxy.signalComposition') }}
</h4>
<div class="grid grid-cols-3 gap-1.5">
<div class="rounded-xl bg-violet-400/8 px-2.5 py-2 text-violet-100">
<div class="grid grid-cols-3">
<div class="px-2.5 py-1 text-violet-100">
<p class="text-[10px] text-violet-100/50">{{ t('views.groupRelationshipGalaxy.replies') }}</p>
<p class="mt-0.5 font-mono text-sm font-semibold">
{{ formatNumber(selectedMember.replyInteractionCount) }}
</p>
</div>
<div class="rounded-xl bg-sky-400/8 px-2.5 py-2 text-sky-100">
<div class="border-l border-white/6 px-2.5 py-1 text-sky-100">
<p class="text-[10px] text-sky-100/50">{{ t('views.groupRelationshipGalaxy.mentions') }}</p>
<p class="mt-0.5 font-mono text-sm font-semibold">
{{ formatNumber(selectedMember.mentionInteractionCount) }}
</p>
</div>
<div class="rounded-xl bg-amber-300/8 px-2.5 py-2 text-amber-100">
<div class="border-l border-white/6 px-2.5 py-1 text-amber-100">
<p class="text-[10px] text-amber-100/50">{{ t('views.groupRelationshipGalaxy.proximity') }}</p>
<p class="mt-0.5 font-mono text-sm font-semibold">
{{ formatNumber(selectedMember.coOccurrenceCount) }}
@@ -309,7 +328,7 @@ onBeforeUnmount(() => {
</div>
</section>
<section class="flex min-h-0 flex-1 flex-col">
<section class="flex min-h-0 flex-1 flex-col border-t border-white/7 pt-3">
<div class="mb-2 flex items-center justify-between">
<h4 class="text-[10px] font-semibold uppercase tracking-[0.16em] text-white/40">
{{ t('views.groupRelationshipGalaxy.strongConnections') }}
File diff suppressed because it is too large. Load diff
@@ -6,6 +6,8 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
applyRelationshipGalaxy3DSafeArea,
buildRelationshipGalaxy3DFocusCameraPose,
buildRelationshipGalaxy3DFocusFrame,
buildRelationshipGalaxy3DViewOffset,
buildRelationshipGalaxy3DImmersiveCameraPose,
} from './relationship-galaxy-3d-camera'
@@ -52,6 +54,113 @@ test('keeps the 3D orbit target fixed while expanding camera distance for a righ
assert.deepEqual(pose.target, { x: 0, y: 0, z: 0 })
})
test('flies toward the original node coordinate with a deterministic orbit angle', () => {
const pose = buildRelationshipGalaxy3DFocusCameraPose(
{
position: { x: 0, y: -1000, z: 1000 },
target: { x: 0, y: 0, z: 0 },
},
{ x: 320, y: -180, z: 240 },
4000,
{ orbitSeed: 0.82 }
)
assert.deepEqual(pose.target, { x: 320, y: -180, z: 240 })
assert.ok(pose.position.x > pose.target.x)
assert.ok(pose.position.y < pose.target.y)
assert.ok(pose.position.z > pose.target.z)
assert.ok(
Math.abs(
Math.hypot(pose.position.x - pose.target.x, pose.position.y - pose.target.y, pose.position.z - pose.target.z) -
1120
) < 0.001
)
const reversePose = buildRelationshipGalaxy3DFocusCameraPose(
{
position: { x: 0, y: -1000, z: 1000 },
target: { x: 0, y: 0, z: 0 },
},
{ x: 320, y: -180, z: 240 },
4000,
{ orbitSeed: 0.18 }
)
assert.ok(reversePose.position.x < reversePose.target.x)
})
test('backs up enough to frame a wide selected relationship neighborhood', () => {
const pose = buildRelationshipGalaxy3DFocusCameraPose(
{
position: { x: 0, y: -1000, z: 1000 },
target: { x: 0, y: 0, z: 0 },
},
{ x: 0, y: 0, z: 0 },
3400,
{
orbitSeed: 0.5,
focusPoints: [
{ x: 0, y: 0, z: 0 },
{ x: -900, y: 0, z: 0 },
{ x: 900, y: 0, z: 0 },
{ x: 0, y: 500, z: -500 },
],
fovDegrees: 45,
aspectRatio: 1.6,
}
)
const distance = Math.hypot(pose.position.x, pose.position.y, pose.position.z)
assert.ok(distance > 1500)
assert.ok(distance < 1700)
})
test('centers a selected relationship neighborhood before fitting the camera', () => {
const frame = buildRelationshipGalaxy3DFocusFrame(
[
{ x: -800, y: -200, z: 100 },
{ x: 1200, y: 600, z: -300 },
{ x: 200, y: 100, z: 500 },
],
{ x: 0, y: 0, z: 0 }
)
assert.deepEqual(frame.target, { x: 200, y: 200, z: 100 })
})
test('keeps a distant weak relationship from pulling a dense neighborhood off center', () => {
const corePoints = Array.from({ length: 20 }, (_, index) => ({
x: (index - 10) * 24,
y: ((index % 5) - 2) * 36,
z: ((index % 4) - 2) * 28,
}))
const frame = buildRelationshipGalaxy3DFocusFrame([...corePoints, { x: 3000, y: 0, z: 0 }], { x: 0, y: 0, z: 0 })
assert.ok(Math.abs(frame.target.x) < 200)
})
test('keeps dense relationship framing at a readable scale despite distant outliers', () => {
const focusPoints = [
...Array.from({ length: 24 }, (_, index) => ({
x: (index - 12) * 36,
y: ((index % 6) - 3) * 48,
z: ((index % 5) - 2) * 42,
})),
{ x: 3200, y: 1800, z: -1200 },
]
const pose = buildRelationshipGalaxy3DFocusCameraPose(
{
position: { x: 0, y: -1000, z: 1000 },
target: { x: 0, y: 0, z: 0 },
},
{ x: 0, y: 0, z: 0 },
3400,
{ orbitSeed: 0.6, focusPoints, fovDegrees: 45, aspectRatio: 1.7 }
)
const distance = Math.hypot(pose.position.x, pose.position.y, pose.position.z)
assert.ok(distance < 2000)
})
test('builds a 3D camera view offset that moves the focus into the visible area', () => {
assert.deepEqual(
buildRelationshipGalaxy3DViewOffset({
@@ -12,6 +12,17 @@ export interface RelationshipGalaxy3DCameraPose {
target: RelationshipGalaxy3DVector
}
export interface RelationshipGalaxy3DFocusFrame {
target: RelationshipGalaxy3DVector
}
export interface RelationshipGalaxy3DFocusCameraOptions {
orbitSeed?: number
focusPoints?: readonly RelationshipGalaxy3DVector[]
fovDegrees?: number
aspectRatio?: number
}
export interface RelationshipGalaxy3DSafeAreaOptions {
viewportWidth: number
viewportHeight: number
@@ -31,6 +42,9 @@ export interface RelationshipGalaxy3DViewOffset {
// The default 3D galaxy view intentionally uses tight, immersive framing.
// Edge nodes may sit near or slightly beyond the viewport instead of always being fully contained.
const IMMERSIVE_CAMERA_PADDING_SCALE = 0.9
const DENSE_FOCUS_MIN_POINTS = 9
const DENSE_FOCUS_COVERAGE = 0.9
const FOCUS_MAX_DISTANCE_SCALE = 0.48
export function buildRelationshipGalaxy3DImmersiveCameraPose(
bounds: RelationshipGalaxy3DScene['bounds']
@@ -47,6 +61,114 @@ export function buildRelationshipGalaxy3DImmersiveCameraPose(
}
}
export function buildRelationshipGalaxy3DFocusCameraPose(
currentPose: RelationshipGalaxy3DCameraPose,
target: RelationshipGalaxy3DVector,
sceneSpan: number,
options: RelationshipGalaxy3DFocusCameraOptions = {}
): RelationshipGalaxy3DCameraPose {
const currentViewDirection = normalizeVector(subtractVector(currentPose.position, currentPose.target))
const normalizedSeed = clamp(options.orbitSeed ?? 0.5, 0, 1)
const orbitDirection = normalizedSeed < 0.5 ? -1 : 1
const orbitAngle = orbitDirection * (0.3 + Math.abs(normalizedSeed - 0.5) * 0.28)
const rotatedDirection = rotateVectorAroundY(currentViewDirection, orbitAngle)
const viewDirection = normalizeVector({
...rotatedDirection,
y: rotatedDirection.y + (normalizedSeed - 0.5) * 0.16,
})
const baseDistance = clamp(sceneSpan * 0.28, 560, 1400)
const framingDistance = deriveRelationshipGalaxy3DFramingDistance(
options.focusPoints ?? [],
target,
viewDirection,
options.fovDegrees ?? 45,
options.aspectRatio ?? 1
)
const comfortableMaxDistance = clamp(sceneSpan * FOCUS_MAX_DISTANCE_SCALE, 1100, 2000)
const distance = clamp(Math.max(baseDistance, Math.min(framingDistance, comfortableMaxDistance)), 560, 2200)
return {
position: addVector(target, multiplyVector(viewDirection, distance)),
target,
}
}
export function buildRelationshipGalaxy3DFocusFrame(
points: readonly RelationshipGalaxy3DVector[],
fallbackTarget: RelationshipGalaxy3DVector
): RelationshipGalaxy3DFocusFrame {
if (points.length === 0) return { target: fallbackTarget }
const xRange = deriveRelationshipGalaxy3DFocusRange(points.map((point) => point.x))
const yRange = deriveRelationshipGalaxy3DFocusRange(points.map((point) => point.y))
const zRange = deriveRelationshipGalaxy3DFocusRange(points.map((point) => point.z))
const boundsCenter = {
x: (xRange.min + xRange.max) / 2,
y: (yRange.min + yRange.max) / 2,
z: (zRange.min + zRange.max) / 2,
}
const selectedBias = points.length >= DENSE_FOCUS_MIN_POINTS ? 0.2 : 0
const target = {
x: boundsCenter.x * (1 - selectedBias) + fallbackTarget.x * selectedBias,
y: boundsCenter.y * (1 - selectedBias) + fallbackTarget.y * selectedBias,
z: boundsCenter.z * (1 - selectedBias) + fallbackTarget.z * selectedBias,
}
return { target }
}
function deriveRelationshipGalaxy3DFramingDistance(
points: readonly RelationshipGalaxy3DVector[],
target: RelationshipGalaxy3DVector,
viewDirection: RelationshipGalaxy3DVector,
fovDegrees: number,
aspectRatio: number
): number {
if (points.length <= 1) return 0
const forward = multiplyVector(viewDirection, -1)
const worldUp = Math.abs(forward.y) > 0.98 ? { x: 0, y: 0, z: 1 } : { x: 0, y: 1, z: 0 }
const right = normalizeVector(crossVector(forward, worldUp))
const up = normalizeVector(crossVector(right, forward))
const verticalTangent = Math.tan((clamp(fovDegrees, 20, 100) * Math.PI) / 360)
const horizontalTangent = verticalTangent * clamp(aspectRatio, 0.4, 4)
const padding = 1.1
const requiredDistances = points.map((point) => {
const offset = subtractVector(point, target)
const towardCamera = dotVector(offset, viewDirection)
const horizontalDistance = Math.abs(dotVector(offset, right))
const verticalDistance = Math.abs(dotVector(offset, up))
return Math.max(
towardCamera + (horizontalDistance * padding) / horizontalTangent,
towardCamera + (verticalDistance * padding) / verticalTangent
)
})
requiredDistances.sort((a, b) => a - b)
const coverage = points.length >= DENSE_FOCUS_MIN_POINTS ? DENSE_FOCUS_COVERAGE : 1
const index = Math.max(0, Math.ceil(requiredDistances.length * coverage) - 1)
return Math.max(0, requiredDistances[index] ?? 0)
}
function deriveRelationshipGalaxy3DFocusRange(values: number[]): { min: number; max: number } {
const sorted = [...values].sort((a, b) => a - b)
if (sorted.length === 0) return { min: 0, max: 0 }
const trimCount = sorted.length >= DENSE_FOCUS_MIN_POINTS ? Math.floor(sorted.length * 0.1) : 0
return {
min: sorted[trimCount] ?? sorted[0],
max: sorted[sorted.length - 1 - trimCount] ?? sorted[sorted.length - 1],
}
}
function rotateVectorAroundY(vector: RelationshipGalaxy3DVector, angle: number): RelationshipGalaxy3DVector {
const cosine = Math.cos(angle)
const sine = Math.sin(angle)
return {
x: vector.x * cosine + vector.z * sine,
y: vector.y,
z: -vector.x * sine + vector.z * cosine,
}
}
export function applyRelationshipGalaxy3DSafeArea(
pose: RelationshipGalaxy3DCameraPose,
options: RelationshipGalaxy3DSafeAreaOptions
@@ -102,6 +224,14 @@ function addVector(a: RelationshipGalaxy3DVector, b: RelationshipGalaxy3DVector)
}
}
function subtractVector(a: RelationshipGalaxy3DVector, b: RelationshipGalaxy3DVector): RelationshipGalaxy3DVector {
return {
x: a.x - b.x,
y: a.y - b.y,
z: a.z - b.z,
}
}
function multiplyVector(vector: RelationshipGalaxy3DVector, scalar: number): RelationshipGalaxy3DVector {
return {
x: vector.x * scalar,
@@ -110,6 +240,18 @@ function multiplyVector(vector: RelationshipGalaxy3DVector, scalar: number): Rel
}
}
function crossVector(a: RelationshipGalaxy3DVector, b: RelationshipGalaxy3DVector): RelationshipGalaxy3DVector {
return {
x: a.y * b.z - a.z * b.y,
y: a.z * b.x - a.x * b.z,
z: a.x * b.y - a.y * b.x,
}
}
function dotVector(a: RelationshipGalaxy3DVector, b: RelationshipGalaxy3DVector): number {
return a.x * b.x + a.y * b.y + a.z * b.z
}
function distanceBetween(a: RelationshipGalaxy3DVector, b: RelationshipGalaxy3DVector): number {
return Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z)
}
@@ -123,3 +265,7 @@ function normalizeVector(vector: RelationshipGalaxy3DVector): RelationshipGalaxy
z: vector.z / length,
}
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
@@ -3,9 +3,13 @@ import test from 'node:test'
import type { RelationshipGalaxyRenderNode } from '@openchatlab/shared-types'
import {
applyRelationshipGalaxy3DCameraViewOffset,
buildRelationshipGalaxy3DSceneLayoutSignature,
captureRelationshipGalaxy3DCameraView,
getRelationshipGalaxy3DDynamicLabelTier,
getRelationshipGalaxy3DZoomLabelRankLimit,
hasExceededRelationshipGalaxyPointerDragThreshold,
parseRelationshipGalaxy3DCameraView,
resolveRelationshipGalaxyPointerClickAction,
} from './relationship-galaxy-3d-canvas'
import { buildRelationshipGalaxy3DScene } from './relationship-galaxy-3d-scene'
@@ -59,6 +63,18 @@ test('shows a temporary 3D label for a hovered node outside the persistent label
assert.equal(getRelationshipGalaxy3DDynamicLabelTier(sceneNode, 'owner', null, new Set(['owner'])), 0)
})
test('progressively reveals additional labels only after the user zooms into the star cloud', () => {
const sceneNode = {
...buildRelationshipGalaxy3DScene({ nodes: [node('bob', 12)], edges: [], communities: [] }).nodes[0],
labelTier: 0 as const,
}
assert.equal(getRelationshipGalaxy3DZoomLabelRankLimit(700, 1000), 0)
assert.equal(getRelationshipGalaxy3DZoomLabelRankLimit(430, 1000), 16)
assert.equal(getRelationshipGalaxy3DZoomLabelRankLimit(300, 1000), 32)
assert.equal(getRelationshipGalaxy3DDynamicLabelTier(sceneNode, null, null, null, 16), 1)
})
test('captures and validates a restorable 3D panorama view', () => {
const view = captureRelationshipGalaxy3DCameraView({ x: 1, y: 2, z: 3 }, { x: 4, y: 5, z: 6 }, true)
@@ -66,3 +82,48 @@ test('captures and validates a restorable 3D panorama view', () => {
assert.equal(parseRelationshipGalaxy3DCameraView({ ...view, position: { x: Number.NaN, y: 2, z: 3 } }), null)
assert.equal(captureRelationshipGalaxy3DCameraView(null, { x: 0, y: 0, z: 0 }, false), null)
})
test('distinguishes an intentional canvas drag from a slightly shaky click', () => {
const start = { x: 120, y: 80 }
assert.equal(hasExceededRelationshipGalaxyPointerDragThreshold(start, { x: 123, y: 84 }), false)
assert.equal(hasExceededRelationshipGalaxyPointerDragThreshold(start, { x: 132, y: 88 }), true)
})
test('keeps blank canvas clicks inert and only selects an actual node click', () => {
assert.deepEqual(resolveRelationshipGalaxyPointerClickAction(null, false), { type: 'ignore' })
assert.deepEqual(resolveRelationshipGalaxyPointerClickAction('alice', true), { type: 'ignore' })
assert.deepEqual(resolveRelationshipGalaxyPointerClickAction('alice', false), { type: 'select', key: 'alice' })
})
test('keeps the 3D geometry stable while selection and relationship edges update', () => {
const alice = node('alice', 1, 2)
const bob = node('bob', 2, 1)
const baseGraph = {
nodes: [alice, bob],
edges: [],
communities: [{ id: 'friends', label: 'Friends', size: 2, x: 0, y: 0, color: '#38bdf8' }],
}
const expandedGraph = {
...baseGraph,
nodes: [{ ...alice, displayName: 'Alice' }, bob],
edges: [{ id: 'alice:bob', sourceKey: 'alice', targetKey: 'bob', weight: 1, visibility: 2 as const }],
}
const panoramaSignature = buildRelationshipGalaxy3DSceneLayoutSignature(buildRelationshipGalaxy3DScene(baseGraph))
const selectedSignature = buildRelationshipGalaxy3DSceneLayoutSignature(
buildRelationshipGalaxy3DScene(expandedGraph, { selectedKey: 'alice' })
)
assert.equal(selectedSignature, panoramaSignature)
})
test('requires a 3D geometry rebuild when the node layout changes', () => {
const baseGraph = { nodes: [node('alice', 1)], edges: [], communities: [] }
const expandedGraph = { nodes: [...baseGraph.nodes, node('bob', 2)], edges: [], communities: [] }
assert.notEqual(
buildRelationshipGalaxy3DSceneLayoutSignature(buildRelationshipGalaxy3DScene(baseGraph)),
buildRelationshipGalaxy3DSceneLayoutSignature(buildRelationshipGalaxy3DScene(expandedGraph))
)
})
@@ -1,18 +1,78 @@
import { buildRelationshipGalaxy3DViewOffset } from './relationship-galaxy-3d-camera'
import type { RelationshipGalaxy3DNode } from './relationship-galaxy-3d-scene'
import type { RelationshipGalaxy3DNode, RelationshipGalaxy3DScene } from './relationship-galaxy-3d-scene'
/**
* 只描述需要重建 Three.js 几何层的空间状态。
*
* 选中节点、关系边和名称变化都不应改变该签名,这些状态可以在原场景内增量更新;
* 节点坐标、颜色或星团光雾变化时才需要完整重建。
*/
export function buildRelationshipGalaxy3DSceneLayoutSignature(model: RelationshipGalaxy3DScene): string {
const nodes = [...model.nodes]
.sort((a, b) => a.key.localeCompare(b.key))
.map((node) => [node.key, node.x, node.y, node.z, node.color, node.seed])
const communities = [...model.communities]
.sort((a, b) => a.id.localeCompare(b.id))
.map((community) => [
community.id,
community.x,
community.y,
community.z,
community.radius,
community.color,
community.opacity,
community.nodeCount,
])
return JSON.stringify({ nodes, communities, bounds: model.bounds })
}
export interface RelationshipGalaxyPointerPosition {
x: number
y: number
}
export function hasExceededRelationshipGalaxyPointerDragThreshold(
start: RelationshipGalaxyPointerPosition,
current: RelationshipGalaxyPointerPosition,
threshold = 6
): boolean {
return Math.hypot(current.x - start.x, current.y - start.y) >= threshold
}
export type RelationshipGalaxyPointerClickAction = { type: 'ignore' } | { type: 'select'; key: string }
export function resolveRelationshipGalaxyPointerClickAction(
hoveredKey: string | null,
pointerMoved: boolean
): RelationshipGalaxyPointerClickAction {
if (pointerMoved || !hoveredKey) return { type: 'ignore' }
return { type: 'select', key: hoveredKey }
}
export function getRelationshipGalaxy3DDynamicLabelTier(
sceneNode: RelationshipGalaxy3DNode,
selectedKey: string | null,
hoveredKey: string | null,
selectedVisibleLabelKeys: ReadonlySet<string> | null
selectedVisibleLabelKeys: ReadonlySet<string> | null,
zoomLabelRankLimit = 0
): 0 | 1 | 2 {
if (sceneNode.key === hoveredKey) return sceneNode.labelTier === 2 ? 2 : 1
if (!selectedKey) return sceneNode.labelTier
if (!selectedKey) {
if (sceneNode.labelTier > 0) return sceneNode.labelTier
return zoomLabelRankLimit > 0 && sceneNode.node.rank <= zoomLabelRankLimit ? 1 : 0
}
if (!selectedVisibleLabelKeys?.has(sceneNode.key)) return 0
return sceneNode.key === selectedKey || sceneNode.node.visualRole === 'anchor' ? 2 : 1
}
export function getRelationshipGalaxy3DZoomLabelRankLimit(cameraDistance: number, sceneSpan: number): number {
const normalizedDistance = cameraDistance / Math.max(1, sceneSpan)
if (normalizedDistance < 0.34) return 32
if (normalizedDistance < 0.48) return 16
return 0
}
export interface RelationshipGalaxy3DProjectionCamera {
clearViewOffset: () => void
setViewOffset: (
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type {
RelationshipGalaxy3DEdge,
RelationshipGalaxy3DNode,
RelationshipGalaxy3DScene,
} from './relationship-galaxy-3d-scene'
import {
buildRelationshipGalaxy3DAmbientParticles,
buildRelationshipGalaxy3DFogVeils,
selectRelationshipGalaxy3DAmbientEdgeIds,
selectRelationshipGalaxy3DPrimarySelectedEdgeIds,
selectRelationshipGalaxy3DSelectedEdgeIds,
} from './relationship-galaxy-3d-environment'
function createScene(nodeCount: number, edgeCount: number): RelationshipGalaxy3DScene {
const nodes = Array.from(
{ length: nodeCount },
(_, index): RelationshipGalaxy3DNode => ({
key: `node-${index}`,
node: {
key: `node-${index}`,
displayName: `Node ${index}`,
avatar: null,
score: 1,
rank: index + 1,
communityId: `community-${index % 4}`,
x: 0,
y: 0,
size: 6,
color: '#7dd3fc',
labelVisibility: 0,
},
x: index * 10,
y: index * 4,
z: index * 2,
radius: 4,
color: 0x7dd3fc,
state: 'normal',
labelTier: 0,
opacity: 0.8,
seed: index / Math.max(1, nodeCount),
})
)
const edges = Array.from(
{ length: edgeCount },
(_, index): RelationshipGalaxy3DEdge => ({
edge: {
id: `edge-${index}`,
sourceKey: nodes[index % nodeCount]?.key ?? 'node-0',
targetKey: nodes[(index + 1) % nodeCount]?.key ?? 'node-0',
weight: edgeCount - index,
visibility: index % 4 === 0 ? 2 : 1,
},
source: nodes[index % nodeCount]!,
target: nodes[(index + 1) % nodeCount]!,
color: 0x7dd3fc,
alpha: 0.1,
width: 1,
highlighted: index < 3,
})
)
return {
nodes,
edges,
communities: [],
selectedNeighborKeys: new Set(),
bounds: {
minX: -1200,
maxX: 1200,
minY: -700,
maxY: 700,
minZ: -350,
maxZ: 350,
width: 2400,
height: 1400,
depth: 700,
},
}
}
test('builds a deterministic non-interactive ambient field around the data scene', () => {
const scene = createScene(80, 0)
const first = buildRelationshipGalaxy3DAmbientParticles(scene)
const second = buildRelationshipGalaxy3DAmbientParticles(scene)
assert.deepEqual(first, second)
assert.ok(first.length >= 900)
assert.ok(first.length <= 2200)
assert.ok(first.every((particle) => particle.opacity < 0.6))
assert.ok(first.every((particle) => particle.size <= 12))
})
test('builds restrained foreground and background fog veils around communities', () => {
const scene = createScene(80, 0)
scene.communities = [
{ id: 'a', x: -200, y: 100, z: 40, radius: 420, color: 0x72b8ff, opacity: 0.14, nodeCount: 50 },
{ id: 'b', x: 360, y: -120, z: -80, radius: 300, color: 0xff86ad, opacity: 0.12, nodeCount: 30 },
]
const first = buildRelationshipGalaxy3DFogVeils(scene)
const second = buildRelationshipGalaxy3DFogVeils(scene)
assert.deepEqual(first, second)
assert.equal(first.length, 6)
assert.equal(
first.some((veil) => veil.foreground),
true
)
assert.equal(
first.some((veil) => !veil.foreground),
true
)
assert.equal(
first.every((veil) => veil.opacity >= 0.026 && veil.opacity <= 0.082),
true
)
})
test('keeps the panorama edge hierarchy bounded while preserving the strongest relationships', () => {
const scene = createScene(100, 500)
const visible = selectRelationshipGalaxy3DAmbientEdgeIds(scene, false)
assert.equal(visible.size, 170)
assert.equal(visible.has('edge-0'), true)
assert.equal(visible.has('edge-499'), false)
})
test('keeps only direct relationship lines in the selected overlay', () => {
const scene = createScene(100, 120)
const selected = selectRelationshipGalaxy3DSelectedEdgeIds(scene)
const primary = selectRelationshipGalaxy3DPrimarySelectedEdgeIds(scene, 2)
assert.deepEqual([...selected], ['edge-0', 'edge-1', 'edge-2'])
assert.deepEqual([...primary], ['edge-0', 'edge-1'])
})
@@ -0,0 +1,155 @@
import type { RelationshipGalaxy3DEdge, RelationshipGalaxy3DScene } from './relationship-galaxy-3d-scene'
export interface RelationshipGalaxy3DAmbientParticle {
x: number
y: number
z: number
size: number
opacity: number
color: number
phase: number
}
export interface RelationshipGalaxy3DFogVeil {
x: number
y: number
z: number
width: number
height: number
opacity: number
color: number
rotation: number
foreground: boolean
}
const AMBIENT_COLORS = [0xf5f8ff, 0x9bbcff, 0xff6fa8, 0xffb36b, 0xb59cff, 0x74ddcf]
export function buildRelationshipGalaxy3DAmbientParticles(
model: RelationshipGalaxy3DScene
): RelationshipGalaxy3DAmbientParticle[] {
const count = clamp(Math.round(900 + model.nodes.length * 3.2), 900, 2200)
const random = createDeterministicRandom(
model.nodes
.map((node) => node.key)
.sort()
.join('|')
)
const xRadius = Math.max(900, model.bounds.width * 0.62)
const yRadius = Math.max(560, model.bounds.height * 0.72)
const zRadius = Math.max(420, model.bounds.depth * 0.72)
return Array.from({ length: count }, () => {
const angle = random() * Math.PI * 2
const distance = Math.pow(random(), 0.62)
const heightNoise = (random() - 0.5) * 2
const color = AMBIENT_COLORS[Math.floor(random() * AMBIENT_COLORS.length)] ?? AMBIENT_COLORS[0]
return {
x: roundNum(Math.cos(angle) * xRadius * distance + (random() - 0.5) * xRadius * 0.12),
y: roundNum(Math.sin(angle) * yRadius * distance + heightNoise * yRadius * 0.14),
z: roundNum((random() - 0.5) * zRadius * 2),
size: roundNum(3.4 + Math.pow(random(), 2.2) * 8.6),
opacity: roundNum(0.16 + Math.pow(random(), 1.5) * 0.42, 3),
color,
phase: roundNum(random() * Math.PI * 2, 4),
}
})
}
export function buildRelationshipGalaxy3DFogVeils(model: RelationshipGalaxy3DScene): RelationshipGalaxy3DFogVeil[] {
const random = createDeterministicRandom(
`fog:${model.nodes
.map((node) => node.key)
.sort()
.join('|')}`
)
return model.communities.flatMap((community, communityIndex) =>
Array.from({ length: 3 }, (_, layerIndex) => {
const angle = random() * Math.PI * 2
const offset = community.radius * (0.16 + random() * 0.48)
const foreground = (communityIndex + layerIndex) % 3 === 0
return {
x: roundNum(community.x + Math.cos(angle) * offset),
y: roundNum(community.y + Math.sin(angle) * offset * 0.72),
z: roundNum(community.z + (random() - 0.5) * community.radius * 1.6),
width: roundNum(community.radius * (2.2 + random() * 1.5)),
height: roundNum(community.radius * (1.05 + random() * 0.75)),
opacity: roundNum(
clamp(community.opacity * (foreground ? 0.26 + random() * 0.12 : 0.38 + random() * 0.18), 0.026, 0.082),
3
),
color: community.color,
rotation: roundNum((random() - 0.5) * 0.85, 3),
foreground,
}
})
)
}
export function selectRelationshipGalaxy3DAmbientEdgeIds(
model: RelationshipGalaxy3DScene,
emphasizeEdges: boolean
): Set<string> {
const limit = clamp(Math.round(model.nodes.length * (emphasizeEdges ? 2.4 : 1.7)), 32, emphasizeEdges ? 260 : 200)
return new Set(
[...model.edges]
.sort(compareAmbientEdges)
.slice(0, limit)
.map((edge) => edge.edge.id)
)
}
export function selectRelationshipGalaxy3DSelectedEdgeIds(model: RelationshipGalaxy3DScene): Set<string> {
return new Set(model.edges.filter((edge) => edge.highlighted).map((edge) => edge.edge.id))
}
export function selectRelationshipGalaxy3DPrimarySelectedEdgeIds(
model: RelationshipGalaxy3DScene,
limit = 24
): Set<string> {
return new Set(
model.edges
.filter((edge) => edge.highlighted)
.sort(compareAmbientEdges)
.slice(0, limit)
.map((edge) => edge.edge.id)
)
}
function compareAmbientEdges(a: RelationshipGalaxy3DEdge, b: RelationshipGalaxy3DEdge): number {
return (
Number(b.edge.visibility) - Number(a.edge.visibility) ||
b.edge.weight - a.edge.weight ||
a.edge.id.localeCompare(b.edge.id)
)
}
function createDeterministicRandom(seedText: string): () => number {
let state = hashToUint(seedText || 'relationship-galaxy') || 0x6d2b79f5
return () => {
state += 0x6d2b79f5
let value = state
value = Math.imul(value ^ (value >>> 15), value | 1)
value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
return ((value ^ (value >>> 14)) >>> 0) / 0x100000000
}
}
function hashToUint(value: string): number {
let hash = 2166136261
for (let i = 0; i < value.length; i += 1) {
hash ^= value.charCodeAt(i)
hash = Math.imul(hash, 16777619)
}
return hash >>> 0
}
function roundNum(value: number, precision = 2): number {
const factor = 10 ** precision
return Math.round(value * factor) / factor
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
@@ -116,7 +116,7 @@ test('fills a volumetric panorama instead of flattening the backend 2D layout',
assert.ok(scene.bounds.depth > scene.bounds.height * 0.8)
})
test('uses a horizontal panorama ellipsoid while keeping selected neighborhoods spherical', () => {
test('keeps every node at its panorama coordinate after selection', () => {
const nodes = Array.from({ length: 60 }, (_, index) =>
node({
key: `weixin:wide-${index}`,
@@ -134,11 +134,14 @@ test('uses a horizontal panorama ellipsoid while keeping selected neighborhoods
assert.ok(panorama.bounds.width > panorama.bounds.height * 1.25)
assert.ok(panorama.bounds.depth > panorama.bounds.height * 0.9)
assert.equal(selected.bounds.width, selected.bounds.height)
assert.equal(selected.bounds.height, selected.bounds.depth)
assert.deepEqual(selected.bounds, panorama.bounds)
assert.deepEqual(
selected.nodes.map((item) => [item.key, item.x, item.y, item.z]),
panorama.nodes.map((item) => [item.key, item.x, item.y, item.z])
)
})
test('highlights selected node neighbors and omits unrelated nodes', () => {
test('highlights selected node neighbors while preserving unrelated background nodes', () => {
const graph: PeopleRelationshipsGraphData = {
nodes: [
node({ key: 'weixin:alice', rank: 1, score: 0.92 }),
@@ -156,7 +159,7 @@ test('highlights selected node neighbors and omits unrelated nodes', () => {
assert.equal(alice?.state, 'selected')
assert.equal(bob?.state, 'neighbor')
assert.equal(chen, undefined)
assert.equal(chen?.state, 'dimmed')
assert.ok(scene.edges[0].highlighted)
assert.ok(scene.edges[0].alpha > 0.3)
})
@@ -215,7 +218,7 @@ test('keeps owner at the 3D panorama center when compacting asymmetric layouts',
assert.ok(scene.bounds.width <= 3600)
})
test('keeps selected contact at the 3D panorama center before owner when focusing a neighborhood', () => {
test('keeps selected contact at its original panorama position', () => {
const graph: PeopleRelationshipsGraphData = {
nodes: [
node({ key: 'weixin:owner', kind: 'owner', rank: 1, score: 1, x: -1200, y: 0 }),
@@ -226,12 +229,16 @@ test('keeps selected contact at the 3D panorama center before owner when focusin
communities: [],
}
const scene = buildRelationshipGalaxy3DScene(graph, { selectedKey: 'weixin:alice' })
const selected = scene.nodes.find((item) => item.key === 'weixin:alice')
const panorama = buildRelationshipGalaxy3DScene(graph)
const selectedScene = buildRelationshipGalaxy3DScene(graph, { selectedKey: 'weixin:alice' })
const panoramaNode = panorama.nodes.find((item) => item.key === 'weixin:alice')
const selectedNode = selectedScene.nodes.find((item) => item.key === 'weixin:alice')
assert.equal(selected?.x, 0)
assert.equal(selected?.y, 0)
assert.equal(selected?.z, 0)
assert.notDeepEqual([panoramaNode?.x, panoramaNode?.y, panoramaNode?.z], [0, 0, 0])
assert.deepEqual(
[selectedNode?.x, selectedNode?.y, selectedNode?.z],
[panoramaNode?.x, panoramaNode?.y, panoramaNode?.z]
)
})
test('keeps selected labels scoped to direct relationship contacts', () => {
@@ -249,13 +256,28 @@ test('keeps selected labels scoped to direct relationship contacts', () => {
assert.equal(scene.nodes.find((item) => item.key === 'weixin:selected')?.labelTier, 2)
assert.equal(scene.nodes.find((item) => item.key === 'weixin:important')?.labelTier, 1)
assert.equal(
scene.nodes.find((item) => item.key === 'weixin:quiet'),
undefined
)
assert.equal(scene.nodes.find((item) => item.key === 'weixin:quiet')?.labelTier, 0)
})
test('limits selected relationship labels to the top eighty direct contacts', () => {
test('keeps the default panorama readable with at most eight persistent labels', () => {
const nodes = Array.from({ length: 60 }, (_, index) =>
node({
key: `weixin:label-${index}`,
rank: index + 1,
score: Math.max(0.1, 1 - index / 70),
labelVisibility: index < 30 ? 2 : 1,
communityId: `community-${index % 5}`,
})
)
const scene = buildRelationshipGalaxy3DScene({ nodes, edges: [], communities: [] })
assert.equal(scene.nodes.filter((item) => item.labelTier > 0).length, 8)
assert.equal(scene.nodes.find((item) => item.key === 'weixin:label-0')?.labelTier, 2)
assert.equal(scene.nodes.find((item) => item.key === 'weixin:label-8')?.labelTier, 0)
})
test('limits selected relationship labels to the top twelve direct contacts', () => {
const nodes = [
node({ key: 'weixin:selected', rank: 1, labelVisibility: 0 }),
...Array.from({ length: 100 }, (_, index) =>
@@ -279,12 +301,12 @@ test('limits selected relationship labels to the top eighty direct contacts', ()
.filter((item) => item.key !== 'weixin:selected' && item.labelTier > 0)
.map((item) => item.key)
assert.equal(labeledPeerKeys.length, 80)
assert.equal(labeledPeerKeys.length, 12)
assert.equal(labeledPeerKeys.includes('weixin:peer-100'), true)
assert.equal(labeledPeerKeys.includes('weixin:peer-20'), false)
assert.equal(labeledPeerKeys.includes('weixin:peer-88'), false)
})
test('renders only selected and visible related contacts in selected 3D scene', () => {
test('preserves the complete graph while selecting a relationship node', () => {
const nodes = [
node({ key: 'weixin:selected', rank: 1, labelVisibility: 0 }),
...Array.from({ length: 100 }, (_, index) =>
@@ -308,16 +330,11 @@ test('renders only selected and visible related contacts in selected 3D scene',
]
const scene = buildRelationshipGalaxy3DScene({ nodes, edges, communities: [] }, { selectedKey: 'weixin:selected' })
const visibleKeys = new Set(scene.nodes.map((item) => item.key))
assert.equal(scene.nodes.length, 81)
assert.equal(visibleKeys.has('weixin:selected'), true)
assert.equal(visibleKeys.has('weixin:peer-100'), true)
assert.equal(visibleKeys.has('weixin:peer-20'), false)
assert.equal(
scene.edges.every((item) => visibleKeys.has(item.edge.sourceKey) && visibleKeys.has(item.edge.targetKey)),
true
)
assert.equal(scene.nodes.length, nodes.length)
assert.equal(scene.edges.length, edges.length)
assert.equal(scene.nodes.find((item) => item.key === 'weixin:selected')?.state, 'selected')
assert.equal(scene.nodes.find((item) => item.key === 'weixin:peer-100')?.state, 'neighbor')
assert.equal(scene.nodes.find((item) => item.key === 'weixin:peer-20')?.state, 'neighbor')
assert.equal(
scene.edges.some((item) => item.edge.sourceKey === 'weixin:peer-100' && item.edge.targetKey === 'weixin:peer-99'),
true
@@ -362,8 +379,12 @@ test('keeps rendered selected relationship labels aligned with the scene label t
true
)
assert.equal(
scene.nodes.find((item) => item.key === 'weixin:peer-20'),
undefined
shouldRenderRelationshipGalaxy3DLabel(
scene.nodes.find((item) => item.key === 'weixin:peer-20')!,
'weixin:selected',
true
),
false
)
})
@@ -384,15 +405,25 @@ test('renders selected-scene related labels from label tier even without recompu
assert.equal(shouldRenderRelationshipGalaxy3DLabel(peer, 'weixin:selected', false), true)
})
test('uses varied node colors, larger important nodes, and no glow field', () => {
test('uses restrained community colors, larger important nodes, and semantic glow fields', () => {
const graph: PeopleRelationshipsGraphData = {
nodes: [
node({ key: 'weixin:owner', kind: 'owner', rank: 1, score: 1, color: '#38bdf8' }),
node({ key: 'weixin:friend', rank: 2, pool: 'friend', score: 0.9, color: '#2563eb' }),
node({ key: 'weixin:groupmate', rank: 80, pool: 'non_friend', score: 0.2, color: '#22d3ee' }),
node({ key: 'weixin:owner', kind: 'owner', rank: 1, score: 1, color: '#38bdf8', communityId: 'core' }),
node({ key: 'weixin:friend', rank: 2, pool: 'friend', score: 0.9, color: '#2563eb', communityId: 'core' }),
node({
key: 'weixin:groupmate',
rank: 80,
pool: 'non_friend',
score: 0.2,
color: '#22d3ee',
communityId: 'outer',
}),
],
edges: [],
communities: [],
communities: [
{ id: 'core', label: 'Core', size: 2, x: 0, y: 0, color: '#7dd3fc' },
{ id: 'outer', label: 'Outer', size: 1, x: 0, y: 0, color: '#f0abfc' },
],
}
const scene = buildRelationshipGalaxy3DScene(graph)
@@ -401,15 +432,34 @@ test('uses varied node colors, larger important nodes, and no glow field', () =>
const groupmate = scene.nodes.find((item) => item.key === 'weixin:groupmate')
assert.equal(owner?.color, 0xf8fbff)
assert.ok((friend?.color ?? 0) !== 0x2563eb)
assert.ok((groupmate?.color ?? 0) !== 0x22d3ee)
assert.equal(friend?.color, 0x7dd3fc)
assert.equal(groupmate?.color, 0xf0abfc)
assert.ok(new Set(scene.nodes.map((item) => item.color)).size >= 3)
assert.ok((owner?.radius ?? 0) > (groupmate?.radius ?? 0) * 2)
assert.equal(Object.hasOwn(owner ?? {}, 'glow'), false)
assert.equal(Object.hasOwn(groupmate ?? {}, 'glow'), false)
assert.equal(scene.communities.length, 2)
assert.ok(scene.communities.every((community) => community.radius >= 260 && community.opacity > 0))
})
test('uses a broad deterministic multi-color palette for contact stars', () => {
test('caps high-signal node sizes so selected stars do not obscure their relationship neighborhood', () => {
const graph: PeopleRelationshipsGraphData = {
nodes: [
node({ key: 'weixin:selected', rank: 1, score: 1, size: 96 }),
node({ key: 'weixin:peer', rank: 2, score: 0.9, size: 72 }),
],
edges: [edge({ sourceKey: 'weixin:selected', targetKey: 'weixin:peer', weight: 2 })],
communities: [],
}
const panorama = buildRelationshipGalaxy3DScene(graph)
const selected = buildRelationshipGalaxy3DScene(graph, { selectedKey: 'weixin:selected' })
assert.ok(panorama.nodes.every((item) => item.radius <= 18))
assert.ok(selected.nodes.find((item) => item.key === 'weixin:selected')!.radius <= 22)
assert.ok(selected.nodes.find((item) => item.key === 'weixin:peer')!.radius <= 20)
})
test('keeps one stable color family inside each relationship community', () => {
const colors = ['#ff86ad', '#72b8ff', '#9f8cff', '#67d9d0', '#f2c879', '#8fdaa8']
const graph: PeopleRelationshipsGraphData = {
nodes: [
node({ key: 'weixin:owner', kind: 'owner', rank: 1, pool: 'friend', score: 1 }),
@@ -417,6 +467,8 @@ test('uses a broad deterministic multi-color palette for contact stars', () => {
node({
key: `weixin:colorful-${index}`,
rank: index + 2,
communityId: `community-${index % colors.length}`,
color: colors[index % colors.length],
pool: index % 3 === 0 ? 'friend' : 'non_friend',
score: Math.max(0.2, 1 - index / 80),
})
@@ -427,34 +479,17 @@ test('uses a broad deterministic multi-color palette for contact stars', () => {
}
const scene = buildRelationshipGalaxy3DScene(graph)
const contactColorFamilies = new Set(
scene.nodes
.filter((item) => item.key !== 'weixin:owner')
.map((item) => colorFamily(item.color))
.filter(Boolean)
const colorsByCommunity = new Map<string, Set<number>>()
for (const item of scene.nodes.filter((item) => item.key !== 'weixin:owner')) {
const communityColors = colorsByCommunity.get(item.node.communityId) ?? new Set<number>()
communityColors.add(item.color)
colorsByCommunity.set(item.node.communityId, communityColors)
}
assert.equal(colorsByCommunity.size, colors.length)
assert.equal(
[...colorsByCommunity.values()].every((communityColors) => communityColors.size === 1),
true
)
assert.ok(contactColorFamilies.size >= 6)
assert.ok(new Set(scene.nodes.map((item) => item.color)).size <= colors.length + 1)
})
function colorFamily(color: number): string | null {
const red = ((color >> 16) & 255) / 255
const green = ((color >> 8) & 255) / 255
const blue = (color & 255) / 255
const max = Math.max(red, green, blue)
const min = Math.min(red, green, blue)
const delta = max - min
if (delta < 0.18) return null
let hue = 0
if (max === red) hue = ((green - blue) / delta + (green < blue ? 6 : 0)) * 60
else if (max === green) hue = ((blue - red) / delta + 2) * 60
else hue = ((red - green) / delta + 4) * 60
if (hue < 30 || hue >= 330) return 'rose'
if (hue < 90) return 'gold'
if (hue < 150) return 'green'
if (hue < 210) return 'cyan'
if (hue < 270) return 'blue'
return 'violet'
}
@@ -3,10 +3,7 @@ import type {
RelationshipGalaxyRenderGraph,
RelationshipGalaxyRenderNode,
} from '@openchatlab/shared-types'
import {
buildRelationshipVisibleGraphForSelection,
buildRelationshipVisibleLabelKeys,
} from './relationship-galaxy-connections'
import { buildRelationshipVisibleLabelKeys } from './relationship-galaxy-connections'
export type RelationshipGalaxy3DNodeState = 'normal' | 'selected' | 'neighbor' | 'dimmed'
@@ -34,9 +31,21 @@ export interface RelationshipGalaxy3DEdge {
highlighted: boolean
}
export interface RelationshipGalaxy3DCommunity {
id: string
x: number
y: number
z: number
radius: number
color: number
opacity: number
nodeCount: number
}
export interface RelationshipGalaxy3DScene {
nodes: RelationshipGalaxy3DNode[]
edges: RelationshipGalaxy3DEdge[]
communities: RelationshipGalaxy3DCommunity[]
selectedNeighborKeys: Set<string>
bounds: {
minX: number
@@ -56,24 +65,15 @@ export interface RelationshipGalaxy3DSceneOptions {
}
const OWNER_COLOR = 0xf8fbff
const FRIEND_NODE_COLORS = [
0xff8fb3, 0xffc857, 0x7dd3fc, 0xa78bfa, 0x86efac, 0xfb7185, 0x38bdf8, 0xf0abfc, 0xfde68a, 0x5eead4,
]
const GROUPMATE_NODE_COLORS = [
0xffb86b, 0x60a5fa, 0x34d399, 0xe879f9, 0xfacc15, 0x5eead4, 0xc084fc, 0xf472b6, 0xa3e635, 0x93c5fd,
]
const FALLBACK_COMMUNITY_COLORS = [0xff86ad, 0x72b8ff, 0x9f8cff, 0x67d9d0, 0xf2c879, 0x8fdaa8]
const MAX_3D_SCENE_RADIUS = 1700
const PANORAMA_LABEL_LIMIT = 8
const SELECTED_LABEL_LIMIT = 12
const PANORAMA_AXIS_SCALE = {
x: 1.42,
y: 0.84,
z: 1,
}
const SPHERICAL_AXIS_SCALE = {
x: 1,
y: 1,
z: 1,
}
interface RelationshipGalaxy3DVector {
x: number
y: number
@@ -85,14 +85,17 @@ export function buildRelationshipGalaxy3DScene(
options: RelationshipGalaxy3DSceneOptions = {}
): RelationshipGalaxy3DScene {
const selectedKey = options.selectedKey ?? null
const renderGraph = buildRelationshipVisibleGraphForSelection(graph, selectedKey)
const renderGraph = graph
const selectedNeighborKeys = buildSelectedNeighborKeys(renderGraph.edges, selectedKey)
const visibleLabelKeys = selectedKey ? buildRelationshipVisibleLabelKeys(renderGraph, selectedKey) : null
const visibleLabelKeys = selectedKey
? buildRelationshipVisibleLabelKeys(renderGraph, selectedKey, { limit: SELECTED_LABEL_LIMIT })
: buildPanoramaVisibleLabelKeys(renderGraph.nodes)
const communityColorById = buildCommunityColorMap(renderGraph)
const nodes = renderGraph.nodes.map((node) => {
const state = resolveNodeState(node.key, selectedKey, selectedNeighborKeys)
const seed = hashToUnit(node.key)
const position = deriveSphericalNodePosition(node, state, selectedKey, seed)
const position = deriveSphericalNodePosition(node, seed)
const radius = deriveNodeRadius(node, state)
const labelTier = deriveLabelTier(node, state, renderGraph.nodes.length, visibleLabelKeys)
@@ -103,7 +106,7 @@ export function buildRelationshipGalaxy3DScene(
y: position.y,
z: position.z,
radius,
color: parseNodeColor(node),
color: parseNodeColor(node, communityColorById),
state,
labelTier,
opacity: deriveNodeOpacity(state),
@@ -143,8 +146,9 @@ export function buildRelationshipGalaxy3DScene(
return {
nodes,
edges,
communities: buildSceneCommunities(nodes, renderGraph),
selectedNeighborKeys,
bounds: deriveBounds(nodes, Boolean(selectedKey)),
bounds: deriveBounds(nodes),
}
}
@@ -187,60 +191,34 @@ function resolveNodeState(
return 'dimmed'
}
function deriveSphericalNodePosition(
node: RelationshipGalaxyRenderNode,
state: RelationshipGalaxy3DNodeState,
selectedKey: string | null,
seed: number
): RelationshipGalaxy3DVector {
if (state === 'selected') return { x: 0, y: 0, z: 0 }
if (!selectedKey && node.visualRole === 'anchor') return { x: 0, y: 0, z: 0 }
function deriveSphericalNodePosition(node: RelationshipGalaxyRenderNode, seed: number): RelationshipGalaxy3DVector {
if (node.visualRole === 'anchor') return { x: 0, y: 0, z: 0 }
const direction = deriveNodeDirection(node, selectedKey)
const orbitRadius = deriveNodeOrbitRadius(node, state, Boolean(selectedKey), seed)
const axisScale = selectedKey ? SPHERICAL_AXIS_SCALE : PANORAMA_AXIS_SCALE
const direction = deriveNodeDirection(node)
const orbitRadius = deriveNodeOrbitRadius(node, seed)
return {
x: roundNum(direction.x * orbitRadius * axisScale.x, 2),
y: roundNum(direction.y * orbitRadius * axisScale.y, 2),
z: roundNum(direction.z * orbitRadius * axisScale.z, 2),
x: roundNum(direction.x * orbitRadius * PANORAMA_AXIS_SCALE.x, 2),
y: roundNum(direction.y * orbitRadius * PANORAMA_AXIS_SCALE.y, 2),
z: roundNum(direction.z * orbitRadius * PANORAMA_AXIS_SCALE.z, 2),
}
}
function deriveNodeDirection(
node: RelationshipGalaxyRenderNode,
selectedKey: string | null
): RelationshipGalaxy3DVector {
const focusSeed = selectedKey ?? 'panorama'
const communityDirection = deriveUnitVector(`community:${focusSeed}:${node.communityId || 'default'}`)
const nodeDirection = deriveUnitVector(`node:${focusSeed}:${node.key}`)
const communityWeight = selectedKey ? 0.7 : 1.15
const nodeWeight = selectedKey ? 1.15 : 0.95
function deriveNodeDirection(node: RelationshipGalaxyRenderNode): RelationshipGalaxy3DVector {
const communityDirection = deriveUnitVector(`community:panorama:${node.communityId || 'default'}`)
const nodeDirection = deriveUnitVector(`node:panorama:${node.key}`)
return normalizeVector({
x: communityDirection.x * communityWeight + nodeDirection.x * nodeWeight,
y: communityDirection.y * communityWeight + nodeDirection.y * nodeWeight,
z: communityDirection.z * communityWeight + nodeDirection.z * nodeWeight,
x: communityDirection.x * 1.15 + nodeDirection.x * 0.95,
y: communityDirection.y * 1.15 + nodeDirection.y * 0.95,
z: communityDirection.z * 1.15 + nodeDirection.z * 0.95,
})
}
function deriveNodeOrbitRadius(
node: RelationshipGalaxyRenderNode,
state: RelationshipGalaxy3DNodeState,
hasSelectedNode: boolean,
seed: number
): number {
function deriveNodeOrbitRadius(node: RelationshipGalaxyRenderNode, seed: number): number {
const importance = deriveNodeImportance(node)
const jitter = (seed - 0.5) * 120
if (hasSelectedNode && state === 'neighbor') {
return clamp(980 - importance * 650 + jitter, 220, 1050)
}
if (hasSelectedNode && state === 'dimmed') {
return clamp(1670 - importance * 260 + jitter, 1280, MAX_3D_SCENE_RADIUS)
}
const visualRole = node.visualRole ?? 'standard'
const minRadius = visualRole === 'close' ? 280 : 620
const maxRadius = visualRole === 'close' ? 1180 : MAX_3D_SCENE_RADIUS
@@ -257,15 +235,16 @@ function deriveNodeImportance(node: RelationshipGalaxyRenderNode): number {
}
function deriveNodeRadius(node: RelationshipGalaxyRenderNode, state: RelationshipGalaxy3DNodeState): number {
let base = Math.max(node.size * 0.5, node.visualRole === 'anchor' ? 14 : 1.6)
let base = Math.max(node.size * 0.42, node.visualRole === 'anchor' ? 11 : 1.6)
const importance = Math.max(0, 1 - (node.rank - 1) / 50)
base += Math.pow(importance, 1.35) * 6
if (node.rank <= 3) base += 2.5
else if (node.rank <= 10) base += 1.2
if (state === 'selected') return base + 5.5
if (state === 'neighbor') return base + 1.5
return base
const normalized = clamp(base, 2.2, 18)
if (state === 'selected') return Math.min(22, normalized + 4)
if (state === 'neighbor') return Math.min(20, normalized + 1.5)
return normalized
}
function deriveLabelTier(
@@ -276,7 +255,9 @@ function deriveLabelTier(
): 0 | 1 | 2 {
if (visibleLabelKeys) {
if (!visibleLabelKeys.has(node.key)) return 0
return state === 'selected' || node.visualRole === 'anchor' ? 2 : 1
return state === 'selected' || node.visualRole === 'anchor' || (state === 'normal' && node.labelVisibility === 2)
? 2
: 1
}
if (state === 'selected') return 2
@@ -288,6 +269,20 @@ function deriveLabelTier(
return 0
}
function buildPanoramaVisibleLabelKeys(nodes: RelationshipGalaxyRenderNode[]): Set<string> {
return new Set(
[...nodes]
.sort((a, b) => {
const anchorDiff = Number(b.visualRole === 'anchor') - Number(a.visualRole === 'anchor')
const labelDiff = b.labelVisibility - a.labelVisibility
const importanceDiff = deriveNodeImportance(b) - deriveNodeImportance(a)
return anchorDiff || labelDiff || importanceDiff || a.rank - b.rank || a.key.localeCompare(b.key)
})
.slice(0, PANORAMA_LABEL_LIMIT)
.map((node) => node.key)
)
}
function deriveNodeOpacity(state: RelationshipGalaxy3DNodeState): number {
if (state === 'selected') return 1
if (state === 'neighbor') return 0.95
@@ -295,7 +290,7 @@ function deriveNodeOpacity(state: RelationshipGalaxy3DNodeState): number {
return 0.82
}
function deriveBounds(nodes: RelationshipGalaxy3DNode[], spherical: boolean): RelationshipGalaxy3DScene['bounds'] {
function deriveBounds(nodes: RelationshipGalaxy3DNode[]): RelationshipGalaxy3DScene['bounds'] {
if (nodes.length === 0) {
return {
minX: -500,
@@ -320,21 +315,6 @@ function deriveBounds(nodes: RelationshipGalaxy3DNode[], spherical: boolean): Re
maxAbsZ = Math.max(maxAbsZ, Math.abs(node.z))
}
const radius = Math.max(400, maxAbsX, maxAbsY, maxAbsZ)
if (spherical) {
return {
minX: -radius,
maxX: radius,
minY: -radius,
maxY: radius,
minZ: -radius,
maxZ: radius,
width: radius * 2,
height: radius * 2,
depth: radius * 2,
}
}
const xRadius = Math.max(400, maxAbsX)
const yRadius = Math.max(400, maxAbsY)
const zRadius = Math.max(400, maxAbsZ)
@@ -352,17 +332,80 @@ function deriveBounds(nodes: RelationshipGalaxy3DNode[], spherical: boolean): Re
}
}
function parseNodeColor(node: RelationshipGalaxyRenderNode): number {
function parseNodeColor(node: RelationshipGalaxyRenderNode, communityColorById: ReadonlyMap<string, number>): number {
if (node.visualRole === 'anchor') return OWNER_COLOR
return pickPaletteColor(node)
return (
communityColorById.get(node.communityId) ??
parseHexColor(node.color) ??
pickFallbackCommunityColor(node.communityId)
)
}
function pickPaletteColor(node: RelationshipGalaxyRenderNode): number {
const visualRole = node.visualRole ?? 'standard'
const palette = visualRole === 'close' ? FRIEND_NODE_COLORS : GROUPMATE_NODE_COLORS
const paletteKey = visualRole === 'close' ? 'friend' : 'non_friend'
const index = hashToUint(`${node.communityId}:${node.key}:${node.rank}:${paletteKey}`) % palette.length
return palette[index] ?? palette[0]
function buildCommunityColorMap(graph: RelationshipGalaxyRenderGraph): Map<string, number> {
const colors = new Map<string, number>()
for (const community of graph.communities) {
colors.set(community.id, parseHexColor(community.color) ?? pickFallbackCommunityColor(community.id))
}
for (const node of graph.nodes) {
if (colors.has(node.communityId)) continue
colors.set(node.communityId, parseHexColor(node.color) ?? pickFallbackCommunityColor(node.communityId))
}
return colors
}
function buildSceneCommunities(
nodes: RelationshipGalaxy3DNode[],
graph: RelationshipGalaxyRenderGraph
): RelationshipGalaxy3DCommunity[] {
const sourceCommunityById = new Map(graph.communities.map((community) => [community.id, community]))
const nodesByCommunity = new Map<string, RelationshipGalaxy3DNode[]>()
for (const node of nodes) {
const group = nodesByCommunity.get(node.node.communityId) ?? []
group.push(node)
nodesByCommunity.set(node.node.communityId, group)
}
return [...nodesByCommunity.entries()]
.sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))
.slice(0, 8)
.map(([id, communityNodes]) => {
const x = average(communityNodes.map((node) => node.x))
const y = average(communityNodes.map((node) => node.y))
const z = average(communityNodes.map((node) => node.z))
const spread = Math.max(
120,
...communityNodes.map((node) => Math.hypot(node.x - x, node.y - y, (node.z - z) * 0.45))
)
const sourceCommunity = sourceCommunityById.get(id)
const color = parseHexColor(sourceCommunity?.color) ?? communityNodes[0]?.color ?? pickFallbackCommunityColor(id)
return {
id,
x: roundNum(x),
y: roundNum(y),
z: roundNum(z),
radius: roundNum(clamp(spread * 1.08 + Math.sqrt(communityNodes.length) * 24, 260, 720)),
color,
opacity: roundNum(clamp(0.085 + Math.log10(communityNodes.length + 1) * 0.026, 0.095, 0.17), 3),
nodeCount: communityNodes.length,
}
})
}
function parseHexColor(value: string | null | undefined): number | null {
if (!value || !/^#[0-9a-f]{6}$/i.test(value)) return null
return Number.parseInt(value.slice(1), 16)
}
function pickFallbackCommunityColor(communityId: string): number {
const index = hashToUint(communityId || 'default') % FALLBACK_COMMUNITY_COLORS.length
return FALLBACK_COMMUNITY_COLORS[index] ?? FALLBACK_COMMUNITY_COLORS[0]
}
function average(values: number[]): number {
if (values.length === 0) return 0
return values.reduce((sum, value) => sum + value, 0) / values.length
}
function deriveUnitVector(value: string): RelationshipGalaxy3DVector {
+52 -55
View File
@@ -39,7 +39,10 @@ import {
restoreRelationshipGalaxyPanoramaView,
type RelationshipGalaxyViewMode,
} from './relationship-galaxy-navigation'
import { buildPeopleRelationshipGalaxyRenderGraph } from './people-relationship-galaxy-render'
import {
buildPeopleRelationshipGalaxyRenderGraph,
resolvePeopleRelationshipGalaxyThreeCanvasGraph,
} from './people-relationship-galaxy-render'
type GalaxyCanvasInstance = {
focusNode: (key: string) => boolean
@@ -98,7 +101,6 @@ let neighborhoodGraphContentKey: string | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
let searchTimer: ReturnType<typeof setTimeout> | null = null
let detailPanelMediaQuery: MediaQueryList | null = null
let isRestoringPanoramaView = false
const numberFormatter = computed(() => new Intl.NumberFormat(locale.value))
@@ -123,15 +125,28 @@ const graphScopeTabs = computed(() => [
},
])
const activeGraph = computed(() => neighborhoodResponse.value?.graph ?? graphResponse.value?.graph ?? EMPTY_GRAPH)
const activeRenderGraph = computed(() =>
buildPeopleRelationshipGalaxyRenderGraph(activeGraph.value, {
const panoramaGraph = computed(() => graphResponse.value?.graph ?? EMPTY_GRAPH)
const activeGraph = computed(() => neighborhoodResponse.value?.graph ?? panoramaGraph.value)
const threeCanvasSourceGraph = computed(() =>
resolvePeopleRelationshipGalaxyThreeCanvasGraph({
panorama: panoramaGraph.value,
neighborhood: neighborhoodResponse.value?.graph ?? null,
selectedKey: selectedKey.value,
})
)
const threeCanvasRenderGraph = computed(() =>
buildPeopleRelationshipGalaxyRenderGraph(threeCanvasSourceGraph.value, {
privacyMode: privacyMode.value,
ownerLabel: t('relationships.owner.me'),
})
)
const threeCanvasSelectedKey = computed(() => {
const selected = selectedKey.value
if (!selected) return null
return threeCanvasSourceGraph.value.nodes.some((node) => node.key === selected) ? selected : null
})
const isNeighborhoodMode = computed(() => Boolean(neighborhoodResponse.value))
const hasGraph = computed(() => activeGraph.value.nodes.length > 0)
const hasGraph = computed(() => panoramaGraph.value.nodes.length > 0 || activeGraph.value.nodes.length > 0)
const task = computed(() =>
resolveRelationshipGalaxyActiveTask({
graphTask: graphResponse.value?.task ?? null,
@@ -157,6 +172,7 @@ const selectedNode = computed(() => {
if (!selectedKey.value) return null
return (
activeGraph.value.nodes.find((node) => node.key === selectedKey.value) ??
panoramaGraph.value.nodes.find((node) => node.key === selectedKey.value) ??
neighborhoodResponse.value?.contact ??
null
)
@@ -179,9 +195,9 @@ const connectionRanking = computed(() =>
)
const stats = computed(() => ({
nodes: activeGraph.value.nodes.length,
edges: activeGraph.value.edges.length,
communities: activeGraph.value.communities.length,
nodes: panoramaGraph.value.nodes.length,
edges: panoramaGraph.value.edges.length,
communities: panoramaGraph.value.communities.length,
}))
const topCommunities = computed(() => [...activeGraph.value.communities].sort((a, b) => b.size - a.size).slice(0, 8))
@@ -368,9 +384,6 @@ function restorePanoramaView() {
void nextTick(() => {
restoreRelationshipGalaxyPanoramaView(canvasRef.value, view)
void nextTick(() => {
isRestoringPanoramaView = false
})
})
}
@@ -501,6 +514,7 @@ async function loadNeighborhood(key: string) {
isLoadingNeighborhood.value = true
loadingNeighborhoodKey.value = key
loadError.value = ''
const isOffCoreSelection = !panoramaGraph.value.nodes.some((node) => node.key === key)
try {
const next = await dataService.getPeopleRelationshipNeighborhood(key, {
@@ -515,7 +529,11 @@ async function loadNeighborhood(key: string) {
canvasSelectedKey.value = selectedKey.value
syncPolling(next.task)
await nextTick()
canvasRef.value?.fitView()
if (viewMode.value === '2d') {
canvasRef.value?.fitView()
} else if (isOffCoreSelection && selectedKey.value) {
canvasRef.value?.focusNode(selectedKey.value)
}
} catch (error) {
if (requestId !== neighborhoodRequestId.value) return
toast.fail(t('relationships.toast.neighborhoodFailed'), { description: String(error) })
@@ -576,7 +594,9 @@ async function selectNode(node: PeopleRelationshipGraphNode) {
}
async function selectRenderNode(node: RelationshipGalaxyRenderNode) {
const peopleNode = activeGraph.value.nodes.find((item) => item.key === node.key)
const peopleNode =
panoramaGraph.value.nodes.find((item) => item.key === node.key) ??
activeGraph.value.nodes.find((item) => item.key === node.key)
if (peopleNode) await selectNode(peopleNode)
}
@@ -588,7 +608,6 @@ function handleThreeCanvasFallback() {
}
function backToPanorama() {
isRestoringPanoramaView = true
cancelNeighborhoodLoad()
clearNeighborhoodResponse()
selectedKey.value = null
@@ -653,16 +672,6 @@ watch(viewMode, async () => {
canvasRef.value?.fitView()
})
watch(detailPanelSafeInsetRight, async () => {
await nextTick()
if (isRestoringPanoramaView) return
if (selectedKey.value) {
canvasRef.value?.focusNode(selectedKey.value)
return
}
canvasRef.value?.fitView()
})
onMounted(() => {
if (typeof window !== 'undefined') {
detailPanelMediaQuery = window.matchMedia('(min-width: 768px)')
@@ -681,12 +690,12 @@ onBeforeUnmount(() => {
<template>
<div class="flex min-h-0 flex-1 overflow-hidden text-gray-900 dark:bg-page-dark dark:text-gray-100">
<main class="relative min-h-0 min-w-0 flex-1 overflow-hidden bg-[#0b0f19] dark:bg-page-dark">
<main class="dark relative min-h-0 min-w-0 flex-1 overflow-hidden bg-[#03050c] text-gray-100">
<RelationshipGalaxyThreeCanvas
v-if="viewMode === '3d'"
ref="canvasRef"
:graph="activeRenderGraph"
:selected-key="canvasSelectedKey"
:graph="threeCanvasRenderGraph"
:selected-key="threeCanvasSelectedKey"
:safe-inset-right="detailPanelSafeInsetRight"
:emphasize-edges="graphScope === 'friends'"
:label="t('relationships.canvas.label3d')"
@@ -706,7 +715,9 @@ onBeforeUnmount(() => {
@select-node="selectNode"
/>
<div class="absolute left-6 top-4 z-20 flex max-w-[calc(100%-3rem)] flex-wrap items-center gap-2">
<div
class="absolute left-4 top-4 z-20 flex max-w-[calc(100%-2rem)] flex-wrap items-center gap-1.5 rounded-2xl border border-white/8 bg-[#070a12]/78 p-1.5 shadow-overlay backdrop-blur-xl sm:left-6 sm:max-w-[calc(100%-3rem)]"
>
<UTabs v-model="timeRangePreset" :items="timeRangeTabs" :content="false" size="xs" class="min-w-max gap-0" />
<UTabs v-model="graphScope" :items="graphScopeTabs" :content="false" size="xs" class="min-w-max gap-0" />
<div class="relative w-28 max-w-full sm:w-32 lg:w-36">
@@ -818,7 +829,7 @@ onBeforeUnmount(() => {
<LoadingState
v-if="showInitialLoading"
variant="overlay"
class="!bg-[#0b0f19] [&_p]:!text-gray-300"
class="!bg-[#03050c] [&_p]:!text-gray-300"
:text="statusText || t('relationships.task.updating')"
/>
@@ -831,14 +842,14 @@ onBeforeUnmount(() => {
<aside
v-if="showDetailPanel"
class="dark absolute inset-x-3 bottom-3 z-20 flex max-h-[70vh] flex-col overflow-hidden rounded-2xl border border-white/5 bg-page-dark/10 text-white shadow-overlay backdrop-blur-md md:inset-x-auto md:bottom-4 md:right-4 md:top-4 md:max-h-none md:w-[360px]"
class="dark absolute inset-x-3 bottom-3 z-20 flex max-h-[70vh] flex-col overflow-hidden rounded-2xl border border-white/8 bg-[#070a12]/84 text-white shadow-overlay backdrop-blur-2xl md:inset-x-auto md:bottom-4 md:right-4 md:top-4 md:max-h-none md:w-[360px]"
>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="xs"
class="absolute right-2.5 top-2.5 z-10 bg-white/35 backdrop-blur-sm hover:bg-white/55 dark:bg-white/5 dark:hover:bg-white/10"
class="absolute right-2.5 top-2.5 z-10 text-white/60 hover:bg-white/8 hover:text-white"
:aria-label="t('relationships.actions.closeDetail')"
@click="closeDetailPanel"
/>
@@ -886,10 +897,8 @@ onBeforeUnmount(() => {
{{ t('relationships.actions.focusConnections') }}
</UButton>
<div class="grid grid-cols-3 gap-1 text-xs">
<div
class="min-w-0 rounded-md border border-gray-100/60 bg-gray-50/35 p-1.5 dark:border-white/5 dark:bg-white/3"
>
<div class="grid grid-cols-3 gap-y-1 rounded-xl border border-white/8 bg-white/3 p-1 text-xs">
<div class="min-w-0 px-2 py-1.5">
<p
class="mb-0.5 truncate text-[9px] font-semibold uppercase leading-3 tracking-wide text-gray-400 dark:text-gray-500"
>
@@ -903,9 +912,7 @@ onBeforeUnmount(() => {
</div>
</div>
<div
class="min-w-0 rounded-md border border-gray-100/60 bg-gray-50/35 p-1.5 dark:border-white/5 dark:bg-white/3"
>
<div class="min-w-0 border-l border-white/6 px-2 py-1.5">
<p
class="mb-0.5 truncate text-[9px] font-semibold uppercase leading-3 tracking-wide text-gray-400 dark:text-gray-500"
>
@@ -923,9 +930,7 @@ onBeforeUnmount(() => {
</span>
</div>
<div
class="min-w-0 rounded-md border border-gray-100/60 bg-gray-50/35 p-1.5 dark:border-white/5 dark:bg-white/3"
>
<div class="min-w-0 border-l border-white/6 px-2 py-1.5">
<p
class="mb-0.5 truncate text-[9px] font-semibold uppercase leading-3 tracking-wide text-gray-400 dark:text-gray-500"
>
@@ -936,9 +941,7 @@ onBeforeUnmount(() => {
</span>
</div>
<div
class="min-w-0 rounded-md border border-gray-100/60 bg-gray-50/35 p-1.5 dark:border-white/5 dark:bg-white/3"
>
<div class="min-w-0 border-t border-white/6 px-2 py-1.5">
<p
class="mb-0.5 truncate text-[9px] font-semibold uppercase leading-3 tracking-wide text-gray-400 dark:text-gray-500"
>
@@ -949,9 +952,7 @@ onBeforeUnmount(() => {
</span>
</div>
<div
class="min-w-0 rounded-md border border-gray-100/60 bg-gray-50/35 p-1.5 dark:border-white/5 dark:bg-white/3"
>
<div class="min-w-0 border-l border-t border-white/6 px-2 py-1.5">
<p
class="mb-0.5 truncate text-[9px] font-semibold uppercase leading-3 tracking-wide text-gray-400 dark:text-gray-500"
>
@@ -962,9 +963,7 @@ onBeforeUnmount(() => {
</span>
</div>
<div
class="min-w-0 rounded-md border border-gray-100/60 bg-gray-50/35 p-1.5 dark:border-white/5 dark:bg-white/3"
>
<div class="min-w-0 border-l border-t border-white/6 px-2 py-1.5">
<p
class="mb-0.5 truncate text-[9px] font-semibold uppercase leading-3 tracking-wide text-gray-400 dark:text-gray-500"
>
@@ -978,9 +977,7 @@ onBeforeUnmount(() => {
</div>
<div class="flex min-h-0 flex-1 flex-col gap-2">
<section
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-100/80 bg-gray-50/25 p-2.5 dark:border-white/5 dark:bg-white/2"
>
<section class="flex min-h-0 flex-1 flex-col overflow-hidden border-t border-white/7 pt-3">
<div class="mb-2 flex shrink-0 items-center justify-between gap-3 pl-1 pr-3">
<div class="flex min-w-0 items-center gap-2">
<h3 class="truncate text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
@@ -1040,7 +1037,7 @@ onBeforeUnmount(() => {
<section
v-if="topCommunities.length > 0"
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-100/80 bg-gray-50/25 p-2.5 dark:border-white/5 dark:bg-white/2"
class="flex min-h-0 flex-1 flex-col overflow-hidden border-t border-white/7 pt-3"
>
<div class="mb-2 flex shrink-0 items-center justify-between gap-3 pl-1 pr-3">
<div class="flex min-w-0 items-center gap-2">
@@ -1,7 +1,15 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { PeopleRelationshipGraphNode, PeopleRelationshipsGraphData } from '@openchatlab/shared-types'
import { buildPeopleRelationshipGalaxyRenderGraph } from './people-relationship-galaxy-render'
import type {
PeopleRelationshipGraphEdge,
PeopleRelationshipGraphNode,
PeopleRelationshipsGraphData,
} from '@openchatlab/shared-types'
import {
buildPeopleRelationshipGalaxyRenderGraph,
mergePeopleRelationshipGalaxyGraphs,
resolvePeopleRelationshipGalaxyThreeCanvasGraph,
} from './people-relationship-galaxy-render'
function node(overrides: Partial<PeopleRelationshipGraphNode> & { key: string }): PeopleRelationshipGraphNode {
return {
@@ -30,6 +38,24 @@ function node(overrides: Partial<PeopleRelationshipGraphNode> & { key: string })
}
}
function edge(sourceKey: string, targetKey: string, weight = 1): PeopleRelationshipGraphEdge {
return {
id: `${sourceKey}:${targetKey}`,
sourceKey,
targetKey,
weight,
coOccurrenceCount: 1,
coOccurrenceRawScore: weight,
replyInteractionCount: 0,
repliesFromSourceToTarget: 0,
repliesFromTargetToSource: 0,
sourceGroupCount: 1,
sourceSessionIds: [],
lastInteractionTs: null,
visibility: 2,
}
}
test('maps People-only semantics into the shared galaxy render contract', () => {
const graph: PeopleRelationshipsGraphData = {
nodes: [
@@ -51,3 +77,78 @@ test('maps People-only semantics into the shared galaxy render contract', () =>
assert.equal(renderGraph.nodes[2].visualRole, 'standard')
assert.ok((renderGraph.nodes[1].importance ?? 0) > (renderGraph.nodes[2].importance ?? 0))
})
test('adds off-core neighborhood data without replacing panorama node coordinates', () => {
const panoramaNode = node({ key: 'owner', kind: 'owner', displayName: 'Owner' })
panoramaNode.x = 320
panoramaNode.y = -180
const offCoreNode = node({ key: 'off-core', displayName: 'Off Core' })
const panorama: PeopleRelationshipsGraphData = {
nodes: [panoramaNode],
edges: [],
communities: [{ id: 'community-a', label: 'A', size: 1, x: 0, y: 0, color: '#38bdf8' }],
}
const neighborhood: PeopleRelationshipsGraphData = {
nodes: [{ ...panoramaNode, x: 0, y: 0 }, offCoreNode],
edges: [edge('owner', 'off-core')],
communities: [],
}
const merged = mergePeopleRelationshipGalaxyGraphs(panorama, neighborhood)
assert.equal(merged.nodes.length, 2)
assert.deepEqual(
merged.nodes.find((item) => item.key === 'owner'),
panoramaNode
)
assert.equal(merged.edges.length, 1)
assert.equal(merged.communities.length, 1)
assert.equal(
resolvePeopleRelationshipGalaxyThreeCanvasGraph({
panorama,
neighborhood,
selectedKey: 'owner',
}).nodes.length,
2
)
assert.equal(
resolvePeopleRelationshipGalaxyThreeCanvasGraph({
panorama,
neighborhood,
selectedKey: 'off-core',
}).nodes.length,
2
)
})
test('keeps every selected neighborhood connection instead of the panorama per-node edge subset', () => {
const owner = node({ key: 'owner', kind: 'owner', displayName: 'Owner' })
owner.x = 320
owner.y = -180
const contacts = Array.from({ length: 79 }, (_, index) => node({ key: `contact-${index}` }))
const neighborhoodEdges = contacts.map((contact, index) => edge('owner', contact.key, 100 - index))
const panorama: PeopleRelationshipsGraphData = {
nodes: [owner, ...contacts],
edges: neighborhoodEdges.slice(0, 12),
communities: [],
}
const neighborhood: PeopleRelationshipsGraphData = {
nodes: [{ ...owner, x: 0, y: 0 }, ...contacts],
edges: neighborhoodEdges,
communities: [],
}
const resolved = resolvePeopleRelationshipGalaxyThreeCanvasGraph({
panorama,
neighborhood,
selectedKey: owner.key,
})
assert.equal(resolved.edges.filter((item) => item.sourceKey === owner.key).length, 79)
assert.equal(resolved.nodes.length, 80)
assert.equal(
resolved.nodes.find((item) => item.key === owner.key),
owner
)
})
@@ -10,6 +10,12 @@ export interface PeopleRelationshipGalaxyRenderOptions {
ownerLabel: string
}
export interface PeopleRelationshipGalaxyThreeCanvasGraphInput {
panorama: PeopleRelationshipsGraphData
neighborhood: PeopleRelationshipsGraphData | null
selectedKey: string | null
}
export function buildPeopleRelationshipGalaxyRenderGraph(
graph: PeopleRelationshipsGraphData,
options: PeopleRelationshipGalaxyRenderOptions
@@ -26,6 +32,38 @@ export function buildPeopleRelationshipGalaxyRenderGraph(
}
}
export function mergePeopleRelationshipGalaxyGraphs(
panorama: PeopleRelationshipsGraphData,
neighborhood: PeopleRelationshipsGraphData
): PeopleRelationshipsGraphData {
const nodes = new Map(panorama.nodes.map((node) => [node.key, node]))
const edges = new Map(panorama.edges.map((edge) => [edge.id, edge]))
const communities = new Map(panorama.communities.map((community) => [community.id, community]))
for (const node of neighborhood.nodes) {
if (!nodes.has(node.key)) nodes.set(node.key, node)
}
for (const edge of neighborhood.edges) edges.set(edge.id, edge)
for (const community of neighborhood.communities) {
if (!communities.has(community.id)) communities.set(community.id, community)
}
return {
nodes: [...nodes.values()],
edges: [...edges.values()],
communities: [...communities.values()],
}
}
export function resolvePeopleRelationshipGalaxyThreeCanvasGraph(
input: PeopleRelationshipGalaxyThreeCanvasGraphInput
): PeopleRelationshipsGraphData {
const selected = input.selectedKey
if (!selected || !input.neighborhood) return input.panorama
if (!input.neighborhood.nodes.some((node) => node.key === selected)) return input.panorama
return mergePeopleRelationshipGalaxyGraphs(input.panorama, input.neighborhood)
}
function computePeopleNodeImportance(node: PeopleRelationshipGraphNode): number {
if (node.kind === 'owner') return 1