@tanishqkancharla/diffmap 0.3.0 → 0.6.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.
@@ -0,0 +1,1129 @@
1
+ import {
2
+ type ReactElement,
3
+ useId,
4
+ useLayoutEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from "react";
9
+ import { motion } from "motion/react";
10
+ import {
11
+ Text,
12
+ Tooltip,
13
+ backgroundColor,
14
+ colors,
15
+ flex,
16
+ focusRing,
17
+ radius,
18
+ spacing,
19
+ text,
20
+ } from "maui";
21
+ import { style, useStyles } from "purse-styles";
22
+ import type { SourceNavigation } from "../annotations.js";
23
+ import {
24
+ flattenServices,
25
+ serviceStateAt,
26
+ type Service,
27
+ type ServiceDiagram,
28
+ type ServiceEvent,
29
+ type ServiceUpdate,
30
+ type Sign,
31
+ } from "../services.js";
32
+ import {
33
+ layoutServices,
34
+ linkId,
35
+ pillText,
36
+ serviceLinks,
37
+ type Point,
38
+ type Rect,
39
+ type ServiceLayout,
40
+ type ServiceLink,
41
+ type Size,
42
+ } from "./serviceLayout.js";
43
+
44
+ export const TRAVEL_MS = 750;
45
+ const NOTE_WIDTH = 200;
46
+ const NOTE_GAP = 28;
47
+
48
+ /** A step's note and the element it explains: an event label or a state row. */
49
+ type StepNote = {
50
+ id: string;
51
+ text: string;
52
+ target: string;
53
+ /** For a state row, the box whose right edge the leader starts from. */
54
+ service: string | undefined;
55
+ };
56
+
57
+ type State = Map<string, Map<string, string>>;
58
+
59
+ /** State replayed on each side of a diff; the sides match outside a diff. */
60
+ export type SidedState = { today: State; proposed: State };
61
+
62
+ type Values = { today: Map<string, string>; proposed: Map<string, string> };
63
+
64
+ export type StageProps = {
65
+ diagram: ServiceDiagram;
66
+ state: SidedState;
67
+ previousState: SidedState;
68
+ changed: Set<string>;
69
+ active: Set<string>;
70
+ events: ServiceEvent[];
71
+ updates: ServiceUpdate[];
72
+ phase: "travel" | "settled";
73
+ stepKey: number;
74
+ } & SourceNavigation;
75
+
76
+ /**
77
+ * Lays the services out with ELK, then draws boxes, routes, and the current
78
+ * step's events on top. A hidden copy of every box, filled with its widest
79
+ * values, gives ELK sizes that hold for every step, so nothing moves while
80
+ * the diagram plays.
81
+ */
82
+ export function ServiceStage(props: StageProps) {
83
+ const diagram = props.diagram;
84
+ const flat = useMemo(() => flattenServices(diagram.services), [diagram]);
85
+ const links = useMemo(() => serviceLinks(diagram), [diagram]);
86
+ const widest = useMemo(() => widestState(diagram), [diagram]);
87
+ const outer = useRef<HTMLDivElement>(null);
88
+ const measure = useRef<HTMLDivElement>(null);
89
+ const [placed, setPlaced] = useState<{
90
+ layout: ServiceLayout;
91
+ /** Each link's widest label pill. */
92
+ labels: Map<string, Size>;
93
+ scale: number;
94
+ /** Where the canvas starts, so the diagram is centered. */
95
+ left: number;
96
+ /** Notes stack under the diagram when no column beside it fits. */
97
+ notesBelow: boolean;
98
+ }>();
99
+ // Diagrams with notes keep room for them at every step, so the diagram
100
+ // doesn't move when notes come and go.
101
+ const gutter = useMemo(
102
+ () =>
103
+ diagram.steps.some(
104
+ (step) =>
105
+ step.events.some((event) => event.note !== undefined) ||
106
+ step.updates.some((update) => update.note !== undefined),
107
+ )
108
+ ? NOTE_WIDTH + NOTE_GAP * 2
109
+ : 0,
110
+ [diagram],
111
+ );
112
+ const markerId = useId();
113
+
114
+ useLayoutEffect(() => {
115
+ let latest = 0;
116
+ let measuredWidth = -1;
117
+ const place = async (force: boolean) => {
118
+ const available = outer.current!.clientWidth;
119
+ if (!force && available === measuredWidth) return;
120
+ measuredWidth = available;
121
+ const run = ++latest;
122
+ const sizes = new Map(
123
+ [...measure.current!.querySelectorAll("[data-measure-service]")].map(
124
+ (element) => [
125
+ element.getAttribute("data-measure-service")!,
126
+ sizeOf(element),
127
+ ],
128
+ ),
129
+ );
130
+ const label = { width: 0, height: 0 };
131
+ const labels = new Map<string, Size>();
132
+ for (const element of measure.current!.querySelectorAll(
133
+ "[data-measure-link]",
134
+ )) {
135
+ const size = sizeOf(element);
136
+ labels.set(element.getAttribute("data-measure-link")!, size);
137
+ label.width = Math.max(label.width, size.width);
138
+ label.height = Math.max(label.height, size.height);
139
+ }
140
+ const input = { diagram, links, sizes, label };
141
+ const fits = (layout: ServiceLayout) =>
142
+ layout.width + gutter <= available;
143
+ // Left to right reads best; go top to bottom when that fits better.
144
+ const right = await layoutServices({ ...input, direction: "RIGHT" });
145
+ const down = fits(right)
146
+ ? right
147
+ : await layoutServices({ ...input, direction: "DOWN" });
148
+ const best = down.width < right.width ? down : right;
149
+ const scale = Math.min(1, available / best.width);
150
+ const width = best.width * scale;
151
+ const left = (available - width) / 2;
152
+ // The diagram stays centered. Notes sit beside it when the space on its
153
+ // right holds them, and underneath otherwise, so the diagram never
154
+ // shrinks or shifts to make room.
155
+ if (run === latest)
156
+ setPlaced({
157
+ layout: best,
158
+ labels,
159
+ scale,
160
+ left,
161
+ notesBelow: left < gutter,
162
+ });
163
+ };
164
+ // oxlint-disable-next-line typescript/no-floating-promises -- The effect owns the layout result.
165
+ void place(true);
166
+ // oxlint-disable-next-line typescript/no-floating-promises -- Web fonts change measured sizes.
167
+ void document.fonts.ready.then(() => place(true));
168
+ const observer = new ResizeObserver(() => void place(false));
169
+ observer.observe(outer.current!);
170
+ return () => {
171
+ latest = Number.NaN;
172
+ observer.disconnect();
173
+ };
174
+ }, [diagram, links, gutter]);
175
+
176
+ const parents = useMemo(
177
+ () =>
178
+ new Map(flat.map(({ service, parent }) => [service.name, parent?.name])),
179
+ [flat],
180
+ );
181
+ const depth = useMemo(() => {
182
+ const result = new Map<string, number>();
183
+ for (const { service, parent } of flat)
184
+ result.set(
185
+ service.name,
186
+ parent === undefined ? 0 : result.get(parent.name)! + 1,
187
+ );
188
+ return result;
189
+ }, [flat]);
190
+
191
+ const stage = useStyles(styles.stage);
192
+ const measureLayer = useStyles(styles.measureLayer);
193
+ const canvas = useStyles(styles.canvas);
194
+ const overlay = useStyles(styles.overlay);
195
+ const box = useStyles(styles.box);
196
+ const pill = useStyles(styles.pill);
197
+
198
+ const layout = placed?.layout;
199
+ // Event labels steer clear of these; a parent's box is room for them.
200
+ const leafBoxes =
201
+ layout === undefined
202
+ ? []
203
+ : flat.flatMap(({ service }) =>
204
+ service.children.length === 0
205
+ ? [layout.boxes.get(service.name)!]
206
+ : [],
207
+ );
208
+ // Notes appear once the step's events have landed.
209
+ const settled = props.phase === "settled";
210
+ const notes = useMemo(
211
+ (): StepNote[] =>
212
+ settled
213
+ ? [
214
+ ...props.events.flatMap((event, index) =>
215
+ event.note === undefined
216
+ ? []
217
+ : [
218
+ {
219
+ id: `event-${index}`,
220
+ text: event.note,
221
+ target: `[data-event-label="${index}"]`,
222
+ service: undefined,
223
+ },
224
+ ],
225
+ ),
226
+ ...props.updates.flatMap((update, index) =>
227
+ update.note === undefined
228
+ ? []
229
+ : [
230
+ {
231
+ id: `update-${index}`,
232
+ text: update.note,
233
+ target: `[data-state-row="${update.service}.${update.key}"]`,
234
+ service: update.service,
235
+ },
236
+ ],
237
+ ),
238
+ ]
239
+ : [],
240
+ [settled, props.events, props.updates],
241
+ );
242
+ const notesX =
243
+ placed === undefined
244
+ ? 0
245
+ : placed.left + placed.layout.width * placed.scale + NOTE_GAP;
246
+ const canvasRef = useRef<HTMLDivElement>(null);
247
+ const noteRefs = useRef(new Map<string, HTMLElement>());
248
+ const [noteLayout, setNoteLayout] = useState<{
249
+ /** `n` numbers notes that sit under the diagram, away from their target. */
250
+ items: Map<string, { top: number; from: Point; n: number }>;
251
+ bottom: number;
252
+ }>();
253
+
254
+ // Line each note up with its target, pushing notes down so none overlap.
255
+ useLayoutEffect(() => {
256
+ if (placed === undefined || notes.length === 0) return;
257
+ const origin = outer.current!.getBoundingClientRect();
258
+ const measured = notes
259
+ .map((note) => {
260
+ const target = canvasRef
261
+ .current!.querySelector(note.target)!
262
+ .getBoundingClientRect();
263
+ const owner =
264
+ note.service === undefined
265
+ ? undefined
266
+ : placed.layout.boxes.get(note.service)!;
267
+ // Event leaders start at the label; state leaders at the box edge.
268
+ const x =
269
+ owner === undefined
270
+ ? target.right - origin.left
271
+ : placed.left + (owner.x + owner.width) * placed.scale;
272
+ return {
273
+ id: note.id,
274
+ from: { x, y: target.top - origin.top + target.height / 2 },
275
+ height: noteRefs.current.get(note.id)!.offsetHeight,
276
+ };
277
+ })
278
+ .toSorted((a, b) => a.from.y - b.from.y);
279
+ const items = new Map<string, { top: number; from: Point; n: number }>();
280
+ let bottom = placed.notesBelow
281
+ ? placed.layout.height * placed.scale + 8
282
+ : Number.NEGATIVE_INFINITY;
283
+ for (const [index, note] of measured.entries()) {
284
+ const top = placed.notesBelow
285
+ ? bottom + 8
286
+ : Math.max(note.from.y - 12, bottom + 8);
287
+ items.set(note.id, { top, from: note.from, n: index + 1 });
288
+ bottom = top + note.height;
289
+ }
290
+ setNoteLayout({ items, bottom });
291
+ }, [notes, placed]);
292
+
293
+ const noteClass = useStyles(styles.note);
294
+ const noteLines = useStyles(styles.noteLines);
295
+ const noteNumber = useStyles(styles.noteNumber);
296
+ const height =
297
+ layout === undefined
298
+ ? 120
299
+ : Math.max(
300
+ layout.height * placed!.scale,
301
+ notes.length > 0 && noteLayout !== undefined
302
+ ? noteLayout.bottom + 4
303
+ : 0,
304
+ );
305
+ return (
306
+ <div ref={outer} className={stage} style={{ height }}>
307
+ <div ref={measure} className={measureLayer} aria-hidden="true" inert>
308
+ {flat.map(({ service }) => (
309
+ <section
310
+ key={service.name}
311
+ className={box}
312
+ data-measure-service={service.name}
313
+ >
314
+ <ServiceCard
315
+ {...props}
316
+ service={service}
317
+ values={{
318
+ today: widest.get(service.name)!,
319
+ proposed: widest.get(service.name)!,
320
+ }}
321
+ previous={undefined}
322
+ changed={new Set()}
323
+ />
324
+ </section>
325
+ ))}
326
+ {links.map(
327
+ (link) =>
328
+ link.label !== undefined && (
329
+ <div key={link.id} className={pill} data-measure-link={link.id}>
330
+ {link.label}
331
+ </div>
332
+ ),
333
+ )}
334
+ </div>
335
+ {layout !== undefined && (
336
+ <div
337
+ ref={canvasRef}
338
+ className={canvas}
339
+ style={{
340
+ left: placed!.left,
341
+ width: layout.width,
342
+ height: layout.height,
343
+ transform: `scale(${placed!.scale})`,
344
+ }}
345
+ >
346
+ {flat.map(({ service }) => {
347
+ const rect = layout.boxes.get(service.name)!;
348
+ return (
349
+ <section
350
+ key={service.name}
351
+ className={box}
352
+ data-depth={depth.get(service.name)}
353
+ data-change={service.sign}
354
+ data-active={props.active.has(service.name)}
355
+ aria-label={service.name}
356
+ style={{
357
+ left: rect.x,
358
+ top: rect.y,
359
+ width: rect.width,
360
+ height: rect.height,
361
+ }}
362
+ >
363
+ <ServiceCard
364
+ {...props}
365
+ service={service}
366
+ values={valuesOf(props.state, service.name)}
367
+ previous={valuesOf(props.previousState, service.name)}
368
+ />
369
+ </section>
370
+ );
371
+ })}
372
+ <svg
373
+ className={overlay}
374
+ width={layout.width}
375
+ height={layout.height}
376
+ aria-hidden="true"
377
+ >
378
+ <defs>
379
+ {tones.map((tone) => (
380
+ <marker
381
+ key={tone.id}
382
+ id={`${markerId}-${tone.id}`}
383
+ viewBox="0 0 10 10"
384
+ refX="9"
385
+ refY="5"
386
+ markerWidth="7"
387
+ markerHeight="7"
388
+ orient="auto-start-reverse"
389
+ >
390
+ <path d="M0,1 L9,5 L0,9 z" style={{ fill: tone.head }} />
391
+ </marker>
392
+ ))}
393
+ </defs>
394
+ {links.map((link) => {
395
+ const route = trimmedRoute(layout, link, parents);
396
+ const sending = (from: string, to: string) =>
397
+ props.events.filter(
398
+ (event) => event.from === from && event.to === to,
399
+ );
400
+ const forward = sending(link.a, link.b);
401
+ const backward = sending(link.b, link.a);
402
+ const active = forward.length > 0 || backward.length > 0;
403
+ // While its events travel, a link takes their sign.
404
+ const tone = toneFor(
405
+ active,
406
+ active ? commonSign([...forward, ...backward]) : link.sign,
407
+ );
408
+ const marker = (sent: ServiceEvent[]) =>
409
+ `url(#${markerId}-${(sent.length > 0 ? tone : toneFor(false, link.sign)).id})`;
410
+ return (
411
+ <path
412
+ key={link.id}
413
+ d={polyline(route)}
414
+ fill="none"
415
+ markerEnd={link.forward ? marker(forward) : undefined}
416
+ markerStart={link.backward ? marker(backward) : undefined}
417
+ style={{
418
+ stroke: tone.line,
419
+ strokeWidth: active ? 2 : 1,
420
+ strokeDasharray: active ? undefined : "4 4",
421
+ }}
422
+ />
423
+ );
424
+ })}
425
+ </svg>
426
+ {props.events.map((event, index) => {
427
+ const id = linkId(event.from, event.to);
428
+ const link = links.find((candidate) => candidate.id === id)!;
429
+ const route = trimmedRoute(layout, link, parents);
430
+ // Replies travel the shared route backward.
431
+ const points = event.from === link.a ? route : route.toReversed();
432
+ const twin = props.events
433
+ .slice(0, index)
434
+ .filter((other) => linkId(other.from, other.to) === id).length;
435
+ // A service talking to itself loops off its side; label its corner.
436
+ const self =
437
+ link.a === link.b ? layout.boxes.get(link.a) : undefined;
438
+ // A link along the canvas edge would push half its label off it.
439
+ const size = placed!.labels.get(id);
440
+ const half = (size?.width ?? 0) / 2;
441
+ const middle = labelPoint(route, size, leafBoxes);
442
+ const at =
443
+ self === undefined
444
+ ? {
445
+ x: Math.min(Math.max(middle.x, half), layout.width - half),
446
+ y: middle.y,
447
+ }
448
+ : { x: self.x + self.width - 8, y: self.y };
449
+ return (
450
+ <EventMarks
451
+ key={`${props.stepKey}-${index}`}
452
+ {...props}
453
+ event={event}
454
+ points={points}
455
+ label={{ x: at.x, y: at.y + twin * 22 }}
456
+ anchor={self === undefined ? "center" : "end"}
457
+ index={index}
458
+ />
459
+ );
460
+ })}
461
+ </div>
462
+ )}
463
+ {notes.length > 0 && (
464
+ <>
465
+ <svg className={noteLines} aria-hidden="true">
466
+ {notes.map((note) => {
467
+ const item = noteLayout?.items.get(note.id);
468
+ if (item === undefined) return undefined;
469
+ const y = item.top + 12;
470
+ if (placed?.notesBelow)
471
+ return (
472
+ <g key={`${props.stepKey}-${note.id}`}>
473
+ <circle cx={item.from.x} cy={item.from.y} r={7} />
474
+ <text x={item.from.x} y={item.from.y}>
475
+ {item.n}
476
+ </text>
477
+ </g>
478
+ );
479
+ return (
480
+ <g key={`${props.stepKey}-${note.id}`}>
481
+ <circle cx={item.from.x} cy={item.from.y} r={2.5} />
482
+ <path
483
+ d={`M${item.from.x},${item.from.y} H${notesX - 12} V${y} H${notesX}`}
484
+ fill="none"
485
+ />
486
+ </g>
487
+ );
488
+ })}
489
+ </svg>
490
+ {notes.map((note) => {
491
+ const item = noteLayout?.items.get(note.id);
492
+ return (
493
+ <motion.div
494
+ key={`${props.stepKey}-${note.id}`}
495
+ ref={(element) => {
496
+ if (element === null) noteRefs.current.delete(note.id);
497
+ else noteRefs.current.set(note.id, element);
498
+ }}
499
+ className={noteClass}
500
+ data-below={placed?.notesBelow}
501
+ style={{
502
+ left: placed?.notesBelow ? 0 : notesX,
503
+ top: item?.top ?? 0,
504
+ visibility: item === undefined ? "hidden" : "visible",
505
+ }}
506
+ initial={{ opacity: 0 }}
507
+ animate={{ opacity: 1 }}
508
+ >
509
+ {placed?.notesBelow && item !== undefined && (
510
+ <span className={noteNumber}>{item.n}</span>
511
+ )}
512
+ {note.text}
513
+ </motion.div>
514
+ );
515
+ })}
516
+ </>
517
+ )}
518
+ </div>
519
+ );
520
+ }
521
+
522
+ function EventMarks(
523
+ props: {
524
+ event: ServiceEvent;
525
+ points: Point[];
526
+ label: Point;
527
+ anchor: "center" | "end";
528
+ index: number;
529
+ phase: StageProps["phase"];
530
+ } & SourceNavigation,
531
+ ) {
532
+ const packet = useStyles(styles.packet);
533
+ const labelAnchor = useStyles(styles.labelAnchor);
534
+ const pill = useStyles(styles.pill);
535
+ const annotation = props.event.annotation;
536
+ const label = props.event.label === "" ? undefined : pillText(props.event);
537
+ const lengths = cumulativeLengths(props.points);
538
+ const total = lengths.at(-1)! || 1;
539
+ return (
540
+ <>
541
+ {props.phase === "travel" && (
542
+ <motion.div
543
+ className={packet}
544
+ data-change={props.event.sign}
545
+ initial={{ x: props.points[0]!.x, y: props.points[0]!.y }}
546
+ animate={{
547
+ x: props.points.map((point) => point.x),
548
+ y: props.points.map((point) => point.y),
549
+ }}
550
+ // Even speed along every segment of the route.
551
+ transition={{
552
+ duration: TRAVEL_MS / 1000,
553
+ ease: "easeInOut",
554
+ times: lengths.map((length) => length / total),
555
+ }}
556
+ />
557
+ )}
558
+ {(label !== undefined || props.event.note !== undefined) && (
559
+ <motion.div
560
+ className={labelAnchor}
561
+ style={{ left: props.label.x, top: props.label.y }}
562
+ data-anchor={props.anchor}
563
+ data-event-label={props.index}
564
+ initial={{ opacity: 0 }}
565
+ animate={{ opacity: 1 }}
566
+ >
567
+ {label !== undefined &&
568
+ (annotation === undefined ? (
569
+ <div className={pill} data-change={props.event.sign}>
570
+ {label}
571
+ </div>
572
+ ) : (
573
+ <button
574
+ type="button"
575
+ className={pill}
576
+ data-change={props.event.sign}
577
+ aria-pressed={props.selectedAnnotation === annotation}
578
+ aria-controls="source-diff-panel"
579
+ aria-label={`Open source: ${annotation.text}`}
580
+ onClick={() => props.onSelectAnnotation(annotation)}
581
+ >
582
+ {label}
583
+ </button>
584
+ ))}
585
+ </motion.div>
586
+ )}
587
+ </>
588
+ );
589
+ }
590
+
591
+ /** A service's header and state, without its sub-services. */
592
+ function ServiceCard(
593
+ props: {
594
+ service: Service;
595
+ values: Values;
596
+ previous: Values | undefined;
597
+ changed: Set<string>;
598
+ } & SourceNavigation,
599
+ ) {
600
+ const service = props.service;
601
+ const header = useStyles(styles.header);
602
+ const headerButton = useStyles(styles.header, styles.headerButton);
603
+ const stateList = useStyles(styles.stateList);
604
+ const stateRow = useStyles(styles.stateRow);
605
+ const annotation = service.annotation;
606
+ const described = service.description !== undefined;
607
+ const title = (
608
+ <>
609
+ {/* Keep the source arrow beside the name when the label wraps. */}
610
+ <Text
611
+ size="sm"
612
+ fontWeight={600}
613
+ style={{
614
+ whiteSpace: "nowrap",
615
+ textDecorationLine: service.sign === "-" ? "line-through" : undefined,
616
+ }}
617
+ data-described={described}
618
+ >
619
+ {service.name}
620
+ {annotation !== undefined && (
621
+ <Text size="xs" color="accent" aria-hidden="true">
622
+ {" ↗"}
623
+ </Text>
624
+ )}
625
+ </Text>
626
+ {service.label !== undefined && (
627
+ <Text size="xs" color="lowContrast">
628
+ {service.label}
629
+ </Text>
630
+ )}
631
+ </>
632
+ );
633
+ return (
634
+ <>
635
+ <Described description={service.description}>
636
+ {annotation === undefined ? (
637
+ <div className={header} tabIndex={described ? 0 : undefined}>
638
+ {title}
639
+ </div>
640
+ ) : (
641
+ <button
642
+ type="button"
643
+ className={headerButton}
644
+ aria-pressed={props.selectedAnnotation === annotation}
645
+ aria-controls="source-diff-panel"
646
+ aria-label={`Open source: ${service.name}`}
647
+ onClick={() => props.onSelectAnnotation(annotation)}
648
+ >
649
+ {title}
650
+ </button>
651
+ )}
652
+ </Described>
653
+ {service.state.length > 0 && (
654
+ <dl
655
+ className={stateList}
656
+ style={{
657
+ ["--key-width" as string]: `${Math.max(...service.state.map(({ key }) => key.length))}ch`,
658
+ }}
659
+ >
660
+ {service.state.map(({ key, description, sign }) => {
661
+ const changed = props.changed.has(`${service.name}.${key}`);
662
+ // A row added or removed in a diff shows only its own side.
663
+ const side = sign === "-" ? "today" : "proposed";
664
+ const today = props.values.today.get(key);
665
+ const proposed = props.values.proposed.get(key);
666
+ return (
667
+ <div
668
+ key={key}
669
+ className={stateRow}
670
+ data-changed={changed}
671
+ data-change={sign}
672
+ data-state-row={`${service.name}.${key}`}
673
+ >
674
+ <Described description={description}>
675
+ <dt
676
+ tabIndex={description === undefined ? undefined : 0}
677
+ data-described={description !== undefined}
678
+ >
679
+ {key}
680
+ </dt>
681
+ </Described>
682
+ <dd>
683
+ {sign === " " && today !== proposed ? (
684
+ <>
685
+ <Text
686
+ size="xs"
687
+ monospace
688
+ style={{ color: colors.red[11] }}
689
+ >
690
+ <s>{today}</s>{" "}
691
+ </Text>
692
+ <Text
693
+ size="xs"
694
+ monospace
695
+ style={{ color: colors.green[11] }}
696
+ >
697
+ {proposed}
698
+ </Text>
699
+ </>
700
+ ) : (
701
+ <>
702
+ {changed && (
703
+ <Text size="xs" monospace color="lowContrast">
704
+ <s>{props.previous?.[side].get(key)}</s>{" "}
705
+ </Text>
706
+ )}
707
+ <Text
708
+ size="xs"
709
+ monospace
710
+ color={changed ? "accent" : undefined}
711
+ >
712
+ {props.values[side].get(key)}
713
+ </Text>
714
+ </>
715
+ )}
716
+ </dd>
717
+ </div>
718
+ );
719
+ })}
720
+ </dl>
721
+ )}
722
+ </>
723
+ );
724
+ }
725
+
726
+ /** Shows `description` in a tooltip on its child, when there is one. */
727
+ export function Described(props: {
728
+ description: string | undefined;
729
+ children: ReactElement;
730
+ }) {
731
+ if (props.description === undefined) return props.children;
732
+ return (
733
+ <Tooltip content={props.description} placement="top">
734
+ {props.children}
735
+ </Tooltip>
736
+ );
737
+ }
738
+
739
+ /**
740
+ * Each state value at its widest, including the struck-through old value a
741
+ * changed row shows beside the new one, and a diff's Today value beside its
742
+ * Proposed one.
743
+ */
744
+ function widestState(diagram: ServiceDiagram) {
745
+ const flat = flattenServices(diagram.services);
746
+ const widest = new Map(
747
+ flat.map(({ service }) => [service.name, new Map<string, string>()]),
748
+ );
749
+ const at = (step: number) => ({
750
+ today: serviceStateAt(diagram, step, "today"),
751
+ proposed: serviceStateAt(diagram, step, "proposed"),
752
+ });
753
+ let before = at(0);
754
+ for (let step = 0; step <= diagram.steps.length; step++) {
755
+ const now = step === 0 ? before : at(step);
756
+ // A row updated in this step shows its old value struck through.
757
+ const updated = new Set(
758
+ diagram.steps[step - 1]?.updates.map(
759
+ (update) => `${update.service}.${update.key}`,
760
+ ),
761
+ );
762
+ for (const { service } of flat)
763
+ for (const { key } of service.state) {
764
+ const best = widest.get(service.name)!;
765
+ const value = (state: State) => state.get(service.name)!.get(key);
766
+ const today = value(now.today);
767
+ const proposed = value(now.proposed);
768
+ const shown = [
769
+ today === undefined || proposed === undefined || today === proposed
770
+ ? (proposed ?? today ?? "")
771
+ : `${today} ${proposed}`,
772
+ ];
773
+ if (updated.has(`${service.name}.${key}`))
774
+ shown.push(
775
+ `${value(before.today) ?? ""} ${today ?? ""}`,
776
+ `${value(before.proposed) ?? ""} ${proposed ?? ""}`,
777
+ );
778
+ for (const candidate of shown)
779
+ if (candidate.length > (best.get(key)?.length ?? -1))
780
+ best.set(key, candidate);
781
+ }
782
+ before = now;
783
+ }
784
+ return widest;
785
+ }
786
+
787
+ function valuesOf(state: SidedState, service: string): Values {
788
+ return {
789
+ today: state.today.get(service)!,
790
+ proposed: state.proposed.get(service)!,
791
+ };
792
+ }
793
+
794
+ /** The sign every event shares, or " " when they differ. */
795
+ function commonSign(events: ServiceEvent[]): Sign {
796
+ return events.every((event) => event.sign === events[0]!.sign)
797
+ ? events[0]!.sign
798
+ : " ";
799
+ }
800
+
801
+ /** Link colors: idle or carrying events, and Today only, Proposed only, or both. */
802
+ const tones = (["idle", "active"] as const).flatMap((state) =>
803
+ (["+", "-", " "] as const).map((sign) => {
804
+ const scale =
805
+ sign === "+" ? colors.green : sign === "-" ? colors.red : undefined;
806
+ const active = state === "active";
807
+ const line = active
808
+ ? (scale?.[9] ?? colors.accent[9])
809
+ : (scale?.[8] ?? colors.gray[7]);
810
+ return {
811
+ id: `${state}-${{ "+": "added", "-": "removed", " ": "same" }[sign]}`,
812
+ state,
813
+ sign,
814
+ line,
815
+ head: active || scale !== undefined ? line : colors.gray[8],
816
+ };
817
+ }),
818
+ );
819
+
820
+ function toneFor(active: boolean, sign: Sign) {
821
+ return tones.find(
822
+ (tone) => tone.sign === sign && (tone.state === "active") === active,
823
+ )!;
824
+ }
825
+
826
+ function isAncestor(
827
+ parents: Map<string, string | undefined>,
828
+ ancestor: string,
829
+ name: string,
830
+ ) {
831
+ for (let at = parents.get(name); at !== undefined; at = parents.get(at))
832
+ if (at === ancestor) return true;
833
+ return false;
834
+ }
835
+
836
+ /**
837
+ * ELK starts a parent-to-child route at the parent's border, which crosses
838
+ * the parent's own header and state; start it below them instead.
839
+ */
840
+ function trimmedRoute(
841
+ layout: ServiceLayout,
842
+ link: ServiceLink,
843
+ parents: Map<string, string | undefined>,
844
+ ): Point[] {
845
+ const route = layout.routes.get(link.id)!;
846
+ if (!isAncestor(parents, link.a, link.b)) return route;
847
+ const parent = layout.boxes.get(link.a)!;
848
+ const bottom = parent.y + layout.sizes.get(link.a)!.height;
849
+ const [start, next] = route;
850
+ if (next === undefined || start!.x !== next.x || next.y <= bottom)
851
+ return route;
852
+ return [{ x: start!.x, y: bottom }, ...route.slice(1)];
853
+ }
854
+
855
+ function sizeOf(element: Element) {
856
+ const rect = element.getBoundingClientRect();
857
+ return { width: Math.ceil(rect.width), height: Math.ceil(rect.height) };
858
+ }
859
+
860
+ function polyline(points: Point[]) {
861
+ return points
862
+ .map((point, index) => `${index === 0 ? "M" : "L"}${point.x},${point.y}`)
863
+ .join(" ");
864
+ }
865
+
866
+ function cumulativeLengths(points: Point[]) {
867
+ let total = 0;
868
+ return points.map((point, index) => {
869
+ const before = points[index - 1];
870
+ if (before !== undefined)
871
+ total += Math.hypot(point.x - before.x, point.y - before.y);
872
+ return total;
873
+ });
874
+ }
875
+
876
+ /** The middle of the route's longest segment, usually the gap between layers. */
877
+ /** The middle of the longest segment whose label clears every leaf box. */
878
+ function labelPoint(
879
+ points: Point[],
880
+ label: Size | undefined,
881
+ boxes: Rect[],
882
+ ): Point {
883
+ const middles = points
884
+ .slice(1)
885
+ .map((to, index) => {
886
+ const from = points[index]!;
887
+ return {
888
+ length: Math.hypot(to.x - from.x, to.y - from.y),
889
+ at: { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 },
890
+ };
891
+ })
892
+ .toSorted((a, b) => b.length - a.length);
893
+ const clear =
894
+ label === undefined
895
+ ? undefined
896
+ : middles.find(({ at }) =>
897
+ boxes.every(
898
+ (box) =>
899
+ Math.abs(at.x - (box.x + box.width / 2)) >=
900
+ (box.width + label.width) / 2 ||
901
+ Math.abs(at.y - (box.y + box.height / 2)) >=
902
+ (box.height + label.height) / 2,
903
+ ),
904
+ );
905
+ return (clear ?? middles[0])?.at ?? points[0]!;
906
+ }
907
+
908
+ const described = {
909
+ "&[data-described='true']": {
910
+ textDecorationLine: "underline",
911
+ textDecorationStyle: "dotted" as const,
912
+ textDecorationColor: colors.gray[8],
913
+ textUnderlineOffset: "3px",
914
+ cursor: "help",
915
+ },
916
+ };
917
+
918
+ const styles = {
919
+ stage: style({ position: "relative", minWidth: 0, overflow: "hidden" }),
920
+ // Lays out every box at its natural size without showing or taking space.
921
+ measureLayer: style({
922
+ position: "absolute",
923
+ width: 0,
924
+ height: 0,
925
+ overflow: "hidden",
926
+ visibility: "hidden",
927
+ "& > [data-measure-service]": {
928
+ position: "absolute",
929
+ width: "max-content",
930
+ minWidth: 120,
931
+ maxWidth: 240,
932
+ },
933
+ "& > [data-measure-link]": { position: "absolute", width: "max-content" },
934
+ }),
935
+ canvas: style({
936
+ position: "absolute",
937
+ top: 0,
938
+ transformOrigin: "0 0",
939
+ }),
940
+ noteLines: style({
941
+ position: "absolute",
942
+ inset: 0,
943
+ width: "100%",
944
+ height: "100%",
945
+ overflow: "visible",
946
+ pointerEvents: "none",
947
+ "& path": { stroke: colors.accent[7], strokeWidth: 1 },
948
+ "& circle": { fill: colors.accent[9] },
949
+ "& text": {
950
+ fill: backgroundColor.app,
951
+ fontSize: 9,
952
+ fontWeight: 600,
953
+ textAnchor: "middle",
954
+ dominantBaseline: "central",
955
+ },
956
+ }),
957
+ noteNumber: style({
958
+ display: "inline-flex",
959
+ alignItems: "center",
960
+ justifyContent: "center",
961
+ width: 14,
962
+ height: 14,
963
+ marginRight: 6,
964
+ borderRadius: "50%",
965
+ fontSize: 9,
966
+ fontWeight: 600,
967
+ verticalAlign: "1px",
968
+ color: backgroundColor.app,
969
+ backgroundColor: colors.accent[9],
970
+ }),
971
+ overlay: style({
972
+ position: "absolute",
973
+ inset: 0,
974
+ overflow: "visible",
975
+ pointerEvents: "none",
976
+ }),
977
+ box: style(radius.md, {
978
+ position: "absolute",
979
+ display: "flex",
980
+ flexDirection: "column",
981
+ border: `1px solid ${colors.gray[6]}`,
982
+ backgroundColor: backgroundColor.element,
983
+ "&[data-depth='1'], &[data-depth='3']": {
984
+ backgroundColor: colors.gray[2],
985
+ },
986
+ "&[data-change='+']": {
987
+ borderColor: colors.green[8],
988
+ backgroundColor: colors.green[2],
989
+ },
990
+ "&[data-change='-']": {
991
+ borderColor: colors.red[8],
992
+ borderStyle: "dashed",
993
+ backgroundColor: colors.red[2],
994
+ },
995
+ "&[data-active='true']": { borderColor: colors.accent[8] },
996
+ "&[data-active='true'][data-change='+']": { borderColor: colors.green[10] },
997
+ "&[data-active='true'][data-change='-']": { borderColor: colors.red[10] },
998
+ }),
999
+ header: style(
1000
+ flex({ direction: "row", alignItems: "baseline", gap: 3, wrap: true }),
1001
+ spacing.padding({ x: 4, y: 3 }),
1002
+ focusRing(),
1003
+ {
1004
+ minWidth: 0,
1005
+ "& > [data-described]": described["&[data-described='true']"],
1006
+ },
1007
+ ),
1008
+ headerButton: style({
1009
+ width: "100%",
1010
+ border: 0,
1011
+ background: "transparent",
1012
+ font: "inherit",
1013
+ color: "inherit",
1014
+ textAlign: "start",
1015
+ cursor: "pointer",
1016
+ borderRadius: "inherit",
1017
+ "&:hover": { backgroundColor: backgroundColor.elementHover },
1018
+ "&[aria-pressed='true']": { backgroundColor: colors.blue[3] },
1019
+ }),
1020
+ // Keys line up in a column; a value too long to sit beside its key wraps
1021
+ // below it instead of squeezing to a sliver.
1022
+ stateList: style(text({ size: "xs", monospace: true }), {
1023
+ margin: 0,
1024
+ paddingInline: spacing.value(4),
1025
+ paddingBottom: spacing.value(3),
1026
+ "& dt": {
1027
+ flex: "none",
1028
+ minWidth: "min(var(--key-width), 100%)",
1029
+ color: colors.gray[11],
1030
+ },
1031
+ "& dt[data-described='true']": described["&[data-described='true']"],
1032
+ "& dt:focus-visible": { outline: `2px solid ${colors.blueAlpha[8]}` },
1033
+ "& dd": {
1034
+ flex: "1 1 auto",
1035
+ margin: 0,
1036
+ minWidth: 0,
1037
+ overflowWrap: "anywhere",
1038
+ },
1039
+ }),
1040
+ stateRow: style(radius.xs, {
1041
+ display: "flex",
1042
+ flexWrap: "wrap",
1043
+ alignItems: "baseline",
1044
+ columnGap: spacing.value(4),
1045
+ paddingInline: spacing.value(2),
1046
+ marginInline: `-${spacing.value(2)}`,
1047
+ "&[data-change='+']": {
1048
+ backgroundColor: colors.greenAlpha[3],
1049
+ boxShadow: `inset 2px 0 ${colors.green[9]}`,
1050
+ },
1051
+ "&[data-change='-']": {
1052
+ backgroundColor: colors.redAlpha[3],
1053
+ boxShadow: `inset 2px 0 ${colors.red[9]}`,
1054
+ },
1055
+ "&[data-changed='true']": { backgroundColor: colors.accentAlpha[3] },
1056
+ }),
1057
+ packet: style(radius.circle, {
1058
+ position: "absolute",
1059
+ top: -6,
1060
+ left: -6,
1061
+ width: 12,
1062
+ height: 12,
1063
+ backgroundColor: colors.accent[9],
1064
+ boxShadow: `0 0 0 3px ${colors.accentAlpha[5]}`,
1065
+ pointerEvents: "none",
1066
+ zIndex: 2,
1067
+ "&[data-change='+']": {
1068
+ backgroundColor: colors.green[9],
1069
+ boxShadow: `0 0 0 3px ${colors.greenAlpha[5]}`,
1070
+ },
1071
+ "&[data-change='-']": {
1072
+ backgroundColor: colors.red[9],
1073
+ boxShadow: `0 0 0 3px ${colors.redAlpha[5]}`,
1074
+ },
1075
+ }),
1076
+ labelAnchor: style({
1077
+ position: "absolute",
1078
+ zIndex: 1,
1079
+ transform: "translate(-50%, -50%)",
1080
+ "&[data-anchor='end']": { transform: "translate(-100%, -50%)" },
1081
+ }),
1082
+ pill: style(
1083
+ radius.pill,
1084
+ text({ size: "xs", monospace: true }),
1085
+ spacing.padding({ x: 3 }),
1086
+ {
1087
+ display: "block",
1088
+ maxWidth: 220,
1089
+ whiteSpace: "nowrap",
1090
+ overflow: "hidden",
1091
+ textOverflow: "ellipsis",
1092
+ border: `1px solid ${colors.accent[7]}`,
1093
+ backgroundColor: backgroundColor.app,
1094
+ color: colors.accent[11],
1095
+ "&[data-change='+']": {
1096
+ borderColor: colors.green[7],
1097
+ color: colors.green[11],
1098
+ },
1099
+ "&[data-change='-']": {
1100
+ borderColor: colors.red[7],
1101
+ borderStyle: "dashed",
1102
+ color: colors.red[11],
1103
+ },
1104
+ "&:is(button)": { cursor: "pointer" },
1105
+ "&:is(button):hover": { backgroundColor: colors.accent[3] },
1106
+ "&[aria-pressed='true']": { backgroundColor: colors.blue[3] },
1107
+ },
1108
+ focusRing(),
1109
+ ),
1110
+ // In the column right of the diagram, joined to its target by a leader.
1111
+ note: style(
1112
+ text({ size: "xs" }),
1113
+ radius.sm,
1114
+ spacing.padding({ x: 4, y: 2 }),
1115
+ {
1116
+ position: "absolute",
1117
+ width: "max-content",
1118
+ maxWidth: NOTE_WIDTH,
1119
+ border: `1px solid ${colors.accent[7]}`,
1120
+ backgroundColor: backgroundColor.app,
1121
+ // Under the diagram: centered, and wider since there is room.
1122
+ "&[data-below='true']": {
1123
+ right: 0,
1124
+ marginInline: "auto",
1125
+ maxWidth: "min(100%, 420px)",
1126
+ },
1127
+ },
1128
+ ),
1129
+ };