steps-model.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  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 type { WireMapLink, WireMapModule, WireStep, WireStepLink, WireStepsPayload } from './wire';
  15. import { buildMapLayout, linkId, PORT_PITCH, type MapLayout } from './map-model';
  16. import {
  17. edgeLabel,
  18. samplePolyline,
  19. trackedCurves,
  20. SCREEN_LAYER_GAP,
  21. type Curve,
  22. type Picture,
  23. type Point,
  24. } from './screens-model';
  25. export interface StepNodeInfo {
  26. id: string;
  27. step: WireStep;
  28. /** What the box prints on its first line. */
  29. label: string;
  30. /** …and on its second. */
  31. sub: string;
  32. }
  33. export interface StepEdgeInfo {
  34. id: string;
  35. from: string;
  36. to: string;
  37. /** Every link between the pair — one connector, several stories. */
  38. links: WireStepLink[];
  39. /** The connector's short label: the innermost condition, or how many links. */
  40. label: string;
  41. /** Every link behind it was synthesized (a dynamic-dispatch bridge). */
  42. synthesized: boolean;
  43. /** The kind the links agree on, or `calls` when they differ. */
  44. kind: WireStepLink['kind'];
  45. }
  46. export interface StepsModel extends Picture {
  47. layout: MapLayout;
  48. nodes: Map<string, StepNodeInfo>;
  49. edges: Map<string, StepEdgeInfo>;
  50. layerGap: number;
  51. curves: Map<string, Curve>;
  52. polylines: Map<string, Point[]>;
  53. /** Steps per kind, for the panel's summary. */
  54. counts: Record<WireStep['kind'], number>;
  55. }
  56. /** Points a curve is sampled at for hit-testing (as the Screens view's). */
  57. const HIT_SAMPLES = 24;
  58. /* ---------------------------------------------------------------- words -- */
  59. /** A short word for a step's kind, as the panel and the legend say it. */
  60. export function kindWord(kind: WireStep['kind']): string {
  61. switch (kind) {
  62. case 'screen':
  63. return 'screen';
  64. case 'trigger':
  65. return 'handler';
  66. case 'bridge':
  67. return 'native call';
  68. case 'event':
  69. return 'native event';
  70. case 'store':
  71. return 'store action';
  72. case 'effect':
  73. return 'outside the index';
  74. default:
  75. return 'start';
  76. }
  77. }
  78. /** The first line of a step's box. Boundary crossings carry an arrow for which way the code goes. */
  79. export function stepLabel(step: WireStep): string {
  80. switch (step.kind) {
  81. case 'bridge':
  82. return `⇢ ${step.label}`;
  83. case 'event': {
  84. const events = step.events ?? (step.event ? [step.event] : []);
  85. if (events.length === 0) return `⇠ ${step.label}`;
  86. return events.length === 1 ? `⇠ ${events[0]}` : `⇠ ${events[0]} +${events.length - 1}`;
  87. }
  88. default:
  89. return step.label;
  90. }
  91. }
  92. /** The second line: what the step is, then where it is. */
  93. export function stepSub(step: WireStep): string {
  94. const file = step.node ? step.node.file.slice(step.node.file.lastIndexOf('/') + 1) : '';
  95. switch (step.kind) {
  96. case 'screen':
  97. return step.sub;
  98. case 'trigger':
  99. return `handler · ${file}`;
  100. case 'bridge':
  101. return `native · ${file}`;
  102. case 'event':
  103. return `${step.label} · ${file}`;
  104. case 'store':
  105. return `store · ${file}`;
  106. case 'effect':
  107. return step.sub;
  108. default:
  109. return step.sub;
  110. }
  111. }
  112. /* ---------------------------------------------------------------- build -- */
  113. export function buildStepsModel(payload: WireStepsPayload): StepsModel {
  114. const nodes = new Map<string, StepNodeInfo>();
  115. const modules: WireMapModule[] = [];
  116. const counts: Record<WireStep['kind'], number> = {
  117. anchor: 0,
  118. screen: 0,
  119. trigger: 0,
  120. bridge: 0,
  121. event: 0,
  122. store: 0,
  123. effect: 0,
  124. };
  125. const degree = new Map<string, number>();
  126. for (const link of payload.links) {
  127. degree.set(link.from, (degree.get(link.from) ?? 0) + 1);
  128. degree.set(link.to, (degree.get(link.to) ?? 0) + 1);
  129. }
  130. for (const step of payload.steps) {
  131. counts[step.kind]++;
  132. const info: StepNodeInfo = { id: step.id, step, label: stepLabel(step), sub: stepSub(step) };
  133. nodes.set(step.id, info);
  134. modules.push({
  135. id: step.id,
  136. label: info.label,
  137. files: 1,
  138. symbols: degree.get(step.id) ?? 0,
  139. languages: [],
  140. test: false,
  141. generated: 0,
  142. generatedFiles: [],
  143. facade: false,
  144. fileList: { total: 1, shown: 1, truncated: false, items: [step.node?.file ?? step.sub] },
  145. });
  146. }
  147. // One layout link per (from, to); the links behind it stay listed.
  148. const byPair = new Map<string, WireStepLink[]>();
  149. for (const link of payload.links) {
  150. if (!nodes.has(link.from) || !nodes.has(link.to) || link.from === link.to) continue;
  151. const key = linkId({ source: link.from, target: link.to });
  152. const list = byPair.get(key) ?? [];
  153. list.push(link);
  154. byPair.set(key, list);
  155. }
  156. const links: WireMapLink[] = [];
  157. const edges = new Map<string, StepEdgeInfo>();
  158. for (const [key, group] of byPair) {
  159. const first = group[0]!;
  160. links.push({
  161. source: first.from,
  162. target: first.to,
  163. count: group.length,
  164. declared: group.length,
  165. byKind: [{ kind: 'calls', count: group.length }],
  166. topPairs: [],
  167. });
  168. edges.set(key, {
  169. id: key,
  170. from: first.from,
  171. to: first.to,
  172. links: group,
  173. label: edgeLabel(group),
  174. synthesized: group.every((l) => l.synthesized),
  175. kind: group.every((l) => l.kind === first.kind) ? first.kind : 'calls',
  176. });
  177. }
  178. // Layer = distance from the anchor, counted by the server. Layer 0 is the
  179. // bottom, so the deepest row is 0 and the anchor is on top.
  180. const depthOf = new Map(payload.steps.map((s) => [s.id, s.depth]));
  181. const deepest = Math.max(0, ...payload.steps.map((s) => s.depth));
  182. const layering = (ids: string[]): Map<string, number> =>
  183. new Map(ids.map((id) => [id, deepest - (depthOf.get(id) ?? deepest)]));
  184. const layout = buildMapLayout(
  185. { modules, links },
  186. {
  187. includeTests: true,
  188. minWeight: 0,
  189. sizing: (m) => {
  190. const info = nodes.get(m.id);
  191. return { label: info?.label ?? m.id, meta: info?.sub ?? '' };
  192. },
  193. layering,
  194. layerGap: SCREEN_LAYER_GAP,
  195. portPitch: PORT_PITCH,
  196. ports: 'directional',
  197. }
  198. );
  199. const curves = trackedCurves(layout, SCREEN_LAYER_GAP);
  200. const polylines = new Map<string, Point[]>();
  201. for (const [id, curve] of curves) polylines.set(id, samplePolyline(curve, HIT_SAMPLES));
  202. return { layout, nodes, edges, layerGap: SCREEN_LAYER_GAP, curves, polylines, counts };
  203. }
  204. /** The side panel's two lists for a selected step. */
  205. export function stepNeighbourhood(
  206. payload: WireStepsPayload,
  207. id: string
  208. ): { arrivesFrom: WireStepLink[]; leadsTo: WireStepLink[] } {
  209. return {
  210. arrivesFrom: payload.links.filter((l) => l.to === id),
  211. leadsTo: payload.links.filter((l) => l.from === id),
  212. };
  213. }
  214. /** `useReviewHandlers → handleApproveAllImages`, or '' when nothing was folded. */
  215. export function stepViaText(link: WireStepLink): string {
  216. return link.via.map((v) => v.name).join(' → ');
  217. }
  218. /** The layout edge a link draws as, or null when it is a self-loop. */
  219. export function stepPairId(link: WireStepLink): string | null {
  220. return link.from === link.to ? null : linkId({ source: link.from, target: link.to });
  221. }