@elabs-ai/components-process 4.2.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +8 -1
  2. package/dist/core/index.d.ts +801 -3
  3. package/dist/core/index.js +1334 -0
  4. package/dist/core/index.js.map +1 -1
  5. package/dist/index.d.ts +1889 -34
  6. package/dist/index.js +5512 -196
  7. package/dist/index.js.map +1 -1
  8. package/dist/test/index.d.ts +223 -5
  9. package/dist/test/index.js +346 -191
  10. package/dist/test/index.js.map +1 -1
  11. package/package.json +14 -13
  12. package/src/__contract__/case-table.contract.test.tsx +49 -0
  13. package/src/__contract__/compare-kpi-strip.contract.test.tsx +49 -0
  14. package/src/__contract__/conformance-overlay.contract.test.tsx +49 -0
  15. package/src/__contract__/happy-path-editor.contract.test.tsx +49 -0
  16. package/src/__contract__/violation-list.contract.test.tsx +49 -0
  17. package/src/abstraction-controls/abstraction-controls-per-type.test.tsx +80 -0
  18. package/src/abstraction-controls/abstraction-controls.stories.tsx +43 -1
  19. package/src/abstraction-controls/abstraction-controls.tsx +198 -5
  20. package/src/case-table/case-table.stories.tsx +89 -0
  21. package/src/case-table/case-table.test.tsx +148 -0
  22. package/src/case-table/case-table.tsx +144 -0
  23. package/src/case-table/columns.ts +116 -0
  24. package/src/case-table/index.ts +11 -0
  25. package/src/case-timeline/case-timeline-model.test.ts +72 -0
  26. package/src/case-timeline/case-timeline-model.ts +112 -0
  27. package/src/case-timeline/case-timeline.stories.tsx +94 -0
  28. package/src/case-timeline/case-timeline.test.tsx +51 -0
  29. package/src/case-timeline/case-timeline.tsx +109 -0
  30. package/src/case-timeline/index.ts +9 -0
  31. package/src/conformance-overlay/conformance-fixture.ts +59 -0
  32. package/src/conformance-overlay/conformance-legend.tsx +109 -0
  33. package/src/conformance-overlay/conformance-overlay.stories.tsx +116 -0
  34. package/src/conformance-overlay/conformance-overlay.test.tsx +88 -0
  35. package/src/conformance-overlay/conformance-overlay.tsx +107 -0
  36. package/src/conformance-overlay/conformance-state.test.ts +79 -0
  37. package/src/conformance-overlay/conformance-state.ts +220 -0
  38. package/src/conformance-overlay/index.ts +4 -0
  39. package/src/core/activity-color-scale.test.ts +107 -0
  40. package/src/core/activity-color-scale.ts +133 -0
  41. package/src/core/adapters/ocel.test.ts +112 -0
  42. package/src/core/adapters/ocel.ts +359 -0
  43. package/src/core/adapters/xes.test.ts +293 -0
  44. package/src/core/adapters/xes.ts +384 -0
  45. package/src/core/cases-from-log.test.ts +72 -0
  46. package/src/core/cases-from-log.ts +85 -0
  47. package/src/core/conformance.test.ts +80 -0
  48. package/src/core/conformance.ts +91 -0
  49. package/src/core/diff-graphs.test.ts +151 -0
  50. package/src/core/diff-graphs.ts +118 -0
  51. package/src/core/discover-object-centric-graph.test.ts +94 -0
  52. package/src/core/discover-object-centric-graph.ts +296 -0
  53. package/src/core/fixtures/ocel-sample.ts +82 -0
  54. package/src/core/fixtures/sample.xes +68 -0
  55. package/src/core/index.ts +113 -0
  56. package/src/core/reference-model.test.ts +43 -0
  57. package/src/core/reference-model.ts +116 -0
  58. package/src/core/replay-timeline.test.ts +161 -0
  59. package/src/core/replay-timeline.ts +260 -0
  60. package/src/core/segments.test.ts +185 -0
  61. package/src/core/segments.ts +153 -0
  62. package/src/core/token-replay.test.ts +218 -0
  63. package/src/core/token-replay.ts +456 -0
  64. package/src/core/types.ts +2 -2
  65. package/src/dotted-chart/compute-dots.test.ts +176 -0
  66. package/src/dotted-chart/compute-dots.ts +241 -0
  67. package/src/dotted-chart/dotted-chart-labels.ts +93 -0
  68. package/src/dotted-chart/dotted-chart.stories.tsx +182 -0
  69. package/src/dotted-chart/dotted-chart.test.tsx +135 -0
  70. package/src/dotted-chart/dotted-chart.tsx +841 -0
  71. package/src/dotted-chart/index.ts +23 -0
  72. package/src/dotted-chart/use-element-size.ts +33 -0
  73. package/src/happy-path-editor/happy-path-editor-context.ts +81 -0
  74. package/src/happy-path-editor/happy-path-editor.stories.tsx +116 -0
  75. package/src/happy-path-editor/happy-path-editor.test.tsx +142 -0
  76. package/src/happy-path-editor/happy-path-editor.tsx +239 -0
  77. package/src/happy-path-editor/happy-path-step-node.tsx +175 -0
  78. package/src/happy-path-editor/index.ts +4 -0
  79. package/src/index.ts +51 -1
  80. package/src/performance-spectrum/aggregate-segments.test.ts +107 -0
  81. package/src/performance-spectrum/aggregate-segments.ts +174 -0
  82. package/src/performance-spectrum/index.ts +25 -0
  83. package/src/performance-spectrum/performance-spectrum-context.tsx +116 -0
  84. package/src/performance-spectrum/performance-spectrum.stories.tsx +128 -0
  85. package/src/performance-spectrum/performance-spectrum.test.tsx +190 -0
  86. package/src/performance-spectrum/performance-spectrum.tsx +870 -0
  87. package/src/process-compare/compare-kpi-strip.stories.tsx +48 -0
  88. package/src/process-compare/compare-kpi-strip.tsx +94 -0
  89. package/src/process-compare/compare-model.ts +83 -0
  90. package/src/process-compare/compare-side.tsx +42 -0
  91. package/src/process-compare/diff-to-graph.ts +104 -0
  92. package/src/process-compare/index.ts +23 -0
  93. package/src/process-compare/process-compare.stories.tsx +184 -0
  94. package/src/process-compare/process-compare.test.tsx +224 -0
  95. package/src/process-compare/process-compare.tsx +251 -0
  96. package/src/process-explorer.stories.tsx +1 -1
  97. package/src/process-filter-bar/index.ts +2 -0
  98. package/src/process-filter-bar/process-filter-bar.stories.tsx +156 -0
  99. package/src/process-filter-bar/process-filter-bar.test.tsx +201 -0
  100. package/src/process-filter-bar/process-filter-bar.tsx +167 -0
  101. package/src/process-kpi-strip/process-kpi-strip.stories.tsx +47 -0
  102. package/src/process-kpi-strip/process-kpi-strip.test.tsx +67 -0
  103. package/src/process-kpi-strip/process-kpi-strip.tsx +148 -8
  104. package/src/process-map/activity-accent.ts +25 -0
  105. package/src/process-map/index.ts +1 -0
  106. package/src/process-map/map-model.test.ts +16 -0
  107. package/src/process-map/map-model.ts +323 -1
  108. package/src/process-map/object-centric-map.test.tsx +132 -0
  109. package/src/process-map/process-activity-node.tsx +152 -14
  110. package/src/process-map/process-map-object-centric.stories.tsx +219 -0
  111. package/src/process-map/process-map.stories.tsx +64 -0
  112. package/src/process-map/process-map.tsx +240 -16
  113. package/src/process-map/process-transition-edge.test.tsx +47 -0
  114. package/src/process-map/process-transition-edge.tsx +134 -7
  115. package/src/process-map/use-process-layout.ts +30 -9
  116. package/src/process-replay/congestion-heat.tsx +107 -0
  117. package/src/process-replay/index.ts +14 -0
  118. package/src/process-replay/process-replay.stories.tsx +168 -0
  119. package/src/process-replay/process-replay.test.tsx +170 -0
  120. package/src/process-replay/process-replay.tsx +285 -0
  121. package/src/process-replay/replay-controls.tsx +147 -0
  122. package/src/process-replay/replay-format.ts +83 -0
  123. package/src/process-replay/replay-tokens-context.ts +30 -0
  124. package/src/process-replay/use-controllable-value.ts +30 -0
  125. package/src/templates-process-explorer.stories.tsx +1304 -0
  126. package/src/test/contract.test.ts +66 -0
  127. package/src/test/contract.ts +107 -6
  128. package/src/test/doubles.test.tsx +87 -1
  129. package/src/test/doubles.tsx +174 -3
  130. package/src/test/index.ts +25 -1
  131. package/src/use-process-explorer/use-process-explorer.test.ts +44 -0
  132. package/src/use-process-explorer/use-process-explorer.ts +34 -2
  133. package/src/variant-explorer/coverage-bar.tsx +36 -0
  134. package/src/variant-explorer/index.ts +16 -0
  135. package/src/variant-explorer/sequence-chips.tsx +103 -0
  136. package/src/variant-explorer/variant-explorer-model.ts +42 -0
  137. package/src/variant-explorer/variant-explorer.stories.tsx +226 -0
  138. package/src/variant-explorer/variant-explorer.test.tsx +302 -0
  139. package/src/variant-explorer/variant-explorer.tsx +567 -0
  140. package/src/variant-explorer/variant-row.tsx +137 -0
  141. package/src/violation-list/index.ts +2 -0
  142. package/src/violation-list/violation-list.stories.tsx +73 -0
  143. package/src/violation-list/violation-list.test.tsx +84 -0
  144. package/src/violation-list/violation-list.tsx +259 -0
