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.
- package/dist/bench/PrimitiveBench.svelte +66 -0
- package/dist/bench/PrimitiveBench.svelte.d.ts +10 -0
- package/dist/bench/primitives.svelte.bench.d.ts +1 -0
- package/dist/bench/primitives.svelte.bench.js +42 -0
- package/dist/components/Axis.svelte +14 -3
- package/dist/components/Axis.svelte.d.ts +1 -1
- package/dist/components/Chart.svelte +110 -12
- package/dist/components/Circle.svelte +20 -17
- package/dist/components/Contour.svelte +90 -13
- package/dist/components/Contour.svelte.d.ts +8 -0
- package/dist/components/Ellipse.svelte +18 -16
- package/dist/components/GeoPath.svelte +1 -1
- package/dist/components/Group.svelte +14 -12
- package/dist/components/Image.svelte +18 -16
- package/dist/components/Labels.svelte +56 -11
- package/dist/components/Labels.svelte.d.ts +3 -2
- package/dist/components/Line.svelte +18 -16
- package/dist/components/LinearGradient.svelte +1 -1
- package/dist/components/Marker.svelte +8 -3
- package/dist/components/Marker.svelte.d.ts +1 -1
- package/dist/components/Month.svelte +273 -0
- package/dist/components/Month.svelte.d.ts +70 -0
- package/dist/components/Path.svelte +28 -12
- package/dist/components/Polygon.svelte +25 -23
- package/dist/components/RadialGradient.svelte +1 -1
- package/dist/components/Raster.svelte +117 -29
- package/dist/components/Raster.svelte.d.ts +8 -0
- package/dist/components/Rect.svelte +26 -20
- package/dist/components/Spline.svelte +123 -25
- package/dist/components/Spline.svelte.d.ts +18 -1
- package/dist/components/Text.svelte +45 -20
- package/dist/components/Text.svelte.d.ts +6 -0
- package/dist/components/TransformContext.svelte +8 -0
- package/dist/components/TransformContext.svelte.test.d.ts +1 -0
- package/dist/components/TransformContext.svelte.test.js +166 -0
- package/dist/components/Vector.svelte +14 -12
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -0
- package/dist/components/tests/TransformTestHarness.svelte +27 -0
- package/dist/components/tests/TransformTestHarness.svelte.d.ts +8 -0
- 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
- 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
- package/dist/states/brush.svelte.d.ts +26 -17
- package/dist/states/brush.svelte.js +118 -25
- package/dist/states/brush.svelte.test.js +126 -1
- package/dist/states/chart.svelte.d.ts +6 -0
- package/dist/states/chart.svelte.js +93 -20
- package/dist/states/transform.svelte.js +3 -1
- package/dist/utils/dataProp.d.ts +2 -10
- package/dist/utils/dataProp.js +16 -5
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/motion.svelte.d.ts +12 -2
- package/dist/utils/motion.svelte.js +22 -0
- package/dist/utils/motion.test.js +49 -1
- package/dist/utils/rasterBounds.d.ts +18 -0
- package/dist/utils/rasterBounds.js +98 -0
- package/dist/utils/rasterBounds.test.d.ts +1 -0
- package/dist/utils/rasterBounds.test.js +63 -0
- package/dist/utils/scales.svelte.js +4 -2
- package/dist/utils/scales.svelte.test.d.ts +1 -0
- package/dist/utils/scales.svelte.test.js +67 -0
- package/dist/utils/ticks.js +7 -3
- package/dist/utils/ticks.test.js +13 -3
- 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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
335
|
-
strokeKey
|
|
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: '
|
|
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>
|