layerchart 2.0.0-next.50 → 2.0.0-next.52

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 (70) hide show
  1. package/dist/components/Arc.svelte +12 -4
  2. package/dist/components/Arc.svelte.d.ts +4 -0
  3. package/dist/components/ArcLabel.svelte +259 -0
  4. package/dist/components/ArcLabel.svelte.d.ts +73 -0
  5. package/dist/components/ArcLabel.svelte.test.d.ts +1 -0
  6. package/dist/components/ArcLabel.svelte.test.js +235 -0
  7. package/dist/components/Axis.svelte +25 -0
  8. package/dist/components/Axis.svelte.d.ts +10 -0
  9. package/dist/components/Circle.svelte +82 -59
  10. package/dist/components/CircleLegend.svelte +389 -0
  11. package/dist/components/CircleLegend.svelte.d.ts +114 -0
  12. package/dist/components/Ellipse.svelte +83 -64
  13. package/dist/components/GeoLegend.svelte +404 -0
  14. package/dist/components/GeoLegend.svelte.d.ts +106 -0
  15. package/dist/components/GeoRaster.svelte +311 -0
  16. package/dist/components/GeoRaster.svelte.d.ts +61 -0
  17. package/dist/components/Grid.svelte +15 -0
  18. package/dist/components/Grid.svelte.d.ts +5 -0
  19. package/dist/components/Image.svelte +2 -2
  20. package/dist/components/Labels.svelte +46 -11
  21. package/dist/components/Labels.svelte.d.ts +7 -3
  22. package/dist/components/Legend.svelte +58 -3
  23. package/dist/components/Legend.svelte.d.ts +7 -0
  24. package/dist/components/Line.svelte +82 -62
  25. package/dist/components/Points.svelte +2 -2
  26. package/dist/components/Polygon.svelte +92 -56
  27. package/dist/components/Rect.svelte +113 -64
  28. package/dist/components/Rule.svelte +2 -0
  29. package/dist/components/Sankey.svelte +0 -2
  30. package/dist/components/Text.svelte +83 -52
  31. package/dist/components/__screenshots__/ArcLabel.svelte.test.ts/ArcLabel-defaults-placement-to-centroid--x-y-at-the-centroid--middle-anchors--1.png +0 -0
  32. package/dist/components/__screenshots__/ArcLabel.svelte.test.ts/ArcLabel-defaults-placement-to-centroid--x-y-at-the-centroid--middle-anchors--2.png +0 -0
  33. package/dist/components/charts/ArcChart.svelte +39 -2
  34. package/dist/components/charts/ArcChart.svelte.d.ts +12 -1
  35. package/dist/components/charts/PieChart.svelte +40 -2
  36. package/dist/components/charts/PieChart.svelte.d.ts +10 -0
  37. package/dist/components/index.d.ts +8 -0
  38. package/dist/components/index.js +8 -0
  39. package/dist/components/layers/Canvas.svelte +65 -48
  40. package/dist/components/layers/Canvas.svelte.d.ts +10 -0
  41. package/dist/contexts/canvas.d.ts +3 -0
  42. package/dist/server/ContextCapture.svelte +30 -0
  43. package/dist/server/ContextCapture.svelte.d.ts +8 -0
  44. package/dist/server/ServerChart.svelte +26 -0
  45. package/dist/server/ServerChart.svelte.d.ts +11 -0
  46. package/dist/server/TestBarChart.svelte +35 -0
  47. package/dist/server/TestBarChart.svelte.d.ts +14 -0
  48. package/dist/server/TestLineChart.svelte +35 -0
  49. package/dist/server/TestLineChart.svelte.d.ts +14 -0
  50. package/dist/server/captureStore.d.ts +8 -0
  51. package/dist/server/captureStore.js +18 -0
  52. package/dist/server/index.d.ts +137 -0
  53. package/dist/server/index.js +141 -0
  54. package/dist/server/renderChart.ssr.test.d.ts +1 -0
  55. package/dist/server/renderChart.ssr.test.js +205 -0
  56. package/dist/server/renderTree.d.ts +8 -0
  57. package/dist/server/renderTree.js +29 -0
  58. package/dist/states/__screenshots__/chart.svelte.test.ts/ChartState-geo-projection-skips-markInfo-should-not-derive-x-y-accessors-from-marks-when-geo-projection-is-active-1.png +0 -0
  59. package/dist/states/__screenshots__/chart.svelte.test.ts/ChartState-geo-projection-skips-markInfo-should-not-derive-x-y-accessors-from-marks-when-geo-projection-is-active-2.png +0 -0
  60. package/dist/states/chart.svelte.d.ts +5 -1
  61. package/dist/states/chart.svelte.js +18 -3
  62. package/dist/states/chart.svelte.test.js +110 -0
  63. package/dist/states/geo.svelte.d.ts +5 -1
  64. package/dist/states/geo.svelte.js +80 -68
  65. package/dist/utils/arcText.svelte.d.ts +7 -1
  66. package/dist/utils/arcText.svelte.js +8 -4
  67. package/dist/utils/canvas.js +29 -10
  68. package/dist/utils/canvas.svelte.test.js +2 -2
  69. package/dist/utils/motion.svelte.js +14 -0
  70. package/package.json +7 -1
