v-uixy 1.9.0 → 1.9.2

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.
@@ -1,218 +1,581 @@
1
1
  <template>
2
- <div ref="wrapperRef" class="relative w-full aspect-square">
3
- <svg
4
- ref="svgRef"
5
- width="100%"
6
- height="100%"
7
- :viewBox="`0 0 ${size} ${size}`"
8
- role="img"
9
- aria-label="Pie chart"
10
- class="block select-none pointer-events-none"
2
+ <div
3
+ ref="rootRef"
4
+ class="flex w-full flex-col items-center gap-4"
5
+ :role="props.interactive ? 'group' : 'img'"
6
+ :aria-roledescription="props.interactive ? 'pie chart' : undefined"
7
+ :aria-label="ariaLabel"
8
+ >
9
+ <div
10
+ ref="ringRef"
11
+ class="relative aspect-square w-full rounded-full"
12
+ :class="props.interactive ? 'cursor-crosshair touch-pan-y' : ''"
13
+ :tabindex="props.interactive ? 0 : undefined"
14
+ @pointermove="handlePointerMove"
15
+ @pointerdown="handlePointerDown"
16
+ @pointerleave="handlePointerLeave"
17
+ @pointercancel="clearHover"
18
+ @focus="handleFocus"
19
+ @blur="handleBlur"
20
+ @keydown="handleKeydown"
11
21
  >
12
- <motion.g
13
- :initial="{ transform: `rotate(0, ${center}, ${center})` }"
14
- :animate="{ transform: `rotate(0, ${center}, ${center})` }"
15
- :transition="groupTransition"
22
+ <svg
23
+ v-if="ready"
24
+ :width="size"
25
+ :height="size"
26
+ :viewBox="`0 0 ${size} ${size}`"
27
+ aria-hidden="true"
28
+ class="pointer-events-none block select-none"
16
29
  >
17
- <circle
18
- :cx="center"
19
- :cy="center"
20
- :r="radius"
21
- fill="none"
22
- class="stroke-gray-300 dark:stroke-gray-800"
23
- :stroke-width="thickness"
24
- vector-effect="non-scaling-stroke"
25
- shape-rendering="geometricPrecision"
26
- />
27
- <template v-if="isReady">
28
- <motion.circle
29
- v-for="seg in segmentsTimed"
30
- :key="seg.key"
31
- :initial="{
32
- strokeDasharray: `0 ${circumference}`,
33
- strokeDashoffset: seg.offset,
34
- opacity: 0.8,
35
- }"
36
- :animate="{
37
- strokeDasharray: `${seg.length} ${circumference}`,
38
- strokeDashoffset: seg.offset,
39
- opacity: 1,
40
- }"
41
- :transition="transitionFor(seg)"
42
- :cx="center"
43
- :cy="center"
44
- :r="radius"
45
- fill="none"
46
- :stroke="seg.color"
47
- :stroke-width="thickness - 2"
48
- stroke-linecap="butt"
49
- vector-effect="non-scaling-stroke"
50
- shape-rendering="geometricPrecision"
30
+ <g
31
+ v-for="cell in cells"
32
+ :key="cell.key"
33
+ :transform="cellTransform(cell)"
34
+ >
35
+ <rect
36
+ :x="-cellSize / 2"
37
+ :y="-cellSize / 2"
38
+ :width="cellSize"
39
+ :height="cellSize"
40
+ class="fill-gray-200 dark:fill-gray-900"
41
+ />
42
+ <rect
43
+ :x="-cellSize / 2"
44
+ :y="-cellSize / 2"
45
+ :width="cellSize"
46
+ :height="cellSize"
47
+ :class="cell.className"
48
+ :fill="cell.fill"
49
+ :style="cellStyle(cell)"
51
50
  />
