@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.
Files changed (145) 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__/process-kpi-strip.contract.test.tsx +49 -0
  17. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  18. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  19. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  20. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  21. package/src/case-table/case-table.stories.tsx +89 -0
  22. package/src/case-table/case-table.test.tsx +148 -0
  23. package/src/case-table/case-table.tsx +144 -0
  24. package/src/case-table/columns.ts +116 -0
  25. package/src/case-table/index.ts +11 -0
  26. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  27. package/src/case-timeline/case-timeline-model.ts +112 -0
  28. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  29. package/src/case-timeline/case-timeline.test.tsx +51 -0
  30. package/src/case-timeline/case-timeline.tsx +109 -0
  31. package/src/case-timeline/index.ts +9 -0
  32. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  33. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  34. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  35. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  36. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  37. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  38. package/src/conformance-overlay/conformance-state.ts +220 -0
  39. package/src/conformance-overlay/index.ts +4 -0
  40. package/src/core/activity-color-scale.test.ts +107 -0
  41. package/src/core/activity-color-scale.ts +133 -0
  42. package/src/core/adapters/ocel.test.ts +112 -0
  43. package/src/core/adapters/ocel.ts +359 -0
  44. package/src/core/adapters/xes.test.ts +293 -0
  45. package/src/core/adapters/xes.ts +384 -0
  46. package/src/core/cases-from-log.test.ts +72 -0
  47. package/src/core/cases-from-log.ts +85 -0
  48. package/src/core/conformance.test.ts +80 -0
  49. package/src/core/conformance.ts +91 -0
  50. package/src/core/diff-graphs.test.ts +151 -0
  51. package/src/core/diff-graphs.ts +118 -0
  52. package/src/core/discover-object-centric-graph.test.ts +94 -0
  53. package/src/core/discover-object-centric-graph.ts +296 -0
  54. package/src/core/fixtures/ocel-sample.ts +82 -0
  55. package/src/core/fixtures/sample.xes +68 -0
  56. package/src/core/index.ts +113 -0
  57. package/src/core/reference-model.test.ts +43 -0
  58. package/src/core/reference-model.ts +116 -0
  59. package/src/core/replay-timeline.test.ts +161 -0
  60. package/src/core/replay-timeline.ts +260 -0
  61. package/src/core/segments.test.ts +185 -0
  62. package/src/core/segments.ts +153 -0
  63. package/src/core/token-replay.test.ts +218 -0
  64. package/src/core/token-replay.ts +456 -0
  65. package/src/core/types.ts +2 -2
  66. package/src/dotted-chart/compute-dots.test.ts +176 -0
  67. package/src/dotted-chart/compute-dots.ts +241 -0
  68. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  69. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  70. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  71. package/src/dotted-chart/dotted-chart.tsx +841 -0
  72. package/src/dotted-chart/index.ts +23 -0
  73. package/src/dotted-chart/use-element-size.ts +33 -0
  74. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  75. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  76. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  77. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  78. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  79. package/src/happy-path-editor/index.ts +4 -0
  80. package/src/index.ts +51 -1
  81. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  82. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  83. package/src/performance-spectrum/index.ts +25 -0
  84. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  85. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  86. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  87. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  88. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  89. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  90. package/src/process-compare/compare-model.ts +83 -0
  91. package/src/process-compare/compare-side.tsx +42 -0
  92. package/src/process-compare/diff-to-graph.ts +104 -0
  93. package/src/process-compare/index.ts +23 -0
  94. package/src/process-compare/process-compare.stories.tsx +184 -0
  95. package/src/process-compare/process-compare.test.tsx +224 -0
  96. package/src/process-compare/process-compare.tsx +251 -0
  97. package/src/process-explorer.stories.tsx +1 -1
  98. package/src/process-filter-bar/index.ts +2 -0
  99. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  100. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  101. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  102. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  103. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  104. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  105. package/src/process-map/activity-accent.ts +25 -0
  106. package/src/process-map/index.ts +1 -0
  107. package/src/process-map/map-model.test.ts +16 -0
  108. package/src/process-map/map-model.ts +323 -1
  109. package/src/process-map/object-centric-map.test.tsx +132 -0
  110. package/src/process-map/process-activity-node.tsx +152 -14
  111. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  112. package/src/process-map/process-map.stories.tsx +64 -0
  113. package/src/process-map/process-map.tsx +240 -16
  114. package/src/process-map/process-transition-edge.test.tsx +47 -0
  115. package/src/process-map/process-transition-edge.tsx +134 -7
  116. package/src/process-map/use-process-layout.ts +30 -9
  117. package/src/process-replay/congestion-heat.tsx +107 -0
  118. package/src/process-replay/index.ts +14 -0
  119. package/src/process-replay/process-replay.stories.tsx +168 -0
  120. package/src/process-replay/process-replay.test.tsx +170 -0
  121. package/src/process-replay/process-replay.tsx +285 -0
  122. package/src/process-replay/replay-controls.tsx +147 -0
  123. package/src/process-replay/replay-format.ts +83 -0
  124. package/src/process-replay/replay-tokens-context.ts +30 -0
  125. package/src/process-replay/use-controllable-value.ts +30 -0
  126. package/src/templates-process-explorer.stories.tsx +1304 -0
  127. package/src/test/contract.test.ts +66 -0
  128. package/src/test/contract.ts +107 -6
  129. package/src/test/doubles.test.tsx +87 -1
  130. package/src/test/doubles.tsx +174 -3
  131. package/src/test/index.ts +25 -1
  132. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  133. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  134. package/src/variant-explorer/coverage-bar.tsx +36 -0
  135. package/src/variant-explorer/index.ts +16 -0
  136. package/src/variant-explorer/sequence-chips.tsx +103 -0
  137. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  138. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  139. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  140. package/src/variant-explorer/variant-explorer.tsx +567 -0
  141. package/src/variant-explorer/variant-row.tsx +137 -0
  142. package/src/violation-list/index.ts +2 -0
  143. package/src/violation-list/violation-list.stories.tsx +73 -0
  144. package/src/violation-list/violation-list.test.tsx +84 -0
  145. package/src/violation-list/violation-list.tsx +259 -0
