@zuilib/charts 0.3.0 → 0.4.0

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 CHANGED
@@ -8,14 +8,20 @@ other and to `@zuilib/data-grid` — without a hard dependency on it.
8
8
  ## Install
9
9
 
10
10
  ```bash
11
- pnpm add @zuilib/charts @zuilib/components @zuilib/tokens recharts
11
+ pnpm add @zuilib/charts
12
12
  ```
13
13
 
14
- `recharts` (v3), `react` and `@zuilib/tokens` are peer dependencies. The
15
- package imports the `@zuilib/components` primitives, so the components'
16
- Tailwind source (or the prebuilt `zui.css`) must be loaded once in your
17
- app; with the Tailwind source, add `@source "…/@zuilib/charts/dist"` so the
18
- chart utilities are generated.
14
+ React and React DOM are the required peers. Recharts, the ZUI primitives and
15
+ tokens install transitively. Install `recharts` yourself only when your own
16
+ code imports it for hand-written composition.
17
+
18
+ For a Tailwind v4 host, load the component and chart source entries once:
19
+
20
+ ```css
21
+ @import "tailwindcss";
22
+ @import "@zuilib/primitives/tailwind.css";
23
+ @import "@zuilib/charts/tailwind.css";
24
+ ```
19
25
 
20
26
  ## Usage
21
27
 
@@ -47,7 +53,7 @@ const spec = {
47
53
  | `formats` | `Record<string, FormatterFn \| FormatterFactory>` | — custom formatter tokens the spec may name (functions stay out of the spec) |
48
54
  | `locale` | `string` | user's locale — BCP 47 tag for formatted values |
49
55
  | `width` / `height` | `number` | responsive — fixed pixel size for tests and SSR |
50
- | `aspect` | `number` | `16/9` — responsive width/height ratio when `height` is left out |
56
+ | `aspect` | `number` | `16/9` — responsive width/height ratio when `height` is left out; the chart never gets shorter than 200px |
51
57
  | `loading` | `boolean` | `false` — renders a skeleton instead of the chart |
52
58
  | `error` | `ReactNode` | — renders an error state instead of the chart |
53
59
  | `emptyState` | `ReactNode` | the `noData` label — renders when `rows` is empty |
@@ -58,6 +64,19 @@ const spec = {
58
64
  | `ariaLabel` | `string` | the spec's title — accessible name of the figure |
59
65
  | `className` | `string` | merged onto the container |
60
66
 
67
+ ## Sizing
68
+
69
+ A chart fills its container. Leave `width` and `height` out and the frame
70
+ measures itself: the height follows `aspect` (16/9) with a 200px floor so a
71
+ phone-width chart keeps a readable plot; pass `height` to fix the height and
72
+ keep the width fluid; pass both for a fixed size (tests, SSR). The frame is
73
+ a CSS size container (`@container`, `min-w-0`), so it never holds a flex or
74
+ grid parent open at the width of a previous render — put it in any column
75
+ without a `min-w-0` of your own. Under 384px the value axis sizes to its
76
+ labels and the category axis keeps only its end ticks; the legend wraps and,
77
+ on touch pointers, its buttons grow to 44px hit areas. Desktop rendering
78
+ does not change.
79
+
61
80
  ## Entry points
62
81
 
63
82
  `@zuilib/charts` (or `./chart`) is the config-driven renderer: ten chart
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  ChartFrame,
4
4
  chart_frame_default
5
- } from "./chunk-G4FXJJ66.js";
5
+ } from "./chunk-SID6O4PY.js";
6
6
  import "./chunk-7WABBHMT.js";
7
7
  import "./chunk-3VGLTPGL.js";
8
8
  import "./chunk-UN3XPBZ5.js";
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  ChartLegendList,
4
4
  chart_legend_default
5
- } from "./chunk-SHQZRND7.js";
5
+ } from "./chunk-4FWNWLEJ.js";
6
6
  import "./chunk-WVWGHGZE.js";
7
7
  import "./chunk-UN3XPBZ5.js";
8
8
  import "./chunk-IPOBQIYU.js";
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  ChartTooltipPanel,
4
4
  chart_tooltip_default
5
- } from "./chunk-PV27RZSV.js";
5
+ } from "./chunk-FDLNPZV6.js";
6
6
  import "./chunk-IPOBQIYU.js";
7
7
  import "./chunk-7WJ26NHR.js";
8
8
  export {
package/dist/chart.js CHANGED
@@ -4,14 +4,14 @@ import {
4
4
  chart_default,
5
5
  configFromSpec,
6
6
  crossFilterFieldOf
7
- } from "./chunk-BFTWWI46.js";
8
- import "./chunk-YMPZODMC.js";
7
+ } from "./chunk-NDBDHQME.js";
8
+ import "./chunk-J2KWQJGC.js";
9
9
  import "./chunk-HNJKYOPE.js";
10
- import "./chunk-G4FXJJ66.js";
10
+ import "./chunk-SID6O4PY.js";
11
11
  import "./chunk-7WABBHMT.js";
12
12
  import "./chunk-3VGLTPGL.js";
13
- import "./chunk-PV27RZSV.js";
14
- import "./chunk-SHQZRND7.js";
13
+ import "./chunk-FDLNPZV6.js";
14
+ import "./chunk-4FWNWLEJ.js";
15
15
  import "./chunk-WVWGHGZE.js";
16
16
  import "./chunk-UN3XPBZ5.js";
17
17
  import "./chunk-IPOBQIYU.js";
@@ -12,8 +12,9 @@ import {
12
12
  } from "./chunk-7WJ26NHR.js";
13
13
 
14
14
  // src/chart-legend.tsx
