layerchart 2.0.0-next.48 → 2.0.0-next.49

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 (65) hide show
  1. package/dist/bench/PrimitiveBench.svelte +66 -0
  2. package/dist/bench/PrimitiveBench.svelte.d.ts +10 -0
  3. package/dist/bench/primitives.svelte.bench.d.ts +1 -0
  4. package/dist/bench/primitives.svelte.bench.js +42 -0
  5. package/dist/components/Axis.svelte +14 -3
  6. package/dist/components/Axis.svelte.d.ts +1 -1
  7. package/dist/components/Chart.svelte +110 -12
  8. package/dist/components/Circle.svelte +20 -17
  9. package/dist/components/Contour.svelte +90 -13
  10. package/dist/components/Contour.svelte.d.ts +8 -0
  11. package/dist/components/Ellipse.svelte +18 -16
  12. package/dist/components/GeoPath.svelte +1 -1
  13. package/dist/components/Group.svelte +14 -12
  14. package/dist/components/Image.svelte +18 -16
  15. package/dist/components/Labels.svelte +56 -11
  16. package/dist/components/Labels.svelte.d.ts +3 -2
  17. package/dist/components/Line.svelte +18 -16
  18. package/dist/components/LinearGradient.svelte +1 -1
  19. package/dist/components/Marker.svelte +8 -3
  20. package/dist/components/Marker.svelte.d.ts +1 -1
  21. package/dist/components/Month.svelte +273 -0
  22. package/dist/components/Month.svelte.d.ts +70 -0
  23. package/dist/components/Path.svelte +28 -12
  24. package/dist/components/Polygon.svelte +25 -23
  25. package/dist/components/RadialGradient.svelte +1 -1
  26. package/dist/components/Raster.svelte +117 -29
  27. package/dist/components/Raster.svelte.d.ts +8 -0
  28. package/dist/components/Rect.svelte +26 -20
  29. package/dist/components/Spline.svelte +123 -25
  30. package/dist/components/Spline.svelte.d.ts +18 -1
  31. package/dist/components/Text.svelte +45 -20
  32. package/dist/components/Text.svelte.d.ts +6 -0
  33. package/dist/components/TransformContext.svelte +8 -0
  34. package/dist/components/TransformContext.svelte.test.d.ts +1 -0
  35. package/dist/components/TransformContext.svelte.test.js +166 -0
  36. package/dist/components/Vector.svelte +14 -12
  37. package/dist/components/index.d.ts +2 -0
  38. package/dist/components/index.js +2 -0
  39. package/dist/components/tests/TransformTestHarness.svelte +27 -0
  40. package/dist/components/tests/TransformTestHarness.svelte.d.ts +8 -0
  41. package/dist/states/__screenshots__/chart.component-node.svelte.test.ts/ChartState-registerComponent-cleans-up-child-nodes-and-mark-registrations-when-components-unmount-1.png +0 -0
  42. package/dist/states/__screenshots__/chart.component-node.svelte.test.ts/ChartState-registerComponent-cleans-up-child-nodes-and-mark-registrations-when-components-unmount-2.png +0 -0
  43. package/dist/states/brush.svelte.d.ts +26 -17
  44. package/dist/states/brush.svelte.js +118 -25
  45. package/dist/states/brush.svelte.test.js +126 -1
  46. package/dist/states/chart.svelte.d.ts +6 -0
  47. package/dist/states/chart.svelte.js +93 -20
  48. package/dist/states/transform.svelte.js +3 -1
  49. package/dist/utils/dataProp.d.ts +2 -10
  50. package/dist/utils/dataProp.js +16 -5
  51. package/dist/utils/index.d.ts +1 -0
  52. package/dist/utils/index.js +1 -0
  53. package/dist/utils/motion.svelte.d.ts +12 -2
  54. package/dist/utils/motion.svelte.js +22 -0
  55. package/dist/utils/motion.test.js +49 -1
  56. package/dist/utils/rasterBounds.d.ts +18 -0
  57. package/dist/utils/rasterBounds.js +98 -0
  58. package/dist/utils/rasterBounds.test.d.ts +1 -0
  59. package/dist/utils/rasterBounds.test.js +63 -0
  60. package/dist/utils/scales.svelte.js +4 -2
  61. package/dist/utils/scales.svelte.test.d.ts +1 -0
  62. package/dist/utils/scales.svelte.test.js +67 -0
  63. package/dist/utils/ticks.js +7 -3
  64. package/dist/utils/ticks.test.js +13 -3
  65. package/package.json +3 -2
