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
@@ -101,6 +101,18 @@
101
101
  */
102
102
  scale?: any;
103
103
 
104
+ /**
105
+ * Stroke color for axis rule, grid lines, and tick marks.
106
+ * Useful for server-side rendering where CSS variables are not available.
107
+ */
108
+ stroke?: string;
109
+
110
+ /**
111
+ * Fill color for tick labels and axis label.
112
+ * Useful for server-side rendering where CSS variables are not available.
113
+ */
114
+ fill?: string;
115
+
104
116
  /**
105
117
  * Classes for styling various parts of the axis
106
118
  * @default {}
@@ -156,6 +168,8 @@
156
168
  tickMarks = true,
157
169
  format,
158
170
  tickLabelProps,
171
+ stroke,
172
+ fill,
159
173
  motion,
160
174
  transitionIn,
161
175
  transitionInParams,
@@ -168,6 +182,8 @@
168
182
 
169
183
  const ctx = getChartContext();
170
184
 
185
+ ctx.registerComponent({ name: 'Axis', kind: 'composite-mark' });
186
+
171
187
  const orientation = $derived(
172
188
  placement === 'angle'
173
189
  ? 'angle'
@@ -465,6 +481,8 @@
465
481
  // complement 10px text (until Text supports custom styles)
466
482
  capHeight: '7px',
467
483
  lineHeight: '11px',
484
+ fill,
485
+ stroke,
468
486
  ...labelProps,
469
487
  class: cls('lc-axis-label', classes.label, labelProps?.class),
470
488
  }) satisfies ComponentProps<typeof Text>;
@@ -479,6 +497,7 @@
479
497
  <Rule
480
498
  x={placement === 'left' ? '$left' : placement === 'right' ? '$right' : placement === 'angle'}
481
499
  y={placement === 'top' ? '$top' : placement === 'bottom' ? '$bottom' : placement === 'radius'}
500
+ {stroke}
482
501
  {motion}
483
502
  {...extractLayerProps(rule, 'lc-axis-rule', classes.rule ?? '')}
484
503
  />
@@ -506,6 +525,8 @@
506
525
  // complement 10px text (until Text supports custom styles)
507
526
  capHeight: '7px',
508
527
  lineHeight: '11px',
528
+ fill,
529
+ stroke,
509
530
  ...tickLabelProps,
510
531
  class: cls('lc-axis-tick-label', classes.tickLabel, tickLabelProps?.class),
511
532
  }}
@@ -515,6 +536,7 @@
515
536
  <Rule
516
537
  x={orientation === 'horizontal' || orientation === 'angle' ? tick : false}
517
538
  y={orientation === 'vertical' || orientation === 'radius' ? tick : false}
539
+ {stroke}
518
540
  {motion}
519
541
  {...extractLayerProps(grid, 'lc-axis-grid', classes.rule ?? '')}
520
542
  />
@@ -528,6 +550,7 @@
528
550
  y1={tickCoords.y}
529
551
  x2={tickCoords.x}
530
552
  y2={tickCoords.y + (placement === 'top' ? -tickLength : tickLength)}
553
+ {stroke}
531
554
  {motion}
532
555
  class={tickClasses}
533
556
  />
@@ -537,6 +560,7 @@
537
560
  y1={tickCoords.y}
538
561
  x2={tickCoords.x + (placement === 'left' ? -tickLength : tickLength)}
539
562
  y2={tickCoords.y}
563
+ {stroke}
540
564
  {motion}
541
565
  class={tickClasses}
542
566
  />
@@ -546,6 +570,7 @@
546
570
  y1={radialTickCoordsY}
547
571
  x2={radialTickMarkCoordsX}
548
572
  y2={radialTickMarkCoordsY}
573
+ {stroke}
549
574
  {motion}
550
575
  class={tickClasses}
551
576
  />
@@ -87,6 +87,16 @@ export type AxisPropsWithoutHTML<In extends Transition = Transition> = {
87
87
  * Override scale for the axis
88
88
  */
89
89
  scale?: any;