15
- import { cn } from "@zuilib/components/lib/cn";
16
- import { focusRingClasses } from "@zuilib/components/lib/focus";
15
+ import { cn } from "@zuilib/primitives/lib/cn";
16
+ import { focusRingClasses } from "@zuilib/primitives/lib/focus";
17
+ import { touchTargetClasses } from "@zuilib/primitives/lib/sizes";
17
18
  import { jsx, jsxs } from "react/jsx-runtime";
18
19
  function ChartLegendList({ payload, className }) {
19
20
  const { config, labels, hiddenSeries, onLegendClick, categoryField, categories } = useChartContext();
@@ -46,7 +47,11 @@ function ChartLegendList({ payload, className }) {
46
47
  "aria-label": fillLabel(labels.toggleSeries, { series: entry.name }),
47
48
  "data-slot": "chart-legend-button",
48
49
  className: cn(
49
- "text-muted-foreground flex cursor-pointer items-center gap-1.5 rounded-sm px-1 py-0.5 text-xs transition-opacity",
50
+ "text-muted-foreground flex min-w-0 max-w-full cursor-pointer items-center gap-1.5 rounded-sm px-1 py-0.5 text-xs transition-opacity",
51
+ /* Touch: a 44px hit area; the legend reports its measured
52
+ height to Recharts, so the plot gives way to it. */
53
+ touchTargetClasses,
54
+ "pointer-coarse:px-2",
50
55
  focusRingClasses,
51
56
  off && "opacity-45"
52
57
  ),
@@ -67,7 +72,7 @@ function ChartLegendList({ payload, className }) {
67
72
  style: { backgroundColor: entry.color }
68
73
  }
69
74
  ),
70
- entry.name
75
+ /* @__PURE__ */ jsx("span", { "data-slot": "chart-legend-label", className: "min-w-0 text-start break-words", children: entry.name })
71
76
  ]
72
77
  }
73
78
  ) }, entry.key);
@@ -6,7 +6,7 @@ import {
6
6
  } from "./chunk-7WJ26NHR.js";
7
7
 
8
8
  // src/chart-tooltip.tsx
9
- import { cn } from "@zuilib/components/lib/cn";
9
+ import { cn } from "@zuilib/primitives/lib/cn";
10
10
  import { jsx, jsxs } from "react/jsx-runtime";
11
11
  function ChartTooltipPanel({
12
12
  active,
@@ -25,7 +25,9 @@ function ChartTooltipPanel({
25
25
  {
26
26
  "data-slot": "chart-tooltip",
27
27
  className: cn(
28
- "bg-popover text-popover-foreground border-border min-w-32 rounded-md border px-2.5 py-1.5 shadow-md",
28
+ /* Never wider than the frame: Recharts keeps the panel inside the
29
+ chart box, so this is what keeps it inside the viewport too. */
30
+ "bg-popover text-popover-foreground border-border min-w-32 max-w-[calc(100cqw-1rem)] rounded-md border px-2.5 py-1.5 shadow-md",
29
31
  className
30
32
  ),
31
33
  children: [
@@ -45,7 +47,7 @@ function ChartTooltipPanel({
45
47
  style: { backgroundColor: series ? seriesColorVar(field) : entry.color }
46
48
  }
47
49
  ),
48
- /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: name }),
50
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground min-w-0 break-words", children: name }),
49
51
  /* @__PURE__ */ jsx("span", { className: "text-foreground ms-auto font-mono font-medium tabular-nums", children: format(entry.value) })
50
52
  ] }, `${field}-${index}`);
51
53
  }) })
@@ -9,10 +9,13 @@ import {
9
9
  } from "./chunk-3VGLTPGL.js";
