@elabs-ai/components-process 4.2.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +8 -1
  2. package/dist/core/index.d.ts +801 -3
  3. package/dist/core/index.js +1334 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/index.d.ts +1889 -34
  6. package/dist/index.js +5512 -196
  7. package/dist/index.js.map +1 -1
  8. package/dist/test/index.d.ts +223 -5
  9. package/dist/test/index.js +346 -191
  10. package/dist/test/index.js.map +1 -1
  11. package/package.json +14 -13
  12. package/src/__contract__/case-table.contract.test.tsx +49 -0
  13. package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
  14. package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
  15. package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
  16. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  17. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  18. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  19. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  20. package/src/case-table/case-table.stories.tsx +89 -0
  21. package/src/case-table/case-table.test.tsx +148 -0
  22. package/src/case-table/case-table.tsx +144 -0
  23. package/src/case-table/columns.ts +116 -0
  24. package/src/case-table/index.ts +11 -0
  25. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  26. package/src/case-timeline/case-timeline-model.ts +112 -0
  27. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  28. package/src/case-timeline/case-timeline.test.tsx +51 -0
  29. package/src/case-timeline/case-timeline.tsx +109 -0
  30. package/src/case-timeline/index.ts +9 -0
  31. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  32. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  33. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  34. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  35. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  36. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  37. package/src/conformance-overlay/conformance-state.ts +220 -0
  38. package/src/conformance-overlay/index.ts +4 -0
  39. package/src/core/activity-color-scale.test.ts +107 -0
  40. package/src/core/activity-color-scale.ts +133 -0
  41. package/src/core/adapters/ocel.test.ts +112 -0
  42. package/src/core/adapters/ocel.ts +359 -0
  43. package/src/core/adapters/xes.test.ts +293 -0
  44. package/src/core/adapters/xes.ts +384 -0
  45. package/src/core/cases-from-log.test.ts +72 -0
  46. package/src/core/cases-from-log.ts +85 -0
  47. package/src/core/conformance.test.ts +80 -0
  48. package/src/core/conformance.ts +91 -0
  49. package/src/core/diff-graphs.test.ts +151 -0
  50. package/src/core/diff-graphs.ts +118 -0
  51. package/src/core/discover-object-centric-graph.test.ts +94 -0
  52. package/src/core/discover-object-centric-graph.ts +296 -0
  53. package/src/core/fixtures/ocel-sample.ts +82 -0
  54. package/src/core/fixtures/sample.xes +68 -0
  55. package/src/core/index.ts +113 -0
  56. package/src/core/reference-model.test.ts +43 -0
  57. package/src/core/reference-model.ts +116 -0
  58. package/src/core/replay-timeline.test.ts +161 -0
  59. package/src/core/replay-timeline.ts +260 -0
  60. package/src/core/segments.test.ts +185 -0
  61. package/src/core/segments.ts +153 -0
  62. package/src/core/token-replay.test.ts +218 -0
  63. package/src/core/token-replay.ts +456 -0
  64. package/src/core/types.ts +2 -2
  65. package/src/dotted-chart/compute-dots.test.ts +176 -0
  66. package/src/dotted-chart/compute-dots.ts +241 -0
  67. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  68. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  69. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  70. package/src/dotted-chart/dotted-chart.tsx +841 -0
  71. package/src/dotted-chart/index.ts +23 -0
  72. package/src/dotted-chart/use-element-size.ts +33 -0
  73. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  74. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  75. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  76. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  77. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  78. package/src/happy-path-editor/index.ts +4 -0
  79. package/src/index.ts +51 -1
  80. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  81. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  82. package/src/performance-spectrum/index.ts +25 -0
  83. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  84. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  85. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  86. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  87. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  88. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  89. package/src/process-compare/compare-model.ts +83 -0
  90. package/src/process-compare/compare-side.tsx +42 -0
  91. package/src/process-compare/diff-to-graph.ts +104 -0
  92. package/src/process-compare/index.ts +23 -0
  93. package/src/process-compare/process-compare.stories.tsx +184 -0
  94. package/src/process-compare/process-compare.test.tsx +224 -0
  95. package/src/process-compare/process-compare.tsx +251 -0
  96. package/src/process-explorer.stories.tsx +1 -1
  97. package/src/process-filter-bar/index.ts +2 -0
  98. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  99. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  100. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  101. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  102. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  103. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  104. package/src/process-map/activity-accent.ts +25 -0
  105. package/src/process-map/index.ts +1 -0
  106. package/src/process-map/map-model.test.ts +16 -0
  107. package/src/process-map/map-model.ts +323 -1
  108. package/src/process-map/object-centric-map.test.tsx +132 -0
  109. package/src/process-map/process-activity-node.tsx +152 -14
  110. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  111. package/src/process-map/process-map.stories.tsx +64 -0
  112. package/src/process-map/process-map.tsx +240 -16
  113. package/src/process-map/process-transition-edge.test.tsx +47 -0
  114. package/src/process-map/process-transition-edge.tsx +134 -7
  115. package/src/process-map/use-process-layout.ts +30 -9
  116. package/src/process-replay/congestion-heat.tsx +107 -0
  117. package/src/process-replay/index.ts +14 -0
  118. package/src/process-replay/process-replay.stories.tsx +168 -0
  119. package/src/process-replay/process-replay.test.tsx +170 -0
  120. package/src/process-replay/process-replay.tsx +285 -0
  121. package/src/process-replay/replay-controls.tsx +147 -0
  122. package/src/process-replay/replay-format.ts +83 -0
  123. package/src/process-replay/replay-tokens-context.ts +30 -0
  124. package/src/process-replay/use-controllable-value.ts +30 -0
  125. package/src/templates-process-explorer.stories.tsx +1304 -0
  126. package/src/test/contract.test.ts +66 -0
  127. package/src/test/contract.ts +107 -6
  128. package/src/test/doubles.test.tsx +87 -1
  129. package/src/test/doubles.tsx +174 -3
  130. package/src/test/index.ts +25 -1
  131. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  132. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  133. package/src/variant-explorer/coverage-bar.tsx +36 -0
  134. package/src/variant-explorer/index.ts +16 -0
  135. package/src/variant-explorer/sequence-chips.tsx +103 -0
  136. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  137. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  138. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  139. package/src/variant-explorer/variant-explorer.tsx +567 -0
  140. package/src/variant-explorer/variant-row.tsx +137 -0
  141. package/src/violation-list/index.ts +2 -0
  142. package/src/violation-list/violation-list.stories.tsx +73 -0
  143. package/src/violation-list/violation-list.test.tsx +84 -0
  144. package/src/violation-list/violation-list.tsx +259 -0
