@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.
@@ -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 };