Просмотр исходного кода

feat(ui): the Map — the repository at module granularity, layered from the graph (CG-49)

`GET /api/map` rolls the whole edge table up to module granularity in one
`GROUP BY`, and the Map tab draws it: one box per directory, dependencies
pointing down, nothing placed by hand.

Two decisions carry the screen.

The vertical order rests on each link's `declared` weight — the edges resolved
through an import, a qualified name, an inheritance clause or a typed receiver —
not on its raw count. Bare name matching resolves `run`, `push` and `finish`
across unrelated directories, and layering on raw counts put `src/db` directly
under `src/bin` on this repository's own index. On declared edges the same data
reproduces the pipeline CLAUDE.md describes, with a third of the mutual pairs.
When too few links carry a declared edge to describe a project, the layout falls
back to raw counts and the side panel says so.

And the aggregation is a single scan. Grouping by the symbol names as well as
the modules costs nothing extra — the join is what is expensive — so one query
yields both the link weights and the tooltip's symbol pairs. Measured against
this index inflated to 800k edges: 1.28s for one scan against 1.89s for two,
which is the difference between meeting and missing the cold budget on a
ten-thousand-file repository. Cached answers come back in ~3ms.

Nothing is dropped silently: thin links are hidden until a module they touch is
selected and counted in the panel, uncertain references are excluded from every
number on screen and the total is printed, and mutual dependencies, module loops
and file-level circular imports are listed rather than straightened away. An
edge that still points up after layering is drawn dashed on selection instead of
being reversed or removed.

The layout — cycle-breaking, longest-path layering, barycenter ordering, ports —
is a pure function of the payload in `ui/src/lib/map-model.ts`, so the tests
toggle and the selection cost no round-trip and the same project always draws
the same picture. Svelte Flow supplies pan, zoom and fit; never a layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry 1 неделя назад
Родитель
Сommit
6d0f60f32c

+ 6 - 0
CHANGELOG.md

@@ -20,6 +20,12 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
   The viewer listens on `127.0.0.1` only, so nothing on your network can reach it, and requests claiming to come from any other host are refused. It is read-only: it opens an index that already exists, never creates one, and never writes to your project or your graph. It sends nothing anywhere.
 
+- **A map of the whole project, in `codegraph ui`.** The Map tab draws your repository at module granularity — one box per directory — with dependencies pointing down, so the top of the picture is what runs first and the bottom is what everything else stands on. Nothing is placed by hand and nothing floats: a module sits one layer above whatever it depends on, line weight is how many calls, imports and type references cross the link, and the same project always draws the same picture. Hover a link for what crosses it, including the busiest symbol pairs behind the weight; click a module to isolate its links, list its dependencies and dependents with counts, and jump straight into one of its files.
+
+  It says what it leaves out. Links carrying only a handful of references stay hidden until you select a module they touch, references CodeGraph isn't confident about are excluded from every count on the screen and the number is printed, and mutual dependencies, module loops and circular imports between files are listed rather than straightened away. The vertical order rests on the dependencies your code actually writes down — imports, qualified names, inheritance, typed receivers — because a method name shared by two unrelated folders should not be able to move a box.
+
+  It opens on your project's source directory; a picker switches to any other top-level folder or the whole repository, a checkbox brings tests in, and `depth` splits a large folder into its sub-folders — useful on a monorepo. What you're looking at lives in the address, so the view is shareable.
+
 
 ## [1.6.0] - 2026-08-26
 

+ 446 - 0
__tests__/ui-map-api.test.ts

@@ -0,0 +1,446 @@
+/**
+ * `GET /api/map` — the module aggregation behind the Map (CG-49).
+ *
+ * Against a real indexed fixture over a real loopback server, like the rest of
+ * the viewer's API suite. The fixture is shaped to produce exactly the things
+ * the endpoint has to get right and that a synthetic payload cannot prove:
+ *
+ * - a façade (`src/index.ts`) that must stay its own box rather than being
+ *   folded in with the loose type declarations beside it,
+ * - real `imports` edges, so the `declared` subset is not always equal to the
+ *   raw count and the layering has something trustworthy to rest on,
+ * - a two-file import cycle, so the file-level cycle report has a component to
+ *   find,
+ * - a test directory, so the `test` flag and the root default can be checked.
+ *
+ * The pure layout — layering, cycle-breaking, ports — is tested without a
+ * server in `ui-map-model.test.ts`.
+ */
+
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as http from 'http';
+import * as fs from 'fs';
+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';
+
+let server: UiServerHandle;
+let api: GraphApi;
+let tempDir: string;
+let projectRoot: string;
+
+function request(requestPath: string): Promise<{ status: number; body: string; type?: string }> {
+  return new Promise((resolve, reject) => {
+    const req = http.request(
+      {
+        host: '127.0.0.1',
+        port: server.port,
+        path: requestPath,
+        method: 'GET',
+        headers: { Host: `127.0.0.1:${server.port}` },
+        setHost: false,
+      },
+      (res) => {
+        const chunks: Buffer[] = [];
+        res.on('data', (c: Buffer) => chunks.push(c));
+        res.on('end', () =>
+          resolve({
+            status: res.statusCode ?? 0,
+            body: Buffer.concat(chunks).toString('utf-8'),
+            type: res.headers['content-type'],
+          })
+        );
+      }
+    );
+    req.on('error', reject);
+    req.end();
+  });
+}
+
+async function getMap(query = ''): Promise<any> {
+  const res = await request(`/api/map${query}`);
+  expect(res.type).toBe('application/json; charset=utf-8');
+  expect(res.status).toBe(200);
+  return JSON.parse(res.body);
+}
+
+function write(root: string, rel: string, body: string): void {
+  const full = path.join(root, rel);
+  fs.mkdirSync(path.dirname(full), { recursive: true });
+  fs.writeFileSync(full, body);
+}
+
+beforeAll(async () => {
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-map-'));
+  projectRoot = path.join(tempDir, 'project');
+
+  write(projectRoot, 'src/types.ts', `export interface Row {\n  id: string;\n}\n`);
+
+  write(
+    projectRoot,
+    'src/db/schema.ts',
+    `export const TABLES = ['rows'];\n`
+  );
+  // db -> core, the LIGHT direction of the mutual pair below.
+  write(
+    projectRoot,
+    'src/db/store.ts',
+    `import { Row } from '../types';
+import { normalise } from '../core/util';
+
+export class Store {
+  rows: Row[] = [];
+  put(row: Row): void {
+    this.rows.push(normalise(row));
+  }
+}
+`
+  );
+
+  // util <-> store is a deliberate two-file import cycle: it gives the file
+  // cycle report a component to find and the module graph a mutual pair.
+  write(
+    projectRoot,
+    'src/core/util.ts',
+    `import { Row } from '../types';
+import { Store } from '../db/store';
+
+export function normalise(row: Row): Row {
+  return { id: row.id.trim() };
+}
+
+export function count(store: Store): number {
+  return store.rows.length;
+}
+`
+  );
+
+  // Two directory levels under `src`, so depth=2 has something real to split.
+  write(
+    projectRoot,
+    'src/core/passes/trim.ts',
+    `import { Row } from '../../types';
+
+export function trim(row: Row): Row {
+  return { id: row.id.slice(0, 8) };
+}
+`
+  );
+  // core -> db, several times over: the HEAVY direction.
+  write(
+    projectRoot,
+    'src/core/engine.ts',
+    `import { Store } from '../db/store';
+import { TABLES } from '../db/schema';
+import { trim } from './passes/trim';
+import { Row } from '../types';
+
+export class Engine {
+  store = new Store();
+  boot(): string[] {
+    return TABLES;
+  }
+  add(row: Row): void {
+    this.store.put(trim(row));
+    this.store.put(row);
+  }
+}
+`
+  );
+
+  write(
+    projectRoot,
+    'src/api/handler.ts',
+    `import { Engine } from '../core/engine';
+import { Row } from '../types';
+
+export function handle(engine: Engine, row: Row): void {
+  engine.add(row);
+}
+`
+  );
+  write(
+    projectRoot,
+    'src/api/routes.ts',
+    `import { Engine } from '../core/engine';
+import { handle } from './handler';
+
+export function route(engine: Engine): void {
+  handle(engine, { id: 'x' });
+}
+`
+  );
+
+  write(
+    projectRoot,
+    'src/index.ts',
+    `import { Engine } from './core/engine';
+import { route } from './api/routes';
+
+export function start(): void {
+  route(new Engine());
+}
+`
+  );
+
+  write(
+    projectRoot,
+    '__tests__/engine.test.ts',
+    `import { Engine } from '../src/core/engine';
+
+export function testBoot(): string[] {
+  return new Engine().boot();
+}
+`
+  );
+
+  const cg = CodeGraph.initSync(projectRoot, {
+    config: { include: ['src/**/*.ts', '__tests__/**/*.ts'], exclude: [] },
+  });
+  await cg.indexAll();
+  cg.resolveReferences();
+  cg.close();
+
+  const viewerDir = path.join(tempDir, 'viewer');
+  fs.mkdirSync(viewerDir, { recursive: true });
+  fs.writeFileSync(path.join(viewerDir, 'index.html'), '<!doctype html><div id="app"></div>');
+
+  resetMapCache();
+  api = createGraphApi({ projectRoot });
+  server = await startUiServer({ projectRoot, viewerDir, port: 0, api: api.handler });
+}, 120_000);
+
+afterAll(async () => {
+  api?.close();
+  await server?.close();
+  resetMapCache();
+  if (tempDir && fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('moduleIdFor', () => {
+  it('names a module after the first `depth` segments under the root', () => {
+    expect(moduleIdFor('src/core/engine.ts', 'src', 1)).toEqual({ id: 'src/core', facade: false });
+    expect(moduleIdFor('src/a/b/c.ts', 'src', 2)).toEqual({ id: 'src/a/b', facade: false });
+    expect(moduleIdFor('a/b/c.ts', '', 1)).toEqual({ id: 'a', facade: false });
+  });
+
+  it('keeps a façade as its own box and buckets the other loose files', () => {
+    expect(moduleIdFor('src/index.ts', 'src', 1)).toEqual({ id: 'src/index.ts', facade: true });
+    expect(moduleIdFor('src/lib.rs', 'src', 1)?.facade).toBe(true);
+    expect(moduleIdFor('pkg/__init__.py', 'pkg', 1)?.facade).toBe(true);
+    expect(moduleIdFor('src/types.ts', 'src', 1)).toEqual({
+      id: 'src/(root files)',
+      facade: false,
+    });
+    expect(moduleIdFor('types.ts', '', 1)).toEqual({ id: '(root files)', facade: false });
+  });
+
+  it('buckets a loose file into the directory it is actually in, not the top one', () => {
+    // Two segments at depth 2 is a loose file inside `src/a`, so it belongs to
+    // that directory's bucket. Folding it into `src/(root files)` would claim a
+    // file lives somewhere it does not.
+    expect(moduleIdFor('src/a/loose.ts', 'src', 2)).toEqual({
+      id: 'src/a/(root files)',
+      facade: false,
+    });
+  });
+
+  it('returns null for a file outside the root', () => {
+    expect(moduleIdFor('__tests__/x.test.ts', 'src', 1)).toBeNull();
+    // A sibling whose name merely starts with the root is not under it.
+    expect(moduleIdFor('srcx/y.ts', 'src', 1)).toBeNull();
+  });
+});
+
+describe('normalizeRoot', () => {
+  it('treats `src`, `src/` and `./src` as one root', () => {
+    expect(normalizeRoot('src')).toBe('src');
+    expect(normalizeRoot('src/')).toBe('src');
+    expect(normalizeRoot('./src')).toBe('src');
+    expect(normalizeRoot('src\\')).toBe('src');
+  });
+
+  it('treats the repository root as the empty string however it is written', () => {
+    expect(normalizeRoot('')).toBe('');
+    expect(normalizeRoot('.')).toBe('');
+    expect(normalizeRoot('/')).toBe('');
+    expect(normalizeRoot(undefined)).toBe('');
+  });
+});
+
+describe('pickDefaultRoot', () => {
+  it('picks the directory holding a clear majority of the non-test symbols', () => {
+    expect(
+      pickDefaultRoot([
+        { path: 'src/a.ts', symbols: 80, test: false },
+        { path: 'scripts/b.ts', symbols: 5, test: false },
+        { path: '__tests__/c.ts', symbols: 900, test: true },
+      ])
+    ).toBe('src');
+  });
+
+  it('falls back to the repository root when no directory dominates', () => {
+    expect(
+      pickDefaultRoot([
+        { path: 'a/one.ts', symbols: 10, test: false },
+        { path: 'b/two.ts', symbols: 10, test: false },
+        { path: 'c/three.ts', symbols: 10, test: false },
+      ])
+    ).toBe('');
+    expect(pickDefaultRoot([{ path: 'flat.ts', symbols: 4, test: false }])).toBe('');
+  });
+});
+
+describe('GET /api/map', () => {
+  it('is listed by the API index', async () => {
+    const res = await request('/api');
+    const body = JSON.parse(res.body);
+    expect(body.endpoints.map((e: any) => e.path)).toContain('/api/map');
+  });
+
+  it('opens on the source directory and keeps the façade its own box', async () => {
+    const map = await getMap();
+    expect(map.root).toBe('src');
+    expect(map.depth).toBe(1);
+
+    const ids = map.modules.map((m: any) => m.id);
+    expect(ids).toEqual(['src/(root files)', 'src/api', 'src/core', 'src/db', 'src/index.ts']);
+    expect(map.modules.find((m: any) => m.id === 'src/core').files).toBe(3);
+
+    const facade = map.modules.find((m: any) => m.id === 'src/index.ts');
+    expect(facade.facade).toBe(true);
+    expect(facade.files).toBe(1);
+    expect(facade.symbols).toBeGreaterThan(0);
+    // Nothing under `src` is a test, so the default root already excludes them.
+    expect(map.modules.every((m: any) => m.test === false)).toBe(true);
+  });
+
+  it('offers every top-level directory as a root, plus the repository itself', async () => {
+    const map = await getMap();
+    expect(map.roots[0]).toEqual({ root: '', label: 'whole repository', files: map.index.files });
+    expect(map.roots.map((r: any) => r.root)).toEqual(
+      expect.arrayContaining(['', 'src', '__tests__'])
+    );
+  });
+
+  it('counts cross-module edges only, with a declared subset and named pairs', async () => {
+    const map = await getMap();
+    const link = map.links.find((l: any) => l.source === 'src/api' && l.target === 'src/core');
+    expect(link).toBeTruthy();
+    expect(link.count).toBeGreaterThan(0);
+    // Every kind's count has to add up to the link's own count, or the tooltip
+    // and the stroke width are describing two different things.
+    expect(link.byKind.reduce((sum: number, k: any) => sum + k.count, 0)).toBe(link.count);
+    // `import { Engine }` is a declared dependency; it must survive as one.
+    expect(link.declared).toBeGreaterThan(0);
+    expect(link.declared).toBeLessThanOrEqual(link.count);
+    expect(link.topPairs.length).toBeGreaterThan(0);
+    expect(link.topPairs.length).toBeLessThanOrEqual(4);
+    expect(link.topPairs.every((p: any) => p.declared <= p.count)).toBe(true);
+
+    // No module ever links to itself: same-module edges are not dependencies.
+    expect(map.links.every((l: any) => l.source !== l.target)).toBe(true);
+  });
+
+  it('keeps the heavier direction of a mutual pair heavier', async () => {
+    const map = await getMap();
+    const coreToDb = map.links.find((l: any) => l.source === 'src/core' && l.target === 'src/db');
+    const dbToCore = map.links.find((l: any) => l.source === 'src/db' && l.target === 'src/core');
+    expect(coreToDb).toBeTruthy();
+    expect(dbToCore).toBeTruthy();
+    expect(coreToDb.count).toBeGreaterThan(dbToCore.count);
+  });
+
+  it('reports the file-level cycle the fixture contains', async () => {
+    const map = await getMap();
+    expect(map.cycles.total).toBeGreaterThanOrEqual(1);
+    const knot = map.cycles.items.find((c: any) =>
+      c.files.includes('src/core/util.ts') && c.files.includes('src/db/store.ts')
+    );
+    expect(knot, JSON.stringify(map.cycles)).toBeTruthy();
+    expect(knot.size).toBe(knot.files.length);
+    expect(knot.modules).toEqual(expect.arrayContaining(['src/core', 'src/db']));
+    expect(map.cycles.shown).toBe(map.cycles.items.length);
+  });
+
+  it('lists each module\'s files, capped, with the true total beside them', async () => {
+    const map = await getMap();
+    for (const module of map.modules) {
+      expect(module.fileList.total).toBe(module.files);
+      expect(module.fileList.shown).toBe(module.fileList.items.length);
+      expect(module.fileList.truncated).toBe(module.fileList.shown < module.fileList.total);
+      expect(module.fileList.items).toEqual([...module.fileList.items].sort());
+    }
+    // A module's files are everything BELOW it, not just the files directly in
+    // it: `src/core` at depth 1 owns `src/core/passes/trim.ts` too, and the
+    // panel's list has to match the count on the box.
+    const core = map.modules.find((m: any) => m.id === 'src/core');
+    expect(core.fileList.items).toEqual([
+      'src/core/engine.ts',
+      'src/core/passes/trim.ts',
+      'src/core/util.ts',
+    ]);
+  });
+
+  it('says how many references the confidence floor excluded', async () => {
+    const map = await getMap();
+    expect(map.excluded.confidenceBelow).toBe(0.6);
+    expect(map.excluded.uncertainEdges).toBeGreaterThanOrEqual(0);
+  });
+
+  it('answers the whole repository, where the tests are a test module', async () => {
+    const map = await getMap('?root=&depth=1');
+    expect(map.root).toBe('');
+    const ids = map.modules.map((m: any) => m.id);
+    expect(ids).toEqual(expect.arrayContaining(['src', '__tests__']));
+    expect(map.modules.find((m: any) => m.id === '__tests__').test).toBe(true);
+    expect(map.modules.find((m: any) => m.id === 'src').test).toBe(false);
+    expect(map.links.some((l: any) => l.source === '__tests__' && l.target === 'src')).toBe(true);
+  });
+
+  it('splits deeper when asked, and `src/` is the same root as `src`', async () => {
+    const deep = await getMap('?root=src&depth=2');
+    const ids = deep.modules.map((m: any) => m.id);
+    // A directory two levels down becomes its own box; a file loose one level
+    // 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');
+
+    const slashed = await getMap('?root=src%2F&depth=2');
+    expect(slashed.modules).toEqual(deep.modules);
+  });
+
+  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);
+    expect(res.type).toBe('application/json; charset=utf-8');
+    const body = JSON.parse(res.body);
+    expect(body.code).toBe('bad-request');
+    expect(body.error).toContain('depth');
+  });
+
+  it('serves the second identical request from the cache, byte for byte', async () => {
+    // Other cases in this file have already warmed `src` at depth 1; the point
+    // here is the first-then-second transition, so start from a cold cache.
+    resetMapCache();
+    const first = await getMap('?root=src&depth=1');
+    const second = await getMap('?root=src&depth=1');
+    expect(first.timing.cached).toBe(false);
+    expect(second.timing.cached).toBe(true);
+    // Everything except the timing stamp must be identical — a map that is not
+    // reproducible between two reloads is not a map of anything.
+    const strip = (m: any) => JSON.stringify({ ...m, timing: undefined });
+    expect(strip(second)).toBe(strip(first));
+  });
+
+  it('does not let one root\'s answer be served for another', async () => {
+    const src = await getMap('?root=src&depth=1');
+    const all = await getMap('?root=&depth=1');
+    expect(all.root).toBe('');
+    expect(all.modules.map((m: any) => m.id)).not.toEqual(src.modules.map((m: any) => m.id));
+  });
+});

