fix(relationships): polish galaxy privacy and canvas artifacts

This commit is contained in:
digua committed 2026-07-01 01:06:36 +08:00
1 parent 13fa375dbc
commit 84223f26cb
12 files changed
+125 -59

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@
},
"graphScope": {
"panorama": "Panorama",
"close": "Close ties",
"close": "High affinity",
"friends": "Friends only"
},
"timeRange": {
+1 -1
View File
@@ -11,7 +11,7 @@
},
"graphScope": {
"panorama": "全景",
"close": "強いつながり",
"close": "高関連",
"friends": "友だちのみ"
},
"timeRange": {
+1 -1
View File
@@ -11,7 +11,7 @@
},
"graphScope": {
"panorama": "全景",
"close": "关系好的",
"close": "高关联",
"friends": "仅好友"
},
"timeRange": {
+1 -1
View File
@@ -11,7 +11,7 @@
},
"graphScope": {
"panorama": "全景",
"close": "關係好的",
"close": "高關聯",
"friends": "僅好友"
},
"timeRange": {
@@ -13,6 +13,7 @@ import {
buildRelationshipGalaxy2DSafeCenter,
buildRelationshipGalaxy2DSafeFitScale,
} from '../relationship-galaxy-viewport'
import { maskRelationshipGalaxyPrivateText } from '../relationship-galaxy-privacy'
interface Ripple {
graphic: Graphics
@@ -97,8 +98,8 @@ function colorToNumber(color: string | null | undefined, fallback: number): numb
function shortName(node: PeopleRelationshipGraphNode): string {
if (node.kind === 'owner') return props.ownerLabel
if (props.privacyMode) return `#${node.rank}`
return node.displayName || node.platformId || node.key
const name = node.displayName || node.platformId || node.key
return props.privacyMode ? maskRelationshipGalaxyPrivateText(name) : name
}
function getNodeColor(node: PeopleRelationshipGraphNode): number {
@@ -21,6 +21,7 @@ import {
buildRelationshipGalaxy3DFitCameraPose,
type RelationshipGalaxy3DCameraPose,
} from '../relationship-galaxy-3d-camera'
import { maskRelationshipGalaxyPrivateText } from '../relationship-galaxy-privacy'
interface NodeObject {
group: THREE.Group
@@ -93,7 +94,6 @@ let cameraFlight: CameraFlight | null = null
const graphGroup = new THREE.Group()
const edgeGroup = new THREE.Group()
const nodeGroup = new THREE.Group()
const starGroup = new THREE.Group()
const nodeObjects = new Map<string, NodeObject>()
const nodePickObjects: THREE.Object3D[] = []
const neighborKeysOf = new Map<string, Set<string>>()
@@ -103,8 +103,8 @@ const tmpWorldPosition = new THREE.Vector3()
function shortName(node: PeopleRelationshipGraphNode): string {
if (node.kind === 'owner') return props.ownerLabel
if (props.privacyMode) return `#${node.rank}`
return node.displayName || node.platformId || node.key
const name = node.displayName || node.platformId || node.key
return props.privacyMode ? maskRelationshipGalaxyPrivateText(name) : name
}
function getViewportSize(): { width: number; height: number } {
@@ -307,7 +307,6 @@ function updateAnimation() {
const autoDrift = hasUserMovedCamera ? 0.003 : 0.008
graphGroup.rotation.y = Math.sin(elapsedMs / 25_000) * autoDrift
graphGroup.rotation.x = Math.cos(elapsedMs / 30_000) * autoDrift * 0.4
starGroup.rotation.y = elapsedMs / 200_000
const activeKey = hoveredKey.value || props.selectedKey
for (const object of nodeObjects.values()) {
@@ -454,9 +453,7 @@ async function initCanvas() {
graphGroup.add(edgeGroup)
graphGroup.add(nodeGroup)
scene.add(starGroup)
scene.add(graphGroup)
addStarField()
renderer.domElement.addEventListener('pointermove', handlePointerMove)
renderer.domElement.addEventListener('pointerleave', handlePointerLeave)
@@ -470,46 +467,6 @@ async function initCanvas() {
animationFrame = requestAnimationFrame(updateAnimation)
}
function addStarField() {
clearGroup(starGroup)
const positions: number[] = []
const colors: number[] = []
const color = new THREE.Color()
const count = 4000
for (let i = 0; i < count; i++) {
const seed = i * 97.317
const x = pseudoRandom(seed) * 12000 - 6000
const y = pseudoRandom(seed + 13.1) * 8000 - 4000
const z = pseudoRandom(seed + 29.7) * 8000 - 4000
positions.push(x, y, z)
const huePick = pseudoRandom(seed + 8.8)
if (huePick < 0.24) color.setHex(0xfff2d4)
else if (huePick < 0.5) color.setHex(0xffd1a3)
else if (huePick < 0.72) color.setHex(0xffb17a)
else color.setHex(0xffffff)
const intensity = 0.12 + pseudoRandom(seed + 19.2) * 0.3
colors.push(color.r * intensity, color.g * intensity, color.b * intensity)
}
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3))
const material = new THREE.PointsMaterial({
size: 1.2,
sizeAttenuation: true,
vertexColors: true,
transparent: true,
opacity: 0.6,
depthWrite: false,
})
starGroup.add(new THREE.Points(geometry, material))
}
function resizeCanvas() {
if (!renderer || !camera) return
const size = getViewportSize()
@@ -850,7 +807,6 @@ onBeforeUnmount(() => {
clearGroup(edgeGroup)
clearGroup(nodeGroup)
clearGroup(starGroup)
for (const texture of textureCache.values()) texture.dispose()
textureCache.clear()
graphGroup.clear()
+18 -6
View File
@@ -25,6 +25,7 @@ import {
resolveRelationshipGalaxyPollingAction,
shouldShowFocusConnectionsAction,
} from './relationship-galaxy-state'
import { maskRelationshipGalaxyPrivateText, relationshipGalaxyPrivateAvatarText } from './relationship-galaxy-privacy'
type GalaxyCanvasInstance = {
focusNode: (key: string) => boolean
@@ -199,13 +200,24 @@ function formatTime(ts: number | null | undefined): string {
function avatarText(node: PeopleRelationshipGraphNode | PeopleRelationshipsSearchResult): string {
if (node.kind === 'owner') return t('relationships.owner.avatarText')
if (privacyMode.value) return relationshipGalaxyPrivateAvatarText()
return (node.displayName || node.platformId || '?').slice(0, 1)
}
function avatarSrc(node: PeopleRelationshipGraphNode | PeopleRelationshipsSearchResult): string | null {
if (node.kind === 'owner') return node.avatar
return privacyMode.value ? null : node.avatar
}
function displayName(node: PeopleRelationshipGraphNode | PeopleRelationshipsSearchResult): string {
if (node.kind === 'owner') return t('relationships.owner.me')
if (privacyMode.value) return `#${node.rank}`
return node.displayName || node.platformId || node.key
const name = node.displayName || node.platformId || node.key
return privacyMode.value ? maskRelationshipGalaxyPrivateText(name) : name
}
function platformIdentity(node: PeopleRelationshipGraphNode): string {
const identity = node.platformId || node.key
return privacyMode.value ? maskRelationshipGalaxyPrivateText(identity) : identity
}
function poolLabel(node: Pick<PeopleRelationshipGraphNode, 'pool' | 'friendSource' | 'kind'>): string {
@@ -680,7 +692,7 @@ onBeforeUnmount(() => {
@click="selectSearchResult(result)"
>
<LazyAvatar
:src="result.avatar"
:src="avatarSrc(result)"
:alt="displayName(result)"
:text="avatarText(result)"
root-class="h-8 w-8 shrink-0 shadow-sm border border-gray-250/20 dark:border-white/10"
@@ -775,7 +787,7 @@ onBeforeUnmount(() => {
<div class="shrink-0 space-y-2.5">
<div class="flex items-center gap-3 pr-8">
<LazyAvatar
:src="selectedNode.avatar"
:src="avatarSrc(selectedNode)"
:alt="displayName(selectedNode)"
:text="avatarText(selectedNode)"
root-class="h-11 w-11 shrink-0 overflow-hidden rounded-lg shadow-sm border border-gray-250/20 dark:border-white/10"
@@ -795,7 +807,7 @@ onBeforeUnmount(() => {
</span>
</div>
<p class="truncate text-xs text-gray-500 dark:text-gray-400">
{{ selectedNode.platform }} · {{ selectedNode.platformId }}
{{ selectedNode.platform }} · {{ platformIdentity(selectedNode) }}
</p>
</div>
</div>
@@ -937,7 +949,7 @@ onBeforeUnmount(() => {
@click="selectNode(item.node)"
>
<LazyAvatar
:src="item.node.avatar"
:src="avatarSrc(item.node)"
:alt="displayName(item.node)"
:text="avatarText(item.node)"
root-class="h-6 w-6 shrink-0 overflow-hidden rounded-md shadow-sm border border-gray-250/20 dark:border-white/10"
@@ -67,4 +67,12 @@ describe('RelationshipGalaxyThreeCanvas scene wiring', () => {
'focus and fit camera poses must apply the safe-area projection separately from the orbit target'
)
})
it('uses masked relationship names for 3D privacy labels instead of rank-only labels', () => {
const source = readCanvasSource()
const shortName = source.slice(source.indexOf('function shortName'), source.indexOf('function getViewportSize'))
assert.ok(shortName.includes('maskRelationshipGalaxyPrivateText(name)'))
assert.equal(shortName.includes('`#${node.rank}`'), false)
})
})
@@ -0,0 +1,21 @@
/**
* Run: pnpm test -- src/pages/people/relationships/relationship-galaxy-canvas-source.test.ts
*/
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { describe, it } from 'node:test'
function readCanvasSource(): string {
return readFileSync(new URL('./components/RelationshipGalaxyCanvas.vue', import.meta.url), 'utf8')
}
describe('RelationshipGalaxyCanvas source', () => {
it('uses masked relationship names for 2D privacy labels instead of rank-only labels', () => {
const source = readCanvasSource()
const shortName = source.slice(source.indexOf('function shortName'), source.indexOf('function getNodeColor'))
assert.ok(shortName.includes('maskRelationshipGalaxyPrivateText(name)'))
assert.equal(shortName.includes('`#${node.rank}`'), false)
})
})
@@ -45,4 +45,24 @@ describe('people relationships page source', () => {
assert.equal(template.includes('v-model="viewMode"'), false)
assert.ok(fallback.includes("viewMode.value = '2d'"), '2D should remain as automatic fallback for 3D failures')
})
it('uses masked relationship names instead of rank-only privacy labels', () => {
const source = readPageSource()
const displayName = source.slice(
source.indexOf('function displayName'),
source.indexOf('function platformIdentity')
)
const avatarText = source.slice(source.indexOf('function avatarText'), source.indexOf('function avatarSrc'))
const avatarSrc = source.slice(source.indexOf('function avatarSrc'), source.indexOf('function displayName'))
const platformIdentity = source.slice(
source.indexOf('function platformIdentity'),
source.indexOf('function poolLabel')
)
assert.ok(displayName.includes('maskRelationshipGalaxyPrivateText(name)'))
assert.ok(avatarText.includes('relationshipGalaxyPrivateAvatarText()'))
assert.ok(avatarSrc.includes('privacyMode.value ? null : node.avatar'))
assert.ok(platformIdentity.includes('maskRelationshipGalaxyPrivateText(identity)'))
assert.equal(displayName.includes('`#${node.rank}`'), false)
})
})
@@ -0,0 +1,30 @@
/**
* Run: pnpm test -- src/pages/people/relationships/relationship-galaxy-privacy.test.ts
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { maskRelationshipGalaxyPrivateText, relationshipGalaxyPrivateAvatarText } from './relationship-galaxy-privacy'
test('masks Chinese names with two stars and the last Chinese character', () => {
assert.equal(maskRelationshipGalaxyPrivateText('张小红'), '**红')
assert.equal(maskRelationshipGalaxyPrivateText('王'), '**王')
assert.equal(maskRelationshipGalaxyPrivateText('Alice 王'), '**王')
})
test('masks latin names by preserving the first and last character', () => {
assert.equal(maskRelationshipGalaxyPrivateText('Alice'), 'A***e')
assert.equal(maskRelationshipGalaxyPrivateText('alice.zhang'), 'a***g')
assert.equal(maskRelationshipGalaxyPrivateText('wxid_8tvjn5yes4hi11'), 'w***1')
})
test('masks very short and empty relationship labels without revealing full names', () => {
assert.equal(maskRelationshipGalaxyPrivateText('Li'), 'L***')
assert.equal(maskRelationshipGalaxyPrivateText('A'), '*')
assert.equal(maskRelationshipGalaxyPrivateText(''), '*')
assert.equal(maskRelationshipGalaxyPrivateText(' '), '*')
})
test('uses a non-identifying avatar fallback in relationship privacy mode', () => {
assert.equal(relationshipGalaxyPrivateAvatarText(), '*')
})
@@ -0,0 +1,18 @@
const HAN_CHARACTER_PATTERN = /\p{Script=Han}/u
export function maskRelationshipGalaxyPrivateText(value: string | null | undefined): string {
const text = value?.trim() ?? ''
if (!text) return '*'
const characters = Array.from(text)
const lastHanCharacter = [...characters].reverse().find((character) => HAN_CHARACTER_PATTERN.test(character))
if (lastHanCharacter) return `**${lastHanCharacter}`
if (characters.length === 1) return '*'
if (characters.length === 2) return `${characters[0]}***`
return `${characters[0]}***${characters.at(-1)}`
}
export function relationshipGalaxyPrivateAvatarText(): string {
return '*'
}