@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,107 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ConformanceOverlay — the process map read against a reference model (RM-062).
5
+ *
6
+ * A thin composition: `ProcessMap` with its `conformance` prop always set, plus a
7
+ * {@link ConformanceLegend} explaining the three states. It authors no node or edge
8
+ * rendering of its own — the tone, glyph and dash per element come from `ProcessMap`'s own
9
+ * activity node and transition edge, so the overlay and the plain map can never drift
10
+ * (`pnpm check --rule process-reuse`).
11
+ *
12
+ * Every state reaches the reader on three visual channels (tone, glyph, line style) and as
13
+ * text (the accessible name on the canvas, a Conformance column in `tableView`), so the
14
+ * overlay never relies on colour alone (analysis §5.4, WCAG 1.4.1).
15
+ */
16
+ import { forwardRef, type HTMLAttributes } from "react";
17
+ import { cn } from "@elabs-ai/components-ui/lib/cn";
18
+ import type { FlowLayoutDirection } from "@elabs-ai/components-flow";
19
+ import type { ConformanceResult } from "../core/conformance";
20
+ import type { ProcessGraph } from "../core/types";
21
+ import type { ProcessMetricSpec, ProcessSelection } from "../process-map/map-model";
22
+ import { ProcessMap, type ProcessMapProps } from "../process-map/process-map";
23
+ import { ConformanceLegend } from "./conformance-legend";
24
+ import type { ConformanceStateLabels } from "./conformance-state";
25
+
26
+ /** The metric the overlay paints when none is given: case frequency on both marks. */
27
+ export const CONFORMANCE_OVERLAY_DEFAULT_METRIC: ProcessMetricSpec = Object.freeze({
28
+ node: "absolute_case",
29
+ edge: "absolute",
30
+ }) as ProcessMetricSpec;
31
+
32
+ /** Props for {@link ConformanceOverlay}. `onSelect` shadows the DOM handler, so it is omitted. */
33
+ export interface ConformanceOverlayProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
34
+ /** The discovered graph to overlay. */
35
+ graph: ProcessGraph;
36
+ /** The replay result (`tokenReplay`) against the reference model. */
37
+ conformance: ConformanceResult;
38
+ /** Which readings the nodes and edges print. @default case frequency */
39
+ metric?: ProcessMetricSpec;
40
+ /** @default "TB" */
41
+ direction?: FlowLayoutDirection;
42
+ /** Controlled selection, passed straight to `ProcessMap`. */
43
+ selection?: ProcessSelection | null;
44
+ onSelect?: ProcessMapProps["onSelect"];
45
+ onFilterIntent?: ProcessMapProps["onFilterIntent"];
46
+ /** Render the accessible table twin instead of the canvas. @default false */
47
+ tableView?: boolean;
48
+ /** No graph or replay yet. Renders the map's loading panel and hides the legend. */
49
+ loading?: boolean;
50
+ /** Override the legend's words. */
51
+ labels?: Partial<ConformanceStateLabels>;
52
+ }
53
+
54
+ /**
55
+ * The conformance overlay.
56
+ *
57
+ * @example
58
+ * ```tsx
59
+ * const model = useMemo(() => liftHappyPath(happyPath), [happyPath]);
60
+ * const conformance = useMemo(() => tokenReplay(log, model), [log, model]);
61
+ * <ConformanceOverlay graph={graph} conformance={conformance} />
62
+ * ```
63
+ */
64
+ export const ConformanceOverlay = forwardRef<HTMLDivElement, ConformanceOverlayProps>(
65
+ function ConformanceOverlay(
66
+ {
67
+ graph,
68
+ conformance,
69
+ metric = CONFORMANCE_OVERLAY_DEFAULT_METRIC,
70
+ direction,
71
+ selection,
72
+ onSelect,
73
+ onFilterIntent,
74
+ tableView = false,
75
+ loading = false,
76
+ labels,
77
+ className,
78
+ ...props
79
+ },
80
+ ref,
81
+ ) {
82
+ return (
83
+ <div
84
+ ref={ref}
85
+ data-slot="conformance-overlay"
86
+ data-view={tableView ? "table" : "canvas"}
87
+ className={cn("flex size-full min-h-0 flex-col gap-3", className)}
88
+ {...props}
89
+ >
90
+ {loading ? null : <ConformanceLegend labels={labels} className="self-start" />}
91
+ <div data-slot="conformance-overlay-map" className="min-h-64 flex-1">
92
+ <ProcessMap
93
+ graph={graph}
94
+ metric={metric}
95
+ conformance={conformance}
96
+ direction={direction}
97
+ selection={selection}
98
+ onSelect={onSelect}
99
+ onFilterIntent={onFilterIntent}
100
+ tableView={tableView}
101
+ loading={loading}
102
+ />
103
+ </div>
104
+ </div>
105
+ );
106
+ },
107
+ );
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { tokenReplay } from "../core/token-replay";
3
+ import { liftHappyPath } from "../core/reference-model";
4
+ import { discoverGraph } from "../core/discover-graph";
5
+ import { buildProcessMapModel } from "../process-map/map-model";
6
+ import { CONFORMANCE_FIXTURE_LOG, CONFORMANCE_FIXTURE_PATH } from "./conformance-fixture";
7
+ import {
8
+ activityConformance,
9
+ CONFORMANCE_STATE_ENCODING,
10
+ CONFORMANCE_STATES,
11
+ resolveConformanceStates,
12
+ transitionConformance,
13
+ withActivityConformance,
14
+ withTransitionConformance,
15
+ } from "./conformance-state";
16
+
17
+ const conformance = tokenReplay(CONFORMANCE_FIXTURE_LOG, liftHappyPath(CONFORMANCE_FIXTURE_PATH));
18
+ const states = resolveConformanceStates(conformance);
19
+
20
+ describe("resolveConformanceStates — the RM-062 acceptance fixture", () => {
21
+ it("marks the deliberately skipped step modelOnly", () => {
22
+ expect(activityConformance(states, "Check credit")).toBe("modelOnly");
23
+ });
24
+
25
+ it("marks an activity outside the model logOnly and conforming steps both", () => {
26
+ expect(activityConformance(states, "Escalate")).toBe("logOnly");
27
+ expect(activityConformance(states, "Register")).toBe("both");
28
+ expect(activityConformance(states, "Pay")).toBe("both");
29
+ // An `incomplete` deviation does not move a modelled activity out of the model.
30
+ expect(activityConformance(states, "Approve")).toBe("both");
31
+ });
32
+
33
+ it("marks the edges where the log diverged logOnly", () => {
34
+ // The skip: cases jumped straight from Register to Approve.
35
+ expect(transitionConformance(states, "Register", "Approve")).toBe("logOnly");
36
+ // Into and out of the undesired activity.
37
+ expect(transitionConformance(states, "Check credit", "Escalate")).toBe("logOnly");
38
+ expect(transitionConformance(states, "Escalate", "Approve")).toBe("logOnly");
39
+ // The prescribed arcs.
40
+ expect(transitionConformance(states, "Register", "Check credit")).toBe("both");
41
+ expect(transitionConformance(states, "Approve", "Pay")).toBe("both");
42
+ });
43
+ });
44
+
45
+ describe("CONFORMANCE_STATE_ENCODING — never colour alone", () => {
46
+ it("gives each state a distinct tone, glyph and dash", () => {
47
+ for (const channel of ["tone", "glyph", "dash"] as const) {
48
+ const values = CONFORMANCE_STATES.map((state) => CONFORMANCE_STATE_ENCODING[state][channel]);
49
+ expect(new Set(values).size).toBe(CONFORMANCE_STATES.length);
50
+ }
51
+ });
52
+ });
53
+
54
+ describe("withActivityConformance / withTransitionConformance", () => {
55
+ const model = buildProcessMapModel({
56
+ graph: discoverGraph(CONFORMANCE_FIXTURE_LOG),
57
+ metric: { node: "absolute_case", edge: "absolute" },
58
+ });
59
+
60
+ it("adds data-conformance beside data-selection and names the state", () => {
61
+ const node = model.nodes.find((n) => n.id === "Check credit")!;
62
+ const decorated = withActivityConformance(node, states);
63
+ expect(decorated.data.conformance).toBe("modelOnly");
64
+ expect(decorated.domAttributes).toMatchObject({
65
+ "data-selection": node.data.selectionState,
66
+ "data-conformance": "modelOnly",
67
+ });
68
+ expect(decorated.ariaLabel).toMatch(/Model only/);
69
+ expect(decorated.ariaLabel?.startsWith(node.ariaLabel ?? "")).toBe(true);
70
+ });
71
+
72
+ it("folds the state into the edge's label-pill name as well as the edge's own", () => {
73
+ const edge = model.edges.find((e) => e.source === "Register" && e.target === "Approve")!;
74
+ const decorated = withTransitionConformance(edge, states);
75
+ expect(decorated.data?.conformance).toBe("logOnly");
76
+ expect(decorated.ariaLabel).toMatch(/Log only/);
77
+ expect(decorated.data?.ariaLabel).toBe(decorated.ariaLabel);
78
+ });
79
+ });
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Conformance states on the process map — RM-062.
3
+ *
4
+ * Reads a `ConformanceResult` (RM-061's `tokenReplay`) as the three-colour overlay the
5
+ * analysis names (§2): every activity and every observed transition is in the log AND the
6
+ * model (`"both"`), in the log only (`"logOnly"`), or expected by the model but missing
7
+ * from the log (`"modelOnly"`).
8
+ *
9
+ * ## How the state is derived — without the reference model in hand
10
+ *
11
+ * A `ConformanceResult` does not carry the model it was replayed against, and it does not
12
+ * need to: token replay charges an `undesired` deviation to EVERY observed event whose
13
+ * activity the model does not contain, so an observed activity is outside the model
14
+ * exactly when it carries an `undesired` deviation. The rules, in precedence order:
15
+ *
16
+ * | element | state | when |
17
+ * | ---------- | ------------ | ------------------------------------------------------------ |
18
+ * | activity | `logOnly` | at least one `undesired` deviation names it |
19
+ * | activity | `modelOnly` | at least one `skipped` deviation names it (cases jumped it) |
20
+ * | activity | `both` | otherwise |
21
+ * | transition | `logOnly` | deviations are charged to it, or either endpoint is `logOnly` |
22
+ * | transition | `both` | otherwise |
23
+ *
24
+ * A transition is never `modelOnly`: every edge on the map was observed. A model step
25
+ * that NO case ever reached is not on the graph at all, so it cannot be painted here —
26
+ * `ViolationList` still counts the cases that skipped it.
27
+ *
28
+ * ## Never colour alone (WCAG 1.4.1, analysis §5.4)
29
+ *
30
+ * Each state pairs a status tone with a distinct glyph AND a distinct line style —
31
+ * solid circle / hollow triangle / dashed square — so the three read apart in greyscale.
32
+ * {@link CONFORMANCE_STATE_ENCODING} is the one table every surface reads it from.
33
+ */
34
+ import { Circle, SquareDashed, Triangle, type LucideIcon } from "lucide-react";
35
+ import type { ConformanceResult } from "../core/conformance";
36
+ import { EDGE_KEY_SEPARATOR } from "../core/discover-graph";
37
+ import type { ProcessMapEdge, ProcessMapNode } from "../process-map/map-model";
38
+
39
+ /** Where an element sits relative to the reference model. */
40
+ export type ConformanceState = "both" | "logOnly" | "modelOnly";
41
+
42
+ /** Every {@link ConformanceState}, in legend order. */
43
+ export const CONFORMANCE_STATES: readonly ConformanceState[] = ["both", "logOnly", "modelOnly"];
44
+
45
+ /** The status tone a state is painted with. */
46
+ export type ConformanceTone = "success" | "warning" | "destructive";
47
+
48
+ /** The line style a state is drawn with — the second non-colour channel. */
49
+ export type ConformanceDash = "solid" | "dotted" | "dashed";
50
+
51
+ /** Everything that encodes one state, colour included. */
52
+ export interface ConformanceStateEncoding {
53
+ tone: ConformanceTone;
54
+ /** Stable glyph name, mirrored to `data-glyph` so a test can assert distinctness. */
55
+ glyph: "circle" | "triangle" | "square";
56
+ /** Lucide glyph drawn for the state. */
57
+ icon: LucideIcon;
58
+ /** Whether the glyph is filled (`true`) or an outline. */
59
+ filled: boolean;
60
+ dash: ConformanceDash;
61
+ /** SVG `stroke-dasharray` for a transition stroke; `undefined` keeps the edge's own. */
62
+ strokeDasharray: string | undefined;
63
+ /** Mark (fill-rung) utility for a glyph or swatch. */
64
+ markClass: string;
65
+ /** Ink (`-text` rung) utility for coloured text. */
66
+ textClass: string;
67
+ /** Border utilities: the tone's fill rung plus the line style. */
68
+ borderClass: string;
69
+ /** CSS colour reference for a stroke or a `--border` override. */
70
+ colorVar: string;
71
+ }
72
+
73
+ /** The single source of truth for how each state looks. */
74
+ export const CONFORMANCE_STATE_ENCODING: Readonly<
75
+ Record<ConformanceState, ConformanceStateEncoding>
76
+ > = Object.freeze({
77
+ both: {
78
+ tone: "success",
79
+ glyph: "circle",
80
+ icon: Circle,
81
+ filled: true,
82
+ dash: "solid",
83
+ strokeDasharray: undefined,
84
+ markClass: "text-success fill-success",
85
+ textClass: "text-success-text",
86
+ borderClass: "border-success border-solid",
87
+ colorVar: "var(--success)",
88
+ },
89
+ logOnly: {
90
+ tone: "warning",
91
+ glyph: "triangle",
92
+ icon: Triangle,
93
+ filled: false,
94
+ dash: "dotted",
95
+ strokeDasharray: "2 4",
96
+ markClass: "text-warning",
97
+ textClass: "text-warning-text",
98
+ borderClass: "border-warning border-dotted",
99
+ colorVar: "var(--warning)",
100
+ },
101
+ modelOnly: {
102
+ tone: "destructive",
103
+ glyph: "square",
104
+ icon: SquareDashed,
105
+ filled: false,
106
+ dash: "dashed",
107
+ strokeDasharray: "6 4",
108
+ markClass: "text-destructive",
109
+ textClass: "text-destructive-text",
110
+ borderClass: "border-destructive border-dashed",
111
+ colorVar: "var(--destructive)",
112
+ },
113
+ });
114
+
115
+ /** The words for each state. Override through a component's `labels` to localize. */
116
+ export interface ConformanceStateLabels {
117
+ both: string;
118
+ logOnly: string;
119
+ modelOnly: string;
120
+ /** The table twin's column header and the legend's title. */
121
+ column: string;
122
+ }
123
+
124
+ /** The shipped English words for each state. */
125
+ export const CONFORMANCE_STATE_DEFAULT_LABELS: Readonly<ConformanceStateLabels> = Object.freeze({
126
+ column: "Conformance",
127
+ both: "Conforms — in log and model",
128
+ logOnly: "Log only — not in the model",
129
+ modelOnly: "Model only — skipped in the log",
130
+ });
131
+
132
+ /** Per-element conformance states for one `ConformanceResult`. */
133
+ export interface ConformanceStates {
134
+ /** Keyed by activity id. An activity absent here is `"both"`. */
135
+ activities: ReadonlyMap<string, ConformanceState>;
136
+ /**
137
+ * Keyed `source + EDGE_KEY_SEPARATOR + target` — the id `discoverGraph` and the map give
138
+ * the transition. A transition absent here is resolved by {@link transitionConformance}.
139
+ */
140
+ transitions: ReadonlyMap<string, ConformanceState>;
141
+ }
142
+
143
+ /** Derive activity and transition states from a replay result. See this module's docblock. */
144
+ export function resolveConformanceStates(conformance: ConformanceResult): ConformanceStates {
145
+ const activities = new Map<string, ConformanceState>();
146
+ for (const trace of conformance.traces) {
147
+ for (const deviation of trace.deviations) {
148
+ if (deviation.activity === undefined) continue;
149
+ if (deviation.type === "undesired") activities.set(deviation.activity, "logOnly");
150
+ else if (deviation.type === "skipped" && activities.get(deviation.activity) !== "logOnly") {
151
+ activities.set(deviation.activity, "modelOnly");
152
+ }
153
+ }
154
+ }
155
+ const transitions = new Map<string, ConformanceState>();
156
+ for (const [key, { deviations }] of Object.entries(conformance.perEdge)) {
157
+ if (deviations > 0) transitions.set(key, "logOnly");
158
+ }
159
+ return { activities, transitions };
160
+ }
161
+
162
+ /** The state of one activity. */
163
+ export function activityConformance(states: ConformanceStates, activity: string): ConformanceState {
164
+ return states.activities.get(activity) ?? "both";
165
+ }
166
+
167
+ /** The state of one observed transition. */
168
+ export function transitionConformance(
169
+ states: ConformanceStates,
170
+ source: string,
171
+ target: string,
172
+ ): ConformanceState {
173
+ const charged = states.transitions.get(`${source}${EDGE_KEY_SEPARATOR}${target}`);
174
+ if (charged) return charged;
175
+ if (
176
+ activityConformance(states, source) === "logOnly" ||
177
+ activityConformance(states, target) === "logOnly"
178
+ ) {
179
+ return "logOnly";
180
+ }
181
+ return "both";
182
+ }
183
+
184
+ /**
185
+ * A map node with its conformance state folded in: `data.conformance` for the glyph and
186
+ * dash, `data-conformance` on React Flow's own node element (additive to
187
+ * `data-selection`), and the state's word appended to the accessible name — so the state
188
+ * reaches assistive technology as text, never as colour.
189
+ */
190
+ export function withActivityConformance(
191
+ node: ProcessMapNode,
192
+ states: ConformanceStates,
193
+ labels: ConformanceStateLabels = CONFORMANCE_STATE_DEFAULT_LABELS,
194
+ ): ProcessMapNode {
195
+ const state = activityConformance(states, node.id);
196
+ return {
197
+ ...node,
198
+ data: { ...node.data, conformance: state },
199
+ ariaLabel: node.ariaLabel ? `${node.ariaLabel}, ${labels[state]}` : labels[state],
200
+ domAttributes: {
201
+ ...node.domAttributes,
202
+ "data-conformance": state,
203
+ } as ProcessMapNode["domAttributes"],
204
+ };
205
+ }
206
+
207
+ /** A map edge with its conformance state folded in. See {@link withActivityConformance}. */
208
+ export function withTransitionConformance(
209
+ edge: ProcessMapEdge,
210
+ states: ConformanceStates,
211
+ labels: ConformanceStateLabels = CONFORMANCE_STATE_DEFAULT_LABELS,
212
+ ): ProcessMapEdge {
213
+ const state = transitionConformance(states, edge.source, edge.target);
214
+ const ariaLabel = edge.ariaLabel ? `${edge.ariaLabel}, ${labels[state]}` : labels[state];
215
+ return {
216
+ ...edge,
217
+ ariaLabel,
218
+ data: edge.data ? { ...edge.data, conformance: state, ariaLabel } : edge.data,
219
+ };
220
+ }
@@ -0,0 +1,4 @@
1
+ /** ConformanceOverlay (RM-062) — public surface. */
2
+ export * from "./conformance-overlay";
3
+ export * from "./conformance-legend";
4
+ export * from "./conformance-state";
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ ACTIVITY_COLOR_SLOTS,
5
+ ACTIVITY_OTHER_TOKEN,
6
+ activityColorScale,
7
+ } from "./activity-color-scale";
8
+ import { discoverGraph } from "./discover-graph";
9
+ import { generateSyntheticLog } from "./fixtures/synthetic-log";
10
+ import type { ActivityStats, ProcessGraph } from "./types";
11
+
12
+ const ZERO = { min: 0, max: 0, mean: 0, median: 0, p90: 0, sum: 0, trimmedMean: 0 };
13
+
14
+ function graphOf(entries: [id: string, cases: number][]): ProcessGraph {
15
+ const activities: ActivityStats[] = entries.map(([id, cases]) => ({
16
+ id,
17
+ label: id,
18
+ instances: cases,
19
+ cases,
20
+ isStart: false,
21
+ isEnd: false,
22
+ duration: ZERO,
23
+ }));
24
+ return {
25
+ activities,
26
+ transitions: [],
27
+ startActivities: {},
28
+ endActivities: {},
29
+ totals: { cases: 0, events: 0, variants: 0 },
30
+ };
31
+ }
32
+
33
+ describe("activityColorScale", () => {
34
+ it("ranks by cases descending and assigns --chart-1.. in rank order", () => {
35
+ const scale = activityColorScale(
36
+ graphOf([
37
+ ["Low", 1],
38
+ ["High", 9],
39
+ ["Mid", 5],
40
+ ]),
41
+ );
42
+ expect(scale.legend.map((e) => [e.activityId, e.token])).toEqual([
43
+ ["High", "--chart-1"],
44
+ ["Mid", "--chart-2"],
45
+ ["Low", "--chart-3"],
46
+ ]);
47
+ expect(scale.colorFor("Mid")).toEqual({ token: "--chart-2" });
48
+ });
49
+
50
+ it("breaks ties by id, independent of input order", () => {
51
+ const a = activityColorScale(
52
+ graphOf([
53
+ ["B", 3],
54
+ ["A", 3],
55
+ ["C", 3],
56
+ ]),
57
+ );
58
+ const b = activityColorScale(
59
+ graphOf([
60
+ ["C", 3],
61
+ ["B", 3],
62
+ ["A", 3],
63
+ ]),
64
+ );
65
+ expect(a.legend).toEqual(b.legend);
66
+ expect(a.legend.map((e) => e.activityId)).toEqual(["A", "B", "C"]);
67
+ });
68
+
69
+ it("gives the top eleven distinct tokens and everything else the hatched other bucket", () => {
70
+ const entries = Array.from(
71
+ { length: 15 },
72
+ (_, i) => [`Activity ${String(i).padStart(2, "0")}`, 100 - i] as [string, number],
73
+ );
74
+ const scale = activityColorScale(graphOf(entries));
75
+ const tokens = scale.legend.slice(0, ACTIVITY_COLOR_SLOTS).map((e) => e.token);
76
+ expect(new Set(tokens).size).toBe(ACTIVITY_COLOR_SLOTS);
77
+ expect(tokens).not.toContain(ACTIVITY_OTHER_TOKEN);
78
+ for (const entry of scale.legend.slice(ACTIVITY_COLOR_SLOTS)) {
79
+ expect(entry.token).toBe(ACTIVITY_OTHER_TOKEN);
80
+ expect(entry.pattern).toBe("other");
81
+ }
82
+ expect(scale.colorFor("never seen")).toEqual({ token: ACTIVITY_OTHER_TOKEN, pattern: "other" });
83
+ });
84
+
85
+ it("derives two-character codes unique within the scale", () => {
86
+ const scale = activityColorScale(
87
+ graphOf([
88
+ ["Create Order", 5],
89
+ ["Cancel Order", 4],
90
+ ["Approve", 3],
91
+ ["Approve Credit", 2],
92
+ ]),
93
+ );
94
+ const codes = scale.legend.map((e) => e.code);
95
+ expect(codes[0]).toBe("CO");
96
+ expect(new Set(codes).size).toBe(codes.length);
97
+ for (const code of codes) expect(code).toHaveLength(2);
98
+ expect(scale.codeFor("Approve")).toBe(scale.legend[2]?.code);
99
+ });
100
+
101
+ it("is deterministic on a real discovered graph", () => {
102
+ const log = generateSyntheticLog({ cases: 120, seed: 7 });
103
+ expect(activityColorScale(discoverGraph(log)).legend).toEqual(
104
+ activityColorScale(discoverGraph(log)).legend,
105
+ );
106
+ });
107
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Activity colour scale — RM-054.
3
+ *
4
+ * One activity, one colour, across every process view. `ProcessMap` paints the colour as
5
+ * a small accent mark on the activity node; `VariantExplorer` paints it on the sequence
6
+ * chips. Both read the SAME scale instance built from one graph, so "Create Order" is the
7
+ * same swatch in the map and in the variant list.
8
+ *
9
+ * ## The colour budget
10
+ *
11
+ * The chart palette ships twelve series tokens (`--chart-1` … `--chart-12`). The eleven
12
+ * most frequent activities (ranked by the number of cases they occur in) take
13
+ * `--chart-1` … `--chart-11`; every remaining activity shares `--chart-12` and is flagged
14
+ * `pattern: "other"`, which the views render as a hatch so "other" never reads as a
15
+ * twelfth distinct activity. Colour is never the only channel: every view that paints a
16
+ * swatch also prints the activity label (or its two-letter {@link ActivityColorScale.codeFor}
17
+ * code) as text.
18
+ *
19
+ * Pure and framework-free: returns token NAMES, never resolved colours, so a theme switch
20
+ * re-inks every swatch with no recomputation.
21
+ */
22
+ import type { ProcessGraph } from "./types";
23
+
24
+ /** How many activities get a distinct palette slot before the rest share "other". */
25
+ export const ACTIVITY_COLOR_SLOTS = 11;
26
+
27
+ /** The token every activity outside the top {@link ACTIVITY_COLOR_SLOTS} shares. */
28
+ export const ACTIVITY_OTHER_TOKEN = "--chart-12";
29
+
30
+ /** The colour one activity is painted with. */
31
+ export interface ActivityColor {
32
+ /** A CSS custom-property NAME, e.g. `"--chart-3"`. Paint with `var(${token})`. */
33
+ token: string;
34
+ /** Present for the shared "other" bucket — render the hatch, not a flat swatch. */
35
+ pattern?: "other";
36
+ }
37
+
38
+ /** One legend entry, in rank order. */
39
+ export interface ActivityColorLegendEntry extends ActivityColor {
40
+ activityId: string;
41
+ label: string;
42
+ /** A two-character code unique within the scale, for abbreviated ("DNA strip") chips. */
43
+ code: string;
44
+ }
45
+
46
+ /** The shared activity→colour mapping. */
47
+ export interface ActivityColorScale {
48
+ /** The colour for an activity. An id the graph never contained is "other". */
49
+ colorFor(activityId: string): ActivityColor;
50
+ /** The two-character code for an activity; derived from the id when it is unknown. */
51
+ codeFor(activityId: string): string;
52
+ /** The activity's display label; the id itself when it is unknown. */
53
+ labelFor(activityId: string): string;
54
+ /** Every activity in the graph, ranked by case count descending, ties by id. */
55
+ legend: ActivityColorLegendEntry[];
56
+ }
57
+
58
+ const OTHER: ActivityColor = Object.freeze({ token: ACTIVITY_OTHER_TOKEN, pattern: "other" });
59
+
60
+ /** Upper-cased letters and digits of a label — the material codes are built from. */
61
+ function alphanumerics(label: string): string {
62
+ return label.replace(/[^\p{L}\p{N}]/gu, "").toUpperCase();
63
+ }
64
+
65
+ /**
66
+ * Candidate two-character codes for a label, most readable first: initials of the first
67
+ * two words ("Create Order" → "CO"), the first two letters ("Approve" → "AP"), then the
68
+ * first letter paired with every later letter, then the first letter plus a digit.
69
+ */
70
+ function codeCandidates(label: string): string[] {
71
+ const out: string[] = [];
72
+ const words = label.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
73
+ const first = words[0];
74
+ const second = words[1];
75
+ if (first && second) out.push(`${first[0]}${second[0]}`.toUpperCase());
76
+ const letters = alphanumerics(label);
77
+ const head = letters[0] ?? "?";
78
+ if (letters.length >= 2) out.push(letters.slice(0, 2));
79
+ for (let i = 2; i < letters.length; i += 1) out.push(`${head}${letters[i]}`);
80
+ for (let digit = 1; digit <= 9; digit += 1) out.push(`${head}${digit}`);
81
+ return out;
82
+ }
83
+
84
+ /** A code for a label, avoiding every code already in `taken`. */
85
+ function uniqueCode(label: string, taken: Set<string>): string {
86
+ for (const candidate of codeCandidates(label)) {
87
+ if (!taken.has(candidate)) return candidate;
88
+ }
89
+ // Pathological: more than ~40 activities sharing one first letter. Fall back to a
90
+ // base-36 counter so the code is still two characters and still unique.
91
+ for (let n = 0; n < 36 * 36; n += 1) {
92
+ const candidate = n.toString(36).toUpperCase().padStart(2, "0");
93
+ if (!taken.has(candidate)) return candidate;
94
+ }
95
+ return (alphanumerics(label).slice(0, 2) || "??").padEnd(2, "?");
96
+ }
97
+
98
+ /**
99
+ * Build the shared colour scale for a graph.
100
+ *
101
+ * Deterministic: the same graph always yields the same assignment, regardless of the
102
+ * order `graph.activities` arrives in. Build it from the FULL (unfiltered, unabstracted)
103
+ * graph so colours stay put while a reader filters or abstracts.
104
+ */
105
+ export function activityColorScale(graph: ProcessGraph): ActivityColorScale {
106
+ const ranked = [...graph.activities].sort(
107
+ (a, b) => b.cases - a.cases || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0),
108
+ );
109
+
110
+ const colors = new Map<string, ActivityColor>();
111
+ const codes = new Map<string, string>();
112
+ const labels = new Map<string, string>();
113
+ const taken = new Set<string>();
114
+ const legend: ActivityColorLegendEntry[] = ranked.map((activity, index) => {
115
+ const color: ActivityColor =
116
+ index < ACTIVITY_COLOR_SLOTS ? { token: `--chart-${index + 1}` } : OTHER;
117
+ const label = activity.label || activity.id;
118
+ const code = uniqueCode(label, taken);
119
+ taken.add(code);
120
+ colors.set(activity.id, color);
121
+ codes.set(activity.id, code);
122
+ labels.set(activity.id, label);
123
+ return { activityId: activity.id, label, code, ...color };
124
+ });
125
+
126
+ return {
127
+ colorFor: (activityId) => colors.get(activityId) ?? OTHER,
128
+ codeFor: (activityId) =>
129
+ codes.get(activityId) ?? (alphanumerics(activityId).slice(0, 2) || "??").padEnd(2, "?"),
130
+ labelFor: (activityId) => labels.get(activityId) ?? activityId,
131
+ legend,
132
+ };
133
+ }