@@ -0,0 +1,311 @@
1
+ <script lang="ts" module>
2
+ import type { GeoProjection } from 'd3-geo';
3
+
4
+ export type GeoRasterPropsWithoutHTML = {
5
+ /**
6
+ * The image to reproject. Either:
7
+ *
8
+ * - a URL string (loaded via `Image` with `crossOrigin="anonymous"`),
9
+ * - a preloaded `HTMLImageElement`,
10
+ * - an `HTMLCanvasElement` (e.g. a stitched tile mosaic),
11
+ * - or an `ImageBitmap`.
12
+ *
13
+ * The image pixel data must be readable (not tainted by CORS). When passing a
14
+ * URL, the component sets `crossOrigin` on the underlying `Image` — the host
15
+ * must respond with the appropriate `Access-Control-Allow-Origin` header.
16
+ */
17
+ image: string | HTMLImageElement | HTMLCanvasElement | ImageBitmap;
18
+
19
+ /**
20
+ * Projection used by the source image. Pass as an uncalled function, e.g.
21
+ * `sourceProjection={geoEquirectangular}`.
22
+ *
23
+ * When omitted, the source is assumed to be an equirectangular (plate carrée)
24
+ * image covering the full globe — i.e. `x = ((lon + 180) / 360) * width`,
25
+ * `y = ((90 - lat) / 180) * height`. This is the common layout for imagery
26
+ * such as NASA Blue Marble / Black Marble.
27
+ */
28
+ sourceProjection?: () => GeoProjection;
29
+
30
+ /**
31
+ * Pixel sampling strategy.
32
+ *
33
+ * - `nearest` (default): fastest, slightly blocky at low source resolution.
34
+ * - `bilinear`: smoother, ~4x slower per pixel.
35
+ *
36
+ * @default 'nearest'
37
+ */
38
+ interpolate?: 'nearest' | 'bilinear';
39
+
40
+ /**
41
+ * `crossOrigin` attribute applied to the underlying `Image` element when
42
+ * loading from a URL. Set to `null` to disable.
43
+ *
44
+ * @default 'anonymous'
45
+ */
46
+ crossOrigin?: string | null;
47
+
48
+ /**
49
+ * Resolution multiplier for the reprojected output, relative to chart CSS
50
+ * pixels. A value greater than 1 produces a sharper image on HiDPI
51
+ * displays at the cost of more work per frame.
52
+ *
53
+ * @default 1
54
+ */
55
+ resolution?: number;
56
+
57
+ /**
58
+ * Disable image smoothing when compositing the reprojected buffer onto the
59
+ * destination canvas. Useful for a pixel-art look.
60
+ *
61
+ * @default false
62
+ */
63
+ pixelated?: boolean;
64
+ };
65
+
66
+ export type GeoRasterProps = GeoRasterPropsWithoutHTML;
67
+ </script>
68
+
69
+ <script lang="ts">
70
+ import { untrack } from 'svelte';
71
+ import { geoPath } from 'd3-geo';
72
+
73
+ import { getChartContext } from '../contexts/chart.js';
74
+ import { getGeoContext } from '../contexts/geo.js';
75
+ import { getLayerContext } from '../contexts/layer.js';
76
+
77
+ let {
78
+ image,
79
+ sourceProjection,
80
+ interpolate = 'nearest',
81
+ crossOrigin = 'anonymous',
82
+ resolution = 1,
83
+ pixelated = false,
84
+ }: GeoRasterProps = $props();
85
+
86
+ const ctx = getChartContext();
87
+ const geo = getGeoContext();
88
+ const layerCtx = getLayerContext();
89
+
90
+ type SourcePixels = {
91
+ data: Uint8ClampedArray;
92
+ width: number;
93
+ height: number;
94
+ };
95
+
96
+ let source = $state<SourcePixels | null>(null);
97
+
98
+ $effect(() => {
99
+ if (typeof window === 'undefined') return;
100
+ // Re-run when `image` changes
101
+ const src = image;
102
+
103
+ let cancelled = false;
104
+
105
+ async function load() {
106
+ let drawable: CanvasImageSource;
107
+ let sw: number;
108
+ let sh: number;
109
+
110
+ if (typeof src === 'string') {
111
+ const img = new Image();
112
+ if (crossOrigin !== null) img.crossOrigin = crossOrigin;
113
+ img.src = src;
114
+ try {
115
+ await img.decode();
116
+ } catch (err) {
117
+ console.warn('[GeoRaster] Failed to decode image', err);
118
+ return;
119
+ }
120
+ drawable = img;
121
+ sw = img.naturalWidth;
122
+ sh = img.naturalHeight;
123
+ } else if (src instanceof HTMLImageElement) {
124
+ try {
125
+ await src.decode();
126
+ } catch (err) {
127
+ console.warn('[GeoRaster] Failed to decode image', err);
128
+ return;
129
+ }
130
+ drawable = src;
131
+ sw = src.naturalWidth;
132
+ sh = src.naturalHeight;
133
+ } else {
134
+ // HTMLCanvasElement or ImageBitmap — already decoded
135
+ drawable = src;
136
+ sw = src.width;
137
+ sh = src.height;
138
+ }
139
+
140
+ if (cancelled) return;
141
+ if (!sw || !sh) return;
142
+
143
+ const offscreen = document.createElement('canvas');
144
+ offscreen.width = sw;
145
+ offscreen.height = sh;
146
+ const octx = offscreen.getContext('2d', { willReadFrequently: true });
147
+ if (!octx) return;
148
+ octx.drawImage(drawable, 0, 0);
149
+
150
+ try {
151
+ const imageData = octx.getImageData(0, 0, sw, sh);
152
+ untrack(() => {
153
+ source = { data: imageData.data, width: sw, height: sh };
154
+ });
155
+ } catch (err) {
156
+ console.warn(
157
+ '[GeoRaster] Unable to read image pixels — the image is likely tainted by CORS',
158
+ err
159
+ );
160
+ }
161
+ }
162
+
163
+ load();
164
+
165
+ return () => {
166
+ cancelled = true;
167
+ };
168
+ });
169
+
170
+ function render(dstCtx: CanvasRenderingContext2D) {
171
+ const src = source;
172
+ if (!src) return;
173
+
174
+ const projection = geo.projection;
175
+ if (!projection?.invert) return;
176
+
177
+ const width = Math.max(1, Math.floor(ctx.width * resolution));
178
+ const height = Math.max(1, Math.floor(ctx.height * resolution));
179
+ if (width <= 0 || height <= 0) return;
180
+
181
+ const buffer = document.createElement('canvas');
182
+ buffer.width = width;
183
+ buffer.height = height;
184
+ const bctx = buffer.getContext('2d');
185
+ if (!bctx) return;
186
+
187
+ const dstImage = bctx.createImageData(width, height);
188
+ const dstData = dstImage.data;
189
+
190
+ const srcData = src.data;
191
+ const sw = src.width;
192
+ const sh = src.height;
193
+
194
+ const srcProj = sourceProjection?.();
195
+ const invert = projection.invert.bind(projection);
196
+ const bilinear = interpolate === 'bilinear';
197
+
198
+ for (let y = 0; y < height; y++) {
199
+ for (let x = 0; x < width; x++) {
200
+ // Inverse-project destination pixel (in chart-space coordinates) to lon/lat.
201
+ // When `resolution !== 1`, scale the sample point back to chart coordinates.
202
+ const lonlat = invert([x / resolution, y / resolution]);
203
+ if (!lonlat) continue;
204
+
205
+ const lon = lonlat[0];
206
+ const lat = lonlat[1];
207
+ if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
208
+
209
+ let sx: number;
210
+ let sy: number;
211
+
212
+ if (srcProj) {
213
+ const p = srcProj([lon, lat]);
214
+ if (!p) continue;
215
+ sx = p[0];
216
+ sy = p[1];
217
+ } else {
218
+ // Equirectangular source (plate carrée) — the default for most
219
+ // full-globe imagery like NASA Blue Marble.
220
+ sx = ((lon + 180) / 360) * sw;
221
+ sy = ((90 - lat) / 180) * sh;
222
+ }
223
+
224
+ if (!(sx >= 0 && sx < sw && sy >= 0 && sy < sh)) continue;
225
+
226
+ const di = (y * width + x) * 4;
227
+
228
+ if (bilinear) {
229
+ const x0 = Math.floor(sx);
230
+ const y0 = Math.floor(sy);
231
+ const x1 = Math.min(x0 + 1, sw - 1);
232
+ const y1 = Math.min(y0 + 1, sh - 1);
233
+ const fx = sx - x0;
234
+ const fy = sy - y0;
235
+ const w00 = (1 - fx) * (1 - fy);
236
+ const w10 = fx * (1 - fy);
237
+ const w01 = (1 - fx) * fy;
238
+ const w11 = fx * fy;
239
+ const i00 = (y0 * sw + x0) * 4;
240
+ const i10 = (y0 * sw + x1) * 4;
241
+ const i01 = (y1 * sw + x0) * 4;
242
+ const i11 = (y1 * sw + x1) * 4;
243
+ dstData[di] =
244
+ srcData[i00] * w00 + srcData[i10] * w10 + srcData[i01] * w01 + srcData[i11] * w11;
245
+ dstData[di + 1] =
246
+ srcData[i00 + 1] * w00 +
247
+ srcData[i10 + 1] * w10 +
248
+ srcData[i01 + 1] * w01 +
249
+ srcData[i11 + 1] * w11;
250
+ dstData[di + 2] =
251
+ srcData[i00 + 2] * w00 +
252
+ srcData[i10 + 2] * w10 +
253
+ srcData[i01 + 2] * w01 +
254
+ srcData[i11 + 2] * w11;
255
+ dstData[di + 3] =
256
+ srcData[i00 + 3] * w00 +
257
+ srcData[i10 + 3] * w10 +
258
+ srcData[i01 + 3] * w01 +
259
+ srcData[i11 + 3] * w11;
260
+ } else {
261
+ const sxi = sx | 0;
262
+ const syi = sy | 0;
263
+ const si = (syi * sw + sxi) * 4;
264
+ dstData[di] = srcData[si];
265
+ dstData[di + 1] = srcData[si + 1];
266
+ dstData[di + 2] = srcData[si + 2];
267
+ dstData[di + 3] = srcData[si + 3];
268
+ }
269
+ }
270
+ }
271
+
272
+ bctx.putImageData(dstImage, 0, 0);
273
+
274
+ // Clip to the projection's sphere so pixels outside the visible region
275
+ // (e.g. the far hemisphere of an orthographic globe) aren't drawn. For
276
+ // non-clipped projections (mercator, etc.) the sphere still renders as a
277
+ // finite region covering the full map.
278
+ dstCtx.save();
279
+ const pathGen = geoPath(projection, dstCtx);
280
+ dstCtx.beginPath();
281
+ pathGen({ type: 'Sphere' });
282
+ dstCtx.clip();
283
+
284
+ const prevSmoothing = dstCtx.imageSmoothingEnabled;
285
+ if (pixelated) dstCtx.imageSmoothingEnabled = false;
286
+ dstCtx.drawImage(buffer, 0, 0, ctx.width, ctx.height);
287
+ if (pixelated) dstCtx.imageSmoothingEnabled = prevSmoothing;
288
+
289
+ dstCtx.restore();
290
+ }
291
+
292
+ if (layerCtx === 'canvas') {
293
+ ctx.registerComponent({
294
+ name: 'GeoRaster',
295
+ kind: 'mark',
296
+ canvasRender: {
297
+ render,
298
+ deps: () => [
299
+ source,
300
+ geo.projection,
301
+ ctx.width,
302
+ ctx.height,
303
+ interpolate,
304
+ resolution,
305
+ pixelated,
306
+ sourceProjection,
307
+ ],
308
+ },
309
+ });
310
+ }
311
+ </script>
@@ -0,0 +1,61 @@
1
+ import type { GeoProjection } from 'd3-geo';
2
+ export type GeoRasterPropsWithoutHTML = {
3
+ /**
4
+ * The image to reproject. Either:
5
+ *
6
+ * - a URL string (loaded via `Image` with `crossOrigin="anonymous"`),
7
+ * - a preloaded `HTMLImageElement`,
8
+ * - an `HTMLCanvasElement` (e.g. a stitched tile mosaic),
9
+ * - or an `ImageBitmap`.
10
+ *
11
+ * The image pixel data must be readable (not tainted by CORS). When passing a
12
+ * URL, the component sets `crossOrigin` on the underlying `Image` — the host
13
+ * must respond with the appropriate `Access-Control-Allow-Origin` header.
14
+ */
15
+ image: string | HTMLImageElement | HTMLCanvasElement | ImageBitmap;
16
+ /**
17
+ * Projection used by the source image. Pass as an uncalled function, e.g.
18
+ * `sourceProjection={geoEquirectangular}`.
19
+ *
20
+ * When omitted, the source is assumed to be an equirectangular (plate carrée)
21
+ * image covering the full globe — i.e. `x = ((lon + 180) / 360) * width`,
22
+ * `y = ((90 - lat) / 180) * height`. This is the common layout for imagery
23
+ * such as NASA Blue Marble / Black Marble.
24
+ */
25
+ sourceProjection?: () => GeoProjection;
26
+ /**
27
+ * Pixel sampling strategy.
28
+ *
29
+ * - `nearest` (default): fastest, slightly blocky at low source resolution.
30
+ * - `bilinear`: smoother, ~4x slower per pixel.
31
+ *
32
+ * @default 'nearest'
33
+ */
34
+ interpolate?: 'nearest' | 'bilinear';
35
+ /**
36
+ * `crossOrigin` attribute applied to the underlying `Image` element when
37
+ * loading from a URL. Set to `null` to disable.
38
+ *
39
+ * @default 'anonymous'
40
+ */
41
+ crossOrigin?: string | null;
42
+ /**
43
+ * Resolution multiplier for the reprojected output, relative to chart CSS
44
+ * pixels. A value greater than 1 produces a sharper image on HiDPI
45
+ * displays at the cost of more work per frame.
46
+ *
47
+ * @default 1
48
+ */
49
+ resolution?: number;
50
+ /**
51
+ * Disable image smoothing when compositing the reprojected buffer onto the
52
+ * destination canvas. Useful for a pixel-art look.
53
+ *
54
+ * @default false
55
+ */
56
+ pixelated?: boolean;
57
+ };
58
+ export type GeoRasterProps = GeoRasterPropsWithoutHTML;
59
+ declare const GeoRaster: import("svelte").Component<GeoRasterPropsWithoutHTML, {}, "">;
60
+ type GeoRaster = ReturnType<typeof GeoRaster>;
61
+ export default GeoRaster;
@@ -44,6 +44,12 @@
44
44
  */
