@coldtea/pr-lens-renderer 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/atlas.d.ts +2 -0
  2. package/dist/atlas.d.ts.map +1 -1
  3. package/dist/atlas.js +1 -1
  4. package/dist/atlas.js.map +1 -1
  5. package/dist/corrections.d.ts.map +1 -1
  6. package/dist/corrections.js +7 -1
  7. package/dist/corrections.js.map +1 -1
  8. package/dist/design.d.ts +25 -0
  9. package/dist/design.d.ts.map +1 -1
  10. package/dist/design.js +25 -0
  11. package/dist/design.js.map +1 -1
  12. package/dist/layout/architecture.d.ts +6 -2
  13. package/dist/layout/architecture.d.ts.map +1 -1
  14. package/dist/layout/architecture.js +12 -19
  15. package/dist/layout/architecture.js.map +1 -1
  16. package/dist/layout/datamodel.d.ts +70 -0
  17. package/dist/layout/datamodel.d.ts.map +1 -0
  18. package/dist/layout/datamodel.js +381 -0
  19. package/dist/layout/datamodel.js.map +1 -0
  20. package/dist/layout/edges.d.ts +8 -0
  21. package/dist/layout/edges.d.ts.map +1 -1
  22. package/dist/layout/edges.js +2 -2
  23. package/dist/layout/edges.js.map +1 -1
  24. package/dist/render.d.ts.map +1 -1
  25. package/dist/render.js +18 -1
  26. package/dist/render.js.map +1 -1
  27. package/dist/scope.d.ts +4 -1
  28. package/dist/scope.d.ts.map +1 -1
  29. package/dist/scope.js +28 -2
  30. package/dist/scope.js.map +1 -1
  31. package/dist/svg/architecture.d.ts +5 -2
  32. package/dist/svg/architecture.d.ts.map +1 -1
  33. package/dist/svg/architecture.js +10 -11
  34. package/dist/svg/architecture.js.map +1 -1
  35. package/dist/svg/datamodel.d.ts +11 -0
  36. package/dist/svg/datamodel.d.ts.map +1 -0
  37. package/dist/svg/datamodel.js +252 -0
  38. package/dist/svg/datamodel.js.map +1 -0
  39. package/dist/svg/icons.d.ts +2 -0
  40. package/dist/svg/icons.d.ts.map +1 -1
  41. package/dist/svg/icons.js +14 -0
  42. package/dist/svg/icons.js.map +1 -1
  43. package/dist/svg/styles.d.ts +3 -1
  44. package/dist/svg/styles.d.ts.map +1 -1
  45. package/dist/svg/styles.js +6 -0
  46. package/dist/svg/styles.js.map +1 -1
  47. package/dist/version.d.ts +1 -1
  48. package/dist/version.js +1 -1
  49. package/package.json +3 -3
  50. package/src/atlas.ts +3 -1
  51. package/src/corrections.ts +7 -1
  52. package/src/design.ts +26 -0
  53. package/src/layout/architecture.ts +16 -10
  54. package/src/layout/datamodel.ts +524 -0
  55. package/src/layout/edges.ts +2 -2
  56. package/src/render.ts +18 -1
  57. package/src/scope.ts +46 -3
  58. package/src/svg/architecture.ts +12 -13
  59. package/src/svg/datamodel.ts +344 -0
  60. package/src/svg/icons.ts +20 -0
  61. package/src/svg/styles.ts +8 -1
  62. package/src/version.ts +1 -1
