@tanishqkancharla/diffmap 0.2.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.
@@ -0,0 +1,323 @@
1
+ import { type KeyboardEvent, useEffect, useMemo, useState } from "react";
2
+ import { useReducedMotion } from "motion/react";
3
+ import {
4
+ Button,
5
+ ChevronLeft,
6
+ ChevronRight,
7
+ Pause,
8
+ Play,
9
+ RotateAntiClockwise,
10
+ Text,
11
+ Tooltip,
12
+ borderColor,
13
+ colors,
14
+ flex,
15
+ focusRing,
16
+ radius,
17
+ spacing,
18
+ text,
19
+ } from "maui";
20
+ import { style, useStyles } from "purse-styles";
21
+ import type { SourceNavigation } from "../annotations.js";
22
+ import { ServiceStage, TRAVEL_MS } from "./ServiceStage.tsx";
23
+ import {
24
+ serviceStateAt,
25
+ type ServiceDiagram,
26
+ type ServicesParse,
27
+ } from "../services.js";
28
+
29
+ const DWELL_MS = 1200;
30
+
31
+ type Phase = "travel" | "settled";
32
+
33
+ export function ServiceDiagramBlock(
34
+ props: { source: string; parsed: ServicesParse } & SourceNavigation,
35
+ ) {
36
+ const error = useStyles(styles.error);
37
+ if (!props.parsed.ok)
38
+ return (
39
+ <div className={error} data-diffmap-kind="services">
40
+ Line {props.parsed.line}: {props.parsed.reason}
41
+ </div>
42
+ );
43
+ // Remount on edits so playback restarts and the stage measures new boxes.
44
+ return (
45
+ <ServicePlayer
46
+ key={props.source}
47
+ {...props}
48
+ diagram={props.parsed.diagram}
49
+ />
50
+ );
51
+ }
52
+
53
+ function ServicePlayer(props: { diagram: ServiceDiagram } & SourceNavigation) {
54
+ const diagram = props.diagram;
55
+ const last = diagram.steps.length;
56
+ const reduceMotion = Boolean(useReducedMotion());
57
+ const [position, setPosition] = useState<{ step: number; phase: Phase }>({
58
+ step: 0,
59
+ phase: "settled",
60
+ });
61
+ const [playing, setPlaying] = useState(false);
62
+ const states = useMemo(
63
+ () =>
64
+ Array.from({ length: last + 1 }, (_, step) =>
65
+ serviceStateAt(diagram, step),
66
+ ),
67
+ [diagram, last],
68
+ );
69
+ const step = position.step;
70
+ const current = step === 0 ? undefined : diagram.steps[step - 1]!;
71
+ const settled = position.phase === "settled";
72
+ // In flight, the events have not landed, so show the previous state.
73
+ const shownState = states[settled ? step : step - 1]!;
74
+ const changed = new Set(
75
+ settled && current !== undefined
76
+ ? current.updates.map((update) => `${update.service}.${update.key}`)
77
+ : [],
78
+ );
79
+ const active = new Set(
80
+ current?.events.flatMap((event) => [event.from, event.to]) ?? [],
81
+ );
82
+
83
+ const goTo = (target: number, animate: boolean) =>
84
+ setPosition(positionFor(diagram, target, animate && !reduceMotion));
85
+
86
+ useEffect(() => {
87
+ if (position.phase !== "travel") return;
88
+ const timer = setTimeout(
89
+ () => setPosition({ step: position.step, phase: "settled" }),
90
+ TRAVEL_MS,
91
+ );
92
+ return () => clearTimeout(timer);
93
+ }, [position]);
94
+
95
+ useEffect(() => {
96
+ if (!playing || position.phase !== "settled") return;
97
+ const timer = setTimeout(
98
+ () => {
99
+ if (position.step >= last) setPlaying(false);
100
+ else
101
+ setPosition(positionFor(diagram, position.step + 1, !reduceMotion));
102
+ },
103
+ position.step === 0 ? 300 : DWELL_MS,
104
+ );
105
+ return () => clearTimeout(timer);
106
+ }, [playing, position, last, diagram, reduceMotion]);
107
+
108
+ const prev = () => {
109
+ setPlaying(false);
110
+ if (step > 0) goTo(step - 1, false);
111
+ };
112
+ const next = () => {
113
+ setPlaying(false);
114
+ if (step < last) goTo(step + 1, true);
115
+ };
116
+ const togglePlay = () => {
117
+ if (playing) return setPlaying(false);
118
+ if (step >= last) goTo(0, false);
119
+ setPlaying(true);
120
+ };
121
+ const onKeyDown = (event: KeyboardEvent) => {
122
+ if (event.key === "ArrowLeft") {
123
+ event.preventDefault();
124
+ prev();
125
+ }
126
+ if (event.key === "ArrowRight") {
127
+ event.preventDefault();
128
+ next();
129
+ }
130
+ };
131
+
132
+ const frame = useStyles(styles.frame);
133
+ const controls = useStyles(styles.controls);
134
+ const scrubber = useStyles(styles.scrubber);
135
+ const segment = useStyles(styles.segment);
136
+ const detail = useStyles(styles.detail);
137
+ const counter = useStyles(styles.counter);
138
+
139
+ return (
140
+ <div
141
+ className={frame}
142
+ data-diffmap-kind="services"
143
+ // Capture: Maui's Button stops arrow keys from bubbling.
144
+ onKeyDownCapture={onKeyDown}
145
+ >
146
+ <ServiceStage
147
+ {...props}
148
+ state={shownState}
149
+ previousState={states[Math.max(step - 1, 0)]!}
150
+ changed={changed}
151
+ active={active}
152
+ events={current?.events ?? []}
153
+ updates={current?.updates ?? []}
154
+ phase={position.phase}
155
+ stepKey={step}
156
+ />
157
+ {last > 0 && (
158
+ <>
159
+ <div className={controls}>
160
+ <Tooltip content="Restart" placement="top">
161
+ <Button
162
+ size="sm"
163
+ variant="quiet"
164
+ aria-label="Restart"
165
+ isDisabled={step === 0 && !playing}
166
+ onClick={() => {
167
+ setPlaying(false);
168
+ goTo(0, false);
169
+ }}
170
+ >
171
+ <RotateAntiClockwise size="xs" />
172
+ </Button>
173
+ </Tooltip>
174
+ <Tooltip content="Previous step (←)" placement="top">
175
+ <Button
176
+ size="sm"
177
+ variant="quiet"
178
+ aria-label="Previous step"
179
+ isDisabled={step === 0}
180
+ onClick={prev}
181
+ >
182
+ <ChevronLeft size="xs" />
183
+ </Button>
184
+ </Tooltip>
185
+ <Button
186
+ size="sm"
187
+ aria-label={playing ? "Pause" : "Play"}
188
+ onClick={togglePlay}
189
+ >
190
+ {playing ? <Pause size="xs" /> : <Play size="xs" />}
191
+ {playing ? "Pause" : step >= last ? "Replay" : "Play"}
192
+ </Button>
193
+ <Tooltip content="Next step (→)" placement="top">
194
+ <Button
195
+ size="sm"
196
+ variant="quiet"
197
+ aria-label="Next step"
198
+ isDisabled={step >= last}
199
+ onClick={next}
200
+ >
201
+ <ChevronRight size="xs" />
202
+ </Button>
203
+ </Tooltip>
204
+ <div className={scrubber} role="group" aria-label="Steps">
205
+ {diagram.steps.map((entry, index) => (
206
+ <Tooltip
207
+ key={index}
208
+ placement="top"
209
+ content={
210
+ <>
211
+ <strong>
212
+ {index + 1}. {entry.caption}
213
+ </strong>
214
+ {entry.description !== undefined && (
215
+ <> {entry.description}</>
216
+ )}
217
+ </>
218
+ }
219
+ >
220
+ <button
221
+ type="button"
222
+ className={segment}
223
+ data-state={
224
+ index + 1 === step
225
+ ? "current"
226
+ : index + 1 < step
227
+ ? "done"
228
+ : "todo"
229
+ }
230
+ aria-label={`Step ${index + 1}: ${entry.caption}`}
231
+ aria-current={index + 1 === step ? "step" : undefined}
232
+ onClick={() => {
233
+ setPlaying(false);
234
+ goTo(index + 1, index + 1 === step + 1);
235
+ }}
236
+ />
237
+ </Tooltip>
238
+ ))}
239
+ </div>
240
+ <Text className={counter} size="xs" color="lowContrast" tabular>
241
+ {step} / {last}
242
+ </Text>
243
+ </div>
244
+ <div className={detail} aria-live="polite">
245
+ <Text size="sm" fontWeight={500}>
246
+ {current?.caption ?? "Start"}
247
+ </Text>
248
+ {current === undefined && (
249
+ <Text size="xs" color="lowContrast">
250
+ Initial state. Press Play, or step with ← and →.
251
+ </Text>
252
+ )}
253
+ {current?.description !== undefined && (
254
+ <Text size="xs" color="lowContrast">
255
+ {current.description}
256
+ </Text>
257
+ )}
258
+ </div>
259
+ </>
260
+ )}
261
+ </div>
262
+ );
263
+ }
264
+
265
+ function positionFor(
266
+ diagram: ServiceDiagram,
267
+ step: number,
268
+ animate: boolean,
269
+ ): { step: number; phase: Phase } {
270
+ const events = diagram.steps[step - 1]?.events.length ?? 0;
271
+ return { step, phase: animate && events > 0 ? "travel" : "settled" };
272
+ }
273
+
274
+ const styles = {
275
+ error: style(text({ size: "sm", color: "lowContrast" })),
276
+ frame: style(
277
+ flex({ direction: "column", gap: 4 }),
278
+ radius.lg,
279
+ spacing.padding({ all: 6 }),
280
+ {
281
+ width: "100%",
282
+ minWidth: 0,
283
+ border: `1px solid ${borderColor.outline}`,
284
+ },
285
+ ),
286
+ controls: style(flex({ direction: "row", alignItems: "center", gap: 2 })),
287
+ scrubber: style(flex({ direction: "row", alignItems: "center", gap: 1 }), {
288
+ flex: "1 1 auto",
289
+ minWidth: 0,
290
+ marginInline: spacing.value(4),
291
+ }),
292
+ segment: style(
293
+ radius.pill,
294
+ {
295
+ flex: "1 1 0",
296
+ minWidth: 6,
297
+ height: 20,
298
+ padding: 0,
299
+ border: 0,
300
+ cursor: "pointer",
301
+ background: "transparent",
302
+ position: "relative",
303
+ "&::after": {
304
+ content: "''",
305
+ position: "absolute",
306
+ insetInline: 0,
307
+ top: 8,
308
+ height: 4,
309
+ borderRadius: 2,
310
+ backgroundColor: colors.gray[5],
311
+ },
312
+ "&:hover::after": { backgroundColor: colors.gray[7] },
313
+ "&[data-state='done']::after": { backgroundColor: colors.accent[7] },
314
+ "&[data-state='current']::after": { backgroundColor: colors.accent[9] },
315
+ },
316
+ focusRing(),
317
+ ),
318
+ counter: style({ flexShrink: 0, minWidth: "5ch", textAlign: "end" }),
319
+ detail: style(flex({ direction: "column", gap: 2 }), {
320
+ minHeight: 44,
321
+ paddingInline: spacing.value(2),
322
+ }),
323
+ };