52
- </template>
53
- </motion.g>
54
- </svg>
51
+ </g>
52
+ </svg>
53
+
54
+ <div
55
+ v-if="ready && showCenter"
56
+ class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center"
57
+ aria-hidden="true"
58
+ >
59
+ <span class="text-xs text-gray-500 dark:text-gray-600">
60
+ {{ centerCaption }}
61
+ </span>
62
+ <span
63
+ class="font-600 tabular-nums text-black dark:text-white"
64
+ :style="{ fontSize: `${centerFontSize}px`, lineHeight: 1.15 }"
65
+ >
66
+ {{ centerValue }}
67
+ </span>
68
+ <span
69
+ class="text-xs tabular-nums text-gray-500 dark:text-gray-600"
70
+ :class="activeSlice ? '' : 'invisible'"
71
+ >
72
+ {{ activeSlice?.shareLabel ?? "–" }}
73
+ </span>
74
+ </div>
75
+ </div>
76
+
77
+ <ul
78
+ v-if="props.legend && slices.length"
79
+ class="flex flex-wrap justify-center gap-x-4 gap-y-1.5"
80
+ aria-hidden="true"
81
+ >
82
+ <li
83
+ v-for="slice in slices"
84
+ :key="slice.index"
85
+ class="flex items-center gap-1.5 text-xs transition-opacity duration-150"
86
+ :class="isDimmed(slice.index) ? 'opacity-40' : ''"
87
+ @pointerenter="handleLegendEnter(slice.index)"
88
+ @pointerdown="handleLegendEnter(slice.index)"
89
+ @pointerleave="handlePointerLeave"
90
+ >
91
+ <span
92
+ class="size-2 shrink-0"
93
+ :class="slice.swatchClass"
94
+ :style="slice.swatchStyle"
95
+ />
96
+ <span class="text-gray-600 dark:text-gray-500">{{ slice.label }}</span>
97
+ <span class="font-500 tabular-nums text-black dark:text-white">
98
+ {{ slice.shareLabel }}
99
+ </span>
100
+ </li>
101
+ </ul>
102
+
103
+ <span v-if="props.interactive" class="sr-only" aria-live="polite">
104
+ {{ announcement }}
105
+ </span>
55
106
  </div>
56
107
  </template>
57
108
 
58
109
  <script setup lang="ts">
59
- import { computed, onMounted, onUnmounted, ref, toRefs, watch } from "vue";
60
- import { motion } from "motion-v";
110
+ import { computed, onMounted, onUnmounted, ref, watch } from "vue";
61
111
  import type { UixyPieChartProps } from "./PieChart.types";
62
112
 
63
- type SegBase = { key: string; length: number; offset: number; color: string };
64
- type SegTimed = SegBase & { dur: number; delay: number };
113
+ type Paint = { fill?: string; className?: string };
114
+
115
+ type RingCell = {
116
+ key: string;
117
+ seed: number;
118
+ turn: number;
119
+ radius: number;
120
+ };
121
+
122
+ type Cell = RingCell & Paint & { slice: number | null; filled: boolean };
123
+
124
+ type Slice = {
125
+ index: number;
126
+ label: string;
127
+ value: number;
128
+ share: number;
129
+ end: number;
130
+ shareLabel: string;
131
+ swatchClass?: string;
132
+ swatchStyle?: { backgroundColor: string };
133
+ };
134
+
135
+ type Unit = { divisor: number; suffix: string };
136
+
137
+ type Batch = { timer: ReturnType<typeof setTimeout>; tasks: (() => void)[] };
138
+
139
+ const GAP = 3;
140
+ const MIN_RESOLUTION = 7;
141
+ const MAX_THICKNESS_RATIO = 0.35;
142
+ const HIT_TOLERANCE = 0.5;
143
+
144
+ const HIDDEN_SCALE = 0.3;
145
+ const DIMMED_SCALE = 0.55;
65
146
 
