screens-model.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834
  1. /**
  2. * The Screens view's model — the app's screens and the transitions between
  3. * them, laid out so that a screen sits above the screens it opens.
  4. *
  5. * The layout is the Map's (`buildMapLayout`): the same barycenter ordering,
  6. * the same ports, the same determinism. A screen graph is a module graph with
  7. * different words — nodes with names, weighted links that mostly point one
  8. * way — but it differs from a module graph in one thing that shapes the
  9. * picture: it is full of cycles. Every screen returns to Home. So this file
  10. * asks the layout for three things the Map leaves alone:
  11. *
  12. * - **layering by distance from the entry screen** (`entryLayering`), where
  13. * "one layer above what it depends on" would put the head of the longest
  14. * chain of screens above the login page;
  15. * - **directional ports**, so a return trip leaves the top of its source and
  16. * arrives at the bottom of its target — drawn around the boxes, not through
  17. * them — and a transition between two screens on one row arches over it;
  18. * - **room**: a wider layer gap, because the edges here carry labels, and a
  19. * port pitch, because a hub with nineteen lines leaving it needs to be wide
  20. * enough for a reader to follow one back.
  21. *
  22. * What is this file's own: which links share a pair (several transitions from
  23. * Home to Capture, each with its own condition, draw as ONE edge whose label
  24. * counts them), the words on that edge, where on the canvas those words sit
  25. * (`placeLabels`), the curve every edge draws — each with its own height
  26. * through the gap, so a hub's lines fan out instead of stacking
  27. * (`trackedCurves`) — which line is under the pointer (`nearestEdge`), and
  28. * the two lists the side panel shows for a selected screen.
  29. */
  30. import type { WireMapLink, WireMapModule, WireScreen, WireScreenLink, WireScreensPayload } from './wire';
  31. import { clauseWords, clauses } from './conditions';
  32. import {
  33. buildMapLayout,
  34. linkId,
  35. portPoint,
  36. PORT_PITCH,
  37. type EdgeRoute,
  38. type MapEdgeLayout,
  39. type MapLayout,
  40. type MapNodeLayout,
  41. } from './map-model';
  42. /* ------------------------------------------------------------- geometry -- */
  43. /**
  44. * Vertical room between two rows of screens. The Map's 74px holds hairlines;
  45. * this holds labels — five lanes of them (see {@link laneCount}) with their
  46. * margins.
  47. */
  48. export const SCREEN_LAYER_GAP = 116;
  49. /** The widest `level` arch in a fan rises this fraction of the layer gap above its row… */
  50. const LEVEL_RISE = 0.66;
  51. /** …and the narrowest this much less, so nested arches stay apart. */
  52. const LEVEL_NEST = 0.26;
  53. /** Points a curve is sampled at for hit-testing; at 116px tall, under a pixel off. */
  54. const HIT_SAMPLES = 24;
  55. /** IBM Plex Mono at 10.5px advances ~6.3px per character; the pill adds 6px each side. */
  56. export const PILL_CHAR_WIDTH = 6.3;
  57. export const PILL_PADDING = 12;
  58. export const PILL_HEIGHT = 17;
  59. /** From a box's edge to the centre of the first lane of pills beside it. */
  60. export const PILL_OFFSET = 13;
  61. /** From one lane to the next: a pill and 4px of paper. */
  62. export const LANE_STEP = PILL_HEIGHT + 4;
  63. /** Two pills on one lane keep this much paper between them. */
  64. const PILL_GAP_X = 4;
  65. /** The last lane keeps this much clear of the neighbouring row's boxes. */
  66. const BAND_MARGIN = 2;
  67. /**
  68. * The longest label a pill prints before an ellipsis; the tooltip and the
  69. * panel have the rest. Sized so the innermost clause of a typical guard
  70. * (`guide.dontShowAgain.captureGuide`, 32 characters) fits whole even as
  71. * `…not guide.dontShowAgain.captureGuide` — the negation is a word now.
  72. */
  73. export const EDGE_LABEL_MAX = 40;
  74. /* ---------------------------------------------------------------- model -- */
  75. export interface ScreenNodeInfo {
  76. id: string;
  77. /** `/object-detail`, or a function name for an origin. */
  78. label: string;
  79. /** The component's name for a screen; the file for an origin. */
  80. sub: string;
  81. screen: WireScreen | null;
  82. /** A navigation that could not be attributed to a screen. */
  83. origin: boolean;
  84. entry: boolean;
  85. /** No path of transitions leads here from the entry screen. */
  86. unreached: boolean;
  87. }
  88. export interface ScreenEdgeInfo {
  89. id: string;
  90. from: string;
  91. to: string;
  92. /** Every transition between the pair — one connector, several stories. */
  93. links: WireScreenLink[];
  94. /** The connector's short label: the innermost condition, or how many transitions. */
  95. label: string;
  96. synthesized: boolean;
  97. }
  98. export interface ScreensModel {
  99. layout: MapLayout;
  100. nodes: Map<string, ScreenNodeInfo>;
  101. /** Keyed by the layout edge's id (see `linkId`). */
  102. edges: Map<string, ScreenEdgeInfo>;
  103. /** Screens no chain of transitions reaches from the entry. */
  104. unreached: number;
  105. /** The vertical room between rows the layout was built with. */
  106. layerGap: number;
  107. /** Every edge's curve, keyed by edge id — see {@link trackedCurves}. */
  108. curves: Map<string, Curve>;
  109. /** The same curves sampled for hit-testing — see {@link nearestEdge}. */
  110. polylines: Map<string, Point[]>;
  111. }
  112. export interface Point {
  113. x: number;
  114. y: number;
  115. }
  116. /**
  117. * What the label placement and the pointer need from a picture: the Screens
  118. * view's model, or any other drawn with its machinery (the Steps view draws
  119. * typed steps with the same layout, curves, pills and hit-testing).
  120. */
  121. export interface Picture {
  122. layout: MapLayout;
  123. layerGap: number;
  124. edges: Map<string, { label: string }>;
  125. curves: Map<string, Curve>;
  126. polylines: Map<string, Point[]>;
  127. }
  128. /* ------------------------------------------------------------- layering -- */
  129. /**
  130. * Layer = distance from the entry screen: the entry on top, each row down one
  131. * more transition away, measured over EVERY transition — the two-cycle break
  132. * the Map performs for its own layering is irrelevant to a distance, so the
  133. * links come in through the closure rather than through the argument the
  134. * layout hands over.
  135. *
  136. * Origins (shared chrome, a store action after login) are not screens and
  137. * have no distance of their own. Each hangs one row above the shallowest
  138. * screen it opens, so what it opens is below it and what it opens is placed
  139. * by the entry, not by the chrome: a top bar rendered on ten screens must not
  140. * drag `/settings` up beside the home screen. An origin whose targets nothing
  141. * else reaches seeds them from wherever it sits, so they are still placed.
  142. *
  143. * Whatever nothing reaches sits in a band at the bottom, layered among itself
  144. * by the same rule from its own sources — a screen the graph cannot see
  145. * anyone open is a fact worth a place, not a crash.
  146. */
  147. export function entryLayering(
  148. entry: string | null,
  149. origins: readonly string[],
  150. links: ReadonlyArray<{ source: string; target: string }>
  151. ) {
  152. return (ids: string[]): Map<string, number> => {
  153. const present = new Set(ids);
  154. const out = new Map<string, string[]>(ids.map((id) => [id, []]));
  155. const indeg = new Map<string, number>(ids.map((id) => [id, 0]));
  156. for (const l of links) {
  157. if (!present.has(l.source) || !present.has(l.target) || l.source === l.target) continue;
  158. out.get(l.source)!.push(l.target);
  159. indeg.set(l.target, (indeg.get(l.target) ?? 0) + 1);
  160. }
  161. for (const list of out.values()) list.sort();
  162. const depth = new Map<string, number>();
  163. // Multi-source BFS whose sources may start at different depths: buckets
  164. // processed in ascending order, first assignment wins, so a node's depth
  165. // is the least over every source — and a depth already set is never
  166. // lowered by a later phase.
  167. const walk = (starts: ReadonlyArray<[string, number]>): void => {
  168. const buckets = new Map<number, string[]>();
  169. const push = (id: string, d: number): void => {
  170. if (depth.has(id)) return;
  171. depth.set(id, d);
  172. const list = buckets.get(d);
  173. if (list) list.push(id);
  174. else buckets.set(d, [id]);
  175. };
  176. let maxStart = 0;
  177. for (const [id, d] of starts) {
  178. push(id, d);
  179. maxStart = Math.max(maxStart, d);
  180. }
  181. for (let d = 0; d <= ids.length + maxStart; d++) {
  182. const list = buckets.get(d);
  183. if (!list) continue;
  184. for (const id of list) for (const t of out.get(id) ?? []) push(t, d + 1);
  185. }
  186. };
  187. // Phase 1: the screens, by distance from the entry.
  188. if (entry !== null && present.has(entry)) walk([[entry, 0]]);
  189. // Phase 2: each origin above its shallowest placed target; then whatever
  190. // only the origins reach, from them.
  191. const seeds: Array<[string, number]> = [];
  192. for (const origin of [...origins].filter((o) => present.has(o)).sort()) {
  193. const placed = (out.get(origin) ?? []).map((t) => depth.get(t)).filter((d): d is number => d !== undefined);
  194. seeds.push([origin, placed.length > 0 ? Math.max(0, Math.min(...placed) - 1) : 0]);
  195. }
  196. walk(seeds);
  197. const reachedMax = Math.max(0, ...[...depth.values()]);
  198. // The unreached band: its own sources first, then whatever they open.
  199. const rest = ids.filter((id) => !depth.has(id));
  200. const restDepth = new Map<string, number>();
  201. if (rest.length > 0) {
  202. const restSet = new Set(rest);
  203. const restSources = rest.filter((id) => (indeg.get(id) ?? 0) === 0);
  204. let frontier = restSources.length > 0 ? restSources : [rest[0]!];
  205. for (const s of frontier) restDepth.set(s, 0);
  206. let d = 0;
  207. while (frontier.length > 0) {
  208. d++;
  209. const next: string[] = [];
  210. for (const id of frontier) {
  211. for (const t of out.get(id) ?? []) {
  212. if (restDepth.has(t) || !restSet.has(t)) continue;
  213. restDepth.set(t, d);
  214. next.push(t);
  215. }
  216. }
  217. frontier = next;
  218. }
  219. for (const id of rest) if (!restDepth.has(id)) restDepth.set(id, 0);
  220. }
  221. const restMax = Math.max(0, ...[...restDepth.values()]);
  222. // Layer 0 is the bottom. Unreached band occupies [0, restMax]; reached
  223. // screens sit above it, the entry highest, with one empty row between.
  224. const base = rest.length > 0 ? restMax + 2 : 0;
  225. const layer = new Map<string, number>();
  226. for (const [id, d] of depth) layer.set(id, base + reachedMax - d);
  227. for (const [id, d] of restDepth) layer.set(id, restMax - d);
  228. return layer;
  229. };
  230. }
  231. /* --------------------------------------------------------------- labels -- */
  232. export { clauses } from './conditions';
  233. /**
  234. * What the connector says. Empty when unconditional and single.
  235. *
  236. * A single transition is labelled with its innermost condition — the one
  237. * checked right at the navigation call — with an ellipsis in front when outer
  238. * guards precede it. The whole chain is often seventy characters and its
  239. * first thirty are usually shared with a sibling (`!loading && !(!objectId
  240. * …` on both arms of a fork); the last clause is the one that tells the two
  241. * apart, and the full text is a hover away.
  242. */
  243. export function edgeLabel(links: ReadonlyArray<{ when: string; sites?: ReadonlyArray<{ when: string }> }>): string {
  244. // A link with several call sites is several scenarios: count them as ways.
  245. const ways = links.flatMap((l) => (l.sites && l.sites.length > 1 ? l.sites.map((s) => s.when) : [l.when]));
  246. if (ways.length === 1) {
  247. const when = ways[0]!;
  248. if (!when) return '';
  249. const parts = clauses(when);
  250. const last = clauseWords(parts[parts.length - 1] ?? when);
  251. const text = parts.length > 1 ? `…${last}` : last;
  252. return text.length > EDGE_LABEL_MAX ? `${text.slice(0, EDGE_LABEL_MAX - 1)}…` : text;
  253. }
  254. const conditional = ways.filter((w) => w).length;
  255. return conditional > 0 ? `${ways.length} ways · ${conditional} conditional` : `${ways.length} ways`;
  256. }
  257. /* ---------------------------------------------------------------- build -- */
  258. export function buildScreensModel(payload: WireScreensPayload): ScreensModel {
  259. const nodes = new Map<string, ScreenNodeInfo>();
  260. const modules: WireMapModule[] = [];
  261. for (const screen of payload.screens) {
  262. const info: ScreenNodeInfo = {
  263. id: screen.id,
  264. label: screen.path,
  265. sub: screen.component?.name ?? screen.file,
  266. screen,
  267. origin: false,
  268. entry: payload.entry === screen.id,
  269. unreached: false,
  270. };
  271. nodes.set(screen.id, info);
  272. modules.push(moduleFor(info, screen.incoming + screen.outgoing));
  273. }
  274. for (const origin of payload.origins) {
  275. const info: ScreenNodeInfo = {
  276. id: origin.id,
  277. label: origin.node.kind === 'component' ? `<${origin.node.name}>` : `${origin.node.name}()`,
  278. sub: origin.sharedBy ? `on ${origin.sharedBy} screens` : origin.node.file,
  279. screen: null,
  280. origin: true,
  281. entry: false,
  282. unreached: false,
  283. };
  284. nodes.set(origin.id, info);
  285. modules.push(moduleFor(info, origin.outgoing));
  286. }
  287. // One layout link per (from, to); the transitions behind it stay listed.
  288. const byPair = new Map<string, WireScreenLink[]>();
  289. for (const link of payload.links) {
  290. const key = linkId({ source: link.from, target: link.to });
  291. const list = byPair.get(key) ?? [];
  292. list.push(link);
  293. byPair.set(key, list);
  294. }
  295. const links: WireMapLink[] = [];
  296. const edges = new Map<string, ScreenEdgeInfo>();
  297. for (const [key, group] of byPair) {
  298. const first = group[0]!;
  299. if (!nodes.has(first.from) || !nodes.has(first.to)) continue;
  300. // A screen that reopens itself (a retry) is a fact for the panel, not an
  301. // arrow the layout can draw.
  302. if (first.from === first.to) continue;
  303. links.push({
  304. source: first.from,
  305. target: first.to,
  306. count: group.length,
  307. declared: group.length,
  308. byKind: [{ kind: 'navigates', count: group.length }],
  309. topPairs: [],
  310. });
  311. edges.set(key, {
  312. id: key,
  313. from: first.from,
  314. to: first.to,
  315. links: group,
  316. label: edgeLabel(group),
  317. synthesized: group.every((l) => l.synthesized),
  318. });
  319. }
  320. // Reachability from the entry (and from the origins, which are entries of
  321. // a kind: chrome is on the screen the user is on).
  322. const seeds = payload.origins.map((o) => o.id);
  323. const reachable = new Set<string>();
  324. {
  325. const out = new Map<string, string[]>();
  326. for (const l of payload.links) out.set(l.from, [...(out.get(l.from) ?? []), l.to]);
  327. const stack = [payload.entry, ...seeds].filter((s): s is string => s !== null);
  328. while (stack.length > 0) {
  329. const id = stack.pop()!;
  330. if (reachable.has(id)) continue;
  331. reachable.add(id);
  332. for (const t of out.get(id) ?? []) stack.push(t);
  333. }
  334. }
  335. let unreached = 0;
  336. for (const info of nodes.values()) {
  337. if (!info.origin && !reachable.has(info.id)) {
  338. info.unreached = true;
  339. unreached++;
  340. }
  341. }
  342. const layout = buildMapLayout(
  343. { modules, links },
  344. {
  345. includeTests: true,
  346. minWeight: 0,
  347. sizing: (m) => {
  348. const info = nodes.get(m.id);
  349. return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
  350. },
  351. layering: entryLayering(payload.entry, seeds, links),
  352. layerGap: SCREEN_LAYER_GAP,
  353. portPitch: PORT_PITCH,
  354. ports: 'directional',
  355. }
  356. );
  357. const curves = trackedCurves(layout, SCREEN_LAYER_GAP);
  358. const polylines = new Map<string, Point[]>();
  359. for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
  360. return { layout, nodes, edges, unreached, layerGap: SCREEN_LAYER_GAP, curves, polylines };
  361. }
  362. function moduleFor(info: ScreenNodeInfo, symbols: number): WireMapModule {
  363. return {
  364. id: info.id,
  365. label: info.label,
  366. files: 1,
  367. symbols,
  368. languages: [],
  369. test: false,
  370. generated: 0,
  371. generatedFiles: [],
  372. facade: false,
  373. fileList: { total: 1, shown: 1, truncated: false, items: [info.screen?.file ?? info.sub] },
  374. };
  375. }
  376. /** The side panel's two lists for a selected node. */
  377. export function neighbourhood(
  378. payload: WireScreensPayload,
  379. id: string
  380. ): { opensFrom: WireScreenLink[]; goesTo: WireScreenLink[] } {
  381. const opensFrom = payload.links.filter((l) => l.to === id);
  382. const goesTo = payload.links.filter((l) => l.from === id);
  383. return { opensFrom, goesTo };
  384. }
  385. /** `ItemCard → openObjectDetail`, or '' when the screen's own component navigates. */
  386. export function viaText(link: WireScreenLink): string {
  387. return link.via.map((v) => v.name).join(' → ');
  388. }
  389. /** The layout edge a transition draws as, or null when it is a self-loop. */
  390. export function pairId(link: WireScreenLink): string | null {
  391. return link.from === link.to ? null : linkId({ source: link.from, target: link.to });
  392. }
  393. /* ---------------------------------------------------------------- curve -- */
  394. /** A cubic Bézier: the point it leaves, two controls, the point it reaches. */
  395. export interface Curve {
  396. x0: number;
  397. y0: number;
  398. x1: number;
  399. y1: number;
  400. x2: number;
  401. y2: number;
  402. x3: number;
  403. y3: number;
  404. }
  405. /**
  406. * The curve an edge draws, from its source port to its target port.
  407. *
  408. * `down` and `up` are the Map's cubic: it leaves and arrives vertically, with
  409. * both control points at one height — the midpoint unless a `track` says
  410. * otherwise (see {@link trackedCurves}). `level` joins two boxes on one row
  411. * from top to top, arching over the row — the only shape that touches neither
  412. * box on the way; `track` is then how far the arch rises. The same arithmetic
  413. * places the labels and answers the pointer, so a pill sits on the line the
  414. * browser draws and the line under the cursor is the one that lights.
  415. */
  416. export function screenCurve(
  417. route: EdgeRoute,
  418. sx: number,
  419. sy: number,
  420. tx: number,
  421. ty: number,
  422. layerGap = SCREEN_LAYER_GAP,
  423. track?: number
  424. ): Curve {
  425. if (route === 'level') {
  426. const rise = track ?? Math.round(layerGap * LEVEL_RISE);
  427. return { x0: sx, y0: sy, x1: sx, y1: sy - rise, x2: tx, y2: ty - rise, x3: tx, y3: ty };
  428. }
  429. const midY = track ?? (sy + ty) / 2;
  430. return { x0: sx, y0: sy, x1: sx, y1: midY, x2: tx, y2: midY, x3: tx, y3: ty };
  431. }
  432. /** The SVG path of a curve. */
  433. export function pathOf(c: Curve): string {
  434. return `M${c.x0},${c.y0} C${c.x1},${c.y1} ${c.x2},${c.y2} ${c.x3},${c.y3}`;
  435. }
  436. /** The SVG path of {@link screenCurve}. */
  437. export function screenEdgePath(
  438. route: EdgeRoute,
  439. sx: number,
  440. sy: number,
  441. tx: number,
  442. ty: number,
  443. layerGap = SCREEN_LAYER_GAP
  444. ): string {
  445. return pathOf(screenCurve(route, sx, sy, tx, ty, layerGap));
  446. }
  447. /* --------------------------------------------------------------- tracks -- */
  448. /**
  449. * Every edge's curve, each with its own height through the gap.
  450. *
  451. * Drawn through one midpoint, every line between two rows crosses that height
  452. * at its middle, and a line to a screen far to the side is nearly horizontal
  453. * there — so a hub's lines run stacked within a few pixels for hundreds, and
  454. * no pointer can pick one. Instead each line in a fan takes a track of its
  455. * own. A fan is the set of lines leaving one side of one box towards one
  456. * side; a line belongs to the bigger of the two fans at its ends (the upper
  457. * one on a tie). Within a fan the line whose far end is farthest out runs on
  458. * the track nearest the fan's own row, the next one a track further out, and
  459. * so on: nested, in the same order the ports along the box are, so no line in
  460. * a fan crosses another. A line spanning several rows keeps its track inside
  461. * the gap beside its fan and drops the rest of the way vertically. Level
  462. * arches nest the same way — the wider arch rises higher.
  463. */
  464. export function trackedCurves(layout: MapLayout, layerGap: number): Map<string, Curve> {
  465. const nodes = new Map(layout.nodes.map((n) => [n.id, n]));
  466. const fanSize = (node: MapNodeLayout, side: 'top' | 'bottom'): number => node.ports[side].length;
  467. interface Member {
  468. edge: MapEdgeLayout;
  469. s: Point;
  470. t: Point;
  471. /** How far out the far end sits from the fan's box; the nesting order. */
  472. reach: number;
  473. /** For a `down`/`up` edge: the fan is at the upper end. */
  474. pivotUpper: boolean;
  475. up: Point;
  476. lo: Point;
  477. }
  478. const groups = new Map<string, Member[]>();
  479. for (const edge of layout.edges) {
  480. const from = nodes.get(edge.source);
  481. const to = nodes.get(edge.target);
  482. if (!from || !to) continue;
  483. const s = portPoint(from, edge.id, 'source');
  484. const t = portPoint(to, edge.id, 'target');
  485. let pivot: MapNodeLayout;
  486. let side: 'top' | 'bottom';
  487. let other: Point;
  488. let pivotUpper = true;
  489. let up = s;
  490. let lo = t;
  491. if (edge.route === 'level') {
  492. pivot = fanSize(to, 'top') > fanSize(from, 'top') ? to : from;
  493. side = 'top';
  494. other = pivot === from ? t : s;
  495. } else {
  496. const upperIsSource = edge.route === 'down';
  497. const upper = upperIsSource ? from : to;
  498. const lower = upperIsSource ? to : from;
  499. up = upperIsSource ? s : t;
  500. lo = upperIsSource ? t : s;
  501. pivotUpper = fanSize(upper, 'bottom') >= fanSize(lower, 'top');
  502. pivot = pivotUpper ? upper : lower;
  503. side = pivotUpper ? 'bottom' : 'top';
  504. other = pivotUpper ? lo : up;
  505. }
  506. const centre = pivot.x + pivot.width / 2;
  507. const key = `${pivot.id}\u0000${side}\u0000${other.x < centre ? 'L' : 'R'}`;
  508. const list = groups.get(key) ?? [];
  509. list.push({ edge, s, t, reach: Math.abs(other.x - centre), pivotUpper, up, lo });
  510. groups.set(key, list);
  511. }
  512. const curves = new Map<string, Curve>();
  513. for (const members of groups.values()) {
  514. members.sort((a, b) => b.reach - a.reach || a.edge.id.localeCompare(b.edge.id));
  515. const n = members.length;
  516. members.forEach((m, k) => {
  517. const { edge, s, t } = m;
  518. if (edge.route === 'level') {
  519. const rise = Math.round(layerGap * (LEVEL_RISE - (LEVEL_NEST * k) / Math.max(1, n - 1)));
  520. curves.set(edge.id, screenCurve('level', s.x, s.y, t.x, t.y, layerGap, rise));
  521. return;
  522. }
  523. const f = (k + 1) / (n + 1);
  524. const span = Math.min(m.lo.y - m.up.y, layerGap);
  525. const track = m.pivotUpper ? m.up.y + span * f : m.lo.y - span * f;
  526. curves.set(edge.id, screenCurve(edge.route, s.x, s.y, t.x, t.y, layerGap, track));
  527. });
  528. }
  529. return curves;
  530. }
  531. /* ------------------------------------------------------------ pointing -- */
  532. /** The curve as `count` points from source to target, for distance tests. */
  533. export function samplePolyline(c: Curve, count = HIT_SAMPLES): Point[] {
  534. const out: Point[] = [];
  535. for (let i = 0; i < count; i++) out.push(pointAt(c, i / (count - 1)));
  536. return out;
  537. }
  538. function distanceToSegment(p: Point, a: Point, b: Point): number {
  539. const dx = b.x - a.x;
  540. const dy = b.y - a.y;
  541. const len2 = dx * dx + dy * dy;
  542. const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));
  543. const x = a.x + t * dx - p.x;
  544. const y = a.y + t * dy - p.y;
  545. return Math.sqrt(x * x + y * y);
  546. }
  547. export function distanceToPolyline(p: Point, line: readonly Point[]): number {
  548. let best = Infinity;
  549. for (let i = 1; i < line.length; i++) best = Math.min(best, distanceToSegment(p, line[i - 1]!, line[i]!));
  550. return best;
  551. }
  552. export interface EdgeHit {
  553. id: string;
  554. distance: number;
  555. }
  556. /**
  557. * The edge nearest `point` within `reach`, among the ids given (every edge
  558. * when null). This is what hovering means on the canvas: not "the topmost hit
  559. * path under the pointer", which in a bundle of eight lines four pixels apart
  560. * is whichever one the DOM drew last, but the line the pointer is closest to
  561. * — so moving three pixels moves to the next line, predictably. Ties go to
  562. * the smaller id, so two visits agree.
  563. */
  564. export function nearestEdge(
  565. model: Picture,
  566. point: Point,
  567. among: ReadonlySet<string> | null,
  568. reach: number
  569. ): EdgeHit | null {
  570. let best: EdgeHit | null = null;
  571. for (const [id, line] of model.polylines) {
  572. if (among !== null && !among.has(id)) continue;
  573. const distance = distanceToPolyline(point, line);
  574. if (distance > reach) continue;
  575. if (best === null || distance < best.distance || (distance === best.distance && id < best.id)) {
  576. best = { id, distance };
  577. }
  578. }
  579. return best;
  580. }
  581. /** The point at `t` on the curve, 0 = source, 1 = target. */
  582. export function pointAt(c: Curve, t: number): { x: number; y: number } {
  583. const u = 1 - t;
  584. const a = u * u * u;
  585. const b = 3 * u * u * t;
  586. const d = 3 * u * t * t;
  587. const e = t * t * t;
  588. return { x: a * c.x0 + b * c.x1 + d * c.x2 + e * c.x3, y: a * c.y0 + b * c.y1 + d * c.y2 + e * c.y3 };
  589. }
  590. /**
  591. * The parameter at which the curve passes height `y`, or null when it never
  592. * does. A `down`/`up` curve is monotonic in y end to end; a `level` arch
  593. * rises and falls, so it is searched on the half nearest `end`.
  594. */
  595. export function tAtY(c: Curve, y: number, end: 'source' | 'target'): number | null {
  596. const arch = c.y0 === c.y3;
  597. let lo = arch && end === 'target' ? 0.5 : 0;
  598. let hi = arch && end === 'source' ? 0.5 : 1;
  599. const yLo = pointAt(c, lo).y;
  600. const yHi = pointAt(c, hi).y;
  601. if (y < Math.min(yLo, yHi) - 1e-6 || y > Math.max(yLo, yHi) + 1e-6) return null;
  602. const rising = yHi > yLo;
  603. for (let i = 0; i < 40; i++) {
  604. const mid = (lo + hi) / 2;
  605. if (pointAt(c, mid).y < y === rising) lo = mid;
  606. else hi = mid;
  607. }
  608. return (lo + hi) / 2;
  609. }
  610. /* ---------------------------------------------------------------- pills -- */
  611. export interface PillPlacement {
  612. edge: string;
  613. text: string;
  614. /** Centre of the pill, in canvas coordinates. */
  615. x: number;
  616. y: number;
  617. /** Estimated from the text; what the lane arithmetic reserved. */
  618. width: number;
  619. lane: number;
  620. /** The end of the edge the pill sits at. */
  621. end: 'source' | 'target';
  622. }
  623. export interface PillLayout {
  624. pills: Map<string, PillPlacement>;
  625. /** Labels that found no lane; the panel says so. */
  626. hidden: number;
  627. }
  628. export function pillWidth(text: string): number {
  629. return text.length * PILL_CHAR_WIDTH + PILL_PADDING;
  630. }
  631. /** How many lanes of pills fit between two rows `layerGap` apart. */
  632. export function laneCount(layerGap: number): number {
  633. return Math.max(1, Math.floor((layerGap - BAND_MARGIN - PILL_HEIGHT / 2 - PILL_OFFSET) / LANE_STEP) + 1);
  634. }
  635. /**
  636. * The words on a pill: an arrow for which way the transition runs relative to
  637. * the selected screen — `→` leaving it, `←` arriving — and the edge's label.
  638. * Empty when the edge has nothing to say (a single, unconditional transition).
  639. */
  640. export function pillText(info: { label: string }, edge: MapEdgeLayout, selected: string | null): string {
  641. if (!info.label) return '';
  642. const arriving = selected !== null && edge.target === selected && edge.source !== selected;
  643. return `${arriving ? '←' : '→'} ${info.label}`;
  644. }
  645. interface Rect {
  646. x: number;
  647. y: number;
  648. w: number;
  649. h: number;
  650. }
  651. function intersects(a: Rect, b: Rect, gapX: number): boolean {
  652. return a.x < b.x + b.w + gapX && b.x < a.x + a.w + gapX && a.y < b.y + b.h && b.y < a.y + a.h;
  653. }
  654. /**
  655. * Lay a pill beside the box at `end` of the edge: the first lane whose pill
  656. * would overlap nothing already placed, walking away from the box one lane
  657. * at a time, each pill centred on its own line at that height. Null when no
  658. * lane is free — or when `lanes` is 1 and that lane is taken.
  659. */
  660. function layPill(
  661. model: Picture,
  662. edge: MapEdgeLayout,
  663. end: 'source' | 'target',
  664. text: string,
  665. nodes: Map<string, MapNodeLayout>,
  666. lanes: number,
  667. taken: Rect[],
  668. bounds: { width: number; height: number }
  669. ): { pill: PillPlacement; rect: Rect } | null {
  670. const curve = model.curves.get(edge.id);
  671. const box = nodes.get(end === 'source' ? edge.source : edge.target);
  672. if (!curve || !box) return null;
  673. const port = end === 'source' ? { x: curve.x0, y: curve.y0 } : { x: curve.x3, y: curve.y3 };
  674. const above = port.y === box.y;
  675. const width = pillWidth(text);
  676. for (let lane = 0; lane < lanes; lane++) {
  677. const off = PILL_OFFSET + lane * LANE_STEP;
  678. const y = above ? port.y - off : port.y + off;
  679. const t = tAtY(curve, y, end);
  680. if (t === null) return null;
  681. const x = pointAt(curve, t).x;
  682. const rect = { x: x - width / 2, y: y - PILL_HEIGHT / 2, w: width, h: PILL_HEIGHT };
  683. if (rect.y < 0 || rect.y + rect.h > bounds.height) return null;
  684. if (taken.some((r) => intersects(r, rect, PILL_GAP_X))) continue;
  685. return { pill: { edge: edge.id, text, x, y, width, lane, end }, rect };
  686. }
  687. return null;
  688. }
  689. /**
  690. * Where the selected screen's labels go.
  691. *
  692. * Every pill sits at the FAR end of its line — beside the other screen, where
  693. * the lines are apart — never beside the selected one, where fifteen of them
  694. * share a box's width and no label can belong to one of them. Pills are laid
  695. * left to right, each in the first free lane walking away from its box, and
  696. * a pill that finds no lane is not drawn but counted, so the panel can say
  697. * so. The boxes themselves are obstacles, so a lane in the margin above the
  698. * top row cannot land a pill on a screen.
  699. *
  700. * A pure function of the model and the selection, so hovering never moves a
  701. * pill: the pill for a hovered edge that is not the selected screen's is
  702. * placed separately by {@link hoverPill}.
  703. */
  704. export function placeLabels(model: Picture, selected: string | null, atRest = false): PillLayout {
  705. const pills = new Map<string, PillPlacement>();
  706. if (selected === null && !atRest) return { pills, hidden: 0 };
  707. const nodes = new Map(model.layout.nodes.map((n) => [n.id, n]));
  708. const lanes = laneCount(model.layerGap);
  709. const bounds = { width: model.layout.width, height: model.layout.height };
  710. const taken: Rect[] = model.layout.nodes.map((n) => ({ x: n.x, y: n.y, w: n.width, h: n.height }));
  711. // At rest, only the selected screen's lines are labelled — a picture with a
  712. // label on every line is unreadable, and the reader has asked about one box.
  713. // A picture whose labels ARE its content says so (`atRest`): the Steps view
  714. // in the code's order, where the conditions on the lines are the flow.
  715. const candidates = model.layout.edges
  716. .filter((e) => atRest || e.source === selected || e.target === selected)
  717. .map((edge) => {
  718. const end: 'source' | 'target' = selected !== null && edge.target === selected ? 'source' : 'target';
  719. const far = nodes.get(end === 'source' ? edge.source : edge.target);
  720. const anchor = far ? portPoint(far, edge.id, end) : { x: 0, y: 0 };
  721. return { edge, end, anchor };
  722. })
  723. .sort((a, b) => a.anchor.x - b.anchor.x || a.anchor.y - b.anchor.y || a.edge.id.localeCompare(b.edge.id));
  724. let hidden = 0;
  725. for (const { edge, end } of candidates) {
  726. const info = model.edges.get(edge.id);
  727. if (!info) continue;
  728. const text = pillText(info, edge, selected);
  729. if (!text) continue;
  730. const laid = layPill(model, edge, end, text, nodes, lanes, taken, bounds);
  731. if (laid === null) {
  732. hidden++;
  733. continue;
  734. }
  735. pills.set(edge.id, laid.pill);
  736. taken.push(laid.rect);
  737. }
  738. return { pills, hidden };
  739. }
  740. /**
  741. * The pill for a hovered edge that has no place in {@link placeLabels} —
  742. * nothing is selected, or the edge has nothing to say at rest. It sits at the
  743. * edge's target end (its source end when the selected screen is the target),
  744. * in the first lane clear of the pills in `avoid` and of every box; when
  745. * there is none it takes the first lane anyway, on top of whatever is there —
  746. * it is transient, and the line under the pointer is the one the reader is
  747. * asking about. `text` overrides the pill's words — the panel hovers a row
  748. * with the whole condition, not the connector's short label.
  749. */
  750. export function hoverPill(
  751. model: Picture,
  752. edgeId: string,
  753. selected: string | null,
  754. text?: string,
  755. avoid?: PillLayout
  756. ): PillPlacement | null {
  757. const edge = model.layout.edges.find((e) => e.id === edgeId);
  758. const info = edge ? model.edges.get(edge.id) : undefined;
  759. if (!edge || !info) return null;
  760. const words = text ?? pillText(info, edge, selected);
  761. if (!words) return null;
  762. const nodes = new Map(model.layout.nodes.map((n) => [n.id, n]));
  763. const end: 'source' | 'target' = selected !== null && edge.target === selected && edge.source !== selected ? 'source' : 'target';
  764. const bounds = { width: model.layout.width, height: model.layout.height };
  765. const taken: Rect[] = model.layout.nodes.map((n) => ({ x: n.x, y: n.y, w: n.width, h: n.height }));
  766. for (const pill of avoid?.pills.values() ?? []) {
  767. if (pill.edge === edgeId) continue;
  768. taken.push({ x: pill.x - pill.width / 2, y: pill.y - PILL_HEIGHT / 2, w: pill.width, h: PILL_HEIGHT });
  769. }
  770. return (
  771. layPill(model, edge, end, words, nodes, laneCount(model.layerGap), taken, bounds)?.pill ??
  772. layPill(model, edge, end, words, nodes, 1, [], bounds)?.pill ??
  773. null
  774. );
  775. }