@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
@@ -33,13 +33,15 @@
33
33
  * identical line height without showing misleading or duplicated copy to a sighted or
34
34
  * screen-reader user.
35
35
  */
36
- import { type HTMLAttributes } from "react";
36
+ import { type HTMLAttributes, type ReactNode } from "react";
37
37
  import { CircleSlash2 } from "lucide-react";
38
- import { MetricCard, type LocaleContextValue } from "@elabs-ai/components-ui";
38
+ import { MetricCard, Skeleton, type LocaleContextValue } from "@elabs-ai/components-ui";
39
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
39
40
  import { useLocale } from "@elabs-ai/components-ui";
40
41
  import { MetricGrid } from "@elabs-ai/components-charts";
41
42
  import { Sparkline } from "@elabs-ai/components-charts";
42
43
  import { formatDurationMs } from "../process-map/map-model";
44
+ import type { ConformanceRatePoint, ConformanceResult } from "../core/conformance";
43
45
 
44
46
  export interface ProcessKpiStripKpis {
45
47
  cases: number;
@@ -53,11 +55,34 @@ export type ProcessKpiStripTrendKey = keyof ProcessKpiStripKpis | "conformance";
53
55
 
54
56
  export interface ProcessKpiStripProps extends HTMLAttributes<HTMLDivElement> {
55
57
  kpis: ProcessKpiStripKpis;
56
- /** `0..1`, or `null`/`undefined` when no conformance model has been fitted. */
57
- conformance?: number | null;
58
+ /**
59
+ * `0..1`, a replay result (RM-061's `tokenReplay`, whose `overallFitness` the tile
60
+ * prints — RM-062), or `null`/`undefined` when no conformance model has been fitted.
61
+ */
62
+ conformance?: number | ConformanceResult | null;
58
63
  /** Optional per-tile trend series (oldest -> newest), keyed by KPI. */
59
64
  trends?: Partial<Record<ProcessKpiStripTrendKey, number[]>>;
65
+ /**
66
+ * Conformance rate over time (`conformanceRateSeries`), plotted as the conformance
67
+ * tile's sparkline — the rate-over-time reading (RM-062). Takes precedence over
68
+ * `trends.conformance`.
69
+ */
70
+ conformanceSeries?: ConformanceRatePoint[];
60
71
  loading?: boolean;
72
+ /**
73
+ * How the six numbers are laid out. `"grid"` (default) is the card grid a dashboard page
74
+ * opens with. `"inline"` is one dense ribbon — label, value and sparkline per cell, divided
75
+ * by hairlines, scrolling sideways when it runs out of room — for a workspace whose height
76
+ * belongs to the process map, not to its KPIs. Same numbers, same formats, same sparkline
77
+ * text alternatives in both.
78
+ */
79
+ layout?: "grid" | "inline";
80
+ }
81
+
82
+ /** The tile's fraction: a number as given, a replay result's `overallFitness`. */
83
+ function conformanceFraction(conformance: number | ConformanceResult | null | undefined) {
84
+ if (conformance === null || conformance === undefined) return undefined;
85
+ return typeof conformance === "number" ? conformance : conformance.overallFitness;
61
86
  }
62
87
 
63
88
  /** How a trend's two endpoints are formatted — mirrors each tile's own headline format. */
@@ -106,6 +131,7 @@ function trendVisual(
106
131
  format: TrendValueFormat,
107
132
  t: LocaleContextValue["t"],
108
133
  formatNumber: LocaleContextValue["formatNumber"],
134
+ size?: { width: number; height: number },
109
135
  ) {
110
136
  if (!values || values.length === 0) return undefined;
111
137
  const first = values[0]!;
@@ -117,22 +143,136 @@ function trendVisual(
117
143
  first: formatTrendValue(formatNumber, first, format),
118
144
  last: formatTrendValue(formatNumber, last, format),
119
145
  });
120
- return <Sparkline values={values} label={label} />;
146
+ return <Sparkline values={values} label={label} {...size} />;
147
+ }
148
+
149
+ /** One cell of the `layout="inline"` ribbon. */
150
+ function InlineKpi({
151
+ label,
152
+ value,
153
+ visual,
154
+ loading,
155
+ }: {
156
+ label: string;
157
+ value: ReactNode;
158
+ visual?: ReactNode;
159
+ loading: boolean;
160
+ }) {
161
+ return (
162
+ // A `div` inside a `<dl>` may hold nothing but `dt`/`dd` — so the cell is
163
+ // exactly those two, and the trend rides INSIDE the `dd` beside the number
164
+ // rather than as a third sibling.
165
+ <div data-slot="process-kpi-strip-cell" className="flex flex-1 flex-col px-4 py-2">
166
+ <dt className="text-meta whitespace-nowrap text-muted-foreground">{label}</dt>
167
+ <dd className="flex items-center justify-between gap-3">
168
+ <span className="text-subtitle font-semibold tabular-nums whitespace-nowrap">
169
+ {loading ? <Skeleton className="mt-1 h-5 w-14" /> : value}
170
+ </span>
171
+ {/* The trend is the first thing to go when the ribbon is short of room; the number stays. */}
172
+ {!loading && visual ? (
173
+ <span className="hidden shrink-0 text-muted-foreground @4xl:block">{visual}</span>
174
+ ) : null}
175
+ </dd>
176
+ </div>
177
+ );
121
178
  }
