mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-21 16:57:09 +08:00
3ed73bc127
* fix(ui): a screen's steps read as clusters, and a link too far to follow is said in words The mobile app's /capture came back as a web: 100 boxes in a 1,227x5,588 ribbon, 113 lines drawn at rest crossing each other 652 times, each one running over about five other boxes' names. Measured, not guessed — three separate causes, very unequal. The region grouping had nothing to divide there (98 of 100 boxes take their region from one memoized component), so the picture fell back to a single 719px column. But the region dimension was not the lever. The lever was that `packRegions` packed every step of one distance onto shared rows and wrapped those rows at a fixed 720px, so a box and the thing it fires landed seven lines apart: 70 of the 113 lines joined boxes ONE step apart. That is what the crossings were made of. So a region is now packed as CLUSTERS — a step, then the steps it sets in motion on the line under it, stepped in — while the starting points that fire nothing still share a line, because a screen's handlers are siblings and giving each its own line turned a flat region into a column. A region's line width is earned rather than fixed (sqrt(total * pitch), clamped 720..2600), so a big screen comes out about as wide as it is tall. Clustering makes most links local but not all: a step reached from two places is drawn under whichever reached it first, so the other way in still crosses the picture. Those are now said in WORDS at both ends — `-> resumeInference` under the box that leads there, `<- CaptureView` under the box it arrives at, capped at three with `+N more` — rather than drawn. This is not a hiding: the link is stated, which says more than a line vanishing off the edge of the screen does, and selecting the box draws every one of its real lines exactly as before. It is the one at-rest cut that does not produce the "box that leads somewhere and draws nothing" every earlier cut produced. Also fixed while here, and predicted by the earlier region work: the in-region row relaxation had no cycle guard, so a region holding one loop pushed 65 of its boxes to rows 294-301 while the rest sat at 0-2. `forwardLinks` sets cycle-closing links aside first, as the order reading's `withoutBackEdges` already did. /capture: 1,227x5,588 -> 2,279x4,356, at-rest crossings 652 -> 1, lines-over-boxes 553 -> 26, with half the links still drawn as real lines and every quiet box still one the screen itself fires directly. The order reading is untouched (it keeps every line). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * fix(ui): a stub names its box without the mark the box wears for its kind `← ⇠ onCaptureProgress +2` reads as two arrows arguing: the stub already leads with a direction, and the box's own kind mark was competing with it. Verified in the live canvas. The box keeps its mark, where nothing competes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * fix(ui): the screen's line into each of its parts stops sweeping the picture Audited all 51 screens of the mobile app on this branch. 96 lines — the screen's own stand-in line into each region — were 17% of everything drawn and caused 79% of every crossing left. A screen with ten regions tiles them into bands, so the line into a region two bands down travelled the height of the whole picture. Two causes, both fixed. The entry the line lands on was the walk's first member of the region; clustering moves a step that fires something BELOW the ones that fire nothing, so that box could sit lines down inside the region and the line had to reach past everything above it. It now lands on the box nearest the region's top-left that the screen actually leads to. And the stand-in line is no longer exempt from the stub rule — when the region is still too far to follow, the link is said in words like any other. The rest of the anchor's fan stays quiet as before: it is already stood in for. Across the 51 screens: crossings 47 -> 10, no screen above 10 (worst was 19, now 2); lines-over-boxes 236 -> 182; boxes with neither a line nor a word 150 -> 135. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * fix(ui): a screen's parts fill the canvas instead of squaring off into rows Audited the app's 28 regioned screens: the median canvas was 55% region and 45% nothing, and /home was 44% — 4,860px tall to hold about 2,160px of picture. The cause is that regions were tiled a row at a time with each row as tall as its tallest member, so one short region beside a tall one left the rest of that row blank, and a reader scrolls through the blank. Each region now goes as high as it can and then as far left as it can, over a skyline of what is already placed. Reading order is untouched: regions are still walked in the screen's own source order, so an earlier one is never pushed below a later one — a short one just tucks under another short one rather than waiting for the tall one beside it. Layering now comes from the finished geometry rather than a band counter, since once regions drop independently what a reader sees as one row IS one row. /home 4,860px -> 3,584px, aspect 0.59 -> 0.94. Tallest screen in the app 4,860 -> 4,356. Crossings 10 -> 13 across all 51 screens, still none above 10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * fix(ui): the width a picture wraps at is tried, not estimated A region's line width came from sqrt(total * pitch) — the width at which total/width lines come out square. That estimate is wrong for how these pictures are drawn: a cluster spends lines on its own structure (a hub gets a line to itself, and what it fires starts another), so it undercounts a region's lines badly and wrapped /capture's 98 boxes into a 4,356px column. Laying a picture out is cheap and exact, so the widths are tried instead: layoutAt runs the whole pack at each of eight widths and the best finished canvas wins (~2ms for the model, all eight included). It has to be scored on the CANVAS, not per region — squaring each region off individually leaves fewer of them side by side, which took /home from 3,584px to 5,624px while every region looked better on its own. Also measured and rejected while here: dropping a region's CLUSTERS side by side the way the regions drop onto the canvas. Total height 42,084 -> 39,756px (-6%), but lines-over-boxes 120 -> 134 and crossings 5 -> 8, because two clusters side by side put each one's lines through the other. Height is cheap to scroll; a crossed line is what made this picture unreadable. The reasoning is recorded in the code so it is not re-tried blindly. Regions differ — they sit far enough apart that few lines run between them. Across the app's 51 screens: tallest picture 4,356 -> 3,796px, total height 45,744 -> 42,084px, lines-over-boxes 184 -> 120, crossings 13 -> 5, and no screen is a tall ribbon any more. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTUFNN5bH3aPw2gi2LqbYD * stuff --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
468 lines
19 KiB
TypeScript
468 lines
19 KiB
TypeScript
/**
|
|
* 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 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:
|
|
*
|
|
* ```
|
|
* 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, 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 { 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 -- */
|
|
|
|
/** The construct a fork came from. */
|
|
export type ForkForm = Extract<WireItem, { kind: 'fork' }>['form'];
|
|
|
|
/**
|
|
* 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 whenTokens(when: string): WordToken[] {
|
|
return conditionTokens(when);
|
|
}
|
|
|
|
/** The words on a run that is not plain sequence — said on the line into it. */
|
|
export function runWords(item: Extract<WireItem, { kind: 'block' }>): string {
|
|
switch (item.block) {
|
|
case 'inline':
|
|
return item.via ? `via ${item.via.name}` : 'via a helper';
|
|
case 'loop':
|
|
if (!item.by) return item.loop === 'while' ? 'again and again' : 'for each item';
|
|
return item.loop === 'while' ? `again while ${item.by}` : `for each ${item.by}`;
|
|
case 'later':
|
|
return item.by ? `later · ${item.by}` : 'later';
|
|
default:
|
|
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[];
|
|
/** 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. */
|
|
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[];
|
|
}
|
|
|
|
/**
|
|
* 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<WireStepsPayload['program']>, anchor: string): OrderGraph {
|
|
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;
|
|
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);
|
|
// 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], ...(tail.arm !== undefined ? { arm: tail.arm } : {}) };
|
|
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') {
|
|
// 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: 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, []);
|
|
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: [] });
|
|
}
|
|
|
|
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.
|
|
*
|
|
* 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 forward) {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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). */
|
|
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<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>();
|
|
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] },
|
|
dependents: { files: 0, modules: 0 },
|
|
});
|
|
}
|
|
// 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: [] },
|
|
dependents: { files: 0, modules: 0 },
|
|
});
|
|
}
|
|
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 (!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: [] });
|
|
// 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',
|
|
});
|
|
}
|
|
|
|
// 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> =>
|
|
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 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.
|
|
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 ?? forkOrder.get(id) ?? Number.MAX_SAFE_INTEGER,
|
|
layerGap: SCREEN_LAYER_GAP,
|
|
portPitch: PORT_PITCH,
|
|
ports: 'directional',
|
|
}
|
|
);
|
|
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. Its
|
|
// decisions are points between steps, not captions under a box. It keeps
|
|
// every line: a reading of when things happen is the lines, and its rows are
|
|
// short enough that they stay local — `stubbed` empty leaves the filter be.
|
|
return {
|
|
layout,
|
|
nodes,
|
|
edges,
|
|
layerGap: SCREEN_LAYER_GAP,
|
|
curves,
|
|
polylines,
|
|
counts,
|
|
regions: null,
|
|
regionEntries: null,
|
|
forks,
|
|
decisions: [],
|
|
stubs: new Map(),
|
|
stubbed: new Set(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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}?`;
|
|
}
|