66
- const INITIAL_TOTAL_DURATION = 1.2;
67
- const UPDATE_DURATION = 0.45;
68
- const OVERLAP = 0.02;
69
- const SIZE_EPSILON = 0.5;
70
- const EASING = "linear";
147
+ const DISSOLVE_DURATION = 0.6;
148
+ const POP_TRANSITION = "transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1)";
149
+ const FADE_TRANSITION = "opacity 0.18s ease-out";
150
+
151
+ const CENTER_MIN_SIZE = 56;
152
+ const CENTER_FONT_RATIO = 0.22;
153
+ const CENTER_FONT_MIN = 14;
154
+ const CENTER_FONT_MAX = 40;
155
+
156
+ const UNITS: Unit[] = [
157
+ { divisor: 1e9, suffix: "B" },
158
+ { divisor: 1e6, suffix: "M" },
159
+ { divisor: 1e3, suffix: "k" },
160
+ ];
161
+
162
+ const PALETTE = [
163
+ { fill: "fill-black dark:fill-white", swatch: "bg-black dark:bg-white" },
164
+ {
165
+ fill: "fill-gray-500 dark:fill-gray-600",
166
+ swatch: "bg-gray-500 dark:bg-gray-600",
167
+ },
168
+ {
169
+ fill: "fill-gray-700 dark:fill-gray-400",
170
+ swatch: "bg-gray-700 dark:bg-gray-400",
171
+ },
172
+ {
173
+ fill: "fill-gray-400 dark:fill-gray-700",
174
+ swatch: "bg-gray-400 dark:bg-gray-700",
175
+ },
176
+ {
177
+ fill: "fill-gray-600 dark:fill-gray-500",
178
+ swatch: "bg-gray-600 dark:bg-gray-500",
179
+ },
180
+ ];
71
181
 
72
182
  const props = withDefaults(defineProps<UixyPieChartProps>(), {
73
- thickness: 18,
183
+ resolution: 27,
184
+ thickness: 5,
185
+ total: "Total",
186
+ legend: true,
74
187
  animate: true,
188
+ interactive: true,
189
+ });
190
+
191
+ const rootRef = ref<HTMLElement | null>(null);
192
+ const ringRef = ref<HTMLElement | null>(null);
193
+
194
+ const size = ref(0);
195
+
196
+ const hoveredSlice = ref<number | null>(null);
197
+ const focusedSlice = ref<number | null>(null);
198
+
199
+ const cells = ref<Cell[]>([]);
200
+
201
+ const clamp = (value: number, min: number, max: number) =>
202
+ Math.max(min, Math.min(max, value));
203
+
204
+ const noise = (seed: number) => {
205
+ const x = Math.sin(seed * 12.9898) * 43758.5453;
206
+ return x - Math.floor(x);
207
+ };
208
+
209
+ const unitFor = (value: number): Unit =>
210
+ UNITS.find((unit) => value >= unit.divisor) ?? { divisor: 1, suffix: "" };
211
+
212
+ const compact = (value: number) => {
213
+ const { divisor, suffix } = unitFor(value);
214
+ const scaled = value / divisor;
215
+ const text =
216
+ Number.isInteger(scaled) || Math.abs(scaled) >= 100
217
+ ? String(Math.round(scaled))
218
+ : scaled.toFixed(1).replace(/\.0$/, "");
219
+
220
+ return `${text}${suffix}`;
221
+ };
222
+
223
+ const formatValue = (value: number) =>
224
+ props.format ? props.format(value) : compact(value);
225
+
226
+ const formatShare = (share: number) => {
227
+ if (share > 0 && share < 0.005) return "<1%";
228
+ return `${Math.round(share * 100)}%`;
229
+ };
230
+
231
+ const swatchOf = (index: number, color?: string) => {
232
+ if (color) return { swatchStyle: { backgroundColor: color } };
233
+ return { swatchClass: PALETTE[index % PALETTE.length]!.swatch };
234
+ };
235
+
236
+ const paintOf = (index: number): Paint => {
237
+ const color = props.data[index]?.color;
238
+ if (color) return { fill: color };
239
+ return { className: PALETTE[index % PALETTE.length]!.fill };
240
+ };
241
+
242
+ const total = computed(() =>
243
+ props.data.reduce((sum, entry) => sum + Math.max(0, entry.value), 0),
244
+ );
245
+
246
+ const slices = computed<Slice[]>(() => {
247
+ let end = 0;
248
+
249
+ return props.data.map((entry, index) => {
250
+ const value = Math.max(0, entry.value);
251
+ const share = total.value ? value / total.value : 0;
252
+ end += share;
253
+
254
+ return {
255
+ index,
256
+ label: entry.label,
257
+ value,
258
+ share,
259
+ end,
260
+ shareLabel: formatShare(share),
261
+ ...swatchOf(index, entry.color),
262
+ };
263
+ });
75
264
  });
