@ocelescope/exploration 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,13 +6,13 @@ backend, so an analysis is only ever one place in the code.
6
6
 
7
7
  ## Layout
8
8
 
9
- | File | Contents |
10
- | --- | --- |
11
- | `analyses.ts` | Every analysis: its question, parameters, SQL and chart |
12
- | `Dashboard.tsx` | The searchable analysis catalog and responsive card grid |
13
- | `Card.tsx` | One card: information, configuration, chart and export frame |
14
- | `Controls.tsx` | The parameter inputs, and the log facts they offer |
15
- | `store.ts` | The cards a reader has added, kept per OCEL in the browser |
9
+ | File | Contents |
10
+ | --------------- | ------------------------------------------------------------ |
11
+ | `analyses.ts` | Every analysis: its question, parameters, SQL and chart |
12
+ | `Dashboard.tsx` | The searchable analysis catalog and responsive card grid |
13
+ | `Card.tsx` | One card: information, configuration, chart and export frame |
14
+ | `Controls.tsx` | The parameter inputs, and the log facts they offer |
15
+ | `store.ts` | The cards a reader has added, kept per OCEL in the browser |
16
16
 
17
17
  To add an analysis, add an entry to `analyses.ts`.
18
18
 
@@ -1,11 +1,11 @@
1
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";
2
+ import { ChartNoAxesCombinedIcon, CheckIcon, Clock3Icon, Code2Icon, GitBranchIcon, InfoIcon, PlusIcon, SearchIcon, Settings2Icon, ShapesIcon, SlidersHorizontalIcon, Trash2Icon } from "lucide-react";
3
+ import { ActionIcon, Alert, Badge, Box, Button, Center, Code, Container, Divider, Group, HoverCard, Input, Modal, MultiSelect, NumberInput, Paper, ScrollArea, SegmentedControl, Select, SimpleGrid, Stack, Text, TextInput, Textarea, ThemeIcon, Title, Tooltip, UnstyledButton } from "@mantine/core";
4
4
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
5
  import { useEffect, useMemo, useState } from "react";
6
- import { AsyncBoundary, EmptyState, ViewerExportFrame, colorForKey } from "@r4pm/components";
6
+ import { AsyncBoundary, ViewerExportFrame } from "@r4pm/components";
7
7
  import { ValueType, useEventAttributes, useObjectAttributes } from "@ocelescope/api-base";
8
- //#region src/analyses.ts
8
+ // src/analyses.ts
9
9
  const ident = (name) => `"${String(name ?? "").replaceAll("\"", "\"\"")}"`;
10
10
  const lit = (value) => `'${String(value ?? "").replaceAll("'", "''")}'`;
11
11
  const int = (value, fallback) => Number.isFinite(Number(value)) ? Math.trunc(Number(value)) : fallback;
@@ -48,15 +48,30 @@ const counts = (source, limit = 25) => `WITH raw AS (${source}),
48
48
  SELECT 'Missing', count(*), ${limit} + 2
49
49
  FROM raw WHERE value IS NULL GROUP BY ALL HAVING count(*) > 0
50
50
  ORDER BY rank`;
51
+ /** The column a histogram's bins start in, named after what they measure so
52
+ * the axis reads it - kept clear of the two columns beside it. */
53
+ const binColumn = (measure) => {
54
+ const name = String(measure ?? "value");
55
+ return name === "count" || name === "bin_end" ? `${name} ` : name;
56
+ };
57
+ /** How to draw what `histogram` returns. */
58
+ const histogramChart = (measure) => ({
59
+ type: "histogram",
60
+ x: binColumn(measure),
61
+ xEnd: "bin_end",
62
+ y: "count"
63
+ });
51
64
  /**
52
- * A histogram of `source`'s `value` column.
65
+ * A histogram of `source`'s `value` column: one row per bin, with its lower
66
+ * edge in a column named after `measure`, its upper edge in `bin_end`.
53
67
  *
54
68
  * Bin width is Freedman-Diaconis (2·IQR/∛n), with Sturges' rule where the
55
69
  * 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.
70
+ * in two open-ended bins - no lower edge, or no upper one - instead of
71
+ * stretching the bins, which is what left a long-tailed distribution as one
72
+ * tall bar and a row of empty ones.
58
73
  */
