●start
- {order ? 'Where the picture starts; below it, what it does in the code’s own order' : 'Where the picture starts; each row down is one more step away'}
+ {order
+ ? 'Where the picture starts; below it, what it does in the code’s own order'
+ : regions
+ ? 'Where the picture starts; below it, the parts of the ' + kindWord('screen', project) + ', each with what happens there'
+ : 'Where the picture starts; each row down is one more step away'}
+ {#if regions && !order}
+
+ Name
+ A region — the component that owns the boxes under its rule, a box above what it sets in motion. One line from
+ the start into each region stands in for the {kindWord('screen', project)}'s whole fan-out; every other line
+ draws where it leads
+
+
+ no line
+ A box nothing points at is the region's own doing — run on render or mount, or from a binding written inline.
+ Select it and its line from the {kindWord('screen', project)} lights, with what fires it
+
+ {/if}
{#if project === 'api'}
POST /x
@@ -188,6 +213,12 @@
font-size: 10.5px;
color: var(--ink-3);
}
+.k-cap {
+ font-size: 10.5px;
+ color: var(--ink-3);
+ border-bottom: 1px solid var(--rule-soft);
+ padding-bottom: 2px;
+}
.k-box {
box-sizing: border-box;
padding: 1px 5px;
diff --git a/ui/src/lib/program-model.ts b/ui/src/lib/program-model.ts
index be5878d..5c5d97e 100644
--- a/ui/src/lib/program-model.ts
+++ b/ui/src/lib/program-model.ts
@@ -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();
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 };
}
/**
diff --git a/ui/src/lib/steps-model.ts b/ui/src/lib/steps-model.ts
index f5754af..2bc008b 100644
--- a/ui/src/lib/steps-model.ts
+++ b/ui/src/lib/steps-model.ts
@@ -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;
/** Steps per kind, for the panel's summary. */
counts: Record;
+ /**
+ * 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 | 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 =>
- 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 =>
+ 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();
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,
+ 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();
+ 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();
+ 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();
+ 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();
+ 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(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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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. */
diff --git a/ui/src/lib/wire.ts b/ui/src/lib/wire.ts
index 88e5e3d..dbea0a0 100644
--- a/ui/src/lib/wire.ts
+++ b/ui/src/lib/wire.ts
@@ -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.
diff --git a/ui/src/views/StepsView.svelte b/ui/src/views/StepsView.svelte
index 46fdc13..2f5ee49 100644
--- a/ui/src/views/StepsView.svelte
+++ b/ui/src/views/StepsView.svelte
@@ -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 RegionCaption from '../components/steps/RegionCaption.svelte';
import StepsKey from '../components/steps/StepsKey.svelte';
import ScreenEdge from '../components/screens/ScreenEdge.svelte';
import KindGlyph from '../components/KindGlyph.svelte';
@@ -32,13 +33,14 @@
} from '../lib/api';
import { live } from '../lib/live.svelte';
import { fileHref, flowHref, navigate, stepsHref, symbolHref } from '../lib/navigation';
- import { isEdgeVisible, type MapEdgeLayout } from '../lib/map-model';
+ import type { MapEdgeLayout } from '../lib/map-model';
import { hoverPill, nearestEdge, placeLabels } from '../lib/screens-model';
import { commonTokens, conditionTokens, restTokens, scenarios, whenWords, type WordToken } from '../lib/conditions';
import {
buildStepsModel,
kindWord,
kindWords,
+ stepEdgeVisible,
stepNeighbourhood,
stepPairId,
stepViaText,
@@ -118,14 +120,17 @@
/**
* The fit. A picture of a few boxes is centred — and the key, bottom left,
* would sit on its second row; it is fitted to the right of the key instead.
- * A picture of many boxes is fitted to the whole stage, as the Screens view's.
+ * A picture of many boxes is fitted to the whole stage, as the Screens view's
+ * — and a picture laid out by region may fit far out: the regions and their
+ * captions are the overview, and the reader zooms into one, where a 0.4
+ * floor on a big screen's picture opened on a window torn out of its middle.
*/
const fitOptions = $derived(
model !== null && model.layout.nodes.length <= 24 && legendOpen
? { padding: { left: '440px', top: '32px', right: '32px', bottom: '32px' }, maxZoom: 1, minZoom: 0.4 }
- : { padding: 0.1, maxZoom: 1, minZoom: 0.4 }
+ : { padding: 0.1, maxZoom: 1, minZoom: model !== null && model.regions !== null ? 0.2 : 0.4 }
);
- const nodeTypes = { step: StepNode };
+ const nodeTypes = { step: StepNode, region: RegionCaption };
/** Two clicks on one box closer than this are a double-click. */
const DOUBLE_CLICK_MS = 400;
@@ -232,7 +237,18 @@
const nodes = $derived.by(() => {
if (model === null) return [];
- return model.layout.nodes.map((node) => ({
+ // A screen's picture carries a caption over each region — text in the gap
+ // above the region's first line, taking no pointer.
+ const captions: Node[] = (model.regions ?? []).map((zone) => ({
+ id: `region:${zone.id}`,
+ type: 'region',
+ position: { x: zone.x, y: zone.y - 32 },
+ draggable: false,
+ selectable: false,
+ connectable: false,
+ data: { label: zone.label, width: zone.width },
+ }));
+ return captions.concat(model.layout.nodes.map((node) => ({
id: node.id,
type: 'step',
position: { x: node.x, y: node.y },
@@ -264,14 +280,14 @@
// no symbol to start from.
...(model.nodes.get(node.id)?.step.node && !model.nodes.get(node.id)?.step.anchor ? { onStart: startHere } : {}),
},
- }));
+ })));
});
const edges = $derived.by(() => {
if (model === null) return [];
const focus = focusId;
return model.layout.edges
- .filter((edge) => isEdgeVisible(edge, selected))
+ .filter((edge) => stepEdgeVisible(model, edge, selected))
.map((edge) => {
const touches = selected !== null && (edge.source === selected || edge.target === selected);
const isFocus = focus === edge.id;
@@ -537,6 +553,7 @@
(legendOpen = next)}