@elabs-ai/components-process 4.2.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__/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
|
@@ -62,7 +62,7 @@ import { discoverGraph } from "../core/discover-graph";
|
|
|
62
62
|
import { durationStats } from "../core/duration-stats";
|
|
63
63
|
import { extractVariants, variantKey } from "../core/extract-variants";
|
|
64
64
|
import type { FilterSpec } from "../core/filter-log";
|
|
65
|
-
import { filterLog } from "../core/filter-log";
|
|
65
|
+
import { filterLog, filterNormalizedLog } from "../core/filter-log";
|
|
66
66
|
import { reconcileGraph } from "../core";
|
|
67
67
|
import type { EventLog, FrequencyMode, PerformanceAgg, ProcessGraph, Variant } from "../core/types";
|
|
68
68
|
import {
|
|
@@ -95,8 +95,15 @@ export interface ProcessExplorerMetricSpec {
|
|
|
95
95
|
* variant selection — a case a click on the process map can never produce, but a click on a
|
|
96
96
|
* variant row can. The widening is local to this hook's own type alias; it does not touch
|
|
97
97
|
* `ProcessFilterIntent` or `ProcessMap`'s menu, which still only ever emits the original four.
|
|
98
|
+
*
|
|
99
|
+
* `{ kind: "cases" }` joins it for the same reason: `DottedChart` (RM-059) and
|
|
100
|
+
* `PerformanceSpectrum` (RM-060) brush a set of case ids, so their `onFilterIntent` can be
|
|
101
|
+
* wired straight to `applyIntent`.
|
|
98
102
|
*/
|
|
99
|
-
export type FilterIntent =
|
|
103
|
+
export type FilterIntent =
|
|
104
|
+
| ProcessFilterIntent
|
|
105
|
+
| Extract<FilterSpec, { kind: "variant" }>
|
|
106
|
+
| Extract<FilterSpec, { kind: "cases" }>;
|
|
100
107
|
|
|
101
108
|
/** Options for {@link useProcessExplorer}. */
|
|
102
109
|
export interface ProcessExplorerOptions {
|
|
@@ -138,6 +145,17 @@ export interface UseProcessExplorerResult {
|
|
|
138
145
|
applyIntent(intent: FilterIntent): void;
|
|
139
146
|
clearIntent(index: number): void;
|
|
140
147
|
intents: FilterIntent[];
|
|
148
|
+
/**
|
|
149
|
+
* How many cases EACH active intent alone excludes — parallel to {@link intents} (same
|
|
150
|
+
* index), for `ProcessFilterBar` (RM-056, #205)'s per-chip "excluded N" count. For intent
|
|
151
|
+
* `i`, this is the case count filtered by every intent BEFORE `i` minus the case count
|
|
152
|
+
* filtered by every intent up to and including `i` — so it isolates what `i` itself
|
|
153
|
+
* removes from the chain, not what the whole chain removes. `[]` when no intents are
|
|
154
|
+
* active. Computed with one `filterNormalizedLog` call per prefix (`n + 1` calls for `n`
|
|
155
|
+
* intents, reusing each prefix's count for both the term it ends and the term it starts),
|
|
156
|
+
* never `n²` — additive field, `intents`/`applyIntent`/`clearIntent` are unchanged.
|
|
157
|
+
*/
|
|
158
|
+
excludedByIntent: number[];
|
|
141
159
|
filteredLog: EventLog;
|
|
142
160
|
/**
|
|
143
161
|
* Per-element states the active filter contributes — pass straight into `ProcessMap`'s
|
|
@@ -419,6 +437,19 @@ export function useProcessExplorer(
|
|
|
419
437
|
[log, intents],
|
|
420
438
|
);
|
|
421
439
|
|
|
440
|
+
// One `filterNormalizedLog` call per prefix (`0..intents.length`, so `n + 1` for `n`
|
|
441
|
+
// intents) — `prefixCaseCounts[i]` is the case count after applying only the first `i`
|
|
442
|
+
// intents. Each intent's own excluded count is then just the drop between two adjacent
|
|
443
|
+
// prefixes, so nothing here is O(intents²) (RM-056, #205).
|
|
444
|
+
const excludedByIntent = useMemo(() => {
|
|
445
|
+
if (intents.length === 0) return [];
|
|
446
|
+
const prefixCaseCounts: number[] = [];
|
|
447
|
+
for (let i = 0; i <= intents.length; i += 1) {
|
|
448
|
+
prefixCaseCounts.push(filterNormalizedLog(log, intents.slice(0, i)).totals.cases);
|
|
449
|
+
}
|
|
450
|
+
return intents.map((_, i) => prefixCaseCounts[i]! - prefixCaseCounts[i + 1]!);
|
|
451
|
+
}, [log, intents]);
|
|
452
|
+
|
|
422
453
|
// Two independent discoveries — see the module docblock. `variants`, `kpis` and `rework`
|
|
423
454
|
// read the FILTERED one; `graph` reads BOTH, full first through abstraction, then
|
|
424
455
|
// reconciled against filtered (Invariant F: filtering re-inks, never removes).
|
|
@@ -529,6 +560,7 @@ export function useProcessExplorer(
|
|
|
529
560
|
applyIntent,
|
|
530
561
|
clearIntent,
|
|
531
562
|
intents,
|
|
563
|
+
excludedByIntent,
|
|
532
564
|
filteredLog,
|
|
533
565
|
selectionStates,
|
|
534
566
|
hiddenCounts: graph.hidden,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A variant's share of cases as a bar plus its printed percentage (RM-054).
|
|
5
|
+
*
|
|
6
|
+
* Composes `@elabs-ai/components-ui`'s `Progress` rather than drawing a bar. The bar is
|
|
7
|
+
* `aria-hidden`: the same number is printed beside it and is part of the row's accessible
|
|
8
|
+
* name, so a progressbar per row would only add 2 000 redundant announcements.
|
|
9
|
+
*/
|
|
10
|
+
import { forwardRef, type HTMLAttributes } from "react";
|
|
11
|
+
import { Progress } from "@elabs-ai/components-ui";
|
|
12
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
13
|
+
|
|
14
|
+
export interface VariantCoverageBarProps extends HTMLAttributes<HTMLDivElement> {
|
|
15
|
+
/** `0..1`. */
|
|
16
|
+
share: number;
|
|
17
|
+
/** The share, already formatted for display. */
|
|
18
|
+
valueLabel: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const VariantCoverageBar = forwardRef<HTMLDivElement, VariantCoverageBarProps>(
|
|
22
|
+
function VariantCoverageBar({ share, valueLabel, className, ...props }, ref) {
|
|
23
|
+
const percent = Math.min(100, Math.max(0, share * 100));
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
ref={ref}
|
|
27
|
+
data-slot="variant-explorer-coverage"
|
|
28
|
+
className={cn("flex min-w-0 items-center gap-2", className)}
|
|
29
|
+
{...props}
|
|
30
|
+
>
|
|
31
|
+
<Progress aria-hidden="true" value={percent} className="h-1.5 min-w-8 flex-1" />
|
|
32
|
+
<span className="w-12 shrink-0 text-end text-meta tabular-nums">{valueLabel}</span>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VariantExplorer (RM-054) — public surface: the explorer, its labels, and the pure
|
|
3
|
+
* coverage-selection helper a host can reuse to preselect "the top N % of paths".
|
|
4
|
+
*/
|
|
5
|
+
export {
|
|
6
|
+
VARIANT_EXPLORER_DEFAULT_LABELS,
|
|
7
|
+
VARIANT_EXPLORER_ROW_HEIGHT,
|
|
8
|
+
VariantExplorer,
|
|
9
|
+
} from "./variant-explorer";
|
|
10
|
+
export type {
|
|
11
|
+
VariantExplorerLabels,
|
|
12
|
+
VariantExplorerProps,
|
|
13
|
+
VariantSelectMode,
|
|
14
|
+
} from "./variant-explorer";
|
|
15
|
+
export { selectVariantsByCoverage, VARIANT_EXPLORER_COLUMNS } from "./variant-explorer-model";
|
|
16
|
+
export type { VariantExplorerColumn } from "./variant-explorer-model";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One variant's activity sequence as a row of chips (RM-054).
|
|
5
|
+
*
|
|
6
|
+
* Each chip is an activity: an identity swatch painted from the shared
|
|
7
|
+
* `ActivityColorScale` (the same `activityAccentStyle` `ProcessActivityNode` uses, so the
|
|
8
|
+
* two views cannot disagree) plus the activity's label — or, in the abbreviated
|
|
9
|
+
* "DNA strip", its two-character code. Colour is never the only channel: the text always
|
|
10
|
+
* names the activity, and the whole strip is one `role="img"` whose accessible name lists
|
|
11
|
+
* the full sequence, so an abbreviated code never reaches a screen reader in place of a
|
|
12
|
+
* name. HTML spans, not SVG — nothing here needs a coordinate system.
|
|
13
|
+
*/
|
|
14
|
+
import { forwardRef, type HTMLAttributes } from "react";
|
|
15
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
16
|
+
import type { ActivityColorScale } from "../core/activity-color-scale";
|
|
17
|
+
import { activityAccentStyle } from "../process-map/activity-accent";
|
|
18
|
+
|
|
19
|
+
export interface VariantSequenceChipsProps extends HTMLAttributes<HTMLDivElement> {
|
|
20
|
+
sequence: readonly string[];
|
|
21
|
+
colorScale: ActivityColorScale;
|
|
22
|
+
/** Two-character codes instead of full labels. */
|
|
23
|
+
abbreviate?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* `"swatch"` drops the text and draws each activity as one identity block — the densest
|
|
26
|
+
* strip, for a narrow rail where even two-character codes leave room for three steps.
|
|
27
|
+
* The strip's own accessible name (and `title`) still lists the full sequence, and each
|
|
28
|
+
* block carries its activity as `title`, so a name is always one hover away. Wins over
|
|
29
|
+
* `abbreviate`. @default "text"
|
|
30
|
+
*/
|
|
31
|
+
display?: "text" | "swatch";
|
|
32
|
+
/** Accessible name for the strip — the full sequence in words. */
|
|
33
|
+
label: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const VariantSequenceChips = forwardRef<HTMLDivElement, VariantSequenceChipsProps>(
|
|
37
|
+
function VariantSequenceChips(
|
|
38
|
+
{ sequence, colorScale, abbreviate = false, display = "text", label, className, ...props },
|
|
39
|
+
ref,
|
|
40
|
+
) {
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
ref={ref}
|
|
44
|
+
role="img"
|
|
45
|
+
aria-label={label}
|
|
46
|
+
title={label}
|
|
47
|
+
data-slot="variant-explorer-sequence"
|
|
48
|
+
data-abbreviated={abbreviate ? "true" : undefined}
|
|
49
|
+
data-display={display === "swatch" ? "swatch" : undefined}
|
|
50
|
+
className={cn(
|
|
51
|
+
"flex min-w-0 flex-nowrap items-center overflow-hidden",
|
|
52
|
+
display === "swatch" || abbreviate ? "gap-0.5" : "gap-1",
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
{...props}
|
|
56
|
+
>
|
|
57
|
+
{sequence.map((activityId, position) => {
|
|
58
|
+
const color = colorScale.colorFor(activityId);
|
|
59
|
+
if (display === "swatch") {
|
|
60
|
+
return (
|
|
61
|
+
<span
|
|
62
|
+
key={`${position}:${activityId}`}
|
|
63
|
+
data-slot="variant-explorer-chip"
|
|
64
|
+
data-activity={activityId}
|
|
65
|
+
data-color-token={color.token}
|
|
66
|
+
data-pattern={color.pattern}
|
|
67
|
+
title={colorScale.labelFor(activityId)}
|
|
68
|
+
className="h-4 w-2 shrink-0 rounded-sm"
|
|
69
|
+
style={activityAccentStyle(color)}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return (
|
|
74
|
+
<span
|
|
75
|
+
// A sequence may repeat an activity (rework), so the id alone is not unique;
|
|
76
|
+
// the position within an immutable sequence is the stable identity here.
|
|
77
|
+
key={`${position}:${activityId}`}
|
|
78
|
+
data-slot="variant-explorer-chip"
|
|
79
|
+
data-activity={activityId}
|
|
80
|
+
data-color-token={color.token}
|
|
81
|
+
data-pattern={color.pattern}
|
|
82
|
+
className={cn(
|
|
83
|
+
"inline-flex shrink-0 items-center gap-1 rounded-sm border border-border bg-card text-meta text-foreground",
|
|
84
|
+
abbreviate ? "px-1 font-mono tabular-nums" : "max-w-40 px-1.5",
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
<span
|
|
88
|
+
data-slot="variant-explorer-chip-swatch"
|
|
89
|
+
data-color-token={color.token}
|
|
90
|
+
data-pattern={color.pattern}
|
|
91
|
+
className="size-2 shrink-0 rounded-sm"
|
|
92
|
+
style={activityAccentStyle(color)}
|
|
93
|
+
/>
|
|
94
|
+
<span className="truncate">
|
|
95
|
+
{abbreviate ? colorScale.codeFor(activityId) : colorScale.labelFor(activityId)}
|
|
96
|
+
</span>
|
|
97
|
+
</span>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
},
|
|
103
|
+
);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers behind `VariantExplorer` (RM-054) — no React, so the selection semantics
|
|
3
|
+
* are unit-testable on their own and identical on every render.
|
|
4
|
+
*/
|
|
5
|
+
import type { Variant } from "../core/types";
|
|
6
|
+
|
|
7
|
+
/** The optional numeric columns a variant row can show, in display order. */
|
|
8
|
+
export type VariantExplorerColumn = "cases" | "coverage" | "medianDuration";
|
|
9
|
+
|
|
10
|
+
/** Every column, in the order the explorer renders them. */
|
|
11
|
+
export const VARIANT_EXPLORER_COLUMNS: readonly VariantExplorerColumn[] = Object.freeze([
|
|
12
|
+
"cases",
|
|
13
|
+
"coverage",
|
|
14
|
+
"medianDuration",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The smallest prefix of `variants` whose `cumulativeShare` reaches `target`.
|
|
19
|
+
*
|
|
20
|
+
* `variants` is expected in `extractVariants` order (count descending, fully tie-broken),
|
|
21
|
+
* so the prefix is the set of most frequent paths that together cover `target` of the
|
|
22
|
+
* cases. Deterministic: the same variants and target always yield the same id list. A
|
|
23
|
+
* target of `0` or less selects nothing; a target above the last variant's share selects
|
|
24
|
+
* every variant. A tiny epsilon absorbs float noise in `cumulativeShare` so a `0.5`
|
|
25
|
+
* target is met by a variant whose share prints as exactly 50 %.
|
|
26
|
+
*/
|
|
27
|
+
export function selectVariantsByCoverage(variants: readonly Variant[], target: number): string[] {
|
|
28
|
+
if (!(target > 0)) return [];
|
|
29
|
+
const ids: string[] = [];
|
|
30
|
+
for (const variant of variants) {
|
|
31
|
+
ids.push(variant.id);
|
|
32
|
+
if (variant.cumulativeShare >= target - 1e-9) break;
|
|
33
|
+
}
|
|
34
|
+
return ids;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fill `{name}` placeholders in a labels string. Unknown names are left as written. */
|
|
38
|
+
export function fillLabel(template: string, vars: Record<string, string | number>): string {
|
|
39
|
+
return template.replace(/\{(\w+)\}/g, (match, key: string) =>
|
|
40
|
+
key in vars ? String(vars[key]) : match,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useCallback, useMemo, useState } from "react";
|
|
3
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
4
|
+
import { activityColorScale } from "../core/activity-color-scale";
|
|
5
|
+
import { discoverGraph } from "../core/discover-graph";
|
|
6
|
+
import { extractVariants } from "../core/extract-variants";
|
|
7
|
+
import { generateBpi2012Subset } from "../core/fixtures/generate-bpi-2012-subset";
|
|
8
|
+
import { generateSyntheticLog } from "../core/fixtures/synthetic-log";
|
|
9
|
+
import type { Variant } from "../core/types";
|
|
10
|
+
import {
|
|
11
|
+
VariantExplorer,
|
|
12
|
+
type VariantExplorerProps,
|
|
13
|
+
type VariantSelectMode,
|
|
14
|
+
} from "./variant-explorer";
|
|
15
|
+
|
|
16
|
+
const log = generateSyntheticLog({ cases: 400, seed: 42 });
|
|
17
|
+
const graph = discoverGraph(log);
|
|
18
|
+
const variants = extractVariants(log);
|
|
19
|
+
const colorScale = activityColorScale(graph);
|
|
20
|
+
|
|
21
|
+
const loanLog = generateBpi2012Subset({ cases: 300, seed: 3 });
|
|
22
|
+
const loanVariants = extractVariants(loanLog);
|
|
23
|
+
const loanColorScale = activityColorScale(discoverGraph(loanLog));
|
|
24
|
+
|
|
25
|
+
/** 2 000 synthetic variants — the virtualization acceptance size. */
|
|
26
|
+
const manyVariants: Variant[] = (() => {
|
|
27
|
+
const total = 2000;
|
|
28
|
+
const cases = (total * (total + 1)) / 2;
|
|
29
|
+
let cumulative = 0;
|
|
30
|
+
return Array.from({ length: total }, (_, i) => {
|
|
31
|
+
const base = variants[i % variants.length]!;
|
|
32
|
+
const count = total - i;
|
|
33
|
+
cumulative += count;
|
|
34
|
+
return {
|
|
35
|
+
...base,
|
|
36
|
+
id: `${base.id}-${i}`,
|
|
37
|
+
count,
|
|
38
|
+
share: count / cases,
|
|
39
|
+
cumulativeShare: cumulative / cases,
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
})();
|
|
43
|
+
|
|
44
|
+
/** Owns the selection a playbook would own, so the stories are interactive. */
|
|
45
|
+
function Stateful(
|
|
46
|
+
props: Omit<VariantExplorerProps, "onSelect" | "selectionStates"> & {
|
|
47
|
+
initialSelected?: string[];
|
|
48
|
+
},
|
|
49
|
+
) {
|
|
50
|
+
const { initialSelected = [], ...rest } = props;
|
|
51
|
+
const [selected, setSelected] = useState<string[]>(initialSelected);
|
|
52
|
+
const onSelect = useCallback((ids: string[], mode: VariantSelectMode) => {
|
|
53
|
+
setSelected((current) => {
|
|
54
|
+
if (mode === "replace") return ids;
|
|
55
|
+
const next = new Set(current);
|
|
56
|
+
for (const id of ids) {
|
|
57
|
+
if (next.has(id)) next.delete(id);
|
|
58
|
+
else next.add(id);
|
|
59
|
+
}
|
|
60
|
+
return [...next];
|
|
61
|
+
});
|
|
62
|
+
}, []);
|
|
63
|
+
const selectionStates = useMemo(
|
|
64
|
+
() => ({ variants: Object.fromEntries(selected.map((id) => [id, "selected" as const])) }),
|
|
65
|
+
[selected],
|
|
66
|
+
);
|
|
67
|
+
return <VariantExplorer {...rest} selectionStates={selectionStates} onSelect={onSelect} />;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const meta = {
|
|
71
|
+
title: "Process/VariantExplorer",
|
|
72
|
+
component: VariantExplorer,
|
|
73
|
+
tags: ["autodocs"],
|
|
74
|
+
parameters: {
|
|
75
|
+
layout: "padded",
|
|
76
|
+
docs: {
|
|
77
|
+
description: {
|
|
78
|
+
component:
|
|
79
|
+
"The ranked list of activity sequences in an event log. Each row shows the sequence as " +
|
|
80
|
+
"colour-keyed chips (the same colours `ProcessMap` paints when it gets the same " +
|
|
81
|
+
"`colorScale`), the number of cases, the share of cases covered and the median " +
|
|
82
|
+
"duration. It only emits `onSelect` — a playbook turns that into a variant filter. " +
|
|
83
|
+
"Rows are virtualized; the checkboxes form one roving tab stop (arrows, Page Up/Down, " +
|
|
84
|
+
"Home/End move; Space and Enter toggle).",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
args: {
|
|
89
|
+
variants,
|
|
90
|
+
colorScale,
|
|
91
|
+
onSelect: () => {},
|
|
92
|
+
},
|
|
93
|
+
render: (args) => (
|
|
94
|
+
<div className="w-full max-w-4xl">
|
|
95
|
+
<Stateful {...args} />
|
|
96
|
+
</div>
|
|
97
|
+
),
|
|
98
|
+
} satisfies Meta<typeof VariantExplorer>;
|
|
99
|
+
export default meta;
|
|
100
|
+
type Story = StoryObj<typeof meta>;
|
|
101
|
+
|
|
102
|
+
/** The ten most frequent paths with every column. */
|
|
103
|
+
export const TopTen: Story = {
|
|
104
|
+
args: { variants: variants.slice(0, 10) },
|
|
105
|
+
play: async ({ canvasElement }) => {
|
|
106
|
+
const canvas = within(canvasElement);
|
|
107
|
+
const rows = canvasElement.querySelectorAll('[data-slot="variant-explorer-row"]');
|
|
108
|
+
await expect(rows.length).toBe(10);
|
|
109
|
+
// Row click toggles the variant.
|
|
110
|
+
const firstSequence = within(rows[0] as HTMLElement).getByRole("img");
|
|
111
|
+
await userEvent.click(firstSequence);
|
|
112
|
+
await waitFor(() => expect(canvas.getAllByRole("checkbox")[0]).toBeChecked());
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* "Variant DNA": two-letter chips so long sequences fit; full names stay in the accessible
|
|
118
|
+
* name. A loan-application log with more than eleven activities, so the rarer activities
|
|
119
|
+
* share the hatched "other" swatch.
|
|
120
|
+
*/
|
|
121
|
+
export const DnaStrip: Story = {
|
|
122
|
+
args: { variants: loanVariants, colorScale: loanColorScale, abbreviate: true },
|
|
123
|
+
play: async ({ canvasElement }) => {
|
|
124
|
+
await expect(canvasElement.querySelector('[data-pattern="other"]')).not.toBeNull();
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `sequenceDisplay="swatch"`: every step is one identity block with no text — the densest
|
|
130
|
+
* strip, for a rail too narrow for codes. Hover a block for its activity; the strip's
|
|
131
|
+
* accessible name still lists the full sequence.
|
|
132
|
+
*/
|
|
133
|
+
export const SwatchStrip: Story = {
|
|
134
|
+
args: {
|
|
135
|
+
variants: loanVariants,
|
|
136
|
+
colorScale: loanColorScale,
|
|
137
|
+
sequenceDisplay: "swatch",
|
|
138
|
+
columns: ["cases"],
|
|
139
|
+
},
|
|
140
|
+
decorators: [
|
|
141
|
+
(StoryFn) => (
|
|
142
|
+
<div className="w-80">
|
|
143
|
+
<StoryFn />
|
|
144
|
+
</div>
|
|
145
|
+
),
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/** The coverage slider selects the fewest paths that cover the target share of cases. */
|
|
150
|
+
export const CoverageSlider: Story = {
|
|
151
|
+
args: { coverageTarget: 0.8, onFilterIntent: () => {} },
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Two variants already selected; keyboard selection with arrows and Space. */
|
|
155
|
+
export const WithSelection: Story = {
|
|
156
|
+
render: (args) => (
|
|
157
|
+
<div className="w-full max-w-4xl">
|
|
158
|
+
<Stateful {...args} initialSelected={[variants[0]!.id, variants[2]!.id]} />
|
|
159
|
+
</div>
|
|
160
|
+
),
|
|
161
|
+
play: async ({ canvasElement }) => {
|
|
162
|
+
const canvas = within(canvasElement);
|
|
163
|
+
const boxes = canvas.getAllByRole("checkbox");
|
|
164
|
+
await expect(boxes[0]).toBeChecked();
|
|
165
|
+
await expect(boxes[2]).toBeChecked();
|
|
166
|
+
|
|
167
|
+
boxes[0]!.focus();
|
|
168
|
+
await userEvent.keyboard("{ArrowDown}");
|
|
169
|
+
const second = canvasElement.querySelector<HTMLElement>(
|
|
170
|
+
'[data-index="1"] [data-slot="checkbox"]',
|
|
171
|
+
);
|
|
172
|
+
await waitFor(() => expect(second).toHaveFocus());
|
|
173
|
+
await userEvent.keyboard(" ");
|
|
174
|
+
await waitFor(() => expect(second).toBeChecked());
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** The accessible table twin — the same numbers as the list, as a real table. */
|
|
179
|
+
export const TableView: Story = {
|
|
180
|
+
args: { variants: variants.slice(0, 12), tableView: true },
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/** Waiting for variants. */
|
|
184
|
+
export const Loading: Story = {
|
|
185
|
+
args: { variants: [], loading: true },
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/** A filter left no cases. */
|
|
189
|
+
export const Empty: Story = {
|
|
190
|
+
args: { variants: [] },
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 2 000 variants. The play test scrolls the list and asserts the number of mounted rows
|
|
195
|
+
* stays bounded — the virtualization that keeps scrolling smooth. It does not time frames,
|
|
196
|
+
* which is too noisy to gate CI on.
|
|
197
|
+
*/
|
|
198
|
+
export const TwoThousandVariants: Story = {
|
|
199
|
+
args: { variants: manyVariants, abbreviate: true },
|
|
200
|
+
play: async ({ canvasElement }) => {
|
|
201
|
+
const rows = () => canvasElement.querySelectorAll('[data-slot="variant-explorer-row"]').length;
|
|
202
|
+
await waitFor(() => expect(rows()).toBeGreaterThan(0));
|
|
203
|
+
const initial = rows();
|
|
204
|
+
await expect(initial).toBeLessThan(60);
|
|
205
|
+
|
|
206
|
+
const viewport = canvasElement.querySelector<HTMLElement>(
|
|
207
|
+
'[data-slot="variant-explorer-viewport"]',
|
|
208
|
+
)!;
|
|
209
|
+
for (const top of [8_000, 40_000, 79_000]) {
|
|
210
|
+
viewport.scrollTop = top;
|
|
211
|
+
await waitFor(() =>
|
|
212
|
+
expect(
|
|
213
|
+
canvasElement.querySelector(`[data-index="${Math.floor(top / 40) + 1}"]`),
|
|
214
|
+
).not.toBeNull(),
|
|
215
|
+
);
|
|
216
|
+
await expect(rows()).toBeLessThan(60);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
/** Decoration dial at 10 — chips and bars are marks inside a control-like row, so they stay put. */
|
|
222
|
+
export const HighDecoration: Story = {
|
|
223
|
+
tags: ["!dev"],
|
|
224
|
+
globals: { decoration: "10" },
|
|
225
|
+
args: { variants: variants.slice(0, 10), coverageTarget: 0.5 },
|
|
226
|
+
};
|