45
45
  radialY?: 'circle' | 'linear';
46
46
 
47
+ /**
48
+ * Stroke color for grid lines.
49
+ * Useful for server-side rendering where CSS variables are not available.
50
+ */
51
+ stroke?: string;
52
+
47
53
  /**
48
54
  * Classes to apply to the rendered elements.
49
55
  *
@@ -113,6 +119,7 @@
113
119
  yTicks: yTicksProp,
114
120
  bandAlign = 'center',
115
121
  radialY = 'circle',
122
+ stroke,
116
123
  motion,
117
124
  transitionIn: transitionInProp,
118
125
  transitionInParams = { easing: cubicIn },
@@ -168,6 +175,7 @@
168
175
  {y1}
169
176
  {x2}
170
177
  {y2}
178
+ {stroke}
171
179
  motion={tweenConfig}
172
180
  {...splineProps}
173
181
  class={cls('lc-grid-x-radial-line', classes.line, splineProps?.class)}
@@ -176,6 +184,7 @@
176
184
  <Rule
177
185
  {x}
178
186
  xOffset={xBandOffset}
187
+ {stroke}
179
188
  {motion}
180
189
  {...splineProps}
181
190
  class={cls('lc-grid-x-rule', classes.line, splineProps?.class)}
@@ -189,6 +198,7 @@
189
198
  <Rule
190
199
  x={xTickVals[xTickVals.length - 1]}
191
200
  xOffset={ctx.xScale.step() + xBandOffset}
201
+ {stroke}
192
202
  {motion}
193
203
  {...splineProps}
194
204
  class={cls('lc-grid-x-end-rule', classes.line, splineProps?.class)}
@@ -205,6 +215,7 @@
205
215
  {#if radialY === 'circle'}
206
216
  <Circle
207
217
  r={ctx.yScale(y) + yBandOffset}
218
+ {stroke}
208
219
  {motion}
209
220
  {...splineProps}
210
221
  class={cls('lc-grid-y-radial-circle', classes.line, splineProps?.class)}
@@ -214,6 +225,7 @@
214
225
  data={xTickVals.map((x) => ({ x, y }))}
215
226
  x="x"
216
227
  y="y"
228
+ {stroke}
217
229
  motion={tweenConfig}
218
230
  curve={curveLinearClosed}
219
231
  {...splineProps}
@@ -226,6 +238,7 @@
226
238
  y1={ctx.yScale(y) + yBandOffset}
227
239
  x2={ctx.xRange[1]}
228
240
  y2={ctx.yScale(y) + yBandOffset}
241
+ {stroke}
229
242
  {motion}
230
243
  {...splineProps}
231
244
  class={cls('lc-grid-y-rule', classes.line, splineProps?.class)}
@@ -238,6 +251,7 @@
238
251
  {#if ctx.radial}
239
252
  <Circle
240
253
  r={ctx.yScale(yTickVals[yTickVals.length - 1])! + ctx.yScale.step() + yBandOffset}
254
+ {stroke}
241
255
  {motion}
242
256
  {...splineProps}
243
257
  class={cls('lc-grid-y-radial-circle', classes.line, splineProps?.class)}
@@ -250,6 +264,7 @@
250
264
  y1={y}
251
265
  x2={ctx.xRange[1]}
252
266
  y2={y}
267
+ {stroke}
253
268
  {motion}
254
269
  {...splineProps}
255
270
  class={cls('lc-grid-y-end-rule', classes.line, splineProps?.class)}
@@ -36,6 +36,11 @@ export type GridPropsWithoutHTML<In extends Transition = Transition> = {
36
36
  * @default 'circle'
37
37
  */
