feat(ui): implement Map grouping, dependents, and weight bars; symbol tab address

Adds a new grouping system for the Map with a new grouping depth control, exposes per-module dependents (files and modules) to drive a weight bar, and renders it on each module. Introduces a MapKey to explain visuals, collapses lone root-file buckets for clearer labeling, and supports a nullable depth value to let the provider pick grouping. The Symbol tab now has its own address (#/s) when nothing is selected, and routing/top-bar logic is updated accordingly. Also updates export SVG rendering to include weight-based bars, and extends tests and docs to cover the new visuals and behavior.
This commit is contained in:
Colby McHenry
2026-08-31 23:49:39 -05:00
parent b9ca4b7981
commit 7ec9ef1818
21 changed files with 905 additions and 41 deletions
+32
View File
@@ -139,6 +139,7 @@ function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
test: over.test ?? false,
facade: over.facade ?? false,
fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
dependents: over.dependents ?? { files: 0, modules: 0 },
};
}
@@ -449,6 +450,37 @@ describe('mapSvg', () => {
expect(svg).not.toContain('>__tests__</text>');
});
it('exports the weight bar the canvas draws, scaled the same way', () => {
const weighted = buildMapLayout(
{
modules: [
mod('src/types', { dependents: { files: 80, modules: 4 } }),
mod('src/db', { dependents: { files: 20, modules: 2 } }),
mod('src/bin'),
],
links: [link('src/db', 'src/types', 30), link('src/bin', 'src/db', 30)],
},
{ includeTests: false }
);
const svg = mapSvg(weighted);
const nodeOf = (id: string) => weighted.nodes.find((n) => n.id === id)!;
// Full bar for the heaviest, a quarter for the module a quarter as leaned
// on, and NO rect at all for the one nothing depends on.
// The export rounds to a tenth, as every coordinate in this file does.
const tenth = (n: number) => Math.round(n * 10) / 10;
const full = nodeOf('src/types');
const quarter = nodeOf('src/db');
expect(quarter.weight).toBeCloseTo(0.25, 5);
expect(svg).toContain(`width="${tenth(full.width)}" height="4" fill="${EXPORT_COLORS.ink}"`);
expect(svg).toContain(
`width="${tenth(quarter.width * 0.25)}" height="4" fill="${EXPORT_COLORS.ink}"`
);
expect(nodeOf('src/bin').weight).toBe(0);
expect(svg.match(/height="4" fill=/g)?.length).toBe(2);
// …and the count rides in the meta line, as on screen.
expect(svg).toContain('· 80 depend on it</text>');
});
it('names the top and bottom bands', () => {
const svg = mapSvg(layout);
expect(svg).toContain('>entry points</text>');
+131 -2
View File
@@ -24,7 +24,13 @@ import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
import { moduleIdFor, normalizeRoot, pickDefaultRoot, resetMapCache } from '../src/ui-server/api/map';
import {
moduleIdFor,
normalizeRoot,
pickDefaultDepth,
pickDefaultRoot,
resetMapCache,
} from '../src/ui-server/api/map';
let server: UiServerHandle;
let api: GraphApi;
@@ -293,6 +299,102 @@ describe('pickDefaultRoot', () => {
});
});
describe('pickDefaultDepth', () => {
/** `n` files under `dir`, each carrying `each` symbols. */
function spread(dir: string, n: number, each: number, test = false) {
return Array.from({ length: n }, (_, i) => ({
path: `${dir}/f${i}.ts`,
symbols: each,
test,
}));
}
it('goes deeper when one box holds the program', () => {
// The shape this rule exists for: a React-Native-ish repo whose whole app
// is under `src/`. At depth 1 the map is a box labelled `src` and nothing
// else — 285 files and two thirds of the symbols, unopenable.
const files = [
...spread('src/components', 119, 12),
...spread('src/app', 53, 21),
...spread('src/api', 47, 7),
...spread('src/utils', 24, 6),
...spread('ios/CaptureView', 63, 28),
...spread('ios/Camera', 3, 38),
...spread('.github/workflows', 4, 0),
];
expect(pickDefaultDepth(files, '')).toBe(2);
});
it('keeps a repository whose directories ARE its modules at one level', () => {
const files = [
...spread('src/db', 8, 40),
...spread('src/graph', 9, 40),
...spread('src/mcp', 7, 40),
...spread('src/search', 5, 40),
...spread('src/sync', 4, 40),
];
expect(pickDefaultDepth(files, 'src')).toBe(1);
});
it('does not open a dominant box that has nothing in it', () => {
// `src/core` holds most of the symbols but only three files: this is a
// small project honestly drawn, not a coarse grouping.
const files = [
...spread('src/core', 3, 90),
...spread('src/db', 2, 10),
...spread('src/api', 2, 10),
{ path: 'src/index.ts', symbols: 5, test: false },
];
expect(pickDefaultDepth(files, 'src')).toBe(1);
});
it('keeps going while the picture is still one box', () => {
// `frontend/` then `frontend/src/` — two levels of packaging before the
// code. Neither is a map; the third level is.
const files = [
...spread('frontend/src/screens', 15, 10),
...spread('frontend/src/components', 14, 10),
...spread('frontend/src/hooks', 8, 10),
...spread('frontend/src/api', 6, 10),
...spread('backend/app', 5, 8),
];
expect(pickDefaultDepth(files, '')).toBe(3);
});
it('stops before a deeper grouping becomes a crowd', () => {
const files = [
...spread('src/a', 30, 10),
...Array.from({ length: 70 }, (_, i) => ({
path: `src/b/m${i}/f.ts`,
symbols: 1,
test: false,
})),
];
// Depth 2 is dominated by `src/a`, but depth 3 would draw 71 boxes.
expect(pickDefaultDepth(files, '')).toBe(2);
});
it('does not chase a tree that has no more levels to give', () => {
const files = [
...spread('src/a', 30, 10),
...spread('src/b', 2, 1),
];
expect(pickDefaultDepth(files, 'src')).toBe(1);
});
it('counts only the modules the map draws by default', () => {
// Test files are hidden unless the reader asks for them, so a depth that
// is only "enough boxes" once tests are counted is not enough boxes.
const files = [
...spread('src/app', 40, 10),
...spread('src/__tests__/a', 12, 10, true),
...spread('src/__tests__/b', 12, 10, true),
...spread('src/__tests__/c', 12, 10, true),
];
expect(pickDefaultDepth(files, 'src')).toBe(1);
});
});
describe('GET /api/map', () => {
it('is listed by the API index', async () => {
const res = await request('/api');
@@ -407,13 +509,40 @@ describe('GET /api/map', () => {
// down joins that level's bucket rather than being promoted to a module.
expect(ids).toContain('src/core/passes');
expect(ids).toContain('src/core/(root files)');
expect(ids).toContain('src/api/(root files)');
expect(ids).not.toContain('src/core');
// …but the bucket keeps its name only because `src/core/passes` sits beside
// it. `src/api` has nothing below it, so its bucket IS `src/api` and saying
// otherwise would name a directory the repository does not have.
expect(ids).toContain('src/api');
expect(ids).not.toContain('src/api/(root files)');
const slashed = await getMap('?root=src%2F&depth=2');
expect(slashed.modules).toEqual(deep.modules);
});
it('counts the files outside each module that reference into it', async () => {
const map = await getMap('?root=src&depth=1');
const by = new Map<string, any>(map.modules.map((m: any) => [m.id, m]));
// `src/types.ts` and `src/index.ts` are what the rest of the fixture
// imports, so the bucket holding types is the most depended-on box.
const types = by.get('src/(root files)');
expect(types.dependents.files).toBeGreaterThan(0);
expect(types.dependents.modules).toBeGreaterThan(1);
// Every count is FILES OUTSIDE the module: never more than the rest of the
// repository, and a module's own internal imports never inflate it.
const total = map.modules.reduce((sum: number, m: any) => sum + m.files, 0);
for (const module of map.modules) {
expect(module.dependents.files).toBeLessThanOrEqual(total - module.files);
expect(module.dependents.modules).toBeLessThanOrEqual(map.modules.length - 1);
// A module nothing arrives at is an island, and the two must agree —
// they are computed from different queries and a reader sees both.
const arrives = map.links.some((l: any) => l.target === module.id);
if (!arrives) expect(module.dependents.files).toBe(0);
}
});
it('rejects an out-of-range depth as JSON, not as a crash', async () => {
const res = await request('/api/map?depth=9');
expect(res.status).toBe(400);
+47
View File
@@ -46,6 +46,7 @@ function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
test: over.test ?? false,
facade: over.facade ?? false,
fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
dependents: over.dependents ?? { files: 0, modules: 0 },
};
}
@@ -473,3 +474,49 @@ describe('directional ports and room', () => {
expect(wide.layers[0]!.y - wide.layers[1]!.y).toBe(NODE_HEIGHT + 116);
});
});
describe('how much leans on a box', () => {
it('scales the bar against the heaviest module DRAWN', () => {
const modules = [
mod('src/types', { dependents: { files: 90, modules: 5 } }),
mod('src/db', { dependents: { files: 45, modules: 3 } }),
mod('src/cli', { dependents: { files: 0, modules: 0 } }),
];
const links = [link('src/db', 'src/types', 20), link('src/cli', 'src/db', 20)];
const layout = buildMapLayout({ modules, links }, OPTS);
const weightOf = (id: string) => layout.nodes.find((n) => n.id === id)!.weight;
expect(weightOf('src/types')).toBe(1);
expect(weightOf('src/db')).toBeCloseTo(0.5, 5);
// Nothing depends on the CLI, so it draws no bar at all rather than a
// sliver a reader would have to squint at to call empty.
expect(weightOf('src/cli')).toBe(0);
});
it('rescales when a heavier test module joins the picture', () => {
const modules = [
mod('src/types', { dependents: { files: 40, modules: 4 } }),
mod('src/app', { dependents: { files: 10, modules: 1 } }),
mod('__tests__', { test: true, dependents: { files: 80, modules: 6 } }),
];
const links = [link('src/app', 'src/types', 20), link('__tests__', 'src/app', 20)];
const spec = { modules, links };
// Tests off: the app's own busiest box is the full bar.
const off = buildMapLayout(spec, { includeTests: false });
expect(off.nodes.find((n) => n.id === 'src/types')!.weight).toBe(1);
// Tests on: the scale moves, rather than leaving a bar running past a
// maximum the reader cannot see.
const on = buildMapLayout(spec, { includeTests: true });
expect(on.nodes.find((n) => n.id === 'src/types')!.weight).toBeCloseTo(0.5, 5);
expect(on.nodes.find((n) => n.id === '__tests__')!.weight).toBe(1);
});
it('says the count on the box, and says nothing when nothing depends on it', () => {
expect(moduleMetaLabel(mod('src/db', { dependents: { files: 45, modules: 3 } }))).toBe(
'30 symbols · 3 files · 45 depend on it'
);
expect(moduleMetaLabel(mod('src/cli'))).toBe('30 symbols · 3 files');
// An island's line is still the one sentence that matters about it.
expect(moduleMetaLabel(mod('src/cli'), true)).toBe('nothing depends on this');
});
});
+40
View File
@@ -674,6 +674,46 @@ describe('@colbymchenry/codegraph-ui — the seams', () => {
expect(symbolHref('function:x')).toBe('#/s/function%3Ax');
});
it('gives the Symbol tab an address of its own when no symbol is chosen', async () => {
const { parseHash } = await import('../ui/src/lib/router.svelte');
// The regression this pins: the tab used to fall back to `#/`, and `#/` is
// the landing page — which renders the SCREENS tab on any project that has
// screens. Clicking Symbol landed you on somebody else's view.
expect(symbolHref(null)).toBe('#/s');
expect(parseHash('#/').route.view).toBe('home');
const empty = parseHash(symbolHref(null)).route;
expect(empty.view).toBe('symbol');
expect(empty).toMatchObject({ view: 'symbol', id: null });
// …and a chosen symbol still round-trips, id and all.
const chosen = parseHash(symbolHref('function:x')).route;
expect(chosen).toMatchObject({ view: 'symbol', id: 'function:x' });
});
it('sends every nav tab to its own view', async () => {
const { parseHash } = await import('../ui/src/lib/router.svelte');
const { entryHref, screensHref, stepsHref, deadHref } = await import(
'../ui/src/lib/navigation'
);
// One href per tab in the top bar, each parsed back. A tab whose link
// resolves to a different tab's view is the bug above, in general form.
const tabs: Array<[string, string]> = [
['screens', screensHref()],
['steps', stepsHref()],
['entry', entryHref()],
['map', mapHref()],
['symbol', symbolHref(null)],
['flow', flowHref()],
['dead', deadHref()],
];
for (const [view, href] of tabs) {
expect(parseHash(href).route.view, `${href} should open the ${view} view`).toBe(view);
}
});
it('the default adapter is the loopback JSON API and asks for `api/...`', async () => {
const asked: string[] = [];
const adapter = createHttpAdapter({