@voila.dev/chart 4.0.107 → 4.0.109

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 (82) 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/rule.d.ts +5 -0
  30. package/dist/core/marks/rule.d.ts.map +1 -1
  31. package/dist/core/marks/rule.js +15 -1
  32. package/dist/core/marks/rule.js.map +2 -2
  33. package/dist/core/marks/shared.d.ts +8 -2
  34. package/dist/core/marks/shared.d.ts.map +1 -1
  35. package/dist/core/marks/shared.js +4 -0
  36. package/dist/core/marks/shared.js.map +2 -2
  37. package/dist/core/marks/stats/dodge.d.ts.map +1 -1
  38. package/dist/core/marks/stats/dodge.js +2 -1
  39. package/dist/core/marks/stats/dodge.js.map +2 -2
  40. package/dist/core/marks/stats/histogram.d.ts.map +1 -1
  41. package/dist/core/marks/stats/histogram.js +2 -1
  42. package/dist/core/marks/stats/histogram.js.map +2 -2
  43. package/dist/core/marks/stats/waffle.d.ts.map +1 -1
  44. package/dist/core/marks/stats/waffle.js +3 -2
  45. package/dist/core/marks/stats/waffle.js.map +2 -2
  46. package/dist/core/scales/resolve.d.ts.map +1 -1
  47. package/dist/core/scales/resolve.js +6 -1
  48. package/dist/core/scales/resolve.js.map +2 -2
  49. package/dist/core/types.d.ts +2 -0
  50. package/dist/core/types.d.ts.map +1 -1
  51. package/dist/react/chart-placeholders.d.ts +13 -0
  52. package/dist/react/chart-placeholders.d.ts.map +1 -0
  53. package/dist/react/chart-placeholders.js +77 -0
  54. package/dist/react/chart-placeholders.js.map +7 -0
  55. package/dist/react/index.d.ts +1 -0
  56. package/dist/react/index.d.ts.map +1 -1
  57. package/dist/react/index.js +3 -0
  58. package/dist/react/index.js.map +2 -2
  59. package/dist/react/svg-scene.d.ts +1 -1
  60. package/dist/react/svg-scene.d.ts.map +1 -1
  61. package/dist/react/svg-scene.js +53 -10
  62. package/dist/react/svg-scene.js.map +2 -2
  63. package/package.json +1 -1
  64. package/src/canvas/paint-scene.ts +45 -1
  65. package/src/core/marks/area.ts +2 -1
  66. package/src/core/marks/bar.ts +25 -8
  67. package/src/core/marks/cell.ts +2 -1
  68. package/src/core/marks/dot.ts +2 -1
  69. package/src/core/marks/funnel.ts +2 -1
  70. package/src/core/marks/line.ts +2 -1
  71. package/src/core/marks/polar.ts +4 -3
  72. package/src/core/marks/rect.ts +2 -1
  73. package/src/core/marks/rule.ts +26 -1
  74. package/src/core/marks/shared.ts +15 -2
  75. package/src/core/marks/stats/dodge.ts +2 -1
  76. package/src/core/marks/stats/histogram.ts +2 -1
  77. package/src/core/marks/stats/waffle.ts +2 -1
  78. package/src/core/scales/resolve.ts +11 -1
  79. package/src/core/types.ts +2 -0
  80. package/src/react/chart-placeholders.tsx +93 -0
  81. package/src/react/index.ts +1 -0
  82. package/src/react/svg-scene.tsx +72 -11
@@ -14,6 +14,48 @@ const CANVAS_ALIGN = {
14
14
  end: "right",
15
15
  } as const satisfies Record<string, CanvasTextAlign>;
16
16
 