+ 401 - 0
__tests__/ui-map-model.test.ts

@@ -0,0 +1,401 @@
+/**
+ * The Map's layout, without a browser (CG-49).
+ *
+ * The properties under test are the ones that make the picture mean something.
+ * A map is only worth reading if the vertical position of a box is a claim
+ * about the code — so the tests here are mostly about *why* a module ends up
+ * where it does:
+ *
+ * - the layering rests on `declared` weight, not raw counts, because bare name
+ *   matching invents cross-module links out of shared method names;
+ * - a two-cycle keeps its heavier direction and the lighter one is reported,
+ *   never quietly dropped;
+ * - the same payload always produces the same picture, because a diagram you
+ *   cannot recognise between two visits is not a map of anything.
+ *
+ * The endpoint that feeds it is tested against a real index in
+ * `ui-map-api.test.ts`.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildMapLayout,
+  isEdgeVisible,
+  linkId,
+  moduleMetaLabel,
+  nodeWidth,
+  strokeWidthFor,
+  LAYER_GAP,
+  MIN_WEIGHT,
+  MIN_WEIGHT_WITH_TESTS,
+  NODE_HEIGHT,
+  type MapLayout,
+} from '../ui/src/lib/map-model';
+import type { WireMapLink, WireMapModule } from '../ui/src/lib/api';
+
+/* ------------------------------------------------------------- fixtures -- */
+
+function mod(id: string, over: Partial<WireMapModule> = {}): WireMapModule {
+  return {
+    id,
+    label: id.slice(id.lastIndexOf('/') + 1) || id,
+    files: over.files ?? 3,
+    symbols: over.symbols ?? 30,
+    languages: over.languages ?? [{ language: 'typescript', files: over.files ?? 3 }],
+    test: over.test ?? false,
+    facade: over.facade ?? false,
+    fileList: over.fileList ?? { total: 3, shown: 3, truncated: false, items: [] },
+  };
+}
+
+function link(
+  source: string,
+  target: string,
+  count: number,
+  declared = count
+): WireMapLink {
+  return {
+    source,
+    target,
+    count,
+    declared,
+    byKind: [{ kind: 'calls', count }],
+    topPairs: [],
+  };
+}
+
+function layerOf(layout: MapLayout, id: string): number {
+  const node = layout.nodes.find((n) => n.id === id);
+  expect(node, `no node ${id}`).toBeTruthy();
+  return node!.layer;
+}
+
+const OPTS = { includeTests: false };
+
+/* ---------------------------------------------------------------- specs -- */
+
+describe('nodeWidth', () => {
+  it('fits the wider of the two lines and never goes under the floor', () => {
+    expect(nodeWidth('ui')).toBe(110);
+    // A long id outgrows the floor; a long meta line outgrows a short id.
+    expect(nodeWidth('src/resolution/(root files)')).toBeGreaterThan(200);
+    expect(nodeWidth('src/db', '1218 symbols · 54 files')).toBeGreaterThan(nodeWidth('src/db'));
+  });
+});
+
+describe('moduleMetaLabel', () => {
+  it('says the counts in singular when there is one of them', () => {
+    expect(moduleMetaLabel(mod('src/x', { symbols: 1, files: 1 }))).toBe('1 symbol · 1 file');
+    expect(moduleMetaLabel(mod('src/x', { symbols: 9, files: 2 }))).toBe('9 symbols · 2 files');
+  });
+});
+
+describe('strokeWidthFor', () => {
+  it('grows with the logarithm of the count and stops at 6', () => {
+    expect(strokeWidthFor(1)).toBe(1);
+    expect(strokeWidthFor(700)).toBeLessThanOrEqual(6);
+    expect(strokeWidthFor(1_000_000)).toBe(6);
+    expect(strokeWidthFor(64)).toBeGreaterThan(strokeWidthFor(8));
+    // A count of zero must not produce -Infinity.
+    expect(Number.isFinite(strokeWidthFor(0))).toBe(true);
+  });
+});
+
+describe('layering', () => {
+  const modules = [mod('src/bin'), mod('src/core'), mod('src/db')];
+
+  it('puts a module one layer above everything it depends on', () => {
+    const layout = buildMapLayout(
+      { modules, links: [link('src/bin', 'src/core', 10), link('src/core', 'src/db', 10)] },
+      OPTS
+    );
+    expect(layerOf(layout, 'src/db')).toBe(0);
+    expect(layerOf(layout, 'src/core')).toBe(1);
+    expect(layerOf(layout, 'src/bin')).toBe(2);
+    // Layer 0 is the foundations, and it is drawn at the BOTTOM.
+    const bin = layout.nodes.find((n) => n.id === 'src/bin')!;
+    const db = layout.nodes.find((n) => n.id === 'src/db')!;
+    expect(bin.y).toBeLessThan(db.y);
+    expect(db.y - bin.y).toBe(2 * (NODE_HEIGHT + LAYER_GAP));
+  });
+
+  it('names only the top and bottom layers', () => {
+    const layout = buildMapLayout(
+      { modules, links: [link('src/bin', 'src/core', 10), link('src/core', 'src/db', 10)] },
+      OPTS
+    );
+    expect(layout.layers.map((l) => l.label)).toEqual([
+      'foundations — depend on nothing below',
+      null,
+      'entry points',
+    ]);
+  });
+
+  it('ignores a link with nothing declared behind it', () => {
+    // `src/db -> src/bin` is 40 name-only matches (`run`, `push`, `finish`) and
+    // would otherwise lift the storage layer above the CLI. It is still drawn —
+    // as a back-edge — but it must not decide the vertical order.
+    const layout = buildMapLayout(
+      {
+        modules,
+        links: [
+          link('src/bin', 'src/core', 10, 10),
+          link('src/core', 'src/db', 10, 10),
+          link('src/db', 'src/bin', 40, 0),
+        ],
+      },
+      OPTS
+    );
+    expect(layout.basis.kind).toBe('declared');
+    expect(layerOf(layout, 'src/db')).toBe(0);
+    expect(layerOf(layout, 'src/bin')).toBe(2);
+    const noisy = layout.edges.find((e) => e.source === 'src/db' && e.target === 'src/bin')!;
+    expect(noisy).toBeTruthy();
+    expect(noisy.back).toBe(true);
+  });
+
+  it('falls back to raw counts, and says so, when almost nothing is declared', () => {
+    const layout = buildMapLayout(
+      {
+        modules,
+        links: [
+          link('src/bin', 'src/core', 10, 0),
+          link('src/core', 'src/db', 10, 0),
+          link('src/db', 'src/core', 2, 1),
+        ],
+      },
+      OPTS
+    );
+    expect(layout.basis.kind).toBe('all');
+    expect(layout.basis.declaredLinks).toBe(1);
+    expect(layout.basis.totalLinks).toBe(3);
+    expect(layout.basis.declaredLinks / layout.basis.totalLinks).toBeLessThan(0.4);
+    // With raw counts the chain is still a chain, and the light back-reference
+    // becomes the mutual one.
+    expect(layerOf(layout, 'src/db')).toBe(0);
+    expect(layerOf(layout, 'src/bin')).toBe(2);
+    expect(layout.mutual.map((m) => m.back.source)).toEqual(['src/db']);
+  });
+
+  it('survives a three-module loop instead of recursing forever', () => {
+    const layout = buildMapLayout(
+      {
+        modules,
+        links: [
+          link('src/bin', 'src/core', 5),
+          link('src/core', 'src/db', 5),
+          link('src/db', 'src/bin', 5),
+        ],
+      },
+      OPTS
+    );
+    expect(layout.nodes).toHaveLength(3);
+    expect(layout.moduleCycles).toEqual([['src/bin', 'src/core', 'src/db']]);
+    // Every module still got a finite layer.
+    expect(layout.nodes.every((n) => Number.isInteger(n.layer))).toBe(true);
+  });
+});
+
+describe('two-cycles', () => {
+  const modules = [mod('src/a'), mod('src/b')];
+
+  it('keeps the heavier direction and reports the lighter as mutual', () => {
+    const layout = buildMapLayout(
+      { modules, links: [link('src/a', 'src/b', 20), link('src/b', 'src/a', 3)] },
+      OPTS
+    );
+    expect(layerOf(layout, 'src/a')).toBe(1);
+    expect(layerOf(layout, 'src/b')).toBe(0);
+    expect(layout.mutual).toHaveLength(1);
+    expect(layout.mutual[0]!.forward.source).toBe('src/a');
+    expect(layout.mutual[0]!.back.source).toBe('src/b');
+    // Both directions are still on the canvas; the lighter one points up.
+    expect(layout.edges).toHaveLength(2);
+    expect(layout.edges.find((e) => e.source === 'src/b')!.back).toBe(true);
+    expect(layout.edges.find((e) => e.source === 'src/a')!.back).toBe(false);
+  });
+
+  it('breaks an exact tie the same way every time', () => {
+    const one = buildMapLayout(
+      { modules, links: [link('src/a', 'src/b', 7), link('src/b', 'src/a', 7)] },
+      OPTS
+    );
+    const two = buildMapLayout(
+      { modules, links: [link('src/b', 'src/a', 7), link('src/a', 'src/b', 7)] },
+      OPTS
+    );
+    expect(one.mutual[0]!.back.source).toBe('src/b');
+    expect(two.mutual[0]!.back.source).toBe('src/b');
+    expect(layerOf(one, 'src/a')).toBe(layerOf(two, 'src/a'));
+  });
+});
+
+describe('tests and thresholds', () => {
+  const modules = [mod('src/core'), mod('__tests__', { test: true })];
+  const links = [link('__tests__', 'src/core', 30), link('src/core', '__tests__', 2)];
+
+  it('leaves test modules out until they are asked for, and their links with them', () => {
+    const off = buildMapLayout({ modules, links }, { includeTests: false });
+    expect(off.nodes.map((n) => n.id)).toEqual(['src/core']);
+    expect(off.edges).toHaveLength(0);
+    expect(off.minWeight).toBe(MIN_WEIGHT);
+
+    const on = buildMapLayout({ modules, links }, { includeTests: true });
+    expect(on.nodes).toHaveLength(2);
+    expect(on.edges).toHaveLength(2);
+    // A test module touches everything, so the bar for a visible link is higher.
+    expect(on.minWeight).toBe(MIN_WEIGHT_WITH_TESTS);
+  });
+
+  it('marks a link under the threshold thin rather than deleting it', () => {
+    const layout = buildMapLayout(
+      {
+        modules: [mod('src/a'), mod('src/b'), mod('src/c')],
+        links: [link('src/a', 'src/b', 12), link('src/a', 'src/c', 2)],
+      },
+      OPTS
+    );
+    const thin = layout.edges.find((e) => e.target === 'src/c')!;
+    expect(thin.thin).toBe(true);
+    expect(isEdgeVisible(thin, null)).toBe(false);
+    // Selecting either end brings it back — that is the whole point of hiding
+    // it rather than dropping it.
+    expect(isEdgeVisible(thin, 'src/a')).toBe(true);
+    expect(isEdgeVisible(thin, 'src/c')).toBe(true);
+    expect(isEdgeVisible(thin, 'src/b')).toBe(false);
+
+    const fat = layout.edges.find((e) => e.target === 'src/b')!;
+    expect(isEdgeVisible(fat, null)).toBe(true);
+    expect(isEdgeVisible(fat, 'src/c')).toBe(false);
+  });
+});
+
+describe('ports', () => {
+  it('gives every link its own port, ordered by where the other end sits', () => {
+    const layout = buildMapLayout(
+      {
+        modules: [mod('src/top'), mod('src/left'), mod('src/mid'), mod('src/right')],
+        links: [
+          link('src/top', 'src/left', 9),
+          link('src/top', 'src/mid', 9),
+          link('src/top', 'src/right', 9),
+        ],
+      },
+      OPTS
+    );
+    const top = layout.nodes.find((n) => n.id === 'src/top')!;
+    expect(top.sourceHandles).toHaveLength(3);
+    expect(new Set(top.sourceHandles).size).toBe(3);
+
+    // The handle order must follow the targets' left-to-right order, or the
+    // three edges cross each other inside the gap for no reason.
+    const xOf = (id: string) => {
+      const n = layout.nodes.find((m) => m.id === id)!;
+      return n.x + n.width / 2;
+    };
+    const targets = top.sourceHandles.map(
+      (id) => layout.edges.find((e) => e.id === id)!.target
+    );
+    const xs = targets.map(xOf);
+    expect(xs).toEqual([...xs].sort((a, b) => a - b));
+
+    // Each target's single incoming link is its only target handle.
+    for (const id of ['src/left', 'src/mid', 'src/right']) {
+      expect(layout.nodes.find((n) => n.id === id)!.targetHandles).toHaveLength(1);
+    }
+  });
+
+  it('names an edge by its endpoints, so two runs key the same', () => {
+    expect(linkId({ source: 'a', target: 'b' })).toBe(linkId({ source: 'a', target: 'b' }));
+    expect(linkId({ source: 'a', target: 'b' })).not.toBe(linkId({ source: 'b', target: 'a' }));
+  });
+});
+
+describe('determinism', () => {
+  const modules = [
+    mod('src/alpha'),
+    mod('src/beta'),
+    mod('src/gamma'),
+    mod('src/delta'),
+    mod('src/epsilon'),
+  ];
+  const links = [
+    link('src/alpha', 'src/beta', 12),
+    link('src/alpha', 'src/gamma', 8),
+    link('src/beta', 'src/delta', 15),
+    link('src/gamma', 'src/delta', 6),
+    link('src/delta', 'src/epsilon', 20),
+    link('src/beta', 'src/epsilon', 5),
+  ];
+
+  it('produces an identical layout from an identical payload', () => {
+    const a = buildMapLayout({ modules, links }, OPTS);
+    const b = buildMapLayout({ modules, links }, OPTS);
+    expect(JSON.stringify(b)).toBe(JSON.stringify(a));
+  });
+
+  it('does not depend on the order the payload happened to arrive in', () => {
+    const a = buildMapLayout({ modules, links }, OPTS);
+    const b = buildMapLayout(
+      { modules: [...modules].reverse(), links: [...links].reverse() },
+      OPTS
+    );
+    const positions = (l: MapLayout) =>
+      l.nodes
+        .map((n) => `${n.id}@${n.layer}:${Math.round(n.x)},${Math.round(n.y)}`)
+        .sort()
+        .join('|');
+    expect(positions(b)).toBe(positions(a));
+  });
+
+  it('places an unconnected module without stretching the canvas around it', () => {
+    const withIsland = buildMapLayout(
+      { modules: [...modules, mod('src/island')], links },
+      OPTS
+    );
+    const island = withIsland.nodes.find((n) => n.id === 'src/island')!;
+    expect(island).toBeTruthy();
+    expect(island.layer).toBe(0);
+    // Parked at the right-hand end of its layer, not interleaved through the
+    // modules that actually connect.
+    const sameLayer = withIsland.nodes.filter((n) => n.layer === 0);
+    expect(Math.max(...sameLayer.map((n) => n.x))).toBe(island.x);
+    // And the canvas is no wider than the boxes standing shoulder to shoulder.
+    const widest = Math.max(
+      ...[0, 1, 2, 3].map((layer) =>
+        withIsland.nodes
+          .filter((n) => n.layer === layer)
+          .reduce((sum, n) => sum + n.width, 0)
+      )
+    );
+    expect(withIsland.width).toBeLessThan(widest + 6 * 34 + 200);
+  });
+});
+
+describe('empty and degenerate inputs', () => {
+  it('answers an empty payload without throwing', () => {
+    const layout = buildMapLayout({ modules: [], links: [] }, OPTS);
+    expect(layout.nodes).toHaveLength(0);
+    expect(layout.edges).toHaveLength(0);
+    expect(layout.basis.kind).toBe('all');
+    expect(Number.isFinite(layout.width)).toBe(true);
+    expect(Number.isFinite(layout.height)).toBe(true);
+  });
+
+  it('drops a link whose other end was filtered out', () => {
+    const layout = buildMapLayout(
+      {
+        modules: [mod('src/a'), mod('__tests__', { test: true })],
+        links: [link('src/a', '__tests__', 9), link('src/a', 'src/ghost', 9)],
+      },
+      OPTS
+    );
+    expect(layout.edges).toHaveLength(0);
+  });
+
+  it('leaves a single layer unlabelled', () => {
+    const layout = buildMapLayout({ modules: [mod('src/only')], links: [] }, OPTS);
+    expect(layout.layers).toHaveLength(1);
+    expect(layout.layers[0]!.label).toBeNull();
+  });
+});

