@elabs-ai/components-process 4.2.0 → 5.1.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 (144) hide show
  1. package/README.md +8 -1
  2. package/dist/core/index.d.ts +801 -3
  3. package/dist/core/index.js +1334 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/index.d.ts +1889 -34
  6. package/dist/index.js +5512 -196
  7. package/dist/index.js.map +1 -1
  8. package/dist/test/index.d.ts +223 -5
  9. package/dist/test/index.js +346 -191
  10. package/dist/test/index.js.map +1 -1
  11. package/package.json +14 -13
  12. package/src/__contract__/case-table.contract.test.tsx +49 -0
  13. package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
  14. package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
  15. package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
  16. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  17. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  18. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  19. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  20. package/src/case-table/case-table.stories.tsx +89 -0
  21. package/src/case-table/case-table.test.tsx +148 -0
  22. package/src/case-table/case-table.tsx +144 -0
  23. package/src/case-table/columns.ts +116 -0
  24. package/src/case-table/index.ts +11 -0
  25. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  26. package/src/case-timeline/case-timeline-model.ts +112 -0
  27. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  28. package/src/case-timeline/case-timeline.test.tsx +51 -0
  29. package/src/case-timeline/case-timeline.tsx +109 -0
  30. package/src/case-timeline/index.ts +9 -0
  31. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  32. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  33. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  34. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  35. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  36. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  37. package/src/conformance-overlay/conformance-state.ts +220 -0
  38. package/src/conformance-overlay/index.ts +4 -0
  39. package/src/core/activity-color-scale.test.ts +107 -0
  40. package/src/core/activity-color-scale.ts +133 -0
  41. package/src/core/adapters/ocel.test.ts +112 -0
  42. package/src/core/adapters/ocel.ts +359 -0
  43. package/src/core/adapters/xes.test.ts +293 -0
  44. package/src/core/adapters/xes.ts +384 -0
  45. package/src/core/cases-from-log.test.ts +72 -0
  46. package/src/core/cases-from-log.ts +85 -0
  47. package/src/core/conformance.test.ts +80 -0
  48. package/src/core/conformance.ts +91 -0
  49. package/src/core/diff-graphs.test.ts +151 -0
  50. package/src/core/diff-graphs.ts +118 -0
  51. package/src/core/discover-object-centric-graph.test.ts +94 -0
  52. package/src/core/discover-object-centric-graph.ts +296 -0
  53. package/src/core/fixtures/ocel-sample.ts +82 -0
  54. package/src/core/fixtures/sample.xes +68 -0
  55. package/src/core/index.ts +113 -0
  56. package/src/core/reference-model.test.ts +43 -0
  57. package/src/core/reference-model.ts +116 -0
  58. package/src/core/replay-timeline.test.ts +161 -0
  59. package/src/core/replay-timeline.ts +260 -0
  60. package/src/core/segments.test.ts +185 -0
  61. package/src/core/segments.ts +153 -0
  62. package/src/core/token-replay.test.ts +218 -0
  63. package/src/core/token-replay.ts +456 -0
  64. package/src/core/types.ts +2 -2
  65. package/src/dotted-chart/compute-dots.test.ts +176 -0
  66. package/src/dotted-chart/compute-dots.ts +241 -0
  67. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  68. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  69. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  70. package/src/dotted-chart/dotted-chart.tsx +841 -0
  71. package/src/dotted-chart/index.ts +23 -0
  72. package/src/dotted-chart/use-element-size.ts +33 -0
  73. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  74. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  75. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  76. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  77. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  78. package/src/happy-path-editor/index.ts +4 -0
  79. package/src/index.ts +51 -1
  80. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  81. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  82. package/src/performance-spectrum/index.ts +25 -0
  83. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  84. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  85. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  86. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  87. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  88. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  89. package/src/process-compare/compare-model.ts +83 -0
  90. package/src/process-compare/compare-side.tsx +42 -0
  91. package/src/process-compare/diff-to-graph.ts +104 -0
  92. package/src/process-compare/index.ts +23 -0
  93. package/src/process-compare/process-compare.stories.tsx +184 -0
  94. package/src/process-compare/process-compare.test.tsx +224 -0
  95. package/src/process-compare/process-compare.tsx +251 -0
  96. package/src/process-explorer.stories.tsx +1 -1
  97. package/src/process-filter-bar/index.ts +2 -0
  98. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  99. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  100. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  101. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  102. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  103. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  104. package/src/process-map/activity-accent.ts +25 -0
  105. package/src/process-map/index.ts +1 -0
  106. package/src/process-map/map-model.test.ts +16 -0
  107. package/src/process-map/map-model.ts +323 -1
  108. package/src/process-map/object-centric-map.test.tsx +132 -0
  109. package/src/process-map/process-activity-node.tsx +152 -14
  110. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  111. package/src/process-map/process-map.stories.tsx +64 -0
  112. package/src/process-map/process-map.tsx +240 -16
  113. package/src/process-map/process-transition-edge.test.tsx +47 -0
  114. package/src/process-map/process-transition-edge.tsx +134 -7
  115. package/src/process-map/use-process-layout.ts +30 -9
  116. package/src/process-replay/congestion-heat.tsx +107 -0
  117. package/src/process-replay/index.ts +14 -0
  118. package/src/process-replay/process-replay.stories.tsx +168 -0
  119. package/src/process-replay/process-replay.test.tsx +170 -0
  120. package/src/process-replay/process-replay.tsx +285 -0
  121. package/src/process-replay/replay-controls.tsx +147 -0
  122. package/src/process-replay/replay-format.ts +83 -0
  123. package/src/process-replay/replay-tokens-context.ts +30 -0
  124. package/src/process-replay/use-controllable-value.ts +30 -0
  125. package/src/templates-process-explorer.stories.tsx +1304 -0
  126. package/src/test/contract.test.ts +66 -0
  127. package/src/test/contract.ts +107 -6
  128. package/src/test/doubles.test.tsx +87 -1
  129. package/src/test/doubles.tsx +174 -3
  130. package/src/test/index.ts +25 -1
  131. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  132. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  133. package/src/variant-explorer/coverage-bar.tsx +36 -0
  134. package/src/variant-explorer/index.ts +16 -0
  135. package/src/variant-explorer/sequence-chips.tsx +103 -0
  136. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  137. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  138. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  139. package/src/variant-explorer/variant-explorer.tsx +567 -0
  140. package/src/variant-explorer/variant-row.tsx +137 -0
  141. package/src/violation-list/index.ts +2 -0
  142. package/src/violation-list/violation-list.stories.tsx +73 -0
  143. package/src/violation-list/violation-list.test.tsx +84 -0
  144. package/src/violation-list/violation-list.tsx +259 -0
