From bd02f3f5065558c159720e75cdae4a1d6495ca29 Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sun, 4 Oct 2026 00:40:05 +0200 Subject: [PATCH] fix(ui): keep a Galaxy hierarchy truthful from two layers on (K5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completeness check of K5: from two layers on every node stood on its parent's side, so the far-left "incoming" column of JSONBAgg held the callees of its tests (len, str, print) and the classes general.py defines, next to real callers. The edge labels merged fans into "CALLS · TESTS ×8" without a direction, and above 150 nodes no name was drawn at all. - A node stands in an incoming or outgoing column only when the whole chain from the root runs one way: left, each node points at its neighbour closer to the root; right, the other way round. Nodes reached through both directions (a callee of a caller, another caller of a callee) stand in a band below the columns, centred under the root, inside a dashed frame with the heading "Mixed directions · N nodes" on its top edge. - Every line carries its own label with its types and an arrow from source to target as the picture shows them ("CALLS · TESTS →", "DEFINES_METHOD → · ← USAGE"); no fan is merged any more. A label stays on its line or right beside it, avoids the band heading, and its measured size follows its text, not the node pair, whose ids change with every picture. - Above 150 nodes the root and its direct neighbours keep their names, single columns and edge labels. A root with more direct neighbours than that keeps its own name and the side that fits (call_command: its 11 callees). A note in the picture says who carries names and what brings the rest back; the hint and the legend describe the band and are right for every depth and trace direction. Signed-off-by: Bernhard Jackiewicz --- .../GalaxyPanel.hierarchy-scope.test.tsx | 119 +++++++++++---- graph-ui/src/galaxy/GalaxyPanel.tsx | 55 +++++-- graph-ui/src/galaxy/GraphScene.tsx | 12 +- graph-ui/src/galaxy/HierarchyEdgeLabels.tsx | 139 +++++++++++------- graph-ui/src/galaxy/galaxy-legend.ts | 5 +- graph-ui/src/galaxy/galaxy-strings.test.ts | 28 ++++ graph-ui/src/galaxy/galaxy-strings.ts | 61 ++++++-- graph-ui/src/galaxy/graph-exploration.css | 5 + graph-ui/src/galaxy/graph-scope.test.ts | 81 +++++++++- graph-ui/src/galaxy/graph-scope.ts | 126 ++++++++++++---- .../src/galaxy/hierarchy-edge-labels.test.ts | 47 +++--- graph-ui/src/galaxy/hierarchy-layout.ts | 8 + graph-ui/src/galaxy/path-frame.ts | 9 +- 13 files changed, 535 insertions(+), 160 deletions(-) create mode 100644 graph-ui/src/galaxy/galaxy-strings.test.ts diff --git a/graph-ui/src/galaxy/GalaxyPanel.hierarchy-scope.test.tsx b/graph-ui/src/galaxy/GalaxyPanel.hierarchy-scope.test.tsx index b8d90e54c..b9db8254c 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.hierarchy-scope.test.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.hierarchy-scope.test.tsx @@ -12,25 +12,28 @@ import type { GraphData } from './types'; import type { ScenePath } from './PathLayer'; import { scopeFetch, scopeNode } from './test-scope-fetch'; import { GALAXY_LEGEND_KEY } from './galaxy-legend'; -import { HierarchyEdgeLabels } from './HierarchyEdgeLabels'; +import { HierarchyBandLabel, HierarchyEdgeLabels } from './HierarchyEdgeLabels'; const scene = vi.hoisted(() => ({ data: undefined as GraphData | undefined, path: undefined as ScenePath | undefined, highlighted: null as Set | null, - labelMaxTextWidth: undefined as number | undefined, overlay: false, overlayNode: undefined as unknown, showLabels: false, labelBudget: undefined as number | undefined })); + labelMaxTextWidth: undefined as number | undefined, overlay: false, overlayNode: undefined as unknown, showLabels: false, labelBudget: undefined as number | undefined, + labelIds: undefined as ReadonlySet | undefined })); vi.mock('./GraphScene', async importOriginal => ({ ...await importOriginal(), - GraphScene: ({ data, path, highlightedIds, labelMaxTextWidth, overlay, showLabels, labelBudget }: { data: GraphData; path?: ScenePath; highlightedIds: Set | null; - labelMaxTextWidth?: number; overlay?: unknown; showLabels: boolean; labelBudget?: number }) => { + GraphScene: ({ data, path, highlightedIds, labelMaxTextWidth, overlay, showLabels, labelBudget, labelIds }: { data: GraphData; path?: ScenePath; highlightedIds: Set | null; + labelMaxTextWidth?: number; overlay?: unknown; showLabels: boolean; labelBudget?: number; labelIds?: ReadonlySet }) => { scene.data = data; scene.path = path; scene.highlighted = highlightedIds; scene.labelMaxTextWidth = labelMaxTextWidth; scene.overlay = Boolean(overlay); - scene.overlayNode = overlay; scene.showLabels = showLabels; scene.labelBudget = labelBudget; + scene.overlayNode = overlay; scene.showLabels = showLabels; scene.labelBudget = labelBudget; scene.labelIds = labelIds; return ; }, })); -/** Die Kantenschilder der Hierarchie im Baum der Ueberlagerung, ohne sie zu zeichnen. */ -function edgeLabelProps(node: unknown): { edges: unknown[] } | undefined { - if (!isValidElement(node)) return Array.isArray(node) ? node.map(edgeLabelProps).find(Boolean) : undefined; - if (node.type === HierarchyEdgeLabels) return node.props as { edges: unknown[] }; - return edgeLabelProps((node.props as { children?: unknown }).children); +/** Ein Element der Ueberlagerung, ohne es zu zeichnen. */ +function overlayProps(node: unknown, type: unknown): T | undefined { + if (!isValidElement(node)) return Array.isArray(node) ? node.map(entry => overlayProps(entry, type)).find(Boolean) : undefined; + if (node.type === type) return node.props as T; + return overlayProps((node.props as { children?: unknown }).children, type); } +/** Die Kantenschilder der Hierarchie im Baum der Ueberlagerung. */ +const edgeLabelProps = (node: unknown) => overlayProps<{ edges: { source: number; target: number }[] }>(node, HierarchyEdgeLabels); let host: HTMLDivElement, root: Root; beforeEach(() => { @@ -101,28 +104,90 @@ it('K5: the hint follows the trace direction', async () => { /* * Review of K5: above sixty nodes the hierarchy of a scope drew no name at all * (JSONBAgg at two layers, 90 nodes) but eighty edge-type labels. Names now - * stand up to 150 nodes, and above that neither names nor edge labels, and the - * hint says why and how to get them back. + * stand up to 150 nodes. Above that the root and its direct neighbours keep + * theirs (second review), and only a hub with more neighbours than that has + * none; a note in the picture says so and how to get them back. */ -it('K5: a two-layer scope of 90 nodes keeps its names in the hierarchy; above 150 names and edge labels go together, and the hint says so', async () => { - const fan = (count: number) => Array.from({ length: count }, (_, at) => scopeNode(100 + at)); - const render = async (count: number) => { - const nodes = [scopeNode(1), ...fan(count)]; - const edges = fan(count).map((node, at) => ({ id: 10 + at, source: node.id, target: 1, type: 'CALLS' })); - await act(async () => root.render()); - await settle(() => expect(seam().nodes).toBe(count + 1)); - await act(async () => { seam().clickNode('sample.n1'); }); - await settle(() => expect(host.querySelector('.atlas-graph-scope-count')?.textContent).toBe(`${count + 1} nodes · ${count} edges`)); - await act(async () => host.querySelector('[data-testid="atlas-graph-mode-chip"][data-mode="hierarchy"]')!.click()); - }; - await render(89); +const renderFan = async (callers: number, outer = 0) => { + const first = Array.from({ length: callers }, (_, at) => scopeNode(100 + at)); + const second = Array.from({ length: outer }, (_, at) => scopeNode(1000 + at)); + const nodes = [scopeNode(1), ...first, ...second]; + const edges = [...first.map((node, at) => ({ id: 10 + at, source: node.id, target: 1, type: 'CALLS' })), + ...second.map((node, at) => ({ id: 5000 + at, source: node.id, target: first[at % callers]!.id, type: 'CALLS' }))]; + await act(async () => root.render()); + await settle(() => expect(seam().nodes).toBe(nodes.length)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(host.querySelector('.atlas-graph-scope-count')?.textContent).toBe(`${callers + 1} nodes · ${callers} edges`)); + await act(async () => host.querySelector('[data-testid="atlas-graph-mode-chip"][data-mode="hierarchy"]')!.click()); + if (outer > 0) { + await act(async () => button('Expand +1')!.click()); + await settle(() => expect(host.querySelector('.atlas-graph-scope-count')?.textContent).toBe(`${nodes.length} nodes · ${edges.length} edges`)); + } +}; +const hierarchyHint = () => host.querySelector('[data-testid="atlas-graph-mode-chip"][data-mode="hierarchy"]')!.closest('[data-hint]')?.getAttribute('data-hint') ?? host.innerHTML; + +it('K5: a two-layer scope of 90 nodes keeps its names in the hierarchy; a hub of 200 direct neighbours names only its root, and the picture says so', async () => { + await renderFan(89); expect(scene.showLabels).toBe(true); expect(scene.labelBudget).toBeGreaterThanOrEqual(90); + expect(scene.labelIds).toBeUndefined(); expect(edgeLabelProps(scene.overlayNode)?.edges).toHaveLength(89); expect(host.innerHTML).not.toContain('names and edge types show'); + expect(host.querySelector('[data-testid="atlas-hierarchy-key"]')).toBeNull(); - await render(200); - expect(scene.showLabels).toBe(false); + await renderFan(200); + // Only the root keeps its name; no edge label stands between two named nodes. + expect(scene.showLabels).toBe(true); + expect([...scene.labelIds ?? []].map(id => scene.data!.nodes.find(node => node.id === id)?.name)).toEqual(['n1']); expect(edgeLabelProps(scene.overlayNode)).toBeUndefined(); - expect(host.innerHTML).toContain('names and edge types show for up to 150 nodes'); + expect(hierarchyHint()).toContain('names and edge types show for up to 150 nodes, and the root has 200 direct neighbours, so only the root carries a name; trace one direction or fewer edge types to see the rest'); + expect(host.querySelector('[data-testid="atlas-hierarchy-key"]')?.textContent) + .toBe('201 nodes, 200 of them direct neighbours of the root: names for the root only (up to 150 names). Trace one direction or fewer edge types to see the rest.'); +}); + +it('K5: above 150 nodes the root and its direct neighbours keep their names and edge labels, and the note says how to see the rest', async () => { + await renderFan(40, 160); + expect(scene.showLabels).toBe(true); + const named = new Set([...scene.labelIds ?? []].map(id => scene.data!.nodes.find(node => node.id === id)?.name)); + expect(named.size).toBe(41); + expect(named.has('n1')).toBe(true); + expect(named.has('n100')).toBe(true); + expect(named.has('n1000')).toBe(false); + // Edge labels only where both ends carry a name: the lines at the root. + const edges = edgeLabelProps(scene.overlayNode)?.edges ?? []; + expect(edges).toHaveLength(40); + expect(hierarchyHint()).toContain('only the root and its direct neighbours carry names'); + expect(host.querySelector('[data-testid="atlas-hierarchy-key"]')?.textContent).toMatch(/201 nodes: names for the root and its 40 direct neighbours only.*Remove layers or trace fewer edge types/); +}); + +/* + * Review of K5, second round: from two layers on the columns mixed the + * directions again. A node reached through both stands in a band of its + * own, the band carries a heading in the picture, and the hint counts it. + */ +it('K5: at two layers a callee of a caller stands in the labelled band, and the hint says so; one layer has no band', async () => { + const nodes = [1, 10, 20, 40, 60].map(id => scopeNode(id)); + const edges = [{ id: 1, source: 10, target: 1, type: 'CALLS' }, { id: 2, source: 10, target: 20, type: 'CALLS' }, + { id: 3, source: 1, target: 40, type: 'INHERITS' }, { id: 4, source: 60, target: 40, type: 'INHERITS' }]; + await act(async () => root.render( (key === GALAXY_LEGEND_KEY ? 'open' : null), setItem: vi.fn() } as unknown as Storage} />)); + await settle(() => expect(seam().nodes).toBe(5)); + await act(async () => { seam().clickNode('sample.n1'); }); + await settle(() => expect(host.querySelector('.atlas-graph-scope-count')?.textContent).toBe('3 nodes · 2 edges')); + await act(async () => host.querySelector('[data-testid="atlas-graph-mode-chip"][data-mode="hierarchy"]')!.click()); + expect(overlayProps(scene.overlayNode, HierarchyBandLabel)).toBeUndefined(); + expect(hierarchyHint()).not.toContain('band'); + expect(hierarchyHint()).toContain('the type and direction of each relationship at its line'); + + await act(async () => button('Expand +1')!.click()); + await settle(() => expect(host.querySelector('.atlas-graph-scope-count')?.textContent).toBe('5 nodes · 4 edges')); + const placed = Object.fromEntries(seam().hierarchy!.placements.map(placement => [placement.name, placement])); + expect(placed.n20).toMatchObject({ mixed: true }); + expect(placed.n60).toMatchObject({ mixed: true }); + expect(placed.n10!.x).toBeLessThan(0); + expect(placed.n40!.x).toBeGreaterThan(0); + expect(overlayProps<{ band: { count: number } }>(scene.overlayNode, HierarchyBandLabel)?.band.count).toBe(2); + expect(hierarchyHint()).toContain('2 nodes reached through both directions, such as a callee of a caller, stand in the band below'); + // The legend explains the band as well. + expect(host.querySelector('[data-entry="positions"]')?.textContent).toContain('band below'); }); diff --git a/graph-ui/src/galaxy/GalaxyPanel.tsx b/graph-ui/src/galaxy/GalaxyPanel.tsx index 72991c028..d842d2e16 100644 --- a/graph-ui/src/galaxy/GalaxyPanel.tsx +++ b/graph-ui/src/galaxy/GalaxyPanel.tsx @@ -140,7 +140,7 @@ import { TraceEdgeFilter } from './TraceEdgeFilter'; import { PathPicker, PathSteps } from './ScopePathControls'; import { callOrder, pathNodes, shortestScopePath, type ScopePathStep } from './scope-path'; import { galaxyHierarchyText, galaxyHistoryText, galaxyLayerText, galaxyPathText, galaxyToolbarText } from './galaxy-strings'; -import { HierarchyEdgeLabels } from './HierarchyEdgeLabels'; +import { HierarchyBandLabel, HierarchyEdgeLabels } from './HierarchyEdgeLabels'; import { FitLabel, useToolbarFit } from './toolbar-fit'; import { emptyNavigationHistory, moveNavigation, peekNavigation, pushNavigation } from '../graph/navigation-history'; import { galaxyHistoryOptions, historyEntryDetail, historyEntryLabel, scopeIdentity, type GalaxyHistoryEntry, type ScopeTrail } from './scope-history'; @@ -352,7 +352,10 @@ export interface AtlasHierarchySeam { cap: number; walkDepth: number; /** Je Knoten: Schluessel, Name, Datei, Hop und die gezeichnete Position. */ - placements: { key: string; name: string; file: string; hop: number; x: number; y: number }[]; + placements: { key: string; name: string; file: string; hop: number; x: number; y: number; side?: -1 | 0 | 1; mixed?: boolean }[]; + /** Zweites Review zu K5: das Band der gemischt erreichten Knoten und wer Namen traegt. */ + band?: { count: number; x: number; y: number; left: number; right: number; bottom: number }; + names?: 'all' | 'neighbours' | 'none'; /** Die gezeichneten Kanten, in den Schluesseln des Walks. */ edges: { from: string; to: string }[]; /** Wie viele Linien aus dem Walk stammen. */ @@ -1109,8 +1112,25 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { const scopedProjection = useMemo(() => props.workspaceExpanded && scope.scope && scope.result ? scopedHierarchy(scope.result, scope.scope.name) : undefined, [props.workspaceExpanded, scope.scope, scope.result]); - /* Review zu K5: bis 150 Knoten stehen in der Hierarchie eines Ausschnitts alle Namen, darueber weder Namen noch Kantenschilder. */ - const scopedNamesHidden = Boolean(scopedProjection && scopedProjection.data.nodes.length > SCOPED_HIERARCHY_LABEL_BUDGET); + /* + * Review zu K5: bis 150 Knoten stehen in der Hierarchie eines Ausschnitts + * alle Namen. Darueber behalten die Wurzel und ihre direkten Nachbarn ihre + * Namen und Kantenschilder (zweites Review), und erst eine Wurzel mit mehr + * Nachbarn als das hat keine; eine Notiz im Bild sagt es. + */ + const scopedNames = scopedProjection?.names ?? 'all'; + const scopedNeighbours = useMemo(() => scopedProjection?.placements.filter(placement => placement.hop === 1).length ?? 0, [scopedProjection]); + const scopedNamedIds = useMemo(() => scopedProjection?.namedIds ? new Set(scopedProjection.namedIds) : undefined, [scopedProjection]); + const scopedNamedSides = useMemo(() => { + const sides = { root: false, incoming: 0, outgoing: 0 }; + for (const placement of scopedProjection?.placements ?? []) { + if (!scopedNamedIds?.has(placement.id)) continue; + if (placement.hop === 0) sides.root = true; + else if (placement.hop === 1 && placement.side === -1) sides.incoming += 1; + else if (placement.hop === 1 && placement.side === 1) sides.outgoing += 1; + } + return sides; + }, [scopedProjection, scopedNamedIds]); const scopedSpots = useMemo(() => scopedProjection ? new Map(scopedProjection.placements.map(placement => [placement.id, { hop: placement.hop, x: placement.x, y: placement.y }])) : undefined, [scopedProjection]); @@ -2322,7 +2342,11 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { hop: placement.hop, x: placement.x, y: placement.y, + ...(placement.side !== undefined ? { side: placement.side } : {}), + ...(placement.mixed ? { mixed: true } : {}), })), + ...(projection.band ? { band: projection.band } : {}), + ...(projection.names ? { names: projection.names } : {}), edges: projection.data.edges.map((edge) => ({ from: projection.data.nodes[edge.source]?.qualified_name ?? projection.data.nodes[edge.source]?.name ?? '', @@ -2374,12 +2398,17 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { */ /* K5: die Kantenarten an den Linien der Hierarchie eines Ausschnitts, solange kein Pfad seine eigenen zeigt. */ // Review zu K5: Kantenschilder nur neben Namen; ohne Namen waeren sie Schilder an Punkten, die niemand zuordnen kann. - const hierarchyEdgeLabels = mode === 'hierarchy' && scopedProjection && !scopedNamesHidden && !sceneTrailIds && sceneShown - ? : undefined; - const overlay: ReactNode = (pulseRing === undefined && !liveOn && hierarchyEdgeLabels === undefined) ? undefined : ( + const labelledEdges = useMemo(() => !sceneShown || !scopedNamedIds ? sceneShown?.edges + : sceneShown.edges.filter(edge => scopedNamedIds.has(edge.source) && scopedNamedIds.has(edge.target)), [sceneShown, scopedNamedIds]); + const hierarchyEdgeLabels = mode === 'hierarchy' && scopedProjection && !sceneTrailIds && sceneShown && labelledEdges?.length + ? : undefined; + // Zweites Review zu K5: die Ueberschrift des Bandes der gemischt erreichten Knoten. + const hierarchyBand = mode === 'hierarchy' && scopedProjection?.band ? : undefined; + const overlay: ReactNode = (pulseRing === undefined && !liveOn && hierarchyEdgeLabels === undefined && hierarchyBand === undefined) ? undefined : ( <> {pulseRing} {hierarchyEdgeLabels} + {hierarchyBand} {liveOn && agentLayerOn && agentsView !== undefined && ( @@ -2836,10 +2866,11 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { */ showLabels={ mode === 'hierarchy' - ? scopedProjection ? !scopedNamesHidden : sceneShown.nodes.length <= HIERARCHY_LABEL_BUDGET + ? scopedProjection ? scopedNames === 'all' || Boolean(scopedNamedIds?.size) : sceneShown.nodes.length <= HIERARCHY_LABEL_BUDGET : trailIds !== undefined || (highlighted !== null && highlighted.size > 0) } labelBudget={mode === 'hierarchy' && scopedProjection ? SCOPED_HIERARCHY_LABEL_BUDGET : undefined} + labelIds={mode === 'hierarchy' ? scopedNamedIds : undefined} /* * Landmarken nur in der Galaxie: der Halo sitzt auf den * groessten Knoten, und "gross" heisst in der Projektion @@ -2873,6 +2904,12 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element { overlay={overlay} /> )} + {/* Zweites Review zu K5: fehlen Namen, sagt das Bild es selbst, und wie man sie bekommt. */} + {mode === 'hierarchy' && scopedProjection && scopedNames !== 'all' && ( +

+ {galaxyHierarchyText.namesNote(scope.direction, scopedProjection.data.nodes.length, scopedNames, scopedNeighbours, SCOPED_HIERARCHY_LABEL_BUDGET, scopedNamedSides)} +

+ )} {state !== 'ready' && (

{headline} diff --git a/graph-ui/src/galaxy/GraphScene.tsx b/graph-ui/src/galaxy/GraphScene.tsx index 6c7d39105..b73b02a9d 100644 --- a/graph-ui/src/galaxy/GraphScene.tsx +++ b/graph-ui/src/galaxy/GraphScene.tsx @@ -107,6 +107,9 @@ * um ihre Ringe (src/galaxy/marker-names.ts), statt bei mehreren Wurzeln * aufeinander zu liegen; ein Name ohne freien Platz wird ausgeblendet. Eine * einzelne Wurzel behaelt ihren Namen unter dem Ring. + * 21. Neu (Review zu K5): die Prop `labelIds`. Mit ihr bekommen nur diese + * Knoten einen Namen (die Hierarchie eines grossen Ausschnitts benennt die + * Wurzel und ihre direkten Nachbarn). Ohne die Prop wie vorher. */ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; @@ -905,6 +908,8 @@ interface GraphSceneProps { labelMaxTextWidth?: number | undefined; /* Wie viele Namen hoechstens; ohne Angabe die achtzig von NodeLabels (Review zu K5: die Hierarchie eines Ausschnitts traegt mehr). */ labelBudget?: number | undefined; + /* Nur diese Knoten tragen einen Namen (Aenderung 21); ohne Angabe jeder, den die Namensebene waehlt. */ + labelIds?: ReadonlySet | undefined; onLabelLayout?: ((boxes: LabelBox[]) => void) | undefined; /* Aenderung 10 (W10): was Rechenzeit kostet. Ohne diese vier zeichnet die * Szene wie vorher. */ @@ -959,6 +964,7 @@ export function GraphScene({ labelWorldFontSize, labelMaxTextWidth, labelBudget, + labelIds, onLabelLayout, projection = 'spatial', drawEdges = true, @@ -990,9 +996,9 @@ export function GraphScene({ [markedRoots, renderedCode]); // Der Ring traegt den Namen der Wurzel, die Pfadebene die Namen ihrer Knoten (K6); ein zweiter Name waere doppelt. const pathIds = useMemo(() => (path ? new Set(pathNodeIds(path)) : undefined), [path]); - const labelNodes = useMemo(() => markedRoots || pathIds - ? renderedCode.filter((node) => !markedRoots?.has(node.id) && !pathIds?.has(node.id)) : renderedCode, - [markedRoots, pathIds, renderedCode]); + const labelNodes = useMemo(() => markedRoots || pathIds || labelIds + ? renderedCode.filter((node) => !markedRoots?.has(node.id) && !pathIds?.has(node.id) && (!labelIds || labelIds.has(node.id))) : renderedCode, + [markedRoots, pathIds, labelIds, renderedCode]); /* Das Kameraziel: das des Panels, oder die Fahrt auf einen neuen Pfad, je nachdem, was zuletzt kam. */ const [sceneTarget, setSceneTarget] = useState(cameraTarget); useEffect(() => { setSceneTarget(cameraTarget); }, [cameraTarget]); diff --git a/graph-ui/src/galaxy/HierarchyEdgeLabels.tsx b/graph-ui/src/galaxy/HierarchyEdgeLabels.tsx index 739b79329..74a959fe4 100644 --- a/graph-ui/src/galaxy/HierarchyEdgeLabels.tsx +++ b/graph-ui/src/galaxy/HierarchyEdgeLabels.tsx @@ -4,77 +4,73 @@ * In der Hierarchie eines Ausschnitts war nicht zu erkennen, welche Linie ein * Aufruf, ein Test oder eine Definition ist. Jetzt steht die Art an der Linie, * eine Beschriftung je Knotenpaar: ein Test, der JSONBAgg aufruft UND testet, - * traegt "CALLS · TESTS" statt zweier Schilder auf derselben Linie. Die Labels - * weichen den Namen der Knoten aus, wie die Labels eines Pfades + * traegt "CALLS · TESTS →" statt zweier Schilder auf derselben Linie. Der + * Pfeil zeigt von der Quelle zum Ziel, so wie die beiden im Bild stehen. Die + * Labels weichen den Namen der Knoten aus, wie die Labels eines Pfades * (src/galaxy/path-frame.ts); die Namen sind hier Sprites der Szene, ihre * Kaesten kommen aus NodeLabels (`onLabelLayout`) in Weltkoordinaten. */ import { useMemo, useRef, type RefObject } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; -import { Html } from '@react-three/drei'; +import { Html, Line } from '@react-three/drei'; import * as THREE from 'three'; import { placeAlongSegment, type ScreenRect } from './path-frame'; import type { LabelBox } from './NodeLabels'; import { HIERARCHY_LABEL_FONT_SIZE } from './hierarchy-layout'; +import { galaxyHierarchyText } from './galaxy-strings'; import type { GraphEdge, GraphNode } from './types'; +import type { HierarchyBand } from './graph-scope'; -/** Mehr Schilder waeren kein Text mehr, sondern eine Flaeche. */ +/** Mehr Schilder auf einmal waeren kein Text mehr, sondern eine Flaeche. */ export const HIERARCHY_EDGE_LABEL_BUDGET = 80; +/** So viele Schilder liegen bereit; gezeigt werden davon die, deren Linie im Bild liegt und die Platz finden. */ +export const HIERARCHY_EDGE_LABEL_RENDER_CAP = 400; export interface HierarchyEdgeLabel { key: string; source: number; target: number; text: string } /** Wo ein Knoten in der Hierarchie steht: Ebene und Lage. */ export type HierarchySpot = { hop: number; x: number; y: number }; +const typeList = (types: ReadonlySet) => [...types].sort((x, y) => Number(y === 'CALLS') - Number(x === 'CALLS') || x.localeCompare(y)).join(' · '); + +/** Der Pfeil von `from` nach `to`, so wie die beiden im Bild stehen (y waechst nach oben). */ +export function edgeArrow(from: { x: number; y: number }, to: { x: number; y: number }): '→' | '←' | '↑' | '↓' { + const dx = to.x - from.x, dy = to.y - from.y; + return Math.abs(dx) >= Math.abs(dy) ? (dx >= 0 ? '→' : '←') : (dy > 0 ? '↑' : '↓'); +} +const withArrow = (types: string, arrow: string) => (arrow === '←' ? `${arrow} ${types}` : `${types} ${arrow}`); + /* * Ein Schild je Knotenpaar, die Arten ohne Doppelte, CALLS zuerst. * - * Mit `layout` (Review zu K5): bei zwei Ebenen um JSONBAgg standen achtzig - * Schilder auf den ersten achtzig Paaren in Kantenreihenfolge, Reihen von - * "DEFINES" ohne einen Namen. Jetzt traegt jede Linie an der Wurzel ihr - * Schild; weiter draussen bekommt ein Faecher, ein Knoten zu vielen ueber - * dieselben Arten, EIN Schild mit seiner Zahl auf seiner mittleren Linie. Die - * Reihenfolge geht von der Wurzel nach aussen, damit eine Obergrenze die - * aeusseren Schilder trifft und nicht die inneren. + * Mit `layout` (zweites Review zu K5): bei zwei Ebenen fasste ein Faecher + * draussen seine Linien zu "CALLS · TESTS ×8" zusammen, ohne Richtung, und wer + * wen aufruft, war nicht zu lesen. Jetzt traegt jede Linie ihr eigenes Schild + * mit ihren Arten und dem Pfeil von der Quelle zum Ziel; laufen zwischen zwei + * Knoten Kanten in beide Richtungen, stehen beide Haelften da + * ("INHERITS → · ← USAGE"). Die Reihenfolge geht von der Wurzel nach aussen, + * damit eine Obergrenze die aeusseren Schilder trifft und nicht die inneren. */ export function hierarchyEdgeLabels(edges: readonly GraphEdge[], layout?: ReadonlyMap): HierarchyEdgeLabel[] { - const pairs = new Map }>(); + const pairs = new Map; backward: Set }>(); for (const edge of edges) { if (edge.source === edge.target) continue; const [a, b] = edge.source < edge.target ? [edge.source, edge.target] : [edge.target, edge.source]; const key = `${a}:${b}`; - const pair = pairs.get(key) ?? { source: edge.source, target: edge.target, types: new Set() }; - pair.types.add(edge.type); pairs.set(key, pair); + const pair = pairs.get(key) ?? { source: edge.source, target: edge.target, forward: new Set(), backward: new Set() }; + (edge.source === pair.source ? pair.forward : pair.backward).add(edge.type); + pairs.set(key, pair); } - const labels = [...pairs].map(([key, pair]) => ({ key, source: pair.source, target: pair.target, - text: [...pair.types].sort((x, y) => Number(y === 'CALLS') - Number(x === 'CALLS') || x.localeCompare(y)).join(' · ') })); - if (!layout) return labels; - const placed: { hop: number; order: number; label: HierarchyEdgeLabel }[] = []; - type Member = { label: HierarchyEdgeLabel; order: number; hop: number; nearId: number; farId: number; near: HierarchySpot; far: HierarchySpot }; - const outer: Member[] = []; - labels.forEach((label, order) => { - const a = layout.get(label.source), b = layout.get(label.target); - if (!a || !b || a.hop === b.hop) { placed.push({ hop: a?.hop ?? Number.MAX_SAFE_INTEGER, order, label }); return; } - const [nearId, farId, near, far] = a.hop < b.hop ? [label.source, label.target, a, b] : [label.target, label.source, b, a]; - if (near.hop === 0) { placed.push({ hop: 0, order, label }); return; } - outer.push({ label, order, hop: near.hop, nearId, farId, near, far }); + const labels = [...pairs].map(([key, pair]) => { + const from = layout?.get(pair.source), to = layout?.get(pair.target); + if (!from || !to) return { key, source: pair.source, target: pair.target, text: typeList(new Set([...pair.forward, ...pair.backward])) }; + const parts = [pair.forward.size ? withArrow(typeList(pair.forward), edgeArrow(from, to)) : '', + pair.backward.size ? withArrow(typeList(pair.backward), edgeArrow(to, from)) : ''].filter(Boolean); + return { key, source: pair.source, target: pair.target, text: parts.join(' · ') }; }); - // Erst die Faecher, die von einem Knoten ausgehen, dann die, die auf einen Knoten zulaufen. - const gather = (members: Member[], keyOf: (member: Member) => string, yOf: (member: Member) => number, last: boolean): Member[] => { - const fans = new Map(); - for (const member of members) fans.set(keyOf(member), [...fans.get(keyOf(member)) ?? [], member]); - const single: Member[] = []; - for (const [key, fan] of fans) { - if (fan.length === 1 && !last) { single.push(fan[0]!); continue; } - const middle = [...fan].sort((x, y) => yOf(y) - yOf(x))[Math.floor((fan.length - 1) / 2)]!.label; - placed.push({ hop: fan[0]!.hop, order: fan[0]!.order, label: fan.length === 1 ? middle - : { ...middle, key: `fan:${key}`, text: `${middle.text} ×${fan.length}` } }); - } - return single; - }; - const single = gather(outer, member => `${member.nearId}|${member.label.text}|${Math.sign(member.far.x - member.near.x)}`, member => member.far.y, false); - gather(single, member => `in:${member.farId}|${member.label.text}|${Math.sign(member.near.x - member.far.x)}`, member => member.near.y, true); - return placed.sort((x, y) => x.hop - y.hop || x.order - y.order).map(entry => entry.label); + if (!layout) return labels; + const hop = (label: HierarchyEdgeLabel) => Math.min(layout.get(label.source)?.hop ?? Number.MAX_SAFE_INTEGER, layout.get(label.target)?.hop ?? Number.MAX_SAFE_INTEGER); + return labels.map((label, order) => ({ label, order, hop: hop(label) })).sort((x, y) => x.hop - y.hop || x.order - y.order).map(entry => entry.label); } const PLACE_EVERY_FRAMES = 3; @@ -88,20 +84,30 @@ const PLACE_EVERY_FRAMES = 3; */ export const EDGE_LABEL_MIN_NAME_PIXELS = 9; -/** Ein freier Platz fuer ein Schild der Hierarchie, sonst keiner: hier liegt kein Schild auf einem Namen, die Farbe der Linie sagt die Art auch. */ +/* + * Ein freier Platz fuer ein Schild der Hierarchie, sonst keiner: hier liegt + * kein Schild auf einem Namen, die Farbe der Linie sagt die Art auch. Das + * Schild steht auf seiner Linie oder direkt daneben (zweites Review zu K5: + * drei Labelbreiten daneben stand es bei zwei Ebenen im Leeren, und welcher + * Linie es gehoert, war nicht mehr zu sehen). + */ +export const HIERARCHY_LABEL_MAX_SIDE = 1; export function hierarchyLabelSpot(from: { x: number; y: number }, to: { x: number; y: number }, size: { width: number; height: number }, blockers: readonly ScreenRect[], placed: readonly ScreenRect[]): { t: number; rect: ScreenRect } | undefined { - const choice = placeAlongSegment(from, to, size, blockers, placed); + const choice = placeAlongSegment(from, to, size, blockers, placed, HIERARCHY_LABEL_MAX_SIDE); return choice.overlap > 0 ? undefined : choice; } export function edgeLabelVisible(namePixels: number, at: { x: number; y: number }, canvas: ScreenRect): boolean { return namePixels >= EDGE_LABEL_MIN_NAME_PIXELS && at.x >= canvas.left && at.x <= canvas.right && at.y >= canvas.top && at.y <= canvas.bottom; } +/** Was ausser den Namen frei bleibt: die Ueberschrift des Bandes (DOM, wie die Schilder selbst). */ +const BLOCKER_SELECTOR = '.atlas-hierarchy-band-label'; + export function HierarchyEdgeLabels({ nodes, edges, layout, nameBoxes }: { nodes: readonly GraphNode[]; edges: readonly GraphEdge[]; - /** Ebene und Lage je Knoten: fasst die Faecher draussen zusammen (siehe `hierarchyEdgeLabels`). */ + /** Ebene und Lage je Knoten: ordnet die Schilder von der Wurzel nach aussen und gibt ihnen den Pfeil (siehe `hierarchyEdgeLabels`). */ layout?: ReadonlyMap; /** Die gezeichneten Namen, in Weltkoordinaten (NodeLabels). */ nameBoxes: RefObject; @@ -110,12 +116,14 @@ export function HierarchyEdgeLabels({ nodes, edges, layout, nameBoxes }: { const labels = useMemo(() => hierarchyEdgeLabels(edges, layout).flatMap((label) => { const from = byId.get(label.source), to = byId.get(label.target); return from && to ? [{ ...label, from, to }] : []; - }).slice(0, HIERARCHY_EDGE_LABEL_BUDGET), [edges, layout, byId]); + }).slice(0, HIERARCHY_EDGE_LABEL_RENDER_CAP), [edges, layout, byId]); const groups = useRef(new Map()); const elements = useRef(new Map()); const sizes = useRef(new Map()); const camera = useThree((state) => state.camera); const gl = useThree((state) => state.gl); + // drei hangs Html into the element the events are connected to, not into the canvas parent. + const connected = useThree((state) => state.events?.connected) as unknown; const tick = useRef(0); const point = useMemo(() => new THREE.Vector3(), []); const ends = useMemo(() => ({ from: new THREE.Vector3(), to: new THREE.Vector3() }), []); @@ -131,23 +139,31 @@ export function HierarchyEdgeLabels({ nodes, edges, layout, nameBoxes }: { const a = toScreen(name.x - name.width / 2, name.y + name.height / 2), b = toScreen(name.x + name.width / 2, name.y - name.height / 2); return { left: Math.min(a.x, b.x), right: Math.max(a.x, b.x), top: Math.min(a.y, b.y), bottom: Math.max(a.y, b.y) }; }); + const host = connected instanceof HTMLElement ? connected : gl.domElement.parentElement; + for (const element of host?.querySelectorAll(BLOCKER_SELECTOR) ?? []) { + const rect = element.getBoundingClientRect(); + if (rect.width > 0) blockers.push({ left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }); + } const placed: ScreenRect[] = []; // So hoch steht ein Name auf dem Schirm: die Schriftgroesse der Hierarchie, durch dieselbe Kamera. const anchor = labels[0]?.from; const namePixels = anchor ? Math.abs(toScreen(anchor.x, anchor.y + HIERARCHY_LABEL_FONT_SIZE, anchor.z).y - toScreen(anchor.x, anchor.y, anchor.z).y) : 0; const canvas = { left: box.left, top: box.top, right: box.right, bottom: box.bottom }; - for (const { key, from, to } of labels) { + for (const { key, text, from, to } of labels) { const group = groups.current.get(key), element = elements.current.get(key); if (!group || !element) continue; const a = toScreen(from.x, from.y, from.z), b = toScreen(to.x, to.y, to.z); // Die Groesse eines Schildes einmal messen: Schreiben und Lesen im Wechsel hiesse ein Layout je Schild und Durchgang. - let size = sizes.current.get(key); + // Nach dem Text und nicht nach dem Paar: die Hierarchie zaehlt ihre Knoten je Bild neu, und dasselbe Paar + // traegt im naechsten Ausschnitt einen anderen, laengeren Text (im Browser gesehen: Schilder auf Namen). + let size = sizes.current.get(text); if (!size) { element.style.display = ''; size = { width: element.offsetWidth, height: element.offsetHeight }; - if (size.width > 0) sizes.current.set(key, size); + if (size.width > 0) sizes.current.set(text, size); } - const choice = size.width > 0 && edgeLabelVisible(namePixels, { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, canvas) + const choice = placed.length < HIERARCHY_EDGE_LABEL_BUDGET && size.width > 0 + && edgeLabelVisible(namePixels, { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, canvas) ? hierarchyLabelSpot(a, b, size, blockers, placed) : undefined; const display = choice ? '' : 'none'; if (element.style.display !== display) element.style.display = display; @@ -172,3 +188,26 @@ export function HierarchyEdgeLabels({ nodes, edges, layout, nameBoxes }: { ); } + +/* + * Die Ueberschrift des Bandes unter den Spalten (zweites Review zu K5): was + * dort steht, ist weder eingehend noch ausgehend, und das steht im Bild, nicht + * erst im Tooltip. Ein gestrichelter Rahmen um das ganze Band, die + * Ueberschrift auf seiner oberen Kante, trennt es von den Spalten darueber. + */ +const BAND_FRAME_COLOR = '#6f8f80'; +export function HierarchyBandLabel({ band }: { band: HierarchyBand }) { + const frame = useMemo(() => [[band.left, band.y, -1], [band.right, band.y, -1], [band.right, band.bottom, -1], [band.left, band.bottom, -1], [band.left, band.y, -1]] as [number, number, number][], + [band.left, band.right, band.y, band.bottom]); + return ( + <> + + + + {galaxyHierarchyText.bandTitle(band.count)}{' '} + {galaxyHierarchyText.bandDetail} + + + + ); +} diff --git a/graph-ui/src/galaxy/galaxy-legend.ts b/graph-ui/src/galaxy/galaxy-legend.ts index fff4b129c..a169a12dd 100644 --- a/graph-ui/src/galaxy/galaxy-legend.ts +++ b/graph-ui/src/galaxy/galaxy-legend.ts @@ -314,7 +314,7 @@ export function hierarchyLegendEntries(data: GraphData | undefined, fileRelation * Punkt selbst (`title`). */ detail: scoped - ? 'Every relationship of this scope, its type written at the line. Edge colours keep their types; this is not a runtime trace.' + ? 'Every relationship of this scope, its type and an arrow from source to target written at the line. Edge colours keep their types; this is not a runtime trace.' : fileRelationships ? 'Recorded relationships touching the selected file or marked code. Edge colours retain their original types; this is not a complete call trace.' : 'one line per call on this walk, cycles included rather than hidden. ' @@ -327,7 +327,8 @@ export function hierarchyLegendEntries(data: GraphData | undefined, fileRelation key: 'positions', title: 'positions', detail: scoped - ? 'Incoming relationships on the left, the root in the middle, outgoing relationships on the right; one column per layer. ' + ? 'Incoming relationships on the left, the root in the middle, outgoing relationships on the right; each column is one layer further in the same direction. ' + + 'Nodes reached through both directions, such as a callee of a caller, stand in the band below. ' + 'Inside a column: by the neighbour that leads there, calls before other types, calls by call-site line.' : fileRelationships ? 'Incoming relationships on the left, selected definitions in the middle, outgoing relationships on the right. Recorded containment orders the definition columns; source paths and lines order each column.' diff --git a/graph-ui/src/galaxy/galaxy-strings.test.ts b/graph-ui/src/galaxy/galaxy-strings.test.ts new file mode 100644 index 000000000..a253be0f9 --- /dev/null +++ b/graph-ui/src/galaxy/galaxy-strings.test.ts @@ -0,0 +1,28 @@ +import { expect, it } from 'vitest'; +import { galaxyHierarchyText } from './galaxy-strings'; + +/* + * Second review of K5: the hint and the note in the picture say what carries + * a name above the budget, for every depth and trace direction, and only give + * advice that helps there. + */ +it('K5: the hierarchy hint and note say who is named and what brings the rest back', () => { + const view = (names: 'all' | 'neighbours' | 'none', sides = { root: true, incoming: 0, outgoing: 0 }) => ({ mixed: 0, names, budget: 150, neighbours: 554, sides }); + expect(galaxyHierarchyText.hint('both', { ...view('all'), mixed: 3 })) + .toBe('hierarchy: incoming relationships on the left, the root in the middle, outgoing on the right; each column is one layer further in the same direction; ' + + '3 nodes reached through both directions, such as a callee of a caller, stand in the band below; the type and direction of each relationship at its line'); + expect(galaxyHierarchyText.hint('inbound', view('neighbours'))).toBe('hierarchy: what reaches the root, one column per layer to the left; ' + + 'above 150 nodes only the root and its direct neighbours carry names, so remove layers or trace fewer edge types to see all names'); + // One layer with a hub: removing a layer is no advice; one side still fits the budget. + expect(galaxyHierarchyText.hint('both', view('none', { root: true, incoming: 0, outgoing: 11 }))) + .toContain('and the root has 554 direct neighbours, so only the root and its 11 outgoing neighbours carry names; trace one direction or fewer edge types to see the rest'); + expect(galaxyHierarchyText.hint('outbound', view('none'))).toContain('so only the root carries a name; trace fewer edge types to see the rest'); + expect(galaxyHierarchyText.namesNote('both', 555, 'none', 554, 150, { root: true, incoming: 0, outgoing: 11 })) + .toBe('555 nodes, 554 of them direct neighbours of the root: names for the root and its 11 outgoing neighbours only (up to 150 names). Trace one direction or fewer edge types to see the rest.'); + // Hundreds of roots (a folder): nobody is named, and the note says so. + expect(galaxyHierarchyText.namesNote('inbound', 1687, 'none', 1319, 150, { root: false, incoming: 0, outgoing: 0 })) + .toBe(`${(1687).toLocaleString()} nodes: no names above 150 nodes. Trace fewer edge types to see them.`); + expect(galaxyHierarchyText.hint('both', view('none', { root: false, incoming: 0, outgoing: 0 }))) + .toContain('names and edge types show for up to 150 nodes, so trace one direction or fewer edge types to see them'); + expect(galaxyHierarchyText.bandTitle(1)).toBe('Mixed directions · 1 node'); +}); diff --git a/graph-ui/src/galaxy/galaxy-strings.ts b/graph-ui/src/galaxy/galaxy-strings.ts index bde9cbcb8..c33840525 100644 --- a/graph-ui/src/galaxy/galaxy-strings.ts +++ b/graph-ui/src/galaxy/galaxy-strings.ts @@ -64,20 +64,57 @@ export const galaxyHistoryText = { pathTo: (name: string) => `path to ${name}`, }; -/** The hierarchy of a Galaxy scope (hand test K5): what the columns mean, per trace direction. */ -export const galaxyHierarchyText = { - /** With `namesUpTo` the scope is past the name budget: no names and no edge labels, and the hint says how to get them back. */ - hint: (direction: 'both' | 'inbound' | 'outbound', namesUpTo?: number) => (direction === 'inbound' - ? 'hierarchy: what reaches the root, one column per layer to the left' - : direction === 'outbound' - ? 'hierarchy: what the root reaches, one column per layer to the right' - : 'hierarchy: incoming relationships on the left, the root in the middle, outgoing on the right; one column per layer') - + (namesUpTo === undefined ? ', edge types at the lines' - : `; names and edge types show for up to ${namesUpTo.toLocaleString()} nodes, so remove a layer or trace fewer edge types to see them`), -}; - const count = (value: number, one: string, many: string) => `${value.toLocaleString()} ${value === 1 ? one : many}`; +/** + * Who carries a name in the hierarchy of a scope: everyone, the root and its + * direct neighbours, or (`none`) only the root and a side of its direct + * neighbours that fits the budget. + */ +export type HierarchyNames = 'all' | 'neighbours' | 'none'; +/** With `names: 'none'`: whether the root still carries its name (not with hundreds of roots) and how many direct neighbours on each side do. */ +export interface HierarchyNamedSides { root: boolean; incoming: number; outgoing: number } + +type TraceDirectionWord = 'both' | 'inbound' | 'outbound'; +/* With the direct neighbours unnamed the root alone has too many: fewer layers would not help, a narrower trace would. */ +const narrower = (direction: TraceDirectionWord) => (direction === 'both' ? 'trace one direction or fewer edge types' : 'trace fewer edge types'); +const namedFew = (sides: HierarchyNamedSides) => ['the root', + sides.incoming > 0 ? `its ${count(sides.incoming, 'incoming neighbour', 'incoming neighbours')}` : '', + sides.outgoing > 0 ? `its ${count(sides.outgoing, 'outgoing neighbour', 'outgoing neighbours')}` : ''].filter(Boolean).join(' and '); + +/** + * The hierarchy of a Galaxy scope (hand test K5): what the columns mean, per + * trace direction and depth. Second review: from two layers on, a column only + * holds chains that run one way to the root, the rest stands in a band below, + * and every line says its type and direction. Names above the budget: see + * `HierarchyNames`; the advice names what brings them back. + */ +export const galaxyHierarchyText = { + hint: (direction: TraceDirectionWord, view: { mixed: number; names: HierarchyNames; budget: number; neighbours: number; sides: HierarchyNamedSides }) => [ + direction === 'inbound' + ? 'hierarchy: what reaches the root, one column per layer to the left' + : direction === 'outbound' + ? 'hierarchy: what the root reaches, one column per layer to the right' + : 'hierarchy: incoming relationships on the left, the root in the middle, outgoing on the right; each column is one layer further in the same direction', + view.mixed > 0 ? `${count(view.mixed, 'node', 'nodes')} reached through both directions, such as a callee of a caller, stand in the band below` : '', + view.names === 'all' ? 'the type and direction of each relationship at its line' + : view.names === 'neighbours' + ? `above ${view.budget.toLocaleString()} nodes only the root and its direct neighbours carry names, so remove layers or trace fewer edge types to see all names` + : !view.sides.root ? `names and edge types show for up to ${view.budget.toLocaleString()} nodes, so ${narrower(direction)} to see them` + : `names and edge types show for up to ${view.budget.toLocaleString()} nodes, and the root has ${count(view.neighbours, 'direct neighbour', 'direct neighbours')}, ` + + `so only ${namedFew(view.sides)} ${view.sides.incoming + view.sides.outgoing > 0 ? 'carry names' : 'carries a name'}; ${narrower(direction)} to see the rest`, + ].filter(Boolean).join('; '), + /** The heading of the band in the picture. */ + bandTitle: (mixed: number) => `Mixed directions · ${count(mixed, 'node', 'nodes')}`, + bandDetail: 'reached through both incoming and outgoing relationships, such as a callee of a caller', + /** The note in the picture when names are missing, and how to get them. */ + namesNote: (direction: TraceDirectionWord, nodes: number, names: Exclude, neighbours: number, budget: number, sides: HierarchyNamedSides) => (names === 'neighbours' + ? `${count(nodes, 'node', 'nodes')}: names for the root and its ${count(neighbours, 'direct neighbour', 'direct neighbours')} only (up to ${budget.toLocaleString()} names). Remove layers or trace fewer edge types to name every node.` + : !sides.root ? `${count(nodes, 'node', 'nodes')}: no names above ${budget.toLocaleString()} nodes. ${narrower(direction).replace(/^t/, 'T')} to see them.` + : `${count(nodes, 'node', 'nodes')}, ${neighbours.toLocaleString()} of them direct neighbours of the root: names for ${namedFew(sides)} only (up to ${budget.toLocaleString()} names). ` + + `${narrower(direction).replace(/^t/, 'T')} to see the rest.`), +}; + /** Loading, cancelling and the render limit of a scope layer (hand test K8). */ export const galaxyLayerText = { checking: 'Checking index…', diff --git a/graph-ui/src/galaxy/graph-exploration.css b/graph-ui/src/galaxy/graph-exploration.css index c74ede1be..af7b3ff61 100644 --- a/graph-ui/src/galaxy/graph-exploration.css +++ b/graph-ui/src/galaxy/graph-exploration.css @@ -125,6 +125,11 @@ .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; } +/* Zweites Review zu K5: die Ueberschrift des Bandes unter den Spalten und die Notiz, wenn Namen fehlen. */ +.atlas-hierarchy-band-label { display:flex; flex-direction:column; align-items:center; gap:1px; padding:2px 8px; border:1px dashed #6f8f80; border-radius:5px; background:rgba(13, 15, 18, .86); color:#d7e6dd; white-space:nowrap; font:600 11px/1.35 var(--atlas-ui-font, system-ui, sans-serif); } +.atlas-hierarchy-band-label small { color:#a9bdb2; font-weight:400; font-size:10px; } +.atlas-hierarchy-key { position:absolute; z-index:20; left:12px; bottom:12px; max-width:min(34rem, calc(100% - 24px)); margin:0; padding:.35rem .6rem; border:1px solid #355246; border-radius:6px; background:rgba(13, 25, 19, .9); color:#cbdbd0; font:12px/1.4 var(--atlas-ui-font, system-ui, sans-serif); pointer-events:none; } + /* Zurueck, Vor und die letzten Wurzeln (Handtest K2): klein, damit die Leiste einzeilig bleibt (K3). */ .atlas-graph-history { display:flex; align-items:center; gap:.25rem; flex:none; } .atlas-graph-history > button { min-width:1.9rem; padding:.25rem .45rem; } diff --git a/graph-ui/src/galaxy/graph-scope.test.ts b/graph-ui/src/galaxy/graph-scope.test.ts index 4d2ccd4cf..6932308c4 100644 --- a/graph-ui/src/galaxy/graph-scope.test.ts +++ b/graph-ui/src/galaxy/graph-scope.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { arrangeScopedGraph, frontierCallCount, graphEdgeTypesKey, hierarchyLabelWidth, limitGraphRender, loadGraphScope, nextLayerEstimate, readGraphPages, scenePictureFor, scopedHierarchy, type GraphQueryClient } from './graph-scope'; +import { arrangeScopedGraph, frontierCallCount, graphEdgeTypesKey, hierarchyLabelWidth, limitGraphRender, loadGraphScope, nextLayerEstimate, readGraphPages, scenePictureFor, SCOPED_HIERARCHY_ROW_GAP, scopedHierarchy, type GraphQueryClient } from './graph-scope'; import type { QueryGraphResult } from '../provider/rpc-schemas'; import type { GraphData, GraphNode } from './types'; @@ -258,14 +258,83 @@ describe('hand test K5: the scoped hierarchy reads incoming left, root in the mi expect(top).toEqual(['n3', 'n4', 'n2', 'n5']); }); - it('keeps a second layer on the side of its parent, next to it', () => { - const nodes = [node(1), node(2), node(3), node(4), node(5)]; - // 1 calls 2, 2 calls 3, 4 calls 2 (reached from 2), 5 calls 1. + it('keeps a pure outgoing chain to the right and a pure incoming chain to the left, a layer per column', () => { + const nodes = [node(1), node(2), node(3), node(5), node(6)]; + // 1 calls 2, 2 calls 3 (outgoing twice); 5 calls 1, 6 calls 5 (incoming twice). const result = scoped(nodes, [{ source: 1, target: 2, type: 'CALLS' }, { source: 2, target: 3, type: 'CALLS' }, - { source: 4, target: 2, type: 'CALLS' }, { source: 5, target: 1, type: 'CALLS' }], [[1, 0], [2, 1], [5, 1], [3, 2], [4, 2]]); + { source: 5, target: 1, type: 'CALLS' }, { source: 6, target: 5, type: 'CALLS' }], [[1, 0], [2, 1], [5, 1], [3, 2], [6, 2]]); expect(at(result, 'n3').x).toBeGreaterThan(at(result, 'n2').x); - expect(at(result, 'n4').x).toBe(at(result, 'n3').x); + expect(at(result, 'n6').x).toBeLessThan(at(result, 'n5').x); expect(at(result, 'n5').x).toBeLessThan(0); + expect(result.placements.some(placement => placement.mixed)).toBe(false); + expect(result.band).toBeUndefined(); + }); + + /* + * Review of K5: from two layers on, every node stood on its parent's side, + * so the far-left "incoming" column of JSONBAgg held the callees of its + * tests (len, str, print) and the classes general.py defines next to it. + * A node reached through both directions belongs to neither side. + */ + it('puts a callee of a caller and a caller of a callee in a labelled band below, never in an incoming or outgoing column', () => { + const nodes = [node(1), node(10), node(20), node(30), node(40), node(50), node(60), node(70)]; + const result = scoped(nodes, [ + { source: 10, target: 1, type: 'CALLS' }, // a test calls the root: incoming + { source: 10, target: 20, type: 'CALLS' }, // the test calls len: a callee of a caller + { source: 30, target: 10, type: 'CALLS' }, // something calls the test: incoming twice + { source: 1, target: 40, type: 'INHERITS' }, // the root inherits a base: outgoing + { source: 40, target: 50, type: 'DEFINES_METHOD' }, // the base defines a method: outgoing twice + { source: 60, target: 40, type: 'INHERITS' }, // a sibling inherits the base: a caller of a callee + { source: 20, target: 70, type: 'CALLS' }, // reached from a mixed node: mixed as well + ], [[1, 0], [10, 1], [40, 1], [20, 2], [30, 2], [50, 2], [60, 2], [70, 3]]); + const mixed = result.placements.filter(placement => placement.mixed).map(placement => placement.name).sort(); + expect(mixed).toEqual(['n20', 'n60', 'n70']); + // The pure chains keep their columns. + expect(at(result, 'n30').x).toBeLessThan(at(result, 'n10').x); + expect(at(result, 'n50').x).toBeGreaterThan(at(result, 'n40').x); + // The band lies below every column, with a gap for its heading, and the heading counts it. + const columns = result.placements.filter(placement => !placement.mixed); + const lowest = Math.min(...columns.map(placement => placement.y)); + for (const name of mixed) expect(at(result, name).y).toBeLessThan(lowest - 2 * SCOPED_HIERARCHY_ROW_GAP); + expect(result.band).toMatchObject({ count: 3 }); + expect(result.band!.y).toBeLessThan(lowest); + expect(result.band!.y).toBeGreaterThan(Math.max(...mixed.map(name => at(result, name).y))); + // The band is centred under the root, not under one of the sides. + const xs = mixed.map(name => at(result, name).x); + expect(Math.abs((Math.min(...xs) + Math.max(...xs)) / 2)).toBeLessThan(1); + // Its frame, the heading on the top edge, holds every band node and none of the columns. + for (const name of mixed) { + const placement = at(result, name); + expect(placement.x).toBeGreaterThan(result.band!.left); expect(placement.x).toBeLessThan(result.band!.right); + expect(placement.y).toBeGreaterThan(result.band!.bottom); expect(placement.y).toBeLessThan(result.band!.y); + } + for (const placement of columns) expect(placement.y).toBeGreaterThan(result.band!.y); + }); + + it('names every node up to the budget; above it the root and its direct neighbours keep names and single columns', () => { + const fan = (count: number, first: number) => Array.from({ length: count }, (_, at) => node(first + at)); + const small = scoped([node(1), ...fan(20, 100)], fan(20, 100).map(entry => ({ source: entry.id, target: 1, type: 'CALLS' })), + [[1, 0], ...fan(20, 100).map(entry => [entry.id, 1] as [number, number])]); + expect(small.names).toBe('all'); + // 40 direct callers, each with four callers of its own: 201 nodes. + const callers = fan(40, 100), outer = fan(160, 1000); + const edges = [...callers.map(entry => ({ source: entry.id, target: 1, type: 'CALLS' })), + ...outer.map((entry, at) => ({ source: entry.id, target: callers[at % 40]!.id, type: 'CALLS' }))]; + const large = scoped([node(1), ...callers, ...outer], edges, + [[1, 0], ...callers.map(entry => [entry.id, 1] as [number, number]), ...outer.map(entry => [entry.id, 2] as [number, number])]); + expect(large.names).toBe('neighbours'); + // The direct callers stand in one column, spaced for their names. + const first = large.placements.filter(placement => placement.hop === 1); + expect(new Set(first.map(placement => placement.x)).size).toBe(1); + expect(new Set(large.namedIds)).toEqual(new Set(large.placements.filter(placement => placement.hop <= 1).map(placement => placement.id))); + // A hub with more direct neighbours than the budget names only the root, and a side of them that fits. + const hub = fan(200, 100), callees = fan(5, 2000); + const none = scoped([node(1), ...hub, ...callees], [...hub.map(entry => ({ source: entry.id, target: 1, type: 'CALLS' })), + ...callees.map(entry => ({ source: 1, target: entry.id, type: 'CALLS' }))], + [[1, 0], ...hub.map(entry => [entry.id, 1] as [number, number]), ...callees.map(entry => [entry.id, 1] as [number, number])]); + expect(none.names).toBe('none'); + const namedNames = none.placements.filter(placement => none.namedIds?.includes(placement.id)).map(placement => placement.name).sort(); + expect(namedNames).toEqual(['n1', ...callees.map(entry => entry.name)].sort()); }); it('spaces columns by the names they carry, so long names keep their full width', () => { diff --git a/graph-ui/src/galaxy/graph-scope.ts b/graph-ui/src/galaxy/graph-scope.ts index 9e18ee121..fbeb11c94 100644 --- a/graph-ui/src/galaxy/graph-scope.ts +++ b/graph-ui/src/galaxy/graph-scope.ts @@ -314,11 +314,17 @@ export const SCOPED_HIERARCHY_ROW_GAP = 32; /** Texture width of a hierarchy name in a scope: about forty characters before an ellipsis. */ export const SCOPED_HIERARCHY_LABEL_MAX_TEXT_WIDTH = 1600; /** - * Up to this many nodes every name is drawn, and columns stay single columns; above it they wrap into grids, - * without names or edge labels. Sixty left JSONBAgg at two layers (90 nodes) without a single name (review of K5). + * Up to this many nodes every name is drawn, and columns stay single columns. Above it the root and its direct + * neighbours keep theirs (second review of K5); the outer layers wrap into grids without names or edge labels. + * Sixty left JSONBAgg at two layers (90 nodes) without a single name (review of K5). */ export const SCOPED_HIERARCHY_LABEL_BUDGET = 150; +/** How far the band of nodes reached through mixed directions stands below the columns: room for its heading. */ +export const SCOPED_HIERARCHY_BAND_GAP = 4 * SCOPED_HIERARCHY_ROW_GAP; const SCOPED_HIERARCHY_WRAP_AT = 12; +const BAND_MIN_ROWS = 6, BAND_FRAME_PAD = 20; +/** The band of nodes reached through mixed directions: how many, where its heading sits (on the top edge) and its frame. */ +export interface HierarchyBand { count: number; x: number; y: number; left: number; right: number; bottom: number } const LABEL_UNITS_PER_CHAR = 7.1, LABEL_UNITS_PADDING = 12, LABEL_GUTTER = 40; const LABEL_MAX_CHARS = Math.floor((SCOPED_HIERARCHY_LABEL_MAX_TEXT_WIDTH - 64) / 38); @@ -336,16 +342,27 @@ export function hierarchyLabelWidth(name: string): number { * * - Eingehendes links, die Wurzel in der Mitte, Ausgehendes rechts. Ein * Knoten der ersten Ebene steht dort, wohin seine Kante zur Wurzel zeigt; - * jeder tiefere Knoten auf der Seite seines Elternknotens, also des - * Nachbarn eine Ebene naeher an der Wurzel, ueber den er gefunden wurde. + * ein tieferer nur dann auf einer Seite, wenn die ganze Kette von der + * Wurzel bis zu ihm in dieselbe Richtung laeuft: links ruft (testet, + * definiert, ...) jeder seinen Nachbarn naeher an der Wurzel, rechts + * umgekehrt. + * - Was ueber gemischte Richtungen erreicht ist (ein Aufgerufener eines + * Aufrufers, ein weiterer Aufrufer eines Aufgerufenen), steht auf keiner + * Seite, sondern in einem Band unter den Spalten (zweites Review zu K5: + * bei zwei Ebenen um JSONBAgg standen len, str und print in der Spalte + * "incoming" ganz links). Das Band steht mittig unter der Wurzel; seine + * Ueberschrift zeichnet das Panel an `band`. * - In einer Spalte stehen die Knoten nach ihrem Elternknoten, dann CALLS vor * den anderen Arten, CALLS nach Aufrufzeile (`edge.line`), dann nach Name. * - Die Spalten stehen so weit auseinander, wie ihre Namen breit sind: kein * Name wird gekuerzt, solange Platz ist. + * - Ueber der Namensgrenze behalten die Wurzel und ihre direkten Nachbarn + * ihre Namen und ihre einfachen Spalten (`names: 'neighbours'`); erst die + * aeusseren Ebenen brechen in kompakte Raster ohne Namen um. Hat die + * Wurzel selbst mehr Nachbarn als die Grenze, traegt niemand einen Namen. * * Die Ebene kommt aus den gefundenen `levels`; nur eine Ringanordnung legt sie * in z ab, eine Vorschau aus dem ganzen Layout behaelt globale Koordinaten. - * Sehr grosse Ebenen (ohne Namen) brechen weiter in kompakte Raster um. */ export function scopedHierarchy(scope: ScopedGraph, name: string): import('./hierarchy-layout').HierarchyProjection { const levelOf = (node: GraphNode) => scope.roots.has(node.id) ? 0 : scope.levels?.get(node.id) ?? Math.max(0, Math.round(-node.z / 18)); @@ -356,51 +373,75 @@ export function scopedHierarchy(scope: ScopedGraph, name: string): import('./hie } const typeRank = (edge: GraphEdge | undefined) => edge?.type === 'CALLS' ? 0 : 1; const side = new Map(), parent = new Map(); + /* Ob die Kette von der Wurzel bis hierher in einer Richtung laeuft. */ + const straight = new Map(); const ordered = [...scope.data.nodes].sort((a, b) => level.get(a.id)! - level.get(b.id)! || a.id - b.id); for (const node of ordered) { const hop = level.get(node.id)!; - if (hop === 0) { side.set(node.id, 0); continue; } - // The neighbour one layer closer to the root decides the side; a consistently directed edge wins, then CALLS. + if (hop === 0) { side.set(node.id, 0); straight.set(node.id, true); continue; } + // The neighbour one layer closer to the root decides: a straight chain through it wins, then CALLS, then the call line. const candidates = (incident.get(node.id) ?? []).flatMap(edge => { const other = edge.source === node.id ? edge.target : edge.source; const otherSide = side.get(other); if (other === node.id || otherSide === undefined || level.get(other) !== hop - 1) return []; const towards = otherSide !== 0 ? otherSide : edge.source === other ? 1 : -1; - const consistent = towards === 1 ? edge.source === other : edge.target === other; + const consistent = (towards === 1 ? edge.source === other : edge.target === other) && straight.get(other) === true; return [{ other, edge, towards: towards as -1 | 1, consistent }]; }).sort((a, b) => Number(b.consistent) - Number(a.consistent) || typeRank(a.edge) - typeRank(b.edge) || (a.edge.line ?? Number.MAX_SAFE_INTEGER) - (b.edge.line ?? Number.MAX_SAFE_INTEGER) || a.other - b.other); const chosen = candidates[0]; side.set(node.id, chosen?.towards ?? 1); + // Without any edge to the layer before (a preview) there is no evidence of a mix: the node keeps the old place on the right. + straight.set(node.id, chosen ? chosen.consistent : true); if (chosen) parent.set(node.id, { id: chosen.other, edge: chosen.edge }); } - const columns = new Map(); + const columns = new Map(), band: GraphNode[] = []; for (const node of scope.data.nodes) { + if (!straight.get(node.id)) { band.push(node); continue; } const column = side.get(node.id)! * level.get(node.id)!; const entries = columns.get(column) ?? []; entries.push(node); columns.set(column, entries); } - const labelled = scope.data.nodes.length <= SCOPED_HIERARCHY_LABEL_BUDGET; + const neighbours = scope.data.nodes.filter(node => level.get(node.id)! <= 1).length; + const names = scope.data.nodes.length <= SCOPED_HIERARCHY_LABEL_BUDGET ? 'all' as const + : neighbours <= SCOPED_HIERARCHY_LABEL_BUDGET ? 'neighbours' as const : 'none' as const; + /* + * Wer einen Namen traegt. Ohne Namen fuer die direkten Nachbarn behalten + * die Wurzel ihren und eine Seite der ersten Ebene ihre, wenn sie in die + * Grenze passt (call_command: 543 Aufrufer links, elf Aufgerufene rechts). + */ + const namedKeys = new Set(names === 'neighbours' ? [-1, 0, 1] : []); + // Die Wurzeln selbst nur, wenn sie hineinpassen: ein Ordner kann hunderte haben. + if (names === 'none' && (columns.get(0)?.length ?? 0) <= SCOPED_HIERARCHY_LABEL_BUDGET) { + namedKeys.add(0); + let budget = SCOPED_HIERARCHY_LABEL_BUDGET - (columns.get(0)?.length ?? 0); + for (const key of [-1, 1].sort((a, b) => (columns.get(a)?.length ?? 0) - (columns.get(b)?.length ?? 0))) { + const size = columns.get(key)?.length ?? 0; + if (size > 0 && size <= budget) { namedKeys.add(key); budget -= size; } + } + } + const named = (key: number) => names === 'all' || namedKeys.has(key); const row = new Map(); const byText = (a: GraphNode, b: GraphNode) => (a.file_path ?? '').localeCompare(b.file_path ?? '') || a.name.localeCompare(b.name) || a.id - b.id; + const byParent = (a: GraphNode, b: GraphNode) => { + const pa = parent.get(a.id), pb = parent.get(b.id); + return (row.get(pa?.id ?? -1) ?? -1) - (row.get(pb?.id ?? -1) ?? -1) || typeRank(pa?.edge) - typeRank(pb?.edge) + || (pa?.edge.type ?? '').localeCompare(pb?.edge.type ?? '') + // Calls in source order; one line with two calls keeps the order of the call list (by identity). + || (pa?.edge.type === 'CALLS' ? (pa.edge.line ?? Number.MAX_SAFE_INTEGER) - (pb?.edge.line ?? Number.MAX_SAFE_INTEGER) || a.id - b.id : 0) + || byText(a, b); + }; const keys = [...columns.keys()].sort((a, b) => Math.abs(a) - Math.abs(b) || a - b); for (const key of keys) { const entries = columns.get(key)!; - entries.sort((a, b) => { - const pa = parent.get(a.id), pb = parent.get(b.id); - return (row.get(pa?.id ?? -1) ?? -1) - (row.get(pb?.id ?? -1) ?? -1) || typeRank(pa?.edge) - typeRank(pb?.edge) - || (pa?.edge.type ?? '').localeCompare(pb?.edge.type ?? '') - // Calls in source order; one line with two calls keeps the order of the call list (by identity). - || (pa?.edge.type === 'CALLS' ? (pa.edge.line ?? Number.MAX_SAFE_INTEGER) - (pb?.edge.line ?? Number.MAX_SAFE_INTEGER) || a.id - b.id : 0) - || byText(a, b); - }); + entries.sort(byParent); entries.forEach((node, index) => row.set(node.id, index)); } // Column blocks: width from wrapping and from the widest name in them. const block = new Map(keys.map(key => { const entries = columns.get(key)!; - const cols = !labelled && entries.length > SCOPED_HIERARCHY_WRAP_AT ? Math.ceil(Math.sqrt(entries.length)) : 1; - const label = labelled ? Math.max(...entries.map(node => hierarchyLabelWidth(node.name))) : 0; - const gapX = labelled ? label + LABEL_GUTTER : SCOPED_HIERARCHY_ROW_GAP * 1.25; + const cols = !named(key) && entries.length > SCOPED_HIERARCHY_WRAP_AT ? Math.ceil(Math.sqrt(entries.length)) : 1; + const label = named(key) ? Math.max(...entries.map(node => hierarchyLabelWidth(node.name))) : 0; + const gapX = named(key) ? label + LABEL_GUTTER : SCOPED_HIERARCHY_ROW_GAP * 1.25; return [key, { entries, cols, rows: Math.ceil(entries.length / cols), gapX, width: (cols - 1) * gapX, label }] as const; })); const left = new Map(); @@ -421,8 +462,15 @@ export function scopedHierarchy(scope: ScopedGraph, name: string): import('./hie previous = key; } } - const nodes: GraphNode[] = [], remap = new Map(), sourceIds: number[] = []; + const nodes: GraphNode[] = [], remap = new Map(), sourceIds: number[] = [], namedIds: number[] = []; const placements: import('./hierarchy-layout').HierarchyPlacement[] = []; + const place = (node: GraphNode, x: number, y: number, mixed: boolean, isNamed: boolean) => { + const id = nodes.length; + remap.set(node.id, id); sourceIds.push(node.id); nodes.push({ ...node, id, x, y, z: 0 }); + if (isNamed) namedIds.push(id); + placements.push({ id, key: node.qualified_name ?? `node:${node.id}`, name: node.name, hop: level.get(node.id)!, + ...(mixed ? { mixed: true } : { side: side.get(node.id)! }), x, y }); + }; for (const key of keys) { const { entries, cols, rows, gapX } = block.get(key)!; // Outgoing blocks grow away from the root to the right, incoming ones to the left. @@ -430,14 +478,38 @@ export function scopedHierarchy(scope: ScopedGraph, name: string): import('./hie entries.forEach((node, index) => { const column = index % cols; const x = key < 0 ? start + width - column * gapX : start + column * gapX; - const y = ((rows - 1) / 2 - Math.floor(index / cols)) * SCOPED_HIERARCHY_ROW_GAP; - const id = nodes.length; - remap.set(node.id, id); sourceIds.push(node.id); nodes.push({ ...node, id, x, y, z: 0 }); - placements.push({ id, key: node.qualified_name ?? `node:${node.id}`, name: node.name, hop: level.get(node.id)!, side: side.get(node.id)!, x, y }); + place(node, x, ((rows - 1) / 2 - Math.floor(index / cols)) * SCOPED_HIERARCHY_ROW_GAP, false, named(key)); }); } + /* + * Das Band: nach Ebene, dann nach dem Elternknoten (links vor rechts, in + * dessen Reihenfolge), Spalte fuer Spalte von oben nach unten, mittig + * unter der Wurzel und unter der tiefsten Spalte. Seine Ueberschrift steht + * auf der oberen Kante eines Rahmens um das ganze Band (`left`, `right`, + * `bottom`; oben ist `y`): ohne Rahmen lasen sich die Spalten des Bandes + * wie Fortsetzungen der Spalten darueber (im Browser gesehen). + */ + let bandHead: HierarchyBand | undefined; + if (band.length) { + const sideOf = (node: GraphNode) => side.get(parent.get(node.id)?.id ?? -1) ?? 0; + band.sort((a, b) => level.get(a.id)! - level.get(b.id)! || sideOf(a) - sideOf(b) || byParent(a, b)); + band.forEach((node, index) => row.set(node.id, index)); + const bandNamed = names === 'all'; + const rows = bandNamed ? Math.min(band.length, Math.max(BAND_MIN_ROWS, Math.ceil(Math.sqrt(band.length * 2)))) : Math.ceil(Math.sqrt(band.length)); + const groups = Array.from({ length: Math.ceil(band.length / rows) }, (_, at) => band.slice(at * rows, at * rows + rows)); + const widths = groups.map(group => (bandNamed ? Math.max(...group.map(node => hierarchyLabelWidth(node.name))) : 0)); + const xs: number[] = []; + groups.forEach((_, at) => xs.push(at === 0 ? 0 : xs[at - 1]! + (bandNamed ? (widths[at - 1]! + widths[at]!) / 2 + LABEL_GUTTER : SCOPED_HIERARCHY_ROW_GAP * 1.25))); + const shift = -xs.at(-1)! / 2; + const bottom = Math.min(0, ...placements.map(placement => placement.y)); + const top = bottom - SCOPED_HIERARCHY_BAND_GAP; + groups.forEach((group, at) => group.forEach((node, index) => place(node, xs[at]! + shift, top - index * SCOPED_HIERARCHY_ROW_GAP, true, bandNamed))); + bandHead = { count: band.length, x: 0, y: bottom - SCOPED_HIERARCHY_BAND_GAP * 0.4, + left: xs[0]! + shift - widths[0]! / 2 - BAND_FRAME_PAD, right: xs.at(-1)! + shift + widths.at(-1)! / 2 + BAND_FRAME_PAD, + bottom: top - (rows - 1) * SCOPED_HIERARCHY_ROW_GAP - BAND_FRAME_PAD }; + } return { data: { nodes, edges: scope.data.edges.map(edge => ({ ...edge, source: remap.get(edge.source)!, target: remap.get(edge.target)! })), total_nodes: nodes.length }, rootId: scope.roots.size === 1 ? remap.get([...scope.roots][0]!) ?? -1 : -1, rootKey: name, rootName: name, symbols: nodes.length, depth: new Set(level.values()).size, truncated: false, cap: nodes.length, walkDepth: scope.depth, - missing: 0, placements, sourceIds }; + missing: 0, placements, sourceIds, names, ...(names === 'all' ? {} : { namedIds }), ...(bandHead ? { band: bandHead } : {}) }; } diff --git a/graph-ui/src/galaxy/hierarchy-edge-labels.test.ts b/graph-ui/src/galaxy/hierarchy-edge-labels.test.ts index bdc917766..08457195a 100644 --- a/graph-ui/src/galaxy/hierarchy-edge-labels.test.ts +++ b/graph-ui/src/galaxy/hierarchy-edge-labels.test.ts @@ -1,5 +1,6 @@ import { expect, it } from 'vitest'; import { EDGE_LABEL_MIN_NAME_PIXELS, edgeLabelVisible, hierarchyEdgeLabels, hierarchyLabelSpot } from './HierarchyEdgeLabels'; +import { placeAlongSegment } from './path-frame'; it('hand test K5: writes one label per node pair, its types without repeats and CALLS first', () => { const labels = hierarchyEdgeLabels([ @@ -11,24 +12,26 @@ it('hand test K5: writes one label per node pair, its types without repeats and }); /* - * Review of K5: at two layers (JSONBAgg, 90 nodes) eighty labels stood on the - * first eighty pairs in edge order, rows of "DEFINES" without a name in sight. - * The lines at the root keep a label each; a fan further out, one node to many - * of the same types, carries one label with its count on its middle line. + * Review of K5, second round: at two layers the labels further out merged a + * fan into "CALLS · TESTS ×8" without a direction, so who calls whom could + * not be read. Each line now carries its own label, with an arrow from the + * source to the target as the picture shows them, nearest the root first. */ -it('labels every line at the root, and a fan further out once with its count, nearest the root first', () => { +it('labels every line on its own with its types and an arrow from source to target, nearest the root first', () => { const at = (hop: number, x: number, y: number) => ({ hop, x, y }); - const layout = new Map([[1, at(0, 0, 0)], [10, at(1, -200, 32)], [11, at(1, -200, 0)], [30, at(1, -200, -32)], - [50, at(2, -400, 32)], [51, at(2, -400, 0)], [52, at(2, -400, -32)], [60, at(2, -400, 64)]]); + const layout = new Map([[1, at(0, 0, 0)], [10, at(1, -200, 32)], [11, at(1, -200, 0)], [30, at(1, -200, -32)], [40, at(1, 200, 0)], + [50, at(2, -400, 32)], [51, at(2, -400, 0)], [52, at(2, -400, -32)], [60, at(2, -190, -400)]]); const labels = hierarchyEdgeLabels([ { source: 30, target: 52, type: 'DEFINES' }, { source: 30, target: 50, type: 'DEFINES' }, { source: 10, target: 60, type: 'CALLS' }, - { source: 10, target: 1, type: 'CALLS' }, { source: 11, target: 1, type: 'CALLS' }, { source: 30, target: 1, type: 'DEFINES' }, - { source: 30, target: 51, type: 'DEFINES' }, { source: 50, target: 51, type: 'CALLS' }, + { source: 10, target: 1, type: 'CALLS' }, { source: 10, target: 1, type: 'TESTS' }, { source: 11, target: 1, type: 'CALLS' }, + { source: 30, target: 1, type: 'DEFINES' }, { source: 1, target: 40, type: 'INHERITS' }, { source: 40, target: 1, type: 'USAGE' }, + { source: 30, target: 51, type: 'DEFINES' }, { source: 51, target: 50, type: 'CALLS' }, ], layout); expect(labels.map(label => [label.text, label.source, label.target])).toEqual([ - ['CALLS', 10, 1], ['CALLS', 11, 1], ['DEFINES', 30, 1], - ['DEFINES ×3', 30, 51], ['CALLS', 10, 60], ['CALLS', 50, 51], + ['CALLS · TESTS →', 10, 1], ['CALLS →', 11, 1], ['DEFINES →', 30, 1], ['INHERITS → · ← USAGE', 1, 40], + ['← DEFINES', 30, 52], ['← DEFINES', 30, 50], ['CALLS ↓', 10, 60], ['← DEFINES', 30, 51], ['CALLS ↑', 51, 50], ]); + expect(labels.some(label => /×/.test(label.text))).toBe(false); }); /* @@ -46,16 +49,6 @@ it('shows an edge label only while the names read and its line is in view', () = expect(edgeLabelVisible(12, { x: 800, y: 960 }, canvas)).toBe(false); }); -it('gathers lines that converge further out on one node too, when they leave no fan of their own', () => { - const at = (hop: number, x: number, y: number) => ({ hop, x, y }); - const layout = new Map([[1, at(0, 0, 0)], [10, at(1, -200, 32)], [11, at(1, -200, 0)], [12, at(1, -200, -32)], [70, at(2, -400, 0)], [71, at(2, -400, 32)]]); - const labels = hierarchyEdgeLabels([ - { source: 10, target: 70, type: 'WRITES' }, { source: 11, target: 70, type: 'WRITES' }, { source: 12, target: 70, type: 'WRITES' }, - { source: 12, target: 71, type: 'CALLS' }, - ], layout); - expect(labels.map(label => [label.text, label.source, label.target])).toEqual([['WRITES ×3', 11, 70], ['CALLS', 12, 71]]); -}); - it('drops a hierarchy edge label that finds no free place instead of laying it over a name', () => { const name = { left: 90, right: 210, top: 90, bottom: 110 }; // A short edge whose whole neighbourhood is a name: no spot without overlap. @@ -64,3 +57,15 @@ it('drops a hierarchy edge label that finds no free place instead of laying it o expect(spot).toBeDefined(); expect(spot!.rect.left >= name.right || spot!.rect.bottom <= name.top || spot!.rect.top >= name.bottom).toBe(true); }); + +/* + * Second review of K5, seen in the browser: with up to three side steps a + * label of a dense fan stood in empty space, far from the line it belongs to. + * In the hierarchy it stays on its line or right beside it, or is not shown. + */ +it('keeps a hierarchy edge label on its line or one step beside it, never further out in empty space', () => { + const from = { x: 0, y: 100 }, to = { x: 400, y: 100 }, size = { width: 60, height: 14 }; + const names = { left: -100, right: 500, top: 70, bottom: 130 }; + expect(placeAlongSegment(from, to, size, [names], []).overlap).toBe(0); + expect(hierarchyLabelSpot(from, to, size, [names], [])).toBeUndefined(); +}); diff --git a/graph-ui/src/galaxy/hierarchy-layout.ts b/graph-ui/src/galaxy/hierarchy-layout.ts index 08e3b5f64..2f1c2902f 100644 --- a/graph-ui/src/galaxy/hierarchy-layout.ts +++ b/graph-ui/src/galaxy/hierarchy-layout.ts @@ -186,6 +186,8 @@ export interface HierarchyPlacement { hop: number; /** Im Ausschnitt (K5): links eingehend (-1), Mitte Wurzel (0), rechts ausgehend (+1). */ side?: -1 | 0 | 1; + /** Im Ausschnitt (Review zu K5): ueber gemischte Richtungen erreicht, steht im Band unter den Spalten und auf keiner Seite. */ + mixed?: boolean; /** Die `id` des erzeugten Szene-Knotens. */ id: number; x: number; @@ -218,6 +220,12 @@ export interface HierarchyProjection { placements: HierarchyPlacement[]; /** Im Ausschnitt (K5): je Szene-ID die ID im Scope, damit Pfade auch in diesem Bild liegen. */ sourceIds?: number[]; + /** Im Ausschnitt (Review zu K5): das Band der gemischt erreichten Knoten, mit Zahl und Lage seiner Ueberschrift. */ + band?: { count: number; x: number; y: number; left: number; right: number; bottom: number }; + /** Im Ausschnitt (Review zu K5): wer einen Namen traegt, alle, nur die Wurzel und ihre direkten Nachbarn, oder niemand. */ + names?: 'all' | 'neighbours' | 'none'; + /** Im Ausschnitt (zweites Review zu K5): ohne `names: 'all'` die Szene-IDs, die einen Namen tragen. */ + namedIds?: number[]; } /** Die Knoten, die eine Projektion aus dem Server-Layout uebernehmen darf. */ diff --git a/graph-ui/src/galaxy/path-frame.ts b/graph-ui/src/galaxy/path-frame.ts index 7afb730a9..39b2a0513 100644 --- a/graph-ui/src/galaxy/path-frame.ts +++ b/graph-ui/src/galaxy/path-frame.ts @@ -76,20 +76,23 @@ function overlapArea(a: ScreenRect, b: ScreenRect): number { return width > 0 && height > 0 ? width * height : 0; } +const SIDES = [0, 1, -1, 2, -2, 3, -3]; + /** * Wo das Label einer Kante steht: der erste Punkt auf der Kante, an dem es * nichts beruehrt, sonst daneben, sonst der Platz mit der kleinsten * Ueberdeckung. `t` ist der Anteil auf der Kante, `side` der seitliche Versatz - * in Labelhoehen. + * in Labelhoehen, hoechstens `maxSide` davon (die Hierarchie bleibt mit ihren + * vielen Linien bei einem, damit ein Schild bei seiner Linie steht). */ export function placeAlongSegment(from: { x: number; y: number }, to: { x: number; y: number }, size: { width: number; height: number }, - blockers: readonly ScreenRect[], placed: readonly ScreenRect[]): { t: number; side: number; rect: ScreenRect; overlap: number } { + blockers: readonly ScreenRect[], placed: readonly ScreenRect[], maxSide = 3): { t: number; side: number; rect: ScreenRect; overlap: number } { const length = Math.hypot(to.x - from.x, to.y - from.y) || 1; const normal = { x: -(to.y - from.y) / length, y: (to.x - from.x) / length }; // One side step clears the label's own extent across the edge: its height on a flat edge, its width on a steep one. const step = (size.width / 2) * Math.abs(normal.x) + (size.height / 2) * Math.abs(normal.y) + 6; let best: { t: number; side: number; rect: ScreenRect; overlap: number } | undefined; - for (const side of [0, 1, -1, 2, -2, 3, -3]) { + for (const side of SIDES.filter((value) => Math.abs(value) <= maxSide)) { for (const t of LABEL_STEPS) { const offset = side * step; const x = from.x + (to.x - from.x) * t + normal.x * offset, y = from.y + (to.y - from.y) * t + normal.y * offset;