feat(steps): lay out screen pictures by region and render region captions

Adds region-based layout support for screens: steps now carry region information, and the server packs regions into dedicated bands with per-region captions. UI changes introduce RegionCaption and region-aware step rendering; StepsModel and related views (StepsView) consume region data, while the region-aware layout keeps anchor and region boundaries intact. Tests and docs updated to reflect region-driven organization and visualization of screen regions. This enables visualizing a screen’s picture as region-based columns rather than a single distance-driven row.
This commit is contained in:
Colby McHenry
2026-08-31 15:38:13 -05:00
parent 6f4887db80
commit 882ea143e8
18 changed files with 1079 additions and 57 deletions
+7 -2
View File
@@ -269,7 +269,11 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
minWeight: 0,
sizing: (m) => {
const info = nodes.get(m.id);
return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
// Size for the ` …` a cut step wears and the anchor's ● mark, or the
// CSS ellipsis eats the name's tail.
const cut = info?.step.cut != null ? ' …' : '';
const mark = info?.step.anchor ? '● ' : '';
return { label: mark + (info?.label ?? m.id) + cut, meta: info?.sub ?? '' };
},
layering,
order: (id) => nodes.get(id)?.step.order ?? Number.MAX_SAFE_INTEGER,
@@ -281,7 +285,8 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
const curves = trackedCurves(layout, SCREEN_LAYER_GAP);
const polylines = new Map<string, Point[]>();
for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts };
// The order reading needs no regions: its rows already say when.
return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts, regions: null, regionEntries: null };
}
/**
+426 -26
View File
@@ -13,7 +13,21 @@
*/
import type { WireMapLink, WireMapModule, WireStep, WireStepLink, WireStepTrigger, WireStepsPayload } from './wire';
import { buildMapLayout, linkId, PORT_PITCH, type MapLayout } from './map-model';
import {
buildMapLayout,
linkId,
nodeWidth,
strokeWidthFor,
NODE_GAP,
NODE_HEIGHT,
PADDING,
PORT_PITCH,
type EdgeRoute,
type MapEdgeLayout,
type MapLayout,
type MapNodeLayout,
type PortRef,
} from './map-model';
import {
edgeLabel,
samplePolyline,
@@ -56,6 +70,26 @@ export interface StepsModel extends Picture {
polylines: Map<string, Point[]>;
/** Steps per kind, for the panel's summary. */
counts: Record<WireStep['kind'], number>;
/**
* A screen's picture only: the regions its boxes are laid out by, for the
* captions. Null when the steps carry no regions — an endpoint's or a
* function's picture, and the order reading — and the rows are distance.
*/
regions: StepRegionZone[] | null;
/** The first box of each region — where the anchor's at-rest line arrives. */
regionEntries: ReadonlySet<string> | null;
}
/** One region of a screen's picture: its caption, and the space its boxes hold. */
export interface StepRegionZone {
id: string;
label: string;
x: number;
y: number;
width: number;
height: number;
/** The region's first box, in the walk's order. */
entry: string;
}
/** Points a curve is sampled at for hit-testing (as the Screens view's). */
@@ -243,34 +277,400 @@ export function buildStepsModel(payload: WireStepsPayload): StepsModel {
});
}
// Layer = distance from the anchor, counted by the server. Layer 0 is the
// bottom, so the deepest row is 0 and the anchor is on top.
const depthOf = new Map(payload.steps.map((s) => [s.id, s.depth]));
const deepest = Math.max(0, ...payload.steps.map((s) => s.depth));
const layering = (ids: string[]): Map<string, number> =>
new Map(ids.map((id) => [id, deepest - (depthOf.get(id) ?? deepest)]));
// A screen's picture is laid out by its REGIONS when the server named them
// (`WireStep.region`): a screen is a set of handlers with no order between
// them, so distance alone put ninety boxes on one enormous row. An
// endpoint's or a function's picture keeps the rows: there, distance IS the
// reading.
const regioned = payload.steps.some((s) => s.region !== undefined);
let layout: MapLayout;
let zones: StepRegionZone[] | null = null;
if (regioned) {
const packed = packRegions(payload.steps, nodes, modules, links);
layout = packed.layout;
zones = packed.zones;
} else {
// Layer = distance from the anchor, counted by the server. Layer 0 is the
// bottom, so the deepest row is 0 and the anchor is on top.
const depthOf = new Map(payload.steps.map((s) => [s.id, s.depth]));
const deepest = Math.max(0, ...payload.steps.map((s) => s.depth));
const layering = (ids: string[]): Map<string, number> =>
new Map(ids.map((id) => [id, deepest - (depthOf.get(id) ?? deepest)]));
const layout = buildMapLayout(
{ modules, links },
{
includeTests: true,
minWeight: 0,
sizing: (m) => {
const info = nodes.get(m.id);
return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
},
layering,
// The server ordered each row the way the code reads; keep it.
order: (id) => nodes.get(id)?.step.order ?? Number.MAX_SAFE_INTEGER,
layerGap: SCREEN_LAYER_GAP,
portPitch: PORT_PITCH,
ports: 'directional',
}
);
const curves = trackedCurves(layout, SCREEN_LAYER_GAP);
layout = buildMapLayout(
{ modules, links },
{
includeTests: true,
minWeight: 0,
sizing: (m) => {
const info = nodes.get(m.id);
// Size for the ` …` a cut step wears and the anchor's ● mark, as `packRegions` does.
const cut = info?.step.cut != null ? ' …' : '';
const mark = info?.step.anchor ? '● ' : '';
return { label: mark + (info?.label ?? m.id) + cut, meta: info?.sub ?? '' };
},
layering,
// The server ordered each row the way the code reads; keep it.
order: (id) => nodes.get(id)?.step.order ?? Number.MAX_SAFE_INTEGER,
layerGap: SCREEN_LAYER_GAP,
portPitch: PORT_PITCH,
ports: 'directional',
}
);
}
const layerGap = regioned ? REGION_GAP_Y : SCREEN_LAYER_GAP;
const curves = trackedCurves(layout, layerGap);
const polylines = new Map<string, Point[]>();
for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts };
return {
layout,
nodes,
edges,
layerGap,
curves,
polylines,
counts,
regions: zones,
regionEntries: zones === null ? null : new Set(zones.map((z) => z.entry)),
};
}
/* --------------------------------------------------------------- regions -- */
/**
* The gap under a line of boxes within a region — tighter than the row gap of
* an unregioned picture, whose gaps carry every line of a whole row's fan-out;
* here a gap holds a few local hops, and a screen's picture is tall enough
* already. The tracked curves take the same number, so a level arch stays
* inside it.
*/
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;
/** 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. */
const BAND_GAP = 84;
/** Bands may run this wide: enough for the widest region, aiming at a readable aspect. */
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 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
* 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.
*/
function packRegions(
steps: WireStep[],
infos: Map<string, StepNodeInfo>,
modules: WireMapModule[],
links: WireMapLink[]
): { layout: MapLayout; zones: StepRegionZone[] } {
const anchor = steps.find((s) => s.anchor)!;
const members = steps.filter((s) => !s.anchor);
const moduleOf = new Map(modules.map((m) => [m.id, m]));
// A box is wide enough for its words and for its ports — the anchor touches
// most of the picture, and its lines need somewhere to leave from.
const degree = new Map<string, number>();
for (const l of links) {
degree.set(l.source, (degree.get(l.source) ?? 0) + 1);
degree.set(l.target, (degree.get(l.target) ?? 0) + 1);
}
const widthOf = (id: string): number => {
const info = infos.get(id);
// A cut step wears ` …` after its name and the anchor its ● mark before
// it; size for both, or the CSS ellipsis eats the name's tail instead
// (`/scan-to-verif…` for `/scan-to-verify …`).
const cut = info?.step.cut != null ? ' …' : '';
const mark = info?.step.anchor ? '● ' : '';
return Math.max(
nodeWidth(mark + (info?.label ?? id) + cut, info?.sub ?? ''),
((degree.get(id) ?? 0) + 1) * PORT_PITCH
);
};
// Regions in the order the walk met them — the screen's own source order.
interface Region {
id: string;
label: string;
members: WireStep[];
}
const regions = new Map<string, Region>();
for (const s of members) {
const id = s.region?.id ?? anchor.id;
const region = regions.get(id) ?? { id, label: s.region?.label ?? anchor.label, members: [] };
region.members.push(s);
regions.set(id, region);
}
// Within a region, a step goes under the steps that lead to it.
const regionOf = new Map(members.map((s) => [s.id, s.region?.id ?? anchor.id]));
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;
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)
);
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);
}
}
packed.set(region.id, { lines, width });
}
// Tile the regions into bands under a width budget.
interface Band {
regions: Region[];
lines: number;
width: number;
}
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;
}
});
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;
}
y += b.lines * REGION_PITCH + BAND_GAP;
globalLine += b.lines;
}
const height = y - REGION_PITCH - BAND_GAP + NODE_HEIGHT + PADDING;
// 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.
const layerOf = (id: string): number =>
id === anchor.id ? globalLine + 1 : globalLine - (at.get(id)?.line ?? 0);
const nodesById = new Map<string, MapNodeLayout>();
const place = (id: string, x: number, yy: number): void => {
nodesById.set(id, {
id,
module: moduleOf.get(id)!,
island: false,
generated: false,
layer: layerOf(id),
x,
y: yy,
width: widthOf(id),
height: NODE_HEIGHT,
sourceHandles: [],
targetHandles: [],
ports: { top: [], bottom: [] },
});
};
place(anchor.id, PADDING + (contentWidth - widthOf(anchor.id)) / 2, anchorY);
for (const [id, p] of at) place(id, p.x, p.y);
// Edges and ports, exactly as the Map lays them: the route from the layers,
// the sides from the route, the ports spread in the order the other end
// appears left to right.
const edges: MapEdgeLayout[] = [];
interface SidePort extends PortRef {
other: number;
}
const sidePorts = new Map<string, { top: SidePort[]; bottom: SidePort[] }>();
const centreOf = (id: string): number => {
const n = nodesById.get(id);
return n ? n.x + n.width / 2 : 0;
};
for (const link of links) {
const from = nodesById.get(link.source);
const to = nodesById.get(link.target);
if (!from || !to) continue;
const id = linkId(link);
const route: EdgeRoute = from.layer > to.layer ? 'down' : from.layer < to.layer ? 'up' : 'level';
edges.push({
id,
source: link.source,
target: link.target,
sourceHandle: `s:${id}`,
targetHandle: `t:${id}`,
link,
width: strokeWidthFor(link.count),
back: from.layer <= to.layer,
thin: false,
route,
});
const sides =
route === 'down'
? { source: 'bottom' as const, target: 'top' as const }
: route === 'up'
? { source: 'top' as const, target: 'bottom' as const }
: { source: 'top' as const, target: 'top' as const };
const bySide = (node: string): { top: SidePort[]; bottom: SidePort[] } => {
const found = sidePorts.get(node) ?? { top: [], bottom: [] };
sidePorts.set(node, found);
return found;
};
bySide(link.source)[sides.source].push({ id, type: 'source', other: centreOf(link.target) });
bySide(link.target)[sides.target].push({ id, type: 'target', other: centreOf(link.source) });
}
const byOther = (a: SidePort, b: SidePort): number => a.other - b.other || a.id.localeCompare(b.id);
for (const [id, sides] of sidePorts) {
const node = nodesById.get(id);
if (!node) continue;
sides.top.sort(byOther);
sides.bottom.sort(byOther);
node.ports = {
top: sides.top.map((p) => ({ id: p.id, type: p.type })),
bottom: sides.bottom.map((p) => ({ id: p.id, type: p.type })),
};
node.sourceHandles = sides.bottom.filter((p) => p.type === 'source').map((p) => p.id);
node.targetHandles = sides.top.filter((p) => p.type === 'target').map((p) => p.id);
}
const layout: MapLayout = {
nodes: [...nodesById.values()],
edges,
layers: [],
width: contentWidth + PADDING * 2,
height,
basis: { kind: 'all', declaredLinks: links.length, totalLinks: links.length },
minWeight: 0,
hiddenLinks: 0,
mutual: [],
moduleCycles: [],
};
return { layout, zones };
}
/**
* Which edges draw, given the selection. Selecting a step says "show me
* everything about this one" — every line touching it comes out. At rest a
* regioned picture hides exactly two things: the anchor's own fan — the
* anchor leads to everything by definition, and a hundred and four ways of
* saying so were the whole canvas, so one line into each region stands in for
* it — and, as everywhere, what points back up the layering. Every other
* lead-to draws, a line between two regions included: the empty state's
* prompt firing the same handler as the header's is the picture, and hiding
* it made a box that leads three places read as wired to nothing. A shared
* step fed from below (the toast every handler calls) stays quiet through the
* back rule alone. An unregioned picture keeps the Map's rule.
*/
export function stepEdgeVisible(model: StepsModel, edge: MapEdgeLayout, selected: string | null): boolean {
if (selected !== null) return edge.source === selected || edge.target === selected;
if (edge.thin || edge.back) 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;
return true;
}
/** The side panel's two lists for a selected step. */
+7
View File
@@ -762,6 +762,13 @@ export interface WireStep {
trigger?: WireStepTrigger;
/** The step's place in its row, in the code's order (a hop written inside another site's arguments before that site). */
order?: number;
/**
* A screen anchor's picture only: the region of the screen this step belongs
* to — the top-level component (or hook) the walk first reached it through,
* the screen's own component for the screen body. The viewer lays a screen's
* picture out by these; absent, the rows are distance.
*/
region?: { id: string; label: string };
/**
* For a screen or an endpoint — also a `bridge` step that is an endpoint
* reached across a tier: its path and the symbol that serves it.