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:
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The caption over one region of a screen's picture — the component that
|
||||
* owns the boxes under it, set small in the gap above them, with a hairline
|
||||
* spanning the region's width so the grouping reads from any zoom. Text
|
||||
* only: it takes no pointer, so hovering a line through it still works.
|
||||
*/
|
||||
import type { NodeProps } from '@xyflow/svelte';
|
||||
|
||||
let { data }: NodeProps = $props();
|
||||
const caption = $derived(data as unknown as { label: string; width: number });
|
||||
</script>
|
||||
|
||||
<div class="rcap" style={`width:${caption.width}px`}>{caption.label}</div>
|
||||
|
||||
<style>
|
||||
.rcap {
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--rule-soft);
|
||||
font: 500 11px/16px var(--mono);
|
||||
color: var(--ink-3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -15,12 +15,14 @@
|
||||
project: ProjectKind;
|
||||
/** The reading on screen: the rail's rows, or the canvas's. */
|
||||
order: boolean;
|
||||
/** A screen's picture, laid out by region rather than by distance. */
|
||||
regions?: boolean;
|
||||
/** In the flow of a scrolling rail rather than floating over a canvas. */
|
||||
flow: boolean;
|
||||
open: boolean;
|
||||
onToggle: (open: boolean) => void;
|
||||
}
|
||||
let { project, order, flow, open, onToggle }: Props = $props();
|
||||
let { project, order, regions = false, flow, open, onToggle }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="legend" class:open class:flow>
|
||||
@@ -31,8 +33,31 @@
|
||||
<div class="legend-body">
|
||||
<div class="lrow">
|
||||
<span class="k-box k-anchor mono"><span class="mark">●</span>start</span>
|
||||
<span>{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'}</span>
|
||||
<span
|
||||
>{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'}</span
|
||||
>
|
||||
</div>
|
||||
{#if regions && !order}
|
||||
<div class="lrow">
|
||||
<span class="k-cap mono">Name</span>
|
||||
<span
|
||||
>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</span
|
||||
>
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<span class="k-label">no line</span>
|
||||
<span
|
||||
>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</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if project === 'api'}
|
||||
<div class="lrow">
|
||||
<span class="k-box mono">POST /x</span>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Node[]>(() => {
|
||||
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<Edge[]>(() => {
|
||||
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 @@
|
||||
<StepsKey
|
||||
project={payload.project}
|
||||
order={readAs === 'order'}
|
||||
regions={model.regions !== null}
|
||||
flow={false}
|
||||
open={legendOpen}
|
||||
onToggle={(next) => (legendOpen = next)}
|
||||
|
||||
Reference in New Issue
Block a user