@@ -89,7 +89,29 @@ import {
89
89
  import { abstractGraph, type AbstractionOptions } from "../core/abstract-graph";
90
90
  import { detectRework, type ReworkStats } from "../core/detect-rework";
91
91
  import { discoverGraph } from "../core/discover-graph";
92
+ import type { ActivityColorScale } from "../core/activity-color-scale";
92
93
  import type { EventLog, ProcessGraph } from "../core/types";
94
+ import type { ConformanceResult } from "../core/conformance";
95
+ import {
96
+ activityConformance,
97
+ CONFORMANCE_STATE_DEFAULT_LABELS,
98
+ resolveConformanceStates,
99
+ transitionConformance,
100
+ withActivityConformance,
101
+ withTransitionConformance,
102
+ } from "../conformance-overlay/conformance-state";
103
+ import { ConformanceStateMark } from "../conformance-overlay/conformance-legend";
104
+ import {
105
+ abstractObjectCentricGraph,
106
+ objectCentricProcessGraph,
107
+ objectTypeColorScale,
108
+ type ObjectCentricGraph,
109
+ } from "../core/discover-object-centric-graph";
110
+ import {
111
+ buildObjectCentricMapModel,
112
+ OBJECT_CENTRIC_MAP_DEFAULT_LABELS,
113
+ type ObjectCentricMapLabels,
114
+ } from "./map-model";
93
115
  import {
94
116
  buildProcessMapModel,
95
117
  processGraphStructureKey,
@@ -209,11 +231,67 @@ export interface ProcessMapProps extends Omit<HTMLAttributes<HTMLDivElement>, "o
209
231
  tableView?: boolean;
210
232
  /** No graph yet. Renders the loading panel rather than an empty canvas. */
211
233
  loading?: boolean;
234
+ /**
235
+ * A shared activity colour scale (RM-054, `/core`'s `activityColorScale`). When given,
236
+ * each activity node shows its identity swatch — the same colour `VariantExplorer`
237
+ * paints that activity with. Build it once from the FULL graph and hand the same
238
+ * instance to both views. Omit for today's uncoloured nodes.
239
+ */
240
+ colorScale?: ActivityColorScale;
241
+ /**
242
+ * A replay result (RM-061's `tokenReplay`) to overlay (RM-062). When given, every node
243
+ * and edge carries `data-conformance="both" | "logOnly" | "modelOnly"` — additive to
244
+ * `data-selection` — painted as a status tone PLUS a glyph and a line style, with the
245
+ * state's word in the accessible name and a Conformance column in the table twin. Omit
246
+ * for today's map, unchanged. Usually reached through `ConformanceOverlay`.
247
+ */
248
+ conformance?: ConformanceResult;
249
+ /**
250
+ * Object-centric — RM-066. An object-centric graph (`discoverObjectCentricGraph`, e.g.
251
+ * over `fromOcel(…).logs`) to draw instead of `graph`/`log` — mutually exclusive with
252
+ * both, and it wins when given together (with a dev-mode console warning). Shared
253
+ * activities merge into one node carrying a chip per object type; edges are drawn once
254
+ * per object type in that type's chart colour, side by side. `abstraction` applies to
255
+ * every type alike; for per-type abstraction pass `abstractObjectCentricGraph(…)`'s
256
+ * result here instead.
257
+ */
258
+ objectCentric?: ObjectCentricGraph;
259
+ /** Object-centric — RM-066. Strings the object-centric mode composes. */
260
+ objectCentricLabels?: ObjectCentricMapLabels;
212
261
  /**
213
262
  * Accessible name for the canvas region. Defaults to the localized
214
263
  * `process.map.label` message.
215
264
  */
216
265
  label?: string;
266
+ /**
267
+ * The smallest zoom the OPENING fit may use. The default keeps activity names legible
268
+ * and, when the process does not fit at that size, opens on its start. A workspace that
269
+ * would rather open on the whole picture — a wide canvas, a reader who zooms in on what
270
+ * matters — lowers it. The reader can always zoom out further by hand.
271
+ * @default PROCESS_MAP_LEGIBLE_ZOOM
272
+ */
273
+ fitMinZoom?: number;
274
+ /**
275
+ * The fraction of the pane the opening fit keeps clear on every side. The default is
276
+ * generous because transition pills are drawn outside the fitted bounds; a pane that is
277
+ * short on the axis the process runs along can trade some of it for a larger picture.
278
+ * @default 0.15
279
+ */
280
+ fitPadding?: number;
281
+ /**
282
+ * Re-frames the map whenever this value changes. The map already re-fits when its own
283
+ * structure or direction changes; it cannot know that the pane around it just changed
284
+ * shape — a dock opened, a side panel closed. Pass anything that changes when that
285
+ * happens. Unset: no extra re-fits, the reader's viewport is left alone.
286
+ */
287
+ refitKey?: string | number;
288
+ // elkjs adapter — RM-067
289
+ /**
290
+ * The layout engine. `"elk"` lays the map out with `@elabs-ai/components-flow`'s
291
+ * `layoutFlowElk` — elkjs, an optional peer, loaded lazily on first use; when it is not
292
+ * installed the map falls back to dagre with a development-only warning. @default "dagre"
293
+ */
294
+ layoutEngine?: "dagre" | "elk";
217
295
  }
218
296
 
219
297
  /** Resolve the graph the map paints, running `/core` only when its own inputs change. */
@@ -236,6 +314,31 @@ function useResolvedGraph(
236
314
  }, [base, activities, paths]);