@@ -0,0 +1,524 @@
1
+ import type { Relation, Store, Table, TableColumn } from "@coldtea/pr-lens-schema";
2
+ import {
3
+ CARD_HEIGHT_WITH_SUBTITLE,
4
+ CONTENT_TOP,
5
+ DIAGRAM_MARGIN,
6
+ ICON_MIN_CARD_WIDTH,
7
+ LANE_BOTTOM_PADDING,
8
+ LANE_CONTENT_WIDTH,
9
+ LANE_GAP,
10
+ LANE_PADDING_X,
11
+ LANE_TOP,
12
+ OVER_CLEARANCE,
13
+ OVER_PITCH,
14
+ PILL_CARD_CLEARANCE,
15
+ PORT_FAN_MAX,
16
+ ROW_GAP,
17
+ TABLE_FOLD_GAP,
18
+ TABLE_FOLD_HEIGHT,
19
+ TABLE_FOLD_MIN_HIDDEN,
20
+ TABLE_ROW_HEIGHT,
21
+ TABLE_ROWS_MAX,
22
+ TABLE_ROWS_PADDING_Y,
23
+ TABLE_UNCHANGED_ROWS_MAX,
24
+ TITLE_SIZE,
25
+ TITLE_SIZE_SMALL,
26
+ TRACK_CLEARANCE,
27
+ TRACK_PITCH_MAX,
28
+ TRACK_PITCH_MIN,
29
+ } from "../design.js";
30
+ import type { Box, Point } from "../geometry.js";
31
+ import type { ScopedGraph } from "../scope.js";
32
+ import { curveThrough, pathOf, type Curve } from "./edges.js";
33
+ import { pillSize } from "./labels.js";
34
+
35
+ export type PlacedColumn = { column: TableColumn; top: number; centre: number };
36
+
37
+ /** The closing row of a folded card: how many columns it does not draw. `top` is its dashed rule. */
38
+ export type PlacedFold = { hidden: number; unchangedOnly: boolean; top: number; centre: number };
39
+
40
+ export type PlacedTable = {
41
+ table: Table;
42
+ box: Box;
43
+ titleSize: number;
44
+ /** The rows the card draws; a folded card leaves some out. */
45
+ columns: PlacedColumn[];
46
+ fold: PlacedFold | undefined;
47
+ laneIndex: number;
48
+ };
49
+
50
+ export type PlacedStore = { store: Store; box: Box };
51
+
52
+ export type DataModelLayout = {
53
+ width: number;
54
+ height: number;
55
+ stores: PlacedStore[];
56
+ tables: PlacedTable[];
57
+ /** Corridor `i` runs to the left of lane `i`; one more lies after the last lane. */
58
+ corridors: { left: number; right: number }[];
59
+ /** Where the lanes start; routes that skip a lane cross above it. */
60
+ laneTop: number;
61
+ };
62
+
63
+ export type ColumnFold = { shown: TableColumn[]; hidden: number; unchangedOnly: boolean };
64
+
65
+ /**
66
+ * Which columns a card draws. A key, a changed column, and the end of a key
67
+ * in scope are the reason the table is in the picture, so they stay; the
68
+ * unchanged rest is sampled up to a few; and the card never passes the
69
+ * ceiling. What is left out is counted, never silently dropped: the fold row
70
+ * says so, and the subtitle keeps the true count.
71
+ */
72
+ export const foldColumns = (table: Table, relations: readonly Relation[]): ColumnFold => {
73
+ const ends = new Set(
74
+ relations.flatMap(({ from, to }) => [from, to]).filter((end) => end.table === table.id).map((end) => end.column),
75
+ );
76
+ const tier = (column: TableColumn): 0 | 1 | 2 =>
77
+ column.key !== undefined || ends.has(column.name) ? 0 : column.delta !== "unchanged" ? 1 : 2;
78
+
79
+ const keep = new Set<TableColumn>();
80
+ let unchanged = 0;
81
+ for (const wanted of [0, 1, 2] as const)
82
+ for (const column of table.columns) {
83
+ if (keep.size >= TABLE_ROWS_MAX) break;
84
+ if (tier(column) !== wanted) continue;
85
+ if (wanted === 2 && unchanged >= TABLE_UNCHANGED_ROWS_MAX) break;
86
+ if (wanted === 2) unchanged += 1;
87
+ keep.add(column);
88
+ }
89
+
90
+ const hidden = table.columns.length - keep.size;
91
+ if (hidden < TABLE_FOLD_MIN_HIDDEN) return { shown: [...table.columns], hidden: 0, unchangedOnly: true };
92
+ return {
93
+ shown: table.columns.filter((column) => keep.has(column)),
94
+ hidden,
95
+ unchangedOnly: table.columns.every((column) => keep.has(column) || column.delta === "unchanged"),
96
+ };
97
+ };
98
+
99
+ export const tableHeight = (fold: ColumnFold): number =>
100
+ CARD_HEIGHT_WITH_SUBTITLE +
101
+ TABLE_ROWS_PADDING_Y * 2 +
102
+ fold.shown.length * TABLE_ROW_HEIGHT +
103
+ (fold.hidden > 0 ? TABLE_FOLD_HEIGHT : 0);
104
+
105
+ /**
106
+ * Lane order. A document that sets `order` on any store is honoured exactly:
107
+ * that is the author's intent, and a rename must never slide a lane. With no
108
+ * order given, the store most keys point into goes in the middle, so the
109
+ * routes reach it from both sides instead of all climbing over the top, and
110
+ * the rest keep their written order around it.
111
+ */
112
+ const orderStores = (graph: ScopedGraph): Store[] => {
113
+ const { stores } = graph;
114
+ if (stores.some((store) => store.order !== undefined))
115
+ return stores
116
+ .map((store, index) => ({ store, index }))
117
+ .sort(
118
+ (a, b) =>
119
+ (a.store.order ?? Number.MAX_SAFE_INTEGER) - (b.store.order ?? Number.MAX_SAFE_INTEGER) ||
120
+ a.index - b.index,
121
+ )
122
+ .map(({ store }) => store);
123
+
124
+ const storeOf = new Map(graph.tables.map((table) => [table.id, table.store]));
125
+ const incoming = new Map<string, number>();
126
+ for (const { from, to } of graph.relations) {
127
+ const target = storeOf.get(to.table);
128
+ if (target === undefined || target === storeOf.get(from.table)) continue;
129
+ incoming.set(target, (incoming.get(target) ?? 0) + 1);
130
+ }
131
+ const hub = stores.reduce<Store | undefined>(
132
+ (best, store) => (best === undefined || (incoming.get(store.id) ?? 0) > (incoming.get(best.id) ?? 0) ? store : best),
133
+ undefined,
134
+ );
135
+ if (hub === undefined || (incoming.get(hub.id) ?? 0) === 0) return [...stores];
136
+
137
+ const rest = stores.filter((store) => store !== hub);
138
+ const middle = Math.floor((stores.length - 1) / 2);
139
+ return [...rest.slice(0, middle), hub, ...rest.slice(middle)];
140
+ };
141
+
142
+ const laneOf = (tables: readonly Table[], stores: readonly Store[]): Map<string, number> => {
143
+ const index = new Map(stores.map((store, at) => [store.id, at]));
144
+ return new Map(tables.map((table) => [table.id, index.get(table.store) ?? 0]));
145
+ };
146
+
147
+ const GUTTER = LANE_PADDING_X * 2 + LANE_GAP;
148
+
149
+ /**
150
+ * Several keys landing on one column from one side merge their last leg into
151
+ * a trunk: one track in the corridor beside the target, one arrowhead on the
152
+ * port. Up to three arrivals still read as separate heads fanned a few pixels
153
+ * apart; from four the heads wedge, so four is where a trunk starts. Tones
154
+ * never merge: a new key and an unchanged one on the same port are two trunks.
155
+ */
156
+ const TRUNK_MIN = 4;
157
+
158
+ const trunkKey = (relation: Relation, toSide: Side): string =>
159
+ `${relation.to.table}\u0000${relation.to.column}\u0000${toSide}\u0000${relation.delta}`;
160
+
161
+ /**
162
+ * What the routes ask of the gaps before anything is placed: how many cross
163
+ * above the lanes, and how many legs each corridor carries. Both follow from
164
+ * lane indices alone, so the lanes can be placed around them. A trunk counts
165
+ * once in the corridor it gathers in.
166
+ */
167
+ const traffic = (
168
+ graph: ScopedGraph,
169
+ lanes: ReadonlyMap<string, number>,
170
+ last: number,
171
+ ): { skips: number; legs: Map<number, number> } => {
172
+ const planned = graph.relations.map((relation) => ({
173
+ relation,
174
+ lanes: planLanes(lanes.get(relation.from.table) ?? 0, lanes.get(relation.to.table) ?? 0, last, relation.label),
175
+ }));
176
+ const trunkSizes = new Map<string, number>();
177
+ for (const { relation, lanes: route } of planned) {
178
+ const key = trunkKey(relation, route.toSide);
179
+ trunkSizes.set(key, (trunkSizes.get(key) ?? 0) + 1);
180
+ }
181
+
182
+ const legs = new Map<number, number>();
183
+ const counted = new Set<string>();
184
+ let skips = 0;
185
+ for (const { relation, lanes: route } of planned) {
186
+ if (route.over) skips += 1;
187
+ const key = trunkKey(relation, route.toSide);
188
+ const trunked = (trunkSizes.get(key) ?? 0) >= TRUNK_MIN;
189
+ route.corridors.forEach((corridor, index) => {
190
+ if (trunked && index === route.corridors.length - 1) {
191
+ if (counted.has(key)) return;
192
+ counted.add(key);
193
+ }
194
+ legs.set(corridor, (legs.get(corridor) ?? 0) + 1);
195
+ });
196
+ }
197
+ return { skips, legs };
198
+ };
199
+
200
+ /** Room a corridor needs beyond the gutter to hold its legs at the floor pitch. */
201
+ const corridorExtra = (legs: number): number =>
202
+ Math.max(0, TRACK_CLEARANCE * 2 + TRACK_PITCH_MIN * (legs - 1) - GUTTER);
203
+
204
+ export const layoutDataModel = (graph: ScopedGraph): DataModelLayout => {
205
+ const stores = orderStores(graph);
206
+ const lanes = laneOf(graph.tables, stores);
207
+ const { skips, legs } = traffic(graph, lanes, stores.length - 1);
208
+ const overBand = skips === 0 ? 0 : skips * OVER_PITCH + OVER_CLEARANCE;
209
+ const laneTop = LANE_TOP + overBand;
210
+ const contentTop = CONTENT_TOP + overBand;
211
+ const laneBoxWidth = LANE_CONTENT_WIDTH + LANE_PADDING_X * 2;
212
+ const extra = (corridor: number): number => corridorExtra(legs.get(corridor) ?? 0);
213
+
214
+ // Lane `i` starts after corridor `i`, and a corridor fuller than the gutter holds pushes it along.
215
+ const laneLefts: number[] = [];
216
+ let laneX = DIAGRAM_MARGIN;
217
+ stores.forEach((_, laneIndex) => {
218
+ laneX += extra(laneIndex);
219
+ laneLefts.push(laneX);
220
+ laneX += laneBoxWidth + LANE_GAP;
221
+ });
222
+ const contentRight = laneX - LANE_GAP + extra(stores.length);
223
+
224
+ const tables: PlacedTable[] = [];
225
+ let laneBottom = contentTop;
226
+ stores.forEach((store, laneIndex) => {
227
+ const x = (laneLefts[laneIndex] ?? DIAGRAM_MARGIN) + LANE_PADDING_X;
228
+ let y = contentTop;
229
+ for (const table of graph.tables) {
230
+ if (table.store !== store.id) continue;
231
+ const fold = foldColumns(table, graph.relations);
232
+ const box = { x, y, width: LANE_CONTENT_WIDTH, height: tableHeight(fold) };
233
+ const rowsTop = y + CARD_HEIGHT_WITH_SUBTITLE + TABLE_ROWS_PADDING_Y;
234
+ const foldTop = rowsTop + fold.shown.length * TABLE_ROW_HEIGHT + TABLE_FOLD_GAP;
235
+ tables.push({
236
+ table,
237
+ box,
238
+ titleSize: LANE_CONTENT_WIDTH >= ICON_MIN_CARD_WIDTH ? TITLE_SIZE : TITLE_SIZE_SMALL,
239
+ columns: fold.shown.map((column, index) => ({
240
+ column,
241
+ top: rowsTop + index * TABLE_ROW_HEIGHT,
242
+ centre: rowsTop + index * TABLE_ROW_HEIGHT + TABLE_ROW_HEIGHT / 2,
243
+ })),
244
+ fold:
245
+ fold.hidden === 0
246
+ ? undefined
247
+ : { hidden: fold.hidden, unchangedOnly: fold.unchangedOnly, top: foldTop, centre: foldTop + TABLE_ROW_HEIGHT / 2 },
248
+ laneIndex,
249
+ });
250
+ y += box.height + ROW_GAP;
251
+ }
252
+ laneBottom = Math.max(laneBottom, y - ROW_GAP);
253
+ });
254
+ laneBottom += LANE_BOTTOM_PADDING;
255
+
256
+ const placedStores = stores.map((store, index) => ({
257
+ store,
258
+ box: {
259
+ x: laneLefts[index] ?? DIAGRAM_MARGIN,
260
+ y: laneTop,
261
+ width: laneBoxWidth,
262
+ height: laneBottom - laneTop,
263
+ },
264
+ }));
265
+
266
+ const corridors = placedStores.map(({ box }, index) => ({
267
+ left: box.x + LANE_PADDING_X - GUTTER - extra(index),
268
+ right: box.x + LANE_PADDING_X,
269
+ }));
270
+ const lastContentRight = (placedStores[placedStores.length - 1]?.box.x ?? DIAGRAM_MARGIN) + LANE_PADDING_X + LANE_CONTENT_WIDTH;
271
+ corridors.push({ left: lastContentRight, right: lastContentRight + GUTTER + extra(stores.length) });
272
+
273
+ return {
274
+ width: Math.ceil(contentRight + DIAGRAM_MARGIN),
275
+ height: Math.ceil(laneBottom + DIAGRAM_MARGIN),
276
+ stores: placedStores,
277
+ tables,
278
+ corridors,
279
+ laneTop,
280
+ };
281
+ };
282
+
283
+ export type RoutedRelation = {
284
+ relation: Relation;
285
+ path: string;
286
+ curve: Curve;
287
+ /** The corners the route turns at, before they are rounded. */
288
+ waypoints: Point[];
289
+ /** Where the label pill sits, when the relation carries one. */
290
+ pill: Box | undefined;
291
+ /** Whether this route draws the arrowhead; a trunk's members share one. */
292
+ head: boolean;
293
+ };
294
+
295
+ type Plan = {
296
+ relation: Relation;
297
+ from: { x: number; y: number; side: Side };
298
+ to: { x: number; y: number; side: Side };
299
+ /** Corridors the route runs a vertical leg in, source side first. */
300
+ corridors: number[];
301
+ over: number | undefined;
302
+ };
303
+
304
+ type Port = { table: PlacedTable; row: PlacedColumn };
305
+
306
+ const portOf = (layout: DataModelLayout, table: string, column: string): Port | undefined => {
307
+ const placed = layout.tables.find((candidate) => candidate.table.id === table);
308
+ const row = placed?.columns.find((candidate) => candidate.column.name === column);
309
+ return placed === undefined || row === undefined ? undefined : { table: placed, row };
310
+ };
311
+
312
+ const faceX = (box: Box, side: Side): number => (side === "left" ? box.x : box.x + box.width);
313
+
314
+ /** Whether a corridor's run could carry a pill of this width without the pill crossing the cards either side. */
315
+ const pillFitsCorridor = (label: string | undefined): boolean =>
316
+ label === undefined || pillSize(label).width + PILL_CARD_CLEARANCE * 2 <= GUTTER;
317
+
318
+ type Side = "left" | "right";
319
+
320
+ type LanePlan = {
321
+ fromSide: Side;
322
+ toSide: Side;
323
+ /** Corridors the route runs a vertical leg in, source side first. */
324
+ corridors: number[];
325
+ over: boolean;
326
+ };
327
+
328
+ /**
329
+ * Where a route goes, from lane indices alone. A neighbouring lane is reached
330
+ * through the corridor between; the same lane steps out into the corridor
331
+ * beside it and back; a lane further away is crossed above the lanes.
332
+ *
333
+ * A labelled relation whose pill would not fit a corridor also crosses above,
334
+ * entering its target from the far face so the top run is a lane wide: the
335
+ * one straight run long enough to carry its words clear of every card.
336
+ */
337
+ const planLanes = (la: number, lb: number, last: number, label: string | undefined): LanePlan => {
338
+ const lift = !pillFitsCorridor(label);
339
+
340
+ if (la === lb) {
341
+ // ponytail: the corridor to the right, or the left for the last lane; a one-lane
342
+ // diagram with many self-keys gets a busy corridor, widen it when that shows.
343
+ const corridor = la < last ? la + 1 : la;
344
+ const side: Side = corridor === la + 1 ? "right" : "left";
345
+ if (!lift) return { fromSide: side, toSide: side, corridors: [corridor], over: false };
346
+ const farSide: Side = side === "right" ? "left" : "right";
347
+ return { fromSide: side, toSide: farSide, corridors: [corridor, farSide === "left" ? lb : lb + 1], over: true };
348
+ }
349
+
350
+ const rightward = la < lb;
351
+ const fromSide: Side = rightward ? "right" : "left";
352
+ const nearSide: Side = rightward ? "left" : "right";
353
+
354
+ if (Math.abs(la - lb) === 1 && !lift)
355
+ return { fromSide, toSide: nearSide, corridors: [Math.max(la, lb)], over: false };
356
+ if (Math.abs(la - lb) === 1)
357
+ return { fromSide, toSide: fromSide, corridors: rightward ? [la + 1, lb + 1] : [la, lb], over: true };
358
+ return { fromSide, toSide: nearSide, corridors: rightward ? [la + 1, lb] : [la, lb + 1], over: true };
359
+ };
360
+
361
+ const plan = (layout: DataModelLayout, relation: Relation): Plan | undefined => {
362
+ const from = portOf(layout, relation.from.table, relation.from.column);
363
+ const to = portOf(layout, relation.to.table, relation.to.column);
364
+ if (from === undefined || to === undefined) return undefined;
365
+
366
+ const lanes = planLanes(from.table.laneIndex, to.table.laneIndex, layout.stores.length - 1, relation.label);
367
+ const end = (port: Port, side: Side) => ({ x: faceX(port.table.box, side), y: port.row.centre, side });
368
+ return {
369
+ relation,
370
+ from: end(from, lanes.fromSide),
371
+ to: end(to, lanes.toSide),
372
+ corridors: lanes.corridors,
373
+ over: lanes.over ? 0 : undefined,
374
+ };
375
+ };
376
+
377
+ const spread = (left: number, right: number, count: number): number[] => {
378
+ // The layout widened the corridor for its legs, so the pitch never falls below the floor here.
379
+ const room = right - left - TRACK_CLEARANCE * 2;
380
+ const pitch = count < 2 ? 0 : Math.min(TRACK_PITCH_MAX, room / (count - 1));
381
+ const start = (left + right) / 2 - (pitch * (count - 1)) / 2;
382
+ return Array.from({ length: count }, (_, index) => start + index * pitch);
383
+ };
384
+
385
+ /**
386
+ * A pill is horizontal, so it sits on a horizontal run: the top run of a
387
+ * route that crosses above the lanes, whatever its other legs measure, and
388
+ * otherwise the longest level leg. A corridor route with no level run that
389
+ * holds the pill is what sent the route over the top in the first place.
390
+ */
391
+ const pillAnchor = (waypoints: readonly Point[], over: boolean): Point => {
392
+ const legs = waypoints.flatMap((point, index) => {
393
+ const previous = waypoints[index - 1];
394
+ return previous === undefined || previous.y !== point.y ? [] : [{ from: previous, to: point }];
395
+ });
396
+ const chosen = legs.reduce<{ from: Point; to: Point } | undefined>((best, leg) => {
397
+ if (best === undefined) return leg;
398
+ if (over) return leg.from.y < best.from.y ? leg : best;
399
+ return Math.abs(leg.to.x - leg.from.x) > Math.abs(best.to.x - best.from.x) ? leg : best;
400
+ }, undefined);
401
+ const first = waypoints[0] ?? { x: 0, y: 0 };
402
+ return chosen === undefined ? first : { x: (chosen.from.x + chosen.to.x) / 2, y: chosen.from.y };
403
+ };
404
+
405
+ export const routeRelations = (
406
+ layout: DataModelLayout,
407
+ relations: readonly Relation[],
408
+ ): RoutedRelation[] => {
409
+ const plans = relations.flatMap((relation) => {
410
+ const planned = plan(layout, relation);
411
+ return planned === undefined ? [] : [planned];
412
+ });
413
+
414
+ // A trunk is every route that lands on one column from one side in one tone, from TRUNK_MIN up.
415
+ const trunkOf = new Map<Plan, string>();
416
+ {
417
+ const groups = new Map<string, Plan[]>();
418
+ for (const route of plans) {
419
+ const key = trunkKey(route.relation, route.to.side);
420
+ groups.set(key, [...(groups.get(key) ?? []), route]);
421
+ }
422
+ for (const [key, group] of groups)
423
+ if (group.length >= TRUNK_MIN) for (const route of group) trunkOf.set(route, key);
424
+ }
425
+ /** A trunk's routes share a track and a landing, so they are one unit where they gather. */
426
+ const unitOf = (route: Plan, leg: number): string | Plan =>
427
+ leg === route.corridors.length - 1 ? (trunkOf.get(route) ?? route) : route;
428
+
429
+ // What arrives on one column fans out a few pixels so each arrival keeps its own head.
430
+ const byTarget = new Map<string, (string | Plan)[]>();
431
+ for (const route of plans) {
432
+ const key = `${route.relation.to.table}\u0000${route.relation.to.column}`;
433
+ const unit = trunkOf.get(route) ?? route;
434
+ const units = byTarget.get(key) ?? [];
435
+ if (!units.includes(unit)) byTarget.set(key, [...units, unit]);
436
+ }
437
+ const firstY = (unit: string | Plan): number =>
438
+ typeof unit === "string"
439
+ ? Math.min(...plans.filter((route) => trunkOf.get(route) === unit).map((route) => route.from.y))
440
+ : unit.from.y;
441
+ const landing = new Map<Plan, number>();
442
+ for (const units of byTarget.values()) {
443
+ const sorted = [...units].sort((a, b) => firstY(a) - firstY(b));
444
+ const pitch = Math.min(PORT_FAN_MAX, (TABLE_ROW_HEIGHT - 2) / Math.max(1, sorted.length - 1));
445
+ sorted.forEach((unit, index) => {
446
+ for (const route of plans)
447
+ if ((trunkOf.get(route) ?? route) === unit)
448
+ landing.set(route, route.to.y + (index - (sorted.length - 1) / 2) * pitch);
449
+ });
450
+ }
451
+
452
+ // One track per vertical leg a corridor carries, ordered by where the leg lands.
453
+ const tracks = new Map<Plan, number[]>();
454
+ layout.corridors.forEach((corridor, index) => {
455
+ const units: (string | Plan)[] = [];
456
+ const members: { route: Plan; leg: number; unit: string | Plan }[] = [];
457
+ plans.forEach((route) =>
458
+ route.corridors.forEach((at, leg) => {
459
+ if (at !== index) return;
460
+ const unit = unitOf(route, leg);
461
+ if (!units.includes(unit)) units.push(unit);
462
+ members.push({ route, leg, unit });
463
+ }),
464
+ );
465
+ const landingOf = (unit: string | Plan): number =>
466
+ typeof unit === "string"
467
+ ? (landing.get(plans.find((route) => trunkOf.get(route) === unit) ?? plans[0]!) ?? 0)
468
+ : (landing.get(unit) ?? 0);
469
+ units.sort((a, b) => landingOf(a) - landingOf(b) || firstY(a) - firstY(b));
470
+ const xs = spread(corridor.left, corridor.right, units.length);
471
+ for (const { route, leg, unit } of members) {
472
+ const own = tracks.get(route) ?? [];
473
+ own[leg] = xs[units.indexOf(unit)] ?? (corridor.left + corridor.right) / 2;
474
+ tracks.set(route, own);
475
+ }
476
+ });
477
+
478
+ // One head per trunk: the member that lands nearest its port carries it.
479
+ const heads = new Set<Plan>();
480
+ for (const route of plans) {
481
+ const trunk = trunkOf.get(route);
482
+ if (trunk === undefined) {
483
+ heads.add(route);
484
+ continue;
485
+ }
486
+ const members = plans.filter((candidate) => trunkOf.get(candidate) === trunk);
487
+ const nearest = members.reduce((best, candidate) =>
488
+ Math.abs(candidate.from.y - candidate.to.y) < Math.abs(best.from.y - best.to.y) ? candidate : best,
489
+ );
490
+ heads.add(nearest);
491
+ }
492
+
493
+ let overCount = 0;
494
+ return plans.map((route): RoutedRelation => {
495
+ const [first, second] = tracks.get(route) ?? [];
496
+ const x1 = first ?? route.from.x;
497
+ const landY = landing.get(route) ?? route.to.y;
498
+ const start = { x: route.from.x, y: route.from.y };
499
+ const end = { x: route.to.x, y: landY };
500
+
501
+ const waypoints: Point[] =
502
+ route.over === undefined
503
+ ? [start, { x: x1, y: start.y }, { x: x1, y: end.y }, end]
504
+ : (() => {
505
+ const top = layout.laneTop - OVER_CLEARANCE - overCount * OVER_PITCH;
506
+ overCount += 1;
507
+ const x2 = second ?? route.to.x;
508
+ return [start, { x: x1, y: start.y }, { x: x1, y: top }, { x: x2, y: top }, { x: x2, y: end.y }, end];
509
+ })();
510
+
511
+ const curve = curveThrough(waypoints);
512
+ const label = route.relation.label;
513
+ const pill =
514
+ label === undefined
515
+ ? undefined
516
+ : (() => {
517
+ const size = pillSize(label);
518
+ const anchor = pillAnchor(waypoints, route.over !== undefined);
519
+ return { x: anchor.x - size.width / 2, y: anchor.y - size.height / 2, ...size };
520
+ })();
521
+
522
+ return { relation: route.relation, path: pathOf(curve), curve, waypoints, pill, head: heads.has(route) };
523
+ });
524
+ };
@@ -63,7 +63,7 @@ const pointAt = (curve: Curve, t: number): Point => {
63
63
  return pointOnSegment(segmentStart(curve, index), segment, scaled - index);
64
64
  };
