@elabs-ai/components-process 4.2.0 → 5.0.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 +8 -1
- package/dist/core/index.d.ts +801 -3
- package/dist/core/index.js +1334 -0
- package/dist/core/index.js.map +1 -1
- package/dist/index.d.ts +1889 -34
- package/dist/index.js +5512 -196
- package/dist/index.js.map +1 -1
- package/dist/test/index.d.ts +223 -5
- package/dist/test/index.js +346 -191
- package/dist/test/index.js.map +1 -1
- package/package.json +14 -13
- package/src/__contract__/case-table.contract.test.tsx +49 -0
- package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
- package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
- package/src/__contract__/violation-list.contract.test.tsx +49 -0
- package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
- package/src/abstraction-controls/abstraction-controls.tsx +198 -5
- package/src/case-table/case-table.stories.tsx +89 -0
- package/src/case-table/case-table.test.tsx +148 -0
- package/src/case-table/case-table.tsx +144 -0
- package/src/case-table/columns.ts +116 -0
- package/src/case-table/index.ts +11 -0
- package/src/case-timeline/case-timeline-model.test.ts +72 -0
- package/src/case-timeline/case-timeline-model.ts +112 -0
- package/src/case-timeline/case-timeline.stories.tsx +94 -0
- package/src/case-timeline/case-timeline.test.tsx +51 -0
- package/src/case-timeline/case-timeline.tsx +109 -0
- package/src/case-timeline/index.ts +9 -0
- package/src/conformance-overlay/conformance-fixture.ts +59 -0
- package/src/conformance-overlay/conformance-legend.tsx +109 -0
- package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
- package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
- package/src/conformance-overlay/conformance-overlay.tsx +107 -0
- package/src/conformance-overlay/conformance-state.test.ts +79 -0
- package/src/conformance-overlay/conformance-state.ts +220 -0
- package/src/conformance-overlay/index.ts +4 -0
- package/src/core/activity-color-scale.test.ts +107 -0
- package/src/core/activity-color-scale.ts +133 -0
- package/src/core/adapters/ocel.test.ts +112 -0
- package/src/core/adapters/ocel.ts +359 -0
- package/src/core/adapters/xes.test.ts +293 -0
- package/src/core/adapters/xes.ts +384 -0
- package/src/core/cases-from-log.test.ts +72 -0
- package/src/core/cases-from-log.ts +85 -0
- package/src/core/conformance.test.ts +80 -0
- package/src/core/conformance.ts +91 -0
- package/src/core/diff-graphs.test.ts +151 -0
- package/src/core/diff-graphs.ts +118 -0
- package/src/core/discover-object-centric-graph.test.ts +94 -0
- package/src/core/discover-object-centric-graph.ts +296 -0
- package/src/core/fixtures/ocel-sample.ts +82 -0
- package/src/core/fixtures/sample.xes +68 -0
- package/src/core/index.ts +113 -0
- package/src/core/reference-model.test.ts +43 -0
- package/src/core/reference-model.ts +116 -0
- package/src/core/replay-timeline.test.ts +161 -0
- package/src/core/replay-timeline.ts +260 -0
- package/src/core/segments.test.ts +185 -0
- package/src/core/segments.ts +153 -0
- package/src/core/token-replay.test.ts +218 -0
- package/src/core/token-replay.ts +456 -0
- package/src/core/types.ts +2 -2
- package/src/dotted-chart/compute-dots.test.ts +176 -0
- package/src/dotted-chart/compute-dots.ts +241 -0
- package/src/dotted-chart/dotted-chart-labels.ts +93 -0
- package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
- package/src/dotted-chart/dotted-chart.test.tsx +135 -0
- package/src/dotted-chart/dotted-chart.tsx +841 -0
- package/src/dotted-chart/index.ts +23 -0
- package/src/dotted-chart/use-element-size.ts +33 -0
- package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
- package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
- package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
- package/src/happy-path-editor/happy-path-editor.tsx +239 -0
- package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
- package/src/happy-path-editor/index.ts +4 -0
- package/src/index.ts +51 -1
- package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
- package/src/performance-spectrum/aggregate-segments.ts +174 -0
- package/src/performance-spectrum/index.ts +25 -0
- package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
- package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
- package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
- package/src/performance-spectrum/performance-spectrum.tsx +870 -0
- package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
- package/src/process-compare/compare-kpi-strip.tsx +94 -0
- package/src/process-compare/compare-model.ts +83 -0
- package/src/process-compare/compare-side.tsx +42 -0
- package/src/process-compare/diff-to-graph.ts +104 -0
- package/src/process-compare/index.ts +23 -0
- package/src/process-compare/process-compare.stories.tsx +184 -0
- package/src/process-compare/process-compare.test.tsx +224 -0
- package/src/process-compare/process-compare.tsx +251 -0
- package/src/process-explorer.stories.tsx +1 -1
- package/src/process-filter-bar/index.ts +2 -0
- package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
- package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
- package/src/process-filter-bar/process-filter-bar.tsx +167 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
- package/src/process-map/activity-accent.ts +25 -0
- package/src/process-map/index.ts +1 -0
- package/src/process-map/map-model.test.ts +16 -0
- package/src/process-map/map-model.ts +323 -1
- package/src/process-map/object-centric-map.test.tsx +132 -0
- package/src/process-map/process-activity-node.tsx +152 -14
- package/src/process-map/process-map-object-centric.stories.tsx +219 -0
- package/src/process-map/process-map.stories.tsx +64 -0
- package/src/process-map/process-map.tsx +240 -16
- package/src/process-map/process-transition-edge.test.tsx +47 -0
- package/src/process-map/process-transition-edge.tsx +134 -7
- package/src/process-map/use-process-layout.ts +30 -9
- package/src/process-replay/congestion-heat.tsx +107 -0
- package/src/process-replay/index.ts +14 -0
- package/src/process-replay/process-replay.stories.tsx +168 -0
- package/src/process-replay/process-replay.test.tsx +170 -0
- package/src/process-replay/process-replay.tsx +285 -0
- package/src/process-replay/replay-controls.tsx +147 -0
- package/src/process-replay/replay-format.ts +83 -0
- package/src/process-replay/replay-tokens-context.ts +30 -0
- package/src/process-replay/use-controllable-value.ts +30 -0
- package/src/templates-process-explorer.stories.tsx +1304 -0
- package/src/test/contract.test.ts +66 -0
- package/src/test/contract.ts +107 -6
- package/src/test/doubles.test.tsx +87 -1
- package/src/test/doubles.tsx +174 -3
- package/src/test/index.ts +25 -1
- package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
- package/src/use-process-explorer/use-process-explorer.ts +34 -2
- package/src/variant-explorer/coverage-bar.tsx +36 -0
- package/src/variant-explorer/index.ts +16 -0
- package/src/variant-explorer/sequence-chips.tsx +103 -0
- package/src/variant-explorer/variant-explorer-model.ts +42 -0
- package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
- package/src/variant-explorer/variant-explorer.test.tsx +302 -0
- package/src/variant-explorer/variant-explorer.tsx +567 -0
- package/src/variant-explorer/variant-row.tsx +137 -0
- package/src/violation-list/index.ts +2 -0
- package/src/violation-list/violation-list.stories.tsx +73 -0
- package/src/violation-list/violation-list.test.tsx +84 -0
- 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
|
+
}
|