@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
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DottedChart — case rows × time, one dot per event, coloured by activity (RM-059, issue
|
|
5
|
+
* #208, analysis §4 R14; bupaR's `dotted_chart` is the reference).
|
|
6
|
+
*
|
|
7
|
+
* ## It composes; it never draws a primitive of its own
|
|
8
|
+
*
|
|
9
|
+
* The card, toolbar and CSV download are `@elabs-ai/components-charts`' `ChartFrame`; the
|
|
10
|
+
* marks are RM-046's `CanvasLayer` (one canvas for up to ~100k dots, a single keyboard
|
|
11
|
+
* cursor, a spatial-grid hit test, a parallel accessible summary); the legend is the charts
|
|
12
|
+
* `ChartLegend`; the accessible twin is `@elabs-ai/components-ui`'s `Table`. The only thing
|
|
13
|
+
* authored here is the `draw` callback that paints dots into the canvas the layer owns.
|
|
14
|
+
*
|
|
15
|
+
* ## Colour is never the only channel
|
|
16
|
+
*
|
|
17
|
+
* Activities take their hue from the SAME `ActivityColorScale` `ProcessMap` and
|
|
18
|
+
* `VariantExplorer` read, so an activity is one colour across the explorer. Every hue is
|
|
19
|
+
* also named: the legend prints each label, the tooltip and the keyboard cursor speak the
|
|
20
|
+
* activity, and `tableView` lists every case as text. The shared "other" bucket is drawn as
|
|
21
|
+
* a hollow ring rather than a filled dot, so it stays distinguishable in greyscale.
|
|
22
|
+
*
|
|
23
|
+
* ## It emits; it never filters
|
|
24
|
+
*
|
|
25
|
+
* Click a dot (or press Enter on a focused row) → `onSelect([caseId])`. Drag a rectangle →
|
|
26
|
+
* `onSelect(ids)` and `onFilterIntent({ kind: "cases", ids })` once, on release, with the
|
|
27
|
+
* covered case ids in row order. Shift+Arrow extends a keyboard range the same way; Enter
|
|
28
|
+
* then commits it as the filter intent. The chart itself never calls `filterLog`.
|
|
29
|
+
*/
|
|
30
|
+
import {
|
|
31
|
+
forwardRef,
|
|
32
|
+
useCallback,
|
|
33
|
+
useMemo,
|
|
34
|
+
useRef,
|
|
35
|
+
useState,
|
|
36
|
+
type HTMLAttributes,
|
|
37
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
38
|
+
type PointerEvent as ReactPointerEvent,
|
|
39
|
+
type ReactNode,
|
|
40
|
+
} from "react";
|
|
41
|
+
import {
|
|
42
|
+
CANVAS_LAYER_HIT_RADIUS,
|
|
43
|
+
CanvasLayer,
|
|
44
|
+
CHART_HAIRLINE_WIDTH,
|
|
45
|
+
ChartFrame,
|
|
46
|
+
ChartLegend,
|
|
47
|
+
canvasTokenColor,
|
|
48
|
+
createSpatialGrid,
|
|
49
|
+
type CanvasLayerRect,
|
|
50
|
+
type ChartFrameColumn,
|
|
51
|
+
} from "@elabs-ai/components-charts";
|
|
52
|
+
import {
|
|
53
|
+
StatePanel,
|
|
54
|
+
Table,
|
|
55
|
+
TableBody,
|
|
56
|
+
TableCaption,
|
|
57
|
+
TableCell,
|
|
58
|
+
TableHead,
|
|
59
|
+
TableHeader,
|
|
60
|
+
TableRow,
|
|
61
|
+
useLocale,
|
|
62
|
+
} from "@elabs-ai/components-ui";
|
|
63
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
64
|
+
import { activityColorScale, type ActivityColorScale } from "../core/activity-color-scale";
|
|
65
|
+
import { discoverGraph } from "../core/discover-graph";
|
|
66
|
+
import type { FilterSpec } from "../core/filter-log";
|
|
67
|
+
import type { EventLog, EventRow } from "../core/types";
|
|
68
|
+
import { formatDurationMs } from "../process-map/map-model";
|
|
69
|
+
import { fillLabel } from "../variant-explorer/variant-explorer-model";
|
|
70
|
+
import {
|
|
71
|
+
casesInBrush,
|
|
72
|
+
computeDots,
|
|
73
|
+
rankCategoryColors,
|
|
74
|
+
type DottedChartDot,
|
|
75
|
+
type DottedChartModel,
|
|
76
|
+
type DottedChartRow,
|
|
77
|
+
type DottedChartSort,
|
|
78
|
+
type DottedChartX,
|
|
79
|
+
} from "./compute-dots";
|
|
80
|
+
import { DOTTED_CHART_DEFAULT_LABELS, type DottedChartLabels } from "./dotted-chart-labels";
|
|
81
|
+
import { useElementSize } from "./use-element-size";
|
|
82
|
+
|
|
83
|
+
/** Dot radius in CSS pixels — a categorical marker, not an area encoding. */
|
|
84
|
+
export const DOTTED_CHART_DOT_RADIUS = 2;
|
|
85
|
+
|
|
86
|
+
/** Plot inset in CSS pixels, so a dot on the domain edge is not clipped. */
|
|
87
|
+
const PLOT_INSET = 6;
|
|
88
|
+
/** Opacity of dots outside the current selection. */
|
|
89
|
+
const DIMMED_ALPHA = 0.2;
|
|
90
|
+
/** A pointer drag shorter than this (CSS px) is a click, not a brush. */
|
|
91
|
+
const BRUSH_THRESHOLD = 4;
|
|
92
|
+
/** How many activities the keyboard cursor speaks before summarizing the rest. */
|
|
93
|
+
const SPOKEN_ACTIVITY_LIMIT = 12;
|
|
94
|
+
|
|
95
|
+
const DAY_MS = 86_400_000;
|
|
96
|
+
/** 1970-01-05 00:00 UTC — the first Monday after the epoch. */
|
|
97
|
+
const FIRST_MONDAY_MS = 4 * DAY_MS;
|
|
98
|
+
|
|
99
|
+
/** What a brush (or a committed keyboard range) emits. A subset of `/core`'s `FilterSpec`. */
|
|
100
|
+
export type DottedChartFilterIntent = Extract<FilterSpec, { kind: "cases" }>;
|
|
101
|
+
|
|
102
|
+
/** How dots are coloured. A function returns a CATEGORY KEY, never a colour. */
|
|
103
|
+
export type DottedChartColor = "activity" | "resource" | ((row: EventRow) => string);
|
|
104
|
+
|
|
105
|
+
/** A datum under the cursor: a case row (keyboard) or one event dot (pointer). */
|
|
106
|
+
export type DottedChartDatum = DottedChartRow | DottedChartDot;
|
|
107
|
+
|
|
108
|
+
export interface DottedChartProps extends Omit<
|
|
109
|
+
HTMLAttributes<HTMLDivElement>,
|
|
110
|
+
"onSelect" | "title" | "color"
|
|
111
|
+
> {
|
|
112
|
+
log: EventLog;
|
|
113
|
+
/** How the x axis places an event. @default "absolute" */
|
|
114
|
+
x?: DottedChartX;
|
|
115
|
+
/** How case rows are ordered, top to bottom. @default "start" */
|
|
116
|
+
sort?: DottedChartSort;
|
|
117
|
+
/**
|
|
118
|
+
* What colours a dot. `"activity"` reads `colorScale`; `"resource"` and a function rank
|
|
119
|
+
* their category keys onto the same `--chart-1 …` budget. @default "activity"
|
|
120
|
+
*/
|
|
121
|
+
color?: DottedChartColor;
|
|
122
|
+
/** The shared activity colour scale — hand `ProcessMap` the same instance. Built from `log` when omitted. */
|
|
123
|
+
colorScale?: ActivityColorScale;
|
|
124
|
+
/** Case ids to highlight; every other dot dims. */
|
|
125
|
+
selectedCaseIds?: readonly string[];
|
|
126
|
+
/** Fires with the case ids a click, brush or keyboard range selects. */
|
|
127
|
+
onSelect?: (caseIds: string[]) => void;
|
|
128
|
+
/** Fires once when a brush is released (or a keyboard range committed), with ids in row order. */
|
|
129
|
+
onFilterIntent?: (intent: DottedChartFilterIntent) => void;
|
|
130
|
+
/** Card title — write the conclusion, not "Dotted chart". */
|
|
131
|
+
title?: ReactNode;
|
|
132
|
+
/** Prose under the title. */
|
|
133
|
+
description?: ReactNode;
|
|
134
|
+
/** Body height in CSS pixels (legend + plot + axis). @default 360 */
|
|
135
|
+
height?: number;
|
|
136
|
+
/** Render the accessible table twin instead of the plot. @default false */
|
|
137
|
+
tableView?: boolean;
|
|
138
|
+
/** No log yet. Renders the frame's skeleton. */
|
|
139
|
+
loading?: boolean;
|
|
140
|
+
/** Override any user-visible string. */
|
|
141
|
+
labels?: Partial<DottedChartLabels>;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface CategoryEntry {
|
|
145
|
+
label: string;
|
|
146
|
+
token: string;
|
|
147
|
+
pattern?: "other";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface ColorBuckets {
|
|
151
|
+
/** Legend entries in rank order, with every "other" category collapsed into one. */
|
|
152
|
+
legend: CategoryEntry[];
|
|
153
|
+
/** Per-dot category label (activity / resource / key). */
|
|
154
|
+
dotLabel: string[];
|
|
155
|
+
/** Dot indices grouped by paint token. */
|
|
156
|
+
buckets: { token: string; pattern?: "other"; indices: Int32Array }[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function asEventRow(dot: DottedChartDot): EventRow {
|
|
160
|
+
const { event } = dot;
|
|
161
|
+
const row: EventRow = {
|
|
162
|
+
caseId: dot.caseId,
|
|
163
|
+
activity: event.activity,
|
|
164
|
+
timestamp: event.end,
|
|
165
|
+
startTimestamp: event.start,
|
|
166
|
+
};
|
|
167
|
+
if (event.resource !== undefined) row.resource = event.resource;
|
|
168
|
+
if (event.attributes !== undefined) row.attributes = event.attributes;
|
|
169
|
+
return row;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function buildColorBuckets(
|
|
173
|
+
model: DottedChartModel,
|
|
174
|
+
color: DottedChartColor,
|
|
175
|
+
scale: ActivityColorScale,
|
|
176
|
+
labels: DottedChartLabels,
|
|
177
|
+
): ColorBuckets {
|
|
178
|
+
const dotLabel = new Array<string>(model.dots.length);
|
|
179
|
+
const tokenOf = new Map<string, { token: string; pattern?: "other" }>();
|
|
180
|
+
const legend: CategoryEntry[] = [];
|
|
181
|
+
let hasOther = false;
|
|
182
|
+
|
|
183
|
+
if (color === "activity") {
|
|
184
|
+
for (let i = 0; i < model.dots.length; i += 1) {
|
|
185
|
+
dotLabel[i] = (model.dots[i] as DottedChartDot).event.activity;
|
|
186
|
+
}
|
|
187
|
+
const present = new Set(dotLabel);
|
|
188
|
+
for (const entry of scale.legend) {
|
|
189
|
+
if (!present.has(entry.activityId)) continue;
|
|
190
|
+
tokenOf.set(entry.activityId, scale.colorFor(entry.activityId));
|
|
191
|
+
if (entry.pattern === "other") hasOther = true;
|
|
192
|
+
else legend.push({ label: entry.label, token: entry.token });
|
|
193
|
+
}
|
|
194
|
+
for (const id of present) {
|
|
195
|
+
if (!tokenOf.has(id)) {
|
|
196
|
+
tokenOf.set(id, scale.colorFor(id));
|
|
197
|
+
if (scale.colorFor(id).pattern === "other") hasOther = true;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
for (let i = 0; i < dotLabel.length; i += 1) {
|
|
201
|
+
dotLabel[i] = scale.labelFor(dotLabel[i] as string);
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
const keyOf =
|
|
205
|
+
color === "resource"
|
|
206
|
+
? (dot: DottedChartDot) => dot.event.resource ?? labels.noResource
|
|
207
|
+
: (dot: DottedChartDot) => color(asEventRow(dot));
|
|
208
|
+
for (let i = 0; i < model.dots.length; i += 1) {
|
|
209
|
+
dotLabel[i] = keyOf(model.dots[i] as DottedChartDot);
|
|
210
|
+
}
|
|
211
|
+
for (const category of rankCategoryColors(dotLabel)) {
|
|
212
|
+
tokenOf.set(category.key, { token: category.token, pattern: category.pattern });
|
|
213
|
+
if (category.pattern === "other") hasOther = true;
|
|
214
|
+
else legend.push({ label: category.key, token: category.token });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const grouped = new Map<string, { token: string; pattern?: "other"; indices: number[] }>();
|
|
219
|
+
// Activity mode keys `tokenOf` by activity id, which `dotLabel` no longer holds.
|
|
220
|
+
for (let i = 0; i < model.dots.length; i += 1) {
|
|
221
|
+
const key =
|
|
222
|
+
color === "activity" ? (model.dots[i] as DottedChartDot).event.activity : dotLabel[i];
|
|
223
|
+
const paint = tokenOf.get(key as string) ?? { token: "--chart-12", pattern: "other" as const };
|
|
224
|
+
const bucketKey = `${paint.token}|${paint.pattern ?? ""}`;
|
|
225
|
+
let bucket = grouped.get(bucketKey);
|
|
226
|
+
if (!bucket) {
|
|
227
|
+
bucket = { token: paint.token, pattern: paint.pattern, indices: [] };
|
|
228
|
+
grouped.set(bucketKey, bucket);
|
|
229
|
+
}
|
|
230
|
+
bucket.indices.push(i);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (hasOther) legend.push({ label: labels.other, token: "--chart-12", pattern: "other" });
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
legend,
|
|
237
|
+
dotLabel,
|
|
238
|
+
buckets: [...grouped.values()].map((b) => ({
|
|
239
|
+
token: b.token,
|
|
240
|
+
pattern: b.pattern,
|
|
241
|
+
indices: Int32Array.from(b.indices),
|
|
242
|
+
})),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Evenly spaced axis ticks for the model's domain, as `[value, label]` pairs. */
|
|
247
|
+
function buildTicks(
|
|
248
|
+
model: DottedChartModel,
|
|
249
|
+
innerWidth: number,
|
|
250
|
+
formatDate: (d: Date | number, opts?: Intl.DateTimeFormatOptions) => string,
|
|
251
|
+
): { value: number; label: string }[] {
|
|
252
|
+
const [d0, d1] = model.domain;
|
|
253
|
+
if (model.x === "relative_day") {
|
|
254
|
+
return [0, 6, 12, 18].map((h) => ({
|
|
255
|
+
value: h * 3_600_000,
|
|
256
|
+
label: formatDate(h * 3_600_000, { hour: "2-digit", minute: "2-digit", timeZone: "UTC" }),
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
if (model.x === "relative_week") {
|
|
260
|
+
return Array.from({ length: 7 }, (_, day) => ({
|
|
261
|
+
value: day * DAY_MS,
|
|
262
|
+
label: formatDate(FIRST_MONDAY_MS + day * DAY_MS, { weekday: "short", timeZone: "UTC" }),
|
|
263
|
+
}));
|
|
264
|
+
}
|
|
265
|
+
const count = Math.max(2, Math.min(8, Math.floor(innerWidth / 120)));
|
|
266
|
+
const span = d1 - d0;
|
|
267
|
+
const dateOpts: Intl.DateTimeFormatOptions =
|
|
268
|
+
span > 2 * DAY_MS
|
|
269
|
+
? { month: "short", day: "numeric", timeZone: "UTC" }
|
|
270
|
+
: { hour: "2-digit", minute: "2-digit", timeZone: "UTC" };
|
|
271
|
+
return Array.from({ length: count }, (_, i) => {
|
|
272
|
+
const value = d0 + (span * i) / (count - 1);
|
|
273
|
+
return {
|
|
274
|
+
value,
|
|
275
|
+
label: model.x === "relative" ? formatDurationMs(value) : formatDate(value, dateOpts),
|
|
276
|
+
};
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Stable per-object numbers, so a `drawSignature` can name "this data" cheaply. */
|
|
281
|
+
const objectIds = new WeakMap<object, number>();
|
|
282
|
+
let nextObjectId = 1;
|
|
283
|
+
function objectId(value: object): number {
|
|
284
|
+
let id = objectIds.get(value);
|
|
285
|
+
if (id === undefined) {
|
|
286
|
+
id = nextObjectId;
|
|
287
|
+
nextObjectId += 1;
|
|
288
|
+
objectIds.set(value, id);
|
|
289
|
+
}
|
|
290
|
+
return id;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const EMPTY_IDS: readonly string[] = Object.freeze([]);
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The dotted chart.
|
|
297
|
+
*
|
|
298
|
+
* @example
|
|
299
|
+
* ```tsx
|
|
300
|
+
* const scale = useMemo(() => activityColorScale(fullGraph), [fullGraph]);
|
|
301
|
+
* <DottedChart
|
|
302
|
+
* log={explorer.filteredLog}
|
|
303
|
+
* colorScale={scale}
|
|
304
|
+
* sort="duration"
|
|
305
|
+
* onFilterIntent={explorer.applyIntent}
|
|
306
|
+
* />
|
|
307
|
+
* ```
|
|
308
|
+
*/
|
|
309
|
+
export const DottedChart = forwardRef<HTMLDivElement, DottedChartProps>(function DottedChart(
|
|
310
|
+
{
|
|
311
|
+
log,
|
|
312
|
+
x = "absolute",
|
|
313
|
+
sort = "start",
|
|
314
|
+
color = "activity",
|
|
315
|
+
colorScale,
|
|
316
|
+
selectedCaseIds = EMPTY_IDS,
|
|
317
|
+
onSelect,
|
|
318
|
+
onFilterIntent,
|
|
319
|
+
title,
|
|
320
|
+
description,
|
|
321
|
+
height = 360,
|
|
322
|
+
tableView = false,
|
|
323
|
+
loading = false,
|
|
324
|
+
labels: labelOverrides,
|
|
325
|
+
className,
|
|
326
|
+
...props
|
|
327
|
+
},
|
|
328
|
+
ref,
|
|
329
|
+
) {
|
|
330
|
+
const { locale, formatNumber, formatDate } = useLocale();
|
|
331
|
+
const labels = useMemo<DottedChartLabels>(
|
|
332
|
+
() => ({ ...DOTTED_CHART_DEFAULT_LABELS, ...labelOverrides }),
|
|
333
|
+
[labelOverrides],
|
|
334
|
+
);
|
|
335
|
+
const pluralRules = useMemo(() => new Intl.PluralRules(locale), [locale]);
|
|
336
|
+
const plural = useCallback(
|
|
337
|
+
(count: number, one: string, other: string) =>
|
|
338
|
+
fillLabel(pluralRules.select(count) === "one" ? one : other, {
|
|
339
|
+
count: formatNumber(count),
|
|
340
|
+
}),
|
|
341
|
+
[pluralRules, formatNumber],
|
|
342
|
+
);
|
|
343
|
+
const formatTime = useCallback(
|
|
344
|
+
(ms: number) => formatDate(ms, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" }),
|
|
345
|
+
[formatDate],
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
// ── Model ───────────────────────────────────────────────────────────────
|
|
349
|
+
const model = useMemo(() => computeDots(log, { x, sort }), [log, x, sort]);
|
|
350
|
+
const scale = useMemo(
|
|
351
|
+
() => colorScale ?? activityColorScale(discoverGraph(log)),
|
|
352
|
+
[colorScale, log],
|
|
353
|
+
);
|
|
354
|
+
const colors = useMemo(
|
|
355
|
+
() => buildColorBuckets(model, color, scale, labels),
|
|
356
|
+
[model, color, scale, labels],
|
|
357
|
+
);
|
|
358
|
+
const selected = useMemo(() => new Set(selectedCaseIds), [selectedCaseIds]);
|
|
359
|
+
const eventCount = model.dots.length;
|
|
360
|
+
|
|
361
|
+
// ── Geometry ────────────────────────────────────────────────────────────
|
|
362
|
+
const [plotRef, plotSize] = useElementSize<HTMLDivElement>();
|
|
363
|
+
const { width, height: plotHeight } = plotSize;
|
|
364
|
+
const geometry = useMemo(() => {
|
|
365
|
+
const left = PLOT_INSET;
|
|
366
|
+
const right = Math.max(left + 1, width - PLOT_INSET);
|
|
367
|
+
const top = PLOT_INSET;
|
|
368
|
+
const bottom = Math.max(top + 1, plotHeight - PLOT_INSET);
|
|
369
|
+
const [d0, d1] = model.domain;
|
|
370
|
+
const rowHeight = (bottom - top) / Math.max(1, model.rows.length);
|
|
371
|
+
const xToPx = (value: number) => left + ((value - d0) / (d1 - d0)) * (right - left);
|
|
372
|
+
const pxToX = (px: number) => d0 + ((px - left) / (right - left)) * (d1 - d0);
|
|
373
|
+
const pxToRow = (py: number) =>
|
|
374
|
+
Math.min(model.rows.length - 1, Math.max(0, Math.floor((py - top) / rowHeight)));
|
|
375
|
+
const px = new Float32Array(model.dots.length);
|
|
376
|
+
const py = new Float32Array(model.dots.length);
|
|
377
|
+
const grid = createSpatialGrid<DottedChartDot>(CANVAS_LAYER_HIT_RADIUS);
|
|
378
|
+
for (let i = 0; i < model.dots.length; i += 1) {
|
|
379
|
+
const dot = model.dots[i] as DottedChartDot;
|
|
380
|
+
const dx = xToPx(dot.x);
|
|
381
|
+
const dy = top + (dot.rowIndex + 0.5) * rowHeight;
|
|
382
|
+
px[i] = dx;
|
|
383
|
+
py[i] = dy;
|
|
384
|
+
grid.insert(dx, dy, dot);
|
|
385
|
+
}
|
|
386
|
+
return { left, right, top, bottom, rowHeight, xToPx, pxToX, pxToRow, px, py, grid };
|
|
387
|
+
}, [model, width, plotHeight]);
|
|
388
|
+
|
|
389
|
+
const ticks = useMemo(
|
|
390
|
+
() => buildTicks(model, geometry.right - geometry.left, formatDate),
|
|
391
|
+
[model, geometry, formatDate],
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
// ── Paint ───────────────────────────────────────────────────────────────
|
|
395
|
+
const draw = useCallback(
|
|
396
|
+
(ctx: CanvasRenderingContext2D) => {
|
|
397
|
+
const el = ctx.canvas;
|
|
398
|
+
const { px, py, top, bottom } = geometry;
|
|
399
|
+
|
|
400
|
+
ctx.globalAlpha = 1;
|
|
401
|
+
ctx.lineWidth = CHART_HAIRLINE_WIDTH;
|
|
402
|
+
ctx.strokeStyle = canvasTokenColor("--chart-grid", el, "GrayText");
|
|
403
|
+
ctx.beginPath();
|
|
404
|
+
for (const tick of ticks) {
|
|
405
|
+
const tx = Math.round(geometry.xToPx(tick.value)) + 0.5;
|
|
406
|
+
ctx.moveTo(tx, top);
|
|
407
|
+
ctx.lineTo(tx, bottom);
|
|
408
|
+
}
|
|
409
|
+
ctx.stroke();
|
|
410
|
+
|
|
411
|
+
const dim = selected.size > 0;
|
|
412
|
+
const passes: (boolean | null)[] = dim ? [false, true] : [null];
|
|
413
|
+
for (const pass of passes) {
|
|
414
|
+
ctx.globalAlpha = pass === false ? DIMMED_ALPHA : 1;
|
|
415
|
+
for (const bucket of colors.buckets) {
|
|
416
|
+
const ink = canvasTokenColor(bucket.token, el, "CanvasText");
|
|
417
|
+
ctx.beginPath();
|
|
418
|
+
for (const i of bucket.indices) {
|
|
419
|
+
if (pass !== null) {
|
|
420
|
+
const isSelected = selected.has((model.dots[i] as DottedChartDot).caseId);
|
|
421
|
+
if (isSelected !== pass) continue;
|
|
422
|
+
}
|
|
423
|
+
const cx = px[i] as number;
|
|
424
|
+
const cy = py[i] as number;
|
|
425
|
+
ctx.moveTo(cx + DOTTED_CHART_DOT_RADIUS, cy);
|
|
426
|
+
ctx.arc(cx, cy, DOTTED_CHART_DOT_RADIUS, 0, Math.PI * 2);
|
|
427
|
+
}
|
|
428
|
+
if (bucket.pattern === "other") {
|
|
429
|
+
ctx.lineWidth = 1;
|
|
430
|
+
ctx.strokeStyle = ink;
|
|
431
|
+
ctx.stroke();
|
|
432
|
+
} else {
|
|
433
|
+
ctx.fillStyle = ink;
|
|
434
|
+
ctx.fill();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
ctx.globalAlpha = 1;
|
|
439
|
+
},
|
|
440
|
+
[geometry, ticks, colors, selected, model],
|
|
441
|
+
);
|
|
442
|
+
const drawSignature = `${objectId(geometry)}:${objectId(colors)}:${objectId(selected)}:${objectId(ticks)}`;
|
|
443
|
+
|
|
444
|
+
// ── Text channels ───────────────────────────────────────────────────────
|
|
445
|
+
const rowLabel = useCallback(
|
|
446
|
+
(row: DottedChartRow) => {
|
|
447
|
+
const activities: string[] = [];
|
|
448
|
+
const limit = Math.min(row.dotEnd, row.dotStart + SPOKEN_ACTIVITY_LIMIT);
|
|
449
|
+
for (let d = row.dotStart; d < limit; d += 1) {
|
|
450
|
+
activities.push(colors.dotLabel[d] as string);
|
|
451
|
+
}
|
|
452
|
+
let spoken = activities.join(", ");
|
|
453
|
+
const rest = row.eventCount - activities.length;
|
|
454
|
+
if (rest > 0) spoken = `${spoken}, ${fillLabel(labels.moreActivities, { count: rest })}`;
|
|
455
|
+
let text = fillLabel(labels.row, {
|
|
456
|
+
caseId: row.caseId,
|
|
457
|
+
events: plural(row.eventCount, labels.eventsOne, labels.eventsOther),
|
|
458
|
+
duration: formatDurationMs(row.duration),
|
|
459
|
+
activities: spoken,
|
|
460
|
+
});
|
|
461
|
+
if (selected.has(row.caseId)) text = `${text}, ${labels.selected}`;
|
|
462
|
+
return text;
|
|
463
|
+
},
|
|
464
|
+
[colors, labels, plural, selected],
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
const dotText = useCallback(
|
|
468
|
+
(dot: DottedChartDot) => {
|
|
469
|
+
const base = fillLabel(labels.dot, {
|
|
470
|
+
activity: dot.event.activity,
|
|
471
|
+
time: formatTime(dot.event.start),
|
|
472
|
+
caseId: dot.caseId,
|
|
473
|
+
});
|
|
474
|
+
return dot.event.resource
|
|
475
|
+
? `${base}, ${fillLabel(labels.dotResource, { resource: dot.event.resource })}`
|
|
476
|
+
: base;
|
|
477
|
+
},
|
|
478
|
+
[labels, formatTime],
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
const labelFor = useCallback(
|
|
482
|
+
(datum: DottedChartDatum) => (datum.kind === "row" ? rowLabel(datum) : dotText(datum)),
|
|
483
|
+
[rowLabel, dotText],
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const summary = useMemo(() => {
|
|
487
|
+
const first = model.rows.reduce((m, r) => Math.min(m, r.start), Number.POSITIVE_INFINITY);
|
|
488
|
+
const last = model.rows.reduce((m, r) => Math.max(m, r.end), Number.NEGATIVE_INFINITY);
|
|
489
|
+
const sortLabel = {
|
|
490
|
+
start: labels.sortStart,
|
|
491
|
+
end: labels.sortEnd,
|
|
492
|
+
duration: labels.sortDuration,
|
|
493
|
+
start_day: labels.sortStartDay,
|
|
494
|
+
}[sort];
|
|
495
|
+
const colorBy =
|
|
496
|
+
color === "activity"
|
|
497
|
+
? labels.colorByActivity
|
|
498
|
+
: color === "resource"
|
|
499
|
+
? labels.colorByResource
|
|
500
|
+
: labels.colorByCustom;
|
|
501
|
+
return fillLabel(labels.summary, {
|
|
502
|
+
cases: plural(model.rows.length, labels.casesOne, labels.casesOther),
|
|
503
|
+
events: plural(eventCount, labels.eventsOne, labels.eventsOther),
|
|
504
|
+
from: Number.isFinite(first) ? formatTime(first) : "—",
|
|
505
|
+
to: Number.isFinite(last) ? formatTime(last) : "—",
|
|
506
|
+
sort: sortLabel,
|
|
507
|
+
colorBy,
|
|
508
|
+
});
|
|
509
|
+
}, [model, labels, sort, color, plural, eventCount, formatTime]);
|
|
510
|
+
|
|
511
|
+
// ── Selection: click, brush, keyboard range ─────────────────────────────
|
|
512
|
+
const interactive = Boolean(onSelect || onFilterIntent);
|
|
513
|
+
const suppressClickRef = useRef(false);
|
|
514
|
+
const focusedRowRef = useRef<DottedChartRow | null>(null);
|
|
515
|
+
const anchorRef = useRef<number | null>(null);
|
|
516
|
+
const extendRef = useRef(false);
|
|
517
|
+
const rangeRef = useRef<string[] | null>(null);
|
|
518
|
+
|
|
519
|
+
const handleActivate = useCallback(
|
|
520
|
+
(datum: DottedChartDatum) => {
|
|
521
|
+
if (suppressClickRef.current) {
|
|
522
|
+
suppressClickRef.current = false;
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const range = rangeRef.current;
|
|
526
|
+
if (datum.kind === "row" && range && range.length > 1) {
|
|
527
|
+
onFilterIntent?.({ kind: "cases", ids: range });
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
onSelect?.([datum.caseId]);
|
|
531
|
+
},
|
|
532
|
+
[onSelect, onFilterIntent],
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
const handleFocus = useCallback(
|
|
536
|
+
(datum: DottedChartDatum | null) => {
|
|
537
|
+
if (datum === null || datum.kind !== "row") {
|
|
538
|
+
focusedRowRef.current = null;
|
|
539
|
+
anchorRef.current = null;
|
|
540
|
+
extendRef.current = false;
|
|
541
|
+
rangeRef.current = null;
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
focusedRowRef.current = datum;
|
|
545
|
+
if (extendRef.current && anchorRef.current !== null) {
|
|
546
|
+
extendRef.current = false;
|
|
547
|
+
const lo = Math.min(anchorRef.current, datum.index);
|
|
548
|
+
const hi = Math.max(anchorRef.current, datum.index);
|
|
549
|
+
const ids = model.rows.slice(lo, hi + 1).map((row) => row.caseId);
|
|
550
|
+
rangeRef.current = ids;
|
|
551
|
+
onSelect?.(ids);
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
[model, onSelect],
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
558
|
+
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (event.shiftKey) {
|
|
562
|
+
if (anchorRef.current === null) anchorRef.current = focusedRowRef.current?.index ?? 0;
|
|
563
|
+
extendRef.current = true;
|
|
564
|
+
} else {
|
|
565
|
+
anchorRef.current = null;
|
|
566
|
+
extendRef.current = false;
|
|
567
|
+
rangeRef.current = null;
|
|
568
|
+
}
|
|
569
|
+
}, []);
|
|
570
|
+
|
|
571
|
+
const brushStartRef = useRef<{ x: number; y: number; pointerId: number } | null>(null);
|
|
572
|
+
const [brush, setBrush] = useState<CanvasLayerRect | null>(null);
|
|
573
|
+
|
|
574
|
+
const localPoint = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
575
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
576
|
+
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
580
|
+
suppressClickRef.current = false;
|
|
581
|
+
rangeRef.current = null;
|
|
582
|
+
if (!interactive || event.button !== 0 || model.rows.length === 0) return;
|
|
583
|
+
brushStartRef.current = { ...localPoint(event), pointerId: event.pointerId };
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
587
|
+
const start = brushStartRef.current;
|
|
588
|
+
if (!start || start.pointerId !== event.pointerId) return;
|
|
589
|
+
const point = localPoint(event);
|
|
590
|
+
if (
|
|
591
|
+
!brush &&
|
|
592
|
+
Math.abs(point.x - start.x) < BRUSH_THRESHOLD &&
|
|
593
|
+
Math.abs(point.y - start.y) < BRUSH_THRESHOLD
|
|
594
|
+
) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (!brush) event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
598
|
+
setBrush({
|
|
599
|
+
x: Math.min(start.x, point.x),
|
|
600
|
+
y: Math.min(start.y, point.y),
|
|
601
|
+
width: Math.abs(point.x - start.x),
|
|
602
|
+
height: Math.abs(point.y - start.y),
|
|
603
|
+
});
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const endBrush = (event: ReactPointerEvent<HTMLDivElement>, commit: boolean) => {
|
|
607
|
+
const start = brushStartRef.current;
|
|
608
|
+
brushStartRef.current = null;
|
|
609
|
+
if (!start || !brush) return;
|
|
610
|
+
setBrush(null);
|
|
611
|
+
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
|
|
612
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
613
|
+
}
|
|
614
|
+
if (!commit) return;
|
|
615
|
+
suppressClickRef.current = true;
|
|
616
|
+
const ids = casesInBrush(
|
|
617
|
+
model,
|
|
618
|
+
[geometry.pxToRow(brush.y), geometry.pxToRow(brush.y + brush.height)],
|
|
619
|
+
[geometry.pxToX(brush.x), geometry.pxToX(brush.x + brush.width)],
|
|
620
|
+
);
|
|
621
|
+
if (ids.length === 0) return;
|
|
622
|
+
onSelect?.(ids);
|
|
623
|
+
onFilterIntent?.({ kind: "cases", ids });
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
// ── Table twin + CSV rows ───────────────────────────────────────────────
|
|
627
|
+
const csvColumns = useMemo<ChartFrameColumn[]>(
|
|
628
|
+
() => [
|
|
629
|
+
{ key: "caseId", header: labels.columnCase },
|
|
630
|
+
{ key: "firstActivity", header: labels.columnFirstActivity },
|
|
631
|
+
{ key: "lastActivity", header: labels.columnLastActivity },
|
|
632
|
+
{ key: "start", header: labels.columnStart },
|
|
633
|
+
{ key: "end", header: labels.columnEnd },
|
|
634
|
+
{ key: "durationMs", header: labels.columnDuration },
|
|
635
|
+
{ key: "events", header: labels.columnEvents },
|
|
636
|
+
],
|
|
637
|
+
[labels],
|
|
638
|
+
);
|
|
639
|
+
const csvRows = useMemo(
|
|
640
|
+
() =>
|
|
641
|
+
model.rows.map((row) => ({
|
|
642
|
+
caseId: row.caseId,
|
|
643
|
+
firstActivity: row.firstActivity,
|
|
644
|
+
lastActivity: row.lastActivity,
|
|
645
|
+
start: new Date(row.start).toISOString(),
|
|
646
|
+
end: new Date(row.end).toISOString(),
|
|
647
|
+
durationMs: row.duration,
|
|
648
|
+
events: row.eventCount,
|
|
649
|
+
})),
|
|
650
|
+
[model],
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
const axisLabel = {
|
|
654
|
+
absolute: labels.axisAbsolute,
|
|
655
|
+
relative: labels.axisRelative,
|
|
656
|
+
relative_day: labels.axisDay,
|
|
657
|
+
relative_week: labels.axisWeek,
|
|
658
|
+
}[x];
|
|
659
|
+
|
|
660
|
+
let body: ReactNode;
|
|
661
|
+
if (model.rows.length === 0) {
|
|
662
|
+
body = (
|
|
663
|
+
<StatePanel
|
|
664
|
+
data-slot="dotted-chart-empty"
|
|
665
|
+
kind="empty"
|
|
666
|
+
title={labels.empty}
|
|
667
|
+
titleAs="div"
|
|
668
|
+
description={labels.emptyBody}
|
|
669
|
+
/>
|
|
670
|
+
);
|
|
671
|
+
} else if (tableView) {
|
|
672
|
+
const hasSelection = selected.size > 0;
|
|
673
|
+
body = (
|
|
674
|
+
<div data-slot="dotted-chart-table" className="size-full overflow-auto">
|
|
675
|
+
<Table>
|
|
676
|
+
<TableCaption>{labels.tableCaption}</TableCaption>
|
|
677
|
+
<TableHeader>
|
|
678
|
+
<TableRow>
|
|
679
|
+
<TableHead scope="col">{labels.columnCase}</TableHead>
|
|
680
|
+
<TableHead scope="col">{labels.columnFirstActivity}</TableHead>
|
|
681
|
+
<TableHead scope="col">{labels.columnLastActivity}</TableHead>
|
|
682
|
+
<TableHead scope="col">{labels.columnStart}</TableHead>
|
|
683
|
+
<TableHead scope="col">{labels.columnEnd}</TableHead>
|
|
684
|
+
<TableHead scope="col">{labels.columnDuration}</TableHead>
|
|
685
|
+
<TableHead scope="col">{labels.columnEvents}</TableHead>
|
|
686
|
+
{hasSelection ? <TableHead scope="col">{labels.columnState}</TableHead> : null}
|
|
687
|
+
</TableRow>
|
|
688
|
+
</TableHeader>
|
|
689
|
+
<TableBody>
|
|
690
|
+
{model.rows.map((row) => {
|
|
691
|
+
const isSelected = selected.has(row.caseId);
|
|
692
|
+
return (
|
|
693
|
+
<TableRow key={row.caseId} data-state={isSelected ? "selected" : undefined}>
|
|
694
|
+
<TableCell>{row.caseId}</TableCell>
|
|
695
|
+
<TableCell>{scale.labelFor(row.firstActivity)}</TableCell>
|
|
696
|
+
<TableCell>{scale.labelFor(row.lastActivity)}</TableCell>
|
|
697
|
+
<TableCell className="tabular-nums">{formatTime(row.start)}</TableCell>
|
|
698
|
+
<TableCell className="tabular-nums">{formatTime(row.end)}</TableCell>
|
|
699
|
+
<TableCell className="tabular-nums">{formatDurationMs(row.duration)}</TableCell>
|
|
700
|
+
<TableCell className="tabular-nums">{formatNumber(row.eventCount)}</TableCell>
|
|
701
|
+
{hasSelection ? <TableCell>{isSelected ? labels.selected : ""}</TableCell> : null}
|
|
702
|
+
</TableRow>
|
|
703
|
+
);
|
|
704
|
+
})}
|
|
705
|
+
</TableBody>
|
|
706
|
+
</Table>
|
|
707
|
+
</div>
|
|
708
|
+
);
|
|
709
|
+
} else {
|
|
710
|
+
body = (
|
|
711
|
+
<div data-slot="dotted-chart-body" className="flex size-full min-h-0 flex-col gap-2">
|
|
712
|
+
<div data-slot="dotted-chart-legend" className="flex shrink-0 items-start gap-3">
|
|
713
|
+
<ChartLegend
|
|
714
|
+
className="max-h-16 min-w-0 flex-1 flex-row flex-wrap gap-x-1 gap-y-0 overflow-y-auto"
|
|
715
|
+
itemClassName="px-1.5 py-0.5"
|
|
716
|
+
labelClassName="text-meta"
|
|
717
|
+
showValue={false}
|
|
718
|
+
items={colors.legend.map((entry) => ({
|
|
719
|
+
label: entry.label,
|
|
720
|
+
value: 0,
|
|
721
|
+
color: `var(${entry.token})`,
|
|
722
|
+
}))}
|
|
723
|
+
/>
|
|
724
|
+
{selected.size > 0 ? (
|
|
725
|
+
<span
|
|
726
|
+
data-slot="dotted-chart-selection"
|
|
727
|
+
className="shrink-0 text-meta text-muted-foreground tabular-nums"
|
|
728
|
+
>
|
|
729
|
+
{fillLabel(labels.selectionCount, {
|
|
730
|
+
cases: plural(selected.size, labels.casesOne, labels.casesOther),
|
|
731
|
+
})}
|
|
732
|
+
</span>
|
|
733
|
+
) : null}
|
|
734
|
+
</div>
|
|
735
|
+
<div
|
|
736
|
+
ref={plotRef}
|
|
737
|
+
dir="ltr"
|
|
738
|
+
data-slot="dotted-chart-plot"
|
|
739
|
+
className={cn("relative min-h-0 flex-1", interactive && "touch-none select-none")}
|
|
740
|
+
onKeyDown={handleKeyDown}
|
|
741
|
+
onPointerDown={handlePointerDown}
|
|
742
|
+
onPointerMove={handlePointerMove}
|
|
743
|
+
onPointerUp={(event) => endBrush(event, true)}
|
|
744
|
+
onPointerCancel={(event) => endBrush(event, false)}
|
|
745
|
+
>
|
|
746
|
+
{width > 0 && plotHeight > 0 ? (
|
|
747
|
+
<CanvasLayer<DottedChartDatum>
|
|
748
|
+
points={model.rows}
|
|
749
|
+
draw={draw}
|
|
750
|
+
drawSignature={drawSignature}
|
|
751
|
+
hitTest={(hx, hy) => geometry.grid.query(hx, hy, CANVAS_LAYER_HIT_RADIUS)}
|
|
752
|
+
width={width}
|
|
753
|
+
height={plotHeight}
|
|
754
|
+
labelFor={labelFor}
|
|
755
|
+
focusRect={(datum) =>
|
|
756
|
+
datum.kind === "row"
|
|
757
|
+
? {
|
|
758
|
+
x: geometry.left - 2,
|
|
759
|
+
y: geometry.top + datum.index * geometry.rowHeight - 1,
|
|
760
|
+
width: geometry.right - geometry.left + 4,
|
|
761
|
+
height: Math.max(4, geometry.rowHeight + 2),
|
|
762
|
+
}
|
|
763
|
+
: {
|
|
764
|
+
x: geometry.xToPx(datum.x) - 4,
|
|
765
|
+
y: geometry.top + (datum.rowIndex + 0.5) * geometry.rowHeight - 4,
|
|
766
|
+
width: 8,
|
|
767
|
+
height: 8,
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
renderTooltip={(datum) => (
|
|
771
|
+
<span className="block max-w-64 text-meta">{labelFor(datum)}</span>
|
|
772
|
+
)}
|
|
773
|
+
onDatapointFocus={handleFocus}
|
|
774
|
+
onDatapointActivate={handleActivate}
|
|
775
|
+
accessibleLabel={labels.chart}
|
|
776
|
+
accessibleDescription={summary}
|
|
777
|
+
/>
|
|
778
|
+
) : null}
|
|
779
|
+
{brush ? (
|
|
780
|
+
<div
|
|
781
|
+
aria-hidden="true"
|
|
782
|
+
data-slot="dotted-chart-brush"
|
|
783
|
+
className="pointer-events-none absolute rounded-sm border border-primary bg-primary/10"
|
|
784
|
+
style={{ left: brush.x, top: brush.y, width: brush.width, height: brush.height }}
|
|
785
|
+
/>
|
|
786
|
+
) : null}
|
|
787
|
+
</div>
|
|
788
|
+
<div
|
|
789
|
+
aria-hidden="true"
|
|
790
|
+
dir="ltr"
|
|
791
|
+
data-slot="dotted-chart-axis"
|
|
792
|
+
className="relative h-4 shrink-0 text-meta text-muted-foreground tabular-nums"
|
|
793
|
+
>
|
|
794
|
+
{ticks.map((tick, i) => (
|
|
795
|
+
<span
|
|
796
|
+
key={tick.value}
|
|
797
|
+
className="absolute top-0 whitespace-nowrap"
|
|
798
|
+
style={{
|
|
799
|
+
left: geometry.xToPx(tick.value),
|
|
800
|
+
transform:
|
|
801
|
+
i === 0
|
|
802
|
+
? undefined
|
|
803
|
+
: i === ticks.length - 1 && model.x !== "relative_week"
|
|
804
|
+
? "translateX(-100%)"
|
|
805
|
+
: "translateX(-50%)",
|
|
806
|
+
}}
|
|
807
|
+
>
|
|
808
|
+
{tick.label}
|
|
809
|
+
</span>
|
|
810
|
+
))}
|
|
811
|
+
</div>
|
|
812
|
+
<div
|
|
813
|
+
aria-hidden="true"
|
|
814
|
+
data-slot="dotted-chart-axis-label"
|
|
815
|
+
className="shrink-0 text-center text-meta text-muted-foreground"
|
|
816
|
+
>
|
|
817
|
+
{axisLabel}
|
|
818
|
+
</div>
|
|
819
|
+
</div>
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return (
|
|
824
|
+
<ChartFrame
|
|
825
|
+
ref={ref}
|
|
826
|
+
data-slot="dotted-chart"
|
|
827
|
+
data-view={tableView ? "table" : "plot"}
|
|
828
|
+
title={title}
|
|
829
|
+
description={description}
|
|
830
|
+
data={model.rows.length > 0 ? csvRows : undefined}
|
|
831
|
+
columns={csvColumns}
|
|
832
|
+
features={["expand", "download"]}
|
|
833
|
+
height={height}
|
|
834
|
+
loading={loading}
|
|
835
|
+
className={cn(className)}
|
|
836
|
+
{...props}
|
|
837
|
+
>
|
|
838
|
+
{body}
|
|
839
|
+
</ChartFrame>
|
|
840
|
+
);
|
|
841
|
+
});
|