@@ -0,0 +1,285 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProcessReplay — cases replayed as tokens moving over the process map (RM-065).
5
+ *
6
+ * A composition, not a new map: `ProcessMap` (read-only — no `onSelect`), with the tokens
7
+ * handed to its own transition edges through {@link ProcessReplayTokensContext}. The edges
8
+ * draw them with `@elabs-ai/components-flow`'s `FlowEdgeTokens`, which owns every SVG mark;
9
+ * this component owns only the clock.
10
+ *
11
+ * - **Time.** `replayTimeline` buckets the log; the playhead is a controllable `time`, driven
12
+ * by a `requestAnimationFrame` loop while `playing`. At 1× the whole timeline plays in
13
+ * {@link REPLAY_PLAYBACK_MS}. Reaching the end pauses.
14
+ * - **Congestion without colour.** One token per in-flight case, so a busy edge carries more
15
+ * tokens; each token's radius grows with its edge's congestion in the current bucket
16
+ * (`replayTokenRadius`). {@link CongestionHeat} states the ranking in words beside the map.
17
+ * The weighted-edge value ramp is deliberately not used.
18
+ * - **Motion.** Never starts on its own under reduced motion: `defaultPlaying` and a
19
+ * parent's `playing` are both held until the reader presses play. The tokens then jump
20
+ * rather than glide (the flow primitive drops its transition).
21
+ * - **Text alternative.** The tokens are `aria-hidden`. One polite live region announces the
22
+ * playhead and the in-flight case count whenever the replay is paused or scrubbed — not
23
+ * frame by frame while it plays.
24
+ */
25
+ import {
26
+ forwardRef,
27
+ useCallback,
28
+ useEffect,
29
+ useLayoutEffect,
30
+ useMemo,
31
+ useRef,
32
+ useState,
33
+ type HTMLAttributes,
34
+ } from "react";
35
+ import { StatePanel } from "@elabs-ai/components-ui";
36
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
37
+ import { useReducedMotion } from "@elabs-ai/components-tokens";
38
+ import type { FlowEdgeToken, FlowLayoutDirection } from "@elabs-ai/components-flow";
39
+ import { replayFrameAt, replayTimeline, replayTokenRadius } from "../core/replay-timeline";
40
+ import type { EventLog, ProcessGraph } from "../core/types";
41
+ import type { ProcessMetricSpec } from "../process-map/map-model";
42
+ import { ProcessMap } from "../process-map/process-map";
43
+ import { fillLabel } from "../variant-explorer/variant-explorer-model";
44
+ import { CongestionHeat } from "./congestion-heat";
45
+ import { ReplayControls } from "./replay-controls";
46
+ import {
47
+ PROCESS_REPLAY_DEFAULT_LABELS,
48
+ useReplayTimeFormatter,
49
+ type ProcessReplayLabels,
50
+ } from "./replay-format";
51
+ import { ProcessReplayTokensContext, type ProcessReplayTokens } from "./replay-tokens-context";
52
+ import { useControllableValue } from "./use-controllable-value";
53
+
54
+ /** Wall-clock length of a full replay at 1×, in ms. */
55
+ export const REPLAY_PLAYBACK_MS = 30_000;
56
+
57
+ /** The metric the map paints when none is given: case frequency on both marks. */
58
+ export const PROCESS_REPLAY_DEFAULT_METRIC: ProcessMetricSpec = Object.freeze({
59
+ node: "absolute_case",
60
+ edge: "absolute",
61
+ }) as ProcessMetricSpec;
62
+
63
+ /** Props for {@link ProcessReplay}. `onSelect` is not offered: the map is read-only. */
64
+ export interface ProcessReplayProps extends HTMLAttributes<HTMLDivElement> {
65
+ /** The discovered graph to replay over. */
66
+ graph: ProcessGraph;
67
+ /** The log the graph came from — tokens need per-case timing, not just the aggregate. */
68
+ log: EventLog;
69
+ /** Align every case's start to `t = 0` instead of the wall clock. @default false */
70
+ synchronizedStart?: boolean;
71
+ /** Bucket width in ms for congestion. @default the log span / 300 */
72
+ bucketMs?: number;
73
+ /** Which readings the nodes and edges print. @default case frequency */
74
+ metric?: ProcessMetricSpec;
75
+ /** @default "TB" */
76
+ direction?: FlowLayoutDirection;
77
+ /** Controlled playing state. Held while reduced motion is on until the reader presses play. */
78
+ playing?: boolean;
79
+ /** Initial playing state when uncontrolled. Ignored under reduced motion. @default false */
80
+ defaultPlaying?: boolean;
81
+ onPlayingChange?: (playing: boolean) => void;
82
+ /** Controlled playhead, ms relative to the timeline origin. */
83
+ time?: number;
84
+ /** @default 0 */
85
+ defaultTime?: number;
86
+ onTimeChange?: (time: number) => void;
87
+ /** Controlled playback multiplier. */
88
+ speed?: number;
89
+ /** @default 1 */
90
+ defaultSpeed?: number;
91
+ onSpeedChange?: (speed: number) => void;
92
+ /** How many transitions the congestion list ranks. @default 5 */
93
+ congestionLimit?: number;
94
+ /** No log yet. Renders the loading panel. */
95
+ loading?: boolean;
96
+ /** Override any user-visible string. */
97
+ labels?: Partial<ProcessReplayLabels>;
98
+ }
99
+
100
+ /**
101
+ * The process replay.
102
+ *
103
+ * @example
104
+ * ```tsx
105
+ * const graph = useMemo(() => discoverGraph(log), [log]);
106
+ * <ProcessReplay graph={graph} log={log} synchronizedStart />
107
+ * ```
108
+ */
109
+ export const ProcessReplay = forwardRef<HTMLDivElement, ProcessReplayProps>(function ProcessReplay(
110
+ {
111
+ graph,
112
+ log,
113
+ synchronizedStart = false,
114
+ bucketMs,
115
+ metric = PROCESS_REPLAY_DEFAULT_METRIC,
116
+ direction,
117
+ playing: playingProp,
118
+ defaultPlaying = false,
119
+ onPlayingChange,
120
+ time: timeProp,
121
+ defaultTime = 0,
122
+ onTimeChange,
123
+ speed: speedProp,
124
+ defaultSpeed = 1,
125
+ onSpeedChange,
126
+ congestionLimit = 5,
127
+ loading = false,
128
+ labels: labelOverrides,
129
+ className,
130
+ ...props
131
+ },
132
+ ref,
133
+ ) {
134
+ const labels = useMemo<ProcessReplayLabels>(
135
+ () => ({ ...PROCESS_REPLAY_DEFAULT_LABELS, ...labelOverrides }),
136
+ [labelOverrides],
137
+ );
138
+ const reducedMotion = useReducedMotion();
139
+ const timeline = useMemo(
140
+ () => replayTimeline(log, { bucketMs, synchronizedStart }),
141
+ [log, bucketMs, synchronizedStart],
142
+ );
143
+ const { duration } = timeline;
144
+
145
+ const [playingRequested, setPlaying] = useControllableValue(
146
+ playingProp,
147
+ defaultPlaying,
148
+ onPlayingChange,
149
+ );
150
+ const [rawTime, setTime] = useControllableValue(timeProp, defaultTime, onTimeChange);
151
+ const [speed, setSpeed] = useControllableValue(speedProp, defaultSpeed, onSpeedChange);
152
+ const time = Math.min(Math.max(rawTime, 0), duration);
153
+
154
+ // Reduced motion: nothing moves until the reader asks for it with the play button.
155
+ const [readerStarted, setReaderStarted] = useState(false);
156
+ const playing = playingRequested && duration > 0 && (!reducedMotion || readerStarted);
157
+
158
+ const timeRef = useRef(time);
159
+ useLayoutEffect(() => {
160
+ timeRef.current = time;
161
+ });
162
+
163
+ useEffect(() => {
164
+ if (!playing) return;
165
+ let handle = 0;
166
+ let last: number | null = null;
167
+ const tick = (now: number) => {
168
+ if (last !== null) {
169
+ const advance = ((now - last) * duration * speed) / REPLAY_PLAYBACK_MS;
170
+ const next = Math.min(timeRef.current + advance, duration);
171
+ timeRef.current = next;
172
+ setTime(next);
173
+ if (next >= duration) {
174
+ setPlaying(false);
175
+ return;
176
+ }
177
+ }
178
+ last = now;
179
+ handle = requestAnimationFrame(tick);
180
+ };
181
+ handle = requestAnimationFrame(tick);
182
+ return () => cancelAnimationFrame(handle);
183
+ }, [playing, duration, speed, setTime, setPlaying]);
184
+
185
+ const handlePlayingChange = useCallback(
186
+ (next: boolean) => {
187
+ if (next) {
188
+ setReaderStarted(true);
189
+ if (timeRef.current >= duration) setTime(0);
190
+ }
191
+ setPlaying(next);
192
+ },
193
+ [duration, setPlaying, setTime],
194
+ );
195
+
196
+ const frame = useMemo(() => replayFrameAt(timeline, time), [timeline, time]);
197
+ const tokensByEdge = useMemo<ProcessReplayTokens>(() => {
198
+ const map = new Map<string, FlowEdgeToken[]>();
199
+ for (const token of frame.tokens) {
200
+ let list = map.get(token.edgeId);
201
+ if (!list) map.set(token.edgeId, (list = []));
202
+ list.push({
203
+ id: token.caseId,
204
+ progress: token.progress,
205
+ radius: replayTokenRadius(frame.congestion[token.edgeId] ?? 0, timeline.peakCongestion),
206
+ });
207
+ }
208
+ return map;
209
+ }, [frame, timeline.peakCongestion]);
210
+
211
+ const formatTime = useReplayTimeFormatter(timeline, labels.relativeTime);
212
+ const inFlight = frame.tokens.length;
213
+
214
+ // The map element is memoized on its own inputs, so a playhead tick re-renders only the
215
+ // edges that read the tokens context — never the layout or the model.
216
+ const map = useMemo(
217
+ () => <ProcessMap graph={graph} metric={metric} direction={direction} label={labels.map} />,
218
+ [graph, metric, direction, labels.map],
219
+ );
220
+
221
+ if (loading) {
222
+ return (
223
+ <div
224
+ ref={ref}
225
+ data-slot="process-replay"
226
+ data-state="loading"
227
+ className={className}
228
+ {...props}
229
+ >
230
+ <StatePanel kind="loading" title={labels.loading} />
231
+ </div>
232
+ );
233
+ }
234
+
235
+ if (timeline.segments.length === 0 && duration === 0) {
236
+ return (
237
+ <div ref={ref} data-slot="process-replay" data-state="empty" className={className} {...props}>
238
+ <StatePanel kind="empty" title={labels.empty} description={labels.emptyBody} />
239
+ </div>
240
+ );
241
+ }
242
+
243
+ return (
244
+ <div
245
+ ref={ref}
246
+ role="region"
247
+ aria-label={labels.region}
248
+ data-slot="process-replay"
249
+ data-state={playing ? "playing" : "paused"}
250
+ data-synchronized-start={synchronizedStart ? "true" : undefined}
251
+ className={cn("flex size-full min-h-0 flex-col gap-3", className)}
252
+ {...props}
253
+ >
254
+ <ReplayControls
255
+ playing={playing}
256
+ onPlayingChange={handlePlayingChange}
257
+ time={time}
258
+ onTimeChange={setTime}
259
+ duration={duration}
260
+ step={timeline.bucketMs}
261
+ speed={speed}
262
+ onSpeedChange={setSpeed}
263
+ inFlight={inFlight}
264
+ formatTime={formatTime}
265
+ labels={labels}
266
+ />
267
+ <p role="status" aria-live="polite" data-slot="process-replay-status" className="sr-only">
268
+ {playing
269
+ ? labels.playing
270
+ : fillLabel(labels.readout, { time: formatTime(time), count: inFlight })}
271
+ </p>
272
+ <div className="flex min-h-0 flex-1 flex-col gap-3 lg:flex-row">
273
+ <div data-slot="process-replay-map" data-in-flight={inFlight} className="min-h-64 flex-1">
274
+ <ProcessReplayTokensContext value={tokensByEdge}>{map}</ProcessReplayTokensContext>
275
+ </div>
276
+ <CongestionHeat
277
+ timeline={timeline}
278
+ limit={congestionLimit}
279
+ labels={labels}
280
+ className="shrink-0 lg:w-72"
281
+ />
282
+ </div>
283
+ </div>
284
+ );
285
+ });
@@ -0,0 +1,147 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ReplayControls — the transport bar of `ProcessReplay` (RM-065).
5
+ *
6
+ * Presentational and fully controlled: a play/pause button, a time slider and a speed select,
7
+ * all native-keyboard components from `@elabs-ai/components-ui` (Space/Enter on the button,
8
+ * arrows/Home/End/PageUp/PageDown on the slider, arrows and typeahead in the select), plus a
9
+ * visible readout of the playhead and the in-flight case count. The readout is NOT a live
10
+ * region — `ProcessReplay` owns the one announcement per region.
11
+ */
12
+ import { forwardRef, useMemo, type HTMLAttributes } from "react";
13
+ import { Pause, Play } from "lucide-react";
14
+ import {
15
+ Button,
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ Slider,
22
+ useLocale,
23
+ } from "@elabs-ai/components-ui";
24
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
25
+ import { fillLabel } from "../variant-explorer/variant-explorer-model";
26
+ import { PROCESS_REPLAY_DEFAULT_LABELS, type ProcessReplayLabels } from "./replay-format";
27
+
28
+ /** The speed multipliers the select offers. */
29
+ export const REPLAY_SPEEDS = Object.freeze([0.5, 1, 2, 4] as const);
30
+
31
+ /** Props for {@link ReplayControls}. */
32
+ export interface ReplayControlsProps extends HTMLAttributes<HTMLDivElement> {
33
+ playing: boolean;
34
+ onPlayingChange: (playing: boolean) => void;
35
+ /** Playhead, ms relative to the timeline origin. */
36
+ time: number;
37
+ onTimeChange: (time: number) => void;
38
+ /** Timeline length in ms — the slider's maximum. */
39
+ duration: number;
40
+ /** Slider step in ms (one bucket). */
41
+ step: number;
42
+ speed: number;
43
+ onSpeedChange: (speed: number) => void;
44
+ /** Cases in flight at {@link time}. */
45
+ inFlight: number;
46
+ /** Formats a playhead for the readout and the slider's value text. */
47
+ formatTime: (time: number) => string;
48
+ /** Disable every control (an empty timeline). */
49
+ disabled?: boolean;
50
+ labels?: Partial<ProcessReplayLabels>;
51
+ }
52
+
53
+ /** Play/pause, time slider, speed select and a readout. */
54
+ export const ReplayControls = forwardRef<HTMLDivElement, ReplayControlsProps>(
55
+ function ReplayControls(
56
+ {
57
+ playing,
58
+ onPlayingChange,
59
+ time,
60
+ onTimeChange,
61
+ duration,
62
+ step,
63
+ speed,
64
+ onSpeedChange,
65
+ inFlight,
66
+ formatTime,
67
+ disabled = false,
68
+ labels: labelOverrides,
69
+ className,
70
+ ...props
71
+ },
72
+ ref,
73
+ ) {
74
+ const { formatNumber } = useLocale();
75
+ const labels = useMemo<ProcessReplayLabels>(
76
+ () => ({ ...PROCESS_REPLAY_DEFAULT_LABELS, ...labelOverrides }),
77
+ [labelOverrides],
78
+ );
79
+ const timeText = formatTime(time);
80
+ const PlayIcon = playing ? Pause : Play;
81
+
82
+ return (
83
+ <div
84
+ ref={ref}
85
+ data-slot="replay-controls"
86
+ data-playing={playing ? "true" : "false"}
87
+ className={cn("flex flex-wrap items-center gap-3", className)}
88
+ {...props}
89
+ >
90
+ <Button
91
+ data-slot="replay-controls-play"
92
+ variant="outline"
93
+ size="icon"
94
+ disabled={disabled}
95
+ aria-label={playing ? labels.pause : labels.play}
96
+ onClick={() => onPlayingChange(!playing)}
97
+ >
98
+ <PlayIcon aria-hidden="true" />
99
+ </Button>
100
+ <Slider
101
+ data-slot="replay-controls-time"
102
+ className="min-w-40 flex-1"
103
+ min={0}
104
+ max={Math.max(duration, step)}
105
+ step={step}
106
+ value={[time]}
107
+ disabled={disabled}
108
+ aria-label={labels.time}
109
+ aria-valuetext={timeText}
110
+ onValueChange={([next]) => {
111
+ if (next !== undefined) onTimeChange(Math.min(next, duration));
112
+ }}
113
+ />
114
+ {/* A `SelectTrigger` is full-width by default, which in this wrapping row claimed a
115
+ line of its own — a speed picker as wide as the map. Its box is the speed, no more. */}
116
+ <div className="w-24 shrink-0">
117
+ <Select
118
+ value={String(speed)}
119
+ disabled={disabled}
120
+ onValueChange={(value) => onSpeedChange(Number(value))}
121
+ >
122
+ <SelectTrigger
123
+ data-slot="replay-controls-speed"
124
+ className="min-w-20"
125
+ aria-label={labels.speed}
126
+ >
127
+ <SelectValue />
128
+ </SelectTrigger>
129
+ <SelectContent>
130
+ {REPLAY_SPEEDS.map((option) => (
131
+ <SelectItem key={option} value={String(option)}>
132
+ {fillLabel(labels.speedOption, { speed: formatNumber(option) })}
133
+ </SelectItem>
134
+ ))}
135
+ </SelectContent>
136
+ </Select>
137
+ </div>
138
+ <p
139
+ data-slot="replay-controls-readout"
140
+ className="text-meta text-muted-foreground tabular-nums"
141
+ >
142
+ {fillLabel(labels.readout, { time: timeText, count: formatNumber(inFlight) })}
143
+ </p>
144
+ </div>
145
+ );
146
+ },
147
+ );
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Words and time formatting shared by the replay parts (RM-065).
5
+ */
6
+ import { useCallback } from "react";
7
+ import { useLocale } from "@elabs-ai/components-ui";
8
+ import type { ReplayTimeline } from "../core/replay-timeline";
9
+ import { formatDurationMs } from "../process-map/map-model";
10
+
11
+ /** Every user-visible string in `ProcessReplay` and its parts. `{…}` are placeholders. */
12
+ export interface ProcessReplayLabels {
13
+ /** Accessible name of the replay region. */
14
+ region: string;
15
+ /** Accessible name of the map canvas. */
16
+ map: string;
17
+ play: string;
18
+ pause: string;
19
+ /** The time slider. */
20
+ time: string;
21
+ /** The speed select. */
22
+ speed: string;
23
+ /** `{speed}` — one speed option. */
24
+ speedOption: string;
25
+ /** `{time}`, `{count}` — the readout and the paused announcement. */
26
+ readout: string;
27
+ /** Announced while the replay runs (the time is not announced frame by frame). */
28
+ playing: string;
29
+ /** `{duration}` — a playhead in synchronized-start mode. */
30
+ relativeTime: string;
31
+ congestionTitle: string;
32
+ /** `{source}`, `{target}`. */
33
+ congestionTransition: string;
34
+ /** `{peak}`, `{time}` — one ranked transition. */
35
+ congestionPeak: string;
36
+ /** `{source}`, `{target}`, `{peak}`, `{time}` — the one-line summary of the busiest transition. */
37
+ congestionSummary: string;
38
+ congestionEmpty: string;
39
+ loading: string;
40
+ empty: string;
41
+ emptyBody: string;
42
+ }
43
+
44
+ /** The shipped English labels. */
45
+ export const PROCESS_REPLAY_DEFAULT_LABELS: Readonly<ProcessReplayLabels> = Object.freeze({
46
+ region: "Process replay",
47
+ map: "Process map with case tokens",
48
+ play: "Play replay",
49
+ pause: "Pause replay",
50
+ time: "Replay time",
51
+ speed: "Playback speed",
52
+ speedOption: "{speed}×",
53
+ readout: "{time} · {count} in flight",
54
+ playing: "Replay playing",
55
+ relativeTime: "+{duration}",
56
+ congestionTitle: "Most congested transitions",
57
+ congestionTransition: "{source} → {target}",
58
+ congestionPeak: "Peak {peak} cases at {time}",
59
+ congestionSummary: "Busiest: {source} → {target}, {peak} cases at {time}",
60
+ congestionEmpty: "No case moves along a transition.",
61
+ loading: "Preparing the replay…",
62
+ empty: "No cases to replay",
63
+ emptyBody: "Load an event log with at least one case to replay it.",
64
+ });
65
+
66
+ /**
67
+ * Formats a playhead: a medium date and short time in wall-clock mode, `+duration` since each
68
+ * case's start in synchronized-start mode.
69
+ */
70
+ export function useReplayTimeFormatter(
71
+ timeline: Pick<ReplayTimeline, "origin" | "synchronizedStart">,
72
+ relativeTemplate: string = PROCESS_REPLAY_DEFAULT_LABELS.relativeTime,
73
+ ): (t: number) => string {
74
+ const { formatDate } = useLocale();
75
+ const { origin, synchronizedStart } = timeline;
76
+ return useCallback(
77
+ (t: number) =>
78
+ synchronizedStart
79
+ ? relativeTemplate.replace("{duration}", formatDurationMs(t))
80
+ : formatDate(origin + t, { dateStyle: "medium", timeStyle: "short" }),
81
+ [formatDate, origin, synchronizedStart, relativeTemplate],
82
+ );
83
+ }
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The replay channel into the process map (RM-065).
5
+ *
6
+ * `ProcessMap` fixes its own edge types, so a replay cannot hand tokens to an edge through
7
+ * props. `ProcessReplay` provides this context above the map instead, keyed by edge id
8
+ * (`processEdgeId`); `ProcessTransitionEdge` reads its own entry and passes it on as the flow
9
+ * edge's `data.tokens`. Without a provider the read answers `undefined` and the edge's data
10
+ * is exactly what it was before this channel existed.
11
+ *
12
+ * Kept in its own tiny module so the edge imports no replay component.
13
+ */
14
+ import { createContext, use } from "react";
15
+ import type { FlowEdgeToken } from "@elabs-ai/components-flow";
16
+
17
+ /** Edge id → the tokens on that edge right now. An edge with no entry carries none. */
18
+ export type ProcessReplayTokens = ReadonlyMap<string, readonly FlowEdgeToken[]>;
19
+
20
+ /** Provided by `ProcessReplay`. `null` (the default) means no replay is running. */
21
+ export const ProcessReplayTokensContext = createContext<ProcessReplayTokens | null>(null);
22
+
23
+ /** The tokens on one edge, or `undefined` outside a replay. */
24
+ export function useProcessReplayEdgeTokens(edgeId: string): readonly FlowEdgeToken[] | undefined {
25
+ const tokens = use(ProcessReplayTokensContext);
26
+ if (tokens === null) return undefined;
27
+ return tokens.get(edgeId) ?? EMPTY_TOKENS;
28
+ }
29
+
30
+ const EMPTY_TOKENS: readonly FlowEdgeToken[] = Object.freeze([]);
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Controlled/uncontrolled state with a STABLE setter (RM-065).
5
+ *
6
+ * Mirrors `useControllableState` in `@elabs-ai/components-ui` (mode locked on mount,
7
+ * `onChange` always called), which that package does not export. The one difference is the
8
+ * point of this copy: the setter's identity never changes, even when a consumer passes an
9
+ * inline `onChange`, so the replay's animation-frame loop does not restart every frame.
10
+ */
11
+ import { useCallback, useLayoutEffect, useRef, useState } from "react";
12
+
13
+ export function useControllableValue<T>(
14
+ controlledValue: T | undefined,
15
+ defaultValue: T,
16
+ onChange?: (value: T) => void,
17
+ ): [T, (next: T) => void] {
18
+ const isControlledRef = useRef(controlledValue !== undefined);
19
+ const [internal, setInternal] = useState<T>(defaultValue);
20
+ const value = isControlledRef.current ? (controlledValue as T) : internal;
21
+ const onChangeRef = useRef(onChange);
22
+ useLayoutEffect(() => {
23
+ onChangeRef.current = onChange;
24
+ });
25
+ const setValue = useCallback((next: T) => {
26
+ if (!isControlledRef.current) setInternal(next);
27
+ onChangeRef.current?.(next);
28
+ }, []);
29
+ return [value, setValue];
30
+ }