From 080c79f2b4116ce87e68298e04adc3e01f12fa07 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 18:59:05 +0200 Subject: [PATCH] feat(ui): add a path and call order view to the scoped galaxy The call asked for step-by-step tracing, modelled on Graphify's shortest-path view. In the scoped Galaxy workspace the toolbar now offers: - "Path to...": a searchable list of the nodes already loaded in the scope. The shortest path from the root follows the scope's trace direction (outgoing, incoming walked backwards, or both) over the loaded edges only. - "Call order": the root's outgoing CALLS sorted by call-site line. The path or call order is highlighted and everything else dimmed; its edges are drawn over the scene in their type colour with type labels on the path edges only, and the active step carries a ring. A compact step list shows "A --CALLS--> B" with the hop (or call-site line), Previous/Next moves the highlight, and Clear or Escape returns to the normal view. A new root, direction or edge filter drops the path; an expansion recomputes it. Hops are the only distance used here, in line with the plan (G6). Nothing is fetched from the server, and /api/trace is unchanged. Signed-off-by: Bernhard Jackiewicz --- .../src/galaxy/GalaxyPanel.stability.test.tsx | 67 ++++++++++++++-- graph-ui/src/galaxy/GalaxyPanel.tsx | 63 ++++++++++++++- graph-ui/src/galaxy/GraphScene.tsx | 8 ++ graph-ui/src/galaxy/PathLayer.tsx | 68 ++++++++++++++++ .../src/galaxy/ScopePathControls.test.tsx | 69 +++++++++++++++++ graph-ui/src/galaxy/ScopePathControls.tsx | 77 +++++++++++++++++++ graph-ui/src/galaxy/galaxy-strings.ts | 35 +++++++++ graph-ui/src/galaxy/graph-exploration.css | 25 ++++++ graph-ui/src/galaxy/scope-path.test.ts | 56 ++++++++++++++ graph-ui/src/galaxy/scope-path.ts | 67 ++++++++++++++++ 10 files changed, 527 insertions(+), 8 deletions(-) create mode 100644 graph-ui/src/galaxy/PathLayer.tsx create mode 100644 graph-ui/src/galaxy/ScopePathControls.test.tsx create mode 100644 graph-ui/src/galaxy/ScopePathControls.tsx create mode 100644 graph-ui/src/galaxy/galaxy-strings.ts create mode 100644 graph-ui/src/galaxy/scope-path.test.ts create mode 100644 graph-ui/src/galaxy/scope-path.ts 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 && ( + + +