steps-model.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881
  1. /**
  2. * The Steps view's model — what happens from an anchor, as typed steps laid
  3. * out so that a step sits above the steps it sets in motion.
  4. *
  5. * Everything geometric is the Screens view's (`screens-model.ts`): the Map's
  6. * layout with directional ports, a curve per edge on a track of its own, the
  7. * pills that label a selected step's links at the far end of each line, and
  8. * the nearest-line pointer. What is this file's own is small: the row a step
  9. * sits on is its distance from the anchor, which the server already counted
  10. * (`WireStep.depth`), so the layering is a lookup rather than a search; the
  11. * words in a box come from the step's kind; and the side panel's two lists
  12. * are the links into and out of the selected step.
  13. */
  14. import { whenWords } from './conditions';
  15. import type { WireMapLink, WireMapModule, WireStep, WireStepDecision, WireStepLink, WireStepTrigger, WireStepsPayload } from './wire';
  16. import {
  17. buildMapLayout,
  18. linkId,
  19. nodeWidth,
  20. strokeWidthFor,
  21. NODE_GAP,
  22. NODE_HEIGHT,
  23. PADDING,
  24. PORT_PITCH,
  25. type EdgeRoute,
  26. type MapEdgeLayout,
  27. type MapLayout,
  28. type MapNodeLayout,
  29. type PortRef,
  30. } from './map-model';
  31. import {
  32. edgeLabel,
  33. samplePolyline,
  34. trackedCurves,
  35. SCREEN_LAYER_GAP,
  36. type Curve,
  37. type Picture,
  38. type Point,
  39. } from './screens-model';
  40. export interface StepNodeInfo {
  41. id: string;
  42. step: WireStep;
  43. /** What the box prints on its first line. */
  44. label: string;
  45. /** …and on its second. */
  46. sub: string;
  47. }
  48. export interface StepEdgeInfo {
  49. id: string;
  50. from: string;
  51. to: string;
  52. /** Every link between the pair — one connector, several stories. */
  53. links: WireStepLink[];
  54. /** The connector's short label: the innermost condition, or how many links. */
  55. label: string;
  56. /** Every link behind it was synthesized (a dynamic-dispatch bridge). */
  57. synthesized: boolean;
  58. /** The kind the links agree on, or `calls` when they differ. */
  59. kind: WireStepLink['kind'];
  60. /**
  61. * The one way of a decision this connector is — `yes`, `no`, a case's
  62. * value — when it and a sibling out of the same box are arms of one fork.
  63. * The condition itself is said once, under the box ({@link StepDecision}).
  64. */
  65. arm?: string;
  66. }
  67. /**
  68. * A decision drawn where it is made: the condition said ONCE, under the box
  69. * that decides it, while each line out of that box says only which way it is.
  70. */
  71. export interface StepDecision {
  72. id: string;
  73. /** The condition as a reader says it, asking: `await hasSeenWelcome(…)?`. */
  74. label: string;
  75. x: number;
  76. y: number;
  77. width: number;
  78. }
  79. export interface StepsModel extends Picture {
  80. layout: MapLayout;
  81. nodes: Map<string, StepNodeInfo>;
  82. edges: Map<string, StepEdgeInfo>;
  83. layerGap: number;
  84. curves: Map<string, Curve>;
  85. polylines: Map<string, Point[]>;
  86. /** Steps per kind, for the panel's summary. */
  87. counts: Record<WireStep['kind'], number>;
  88. /**
  89. * A screen's picture only: the regions its boxes are laid out by, for the
  90. * captions. Null when the steps carry no regions — an endpoint's or a
  91. * function's picture, and the order reading — and the rows are distance.
  92. */
  93. regions: StepRegionZone[] | null;
  94. /** The first box of each region — where the anchor's at-rest line arrives. */
  95. regionEntries: ReadonlySet<string> | null;
  96. /**
  97. * The order reading only: its decisions, each drawn as a point of its own
  98. * where the arms diverge (`fork:N` in the layout). Null on the tree, whose
  99. * decisions are made INSIDE a box and drawn under it ({@link decisions}).
  100. */
  101. forks: Map<string, StepForkInfo> | null;
  102. /**
  103. * The tree reading's decisions: a condition said once under the box that
  104. * decides it, its arms labelled on the lines out. Empty when the picture
  105. * holds none.
  106. */
  107. decisions: StepDecision[];
  108. }
  109. /**
  110. * A decision on the order reading's canvas — a fork of the code with two or
  111. * more arms that lead somewhere. The condition is said ONCE, on the point,
  112. * and each line out answers it (`yes`, `no`, a case's value): two lines that
  113. * each carried the whole predicate, one of them negated, never said they were
  114. * the same choice.
  115. */
  116. export interface StepForkInfo {
  117. id: string;
  118. /** The condition in positive words — a switch's subject; '' when the arms share none. */
  119. on: string;
  120. form: 'if' | 'switch' | 'ternary' | 'try';
  121. /** The point's words: the condition, asked — `user AND (await …)?`. */
  122. label: string;
  123. }
  124. /** One region of a screen's picture: its caption, and the space its boxes hold. */
  125. export interface StepRegionZone {
  126. id: string;
  127. label: string;
  128. x: number;
  129. y: number;
  130. width: number;
  131. height: number;
  132. /** The region's first box, in the walk's order. */
  133. entry: string;
  134. }
  135. /** Points a curve is sampled at for hit-testing (as the Screens view's). */
  136. const HIT_SAMPLES = 24;
  137. /* ---------------------------------------------------------------- words -- */
  138. /** What the index is a picture of; the server decides it from the routes (`WireStepsPayload.project`). */
  139. export type ProjectKind = WireStepsPayload['project'];
  140. /**
  141. * A short word for a step's kind, as the panel and the legend say it — in the
  142. * project's own vocabulary. The same box is a screen in an app, a page in a
  143. * web app and an endpoint in an API; a route that leads with an HTTP verb is
  144. * an endpoint wherever it is. One place decides, so the legend, the panel
  145. * and the tooltip never disagree.
  146. */
  147. export function kindWord(kind: WireStep['kind'], project: ProjectKind = 'app', step?: WireStep): string {
  148. return kindWords(kind, project, step)[0];
  149. }
  150. /** The singular and the plural, for counts: `1 endpoint`, `3 outside the index`. */
  151. export function kindWords(kind: WireStep['kind'], project: ProjectKind = 'app', step?: WireStep): [string, string] {
  152. switch (kind) {
  153. case 'screen':
  154. if (step?.screen?.endpoint) return ['endpoint', 'endpoints'];
  155. return project === 'api' ? ['endpoint', 'endpoints'] : project === 'web' ? ['page', 'pages'] : ['screen', 'screens'];
  156. case 'trigger':
  157. return ['handler', 'handlers'];
  158. case 'bridge':
  159. // An endpoint reached across a tier is a call to the server wherever it is.
  160. if (step?.screen?.endpoint) return ['call to the server', 'calls to the server'];
  161. return project === 'app' ? ['native call', 'native calls'] : project === 'web' ? ['call to the server', 'calls to the server'] : ['call to another tier', 'calls to another tier'];
  162. case 'event':
  163. return project === 'app' ? ['native event', 'native events'] : project === 'web' ? ['arrives from the server', 'arrive from the server'] : ['arrives from a queue or bus', 'arrive from a queue or bus'];
  164. case 'store':
  165. return project === 'api' ? ['data call', 'data calls'] : ['store action', 'store actions'];
  166. case 'effect':
  167. return ['outside the index', 'outside the index'];
  168. default:
  169. return ['start', 'start'];
  170. }
  171. }
  172. /** `3 handlers`, `1 endpoint`, `11 outside the index`. */
  173. export function countWords(n: number, kind: WireStep['kind'], project: ProjectKind = 'app'): string {
  174. const [one, many] = kindWords(kind, project);
  175. return `${n} ${n === 1 ? one : many}`;
  176. }
  177. /**
  178. * What fires something, in a few characters: `onPress · <Button>`,
  179. * `onSubmit · useFormik(…)`, `addListener('onZipComplete')`, `useEffect`;
  180. * for a server, `POST /users · after authenticate, validate(…)`,
  181. * `@Process('email')`, `page load · /blog/[slug]`.
  182. */
  183. export function triggerWords(t: WireStepTrigger): string {
  184. const after = t.after && t.after.length > 0 ? ` · after ${t.after.join(', ')}` : '';
  185. switch (t.kind) {
  186. case 'prop':
  187. return t.of ? `${t.name} · <${t.of}>` : t.name;
  188. case 'option':
  189. return t.of ? `${t.name} · ${t.of}(…)` : t.name;
  190. case 'request':
  191. return `${t.name} ${t.of ?? ''}`.trim() + after;
  192. case 'decorator':
  193. return `@${t.name}(${t.of ?? ''})` + after;
  194. case 'load':
  195. return `page load · ${t.of ?? t.name}` + after;
  196. default:
  197. return t.of ? `${t.name}(${t.of})` : t.name;
  198. }
  199. }
  200. /** The first line of a step's box. Boundary crossings carry an arrow for which way the code goes. */
  201. export function stepLabel(step: WireStep): string {
  202. switch (step.kind) {
  203. case 'bridge':
  204. return `⇢ ${step.label}`;
  205. case 'event': {
  206. const events = step.events ?? (step.event ? [step.event] : []);
  207. if (events.length === 0) return `⇠ ${step.label}`;
  208. return events.length === 1 ? `⇠ ${events[0]}` : `⇠ ${events[0]} +${events.length - 1}`;
  209. }
  210. default:
  211. return step.label;
  212. }
  213. }
  214. /** The second line: what the step is, then where it is. */
  215. export function stepSub(step: WireStep, project: ProjectKind = 'app'): string {
  216. const file = step.node ? step.node.file.slice(step.node.file.lastIndexOf('/') + 1) : '';
  217. switch (step.kind) {
  218. case 'screen':
  219. return step.sub;
  220. case 'trigger':
  221. // The event before the file: `onPress · <Button> · index.tsx`.
  222. return step.trigger ? `${triggerWords(step.trigger)} · ${file}` : `handler · ${file}`;
  223. case 'bridge':
  224. // An endpoint the code crosses to says its handler, as an endpoint box does.
  225. if (step.screen) return step.sub;
  226. return `${project === 'app' ? 'native' : project === 'web' ? 'server' : 'another tier'} · ${file}`;
  227. case 'event':
  228. return `${step.label} · ${file}`;
  229. case 'store':
  230. return `${project === 'api' ? 'data' : 'store'} · ${file}`;
  231. case 'effect':
  232. return step.sub;
  233. default:
  234. // The anchor: its file, at the size of a box; the panel prints the whole path.
  235. return step.node && step.sub === step.node.file ? file : step.sub;
  236. }
  237. }
  238. /* ------------------------------------------------------------- decisions -- */
  239. /** A case value longer than this is cut on the line; the whole condition is a hover away. */
  240. const ARM_WORD_MAX = 24;
  241. /** Room for one line of a decision's caption under its box. */
  242. const DECISION_LINE = 15;
  243. /** Advance of the caption's 10.5px mono, and the room it may take past its box. */
  244. const DECISION_CHAR = 6.3;
  245. const DECISION_MAX_WIDTH = 320;
  246. /**
  247. * The word a line out of a decision says — the ONE place that decides it, so
  248. * the two readings can never word an arm differently. `yes` / `no` for an
  249. * `if` or a ternary; a case's own value for a switch, with the subject the
  250. * decision already asks stripped off (`status === 'expired'` → `'expired'`),
  251. * and `else` for its default; a `try`'s arms keep their own words.
  252. */
  253. export function armWords(d: { on: string; arm: string; form: 'if' | 'switch' | 'ternary' | 'try'; not?: true }): string {
  254. if (d.form === 'if' || d.form === 'ternary') return d.not ? 'no' : 'yes';
  255. if (d.not) return 'else';
  256. let text = d.arm;
  257. if (d.on && text.startsWith(d.on)) text = text.slice(d.on.length).trim().replace(/^===?\s*/, '');
  258. if (!text) return 'yes';
  259. return text.length > ARM_WORD_MAX ? `${text.slice(0, ARM_WORD_MAX - 1)}…` : text;
  260. }
  261. /**
  262. * The one arm of one decision a connector is, when EVERY site behind it
  263. * agrees. A connector with a site that runs under no condition is not
  264. * exclusively an arm — the step happens either way — and one whose sites
  265. * disagree is several stories; both stay plain lines rather than claim a side.
  266. */
  267. function edgeArm(info: StepEdgeInfo): WireStepDecision | null {
  268. let found: WireStepDecision | null = null;
  269. for (const link of info.links) {
  270. for (const site of link.sites) {
  271. if (!site.decision) return null;
  272. if (found === null) found = site.decision;
  273. else if (found.branch !== site.decision.branch || found.arm !== site.decision.arm) return null;
  274. }
  275. }
  276. return found;
  277. }
  278. /**
  279. * Sibling connectors out of one box that are arms of ONE fork, marked as the
  280. * choice they are: each line says only which way it is, and the condition is
  281. * said once under the box that decides it. Two lines that each carried the
  282. * whole predicate — one of them the other's negation, both truncated to the
  283. * same forty characters — never said they were the same choice, and at rest
  284. * the tree drew them with no label at all.
  285. *
  286. * A fork with ONE drawn arm is a guard clause, not a choice, and keeps its
  287. * condition on the line: the decision has to have at least two ways drawn
  288. * before it is worth a caption.
  289. */
  290. function markDecisions(edges: Map<string, StepEdgeInfo>, layout: MapLayout): StepDecision[] {
  291. const groups = new Map<string, Array<{ info: StepEdgeInfo; decision: WireStepDecision }>>();
  292. for (const info of edges.values()) {
  293. const decision = edgeArm(info);
  294. if (decision === null) continue;
  295. const key = `${info.from}�${decision.branch}`;
  296. const list = groups.get(key) ?? [];
  297. list.push({ info, decision });
  298. groups.set(key, list);
  299. }
  300. const boxes = new Map(layout.nodes.map((n) => [n.id, n]));
  301. const out: StepDecision[] = [];
  302. /** Two decisions made in one box stack under it rather than sitting on each other. */
  303. const perBox = new Map<string, number>();
  304. for (const [key, group] of groups) {
  305. if (new Set(group.map((g) => g.decision.arm)).size < 2) continue;
  306. const box = boxes.get(group[0]!.info.from);
  307. if (!box) continue;
  308. for (const { info, decision } of group) {
  309. info.arm = armWords(decision);
  310. // The connector's label IS the arm now: the decision says the rest.
  311. info.label = info.arm;
  312. }
  313. const nth = perBox.get(box.id) ?? 0;
  314. perBox.set(box.id, nth + 1);
  315. const on = group[0]!.decision.on;
  316. const label = `${whenWords(on) || on}?`;
  317. // The condition is the whole point of the caption, so it may take a
  318. // little more room than the box it sits under — centred on it, and capped
  319. // so a long predicate cannot reach across its neighbours.
  320. const width = Math.max(box.width, Math.min(label.length * DECISION_CHAR + 8, DECISION_MAX_WIDTH));
  321. out.push({
  322. id: key,
  323. label,
  324. x: box.x + (box.width - width) / 2,
  325. y: box.y + box.height + 4 + nth * DECISION_LINE,
  326. width,
  327. });
  328. }
  329. return out;
  330. }
  331. /* ---------------------------------------------------------------- build -- */
  332. export function buildStepsModel(payload: WireStepsPayload): StepsModel {
  333. const nodes = new Map<string, StepNodeInfo>();
  334. const modules: WireMapModule[] = [];
  335. const counts: Record<WireStep['kind'], number> = {
  336. anchor: 0,
  337. screen: 0,
  338. trigger: 0,
  339. bridge: 0,
  340. event: 0,
  341. store: 0,
  342. effect: 0,
  343. };
  344. const degree = new Map<string, number>();
  345. for (const link of payload.links) {
  346. degree.set(link.from, (degree.get(link.from) ?? 0) + 1);
  347. degree.set(link.to, (degree.get(link.to) ?? 0) + 1);
  348. }
  349. for (const step of payload.steps) {
  350. counts[step.kind]++;
  351. const info: StepNodeInfo = { id: step.id, step, label: stepLabel(step), sub: stepSub(step, payload.project) };
  352. nodes.set(step.id, info);
  353. modules.push({
  354. id: step.id,
  355. label: info.label,
  356. files: 1,
  357. symbols: degree.get(step.id) ?? 0,
  358. languages: [],
  359. test: false,
  360. generated: 0,
  361. generatedFiles: [],
  362. facade: false,
  363. fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
  364. });
  365. }
  366. // One layout link per (from, to); the links behind it stay listed.
  367. const byPair = new Map<string, WireStepLink[]>();
  368. for (const link of payload.links) {
  369. if (!nodes.has(link.from) || !nodes.has(link.to) || link.from === link.to) continue;
  370. const key = linkId({ source: link.from, target: link.to });
  371. const list = byPair.get(key) ?? [];
  372. list.push(link);
  373. byPair.set(key, list);
  374. }
  375. const links: WireMapLink[] = [];
  376. const edges = new Map<string, StepEdgeInfo>();
  377. for (const [key, group] of byPair) {
  378. const first = group[0]!;
  379. links.push({
  380. source: first.from,
  381. target: first.to,
  382. count: group.length,
  383. declared: group.length,
  384. byKind: [{ kind: 'calls', count: group.length }],
  385. topPairs: [],
  386. });
  387. // A link into a handler says the EVENT — `onPress · <Button>` — not the
  388. // conditions; those are one hover away, and the event is what a reader
  389. // asking "at what point does this run" came for.
  390. const trigger = group.length === 1 && first.kind === 'handler' && first.trigger ? first.trigger : null;
  391. edges.set(key, {
  392. id: key,
  393. from: first.from,
  394. to: first.to,
  395. links: group,
  396. label: trigger ? triggerWords(trigger) : edgeLabel(group),
  397. synthesized: group.every((l) => l.synthesized),
  398. kind: group.every((l) => l.kind === first.kind) ? first.kind : 'calls',
  399. });
  400. }
  401. // A screen's picture is laid out by its REGIONS when the server named them
  402. // (`WireStep.region`): a screen is a set of handlers with no order between
  403. // them, so distance alone put ninety boxes on one enormous row. An
  404. // endpoint's or a function's picture keeps the rows: there, distance IS the
  405. // reading.
  406. const regioned = payload.steps.some((s) => s.region !== undefined);
  407. let layout: MapLayout;
  408. let zones: StepRegionZone[] | null = null;
  409. if (regioned) {
  410. const packed = packRegions(payload.steps, nodes, modules, links);
  411. layout = packed.layout;
  412. zones = packed.zones;
  413. } else {
  414. // Layer = distance from the anchor, counted by the server. Layer 0 is the
  415. // bottom, so the deepest row is 0 and the anchor is on top.
  416. const depthOf = new Map(payload.steps.map((s) => [s.id, s.depth]));
  417. const deepest = Math.max(0, ...payload.steps.map((s) => s.depth));
  418. const layering = (ids: string[]): Map<string, number> =>
  419. new Map(ids.map((id) => [id, deepest - (depthOf.get(id) ?? deepest)]));
  420. layout = buildMapLayout(
  421. { modules, links },
  422. {
  423. includeTests: true,
  424. minWeight: 0,
  425. sizing: (m) => {
  426. const info = nodes.get(m.id);
  427. // Size for the ` …` a cut step wears and the anchor's ● mark, as `packRegions` does.
  428. const cut = info?.step.cut != null ? ' …' : '';
  429. const mark = info?.step.anchor ? '● ' : '';
  430. return { label: mark + (info?.label ?? m.id) + cut, meta: info?.sub ?? '' };
  431. },
  432. layering,
  433. // The server ordered each row the way the code reads; keep it.
  434. order: (id) => nodes.get(id)?.step.order ?? Number.MAX_SAFE_INTEGER,
  435. layerGap: SCREEN_LAYER_GAP,
  436. portPitch: PORT_PITCH,
  437. ports: 'directional',
  438. }
  439. );
  440. }
  441. const layerGap = regioned ? REGION_GAP_Y : SCREEN_LAYER_GAP;
  442. const curves = trackedCurves(layout, layerGap);
  443. const polylines = new Map<string, Point[]>();
  444. for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
  445. return {
  446. layout,
  447. nodes,
  448. edges,
  449. layerGap,
  450. curves,
  451. polylines,
  452. counts,
  453. regions: zones,
  454. regionEntries: zones === null ? null : new Set(zones.map((z) => z.entry)),
  455. forks: null,
  456. // Placed against the finished layout: a decision is drawn under the box
  457. // that makes it, so it needs to know where that box ended up.
  458. decisions: markDecisions(edges, layout),
  459. };
  460. }
  461. /* --------------------------------------------------------------- regions -- */
  462. /**
  463. * The gap under a line of boxes within a region — tighter than the row gap of
  464. * an unregioned picture, whose gaps carry every line of a whole row's fan-out;
  465. * here a gap holds a few local hops, and a screen's picture is tall enough
  466. * already. The tracked curves take the same number, so a level arch stays
  467. * inside it.
  468. */
  469. const REGION_GAP_Y = 72;
  470. /** The vertical rhythm of a regioned picture: one line of boxes and the gap under it. */
  471. const REGION_PITCH = NODE_HEIGHT + REGION_GAP_Y;
  472. /** A region's line of boxes wraps past this natural width. */
  473. const REGION_LINE_MAX = 720;
  474. /** Between two regions side by side. */
  475. const REGION_GUTTER = 72;
  476. /** Extra room between two rows of regions — the captions of the next row live in it. */
  477. const BAND_GAP = 84;
  478. /** Bands may run this wide: enough for the widest region, aiming at a readable aspect. */
  479. function bandBudget(area: number, widest: number): number {
  480. return Math.max(widest, Math.min(3400, Math.max(1440, Math.ceil(Math.sqrt(area * 2.4)))));
  481. }
  482. /**
  483. * The layout of a screen's picture: each region a small column of lines —
  484. * a box above what it sets in motion, a line wrapping when it grows past
  485. * {@link REGION_LINE_MAX} — and the regions tiled left to right, wrapping
  486. * into bands, in the order the walk met them: the screen's own source order.
  487. * The anchor sits alone on top. Everything downstream — the tracked curves,
  488. * the pills, the pointer — is the same machinery over the same shapes.
  489. */
  490. function packRegions(
  491. steps: WireStep[],
  492. infos: Map<string, StepNodeInfo>,
  493. modules: WireMapModule[],
  494. links: WireMapLink[]
  495. ): { layout: MapLayout; zones: StepRegionZone[] } {
  496. const anchor = steps.find((s) => s.anchor)!;
  497. const members = steps.filter((s) => !s.anchor);
  498. const moduleOf = new Map(modules.map((m) => [m.id, m]));
  499. // A box is wide enough for its words and for its ports — the anchor touches
  500. // most of the picture, and its lines need somewhere to leave from.
  501. const degree = new Map<string, number>();
  502. for (const l of links) {
  503. degree.set(l.source, (degree.get(l.source) ?? 0) + 1);
  504. degree.set(l.target, (degree.get(l.target) ?? 0) + 1);
  505. }
  506. const widthOf = (id: string): number => {
  507. const info = infos.get(id);
  508. // A cut step wears ` …` after its name and the anchor its ● mark before
  509. // it; size for both, or the CSS ellipsis eats the name's tail instead
  510. // (`/scan-to-verif…` for `/scan-to-verify …`).
  511. const cut = info?.step.cut != null ? ' …' : '';
  512. const mark = info?.step.anchor ? '● ' : '';
  513. return Math.max(
  514. nodeWidth(mark + (info?.label ?? id) + cut, info?.sub ?? ''),
  515. ((degree.get(id) ?? 0) + 1) * PORT_PITCH
  516. );
  517. };
  518. // Regions in the order the walk met them — the screen's own source order.
  519. interface Region {
  520. id: string;
  521. label: string;
  522. members: WireStep[];
  523. }
  524. const regions = new Map<string, Region>();
  525. for (const s of members) {
  526. const id = s.region?.id ?? anchor.id;
  527. const region = regions.get(id) ?? { id, label: s.region?.label ?? anchor.label, members: [] };
  528. region.members.push(s);
  529. regions.set(id, region);
  530. }
  531. // Within a region, a step goes under the steps that lead to it.
  532. const regionOf = new Map(members.map((s) => [s.id, s.region?.id ?? anchor.id]));
  533. const parentsOf = new Map<string, string[]>();
  534. for (const l of links) {
  535. if (l.source === anchor.id || l.target === anchor.id) continue;
  536. if (regionOf.get(l.source) !== regionOf.get(l.target)) continue;
  537. const list = parentsOf.get(l.target) ?? [];
  538. list.push(l.source);
  539. parentsOf.set(l.target, list);
  540. }
  541. interface Packed {
  542. lines: string[][];
  543. width: number;
  544. }
  545. const packed = new Map<string, Packed>();
  546. for (const region of regions.values()) {
  547. // A step goes under the steps that lead to it — rows from the region's OWN
  548. // links, never from distance to the anchor, which is flat inside a region:
  549. // a handler and the store it calls are both one hop from the screen, and
  550. // side by side their line was a level arch, hidden at rest, so the store
  551. // looked wired to nothing. Longest lead-to path, settled by relaxation as
  552. // the order reading settles its rows; a cycle stops moving at the bound.
  553. const rowOf = new Map<string, number>(region.members.map((m) => [m.id, 0]));
  554. for (let pass = 0; pass < region.members.length; pass++) {
  555. let moved = false;
  556. for (const m of region.members) {
  557. const above = (parentsOf.get(m.id) ?? [])
  558. .map((p) => rowOf.get(p))
  559. .filter((x): x is number => x !== undefined);
  560. if (above.length === 0) continue;
  561. const next = Math.max(...above) + 1;
  562. if (next > rowOf.get(m.id)!) {
  563. rowOf.set(m.id, next);
  564. moved = true;
  565. }
  566. }
  567. if (!moved) break;
  568. }
  569. const rows = new Map<number, WireStep[]>();
  570. for (const m of region.members) {
  571. const d = rowOf.get(m.id)!;
  572. rows.set(d, [...(rows.get(d) ?? []), m]);
  573. }
  574. const lines: string[][] = [];
  575. // The order a step was placed in, for putting its children near it.
  576. const placedAt = new Map<string, number>();
  577. let width = 0;
  578. for (const d of [...rows.keys()].sort((a, b) => a - b)) {
  579. const row = rows.get(d)!;
  580. const near = (s: WireStep): number => {
  581. const placed = (parentsOf.get(s.id) ?? []).map((p) => placedAt.get(p)).filter((x): x is number => x !== undefined);
  582. if (placed.length === 0) return Number.MAX_SAFE_INTEGER;
  583. return placed.reduce((a, b) => a + b, 0) / placed.length;
  584. };
  585. row.sort(
  586. (a, b) => near(a) - near(b) || (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id)
  587. );
  588. let line: string[] = [];
  589. let w = 0;
  590. for (const m of row) {
  591. const bw = widthOf(m.id);
  592. if (line.length > 0 && w + NODE_GAP + bw > REGION_LINE_MAX) {
  593. lines.push(line);
  594. width = Math.max(width, w);
  595. line = [];
  596. w = 0;
  597. }
  598. line.push(m.id);
  599. w += (line.length > 1 ? NODE_GAP : 0) + bw;
  600. placedAt.set(m.id, placedAt.size);
  601. }
  602. if (line.length > 0) {
  603. lines.push(line);
  604. width = Math.max(width, w);
  605. }
  606. }
  607. packed.set(region.id, { lines, width });
  608. }
  609. // Tile the regions into bands under a width budget.
  610. interface Band {
  611. regions: Region[];
  612. lines: number;
  613. width: number;
  614. }
  615. let area = 0;
  616. let widest = 0;
  617. for (const region of regions.values()) {
  618. const p = packed.get(region.id)!;
  619. area += p.width * p.lines.length * REGION_PITCH;
  620. widest = Math.max(widest, p.width);
  621. }
  622. const budget = bandBudget(area, widest);
  623. const bands: Band[] = [];
  624. let band: Band | null = null;
  625. for (const region of regions.values()) {
  626. const p = packed.get(region.id)!;
  627. if (band === null || band.width + REGION_GUTTER + p.width > budget) {
  628. band = { regions: [], lines: 0, width: -REGION_GUTTER };
  629. bands.push(band);
  630. }
  631. band.regions.push(region);
  632. band.lines = Math.max(band.lines, p.lines.length);
  633. band.width += REGION_GUTTER + p.width;
  634. }
  635. const contentWidth = Math.max(widthOf(anchor.id), ...bands.map((b) => b.width));
  636. // Place everything. The anchor is alone on top; each band's regions centre
  637. // as a row of columns; a region's lines centre within its own width.
  638. const at = new Map<string, { x: number; y: number; line: number }>();
  639. const zones: StepRegionZone[] = [];
  640. const anchorY = PADDING;
  641. let y = anchorY + NODE_HEIGHT + SCREEN_LAYER_GAP + BAND_GAP;
  642. let globalLine = 0;
  643. for (const b of bands) {
  644. let x = PADDING + (contentWidth - b.width) / 2;
  645. for (const region of b.regions) {
  646. const p = packed.get(region.id)!;
  647. p.lines.forEach((line, j) => {
  648. const lw = line.reduce((a, id) => a + widthOf(id), 0) + NODE_GAP * (line.length - 1);
  649. let lx = x + (p.width - lw) / 2;
  650. for (const id of line) {
  651. at.set(id, { x: lx, y: y + j * REGION_PITCH, line: globalLine + j });
  652. lx += widthOf(id) + NODE_GAP;
  653. }
  654. });
  655. zones.push({
  656. id: region.id,
  657. label: region.label,
  658. x,
  659. y,
  660. width: p.width,
  661. height: (p.lines.length - 1) * REGION_PITCH + NODE_HEIGHT,
  662. entry: p.lines[0]![0]!,
  663. });
  664. x += p.width + REGION_GUTTER;
  665. }
  666. y += b.lines * REGION_PITCH + BAND_GAP;
  667. globalLine += b.lines;
  668. }
  669. const height = y - REGION_PITCH - BAND_GAP + NODE_HEIGHT + PADDING;
  670. // Layers count from the bottom, as the Map's do: the route of an edge and
  671. // which sides it uses fall out of the comparison alone.
  672. const layerOf = (id: string): number =>
  673. id === anchor.id ? globalLine + 1 : globalLine - (at.get(id)?.line ?? 0);
  674. const nodesById = new Map<string, MapNodeLayout>();
  675. const place = (id: string, x: number, yy: number): void => {
  676. nodesById.set(id, {
  677. id,
  678. module: moduleOf.get(id)!,
  679. island: false,
  680. generated: false,
  681. layer: layerOf(id),
  682. x,
  683. y: yy,
  684. width: widthOf(id),
  685. height: NODE_HEIGHT,
  686. sourceHandles: [],
  687. targetHandles: [],
  688. ports: { top: [], bottom: [] },
  689. });
  690. };
  691. place(anchor.id, PADDING + (contentWidth - widthOf(anchor.id)) / 2, anchorY);
  692. for (const [id, p] of at) place(id, p.x, p.y);
  693. // Edges and ports, exactly as the Map lays them: the route from the layers,
  694. // the sides from the route, the ports spread in the order the other end
  695. // appears left to right.
  696. const edges: MapEdgeLayout[] = [];
  697. interface SidePort extends PortRef {
  698. other: number;
  699. }
  700. const sidePorts = new Map<string, { top: SidePort[]; bottom: SidePort[] }>();
  701. const centreOf = (id: string): number => {
  702. const n = nodesById.get(id);
  703. return n ? n.x + n.width / 2 : 0;
  704. };
  705. for (const link of links) {
  706. const from = nodesById.get(link.source);
  707. const to = nodesById.get(link.target);
  708. if (!from || !to) continue;
  709. const id = linkId(link);
  710. const route: EdgeRoute = from.layer > to.layer ? 'down' : from.layer < to.layer ? 'up' : 'level';
  711. edges.push({
  712. id,
  713. source: link.source,
  714. target: link.target,
  715. sourceHandle: `s:${id}`,
  716. targetHandle: `t:${id}`,
  717. link,
  718. width: strokeWidthFor(link.count),
  719. back: from.layer <= to.layer,
  720. thin: false,
  721. route,
  722. });
  723. const sides =
  724. route === 'down'
  725. ? { source: 'bottom' as const, target: 'top' as const }
  726. : route === 'up'
  727. ? { source: 'top' as const, target: 'bottom' as const }
  728. : { source: 'top' as const, target: 'top' as const };
  729. const bySide = (node: string): { top: SidePort[]; bottom: SidePort[] } => {
  730. const found = sidePorts.get(node) ?? { top: [], bottom: [] };
  731. sidePorts.set(node, found);
  732. return found;
  733. };
  734. bySide(link.source)[sides.source].push({ id, type: 'source', other: centreOf(link.target) });
  735. bySide(link.target)[sides.target].push({ id, type: 'target', other: centreOf(link.source) });
  736. }
  737. const byOther = (a: SidePort, b: SidePort): number => a.other - b.other || a.id.localeCompare(b.id);
  738. for (const [id, sides] of sidePorts) {
  739. const node = nodesById.get(id);
  740. if (!node) continue;
  741. sides.top.sort(byOther);
  742. sides.bottom.sort(byOther);
  743. node.ports = {
  744. top: sides.top.map((p) => ({ id: p.id, type: p.type })),
  745. bottom: sides.bottom.map((p) => ({ id: p.id, type: p.type })),
  746. };
  747. node.sourceHandles = sides.bottom.filter((p) => p.type === 'source').map((p) => p.id);
  748. node.targetHandles = sides.top.filter((p) => p.type === 'target').map((p) => p.id);
  749. }
  750. const layout: MapLayout = {
  751. nodes: [...nodesById.values()],
  752. edges,
  753. layers: [],
  754. width: contentWidth + PADDING * 2,
  755. height,
  756. basis: { kind: 'all', declaredLinks: links.length, totalLinks: links.length },
  757. minWeight: 0,
  758. hiddenLinks: 0,
  759. mutual: [],
  760. moduleCycles: [],
  761. };
  762. return { layout, zones };
  763. }
  764. /**
  765. * The selection, extended through decisions: a fork's point is not a step —
  766. * it belongs to the steps around it — so selecting the step before a fork, or
  767. * one of its arms, reaches the point and, through it, the fork's other lines.
  768. * The set holds the selected id and every point connected to it through
  769. * points alone; a picture without forks is just the selection.
  770. */
  771. export function selectionReach(model: StepsModel, selected: string): ReadonlySet<string> {
  772. const reach = new Set([selected]);
  773. if (model.forks === null || model.forks.size === 0) return reach;
  774. for (let grew = true; grew; ) {
  775. grew = false;
  776. for (const e of model.layout.edges) {
  777. const from = reach.has(e.source);
  778. const to = reach.has(e.target);
  779. if (from === to) continue;
  780. const other = from ? e.target : e.source;
  781. if (model.forks.has(other) && !reach.has(other)) {
  782. reach.add(other);
  783. grew = true;
  784. }
  785. }
  786. }
  787. return reach;
  788. }
  789. /**
  790. * Which edges draw, given the selection. Selecting a step says "show me
  791. * everything about this one" — every line touching it comes out, a decision's
  792. * lines through its point ({@link selectionReach}). At rest a regioned
  793. * picture hides exactly two things: the anchor's own fan — the anchor leads
  794. * to everything by definition, and a hundred and four ways of saying so were
  795. * the whole canvas, so one line into each region stands in for it — and, as
  796. * everywhere, what points back up the layering. Every other lead-to draws, a
  797. * line between two regions included: the empty state's prompt firing the same
  798. * handler as the header's is the picture, and hiding it made a box that leads
  799. * three places read as wired to nothing. A shared step fed from below (the
  800. * toast every handler calls) stays quiet through the back rule alone. An
  801. * unregioned picture keeps the Map's rule.
  802. */
  803. export function stepEdgeVisible(
  804. model: StepsModel,
  805. edge: MapEdgeLayout,
  806. selected: string | null,
  807. reach?: ReadonlySet<string>
  808. ): boolean {
  809. if (selected !== null) {
  810. const r = reach ?? selectionReach(model, selected);
  811. return r.has(edge.source) || r.has(edge.target);
  812. }
  813. if (edge.thin || edge.back) return false;
  814. if (model.regions === null) return true;
  815. const from = model.nodes.get(edge.source)?.step;
  816. if (from?.anchor) return model.regionEntries?.has(edge.target) ?? true;
  817. return true;
  818. }
  819. /** The side panel's two lists for a selected step. */
  820. export function stepNeighbourhood(
  821. payload: WireStepsPayload,
  822. id: string
  823. ): { arrivesFrom: WireStepLink[]; leadsTo: WireStepLink[] } {
  824. return {
  825. arrivesFrom: payload.links.filter((l) => l.to === id),
  826. leadsTo: payload.links.filter((l) => l.from === id),
  827. };
  828. }
  829. /** `useReviewHandlers → handleApproveAllImages`, or '' when nothing was folded. */
  830. export function stepViaText(link: WireStepLink): string {
  831. return link.via.map((v) => v.name).join(' → ');
  832. }
  833. /** The layout edge a link draws as, or null when it is a self-loop. */
  834. export function stepPairId(link: WireStepLink): string | null {
  835. return link.from === link.to ? null : linkId({ source: link.from, target: link.to });
  836. }