mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(relationships): refine galaxy depth and focus
This commit is contained in:
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user