@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.
- package/README.md +35 -0
- package/dist/index.d.mts +19 -0
- package/dist/index.mjs +18 -0
- package/dist/theme.css +67 -0
- package/dist/ui/chart-axis-ticks.mjs +65 -0
- package/dist/ui/chart-canvas.d.mts +40 -0
- package/dist/ui/chart-canvas.mjs +872 -0
- package/dist/ui/chart-context.d.mts +101 -0
- package/dist/ui/chart-context.mjs +117 -0
- package/dist/ui/chart-date-range-badge.d.mts +20 -0
- package/dist/ui/chart-date-range-badge.mjs +49 -0
- package/dist/ui/chart-date-range-panel.d.mts +18 -0
- package/dist/ui/chart-date-range-panel.mjs +126 -0
- package/dist/ui/chart-date-range.d.mts +20 -0
- package/dist/ui/chart-date-range.mjs +67 -0
- package/dist/ui/chart-debug.d.mts +21 -0
- package/dist/ui/chart-debug.mjs +172 -0
- package/dist/ui/chart-dropdown.mjs +92 -0
- package/dist/ui/chart-filters-panel.d.mts +26 -0
- package/dist/ui/chart-filters-panel.mjs +258 -0
- package/dist/ui/chart-filters.d.mts +18 -0
- package/dist/ui/chart-filters.mjs +48 -0
- package/dist/ui/chart-group-by-selector.d.mts +16 -0
- package/dist/ui/chart-group-by-selector.mjs +32 -0
- package/dist/ui/chart-metric-panel.d.mts +25 -0
- package/dist/ui/chart-metric-panel.mjs +172 -0
- package/dist/ui/chart-metric-selector.d.mts +16 -0
- package/dist/ui/chart-metric-selector.mjs +50 -0
- package/dist/ui/chart-select.mjs +61 -0
- package/dist/ui/chart-source-switcher.d.mts +24 -0
- package/dist/ui/chart-source-switcher.mjs +56 -0
- package/dist/ui/chart-time-bucket-selector.d.mts +17 -0
- package/dist/ui/chart-time-bucket-selector.mjs +37 -0
- package/dist/ui/chart-toolbar-overflow.d.mts +28 -0
- package/dist/ui/chart-toolbar-overflow.mjs +223 -0
- package/dist/ui/chart-toolbar.d.mts +33 -0
- package/dist/ui/chart-toolbar.mjs +60 -0
- package/dist/ui/chart-type-selector.d.mts +19 -0
- package/dist/ui/chart-type-selector.mjs +173 -0
- package/dist/ui/chart-x-axis-selector.d.mts +16 -0
- package/dist/ui/chart-x-axis-selector.mjs +28 -0
- package/dist/ui/index.d.mts +18 -0
- package/dist/ui/percent-stacked.mjs +36 -0
- package/dist/ui/toolbar-types.d.mts +7 -0
- package/dist/ui/toolbar-types.mjs +83 -0
- 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 };
|