@voila.dev/chart 4.0.107 → 4.0.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/canvas/paint-scene.d.ts.map +1 -1
  2. package/dist/canvas/paint-scene.js +31 -1
  3. package/dist/canvas/paint-scene.js.map +2 -2
  4. package/dist/core/marks/area.d.ts.map +1 -1
  5. package/dist/core/marks/area.js +3 -2
  6. package/dist/core/marks/area.js.map +2 -2
  7. package/dist/core/marks/bar.d.ts +5 -0
  8. package/dist/core/marks/bar.d.ts.map +1 -1
  9. package/dist/core/marks/bar.js +19 -9
  10. package/dist/core/marks/bar.js.map +2 -2
  11. package/dist/core/marks/cell.d.ts.map +1 -1
  12. package/dist/core/marks/cell.js +2 -1
  13. package/dist/core/marks/cell.js.map +2 -2
  14. package/dist/core/marks/dot.d.ts.map +1 -1
  15. package/dist/core/marks/dot.js +2 -1
  16. package/dist/core/marks/dot.js.map +2 -2
  17. package/dist/core/marks/funnel.d.ts.map +1 -1
  18. package/dist/core/marks/funnel.js +3 -2
  19. package/dist/core/marks/funnel.js.map +2 -2
  20. package/dist/core/marks/line.d.ts.map +1 -1
  21. package/dist/core/marks/line.js +2 -1
  22. package/dist/core/marks/line.js.map +2 -2
  23. package/dist/core/marks/polar.d.ts.map +1 -1
  24. package/dist/core/marks/polar.js +5 -4
  25. package/dist/core/marks/polar.js.map +2 -2
  26. package/dist/core/marks/rect.d.ts.map +1 -1
  27. package/dist/core/marks/rect.js +2 -1
  28. package/dist/core/marks/rect.js.map +2 -2
  29. package/dist/core/marks/shared.d.ts +8 -2
  30. package/dist/core/marks/shared.d.ts.map +1 -1
  31. package/dist/core/marks/shared.js +4 -0
  32. package/dist/core/marks/shared.js.map +2 -2
  33. package/dist/core/marks/stats/dodge.d.ts.map +1 -1
  34. package/dist/core/marks/stats/dodge.js +2 -1
  35. package/dist/core/marks/stats/dodge.js.map +2 -2
  36. package/dist/core/marks/stats/histogram.d.ts.map +1 -1
  37. package/dist/core/marks/stats/histogram.js +2 -1
  38. package/dist/core/marks/stats/histogram.js.map +2 -2
  39. package/dist/core/marks/stats/waffle.d.ts.map +1 -1
  40. package/dist/core/marks/stats/waffle.js +3 -2
  41. package/dist/core/marks/stats/waffle.js.map +2 -2
  42. package/dist/core/types.d.ts +2 -0
  43. package/dist/core/types.d.ts.map +1 -1
  44. package/dist/react/chart-placeholders.d.ts +13 -0
  45. package/dist/react/chart-placeholders.d.ts.map +1 -0
  46. package/dist/react/chart-placeholders.js +77 -0
  47. package/dist/react/chart-placeholders.js.map +7 -0
  48. package/dist/react/index.d.ts +1 -0
  49. package/dist/react/index.d.ts.map +1 -1
  50. package/dist/react/index.js +3 -0
  51. package/dist/react/index.js.map +2 -2
  52. package/dist/react/svg-scene.d.ts +1 -1
  53. package/dist/react/svg-scene.d.ts.map +1 -1
  54. package/dist/react/svg-scene.js +53 -10
  55. package/dist/react/svg-scene.js.map +2 -2
  56. package/package.json +1 -1
  57. package/src/canvas/paint-scene.ts +45 -1
  58. package/src/core/marks/area.ts +2 -1
  59. package/src/core/marks/bar.ts +25 -8
  60. package/src/core/marks/cell.ts +2 -1
  61. package/src/core/marks/dot.ts +2 -1
  62. package/src/core/marks/funnel.ts +2 -1
  63. package/src/core/marks/line.ts +2 -1
  64. package/src/core/marks/polar.ts +4 -3
  65. package/src/core/marks/rect.ts +2 -1
  66. package/src/core/marks/shared.ts +15 -2
  67. package/src/core/marks/stats/dodge.ts +2 -1
  68. package/src/core/marks/stats/histogram.ts +2 -1
  69. package/src/core/marks/stats/waffle.ts +2 -1
  70. package/src/core/types.ts +2 -0
  71. package/src/react/chart-placeholders.tsx +93 -0
  72. package/src/react/index.ts +1 -0
  73. package/src/react/svg-scene.tsx +72 -11
@@ -10,6 +10,7 @@ import {
10
10
  markId,
11
11
  paint,
12
12
  seriesResolver,
13
+ tips,
13
14
  } from "#/core/marks/shared.ts";