38
38
  radialY?: 'circle' | 'linear';
39
+ /**
40
+ * Stroke color for grid lines.
41
+ * Useful for server-side rendering where CSS variables are not available.
42
+ */
43
+ stroke?: string;
39
44
  /**
40
45
  * Classes to apply to the rendered elements.
41
46
  *
@@ -514,7 +514,7 @@
514
514
  style:object-fit="cover"
515
515
  crossorigin={crossOrigin}
516
516
  class={cls('lc-image', className)}
517
- {...restProps}
517
+ {...restProps as any}
518
518
  />
519
519
  {/each}
520
520
  {:else}
@@ -532,7 +532,7 @@
532
532
  style:object-fit="cover"
533
533
  crossorigin={crossOrigin}
534
534
  class={cls('lc-image', className)}
535
- {...restProps}
535
+ {...restProps as any}
536
536
  />
537
537
  {/if}
538
538
  {/if}
@@ -42,15 +42,19 @@
42
42
 
43
43
  /**
44
44
  * The placement of the label relative to the point.
45
- * `smart` dynamically positions labels based on neighboring point values (peak, trough, rising, falling).
45
+ * - `outside`: outside the bar/point.
46
+ * - `inside`: inside the bar/point near the value edge.
47
+ * - `middle`: aligned to the value edge with a middle anchor.
48
+ * - `center`: centered within the bar body (between the value edge and baseline).
49
+ * - `smart`: dynamically positions labels based on neighboring point values (peak, trough, rising, falling).
46
50
  * @default 'outside'
47
51
  */
