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) && onView('overview')}>Structure onView('entryPoints')}>Entry points} {(view === 'overview' || view === 'dependencies') && {project} - {areaPath && <>/ { setFilePath(undefined); setSelection(undefined); }}>{areaPath}>}{filePath && <>/{filePath.split('/').at(-1)}>} + {areaPath && areaLevels(areaPath).map((level, index, levels) => / { setAreaPath(level); setFilePath(undefined); setSelection(undefined); }}>{index ? level.slice(levels[index - 1].length + 1) : level})}{filePath && <>/{filePath.split('/').at(-1)}>} } {view === 'entryPoints' && <>Start { const node = entries.find(node => node.id === Number(event.target.value)); setEntryChoice(node ? { node, generation } : undefined); setSelection(undefined); }}> {!entries.length && No indexed entry points}{entries.map(node => {node.name} · {node.file_path})} @@ -161,7 +162,8 @@ export default function SpatialArchitecture({ project, generation, graph, overvi {model.nodes.length ? { setSelection({ scope, edge: id }); setMemberLimit(5); }} onOpen={id => { const node = nodesById.get(id); if (node && (node.kind === 'area' || node.kind === 'file')) openGroup(node); }} onClearSelection={clearScope} active={active} planar={planar} resetKey={resetKey} catalog={catalog} heightMetric={heightMetric} colorMetric={colorMetric} hotspots={hotspotCatalog} showHotspots={showHotspots} /> + onSelectEdge={id => { setSelection({ scope, edge: id }); setMemberLimit(5); }} onOpen={id => { const node = nodesById.get(id); if (node && (node.kind === 'area' || node.kind === 'file')) openGroup(node); }} onClearSelection={clearScope} active={active} planar={planar} resetKey={resetKey} catalog={catalog} heightMetric={heightMetric} colorMetric={colorMetric} hotspots={hotspotCatalog} showHotspots={showHotspots} + adaptiveLabels={view === 'overview' || view === 'dependencies'} /> : {filter ? 'No matching graph evidence. Try a broader filter.' : view === 'hotspots' ? 'No ranked hotspot measurements are available in this snapshot.' : view === 'entryPoints' ? 'No indexed entry points in this snapshot.' : view === 'routes' ? 'No endpoint evidence is available in this snapshot.' : filePath ? 'No indexed symbols in this file. Use “Read this file” to inspect its source.' : 'No source nodes are available in this repository snapshot.'}} {view === 'hotspots' ? 'Gravity: incoming references' : view === 'entryPoints' ? 'Static call paths' : 'Source folders and indexed relationships'}Drag to orbit · scroll to zoom · select to inspect