@wick-charts/vue 0.2.2 → 0.2.3
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 +28 -3
- package/src/BarSeries.vue +59 -0
- package/src/CandlestickSeries.vue +71 -0
- package/src/ChartContainer.vue +141 -0
- package/src/LineSeries.vue +59 -0
- package/src/PieSeries.vue +50 -0
- package/src/composables.ts +60 -0
- package/src/context.ts +31 -0
- package/src/index.ts +104 -0
- package/src/shims-vue.d.ts +6 -0
- package/src/ui/Crosshair.vue +60 -0
- package/src/ui/InfoBar.vue +194 -0
- package/src/ui/Legend.vue +302 -0
- package/src/ui/NumberFlow.vue +98 -0
- package/src/ui/PieLegend.vue +126 -0
- package/src/ui/PieTooltip.vue +200 -0
- package/src/ui/TimeAxis.vue +92 -0
- package/src/ui/Title.vue +57 -0
- package/src/ui/Tooltip.vue +260 -0
- package/src/ui/YAxis.vue +100 -0
- package/src/ui/YLabel.vue +194 -0
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
} from '@wick-charts/core';
|
|
14
|
+
import { computed, inject, onMounted, onUnmounted, ref, useSlots } from 'vue';
|
|
15
|
+
|
|
16
|
+
import { useCrosshairPosition } from '../composables';
|
|
17
|
+
import { InfoBarAnchorKey, ThemeKey, useChartInstance } from '../context';
|
|
18
|
+
|
|
19
|
+
const props = defineProps<{
|
|
20
|
+
sort?: SnapshotSort;
|
|
21
|
+
format?: TooltipFormatter;
|
|
22
|
+
}>();
|
|
23
|
+
|
|
24
|
+
defineSlots<{
|
|
25
|
+
default?(ctx: { snapshots: readonly SeriesSnapshot[]; time: number; isHover: boolean }): unknown;
|
|
26
|
+
}>();
|
|
27
|
+
|
|
28
|
+
// Use computed fallbacks — `withDefaults` is inconsistent for function props.
|
|
29
|
+
const effectiveSort = computed<SnapshotSort>(() => props.sort ?? 'none');
|
|
30
|
+
const effectiveFormat = computed<TooltipFormatter>(
|
|
31
|
+
() =>
|
|
32
|
+
props.format ?? ((v: number, field: string) => (field === 'volume' ? formatCompact(v) : formatPriceAdaptive(v))),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const chart = useChartInstance();
|
|
36
|
+
const crosshair = useCrosshairPosition(chart);
|
|
37
|
+
const theme = inject(ThemeKey);
|
|
38
|
+
const anchor = inject(InfoBarAnchorKey);
|
|
39
|
+
|
|
40
|
+
// Match the "loud failure" contract of other Vue overlays — misuse outside
|
|
41
|
+
// `<ChartContainer>` should throw rather than render nothing silently.
|
|
42
|
+
if (!theme) {
|
|
43
|
+
throw new Error('<InfoBar> must be used within <ChartContainer>: missing ThemeKey.');
|
|
44
|
+
}
|
|
45
|
+
if (!anchor) {
|
|
46
|
+
throw new Error('<InfoBar> must be used within <ChartContainer>: missing InfoBarAnchorKey.');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// `bump` re-runs computed values after any overlay-relevant mutation
|
|
50
|
+
// (dataUpdate / seriesChange / setSeriesVisible / setTheme / option changes).
|
|
51
|
+
const bump = ref(0);
|
|
52
|
+
const onOverlayChange = () => {
|
|
53
|
+
bump.value++;
|
|
54
|
+
};
|
|
55
|
+
onMounted(() => {
|
|
56
|
+
chart.on('overlayChange', onOverlayChange);
|
|
57
|
+
// Catch-up: a sibling series' mount effect may have registered data in the
|
|
58
|
+
// same synchronous flush, so poke the computed now.
|
|
59
|
+
if (chart.getSeriesIds().length > 0) bump.value++;
|
|
60
|
+
});
|
|
61
|
+
onUnmounted(() => {
|
|
62
|
+
chart.off('overlayChange', onOverlayChange);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Hover-over-the-y-axis gap: the overlay canvas includes the y-axis strip,
|
|
66
|
+
// so a crosshair event fires for offsets past the plotted data. The
|
|
67
|
+
// nearest-time lookup then snaps to an out-of-range timestamp and returns
|
|
68
|
+
// no samples. Falling back to the last-mode snapshots keeps the bar
|
|
69
|
+
// populated (showing last values at 0.6 opacity) instead of blinking out
|
|
70
|
+
// every time the pointer grazes the y-axis.
|
|
71
|
+
const hoverSnapshots = computed<readonly SeriesSnapshot[] | null>(() => {
|
|
72
|
+
void bump.value;
|
|
73
|
+
const pos = crosshair.value;
|
|
74
|
+
if (pos === null) return null;
|
|
75
|
+
|
|
76
|
+
return buildHoverSnapshots(chart, { time: pos.time, sort: effectiveSort.value, cacheKey: 'infobar-hover' });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const lastSnapshots = computed<readonly SeriesSnapshot[]>(() => {
|
|
80
|
+
void bump.value;
|
|
81
|
+
|
|
82
|
+
return buildLastSnapshots(chart, { sort: effectiveSort.value, cacheKey: 'infobar-last' });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const isHover = computed(() => hoverSnapshots.value !== null && hoverSnapshots.value.length > 0);
|
|
86
|
+
const snapshots = computed<readonly SeriesSnapshot[]>(() =>
|
|
87
|
+
isHover.value ? (hoverSnapshots.value as readonly SeriesSnapshot[]) : lastSnapshots.value,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const dataInterval = computed(() => chart.getDataInterval());
|
|
91
|
+
const displayTime = computed(() => {
|
|
92
|
+
if (snapshots.value.length === 0) return 0;
|
|
93
|
+
if (isHover.value && crosshair.value) return crosshair.value.time;
|
|
94
|
+
|
|
95
|
+
return Math.max(...snapshots.value.map((s) => s.data.time));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const slots = useSlots();
|
|
99
|
+
const hasCustomSlot = computed(() => typeof slots.default === 'function');
|
|
100
|
+
|
|
101
|
+
function isOHLC(data: OHLCData | TimePoint): data is OHLCData {
|
|
102
|
+
return 'open' in data;
|
|
103
|
+
}
|
|
104
|
+
</script>
|
|
105
|
+
|
|
106
|
+
<template>
|
|
107
|
+
<Teleport v-if="anchor && theme && snapshots.length > 0" :to="anchor">
|
|
108
|
+
<div
|
|
109
|
+
data-tooltip-legend=""
|
|
110
|
+
:style="
|
|
111
|
+
hasCustomSlot
|
|
112
|
+
? {
|
|
113
|
+
display: 'flex',
|
|
114
|
+
alignItems: 'center',
|
|
115
|
+
flexShrink: 0,
|
|
116
|
+
fontSize: theme.typography.fontSize + 'px',
|
|
117
|
+
fontFamily: theme.typography.fontFamily,
|
|
118
|
+
fontVariantNumeric: 'tabular-nums',
|
|
119
|
+
opacity: isHover ? 1 : 0.6,
|
|
120
|
+
transition: 'opacity 0.2s ease',
|
|
121
|
+
pointerEvents: 'none',
|
|
122
|
+
}
|
|
123
|
+
: {
|
|
124
|
+
display: 'flex',
|
|
125
|
+
alignItems: 'center',
|
|
126
|
+
gap: '4px',
|
|
127
|
+
flexWrap: 'wrap',
|
|
128
|
+
padding: '4px 8px',
|
|
129
|
+
flexShrink: 0,
|
|
130
|
+
fontSize: theme.typography.fontSize + 'px',
|
|
131
|
+
fontFamily: theme.typography.fontFamily,
|
|
132
|
+
fontVariantNumeric: 'tabular-nums',
|
|
133
|
+
opacity: isHover ? 1 : 0.6,
|
|
134
|
+
transition: 'opacity 0.2s ease',
|
|
135
|
+
pointerEvents: 'none',
|
|
136
|
+
}
|
|
137
|
+
"
|
|
138
|
+
>
|
|
139
|
+
<slot v-if="hasCustomSlot" :snapshots="snapshots" :time="displayTime" :is-hover="isHover" />
|
|
140
|
+
<template v-else>
|
|
141
|
+
<span :style="{ color: theme.axis.textColor, marginRight: '2px' }">
|
|
142
|
+
{{ formatTime(displayTime, dataInterval) }}
|
|
143
|
+
</span>
|
|
144
|
+
<template v-for="s in snapshots" :key="s.id">
|
|
145
|
+
<span v-if="isOHLC(s.data)" :style="{ display: 'inline-flex', alignItems: 'center', gap: '4px' }">
|
|
146
|
+
<template
|
|
147
|
+
v-for="cell in [
|
|
148
|
+
{ label: 'O', field: 'open' as const, val: (s.data as OHLCData).open },
|
|
149
|
+
{ label: 'H', field: 'high' as const, val: (s.data as OHLCData).high },
|
|
150
|
+
{ label: 'L', field: 'low' as const, val: (s.data as OHLCData).low },
|
|
151
|
+
{ label: 'C', field: 'close' as const, val: (s.data as OHLCData).close },
|
|
152
|
+
]"
|
|
153
|
+
:key="cell.label"
|
|
154
|
+
>
|
|
155
|
+
<span :style="{ color: theme.axis.textColor, opacity: 0.5, marginLeft: '5px' }">{{ cell.label }}</span>
|
|
156
|
+
<span
|
|
157
|
+
:style="{
|
|
158
|
+
color:
|
|
159
|
+
(s.data as OHLCData).close >= (s.data as OHLCData).open
|
|
160
|
+
? theme.candlestick.upColor
|
|
161
|
+
: theme.candlestick.downColor,
|
|
162
|
+
fontWeight: 500,
|
|
163
|
+
marginLeft: '2px',
|
|
164
|
+
}"
|
|
165
|
+
>
|
|
166
|
+
{{ effectiveFormat(cell.val, cell.field) }}
|
|
167
|
+
</span>
|
|
168
|
+
</template>
|
|
169
|
+
<template v-if="(s.data as OHLCData).volume != null">
|
|
170
|
+
<span :style="{ color: theme.axis.textColor, opacity: 0.5, marginLeft: '5px' }">V</span>
|
|
171
|
+
<span :style="{ color: theme.axis.textColor, fontWeight: 500, marginLeft: '2px' }">
|
|
172
|
+
{{ effectiveFormat((s.data as OHLCData).volume!, 'volume') }}
|
|
173
|
+
</span>
|
|
174
|
+
</template>
|
|
175
|
+
</span>
|
|
176
|
+
<span v-else :style="{ display: 'inline-flex', alignItems: 'center', gap: '3px' }">
|
|
177
|
+
<span
|
|
178
|
+
:style="{
|
|
179
|
+
width: '6px',
|
|
180
|
+
height: '6px',
|
|
181
|
+
borderRadius: '50%',
|
|
182
|
+
background: s.color,
|
|
183
|
+
flexShrink: 0,
|
|
184
|
+
}"
|
|
185
|
+
/>
|
|
186
|
+
<span :style="{ color: s.color, fontWeight: 500 }">
|
|
187
|
+
{{ effectiveFormat((s.data as TimePoint).value, 'value') }}
|
|
188
|
+
</span>
|
|
189
|
+
</span>
|
|
190
|
+
</template>
|
|
191
|
+
</template>
|
|
192
|
+
</div>
|
|
193
|
+
</Teleport>
|
|
194
|
+
</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.typography.axisFontSize + '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.typography.axisFontSize + '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.typography.axisFontSize + '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>
|