v-uixy 1.2.0 → 1.3.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "v-uixy",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "private": false,
5
5
  "author": {
6
6
  "name": "Alan Haber",
@@ -0,0 +1,218 @@
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"
11
+ >
12
+ <motion.g
13
+ :initial="{ transform: `rotate(0, ${center}, ${center})` }"
14
+ :animate="{ transform: `rotate(0, ${center}, ${center})` }"
15
+ :transition="groupTransition"
16
+ >
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"
51
+ />
52
+ </template>
53
+ </motion.g>
54
+ </svg>
55
+ </div>
56
+ </template>
57
+
58
+ <script setup lang="ts">
59
+ import { computed, onMounted, onUnmounted, ref, toRefs, watch } from "vue";
60
+ import { motion } from "motion-v";
61
+ import type { UixyPieChartProps } from "./PieChart.types";
62
+
63
+ type SegBase = { key: string; length: number; offset: number; color: string };
64
+ type SegTimed = SegBase & { dur: number; delay: number };
65
+
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";
71
+
72
+ const props = withDefaults(defineProps<UixyPieChartProps>(), {
73
+ thickness: 18,
74
+ animate: true,
75
+ });
76
+
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)
88
+ );
89
+
90
+ const circumference = computed(() => 2 * Math.PI * radius.value);
91
+
92
+ const groupTransition = computed(() =>
93
+ !isReady.value || isInitial.value
94
+ ? { duration: 0 }
95
+ : { duration: 0.35, easing: EASING }
96
+ );
97
+
98
+ const total = computed(() =>
99
+ props.data.reduce((acc, d) => acc + Math.max(0, d.value), 0)
100
+ );
101
+
102
+ const segmentsBase = computed<SegBase[]>(() => {
103
+ const totalVal = total.value || 1;
104
+ let acc = 0;
105
+ const list: SegBase[] = [];
106
+
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);
111
+
112
+ if (drawLen > 0) {
113
+ list.push({
114
+ key: `seg-${i}-${d.color ?? "c"}`,
115
+ length: drawLen,
116
+ offset: -acc,
117
+ color: d.color || "#999999",
118
+ });
119
+ }
120
+
121
+ acc += baseLen;
122
+ });
123
+
124
+ return list;
125
+ });
126
+
127
+ const segmentsTimed = computed<SegTimed[]>(() => {
128
+ const totalDur = Math.max(0.01, INITIAL_TOTAL_DURATION);
129
+ let running = 0;
130
+
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
+ });
138
+ });
139
+
140
+ const isInitial = ref(true);
141
+
142
+ watch(
143
+ () => ({ ready: isReady.value }),
144
+ ({ ready }) => {
145
+ if (!ready) return;
146
+
147
+ if (initialTimer.value) return;
148
+
149
+ const totalDur = INITIAL_TOTAL_DURATION;
150
+
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 }
160
+ );
161
+
162
+ onMounted(() => {
163
+ startResizeObserver();
164
+ });
165
+
166
+ onUnmounted(() => {
167
+ stopResizeObserver();
168
+
169
+ if (initialTimer.value) {
170
+ clearTimeout(initialTimer.value);
171
+ initialTimer.value = null;
172
+ }
173
+ });
174
+
175
+ const transitionFor = (seg: SegTimed) => {
176
+ if (isInitial.value)
177
+ return { delay: seg.delay, duration: seg.dur, easing: EASING } as const;
178
+
179
+ return { delay: 0, duration: UPDATE_DURATION, easing: EASING };
180
+ };
181
+
182
+ const measure = () => {
183
+ const el = wrapperRef.value ?? svgRef.value;
184
+
185
+ if (!el) return;
186
+
187
+ const rect = el.getBoundingClientRect();
188
+ const next = Math.max(1, Math.min(rect.width || 0, rect.height || 0));
189
+
190
+ if (Math.abs(next - measuredSize.value) > SIZE_EPSILON)
191
+ measuredSize.value = next;
192
+
193
+ if (!isReady.value && next > 1) isReady.value = true;
194
+ };
195
+
196
+ const startResizeObserver = () => {
197
+ if (roRef.value) return;
198
+
199
+ roRef.value = new ResizeObserver(measure);
200
+
201
+ const el = wrapperRef.value ?? svgRef.value;
202
+
203
+ if (el) roRef.value.observe(el);
204
+
205
+ requestAnimationFrame(measure);
206
+ };
207
+
208
+ const stopResizeObserver = () => {
209
+ if (!roRef.value) return;
210
+
211
+ const el = wrapperRef.value ?? svgRef.value;
212
+
213
+ if (el) roRef.value.unobserve(el);
214
+
215
+ roRef.value.disconnect();
216
+ roRef.value = null;
217
+ };
218
+ </script>
@@ -0,0 +1,10 @@
1
+ export type UixyPieChartData = {
2
+ label: string;
3
+ value: number;
4
+ color: string;
5
+ };
6
+
7
+ export interface UixyPieChartProps {
8
+ data: UixyPieChartData[];
9
+ thickness?: number;
10
+ }
@@ -0,0 +1,4 @@
1
+ import UixyPieChart from "./PieChart.component.vue";
2
+ import type { UixyPieChartProps, UixyPieChartData } from "./PieChart.types";
3
+
4
+ export { UixyPieChart, type UixyPieChartData, type UixyPieChartProps };
@@ -96,6 +96,12 @@
96
96
  "packages": ["motion-v"],
97
97
  "composables": []
98
98
  },
99
+ {
100
+ "name": "PieChart",
101
+ "related-components": [],
102
+ "packages": ["motion-v"],
103
+ "composables": []
104
+ },
99
105
  {
100
106
  "name": "Popover",
101
107
  "related-components": [],