@matthieumordrel/chart-studio 0.2.2 → 0.2.4

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.
@@ -9,7 +9,7 @@ import { Area, AreaChart, Bar, BarChart, CartesianGrid, LabelList, Legend, Line,
9
9
  /**
10
10
  * Chart canvas — renders the actual recharts chart based on the current state.
11
11
  *
12
- * Supports: bar, line, area (time-series), bar, pie, donut (categorical).
12
+ * Supports: bar, grouped-bar, percent-bar, line, area, percent-area (time-series), bar, grouped-bar, percent-bar, pie, donut (categorical).
13
13
  * Automatically switches between chart types based on the chart instance state.
14
14
  */
15
15
  /**
@@ -179,10 +179,44 @@ function useContainerWidth() {
179
179
  width
180
180
  };
181
181
  }
182
+ /**
183
+ * Read the resolved `--cs-radius` CSS variable and convert it to a pixel value
184
+ * suitable for recharts bar corner radius (roughly half the theme radius).
185
+ * Observes style attribute mutations on the root element so the chart reacts
186
+ * when the consumer changes `--radius` at runtime.
187
+ */
188
+ function useCssBarRadius() {
189
+ const [radiusPx, setRadiusPx] = useState(4);
190
+ useEffect(() => {
191
+ if (typeof document === "undefined") return;
192
+ const root = document.documentElement;
193
+ function read() {
194
+ const style = getComputedStyle(root);
195
+ const raw = style.getPropertyValue("--cs-radius").trim();
196
+ if (!raw) return;
197
+ const rem = parseFloat(raw);
198
+ if (Number.isNaN(rem)) return;
199
+ const fontSize = parseFloat(style.fontSize) || 16;
200
+ setRadiusPx(Math.max(0, Math.round(rem * fontSize / 2)));
201
+ }
202
+ read();
203
+ const observer = new MutationObserver(read);
204
+ observer.observe(root, {
205
+ attributes: true,
206
+ attributeFilter: [
207
+ "style",
208
+ "class",
209
+ "data-theme"
210
+ ]
211
+ });
212
+ return () => observer.disconnect();
213
+ }, []);
214
+ return radiusPx;
215
+ }
182
216
  /** Renders the appropriate recharts chart based on the chart instance state. */
183
217
  function ChartCanvas({ height = 300, className, showDataLabels = false }) {
184
218
  const chart = useChartContext();
185
- const { chartType, transformedData, series } = chart;
219
+ const { chartType, transformedData, series, connectNulls } = chart;
186
220
  const { ref, width } = useContainerWidth();
187
221
  const xColumn = chart.columns.find((column) => column.id === chart.xAxisId) ?? null;
188
222
  const aggregateMetric = chart.metric.kind === "aggregate" ? chart.metric : null;
@@ -217,7 +251,8 @@ function ChartCanvas({ height = 300, className, showDataLabels = false }) {
217
251
  allowDecimalTicks,
218
252
  xColumn,
219
253
  timeBucket: chart.isTimeSeries ? chart.timeBucket : void 0,
220
- showDataLabels
254
+ showDataLabels,
255
+ connectNulls
221
256
  }) : chartType === "line" ? /* @__PURE__ */ jsx(LineChartRenderer, {
222
257
  data: transformedData,
223
258
  series,
@@ -228,7 +263,20 @@ function ChartCanvas({ height = 300, className, showDataLabels = false }) {
228
263
  allowDecimalTicks,
229
264
  xColumn,
230
265
  timeBucket: chart.isTimeSeries ? chart.timeBucket : void 0,
231
- showDataLabels
266
+ showDataLabels,
267
+ connectNulls
268
+ }) : chartType === "percent-area" ? /* @__PURE__ */ jsx(PercentAreaChartRenderer, {
269
+ data: transformedData,
270
+ series,
271
+ width,
272
+ height,
273
+ valueColumn,
274
+ valueRange,
275
+ allowDecimalTicks,
276
+ xColumn,
277
+ timeBucket: chart.isTimeSeries ? chart.timeBucket : void 0,
278
+ showDataLabels,
279
+ connectNulls
232
280
  }) : chartType === "area" ? /* @__PURE__ */ jsx(AreaChartRenderer, {
233
281
  data: transformedData,
234
282
  series,
@@ -239,7 +287,32 @@ function ChartCanvas({ height = 300, className, showDataLabels = false }) {
239
287
  allowDecimalTicks,
240
288
  xColumn,
241
289
  timeBucket: chart.isTimeSeries ? chart.timeBucket : void 0,
242
- showDataLabels
290
+ showDataLabels,
291
+ connectNulls
292
+ }) : chartType === "grouped-bar" ? /* @__PURE__ */ jsx(GroupedBarChartRenderer, {
293
+ data: transformedData,
294
+ series,
295
+ width,
296
+ height,
297
+ valueColumn,
298
+ valueRange,
299
+ allowDecimalTicks,
300
+ xColumn,
301
+ timeBucket: chart.isTimeSeries ? chart.timeBucket : void 0,
302
+ showDataLabels,
303
+ connectNulls
304
+ }) : chartType === "percent-bar" ? /* @__PURE__ */ jsx(PercentBarChartRenderer, {
305
+ data: transformedData,
306
+ series,
307
+ width,
308
+ height,
309
+ valueColumn,
310
+ valueRange,
311
+ allowDecimalTicks,
312
+ xColumn,
313
+ timeBucket: chart.isTimeSeries ? chart.timeBucket : void 0,
314
+ showDataLabels,
315
+ connectNulls
243
316
  }) : /* @__PURE__ */ jsx(BarChartRenderer, {
244
317
  data: transformedData,
245
318
  series,
@@ -250,11 +323,39 @@ function ChartCanvas({ height = 300, className, showDataLabels = false }) {
250
323
  allowDecimalTicks,
251
324
  xColumn,
252
325
  timeBucket: chart.isTimeSeries ? chart.timeBucket : void 0,
253
- showDataLabels
326
+ showDataLabels,
327
+ connectNulls
254
328
  }))
255
329
  });
256
330
  }
