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.
- package/package.json +1 -1
- package/templates/components/Button/Button.component.vue +2 -2
- package/templates/components/Chart/Chart.component.vue +895 -222
- package/templates/components/Chart/Chart.types.ts +7 -0
- package/templates/components/DatePicker/DatePicker.component.vue +25 -5
- package/templates/components/DatePicker/DatePicker.types.ts +1 -0
- package/templates/components/Input/Input.styles.ts +4 -2
- package/templates/components/Modal/Modal.component.vue +10 -1
- package/templates/components/Modal/Modal.styles.ts +1 -1
- package/templates/components/PieChart/PieChart.component.vue +515 -152
- package/templates/components/PieChart/PieChart.types.ts +7 -1
- package/templates/components/Sheet/Sheet.component.vue +4 -2
- package/templates/components/index.ts +8 -1
- package/templates/components.json +4 -4
- package/templates/composables/useGpuAcceleration.ts +79 -0
- package/templates/composables/useOverlayLayer.ts +9 -2
|
@@ -1,218 +1,581 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
<
|
|
13
|
-
|
|
14
|
-
:
|
|
15
|
-
:
|
|
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
|
-
<
|
|
18
|
-
|
|
19
|
-
:
|
|
20
|
-
:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
:
|
|
31
|
-
:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
:
|
|
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
|
-
</
|
|
53
|
-
</
|
|
54
|
-
|
|
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,
|
|
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
|
|
64
|
-
|
|
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
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
const
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
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
|
|
270
|
+
const sliceAtTurn = (turn: number) =>
|
|
271
|
+
visibleSlices.value.find((slice) => turn < slice.end) ??
|
|
272
|
+
visibleSlices.value[visibleSlices.value.length - 1];
|
|
91
273
|
|
|
92
|
-
const
|
|
93
|
-
|
|
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
|
|
99
|
-
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
const
|
|
109
|
-
const
|
|
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
|
-
|
|
299
|
+
for (let step = 0; step < count; step++) {
|
|
113
300
|
list.push({
|
|
114
|
-
key:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (!ready) return;
|
|
351
|
+
const cellTransition = computed(() =>
|
|
352
|
+
props.animate ? `${FADE_TRANSITION}, ${POP_TRANSITION}` : "none",
|
|
353
|
+
);
|
|
146
354
|
|
|
147
|
-
|
|
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
|
-
|
|
369
|
+
const centerCaption = computed(() => activeSlice.value?.label ?? props.total);
|
|
150
370
|
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
382
|
+
const ariaLabel = computed(() => {
|
|
383
|
+
if (!visibleSlices.value.length) return "Pie chart, no data";
|
|
168
384
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
|
183
|
-
const
|
|
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
|
-
|
|
428
|
+
const clearHover = () => {
|
|
429
|
+
hoveredSlice.value = null;
|
|
430
|
+
};
|
|
186
431
|
|
|
187
|
-
|
|
188
|
-
|
|
432
|
+
const handlePointerMove = (event: PointerEvent) => {
|
|
433
|
+
hoveredSlice.value = sliceAt(event.clientX, event.clientY);
|
|
434
|
+
};
|
|
189
435
|
|
|
190
|
-
|
|
191
|
-
|
|
436
|
+
const handlePointerDown = (event: PointerEvent) => {
|
|
437
|
+
focusedSlice.value = null;
|
|
438
|
+
handlePointerMove(event);
|
|
439
|
+
};
|
|
192
440
|
|
|
193
|
-
|
|
441
|
+
const handlePointerLeave = (event: PointerEvent) => {
|
|
442
|
+
if (event.pointerType !== "touch") clearHover();
|
|
194
443
|
};
|
|
195
444
|
|
|
196
|
-
const
|
|
197
|
-
|
|
445
|
+
const handleLegendEnter = (index: number) => {
|
|
446
|
+
focusedSlice.value = null;
|
|
447
|
+
hoveredSlice.value = index;
|
|
448
|
+
};
|
|
198
449
|
|
|
199
|
-
|
|
450
|
+
const handleOutsidePointerDown = (event: PointerEvent) => {
|
|
451
|
+
if (!rootRef.value?.contains(event.target as Node)) clearHover();
|
|
452
|
+
};
|
|
200
453
|
|
|
201
|
-
|
|
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
|
-
|
|
459
|
+
const handleBlur = () => {
|
|
460
|
+
focusedSlice.value = null;
|
|
461
|
+
};
|
|
204
462
|
|
|
205
|
-
|
|
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
|
|
209
|
-
|
|
480
|
+
const batches = new Map<number, Batch>();
|
|
481
|
+
let cellsShape = "";
|
|
210
482
|
|
|
211
|
-
|
|
483
|
+
const schedule = (delay: number, task: () => void) => {
|
|
484
|
+
const batch = batches.get(delay);
|
|
212
485
|
|
|
213
|
-
if (
|
|
486
|
+
if (batch) {
|
|
487
|
+
batch.tasks.push(task);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
214
490
|
|
|
215
|
-
|
|
216
|
-
|
|
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>
|