@@ -151,7 +151,7 @@
151
151
  <Path
152
152
  {pathData}
153
153
  {...restProps}
154
- onclick={_onClick}
154
+ onclick={onclick ? _onClick : undefined}
155
155
  onpointerenter={tooltip || onpointerenter ? _onPointerEnter : undefined}
156
156
  onpointermove={tooltip || onpointermove ? _onPointerMove : undefined}
157
157
  onpointerleave={tooltip || onpointerleave ? _onPointerLeave : undefined}
@@ -156,18 +156,20 @@
156
156
  // --- Data mode motion ---
157
157
  const dataMotionMap = createDataMotionMap(motion);
158
158
 
159
- $effect(() => {
160
- if (!dataMode || !dataMotionMap) return;
161
- const activeKeys = new Set<any>();
162
- for (let i = 0; i < resolvedData.length; i++) {
163
- const d = resolvedData[i];
164
- const key = keyFn(d, i);
165
- activeKeys.add(key);
166
- const resolved = resolveGroup(d);
167
- untrack(() => dataMotionMap.update(key, resolved));
168
- }
169
- untrack(() => dataMotionMap.cleanup(activeKeys));
170
- });
159
+ if (dataMotionMap) {
160
+ $effect(() => {
161
+ if (!dataMode) return;
162
+ const activeKeys = new Set<any>();
163
+ for (let i = 0; i < resolvedData.length; i++) {
164
+ const d = resolvedData[i];
165
+ const key = keyFn(d, i);
166
+ activeKeys.add(key);
167
+ const resolved = resolveGroup(d);
168
+ untrack(() => dataMotionMap.update(key, resolved));
169
+ }
170
+ untrack(() => dataMotionMap.cleanup(activeKeys));
171
+ });
172
+ }
171
173
 
172
174
  // Single source of truth: resolved values with animated overlay