237
315
  }
238
316
 
317
+ /** Object-centric — RM-066. The abstracted graph, its flattened twin and the type scale. */
318
+ function useObjectCentricView(
319
+ objectCentric: ObjectCentricGraph | undefined,
320
+ abstraction: Pick<AbstractionOptions, "activities" | "paths"> | undefined,
321
+ ) {
322
+ const scale = useMemo(
323
+ () => (objectCentric ? objectTypeColorScale(objectCentric) : undefined),
324
+ [objectCentric],
325
+ );
326
+ const activities = abstraction?.activities;
327
+ const paths = abstraction?.paths;
328
+ return useMemo(() => {
329
+ if (!objectCentric || !scale) return undefined;
330
+ const graph =
331
+ activities === undefined && paths === undefined
332
+ ? objectCentric
333
+ : abstractObjectCentricGraph(
334
+ objectCentric,
335
+ {},
336
+ { activities: activities ?? 1, paths: paths ?? 1 },
337
+ );
338
+ return { graph, flat: objectCentricProcessGraph(graph), scale };
339
+ }, [objectCentric, scale, activities, paths]);
340
+ }
341
+
239
342
  /**
240
343
  * The process map.
241
344
  *
@@ -259,7 +362,15 @@ export function ProcessMap({
259
362
  showLegend = true,
260
363
  tableView = false,
261
364
  loading = false,
365
+ colorScale,
366
+ conformance,
367
+ objectCentric,
368
+ objectCentricLabels = OBJECT_CENTRIC_MAP_DEFAULT_LABELS,
262
369
  label,
370
+ layoutEngine = "dagre",
371
+ fitMinZoom = PROCESS_MAP_LEGIBLE_ZOOM,
372
+ fitPadding = FIT_VIEW_OPTIONS.padding,
373
+ refitKey,
263
374
  className,
264
375
  ...props
265
376
  }: ProcessMapProps) {
@@ -280,7 +391,22 @@ export function ProcessMap({
280
391
  },
281
392
  [t],
282
393
  );
283
- const resolved = useResolvedGraph(graph, log, abstraction);
394
+ // Object-centric RM-066: abstract per type, flatten for layout, colour from the FULL
395
+ // graph so a type keeps its swatch while the reader abstracts.
396
+ const objectCentricView = useObjectCentricView(objectCentric, abstraction);
397
+ useEffect(() => {
398
+ if (process.env.NODE_ENV === "production") return;
399
+ if (objectCentric && (graph || log)) {
400
+ console.warn(
401
+ "ProcessMap: `objectCentric` is mutually exclusive with `graph`/`log`; drawing `objectCentric`.",
402
+ );
403
+ }
404
+ }, [objectCentric, graph, log]);
405
+ const resolved = useResolvedGraph(
406
+ objectCentricView ? objectCentricView.flat : graph,
407
+ objectCentricView ? undefined : log,
408
+ objectCentricView ? undefined : abstraction,
409
+ );
284
410
  const derivedRework = useMemo(
285
411
  () => (rework ? undefined : log ? detectRework(log) : undefined),
286
412
  [rework, log],
@@ -315,9 +441,10 @@ export function ProcessMap({
315
441
  rework: activeRework,
316
442
  selection: activeSelection,
317
443
  selectionStates,
444
+ colorScale,
318
445
  })
319
446
  : null,
320
- [resolved, metric, activeRework, activeSelection, selectionStates],
447
+ [resolved, metric, activeRework, activeSelection, selectionStates, colorScale],
321
448
  );
322
449
 
323
450
  // ── Measured node sizes ───────────────────────────────────────────────────
@@ -387,6 +514,7 @@ export function ProcessMap({
387
514
  edges: firstPass?.edges ?? EMPTY_EDGES,
388
515
  structureKey: layoutKey,
389
516
  direction,
517
+ layoutEngine,
390
518
  });
391
519
 
392
520
  // PASS 2 — the same model, now told which edges run against the layout direction, so
@@ -395,16 +523,30 @@ export function ProcessMap({
395
523
  const model: ProcessMapModel | null = useMemo(
396
524
  () =>
397
525
  resolved
398
- ? buildProcessMapModel({
526
+ ? buildObjectCentricMapModel({
399
527
  graph: resolved,
400
528
  metric,
401
529
  rework: activeRework,
402
530
  selection: activeSelection,
403
531
  selectionStates,
404
532
  backEdgeIds: layout.backEdgeIds,
533
+ colorScale,
534
+ objectCentric: objectCentricView?.graph,
535
+ objectTypeScale: objectCentricView?.scale,
536
+ objectCentricLabels,
405
537
  })
406
538
  : null,
407
- [resolved, metric, activeRework, activeSelection, selectionStates, layout.backEdgeIds],
539
+ [
540
+ objectCentricView,
541
+ objectCentricLabels,
542
+ resolved,
543
+ metric,
544
+ activeRework,
545
+ activeSelection,
546
+ selectionStates,
547
+ layout.backEdgeIds,
548
+ colorScale,
549
+ ],
408
550
  );
409
551
 
410
552
  const positionedNodes = useMemo(
@@ -412,6 +554,38 @@ export function ProcessMap({
412
554
  [model, layout, direction],
413
555
  );
414
556
 
557
+ // ── Conformance (RM-062) ──────────────────────────────────────────────────
558
+ // A decoration AFTER the model and the layout, never an input to either: switching the
559
+ // reference model re-inks nodes and edges without re-deriving a metric or moving a node.
560
+ const conformanceStates = useMemo(
561
+ () => (conformance ? resolveConformanceStates(conformance) : null),
562
+ [conformance],
563
+ );
564
+ const canvasNodes = useMemo(
565
+ () =>
566
+ conformanceStates
567
+ ? positionedNodes.map((node) => withActivityConformance(node, conformanceStates))
568
+ : positionedNodes,
569
+ [positionedNodes, conformanceStates],
570
+ );
571
+ // Where the process begins: a clamped opening fit keeps these in view on BOTH axes — in a
572
+ // left-to-right layout the first rank sits at mid-height, not in the top-left corner.
573
+ const startNodeIds = useMemo(
574
+ () => positionedNodes.filter((node) => node.data.isStart).map((node) => node.id),
575
+ [positionedNodes],
576
+ );
577
+ const fitViewOptions = useMemo(
578
+ () => ({ ...FIT_VIEW_OPTIONS, minZoom: fitMinZoom, padding: fitPadding }),
579
+ [fitMinZoom, fitPadding],
580
+ );
581
+ const canvasEdges = useMemo(
582
+ () =>
583
+ model && conformanceStates
584
+ ? model.edges.map((edge) => withTransitionConformance(edge, conformanceStates))
585
+ : (model?.edges ?? EMPTY_EDGES),
586
+ [model, conformanceStates],
587
+ );
588
+
415
589
  // ── Hover ─────────────────────────────────────────────────────────────────
416
590
  const [hover, setHover] = useState<ProcessMapHoverState>(EMPTY_PROCESS_MAP_HOVER);
417
591
  const edgesRef = useRef<ProcessMapEdge[]>([]);
@@ -723,6 +897,12 @@ export function ProcessMap({
723
897
  <TableHead scope="col">{model.nodeMetricLabel}</TableHead>
724
898
  <TableHead scope="col">{t("process.map.columnRework")}</TableHead>
725
899
  <TableHead scope="col">{t("process.map.columnState")}</TableHead>
900
+ {conformanceStates ? (
901
+ <TableHead scope="col">{CONFORMANCE_STATE_DEFAULT_LABELS.column}</TableHead>
902
+ ) : null}
903
+ {objectCentricView ? (
904
+ <TableHead scope="col">{objectCentricLabels.columnObjectTypes}</TableHead>
905
+ ) : null}
726
906
  </TableRow>
727
907
  </TableHeader>
728
908
  <TableBody>
@@ -730,6 +910,9 @@ export function ProcessMap({
730
910
  <TableRow
731
911
  key={row.id}
732
912
  data-selection={row.selectionState}
913
+ data-conformance={
914
+ conformanceStates ? activityConformance(conformanceStates, row.id) : undefined
915
+ }
733
916
  // Complementary, colour-only styling hook (step 4): the real channel is the
734
917
  // State cell's text below, which fires for `selected` AND `excluded`; this
735
918
  // only lights up `TableRow`'s existing `data-[state=selected]:bg-accent`.
@@ -744,6 +927,12 @@ export function ProcessMap({
744
927
  </TableCell>
745
928
  <TableCell className="tabular-nums">{row.reworkCount ?? 0}</TableCell>
746
929
  <TableCell>{selectionStateText(row.selectionState)}</TableCell>
930
+ {conformanceStates ? (
931
+ <TableCell>
932
+ <ConformanceStateMark state={activityConformance(conformanceStates, row.id)} />
933
+ </TableCell>
934
+ ) : null}
935
+ {objectCentricView ? <TableCell>{row.objectTypes}</TableCell> : null}
747
936
  </TableRow>
748
937
  ))}
749
938
  </TableBody>
@@ -756,11 +945,17 @@ export function ProcessMap({
756
945
  </TableCaption>
757
946
  <TableHeader>
758
947
  <TableRow>
948
+ {objectCentricView ? (
949
+ <TableHead scope="col">{objectCentricLabels.columnObjectType}</TableHead>
950
+ ) : null}
759
951
  <TableHead scope="col">{t("process.map.columnFrom")}</TableHead>
760
952
  <TableHead scope="col">{t("process.map.columnTo")}</TableHead>
761
953
  <TableHead scope="col">{t("process.map.columnShape")}</TableHead>
762
954
  <TableHead scope="col">{model.edgeMetricLabel}</TableHead>
763
955
  <TableHead scope="col">{t("process.map.columnState")}</TableHead>
956
+ {conformanceStates ? (
957
+ <TableHead scope="col">{CONFORMANCE_STATE_DEFAULT_LABELS.column}</TableHead>
958
+ ) : null}
764
959
  </TableRow>
765
960
  </TableHeader>
766
961
  <TableBody>
@@ -768,8 +963,14 @@ export function ProcessMap({
768
963
  <TableRow
769
964
  key={row.id}
770
965
  data-selection={row.selectionState}
966
+ data-conformance={
967
+ conformanceStates
968
+ ? transitionConformance(conformanceStates, row.source, row.target)
969
+ : undefined
970
+ }
771
971
  data-state={row.selectionState === "selected" ? "selected" : undefined}
772
972
  >
973
+ {objectCentricView ? <TableCell>{row.objectType}</TableCell> : null}
773
974
  <TableCell>{row.source}</TableCell>
774
975
  <TableCell>{row.target}</TableCell>
775
976
  <TableCell>{row.shape}</TableCell>
@@ -779,6 +980,13 @@ export function ProcessMap({
779
980
  : row.primaryLabel}
780
981
  </TableCell>
781
982
  <TableCell>{selectionStateText(row.selectionState)}</TableCell>
983
+ {conformanceStates ? (
984
+ <TableCell>
985
+ <ConformanceStateMark
986
+ state={transitionConformance(conformanceStates, row.source, row.target)}
987
+ />
988
+ </TableCell>
989
+ ) : null}
782
990
  </TableRow>
783
991
  ))}
784
992
  </TableBody>
@@ -807,8 +1015,8 @@ export function ProcessMap({
807
1015
  <ProcessMapHoverContext value={hover}>
808
1016
  <ProcessMapEdgeKeyContext value={handleEdgeKey}>
809
1017
  <CanvasShell
810
- nodes={positionedNodes}
811
- edges={model.edges}
1018
+ nodes={canvasNodes}
1019
+ edges={canvasEdges}
812
1020
  nodeTypes={NODE_TYPES}
813
1021
  edgeTypes={EDGE_TYPES}
814
1022
  // dagre runs in an EFFECT, so the first paint has every node stacked at the
@@ -819,8 +1027,9 @@ export function ProcessMap({
819
1027
  // change (or a direction flip) genuinely moves the picture and has to be
820
1028
  // re-framed; a metric switch is a cache hit, leaves this key alone, and must
821
1029
  // NOT yank the viewport out from under the reader.
822
- fitViewKey={`${layoutKey}::${direction}::${layout.layoutRuns}`}
823
- fitViewKeyOptions={FIT_VIEW_OPTIONS}
1030
+ fitViewKey={`${layoutKey}::${direction}::${layout.layoutRuns}::${refitKey ?? ""}`}
1031
+ fitViewKeyOptions={fitViewOptions}
1032
+ fitViewAnchorNodeIds={startNodeIds}
824
1033
  minZoom={MIN_ZOOM}
825
1034
  nodesDraggable={false}
826
1035
  nodesConnectable={false}
@@ -873,14 +1082,29 @@ export function ProcessMap({
873
1082
  className="pointer-events-none absolute inset-x-3 top-3 flex items-start justify-between gap-3"
874
1083
  >
875
1084
  {showLegend ? (
876
- <Legend
877
- variant="scale"
878
- kind="width"
879
- domain={model.edgeDomain}
880
- format={model.formatEdgeValue}
881
- title={model.edgeMetricLabel}
882
- className="pointer-events-auto"
883
- />
1085
+ <div className="flex items-start gap-3">
1086
+ <Legend
1087
+ variant="scale"
1088
+ kind="width"
1089
+ domain={model.edgeDomain}
1090
+ format={model.formatEdgeValue}
1091
+ title={model.edgeMetricLabel}
1092
+ className="pointer-events-auto"
1093
+ />
1094
+ {/* Object-centric — RM-066: each type's colour beside its printed code and name. */}
1095
+ {objectCentricView ? (
1096
+ <Legend
1097
+ title={objectCentricLabels.legendTitle}
1098
+ items={objectCentricView.scale.legend
1099
+ .filter((entry) => objectCentricView.graph.objectTypes.includes(entry.activityId))
1100
+ .map((entry) => ({
1101
+ label: `${entry.code} · ${entry.label}`,
1102
+ color: `var(${entry.token})`,
1103
+ }))}
1104
+ className="pointer-events-auto"
1105
+ />
1106
+ ) : null}
1107
+ </div>
884
1108
  ) : (
885
1109
  <span />
886
1110
  )}