14
15
  import {
15
16
  arcPath,
@@ -187,7 +188,7 @@ export function arc<TDatum>(
187
188
  opacity: options.opacity,
188
189
  }),
189
190
  });
190
- if (options.tip === false) {
191
+ if (!tips(options.tip, data[index], index)) {
191
192
  continue;
192
193
  }
193
194
  const middle = (angle.startAngle + angle.endAngle) / 2;
@@ -307,7 +308,7 @@ export function radialBar<TDatum>(
307
308
  d: arcPath(shape),
308
309
  paint: paint({ fill: series.color, opacity: options.opacity }),
309
310
  });
310
- if (options.tip === false) {
311
+ if (!tips(options.tip, data[index], index)) {
311
312
  continue;
312
313
  }
313
314
  const anchor = polarToCartesian(
@@ -512,7 +513,7 @@ export function radar<TDatum>(
512
513
  };
513
514
  polygon.vertices[spoke] = vertex;
514
515
  polygons.set(series.key, polygon);
515
- if (options.tip !== false) {
516
+ if (tips(options.tip, data[index], index)) {
516
517
  points.push({
517
518
  key: `${id}:${index}`,
518
519
  markId: id,
@@ -10,6 +10,7 @@ import {
10
10
  markId,
11
11
  paint,
12
12
  seriesResolver,
13
+ tips,
13
14
  valueOn,
14
15
  } from "#/core/marks/shared.ts";
15
16
  import { stackSegments } from "#/core/marks/stack.ts";
@@ -131,7 +132,7 @@ export function rectY<TDatum>(
131
132
  opacity: options.opacity,
132
133
  }),
133
134
  });
134
- if (options.tip !== false) {
135
+ if (tips(options.tip, data[index], index)) {
135
136
  points.push({
136
137
  key: `${id}:${index}`,
137
138
  markId: id,
@@ -22,8 +22,12 @@ export interface ChartMarkOptions<TDatum> {
22
22
  readonly label?: string;
23
23
  /** A field or function whose distinct values split the mark into coloured series. */
24
24
  readonly color?: ChartAccessor<TDatum, ChartValue>;
25
- /** `false` keeps the mark out of focus, tooltips and the data table. */
26
- readonly tip?: boolean;
25
+ /**
26
+ * `false` keeps the mark out of focus, tooltips and the data table; a
27
+ * function decides per datum (the point where a recorded line hands over
28
+ * to its dashed projection belongs to one of the two, not both).
29
+ */
30
+ readonly tip?: boolean | ((datum: TDatum, index: number) => boolean);
27
31
  readonly opacity?: number;
28
32
  }
29
33
 
@@ -132,3 +136,12 @@ export function valueOn(
132
136
  }
133
137
  return numericValue(raw);
134
138
  }
139
+
140
+ /** Whether a datum takes part in focus, tooltips and the data table. */
141
+ export function tips<TDatum>(
142
+ tip: ChartMarkOptions<TDatum>["tip"],
143
+ datum: TDatum,
144
+ index: number,
145
+ ): boolean {
146
+ return typeof tip === "function" ? tip(datum, index) : tip !== false;
147
+ }
@@ -5,6 +5,7 @@ import {
5
5
  markId,
6
6
  paint,
7
7
  seriesResolver,
8
+ tips,
8
9
  valueOn,
9
10
  } from "#/core/marks/shared.ts";
10
11
  import type {
@@ -109,7 +110,7 @@ export function dodgeY<TDatum>(
109
110
  r,
110
111
  paint: paint({ fill: series.color, opacity: options.opacity }),
111
112
  });
112
- if (options.tip !== false) {
113
+ if (tips(options.tip, data[entry.index], entry.index)) {
113
114
  points.push({
114
115
  key: `${id}:${entry.index}`,
115
116
  markId: id,
@@ -95,7 +95,8 @@ export function histogram<TDatum>(
95
95
  return rectY(bins, {
96
96
  id: options.id,
97
97
  label: options.label,
98
- tip: options.tip,
98
+ // Bins are not the caller's data: only a plain on/off carries over.
99
+ tip: typeof options.tip === "function" ? undefined : options.tip,
99
100
  opacity: options.opacity,
100
101
  fill: options.fill,
101
102
  x1: (bin) => bin.x1,
@@ -10,6 +10,7 @@ import {
10
10
  markId,
11
11
  paint,
12
12
  seriesResolver,
13
+ tips,
13
14
  } from "#/core/marks/shared.ts";
14
15
  import { categoryKey } from "#/core/scales/discrete.ts";
15
16
  import type {
@@ -117,7 +118,7 @@ export function waffleY<TDatum>(
117
118
  paint: paint({ fill: series.color, opacity: options.opacity }),
118
119
  });
119
120
  }
120
- if (options.tip !== false) {
121
+ if (tips(options.tip, data[index], index)) {
121
122
  points.push({
122
123
  key: `${id}:${index}`,
123
124
  markId: id,
package/src/core/types.ts CHANGED
@@ -90,6 +90,8 @@ export interface ChartPaint {
90
90
  readonly opacity?: number;
91
91
  readonly fillOpacity?: number;
92
92
  readonly strokeOpacity?: number;
93
+ /** Fill with diagonal hatching in the fill colour: a value pencilled in, not yet true. */
94
+ readonly hatch?: boolean;
93
95
  }
94
96
 
95
97
  export interface ChartTextPaint extends ChartPaint {
@@ -0,0 +1,93 @@
1
+ import type * as React from "react";
2
+
3
+ /**
4
+ * Stand-ins for a chart: no data yet, or none at all. Sized like the chart
5
+ * they replace (pass the same height), so the page does not jump when the
6
+ * data lands. Styled with the kit's tokens when the page has them.
7
+ */
8
+
9
+ const BOX: React.CSSProperties = {
10
+ width: "100%",
11
+ aspectRatio: "16 / 9",
12
+ borderRadius: 8,
13
+ };
14
+
15
+ interface EmptyProps extends React.ComponentProps<"div"> {}
16
+
17
+ /** In place of a chart with nothing to show. Pass the localized message as children. */
18
+ export function ChartEmpty({ style, ...props }: EmptyProps) {
19
+ return (
20
+ <div
21
+ data-slot="chart-empty"
22
+ style={{
23
+ ...BOX,
24
+ display: "flex",
25
+ flexDirection: "column",
26
+ alignItems: "center",
27
+ justifyContent: "center",
28
+ gap: 4,
29
+ border:
30
+ "1px dashed var(--border, color-mix(in oklab, currentColor 25%, transparent))",
31
+ color: "var(--muted-foreground, inherit)",
32
+ fontSize: 14,
33
+ textAlign: "center",
34
+ textWrap: "balance",
35
+ ...style,
36
+ }}
37
+ {...props}
38
+ />
39
+ );
40
+ }
41
+
42
+ /** Fixed, not random, so the server and the browser render the same bars. */
43
+ const BAR_HEIGHTS = [40, 70, 55, 90, 65, 80, 50] as const;
44
+
45
+ const SKELETON_CSS = `
46
+ @keyframes voila-chart-pulse{50%{opacity:.5}}
47
+ @media (prefers-reduced-motion:no-preference){[data-slot="chart-skeleton-bar"]{animation:voila-chart-pulse 2s cubic-bezier(.4,0,.6,1) infinite}}
48
+ `;
49
+
50
+ interface SkeletonProps extends React.ComponentProps<"div"> {
51
+ /** What a screen reader hears while the chart loads. */
52
+ readonly label?: string;
53
+ }
54
+
55
+ /** A loading placeholder shaped like a bar chart, announced as a status. */
56
+ export function ChartSkeleton({
57
+ label = "Chargement",
58
+ style,
59
+ ...props
60
+ }: SkeletonProps) {
61
+ return (
62
+ <div
63
+ data-slot="chart-skeleton"
64
+ role="status"
65
+ aria-label={label}
66
+ style={{
67
+ ...BOX,
68
+ display: "flex",
69
+ alignItems: "flex-end",
70
+ gap: 8,
71
+ ...style,
72
+ }}
73
+ {...props}
74
+ >
75
+ <style href="voila-chart-skeleton" precedence="default">
76
+ {SKELETON_CSS}
77
+ </style>
78
+ {BAR_HEIGHTS.map((height) => (
79
+ <div
80
+ key={height}
81
+ data-slot="chart-skeleton-bar"
82
+ style={{
83
+ width: "100%",
84
+ height: `${height}%`,
85
+ borderRadius: 6,
86
+ background:
87
+ "var(--muted, color-mix(in oklab, currentColor 10%, transparent))",
88
+ }}
89
+ />
90
+ ))}
91
+ </div>
92
+ );
93
+ }
@@ -1,5 +1,6 @@
1
1
  export type { ChartBrush, ChartBrushRange } from "#/react/brush-contract.ts";
2
2
  export { Chart } from "#/react/chart.tsx";
3
+ export { ChartEmpty, ChartSkeleton } from "#/react/chart-placeholders.tsx";
3
4
  export {
4
5
  ChartTooltipContent,
5
6
  type ChartTooltipRenderProps,
@@ -17,9 +17,40 @@ const BASELINE: Record<
17
17
  alphabetic: "alphabetic",
18
18
  };
19
19
 
20
- function paintProps(paint: ChartPaint): React.SVGAttributes<SVGElement> {
20
+ const HATCH_STEP = 6;
21
+
22
+ /** A stable id per colour (djb2), the same on the server and in the browser. */
23
+ function hatchId(prefix: string, color: string): string {
24
+ let hash = 5381;
25
+ for (const character of color) {
26
+ hash = ((hash << 5) + hash + character.charCodeAt(0)) | 0;
27
+ }
28
+ return `${prefix}-hatch-${(hash >>> 0).toString(36)}`;
29
+ }
30
+
31
+ function hatchColors(
32
+ nodes: ReadonlyArray<SceneNode>,
33
+ into = new Set<string>(),
34
+ ): Set<string> {
35
+ for (const node of nodes) {
36
+ if (node.kind === "group") {
37
+ hatchColors(node.children, into);
38
+ } else if (node.paint.hatch && node.paint.fill) {
39
+ into.add(node.paint.fill);
40
+ }
41
+ }
42
+ return into;
43
+ }
44
+
45
+ function paintProps(
46
+ paint: ChartPaint,
47
+ prefix: string,
48
+ ): React.SVGAttributes<SVGElement> {
21
49
  return {
22
- fill: paint.fill ?? "none",
50
+ fill:
51
+ paint.hatch && paint.fill
52
+ ? `url(#${hatchId(prefix, paint.fill)})`
53
+ : (paint.fill ?? "none"),
23
54
  stroke: paint.stroke,
24
55
  strokeWidth: paint.strokeWidth,
25
56
  strokeDasharray: paint.strokeDasharray,
@@ -72,7 +103,7 @@ function SceneElement({
72
103
  <path
73
104
  {...data}
74
105
  d={roundedBarPath({ ...node, radius: node.corners })}
75
- {...paintProps(node.paint)}
106
+ {...paintProps(node.paint, clipPrefix)}
76
107
  />
77
108
  ) : (
78
109
  <rect
@@ -81,11 +112,13 @@ function SceneElement({
81
112
  y={node.y}
82
113
  width={node.width}
83
114
  height={node.height}
84
- {...paintProps(node.paint)}
115
+ {...paintProps(node.paint, clipPrefix)}
85
116
  />
86
117
  );
87
118
  case "path":
88
- return <path {...data} d={node.d} {...paintProps(node.paint)} />;
119
+ return (
120
+ <path {...data} d={node.d} {...paintProps(node.paint, clipPrefix)} />
121
+ );
89
122
  case "circle":
90
123
  return (
91
124
  <circle
@@ -93,7 +126,7 @@ function SceneElement({
93
126
  cx={node.cx}
94
127
  cy={node.cy}
95
128
  r={node.r}
96
- {...paintProps(node.paint)}
129
+ {...paintProps(node.paint, clipPrefix)}
97
130
  />
98
131
  );
99
132
  case "line":
@@ -104,7 +137,7 @@ function SceneElement({
104
137
  y1={node.y1}
105
138
  x2={node.x2}
106
139
  y2={node.y2}
107
- {...paintProps(node.paint)}
140
+ {...paintProps(node.paint, clipPrefix)}
108
141
  />
109
142
  );
110
143
  case "text":
@@ -124,7 +157,7 @@ function SceneElement({
124
157
  dominantBaseline={
125
158
  node.paint.baseline ? BASELINE[node.paint.baseline] : undefined
126
159
  }
127
- {...paintProps(node.paint)}
160
+ {...paintProps(node.paint, clipPrefix)}
128
161
  >
129
162
  {node.text}
130
163
  </text>
@@ -140,7 +173,35 @@ export function SvgSceneNodes({
140
173
  scene: ChartScene;
141
174
  clipPrefix: string;
142
175
  }) {
143
- return scene.nodes.map((node) => (
144
- <SceneElement key={node.key} node={node} clipPrefix={clipPrefix} />
145
- ));
176
+ const hatches = [...hatchColors(scene.nodes)];
177
+ return (
178
+ <>
179
+ {hatches.length > 0 ? (
180
+ <defs>
181
+ {hatches.map((color) => (
182
+ <pattern
183
+ key={color}
184
+ id={hatchId(clipPrefix, color)}
185
+ width={HATCH_STEP}
186
+ height={HATCH_STEP}
187
+ patternUnits="userSpaceOnUse"
188
+ patternTransform="rotate(45)"
189
+ >
190
+ <line
191
+ x1={0}
192
+ y1={0}
193
+ x2={0}
194
+ y2={HATCH_STEP}
195
+ stroke={color}
196
+ strokeWidth={1.5}
197
+ />
198
+ </pattern>
199
+ ))}
200
+ </defs>
201
+ ) : null}
202
+ {scene.nodes.map((node) => (
203
+ <SceneElement key={node.key} node={node} clipPrefix={clipPrefix} />
204
+ ))}
205
+ </>
206
+ );
146
207
  }