@bonnard/react
Advanced tools
| import { createContext, useContext, useMemo, useState } from "react"; | ||
| import { jsx, jsxs } from "react/jsx-runtime"; | ||
| import { format } from "numfmt"; | ||
| import ReactECharts from "echarts-for-react"; | ||
| //#region src/context.ts | ||
| const BonnardContext = createContext(null); | ||
| function useBonnard() { | ||
| const ctx = useContext(BonnardContext); | ||
| if (!ctx) throw new Error("useBonnard() must be used within a <BonnardProvider>"); | ||
| return ctx; | ||
| } | ||
| //#endregion | ||
| //#region src/lib/format-value.ts | ||
| /** Named presets mapping to Excel format codes */ | ||
| const PRESETS = { | ||
| num0: "#,##0", | ||
| num1: "#,##0.0", | ||
| num2: "#,##0.00", | ||
| usd: "$#,##0", | ||
| usd2: "$#,##0.00", | ||
| eur: "#,##0 \"€\"", | ||
| eur2: "#,##0.00 \"€\"", | ||
| gbp: "£#,##0", | ||
| gbp2: "£#,##0.00", | ||
| chf: "\"CHF \"#,##0", | ||
| chf2: "\"CHF \"#,##0.00", | ||
| pct: "0%", | ||
| pct1: "0.0%", | ||
| pct2: "0.00%", | ||
| shortdate: "d mmm yyyy", | ||
| longdate: "d mmmm yyyy", | ||
| monthyear: "mmm yyyy" | ||
| }; | ||
| /** Resolve a preset name to an Excel format code, or pass through raw codes */ | ||
| function parsePreset(name) { | ||
| return PRESETS[name] ?? name; | ||
| } | ||
| /** Detect whether an Excel format code is a date pattern */ | ||
| function isDatePattern(pattern) { | ||
| const stripped = pattern.replace(/"[^"]*"/g, "").replace(/\[[^\]]*\]/g, ""); | ||
| return /[ymdhs]/i.test(stripped); | ||
| } | ||
| /** Format a value with a preset name or raw Excel format code */ | ||
| function applyFormat(value, fmt) { | ||
| if (value == null) return "—"; | ||
| const pattern = parsePreset(fmt); | ||
| if (isDatePattern(pattern) && typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) { | ||
| const d = new Date(value); | ||
| if (!isNaN(d.getTime())) return format(pattern, d); | ||
| } | ||
| const num = Number(value); | ||
| if (!isNaN(num)) return format(pattern, num); | ||
| return String(value); | ||
| } | ||
| /** Auto-detect value type and format with sensible defaults */ | ||
| function autoFormat(value) { | ||
| if (value == null) return "—"; | ||
| if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) { | ||
| const d = new Date(value); | ||
| if (!isNaN(d.getTime())) return format("d mmm yyyy", d); | ||
| } | ||
| const num = Number(value); | ||
| if (typeof value === "number" || typeof value === "string" && value !== "" && !isNaN(num)) return Number.isInteger(num) ? format("#,##0", num) : format("#,##0.##", num); | ||
| return String(value); | ||
| } | ||
| /** | ||
| * Parse a fmt prop string like "revenue:eur2,date:shortdate" into a column→format map. | ||
| * Splits on commas only when followed by a column name and colon (to avoid breaking | ||
| * Excel format codes that contain commas like `#,##0`). | ||
| * A single format without a colon (e.g. `fmt="eur2"`) is returned under the empty key. | ||
| */ | ||
| function parseFmtProp(fmt) { | ||
| const map = /* @__PURE__ */ new Map(); | ||
| const entries = fmt.split(/,(?=\s*[a-zA-Z_]\w*\s*:)/); | ||
| for (const entry of entries) { | ||
| const trimmed = entry.trim(); | ||
| if (!trimmed) continue; | ||
| const colonIdx = trimmed.indexOf(":"); | ||
| if (colonIdx === -1) map.set("", trimmed); | ||
| else { | ||
| const col = trimmed.slice(0, colonIdx).trim(); | ||
| const fmtVal = trimmed.slice(colonIdx + 1).trim(); | ||
| map.set(col, fmtVal); | ||
| } | ||
| } | ||
| return map; | ||
| } | ||
| //#endregion | ||
| //#region src/theme/chart-theme.ts | ||
| /** | ||
| * Shared ECharts theme constants — adapted from metric-builder patterns. | ||
| */ | ||
| /** Named color palettes for charts */ | ||
| const PALETTES = { | ||
| default: [ | ||
| "#2563eb", | ||
| "#dc2626", | ||
| "#16a34a", | ||
| "#ca8a04", | ||
| "#9333ea", | ||
| "#ec4899", | ||
| "#0891b2", | ||
| "#ea580c" | ||
| ], | ||
| tableau: [ | ||
| "#4e79a7", | ||
| "#f28e2c", | ||
| "#e15759", | ||
| "#76b7b2", | ||
| "#59a14f", | ||
| "#edc949", | ||
| "#af7aa1", | ||
| "#ff9da7", | ||
| "#9c755f", | ||
| "#bab0ab" | ||
| ], | ||
| observable: [ | ||
| "#4269d0", | ||
| "#efb118", | ||
| "#ff725c", | ||
| "#6cc5b0", | ||
| "#3ca951", | ||
| "#ff8ab7", | ||
| "#a463f2", | ||
| "#97bbf5", | ||
| "#9c6b4e", | ||
| "#9498a0" | ||
| ], | ||
| metabase: [ | ||
| "#509EE3", | ||
| "#88BF4D", | ||
| "#A989C5", | ||
| "#EF8C8C", | ||
| "#F9D45C", | ||
| "#F2A86F", | ||
| "#98D9D9", | ||
| "#7172AD" | ||
| ] | ||
| }; | ||
| const CHART_COLORS = PALETTES.tableau; | ||
| const LIGHT_THEME = { | ||
| text: { | ||
| label: "#6b7280", | ||
| title: "#374151", | ||
| muted: "#9ca3af" | ||
| }, | ||
| tooltip: { | ||
| backgroundColor: "#fff", | ||
| borderColor: "#e5e7eb", | ||
| textColor: "#374151", | ||
| shadow: "box-shadow: 0 2px 8px rgba(59,130,246,0.05);" | ||
| }, | ||
| gridLine: "#f3f4f6", | ||
| legendText: "#6b7280" | ||
| }; | ||
| const DARK_THEME = { | ||
| text: { | ||
| label: "#9ca3af", | ||
| title: "#e5e7eb", | ||
| muted: "#6b7280" | ||
| }, | ||
| tooltip: { | ||
| backgroundColor: "#1f2937", | ||
| borderColor: "#374151", | ||
| textColor: "#e5e7eb", | ||
| shadow: "box-shadow: 0 2px 8px rgba(0,0,0,0.3);" | ||
| }, | ||
| gridLine: "#374151", | ||
| legendText: "#9ca3af" | ||
| }; | ||
| function getChartTheme(isDark) { | ||
| return isDark ? DARK_THEME : LIGHT_THEME; | ||
| } | ||
| /** Build tooltip config for the current theme */ | ||
| function buildTooltip(theme) { | ||
| return { | ||
| backgroundColor: theme.tooltip.backgroundColor, | ||
| borderColor: theme.tooltip.borderColor, | ||
| borderWidth: 1, | ||
| textStyle: { | ||
| color: theme.tooltip.textColor, | ||
| fontSize: 13 | ||
| }, | ||
| extraCssText: theme.tooltip.shadow, | ||
| appendToBody: true, | ||
| position(point, _params, _dom, _rect, size) { | ||
| const [mouseX] = point; | ||
| const [tooltipW] = size.contentSize; | ||
| const [chartW] = size.viewSize; | ||
| const gap = 15; | ||
| return [mouseX + gap + tooltipW < chartW ? mouseX + gap : mouseX - tooltipW - gap, 10]; | ||
| } | ||
| }; | ||
| } | ||
| /** Build legend config for the current theme */ | ||
| function buildLegend(theme) { | ||
| return { | ||
| type: "scroll", | ||
| orient: "horizontal", | ||
| bottom: 0, | ||
| left: "center", | ||
| textStyle: { | ||
| color: theme.legendText, | ||
| fontSize: 12 | ||
| }, | ||
| pageTextStyle: { color: theme.legendText }, | ||
| itemWidth: 16, | ||
| itemHeight: 4 | ||
| }; | ||
| } | ||
| const DEFAULT_CHART_HEIGHT = 320; | ||
| /** Check if x-axis labels are ISO dates — if so, use ECharts time axis */ | ||
| function isTimeAxis(rawLabels) { | ||
| if (rawLabels.length === 0) return false; | ||
| for (const label of rawLabels) { | ||
| if (!label) continue; | ||
| return /^\d{4}-\d{2}-\d{2}/.test(label); | ||
| } | ||
| return false; | ||
| } | ||
| /** Determine axis label rotation based on longest label length */ | ||
| function labelRotation(labels) { | ||
| return labels.reduce((max, l) => Math.max(max, String(l).length), 0) > 10 ? -45 : 0; | ||
| } | ||
| /** Grid bottom padding based on rotation */ | ||
| function gridBottom(rotation) { | ||
| return rotation === 0 ? 5 : 30; | ||
| } | ||
| /** Format a number for display (compact notation for large values) */ | ||
| function formatValue(val) { | ||
| if (val == null) return "—"; | ||
| const num = Number(val); | ||
| if (isNaN(num)) return String(val); | ||
| if (Math.abs(num) >= 1e6) return `${(num / 1e6).toFixed(1)}M`; | ||
| if (Math.abs(num) >= 1e3) return `${(num / 1e3).toFixed(1)}K`; | ||
| if (Number.isInteger(num)) return num.toLocaleString(); | ||
| return num.toFixed(2); | ||
| } | ||
| /** ECharts tooltip valueFormatter — uses explicit format or compact fallback */ | ||
| function tooltipFormatter(yFmt) { | ||
| if (yFmt) return (val) => applyFormat(val, yFmt); | ||
| return (val) => formatValue(val); | ||
| } | ||
| /** Format axis label — detects ISO dates and formats them nicely */ | ||
| function formatAxisLabel(val) { | ||
| if (/^\d{4}-\d{2}-\d{2}T/.test(val)) { | ||
| const d = new Date(val); | ||
| if (!isNaN(d.getTime())) return d.toLocaleDateString("en-GB", { | ||
| day: "numeric", | ||
| month: "short" | ||
| }); | ||
| } | ||
| if (/^\d{4}-\d{2}-\d{2}$/.test(val)) { | ||
| const d = /* @__PURE__ */ new Date(val + "T00:00:00"); | ||
| if (!isNaN(d.getTime())) return d.toLocaleDateString("en-GB", { | ||
| day: "numeric", | ||
| month: "short" | ||
| }); | ||
| } | ||
| return val; | ||
| } | ||
| /** ECharts y-axis formatter — compact numbers */ | ||
| function axisValueFormatter(val) { | ||
| if (Math.abs(val) >= 1e6) return `${(val / 1e6).toFixed(1)}M`; | ||
| if (Math.abs(val) >= 1e3) { | ||
| const k = val / 1e3; | ||
| return k % 1 === 0 ? `${k}K` : `${k.toFixed(1)}K`; | ||
| } | ||
| return String(val); | ||
| } | ||
| /** Convert snake_case or camelCase field names to Title Case */ | ||
| function formatColumnHeader(col) { | ||
| return col.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/\b\w/g, (c) => c.toUpperCase()); | ||
| } | ||
| //#endregion | ||
| //#region src/charts/big-value.tsx | ||
| function BigValue({ data, value, title, fmt }) { | ||
| const row = data[0]; | ||
| if (!row) return null; | ||
| const displayValue = fmt ? applyFormat(row[value], fmt) : formatValue(row[value]); | ||
| return /* @__PURE__ */ jsxs("div", { | ||
| style: { | ||
| minWidth: 0, | ||
| borderRadius: "var(--bon-radius)", | ||
| border: "1px solid var(--bon-border)", | ||
| backgroundColor: "var(--bon-bg-card)", | ||
| padding: 20, | ||
| boxShadow: "var(--bon-shadow)" | ||
| }, | ||
| children: [/* @__PURE__ */ jsx("p", { | ||
| style: { | ||
| overflow: "hidden", | ||
| textOverflow: "ellipsis", | ||
| whiteSpace: "nowrap", | ||
| fontSize: 14, | ||
| fontWeight: 500, | ||
| color: "var(--bon-text-muted)", | ||
| margin: 0 | ||
| }, | ||
| children: title ?? value | ||
| }), /* @__PURE__ */ jsx("p", { | ||
| style: { | ||
| fontSize: 30, | ||
| fontWeight: 700, | ||
| letterSpacing: "-0.025em", | ||
| color: "var(--bon-text)", | ||
| margin: 0, | ||
| marginTop: 4 | ||
| }, | ||
| children: displayValue | ||
| })] | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region src/lib/build-series.ts | ||
| /** | ||
| * Pure data transformation: splits flat query results into multi-series datasets. | ||
| * | ||
| * Four cases: | ||
| * | y columns | series prop | Result | | ||
| * |-----------|-------------|-------------------------------| | ||
| * | Single | None | 1 dataset (current behavior) | | ||
| * | Single | Set | N datasets (per series value) | | ||
| * | Multiple | None | N datasets (per y column) | | ||
| * | Multiple | Set | N*M datasets | | ||
| */ | ||
| /** Strip view/cube prefix and convert snake_case to Title Case for legend labels */ | ||
| function humanizeField(name) { | ||
| return (name.includes(".") ? name.slice(name.indexOf(".") + 1) : name).replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); | ||
| } | ||
| /** | ||
| * Build multi-series datasets from flat query data. | ||
| * | ||
| * @param data - Flat array of row objects from query | ||
| * @param x - Column name for x-axis | ||
| * @param y - Column name(s) for y-axis, comma-separated for multiple | ||
| * @param series - Optional column name to split data into separate series | ||
| */ | ||
| function buildSeries(data, x, y, series) { | ||
| if (!data || data.length === 0) return { | ||
| labels: [], | ||
| datasets: [] | ||
| }; | ||
| const firstX = String(data[0][x] ?? ""); | ||
| if (/^\d{4}-\d{2}-\d{2}/.test(firstX)) data = [...data].sort((a, b) => { | ||
| const aVal = String(a[x] ?? ""); | ||
| const bVal = String(b[x] ?? ""); | ||
| return aVal.localeCompare(bVal); | ||
| }); | ||
| const yColumns = y.split(",").map((col) => col.trim()).filter(Boolean); | ||
| if (!series) return { | ||
| labels: data.map((row) => String(row[x] ?? "")), | ||
| datasets: yColumns.map((col) => ({ | ||
| name: humanizeField(col), | ||
| values: data.map((row) => { | ||
| const val = row[col]; | ||
| return val == null ? null : Number(val); | ||
| }) | ||
| })) | ||
| }; | ||
| const xValues = []; | ||
| const xSet = /* @__PURE__ */ new Set(); | ||
| const seriesKeys = []; | ||
| const seriesSet = /* @__PURE__ */ new Set(); | ||
| for (const row of data) { | ||
| const xRaw = String(row[x] ?? ""); | ||
| if (!xSet.has(xRaw)) { | ||
| xSet.add(xRaw); | ||
| xValues.push(xRaw); | ||
| } | ||
| const sk = String(row[series] ?? ""); | ||
| if (!seriesSet.has(sk)) { | ||
| seriesSet.add(sk); | ||
| seriesKeys.push(sk); | ||
| } | ||
| } | ||
| const lookup = /* @__PURE__ */ new Map(); | ||
| for (const row of data) { | ||
| const key = `${String(row[x] ?? "")}\0${String(row[series] ?? "")}`; | ||
| lookup.set(key, row); | ||
| } | ||
| const datasets = []; | ||
| for (const sk of seriesKeys) for (const col of yColumns) { | ||
| const name = yColumns.length === 1 ? sk : `${sk} - ${humanizeField(col)}`; | ||
| const values = xValues.map((xRaw) => { | ||
| const row = lookup.get(`${xRaw}\0${sk}`); | ||
| if (!row) return null; | ||
| const val = row[col]; | ||
| return val == null ? null : Number(val); | ||
| }); | ||
| datasets.push({ | ||
| name, | ||
| values | ||
| }); | ||
| } | ||
| return { | ||
| labels: xValues, | ||
| datasets | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/lib/echarts-series.ts | ||
| /** | ||
| * Convert BuildSeriesResult to ECharts series and legend config. | ||
| * | ||
| * @param result - Output from buildSeries() | ||
| * @param chartKind - 'bar', 'line', or 'line-area' | ||
| * @param seriesType - 'stacked', 'grouped', or undefined | ||
| * @param horizontal - For bar charts: swap axes | ||
| * @param rawLabels - When provided, converts data to [label, value] pairs (for time axis) | ||
| */ | ||
| function toEChartsSeries(result, chartKind, seriesType, horizontal, rawLabels) { | ||
| const multiSeries = result.datasets.length > 1; | ||
| let stack; | ||
| if (chartKind === "bar") stack = multiSeries && seriesType !== "grouped" ? "stack1" : void 0; | ||
| else if (chartKind === "line-area") stack = seriesType === "stacked" ? "stack1" : void 0; | ||
| return { | ||
| series: result.datasets.map((ds, idx) => { | ||
| const data = rawLabels ? rawLabels.map((label, i) => [label, ds.values[i]]) : ds.values; | ||
| const base = { | ||
| name: ds.name, | ||
| data | ||
| }; | ||
| if (chartKind === "bar") { | ||
| base.type = "bar"; | ||
| base.barMaxWidth = 40; | ||
| if (stack) base.stack = stack; | ||
| base.itemStyle = { borderRadius: !stack || idx === result.datasets.length - 1 ? horizontal ? [ | ||
| 0, | ||
| 4, | ||
| 4, | ||
| 0 | ||
| ] : [ | ||
| 4, | ||
| 4, | ||
| 0, | ||
| 0 | ||
| ] : 0 }; | ||
| } else { | ||
| base.type = "line"; | ||
| base.smooth = true; | ||
| base.symbol = "circle"; | ||
| base.symbolSize = 6; | ||
| base.lineStyle = { width: 2 }; | ||
| if (stack) base.stack = stack; | ||
| if (chartKind === "line-area") base.areaStyle = { opacity: stack ? .6 : .15 }; | ||
| } | ||
| return base; | ||
| }), | ||
| showLegend: multiSeries | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/theme/use-chart-theme.ts | ||
| /** | ||
| * Returns mode-aware chart theme values. | ||
| * Reads isDark from BonnardProvider context. | ||
| */ | ||
| function useChartTheme() { | ||
| const { isDark } = useBonnard(); | ||
| const theme = getChartTheme(isDark); | ||
| return { | ||
| isDark, | ||
| theme, | ||
| tooltip: buildTooltip(theme), | ||
| legend: buildLegend(theme) | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/charts/bar-chart.tsx | ||
| function BarChart({ data, x, y, title, horizontal, series, type, yFmt }) { | ||
| const { theme, tooltip, legend } = useChartTheme(); | ||
| const result = buildSeries(data, x, y, series); | ||
| const useTimeAxis = !horizontal && isTimeAxis(result.labels); | ||
| const labels = useTimeAxis ? result.labels : result.labels.map(formatAxisLabel); | ||
| const rotation = horizontal ? 0 : useTimeAxis ? 0 : labelRotation(labels); | ||
| const { series: echartsSeriesList, showLegend } = toEChartsSeries(result, "bar", type, horizontal, useTimeAxis ? result.labels : void 0); | ||
| const categoryAxis = useTimeAxis ? { | ||
| type: "time", | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| hideOverlap: true | ||
| } | ||
| } : { | ||
| type: "category", | ||
| data: labels, | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| ...horizontal ? {} : { rotate: rotation } | ||
| } | ||
| }; | ||
| const valueAxis = { | ||
| type: "value", | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| formatter: axisValueFormatter | ||
| }, | ||
| splitLine: { lineStyle: { color: theme.gridLine } } | ||
| }; | ||
| const option = { | ||
| color: CHART_COLORS, | ||
| tooltip: { | ||
| ...tooltip, | ||
| trigger: "axis", | ||
| valueFormatter: tooltipFormatter(yFmt) | ||
| }, | ||
| ...showLegend && { legend }, | ||
| grid: { | ||
| left: 10, | ||
| right: 10, | ||
| top: 10, | ||
| bottom: (horizontal ? 40 : gridBottom(rotation)) + (showLegend ? 30 : 0), | ||
| containLabel: true | ||
| }, | ||
| [horizontal ? "yAxis" : "xAxis"]: categoryAxis, | ||
| [horizontal ? "xAxis" : "yAxis"]: valueAxis, | ||
| series: echartsSeriesList | ||
| }; | ||
| return /* @__PURE__ */ jsxs("div", { | ||
| style: { width: "100%" }, | ||
| children: [title && /* @__PURE__ */ jsx("h3", { | ||
| style: { | ||
| fontSize: 14, | ||
| fontWeight: 500, | ||
| color: "var(--bon-text-title)", | ||
| marginBottom: 4 | ||
| }, | ||
| children: title | ||
| }), /* @__PURE__ */ jsx(ReactECharts, { | ||
| option, | ||
| style: { | ||
| height: DEFAULT_CHART_HEIGHT, | ||
| width: "100%" | ||
| }, | ||
| notMerge: true | ||
| })] | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region src/charts/line-chart.tsx | ||
| function LineChart({ data, x, y, title, series, type, yFmt }) { | ||
| const { theme, tooltip, legend } = useChartTheme(); | ||
| const result = buildSeries(data, x, y, series); | ||
| const useTimeAxis = isTimeAxis(result.labels); | ||
| const labels = useTimeAxis ? result.labels : result.labels.map(formatAxisLabel); | ||
| const rotation = useTimeAxis ? 0 : labelRotation(labels); | ||
| const { series: echartsSeriesList, showLegend } = toEChartsSeries(result, "line", type, void 0, useTimeAxis ? result.labels : void 0); | ||
| const xAxisConfig = useTimeAxis ? { | ||
| type: "time", | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| hideOverlap: true | ||
| } | ||
| } : { | ||
| type: "category", | ||
| data: labels, | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| rotate: rotation | ||
| }, | ||
| boundaryGap: false | ||
| }; | ||
| const option = { | ||
| color: CHART_COLORS, | ||
| tooltip: { | ||
| ...tooltip, | ||
| trigger: "axis", | ||
| valueFormatter: tooltipFormatter(yFmt) | ||
| }, | ||
| ...showLegend && { legend }, | ||
| grid: { | ||
| left: 10, | ||
| right: 10, | ||
| top: 10, | ||
| bottom: gridBottom(rotation) + (showLegend ? 30 : 0), | ||
| containLabel: true | ||
| }, | ||
| xAxis: xAxisConfig, | ||
| yAxis: { | ||
| type: "value", | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| formatter: axisValueFormatter | ||
| }, | ||
| splitLine: { lineStyle: { color: theme.gridLine } } | ||
| }, | ||
| series: echartsSeriesList | ||
| }; | ||
| return /* @__PURE__ */ jsxs("div", { | ||
| style: { width: "100%" }, | ||
| children: [title && /* @__PURE__ */ jsx("h3", { | ||
| style: { | ||
| fontSize: 14, | ||
| fontWeight: 500, | ||
| color: "var(--bon-text-title)", | ||
| marginBottom: 4 | ||
| }, | ||
| children: title | ||
| }), /* @__PURE__ */ jsx(ReactECharts, { | ||
| option, | ||
| style: { | ||
| height: DEFAULT_CHART_HEIGHT, | ||
| width: "100%" | ||
| }, | ||
| notMerge: true | ||
| })] | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region src/charts/area-chart.tsx | ||
| function AreaChart({ data, x, y, title, series, type, yFmt }) { | ||
| const { theme, tooltip, legend } = useChartTheme(); | ||
| const result = buildSeries(data, x, y, series); | ||
| const useTimeAxis = isTimeAxis(result.labels); | ||
| const labels = useTimeAxis ? result.labels : result.labels.map(formatAxisLabel); | ||
| const rotation = useTimeAxis ? 0 : labelRotation(labels); | ||
| const { series: echartsSeriesList, showLegend } = toEChartsSeries(result, "line-area", type, void 0, useTimeAxis ? result.labels : void 0); | ||
| const xAxisConfig = useTimeAxis ? { | ||
| type: "time", | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| hideOverlap: true | ||
| } | ||
| } : { | ||
| type: "category", | ||
| data: labels, | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| rotate: rotation | ||
| }, | ||
| boundaryGap: false | ||
| }; | ||
| const option = { | ||
| color: CHART_COLORS, | ||
| tooltip: { | ||
| ...tooltip, | ||
| trigger: "axis", | ||
| valueFormatter: tooltipFormatter(yFmt) | ||
| }, | ||
| ...showLegend && { legend }, | ||
| grid: { | ||
| left: 10, | ||
| right: 10, | ||
| top: 10, | ||
| bottom: gridBottom(rotation) + (showLegend ? 30 : 0), | ||
| containLabel: true | ||
| }, | ||
| xAxis: xAxisConfig, | ||
| yAxis: { | ||
| type: "value", | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| formatter: axisValueFormatter | ||
| }, | ||
| splitLine: { lineStyle: { color: theme.gridLine } } | ||
| }, | ||
| series: echartsSeriesList | ||
| }; | ||
| return /* @__PURE__ */ jsxs("div", { | ||
| style: { width: "100%" }, | ||
| children: [title && /* @__PURE__ */ jsx("h3", { | ||
| style: { | ||
| fontSize: 14, | ||
| fontWeight: 500, | ||
| color: "var(--bon-text-title)", | ||
| marginBottom: 4 | ||
| }, | ||
| children: title | ||
| }), /* @__PURE__ */ jsx(ReactECharts, { | ||
| option, | ||
| style: { | ||
| height: DEFAULT_CHART_HEIGHT, | ||
| width: "100%" | ||
| }, | ||
| notMerge: true | ||
| })] | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region src/charts/pie-chart.tsx | ||
| function PieChart({ data, name: nameField, value: valueField, title }) { | ||
| const { theme, tooltip } = useChartTheme(); | ||
| const pieData = data.map((row) => ({ | ||
| name: String(row[nameField] ?? ""), | ||
| value: Number(row[valueField]) || 0 | ||
| })); | ||
| const option = { | ||
| color: CHART_COLORS, | ||
| tooltip: { | ||
| ...tooltip, | ||
| trigger: "item", | ||
| formatter: "{b}: {c} ({d}%)" | ||
| }, | ||
| legend: { | ||
| orient: "horizontal", | ||
| bottom: 0, | ||
| left: "center", | ||
| type: "scroll", | ||
| textStyle: { | ||
| color: theme.legendText, | ||
| fontSize: 12 | ||
| } | ||
| }, | ||
| grid: { bottom: 0 }, | ||
| series: [{ | ||
| type: "pie", | ||
| radius: ["35%", "65%"], | ||
| center: ["50%", "40%"], | ||
| data: pieData, | ||
| label: { show: false }, | ||
| emphasis: { | ||
| label: { | ||
| show: true, | ||
| fontWeight: "bold" | ||
| }, | ||
| itemStyle: { | ||
| shadowBlur: 10, | ||
| shadowColor: "rgba(0,0,0,0.1)" | ||
| } | ||
| } | ||
| }] | ||
| }; | ||
| return /* @__PURE__ */ jsxs("div", { | ||
| style: { width: "100%" }, | ||
| children: [title && /* @__PURE__ */ jsx("h3", { | ||
| style: { | ||
| fontSize: 14, | ||
| fontWeight: 500, | ||
| color: "var(--bon-text-title)", | ||
| marginBottom: 4 | ||
| }, | ||
| children: title | ||
| }), /* @__PURE__ */ jsx(ReactECharts, { | ||
| option, | ||
| style: { | ||
| height: DEFAULT_CHART_HEIGHT, | ||
| width: "100%" | ||
| }, | ||
| notMerge: true | ||
| })] | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region src/charts/data-table.tsx | ||
| const DEFAULT_PAGE_SIZE = 10; | ||
| const ChevronUpIcon = () => /* @__PURE__ */ jsx("svg", { | ||
| width: "14", | ||
| height: "14", | ||
| viewBox: "0 0 24 24", | ||
| fill: "none", | ||
| stroke: "currentColor", | ||
| strokeWidth: "2", | ||
| strokeLinecap: "round", | ||
| strokeLinejoin: "round", | ||
| children: /* @__PURE__ */ jsx("path", { d: "m18 15-6-6-6 6" }) | ||
| }); | ||
| const ChevronDownIcon = () => /* @__PURE__ */ jsx("svg", { | ||
| width: "14", | ||
| height: "14", | ||
| viewBox: "0 0 24 24", | ||
| fill: "none", | ||
| stroke: "currentColor", | ||
| strokeWidth: "2", | ||
| strokeLinecap: "round", | ||
| strokeLinejoin: "round", | ||
| children: /* @__PURE__ */ jsx("path", { d: "m6 9 6 6 6-6" }) | ||
| }); | ||
| const ChevronsUpDownIcon = () => /* @__PURE__ */ jsxs("svg", { | ||
| width: "14", | ||
| height: "14", | ||
| viewBox: "0 0 24 24", | ||
| fill: "none", | ||
| stroke: "currentColor", | ||
| strokeWidth: "2", | ||
| strokeLinecap: "round", | ||
| strokeLinejoin: "round", | ||
| children: [/* @__PURE__ */ jsx("path", { d: "m7 15 5 5 5-5" }), /* @__PURE__ */ jsx("path", { d: "m7 9 5-5 5 5" })] | ||
| }); | ||
| const ChevronLeftIcon = () => /* @__PURE__ */ jsx("svg", { | ||
| width: "14", | ||
| height: "14", | ||
| viewBox: "0 0 24 24", | ||
| fill: "none", | ||
| stroke: "currentColor", | ||
| strokeWidth: "2", | ||
| strokeLinecap: "round", | ||
| strokeLinejoin: "round", | ||
| children: /* @__PURE__ */ jsx("path", { d: "m15 18-6-6 6-6" }) | ||
| }); | ||
| const ChevronRightIcon = () => /* @__PURE__ */ jsx("svg", { | ||
| width: "14", | ||
| height: "14", | ||
| viewBox: "0 0 24 24", | ||
| fill: "none", | ||
| stroke: "currentColor", | ||
| strokeWidth: "2", | ||
| strokeLinecap: "round", | ||
| strokeLinejoin: "round", | ||
| children: /* @__PURE__ */ jsx("path", { d: "m9 18 6-6-6-6" }) | ||
| }); | ||
| /** Check if the first non-null value in a column is numeric */ | ||
| function isNumericColumn(data, col) { | ||
| for (const row of data) { | ||
| const v = row[col]; | ||
| if (v == null) continue; | ||
| return typeof v === "number" || typeof v === "string" && v !== "" && !isNaN(Number(v)); | ||
| } | ||
| return false; | ||
| } | ||
| /** Check if the first non-null value in a column looks like an ISO date */ | ||
| function isDateColumn(data, col) { | ||
| for (const row of data) { | ||
| const v = row[col]; | ||
| if (v == null) continue; | ||
| return typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v); | ||
| } | ||
| return false; | ||
| } | ||
| /** Compare two values for sorting — nulls always last */ | ||
| function compareValues(a, b, asc) { | ||
| if (a == null && b == null) return 0; | ||
| if (a == null) return 1; | ||
| if (b == null) return -1; | ||
| const numA = Number(a); | ||
| const numB = Number(b); | ||
| const bothNumeric = !isNaN(numA) && !isNaN(numB) && a !== "" && b !== ""; | ||
| let cmp; | ||
| if (bothNumeric) cmp = numA - numB; | ||
| else cmp = String(a).localeCompare(String(b), void 0, { sensitivity: "base" }); | ||
| return asc ? cmp : -cmp; | ||
| } | ||
| /** Cube's default row limit when no explicit limit is set */ | ||
| const CUBE_DEFAULT_LIMIT = 1e4; | ||
| function DataTable({ data, columns: explicitColumns, fmt, rows: rowsProp, queryLimit }) { | ||
| const [sort, setSort] = useState(null); | ||
| const [page, setPage] = useState(0); | ||
| if (data.length === 0) return null; | ||
| const columns = explicitColumns ?? Object.keys(data[0]); | ||
| const fmtMap = fmt ? parseFmtProp(fmt) : null; | ||
| const numericCols = new Set(columns.filter((col) => isNumericColumn(data, col))); | ||
| const dateCols = new Set(columns.filter((col) => isDateColumn(data, col))); | ||
| const pageSize = rowsProp === "all" ? data.length : rowsProp ?? DEFAULT_PAGE_SIZE; | ||
| const paginated = pageSize < data.length; | ||
| const effectiveLimit = queryLimit ?? CUBE_DEFAULT_LIMIT; | ||
| const isTruncated = data.length >= effectiveLimit; | ||
| const formatCell = (col, val) => { | ||
| const colFmt = fmtMap?.get(col); | ||
| if (colFmt) return applyFormat(val, colFmt); | ||
| return autoFormat(val); | ||
| }; | ||
| const sortedData = useMemo(() => { | ||
| if (!sort) return data; | ||
| return [...data].sort((a, b) => compareValues(a[sort.col], b[sort.col], sort.asc)); | ||
| }, [data, sort]); | ||
| const pageData = paginated ? sortedData.slice(page * pageSize, (page + 1) * pageSize) : sortedData; | ||
| const totalPages = Math.ceil(sortedData.length / pageSize); | ||
| const handleSort = (col) => { | ||
| setSort((prev) => { | ||
| if (prev?.col === col) return { | ||
| col, | ||
| asc: !prev.asc | ||
| }; | ||
| return { | ||
| col, | ||
| asc: true | ||
| }; | ||
| }); | ||
| setPage(0); | ||
| }; | ||
| return /* @__PURE__ */ jsxs("div", { | ||
| style: { width: "100%" }, | ||
| children: [/* @__PURE__ */ jsx("div", { | ||
| style: { | ||
| overflowX: "auto", | ||
| borderRadius: "var(--bon-radius)", | ||
| border: "1px solid var(--bon-border)", | ||
| scrollbarWidth: "thin" | ||
| }, | ||
| children: /* @__PURE__ */ jsxs("table", { | ||
| style: { | ||
| width: "100%", | ||
| fontSize: 14, | ||
| borderCollapse: "collapse", | ||
| fontVariantNumeric: "tabular-nums" | ||
| }, | ||
| children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", { | ||
| style: { | ||
| borderBottom: "1px solid var(--bon-border)", | ||
| backgroundColor: "var(--bon-table-header-bg)" | ||
| }, | ||
| children: columns.map((col) => { | ||
| const isNumeric = numericCols.has(col); | ||
| const isSorted = sort?.col === col; | ||
| return /* @__PURE__ */ jsx("th", { | ||
| onClick: () => handleSort(col), | ||
| style: { | ||
| padding: "8px 12px", | ||
| fontWeight: 500, | ||
| color: "var(--bon-text-muted)", | ||
| whiteSpace: "nowrap", | ||
| userSelect: "none", | ||
| cursor: "pointer", | ||
| textAlign: isNumeric ? "right" : "left" | ||
| }, | ||
| children: /* @__PURE__ */ jsxs("span", { | ||
| style: { | ||
| display: "inline-flex", | ||
| alignItems: "center", | ||
| gap: 4, | ||
| flexDirection: isNumeric ? "row-reverse" : "row" | ||
| }, | ||
| children: [formatColumnHeader(col), /* @__PURE__ */ jsx("span", { | ||
| style: { opacity: .5 }, | ||
| children: isSorted ? sort.asc ? /* @__PURE__ */ jsx(ChevronUpIcon, {}) : /* @__PURE__ */ jsx(ChevronDownIcon, {}) : /* @__PURE__ */ jsx(ChevronsUpDownIcon, {}) | ||
| })] | ||
| }) | ||
| }, col); | ||
| }) | ||
| }) }), /* @__PURE__ */ jsx("tbody", { children: pageData.map((row, i) => /* @__PURE__ */ jsx("tr", { | ||
| style: { borderBottom: i < pageData.length - 1 ? "1px solid var(--bon-border)" : void 0 }, | ||
| children: columns.map((col) => /* @__PURE__ */ jsx("td", { | ||
| style: { | ||
| padding: "6px 12px", | ||
| textAlign: numericCols.has(col) ? "right" : "left", | ||
| whiteSpace: dateCols.has(col) ? "nowrap" : void 0, | ||
| color: "var(--bon-text)" | ||
| }, | ||
| children: formatCell(col, row[col]) | ||
| }, col)) | ||
| }, i)) })] | ||
| }) | ||
| }), (paginated || isTruncated) && /* @__PURE__ */ jsxs("div", { | ||
| style: { | ||
| display: "flex", | ||
| alignItems: "center", | ||
| justifyContent: "space-between", | ||
| padding: "8px 4px 0", | ||
| fontSize: 12, | ||
| color: "var(--bon-text-muted)" | ||
| }, | ||
| children: [/* @__PURE__ */ jsx("span", { children: isTruncated ? `Showing first ${data.length.toLocaleString()} rows` : paginated ? `${(page * pageSize + 1).toLocaleString()}\u2013${Math.min((page + 1) * pageSize, sortedData.length).toLocaleString()} of ${sortedData.length.toLocaleString()}` : `${data.length.toLocaleString()} rows` }), paginated && totalPages > 1 && /* @__PURE__ */ jsxs("div", { | ||
| style: { | ||
| display: "flex", | ||
| alignItems: "center", | ||
| gap: 4 | ||
| }, | ||
| children: [ | ||
| /* @__PURE__ */ jsx("button", { | ||
| onClick: () => setPage((p) => Math.max(0, p - 1)), | ||
| disabled: page === 0, | ||
| style: { | ||
| padding: 4, | ||
| borderRadius: 4, | ||
| border: "none", | ||
| background: "none", | ||
| cursor: page === 0 ? "not-allowed" : "pointer", | ||
| opacity: page === 0 ? .3 : 1, | ||
| color: "var(--bon-text-muted)" | ||
| }, | ||
| children: /* @__PURE__ */ jsx(ChevronLeftIcon, {}) | ||
| }), | ||
| /* @__PURE__ */ jsxs("span", { children: [ | ||
| page + 1, | ||
| " / ", | ||
| totalPages | ||
| ] }), | ||
| /* @__PURE__ */ jsx("button", { | ||
| onClick: () => setPage((p) => Math.min(totalPages - 1, p + 1)), | ||
| disabled: page >= totalPages - 1, | ||
| style: { | ||
| padding: 4, | ||
| borderRadius: 4, | ||
| border: "none", | ||
| background: "none", | ||
| cursor: page >= totalPages - 1 ? "not-allowed" : "pointer", | ||
| opacity: page >= totalPages - 1 ? .3 : 1, | ||
| color: "var(--bon-text-muted)" | ||
| }, | ||
| children: /* @__PURE__ */ jsx(ChevronRightIcon, {}) | ||
| }) | ||
| ] | ||
| })] | ||
| })] | ||
| }); | ||
| } | ||
| //#endregion | ||
| export { BarChart as a, PALETTES as c, LineChart as i, BonnardContext as l, PieChart as n, BigValue as o, AreaChart as r, CHART_COLORS as s, DataTable as t, useBonnard as u }; |
@@ -1,2 +0,2 @@ | ||
| import { a as BarChart, i as LineChart, n as PieChart, o as BigValue, r as AreaChart, t as DataTable, u as useBonnard } from "./data-table-C3K4rPh2.js"; | ||
| import { a as BarChart, i as LineChart, n as PieChart, o as BigValue, r as AreaChart, t as DataTable, u as useBonnard } from "./data-table-CkOo6s_V.js"; | ||
| import { useCallback, useEffect, useRef, useState } from "react"; | ||
@@ -810,4 +810,4 @@ import DOMPurify from "isomorphic-dompurify"; | ||
| borderRadius: "var(--bon-radius, 8px)", | ||
| border: "1px solid var(--bon-border, rgba(128,128,128,0.2))", | ||
| backgroundColor: "var(--bon-card-bg, transparent)", | ||
| border: "1px solid var(--bon-border)", | ||
| backgroundColor: "var(--bon-bg-card)", | ||
| padding: 16 | ||
@@ -814,0 +814,0 @@ }, |
+1
-1
@@ -1,2 +0,2 @@ | ||
| import { a as BarChart, c as PALETTES, i as LineChart, l as BonnardContext, n as PieChart, o as BigValue, r as AreaChart, s as CHART_COLORS, t as DataTable, u as useBonnard } from "./data-table-C3K4rPh2.js"; | ||
| import { a as BarChart, c as PALETTES, i as LineChart, l as BonnardContext, n as PieChart, o as BigValue, r as AreaChart, s as CHART_COLORS, t as DataTable, u as useBonnard } from "./data-table-CkOo6s_V.js"; | ||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||
@@ -3,0 +3,0 @@ import { jsx } from "react/jsx-runtime"; |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"build-series.d.ts","sourceRoot":"","sources":["../../src/lib/build-series.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,WAAW,aAAa;IAC5B,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,iEAAiE;IACjE,MAAM,EAAE,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAChC,4BAA4B;IAC5B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,6BAA6B;IAC7B,QAAQ,EAAE,aAAa,EAAE,CAAC;CAC3B;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC/B,CAAC,EAAE,MAAM,EACT,CAAC,EAAE,MAAM,EACT,MAAM,CAAC,EAAE,MAAM,GACd,iBAAiB,CA+EnB"} | ||
| {"version":3,"file":"build-series.d.ts","sourceRoot":"","sources":["../../src/lib/build-series.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAUH,MAAM,WAAW,aAAa;IAC5B,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,iEAAiE;IACjE,MAAM,EAAE,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,iBAAiB;IAChC,4BAA4B;IAC5B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,6BAA6B;IAC7B,QAAQ,EAAE,aAAa,EAAE,CAAC;CAC3B;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAC/B,CAAC,EAAE,MAAM,EACT,CAAC,EAAE,MAAM,EACT,MAAM,CAAC,EAAE,MAAM,GACd,iBAAiB,CA+EnB"} |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"chart-theme.d.ts","sourceRoot":"","sources":["../../src/theme/chart-theme.ts"],"names":[],"mappings":"AAKA,sCAAsC;AACtC,eAAO,MAAM,QAAQ;;;;;CAuBX,CAAC;AAEX,MAAM,MAAM,WAAW,GAAG,MAAM,OAAO,QAAQ,CAAC;AAGhD,eAAO,MAAM,YAAY,EAAE,SAAS,MAAM,EAAqB,CAAC;AAEhE,8BAA8B;AAC9B,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IACtD,OAAO,EAAE;QACP,eAAe,EAAE,MAAM,CAAC;QACxB,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AA0BD,wBAAgB,aAAa,CAAC,MAAM,EAAE,OAAO,GAAG,gBAAgB,CAE/D;AAED,iDAAiD;AACjD,wBAAgB,YAAY,CAAC,KAAK,EAAE,gBAAgB;;;;;;;;;;oBAQhC,MAAM,EAAE,WAAW,OAAO,QAAQ,WAAW,SAAS,OAAO,QAAQ;QAAE,WAAW,EAAE,MAAM,EAAE,CAAC;QAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;KAAE;EAWrI;AAED,gDAAgD;AAChD,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB;;;;;;;;;;;;;;EAWlD;AAED,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC,0EAA0E;AAC1E,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAQvD;AAED,kEAAkE;AAClE,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,CAGtD;AAED,4CAA4C;AAC5C,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,sEAAsE;AACtE,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAQhD;AAED,gFAAgF;AAChF,wBAAgB,gBAAgB,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC,CAKzE;AAED,oEAAoE;AACpE,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAgBnD;AAED,iDAAiD;AACjD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAItD;AAED,gEAAgE;AAChE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAKtD"} | ||
| {"version":3,"file":"chart-theme.d.ts","sourceRoot":"","sources":["../../src/theme/chart-theme.ts"],"names":[],"mappings":"AAKA,sCAAsC;AACtC,eAAO,MAAM,QAAQ;;;;;CAuBX,CAAC;AAEX,MAAM,MAAM,WAAW,GAAG,MAAM,OAAO,QAAQ,CAAC;AAGhD,eAAO,MAAM,YAAY,EAAE,SAAS,MAAM,EAAqB,CAAC;AAEhE,8BAA8B;AAC9B,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IACtD,OAAO,EAAE;QACP,eAAe,EAAE,MAAM,CAAC;QACxB,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AA0BD,wBAAgB,aAAa,CAAC,MAAM,EAAE,OAAO,GAAG,gBAAgB,CAE/D;AAED,iDAAiD;AACjD,wBAAgB,YAAY,CAAC,KAAK,EAAE,gBAAgB;;;;;;;;;;oBAQhC,MAAM,EAAE,WAAW,OAAO,QAAQ,WAAW,SAAS,OAAO,QAAQ;QAAE,WAAW,EAAE,MAAM,EAAE,CAAC;QAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;KAAE;EAWrI;AAED,gDAAgD;AAChD,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB;;;;;;;;;;;;;;EAWlD;AAED,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC,0EAA0E;AAC1E,wBAAgB,UAAU,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAQvD;AAED,kEAAkE;AAClE,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,CAGtD;AAED,4CAA4C;AAC5C,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,sEAAsE;AACtE,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAQhD;AAED,gFAAgF;AAChF,wBAAgB,gBAAgB,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,CAAC,CAKzE;AAED,oEAAoE;AACpE,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAgBnD;AAED,iDAAiD;AACjD,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAQtD;AAED,gEAAgE;AAChE,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAKtD"} |
+1
-1
| { | ||
| "name": "@bonnard/react", | ||
| "version": "0.2.1", | ||
| "version": "0.3.0", | ||
| "description": "Bonnard embedded analytics — React charts, dashboards, and hooks", | ||
@@ -5,0 +5,0 @@ "repository": { |
| import { createContext, useContext, useMemo, useState } from "react"; | ||
| import { jsx, jsxs } from "react/jsx-runtime"; | ||
| import { format } from "numfmt"; | ||
| import ReactECharts from "echarts-for-react"; | ||
| //#region src/context.ts | ||
| const BonnardContext = createContext(null); | ||
| function useBonnard() { | ||
| const ctx = useContext(BonnardContext); | ||
| if (!ctx) throw new Error("useBonnard() must be used within a <BonnardProvider>"); | ||
| return ctx; | ||
| } | ||
| //#endregion | ||
| //#region src/lib/format-value.ts | ||
| /** Named presets mapping to Excel format codes */ | ||
| const PRESETS = { | ||
| num0: "#,##0", | ||
| num1: "#,##0.0", | ||
| num2: "#,##0.00", | ||
| usd: "$#,##0", | ||
| usd2: "$#,##0.00", | ||
| eur: "#,##0 \"€\"", | ||
| eur2: "#,##0.00 \"€\"", | ||
| gbp: "£#,##0", | ||
| gbp2: "£#,##0.00", | ||
| chf: "\"CHF \"#,##0", | ||
| chf2: "\"CHF \"#,##0.00", | ||
| pct: "0%", | ||
| pct1: "0.0%", | ||
| pct2: "0.00%", | ||
| shortdate: "d mmm yyyy", | ||
| longdate: "d mmmm yyyy", | ||
| monthyear: "mmm yyyy" | ||
| }; | ||
| /** Resolve a preset name to an Excel format code, or pass through raw codes */ | ||
| function parsePreset(name) { | ||
| return PRESETS[name] ?? name; | ||
| } | ||
| /** Detect whether an Excel format code is a date pattern */ | ||
| function isDatePattern(pattern) { | ||
| const stripped = pattern.replace(/"[^"]*"/g, "").replace(/\[[^\]]*\]/g, ""); | ||
| return /[ymdhs]/i.test(stripped); | ||
| } | ||
| /** Format a value with a preset name or raw Excel format code */ | ||
| function applyFormat(value, fmt) { | ||
| if (value == null) return "—"; | ||
| const pattern = parsePreset(fmt); | ||
| if (isDatePattern(pattern) && typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) { | ||
| const d = new Date(value); | ||
| if (!isNaN(d.getTime())) return format(pattern, d); | ||
| } | ||
| const num = Number(value); | ||
| if (!isNaN(num)) return format(pattern, num); | ||
| return String(value); | ||
| } | ||
| /** Auto-detect value type and format with sensible defaults */ | ||
| function autoFormat(value) { | ||
| if (value == null) return "—"; | ||
| if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) { | ||
| const d = new Date(value); | ||
| if (!isNaN(d.getTime())) return format("d mmm yyyy", d); | ||
| } | ||
| const num = Number(value); | ||
| if (typeof value === "number" || typeof value === "string" && value !== "" && !isNaN(num)) return Number.isInteger(num) ? format("#,##0", num) : format("#,##0.##", num); | ||
| return String(value); | ||
| } | ||
| /** | ||
| * Parse a fmt prop string like "revenue:eur2,date:shortdate" into a column→format map. | ||
| * Splits on commas only when followed by a column name and colon (to avoid breaking | ||
| * Excel format codes that contain commas like `#,##0`). | ||
| * A single format without a colon (e.g. `fmt="eur2"`) is returned under the empty key. | ||
| */ | ||
| function parseFmtProp(fmt) { | ||
| const map = /* @__PURE__ */ new Map(); | ||
| const entries = fmt.split(/,(?=\s*[a-zA-Z_]\w*\s*:)/); | ||
| for (const entry of entries) { | ||
| const trimmed = entry.trim(); | ||
| if (!trimmed) continue; | ||
| const colonIdx = trimmed.indexOf(":"); | ||
| if (colonIdx === -1) map.set("", trimmed); | ||
| else { | ||
| const col = trimmed.slice(0, colonIdx).trim(); | ||
| const fmtVal = trimmed.slice(colonIdx + 1).trim(); | ||
| map.set(col, fmtVal); | ||
| } | ||
| } | ||
| return map; | ||
| } | ||
| //#endregion | ||
| //#region src/theme/chart-theme.ts | ||
| /** | ||
| * Shared ECharts theme constants — adapted from metric-builder patterns. | ||
| */ | ||
| /** Named color palettes for charts */ | ||
| const PALETTES = { | ||
| default: [ | ||
| "#2563eb", | ||
| "#dc2626", | ||
| "#16a34a", | ||
| "#ca8a04", | ||
| "#9333ea", | ||
| "#ec4899", | ||
| "#0891b2", | ||
| "#ea580c" | ||
| ], | ||
| tableau: [ | ||
| "#4e79a7", | ||
| "#f28e2c", | ||
| "#e15759", | ||
| "#76b7b2", | ||
| "#59a14f", | ||
| "#edc949", | ||
| "#af7aa1", | ||
| "#ff9da7", | ||
| "#9c755f", | ||
| "#bab0ab" | ||
| ], | ||
| observable: [ | ||
| "#4269d0", | ||
| "#efb118", | ||
| "#ff725c", | ||
| "#6cc5b0", | ||
| "#3ca951", | ||
| "#ff8ab7", | ||
| "#a463f2", | ||
| "#97bbf5", | ||
| "#9c6b4e", | ||
| "#9498a0" | ||
| ], | ||
| metabase: [ | ||
| "#509EE3", | ||
| "#88BF4D", | ||
| "#A989C5", | ||
| "#EF8C8C", | ||
| "#F9D45C", | ||
| "#F2A86F", | ||
| "#98D9D9", | ||
| "#7172AD" | ||
| ] | ||
| }; | ||
| const CHART_COLORS = PALETTES.tableau; | ||
| const LIGHT_THEME = { | ||
| text: { | ||
| label: "#6b7280", | ||
| title: "#374151", | ||
| muted: "#9ca3af" | ||
| }, | ||
| tooltip: { | ||
| backgroundColor: "#fff", | ||
| borderColor: "#e5e7eb", | ||
| textColor: "#374151", | ||
| shadow: "box-shadow: 0 2px 8px rgba(59,130,246,0.05);" | ||
| }, | ||
| gridLine: "#f3f4f6", | ||
| legendText: "#6b7280" | ||
| }; | ||
| const DARK_THEME = { | ||
| text: { | ||
| label: "#9ca3af", | ||
| title: "#e5e7eb", | ||
| muted: "#6b7280" | ||
| }, | ||
| tooltip: { | ||
| backgroundColor: "#1f2937", | ||
| borderColor: "#374151", | ||
| textColor: "#e5e7eb", | ||
| shadow: "box-shadow: 0 2px 8px rgba(0,0,0,0.3);" | ||
| }, | ||
| gridLine: "#374151", | ||
| legendText: "#9ca3af" | ||
| }; | ||
| function getChartTheme(isDark) { | ||
| return isDark ? DARK_THEME : LIGHT_THEME; | ||
| } | ||
| /** Build tooltip config for the current theme */ | ||
| function buildTooltip(theme) { | ||
| return { | ||
| backgroundColor: theme.tooltip.backgroundColor, | ||
| borderColor: theme.tooltip.borderColor, | ||
| borderWidth: 1, | ||
| textStyle: { | ||
| color: theme.tooltip.textColor, | ||
| fontSize: 13 | ||
| }, | ||
| extraCssText: theme.tooltip.shadow, | ||
| appendToBody: true, | ||
| position(point, _params, _dom, _rect, size) { | ||
| const [mouseX] = point; | ||
| const [tooltipW] = size.contentSize; | ||
| const [chartW] = size.viewSize; | ||
| const gap = 15; | ||
| return [mouseX + gap + tooltipW < chartW ? mouseX + gap : mouseX - tooltipW - gap, 10]; | ||
| } | ||
| }; | ||
| } | ||
| /** Build legend config for the current theme */ | ||
| function buildLegend(theme) { | ||
| return { | ||
| type: "scroll", | ||
| orient: "horizontal", | ||
| bottom: 0, | ||
| left: "center", | ||
| textStyle: { | ||
| color: theme.legendText, | ||
| fontSize: 12 | ||
| }, | ||
| pageTextStyle: { color: theme.legendText }, | ||
| itemWidth: 16, | ||
| itemHeight: 4 | ||
| }; | ||
| } | ||
| const DEFAULT_CHART_HEIGHT = 320; | ||
| /** Check if x-axis labels are ISO dates — if so, use ECharts time axis */ | ||
| function isTimeAxis(rawLabels) { | ||
| if (rawLabels.length === 0) return false; | ||
| for (const label of rawLabels) { | ||
| if (!label) continue; | ||
| return /^\d{4}-\d{2}-\d{2}/.test(label); | ||
| } | ||
| return false; | ||
| } | ||
| /** Determine axis label rotation based on longest label length */ | ||
| function labelRotation(labels) { | ||
| return labels.reduce((max, l) => Math.max(max, String(l).length), 0) > 10 ? -45 : 0; | ||
| } | ||
| /** Grid bottom padding based on rotation */ | ||
| function gridBottom(rotation) { | ||
| return rotation === 0 ? 5 : 30; | ||
| } | ||
| /** Format a number for display (compact notation for large values) */ | ||
| function formatValue(val) { | ||
| if (val == null) return "—"; | ||
| const num = Number(val); | ||
| if (isNaN(num)) return String(val); | ||
| if (Math.abs(num) >= 1e6) return `${(num / 1e6).toFixed(1)}M`; | ||
| if (Math.abs(num) >= 1e3) return `${(num / 1e3).toFixed(1)}K`; | ||
| if (Number.isInteger(num)) return num.toLocaleString(); | ||
| return num.toFixed(2); | ||
| } | ||
| /** ECharts tooltip valueFormatter — uses explicit format or compact fallback */ | ||
| function tooltipFormatter(yFmt) { | ||
| if (yFmt) return (val) => applyFormat(val, yFmt); | ||
| return (val) => formatValue(val); | ||
| } | ||
| /** Format axis label — detects ISO dates and formats them nicely */ | ||
| function formatAxisLabel(val) { | ||
| if (/^\d{4}-\d{2}-\d{2}T/.test(val)) { | ||
| const d = new Date(val); | ||
| if (!isNaN(d.getTime())) return d.toLocaleDateString("en-GB", { | ||
| day: "numeric", | ||
| month: "short" | ||
| }); | ||
| } | ||
| if (/^\d{4}-\d{2}-\d{2}$/.test(val)) { | ||
| const d = /* @__PURE__ */ new Date(val + "T00:00:00"); | ||
| if (!isNaN(d.getTime())) return d.toLocaleDateString("en-GB", { | ||
| day: "numeric", | ||
| month: "short" | ||
| }); | ||
| } | ||
| return val; | ||
| } | ||
| /** ECharts y-axis formatter — compact numbers */ | ||
| function axisValueFormatter(val) { | ||
| if (Math.abs(val) >= 1e6) return `${(val / 1e6).toFixed(1)}M`; | ||
| if (Math.abs(val) >= 1e3) return `${(val / 1e3).toFixed(0)}K`; | ||
| return String(val); | ||
| } | ||
| /** Convert snake_case or camelCase field names to Title Case */ | ||
| function formatColumnHeader(col) { | ||
| return col.replace(/_/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/\b\w/g, (c) => c.toUpperCase()); | ||
| } | ||
| //#endregion | ||
| //#region src/charts/big-value.tsx | ||
| function BigValue({ data, value, title, fmt }) { | ||
| const row = data[0]; | ||
| if (!row) return null; | ||
| const displayValue = fmt ? applyFormat(row[value], fmt) : formatValue(row[value]); | ||
| return /* @__PURE__ */ jsxs("div", { | ||
| style: { | ||
| minWidth: 0, | ||
| borderRadius: "var(--bon-radius)", | ||
| border: "1px solid var(--bon-border)", | ||
| backgroundColor: "var(--bon-bg-card)", | ||
| padding: 20, | ||
| boxShadow: "var(--bon-shadow)" | ||
| }, | ||
| children: [/* @__PURE__ */ jsx("p", { | ||
| style: { | ||
| overflow: "hidden", | ||
| textOverflow: "ellipsis", | ||
| whiteSpace: "nowrap", | ||
| fontSize: 14, | ||
| fontWeight: 500, | ||
| color: "var(--bon-text-muted)", | ||
| margin: 0 | ||
| }, | ||
| children: title ?? value | ||
| }), /* @__PURE__ */ jsx("p", { | ||
| style: { | ||
| fontSize: 30, | ||
| fontWeight: 700, | ||
| letterSpacing: "-0.025em", | ||
| color: "var(--bon-text)", | ||
| margin: 0, | ||
| marginTop: 4 | ||
| }, | ||
| children: displayValue | ||
| })] | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region src/lib/build-series.ts | ||
| /** | ||
| * Build multi-series datasets from flat query data. | ||
| * | ||
| * @param data - Flat array of row objects from query | ||
| * @param x - Column name for x-axis | ||
| * @param y - Column name(s) for y-axis, comma-separated for multiple | ||
| * @param series - Optional column name to split data into separate series | ||
| */ | ||
| function buildSeries(data, x, y, series) { | ||
| if (!data || data.length === 0) return { | ||
| labels: [], | ||
| datasets: [] | ||
| }; | ||
| const firstX = String(data[0][x] ?? ""); | ||
| if (/^\d{4}-\d{2}-\d{2}/.test(firstX)) data = [...data].sort((a, b) => { | ||
| const aVal = String(a[x] ?? ""); | ||
| const bVal = String(b[x] ?? ""); | ||
| return aVal.localeCompare(bVal); | ||
| }); | ||
| const yColumns = y.split(",").map((col) => col.trim()).filter(Boolean); | ||
| if (!series) return { | ||
| labels: data.map((row) => String(row[x] ?? "")), | ||
| datasets: yColumns.map((col) => ({ | ||
| name: col, | ||
| values: data.map((row) => { | ||
| const val = row[col]; | ||
| return val == null ? null : Number(val); | ||
| }) | ||
| })) | ||
| }; | ||
| const xValues = []; | ||
| const xSet = /* @__PURE__ */ new Set(); | ||
| const seriesKeys = []; | ||
| const seriesSet = /* @__PURE__ */ new Set(); | ||
| for (const row of data) { | ||
| const xRaw = String(row[x] ?? ""); | ||
| if (!xSet.has(xRaw)) { | ||
| xSet.add(xRaw); | ||
| xValues.push(xRaw); | ||
| } | ||
| const sk = String(row[series] ?? ""); | ||
| if (!seriesSet.has(sk)) { | ||
| seriesSet.add(sk); | ||
| seriesKeys.push(sk); | ||
| } | ||
| } | ||
| const lookup = /* @__PURE__ */ new Map(); | ||
| for (const row of data) { | ||
| const key = `${String(row[x] ?? "")}\0${String(row[series] ?? "")}`; | ||
| lookup.set(key, row); | ||
| } | ||
| const datasets = []; | ||
| for (const sk of seriesKeys) for (const col of yColumns) { | ||
| const name = yColumns.length === 1 ? sk : `${sk} - ${col}`; | ||
| const values = xValues.map((xRaw) => { | ||
| const row = lookup.get(`${xRaw}\0${sk}`); | ||
| if (!row) return null; | ||
| const val = row[col]; | ||
| return val == null ? null : Number(val); | ||
| }); | ||
| datasets.push({ | ||
| name, | ||
| values | ||
| }); | ||
| } | ||
| return { | ||
| labels: xValues, | ||
| datasets | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/lib/echarts-series.ts | ||
| /** | ||
| * Convert BuildSeriesResult to ECharts series and legend config. | ||
| * | ||
| * @param result - Output from buildSeries() | ||
| * @param chartKind - 'bar', 'line', or 'line-area' | ||
| * @param seriesType - 'stacked', 'grouped', or undefined | ||
| * @param horizontal - For bar charts: swap axes | ||
| * @param rawLabels - When provided, converts data to [label, value] pairs (for time axis) | ||
| */ | ||
| function toEChartsSeries(result, chartKind, seriesType, horizontal, rawLabels) { | ||
| const multiSeries = result.datasets.length > 1; | ||
| let stack; | ||
| if (chartKind === "bar") stack = multiSeries && seriesType !== "grouped" ? "stack1" : void 0; | ||
| else if (chartKind === "line-area") stack = seriesType === "stacked" ? "stack1" : void 0; | ||
| return { | ||
| series: result.datasets.map((ds, idx) => { | ||
| const data = rawLabels ? rawLabels.map((label, i) => [label, ds.values[i]]) : ds.values; | ||
| const base = { | ||
| name: ds.name, | ||
| data | ||
| }; | ||
| if (chartKind === "bar") { | ||
| base.type = "bar"; | ||
| base.barMaxWidth = 40; | ||
| if (stack) base.stack = stack; | ||
| base.itemStyle = { borderRadius: !stack || idx === result.datasets.length - 1 ? horizontal ? [ | ||
| 0, | ||
| 4, | ||
| 4, | ||
| 0 | ||
| ] : [ | ||
| 4, | ||
| 4, | ||
| 0, | ||
| 0 | ||
| ] : 0 }; | ||
| } else { | ||
| base.type = "line"; | ||
| base.smooth = true; | ||
| base.symbol = "circle"; | ||
| base.symbolSize = 6; | ||
| base.lineStyle = { width: 2 }; | ||
| if (stack) base.stack = stack; | ||
| if (chartKind === "line-area") base.areaStyle = { opacity: stack ? .6 : .15 }; | ||
| } | ||
| return base; | ||
| }), | ||
| showLegend: multiSeries | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/theme/use-chart-theme.ts | ||
| /** | ||
| * Returns mode-aware chart theme values. | ||
| * Reads isDark from BonnardProvider context. | ||
| */ | ||
| function useChartTheme() { | ||
| const { isDark } = useBonnard(); | ||
| const theme = getChartTheme(isDark); | ||
| return { | ||
| isDark, | ||
| theme, | ||
| tooltip: buildTooltip(theme), | ||
| legend: buildLegend(theme) | ||
| }; | ||
| } | ||
| //#endregion | ||
| //#region src/charts/bar-chart.tsx | ||
| function BarChart({ data, x, y, title, horizontal, series, type, yFmt }) { | ||
| const { theme, tooltip, legend } = useChartTheme(); | ||
| const result = buildSeries(data, x, y, series); | ||
| const useTimeAxis = !horizontal && isTimeAxis(result.labels); | ||
| const labels = useTimeAxis ? result.labels : result.labels.map(formatAxisLabel); | ||
| const rotation = horizontal ? 0 : useTimeAxis ? 0 : labelRotation(labels); | ||
| const { series: echartsSeriesList, showLegend } = toEChartsSeries(result, "bar", type, horizontal, useTimeAxis ? result.labels : void 0); | ||
| const categoryAxis = useTimeAxis ? { | ||
| type: "time", | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| hideOverlap: true | ||
| } | ||
| } : { | ||
| type: "category", | ||
| data: labels, | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| ...horizontal ? {} : { rotate: rotation } | ||
| } | ||
| }; | ||
| const valueAxis = { | ||
| type: "value", | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| formatter: axisValueFormatter | ||
| }, | ||
| splitLine: { lineStyle: { color: theme.gridLine } } | ||
| }; | ||
| const option = { | ||
| color: CHART_COLORS, | ||
| tooltip: { | ||
| ...tooltip, | ||
| trigger: "axis", | ||
| valueFormatter: tooltipFormatter(yFmt) | ||
| }, | ||
| ...showLegend && { legend }, | ||
| grid: { | ||
| left: 10, | ||
| right: 10, | ||
| top: 10, | ||
| bottom: (horizontal ? 40 : gridBottom(rotation)) + (showLegend ? 30 : 0), | ||
| containLabel: true | ||
| }, | ||
| [horizontal ? "yAxis" : "xAxis"]: categoryAxis, | ||
| [horizontal ? "xAxis" : "yAxis"]: valueAxis, | ||
| series: echartsSeriesList | ||
| }; | ||
| return /* @__PURE__ */ jsxs("div", { | ||
| style: { width: "100%" }, | ||
| children: [title && /* @__PURE__ */ jsx("h3", { | ||
| style: { | ||
| fontSize: 14, | ||
| fontWeight: 500, | ||
| color: "var(--bon-text-title)", | ||
| marginBottom: 4 | ||
| }, | ||
| children: title | ||
| }), /* @__PURE__ */ jsx(ReactECharts, { | ||
| option, | ||
| style: { | ||
| height: DEFAULT_CHART_HEIGHT, | ||
| width: "100%" | ||
| }, | ||
| notMerge: true | ||
| })] | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region src/charts/line-chart.tsx | ||
| function LineChart({ data, x, y, title, series, type, yFmt }) { | ||
| const { theme, tooltip, legend } = useChartTheme(); | ||
| const result = buildSeries(data, x, y, series); | ||
| const useTimeAxis = isTimeAxis(result.labels); | ||
| const labels = useTimeAxis ? result.labels : result.labels.map(formatAxisLabel); | ||
| const rotation = useTimeAxis ? 0 : labelRotation(labels); | ||
| const { series: echartsSeriesList, showLegend } = toEChartsSeries(result, "line", type, void 0, useTimeAxis ? result.labels : void 0); | ||
| const xAxisConfig = useTimeAxis ? { | ||
| type: "time", | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| hideOverlap: true | ||
| } | ||
| } : { | ||
| type: "category", | ||
| data: labels, | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| rotate: rotation | ||
| }, | ||
| boundaryGap: false | ||
| }; | ||
| const option = { | ||
| color: CHART_COLORS, | ||
| tooltip: { | ||
| ...tooltip, | ||
| trigger: "axis", | ||
| valueFormatter: tooltipFormatter(yFmt) | ||
| }, | ||
| ...showLegend && { legend }, | ||
| grid: { | ||
| left: 10, | ||
| right: 10, | ||
| top: 10, | ||
| bottom: gridBottom(rotation) + (showLegend ? 30 : 0), | ||
| containLabel: true | ||
| }, | ||
| xAxis: xAxisConfig, | ||
| yAxis: { | ||
| type: "value", | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| formatter: axisValueFormatter | ||
| }, | ||
| splitLine: { lineStyle: { color: theme.gridLine } } | ||
| }, | ||
| series: echartsSeriesList | ||
| }; | ||
| return /* @__PURE__ */ jsxs("div", { | ||
| style: { width: "100%" }, | ||
| children: [title && /* @__PURE__ */ jsx("h3", { | ||
| style: { | ||
| fontSize: 14, | ||
| fontWeight: 500, | ||
| color: "var(--bon-text-title)", | ||
| marginBottom: 4 | ||
| }, | ||
| children: title | ||
| }), /* @__PURE__ */ jsx(ReactECharts, { | ||
| option, | ||
| style: { | ||
| height: DEFAULT_CHART_HEIGHT, | ||
| width: "100%" | ||
| }, | ||
| notMerge: true | ||
| })] | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region src/charts/area-chart.tsx | ||
| function AreaChart({ data, x, y, title, series, type, yFmt }) { | ||
| const { theme, tooltip, legend } = useChartTheme(); | ||
| const result = buildSeries(data, x, y, series); | ||
| const useTimeAxis = isTimeAxis(result.labels); | ||
| const labels = useTimeAxis ? result.labels : result.labels.map(formatAxisLabel); | ||
| const rotation = useTimeAxis ? 0 : labelRotation(labels); | ||
| const { series: echartsSeriesList, showLegend } = toEChartsSeries(result, "line-area", type, void 0, useTimeAxis ? result.labels : void 0); | ||
| const xAxisConfig = useTimeAxis ? { | ||
| type: "time", | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| hideOverlap: true | ||
| } | ||
| } : { | ||
| type: "category", | ||
| data: labels, | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| rotate: rotation | ||
| }, | ||
| boundaryGap: false | ||
| }; | ||
| const option = { | ||
| color: CHART_COLORS, | ||
| tooltip: { | ||
| ...tooltip, | ||
| trigger: "axis", | ||
| valueFormatter: tooltipFormatter(yFmt) | ||
| }, | ||
| ...showLegend && { legend }, | ||
| grid: { | ||
| left: 10, | ||
| right: 10, | ||
| top: 10, | ||
| bottom: gridBottom(rotation) + (showLegend ? 30 : 0), | ||
| containLabel: true | ||
| }, | ||
| xAxis: xAxisConfig, | ||
| yAxis: { | ||
| type: "value", | ||
| axisLabel: { | ||
| color: theme.text.label, | ||
| fontSize: 12, | ||
| formatter: axisValueFormatter | ||
| }, | ||
| splitLine: { lineStyle: { color: theme.gridLine } } | ||
| }, | ||
| series: echartsSeriesList | ||
| }; | ||
| return /* @__PURE__ */ jsxs("div", { | ||
| style: { width: "100%" }, | ||
| children: [title && /* @__PURE__ */ jsx("h3", { | ||
| style: { | ||
| fontSize: 14, | ||
| fontWeight: 500, | ||
| color: "var(--bon-text-title)", | ||
| marginBottom: 4 | ||
| }, | ||
| children: title | ||
| }), /* @__PURE__ */ jsx(ReactECharts, { | ||
| option, | ||
| style: { | ||
| height: DEFAULT_CHART_HEIGHT, | ||
| width: "100%" | ||
| }, | ||
| notMerge: true | ||
| })] | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region src/charts/pie-chart.tsx | ||
| function PieChart({ data, name: nameField, value: valueField, title }) { | ||
| const { theme, tooltip } = useChartTheme(); | ||
| const pieData = data.map((row) => ({ | ||
| name: String(row[nameField] ?? ""), | ||
| value: Number(row[valueField]) || 0 | ||
| })); | ||
| const option = { | ||
| color: CHART_COLORS, | ||
| tooltip: { | ||
| ...tooltip, | ||
| trigger: "item", | ||
| formatter: "{b}: {c} ({d}%)" | ||
| }, | ||
| legend: { | ||
| orient: "horizontal", | ||
| bottom: 0, | ||
| left: "center", | ||
| type: "scroll", | ||
| textStyle: { | ||
| color: theme.legendText, | ||
| fontSize: 12 | ||
| } | ||
| }, | ||
| grid: { bottom: 0 }, | ||
| series: [{ | ||
| type: "pie", | ||
| radius: ["35%", "65%"], | ||
| center: ["50%", "40%"], | ||
| data: pieData, | ||
| label: { show: false }, | ||
| emphasis: { | ||
| label: { | ||
| show: true, | ||
| fontWeight: "bold" | ||
| }, | ||
| itemStyle: { | ||
| shadowBlur: 10, | ||
| shadowColor: "rgba(0,0,0,0.1)" | ||
| } | ||
| } | ||
| }] | ||
| }; | ||
| return /* @__PURE__ */ jsxs("div", { | ||
| style: { width: "100%" }, | ||
| children: [title && /* @__PURE__ */ jsx("h3", { | ||
| style: { | ||
| fontSize: 14, | ||
| fontWeight: 500, | ||
| color: "var(--bon-text-title)", | ||
| marginBottom: 4 | ||
| }, | ||
| children: title | ||
| }), /* @__PURE__ */ jsx(ReactECharts, { | ||
| option, | ||
| style: { | ||
| height: DEFAULT_CHART_HEIGHT, | ||
| width: "100%" | ||
| }, | ||
| notMerge: true | ||
| })] | ||
| }); | ||
| } | ||
| //#endregion | ||
| //#region src/charts/data-table.tsx | ||
| const DEFAULT_PAGE_SIZE = 10; | ||
| const ChevronUpIcon = () => /* @__PURE__ */ jsx("svg", { | ||
| width: "14", | ||
| height: "14", | ||
| viewBox: "0 0 24 24", | ||
| fill: "none", | ||
| stroke: "currentColor", | ||
| strokeWidth: "2", | ||
| strokeLinecap: "round", | ||
| strokeLinejoin: "round", | ||
| children: /* @__PURE__ */ jsx("path", { d: "m18 15-6-6-6 6" }) | ||
| }); | ||
| const ChevronDownIcon = () => /* @__PURE__ */ jsx("svg", { | ||
| width: "14", | ||
| height: "14", | ||
| viewBox: "0 0 24 24", | ||
| fill: "none", | ||
| stroke: "currentColor", | ||
| strokeWidth: "2", | ||
| strokeLinecap: "round", | ||
| strokeLinejoin: "round", | ||
| children: /* @__PURE__ */ jsx("path", { d: "m6 9 6 6 6-6" }) | ||
| }); | ||
| const ChevronsUpDownIcon = () => /* @__PURE__ */ jsxs("svg", { | ||
| width: "14", | ||
| height: "14", | ||
| viewBox: "0 0 24 24", | ||
| fill: "none", | ||
| stroke: "currentColor", | ||
| strokeWidth: "2", | ||
| strokeLinecap: "round", | ||
| strokeLinejoin: "round", | ||
| children: [/* @__PURE__ */ jsx("path", { d: "m7 15 5 5 5-5" }), /* @__PURE__ */ jsx("path", { d: "m7 9 5-5 5 5" })] | ||
| }); | ||
| const ChevronLeftIcon = () => /* @__PURE__ */ jsx("svg", { | ||
| width: "14", | ||
| height: "14", | ||
| viewBox: "0 0 24 24", | ||
| fill: "none", | ||
| stroke: "currentColor", | ||
| strokeWidth: "2", | ||
| strokeLinecap: "round", | ||
| strokeLinejoin: "round", | ||
| children: /* @__PURE__ */ jsx("path", { d: "m15 18-6-6 6-6" }) | ||
| }); | ||
| const ChevronRightIcon = () => /* @__PURE__ */ jsx("svg", { | ||
| width: "14", | ||
| height: "14", | ||
| viewBox: "0 0 24 24", | ||
| fill: "none", | ||
| stroke: "currentColor", | ||
| strokeWidth: "2", | ||
| strokeLinecap: "round", | ||
| strokeLinejoin: "round", | ||
| children: /* @__PURE__ */ jsx("path", { d: "m9 18 6-6-6-6" }) | ||
| }); | ||
| /** Check if the first non-null value in a column is numeric */ | ||
| function isNumericColumn(data, col) { | ||
| for (const row of data) { | ||
| const v = row[col]; | ||
| if (v == null) continue; | ||
| return typeof v === "number" || typeof v === "string" && v !== "" && !isNaN(Number(v)); | ||
| } | ||
| return false; | ||
| } | ||
| /** Check if the first non-null value in a column looks like an ISO date */ | ||
| function isDateColumn(data, col) { | ||
| for (const row of data) { | ||
| const v = row[col]; | ||
| if (v == null) continue; | ||
| return typeof v === "string" && /^\d{4}-\d{2}-\d{2}/.test(v); | ||
| } | ||
| return false; | ||
| } | ||
| /** Compare two values for sorting — nulls always last */ | ||
| function compareValues(a, b, asc) { | ||
| if (a == null && b == null) return 0; | ||
| if (a == null) return 1; | ||
| if (b == null) return -1; | ||
| const numA = Number(a); | ||
| const numB = Number(b); | ||
| const bothNumeric = !isNaN(numA) && !isNaN(numB) && a !== "" && b !== ""; | ||
| let cmp; | ||
| if (bothNumeric) cmp = numA - numB; | ||
| else cmp = String(a).localeCompare(String(b), void 0, { sensitivity: "base" }); | ||
| return asc ? cmp : -cmp; | ||
| } | ||
| /** Cube's default row limit when no explicit limit is set */ | ||
| const CUBE_DEFAULT_LIMIT = 1e4; | ||
| function DataTable({ data, columns: explicitColumns, fmt, rows: rowsProp, queryLimit }) { | ||
| const [sort, setSort] = useState(null); | ||
| const [page, setPage] = useState(0); | ||
| if (data.length === 0) return null; | ||
| const columns = explicitColumns ?? Object.keys(data[0]); | ||
| const fmtMap = fmt ? parseFmtProp(fmt) : null; | ||
| const numericCols = new Set(columns.filter((col) => isNumericColumn(data, col))); | ||
| const dateCols = new Set(columns.filter((col) => isDateColumn(data, col))); | ||
| const pageSize = rowsProp === "all" ? data.length : rowsProp ?? DEFAULT_PAGE_SIZE; | ||
| const paginated = pageSize < data.length; | ||
| const effectiveLimit = queryLimit ?? CUBE_DEFAULT_LIMIT; | ||
| const isTruncated = data.length >= effectiveLimit; | ||
| const formatCell = (col, val) => { | ||
| const colFmt = fmtMap?.get(col); | ||
| if (colFmt) return applyFormat(val, colFmt); | ||
| return autoFormat(val); | ||
| }; | ||
| const sortedData = useMemo(() => { | ||
| if (!sort) return data; | ||
| return [...data].sort((a, b) => compareValues(a[sort.col], b[sort.col], sort.asc)); | ||
| }, [data, sort]); | ||
| const pageData = paginated ? sortedData.slice(page * pageSize, (page + 1) * pageSize) : sortedData; | ||
| const totalPages = Math.ceil(sortedData.length / pageSize); | ||
| const handleSort = (col) => { | ||
| setSort((prev) => { | ||
| if (prev?.col === col) return { | ||
| col, | ||
| asc: !prev.asc | ||
| }; | ||
| return { | ||
| col, | ||
| asc: true | ||
| }; | ||
| }); | ||
| setPage(0); | ||
| }; | ||
| return /* @__PURE__ */ jsxs("div", { | ||
| style: { width: "100%" }, | ||
| children: [/* @__PURE__ */ jsx("div", { | ||
| style: { | ||
| overflowX: "auto", | ||
| borderRadius: "var(--bon-radius)", | ||
| border: "1px solid var(--bon-border)", | ||
| scrollbarWidth: "thin" | ||
| }, | ||
| children: /* @__PURE__ */ jsxs("table", { | ||
| style: { | ||
| width: "100%", | ||
| fontSize: 14, | ||
| borderCollapse: "collapse", | ||
| fontVariantNumeric: "tabular-nums" | ||
| }, | ||
| children: [/* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx("tr", { | ||
| style: { | ||
| borderBottom: "1px solid var(--bon-border)", | ||
| backgroundColor: "var(--bon-table-header-bg)" | ||
| }, | ||
| children: columns.map((col) => { | ||
| const isNumeric = numericCols.has(col); | ||
| const isSorted = sort?.col === col; | ||
| return /* @__PURE__ */ jsx("th", { | ||
| onClick: () => handleSort(col), | ||
| style: { | ||
| padding: "8px 12px", | ||
| fontWeight: 500, | ||
| color: "var(--bon-text-muted)", | ||
| whiteSpace: "nowrap", | ||
| userSelect: "none", | ||
| cursor: "pointer", | ||
| textAlign: isNumeric ? "right" : "left" | ||
| }, | ||
| children: /* @__PURE__ */ jsxs("span", { | ||
| style: { | ||
| display: "inline-flex", | ||
| alignItems: "center", | ||
| gap: 4, | ||
| flexDirection: isNumeric ? "row-reverse" : "row" | ||
| }, | ||
| children: [formatColumnHeader(col), /* @__PURE__ */ jsx("span", { | ||
| style: { opacity: .5 }, | ||
| children: isSorted ? sort.asc ? /* @__PURE__ */ jsx(ChevronUpIcon, {}) : /* @__PURE__ */ jsx(ChevronDownIcon, {}) : /* @__PURE__ */ jsx(ChevronsUpDownIcon, {}) | ||
| })] | ||
| }) | ||
| }, col); | ||
| }) | ||
| }) }), /* @__PURE__ */ jsx("tbody", { children: pageData.map((row, i) => /* @__PURE__ */ jsx("tr", { | ||
| style: { borderBottom: i < pageData.length - 1 ? "1px solid var(--bon-border)" : void 0 }, | ||
| children: columns.map((col) => /* @__PURE__ */ jsx("td", { | ||
| style: { | ||
| padding: "6px 12px", | ||
| textAlign: numericCols.has(col) ? "right" : "left", | ||
| whiteSpace: dateCols.has(col) ? "nowrap" : void 0, | ||
| color: "var(--bon-text)" | ||
| }, | ||
| children: formatCell(col, row[col]) | ||
| }, col)) | ||
| }, i)) })] | ||
| }) | ||
| }), (paginated || isTruncated) && /* @__PURE__ */ jsxs("div", { | ||
| style: { | ||
| display: "flex", | ||
| alignItems: "center", | ||
| justifyContent: "space-between", | ||
| padding: "8px 4px 0", | ||
| fontSize: 12, | ||
| color: "var(--bon-text-muted)" | ||
| }, | ||
| children: [/* @__PURE__ */ jsx("span", { children: isTruncated ? `Showing first ${data.length.toLocaleString()} rows` : paginated ? `${(page * pageSize + 1).toLocaleString()}\u2013${Math.min((page + 1) * pageSize, sortedData.length).toLocaleString()} of ${sortedData.length.toLocaleString()}` : `${data.length.toLocaleString()} rows` }), paginated && totalPages > 1 && /* @__PURE__ */ jsxs("div", { | ||
| style: { | ||
| display: "flex", | ||
| alignItems: "center", | ||
| gap: 4 | ||
| }, | ||
| children: [ | ||
| /* @__PURE__ */ jsx("button", { | ||
| onClick: () => setPage((p) => Math.max(0, p - 1)), | ||
| disabled: page === 0, | ||
| style: { | ||
| padding: 4, | ||
| borderRadius: 4, | ||
| border: "none", | ||
| background: "none", | ||
| cursor: page === 0 ? "not-allowed" : "pointer", | ||
| opacity: page === 0 ? .3 : 1, | ||
| color: "var(--bon-text-muted)" | ||
| }, | ||
| children: /* @__PURE__ */ jsx(ChevronLeftIcon, {}) | ||
| }), | ||
| /* @__PURE__ */ jsxs("span", { children: [ | ||
| page + 1, | ||
| " / ", | ||
| totalPages | ||
| ] }), | ||
| /* @__PURE__ */ jsx("button", { | ||
| onClick: () => setPage((p) => Math.min(totalPages - 1, p + 1)), | ||
| disabled: page >= totalPages - 1, | ||
| style: { | ||
| padding: 4, | ||
| borderRadius: 4, | ||
| border: "none", | ||
| background: "none", | ||
| cursor: page >= totalPages - 1 ? "not-allowed" : "pointer", | ||
| opacity: page >= totalPages - 1 ? .3 : 1, | ||
| color: "var(--bon-text-muted)" | ||
| }, | ||
| children: /* @__PURE__ */ jsx(ChevronRightIcon, {}) | ||
| }) | ||
| ] | ||
| })] | ||
| })] | ||
| }); | ||
| } | ||
| //#endregion | ||
| export { BarChart as a, PALETTES as c, LineChart as i, BonnardContext as l, PieChart as n, BigValue as o, AreaChart as r, CHART_COLORS as s, DataTable as t, useBonnard as u }; |
103935
0.75%2632
0.69%