@elabs-ai/components-process 4.1.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__/process-kpi-strip.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,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { liftHappyPath } from "./reference-model";
|
|
4
|
+
|
|
5
|
+
describe("liftHappyPath", () => {
|
|
6
|
+
it("lifts a plain sequence to one place between consecutive steps", () => {
|
|
7
|
+
const model = liftHappyPath({
|
|
8
|
+
id: "p",
|
|
9
|
+
label: "Plain",
|
|
10
|
+
steps: [{ activity: "A" }, { activity: "B" }],
|
|
11
|
+
});
|
|
12
|
+
expect(model.places).toEqual(["p0", "p1", "p2"]);
|
|
13
|
+
expect(model.initialMarking).toEqual(["p0"]);
|
|
14
|
+
expect(model.finalMarking).toEqual(["p2"]);
|
|
15
|
+
expect(model.transitions).toEqual([
|
|
16
|
+
{ id: "t0", activity: "A", kind: "step", consumes: ["p0"], produces: ["p1"] },
|
|
17
|
+
{ id: "t1", activity: "B", kind: "step", consumes: ["p1"], produces: ["p2"] },
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("adds a silent skip arc for an optional step and a self-loop for a repeatable one", () => {
|
|
22
|
+
const model = liftHappyPath({
|
|
23
|
+
id: "p",
|
|
24
|
+
label: "Flags",
|
|
25
|
+
steps: [{ activity: "A", optional: true, repeatable: true }],
|
|
26
|
+
});
|
|
27
|
+
expect(model.transitions).toEqual([
|
|
28
|
+
{ id: "t0", activity: "A", kind: "step", consumes: ["p0"], produces: ["p1"] },
|
|
29
|
+
{ id: "skip0", activity: "A", kind: "skip", consumes: ["p0"], produces: ["p1"] },
|
|
30
|
+
{ id: "repeat0", activity: "A", kind: "repeat", consumes: ["p1"], produces: ["p1"] },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("lifts an empty path to a single place that is both initial and final", () => {
|
|
35
|
+
const model = liftHappyPath({ id: "e", label: "Empty", steps: [] });
|
|
36
|
+
expect(model).toEqual({
|
|
37
|
+
places: ["p0"],
|
|
38
|
+
initialMarking: ["p0"],
|
|
39
|
+
finalMarking: ["p0"],
|
|
40
|
+
transitions: [],
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference model — RM-061.
|
|
3
|
+
*
|
|
4
|
+
* A HAPPY PATH (the prescribed sequence of activities an owner or auditor expects) lifted
|
|
5
|
+
* to the smallest workflow-net-like structure token replay can run against: one place
|
|
6
|
+
* between consecutive steps, one visible transition per step, a silent SKIP transition
|
|
7
|
+
* beside every optional step and a SELF-LOOP transition after every repeatable one.
|
|
8
|
+
*
|
|
9
|
+
* Scope boundary (analysis §9 risk 2): this is deliberately NOT a general Petri-net
|
|
10
|
+
* importer. No BPMN, no parallel gateways, no alignments beyond skip/repeat. A host that
|
|
11
|
+
* needs full alignment-based conformance brings a backend; this module never grows one.
|
|
12
|
+
*
|
|
13
|
+
* Framework-free, deterministic: no React, no `@elabs-ai/components-*` import.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One prescribed step of a happy path. */
|
|
17
|
+
export interface HappyPathStep {
|
|
18
|
+
/** Activity name, matched exactly against `EventRow.activity`. */
|
|
19
|
+
activity: string;
|
|
20
|
+
/** The step may be left out without a deviation (a silent skip arc is added). */
|
|
21
|
+
optional?: boolean;
|
|
22
|
+
/** The step may run several times in a row without a deviation (a self-loop is added). */
|
|
23
|
+
repeatable?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A prescribed process: an ordered list of steps. */
|
|
27
|
+
export interface HappyPath {
|
|
28
|
+
id: string;
|
|
29
|
+
label: string;
|
|
30
|
+
steps: HappyPathStep[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What a transition stands for. `"step"` fires a step's activity for the first time,
|
|
35
|
+
* `"repeat"` fires it again from the place after it, `"skip"` is SILENT (no activity is
|
|
36
|
+
* observed) and moves the token past an optional step.
|
|
37
|
+
*/
|
|
38
|
+
export type ReplayTransitionKind = "step" | "skip" | "repeat";
|
|
39
|
+
|
|
40
|
+
/** One transition of a {@link ReplayModel}. */
|
|
41
|
+
export interface ReplayTransition {
|
|
42
|
+
id: string;
|
|
43
|
+
/** The activity this transition fires on. The skipped step's activity for a `"skip"`. */
|
|
44
|
+
activity: string;
|
|
45
|
+
kind: ReplayTransitionKind;
|
|
46
|
+
/** Input places — one token each is consumed when the transition fires. */
|
|
47
|
+
consumes: string[];
|
|
48
|
+
/** Output places — one token each is produced when the transition fires. */
|
|
49
|
+
produces: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A workflow-net-like replay structure. Places are listed in path order. */
|
|
53
|
+
export interface ReplayModel {
|
|
54
|
+
places: string[];
|
|
55
|
+
initialMarking: string[];
|
|
56
|
+
finalMarking: string[];
|
|
57
|
+
transitions: ReplayTransition[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Name of the place before step `index` (and after step `index - 1`). */
|
|
61
|
+
function placeName(index: number): string {
|
|
62
|
+
return `p${index}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Lift a happy path to a {@link ReplayModel}.
|
|
67
|
+
*
|
|
68
|
+
* For `n` steps the model has places `p0 … pn`, starts with one token in `p0` and ends
|
|
69
|
+
* with one token in `pn`. Step `i` gets transition `t<i>` (`p<i>` → `p<i+1>`); an optional
|
|
70
|
+
* step also gets a silent `skip<i>` over the same places; a repeatable step also gets
|
|
71
|
+
* `repeat<i>` consuming and producing `p<i+1>`. An empty path lifts to a single place
|
|
72
|
+
* that is both initial and final.
|
|
73
|
+
*/
|
|
74
|
+
export function liftHappyPath(path: HappyPath): ReplayModel {
|
|
75
|
+
const steps = path.steps;
|
|
76
|
+
const places: string[] = [];
|
|
77
|
+
for (let i = 0; i <= steps.length; i += 1) places.push(placeName(i));
|
|
78
|
+
|
|
79
|
+
const transitions: ReplayTransition[] = [];
|
|
80
|
+
steps.forEach((step, i) => {
|
|
81
|
+
const before = placeName(i);
|
|
82
|
+
const after = placeName(i + 1);
|
|
83
|
+
transitions.push({
|
|
84
|
+
id: `t${i}`,
|
|
85
|
+
activity: step.activity,
|
|
86
|
+
kind: "step",
|
|
87
|
+
consumes: [before],
|
|
88
|
+
produces: [after],
|
|
89
|
+
});
|
|
90
|
+
if (step.optional) {
|
|
91
|
+
transitions.push({
|
|
92
|
+
id: `skip${i}`,
|
|
93
|
+
activity: step.activity,
|
|
94
|
+
kind: "skip",
|
|
95
|
+
consumes: [before],
|
|
96
|
+
produces: [after],
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (step.repeatable) {
|
|
100
|
+
transitions.push({
|
|
101
|
+
id: `repeat${i}`,
|
|
102
|
+
activity: step.activity,
|
|
103
|
+
kind: "repeat",
|
|
104
|
+
consumes: [after],
|
|
105
|
+
produces: [after],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
places,
|
|
112
|
+
initialMarking: [placeName(0)],
|
|
113
|
+
finalMarking: [placeName(steps.length)],
|
|
114
|
+
transitions,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { EDGE_KEY_SEPARATOR } from "./discover-graph";
|
|
4
|
+
import fixture from "./fixtures/order-to-cash-small.json";
|
|
5
|
+
import {
|
|
6
|
+
REPLAY_MAX_FRAMES,
|
|
7
|
+
REPLAY_TOKEN_RADIUS_RANGE,
|
|
8
|
+
rankReplayCongestion,
|
|
9
|
+
replayFrameAt,
|
|
10
|
+
replayTimeline,
|
|
11
|
+
replayTokenRadius,
|
|
12
|
+
} from "./replay-timeline";
|
|
13
|
+
import type { EventLog } from "./types";
|
|
14
|
+
|
|
15
|
+
const log = fixture as EventLog;
|
|
16
|
+
const HOUR = 3_600_000;
|
|
17
|
+
const edge = (source: string, target: string) => `${source}${EDGE_KEY_SEPARATOR}${target}`;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The five traces of `order-to-cash-small.json`, as hours after each case's own start
|
|
21
|
+
* (every event is atomic, so an edge runs from one event to the next):
|
|
22
|
+
*
|
|
23
|
+
* case-1 CO 0 · CC 1 · AO 2 · SO 3 · SI 4 · RP 5
|
|
24
|
+
* case-2 CO 0 · CC 1 · AO 2 · SO 3 · SI 4 · RP 5
|
|
25
|
+
* case-3 CO 0 · CC 1 · RO 2
|
|
26
|
+
* case-4 CO 0 · CC 1 · AmO 2 · CC 3 · AO 4 · SO 5 · SI 6 · RP 7
|
|
27
|
+
* case-5 CO 0 · CC 2 · AO 4 · SI 6 · SO 8 · RP 10
|
|
28
|
+
*
|
|
29
|
+
* A case is in flight at hour h when one of its edges spans [enter, exit) and contains h.
|
|
30
|
+
* Counted by hand from the five lines above:
|
|
31
|
+
*
|
|
32
|
+
* h 0 1 2 3 4 5 6 7 8 9 10
|
|
33
|
+
* c1 1 1 1 1 1 - - - - - -
|
|
34
|
+
* c2 1 1 1 1 1 - - - - - -
|
|
35
|
+
* c3 1 1 - - - - - - - - -
|
|
36
|
+
* c4 1 1 1 1 1 1 1 - - - -
|
|
37
|
+
* c5 1 1 1 1 1 1 1 1 1 1 -
|
|
38
|
+
* tokens 5 5 4 4 4 2 2 1 1 1 0
|
|
39
|
+
*/
|
|
40
|
+
const SYNCHRONIZED_TOKENS_PER_HOUR = [5, 5, 4, 4, 4, 2, 2, 1, 1, 1, 0];
|
|
41
|
+
|
|
42
|
+
describe("replayTimeline — synchronized start", () => {
|
|
43
|
+
const timeline = replayTimeline(log, { bucketMs: HOUR, synchronizedStart: true });
|
|
44
|
+
|
|
45
|
+
it("aligns all five cases' first activity to t = 0", () => {
|
|
46
|
+
expect(timeline.origin).toBe(0);
|
|
47
|
+
const firstMoves = new Map<string, number>();
|
|
48
|
+
for (const s of timeline.segments) {
|
|
49
|
+
if (!firstMoves.has(s.caseId)) firstMoves.set(s.caseId, s.enterAt);
|
|
50
|
+
}
|
|
51
|
+
expect([...firstMoves.keys()].sort()).toEqual([
|
|
52
|
+
"case-1",
|
|
53
|
+
"case-2",
|
|
54
|
+
"case-3",
|
|
55
|
+
"case-4",
|
|
56
|
+
"case-5",
|
|
57
|
+
]);
|
|
58
|
+
expect([...firstMoves.values()]).toEqual([0, 0, 0, 0, 0]);
|
|
59
|
+
expect(timeline.frames[0]!.tokens.map((token) => token.edgeId)).toEqual(
|
|
60
|
+
Array(5).fill(edge("Create Order", "Check Credit")),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("matches the hand-computed token count per bucket", () => {
|
|
65
|
+
expect(timeline.duration).toBe(10 * HOUR);
|
|
66
|
+
expect(timeline.frames.map((frame) => frame.t)).toEqual(
|
|
67
|
+
SYNCHRONIZED_TOKENS_PER_HOUR.map((_, h) => h * HOUR),
|
|
68
|
+
);
|
|
69
|
+
expect(timeline.frames.map((frame) => frame.tokens.length)).toEqual(
|
|
70
|
+
SYNCHRONIZED_TOKENS_PER_HOUR,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("counts distinct cases per edge per bucket as congestion", () => {
|
|
75
|
+
expect(timeline.frames[0]!.congestion).toEqual({ [edge("Create Order", "Check Credit")]: 5 });
|
|
76
|
+
// Hour 1: case-5 is still on its first edge; the rest have moved on.
|
|
77
|
+
expect(timeline.frames[1]!.congestion).toEqual({
|
|
78
|
+
[edge("Create Order", "Check Credit")]: 1,
|
|
79
|
+
[edge("Check Credit", "Approve Order")]: 2,
|
|
80
|
+
[edge("Check Credit", "Reject Order")]: 1,
|
|
81
|
+
[edge("Check Credit", "Amend Order")]: 1,
|
|
82
|
+
});
|
|
83
|
+
expect(timeline.peakCongestion).toBe(5);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("ranks the busiest transition first", () => {
|
|
87
|
+
const ranked = rankReplayCongestion(timeline, 3);
|
|
88
|
+
expect(ranked).toHaveLength(3);
|
|
89
|
+
expect(ranked[0]).toMatchObject({
|
|
90
|
+
edgeId: edge("Create Order", "Check Credit"),
|
|
91
|
+
source: "Create Order",
|
|
92
|
+
target: "Check Credit",
|
|
93
|
+
peak: 5,
|
|
94
|
+
peakAt: 0,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("replayTimeline — wall clock", () => {
|
|
100
|
+
const timeline = replayTimeline(log, { bucketMs: HOUR });
|
|
101
|
+
|
|
102
|
+
it("starts at the earliest event and runs to the latest", () => {
|
|
103
|
+
expect(timeline.origin).toBe(Date.parse("2026-01-05T09:00:00.000Z"));
|
|
104
|
+
// 2026-01-05T09:00 to 2026-01-09T19:00.
|
|
105
|
+
expect(timeline.duration).toBe(106 * HOUR);
|
|
106
|
+
expect(timeline.frames).toHaveLength(107);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("never has more than one case in flight — each starts on its own day", () => {
|
|
110
|
+
expect(Math.max(...timeline.frames.map((frame) => frame.tokens.length))).toBe(1);
|
|
111
|
+
expect(timeline.frames[24]!.tokens).toEqual([
|
|
112
|
+
{ caseId: "case-2", edgeId: edge("Create Order", "Check Credit"), progress: 0 },
|
|
113
|
+
]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("replayFrameAt", () => {
|
|
118
|
+
const timeline = replayTimeline(log, { bucketMs: HOUR, synchronizedStart: true });
|
|
119
|
+
|
|
120
|
+
it("interpolates progress between buckets and reads the containing bucket's congestion", () => {
|
|
121
|
+
const frame = replayFrameAt(timeline, 1.5 * HOUR);
|
|
122
|
+
expect(frame.tokens.find((token) => token.caseId === "case-5")).toEqual({
|
|
123
|
+
caseId: "case-5",
|
|
124
|
+
edgeId: edge("Create Order", "Check Credit"),
|
|
125
|
+
progress: 0.75,
|
|
126
|
+
});
|
|
127
|
+
expect(frame.congestion).toEqual(timeline.frames[1]!.congestion);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("clamps the playhead", () => {
|
|
131
|
+
expect(replayFrameAt(timeline, -5).t).toBe(0);
|
|
132
|
+
expect(replayFrameAt(timeline, 99 * HOUR).t).toBe(10 * HOUR);
|
|
133
|
+
expect(replayFrameAt(timeline, Number.NaN).t).toBe(0);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("edge cases", () => {
|
|
138
|
+
it("answers one empty frame for an empty log", () => {
|
|
139
|
+
const timeline = replayTimeline({ events: [] });
|
|
140
|
+
expect(timeline.duration).toBe(0);
|
|
141
|
+
expect(timeline.frames).toEqual([{ t: 0, tokens: [], congestion: {} }]);
|
|
142
|
+
expect(rankReplayCongestion(timeline)).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("widens a bucket that would exceed the frame cap", () => {
|
|
146
|
+
const timeline = replayTimeline(log, { bucketMs: 1 });
|
|
147
|
+
expect(timeline.frames.length).toBeLessThanOrEqual(REPLAY_MAX_FRAMES);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("defaults to about 300 frames", () => {
|
|
151
|
+
expect(replayTimeline(log).frames.length).toBe(301);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("scales token radius by area within the range", () => {
|
|
155
|
+
const [min, max] = REPLAY_TOKEN_RADIUS_RANGE;
|
|
156
|
+
expect(replayTokenRadius(0, 5)).toBe(min);
|
|
157
|
+
expect(replayTokenRadius(5, 5)).toBe(max);
|
|
158
|
+
expect(replayTokenRadius(1, 4)).toBeCloseTo(min + (max - min) * 0.5);
|
|
159
|
+
expect(replayTokenRadius(3, 0)).toBe(min);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* replayTimeline — the timing model behind `ProcessReplay` (RM-065).
|
|
3
|
+
*
|
|
4
|
+
* Pure, deterministic, framework-free: no DOM, no clock, no randomness. It turns a log into
|
|
5
|
+
* the moves each case makes along the directly-follows edges of its trace, then answers two
|
|
6
|
+
* questions for any playhead `t`:
|
|
7
|
+
*
|
|
8
|
+
* - **Where is every in-flight case?** A token per case on the edge it is travelling, with
|
|
9
|
+
* `progress` 0..1 from the source activity's completion to the target activity's start
|
|
10
|
+
* (the idle time the edge measures). {@link replayFrameAt}.
|
|
11
|
+
* - **How congested is each edge?** The number of distinct cases on it at any moment of the
|
|
12
|
+
* current bucket (`bucketMs` wide). {@link ReplayFrame.congestion}.
|
|
13
|
+
*
|
|
14
|
+
* Edge ids are `/core`'s `source + EDGE_KEY_SEPARATOR + target` — the same string
|
|
15
|
+
* `discoverGraph`'s transitions and the process map's React Flow edges use.
|
|
16
|
+
*
|
|
17
|
+
* Time is RELATIVE to the timeline's `origin`: `t = 0` is the earliest event in wall-clock
|
|
18
|
+
* mode, or each case's own first activity in `synchronizedStart` mode (Disco's
|
|
19
|
+
* "synchronized start"), where `origin` is `0` and absolute timestamps are meaningless.
|
|
20
|
+
*/
|
|
21
|
+
import { EDGE_KEY_SEPARATOR } from "./discover-graph";
|
|
22
|
+
import { asNormalizedLog, type AnyLog } from "./event-log";
|
|
23
|
+
|
|
24
|
+
/** One case travelling one edge. Times are relative to {@link ReplayTimeline.origin}. */
|
|
25
|
+
export interface ReplaySegment {
|
|
26
|
+
caseId: string;
|
|
27
|
+
/** `source + EDGE_KEY_SEPARATOR + target`. */
|
|
28
|
+
edgeId: string;
|
|
29
|
+
source: string;
|
|
30
|
+
target: string;
|
|
31
|
+
/** The source activity's completion. */
|
|
32
|
+
enterAt: number;
|
|
33
|
+
/** The target activity's start — never before {@link enterAt}. */
|
|
34
|
+
exitAt: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One case's token on one edge at a playhead. */
|
|
38
|
+
export interface ReplayFrameToken {
|
|
39
|
+
caseId: string;
|
|
40
|
+
edgeId: string;
|
|
41
|
+
/** 0 at the source end, 1 at the target end. */
|
|
42
|
+
progress: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The replay at one playhead. */
|
|
46
|
+
export interface ReplayFrame {
|
|
47
|
+
/** Playhead, relative to the timeline origin. */
|
|
48
|
+
t: number;
|
|
49
|
+
/** One token per in-flight case, in segment order. */
|
|
50
|
+
tokens: ReplayFrameToken[];
|
|
51
|
+
/** Edge id → distinct cases on that edge during the bucket starting at `t`. Zero edges are omitted. */
|
|
52
|
+
congestion: Record<string, number>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Options for {@link replayTimeline}. */
|
|
56
|
+
export interface ReplayTimelineOptions {
|
|
57
|
+
/** Bucket width in ms. Defaults to {@link defaultReplayBucketMs}. Raised when it would exceed {@link REPLAY_MAX_FRAMES}. */
|
|
58
|
+
bucketMs?: number;
|
|
59
|
+
/** Align every case's first activity to `t = 0`. @default false */
|
|
60
|
+
synchronizedStart?: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A replay-ready timeline. */
|
|
64
|
+
export interface ReplayTimeline {
|
|
65
|
+
/** Epoch ms of `t = 0` in wall-clock mode; `0` in synchronized-start mode. */
|
|
66
|
+
origin: number;
|
|
67
|
+
/** Last relative instant any case reaches. `0` for an empty log. */
|
|
68
|
+
duration: number;
|
|
69
|
+
/** The bucket width actually used. */
|
|
70
|
+
bucketMs: number;
|
|
71
|
+
synchronizedStart: boolean;
|
|
72
|
+
/** Every edge move of every case, ordered by `enterAt` then case order. */
|
|
73
|
+
segments: ReplaySegment[];
|
|
74
|
+
/** One frame per bucket, `frames[i].t === i * bucketMs`, covering `[0, duration]`. */
|
|
75
|
+
frames: ReplayFrame[];
|
|
76
|
+
/** Highest congestion any edge reaches in any bucket. `0` when nothing moves. */
|
|
77
|
+
peakCongestion: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** How many frames {@link defaultReplayBucketMs} aims for. */
|
|
81
|
+
export const REPLAY_TARGET_FRAMES = 300;
|
|
82
|
+
/** Upper bound on frames; a finer `bucketMs` is widened to respect it. */
|
|
83
|
+
export const REPLAY_MAX_FRAMES = 5000;
|
|
84
|
+
|
|
85
|
+
/** The default bucket: the log span over {@link REPLAY_TARGET_FRAMES}, at least 1 ms. */
|
|
86
|
+
export function defaultReplayBucketMs(duration: number): number {
|
|
87
|
+
if (!Number.isFinite(duration) || duration <= 0) return 1;
|
|
88
|
+
return Math.max(1, duration / REPLAY_TARGET_FRAMES);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Every case's edge moves, relative to the chosen origin. */
|
|
92
|
+
function buildSegments(
|
|
93
|
+
log: AnyLog,
|
|
94
|
+
synchronizedStart: boolean,
|
|
95
|
+
): { origin: number; duration: number; segments: ReplaySegment[] } {
|
|
96
|
+
const { cases } = asNormalizedLog(log);
|
|
97
|
+
if (cases.length === 0) return { origin: 0, duration: 0, segments: [] };
|
|
98
|
+
const origin = synchronizedStart ? 0 : Math.min(...cases.map((c) => c.start));
|
|
99
|
+
const segments: ReplaySegment[] = [];
|
|
100
|
+
let duration = 0;
|
|
101
|
+
for (const trace of cases) {
|
|
102
|
+
const base = synchronizedStart ? trace.start : origin;
|
|
103
|
+
duration = Math.max(duration, trace.end - base);
|
|
104
|
+
for (let i = 1; i < trace.events.length; i++) {
|
|
105
|
+
const from = trace.events[i - 1]!;
|
|
106
|
+
const to = trace.events[i]!;
|
|
107
|
+
const enterAt = from.end - base;
|
|
108
|
+
segments.push({
|
|
109
|
+
caseId: trace.caseId,
|
|
110
|
+
edgeId: `${from.activity}${EDGE_KEY_SEPARATOR}${to.activity}`,
|
|
111
|
+
source: from.activity,
|
|
112
|
+
target: to.activity,
|
|
113
|
+
enterAt,
|
|
114
|
+
exitAt: Math.max(enterAt, to.start - base),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Stable: equal `enterAt` keeps case order.
|
|
119
|
+
segments.sort((a, b) => a.enterAt - b.enterAt);
|
|
120
|
+
return { origin, duration, segments };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Tokens at `t`: a segment is in flight over `[enterAt, exitAt)`. */
|
|
124
|
+
function tokensAt(segments: readonly ReplaySegment[], t: number): ReplayFrameToken[] {
|
|
125
|
+
const tokens: ReplayFrameToken[] = [];
|
|
126
|
+
for (const segment of segments) {
|
|
127
|
+
if (segment.enterAt > t) break;
|
|
128
|
+
if (t >= segment.exitAt) continue;
|
|
129
|
+
tokens.push({
|
|
130
|
+
caseId: segment.caseId,
|
|
131
|
+
edgeId: segment.edgeId,
|
|
132
|
+
progress: (t - segment.enterAt) / (segment.exitAt - segment.enterAt),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return tokens;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Distinct cases per edge overlapping `[start, end)`. A zero-length move counts where it happens. */
|
|
139
|
+
function congestionIn(
|
|
140
|
+
segments: readonly ReplaySegment[],
|
|
141
|
+
start: number,
|
|
142
|
+
end: number,
|
|
143
|
+
): Record<string, number> {
|
|
144
|
+
const casesByEdge = new Map<string, Set<string>>();
|
|
145
|
+
for (const segment of segments) {
|
|
146
|
+
if (segment.enterAt >= end) break;
|
|
147
|
+
const overlaps = segment.exitAt > start || segment.enterAt >= start;
|
|
148
|
+
if (!overlaps) continue;
|
|
149
|
+
let cases = casesByEdge.get(segment.edgeId);
|
|
150
|
+
if (!cases) casesByEdge.set(segment.edgeId, (cases = new Set()));
|
|
151
|
+
cases.add(segment.caseId);
|
|
152
|
+
}
|
|
153
|
+
const congestion: Record<string, number> = {};
|
|
154
|
+
for (const [edgeId, cases] of casesByEdge) congestion[edgeId] = cases.size;
|
|
155
|
+
return congestion;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Bucket a log into replay frames.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* const timeline = replayTimeline(log, { synchronizedStart: true });
|
|
164
|
+
* const frame = replayFrameAt(timeline, timeline.duration / 2);
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
export function replayTimeline(log: AnyLog, options: ReplayTimelineOptions = {}): ReplayTimeline {
|
|
168
|
+
const synchronizedStart = options.synchronizedStart ?? false;
|
|
169
|
+
const { origin, duration, segments } = buildSegments(log, synchronizedStart);
|
|
170
|
+
const requested =
|
|
171
|
+
options.bucketMs !== undefined && Number.isFinite(options.bucketMs) && options.bucketMs > 0
|
|
172
|
+
? options.bucketMs
|
|
173
|
+
: defaultReplayBucketMs(duration);
|
|
174
|
+
const bucketMs = Math.max(requested, duration / (REPLAY_MAX_FRAMES - 1));
|
|
175
|
+
const frameCount = Math.floor(duration / bucketMs) + 1;
|
|
176
|
+
const frames: ReplayFrame[] = [];
|
|
177
|
+
let peakCongestion = 0;
|
|
178
|
+
for (let i = 0; i < frameCount; i++) {
|
|
179
|
+
const t = i * bucketMs;
|
|
180
|
+
const congestion = congestionIn(segments, t, t + bucketMs);
|
|
181
|
+
for (const count of Object.values(congestion)) peakCongestion = Math.max(peakCongestion, count);
|
|
182
|
+
frames.push({ t, tokens: tokensAt(segments, t), congestion });
|
|
183
|
+
}
|
|
184
|
+
return { origin, duration, bucketMs, synchronizedStart, segments, frames, peakCongestion };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The replay at any playhead — tokens at exactly `t` (not snapped), congestion from the
|
|
189
|
+
* bucket containing `t`. `t` is clamped to `[0, duration]`.
|
|
190
|
+
*/
|
|
191
|
+
export function replayFrameAt(timeline: ReplayTimeline, t: number): ReplayFrame {
|
|
192
|
+
const clamped = Math.min(Math.max(Number.isFinite(t) ? t : 0, 0), timeline.duration);
|
|
193
|
+
const bucket = Math.min(Math.floor(clamped / timeline.bucketMs), timeline.frames.length - 1);
|
|
194
|
+
return {
|
|
195
|
+
t: clamped,
|
|
196
|
+
tokens: tokensAt(timeline.segments, clamped),
|
|
197
|
+
congestion: timeline.frames[Math.max(bucket, 0)]?.congestion ?? {},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** One transition in {@link rankReplayCongestion}'s list. */
|
|
202
|
+
export interface ReplayCongestionEntry {
|
|
203
|
+
edgeId: string;
|
|
204
|
+
source: string;
|
|
205
|
+
target: string;
|
|
206
|
+
/** Most distinct cases on the edge in one bucket. */
|
|
207
|
+
peak: number;
|
|
208
|
+
/** Relative start of the first bucket reaching {@link peak}. */
|
|
209
|
+
peakAt: number;
|
|
210
|
+
/** Mean congestion over every bucket of the timeline. */
|
|
211
|
+
mean: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Transitions ranked by peak congestion, then mean, then edge id. Edges that never carry a case are omitted. */
|
|
215
|
+
export function rankReplayCongestion(
|
|
216
|
+
timeline: ReplayTimeline,
|
|
217
|
+
limit = Number.POSITIVE_INFINITY,
|
|
218
|
+
): ReplayCongestionEntry[] {
|
|
219
|
+
const ends = new Map<string, { source: string; target: string }>();
|
|
220
|
+
for (const s of timeline.segments) ends.set(s.edgeId, { source: s.source, target: s.target });
|
|
221
|
+
const byEdge = new Map<string, ReplayCongestionEntry>();
|
|
222
|
+
for (const frame of timeline.frames) {
|
|
223
|
+
for (const [edgeId, count] of Object.entries(frame.congestion)) {
|
|
224
|
+
let entry = byEdge.get(edgeId);
|
|
225
|
+
if (!entry) {
|
|
226
|
+
const { source, target } = ends.get(edgeId)!;
|
|
227
|
+
byEdge.set(edgeId, (entry = { edgeId, source, target, peak: 0, peakAt: 0, mean: 0 }));
|
|
228
|
+
}
|
|
229
|
+
if (count > entry.peak) {
|
|
230
|
+
entry.peak = count;
|
|
231
|
+
entry.peakAt = frame.t;
|
|
232
|
+
}
|
|
233
|
+
entry.mean += count;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const frameCount = Math.max(timeline.frames.length, 1);
|
|
237
|
+
return [...byEdge.values()]
|
|
238
|
+
.map((entry) => ({ ...entry, mean: entry.mean / frameCount }))
|
|
239
|
+
.sort(
|
|
240
|
+
(a, b) =>
|
|
241
|
+
b.peak - a.peak ||
|
|
242
|
+
b.mean - a.mean ||
|
|
243
|
+
(a.edgeId < b.edgeId ? -1 : a.edgeId > b.edgeId ? 1 : 0),
|
|
244
|
+
)
|
|
245
|
+
.slice(0, limit);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Smallest and largest token radius, in px. */
|
|
249
|
+
export const REPLAY_TOKEN_RADIUS_RANGE = Object.freeze([3, 8] as const);
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Token radius for an edge's congestion: the radius above the minimum grows with
|
|
253
|
+
* √(count / peak), so a busier edge's blob grows by area rather than by diameter.
|
|
254
|
+
*/
|
|
255
|
+
export function replayTokenRadius(congestion: number, peak: number): number {
|
|
256
|
+
const [min, max] = REPLAY_TOKEN_RADIUS_RANGE;
|
|
257
|
+
if (!(peak > 0) || !(congestion > 0)) return min;
|
|
258
|
+
const share = Math.min(congestion / peak, 1);
|
|
259
|
+
return min + (max - min) * Math.sqrt(share);
|
|
260
|
+
}
|