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