diff --git a/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx index 58b10f499..1b72cec6c 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.stability.test.tsx @@ -10,12 +10,14 @@ 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'; -const scene = vi.hoisted(() => ({ mounts: 0, unmounts: 0, renders: 0, roots: undefined as ReadonlySet | undefined })); +const scene = vi.hoisted(() => ({ mounts: 0, unmounts: 0, renders: 0, roots: undefined as ReadonlySet | undefined, + highlighted: null as Set | null, path: undefined as ScenePath | undefined })); vi.mock('./GraphScene', async importOriginal => ({ ...await importOriginal(), - GraphScene: ({ data, rootIds }: { data: GraphData; rootIds?: ReadonlySet }) => { - scene.renders += 1; scene.roots = rootIds; + GraphScene: ({ data, rootIds, highlightedIds, path }: { data: GraphData; rootIds?: ReadonlySet; highlightedIds: Set | null; path?: ScenePath }) => { + scene.renders += 1; scene.roots = rootIds; scene.highlighted = highlightedIds; scene.path = path; useEffect(() => { scene.mounts += 1; return () => { scene.unmounts += 1; }; }, []); return {data.nodes.length}; }, @@ -27,6 +29,7 @@ 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. */ @@ -77,8 +80,8 @@ it('keeps one scene mounted through selection and expansion and fits once per se await act(async () => { seam().clickNode('sample.n1'); }); await settle(() => { - expect(seam().nodes).toBe(3); - expect(host.querySelector('.atlas-graph-scope-count')?.textContent).toBe('3 nodes · 2 edges'); + expect(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); @@ -88,7 +91,7 @@ it('keeps one scene mounted through selection and expansion and fits once per se const expand = [...host.querySelectorAll('button')].find(button => button.textContent === 'Expand +1')!; await act(async () => expand.click()); - await settle(() => expect(seam().nodes).toBe(4)); + 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); @@ -98,3 +101,55 @@ it('keeps one scene mounted through selection and expansion and fits once per se 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()); + 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('.atlas-graph-path-picker')!.open = true; + const target = [...host.querySelectorAll('.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('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('.atlas-graph-path-picker')!.open = true; + const caller = [...host.querySelectorAll('.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(); +}); diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index 289446ef5..b2f6eef28 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -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 } from './galaxy-strings'; import { useOrganicLayout } from './use-organic-layout'; import RenderProgress from './RenderProgress'; import { useGraphScope } from './use-graph-scope'; @@ -1185,6 +1188,52 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { }); }, [props.workspaceExpanded, scope.scope, traceTypes, kinds, changeTraceTypes]); + /* + * 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. + */ + const [trail, setTrail] = useState<{ key: string; kind: 'path'; target: number } | { key: string; kind: 'calls' }>(); + const [trailStep, setTrailStep] = useState(0); + 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 = nameOf(trail.target); + 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); }, []); + useEffect(() => { + if (!trailView) return; + const onKey = (event: globalThis.KeyboardEvent): void => { + if (event.key !== 'Escape' || event.defaultPrevented) return; + event.preventDefault(); + clearTrail(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [trailView, clearTrail]); + const index = useMemo( () => (picture === undefined ? new Map() : nodesByQualifiedName(picture.nodes)), [picture], @@ -2269,6 +2318,13 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { {scope.depth} {scope.depth === 1 ? 'layer' : 'layers'} + {props.workspaceExpanded && mode === 'galaxy' && <> + { setTrail({ key: organicKey, kind: 'path', target: node.id }); setTrailStep(0); }} /> + + } {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'} @@ -2407,6 +2463,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
+ {trailView && } {/* * Der Weg zurueck zur eingepassten Ansicht (AC5). * @@ -2440,7 +2498,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { rootIds={mode === 'galaxy' && sceneScoped ? scope.result?.roots : undefined} data={shown} 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} /* @@ -2477,7 +2536,7 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { showLabels={ mode === 'hierarchy' ? shown.nodes.length <= HIERARCHY_LABEL_BUDGET - : highlighted !== null && highlighted.size > 0 + : trailIds !== undefined || (highlighted !== null && highlighted.size > 0) } /* * Landmarken nur in der Galaxie: der Halo sitzt auf den diff --git a/graph-ui/src/galaxy/GraphScene.tsx b/graph-ui/src/galaxy/GraphScene.tsx index 2593a7967..16a12884a 100644 --- a/graph-ui/src/galaxy/GraphScene.tsx +++ b/graph-ui/src/galaxy/GraphScene.tsx @@ -96,6 +96,9 @@ * 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'; @@ -110,6 +113,7 @@ 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 { FRAME_MIN_DISTANCE, containShift, fitCamera, flatBounds, frameDistance, orthographicZoom } from './camera-frame'; import type { CameraFit, FrameBox } from './camera-frame'; @@ -813,6 +817,8 @@ interface GraphSceneProps { frameCap?: number; /* Die Wurzeln des gewaehlten Scopes (Aenderung 17). */ rootIds?: ReadonlySet; + /* Ein Pfad oder eine Aufrufreihe ueber der Szene (Aenderung 18). */ + path?: ScenePath; } export type { CameraTarget }; @@ -852,6 +858,7 @@ export function GraphScene({ labelDistanceFactor = 0, frameCap = 0, rootIds, + path, }: GraphSceneProps) { const [hovered, setHovered] = useState(null); const [hoveredShadow, setHoveredShadow] = useState(null); @@ -1016,6 +1023,7 @@ export function GraphScene({ )} {landmarks && } {rootNodes.length > 0 && } + {path !== undefined && } {renderedShadow && renderedShadow.nodes.length > 0 && {drawEdges && } diff --git a/graph-ui/src/galaxy/PathLayer.tsx b/graph-ui/src/galaxy/PathLayer.tsx new file mode 100644 index 000000000..1c514cbdd --- /dev/null +++ b/graph-ui/src/galaxy/PathLayer.tsx @@ -0,0 +1,68 @@ +/* + * 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 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 ( + <> + + + + {segments.filter(({ index }) => path.labels === 'all' || index === path.active).map(({ step, index, from, to }) => ( + + {step.edge.type} + + ))} + {goal && ( + + +