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