fix(relationships): preserve orbit target with detail panel

This commit is contained in:
digua committed 2026-07-01 01:06:36 +08:00
1 parent de3494bc7f
commit d54675cb3a
4 files changed
+114 -27

No files matched your search

@@ -17,6 +17,7 @@ import {
import { buildRelationshipVisibleLabelKeys } from '../relationship-galaxy-connections'
import {
applyRelationshipGalaxy3DSafeArea,
buildRelationshipGalaxy3DViewOffset,
buildRelationshipGalaxy3DFitCameraPose,
type RelationshipGalaxy3DCameraPose,
} from '../relationship-galaxy-3d-camera'
@@ -513,8 +514,8 @@ function resizeCanvas() {
if (!renderer || !camera) return
const size = getViewportSize()
camera.aspect = size.width / size.height
camera.updateProjectionMatrix()
renderer.setSize(size.width, size.height)
applyCameraSafeAreaProjection(size)
}
function handlePointerMove(event: PointerEvent) {
@@ -631,6 +632,7 @@ function fitView() {
function applySafeAreaToCameraPose(pose: RelationshipGalaxy3DCameraPose): RelationshipGalaxy3DCameraPose {
const size = getViewportSize()
applyCameraSafeAreaProjection(size)
return applyRelationshipGalaxy3DSafeArea(pose, {
viewportWidth: size.width,
viewportHeight: size.height,
@@ -639,6 +641,28 @@ function applySafeAreaToCameraPose(pose: RelationshipGalaxy3DCameraPose): Relati
})
}
function applyCameraSafeAreaProjection(size = getViewportSize()) {
if (!camera) return
const viewOffset = buildRelationshipGalaxy3DViewOffset({
viewportWidth: size.width,
viewportHeight: size.height,
safeInsetRight: props.safeInsetRight,
})
if (!viewOffset) {
camera.clearViewOffset()
return
}
camera.setViewOffset(
viewOffset.fullWidth,
viewOffset.fullHeight,
viewOffset.offsetX,
viewOffset.offsetY,
viewOffset.width,
viewOffset.height
)
}
function vectorToPose(vector: THREE.Vector3): RelationshipGalaxy3DCameraPose['position'] {
return { x: vector.x, y: vector.y, z: vector.z }
}
@@ -6,6 +6,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
applyRelationshipGalaxy3DSafeArea,
buildRelationshipGalaxy3DViewOffset,
buildRelationshipGalaxy3DFitCameraPose,
} from './relationship-galaxy-3d-camera'
@@ -30,7 +31,7 @@ test('fits the panorama with enough padding for a selected relationship network'
assert.deepEqual(pose.target, { x: 0, y: 0, z: 0 })
})
test('shifts and expands 3D camera pose so the focused node network fits left of a right-side panel', () => {
test('keeps the 3D orbit target fixed while expanding camera distance for a right-side panel', () => {
const pose = applyRelationshipGalaxy3DSafeArea(
{
position: { x: 0, y: 0, z: 1000 },
@@ -44,10 +45,36 @@ test('shifts and expands 3D camera pose so the focused node network fits left of
}
)
assert.ok(pose.position.x > 769)
assert.ok(pose.position.x < 771)
assert.equal(pose.position.x, 0)
assert.equal(pose.position.y, 0)
assert.ok(pose.position.z > 1650)
assert.ok(pose.position.z < 1670)
assert.deepEqual(pose.target, { x: pose.position.x, y: 0, z: 0 })
assert.deepEqual(pose.target, { x: 0, y: 0, z: 0 })
})
test('builds a 3D camera view offset that moves the focus into the visible area', () => {
assert.deepEqual(
buildRelationshipGalaxy3DViewOffset({
viewportWidth: 1000,
viewportHeight: 500,
safeInsetRight: 400,
}),
{
fullWidth: 1400,
fullHeight: 500,
offsetX: 400,
offsetY: 0,
width: 1000,
height: 500,
}
)
assert.equal(
buildRelationshipGalaxy3DViewOffset({
viewportWidth: 1000,
viewportHeight: 500,
safeInsetRight: 0,
}),
null
)
})
@@ -19,6 +19,15 @@ export interface RelationshipGalaxy3DSafeAreaOptions {
fovDegrees: number
}
export interface RelationshipGalaxy3DViewOffset {
fullWidth: number
fullHeight: number
offsetX: number
offsetY: number
width: number
height: number
}
const FIT_CAMERA_PADDING_SCALE = 1.3
export function buildRelationshipGalaxy3DFitCameraPose(
@@ -42,7 +51,6 @@ export function applyRelationshipGalaxy3DSafeArea(
): RelationshipGalaxy3DCameraPose {
const inset = normalizeRelationshipGalaxySafeInsetRight(options)
const viewportWidth = Math.max(1, options.viewportWidth)
const viewportHeight = Math.max(1, options.viewportHeight)
if (inset <= 0) return pose
const forward = normalizeVector({
@@ -50,27 +58,40 @@ export function applyRelationshipGalaxy3DSafeArea(
y: pose.target.y - pose.position.y,
z: pose.target.z - pose.position.z,
})
const right = normalizeVector(crossVector(forward, { x: 0, y: 1, z: 0 }))
const distance = Math.max(1, distanceBetween(pose.position, pose.target))
// The side panel reduces usable width, so move back before shifting the target into the visible area.
// The side panel reduces usable width. Move the camera back, but keep the orbit target fixed.
const visibleWidthRatio = Math.max(0.001, (viewportWidth - inset) / viewportWidth)
const expandedDistance = distance / visibleWidthRatio
const visibleHeight = 2 * expandedDistance * Math.tan((Math.max(1, options.fovDegrees) * Math.PI) / 360)
const visibleWidth = visibleHeight * (viewportWidth / viewportHeight)
const offset = (inset / 2 / viewportWidth) * visibleWidth
const shift = {
x: right.x * offset,
y: right.y * offset,
z: right.z * offset,
}
const shiftedTarget = addVector(pose.target, shift)
return {
position: addVector(shiftedTarget, multiplyVector(forward, -expandedDistance)),
target: shiftedTarget,
position: addVector(pose.target, multiplyVector(forward, -expandedDistance)),
target: pose.target,
}
}
export function buildRelationshipGalaxy3DViewOffset(
options: RelationshipGalaxyViewportViewOffsetOptions
): RelationshipGalaxy3DViewOffset | null {
const viewportWidth = Math.max(1, Math.floor(options.viewportWidth))
const viewportHeight = Math.max(1, Math.floor(options.viewportHeight))
const inset = Math.floor(normalizeRelationshipGalaxySafeInsetRight(options))
if (inset <= 0) return null
return {
fullWidth: viewportWidth + inset,
fullHeight: viewportHeight,
offsetX: inset,
offsetY: 0,
width: viewportWidth,
height: viewportHeight,
}
}
type RelationshipGalaxyViewportViewOffsetOptions = Pick<
RelationshipGalaxy3DSafeAreaOptions,
'viewportWidth' | 'viewportHeight' | 'safeInsetRight'
>
function addVector(a: RelationshipGalaxy3DVector, b: RelationshipGalaxy3DVector): RelationshipGalaxy3DVector {
return {
x: a.x + b.x,
@@ -100,11 +121,3 @@ function normalizeVector(vector: RelationshipGalaxy3DVector): RelationshipGalaxy
z: vector.z / length,
}
}
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,
}
}
@@ -44,4 +44,27 @@ describe('RelationshipGalaxyThreeCanvas scene wiring', () => {
'selectedKey changes affect 3D topology and must not be handled as a label-only update'
)
})
it('uses camera view offset for right panel safe area instead of moving the orbit target', () => {
const source = readCanvasSource()
const resizeCanvas = source.slice(
source.indexOf('function resizeCanvas'),
source.indexOf('function handlePointerMove')
)
const applySafeArea = source.slice(
source.indexOf('function applySafeAreaToCameraPose'),
source.indexOf('function vectorToPose')
)
assert.ok(source.includes('camera.setViewOffset('), '3D safe area should use projection view offset')
assert.ok(source.includes('camera.clearViewOffset()'), '3D safe area should clear projection offset when closed')
assert.ok(
resizeCanvas.includes('applyCameraSafeAreaProjection(size)'),
'resizing the canvas must preserve the current safe-area projection'
)
assert.ok(
applySafeArea.includes('applyCameraSafeAreaProjection(size)'),
'focus and fit camera poses must apply the safe-area projection separately from the orbit target'
)
})
})