122
179
 
123
180
  export function ProcessKpiStrip({
124
181
  kpis,
125
182
  conformance,
126
183
  trends,
184
+ conformanceSeries,
127
185
  loading = false,
186
+ layout = "grid",
128
187
  className,
129
188
  ...props
130
189
  }: ProcessKpiStripProps) {
131
190
  const { t, formatNumber } = useLocale();
132
191
 
133
- const hasConformance = conformance !== null && conformance !== undefined;
192
+ const fraction = conformanceFraction(conformance);
193
+ const hasConformance = fraction !== undefined;
194
+ const conformanceTrend = conformanceSeries
195
+ ? conformanceSeries.map((point) => point.fitness)
196
+ : trends?.conformance;
134
197
  const conformanceHint = t("process.kpiStrip.conformanceUnavailableHint");
135
198
 
199
+ if (layout === "inline") {
200
+ const percent = (value: number) =>
201
+ formatNumber(value, { style: "percent", maximumFractionDigits: 1 });
202
+ const spark = (values: number[] | undefined, subject: string, format: TrendValueFormat) =>
203
+ trendVisual(values, subject, format, t, formatNumber, { width: 64, height: 24 });
204
+ return (
205
+ <div
206
+ data-slot="process-kpi-strip"
207
+ data-layout="inline"
208
+ role={loading ? "status" : undefined}
209
+ aria-busy={loading || undefined}
210
+ className={cn("@container overflow-x-auto", className)}
211
+ {...props}
212
+ >
213
+ <dl className="flex min-w-max divide-x divide-border">
214
+ <InlineKpi
215
+ label={t("process.kpiStrip.cases")}
216
+ value={formatNumber(kpis.cases)}
217
+ visual={spark(trends?.cases, t("process.kpiStrip.cases"), "count")}
218
+ loading={loading}
219
+ />
220
+ <InlineKpi
221
+ label={t("process.kpiStrip.events")}
222
+ value={formatNumber(kpis.events)}
223
+ visual={spark(trends?.events, t("process.kpiStrip.events"), "count")}
224
+ loading={loading}
225
+ />
226
+ <InlineKpi
227
+ label={t("process.kpiStrip.variants")}
228
+ value={formatNumber(kpis.variants)}
229
+ visual={spark(trends?.variants, t("process.kpiStrip.variants"), "count")}
230
+ loading={loading}
231
+ />
232
+ <InlineKpi
233
+ label={t("process.kpiStrip.medianThroughput")}
234
+ value={formatDurationMs(kpis.medianThroughput)}
235
+ visual={spark(
236
+ trends?.medianThroughput,
237
+ t("process.kpiStrip.medianThroughput"),
238
+ "duration",
239
+ )}
240
+ loading={loading}
241
+ />
242
+ <InlineKpi
243
+ label={t("process.kpiStrip.reworkRate")}
244
+ value={percent(kpis.reworkRate)}
245
+ visual={spark(trends?.reworkRate, t("process.kpiStrip.reworkRate"), "percent")}
246
+ loading={loading}
247
+ />
248
+ <InlineKpi
249
+ label={t("process.kpiStrip.conformance")}
250
+ value={
251
+ hasConformance ? (
252
+ percent(fraction)
253
+ ) : (
254
+ <span
255
+ data-slot="process-kpi-strip-conformance-unavailable"
256
+ title={conformanceHint}
257
+ className="inline-flex items-center gap-1.5 text-body font-normal text-muted-foreground"
258
+ >
259
+ <CircleSlash2 aria-hidden="true" className="size-4" />
260
+ {t("process.kpiStrip.conformanceUnavailable")}
261
+ </span>
262
+ )
263
+ }
264
+ visual={
265
+ hasConformance
266
+ ? spark(conformanceTrend, t("process.kpiStrip.conformance"), "percent")
267
+ : undefined
268
+ }
269
+ loading={loading}
270
+ />
271
+ </dl>
272
+ </div>
273
+ );
274
+ }
275
+
136
276
  return (
137
277
  <div data-slot="process-kpi-strip" className={className} {...props}>
138
278
  <MetricGrid columns={3} loading={loading}>
@@ -195,7 +335,7 @@ export function ProcessKpiStrip({
195
335
  label={t("process.kpiStrip.conformance")}
196
336
  value={
197
337
  hasConformance ? (
198
- (conformance as number)
338
+ fraction
199
339
  ) : (
200
340
  <span
201
341
  data-slot="process-kpi-strip-conformance-unavailable"
@@ -222,7 +362,7 @@ export function ProcessKpiStrip({
222
362
  visual={
223
363
  hasConformance
224
364
  ? trendVisual(
225
- trends?.conformance,
365
+ conformanceTrend,
226
366
  t("process.kpiStrip.conformance"),
227
367
  "percent",
228
368
  t,
@@ -0,0 +1,25 @@
1
+ /**
2
+ * How an activity's identity colour is painted (RM-054).
3
+ *
4
+ * The ONE place that turns an {@link ActivityColor} into CSS, shared by
5
+ * `ProcessActivityNode`'s accent swatch and `VariantExplorer`'s sequence chips — so the
6
+ * two views cannot drift into painting the same activity differently. Both endpoints are
7
+ * semantic tokens; no literal colour is authored.
8
+ *
9
+ * The shared "other" bucket is a diagonal hatch over a light wash of the same token, so
10
+ * it reads as "one of many" in greyscale too, never as a twelfth distinct activity.
11
+ */
12
+ import type { CSSProperties } from "react";
13
+ import type { ActivityColor } from "../core/activity-color-scale";
14
+
15
+ /** Inline style for an accent swatch painted with `color`. */
16
+ export function activityAccentStyle(color: ActivityColor): CSSProperties {
17
+ const ink = `var(${color.token})`;
18
+ if (color.pattern === "other") {
19
+ return {
20
+ backgroundColor: `color-mix(in oklab, ${ink} 30%, transparent)`,
21
+ backgroundImage: `repeating-linear-gradient(135deg, ${ink} 0 1.5px, transparent 1.5px 3.5px)`,
22
+ };
23
+ }
24
+ return { backgroundColor: ink };
25
+ }
@@ -5,6 +5,7 @@
5
5
  * read, the layout hook, and the whole `map-model` vocabulary (the model is what keeps the
6
6
  * canvas and the `tableView` twin printing identical numbers, so it is public on purpose).
7
7
  */
8
+ export * from "./activity-accent";
8
9
  export * from "./map-model";
9
10
  export * from "./process-map";
10
11
  export * from "./process-map-context";
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { computeEdgeWeightScale, DEFAULT_EDGE_WIDTH_RANGE } from "@elabs-ai/components-flow";
3
+ import { activityColorScale } from "../core/activity-color-scale";
3
4
  import { discoverGraph } from "../core/discover-graph";
4
5
  import { detectRework } from "../core/detect-rework";
5
6
  import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
@@ -324,3 +325,18 @@ describe("non-colour channels reach assistive technology", () => {
324
325
  for (const edge of model.edges) expect(edge.ariaLabel).toBeTruthy();
325
326
  });
326
327
  });
328
+
329
+ describe("activity colour accent (RM-054)", () => {
330
+ const metric = { node: "absolute_case", edge: "absolute" } as const;
331
+
332
+ it("carries no accent when no colour scale is given — today's model, unchanged", () => {
333
+ const plain = buildProcessMapModel({ graph, metric });
334
+ for (const node of plain.nodes) expect("accent" in node.data).toBe(false);
335
+ });
336
+
337
+ it("stamps each node with the scale's colour for its activity", () => {
338
+ const scale = activityColorScale(graph);
339
+ const coloured = buildProcessMapModel({ graph, metric, colorScale: scale });
340
+ for (const node of coloured.nodes) expect(node.data.accent).toEqual(scale.colorFor(node.id));
341
+ });
342
+ });
@@ -38,11 +38,14 @@
38
38
  // bundle's tree-shaking mercy; naming the four modules the map actually needs takes it off
39
39
  // the table entirely. Same package, so this is an ordinary relative import.
40
40
  import type { Edge, Node } from "@xyflow/react";
41
+ import type { ActivityColor, ActivityColorScale } from "../core/activity-color-scale";
42
+ import type { ConformanceState } from "../conformance-overlay/conformance-state";
41
43
  import { performanceValue } from "../core/aggregate-performance";
42
44
  import { EDGE_KEY_SEPARATOR } from "../core/discover-graph";
43
45
  import { minMax } from "../core/scale";
44
46
  import type { ReworkStats } from "../core/detect-rework";
45
47
  import type { FilterSpec } from "../core/filter-log";
48
+ import type { ObjectCentricGraph } from "../core/discover-object-centric-graph";
46
49
  import type {
47
50
  ActivityStats,
48
51
  FrequencyMode,
@@ -192,6 +195,22 @@ export interface ProcessActivityNodeData extends Record<string, unknown> {
192
195
  /** Repeat executions of this activity across the log; omitted when no rework data. */
193
196
  reworkCount?: number;
194
197
  selectionState: ProcessSelectionState;
198
+ /**
199
+ * The activity's identity colour from a shared `ActivityColorScale` (RM-054), painted as
200
+ * a small accent swatch — never the card fill, which would fight the metric reading.
201
+ * Absent when the map was given no `colorScale`.
202
+ */
203
+ accent?: ActivityColor;
204
+ /**
205
+ * Where this activity sits against a reference model (RM-062). Set only when the map
206
+ * was given `conformance`; drives `data-conformance` plus its glyph and dash.
207
+ */
208
+ conformance?: ConformanceState;
209
+ /**
210
+ * Object-centric — RM-066. One entry per object type this activity occurs in, in the
211
+ * graph's type order. Set only in object-centric mode; drives the per-type chips.
212
+ */
213
+ objectTypes?: ProcessObjectTypeCount[];
195
214
  }
196
215
 
197
216
  /** `data` carried by every {@link ProcessMapEdge}. */
@@ -210,6 +229,32 @@ export interface ProcessTransitionEdgeData extends Record<string, unknown> {
210
229
  isSelfLoop: boolean;
211
230
  isBackEdge: boolean;
212
231
  selectionState: ProcessSelectionState;
232
+ /**
233
+ * This transition's composed accessible name ({@link transitionAriaLabel}), duplicated
234
+ * here from the edge object's own `ariaLabel` (#354). React Flow's `EdgeWrapper` reads
235
+ * `ariaLabel` off the edge OBJECT for the outer, non-focusable `<g>` — it never forwards
236
+ * it to the edge COMPONENT via `EdgeProps` (`@xyflow/react`'s `EdgeProps` type has no
237
+ * `ariaLabel` field) — so `ProcessTransitionEdge`, which only receives `data`, has no
238
+ * other channel to reach it. Read this field, never recompute the name locally, or the
239
+ * canvas and the `TableView` twin (`transitionRows`) can drift apart. Always set by
240
+ * {@link buildProcessMapModel} — optional only because it is filled in a step after the
241
+ * rest of this object (it is itself derived from this object).
242
+ */
243
+ ariaLabel?: string;
244
+ /**
245
+ * Where this transition sits against a reference model (RM-062). Set only when the map
246
+ * was given `conformance`; drives `data-conformance` plus its glyph and dash.
247
+ */
248
+ conformance?: ConformanceState;
249
+ /**
250
+ * Object-centric — RM-066. The object type this edge belongs to; per-type edges between
251
+ * the same two activities are drawn side by side (see `parallelIndex`/`parallelCount`).
252
+ */
253
+ objectType?: ProcessObjectTypeMark;
254
+ /** Object-centric — RM-066. This edge's slot among the per-type edges of its pair. */
255
+ parallelIndex?: number;
256
+ /** Object-centric — RM-066. How many per-type edges join this edge's pair. */
257
+ parallelCount?: number;
213
258
  }
214
259
 
215
260
  /** A process-map activity node. Register as `nodeTypes={{ "process-activity": … }}`. */
@@ -226,6 +271,8 @@ export interface ProcessActivityRow {
226
271
  reworkCount?: number;
227
272
  role: string;
228
273
  selectionState: ProcessSelectionState;
274
+ /** Object-centric — RM-066. Per-type counts, as printed in the table twin. */
275
+ objectTypes?: string;
229
276
  }
230
277
 
231
278
  /** One row of the accessible `TableView` twin — transitions half. */
@@ -237,6 +284,8 @@ export interface ProcessTransitionRow {
237
284
  secondaryLabel?: string;
238
285
  shape: string;
239
286
  selectionState: ProcessSelectionState;
287
+ /** Object-centric — RM-066. The object type's display name. */
288
+ objectType?: string;
240
289
  }
241
290
 
242
291
  /** Everything the canvas and the table are both rendered from. */
@@ -627,6 +676,11 @@ export interface BuildProcessMapModelOptions {
627
676
  * every edge starts `isBackEdge: false` exactly as `discoverGraph` leaves it.
628
677
  */
629
678
  backEdgeIds?: ReadonlySet<string>;
679
+ /**
680
+ * The shared activity colour scale (RM-054). When given, every node carries
681
+ * `data.accent`, so the map and `VariantExplorer` paint an activity identically.
682
+ */
683
+ colorScale?: ActivityColorScale;
630
684
  }
631
685
 
632
686
  /**
@@ -640,6 +694,7 @@ export function buildProcessMapModel({
640
694
  selection,
641
695
  selectionStates,
642
696
  backEdgeIds,
697
+ colorScale,
643
698
  }: BuildProcessMapModelOptions): ProcessMapModel {
644
699
  const neighbourhood = selectionNeighbourhood(graph, selection);
645
700
  const denominators = processEdgeDenominators(graph.transitions);
@@ -688,6 +743,7 @@ export function buildProcessMapModel({
688
743
  isEnd: activity.isEnd,
689
744
  reworkCount,
690
745
  selectionState,
746
+ ...(colorScale ? { accent: colorScale.colorFor(activity.id) } : {}),
691
747
  };
692
748
  return {
693
749
  id: activity.id,
@@ -741,13 +797,18 @@ export function buildProcessMapModel({
741
797
  isBackEdge: backEdgeIds?.has(id) ?? transition.isBackEdge,
742
798
  selectionState,
743
799
  };
800
+ // Computed once, read from two places (#354): React Flow's own `EdgeWrapper` reads
801
+ // `ariaLabel` off THIS edge object for the outer, non-focusable `<g>`; `data.ariaLabel`
802
+ // is what `ProcessTransitionEdge` — which never sees this edge object, only `data` —
803
+ // folds onto the focused label pill. One computation, so the two can never drift.
804
+ data.ariaLabel = transitionAriaLabel(data, resolvedEdgeMetricLabel);
744
805
  return {
745
806
  id,
746
807
  source: transition.source,
747
808
  target: transition.target,
748
809
  type: "process-transition",
749
810
  data,
750
- ariaLabel: transitionAriaLabel(data, resolvedEdgeMetricLabel),
811
+ ariaLabel: data.ariaLabel,
751
812
  };
752
813
  });
753
814
 
@@ -871,3 +932,264 @@ export function transitionAriaLabel(data: ProcessTransitionEdgeData, metricLabel
871
932
  if (data.selectionState !== "associated") parts.push(data.selectionState);
872
933
  return parts.join(", ");
873
934
  }
935
+ // ── Object-centric — RM-066 ──────────────────────────────────────────────────
936
+
937
+ /** How one object type is marked on the map: a name, a text code and a chart colour. */
938
+ export interface ProcessObjectTypeMark {
939
+ /** The object type's id, as keyed in the `ObjectCentricGraph`. */
940
+ type: string;
941
+ /** Display name. */
942
+ label: string;
943
+ /** Two-character code printed beside every swatch — the non-colour channel. */
944
+ code: string;
945
+ /** A categorical chart token from `objectTypeColorScale` (RM-054's scale, reused). */
946
+ color: ActivityColor;
947
+ }
948
+
949
+ /** One object type's share of an activity node. */
950
+ export interface ProcessObjectTypeCount extends ProcessObjectTypeMark {
951
+ instances: number;
952
+ /** Distinct objects of this type the activity touches. */
953
+ cases: number;
954
+ /** The chip's accessible name, e.g. "order: 2 objects, 3 occurrences". */
955
+ ariaLabel: string;
956
+ }
957
+
958
+ /** Every string the object-centric mode composes. Override for a non-English locale. */
959
+ export interface ObjectCentricMapLabels {
960
+ /** A node chip's accessible name and table text. */
961
+ typeCount: (type: string, objects: number, occurrences: number) => string;
962
+ /** Prefixes a per-type edge's accessible name. */
963
+ edgePrefix: (type: string) => string;
964
+ /** Activity-table column header. */
965
+ columnObjectTypes: string;
966
+ /** Transition-table column header. */
967
+ columnObjectType: string;
968
+ /** The canvas legend's title. */
969
+ legendTitle: string;
970
+ }
971
+
972
+ /** English defaults for {@link ObjectCentricMapLabels}. */
973
+ export const OBJECT_CENTRIC_MAP_DEFAULT_LABELS: Readonly<ObjectCentricMapLabels> = Object.freeze({
974
+ typeCount: (type: string, objects: number, occurrences: number) =>
975
+ `${type}: ${objects} ${objects === 1 ? "object" : "objects"}, ${occurrences} ${
976
+ occurrences === 1 ? "occurrence" : "occurrences"
977
+ }`,
978
+ edgePrefix: (type: string) => `${type} flow`,
979
+ columnObjectTypes: "Object types",
980
+ columnObjectType: "Object type",
981
+ legendTitle: "Object types",
982
+ });
983
+
984
+ /** The id of one object type's edge: the merged edge id, the separator, the type. */
985
+ export function objectCentricEdgeId(source: string, target: string, type: string): string {
986
+ return `${processEdgeId(source, target)}${EDGE_KEY_SEPARATOR}${type}`;
987
+ }
988
+
989
+ /** Inputs to {@link buildObjectCentricMapModel}. */
990
+ export interface BuildObjectCentricMapModelOptions extends BuildProcessMapModelOptions {
991
+ /**
992
+ * The object-centric graph `graph` was flattened from (`objectCentricProcessGraph`).
993
+ * Omit — or omit `objectTypeScale` — and this is exactly {@link buildProcessMapModel}.
994
+ */
995
+ objectCentric?: ObjectCentricGraph;
996
+ /** `objectTypeColorScale(objectCentric)`, built once from the FULL graph. */
997
+ objectTypeScale?: ActivityColorScale;
998
+ objectCentricLabels?: ObjectCentricMapLabels;
999
+ }
1000
+
1001
+ /**
1002
+ * The object-centric process-map model: {@link buildProcessMapModel} over the flattened
1003
+ * graph, then
1004
+ *
1005
+ * - every node gains `data.objectTypes` (one chip per type, each with a text code and a
1006
+ * real accessible name folded into the node's own), and
1007
+ * - every merged edge is replaced by one edge PER OBJECT TYPE — its weight and printed
1008
+ * label read that type's own counts, its label is prefixed with the type's code, and its
1009
+ * stroke is the type's chart token. The edge-value colour ramp is never used here.
1010
+ *
1011
+ * A transition selection may name either a per-type edge or the merged pair; both select
1012
+ * the pair's neighbourhood, and only the named per-type edge reads `"selected"`.
1013
+ */
1014
+ export function buildObjectCentricMapModel({
1015
+ objectCentric,
1016
+ objectTypeScale,
1017
+ objectCentricLabels = OBJECT_CENTRIC_MAP_DEFAULT_LABELS,
1018
+ ...options
1019
+ }: BuildObjectCentricMapModelOptions): ProcessMapModel {
1020
+ if (!objectCentric || !objectTypeScale) return buildProcessMapModel(options);
1021
+ const labels = objectCentricLabels;
1022
+ const { metric } = options;
1023
+
1024
+ const typeEdges = new Map<string, Map<string, TransitionStats>>();
1025
+ const mergedIdOf = new Map<string, string>();
1026
+ for (const type of objectCentric.objectTypes) {
1027
+ for (const transition of objectCentric.transitionsByType[type] ?? []) {
1028
+ const mergedId = processEdgeId(transition.source, transition.target);
1029
+ mergedIdOf.set(objectCentricEdgeId(transition.source, transition.target, type), mergedId);
1030
+ let byType = typeEdges.get(mergedId);
1031
+ if (byType === undefined) {
1032
+ byType = new Map();
1033
+ typeEdges.set(mergedId, byType);
1034
+ }
1035
+ byType.set(type, transition);
1036
+ }
1037
+ }
1038
+
1039
+ const requested = options.selection;
1040
+ const typeSelectionId =
1041
+ requested?.kind === "transition" && mergedIdOf.has(requested.id) ? requested.id : undefined;
1042
+ const model = buildProcessMapModel({
1043
+ ...options,
1044
+ selection: typeSelectionId
1045
+ ? { kind: "transition", id: mergedIdOf.get(typeSelectionId) as string }
1046
+ : requested,
1047
+ });
1048
+
1049
+ const marks = new Map<string, ProcessObjectTypeMark>(
1050
+ objectCentric.objectTypes.map((type) => [
1051
+ type,
1052
+ {
1053
+ type,
1054
+ label: objectTypeScale.labelFor(type),
1055
+ code: objectTypeScale.codeFor(type),
1056
+ color: objectTypeScale.colorFor(type),
1057
+ },
1058
+ ]),
1059
+ );
1060
+
1061
+ const activityById = new Map(objectCentric.activities.map((a) => [a.id, a]));
1062
+ const nodes: ProcessMapNode[] = model.nodes.map((node) => {
1063
+ const activity = activityById.get(node.id);
1064
+ if (!activity) return node;
1065
+ const objectTypes: ProcessObjectTypeCount[] = [];
1066
+ for (const type of objectCentric.objectTypes) {
1067
+ const counts = activity.perType[type];
1068
+ const mark = marks.get(type);
1069
+ if (!counts || !mark) continue;
1070
+ objectTypes.push({
1071
+ ...mark,
1072
+ instances: counts.instances,
1073
+ cases: counts.cases,
1074
+ ariaLabel: labels.typeCount(mark.label, counts.cases, counts.instances),
1075
+ });
1076
+ }
1077
+ return {
1078
+ ...node,
1079
+ data: { ...node.data, objectTypes },
1080
+ ariaLabel: [node.ariaLabel, ...objectTypes.map((entry) => entry.ariaLabel)].join(", "),
1081
+ };
1082
+ });
1083
+
1084
+ const denominators = new Map(
1085
+ objectCentric.objectTypes.map((type) => [
1086
+ type,
1087
+ processEdgeDenominators(objectCentric.transitionsByType[type] ?? []),
1088
+ ]),
1089
+ );
1090
+
1091
+ const edges: ProcessMapEdge[] = [];
1092
+ const values: number[] = [];
1093
+ for (const merged of model.edges) {
1094
+ const byType = typeEdges.get(merged.id);
1095
+ const mergedData = merged.data as ProcessTransitionEdgeData;
1096
+ if (!byType) continue;
1097
+ let parallelIndex = 0;
1098
+ for (const type of objectCentric.objectTypes) {
1099
+ const transition = byType.get(type);
1100
+ const mark = marks.get(type);
1101
+ const typeGraph = objectCentric.graphsByType[type];
1102
+ if (!transition || !mark || !typeGraph) continue;
1103
+ const typeDenominators = denominators.get(type) as ProcessEdgeDenominators;
1104
+ const id = objectCentricEdgeId(transition.source, transition.target, type);
1105
+ const value = transitionMetricValue(
1106
+ transition,
1107
+ metric.edge,
1108
+ typeGraph.totals,
1109
+ typeDenominators,
1110
+ );
1111
+ values.push(value);
1112
+ const selectionState: ProcessSelectionState =
1113
+ typeSelectionId !== undefined &&
1114
+ mergedData.selectionState === "selected" &&
1115
+ typeSelectionId !== id
1116
+ ? "associated"
1117
+ : mergedData.selectionState;
1118
+ const data: ProcessTransitionEdgeData = {
1119
+ ...mergedData,
1120
+ weight: value,
1121
+ value,
1122
+ label: `${mark.code} ${formatMetricValue(value, metric.edge)}`,
1123
+ secondaryLabel:
1124
+ metric.secondary === undefined
1125
+ ? undefined
1126
+ : formatMetricValue(
1127
+ transitionMetricValue(
1128
+ transition,
1129
+ metric.secondary,
1130
+ typeGraph.totals,
1131
+ typeDenominators,
1132
+ ),
1133
+ metric.secondary,
1134
+ ),
1135
+ selectionState,
1136
+ objectType: mark,
1137
+ parallelIndex,
1138
+ parallelCount: byType.size,
1139
+ };
1140
+ data.ariaLabel = `${labels.edgePrefix(mark.label)}: ${transitionAriaLabel(
1141
+ data,
1142
+ model.edgeMetricLabel,
1143
+ )}`;
1144
+ edges.push({
1145
+ ...merged,
1146
+ id,
1147
+ data,
1148
+ ariaLabel: data.ariaLabel,
1149
+ style: { stroke: `var(${mark.color.token})` },
1150
+ });
1151
+ parallelIndex += 1;
1152
+ }
1153
+ }
1154
+
1155
+ const edgeDomain = values.length > 0 ? minMax(values) : model.edgeDomain;
1156
+ for (const edge of edges) (edge.data as ProcessTransitionEdgeData).valueDomain = edgeDomain;
1157
+
1158
+ const activityRows: ProcessActivityRow[] = model.activityRows.map((row, index) => {
1159
+ const objectTypes = nodes[index]?.data.objectTypes;
1160
+ return objectTypes
1161
+ ? {
1162
+ ...row,
1163
+ objectTypes: objectTypes.map((entry) => entry.ariaLabel).join("; "),
1164
+ }
1165
+ : row;
1166
+ });
1167
+
1168
+ const transitionRows: ProcessTransitionRow[] = edges.map((edge) => {
1169
+ const data = edge.data as ProcessTransitionEdgeData;
1170
+ return {
1171
+ id: edge.id,
1172
+ source: data.source,
1173
+ target: data.target,
1174
+ primaryLabel: data.label,
1175
+ secondaryLabel: data.secondaryLabel,
1176
+ shape: transitionShape(data),
1177
+ selectionState: data.selectionState,
1178
+ objectType: data.objectType?.label,
1179
+ };
1180
+ });
1181
+
1182
+ return {
1183
+ ...model,
1184
+ nodes,
1185
+ edges,
1186
+ edgeDomain,
1187
+ activityRows,
1188
+ transitionRows,
1189
+ excludedCounts: {
1190
+ ...model.excludedCounts,
1191
+ transitions: transitionRows.filter((row) => row.selectionState === "excluded").length,
1192
+ totalTransitions: transitionRows.length,
1193
+ },
1194
+ };
1195
+ }