@matthieumordrel/chart-studio-ui 0.5.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.
Files changed (46) hide show
  1. package/README.md +35 -0
  2. package/dist/index.d.mts +19 -0
  3. package/dist/index.mjs +18 -0
  4. package/dist/theme.css +67 -0
  5. package/dist/ui/chart-axis-ticks.mjs +65 -0
  6. package/dist/ui/chart-canvas.d.mts +40 -0
  7. package/dist/ui/chart-canvas.mjs +872 -0
  8. package/dist/ui/chart-context.d.mts +101 -0
  9. package/dist/ui/chart-context.mjs +117 -0
  10. package/dist/ui/chart-date-range-badge.d.mts +20 -0
  11. package/dist/ui/chart-date-range-badge.mjs +49 -0
  12. package/dist/ui/chart-date-range-panel.d.mts +18 -0
  13. package/dist/ui/chart-date-range-panel.mjs +126 -0
  14. package/dist/ui/chart-date-range.d.mts +20 -0
  15. package/dist/ui/chart-date-range.mjs +67 -0
  16. package/dist/ui/chart-debug.d.mts +21 -0
  17. package/dist/ui/chart-debug.mjs +172 -0
  18. package/dist/ui/chart-dropdown.mjs +92 -0
  19. package/dist/ui/chart-filters-panel.d.mts +26 -0
  20. package/dist/ui/chart-filters-panel.mjs +258 -0
  21. package/dist/ui/chart-filters.d.mts +18 -0
  22. package/dist/ui/chart-filters.mjs +48 -0
  23. package/dist/ui/chart-group-by-selector.d.mts +16 -0
  24. package/dist/ui/chart-group-by-selector.mjs +32 -0
  25. package/dist/ui/chart-metric-panel.d.mts +25 -0
  26. package/dist/ui/chart-metric-panel.mjs +172 -0
  27. package/dist/ui/chart-metric-selector.d.mts +16 -0
  28. package/dist/ui/chart-metric-selector.mjs +50 -0
  29. package/dist/ui/chart-select.mjs +61 -0
  30. package/dist/ui/chart-source-switcher.d.mts +24 -0
  31. package/dist/ui/chart-source-switcher.mjs +56 -0
  32. package/dist/ui/chart-time-bucket-selector.d.mts +17 -0
  33. package/dist/ui/chart-time-bucket-selector.mjs +37 -0
  34. package/dist/ui/chart-toolbar-overflow.d.mts +28 -0
  35. package/dist/ui/chart-toolbar-overflow.mjs +223 -0
  36. package/dist/ui/chart-toolbar.d.mts +33 -0
  37. package/dist/ui/chart-toolbar.mjs +60 -0
  38. package/dist/ui/chart-type-selector.d.mts +19 -0
  39. package/dist/ui/chart-type-selector.mjs +173 -0
  40. package/dist/ui/chart-x-axis-selector.d.mts +16 -0
  41. package/dist/ui/chart-x-axis-selector.mjs +28 -0
  42. package/dist/ui/index.d.mts +18 -0
  43. package/dist/ui/percent-stacked.mjs +36 -0
  44. package/dist/ui/toolbar-types.d.mts +7 -0
  45. package/dist/ui/toolbar-types.mjs +83 -0
  46. package/package.json +55 -0