257
331
  /**
332
+ * Percent-stacked charts use stackOffset="expand" which normalizes values to
333
+ * the 0–1 range. These constants let the shared formatting pipeline treat
334
+ * them as proper percentages via Intl.NumberFormat({ style: 'percent' }).
335
+ */
336
+ const PERCENT_STACKED_COLUMN = {
337
+ type: "number",
338
+ format: "percent",
339
+ formatter: void 0
340
+ };
341
+ const PERCENT_STACKED_RANGE = {
342
+ min: 0,
343
+ max: 1
344
+ };
345
+ /**
346
+ * Remove data points where every series value is null.
347
+ *
348
+ * Stacked charts (percent-area, percent-bar) cannot represent null in the
349
+ * stack — d3's stack layout coerces missing values to 0 which distorts the
350
+ * visual. Dropping entirely-empty buckets lets `connectNulls` bridge the
351
+ * gap while keeping partially-populated buckets intact (null → 0 is
352
+ * acceptable there because the segment genuinely contributes nothing to the
353
+ * total).
354
+ */
355
+ function filterAllNullPoints(data, series) {
356
+ return data.filter((point) => series.some((s) => point[s.dataKey] != null));
357
+ }
358
+ /**
258
359
  * Shared shell for all Cartesian chart types.
259
360
  * Owns the grid, axes, tooltip, and legend — the only things that change
260
361
  * per chart type are the root component and the series element.
@@ -336,30 +437,37 @@ function formatXAxisValue(value, xColumn, timeBucket, surface) {
336
437
  }
337
438
  function BarChartRenderer(props) {
338
439
  const { series, showDataLabels, valueColumn, valueRange } = props;
440
+ const barRadius = useCssBarRadius();
441
+ const isStacked = series.length > 1;
442
+ const topSeriesKey = series[series.length - 1]?.dataKey;
339
443
  return /* @__PURE__ */ jsx(CartesianChartShell, {
340
444
  ...props,
341
445
  Chart: BarChart,
342
- renderSeries: (s) => /* @__PURE__ */ jsx(Bar, {
343
- dataKey: s.dataKey,
344
- name: s.label,
345
- fill: s.color,
346
- radius: [
347
- 4,
348
- 4,
349
- 0,
350
- 0
351
- ],
352
- stackId: series.length > 1 ? "stack" : void 0,
353
- children: showDataLabels && /* @__PURE__ */ jsx(LabelList, {
354
- position: "top",
355
- offset: 8,
356
- formatter: (value) => formatDataLabel(value, valueColumn, valueRange)
357
- })
358
- }, s.dataKey)
446
+ renderSeries: (s) => {
447
+ const isTop = !isStacked || s.dataKey === topSeriesKey;
448
+ return /* @__PURE__ */ jsx(Bar, {
449
+ dataKey: s.dataKey,
450
+ name: s.label,
451
+ fill: s.color,
452
+ fillOpacity: .7,
453
+ radius: isTop ? [
454
+ barRadius,
455
+ barRadius,
456
+ 0,
457
+ 0
458
+ ] : 0,
459
+ stackId: isStacked ? "stack" : void 0,
460
+ children: showDataLabels && /* @__PURE__ */ jsx(LabelList, {
461
+ position: "top",
462
+ offset: 8,
463
+ formatter: (value) => formatDataLabel(value, valueColumn, valueRange)
464
+ })
465
+ }, s.dataKey);
466
+ }
359
467
  });
