@elabs-ai/components-process 4.1.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/dist/core/index.d.ts +801 -3
- package/dist/core/index.js +1334 -0
- package/dist/core/index.js.map +1 -1
- package/dist/index.d.ts +1889 -34
- package/dist/index.js +5512 -196
- package/dist/index.js.map +1 -1
- package/dist/test/index.d.ts +223 -5
- package/dist/test/index.js +346 -191
- package/dist/test/index.js.map +1 -1
- package/package.json +14 -13
- package/src/__contract__/case-table.contract.test.tsx +49 -0
- package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
- package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
- package/src/__contract__/process-kpi-strip.contract.test.tsx +49 -0
- package/src/__contract__/violation-list.contract.test.tsx +49 -0
- package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
- package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
- package/src/abstraction-controls/abstraction-controls.tsx +198 -5
- package/src/case-table/case-table.stories.tsx +89 -0
- package/src/case-table/case-table.test.tsx +148 -0
- package/src/case-table/case-table.tsx +144 -0
- package/src/case-table/columns.ts +116 -0
- package/src/case-table/index.ts +11 -0
- package/src/case-timeline/case-timeline-model.test.ts +72 -0
- package/src/case-timeline/case-timeline-model.ts +112 -0
- package/src/case-timeline/case-timeline.stories.tsx +94 -0
- package/src/case-timeline/case-timeline.test.tsx +51 -0
- package/src/case-timeline/case-timeline.tsx +109 -0
- package/src/case-timeline/index.ts +9 -0
- package/src/conformance-overlay/conformance-fixture.ts +59 -0
- package/src/conformance-overlay/conformance-legend.tsx +109 -0
- package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
- package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
- package/src/conformance-overlay/conformance-overlay.tsx +107 -0
- package/src/conformance-overlay/conformance-state.test.ts +79 -0
- package/src/conformance-overlay/conformance-state.ts +220 -0
- package/src/conformance-overlay/index.ts +4 -0
- package/src/core/activity-color-scale.test.ts +107 -0
- package/src/core/activity-color-scale.ts +133 -0
- package/src/core/adapters/ocel.test.ts +112 -0
- package/src/core/adapters/ocel.ts +359 -0
- package/src/core/adapters/xes.test.ts +293 -0
- package/src/core/adapters/xes.ts +384 -0
- package/src/core/cases-from-log.test.ts +72 -0
- package/src/core/cases-from-log.ts +85 -0
- package/src/core/conformance.test.ts +80 -0
- package/src/core/conformance.ts +91 -0
- package/src/core/diff-graphs.test.ts +151 -0
- package/src/core/diff-graphs.ts +118 -0
- package/src/core/discover-object-centric-graph.test.ts +94 -0
- package/src/core/discover-object-centric-graph.ts +296 -0
- package/src/core/fixtures/ocel-sample.ts +82 -0
- package/src/core/fixtures/sample.xes +68 -0
- package/src/core/index.ts +113 -0
- package/src/core/reference-model.test.ts +43 -0
- package/src/core/reference-model.ts +116 -0
- package/src/core/replay-timeline.test.ts +161 -0
- package/src/core/replay-timeline.ts +260 -0
- package/src/core/segments.test.ts +185 -0
- package/src/core/segments.ts +153 -0
- package/src/core/token-replay.test.ts +218 -0
- package/src/core/token-replay.ts +456 -0
- package/src/core/types.ts +2 -2
- package/src/dotted-chart/compute-dots.test.ts +176 -0
- package/src/dotted-chart/compute-dots.ts +241 -0
- package/src/dotted-chart/dotted-chart-labels.ts +93 -0
- package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
- package/src/dotted-chart/dotted-chart.test.tsx +135 -0
- package/src/dotted-chart/dotted-chart.tsx +841 -0
- package/src/dotted-chart/index.ts +23 -0
- package/src/dotted-chart/use-element-size.ts +33 -0
- package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
- package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
- package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
- package/src/happy-path-editor/happy-path-editor.tsx +239 -0
- package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
- package/src/happy-path-editor/index.ts +4 -0
- package/src/index.ts +51 -1
- package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
- package/src/performance-spectrum/aggregate-segments.ts +174 -0
- package/src/performance-spectrum/index.ts +25 -0
- package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
- package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
- package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
- package/src/performance-spectrum/performance-spectrum.tsx +870 -0
- package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
- package/src/process-compare/compare-kpi-strip.tsx +94 -0
- package/src/process-compare/compare-model.ts +83 -0
- package/src/process-compare/compare-side.tsx +42 -0
- package/src/process-compare/diff-to-graph.ts +104 -0
- package/src/process-compare/index.ts +23 -0
- package/src/process-compare/process-compare.stories.tsx +184 -0
- package/src/process-compare/process-compare.test.tsx +224 -0
- package/src/process-compare/process-compare.tsx +251 -0
- package/src/process-explorer.stories.tsx +1 -1
- package/src/process-filter-bar/index.ts +2 -0
- package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
- package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
- package/src/process-filter-bar/process-filter-bar.tsx +167 -0
- package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
- package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
- package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
- package/src/process-map/activity-accent.ts +25 -0
- package/src/process-map/index.ts +1 -0
- package/src/process-map/map-model.test.ts +16 -0
- package/src/process-map/map-model.ts +323 -1
- package/src/process-map/object-centric-map.test.tsx +132 -0
- package/src/process-map/process-activity-node.tsx +152 -14
- package/src/process-map/process-map-object-centric.stories.tsx +219 -0
- package/src/process-map/process-map.stories.tsx +64 -0
- package/src/process-map/process-map.tsx +240 -16
- package/src/process-map/process-transition-edge.test.tsx +47 -0
- package/src/process-map/process-transition-edge.tsx +134 -7
- package/src/process-map/use-process-layout.ts +30 -9
- package/src/process-replay/congestion-heat.tsx +107 -0
- package/src/process-replay/index.ts +14 -0
- package/src/process-replay/process-replay.stories.tsx +168 -0
- package/src/process-replay/process-replay.test.tsx +170 -0
- package/src/process-replay/process-replay.tsx +285 -0
- package/src/process-replay/replay-controls.tsx +147 -0
- package/src/process-replay/replay-format.ts +83 -0
- package/src/process-replay/replay-tokens-context.ts +30 -0
- package/src/process-replay/use-controllable-value.ts +30 -0
- package/src/templates-process-explorer.stories.tsx +1304 -0
- package/src/test/contract.test.ts +66 -0
- package/src/test/contract.ts +107 -6
- package/src/test/doubles.test.tsx +87 -1
- package/src/test/doubles.tsx +174 -3
- package/src/test/index.ts +25 -1
- package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
- package/src/use-process-explorer/use-process-explorer.ts +34 -2
- package/src/variant-explorer/coverage-bar.tsx +36 -0
- package/src/variant-explorer/index.ts +16 -0
- package/src/variant-explorer/sequence-chips.tsx +103 -0
- package/src/variant-explorer/variant-explorer-model.ts +42 -0
- package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
- package/src/variant-explorer/variant-explorer.test.tsx +302 -0
- package/src/variant-explorer/variant-explorer.tsx +567 -0
- package/src/variant-explorer/variant-row.tsx +137 -0
- package/src/violation-list/index.ts +2 -0
- package/src/violation-list/violation-list.stories.tsx +73 -0
- package/src/violation-list/violation-list.test.tsx +84 -0
- package/src/violation-list/violation-list.tsx +259 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* VariantExplorer — the ranked list of activity sequences (RM-054, issue #203).
|
|
5
|
+
*
|
|
6
|
+
* One row per variant: its activity sequence as colour-keyed chips, how many cases follow
|
|
7
|
+
* it, the share of all cases it covers, and its median end-to-end duration. Multi-select
|
|
8
|
+
* with checkboxes, or let the coverage slider pick "the fewest paths that explain N % of
|
|
9
|
+
* cases" in one move.
|
|
10
|
+
*
|
|
11
|
+
* ## It emits; it never filters
|
|
12
|
+
*
|
|
13
|
+
* `onSelect(ids, mode)` is the whole output. A playbook wires it to
|
|
14
|
+
* `useProcessExplorer().applyIntent({ kind: "variant", ids })` so the map re-derives from
|
|
15
|
+
* the chosen cases; the explorer itself never calls `filterLog`. The list NARROWS rather
|
|
16
|
+
* than ghosts under a filter (decided 2026-09-05): `selectionStates.variants` carries
|
|
17
|
+
* `selected` and only carries `excluded` when a host engine supplies it.
|
|
18
|
+
*
|
|
19
|
+
* ## Shared colours
|
|
20
|
+
*
|
|
21
|
+
* Chips are painted from the SAME `ActivityColorScale` instance `ProcessMap` receives as
|
|
22
|
+
* `colorScale`, through the same `activityAccentStyle`, so an activity is one colour in
|
|
23
|
+
* both views. Colour is never the only channel: every chip prints its label (or two-letter
|
|
24
|
+
* code) and every row has a full accessible name.
|
|
25
|
+
*
|
|
26
|
+
* ## Scale
|
|
27
|
+
*
|
|
28
|
+
* Rows are virtualized with `@tanstack/react-virtual`, so 2 000 variants mount only the
|
|
29
|
+
* rows in view plus a small overscan. Keyboard: the checkboxes form one roving tab stop;
|
|
30
|
+
* Arrow Up/Down, Page Up/Down, Home and End move between rows, Space toggles the focused
|
|
31
|
+
* row, Enter toggles it too (same as a click).
|
|
32
|
+
*/
|
|
33
|
+
import {
|
|
34
|
+
forwardRef,
|
|
35
|
+
useCallback,
|
|
36
|
+
useId,
|
|
37
|
+
useLayoutEffect,
|
|
38
|
+
useMemo,
|
|
39
|
+
useRef,
|
|
40
|
+
useState,
|
|
41
|
+
type HTMLAttributes,
|
|
42
|
+
type KeyboardEvent,
|
|
43
|
+
} from "react";
|
|
44
|
+
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
45
|
+
import {
|
|
46
|
+
Button,
|
|
47
|
+
Slider,
|
|
48
|
+
StatePanel,
|
|
49
|
+
Table,
|
|
50
|
+
TableBody,
|
|
51
|
+
TableCaption,
|
|
52
|
+
TableCell,
|
|
53
|
+
TableHead,
|
|
54
|
+
TableHeader,
|
|
55
|
+
TableRow,
|
|
56
|
+
useLocale,
|
|
57
|
+
} from "@elabs-ai/components-ui";
|
|
58
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
59
|
+
import type { ActivityColorScale } from "../core/activity-color-scale";
|
|
60
|
+
import type { Variant } from "../core/types";
|
|
61
|
+
import {
|
|
62
|
+
formatDurationMs,
|
|
63
|
+
type ProcessSelectionState,
|
|
64
|
+
type ProcessSelectionStates,
|
|
65
|
+
} from "../process-map/map-model";
|
|
66
|
+
import type { FilterIntent } from "../use-process-explorer";
|
|
67
|
+
import {
|
|
68
|
+
fillLabel,
|
|
69
|
+
selectVariantsByCoverage,
|
|
70
|
+
VARIANT_EXPLORER_COLUMNS,
|
|
71
|
+
type VariantExplorerColumn,
|
|
72
|
+
} from "./variant-explorer-model";
|
|
73
|
+
import { VariantRow, type VariantRowText } from "./variant-row";
|
|
74
|
+
|
|
75
|
+
/** Row height in px — fixed, so the virtualizer never has to measure. Matches `h-10`. */
|
|
76
|
+
export const VARIANT_EXPLORER_ROW_HEIGHT = 40;
|
|
77
|
+
|
|
78
|
+
/** How a selection change applies: replace the whole selection, or flip these ids. */
|
|
79
|
+
export type VariantSelectMode = "replace" | "toggle";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Every user-visible string. `{name}` placeholders are filled at render. Override any
|
|
83
|
+
* subset through `labels` to localize.
|
|
84
|
+
*/
|
|
85
|
+
export interface VariantExplorerLabels {
|
|
86
|
+
/** Accessible name of the list. */
|
|
87
|
+
list: string;
|
|
88
|
+
columnVariant: string;
|
|
89
|
+
columnSequence: string;
|
|
90
|
+
columnCases: string;
|
|
91
|
+
columnCoverage: string;
|
|
92
|
+
columnMedianDuration: string;
|
|
93
|
+
columnState: string;
|
|
94
|
+
/** `{rank}`, `{activities}`, `{cases}`, `{coverage}` (a whole percent number). */
|
|
95
|
+
rowSummary: string;
|
|
96
|
+
/** `{activities}` — the sequence joined with commas. */
|
|
97
|
+
sequence: string;
|
|
98
|
+
activitiesOne: string;
|
|
99
|
+
activitiesOther: string;
|
|
100
|
+
casesOne: string;
|
|
101
|
+
casesOther: string;
|
|
102
|
+
selected: string;
|
|
103
|
+
excluded: string;
|
|
104
|
+
coverageTarget: string;
|
|
105
|
+
/** `{percent}`, `{count}`. */
|
|
106
|
+
coverageTargetValue: string;
|
|
107
|
+
filterToSelected: string;
|
|
108
|
+
tableCaption: string;
|
|
109
|
+
loading: string;
|
|
110
|
+
empty: string;
|
|
111
|
+
emptyBody: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The shipped English labels. */
|
|
115
|
+
export const VARIANT_EXPLORER_DEFAULT_LABELS: Readonly<VariantExplorerLabels> = Object.freeze({
|
|
116
|
+
list: "Variants",
|
|
117
|
+
columnVariant: "Variant",
|
|
118
|
+
columnSequence: "Sequence",
|
|
119
|
+
columnCases: "Cases",
|
|
120
|
+
columnCoverage: "Coverage",
|
|
121
|
+
columnMedianDuration: "Median duration",
|
|
122
|
+
columnState: "State",
|
|
123
|
+
rowSummary: "Variant {rank}, {activities}, {cases}, {coverage} percent coverage",
|
|
124
|
+
sequence: "Sequence: {activities}",
|
|
125
|
+
activitiesOne: "{count} activity",
|
|
126
|
+
activitiesOther: "{count} activities",
|
|
127
|
+
casesOne: "{count} case",
|
|
128
|
+
casesOther: "{count} cases",
|
|
129
|
+
selected: "selected",
|
|
130
|
+
excluded: "excluded",
|
|
131
|
+
coverageTarget: "Coverage target",
|
|
132
|
+
coverageTargetValue: "{percent} of cases · {count} variants",
|
|
133
|
+
filterToSelected: "Show only selected variants",
|
|
134
|
+
tableCaption: "Variants — cases, coverage and median duration per activity sequence",
|
|
135
|
+
loading: "Extracting variants…",
|
|
136
|
+
empty: "No variants",
|
|
137
|
+
emptyBody: "No cases match the current filters.",
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/** Props for {@link VariantExplorer}. `onSelect` shadows the DOM handler, so it is omitted. */
|
|
141
|
+
export interface VariantExplorerProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
|
|
142
|
+
/** Variants in `extractVariants` order (count descending). */
|
|
143
|
+
variants: Variant[];
|
|
144
|
+
/** The shared colour scale — hand `ProcessMap` the same instance. */
|
|
145
|
+
colorScale: ActivityColorScale;
|
|
146
|
+
/** Tri-state per variant id; an absent key means `"associated"`. */
|
|
147
|
+
selectionStates?: ProcessSelectionStates;
|
|
148
|
+
/** Fires with the ids to select and how to apply them. The explorer never filters. */
|
|
149
|
+
onSelect: (variantIds: string[], mode: VariantSelectMode) => void;
|
|
150
|
+
/** When given, a button emits `{ kind: "variant", ids }` for the selected variants. */
|
|
151
|
+
onFilterIntent?: (intent: FilterIntent) => void;
|
|
152
|
+
/** Numeric columns to show. @default ["cases", "coverage", "medianDuration"] */
|
|
153
|
+
columns?: VariantExplorerColumn[];
|
|
154
|
+
/**
|
|
155
|
+
* Initial coverage target, `0..1`. When given, a slider selects the smallest prefix of
|
|
156
|
+
* variants whose cumulative share reaches the target (mode `"replace"`).
|
|
157
|
+
*/
|
|
158
|
+
coverageTarget?: number;
|
|
159
|
+
/** "Variant DNA" strip: two-character codes instead of full activity labels. */
|
|
160
|
+
abbreviate?: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* `"swatch"`: each step is one identity block with no text — the densest strip, for a
|
|
163
|
+
* narrow rail. The strip keeps its accessible name and every block its `title`.
|
|
164
|
+
* @default "text"
|
|
165
|
+
*/
|
|
166
|
+
sequenceDisplay?: "text" | "swatch";
|
|
167
|
+
/** Render the accessible table twin instead of the list. @default false */
|
|
168
|
+
tableView?: boolean;
|
|
169
|
+
/** No variants yet. Renders the loading panel rather than an empty list. */
|
|
170
|
+
loading?: boolean;
|
|
171
|
+
/** Override any user-visible string. */
|
|
172
|
+
labels?: Partial<VariantExplorerLabels>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function stateOf(states: ProcessSelectionStates | undefined, id: string): ProcessSelectionState {
|
|
176
|
+
return states?.variants?.[id] ?? "associated";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The variant explorer.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```tsx
|
|
184
|
+
* const scale = useMemo(() => activityColorScale(fullGraph), [fullGraph]);
|
|
185
|
+
* <VariantExplorer
|
|
186
|
+
* variants={explorer.variants}
|
|
187
|
+
* colorScale={scale}
|
|
188
|
+
* selectionStates={explorer.selectionStates}
|
|
189
|
+
* onSelect={(ids) => explorer.applyIntent({ kind: "variant", ids })}
|
|
190
|
+
* />
|
|
191
|
+
* ```
|
|
192
|
+
*/
|
|
193
|
+
export const VariantExplorer = forwardRef<HTMLDivElement, VariantExplorerProps>(
|
|
194
|
+
function VariantExplorer(
|
|
195
|
+
{
|
|
196
|
+
variants,
|
|
197
|
+
colorScale,
|
|
198
|
+
selectionStates,
|
|
199
|
+
onSelect,
|
|
200
|
+
onFilterIntent,
|
|
201
|
+
columns = VARIANT_EXPLORER_COLUMNS as VariantExplorerColumn[],
|
|
202
|
+
coverageTarget,
|
|
203
|
+
abbreviate = false,
|
|
204
|
+
sequenceDisplay = "text",
|
|
205
|
+
tableView = false,
|
|
206
|
+
loading = false,
|
|
207
|
+
labels: labelOverrides,
|
|
208
|
+
className,
|
|
209
|
+
...props
|
|
210
|
+
},
|
|
211
|
+
ref,
|
|
212
|
+
) {
|
|
213
|
+
const { locale, formatNumber } = useLocale();
|
|
214
|
+
const labels = useMemo<VariantExplorerLabels>(
|
|
215
|
+
() => ({ ...VARIANT_EXPLORER_DEFAULT_LABELS, ...labelOverrides }),
|
|
216
|
+
[labelOverrides],
|
|
217
|
+
);
|
|
218
|
+
const pluralRules = useMemo(() => new Intl.PluralRules(locale), [locale]);
|
|
219
|
+
const plural = useCallback(
|
|
220
|
+
(count: number, one: string, other: string) =>
|
|
221
|
+
fillLabel(pluralRules.select(count) === "one" ? one : other, {
|
|
222
|
+
count: formatNumber(count),
|
|
223
|
+
}),
|
|
224
|
+
[pluralRules, formatNumber],
|
|
225
|
+
);
|
|
226
|
+
const formatShare = useCallback(
|
|
227
|
+
(share: number) => formatNumber(share, { style: "percent", maximumFractionDigits: 1 }),
|
|
228
|
+
[formatNumber],
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const selectedIds = useMemo(
|
|
232
|
+
() => variants.filter((v) => stateOf(selectionStates, v.id) === "selected").map((v) => v.id),
|
|
233
|
+
[variants, selectionStates],
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const toggle = useCallback((id: string) => onSelect([id], "toggle"), [onSelect]);
|
|
237
|
+
|
|
238
|
+
const rowText = useCallback(
|
|
239
|
+
(variant: Variant, rank: number, state: ProcessSelectionState): VariantRowText => {
|
|
240
|
+
const activityNames = variant.sequence.map((id) => colorScale.labelFor(id));
|
|
241
|
+
let summary = fillLabel(labels.rowSummary, {
|
|
242
|
+
rank,
|
|
243
|
+
activities: plural(variant.sequence.length, labels.activitiesOne, labels.activitiesOther),
|
|
244
|
+
cases: plural(variant.count, labels.casesOne, labels.casesOther),
|
|
245
|
+
coverage: formatNumber(Math.round(variant.share * 100)),
|
|
246
|
+
});
|
|
247
|
+
if (state === "selected") summary = `${summary}, ${labels.selected}`;
|
|
248
|
+
if (state === "excluded") summary = `${summary}, ${labels.excluded}`;
|
|
249
|
+
return {
|
|
250
|
+
summary,
|
|
251
|
+
sequence: fillLabel(labels.sequence, { activities: activityNames.join(", ") }),
|
|
252
|
+
cases: formatNumber(variant.count),
|
|
253
|
+
coverage: formatShare(variant.share),
|
|
254
|
+
medianDuration: formatDurationMs(variant.duration.median),
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
[colorScale, labels, plural, formatNumber, formatShare],
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
// ── Coverage target ────────────────────────────────────────────────────
|
|
261
|
+
const sliderLabelId = useId();
|
|
262
|
+
const [targetPercent, setTargetPercent] = useState(() =>
|
|
263
|
+
Math.round(Math.min(1, Math.max(0, coverageTarget ?? 0)) * 100),
|
|
264
|
+
);
|
|
265
|
+
const targetCount = useMemo(
|
|
266
|
+
() => selectVariantsByCoverage(variants, targetPercent / 100).length,
|
|
267
|
+
[variants, targetPercent],
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// ── Virtualized list + roving focus ────────────────────────────────────
|
|
271
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
272
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
273
|
+
const virtualizer = useVirtualizer({
|
|
274
|
+
count: variants.length,
|
|
275
|
+
getScrollElement: () => scrollRef.current,
|
|
276
|
+
estimateSize: () => VARIANT_EXPLORER_ROW_HEIGHT,
|
|
277
|
+
overscan: 6,
|
|
278
|
+
});
|
|
279
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
280
|
+
const virtualRows = virtualizer.getVirtualItems();
|
|
281
|
+
// The roving tab stop is the last focused row — but only while that row is mounted. Once
|
|
282
|
+
// the reader scrolls it out of the window (wheel, scrollbar), the stop moves to the first
|
|
283
|
+
// row in view, so Tab can always reach the list and never lands on an unmounted row.
|
|
284
|
+
const clampedActive = Math.min(activeIndex, Math.max(0, variants.length - 1));
|
|
285
|
+
const tabIndex = virtualRows.some((item) => item.index === clampedActive)
|
|
286
|
+
? clampedActive
|
|
287
|
+
: (virtualizer.range?.startIndex ?? virtualRows[0]?.index ?? clampedActive);
|
|
288
|
+
const pendingFocus = useRef<number | null>(null);
|
|
289
|
+
|
|
290
|
+
// A row scrolled into view by the keyboard mounts on a LATER render than the key press,
|
|
291
|
+
// so focus is handed over whenever the pending row finally exists. No deps on purpose:
|
|
292
|
+
// it has to look after every render until the row is there.
|
|
293
|
+
useLayoutEffect(() => {
|
|
294
|
+
const index = pendingFocus.current;
|
|
295
|
+
if (index === null) return;
|
|
296
|
+
const checkbox = listRef.current?.querySelector<HTMLElement>(
|
|
297
|
+
`[data-index="${index}"] [data-slot="checkbox"]`,
|
|
298
|
+
);
|
|
299
|
+
if (checkbox) {
|
|
300
|
+
pendingFocus.current = null;
|
|
301
|
+
checkbox.focus();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const handleKeyDown = useCallback(
|
|
306
|
+
(event: KeyboardEvent<HTMLDivElement>) => {
|
|
307
|
+
if (variants.length === 0) return;
|
|
308
|
+
const last = variants.length - 1;
|
|
309
|
+
const page = Math.max(
|
|
310
|
+
1,
|
|
311
|
+
Math.floor((scrollRef.current?.clientHeight ?? 0) / VARIANT_EXPLORER_ROW_HEIGHT),
|
|
312
|
+
);
|
|
313
|
+
let next: number | null = null;
|
|
314
|
+
switch (event.key) {
|
|
315
|
+
case "ArrowDown":
|
|
316
|
+
next = Math.min(last, tabIndex + 1);
|
|
317
|
+
break;
|
|
318
|
+
case "ArrowUp":
|
|
319
|
+
next = Math.max(0, tabIndex - 1);
|
|
320
|
+
break;
|
|
321
|
+
case "PageDown":
|
|
322
|
+
next = Math.min(last, tabIndex + page);
|
|
323
|
+
break;
|
|
324
|
+
case "PageUp":
|
|
325
|
+
next = Math.max(0, tabIndex - page);
|
|
326
|
+
break;
|
|
327
|
+
case "Home":
|
|
328
|
+
next = 0;
|
|
329
|
+
break;
|
|
330
|
+
case "End":
|
|
331
|
+
next = last;
|
|
332
|
+
break;
|
|
333
|
+
case "Enter": {
|
|
334
|
+
event.preventDefault();
|
|
335
|
+
const variant = variants[tabIndex];
|
|
336
|
+
if (variant) toggle(variant.id);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
default:
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
event.preventDefault();
|
|
343
|
+
setActiveIndex(next);
|
|
344
|
+
pendingFocus.current = next;
|
|
345
|
+
virtualizer.scrollToIndex(next, { align: "auto" });
|
|
346
|
+
},
|
|
347
|
+
[variants, tabIndex, toggle, virtualizer],
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
if (loading) {
|
|
351
|
+
return (
|
|
352
|
+
<div
|
|
353
|
+
ref={ref}
|
|
354
|
+
data-slot="variant-explorer"
|
|
355
|
+
data-state="loading"
|
|
356
|
+
className={cn("relative flex h-96 flex-col", className)}
|
|
357
|
+
{...props}
|
|
358
|
+
>
|
|
359
|
+
<StatePanel kind="loading" title={labels.loading} />
|
|
360
|
+
</div>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (variants.length === 0) {
|
|
365
|
+
return (
|
|
366
|
+
<div
|
|
367
|
+
ref={ref}
|
|
368
|
+
data-slot="variant-explorer"
|
|
369
|
+
data-state="empty"
|
|
370
|
+
className={cn("relative flex h-96 flex-col", className)}
|
|
371
|
+
{...props}
|
|
372
|
+
>
|
|
373
|
+
<StatePanel kind="empty" title={labels.empty} description={labels.emptyBody} />
|
|
374
|
+
</div>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const toolbar =
|
|
379
|
+
coverageTarget !== undefined || onFilterIntent ? (
|
|
380
|
+
<div data-slot="variant-explorer-toolbar" className="flex flex-wrap items-center gap-3">
|
|
381
|
+
{coverageTarget !== undefined ? (
|
|
382
|
+
<div
|
|
383
|
+
data-slot="variant-explorer-coverage-target"
|
|
384
|
+
className="flex min-w-0 flex-1 items-center gap-3"
|
|
385
|
+
>
|
|
386
|
+
<span id={sliderLabelId} className="shrink-0 text-meta text-muted-foreground">
|
|
387
|
+
{labels.coverageTarget}
|
|
388
|
+
</span>
|
|
389
|
+
<Slider
|
|
390
|
+
aria-labelledby={sliderLabelId}
|
|
391
|
+
aria-valuetext={fillLabel(labels.coverageTargetValue, {
|
|
392
|
+
percent: formatShare(targetPercent / 100),
|
|
393
|
+
count: formatNumber(targetCount),
|
|
394
|
+
})}
|
|
395
|
+
className="min-w-24 max-w-64 flex-1"
|
|
396
|
+
min={0}
|
|
397
|
+
max={100}
|
|
398
|
+
step={1}
|
|
399
|
+
value={[targetPercent]}
|
|
400
|
+
onValueChange={(value) => setTargetPercent(value[0] ?? 0)}
|
|
401
|
+
onValueCommit={(value) =>
|
|
402
|
+
onSelect(selectVariantsByCoverage(variants, (value[0] ?? 0) / 100), "replace")
|
|
403
|
+
}
|
|
404
|
+
/>
|
|
405
|
+
<span className="shrink-0 text-meta tabular-nums">
|
|
406
|
+
{fillLabel(labels.coverageTargetValue, {
|
|
407
|
+
percent: formatShare(targetPercent / 100),
|
|
408
|
+
count: formatNumber(targetCount),
|
|
409
|
+
})}
|
|
410
|
+
</span>
|
|
411
|
+
</div>
|
|
412
|
+
) : null}
|
|
413
|
+
{onFilterIntent ? (
|
|
414
|
+
<Button
|
|
415
|
+
type="button"
|
|
416
|
+
variant="outline"
|
|
417
|
+
size="sm"
|
|
418
|
+
className="ms-auto"
|
|
419
|
+
disabled={selectedIds.length === 0}
|
|
420
|
+
onClick={() => onFilterIntent({ kind: "variant", ids: selectedIds })}
|
|
421
|
+
>
|
|
422
|
+
{labels.filterToSelected}
|
|
423
|
+
</Button>
|
|
424
|
+
) : null}
|
|
425
|
+
</div>
|
|
426
|
+
) : null;
|
|
427
|
+
|
|
428
|
+
if (tableView) {
|
|
429
|
+
return (
|
|
430
|
+
<div
|
|
431
|
+
ref={ref}
|
|
432
|
+
data-slot="variant-explorer"
|
|
433
|
+
data-view="table"
|
|
434
|
+
className={cn("flex flex-col gap-3", className)}
|
|
435
|
+
{...props}
|
|
436
|
+
>
|
|
437
|
+
{toolbar}
|
|
438
|
+
<Table data-slot="variant-explorer-table">
|
|
439
|
+
<TableCaption>{labels.tableCaption}</TableCaption>
|
|
440
|
+
<TableHeader>
|
|
441
|
+
<TableRow>
|
|
442
|
+
<TableHead scope="col">{labels.columnVariant}</TableHead>
|
|
443
|
+
<TableHead scope="col">{labels.columnSequence}</TableHead>
|
|
444
|
+
{columns.includes("cases") ? (
|
|
445
|
+
<TableHead scope="col">{labels.columnCases}</TableHead>
|
|
446
|
+
) : null}
|
|
447
|
+
{columns.includes("coverage") ? (
|
|
448
|
+
<TableHead scope="col">{labels.columnCoverage}</TableHead>
|
|
449
|
+
) : null}
|
|
450
|
+
{columns.includes("medianDuration") ? (
|
|
451
|
+
<TableHead scope="col">{labels.columnMedianDuration}</TableHead>
|
|
452
|
+
) : null}
|
|
453
|
+
<TableHead scope="col">{labels.columnState}</TableHead>
|
|
454
|
+
</TableRow>
|
|
455
|
+
</TableHeader>
|
|
456
|
+
<TableBody>
|
|
457
|
+
{variants.map((variant, index) => {
|
|
458
|
+
const state = stateOf(selectionStates, variant.id);
|
|
459
|
+
const text = rowText(variant, index + 1, state);
|
|
460
|
+
return (
|
|
461
|
+
<TableRow
|
|
462
|
+
key={variant.id}
|
|
463
|
+
data-selection={state}
|
|
464
|
+
data-state={state === "selected" ? "selected" : undefined}
|
|
465
|
+
>
|
|
466
|
+
<TableCell className="tabular-nums">{index + 1}</TableCell>
|
|
467
|
+
<TableCell>
|
|
468
|
+
{variant.sequence.map((id) => colorScale.labelFor(id)).join(" → ")}
|
|
469
|
+
</TableCell>
|
|
470
|
+
{columns.includes("cases") ? (
|
|
471
|
+
<TableCell className="tabular-nums">{text.cases}</TableCell>
|
|
472
|
+
) : null}
|
|
473
|
+
{columns.includes("coverage") ? (
|
|
474
|
+
<TableCell className="tabular-nums">{text.coverage}</TableCell>
|
|
475
|
+
) : null}
|
|
476
|
+
{columns.includes("medianDuration") ? (
|
|
477
|
+
<TableCell className="tabular-nums">{text.medianDuration}</TableCell>
|
|
478
|
+
) : null}
|
|
479
|
+
<TableCell>
|
|
480
|
+
{state === "selected"
|
|
481
|
+
? labels.selected
|
|
482
|
+
: state === "excluded"
|
|
483
|
+
? labels.excluded
|
|
484
|
+
: ""}
|
|
485
|
+
</TableCell>
|
|
486
|
+
</TableRow>
|
|
487
|
+
);
|
|
488
|
+
})}
|
|
489
|
+
</TableBody>
|
|
490
|
+
</Table>
|
|
491
|
+
</div>
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return (
|
|
496
|
+
<div
|
|
497
|
+
ref={ref}
|
|
498
|
+
data-slot="variant-explorer"
|
|
499
|
+
data-view="list"
|
|
500
|
+
className={cn("flex h-96 min-h-0 flex-col gap-3", className)}
|
|
501
|
+
{...props}
|
|
502
|
+
>
|
|
503
|
+
{toolbar}
|
|
504
|
+
{/* header-band-exempt: a table column header row, not shell chrome */}
|
|
505
|
+
<div
|
|
506
|
+
aria-hidden="true"
|
|
507
|
+
data-slot="variant-explorer-header"
|
|
508
|
+
className="flex h-8 shrink-0 items-center gap-3 border-b border-border-strong px-3 text-meta text-muted-foreground"
|
|
509
|
+
>
|
|
510
|
+
<span className="w-4 shrink-0" />
|
|
511
|
+
<span className="w-8 shrink-0">#</span>
|
|
512
|
+
<span className="min-w-0 flex-1">{labels.columnSequence}</span>
|
|
513
|
+
{columns.includes("cases") ? (
|
|
514
|
+
<span className="w-16 shrink-0 text-end">{labels.columnCases}</span>
|
|
515
|
+
) : null}
|
|
516
|
+
{columns.includes("coverage") ? (
|
|
517
|
+
<span className="w-32 shrink-0">{labels.columnCoverage}</span>
|
|
518
|
+
) : null}
|
|
519
|
+
{columns.includes("medianDuration") ? (
|
|
520
|
+
<span className="w-20 shrink-0 truncate text-end">{labels.columnMedianDuration}</span>
|
|
521
|
+
) : null}
|
|
522
|
+
</div>
|
|
523
|
+
<div
|
|
524
|
+
ref={scrollRef}
|
|
525
|
+
data-slot="variant-explorer-viewport"
|
|
526
|
+
className="relative min-h-0 flex-1 overflow-auto"
|
|
527
|
+
onKeyDown={handleKeyDown}
|
|
528
|
+
>
|
|
529
|
+
<div
|
|
530
|
+
ref={listRef}
|
|
531
|
+
role="list"
|
|
532
|
+
aria-label={labels.list}
|
|
533
|
+
data-slot="variant-explorer-list"
|
|
534
|
+
className="relative w-full"
|
|
535
|
+
style={{ height: virtualizer.getTotalSize() }}
|
|
536
|
+
>
|
|
537
|
+
{virtualRows.map((item) => {
|
|
538
|
+
const variant = variants[item.index] as Variant;
|
|
539
|
+
const state = stateOf(selectionStates, variant.id);
|
|
540
|
+
return (
|
|
541
|
+
<VariantRow
|
|
542
|
+
key={variant.id}
|
|
543
|
+
aria-setsize={variants.length}
|
|
544
|
+
aria-posinset={item.index + 1}
|
|
545
|
+
className="absolute inset-x-0 top-0"
|
|
546
|
+
style={{ transform: `translateY(${item.start}px)` }}
|
|
547
|
+
variant={variant}
|
|
548
|
+
rank={item.index + 1}
|
|
549
|
+
index={item.index}
|
|
550
|
+
selectionState={state}
|
|
551
|
+
colorScale={colorScale}
|
|
552
|
+
abbreviate={abbreviate}
|
|
553
|
+
sequenceDisplay={sequenceDisplay}
|
|
554
|
+
columns={columns}
|
|
555
|
+
text={rowText(variant, item.index + 1, state)}
|
|
556
|
+
tabbable={item.index === tabIndex}
|
|
557
|
+
onToggle={toggle}
|
|
558
|
+
onFocusRow={setActiveIndex}
|
|
559
|
+
/>
|
|
560
|
+
);
|
|
561
|
+
})}
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
</div>
|
|
565
|
+
);
|
|
566
|
+
},
|
|
567
|
+
);
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One variant as a list row (RM-054): selection checkbox, rank, sequence chips and the
|
|
5
|
+
* requested numeric columns.
|
|
6
|
+
*
|
|
7
|
+
* The checkbox is the row's ONE focus target. Its accessible name is the whole row in
|
|
8
|
+
* words ("Variant 3, 6 activities, 214 cases, 12 percent coverage"), so a keyboard or
|
|
9
|
+
* screen-reader user gets every number without a second tab stop per row. A pointer click
|
|
10
|
+
* anywhere on the row toggles it too — the checkbox already offers the same action from the
|
|
11
|
+
* keyboard, so the row click is a convenience, never the only path.
|
|
12
|
+
*/
|
|
13
|
+
import { forwardRef, type CSSProperties, type HTMLAttributes, type MouseEvent } from "react";
|
|
14
|
+
import { Checkbox } from "@elabs-ai/components-ui";
|
|
15
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
16
|
+
import type { ActivityColorScale } from "../core/activity-color-scale";
|
|
17
|
+
import type { Variant } from "../core/types";
|
|
18
|
+
import type { ProcessSelectionState } from "../process-map/map-model";
|
|
19
|
+
import { VariantCoverageBar } from "./coverage-bar";
|
|
20
|
+
import { VariantSequenceChips } from "./sequence-chips";
|
|
21
|
+
import type { VariantExplorerColumn } from "./variant-explorer-model";
|
|
22
|
+
|
|
23
|
+
/** Pre-formatted text a row prints — the explorer formats once, with its locale. */
|
|
24
|
+
export interface VariantRowText {
|
|
25
|
+
/** The checkbox's accessible name — the whole row in words. */
|
|
26
|
+
summary: string;
|
|
27
|
+
/** The sequence strip's accessible name. */
|
|
28
|
+
sequence: string;
|
|
29
|
+
cases: string;
|
|
30
|
+
coverage: string;
|
|
31
|
+
medianDuration: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VariantRowProps extends Omit<HTMLAttributes<HTMLDivElement>, "onToggle"> {
|
|
35
|
+
variant: Variant;
|
|
36
|
+
/** 1-based position in the list. */
|
|
37
|
+
rank: number;
|
|
38
|
+
/** 0-based position in the list, stamped as `data-index` for the virtualizer. */
|
|
39
|
+
index: number;
|
|
40
|
+
selectionState: ProcessSelectionState;
|
|
41
|
+
colorScale: ActivityColorScale;
|
|
42
|
+
abbreviate: boolean;
|
|
43
|
+
sequenceDisplay?: "text" | "swatch";
|
|
44
|
+
columns: readonly VariantExplorerColumn[];
|
|
45
|
+
text: VariantRowText;
|
|
46
|
+
/** Whether this row's checkbox is the list's single tab stop. */
|
|
47
|
+
tabbable: boolean;
|
|
48
|
+
onToggle: (variantId: string) => void;
|
|
49
|
+
onFocusRow: (index: number) => void;
|
|
50
|
+
style?: CSSProperties;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const VariantRow = forwardRef<HTMLDivElement, VariantRowProps>(function VariantRow(
|
|
54
|
+
{
|
|
55
|
+
variant,
|
|
56
|
+
rank,
|
|
57
|
+
index,
|
|
58
|
+
selectionState,
|
|
59
|
+
colorScale,
|
|
60
|
+
abbreviate,
|
|
61
|
+
sequenceDisplay,
|
|
62
|
+
columns,
|
|
63
|
+
text,
|
|
64
|
+
tabbable,
|
|
65
|
+
onToggle,
|
|
66
|
+
onFocusRow,
|
|
67
|
+
className,
|
|
68
|
+
...props
|
|
69
|
+
},
|
|
70
|
+
ref,
|
|
71
|
+
) {
|
|
72
|
+
const selected = selectionState === "selected";
|
|
73
|
+
|
|
74
|
+
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
|
|
75
|
+
// The checkbox handles its own click; toggling here too would undo it.
|
|
76
|
+
if ((event.target as HTMLElement).closest('[data-slot="checkbox"]')) return;
|
|
77
|
+
onToggle(variant.id);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div
|
|
82
|
+
ref={ref}
|
|
83
|
+
role="listitem"
|
|
84
|
+
data-slot="variant-explorer-row"
|
|
85
|
+
data-index={index}
|
|
86
|
+
data-variant={variant.id}
|
|
87
|
+
data-selection={selectionState}
|
|
88
|
+
className={cn(
|
|
89
|
+
"flex h-10 items-center gap-3 border-b border-border-strong px-3 transition-colors duration-fast ease-standard motion-reduce:transition-none",
|
|
90
|
+
"hover:bg-muted/60 data-[selection=selected]:bg-accent data-[selection=excluded]:text-muted-foreground",
|
|
91
|
+
className,
|
|
92
|
+
)}
|
|
93
|
+
onClick={handleClick}
|
|
94
|
+
{...props}
|
|
95
|
+
>
|
|
96
|
+
<Checkbox
|
|
97
|
+
checked={selected}
|
|
98
|
+
aria-label={text.summary}
|
|
99
|
+
tabIndex={tabbable ? 0 : -1}
|
|
100
|
+
onCheckedChange={() => onToggle(variant.id)}
|
|
101
|
+
onFocus={() => onFocusRow(index)}
|
|
102
|
+
/>
|
|
103
|
+
<span
|
|
104
|
+
aria-hidden="true"
|
|
105
|
+
className="w-8 shrink-0 text-meta text-muted-foreground tabular-nums"
|
|
106
|
+
>
|
|
107
|
+
{rank}
|
|
108
|
+
</span>
|
|
109
|
+
<VariantSequenceChips
|
|
110
|
+
className="flex-1"
|
|
111
|
+
sequence={variant.sequence}
|
|
112
|
+
colorScale={colorScale}
|
|
113
|
+
abbreviate={abbreviate}
|
|
114
|
+
display={sequenceDisplay}
|
|
115
|
+
label={text.sequence}
|
|
116
|
+
/>
|
|
117
|
+
{columns.includes("cases") ? (
|
|
118
|
+
<span aria-hidden="true" className="w-16 shrink-0 text-end text-meta tabular-nums">
|
|
119
|
+
{text.cases}
|
|
120
|
+
</span>
|
|
121
|
+
) : null}
|
|
122
|
+
{columns.includes("coverage") ? (
|
|
123
|
+
<VariantCoverageBar
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
className="w-32 shrink-0"
|
|
126
|
+
share={variant.share}
|
|
127
|
+
valueLabel={text.coverage}
|
|
128
|
+
/>
|
|
129
|
+
) : null}
|
|
130
|
+
{columns.includes("medianDuration") ? (
|
|
131
|
+
<span aria-hidden="true" className="w-20 shrink-0 text-end text-meta tabular-nums">
|
|
132
|
+
{text.medianDuration}
|
|
133
|
+
</span>
|
|
134
|
+
) : null}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
});
|