65
65
 
66
- const pathOf = (curve: Curve): string =>
66
+ export const pathOf = (curve: Curve): string =>
67
67
  `M${coord(curve.from.x)},${coord(curve.from.y)}` +
68
68
  curve.segments
69
69
  .map((segment) => {
@@ -548,7 +548,7 @@ const KAPPA = 0.5523;
548
548
  * the radius from the longer leg is the known failure — it balloons a route
549
549
  * with one short leg clear out of the corridor the planner put it in.
550
550
  */
551
- const curveThrough = (points: readonly Point[]): Curve => {
551
+ export const curveThrough = (points: readonly Point[]): Curve => {
552
552
  const first = points[0] ?? { x: 0, y: 0 };
553
553
  const segments: Segment[] = [];
554
554
 
package/src/render.ts CHANGED
@@ -8,6 +8,7 @@ import { findView, flattenViews, resolveScope, type ScopedGraph } from "./scope.
8
8
  import { paletteFor, THEME_PAIR, type Theme } from "./theme.js";
9
9
  import { paintArchitecture } from "./svg/architecture.js";
10
10
  import { paintDataFlow } from "./svg/dataflow.js";
11
+ import { paintDataModel } from "./svg/datamodel.js";
11
12
  import { svgDocument } from "./svg/document.js";
12
13
 
13
14
  export type RenderOptions = {
@@ -60,6 +61,11 @@ const paint = (
60
61
  animated: graph.flows.some((flow) => flow.messages.some((message) => message.animated)),
61
62
  };
62
63
  }
64
+ case "data-model": {
65
+ if (graph.tables.length === 0)
66
+ throw new PrLensRenderError("NOTHING_TO_RENDER", "no tables are in scope for this view");
67
+ return { ...paintDataModel(graph, palette), animated: false };
68
+ }
63
69
  default:
64
70
  return assertNever(lens, "Unhandled lens");
65
71
  }
@@ -167,7 +173,18 @@ export const renderAll = (doc: GraphDoc, options: RenderAllOptions = {}): Render
167
173
  .filter((view) => prepared.lenses.includes(view.lens))
168
174
  .map((view) => ({ lens: view.lens, view }))
169
175
  : prepared.lenses
170
- .filter((lens) => lens !== "data-flow" || prepared.flows.length > 0)
176
+ .filter((lens) => {
177
+ switch (lens) {
178
+ case "architecture":
179
+ return true;
180
+ case "data-flow":
181
+ return prepared.flows.length > 0;
182
+ case "data-model":
183
+ return prepared.dataModel !== undefined;
184
+ default:
185
+ return assertNever(lens, "Unhandled lens");
186
+ }
187
+ })
171
188
  .map((lens) => ({ lens, view: undefined }));
172
189
 
173
190
  /**
package/src/scope.ts CHANGED
@@ -1,4 +1,15 @@
1
- import type { Flow, GraphDoc, GraphEdge, GraphNode, Lane, View, ViewScope } from "@coldtea/pr-lens-schema";
1
+ import type {
2
+ Flow,
3
+ GraphDoc,
4
+ GraphEdge,
5
+ GraphNode,
6
+ Lane,
7
+ Relation,
8
+ Store,
9
+ Table,
10
+ View,
11
+ ViewScope,
12
+ } from "@coldtea/pr-lens-schema";
2
13
  import { assertNever } from "@coldtea/pr-lens-schema";
3
14
 
4
15
  /** The slice of a document one SVG draws. */
@@ -7,6 +18,26 @@ export type ScopedGraph = {
7
18
  nodes: GraphNode[];
8
19
  edges: GraphEdge[];
9
20
  flows: Flow[];
21
+ stores: Store[];
22
+ tables: Table[];
23
+ relations: Relation[];
24
+ };
25
+
26
+ /** The tables a selection keeps, with the stores they sit in and the relations that stay whole. */
27
+ const scopeModel = (
28
+ doc: GraphDoc,
29
+ tableIds: ReadonlySet<string> | undefined,
30
+ ): Pick<ScopedGraph, "stores" | "tables" | "relations"> => {
31
+ const model = doc.dataModel;
32
+ if (model === undefined) return { stores: [], tables: [], relations: [] };
33
+ const tables = tableIds === undefined ? [...model.tables] : model.tables.filter((table) => tableIds.has(table.id));
34
+ const kept = new Set(tables.map((table) => table.id));
35
+ const storeIds = new Set(tables.map((table) => table.store));
36
+ return {
37
+ stores: model.stores.filter((store) => storeIds.has(store.id)),
38
+ tables,
39
+ relations: model.relations.filter(({ from, to }) => kept.has(from.table) && kept.has(to.table)),
40
+ };
10
41
  };
11
42
 
12
43
  export const findView = (views: readonly View[], id: string): View | undefined => {
@@ -33,7 +64,13 @@ export const flattenViews = (views: readonly View[]): View[] =>
33
64
  export const resolveScope = (doc: GraphDoc, scope: ViewScope): ScopedGraph => {
34
65
  switch (scope.kind) {
35
66
  case "all":
36
- return { lanes: [...doc.lanes], nodes: [...doc.nodes], edges: [...doc.edges], flows: [...doc.flows] };
67
+ return {
68
+ lanes: [...doc.lanes],
69
+ nodes: [...doc.nodes],
70
+ edges: [...doc.edges],
71
+ flows: [...doc.flows],
72
+ ...scopeModel(doc, undefined),
73
+ };
37
74
  case "selection": {
38
75
  const selectedLanes = new Set(scope.lanes);
39
76
  const selectedEdges = new Set(scope.edges);
@@ -59,7 +96,13 @@ export const resolveScope = (doc: GraphDoc, scope: ViewScope): ScopedGraph => {
59
96
  const laneIds = new Set<string>(scope.lanes);
60
97
  for (const node of nodes) laneIds.add(node.lane);
61
98
 
62
- return { lanes: doc.lanes.filter((lane) => laneIds.has(lane.id)), nodes, edges, flows };
99
+ return {
100
+ lanes: doc.lanes.filter((lane) => laneIds.has(lane.id)),
101
+ nodes,
102
+ edges,
103
+ flows,
104
+ ...scopeModel(doc, new Set(scope.tables)),
105
+ };
63
106
  }
64
107
  default:
65
108
  return assertNever(scope, "Unhandled view scope");