Merge call-feedback stream: galaxy scope stability, root framing and path view

This commit is contained in:
Bernhard Jackiewicz
2026-10-02 20:23:34 +02:00
28 changed files with 1398 additions and 79 deletions
@@ -51,7 +51,7 @@ it('returns a standalone hierarchy selection to the overall Galaxy without stale
await act(async () => select.click());
const evidence = JSON.parse(onSelectionEvidence.mock.lastCall![0].text).evidence;
expect(evidence.selected.scope).toMatchObject({ kind: 'node', id: 1, name: 'node1' });
expect(evidence.scope).toMatchObject({ depth: 0, direction: 'both', edgeTypes: 'all' });
expect(evidence.scope).toMatchObject({ depth: 1, direction: 'both', edgeTypes: 'all' });
expect(evidence.limitations.state).not.toBe('complete-indexed-scope');
await act(async () => host.querySelector<HTMLButtonElement>('[data-mode="hierarchy"]')!.click());
expect(globalThis.__atlasGalaxy?.mode).toBe('hierarchy');
@@ -0,0 +1,274 @@
// @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, useLayoutEffect } 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';
import type { ScenePath } from './PathLayer';
import { GALAXY_LEGEND_KEY } from './galaxy-legend';
const scene = vi.hoisted(() => ({ mounts: 0, unmounts: 0, renders: 0, roots: undefined as ReadonlySet<number> | undefined,
highlighted: null as Set<number> | null, path: undefined as ScenePath | undefined,
/** Every committed picture: what the scene draws and what the edge type filter lists beside it. */
frames: [] as { nodes: number; types: string[]; traceRows: number }[] }));
vi.mock('./GraphScene', async importOriginal => ({
...await importOriginal<typeof import('./GraphScene')>(),
GraphScene: ({ data, rootIds, highlightedIds, path }: { data: GraphData; rootIds?: ReadonlySet<number>; highlightedIds: Set<number> | null; path?: ScenePath }) => {
scene.renders += 1; scene.roots = rootIds; scene.highlighted = highlightedIds; scene.path = path;
useEffect(() => { scene.mounts += 1; return () => { scene.unmounts += 1; }; }, []);
useLayoutEffect(() => {
scene.frames.push({ nodes: data.nodes.length, types: [...new Set(data.edges.map(edge => edge.type))].sort(),
traceRows: document.querySelectorAll('.atlas-trace-edge-row').length });
});
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' },
{ source: 1, target: 5, type: 'CALLS', line: 1 },
];
/** /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; scene.frames = [];
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;
// The whole graph keeps its render limits in the toolbar row.
expect(host.querySelector('.atlas-graph-limits')).toBeNull();
expect(host.querySelector('select[aria-label="Rendered node limit"]')).not.toBeNull();
await act(async () => { seam().clickNode('sample.n1'); });
await settle(() => {
expect(seam().nodes).toBe(4);
expect(host.querySelector('.atlas-graph-scope-count')?.textContent).toBe('4 nodes · 3 edges');
});
expect(scene.mounts).toBe(1); expect(scene.unmounts).toBe(0);
expect(seam().fits).toBe(fitsBefore + 1);
// In a scope the limits step back into a compact menu so the row does not wrap.
expect(host.querySelector('details.atlas-graph-limits select[aria-label="Rendered node limit"]')).not.toBeNull();
expect(host.querySelector('details.atlas-graph-limits select[aria-label="Rendered edge limit"]')).not.toBeNull();
// The root is marked, sits at the origin and the fit is centred on it.
expect([...scene.roots ?? []]).toEqual([1]);
expect(seam().lastFit?.center?.map(value => value + 0)).toEqual([0, 0, 0]);
const expand = [...host.querySelectorAll('button')].find(button => button.textContent === 'Expand +1')!;
await act(async () => expand.click());
await settle(() => expect(seam().nodes).toBe(5));
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);
});
const button = (label: string) => [...host.querySelectorAll('button')].find(entry => entry.textContent === label)!;
const steps = () => [...host.querySelectorAll('[data-testid="atlas-galaxy-path-panel"] li code')].map(entry => entry.textContent);
const heading = () => host.querySelector('[data-testid="atlas-galaxy-path-panel"] strong')?.textContent;
it('highlights a path and the call order of the root inside the loaded scope, and clears with Escape or a new trace', async () => {
await act(async () => root.render(<GalaxyPanel project="sample" visible workspaceExpanded onOpenNode={vi.fn()} fetch={graphFetch()} />));
await settle(() => expect(seam().nodes).toBe(5));
await act(async () => { seam().clickNode('sample.n1'); });
await settle(() => expect(seam().nodes).toBe(4));
await act(async () => button('Expand +1').click());
await settle(() => expect(seam().nodes).toBe(5));
await act(async () => button('Call order').click());
expect(heading()).toBe('Calls of n1 · 2 calls');
expect(steps()).toEqual(['n1 --CALLS--> n5', 'n1 --CALLS--> n2']);
expect([...scene.highlighted ?? []].sort()).toEqual([1, 2, 5]);
expect(scene.path).toMatchObject({ active: 0, labels: 'active' });
await act(async () => button('Next').click());
expect(scene.path?.active).toBe(1);
host.querySelector<HTMLDetailsElement>('.atlas-graph-path-picker')!.open = true;
const target = [...host.querySelectorAll<HTMLButtonElement>('.atlas-graph-path-menu li button')]
.find(entry => entry.querySelector('strong')?.textContent === 'n4')!;
await act(async () => target.click());
expect(heading()).toBe('Path to n4 · 2 hops');
expect(steps()).toEqual(['n1 --CALLS--> n2', 'n2 --CALLS--> n4']);
expect([...scene.highlighted ?? []].sort()).toEqual([1, 2, 4]);
expect(scene.path).toMatchObject({ active: 0, labels: 'all' });
expect(seam().nodes).toBe(5);
await act(async () => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); });
expect(host.querySelector('[data-testid="atlas-galaxy-path-panel"]')).toBeNull();
expect(scene.path).toBeUndefined();
// Incoming: the path walks the CALLS edge backwards and says so.
const direction = (value: string) => act(async () => {
const select = host.querySelector<HTMLSelectElement>('select[aria-label="Trace direction"]')!;
select.value = value; select.dispatchEvent(new Event('change', { bubbles: true }));
});
await direction('inbound');
await settle(() => expect(seam().nodes).toBe(2));
host.querySelector<HTMLDetailsElement>('.atlas-graph-path-picker')!.open = true;
const caller = [...host.querySelectorAll<HTMLButtonElement>('.atlas-graph-path-menu li button')]
.find(entry => entry.querySelector('strong')?.textContent === 'n3')!;
await act(async () => caller.click());
expect(heading()).toBe('Path to n3 · 1 hop');
expect(steps()).toEqual(['n1 <--CALLS-- n3']);
// A new trace direction is a new scope: the path view returns to the normal picture.
await direction('outbound');
expect(host.querySelector('[data-testid="atlas-galaxy-path-panel"]')).toBeNull();
});
const panel = () => host.querySelector('[data-testid="atlas-galaxy-path-panel"]');
const traceDirection = (value: string) => act(async () => {
const select = host.querySelector<HTMLSelectElement>('select[aria-label="Trace direction"]')!;
select.value = value; select.dispatchEvent(new Event('change', { bubbles: true }));
});
const pickPath = (name: string) => act(async () => {
host.querySelector<HTMLDetailsElement>('.atlas-graph-path-picker')!.open = true;
[...host.querySelectorAll<HTMLButtonElement>('.atlas-graph-path-menu li button')]
.find(entry => entry.querySelector('strong')?.textContent === name)!.click();
});
it('drops a path on a new scope key, so returning to the old key does not bring it back', async () => {
await act(async () => root.render(<GalaxyPanel project="sample" visible workspaceExpanded onOpenNode={vi.fn()} fetch={graphFetch()} />));
await settle(() => expect(seam().nodes).toBe(5));
await act(async () => { seam().clickNode('sample.n1'); });
await settle(() => expect(seam().nodes).toBe(4));
await pickPath('n2');
expect(heading()).toBe('Path to n2 · 1 hop');
// Incoming and back to both directions: the same key as before, but the path is gone.
await traceDirection('inbound');
await settle(() => expect(seam().nodes).toBe(2));
expect(panel()).toBeNull();
await traceDirection('both');
await settle(() => expect(seam().nodes).toBe(4));
expect(panel()).toBeNull();
expect(scene.path).toBeUndefined();
// The whole graph and the same root again: a fresh picture without the old path.
await act(async () => button('Call order').click());
expect(heading()).toBe('Calls of n1 · 2 calls');
await act(async () => button('All graph').click());
await settle(() => expect(seam().nodes).toBe(5));
await act(async () => { seam().clickNode('sample.n1'); });
await settle(() => expect(seam().nodes).toBe(4));
expect(panel()).toBeNull();
expect(button('Call order').getAttribute('aria-pressed')).toBe('false');
});
it('leaves Escape to an overlay above the galaxy and to a field being typed in', async () => {
const fetch = graphFetch();
const panelWith = (escapeTaken: boolean) => <GalaxyPanel project="sample" visible workspaceExpanded escapeTaken={escapeTaken} onOpenNode={vi.fn()} fetch={fetch} />;
await act(async () => root.render(panelWith(false)));
await settle(() => expect(seam().nodes).toBe(5));
await act(async () => { seam().clickNode('sample.n1'); });
await settle(() => expect(seam().nodes).toBe(4));
await pickPath('n2');
expect(panel()).not.toBeNull();
const escape = (target: EventTarget) => {
const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
act(() => { target.dispatchEvent(event); });
return event;
};
// Help, settings or projects lie above: the key is theirs.
await act(async () => root.render(panelWith(true)));
expect(escape(window).defaultPrevented).toBe(false);
expect(panel()).not.toBeNull();
// Typing in a field: Escape leaves the field, not the path.
await act(async () => root.render(panelWith(false)));
const field = host.querySelector<HTMLInputElement>('input[aria-label="Find a graph node"]')!;
expect(escape(field).defaultPrevented).toBe(false);
expect(panel()).not.toBeNull();
expect(escape(window).defaultPrevented).toBe(true);
expect(panel()).toBeNull();
});
it('keeps the picked name when a removed layer takes the path target out of the picture', async () => {
await act(async () => root.render(<GalaxyPanel project="sample" visible workspaceExpanded onOpenNode={vi.fn()} fetch={graphFetch()} />));
await settle(() => expect(seam().nodes).toBe(5));
await act(async () => { seam().clickNode('sample.n1'); });
await settle(() => expect(seam().nodes).toBe(4));
await act(async () => button('Expand +1').click());
await settle(() => expect(seam().nodes).toBe(5));
await pickPath('n4');
expect(heading()).toBe('Path to n4 · 2 hops');
await act(async () => host.querySelector<HTMLButtonElement>('button[aria-label="Remove graph layer"]')!.click());
await settle(() => expect(seam().nodes).toBe(4));
expect(heading()).toBe('Path to n4 · 0 hops');
expect(panel()?.textContent).toContain('No path to n4 over the loaded relationships');
expect(panel()?.textContent).not.toContain('#4');
});
it('shows the whole graph with its hidden edge kinds until the first scoped picture, and lists only current kinds', async () => {
await act(async () => root.render(<GalaxyPanel project="sample" visible workspaceExpanded onOpenNode={vi.fn()} fetch={graphFetch()}
legendStore={{ getItem: (key: string) => (key === GALAXY_LEGEND_KEY ? 'open' : null), setItem: vi.fn() } as unknown as Storage} />));
await settle(() => expect(seam().nodes).toBe(5));
await act(async () => host.querySelector<HTMLButtonElement>('[data-testid="atlas-galaxy-legend-swatch"][data-type="CALLS"]')!.click());
expect(seam().hiddenKinds).toEqual(['CALLS']);
expect(seam().drawnEdges).toBe(1);
scene.frames = [];
await act(async () => { seam().clickNode('sample.n1'); });
await settle(() => expect(seam().nodes).toBe(4));
const placeholder = scene.frames.filter(frame => frame.nodes === 5);
expect(placeholder.length).toBeGreaterThan(0);
// The whole graph that stays on screen keeps the reader's choice ...
expect(placeholder.every(frame => frame.types.join() === 'IMPORTS')).toBe(true);
// ... and the trace filter beside it never offers the whole graph's kinds as the scope's.
expect(placeholder.every(frame => frame.traceRows === 0)).toBe(true);
expect(scene.frames.at(-1)).toMatchObject({ nodes: 4, types: ['CALLS'], traceRows: 1 });
});
+182 -32
View File
@@ -137,6 +137,9 @@ import type { LabelBox } from './NodeLabels';
import { NodeTooltipCard } from './NodeTooltipCard';
import GalaxyNavigator from './GalaxyNavigator';
import { TraceEdgeFilter } from './TraceEdgeFilter';
import { PathPicker, PathSteps } from './ScopePathControls';
import { callOrder, pathNodes, shortestScopePath, type ScopePathStep } from './scope-path';
import { galaxyPathText, galaxyToolbarText } from './galaxy-strings';
import { useOrganicLayout } from './use-organic-layout';
import RenderProgress from './RenderProgress';
import { useGraphScope } from './use-graph-scope';
@@ -212,6 +215,7 @@ import type { AgentsRuntime } from '../agents/agent-source';
import { loadAgentsPreference, saveAgentsPreference } from '../agents/agent-preference';
import type { AgentsPreference } from '../agents/agent-preference';
import { fullscreenIsolationRequired, isolateFullscreenBackground } from './fullscreen-isolation';
import { isTypingTarget } from '../app/keyboard';
/**
* Wie lange die Ereigniszeile des FOLLOW-Modus stehen bleibt.
@@ -424,6 +428,8 @@ export interface AtlasGalaxySeam {
depth: number;
normal: [number, number, number];
up: [number, number, number];
/** Worauf die Kamera sieht: in einem Scope die Wurzel. */
center?: [number, number, number];
} | undefined;
/** Genau der Satz, der im Kopf steht. */
headline: string;
@@ -774,6 +780,18 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
if (!scope.scope) organicHistory.current = undefined;
}, [scope.complete, scope.scope, 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,17 +1139,27 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
const hiddenHere = kinds.filter((kind) => hiddenKinds.has(kind.type)).length;
const kindNote = edgeKindNote(kinds.length, hiddenHere);
const shown = useMemo(
/*
* Die Szene bekommt in der Galaxie das stehende Bild (siehe `sceneData`).
* Steht dort noch der ganze Graph, gelten auch seine ausgeblendeten
* Kantenarten weiter; erst ein Scope-Bild filtert ueber `traceTypes`.
* `shown` ist nur das AKTUELLE Bild: Kantenfilter, Zaehler und Auskunft an
* den Chat rechnen nie mit dem Platzhalter.
*/
const scenePicture = mode === 'hierarchy' ? picture : sceneData;
const sceneShown = useMemo(
() => {
if (!picture) return undefined;
const filtered = props.workspaceExpanded && scope.scope ? picture : withoutEdgeKinds(picture, hiddenKinds);
if (!scenePicture) return undefined;
const traced = props.workspaceExpanded && scope.scope && (mode === 'hierarchy' || sceneScoped);
const filtered = traced ? 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, sceneScoped],
);
const shown = mode === 'hierarchy' || sceneData === data ? sceneShown : undefined;
const traceKinds = useMemo(() => edgeKinds(shown), [shown]);
const agentEvidence = useMemo(() => {
@@ -1169,6 +1197,76 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
});
}, [props.workspaceExpanded, scope.scope, traceTypes, kinds, changeTraceTypes]);
const renderLimits = <>
<label>Nodes <select aria-label="Rendered node limit" value={nodeBudget} onChange={event => setViewPreferences({ galaxyNodes: Number(event.target.value) })}>
{GALAXY_NODE_LIMITS.map(value => <option key={value} value={value}>{value.toLocaleString()}</option>)}
</select></label>
<label>Edges <select aria-label="Rendered edge limit" value={edgeBudget} onChange={event => setViewPreferences({ galaxyEdges: Number(event.target.value) })}>
{GALAXY_EDGE_LIMITS.map(value => <option key={value} value={value}>{value.toLocaleString()}</option>)}
</select></label>
</>;
/*
* Der Pfad und die Aufrufreihe (Review-Befund G5, nach dem Vorbild von
* Graphify).
*
* Beide rechnen nur auf dem, was dieser Scope schon geladen hat, und in der
* Richtung, in der er verfolgt wird: der Server wird nicht gefragt, und ein
* fehlender Pfad heisst "nicht in diesen Beziehungen", nicht "gibt es
* nicht". Der Abstand ist hier die Zahl der Hops und nur hier (G6); die
* Wolke selbst behauptet keinen. Die Wahl haengt am Scope (`organicKey`):
* eine neue Wurzel, Richtung oder Kantenart verwirft sie, ein Expand
* rechnet sie auf dem groesseren Bild neu. Verworfen heisst verworfen: wer
* die Richtung zurueckstellt oder dieselbe Wurzel neu waehlt, bekommt den
* alten Pfad nicht ungefragt wieder. Das Ziel behaelt seinen Namen, auch
* wenn ein entfernter Layer es aus dem Bild nimmt.
*/
const [trail, setTrail] = useState<{ key: string; kind: 'path'; target: number; name: string } | { key: string; kind: 'calls' }>();
const [trailStep, setTrailStep] = useState(0);
useEffect(() => {
setTrail(current => (current && current.key !== organicKey ? undefined : current));
setTrailStep(0);
}, [organicKey]);
const trailRoot = scope.result?.roots.size === 1 ? [...scope.result.roots][0] : undefined;
const rootCalls = useMemo(() => (data && trailRoot !== undefined ? callOrder(data.edges, trailRoot) : []), [data, trailRoot]);
const pathCandidates = useMemo(() => data?.nodes.filter(node => !scope.result?.roots.has(node.id)) ?? [], [data, scope.result]);
const trailView = useMemo(() => {
if (!props.workspaceExpanded || mode !== 'galaxy' || trail?.key !== organicKey || !data || !scope.result) return undefined;
const names = new Map(data.nodes.map(node => [node.id, node.name]));
const nameOf = (id: number) => names.get(id) ?? `#${id}`;
if (trail.kind === 'calls') {
return { nameOf, lines: true, labels: 'active' as const, steps: rootCalls,
heading: galaxyPathText.callsHeading(trailRoot === undefined ? '' : nameOf(trailRoot), rootCalls.length) };
}
const steps: ScopePathStep[] | undefined = shortestScopePath(data.edges, scope.result.roots, trail.target, scope.direction);
const target = names.get(trail.target) ?? trail.name;
return { nameOf, lines: false, labels: 'all' as const, steps: steps ?? [],
heading: galaxyPathText.pathHeading(target, steps?.length ?? 0),
note: steps === undefined ? galaxyPathText.noPath(target) : steps.length === 0 ? galaxyPathText.isRoot(target) : undefined };
}, [props.workspaceExpanded, mode, trail, organicKey, data, scope.result, scope.direction, rootCalls, trailRoot]);
const trailActive = trailView ? Math.min(trailStep, Math.max(0, trailView.steps.length - 1)) : 0;
const trailIds = useMemo(() => trailView?.steps.length && scope.result ? pathNodes(trailView.steps, scope.result.roots) : undefined,
[trailView, scope.result]);
const clearTrail = useCallback(() => { setTrail(undefined); setTrailStep(0); }, []);
/*
* Escape gibt den Pfad frei, aber erst nach allen, die vorgehen: liegt eine
* Flaeche darueber (`escapeTaken`, dieselbe Reihenfolge wie beim
* Vollbild), gehoert die Taste ihr, und wer in einem Feld oder im Editor
* tippt, verlaesst mit Escape das Feld und nicht den Pfad.
*/
const escapeTaken = props.escapeTaken === true;
useEffect(() => {
if (!trailView || escapeTaken) return;
const onKey = (event: globalThis.KeyboardEvent): void => {
if (event.key !== 'Escape' || event.defaultPrevented) return;
if (isTypingTarget(event.target instanceof Element ? event.target : null)) return;
event.preventDefault();
clearTrail();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [trailView, escapeTaken, clearTrail]);
const index = useMemo(
() => (picture === undefined ? new Map<string, GraphNode>() : nodesByQualifiedName(picture.nodes)),
[picture],
@@ -1439,8 +1537,23 @@ 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). Wie weit die Kamera fuer das neue Bild stehen muss,
* stellt die Szene selbst nach, entlang derselben Blickrichtung und nur,
* 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 +1567,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;
@@ -1480,10 +1595,22 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
if (nodes.length === 0) {
return;
}
const target = computeFitTarget(nodes, aspect);
/*
* Die Wurzel steht in der Mitte (Review-Befund G4). Gerahmt wird um sie
* herum, weit genug, dass auch der fernste Knoten passt; ohne das stand
* die Mitte der Wolke im Bild und die Wurzel irgendwo darin.
*/
const roots = fitScope === undefined ? [] : nodes.filter((node) => scope.result?.roots.has(node.id));
const center = roots.length === 0 ? undefined : {
x: roots.reduce((sum, node) => sum + node.x, 0) / roots.length,
y: roots.reduce((sum, node) => sum + node.y, 0) / roots.length,
z: roots.reduce((sum, node) => sum + node.z, 0) / roots.length,
};
const target = computeFitTarget(nodes, aspect, center);
if (target === null) {
return;
}
lastFitRequest.current = fitRequest;
setCameraTarget(target);
targetChanges.current += 1;
fitCount.current += 1;
@@ -1499,8 +1626,11 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
depth: fit.depth,
normal: [fit.normal.x, fit.normal.y, fit.normal.z],
up: [fit.up.x, fit.up.y, fit.up.z],
center: [fit.center.x, fit.center.y, fit.center.z],
};
}, [visible, mode, projection, picture, aspect, requestedFit, ownFit, coverageShadow, fitRequest, props.workspaceExpanded]);
// `scope.result` folgt dem Bild und loest selbst keine Einpassung aus.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, mode, projection, picture, aspect, requestedFit, ownFit, coverageShadow, fitRequest, props.workspaceExpanded, fitScope]);
const [backgroundCleared, setBackgroundCleared] = useState(false);
useEffect(() => {
@@ -1621,9 +1751,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
* die Oberflaeche legt, und zwar ZULETZT: liegt eine andere Flaeche darueber
* (Hilfe, Einstiegsdialog, Suchfenster, Einstellungen), gehoert die Taste
* ihr. Der Vollbildmodus reiht sich in die bestehende Reihenfolge ein, er
* draengt sich nicht vor.
* draengt sich nicht vor (`escapeTaken` steht beim Pfad weiter oben).
*/
const escapeTaken = props.escapeTaken === true;
useEffect(() => {
const node = panel.current;
if (node === null || !fullscreenIsolationRequired(fullscreen, escapeTaken)) {
@@ -1704,6 +1833,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
const selected = layoutNodeForSelection(layout, node) ?? layoutNodeForSelection(data, node) ?? node;
props.onSelectNode?.(selected);
if (props.workspaceExpanded) {
clearTrail();
scope.select({ kind: 'node', id: selected.id, name: selected.name, qualifiedName: selected.qualified_name });
setNote('');
return;
@@ -1715,19 +1845,20 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
setNote('');
onOpenNode(selected);
},
[layout, data, picture, mode, flyTo, onOpenNode, props.onSelectNode, props.workspaceExpanded, scope.select],
[layout, data, picture, mode, flyTo, onOpenNode, props.onSelectNode, props.workspaceExpanded, scope.select, clearTrail],
);
const handleBackgroundClick = useCallback(() => {
setBackgroundCleared(true);
if (props.workspaceExpanded) setChosenMode('galaxy');
clearTrail();
scope.reset();
changeTraceTypes(undefined);
setHighlighted(null);
setNote(props.workspaceExpanded ? '' : mode === 'hierarchy' ? readerProjection?.message ?? HIERARCHY_NO_FOCUS_NOTE : GALAXY_NO_FOCUS_NOTE);
refitNow();
props.onClearSelection?.();
}, [mode, refitNow, props.onClearSelection, readerProjection, scope.reset, props.workspaceExpanded, changeTraceTypes]);
}, [mode, refitNow, props.onClearSelection, readerProjection, scope.reset, props.workspaceExpanded, changeTraceTypes, clearTrail]);
/*
* Die Vorgabe der Ansicht, mit der Wahl des Lesers darauf.
@@ -1742,7 +1873,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 +1891,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).
@@ -1944,7 +2076,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
pulsedQn: pulsedNode?.qualified_name ?? pulsedNode?.name ?? '',
edgeKinds: kinds.map((kind) => ({ ...kind, hidden: hiddenKinds.has(kind.type) })),
hiddenKinds: [...hiddenKinds].sort(),
drawnEdges: shown?.edges.length ?? 0,
drawnEdges: sceneShown?.edges.length ?? 0,
edgeNote,
hierarchy:
projection === undefined
@@ -2205,7 +2337,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
)}
</header>
{(props.workspaceExpanded || scope.scope) && <div className="atlas-graph-exploration" aria-label="Graph scope">
{props.workspaceExpanded && <GalaxyNavigator embedded nodes={layout?.nodes ?? []} project={project} fetch={fetchImpl} onSelect={handleNodeClick} onSelectScope={next => { props.onClearSelection?.(); setBackgroundCleared(false); scope.select(next); }} />}
{props.workspaceExpanded && <GalaxyNavigator embedded nodes={layout?.nodes ?? []} project={project} fetch={fetchImpl} onSelect={handleNodeClick} onSelectScope={next => { props.onClearSelection?.(); setBackgroundCleared(false); clearTrail(); scope.select(next); }} />}
{scope.scope ? <>
{props.workspaceExpanded && <button type="button" onClick={handleBackgroundClick}>All graph</button>}
<strong className="atlas-graph-scope-name" title={scope.scope.name}>{scope.scope.name}</strong>
@@ -2215,25 +2347,39 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
<option value="both">Both directions</option><option value="inbound">Incoming</option><option value="outbound">Outgoing</option>
</select></label>}
{props.workspaceExpanded && <TraceEdgeFilter kinds={traceKinds} availableTypes={kinds.map(kind => kind.type)} selected={traceTypes} onChange={changeTraceTypes} />}
<button type="button" disabled={scope.depth <= (!props.workspaceExpanded && props.focusFilePath ? 1 : 0) || scope.loading}
<button type="button" disabled={scope.depth <= scope.minDepth || scope.loading}
onClick={() => scope.setDepth(scope.depth - 1)} aria-label="Remove graph layer">−</button>
<span>{scope.depth} {scope.depth === 1 ? 'layer' : 'layers'}</span>
<button type="button" disabled={scope.loading || scope.result?.exhausted}
onClick={() => scope.setDepth(scope.depth + 1)}>Expand +1</button>
{props.workspaceExpanded && mode === 'galaxy' && <>
<PathPicker nodes={pathCandidates} onPick={node => { setTrail({ key: organicKey, kind: 'path', target: node.id, name: node.name }); setTrailStep(0); }} />
<button type="button" disabled={rootCalls.length === 0} aria-pressed={trail?.key === organicKey && trail.kind === 'calls'}
title={rootCalls.length ? galaxyPathText.callOrderTitle : galaxyPathText.callOrderUnavailable}
onClick={() => { if (trail?.key === organicKey && trail.kind === 'calls') clearTrail(); else { setTrail({ key: organicKey, kind: 'calls' }); setTrailStep(0); } }}>
{galaxyPathText.callOrder}</button>
</>}
<span className="atlas-graph-scope-count" role="status">{scope.validating ? 'Checking index…' : scope.loading ? 'Loading relationships…' : organicTask.loading ? 'Arranging nodes…'
: scope.complete ? `${data?.nodes.length ?? 0} ${data?.nodes.length === 1 ? 'node' : 'nodes'} · ${data?.edges.length ?? 0} ${data?.edges.length === 1 ? 'edge' : 'edges'}${scope.result?.exhausted ? ' · end of trace' : ''}`
: 'Partial preview'}</span>
{scope.error && <span className="atlas-graph-scope-warning" title={scope.error}>Some relationships could not be loaded. <button type="button" onClick={scope.retry}>Retry</button></span>}
{!props.workspaceExpanded && scope.complete && <small>All indexed direct dependencies included.</small>}
{mode === 'galaxy' && organic && organic.groups.length > 1 && <small title="Groups reflect connections in this trace, not inferred architecture components.">{organic.groups.length} connection groups</small>}
{mode === 'galaxy' && organic && organic.groups.length > 1 && <small className="atlas-graph-scope-groups" title={galaxyToolbarText.groupsTitle(organic.groups.length)}>{galaxyToolbarText.groups(organic.groups.length)}</small>}
</> : null}
{props.workspaceExpanded && <>
<label>Nodes <select aria-label="Rendered node limit" value={nodeBudget} onChange={event => setViewPreferences({ galaxyNodes: Number(event.target.value) })}>
{GALAXY_NODE_LIMITS.map(value => <option key={value} value={value}>{value.toLocaleString()}</option>)}
</select></label>
<label>Edges <select aria-label="Rendered edge limit" value={edgeBudget} onChange={event => setViewPreferences({ galaxyEdges: Number(event.target.value) })}>
{GALAXY_EDGE_LIMITS.map(value => <option key={value} value={value}>{value.toLocaleString()}</option>)}
</select></label>
{/*
* Im Scope tritt der Deckel in ein Aufklappfeld zurueck
* (Review-Befund G7): die Leiste traegt dort Pfad,
* Aufrufreihe und Zaehler, und bei 1600 Pixeln brach die
* Zeile sonst um. Ein Scope liegt fast immer unter dem
* Deckel; im ganzen Graphen bleibt er in der Zeile.
*/}
{scope.scope ? <details className="atlas-graph-limits" title={galaxyToolbarText.limitsTitle} onKeyDown={event => {
if (event.key === 'Escape') { event.stopPropagation(); event.currentTarget.open = false; }
}}>
<summary>{galaxyToolbarText.limits}</summary>
<div className="atlas-graph-limits-menu">{renderLimits}</div>
</details> : renderLimits}
{mode === 'galaxy' && !scope.scope && <label className="atlas-graph-coverage-filter" title="Show files and folders with indexing gaps">
<input type="checkbox" aria-label="Show coverage graph" checked={showCoverage} onChange={event => setViewPreferences({ coverageShadow: event.target.checked })} />
Coverage
@@ -2358,6 +2504,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
<div className="atlas-galaxy-scene" data-testid="atlas-galaxy-scene" ref={scene}
aria-busy={layoutLoading || scope.loading || organicTask.loading || spacingBusy}>
<RenderProgress busy={visible && (layoutLoading || scope.loading || organicTask.loading || spacingBusy)} />
{trailView && <PathSteps heading={trailView.heading} note={trailView.note} steps={trailView.steps} active={trailActive}
nameOf={trailView.nameOf} lines={trailView.lines} onStep={setTrailStep} onClear={clearTrail} />}
{/*
* Der Weg zurueck zur eingepassten Ansicht (AC5).
*
@@ -2382,15 +2530,17 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
</button>
</Hint>
)}
{shown !== undefined && everVisible.current && (
{sceneShown !== undefined && everVisible.current && (
<GraphScene
active={visible}
separateNodes={mode === 'galaxy' && Boolean(scope.scope)}
separateNodes={mode === 'galaxy' && sceneScoped}
onRenderBusyChange={setSpacingBusy}
idleRotation={mode === 'galaxy' && !scope.scope}
data={shown}
idleRotation={mode === 'galaxy' && !sceneScoped}
rootIds={mode === 'galaxy' && sceneScoped ? scope.result?.roots : undefined}
data={sceneShown}
display={display}
highlightedIds={scope.scope && scope.depth > 1 ? null : highlighted}
highlightedIds={trailIds ?? (scope.scope && scope.depth > 1 ? null : highlighted)}
path={trailIds && trailView ? { steps: trailView.steps, active: trailActive, labels: trailView.labels } : undefined}
emphasizeIncidentEdges={mode === 'galaxy' && Boolean(props.focusFilePath)}
cameraTarget={cameraTarget}
/*
@@ -2426,8 +2576,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
*/
showLabels={
mode === 'hierarchy'
? shown.nodes.length <= HIERARCHY_LABEL_BUDGET
: highlighted !== null && highlighted.size > 0
? sceneShown.nodes.length <= HIERARCHY_LABEL_BUDGET
: trailIds !== undefined || (highlighted !== null && highlighted.size > 0)
}
/*
* Landmarken nur in der Galaxie: der Halo sitzt auf den
@@ -0,0 +1,87 @@
// @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 { FRAME_MARGIN, FRAME_MIN_DISTANCE } from './camera-frame';
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 />));
const half = Math.tan(25 * Math.PI / 180);
/** How much of the half-height the outermost node uses, margin included. */
const reach = (y: number) => y * FRAME_MARGIN / (scene.camera.position.z * half);
it('frames the next picture along the kept view direction: back for a larger one, forward for a smaller one', () => {
moved.current = false;
const fit = fitTo(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);
expect(reach(900)).toBeCloseTo(1, 4);
render([node(1, 0, 0), node(2, 0, 300)], fit);
expect(reach(300)).toBeCloseTo(1, 4);
// Never closer to the pivot than any fit would stand.
render([node(1, 0, 0)], fit);
expect(scene.camera.position.z).toBeCloseTo(FRAME_MIN_DISTANCE, 6);
});
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), node(2, 0, 300)], fit);
const framed = scene.camera.position.z;
moved.current = true;
render([node(1, 0, 0), node(2, 0, 900)], fit);
expect(scene.camera.position.z).toBe(framed);
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(framed);
});
});
+154 -6
View File
@@ -84,12 +84,27 @@
* 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` rahmt das naechste Bild
* eines Scopes, ohne neu einzupassen: die Kamera tritt entlang ihrer
* Blickrichtung zurueck oder vor, solange der Leser sie nicht bewegt hat.
* 16. Neu (Review-Call 2026-10-02): die OrbitControls zoomen zum Mauszeiger
* (`zoomToCursor`), nicht mehr in die Bildmitte.
* 17. Neu (Review-Call 2026-10-02): die Prop `rootIds`. Die Wurzeln eines
* Scopes bekommen einen Ring und einen Namen, der immer zu sehen ist, und
* die Trennung auf dem Schirm schiebt sie nie weg. Ohne die Prop zeichnet
* die Szene wie vorher.
* 18. Neu (Review-Call 2026-10-02): die Prop `path`. Ein Pfad oder eine
* Aufrufreihe liegt mit beschrifteten Kanten ueber der Szene
* (src/galaxy/PathLayer.tsx). Ohne die Prop zeichnet die Szene wie vorher.
*/
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import type { JSX, ReactNode, RefObject } from 'react';
import { Canvas, useThree, useFrame } from '@react-three/fiber';
import { OrbitControls, OrthographicCamera } from '@react-three/drei';
import { Html, OrbitControls, OrthographicCamera } from '@react-three/drei';
import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib';
import { EffectComposer, Bloom } from '@react-three/postprocessing';
import * as THREE from 'three';
@@ -98,8 +113,9 @@ import { HaloLayer } from './HaloLayer';
import { EdgeLines } from './EdgeLines';
import { NodeLabels } from './NodeLabels';
import { ScreenNodeSeparation } from './ScreenNodeSeparation';
import { PathLayer, type ScenePath } from './PathLayer';
import type { LabelBox } from './NodeLabels';
import { fitCamera, flatBounds, frameDistance, orthographicZoom } from './camera-frame';
import { FRAME_MIN_DISTANCE, containShift, 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 +178,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 +196,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 +347,96 @@ function CameraAnimator({
return null;
}
/*
* Aenderung 15: das naechste Bild eines Scopes wird gerahmt, nicht eingepasst.
*
* 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. Das neue Bild ist aber groesser oder kleiner als das alte,
* und die Trennung auf dem Schirm schiebt Knoten nach aussen. Also tritt die
* Kamera entlang ihrer Blickrichtung so weit zurueck oder vor, dass der
* aeusserste gezeichnete Knoten mit Rand am Bildrand steht (`containShift`).
* Richtung, Oben und Drehpunkt bleiben, und naeher als jede Einpassung
* (`FRAME_MIN_DISTANCE`) kommt sie dem Drehpunkt nicht. Das gilt nur, solange
* die letzte Lage eine Einpassung war und der Leser die Kamera seitdem nicht
* bewegt hat: wer gezoomt 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<CameraTarget | null>(null);
useEffect(() => {
if (target === null) {
return;
}
armed.current = target.immediate === true ? target : null;
moved.current = false;
}, [target, moved]);
useEffect(() => {
const fit = armed.current;
if (!enabled || fit === null || moved.current || nodes.length === 0 || 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 shift = Math.max(containShift(view, camera.fov, size.width / size.height),
FRAME_MIN_DISTANCE - camera.position.distanceTo(fit.lookAt));
if (!(Math.abs(shift) > 0.5)) {
return;
}
const direction = camera.getWorldDirection(new THREE.Vector3());
camera.position.addScaledVector(direction, -shift);
camera.updateMatrixWorld();
controlsRef.current?.update();
}, [nodes, enabled, camera, size, controlsRef, moved]);
return null;
}
/*
* Aenderung 17: die Wurzel ist zu finden.
*
* Nach einem Expand stand die Wurzel als ein Punkt unter neunzig, ohne Namen,
* und die Trennung auf dem Schirm hatte sie aus der Mitte geschoben
* (Review-Befund G4). Der Ring ist DOM wie der Ring der Hierarchie (Html aus
* drei), also kein Neubau der Puffer, und sein Name steht immer da, nicht erst
* bei Fokus. Ein Ordner mit hundert Wurzeln bekommt keine hundert Ringe: ueber
* der Grenze markiert die Szene nichts und schiebt wie vorher.
*/
export const ROOT_MARKER_LIMIT = 12;
function RootMarkers({ nodes }: { nodes: readonly GraphNode[] }): JSX.Element {
return (
<>
{nodes.map((node) => (
<Html key={node.id} position={[node.x, node.y, node.z]} zIndexRange={[10, 0]} style={{ pointerEvents: 'none' }}>
<span className="atlas-galaxy-root-marker" data-testid="atlas-galaxy-root-marker" data-qn={node.qualified_name ?? node.name}>
<i aria-hidden="true" />
<b>{node.name}</b>
</span>
</Html>
))}
</>
);
}
/* Bildratenmesser und Bildratendeckel (W10) */
/**
@@ -697,6 +815,10 @@ interface GraphSceneProps {
labelDistanceFactor?: number;
/* Bilder je Sekunde, hoechstens. 0 heisst: kein Deckel. */
frameCap?: number;
/* Die Wurzeln des gewaehlten Scopes (Aenderung 17). */
rootIds?: ReadonlySet<number>;
/* Ein Pfad oder eine Aufrufreihe ueber der Szene (Aenderung 18). */
path?: ScenePath;
}
export type { CameraTarget };
@@ -735,10 +857,14 @@ export function GraphScene({
drawEdges = true,
labelDistanceFactor = 0,
frameCap = 0,
rootIds,
path,
}: GraphSceneProps) {
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]);
@@ -752,6 +878,12 @@ export function GraphScene({
const nodes = renderedNodes.slice(data.nodes.length) as CoverageShadowNode[];
return { ...coverageShadow, nodes, nodeById: new Map(nodes.map(node => [node.id, node])) };
}, [coverageShadow, renderedNodes, sceneNodes, data.nodes.length]);
const markedRoots = rootIds !== undefined && rootIds.size > 0 && rootIds.size <= ROOT_MARKER_LIMIT ? rootIds : undefined;
const rootNodes = useMemo(() => markedRoots ? renderedCode.filter((node) => markedRoots.has(node.id)) : [],
[markedRoots, renderedCode]);
// Der Ring traegt den Namen der Wurzel; ein zweiter Name darunter waere doppelt.
const labelNodes = useMemo(() => markedRoots ? renderedCode.filter((node) => !markedRoots.has(node.id)) : renderedCode,
[markedRoots, renderedCode]);
const onCodeHover = useCallback((node: GraphNode | null) => {
setHovered(node);
if (node) setHoveredShadow(null);
@@ -881,7 +1013,7 @@ export function GraphScene({
/>
{showLabels && (
<NodeLabels
nodes={renderedCode}
nodes={labelNodes}
highlightedIds={highlightedIds}
worldFontSize={labelWorldFontSize}
maxTextWidth={labelMaxTextWidth}
@@ -890,6 +1022,8 @@ export function GraphScene({
/>
)}
{landmarks && <HaloLayer nodes={renderedCode} />}
{rootNodes.length > 0 && <RootMarkers nodes={rootNodes} />}
{path !== undefined && <PathLayer nodes={renderedCode} path={path} />}
{renderedShadow && renderedShadow.nodes.length > 0 && <group>
{drawEdges && <CoverageShadowEdges shadow={renderedShadow} brightness={display.edgeBrightness} />}
@@ -902,7 +1036,10 @@ export function GraphScene({
{resolveCoverageShadowNode(renderedShadow, hoveredShadow) && hoveredShadow && renderShadowTooltip?.(hoveredShadow)}
<CameraAnimator target={cameraTarget} controlsRef={controlsRef} flat={flat} />
{separateNodes && <ScreenNodeSeparation nodes={sceneNodes} active={active} onChange={receiveSeparation} onBusyChange={onRenderBusyChange} />}
<FitContainment nodes={renderedNodes} target={cameraTarget} controlsRef={controlsRef} moved={moved}
enabled={separateNodes && !flat} />
{separateNodes && <ScreenNodeSeparation nodes={sceneNodes} active={active} onChange={receiveSeparation} onBusyChange={onRenderBusyChange}
pinned={markedRoots} />}
<FitProbe nodes={renderedNodes} />
<IdleAutoRotate controlsRef={controlsRef} enabled={!flat && idleRotation && !separateNodes} />
<FrameRateMeter nodes={sceneNodes.length} edges={data.edges.length + (coverageShadow?.edges.length ?? 0)} cap={frameCap} />
@@ -938,9 +1075,18 @@ export function GraphScene({
dampingFactor={0.08}
rotateSpeed={0.5}
zoomSpeed={1.5}
/*
* Aenderung 16: das Mausrad zoomt dorthin, wo der Zeiger steht,
* und nicht in die Bildmitte (Review-Befund G3). three-stdlib
* versetzt dabei auch den Drehpunkt; die Einpassung und jeder
* Anflug setzen ihn ohnehin selbst, und die Grenzen der
* Entfernung gelten fuer den neuen Abstand genauso.
*/
zoomToCursor
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>
@@ -979,8 +1125,10 @@ export function computeFrameTarget(box: FrameBox, aspect: number): CameraTarget
export function computeFitTarget(
nodes: readonly { x: number; y: number; z: number }[],
aspect: number,
/* Der Punkt in der Bildmitte, etwa die Wurzel eines Scopes (Review-Befund G4). */
center?: { x: number; y: number; z: number },
): (CameraTarget & { fit: CameraFit }) | null {
const fit = fitCamera(nodes, GRAPH_CAMERA_FOV, aspect);
const fit = fitCamera(nodes, GRAPH_CAMERA_FOV, aspect, undefined, center);
if (fit === null) {
return null;
}
+69
View File
@@ -0,0 +1,69 @@
/*
* Der Pfad in der Szene (Review-Befund G5).
*
* Die Kanten des Pfades liegen als eigene Linien UEBER der Kantenebene, in der
* Farbe ihrer Art und ohne Tiefentest, damit sie im abgedunkelten Rest nicht
* verschwinden. Ihre Art steht als Beschriftung daneben, und nur an ihnen:
* eine Beschriftung an jeder Kante waere bei hundert Kanten kein Text mehr.
* Der Schritt, auf dem der Leser steht, ist heller und traegt einen Ring samt
* Namen an seinem Ziel. Gezeichnet wird an den Positionen, die die Szene wirklich
* zeichnet, also nach der Trennung auf dem Schirm.
*/
import { useEffect, useMemo } from 'react';
import { Html } from '@react-three/drei';
import * as THREE from 'three';
import { edgeColor } from '../graph/edge-style';
import type { ScopePathStep } from './scope-path';
import type { GraphNode } from './types';
export interface ScenePath {
steps: readonly ScopePathStep[];
active: number;
/** `all` beschriftet jede Kante des Pfades, `active` nur den aktuellen Schritt. */
labels: 'all' | 'active';
}
export function PathLayer({ nodes, path }: { nodes: readonly GraphNode[]; path: ScenePath }) {
const byId = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
const segments = useMemo(() => path.steps.flatMap((step, index) => {
const from = byId.get(step.from), to = byId.get(step.to);
return from && to ? [{ step, index, from, to }] : [];
}), [path.steps, byId]);
const geometry = useMemo(() => {
const positions = new Float32Array(segments.length * 6), colors = new Float32Array(segments.length * 6);
const white = new THREE.Color('#ffffff');
segments.forEach(({ step, index, from, to }, at) => {
positions.set([from.x, from.y, from.z, to.x, to.y, to.z], at * 6);
const color = new THREE.Color(edgeColor(step.edge.type));
if (index === path.active) color.lerp(white, 0.55);
colors.set([color.r, color.g, color.b, color.r, color.g, color.b], at * 6);
});
const result = new THREE.BufferGeometry();
result.setAttribute('position', new THREE.BufferAttribute(positions, 3));
result.setAttribute('color', new THREE.BufferAttribute(colors, 3));
return result;
}, [segments, path.active]);
useEffect(() => () => geometry.dispose(), [geometry]);
const goal = segments.find((segment) => segment.index === path.active);
return (
<>
<lineSegments geometry={geometry} renderOrder={18}>
<lineBasicMaterial vertexColors transparent depthTest={false} depthWrite={false} toneMapped={false} />
</lineSegments>
{segments.filter(({ index }) => path.labels === 'all' || index === path.active).map(({ step, index, from, to }) => (
<Html key={index} position={[(from.x + to.x) / 2, (from.y + to.y) / 2, (from.z + to.z) / 2]} center
zIndexRange={[9, 0]} style={{ pointerEvents: 'none' }}>
<span className="atlas-galaxy-path-label" data-active={index === path.active}>{step.edge.type}</span>
</Html>
))}
{goal && (
<Html position={[goal.to.x, goal.to.y, goal.to.z]} zIndexRange={[10, 0]} style={{ pointerEvents: 'none' }}>
<span className="atlas-galaxy-path-step" data-testid="atlas-galaxy-path-step" data-node={goal.to.id}>
<i aria-hidden="true" />
<b>{goal.to.name}</b>
</span>
</Html>
)}
</>
);
}
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { PathPicker, PathSteps } from './ScopePathControls';
import type { GraphNode } from './types';
let host: HTMLDivElement, root: Root;
beforeEach(() => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
host = document.createElement('div'); document.body.append(host); root = createRoot(host);
});
afterEach(async () => { await act(async () => root.unmount()); host.remove(); });
const node = (id: number, name: string, file = `src/${name}.py`): GraphNode => ({ id, name, file_path: file, qualified_name: `p.${name}`,
label: 'Function', x: 0, y: 0, z: 0, size: 2, color: '#999999' });
const type = async (input: HTMLInputElement, value: string) => act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
});
it('searches only the loaded scope, sorted by name, and closes after a pick', async () => {
const onPick = vi.fn();
await act(async () => root.render(<PathPicker nodes={[node(3, 'save'), node(1, 'as_sql'), node(2, 'resolve', 'other/save_helpers.py')]} onPick={onPick} />));
const details = host.querySelector('details')!;
details.open = true;
const names = () => [...host.querySelectorAll('li strong')].map(entry => entry.textContent);
expect(names()).toEqual(['as_sql', 'resolve', 'save']);
await type(host.querySelector<HTMLInputElement>('input[aria-label="Find a path target"]')!, 'save');
// A name match ranks above a match in the file path.
expect(names()).toEqual(['save', 'resolve']);
await type(host.querySelector<HTMLInputElement>('input')!, 'nothing');
expect(host.textContent).toContain('No matching node in the loaded scope.');
await type(host.querySelector<HTMLInputElement>('input')!, 'sql');
await act(async () => host.querySelector<HTMLButtonElement>('li button')!.click());
expect(onPick).toHaveBeenCalledWith(expect.objectContaining({ id: 1 }));
expect(details.open).toBe(false);
});
it('lists every hop with its indexed direction and steps a highlight through them', async () => {
const onStep = vi.fn(), onClear = vi.fn();
const names = new Map([[1, 'JSONBAgg'], [2, 'test_jsonb_agg'], [3, 'Aggregate']]);
const steps = [{ edge: { id: 7, source: 2, target: 1, type: 'CALLS' }, from: 1, to: 2 },
{ edge: { id: 8, source: 1, target: 3, type: 'INHERITS' }, from: 1, to: 3 }];
const render = (active: number) => act(async () => root.render(<PathSteps heading="Path" steps={steps} active={active}
nameOf={id => names.get(id)!} lines={false} onStep={onStep} onClear={onClear} />));
await render(0);
expect([...host.querySelectorAll('li code')].map(entry => entry.textContent))
.toEqual(['JSONBAgg <--CALLS-- test_jsonb_agg', 'JSONBAgg --INHERITS--> Aggregate']);
expect([...host.querySelectorAll('.atlas-galaxy-path-hop')].map(entry => entry.textContent)).toEqual(['hop 1', 'hop 2']);
expect(host.querySelector('[aria-current="step"]')?.textContent).toContain('test_jsonb_agg');
const button = (label: string) => [...host.querySelectorAll('button')].find(entry => entry.textContent === label)!;
expect(button('Previous').disabled).toBe(true);
await act(async () => button('Next').click());
expect(onStep).toHaveBeenLastCalledWith(1);
await render(1);
expect(button('Next').disabled).toBe(true);
expect(host.textContent).toContain('2 of 2');
await act(async () => button('Clear').click());
expect(onClear).toHaveBeenCalledOnce();
});
it('shows call-site lines for a call order and a note without steps', async () => {
const steps = [{ edge: { id: 1, source: 1, target: 2, type: 'CALLS', line: 14 }, from: 1, to: 2 },
{ edge: { id: 2, source: 1, target: 3, type: 'CALLS' }, from: 1, to: 3 }];
await act(async () => root.render(<PathSteps heading="Calls" steps={steps} active={0} nameOf={String} lines onStep={vi.fn()} onClear={vi.fn()} />));
expect([...host.querySelectorAll('.atlas-galaxy-path-hop')].map(entry => entry.textContent)).toEqual(['line 14', 'no line']);
await act(async () => root.render(<PathSteps heading="Path" note="No path" steps={[]} active={0} nameOf={String} lines={false} onStep={vi.fn()} onClear={vi.fn()} />));
expect(host.querySelector('ol')).toBeNull();
expect(host.textContent).toContain('No path');
});
+82
View File
@@ -0,0 +1,82 @@
import { useMemo, useRef, useState } from 'react';
import type { KeyboardEvent } from 'react';
import { galaxyPathText as text } from './galaxy-strings';
import type { ScopePathStep } from './scope-path';
import type { GraphNode } from './types';
const PICKER_LIMIT = 40;
function closeOnEscape(event: KeyboardEvent<HTMLDetailsElement>) {
if (event.key !== 'Escape') return;
event.stopPropagation(); event.currentTarget.open = false;
event.currentTarget.querySelector('summary')?.focus();
}
/** A searchable list of the nodes already loaded in this scope; nothing is fetched. */
export function PathPicker({ nodes, onPick }: { nodes: readonly GraphNode[]; onPick: (node: GraphNode) => void }) {
const [query, setQuery] = useState('');
const details = useRef<HTMLDetailsElement>(null);
const matches = useMemo(() => {
const needle = query.trim().toLocaleLowerCase();
// Name matches first, as in the node search: exact, prefix, anywhere, then path or qualified name.
const rank = (node: GraphNode) => {
const name = node.name.toLocaleLowerCase();
return name === needle ? 0 : name.startsWith(needle) ? 1 : name.includes(needle) ? 2 : 3;
};
return nodes.filter(node => !needle || [node.name, node.qualified_name, node.file_path]
.some(value => value?.toLocaleLowerCase().includes(needle)))
.sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name) || a.id - b.id);
}, [nodes, query]);
return <details ref={details} className="atlas-graph-path-picker" onKeyDown={closeOnEscape}>
<summary title={text.pathToTitle}>{text.pathTo}</summary>
<div className="atlas-graph-path-menu" role="group" aria-label={text.pathToTitle}>
<input type="search" aria-label={text.pathSearch} placeholder={text.pathSearchPlaceholder}
value={query} onChange={event => setQuery(event.target.value)} />
<ul>{matches.slice(0, PICKER_LIMIT).map(node => <li key={node.id}>
<button type="button" onClick={() => {
onPick(node);
if (details.current) details.current.open = false;
}}><strong>{node.name}</strong><span>{node.file_path ?? node.qualified_name ?? node.label}</span></button>
</li>)}</ul>
{matches.length === 0 && <small>{text.pathNoMatch}</small>}
{matches.length > PICKER_LIMIT && <small>{text.pathMore(matches.length - PICKER_LIMIT)}</small>}
</div>
</details>;
}
/** The compact step list: one line per hop or call site, stepping moves the highlight. */
export function PathSteps({ heading, note, steps, active, nameOf, lines, onStep, onClear }: {
heading: string;
note?: string;
steps: readonly ScopePathStep[];
active: number;
nameOf: (id: number) => string;
/** Call order shows the call-site line; a path shows the hop. */
lines: boolean;
onStep: (index: number) => void;
onClear: () => void;
}) {
return <section className="atlas-galaxy-path-panel" aria-label={text.panel} data-testid="atlas-galaxy-path-panel">
<header>
<strong>{heading}</strong>
<button type="button" title={text.clearTitle} onClick={onClear}>{text.clear}</button>
</header>
{note && <p>{note}</p>}
{steps.length > 0 && <>
<ol>{steps.map((step, index) => <li key={`${index}:${step.edge.id ?? ''}`}>
<button type="button" data-active={index === active} aria-current={index === active ? 'step' : undefined}
onClick={() => onStep(index)}>
<span className="atlas-galaxy-path-hop">{lines
? step.edge.line === undefined ? text.lineUnknown : text.line(step.edge.line)
: text.hop(index + 1)}</span>
<code>{text.step(nameOf(step.from), step.edge.type, nameOf(step.to), step.edge.source === step.from)}</code>
</button>
</li>)}</ol>
<footer>
<button type="button" disabled={active <= 0} onClick={() => onStep(active - 1)}>{text.previous}</button>
<span>{text.position(active + 1, steps.length)}</span>
<button type="button" disabled={active >= steps.length - 1} onClick={() => onStep(active + 1)}>{text.next}</button>
</footer>
</>}
</section>;
}
+7 -5
View File
@@ -5,18 +5,20 @@ import type { GraphNode } from './types';
import { separateProjectedNodes } from './projected-node-spacing';
/** Arrange a new graph or viewport after its camera fit settles. Once committed,
* positions stay fixed while zooming, panning or orbiting the same graph. */
export function ScreenNodeSeparation({ nodes, active, onChange, onBusyChange }: {
* positions stay fixed while zooming, panning or orbiting the same graph.
* Pinned nodes are never moved, so a marked scope root stays where it is. */
export function ScreenNodeSeparation({ nodes, active, onChange, onBusyChange, pinned }: {
nodes: GraphNode[];
active: boolean;
pinned?: ReadonlySet<number>;
onChange: (source: GraphNode[], positioned: GraphNode[]) => void;
onBusyChange?: (busy: boolean) => void;
}) {
const { camera, size, invalidate } = useThree();
const state = useRef({ matrix: new Matrix4(), projection: new Matrix4(), source: undefined as GraphNode[] | undefined,
width: 0, height: 0, changedAt: 0, dirty: true, queued: 0, busy: false });
const callbacks = useRef({ onChange, onBusyChange });
callbacks.current = { onChange, onBusyChange };
const callbacks = useRef({ onChange, onBusyChange, pinned });
callbacks.current = { onChange, onBusyChange, pinned };
useEffect(() => () => {
cancelAnimationFrame(state.current.queued);
callbacks.current.onBusyChange?.(false);
@@ -43,7 +45,7 @@ export function ScreenNodeSeparation({ nodes, active, onChange, onBusyChange }:
current.queued = requestAnimationFrame(() => {
current.queued = requestAnimationFrame(() => {
current.queued = 0;
callbacks.current.onChange(nodes, separateProjectedNodes(nodes, camera, size.width, size.height));
callbacks.current.onChange(nodes, separateProjectedNodes(nodes, camera, size.width, size.height, callbacks.current.pinned));
current.dirty = false; current.busy = false;
callbacks.current.onBusyChange?.(false);
invalidate();
+60
View File
@@ -15,6 +15,7 @@ import {
FLAT_MIN_ZOOM,
FRAME_MARGIN,
FRAME_MIN_DISTANCE,
containShift,
fitCamera,
flatBounds,
frameDistance,
@@ -324,9 +325,68 @@ describe('fitCamera', () => {
expect(fitCamera([{ x: Number.NaN, y: Number.NaN, z: Number.NaN }], FOV, ASPECT)).toBeNull();
});
it('steht auf der Wurzel und nicht auf der Mitte, und haelt trotzdem jeden Knoten im Bild', () => {
// Eine Wolke, deren Wurzel am Rand liegt: die Mitte des Kastens ist weit weg.
const points = cloud(300, [900, 500, 160], 31).map((point) => ({ x: point.x + 380, y: point.y, z: point.z }));
const root = { x: 0, y: 0, z: 0 };
const fit = fitCamera([...points, root], FOV, ASPECT, FRAME_MARGIN, root)!;
expect(fit.center).toEqual(root);
const seen = project(root, fit, FOV, ASPECT);
expect(Math.abs(seen.x)).toBeLessThan(1e-9);
expect(Math.abs(seen.y)).toBeLessThan(1e-9);
for (const point of points) {
const at = project(point, fit, FOV, ASPECT);
expect(at.z).toBeGreaterThan(0);
expect(Math.max(Math.abs(at.x), Math.abs(at.y))).toBeLessThanOrEqual(1);
}
expect(fit.distance).toBeGreaterThan(fitCamera(points, FOV, ASPECT)!.distance);
});
it('steht bei einem einzigen Punkt an der Untergrenze und sieht ihn an', () => {
const fit = fitCamera([{ x: 40, y: -12, z: 5 }], FOV, ASPECT)!;
expect(fit.distance).toBe(FRAME_MIN_DISTANCE);
expect(fit.center).toEqual({ x: 40, y: -12, z: 5 });
});
});
/*
* Das naechste Bild desselben Scopes (Review-Befund G1).
*
* Ein Expand und die Antwort nach einer Vorschau passen nicht neu ein; die
* Kamera tritt entlang ihrer Blickrichtung nur so weit zurueck oder vor, dass
* der aeusserste Knoten mit Rand am Bildrand steht. Gerechnet im Blickraum:
* vor der Kamera ist z negativ.
*/
describe('containShift', () => {
const ASPECT = 1.5;
const half = Math.tan((FOV * Math.PI) / 360);
it('tritt genau so weit zurueck, dass der aeusserste Punkt am Rand steht', () => {
const point = { x: 0, y: 300, z: -200 };
const back = containShift([point], FOV, ASPECT);
expect(back).toBeGreaterThan(0);
expect((point.y * FRAME_MARGIN) / ((200 + back) * half)).toBeCloseTo(1, 6);
});
it('tritt vor, wenn ein kleineres Bild mit viel Rand dasteht', () => {
const points = [{ x: 0, y: 0, z: -400 }, { x: 100, y: -80, z: -500 }];
const shift = containShift(points, FOV, ASPECT);
expect(shift).toBeLessThan(0);
const fills = points.map((point) => Math.max(
(Math.abs(point.x) * FRAME_MARGIN) / ((-point.z + shift) * half * ASPECT),
(Math.abs(point.y) * FRAME_MARGIN) / ((-point.z + shift) * half)));
expect(Math.max(...fills)).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 = containShift([wide], FOV, ASPECT);
expect((wide.x * FRAME_MARGIN) / ((100 + back) * half * ASPECT)).toBeCloseTo(1, 6);
expect(containShift([{ x: 0, y: 0, z: 50 }], FOV, ASPECT)).toBeCloseTo(50, 6);
});
it('bleibt ohne brauchbare Punkte stehen, statt NaN zu liefern', () => {
expect(containShift([{ x: Number.NaN, y: 0, z: -1 }], FOV, ASPECT)).toBe(0);
expect(containShift([], FOV, ASPECT)).toBe(0);
});
});
+56 -5
View File
@@ -414,6 +414,37 @@ export function principalFrame(points: readonly Vector3Like[]): PrincipalFrame {
};
}
/**
* Wie weit eine Kamera entlang ihrer Blickrichtung treten muss, damit jeder
* Punkt mit Rand im Bild liegt und der aeusserste genau am Rand steht.
*
* Die Punkte stehen im Blickraum der Kamera (vor ihr ist z negativ). Ein
* Schritt entlang der Blickrichtung 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. Positiv heisst
* zurueck, negativ heisst vor; ohne brauchbare Punkte bleibt sie stehen.
*/
export function containShift(
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 shift = Number.NEGATIVE_INFINITY;
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);
shift = Math.max(shift, needed + point.z);
}
return Number.isFinite(shift) ? shift : 0;
}
/** Wo die Kamera steht, damit die ganze Wolke im Bild ist. */
export interface CameraFit {
/** Der Punkt, auf den die Kamera sieht. */
@@ -451,18 +482,38 @@ export interface CameraFit {
*
* Der Rand ist derselbe wie bei jeder anderen Rahmung dieser Datei
* ({@link FRAME_MARGIN}), damit "eingepasst" ueberall dasselbe heisst.
*
* Mit `center` (Review-Befund G4) steht die Kamera auf diesen Punkt und nicht
* auf die Mitte der Wolke: die Wurzel eines Scopes bleibt in der Bildmitte.
* Die Ausdehnungen werden dann symmetrisch um ihn gemessen, also ist der
* Rahmen so gross, dass auch der fernste Knoten auf der anderen Seite passt.
*/
export function fitCamera(
points: readonly Vector3Like[],
fovDegrees: number,
aspect: number,
margin: number = FRAME_MARGIN,
center?: Vector3Like,
): CameraFit | null {
const frame = principalFrame(points);
if (frame.counted === 0) {
return null;
}
const [width, height, depth] = frame.extents;
let [width, height, depth] = frame.extents;
const pinned = center !== undefined && [center.x, center.y, center.z].every(Number.isFinite) ? center : undefined;
if (pinned !== undefined) {
[width, height, depth] = frame.axes.map((axis) => {
const offset = dot(pinned, axis);
let reach = 0;
for (const point of points) {
if (Number.isFinite(point.x) && Number.isFinite(point.y) && Number.isFinite(point.z)) {
reach = Math.max(reach, Math.abs(dot(point, axis) - offset));
}
}
return reach * 2;
}) as [number, number, number];
}
const middle = pinned ?? frame.center;
const planar = frameDistance(
{ centerX: 0, centerY: 0, width, height },
fovDegrees,
@@ -472,11 +523,11 @@ export function fitCamera(
const distance = planar + depth / 2;
const normal = (frame.axes[2] as Vector3Like);
return {
center: frame.center,
center: middle,
eye: {
x: frame.center.x + normal.x * distance,
y: frame.center.y + normal.y * distance,
z: frame.center.z + normal.z * distance,
x: middle.x + normal.x * distance,
y: middle.y + normal.y * distance,
z: middle.z + normal.z * distance,
},
up: frame.axes[1] as Vector3Like,
normal,
+43
View File
@@ -0,0 +1,43 @@
/**
* The words of the Galaxy path view, beside the view like the other domain
* copy (agents/agent-strings.ts, architecture/strings.ts).
*
* One rule for every sentence here: a path is counted in hops over the
* relationships that are loaded in this scope, nothing more. It is not a
* runtime trace and it does not claim that nothing else connects the two.
*/
export const galaxyPathText = {
pathTo: 'Path to…',
pathToTitle: 'Highlight the shortest path from the root to a node in this scope',
pathSearch: 'Find a path target',
pathSearchPlaceholder: 'Node in this scope…',
pathNoMatch: 'No matching node in the loaded scope.',
pathMore: (count: number) => `${count.toLocaleString()} more, type to narrow`,
callOrder: 'Call order',
callOrderTitle: 'Step through the outgoing calls of the root in source line order',
callOrderUnavailable: 'The root has no outgoing calls in the loaded scope',
panel: 'Path steps',
pathHeading: (target: string, hops: number) => `Path to ${target} · ${hops === 1 ? '1 hop' : `${hops} hops`}`,
callsHeading: (root: string, calls: number) => `Calls of ${root} · ${calls === 1 ? '1 call' : `${calls} calls`}`,
noPath: (target: string) => `No path to ${target} over the loaded relationships in this trace direction. Expand the scope or trace both directions.`,
isRoot: (target: string) => `${target} is the root of this scope.`,
hop: (hop: number) => `hop ${hop}`,
line: (line: number) => `line ${line}`,
lineUnknown: 'no line',
/** The hop as the reader follows it, with the indexed direction kept. */
step: (from: string, type: string, to: string, forward: boolean) =>
(forward ? `${from} --${type}--> ${to}` : `${from} <--${type}-- ${to}`),
previous: 'Previous',
next: 'Next',
clear: 'Clear',
clearTitle: 'Return to the whole scope (Esc)',
position: (index: number, total: number) => `${index} of ${total}`,
};
/** Compact toolbar words, so the scoped toolbar keeps to one row at 1600 px. */
export const galaxyToolbarText = {
groups: (count: number) => `${count.toLocaleString()} groups`,
groupsTitle: (count: number) => `${count.toLocaleString()} connection groups. Groups reflect connections in this trace, not inferred architecture components.`,
limits: 'Limits',
limitsTitle: 'Rendered node and edge limits',
};
+39 -3
View File
@@ -1,5 +1,5 @@
.atlas-graph-exploration { display:flex; align-items:center; flex-wrap:wrap; gap:.5rem; padding:.4rem .75rem; border-bottom:1px solid var(--border, #29423b); font-size:.8rem; }
.atlas-galaxy-search-container { position:relative; flex:1 1 18rem; min-width:12rem; max-width:32rem; }
.atlas-galaxy-search-container { position:relative; flex:1 1 12rem; min-width:9rem; max-width:32rem; }
.atlas-galaxy-search-container > input { box-sizing:border-box; width:100%; min-height:2rem; padding:.4rem .65rem; border:1px solid #29473a; border-radius:6px; background:rgba(7,17,12,.65); color:var(--atlas-fg,#cbdbd0); font:inherit; }
.atlas-galaxy-search-container > input:focus { outline:1px solid #6d9a84; border-color:#6d9a84; }
.atlas-galaxy-search-results { position:absolute; top:calc(100% + .4rem); left:0; z-index:35; width:min(34rem, calc(100vw - 2rem)); padding:.5rem; border:1px solid #355246; border-radius:8px; background:rgba(13,25,19,.98); box-shadow:0 8px 24px #0005; }
@@ -11,8 +11,13 @@
.atlas-graph-exploration button:hover:not(:disabled) { background:rgba(68, 112, 86, .24); border-color:#517d65; }
.atlas-graph-exploration button:focus-visible, .atlas-galaxy-selection-details > summary:focus-visible { outline:2px solid #75ac94; outline-offset:2px; }
.atlas-graph-exploration button:disabled { opacity:.4; cursor:not-allowed; }
.atlas-graph-scope-name { max-width:22rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.atlas-graph-scope-count { color:#93b2a1; margin-inline-start:auto; font-variant-numeric:tabular-nums; }
.atlas-graph-scope-name { max-width:14rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.atlas-graph-scope-count { color:#93b2a1; margin-inline-start:auto; font-variant-numeric:tabular-nums; white-space:nowrap; }
.atlas-graph-scope-groups { white-space:nowrap; }
/* Der Deckel im Scope: dieselbe Bauform wie die Kantenarten (Review-Befund G7). */
.atlas-graph-limits { position:relative; }
.atlas-graph-limits > summary { padding:.25rem .5rem; border:1px solid #29473a; border-radius:6px; cursor:pointer; color:#b5c9bc; white-space:nowrap; }
.atlas-graph-limits-menu { position:absolute; top:calc(100% + .35rem); right:0; z-index:30; display:flex; flex-direction:column; align-items:flex-end; gap:.45rem; padding:.6rem; border:1px solid #355246; border-radius:8px; background:rgba(13,25,19,.98); box-shadow:0 8px 24px #0005; }
.atlas-graph-scope-warning { width:100%; color:#c6aa82; }
.atlas-trace-edge-filter { position:relative; }
.atlas-trace-edge-filter > summary { padding:.25rem .5rem; border:1px solid #29473a; border-radius:6px; cursor:pointer; color:#b5c9bc; }
@@ -43,3 +48,34 @@
.atlas-graph-render-spinner { width:12px; height:12px; box-sizing:border-box; flex:none; border:1.5px solid #99b7a52e; border-top-color:#a8c4b3; border-radius:50%; animation:atlas-graph-render-spin .8s linear infinite; }
@keyframes atlas-graph-render-spin { to { transform:rotate(360deg); } }
@media (prefers-reduced-motion:reduce) { .atlas-graph-render-spinner { animation:none; } }
/* Der Pfad: Auswahl in der Werkzeugleiste, Schrittliste in der Szene, Beschriftung und Ring an den Kanten. */
.atlas-graph-path-picker { position:relative; }
.atlas-graph-path-picker > summary { padding:.25rem .5rem; border:1px solid #29473a; border-radius:6px; cursor:pointer; color:#b5c9bc; white-space:nowrap; }
.atlas-graph-path-menu { position:absolute; top:calc(100% + .35rem); left:0; z-index:30; width:min(22rem, calc(100vw - 3rem)); padding:.55rem; border:1px solid #355246; border-radius:8px; background:rgba(13,25,19,.98); box-shadow:0 8px 24px #0005; }
.atlas-graph-path-menu input { box-sizing:border-box; width:100%; padding:.35rem .5rem; border:1px solid #29473a; border-radius:5px; background:rgba(7,17,12,.65); color:var(--atlas-fg,#cbdbd0); font:inherit; }
.atlas-graph-path-menu ul { max-height:18rem; overflow:auto; list-style:none; padding:0; margin:.45rem 0 0; }
.atlas-graph-path-menu button { width:100%; display:flex; gap:.5rem; align-items:baseline; text-align:left; border:0; background:transparent; }
.atlas-graph-path-menu button span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:.72rem; color:#95a89e; }
.atlas-graph-path-menu small { display:block; color:#95a89e; margin-top:.4rem; }
.atlas-graph-exploration button[aria-pressed=true] { border-color:#80ac96; background:rgba(68, 112, 86, .3); }
.atlas-galaxy-path-panel { position:absolute; z-index:25; top:12px; left:12px; display:flex; flex-direction:column; gap:.35rem; width:min(26rem, calc(100% - 24px)); max-height:min(22rem, 55%); box-sizing:border-box; padding:.55rem .65rem; border:1px solid #355246; border-radius:8px; background:rgba(13, 25, 19, .94); color:#cbdbd0; font-size:.78rem; }
.atlas-galaxy-path-panel header, .atlas-galaxy-path-panel footer { display:flex; align-items:center; justify-content:space-between; gap:.5rem; }
.atlas-galaxy-path-panel p { margin:0; color:#c6aa82; }
.atlas-galaxy-path-panel ol { flex:1 1 auto; min-height:0; overflow:auto; margin:0; padding:0; list-style:none; }
.atlas-galaxy-path-panel li button { width:100%; display:flex; gap:.5rem; align-items:baseline; text-align:left; padding:.2rem .35rem; border:1px solid transparent; border-radius:5px; background:transparent; color:inherit; font:inherit; cursor:pointer; }
.atlas-galaxy-path-panel li button[data-active=true] { border-color:#80ac96; background:rgba(68, 112, 86, .28); }
.atlas-galaxy-path-panel code { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font:11px var(--atlas-mono, ui-monospace, monospace); }
.atlas-galaxy-path-hop { flex:none; min-width:3.6rem; color:#91b6a3; font-variant-numeric:tabular-nums; }
.atlas-galaxy-path-panel header button, .atlas-galaxy-path-panel footer button { padding:.15rem .5rem; border:1px solid #29473a; border-radius:5px; background:rgba(17, 34, 26, .72); color:inherit; font:inherit; cursor:pointer; }
.atlas-galaxy-path-panel footer button:disabled { opacity:.4; cursor:not-allowed; }
.atlas-galaxy-path-label { display:block; padding:0 4px; border-radius:3px; background:rgba(13, 15, 18, .78); color:#cfe0d6; font:600 10px/1.4 var(--atlas-mono, ui-monospace, monospace); white-space:nowrap; }
.atlas-galaxy-path-label[data-active=true] { color:#ffffff; outline:1px solid #80ac96; }
.atlas-galaxy-path-step { position:relative; display:block; width:0; height:0; pointer-events:none; }
.atlas-galaxy-path-step > i { position:absolute; left:-12px; top:-12px; width:24px; height:24px; box-sizing:border-box; border:2px solid #9fe0bd; border-radius:50%; box-shadow:0 0 12px rgba(159, 224, 189, .55); }
.atlas-galaxy-path-step > b { position:absolute; bottom:15px; left:0; transform:translateX(-50%); padding:1px 6px; border-radius:4px; background:rgba(13, 15, 18, .84); color:#cfeedd; font:600 12px/1.4 var(--atlas-ui-font, system-ui, sans-serif); white-space:nowrap; }
/* Die Wurzel eines Scopes: ein Ring auf dem Punkt und ihr Name darunter, immer sichtbar. */
.atlas-galaxy-root-marker { position:relative; display:block; width:0; height:0; pointer-events:none; }
.atlas-galaxy-root-marker > i { position:absolute; left:-15px; top:-15px; width:30px; height:30px; box-sizing:border-box; border:2px solid #f0cf73; border-radius:50%; box-shadow:0 0 0 4px rgba(240, 207, 115, .16), 0 0 16px rgba(240, 207, 115, .5); }
.atlas-galaxy-root-marker > b { position:absolute; top:19px; left:0; transform:translateX(-50%); padding:1px 6px; border-radius:4px; background:rgba(13, 15, 18, .84); color:#f5e2a6; font:600 12px/1.4 var(--atlas-ui-font, system-ui, sans-serif); white-space:nowrap; }
+8
View File
@@ -215,6 +215,14 @@ it('wraps large hierarchy levels into readable blocks without losing or overlapp
}
expect(result.data.nodes.every(node => node.color === '#abcabc')).toBe(true);
});
it('takes hop columns from discovered levels, not from the global layout depth of a preview', () => {
// A preview from the overall layout keeps server coordinates; z says nothing about hops.
const nodes = [{ ...node(1), z: -250 }, { ...node(2), z: 40 }, { ...node(3), z: -90 }];
const levels = new Map([[1, 0], [2, 1], [3, 2]]);
const result = scopedHierarchy({ data: { nodes, edges: [], total_nodes: 3 }, roots: new Set([1]), depth: 2, exhausted: false, levels }, 'preview');
expect(Object.fromEntries(result.placements.map(placement => [placement.name, placement.hop]))).toEqual({ n1: 0, n2: 1, n3: 2 });
expect(result.depth).toBe(3);
});
it('retains simple columns for small hierarchy levels', () => {
const nodes = [{ ...node(1), z: 0 }, { ...node(2), z: -18 }, { ...node(3), z: -18 }];
const result = scopedHierarchy({ data: { nodes, edges: [], total_nodes: 3 }, roots: new Set([1]), depth: 1, exhausted: false }, 'small');
+5 -2
View File
@@ -208,11 +208,14 @@ export function limitGraphRender(data: GraphData, nodeLimit: number, edgeLimit:
/** The same evidence in successive hop blocks. Small hops keep one column;
* large hops wrap into compact grids so fitting thousands of neighbors does
* not turn the entire scene into a single vertical line. */
* not turn the entire scene into a single vertical line.
*
* Hops come from the discovered levels. Only arranged rings encode them in z;
* a preview built from the overall layout keeps its global coordinates. */
export function scopedHierarchy(scope: ScopedGraph, name: string): import('./hierarchy-layout').HierarchyProjection {
const columns = new Map<number, GraphNode[]>();
for (const node of scope.data.nodes) {
const hop = Math.max(0, Math.round(-node.z / 18));
const hop = scope.roots.has(node.id) ? 0 : scope.levels?.get(node.id) ?? Math.max(0, Math.round(-node.z / 18));
const entries = columns.get(hop) ?? []; entries.push(node); columns.set(hop, entries);
}
const nodes: GraphNode[] = [], remap = new Map<number, number>();
@@ -98,6 +98,28 @@ describe('organic relationship clusters', () => {
expect(Math.hypot(added.x - neighbor.x, added.y - neighbor.y, added.z - neighbor.z)).toBeGreaterThan(0);
});
it('keeps the root at the origin across expansions, for a fresh trace and for a previous picture without it', () => {
const origin = { x: 0, y: 0, z: 0 };
const rootAt = (layout: { data: GraphData }, id: number) => {
const found = layout.data.nodes.find(candidate => candidate.id === id)!;
return { x: found.x + 0, y: found.y + 0, z: found.z + 0 };
};
const first = layoutOrganicClusters({ nodes: [node(0), node(1), node(2)], edges: [edge(0, 1), edge(2, 0)], total_nodes: 3 },
{ rootIds: new Set([0]) });
expect(rootAt(first, 0)).toEqual(origin);
const second = layoutOrganicClusters({ nodes: [0, 1, 2, 3, 4].map(id => node(id)), edges: [edge(0, 1), edge(2, 0), edge(1, 3), edge(4, 2)], total_nodes: 5 },
{ rootIds: new Set([0]), previous: first.data });
expect(rootAt(second, 0)).toEqual(origin);
expect(positions({ ...second.data, nodes: second.data.nodes.slice(0, 3) })).toEqual(positions(first.data));
// A different direction or edge filter starts without previous positions.
const fresh = layoutOrganicClusters({ ...second.data, nodes: second.data.nodes.map(candidate => ({ ...candidate, x: 500 })) }, { rootIds: new Set([0]) });
expect(rootAt(fresh, 0)).toEqual(origin);
const elsewhere = layoutOrganicClusters({ nodes: [node(0), node(1), node(2), node(9)], edges: [edge(0, 1), edge(9, 2)], total_nodes: 4 },
{ rootIds: new Set([9]), previous: first.data });
expect(rootAt(elsewhere, 9)).toEqual(origin);
expect(positions({ ...elsewhere.data, nodes: elsewhere.data.nodes.slice(0, 3) })).toEqual(positions(first.data));
});
it('uses source folders only as labels, not artificial communities or layout coordinates', () => {
const source = cliques(), first = layoutOrganicClusters(source);
const renamed = layoutOrganicClusters({ ...source, nodes: source.nodes.map(candidate => ({ ...candidate, file_path: `other/folder-${candidate.id}/file.ts` })) });
+9
View File
@@ -279,10 +279,17 @@ export function layoutOrganicClusters(data: GraphData, options: OrganicClusterOp
}), .006);
const adjacent: number[][] = nodes.map(() => []);
for (const pair of pairs) { adjacent[pair.a]!.push(pair.b); adjacent[pair.b]!.push(pair.a); }
const rootCount = nodes.filter(node => options.rootIds?.has(node.id)).length;
const particles: Particle[] = nodes.map((node, index) => {
const old = previous.get(node.id), anchor = anchors[groupIndex[index]!]!;
const radius = 8 + 1.5 * Math.sqrt(Math.max(0, Number.isFinite(node.size) ? node.size : 1));
if (old) return { x: old.x, y: old.y, z: old.z, radius, fixed: true, home: { x: old.x, y: old.y, z: old.z } };
// Pinned positions cannot be shifted, so a root they do not hold is
// placed at the origin directly instead of re-centering afterwards.
if (previous.size && options.rootIds?.has(node.id)) {
const offset = rootCount > 1 ? cloudPoint(`root:${node.id}`, 20) : { x: 0, y: 0, z: 0 };
return { ...offset, radius, fixed: rootCount === 1, home: { x: 0, y: 0, z: 0 } };
}
const neighboringOld = adjacent[index]!.flatMap(neighbor => previous.has(nodes[neighbor]!.id) ? [previous.get(nodes[neighbor]!.id)!] : []);
const home = neighboringOld.length ? { x: neighboringOld.reduce((sum, p) => sum + p.x, 0) / neighboringOld.length,
y: neighboringOld.reduce((sum, p) => sum + p.y, 0) / neighboringOld.length, z: neighboringOld.reduce((sum, p) => sum + p.z, 0) / neighboringOld.length }
@@ -295,6 +302,8 @@ export function layoutOrganicClusters(data: GraphData, options: OrganicClusterOp
distance: 42 + hash(`length:${nodes[pair.a]!.id}:${nodes[pair.b]!.id}`) / 0x100000000 * 26,
strength: groupIndex[pair.a] === groupIndex[pair.b] ? .034 : .0015,
}), .018);
// Roots sit at the origin: a fresh layout is re-centered on them, and an
// expansion keeps the earlier, already centered positions.
const roots = nodes.flatMap((node, index) => options.rootIds?.has(node.id) ? [particles[index]!] : []);
const origin = previous.size || !roots.length ? { x: 0, y: 0, z: 0 }
: { x: roots.reduce((sum, p) => sum + p.x, 0) / roots.length, y: roots.reduce((sum, p) => sum + p.y, 0) / roots.length,
@@ -30,8 +30,10 @@ export function projectNodeDisks(nodes: readonly GraphNode[], camera: Camera, wi
}
/** Display-only offsets: index coordinates, identities and relationships stay
* intact. Keep depth and let the drawing grow outside the viewport if needed. */
export function separateProjectedNodes(nodes: GraphNode[], camera: Camera, width: number, height: number): GraphNode[] {
* intact. Keep depth and let the drawing grow outside the viewport if needed.
* Pinned nodes (a scope root) keep their place; the others move around them. */
export function separateProjectedNodes(nodes: GraphNode[], camera: Camera, width: number, height: number,
pinned: ReadonlySet<number> = new Set()): GraphNode[] {
if (width <= 0 || height <= 0 || nodes.length < 2) return nodes;
const view = new Vector3();
const perspective = camera.projectionMatrix.elements[15] === 0;
@@ -44,7 +46,7 @@ export function separateProjectedNodes(nodes: GraphNode[], camera: Camera, width
}) : nodes;
for (let pass = 0; pass < 4; pass++) {
const disks = projectNodeDisks(positioned, camera, width, height);
const separated = separateScreenNodes(disks, 7);
const separated = separateScreenNodes(disks, 7, pinned);
const moved = new Map<number, GraphNode>(), point = new Vector3();
for (let index = 0; index < separated.length; index++) {
const disk = separated[index]!, original = disks[index]!;
@@ -57,7 +59,7 @@ export function separateProjectedNodes(nodes: GraphNode[], camera: Camera, width
// Lateral movement can enlarge perspective silhouettes. Verify against
// the actual reprojected bodies, not only the input circle estimates.
const actual = projectNodeDisks(positioned, camera, width, height);
if (separateScreenNodes(actual, 5).every((disk, index) => disk === actual[index])) break;
if (separateScreenNodes(actual, 5, pinned).every((disk, index) => disk === actual[index])) break;
}
return positioned;
}
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { callOrder, pathNodes, shortestScopePath } from './scope-path';
import type { GraphEdge } from './types';
const edge = (id: number, source: number, target: number, type = 'CALLS', line?: number): GraphEdge => ({ id, source, target, type, line });
const hops = (steps: ReturnType<typeof shortestScopePath>) => steps?.map(step => `${step.from}>${step.to}:${step.edge.id}`);
describe('shortest path inside the loaded scope', () => {
// 1 -> 2 -> 3 -> 4, a shortcut 1 -> 4 typed IMPORTS, and a caller 5 -> 1.
const edges = [edge(10, 1, 2), edge(11, 2, 3), edge(12, 3, 4), edge(13, 1, 4, 'IMPORTS'), edge(14, 5, 1), edge(15, 6, 6)];
const roots = new Set([1]);
it('follows outgoing relationships and takes the fewest hops', () => {
expect(hops(shortestScopePath(edges, roots, 4, 'outbound'))).toEqual(['1>4:13']);
expect(hops(shortestScopePath(edges, roots, 3, 'outbound'))).toEqual(['1>2:10', '2>3:11']);
expect(shortestScopePath(edges, roots, 5, 'outbound')).toBeUndefined();
});
it('walks edges backwards for incoming traces and keeps their indexed direction', () => {
const steps = shortestScopePath(edges, roots, 5, 'inbound')!;
expect(hops(steps)).toEqual(['1>5:14']);
expect(steps[0]!.edge.source).toBe(5);
expect(shortestScopePath(edges, roots, 2, 'inbound')).toBeUndefined();
});
it('ignores direction for both and returns the empty path for a root', () => {
expect(hops(shortestScopePath(edges, roots, 5, 'both'))).toEqual(['1>5:14']);
expect(hops(shortestScopePath(edges, roots, 3, 'both'))).toHaveLength(2);
expect(shortestScopePath(edges, roots, 1, 'both')).toEqual([]);
expect(shortestScopePath(edges, roots, 6, 'both')).toBeUndefined();
});
it('is deterministic under edge order and starts from the nearest of several roots', () => {
const diamond = [edge(1, 1, 2), edge(2, 1, 3), edge(3, 2, 4), edge(4, 3, 4)];
const forward = hops(shortestScopePath(diamond, roots, 4, 'outbound'));
expect(hops(shortestScopePath([...diamond].reverse(), roots, 4, 'outbound'))).toEqual(forward);
expect(forward).toEqual(['1>2:1', '2>4:3']);
expect(hops(shortestScopePath(diamond, new Set([1, 3]), 4, 'outbound'))).toEqual(['3>4:4']);
});
it('collects every node a path touches', () => {
const steps = shortestScopePath(edges, roots, 3, 'outbound')!;
expect([...pathNodes(steps, roots)].sort()).toEqual([1, 2, 3]);
expect([...pathNodes([], roots)]).toEqual([1]);
});
});
describe('call order of the root', () => {
it('lists outgoing CALLS by call-site line, unknown lines last, every call site once', () => {
const edges = [edge(1, 1, 4, 'CALLS', 30), edge(2, 1, 2, 'CALLS', 12), edge(3, 1, 3, 'IMPORTS', 1),
edge(4, 5, 1, 'CALLS', 2), edge(5, 1, 3, 'CALLS'), edge(6, 1, 2, 'CALLS', 40), edge(7, 1, 1, 'CALLS', 5)];
expect(callOrder(edges, 1).map(step => `${step.to}@${step.edge.line ?? '-'}`)).toEqual(['2@12', '4@30', '2@40', '3@-']);
expect(callOrder(edges, 1).every(step => step.from === 1 && step.edge.source === 1)).toBe(true);
expect(callOrder(edges, 9)).toEqual([]);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { TraceDirection } from './graph-scope';
import type { GraphEdge } from './types';
/** One hop of a path, in path order: `from` is one hop closer to the root.
* The edge keeps its indexed direction, so `edge.source === from` tells
* whether the hop follows the relationship or walks it backwards. */
export interface ScopePathStep { edge: GraphEdge; from: number; to: number }
/** The traversal key for deterministic ties: neighbor first, then edge identity. */
const order = (a: { to: number; edge: GraphEdge; index: number }, b: { to: number; edge: GraphEdge; index: number }) =>
a.to - b.to || (a.edge.id ?? Number.MAX_SAFE_INTEGER) - (b.edge.id ?? Number.MAX_SAFE_INTEGER)
|| a.edge.type.localeCompare(b.edge.type) || a.index - b.index;
/** Shortest path from any root to `target` over the loaded scope edges only.
* `outbound` follows edges, `inbound` walks them backwards (who reaches the
* root), `both` ignores direction. Undefined means no path exists inside the
* loaded scope; a root target has the empty path. Hops are counted, nothing
* else: there is no runtime or architectural distance in this number. */
export function shortestScopePath(edges: readonly GraphEdge[], roots: ReadonlySet<number>, target: number,
direction: TraceDirection): ScopePathStep[] | undefined {
if (roots.has(target)) return [];
const next = new Map<number, { to: number; edge: GraphEdge; index: number }[]>();
const link = (from: number, to: number, edge: GraphEdge, index: number) => {
const list = next.get(from) ?? []; list.push({ to, edge, index }); next.set(from, list);
};
edges.forEach((edge, index) => {
if (edge.source === edge.target) return;
if (direction !== 'inbound') link(edge.source, edge.target, edge, index);
if (direction !== 'outbound') link(edge.target, edge.source, edge, index);
});
next.forEach(list => list.sort(order));
const reached = new Map<number, { from: number; edge: GraphEdge } | undefined>([...roots].sort((a, b) => a - b).map(root => [root, undefined]));
const queue = [...reached.keys()];
for (let at = 0; at < queue.length && !reached.has(target); at++) {
const from = queue[at]!;
for (const hop of next.get(from) ?? []) {
if (reached.has(hop.to)) continue;
reached.set(hop.to, { from, edge: hop.edge });
queue.push(hop.to);
}
}
if (!reached.has(target)) return undefined;
const steps: ScopePathStep[] = [];
let node = target, parent = reached.get(node);
while (parent) {
steps.push({ edge: parent.edge, from: parent.from, to: node });
node = parent.from; parent = reached.get(node);
}
return steps.reverse();
}
/** The root's outgoing CALLS in source order: by call-site line, calls without
* a recorded line last, then by callee. Every call site is its own step. */
export function callOrder(edges: readonly GraphEdge[], root: number): ScopePathStep[] {
return edges.map((edge, index) => ({ edge, index }))
.filter(({ edge }) => edge.type === 'CALLS' && edge.source === root && edge.target !== root)
.sort((a, b) => (a.edge.line ?? Number.MAX_SAFE_INTEGER) - (b.edge.line ?? Number.MAX_SAFE_INTEGER)
|| a.edge.target - b.edge.target || a.index - b.index)
.map(({ edge }) => ({ edge, from: root, to: edge.target }));
}
/** Every node a path touches, roots included. */
export function pathNodes(steps: readonly ScopePathStep[], roots: ReadonlySet<number>): Set<number> {
const ids = new Set<number>(steps.length ? [steps[0]!.from] : roots);
for (const step of steps) { ids.add(step.from); ids.add(step.to); }
return ids;
}
@@ -61,4 +61,12 @@ describe('projected node spacing', () => {
expect(separateScreenNodes(nodes)[0]).toBe(nodes[0]);
expect(separateScreenNodes([])).toEqual([]);
});
it('never moves a pinned root, even when larger disks sit on top of it', () => {
const nodes = [{ id: 1, x: 0, y: 0, radius: 3 }, ...Array.from({ length: 30 }, (_, index) =>
({ id: index + 2, x: (index % 5) - 2, y: Math.floor(index / 5) - 3, radius: 6 + index % 9 }))];
const result = separateScreenNodes(nodes, 4, new Set([1]));
expect(result[0]).toBe(nodes[0]);
expect(smallestGap(result)).toBeGreaterThanOrEqual(4 - 1e-8);
});
});
+14 -10
View File
@@ -18,20 +18,23 @@ function directionSeed(id: number): number {
return ((seed ^ (seed >>> 16)) >>> 0) / 0x100000000 * Math.PI * 2;
}
/** Screen-space presentation only. Largest disks are placed first, preserving
* their preferred positions when possible. Existing graph IDs and metadata
* are retained, and the result remains in the original input order.
/** Screen-space presentation only. Pinned disks (a scope root) never move and
* are placed first; then the largest disks, preserving their preferred
* positions when possible. Existing graph IDs and metadata are retained, and
* the result remains in the original input order.
*
* Each radius scale has its own spatial hash. Earlier disks are at least as
* large as the candidate, so only nine cells per occupied scale can overlap.
* large as the candidate, so only nine cells per occupied scale can overlap;
* pinned disks are hashed at the largest scale for the same guarantee.
* Local search is bounded; an outside-bounds fallback guarantees clearance
* without dropping nodes or squeezing them into a fixed viewport. */
export function separateScreenNodes<T extends ScreenNode>(nodes: readonly T[], gap = 4): T[] {
export function separateScreenNodes<T extends ScreenNode>(nodes: readonly T[], gap = 4, pinned: ReadonlySet<number> = new Set()): T[] {
if (!Number.isFinite(gap) || gap < 0 || nodes.some(node => ![node.x, node.y, node.radius].every(Number.isFinite) || node.radius < 0)) {
throw new RangeError('Screen node positions, radii and gap must be finite; radii and gap must be nonnegative.');
}
const order = nodes.map((node, index) => ({ node, index }))
.sort((a, b) => b.node.radius - a.node.radius || a.node.id - b.node.id || a.index - b.index);
const order = nodes.map((node, index) => ({ node, index, pinned: pinned.has(node.id) }))
.sort((a, b) => Number(b.pinned) - Number(a.pinned) || b.node.radius - a.node.radius || a.node.id - b.node.id || a.index - b.index);
const largest = nodes.reduce((max, node) => Math.max(max, node.radius + gap / 2), 0);
const result = new Array<T>(nodes.length), levels = new Map<number, Level>();
let left = Infinity, right = -Infinity, top = Infinity, bottom = -Infinity;
@@ -53,11 +56,11 @@ export function separateScreenNodes<T extends ScreenNode>(nodes: readonly T[], g
return found;
};
const insert = (disk: Disk) => {
const insert = (disk: Disk, scale = disk.radius) => {
left = Math.min(left, disk.x - disk.radius); right = Math.max(right, disk.x + disk.radius);
top = Math.min(top, disk.y - disk.radius); bottom = Math.max(bottom, disk.y + disk.radius);
if (disk.radius === 0) return; // Zero-area disks cannot obstruct later zero-area disks.
const width = 2 ** Math.ceil(Math.log2(disk.radius * 2));
const width = 2 ** Math.ceil(Math.log2(scale * 2));
let level = levels.get(width);
if (!level) { level = { width, columns: new Map() }; levels.set(width, level); }
const x = Math.floor(disk.x / width), y = Math.floor(disk.y / width);
@@ -65,8 +68,9 @@ export function separateScreenNodes<T extends ScreenNode>(nodes: readonly T[], g
const bucket = column.get(y) ?? []; bucket.push(disk); column.set(y, bucket);
};
for (const { node, index } of order) {
for (const { node, index, pinned: fixed } of order) {
const radius = node.radius + gap / 2;
if (fixed) { result[index] = node; insert({ x: node.x, y: node.y, radius }, largest); continue; }
const blocked = collisions(node.x, node.y, radius);
let placed = { x: node.x, y: node.y };
if (blocked.length) {
@@ -127,3 +127,27 @@ it('does not display excluded prior-layer nodes while the filtered request is pe
await act(async () => root.render(<Probe types={[]} />));
expect(host.textContent).toBe('1:pending');
});
it('opens node and symbol scopes one layer deep and keeps them there, while clusters start at their members', async () => {
(globalThis as unknown as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
const host = document.createElement('div'); document.body.append(host); const root = createRoot(host);
dispose = async () => { await act(async () => root.unmount()); host.remove(); };
state.load.mockImplementation(async (...args: unknown[]) => scoped([1], Number(args[2])));
let latest: ReturnType<typeof useGraphScope> | undefined;
function Probe() { latest = useGraphScope({ project: 'p' }); return null; }
await act(async () => root.render(<Probe />));
await act(async () => latest!.select({ kind: 'node', id: 1, name: 'n1', qualifiedName: 'p.n1' }));
expect(latest!.depth).toBe(1); expect(latest!.minDepth).toBe(1);
expect(state.load.mock.calls.at(-1)![2]).toBe(1);
await act(async () => latest!.setDepth(0));
expect(latest!.depth).toBe(1);
expect(state.load).toHaveBeenCalledTimes(1);
await act(async () => latest!.select({ kind: 'symbol', qualifiedName: 'p.n2', name: 'n2' }));
expect(latest!.depth).toBe(1);
expect(state.load.mock.calls.at(-1)![2]).toBe(1);
await act(async () => latest!.select({ kind: 'folder', path: 'src', name: 'src/' }));
expect(latest!.depth).toBe(0); expect(latest!.minDepth).toBe(0);
expect(state.load.mock.calls.at(-1)![2]).toBe(0);
await act(async () => latest!.select({ kind: 'file', path: 'src/a.ts', name: 'a.ts' }));
expect(latest!.depth).toBe(0);
});
+11 -4
View File
@@ -5,6 +5,12 @@ import { readerGraphFocus, type SourceFocusRange } from './reader-graph-focus';
import type { GraphData } from './types';
import { graphNeighborhoodCache, graphNeighborhoodTransportKey, peekGraphNeighborhoodCache } from './graph-neighborhood-cache';
/** A node or symbol alone has no relationships to show, so it starts one hop
* out. Files and folders already carry their internal edges at depth 0. */
export function minimumScopeDepth(scope: GraphScope | undefined): number {
return scope?.kind === 'node' || scope?.kind === 'symbol' ? 1 : 0;
}
/** Scoped requests are independent of the whole-repository layout budget. */
export function useGraphScope({ project, layout, filePath, range, fetch: fetchImpl, edgeTypes }: {
project: string; layout?: GraphData; filePath?: string; range?: SourceFocusRange; fetch?: typeof globalThis.fetch; edgeTypes?: readonly string[];
@@ -23,7 +29,7 @@ export function useGraphScope({ project, layout, filePath, range, fetch: fetchIm
const [readerDepth, setReaderDepth] = useState<{ key: string; depth: number }>();
const actualDepth = filePath ? readerDepth?.key === readerKey ? readerDepth.depth : 1 : depth;
const scope = useMemo<GraphScope | undefined>(() => filePath ? { kind: 'file', path: filePath,
name: `${filePath.split('/').pop()}${range ? `:${range.startLine}–${range.endLine}` : ''}`, ...(range ? { range } : {}) } : chosen,
name: `${filePath.split('/').pop()}${range ? `:${range.startLine}-${range.endLine}` : ''}`, ...(range ? { range } : {}) } : chosen,
[filePath, range?.startLine, range?.endLine, chosen]);
const edgeTypesKey = graphEdgeTypesKey(edgeTypes);
const selectedTypes = useMemo<string[] | undefined>(() => edgeTypesKey === 'null' ? undefined : JSON.parse(edgeTypesKey) as string[], [edgeTypesKey]);
@@ -122,13 +128,14 @@ export function useGraphScope({ project, layout, filePath, range, fetch: fetchIm
return { data: { nodes: layout.nodes.filter(node => ids.has(node.id)), edges: [...edges], total_nodes: ids.size },
roots, depth: actualDepth, exhausted: false, levels };
}, [scope, layout, actualDepth, actualDirection, selectedTypes]);
return { scope, depth: actualDepth, direction, setDirection,
const minDepth = filePath ? 1 : minimumScopeDepth(scope);
return { scope, depth: actualDepth, minDepth, direction, setDirection,
result: complete ?? (cachedPreview?.key === key ? cachedPreview.value : undefined)
?? (result?.scopeKey === scopeKey && result.value.depth <= actualDepth ? result.value : fallback), loading: Boolean(scope && !complete && !error), error,
validating: Boolean(scope && !complete && !error && pendingPhase?.key === key && pendingPhase.validating),
complete: Boolean(complete), retry: () => setReload(value => value + 1),
select: (next: GraphScope) => { setChosen(next); setDepth(0); setDirection('both'); },
select: (next: GraphScope) => { setChosen(next); setDepth(minimumScopeDepth(next)); setDirection('both'); },
reset: () => { setChosen(undefined); setDepth(0); setReaderDepth(undefined); setDirection('both'); },
setDepth: (next: number) => filePath ? setReaderDepth({ key: readerKey, depth: Math.max(1, next) }) : setDepth(Math.max(0, next)),
setDepth: (next: number) => filePath ? setReaderDepth({ key: readerKey, depth: Math.max(minDepth, next) }) : setDepth(Math.max(minDepth, next)),
};
}
@@ -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 };
}
+10
View File
@@ -237,6 +237,16 @@ describe('die Werkzeuge, die erst auf Bitte JSON liefern', () => {
expect(result.callers).toHaveLength(1);
});
it('spricht Richtung und Tiefe in den Woertern des trace_path-Schemas', async () => {
const { client: c, rpc } = client([{ tool: 'trace_path', json: { function: 'createUser', direction: 'inbound' } }]);
await c.tracePath(RECORDED_PROJECT, 'createUser', { direction: 'inbound', depth: 2 });
expect(rpc.calls[0].args).toMatchObject({ function_name: 'createUser', direction: 'inbound', depth: 2 });
expect(rpc.calls[0].args).not.toHaveProperty('max_depth');
await c.tracePath(RECORDED_PROJECT, 'createUser');
expect(rpc.calls[1].args).not.toHaveProperty('direction');
expect(rpc.calls[1].args).not.toHaveProperty('depth');
});
it('gibt die Ablehnung wegen Mehrdeutigkeit als Antwort weiter, nicht als Fehler', async () => {
const { client: c } = client([{
tool: 'trace_path',
+7 -2
View File
@@ -331,17 +331,22 @@ export class RpcIntelligenceClient {
* Pfade von einem Symbol aus laufen. Liefert die Vereinigung: eine
* Ablehnung wegen Mehrdeutigkeit ist hier eine normale Antwort, kein
* Fehler.
*
* Richtung und Tiefe in den Woertern des Servers (src/mcp/mcp.c,
* `trace_path`): `inbound`/`outbound`/`both` und `depth`. Bis zum Review
* vom 2026-10-02 gingen hier `callers`/`callees` und `max_depth` hinaus,
* und beides kennt das Schema nicht.
*/
async tracePath(
project: string,
functionName: string,
args: { direction?: 'callers' | 'callees' | 'both'; maxDepth?: number } = {},
args: { direction?: 'inbound' | 'outbound' | 'both'; depth?: number } = {},
): Promise<PathResult> {
return readPathResult(await this.json('trace_path', {
project,
function_name: functionName,
...(args.direction ? { direction: args.direction } : {}),
...(args.maxDepth !== undefined ? { max_depth: args.maxDepth } : {}),
...(args.depth !== undefined ? { depth: args.depth } : {}),
}));
}