From 1999aaba8a4cf02c6336637693093acfecf0551f Mon Sep 17 00:00:00 2001 From: Bernhard Jackiewicz Date: Sat, 3 Oct 2026 19:02:00 +0200 Subject: [PATCH] fix(ui): show the part that tells similar route labels apart A compact route chip shows about fourteen characters. After "Show these 2 routes" both chips read "/generic-lastmod...", and unrelated routes such as /callable-lastmod-full-sitemap.xml and /callable-lastmod-partial-sitemap.xml were cut to the same text too. Labels that would read alike now drop their shared start at a separator ("...index.xml" style, a slash kept for path segments); the tooltip, the inspector and the list keep the full path. Signed-off-by: Bernhard Jackiewicz --- .../src/architecture/ArchitectureScene.tsx | 4 +-- .../src/architecture/semantic-graph.test.ts | 16 +++++++++ graph-ui/src/architecture/semantic-graph.ts | 33 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/graph-ui/src/architecture/ArchitectureScene.tsx b/graph-ui/src/architecture/ArchitectureScene.tsx index bf5cc74d4..f7db451dc 100644 --- a/graph-ui/src/architecture/ArchitectureScene.tsx +++ b/graph-ui/src/architecture/ArchitectureScene.tsx @@ -173,7 +173,7 @@ function ArchitectureNode({ node, selected, highlighted, dimmed, labelVisible = onPointerEnter={() => onHover(node.id)} onPointerLeave={() => onHover()} 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))}%} + {node.hotspot && ◉ }{node.shortLabel ?? 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' : ''}} {selected && {node.detail}} @@ -214,7 +214,7 @@ function HotspotLabels({ model, priorityId, onVisible }: { model: RenderGraph; p 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 box = boxes.current.get(node.id); - const width = box?.width ?? Math.min(178, Math.max(78, node.label.length * 7 + (node.gravityPercent === undefined ? 26 : 68))); + const width = box?.width ?? Math.min(178, Math.max(78, (node.shortLabel ?? node.label).length * 7 + (node.gravityPercent === undefined ? 26 : 68))); const height = node.id === priorityId ? 96 : box?.height ?? 38; const rect = labelRect(x, y, width, height, box ? inset : [-5, 0]); if (node.id !== priorityId && (rect.right < 0 || rect.left > size.width || rect.bottom < 0 || rect.top > size.height diff --git a/graph-ui/src/architecture/semantic-graph.test.ts b/graph-ui/src/architecture/semantic-graph.test.ts index 0a793cafa..a1a07259f 100644 --- a/graph-ui/src/architecture/semantic-graph.test.ts +++ b/graph-ui/src/architecture/semantic-graph.test.ts @@ -332,6 +332,22 @@ describe('route identities and service evidence', () => { const filtered = buildSemanticGraph(graph, { view: 'routes', groupRoutes: true, filter: '/accounts', routeSnapshot }); expect(filtered.nodes.filter(item => item.kind === 'route').map(item => item.label)).toEqual(['/accounts/login/', '/accounts/logout/']); }); + it('shortens the shared start of routes that would read alike and keeps the distinguishing part', () => { + const paths = ['/generic-lastmod/index.xml', '/generic-lastmod/sitemap.xml', '/callable-lastmod-full-sitemap.xml', '/callable-lastmod-partial-sitemap.xml', '/about/']; + const routes = paths.map((name, index) => ({ ...route, id: 600 + index, name, qualified_name: `fixture.route.${600 + index}`, file_path: 'tests/sitemaps_tests/urls/http.py' })); + const graph: GraphData = { nodes: routes, edges: [], total_nodes: routes.length }; + const shown = (model: ReturnType) => Object.fromEntries(model.nodes.filter(item => item.kind === 'route') + .map(item => [item.label, item.shortLabel ?? item.label])); + // "Show these 2 routes" filters by the group prefix: both labels started "/generic-lastmod…" and read alike. + expect(shown(buildSemanticGraph(graph, { view: 'routes', groupRoutes: true, filter: '/generic-lastmod' }))) + .toEqual({ '/generic-lastmod/index.xml': '…/index.xml', '/generic-lastmod/sitemap.xml': '…/sitemap.xml' }); + // Distinct first segments are not grouped, yet were cut to the same "/callable-lastmo…". + const grouped = shown(buildSemanticGraph(graph, { view: 'routes', groupRoutes: true })); + expect(grouped['/callable-lastmod-full-sitemap.xml']).toBe('…full-sitemap.xml'); + expect(grouped['/callable-lastmod-partial-sitemap.xml']).toBe('…partial-sitemap.xml'); + expect(grouped['/about/']).toBe('/about/'); + expect(grouped['/generic-lastmod · 2']).toBe('/generic-lastmod · 2'); + }); it('selects exactly the routes of a group when the filter names its prefix', () => { const at = (id: number, name: string, file_path: string): GraphNode => ({ ...route, id, name, qualified_name: `fixture.route.${id}`, file_path }); const routes = [at(400, '/', 'app/urls.py'), at(401, '/', 'shop/urls.py'), at(402, '/', 'blog/urls.py'), diff --git a/graph-ui/src/architecture/semantic-graph.ts b/graph-ui/src/architecture/semantic-graph.ts index 2150a348b..6bf420793 100644 --- a/graph-ui/src/architecture/semantic-graph.ts +++ b/graph-ui/src/architecture/semantic-graph.ts @@ -26,6 +26,8 @@ export interface SemanticNode { external?: boolean; /** The first path segment shared by the routes of a route group. */ routePrefix?: string; + /** What the chip shows when the full label would read like a neighbour's; the tooltip keeps the full label. */ + shortLabel?: string; } export interface SemanticEvidence { source: GraphNode; @@ -110,6 +112,33 @@ const TEST_SOURCE = /(^|\/)(tests|__tests__|specs?)\/|^(src\/)?test\/|(^|\/)(tes * (decodeURI), and a path that is not valid percent-encoding stays as indexed. */ export const readableRoute = (path: string): string => { try { return decodeURI(path); } catch { return path; } }; const routePrefixOf = (label: string) => `/${label.replace(/^[A-Z]+\s+/, '').split('/').filter(Boolean)[0] ?? ''}`; +/** Characters a compact route chip shows before it cuts the label (spatial-architecture.css, 112px at 10px). */ +const COMPACT_LABEL_CHARS = 14; + +/** + * Labels that a compact chip would cut to the same visible start ("/generic-lastmo…") + * lose their shared start instead: "…/index.xml" and "…/sitemap.xml". The cut falls on a + * separator, so the rest begins with a whole segment or word; a slash stays to mark a path. + */ +export function distinctLabels(labels: readonly string[], visible = COMPACT_LABEL_CHARS): Map { + const result = new Map(); + const long = [...new Set(labels)].filter(label => label.length > visible); + for (const label of long) { + let shared = 0; + for (const other of long) { + if (other === label || other.slice(0, visible - 1) !== label.slice(0, visible - 1)) continue; + let common = 0; + while (common < label.length && label[common] === other[common]) common++; + shared = Math.max(shared, common); + } + if (!shared) continue; + const cut = Math.max(...['/', '-', '_', '.'].map(separator => label.lastIndexOf(separator, shared - 1))); + const rest = cut > 0 ? (label[cut] === '/' ? label.slice(cut) : label.slice(cut + 1)) : ''; + // A label that is the shared start itself keeps its full text; its neighbours carry the difference. + if (rest.replace('/', '').length > 1) result.set(label, `…${rest}`); + } + return result; +} const sourceNode = (node: GraphNode) => Boolean(node.file_path && node.file_path !== '{}' && !['Project', 'Folder', 'Package', 'Branch', 'Route'].includes(node.label)); const symbolNode = (node: GraphNode): SemanticNode => ({ id: symbolId(node), @@ -601,6 +630,10 @@ export function buildSemanticGraph(graph: GraphData, options: SemanticGraphOptio options.view === 'entryPoints' ? lane * 3 : 0, (siblingIndex % laneRows - (laneRows - 1) / 2) * 20]; } }); + if (options.view === 'routes') { + const short = distinctLabels(nodes.filter(node => node.kind === 'route').map(node => node.label)); + for (const node of nodes) if (node.kind === 'route' && short.has(node.label)) node.shortLabel = short.get(node.label); + } if (unresolved) warnings.push(`${unresolved} relationships have an endpoint outside the loaded repository snapshot.`); if (graph.total_nodes > graph.nodes.length) warnings.push('The loaded repository snapshot contains only part of the indexed graph.'); return { view: options.view, scopeKey: `${options.view}:${options.areaPath ?? ''}:${options.filePath ?? ''}:${rootId ?? ''}:${filter ?? ''}`,