layerchart 2.0.0-next.51 → 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.
@@ -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>
@@ -0,0 +1,114 @@
1
+ import type { Placement } from './types.js';
2
+ import { type Without } from '../utils/types.js';
3
+ export type CircleLegendPropsWithoutHTML = {
4
+ /**
5
+ * The scale to use for the legend. Defaults to the chart's `rScale`.
6
+ */
7
+ scale?: AnyScale;
8
+ /**
9
+ * The title of the legend.
10
+ *
11
+ * @default ''
12
+ */
13
+ title?: string;
14
+ /**
15
+ * The number of ticks to show.
16
+ *
17
+ * @default 4
18
+ */
19
+ ticks?: number;
20
+ /**
21
+ * Explicit tick values to show. Overrides `ticks`.
22
+ */
23
+ tickValues?: number[];
24
+ /**
25
+ * Format for the tick labels.
26
+ */
27
+ tickFormat?: FormatType | FormatConfig;
28
+ /**
29
+ * The font size of the tick labels.
30
+ *
31
+ * @default 10
32
+ */
33
+ tickFontSize?: number;
34
+ /**
35
+ * The font size of the title.
36
+ *
37
+ * @default 10
38
+ */
39
+ titleFontSize?: number;
40
+ /**
41
+ * Width reserved for the tick labels next to the circles.
42
+ *
43
+ * @default 40
44
+ */
45
+ labelWidth?: number;
46
+ /**
47
+ * Gap between the top of each circle and the leader line/label.
48
+ *
49
+ * @default 4
50
+ */
51
+ labelGap?: number;
52
+ /**
53
+ * Where to render the tick labels.
54
+ * - `'right'` / `'left'`: outside the circles with a leader line
55
+ * - `'inline'`: centered inside each circle, near the top
56
+ *
57
+ * @default 'right'
58
+ */
59
+ labelPlacement?: 'left' | 'right' | 'inline';
60
+ /**
61
+ * The placement of the legend.
62
+ */
63
+ placement?: Placement;
64
+ /**
65
+ * The fill color of the circles.
66
+ *
67
+ * @default 'none'
68
+ */
69
+ fill?: string;
70
+ /**
71
+ * The stroke color of the circles and leader lines.
72
+ *
73
+ * @default 'currentColor'
74
+ */
75
+ stroke?: string;
76
+ /**
77
+ * The stroke width of the circles.
78
+ *
79
+ * @default 1
80
+ */
81
+ strokeWidth?: number;
82
+ /**
83
+ * Value to indicate on the legend (e.g. the currently hovered data point).
84
+ * When set, a 50%-opacity filled circle is drawn at this value's radius.
85
+ * Defaults to auto-detecting from `ctx.tooltip.data` via the chart's
86
+ * radius accessor (`ctx.r`).
87
+ */
88
+ value?: number | null;
89
+ /**
90
+ * Classes to apply to the elements.
91
+ *
92
+ * @default {}
93
+ */
94
+ classes?: {
95
+ root?: string;
96
+ title?: string;
97
+ circle?: string;
98
+ tick?: string;
99
+ label?: string;
100
+ };
101
+ /**
102
+ * A bindable reference to the wrapping `<div>` element.
103
+ *
104
+ * @bindable
105
+ */
106
+ ref?: HTMLElement;
107
+ };
108
+ export type CircleLegendProps = CircleLegendPropsWithoutHTML & Without<HTMLAttributes<HTMLElement>, CircleLegendPropsWithoutHTML>;
109
+ import type { HTMLAttributes } from 'svelte/elements';
110
+ import { type FormatType, type FormatConfig } from '@layerstack/utils';
111
+ import type { AnyScale } from '../utils/scales.svelte.js';
112
+ declare const CircleLegend: import("svelte").Component<CircleLegendProps, {}, "ref">;
113
+ type CircleLegend = ReturnType<typeof CircleLegend>;
114
+ export default CircleLegend;