@@ -158,3 +158,50 @@ describe("ProcessTransitionEdge — reaches an excluded edge's label pill by dat
158
158
  expect(pill).not.toHaveAttribute("data-selection");
159
159
  });
160
160
  });
161
+
162
+ // #354 — the focused element (this pill) and the named element (React Flow's own edge
163
+ // `<g>`, which `data.ariaLabel` also reaches via the top-level `edge.ariaLabel` React Flow
164
+ // itself reads — see `map-model.ts`) must be the SAME element. `data.ariaLabel` is the
165
+ // channel; this locks that it actually lands on the rendered pill's accessible name,
166
+ // merged with (never replacing) the excluded-state fields #351 already locks above.
167
+ describe("ProcessTransitionEdge — folds data.ariaLabel onto the pill's accessible name (#354)", () => {
168
+ it("uses data.ariaLabel as the pill's accessible name instead of the bare printed label", () => {
169
+ render(
170
+ <ProcessTransitionEdge
171
+ {...makeEdgeProps({
172
+ data: {
173
+ ...BASE_EDGE_DATA,
174
+ ariaLabel: "Transition from a to b, Transitions 12",
175
+ },
176
+ })}
177
+ />,
178
+ );
179
+ expect(
180
+ screen.getByRole("button", { name: "Transition from a to b, Transitions 12" }),
181
+ ).toBeInTheDocument();
182
+ });
183
+
184
+ it("merges data.ariaLabel with the excluded-state ghost frame rather than replacing it", () => {
185
+ render(
186
+ <ProcessTransitionEdge
187
+ {...makeEdgeProps({
188
+ data: {
189
+ ...BASE_EDGE_DATA,
190
+ selectionState: "excluded",
191
+ ariaLabel: "Transition from a to b, Transitions 12, excluded",
192
+ },
193
+ })}
194
+ />,
195
+ );
196
+ const pill = screen.getByRole("button", {
197
+ name: "Transition from a to b, Transitions 12, excluded",
198
+ });
199
+ expect(pill.className).toMatch(/\bborder-dashed\b/);
200
+ expect(pill).toHaveAttribute("data-selection", "excluded");
201
+ });
202
+
203
+ it("falls back to the pill's own computed name when data carries no ariaLabel", () => {
204
+ render(<ProcessTransitionEdge {...makeEdgeProps()} />);
205
+ expect(screen.getByRole("button", { name: "12×" })).toBeInTheDocument();
206
+ });
207
+ });
@@ -47,17 +47,67 @@
47
47
  * attribute straight onto the pill's root button — visible, non-text, reachable by a
