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:
@@ -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.
|
||||
|
||||
@@ -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>');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (139–282 of 377 files on a real mobile app, a flat spread that only reports cyclicity), while the direct count on the
|
||||
same repository spreads 0–127 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
@@ -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 (139–282 of 377, a flat 2× spread that says nothing but
|
||||
* "this repo has cycles"). The direct count on the same repository spreads
|
||||
* 0–127 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
@@ -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} />
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user