@@ -0,0 +1,175 @@
1
+ "use client";
2
+
3
+ /**
4
+ * HappyPathStepNode — one prescribed step on the happy-path canvas (RM-062).
5
+ *
6
+ * Composes `@elabs-ai/components-flow`'s `FlowNode` (frame, handles, focus treatment) and
7
+ * puts the step's controls in its `footer` slot: the activity (a `Select` over
8
+ * `availableActivities`, or free text), the `optional`/`repeatable` `Switch`es that map
9
+ * one-to-one onto `HappyPathStep`, and a remove button. It holds no state — every control
10
+ * writes straight back through the editor context.
11
+ *
12
+ * A repeatable step shows a repeat glyph and an optional one a skip glyph beside its
13
+ * title, and both words join the node's accessible name, so neither flag is carried by a
14
+ * switch position alone.
15
+ */
16
+ import { useId, useMemo } from "react";
17
+ import { Redo2, Repeat, Trash2 } from "lucide-react";
18
+ import {
19
+ Button,
20
+ Input,
21
+ Label,
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ SelectValue,
27
+ Switch,
28
+ } from "@elabs-ai/components-ui";
29
+ import { FlowNode, type FlowNodeData } from "@elabs-ai/components-flow";
30
+ import type { Node, NodeProps } from "@xyflow/react";
31
+ import type { HappyPathStep } from "../core/reference-model";
32
+ import { fillLabel } from "../variant-explorer/variant-explorer-model";
33
+ import { useHappyPathEditor } from "./happy-path-editor-context";
34
+
35
+ /** `data` carried by a {@link HappyPathStepFlowNode}. */
36
+ export interface HappyPathStepNodeData extends Record<string, unknown> {
37
+ /** Zero-based position in the path. */
38
+ index: number;
39
+ step: HappyPathStep;
40
+ }
41
+
42
+ /** A happy-path step node. Register as `nodeTypes={{ "happy-path-step": … }}`. */
43
+ export type HappyPathStepFlowNode = Node<HappyPathStepNodeData, "happy-path-step">;
44
+
45
+ /** One step of the happy-path editor. */
46
+ export function HappyPathStepNode(props: NodeProps<HappyPathStepFlowNode>) {
47
+ const { index, step } = props.data;
48
+ const { labels, availableActivities, updateStep, removeStep } = useHappyPathEditor();
49
+ const optionalId = useId();
50
+ const repeatableId = useId();
51
+ const n = index + 1;
52
+ const activityName = step.activity || labels.untitled;
53
+
54
+ const options = useMemo(() => {
55
+ if (!availableActivities) return undefined;
56
+ return step.activity && !availableActivities.includes(step.activity)
57
+ ? [step.activity, ...availableActivities]
58
+ : [...availableActivities];
59
+ }, [availableActivities, step.activity]);
60
+
61
+ const flowData = useMemo<FlowNodeData>(
62
+ () => ({
63
+ title: activityName,
64
+ kind: fillLabel(labels.step, { n }),
65
+ icon:
66
+ step.repeatable || step.optional ? (
67
+ <span className="flex items-center gap-0.5">
68
+ {step.optional ? <Redo2 aria-hidden="true" /> : null}
69
+ {step.repeatable ? <Repeat aria-hidden="true" /> : null}
70
+ </span>
71
+ ) : undefined,
72
+ footer: (
73
+ <div
74
+ data-slot="happy-path-step-node-controls"
75
+ // `nodrag nopan`: typing, toggling and clicking here must never pan the canvas
76
+ // or start a node drag.
77
+ className="nodrag nopan flex flex-col gap-2"
78
+ >
79
+ {options ? (
80
+ <Select
81
+ value={step.activity || undefined}
82
+ onValueChange={(activity) => updateStep(index, { activity })}
83
+ >
84
+ <SelectTrigger
85
+ size="sm"
86
+ aria-label={fillLabel(labels.activity, { n })}
87
+ data-slot="happy-path-step-node-activity"
88
+ className="w-full"
89
+ >
90
+ <SelectValue placeholder={labels.activityPlaceholder} />
91
+ </SelectTrigger>
92
+ <SelectContent>
93
+ {options.map((activity) => (
94
+ <SelectItem key={activity} value={activity}>
95
+ {activity}
96
+ </SelectItem>
97
+ ))}
98
+ </SelectContent>
99
+ </Select>
100
+ ) : (
101
+ <Input
102
+ value={step.activity}
103
+ onChange={(event) => updateStep(index, { activity: event.target.value })}
104
+ aria-label={fillLabel(labels.activity, { n })}
105
+ placeholder={labels.activityPlaceholder}
106
+ data-slot="happy-path-step-node-activity"
107
+ className="h-control-sm"
108
+ />
109
+ )}
110
+ <div className="flex items-center justify-between gap-3">
111
+ <Label htmlFor={optionalId} className="text-caption">
112
+ {labels.optional}
113
+ </Label>
114
+ <Switch
115
+ id={optionalId}
116
+ data-slot="happy-path-step-node-optional"
117
+ aria-label={fillLabel(labels.optionalFor, { activity: activityName })}
118
+ checked={step.optional === true}
119
+ onCheckedChange={(optional) => updateStep(index, { optional })}
120
+ />
121
+ </div>
122
+ <div className="flex items-center justify-between gap-3">
123
+ <Label htmlFor={repeatableId} className="text-caption">
124
+ {labels.repeatable}
125
+ </Label>
126
+ <Switch
127
+ id={repeatableId}
128
+ data-slot="happy-path-step-node-repeatable"
129
+ aria-label={fillLabel(labels.repeatableFor, { activity: activityName })}
130
+ checked={step.repeatable === true}
131
+ onCheckedChange={(repeatable) => updateStep(index, { repeatable })}
132
+ />
133
+ </div>
134
+ <Button
135
+ type="button"
136
+ variant="ghost"
137
+ size="sm"
138
+ data-slot="happy-path-step-node-remove"
139
+ aria-label={fillLabel(labels.removeFor, { activity: activityName })}
140
+ className="self-end gap-1.5"
141
+ onClick={() => removeStep(index)}
142
+ >
143
+ <Trash2 aria-hidden="true" className="size-3.5" />
144
+ {labels.remove}
145
+ </Button>
146
+ </div>
147
+ ),
148
+ }),
149
+ [
150
+ activityName,
151
+ labels,
152
+ n,
153
+ index,
154
+ step.activity,
155
+ step.optional,
156
+ step.repeatable,
157
+ options,
158
+ optionalId,
159
+ repeatableId,
160
+ updateStep,
161
+ removeStep,
162
+ ],
163
+ );
164
+
165
+ return (
166
+ <div
167
+ data-slot="happy-path-step-node"
168
+ data-optional={step.optional ? "true" : undefined}
169
+ data-repeatable={step.repeatable ? "true" : undefined}
170
+ className="w-56"
171
+ >
172
+ <FlowNode {...props} type="brand" data={flowData} />
173
+ </div>
174
+ );
175
+ }
@@ -0,0 +1,4 @@
1
+ /** HappyPathEditor (RM-062) — public surface. */
2
+ export * from "./happy-path-editor";
3
+ export * from "./happy-path-editor-context";
4
+ export * from "./happy-path-step-node";
package/src/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * `-charts`, `-data` and `-ui`, and nothing depends on it. The binding rule is
6
6
  * "primitives go down, compositions go up" — a generic edge, mark, table, scale or