76
265
 
77
- const wrapperRef = ref<HTMLElement | null>(null);
78
- const svgRef = ref<SVGSVGElement | null>(null);
79
- const measuredSize = ref<number>(180);
80
- const roRef = ref<ResizeObserver | null>(null);
81
- const isReady = ref(false);
82
- const initialTimer = ref<ReturnType<typeof setTimeout> | null>(null);
83
-
84
- const size = computed(() => measuredSize.value);
85
- const center = computed(() => measuredSize.value / 2);
86
- const radius = computed(() =>
87
- Math.max(1, measuredSize.value / 2 - props.thickness / 2 - 2)
266
+ const visibleSlices = computed(() =>
267
+ slices.value.filter((slice) => slice.share > 0),
88
268
  );
89
269
 
90
- const circumference = computed(() => 2 * Math.PI * radius.value);
270
+ const sliceAtTurn = (turn: number) =>
271
+ visibleSlices.value.find((slice) => turn < slice.end) ??
272
+ visibleSlices.value[visibleSlices.value.length - 1];
91
273
 
92
- const groupTransition = computed(() =>
93
- !isReady.value || isInitial.value
94
- ? { duration: 0 }
95
- : { duration: 0.35, easing: EASING }
274
+ const resolution = computed(() =>
275
+ Math.max(MIN_RESOLUTION, Math.floor(props.resolution)),
96
276
  );
97
277
 
98
- const total = computed(() =>
99
- props.data.reduce((acc, d) => acc + Math.max(0, d.value), 0)
278
+ const thickness = computed(() =>
279
+ clamp(
280
+ Math.round(props.thickness),
281
+ 1,
282
+ Math.floor(resolution.value * MAX_THICKNESS_RATIO),
283
+ ),
100
284
  );
101
285
 
102
- const segmentsBase = computed<SegBase[]>(() => {
103
- const totalVal = total.value || 1;
104
- let acc = 0;
105
- const list: SegBase[] = [];
286
+ const outerRadius = computed(() => resolution.value / 2);
287
+ const innerRadius = computed(() => outerRadius.value - thickness.value);
288
+
289
+ const turnOf = (dx: number, dy: number) =>
290
+ (Math.atan2(dx, -dy) / (2 * Math.PI) + 1) % 1;
291
+
292
+ const ring = computed(() => {
293
+ const list: RingCell[] = [];
106
294
 
107
- props.data.forEach((d, i) => {
108
- const frac = Math.max(0, d.value) / totalVal;
109
- const baseLen = frac * circumference.value;
110
- const drawLen = Math.max(0, baseLen);
295
+ for (let band = 0; band < thickness.value; band++) {
296
+ const radius = outerRadius.value - 0.5 - band;
297
+ const count = Math.floor(2 * Math.PI * radius);
111
298
 
112
- if (drawLen > 0) {
299
+ for (let step = 0; step < count; step++) {
113
300
  list.push({
114
- key: `seg-${i}-${d.color ?? "c"}`,
115
- length: drawLen,
116
- offset: -acc,
117
- color: d.color || "#999999",
301
+ key: `${band}-${step}`,
302
+ seed: list.length + 1,
303
+ turn: (step + 0.5) / count,
304
+ radius,
118
305
  });
119
306
  }
120
-
121
- acc += baseLen;
122
- });
307
+ }
123
308
 
124
309
  return list;
125
310
  });
126
311
 
127
- const segmentsTimed = computed<SegTimed[]>(() => {
128
- const totalDur = Math.max(0.01, INITIAL_TOTAL_DURATION);
129
- let running = 0;
312
+ const pitch = computed(() => size.value / resolution.value);
313
+ const cellSize = computed(() => Math.max(1, pitch.value - GAP));
130
314
 
131
- return segmentsBase.value.map((s) => {
132
- const dur = (s.length / Math.max(1e-6, circumference.value)) * totalDur;
133
- const delay = Math.max(0, running - OVERLAP);
134
- const segT: SegTimed = { ...s, dur, delay };
135
- running += dur;
136
- return segT;
137
- });
315
+ const cellTransform = ({ turn, radius }: RingCell) => {
316
+ const angle = turn * 2 * Math.PI;
317
+ const x = size.value / 2 + Math.sin(angle) * radius * pitch.value;
318
+ const y = size.value / 2 - Math.cos(angle) * radius * pitch.value;
319
+
320
+ return `translate(${x} ${y}) rotate(${turn * 360})`;
321
+ };
322
+
323
+ const ready = computed(() => size.value > 0);
324
+
325
+ const holeSize = computed(() => 2 * innerRadius.value * pitch.value);
326
+
327
+ const showCenter = computed(() => holeSize.value >= CENTER_MIN_SIZE);
328
+
329
+ const centerFontSize = computed(() =>
330
+ clamp(
331
+ Math.round(holeSize.value * CENTER_FONT_RATIO),
332
+ CENTER_FONT_MIN,
333
+ CENTER_FONT_MAX,
334
+ ),
335
+ );
336
+
337
+ const activeIndex = computed(() => {
338
+ if (!props.interactive) return null;
339
+
340
+ const index = hoveredSlice.value ?? focusedSlice.value;
341
+ return index !== null && slices.value[index]?.share ? index : null;
138
342
  });
139
343
 
140
- const isInitial = ref(true);
344
+ const activeSlice = computed(() =>
345
+ activeIndex.value === null ? null : slices.value[activeIndex.value]!,
346
+ );
347
+
348
+ const isDimmed = (index: number | null) =>
349
+ activeIndex.value !== null && index !== activeIndex.value;
141
350
 
142
- watch(
143
- () => ({ ready: isReady.value }),
144
- ({ ready }) => {
145
- if (!ready) return;
351
+ const cellTransition = computed(() =>
352
+ props.animate ? `${FADE_TRANSITION}, ${POP_TRANSITION}` : "none",
353
+ );
146
354
 
147
- if (initialTimer.value) return;
355
+ const cellStyle = (cell: Cell) => {
356
+ const scale = !cell.filled
357
+ ? HIDDEN_SCALE
358
+ : isDimmed(cell.slice)
359
+ ? DIMMED_SCALE
360
+ : 1;
361
+
362
+ return {
363
+ opacity: cell.filled ? 1 : 0,
364
+ transform: `scale(${scale})`,
365
+ transition: cellTransition.value,
366
+ };
367
+ };
148
368
 
149
- const totalDur = INITIAL_TOTAL_DURATION;
369
+ const centerCaption = computed(() => activeSlice.value?.label ?? props.total);
150
370
 
151
- initialTimer.value = setTimeout(() => {
152
- isInitial.value = false;
153
- if (initialTimer.value) {
154
- clearTimeout(initialTimer.value);
155
- initialTimer.value = null;
156
- }
157
- }, Math.ceil(totalDur * 1000) + 16);
158
- },
159
- { immediate: true }
371
+ const centerValue = computed(() =>
372
+ formatValue(activeSlice.value?.value ?? total.value),
160
373
  );
161
374
 
162
- onMounted(() => {
163
- startResizeObserver();
375
+ const announcement = computed(() => {
376
+ const slice = activeSlice.value;
377
+ if (focusedSlice.value === null || !slice) return "";
378
+
379
+ return `${slice.label}: ${formatValue(slice.value)}, ${slice.shareLabel}`;
164
380
  });
165
381
 
166
- onUnmounted(() => {
167
- stopResizeObserver();
382
+ const ariaLabel = computed(() => {
383
+ if (!visibleSlices.value.length) return "Pie chart, no data";
168
384
 
169
- if (initialTimer.value) {
170
- clearTimeout(initialTimer.value);
171
- initialTimer.value = null;
172
- }
385
+ const parts = visibleSlices.value.map(
386
+ (slice) =>
387
+ `${slice.label} ${formatValue(slice.value)} (${slice.shareLabel})`,
388
+ );
389
+
390
+ return `Pie chart: ${parts.join(", ")}`;
173
391
  });
174
392
 
175
- const transitionFor = (seg: SegTimed) => {
176
- if (isInitial.value)
177
- return { delay: seg.delay, duration: seg.dur, easing: EASING } as const;
393
+ const sliceAt = (clientX: number, clientY: number) => {
394
+ const rect = ringRef.value!.getBoundingClientRect();
395
+ const dx = clientX - rect.left - size.value / 2;
396
+ const dy = clientY - rect.top - size.value / 2;
397
+ const distance = Math.hypot(dx, dy) / pitch.value;
178
398
 
179
- return { delay: 0, duration: UPDATE_DURATION, easing: EASING };
399
+ const inside =
400
+ distance >= innerRadius.value - HIT_TOLERANCE &&
401
+ distance <= outerRadius.value + HIT_TOLERANCE;
402
+
403
+ if (!inside) return null;
404
+ return sliceAtTurn(turnOf(dx, dy))?.index ?? null;
180
405
  };
181
406
 
182
- const measure = () => {
183
- const el = wrapperRef.value ?? svgRef.value;
407
+ const stepSlice = (key: string, from: number | null) => {
408
+ const indexes = visibleSlices.value.map((slice) => slice.index);
409
+ const position = from === null ? -1 : indexes.indexOf(from);
410
+ const last = indexes.length - 1;
411
+
412
+ switch (key) {
413
+ case "ArrowRight":
414
+ case "ArrowDown":
415
+ return indexes[position >= last ? 0 : position + 1];
416
+ case "ArrowLeft":
417
+ case "ArrowUp":
418
+ return indexes[position <= 0 ? last : position - 1];
419
+ case "Home":
420
+ return indexes[0];
421
+ case "End":
422
+ return indexes[last];
423
+ default:
424
+ return undefined;
425
+ }
426
+ };
184
427
 
185
- if (!el) return;
428
+ const clearHover = () => {
429
+ hoveredSlice.value = null;
430
+ };
186
431
 
187
- const rect = el.getBoundingClientRect();
188
- const next = Math.max(1, Math.min(rect.width || 0, rect.height || 0));
432
+ const handlePointerMove = (event: PointerEvent) => {
433
+ hoveredSlice.value = sliceAt(event.clientX, event.clientY);
434
+ };
189
435
 
190
- if (Math.abs(next - measuredSize.value) > SIZE_EPSILON)
191
- measuredSize.value = next;
436
+ const handlePointerDown = (event: PointerEvent) => {
437
+ focusedSlice.value = null;
438
+ handlePointerMove(event);
439
+ };
192
440
 
193
- if (!isReady.value && next > 1) isReady.value = true;
441
+ const handlePointerLeave = (event: PointerEvent) => {
442
+ if (event.pointerType !== "touch") clearHover();
194
443
  };
195
444
 
196
- const startResizeObserver = () => {
197
- if (roRef.value) return;
445
+ const handleLegendEnter = (index: number) => {
446
+ focusedSlice.value = null;
447
+ hoveredSlice.value = index;
448
+ };
198
449
 
199
- roRef.value = new ResizeObserver(measure);
450
+ const handleOutsidePointerDown = (event: PointerEvent) => {
451
+ if (!rootRef.value?.contains(event.target as Node)) clearHover();
452
+ };
200
453
 
201
- const el = wrapperRef.value ?? svgRef.value;
454
+ const handleFocus = (event: FocusEvent) => {
455
+ if (!(event.target as HTMLElement).matches(":focus-visible")) return;
456
+ focusedSlice.value ??= visibleSlices.value[0]?.index ?? null;
457
+ };
202
458
 
203
- if (el) roRef.value.observe(el);
459
+ const handleBlur = () => {
460
+ focusedSlice.value = null;
461
+ };
204
462
 
205
- requestAnimationFrame(measure);
463
+ const handleKeydown = (event: KeyboardEvent) => {
464
+ if (event.key === "Escape") {
465
+ clearHover();
466
+ focusedSlice.value = null;
467
+ return;
468
+ }
469
+
470
+ if (!visibleSlices.value.length) return;
471
+
472
+ const target = stepSlice(event.key, activeIndex.value);
473
+ if (target === undefined) return;
474
+
475
+ event.preventDefault();
476
+ clearHover();
477
+ focusedSlice.value = target;
206
478
  };
207
479
 
208
- const stopResizeObserver = () => {
209
- if (!roRef.value) return;
480
+ const batches = new Map<number, Batch>();
481
+ let cellsShape = "";
210
482
 
211
- const el = wrapperRef.value ?? svgRef.value;
483
+ const schedule = (delay: number, task: () => void) => {
484
+ const batch = batches.get(delay);
212
485
 
213
- if (el) roRef.value.unobserve(el);
486
+ if (batch) {
487
+ batch.tasks.push(task);
488
+ return;
489
+ }
214
490
 
215
- roRef.value.disconnect();
216
- roRef.value = null;
491
+ batches.set(delay, {
492
+ tasks: [task],
493
+ timer: setTimeout(() => {
494
+ batches.get(delay)?.tasks.forEach((run) => run());
495
+ batches.delete(delay);
496
+ }, delay),
497
+ });
217
498
  };
499
+
500
+ const cancelBatches = () => {
501
+ batches.forEach(({ timer }) => clearTimeout(timer));
502
+ batches.clear();
503
+ };
504
+
505
+ const flushBatches = () => {
506
+ const tasks = [...batches.values()].flatMap((batch) => batch.tasks);
507
+
508
+ cancelBatches();
509
+ tasks.forEach((run) => run());
510
+ };
511
+
512
+ const ensureCells = () => {
513
+ const shape = `${resolution.value}/${thickness.value}`;
514
+ if (shape === cellsShape) return;
515
+ cellsShape = shape;
516
+
517
+ cells.value = ring.value.map((cell) => ({
518
+ ...cell,
519
+ slice: null,
520
+ filled: false,
521
+ className: PALETTE[0]!.fill,
522
+ }));
523
+ };
524
+
525
+ const syncCells = () => {
526
+ flushBatches();
527
+ ensureCells();
528
+
529
+ if (!ready.value) return;
530
+
531
+ cells.value.forEach((cell) => {
532
+ const slice = total.value ? sliceAtTurn(cell.turn) : undefined;
533
+ const index = slice?.index ?? null;
534
+ const filled = index !== null;
535
+ const { fill, className } = index !== null ? paintOf(index) : cell;
536
+
537
+ const unchanged =
538
+ filled === cell.filled &&
539
+ index === cell.slice &&
540
+ fill === cell.fill &&
541
+ className === cell.className;
542
+
543
+ if (unchanged) return;
544
+
545
+ const apply = () =>
546
+ Object.assign(cell, { filled, slice: index, fill, className });
547
+
548
+ const delay = props.animate
549
+ ? Math.round(noise(cell.seed) * DISSOLVE_DURATION * 1000)
550
+ : 0;
551
+
552
+ if (delay > 0) schedule(delay, apply);
553
+ else apply();
554
+ });
555
+ };
556
+
557
+ watch([slices, ring, () => props.animate, ready], syncCells, {
558
+ immediate: true,
559
+ flush: "post",
560
+ });
561
+
562
+ let observer: ResizeObserver | null = null;
563
+
564
+ const measure = () => {
565
+ if (!ringRef.value) return;
566
+ size.value = ringRef.value.getBoundingClientRect().width;
567
+ };
568
+
569
+ onMounted(() => {
570
+ observer = new ResizeObserver(measure);
571
+ if (ringRef.value) observer.observe(ringRef.value);
572
+
573
+ document.addEventListener("pointerdown", handleOutsidePointerDown);
574
+ });
575
+
576
+ onUnmounted(() => {
577
+ observer?.disconnect();
578
+ document.removeEventListener("pointerdown", handleOutsidePointerDown);
579
+ cancelBatches();
580
+ });
218
581
  </script>