+ 213 - 0
package-lock.json

@@ -1006,6 +1006,16 @@
       "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
       "license": "MIT"
     },
+    "node_modules/@svelte-put/shortcut": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/@svelte-put/shortcut/-/shortcut-4.2.0.tgz",
+      "integrity": "sha512-hqNLo4yEc++SLgAkZUvuwMxIAsii9qjQtTuzfcYVf3xRxa+0HFcfaWFK7LdU3l+15s9SYVNbPB0qQj9CHFqSuw==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "svelte": "^5.1.0"
+      }
+    },
     "node_modules/@sveltejs/acorn-typescript": {
       "version": "1.0.13",
       "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz",
@@ -1036,6 +1046,61 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/d3-color": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+      "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-drag": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+      "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-selection": "*"
+      }
+    },
+    "node_modules/@types/d3-interpolate": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+      "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-color": "*"
+      }
+    },
+    "node_modules/@types/d3-selection": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+      "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/d3-transition": {
+      "version": "3.0.9",
+      "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+      "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-selection": "*"
+      }
+    },
+    "node_modules/@types/d3-zoom": {
+      "version": "3.0.8",
+      "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+      "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-interpolate": "*",
+        "@types/d3-selection": "*"
+      }
+    },
     "node_modules/@types/estree": {
       "version": "1.0.8",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1211,6 +1276,38 @@
         "url": "https://opencollective.com/vitest"
       }
     },
+    "node_modules/@xyflow/svelte": {
+      "version": "1.6.5",
+      "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-1.6.5.tgz",
+      "integrity": "sha512-bSPLuFlaa5mVWNg4FIZEt0vY2x+8eImnq57p4G6TlfkFPVBVVOyiBynhn4IN76oVOm7wWyFLHaCloV+4biLYhw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@svelte-put/shortcut": "^4.1.0",
+        "@xyflow/system": "0.0.81"
+      },
+      "peerDependencies": {
+        "svelte": "^5.25.0"
+      }
+    },
+    "node_modules/@xyflow/system": {
+      "version": "0.0.81",
+      "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.81.tgz",
+      "integrity": "sha512-hfbafW4i7uLq7ILok8QWFFm4KMFw22lbZNJHKfHOMSOOoCk5e5m8yfr84UV9NaJajmogWaLVnp2XFU9JQejlqg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/d3-drag": "^3.0.7",
+        "@types/d3-interpolate": "^3.0.4",
+        "@types/d3-selection": "^3.0.10",
+        "@types/d3-transition": "^3.0.8",
+        "@types/d3-zoom": "^3.0.8",
+        "d3-drag": "^3.0.0",
+        "d3-interpolate": "^3.0.1",
+        "d3-selection": "^3.0.0",
+        "d3-zoom": "^3.0.0"
+      }
+    },
     "node_modules/acorn": {
       "version": "8.18.0",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -1371,6 +1468,121 @@
         "node": ">=20"
       }
     },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-dispatch": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+      "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-drag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+      "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-selection": "3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-selection": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+      "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+      "dev": true,
+      "license": "ISC",
+      "peer": true,
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-transition": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+      "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-color": "1 - 3",
+        "d3-dispatch": "1 - 3",
+        "d3-ease": "1 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-timer": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "d3-selection": "2 - 3"
+      }
+    },
+    "node_modules/d3-zoom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+      "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-drag": "2 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-selection": "2 - 3",
+        "d3-transition": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/debug": {
       "version": "4.4.3",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2569,6 +2781,7 @@
         "@fontsource-variable/archivo": "^5.3.0",
         "@fontsource/ibm-plex-mono": "^5.3.0",
         "@sveltejs/vite-plugin-svelte": "^6.2.4",
+        "@xyflow/svelte": "^1.6.5",
         "svelte": "^5.56.10",
         "svelte-check": "^4.7.6",
         "typescript": "^5.0.0",

+ 12 - 0
site/src/content/docs/guides/viewer.md

@@ -42,6 +42,18 @@ The viewer never presents a guess as a fact:
 
 Clicking any file path opens the **file view**: everything that file depends on, its outline in source order, and everything that depends on it.
 
+## The map
+
+The **Map** tab (`m`) draws the project at module granularity — one box per directory — with dependencies pointing down. Nothing is placed by hand: a module sits one layer above whatever it depends on, so the top of the picture is what runs first and the bottom is what everything else stands on, and the same project always draws the same picture.
+
+- **Line weight** is how many calls, imports and type references cross the link. Hover one for the breakdown by kind and the busiest symbol pairs behind it.
+- **Click a module** to isolate its links and see its dependencies and dependents with counts, plus its files — click one to open the file view.
+- **Cycles are listed, not straightened away**: mutual dependencies between two modules, loops of three or more, and circular imports between individual files.
+
+It is honest about what it leaves out. Links carrying only a handful of references stay hidden until you select a module they touch, and references CodeGraph isn't confident about are excluded from every count on the screen — the panel prints how many. The vertical order rests on the dependencies your code writes down (imports, qualified names, inheritance, typed receivers), because a method name shared by two unrelated folders should not be able to move a box; when a project has too few of those to go on, the panel says the order came from raw reference counts instead.
+
+The map opens on your project's source directory. The picker switches to any other top-level folder or the whole repository, the checkbox brings test modules in, and `?depth=2` in the address splits a large folder into its sub-folders — the useful setting on a monorepo. What you are looking at lives in the URL, so the view is shareable.
+
 ## Options
 
 | | |

+ 270 - 0
src/db/queries.ts

@@ -2075,6 +2075,161 @@ export class QueryBuilder {
       .all(JSON.stringify(filePaths)) as Array<{ filePath: string; dependents: number }>;
   }
 
+  /**
+   * Roll the whole edge table up to module granularity in one pass.
+   *
+   * The caller decides what a module IS — it hands in a file → module
+   * assignment and gets back the cross-module traffic. That split is
+   * deliberate: naming modules is a *policy* (top-level directories, a façade
+   * file kept separate, a monorepo root) that belongs where the reader lives,
+   * while grouping a million edges by it is *mechanics* that must happen in
+   * SQLite. Doing the fold in JavaScript instead means materialising every
+   * cross-file edge in memory; doing the naming in SQL means a tower of
+   * `instr`/`substr` no one can read.
+   *
+   * The assignment lands in a TEMP table with a primary key, so the join is
+   * indexed and the result set is bounded by modules², not by edges. Temp
+   * tables live in SQLite's own temp database, so this stays valid against a
+   * read-only main.
+   *
+   * Two result sets, because they need two different groupings over the same
+   * join: `links` counts edges per (module, module, kind), and `pairs` names
+   * the busiest symbol pairs behind each link (the map's tooltip). `pairs` is
+   * ranked and cut inside SQLite — the un-cut grouping is the one thing here
+   * that scales with distinct symbol names rather than with modules. Pairs are
+   * ranked by `declared` before raw count, so a link's tooltip names the
+   * symbols the source actually points at rather than whichever `has`/`get`
+   * happened to name-match most often.
+   *
+   * `declared` is the subset of a link's edges that came from something the
+   * source *writes down*: an import, a qualified name, an inheritance clause,
+   * or a call through a typed receiver. It exists because bare name matching
+   * (`resolvedBy: 'exact-match'`) is what invents cross-module links out of
+   * common method names — `run`, `push`, `finish` — and a map that lets those
+   * decide the layering puts the storage layer above the CLI.
+   */
+  aggregateModuleGraph(
+    assignments: ReadonlyArray<{ filePath: string; module: string }>,
+    options: {
+      kinds: readonly EdgeKind[];
+      minConfidence: number;
+      topPairsPerLink: number;
+      pairKinds: readonly EdgeKind[];
+    }
+  ): {
+    links: Array<{
+      source: string;
+      target: string;
+      kind: EdgeKind;
+      count: number;
+      declared: number;
+      uncertain: number;
+    }>;
+    pairs: Array<{
+      source: string;
+      target: string;
+      from: string;
+      to: string;
+      count: number;
+      declared: number;
+    }>;
+  } {
+    if (assignments.length === 0 || options.kinds.length === 0) return { links: [], pairs: [] };
+
+    const CONFIDENCE = `COALESCE(json_extract(e.metadata, '$.confidence'), 1)`;
+    const DECLARED = `(json_extract(e.metadata, '$.resolvedBy') IN ('import', 'qualified-name')
+                       OR e.kind IN ('extends', 'implements')
+                       OR (json_extract(e.metadata, '$.resolvedBy') = 'instance-method'
+                           AND ${CONFIDENCE} >= 0.9))`;
+
+    this.db.exec('DROP TABLE IF EXISTS temp.cg_module_map');
+    this.db.exec('CREATE TEMP TABLE cg_module_map (path TEXT PRIMARY KEY, mod TEXT NOT NULL)');
+    try {
+      const insert = this.db.prepare(
+        'INSERT OR REPLACE INTO cg_module_map (path, mod) VALUES (?, ?)'
+      );
+      this.db.exec('BEGIN');
+      try {
+        for (const row of assignments) insert.run(row.filePath, row.module);
+        this.db.exec('COMMIT');
+      } catch (err) {
+        this.db.exec('ROLLBACK');
+        throw err;
+      }
+
+      // ONE pass over the edge table. Grouping by the symbol names as well as
+      // the modules costs nothing extra in scan time — the join is what is
+      // expensive — and it buys both results from a single scan. Measured on
+      // this index inflated to 1.6M edges: 1.66s for this query against 3.0s
+      // for the module-level and name-level queries run separately, which is
+      // the difference between meeting and missing the map's cold budget on a
+      // ten-thousand-file repository.
+      const rows = this.db
+        .prepare(
+          `SELECT ms.mod AS source, mt.mod AS target, e.kind AS kind,
+                  sn.name AS "from", tn.name AS "to",
+                  SUM(CASE WHEN ${CONFIDENCE} >= ? THEN 1 ELSE 0 END) AS count,
+                  SUM(CASE WHEN ${CONFIDENCE} >= ? AND ${DECLARED} THEN 1 ELSE 0 END) AS declared,
+                  SUM(CASE WHEN ${CONFIDENCE} <  ? THEN 1 ELSE 0 END) AS uncertain
+             FROM edges e
+             JOIN nodes sn ON sn.id = e.source
+             JOIN nodes tn ON tn.id = e.target
+             JOIN cg_module_map ms ON ms.path = sn.file_path
+             JOIN cg_module_map mt ON mt.path = tn.file_path
+            WHERE e.kind IN (SELECT value FROM json_each(?))
+              AND ms.mod <> mt.mod
+         GROUP BY ms.mod, mt.mod, e.kind, sn.name, tn.name`
+        )
+        .all(
+          options.minConfidence,
+          options.minConfidence,
+          options.minConfidence,
+          JSON.stringify(options.kinds)
+        ) as Array<{
+        source: string;
+        target: string;
+        kind: EdgeKind;
+        from: string;
+        to: string;
+        count: number;
+        declared: number;
+        uncertain: number;
+      }>;
+
+      return foldModuleRows(rows, options);
+    } finally {
+      this.db.exec('DROP TABLE IF EXISTS temp.cg_module_map');
+    }
+  }
+
+  /**
+   * Every ordered pair of files where one reaches into the other, once each.
+   *
+   * The input a cycle finder wants: file-level circular dependencies are the
+   * strongly connected components of this graph. One query instead of the
+   * dependency lookup per file that {@link GraphQueryManager.findCircularDependencies}
+   * does — which matters because a cycle report is only interesting on a large
+   * repo, and that is exactly where a query per file stops being affordable.
+   *
+   * `contains` is excluded (a file "contains" its own symbols, which is not a
+   * dependency), and so are same-file edges and low-confidence name matches:
+   * a cycle conjured by a common method name is a false alarm a reader cannot
+   * check.
+   */
+  getCrossFileDependencyPairs(minConfidence: number): Array<{ source: string; target: string }> {
+    return this.db
+      .prepare(
+        `SELECT DISTINCT sn.file_path AS source, tn.file_path AS target
+           FROM edges e
+           JOIN nodes sn ON sn.id = e.source
+           JOIN nodes tn ON tn.id = e.target
+          WHERE e.kind <> 'contains'
+            AND sn.file_path <> tn.file_path
+            AND COALESCE(json_extract(e.metadata, '$.confidence'), 1) >= ?`
+      )
+      .all(minConfidence) as Array<{ source: string; target: string }>;
+  }
+
   /**
    * References recorded against a symbol that never resolved to a node — the
    * calls and type mentions that leave the index (a third-party package, a
@@ -3135,3 +3290,118 @@ export class QueryBuilder {
     })();
   }
 }
+
+/**
+ * Turn the module aggregation's one result set into its two answers.
+ *
+ * The query groups by module pair AND kind AND symbol names, because the join
+ * is what costs and a finer grouping rides along free. That leaves two folds:
+ * counts per (module, module, kind) for the map's link weights, and the busiest
+ * symbol pairs per link for its tooltip.
+ *
+ * Pairs are ranked `declared` first and only then by raw count, so a link's
+ * tooltip names the symbols the source actually points at rather than whichever
+ * `has`/`get`/`run` happened to name-match most often. Only `pairKinds` are
+ * eligible: "Config to Config" is real traffic but not an interesting row.
+ */
+interface ModuleGroupRow {
+  source: string;
+  target: string;
+  kind: EdgeKind;
+  from: string;
+  to: string;
+  count: number;
+  declared: number;
+  uncertain: number;
+}
+
+interface ModuleLinkTotal {
+  source: string;
+  target: string;
+  kind: EdgeKind;
+  count: number;
+  declared: number;
+  uncertain: number;
+}
+
+interface ModulePairTotal {
+  source: string;
+  target: string;
+  from: string;
+  to: string;
+  count: number;
+  declared: number;
+}
+
+function foldModuleRows(
+  rows: ReadonlyArray<ModuleGroupRow>,
+  options: { topPairsPerLink: number; pairKinds: readonly EdgeKind[] }
+): { links: ModuleLinkTotal[]; pairs: ModulePairTotal[] } {
+  // A module id is a path and may contain anything printable, so the key
+  // separator has to be something a path cannot hold.
+  const SEP = '\u0000';
+  const links = new Map<string, ModuleLinkTotal>();
+  const pairKinds = new Set(options.pairKinds);
+  const wantPairs = options.topPairsPerLink > 0 && pairKinds.size > 0;
+  const pairTotals = new Map<string, ModulePairTotal>();
+
+  for (const row of rows) {
+    const linkKey = `${row.source}${SEP}${row.target}${SEP}${row.kind}`;
+    const link = links.get(linkKey);
+    if (link) {
+      link.count += row.count;
+      link.declared += row.declared;
+      link.uncertain += row.uncertain;
+    } else {
+      links.set(linkKey, {
+        source: row.source,
+        target: row.target,
+        kind: row.kind,
+        count: row.count,
+        declared: row.declared,
+        uncertain: row.uncertain,
+      });
+    }
+
+    // Only the confident half of a row can be named: an uncertain edge is a
+    // guess, and printing "a to b, 12" for twelve guesses is the map claiming
+    // something it does not know.
+    if (!wantPairs || row.count === 0 || !pairKinds.has(row.kind)) continue;
+    const pairKey = `${row.source}${SEP}${row.target}${SEP}${row.from}${SEP}${row.to}`;
+    const pair = pairTotals.get(pairKey);
+    if (pair) {
+      pair.count += row.count;
+      pair.declared += row.declared;
+    } else {
+      pairTotals.set(pairKey, {
+        source: row.source,
+        target: row.target,
+        from: row.from,
+        to: row.to,
+        count: row.count,
+        declared: row.declared,
+      });
+    }
+  }
+
+  const byLink = new Map<string, ModulePairTotal[]>();
+  for (const pair of pairTotals.values()) {
+    const key = `${pair.source}${SEP}${pair.target}`;
+    let list = byLink.get(key);
+    if (!list) byLink.set(key, (list = []));
+    list.push(pair);
+  }
+  const pairs: ModulePairTotal[] = [];
+  for (const list of byLink.values()) {
+    list.sort(
+      (a, b) =>
+        b.declared - a.declared ||
+        b.count - a.count ||
+        a.from.localeCompare(b.from) ||
+        a.to.localeCompare(b.to)
+    );
+    for (const pair of list.slice(0, options.topPairsPerLink)) pairs.push(pair);
+  }
+
+  return { links: [...links.values()], pairs };
+}

