feat(steps): render fork decisions as points with per-arm edges and captions
Adds full support for decisions at forks in both the code graph and the UI. Key changes introduce a decision model for forks (innermost guard decisions), propagate decision data through the server and wire layer, and render decisions in the UI as distinct points with labeled arms. New components (ForkPoint and DecisionCaption) visualize the decision and its arms, while utilities (armWords, forkLabel) generate arm captions. The order reading (canvas) now shows decisions as points, and arms are drawn as separate edges (yes/no/case), with labels and captions displayed under the deciding box. Tests, typings, and docs updated to reflect the new decision visualization and behavior, including selection reach and resting-label semantics. This lays the groundwork for clearer visualization of conditional navigation and guarded branches on the order canvas.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A decision made INSIDE a box, said under it — the tree reading's answer to
|
||||
* a fork.
|
||||
*
|
||||
* In the code's order a fork sits BETWEEN steps and draws as a point of its
|
||||
* own ({@link ForkPoint}). In the tree the decision is written inside a box
|
||||
* and its arms leave that box, so the box is the decision: the condition
|
||||
* goes here, once, under it, and each line out says only which way it is.
|
||||
* Text only, taking no pointer, so hovering a line through it still works.
|
||||
*/
|
||||
import type { NodeProps } from '@xyflow/svelte';
|
||||
import { joinTokens, whenTokens } from '../../lib/conditions';
|
||||
|
||||
let { data }: NodeProps = $props();
|
||||
const caption = $derived(data as unknown as { label: string; width: number; dimmed: boolean });
|
||||
// The label arrives already worded and asking; the tokens are re-read here
|
||||
// only so the joins we add (NOT, AND, OR) set a little bolder, as they do
|
||||
// everywhere else conditions are shown.
|
||||
const tokens = $derived(whenTokens(caption.label.replace(/\?$/, '')));
|
||||
const plain = $derived(tokens.length === 0 || joinTokens(tokens) !== caption.label.replace(/\?$/, ''));
|
||||
</script>
|
||||
|
||||
<div class="dcap" class:dimmed={caption.dimmed} style={`width:${caption.width}px`} title={caption.label}>
|
||||
{#if plain}{caption.label}{:else}{#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}<b class="kw">{t.text}</b
|
||||
>{:else}{t.text}{/if}{/each}?{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dcap {
|
||||
box-sizing: border-box;
|
||||
font: 400 10.5px/14px var(--mono);
|
||||
color: var(--ink-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.dcap.dimmed {
|
||||
color: var(--ink-4);
|
||||
}
|
||||
.kw {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* A decision on the order reading's canvas — the point where a fork's arms
|
||||
* diverge. The condition is said ONCE, here, and each line out answers it
|
||||
* (`yes`, `no`, a case's value): two lines that each carried the whole
|
||||
* predicate, one of them negated, never said they were the same choice.
|
||||
* It is not a step — it takes no click and the panel has nothing to list —
|
||||
* so it draws quieter than a box: one centred line, asking.
|
||||
*/
|
||||
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
|
||||
import type { MapNodeLayout } from '../../lib/map-model';
|
||||
import type { StepForkInfo } from '../../lib/steps-model';
|
||||
import { joinTokens, whenTokens } from '../../lib/conditions';
|
||||
import { EDGE_LABEL_MAX } from '../../lib/screens-model';
|
||||
|
||||
let { data }: NodeProps = $props();
|
||||
const node = $derived(data as unknown as { layout: MapNodeLayout; fork: StepForkInfo; dimmed: boolean });
|
||||
const layout = $derived(node.layout);
|
||||
const tokens = $derived(whenTokens(node.fork.on));
|
||||
// A condition past the box's cap is drawn as the capped plain label the box
|
||||
// was sized for, or the ellipsis eats the question mark.
|
||||
const plain = $derived(tokens.length === 0 || joinTokens(tokens).length > EDGE_LABEL_MAX);
|
||||
|
||||
function portStyle(index: number, total: number): string {
|
||||
return `left:${((index + 1) / (total + 1)) * 100}%`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each layout.ports.top as port, i (`${port.type}:${port.id}`)}
|
||||
<Handle
|
||||
type={port.type}
|
||||
id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
|
||||
position={Position.Top}
|
||||
style={portStyle(i, layout.ports.top.length)}
|
||||
isConnectable={false}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<div
|
||||
class="fpoint"
|
||||
class:dimmed={node.dimmed}
|
||||
style={`width:${layout.width}px;height:${layout.height}px`}
|
||||
title={node.fork.on
|
||||
? `${node.fork.on} — the code forks here; each line out is one arm.`
|
||||
: 'The code forks here; each line out is one arm.'}
|
||||
>
|
||||
<span class="q"
|
||||
>{#if plain}{node.fork.label}{:else}{#each tokens as t, i (i)}{#if i > 0}{' '}{/if}{#if t.kw}<b class="kw"
|
||||
>{t.text}</b
|
||||
>{:else}{t.text}{/if}{/each}?{/if}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
{#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)}
|
||||
<Handle
|
||||
type={port.type}
|
||||
id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
|
||||
position={Position.Bottom}
|
||||
style={portStyle(i, layout.ports.bottom.length)}
|
||||
isConnectable={false}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
.fpoint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0 9px;
|
||||
border: 1px solid var(--ink-2);
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
user-select: none;
|
||||
}
|
||||
.fpoint.dimmed {
|
||||
border-color: var(--ink-4);
|
||||
color: var(--ink-4);
|
||||
}
|
||||
.q {
|
||||
font: 400 12px var(--mono);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
.kw {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -115,9 +115,13 @@
|
||||
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line" /></svg>
|
||||
<span>And then — the step at the other end happens after this one; the plumbing between them is folded into the line</span>
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<span class="k-box k-fork mono">x?</span>
|
||||
<span>A decision both of whose ways are drawn: the box asks the condition once and each line out answers — <span class="mono">yes</span>, <span class="mono">no</span>, a case. An arm that answers or leaves ends there</span>
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<span class="k-label mono">WHEN x</span>
|
||||
<span>Where the code forks — an <span class="mono">if</span>, a <span class="mono">switch</span>, a <span class="mono">try</span>, an early exit: what has to hold for the step at the other end. No label = it happens either way</span>
|
||||
<span>A lone guard — an early exit, an <span class="mono">if</span> with one drawn side: what has to hold for the step at the other end. No label = it happens either way</span>
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<span class="k-label">via x</span>
|
||||
@@ -144,6 +148,14 @@
|
||||
<svg width="44" height="12" aria-hidden="true"><path d="M2 6 H42" class="k-line k-back" /></svg>
|
||||
<span>Goes back up the picture — leaves the top of its box, arrives at the bottom of the other</span>
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<span class="k-label mono">x? · yes</span>
|
||||
<span
|
||||
>A decision made inside a box, both of whose ways are drawn: the condition is said once under the box and
|
||||
each line out of it answers — <span class="mono">yes</span>, <span class="mono">no</span>, a case. These are
|
||||
the only lines labelled before you select anything</span
|
||||
>
|
||||
</div>
|
||||
<div class="lrow">
|
||||
<span class="k-label mono">→ …x</span>
|
||||
<span>The last condition checked before the step, beside the box at the other end of the selected step's line; ← when it arrives there. None = always</span>
|
||||
@@ -237,6 +249,10 @@
|
||||
border-style: dashed;
|
||||
border-color: var(--ink-3);
|
||||
}
|
||||
/* The decision's point draws quieter than a step, on the canvas and here. */
|
||||
.k-box.k-fork {
|
||||
border-color: var(--ink-2);
|
||||
}
|
||||
.k-anchor .mark {
|
||||
font-size: 8px;
|
||||
margin-right: 3px;
|
||||
|
||||
+164
-17
@@ -31,10 +31,10 @@
|
||||
* same pills, hover and panel. Only the graph changes.
|
||||
*/
|
||||
|
||||
import { conditionTokens, joinTokens, type WordToken } from './conditions';
|
||||
import { conditionTokens, joinTokens, whenWords, 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 { armWords, stepLabel, stepSub, type StepEdgeInfo, type StepForkInfo, type StepNodeInfo, type StepsModel } from './steps-model';
|
||||
import type { WireArm, WireBlock, WireItem, WireMapLink, WireMapModule, WireStep, WireStepsPayload } from './wire';
|
||||
|
||||
/* ----------------------------------------------------------------- words -- */
|
||||
@@ -76,6 +76,8 @@ export interface OrderEdge {
|
||||
when: string;
|
||||
/** `via generateToken`, `for each item of items`, `later · then` — the run it happens inside. */
|
||||
runs: string[];
|
||||
/** A line out of a decision's point: which arm this is — `yes`, `no`, a case's value. */
|
||||
arm?: string;
|
||||
}
|
||||
|
||||
/** Where a next step would follow from, and under what. */
|
||||
@@ -83,12 +85,26 @@ interface Tail {
|
||||
id: string;
|
||||
when: string[];
|
||||
runs: string[];
|
||||
arm?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A decision drawn as a point of its own: a fork two or more of whose arms
|
||||
* lead somewhere. Its condition is said once, on the point; each line out
|
||||
* says only which arm it is.
|
||||
*/
|
||||
export interface OrderFork {
|
||||
id: string;
|
||||
on: string;
|
||||
form: ForkForm;
|
||||
}
|
||||
|
||||
export interface OrderGraph {
|
||||
edges: OrderEdge[];
|
||||
/** How many things happen before each step: its row. */
|
||||
depth: Map<string, number>;
|
||||
/** The decisions drawn as points, in the order the reading met them. */
|
||||
forks: OrderFork[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +115,7 @@ export function orderGraph(program: NonNullable<WireStepsPayload['program']>, an
|
||||
const edges: OrderEdge[] = [];
|
||||
const seen = new Set<string>([anchor]);
|
||||
const at = new Map<string, OrderEdge>();
|
||||
const forks: OrderFork[] = [];
|
||||
|
||||
const join = (from: string, to: string, tail: Tail): void => {
|
||||
if (from === to) return;
|
||||
@@ -110,9 +127,12 @@ export function orderGraph(program: NonNullable<WireStepsPayload['program']>, an
|
||||
// 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);
|
||||
// Reached as two different arms of one decision: it happens either way,
|
||||
// and the line stops claiming a side.
|
||||
if (found.arm !== tail.arm) delete found.arm;
|
||||
return;
|
||||
}
|
||||
const edge: OrderEdge = { from, to, when, runs: [...tail.runs] };
|
||||
const edge: OrderEdge = { from, to, when, runs: [...tail.runs], ...(tail.arm !== undefined ? { arm: tail.arm } : {}) };
|
||||
at.set(key, edge);
|
||||
edges.push(edge);
|
||||
};
|
||||
@@ -129,11 +149,28 @@ export function orderGraph(program: NonNullable<WireStepsPayload['program']>, an
|
||||
if (item.body && item.body.length > 0) inner = flow(item.body, inner, []);
|
||||
tails = inner;
|
||||
} else if (item.kind === 'fork') {
|
||||
// A decision two or more of whose arms lead somewhere diverges from a
|
||||
// POINT: the condition said once, each line out an arm — instead of
|
||||
// two lines that each carry the whole predicate, one of them negated,
|
||||
// with nothing saying they are the same choice. A fork with one drawn
|
||||
// arm keeps the plain line: an early exit reads as a guard clause.
|
||||
const drawn = item.arms.filter((a) => hasStep(a.body)).length;
|
||||
const fork: OrderFork | null =
|
||||
drawn >= 2 && tails.length > 0 ? { id: `fork:${forks.length}`, on: item.on, form: item.form } : null;
|
||||
if (fork !== null) {
|
||||
forks.push(fork);
|
||||
for (const t of tails) join(t.id, fork.id, { ...t, runs: [...t.runs, ...runs] });
|
||||
}
|
||||
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] }));
|
||||
const entry: Tail[] =
|
||||
fork !== null && hasStep(arm.body)
|
||||
? // From the point: the line says the arm; the arm's own
|
||||
// condition rides along for the hover.
|
||||
[{ id: fork.id, when: [arm.when], runs: [...runs], arm: armWord(item, arm) }]
|
||||
: 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, []);
|
||||
@@ -164,22 +201,50 @@ export function orderGraph(program: NonNullable<WireStepsPayload['program']>, an
|
||||
if (id !== anchor && !edges.some((e) => e.to === id)) join(anchor, id, { id: anchor, when: [], runs: [] });
|
||||
}
|
||||
|
||||
return { edges, depth: rows(anchor, seen, edges) };
|
||||
const ids = new Set(seen);
|
||||
for (const f of forks) ids.add(f.id);
|
||||
return { edges, depth: rows(anchor, ids, edges), forks };
|
||||
}
|
||||
|
||||
/** Whether anything in this block draws a box — a fork with one drawn arm is a guard clause, not a point. */
|
||||
function hasStep(block: WireBlock): boolean {
|
||||
return block.some((item) =>
|
||||
item.kind === 'step'
|
||||
? true
|
||||
: item.kind === 'fork'
|
||||
? item.arms.some((a) => hasStep(a.body))
|
||||
: item.kind === 'block'
|
||||
? hasStep(item.body)
|
||||
: false
|
||||
);
|
||||
}
|
||||
|
||||
/** The word on a line out of a decision's point — the same one the tree's arms say. */
|
||||
function armWord(fork: Extract<WireItem, { kind: 'fork' }>, arm: WireArm): string {
|
||||
return armWords({ on: fork.on, arm: arm.when, form: fork.form, not: arm.not });
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* it, so a step never draws above something that has to happen first.
|
||||
*
|
||||
* A step reached twice (`session.add` before and after a check, a logout
|
||||
* helper the code comes back to) makes the graph cyclic, and relaxing over a
|
||||
* cycle never settles — it adds a row on every pass until the pass bound, so
|
||||
* sixteen boxes spread over sixty rows and the picture is a mostly-empty
|
||||
* ribbon no fit can open on. So the lines that close a cycle are dropped
|
||||
* first: a line back to something already on the way here cannot be what
|
||||
* decides its row. The longest path over what remains settles by relaxation,
|
||||
* and no picture is ever taller than it has boxes.
|
||||
*/
|
||||
function rows(anchor: string, nodes: ReadonlySet<string>, edges: readonly OrderEdge[]): Map<string, number> {
|
||||
const forward = withoutBackEdges(anchor, nodes, edges);
|
||||
const depth = new Map<string, number>();
|
||||
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) {
|
||||
for (const e of forward) {
|
||||
const next = (depth.get(e.from) ?? 0) + 1;
|
||||
if (next > (depth.get(e.to) ?? 0)) {
|
||||
depth.set(e.to, next);
|
||||
@@ -191,6 +256,46 @@ function rows(anchor: string, nodes: ReadonlySet<string>, edges: readonly OrderE
|
||||
return depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* The edges minus the ones that close a cycle — those whose end is still open
|
||||
* on the way in, found by one walk from the anchor (then from anything it
|
||||
* does not reach), so the reading's own order decides which way round a cycle
|
||||
* is the forward one.
|
||||
*/
|
||||
function withoutBackEdges(anchor: string, nodes: ReadonlySet<string>, edges: readonly OrderEdge[]): OrderEdge[] {
|
||||
const out = new Map<string, OrderEdge[]>();
|
||||
for (const e of edges) {
|
||||
const list = out.get(e.from);
|
||||
if (list) list.push(e);
|
||||
else out.set(e.from, [e]);
|
||||
}
|
||||
/** 1 = open on the way in, 2 = done with. */
|
||||
const state = new Map<string, 1 | 2>();
|
||||
const back = new Set<OrderEdge>();
|
||||
for (const root of [anchor, ...nodes]) {
|
||||
if (state.has(root)) continue;
|
||||
state.set(root, 1);
|
||||
const stack: Array<{ id: string; next: number }> = [{ id: root, next: 0 }];
|
||||
while (stack.length > 0) {
|
||||
const top = stack[stack.length - 1]!;
|
||||
const list = out.get(top.id) ?? [];
|
||||
if (top.next >= list.length) {
|
||||
state.set(top.id, 2);
|
||||
stack.pop();
|
||||
continue;
|
||||
}
|
||||
const edge = list[top.next++]!;
|
||||
const seen = state.get(edge.to);
|
||||
if (seen === 1) back.add(edge);
|
||||
else if (seen === undefined) {
|
||||
state.set(edge.to, 1);
|
||||
stack.push({ id: edge.to, next: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges.filter((e) => !back.has(e));
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- build -- */
|
||||
|
||||
/** Points a curve is sampled at for hit-testing (as the other reading's). */
|
||||
@@ -207,6 +312,7 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
|
||||
const graph = orderGraph(payload.program, anchorStep.id);
|
||||
|
||||
const nodes = new Map<string, StepNodeInfo>();
|
||||
const forks = new Map<string, StepForkInfo>();
|
||||
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<string, number>();
|
||||
@@ -235,11 +341,30 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
|
||||
fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
|
||||
});
|
||||
}
|
||||
// Each decision is a point of its own on the canvas: a small box asking the
|
||||
// condition once, where the arms diverge.
|
||||
for (const f of graph.forks) {
|
||||
const label = forkLabel(f.on);
|
||||
forks.set(f.id, { id: f.id, on: f.on, form: f.form, label });
|
||||
modules.push({
|
||||
id: f.id,
|
||||
label,
|
||||
files: 0,
|
||||
symbols: degree.get(f.id) ?? 0,
|
||||
languages: [],
|
||||
test: false,
|
||||
generated: 0,
|
||||
generatedFiles: [],
|
||||
facade: false,
|
||||
fileList: { total: 0, shown: 0, truncated: false, items: [] },
|
||||
});
|
||||
}
|
||||
const drawn = (id: string): boolean => nodes.has(id) || forks.has(id);
|
||||
|
||||
const links: WireMapLink[] = [];
|
||||
const edges = new Map<string, StepEdgeInfo>();
|
||||
for (const e of graph.edges) {
|
||||
if (!nodes.has(e.from) || !nodes.has(e.to)) continue;
|
||||
if (!drawn(e.from) || !drawn(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: [] });
|
||||
@@ -257,6 +382,15 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
|
||||
});
|
||||
}
|
||||
|
||||
// A point sits where its arms are: the leftmost of them, in the code's order.
|
||||
const forkOrder = new Map<string, number>();
|
||||
for (const e of graph.edges) {
|
||||
if (!forks.has(e.from)) continue;
|
||||
const order = byId.get(e.to)?.order;
|
||||
if (order === undefined) continue;
|
||||
forkOrder.set(e.from, Math.min(forkOrder.get(e.from) ?? Number.MAX_SAFE_INTEGER, order));
|
||||
}
|
||||
|
||||
// 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<string, number> =>
|
||||
@@ -268,6 +402,8 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
|
||||
includeTests: true,
|
||||
minWeight: 0,
|
||||
sizing: (m) => {
|
||||
const fork = forks.get(m.id);
|
||||
if (fork) return { label: fork.label, meta: '' };
|
||||
const info = nodes.get(m.id);
|
||||
// Size for the ` …` a cut step wears and the anchor's ● mark, or the
|
||||
// CSS ellipsis eats the name's tail.
|
||||
@@ -276,7 +412,7 @@ export function buildOrderModel(payload: WireStepsPayload): StepsModel | null {
|
||||
return { label: mark + (info?.label ?? m.id) + cut, meta: info?.sub ?? '' };
|
||||
},
|
||||
layering,
|
||||
order: (id) => nodes.get(id)?.step.order ?? Number.MAX_SAFE_INTEGER,
|
||||
order: (id) => nodes.get(id)?.step.order ?? forkOrder.get(id) ?? Number.MAX_SAFE_INTEGER,
|
||||
layerGap: SCREEN_LAYER_GAP,
|
||||
portPitch: PORT_PITCH,
|
||||
ports: 'directional',
|
||||
@@ -285,18 +421,29 @@ 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));
|
||||
// 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 };
|
||||
// The order reading needs no regions: its rows already say when. Its
|
||||
// decisions are points between steps, not captions under a box.
|
||||
return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts, regions: null, regionEntries: null, forks, decisions: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* What a line says: the arm it is, when it leaves a decision's point — the
|
||||
* point asks, the line answers — else 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.arm) return e.arm;
|
||||
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;
|
||||
}
|
||||
|
||||
/** The point's words: the condition once, as the view says conditions, asking. */
|
||||
function forkLabel(on: string): string {
|
||||
const text = whenWords(on);
|
||||
if (!text) return '?';
|
||||
return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}…?` : `${text}?`;
|
||||
}
|
||||
|
||||
@@ -757,9 +757,18 @@ 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, atRest = false): PillLayout {
|
||||
export function placeLabels(
|
||||
model: Picture,
|
||||
selected: string | null,
|
||||
atRest: boolean | ReadonlySet<string> = false
|
||||
): PillLayout {
|
||||
const pills = new Map<string, PillPlacement>();
|
||||
if (selected === null && !atRest) return { pills, hidden: 0 };
|
||||
// `true` labels every line (the code's order, where the conditions ARE the
|
||||
// picture); a SET labels only those lines (the tree, where the arms of a
|
||||
// decision are the one thing worth saying before anything is selected).
|
||||
const restLabels = (id: string): boolean => (atRest === true ? true : atRest !== false && atRest.has(id));
|
||||
const anyAtRest = atRest === true || (atRest !== false && atRest.size > 0);
|
||||
if (selected === null && !anyAtRest) 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 };
|
||||
@@ -770,7 +779,7 @@ export function placeLabels(model: Picture, selected: string | null, atRest = fa
|
||||
// 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) => atRest || e.source === selected || e.target === selected)
|
||||
.filter((e) => restLabels(e.id) || e.source === selected || e.target === selected)
|
||||
.map((edge) => {
|
||||
const end: 'source' | 'target' = selected !== null && edge.target === selected ? 'source' : 'target';
|
||||
const far = nodes.get(end === 'source' ? edge.source : edge.target);
|
||||
|
||||
+200
-14
@@ -12,7 +12,8 @@
|
||||
* are the links into and out of the selected step.
|
||||
*/
|
||||
|
||||
import type { WireMapLink, WireMapModule, WireStep, WireStepLink, WireStepTrigger, WireStepsPayload } from './wire';
|
||||
import { whenWords } from './conditions';
|
||||
import type { WireMapLink, WireMapModule, WireStep, WireStepDecision, WireStepLink, WireStepTrigger, WireStepsPayload } from './wire';
|
||||
import {
|
||||
buildMapLayout,
|
||||
linkId,
|
||||
@@ -59,6 +60,25 @@ export interface StepEdgeInfo {
|
||||
synthesized: boolean;
|
||||
/** The kind the links agree on, or `calls` when they differ. */
|
||||
kind: WireStepLink['kind'];
|
||||
/**
|
||||
* The one way of a decision this connector is — `yes`, `no`, a case's
|
||||
* value — when it and a sibling out of the same box are arms of one fork.
|
||||
* The condition itself is said once, under the box ({@link StepDecision}).
|
||||
*/
|
||||
arm?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A decision drawn where it is made: the condition said ONCE, under the box
|
||||
* that decides it, while each line out of that box says only which way it is.
|
||||
*/
|
||||
export interface StepDecision {
|
||||
id: string;
|
||||
/** The condition as a reader says it, asking: `await hasSeenWelcome(…)?`. */
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface StepsModel extends Picture {
|
||||
@@ -78,6 +98,34 @@ export interface StepsModel extends Picture {
|
||||
regions: StepRegionZone[] | null;
|
||||
/** The first box of each region — where the anchor's at-rest line arrives. */
|
||||
regionEntries: ReadonlySet<string> | null;
|
||||
/**
|
||||
* The order reading only: its decisions, each drawn as a point of its own
|
||||
* where the arms diverge (`fork:N` in the layout). Null on the tree, whose
|
||||
* decisions are made INSIDE a box and drawn under it ({@link decisions}).
|
||||
*/
|
||||
forks: Map<string, StepForkInfo> | null;
|
||||
/**
|
||||
* The tree reading's decisions: a condition said once under the box that
|
||||
* decides it, its arms labelled on the lines out. Empty when the picture
|
||||
* holds none.
|
||||
*/
|
||||
decisions: StepDecision[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A decision on the order reading's canvas — a fork of the code with two or
|
||||
* more arms that lead somewhere. The condition is said ONCE, on the point,
|
||||
* and each line out answers it (`yes`, `no`, a case's value): two lines that
|
||||
* each carried the whole predicate, one of them negated, never said they were
|
||||
* the same choice.
|
||||
*/
|
||||
export interface StepForkInfo {
|
||||
id: string;
|
||||
/** The condition in positive words — a switch's subject; '' when the arms share none. */
|
||||
on: string;
|
||||
form: 'if' | 'switch' | 'ternary' | 'try';
|
||||
/** The point's words: the condition, asked — `user AND (await …)?`. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** One region of a screen's picture: its caption, and the space its boxes hold. */
|
||||
@@ -204,6 +252,105 @@ export function stepSub(step: WireStep, project: ProjectKind = 'app'): string {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- decisions -- */
|
||||
|
||||
/** A case value longer than this is cut on the line; the whole condition is a hover away. */
|
||||
const ARM_WORD_MAX = 24;
|
||||
/** Room for one line of a decision's caption under its box. */
|
||||
const DECISION_LINE = 15;
|
||||
/** Advance of the caption's 10.5px mono, and the room it may take past its box. */
|
||||
const DECISION_CHAR = 6.3;
|
||||
const DECISION_MAX_WIDTH = 320;
|
||||
|
||||
/**
|
||||
* The word a line out of a decision says — the ONE place that decides it, so
|
||||
* the two readings can never word an arm differently. `yes` / `no` for an
|
||||
* `if` or a ternary; a case's own value for a switch, with the subject the
|
||||
* decision already asks stripped off (`status === 'expired'` → `'expired'`),
|
||||
* and `else` for its default; a `try`'s arms keep their own words.
|
||||
*/
|
||||
export function armWords(d: { on: string; arm: string; form: 'if' | 'switch' | 'ternary' | 'try'; not?: true }): string {
|
||||
if (d.form === 'if' || d.form === 'ternary') return d.not ? 'no' : 'yes';
|
||||
if (d.not) return 'else';
|
||||
let text = d.arm;
|
||||
if (d.on && text.startsWith(d.on)) text = text.slice(d.on.length).trim().replace(/^===?\s*/, '');
|
||||
if (!text) return 'yes';
|
||||
return text.length > ARM_WORD_MAX ? `${text.slice(0, ARM_WORD_MAX - 1)}…` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one arm of one decision a connector is, when EVERY site behind it
|
||||
* agrees. A connector with a site that runs under no condition is not
|
||||
* exclusively an arm — the step happens either way — and one whose sites
|
||||
* disagree is several stories; both stay plain lines rather than claim a side.
|
||||
*/
|
||||
function edgeArm(info: StepEdgeInfo): WireStepDecision | null {
|
||||
let found: WireStepDecision | null = null;
|
||||
for (const link of info.links) {
|
||||
for (const site of link.sites) {
|
||||
if (!site.decision) return null;
|
||||
if (found === null) found = site.decision;
|
||||
else if (found.branch !== site.decision.branch || found.arm !== site.decision.arm) return null;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sibling connectors out of one box that are arms of ONE fork, marked as the
|
||||
* choice they are: each line says only which way it is, and the condition is
|
||||
* said once under the box that decides it. Two lines that each carried the
|
||||
* whole predicate — one of them the other's negation, both truncated to the
|
||||
* same forty characters — never said they were the same choice, and at rest
|
||||
* the tree drew them with no label at all.
|
||||
*
|
||||
* A fork with ONE drawn arm is a guard clause, not a choice, and keeps its
|
||||
* condition on the line: the decision has to have at least two ways drawn
|
||||
* before it is worth a caption.
|
||||
*/
|
||||
function markDecisions(edges: Map<string, StepEdgeInfo>, layout: MapLayout): StepDecision[] {
|
||||
const groups = new Map<string, Array<{ info: StepEdgeInfo; decision: WireStepDecision }>>();
|
||||
for (const info of edges.values()) {
|
||||
const decision = edgeArm(info);
|
||||
if (decision === null) continue;
|
||||
const key = `${info.from} | ||||