48
48
  * `[data-selection="excluded"]` selector same as the node and the edge, and never lower
49
49
  * than 4.5:1 because the label text itself is untouched.
50
+ *
51
+ * ## The label pill is also the only reachable place for the transition's name (#354)
52
+ *
53
+ * The same portal-out-of-the-`<g>` fact applies to `aria-label`: React Flow's `EdgeWrapper`
54
+ * puts `edge.ariaLabel` on the outer `<g>`, but that `<g>` is never a tab stop
55
+ * (`process-map.tsx` sets `edgesFocusable={false}` so the pill is the arrow's one stop —
56
+ * see that file's own docblock). `labelProps` carries `data.ariaLabel` (computed once, in
57
+ * `map-model.ts`'s `transitionAriaLabel`, and read here rather than recomputed, so the
58
+ * canvas and the `TableView` twin cannot drift) onto the pill's `aria-label`, merged with
59
+ * the excluded-state fields above rather than replacing them.
50
60
  */
51
- import { useMemo } from "react";
61
+ import { useMemo, type CSSProperties } from "react";
52
62
  import type { EdgeProps } from "@xyflow/react";
63
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
53
64
  import {
54
65
  FlowSelfLoopEdge,
55
66
  FlowWeightedEdge,
67
+ Position,
56
68
  type FlowSelfLoopEdgeData,
57
69
  type FlowWeightedEdgeData,
58
70
  } from "@elabs-ai/components-flow";