360
468
  }
361
469
  function LineChartRenderer(props) {
362
- const { showDataLabels, valueColumn, valueRange } = props;
470
+ const { showDataLabels, valueColumn, valueRange, connectNulls } = props;
363
471
  return /* @__PURE__ */ jsx(CartesianChartShell, {
364
472
  ...props,
365
473
  Chart: LineChart,
@@ -371,6 +479,7 @@ function LineChartRenderer(props) {
371
479
  strokeWidth: 2,
372
480
  dot: { r: 3 },
373
481
  activeDot: { r: 5 },
482
+ connectNulls,
374
483
  children: showDataLabels && /* @__PURE__ */ jsx(LabelList, {
375
484
  position: "top",
376
485
  offset: 8,
@@ -380,7 +489,7 @@ function LineChartRenderer(props) {
380
489
  });
381
490
  }
382
491
  function AreaChartRenderer(props) {
383
- const { series, showDataLabels, valueColumn, valueRange } = props;
492
+ const { showDataLabels, valueColumn, valueRange, connectNulls } = props;
384
493
  return /* @__PURE__ */ jsx(CartesianChartShell, {
385
494
  ...props,
386
495
  Chart: AreaChart,
@@ -391,7 +500,7 @@ function AreaChartRenderer(props) {
391
500
  stroke: s.color,
392
501
  fill: s.color,
393
502
  fillOpacity: .3,
394
- stackId: series.length > 1 ? "stack" : void 0,
503
+ connectNulls,
395
504
  children: showDataLabels && /* @__PURE__ */ jsx(LabelList, {
396
505
  position: "top",
397
506
  offset: 8,
@@ -400,6 +509,175 @@ function AreaChartRenderer(props) {
400
509
  }, s.dataKey)
401
510
  });
402
511
  }
512
+ function PercentAreaChartRenderer(props) {
513
+ const { series, data, xColumn, timeBucket, showDataLabels, connectNulls, width, height } = props;
514
+ const stackableData = filterAllNullPoints(data, series);
515
+ const yAxisWidth = estimateYAxisWidth(PERCENT_STACKED_RANGE, PERCENT_STACKED_COLUMN);
516
+ const xAxisTickValues = selectVisibleXAxisTicks({
517
+ values: stackableData.map(getXAxisTickValue),
518
+ labels: stackableData.map((point) => formatXAxisValue(getXAxisTickValue(point), xColumn, timeBucket, "axis")),
519
+ plotWidth: getCartesianPlotWidth(width, yAxisWidth),
520
+ minimumTickGap: X_AXIS_MINIMUM_TICK_GAP,
521
+ measureLabelWidth: measureAxisLabelWidth
522
+ });
523
+ return /* @__PURE__ */ jsxs(AreaChart, {
524
+ data: stackableData,
525
+ width,
526
+ height,
527
+ margin: getCartesianChartMargin(showDataLabels),
528
+ stackOffset: "expand",
529
+ children: [
530
+ /* @__PURE__ */ jsx(CartesianGrid, {
531
+ vertical: false,
532
+ strokeDasharray: "3 3"
533
+ }),
534
+ /* @__PURE__ */ jsx(XAxis, {
535
+ dataKey: "xKey",
536
+ tickLine: false,
537
+ axisLine: false,
538
+ tickMargin: 8,
539
+ interval: 0,
540
+ padding: CARTESIAN_X_AXIS_PADDING,
541
+ ticks: xAxisTickValues,
542
+ tickFormatter: (value) => formatXAxisValue(value, xColumn, timeBucket, "axis")
543
+ }),
544
+ /* @__PURE__ */ jsx(YAxis, {
545
+ tickLine: false,
546
+ axisLine: false,
547
+ tickMargin: 4,
548
+ tickFormatter: (value) => typeof value === "number" ? formatChartValue(value, {
549
+ column: PERCENT_STACKED_COLUMN,
550
+ surface: "axis",
551
+ numericRange: PERCENT_STACKED_RANGE
552
+ }) : String(value),
553
+ width: yAxisWidth
554
+ }),
555
+ /* @__PURE__ */ jsx(Tooltip, {
556
+ formatter: (value) => typeof value === "number" ? formatChartValue(value, {
557
+ column: PERCENT_STACKED_COLUMN,
558
+ surface: "tooltip",
559
+ numericRange: PERCENT_STACKED_RANGE
560
+ }) : value,
561
+ labelFormatter: (label, payload) => formatTooltipLabel(label, payload, xColumn, timeBucket)
562
+ }),
563
+ series.length > 1 && /* @__PURE__ */ jsx(Legend, {}),
564
+ series.map((s) => /* @__PURE__ */ jsx(Area, {
565
+ type: "monotone",
566
+ dataKey: s.dataKey,
567
+ name: s.label,
568
+ stroke: s.color,
569
+ fill: s.color,
570
+ fillOpacity: .3,
571
+ stackId: "percent",
572
+ connectNulls,
573
+ children: showDataLabels && /* @__PURE__ */ jsx(LabelList, {
574
+ position: "top",
575
+ offset: 8,
576
+ formatter: (value) => formatDataLabel(value, PERCENT_STACKED_COLUMN, PERCENT_STACKED_RANGE)
577
+ })
578
+ }, s.dataKey))
579
+ ]
580
+ });
581
+ }
582
+ function GroupedBarChartRenderer(props) {
583
+ const { showDataLabels, valueColumn, valueRange } = props;
584
+ const barRadius = useCssBarRadius();
585
+ return /* @__PURE__ */ jsx(CartesianChartShell, {
586
+ ...props,
587
+ Chart: BarChart,
588
+ renderSeries: (s) => /* @__PURE__ */ jsx(Bar, {
589
+ dataKey: s.dataKey,
590
+ name: s.label,
591
+ fill: s.color,
592
+ fillOpacity: .7,
593
+ radius: [
594
+ barRadius,
595
+ barRadius,
596
+ 0,
597
+ 0
598
+ ],
599
+ children: showDataLabels && /* @__PURE__ */ jsx(LabelList, {
600
+ position: "top",
601
+ offset: 8,
602
+ formatter: (value) => formatDataLabel(value, valueColumn, valueRange)
603
+ })
604
+ }, s.dataKey)
605
+ });
606
+ }
607
+ function PercentBarChartRenderer(props) {
608
+ const { series, data, xColumn, timeBucket, showDataLabels, width, height } = props;
609
+ const barRadius = useCssBarRadius();
610
+ const topSeriesKey = series[series.length - 1]?.dataKey;
611
+ const yAxisWidth = estimateYAxisWidth({
612
+ min: 0,
613
+ max: 100
614
+ }, {
615
+ type: "number",
616
+ format: void 0,
617
+ formatter: void 0
618
+ });
619
+ const xAxisTickValues = selectVisibleXAxisTicks({
620
+ values: data.map(getXAxisTickValue),
621
+ labels: data.map((point) => formatXAxisValue(getXAxisTickValue(point), xColumn, timeBucket, "axis")),
622
+ plotWidth: getCartesianPlotWidth(width, yAxisWidth),
623
+ minimumTickGap: X_AXIS_MINIMUM_TICK_GAP,
624
+ measureLabelWidth: measureAxisLabelWidth
625
+ });
626
+ return /* @__PURE__ */ jsxs(BarChart, {
627
+ data,
628
+ width,
629
+ height,
630
+ margin: getCartesianChartMargin(showDataLabels),
631
+ stackOffset: "expand",
632
+ children: [
633
+ /* @__PURE__ */ jsx(CartesianGrid, {
634
+ vertical: false,
635
+ strokeDasharray: "3 3"
636
+ }),
637
+ /* @__PURE__ */ jsx(XAxis, {
638
+ dataKey: "xKey",
639
+ tickLine: false,
640
+ axisLine: false,
641
+ tickMargin: 8,
642
+ interval: 0,
643
+ padding: CARTESIAN_X_AXIS_PADDING,
644
+ ticks: xAxisTickValues,
645
+ tickFormatter: (value) => formatXAxisValue(value, xColumn, timeBucket, "axis")
646
+ }),
647
+ /* @__PURE__ */ jsx(YAxis, {
648
+ tickLine: false,
649
+ axisLine: false,
650
+ tickMargin: 4,
651
+ tickFormatter: (value) => typeof value === "number" ? `${Math.round(value * 100)}%` : String(value),
652
+ width: yAxisWidth
653
+ }),
654
+ /* @__PURE__ */ jsx(Tooltip, {
655
+ formatter: (value, name) => {
656
+ if (typeof value === "number") return [`${(value * 100).toFixed(1)}%`, name];
657
+ return [value, name];
658
+ },
659
+ labelFormatter: (label, payload) => formatTooltipLabel(label, payload, xColumn, timeBucket)
660
+ }),
661
+ series.length > 1 && /* @__PURE__ */ jsx(Legend, {}),
662
+ series.map((s) => {
663
+ const isTop = s.dataKey === topSeriesKey;
664
+ return /* @__PURE__ */ jsx(Bar, {
665
+ dataKey: s.dataKey,
666
+ name: s.label,
667
+ fill: s.color,
668
+ fillOpacity: .7,
669
+ radius: isTop ? [
670
+ barRadius,
671
+ barRadius,
672
+ 0,
673
+ 0
674
+ ] : 0,
675
+ stackId: "percent"
676
+ }, s.dataKey);
677
+ })
678
+ ]
679
+ });
680
+ }
403
681
  function PieChartRenderer({ data, series, innerRadius, width, height, valueColumn, valueRange, xColumn, timeBucket, showDataLabels }) {
404
682
  const valueKey = series[0]?.dataKey;
405
683
  const pieData = data.map((point, index) => {
@@ -455,7 +733,7 @@ function formatDataLabel(value, valueColumn, valueRange) {
455
733
  * default while tooltips and raw values remain unchanged.
456
734
  */
457
735
  function shouldHideDataLabel(value) {
458
- return typeof value === "number" && value === 0;
736
+ return value == null || typeof value === "number" && value === 0;
459
737
  }
460
738
  //#endregion
461
739
  export { ChartCanvas };
@@ -35,6 +35,7 @@ type AnyChartInstance = {
35
35
  setTimeBucket: (...args: any[]) => unknown;
36
36
  availableTimeBuckets: ChartContextChart['availableTimeBuckets'];
37
37
  isTimeSeries: boolean;
38
+ connectNulls: boolean;
38
39
  filters: Map<any, Set<string>>;
39
40
  toggleFilter: (...args: any[]) => unknown;
40
41
  clearFilter: (...args: any[]) => unknown;
@@ -46,6 +47,8 @@ type AnyChartInstance = {
46
47
  referenceDateId: string | null;
47
48
  setReferenceDateId: (...args: any[]) => unknown;
48
49
  availableDateColumns: ChartContextChart['availableDateColumns'];
50
+ dateRangePreset: ChartContextChart['dateRangePreset'];
51
+ setDateRangePreset: (...args: any[]) => unknown;
49
52
  dateRangeFilter: ChartContextChart['dateRangeFilter'];
50
53
  setDateRangeFilter: (...args: any[]) => unknown;
51
54
  transformedData: ChartContextChart['transformedData'];
@@ -50,6 +50,7 @@ function createChartContextChart(chart) {
50
50
  setTimeBucket: chart.setTimeBucket,
51
51
  availableTimeBuckets: chart.availableTimeBuckets,
52
52
  isTimeSeries: chart.isTimeSeries,
53
+ connectNulls: chart.connectNulls,
53
54
  filters: new Map(chart.filters),
54
55
  toggleFilter: (columnId, value) => {
55
56
  if (!isKnownColumnId(columnIds, columnId)) throw new Error(`Unknown chart column ID: "${columnId}"`);
@@ -70,6 +71,8 @@ function createChartContextChart(chart) {
70
71
  chart.setReferenceDateId(columnId);
71
72
  },
72
73
  availableDateColumns: chart.availableDateColumns,
74
+ dateRangePreset: chart.dateRangePreset,
75
+ setDateRangePreset: chart.setDateRangePreset,
73
76
  dateRangeFilter: chart.dateRangeFilter,
74
77
  setDateRangeFilter: chart.setDateRangeFilter,
75
78
  transformedData: chart.transformedData,
@@ -22,9 +22,9 @@ function formatDate(date) {
22
22
  * Renders nothing if no date columns are available.
23
23
  */
24
24
  function ChartDateRangeBadge({ className }) {
25
- const { dateRange, dateRangeFilter, availableDateColumns } = useChartContext();
25
+ const { dateRange, dateRangePreset, availableDateColumns } = useChartContext();
26
26
  if (availableDateColumns.length === 0) return null;
27
- const activeLabel = resolvePresetLabel(dateRangeFilter);
27
+ const activeLabel = resolvePresetLabel(dateRangePreset);
28
28
  const hasRange = dateRange?.min && dateRange?.max;
29
29
  return /* @__PURE__ */ jsxs("div", {
30
30
  className: `inline-flex h-7 items-center gap-1.5 rounded-lg border border-border/50 bg-muted/30 px-2.5 text-xs text-muted-foreground ${className ?? ""}`,
@@ -1,86 +1,14 @@
1
+ import { DATE_RANGE_PRESETS, getPresetLabel } from "../core/date-range-presets.mjs";
1
2
  import { useChartContext } from "./chart-context.mjs";
2
- import { useMemo } from "react";
3
3
  import { jsx, jsxs } from "react/jsx-runtime";
4
4
  //#region src/ui/chart-date-range-panel.tsx
5
5
  /**
6
6
  * Date range panel content — reusable by both ChartDateRange (inside a popover)
7
7
  * and ChartToolbarOverflow (rendered inline).
8
8
  *
9
- * Shows preset buttons (All time, Last 7 days, etc.), a reference date
9
+ * Shows preset buttons (Auto, All time, Last 7 days, etc.), a reference date
10
10
  * column picker, and custom date inputs.
11
11
  */
12
- /**
13
- * Build the list of date range presets relative to "now".
14
- *
15
- * "All time" → null (no date filtering)
16
- * Other presets → { from: Date, to: null } (bounded filter)
17
- */
18
- function getPresets() {
19
- return [
20
- {
21
- label: "All time",
22
- buildFilter: () => null
23
- },
24
- {
25
- label: "Last 7 days",
26
- buildFilter: () => ({
27
- from: daysAgo(7),
28
- to: null
29
- })
30
- },
31
- {
32
- label: "Last 30 days",
33
- buildFilter: () => ({
34
- from: daysAgo(30),
35
- to: null
36
- })
37
- },
38
- {
39
- label: "Last 3 months",
40
- buildFilter: () => ({
41
- from: monthsAgo(3),
42
- to: null
43
- })
44
- },
45
- {
46
- label: "Last 6 months",
47
- buildFilter: () => ({
48
- from: monthsAgo(6),
49
- to: null
50
- })
51
- },
52
- {
53
- label: "Last 12 months",
54
- buildFilter: () => ({
55
- from: monthsAgo(12),
56
- to: null
57
- })
58
- },
59
- {
60
- label: "Year to date",
61
- buildFilter: () => ({
62
- from: startOfYear(),
63
- to: null
64
- })
65
- }
66
- ];
67
- }
68
- function daysAgo(n) {
69
- const d = /* @__PURE__ */ new Date();
70
- d.setDate(d.getDate() - n);
71
- d.setHours(0, 0, 0, 0);
72
- return d;
73
- }
74
- function monthsAgo(n) {
75
- const d = /* @__PURE__ */ new Date();
76
- d.setMonth(d.getMonth() - n);
77
- d.setHours(0, 0, 0, 0);
78
- return d;
79
- }
80
- function startOfYear() {
81
- const d = /* @__PURE__ */ new Date();
82
- return new Date(d.getFullYear(), 0, 1);
83
- }
84
12
  /** Format a Date as YYYY-MM-DD for native date input value. */
85
13
  function toInputValue(date) {
86
14
  if (!date) return "";
@@ -93,24 +21,14 @@ function fromInputValue(value) {
93
21
  return Number.isNaN(d.getTime()) ? null : d;
94
22
  }
95
23
  /**
96
- * Determine which preset label matches the current filter, or "Custom".
97
- * null → "All time" (no date filtering)
98
- * Compares dates at day-level precision.
24
+ * Resolve the display label for the current date range state.
25
+ *
26
+ * When a preset is active, returns the preset label.
27
+ * When no preset is active (custom range), returns "Custom".
99
28
  */
100
- function resolvePresetLabel(filter) {
101
- if (filter === null) return "All time";
102
- const presets = getPresets();
103
- for (const preset of presets) {
104
- const pf = preset.buildFilter();
105
- if (pf === null) continue;
106
- if (sameDay(pf.from, filter.from) && sameDay(pf.to, filter.to)) return preset.label;
107
- }
108
- return "Custom";
109
- }
110
- function sameDay(a, b) {
111
- if (!a && !b) return true;
112
- if (!a || !b) return false;
113
- return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
29
+ function resolvePresetLabel(dateRangePreset) {
30
+ if (dateRangePreset === null) return "Custom";
31
+ return getPresetLabel(dateRangePreset);
114
32
  }
115
33
  /**
116
34
  * Date range panel content (no popover wrapper).
@@ -119,13 +37,11 @@ function sameDay(a, b) {
119
37
  * @property className - Additional CSS classes
120
38
  */
121
39
  function ChartDateRangePanel({ onClose, className }) {
122
- const { dateRangeFilter, setDateRangeFilter, referenceDateId, setReferenceDateId, availableDateColumns } = useChartContext();
123
- const presets = useMemo(() => getPresets(), []);
124
- const activeLabel = resolvePresetLabel(dateRangeFilter);
40
+ const { dateRangePreset, setDateRangePreset, dateRangeFilter, setDateRangeFilter, referenceDateId, setReferenceDateId, availableDateColumns } = useChartContext();
125
41
  const hasMultipleDateColumns = availableDateColumns.length > 1;
126
- const handlePreset = (preset) => {
127
- setDateRangeFilter(preset.buildFilter());
128
- if (preset.label !== "Custom") onClose?.();
42
+ const handlePreset = (presetId) => {
43
+ setDateRangePreset(presetId);
44
+ onClose?.();
129
45
  };
130
46
  const handleCustomFrom = (value) => {
131
47
  setDateRangeFilter({
@@ -166,12 +82,14 @@ function ChartDateRangePanel({ onClose, className }) {
166
82
  children: "Range"
167
83
  }), /* @__PURE__ */ jsx("div", {
168
84
  className: "grid grid-cols-2 gap-1",
169
- children: presets.map((preset) => {
85
+ children: DATE_RANGE_PRESETS.map((preset) => {
86
+ const isActive = dateRangePreset === preset.id;
170
87
  return /* @__PURE__ */ jsx("button", {
171
- onClick: () => handlePreset(preset),
172
- className: `rounded-md px-2 py-1.5 text-left text-xs transition-colors ${activeLabel === preset.label ? "bg-primary/10 font-medium text-primary" : "text-foreground hover:bg-muted"}`,
88
+ onClick: () => handlePreset(preset.id),
89
+ title: preset.description,
90
+ className: `rounded-md px-2 py-1.5 text-left text-xs transition-colors ${isActive ? "bg-primary/10 font-medium text-primary" : "text-foreground hover:bg-muted"}`,
173
91
  children: preset.label
174
- }, preset.label);
92
+ }, preset.id);
175
93
  })
176
94
  })]
177
95
  }),
@@ -24,12 +24,12 @@ function formatDate(date) {
24
24
  * Also serves as the reference date column picker when multiple date columns exist.
25
25
  */
26
26
  function ChartDateRange({ className }) {
27
- const { dateRange, dateRangeFilter, availableDateColumns } = useChartContext();
27
+ const { dateRange, dateRangePreset, availableDateColumns } = useChartContext();
28
28
  const [isOpen, setIsOpen] = useState(false);
29
29
  const triggerRef = useRef(null);
30
30
  if (availableDateColumns.length === 0) return null;
31
- const activeLabel = resolvePresetLabel(dateRangeFilter);
32
- const isFiltered = dateRangeFilter !== null;
31
+ const activeLabel = resolvePresetLabel(dateRangePreset);
32
+ const isFiltered = dateRangePreset !== "all-time";
33
33
  const hasRange = dateRange?.min && dateRange?.max;
34
34
  return /* @__PURE__ */ jsxs("div", {
35
35
  className,
@@ -6,9 +6,11 @@ import * as react_jsx_runtime0 from "react/jsx-runtime";
6
6
  */
7
7
  /** Custom dropdown to select the groupBy column. */
8
8
  declare function ChartGroupBySelector({
9
- className
9
+ className,
10
+ hideIcon
10
11
  }: {
11
12
  className?: string;
13
+ hideIcon?: boolean;
12
14
  }): react_jsx_runtime0.JSX.Element | null;
13
15
  //#endregion
14
16
  export { ChartGroupBySelector };
@@ -2,12 +2,13 @@ import { CHART_TYPE_CONFIG } from "../core/chart-capabilities.mjs";
2
2
  import { useChartContext } from "./chart-context.mjs";
3
3
  import { ChartSelect } from "./chart-select.mjs";
4
4
  import { jsx } from "react/jsx-runtime";
5
+ import { Layers } from "lucide-react";
5
6
  //#region src/ui/chart-group-by-selector.tsx
6
7
  /**
7
8
  * GroupBy selector — premium custom dropdown replacing native <select>.
8
9
  */
9
10
  /** Custom dropdown to select the groupBy column. */
10
- function ChartGroupBySelector({ className }) {
11
+ function ChartGroupBySelector({ className, hideIcon }) {
11
12
  const { chartType, groupById, setGroupBy, availableGroupBys } = useChartContext();
12
13
  if (!CHART_TYPE_CONFIG[chartType].supportsGrouping || availableGroupBys.length === 0) return null;
13
14
  const options = [{
@@ -22,6 +23,8 @@ function ChartGroupBySelector({ className }) {
22
23
  options,
23
24
  onChange: (v) => setGroupBy(v || null),
24
25
  ariaLabel: "Group by",
26
+ icon: Layers,
27
+ hideIcon,
25
28
  className
26
29
  });
27
30
  }