+ 29 - 0
src/index.ts

@@ -1400,6 +1400,35 @@ export class CodeGraph {
     );
   }
 
+  /**
+   * Roll the edge table up to module granularity, for a file → module
+   * assignment the caller decides.
+   *
+   * The architecture map's single query: cross-module edge counts by kind,
+   * the `declared` subset of each (see {@link QueryBuilder.aggregateModuleGraph}),
+   * and the busiest symbol pairs behind each link. Read-only, and bounded by
+   * the number of modules rather than the number of edges.
+   */
+  getModuleAggregation(
+    assignments: ReadonlyArray<{ filePath: string; module: string }>,
+    options: {
+      kinds: readonly Edge['kind'][];
+      minConfidence: number;
+      topPairsPerLink: number;
+      pairKinds: readonly Edge['kind'][];
+    }
+  ): ReturnType<QueryBuilder['aggregateModuleGraph']> {
+    return this.queries.aggregateModuleGraph(assignments, options);
+  }
+
+  /**
+   * Every ordered pair of files where one reaches into the other — the edge
+   * list a cycle finder runs on. See {@link QueryBuilder.getCrossFileDependencyPairs}.
+   */
+  getFileDependencyPairs(minConfidence = 0): Array<{ source: string; target: string }> {
+    return this.queries.getCrossFileDependencyPairs(minConfidence);
+  }
+
   /**
    * References from a symbol that never resolved to an indexed node — the
    * calls and type mentions that leave the index. Lets a reader account for

+ 16 - 1
src/ui-server/api/index.ts

@@ -1,7 +1,7 @@
 /**
  * The read-only JSON API the viewer reads its screens from.
  *
- * Eight endpoints, one per screen, each answering in a single round-trip — the
+ * Nine endpoints, one per screen, each answering in a single round-trip — the
  * same principle as `codegraph_explore`: return enough that the caller does not
  * have to ask a follow-up question. Everything here is a *reader* of the
  * existing schema; nothing indexes, resolves, or writes.
@@ -15,6 +15,7 @@
  * GET /api/file/<path>               the File view: outline and import rails
  * GET /api/routes                    the URL to handler map, when there is one
  * GET /api/entrypoints               where to start reading: routes, roots, hubs
+ * GET /api/map?root=&depth=          the module map: modules, links, cycles
  * ```
  *
  * It mounts on the `api` seam of `startUiServer`, which means it sits *behind*
@@ -37,12 +38,19 @@ import { buildFile } from './file';
 import { buildRoutes } from './routes';
 import { buildEntryPoints } from './entrypoints';
 import { buildNodeRefs } from './nodes';
+import { buildMap } from './map';
 
 export { GraphSession } from './session';
 export { ApiError } from './respond';
 export * from './wire';
 export type { WireEntryPoints, WireEntryFile, WireEntryHub } from './entrypoints';
 export type { WireNodeRefs } from './nodes';
+export type {
+  WireMapPayload,
+  WireMapModule,
+  WireMapLink,
+  WireMapCycle,
+} from './map';
 
 /**
  * A mounted API, plus the handle it holds open.
@@ -76,6 +84,11 @@ const API_INDEX = {
     },
     { path: '/api/file/<path>', description: 'One file: outline and import rails.' },
     { path: '/api/routes', description: 'URL to handler map, when the project is a routed app.', params: ['limit'] },
+    {
+      path: '/api/map',
+      description: 'The repository at module granularity: modules, cross-module links, cycles.',
+      params: ['root', 'depth'],
+    },
     {
       path: '/api/entrypoints',
       description: 'Where to start reading: routes, files that run something, and hubs.',
@@ -101,6 +114,8 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
           return ok(res, buildSearch(session.acquire(), ctx.query), ctx.method);
         case '/api/routes':
           return ok(res, buildRoutes(session.acquire(), ctx.query), ctx.method);
+        case '/api/map':
+          return ok(res, buildMap(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
         case '/api/entrypoints':
           return ok(res, buildEntryPoints(session.acquire(), ctx.query), ctx.method);
         case '/api/nodes':

+ 576 - 0
src/ui-server/api/map.ts

@@ -0,0 +1,576 @@
+/**
+ * `GET /api/map` — the repository at module granularity.
+ *
+ * The Map answers "what is in here and how is it organised" without anybody
+ * having drawn a diagram: modules are directories, the arrows between them are
+ * the edges the index already holds, and the vertical order falls out of the
+ * dependency direction (design spec §3.6). This module produces the *data*;
+ * the layering, cycle-breaking and geometry are pure functions in the viewer
+ * (`ui/src/lib/map-model.ts`), so toggling tests or selecting a module never
+ * costs a round-trip.
+ *
+ * Three decisions shape the payload, and all three are about not lying:
+ *
+ * **A module is a directory, not a guess.** `moduleIdFor` maps each indexed
+ * file to the first {@link MapQuery.depth} path segments under the chosen root.
+ * A file sitting loose in the root gets folded into one `(root files)` box —
+ * except a façade (`index.ts`, `lib.rs`, `__init__.py`), which is its own box
+ * because it is the thing everything else imports. No clustering, no
+ * heuristics about "what belongs together": if two files are in the same
+ * directory the repository already said they belong together.
+ *
+ * **Weight counts edges; layering counts *declared* edges.** A link's `count`
+ * is every confident cross-module edge behind it, which is what the reader
+ * sees as thickness. Its `declared` count is the subset resolved through an
+ * import, a qualified name, an inheritance clause or a typed receiver — and
+ * that is what the layout layers on. The difference is not academic: on this
+ * repository, bare name matching resolves calls to `run`, `push` and `finish`
+ * across unrelated directories, and layering on raw counts puts the storage
+ * layer directly under the CLI. Layering on declared edges reproduces the
+ * pipeline the project's own docs describe.
+ *
+ * **Nothing is dropped silently.** Uncertain edges (confidence below
+ * {@link UNCERTAIN_BELOW}) are excluded from every count, and how many were
+ * excluded rides on the payload so the side panel can say so.
+ */
+
+import type { CodeGraph } from '../../index';
+import type { EdgeKind, Language } from '../../types';
+import { isTestFile } from '../../search/query-utils';
+import { badRequest } from './respond';
+import { UNCERTAIN_BELOW, toPosixPath, wireList, type WireList } from './wire';
+
+/**
+ * The edge kinds that count as "module A reaches into module B".
+ *
+ * `contains` is absent on purpose — a file containing its own symbols is not a
+ * dependency, and including it would make every module depend on itself.
+ */
+export const MAP_EDGE_KINDS: readonly EdgeKind[] = [
+  'calls',
+  'imports',
+  'references',
+  'instantiates',
+  'extends',
+  'implements',
+];
+
+/**
+ * The kinds whose symbol pairs the tooltip names.
+ *
+ * A `references` edge to a type is real traffic but "Config → Config" is not
+ * an interesting row; calls and imports are what a reader wants named.
+ */
+const PAIR_EDGE_KINDS: readonly EdgeKind[] = ['calls', 'imports', 'instantiates'];
+
+/** Symbol pairs kept per link — the tooltip shows four (design spec §3.6). */
+const TOP_PAIRS_PER_LINK = 4;
+
+/**
+ * File paths listed per module.
+ *
+ * The panel's file list is a drill-down, not a directory listing, and it rides
+ * on this payload so that clicking a module and then one of its files costs no
+ * round-trip at all. Capped because a module can hold hundreds of files and the
+ * map is not where you read them; `total` stays the real number.
+ */
+const MAX_FILES_PER_MODULE = 40;
+
+/** Longest cycle reported, and how many. Beyond this a cycle list stops being readable. */
+const MAX_FILE_CYCLES = 40;
+const MAX_CYCLE_LENGTH = 12;
+
+/** Default segments below the root that name a module. */
+const DEFAULT_DEPTH = 1;
+const MAX_DEPTH = 4;
+
+/**
+ * Basenames that stay their own box when they sit loose in a module root.
+ *
+ * These are façades — the file every other module imports the directory
+ * *through*. Folding `src/index.ts` into a "(root files)" bucket with the type
+ * declarations next to it hides the busiest node on the map.
+ */
+const FACADE_STEMS = new Set(['index', 'main', 'lib', 'mod', '__init__', 'init']);
+
+/** Id of the bucket loose files fall into. Deliberately not a real directory name. */
+export function rootFilesId(root: string): string {
+  return root ? `${root}/(root files)` : '(root files)';
+}
+
+// =============================================================================
+// Wire shapes
+// =============================================================================
+
+export interface WireMapModule {
+  /** Directory path, or the `(root files)` bucket, or a façade file's own path. */
+  id: string;
+  /** Last path segment — what the node label shows when the id is long. */
+  label: string;
+  files: number;
+  symbols: number;
+  /** File count by language, most files first. */
+  languages: Array<{ language: Language; files: number }>;
+  /** More than half its files are tests — drawn dashed, hidden by default. */
+  test: boolean;
+  /** True when this box is a single file kept out of the root bucket (a façade). */
+  facade: boolean;
+  /** Its files, capped — what the side panel lists when the module is selected. */
+  fileList: WireList<string>;
+}
+
+export interface WireMapLink {
+  source: string;
+  target: string;
+  /** Every confident cross-module edge behind this link. Drives thickness. */
+  count: number;
+  /**
+   * The subset resolved through an import, a qualified name, an inheritance
+   * clause or a typed receiver. Drives the layering — see the module header.
+   */
+  declared: number;
+  /** `count` broken down by edge kind, biggest first. */
+  byKind: Array<{ kind: EdgeKind; count: number }>;
+  /**
+   * The busiest symbol pairs behind the link, at most
+   * {@link TOP_PAIRS_PER_LINK}, declared ones first.
+   */
+  topPairs: Array<{ from: string; to: string; count: number; declared: number }>;
+}
+
+export interface WireMapCycle {
+  /** How many files are in the component. `files` may be shorter. */
+  size: number;
+  /** The files, capped — a 200-file knot is a fact, not a list anybody reads. */
+  files: string[];
+  /** The modules the cycle passes through, deduped in order. */
+  modules: string[];
+}
+
+export interface WireMapPayload {
+  root: string;
+  depth: number;
+  /** Every root the selector may offer, this index's own directories. */
+  roots: Array<{ root: string; label: string; files: number }>;
+  modules: WireMapModule[];
+  links: WireMapLink[];
+  /**
+   * File-level circular dependencies — the strongly connected components of
+   * the file graph, which is what `findCircularDependencies` reports, computed
+   * from one query so it stays affordable on a large index.
+   */
+  cycles: { total: number; shown: number; truncated: boolean; items: WireMapCycle[] };
+  excluded: {
+    /** Cross-module edges left out for being name-only guesses. */
+    uncertainEdges: number;
+    /** The confidence floor applied. */
+    confidenceBelow: number;
+  };
+  index: { lastIndexedAt: number | null; edges: number; files: number };
+  /** How long the aggregation took, and whether this answer came from the cache. */
+  timing: { elapsedMs: number; cached: boolean };
+}
+
+export interface MapQuery {
+  root: string;
+  depth: number;
+}
+
+// =============================================================================
+// Module naming
+// =============================================================================
+
+/** Strip a trailing slash and any leading `./`, so `src/` and `src` are one root. */
+export function normalizeRoot(raw: string | undefined): string {
+  let root = (raw ?? '').trim().replace(/\\/g, '/');
+  while (root.startsWith('./')) root = root.slice(2);
+  while (root.endsWith('/')) root = root.slice(0, -1);
+  if (root === '.' || root === '/') return '';
+  return root;
+}
+
+function stemOf(basename: string): string {
+  const dot = basename.indexOf('.');
+  return dot <= 0 ? basename : basename.slice(0, dot);
+}
+
+/**
+ * Which module a file belongs to, or `null` when it is outside the root.
+ *
+ * `depth` segments under the root name the module. A file with fewer segments
+ * than that is loose in the root: a façade keeps its own box, everything else
+ * joins the `(root files)` bucket.
+ */
+export function moduleIdFor(
+  filePath: string,
+  root: string,
+  depth: number
+): { id: string; facade: boolean } | null {
+  const path = toPosixPath(filePath);
+  let rel = path;
+  if (root) {
+    if (!path.startsWith(`${root}/`)) return null;
+    rel = path.slice(root.length + 1);
+  }
+  const parts = rel.split('/').filter(Boolean);
+  if (parts.length === 0) return null;
+  if (parts.length <= depth) {
+    // A loose file. The directories it DOES have still qualify it, so
+    // `src/a/b.ts` at depth 2 lands in `src/a/(root files)`, not the top one.
+    const dir = [root, ...parts.slice(0, -1)].filter(Boolean).join('/');
+    if (FACADE_STEMS.has(stemOf(parts[parts.length - 1] ?? ''))) {
+      return { id: [root, ...parts].filter(Boolean).join('/'), facade: true };
+    }
+    return { id: rootFilesId(dir), facade: false };
+  }
+  return { id: [root, ...parts.slice(0, depth)].filter(Boolean).join('/'), facade: false };
+}
+
+/**
+ * The root the map opens on: the directory holding the most non-test symbols.
+ *
+ * A repository's source almost always lives under one directory (`src`, `lib`,
+ * `pkg`, `app`), and opening there is what keeps the default map about the
+ * program rather than about its tests, scripts and sibling packages. The
+ * fallback is the repository root, which is correct for a flat project.
+ *
+ * A directory only wins if it holds a clear majority of the symbols — anything
+ * less and the honest answer is "this repository has no single source root".
+ */
+export function pickDefaultRoot(
+  files: ReadonlyArray<{ path: string; symbols: number; test: boolean }>
+): string {
+  const byDir = new Map<string, number>();
+  let total = 0;
+  for (const file of files) {
+    if (file.test) continue;
+    const slash = file.path.indexOf('/');
+    if (slash <= 0) continue;
+    const dir = file.path.slice(0, slash);
+    byDir.set(dir, (byDir.get(dir) ?? 0) + file.symbols);
+    total += file.symbols;
+  }
+  if (total === 0) return '';
+  let best = '';
+  let bestSymbols = 0;
+  for (const [dir, symbols] of [...byDir].sort((a, b) => a[0].localeCompare(b[0]))) {
+    if (symbols > bestSymbols) {
+      best = dir;
+      bestSymbols = symbols;
+    }
+  }
+  return bestSymbols * 2 > total ? best : '';
+}
+
+// =============================================================================
+// Cache
+// =============================================================================
+
+/**
+ * One aggregation per (project, index build, root, depth).
+ *
+ * The map is the one screen whose cost is proportional to the whole edge
+ * table, so it is also the one screen worth caching. Keyed on the index's
+ * stamp AND its edge count, exactly as the blast scale is: a re-index or a
+ * sync that only moved edges must invalidate it, or the map draws a shape the
+ * code no longer has. A handful of entries, because the root selector is the
+ * only thing that varies.
+ */
+const CACHE_LIMIT = 8;
+const cache = new Map<string, WireMapPayload>();
+
+export function resetMapCache(): void {
+  cache.clear();
+}
+
+// =============================================================================
+// Build
+// =============================================================================
+
+export function parseMapQuery(query: URLSearchParams): { root: string | null; depth: number } {
+  const rawDepth = query.get('depth');
+  let depth = DEFAULT_DEPTH;
+  if (rawDepth !== null && rawDepth !== '') {
+    depth = Number.parseInt(rawDepth, 10);
+    if (!Number.isFinite(depth) || depth < 1 || depth > MAX_DEPTH) {
+      throw badRequest(`depth must be a whole number from 1 to ${MAX_DEPTH}.`);
+    }
+  }
+  const rawRoot = query.get('root');
+  return { root: rawRoot === null ? null : normalizeRoot(rawRoot), depth };
+}
+
+export function buildMap(cg: CodeGraph, projectRoot: string, query: URLSearchParams): WireMapPayload {
+  const started = Date.now();
+  const { root: requestedRoot, depth } = parseMapQuery(query);
+
+  const fileRecords = cg.getFiles().map((file) => {
+    const path = toPosixPath(file.path);
+    return {
+      path,
+      language: file.language,
+      symbols: file.nodeCount ?? 0,
+      test: isTestFile(path),
+    };
+  });
+
+  const root = requestedRoot ?? pickDefaultRoot(fileRecords);
+  const stats = cg.getStats();
+  const key = [
+    projectRoot,
+    cg.getLastIndexedAt() ?? 0,
+    stats.edgeCount,
+    stats.fileCount,
+    root,
+    depth,
+  ].join('\u0000');
+  const hit = cache.get(key);
+  if (hit) {
+    // Re-stamp rather than mutate: the cached body is shared, and a caller
+    // must not see another request's elapsed time.
+    return { ...hit, timing: { elapsedMs: Date.now() - started, cached: true } };
+  }
+
+  const assignments: Array<{ filePath: string; module: string }> = [];
+  const modules = new Map<
+    string,
+    {
+      id: string;
+      facade: boolean;
+      files: number;
+      symbols: number;
+      testFiles: number;
+      languages: Map<Language, number>;
+      paths: string[];
+    }
+  >();
+  const moduleOfFile = new Map<string, string>();
+
+  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);
+    if (!entry) {
+      entry = {
+        id: assigned.id,
+        facade: assigned.facade,
+        files: 0,
+        symbols: 0,
+        testFiles: 0,
+        languages: new Map(),
+        paths: [],
+      };
+      modules.set(assigned.id, entry);
+    }
+    entry.files += 1;
+    entry.paths.push(file.path);
+    entry.symbols += file.symbols;
+    if (file.test) entry.testFiles += 1;
+    entry.languages.set(file.language, (entry.languages.get(file.language) ?? 0) + 1);
+  }
+
+  const aggregation = cg.getModuleAggregation(assignments, {
+    kinds: MAP_EDGE_KINDS,
+    minConfidence: UNCERTAIN_BELOW,
+    topPairsPerLink: TOP_PAIRS_PER_LINK,
+    pairKinds: PAIR_EDGE_KINDS,
+  });
+
+  const links = new Map<string, WireMapLink>();
+  let uncertainEdges = 0;
+  for (const row of aggregation.links) {
+    // The same pass counts what the confidence floor left out, so the "N
+    // name-only matches excluded" note reports the number the map actually
+    // applied rather than a second query's opinion of it.
+    uncertainEdges += row.uncertain;
+    if (row.count === 0) continue;
+    const id = `${row.source}\u0000${row.target}`;
+    let link = links.get(id);
+    if (!link) {
+      link = { source: row.source, target: row.target, count: 0, declared: 0, byKind: [], topPairs: [] };
+      links.set(id, link);
+    }
+    link.count += row.count;
+    link.declared += row.declared;
+    link.byKind.push({ kind: row.kind, count: row.count });
+  }
+  for (const link of links.values()) {
+    link.byKind.sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind));
+  }
+  for (const pair of aggregation.pairs) {
+    const link = links.get(`${pair.source}\u0000${pair.target}`);
+    if (link && link.topPairs.length < TOP_PAIRS_PER_LINK) {
+      link.topPairs.push({
+        from: pair.from,
+        to: pair.to,
+        count: pair.count,
+        declared: pair.declared,
+      });
+    }
+  }
+
+  const payload: WireMapPayload = {
+    root,
+    depth,
+    roots: rootOptions(fileRecords),
+    modules: [...modules.values()]
+      .map((entry) => ({
+        id: entry.id,
+        label: entry.id.slice(entry.id.lastIndexOf('/') + 1) || entry.id,
+        files: entry.files,
+        symbols: entry.symbols,
+        languages: [...entry.languages]
+          .map(([language, files]) => ({ language, files }))
+          .sort((a, b) => b.files - a.files || a.language.localeCompare(b.language)),
+        test: entry.testFiles * 2 > entry.files,
+        facade: entry.facade,
+        fileList: wireList(entry.paths.slice().sort().slice(0, MAX_FILES_PER_MODULE), entry.files),
+      }))
+      // Sorted so two runs over one index produce byte-identical payloads —
+      // the layout is deterministic, and it cannot be if its input is not.
+      .sort((a, b) => a.id.localeCompare(b.id)),
+    links: [...links.values()].sort(
+      (a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target)
+    ),
+    cycles: fileCycles(cg, moduleOfFile),
+    excluded: { uncertainEdges, confidenceBelow: UNCERTAIN_BELOW },
+    index: {
+      lastIndexedAt: cg.getLastIndexedAt(),
+      edges: stats.edgeCount,
+      files: stats.fileCount,
+    },
+    timing: { elapsedMs: Date.now() - started, cached: false },
+  };
+
+  if (cache.size >= CACHE_LIMIT) {
+    const oldest = cache.keys().next();
+    if (!oldest.done) cache.delete(oldest.value);
+  }
+  cache.set(key, payload);
+  return payload;
+}
+
+/**
+ * File-level circular dependencies, as strongly connected components.
+ *
+ * Tarjan over the one-query file edge list. Components of size 1 are not
+ * cycles (a file depending on itself is a same-file edge, already excluded),
+ * and a component longer than {@link MAX_CYCLE_LENGTH} is reported truncated
+ * rather than printed — a 200-file knot is a fact about the repository, not a
+ * list anybody reads.
+ */
+function fileCycles(
+  cg: CodeGraph,
+  moduleOfFile: Map<string, string>
+): WireMapPayload['cycles'] {
+  const adjacency = new Map<string, string[]>();
+  for (const pair of cg.getFileDependencyPairs(UNCERTAIN_BELOW)) {
+    if (!moduleOfFile.has(pair.source) || !moduleOfFile.has(pair.target)) continue;
+    let out = adjacency.get(pair.source);
+    if (!out) adjacency.set(pair.source, (out = []));
+    out.push(pair.target);
+  }
+  // Deterministic iteration: SQLite's DISTINCT ordering is not a contract.
+  const nodes = [...new Set([...adjacency.keys(), ...[...adjacency.values()].flat()])].sort();
+  for (const list of adjacency.values()) list.sort();
+
+  const components = tarjan(nodes, (id) => adjacency.get(id) ?? []);
+  const cycles = components
+    .filter((component) => component.length > 1)
+    .map((component) => component.slice().sort())
+    .sort((a, b) => a.length - b.length || (a[0] ?? '').localeCompare(b[0] ?? ''));
+
+  const items = cycles.slice(0, MAX_FILE_CYCLES).map((files) => ({
+    size: files.length,
+    files: files.slice(0, MAX_CYCLE_LENGTH),
+    modules: [...new Set(files.map((file) => moduleOfFile.get(file) ?? file))].sort(),
+  }));
+  return {
+    total: cycles.length,
+    shown: items.length,
+    truncated: cycles.length > items.length,
+    items,
+  };
+}
+
+/** Tarjan's strongly connected components, iterative so a deep graph cannot blow the stack. */
+function tarjan(nodes: readonly string[], edgesOf: (id: string) => readonly string[]): string[][] {
+  const index = new Map<string, number>();
+  const low = new Map<string, number>();
+  const onStack = new Set<string>();
+  const stack: string[] = [];
+  const out: string[][] = [];
+  let counter = 0;
+
+  for (const start of nodes) {
+    if (index.has(start)) continue;
+    const work: Array<{ id: string; edges: readonly string[]; at: number }> = [
+      { id: start, edges: edgesOf(start), at: 0 },
+    ];
+    index.set(start, counter);
+    low.set(start, counter);
+    counter += 1;
+    stack.push(start);
+    onStack.add(start);
+
+    while (work.length > 0) {
+      const frame = work[work.length - 1];
+      if (frame === undefined) break;
+      if (frame.at < frame.edges.length) {
+        const next = frame.edges[frame.at]!;
+        frame.at += 1;
+        if (!index.has(next)) {
+          index.set(next, counter);
+          low.set(next, counter);
+          counter += 1;
+          stack.push(next);
+          onStack.add(next);
+          work.push({ id: next, edges: edgesOf(next), at: 0 });
+        } else if (onStack.has(next)) {
+          low.set(frame.id, Math.min(low.get(frame.id) ?? 0, index.get(next) ?? 0));
+        }
+        continue;
+      }
+      work.pop();
+      if (low.get(frame.id) === index.get(frame.id)) {
+        const component: string[] = [];
+        for (;;) {
+          const popped = stack.pop();
+          if (popped === undefined) break;
+          onStack.delete(popped);
+          component.push(popped);
+          if (popped === frame.id) break;
+        }
+        out.push(component);
+      }
+      const parent = work[work.length - 1];
+      if (parent) low.set(parent.id, Math.min(low.get(parent.id) ?? 0, low.get(frame.id) ?? 0));
+    }
+  }
+  return out;
+}
+
+/**
+ * The roots the selector offers: the repository root plus every top-level
+ * directory that holds indexed files, biggest first.
+ *
+ * A monorepo's answer to "which project am I looking at" — and on a single
+ * project it is a one-line list nobody has to use.
+ */
+function rootOptions(
+  files: ReadonlyArray<{ path: string; symbols: number }>
+): WireMapPayload['roots'] {
+  const byDir = new Map<string, number>();
+  for (const file of files) {
+    const slash = file.path.indexOf('/');
+    if (slash <= 0) continue;
+    const dir = file.path.slice(0, slash);
+    byDir.set(dir, (byDir.get(dir) ?? 0) + 1);
+  }
+  const dirs = [...byDir]
+    .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+    .map(([root, count]) => ({ root, label: root, files: count }));
+  return [{ root: '', label: 'whole repository', files: files.length }, ...dirs];
+}

