@ocelescope/exploration 0.10.0 → 0.10.2

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.
@@ -1,9 +1,9 @@
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
8
  //#region src/analyses.ts
9
9
  const ident = (name) => `"${String(name ?? "").replaceAll("\"", "\"\"")}"`;
@@ -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,9 +514,24 @@ 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);
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
+ };
509
535
  //#endregion
510
536
  //#region src/Controls.tsx
511
537
  /**
@@ -530,69 +556,110 @@ const useNumeric = (values) => {
530
556
  const objects = useObjectAttributes(id, { names: type }, { query: { enabled: id != null && type.length > 0 } });
531
557
  return (attribute) => [...events.data ?? [], ...objects.data ?? []].some((candidate) => candidate.name === attribute && BINNED.includes(candidate.type));
532
558
  };
533
- const Control = ({ param, values, onChange }) => {
559
+ /** Whether a parameter narrows another: its choices depend on the activity. */
560
+ const followsActivity = (analysis, param) => param.kind === "eventAttribute" || param.kind === "objectType" && param.required !== false && analysis.params.some((other) => other.name === "activity");
561
+ /**
562
+ * The parameters to clear when one changes, because what they offer depends
563
+ * on it: an attribute on its activity or object type, an object type on the
564
+ * activity it is narrowed to.
565
+ */
566
+ const dependents = (analysis, changed) => {
567
+ const stale = (param) => changed.name === "activity" && followsActivity(analysis, param) || changed.name === "object_type" && param.kind === "objectAttribute";
568
+ const names = analysis.params.filter(stale).map((param) => param.name);
569
+ return changed.name === "activity" && names.includes("object_type") ? [...names, ...analysis.params.filter((param) => param.kind === "objectAttribute").map((param) => param.name)] : names;
570
+ };
571
+ /**
572
+ * An analysis's parameters as numbered steps. A step shows a check once it
573
+ * has a value; the next one to fill is highlighted, and optional ones are
574
+ * dashed - so what is left to do is seen rather than read.
575
+ */
576
+ const Fields = ({ analysis, values, onChange }) => {
577
+ const next = analysis.params.find((param) => param.required !== false && !hasValue(values[param.name]));
578
+ return /* @__PURE__ */ jsx(Stack, {
579
+ gap: "md",
580
+ children: analysis.params.map((param, index) => {
581
+ const done = hasValue(values[param.name]);
582
+ return /* @__PURE__ */ jsxs(Group, {
583
+ gap: "sm",
584
+ align: "flex-start",
585
+ wrap: "nowrap",
586
+ children: [/* @__PURE__ */ jsx(ThemeIcon, {
587
+ size: 22,
588
+ radius: "xl",
589
+ color: done ? "teal" : param === next ? "blue" : "gray",
590
+ variant: done || param === next ? "filled" : "outline",
591
+ style: {
592
+ fontSize: 11,
593
+ fontWeight: 600,
594
+ borderStyle: param.required === false ? "dashed" : void 0
595
+ },
596
+ children: done ? /* @__PURE__ */ jsx(CheckIcon, { size: 13 }) : index + 1
597
+ }), /* @__PURE__ */ jsx(Box, {
598
+ flex: 1,
599
+ miw: 0,
600
+ children: /* @__PURE__ */ jsx(Control, {
601
+ analysis,
602
+ param,
603
+ values,
604
+ onChange: (value) => onChange(param, value)
605
+ })
606
+ })]
607
+ }, param.name);
608
+ })
609
+ });
610
+ };
611
+ const Control = ({ analysis, param, values, onChange }) => {
534
612
  const value = values[param.name];
613
+ const activity = typeof values.activity === "string" ? values.activity : void 0;
535
614
  if (param.kind === "number") return /* @__PURE__ */ jsx(NumberInput, {
536
- size: "xs",
537
615
  label: param.label,
538
616
  min: param.min,
539
617
  max: param.max,
540
- value: Number(value ?? param.default ?? 0),
618
+ value: Number(value ?? 0),
541
619
  onChange: (next) => onChange(typeof next === "number" ? next : void 0)
542
620
  });
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, {
621
+ if (param.kind === "choice") return /* @__PURE__ */ jsx(Input.Wrapper, {
568
622
  label: param.label,
569
- value: value == null ? "" : String(value),
570
- onChange: (event) => onChange(event.currentTarget.value || void 0)
623
+ children: /* @__PURE__ */ jsx(SegmentedControl, {
624
+ fullWidth: true,
625
+ data: [...param.options ?? []],
626
+ value: String(value ?? ""),
627
+ onChange
628
+ })
571
629
  });
572
- if (param.kind === "sql") return /* @__PURE__ */ jsx(Textarea, {
630
+ if (param.kind === "activities") return /* @__PURE__ */ jsx(ActivityPicker, {
573
631
  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
- } }
632
+ placeholder: "All activities",
633
+ multiple: true,
634
+ value: Array.isArray(value) ? value.map(String) : [],
635
+ onChange
582
636
  });
583
- if (param.kind === "columns") return null;
584
637
  const chosen = {
585
638
  value: value == null ? null : String(value),
586
639
  onChange
587
640
  };
641
+ if (param.kind === "activity") return /* @__PURE__ */ jsx(ActivityPicker, {
642
+ label: param.label,
643
+ ...chosen
644
+ });
645
+ if (param.kind === "objectType") {
646
+ const narrowed = followsActivity(analysis, param);
647
+ return /* @__PURE__ */ jsx(ObjectTypePicker, {
648
+ label: param.label,
649
+ activity: narrowed ? activity : void 0,
650
+ disabled: narrowed && activity === void 0,
651
+ ...param.required === false && { placeholder: "All object types" },
652
+ ...chosen
653
+ });
654
+ }
588
655
  if (param.kind === "object") return /* @__PURE__ */ jsx(ObjectPicker, {
589
656
  label: param.label,
590
657
  ...chosen
591
658
  });
592
659
  if (param.kind === "eventAttribute") return /* @__PURE__ */ jsx(EventAttributePicker, {
593
660
  label: param.label,
594
- activity: values.activity == null ? void 0 : String(values.activity),
595
- disabled: values.activity == null,
661
+ activity,
662
+ disabled: activity === void 0,
596
663
  ...chosen
597
664
  });
598
665
  if (param.kind === "objectAttribute") return /* @__PURE__ */ jsx(ObjectAttributePicker, {
@@ -601,35 +668,21 @@ const Control = ({ param, values, onChange }) => {
601
668
  disabled: values.object_type == null,
602
669
  ...chosen
603
670
  });
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
671
  return null;
613
672
  };
614
673
  //#endregion
615
674
  //#region src/Card.tsx
616
675
  /** One analysis on the dashboard: its parameters, and the chart they produce. */
617
- const AnalysisCard = ({ analysis, values, onChange, onRemove, configurationOpened, onConfigure, onCloseConfiguration }) => {
676
+ const AnalysisCard = ({ analysis, values: stored, onChange, onRemove, configurationOpened, onConfigure, onCloseConfiguration }) => {
677
+ const values = resolve(analysis, stored);
618
678
  const numeric = useNumeric(values);
619
679
  const configured = isConfigured(analysis, values);
620
680
  const setParam = (param, value) => {
621
681
  const next = {
622
- ...values,
682
+ ...stored,
623
683
  [param.name]: value
624
684
  };
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
- }
685
+ for (const name of dependents(analysis, param)) next[name] = void 0;
633
686
  onChange(next);
634
687
  };
635
688
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs(Paper, {
@@ -656,25 +709,16 @@ const AnalysisCard = ({ analysis, values, onChange, onRemove, configurationOpene
656
709
  children: [
657
710
  /* @__PURE__ */ jsxs(Group, {
658
711
  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
- ]
712
+ children: [/* @__PURE__ */ jsx(Text, {
713
+ fw: 600,
714
+ size: "sm",
715
+ children: analysis.label
716
+ }), /* @__PURE__ */ jsx(Badge, {
717
+ variant: "light",
718
+ color: "gray",
719
+ size: "xs",
720
+ children: analysis.category
721
+ })]
678
722
  }),
679
723
  /* @__PURE__ */ jsx(Text, {
680
724
  c: "dimmed",
@@ -763,57 +807,49 @@ const AnalysisCard = ({ analysis, values, onChange, onRemove, configurationOpene
763
807
  }) : /* @__PURE__ */ jsx(OcelChart, {
764
808
  height: "100%",
765
809
  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.`
810
+ ...analysis.chart(values, numeric)
811
+ }) : /* @__PURE__ */ jsx(Center, {
812
+ h: "100%",
813
+ "data-export-ignore": true,
814
+ children: /* @__PURE__ */ jsx(Button, {
815
+ variant: "light",
816
+ leftSection: /* @__PURE__ */ jsx(Settings2Icon, { size: 16 }),
817
+ onClick: onConfigure,
818
+ children: "Configure"
819
+ })
770
820
  })
771
821
  })]
772
822
  }), /* @__PURE__ */ jsx(Modal, {
773
823
  opened: configurationOpened,
774
824
  onClose: onCloseConfiguration,
775
- title: `Configure ${analysis.label}`,
825
+ title: analysis.label,
776
826
  size: "lg",
777
827
  centered: true,
778
828
  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
- })]
829
+ gap: "lg",
830
+ children: [/* @__PURE__ */ jsx(ScrollArea.Autosize, {
831
+ mah: "62vh",
832
+ type: "auto",
833
+ offsetScrollbars: true,
834
+ children: analysis.view === "custom-chart" ? /* @__PURE__ */ jsx(CustomChartControls, {
835
+ values,
836
+ onChange
837
+ }) : /* @__PURE__ */ jsx(Fields, {
838
+ analysis,
839
+ values,
840
+ onChange: setParam
809
841
  })
810
- ]
842
+ }), /* @__PURE__ */ jsx(Group, {
843
+ justify: "flex-end",
844
+ children: /* @__PURE__ */ jsx(Button, {
845
+ disabled: !configured,
846
+ onClick: onCloseConfiguration,
847
+ children: "Done"
848
+ })
849
+ })]
811
850
  })
812
851
  })] });
813
852
  };
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
853
  const summary = (analysis, values) => analysis.params.filter((param) => param.kind !== "sql" && values[param.name] != null).slice(0, 3).map((param) => {
818
854
  const value = values[param.name];
819
855
  const text = Array.isArray(value) ? value.length === 0 ? "All" : value.length <= 2 ? value.join(", ") : `${value.length} selected` : String(value);
@@ -948,8 +984,8 @@ const CustomChartControls = ({ values, onChange }) => {
948
984
  })]
949
985
  }),
950
986
  /* @__PURE__ */ jsx(Select, {
951
- label: "Series (optional)",
952
- description: "Split rows into one trace per distinct value.",
987
+ label: "Series",
988
+ placeholder: "None",
953
989
  data: columns,
954
990
  value: values.series == null ? null : String(values.series),
955
991
  clearable: true,
@@ -1016,7 +1052,7 @@ const Changes = ({ analysis, values }) => {
1016
1052
  series: "attribute",
1017
1053
  xRange: lifetime,
1018
1054
  yAxes: "independent",
1019
- colorOf: (attribute) => colorForKey("attribute", attribute) ?? "#888888",
1055
+ colorScope: "attribute",
1020
1056
  step: true
1021
1057
  });
1022
1058
  }
@@ -1046,12 +1082,14 @@ const useCards = (ocelId) => {
1046
1082
  ]);
1047
1083
  return {
1048
1084
  cards,
1085
+ /** False until the stored dashboard has been read. */
1086
+ loaded,
1049
1087
  add: (analysis) => {
1050
1088
  const id = globalThis.crypto.randomUUID();
1051
1089
  setCards((current) => [...current, {
1052
1090
  id,
1053
1091
  analysis,
1054
- values: fixed(analysis)
1092
+ values: {}
1055
1093
  }]);
1056
1094
  return id;
1057
1095
  },
@@ -1062,15 +1100,6 @@ const useCards = (ocelId) => {
1062
1100
  remove: (id) => setCards((current) => current.filter((card) => card.id !== id))
1063
1101
  };
1064
1102
  };
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
1103
  const read = (ocelId) => {
1075
1104
  try {
1076
1105
  const stored = JSON.parse(window.localStorage.getItem(key(ocelId)) ?? "[]");
@@ -1083,13 +1112,14 @@ const read = (ocelId) => {
1083
1112
  //#region src/Dashboard.tsx
1084
1113
  /** The exploration dashboard: choose a question, configure it, keep the result. */
1085
1114
  const Dashboard = ({ ocelId }) => {
1086
- const { cards, add, update, remove } = useCards(ocelId);
1115
+ const { cards, loaded, add, update, remove } = useCards(ocelId);
1087
1116
  const [catalogOpen, setCatalogOpen] = useState(false);
1088
1117
  const [configure, setConfigure] = useState();
1089
1118
  const addAnalysis = (analysis) => {
1090
1119
  const id = add(analysis);
1091
1120
  setCatalogOpen(false);
1092
- setConfigure(id);
1121
+ const added = findAnalysis(analysis);
1122
+ if (added && !isConfigured(added, {})) setConfigure(id);
1093
1123
  };
1094
1124
  return /* @__PURE__ */ jsxs(Container, {
1095
1125
  fluid: true,
@@ -1100,52 +1130,65 @@ const Dashboard = ({ ocelId }) => {
1100
1130
  py: "md",
1101
1131
  children: [/* @__PURE__ */ jsxs(Stack, {
1102
1132
  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
- })]
1133
+ children: [
1134
+ /* @__PURE__ */ jsxs(Group, {
1135
+ align: "center",
1136
+ justify: "space-between",
1137
+ wrap: "wrap",
1138
+ children: [/* @__PURE__ */ jsx(Stack, {
1139
+ gap: 2,
1140
+ children: /* @__PURE__ */ jsxs(Group, {
1141
+ gap: "xs",
1142
+ children: [/* @__PURE__ */ jsx(Title, {
1143
+ order: 2,
1144
+ children: "Explore the log"
1145
+ }), cards.length > 0 && /* @__PURE__ */ jsxs(Badge, {
1146
+ variant: "light",
1147
+ color: "gray",
1148
+ radius: "sm",
1149
+ children: [
1150
+ cards.length,
1151
+ " ",
1152
+ cards.length === 1 ? "view" : "views"
1153
+ ]
1154
+ })]
1155
+ })
1156
+ }), cards.length > 0 && /* @__PURE__ */ jsx(Button, {
1157
+ leftSection: /* @__PURE__ */ jsx(PlusIcon, { size: 16 }),
1158
+ onClick: () => setCatalogOpen(true),
1159
+ children: "Add visualization"
1160
+ })]
1161
+ }),
1162
+ loaded && cards.length === 0 && /* @__PURE__ */ jsx(Center, {
1163
+ h: "50vh",
1164
+ children: /* @__PURE__ */ jsx(Button, {
1165
+ size: "lg",
1166
+ variant: "light",
1167
+ leftSection: /* @__PURE__ */ jsx(PlusIcon, { size: 18 }),
1168
+ onClick: () => setCatalogOpen(true),
1169
+ children: "Add visualization"
1170
+ })
1171
+ }),
1172
+ /* @__PURE__ */ jsx(SimpleGrid, {
1173
+ cols: {
1174
+ base: 1,
1175
+ xl: 2
1176
+ },
1177
+ spacing: "md",
1178
+ children: cards.map((card) => {
1179
+ const analysis = findAnalysis(card.analysis);
1180
+ return analysis ? /* @__PURE__ */ jsx(AnalysisCard, {
1181
+ analysis,
1182
+ values: card.values,
1183
+ onChange: (values) => update(card.id, values),
1184
+ onRemove: () => remove(card.id),
1185
+ configurationOpened: configure === card.id,
1186
+ onConfigure: () => setConfigure(card.id),
1187
+ onCloseConfiguration: () => setConfigure(void 0)
1188
+ }, card.id) : null;
1124
1189
  })
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
1190
  })
1148
- })]
1191
+ ]
1149
1192
  }), /* @__PURE__ */ jsx(AnalysisCatalog, {
1150
1193
  opened: catalogOpen,
1151
1194
  onClose: () => setCatalogOpen(false),
@@ -1277,11 +1320,6 @@ const CatalogItem = ({ analysis, onAdd }) => {
1277
1320
  size: "xs",
1278
1321
  lineClamp: 3,
1279
1322
  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
1323
  })
1286
1324
  ]
1287
1325
  })
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { LoadingOverlay } from "@mantine/core";
4
4
  import dynamic from "next/dynamic";
5
5
  import { jsx } from "react/jsx-runtime";
6
6
  //#region src/pages/dashboard.tsx
7
- const Dashboard = dynamic(() => import("./Dashboard-BQ_EzP8N.js").then((module) => module.Dashboard), { ssr: false });
7
+ const Dashboard = dynamic(() => import("./Dashboard-DlIf2ec5.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 });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ocelescope/exploration",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.10.2",
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.0",
24
- "@ocelescope/core": "^0.10.0",
22
+ "@mantine/core": "^9.7.2",
23
+ "@ocelescope/api-base": "^0.10.2",
24
+ "@ocelescope/core": "^0.10.2",
25
25
  "@r4pm/components": "^0.3.2",
26
- "@tanstack/react-query": "^5.90.21",
27
- "lucide-react": "^1.0.0",
28
- "next": "16.3.6",
26
+ "@tanstack/react-query": "^5.104.2",
27
+ "lucide-react": "^1.56.0",
28
+ "next": "16.4.0",
29
29
  "react": "^19.3.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^26.6.1",
33
33
  "@types/react": "^19.3.0",
34
- "tsdown": "^0.23.0",
34
+ "tsdown": "^0.23.1",
35
35
  "typescript": "^6.0.3"
36
36
  },
37
37
  "scripts": {