feat(ui): implement Map grouping, dependents, and weight bars; symbol tab address

Adds a new grouping system for the Map with a new grouping depth control, exposes per-module dependents (files and modules) to drive a weight bar, and renders it on each module. Introduces a MapKey to explain visuals, collapses lone root-file buckets for clearer labeling, and supports a nullable depth value to let the provider pick grouping. The Symbol tab now has its own address (#/s) when nothing is selected, and routing/top-bar logic is updated accordingly. Also updates export SVG rendering to include weight-based bars, and extends tests and docs to cover the new visuals and behavior.
This commit is contained in:
Colby McHenry
2026-08-31 23:49:39 -05:00
parent b9ca4b7981
commit 7ec9ef1818
21 changed files with 905 additions and 41 deletions
+5 -2
View File
@@ -81,7 +81,8 @@
const encoded = router.params.get('t');
untrack(() => {
trail.hydrate(encoded);
if (current.view === 'symbol' && trail.current?.id !== current.id) {
// `id: null` is the tab with nothing chosen — there is no hop to record.
if (current.view === 'symbol' && current.id !== null && trail.current?.id !== current.id) {
trail.push({ id: current.id });
}
});
@@ -157,7 +158,7 @@
<TopBar bind:this={topbar} project={project.name} stats={project.summary} showScreens={hasScreens} />
<TrailBar />
<main>
{#if route.view === 'symbol'}
{#if route.view === 'symbol' && route.id !== null}
<SymbolView id={route.id} line={route.line} />
{:else if route.view === 'file' && route.source}
<FileCodeView path={route.path} line={route.line} />
@@ -175,6 +176,8 @@
{:else if route.view === 'entry'}
<EntryView project={project.name} />
{:else if route.view === 'screens' || (route.view === 'home' && hasScreens)}
<!-- `home` renders Screens when the project has any, which is why the
Symbol tab needs its own `#/s` and must never fall back to `#/`. -->
<ScreensView />
{:else if route.view === 'steps'}
<StepsView anchor={route.anchor} symbol={route.symbol} depth={route.depth} through={route.through} reading={route.reading} />
+6 -3
View File
@@ -20,12 +20,15 @@
let view = $derived(router.route.view);
// The Symbol tab returns you to where you were reading, not to a blank
// view: the current symbol if you are on one, else the trail's last hop.
// view: the current symbol if you are on one, else the trail's last hop
// and failing both, the tab's own empty screen. NOT `#/`: the landing page
// renders the Screens tab on any project that has screens, so that fallback
// sent a reader who clicked Symbol to somebody else's view.
let symbolTabHref = $derived.by(() => {
const route = router.route;
if (route.view === 'symbol') return symbolHref(route.id);
if (route.view === 'symbol' && route.id !== null) return symbolHref(route.id);
const current = trail.current;
return current ? symbolHref(current.id) : '#/';
return symbolHref(current ? current.id : null);
});
/** What `/` and Cmd-K reach — the palette owns its own keyboard. */
+189
View File
@@ -0,0 +1,189 @@
<script lang="ts">
/**
* The Map's key (design spec §3.6), matching the Screens and Steps views'.
*
* Each row draws the actual stroke or box rather than a word for it — a
* reader matches shapes. Two rows here exist because the Map hides things at
* rest and a picture that hides must say so: the thin links, and the dashed
* back-edges that appear only once a module is selected. A reader who selects
* `src/utils` and watches four maroon dashes appear has no way to guess what
* they are, and the side panel's prose is not where anyone looks for a stroke.
*/
interface Props {
/** The weight below which a link waits for a selection. */
minWeight: number;
/** How many links are waiting on one right now; the row is skipped at zero. */
thinCount: number;
/** Whether the vertical order came from declared edges or from raw counts. */
declaredBasis: boolean;
open: boolean;
onToggle: (open: boolean) => void;
}
let { minWeight, thinCount, declaredBasis, open, onToggle }: Props = $props();
</script>
<div class="legend" class:open>
<button class="legend-h" onclick={() => onToggle(!open)} aria-expanded={open}>
Key <span class="dim">{open ? '▾' : '▸'}</span>
</button>
{#if open}
<div class="legend-body">
<div class="lrow">
<span class="k-box mono">src/api</span>
<span>A module — one directory, with the symbols and files in it</span>
</div>
<div class="lrow">
<span class="k-box k-weight mono">src/db</span>
<span>
The bar along the bottom is how much leans on it — files elsewhere that reference
straight into it, against the most depended-on box here. The count is on the box
</span>
</div>
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
<span>
Depends on — the box above calls, imports, extends or names a type from the box below.
Thicker is more references{declaredBasis
? ''
: '; here the layering had too few imports to trust, so it used raw counts'}
</span>
</div>
<div class="lrow">
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-back" /></svg>
<span>
Points back up — the lighter half of a mutual dependency, or a link with no import or
declared type behind it. Drawn only while a module it touches is selected
</span>
</div>
<div class="lrow">
<span class="k-label">top / bottom</span>
<span>
A module sits one layer above everything it depends on, so entry points end up at the top
and the foundations — which depend on nothing below — at the bottom
</span>
</div>
<div class="lrow">
<span class="k-box k-sel mono">src/api</span>
<span>Selected: click a module to bring out its links and list its files; everything more than one hop away fades</span>
</div>
<div class="lrow">
<span class="k-label">nothing depends on this</span>
<span>No link in the index arrives here — a script, a workflow, an unreferenced corner</span>
</div>
<div class="lrow">
<span class="k-box k-test mono">__tests__</span>
<span>More than half its files are tests; off unless you turn tests on</span>
</div>
<div class="lrow">
<span class="k-box k-gen mono">gen</span>
<span>Every file in it is tool-generated — nobody wrote it and nobody edits it</span>
</div>
{#if thinCount > 0}
<div class="lrow">
<span class="k-label">{thinCount} hidden</span>
<span>
Links carrying fewer than {minWeight} references wait until you select a module they
touch, so a weak coincidence never draws as a dependency
</span>
</div>
{/if}
</div>
{/if}
</div>
<style>
.legend {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 4;
max-width: 400px;
border: 1px solid var(--rule);
background: var(--paper);
font-size: 11.5px;
color: var(--ink-2);
}
.legend-h {
display: block;
width: 100%;
border: 0;
background: transparent;
padding: 5px 10px;
text-align: left;
color: var(--ink);
font: 600 12px var(--sans);
cursor: pointer;
}
.legend-body {
padding: 2px 10px 8px;
border-top: 1px solid var(--rule-soft);
}
.lrow {
display: flex;
align-items: center;
gap: 10px;
padding: 3px 0;
}
.lrow > :first-child {
flex: 0 0 52px;
display: inline-flex;
justify-content: center;
}
.k-line {
stroke: var(--ink);
stroke-opacity: 0.6;
stroke-width: 1.5;
fill: none;
}
.k-line.k-back {
stroke: var(--accent);
stroke-opacity: 0.8;
stroke-dasharray: 4 3;
}
.k-label {
font-size: 10px;
color: var(--ink-3);
text-align: center;
line-height: 1.2;
}
.k-box {
box-sizing: border-box;
padding: 1px 5px;
border: 1px solid var(--ink);
font-size: 10.5px;
color: var(--ink);
line-height: 14px;
}
/* The bar, drawn the way the canvas draws it: inside the bottom edge. */
.k-box.k-weight {
position: relative;
}
.k-box.k-weight::after {
content: '';
position: absolute;
left: 0;
bottom: 0;
width: 68%;
height: 4px;
background: var(--ink);
opacity: 0.3;
}
/* The same three treatments the canvas uses, at key size. */
.k-box.k-sel {
border-width: 2px;
background: var(--press);
}
.k-box.k-test {
border-style: dashed;
border-color: var(--ink-3);
color: var(--ink-3);
}
.k-box.k-gen {
border-color: var(--ink-4);
color: var(--ink-4);
}
.dim {
color: var(--ink-3);
}
</style>
+55
View File
@@ -25,6 +25,9 @@
files: string[];
onToggleTests: (value: boolean) => void;
onSelectRoot: (root: string) => void;
/** What the reader asked for, or `null` when the depth in `payload` was chosen for them. */
chosenDepth: number | null;
onSelectDepth: (depth: number | null) => void;
onSelect: (id: string | null) => void;
/** Builds the map as an SVG at a given device-pixel scale. */
buildSvg: (scale: number) => string;
@@ -40,11 +43,31 @@
files,
onToggleTests,
onSelectRoot,
chosenDepth,
onSelectDepth,
onSelect,
buildSvg,
exportName,
}: Props = $props();
/**
* The grouping options.
*
* The first one is the default and is not a number: the answering side reads
* the repository and picks the shallowest grouping that is not one box
* holding the whole program. The numbers below it are there for when its
* choice is wrong for what the reader is looking at — an escape hatch, not
* the thing anybody should have to reach for.
*/
const DEPTHS = [1, 2, 3, 4] as const;
function depthLabel(depth: number): string {
return depth === 1 ? 'top-level folders' : `${depth} folders deep`;
}
/** An em dash the mono face has; the select is narrow enough to notice a tofu. */
const DASH = '\u2014';
const selectedNode = $derived(
selected === null ? null : (layout.nodes.find((n) => n.id === selected) ?? null)
);
@@ -96,6 +119,25 @@
</select>
</label>
<!-- The grouping. A repository whose whole program sits under one directory
draws as one box at the shallowest setting, which is why the default is
chosen from the repository rather than fixed at 1. -->
<label class="field">
<span>Grouping</span>
<select
value={chosenDepth === null ? 'auto' : String(chosenDepth)}
onchange={(event) => {
const value = (event.currentTarget as HTMLSelectElement).value;
onSelectDepth(value === 'auto' ? null : Number(value));
}}
>
<option value="auto">automatic {DASH} {depthLabel(payload.depth)}</option>
{#each DEPTHS as option (option)}
<option value={String(option)}>{depthLabel(option)}</option>
{/each}
</select>
</label>
<label class="toggle">
<input
type="checkbox"
@@ -215,6 +257,15 @@
{/if}
</p>
{#if (selectedModule.dependents?.files ?? 0) > 0}
<p class="reach">
<b>{plural(selectedModule.dependents.files, 'file')}</b> outside it, across
{plural(selectedModule.dependents.modules, 'module')}, reference straight into it — the
floor on what a change here has to be checked against, and the bar along the bottom of
the box.
</p>
{/if}
{#if selectedNode?.island}
<p class="island">
Nothing in the index depends on this module — no import, call or reference crosses into
@@ -289,6 +340,10 @@
font-size: 11.5px;
margin-bottom: 8px;
}
.reach {
font-size: 11.5px;
margin: 0 0 8px;
}
.field {
display: flex;
gap: 8px;
+33
View File
@@ -52,6 +52,10 @@
aria-pressed={node.selected}
title={`${module.id}${module.symbols} symbols in ${module.files} file${
module.files === 1 ? '' : 's'
}${
(module.dependents?.files ?? 0) > 0
? `. ${module.dependents.files} file${module.dependents.files === 1 ? '' : 's'} outside it, across ${module.dependents.modules} module${module.dependents.modules === 1 ? '' : 's'}, reference into it.`
: ''
}${layout.island ? '. Nothing in the index depends on it.' : ''}${
layout.generated ? '. Every file in it is tool-generated.' : ''
}`}
@@ -61,6 +65,12 @@
<span class="count" class:island={layout.island}
>{moduleMetaLabel(module, layout.island)}</span
>
<!-- How much leans on this box, as a share of the heaviest one drawn. Inside
the border rather than on it, so it reads as a level in the box and not
as a second, thicker edge. -->
{#if layout.weight > 0}
<span class="weight" style={`width:${(layout.weight * 100).toFixed(1)}%`}></span>
{/if}
</button>
{#each layout.sourceHandles as handle, i (handle)}
@@ -75,6 +85,7 @@
<style>
.mnode {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
@@ -125,6 +136,28 @@
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* A wash, not a rule: it is a quantity the eye should compare across boxes at
a glance, never a line competing with the box's own border. */
.weight {
position: absolute;
left: 0;
bottom: 0;
height: 4px;
background: var(--ink);
/* Dark enough to survive the fit: the map opens as far out as 0.45, where a
3px band at 0.18 was a rumour. Length is what carries the comparison, and
length cannot be read off a stroke the eye has to hunt for. */
opacity: 0.3;
pointer-events: none;
}
.mnode:hover .weight,
.mnode.sel .weight {
opacity: 0.55;
}
.mnode.dimmed .weight,
.mnode.gen .weight {
opacity: 0.1;
}
.name {
font: 500 13px var(--mono);
line-height: 15px;
+14 -1
View File
@@ -172,6 +172,7 @@ function rect(
h: number,
attrs: {
fill?: string;
fillOpacity?: number;
stroke?: string;
strokeWidth?: number;
dash?: string;
@@ -184,6 +185,7 @@ function rect(
`height="${round(h)}"`,
`fill="${attrs.fill ?? 'none'}"`,
];
if (attrs.fillOpacity !== undefined) parts.push(`fill-opacity="${attrs.fillOpacity}"`);
if (attrs.stroke) {
parts.push(`stroke="${attrs.stroke}"`, `stroke-width="${attrs.strokeWidth ?? 1}"`);
if (attrs.dash) parts.push(`stroke-dasharray="${attrs.dash}"`);
@@ -837,9 +839,20 @@ function mapNodeSvg(node: MapNodeLayout, selected: boolean, dimmed: boolean): st
size: MODULE_META_SIZE,
fill: dimmed ? EXPORT_COLORS.ink4 : EXPORT_COLORS.ink3,
},
esc(truncate(moduleMetaLabel(module), room, MODULE_META_SIZE, SANS_ADVANCE))
// `node.island`, matching the canvas: an exported map that counts a
// module the screen said nothing depends on is a different picture.
esc(truncate(moduleMetaLabel(module, node.island), room, MODULE_META_SIZE, SANS_ADVANCE))
)
);
// The weight bar, same 3px inside the bottom edge as the canvas draws.
if (node.weight > 0) {
out.push(
rect(node.x, node.y + node.height - 4, node.width * node.weight, 4, {
fill: EXPORT_COLORS.ink,
fillOpacity: dimmed ? 0.1 : 0.3,
})
);
}
return out.join('');
}
+25 -1
View File
@@ -122,7 +122,16 @@ export function moduleMetaLabel(module: WireMapModule, island = false): string {
if (island) return 'nothing depends on this';
const symbols = `${module.symbols} symbol${module.symbols === 1 ? '' : 's'}`;
const files = `${module.files} file${module.files === 1 ? '' : 's'}`;
return `${symbols} · ${files}`;
// How big a change here is, said in the same breath as how big the module is.
// Two boxes of 20 files are not the same box when one of them is imported by
// ninety files and the other by two, and until this line the picture had no
// channel that said so — width tracked the length of the PATH.
// `?.` because `GraphAdapter` is a public seam: a host that assembles this
// payload itself and has not caught up to the field must lose the bar, not
// the screen. Every other read of `dependents` goes through this one.
const reach = module.dependents?.files ?? 0;
const depend = reach > 0 ? ` · ${reach} depend on it` : '';
return `${symbols} · ${files}${depend}`;
}
/** One port on a box's edge: the link it belongs to, and which end of it this is. */
@@ -144,6 +153,17 @@ export interface MapNodeLayout {
island: boolean;
/** Every file in it is tool-generated, so it draws in ink-4. */
generated: boolean;
/**
* How much of the picture leans on this box, 0..1, as a share of the
* most-depended-on box DRAWN — the bar along the bottom of the node.
*
* Relative rather than absolute because there is no absolute scale a reader
* could calibrate against: 94 dependent files is enormous in a 377-file app
* and unremarkable in a monorepo. Relative to what is on screen, the longest
* bar always means "this is the one to be careful with, here". The absolute
* number is on the box beside it, so the bar never has to be trusted alone.
*/
weight: number;
layer: number;
x: number;
y: number;
@@ -417,6 +437,9 @@ export function buildMapLayout(
const nodesById = new Map<string, MapNodeLayout>();
const byId = new Map(modules.map((m) => [m.id, m]));
// The busiest box DRAWN sets the scale — so turning tests on rescales the
// bars rather than leaving a test module's bar overflowing a hidden maximum.
const heaviest = Math.max(0, ...modules.map((m) => m.dependents?.files ?? 0));
rows.forEach((row, index) => {
const span = rowSpans[index] ?? 0;
const sum = rowSums[index] ?? 0;
@@ -435,6 +458,7 @@ export function buildMapLayout(
// Every file generated, not merely some: a module with one `.pb.go` in
// it is still a module somebody writes by hand.
generated: module.files > 0 && module.generated === module.files,
weight: heaviest === 0 ? 0 : (module.dependents?.files ?? 0) / heaviest,
layer: index,
x,
y,
+17 -4
View File
@@ -39,7 +39,8 @@ export interface FileHrefOptions {
export interface MapHrefOptions {
root?: string | null;
depth?: number;
/** Absent or null leaves the grouping to the answering side. */
depth?: number | null;
tests?: boolean;
}
@@ -79,7 +80,14 @@ export interface StepsHrefOptions {
* serve.
*/
export interface NavigationDriver {
symbolHref(id: string, opts?: SymbolHrefOptions): string;
/**
* A symbol's page — or, with `null`, the Symbol tab with nothing chosen yet.
*
* The null case has to be addressable. Without it the tab had no href of its
* own and fell back to the landing page, which on a project that HAS screens
* is the Screens tab: clicking Symbol landed you on somebody else's view.
*/
symbolHref(id: string | null, opts?: SymbolHrefOptions): string;
fileHref(path: string, opts?: FileHrefOptions): string;
mapHref(opts?: MapHrefOptions): string;
flowHref(opts?: FlowHrefOptions): string;
@@ -116,6 +124,9 @@ export const hashNavigation: NavigationDriver = {
const params = new URLSearchParams();
if (opts.trail) params.set('t', opts.trail);
if (opts.line) params.set('hl', String(opts.line));
// No id: the tab itself. `#/s` rather than `#/s/` so the segment filter
// cannot read an empty id back out of it.
if (!id) return `#/s${query(params)}`;
return `#/s/${encodePath(id)}${query(params)}`;
},
@@ -131,7 +142,9 @@ export const hashNavigation: NavigationDriver = {
mapHref(opts = {}) {
const params = new URLSearchParams();
if (opts.root !== undefined && opts.root !== null) params.set('root', opts.root);
if (opts.depth && opts.depth !== 1) params.set('depth', String(opts.depth));
// Including 1: a reader who asked for top-level directories has said
// something, and dropping it would hand the choice back to the answer.
if (opts.depth) params.set('depth', String(opts.depth));
if (opts.tests) params.set('tests', '1');
return `#/map${query(params)}`;
},
@@ -224,7 +237,7 @@ export function getNavigationDriver(): NavigationDriver {
/* --------------------------- what the components actually call ----------- */
export function symbolHref(id: string, opts: SymbolHrefOptions = {}): string {
export function symbolHref(id: string | null, opts: SymbolHrefOptions = {}): string {
return driver.symbolHref(id, opts);
}
+2
View File
@@ -339,6 +339,7 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
generatedFiles: [],
facade: false,
fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
dependents: { files: 0, modules: 0 },
});
}
// Each decision is a point of its own on the canvas: a small box asking the
@@ -357,6 +358,7 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
generatedFiles: [],
facade: false,
fileList: { total: 0, shown: 0, truncated: false, items: [] },
dependents: { files: 0, modules: 0 },
});
}
const drawn = (id: string): boolean => nodes.has(id) || forks.has(id);
+12 -6
View File
@@ -59,7 +59,8 @@ export type {
export type Route =
| { view: 'home' }
| { view: 'symbol'; id: string; line: number | null }
/** `id: null` = the Symbol tab, nothing chosen — the empty screen. */
| { view: 'symbol'; id: string | null; line: number | null }
| {
view: 'file';
path: string;
@@ -67,7 +68,7 @@ export type Route =
/** The whole-file source view rather than the outline (design spec §3.4). */
source: boolean;
}
| { view: 'map'; root: string | null; depth: number; tests: boolean }
| { view: 'map'; root: string | null; depth: number | null; tests: boolean }
| {
view: 'flow';
/** "how does X reach Y" — both ends pinned. */
@@ -135,19 +136,24 @@ export function parseHash(hash: string): RouterLocation {
let route: Route;
if (head === undefined) {
route = { view: 'home' };
} else if (head === 's' && rest.length > 0) {
route = { view: 'symbol', id: rest.join('/'), line };
} else if (head === 's') {
// `#/s` on its own is the tab, not a 404: nothing is chosen yet.
route = { view: 'symbol', id: rest.length > 0 ? rest.join('/') : null, line };
} else if (head === 'file' && rest.length > 0) {
route = { view: 'file', path: rest.join('/'), line, source: params.get('src') === '1' };
} else if (head === 'map' && rest.length === 0) {
// The map's shape travels in the URL like the trail does: a link to
// "src/vs at depth 2, tests on" has to reopen the same picture.
// "src/vs at depth 2, tests on" has to reopen the same picture. Absent, it
// stays absent: the answering side reads the repository and picks a depth,
// and a 1 defaulted in here would silently override that with the one
// grouping — top-level directories — that is wrong for every project whose
// program lives under a single `src/`.
const root = params.get('root');
const depth = Number.parseInt(params.get('depth') ?? '', 10);
route = {
view: 'map',
root: root === null ? null : root,
depth: Number.isFinite(depth) && depth >= 1 && depth <= 4 ? depth : 1,
depth: Number.isFinite(depth) && depth >= 1 && depth <= 4 ? depth : null,
tests: params.get('tests') === '1',
};
} else if (head === 'entry' && rest.length === 0) {
+2
View File
@@ -398,6 +398,8 @@ function moduleFor(info: ScreenNodeInfo, symbols: number): WireMapModule {
generatedFiles: [],
facade: false,
fileList: { total: 1, shown: 1, truncated: false, items: [info.screen?.file ?? info.sub] },
// Not the Map: a screen has no dependent count and draws no weight bar.
dependents: { files: 0, modules: 0 },
};
}
+3
View File
@@ -385,6 +385,8 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
generatedFiles: [],
facade: false,
fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
// Not the Map: a step has no dependent count and draws no weight bar.
dependents: { files: 0, modules: 0 },
});
}
@@ -716,6 +718,7 @@ function packRegions(
module: moduleOf.get(id)!,
island: false,
generated: false,
weight: 0,
layer: layerOf(id),
x,
y: yy,
+6
View File
@@ -610,6 +610,12 @@ export interface WireMapModule {
facade: boolean;
/** Its files, capped — the side panel's list when the module is selected. */
fileList: { total: number; shown: number; truncated: boolean; items: string[] };
/**
* Files OUTSIDE this module with a direct reference into it, and how many
* modules they span what a change in here reaches. Direct, not transitive:
* a cycle saturates the transitive count and it stops discriminating.
*/
dependents: { files: number; modules: number };
}
export interface WireMapLink {
+46 -3
View File
@@ -18,6 +18,7 @@
import ModuleNode from '../components/map/ModuleNode.svelte';
import ModuleEdge from '../components/map/ModuleEdge.svelte';
import MapSidePanel from '../components/map/MapSidePanel.svelte';
import MapKey from '../components/map/MapKey.svelte';
import { exportFilename, mapSvg } from '../lib/export-svg';
import { fetchMap, type WireMapPayload } from '../lib/api';
import { live } from '../lib/live.svelte';
@@ -31,7 +32,8 @@
interface Props {
root: string | null;
depth: number;
/** `null` = nobody has chosen; the answer picks a grouping for this repo. */
depth: number | null;
tests: boolean;
}
@@ -55,6 +57,26 @@
*/
const FIT = { fitViewOptions: { padding: 0.12, maxZoom: 1, minZoom: 0.45 } };
// The key stays open until the reader closes it; the choice survives a reload
// but is per browser — a preference, not a fact about the project. Same
// storage shape as the Screens and Steps keys.
const LEGEND_KEY = 'codegraph-ui:map-legend';
let legendOpen = $state(readLegendOpen());
function readLegendOpen(): boolean {
try {
return localStorage.getItem(LEGEND_KEY) !== 'closed';
} catch {
return true;
}
}
$effect(() => {
try {
localStorage.setItem(LEGEND_KEY, legendOpen ? 'open' : 'closed');
} catch {
// Storage refused (private mode): the key simply reopens next time.
}
});
const nodeTypes = { module: ModuleNode };
const edgeTypes = { module: ModuleEdge };
@@ -72,7 +94,7 @@
const controller = new AbortController();
loading = true;
error = null;
fetchMap({ root: wantRoot, depth: wantDepth }, controller.signal)
fetchMap({ root: wantRoot, depth: wantDepth ?? undefined }, controller.signal)
.then((next) => {
payload = next;
loading = false;
@@ -165,7 +187,16 @@
function setRoot(next: string): void {
selected = null;
navigate(mapHref({ root: next, depth, tests }));
// Deliberately dropping the depth: how finely to cut `ios` is a different
// question from how finely to cut the whole project, and carrying the old
// answer over is how a reader lands on a one-box map.
navigate(mapHref({ root: next, tests }));
}
/** `null` hands the grouping back to the answering side. */
function setDepth(next: number | null): void {
selected = null;
navigate(mapHref({ root, depth: next, tests }));
}
/**
@@ -191,6 +222,7 @@
selected = null;
navigate(mapHref({ root, depth, tests: next }));
}
</script>
<div class="mapview">
@@ -256,6 +288,15 @@
<Controls position="bottom-right" showLock={false} />
</SvelteFlow>
<!-- The key, on the picture it explains. -->
<MapKey
minWeight={layout.minWeight}
thinCount={layout.edges.filter((e) => e.thin && !e.back).length}
declaredBasis={layout.basis.kind === 'declared'}
open={legendOpen}
onToggle={(next) => (legendOpen = next)}
/>
{#if hovered !== null}
<div class="tip" style={`left:${hovered.x}px;top:${hovered.y}px`}>
<div class="mono"><b>{hovered.edge.source}</b>{hovered.edge.target}</div>
@@ -291,6 +332,8 @@
exportName={exportFilename('map', payload.root ?? '')}
onToggleTests={setTests}
onSelectRoot={setRoot}
chosenDepth={depth}
onSelectDepth={setDepth}
onSelect={(id) => (selected = id)}
/>
{/if}