+ 3 - 2
ui/README.md

@@ -42,7 +42,8 @@ src/
   lib/router.svelte.ts    hash router: #/s/<id>, #/file/<path>, #/map, #/flow
   lib/trail.svelte.ts     the walked path; mirrored into the `t` query param
   lib/kinds.ts            kind glyph letters
-  components/             TopBar, TrailBar, KindGlyph
+  lib/map-model.ts        the Map's deterministic layered layout (pure)
+  components/             TopBar, TrailBar, KindGlyph, map/, symbol/, file/
   views/                  one component per route
 ```
 
@@ -57,7 +58,7 @@ announce the project to a font CDN.
 | `#/` | nothing selected |
 | `#/s/<id>?hl=<line>&t=<trail>` | symbol view |
 | `#/file/<path>?hl=<line>` | file view |
-| `#/map` | module map — reserved, phase 2 |
+| `#/map?root=&depth=&tests=1` | module map |
 | `#/flow[/<key>]` | flow strip — reserved, phase 2 |
 
 Node ids and file paths are encoded per slash-separated segment, so

+ 1 - 0
ui/package.json

@@ -15,6 +15,7 @@
     "@fontsource-variable/archivo": "^5.3.0",
     "@fontsource/ibm-plex-mono": "^5.3.0",
     "@sveltejs/vite-plugin-svelte": "^6.2.4",
+    "@xyflow/svelte": "^1.6.5",
     "svelte": "^5.56.10",
     "svelte-check": "^4.7.6",
     "typescript": "^5.0.0",

+ 1 - 1
ui/src/App.svelte

@@ -98,7 +98,7 @@
   {:else if route.view === 'file'}
     <FileView path={route.path} line={route.line} />
   {:else if route.view === 'map'}
-    <MapView />
+    <MapView root={route.root} depth={route.depth} tests={route.tests} />
   {:else if route.view === 'flow'}
     <FlowView flowKey={route.key} />
   {:else if route.view === 'unknown'}

+ 373 - 0
ui/src/components/map/MapSidePanel.svelte