7
7
  * control belongs in the base package that owns it, never here. See
8
- * `.claude/rules/process-components.md` and `pnpm process:reuse:check`.
8
+ * `.claude/rules/data.md` ("Process mining" section) and `pnpm check --rule process-reuse`.
9
9
  *
10
10
  * Wave-1 items APPEND their exports at the end of the block below, each under a
11
11
  * `// <Name> — RM-NNN` comment, so concurrent branches merge as appends.
@@ -27,3 +27,53 @@ export * from "./abstraction-controls";
27
27
  export * from "./metric-layer-switch";
28
28
  export * from "./process-kpi-strip";
29
29
  export * from "./use-process-explorer";
30
+
31
+ // VariantExplorer — RM-054
32
+ export * from "./variant-explorer";
33
+ export {
34
+ ACTIVITY_COLOR_SLOTS,
35
+ ACTIVITY_OTHER_TOKEN,
36
+ activityColorScale,
37
+ } from "./core/activity-color-scale";
38
+ export type {
39
+ ActivityColor,
40
+ ActivityColorLegendEntry,
41
+ ActivityColorScale,
42
+ } from "./core/activity-color-scale";
43
+
44
+ // ProcessFilterBar — RM-056
45
+ export * from "./process-filter-bar";
46
+
47
+ // CaseTable / CaseTimeline — RM-055
48
+ export * from "./case-table";
49
+ export * from "./case-timeline";
50
+
51
+ // DottedChart — RM-059
52
+ export * from "./dotted-chart";
53
+
54
+ // PerformanceSpectrum — RM-060
55
+ export * from "./performance-spectrum";
56
+
57
+ // ConformanceOverlay / ViolationList / HappyPathEditor — RM-062
58
+ export * from "./conformance-overlay";
59
+ export * from "./violation-list";
60
+ export * from "./happy-path-editor";
61
+
62
+ // ProcessCompare — RM-064
63
+ export * from "./process-compare";
64
+
65
+ // ProcessReplay — RM-065
66
+ export * from "./process-replay";
67
+
68
+ // Object-centric — RM-066
69
+ export type {
70
+ AbstractedObjectCentricGraph,
71
+ ObjectCentricActivityStats,
72
+ ObjectCentricGraph,
73
+ ObjectTypeActivityCounts,
74
+ } from "./core/discover-object-centric-graph";
75
+ export { OBJECT_TYPE_ABSTRACTION_DEFAULT_LABELS } from "./abstraction-controls/abstraction-controls";
76
+ export type {
77
+ ObjectTypeAbstraction,
78
+ ObjectTypeAbstractionLabels,
79
+ } from "./abstraction-controls/abstraction-controls";
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { segmentKey, type SegmentOccurrence } from "../core/segments";
4
+ import {
5
+ aggregateSegmentBins,
6
+ buildSpectrumRows,
7
+ casesInRange,
8
+ spectrumDomain,
9
+ spectrumTicks,
10
+ } from "./aggregate-segments";
11
+
12
+ const AB = segmentKey("A", "B");
13
+ const BC = segmentKey("B", "C");
14
+
15
+ const occ = (segment: string, caseId: string, start: number, end: number): SegmentOccurrence => ({
16
+ segment,
17
+ caseId,
18
+ start,
19
+ end,
20
+ duration: end - start,
21
+ });
22
+
23
+ const OCCURRENCES: SegmentOccurrence[] = [
24
+ occ(AB, "c2", 20, 40),
25
+ occ(AB, "c1", 0, 10),
26
+ occ(AB, "c3", 30, 60),
27
+ occ(AB, "c4", 35, 75),
28
+ occ(BC, "c1", 10, 100),
29
+ occ(BC, "c2", 40, 50),
30
+ ];
31
+
32
+ describe("buildSpectrumRows", () => {
33
+ const rows = buildSpectrumRows(
34
+ [
35
+ { from: "A", to: "B" },
36
+ { from: "B", to: "C" },
37
+ { from: "A", to: "B" },
38
+ { from: "C", to: "D" },
39
+ ],
40
+ OCCURRENCES,
41
+ );
42
+
43
+ it("keeps one row per distinct definition, in order, including empty ones", () => {
44
+ expect(rows.map((r) => r.key)).toEqual([AB, BC, segmentKey("C", "D")]);
45
+ expect(rows[2]?.lines).toEqual([]);
46
+ expect(rows[2]?.caseCount).toBe(0);
47
+ });
48
+
49
+ it("sorts lines by start and reads quartiles against the row's own distribution", () => {
50
+ const ab = rows[0]!;
51
+ expect(ab.lines.map((l) => [l.caseId, l.duration, l.quartile])).toEqual([
52
+ ["c1", 10, 1],
53
+ ["c2", 20, 2],
54
+ ["c3", 30, 3],
55
+ ["c4", 40, 4],
56
+ ]);
57
+ // B→C has only two samples: 90 is slow FOR B→C even though A→B never saw it.
58
+ expect(rows[1]!.lines.map((l) => [l.caseId, l.quartile])).toEqual([
59
+ ["c1", 4],
60
+ ["c2", 1],
61
+ ]);
62
+ });
63
+
64
+ it("computes case count, median and p90 per row", () => {
65
+ expect(rows[0]).toMatchObject({ caseCount: 4, medianDuration: 25, p90Duration: 37 });
66
+ });
67
+ });
68
+
69
+ describe("aggregateSegmentBins", () => {
70
+ const [ab] = buildSpectrumRows([{ from: "A", to: "B" }], OCCURRENCES);
71
+
72
+ it("buckets by entry time, returns only non-empty bins, with a median quartile", () => {
73
+ expect(aggregateSegmentBins(ab!, 25, 0)).toEqual([
74
+ { segment: AB, start: 0, end: 25, count: 2, medianDuration: 15, quartile: 2 },
75
+ { segment: AB, start: 25, end: 50, count: 2, medianDuration: 35, quartile: 4 },
76
+ ]);
77
+ });
78
+
79
+ it("treats a non-positive bin size as 1 ms rather than looping forever", () => {
80
+ expect(aggregateSegmentBins(ab!, 0, 0)).toHaveLength(4);
81
+ });
82
+ });
83
+
84
+ describe("spectrumDomain / spectrumTicks / casesInRange", () => {
85
+ const rows = buildSpectrumRows(
86
+ [
87
+ { from: "A", to: "B" },
88
+ { from: "B", to: "C" },
89
+ ],
90
+ OCCURRENCES,
91
+ );
92
+
93
+ it("spans every row's first start to last end, widening a degenerate domain", () => {
94
+ expect(spectrumDomain(rows)).toEqual([0, 100]);
95
+ expect(spectrumDomain([])).toEqual([0, 1000]);
96
+ });
97
+
98
+ it("spaces ticks evenly, ends included", () => {
99
+ expect(spectrumTicks([0, 100], 5)).toEqual([0, 25, 50, 75, 100]);
100
+ });
101
+
102
+ it("finds cases whose occurrences overlap the range, in row then time order", () => {
103
+ expect(casesInRange(rows, 45, 55)).toEqual(["c3", "c4", "c1", "c2"]);
104
+ expect(casesInRange(rows, 12, 18)).toEqual(["c1"]);
105
+ expect(casesInRange(rows, 101, 200)).toEqual([]);
106
+ });
107
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The pure model behind `PerformanceSpectrum` — RM-060.
3
+ *
4
+ * Everything here is React-free and unit-tested on its own: grouping occurrences into
5
+ * rows (with each occurrence's quartile read against ITS OWN segment), the aggregated
6
+ * mode's time bins, the shared time domain and ticks, and the brush's case lookup. The
7
+ * component only lays these out and paints them.
8
+ */
9
+ import {
10
+ durationQuartileThresholds,
11
+ quartileOf,
12
+ segmentKey,
13
+ type DurationQuartile,
14
+ type SegmentDefinition,
15
+ type SegmentOccurrence,
16
+ } from "../core/segments";
17
+ import { ascending, quantileSorted } from "../core/scale";
18
+
19
+ /** One occurrence, ready to paint: its quartile is already resolved. */
20
+ export interface SpectrumLine extends SegmentOccurrence {
21
+ quartile: DurationQuartile;
22
+ }
23
+
24
+ /** One `binSize` bucket of one row in aggregated mode. */
25
+ export interface SpectrumBin {
26
+ segment: string;
27
+ /** Bucket start, epoch ms (inclusive). */
28
+ start: number;
29
+ /** Bucket end, epoch ms (exclusive). */
30
+ end: number;
31
+ /** Occurrences entering the segment inside the bucket. */
32
+ count: number;
33
+ /** Median duration of those occurrences, ms. */
34
+ medianDuration: number;
35
+ /** The median of their quartiles, rounded up — the bar's fill. */
36
+ quartile: DurationQuartile;
37
+ }
38
+
39
+ /** One spectrum row: a segment and everything drawn in it. */
40
+ export interface SpectrumRow {
41
+ key: string;
42
+ definition: SegmentDefinition;
43
+ /** Sorted by `start`, then `end`, then `caseId` — the keyboard cursor's walk order. */
44
+ lines: SpectrumLine[];
45
+ /** Distinct cases with at least one occurrence. */
46
+ caseCount: number;
47
+ medianDuration: number;
48
+ p90Duration: number;
49
+ }
50
+
51
+ function compareLines(a: SegmentOccurrence, b: SegmentOccurrence): number {
52
+ return (
53
+ a.start - b.start || a.end - b.end || (a.caseId < b.caseId ? -1 : a.caseId > b.caseId ? 1 : 0)
54
+ );
55
+ }
56
+
57
+ /** Median of an ascending-sorted array's rounded-up middle — for small integer quartiles. */
58
+ function medianQuartile(quartiles: DurationQuartile[]): DurationQuartile {
59
+ const sorted = [...quartiles].sort(ascending);
60
+ return Math.ceil(quantileSorted(sorted, 0.5)) as DurationQuartile;
61
+ }
62
+
63
+ /**
64
+ * Groups `occurrences` into one row per definition in `order` (duplicates dropped),
65
+ * resolving each occurrence's quartile against its own segment's distribution.
66
+ */
67
+ export function buildSpectrumRows(
68
+ order: readonly SegmentDefinition[],
69
+ occurrences: readonly SegmentOccurrence[],
70
+ ): SpectrumRow[] {
71
+ const byKey = new Map<string, SegmentOccurrence[]>();
72
+ for (const occurrence of occurrences) {
73
+ const bucket = byKey.get(occurrence.segment);
74
+ if (bucket) bucket.push(occurrence);
75
+ else byKey.set(occurrence.segment, [occurrence]);
76
+ }
77
+
78
+ const rows: SpectrumRow[] = [];
79
+ const seen = new Set<string>();
80
+ for (const definition of order) {
81
+ const key = segmentKey(definition.from, definition.to);
82
+ if (seen.has(key)) continue;
83
+ seen.add(key);
84
+ const own = byKey.get(key) ?? [];
85
+ const durations = own.map((o) => o.duration).sort(ascending);
86
+ const thresholds = durationQuartileThresholds(durations);
87
+ const lines = own
88
+ .map((o) => ({ ...o, quartile: quartileOf(o.duration, thresholds) }))
89
+ .sort(compareLines);
90
+ const cases = new Set<string>();
91
+ for (const o of own) cases.add(o.caseId);
92
+ rows.push({
93
+ key,
94
+ definition,
95
+ lines,
96
+ caseCount: cases.size,
97
+ medianDuration: durations.length ? quantileSorted(durations, 0.5) : 0,
98
+ p90Duration: durations.length ? quantileSorted(durations, 0.9) : 0,
99
+ });
100
+ }
101
+ return rows;
102
+ }
103
+
104
+ /**
105
+ * The aggregated mode's bars for one row: occurrences bucketed by the time they ENTER the
106
+ * segment, `binSize` ms wide, aligned to `origin`. Only non-empty buckets are returned,
107
+ * in time order, so a sparse year at a one-minute bin costs nothing for the empty minutes.
108
+ */
109
+ export function aggregateSegmentBins(
110
+ row: SpectrumRow,
111
+ binSize: number,
112
+ origin: number,
113
+ ): SpectrumBin[] {
114
+ const size = Number.isFinite(binSize) && binSize > 0 ? binSize : 1;
115
+ const buckets = new Map<number, SpectrumLine[]>();
116
+ for (const line of row.lines) {
117
+ const index = Math.floor((line.start - origin) / size);
118
+ const bucket = buckets.get(index);
119
+ if (bucket) bucket.push(line);
120
+ else buckets.set(index, [line]);
121
+ }
122
+ return [...buckets.keys()].sort(ascending).map((index) => {
123
+ const lines = buckets.get(index) as SpectrumLine[];
124
+ const durations = lines.map((l) => l.duration).sort(ascending);
125
+ return {
126
+ segment: row.key,
127
+ start: origin + index * size,
128
+ end: origin + (index + 1) * size,
129
+ count: lines.length,
130
+ medianDuration: quantileSorted(durations, 0.5),
131
+ quartile: medianQuartile(lines.map((l) => l.quartile)),
132
+ };
133
+ });
134
+ }
135
+
136
+ /**
137
+ * The shared time domain `[min start, max end]` across every row. A degenerate domain
138
+ * (one instant, or nothing at all) is widened by one second so a scale never divides by 0.
139
+ */
140
+ export function spectrumDomain(rows: readonly SpectrumRow[]): [number, number] {
141
+ let lo = Number.POSITIVE_INFINITY;
142
+ let hi = Number.NEGATIVE_INFINITY;
143
+ for (const row of rows) {
144
+ for (const line of row.lines) {
145
+ if (line.start < lo) lo = line.start;
146
+ if (line.end > hi) hi = line.end;
147
+ }
148
+ }
149
+ if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [0, 1000];
150
+ return hi > lo ? [lo, hi] : [lo, lo + 1000];
151
+ }
152
+
153
+ /** `count` evenly spaced tick instants across `domain`, both ends included. */
154
+ export function spectrumTicks(domain: readonly [number, number], count = 5): number[] {
155
+ const n = Math.max(2, Math.floor(count));
156
+ const [lo, hi] = domain;
157
+ return Array.from({ length: n }, (_, i) => lo + ((hi - lo) * i) / (n - 1));
158
+ }
159
+
160
+ /**
161
+ * Case ids with at least one occurrence overlapping `[from, to]` (inclusive), in the order
162
+ * they are first met walking rows top to bottom and each row in time order.
163
+ */
164
+ export function casesInRange(rows: readonly SpectrumRow[], from: number, to: number): string[] {
165
+ const lo = Math.min(from, to);
166
+ const hi = Math.max(from, to);
167
+ const ids = new Set<string>();
168
+ for (const row of rows) {
169
+ for (const line of row.lines) {
170
+ if (line.start <= hi && line.end >= lo) ids.add(line.caseId);
171
+ }
172
+ }
173
+ return [...ids];
174
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * PerformanceSpectrum (RM-060) — public surface: the view, its labels, and the pure row /
3
+ * bin model a host can reuse (e.g. to print the same numbers in a report).
4
+ */
5
+ export {
6
+ PERFORMANCE_SPECTRUM_DEFAULT_BIN_SIZE,
7
+ PERFORMANCE_SPECTRUM_ROW_HEIGHT,
8
+ PERFORMANCE_SPECTRUM_SEGMENT_LIMIT,
9
+ PerformanceSpectrum,
10
+ } from "./performance-spectrum";
11
+ export type {
12
+ PerformanceSpectrumFilterIntent,
13
+ PerformanceSpectrumOrder,
14
+ PerformanceSpectrumProps,
15
+ } from "./performance-spectrum";
16
+ export { PERFORMANCE_SPECTRUM_DEFAULT_LABELS } from "./performance-spectrum-context";
17
+ export type { PerformanceSpectrumLabels } from "./performance-spectrum-context";
18
+ export {
19
+ aggregateSegmentBins,
20
+ buildSpectrumRows,
21
+ casesInRange,
22
+ spectrumDomain,
23
+ spectrumTicks,
24
+ } from "./aggregate-segments";
25
+ export type { SpectrumBin, SpectrumLine, SpectrumRow } from "./aggregate-segments";
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The context `PerformanceSpectrum` shares with its rows — RM-060.
5
+ *
6
+ * Every row paints against the SAME time domain, ticks, ramp and formatters; lifting
7
+ * them into one provider keeps the row component prop-light and guarantees two rows can
8
+ * never disagree about where an instant sits on the shared x-axis.
9
+ */
10
+ import { createContext, useContext } from "react";
11
+ import type { SpectrumLine } from "./aggregate-segments";
12
+
13
+ /** Every user-visible string `PerformanceSpectrum` renders. `{name}` placeholders fill. */
14
+ export interface PerformanceSpectrumLabels {
15
+ /** Accessible name of the whole view. */
16
+ label: string;
17
+ /** `{from}`, `{to}` — a row's accessible name and default visible label. */
18
+ segment: string;
19
+ /** Appended to a selected row's accessible name. */
20
+ selected: string;
21
+ /** `{count}`, `{cases}`, `{median}`, `{p90}` — a row's parallel summary. */
22
+ rowSummary: string;
23
+ /** `{caseId}`, `{start}`, `{end}`, `{duration}`, `{quartile}` — one line, spoken. */
24
+ occurrence: string;
25
+ /** `{start}`, `{end}`, `{count}`, `{median}`, `{quartile}` — one aggregated bar, spoken. */
26
+ bin: string;
27
+ tooltipSegment: string;
28
+ tooltipStart: string;
29
+ tooltipEnd: string;
30
+ tooltipDuration: string;
31
+ tooltipQuartile: string;
32
+ tooltipCount: string;
33
+ tooltipMedian: string;
34
+ /** `{n}` — a quartile's short name. */
35
+ quartile: string;
36
+ quartileFastest: string;
37
+ quartileSlowest: string;
38
+ /** Heading of the colour key. */
39
+ legend: string;
40
+ /** Accessible name of the time-axis brush track. */
41
+ brush: string;
42
+ brushHint: string;
43
+ /** `{start}`, `{end}`, `{count}` — announced when a range is set. */
44
+ brushRange: string;
45
+ columnSegment: string;
46
+ columnCases: string;
47
+ columnMedian: string;
48
+ columnP90: string;
49
+ tableCaption: string;
50
+ loading: string;
51
+ empty: string;
52
+ emptyBody: string;
53
+ }
54
+
55
+ /** The shipped English labels. */
56
+ export const PERFORMANCE_SPECTRUM_DEFAULT_LABELS: Readonly<PerformanceSpectrumLabels> =
57
+ Object.freeze({
58
+ label: "Performance spectrum",
59
+ segment: "{from} → {to}",
60
+ selected: "selected",
61
+ rowSummary: "{count} occurrences across {cases} cases, median {median}, 90th percentile {p90}",
62
+ occurrence: "Case {caseId}, {start} to {end}, took {duration}, {quartile}",
63
+ bin: "{start} to {end}: {count} cases entered, median {median}, {quartile}",
64
+ tooltipSegment: "Segment",
65
+ tooltipStart: "Start",
66
+ tooltipEnd: "End",
67
+ tooltipDuration: "Duration",
68
+ tooltipQuartile: "Quartile",
69
+ tooltipCount: "Cases entered",
70
+ tooltipMedian: "Median duration",
71
+ quartile: "Quartile {n}",
72
+ quartileFastest: "fastest",
73
+ quartileSlowest: "slowest",
74
+ legend: "Duration quartile, per segment",
75
+ brush: "Time range",
76
+ brushHint:
77
+ "Drag across the axis, or use Shift with the arrow keys, to pick a time range. Press Enter to filter to cases in the range, Escape to clear.",
78
+ brushRange: "{start} to {end}, {count} cases",
79
+ columnSegment: "Segment",
80
+ columnCases: "Cases",
81
+ columnMedian: "Median duration",
82
+ columnP90: "90th percentile duration",
83
+ tableCaption: "Performance spectrum — cases and duration per segment",
84
+ loading: "Loading segment occurrences…",
85
+ empty: "No segment occurrences",
86
+ emptyBody: "None of the chosen segments occur in this log.",
87
+ });
88
+
89
+ /** What every spectrum row reads. */
90
+ export interface PerformanceSpectrumContextValue {
91
+ labels: PerformanceSpectrumLabels;
92
+ mode: "lines" | "aggregated";
93
+ binSize: number;
94
+ domain: readonly [number, number];
95
+ ticks: readonly number[];
96
+ rowHeight: number;
97
+ /** The four quartile inks as `var(--…)` references, quartile 1 first. */
98
+ quartileColors: readonly string[];
99
+ formatInstant: (ms: number) => string;
100
+ formatDuration: (ms: number) => string;
101
+ quartileName: (quartile: number) => string;
102
+ onCaseSelect?: (caseId: string, occurrence: SpectrumLine) => void;
103
+ }
104
+
105
+ const PerformanceSpectrumContext = createContext<PerformanceSpectrumContextValue | null>(null);
106
+
107
+ export const PerformanceSpectrumProvider = PerformanceSpectrumContext.Provider;
108
+
109
+ /** Reads the spectrum context. Throws outside a `PerformanceSpectrum`. */
110
+ export function usePerformanceSpectrum(): PerformanceSpectrumContextValue {
111
+ const value = useContext(PerformanceSpectrumContext);
112
+ if (!value) {
113
+ throw new Error("usePerformanceSpectrum must be used inside a PerformanceSpectrum");
114
+ }
115
+ return value;
116
+ }