17
+ const HATCH_STEP = 6;
18
+ const hatchCache = new WeakMap<
19
+ CanvasRenderingContext2D,
20
+ Map<string, CanvasPattern | string>
21
+ >();
22
+
23
+ /** Diagonal lines every few pixels in the colour, as the SVG pattern draws them. */
24
+ function hatchPattern(
25
+ context: CanvasRenderingContext2D,
26
+ color: string,
27
+ ): CanvasPattern | string {
28
+ const cache =
29
+ hatchCache.get(context) ?? new Map<string, CanvasPattern | string>();
30
+ hatchCache.set(context, cache);
31
+ const cached = cache.get(color);
32
+ if (cached !== undefined) {
33
+ return cached;
34
+ }
35
+ const tile = document.createElement("canvas");
36
+ tile.width = HATCH_STEP;
37
+ tile.height = HATCH_STEP;
38
+ const pen = tile.getContext("2d");
39
+ if (pen === null) {
40
+ return color;
41
+ }
42
+ pen.strokeStyle = color;
43
+ pen.lineWidth = 1.5;
44
+ pen.beginPath();
45
+ // One diagonal across the tile, plus the two corner stubs that join it to
46
+ // the neighbouring tiles' lines.
47
+ pen.moveTo(0, HATCH_STEP);
48
+ pen.lineTo(HATCH_STEP, 0);
49
+ pen.moveTo(-1, 1);
50
+ pen.lineTo(1, -1);
51
+ pen.moveTo(HATCH_STEP - 1, HATCH_STEP + 1);
52
+ pen.lineTo(HATCH_STEP + 1, HATCH_STEP - 1);
53
+ pen.stroke();
54
+ const pattern = context.createPattern(tile, "repeat") ?? color;
55
+ cache.set(color, pattern);
56
+ return pattern;
57
+ }
58
+
17
59
  function dashes(dasharray: string | undefined): number[] {
18
60
  if (!dasharray) {
19
61
  return [];
@@ -39,7 +81,9 @@ function fillAndStroke(
39
81
  const opacity = paint.opacity ?? 1;
40
82
  if (paint.fill && paint.fill !== "none") {
41
83
  context.globalAlpha = opacity * (paint.fillOpacity ?? 1);
42
- context.fillStyle = resolve(paint.fill);
84
+ context.fillStyle = paint.hatch
85
+ ? hatchPattern(context, resolve(paint.fill))
86
+ : resolve(paint.fill);
43
87
  draw("fill");
44
88
  }
45
89
  if (paint.stroke && paint.stroke !== "none") {
@@ -11,6 +11,7 @@ import {
11
11
  markId,
12
12
  paint,
13
13
  seriesResolver,
14
+ tips,
14
15
  } from "#/core/marks/shared.ts";
15
16
  import { stackSegments } from "#/core/marks/stack.ts";
16
17
  import { areaPath, type ChartXY, linePath } from "#/core/paths.ts";
@@ -149,7 +150,7 @@ function areaMark<TDatum>(
149
150
  const run = runs[runs.length - 1];
150
151
  run.top.push(top);
151
152
  run.bottom.push(place(along, edge.low));
152
- if (options.tip === false) {
153
+ if (!tips(options.tip, data[index], index)) {
153
154
  continue;
154
155
  }
155
156
  const value = explicit ? edge.high : (values[index] ?? 0);
@@ -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 { stackSegments } from "#/core/marks/stack.ts";
15
16
  import { categoryKey } from "#/core/scales/discrete.ts";
@@ -40,6 +41,11 @@ export interface BarOptions<TDatum> extends ChartMarkOptions<TDatum> {
40
41
  readonly radius?: number;
41
42
  /** Pixels kept clear on each side of the bar inside its band. */
42
43
  readonly inset?: number;
44
+ /**
45
+ * Bars to draw hatched and outlined: projections, stages still in flight,
46
+ * anything pencilled in rather than recorded.
47
+ */
48
+ readonly projected?: (datum: TDatum, index: number) => boolean;
43
49
  }
44
50
 
45
51
  const DEFAULT_RADIUS = 4;
@@ -192,15 +198,26 @@ function barMark<TDatum>(
192
198
  role: "mark",
193
199
  ...rect,
194
200
  corners: corners(vertical, bar, radius, explicit),
195
- paint: paint({
196
- fill: series.color,
197
- fillOpacity: options.fillOpacity,
198
- stroke: options.stroke,
199
- strokeDasharray: options.strokeDasharray,
200
- opacity: options.opacity,
201
- }),
201
+ paint: paint(
202
+ options.projected?.(data[bar.index], bar.index)
203
+ ? {
204
+ fill: series.color,
205
+ hatch: true,
206
+ stroke: series.color,
207
+ strokeWidth: 1,
208
+ strokeDasharray: "3 2",
209
+ opacity: options.opacity,
210
+ }
211
+ : {
212
+ fill: series.color,
213
+ fillOpacity: options.fillOpacity,
214
+ stroke: options.stroke,
215
+ strokeDasharray: options.strokeDasharray,
216
+ opacity: options.opacity,
217
+ },
218
+ ),
202
219
  });
203
- if (options.tip === false) {
220
+ if (!tips(options.tip, data[bar.index], bar.index)) {
204
221
  continue;
205
222
  }
206
223
  const along = alongStart + alongSize / 2;
@@ -6,6 +6,7 @@ import {
6
6
  markId,
7
7
  paint,
8
8
  seriesResolver,
9
+ tips,
9
10
  valueOn,
10
11
  } from "#/core/marks/shared.ts";
11
12
  import type {
@@ -96,7 +97,7 @@ export function cell<TDatum>(
96
97
  corners: [radius, radius, radius, radius],
97
98
  paint: paint({ fill: series.color, opacity: options.opacity }),
98
99
  });
99
- if (options.tip !== false) {
100
+ if (tips(options.tip, datum, index)) {
100
101
  points.push({
101
102
  key: `${id}:${index}`,
102
103
  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 type {
@@ -129,7 +130,7 @@ export function dot<TDatum>(
129
130
  opacity: options.opacity,
130
131
  }),
131
132
  });
132
- if (options.tip !== false) {
133
+ if (tips(options.tip, datum, index)) {
133
134
  points.push({
134
135
  key: `${id}:${index}`,
135
136
  markId: id,
@@ -11,6 +11,7 @@ import {
11
11
  markId,
12
12
  paint,
13
13
  seriesResolver,
14
+ tips,
14
15
  } from "#/core/marks/shared.ts";
15
16
  import { categoryKey } from "#/core/scales/discrete.ts";
16
17
  import type {
@@ -133,7 +134,7 @@ export function funnel<TDatum>(
133
134
  },
134
135
  );
135
136
  }
136
- if (options.tip === false) {
137
+ if (!tips(options.tip, data[slice.index], slice.index)) {
137
138
  continue;
138
139
  }
139
140
  const previous = slice.index > 0 ? values[slice.index - 1] : undefined;
@@ -6,6 +6,7 @@ import {
6
6
  markId,
7
7
  paint,
8
8
  seriesResolver,
9
+ tips,
9
10
  valueOn,
10
11
  } from "#/core/marks/shared.ts";
11
12
  import { type ChartXY, linePath } from "#/core/paths.ts";
@@ -111,7 +112,7 @@ function lineMark<TDatum>(
111
112
  ? { x: along, y: across }
112
113
  : { x: across, y: along };
113
114
  runs[runs.length - 1].push(xy);
114
- if (options.tip !== false) {
115
+ if (tips(options.tip, data[index], index)) {
115
116
  points.push({
116
117
  key: `${id}:${index}`,
117
118
  markId: id,
@@ -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,
@@ -1,8 +1,10 @@
1
1
  import { isPlaceable, readChannel } from "#/core/channel.ts";
2
2
  import { markId, paint, valueOn } from "#/core/marks/shared.ts";
3
+ import { categoryKey } from "#/core/scales/discrete.ts";
3
4
  import type {
4
5
  ChartAccessor,
5
6
  ChartMark,
7
+ ChartPositionScale,
6
8
  ChartValue,
7
9
  SceneNode,
8
10
  } from "#/core/types.ts";
@@ -17,10 +19,30 @@ export interface RuleOptions<TDatum> {
17
19
  readonly opacity?: number;
18
20
  /** Text written at the end of the rule: "Objectif", "Moyenne". */
19
21
  readonly label?: string;
22
+ /**
23
+ * On a band or point axis, `before` draws the rule between the value's
24
+ * column and the one before it: where a period starts, not its middle.
25
+ */
26
+ readonly position?: "center" | "before";
20
27
  }
21
28
 
22
29
  const LABEL_GAP = 4;
23
30
 
31
+ /** Halfway between a category's slot and the previous one's, or its own start for the first. */
32
+ function before(scale: ChartPositionScale, value: ChartValue): number {
33
+ if (scale.kind !== "band" && scale.kind !== "point") {
34
+ return scale.center(value);
35
+ }
36
+ const index = scale.domain.findIndex(
37
+ (candidate) => categoryKey(candidate) === categoryKey(value),
38
+ );
39
+ const previous = scale.domain[index - 1];
40
+ if (index <= 0 || previous === undefined) {
41
+ return scale.map(value);
42
+ }
43
+ return (scale.map(previous) + scale.bandwidth + scale.map(value)) / 2;
44
+ }
45
+
24
46
  function ruleMark<TDatum>(
25
47
  kind: "ruleY" | "ruleX",
26
48
  data: ReadonlyArray<TDatum>,
@@ -50,7 +72,10 @@ function ruleMark<TDatum>(
50
72
  if (value === undefined) {
51
73
  continue;
52
74
  }
53
- const at = scale.center(value);
75
+ const at =
76
+ options.position === "before"
77
+ ? before(scale, value)
78
+ : scale.center(value);
54
79
  nodes.push({
55
80
  kind: "line",
56
81
  key: `${id}:${index}`,
@@ -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,
@@ -111,7 +111,17 @@ export function planScale(
111
111
  };
112
112
  }
113
113
  if (options?.domain !== undefined) {
114
- return { kind, domain: options.domain.map(toNumber), label };
114
+ const [low, high] = options.domain.map(toNumber);
115
+ // An explicit domain still widens to round ticks when asked to: the
116
+ // data's own maximum as the top of the axis would end it mid-air.
117
+ return {
118
+ kind,
119
+ domain:
120
+ options.nice === true && kind === "linear"
121
+ ? niceDomain(low, high)
122
+ : [low, high],
123
+ label,
124
+ };
115
125
  }
116
126
  const zero =
117
127
  options?.zero ?? channels.some((channel) => channel.includeZero === true);
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
  }