173
175
  const resolvedItems = $derived.by(() => {
@@ -238,18 +238,20 @@
238
238
  // --- Data mode motion ---
239
239
  const dataMotionMap = createDataMotionMap(motion as MotionOptions | undefined);
240
240
 
241
- $effect(() => {
242
- if (!dataMode || !dataMotionMap) return;
243
- const activeKeys = new Set<any>();
244
- for (let i = 0; i < resolvedData.length; i++) {
245
- const d = resolvedData[i];
246
- const key = keyFn(d, i);
247
- activeKeys.add(key);
248
- const resolved = resolveImage(d);
249
- untrack(() => dataMotionMap.update(key, { x: resolved.x, y: resolved.y, width: resolved.width, height: resolved.height }));
250
- }
251
- untrack(() => dataMotionMap.cleanup(activeKeys));
252
- });
241
+ if (dataMotionMap) {
242
+ $effect(() => {
243
+ if (!dataMode) return;
244
+ const activeKeys = new Set<any>();
245
+ for (let i = 0; i < resolvedData.length; i++) {
246
+ const d = resolvedData[i];
247
+ const key = keyFn(d, i);
248
+ activeKeys.add(key);
249
+ const resolved = resolveImage(d);
250
+ untrack(() => dataMotionMap.update(key, { x: resolved.x, y: resolved.y, width: resolved.width, height: resolved.height }));
251
+ }
252
+ untrack(() => dataMotionMap.cleanup(activeKeys));
253
+ });
254
+ }
253
255
 
254
256
  // Single source of truth: resolved values with animated overlay
255
257
  const resolvedItems = $derived.by(() => {
@@ -292,22 +294,22 @@
292
294
  const motionX = createMotion(
293
295
  _initialX,
294
296
  () => (typeof x === 'number' ? x : 0),
295
- parseMotionProp(motion, 'x')
297
+ motion === undefined ? undefined : parseMotionProp(motion, 'x')
296
298
  );
297
299
  const motionY = createMotion(
298
300
  _initialY,
299
301
  () => (typeof y === 'number' ? y : 0),
300
- parseMotionProp(motion, 'y')
302
+ motion === undefined ? undefined : parseMotionProp(motion, 'y')
301
303
  );
302
304
  const motionWidth = createMotion(
303
305
  _initialWidth,
304
306
  () => resolvedPixelWidth,
305
- parseMotionProp(motion, 'width')
307
+ motion === undefined ? undefined : parseMotionProp(motion, 'width')
306
308
  );
307
309
  const motionHeight = createMotion(
308
310
  _initialHeight,
309
311
  () => resolvedPixelHeight,
310
- parseMotionProp(motion, 'height')
312
+ motion === undefined ? undefined : parseMotionProp(motion, 'height')
311
313
  );
312
314
 
313
315
  // Pixel mode r and rotate (only when direct number values)
@@ -41,10 +41,11 @@
41
41
  seriesKey?: string;
42
42
 
43
43
  /**
44
- * The placement of the label relative to the point
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
46
  * @default 'outside'
46
47
  */
47
- placement?: 'inside' | 'outside' | 'center';
48
+ placement?: 'inside' | 'outside' | 'center' | 'smart';
48
49
 
49
50
  /**
50
51
  * The offset of the label from the point
@@ -114,12 +115,11 @@
114
115
  : 0.1)
115
116
  );
116
117
 
117
- function getTextProps(point: Point): ComponentProps<typeof Text> {
118
+ function getTextProps(point: Point, points?: Point[], i?: number): ComponentProps<typeof Text> {
118
119
  // Used for positioning direction.
119
120
  // For array accessors (edgeIndex defined), use edge position: 0 = start/low, 1 = end/high
120
121
  const pointValue = isScaleBand(ctx.yScale) ? point.xValue : point.yValue;
121
- const isLowEdge =
122
- point.edgeIndex != null ? point.edgeIndex === 0 : pointValue < 0;
122
+ const isLowEdge = point.edgeIndex != null ? point.edgeIndex === 0 : pointValue < 0;
123
123
 
124
124
  // extract the true fill value from `fill` which could be an
125
125
  // accessor function or string/undefined
@@ -142,11 +142,13 @@
142
142
  : ctx.yScale.tickFormat?.())
143
143
  );
144
144
 
145
+ let result: ComponentProps<typeof Text>;
146
+
145
147
  if (isScaleBand(ctx.yScale)) {
146
148
  // Position label left/right on horizontal bars
147
149
  if (isLowEdge) {
148
150
  // left
149
- return {
151
+ result = {
150
152
  value: formattedValue,
151
153
  fill: fillValue,
152
154
  x: point.x + (placement === 'outside' ? -offset : offset),
@@ -157,7 +159,7 @@
157
159
  };
158
160
  } else {
159
161
  // right
160
- return {
162
+ result = {
161
163
  value: formattedValue,
162
164
  fill: fillValue,
163
165
  x: point.x + (placement === 'outside' ? offset : -offset),
@@ -171,7 +173,7 @@
171
173
  // Position label top/bottom on vertical bars
172
174
  if (isLowEdge) {
173
175
  // bottom
174
- return {
176
+ result = {
175
177
  value: formattedValue,
176
178
  fill: fillValue,
177
179
  x: point.x,
@@ -183,7 +185,7 @@
183
185
  };
184
186
  } else {
185
187
  // top
186
- return {
188
+ result = {
187
189
  value: formattedValue,
188
190
  fill: fillValue,
189
191
  x: point.x,
@@ -195,6 +197,48 @@
195
197
  };
196
198
  }
197
199
  }
200
+
201
+ if (placement === 'smart' && points != null && i != null) {
202
+ const getValue = (p: Point): number => (isScaleBand(ctx.yScale) ? p.xValue : p.yValue);
203
+ const curr = getValue(point);
204
+ const prev = i > 0 ? getValue(points[i - 1]) : curr;
205
+ const next = i < points.length - 1 ? getValue(points[i + 1]) : curr;
206
+
207
+ const xPrevTight = Math.abs(prev - curr) < offset;
208
+ const xNextTight = Math.abs(curr - next) < offset;
209
+ const isPeak = (prev <= curr && curr >= next) || (xPrevTight && xNextTight);
210
+ const isTrough = (prev >= curr && curr <= next) || (xPrevTight && xNextTight);
211
+ const isRising = !isPeak && !isTrough && prev < curr;
212
+ const isFalling = !isPeak && !isTrough && prev >= curr;
213
+
214
+ return {
215
+ ...result,
216
+ x: point.x,
217
+ y: point.y,
218
+ dx: isRising
219
+ ? xPrevTight
220
+ ? offset
221
+ : -offset
222
+ : isFalling
223
+ ? xNextTight
224
+ ? -offset
225
+ : offset
226
+ : 0,
227
+ dy: isPeak ? -offset : isTrough ? offset : 0,
228
+ textAnchor: isRising
229
+ ? xPrevTight
230
+ ? 'start'
231
+ : 'end'
232
+ : isFalling
233
+ ? xNextTight
234
+ ? 'end'
235
+ : 'start'
236
+ : 'middle',
237
+ verticalAnchor: isPeak ? 'end' : isTrough ? 'start' : 'middle',
238
+ };
239
+ }
240
+
241
+ return result;
198
242
  }
199
243
  </script>
200
244
 
@@ -202,7 +246,8 @@
202
246
  <Points {data} {x} {y} {seriesKey}>
203
247
  {#snippet children({ points })}
204
248
  {#each points as point, i (key(point.data, i))}
205
- {@const textProps = extractLayerProps(getTextProps(point), 'lc-labels-text')}
249
+ {@const baseProps = getTextProps(point, points, i)}
250
+ {@const textProps = extractLayerProps(baseProps, 'lc-labels-text')}
206
251
  {#if childrenProp}
207
252
  {@render childrenProp({ data: point, textProps })}
208
253
  {:else}
@@ -210,7 +255,7 @@
210
255
  data-placement={placement}
211
256
  {...textProps}
212
257
  {...restProps}
213
- {...extractLayerProps(getTextProps(point), 'lc-labels-text', className ?? '')}
258
+ {...extractLayerProps(baseProps, 'lc-labels-text', className ?? '')}
214
259
  />
215
260
  {/if}
216
261
  {/each}
@@ -33,10 +33,11 @@ export type LabelsPropsWithoutHTML<T = any> = {
33
33
  */
34
34
  seriesKey?: string;
35
35
  /**
36
- * The placement of the label relative to the point
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
38
  * @default 'outside'
38
39
  */
39
- placement?: 'inside' | 'outside' | 'center';
40
+ placement?: 'inside' | 'outside' | 'center' | 'smart';
40
41
  /**
41
42
  * The offset of the label from the point
42
43
  *
@@ -189,18 +189,20 @@
189
189
  // --- Data mode motion ---
190
190
  const dataMotionMap = createDataMotionMap(motion);
191
191
 
192
- $effect(() => {
193
- if (!dataMode || !dataMotionMap) return;
194
- const activeKeys = new Set<any>();
195
- for (let i = 0; i < resolvedData.length; i++) {
196
- const d = resolvedData[i];
197
- const key = keyFn(d, i);
198
- activeKeys.add(key);
199
- const resolved = resolveLine(d);
200
- untrack(() => dataMotionMap.update(key, resolved));
201
- }
202
- untrack(() => dataMotionMap.cleanup(activeKeys));
203
- });
192
+ if (dataMotionMap) {
193
+ $effect(() => {
194
+ if (!dataMode) return;
195
+ const activeKeys = new Set<any>();
196
+ for (let i = 0; i < resolvedData.length; i++) {
197
+ const d = resolvedData[i];
198
+ const key = keyFn(d, i);
199
+ activeKeys.add(key);
200
+ const resolved = resolveLine(d);
201
+ untrack(() => dataMotionMap.update(key, resolved));
202
+ }
203
+ untrack(() => dataMotionMap.cleanup(activeKeys));
204
+ });
205
+ }
204
206
 
205
207
  // Single source of truth: resolved values with animated overlay
206
208
  const resolvedItems = $derived.by(() => {
@@ -301,8 +303,8 @@
301
303
  }
302
304
  }
303
305
 
304
- const fillKey = createKey(() => fill);
305
- const strokeKey = createKey(() => stroke);
306
+ const fillKey = layerCtx === 'canvas' ? createKey(() => fill) : undefined;
307
+ const strokeKey = layerCtx === 'canvas' ? createKey(() => stroke) : undefined;
306
308
 
307
309
  chartCtx.registerComponent({
308
310
  name: 'Line',
@@ -331,8 +333,8 @@
331
333
  motionY1.current,
332
334
  motionX2.current,
333
335
  motionY2.current,
334
- fillKey.current,
335
- strokeKey.current,
336
+ fillKey!.current,
337
+ strokeKey!.current,
336
338
  strokeWidth,
337
339
  opacity,
338
340
  className,
@@ -174,7 +174,7 @@
174
174
  }
175
175
 
176
176
  if (layerCtx === 'canvas') {
177
- ctx.registerComponent({ name: 'Gradient', kind: 'mark', canvasRender: {
177
+ ctx.registerComponent({ name: 'Gradient', kind: 'group', canvasRender: {
178
178
  render,
179
179
  deps: () => [x1, y1, x2, y2, stops, className],
180
180
  } });
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Pass `children` to render a custom element/component inside the marker instead.
10
10
  */
11
- type?: 'arrow' | 'triangle' | 'line' | 'circle' | 'circle-stroke' | 'dot';
11
+ type?: 'arrow' | 'triangle' | 'line' | 'circle' | 'circle-stroke' | 'dot' | 'square' | 'square-stroke';
12
12
 
13
13
  /**
14
14
  * Unique identifier for the marker
@@ -117,6 +117,8 @@
117
117
  <circle cx={5} cy={5} r={5} class="lc-marker-circle" />
118
118
  {:else if type === 'line'}
119
119
  <polyline points="5 0, 5 10" class="lc-marker-line" />
120
+ {:else if type === 'square' || type === 'square-stroke'}
121
+ <rect x={0} y={0} width={10} height={10} class="lc-marker-square" />
120
122
  {/if}
121
123
  </marker>
122
124
  </defs>
@@ -128,6 +130,7 @@
128
130
 
129
131
  &[data-type='arrow'],
130
132
  &[data-type='circle-stroke'],
133
+ &[data-type='square-stroke'],
131
134
  &[data-type='line'] {
132
135
  fill: none;
133
136
  stroke: context-stroke;
@@ -141,11 +144,13 @@
141
144
 
142
145
  &[data-type='triangle'],
143
146
  &[data-type='dot'],
144
- &[data-type='circle'] {
147
+ &[data-type='circle'],
148
+ &[data-type='square'] {
145
149
  fill: context-stroke;
146
150
  }
147
151
 
148
- &[data-type='circle-stroke'] {
152
+ &[data-type='circle-stroke'],
153
+ &[data-type='square-stroke'] {
149
154
  fill: var(--color-surface-100, light-dark(white, black));
150
155
  }
151
156
  }
@@ -6,7 +6,7 @@ export type MarkerPropsWithoutHTML = {
6
6
  *
7
7
  * Pass `children` to render a custom element/component inside the marker instead.
8
8
  */
9
- type?: 'arrow' | 'triangle' | 'line' | 'circle' | 'circle-stroke' | 'dot';
9
+ type?: 'arrow' | 'triangle' | 'line' | 'circle' | 'circle-stroke' | 'dot' | 'square' | 'square-stroke';
10
10
  /**
11
11
  * Unique identifier for the marker
12
12
  */
@@ -0,0 +1,273 @@
1
+ <script lang="ts" module>
2
+ export type MonthCell = {
3
+ x: number;
4
+ y: number;
5
+ color: any;
6
+ data: any;
7
+ date: Date;
8
+ };
9
+
10
+ export type MonthPropsWithoutHTML = {
11
+ /**
12
+ * The start date of the calendar.
13
+ */
14
+ start: Date;
15
+
16
+ /**
17
+ * The end date of the calendar.
18
+ */
19
+ end: Date;
20
+
21
+ /**
22
+ * Size of the cell in the calendar.
23
+ *
24
+ * @default 25
25
+ */
26
+ cellSize?: number;
27
+
28
+ /**
29
+ * Number of months to display per row. If undefined, automatically calculated based on available width.
30
+ */
31
+ monthsPerRow?: number;
32
+
33
+ /**
34
+ * Padding multiplier between months (relative to cellSize).
35
+ *
36
+ * @default 1.2
37
+ */
38
+ monthPadding?: number;
39
+
40
+ /**
41
+ * Vertical spacing multiplier between month rows (in number of cell heights).
42
+ *
43
+ * @default 8
44
+ */
45
+ rowSpacing?: number;
46
+
47
+ /**
48
+ * Whether to show the day number in each cell.
49
+ *
50
+ * @default true
51
+ */
52
+ showDayNumber?: boolean;
53
+
54
+ /**
55
+ * Props to pass to the `<text>` element for month labels.
56
+ */
57
+ monthLabel?: boolean | Partial<ComponentProps<typeof Text>>;
58
+
59
+ /**
60
+ * Props to pass to the `<text>` element for day numbers.
61
+ */
62
+ dayNumberProps?: Partial<ComponentProps<typeof Text>>;
63
+
64
+ /**
65
+ * Setup pointer events to show tooltip for related data
66
+ */
67
+ tooltip?: boolean;
68
+
69
+ children?: Snippet<[{ cells: MonthCell[]; cellSize: number }]>;
70
+ } & Omit<
71
+ RectPropsWithoutHTML,
72
+ 'children' | 'x' | 'y' | 'width' | 'height' | 'fill' | 'onpointermove' | 'onpointerleave'
73
+ >;
74
+
75
+ export type MonthProps = MonthPropsWithoutHTML &
76
+ Without<SVGAttributes<SVGRectElement>, MonthPropsWithoutHTML>;
77
+ </script>
78
+
79
+ <script lang="ts">
80
+ import { type ComponentProps, type Snippet } from 'svelte';
81
+ import { timeDays, timeMonths, timeWeek } from 'd3-time';
82
+ import { index } from 'd3-array';
83
+ import { format } from '@layerstack/utils';
84
+
85
+ import Rect, { type RectPropsWithoutHTML } from './Rect.svelte';
86
+ import Group from './Group.svelte';
87
+ import Text from './Text.svelte';
88
+ import { chartDataArray } from '../utils/common.js';
89
+ import { getChartContext } from '../contexts/chart.js';
90
+ import type { SVGAttributes } from 'svelte/elements';
91
+ import type { Without } from '../utils/types.js';
92
+ import { extractLayerProps } from '../utils/attributes.js';
93
+
94
+ const DAYS_PER_WEEK = 7;
95
+
96
+ let {
97
+ start,
98
+ end,
99
+ cellSize = 25,
100
+ monthsPerRow: monthsPerRowProp,
101
+ monthPadding = 1.2,
102
+ rowSpacing = 8,
103
+ showDayNumber = true,
104
+ monthLabel = true,
105
+ dayNumberProps = {},
106
+ tooltip,
107
+ children,
108
+ ...restProps
109
+ }: MonthPropsWithoutHTML = $props();
110
+
111
+ const ctx = getChartContext();
112
+
113
+ const rangeDays = $derived(timeDays(start, end));
114
+
115
+ // Space needed for month labels at the top (only if labels are shown)
116
+ const monthLabelHeight = $derived(monthLabel ? cellSize : 0);
117
+
118
+ // Calculate monthsPerRow based on the actual space taken by each month
119
+ // Each month (except the last in a row) takes: (monthPadding * cellSize * DAYS_PER_WEEK)
120
+ // The calculation accounts for n-1 padded months plus one unpadded month
121
+ // Formula: (n-1) * monthPadding * width + width = totalWidth
122
+ // Solving for n: n = (totalWidth + (monthPadding - 1) * width) / (monthPadding * width)
123
+ const monthsPerRow = $derived(
124
+ monthsPerRowProp ??
125
+ Math.floor(
126
+ (ctx.width + (monthPadding - 1) * cellSize * DAYS_PER_WEEK) /
127
+ (monthPadding * cellSize * DAYS_PER_WEEK)
128
+ )
129
+ );
130
+
131
+ // Generate data indexed by date (using date object as key)
132
+ const dataByDate = $derived(
133
+ ctx.data && ctx.config.x ? index(chartDataArray(ctx.data), (d) => ctx.x(d)) : new Map()
134
+ );
135
+
136
+ // Generate cells for the date range
137
+ const allCells = $derived.by(() => {
138
+ const cells: MonthCell[] = [];
139
+ // Create a map of month index to track which months we've seen
140
+ const monthIndexMap = new Map<string, number>();
141
+ let currentMonthIndex = 0;
142
+
143
+ rangeDays.forEach((day) => {
144
+ const firstDayOfMonth = new Date(day.getFullYear(), day.getMonth(), 1);
145
+ const monthKey = `${day.getFullYear()}-${day.getMonth()}`;
146
+
147
+ // Assign a sequential index to each unique month in the range
148
+ if (!monthIndexMap.has(monthKey)) {
149
+ monthIndexMap.set(monthKey, currentMonthIndex);
150
+ currentMonthIndex++;
151
+ }
152
+
153
+ const monthIndex = monthIndexMap.get(monthKey)!;
154
+ const cellData = dataByDate.get(day) ?? { date: day };
155
+
156
+ const monthCol = monthIndex % monthsPerRow;
157
+ const monthRow = Math.floor(monthIndex / monthsPerRow);
158
+
159
+ const monthPaddingOffset = monthPadding * cellSize * DAYS_PER_WEEK * monthCol;
160
+ const weekDiff = timeWeek.count(firstDayOfMonth, day);
161
+
162
+ cells.push({
163
+ x: day.getDay() * cellSize + monthPaddingOffset,
164
+ y: weekDiff * cellSize + monthRow * cellSize * rowSpacing + monthLabelHeight,
165
+ color: ctx.config.c ? ctx.cGet(cellData) : 'transparent',
166
+ data: cellData,
167
+ date: day,
168
+ });
169
+ });
170
+
171
+ return { cells, monthIndexMap };
172
+ });
173
+
174
+ // Generate month labels based on the actual months encountered in the cells
175
+ const monthLabels = $derived.by(() => {
176
+ const labels: Array<{ x: number; y: number; text: string }> = [];
177
+ const monthIndexMap = allCells.monthIndexMap;
178
+
179
+ // Convert the map to an array of [monthKey, index] pairs and sort by index
180
+ const monthEntries = Array.from(monthIndexMap.entries()).sort((a, b) => a[1] - b[1]);
181
+
182
+ monthEntries.forEach(([monthKey, index]) => {
183
+ // Parse the monthKey to get the year and month
184
+ const [year, month] = monthKey.split('-').map(Number);
185
+ const firstDayOfMonth = new Date(year, month, 1);
186
+
187
+ const monthCol = index % monthsPerRow;
188
+ const monthRow = Math.floor(index / monthsPerRow);
189
+
190
+ const monthPaddingOffset = monthPadding * cellSize * DAYS_PER_WEEK * monthCol;
191
+
192
+ labels.push({
193
+ x: monthPaddingOffset,
194
+ y: monthRow * cellSize * rowSpacing,
195
+ text: format(firstDayOfMonth, 'month'),
196
+ });
197
+ });
198
+
199
+ return labels;
200
+ });
201
+ </script>
202
+
203
+ <Group>
204
+ <!-- Cells -->
205
+ {#if children}
206
+ {@render children({ cells: allCells.cells, cellSize })}
207
+ {:else}
208
+ {#each allCells.cells as cell}
209
+ <Rect
210
+ x={cell.x}
211
+ y={cell.y}
212
+ width={cellSize}
213
+ height={cellSize}
214
+ fill={cell.color}
215
+ onpointermove={(e) => tooltip && ctx.tooltip?.show(e, cell.data)}
216
+ onpointerleave={(e) => tooltip && ctx.tooltip?.hide()}
217
+ {...extractLayerProps(restProps, 'lc-month-cell')}
218
+ />
219
+
220
+ {#if showDayNumber}
221
+ <Text
222
+ x={cell.x + cellSize / 2}
223
+ y={cell.y + cellSize / 2}
224
+ lineHeight="0.8em"
225
+ value={cell.date.getDate()}
226
+ textAnchor="middle"
227
+ verticalAnchor="middle"
228
+ class="lc-month-day-number"
229
+ {...dayNumberProps}
230
+ />
231
+ {/if}
232
+ {/each}
233
+ {/if}
234
+
235
+ <!-- Month labels -->
236
+ {#if monthLabel}
237
+ {#each monthLabels as label}
238
+ <Text
239
+ x={label.x}
240
+ y={label.y}
241
+ value={label.text}
242
+ verticalAnchor="start"
243
+ class="lc-month-month-label"
244
+ {...extractLayerProps(monthLabel, 'lc-month-month-label')}
245
+ />
246
+ {/each}
247
+ {/if}
248
+ </Group>
249
+
250
+ <style>
251
+ @layer components {
252
+ :global(:where(.lc-month-cell)) {
253
+ stroke-width: 1;
254
+ --stroke-color: color-mix(
255
+ in oklab,
256
+ var(--color-surface-content, currentColor) 20%,
257
+ transparent
258
+ );
259
+ }
260
+
261
+ :global(:where(.lc-month-day-number)) {
262
+ font-size: 10px;
263
+ pointer-events: none;
264
+ stroke: var(--color-surface-100, light-dark(white, black));
265
+ stroke-width: 1px;
266
+ font-weight: 600;
267
+ }
268
+
269
+ :global(:where(.lc-month-month-label)) {
270
+ font-size: 16px;
271
+ }
272
+ }
273
+ </style>