- {#each items as item, i (i)}
- {#if item.kind === 'step'}
-
- {#if item.within}inside {item.within}(…){/if}
- {#if item.info}
- onSelect(item.id)}
- onStart={canStart(item.id) ? () => onStart(item.id) : undefined}
- />
- {:else}
- a step the picture left out
- {/if}
- {#if item.again}as above{/if}
-
- {#if item.body.length > 0}
-
-
-
- {/if}
- {:else if item.kind === 'fork'}
- {@const guard = item.arms.length <= 2 && item.arms[0]?.body.length === 0 && item.arms[0]?.ends !== null}
- {#if guard}
-
-
- {@render words(item.words)}
- {item.arms[0]!.ends}
-
- {#if item.arms[1] && item.arms[1].body.length > 0}
-
- {/if}
- {#if item.arms[1]?.ends}
{item.arms[1].ends}
{/if}
- {:else}
-
-
{@render words(item.words)}
-
- {#each item.arms as arm, a (a)}
-
-
{@render words(arm.words)}
- {#if arm.body.length > 0}
-
- {/if}
- {#if arm.ends}
{arm.ends}
{/if}
-
- {/each}
-
-
- {/if}
- {:else if item.kind === 'group'}
-
-
- {item.label}{#if item.within} · inside {item.within}(…){/if}{#if item.again} · read above{/if}
-
- {#if item.body.length > 0}
-
- {/if}
-
- {:else}
-
{item.text}
- {/if}
- {/each}
-
-
-
diff --git a/ui/src/components/steps/StepsKey.svelte b/ui/src/components/steps/StepsKey.svelte
index 37ca1bc..97fbfac 100644
--- a/ui/src/components/steps/StepsKey.svelte
+++ b/ui/src/components/steps/StepsKey.svelte
@@ -87,16 +87,20 @@
{/if}
{#if order}
name …
diff --git a/ui/src/components/steps/StepsRail.svelte b/ui/src/components/steps/StepsRail.svelte
deleted file mode 100644
index d62ddcf..0000000
--- a/ui/src/components/steps/StepsRail.svelte
+++ /dev/null
@@ -1,94 +0,0 @@
-
-
-
-
-
onSelect(anchor.id)}
- />
- {#if anchor.step.trigger}
- FIRES FROM {triggerWords(anchor.step.trigger)}
- {/if}
-
- {#if items.length === 0}
-
Nothing in the index happens in this symbol's body — the picture has no order to read.
- {:else}
-
-
-
- {/if}
- {#if truncated > 0}
-
- {truncated} place{truncated === 1 ? '' : 's'} the reading stopped: code it had already read, or as deep as it goes.
- Start at a step to read on from there.
-
- {/if}
- {@render children?.()}
-
-
-
diff --git a/ui/src/lib/program-model.ts b/ui/src/lib/program-model.ts
index 6600329..be5878d 100644
--- a/ui/src/lib/program-model.ts
+++ b/ui/src/lib/program-model.ts
@@ -1,175 +1,58 @@
/**
- * The Steps view's second reading, as the rail draws it.
+ * The Steps picture in the code's ORDER — the same canvas, laid out by when
+ * things happen rather than by how far they are from the anchor.
*
- * The server answers with the anchor's body folded into blocks and forks
- * (`api/program.ts`); this turns that into what a reader sees — the boxes of
- * the picture in the code's order, the conditions as words, and one line for
- * every place the reading has to be honest about not being plain sequence
- * (work registered to run later, calls started together, a helper already read
- * above). Nothing here is geometry: the rail is a column of boxes with a
- * hairline down its left, and a fork is a row of columns, so the browser lays
- * it out and this file only decides what each thing SAYS.
+ * The tree's rows are distance: on proshop's login `User.findOne`, `jwt.sign`,
+ * `200` and `401` are each one step out of the handler, so they land in one
+ * row. True, and not the flow — the token is signed while the reply is being
+ * built, so the `200` comes AFTER it, and the `401` is the other side of the
+ * same `if`. This model draws that:
*
- * The words are the ones the rest of the view uses: `steps-model.ts` for a
- * box's two lines and the vocabulary a project is read in, `conditions.ts` for
- * WHEN / AND / OR / NOT. A fork carries its condition once, on its head; an
- * arm then says only which side it is — *when* and *when not* — except in a
- * `switch`, where each arm has a condition of its own to say.
+ * ```
+ * POST /api/users/login
+ * |
+ * User.findOne
+ * +-------------+--------------+
+ * WHEN user AND (await ...) WHEN NOT (...)
+ * | |
+ * jwt.sign 401
+ * |
+ * 200
+ * ```
+ *
+ * **A line here means "then", not "calls"** — that is the whole difference from
+ * the other reading, and the key says so. It comes from the block tree the
+ * server folds out of the walk (`api/program.ts`): items in source order, forks
+ * where the code forks, an arm that answers or leaves ending there. Walking that
+ * tree with a set of *tails* — the steps a next step would follow — gives one
+ * edge per "and then", carrying the arm's condition where the code branched.
+ *
+ * Everything else is the canvas's: the same boxes, the same layout engine, the
+ * same pills, hover and panel. Only the graph changes.
*/
-import { conditionTokens, whenTokens, type WordToken } from './conditions';
-import { stepLabel, stepSub, type ProjectKind, type StepNodeInfo } from './steps-model';
-import type { WireArm, WireArmEnd, WireBlock, WireItem, WireNodeRef, WireStep, WireStepsPayload } from './wire';
+import { conditionTokens, joinTokens, type WordToken } from './conditions';
+import { buildMapLayout, linkId, PORT_PITCH, type MapLayout } from './map-model';
+import { samplePolyline, trackedCurves, EDGE_LABEL_MAX, SCREEN_LAYER_GAP, type Point } from './screens-model';
+import { stepLabel, stepSub, type StepEdgeInfo, type StepNodeInfo, type StepsModel } from './steps-model';
+import type { WireArm, WireBlock, WireItem, WireMapLink, WireMapModule, WireStep, WireStepsPayload } from './wire';
+
+/* ----------------------------------------------------------------- words -- */
/** The construct a fork came from. */
export type ForkForm = Extract
['form'];
-/** A step of the picture, where the code writes it. */
-export interface RailStep {
- kind: 'step';
- id: string;
- /** The link it arrived on — the panel's rows for this site. */
- link: string | null;
- /** The box's words; null when the step is not in the picture (a cap removed it). */
- info: StepNodeInfo | null;
- /** The call this one is written inside the arguments of — `res.json`. */
- within: string | null;
- /** What it does, when the walk read on into it. */
- body: RailItem[];
- /** It happens here too, and was read above. */
- again: boolean;
-}
-
-export interface RailArm {
- /** WHEN / WHEN NOT, or a case's own condition. */
- words: WordToken[];
- /** What the arm's last line says when it stops there: `answers`, `returns`, `throws`, `leaves`. */
- ends: string | null;
- body: RailItem[];
-}
-
-export interface RailFork {
- kind: 'fork';
- /** The decision, in the conditions vocabulary. */
- words: WordToken[];
- /** The word for the construct: `if`, `switch`, `try`. */
- form: string;
- arms: RailArm[];
-}
-
-/** A run that is not plain sequence, bracketed and labelled. */
-export interface RailGroup {
- kind: 'group';
- block: 'inline' | 'loop' | 'later' | 'together';
- /** `via generateToken`, `for each item of items`, `later · then`, `together`. */
- label: string;
- /** The helper drawn here, for its link to the symbol view. */
- via: WireNodeRef | null;
- within: string | null;
- again: boolean;
- body: RailItem[];
-}
-
-export interface RailCut {
- kind: 'cut';
- text: string;
-}
-
-export type RailItem = RailStep | RailFork | RailGroup | RailCut;
-
/**
- * The rail for a payload: its anchor's body in the code's order, or an empty
- * list when the server had nothing to read (a screen, an unreadable file).
+ * What has to hold for an arm to be the one taken, in the words the rest of the
+ * view uses. Said in FULL on the line, because a line on a canvas has no head
+ * above it to refer back to: `WHEN user AND (await …)`, `WHEN NOT (…)`.
*/
-export function buildRailModel(payload: WireStepsPayload): RailItem[] {
- if (!payload.program) return [];
- const steps = new Map(payload.steps.map((s) => [s.id, s]));
- return block(payload.program.root, steps, payload.project);
+export function whenTokens(when: string): WordToken[] {
+ return conditionTokens(when);
}
-function block(items: WireBlock, steps: Map, project: ProjectKind): RailItem[] {
- return items.map((item) => one(item, steps, project));
-}
-
-function one(item: WireItem, steps: Map, project: ProjectKind): RailItem {
- switch (item.kind) {
- case 'step': {
- const step = steps.get(item.step);
- return {
- kind: 'step',
- id: item.step,
- link: item.link ?? null,
- info: step ? { id: step.id, step, label: stepLabel(step), sub: stepSub(step, project) } : null,
- within: item.within ?? null,
- body: item.body ? block(item.body, steps, project) : [],
- again: item.again === true,
- };
- }
- case 'fork':
- return {
- kind: 'fork',
- // The head is the decision itself, said once; the arms say only which
- // side of it they are, so the head carries no WHEN of its own.
- words: whenTokens(item.on),
- form: item.form === 'switch' ? 'switch' : item.form === 'try' ? 'try' : 'if',
- arms: item.arms.map((arm) => ({
- words: armWords(item.form, item.on, arm),
- ends: arm.ends === null ? null : endWords(arm.ends),
- body: block(arm.body, steps, project),
- })),
- };
- case 'block':
- return {
- kind: 'group',
- block: item.block,
- label: groupLabel(item),
- via: item.via ?? null,
- within: item.within ?? null,
- again: item.again === true,
- body: block(item.body, steps, project),
- };
- default:
- return {
- kind: 'cut',
- text:
- item.why === 'folded'
- ? 'reads back into itself — the rest is the same code again'
- : 'as deep as this reading goes — start at a step below to read on',
- };
- }
-}
-
-/**
- * What an arm says. The fork already carries the condition, so the two sides of
- * an `if` say only which side they are; a `switch` arm has a condition of its
- * own, and so does an arm the reading could not match to the head.
- */
-export function armWords(form: ForkForm, on: string, arm: WireArm): WordToken[] {
- // A case has a condition of its own to say; the one arm of a `try` is the
- // head (`on error`) and says nothing twice.
- if (form === 'switch') return conditionTokens(arm.when);
- if (form === 'try') return [];
- if (arm.not === true) return [{ kw: true, text: 'WHEN' }, { kw: true, text: 'NOT' }];
- if (arm.when === on) return [{ kw: true, text: 'WHEN' }];
- return conditionTokens(arm.when);
-}
-
-/** How an arm leaves, as a reader says it. */
-export function endWords(end: WireArmEnd): string {
- switch (end) {
- case 'reply':
- return 'answers here';
- case 'return':
- return 'returns here';
- case 'throw':
- return 'throws here';
- default:
- return 'leaves here';
- }
-}
-
-/** The words on a bracketed run. */
-export function groupLabel(item: Extract): string {
+/** The words on a run that is not plain sequence — said on the line into it. */
+export function runWords(item: Extract): string {
switch (item.block) {
case 'inline':
return item.via ? `via ${item.via.name}` : 'via a helper';
@@ -182,3 +65,233 @@ export function groupLabel(item: Extract): string {
return item.by ? `together · ${item.by}` : 'together';
}
}
+
+/* ----------------------------------------------------------------- graph -- */
+
+/** One "and then": the step it follows, the step that happens, and what had to hold. */
+export interface OrderEdge {
+ from: string;
+ to: string;
+ /** The conditions on the way — the arms of the forks crossed, joined by ` && `. */
+ when: string;
+ /** `via generateToken`, `for each item of items`, `later · then` — the run it happens inside. */
+ runs: string[];
+}
+
+/** Where a next step would follow from, and under what. */
+interface Tail {
+ id: string;
+ when: string[];
+ runs: string[];
+}
+
+export interface OrderGraph {
+ edges: OrderEdge[];
+ /** How many things happen before each step: its row. */
+ depth: Map;
+}
+
+/**
+ * The block tree as a graph of what happens next. `anchor` is where the reading
+ * starts, so the first thing in the body follows it.
+ */
+export function orderGraph(program: NonNullable, anchor: string): OrderGraph {
+ const edges: OrderEdge[] = [];
+ const seen = new Set([anchor]);
+ const at = new Map();
+
+ const join = (from: string, to: string, tail: Tail): void => {
+ if (from === to) return;
+ const key = `${from} ${to}`;
+ const when = tail.when.filter((w, i) => w && tail.when.indexOf(w) === i).join(' && ');
+ const found = at.get(key);
+ if (found) {
+ // Two ways to the same step: the picture keeps both conditions, the way
+ // a link with several sites does.
+ if (when !== found.when) found.when = !when || !found.when ? '' : `${found.when} || ${when}`;
+ for (const r of tail.runs) if (!found.runs.includes(r)) found.runs.push(r);
+ return;
+ }
+ const edge: OrderEdge = { from, to, when, runs: [...tail.runs] };
+ at.set(key, edge);
+ edges.push(edge);
+ };
+
+ const flow = (block: WireBlock, incoming: readonly Tail[], runs: readonly string[]): Tail[] => {
+ let tails: Tail[] = [...incoming];
+ for (const item of block) {
+ if (item.kind === 'step') {
+ seen.add(item.step);
+ for (const t of tails) join(t.id, item.step, { ...t, runs: [...t.runs, ...runs] });
+ // What the step itself sets in motion happens inside it, so the next
+ // thing in the block follows THAT, not the box.
+ let inner: Tail[] = [{ id: item.step, when: [], runs: [] }];
+ if (item.body && item.body.length > 0) inner = flow(item.body, inner, []);
+ tails = inner;
+ } else if (item.kind === 'fork') {
+ const out: Tail[] = [];
+ for (const arm of item.arms) {
+ // The arm's condition as the SOURCE has it: the words are made once,
+ // at the end, or two ways of arriving would each carry their own WHEN.
+ const entry = tails.map((t) => ({ id: t.id, when: [...t.when, arm.when], runs: [...t.runs, ...runs] }));
+ // An arm that answers, returns or throws does not rejoin — nothing
+ // leaves the last box in it, which is what says so on a canvas.
+ const armTails = flow(arm.body, entry, []);
+ if (arm.ends === null) out.push(...armTails);
+ }
+ // An `if` with no `else` runs on either way; a fork with both sides
+ // covered runs on only through the arms that did not end.
+ if (item.arms.length < 2) out.push(...tails.map((t) => ({ ...t, runs: [...t.runs, ...runs] })));
+ tails = out;
+ } else if (item.kind === 'block') {
+ const label = runWords(item);
+ const inner = flow(item.body, tails, [...runs, label]);
+ // A helper that answers on one path still returns on another: the code
+ // after the call follows the call, not nothing. And what comes after
+ // the call is not inside it — a tail that fell through the block drops
+ // the block's own words on the way out.
+ tails = (inner.length > 0 ? inner : tails).map((t) => (t.runs.includes(label) ? { ...t, runs: t.runs.filter((r) => r !== label) } : t));
+ }
+ }
+ return tails;
+ };
+
+ flow(program.root, [{ id: anchor, when: [], runs: [] }], []);
+
+ // Nothing the reading holds may float: a step the walk drew but the fold
+ // could not place follows the anchor, unconditionally.
+ for (const id of seen) {
+ if (id !== anchor && !edges.some((e) => e.to === id)) join(anchor, id, { id: anchor, when: [], runs: [] });
+ }
+
+ return { edges, depth: rows(anchor, seen, edges) };
+}
+
+/**
+ * The row each step sits on: the longest run of "and then" from the anchor to
+ * it, so a step never draws above something that has to happen first. Settled
+ * by relaxation rather than a topological sort, because a step reached twice
+ * (`session.add` before and after a check) can make the graph cyclic.
+ */
+function rows(anchor: string, nodes: ReadonlySet, edges: readonly OrderEdge[]): Map {
+ const depth = new Map();
+ for (const id of nodes) depth.set(id, 0);
+ depth.set(anchor, 0);
+ for (let pass = 0; pass < nodes.size; pass++) {
+ let moved = false;
+ for (const e of edges) {
+ const next = (depth.get(e.from) ?? 0) + 1;
+ if (next > (depth.get(e.to) ?? 0)) {
+ depth.set(e.to, next);
+ moved = true;
+ }
+ }
+ if (!moved) break;
+ }
+ return depth;
+}
+
+/* ----------------------------------------------------------------- build -- */
+
+/** Points a curve is sampled at for hit-testing (as the other reading's). */
+const HIT_SAMPLES = 24;
+
+/**
+ * The same picture as `buildStepsModel`, laid out in the code's order. Null
+ * when the anchor has no body to read — the view then offers the tree.
+ */
+export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
+ if (!payload.program) return null;
+ const anchorStep = payload.steps.find((s) => s.anchor);
+ if (!anchorStep) return null;
+ const graph = orderGraph(payload.program, anchorStep.id);
+
+ const nodes = new Map();
+ const modules: WireMapModule[] = [];
+ const counts: StepsModel['counts'] = { anchor: 0, screen: 0, trigger: 0, bridge: 0, event: 0, store: 0, effect: 0 };
+ const degree = new Map();
+ for (const e of graph.edges) {
+ degree.set(e.from, (degree.get(e.from) ?? 0) + 1);
+ degree.set(e.to, (degree.get(e.to) ?? 0) + 1);
+ }
+ const byId = new Map(payload.steps.map((s) => [s.id, s]));
+ for (const id of [anchorStep.id, ...graph.depth.keys()]) {
+ if (nodes.has(id)) continue;
+ const step: WireStep | undefined = byId.get(id);
+ if (!step) continue;
+ counts[step.kind]++;
+ const info: StepNodeInfo = { id, step, label: stepLabel(step), sub: stepSub(step, payload.project) };
+ nodes.set(id, info);
+ modules.push({
+ id,
+ label: info.label,
+ files: 1,
+ symbols: degree.get(id) ?? 0,
+ languages: [],
+ test: false,
+ generated: 0,
+ generatedFiles: [],
+ facade: false,
+ fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
+ });
+ }
+
+ const links: WireMapLink[] = [];
+ const edges = new Map();
+ for (const e of graph.edges) {
+ if (!nodes.has(e.from) || !nodes.has(e.to)) continue;
+ const key = linkId({ source: e.from, target: e.to });
+ if (edges.has(key)) continue;
+ links.push({ source: e.from, target: e.to, count: 1, declared: 1, byKind: [{ kind: 'calls', count: 1 }], topPairs: [] });
+ // The panel and the tooltip still read the walk's own links — the sites,
+ // the `via` chain, what fires it — for the step the line arrives at.
+ const behind = payload.links.filter((l) => l.to === e.to);
+ edges.set(key, {
+ id: key,
+ from: e.from,
+ to: e.to,
+ links: behind,
+ label: lineWords(e),
+ synthesized: behind.length > 0 && behind.every((l) => l.synthesized),
+ kind: behind[0]?.kind ?? 'calls',
+ });
+ }
+
+ // Row 0 is the bottom, so the anchor — nothing happens before it — is on top.
+ const deepest = Math.max(0, ...graph.depth.values());
+ const layering = (ids: string[]): Map =>
+ new Map(ids.map((id) => [id, deepest - (graph.depth.get(id) ?? deepest)]));
+
+ const layout: MapLayout = 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,
+ 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);
+ 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 };
+}
+
+/**
+ * What a line says: the whole condition the step at its end runs under — this
+ * picture's lines ARE its conditions, so they are not shortened to the last
+ * clause the way the other reading's are — else the run it happens inside
+ * (`via generateToken`), and nothing at all when the code simply goes on.
+ */
+export function lineWords(e: OrderEdge): string {
+ if (!e.when) return e.runs.length > 0 ? e.runs[e.runs.length - 1]! : '';
+ const text = joinTokens(whenTokens(e.when));
+ return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}…` : text;
+}
diff --git a/ui/src/lib/screens-model.ts b/ui/src/lib/screens-model.ts
index b4af9b3..3d29355 100644
--- a/ui/src/lib/screens-model.ts
+++ b/ui/src/lib/screens-model.ts
@@ -757,18 +757,22 @@ function layPill(
* pill: the pill for a hovered edge that is not the selected screen's is
* placed separately by {@link hoverPill}.
*/
-export function placeLabels(model: Picture, selected: string | null): PillLayout {
+export function placeLabels(model: Picture, selected: string | null, atRest = false): PillLayout {
const pills = new Map();
- if (selected === null) return { pills, hidden: 0 };
+ if (selected === null && !atRest) return { pills, hidden: 0 };
const nodes = new Map(model.layout.nodes.map((n) => [n.id, n]));
const lanes = laneCount(model.layerGap);
const bounds = { width: model.layout.width, height: model.layout.height };
const taken: Rect[] = model.layout.nodes.map((n) => ({ x: n.x, y: n.y, w: n.width, h: n.height }));
+ // At rest, only the selected screen's lines are labelled — a picture with a
+ // label on every line is unreadable, and the reader has asked about one box.
+ // A picture whose labels ARE its content says so (`atRest`): the Steps view
+ // in the code's order, where the conditions on the lines are the flow.
const candidates = model.layout.edges
- .filter((e) => e.source === selected || e.target === selected)
+ .filter((e) => atRest || e.source === selected || e.target === selected)
.map((edge) => {
- const end: 'source' | 'target' = edge.source === selected ? 'target' : 'source';
+ const end: 'source' | 'target' = selected !== null && edge.target === selected ? 'source' : 'target';
const far = nodes.get(end === 'source' ? edge.source : edge.target);
const anchor = far ? portPoint(far, edge.id, end) : { x: 0, y: 0 };
return { edge, end, anchor };
diff --git a/ui/src/views/StepsView.svelte b/ui/src/views/StepsView.svelte
index e1cb026..46fdc13 100644
--- a/ui/src/views/StepsView.svelte
+++ b/ui/src/views/StepsView.svelte
@@ -17,7 +17,6 @@
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 StepsRail from '../components/steps/StepsRail.svelte';
import StepsKey from '../components/steps/StepsKey.svelte';
import ScreenEdge from '../components/screens/ScreenEdge.svelte';
import KindGlyph from '../components/KindGlyph.svelte';
@@ -46,7 +45,7 @@
triggerWords,
type StepsModel,
} from '../lib/steps-model';
- import { buildRailModel } from '../lib/program-model';
+ import { buildOrderModel } from '../lib/program-model';
interface Props {
anchor: string | null;
@@ -192,37 +191,24 @@
return () => controller.abort();
});
- const model = $derived(payload === null ? null : buildStepsModel(payload));
-
/**
* Which reading is on screen. The URL wins; otherwise the answer's own
* default — the code's order for a handler, an endpoint or any function, the
* tree for a screen, where handlers fire on events and have nothing to order.
*/
const readAs = $derived<'order' | 'tree'>(reading ?? payload?.defaultView ?? 'tree');
- const rail = $derived(payload === null || readAs !== 'order' ? [] : buildRailModel(payload));
- /** The rail can be asked for and have nothing to show: say so rather than drawing an empty page. */
- const railReadable = $derived(payload?.program != null);
- /** The steps on the selected step's own lines — everything else on the rail is dimmed. */
- const litOnRail = $derived.by(() => {
- if (payload === null || selected === null) return null;
- const set = new Set([selected]);
- for (const l of payload.links) {
- if (l.from === selected) set.add(l.to);
- if (l.to === selected) set.add(l.from);
- }
- return set;
- });
- /** A step with a symbol behind it can become the next anchor. */
- function canStart(id: string): boolean {
- const step = payload?.steps.find((s) => s.id === id);
- return !!step?.node && !step.anchor;
- }
- function selectOnRail(id: string): void {
- selected = selected === id ? null : id;
- hovered = null;
- panelHot = null;
- }
+
+ /**
+ * The picture. Both readings are the same canvas over the same boxes; what
+ * differs is the graph — in the code's order a line means "and then" and the
+ * rows are how much has already happened, in the tree it means "leads to" and
+ * the rows are distance from the anchor.
+ */
+ const model = $derived(
+ payload === null ? null : (readAs === 'order' ? buildOrderModel(payload) : null) ?? buildStepsModel(payload)
+ );
+ /** The order can be asked for and have nothing to read: the view then says so. */
+ const orderReadable = $derived(payload?.program != null);
const neighbours = $derived.by(() => {
if (model === null || selected === null) return null;
@@ -234,7 +220,9 @@
return set;
});
- const pills = $derived(model === null ? null : placeLabels(model, selected));
+ // In the code's order the conditions ON the lines are the picture: they are
+ // drawn at rest, not only for the step the reader selected.
+ const pills = $derived(model === null ? null : placeLabels(model, selected, readAs === 'order'));
const focusId = $derived(hovered?.edge.id ?? panelHot?.edge ?? null);
const focusPill = $derived.by(() => {
if (model === null || focusId === null || pills?.pills.has(focusId)) return null;
@@ -349,7 +337,7 @@
}
function onStageMove(event: MouseEvent): void {
- if (model === null || stage === null || readAs === 'order') return;
+ if (model === null || stage === null) return;
const target = event.target as Element | null;
if (target?.closest('.spill')) return;
if (target?.closest('.snode, .legend, .tip, .svelte-flow__controls')) {
@@ -491,37 +479,15 @@
{:else if loading && payload === null}