feat(ui): the Steps tab lays a screen out in clusters and says a far link in words; a <Card/> is the Card its file imports (#1817)

* fix(ui): a screen's steps read as clusters, and a link too far to follow is said in words

The mobile app's /capture came back as a web: 100 boxes in a 1,227x5,588
ribbon, 113 lines drawn at rest crossing each other 652 times, each one
running over about five other boxes' names. Measured, not guessed — three
separate causes, very unequal.

The region grouping had nothing to divide there (98 of 100 boxes take their
region from one memoized component), so the picture fell back to a single
719px column. But the region dimension was not the lever. The lever was that
`packRegions` packed every step of one distance onto shared rows and wrapped
those rows at a fixed 720px, so a box and the thing it fires landed seven
lines apart: 70 of the 113 lines joined boxes ONE step apart. That is what
the crossings were made of.

So a region is now packed as CLUSTERS — a step, then the steps it sets in
motion on the line under it, stepped in — while the starting points that fire
nothing still share a line, because a screen's handlers are siblings and
giving each its own line turned a flat region into a column. A region's line
width is earned rather than fixed (sqrt(total * pitch), clamped 720..2600),
so a big screen comes out about as wide as it is tall.

Clustering makes most links local but not all: a step reached from two places
is drawn under whichever reached it first, so the other way in still crosses
the picture. Those are now said in WORDS at both ends — `-> resumeInference`
under the box that leads there, `<- CaptureView` under the box it arrives at,
capped at three with `+N more` — rather than drawn. This is not a hiding: the
link is stated, which says more than a line vanishing off the edge of the
screen does, and selecting the box draws every one of its real lines exactly
as before. It is the one at-rest cut that does not produce the "box that leads
somewhere and draws nothing" every earlier cut produced.

Also fixed while here, and predicted by the earlier region work: the in-region
row relaxation had no cycle guard, so a region holding one loop pushed 65 of
its boxes to rows 294-301 while the rest sat at 0-2. `forwardLinks` sets
cycle-closing links aside first, as the order reading's `withoutBackEdges`
already did.

/capture: 1,227x5,588 -> 2,279x4,356, at-rest crossings 652 -> 1,
lines-over-boxes 553 -> 26, with half the links still drawn as real lines and
every quiet box still one the screen itself fires directly. The order reading
is untouched (it keeps every line).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD

* fix(ui): a stub names its box without the mark the box wears for its kind

`← ⇠ onCaptureProgress +2` reads as two arrows arguing: the stub already
leads with a direction, and the box's own kind mark was competing with it.
Verified in the live canvas. The box keeps its mark, where nothing competes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD

* fix(ui): the screen's line into each of its parts stops sweeping the picture

Audited all 51 screens of the mobile app on this branch. 96 lines — the
screen's own stand-in line into each region — were 17% of everything drawn
and caused 79% of every crossing left. A screen with ten regions tiles them
into bands, so the line into a region two bands down travelled the height of
the whole picture.

Two causes, both fixed. The entry the line lands on was the walk's first
member of the region; clustering moves a step that fires something BELOW the
ones that fire nothing, so that box could sit lines down inside the region
and the line had to reach past everything above it. It now lands on the box
nearest the region's top-left that the screen actually leads to. And the
stand-in line is no longer exempt from the stub rule — when the region is
still too far to follow, the link is said in words like any other. The rest
of the anchor's fan stays quiet as before: it is already stood in for.

Across the 51 screens: crossings 47 -> 10, no screen above 10 (worst was 19,
now 2); lines-over-boxes 236 -> 182; boxes with neither a line nor a word
150 -> 135.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD

* fix(ui): a screen's parts fill the canvas instead of squaring off into rows

Audited the app's 28 regioned screens: the median canvas was 55% region and
45% nothing, and /home was 44% — 4,860px tall to hold about 2,160px of
picture. The cause is that regions were tiled a row at a time with each row
as tall as its tallest member, so one short region beside a tall one left the
rest of that row blank, and a reader scrolls through the blank.

Each region now goes as high as it can and then as far left as it can, over a
skyline of what is already placed. Reading order is untouched: regions are
still walked in the screen's own source order, so an earlier one is never
pushed below a later one — a short one just tucks under another short one
rather than waiting for the tall one beside it. Layering now comes from the
finished geometry rather than a band counter, since once regions drop
independently what a reader sees as one row IS one row.

/home 4,860px -> 3,584px, aspect 0.59 -> 0.94. Tallest screen in the app
4,860 -> 4,356. Crossings 10 -> 13 across all 51 screens, still none above 10.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD

* fix(ui): the width a picture wraps at is tried, not estimated

A region's line width came from sqrt(total * pitch) — the width at which
total/width lines come out square. That estimate is wrong for how these
pictures are drawn: a cluster spends lines on its own structure (a hub gets a
line to itself, and what it fires starts another), so it undercounts a
region's lines badly and wrapped /capture's 98 boxes into a 4,356px column.

Laying a picture out is cheap and exact, so the widths are tried instead:
layoutAt runs the whole pack at each of eight widths and the best finished
canvas wins (~2ms for the model, all eight included). It has to be scored on
the CANVAS, not per region — squaring each region off individually leaves
fewer of them side by side, which took /home from 3,584px to 5,624px while
every region looked better on its own.

Also measured and rejected while here: dropping a region's CLUSTERS side by
side the way the regions drop onto the canvas. Total height 42,084 -> 39,756px
(-6%), but lines-over-boxes 120 -> 134 and crossings 5 -> 8, because two
clusters side by side put each one's lines through the other. Height is cheap
to scroll; a crossed line is what made this picture unreadable. The reasoning
is recorded in the code so it is not re-tried blindly. Regions differ — they
sit far enough apart that few lines run between them.

Across the app's 51 screens: tallest picture 4,356 -> 3,796px, total height
45,744 -> 42,084px, lines-over-boxes 184 -> 120, crossings 13 -> 5, and no
screen is a tall ribbon any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD

* stuff

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-09 00:54:15 -05:00
committed by GitHub
co-authored by Claude Opus 5
parent 7a23648db9
commit 3ed73bc127
11 changed files with 1044 additions and 150 deletions
+76
View File
@@ -0,0 +1,76 @@
<script lang="ts">
/**
* What a box leads to, and what reaches it, when the line would be too long
* to follow — said in words under the box instead of drawn across the canvas
* ({@link StepStub}).
*
* It sits in the gap under its box, takes no pointer of its own, and shows
* the first few; a box that fires twenty things says so and keeps its size.
* Clicking the box draws every one of its real lines, so this is the resting
* summary, never the only way to see them.
*/
import type { NodeProps } from '@xyflow/svelte';
import type { StepStub } from '../../lib/steps-model';
/** The most that are named before the rest become a count. */
const MAX = 3;
let { data }: NodeProps = $props();
const node = $derived(data as unknown as { stubs: StepStub[]; width: number; dimmed: boolean });
const stubs = $derived(node.stubs);
const width = $derived(node.width);
const dimmed = $derived(node.dimmed);
const shown = $derived(stubs.slice(0, MAX));
const rest = $derived(stubs.length - shown.length);
const restTitle = $derived(
stubs
.slice(MAX)
.map((s) => `${s.dir === 'out' ? 'leads to' : 'arrives from'} ${s.label}`)
.join('\n')
);
</script>
<div class="stubs" class:dimmed style={`width:${width}px`} aria-hidden="true">
{#each shown as stub (stub.edge + stub.dir)}
<span class="stub" title={`${stub.dir === 'out' ? 'Leads to' : 'Arrives from'} ${stub.label} — too far across the picture to draw as a line. Click the box to draw it.`}>
<span class="arrow">{stub.dir === 'out' ? '→' : '←'}</span>{stub.label}
</span>
{/each}
{#if rest > 0}
<span class="stub more" title={restTitle}>+{rest} more</span>
{/if}
</div>
<style>
.stubs {
display: flex;
flex-direction: column;
gap: 1px;
padding-top: 3px;
box-sizing: border-box;
pointer-events: none;
font-size: 10.5px;
line-height: 13px;
color: var(--ink-3);
}
.stubs.dimmed {
opacity: 0.25;
}
.stub {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--mono);
pointer-events: auto;
}
.arrow {
display: inline-block;
width: 11px;
color: var(--ink-4);
}
.more {
color: var(--ink-4);
}
</style>
+10
View File
@@ -58,6 +58,16 @@
>
</div>
{/if}
{#if !order}
<div class="lrow">
<span class="k-label mono">→ name</span>
<span
>What a box leads to, or what reaches it (←), when the two are too far apart for a line to be followed — said
in words under the box rather than drawn across the picture. Select the box and every one of its real lines
draws</span
>
</div>
{/if}
{#if project === 'api'}
<div class="lrow">
<span class="k-box mono">POST /x</span>
+18 -2
View File
@@ -424,8 +424,24 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
const polylines = new Map<string, Point[]>();
for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
// The order reading needs no regions: its rows already say when. Its
// decisions are points between steps, not captions under a box.
return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts, regions: null, regionEntries: null, forks, decisions: [] };
// decisions are points between steps, not captions under a box. It keeps
// every line: a reading of when things happen is the lines, and its rows are
// short enough that they stay local — `stubbed` empty leaves the filter be.
return {
layout,
nodes,
edges,
layerGap: SCREEN_LAYER_GAP,
curves,
polylines,
counts,
regions: null,
regionEntries: null,
forks,
decisions: [],
stubs: new Map(),
stubbed: new Set(),
};
}
/**
+446 -129
View File
@@ -110,6 +110,14 @@ export interface StepsModel extends Picture {
* holds none.
*/
decisions: StepDecision[];
/**
* Per box, the links it does NOT draw a line for at rest, said in words on
* it instead ({@link StepStub}). Empty for a picture whose every line is
* local.
*/
stubs: Map<string, StepStub[]>;
/** The edges drawn as stubs rather than lines — what {@link stepEdgeVisible} keeps back. */
stubbed: ReadonlySet<string>;
}
/**
@@ -128,6 +136,31 @@ export interface StepForkInfo {
label: string;
}
/**
* One end of a link said in WORDS on its box instead of drawn as a line across
* the canvas.
*
* A line is a good drawing of a hop between two boxes a reader can see at
* once. It is a bad drawing of a hop across two thousand pixels: on one real
* screen the hundred and thirteen lines drawn at rest crossed each other six
* hundred and fifty-two times and each ran over five other boxes' names, so
* no single line could be followed and the boxes could not be read either.
* The link is not dropped — it is stated at BOTH ends, `→ resumeInference` on
* the box that leads there and `← CaptureView` on the box it arrives at, which
* says more than a line disappearing off the edge of the screen does. Select
* the box and every one of its real lines draws, exactly as before.
*/
export interface StepStub {
/** The layout edge this stands for, so selecting draws the real line. */
edge: string;
/** The box at the other end. */
other: string;
/** What that box calls itself. */
label: string;
/** `out` — this box leads there; `in` — it arrives from there. */
dir: 'out' | 'in';
}
/** One region of a screen's picture: its caption, and the space its boxes hold. */
export interface StepRegionZone {
id: string;
@@ -471,6 +504,8 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
const curves = trackedCurves(layout, layerGap);
const polylines = new Map<string, Point[]>();
for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
const entries = zones === null ? null : new Set(zones.map((z) => z.entry));
const { stubs, stubbed } = packStubs(layout, nodes, layerGap, entries);
return {
layout,
nodes,
@@ -480,14 +515,101 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
polylines,
counts,
regions: zones,
regionEntries: zones === null ? null : new Set(zones.map((z) => z.entry)),
regionEntries: entries,
forks: null,
stubs,
stubbed,
// Placed against the finished layout: a decision is drawn under the box
// that makes it, so it needs to know where that box ended up.
decisions: markDecisions(edges, layout),
};
}
/* ----------------------------------------------------------------- stubs -- */
/**
* How far apart two boxes may be, in lines, for the hop between them to still
* read as a line. Three lines is about as far as an eye follows a curve
* through other boxes without losing which one it left.
*/
const STUB_SPAN_LINES = 3;
/**
* Which links are said in words rather than drawn, and the words for each box.
*
* A link is drawn when both its boxes are close enough to take it in at once —
* within {@link STUB_SPAN_LINES} lines, and no further across than a region's
* own lines start out running ({@link REGION_LINE_MIN}), so a drawn line stays
* inside one column of reading — and it runs down the layering. Everything else becomes a
* {@link StepStub} at both ends — the one link into each region included, so a
* region tiled into a lower band no longer reaches back up to the start with a
* line across the whole picture. Those 96 lines were 17% of what a real app's
* 51 screens drew and 79% of everything they crossed.
*/
/**
* The name a stub points at, without the mark its box wears for its kind: the
* stub already leads with a direction, and `← ⇠ onCaptureProgress` reads as
* two arrows arguing. The box itself keeps its mark, where nothing competes.
*/
function stubLabel(label: string): string {
return label.startsWith('⇢ ') || label.startsWith('⇠ ') ? label.slice(2) : label;
}
function packStubs(
layout: MapLayout,
infos: Map<string, StepNodeInfo>,
layerGap: number,
entries: ReadonlySet<string> | null
): { stubs: Map<string, StepStub[]>; stubbed: ReadonlySet<string> } {
const stubs = new Map<string, StepStub[]>();
const stubbed = new Set<string>();
const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));
const pitch = NODE_HEIGHT + layerGap;
const add = (id: string, stub: StepStub): void => {
stubs.set(id, [...(stubs.get(id) ?? []), stub]);
};
for (const edge of layout.edges) {
const from = nodeById.get(edge.source);
const to = nodeById.get(edge.target);
if (!from || !to) continue;
// On a regioned picture the anchor's fan is already stood in for by one
// link into each region ({@link StepsModel.regionEntries}); only THAT link
// is a line worth keeping or words worth saying, and the rest of the fan
// stays quiet as it was. An unregioned picture has no stand-in, so its
// anchor's links follow the same rule as every other.
if (entries !== null && infos.get(edge.source)?.step.anchor && !entries.has(edge.target)) continue;
const lines = Math.abs(to.y - from.y) / pitch;
const across = Math.abs(to.x + to.width / 2 - (from.x + from.width / 2));
// A back edge points up the layering: it is not a local hop however near
// it is, and it was already drawing nothing at rest — now it says so.
const near = !edge.back && !edge.thin && lines <= STUB_SPAN_LINES + 0.01 && across <= REGION_LINE_MIN;
if (near) continue;
stubbed.add(edge.id);
add(edge.source, {
edge: edge.id,
other: edge.target,
label: stubLabel(infos.get(edge.target)?.label ?? edge.target),
dir: 'out',
});
add(edge.target, {
edge: edge.id,
other: edge.source,
label: stubLabel(infos.get(edge.source)?.label ?? edge.source),
dir: 'in',
});
}
// What a box leads to reads before what reaches it, and each side in the
// order the picture puts the other end — down the page, then across.
const place = (id: string): number => {
const n = nodeById.get(id);
return n ? n.y * 100000 + n.x : 0;
};
for (const list of stubs.values()) {
list.sort((a, b) => (a.dir === b.dir ? place(a.other) - place(b.other) : a.dir === 'out' ? -1 : 1));
}
return { stubs, stubbed };
}
/* --------------------------------------------------------------- regions -- */
/**
@@ -500,8 +622,29 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
const REGION_GAP_Y = 72;
/** The vertical rhythm of a regioned picture: one line of boxes and the gap under it. */
const REGION_PITCH = NODE_HEIGHT + REGION_GAP_Y;
/** A region's line of boxes wraps past this natural width. */
const REGION_LINE_MAX = 720;
/** The least width a region's line of boxes runs to before it wraps. */
const REGION_LINE_MIN = 720;
/**
* The widths a region's lines are tried at, narrowest first — a tie keeps the
* narrowest, so a small region never sprawls. A fixed 720 was the whole reason
* a big screen came out a 6,500px ribbon.
*/
const REGION_WIDTHS = [REGION_LINE_MIN, 1000, 1300, 1600, 1900, 2200, 2600, 3000];
/**
* The shape the whole picture is aimed at: a little wider than tall. Boxes are
* wide and short, and so is the window a reader has, so a landscape picture
* wastes less of both than a square one — and a reader scrolls a tall picture
* far more than they pan a wide one.
*/
const CANVAS_ASPECT = 1.4;
/** How far a finished canvas is from {@link CANVAS_ASPECT}, in log space so wide and tall cost alike. */
function canvasCost(laid: { width: number; height: number }): number {
return Math.abs(Math.log(Math.max(1, laid.width) / Math.max(1, laid.height) / CANVAS_ASPECT));
}
/** How far a cluster's boxes sit in from the step that fires them. */
const CLUSTER_INDENT = 26;
/** Clusters stop stepping in past this depth, so a long chain stays on screen. */
const CLUSTER_DEPTH_MAX = 6;
/** Between two regions side by side. */
const REGION_GUTTER = 72;
/** Extra room between two rows of regions — the captions of the next row live in it. */
@@ -511,10 +654,55 @@ function bandBudget(area: number, widest: number): number {
return Math.max(widest, Math.min(3400, Math.max(1440, Math.ceil(Math.sqrt(area * 2.4)))));
}
/**
* The links minus the ones that close a cycle — those whose end is still open
* on the way in, found by one walk from every link's source in order, so the
* walk's own order decides which way round a cycle is the forward one. The
* twin of the order reading's `withoutBackEdges`, over map links.
*/
function forwardLinks(links: readonly WireMapLink[]): WireMapLink[] {
const out = new Map<string, WireMapLink[]>();
const nodes = new Set<string>();
for (const l of links) {
nodes.add(l.source);
nodes.add(l.target);
const list = out.get(l.source);
if (list) list.push(l);
else out.set(l.source, [l]);
}
/** 1 = open on the way in, 2 = done with. */
const state = new Map<string, number>();
const closes = new Set<WireMapLink>();
const visit = (root: string): void => {
const stack: { id: string; next: number }[] = [{ id: root, next: 0 }];
state.set(root, 1);
while (stack.length > 0) {
const top = stack[stack.length - 1]!;
const list = out.get(top.id) ?? [];
if (top.next >= list.length) {
state.set(top.id, 2);
stack.pop();
continue;
}
const link = list[top.next++]!;
const seen = state.get(link.target) ?? 0;
if (seen === 1) {
closes.add(link);
continue;
}
if (seen === 2) continue;
state.set(link.target, 1);
stack.push({ id: link.target, next: 0 });
}
};
for (const id of nodes) if (!state.has(id)) visit(id);
return links.filter((l) => !closes.has(l));
}
/**
* The layout of a screen's picture: each region a small column of lines —
* a box above what it sets in motion, a line wrapping when it grows past
* {@link REGION_LINE_MAX} — and the regions tiled left to right, wrapping
* a box above what it sets in motion, a line wrapping when it grows past the
* width its shape earns ({@link REGION_WIDTHS}) — and the regions tiled left to right, wrapping
* into bands, in the order the walk met them: the screen's own source order.
* The anchor sits alone on top. Everything downstream — the tracked curves,
* the pills, the pointer — is the same machinery over the same shapes.
@@ -563,148 +751,274 @@ function packRegions(
regions.set(id, region);
}
// Within a region, a step goes under the steps that lead to it.
// Within a region, a step goes under the steps that lead to it — minus the
// links that close a cycle. Relaxation over a cyclic graph never settles: it
// adds a row per pass until the bound, so a region holding one cycle sent
// sixty-five of its boxes to rows 294-301 while the rest sat at 0-2, and the
// order they were then packed in had nothing to do with what leads to what.
// The order reading learned this first ({@link withoutBackEdges} there); the
// cycle is still drawn, it just cannot stretch the picture.
const regionOf = new Map(members.map((s) => [s.id, s.region?.id ?? anchor.id]));
const intra = links.filter(
(l) =>
l.source !== anchor.id &&
l.target !== anchor.id &&
l.source !== l.target &&
regionOf.get(l.source) === regionOf.get(l.target)
);
/** The steps the screen itself leads to — where its one line into a region can land. */
const fromAnchor = new Set(links.filter((l) => l.source === anchor.id).map((l) => l.target));
const parentsOf = new Map<string, string[]>();
for (const l of links) {
if (l.source === anchor.id || l.target === anchor.id) continue;
if (regionOf.get(l.source) !== regionOf.get(l.target)) continue;
for (const l of forwardLinks(intra)) {
const list = parentsOf.get(l.target) ?? [];
list.push(l.source);
parentsOf.set(l.target, list);
}
interface Packed {
lines: string[][];
width: number;
}
const packed = new Map<string, Packed>();
for (const region of regions.values()) {
// A step goes under the steps that lead to it — rows from the region's OWN
// links, never from distance to the anchor, which is flat inside a region:
// a handler and the store it calls are both one hop from the screen, and
// side by side their line was a level arch, hidden at rest, so the store
// looked wired to nothing. Longest lead-to path, settled by relaxation as
// the order reading settles its rows; a cycle stops moving at the bound.
const rowOf = new Map<string, number>(region.members.map((m) => [m.id, 0]));
for (let pass = 0; pass < region.members.length; pass++) {
let moved = false;
for (const m of region.members) {
const above = (parentsOf.get(m.id) ?? [])
.map((p) => rowOf.get(p))
.filter((x): x is number => x !== undefined);
if (above.length === 0) continue;
const next = Math.max(...above) + 1;
if (next > rowOf.get(m.id)!) {
rowOf.set(m.id, next);
moved = true;
}
}
if (!moved) break;
}
const rows = new Map<number, WireStep[]>();
for (const m of region.members) {
const d = rowOf.get(m.id)!;
rows.set(d, [...(rows.get(d) ?? []), m]);
}
const lines: string[][] = [];
// The order a step was placed in, for putting its children near it.
const placedAt = new Map<string, number>();
let width = 0;
for (const d of [...rows.keys()].sort((a, b) => a - b)) {
const row = rows.get(d)!;
const near = (s: WireStep): number => {
const placed = (parentsOf.get(s.id) ?? []).map((p) => placedAt.get(p)).filter((x): x is number => x !== undefined);
if (placed.length === 0) return Number.MAX_SAFE_INTEGER;
return placed.reduce((a, b) => a + b, 0) / placed.length;
};
row.sort(
(a, b) => near(a) - near(b) || (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id)
// Who a step is drawn under: the FIRST step in the region that leads to it,
// in the walk's order — the same first-reach-wins the walk itself uses. A
// step belongs to one cluster, so a box that fires twenty things has those
// twenty under it rather than scattered down the region.
const childrenOf = new Map<string, string[]>();
{
const order = new Map(members.map((s) => [s.id, s.order ?? Number.MAX_SAFE_INTEGER]));
const out = new Map<string, string[]>();
for (const l of forwardLinks(intra)) out.set(l.source, [...(out.get(l.source) ?? []), l.target]);
const owned = new Set<string>();
const queue = members.filter((s) => (parentsOf.get(s.id) ?? []).length === 0).map((s) => s.id);
for (const id of queue) owned.add(id);
while (queue.length > 0) {
const id = queue.shift()!;
const kids = [...new Set(out.get(id) ?? [])].sort(
(a, b) => (order.get(a) ?? 0) - (order.get(b) ?? 0) || a.localeCompare(b)
);
let line: string[] = [];
let w = 0;
for (const m of row) {
const bw = widthOf(m.id);
if (line.length > 0 && w + NODE_GAP + bw > REGION_LINE_MAX) {
lines.push(line);
width = Math.max(width, w);
line = [];
w = 0;
}
line.push(m.id);
w += (line.length > 1 ? NODE_GAP : 0) + bw;
placedAt.set(m.id, placedAt.size);
}
if (line.length > 0) {
lines.push(line);
width = Math.max(width, w);
for (const kid of kids) {
if (owned.has(kid)) continue;
owned.add(kid);
childrenOf.set(id, [...(childrenOf.get(id) ?? []), kid]);
queue.push(kid);
}
}
packed.set(region.id, { lines, width });
}
// Tile the regions into bands under a width budget.
interface Band {
regions: Region[];
interface Packed {
/** Where each member sits: x from the region's left, and which line it is on. */
pos: Map<string, { x: number; line: number }>;
lines: number;
width: number;
entry: string;
}
let area = 0;
let widest = 0;
for (const region of regions.values()) {
const p = packed.get(region.id)!;
area += p.width * p.lines.length * REGION_PITCH;
widest = Math.max(widest, p.width);
}
const budget = bandBudget(area, widest);
const bands: Band[] = [];
let band: Band | null = null;
for (const region of regions.values()) {
const p = packed.get(region.id)!;
if (band === null || band.width + REGION_GUTTER + p.width > budget) {
band = { regions: [], lines: 0, width: -REGION_GUTTER };
bands.push(band);
}
band.regions.push(region);
band.lines = Math.max(band.lines, p.lines.length);
band.width += REGION_GUTTER + p.width;
}
const contentWidth = Math.max(widthOf(anchor.id), ...bands.map((b) => b.width));
// Place everything. The anchor is alone on top; each band's regions centre
// as a row of columns; a region's lines centre within its own width.
const at = new Map<string, { x: number; y: number; line: number }>();
const zones: StepRegionZone[] = [];
const anchorY = PADDING;
let y = anchorY + NODE_HEIGHT + SCREEN_LAYER_GAP + BAND_GAP;
let globalLine = 0;
for (const b of bands) {
let x = PADDING + (contentWidth - b.width) / 2;
for (const region of b.regions) {
const p = packed.get(region.id)!;
p.lines.forEach((line, j) => {
const lw = line.reduce((a, id) => a + widthOf(id), 0) + NODE_GAP * (line.length - 1);
let lx = x + (p.width - lw) / 2;
for (const id of line) {
at.set(id, { x: lx, y: y + j * REGION_PITCH, line: globalLine + j });
lx += widthOf(id) + NODE_GAP;
/**
* The whole picture at one line width: every region packed with its lines
* allowed to run that wide, then the regions dropped onto the canvas.
*
* The width cannot be estimated from the boxes alone — a cluster spends
* lines on its own structure, so `total / width` badly under-counts what a
* region takes — and it cannot be chosen per region either: widening one
* region to square it off leaves fewer of them side by side, so the CANVAS
* gets taller even as each region looks better (`/home` went 3,584px to
* 5,624px that way). One width, scored on the finished canvas.
*/
const layoutAt = (lineMax: number) => {
const packed = new Map<string, Packed>();
for (const region of regions.values()) {
// A region is drawn as CLUSTERS, not as rows: a step, then the steps it
// sets in motion on the line under it, indented. Rows-then-wrap put every
// step of one distance on the same rows and wrapped them at a fixed width,
// so a box and the thing it fires ended up seven
// lines apart and their line crossed everything between — 70 of 113 lines
// on one real screen joined boxes ONE step apart and rendered seven lines
// apart. Under a cluster the same line is one line long.
const ids = new Set(region.members.map((m) => m.id));
const kidsOf = (id: string): string[] => (childrenOf.get(id) ?? []).filter((k) => ids.has(k));
const starts = region.members.filter((m) => (parentsOf.get(m.id) ?? []).length === 0).map((m) => m.id);
/** Lay the region out with its lines allowed to run this wide. */
const layAt = (lineMax: number): { pos: Map<string, { x: number; line: number }>; lines: number; width: number } => {
/**
* One cluster, in its own coordinates: a step, then the steps it sets
* in motion on the line under it, stepped in, and a cluster of its own
* for anything that leads on further.
*/
const cluster = (root: string): { pos: Map<string, { x: number; line: number }>; lines: number; width: number } => {
const pos = new Map<string, { x: number; line: number }>();
let line = 0;
let width = 0;
const spread = (list: string[], left: number): void => {
if (list.length === 0) return;
let lx = left;
for (const id of list) {
const bw = widthOf(id);
if (lx > left && lx + bw - left > lineMax) {
line += 1;
lx = left;
}
pos.set(id, { x: lx, line });
width = Math.max(width, lx + bw);
lx += bw + NODE_GAP;
}
line += 1;
};
const place = (id: string, depth: number): void => {
const x = Math.min(depth, CLUSTER_DEPTH_MAX) * CLUSTER_INDENT;
pos.set(id, { x, line });
width = Math.max(width, x + widthOf(id));
line += 1;
const kids = kidsOf(id);
spread(kids.filter((k) => kidsOf(k).length === 0), x + CLUSTER_INDENT);
for (const hub of kids.filter((k) => kidsOf(k).length > 0)) place(hub, depth + 1);
};
place(root, 0);
return { pos, lines: line, width };
};
/** The steps that fire nothing, side by side — a screen's handlers are siblings, not a hierarchy. */
const flat = (list: string[]): { pos: Map<string, { x: number; line: number }>; lines: number; width: number } => {
const pos = new Map<string, { x: number; line: number }>();
let line = 0;
let width = 0;
let lx = 0;
for (const id of list) {
const bw = widthOf(id);
if (lx > 0 && lx + bw > lineMax) {
line += 1;
lx = 0;
}
pos.set(id, { x: lx, line });
width = Math.max(width, lx + bw);
lx += bw + NODE_GAP;
}
return { pos, lines: list.length === 0 ? 0 : line + 1, width };
};
// The region's blocks, in the walk's order: everything that fires
// nothing first, as one spread, then a cluster per step that does.
const blocks: { pos: Map<string, { x: number; line: number }>; lines: number; width: number }[] = [];
const bare = starts.filter((id) => kidsOf(id).length === 0);
if (bare.length > 0) blocks.push(flat(bare));
const placed = new Set(bare);
for (const id of starts) {
if (placed.has(id)) continue;
const b = cluster(id);
for (const k of b.pos.keys()) placed.add(k);
blocks.push(b);
}
});
zones.push({
id: region.id,
label: region.label,
x,
y,
width: p.width,
height: (p.lines.length - 1) * REGION_PITCH + NODE_HEIGHT,
entry: p.lines[0]![0]!,
});
x += p.width + REGION_GUTTER;
// A cycle can leave a member with no reachable start; it stands alone.
for (const m of region.members) {
if (placed.has(m.id)) continue;
const b = cluster(m.id);
for (const k of b.pos.keys()) placed.add(k);
blocks.push(b);
}
// The blocks stack, one under the next, in the walk's order.
//
// Dropping them side by side the way the REGIONS drop onto the canvas
// was tried and measured across a real app's 51 screens, and it is a
// bad trade: total height 42,084px -> 39,756px (-6%), but lines running
// over other boxes 120 -> 134 and lines crossing each other 5 -> 8,
// because two clusters side by side put each one's lines through the
// other. Height is cheap to scroll; a crossed line is what made this
// picture unreadable in the first place. Regions differ — they are far
// enough apart that few lines run between them.
const pos = new Map<string, { x: number; line: number }>();
let width = 0;
let lines = 0;
for (const b of blocks) {
for (const [id, at2] of b.pos) pos.set(id, { x: at2.x, line: lines + at2.line });
width = Math.max(width, b.width);
lines += b.lines;
}
return { pos, lines, width };
};
const { pos, lines: line, width } = layAt(lineMax);
// Where the screen's own line into this region lands: the box nearest the
// region's top-left that the screen actually leads to. The walk's first
// member used to stand for the region, but clustering moves a step that
// fires something below the ones that fire nothing, so that box could sit
// lines down inside the region and the line from the start had to reach
// past everything above it to get there.
const topmost = (ids: string[]): string | null =>
ids
.filter((id) => pos.has(id))
.sort((a, b) => pos.get(a)!.line - pos.get(b)!.line || pos.get(a)!.x - pos.get(b)!.x)[0] ?? null;
const entry =
topmost(region.members.filter((m) => fromAnchor.has(m.id)).map((m) => m.id)) ??
topmost(region.members.map((m) => m.id)) ??
region.members[0]!.id;
packed.set(region.id, { pos, lines: line, width, entry });
}
y += b.lines * REGION_PITCH + BAND_GAP;
globalLine += b.lines;
}
const height = y - REGION_PITCH - BAND_GAP + NODE_HEIGHT + PADDING;
// How wide the picture may run before a region has to go underneath.
let area = 0;
let widest = 0;
for (const region of regions.values()) {
const p = packed.get(region.id)!;
area += p.width * p.lines * REGION_PITCH;
widest = Math.max(widest, p.width);
}
const budget = bandBudget(area, widest);
// Place everything. The anchor is alone on top; each region, in the order
// the walk met them, goes as high as it can and then as far left as it can.
//
// Squaring the regions off into bands — a row at a time, the row as tall as
// its tallest member — left a screen's canvas 55% region and 45% nothing
// (`/home` 44%: 4,860px tall to hold 2,160px of picture), and that emptiness
// is what a reader scrolls through. Going highest-then-leftmost keeps the
// reading order (an earlier region is placed first, so it is never pushed
// below a later one) while a short region tucks under another short one
// instead of waiting for the tall one beside it.
const at = new Map<string, { x: number; y: number; line: number }>();
const zones: StepRegionZone[] = [];
const topY = anchorY + NODE_HEIGHT + SCREEN_LAYER_GAP + BAND_GAP;
/** What each stretch of the canvas is filled to, so far. */
const sky: { x0: number; x1: number; y: number }[] = [];
const floorAt = (x0: number, x1: number): number => {
let f = topY;
for (const s of sky) if (s.x1 > x0 + 1 && s.x0 < x1 - 1) f = Math.max(f, s.y);
return f;
};
for (const region of regions.values()) {
const p = packed.get(region.id)!;
const rh = Math.max(0, p.lines - 1) * REGION_PITCH + NODE_HEIGHT;
// Somewhere to start, plus the right-hand edge of everything already down.
const spots = [PADDING, ...sky.map((s) => s.x1 + REGION_GUTTER)]
.filter((x, i, all) => all.indexOf(x) === i && x + p.width <= PADDING + Math.max(budget, p.width))
.sort((m, n) => m - n);
let best = { x: PADDING, y: floorAt(PADDING, PADDING + p.width) };
for (const x of spots) {
const y = floorAt(x, x + p.width);
if (y < best.y - 1) best = { x, y };
}
const { x, y } = best;
// A cluster reads from its left edge, not from the region's centre: the
// indent is what says which step fired which.
for (const [id, at2] of p.pos) {
at.set(id, { x: x + at2.x, y: y + at2.line * REGION_PITCH, line: 0 });
}
zones.push({ id: region.id, label: region.label, x, y, width: p.width, height: rh, entry: p.entry });
// The gap under a region carries the next one's caption.
sky.push({ x0: x, x1: x + p.width, y: y + rh + BAND_GAP });
}
const contentWidth = Math.max(widthOf(anchor.id), ...zones.map((z) => z.x + z.width - PADDING));
// The layering comes from the finished geometry, not from a band counter:
// once regions drop independently, what a reader sees as one row IS one row.
for (const spot of at.values()) spot.line = Math.round((spot.y - topY) / REGION_PITCH);
const globalLine = Math.max(0, ...[...at.values()].map((v) => v.line)) + 1;
const height = Math.max(anchorY + NODE_HEIGHT, ...zones.map((z) => z.y + z.height)) + PADDING;
return { at, zones, contentWidth, globalLine, height, width: contentWidth + PADDING * 2 };
};
// Try the widths and keep the picture that comes out closest to the shape a
// window has. A tie keeps the narrowest, so a small picture never sprawls.
const tries = REGION_WIDTHS.map((w) => layoutAt(w));
const { at, zones, contentWidth, globalLine, height } = tries.reduce((a, b) =>
canvasCost(a) <= canvasCost(b) ? a : b
);
// Layers count from the bottom, as the Map's do: the route of an edge and
// which sides it uses fall out of the comparison alone.
@@ -856,6 +1170,9 @@ export function stepEdgeVisible(
return r.has(edge.source) || r.has(edge.target);
}
if (edge.thin || edge.back) return false;
// A hop too far to follow says itself in words on both its boxes instead
// ({@link StepStub}); drawing it as well is the web those words replace.
if (model.stubbed.has(edge.id)) return false;
if (model.regions === null) return true;
const from = model.nodes.get(edge.source)?.step;
if (from?.anchor) return model.regionEntries?.has(edge.target) ?? true;
+22 -1
View File
@@ -17,6 +17,7 @@
import { SvelteFlow, Controls, type Node, type Edge, type Viewport } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import StepNode from '../components/steps/StepNode.svelte';
import StepStubs from '../components/steps/StepStubs.svelte';
import ForkPoint from '../components/steps/ForkPoint.svelte';
import DecisionCaption from '../components/steps/DecisionCaption.svelte';
import RegionCaption from '../components/steps/RegionCaption.svelte';
@@ -120,7 +121,7 @@
}
});
const nodeTypes = { step: StepNode, region: RegionCaption, fork: ForkPoint, decision: DecisionCaption };
const nodeTypes = { step: StepNode, region: RegionCaption, fork: ForkPoint, decision: DecisionCaption, stubs: StepStubs };
/** Two clicks on one box closer than this are a double-click. */
const DOUBLE_CLICK_MS = 400;
@@ -285,6 +286,26 @@
data: { label: d.label, width: d.width, dimmed: neighbours !== null && !neighbours.has(owner) },
});
}
// A box's far links, said in words in the gap under it. Not while it is
// selected: then every one of its real lines is drawn, and the words would
// be saying a second time what the reader can now see.
for (const node of model.layout.nodes) {
const list = model.stubs.get(node.id);
if (list === undefined || list.length === 0 || selected === node.id) continue;
captions.push({
id: `stubs:${node.id}`,
type: 'stubs',
position: { x: node.x, y: node.y + node.height },
draggable: false,
selectable: false,
connectable: false,
data: {
stubs: list,
width: node.width,
dimmed: neighbours !== null && !neighbours.has(node.id),
},
});
}
return captions.concat(model.layout.nodes.map((node) => {
// A decision's point: not a step — no selection, no panel; the box asks
// and the lines out answer.