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>
This commit is contained in:
Colby McHenry
2026-08-27 02:38:24 -05:00
co-authored by Claude Opus 5
parent a1dfa72cac
commit 6d0f60f32c
19 changed files with 3501 additions and 21 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>