mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-29 07:13:44 +08:00
fix(ui): keep a Galaxy hierarchy truthful from two layers on (K5)
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 <bj@techport.io>
This commit is contained in:
@@ -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<number> | 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<number> | undefined }));
|
||||
vi.mock('./GraphScene', async importOriginal => ({
|
||||
...await importOriginal<typeof import('./GraphScene')>(),
|
||||
GraphScene: ({ data, path, highlightedIds, labelMaxTextWidth, overlay, showLabels, labelBudget }: { data: GraphData; path?: ScenePath; highlightedIds: Set<number> | null;
|
||||
labelMaxTextWidth?: number; overlay?: unknown; showLabels: boolean; labelBudget?: number }) => {
|
||||
GraphScene: ({ data, path, highlightedIds, labelMaxTextWidth, overlay, showLabels, labelBudget, labelIds }: { data: GraphData; path?: ScenePath; highlightedIds: Set<number> | null;
|
||||
labelMaxTextWidth?: number; overlay?: unknown; showLabels: boolean; labelBudget?: number; labelIds?: ReadonlySet<number> }) => {
|
||||
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 <output data-testid="scene" />;
|
||||
},
|
||||
}));
|
||||
/** 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<T>(node: unknown, type: unknown): T | undefined {
|
||||
if (!isValidElement(node)) return Array.isArray(node) ? node.map(entry => overlayProps<T>(entry, type)).find(Boolean) : undefined;
|
||||
if (node.type === type) return node.props as T;
|
||||
return overlayProps<T>((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(<GalaxyPanel key={count} project="sample" visible workspaceExpanded onOpenNode={vi.fn()} fetch={scopeFetch({ nodes, edges }).fetch} />));
|
||||
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<HTMLButtonElement>('[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(<GalaxyPanel key={`${callers}:${outer}`} project="sample" visible workspaceExpanded onOpenNode={vi.fn()} fetch={scopeFetch({ nodes, edges }).fetch} />));
|
||||
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<HTMLButtonElement>('[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(<GalaxyPanel project="sample" visible workspaceExpanded onOpenNode={vi.fn()} fetch={scopeFetch({ nodes, edges }).fetch}
|
||||
legendStore={{ getItem: (key: string) => (key === GALAXY_LEGEND_KEY ? 'open' : null), setItem: vi.fn() } as unknown as Storage} />));
|
||||
await settle(() => expect(seam().nodes).toBe(5));
|
||||
await act(async () => { seam().clickNode('sample.n1'); });
|
||||
await settle(() => expect(host.querySelector('.atlas-graph-scope-count')?.textContent).toBe('3 nodes · 2 edges'));
|
||||
await act(async () => host.querySelector<HTMLButtonElement>('[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');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
? <HierarchyEdgeLabels nodes={sceneShown.nodes} edges={sceneShown.edges} layout={scopedSpots} nameBoxes={labelBoxes} /> : 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
|
||||
? <HierarchyEdgeLabels nodes={sceneShown.nodes} edges={labelledEdges} layout={scopedSpots} nameBoxes={labelBoxes} /> : undefined;
|
||||
// Zweites Review zu K5: die Ueberschrift des Bandes der gemischt erreichten Knoten.
|
||||
const hierarchyBand = mode === 'hierarchy' && scopedProjection?.band ? <HierarchyBandLabel band={scopedProjection.band} /> : undefined;
|
||||
const overlay: ReactNode = (pulseRing === undefined && !liveOn && hierarchyEdgeLabels === undefined && hierarchyBand === undefined) ? undefined : (
|
||||
<>
|
||||
{pulseRing}
|
||||
{hierarchyEdgeLabels}
|
||||
{hierarchyBand}
|
||||
{liveOn && agentLayerOn && agentsView !== undefined && (
|
||||
<AgentLayer
|
||||
actors={agentsView.actors}
|
||||
@@ -2458,7 +2487,8 @@ export default function GalaxyPanel(props: GalaxyPanelProps): JSX.Element {
|
||||
: candidate === 'galaxy'
|
||||
? 'galaxy: the whole project, laid out by the server'
|
||||
: readerHierarchyActive ? 'hierarchy: incoming relationships, file definitions, and outgoing relationships'
|
||||
: scopedProjection ? galaxyHierarchyText.hint(scope.direction, scopedNamesHidden ? SCOPED_HIERARCHY_LABEL_BUDGET : undefined)
|
||||
: scopedProjection ? galaxyHierarchyText.hint(scope.direction, { mixed: scopedProjection.band?.count ?? 0, names: scopedNames,
|
||||
budget: SCOPED_HIERARCHY_LABEL_BUDGET, neighbours: scopedNeighbours, sides: scopedNamedSides })
|
||||
: 'hierarchy: what the chosen symbol reaches, one column per call depth'
|
||||
}
|
||||
>
|
||||
@@ -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' && (
|
||||
<p className="atlas-hierarchy-key" data-testid="atlas-hierarchy-key">
|
||||
{galaxyHierarchyText.namesNote(scope.direction, scopedProjection.data.nodes.length, scopedNames, scopedNeighbours, SCOPED_HIERARCHY_LABEL_BUDGET, scopedNamedSides)}
|
||||
</p>
|
||||
)}
|
||||
{state !== 'ready' && (
|
||||
<p className="atlas-galaxy-placeholder" data-state={state}>
|
||||
{headline}
|
||||
|
||||
@@ -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<number> | 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 | null>(cameraTarget);
|
||||
useEffect(() => { setSceneTarget(cameraTarget); }, [cameraTarget]);
|
||||
|
||||
@@ -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<string>) => [...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<number, HierarchySpot>): HierarchyEdgeLabel[] {
|
||||
const pairs = new Map<string, { source: number; target: number; types: Set<string> }>();
|
||||
const pairs = new Map<string, { source: number; target: number; forward: Set<string>; backward: Set<string> }>();
|
||||
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<string>() };
|
||||
pair.types.add(edge.type); pairs.set(key, pair);
|
||||
const pair = pairs.get(key) ?? { source: edge.source, target: edge.target, forward: new Set<string>(), backward: new Set<string>() };
|
||||
(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<string, Member[]>();
|
||||
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<number, HierarchySpot>;
|
||||
/** Die gezeichneten Namen, in Weltkoordinaten (NodeLabels). */
|
||||
nameBoxes: RefObject<LabelBox[]>;
|
||||
@@ -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<string, THREE.Group>());
|
||||
const elements = useRef(new Map<string, HTMLElement>());
|
||||
const sizes = useRef(new Map<string, { width: number; height: number }>());
|
||||
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<HTMLElement>(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 (
|
||||
<>
|
||||
<Line points={frame} color={BAND_FRAME_COLOR} lineWidth={1} dashed dashSize={10} gapSize={7} transparent opacity={0.8} />
|
||||
<Html position={[band.x, band.y, 0]} center zIndexRange={[9, 0]} style={{ pointerEvents: 'none' }}>
|
||||
<span className="atlas-hierarchy-band-label" data-testid="atlas-hierarchy-band-label" data-count={band.count}>
|
||||
<strong>{galaxyHierarchyText.bandTitle(band.count)}</strong>{' '}
|
||||
<small>{galaxyHierarchyText.bandDetail}</small>
|
||||
</span>
|
||||
</Html>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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<HierarchyNames, 'all'>, 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…',
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<number, -1 | 0 | 1>(), parent = new Map<number, { id: number; edge: GraphEdge }>();
|
||||
/* Ob die Kette von der Wurzel bis hierher in einer Richtung laeuft. */
|
||||
const straight = new Map<number, boolean>();
|
||||
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<number, GraphNode[]>();
|
||||
const columns = new Map<number, GraphNode[]>(), 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<number>(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<number, number>();
|
||||
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<number, number>();
|
||||
@@ -421,8 +462,15 @@ export function scopedHierarchy(scope: ScopedGraph, name: string): import('./hie
|
||||
previous = key;
|
||||
}
|
||||
}
|
||||
const nodes: GraphNode[] = [], remap = new Map<number, number>(), sourceIds: number[] = [];
|
||||
const nodes: GraphNode[] = [], remap = new Map<number, number>(), 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 } : {}) };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user