mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
fix(relationships): polish galaxy privacy and canvas artifacts
This commit is contained in:
1 parent
13fa375dbc
commit
84223f26cb
12 files changed
+125
-59
No files matched your search
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"graphScope": {
|
||||
"panorama": "Panorama",
|
||||
"close": "Close ties",
|
||||
"close": "High affinity",
|
||||
"friends": "Friends only"
|
||||
},
|
||||
"timeRange": {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"graphScope": {
|
||||
"panorama": "全景",
|
||||
"close": "強いつながり",
|
||||
"close": "高関連",
|
||||
"friends": "友だちのみ"
|
||||
},
|
||||
"timeRange": {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"graphScope": {
|
||||
"panorama": "全景",
|
||||
"close": "关系好的",
|
||||
"close": "高关联",
|
||||
"friends": "仅好友"
|
||||
},
|
||||
"timeRange": {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 '*'
|
||||
}
|
||||
Reference in new issue
Block a user