59
- const histogram = (source) => `WITH raw AS (${source}),
74
+ const histogram = (source, measure) => `WITH raw AS (${source}),
60
75
  spread AS (
61
76
  SELECT quantile_cont(value, 0.25) AS q1, quantile_cont(value, 0.75) AS q3 FROM raw
62
77
  ),
@@ -81,16 +96,17 @@ const histogram = (source) => `WITH raw AS (${source}),
81
96
  SELECT lo + width * least(floor((value - lo) / nullif(width, 0)), bins - 1) AS start, width
82
97
  FROM raw, plan WHERE value BETWEEN lo AND hi
83
98
  )
84
- SELECT format('{:.6g} – {:.6g}', start, start + width) AS bucket, count(*) AS count, start
99
+ SELECT start AS ${ident(binColumn(measure))}, start + width AS bin_end, count(*) AS count
85
100
  FROM binned GROUP BY ALL
86
101
  UNION ALL
87
- SELECT format('< {:.6g}', lo), count(*), -1e308 FROM raw, plan WHERE value < lo GROUP BY ALL
102
+ SELECT NULL, lo, count(*) FROM raw, plan WHERE value < lo GROUP BY ALL
88
103
  UNION ALL
89
- SELECT format('> {:.6g}', hi), count(*), 1e308 FROM raw, plan WHERE value > hi GROUP BY ALL
90
- ORDER BY start`;
104
+ SELECT hi, NULL, count(*) FROM raw, plan WHERE value > hi GROUP BY ALL
105
+ ORDER BY 1 NULLS FIRST`;
91
106
  /** Value counts or a histogram, whichever the attribute deserves. */
92
- const distribution = (source, isNumeric) => isNumeric ? histogram(source) : counts(source);
93
- const DISTRIBUTION_CHART = {
107
+ const distribution = (source, attribute, isNumeric) => isNumeric ? histogram(source, attribute) : counts(source);
108
+ /** How to draw what `distribution` returns. */
109
+ const distributionChart = (attribute, isNumeric) => isNumeric ? histogramChart(attribute) : {
94
110
  x: "bucket",
95
111
  y: "count",
96
112
  types: ["bar", "pie"]
@@ -133,7 +149,7 @@ const analyses = [
133
149
  },
134
150
  {
135
151
  name: "series",
136
- label: "Series (optional)",
152
+ label: "Series",
137
153
  kind: "column",
138
154
  required: false
139
155
  },
@@ -381,8 +397,8 @@ const analyses = [
381
397
  kind: "eventAttribute"
382
398
  }],
383
399
  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
400
+ WHERE "ocel:activity" = ${lit(activity)}`, attribute, numeric(String(attribute))),
401
+ chart: ({ attribute }, numeric) => distributionChart(attribute, numeric(String(attribute)))
386
402
  },
387
403
  {
388
404
  id: "object-attribute-distribution",
@@ -418,8 +434,8 @@ const analyses = [
418
434
  WHERE activity = ${lit(activity)} AND type = ${lit(object_type)}
419
435
  )
420
436
  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
437
+ ASOF LEFT JOIN changes ON pairs.oid = changes.oid AND pairs.ts >= changes.ts`, attribute, numeric(String(attribute))),
438
+ chart: ({ attribute }, numeric) => distributionChart(attribute, numeric(String(attribute)))
423
439
  },
424
440
  {
425
441
  id: "time-between-activities",
@@ -440,7 +456,7 @@ const analyses = [
440
456
  },
441
457
  {
442
458
  name: "object_type",
443
- label: "Object type (optional)",
459
+ label: "Object type",
444
460
  kind: "objectType",
445
461
  required: false
446
462
  },
@@ -466,13 +482,8 @@ const analyses = [
466
482
  WINDOW w AS (PARTITION BY oid ORDER BY ts, eid)
467
483
  )
468
484
  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
- })
485
+ FROM trace WHERE activity = ${lit(source)} AND next_activity = ${lit(target)}`, unit),
486
+ chart: ({ unit }) => histogramChart(unit)
476
487
  },
477
488
  {
478
489
  id: "object-attribute-timeline",
@@ -503,11 +514,25 @@ const analyses = [
503
514
  }
504
515
  ];
505
516
  const findAnalysis = (id) => analyses.find((analysis) => analysis.id === id);