10
10
  import {
11
11
  ChartTooltipPanel
12
- } from "./chunk-PV27RZSV.js";
12
+ } from "./chunk-FDLNPZV6.js";
13
13
  import {
14
14
  ChartLegendList
15
- } from "./chunk-SHQZRND7.js";
15
+ } from "./chunk-4FWNWLEJ.js";
16
+ import {
17
+ ChartContext
18
+ } from "./chunk-IPOBQIYU.js";
16
19
  import {
17
20
  cartesianGridDefaults,
18
21
  chartColorVar,
@@ -27,6 +30,7 @@ import {
27
30
  import { Area, AreaChart } from "recharts";
28
31
 
29
32
  // src/charts/cartesian.tsx
33
+ import { useContext } from "react";
30
34
  import { Brush, CartesianGrid, Legend, ReferenceLine, Tooltip, XAxis, YAxis } from "recharts";
31
35
  import { jsx } from "react/jsx-runtime";
32
36
  function stackIdOf(series, stacked) {
@@ -35,6 +39,11 @@ function stackIdOf(series, stacked) {
35
39
  function valueFormatOf(spec, stacked) {
36
40
  return stacked === "percent" ? "percent" : spec.yAxis?.format ?? "number";
37
41
  }
42
+ function useNarrowFrame() {
43
+ return useContext(ChartContext)?.narrow ?? false;
44
+ }
45
+ var narrowXAxisProps = { interval: "preserveStartEnd", minTickGap: 16 };
46
+ var narrowYAxisProps = { width: "auto" };
38
47
  function renderXAxis(xKey, spec, formats, options) {
39
48
  const axis = spec.xAxis ?? {};
40
49
  const numeric = options?.numeric || axis.scale !== void 0 && axis.scale !== "band";
@@ -43,6 +52,7 @@ function renderXAxis(xKey, spec, formats, options) {
43
52
  {
44
53
  dataKey: xKey,
45
54
  ...xAxisDefaults,
55
+ ...options?.narrow ? narrowXAxisProps : {},
46
56
  type: numeric ? "number" : "category",
47
57
  ...axis.scale === "log" ? { scale: "log", domain: ["auto", "auto"] } : {},
48
58
  ...axis.scale === "time" ? { domain: ["dataMin", "dataMax"] } : {},
@@ -54,14 +64,16 @@ function renderXAxis(xKey, spec, formats, options) {
54
64
  "x"
55
65
  );
56
66
  }
57
- function renderYAxes(spec, formats, valueFormat, hasSecondary) {
67
+ function renderYAxes(spec, formats, valueFormat, hasSecondary, options) {
58
68
  const y = spec.yAxis ?? {};
69
+ const narrowProps = options?.narrow ? narrowYAxisProps : {};
59
70
  const axes = [
60
71
  /* @__PURE__ */ jsx(
61
72
  YAxis,
62
73
  {
63
74
  yAxisId: "primary",
64
75
  ...yAxisDefaults,
76
+ ...narrowProps,
65
77
  hide: y.hidden,
66
78
  domain: y.domain,
67
79
  tickCount: y.tickCount,
@@ -80,6 +92,7 @@ function renderYAxes(spec, formats, valueFormat, hasSecondary) {
80
92
  yAxisId: "secondary",
81
93
  orientation: "right",
82
94
  ...yAxisDefaults,
95
+ ...narrowProps,
83
96
  hide: secondary.hidden,
84
97
  domain: secondary.domain,
85
98
  tickCount: secondary.tickCount,
@@ -187,6 +200,7 @@ function AreaChartRenderer({
187
200
  animate
188
201
  }) {
189
202
  warnOnSpecIssues(spec);
203
+ const narrow = useNarrowFrame();
190
204
  const hasSecondary = spec.series.some((s) => s.axis === "secondary");
191
205
  const valueFormat = valueFormatOf(spec, spec.stacked);
192
206
  return /* @__PURE__ */ jsxs(
@@ -200,8 +214,8 @@ function AreaChartRenderer({
200
214
  margin: { top: 8, bottom: 4 },
201
215
  children: [
202
216
  renderGrid(spec),
203
- renderXAxis(spec.xKey, spec, formats),
204
- renderYAxes(spec, formats, valueFormat, hasSecondary),
217
+ renderXAxis(spec.xKey, spec, formats, { narrow }),
218
+ renderYAxes(spec, formats, valueFormat, hasSecondary, { narrow }),
205
219
  renderTooltip(spec, valueFormat, tooltipCrosshairDefaults),
206
220
  renderLegend(spec, spec.series.length),
207
221
  renderReferenceLines(spec),
@@ -244,6 +258,7 @@ function BarChartRenderer({
244
258
  animate
245
259
  }) {
246
260
  warnOnSpecIssues(spec);
261
+ const narrow = useNarrowFrame();
247
262
  const horizontal = spec.orientation === "horizontal";
248
263
  const hasSecondary = !horizontal && spec.series.some((s) => s.axis === "secondary");
249
264
  const valueFormat = valueFormatOf(spec, spec.stacked);
@@ -283,6 +298,7 @@ function BarChartRenderer({
283
298
  {
284
299
  type: "number",
285
300
  ...xAxisDefaults,
301
+ ...narrow ? narrowXAxisProps : {},
286
302
  hide: spec.yAxis?.hidden,
287
303
  domain: spec.yAxis?.domain,
288
304
  tickFormatter: formats.resolve(spec.yAxis?.format, valueFormat)
@@ -318,8 +334,8 @@ function BarChartRenderer({
318
334
  margin: { top: 8, bottom: 4 },
319
335
  children: [
320
336
  renderGrid(spec),
321
- renderXAxis(spec.xKey, spec, formats),
322
- renderYAxes(spec, formats, valueFormat, hasSecondary),
337
+ renderXAxis(spec.xKey, spec, formats, { narrow }),
338
+ renderYAxes(spec, formats, valueFormat, hasSecondary, { narrow }),
323
339
  renderTooltip(spec, valueFormat, tooltipCursorDefaults),
324
340
  renderLegend(spec, spec.series.length),
325
341
  renderReferenceLines(spec),
@@ -381,6 +397,7 @@ function ComposedChartRenderer({
381
397
  animate
382
398
  }) {
383
399
  warnOnSpecIssues(spec);
400
+ const narrow = useNarrowFrame();
384
401
  const hasSecondary = spec.series.some((s) => s.axis === "secondary");
385
402
  const valueFormat = valueFormatOf(spec);
386
403
  return /* @__PURE__ */ jsxs3(
@@ -393,8 +410,8 @@ function ComposedChartRenderer({
393
410
  margin: { top: 8, bottom: 4 },
394
411
  children: [
395
412
  renderGrid(spec),
396
- renderXAxis(spec.xKey, spec, formats),
397
- renderYAxes(spec, formats, valueFormat, hasSecondary),
413
+ renderXAxis(spec.xKey, spec, formats, { narrow }),
414
+ renderYAxes(spec, formats, valueFormat, hasSecondary, { narrow }),
398
415
  renderTooltip(spec, valueFormat, tooltipCursorDefaults),
399
416
  renderLegend(spec, spec.series.length),
400
417
  renderReferenceLines(spec),
@@ -465,6 +482,7 @@ function LineChartRenderer({
465
482
  animate
466
483
  }) {
467
484
  warnOnSpecIssues(spec);
485
+ const narrow = useNarrowFrame();
468
486
  const hasSecondary = spec.series.some((s) => s.axis === "secondary");
469
487
  const valueFormat = valueFormatOf(spec);
470
488
  return /* @__PURE__ */ jsxs5(
@@ -477,8 +495,8 @@ function LineChartRenderer({
477
495
  margin: { top: 8, bottom: 4 },
478
496
  children: [
479
497
  renderGrid(spec),
480
- renderXAxis(spec.xKey, spec, formats),
481
- renderYAxes(spec, formats, valueFormat, hasSecondary),
498
+ renderXAxis(spec.xKey, spec, formats, { narrow }),
499
+ renderYAxes(spec, formats, valueFormat, hasSecondary, { narrow }),
482
500
  renderTooltip(spec, valueFormat, tooltipCrosshairDefaults),
483
501
  renderLegend(spec, spec.series.length),
484
502
  renderReferenceLines(spec),
@@ -667,11 +685,12 @@ function ScatterChartRenderer({
667
685
  );
668
686
  }
669
687
  const active = activeCategoryValues(markInteraction);
688
+ const narrow = useNarrowFrame();
670
689
  const valueFormat = spec.yAxis?.format ?? "number";
671
690
  return /* @__PURE__ */ jsxs9(ScatterChart, { width, height, margin: { top: 8, bottom: 4 }, children: [
672
691
  renderGrid(spec),
673
- renderXAxis(spec.xKey, spec, formats, { numeric: true }),
674
- renderYAxes(spec, formats, valueFormat, false),
692
+ renderXAxis(spec.xKey, spec, formats, { numeric: true, narrow }),
693
+ renderYAxes(spec, formats, valueFormat, false, { narrow }),
675
694
  spec.sizeKey && /* @__PURE__ */ jsx10(ZAxis, { dataKey: spec.sizeKey, range: spec.sizeRange ?? [64, 400] }),
676
695
  renderTooltip(spec, valueFormat, tooltipCrosshairDefaults),
677
696
  spec.groupKey ? renderLegend(spec, groups.length) : null,
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  defaultChartRegistry
3
- } from "./chunk-YMPZODMC.js";
3
+ } from "./chunk-J2KWQJGC.js";
4
4
  import {
5
5
  prefersReducedMotion
6
6
  } from "./chunk-HNJKYOPE.js";
7
7
  import {
8
8
  chart_frame_default
9
- } from "./chunk-G4FXJJ66.js";
9
+ } from "./chunk-SID6O4PY.js";
10
10
  import {
11
11
  createFormatters
12
12
  } from "./chunk-7WABBHMT.js";
@@ -0,0 +1,44 @@
1
+ import {
2
+ chart_default
3
+ } from "./chunk-NDBDHQME.js";
4
+
5
+ // src/forecast-chart.tsx
6
+ import { jsx } from "react/jsx-runtime";
7
+ function ForecastChart({
8
+ rows,
9
+ xKey,
10
+ actualKey,
11
+ forecastKey,
12
+ lowerKey,
13
+ upperKey,
14
+ title,
15
+ description,
16
+ format,
17
+ actualLabel = "Actual",
18
+ forecastLabel = "Forecast",
19
+ intervalLabel = "Forecast interval",
20
+ ...chartProps
21
+ }) {
22
+ const spec = {
23
+ version: 1,
24
+ type: "composed",
25
+ title,
26
+ description,
27
+ xKey,
28
+ xAxis: { scale: "time" },
29
+ yAxis: { format },
30
+ series: [
31
+ ...lowerKey ? [{ field: lowerKey, label: `${intervalLabel} lower`, mark: "area", paletteSlot: 3, format }] : [],
32
+ ...upperKey ? [{ field: upperKey, label: `${intervalLabel} upper`, mark: "area", paletteSlot: 3, format }] : [],
33
+ { field: actualKey, label: actualLabel, mark: "line", paletteSlot: 1, format },
34
+ { field: forecastKey, label: forecastLabel, mark: "line", paletteSlot: 2, format }
35
+ ],
36
+ tooltip: true,
37
+ legend: true
38
+ };
39
+ return /* @__PURE__ */ jsx(chart_default, { ...chartProps, rows, spec });
40
+ }
41
+
42
+ export {
43
+ ForecastChart
44
+ };
@@ -24,8 +24,8 @@ import {
24
24
  useState
25
25
  } from "react";
26
26
  import { ResponsiveContainer } from "recharts";
27
- import Alert from "@zuilib/components/alert";
28
- import { cn } from "@zuilib/components/lib/cn";
27
+ import Alert from "@zuilib/primitives/alert";
28
+ import { cn } from "@zuilib/primitives/lib/cn";
29
29
 
30
30
  // src/legend-toggle.ts
31
31
  function legendToggle(allFields, hidden, field, options) {
@@ -39,6 +39,8 @@ function legendToggle(allFields, hidden, field, options) {
39
39
 
40
40
  // src/chart-frame.tsx
41
41
  import { jsx, jsxs } from "react/jsx-runtime";
42
+ var NARROW_FRAME_WIDTH = 384;
43
+ var MIN_RESPONSIVE_HEIGHT = 200;
42
44
  var ChartFrame = forwardRef(
43
45
  ({
44
46
  config,
@@ -77,9 +79,17 @@ var ChartFrame = forwardRef(
77
79
  },
78
80
  [allFields, hiddenSeries, innerHidden, onHiddenSeriesChange]
79
81
  );
82
+ const [measuredWidth, setMeasuredWidth] = useState(null);
83
+ const onResize = useCallback((nextWidth) => {
84
+ setMeasuredWidth(nextWidth > 0 ? Math.round(nextWidth) : null);
85
+ }, []);
86
+ const fixedSize = typeof width === "number" && typeof height === "number";
87
+ const frameWidth = fixedSize ? width : measuredWidth;
88
+ const narrow = frameWidth !== null && frameWidth < NARROW_FRAME_WIDTH;
89
+ const responsiveAspect = measuredWidth !== null && measuredWidth / aspect < MIN_RESPONSIVE_HEIGHT ? measuredWidth / MIN_RESPONSIVE_HEIGHT : aspect;
80
90
  const context = useMemo(
81
- () => ({ config, formats, labels, hiddenSeries: hidden, onLegendClick, categoryField, categories }),
82
- [config, formats, labels, hidden, onLegendClick, categoryField, categories]
91
+ () => ({ config, formats, labels, hiddenSeries: hidden, onLegendClick, categoryField, categories, narrow }),
92
+ [config, formats, labels, hidden, onLegendClick, categoryField, categories, narrow]
83
93
  );
84
94
  const colorStyle = useMemo(() => {
85
95
  const vars = {};
@@ -93,7 +103,6 @@ var ChartFrame = forwardRef(
93
103
  () => Object.entries(config).filter((entry) => !!entry[1].theme && !entry[1].color),
94
104
  [config]
95
105
  );
96
- const fixedSize = typeof width === "number" && typeof height === "number";
97
106
  let body;
98
107
  if (loading) {
99
108
  body = /* @__PURE__ */ jsx(
@@ -124,7 +133,15 @@ var ChartFrame = forwardRef(
124
133
  if (fixedSize && isValidElement(chart)) {
125
134
  body = cloneElement(chart, { width, height });
126
135
  } else {
127
- body = /* @__PURE__ */ jsx(ResponsiveContainer, { width: "100%", ...height !== void 0 ? { height } : { aspect }, children: chart });
136
+ body = /* @__PURE__ */ jsx(
137
+ ResponsiveContainer,
138
+ {
139
+ width: "100%",
140
+ onResize,
141
+ ...height !== void 0 ? { height } : { aspect: responsiveAspect },
142
+ children: chart
143
+ }
144
+ );
128
145
  }
129
146
  }
130
147
  return /* @__PURE__ */ jsxs(
@@ -136,7 +153,11 @@ var ChartFrame = forwardRef(
136
153
  role: "figure",
137
154
  "aria-label": ariaLabel,
138
155
  className: cn(
139
- "text-foreground text-xs [&_.recharts-surface]:outline-none [&_.recharts-sector]:outline-none",
156
+ /* `@container` (inline-size containment) is load-bearing beyond
157
+ container queries: the rendered svg carries a pixel width, and
158
+ without containment a flex/grid parent's `min-width: auto`
159
+ would grow to it and never let the chart shrink again. */
160
+ "@container min-w-0 text-foreground text-xs [&_.recharts-surface]:outline-none [&_.recharts-sector]:outline-none",
140
161
  fixedSize ? "" : "w-full",
141
162
  className
142
163
  ),
@@ -0,0 +1,28 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ChartProps } from './chart.js';
3
+ import { d as ChartRow, F as FormatToken } from './chart-spec-CH0tAkLG.js';
4
+ import 'react';
5
+ import './chart-frame-B9fxUxGG.js';
6
+ import './format.js';
7
+ import './registry.js';
8
+ import './authoring.js';
9
+ import './cross-filter.js';
10
+
11
+ interface ForecastChartProps extends Omit<ChartProps, 'spec' | 'rows'> {
12
+ rows: ChartRow[];
13
+ xKey: string;
14
+ actualKey: string;
15
+ forecastKey: string;
16
+ lowerKey?: string;
17
+ upperKey?: string;
18
+ title?: string;
19
+ description?: string;
20
+ format?: FormatToken;
21
+ actualLabel?: string;
22
+ forecastLabel?: string;
23
+ intervalLabel?: string;
24
+ }
25
+ /** Opinionated actual-versus-forecast chart with optional confidence bounds. */
26
+ declare function ForecastChart({ rows, xKey, actualKey, forecastKey, lowerKey, upperKey, title, description, format, actualLabel, forecastLabel, intervalLabel, ...chartProps }: ForecastChartProps): react_jsx_runtime.JSX.Element;
27
+
28
+ export { ForecastChart, type ForecastChartProps, ForecastChart as default };
@@ -0,0 +1,20 @@
1
+ "use client";
2
+ import {
3
+ ForecastChart
4
+ } from "./chunk-PPVZ3BYP.js";
5
+ import "./chunk-NDBDHQME.js";
6
+ import "./chunk-J2KWQJGC.js";
7
+ import "./chunk-HNJKYOPE.js";
8
+ import "./chunk-SID6O4PY.js";
9
+ import "./chunk-7WABBHMT.js";
10
+ import "./chunk-3VGLTPGL.js";
11
+ import "./chunk-FDLNPZV6.js";
12
+ import "./chunk-4FWNWLEJ.js";
13
+ import "./chunk-WVWGHGZE.js";
14
+ import "./chunk-UN3XPBZ5.js";
15
+ import "./chunk-IPOBQIYU.js";
16
+ import "./chunk-7WJ26NHR.js";
17
+ export {
18
+ ForecastChart,
19
+ ForecastChart as default
20
+ };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Chart, ChartProps, CustomChartProps, configFromSpec, crossFilterFieldOf, Chart as default } from './chart.js';
2
+ export { ForecastChart, ForecastChartProps } from './forecast-chart.js';
2
3
  export { AnyChartSpec, ChartRegistry, ChartTypeDef, CustomChartSpec, chartSpecJsonSchema, chartTypeSchema, createChartRegistry, defaultChartRegistry, defaultChartTypes, defineChartType } from './registry.js';
3
4
  export { C as ChartConfig, a as ChartFrame, b as ChartFrameProps, c as ChartLabels, d as ChartSeriesConfig, e as defaultChartLabels, f as fillLabel } from './chart-frame-B9fxUxGG.js';
4
5
  export { ChartTooltipPanel, ChartTooltipPanelProps } from './chart-tooltip.js';
@@ -9,5 +10,5 @@ export { ColumnFilter, applyFilters, fromColumnFilters, toColumnFilters, toHighl
9
10
  export { FormatterFactory, FormatterFn, Formatters, createFormatters } from './format.js';
10
11
  export { A as AreaChartSpec, a as AxisSpec, B as BarChartSpec, C as CartesianFrameSpec, b as CartesianSpecCommon, c as ChartMarkEvent, d as ChartRow, e as ChartSpec, f as ChartSpecCommon, g as ComposedChartSpec, F as FormatToken, h as FunnelChartSpec, L as LineChartSpec, P as PieChartSpec, R as RadarChartSpec, i as RadialBarChartSpec, j as ReferenceLineSpec, S as ScatterChartSpec, k as SeriesSpec, T as TreemapChartSpec } from './chart-spec-CH0tAkLG.js';
11
12
  import 'react';
12
- import './authoring.js';
13
13
  import 'react/jsx-runtime';
14
+ import './authoring.js';
package/dist/index.js CHANGED
@@ -5,11 +5,14 @@ import {
5
5
  toColumnFilters,
6
6
  toHighlight
7
7
  } from "./chunk-D7K23UFP.js";
8
+ import {
9
+ ForecastChart
10
+ } from "./chunk-PPVZ3BYP.js";
8
11
  import {
9
12
  chart_default,
10
13
  configFromSpec,
11
14
  crossFilterFieldOf
12
- } from "./chunk-BFTWWI46.js";
15
+ } from "./chunk-NDBDHQME.js";
13
16
  import {
14
17
  chartSpecJsonSchema,
15
18
  chartTypeSchema,
@@ -17,21 +20,21 @@ import {
17
20
  defaultChartRegistry,
18
21
  defaultChartTypes,
19
22
  defineChartType
20
- } from "./chunk-YMPZODMC.js";
23
+ } from "./chunk-J2KWQJGC.js";
21
24
  import "./chunk-HNJKYOPE.js";
22
25
  import {
23
26
  chart_frame_default
24
- } from "./chunk-G4FXJJ66.js";
27
+ } from "./chunk-SID6O4PY.js";
25
28
  import {
26
29
  createFormatters
27
30
  } from "./chunk-7WABBHMT.js";
28
31
  import "./chunk-3VGLTPGL.js";
29
32
  import {
30
33
  ChartTooltipPanel
31
- } from "./chunk-PV27RZSV.js";
34
+ } from "./chunk-FDLNPZV6.js";
32
35
  import {
33
36
  ChartLegendList
34
- } from "./chunk-SHQZRND7.js";
37
+ } from "./chunk-4FWNWLEJ.js";
35
38
  import {
36
39
  cross_filter_default,
37
40
  defaultCrossFilterState,
@@ -63,6 +66,7 @@ export {
63
66
  ChartTooltipPanel,
64
67
  cross_filter_default as CrossFilterProvider,
65
68
  DIMMED_OPACITY,
69
+ ForecastChart,
66
70
  applyFilters,
67
71
  cartesianGridDefaults,
68
72
  chartColorVar,
package/dist/registry.js CHANGED
@@ -6,11 +6,11 @@ import {
6
6
  defaultChartRegistry,
7
7
  defaultChartTypes,
8
8
  defineChartType
9
- } from "./chunk-YMPZODMC.js";
9
+ } from "./chunk-J2KWQJGC.js";
10
10
  import "./chunk-HNJKYOPE.js";
11
11
  import "./chunk-3VGLTPGL.js";
12
- import "./chunk-PV27RZSV.js";
13
- import "./chunk-SHQZRND7.js";
12
+ import "./chunk-FDLNPZV6.js";
13
+ import "./chunk-4FWNWLEJ.js";
14
14
  import "./chunk-WVWGHGZE.js";
15
15
  import "./chunk-UN3XPBZ5.js";
16
16
  import "./chunk-IPOBQIYU.js";
@@ -0,0 +1,2 @@
1
+ /** Tailwind v4 source registration for @zuilib/charts. */
2
+ @source "./**/*.js";
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "@zuilib/charts",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "ZUI — enterprise analytics charts on Recharts: a serializable chart spec, styled primitives, and cross-filtering that links charts to data tables",
5
5
  "type": "module",
6
- "sideEffects": false,
6
+ "sideEffects": [
7
+ "**/*.css"
8
+ ],
7
9
  "files": [
8
10
  "dist"
9
11
  ],
@@ -16,6 +18,10 @@
16
18
  "types": "./dist/chart.d.ts",
17
19
  "import": "./dist/chart.js"
18
20
  },
21
+ "./forecast-chart": {
22
+ "types": "./dist/forecast-chart.d.ts",
23
+ "import": "./dist/forecast-chart.js"
24
+ },
19
25
  "./registry": {
20
26
  "types": "./dist/registry.d.ts",
21
27
  "import": "./dist/registry.js"
@@ -52,16 +58,16 @@
52
58
  "types": "./dist/format.d.ts",
53
59
  "import": "./dist/format.js"
54
60
  },
61
+ "./tailwind.css": "./dist/tailwind.css",
55
62
  "./package.json": "./package.json"
56
63
  },
57
64
  "peerDependencies": {
58
65
  "react": "^18.0.0 || ^19.0.0",
59
- "react-dom": "^18.0.0 || ^19.0.0",
60
- "recharts": "^3.0.0",
61
- "@zuilib/tokens": "^0.2.0"
66
+ "react-dom": "^18.0.0 || ^19.0.0"
62
67
  },
63
68
  "dependencies": {
64
- "@zuilib/components": "^0.2.0"
69
+ "recharts": "^3.0.0",
70
+ "@zuilib/primitives": "^0.3.0"
65
71
  },
66
72
  "devDependencies": {
67
73
  "@testing-library/dom": "^10.4.1",
@@ -75,10 +81,8 @@
75
81
  "jsdom": "^30.0.1",
76
82
  "react": "^18.2.0",
77
83
  "react-dom": "^18.2.0",
78
- "recharts": "^3.0.0",
79
84
  "typescript": "^6.0.2",
80
- "vitest": "^4.1.11",
81
- "@zuilib/tokens": "^0.2.0"
85
+ "vitest": "^4.1.11"
82
86
  },
83
87
  "keywords": [
84
88
  "charts",
@@ -1,169 +0,0 @@
1
- import {
2
- createFormatters
3
- } from "./chunk-7WABBHMT.js";
4
- import {
5
- defaultChartLabels
6
- } from "./chunk-UN3XPBZ5.js";
7
- import {
8
- ChartContext
9
- } from "./chunk-IPOBQIYU.js";
10
- import {
11
- CHART_COLOR_COUNT,
12
- seriesColorProperty
13
- } from "./chunk-7WJ26NHR.js";
14
-
15
- // src/chart-frame.tsx
16
- import {
17
- Children,
18
- cloneElement,
19
- forwardRef,
20
- isValidElement,
21
- useCallback,
22
- useId,
23
- useMemo,
24
- useState
25
- } from "react";
26
- import { ResponsiveContainer } from "recharts";
27
- import Alert from "@zuilib/components/alert";
28
- import { cn } from "@zuilib/components/lib/cn";
29
-
30
- // src/legend-toggle.ts
31
- function legendToggle(allFields, hidden, field, options) {
32
- if (options?.additive) {
33
- return hidden.includes(field) ? hidden.filter((f) => f !== field) : [...hidden, field];
34
- }
35
- const visible = allFields.filter((f) => !hidden.includes(f));
36
- const isolated = visible.length === 1 && visible[0] === field;
37
- return isolated ? [] : allFields.filter((f) => f !== field);
38
- }
39
-
40
- // src/chart-frame.tsx
41
- import { jsx, jsxs } from "react/jsx-runtime";
42
- var ChartFrame = forwardRef(
43
- ({
44
- config,
45
- children,
46
- width,
47
- height,
48
- aspect = 16 / 9,
49
- loading = false,
50
- error,
51
- emptyState,
52
- empty = false,
53
- ariaLabel,
54
- labels: labelOverrides,
55
- formats: customFormats,
56
- locale,
57
- hiddenSeries,
58
- defaultHiddenSeries,
59
- onHiddenSeriesChange,
60
- categoryField,
61
- categories,
62
- className,
63
- style,
64
- ...props
65
- }, ref) => {
66
- const chartId = useId().replace(/:/g, "");
67
- const labels = useMemo(() => ({ ...defaultChartLabels, ...labelOverrides }), [labelOverrides]);
68
- const formats = useMemo(() => createFormatters(locale, customFormats), [locale, customFormats]);
69
- const [innerHidden, setInnerHidden] = useState(defaultHiddenSeries ?? []);
70
- const hidden = hiddenSeries ?? innerHidden;
71
- const allFields = useMemo(() => Object.keys(config), [config]);
72
- const onLegendClick = useCallback(
73
- (field, options) => {
74
- const next = legendToggle(allFields, hiddenSeries ?? innerHidden, field, options);
75
- if (hiddenSeries === void 0) setInnerHidden(next);
76
- onHiddenSeriesChange?.(next);
77
- },
78
- [allFields, hiddenSeries, innerHidden, onHiddenSeriesChange]
79
- );
80
- const context = useMemo(
81
- () => ({ config, formats, labels, hiddenSeries: hidden, onLegendClick, categoryField, categories }),
82
- [config, formats, labels, hidden, onLegendClick, categoryField, categories]
83
- );
84
- const colorStyle = useMemo(() => {
85
- const vars = {};
86
- Object.entries(config).forEach(([field, series], index) => {
87
- const slot = Math.min(index + 1, CHART_COLOR_COUNT);
88
- vars[seriesColorProperty(field)] = series.color ?? (series.theme ? series.theme.light : `var(--chart-${slot})`);
89
- });
90
- return vars;
91
- }, [config]);
92
- const themed = useMemo(
93
- () => Object.entries(config).filter((entry) => !!entry[1].theme && !entry[1].color),
94
- [config]
95
- );
96
- const fixedSize = typeof width === "number" && typeof height === "number";
97
- let body;
98
- if (loading) {
99
- body = /* @__PURE__ */ jsx(
100
- "div",
101
- {
102
- "data-slot": "chart-skeleton",
103
- role: "status",
104
- "aria-label": labels.loading,
105
- className: "bg-muted h-full min-h-32 w-full animate-pulse rounded-md motion-reduce:animate-none"
106
- }
107
- );
108
- } else if (error !== void 0 && error !== null && error !== false) {
109
- body = /* @__PURE__ */ jsxs(Alert, { variant: "destructive", "data-slot": "chart-error", children: [
110
- /* @__PURE__ */ jsx(Alert.Title, { children: labels.chartError }),
111
- typeof error === "boolean" ? null : /* @__PURE__ */ jsx(Alert.Description, { children: error })
112
- ] });
113
- } else if (empty) {
114
- body = /* @__PURE__ */ jsx(
115
- "div",
116
- {
117
- "data-slot": "chart-empty",
118
- className: "text-muted-foreground flex h-full min-h-32 w-full items-center justify-center text-sm",
119
- children: emptyState ?? labels.noData
120
- }
121
- );
122
- } else {
123
- const chart = Children.only(children);
124
- if (fixedSize && isValidElement(chart)) {
125
- body = cloneElement(chart, { width, height });
126
- } else {
127
- body = /* @__PURE__ */ jsx(ResponsiveContainer, { width: "100%", ...height !== void 0 ? { height } : { aspect }, children: chart });
128
- }
129
- }
130
- return /* @__PURE__ */ jsxs(
131
- "div",
132
- {
133
- ref,
134
- "data-slot": "chart",
135
- "data-chart": chartId,
136
- role: "figure",
137
- "aria-label": ariaLabel,
138
- className: cn(
139
- "text-foreground text-xs [&_.recharts-surface]:outline-none [&_.recharts-sector]:outline-none",
140
- fixedSize ? "" : "w-full",
141
- className
142
- ),
143
- style: { ...colorStyle, ...style },
144
- ...props,
145
- children: [
146
- themed.length > 0 && /* @__PURE__ */ jsx(
147
- "style",
148
- {
149
- dangerouslySetInnerHTML: {
150
- __html: [
151
- `[data-chart="${chartId}"]{${themed.map(([field, s]) => `${seriesColorProperty(field)}:${s.theme.light};`).join("")}}`,
152
- `.dark [data-chart="${chartId}"]{${themed.map(([field, s]) => `${seriesColorProperty(field)}:${s.theme.dark};`).join("")}}`
153
- ].join("\n")
154
- }
155
- }
156
- ),
157
- /* @__PURE__ */ jsx(ChartContext.Provider, { value: context, children: body })
158
- ]
159
- }
160
- );
161
- }
162
- );
163
- ChartFrame.displayName = "ChartFrame";
164
- var chart_frame_default = ChartFrame;
165
-
166
- export {
167
- ChartFrame,
168
- chart_frame_default
169
- };
@@ -1,148 +0,0 @@
1
- import {
2
- defaultChartRegistry
3
- } from "./chunk-YMPZODMC.js";
4
- import {
5
- prefersReducedMotion
6
- } from "./chunk-HNJKYOPE.js";
7
- import {
8
- chart_frame_default
9
- } from "./chunk-6QTIAMA3.js";
10
- import {
11
- createFormatters
12
- } from "./chunk-7WABBHMT.js";
13
- import {
14
- warnOnce
15
- } from "./chunk-3VGLTPGL.js";
16
- import {
17
- useOptionalCrossFilter
18
- } from "./chunk-WVWGHGZE.js";
19
- import {
20
- chartColorVar
21
- } from "./chunk-7WJ26NHR.js";
22
-
23
- // src/chart.tsx
24
- import { useMemo, useState } from "react";
25
- import { jsx } from "react/jsx-runtime";
26
- function configFromSpec(spec, registry = defaultChartRegistry) {
27
- const fromDef = registry.types.get(spec.type)?.configFrom?.(spec);
28
- if (fromDef) return fromDef;
29
- if ("series" in spec && Array.isArray(spec.series)) {
30
- return Object.fromEntries(
31
- spec.series.map((series) => [
32
- series.field,
33
- {
34
- label: series.label ?? series.field,
35
- color: series.paletteSlot ? chartColorVar(series.paletteSlot) : void 0,
36
- format: series.format
37
- }
38
- ])
39
- );
40
- }
41
- return {};
42
- }
43
- function crossFilterFieldOf(spec, registry = defaultChartRegistry) {
44
- const field = registry.types.get(spec.type)?.crossFilterField?.(spec);
45
- if (field !== void 0) return field;
46
- const xKey = spec.xKey;
47
- return typeof xKey === "string" ? xKey : "";
48
- }
49
- function categoriesOf(spec, rows, registry) {
50
- const field = categoryFieldOf(spec, registry);
51
- if (!field) return void 0;
52
- const values = [];
53
- for (const row of rows) {
54
- const value = row[field];
55
- if ((typeof value === "string" || typeof value === "number") && !values.includes(value)) values.push(value);
56
- }
57
- return values.map((value, index) => ({ value, color: chartColorVar(index + 1) }));
58
- }
59
- function categoryFieldOf(spec, registry) {
60
- return registry.types.get(spec.type)?.categoryField?.(spec);
61
- }
62
- function renderSpec(props, registry) {
63
- const def = registry.types.get(props.spec.type);
64
- if (!def) {
65
- warnOnce(`type-${props.spec.type}`, `@zuilib/charts: unknown chart type "${props.spec.type}"`);
66
- return null;
67
- }
68
- return /* @__PURE__ */ jsx(def.render, { ...props });
69
- }
70
- function Chart({
71
- spec,
72
- rows,
73
- registry,
74
- formats,
75
- locale,
76
- className,
77
- width,
78
- height,
79
- aspect,
80
- loading,
81
- error,
82
- emptyState,
83
- ariaLabel,
84
- hiddenSeries,
85
- defaultHiddenSeries,
86
- onHiddenSeriesChange,
87
- onMarkClick,
88
- labels,
89
- animate
90
- }) {
91
- const chartRegistry = registry ?? defaultChartRegistry;
92
- const crossFilter = useOptionalCrossFilter();
93
- const formatters = useMemo(() => createFormatters(locale, formats), [locale, formats]);
94
- const config = useMemo(() => configFromSpec(spec, chartRegistry), [spec, chartRegistry]);
95
- const [innerHidden, setInnerHidden] = useState(defaultHiddenSeries ?? []);
96
- const hidden = hiddenSeries ?? innerHidden;
97
- const onHiddenChange = (next) => {
98
- if (hiddenSeries === void 0) setInnerHidden(next);
99
- onHiddenSeriesChange?.(next);
100
- };
101
- const markInteraction = {
102
- field: crossFilterFieldOf(spec, chartRegistry),
103
- crossFilter,
104
- interaction: spec.interaction ?? "filter",
105
- onMarkClick
106
- };
107
- return /* @__PURE__ */ jsx(
108
- chart_frame_default,
109
- {
110
- config,
111
- className,
112
- width,
113
- height,
114
- aspect,
115
- loading,
116
- error,
117
- emptyState,
118
- empty: rows.length === 0,
119
- ariaLabel: ariaLabel ?? spec.title,
120
- labels,
121
- formats,
122
- locale,
123
- hiddenSeries: hidden,
124
- onHiddenSeriesChange: onHiddenChange,
125
- categoryField: categoryFieldOf(spec, chartRegistry),
126
- categories: useMemo(() => categoriesOf(spec, rows, chartRegistry), [spec, rows, chartRegistry]),
127
- children: renderSpec(
128
- {
129
- spec,
130
- rows,
131
- formats: formatters,
132
- hiddenSeries: hidden,
133
- markInteraction,
134
- animate: animate ?? !prefersReducedMotion()
135
- },
136
- chartRegistry
137
- )
138
- }
139
- );
140
- }
141
- var chart_default = Chart;
142
-
143
- export {
144
- configFromSpec,
145
- crossFilterFieldOf,
146
- Chart,
147
- chart_default
148
- };