90
+ /**
91
+ * Stroke color for axis rule, grid lines, and tick marks.
92
+ * Useful for server-side rendering where CSS variables are not available.
93
+ */
94
+ stroke?: string;
95
+ /**
96
+ * Fill color for tick labels and axis label.
97
+ * Useful for server-side rendering where CSS variables are not available.
98
+ */
99
+ fill?: string;
90
100
  /**
91
101
  * Classes for styling various parts of the axis
92
102
  * @default {}
@@ -95,7 +95,13 @@
95
95
  import { untrack } from 'svelte';
96
96
  import { createMotion, createDataMotionMap, type MotionProp } from '../utils/motion.svelte.js';
97
97
  import { renderCircle, type ComputedStylesOptions } from '../utils/canvas.js';
98
- import { hasAnyDataProp, resolveDataProp, resolveColorProp, resolveGeoDataPair, resolveStyleProp } from '../utils/dataProp.js';
98
+ import {
99
+ hasAnyDataProp,
100
+ resolveDataProp,
101
+ resolveColorProp,
102
+ resolveGeoDataPair,
103
+ resolveStyleProp,
104
+ } from '../utils/dataProp.js';
99
105
  import { getGeoContext } from '../contexts/geo.js';
100
106
  import { chartDataArray } from '../utils/common.js';
101
107
  import type { SVGAttributes } from 'svelte/elements';
@@ -130,9 +136,7 @@
130
136
  const geo = getGeoContext();
131
137
 
132
138
  // Data to iterate over in data mode
133
- const resolvedData: any[] = $derived(
134
- dataMode ? (dataProp ?? chartDataArray(chartCtx.data)) : []
135
- );
139
+ const resolvedData: any[] = $derived(dataMode ? (dataProp ?? chartDataArray(chartCtx.data)) : []);
136
140
 
137
141
  // Resolve a single data item to pixel coordinates
138
142
  function resolveCircle(d: any) {
@@ -201,21 +205,16 @@
201
205
 
202
206
  const layerCtx = getLayerContext();
203
207
 
204
- const motionCx = createMotion(
205
- initialCx,
206
- () => (typeof cx === 'number' ? cx : 0),
207
- motion
208
- );
209
- const motionCy = createMotion(
210
- initialCy,
211
- () => (typeof cy === 'number' ? cy : 0),
212
- motion
213
- );
214
- const motionR = createMotion(
215
- initialR,
216
- () => (typeof r === 'number' ? r : 1),
217
- motion
218
- );
208
+ const motionCx = createMotion(initialCx, () => (typeof cx === 'number' ? cx : 0), motion);
209
+ const motionCy = createMotion(initialCy, () => (typeof cy === 'number' ? cy : 0), motion);
210
+ const motionR = createMotion(initialR, () => (typeof r === 'number' ? r : 1), motion);
211
+
212
+ const staticFill = $derived(typeof fill === 'string' ? fill : undefined);
213
+ const staticFillOpacity = $derived(typeof fillOpacity === 'number' ? fillOpacity : undefined);
214
+ const staticStroke = $derived(typeof stroke === 'string' ? stroke : undefined);
215
+ const staticStrokeWidth = $derived(typeof strokeWidth === 'number' ? strokeWidth : undefined);
216
+ const staticOpacity = $derived(typeof opacity === 'number' ? opacity : undefined);
217
+ const staticClassName = $derived(typeof className === 'string' ? className : undefined);
219
218
 
220
219
  // Style options (shared between pixel and data mode)
221
220
  function getStyleOptions(
@@ -228,16 +227,29 @@
228
227
  itemClass?: string | undefined
229
228
  ) {
230
229
  return styleOverrides
231
- ? merge({ styles: { strokeWidth: itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined) } }, styleOverrides)
230
+ ? merge(
231
+ {
232
+ styles: {
233
+ strokeWidth:
234
+ itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined),
235
+ },
236
+ },
237
+ styleOverrides
238
+ )
232
239
  : {
233
240
  styles: {
234
241
  fill: itemFill ?? fill,
235
- fillOpacity: itemFillOpacity ?? (typeof fillOpacity === 'number' ? fillOpacity : undefined),
242
+ fillOpacity:
243
+ itemFillOpacity ?? (typeof fillOpacity === 'number' ? fillOpacity : undefined),
236
244
  stroke: itemStroke ?? stroke,
237
- strokeWidth: itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined),
245
+ strokeWidth:
246
+ itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined),
238
247
  opacity: itemOpacity ?? (typeof opacity === 'number' ? opacity : undefined),
239
248
  },
240
- classes: cls('lc-circle', itemClass ?? (typeof className === 'string' ? className : undefined)),
249
+ classes: cls(
250
+ 'lc-circle',
251
+ itemClass ?? (typeof className === 'string' ? className : undefined)
252
+ ),
241
253
  style: restProps.style as string | undefined,
242
254
  };
243
255
  }
@@ -254,7 +266,15 @@
254
266
  const resolvedStrokeWidth = resolveStyleProp(strokeWidth, item.d);
255
267
  const resolvedOpacity = resolveStyleProp(opacity, item.d);
256
268
  const resolvedClass = resolveStyleProp(className, item.d);
257
- const styleOpts = getStyleOptions(styleOverrides, resolvedFill, resolvedStroke, resolvedFillOpacity, resolvedStrokeWidth, resolvedOpacity, resolvedClass);
269
+ const styleOpts = getStyleOptions(
270
+ styleOverrides,
271
+ resolvedFill,
272
+ resolvedStroke,
273
+ resolvedFillOpacity,
274
+ resolvedStrokeWidth,
275
+ resolvedOpacity,
276
+ resolvedClass
277
+ );
258
278
  renderCircle(ctx, item, styleOpts);
259
279
  }
260
280
  } else {
@@ -284,30 +304,33 @@
284
304
  color: typeof fill === 'string' ? fill : undefined,
285
305
  };
286
306
  },
287
- canvasRender: layerCtx === 'canvas' ? {
288
- render,
289
- events: {
290
- click: restProps.onclick,
291
- pointerdown: restProps.onpointerdown,
292
- pointerenter: restProps.onpointerenter,
293
- pointermove: restProps.onpointermove,
294
- pointerleave: restProps.onpointerleave,
295
- },
296
- deps: () => [
297
- dataMode,
298
- dataMode ? resolvedItems : null,
299
- motionCx.current,
300
- motionCy.current,
301
- motionR.current,
302
- fillKey!.current,
303
- fillOpacity,
304
- strokeKey!.current,
305
- strokeWidth,
306
- opacity,
307
- className,
308
- restProps.style,
309
- ],
310
- } : undefined,
307
+ canvasRender:
308
+ layerCtx === 'canvas'
309
+ ? {
310
+ render,
311
+ events: {
312
+ click: restProps.onclick,
313
+ pointerdown: restProps.onpointerdown,
314
+ pointerenter: restProps.onpointerenter,
315
+ pointermove: restProps.onpointermove,
316
+ pointerleave: restProps.onpointerleave,
317
+ },
318
+ deps: () => [
319
+ dataMode,
320
+ dataMode ? resolvedItems : null,
321
+ motionCx.current,
322
+ motionCy.current,
323
+ motionR.current,
324
+ fillKey!.current,
325
+ fillOpacity,
326
+ strokeKey!.current,
327
+ strokeWidth,
328
+ opacity,
329
+ className,
330
+ restProps.style,
331
+ ],
332
+ }
333
+ : undefined,
311
334
  });
312
335
  </script>
313
336
 
@@ -339,12 +362,12 @@
339
362
  cx={motionCx.current}
340
363
  cy={motionCy.current}
341
364
  r={motionR.current}
342
- fill={fill as string}
343
- fill-opacity={fillOpacity as number}
344
- stroke={stroke as string}
345
- stroke-width={strokeWidth as number}
346
- opacity={opacity as number}
347
- class={cls('lc-circle', className as string)}
365
+ fill={staticFill}
366
+ fill-opacity={staticFillOpacity}
367
+ stroke={staticStroke}
368
+ stroke-width={staticStrokeWidth}
369
+ opacity={staticOpacity}
370
+ class={cls('lc-circle', staticClassName)}
348
371
  {...restProps}
349
372
  />
350
373
  {/if}
@@ -382,13 +405,13 @@
382
405
  style:width="{motionR.current * 2}px"
383
406
  style:height="{motionR.current * 2}px"
384
407
  style:border-radius="50%"
385
- style:background-color={fill as string}
386
- style:opacity={opacity as number}
387
- style:border-width={strokeWidth as number}
388
- style:border-color={stroke as string}
408
+ style:background-color={staticFill}
409
+ style:opacity={staticOpacity}
410
+ style:border-width={staticStrokeWidth}
411
+ style:border-color={staticStroke}
389
412
  style:border-style="solid"
390
413
  style:transform="translate(-50%, -50%)"
391
- class={cls('lc-circle', className as string)}
414
+ class={cls('lc-circle', staticClassName)}
392
415
  {...restProps}
393
416
  >
394
417
  {@render children?.()}
@@ -0,0 +1,389 @@
1
+ <script lang="ts" module>
2
+ import type { Placement } from './types.js';
3
+ import { asAny, type Without } from '../utils/types.js';
4
+
5
+ export type CircleLegendPropsWithoutHTML = {
6
+ /**
7
+ * The scale to use for the legend. Defaults to the chart's `rScale`.
8
+ */
9
+ scale?: AnyScale;
10
+
11
+ /**
12
+ * The title of the legend.
13
+ *
14
+ * @default ''
15
+ */
16
+ title?: string;
17
+
18
+ /**
19
+ * The number of ticks to show.
20
+ *
21
+ * @default 4
22
+ */
23
+ ticks?: number;
24
+
25
+ /**
26
+ * Explicit tick values to show. Overrides `ticks`.
27
+ */
28
+ tickValues?: number[];
29
+
30
+ /**
31
+ * Format for the tick labels.
32
+ */
33
+ tickFormat?: FormatType | FormatConfig;
34
+
35
+ /**
36
+ * The font size of the tick labels.
37
+ *
38
+ * @default 10
39
+ */
40
+ tickFontSize?: number;
41
+
42
+ /**
43
+ * The font size of the title.
44
+ *
45
+ * @default 10
46
+ */
47
+ titleFontSize?: number;
48
+
49
+ /**
50
+ * Width reserved for the tick labels next to the circles.
51
+ *
52
+ * @default 40
53
+ */
54
+ labelWidth?: number;
55
+
56
+ /**
57
+ * Gap between the top of each circle and the leader line/label.
58
+ *
59
+ * @default 4
60
+ */
61
+ labelGap?: number;
62
+
63
+ /**
64
+ * Where to render the tick labels.
65
+ * - `'right'` / `'left'`: outside the circles with a leader line
66
+ * - `'inline'`: centered inside each circle, near the top
67
+ *
68
+ * @default 'right'
69
+ */
70
+ labelPlacement?: 'left' | 'right' | 'inline';
71
+
72
+ /**
73
+ * The placement of the legend.
74
+ */
75
+ placement?: Placement;
76
+
77
+ /**
78
+ * The fill color of the circles.
79
+ *
80
+ * @default 'none'
81
+ */
82
+ fill?: string;
83
+
84
+ /**
85
+ * The stroke color of the circles and leader lines.
86
+ *
87
+ * @default 'currentColor'
88
+ */
89
+ stroke?: string;
90
+
91
+ /**
92
+ * The stroke width of the circles.
93
+ *
94
+ * @default 1
95
+ */
96
+ strokeWidth?: number;
97
+
98
+ /**
99
+ * Value to indicate on the legend (e.g. the currently hovered data point).
100
+ * When set, a 50%-opacity filled circle is drawn at this value's radius.
101
+ * Defaults to auto-detecting from `ctx.tooltip.data` via the chart's
102
+ * radius accessor (`ctx.r`).
103
+ */
104
+ value?: number | null;
105
+
106
+ /**
107
+ * Classes to apply to the elements.
108
+ *
109
+ * @default {}
110
+ */
111
+ classes?: {
112
+ root?: string;
113
+ title?: string;
114
+ circle?: string;
115
+ tick?: string;
116
+ label?: string;
117
+ };
118
+
119
+ /**
120
+ * A bindable reference to the wrapping `<div>` element.
121
+ *
122
+ * @bindable
123
+ */
124
+ ref?: HTMLElement;
125
+ };
126
+
127
+ export type CircleLegendProps = CircleLegendPropsWithoutHTML &
128
+ Without<HTMLAttributes<HTMLElement>, CircleLegendPropsWithoutHTML>;
129
+ </script>
130
+
131
+ <script lang="ts">
132
+ import type { HTMLAttributes } from 'svelte/elements';
133
+ import { format, type FormatType, type FormatConfig } from '@layerstack/utils';
134
+
135
+ import { cls } from '@layerstack/tailwind';
136
+ import type { AnyScale } from '../utils/scales.svelte.js';
137
+ import { getChartContext } from '../contexts/chart.js';
138
+
139
+ let {
140
+ scale: scaleProp,
141
+ title = '',
142
+ ticks = 4,
143
+ tickValues: tickValuesProp,
144
+ tickFormat: tickFormatProp,
145
+ tickFontSize = 10,
146
+ titleFontSize = 10,
147
+ labelWidth = 40,
148
+ labelGap = 4,
149
+ labelPlacement = 'right',
150
+ placement,
151
+ fill = 'none',
152
+ stroke = 'currentColor',
153
+ strokeWidth = 1,
154
+ value: valueProp,
155
+ classes = {},
156
+ ref: refProp = $bindable(),
157
+ class: className,
158
+ ...restProps
159
+ }: CircleLegendProps = $props();
160
+
161
+ let ref = $state<HTMLElement>();
162
+ $effect.pre(() => {
163
+ refProp = ref;
164
+ });
165
+
166
+ const ctx = getChartContext();
167
+
168
+ const scale = $derived(scaleProp ?? ctx.rScale);
169
+
170
+ const tickValues = $derived.by(() => {
171
+ if (tickValuesProp) return tickValuesProp;
172
+ if (!scale) return [] as number[];
173
+
174
+ // Prefer scale.ticks (continuous scales) and pick the largest `ticks` positive values
175
+ if (typeof (scale as any).ticks === 'function') {
176
+ const all = ((scale as any).ticks(ticks) as number[]).filter((v) => Number(scale(v)) > 0);
177
+ if (all.length >= 2) {
178
+ return all.slice(-ticks);
179
+ }
180
+ }
181
+
182
+ // Fallback: derive evenly spaced values from the domain extent
183
+ const domain = scale.domain() as number[];
184
+ const min = Number(domain[0]);
185
+ const max = Number(domain[domain.length - 1]);
186
+ const n = Math.max(2, ticks);
187
+ return Array.from({ length: n }, (_, i) => min + ((max - min) * (i + 1)) / n);
188
+ });
189
+
190
+ const items = $derived.by(() => {
191
+ if (!scale) return [] as Array<{ value: number; radius: number }>;
192
+ return tickValues
193
+ .map((value) => ({ value, radius: Number(scale(value)) }))
194
+ .filter((d) => Number.isFinite(d.radius) && d.radius > 0)
195
+ .sort((a, b) => b.radius - a.radius);
196
+ });
197
+
198
+ const maxRadius = $derived(items[0]?.radius ?? 0);
199
+
200
+ // Indicator for the currently hovered value. If `value` is explicitly
201
+ // provided, use it; otherwise fall back to `ctx.tooltip.data` piped through
202
+ // the chart's radius accessor (`ctx.r`).
203
+ const indicatorRadius = $derived.by(() => {
204
+ if (!scale) return null;
205
+ let value: any = valueProp;
206
+ if (value == null) {
207
+ const data = ctx.tooltip?.data;
208
+ if (data == null) return null;
209
+ value = ctx.r?.(data);
210
+ }
211
+ if (value == null) return null;
212
+ const r = Number(scale(value));
213
+ if (!Number.isFinite(r) || r <= 0) return null;
214
+ return r;
215
+ });
216
+
217
+ const padding = $derived(Math.ceil(strokeWidth / 2));
218
+ const titleHeight = $derived(title ? titleFontSize + 6 : 0);
219
+ const width = $derived(
220
+ labelPlacement === 'inline'
221
+ ? maxRadius * 2 + padding * 2
222
+ : maxRadius * 2 + padding * 2 + labelGap + labelWidth
223
+ );
224
+ const svgHeight = $derived(maxRadius * 2 + padding * 2 + titleHeight);
225
+ const cx = $derived(
226
+ labelPlacement === 'left' ? labelWidth + labelGap + maxRadius + padding : maxRadius + padding
227
+ );
228
+ const baseY = $derived(maxRadius * 2 + padding + titleHeight);
229
+
230
+ // Leader line / label x positions (only used for left/right placement)
231
+ const labelLineX = $derived(
232
+ labelPlacement === 'left' ? cx - maxRadius - labelGap : cx + maxRadius + labelGap
233
+ );
234
+ const labelTextX = $derived(
235
+ labelPlacement === 'inline' ? cx : labelPlacement === 'left' ? labelLineX - 2 : labelLineX + 2
236
+ );
237
+ const labelTextAnchor = $derived(
238
+ labelPlacement === 'inline' ? 'middle' : labelPlacement === 'left' ? 'end' : 'start'
239
+ );
240
+ </script>
241
+
242
+ <div
243
+ bind:this={ref}
244
+ {...restProps}
245
+ data-placement={placement}
246
+ class={cls('lc-circle-legend-container', className, classes.root)}
247
+ >
248
+ {#if items.length}
249
+ <svg {width} height={svgHeight} viewBox="0 0 {width} {svgHeight}" class="lc-circle-legend-svg">
250
+ {#if title}
251
+ <text
252
+ x={cx}
253
+ y={titleFontSize}
254
+ text-anchor="middle"
255
+ style:font-size={titleFontSize}
256
+ class={cls('lc-circle-legend-title', classes.title)}
257
+ >
258
+ {title}
259
+ </text>
260
+ {/if}
261
+ <g class="lc-circle-legend-g">
262
+ {#if indicatorRadius != null}
263
+ <circle
264
+ {cx}
265
+ cy={baseY - indicatorRadius}
266
+ r={indicatorRadius}
267
+ fill={stroke}
268
+ fill-opacity="0.5"
269
+ class={cls('lc-circle-legend-indicator')}
270
+ />
271
+ {/if}
272
+ {#each items as item (item.value)}
273
+ <circle
274
+ {cx}
275
+ cy={baseY - item.radius}
276
+ r={item.radius}
277
+ {fill}
278
+ {stroke}
279
+ stroke-width={strokeWidth}
280
+ class={cls('lc-circle-legend-circle', classes.circle)}
281
+ />
282
+ {#if labelPlacement !== 'inline'}
283
+ <line
284
+ x1={cx}
285
+ y1={baseY - item.radius * 2}
286
+ x2={labelLineX}
287
+ y2={baseY - item.radius * 2}
288
+ {stroke}
289
+ stroke-dasharray="2,2"
290
+ class={cls('lc-circle-legend-tick', classes.tick)}
291
+ />
292
+ {/if}
293
+ <text
294
+ x={labelTextX}
295
+ y={labelPlacement === 'inline'
296
+ ? baseY - item.radius * 2 + tickFontSize
297
+ : baseY - item.radius * 2}
298
+ text-anchor={labelTextAnchor}
299
+ dominant-baseline={labelPlacement === 'inline' ? 'auto' : 'middle'}
300
+ style:font-size={tickFontSize}
301
+ class={cls('lc-circle-legend-label', classes.label)}
302
+ >
303
+ {tickFormatProp ? format(item.value, asAny(tickFormatProp)) : item.value}
304
+ </text>
305
+ {/each}
306
+ </g>
307
+ </svg>
308
+ {/if}
309
+ </div>
310
+
311
+ <style>
312
+ @layer components {
313
+ :where(.lc-circle-legend-container) {
314
+ display: inline-block;
315
+ z-index: 1;
316
+
317
+ &[data-placement] {
318
+ position: absolute;
319
+ }
320
+
321
+ &[data-placement='top-left'] {
322
+ top: 0;
323
+ left: 0;
324
+ }
325
+ &[data-placement='top'] {
326
+ top: 0;
327
+ left: 50%;
328
+ transform: translateX(-50%);
329
+ }
330
+ &[data-placement='top-right'] {
331
+ top: 0;
332
+ right: 0;
333
+ }
334
+ &[data-placement='left'] {
335
+ top: 50%;
336
+ left: 0;
337
+ transform: translateY(-50%);
338
+ }
339
+ &[data-placement='center'] {
340
+ top: 50%;
341
+ left: 50%;
342
+ transform: translate(-50%, -50%);
343
+ }
344
+ &[data-placement='right'] {
345
+ top: 50%;
346
+ right: 0;
347
+ transform: translateY(-50%);
348
+ }
349
+ &[data-placement='bottom-left'] {
350
+ bottom: 0;
351
+ left: 0;
352
+ }
353
+ &[data-placement='bottom'] {
354
+ bottom: 0;
355
+ left: 50%;
356
+ transform: translateX(-50%);
357
+ }
358
+ &[data-placement='bottom-right'] {
359
+ bottom: 0;
360
+ right: 0;
361
+ }
362
+ }
363
+
364
+ :where(.lc-circle-legend-title) {
365
+ font-weight: 600;
366
+ fill: var(--color-surface-content, currentColor);
367
+ }
368
+
369
+ :where(.lc-circle-legend-svg) {
370
+ overflow: visible;
371
+ }
372
+
373
+ :where(.lc-circle-legend-label) {
374
+ fill: var(--color-surface-content, currentColor);
375
+ }
376
+
377
+ :where(.lc-circle-legend-tick) {
378
+ stroke: var(--color-surface-content, currentColor);
379
+ }
380
+
381
+ :where(.lc-circle-legend-circle) {
382
+ stroke: var(--color-surface-content, currentColor);
383
+ }
384
+
385
+ :where(.lc-circle-legend-indicator) {
386
+ fill: var(--color-surface-content, currentColor);
387
+ }
388
+ }
389
+ </style>