59
71
  import { useProcessMapEdgeKeys, useProcessMapHover } from "./process-map-context";
60
72
  import { GHOST_OPACITY, type ProcessMapEdge } from "./map-model";
73
+ import {
74
+ CONFORMANCE_STATE_ENCODING,
75
+ type ConformanceState,
76
+ } from "../conformance-overlay/conformance-state";
77
+ import { useProcessReplayEdgeTokens } from "../process-replay/replay-tokens-context";
78
+
79
+ /**
80
+ * The label pill under a conformance state (RM-062): the state's glyph as a leading
81
+ * `::before` mark (circle / triangle / square — the same shapes as the node marker and the
82
+ * legend) and the tone's fill rung on the pill border. The pill's own `aria-label` already
83
+ * carries the state's word (`ProcessMap` folds it into `data.ariaLabel`), so the glyph is
84
+ * a visible, non-colour channel only. The line style lives on the STROKE, not the pill, so
85
+ * it never fights the excluded state's dashed pill border.
86
+ */
87
+ const CONFORMANCE_LABEL_CLASS: Record<ConformanceState, string> = {
88
+ both: "border-success before:text-success before:content-['●']",
89
+ logOnly: "border-warning before:text-warning before:content-['△']",
90
+ modelOnly: "border-destructive before:text-destructive before:content-['⬚']",
91
+ };
92
+
93
+ /**
94
+ * The stroke under a conformance state: the tone as paint plus the state's dash pattern.
95
+ * Omitted while the edge is selected, so React Flow's selection stroke still wins; a
96
+ * `"both"` edge keeps its own dash (a back edge stays dashed).
97
+ */
98
+ function conformanceStrokeStyle(
99
+ state: ConformanceState | undefined,
100
+ selected: boolean | undefined,
101
+ ): CSSProperties | undefined {
102
+ if (!state || selected) return undefined;
103
+ const encoding = CONFORMANCE_STATE_ENCODING[state];
104
+ return {
105
+ stroke: encoding.colorVar,
106
+ ...(encoding.strokeDasharray
107
+ ? { strokeDasharray: encoding.strokeDasharray, strokeLinecap: "round" }
108
+ : {}),
109
+ };
110
+ }
61
111
 