@@ -0,0 +1,373 @@
+<!--
+  The Map's 320px side panel (design spec §3.6).
+
+  Three jobs, in the order a reader needs them: say what the picture IS and how
+  it was derived, account for everything the picture leaves out, and — once a
+  module is selected — become that module's dependency sheet.
+
+  The accounting is not decoration. A map that hides thin links, drops
+  name-only edges and layers on declared ones is a map with three deliberate
+  omissions in it; each of them gets a sentence here, because a diagram nobody
+  can audit is a diagram that gets believed too much.
+-->
+<script lang="ts">
+  import { fileHref } from '../../lib/router.svelte';
+  import { plural } from '../../lib/symbol-model';
+  import type { WireMapLink, WireMapPayload } from '../../lib/api';
+  import type { MapLayout } from '../../lib/map-model';
+
+  interface Props {
+    payload: WireMapPayload;
+    layout: MapLayout;
+    selected: string | null;
+    includeTests: boolean;
+    files: string[];
+    onToggleTests: (value: boolean) => void;
+    onSelectRoot: (root: string) => void;
+    onSelect: (id: string | null) => void;
+  }
+
+  let {
+    payload,
+    layout,
+    selected,
+    includeTests,
+    files,
+    onToggleTests,
+    onSelectRoot,
+    onSelect,
+  }: Props = $props();
+
+  const selectedModule = $derived(
+    selected === null ? null : (layout.nodes.find((n) => n.id === selected)?.module ?? null)
+  );
+
+  const dependencies = $derived(
+    selected === null
+      ? []
+      : layout.edges
+          .filter((e) => e.source === selected)
+          .map((e) => e.link)
+          .sort((a, b) => b.count - a.count || a.target.localeCompare(b.target))
+  );
+  const dependents = $derived(
+    selected === null
+      ? []
+      : layout.edges
+          .filter((e) => e.target === selected)
+          .map((e) => e.link)
+          .sort((a, b) => b.count - a.count || a.source.localeCompare(b.source))
+  );
+
+  const thinCount = $derived(layout.edges.filter((e) => e.thin && !e.back).length);
+</script>
+
+<aside class="mapside">
+  <h2>Architecture map</h2>
+  <p>
+    Derived from the graph, not drawn by hand: each module sits one layer above the modules it
+    depends on, so reading top to bottom follows the dependency direction. Line weight is how many
+    calls, imports and type references cross the link.
+  </p>
+
+  <label class="field">
+    <span>Showing</span>
+    <select
+      value={payload.root}
+      onchange={(event) => onSelectRoot((event.currentTarget as HTMLSelectElement).value)}
+    >
+      {#each payload.roots as option (option.root)}
+        <option value={option.root}>{option.label} · {option.files} files</option>
+      {/each}
+    </select>
+  </label>
+
+  <label class="toggle">
+    <input
+      type="checkbox"
+      checked={includeTests}
+      onchange={(event) => onToggleTests((event.currentTarget as HTMLInputElement).checked)}
+    />
+    Include test modules
+  </label>
+
+  <div class="notes">
+    {#if thinCount > 0}
+      <p class="dim">
+        {plural(thinCount, 'link')} carrying fewer than {layout.minWeight} references
+        {thinCount === 1 ? 'is' : 'are'} hidden until you select a module {thinCount === 1
+          ? 'it'
+          : 'they'} touch.
+      </p>
+    {/if}
+    {#if layout.basis.kind === 'declared'}
+      <p class="dim">
+        The layering uses the {layout.basis.declaredLinks} of {layout.basis.totalLinks} links with an
+        import, a qualified name, an inheritance clause or a typed receiver behind them. Bare
+        name matches still count toward line weight, but they do not decide what sits above what.
+      </p>
+    {:else}
+      <p class="dim">
+        Too few links here carry an import or a declared type, so the layering uses raw reference
+        counts. A name shared by two unrelated modules can move a box.
+      </p>
+    {/if}
+    {#if payload.excluded.uncertainEdges > 0}
+      <p class="dim">
+        {plural(payload.excluded.uncertainEdges, 'cross-module reference')} below confidence {payload
+          .excluded.confidenceBelow}
+        {payload.excluded.uncertainEdges === 1 ? 'is' : 'are'} excluded from every count on this
+        screen — they are name-only guesses.
+      </p>
+    {/if}
+  </div>
+
+  {#if layout.mutual.length > 0}
+    <details>
+      <summary>
+        Mutual dependencies
+        <span class="dim">
+          · {plural(layout.mutual.length, 'pair')} — the lighter direction, dashed when
+          selected
+        </span>
+      </summary>
+      {#each layout.mutual.slice(0, 8) as pair (pair.back.source + pair.back.target)}
+        <div class="cyc">
+          <b>{pair.back.source}</b> ⇄ {pair.back.target}
+          <span class="dim">({pair.back.count} back-references)</span>
+        </div>
+      {/each}
+      {#if layout.mutual.length > 8}
+        <div class="cyc dim">+{layout.mutual.length - 8} more</div>
+      {/if}
+    </details>
+  {/if}
+
+  {#if layout.moduleCycles.length > 0}
+    <details>
+      <summary>
+        Dependency cycles
+        <span class="dim">
+          · {plural(layout.moduleCycles.length, 'loop')} of three or more modules
+        </span>
+      </summary>
+      {#each layout.moduleCycles.slice(0, 6) as cycle, i (i)}
+        <div class="cyc">{cycle.join(' → ')} → {cycle[0]}</div>
+      {/each}
+    </details>
+  {/if}
+
+  {#if payload.cycles.total > 0}
+    <details>
+      <summary>
+        Circular imports between files
+        <span class="dim">
+          · {plural(payload.cycles.total, 'group')}
+        </span>
+      </summary>
+      {#each payload.cycles.items.slice(0, 6) as cycle, i (i)}
+        <div class="cyc">
+          <span class="dim">{cycle.size} files ·</span>
+          {cycle.modules.join(', ')}
+        </div>
+        {#each cycle.files as file (file)}
+          <a class="filerow" href={fileHref(file)}>{file}</a>
+        {/each}
+        {#if cycle.size > cycle.files.length}
+          <div class="cyc dim">+{cycle.size - cycle.files.length} more files in this group</div>
+        {/if}
+      {/each}
+      {#if payload.cycles.truncated}
+        <div class="cyc dim">+{payload.cycles.total - payload.cycles.shown} more groups</div>
+      {/if}
+    </details>
+  {/if}
+
+  {#if selectedModule}
+    <div class="edgeinfo">
+      <div class="head">
+        <b class="mono">{selectedModule.id}</b>
+        <button class="clear" onclick={() => onSelect(null)}>clear</button>
+      </div>
+      <p>
+        {plural(selectedModule.symbols, 'symbol')} in {plural(selectedModule.files, 'file')}
+        {#if selectedModule.languages.length > 0}
+          · {selectedModule.languages.map((l) => `${l.language} ${l.files}`).join(', ')}
+        {/if}
+      </p>
+
+      {@render linkList('depends on', dependencies, 'target')}
+      {@render linkList('depended on by', dependents, 'source')}
+
+      <div class="pair label">files</div>
+      {#if files.length > 0}
+        {#each files as file (file)}
+          <a class="filerow" href={fileHref(file)}>{file}</a>
+        {/each}
+      {:else}
+        <div class="pair dim">no files in the index for this module</div>
+      {/if}
+    </div>
+  {:else}
+    <div class="edgeinfo">
+      <p class="dim">
+        Hover a link to see what crosses it — the counts by kind and the symbol pairs behind the
+        weight. Click a module to isolate its links and list its files.
+      </p>
+    </div>
+  {/if}
+</aside>
+
+{#snippet linkList(label: string, links: WireMapLink[], side: 'source' | 'target')}
+  <div class="pair label">{label}</div>
+  {#if links.length > 0}
+    {#each links as link (link.source + link.target)}
+      <div class="pair">
+        <b>{side === 'target' ? link.target : link.source}</b>
+        <span>{link.count}</span>
+      </div>
+    {/each}
+  {:else}
+    <div class="pair dim">nothing</div>
+  {/if}
+{/snippet}
+
+<style>
+  .mapside {
+    border-left: 1px solid var(--rule-soft);
+    overflow: auto;
+    padding: 14px 16px;
+    background: var(--paper);
+  }
+  h2 {
+    margin: 0 0 6px;
+    font-size: 15px;
+    font-weight: 600;
+  }
+  p {
+    margin: 0 0 10px;
+    color: var(--ink-2);
+    font-size: 12.5px;
+    line-height: 1.5;
+    max-width: 40ch;
+  }
+  .dim {
+    color: var(--ink-3);
+  }
+  .notes p {
+    font-size: 11.5px;
+    margin-bottom: 8px;
+  }
+  .field {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    font-size: 12.5px;
+    color: var(--ink-2);
+    margin: 12px 0 8px;
+  }
+  .field select {
+    flex: 1 1 auto;
+    min-width: 0;
+    font: 12px var(--mono);
+    color: var(--ink);
+    background: var(--paper);
+    border: 1px solid var(--rule-soft);
+    border-radius: 0;
+    padding: 3px 4px;
+  }
+  .toggle {
+    display: flex;
+    gap: 8px;
+    align-items: center;
+    font-size: 12.5px;
+    color: var(--ink-2);
+    margin: 0 0 12px;
+    cursor: pointer;
+  }
+  .toggle input {
+    margin: 0;
+    accent-color: var(--ink);
+  }
+  details {
+    margin: 4px 0 10px;
+  }
+  summary {
+    cursor: pointer;
+    font-weight: 600;
+    font-size: 12.5px;
+    list-style: none;
+  }
+  summary::-webkit-details-marker {
+    display: none;
+  }
+  summary .dim {
+    font-weight: 400;
+  }
+  .cyc {
+    font: 11.5px var(--mono);
+    color: var(--ink-2);
+    padding: 3px 0;
+  }
+  .cyc b {
+    color: var(--accent);
+    font-weight: 500;
+  }
+  .edgeinfo {
+    margin-top: 12px;
+    border-top: 1px solid var(--rule-soft);
+    padding-top: 10px;
+  }
+  .head {
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: 8px;
+  }
+  .mono {
+    font: 500 12.5px var(--mono);
+  }
+  .clear {
+    border: 0;
+    background: none;
+    padding: 0;
+    font: 11.5px var(--sans);
+    color: var(--ink-3);
+    cursor: pointer;
+    text-decoration: underline;
+  }
+  .clear:hover {
+    color: var(--accent);
+  }
+  .pair {
+    font: 11.5px var(--mono);
+    color: var(--ink-2);
+    padding: 2px 0;
+    display: flex;
+    justify-content: space-between;
+    gap: 10px;
+  }
+  .pair b {
+    color: var(--ink);
+    font-weight: 500;
+  }
+  .pair.label {
+    font: 400 11.5px var(--sans);
+    color: var(--ink-3);
+    margin-top: 8px;
+  }
+  .filerow {
+    display: block;
+    font: 11.5px var(--mono);
+    color: var(--ink-2);
+    padding: 2px 0;
+    text-decoration: none;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  .filerow:hover {
+    color: var(--accent);
+    text-decoration: underline;
+  }
+</style>

+ 75 - 0
ui/src/components/map/ModuleEdge.svelte

@@ -0,0 +1,75 @@
+<script lang="ts">
+  /**
+   * One dependency link on the Map (design spec §3.6).
+   *
+   * A cubic that leaves the source's bottom port and arrives at the target's
+   * top port through the vertical midpoint, so every edge in a bundle bends the
+   * same way and the crossings stay readable. Width is `min(6, 1 + log2(count)
+   * x 0.7)`: a link carrying 700 edges must look heavier than one carrying 7
+   * without being a hundred times fatter.
+   *
+   * A second, transparent, 12px-wide copy of the same path is the hit target —
+   * a 1px stroke is not something anyone can hover on purpose.
+   *
+   * Back-edges (a mutual dependency's lighter direction, or a link with nothing
+   * declared behind it) are dashed in the accent. They point *up* the layering,
+   * which is exactly why they are worth marking rather than straightening out.
+   */
+  import { BaseEdge, type EdgeProps } from '@xyflow/svelte';
+  import type { MapEdgeLayout } from '../../lib/map-model';
+
+  let { sourceX, sourceY, targetX, targetY, data }: EdgeProps = $props();
+
+  const d = $derived(
+    data as unknown as {
+      edge: MapEdgeLayout;
+      hot: boolean;
+      dimmed: boolean;
+      onHover: (edge: MapEdgeLayout | null, event: MouseEvent | null) => void;
+    }
+  );
+
+  const path = $derived.by(() => {
+    const midY = (sourceY + targetY) / 2;
+    return `M${sourceX},${sourceY} C${sourceX},${midY} ${targetX},${midY} ${targetX},${targetY}`;
+  });
+</script>
+
+<BaseEdge
+  {path}
+  class={`medge${d.edge.back ? ' back' : ''}${d.hot ? ' hot' : ''}${d.dimmed ? ' dimmed' : ''}`}
+  style={`stroke-width:${d.edge.width}px`}
+/>
+<path
+  class="hit"
+  d={path}
+  role="presentation"
+  onmousemove={(event) => d.onHover(d.edge, event)}
+  onmouseleave={() => d.onHover(null, null)}
+/>
+
+<style>
+  :global(.svelte-flow__edge-path.medge) {
+    stroke: var(--ink);
+    stroke-opacity: 0.28;
+    fill: none;
+  }
+  :global(.svelte-flow__edge-path.medge.hot) {
+    stroke-opacity: 0.95;
+  }
+  :global(.svelte-flow__edge-path.medge.dimmed) {
+    stroke-opacity: 0.06;
+  }
+  :global(.svelte-flow__edge-path.medge.back) {
+    stroke: var(--accent);
+    stroke-opacity: 0.6;
+    stroke-dasharray: 4 3;
+  }
+  .hit {
+    stroke: transparent;
+    stroke-width: 12;
+    fill: none;
+    pointer-events: stroke;
+    cursor: crosshair;
+  }
+</style>

+ 123 - 0
ui/src/components/map/ModuleNode.svelte

@@ -0,0 +1,123 @@
+<script lang="ts">
+  /**
+   * One module box on the Map (design spec §3.6): a 40px rectangle carrying
+   * the module's path and what is inside it.
+   *
+   * The handles are the point of the component. Svelte Flow routes an edge
+   * between two handles, so giving each box one hidden handle per link — laid
+   * out along its top and bottom edges at `(i+1)/(n+1)` — is what makes a
+   * bundle of eight dependencies fan across the box instead of converging on a
+   * single corner. They are invisible and non-connectable: this canvas is a
+   * drawing, never an editor.
+   */
+  import { Handle, Position, type NodeProps } from '@xyflow/svelte';
+  import { moduleMetaLabel, type MapNodeLayout } from '../../lib/map-model';
+
+  let { data }: NodeProps = $props();
+
+  const node = $derived(
+    data as unknown as {
+      layout: MapNodeLayout;
+      selected: boolean;
+      dimmed: boolean;
+      onSelect: (id: string) => void;
+    }
+  );
+  const layout = $derived(node.layout);
+  const module = $derived(layout.module);
+
+  function portStyle(index: number, total: number): string {
+    return `left:${((index + 1) / (total + 1)) * 100}%`;
+  }
+</script>
+
+{#each layout.targetHandles as handle, i (handle)}
+  <Handle
+    type="target"
+    id={`t:${handle}`}
+    position={Position.Top}
+    style={portStyle(i, layout.targetHandles.length)}
+    isConnectable={false}
+  />
+{/each}
+
+<button
+  class="mnode"
+  class:sel={node.selected}
+  class:dimmed={node.dimmed}
+  class:test={module.test}
+  style={`width:${layout.width}px;height:${layout.height}px`}
+  onclick={() => node.onSelect(layout.id)}
+  aria-pressed={node.selected}
+  title={`${module.id} — ${module.symbols} symbols in ${module.files} file${module.files === 1 ? '' : 's'}`}
+>
+  <span class="name">{module.id}</span>
+  <!-- The same string nodeWidth() sized the box for; they must not drift. -->
+  <span class="count">{moduleMetaLabel(module)}</span>
+</button>
+
+{#each layout.sourceHandles as handle, i (handle)}
+  <Handle
+    type="source"
+    id={`s:${handle}`}
+    position={Position.Bottom}
+    style={portStyle(i, layout.sourceHandles.length)}
+    isConnectable={false}
+  />
+{/each}
+
+<style>
+  .mnode {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    gap: 1px;
+    box-sizing: border-box;
+    padding: 0 9px;
+    border: 1px solid var(--ink);
+    border-radius: 0;
+    background: var(--paper);
+    text-align: left;
+    cursor: pointer;
+    font: inherit;
+    color: var(--ink);
+    transition: background 90ms linear;
+  }
+  .mnode:hover,
+  .mnode.sel {
+    border-width: 2px;
+    padding: 0 8px;
+    background: var(--press);
+  }
+  .mnode.dimmed {
+    border-color: var(--ink-4);
+    color: var(--ink-4);
+  }
+  .mnode.dimmed .count {
+    color: var(--ink-4);
+  }
+  /* Test modules read as scaffolding, not as part of the program. */
+  .mnode.test {
+    border-style: dashed;
+    border-color: var(--ink-3);
+  }
+  .mnode:focus-visible {
+    outline: 2px solid var(--accent);
+    outline-offset: 1px;
+  }
+  .name {
+    font: 500 13px var(--mono);
+    line-height: 15px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+  .count {
+    font: 400 11px var(--sans);
+    line-height: 13px;
+    color: var(--ink-3);
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+</style>

+ 66 - 0
ui/src/lib/api.ts

@@ -367,6 +367,55 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
   return body as T;
 }
 
+/* -------------------------------------------------------------- the map -- */
+
+export interface WireMapModule {
+  /** Directory path, the `(root files)` bucket, or a façade file's own path. */
+  id: string;
+  label: string;
+  files: number;
+  symbols: number;
+  languages: Array<{ language: string; files: number }>;
+  /** More than half its files are tests. */
+  test: boolean;
+  /** A single file kept out of the root bucket because it is the façade. */
+  facade: boolean;
+  /** Its files, capped — the side panel's list when the module is selected. */
+  fileList: { total: number; shown: number; truncated: boolean; items: string[] };
+}
+
+export interface WireMapLink {
+  source: string;
+  target: string;
+  /** Every confident cross-module edge behind this link. */
+  count: number;
+  /**
+   * The subset resolved through an import, a qualified name, an inheritance
+   * clause or a typed receiver — what the layering trusts.
+   */
+  declared: number;
+  byKind: Array<{ kind: EdgeKind; count: number }>;
+  topPairs: Array<{ from: string; to: string; count: number; declared: number }>;
+}
+
+export interface WireMapCycle {
+  size: number;
+  files: string[];
+  modules: string[];
+}
+
+export interface WireMapPayload {
+  root: string;
+  depth: number;
+  roots: Array<{ root: string; label: string; files: number }>;
+  modules: WireMapModule[];
+  links: WireMapLink[];
+  cycles: { total: number; shown: number; truncated: boolean; items: WireMapCycle[] };
+  excluded: { uncertainEdges: number; confidenceBelow: number };
+  index: { lastIndexedAt: number | null; edges: number; files: number };
+  timing: { elapsedMs: number; cached: boolean };
+}
+
 export function fetchStats(signal?: AbortSignal): Promise<WireStats> {
   return getJson<WireStats>('api/stats', signal);
 }
@@ -421,3 +470,20 @@ export function fetchSource(
   const params = new URLSearchParams({ file, from: String(from), to: String(to) });
   return getJson<WireSource>(`api/source?${params}`, signal);
 }
+
+
+/**
+ * The module map. `root` selects the subtree (a monorepo's package); `depth`
+ * is how many path segments under it name a module. Omitting `root` lets the
+ * server pick the repository's source directory.
+ */
+export function fetchMap(
+  opts: { root?: string | null; depth?: number } = {},
+  signal?: AbortSignal
+): Promise<WireMapPayload> {
+  const params = new URLSearchParams();
+  if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
+  if (opts.depth) params.set('depth', String(opts.depth));
+  const query = params.toString();
+  return getJson<WireMapPayload>(`api/map${query ? `?${query}` : ''}`, signal);
+}

+ 489 - 0
ui/src/lib/map-model.ts

@@ -0,0 +1,489 @@
+/**
+ * The Map's layout — deterministic, and computed here rather than by a physics
+ * simulation (design spec §3.6, epic rule 2).
+ *
+ * Everything in this file is a pure function of the `/api/map` payload plus two
+ * switches (include tests, which module is selected). That is what lets the
+ * canvas re-render on a toggle without a round-trip, and what lets the layout
+ * be unit-tested — a force-directed graph settles somewhere slightly different
+ * every time you open it, and a diagram you cannot recognise between two visits
+ * is not a map of anything.
+ *
+ * The pipeline, in order:
+ *
+ * 1. **Filter.** Drop test modules unless asked for; drop links whose ends went
+ *    with them.
+ * 2. **Pick a layering basis.** Prefer each link's `declared` weight — the
+ *    edges resolved through an import, a qualified name, an inheritance clause
+ *    or a typed receiver. Bare name matching resolves calls to `run`, `push`
+ *    and `finish` across unrelated directories, and letting those set the
+ *    vertical order puts the storage layer under the CLI. When too few links
+ *    carry a declared edge to describe the repository (a language whose
+ *    imports the resolver cannot follow), fall back to raw counts and say so.
+ * 3. **Break two-cycles.** Keep the heavier direction; the lighter one becomes
+ *    a mutual dependency, drawn only when one of its modules is selected.
+ * 4. **Layer.** Longest path: a module sits one layer above everything it
+ *    depends on. Layer 0 is the foundations, at the bottom.
+ * 5. **Order.** Barycenter, three sweeps, from a stable alphabetical start.
+ * 6. **Place, then port.** Boxes get x/y; each edge gets a distinct port along
+ *    its endpoints' edges so a bundle fans out instead of knotting at a corner.
+ *
+ * An edge that points *up* after all that — a broken two-cycle, or a link with
+ * no declared edge behind it — is marked `back` and drawn only when a module it
+ * touches is selected. Drawing it downward would be a lie about the direction
+ * of the dependency; hiding it entirely would be a lie about its existence.
+ */
+
+import type { WireMapLink, WireMapModule, WireMapPayload } from './api';
+
+// Geometry, from the design spec. Changing these changes the picture.
+export const NODE_HEIGHT = 40;
+export const LAYER_GAP = 74;
+export const NODE_GAP = 34;
+export const PADDING = 44;
+/** Least horizontal room a layer gets per module, so a sparse row still spreads. */
+const MIN_SLOT = 230;
+const MIN_NODE_WIDTH = 110;
+/**
+ * IBM Plex Mono's real advance at 13px (0.6em), not the spec's 7.3 estimate.
+ *
+ * The prototype drew labels as SVG text that spilled harmlessly past the
+ * rectangle, so 7.3 was close enough there. An HTML box clips instead, and at
+ * 7.3 a 27-character id like `src/resolution/(root files)` lost its last
+ * characters to an ellipsis — measured in the browser: 211px of text in 205px
+ * of box. Padding is the box's own 9px each side plus its 1px borders.
+ */
+const CHAR_WIDTH = 7.81;
+const LABEL_PADDING = 22;
+
+/** Links below this weight stay hidden until a module they touch is selected. */
+export const MIN_WEIGHT = 4;
+/** …raised when tests are included, because a test module touches everything. */
+export const MIN_WEIGHT_WITH_TESTS = 6;
+
+/**
+ * Share of links that must carry a declared edge for the declared basis to be
+ * used. Below this the declared graph is too sparse to describe the repository
+ * — most modules would land on layer 0 with nothing explaining why — and the
+ * layout falls back to raw counts, announced in the side panel, never silent.
+ *
+ * Two thirds of this repository's links are declared at every depth, and the
+ * same holds for any language whose imports the resolver can follow; the
+ * fallback exists for the ones where it cannot.
+ */
+const DECLARED_BASIS_COVERAGE = 0.4;
+
+/** Approximate advance of the 11px sans meta line, measured against Archivo. */
+const META_CHAR_WIDTH = 5.9;
+const META_PADDING = 24;
+
+/**
+ * A box wide enough for BOTH of its lines.
+ *
+ * The spec sizes a node from its label (`label.length x 7.3 + 28`); the
+ * prototype's SVG let the "N symbols · M files" line spill outside the
+ * rectangle, which an HTML box cannot do without looking broken. So the width
+ * is the wider of the two lines. Same formula for the label, same determinism,
+ * and `src/bin` now says "63 symbols · 5 files" instead of "5 fi…" — a count
+ * clipped to an ellipsis is worse than a slightly wider box.
+ */
+export function nodeWidth(label: string, meta = ''): number {
+  return Math.max(
+    MIN_NODE_WIDTH,
+    label.length * CHAR_WIDTH + LABEL_PADDING,
+    meta.length * META_CHAR_WIDTH + META_PADDING
+  );
+}
+
+/** The second line of a module box — and the string {@link nodeWidth} sizes for. */
+export function moduleMetaLabel(module: WireMapModule): string {
+  const symbols = `${module.symbols} symbol${module.symbols === 1 ? '' : 's'}`;
+  const files = `${module.files} file${module.files === 1 ? '' : 's'}`;
+  return `${symbols} · ${files}`;
+}
+
+export interface MapNodeLayout {
+  id: string;
+  module: WireMapModule;
+  layer: number;
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+  /** Link ids leaving this node, left to right — one hidden handle each. */
+  sourceHandles: string[];
+  /** Link ids arriving at this node, left to right. */
+  targetHandles: string[];
+}
+
+export interface MapEdgeLayout {
+  id: string;
+  source: string;
+  target: string;
+  sourceHandle: string;
+  targetHandle: string;
+  link: WireMapLink;
+  /** Stroke width, from the spec's `min(6, 1 + log2(count) x 0.7)`. */
+  width: number;
+  /** Points up the layering: a mutual dependency or a link with nothing declared. */
+  back: boolean;
+  /** Below the weight threshold — drawn only when a touching module is selected. */
+  thin: boolean;
+}
+
+export interface MapLayerLayout {
+  index: number;
+  y: number;
+  /** Only the top and bottom layers are named. */
+  label: string | null;
+}
+
+export interface MutualPair {
+  /** The heavier direction. */
+  forward: WireMapLink;
+  /** The lighter one — the back-reference. */
+  back: WireMapLink;
+}
+
+export interface MapLayout {
+  nodes: MapNodeLayout[];
+  edges: MapEdgeLayout[];
+  layers: MapLayerLayout[];
+  width: number;
+  height: number;
+  /** What set the vertical order, and how thin the evidence was. */
+  basis: {
+    kind: 'declared' | 'all';
+    declaredLinks: number;
+    totalLinks: number;
+  };
+  minWeight: number;
+  /** Links hidden for being thin, at rest. */
+  hiddenLinks: number;
+  mutual: MutualPair[];
+  /** Module-level cycles of three or more, in the drawn graph. */
+  moduleCycles: string[][];
+}
+
+export interface MapLayoutOptions {
+  includeTests: boolean;
+}
+
+export function strokeWidthFor(count: number): number {
+  return Math.min(6, 1 + Math.log2(Math.max(1, count)) * 0.7);
+}
+
+/**
+ * A link's stable identity, and the id Svelte Flow keys its edge on.
+ *
+ * NUL is the separator because a module id is a path and a path may contain
+ * anything else — including the spaces, arrows and colons that read nicer.
+ */
+export function linkId(link: { source: string; target: string }): string {
+  return `${link.source}\u0000${link.target}`;
+}
+
+export function buildMapLayout(
+  payload: Pick<WireMapPayload, 'modules' | 'links'>,
+  options: MapLayoutOptions
+): MapLayout {
+  const modules = payload.modules.filter((m) => options.includeTests || !m.test);
+  const present = new Set(modules.map((m) => m.id));
+  const links = payload.links.filter((l) => present.has(l.source) && present.has(l.target));
+  const minWeight = options.includeTests ? MIN_WEIGHT_WITH_TESTS : MIN_WEIGHT;
+
+  const declaredLinks = links.filter((l) => l.declared > 0);
+  const useDeclared =
+    links.length > 0 && declaredLinks.length >= links.length * DECLARED_BASIS_COVERAGE;
+  const weightOf = (link: WireMapLink): number => (useDeclared ? link.declared : link.count);
+  const layeringLinks = useDeclared ? declaredLinks : links;
+
+  // --- 2-cycle break, on the layering graph only ---------------------------
+  const byPair = new Map(layeringLinks.map((l) => [linkId(l), l]));
+  const acyclic: WireMapLink[] = [];
+  const mutual: MutualPair[] = [];
+  for (const link of layeringLinks) {
+    const back = byPair.get(linkId({ source: link.target, target: link.source }));
+    if (!back) {
+      acyclic.push(link);
+      continue;
+    }
+    const mine = weightOf(link);
+    const theirs = weightOf(back);
+    // Ties broken by id so two runs over one payload agree.
+    if (theirs > mine || (theirs === mine && link.source > link.target)) {
+      mutual.push({ forward: back, back: link });
+      continue;
+    }
+    acyclic.push(link);
+  }
+
+  // --- longest-path layering ----------------------------------------------
+  const out = new Map<string, string[]>(modules.map((m) => [m.id, []]));
+  for (const link of acyclic) out.get(link.source)?.push(link.target);
+  for (const list of out.values()) list.sort();
+
+  const layer = new Map<string, number>();
+  for (const module of modules) longestPath(module.id, out, layer, new Set());
+
+  const layerCount = Math.max(1, ...[...layer.values()].map((v) => v + 1));
+  const rows: string[][] = Array.from({ length: layerCount }, () => []);
+  for (const module of modules) rows[layer.get(module.id) ?? 0]!.push(module.id);
+  for (const row of rows) row.sort();
+
+  // --- barycenter ordering, three sweeps -----------------------------------
+  const neighbours = new Map<string, string[]>(modules.map((m) => [m.id, []]));
+  for (const link of acyclic) {
+    neighbours.get(link.source)?.push(link.target);
+    neighbours.get(link.target)?.push(link.source);
+  }
+  const position = new Map<string, number>();
+  for (const row of rows) row.forEach((id, i) => position.set(id, i));
+  for (let sweep = 0; sweep < 3; sweep += 1) {
+    for (const row of rows) {
+      const bary = new Map(row.map((id) => [id, barycenter(id, neighbours, position)]));
+      // Sort by barycenter, then by the previous position, then by id: three
+      // total-order tiebreaks so the sweep cannot depend on sort stability.
+      // Infinity minus Infinity is NaN, so the unconnected modules — which all
+      // carry Infinity — are compared by the later keys instead.
+      row.sort((a, b) => {
+        const ba = bary.get(a) ?? 0;
+        const bb = bary.get(b) ?? 0;
+        if (ba !== bb && Number.isFinite(ba - bb)) return ba - bb;
+        if (ba !== bb) return ba < bb ? -1 : 1;
+        return (position.get(a) ?? 0) - (position.get(b) ?? 0) || a.localeCompare(b);
+      });
+      row.forEach((id, i) => position.set(id, i));
+    }
+  }
+
+  // --- placement -----------------------------------------------------------
+  const widths = new Map(modules.map((m) => [m.id, nodeWidth(m.id, moduleMetaLabel(m))]));
+  const rowSums = rows.map((row) => row.reduce((sum, id) => sum + (widths.get(id) ?? 0), 0));
+  // Natural span = the boxes shoulder to shoulder. The content width is the
+  // widest of those, and NOTHING may exceed it — a row of forty leaf modules
+  // must not stretch the canvas to `40 x MIN_SLOT` and shrink every other row
+  // to a thumbnail. MIN_SLOT only breathes a row out INSIDE that width.
+  const naturalSpans = rows.map(
+    (row, i) => (rowSums[i] ?? 0) + Math.max(0, row.length - 1) * NODE_GAP
+  );
+  const contentWidth = Math.max(1, ...naturalSpans);
+  const rowSpans = rows.map((row, i) =>
+    Math.min(contentWidth, Math.max(naturalSpans[i] ?? 0, row.length * MIN_SLOT))
+  );
+  const width = contentWidth + PADDING * 2;
+  const height = layerCount * (NODE_HEIGHT + LAYER_GAP) - LAYER_GAP + PADDING * 2;
+
+  const nodesById = new Map<string, MapNodeLayout>();
+  const byId = new Map(modules.map((m) => [m.id, m]));
+  rows.forEach((row, index) => {
+    const span = rowSpans[index] ?? 0;
+    const sum = rowSums[index] ?? 0;
+    const gap = row.length > 1 ? (span - sum) / (row.length - 1) : 0;
+    // A single box centres in the content width instead of clinging to the
+    // left edge — the common case for the entry point at the top.
+    let x = PADDING + (contentWidth - span) / 2 + (row.length === 1 ? (span - sum) / 2 : 0);
+    const y = PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + LAYER_GAP);
+    for (const id of row) {
+      const w = widths.get(id) ?? MIN_NODE_WIDTH;
+      nodesById.set(id, {
+        id,
+        module: byId.get(id)!,
+        layer: index,
+        x,
+        y,
+        width: w,
+        height: NODE_HEIGHT,
+        sourceHandles: [],
+        targetHandles: [],
+      });
+      x += w + gap;
+    }
+  });
+
+  // --- edges and ports -----------------------------------------------------
+  // EVERY link is laid out, including the ones the layering ignored: a link
+  // that survives the filter exists in the code, and the map's job is to say
+  // where it goes, not to pretend it is absent.
+  const edges: MapEdgeLayout[] = [];
+  const outgoing = new Map<string, MapEdgeLayout[]>();
+  const incoming = new Map<string, MapEdgeLayout[]>();
+  for (const link of links) {
+    const from = nodesById.get(link.source);
+    const to = nodesById.get(link.target);
+    if (!from || !to) continue;
+    const id = linkId(link);
+    const edge: MapEdgeLayout = {
+      id,
+      source: link.source,
+      target: link.target,
+      sourceHandle: `s:${id}`,
+      targetHandle: `t:${id}`,
+      link,
+      width: strokeWidthFor(link.count),
+      back: from.layer <= to.layer,
+      thin: link.count < minWeight,
+    };
+    edges.push(edge);
+    (outgoing.get(link.source) ?? setDefault(outgoing, link.source)).push(edge);
+    (incoming.get(link.target) ?? setDefault(incoming, link.target)).push(edge);
+  }
+  // Ports spread in the order the other end appears left-to-right, so bundles
+  // between two layers stay untangled instead of crossing inside the gap.
+  for (const [id, list] of outgoing) {
+    list.sort((a, b) => xOf(nodesById, a.target) - xOf(nodesById, b.target) || a.id.localeCompare(b.id));
+    const node = nodesById.get(id);
+    if (node) node.sourceHandles = list.map((e) => e.id);
+  }
+  for (const [id, list] of incoming) {
+    list.sort((a, b) => xOf(nodesById, a.source) - xOf(nodesById, b.source) || a.id.localeCompare(b.id));
+    const node = nodesById.get(id);
+    if (node) node.targetHandles = list.map((e) => e.id);
+  }
+
+  const layers: MapLayerLayout[] = rows.map((_, index) => ({
+    index,
+    y: PADDING + (layerCount - 1 - index) * (NODE_HEIGHT + LAYER_GAP) + NODE_HEIGHT / 2,
+    label:
+      layerCount === 1
+        ? null
+        : index === layerCount - 1
+          ? 'entry points'
+          : index === 0
+            ? 'foundations — depend on nothing below'
+            : null,
+  }));
+
+  return {
+    nodes: [...nodesById.values()],
+    edges,
+    layers,
+    width,
+    height,
+    basis: {
+      kind: useDeclared ? 'declared' : 'all',
+      declaredLinks: declaredLinks.length,
+      totalLinks: links.length,
+    },
+    minWeight,
+    hiddenLinks: edges.filter((e) => e.thin || e.back).length,
+    mutual: mutual.sort((a, b) => b.back.count - a.back.count || a.back.source.localeCompare(b.back.source)),
+    moduleCycles: moduleCycles(modules.map((m) => m.id), edges),
+  };
+}
+
+/**
+ * Which edges are drawn, given the selection.
+ *
+ * At rest the map shows the layering: downward links carrying real weight.
+ * Selecting a module says "show me everything about this one", so its thin
+ * links and its back-references come out — for that module only.
+ */
+export function isEdgeVisible(edge: MapEdgeLayout, selected: string | null): boolean {
+  if (selected !== null) return edge.source === selected || edge.target === selected;
+  return !edge.thin && !edge.back;
+}
+
+function setDefault(map: Map<string, MapEdgeLayout[]>, key: string): MapEdgeLayout[] {
+  const list: MapEdgeLayout[] = [];
+  map.set(key, list);
+  return list;
+}
+
+function xOf(nodes: Map<string, MapNodeLayout>, id: string): number {
+  const node = nodes.get(id);
+  return node ? node.x + node.width / 2 : 0;
+}
+
+/**
+ * A module's horizontal pull: the mean position of everything it connects to.
+ *
+ * A module connected to nothing has no pull, and giving it its own position
+ * back leaves it wherever the alphabet dropped it — which on a repository with
+ * forty leaf directories means forty unconnected boxes interleaved through the
+ * drawing, pushing the parts that DO connect apart. Infinity parks them at the
+ * right-hand end of their layer instead, so the connected picture stays
+ * contiguous. They are still drawn, and still counted.
+ */
+function barycenter(
+  id: string,
+  neighbours: Map<string, string[]>,
+  position: Map<string, number>
+): number {
+  const list = neighbours.get(id) ?? [];
+  if (list.length === 0) return Number.POSITIVE_INFINITY;
+  let sum = 0;
+  for (const other of list) sum += position.get(other) ?? 0;
+  return sum / list.length;
+}
+
+/**
+ * A module's layer: one above the deepest thing it depends on.
+ *
+ * `visiting` guards a cycle the two-cycle break did not catch (a three-module
+ * loop). Returning 0 there is not an answer, it is a floor — the module still
+ * gets placed above whatever else it depends on, and the loop itself is
+ * reported separately in {@link MapLayout.moduleCycles}.
+ */
+function longestPath(
+  id: string,
+  out: Map<string, string[]>,
+  layer: Map<string, number>,
+  visiting: Set<string>
+): number {
+  const known = layer.get(id);
+  if (known !== undefined) return known;
+  if (visiting.has(id)) return 0;
+  visiting.add(id);
+  let value = 0;
+  for (const next of out.get(id) ?? []) {
+    value = Math.max(value, longestPath(next, out, layer, visiting) + 1);
+  }
+  visiting.delete(id);
+  layer.set(id, value);
+  return value;
+}
+
+/** Strongly connected components of three or more modules, in the drawn graph. */
+function moduleCycles(ids: readonly string[], edges: readonly MapEdgeLayout[]): string[][] {
+  const out = new Map<string, string[]>(ids.map((id) => [id, []]));
+  for (const edge of edges) out.get(edge.source)?.push(edge.target);
+  for (const list of out.values()) list.sort();
+
+  const index = new Map<string, number>();
+  const low = new Map<string, number>();
+  const onStack = new Set<string>();
+  const stack: string[] = [];
+  const found: string[][] = [];
+  let counter = 0;
+
+  const strongconnect = (id: string): void => {
+    index.set(id, counter);
+    low.set(id, counter);
+    counter += 1;
+    stack.push(id);
+    onStack.add(id);
+    for (const next of out.get(id) ?? []) {
+      if (!index.has(next)) {
+        strongconnect(next);
+        low.set(id, Math.min(low.get(id) ?? 0, low.get(next) ?? 0));
+      } else if (onStack.has(next)) {
+        low.set(id, Math.min(low.get(id) ?? 0, index.get(next) ?? 0));
+      }
+    }
+    if (low.get(id) === index.get(id)) {
+      const component: string[] = [];
+      for (;;) {
+        const popped = stack.pop();
+        if (popped === undefined) break;
+        onStack.delete(popped);
+        component.push(popped);
+        if (popped === id) break;
+      }
+      if (component.length > 2) found.push(component.sort());
+    }
+  };
+
+  for (const id of [...ids].sort()) if (!index.has(id)) strongconnect(id);
+  return found.sort((a, b) => b.length - a.length || (a[0] ?? '').localeCompare(b[0] ?? ''));
+}

+ 21 - 5
ui/src/lib/router.svelte.ts

@@ -8,7 +8,7 @@
  *   #/                     home / nothing selected
  *   #/s/<id>               symbol view      (?hl=<line> highlights a line, ?t=<trail>)
  *   #/file/<path>          file view        (?hl=<line>)
- *   #/map                  module map       — reserved, phase 2
+ *   #/map                  module map       (?root=&depth=&tests=1)
  *   #/flow[/<key>]         flow strip       — reserved, phase 2
  *
  * Node ids are opaque engine strings shaped `<kind>:<hash>` or
@@ -23,7 +23,7 @@ export type Route =
   | { view: 'home' }
   | { view: 'symbol'; id: string; line: number | null }
   | { view: 'file'; path: string; line: number | null }
-  | { view: 'map' }
+  | { view: 'map'; root: string | null; depth: number; tests: boolean }
   | { view: 'flow'; key: string | null }
   | { view: 'unknown'; path: string };
 
@@ -74,7 +74,16 @@ export function parseHash(hash: string): RouterLocation {
   } else if (head === 'file' && rest.length > 0) {
     route = { view: 'file', path: rest.join('/'), line };
   } else if (head === 'map' && rest.length === 0) {
-    route = { view: 'map' };
+    // 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.
+    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,
+      tests: params.get('tests') === '1',
+    };
   } else if (head === 'flow') {
     route = { view: 'flow', key: rest.length > 0 ? rest.join('/') : null };
   } else {
@@ -99,8 +108,15 @@ export function fileHref(path: string, opts: { line?: number } = {}): string {
   return `#/file/${encodePath(path)}${query}`;
 }
 
-export function mapHref(): string {
-  return '#/map';
+export function mapHref(
+  opts: { root?: string | null; depth?: number; tests?: boolean } = {}
+): string {
+  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));
+  if (opts.tests) params.set('tests', '1');
+  const query = params.toString();
+  return `#/map${query ? `?${query}` : ''}`;
 }
 
 export function flowHref(key?: string): string {

+ 380 - 12
ui/src/views/MapView.svelte

@@ -1,21 +1,389 @@
 <!--
-  Reserved route. The module-level map (design spec §3.6) is phase 2 — the
-  aggregation query, cycle-breaking and layered layout land in CG-49.
+  The Map (`#/map`, design spec §3.6): the repository at module granularity,
+  layered so dependencies point down.
+
+  Svelte Flow draws it — custom node, custom edge, hidden handles as ports —
+  but none of Svelte Flow's editing machinery is in play: positions come from
+  `buildMapLayout`, selection is a local string, and nothing here is draggable.
+  What the library provides is pan, zoom and fit; what it must not provide is
+  a layout, because a map you cannot recognise between two visits is not a map.
+
+  Root and depth ride in the hash, so a link to "src/vs at depth 2" reopens the
+  same picture. Selection does not: it is a question you ask of the map, not a
+  place you were.
 -->
-<div class="scroll">
-  <div class="emptystate">
-    <h2>Map</h2>
-    <p>
-      The module map — every module in the project, layered so dependencies point down — is not part
-      of this release.
-    </p>
-    <p>Open a symbol instead: search for one, or press <code>/</code> to focus the search box.</p>
+<script lang="ts">
+  import { SvelteFlow, Controls, ViewportPortal, type Node, type Edge } from '@xyflow/svelte';
+  import '@xyflow/svelte/dist/style.css';
+  import ModuleNode from '../components/map/ModuleNode.svelte';
+  import ModuleEdge from '../components/map/ModuleEdge.svelte';
+  import MapSidePanel from '../components/map/MapSidePanel.svelte';
+  import { fetchMap, type WireMapPayload } from '../lib/api';
+  import { mapHref, navigate } from '../lib/router.svelte';
+  import {
+    buildMapLayout,
+    isEdgeVisible,
+    type MapEdgeLayout,
+    type MapLayout,
+  } from '../lib/map-model';
+
+  interface Props {
+    root: string | null;
+    depth: number;
+    tests: boolean;
+  }
+
+  let { root, depth, tests }: Props = $props();
+
+  let payload = $state<WireMapPayload | null>(null);
+  let error = $state<string | null>(null);
+  let loading = $state(true);
+  let selected = $state<string | null>(null);
+  let hovered = $state<{ edge: MapEdgeLayout; x: number; y: number } | null>(null);
+  let stage = $state<HTMLDivElement | null>(null);
+
+  /**
+   * Fit, but never past readable.
+   *
+   * The prototype refused to scale a label below ~0.9 and scrolled instead;
+   * a pannable canvas can be more generous, but not unboundedly so — a
+   * seventy-module repository fitted to a laptop screen is a picture of grey
+   * hair, not a map. Below this floor the view opens part-way and the reader
+   * pans, which is the honest trade.
+   */
+  const FIT = { fitViewOptions: { padding: 0.12, maxZoom: 1, minZoom: 0.45 } };
+
+  const nodeTypes = { module: ModuleNode };
+  const edgeTypes = { module: ModuleEdge };
+
+  // One fetch per (root, depth). The tests toggle is deliberately NOT in here:
+  // the payload already carries every module, so including them is a filter,
+  // not a question for the server.
+  $effect(() => {
+    const wantRoot = root;
+    const wantDepth = depth;
+    const controller = new AbortController();
+    loading = true;
+    error = null;
+    fetchMap({ root: wantRoot, depth: wantDepth }, controller.signal)
+      .then((next) => {
+        payload = next;
+        loading = false;
+      })
+      .catch((err: unknown) => {
+        if (controller.signal.aborted) return;
+        error = err instanceof Error ? err.message : String(err);
+        loading = false;
+      });
+    return () => controller.abort();
+  });
+
+  const layout = $derived<MapLayout | null>(
+    payload === null ? null : buildMapLayout(payload, { includeTests: tests })
+  );
+
+  /** Modules one hop from the selection — everything else is dimmed, not hidden. */
+  const neighbours = $derived.by(() => {
+    if (layout === null || selected === null) return null;
+    const set = new Set<string>([selected]);
+    for (const edge of layout.edges) {
+      if (edge.source === selected) set.add(edge.target);
+      if (edge.target === selected) set.add(edge.source);
+    }
+    return set;
+  });
+
+  const nodes = $derived.by<Node[]>(() => {
+    if (layout === null) return [];
+    return layout.nodes.map((node) => ({
+      id: node.id,
+      type: 'module',
+      position: { x: node.x, y: node.y },
+      draggable: false,
+      selectable: false,
+      connectable: false,
+      data: {
+        layout: node,
+        selected: selected === node.id,
+        dimmed: neighbours !== null && !neighbours.has(node.id),
+        onSelect: (id: string) => {
+          selected = selected === id ? null : id;
+          hovered = null;
+        },
+      },
+    }));
+  });
+
+  const edges = $derived.by<Edge[]>(() => {
+    if (layout === null) return [];
+    return layout.edges
+      .filter((edge) => isEdgeVisible(edge, selected))
+      .map((edge) => ({
+        id: edge.id,
+        source: edge.source,
+        target: edge.target,
+        sourceHandle: edge.sourceHandle,
+        targetHandle: edge.targetHandle,
+        type: 'module',
+        selectable: false,
+        deletable: false,
+        data: {
+          edge,
+          hot: hovered?.edge.id === edge.id || (selected !== null && !edge.back),
+          dimmed: false,
+          onHover: onEdgeHover,
+        },
+      }));
+  });
+
+  const selectedFiles = $derived(
+    selected === null || payload === null
+      ? []
+      : (payload.modules.find((m) => m.id === selected)?.fileList.items ?? [])
+  );
+
+  function onEdgeHover(edge: MapEdgeLayout | null, event: MouseEvent | null): void {
+    if (edge === null || event === null || stage === null) {
+      hovered = null;
+      return;
+    }
+    const box = stage.getBoundingClientRect();
+    hovered = {
+      edge,
+      // Clamped so the card never runs off the right-hand side of the canvas.
+      x: Math.min(event.clientX - box.left + 14, box.width - 330),
+      y: event.clientY - box.top + 14,
+    };
+  }
+
+  function setRoot(next: string): void {
+    selected = null;
+    navigate(mapHref({ root: next, depth, tests }));
+  }
+
+  function setTests(next: boolean): void {
+    selected = null;
+    navigate(mapHref({ root, depth, tests: next }));
+  }
+</script>
+
+<div class="mapview">
+  <div class="mapstage" bind:this={stage}>
+    {#if error !== null}
+      <div class="state">
+        <h2>The map could not be built</h2>
+        <p>{error}</p>
+      </div>
+    {:else if loading && payload === null}
+      <div class="state"><p class="dim">Aggregating the graph by module…</p></div>
+    {:else if layout !== null && layout.nodes.length === 0}
+      <div class="state">
+        <h2>Nothing to draw here</h2>
+        <p>
+          No indexed files sit under this root{tests
+            ? ''
+            : ', or every module under it is test code'}. Pick another root on the right.
+        </p>
+      </div>
+    {:else if layout !== null}
+      <SvelteFlow
+        {nodes}
+        {edges}
+        {nodeTypes}
+        {edgeTypes}
+        fitView
+        {...FIT}
+        minZoom={0.2}
+        maxZoom={1.6}
+        nodesDraggable={false}
+        nodesConnectable={false}
+        elementsSelectable={false}
+        panOnDrag
+        proOptions={{ hideAttribution: true }}
+        onpaneclick={() => {
+          selected = null;
+          hovered = null;
+        }}
+      >
+        <!-- The layer rules ride INSIDE the viewport, so they pan and zoom
+             with the boxes they explain. A layer line drawn on the frame
+             would sit next to the wrong row the moment anyone scrolled. -->
+        <ViewportPortal target="back">
+          {#each layout.layers as row (row.index)}
+            <div
+              class="layerline"
+              style={`transform:translate(0px,${row.y}px);width:${layout.width}px`}
+            ></div>
+            {#if row.label !== null}
+              <!-- Above the top row, below the bottom one: both sit in the
+                   clear band outside the drawing rather than under the edge
+                   bundles, which is where a label stops being readable. -->
+              <div
+                class="layerlbl"
+                style={`transform:translate(8px,${row.index === 0 ? row.y + 40 : row.y - 36}px)`}
+              >
+                {row.label}
+              </div>
+            {/if}
+          {/each}
+        </ViewportPortal>
+        <Controls position="bottom-right" showLock={false} />
+      </SvelteFlow>
+
+      {#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>
+          <div class="row2">
+            <span>{hovered.edge.link.count} edges</span>
+            <span
+              >{hovered.edge.link.byKind.map((k) => `${k.kind} ${k.count}`).join(' · ')}</span
+            >
+          </div>
+          {#if hovered.edge.link.declared !== hovered.edge.link.count}
+            <div class="row2 dim">
+              <span>{hovered.edge.link.declared} through an import or a declared type</span>
+            </div>
+          {/if}
+          {#each hovered.edge.link.topPairs as pair (pair.from + pair.to)}
+            <div class="row2 mono">
+              <span>{pair.from} → {pair.to}</span><span>{pair.count}</span>
+            </div>
+          {/each}
+        </div>
+      {/if}
+    {/if}
   </div>
+
+  {#if payload !== null && layout !== null}
+    <MapSidePanel
+      {payload}
+      {layout}
+      {selected}
+      includeTests={tests}
+      files={selectedFiles}
+      onToggleTests={setTests}
+      onSelectRoot={setRoot}
+      onSelect={(id) => (selected = id)}
+    />
+  {/if}
 </div>
 
 <style>
-  .scroll {
+  .mapview {
+    display: grid;
+    grid-template-columns: minmax(600px, 1fr) 320px;
     height: 100%;
-    overflow: auto;
+    min-height: 0;
+  }
+  .mapstage {
+    position: relative;
+    overflow: hidden;
+    background: var(--paper);
+  }
+  /* Svelte Flow paints its own surface and its own controls; both are
+     re-tokenised so the canvas belongs to the paper/ink system rather than
+     arriving with the library's blue-grey defaults. */
+  .mapstage :global(.svelte-flow) {
+    background: var(--paper);
+  }
+  .mapstage :global(.svelte-flow__handle) {
+    opacity: 0;
+    width: 1px;
+    height: 1px;
+    min-width: 0;
+    min-height: 0;
+    border: 0;
+    pointer-events: none;
+  }
+  .mapstage :global(.svelte-flow__controls-button) {
+    background: var(--paper);
+    border: 0;
+    border-bottom: 1px solid var(--rule-soft);
+    border-radius: 0;
+    box-shadow: none;
+    fill: var(--ink-2);
+  }
+  .mapstage :global(.svelte-flow__controls) {
+    box-shadow: none;
+    border: 1px solid var(--rule-soft);
+  }
+  .mapstage :global(.svelte-flow__node) {
+    cursor: default;
+  }
+
+  .layerline {
+    position: absolute;
+    top: 0;
+    left: 0;
+    height: 1px;
+    background: var(--rule-faint);
+    pointer-events: none;
+  }
+  .layerlbl {
+    position: absolute;
+    top: 0;
+    left: 0;
+    font: 12px var(--sans);
+    color: var(--ink-3);
+    white-space: nowrap;
+    pointer-events: none;
+  }
+
+  .state {
+    padding: 40px;
+    max-width: 46ch;
+  }
+  .state h2 {
+    margin: 0 0 8px;
+    font-size: 15px;
+    font-weight: 600;
+  }
+  .state p {
+    margin: 0;
+    color: var(--ink-2);
+    font-size: 12.5px;
+    line-height: 1.5;
+  }
+  .dim {
+    color: var(--ink-3);
+  }
+
+  .tip {
+    position: absolute;
+    z-index: 6;
+    max-width: 320px;
+    background: var(--paper);
+    border: 1px solid var(--ink);
+    padding: 8px 10px;
+    font-size: 12px;
+    color: var(--ink-2);
+    pointer-events: none;
+  }
+  .tip .mono {
+    font: 12px var(--mono);
+    color: var(--ink-2);
+    margin-bottom: 4px;
+  }
+  .tip .mono b {
+    color: var(--ink);
+    font-weight: 600;
+  }
+  .tip .row2 {
+    display: flex;
+    justify-content: space-between;
+    gap: 12px;
+    padding: 1px 0;
+  }
+  .tip .row2.mono {
+    font: 11.5px var(--mono);
+  }
+  .tip .row2.dim {
+    color: var(--ink-3);
+  }
+
+  @media (max-width: 1100px) {
+    .mapview {
+      grid-template-columns: 1fr 260px;
+    }
   }
 </style>