diff --git a/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx new file mode 100644 index 000000000..b83a9fd6c --- /dev/null +++ b/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx @@ -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(), + GraphScene: ({ data }: { data: GraphData }) => { + scene.renders += 1; + useEffect(() => { scene.mounts += 1; return () => { scene.unmounts += 1; }; }, []); + return {data.nodes.length}; + }, +})); + +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).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()); + 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); +}); diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index 3590cc852..db44174c8 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -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(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 && ( 1 ? null : highlighted} diff --git a/graph-ui/src/galaxy/GraphScene.camera.test.tsx b/graph-ui/src/galaxy/GraphScene.camera.test.tsx new file mode 100644 index 000000000..e274ec68c --- /dev/null +++ b/graph-ui/src/galaxy/GraphScene.camera.test.tsx @@ -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(), + 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() as { current: OrbitControlsImpl | null }; +beforeEach(() => { + (globalThis as unknown as Record).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()); + runFrames(120); + expect(scene.camera.position.toArray()).toEqual([0, 0, 800]); + act(() => root.render()); + 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()); + 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( + )); + + 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); + }); +}); diff --git a/graph-ui/src/galaxy/GraphScene.tsx b/graph-ui/src/galaxy/GraphScene.tsx index cff37c219..52b3de151 100644 --- a/graph-ui/src/galaxy/GraphScene.tsx +++ b/graph-ui/src/galaxy/GraphScene.tsx @@ -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; + /** Wird von der Steuerung gesetzt, sobald der Leser zieht, dreht oder zoomt. */ + moved: RefObject; + 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(null); const [hoveredShadow, setHoveredShadow] = useState(null); const controlsRef = useRef(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)} + {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 && } diff --git a/graph-ui/src/galaxy/camera-frame.test.ts b/graph-ui/src/galaxy/camera-frame.test.ts index c4b752635..af4fbbb3d 100644 --- a/graph-ui/src/galaxy/camera-frame.test.ts +++ b/graph-ui/src/galaxy/camera-frame.test.ts @@ -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); + }); +}); diff --git a/graph-ui/src/galaxy/camera-frame.ts b/graph-ui/src/galaxy/camera-frame.ts index aa07fe97a..9adce99f2 100644 --- a/graph-ui/src/galaxy/camera-frame.ts +++ b/graph-ui/src/galaxy/camera-frame.ts @@ -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. */ diff --git a/graph-ui/src/galaxy/use-organic-layout.test.tsx b/graph-ui/src/galaxy/use-organic-layout.test.tsx index b5ef515e0..298b5c80a 100644 --- a/graph-ui/src/galaxy/use-organic-layout.test.tsx +++ b/graph-ui/src/galaxy/use-organic-layout.test.tsx @@ -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]!; diff --git a/graph-ui/src/galaxy/use-organic-layout.ts b/graph-ui/src/galaxy/use-organic-layout.ts index aee11ed8e..3a37c527c 100644 --- a/graph-ui/src/galaxy/use-organic-layout.ts +++ b/graph-ui/src/galaxy/use-organic-layout.ts @@ -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(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 }; }