@wick-charts/vue 0.2.2 → 0.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.
@@ -0,0 +1,60 @@
1
+ <script setup lang="ts">
2
+ import { formatTime } from '@wick-charts/core';
3
+ import { computed } from 'vue';
4
+
5
+ import { useCrosshairPosition } from '../composables';
6
+ import { useChartInstance } from '../context';
7
+
8
+ const chart = useChartInstance();
9
+ const position = useCrosshairPosition(chart);
10
+
11
+ const theme = computed(() => chart.getTheme());
12
+ const dataInterval = computed(() => chart.getDataInterval());
13
+
14
+ const labelStyle = computed(() => ({
15
+ // Blend the theme's labelBackground at 80% opacity so the axis grid
16
+ // shows through — keeps the badge from looking like an opaque block
17
+ // while still masking whatever tick sits underneath.
18
+ background: `color-mix(in srgb, ${theme.value.crosshair.labelBackground} 80%, transparent)`,
19
+ color: theme.value.crosshair.labelTextColor,
20
+ fontSize: theme.value.axis.fontSize + 'px',
21
+ fontFamily: theme.value.typography.fontFamily,
22
+ fontVariantNumeric: 'tabular-nums' as const,
23
+ padding: '2px 6px',
24
+ borderRadius: '2px',
25
+ whiteSpace: 'nowrap' as const,
26
+ pointerEvents: 'none' as const,
27
+ // Sit above axis ticks (z:0) but below the YLabel badge (z:3) so the
28
+ // live last-value stays visible when the crosshair crosses its row.
29
+ zIndex: 2,
30
+ }));
31
+ </script>
32
+
33
+ <template>
34
+ <template v-if="position">
35
+ <!-- Y label on right axis -->
36
+ <div
37
+ :style="{
38
+ position: 'absolute',
39
+ right: '0',
40
+ top: position.mediaY + 'px',
41
+ transform: 'translateY(-50%)',
42
+ ...labelStyle,
43
+ }"
44
+ >
45
+ {{ chart.yScale.formatY(position.y) }}
46
+ </div>
47
+ <!-- Time label on bottom axis -->
48
+ <div
49
+ :style="{
50
+ position: 'absolute',
51
+ bottom: '0',
52
+ left: position.mediaX + 'px',
53
+ transform: 'translateX(-50%)',
54
+ ...labelStyle,
55
+ }"
56
+ >
57
+ {{ formatTime(position.time, dataInterval) }}
58
+ </div>
59
+ </template>
60
+ </template>
@@ -0,0 +1,196 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ type OHLCData,
4
+ type SeriesSnapshot,
5
+ type SnapshotSort,
6
+ type TimePoint,
7
+ type TooltipFormatter,
8
+ buildHoverSnapshots,
9
+ buildLastSnapshots,
10
+ formatCompact,
11
+ formatPriceAdaptive,
12
+ formatTime,
13
+ resolveCandlestickBodyColor,
14
+ } from '@wick-charts/core';
15
+ import { computed, inject, onMounted, onUnmounted, ref, useSlots } from 'vue';
16
+
17
+ import { useCrosshairPosition } from '../composables';
18
+ import { InfoBarAnchorKey, ThemeKey, useChartInstance } from '../context';
19
+
20
+ const props = defineProps<{
21
+ sort?: SnapshotSort;
22
+ format?: TooltipFormatter;
23
+ }>();
24
+
25
+ defineSlots<{
26
+ default?(ctx: { snapshots: readonly SeriesSnapshot[]; time: number; isHover: boolean }): unknown;
27
+ }>();
28
+
29
+ // Use computed fallbacks — `withDefaults` is inconsistent for function props.
30
+ const effectiveSort = computed<SnapshotSort>(() => props.sort ?? 'none');
31
+ const effectiveFormat = computed<TooltipFormatter>(
32
+ () =>
33
+ props.format ?? ((v: number, field: string) => (field === 'volume' ? formatCompact(v) : formatPriceAdaptive(v))),
34
+ );
35
+
36
+ const chart = useChartInstance();
37
+ const crosshair = useCrosshairPosition(chart);
38
+ const theme = inject(ThemeKey);
39
+ const anchor = inject(InfoBarAnchorKey);
40
+
41
+ // Match the "loud failure" contract of other Vue overlays — misuse outside
42
+ // `<ChartContainer>` should throw rather than render nothing silently.
43
+ if (!theme) {
44
+ throw new Error('<InfoBar> must be used within <ChartContainer>: missing ThemeKey.');
45
+ }
46
+ if (!anchor) {
47
+ throw new Error('<InfoBar> must be used within <ChartContainer>: missing InfoBarAnchorKey.');
48
+ }
49
+
50
+ // `bump` re-runs computed values after any overlay-relevant mutation
51
+ // (dataUpdate / seriesChange / setSeriesVisible / setTheme / option changes).
52
+ const bump = ref(0);
53
+ const onOverlayChange = () => {
54
+ bump.value++;
55
+ };
56
+ onMounted(() => {
57
+ chart.on('overlayChange', onOverlayChange);
58
+ // Catch-up: a sibling series' mount effect may have registered data in the
59
+ // same synchronous flush, so poke the computed now.
60
+ if (chart.getSeriesIds().length > 0) bump.value++;
61
+ });
62
+ onUnmounted(() => {
63
+ chart.off('overlayChange', onOverlayChange);
64
+ });
65
+
66
+ // Hover-over-the-y-axis gap: the overlay canvas includes the y-axis strip,
67
+ // so a crosshair event fires for offsets past the plotted data. The
68
+ // nearest-time lookup then snaps to an out-of-range timestamp and returns
69
+ // no samples. Falling back to the last-mode snapshots keeps the bar
70
+ // populated (showing last values at 0.6 opacity) instead of blinking out
71
+ // every time the pointer grazes the y-axis.
72
+ const hoverSnapshots = computed<readonly SeriesSnapshot[] | null>(() => {
73
+ void bump.value;
74
+ const pos = crosshair.value;
75
+ if (pos === null) return null;
76
+
77
+ return buildHoverSnapshots(chart, { time: pos.time, sort: effectiveSort.value, cacheKey: 'infobar-hover' });
78
+ });
79
+
80
+ const lastSnapshots = computed<readonly SeriesSnapshot[]>(() => {
81
+ void bump.value;
82
+
83
+ return buildLastSnapshots(chart, { sort: effectiveSort.value, cacheKey: 'infobar-last' });
84
+ });
85
+
86
+ const isHover = computed(() => hoverSnapshots.value !== null && hoverSnapshots.value.length > 0);
87
+ const snapshots = computed<readonly SeriesSnapshot[]>(() =>
88
+ isHover.value ? (hoverSnapshots.value as readonly SeriesSnapshot[]) : lastSnapshots.value,
89
+ );
90
+
91
+ const dataInterval = computed(() => chart.getDataInterval());
92
+ const displayTime = computed(() => {
93
+ if (snapshots.value.length === 0) return 0;
94
+ if (isHover.value && crosshair.value) return crosshair.value.time;
95
+
96
+ return Math.max(...snapshots.value.map((s) => s.data.time));
97
+ });
98
+
99
+ const slots = useSlots();
100
+ const hasCustomSlot = computed(() => typeof slots.default === 'function');
101
+
102
+ function isOHLC(data: OHLCData | TimePoint): data is OHLCData {
103
+ return 'open' in data;
104
+ }
105
+ </script>
106
+
107
+ <template>
108
+ <Teleport v-if="anchor && theme && snapshots.length > 0" :to="anchor">
109
+ <div
110
+ data-tooltip-legend=""
111
+ :style="
112
+ hasCustomSlot
113
+ ? {
114
+ display: 'flex',
115
+ alignItems: 'center',
116
+ flexShrink: 0,
117
+ fontSize: theme.typography.fontSize + 'px',
118
+ fontFamily: theme.typography.fontFamily,
119
+ fontVariantNumeric: 'tabular-nums',
120
+ opacity: isHover ? 1 : 0.6,
121
+ transition: 'opacity 0.2s ease',
122
+ pointerEvents: 'none',
123
+ }
124
+ : {
125
+ display: 'flex',
126
+ alignItems: 'center',
127
+ gap: '4px',
128
+ flexWrap: 'wrap',
129
+ padding: '4px 8px',
130
+ flexShrink: 0,
131
+ fontSize: theme.typography.fontSize + 'px',
132
+ fontFamily: theme.typography.fontFamily,
133
+ fontVariantNumeric: 'tabular-nums',
134
+ opacity: isHover ? 1 : 0.6,
135
+ transition: 'opacity 0.2s ease',
136
+ pointerEvents: 'none',
137
+ }
138
+ "
139
+ >
140
+ <slot v-if="hasCustomSlot" :snapshots="snapshots" :time="displayTime" :is-hover="isHover" />
141
+ <template v-else>
142
+ <span :style="{ color: theme.axis.textColor, marginRight: '2px' }">
143
+ {{ formatTime(displayTime, dataInterval) }}
144
+ </span>
145
+ <template v-for="s in snapshots" :key="s.id">
146
+ <span v-if="isOHLC(s.data)" :style="{ display: 'inline-flex', alignItems: 'center', gap: '4px' }">
147
+ <template
148
+ v-for="cell in [
149
+ { label: 'O', field: 'open' as const, val: (s.data as OHLCData).open },
150
+ { label: 'H', field: 'high' as const, val: (s.data as OHLCData).high },
151
+ { label: 'L', field: 'low' as const, val: (s.data as OHLCData).low },
152
+ { label: 'C', field: 'close' as const, val: (s.data as OHLCData).close },
153
+ ]"
154
+ :key="cell.label"
155
+ >
156
+ <span :style="{ color: theme.axis.textColor, opacity: 0.5, marginLeft: '5px' }">{{ cell.label }}</span>
157
+ <span
158
+ :style="{
159
+ color: resolveCandlestickBodyColor(
160
+ (s.data as OHLCData).close >= (s.data as OHLCData).open
161
+ ? theme.candlestick.up.body
162
+ : theme.candlestick.down.body,
163
+ ),
164
+ fontWeight: 500,
165
+ marginLeft: '2px',
166
+ }"
167
+ >
168
+ {{ effectiveFormat(cell.val, cell.field) }}
169
+ </span>
170
+ </template>
171
+ <template v-if="(s.data as OHLCData).volume != null">
172
+ <span :style="{ color: theme.axis.textColor, opacity: 0.5, marginLeft: '5px' }">V</span>
173
+ <span :style="{ color: theme.axis.textColor, fontWeight: 500, marginLeft: '2px' }">
174
+ {{ effectiveFormat((s.data as OHLCData).volume!, 'volume') }}
175
+ </span>
176
+ </template>
177
+ </span>
178
+ <span v-else :style="{ display: 'inline-flex', alignItems: 'center', gap: '3px' }">
179
+ <span
180
+ :style="{
181
+ width: '6px',
182
+ height: '6px',
183
+ borderRadius: '50%',
184
+ background: s.color,
185
+ flexShrink: 0,
186
+ }"
187
+ />
188
+ <span :style="{ color: s.color, fontWeight: 500 }">
189
+ {{ effectiveFormat((s.data as TimePoint).value, 'value') }}
190
+ </span>
191
+ </span>
192
+ </template>
193
+ </template>
194
+ </div>
195
+ </Teleport>
196
+ </template>
@@ -0,0 +1,302 @@
1
+ <script setup lang="ts">
2
+ import type { ChartInstance, LegendItem } from '@wick-charts/core';
3
+ import { computed, inject, onMounted, onUnmounted, ref, useSlots } from 'vue';
4
+
5
+ import { LegendAnchorKey, LegendRightAnchorKey, ThemeKey, useChartInstance } from '../context';
6
+
7
+ /**
8
+ * Minimal visual shape the {@link LegendProps.items} override accepts. The
9
+ * canonical {@link LegendItem} (re-exported from `@wick-charts/core`) carries
10
+ * the full identity + closures; those aren't meaningful for a pre-baked,
11
+ * non-interactive legend.
12
+ */
13
+ export interface LegendItemOverride {
14
+ label: string;
15
+ color: string;
16
+ }
17
+
18
+ type LegendMode = 'toggle' | 'isolate' | 'solo';
19
+
20
+ const props = withDefaults(
21
+ defineProps<{
22
+ items?: LegendItemOverride[];
23
+ position?: 'bottom' | 'right';
24
+ /** `'isolate'` shows only the clicked item; `'solo'` is a @deprecated alias. */
25
+ mode?: LegendMode;
26
+ }>(),
27
+ {
28
+ position: 'bottom',
29
+ mode: 'toggle',
30
+ },
31
+ );
32
+
33
+ defineSlots<{
34
+ default?(ctx: { items: readonly LegendItem[] }): unknown;
35
+ }>();
36
+
37
+ const chart = useChartInstance();
38
+ const theme = inject(ThemeKey);
39
+ const bottomAnchor = inject(LegendAnchorKey);
40
+ const rightAnchor = inject(LegendRightAnchorKey);
41
+
42
+ if (!theme) {
43
+ throw new Error('<Legend> must be used within <ChartContainer>: missing ThemeKey.');
44
+ }
45
+ if (!bottomAnchor || !rightAnchor) {
46
+ throw new Error('<Legend> must be used within <ChartContainer>: missing legend anchors.');
47
+ }
48
+
49
+ const anchor = computed(() => (props.position === 'right' ? rightAnchor.value : bottomAnchor.value));
50
+
51
+ const isolatedId = ref<string | null>(null);
52
+ const bump = ref(0);
53
+
54
+ const overlayHandler = () => {
55
+ bump.value++;
56
+ };
57
+ const seriesChangeHandler = () => {
58
+ isolatedId.value = null;
59
+ };
60
+
61
+ onMounted(() => {
62
+ chart.on('overlayChange', overlayHandler);
63
+ chart.on('seriesChange', seriesChangeHandler);
64
+ if (chart.getSeriesIds().length > 0) bump.value++;
65
+ });
66
+
67
+ onUnmounted(() => {
68
+ chart.off('overlayChange', overlayHandler);
69
+ chart.off('seriesChange', seriesChangeHandler);
70
+ });
71
+
72
+ interface MakeItemArgs {
73
+ id: string;
74
+ seriesId: string;
75
+ layerIndex: number | undefined;
76
+ label: string;
77
+ color: string;
78
+ isDisabled: boolean;
79
+ }
80
+
81
+ function makeItem(args: MakeItemArgs): LegendItem {
82
+ const { id, seriesId, layerIndex, label, color, isDisabled } = args;
83
+
84
+ const toggle = () => {
85
+ if (layerIndex !== undefined) {
86
+ chart.setLayerVisible(seriesId, layerIndex, !chart.isLayerVisible(seriesId, layerIndex));
87
+ } else {
88
+ chart.setSeriesVisible(seriesId, !chart.isSeriesVisible(seriesId));
89
+ }
90
+ };
91
+
92
+ const isolate = () => {
93
+ if (isolatedId.value === id) {
94
+ chart.batch(() => {
95
+ for (const sid of chart.getSeriesIds()) {
96
+ chart.setSeriesVisible(sid, true);
97
+ const layers = chart.getSeriesLayers(sid);
98
+ if (layers) {
99
+ for (let i = 0; i < layers.length; i++) chart.setLayerVisible(sid, i, true);
100
+ }
101
+ }
102
+ });
103
+ isolatedId.value = null;
104
+
105
+ return;
106
+ }
107
+
108
+ chart.batch(() => {
109
+ for (const sid of chart.getSeriesIds()) {
110
+ const layers = chart.getSeriesLayers(sid);
111
+ if (layers) {
112
+ chart.setSeriesVisible(sid, sid === seriesId);
113
+ for (let i = 0; i < layers.length; i++) {
114
+ chart.setLayerVisible(sid, i, sid === seriesId && i === layerIndex);
115
+ }
116
+ } else {
117
+ chart.setSeriesVisible(sid, sid === id);
118
+ }
119
+ }
120
+ });
121
+ isolatedId.value = id;
122
+ };
123
+
124
+ return { id, seriesId, layerIndex, label, color, isDisabled, toggle, isolate };
125
+ }
126
+
127
+ function buildLegendItems(c: ChartInstance): LegendItem[] {
128
+ const items: LegendItem[] = [];
129
+ for (const seriesId of c.getSeriesIds()) {
130
+ const layers = c.getSeriesLayers(seriesId);
131
+ if (layers) {
132
+ const baseLabel = c.getSeriesLabel(seriesId);
133
+ for (let layerIndex = 0; layerIndex < layers.length; layerIndex++) {
134
+ const id = `${seriesId}_layer${layerIndex}`;
135
+ const visible = c.isSeriesVisible(seriesId) && c.isLayerVisible(seriesId, layerIndex);
136
+ items.push(
137
+ makeItem({
138
+ id,
139
+ seriesId,
140
+ layerIndex,
141
+ label: baseLabel ? `${baseLabel} ${layerIndex + 1}` : `Series ${layerIndex + 1}`,
142
+ color: layers[layerIndex].color,
143
+ isDisabled: !visible,
144
+ }),
145
+ );
146
+ }
147
+ } else {
148
+ const color = c.getSeriesColor(seriesId);
149
+ if (!color) continue;
150
+
151
+ const label = c.getSeriesLabel(seriesId);
152
+ const visible = c.isSeriesVisible(seriesId);
153
+ items.push(
154
+ makeItem({
155
+ id: seriesId,
156
+ seriesId,
157
+ layerIndex: undefined,
158
+ label: label ?? 'Series',
159
+ color,
160
+ isDisabled: !visible,
161
+ }),
162
+ );
163
+ }
164
+ }
165
+
166
+ return items;
167
+ }
168
+
169
+ const legendItems = computed<LegendItem[]>(() => {
170
+ void bump.value;
171
+ void isolatedId.value;
172
+
173
+ return buildLegendItems(chart);
174
+ });
175
+
176
+ const slots = useSlots();
177
+ const hasCustomSlot = computed(() => typeof slots.default === 'function');
178
+
179
+ function handleClick(item: LegendItem) {
180
+ if (props.mode === 'isolate' || props.mode === 'solo') item.isolate();
181
+ else item.toggle();
182
+ }
183
+ </script>
184
+
185
+ <template>
186
+ <Teleport v-if="anchor && theme" :to="anchor">
187
+ <div
188
+ v-if="hasCustomSlot && legendItems.length > 0"
189
+ :data-legend="position"
190
+ :style="{
191
+ display: 'flex',
192
+ flexDirection: position === 'right' ? 'column' : 'row',
193
+ flexWrap: 'wrap',
194
+ gap: position === 'right' ? '6px' : '14px',
195
+ padding: position === 'right' ? '8px 6px' : '6px 8px',
196
+ alignItems: position === 'right' ? 'flex-start' : 'center',
197
+ justifyContent: position === 'right' ? 'flex-start' : 'center',
198
+ fontFamily: theme.typography.fontFamily,
199
+ fontSize: theme.axis.fontSize + 'px',
200
+ color: theme.axis.textColor,
201
+ pointerEvents: 'auto',
202
+ flexShrink: 0,
203
+ }"
204
+ >
205
+ <slot :items="legendItems" />
206
+ </div>
207
+
208
+ <div
209
+ v-else-if="items && items.length > 0"
210
+ :data-legend="position"
211
+ :style="{
212
+ display: 'flex',
213
+ flexDirection: position === 'right' ? 'column' : 'row',
214
+ flexWrap: 'wrap',
215
+ gap: position === 'right' ? '6px' : '14px',
216
+ padding: position === 'right' ? '8px 6px' : '6px 8px',
217
+ alignItems: position === 'right' ? 'flex-start' : 'center',
218
+ justifyContent: position === 'right' ? 'flex-start' : 'center',
219
+ fontFamily: theme.typography.fontFamily,
220
+ fontSize: theme.axis.fontSize + 'px',
221
+ color: theme.axis.textColor,
222
+ pointerEvents: 'auto',
223
+ flexShrink: 0,
224
+ }"
225
+ >
226
+ <div
227
+ v-for="(item, i) in items"
228
+ :key="i"
229
+ :style="{
230
+ display: 'flex',
231
+ alignItems: 'center',
232
+ gap: '4px',
233
+ userSelect: 'none',
234
+ }"
235
+ >
236
+ <span
237
+ :style="{
238
+ width: '8px',
239
+ height: '8px',
240
+ borderRadius: '2px',
241
+ background: item.color,
242
+ flexShrink: 0,
243
+ }"
244
+ />
245
+ <span style="white-space: nowrap">{{ item.label }}</span>
246
+ </div>
247
+ </div>
248
+
249
+ <div
250
+ v-else-if="legendItems.length > 0"
251
+ :data-legend="position"
252
+ :style="{
253
+ display: 'flex',
254
+ flexDirection: position === 'right' ? 'column' : 'row',
255
+ flexWrap: 'wrap',
256
+ gap: position === 'right' ? '6px' : '14px',
257
+ padding: position === 'right' ? '8px 6px' : '6px 8px',
258
+ alignItems: position === 'right' ? 'flex-start' : 'center',
259
+ justifyContent: position === 'right' ? 'flex-start' : 'center',
260
+ fontFamily: theme.typography.fontFamily,
261
+ fontSize: theme.axis.fontSize + 'px',
262
+ color: theme.axis.textColor,
263
+ pointerEvents: 'auto',
264
+ flexShrink: 0,
265
+ }"
266
+ >
267
+ <button
268
+ v-for="item in legendItems"
269
+ :key="item.id"
270
+ type="button"
271
+ @click="handleClick(item)"
272
+ :style="{
273
+ display: 'flex',
274
+ alignItems: 'center',
275
+ gap: '4px',
276
+ cursor: 'pointer',
277
+ opacity: item.isDisabled ? 0.35 : 1,
278
+ transition: 'opacity 0.15s ease',
279
+ userSelect: 'none',
280
+ border: 'none',
281
+ background: 'transparent',
282
+ padding: 0,
283
+ margin: 0,
284
+ font: 'inherit',
285
+ color: 'inherit',
286
+ textAlign: 'left',
287
+ }"
288
+ >
289
+ <span
290
+ :style="{
291
+ width: '8px',
292
+ height: '8px',
293
+ borderRadius: '2px',
294
+ background: item.color,
295
+ flexShrink: 0,
296
+ }"
297
+ />
298
+ <span style="white-space: nowrap">{{ item.label }}</span>
299
+ </button>
300
+ </div>
301
+ </Teleport>
302
+ </template>
@@ -0,0 +1,98 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref } from 'vue';
3
+
4
+ const props = withDefaults(
5
+ defineProps<{
6
+ value: number;
7
+ /** Value-to-string formatter. Defaults to the current locale's `Intl.NumberFormat`. */
8
+ format?: (value: number) => string;
9
+ locale?: string;
10
+ spinDuration?: number;
11
+ }>(),
12
+ {
13
+ locale: 'en-US',
14
+ spinDuration: 350,
15
+ },
16
+ );
17
+
18
+ const mounted = ref(false);
19
+ onMounted(() => {
20
+ mounted.value = true;
21
+ });
22
+
23
+ const defaultFormat = computed(() => {
24
+ const nf = new Intl.NumberFormat(props.locale);
25
+ return (v: number) => nf.format(v);
26
+ });
27
+
28
+ interface CharPart {
29
+ type: 'digit' | 'symbol';
30
+ value: string;
31
+ }
32
+
33
+ const parts = computed<CharPart[]>(() => {
34
+ const fn = props.format ?? defaultFormat.value;
35
+ const formatted = fn(props.value);
36
+ const result: CharPart[] = [];
37
+ for (const char of formatted) {
38
+ if (char >= '0' && char <= '9') {
39
+ result.push({ type: 'digit', value: char });
40
+ } else {
41
+ result.push({ type: 'symbol', value: char });
42
+ }
43
+ }
44
+ return result;
45
+ });
46
+
47
+ const digits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
48
+ </script>
49
+
50
+ <template>
51
+ <span
52
+ :style="{
53
+ display: 'inline-flex',
54
+ fontVariantNumeric: 'tabular-nums',
55
+ lineHeight: 1.2,
56
+ }"
57
+ >
58
+ <template v-for="(part, i) in parts" :key="i">
59
+ <!-- Digit slot with spinner -->
60
+ <span
61
+ v-if="part.type === 'digit'"
62
+ :style="{
63
+ display: 'inline-block',
64
+ height: '1.2em',
65
+ overflow: 'hidden',
66
+ position: 'relative',
67
+ }"
68
+ >
69
+ <span
70
+ :style="{
71
+ display: 'flex',
72
+ flexDirection: 'column',
73
+ transform: 'translateY(' + (-parseInt(part.value) * 1.2) + 'em)',
74
+ transition: mounted
75
+ ? 'transform ' + spinDuration + 'ms cubic-bezier(0.16, 1, 0.3, 1)'
76
+ : 'none',
77
+ }"
78
+ >
79
+ <span
80
+ v-for="d in digits"
81
+ :key="d"
82
+ :style="{
83
+ display: 'flex',
84
+ alignItems: 'center',
85
+ justifyContent: 'center',
86
+ height: '1.2em',
87
+ }"
88
+ >{{ d }}</span>
89
+ </span>
90
+ </span>
91
+ <!-- Symbol (non-digit) -->
92
+ <span
93
+ v-else
94
+ :style="{ display: 'inline-block' }"
95
+ >{{ part.value }}</span>
96
+ </template>
97
+ </span>
98
+ </template>