@ocelescope/exploration 0.8.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/LICENSE +661 -0
- package/README.md +34 -0
- package/dist/Dashboard-BQ_EzP8N.js +1292 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +29 -0
- package/package.json +38 -0
|
@@ -0,0 +1,1292 @@
|
|
|
1
|
+
import { ActivityPicker, EventAttributePicker, LineChart, ObjectAttributePicker, ObjectPicker, ObjectTypePicker, OcelChart, useCurrentOcel, useOcelQuery } from "@ocelescope/core";
|
|
2
|
+
import { ChartNoAxesCombinedIcon, Clock3Icon, Code2Icon, GitBranchIcon, InfoIcon, PlusIcon, SearchIcon, Settings2Icon, ShapesIcon, SlidersHorizontalIcon, Trash2Icon } from "lucide-react";
|
|
3
|
+
import { ActionIcon, Alert, Badge, Button, Code, Container, Divider, Group, HoverCard, Modal, MultiSelect, NumberInput, Paper, ScrollArea, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput, Textarea, ThemeIcon, Title, Tooltip, UnstyledButton } from "@mantine/core";
|
|
4
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
5
|
+
import { useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { AsyncBoundary, EmptyState, ViewerExportFrame, colorForKey } from "@r4pm/components";
|
|
7
|
+
import { ValueType, useEventAttributes, useObjectAttributes } from "@ocelescope/api-base";
|
|
8
|
+
//#region src/analyses.ts
|
|
9
|
+
const ident = (name) => `"${String(name ?? "").replaceAll("\"", "\"\"")}"`;
|
|
10
|
+
const lit = (value) => `'${String(value ?? "").replaceAll("'", "''")}'`;
|
|
11
|
+
const int = (value, fallback) => Number.isFinite(Number(value)) ? Math.trunc(Number(value)) : fallback;
|
|
12
|
+
const list = (value) => Array.isArray(value) ? value.map(String) : value == null ? [] : [String(value)];
|
|
13
|
+
const activityFilter = (value, column = "activity") => {
|
|
14
|
+
const selected = list(value);
|
|
15
|
+
return selected.length === 0 ? "TRUE" : `${column} IN (${selected.map(lit).join(", ")})`;
|
|
16
|
+
};
|
|
17
|
+
/** Every event-object relation, once, with its event and object described.
|
|
18
|
+
* The joins drop relations whose event or object is missing from the log. */
|
|
19
|
+
const REL = `rel AS (
|
|
20
|
+
SELECT DISTINCT r."ocel:eid" AS eid, r."ocel:oid" AS oid,
|
|
21
|
+
e."ocel:activity" AS activity, e."ocel:timestamp" AS ts, o."ocel:type" AS type
|
|
22
|
+
FROM e2o r JOIN events e USING ("ocel:eid") JOIN objects o USING ("ocel:oid")
|
|
23
|
+
)`;
|
|
24
|
+
const SECONDS = {
|
|
25
|
+
seconds: 1,
|
|
26
|
+
minutes: 60,
|
|
27
|
+
hours: 3600,
|
|
28
|
+
days: 86400
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Value counts, most frequent first, with the tail folded into one bar.
|
|
32
|
+
*
|
|
33
|
+
* `source` selects a single column named `value`, one row per member of the
|
|
34
|
+
* population - so missing values can be counted rather than quietly dropped.
|
|
35
|
+
*/
|
|
36
|
+
const counts = (source, limit = 25) => `WITH raw AS (${source}),
|
|
37
|
+
counted AS (
|
|
38
|
+
SELECT value, count(*) AS count FROM raw WHERE value IS NOT NULL GROUP BY 1
|
|
39
|
+
),
|
|
40
|
+
ranked AS (
|
|
41
|
+
SELECT *, row_number() OVER (ORDER BY count DESC, value) AS rank FROM counted
|
|
42
|
+
)
|
|
43
|
+
SELECT CAST(value AS VARCHAR) AS bucket, count, rank FROM ranked WHERE rank <= ${limit}
|
|
44
|
+
UNION ALL
|
|
45
|
+
SELECT 'Other (' || count(*) || ')', sum(count), ${limit} + 1
|
|
46
|
+
FROM ranked WHERE rank > ${limit} GROUP BY ALL HAVING count(*) > 0
|
|
47
|
+
UNION ALL
|
|
48
|
+
SELECT 'Missing', count(*), ${limit} + 2
|
|
49
|
+
FROM raw WHERE value IS NULL GROUP BY ALL HAVING count(*) > 0
|
|
50
|
+
ORDER BY rank`;
|
|
51
|
+
/**
|
|
52
|
+
* A histogram of `source`'s `value` column.
|
|
53
|
+
*
|
|
54
|
+
* Bin width is Freedman-Diaconis (2·IQR/∛n), with Sturges' rule where the
|
|
55
|
+
* interquartile range is zero. Values beyond Tukey's outer fences are counted
|
|
56
|
+
* in a `<` and a `>` bar instead of stretching the bins, which is what left a
|
|
57
|
+
* long-tailed distribution as one tall bar and a row of empty ones.
|
|
58
|
+
*/
|
|
59
|
+
const histogram = (source) => `WITH raw AS (${source}),
|
|
60
|
+
spread AS (
|
|
61
|
+
SELECT quantile_cont(value, 0.25) AS q1, quantile_cont(value, 0.75) AS q3 FROM raw
|
|
62
|
+
),
|
|
63
|
+
kept AS (
|
|
64
|
+
SELECT value FROM raw, spread
|
|
65
|
+
WHERE q3 = q1 OR value BETWEEN q1 - 3 * (q3 - q1) AND q3 + 3 * (q3 - q1)
|
|
66
|
+
),
|
|
67
|
+
shape AS (
|
|
68
|
+
SELECT count(*) AS n, min(value) AS lo, max(value) AS hi,
|
|
69
|
+
quantile_cont(value, 0.25) AS q1, quantile_cont(value, 0.75) AS q3
|
|
70
|
+
FROM kept
|
|
71
|
+
),
|
|
72
|
+
plan AS (
|
|
73
|
+
SELECT n, lo, hi, (hi - lo) / bins AS width, bins FROM (
|
|
74
|
+
SELECT *, least(200, greatest(1, ceil((hi - lo) / nullif(
|
|
75
|
+
CASE WHEN q3 > q1 THEN 2 * (q3 - q1) / pow(n, 1.0 / 3)
|
|
76
|
+
ELSE (hi - lo) / (ceil(log2(greatest(n, 2))) + 1) END, 0))::BIGINT)) AS bins
|
|
77
|
+
FROM shape
|
|
78
|
+
)
|
|
79
|
+
),
|
|
80
|
+
binned AS (
|
|
81
|
+
SELECT lo + width * least(floor((value - lo) / nullif(width, 0)), bins - 1) AS start, width
|
|
82
|
+
FROM raw, plan WHERE value BETWEEN lo AND hi
|
|
83
|
+
)
|
|
84
|
+
SELECT format('{:.6g} – {:.6g}', start, start + width) AS bucket, count(*) AS count, start
|
|
85
|
+
FROM binned GROUP BY ALL
|
|
86
|
+
UNION ALL
|
|
87
|
+
SELECT format('< {:.6g}', lo), count(*), -1e308 FROM raw, plan WHERE value < lo GROUP BY ALL
|
|
88
|
+
UNION ALL
|
|
89
|
+
SELECT format('> {:.6g}', hi), count(*), 1e308 FROM raw, plan WHERE value > hi GROUP BY ALL
|
|
90
|
+
ORDER BY start`;
|
|
91
|
+
/** Value counts or a histogram, whichever the attribute deserves. */
|
|
92
|
+
const distribution = (source, isNumeric) => isNumeric ? histogram(source) : counts(source);
|
|
93
|
+
const DISTRIBUTION_CHART = {
|
|
94
|
+
x: "bucket",
|
|
95
|
+
y: "count",
|
|
96
|
+
types: ["bar", "pie"]
|
|
97
|
+
};
|
|
98
|
+
const analyses = [
|
|
99
|
+
{
|
|
100
|
+
id: "custom-sql-chart",
|
|
101
|
+
label: "Custom SQL chart",
|
|
102
|
+
category: "Custom",
|
|
103
|
+
question: "Run your own SQL query and choose exactly how its result is plotted.",
|
|
104
|
+
method: "The query runs read-only against the current OCEL's DuckDB tables. Its returned columns are mapped directly to the selected axes and optional series grouping.",
|
|
105
|
+
params: [
|
|
106
|
+
{
|
|
107
|
+
name: "sql",
|
|
108
|
+
label: "SQL query",
|
|
109
|
+
kind: "sql"
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: "plot_type",
|
|
113
|
+
label: "Plot type",
|
|
114
|
+
kind: "choice",
|
|
115
|
+
options: [
|
|
116
|
+
"bar",
|
|
117
|
+
"line",
|
|
118
|
+
"area",
|
|
119
|
+
"scatter",
|
|
120
|
+
"pie"
|
|
121
|
+
],
|
|
122
|
+
default: "bar"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "x",
|
|
126
|
+
label: "X axis",
|
|
127
|
+
kind: "column"
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "y",
|
|
131
|
+
label: "Y axis",
|
|
132
|
+
kind: "columns"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: "series",
|
|
136
|
+
label: "Series (optional)",
|
|
137
|
+
kind: "column",
|
|
138
|
+
required: false
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "bar_mode",
|
|
142
|
+
label: "Bar mode",
|
|
143
|
+
kind: "choice",
|
|
144
|
+
options: ["grouped", "stacked"],
|
|
145
|
+
default: "grouped",
|
|
146
|
+
required: false
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "axis_mode",
|
|
150
|
+
label: "Line axes",
|
|
151
|
+
kind: "choice",
|
|
152
|
+
options: ["shared", "independent"],
|
|
153
|
+
default: "shared",
|
|
154
|
+
required: false
|
|
155
|
+
}
|
|
156
|
+
],
|
|
157
|
+
sql: ({ sql }) => String(sql ?? ""),
|
|
158
|
+
chart: ({ plot_type, x, y, series, bar_mode, axis_mode }) => ({
|
|
159
|
+
type: [
|
|
160
|
+
"bar",
|
|
161
|
+
"line",
|
|
162
|
+
"area",
|
|
163
|
+
"scatter",
|
|
164
|
+
"pie"
|
|
165
|
+
].includes(String(plot_type)) ? String(plot_type) : "bar",
|
|
166
|
+
x: String(x ?? ""),
|
|
167
|
+
y: Array.isArray(y) ? y.map(String) : String(y ?? ""),
|
|
168
|
+
...series && { series: String(series) },
|
|
169
|
+
stacked: bar_mode === "stacked",
|
|
170
|
+
yAxes: axis_mode === "independent" ? "independent" : "shared"
|
|
171
|
+
}),
|
|
172
|
+
view: "custom-chart"
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: "total-object-involvement",
|
|
176
|
+
label: "Total objects per event",
|
|
177
|
+
category: "Relationships",
|
|
178
|
+
question: "How many distinct objects does an event involve, and which activities are the events of?",
|
|
179
|
+
method: "Each event is joined to its unique event–object relations. Events are grouped by their number of distinct objects and activity; the bar height is the number of events in each group.",
|
|
180
|
+
params: [],
|
|
181
|
+
sql: () => `WITH per_event AS (
|
|
182
|
+
SELECT e."ocel:activity" AS activity, count(DISTINCT o."ocel:oid") AS objects
|
|
183
|
+
FROM events e
|
|
184
|
+
LEFT JOIN e2o r USING ("ocel:eid")
|
|
185
|
+
LEFT JOIN objects o USING ("ocel:oid")
|
|
186
|
+
GROUP BY e."ocel:eid", e."ocel:activity"
|
|
187
|
+
)
|
|
188
|
+
SELECT objects, activity, count(*) AS events FROM per_event GROUP BY ALL ORDER BY objects`,
|
|
189
|
+
chart: () => ({
|
|
190
|
+
x: "objects",
|
|
191
|
+
y: "events",
|
|
192
|
+
series: "activity",
|
|
193
|
+
stacked: true,
|
|
194
|
+
colorScope: "activity"
|
|
195
|
+
})
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
id: "object-counts-per-event",
|
|
199
|
+
label: "Objects per type in an event",
|
|
200
|
+
category: "Relationships",
|
|
201
|
+
question: "How many distinct objects of each type take part in an event of an activity?",
|
|
202
|
+
method: "Unique event–object relations are counted per event and object type. The rings show activity, object type, and the exact number of involved objects; segment size is the number of matching events.",
|
|
203
|
+
params: [{
|
|
204
|
+
name: "activities",
|
|
205
|
+
label: "Activities",
|
|
206
|
+
kind: "activities",
|
|
207
|
+
required: false,
|
|
208
|
+
default: []
|
|
209
|
+
}],
|
|
210
|
+
sql: ({ activities }) => `WITH ${REL},
|
|
211
|
+
per_event AS (
|
|
212
|
+
SELECT activity, type, eid, count(DISTINCT oid) AS objects
|
|
213
|
+
FROM rel WHERE ${activityFilter(activities)} GROUP BY ALL
|
|
214
|
+
)
|
|
215
|
+
SELECT activity, type AS object_type, objects, count(*) AS events
|
|
216
|
+
FROM per_event GROUP BY ALL ORDER BY ALL`,
|
|
217
|
+
chart: () => ({
|
|
218
|
+
type: "sunburst",
|
|
219
|
+
types: ["sunburst", "bar"],
|
|
220
|
+
path: [
|
|
221
|
+
"activity",
|
|
222
|
+
"object_type",
|
|
223
|
+
"objects"
|
|
224
|
+
],
|
|
225
|
+
x: "object_type",
|
|
226
|
+
y: "events",
|
|
227
|
+
series: "objects"
|
|
228
|
+
})
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
id: "object-type-combinations",
|
|
232
|
+
label: "Object-type combinations",
|
|
233
|
+
category: "Relationships",
|
|
234
|
+
question: "Which exact sets of object types appear together in an event, and in which activities?",
|
|
235
|
+
method: "For every event, duplicate relations are removed and the involved object types are sorted into an exact set. The most frequent sets become bars, split by the events' activities.",
|
|
236
|
+
params: [{
|
|
237
|
+
name: "activities",
|
|
238
|
+
label: "Activities",
|
|
239
|
+
kind: "activities",
|
|
240
|
+
required: false,
|
|
241
|
+
default: []
|
|
242
|
+
}, {
|
|
243
|
+
name: "limit",
|
|
244
|
+
label: "Combinations",
|
|
245
|
+
kind: "number",
|
|
246
|
+
default: 15,
|
|
247
|
+
min: 1,
|
|
248
|
+
max: 50
|
|
249
|
+
}],
|
|
250
|
+
sql: ({ activities, limit }) => `WITH ${REL},
|
|
251
|
+
sets AS (
|
|
252
|
+
SELECT e."ocel:eid" AS eid, e."ocel:activity" AS activity,
|
|
253
|
+
coalesce(list_sort(list(DISTINCT rel.type)), []::VARCHAR[]) AS types
|
|
254
|
+
FROM events e LEFT JOIN rel ON rel.eid = e."ocel:eid"
|
|
255
|
+
WHERE ${activityFilter(activities, "e.\"ocel:activity\"")}
|
|
256
|
+
GROUP BY ALL
|
|
257
|
+
),
|
|
258
|
+
ranked AS (
|
|
259
|
+
SELECT types, row_number() OVER (ORDER BY count(*) DESC, types) AS rank
|
|
260
|
+
FROM sets GROUP BY types
|
|
261
|
+
)
|
|
262
|
+
SELECT coalesce(nullif(array_to_string(types, ' + '), ''), 'no objects') AS combination,
|
|
263
|
+
activity, count(*) AS events, rank
|
|
264
|
+
FROM sets JOIN ranked USING (types)
|
|
265
|
+
WHERE rank <= ${int(limit, 15)}
|
|
266
|
+
GROUP BY ALL ORDER BY rank DESC`,
|
|
267
|
+
chart: () => ({
|
|
268
|
+
x: "combination",
|
|
269
|
+
y: "events",
|
|
270
|
+
series: "activity",
|
|
271
|
+
stacked: true,
|
|
272
|
+
horizontal: true,
|
|
273
|
+
colorScope: "activity"
|
|
274
|
+
})
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
id: "activity-execution-frequency",
|
|
278
|
+
label: "Activity execution frequency",
|
|
279
|
+
category: "Behaviour",
|
|
280
|
+
question: "How many first, second, third, and later executions of each activity occur for objects of a type?",
|
|
281
|
+
method: "Events are ranked chronologically within every object–activity pair. Each stacked layer counts event–object pairs at that execution number, revealing repeated activity executions directly.",
|
|
282
|
+
params: [{
|
|
283
|
+
name: "object_type",
|
|
284
|
+
label: "Object type",
|
|
285
|
+
kind: "objectType"
|
|
286
|
+
}],
|
|
287
|
+
sql: ({ object_type }) => `WITH ${REL},
|
|
288
|
+
ranked AS (
|
|
289
|
+
SELECT oid, activity,
|
|
290
|
+
row_number() OVER (PARTITION BY oid, activity ORDER BY ts, eid) AS execution
|
|
291
|
+
FROM rel WHERE type = ${lit(object_type)}
|
|
292
|
+
),
|
|
293
|
+
counted AS (
|
|
294
|
+
SELECT activity, execution, count(*) AS pairs
|
|
295
|
+
FROM ranked GROUP BY activity, execution
|
|
296
|
+
)
|
|
297
|
+
SELECT activity, CAST(execution AS VARCHAR) AS execution, pairs
|
|
298
|
+
FROM counted ORDER BY counted.execution, activity`,
|
|
299
|
+
chart: () => ({
|
|
300
|
+
x: "activity",
|
|
301
|
+
y: "pairs",
|
|
302
|
+
series: "execution",
|
|
303
|
+
stacked: true,
|
|
304
|
+
colorScope: "execution"
|
|
305
|
+
})
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
id: "object-activity-execution-distribution",
|
|
309
|
+
label: "Execution counts per object",
|
|
310
|
+
category: "Behaviour",
|
|
311
|
+
question: "For activities that objects repeat, how many objects executed one exactly n times?",
|
|
312
|
+
method: "Unique event–object relations are counted for each object and activity. Activities that never repeat are removed; ring size then represents the number of objects with each exact execution count.",
|
|
313
|
+
params: [{
|
|
314
|
+
name: "object_type",
|
|
315
|
+
label: "Object type",
|
|
316
|
+
kind: "objectType"
|
|
317
|
+
}],
|
|
318
|
+
sql: ({ object_type }) => `WITH ${REL},
|
|
319
|
+
executions AS (
|
|
320
|
+
SELECT oid, activity, count(*) AS runs
|
|
321
|
+
FROM rel WHERE type = ${lit(object_type)} GROUP BY ALL
|
|
322
|
+
),
|
|
323
|
+
repeated AS (
|
|
324
|
+
SELECT * FROM executions QUALIFY max(runs) OVER (PARTITION BY activity) > 1
|
|
325
|
+
)
|
|
326
|
+
SELECT activity, runs AS executions, count(*) AS objects
|
|
327
|
+
FROM repeated GROUP BY ALL ORDER BY ALL`,
|
|
328
|
+
chart: () => ({
|
|
329
|
+
type: "sunburst",
|
|
330
|
+
types: ["sunburst", "bar"],
|
|
331
|
+
path: ["activity", "executions"],
|
|
332
|
+
x: "executions",
|
|
333
|
+
y: "objects",
|
|
334
|
+
series: "activity",
|
|
335
|
+
colorScope: "activity"
|
|
336
|
+
})
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
id: "object-involvement-distribution",
|
|
340
|
+
label: "Objects of one type per event",
|
|
341
|
+
category: "Relationships",
|
|
342
|
+
question: "Across the events of an activity, how much does the number of involved objects of a type vary?",
|
|
343
|
+
method: "For each event of the selected activity, unique related objects of the selected type are counted—including zero. The chart shows the frequency of every resulting count.",
|
|
344
|
+
params: [{
|
|
345
|
+
name: "activity",
|
|
346
|
+
label: "Activity",
|
|
347
|
+
kind: "activity"
|
|
348
|
+
}, {
|
|
349
|
+
name: "object_type",
|
|
350
|
+
label: "Object type",
|
|
351
|
+
kind: "objectType"
|
|
352
|
+
}],
|
|
353
|
+
sql: ({ activity, object_type }) => `WITH ${REL},
|
|
354
|
+
per_event AS (
|
|
355
|
+
SELECT count(DISTINCT rel.oid) AS objects
|
|
356
|
+
FROM events e
|
|
357
|
+
LEFT JOIN rel ON rel.eid = e."ocel:eid" AND rel.type = ${lit(object_type)}
|
|
358
|
+
WHERE e."ocel:activity" = ${lit(activity)}
|
|
359
|
+
GROUP BY e."ocel:eid"
|
|
360
|
+
)
|
|
361
|
+
SELECT objects, count(*) AS events FROM per_event GROUP BY 1 ORDER BY 1`,
|
|
362
|
+
chart: () => ({
|
|
363
|
+
x: "objects",
|
|
364
|
+
y: "events",
|
|
365
|
+
types: ["bar", "pie"]
|
|
366
|
+
})
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
id: "event-attribute-distribution",
|
|
370
|
+
label: "Event attribute distribution",
|
|
371
|
+
category: "Attributes",
|
|
372
|
+
question: "How are an attribute's values distributed over an activity's events?",
|
|
373
|
+
method: "The selected attribute is read once per event of the chosen activity. Numeric values use robust histogram bins; categorical values use frequency counts. Missing values are retained explicitly.",
|
|
374
|
+
params: [{
|
|
375
|
+
name: "activity",
|
|
376
|
+
label: "Activity",
|
|
377
|
+
kind: "activity"
|
|
378
|
+
}, {
|
|
379
|
+
name: "attribute",
|
|
380
|
+
label: "Attribute",
|
|
381
|
+
kind: "eventAttribute"
|
|
382
|
+
}],
|
|
383
|
+
sql: ({ activity, attribute }, numeric) => distribution(`SELECT ${ident(attribute)} AS value FROM events
|
|
384
|
+
WHERE "ocel:activity" = ${lit(activity)}`, numeric(String(attribute))),
|
|
385
|
+
chart: () => DISTRIBUTION_CHART
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
id: "object-attribute-distribution",
|
|
389
|
+
label: "Object attribute distribution",
|
|
390
|
+
category: "Attributes",
|
|
391
|
+
question: "What values does an object attribute hold at the moments objects take part in an activity?",
|
|
392
|
+
method: "For every matching event–object relation, the latest attribute change at or before the event is selected. Those point-in-time values are then binned or counted, with missing values retained.",
|
|
393
|
+
params: [
|
|
394
|
+
{
|
|
395
|
+
name: "activity",
|
|
396
|
+
label: "Activity",
|
|
397
|
+
kind: "activity"
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: "object_type",
|
|
401
|
+
label: "Object type",
|
|
402
|
+
kind: "objectType"
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
name: "attribute",
|
|
406
|
+
label: "Attribute",
|
|
407
|
+
kind: "objectAttribute"
|
|
408
|
+
}
|
|
409
|
+
],
|
|
410
|
+
sql: ({ activity, object_type, attribute }, numeric) => distribution(`WITH ${REL},
|
|
411
|
+
changes AS (
|
|
412
|
+
SELECT "ocel:oid" AS oid, "ocel:timestamp" AS ts, ${ident(attribute)} AS value
|
|
413
|
+
FROM object_changes
|
|
414
|
+
WHERE "ocel:field" = ${lit(attribute)} AND ${ident(attribute)} IS NOT NULL
|
|
415
|
+
),
|
|
416
|
+
pairs AS (
|
|
417
|
+
SELECT oid, ts FROM rel
|
|
418
|
+
WHERE activity = ${lit(activity)} AND type = ${lit(object_type)}
|
|
419
|
+
)
|
|
420
|
+
SELECT changes.value FROM pairs
|
|
421
|
+
ASOF LEFT JOIN changes ON pairs.oid = changes.oid AND pairs.ts >= changes.ts`, numeric(String(attribute))),
|
|
422
|
+
chart: () => DISTRIBUTION_CHART
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
id: "time-between-activities",
|
|
426
|
+
label: "Time between activities",
|
|
427
|
+
category: "Time",
|
|
428
|
+
question: "How long does an object take to get from one activity to the next one?",
|
|
429
|
+
method: "Each object's events are ordered in time and reduced to the two selected activities. Durations are measured only where the source is directly followed by the target in that reduced trace, then robustly binned.",
|
|
430
|
+
params: [
|
|
431
|
+
{
|
|
432
|
+
name: "source",
|
|
433
|
+
label: "From",
|
|
434
|
+
kind: "activity"
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
name: "target",
|
|
438
|
+
label: "To",
|
|
439
|
+
kind: "activity"
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
name: "object_type",
|
|
443
|
+
label: "Object type (optional)",
|
|
444
|
+
kind: "objectType",
|
|
445
|
+
required: false
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
name: "unit",
|
|
449
|
+
label: "Unit",
|
|
450
|
+
kind: "choice",
|
|
451
|
+
options: [
|
|
452
|
+
"hours",
|
|
453
|
+
"days",
|
|
454
|
+
"minutes",
|
|
455
|
+
"seconds"
|
|
456
|
+
]
|
|
457
|
+
}
|
|
458
|
+
],
|
|
459
|
+
sql: ({ source, target, object_type, unit }) => histogram(`WITH ${REL},
|
|
460
|
+
trace AS (
|
|
461
|
+
SELECT oid, activity, ts,
|
|
462
|
+
lead(activity) OVER w AS next_activity, lead(ts) OVER w AS next_ts
|
|
463
|
+
FROM rel
|
|
464
|
+
WHERE ${object_type == null ? "TRUE" : `type = ${lit(object_type)}`}
|
|
465
|
+
AND activity IN (${lit(source)}, ${lit(target)})
|
|
466
|
+
WINDOW w AS (PARTITION BY oid ORDER BY ts, eid)
|
|
467
|
+
)
|
|
468
|
+
SELECT epoch(next_ts - ts) / ${SECONDS[String(unit)] ?? 3600} AS value
|
|
469
|
+
FROM trace WHERE activity = ${lit(source)} AND next_activity = ${lit(target)}`),
|
|
470
|
+
chart: ({ unit }) => ({
|
|
471
|
+
x: "bucket",
|
|
472
|
+
y: "count",
|
|
473
|
+
types: ["bar", "pie"],
|
|
474
|
+
title: `Time in ${unit ?? "hours"}`
|
|
475
|
+
})
|
|
476
|
+
},
|
|
477
|
+
{
|
|
478
|
+
id: "object-attribute-timeline",
|
|
479
|
+
label: "Attribute development of one object",
|
|
480
|
+
category: "Attributes",
|
|
481
|
+
question: "How did one object's attributes change over its lifetime?",
|
|
482
|
+
method: "The object's initial values and change records are ordered on one shared timeline. Each attribute is forward-held until its next change and receives its own y-axis so values with different scales remain legible.",
|
|
483
|
+
params: [{
|
|
484
|
+
name: "object",
|
|
485
|
+
label: "Object",
|
|
486
|
+
kind: "object"
|
|
487
|
+
}],
|
|
488
|
+
sql: ({ object }) => `WITH text AS (
|
|
489
|
+
SELECT "ocel:timestamp" AS time, "ocel:field" AS field,
|
|
490
|
+
CAST(COLUMNS(* EXCLUDE ("ocel:oid", "ocel:timestamp", "ocel:field")) AS VARCHAR)
|
|
491
|
+
FROM object_changes WHERE "ocel:oid" = ${lit(object)}
|
|
492
|
+
),
|
|
493
|
+
born AS (
|
|
494
|
+
SELECT min(e."ocel:timestamp") AS time FROM e2o r
|
|
495
|
+
JOIN events e USING ("ocel:eid") WHERE r."ocel:oid" = ${lit(object)}
|
|
496
|
+
)
|
|
497
|
+
SELECT greatest(time, coalesce((SELECT time FROM born), time)) AS time, attribute, value
|
|
498
|
+
FROM (
|
|
499
|
+
UNPIVOT text ON COLUMNS(* EXCLUDE (time, field)) INTO NAME attribute VALUE value
|
|
500
|
+
) WHERE attribute = field ORDER BY time`,
|
|
501
|
+
chart: () => ({}),
|
|
502
|
+
view: "attribute-changes"
|
|
503
|
+
}
|
|
504
|
+
];
|
|
505
|
+
const findAnalysis = (id) => analyses.find((analysis) => analysis.id === id);
|
|
506
|
+
/** Whether every required parameter has a value. */
|
|
507
|
+
const isConfigured = (analysis, values) => analysis.params.every((param) => param.required === false || hasValue$1(values[param.name]));
|
|
508
|
+
const hasValue$1 = (value) => value != null && (!Array.isArray(value) || value.length > 0);
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/Controls.tsx
|
|
511
|
+
/**
|
|
512
|
+
* The parameter inputs a card is configured with.
|
|
513
|
+
*
|
|
514
|
+
* Anything chosen out of the log - an activity, an object type, an attribute,
|
|
515
|
+
* an object - is one of core's pickers, so choosing here reads the same
|
|
516
|
+
* endpoints and looks the same as on every other page. The rest is Mantine.
|
|
517
|
+
*/
|
|
518
|
+
/** The value types a chart bins; the rest it counts by value. */
|
|
519
|
+
const BINNED = [ValueType.int, ValueType.float];
|
|
520
|
+
/**
|
|
521
|
+
* Whether a card's attribute holds numbers, which is what decides bins versus
|
|
522
|
+
* value counts. A card names one attribute, of its activity or of its object
|
|
523
|
+
* type, so both lists are asked and whichever holds it answers.
|
|
524
|
+
*/
|
|
525
|
+
const useNumeric = (values) => {
|
|
526
|
+
const { id } = useCurrentOcel();
|
|
527
|
+
const activity = values.activity == null ? [] : Array.isArray(values.activity) ? values.activity.map(String) : [String(values.activity)];
|
|
528
|
+
const type = values.object_type == null ? [] : [String(values.object_type)];
|
|
529
|
+
const events = useEventAttributes(id, { names: activity }, { query: { enabled: id != null && activity.length > 0 } });
|
|
530
|
+
const objects = useObjectAttributes(id, { names: type }, { query: { enabled: id != null && type.length > 0 } });
|
|
531
|
+
return (attribute) => [...events.data ?? [], ...objects.data ?? []].some((candidate) => candidate.name === attribute && BINNED.includes(candidate.type));
|
|
532
|
+
};
|
|
533
|
+
const Control = ({ param, values, onChange }) => {
|
|
534
|
+
const value = values[param.name];
|
|
535
|
+
if (param.kind === "number") return /* @__PURE__ */ jsx(NumberInput, {
|
|
536
|
+
size: "xs",
|
|
537
|
+
label: param.label,
|
|
538
|
+
min: param.min,
|
|
539
|
+
max: param.max,
|
|
540
|
+
value: Number(value ?? param.default ?? 0),
|
|
541
|
+
onChange: (next) => onChange(typeof next === "number" ? next : void 0)
|
|
542
|
+
});
|
|
543
|
+
if (param.kind === "activities") {
|
|
544
|
+
const selected = Array.isArray(value) ? value.map(String) : [];
|
|
545
|
+
return /* @__PURE__ */ jsxs(Stack, {
|
|
546
|
+
gap: 4,
|
|
547
|
+
children: [/* @__PURE__ */ jsx(ActivityPicker, {
|
|
548
|
+
label: param.label,
|
|
549
|
+
multiple: true,
|
|
550
|
+
value: selected,
|
|
551
|
+
onChange
|
|
552
|
+
}), /* @__PURE__ */ jsx(Text, {
|
|
553
|
+
size: "xs",
|
|
554
|
+
c: "dimmed",
|
|
555
|
+
children: "Leave empty to include all activities."
|
|
556
|
+
})]
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
if (param.kind === "choice") return /* @__PURE__ */ jsx(Select, {
|
|
560
|
+
size: "xs",
|
|
561
|
+
label: param.label,
|
|
562
|
+
data: [...param.options ?? []],
|
|
563
|
+
value: String(value ?? param.options?.[0] ?? ""),
|
|
564
|
+
onChange: (next) => onChange(next ?? void 0),
|
|
565
|
+
allowDeselect: false
|
|
566
|
+
});
|
|
567
|
+
if (param.kind === "text" || param.kind === "column") return /* @__PURE__ */ jsx(TextInput, {
|
|
568
|
+
label: param.label,
|
|
569
|
+
value: value == null ? "" : String(value),
|
|
570
|
+
onChange: (event) => onChange(event.currentTarget.value || void 0)
|
|
571
|
+
});
|
|
572
|
+
if (param.kind === "sql") return /* @__PURE__ */ jsx(Textarea, {
|
|
573
|
+
label: param.label,
|
|
574
|
+
autosize: true,
|
|
575
|
+
minRows: 6,
|
|
576
|
+
value: value == null ? "" : String(value),
|
|
577
|
+
onChange: (event) => onChange(event.currentTarget.value || void 0),
|
|
578
|
+
styles: { input: {
|
|
579
|
+
fontFamily: "monospace",
|
|
580
|
+
fontSize: 12
|
|
581
|
+
} }
|
|
582
|
+
});
|
|
583
|
+
if (param.kind === "columns") return null;
|
|
584
|
+
const chosen = {
|
|
585
|
+
value: value == null ? null : String(value),
|
|
586
|
+
onChange
|
|
587
|
+
};
|
|
588
|
+
if (param.kind === "object") return /* @__PURE__ */ jsx(ObjectPicker, {
|
|
589
|
+
label: param.label,
|
|
590
|
+
...chosen
|
|
591
|
+
});
|
|
592
|
+
if (param.kind === "eventAttribute") return /* @__PURE__ */ jsx(EventAttributePicker, {
|
|
593
|
+
label: param.label,
|
|
594
|
+
activity: values.activity == null ? void 0 : String(values.activity),
|
|
595
|
+
disabled: values.activity == null,
|
|
596
|
+
...chosen
|
|
597
|
+
});
|
|
598
|
+
if (param.kind === "objectAttribute") return /* @__PURE__ */ jsx(ObjectAttributePicker, {
|
|
599
|
+
label: param.label,
|
|
600
|
+
objectType: values.object_type == null ? void 0 : String(values.object_type),
|
|
601
|
+
disabled: values.object_type == null,
|
|
602
|
+
...chosen
|
|
603
|
+
});
|
|
604
|
+
if (param.kind === "activity") return /* @__PURE__ */ jsx(ActivityPicker, {
|
|
605
|
+
label: param.label,
|
|
606
|
+
...chosen
|
|
607
|
+
});
|
|
608
|
+
if (param.kind === "objectType") return /* @__PURE__ */ jsx(ObjectTypePicker, {
|
|
609
|
+
label: param.label,
|
|
610
|
+
...chosen
|
|
611
|
+
});
|
|
612
|
+
return null;
|
|
613
|
+
};
|
|
614
|
+
//#endregion
|
|
615
|
+
//#region src/Card.tsx
|
|
616
|
+
/** One analysis on the dashboard: its parameters, and the chart they produce. */
|
|
617
|
+
const AnalysisCard = ({ analysis, values, onChange, onRemove, configurationOpened, onConfigure, onCloseConfiguration }) => {
|
|
618
|
+
const numeric = useNumeric(values);
|
|
619
|
+
const configured = isConfigured(analysis, values);
|
|
620
|
+
const setParam = (param, value) => {
|
|
621
|
+
const next = {
|
|
622
|
+
...values,
|
|
623
|
+
[param.name]: value
|
|
624
|
+
};
|
|
625
|
+
if (param.name === "activity" && param.kind === "activity") {
|
|
626
|
+
const dependent = analysis.params.find((candidate) => candidate.kind === "eventAttribute");
|
|
627
|
+
if (dependent) next[dependent.name] = void 0;
|
|
628
|
+
}
|
|
629
|
+
if (param.name === "object_type") {
|
|
630
|
+
const dependent = analysis.params.find((candidate) => candidate.kind === "objectAttribute");
|
|
631
|
+
if (dependent) next[dependent.name] = void 0;
|
|
632
|
+
}
|
|
633
|
+
onChange(next);
|
|
634
|
+
};
|
|
635
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs(Paper, {
|
|
636
|
+
withBorder: true,
|
|
637
|
+
p: "md",
|
|
638
|
+
radius: "lg",
|
|
639
|
+
children: [/* @__PURE__ */ jsxs(Group, {
|
|
640
|
+
align: "flex-start",
|
|
641
|
+
justify: "space-between",
|
|
642
|
+
gap: "md",
|
|
643
|
+
wrap: "nowrap",
|
|
644
|
+
children: [/* @__PURE__ */ jsxs(Group, {
|
|
645
|
+
align: "flex-start",
|
|
646
|
+
gap: "sm",
|
|
647
|
+
wrap: "nowrap",
|
|
648
|
+
children: [/* @__PURE__ */ jsx(ThemeIcon, {
|
|
649
|
+
variant: "light",
|
|
650
|
+
color: "gray",
|
|
651
|
+
radius: "md",
|
|
652
|
+
mt: 1,
|
|
653
|
+
children: /* @__PURE__ */ jsx(ChartNoAxesCombinedIcon, { size: 17 })
|
|
654
|
+
}), /* @__PURE__ */ jsxs(Stack, {
|
|
655
|
+
gap: 4,
|
|
656
|
+
children: [
|
|
657
|
+
/* @__PURE__ */ jsxs(Group, {
|
|
658
|
+
gap: "xs",
|
|
659
|
+
children: [
|
|
660
|
+
/* @__PURE__ */ jsx(Text, {
|
|
661
|
+
fw: 600,
|
|
662
|
+
size: "sm",
|
|
663
|
+
children: analysis.label
|
|
664
|
+
}),
|
|
665
|
+
/* @__PURE__ */ jsx(Badge, {
|
|
666
|
+
variant: "light",
|
|
667
|
+
color: "gray",
|
|
668
|
+
size: "xs",
|
|
669
|
+
children: analysis.category
|
|
670
|
+
}),
|
|
671
|
+
!configured && /* @__PURE__ */ jsx(Badge, {
|
|
672
|
+
variant: "light",
|
|
673
|
+
color: "orange",
|
|
674
|
+
size: "xs",
|
|
675
|
+
children: "Configure first"
|
|
676
|
+
})
|
|
677
|
+
]
|
|
678
|
+
}),
|
|
679
|
+
/* @__PURE__ */ jsx(Text, {
|
|
680
|
+
c: "dimmed",
|
|
681
|
+
size: "xs",
|
|
682
|
+
lineClamp: 2,
|
|
683
|
+
children: analysis.question
|
|
684
|
+
}),
|
|
685
|
+
configured && analysis.params.length > 0 && /* @__PURE__ */ jsx(Group, {
|
|
686
|
+
gap: 4,
|
|
687
|
+
children: summary(analysis, values)
|
|
688
|
+
})
|
|
689
|
+
]
|
|
690
|
+
})]
|
|
691
|
+
}), /* @__PURE__ */ jsxs(Group, {
|
|
692
|
+
gap: 4,
|
|
693
|
+
wrap: "nowrap",
|
|
694
|
+
"data-export-ignore": true,
|
|
695
|
+
children: [
|
|
696
|
+
/* @__PURE__ */ jsxs(HoverCard, {
|
|
697
|
+
width: 340,
|
|
698
|
+
shadow: "md",
|
|
699
|
+
position: "bottom-end",
|
|
700
|
+
openDelay: 200,
|
|
701
|
+
children: [/* @__PURE__ */ jsx(HoverCard.Target, { children: /* @__PURE__ */ jsx(ActionIcon, {
|
|
702
|
+
variant: "subtle",
|
|
703
|
+
color: "gray",
|
|
704
|
+
"aria-label": `About ${analysis.label}`,
|
|
705
|
+
children: /* @__PURE__ */ jsx(InfoIcon, { size: 16 })
|
|
706
|
+
}) }), /* @__PURE__ */ jsx(HoverCard.Dropdown, { children: /* @__PURE__ */ jsxs(Stack, {
|
|
707
|
+
gap: "xs",
|
|
708
|
+
children: [
|
|
709
|
+
/* @__PURE__ */ jsx(Text, {
|
|
710
|
+
fw: 600,
|
|
711
|
+
size: "sm",
|
|
712
|
+
children: "What this shows"
|
|
713
|
+
}),
|
|
714
|
+
/* @__PURE__ */ jsx(Text, {
|
|
715
|
+
size: "sm",
|
|
716
|
+
children: analysis.question
|
|
717
|
+
}),
|
|
718
|
+
/* @__PURE__ */ jsx(Divider, {}),
|
|
719
|
+
/* @__PURE__ */ jsx(Text, {
|
|
720
|
+
fw: 600,
|
|
721
|
+
size: "sm",
|
|
722
|
+
children: "How it is calculated"
|
|
723
|
+
}),
|
|
724
|
+
/* @__PURE__ */ jsx(Text, {
|
|
725
|
+
size: "sm",
|
|
726
|
+
c: "dimmed",
|
|
727
|
+
children: analysis.method
|
|
728
|
+
})
|
|
729
|
+
]
|
|
730
|
+
}) })]
|
|
731
|
+
}),
|
|
732
|
+
analysis.params.length > 0 && /* @__PURE__ */ jsx(Tooltip, {
|
|
733
|
+
label: "Configure",
|
|
734
|
+
children: /* @__PURE__ */ jsx(ActionIcon, {
|
|
735
|
+
variant: configured ? "subtle" : "light",
|
|
736
|
+
color: configured ? "gray" : "blue",
|
|
737
|
+
"aria-label": `Configure ${analysis.label}`,
|
|
738
|
+
onClick: onConfigure,
|
|
739
|
+
children: /* @__PURE__ */ jsx(Settings2Icon, { size: 16 })
|
|
740
|
+
})
|
|
741
|
+
}),
|
|
742
|
+
/* @__PURE__ */ jsx(Tooltip, {
|
|
743
|
+
label: "Remove",
|
|
744
|
+
children: /* @__PURE__ */ jsx(ActionIcon, {
|
|
745
|
+
variant: "subtle",
|
|
746
|
+
color: "gray",
|
|
747
|
+
"aria-label": `Remove ${analysis.label}`,
|
|
748
|
+
onClick: onRemove,
|
|
749
|
+
children: /* @__PURE__ */ jsx(Trash2Icon, { size: 16 })
|
|
750
|
+
})
|
|
751
|
+
})
|
|
752
|
+
]
|
|
753
|
+
})]
|
|
754
|
+
}), /* @__PURE__ */ jsx(ViewerExportFrame, {
|
|
755
|
+
filename: analysis.id,
|
|
756
|
+
style: {
|
|
757
|
+
height: 340,
|
|
758
|
+
marginTop: 12
|
|
759
|
+
},
|
|
760
|
+
children: configured ? analysis.view === "attribute-changes" ? /* @__PURE__ */ jsx(Changes, {
|
|
761
|
+
analysis,
|
|
762
|
+
values
|
|
763
|
+
}) : /* @__PURE__ */ jsx(OcelChart, {
|
|
764
|
+
height: "100%",
|
|
765
|
+
sql: analysis.sql(values, numeric),
|
|
766
|
+
...analysis.chart(values)
|
|
767
|
+
}) : /* @__PURE__ */ jsx(EmptyState, {
|
|
768
|
+
title: "Choose what to analyze",
|
|
769
|
+
description: `Configure ${unset(analysis, values)} to create this visualization.`
|
|
770
|
+
})
|
|
771
|
+
})]
|
|
772
|
+
}), /* @__PURE__ */ jsx(Modal, {
|
|
773
|
+
opened: configurationOpened,
|
|
774
|
+
onClose: onCloseConfiguration,
|
|
775
|
+
title: `Configure ${analysis.label}`,
|
|
776
|
+
size: "lg",
|
|
777
|
+
centered: true,
|
|
778
|
+
children: /* @__PURE__ */ jsxs(Stack, {
|
|
779
|
+
gap: "md",
|
|
780
|
+
children: [
|
|
781
|
+
/* @__PURE__ */ jsx(Divider, {}),
|
|
782
|
+
/* @__PURE__ */ jsx(ScrollArea.Autosize, {
|
|
783
|
+
mah: "62vh",
|
|
784
|
+
type: "auto",
|
|
785
|
+
offsetScrollbars: true,
|
|
786
|
+
children: /* @__PURE__ */ jsx(Stack, {
|
|
787
|
+
gap: "md",
|
|
788
|
+
pr: "xs",
|
|
789
|
+
children: analysis.view === "custom-chart" ? /* @__PURE__ */ jsx(CustomChartControls, {
|
|
790
|
+
values,
|
|
791
|
+
onChange
|
|
792
|
+
}) : analysis.params.map((param) => /* @__PURE__ */ jsx(Control, {
|
|
793
|
+
param,
|
|
794
|
+
values,
|
|
795
|
+
onChange: (value) => setParam(param, value)
|
|
796
|
+
}, param.name))
|
|
797
|
+
})
|
|
798
|
+
}),
|
|
799
|
+
/* @__PURE__ */ jsxs(Group, {
|
|
800
|
+
justify: "space-between",
|
|
801
|
+
children: [/* @__PURE__ */ jsx(Text, {
|
|
802
|
+
size: "xs",
|
|
803
|
+
c: configured ? "teal" : "dimmed",
|
|
804
|
+
children: configured ? "Ready—the visualization is updating with these choices." : `Still needed: ${unset(analysis, values)}.`
|
|
805
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
806
|
+
onClick: onCloseConfiguration,
|
|
807
|
+
children: "Done"
|
|
808
|
+
})]
|
|
809
|
+
})
|
|
810
|
+
]
|
|
811
|
+
})
|
|
812
|
+
})] });
|
|
813
|
+
};
|
|
814
|
+
/** The parameters still without a value, in words. */
|
|
815
|
+
const unset = (analysis, values) => analysis.params.filter((param) => param.required !== false && !hasValue(values[param.name])).map((param) => param.label.toLowerCase()).join(", ");
|
|
816
|
+
const hasValue = (value) => value != null && (!Array.isArray(value) || value.length > 0);
|
|
817
|
+
const summary = (analysis, values) => analysis.params.filter((param) => param.kind !== "sql" && values[param.name] != null).slice(0, 3).map((param) => {
|
|
818
|
+
const value = values[param.name];
|
|
819
|
+
const text = Array.isArray(value) ? value.length === 0 ? "All" : value.length <= 2 ? value.join(", ") : `${value.length} selected` : String(value);
|
|
820
|
+
return /* @__PURE__ */ jsxs(Badge, {
|
|
821
|
+
variant: "outline",
|
|
822
|
+
color: "gray",
|
|
823
|
+
size: "xs",
|
|
824
|
+
children: [
|
|
825
|
+
param.label,
|
|
826
|
+
": ",
|
|
827
|
+
text
|
|
828
|
+
]
|
|
829
|
+
}, param.name);
|
|
830
|
+
});
|
|
831
|
+
const PLOT_TYPES = [
|
|
832
|
+
"bar",
|
|
833
|
+
"line",
|
|
834
|
+
"area",
|
|
835
|
+
"scatter",
|
|
836
|
+
"pie"
|
|
837
|
+
];
|
|
838
|
+
const CustomChartControls = ({ values, onChange }) => {
|
|
839
|
+
const appliedSql = String(values.sql ?? "");
|
|
840
|
+
const [draftSql, setDraftSql] = useState(appliedSql);
|
|
841
|
+
const query = useOcelQuery({
|
|
842
|
+
sql: appliedSql,
|
|
843
|
+
enabled: appliedSql.trim().length > 0
|
|
844
|
+
});
|
|
845
|
+
const columns = (query.data?.columns ?? []).map((column) => ({
|
|
846
|
+
value: column.name,
|
|
847
|
+
label: `${column.name} · ${column.type.toLowerCase()}`
|
|
848
|
+
}));
|
|
849
|
+
const y = Array.isArray(values.y) ? values.y.map(String) : values.y == null ? [] : [String(values.y)];
|
|
850
|
+
const applyQuery = () => {
|
|
851
|
+
const sql = draftSql.trim();
|
|
852
|
+
if (sql === appliedSql) {
|
|
853
|
+
query.refetch();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
onChange({
|
|
857
|
+
...values,
|
|
858
|
+
sql,
|
|
859
|
+
x: void 0,
|
|
860
|
+
y: void 0,
|
|
861
|
+
series: void 0
|
|
862
|
+
});
|
|
863
|
+
};
|
|
864
|
+
return /* @__PURE__ */ jsxs(Stack, {
|
|
865
|
+
gap: "md",
|
|
866
|
+
children: [
|
|
867
|
+
/* @__PURE__ */ jsxs(Stack, {
|
|
868
|
+
gap: 4,
|
|
869
|
+
children: [/* @__PURE__ */ jsx(Textarea, {
|
|
870
|
+
label: "SQL query",
|
|
871
|
+
description: /* @__PURE__ */ jsxs("span", { children: [
|
|
872
|
+
"Available tables: ",
|
|
873
|
+
/* @__PURE__ */ jsx(Code, { children: "events" }),
|
|
874
|
+
", ",
|
|
875
|
+
/* @__PURE__ */ jsx(Code, { children: "objects" }),
|
|
876
|
+
",",
|
|
877
|
+
" ",
|
|
878
|
+
/* @__PURE__ */ jsx(Code, { children: "e2o" }),
|
|
879
|
+
", ",
|
|
880
|
+
/* @__PURE__ */ jsx(Code, { children: "o2o" }),
|
|
881
|
+
", and",
|
|
882
|
+
" ",
|
|
883
|
+
/* @__PURE__ */ jsx(Code, { children: "object_changes" }),
|
|
884
|
+
"."
|
|
885
|
+
] }),
|
|
886
|
+
placeholder: "SELECT \"ocel:activity\" AS activity, count(*) AS events\nFROM events\nGROUP BY 1",
|
|
887
|
+
autosize: true,
|
|
888
|
+
minRows: 6,
|
|
889
|
+
maxRows: 14,
|
|
890
|
+
value: draftSql,
|
|
891
|
+
onChange: (event) => setDraftSql(event.currentTarget.value),
|
|
892
|
+
styles: { input: {
|
|
893
|
+
fontFamily: "monospace",
|
|
894
|
+
fontSize: 12
|
|
895
|
+
} }
|
|
896
|
+
}), /* @__PURE__ */ jsx(Group, {
|
|
897
|
+
justify: "flex-end",
|
|
898
|
+
children: /* @__PURE__ */ jsx(Button, {
|
|
899
|
+
size: "xs",
|
|
900
|
+
variant: "light",
|
|
901
|
+
loading: query.isFetching,
|
|
902
|
+
disabled: draftSql.trim().length === 0,
|
|
903
|
+
onClick: applyQuery,
|
|
904
|
+
children: columns.length > 0 ? "Reload columns" : "Load columns"
|
|
905
|
+
})
|
|
906
|
+
})]
|
|
907
|
+
}),
|
|
908
|
+
query.isError && /* @__PURE__ */ jsx(Alert, {
|
|
909
|
+
color: "red",
|
|
910
|
+
title: "The query could not be loaded",
|
|
911
|
+
children: query.error instanceof Error ? query.error.message : "Check the SQL statement and try again."
|
|
912
|
+
}),
|
|
913
|
+
columns.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
914
|
+
/* @__PURE__ */ jsx(Divider, {
|
|
915
|
+
label: "Plot",
|
|
916
|
+
labelPosition: "left"
|
|
917
|
+
}),
|
|
918
|
+
/* @__PURE__ */ jsx(Select, {
|
|
919
|
+
label: "Plot type",
|
|
920
|
+
data: PLOT_TYPES,
|
|
921
|
+
value: String(values.plot_type ?? "bar"),
|
|
922
|
+
allowDeselect: false,
|
|
923
|
+
onChange: (plot_type) => onChange({
|
|
924
|
+
...values,
|
|
925
|
+
plot_type: plot_type ?? "bar"
|
|
926
|
+
})
|
|
927
|
+
}),
|
|
928
|
+
/* @__PURE__ */ jsxs(Group, {
|
|
929
|
+
grow: true,
|
|
930
|
+
align: "flex-start",
|
|
931
|
+
children: [/* @__PURE__ */ jsx(Select, {
|
|
932
|
+
label: "X axis",
|
|
933
|
+
data: columns,
|
|
934
|
+
value: values.x == null ? null : String(values.x),
|
|
935
|
+
onChange: (x) => onChange({
|
|
936
|
+
...values,
|
|
937
|
+
x: x ?? void 0
|
|
938
|
+
})
|
|
939
|
+
}), /* @__PURE__ */ jsx(MultiSelect, {
|
|
940
|
+
label: "Y axis",
|
|
941
|
+
data: columns,
|
|
942
|
+
value: y,
|
|
943
|
+
searchable: true,
|
|
944
|
+
onChange: (next) => onChange({
|
|
945
|
+
...values,
|
|
946
|
+
y: next
|
|
947
|
+
})
|
|
948
|
+
})]
|
|
949
|
+
}),
|
|
950
|
+
/* @__PURE__ */ jsx(Select, {
|
|
951
|
+
label: "Series (optional)",
|
|
952
|
+
description: "Split rows into one trace per distinct value.",
|
|
953
|
+
data: columns,
|
|
954
|
+
value: values.series == null ? null : String(values.series),
|
|
955
|
+
clearable: true,
|
|
956
|
+
searchable: true,
|
|
957
|
+
onChange: (series) => onChange({
|
|
958
|
+
...values,
|
|
959
|
+
series: series ?? void 0
|
|
960
|
+
})
|
|
961
|
+
}),
|
|
962
|
+
values.plot_type === "bar" && /* @__PURE__ */ jsx(Select, {
|
|
963
|
+
label: "Bar mode",
|
|
964
|
+
data: ["grouped", "stacked"],
|
|
965
|
+
value: String(values.bar_mode ?? "grouped"),
|
|
966
|
+
allowDeselect: false,
|
|
967
|
+
onChange: (bar_mode) => onChange({
|
|
968
|
+
...values,
|
|
969
|
+
bar_mode: bar_mode ?? "grouped"
|
|
970
|
+
})
|
|
971
|
+
}),
|
|
972
|
+
values.plot_type === "line" && /* @__PURE__ */ jsx(Select, {
|
|
973
|
+
label: "Y-axis mode",
|
|
974
|
+
data: [{
|
|
975
|
+
value: "shared",
|
|
976
|
+
label: "Shared scale"
|
|
977
|
+
}, {
|
|
978
|
+
value: "independent",
|
|
979
|
+
label: "One scale per line"
|
|
980
|
+
}],
|
|
981
|
+
value: String(values.axis_mode ?? "shared"),
|
|
982
|
+
allowDeselect: false,
|
|
983
|
+
onChange: (axis_mode) => onChange({
|
|
984
|
+
...values,
|
|
985
|
+
axis_mode: axis_mode ?? "shared"
|
|
986
|
+
})
|
|
987
|
+
})
|
|
988
|
+
] })
|
|
989
|
+
]
|
|
990
|
+
});
|
|
991
|
+
};
|
|
992
|
+
/**
|
|
993
|
+
* How one object's attributes changed. Every attribute keeps its own scale,
|
|
994
|
+
* but all lines share the timeline and hover in one plot.
|
|
995
|
+
*/
|
|
996
|
+
const Changes = ({ analysis, values }) => {
|
|
997
|
+
const query = useOcelQuery({ sql: analysis.sql(values, () => false) });
|
|
998
|
+
return /* @__PURE__ */ jsx(AsyncBoundary, {
|
|
999
|
+
status: query,
|
|
1000
|
+
isEmpty: (result) => result.rows.length === 0,
|
|
1001
|
+
loadingLabel: "Querying the OCEL…",
|
|
1002
|
+
errorTitle: "The query failed",
|
|
1003
|
+
emptyState: {
|
|
1004
|
+
title: "No changes",
|
|
1005
|
+
description: "This object's attributes never change."
|
|
1006
|
+
},
|
|
1007
|
+
onRetry: () => void query.refetch(),
|
|
1008
|
+
children: (result) => {
|
|
1009
|
+
const rows = result.rows;
|
|
1010
|
+
const times = rows.map((row) => row.time).sort();
|
|
1011
|
+
const lifetime = [times[0], times[times.length - 1]];
|
|
1012
|
+
return /* @__PURE__ */ jsx(LineChart, {
|
|
1013
|
+
rows,
|
|
1014
|
+
x: "time",
|
|
1015
|
+
y: "value",
|
|
1016
|
+
series: "attribute",
|
|
1017
|
+
xRange: lifetime,
|
|
1018
|
+
yAxes: "independent",
|
|
1019
|
+
colorOf: (attribute) => colorForKey("attribute", attribute) ?? "#888888",
|
|
1020
|
+
step: true
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
};
|
|
1025
|
+
//#endregion
|
|
1026
|
+
//#region src/store.ts
|
|
1027
|
+
/** The dashboard a reader has built, kept per OCEL in browser storage. */
|
|
1028
|
+
const key = (ocelId) => `ocelescope:exploration:${ocelId}`;
|
|
1029
|
+
const useCards = (ocelId) => {
|
|
1030
|
+
const [cards, setCards] = useState([]);
|
|
1031
|
+
const [loaded, setLoaded] = useState(false);
|
|
1032
|
+
useEffect(() => {
|
|
1033
|
+
setLoaded(false);
|
|
1034
|
+
setCards(read(ocelId));
|
|
1035
|
+
setLoaded(true);
|
|
1036
|
+
}, [ocelId]);
|
|
1037
|
+
useEffect(() => {
|
|
1038
|
+
if (!loaded) return;
|
|
1039
|
+
try {
|
|
1040
|
+
window.localStorage.setItem(key(ocelId), JSON.stringify(cards));
|
|
1041
|
+
} catch {}
|
|
1042
|
+
}, [
|
|
1043
|
+
cards,
|
|
1044
|
+
loaded,
|
|
1045
|
+
ocelId
|
|
1046
|
+
]);
|
|
1047
|
+
return {
|
|
1048
|
+
cards,
|
|
1049
|
+
add: (analysis) => {
|
|
1050
|
+
const id = globalThis.crypto.randomUUID();
|
|
1051
|
+
setCards((current) => [...current, {
|
|
1052
|
+
id,
|
|
1053
|
+
analysis,
|
|
1054
|
+
values: fixed(analysis)
|
|
1055
|
+
}]);
|
|
1056
|
+
return id;
|
|
1057
|
+
},
|
|
1058
|
+
update: (id, values) => setCards((current) => current.map((card) => card.id === id ? {
|
|
1059
|
+
...card,
|
|
1060
|
+
values
|
|
1061
|
+
} : card)),
|
|
1062
|
+
remove: (id) => setCards((current) => current.filter((card) => card.id !== id))
|
|
1063
|
+
};
|
|
1064
|
+
};
|
|
1065
|
+
/**
|
|
1066
|
+
* A new card starts with nothing chosen: which activity or object type a chart
|
|
1067
|
+
* is about is the reader's question to ask, and guessing it for them either
|
|
1068
|
+
* shows an arbitrary one or, across all of them at once, an unreadable chart.
|
|
1069
|
+
*
|
|
1070
|
+
* Only settings that are not names from the log - a bin count, a time unit -
|
|
1071
|
+
* carry their built-in value.
|
|
1072
|
+
*/
|
|
1073
|
+
const fixed = (analysis) => Object.fromEntries((findAnalysis(analysis)?.params ?? []).map((param) => [param.name, param.default ?? param.options?.[0]]).filter(([, value]) => value != null));
|
|
1074
|
+
const read = (ocelId) => {
|
|
1075
|
+
try {
|
|
1076
|
+
const stored = JSON.parse(window.localStorage.getItem(key(ocelId)) ?? "[]");
|
|
1077
|
+
return Array.isArray(stored) ? stored.filter((card) => findAnalysis(card?.analysis)) : [];
|
|
1078
|
+
} catch {
|
|
1079
|
+
return [];
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
//#endregion
|
|
1083
|
+
//#region src/Dashboard.tsx
|
|
1084
|
+
/** The exploration dashboard: choose a question, configure it, keep the result. */
|
|
1085
|
+
const Dashboard = ({ ocelId }) => {
|
|
1086
|
+
const { cards, add, update, remove } = useCards(ocelId);
|
|
1087
|
+
const [catalogOpen, setCatalogOpen] = useState(false);
|
|
1088
|
+
const [configure, setConfigure] = useState();
|
|
1089
|
+
const addAnalysis = (analysis) => {
|
|
1090
|
+
const id = add(analysis);
|
|
1091
|
+
setCatalogOpen(false);
|
|
1092
|
+
setConfigure(id);
|
|
1093
|
+
};
|
|
1094
|
+
return /* @__PURE__ */ jsxs(Container, {
|
|
1095
|
+
fluid: true,
|
|
1096
|
+
px: {
|
|
1097
|
+
base: "sm",
|
|
1098
|
+
md: "lg"
|
|
1099
|
+
},
|
|
1100
|
+
py: "md",
|
|
1101
|
+
children: [/* @__PURE__ */ jsxs(Stack, {
|
|
1102
|
+
gap: "lg",
|
|
1103
|
+
children: [/* @__PURE__ */ jsxs(Group, {
|
|
1104
|
+
align: "center",
|
|
1105
|
+
justify: "space-between",
|
|
1106
|
+
wrap: "wrap",
|
|
1107
|
+
children: [/* @__PURE__ */ jsx(Stack, {
|
|
1108
|
+
gap: 2,
|
|
1109
|
+
children: /* @__PURE__ */ jsxs(Group, {
|
|
1110
|
+
gap: "xs",
|
|
1111
|
+
children: [/* @__PURE__ */ jsx(Title, {
|
|
1112
|
+
order: 2,
|
|
1113
|
+
children: "Explore the log"
|
|
1114
|
+
}), cards.length > 0 && /* @__PURE__ */ jsxs(Badge, {
|
|
1115
|
+
variant: "light",
|
|
1116
|
+
color: "gray",
|
|
1117
|
+
radius: "sm",
|
|
1118
|
+
children: [
|
|
1119
|
+
cards.length,
|
|
1120
|
+
" ",
|
|
1121
|
+
cards.length === 1 ? "view" : "views"
|
|
1122
|
+
]
|
|
1123
|
+
})]
|
|
1124
|
+
})
|
|
1125
|
+
}), /* @__PURE__ */ jsx(Button, {
|
|
1126
|
+
leftSection: /* @__PURE__ */ jsx(PlusIcon, { size: 16 }),
|
|
1127
|
+
onClick: () => setCatalogOpen(true),
|
|
1128
|
+
children: "Add visualization"
|
|
1129
|
+
})]
|
|
1130
|
+
}), /* @__PURE__ */ jsx(SimpleGrid, {
|
|
1131
|
+
cols: {
|
|
1132
|
+
base: 1,
|
|
1133
|
+
xl: 2
|
|
1134
|
+
},
|
|
1135
|
+
spacing: "md",
|
|
1136
|
+
children: cards.map((card) => {
|
|
1137
|
+
const analysis = findAnalysis(card.analysis);
|
|
1138
|
+
return analysis ? /* @__PURE__ */ jsx(AnalysisCard, {
|
|
1139
|
+
analysis,
|
|
1140
|
+
values: card.values,
|
|
1141
|
+
onChange: (values) => update(card.id, values),
|
|
1142
|
+
onRemove: () => remove(card.id),
|
|
1143
|
+
configurationOpened: configure === card.id,
|
|
1144
|
+
onConfigure: () => setConfigure(card.id),
|
|
1145
|
+
onCloseConfiguration: () => setConfigure(void 0)
|
|
1146
|
+
}, card.id) : null;
|
|
1147
|
+
})
|
|
1148
|
+
})]
|
|
1149
|
+
}), /* @__PURE__ */ jsx(AnalysisCatalog, {
|
|
1150
|
+
opened: catalogOpen,
|
|
1151
|
+
onClose: () => setCatalogOpen(false),
|
|
1152
|
+
onAdd: addAnalysis
|
|
1153
|
+
})]
|
|
1154
|
+
});
|
|
1155
|
+
};
|
|
1156
|
+
const categoryStyle = {
|
|
1157
|
+
Behaviour: {
|
|
1158
|
+
color: "violet",
|
|
1159
|
+
icon: ShapesIcon
|
|
1160
|
+
},
|
|
1161
|
+
Relationships: {
|
|
1162
|
+
color: "blue",
|
|
1163
|
+
icon: GitBranchIcon
|
|
1164
|
+
},
|
|
1165
|
+
Attributes: {
|
|
1166
|
+
color: "teal",
|
|
1167
|
+
icon: SlidersHorizontalIcon
|
|
1168
|
+
},
|
|
1169
|
+
Time: {
|
|
1170
|
+
color: "orange",
|
|
1171
|
+
icon: Clock3Icon
|
|
1172
|
+
},
|
|
1173
|
+
Custom: {
|
|
1174
|
+
color: "cyan",
|
|
1175
|
+
icon: Code2Icon
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
const AnalysisCatalog = ({ opened, onClose, onAdd }) => {
|
|
1179
|
+
const [query, setQuery] = useState("");
|
|
1180
|
+
const [category, setCategory] = useState("All");
|
|
1181
|
+
const categories = [...new Set(analyses.map((analysis) => analysis.category))];
|
|
1182
|
+
const visible = useMemo(() => {
|
|
1183
|
+
const needle = query.trim().toLocaleLowerCase();
|
|
1184
|
+
return analyses.filter((analysis) => (category === "All" || analysis.category === category) && (!needle || `${analysis.label} ${analysis.question}`.toLocaleLowerCase().includes(needle)));
|
|
1185
|
+
}, [category, query]);
|
|
1186
|
+
return /* @__PURE__ */ jsx(Modal, {
|
|
1187
|
+
opened,
|
|
1188
|
+
onClose,
|
|
1189
|
+
title: "Choose a visualization",
|
|
1190
|
+
size: "xl",
|
|
1191
|
+
centered: true,
|
|
1192
|
+
children: /* @__PURE__ */ jsxs(Stack, {
|
|
1193
|
+
gap: "md",
|
|
1194
|
+
children: [
|
|
1195
|
+
/* @__PURE__ */ jsxs(Group, {
|
|
1196
|
+
align: "center",
|
|
1197
|
+
wrap: "wrap",
|
|
1198
|
+
children: [/* @__PURE__ */ jsx(TextInput, {
|
|
1199
|
+
value: query,
|
|
1200
|
+
onChange: (event) => setQuery(event.currentTarget.value),
|
|
1201
|
+
placeholder: "Search analyses",
|
|
1202
|
+
leftSection: /* @__PURE__ */ jsx(SearchIcon, { size: 15 }),
|
|
1203
|
+
style: {
|
|
1204
|
+
flex: 1,
|
|
1205
|
+
minWidth: 220
|
|
1206
|
+
}
|
|
1207
|
+
}), /* @__PURE__ */ jsx(SegmentedControl, {
|
|
1208
|
+
value: category,
|
|
1209
|
+
onChange: setCategory,
|
|
1210
|
+
data: ["All", ...categories]
|
|
1211
|
+
})]
|
|
1212
|
+
}),
|
|
1213
|
+
/* @__PURE__ */ jsx(Divider, {}),
|
|
1214
|
+
/* @__PURE__ */ jsx(ScrollArea.Autosize, {
|
|
1215
|
+
mah: "62vh",
|
|
1216
|
+
type: "auto",
|
|
1217
|
+
offsetScrollbars: true,
|
|
1218
|
+
children: visible.length > 0 ? /* @__PURE__ */ jsx(SimpleGrid, {
|
|
1219
|
+
cols: {
|
|
1220
|
+
base: 1,
|
|
1221
|
+
sm: 2
|
|
1222
|
+
},
|
|
1223
|
+
spacing: "sm",
|
|
1224
|
+
pr: "xs",
|
|
1225
|
+
children: visible.map((analysis) => /* @__PURE__ */ jsx(CatalogItem, {
|
|
1226
|
+
analysis,
|
|
1227
|
+
onAdd: () => onAdd(analysis.id)
|
|
1228
|
+
}, analysis.id))
|
|
1229
|
+
}) : /* @__PURE__ */ jsx(Text, {
|
|
1230
|
+
c: "dimmed",
|
|
1231
|
+
ta: "center",
|
|
1232
|
+
py: "xl",
|
|
1233
|
+
children: "No visualization matches that search."
|
|
1234
|
+
})
|
|
1235
|
+
})
|
|
1236
|
+
]
|
|
1237
|
+
})
|
|
1238
|
+
});
|
|
1239
|
+
};
|
|
1240
|
+
const CatalogItem = ({ analysis, onAdd }) => {
|
|
1241
|
+
const visual = categoryStyle[analysis.category];
|
|
1242
|
+
const Icon = visual.icon;
|
|
1243
|
+
return /* @__PURE__ */ jsx(UnstyledButton, {
|
|
1244
|
+
onClick: onAdd,
|
|
1245
|
+
style: { height: "100%" },
|
|
1246
|
+
children: /* @__PURE__ */ jsx(Paper, {
|
|
1247
|
+
withBorder: true,
|
|
1248
|
+
radius: "md",
|
|
1249
|
+
p: "md",
|
|
1250
|
+
h: "100%",
|
|
1251
|
+
children: /* @__PURE__ */ jsxs(Stack, {
|
|
1252
|
+
gap: "xs",
|
|
1253
|
+
children: [
|
|
1254
|
+
/* @__PURE__ */ jsxs(Group, {
|
|
1255
|
+
justify: "space-between",
|
|
1256
|
+
align: "flex-start",
|
|
1257
|
+
wrap: "nowrap",
|
|
1258
|
+
children: [/* @__PURE__ */ jsx(ThemeIcon, {
|
|
1259
|
+
color: visual.color,
|
|
1260
|
+
variant: "light",
|
|
1261
|
+
radius: "md",
|
|
1262
|
+
children: /* @__PURE__ */ jsx(Icon, { size: 17 })
|
|
1263
|
+
}), /* @__PURE__ */ jsx(Badge, {
|
|
1264
|
+
color: visual.color,
|
|
1265
|
+
variant: "light",
|
|
1266
|
+
size: "xs",
|
|
1267
|
+
children: analysis.category
|
|
1268
|
+
})]
|
|
1269
|
+
}),
|
|
1270
|
+
/* @__PURE__ */ jsx(Text, {
|
|
1271
|
+
fw: 600,
|
|
1272
|
+
size: "sm",
|
|
1273
|
+
children: analysis.label
|
|
1274
|
+
}),
|
|
1275
|
+
/* @__PURE__ */ jsx(Text, {
|
|
1276
|
+
c: "dimmed",
|
|
1277
|
+
size: "xs",
|
|
1278
|
+
lineClamp: 3,
|
|
1279
|
+
children: analysis.question
|
|
1280
|
+
}),
|
|
1281
|
+
/* @__PURE__ */ jsx(Text, {
|
|
1282
|
+
size: "xs",
|
|
1283
|
+
c: analysis.params.length > 0 ? "dimmed" : "blue",
|
|
1284
|
+
children: analysis.params.length > 0 ? `${analysis.params.length} configuration ${analysis.params.length === 1 ? "choice" : "choices"}` : "Ready to view"
|
|
1285
|
+
})
|
|
1286
|
+
]
|
|
1287
|
+
})
|
|
1288
|
+
})
|
|
1289
|
+
});
|
|
1290
|
+
};
|
|
1291
|
+
//#endregion
|
|
1292
|
+
export { Dashboard };
|