fix(ui): keep the galaxy canvas mounted while a scope re-lays out

The organic layout returned nothing while it computed, so every selection,
expansion and preview-to-complete step unmounted the scene and built a new
WebGL canvas, and each new picture counted as a fit request. The camera
jumped to a new orientation every time, and the quick unmount let React
Three Fiber connect its events to a canvas that was already gone, which is
the intermittent "reading 'addEventListener'" page error.

The layout hook now exposes the previous arrangement as stale while the next
one runs, and the panel keeps showing it (or the whole graph before the first
scoped picture). In the scoped Galaxy a fit is requested by the scope itself
(root, direction, edge types), not by each picture; nodes that arrive later
are brought into view by stepping the camera back along its view direction,
as long as the reader has not moved it. A fly-to set before the scene mounted
is not replayed.

Signed-off-by: Bernhard Jackiewicz <bj@techport.io>
This commit is contained in:
Bernhard Jackiewicz
2026-10-02 18:42:11 +02:00
parent 6a9a9d03ae
commit e341a4988e
8 changed files with 403 additions and 19 deletions
@@ -0,0 +1,97 @@
// @vitest-environment jsdom
/*
* Review-Befund G1: der Canvas wurde bei jedem Scope-Schritt neu aufgebaut und
* die Kamera bei jedem Bild neu eingepasst. Hier steht die Szene als Attrappe,
* die mitzaehlt, wie oft sie auf- und abgebaut wird; die Beziehungen kommen
* ueber dieselbe RPC-Strecke wie im Betrieb.
*/
import { act, useEffect } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import GalaxyPanel from './GalaxyPanel';
import type { GraphData, GraphEdge, GraphNode } from './types';
const scene = vi.hoisted(() => ({ mounts: 0, unmounts: 0, renders: 0 }));
vi.mock('./GraphScene', async importOriginal => ({
...await importOriginal<typeof import('./GraphScene')>(),
GraphScene: ({ data }: { data: GraphData }) => {
scene.renders += 1;
useEffect(() => { scene.mounts += 1; return () => { scene.unmounts += 1; }; }, []);
return <output data-testid="scene-nodes">{data.nodes.length}</output>;
},
}));
const node = (id: number): GraphNode => ({ id, name: `n${id}`, qualified_name: `sample.n${id}`, label: 'Function',
file_path: `src/n${id}.ts`, start_line: 1, end_line: 5, x: id * 10, y: 0, z: 0, size: 2, color: '#999999' });
const nodes = [1, 2, 3, 4, 5].map(node);
const edges: GraphEdge[] = [
{ source: 1, target: 2, type: 'CALLS', line: 3 }, { source: 3, target: 1, type: 'CALLS', line: 4 },
{ source: 2, target: 4, type: 'CALLS', line: 2 }, { source: 4, target: 5, type: 'IMPORTS' },
];
/** /api/layout plus the two query_graph shapes the scope loader sends. */
function graphFetch() {
return vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
if (String(url).includes('/api/layout')) return new Response(JSON.stringify({ nodes, edges, total_nodes: nodes.length }));
const request = JSON.parse(String(init?.body)) as { params: { name: string; arguments: { query: string } } };
if (request.params.name === 'index_status') return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { content: [{ type: 'text', text: JSON.stringify({ indexed_at: 'generation-1' }) }] } }));
const query = request.params.arguments.query;
const columns = (prefix: string) => ['id', 'label', 'name', 'qn', 'file', 'start_line', 'end_line'].map(key => prefix + key);
const values = (entry: GraphNode) => [entry.id, entry.label, entry.name, entry.qualified_name ?? '', entry.file_path ?? '', entry.start_line ?? '', entry.end_line ?? ''].map(String);
const names = [...query.matchAll(/qualified_name = "([^"]+)"/g)].map(match => match[1]);
let cols: string[], rows: string[][];
if (query.startsWith('MATCH (n)')) {
cols = columns(''); rows = nodes.filter(entry => names.includes(entry.qualified_name ?? '')).map(values);
} else {
cols = ['edge_id', 'edge_type', 'edge_line', ...columns('a_'), ...columns('b_')];
rows = edges.flatMap((edge, i) => {
const a = nodes.find(entry => entry.id === edge.source)!, b = nodes.find(entry => entry.id === edge.target)!;
return names.includes(a.qualified_name ?? '') || names.includes(b.qualified_name ?? '')
? [[String(i + 1), edge.type, String(edge.line ?? ''), ...values(a), ...values(b)]] : [];
});
}
const text = `rows: ${rows.length} (cols: ${cols.join(' ')})\n${rows.map(row => ' ' + row.map(value => JSON.stringify(value || '-')).join(' ')).join('\n')}\ntotal: ${rows.length}\nhas_more: false\ntruncated: false`;
return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: { content: [{ type: 'text', text }] } }));
});
}
let host: HTMLDivElement, root: Root;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
scene.mounts = 0; scene.unmounts = 0; scene.renders = 0;
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); globalThis.__atlasGalaxy = undefined; });
const seam = () => globalThis.__atlasGalaxy!;
const settle = (check: () => void) => vi.waitFor(async () => {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
check();
});
it('keeps one scene mounted through selection and expansion and fits once per selected root', async () => {
await act(async () => root.render(<GalaxyPanel project="sample" visible workspaceExpanded onOpenNode={vi.fn()} fetch={graphFetch()} />));
await settle(() => expect(seam().nodes).toBe(5));
expect(scene.mounts).toBe(1);
const fitsBefore = seam().fits;
await act(async () => { seam().clickNode('sample.n1'); });
await settle(() => {
expect(seam().nodes).toBe(3);
expect(host.querySelector('.atlas-graph-scope-count')?.textContent).toBe('3 nodes · 2 edges');
});
expect(scene.mounts).toBe(1); expect(scene.unmounts).toBe(0);
expect(seam().fits).toBe(fitsBefore + 1);
const expand = [...host.querySelectorAll('button')].find(button => button.textContent === 'Expand +1')!;
await act(async () => expand.click());
await settle(() => expect(seam().nodes).toBe(4));
expect(scene.mounts).toBe(1); expect(scene.unmounts).toBe(0);
// An expansion is the same scope: no new fit, the scene keeps the reader's camera.
expect(seam().fits).toBe(fitsBefore + 1);
await act(async () => { seam().clickNode('sample.n4'); });
await settle(() => expect(seam().lastFit?.nodes).toBe(3));
expect(scene.mounts).toBe(1);
expect(seam().fits).toBe(fitsBefore + 2);
});
+46 -12
View File
@@ -769,11 +769,25 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
}, [scope.result, scope.scope, organicKey, scope.depth]);
const organicTask = useOrganicLayout(scope.scope ? scope.result?.data : undefined, organicOptions);
const organic = organicTask.result;
// Jedes gezeichnete Bild desselben Scopes haelt seine Punkte fest, auch die
// Vorschau: sonst springt die Wolke beim Wechsel zur vollstaendigen Antwort.
useEffect(() => {
if (scope.complete && organic) organicHistory.current = { key: organicKey, depth: scope.depth, data: organic.data };
if (organic) organicHistory.current = { key: organicKey, depth: scope.result?.depth ?? scope.depth, data: organic.data };
if (!scope.scope) organicHistory.current = undefined;
}, [scope.complete, scope.scope, organic, organicKey, scope.depth]);
}, [scope.scope, scope.result, organic, organicKey, scope.depth]);
const data = scope.scope ? organic?.data : layout;
/*
* Was die Szene zeigt, solange das naechste Bild noch angeordnet wird.
*
* Bis hierher war `data` waehrend jeder Anordnung leer, die Szene wurde
* ausgehaengt, und jeder Schritt (Auswahl, Expand, Vorschau zu Antwort)
* baute den Canvas neu auf (Review-Befund G1). Jetzt bleibt das letzte Bild
* stehen, bis das neue fertig ist; vor dem ersten Bild eines Scopes ist das
* der ganze Graph, der gerade noch zu sehen war. `data` bleibt dabei das
* AKTUELLE Bild: Einpassung, Legende und Zaehler rechnen nie mit dem alten.
*/
const sceneData = scope.scope ? (organic ?? organicTask.stale)?.data ?? layout : layout;
const sceneScoped = Boolean(scope.scope) && sceneData !== layout;
const scopedRoot = scope.result?.roots.size === 1
? scope.result.data.nodes.find(node => scope.result!.roots.has(node.id)) : undefined;
const notifiedScopedSymbol = useRef('');
@@ -1121,16 +1135,18 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
const hiddenHere = kinds.filter((kind) => hiddenKinds.has(kind.type)).length;
const kindNote = edgeKindNote(kinds.length, hiddenHere);
// Die Szene bekommt in der Galaxie das stehende Bild (siehe `sceneData`).
const scenePicture = mode === 'hierarchy' ? picture : sceneData;
const shown = useMemo(
() => {
if (!picture) return undefined;
const filtered = props.workspaceExpanded && scope.scope ? picture : withoutEdgeKinds(picture, hiddenKinds);
if (!scenePicture) return undefined;
const filtered = props.workspaceExpanded && scope.scope ? scenePicture : withoutEdgeKinds(scenePicture, hiddenKinds);
const requiredNames = new Set(data?.nodes.filter(node => scope.result?.roots.has(node.id)).map(node => node.qualified_name));
return props.workspaceExpanded ? limitGraphRender(filtered, nodeBudget, edgeBudget, mode === 'hierarchy'
? new Set(filtered.nodes.filter(node => requiredNames.has(node.qualified_name)).map(node => node.id))
: scope.result?.roots) : filtered;
},
[picture, hiddenKinds, props.workspaceExpanded, nodeBudget, edgeBudget, scope.result?.roots, scope.scope, mode, data],
[scenePicture, hiddenKinds, props.workspaceExpanded, nodeBudget, edgeBudget, scope.result?.roots, scope.scope, mode, data],
);
const traceKinds = useMemo(() => edgeKinds(shown), [shown]);
@@ -1439,8 +1455,22 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
const refitNow = useCallback(() => setOwnFit((count) => count + 1), []);
const coverageShadow = useMemo(() => showCoverage && props.workspaceExpanded && mode === 'galaxy' && data
? buildCoverageShadow(data) : null, [data, props.workspaceExpanded, mode, showCoverage]);
const fitRequest = useMemo(() => ({ picture, mode, projection, requestedFit, ownFit, coverageShadow }),
[picture, mode, projection, requestedFit, ownFit, coverageShadow]);
/*
* Was eine neue Einpassung verlangt.
*
* In der gewaehlten Galaxie ist das der Scope selbst (Wurzel, Richtung,
* Kantenarten) und nicht sein Bild: ein Expand, eine Vorschau und die
* vollstaendige Antwort danach sind derselbe Ausschnitt, und eine Kamera,
* die bei jedem davon neu einpasst, stellt den Leser jedes Mal anders hin
* (Review-Befund G1). Was dabei neu hinzukommt, holt die Szene selbst ins
* Bild, solange der Leser die Kamera nicht bewegt hat (GraphScene,
* `FitContainment`).
*/
const fitScope = mode === 'galaxy' && props.workspaceExpanded && scope.scope ? organicKey : undefined;
const fitPicture = fitScope === undefined ? picture : undefined;
const fitProjection = fitScope === undefined ? projection : undefined;
const fitRequest = useMemo(() => ({ scope: fitScope, picture: fitPicture, projection: fitProjection, mode, requestedFit, ownFit, coverageShadow }),
[fitScope, fitPicture, fitProjection, mode, requestedFit, ownFit, coverageShadow]);
const lastFitRequest = useRef<typeof fitRequest | undefined>(undefined);
useEffect(() => {
@@ -1454,10 +1484,12 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
skipNextFit.current = false;
return;
}
lastFitRequest.current = fitRequest;
if (mode === 'hierarchy' && projection !== undefined) {
const box = hierarchyFrame(projection);
const target = computeFrameTarget(box, aspect);
// Erst eine Einpassung, die wirklich lief, verbraucht die Anfrage:
// ein Scope, dessen Bild noch angeordnet wird, passt danach ein.
lastFitRequest.current = fitRequest;
setCameraTarget(target);
targetChanges.current += 1;
fitCount.current += 1;
@@ -1484,6 +1516,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
if (target === null) {
return;
}
lastFitRequest.current = fitRequest;
setCameraTarget(target);
targetChanges.current += 1;
fitCount.current += 1;
@@ -1742,7 +1775,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
mode === 'hierarchy' ? HIERARCHY_DISPLAY : DEFAULT_DISPLAY_SETTINGS,
choice,
);
const layoutState = error.length > 0 ? 'failed' : data === undefined ? 'loading' : 'ready';
// Ein stehendes Bild ist kein Ladezustand: die Werkzeugleiste sagt, dass angeordnet wird.
const layoutState = error.length > 0 ? 'failed' : sceneData === undefined ? 'loading' : 'ready';
// Die Hierarchie braucht das Layout nicht: sie faerbt sich damit, sie lebt
// nicht davon. Ein Walk, dessen Bild dasteht, ist fertig.
const state = mode === 'hierarchy' ? projection ? 'ready'
@@ -1759,7 +1793,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
? `layout unavailable: ${error}`
: layoutState === 'loading'
? `loading the layout of ${project.length > 0 ? project : 'no project'} ...`
: layoutSummary(data as GraphData, LAYOUT_NODE_BUDGET);
: layoutSummary((data ?? sceneData) as GraphData, LAYOUT_NODE_BUDGET);
/*
* Die zweite Zeile des Kopfes: woraus die Linien bestehen (W9).
@@ -2385,9 +2419,9 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
{shown !== undefined && everVisible.current && (
<GraphScene
active={visible}
separateNodes={mode === 'galaxy' && Boolean(scope.scope)}
separateNodes={mode === 'galaxy' && sceneScoped}
onRenderBusyChange={setSpacingBusy}
idleRotation={mode === 'galaxy' && !scope.scope}
idleRotation={mode === 'galaxy' && !sceneScoped}
data={shown}
display={display}
highlightedIds={scope.scope && scope.depth > 1 ? null : highlighted}
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { act, createRef } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PerspectiveCamera, Vector3 } from 'three';
import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib';
import { CameraAnimator, FitContainment, type CameraTarget } from './GraphScene';
import type { GraphNode } from './types';
const fiber = vi.hoisted(() => ({ frames: [] as ((state: unknown, delta: number) => void)[] }));
vi.mock('@react-three/fiber', async importOriginal => ({
...await importOriginal<typeof import('@react-three/fiber')>(),
useFrame: (callback: (state: unknown, delta: number) => void) => { fiber.frames.push(callback); },
useThree: (select?: (state: unknown) => unknown) => (select ? select(scene) : scene),
}));
let scene: { camera: PerspectiveCamera; size: { width: number; height: number } };
let host: HTMLDivElement, root: Root;
const controlsRef = createRef<OrbitControlsImpl | null>() as { current: OrbitControlsImpl | null };
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
scene = { camera: new PerspectiveCamera(50, 1.5, 0.1, 100000), size: { width: 900, height: 600 } };
scene.camera.position.set(0, 0, 800); scene.camera.lookAt(0, 0, 0); scene.camera.updateMatrixWorld();
fiber.frames = []; controlsRef.current = null;
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(() => { act(() => root.unmount()); host.remove(); });
const runFrames = (count: number) => act(() => { for (let i = 0; i < count; i++) fiber.frames.at(-1)?.({}, 1 / 60); });
const flyTo = (x: number): CameraTarget => ({ position: new Vector3(x, 0, 300), lookAt: new Vector3(x, 0, 0) });
const fitTo = (z: number): CameraTarget => ({ position: new Vector3(0, 0, z), lookAt: new Vector3(0, 0, 0), immediate: true });
describe('CameraAnimator', () => {
it('does not replay a fly-to that was set before the scene mounted, but flies to the next one', () => {
act(() => root.render(<CameraAnimator target={flyTo(500)} controlsRef={controlsRef} />));
runFrames(120);
expect(scene.camera.position.toArray()).toEqual([0, 0, 800]);
act(() => root.render(<CameraAnimator target={flyTo(-200)} controlsRef={controlsRef} />));
runFrames(120);
// The fly-to eases toward its goal; it is clearly on its way there.
expect(scene.camera.position.x).toBeLessThan(-150);
});
it('still applies a fit that is present at mount', () => {
act(() => root.render(<CameraAnimator target={fitTo(420)} controlsRef={controlsRef} />));
expect(scene.camera.position.toArray()).toEqual([0, 0, 420]);
});
});
describe('FitContainment', () => {
const node = (id: number, x: number, y: number): GraphNode => ({ id, x, y, z: 0, name: `n${id}`, label: 'Function', size: 3, color: '#abcdef' });
const moved = { current: false };
const render = (nodes: GraphNode[], target: CameraTarget | null) => act(() => root.render(
<FitContainment nodes={nodes} target={target} controlsRef={controlsRef} moved={moved} enabled />));
it('steps back along the view direction after a fit until added nodes are inside, keeping the direction', () => {
moved.current = false;
const fit = fitTo(800);
scene.camera.position.copy(fit.position);
render([node(1, 0, 0)], fit);
expect(scene.camera.position.z).toBe(800);
render([node(1, 0, 0), node(2, 0, 900)], fit);
expect(scene.camera.position.x).toBe(0); expect(scene.camera.position.y).toBe(0);
expect(scene.camera.position.z).toBeGreaterThan(800);
const half = Math.tan(25 * Math.PI / 180);
expect(900 / (scene.camera.position.z * half)).toBeLessThan(1);
});
it('leaves the camera alone after the reader moved it or after a fly-to', () => {
moved.current = false;
const fit = fitTo(800);
render([node(1, 0, 0)], fit);
moved.current = true;
render([node(1, 0, 0), node(2, 0, 900)], fit);
expect(scene.camera.position.z).toBe(800);
const fly = flyTo(0);
render([node(1, 0, 0), node(2, 0, 900)], fly);
render([node(1, 0, 0), node(2, 0, 1900)], fly);
expect(scene.camera.position.z).toBe(800);
});
});
+85 -2
View File
@@ -84,6 +84,12 @@
* ihre Geschwindigkeit ueber das neue Ziel hinweg; ihr Daempfungsgrad ist
* genau 1, sie kann also nicht ueberschwingen. Ohne das Feld ist jeder
* bestehende Anflug Zeichen fuer Zeichen der von vorher.
* 14. Neu (Review-Call 2026-10-02): CameraAnimator spielt einen Anflug, der
* schon vor dem Aufsetzen der Szene gesetzt war, nicht nach. Er gehoert
* zum Bild davor; eine Einpassung gilt weiter.
* 15. Neu (Review-Call 2026-10-02): `FitContainment` holt Knoten, die nach
* einer Einpassung dazukommen, ins Bild, indem die Kamera entlang ihrer
* Blickrichtung zuruecktritt, solange der Leser sie nicht bewegt hat.
*/
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
@@ -99,7 +105,7 @@ import { EdgeLines } from './EdgeLines';
import { NodeLabels } from './NodeLabels';
import { ScreenNodeSeparation } from './ScreenNodeSeparation';
import type { LabelBox } from './NodeLabels';
import { fitCamera, flatBounds, frameDistance, orthographicZoom } from './camera-frame';
import { containBackoff, fitCamera, flatBounds, frameDistance, orthographicZoom } from './camera-frame';
import type { CameraFit, FrameBox } from './camera-frame';
import { FRAME_WINDOW_MS, recordFrameWindow, recordSceneFacts } from './frame-rate';
import { springStep } from '../agents/agent-motion';
@@ -162,7 +168,7 @@ const FLY_LERP_PER_FRAME = 0.08;
* einen ganzen Anflug in einem Sprung erledigen. */
const FLY_MAX_DELTA = 0.1;
function CameraAnimator({
export function CameraAnimator({
target,
controlsRef,
flat = false,
@@ -180,11 +186,23 @@ function CameraAnimator({
position: [0, 0, 0] as [number, number, number],
lookAt: [0, 0, 0] as [number, number, number],
});
/* Das Ziel, das beim Aufsetzen schon dastand. Siehe Aenderung 14. */
const mountedWith = useRef(target);
useEffect(() => {
if (!target) {
return;
}
/*
* Aenderung 14: ein Anflug, der schon vor dem Aufsetzen dieser Szene
* gesetzt war, gehoert zum Bild davor. Ihn jetzt abzuspielen hiesse,
* die Kamera zu einem Ziel zu fahren, das niemand mehr meint
* (Review-Befund G1). Eine Einpassung (`immediate`) gilt weiter: sie ist
* die Lage, in der das Bild anfaengt.
*/
if (target === mountedWith.current && target.immediate !== true) {
return;
}
targetRef.current = target;
progress.current = 0;
/*
@@ -319,6 +337,66 @@ function CameraAnimator({
return null;
}
/*
* Aenderung 15: was nach einer Einpassung dazukommt, kommt ins Bild.
*
* Ein Expand und die vollstaendige Antwort nach einer Vorschau passen nicht
* neu ein (Review-Befund G1): eine neue Einpassung stellte die Kamera jedes
* Mal anders hin. Die neuen Knoten liegen aber oft ausserhalb des alten
* Rahmens, und die Trennung auf dem Schirm schiebt Knoten nach aussen. Also
* tritt die Kamera entlang ihrer Blickrichtung genau so weit zurueck, dass
* jeder gezeichnete Knoten mit Rand im Bild liegt (`containBackoff`). Richtung,
* Oben und Drehpunkt bleiben. Das gilt nur, solange die letzte Lage eine
* Einpassung war und der Leser die Kamera seitdem nicht bewegt hat: wer
* hineingezoomt hat, wollte genau diesen Ausschnitt.
*/
export function FitContainment({
nodes,
target,
controlsRef,
moved,
enabled,
}: {
nodes: readonly GraphNode[];
target: CameraTarget | null;
controlsRef: RefObject<OrbitControlsImpl | null>;
/** Wird von der Steuerung gesetzt, sobald der Leser zieht, dreht oder zoomt. */
moved: RefObject<boolean>;
enabled: boolean;
}): null {
const camera = useThree((state) => state.camera);
const size = useThree((state) => state.size);
const armed = useRef(false);
useEffect(() => {
if (target === null) {
return;
}
armed.current = target.immediate === true;
moved.current = false;
}, [target, moved]);
useEffect(() => {
if (!enabled || !armed.current || moved.current || size.height <= 0
|| !(camera instanceof THREE.PerspectiveCamera)) {
return;
}
camera.updateMatrixWorld();
const view = nodes.map((node) =>
new THREE.Vector3(node.x, node.y, node.z).applyMatrix4(camera.matrixWorldInverse));
const back = containBackoff(view, camera.fov, size.width / size.height);
if (!(back > 0.001)) {
return;
}
const direction = camera.getWorldDirection(new THREE.Vector3());
camera.position.addScaledVector(direction, -back);
camera.updateMatrixWorld();
controlsRef.current?.update();
}, [nodes, enabled, camera, size, controlsRef, moved]);
return null;
}
/* Bildratenmesser und Bildratendeckel (W10) */
/**
@@ -739,6 +817,8 @@ export function GraphScene({
const [hovered, setHovered] = useState<GraphNode | null>(null);
const [hoveredShadow, setHoveredShadow] = useState<CoverageShadowNode | null>(null);
const controlsRef = useRef<OrbitControlsImpl | null>(null);
/* Ob der Leser die Kamera seit der letzten Einpassung bewegt hat (Aenderung 15). */
const moved = useRef(false);
const background = useGraphBackgroundReset(() => { setHovered(null); setHoveredShadow(null); onBackgroundClick?.(); });
const flat = projection === 'flat';
const sceneNodes = useMemo(() => coverageShadow ? [...data.nodes, ...coverageShadow.nodes] : data.nodes, [data.nodes, coverageShadow]);
@@ -902,6 +982,8 @@ export function GraphScene({
{resolveCoverageShadowNode(renderedShadow, hoveredShadow) && hoveredShadow && renderShadowTooltip?.(hoveredShadow)}
<CameraAnimator target={cameraTarget} controlsRef={controlsRef} flat={flat} />
<FitContainment nodes={renderedNodes} target={cameraTarget} controlsRef={controlsRef} moved={moved}
enabled={separateNodes && !flat} />
{separateNodes && <ScreenNodeSeparation nodes={sceneNodes} active={active} onChange={receiveSeparation} onBusyChange={onRenderBusyChange} />}
<FitProbe nodes={renderedNodes} />
<IdleAutoRotate controlsRef={controlsRef} enabled={!flat && idleRotation && !separateNodes} />
@@ -941,6 +1023,7 @@ export function GraphScene({
minDistance={Math.max(5, sceneRadius * 0.02)}
maxDistance={Math.max(sceneRadius, orbitReach) * 4}
autoRotateSpeed={0.4}
onStart={() => { moved.current = true; }}
/>
{flat && <FlatTarget box={flatBox} controlsRef={controlsRef} />}
</Canvas>
+37
View File
@@ -15,6 +15,7 @@ import {
FLAT_MIN_ZOOM,
FRAME_MARGIN,
FRAME_MIN_DISTANCE,
containBackoff,
fitCamera,
flatBounds,
frameDistance,
@@ -330,3 +331,39 @@ describe('fitCamera', () => {
expect(fit.center).toEqual({ x: 40, y: -12, z: 5 });
});
});
/*
* Was nach einer Einpassung dazukommt (Review-Befund G1).
*
* Ein Expand passt nicht neu ein; die Kamera tritt nur so weit zurueck, dass
* die neuen Knoten mit Rand ins Bild kommen. Gerechnet im Blickraum: vor der
* Kamera ist z negativ.
*/
describe('containBackoff', () => {
const ASPECT = 1.5;
const half = Math.tan((FOV * Math.PI) / 360);
it('bleibt stehen, solange jeder Punkt mit Rand im Bild liegt', () => {
const points = [{ x: 0, y: 0, z: -400 }, { x: 100, y: -80, z: -500 }];
expect(containBackoff(points, FOV, ASPECT)).toBe(0);
});
it('tritt genau so weit zurueck, dass der aeusserste Punkt am Rand steht', () => {
const point = { x: 0, y: 300, z: -200 };
const back = containBackoff([point], FOV, ASPECT);
const depth = 200 + back;
expect((point.y * FRAME_MARGIN) / (depth * half)).toBeCloseTo(1, 6);
});
it('rechnet die Breite mit dem Seitenverhaeltnis und holt auch Punkte hinter der Kamera', () => {
const wide = { x: 600, y: 0, z: -100 };
const back = containBackoff([wide], FOV, ASPECT);
expect((wide.x * FRAME_MARGIN) / ((100 + back) * half * ASPECT)).toBeCloseTo(1, 6);
expect(containBackoff([{ x: 0, y: 0, z: 50 }], FOV, ASPECT)).toBeCloseTo(50, 6);
});
it('uebergeht unbrauchbare Zahlen statt NaN zu liefern', () => {
expect(containBackoff([{ x: Number.NaN, y: 0, z: -1 }], FOV, ASPECT)).toBe(0);
expect(containBackoff([], FOV, ASPECT)).toBe(0);
});
});
+31
View File
@@ -414,6 +414,37 @@ export function principalFrame(points: readonly Vector3Like[]): PrincipalFrame {
};
}
/**
* Wie weit eine Kamera entlang ihrer Blickrichtung zuruecktreten muss, damit
* jeder Punkt mit Rand im Bild liegt.
*
* Die Punkte stehen im Blickraum der Kamera (vor ihr ist z negativ). Ein
* Schritt zurueck aendert ihr x und y nicht, nur ihre Tiefe, also ist die
* Antwort fuer jeden Punkt geschlossen ausrechenbar: die Tiefe, bei der er
* genau am Rand steht, weniger der Tiefe, die er hat. Null heisst: alles ist
* schon im Bild, und die Kamera bleibt, wo sie ist. Sie tritt nie vor.
*/
export function containBackoff(
points: readonly Vector3Like[],
fovDegrees: number,
aspect: number,
margin: number = FRAME_MARGIN,
): number {
const fov = (Math.max(1, Math.min(179, fovDegrees)) * Math.PI) / 180;
const scale = Number.isFinite(margin) && margin > 0 ? margin : 1;
const vertical = Math.tan(fov / 2) / scale;
const horizontal = vertical * (Number.isFinite(aspect) && aspect > 0 ? aspect : 1);
let back = 0;
for (const point of points) {
if (!Number.isFinite(point.x) || !Number.isFinite(point.y) || !Number.isFinite(point.z)) {
continue;
}
const needed = Math.max(Math.abs(point.x) / horizontal, Math.abs(point.y) / vertical);
back = Math.max(back, needed + point.z);
}
return back;
}
/** Wo die Kamera steht, damit die ganze Wolke im Bild ist. */
export interface CameraFit {
/** Der Punkt, auf den die Kamera sieht. */
@@ -44,14 +44,27 @@ it('ignores obsolete replies and never presents the previous source as current',
expect(observed.result?.data).toBe(first);
await render(second);
expect(observed.result).toBeUndefined(); expect(observed.loading).toBe(true);
// The previous picture stays available to keep a scene mounted, but only as stale.
expect(observed.stale?.data).toBe(first);
await act(async () => worker.reply(firstRequest.sequence, first));
expect(observed.result).toBeUndefined();
expect(observed.result).toBeUndefined(); expect(observed.stale?.data).toBe(first);
const secondRequest = worker.postMessage.mock.calls[1]![0];
await act(async () => worker.reply(secondRequest.sequence, second));
expect(observed.result?.data).toBe(second); expect(observed.loading).toBe(false);
expect(observed.stale).toBeUndefined();
expect(WorkerStub.instances).toHaveLength(1);
});
it('forgets the stale picture after a gap without input', async () => {
await render(first);
const worker = WorkerStub.instances[0]!;
await act(async () => worker.reply(worker.postMessage.mock.calls[0]![0].sequence, first));
await render(undefined);
expect(observed.result).toBeUndefined(); expect(observed.stale).toBeUndefined(); expect(observed.loading).toBe(false);
await render(second);
expect(observed.loading).toBe(true); expect(observed.stale).toBeUndefined();
});
it('retains its worker while disabled and terminates it on unmount', async () => {
await render(first);
const worker = WorkerStub.instances[0]!;
+13 -4
View File
@@ -11,11 +11,19 @@ interface Reading {
error?: string;
}
export interface OrganicLayoutReading { result?: OrganicClusterLayout; loading: boolean; error?: string }
export interface OrganicLayoutReading {
result?: OrganicClusterLayout;
/** The last arrangement of an earlier input, kept only while the current
* one is computing so a scene can stay on screen. Never the current result. */
stale?: OrganicClusterLayout;
loading: boolean;
error?: string;
}
const EMPTY_OPTIONS: OrganicClusterOptions = {};
/** Off-thread layout leaves the last painted frame responsive while work runs.
* Input references must be stable; a new source never inherits an old result. */
* Input references must be stable; a new source never inherits an old result,
* it can only see it as `stale`. A gap without input forgets the old result. */
export function useOrganicLayout(data: GraphData | undefined, options: OrganicClusterOptions = EMPTY_OPTIONS, enabled = true): OrganicLayoutReading {
const worker = useRef<Worker | undefined>(undefined);
const sequence = useRef(0);
@@ -29,7 +37,7 @@ export function useOrganicLayout(data: GraphData | undefined, options: OrganicCl
useEffect(() => {
const requestId = ++sequence.current;
if (!enabled || !data) return;
if (!enabled || !data) { setReading(undefined); return; }
let active = true;
let timer: number | undefined;
const accept = (response: OrganicLayoutResponse) => {
@@ -73,5 +81,6 @@ export function useOrganicLayout(data: GraphData | undefined, options: OrganicCl
}, [data, roots, previous, enabled]);
const current = enabled && data && reading?.data === data && reading.roots === roots && reading.previous === previous ? reading : undefined;
return { result: current?.result, error: current?.error, loading: Boolean(enabled && data && !current) };
const loading = Boolean(enabled && data && !current);
return { result: current?.result, stale: loading ? reading?.result : undefined, error: current?.error, loading };
}