@@ -0,0 +1,48 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { CompareKpiStrip } from "./compare-kpi-strip";
3
+
4
+ const HOUR = 60 * 60 * 1000;
5
+
6
+ const meta = {
7
+ title: "Process/ProcessCompare/CompareKpiStrip",
8
+ component: CompareKpiStrip,
9
+ tags: ["autodocs"],
10
+ parameters: {
11
+ layout: "padded",
12
+ docs: {
13
+ description: {
14
+ component:
15
+ "The KPI pair under `ProcessCompare`: cases and median throughput for each side, " +
16
+ "with B’s tiles carrying a signed delta against A. Direction is an arrow glyph plus " +
17
+ "an accessible name, never colour alone.",
18
+ },
19
+ },
20
+ },
21
+ args: {
22
+ a: { label: "Before", kpis: { cases: 1200, medianThroughput: 52 * HOUR } },
23
+ b: { label: "After", kpis: { cases: 1350, medianThroughput: 41 * HOUR } },
24
+ },
25
+ render: (args) => (
26
+ <div className="w-full max-w-4xl">
27
+ <CompareKpiStrip {...args} />
28
+ </div>
29
+ ),
30
+ } satisfies Meta<typeof CompareKpiStrip>;
31
+ export default meta;
32
+ type Story = StoryObj<typeof meta>;
33
+
34
+ /** Both sides carry a log, so cases and median throughput pair up. */
35
+ export const Default: Story = {};
36
+
37
+ /** A graph-only side has no throughput — the strip falls back to the cases pair alone. */
38
+ export const CasesOnly: Story = {
39
+ args: {
40
+ a: { label: "Before", kpis: { cases: 1200 } },
41
+ b: { label: "After", kpis: { cases: 1100 } },
42
+ },
43
+ };
44
+
45
+ /** Not ready yet: `MetricGrid`’s own loading state, same footprint as the resolved strip. */
46
+ export const Loading: Story = {
47
+ args: { loading: true },
48
+ };
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CompareKpiStrip — the one shared reading of "how did B differ from A" (RM-064): cases and
5
+ * median throughput for each side, paired, with a delta on B's own tile.
6
+ *
7
+ * Built entirely on `MetricCard`/`MetricGrid` (`@elabs-ai/components-ui`/`-charts`) — no
8
+ * local grid, no local delta styling. `MetricCard`'s own `delta`/`deltaDirection` already
9
+ * carries direction as an arrow glyph AND a "up/down, favorable/unfavorable" accessible
10
+ * name (never colour alone, see `metric-card.tsx`), so this file adds no channel of its
11
+ * own — it only decides WHICH two numbers to pair and what counts as favourable.
12
+ */
13
+ import { forwardRef, type HTMLAttributes } from "react";
14
+ import { MetricCard, useLocale } from "@elabs-ai/components-ui";
15
+ import { MetricGrid } from "@elabs-ai/components-charts";
16
+ import { formatDurationMs } from "../process-map/map-model";
17
+ import type { CompareSideKpis } from "./compare-model";
18
+
19
+ export interface CompareKpiStripSide {
20
+ /** The host-supplied name for this side ("Before", "Q1", the log's own file name, …). */
21
+ label: string;
22
+ kpis: CompareSideKpis;
23
+ }
24
+
25
+ export interface CompareKpiStripProps extends HTMLAttributes<HTMLDivElement> {
26
+ a: CompareKpiStripSide;
27
+ b: CompareKpiStripSide;
28
+ loading?: boolean;
29
+ }
30
+
31
+ interface Delta {
32
+ text: string;
33
+ direction: "up" | "down" | "neutral";
34
+ }
35
+
36
+ /** `b` relative to `a`, as a signed percentage — `null` when `a` is `0` (nothing to divide by). */
37
+ function percentDelta(a: number, b: number): Delta | null {
38
+ if (a === 0) return null;
39
+ const fraction = (b - a) / a;
40
+ const percent = Math.round(fraction * 100);
41
+ if (percent === 0) return { text: "0%", direction: "neutral" };
42
+ return { text: `${percent > 0 ? "+" : ""}${percent}%`, direction: percent > 0 ? "up" : "down" };
43
+ }
44
+
45
+ /** Compact KPI pairs for `ProcessCompare`: cases and median throughput, A vs B. */
46
+ export const CompareKpiStrip = forwardRef<HTMLDivElement, CompareKpiStripProps>(
47
+ function CompareKpiStrip({ a, b, loading = false, className, ...props }, ref) {
48
+ const { t } = useLocale();
49
+ const casesDelta = percentDelta(a.kpis.cases, b.kpis.cases);
50
+ const hasThroughput =
51
+ a.kpis.medianThroughput !== undefined && b.kpis.medianThroughput !== undefined;
52
+ const throughputDelta = hasThroughput
53
+ ? percentDelta(a.kpis.medianThroughput as number, b.kpis.medianThroughput as number)
54
+ : null;
55
+
56
+ return (
57
+ <div ref={ref} data-slot="compare-kpi-strip" className={className} {...props}>
58
+ <MetricGrid columns={hasThroughput ? 4 : 2} loading={loading}>
59
+ <MetricCard
60
+ label={t("process.compare.cases", { label: a.label })}
61
+ value={a.kpis.cases}
62
+ announceLoading={false}
63
+ />
64
+ <MetricCard
65
+ label={t("process.compare.cases", { label: b.label })}
66
+ value={b.kpis.cases}
67
+ announceLoading={false}
68
+ delta={casesDelta?.text}
69
+ deltaDirection={casesDelta?.direction}
70
+ />
71
+ {hasThroughput ? (
72
+ <>
73
+ <MetricCard
74
+ label={t("process.compare.medianThroughput", { label: a.label })}
75
+ value={formatDurationMs(a.kpis.medianThroughput as number)}
76
+ announceLoading={false}
77
+ />
78
+ <MetricCard
79
+ label={t("process.compare.medianThroughput", { label: b.label })}
80
+ value={formatDurationMs(b.kpis.medianThroughput as number)}
81
+ announceLoading={false}
82
+ delta={throughputDelta?.text}
83
+ deltaDirection={throughputDelta?.direction}
84
+ // A shorter throughput is the improvement — the inverse of the cases pair
85
+ // above, where more/fewer cases carries no inherent favourability.
86
+ positiveIsGood={false}
87
+ />
88
+ </>
89
+ ) : null}
90
+ </MetricGrid>
91
+ </div>
92
+ );
93
+ },
94
+ );
@@ -0,0 +1,83 @@
1
+ /**
2
+ * compare-model — the presentation-shaping helpers `ProcessCompare`/`CompareKpiStrip` read
3
+ * from, kept out of `process-compare.tsx` so the render function stays about layout, not
4
+ * derivation (RM-064).
5
+ */
6
+ import { asNormalizedLog } from "../core/event-log";
7
+ import { durationStats } from "../core/duration-stats";
8
+ import type { DiffState, ProcessGraphDiff } from "../core/diff-graphs";
9
+ import type { EventLog, ProcessGraph } from "../core/types";
10
+ import { diffStateByActivity } from "./diff-to-graph";
11
+
12
+ /** One side's raw input — a discovered graph, an event log, or (loading) neither. */
13
+ export interface CompareSideInput {
14
+ graph?: ProcessGraph;
15
+ log?: EventLog;
16
+ }
17
+
18
+ /** The two numbers {@link CompareKpiStrip} reads for one side. */
19
+ export interface CompareSideKpis {
20
+ cases: number;
21
+ /** Median case throughput, ms. Omitted when the side has no `log` to derive it from. */
22
+ medianThroughput?: number;
23
+ }
24
+
25
+ /**
26
+ * Cases and median throughput for one side, mirroring `useProcessExplorer`'s own `kpis`
27
+ * derivation (`cases`/`medianThroughput` from a normalized log's per-case durations).
28
+ * `medianThroughput` needs case-level duration samples that a bare `ProcessGraph` does not
29
+ * carry (only per-activity/per-transition aggregates do) — so it is only derivable when the
30
+ * caller also handed this side a `log`; a `graph`-only side reports cases alone.
31
+ */
32
+ export function resolveCompareKpis(side: CompareSideInput): CompareSideKpis {
33
+ if (side.log) {
34
+ const normalized = asNormalizedLog(side.log);
35
+ const medianThroughput = durationStats(normalized.cases.map((kase) => kase.duration)).median;
36
+ return { cases: normalized.totals.cases, medianThroughput };
37
+ }
38
+ return { cases: side.graph?.totals.cases ?? 0 };
39
+ }
40
+
41
+ /** Translator shape this module needs — the same signature `useLocale()` returns. */
42
+ export type Translate = (key: string, vars?: Record<string, string | number>) => string;
43
+
44
+ /**
45
+ * The diff-state word for one activity, localized. `"common"` reads as a plain word; the
46
+ * two single-sided states name the OWNING side by its host-supplied label ("Before only",
47
+ * "After only") rather than the generic "A"/"B" — the label is what the reader actually
48
+ * chose to call that side.
49
+ */
50
+ export function diffStateSuffix(
51
+ state: DiffState,
52
+ aLabel: string,
53
+ bLabel: string,
54
+ t: Translate,
55
+ ): string {
56
+ if (state === "common") return t("process.compare.diffSuffixCommon");
57
+ return t("process.compare.diffSuffixOnly", { label: state === "aOnly" ? aLabel : bLabel });
58
+ }
59
+
60
+ /**
61
+ * Append the localized diff-state word to every activity's own label — the TEXT channel a
62
+ * superimposed `ProcessMap` reads as its node title (`map-model.ts`'s `buildProcessMapModel`
63
+ * always prints `activity.label`), so diff state reaches the reader through real text and
64
+ * the node's accessible name, not only through {@link diffColorScale}'s accent swatch
65
+ * (WCAG 1.4.1 — colour is never the only channel).
66
+ */
67
+ export function withDiffLabels(
68
+ graph: ProcessGraph,
69
+ diff: ProcessGraphDiff,
70
+ aLabel: string,
71
+ bLabel: string,
72
+ t: Translate,
73
+ ): ProcessGraph {
74
+ const stateById = diffStateByActivity(diff);
75
+ return {
76
+ ...graph,
77
+ activities: graph.activities.map((activity) => {
78
+ const state = stateById.get(activity.id) ?? "common";
79
+ const suffix = diffStateSuffix(state, aLabel, bLabel, t);
80
+ return { ...activity, label: `${activity.label || activity.id} · ${suffix}` };
81
+ }),
82
+ };
83
+ }
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ /**
4
+ * CompareSide — one labeled `ProcessMap` panel inside `ProcessCompare`'s side-by-side mode
5
+ * (RM-064). Owns nothing beyond a heading and the map itself — `ProcessCompare` lifts
6
+ * abstraction, metric and table-view state so both sides stay in lockstep, the same
7
+ * "lift state into the provider" convention a compound component follows, applied to a
8
+ * fixed pair rather than an open set of children.
9
+ */
10
+ import { forwardRef, type HTMLAttributes } from "react";
11
+ import { Heading } from "@elabs-ai/components-ui";
12
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
13
+ import { ProcessMap, type ProcessMapProps } from "../process-map/process-map";
14
+
15
+ export interface CompareSideProps extends HTMLAttributes<HTMLDivElement> {
16
+ /** The host-supplied name for this side ("Before", "Q1", the log's own file name, …). */
17
+ label: string;
18
+ /** Forwarded to `ProcessMap` verbatim — everything this side's canvas needs. */
19
+ map: Omit<ProcessMapProps, "className">;
20
+ }
21
+
22
+ /** One heading + one `ProcessMap`, stacked to fill its panel. */
23
+ export const CompareSide = forwardRef<HTMLDivElement, CompareSideProps>(function CompareSide(
24
+ { label, map, className, ...props },
25
+ ref,
26
+ ) {
27
+ return (
28
+ <div
29
+ ref={ref}
30
+ data-slot="compare-side"
31
+ className={cn("flex min-h-0 flex-col gap-2", className)}
32
+ {...props}
33
+ >
34
+ <Heading level={3} size="subtitle" data-slot="compare-side-label">
35
+ {label}
36
+ </Heading>
37
+ <div className="min-h-96 flex-1">
38
+ <ProcessMap {...map} className="size-full" />
39
+ </div>
40
+ </div>
41
+ );
42
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * diffToProcessGraph — turn a `ProcessGraphDiff` (`/core`'s `diffGraphs`, RM-064) into the
3
+ * ONE `ProcessGraph` `ProcessCompare`'s superimposed mode hands to a single `ProcessMap`,
4
+ * plus a synthetic `ActivityColorScale` that paints by diff state instead of by identity.
5
+ *
6
+ * `ProcessMap` (RM-051) is never forked or given a new prop for this: its existing
7
+ * `colorScale` prop (RM-054) is the only per-activity colour hook it exposes, and
8
+ * `ActivityColorScale` is a plain interface — nothing stops a second caller building one
9
+ * keyed by diff state rather than by rank. Pure, no React; lives beside `process-compare.tsx`
10
+ * rather than under `/core` because it is presentation shaping for ONE component (label
11
+ * suffixes, colour tokens), not a reusable process-mining derivation.
12
+ */
13
+ import type {
14
+ ActivityColor,
15
+ ActivityColorLegendEntry,
16
+ ActivityColorScale,
17
+ } from "../core/activity-color-scale";
18
+ import type { DiffEntry, DiffState, ProcessGraphDiff } from "../core/diff-graphs";
19
+ import type { ActivityStats, ProcessGraph, TransitionStats } from "../core/types";
20
+
21
+ /** Empty graph — the identity input for a side that has not discovered anything yet. */
22
+ export const EMPTY_PROCESS_GRAPH: ProcessGraph = {
23
+ activities: [],
24
+ transitions: [],
25
+ startActivities: {},
26
+ endActivities: {},
27
+ totals: { cases: 0, events: 0, variants: 0 },
28
+ };
29
+
30
+ /**
31
+ * The three diff tokens the design asks for (RM-064's spec): `common` reads as success,
32
+ * `aOnly` as the first chart series, `bOnly` as destructive. Semantic tokens only — no
33
+ * literal colour is authored (`.claude/rules/conventions.md`'s styling rule).
34
+ */
35
+ export const DIFF_STATE_TOKEN: Record<DiffState, string> = {
36
+ common: "--success",
37
+ aOnly: "--chart-1",
38
+ bOnly: "--destructive",
39
+ };
40
+
41
+ /** `b`'s stats when the element survives into `b` (common or bOnly); `a`'s otherwise. */
42
+ function representative<Stats>(entry: DiffEntry<Stats>): Stats {
43
+ return (entry.b ?? entry.a) as Stats;
44
+ }
45
+
46
+ /**
47
+ * The ONE `ProcessGraph` a superimposed `ProcessMap` renders: every activity/transition
48
+ * either side ever saw, painted with whichever side's stats are the "current" reading (`b`
49
+ * when present, `a` otherwise). `startActivities`/`endActivities` are recomputed from the
50
+ * represented activities themselves, so they can never disagree with what `activities`
51
+ * above actually carries. `totals` prefers `b`'s (the "current" period) and falls back to
52
+ * `a`'s only when `b` measured nothing at all (e.g. a `b` side still loading).
53
+ */
54
+ export function diffToProcessGraph(diff: ProcessGraphDiff): ProcessGraph {
55
+ const activities: ActivityStats[] = diff.activities.map(representative);
56
+ const transitions: TransitionStats[] = diff.transitions.map(representative);
57
+ const totals =
58
+ diff.totals.b.cases > 0 || diff.totals.b.events > 0 ? diff.totals.b : diff.totals.a;
59
+ return {
60
+ activities,
61
+ transitions,
62
+ startActivities: Object.fromEntries(
63
+ activities
64
+ .filter((activity) => activity.isStart)
65
+ .map((activity) => [activity.id, activity.cases]),
66
+ ),
67
+ endActivities: Object.fromEntries(
68
+ activities
69
+ .filter((activity) => activity.isEnd)
70
+ .map((activity) => [activity.id, activity.cases]),
71
+ ),
72
+ totals,
73
+ };
74
+ }
75
+
76
+ /** Diff state per activity id, straight off {@link ProcessGraphDiff.activities}. */
77
+ export function diffStateByActivity(diff: ProcessGraphDiff): ReadonlyMap<string, DiffState> {
78
+ return new Map(diff.activities.map((entry) => [entry.id, entry.state]));
79
+ }
80
+
81
+ /**
82
+ * A synthetic `ActivityColorScale` that paints every activity by its DIFF STATE rather
83
+ * than its identity. `colorFor` is the only method `ProcessMap` itself calls (see
84
+ * `map-model.ts`'s `buildProcessMapModel`); `codeFor`/`labelFor`/`legend` exist only to
85
+ * satisfy the shared interface.
86
+ */
87
+ export function diffColorScale(diff: ProcessGraphDiff): ActivityColorScale {
88
+ const states = diffStateByActivity(diff);
89
+ const colorFor = (activityId: string): ActivityColor => ({
90
+ token: DIFF_STATE_TOKEN[states.get(activityId) ?? "common"],
91
+ });
92
+ const legend: ActivityColorLegendEntry[] = diff.activities.map((entry) => ({
93
+ activityId: entry.id,
94
+ label: entry.id,
95
+ code: entry.id.slice(0, 2).toUpperCase(),
96
+ ...colorFor(entry.id),
97
+ }));
98
+ return {
99
+ colorFor,
100
+ codeFor: (activityId) => activityId.slice(0, 2).toUpperCase(),
101
+ labelFor: (activityId) => activityId,
102
+ legend,
103
+ };
104
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * ProcessCompare (RM-064) — public surface: the compare view, its sub-parts, and the pure
3
+ * diff-to-graph helpers a host can reuse to build its own superimposed reading.
4
+ */
5
+ export { ProcessCompare } from "./process-compare";
6
+ export type {
7
+ ProcessCompareMode,
8
+ ProcessCompareProps,
9
+ ProcessCompareSide,
10
+ } from "./process-compare";
11
+ export { CompareSide } from "./compare-side";
12
+ export type { CompareSideProps } from "./compare-side";
13
+ export { CompareKpiStrip } from "./compare-kpi-strip";
14
+ export type { CompareKpiStripProps, CompareKpiStripSide } from "./compare-kpi-strip";
15
+ export { resolveCompareKpis } from "./compare-model";
16
+ export type { CompareSideInput, CompareSideKpis } from "./compare-model";
17
+ export {
18
+ DIFF_STATE_TOKEN,
19
+ diffColorScale,
20
+ diffStateByActivity,
21
+ diffToProcessGraph,
22
+ EMPTY_PROCESS_GRAPH,
23
+ } from "./diff-to-graph";
@@ -0,0 +1,184 @@
1
+ import "@xyflow/react/dist/style.css";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { useState } from "react";
4
+ import { expect, userEvent, waitFor, within } from "storybook/test";
5
+ import { discoverGraph } from "../core/discover-graph";
6
+ import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
7
+ import type { AbstractionOptions } from "../core/abstract-graph";
8
+ import type { ProcessGraph } from "../core/types";
9
+ import { ProcessCompare, type ProcessCompareProps } from "./process-compare";
10
+
11
+ const beforeLog = generateSyntheticLog({ cases: 240, seed: 42 });
12
+ const afterLog = generateSyntheticLog({ cases: 300, seed: 7 });
13
+ const beforeGraph = discoverGraph(beforeLog);
14
+ const afterGraph = discoverGraph(afterLog);
15
+
16
+ const IDENTITY_ABSTRACTION: AbstractionOptions = {
17
+ activities: 1,
18
+ paths: 1,
19
+ invert: false,
20
+ keepConnected: true,
21
+ };
22
+
23
+ /** A tiny, hand-built graph — used where two SHARED-vocabulary synthetic logs would not do. */
24
+ function tinyGraph(id: string): ProcessGraph {
25
+ return {
26
+ activities: [
27
+ {
28
+ id,
29
+ label: id,
30
+ instances: 5,
31
+ cases: 5,
32
+ isStart: true,
33
+ isEnd: true,
34
+ duration: { min: 1, max: 1, mean: 1, median: 1, p90: 1, sum: 5, trimmedMean: 1 },
35
+ },
36
+ ],
37
+ transitions: [],
38
+ startActivities: { [id]: 5 },
39
+ endActivities: { [id]: 5 },
40
+ totals: { cases: 5, events: 5, variants: 1 },
41
+ };
42
+ }
43
+
44
+ /** Owns `abstraction` the way a real playbook would, so the shared slider is interactive. */
45
+ function Stateful(props: Omit<ProcessCompareProps, "abstraction" | "onAbstractionChange">) {
46
+ const [abstraction, setAbstraction] = useState<AbstractionOptions>(IDENTITY_ABSTRACTION);
47
+ return (
48
+ <ProcessCompare
49
+ {...props}
50
+ abstraction={abstraction}
51
+ onAbstractionChange={(next) => setAbstraction((prev) => ({ ...prev, ...next }))}
52
+ />
53
+ );
54
+ }
55
+
56
+ const meta = {
57
+ title: "Process/ProcessCompare",
58
+ component: ProcessCompare,
59
+ tags: ["autodocs"],
60
+ parameters: {
61
+ layout: "fullscreen",
62
+ docs: {
63
+ description: {
64
+ component:
65
+ "Side-by-side or superimposed diff of two process maps — a before/after run, an " +
66
+ "A/B cohort, or any two discovered graphs. Both modes compose the shipped " +
67
+ "`ProcessMap` (twice for `side-by-side`, once over the union for " +
68
+ "`superimposed`) with ONE shared `AbstractionControls` and a `CompareKpiStrip` " +
69
+ "underneath. The common/A-only/B-only diff is never colour alone: superimposed " +
70
+ "mode paints each activity's accent with a diff-state token AND appends the diff " +
71
+ "state as real text to the activity's own title, read by AT as part of its " +
72
+ "accessible name — the `Legend` alongside states the same three words. " +
73
+ "Transitions carry no diff encoding of their own (no per-edge colour hook exists " +
74
+ "on `ProcessMap` without a change to that component, out of scope here).",
75
+ },
76
+ },
77
+ },
78
+ args: {
79
+ a: { label: "Before", graph: beforeGraph, log: beforeLog },
80
+ b: { label: "After", graph: afterGraph, log: afterLog },
81
+ metric: { node: "absolute_case", edge: "absolute" },
82
+ },
83
+ render: (args) => (
84
+ <div className="h-[42rem] w-full bg-background p-4">
85
+ <Stateful {...args} />
86
+ </div>
87
+ ),
88
+ } satisfies Meta<typeof ProcessCompare>;
89
+ export default meta;
90
+ type Story = StoryObj<typeof meta>;
91
+
92
+ /** Two independent panes, one shared abstraction slider above both. */
93
+ export const SideBySide: Story = {
94
+ play: async ({ canvasElement }) => {
95
+ const canvas = within(canvasElement);
96
+ await waitFor(() =>
97
+ expect(canvas.getAllByRole("heading", { level: 3 }).map((h) => h.textContent)).toEqual([
98
+ "Before",
99
+ "After",
100
+ ]),
101
+ );
102
+ // One shared control, not one per side.
103
+ expect(canvas.getAllByRole("group", { name: "Abstraction" })).toHaveLength(1);
104
+ },
105
+ };
106
+
107
+ /** One map over the union of both graphs, with the diff legend and diff-labelled titles. */
108
+ export const Superimposed: Story = {
109
+ args: { mode: "superimposed" },
110
+ play: async ({ canvasElement }) => {
111
+ const canvas = within(canvasElement);
112
+ await waitFor(() => expect(canvas.getByText("common")).toBeInTheDocument());
113
+ expect(canvas.getByText("Before only")).toBeInTheDocument();
114
+ expect(canvas.getByText("After only")).toBeInTheDocument();
115
+ },
116
+ };
117
+
118
+ /**
119
+ * The named use case: a "Before"/"After" pair from the same process, toggled into the
120
+ * accessible table twin, which both sides always share (RM-064's own contract — the same
121
+ * numbers the canvas draws, never a second source of truth).
122
+ */
123
+ export const BeforeAfter: Story = {
124
+ play: async ({ canvasElement }) => {
125
+ const canvas = within(canvasElement);
126
+ await userEvent.click(canvas.getByRole("switch", { name: "Table view" }));
127
+ await waitFor(() => expect(canvas.getAllByRole("table")).toHaveLength(4));
128
+ },
129
+ };
130
+
131
+ /** Two graphs that share no activity at all — every entry reads A-only or B-only. */
132
+ export const NoOverlap: Story = {
133
+ args: {
134
+ a: { label: "Before", graph: tinyGraph("OnlyBefore") },
135
+ b: { label: "After", graph: tinyGraph("OnlyAfter") },
136
+ mode: "superimposed",
137
+ },
138
+ play: async ({ canvasElement }) => {
139
+ const canvas = within(canvasElement);
140
+ await waitFor(() => expect(canvas.getByText("Before only")).toBeInTheDocument());
141
+ expect(canvas.getByText("After only")).toBeInTheDocument();
142
+ await userEvent.click(canvas.getByRole("switch", { name: "Table view" }));
143
+ const activityTable = canvas.getAllByRole("table")[0]!;
144
+ const rowText = within(activityTable)
145
+ .getAllByRole("row")
146
+ .map((row) => row.textContent ?? "");
147
+ expect(rowText.some((text) => text.includes("common"))).toBe(false);
148
+ },
149
+ };
150
+
151
+ /** Neither side has a graph yet — every map's own loading panel, one shared control shell. */
152
+ export const Loading: Story = {
153
+ args: {
154
+ a: { label: "Before", graph: undefined },
155
+ b: { label: "After", graph: undefined },
156
+ loading: true,
157
+ },
158
+ };
159
+
160
+ /** Two graphs with no activities — an abstraction that hid everything, or two empty logs. */
161
+ export const Empty: Story = {
162
+ args: {
163
+ a: {
164
+ label: "Before",
165
+ graph: {
166
+ activities: [],
167
+ transitions: [],
168
+ startActivities: {},
169
+ endActivities: {},
170
+ totals: { cases: 0, events: 0, variants: 0 },
171
+ },
172
+ },
173
+ b: {
174
+ label: "After",
175
+ graph: {
176
+ activities: [],
177
+ transitions: [],
178
+ startActivities: {},
179
+ endActivities: {},
180
+ totals: { cases: 0, events: 0, variants: 0 },
181
+ },
182
+ },
183
+ },
184
+ };