48
- placement?: 'inside' | 'outside' | 'center' | 'smart';
52
+ placement?: 'inside' | 'outside' | 'middle' | 'center' | 'smart';
49
53
 
50
54
  /**
51
55
  * The offset of the label from the point
52
56
  *
53
- * @default placement === 'center' ? 0 : 4
57
+ * @default placement === 'center' || placement === 'middle' ? 0 : 4
54
58
  */
55
59
  offset?: number;
56
60
 
@@ -81,6 +85,7 @@
81
85
  import { getChartContext } from '../contexts/chart.js';
82
86
  import Group from './Group.svelte';
83
87
  import { extractLayerProps } from '../utils/attributes.js';
88
+ import { createDimensionGetter } from '../utils/rect.svelte.js';
84
89
 
85
90
  const ctx = getChartContext();
86
91
 
@@ -94,7 +99,7 @@
94
99
  y,
95
100
  seriesKey,
96
101
  placement = 'outside',
97
- offset = placement === 'center' ? 0 : 4,
102
+ offset = placement === 'center' || placement === 'middle' ? 0 : 4,
98
103
  format,
99
104
  key = (_: any, i: number) => i,
100
105
  children: childrenProp,
@@ -104,6 +109,9 @@
104
109
  ...restProps
105
110
  }: LabelsProps<TData> = $props();
