mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-07 08:43:35 +08:00
Merge call-feedback stream: galaxy scope stability, root framing and path view
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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; }
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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` })) });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user