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
|
#### 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.
|
- **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.
|
- **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,
|
test: over.test ?? false,
|
||||||
facade: over.facade ?? false,
|
facade: over.facade ?? false,
|
||||||
fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
|
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>');
|
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', () => {
|
it('names the top and bottom bands', () => {
|
||||||
const svg = mapSvg(layout);
|
const svg = mapSvg(layout);
|
||||||
expect(svg).toContain('>entry points</text>');
|
expect(svg).toContain('>entry points</text>');
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ import * as os from 'os';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import CodeGraph from '../src/index';
|
import CodeGraph from '../src/index';
|
||||||
import { createGraphApi, startUiServer, type GraphApi, type UiServerHandle } from '../src/ui-server';
|
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 server: UiServerHandle;
|
||||||
let api: GraphApi;
|
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', () => {
|
describe('GET /api/map', () => {
|
||||||
it('is listed by the API index', async () => {
|
it('is listed by the API index', async () => {
|
||||||
const res = await request('/api');
|
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.
|
// down joins that level's bucket rather than being promoted to a module.
|
||||||
expect(ids).toContain('src/core/passes');
|
expect(ids).toContain('src/core/passes');
|
||||||
expect(ids).toContain('src/core/(root files)');
|
expect(ids).toContain('src/core/(root files)');
|
||||||
expect(ids).toContain('src/api/(root files)');
|
|
||||||
expect(ids).not.toContain('src/core');
|
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');
|
const slashed = await getMap('?root=src%2F&depth=2');
|
||||||
expect(slashed.modules).toEqual(deep.modules);
|
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 () => {
|
it('rejects an out-of-range depth as JSON, not as a crash', async () => {
|
||||||
const res = await request('/api/map?depth=9');
|
const res = await request('/api/map?depth=9');
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
|
|||||||
test: over.test ?? false,
|
test: over.test ?? false,
|
||||||
facade: over.facade ?? false,
|
facade: over.facade ?? false,
|
||||||
fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
|
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);
|
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');
|
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 () => {
|
it('the default adapter is the loopback JSON API and asks for `api/...`', async () => {
|
||||||
const asked: string[] = [];
|
const asked: string[] = [];
|
||||||
const adapter = createHttpAdapter({
|
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).
|
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
|
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
|
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`.
|
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
|
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
|
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;
|
facade: boolean;
|
||||||
/** Its files, capped — what the side panel lists when the module is selected. */
|
/** Its files, capped — what the side panel lists when the module is selected. */
|
||||||
fileList: WireList<string>;
|
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 {
|
export interface WireMapLink {
|
||||||
@@ -276,6 +295,107 @@ export function pickDefaultRoot(
|
|||||||
return bestSymbols * 2 > total ? best : '';
|
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
|
// Cache
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -301,9 +421,17 @@ export function resetMapCache(): void {
|
|||||||
// Build
|
// 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');
|
const rawDepth = query.get('depth');
|
||||||
let depth = DEFAULT_DEPTH;
|
let depth: number | null = null;
|
||||||
if (rawDepth !== null && rawDepth !== '') {
|
if (rawDepth !== null && rawDepth !== '') {
|
||||||
depth = Number.parseInt(rawDepth, 10);
|
depth = Number.parseInt(rawDepth, 10);
|
||||||
if (!Number.isFinite(depth) || depth < 1 || depth > MAX_DEPTH) {
|
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 };
|
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 {
|
export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchParams): WireMapPayload {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
let { root: requestedRoot, depth } = parseMapQuery(query);
|
const { root: requestedRoot, depth: requestedDepth } = parseMapQuery(query);
|
||||||
|
|
||||||
const fileRecords = cg.getFiles().map((file) => {
|
const fileRecords = cg.getFiles().map((file) => {
|
||||||
const path = toPosixPath(file.path);
|
const path = toPosixPath(file.path);
|
||||||
@@ -330,10 +494,10 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
|
|||||||
});
|
});
|
||||||
|
|
||||||
const root = requestedRoot ?? pickDefaultRoot(fileRecords);
|
const root = requestedRoot ?? pickDefaultRoot(fileRecords);
|
||||||
// Left to choose, and choosing the whole project (two substantial roots):
|
// Root first, then depth against THAT root: how finely to cut depends on
|
||||||
// one level deeper, so the boxes are `src/app` and `ios/CaptureView`, not
|
// what is being cut. Choosing `src` and then asking for one level under it
|
||||||
// `src` and `ios`.
|
// is the same question as choosing the whole project and asking for two.
|
||||||
if (requestedRoot === null && root === '' && !query.has('depth')) depth = 2;
|
const depth = requestedDepth ?? pickDefaultDepth(fileRecords, root);
|
||||||
const stats = cg.getStats();
|
const stats = cg.getStats();
|
||||||
const key = [
|
const key = [
|
||||||
projectRoot,
|
projectRoot,
|
||||||
@@ -367,16 +531,24 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
|
|||||||
>();
|
>();
|
||||||
const moduleOfFile = new Map<string, string>();
|
const moduleOfFile = new Map<string, string>();
|
||||||
|
|
||||||
|
const assigned = new Map<string, { id: string; facade: boolean }>();
|
||||||
for (const file of fileRecords) {
|
for (const file of fileRecords) {
|
||||||
const assigned = moduleIdFor(file.path, root, depth);
|
const at = moduleIdFor(file.path, root, depth);
|
||||||
if (assigned === null) continue;
|
if (at !== null) assigned.set(file.path, at);
|
||||||
assignments.push({ filePath: file.path, module: assigned.id });
|
}
|
||||||
moduleOfFile.set(file.path, assigned.id);
|
const renamed = collapseLoneRootFiles(new Set([...assigned.values()].map((a) => a.id)));
|
||||||
let entry = modules.get(assigned.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) {
|
if (!entry) {
|
||||||
entry = {
|
entry = {
|
||||||
id: assigned.id,
|
id,
|
||||||
facade: assigned.facade,
|
facade: at.facade,
|
||||||
files: 0,
|
files: 0,
|
||||||
symbols: 0,
|
symbols: 0,
|
||||||
testFiles: 0,
|
testFiles: 0,
|
||||||
@@ -385,7 +557,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
|
|||||||
languages: new Map(),
|
languages: new Map(),
|
||||||
paths: [],
|
paths: [],
|
||||||
};
|
};
|
||||||
modules.set(assigned.id, entry);
|
modules.set(id, entry);
|
||||||
}
|
}
|
||||||
entry.files += 1;
|
entry.files += 1;
|
||||||
entry.paths.push(file.path);
|
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 = {
|
const payload: WireMapPayload = {
|
||||||
root,
|
root,
|
||||||
depth,
|
depth,
|
||||||
@@ -460,6 +638,7 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
|
|||||||
// draws are the same list — the count-equals-list rule.
|
// draws are the same list — the count-equals-list rule.
|
||||||
generatedFiles: shown.filter((path) => entry.generatedPaths.has(path)),
|
generatedFiles: shown.filter((path) => entry.generatedPaths.has(path)),
|
||||||
fileList: wireList(shown, entry.files),
|
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 —
|
// 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(
|
links: [...links.values()].sort(
|
||||||
(a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target)
|
(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 },
|
excluded: { uncertainEdges, confidenceBelow: UNCERTAIN_BELOW },
|
||||||
index: {
|
index: {
|
||||||
lastIndexedAt: cg.getLastIndexedAt(),
|
lastIndexedAt: cg.getLastIndexedAt(),
|
||||||
@@ -486,6 +665,38 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
|
|||||||
return payload;
|
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.
|
* File-level circular dependencies, as strongly connected components.
|
||||||
*
|
*
|
||||||
@@ -496,11 +707,11 @@ export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchPar
|
|||||||
* list anybody reads.
|
* list anybody reads.
|
||||||
*/
|
*/
|
||||||
function fileCycles(
|
function fileCycles(
|
||||||
cg: CodeGraph,
|
pairs: ReadonlyArray<{ source: string; target: string }>,
|
||||||
moduleOfFile: Map<string, string>
|
moduleOfFile: Map<string, string>
|
||||||
): WireMapPayload['cycles'] {
|
): WireMapPayload['cycles'] {
|
||||||
const adjacency = new Map<string, string[]>();
|
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;
|
if (!moduleOfFile.has(pair.source) || !moduleOfFile.has(pair.target)) continue;
|
||||||
let out = adjacency.get(pair.source);
|
let out = adjacency.get(pair.source);
|
||||||
if (!out) adjacency.set(pair.source, (out = []));
|
if (!out) adjacency.set(pair.source, (out = []));
|
||||||
|
|||||||
+5
-2
@@ -81,7 +81,8 @@
|
|||||||
const encoded = router.params.get('t');
|
const encoded = router.params.get('t');
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
trail.hydrate(encoded);
|
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 });
|
trail.push({ id: current.id });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -157,7 +158,7 @@
|
|||||||
<TopBar bind:this={topbar} project={project.name} stats={project.summary} showScreens={hasScreens} />
|
<TopBar bind:this={topbar} project={project.name} stats={project.summary} showScreens={hasScreens} />
|
||||||
<TrailBar />
|
<TrailBar />
|
||||||
<main>
|
<main>
|
||||||
{#if route.view === 'symbol'}
|
{#if route.view === 'symbol' && route.id !== null}
|
||||||
<SymbolView id={route.id} line={route.line} />
|
<SymbolView id={route.id} line={route.line} />
|
||||||
{:else if route.view === 'file' && route.source}
|
{:else if route.view === 'file' && route.source}
|
||||||
<FileCodeView path={route.path} line={route.line} />
|
<FileCodeView path={route.path} line={route.line} />
|
||||||
@@ -175,6 +176,8 @@
|
|||||||
{:else if route.view === 'entry'}
|
{:else if route.view === 'entry'}
|
||||||
<EntryView project={project.name} />
|
<EntryView project={project.name} />
|
||||||
{:else if route.view === 'screens' || (route.view === 'home' && hasScreens)}
|
{: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 />
|
<ScreensView />
|
||||||
{:else if route.view === 'steps'}
|
{:else if route.view === 'steps'}
|
||||||
<StepsView anchor={route.anchor} symbol={route.symbol} depth={route.depth} through={route.through} reading={route.reading} />
|
<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);
|
let view = $derived(router.route.view);
|
||||||
|
|
||||||
// The Symbol tab returns you to where you were reading, not to a blank
|
// 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(() => {
|
let symbolTabHref = $derived.by(() => {
|
||||||
const route = router.route;
|
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;
|
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. */
|
/** 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[];
|
files: string[];
|
||||||
onToggleTests: (value: boolean) => void;
|
onToggleTests: (value: boolean) => void;
|
||||||
onSelectRoot: (root: string) => 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;
|
onSelect: (id: string | null) => void;
|
||||||
/** Builds the map as an SVG at a given device-pixel scale. */
|
/** Builds the map as an SVG at a given device-pixel scale. */
|
||||||
buildSvg: (scale: number) => string;
|
buildSvg: (scale: number) => string;
|
||||||
@@ -40,11 +43,31 @@
|
|||||||
files,
|
files,
|
||||||
onToggleTests,
|
onToggleTests,
|
||||||
onSelectRoot,
|
onSelectRoot,
|
||||||
|
chosenDepth,
|
||||||
|
onSelectDepth,
|
||||||
onSelect,
|
onSelect,
|
||||||
buildSvg,
|
buildSvg,
|
||||||
exportName,
|
exportName,
|
||||||
}: Props = $props();
|
}: 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(
|
const selectedNode = $derived(
|
||||||
selected === null ? null : (layout.nodes.find((n) => n.id === selected) ?? null)
|
selected === null ? null : (layout.nodes.find((n) => n.id === selected) ?? null)
|
||||||
);
|
);
|
||||||
@@ -96,6 +119,25 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</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">
|
<label class="toggle">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -215,6 +257,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</p>
|
</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}
|
{#if selectedNode?.island}
|
||||||
<p class="island">
|
<p class="island">
|
||||||
Nothing in the index depends on this module — no import, call or reference crosses into
|
Nothing in the index depends on this module — no import, call or reference crosses into
|
||||||
@@ -289,6 +340,10 @@
|
|||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
.reach {
|
||||||
|
font-size: 11.5px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
.field {
|
.field {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -52,6 +52,10 @@
|
|||||||
aria-pressed={node.selected}
|
aria-pressed={node.selected}
|
||||||
title={`${module.id} — ${module.symbols} symbols in ${module.files} file${
|
title={`${module.id} — ${module.symbols} symbols in ${module.files} file${
|
||||||
module.files === 1 ? '' : 's'
|
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.island ? '. Nothing in the index depends on it.' : ''}${
|
||||||
layout.generated ? '. Every file in it is tool-generated.' : ''
|
layout.generated ? '. Every file in it is tool-generated.' : ''
|
||||||
}`}
|
}`}
|
||||||
@@ -61,6 +65,12 @@
|
|||||||
<span class="count" class:island={layout.island}
|
<span class="count" class:island={layout.island}
|
||||||
>{moduleMetaLabel(module, layout.island)}</span
|
>{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>
|
</button>
|
||||||
|
|
||||||
{#each layout.sourceHandles as handle, i (handle)}
|
{#each layout.sourceHandles as handle, i (handle)}
|
||||||
@@ -75,6 +85,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.mnode {
|
.mnode {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -125,6 +136,28 @@
|
|||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 1px;
|
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 {
|
.name {
|
||||||
font: 500 13px var(--mono);
|
font: 500 13px var(--mono);
|
||||||
line-height: 15px;
|
line-height: 15px;
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ function rect(
|
|||||||
h: number,
|
h: number,
|
||||||
attrs: {
|
attrs: {
|
||||||
fill?: string;
|
fill?: string;
|
||||||
|
fillOpacity?: number;
|
||||||
stroke?: string;
|
stroke?: string;
|
||||||
strokeWidth?: number;
|
strokeWidth?: number;
|
||||||
dash?: string;
|
dash?: string;
|
||||||
@@ -184,6 +185,7 @@ function rect(
|
|||||||
`height="${round(h)}"`,
|
`height="${round(h)}"`,
|
||||||
`fill="${attrs.fill ?? 'none'}"`,
|
`fill="${attrs.fill ?? 'none'}"`,
|
||||||
];
|
];
|
||||||
|
if (attrs.fillOpacity !== undefined) parts.push(`fill-opacity="${attrs.fillOpacity}"`);
|
||||||
if (attrs.stroke) {
|
if (attrs.stroke) {
|
||||||
parts.push(`stroke="${attrs.stroke}"`, `stroke-width="${attrs.strokeWidth ?? 1}"`);
|
parts.push(`stroke="${attrs.stroke}"`, `stroke-width="${attrs.strokeWidth ?? 1}"`);
|
||||||
if (attrs.dash) parts.push(`stroke-dasharray="${attrs.dash}"`);
|
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,
|
size: MODULE_META_SIZE,
|
||||||
fill: dimmed ? EXPORT_COLORS.ink4 : EXPORT_COLORS.ink3,
|
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('');
|
return out.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -122,7 +122,16 @@ export function moduleMetaLabel(module: WireMapModule, island = false): string {
|
|||||||
if (island) return 'nothing depends on this';
|
if (island) return 'nothing depends on this';
|
||||||
const symbols = `${module.symbols} symbol${module.symbols === 1 ? '' : 's'}`;
|
const symbols = `${module.symbols} symbol${module.symbols === 1 ? '' : 's'}`;
|
||||||
const files = `${module.files} file${module.files === 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. */
|
/** 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;
|
island: boolean;
|
||||||
/** Every file in it is tool-generated, so it draws in ink-4. */
|
/** Every file in it is tool-generated, so it draws in ink-4. */
|
||||||
generated: boolean;
|
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;
|
layer: number;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -417,6 +437,9 @@ export function buildMapLayout(
|
|||||||
|
|
||||||
const nodesById = new Map<string, MapNodeLayout>();
|
const nodesById = new Map<string, MapNodeLayout>();
|
||||||
const byId = new Map(modules.map((m) => [m.id, m]));
|
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) => {
|
rows.forEach((row, index) => {
|
||||||
const span = rowSpans[index] ?? 0;
|
const span = rowSpans[index] ?? 0;
|
||||||
const sum = rowSums[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
|
// Every file generated, not merely some: a module with one `.pb.go` in
|
||||||
// it is still a module somebody writes by hand.
|
// it is still a module somebody writes by hand.
|
||||||
generated: module.files > 0 && module.generated === module.files,
|
generated: module.files > 0 && module.generated === module.files,
|
||||||
|
weight: heaviest === 0 ? 0 : (module.dependents?.files ?? 0) / heaviest,
|
||||||
layer: index,
|
layer: index,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export interface FileHrefOptions {
|
|||||||
|
|
||||||
export interface MapHrefOptions {
|
export interface MapHrefOptions {
|
||||||
root?: string | null;
|
root?: string | null;
|
||||||
depth?: number;
|
/** Absent or null leaves the grouping to the answering side. */
|
||||||
|
depth?: number | null;
|
||||||
tests?: boolean;
|
tests?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +80,14 @@ export interface StepsHrefOptions {
|
|||||||
* serve.
|
* serve.
|
||||||
*/
|
*/
|
||||||
export interface NavigationDriver {
|
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;
|
fileHref(path: string, opts?: FileHrefOptions): string;
|
||||||
mapHref(opts?: MapHrefOptions): string;
|
mapHref(opts?: MapHrefOptions): string;
|
||||||
flowHref(opts?: FlowHrefOptions): string;
|
flowHref(opts?: FlowHrefOptions): string;
|
||||||
@@ -116,6 +124,9 @@ export const hashNavigation: NavigationDriver = {
|
|||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (opts.trail) params.set('t', opts.trail);
|
if (opts.trail) params.set('t', opts.trail);
|
||||||
if (opts.line) params.set('hl', String(opts.line));
|
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)}`;
|
return `#/s/${encodePath(id)}${query(params)}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -131,7 +142,9 @@ export const hashNavigation: NavigationDriver = {
|
|||||||
mapHref(opts = {}) {
|
mapHref(opts = {}) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
|
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');
|
if (opts.tests) params.set('tests', '1');
|
||||||
return `#/map${query(params)}`;
|
return `#/map${query(params)}`;
|
||||||
},
|
},
|
||||||
@@ -224,7 +237,7 @@ export function getNavigationDriver(): NavigationDriver {
|
|||||||
|
|
||||||
/* --------------------------- what the components actually call ----------- */
|
/* --------------------------- 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);
|
return driver.symbolHref(id, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
|
|||||||
generatedFiles: [],
|
generatedFiles: [],
|
||||||
facade: false,
|
facade: false,
|
||||||
fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
|
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
|
// 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: [],
|
generatedFiles: [],
|
||||||
facade: false,
|
facade: false,
|
||||||
fileList: { total: 0, shown: 0, truncated: false, items: [] },
|
fileList: { total: 0, shown: 0, truncated: false, items: [] },
|
||||||
|
dependents: { files: 0, modules: 0 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const drawn = (id: string): boolean => nodes.has(id) || forks.has(id);
|
const drawn = (id: string): boolean => nodes.has(id) || forks.has(id);
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ export type {
|
|||||||
|
|
||||||
export type Route =
|
export type Route =
|
||||||
| { view: 'home' }
|
| { 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';
|
view: 'file';
|
||||||
path: string;
|
path: string;
|
||||||
@@ -67,7 +68,7 @@ export type Route =
|
|||||||
/** The whole-file source view rather than the outline (design spec §3.4). */
|
/** The whole-file source view rather than the outline (design spec §3.4). */
|
||||||
source: boolean;
|
source: boolean;
|
||||||
}
|
}
|
||||||
| { view: 'map'; root: string | null; depth: number; tests: boolean }
|
| { view: 'map'; root: string | null; depth: number | null; tests: boolean }
|
||||||
| {
|
| {
|
||||||
view: 'flow';
|
view: 'flow';
|
||||||
/** "how does X reach Y" — both ends pinned. */
|
/** "how does X reach Y" — both ends pinned. */
|
||||||
@@ -135,19 +136,24 @@ export function parseHash(hash: string): RouterLocation {
|
|||||||
let route: Route;
|
let route: Route;
|
||||||
if (head === undefined) {
|
if (head === undefined) {
|
||||||
route = { view: 'home' };
|
route = { view: 'home' };
|
||||||
} else if (head === 's' && rest.length > 0) {
|
} else if (head === 's') {
|
||||||
route = { view: 'symbol', id: rest.join('/'), line };
|
// `#/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) {
|
} else if (head === 'file' && rest.length > 0) {
|
||||||
route = { view: 'file', path: rest.join('/'), line, source: params.get('src') === '1' };
|
route = { view: 'file', path: rest.join('/'), line, source: params.get('src') === '1' };
|
||||||
} else if (head === 'map' && rest.length === 0) {
|
} else if (head === 'map' && rest.length === 0) {
|
||||||
// The map's shape travels in the URL like the trail does: a link to
|
// 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 root = params.get('root');
|
||||||
const depth = Number.parseInt(params.get('depth') ?? '', 10);
|
const depth = Number.parseInt(params.get('depth') ?? '', 10);
|
||||||
route = {
|
route = {
|
||||||
view: 'map',
|
view: 'map',
|
||||||
root: root === null ? null : root,
|
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',
|
tests: params.get('tests') === '1',
|
||||||
};
|
};
|
||||||
} else if (head === 'entry' && rest.length === 0) {
|
} else if (head === 'entry' && rest.length === 0) {
|
||||||
|
|||||||
@@ -398,6 +398,8 @@ function moduleFor(info: ScreenNodeInfo, symbols: number): WireMapModule {
|
|||||||
generatedFiles: [],
|
generatedFiles: [],
|
||||||
facade: false,
|
facade: false,
|
||||||
fileList: { total: 1, shown: 1, truncated: false, items: [info.screen?.file ?? info.sub] },
|
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: [],
|
generatedFiles: [],
|
||||||
facade: false,
|
facade: false,
|
||||||
fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
|
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)!,
|
module: moduleOf.get(id)!,
|
||||||
island: false,
|
island: false,
|
||||||
generated: false,
|
generated: false,
|
||||||
|
weight: 0,
|
||||||
layer: layerOf(id),
|
layer: layerOf(id),
|
||||||
x,
|
x,
|
||||||
y: yy,
|
y: yy,
|
||||||
|
|||||||
@@ -610,6 +610,12 @@ export interface WireMapModule {
|
|||||||
facade: boolean;
|
facade: boolean;
|
||||||
/** Its files, capped — the side panel's list when the module is selected. */
|
/** Its files, capped — the side panel's list when the module is selected. */
|
||||||
fileList: { total: number; shown: number; truncated: boolean; items: string[] };
|
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 {
|
export interface WireMapLink {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import ModuleNode from '../components/map/ModuleNode.svelte';
|
import ModuleNode from '../components/map/ModuleNode.svelte';
|
||||||
import ModuleEdge from '../components/map/ModuleEdge.svelte';
|
import ModuleEdge from '../components/map/ModuleEdge.svelte';
|
||||||
import MapSidePanel from '../components/map/MapSidePanel.svelte';
|
import MapSidePanel from '../components/map/MapSidePanel.svelte';
|
||||||
|
import MapKey from '../components/map/MapKey.svelte';
|
||||||
import { exportFilename, mapSvg } from '../lib/export-svg';
|
import { exportFilename, mapSvg } from '../lib/export-svg';
|
||||||
import { fetchMap, type WireMapPayload } from '../lib/api';
|
import { fetchMap, type WireMapPayload } from '../lib/api';
|
||||||
import { live } from '../lib/live.svelte';
|
import { live } from '../lib/live.svelte';
|
||||||
@@ -31,7 +32,8 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
root: string | null;
|
root: string | null;
|
||||||
depth: number;
|
/** `null` = nobody has chosen; the answer picks a grouping for this repo. */
|
||||||
|
depth: number | null;
|
||||||
tests: boolean;
|
tests: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +57,26 @@
|
|||||||
*/
|
*/
|
||||||
const FIT = { fitViewOptions: { padding: 0.12, maxZoom: 1, minZoom: 0.45 } };
|
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 nodeTypes = { module: ModuleNode };
|
||||||
const edgeTypes = { module: ModuleEdge };
|
const edgeTypes = { module: ModuleEdge };
|
||||||
|
|
||||||
@@ -72,7 +94,7 @@
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
fetchMap({ root: wantRoot, depth: wantDepth }, controller.signal)
|
fetchMap({ root: wantRoot, depth: wantDepth ?? undefined }, controller.signal)
|
||||||
.then((next) => {
|
.then((next) => {
|
||||||
payload = next;
|
payload = next;
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -165,7 +187,16 @@
|
|||||||
|
|
||||||
function setRoot(next: string): void {
|
function setRoot(next: string): void {
|
||||||
selected = null;
|
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;
|
selected = null;
|
||||||
navigate(mapHref({ root, depth, tests: next }));
|
navigate(mapHref({ root, depth, tests: next }));
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mapview">
|
<div class="mapview">
|
||||||
@@ -256,6 +288,15 @@
|
|||||||
<Controls position="bottom-right" showLock={false} />
|
<Controls position="bottom-right" showLock={false} />
|
||||||
</SvelteFlow>
|
</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}
|
{#if hovered !== null}
|
||||||
<div class="tip" style={`left:${hovered.x}px;top:${hovered.y}px`}>
|
<div class="tip" style={`left:${hovered.x}px;top:${hovered.y}px`}>
|
||||||
<div class="mono"><b>{hovered.edge.source}</b> → {hovered.edge.target}</div>
|
<div class="mono"><b>{hovered.edge.source}</b> → {hovered.edge.target}</div>
|
||||||
@@ -291,6 +332,8 @@
|
|||||||
exportName={exportFilename('map', payload.root ?? '')}
|
exportName={exportFilename('map', payload.root ?? '')}
|
||||||
onToggleTests={setTests}
|
onToggleTests={setTests}
|
||||||
onSelectRoot={setRoot}
|
onSelectRoot={setRoot}
|
||||||
|
chosenDepth={depth}
|
||||||
|
onSelectDepth={setDepth}
|
||||||
onSelect={(id) => (selected = id)}
|
onSelect={(id) => (selected = id)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user