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
+4
View File
@@ -201,6 +201,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
#### Symbols, tests and the viewer
- **The Map groups a repository the way that repository is shaped.** It always drew top-level directories, so a project whose whole program lives under one `src/` opened as a picture of four boxes — `src`, `ios`, `.github`, `(root files)` — with two thirds of the code inside one of them and nothing to say about it. The Map now picks its own grouping: the shallowest one that is not a single box holding the program, so a mobile app opens on `src/app`, `src/components`, `src/api`, `ios/CaptureView` and the rest, and a project packaged as `frontend/src/…` opens on the screens, components and reducers instead of on the word `frontend`. A repository whose top-level directories really are its modules is left exactly where it was. A new **Grouping** control on the right says which one was chosen and lets you take it a level in or out, and a leaf directory is now named for itself rather than as `…/(root files)`. Each box now also says how much leans on it — how many files elsewhere reference straight into it — with a bar along its bottom edge scaled against the most depended-on box on screen, so the folder you have to be careful with is the one you can see at a glance rather than the one with the longest name. The Map also has a **Key** now, like the Screens and Steps tabs — including what the dashed maroon lines mean, which only appear once you select a module: that module reaching back UP into something that depends on it.
- **The Symbol tab opens the Symbol tab.** With no symbol open and no trail to return to, clicking **Symbol** in the top bar took you to the landing page — which, on any project that has screens, is the Screens tab. So the button said Symbol and gave you somebody else's view. It now has an address of its own (`#/s`) that opens the "nothing selected" screen: the search prompt and the where-to-start list of routes, entry files and the symbols the most code depends on.
- **Files under an `e2e/` directory count as tests.** Their calls no longer appear as production callers in Steps, dead-code and test badges.
- **Production code under a `samples` or `examples` package path is no longer treated as test code.** A Kotlin or Java project whose package path runs through `com/google/samples/…` (Now in Android, for one) had nearly every file counted as a fixture, so the Map opened on `build-logic`, the entry points hid the app, and dead-code and test badges were wrong. Only the project layout above a `src/` folder decides now; the package path below it never does.
+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({
+7 -1
View File
@@ -207,7 +207,13 @@ with — so the strip and the MCP answer cannot disagree.
Grid: canvas `minmax(600px,1fr)` | side panel **320px** (`--rule-soft` left border, 14px 16px padding).
Nodes: rect `width = max(110, label.length × 7.3 + 28)`, **height 40**, `--paper` fill, 1px `--ink` stroke (2px + `--press` fill
when hovered/selected; `--ink-4` when dimmed; test modules dashed `4 3` in `--ink-3`), label 13px mono at (10,17), count
"N symbols · M files" 11px `--ink-3` at (10,32). Layers: vertical gap **74px**, horizontal gap **34px**, padding 44px; entry points at the
"N symbols · M files · R depend on it" 11px `--ink-3` at (10,32). **Weight bar:** 4px band inside the bottom edge, `--ink` at
0.3 (0.55 hovered/selected, 0.1 dimmed or generated), `width = node.width × (R / max R drawn)` — how much of the picture
leans on this box. `R` is `dependents.files`: files OUTSIDE the module holding a direct confident reference into one of its
files. **Direct, not transitive** — the transitive closure was measured and saturates on any repository with a dependency
cycle (139282 of 377 files on a real mobile app, a flat spread that only reports cyclicity), while the direct count on the
same repository spreads 0127 and names the modules a reader would name by hand. Relative to the heaviest box *drawn*, so
turning tests on rescales rather than overflowing a maximum nobody can see; a module with R=0 draws no bar at all. Layers: vertical gap **74px**, horizontal gap **34px**, padding 44px; entry points at the
top ("entry points" label), foundations at the bottom ("foundations — depend on nothing below"); faint layer lines `--rule-faint`.
Layout: aggregate edges by module; break 2-cycles keeping the heavier direction; longest-path layering (a module sits one layer
above everything it depends on); barycenter ordering, 3 sweeps; single-node layers centred; ports spread along each box
+229 -18
View File
@@ -126,6 +126,25 @@ export interface WireMapModule {
facade: boolean;
/** Its files, capped — what the side panel lists when the module is selected. */
fileList: WireList<string>;
/**
* What a change in here reaches: files OUTSIDE this module holding a direct,
* confident reference into one of its files, and how many modules those
* files span.
*
* DIRECT, deliberately. The transitive closure was measured first and it is
* useless on a real repository: any dependency cycle and a mobile app had
* nine mutual pairs saturates it, so every module comes out reaching
* nearly every file (139282 of 377, a flat 2× spread that says nothing but
* "this repo has cycles"). The direct count on the same repository spreads
* 0127 and names the modules a reader would name by hand: the shared types
* at the top, the CLI at zero.
*
* The counts are FILES, not symbols: a module is a set of files, and "94
* files would have to be re-read if this changed" is a claim the index can
* stand behind. It is a floor on blast radius, not the whole of it a
* symbol-level answer for one symbol is what the Symbol view is for.
*/
dependents: { files: number; modules: number };
}
export interface WireMapLink {
@@ -276,6 +295,107 @@ export function pickDefaultRoot(
return bestSymbols * 2 > total ? best : '';
}
/**
* A box holding more than this share of the mapped symbols IS the program, and
* a map whose subject is one box has not said anything.
*/
const DOMINANT_SHARE = 0.4;
/**
* but only if there is something inside it. A dominant box of four files is a
* small project honestly drawn; opening it just spreads four files over four
* boxes. This is the line between "grouped too coarsely" and "actually small".
*/
const DOMINANT_MIN_FILES = 25;
/** Fewer boxes than this is a list, not a picture. */
const MIN_MODULES = 4;
/** More than this and a deeper grouping has traded one unreadable map for another. */
const MAX_MODULES = 60;
/** The non-test modules a given depth would draw, and how concentrated they are. */
function tallyModules(
files: ReadonlyArray<{ path: string; symbols: number; test: boolean }>,
root: string,
depth: number
): { count: number; share: number; largestFiles: number } {
const byModule = new Map<string, { symbols: number; files: number }>();
let total = 0;
for (const file of files) {
if (file.test) continue;
const assigned = moduleIdFor(file.path, root, depth);
if (assigned === null) continue;
let entry = byModule.get(assigned.id);
if (!entry) byModule.set(assigned.id, (entry = { symbols: 0, files: 0 }));
entry.symbols += file.symbols;
entry.files += 1;
total += file.symbols;
}
let largest = { symbols: 0, files: 0 };
for (const entry of byModule.values()) {
if (entry.symbols > largest.symbols) largest = entry;
}
return {
count: byModule.size,
share: total === 0 ? 0 : largest.symbols / total,
largestFiles: largest.files,
};
}
/**
* How many segments name a module, when the reader has not said.
*
* Depth is not a property of the reader's taste, it is a property of the
* repository: one level under the root is the right grouping for a project
* whose directories ARE its modules, and the wrong one for the very common
* shape where every line of the program lives under a single `src/`. Drawing
* that project at depth 1 produces the map this rule exists to prevent a box
* labelled `src`, holding two thirds of the code, with nothing to say about it.
*
* So: take the shallowest depth that is neither dominated by one box worth
* opening nor too small to be a picture; stop before a deeper one becomes a
* crowd; and never go past the last level the directory tree actually has.
*
* The walk does NOT stop at the first depth that fails to add boxes. A repo
* packaged as `frontend/src/...` plateaus at two boxes for two levels running
* before the third splits it, and a rule that gave up on the plateau would
* draw exactly the picture this function exists to avoid.
*/
export function pickDefaultDepth(
files: ReadonlyArray<{ path: string; symbols: number; test: boolean }>,
root: string
): number {
// Past the deepest directory, a bigger number only renames boxes to
// `src/a/(root files)`. There is nothing below the leaves.
let deepest = DEFAULT_DEPTH;
for (const file of files) {
if (file.test) continue;
const path = toPosixPath(file.path);
if (root && !path.startsWith(`${root}/`)) continue;
const rel = root ? path.slice(root.length + 1) : path;
deepest = Math.max(deepest, rel.split('/').filter(Boolean).length - 1);
}
let fallback = DEFAULT_DEPTH;
let fallbackCount = 0;
for (let depth = DEFAULT_DEPTH; depth <= Math.min(MAX_DEPTH, deepest); depth += 1) {
const tally = tallyModules(files, root, depth);
if (tally.count === 0) break;
// Deeper only gets more crowded from here.
if (tally.count > MAX_MODULES) break;
const dominated = tally.share > DOMINANT_SHARE && tally.largestFiles >= DOMINANT_MIN_FILES;
if (tally.count >= MIN_MODULES && !dominated) return depth;
// Not a picture yet. Worth keeping only if it drew more than the last one:
// a deeper grouping that splits nothing is the same map with longer labels.
if (tally.count > fallbackCount) {
fallback = depth;
fallbackCount = tally.count;
}
}
return fallback;
}
// =============================================================================
// Cache
// =============================================================================
@@ -301,9 +421,17 @@ export function resetMapCache(): void {
// Build
// =============================================================================
export function parseMapQuery(query: URLSearchParams): { root: string | null; depth: number } {
/**
* `null` for either field means "nobody said" the answer picks. Absence has
* to survive parsing: a depth defaulted to 1 here is indistinguishable from a
* reader who asked for 1, and {@link pickDefaultDepth} would never run.
*/
export function parseMapQuery(query: URLSearchParams): {
root: string | null;
depth: number | null;
} {
const rawDepth = query.get('depth');
let depth = DEFAULT_DEPTH;
let depth: number | null = null;
if (rawDepth !== null && rawDepth !== '') {
depth = Number.parseInt(rawDepth, 10);
if (!Number.isFinite(depth) || depth < 1 || depth > MAX_DEPTH) {
@@ -314,9 +442,45 @@ export function parseMapQuery(query: URLSearchParams): { root: string | null; de
return { root: rawRoot === null ? null : normalizeRoot(rawRoot), depth };
}
/**
* Rename `x/(root files)` to `x` wherever the bucket is all `x` has.
*
* The bucket earns its name only when it stands beside something: `src` holding
* both `src/api` and three loose files needs a box for the loose ones, and that
* box has to say it is not the whole of `src`. But a `backend/controllers` with
* no subdirectories in it is not a directory with a bucket in it it IS the
* directory, and drawing it as `backend/controllers/(root files)` names a thing
* the repository does not have. Deeper groupings hit this constantly (every
* leaf directory becomes a bucket), which is what makes it worth a pass.
*
* Returns only the ids that move, so a caller can leave the rest alone.
*/
function collapseLoneRootFiles(ids: ReadonlySet<string>): Map<string, string> {
const renamed = new Map<string, string>();
for (const id of ids) {
const cut = id.lastIndexOf('/(root files)');
// A bucket at the very top (`(root files)`) has no directory to become.
if (cut <= 0 || cut + '/(root files)'.length !== id.length) continue;
const dir = id.slice(0, cut);
let alone = true;
for (const other of ids) {
// A façade counts: `src/utils` beside `src/utils/index.tsx` would read as
// if the box contained the file drawn next to it.
if (other !== id && other.startsWith(`${dir}/`)) {
alone = false;
break;
}
}
// `dir` can only already be a module if something lives BELOW it, which is
// exactly the case `alone` just ruled out — so this rename cannot collide.
if (alone) renamed.set(id, dir);
}
return renamed;
}
export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchParams): WireMapPayload {
const started = Date.now();
let { root: requestedRoot, depth } = parseMapQuery(query);
const { root: requestedRoot, depth: requestedDepth } = parseMapQuery(query);
const fileRecords = cg.getFiles().map((file) => {
const path = toPosixPath(file.path);
@@ -330,10 +494,10 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
});
const root = requestedRoot ?? pickDefaultRoot(fileRecords);
// Left to choose, and choosing the whole project (two substantial roots):
// one level deeper, so the boxes are `src/app` and `ios/CaptureView`, not
// `src` and `ios`.
if (requestedRoot === null && root === '' && !query.has('depth')) depth = 2;
// Root first, then depth against THAT root: how finely to cut depends on
// what is being cut. Choosing `src` and then asking for one level under it
// is the same question as choosing the whole project and asking for two.
const depth = requestedDepth ?? pickDefaultDepth(fileRecords, root);
const stats = cg.getStats();
const key = [
projectRoot,
@@ -367,16 +531,24 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
>();
const moduleOfFile = new Map<string, string>();
const assigned = new Map<string, { id: string; facade: boolean }>();
for (const file of fileRecords) {
const assigned = moduleIdFor(file.path, root, depth);
if (assigned === null) continue;
assignments.push({ filePath: file.path, module: assigned.id });
moduleOfFile.set(file.path, assigned.id);
let entry = modules.get(assigned.id);
const at = moduleIdFor(file.path, root, depth);
if (at !== null) assigned.set(file.path, at);
}
const renamed = collapseLoneRootFiles(new Set([...assigned.values()].map((a) => a.id)));
for (const file of fileRecords) {
const at = assigned.get(file.path);
if (at === undefined) continue;
const id = renamed.get(at.id) ?? at.id;
assignments.push({ filePath: file.path, module: id });
moduleOfFile.set(file.path, id);
let entry = modules.get(id);
if (!entry) {
entry = {
id: assigned.id,
facade: assigned.facade,
id,
facade: at.facade,
files: 0,
symbols: 0,
testFiles: 0,
@@ -385,7 +557,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
languages: new Map(),
paths: [],
};
modules.set(assigned.id, entry);
modules.set(id, entry);
}
entry.files += 1;
entry.paths.push(file.path);
@@ -438,6 +610,12 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
}
}
// ONE fetch of the file edge list, read twice: the cycle finder and the
// dependent counts are both questions about it, and it is the expensive query
// on this screen.
const filePairs = cg.getFileDependencyPairs(UNCERTAIN_BELOW);
const dependents = countDependents(filePairs, moduleOfFile);
const payload: WireMapPayload = {
root,
depth,
@@ -460,6 +638,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
// draws are the same list — the count-equals-list rule.
generatedFiles: shown.filter((path) => entry.generatedPaths.has(path)),
fileList: wireList(shown, entry.files),
dependents: dependents.get(entry.id) ?? { files: 0, modules: 0 },
};
})
// Sorted so two runs over one index produce byte-identical payloads —
@@ -468,7 +647,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
links: [...links.values()].sort(
(a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target)
),
cycles: fileCycles(cg, moduleOfFile),
cycles: fileCycles(filePairs, moduleOfFile),
excluded: { uncertainEdges, confidenceBelow: UNCERTAIN_BELOW },
index: {
lastIndexedAt: cg.getLastIndexedAt(),
@@ -486,6 +665,38 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
return payload;
}
/**
* Per module: how many files outside it reference into it, and across how many
* modules those files sit.
*
* One pass over the edge list. A pair whose two ends land in the same module is
* internal cohesion, not blast radius, and is skipped; a pair touching a file
* outside the chosen root has no module and is skipped too. The `Set` per
* module is what makes the count DISTINCT FILES rather than distinct
* references twelve calls from one file are one file that has to be re-read.
*/
function countDependents(
pairs: ReadonlyArray<{ source: string; target: string }>,
moduleOfFile: Map<string, string>
): Map<string, { files: number; modules: number }> {
const incoming = new Map<string, Set<string>>();
for (const pair of pairs) {
const from = moduleOfFile.get(pair.source);
const to = moduleOfFile.get(pair.target);
if (from === undefined || to === undefined || from === to) continue;
let seen = incoming.get(to);
if (!seen) incoming.set(to, (seen = new Set()));
seen.add(pair.source);
}
const out = new Map<string, { files: number; modules: number }>();
for (const [module, files] of incoming) {
const modules = new Set<string>();
for (const file of files) modules.add(moduleOfFile.get(file)!);
out.set(module, { files: files.size, modules: modules.size });
}
return out;
}
/**
* File-level circular dependencies, as strongly connected components.
*
@@ -496,11 +707,11 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
* list anybody reads.
*/
function fileCycles(
cg: CodeGraph,
pairs: ReadonlyArray<{ source: string; target: string }>,
moduleOfFile: Map<string, string>
): WireMapPayload['cycles'] {
const adjacency = new Map<string, string[]>();
for (const pair of cg.getFileDependencyPairs(UNCERTAIN_BELOW)) {
for (const pair of pairs) {
if (!moduleOfFile.has(pair.source) || !moduleOfFile.has(pair.target)) continue;
let out = adjacency.get(pair.source);
if (!out) adjacency.set(pair.source, (out = []));
+5 -2
View File
@@ -81,7 +81,8 @@
const encoded = router.params.get('t');
untrack(() => {
trail.hydrate(encoded);
if (current.view === 'symbol' && trail.current?.id !== current.id) {
// `id: null` is the tab with nothing chosen — there is no hop to record.
if (current.view === 'symbol' && current.id !== null && trail.current?.id !== current.id) {
trail.push({ id: current.id });
}
});
@@ -157,7 +158,7 @@
<TopBar bind:this={topbar} project={project.name} stats={project.summary} showScreens={hasScreens} />
<TrailBar />
<main>
{#if route.view === 'symbol'}
{#if route.view === 'symbol' && route.id !== null}
<SymbolView id={route.id} line={route.line} />
{:else if route.view === 'file' && route.source}
<FileCodeView path={route.path} line={route.line} />
@@ -175,6 +176,8 @@
{:else if route.view === 'entry'}
<EntryView project={project.name} />
{:else if route.view === 'screens' || (route.view === 'home' && hasScreens)}
<!-- `home` renders Screens when the project has any, which is why the
Symbol tab needs its own `#/s` and must never fall back to `#/`. -->
<ScreensView />
{:else if route.view === 'steps'}
<StepsView anchor={route.anchor} symbol={route.symbol} depth={route.depth} through={route.through} reading={route.reading} />
+6 -3
View File
@@ -20,12 +20,15 @@
let view = $derived(router.route.view);
// The Symbol tab returns you to where you were reading, not to a blank
// view: the current symbol if you are on one, else the trail's last hop.
// view: the current symbol if you are on one, else the trail's last hop
// and failing both, the tab's own empty screen. NOT `#/`: the landing page
// renders the Screens tab on any project that has screens, so that fallback
// sent a reader who clicked Symbol to somebody else's view.
let symbolTabHref = $derived.by(() => {
const route = router.route;
if (route.view === 'symbol') return symbolHref(route.id);
if (route.view === 'symbol' && route.id !== null) return symbolHref(route.id);
const current = trail.current;
return current ? symbolHref(current.id) : '#/';
return symbolHref(current ? current.id : null);
});
/** What `/` and Cmd-K reach — the palette owns its own keyboard. */
+189
View File
@@ -0,0 +1,189 @@
<script lang="ts">
/**
* The Map's key (design spec §3.6), matching the Screens and Steps views'.
*
* Each row draws the actual stroke or box rather than a word for it — a
* reader matches shapes. Two rows here exist because the Map hides things at
* rest and a picture that hides must say so: the thin links, and the dashed
* back-edges that appear only once a module is selected. A reader who selects
* `src/utils` and watches four maroon dashes appear has no way to guess what
* they are, and the side panel's prose is not where anyone looks for a stroke.
*/
interface Props {
/** The weight below which a link waits for a selection. */
minWeight: number;
/** How many links are waiting on one right now; the row is skipped at zero. */
thinCount: number;
/** Whether the vertical order came from declared edges or from raw counts. */
declaredBasis: boolean;
open: boolean;
onToggle: (open: boolean) => void;
}
let { minWeight, thinCount, declaredBasis, open, onToggle }: Props = $props();
</script>
<div class="legend" class:open>
<button class="legend-h" onclick={() => onToggle(!open)} aria-expanded={open}>
Key <span class="dim">{open ? '▾' : '▸'}</span>
</button>
{#if open}
<div class="legend-body">
<div class="lrow">
<span class="k-box mono">src/api</span>
<span>A module — one directory, with the symbols and files in it</span>
</div>
<div class="lrow">
<span class="k-box k-weight mono">src/db</span>
<span>
The bar along the bottom is how much leans on it — files elsewhere that reference
straight into it, against the most depended-on box here. The count is on the box
</span>
</div>
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
<span>
Depends on — the box above calls, imports, extends or names a type from the box below.
Thicker is more references{declaredBasis
? ''
: '; here the layering had too few imports to trust, so it used raw counts'}
</span>
</div>
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-back" /></svg>
<span>
Points back up — the lighter half of a mutual dependency, or a link with no import or
declared type behind it. Drawn only while a module it touches is selected
</span>
</div>
<div class="lrow">
<span class="k-label">top / bottom</span>
<span>
A module sits one layer above everything it depends on, so entry points end up at the top
and the foundations — which depend on nothing below — at the bottom
</span>
</div>
<div class="lrow">
<span class="k-box k-sel mono">src/api</span>
<span>Selected: click a module to bring out its links and list its files; everything more than one hop away fades</span>
</div>
<div class="lrow">
<span class="k-label">nothing depends on this</span>
<span>No link in the index arrives here — a script, a workflow, an unreferenced corner</span>
</div>
<div class="lrow">
<span class="k-box k-test mono">__tests__</span>
<span>More than half its files are tests; off unless you turn tests on</span>
</div>
<div class="lrow">
<span class="k-box k-gen mono">gen</span>
<span>Every file in it is tool-generated — nobody wrote it and nobody edits it</span>
</div>
{#if thinCount > 0}
<div class="lrow">
<span class="k-label">{thinCount} hidden</span>
<span>
Links carrying fewer than {minWeight} references wait until you select a module they
touch, so a weak coincidence never draws as a dependency
</span>
</div>
{/if}
</div>
{/if}
</div>
<style>
.legend {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 4;
max-width: 400px;
border: 1px solid var(--rule);
background: var(--paper);
font-size: 11.5px;
color: var(--ink-2);
}
.legend-h {
display: block;
width: 100%;
border: 0;
background: transparent;
padding: 5px 10px;
text-align: left;
color: var(--ink);
font: 600 12px var(--sans);
cursor: pointer;
}
.legend-body {
padding: 2px 10px 8px;
border-top: 1px solid var(--rule-soft);
}
.lrow {
display: flex;
align-items: center;
gap: 10px;
padding: 3px 0;
}
.lrow > :first-child {
flex: 0 0 52px;
display: inline-flex;
justify-content: center;
}
.k-line {
stroke: var(--ink);
stroke-opacity: 0.6;
stroke-width: 1.5;
fill: none;
}
.k-line.k-back {
stroke: var(--accent);
stroke-opacity: 0.8;
stroke-dasharray: 4 3;
}
.k-label {
font-size: 10px;
color: var(--ink-3);
text-align: center;
line-height: 1.2;
}
.k-box {
box-sizing: border-box;
padding: 1px 5px;
border: 1px solid var(--ink);
font-size: 10.5px;
color: var(--ink);
line-height: 14px;
}
/* The bar, drawn the way the canvas draws it: inside the bottom edge. */
.k-box.k-weight {
position: relative;
}
.k-box.k-weight::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 68%;
height: 4px;
background: var(--ink);
opacity: 0.3;
}
/* The same three treatments the canvas uses, at key size. */
.k-box.k-sel {
border-width: 2px;
background: var(--press);
}
.k-box.k-test {
border-style: dashed;
border-color: var(--ink-3);
color: var(--ink-3);
}
.k-box.k-gen {
border-color: var(--ink-4);
color: var(--ink-4);
}
.dim {
color: var(--ink-3);
}
</style>
+55
View File
@@ -25,6 +25,9 @@
files: string[];
onToggleTests: (value: boolean) => void;
onSelectRoot: (root: string) => void;
/** What the reader asked for, or `null` when the depth in `payload` was chosen for them. */
chosenDepth: number | null;
onSelectDepth: (depth: number | null) => void;
onSelect: (id: string | null) => void;
/** Builds the map as an SVG at a given device-pixel scale. */
buildSvg: (scale: number) => string;
@@ -40,11 +43,31 @@
files,
onToggleTests,
onSelectRoot,
chosenDepth,
onSelectDepth,
onSelect,
buildSvg,
exportName,
}: Props = $props();
/**
* The grouping options.
*
* The first one is the default and is not a number: the answering side reads
* the repository and picks the shallowest grouping that is not one box
* holding the whole program. The numbers below it are there for when its
* choice is wrong for what the reader is looking at — an escape hatch, not
* the thing anybody should have to reach for.
*/
const DEPTHS = [1, 2, 3, 4] as const;
function depthLabel(depth: number): string {
return depth === 1 ? 'top-level folders' : `${depth} folders deep`;
}
/** An em dash the mono face has; the select is narrow enough to notice a tofu. */
const DASH = '\u2014';
const selectedNode = $derived(
selected === null ? null : (layout.nodes.find((n) => n.id === selected) ?? null)
);
@@ -96,6 +119,25 @@
</select>
</label>
<!-- The grouping. A repository whose whole program sits under one directory
draws as one box at the shallowest setting, which is why the default is
chosen from the repository rather than fixed at 1. -->
<label class="field">
<span>Grouping</span>
<select
value={chosenDepth === null ? 'auto' : String(chosenDepth)}
onchange={(event) => {
const value = (event.currentTarget as HTMLSelectElement).value;
onSelectDepth(value === 'auto' ? null : Number(value));
}}
>
<option value="auto">automatic {DASH} {depthLabel(payload.depth)}</option>
{#each DEPTHS as option (option)}
<option value={String(option)}>{depthLabel(option)}</option>
{/each}
</select>
</label>
<label class="toggle">
<input
type="checkbox"
@@ -215,6 +257,15 @@
{/if}
</p>
{#if (selectedModule.dependents?.files ?? 0) > 0}
<p class="reach">
<b>{plural(selectedModule.dependents.files, 'file')}</b> outside it, across
{plural(selectedModule.dependents.modules, 'module')}, reference straight into it — the
floor on what a change here has to be checked against, and the bar along the bottom of
the box.
</p>
{/if}
{#if selectedNode?.island}
<p class="island">
Nothing in the index depends on this module — no import, call or reference crosses into
@@ -289,6 +340,10 @@
font-size: 11.5px;
margin-bottom: 8px;
}
.reach {
font-size: 11.5px;
margin: 0 0 8px;
}
.field {
display: flex;
gap: 8px;
+33
View File
@@ -52,6 +52,10 @@
aria-pressed={node.selected}
title={`${module.id} ${module.symbols} symbols in ${module.files} file${
module.files === 1 ? '' : 's'
}${
(module.dependents?.files ?? 0) > 0
? `. ${module.dependents.files} file${module.dependents.files === 1 ? '' : 's'} outside it, across ${module.dependents.modules} module${module.dependents.modules === 1 ? '' : 's'}, reference into it.`
: ''
}${layout.island ? '. Nothing in the index depends on it.' : ''}${
layout.generated ? '. Every file in it is tool-generated.' : ''
}`}
@@ -61,6 +65,12 @@
<span class="count" class:island={layout.island}
>{moduleMetaLabel(module, layout.island)}</span
>
<!-- How much leans on this box, as a share of the heaviest one drawn. Inside
the border rather than on it, so it reads as a level in the box and not
as a second, thicker edge. -->
{#if layout.weight > 0}
<span class="weight" style={`width:${(layout.weight * 100).toFixed(1)}%`}></span>
{/if}
</button>
{#each layout.sourceHandles as handle, i (handle)}
@@ -75,6 +85,7 @@
<style>
.mnode {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
@@ -125,6 +136,28 @@
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* A wash, not a rule: it is a quantity the eye should compare across boxes at
a glance, never a line competing with the box's own border. */
.weight {
position: absolute;
left: 0;
bottom: 0;
height: 4px;
background: var(--ink);
/* Dark enough to survive the fit: the map opens as far out as 0.45, where a
3px band at 0.18 was a rumour. Length is what carries the comparison, and
length cannot be read off a stroke the eye has to hunt for. */
opacity: 0.3;
pointer-events: none;
}
.mnode:hover .weight,
.mnode.sel .weight {
opacity: 0.55;
}
.mnode.dimmed .weight,
.mnode.gen .weight {
opacity: 0.1;
}
.name {
font: 500 13px var(--mono);
line-height: 15px;
+14 -1
View File
@@ -172,6 +172,7 @@ function rect(
h: number,
attrs: {
fill?: string;
fillOpacity?: number;
stroke?: string;
strokeWidth?: number;
dash?: string;
@@ -184,6 +185,7 @@ function rect(
`height="${round(h)}"`,
`fill="${attrs.fill ?? 'none'}"`,
];
if (attrs.fillOpacity !== undefined) parts.push(`fill-opacity="${attrs.fillOpacity}"`);
if (attrs.stroke) {
parts.push(`stroke="${attrs.stroke}"`, `stroke-width="${attrs.strokeWidth ?? 1}"`);
if (attrs.dash) parts.push(`stroke-dasharray="${attrs.dash}"`);
@@ -837,9 +839,20 @@ function mapNodeSvg(node: MapNodeLayout, selected: boolean, dimmed: boolean): st
size: MODULE_META_SIZE,
fill: dimmed ? EXPORT_COLORS.ink4 : EXPORT_COLORS.ink3,
},
esc(truncate(moduleMetaLabel(module), room, MODULE_META_SIZE, SANS_ADVANCE))
// `node.island`, matching the canvas: an exported map that counts a
// module the screen said nothing depends on is a different picture.
esc(truncate(moduleMetaLabel(module, node.island), room, MODULE_META_SIZE, SANS_ADVANCE))
)
);
// The weight bar, same 3px inside the bottom edge as the canvas draws.
if (node.weight > 0) {
out.push(
rect(node.x, node.y + node.height - 4, node.width * node.weight, 4, {
fill: EXPORT_COLORS.ink,
fillOpacity: dimmed ? 0.1 : 0.3,
})
);
}
return out.join('');
}
+25 -1
View File
@@ -122,7 +122,16 @@ export function moduleMetaLabel(module: WireMapModule, island = false): string {
if (island) return 'nothing depends on this';
const symbols = `${module.symbols} symbol${module.symbols === 1 ? '' : 's'}`;
const files = `${module.files} file${module.files === 1 ? '' : 's'}`;
return `${symbols} · ${files}`;
// How big a change here is, said in the same breath as how big the module is.
// Two boxes of 20 files are not the same box when one of them is imported by
// ninety files and the other by two, and until this line the picture had no
// channel that said so — width tracked the length of the PATH.
// `?.` because `GraphAdapter` is a public seam: a host that assembles this
// payload itself and has not caught up to the field must lose the bar, not
// the screen. Every other read of `dependents` goes through this one.
const reach = module.dependents?.files ?? 0;
const depend = reach > 0 ? ` · ${reach} depend on it` : '';
return `${symbols} · ${files}${depend}`;
}
/** One port on a box's edge: the link it belongs to, and which end of it this is. */
@@ -144,6 +153,17 @@ export interface MapNodeLayout {
island: boolean;
/** Every file in it is tool-generated, so it draws in ink-4. */
generated: boolean;
/**
* How much of the picture leans on this box, 0..1, as a share of the
* most-depended-on box DRAWN the bar along the bottom of the node.
*
* Relative rather than absolute because there is no absolute scale a reader
* could calibrate against: 94 dependent files is enormous in a 377-file app
* and unremarkable in a monorepo. Relative to what is on screen, the longest
* bar always means "this is the one to be careful with, here". The absolute
* number is on the box beside it, so the bar never has to be trusted alone.
*/
weight: number;
layer: number;
x: number;
y: number;
@@ -417,6 +437,9 @@ export function buildMapLayout(
const nodesById = new Map<string, MapNodeLayout>();
const byId = new Map(modules.map((m) => [m.id, m]));
// The busiest box DRAWN sets the scale — so turning tests on rescales the
// bars rather than leaving a test module's bar overflowing a hidden maximum.
const heaviest = Math.max(0, ...modules.map((m) => m.dependents?.files ?? 0));
rows.forEach((row, index) => {
const span = rowSpans[index] ?? 0;
const sum = rowSums[index] ?? 0;
@@ -435,6 +458,7 @@ export function buildMapLayout(
// Every file generated, not merely some: a module with one `.pb.go` in
// it is still a module somebody writes by hand.
generated: module.files > 0 && module.generated === module.files,
weight: heaviest === 0 ? 0 : (module.dependents?.files ?? 0) / heaviest,
layer: index,
x,
y,
+17 -4
View File
@@ -39,7 +39,8 @@ export interface FileHrefOptions {
export interface MapHrefOptions {
root?: string | null;
depth?: number;
/** Absent or null leaves the grouping to the answering side. */
depth?: number | null;
tests?: boolean;
}
@@ -79,7 +80,14 @@ export interface StepsHrefOptions {
* serve.
*/
export interface NavigationDriver {
symbolHref(id: string, opts?: SymbolHrefOptions): string;
/**
* A symbol's page or, with `null`, the Symbol tab with nothing chosen yet.
*
* The null case has to be addressable. Without it the tab had no href of its
* own and fell back to the landing page, which on a project that HAS screens
* is the Screens tab: clicking Symbol landed you on somebody else's view.
*/
symbolHref(id: string | null, opts?: SymbolHrefOptions): string;
fileHref(path: string, opts?: FileHrefOptions): string;
mapHref(opts?: MapHrefOptions): string;
flowHref(opts?: FlowHrefOptions): string;
@@ -116,6 +124,9 @@ export const hashNavigation: NavigationDriver = {
const params = new URLSearchParams();
if (opts.trail) params.set('t', opts.trail);
if (opts.line) params.set('hl', String(opts.line));
// No id: the tab itself. `#/s` rather than `#/s/` so the segment filter
// cannot read an empty id back out of it.
if (!id) return `#/s${query(params)}`;
return `#/s/${encodePath(id)}${query(params)}`;
},
@@ -131,7 +142,9 @@ export const hashNavigation: NavigationDriver = {
mapHref(opts = {}) {
const params = new URLSearchParams();
if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
if (opts.depth && opts.depth !== 1) params.set('depth', String(opts.depth));
// Including 1: a reader who asked for top-level directories has said
// something, and dropping it would hand the choice back to the answer.
if (opts.depth) params.set('depth', String(opts.depth));
if (opts.tests) params.set('tests', '1');
return `#/map${query(params)}`;
},
@@ -224,7 +237,7 @@ export function getNavigationDriver(): NavigationDriver {
/* --------------------------- what the components actually call ----------- */
export function symbolHref(id: string, opts: SymbolHrefOptions = {}): string {
export function symbolHref(id: string | null, opts: SymbolHrefOptions = {}): string {
return driver.symbolHref(id, opts);
}
+2
View File
@@ -339,6 +339,7 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
generatedFiles: [],
facade: false,
fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
dependents: { files: 0, modules: 0 },
});
}
// Each decision is a point of its own on the canvas: a small box asking the
@@ -357,6 +358,7 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
generatedFiles: [],
facade: false,
fileList: { total: 0, shown: 0, truncated: false, items: [] },
dependents: { files: 0, modules: 0 },
});
}
const drawn = (id: string): boolean => nodes.has(id) || forks.has(id);
+12 -6
View File
@@ -59,7 +59,8 @@ export type {
export type Route =
| { view: 'home' }
| { view: 'symbol'; id: string; line: number | null }
/** `id: null` = the Symbol tab, nothing chosen — the empty screen. */
| { view: 'symbol'; id: string | null; line: number | null }
| {
view: 'file';
path: string;
@@ -67,7 +68,7 @@ export type Route =
/** The whole-file source view rather than the outline (design spec §3.4). */
source: boolean;
}
| { view: 'map'; root: string | null; depth: number; tests: boolean }
| { view: 'map'; root: string | null; depth: number | null; tests: boolean }
| {
view: 'flow';
/** "how does X reach Y" — both ends pinned. */
@@ -135,19 +136,24 @@ export function parseHash(hash: string): RouterLocation {
let route: Route;
if (head === undefined) {
route = { view: 'home' };
} else if (head === 's' && rest.length > 0) {
route = { view: 'symbol', id: rest.join('/'), line };
} else if (head === 's') {
// `#/s` on its own is the tab, not a 404: nothing is chosen yet.
route = { view: 'symbol', id: rest.length > 0 ? rest.join('/') : null, line };
} else if (head === 'file' && rest.length > 0) {
route = { view: 'file', path: rest.join('/'), line, source: params.get('src') === '1' };
} else if (head === 'map' && rest.length === 0) {
// The map's shape travels in the URL like the trail does: a link to
// "src/vs at depth 2, tests on" has to reopen the same picture.
// "src/vs at depth 2, tests on" has to reopen the same picture. Absent, it
// stays absent: the answering side reads the repository and picks a depth,
// and a 1 defaulted in here would silently override that with the one
// grouping — top-level directories — that is wrong for every project whose
// program lives under a single `src/`.
const root = params.get('root');
const depth = Number.parseInt(params.get('depth') ?? '', 10);
route = {
view: 'map',
root: root === null ? null : root,
depth: Number.isFinite(depth) && depth >= 1 && depth <= 4 ? depth : 1,
depth: Number.isFinite(depth) && depth >= 1 && depth <= 4 ? depth : null,
tests: params.get('tests') === '1',
};
} else if (head === 'entry' && rest.length === 0) {
+2
View File
@@ -398,6 +398,8 @@ function moduleFor(info: ScreenNodeInfo, symbols: number): WireMapModule {
generatedFiles: [],
facade: false,
fileList: { total: 1, shown: 1, truncated: false, items: [info.screen?.file ?? info.sub] },
// Not the Map: a screen has no dependent count and draws no weight bar.
dependents: { files: 0, modules: 0 },
};
}
+3
View File
@@ -385,6 +385,8 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
generatedFiles: [],
facade: false,
fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
// Not the Map: a step has no dependent count and draws no weight bar.
dependents: { files: 0, modules: 0 },
});
}
@@ -716,6 +718,7 @@ function packRegions(
module: moduleOf.get(id)!,
island: false,
generated: false,
weight: 0,
layer: layerOf(id),
x,
y: yy,
+6
View File
@@ -610,6 +610,12 @@ export interface WireMapModule {
facade: boolean;
/** Its files, capped — the side panel's list when the module is selected. */
fileList: { total: number; shown: number; truncated: boolean; items: string[] };
/**
* Files OUTSIDE this module with a direct reference into it, and how many
* modules they span what a change in here reaches. Direct, not transitive:
* a cycle saturates the transitive count and it stops discriminating.
*/
dependents: { files: number; modules: number };
}
export interface WireMapLink {
+46 -3
View File
@@ -18,6 +18,7 @@
import ModuleNode from '../components/map/ModuleNode.svelte';
import ModuleEdge from '../components/map/ModuleEdge.svelte';
import MapSidePanel from '../components/map/MapSidePanel.svelte';
import MapKey from '../components/map/MapKey.svelte';
import { exportFilename, mapSvg } from '../lib/export-svg';
import { fetchMap, type WireMapPayload } from '../lib/api';
import { live } from '../lib/live.svelte';
@@ -31,7 +32,8 @@
interface Props {
root: string | null;
depth: number;
/** `null` = nobody has chosen; the answer picks a grouping for this repo. */
depth: number | null;
tests: boolean;
}
@@ -55,6 +57,26 @@
*/
const FIT = { fitViewOptions: { padding: 0.12, maxZoom: 1, minZoom: 0.45 } };
// The key stays open until the reader closes it; the choice survives a reload
// but is per browser — a preference, not a fact about the project. Same
// storage shape as the Screens and Steps keys.
const LEGEND_KEY = 'codegraph-ui:map-legend';
let legendOpen = $state(readLegendOpen());
function readLegendOpen(): boolean {
try {
return localStorage.getItem(LEGEND_KEY) !== 'closed';
} catch {
return true;
}
}
$effect(() => {
try {
localStorage.setItem(LEGEND_KEY, legendOpen ? 'open' : 'closed');
} catch {
// Storage refused (private mode): the key simply reopens next time.
}
});
const nodeTypes = { module: ModuleNode };
const edgeTypes = { module: ModuleEdge };
@@ -72,7 +94,7 @@
const controller = new AbortController();
loading = true;
error = null;
fetchMap({ root: wantRoot, depth: wantDepth }, controller.signal)
fetchMap({ root: wantRoot, depth: wantDepth ?? undefined }, controller.signal)
.then((next) => {
payload = next;
loading = false;
@@ -165,7 +187,16 @@
function setRoot(next: string): void {
selected = null;
navigate(mapHref({ root: next, depth, tests }));
// Deliberately dropping the depth: how finely to cut `ios` is a different
// question from how finely to cut the whole project, and carrying the old
// answer over is how a reader lands on a one-box map.
navigate(mapHref({ root: next, tests }));
}
/** `null` hands the grouping back to the answering side. */
function setDepth(next: number | null): void {
selected = null;
navigate(mapHref({ root, depth: next, tests }));
}
/**
@@ -191,6 +222,7 @@
selected = null;
navigate(mapHref({ root, depth, tests: next }));
}
</script>
<div class="mapview">
@@ -256,6 +288,15 @@
<Controls position="bottom-right" showLock={false} />
</SvelteFlow>
<!-- The key, on the picture it explains. -->
<MapKey
minWeight={layout.minWeight}
thinCount={layout.edges.filter((e) => e.thin && !e.back).length}
declaredBasis={layout.basis.kind === 'declared'}
open={legendOpen}
onToggle={(next) => (legendOpen = next)}
/>
{#if hovered !== null}
<div class="tip" style={`left:${hovered.x}px;top:${hovered.y}px`}>
<div class="mono"><b>{hovered.edge.source}</b>{hovered.edge.target}</div>
@@ -291,6 +332,8 @@
exportName={exportFilename('map', payload.root ?? '')}
onToggleTests={setTests}
onSelectRoot={setRoot}
chosenDepth={depth}
onSelectDepth={setDepth}
onSelect={(id) => (selected = id)}
/>
{/if}