517
+ /**
518
+ * A card's values, with every parameter the reader has not set at its
519
+ * built-in value - so what a control shows is what the query runs with.
520
+ */
521
+ const resolve = (analysis, values) => {
522
+ const resolved = {};
523
+ for (const param of analysis.params) {
524
+ const value = values[param.name] ?? param.default ?? param.options?.[0] ?? void 0;
525
+ if (value != null) resolved[param.name] = value;
526
+ }
527
+ return resolved;
528
+ };
529
+ const hasValue = (value) => value != null && (!Array.isArray(value) || value.length > 0);
506
530
  /** 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
531
+ const isConfigured = (analysis, values) => {
532
+ const resolved = resolve(analysis, values);
533
+ return analysis.params.every((param) => param.required === false || hasValue(resolved[param.name]));
534
+ };
535
+ // src/Controls.tsx
511
536
  /**
512
537
  * The parameter inputs a card is configured with.
513
538
  *
@@ -530,69 +555,110 @@ const useNumeric = (values) => {
530
555
  const objects = useObjectAttributes(id, { names: type }, { query: { enabled: id != null && type.length > 0 } });
531
556
  return (attribute) => [...events.data ?? [], ...objects.data ?? []].some((candidate) => candidate.name === attribute && BINNED.includes(candidate.type));
532
557
  };
533
- const Control = ({ param, values, onChange }) => {
558
+ /** Whether a parameter narrows another: its choices depend on the activity. */
559
+ const followsActivity = (analysis, param) => param.kind === "eventAttribute" || param.kind === "objectType" && param.required !== false && analysis.params.some((other) => other.name === "activity");
560
+ /**
561
+ * The parameters to clear when one changes, because what they offer depends
562
+ * on it: an attribute on its activity or object type, an object type on the
563
+ * activity it is narrowed to.
564
+ */
565
+ const dependents = (analysis, changed) => {
566
+ const stale = (param) => changed.name === "activity" && followsActivity(analysis, param) || changed.name === "object_type" && param.kind === "objectAttribute";
567
+ const names = analysis.params.filter(stale).map((param) => param.name);
568
+ return changed.name === "activity" && names.includes("object_type") ? [...names, ...analysis.params.filter((param) => param.kind === "objectAttribute").map((param) => param.name)] : names;
569
+ };
570
+ /**
571
+ * An analysis's parameters as numbered steps. A step shows a check once it
572
+ * has a value; the next one to fill is highlighted, and optional ones are
573
+ * dashed - so what is left to do is seen rather than read.
574
+ */
575
+ const Fields = ({ analysis, values, onChange }) => {
576
+ const next = analysis.params.find((param) => param.required !== false && !hasValue(values[param.name]));
577
+ return /* @__PURE__ */ jsx(Stack, {
578
+ gap: "md",
579
+ children: analysis.params.map((param, index) => {
580
+ const done = hasValue(values[param.name]);
581
+ return /* @__PURE__ */ jsxs(Group, {
582
+ gap: "sm",
583
+ align: "flex-start",
584
+ wrap: "nowrap",
585
+ children: [/* @__PURE__ */ jsx(ThemeIcon, {
586
+ size: 22,
587
+ radius: "xl",
588
+ color: done ? "teal" : param === next ? "blue" : "gray",
589
+ variant: done || param === next ? "filled" : "outline",
590
+ style: {
591
+ fontSize: 11,
592
+ fontWeight: 600,
593
+ borderStyle: param.required === false ? "dashed" : void 0
594
+ },
595
+ children: done ? /* @__PURE__ */ jsx(CheckIcon, { size: 13 }) : index + 1
596
+ }), /* @__PURE__ */ jsx(Box, {
597
+ flex: 1,
598
+ miw: 0,
599
+ children: /* @__PURE__ */ jsx(Control, {
600
+ analysis,
601
+ param,
602
+ values,
603
+ onChange: (value) => onChange(param, value)
604
+ })
605
+ })]
606
+ }, param.name);
607
+ })
608
+ });
609
+ };
610
+ const Control = ({ analysis, param, values, onChange }) => {
534
611
  const value = values[param.name];
612
+ const activity = typeof values.activity === "string" ? values.activity : void 0;
535
613
  if (param.kind === "number") return /* @__PURE__ */ jsx(NumberInput, {
536
- size: "xs",
537
614
  label: param.label,
538
615
  min: param.min,
539
616
  max: param.max,
540
- value: Number(value ?? param.default ?? 0),
617
+ value: Number(value ?? 0),
541
618
  onChange: (next) => onChange(typeof next === "number" ? next : void 0)
542
619
  });
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, {
620
+ if (param.kind === "choice") return /* @__PURE__ */ jsx(Input.Wrapper, {
568
621
  label: param.label,
569
- value: value == null ? "" : String(value),
570
- onChange: (event) => onChange(event.currentTarget.value || void 0)
622
+ children: /* @__PURE__ */ jsx(SegmentedControl, {
623
+ fullWidth: true,
624
+ data: [...param.options ?? []],
625
+ value: String(value ?? ""),
626
+ onChange
627
+ })
571
628
  });
572
- if (param.kind === "sql") return /* @__PURE__ */ jsx(Textarea, {
629
+ if (param.kind === "activities") return /* @__PURE__ */ jsx(ActivityPicker, {
573
630
  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
- } }
631
+ placeholder: "All activities",
632
+ multiple: true,
633
+ value: Array.isArray(value) ? value.map(String) : [],
634
+ onChange
582
635
  });
583
- if (param.kind === "columns") return null;
584
636
  const chosen = {
585
637
  value: value == null ? null : String(value),
586
638
  onChange
587
639
  };
640
+ if (param.kind === "activity") return /* @__PURE__ */ jsx(ActivityPicker, {
641
+ label: param.label,
642
+ ...chosen
643
+ });
644
+ if (param.kind === "objectType") {
645
+ const narrowed = followsActivity(analysis, param);
646
+ return /* @__PURE__ */ jsx(ObjectTypePicker, {
647
+ label: param.label,
648
+ activity: narrowed ? activity : void 0,
649
+ disabled: narrowed && activity === void 0,
650
+ ...param.required === false && { placeholder: "All object types" },
651
+ ...chosen
652
+ });
653
+ }
588
654
  if (param.kind === "object") return /* @__PURE__ */ jsx(ObjectPicker, {
589
655
  label: param.label,
590
656
  ...chosen
591
657
  });
592
658
  if (param.kind === "eventAttribute") return /* @__PURE__ */ jsx(EventAttributePicker, {
593
659
  label: param.label,
594
- activity: values.activity == null ? void 0 : String(values.activity),
595
- disabled: values.activity == null,
660
+ activity,
661
+ disabled: activity === void 0,
596
662
  ...chosen
597
663
  });
598
664
  if (param.kind === "objectAttribute") return /* @__PURE__ */ jsx(ObjectAttributePicker, {
@@ -601,35 +667,20 @@ const Control = ({ param, values, onChange }) => {
601
667
  disabled: values.object_type == null,
602
668
  ...chosen
603
669
  });
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
670
  return null;
613
671
  };
614
- //#endregion
615
- //#region src/Card.tsx
672
+ // src/Card.tsx
616
673
  /** One analysis on the dashboard: its parameters, and the chart they produce. */
617
- const AnalysisCard = ({ analysis, values, onChange, onRemove, configurationOpened, onConfigure, onCloseConfiguration }) => {
674
+ const AnalysisCard = ({ analysis, values: stored, onChange, onRemove, configurationOpened, onConfigure, onCloseConfiguration }) => {
675
+ const values = resolve(analysis, stored);
618
676
  const numeric = useNumeric(values);
619
677
  const configured = isConfigured(analysis, values);
620
678
  const setParam = (param, value) => {
621
679
  const next = {
622
- ...values,
680
+ ...stored,
623
681
  [param.name]: value
624
682
  };
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
- }
683
+ for (const name of dependents(analysis, param)) next[name] = void 0;
633
684
  onChange(next);
634
685
  };
635
686
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs(Paper, {
@@ -656,25 +707,16 @@ const AnalysisCard = ({ analysis, values, onChange, onRemove, configurationOpene
656
707
  children: [
657
708
  /* @__PURE__ */ jsxs(Group, {
658
709
  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
- ]
710
+ children: [/* @__PURE__ */ jsx(Text, {
711
+ fw: 600,
712
+ size: "sm",
713
+ children: analysis.label
714
+ }), /* @__PURE__ */ jsx(Badge, {
715
+ variant: "light",
716
+ color: "gray",
717
+ size: "xs",
718
+ children: analysis.category
719
+ })]
678
720
  }),
679
721
  /* @__PURE__ */ jsx(Text, {
680
722
  c: "dimmed",
@@ -763,57 +805,49 @@ const AnalysisCard = ({ analysis, values, onChange, onRemove, configurationOpene
763
805
  }) : /* @__PURE__ */ jsx(OcelChart, {
764
806
  height: "100%",
765
807
  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.`
808
+ ...analysis.chart(values, numeric)
809
+ }) : /* @__PURE__ */ jsx(Center, {
810
+ h: "100%",
811
+ "data-export-ignore": true,
812
+ children: /* @__PURE__ */ jsx(Button, {
813
+ variant: "light",
814
+ leftSection: /* @__PURE__ */ jsx(Settings2Icon, { size: 16 }),
815
+ onClick: onConfigure,
816
+ children: "Configure"
817
+ })
770
818
  })
771
819
  })]
772
820
  }), /* @__PURE__ */ jsx(Modal, {
773
821
  opened: configurationOpened,
774
822
  onClose: onCloseConfiguration,
775
- title: `Configure ${analysis.label}`,
823
+ title: analysis.label,
776
824
  size: "lg",
777
825
  centered: true,
778
826
  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
- })]
827
+ gap: "lg",
828
+ children: [/* @__PURE__ */ jsx(ScrollArea.Autosize, {
829
+ mah: "62vh",
830
+ type: "auto",
831
+ offsetScrollbars: true,
832
+ children: analysis.view === "custom-chart" ? /* @__PURE__ */ jsx(CustomChartControls, {
833
+ values,
834
+ onChange
835
+ }) : /* @__PURE__ */ jsx(Fields, {
836
+ analysis,
837
+ values,
838
+ onChange: setParam
809
839
  })
810
- ]
840
+ }), /* @__PURE__ */ jsx(Group, {
841
+ justify: "flex-end",
842
+ children: /* @__PURE__ */ jsx(Button, {
843
+ disabled: !configured,
844
+ onClick: onCloseConfiguration,
845
+ children: "Done"
846
+ })
847
+ })]
811
848
  })
812
849
  })] });
813
850
  };
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
851
  const summary = (analysis, values) => analysis.params.filter((param) => param.kind !== "sql" && values[param.name] != null).slice(0, 3).map((param) => {
818
852
  const value = values[param.name];
819
853
  const text = Array.isArray(value) ? value.length === 0 ? "All" : value.length <= 2 ? value.join(", ") : `${value.length} selected` : String(value);
@@ -948,8 +982,8 @@ const CustomChartControls = ({ values, onChange }) => {
948
982
  })]
949
983
  }),
950
984
  /* @__PURE__ */ jsx(Select, {
951
- label: "Series (optional)",
952
- description: "Split rows into one trace per distinct value.",
985
+ label: "Series",
986
+ placeholder: "None",
953
987
  data: columns,
954
988
  value: values.series == null ? null : String(values.series),
955
989
  clearable: true,
@@ -1016,14 +1050,13 @@ const Changes = ({ analysis, values }) => {
1016
1050
  series: "attribute",
1017
1051
  xRange: lifetime,
1018
1052
  yAxes: "independent",
1019
- colorOf: (attribute) => colorForKey("attribute", attribute) ?? "#888888",
1053
+ colorScope: "attribute",
1020
1054
  step: true
1021
1055
  });
1022
1056
  }
1023
1057
  });
1024
1058
  };
1025
- //#endregion
1026
- //#region src/store.ts
1059
+ // src/store.ts
1027
1060
  /** The dashboard a reader has built, kept per OCEL in browser storage. */
1028
1061
  const key = (ocelId) => `ocelescope:exploration:${ocelId}`;
1029
1062
  const useCards = (ocelId) => {
@@ -1046,12 +1079,14 @@ const useCards = (ocelId) => {
1046
1079
  ]);
1047
1080
  return {
1048
1081
  cards,
1082
+ /** False until the stored dashboard has been read. */
1083
+ loaded,
1049
1084
  add: (analysis) => {
1050
1085
  const id = globalThis.crypto.randomUUID();
1051
1086
  setCards((current) => [...current, {
1052
1087
  id,
1053
1088
  analysis,
1054
- values: fixed(analysis)
1089
+ values: {}
1055
1090
  }]);
1056
1091
  return id;
1057
1092
  },
@@ -1062,15 +1097,6 @@ const useCards = (ocelId) => {
1062
1097
  remove: (id) => setCards((current) => current.filter((card) => card.id !== id))
1063
1098
  };
1064
1099
  };
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
1100
  const read = (ocelId) => {
1075
1101
  try {
1076
1102
  const stored = JSON.parse(window.localStorage.getItem(key(ocelId)) ?? "[]");
@@ -1079,17 +1105,17 @@ const read = (ocelId) => {
1079
1105
  return [];
1080
1106
  }
1081
1107
  };
1082
- //#endregion
1083
- //#region src/Dashboard.tsx
1108
+ // src/Dashboard.tsx
1084
1109
  /** The exploration dashboard: choose a question, configure it, keep the result. */
1085
1110
  const Dashboard = ({ ocelId }) => {
1086
- const { cards, add, update, remove } = useCards(ocelId);
1111
+ const { cards, loaded, add, update, remove } = useCards(ocelId);
1087
1112
  const [catalogOpen, setCatalogOpen] = useState(false);
1088
1113
  const [configure, setConfigure] = useState();
1089
1114
  const addAnalysis = (analysis) => {
1090
1115
  const id = add(analysis);
1091
1116
  setCatalogOpen(false);
1092
- setConfigure(id);
1117
+ const added = findAnalysis(analysis);
1118
+ if (added && !isConfigured(added, {})) setConfigure(id);
1093
1119
  };
1094
1120
  return /* @__PURE__ */ jsxs(Container, {
1095
1121
  fluid: true,
@@ -1100,52 +1126,65 @@ const Dashboard = ({ ocelId }) => {
1100
1126
  py: "md",
1101
1127
  children: [/* @__PURE__ */ jsxs(Stack, {
1102
1128
  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
- })]
1129
+ children: [
1130
+ /* @__PURE__ */ jsxs(Group, {
1131
+ align: "center",
1132
+ justify: "space-between",
1133
+ wrap: "wrap",
1134
+ children: [/* @__PURE__ */ jsx(Stack, {
1135
+ gap: 2,
1136
+ children: /* @__PURE__ */ jsxs(Group, {
1137
+ gap: "xs",
1138
+ children: [/* @__PURE__ */ jsx(Title, {
1139
+ order: 2,
1140
+ children: "Explore the log"
1141
+ }), cards.length > 0 && /* @__PURE__ */ jsxs(Badge, {
1142
+ variant: "light",
1143
+ color: "gray",
1144
+ radius: "sm",
1145
+ children: [
1146
+ cards.length,
1147
+ " ",
1148
+ cards.length === 1 ? "view" : "views"
1149
+ ]
1150
+ })]
1151
+ })
1152
+ }), cards.length > 0 && /* @__PURE__ */ jsx(Button, {
1153
+ leftSection: /* @__PURE__ */ jsx(PlusIcon, { size: 16 }),
1154
+ onClick: () => setCatalogOpen(true),
1155
+ children: "Add visualization"
1156
+ })]
1157
+ }),
1158
+ loaded && cards.length === 0 && /* @__PURE__ */ jsx(Center, {
1159
+ h: "50vh",
1160
+ children: /* @__PURE__ */ jsx(Button, {
1161
+ size: "lg",
1162
+ variant: "light",
1163
+ leftSection: /* @__PURE__ */ jsx(PlusIcon, { size: 18 }),
1164
+ onClick: () => setCatalogOpen(true),
1165
+ children: "Add visualization"
1166
+ })
1167
+ }),
1168
+ /* @__PURE__ */ jsx(SimpleGrid, {
1169
+ cols: {
1170
+ base: 1,
1171
+ xl: 2
1172
+ },
1173
+ spacing: "md",
1174
+ children: cards.map((card) => {
1175
+ const analysis = findAnalysis(card.analysis);
1176
+ return analysis ? /* @__PURE__ */ jsx(AnalysisCard, {
1177
+ analysis,
1178
+ values: card.values,
1179
+ onChange: (values) => update(card.id, values),
1180
+ onRemove: () => remove(card.id),
1181
+ configurationOpened: configure === card.id,
1182
+ onConfigure: () => setConfigure(card.id),
1183
+ onCloseConfiguration: () => setConfigure(void 0)
1184
+ }, card.id) : null;
1124
1185
  })
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
1186
  })
1148
- })]
1187
+ ]
1149
1188
  }), /* @__PURE__ */ jsx(AnalysisCatalog, {
1150
1189
  opened: catalogOpen,
1151
1190
  onClose: () => setCatalogOpen(false),
@@ -1277,16 +1316,10 @@ const CatalogItem = ({ analysis, onAdd }) => {
1277
1316
  size: "xs",
1278
1317
  lineClamp: 3,
1279
1318
  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
1319
  })
1286
1320
  ]
1287
1321
  })
1288
1322
  })
1289
1323
  });
1290
1324
  };
1291
- //#endregion
1292
1325
  export { Dashboard };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,2 @@
1
- //#region src/index.d.ts
2
1
  declare const _default: import("@ocelescope/core").ModuleDefinition;
3
- //#endregion
4
2
  export { _default as default };
package/dist/index.js CHANGED
@@ -3,8 +3,8 @@ import { ChartNoAxesCombinedIcon } from "lucide-react";
3
3
  import { LoadingOverlay } from "@mantine/core";
4
4
  import dynamic from "next/dynamic";
5
5
  import { jsx } from "react/jsx-runtime";
6
- //#region src/pages/dashboard.tsx
7
- const Dashboard = dynamic(() => import("./Dashboard-BQ_EzP8N.js").then((module) => module.Dashboard), { ssr: false });
6
+ // src/pages/dashboard.tsx
7
+ const Dashboard = dynamic(() => import("./Dashboard-jE93H3NS.js").then((module) => module.Dashboard), { ssr: false });
8
8
  const DashboardPage = () => {
9
9
  const { id } = useCurrentOcel();
10
10
  return id ? /* @__PURE__ */ jsx(Dashboard, { ocelId: id }, id) : /* @__PURE__ */ jsx(LoadingOverlay, { visible: true });
@@ -15,8 +15,7 @@ var dashboard_default = defineModuleRoute({
15
15
  name: "dashboard",
16
16
  requiresOcel: true
17
17
  });
18
- //#endregion
19
- //#region src/index.ts
18
+ // src/index.ts
20
19
  var src_default = defineModule({
21
20
  name: "exploration",
22
21
  description: "Explore the active OCEL through configurable visualizations.",
@@ -25,5 +24,4 @@ var src_default = defineModule({
25
24
  routes: [dashboard_default],
26
25
  icon: ChartNoAxesCombinedIcon
27
26
  });
28
- //#endregion
29
27
  export { src_default as default };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ocelescope/exploration",
3
3
  "type": "module",
4
- "version": "0.10.1",
4
+ "version": "0.11.0",
5
5
  "description": "Interactive analytical dashboards for OCEL data",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -19,19 +19,19 @@
19
19
  "dist"
20
20
  ],
21
21
  "peerDependencies": {
22
- "@mantine/core": "^9.5.1",
23
- "@ocelescope/api-base": "^0.10.1",
24
- "@ocelescope/core": "^0.10.1",
25
- "@r4pm/components": "^0.3.2",
26
- "@tanstack/react-query": "^5.90.21",
27
- "lucide-react": "^1.0.0",
28
- "next": "16.3.6",
29
- "react": "^19.3.0"
22
+ "@mantine/core": "^9.7.4",
23
+ "@ocelescope/api-base": "^0.11.0",
24
+ "@ocelescope/core": "^0.11.0",
25
+ "@r4pm/components": "^0.4.0",
26
+ "@tanstack/react-query": "^5.105.0",
27
+ "lucide-react": "^1.63.0",
28
+ "next": "16.4.3",
29
+ "react": "^19.3.1"
30
30
  },
31
31
  "devDependencies": {
32
- "@types/node": "^26.6.1",
32
+ "@types/node": "^26.6.5",
33
33
  "@types/react": "^19.3.0",
34
- "tsdown": "^0.23.0",
34
+ "tsdown": "^0.23.3",
35
35
  "typescript": "^6.0.3"
36
36
  },
37
37
  "scripts": {