From 2e0b8eaa35768e9e9ff2bd5d84f8f1ed5643f5eb Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Fri, 2 Oct 2026 19:02:12 +0200 Subject: [PATCH] fix(ui): keep the architecture overview readable inside large areas Opening "django" in the Overview jumped straight to its files: areas were one folder deep except under src, lib and similar containers. The folder layout was computed for every file while only 40 were drawn, so the pruned platforms kept their full size, filled the canvas and flattened the tilted view; full-path labels overlapped and heights were scaled against the largest repository area. An opened area now shows its next folder level as area blocks, applying the repository area rule to the path below it (so graph-ui shows src/app as one step, and django shows contrib, db, utils ...). The node cap runs before the folder layout, so platforms are sized to what is drawn; a filter that only narrows the drawn map keeps its positions. Outside areas are named as siblings of the opened one and sit in their own lane in front of its platform. Labels are relative to the opened area with the full path in the tooltip, heights compare the bricks of the current scope, and the Overview uses adaptive labels whose culling measures the real label boxes instead of the larger service-map estimate. The location trail offers every drill-in level. Signed-off-by: Bernhard Jackiewicz --- .../src/architecture/ArchitectureScene.tsx | 66 +++++++--- .../architecture/SpatialArchitecture.test.tsx | 18 ++- .../src/architecture/SpatialArchitecture.tsx | 8 +- graph-ui/src/architecture/hotspot-map.test.ts | 7 + graph-ui/src/architecture/hotspot-map.ts | 12 +- .../src/architecture/repository-map.test.ts | 22 +++- graph-ui/src/architecture/repository-map.ts | 19 +++ .../src/architecture/semantic-graph.test.ts | 69 +++++++++- graph-ui/src/architecture/semantic-graph.ts | 124 +++++++++++++----- .../src/architecture/source-metrics.test.ts | 11 ++ graph-ui/src/architecture/source-metrics.ts | 10 +- 11 files changed, 301 insertions(+), 65 deletions(-) diff --git a/graph-ui/src/architecture/ArchitectureScene.tsx b/graph-ui/src/architecture/ArchitectureScene.tsx index 670be5be2..cfaf50bb8 100644 --- a/graph-ui/src/architecture/ArchitectureScene.tsx +++ b/graph-ui/src/architecture/ArchitectureScene.tsx @@ -137,6 +137,8 @@ function ArchitectureNode({ node, selected, highlighted, dimmed, labelVisible = const roofLanguages = node.tint && mixed ? measure!.languages.slice(0, 4) : []; const roofTotal = measure?.lines || measure?.files || 1; let roofOffset = -size[0] / 2; + // Labels name a part relative to the opened area; the tooltip keeps its full path. + const path = node.kind === 'area' || node.kind === 'file' ? node.filePath ?? node.areaPath : undefined; return { event.stopPropagation(); onSelect(); }} @@ -168,7 +170,7 @@ function ArchitectureNode({ node, selected, highlighted, dimmed, labelVisible = data-node-id={node.id} data-position={node.position.join(',')} data-label-visible={labelVisible} data-lines={measure?.lines} data-height={size[1]} data-language={measure?.languages[0]?.name} data-gravity={node.gravity ?? 0} data-size-percent={node.sizePercent} data-gravity-percent={node.gravityPercent} onPointerEnter={() => onHover(node.id)} onPointerLeave={() => onHover()} - title={`${node.label}\n${node.detail}${measure ? `\n${lineText}\n${measure.languages.map(language => language.name).join(', ')} (from file types)` : ''}`}> + title={`${node.label}${path && path !== node.label ? `\n${path}` : ''}\n${node.detail}${measure ? `\n${lineText}\n${measure.languages.map(language => language.name).join(', ')} (from file types)` : ''}`}> {selected && {node.kindLabel ?? (node.kind === 'area' ? 'Source area' : node.kind)}} {node.hotspot && ◉ }{node.label}{!measure && node.gravityPercent !== undefined && {node.gravityPercent > 0 && node.gravityPercent < 0.1 ? '<0.1' : Number(node.gravityPercent.toFixed(1))}%} {selected && measure && {measure.lines === undefined ? '?' : `${measure.measuredFiles < measure.files ? '≥' : ''}${Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(measure.lines)}`} lines{mixed ? ' · mixed' : ''}} @@ -178,26 +180,48 @@ function ArchitectureNode({ node, selected, highlighted, dimmed, labelVisible = ; } -/** Screen-space label density changes with zoom; source positions never do. */ +/** + * Screen-space label density changes with zoom; source positions never do. + * Label boxes are measured once per model, so culling follows the CSS of the + * map that shows them (the compact Overview labels are far smaller than the + * service map's); the estimate only covers a label not laid out yet. + */ function HotspotLabels({ model, priorityId, onVisible }: { model: RenderGraph; priorityId?: string; onVisible: (ids: Set) => void }) { - const { camera, size } = useThree(); + const { camera, size, gl, events, invalidate } = useThree(); + // Html labels mount where drei puts them: the event source, else the canvas parent. + const labelHost = (events.connected as HTMLElement | undefined) ?? gl.domElement.parentElement; const previous = useRef(''); + const boxes = useRef(new Map()); + const attempts = useRef(0); + useEffect(() => { boxes.current.clear(); attempts.current = 0; }, [model]); useFrame(() => { + const unmeasured = () => model.nodes.some(node => !boxes.current.has(node.id)); + if (unmeasured()) labelHost?.querySelectorAll('.architecture-node-label[data-node-id]').forEach(label => { + // A selected label carries extra rows; it is the priority label and keeps its reserve below. + if (label.offsetWidth && !label.classList.contains('is-selected')) boxes.current.set(label.dataset.nodeId!, [label.offsetWidth, label.offsetHeight]); + }); const occupied: { left: number; right: number; top: number; bottom: number }[] = []; const ids = new Set(); - const ordered = [...model.nodes].sort((a, b) => Number(b.id === priorityId) - Number(a.id === priorityId)); + // Hotspot wells, then larger parts, claim their label space first. + const ordered = [...model.nodes].sort((a, b) => Number(b.id === priorityId) - Number(a.id === priorityId) + || (b.gravity ?? 0) - (a.gravity ?? 0) || b.count - a.count); for (const node of ordered) { const point = new Vector3(...node.position).add(new Vector3(0, dimensions(node)[1] + 7, 0)).project(camera); const x = (point.x + 1) * size.width / 2; const y = (1 - point.y) * size.height / 2; - const width = Math.min(178, Math.max(78, node.label.length * 7 + (node.gravityPercent === undefined ? 26 : 68))); - const halfHeight = node.id === priorityId ? 48 : 19; - const rect = { left: x - width / 2 - 5, right: x + width / 2 + 5, top: y - halfHeight, bottom: y + halfHeight }; + const box = boxes.current.get(node.id); + const width = box?.[0] ?? Math.min(178, Math.max(78, node.label.length * 7 + (node.gravityPercent === undefined ? 26 : 68))); + const halfHeight = node.id === priorityId ? 48 : (box?.[1] ?? 38) / 2; + // A measured label may overlap a neighbour by its padding, never by its text. + const [insetX, insetY] = box ? [6, 4] : [-5, 0]; + const rect = { left: x - width / 2 + insetX, right: x + width / 2 - insetX, top: y - halfHeight + insetY, bottom: y + halfHeight - insetY }; if (node.id !== priorityId && (rect.right < 0 || rect.left > size.width || rect.bottom < 0 || rect.top > size.height || occupied.some(other => rect.left < other.right && rect.right > other.left && rect.top < other.bottom && rect.bottom > other.top))) continue; ids.add(node.id); occupied.push(rect); } const key = [...ids].sort().join('|'); if (previous.current !== key) { previous.current = key; onVisible(ids); } + // Labels mount a moment after the scene; a few extra frames pick up their real size. + if (unmeasured() && attempts.current++ < 8) invalidate(); }); return null; } @@ -256,16 +280,24 @@ function FitArchitecture({ model, planar, resetKey, controls, active }: { } export function ArchitectureScene({ model: graphModel, selectedId, selectedEdgeId, onSelect, onSelectEdge, onOpen, onClearSelection, active = true, planar = false, resetKey = 0, catalog, heightMetric = 'uniform', colorMetric = 'kind', hotspots, showHotspots = true, adaptiveLabels = false }: ArchitectureSceneProps) { - const model: RenderGraph = useMemo(() => ({ ...graphModel, nodes: graphModel.nodes.map(node => { - const measure = catalog ? measureSourceNode(node, catalog) : undefined; - const sizePercent = catalog ? sourceNodeSizePercent(node, catalog) : undefined; - const language = measure?.languages[0]?.name ?? (node.filePath ? sourceLanguage(node.filePath) : undefined); - const hotspot = showHotspots && hotspots ? hotspotsForNode(node, hotspots) : undefined; - return { ...node, measure, sizePercent, hotspot, gravity: hotspot && hotspots ? gravityStrength(hotspot.maxFanIn, hotspots.maxFanIn) : 0, - gravityPercent: hotspot && hotspots ? gravityPercent(hotspot.maxFanIn, hotspots.maxFanIn) : undefined, - height: catalog && heightMetric === 'lines' && ['area', 'file'].includes(node.kind) ? node.kind === 'area' && node.areaPath === '(root)' ? 0.35 : sourceBrickHeight(sizePercent, 100) : undefined, - tint: node.tint ?? (colorMetric === 'language' && node.kind !== 'route' ? languageColor(language ?? 'Unknown') : undefined) }; - }) }), [graphModel, catalog, heightMetric, colorMetric, hotspots, showHotspots]); + const model: RenderGraph = useMemo(() => { + const measures = new Map(graphModel.nodes.map(node => [node.id, catalog ? measureSourceNode(node, catalog) : undefined])); + // Heights compare the bricks of this scope with each other. Root files are + // the flat baseline; outside areas are context and stay flat as well. + const rootFiles = (node: SemanticNode) => node.kind === 'area' && node.areaPath === '(root)'; + const reference = Math.max(1, ...graphModel.nodes.filter(node => ['area', 'file'].includes(node.kind) && !node.external && !rootFiles(node)) + .map(node => measures.get(node.id)?.lines ?? 0)); + return { ...graphModel, nodes: graphModel.nodes.map(node => { + const measure = measures.get(node.id); + const sizePercent = catalog ? sourceNodeSizePercent(node, catalog) : undefined; + const language = measure?.languages[0]?.name ?? (node.filePath ? sourceLanguage(node.filePath) : undefined); + const hotspot = showHotspots && hotspots ? hotspotsForNode(node, hotspots) : undefined; + return { ...node, measure, sizePercent, hotspot, gravity: hotspot && hotspots ? gravityStrength(hotspot.maxFanIn, hotspots.maxFanIn) : 0, + gravityPercent: hotspot && hotspots ? gravityPercent(hotspot.maxFanIn, hotspots.maxFanIn) : undefined, + height: catalog && heightMetric === 'lines' && ['area', 'file'].includes(node.kind) && !node.external ? rootFiles(node) ? 0.35 : sourceBrickHeight(measure?.lines, reference) : undefined, + tint: node.tint ?? (colorMetric === 'language' && node.kind !== 'route' ? languageColor(language ?? 'Unknown') : undefined) }; + }) }; + }, [graphModel, catalog, heightMetric, colorMetric, hotspots, showHotspots]); const [hoveredId, setHoveredId] = useState(); const [hoveredEdgeId, setHoveredEdgeId] = useState(); const [visibleHotspotLabels, setVisibleHotspotLabels] = useState>(); diff --git a/graph-ui/src/architecture/SpatialArchitecture.test.tsx b/graph-ui/src/architecture/SpatialArchitecture.test.tsx index bc5522a6d..426eac5ba 100644 --- a/graph-ui/src/architecture/SpatialArchitecture.test.tsx +++ b/graph-ui/src/architecture/SpatialArchitecture.test.tsx @@ -32,7 +32,7 @@ it('drills inferred areas to files and symbols before enabling real-node impact await click('src/api'); expect(onSelect).not.toHaveBeenCalled(); expect(host.textContent).not.toContain('Analyze selected symbol'); - await click('Open area →'); await click('src/api/main.ts'); await click('Open file symbols →'); await click('start'); + await click('Open area →'); await click('main.ts'); await click('Open file symbols →'); await click('start'); expect(onSelect).toHaveBeenCalledExactlyOnceWith(node); expect(host.textContent).toContain('Analyze selected symbol'); await click('Open source'); expect(onNavigate).toHaveBeenCalledWith('src/api/main.ts', 12, 'start'); @@ -40,13 +40,27 @@ it('drills inferred areas to files and symbols before enabling real-node impact it('returns from a file symbol to the repository map on empty background', async () => { const clear = vi.fn(); await act(async () => root.render(Analyze selected symbol} />)); - await click('src/api'); await click('Open area →'); await click('src/api/main.ts'); await click('Open file symbols →'); await click('start'); + await click('src/api'); await click('Open area →'); await click('main.ts'); await click('Open file symbols →'); await click('start'); expect(host.textContent).toContain('Analyze selected symbol'); await act(async () => host.querySelector('[aria-label="Empty background"]')!.click()); expect(host.querySelector('[data-testid="scene"]')?.textContent).toContain('src/api'); expect(host.textContent).not.toContain('Analyze selected symbol'); expect(clear).toHaveBeenCalledOnce(); }); +it('opens nested areas level by level and walks back through the location trail', async () => { + const nested: GraphData = { nodes: [{ ...node, file_path: 'django/contrib/admin/options.py' }, { ...node, id: 2, name: 'render', qualified_name: 'sample.render', file_path: 'django/shortcuts.py' }], edges: [], total_nodes: 2 }; + await act(async () => root.render()); + const scene = () => [...host.querySelectorAll('[data-testid="scene"] [data-node]')].map(item => item.textContent); + const trail = () => [...host.querySelectorAll('[aria-label="Architecture location"] button')].map(item => item.textContent); + await click('django'); await click('Open area →'); + expect(scene()).toEqual(['contrib', 'shortcuts.py']); + await click('contrib'); await click('Open area →'); + expect(scene()).toEqual(['admin']); + expect(trail()).toEqual(['sample', 'django', 'contrib']); + const django = [...host.querySelectorAll('[aria-label="Architecture location"] button')].find(item => item.textContent === 'django')!; + await act(async () => django.click()); + expect(scene()).toEqual(['contrib', 'shortcuts.py']); +}); it('publishes source-area evidence without inventing a symbol and clears it on background', async () => { const onSelectionEvidence = vi.fn(); await act(async () => root.render()); diff --git a/graph-ui/src/architecture/SpatialArchitecture.tsx b/graph-ui/src/architecture/SpatialArchitecture.tsx index 22c88ec49..1557f3d7c 100644 --- a/graph-ui/src/architecture/SpatialArchitecture.tsx +++ b/graph-ui/src/architecture/SpatialArchitecture.tsx @@ -1,10 +1,11 @@ -import { Component, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { Component, Fragment, useEffect, useMemo, useState, type ReactNode } from 'react'; import type { ArchitectureOverviewDto } from '../core/intelligence-provider'; import type { GraphData, GraphNode } from '../galaxy/types'; import { graphNodeEvidence, useSelectionEvidence, type SelectionEvidenceListener } from '../galaxy/selection-evidence'; import { ArchitectureScene } from './ArchitectureScene'; import { buildSemanticGraph, semanticEntryPoints, type SemanticNode, type SemanticView } from './semantic-graph'; import { loadRouteGraph, type RouteGraphSnapshot } from './route-graph-source'; +import { areaLevels } from './repository-map'; import { collectSourceMetrics, measureSourceNode } from './source-metrics'; import SourceMetricsDetails from './SourceMetricsDetails'; import type { CoverageIndex } from '../app/tree-model'; @@ -139,7 +140,7 @@ export default function SpatialArchitecture({ project, generation, graph, overvi
{['overview', 'dependencies', 'entryPoints'].includes(view) &&
} {(view === 'overview' || view === 'dependencies') && } {view === 'entryPoints' && <>