62
112
  /**
63
113
  * The `scaleGroup` every process-map edge shares, so `computeEdgeWeightScale` min-maxes
@@ -75,6 +125,21 @@ const UNRELATED_OPACITY = 0.25;
75
125
  * opacity (see this file's own docblock, and {@link GHOST_OPACITY}'s, for why). */
76
126
  const EXCLUDED_LABEL_PROPS = { className: "border-dashed", "data-selection": "excluded" } as const;
77
127
 
128
+ /**
129
+ * Object-centric — RM-066. Per-type edges joining the same two activities are drawn side
130
+ * by side: each one's endpoints shift across the flow axis by its slot, so the strokes and
131
+ * their label pills separate instead of stacking. The spread is capped so every endpoint
132
+ * stays on the card's own face.
133
+ */
134
+ const PARALLEL_EDGE_GAP = 40;
135
+ const PARALLEL_EDGE_MAX_SPREAD = 120;
136
+
137
+ function parallelShift(index: number | undefined, count: number | undefined): number {
138
+ if (index === undefined || count === undefined || count < 2) return 0;
139
+ const gap = Math.min(PARALLEL_EDGE_GAP, PARALLEL_EDGE_MAX_SPREAD / (count - 1));
140
+ return (index - (count - 1) / 2) * gap;
141
+ }
142
+
78
143
  /**
79
144
  * Branded process-map transition edge. Register it in
80
145
  * `edgeTypes={{ "process-transition": ProcessTransitionEdge }}`; build edges with
@@ -85,20 +150,65 @@ export function ProcessTransitionEdge(props: EdgeProps<ProcessMapEdge>) {
85
150
  const hover = useProcessMapHover();
86
151
  const onEdgeKey = useProcessMapEdgeKeys();
87
152
  const isExcluded = data?.selectionState === "excluded";
88
- const labelProps = isExcluded ? EXCLUDED_LABEL_PROPS : undefined;
153
+ // The focused element (the label pill's `<button>`) and the named element (React Flow's
154
+ // own edge `<g>`, unreachable — see this file's own docblock) are two different DOM
155
+ // nodes; `data.ariaLabel` (`transitionAriaLabel`, computed once in `map-model.ts`) is the
156
+ // channel that gets the same accessible name onto the one a screen-reader user actually
157
+ // lands on (#354). Merged with, never replacing, the excluded-state fields.
158
+ const conformance = data?.conformance;
159
+ const selected = props.selected;
160
+ const labelProps = useMemo(() => {
161
+ if (!isExcluded && !data?.ariaLabel && !conformance) return undefined;
162
+ return {
163
+ ...(isExcluded ? EXCLUDED_LABEL_PROPS : undefined),
164
+ ...(data?.ariaLabel ? { "aria-label": data.ariaLabel } : undefined),
165
+ // Additive to the excluded fields (RM-062): the dashed excluded border stays, the
166
+ // conformance glyph and tone join it. The tone border yields to the selection ring.
167
+ ...(conformance
168
+ ? {
169
+ "data-conformance": conformance,
170
+ className: cn(
171
+ isExcluded && EXCLUDED_LABEL_PROPS.className,
172
+ CONFORMANCE_LABEL_CLASS[conformance],
173
+ selected && "border-ring",
174
+ ),
175
+ }
176
+ : undefined),
177
+ };
178
+ }, [isExcluded, data?.ariaLabel, conformance, selected]);
179
+ const strokeStyle = conformanceStrokeStyle(conformance, selected);
180
+ const edgeStyle = strokeStyle ? { ...props.style, ...strokeStyle } : props.style;
181
+ // RM-065: tokens from an enclosing `ProcessReplay`. `undefined` outside one, and then the
182
+ // key is not added at all, so a map with no replay builds exactly the data it did before.
183
+ const replayTokens = useProcessReplayEdgeTokens(props.id);
184
+ // Object-centric — RM-066: a per-type edge takes its type's chart stroke (on the edge
185
+ // object's `style`), never the value ramp, and sits in its own parallel slot.
186
+ const isObjectTyped = data?.objectType !== undefined;
187
+ const shift = parallelShift(data?.parallelIndex, data?.parallelCount);
188
+ const acrossX = props.targetPosition === Position.Top || props.targetPosition === Position.Bottom;
189
+ const placed = shift
190
+ ? {
191
+ sourceX: props.sourceX + (acrossX ? shift : 0),
192
+ targetX: props.targetX + (acrossX ? shift : 0),
193
+ sourceY: props.sourceY + (acrossX ? 0 : shift),
194
+ targetY: props.targetY + (acrossX ? 0 : shift),
195
+ }
196
+ : undefined;
89
197
 
90
198
  const weightedData = useMemo<FlowWeightedEdgeData>(
91
199
  () => ({
92
200
  weight: data?.weight,
93
201
  scaleGroup: PROCESS_MAP_EDGE_SCALE_GROUP,
94
- value: data?.value,
95
- valueDomain: data?.valueDomain,
202
+ value: isObjectTyped ? undefined : data?.value,
203
+ valueDomain: isObjectTyped ? undefined : data?.valueDomain,
96
204
  label: data?.label,
97
205
  secondaryLabel: data?.secondaryLabel,
98
206
  variant: data?.isBackEdge ? "back" : "forward",
99
207
  labelProps,
208
+ ...(replayTokens ? { tokens: replayTokens } : undefined),
100
209
  }),
101
210
  [
211
+ isObjectTyped,
102
212
  data?.weight,
103
213
  data?.value,
104
214
  data?.valueDomain,
@@ -106,6 +216,7 @@ export function ProcessTransitionEdge(props: EdgeProps<ProcessMapEdge>) {
106
216
  data?.secondaryLabel,
107
217
  data?.isBackEdge,
108
218
  labelProps,
219
+ replayTokens,
109
220
  ],
110
221
  );
111
222
 
@@ -116,8 +227,9 @@ export function ProcessTransitionEdge(props: EdgeProps<ProcessMapEdge>) {
116
227
  label: data?.label,
117
228
  secondaryLabel: data?.secondaryLabel,
118
229
  labelProps,
230
+ ...(replayTokens ? { tokens: replayTokens } : undefined),
119
231
  }),
120
- [data?.weight, data?.label, data?.secondaryLabel, labelProps],
232
+ [data?.weight, data?.label, data?.secondaryLabel, labelProps, replayTokens],
121
233
  );
122
234
 
123
235
  const opacity = isExcluded
@@ -132,6 +244,9 @@ export function ProcessTransitionEdge(props: EdgeProps<ProcessMapEdge>) {
132
244
  data-shape={data?.isSelfLoop ? "self-loop" : data?.isBackEdge ? "back" : "forward"}
133
245
  data-selection={data?.selectionState}
134
246
  data-incident={hover.incidentEdgeIds.has(props.id) ? "true" : undefined}
247
+ data-conformance={conformance}
248
+ data-dash={conformance ? CONFORMANCE_STATE_ENCODING[conformance].dash : undefined}
249
+ data-object-type={data?.objectType?.type}
135
250
  className="transition-opacity duration-fast ease-standard motion-reduce:transition-none"
136
251
  style={{ opacity }}
137
252
  // The label pill is portalled out of this `<g>` by `EdgeLabelRenderer`, so it has no
@@ -142,9 +257,21 @@ export function ProcessTransitionEdge(props: EdgeProps<ProcessMapEdge>) {
142
257
  onKeyDown={(event) => onEdgeKey(props.id, event)}
143
258
  >
144
259
  {data?.isSelfLoop ? (
145
- <FlowSelfLoopEdge {...props} type="self-loop" data={selfLoopData} />
260
+ <FlowSelfLoopEdge
261
+ {...props}
262
+ {...placed}
263
+ style={edgeStyle}
264
+ type="self-loop"
265
+ data={selfLoopData}
266
+ />
146
267
  ) : (
147
- <FlowWeightedEdge {...props} type="weighted" data={weightedData} />
268
+ <FlowWeightedEdge
269
+ {...props}
270
+ {...placed}
271
+ style={edgeStyle}
272
+ type="weighted"
273
+ data={weightedData}
274
+ />
148
275
  )}
149
276
  </g>
150
277
  );