@@ -0,0 +1,872 @@
1
+ import { useChartContext } from "./chart-context.mjs";
2
+ import { selectVisibleXAxisTicks } from "./chart-axis-ticks.mjs";
3
+ import { getPercentStackedDisplayValue } from "./percent-stacked.mjs";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ import { Area, AreaChart, Bar, BarChart, CartesianGrid, LabelList, Legend, Line, LineChart, Pie, PieChart, Tooltip, XAxis, YAxis } from "recharts";
7
+ import { DATA_LABEL_DEFAULTS, createNumericRange, formatChartValue, formatTimeBucketLabel, getSeriesColor, resolveShowDataLabels, shouldAllowDecimalTicks } from "@matthieumordrel/chart-studio/_internal";
8
+ //#region src/ui/chart-canvas.tsx
9
+ /**
10
+ * Chart canvas — renders the actual recharts chart based on the current state.
11
+ *
12
+ * Supports: bar, grouped-bar, percent-bar, line, area, percent-area (time-series), bar, grouped-bar, percent-bar, pie, donut (categorical).
13
+ * Automatically switches between chart types based on the chart instance state.
14
+ */
15
+ /**
16
+ * Estimates the pixel width the YAxis needs so no label is ever clipped.
17
+ * Considers both the visible range and the likely "nice" axis boundary Recharts
18
+ * may choose above it, then measures the widest formatted label and adds a
19
+ * small gutter for tick spacing.
20
+ */
21
+ function estimateYAxisWidth(numericRange, valueColumn) {
22
+ const widestLabel = getYAxisLabelCandidates(numericRange, valueColumn).reduce((maxWidth, label) => Math.max(maxWidth, measureAxisLabelWidth(label)), 0);
23
+ return Math.max(MIN_Y_AXIS_WIDTH, Math.ceil(widestLabel + Y_AXIS_WIDTH_GUTTER));
24
+ }
25
+ /**
26
+ * Font used by the shared chart axis ticks (`text-xs` in the default theme).
27
+ */
28
+ const AXIS_TICK_FONT = "12px system-ui, sans-serif";
29
+ /**
30
+ * Canvas measurement can be unavailable in some environments, so keep a
31
+ * slightly generous character-width fallback.
32
+ */
33
+ const FALLBACK_AXIS_CHARACTER_WIDTH = 8;
34
+ /**
35
+ * Minimum width so short numeric axes still have breathing room.
36
+ */
37
+ const MIN_Y_AXIS_WIDTH = 48;
38
+ /**
39
+ * Extra space for tick margin plus a small anti-clipping buffer.
40
+ */
41
+ const Y_AXIS_WIDTH_GUTTER = 18;
42
+ /**
43
+ * Horizontal breathing room kept between two visible X-axis labels.
44
+ */
45
+ const X_AXIS_MINIMUM_TICK_GAP = 8;
46
+ /**
47
+ * Build a small set of realistic axis labels and size for the widest one.
48
+ * This catches cases where the chart data tops out below the rounded axis
49
+ * tick, such as `950` minutes producing a `1000` minute top tick.
50
+ */
51
+ function getYAxisLabelCandidates(numericRange, valueColumn) {
52
+ return getYAxisCandidateValues(numericRange).map((value) => formatChartValue(value, {
53
+ column: valueColumn,
54
+ surface: "axis",
55
+ numericRange
56
+ }));
57
+ }
58
+ /**
59
+ * Include the visible extrema plus rounded axis boundaries so the width
60
+ * estimate matches what the axis is likely to render.
61
+ */
62
+ function getYAxisCandidateValues(numericRange) {
63
+ if (!numericRange) return [0];
64
+ const maxAbs = Math.max(Math.abs(numericRange.min), Math.abs(numericRange.max));
65
+ const niceMaxAbs = getNiceAxisBoundary(maxAbs);
66
+ const values = new Set([
67
+ 0,
68
+ numericRange.min,
69
+ numericRange.max,
70
+ maxAbs,
71
+ niceMaxAbs
72
+ ]);
73
+ if (numericRange.min < 0) values.add(-niceMaxAbs);
74
+ return Array.from(values);
75
+ }
76
+ /**
77
+ * Approximate the rounded outer tick value chart libraries tend to choose for
78
+ * a human-friendly numeric axis.
79
+ */
80
+ function getNiceAxisBoundary(value) {
81
+ if (!Number.isFinite(value) || value <= 0) return 0;
82
+ const magnitude = 10 ** Math.floor(Math.log10(value));
83
+ const fraction = value / magnitude;
84
+ if (fraction <= 1) return magnitude;
85
+ if (fraction <= 2) return 2 * magnitude;
86
+ if (fraction <= 5) return 5 * magnitude;
87
+ return 10 * magnitude;
88
+ }
89
+ /**
90
+ * Measure axis text width in the browser and fall back to a safe character
91
+ * estimate in non-DOM environments.
92
+ */
93
+ function measureAxisLabelWidth(label) {
94
+ if (typeof document === "undefined") return label.length * FALLBACK_AXIS_CHARACTER_WIDTH;
95
+ const context = document.createElement("canvas").getContext("2d");
96
+ if (!context) return label.length * FALLBACK_AXIS_CHARACTER_WIDTH;
97
+ context.font = AXIS_TICK_FONT;
98
+ return context.measureText(label).width;
99
+ }
100
+ /**
101
+ * Approximate the drawable X-axis width after margins, Y-axis labels, and axis
102
+ * padding have taken their share of the SVG width.
103
+ */
104
+ function getCartesianPlotWidth(totalWidth, yAxisWidth) {
105
+ return Math.max(1, totalWidth - yAxisWidth - CARTESIAN_BASE_MARGIN.left - CARTESIAN_BASE_MARGIN.right - CARTESIAN_X_AXIS_PADDING.left - CARTESIAN_X_AXIS_PADDING.right);
106
+ }
107
+ /**
108
+ * Resolve the raw categorical tick value used by Recharts for one transformed
109
+ * pipeline point.
110
+ */
111
+ function getXAxisTickValue(point) {
112
+ return typeof point["xKey"] === "string" || typeof point["xKey"] === "number" ? point["xKey"] : String(point["xLabel"]);
113
+ }
114
+ /**
115
+ * Important recharts styling — mirrors shadcn's ChartContainer CSS.
116
+ * Ensures proper text colors, grid lines, and outline handling.
117
+ */
118
+ const RECHARTS_STYLES = [
119
+ "text-xs",
120
+ "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground",
121
+ "[&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50",
122
+ "[&_.recharts-curve.recharts-tooltip-cursor]:stroke-border",
123
+ "[&_.recharts-layer]:outline-hidden",
124
+ "[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted",
125
+ "[&_.recharts-reference-line_[stroke='#ccc']]:stroke-border",
126
+ "[&_.recharts-sector]:outline-hidden",
127
+ "[&_.recharts-surface]:outline-hidden"
128
+ ].join(" ");
129
+ /**
130
+ * Reserve enough top padding for the tallest bar/point label plus its offset so
131
+ * data labels never clip against the SVG edge.
132
+ */
133
+ const CARTESIAN_BASE_MARGIN = {
134
+ top: 4,
135
+ right: 8,
136
+ left: 0,
137
+ bottom: 0
138
+ };
139
+ /**
140
+ * Vertical headroom required for one top-positioned cartesian data label.
141
+ */
142
+ const CARTESIAN_DATA_LABEL_TOP_CLEARANCE = 28;
143
+ /**
144
+ * Expand the cartesian chart margin only when top-positioned data labels are
145
+ * enabled.
146
+ */
147
+ function getCartesianChartMargin(showDataLabels) {
148
+ return showDataLabels ? {
149
+ ...CARTESIAN_BASE_MARGIN,
150
+ top: CARTESIAN_BASE_MARGIN.top + CARTESIAN_DATA_LABEL_TOP_CLEARANCE
151
+ } : CARTESIAN_BASE_MARGIN;
152
+ }
153
+ /**
154
+ * Keep the first and last rendered values from hugging the chart edges.
155
+ */
156
+ const CARTESIAN_X_AXIS_PADDING = {
157
+ left: 12,
158
+ right: 12
159
+ };
160
+ /**
161
+ * Hook that measures a container's width using ResizeObserver.
162
+ * Avoids the ResponsiveContainer issues with flexbox/grid layouts.
163
+ */
164
+ function useContainerWidth() {
165
+ const ref = useRef(null);
166
+ const [width, setWidth] = useState(0);
167
+ useEffect(() => {
168
+ const el = ref.current;
169
+ if (!el) return;
170
+ const observer = new ResizeObserver((entries) => {
171
+ const entry = entries[0];
172
+ if (entry) setWidth(entry.contentRect.width);
173
+ });
174
+ observer.observe(el);
175
+ return () => observer.disconnect();
176
+ }, []);
177
+ return {
178
+ ref,
179
+ width
180
+ };
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
+ }
216
+ /** Renders the appropriate recharts chart based on the chart instance state.
217
+ * @param height - Chart height in pixels (default: 300)
218
+ * @param className - Additional CSS classes
219
+ * @param showDataLabels - Control data label visibility: `true`/`false` for explicit control, or `'auto'` (default) to let {@link DATA_LABEL_DEFAULTS} decide per chart type and time bucket.
220
+ */
221
+ function ChartCanvas({ height = 300, className, showDataLabels = "auto" }) {
222
+ const chart = useChartContext();
223
+ const { chartType, transformedData, series, connectNulls } = chart;
224
+ const timeBucket = chart.isTimeSeries ? chart.timeBucket : void 0;
225
+ const resolvedShowDataLabels = resolveShowDataLabels(chartType, timeBucket, showDataLabels);
226
+ const dataLabelStyle = DATA_LABEL_DEFAULTS[chartType].style;
227
+ const { ref, width } = useContainerWidth();
228
+ const xColumn = chart.columns.find((column) => column.id === chart.xAxisId) ?? null;
229
+ const aggregateMetric = chart.metric.kind === "aggregate" ? chart.metric : null;
230
+ const valueColumn = (aggregateMetric ? chart.columns.find((column) => column.id === aggregateMetric.columnId && column.type === "number") : null) ?? {
231
+ type: "number",
232
+ format: void 0
233
+ };
234
+ const numericValues = transformedData.flatMap((point) => series.flatMap((seriesItem) => {
235
+ const value = point[seriesItem.dataKey];
236
+ return typeof value === "number" ? [value] : [];
237
+ }));
238
+ const valueRange = createNumericRange(numericValues);
239
+ const allowDecimalTicks = shouldAllowDecimalTicks(numericValues);
240
+ if (transformedData.length === 0) return /* @__PURE__ */ jsx("div", {
241
+ ref,
242
+ className: `flex items-center justify-center text-sm text-muted-foreground ${className ?? ""}`,
243
+ style: { height },
244
+ children: "No data available"
245
+ });
246
+ if (chartType === "table") return /* @__PURE__ */ jsx("div", {
247
+ ref,
248
+ children: /* @__PURE__ */ jsx(TableRenderer, {
249
+ data: transformedData,
250
+ series,
251
+ height,
252
+ className,
253
+ valueColumn,
254
+ valueRange,
255
+ xColumn,
256
+ timeBucket
257
+ })
258
+ });
259
+ return /* @__PURE__ */ jsx("div", {
260
+ ref,
261
+ className: `${RECHARTS_STYLES} ${className ?? ""}`,
262
+ style: { height },
263
+ children: width > 0 && (chartType === "pie" || chartType === "donut" ? /* @__PURE__ */ jsx(PieChartRenderer, {
264
+ data: transformedData,
265
+ series,
266
+ innerRadius: chartType === "donut",
267
+ width,
268
+ height,
269
+ valueColumn,
270
+ valueRange,
271
+ allowDecimalTicks,
272
+ xColumn,
273
+ timeBucket,
274
+ showDataLabels: resolvedShowDataLabels,
275
+ dataLabelStyle,
276
+ connectNulls
277
+ }) : chartType === "line" ? /* @__PURE__ */ jsx(LineChartRenderer, {
278
+ data: transformedData,
279
+ series,
280
+ width,
281
+ height,
282
+ valueColumn,
283
+ valueRange,
284
+ allowDecimalTicks,
285
+ xColumn,
286
+ timeBucket,
287
+ showDataLabels: resolvedShowDataLabels,
288
+ dataLabelStyle,
289
+ connectNulls
290
+ }) : chartType === "percent-area" ? /* @__PURE__ */ jsx(PercentAreaChartRenderer, {
291
+ data: transformedData,
292
+ series,
293
+ width,
294
+ height,
295
+ valueColumn,
296
+ valueRange,
297
+ allowDecimalTicks,
298
+ xColumn,
299
+ timeBucket,
300
+ showDataLabels: resolvedShowDataLabels,
301
+ dataLabelStyle,
302
+ connectNulls
303
+ }) : chartType === "area" ? /* @__PURE__ */ jsx(AreaChartRenderer, {
304
+ data: transformedData,
305
+ series,
306
+ width,
307
+ height,
308
+ valueColumn,
309
+ valueRange,
310
+ allowDecimalTicks,
311
+ xColumn,
312
+ timeBucket,
313
+ showDataLabels: resolvedShowDataLabels,
314
+ dataLabelStyle,
315
+ connectNulls
316
+ }) : chartType === "grouped-bar" ? /* @__PURE__ */ jsx(GroupedBarChartRenderer, {
317
+ data: transformedData,
318
+ series,
319
+ width,
320
+ height,
321
+ valueColumn,
322
+ valueRange,
323
+ allowDecimalTicks,
324
+ xColumn,
325
+ timeBucket,
326
+ showDataLabels: resolvedShowDataLabels,
327
+ dataLabelStyle,
328
+ connectNulls
329
+ }) : chartType === "percent-bar" ? /* @__PURE__ */ jsx(PercentBarChartRenderer, {
330
+ data: transformedData,
331
+ series,
332
+ width,
333
+ height,
334
+ valueColumn,
335
+ valueRange,
336
+ allowDecimalTicks,
337
+ xColumn,
338
+ timeBucket,
339
+ showDataLabels: resolvedShowDataLabels,
340
+ dataLabelStyle,
341
+ connectNulls
342
+ }) : /* @__PURE__ */ jsx(BarChartRenderer, {
343
+ data: transformedData,
344
+ series,
345
+ width,
346
+ height,
347
+ valueColumn,
348
+ valueRange,
349
+ allowDecimalTicks,
350
+ xColumn,
351
+ timeBucket,
352
+ showDataLabels: resolvedShowDataLabels,
353
+ dataLabelStyle,
354
+ connectNulls
355
+ }))
356
+ });
357
+ }
358
+ /**
359
+ * Percent-stacked charts use stackOffset="expand" which normalizes values to
360
+ * the 0–1 range. These constants let the shared formatting pipeline treat
361
+ * them as proper percentages via Intl.NumberFormat({ style: 'percent' }).
362
+ */
363
+ const PERCENT_STACKED_COLUMN = {
364
+ type: "number",
365
+ format: "percent"
366
+ };
367
+ const PERCENT_STACKED_RANGE = {
368
+ min: 0,
369
+ max: 1
370
+ };
371
+ function createStackedTooltipItemSorter(series) {
372
+ const order = new Map(series.map((s, index) => [s.dataKey, index]));
373
+ return (item) => {
374
+ const dataKey = typeof item.dataKey === "string" ? item.dataKey : typeof item.name === "string" ? item.name : "";
375
+ return -(order.get(dataKey) ?? -1);
376
+ };
377
+ }
378
+ /**
379
+ * Remove data points where every series value is null.
380
+ *
381
+ * Stacked charts (percent-area, percent-bar) cannot represent null in the
382
+ * stack — d3's stack layout coerces missing values to 0 which distorts the
383
+ * visual. Dropping entirely-empty buckets lets `connectNulls` bridge the
384
+ * gap while keeping partially-populated buckets intact (null → 0 is
385
+ * acceptable there because the segment genuinely contributes nothing to the
386
+ * total).
387
+ */
388
+ function filterAllNullPoints(data, series) {
389
+ return data.filter((point) => series.some((s) => point[s.dataKey] != null));
390
+ }
391
+ /**
392
+ * Shared shell for all Cartesian chart types.
393
+ * Owns the grid, axes, tooltip, and legend — the only things that change
394
+ * per chart type are the root component and the series element.
395
+ */
396
+ function CartesianChartShell({ data, series, width, height, valueColumn, valueRange, allowDecimalTicks, xColumn, timeBucket, showDataLabels, Chart, renderSeries, tooltipItemSorter }) {
397
+ const yAxisWidth = estimateYAxisWidth(valueRange, valueColumn);
398
+ const xAxisTickValues = selectVisibleXAxisTicks({
399
+ values: data.map(getXAxisTickValue),
400
+ labels: data.map((point) => formatXAxisValue(getXAxisTickValue(point), xColumn, timeBucket, "axis")),
401
+ plotWidth: getCartesianPlotWidth(width, yAxisWidth),
402
+ minimumTickGap: X_AXIS_MINIMUM_TICK_GAP,
403
+ measureLabelWidth: measureAxisLabelWidth
404
+ });
405
+ return /* @__PURE__ */ jsxs(Chart, {
406
+ data,
407
+ width,
408
+ height,
409
+ margin: getCartesianChartMargin(showDataLabels),
410
+ children: [
411
+ /* @__PURE__ */ jsx(CartesianGrid, {
412
+ vertical: false,
413
+ strokeDasharray: "3 3"
414
+ }),
415
+ /* @__PURE__ */ jsx(XAxis, {
416
+ dataKey: "xKey",
417
+ tickLine: false,
418
+ axisLine: false,
419
+ tickMargin: 8,
420
+ interval: 0,
421
+ padding: CARTESIAN_X_AXIS_PADDING,
422
+ ticks: xAxisTickValues,
423
+ tickFormatter: (value) => formatXAxisValue(value, xColumn, timeBucket, "axis")
424
+ }),
425
+ /* @__PURE__ */ jsx(YAxis, {
426
+ tickLine: false,
427
+ axisLine: false,
428
+ tickMargin: 4,
429
+ allowDecimals: allowDecimalTicks,
430
+ width: yAxisWidth,
431
+ tickFormatter: (value) => typeof value === "number" ? formatChartValue(value, {
432
+ column: valueColumn,
433
+ surface: "axis",
434
+ numericRange: valueRange
435
+ }) : String(value)
436
+ }),
437
+ /* @__PURE__ */ jsx(Tooltip, {
438
+ itemSorter: tooltipItemSorter,
439
+ formatter: (value) => typeof value === "number" ? formatChartValue(value, {
440
+ column: valueColumn,
441
+ surface: "tooltip",
442
+ numericRange: valueRange
443
+ }) : value,
444
+ labelFormatter: (label, payload) => formatTooltipLabel(label, payload, xColumn, timeBucket)
445
+ }),
446
+ series.length > 1 && /* @__PURE__ */ jsx(Legend, {}),
447
+ series.map(renderSeries)
448
+ ]
449
+ });
450
+ }
451
+ /**
452
+ * Resolve the most descriptive tooltip label from the transformed data point.
453
+ */
454
+ function formatTooltipLabel(label, payload, xColumn, timeBucket) {
455
+ if (!xColumn) return String(label);
456
+ const point = payload?.[0]?.payload;
457
+ return formatXAxisValue(typeof point?.["xKey"] === "string" || typeof point?.["xKey"] === "number" ? point["xKey"] : label, xColumn, timeBucket, "tooltip");
458
+ }
459
+ /**
460
+ * Format one X-axis value using the same shared column rules as the rest of the
461
+ * chart while preserving the special bucket labels for inferred date buckets.
462
+ */
463
+ function formatXAxisValue(value, xColumn, timeBucket, surface) {
464
+ if (!xColumn) return String(value);
465
+ if (xColumn.type === "date" && timeBucket && typeof value === "string" && !xColumn.formatter) return formatTimeBucketLabel(value, timeBucket, surface);
466
+ return formatChartValue(value, {
467
+ column: xColumn,
468
+ surface,
469
+ timeBucket
470
+ });
471
+ }
472
+ function BarChartRenderer(props) {
473
+ const { series, showDataLabels, dataLabelStyle, valueColumn, valueRange } = props;
474
+ const barRadius = useCssBarRadius();
475
+ const isStacked = series.length > 1;
476
+ const topSeriesKey = series[series.length - 1]?.dataKey;
477
+ return /* @__PURE__ */ jsx(CartesianChartShell, {
478
+ ...props,
479
+ Chart: BarChart,
480
+ tooltipItemSorter: isStacked ? createStackedTooltipItemSorter(series) : void 0,
481
+ renderSeries: (s) => {
482
+ const isTop = !isStacked || s.dataKey === topSeriesKey;
483
+ return /* @__PURE__ */ jsx(Bar, {
484
+ dataKey: s.dataKey,
485
+ name: s.label,
486
+ fill: s.color,
487
+ fillOpacity: .7,
488
+ radius: isTop ? [
489
+ barRadius,
490
+ barRadius,
491
+ 0,
492
+ 0
493
+ ] : 0,
494
+ stackId: isStacked ? "stack" : void 0,
495
+ children: showDataLabels && /* @__PURE__ */ jsx(LabelList, {
496
+ position: dataLabelStyle.position,
497
+ offset: dataLabelStyle.offset,
498
+ formatter: (value) => formatDataLabel(value, valueColumn, valueRange)
499
+ })
500
+ }, s.dataKey);
501
+ }
502
+ });
503
+ }
504
+ function LineChartRenderer(props) {
505
+ const { showDataLabels, dataLabelStyle, valueColumn, valueRange, connectNulls } = props;
506
+ return /* @__PURE__ */ jsx(CartesianChartShell, {
507
+ ...props,
508
+ Chart: LineChart,
509
+ renderSeries: (s) => /* @__PURE__ */ jsx(Line, {
510
+ type: "monotone",
511
+ dataKey: s.dataKey,
512
+ name: s.label,
513
+ stroke: s.color,
514
+ strokeWidth: 2,
515
+ dot: { r: 3 },
516
+ activeDot: { r: 5 },
517
+ connectNulls,
518
+ children: showDataLabels && /* @__PURE__ */ jsx(LabelList, {
519
+ position: dataLabelStyle.position,
520
+ offset: dataLabelStyle.offset,
521
+ formatter: (value) => formatDataLabel(value, valueColumn, valueRange)
522
+ })
523
+ }, s.dataKey)
524
+ });
525
+ }
526
+ function AreaChartRenderer(props) {
527
+ const { showDataLabels, dataLabelStyle, valueColumn, valueRange, connectNulls } = props;
528
+ return /* @__PURE__ */ jsx(CartesianChartShell, {
529
+ ...props,
530
+ Chart: AreaChart,
531
+ renderSeries: (s) => /* @__PURE__ */ jsx(Area, {
532
+ type: "monotone",
533
+ dataKey: s.dataKey,
534
+ name: s.label,
535
+ stroke: s.color,
536
+ fill: s.color,
537
+ fillOpacity: .3,
538
+ connectNulls,
539
+ children: showDataLabels && /* @__PURE__ */ jsx(LabelList, {
540
+ position: dataLabelStyle.position,
541
+ offset: dataLabelStyle.offset,
542
+ formatter: (value) => formatDataLabel(value, valueColumn, valueRange)
543
+ })
544
+ }, s.dataKey)
545
+ });
546
+ }
547
+ function PercentAreaChartRenderer(props) {
548
+ const { series, data, xColumn, timeBucket, showDataLabels, dataLabelStyle, connectNulls, width, height } = props;
549
+ const seriesKeys = series.map((s) => s.dataKey);
550
+ const tooltipItemSorter = createStackedTooltipItemSorter(series);
551
+ const stackableData = filterAllNullPoints(data, series);
552
+ const yAxisWidth = estimateYAxisWidth(PERCENT_STACKED_RANGE, PERCENT_STACKED_COLUMN);
553
+ const xAxisTickValues = selectVisibleXAxisTicks({
554
+ values: stackableData.map(getXAxisTickValue),
555
+ labels: stackableData.map((point) => formatXAxisValue(getXAxisTickValue(point), xColumn, timeBucket, "axis")),
556
+ plotWidth: getCartesianPlotWidth(width, yAxisWidth),
557
+ minimumTickGap: X_AXIS_MINIMUM_TICK_GAP,
558
+ measureLabelWidth: measureAxisLabelWidth
559
+ });
560
+ return /* @__PURE__ */ jsxs(AreaChart, {
561
+ data: stackableData,
562
+ width,
563
+ height,
564
+ margin: getCartesianChartMargin(showDataLabels),
565
+ stackOffset: "expand",
566
+ children: [
567
+ /* @__PURE__ */ jsx(CartesianGrid, {
568
+ vertical: false,
569
+ strokeDasharray: "3 3"
570
+ }),
571
+ /* @__PURE__ */ jsx(XAxis, {
572
+ dataKey: "xKey",
573
+ tickLine: false,
574
+ axisLine: false,
575
+ tickMargin: 8,
576
+ interval: 0,
577
+ padding: CARTESIAN_X_AXIS_PADDING,
578
+ ticks: xAxisTickValues,
579
+ tickFormatter: (value) => formatXAxisValue(value, xColumn, timeBucket, "axis")
580
+ }),
581
+ /* @__PURE__ */ jsx(YAxis, {
582
+ tickLine: false,
583
+ axisLine: false,
584
+ tickMargin: 4,
585
+ tickFormatter: (value) => typeof value === "number" ? formatChartValue(value, {
586
+ column: PERCENT_STACKED_COLUMN,
587
+ surface: "axis",
588
+ numericRange: PERCENT_STACKED_RANGE
589
+ }) : String(value),
590
+ width: yAxisWidth
591
+ }),
592
+ /* @__PURE__ */ jsx(Tooltip, {
593
+ itemSorter: tooltipItemSorter,
594
+ formatter: (_value, _name, entry) => {
595
+ const proportion = getPercentStackedDisplayValue(entry, String(entry.dataKey ?? ""), seriesKeys);
596
+ if (proportion != null) return formatChartValue(proportion, {
597
+ column: PERCENT_STACKED_COLUMN,
598
+ surface: "tooltip",
599
+ numericRange: PERCENT_STACKED_RANGE
600
+ });
601
+ return typeof _value === "number" ? formatChartValue(_value, {
602
+ column: PERCENT_STACKED_COLUMN,
603
+ surface: "tooltip",
604
+ numericRange: PERCENT_STACKED_RANGE
605
+ }) : _value;
606
+ },
607
+ labelFormatter: (label, payload) => formatTooltipLabel(label, payload, xColumn, timeBucket)
608
+ }),
609
+ series.length > 1 && /* @__PURE__ */ jsx(Legend, {}),
610
+ series.map((s) => /* @__PURE__ */ jsx(Area, {
611
+ type: "monotone",
612
+ dataKey: s.dataKey,
613
+ name: s.label,
614
+ stroke: s.color,
615
+ fill: s.color,
616
+ fillOpacity: .3,
617
+ stackId: "percent",
618
+ connectNulls,
619
+ children: showDataLabels && /* @__PURE__ */ jsx(LabelList, {
620
+ position: dataLabelStyle.position,
621
+ offset: dataLabelStyle.offset,
622
+ valueAccessor: (entry) => getPercentStackedDisplayValue(entry, s.dataKey, seriesKeys) ?? 0,
623
+ formatter: (value) => formatDataLabel(value, PERCENT_STACKED_COLUMN, PERCENT_STACKED_RANGE)
624
+ })
625
+ }, s.dataKey))
626
+ ]
627
+ });
628
+ }
629
+ function GroupedBarChartRenderer(props) {
630
+ const { showDataLabels, dataLabelStyle, valueColumn, valueRange } = props;
631
+ const barRadius = useCssBarRadius();
632
+ return /* @__PURE__ */ jsx(CartesianChartShell, {
633
+ ...props,
634
+ Chart: BarChart,
635
+ renderSeries: (s) => /* @__PURE__ */ jsx(Bar, {
636
+ dataKey: s.dataKey,
637
+ name: s.label,
638
+ fill: s.color,
639
+ fillOpacity: .7,
640
+ radius: [
641
+ barRadius,
642
+ barRadius,
643
+ 0,
644
+ 0
645
+ ],
646
+ children: showDataLabels && /* @__PURE__ */ jsx(LabelList, {
647
+ position: dataLabelStyle.position,
648
+ offset: dataLabelStyle.offset,
649
+ formatter: (value) => formatDataLabel(value, valueColumn, valueRange)
650
+ })
651
+ }, s.dataKey)
652
+ });
653
+ }
654
+ function PercentBarChartRenderer(props) {
655
+ const { series, data, xColumn, timeBucket, showDataLabels, dataLabelStyle, width, height } = props;
656
+ const barRadius = useCssBarRadius();
657
+ const topSeriesKey = series[series.length - 1]?.dataKey;
658
+ const seriesKeys = series.map((s) => s.dataKey);
659
+ const tooltipItemSorter = createStackedTooltipItemSorter(series);
660
+ const yAxisWidth = estimateYAxisWidth(PERCENT_STACKED_RANGE, PERCENT_STACKED_COLUMN);
661
+ const xAxisTickValues = selectVisibleXAxisTicks({
662
+ values: data.map(getXAxisTickValue),
663
+ labels: data.map((point) => formatXAxisValue(getXAxisTickValue(point), xColumn, timeBucket, "axis")),
664
+ plotWidth: getCartesianPlotWidth(width, yAxisWidth),
665
+ minimumTickGap: X_AXIS_MINIMUM_TICK_GAP,
666
+ measureLabelWidth: measureAxisLabelWidth
667
+ });
668
+ return /* @__PURE__ */ jsxs(BarChart, {
669
+ data,
670
+ width,
671
+ height,
672
+ margin: getCartesianChartMargin(showDataLabels),
673
+ stackOffset: "expand",
674
+ children: [
675
+ /* @__PURE__ */ jsx(CartesianGrid, {
676
+ vertical: false,
677
+ strokeDasharray: "3 3"
678
+ }),
679
+ /* @__PURE__ */ jsx(XAxis, {
680
+ dataKey: "xKey",
681
+ tickLine: false,
682
+ axisLine: false,
683
+ tickMargin: 8,
684
+ interval: 0,
685
+ padding: CARTESIAN_X_AXIS_PADDING,
686
+ ticks: xAxisTickValues,
687
+ tickFormatter: (value) => formatXAxisValue(value, xColumn, timeBucket, "axis")
688
+ }),
689
+ /* @__PURE__ */ jsx(YAxis, {
690
+ tickLine: false,
691
+ axisLine: false,
692
+ tickMargin: 4,
693
+ tickFormatter: (value) => typeof value === "number" ? formatChartValue(value, {
694
+ column: PERCENT_STACKED_COLUMN,
695
+ surface: "axis",
696
+ numericRange: PERCENT_STACKED_RANGE
697
+ }) : String(value),
698
+ width: yAxisWidth
699
+ }),
700
+ /* @__PURE__ */ jsx(Tooltip, {
701
+ itemSorter: tooltipItemSorter,
702
+ formatter: (_value, _name, entry) => {
703
+ const proportion = getPercentStackedDisplayValue(entry, String(entry.dataKey ?? ""), seriesKeys);
704
+ if (proportion != null) return formatChartValue(proportion, {
705
+ column: PERCENT_STACKED_COLUMN,
706
+ surface: "tooltip",
707
+ numericRange: PERCENT_STACKED_RANGE
708
+ });
709
+ return typeof _value === "number" ? formatChartValue(_value, {
710
+ column: PERCENT_STACKED_COLUMN,
711
+ surface: "tooltip",
712
+ numericRange: PERCENT_STACKED_RANGE
713
+ }) : _value;
714
+ },
715
+ labelFormatter: (label, payload) => formatTooltipLabel(label, payload, xColumn, timeBucket)
716
+ }),
717
+ series.length > 1 && /* @__PURE__ */ jsx(Legend, {}),
718
+ series.map((s) => {
719
+ const isTop = s.dataKey === topSeriesKey;
720
+ return /* @__PURE__ */ jsx(Bar, {
721
+ dataKey: s.dataKey,
722
+ name: s.label,
723
+ fill: s.color,
724
+ fillOpacity: .7,
725
+ radius: isTop ? [
726
+ barRadius,
727
+ barRadius,
728
+ 0,
729
+ 0
730
+ ] : 0,
731
+ stackId: "percent",
732
+ children: showDataLabels && /* @__PURE__ */ jsx(LabelList, {
733
+ position: dataLabelStyle.position,
734
+ offset: dataLabelStyle.offset,
735
+ valueAccessor: (entry) => getPercentStackedDisplayValue(entry, s.dataKey, seriesKeys) ?? 0,
736
+ formatter: (value) => formatDataLabel(value, PERCENT_STACKED_COLUMN, PERCENT_STACKED_RANGE)
737
+ })
738
+ }, s.dataKey);
739
+ })
740
+ ]
741
+ });
742
+ }
743
+ function PieChartRenderer({ data, series, innerRadius, width, height, valueColumn, valueRange, xColumn, timeBucket, showDataLabels }) {
744
+ const valueKey = series[0]?.dataKey;
745
+ const pieData = data.map((point, index) => {
746
+ return {
747
+ name: typeof point["xKey"] === "string" || typeof point["xKey"] === "number" ? formatXAxisValue(point["xKey"], xColumn, timeBucket, "tooltip") : String(point["xLabel"]),
748
+ value: valueKey && typeof point[valueKey] === "number" ? point[valueKey] : 0,
749
+ fill: getSeriesColor(index)
750
+ };
751
+ });
752
+ return /* @__PURE__ */ jsxs(PieChart, {
753
+ width,
754
+ height,
755
+ children: [
756
+ /* @__PURE__ */ jsx(Tooltip, { formatter: (value) => typeof value === "number" ? formatChartValue(value, {
757
+ column: valueColumn,
758
+ surface: "tooltip",
759
+ numericRange: valueRange
760
+ }) : value }),
761
+ /* @__PURE__ */ jsx(Legend, {}),
762
+ /* @__PURE__ */ jsx(Pie, {
763
+ data: pieData,
764
+ dataKey: "value",
765
+ nameKey: "name",
766
+ cx: "50%",
767
+ cy: "50%",
768
+ innerRadius: innerRadius ? "40%" : 0,
769
+ outerRadius: "80%",
770
+ label: showDataLabels ? ({ name, value }) => shouldHideDataLabel(value) ? "" : `${name}: ${typeof value === "number" ? formatChartValue(value, {
771
+ column: valueColumn,
772
+ surface: "data-label",
773
+ numericRange: valueRange
774
+ }) : value}` : false,
775
+ labelLine: false
776
+ })
777
+ ]
778
+ });
779
+ }
780
+ /**
781
+ * Format one cartesian data label with the same surface-aware rules used
782
+ * elsewhere in the chart UI.
783
+ */
784
+ function formatDataLabel(value, valueColumn, valueRange) {
785
+ if (shouldHideDataLabel(value)) return "";
786
+ if (typeof value !== "number") return String(value);
787
+ return formatChartValue(value, {
788
+ column: valueColumn,
789
+ surface: "data-label",
790
+ numericRange: valueRange
791
+ });
792
+ }
793
+ /**
794
+ * Suppress zero-value labels in the built-in UI so charts stay quieter by
795
+ * default while tooltips and raw values remain unchanged.
796
+ */
797
+ function shouldHideDataLabel(value) {
798
+ return value == null || typeof value === "number" && value === 0;
799
+ }
800
+ /**
801
+ * Table renderer — shows the pipeline output as an HTML table.
802
+ *
803
+ * Applies the same formatting pipeline as the chart renderers (dates respect
804
+ * time-bucket labels, numbers use full-precision `table-cell` surface rules).
805
+ * Scrolls both axes when the data overflows the configured height.
806
+ */
807
+ function TableRenderer({ data, series, height, className, valueColumn, valueRange, xColumn, timeBucket }) {
808
+ if (data.length === 0) return /* @__PURE__ */ jsx("div", {
809
+ className: `flex items-center justify-center text-sm text-muted-foreground ${className ?? ""}`,
810
+ style: { height },
811
+ children: "No data available"
812
+ });
813
+ return /* @__PURE__ */ jsx("div", {
814
+ className: `overflow-auto border border-border/50 rounded-md ${className ?? ""}`,
815
+ style: { maxHeight: height },
816
+ children: /* @__PURE__ */ jsxs("table", {
817
+ className: "w-full border-collapse text-sm",
818
+ children: [/* @__PURE__ */ jsx("thead", {
819
+ className: "sticky top-0 z-10 bg-muted/80 backdrop-blur-sm",
820
+ children: /* @__PURE__ */ jsxs("tr", { children: [/* @__PURE__ */ jsx("th", {
821
+ className: "overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-left text-xs font-medium text-muted-foreground",
822
+ children: xColumn?.label ?? "Category"
823
+ }), series.map((s) => /* @__PURE__ */ jsx("th", {
824
+ className: "overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2 text-right text-xs font-medium text-muted-foreground",
825
+ children: /* @__PURE__ */ jsxs("span", {
826
+ className: "inline-flex items-center justify-end gap-1.5",
827
+ children: [/* @__PURE__ */ jsx("span", {
828
+ className: "inline-block h-2.5 w-2.5 shrink-0 rounded-[2px]",
829
+ style: { backgroundColor: s.color },
830
+ "aria-hidden": true
831
+ }), /* @__PURE__ */ jsx("span", {
832
+ className: "overflow-hidden text-ellipsis",
833
+ children: s.label
834
+ })]
835
+ })
836
+ }, s.dataKey))] })
837
+ }), /* @__PURE__ */ jsx("tbody", { children: data.map((point, rowIndex) => /* @__PURE__ */ jsxs("tr", {
838
+ className: "border-t border-border/30 transition-colors hover:bg-muted/30",
839
+ children: [/* @__PURE__ */ jsx("td", {
840
+ className: "overflow-hidden text-ellipsis whitespace-nowrap px-3 py-1.5 font-medium text-foreground",
841
+ children: formatTableXValue(point, xColumn, timeBucket)
842
+ }), series.map((s) => /* @__PURE__ */ jsx("td", {
843
+ className: "overflow-hidden text-ellipsis whitespace-nowrap px-3 py-1.5 text-right tabular-nums text-foreground",
844
+ children: formatTableCellValue(point[s.dataKey], valueColumn, valueRange)
845
+ }, s.dataKey))]
846
+ }, rowIndex)) })]
847
+ })
848
+ });
849
+ }
850
+ /**
851
+ * Format the X-axis value for a table row using the same logic as the chart
852
+ * renderers: time-bucket-aware date labels, column formatters, etc.
853
+ */
854
+ function formatTableXValue(point, xColumn, timeBucket) {
855
+ const rawValue = typeof point["xKey"] === "string" || typeof point["xKey"] === "number" ? point["xKey"] : point["xLabel"];
856
+ if (rawValue == null) return "Unknown";
857
+ return formatXAxisValue(rawValue, xColumn, timeBucket, "table-cell");
858
+ }
859
+ /**
860
+ * Format one metric cell value with full precision (matches tooltip depth).
861
+ */
862
+ function formatTableCellValue(value, valueColumn, valueRange) {
863
+ if (value == null) return "—";
864
+ if (typeof value !== "number") return String(value);
865
+ return formatChartValue(value, {
866
+ column: valueColumn,
867
+ surface: "table-cell",
868
+ numericRange: valueRange
869
+ });
870
+ }
871
+ //#endregion
872
+ export { ChartCanvas };