106
111
 
112
+ // Used to compute the bar's bounding rect for `center` placement
113
+ const getDimensions = $derived(createDimensionGetter(ctx, () => ({ x, y })));
114
+
107
115
  // TODO: Should we let `Points` handle opacity for children snippet as well?
108
116
  let series = $derived(ctx.series.series.find((s) => s.key === seriesKey));
109
117
  let derivedOpacity = $derived(
@@ -146,14 +154,27 @@
146
154
 
147
155
  if (isScaleBand(ctx.yScale)) {
148
156
  // Position label left/right on horizontal bars
149
- if (isLowEdge) {
157
+ if (placement === 'center') {
158
+ // Center within the bar body
159
+ const dims = getDimensions(point.data) ?? { x: point.x, y: point.y, width: 0, height: 0 };
160
+ result = {
161
+ value: formattedValue,
162
+ fill: fillValue,
163
+ x: dims.x + dims.width / 2,
164
+ y: dims.y + dims.height / 2,
165
+ textAnchor: 'middle',
166
+ verticalAnchor: 'middle',
167
+ capHeight: '.6rem',
168
+ };
169
+ } else if (isLowEdge) {
150
170
  // left
151
171
  result = {
152
172
  value: formattedValue,
153
173
  fill: fillValue,
154
174
  x: point.x + (placement === 'outside' ? -offset : offset),
155
175
  y: point.y,
156
- textAnchor: placement === 'outside' ? 'end' : 'start',
176
+ textAnchor:
177
+ placement === 'middle' ? 'middle' : placement === 'outside' ? 'end' : 'start',
157
178
  verticalAnchor: 'middle',
158
179
  capHeight: '.6rem',
159
180
  };
@@ -164,14 +185,27 @@
164
185
  fill: fillValue,
165
186
  x: point.x + (placement === 'outside' ? offset : -offset),
166
187
  y: point.y,
167
- textAnchor: placement === 'outside' ? 'start' : 'end',
188
+ textAnchor:
189
+ placement === 'middle' ? 'middle' : placement === 'outside' ? 'start' : 'end',
168
190
  verticalAnchor: 'middle',
169
191
  capHeight: '.6rem',
170
192
  };
171
193
  }
172
194
  } else {
173
195
  // Position label top/bottom on vertical bars
174
- if (isLowEdge) {
196
+ if (placement === 'center') {
197
+ // Center within the bar body
198
+ const dims = getDimensions(point.data) ?? { x: point.x, y: point.y, width: 0, height: 0 };
199
+ result = {
200
+ value: formattedValue,
201
+ fill: fillValue,
202
+ x: dims.x + dims.width / 2,
203
+ y: dims.y + dims.height / 2,
204
+ capHeight: '.6rem',
205
+ textAnchor: 'middle',
206
+ verticalAnchor: 'middle',
207
+ };
208
+ } else if (isLowEdge) {
175
209
  // bottom
176
210
  result = {
177
211
  value: formattedValue,
@@ -181,7 +215,7 @@
181
215
  capHeight: '.6rem',
182
216
  textAnchor: 'middle',
183
217
  verticalAnchor:
184
- placement === 'center' ? 'middle' : placement === 'outside' ? 'start' : 'end',
218
+ placement === 'middle' ? 'middle' : placement === 'outside' ? 'start' : 'end',
185
219
  };
186
220
  } else {
187
221
  // top
@@ -193,7 +227,7 @@
193
227
  capHeight: '.6rem',
194
228
  textAnchor: 'middle',
195
229
  verticalAnchor:
196
- placement === 'center' ? 'middle' : placement === 'outside' ? 'end' : 'start',
230
+ placement === 'middle' ? 'middle' : placement === 'outside' ? 'end' : 'start',
197
231
  };
198
232
  }
199
233
  }
@@ -271,7 +305,8 @@
271
305
  --fill-color: var(--color-surface-content, currentColor);
272
306
  --stroke-color: var(--color-surface-100, light-dark(white, black));
273
307
 
274
- &[data-placement='inside'] {
308
+ &[data-placement='inside'],
309
+ &[data-placement='center'] {
275
310
  --fill-color: var(--color-surface-100, light-dark(white, black));
276
311
  --stroke-color: var(--color-surface-content, currentColor);
277
312
  }
@@ -34,14 +34,18 @@ export type LabelsPropsWithoutHTML<T = any> = {
34
34
  seriesKey?: string;
35
35
  /**
36
36
  * The placement of the label relative to the point.
37
- * `smart` dynamically positions labels based on neighboring point values (peak, trough, rising, falling).
37
+ * - `outside`: outside the bar/point.
38
+ * - `inside`: inside the bar/point near the value edge.
39
+ * - `middle`: aligned to the value edge with a middle anchor.
40
+ * - `center`: centered within the bar body (between the value edge and baseline).
41
+ * - `smart`: dynamically positions labels based on neighboring point values (peak, trough, rising, falling).
38
42
  * @default 'outside'
39
43
  */
40
- placement?: 'inside' | 'outside' | 'center' | 'smart';
44
+ placement?: 'inside' | 'outside' | 'middle' | 'center' | 'smart';
41
45
  /**
42
46
  * The offset of the label from the point
43
47
  *
44
- * @default placement === 'center' ? 0 : 4
48
+ * @default placement === 'center' || placement === 'middle' ? 0 : 4
45
49
  */
46
50
  offset?: number;
47
51
  /**