@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,260 @@
|
|
|
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
|
+
computeTooltipPosition,
|
|
10
|
+
formatCompact,
|
|
11
|
+
formatDate,
|
|
12
|
+
formatPriceAdaptive,
|
|
13
|
+
formatTime,
|
|
14
|
+
} from '@wick-charts/core';
|
|
15
|
+
import { computed, onMounted, onUnmounted, ref, useSlots, watchEffect } from 'vue';
|
|
16
|
+
|
|
17
|
+
import { useCrosshairPosition } from '../composables';
|
|
18
|
+
import { 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 }): unknown;
|
|
27
|
+
}>();
|
|
28
|
+
|
|
29
|
+
// Vue's `withDefaults` is inconsistent for function-typed props (sometimes
|
|
30
|
+
// treats the default as a factory, sometimes as the value). Use a computed
|
|
31
|
+
// fallback instead.
|
|
32
|
+
const effectiveSort = computed<SnapshotSort>(() => props.sort ?? 'none');
|
|
33
|
+
const effectiveFormat = computed<TooltipFormatter>(
|
|
34
|
+
() =>
|
|
35
|
+
props.format ?? ((v: number, field: string) => (field === 'volume' ? formatCompact(v) : formatPriceAdaptive(v))),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const chart = useChartInstance();
|
|
39
|
+
const crosshair = useCrosshairPosition(chart);
|
|
40
|
+
|
|
41
|
+
// `bump` re-runs computed values on any overlay-relevant chart mutation.
|
|
42
|
+
const bump = ref(0);
|
|
43
|
+
const onOverlayChange = () => {
|
|
44
|
+
bump.value++;
|
|
45
|
+
};
|
|
46
|
+
onMounted(() => {
|
|
47
|
+
chart.on('overlayChange', onOverlayChange);
|
|
48
|
+
if (chart.getSeriesIds().length > 0) bump.value++;
|
|
49
|
+
});
|
|
50
|
+
onUnmounted(() => {
|
|
51
|
+
chart.off('overlayChange', onOverlayChange);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const theme = computed(() => chart.getTheme());
|
|
55
|
+
const dataInterval = computed(() => chart.getDataInterval());
|
|
56
|
+
|
|
57
|
+
const snapshots = computed<readonly SeriesSnapshot[]>(() => {
|
|
58
|
+
void bump.value;
|
|
59
|
+
if (!crosshair.value) return [];
|
|
60
|
+
|
|
61
|
+
return buildHoverSnapshots(chart, {
|
|
62
|
+
time: crosshair.value.time,
|
|
63
|
+
sort: effectiveSort.value,
|
|
64
|
+
cacheKey: 'tooltip',
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// `crosshair.time` is the semantic truth — `snapshots[0].data.time` would
|
|
69
|
+
// shift with `sort` and, for ragged multi-layer data, disagree with the
|
|
70
|
+
// actual hover moment.
|
|
71
|
+
const displayTime = computed(() => crosshair.value?.time ?? 0);
|
|
72
|
+
|
|
73
|
+
const slots = useSlots();
|
|
74
|
+
const hasCustomSlot = computed(() => typeof slots.default === 'function');
|
|
75
|
+
|
|
76
|
+
const containerRef = ref<HTMLDivElement | null>(null);
|
|
77
|
+
const measuredSize = ref<{ width: number; height: number } | null>(null);
|
|
78
|
+
|
|
79
|
+
// `ResizeObserver` on the custom-render container — unknown content dimensions
|
|
80
|
+
// mean we can't pre-compute `tooltipWidth/Height` for `computeTooltipPosition`.
|
|
81
|
+
// Measure on mount + any content change, then flip `visibility` to visible.
|
|
82
|
+
let observer: ResizeObserver | null = null;
|
|
83
|
+
watchEffect((onCleanup) => {
|
|
84
|
+
if (!hasCustomSlot.value) return;
|
|
85
|
+
const node = containerRef.value;
|
|
86
|
+
if (!node || typeof ResizeObserver === 'undefined') return;
|
|
87
|
+
|
|
88
|
+
observer = new ResizeObserver((entries) => {
|
|
89
|
+
const box = entries[0]?.contentRect;
|
|
90
|
+
if (!box) return;
|
|
91
|
+
const prev = measuredSize.value;
|
|
92
|
+
if (!prev || prev.width !== box.width || prev.height !== box.height) {
|
|
93
|
+
measuredSize.value = { width: box.width, height: box.height };
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
observer.observe(node);
|
|
97
|
+
|
|
98
|
+
onCleanup(() => {
|
|
99
|
+
observer?.disconnect();
|
|
100
|
+
observer = null;
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const floatingPos = computed<{ left: number; top: number } | null>(() => {
|
|
105
|
+
if (!crosshair.value || snapshots.value.length === 0) return null;
|
|
106
|
+
const mediaSize = chart.getMediaSize();
|
|
107
|
+
const chartWidth = mediaSize.width - chart.yAxisWidth;
|
|
108
|
+
const chartHeight = mediaSize.height - chart.xAxisHeight;
|
|
109
|
+
|
|
110
|
+
if (hasCustomSlot.value) {
|
|
111
|
+
const size = measuredSize.value;
|
|
112
|
+
if (!size) return { left: 0, top: 0 };
|
|
113
|
+
|
|
114
|
+
return computeTooltipPosition({
|
|
115
|
+
x: crosshair.value.mediaX,
|
|
116
|
+
y: crosshair.value.mediaY,
|
|
117
|
+
chartWidth,
|
|
118
|
+
chartHeight,
|
|
119
|
+
tooltipWidth: size.width,
|
|
120
|
+
tooltipHeight: size.height,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const hasOHLC = snapshots.value.some((s) => 'open' in s.data);
|
|
125
|
+
const lineCount = snapshots.value.filter((s) => !('open' in s.data)).length;
|
|
126
|
+
const tooltipWidth = 160;
|
|
127
|
+
const tooltipHeight = hasOHLC ? 140 : 40 + lineCount * 22;
|
|
128
|
+
|
|
129
|
+
return computeTooltipPosition({
|
|
130
|
+
x: crosshair.value.mediaX,
|
|
131
|
+
y: crosshair.value.mediaY,
|
|
132
|
+
chartWidth,
|
|
133
|
+
chartHeight,
|
|
134
|
+
tooltipWidth,
|
|
135
|
+
tooltipHeight,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const measured = computed(() => !hasCustomSlot.value || measuredSize.value !== null);
|
|
140
|
+
|
|
141
|
+
function isOHLC(data: OHLCData | TimePoint): data is OHLCData {
|
|
142
|
+
return 'open' in data;
|
|
143
|
+
}
|
|
144
|
+
</script>
|
|
145
|
+
|
|
146
|
+
<template>
|
|
147
|
+
<div
|
|
148
|
+
v-if="crosshair && snapshots.length > 0 && floatingPos && !hasCustomSlot"
|
|
149
|
+
:style="{
|
|
150
|
+
position: 'absolute',
|
|
151
|
+
left: floatingPos.left + 'px',
|
|
152
|
+
top: floatingPos.top + 'px',
|
|
153
|
+
pointerEvents: 'none',
|
|
154
|
+
background: theme.tooltip.background,
|
|
155
|
+
backdropFilter: 'blur(12px)',
|
|
156
|
+
WebkitBackdropFilter: 'blur(12px)',
|
|
157
|
+
border: '1px solid ' + theme.tooltip.borderColor,
|
|
158
|
+
borderRadius: '8px',
|
|
159
|
+
padding: '10px 14px',
|
|
160
|
+
boxShadow: '0 4px 16px rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.06)',
|
|
161
|
+
fontSize: theme.typography.tooltipFontSize + 'px',
|
|
162
|
+
fontFamily: theme.typography.fontFamily,
|
|
163
|
+
fontVariantNumeric: 'tabular-nums',
|
|
164
|
+
color: theme.tooltip.textColor,
|
|
165
|
+
minWidth: '140px',
|
|
166
|
+
zIndex: 10,
|
|
167
|
+
transition: 'opacity 0.15s ease',
|
|
168
|
+
}"
|
|
169
|
+
>
|
|
170
|
+
<div
|
|
171
|
+
:style="{
|
|
172
|
+
fontSize: theme.typography.axisFontSize + 'px',
|
|
173
|
+
color: theme.axis.textColor,
|
|
174
|
+
marginBottom: '8px',
|
|
175
|
+
paddingBottom: '6px',
|
|
176
|
+
borderBottom: '1px solid ' + theme.tooltip.borderColor,
|
|
177
|
+
letterSpacing: '0.02em',
|
|
178
|
+
}"
|
|
179
|
+
>
|
|
180
|
+
{{ formatDate(displayTime) }} {{ formatTime(displayTime, dataInterval) }}
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<template v-for="s in snapshots" :key="s.id">
|
|
184
|
+
<div v-if="isOHLC(s.data)" :style="{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 12px' }">
|
|
185
|
+
<template
|
|
186
|
+
v-for="row in [
|
|
187
|
+
{ label: 'Open', val: (s.data as OHLCData).open, field: 'open' as const },
|
|
188
|
+
{ label: 'High', val: (s.data as OHLCData).high, field: 'high' as const },
|
|
189
|
+
{ label: 'Low', val: (s.data as OHLCData).low, field: 'low' as const },
|
|
190
|
+
{ label: 'Close', val: (s.data as OHLCData).close, field: 'close' as const },
|
|
191
|
+
]"
|
|
192
|
+
:key="row.label"
|
|
193
|
+
>
|
|
194
|
+
<span :style="{ opacity: 0.5 }">{{ row.label }}</span>
|
|
195
|
+
<span
|
|
196
|
+
:style="{
|
|
197
|
+
fontWeight: 600,
|
|
198
|
+
color:
|
|
199
|
+
(s.data as OHLCData).close >= (s.data as OHLCData).open
|
|
200
|
+
? theme.candlestick.upColor
|
|
201
|
+
: theme.candlestick.downColor,
|
|
202
|
+
textAlign: 'right',
|
|
203
|
+
}"
|
|
204
|
+
>{{ effectiveFormat(row.val, row.field) }}</span>
|
|
205
|
+
</template>
|
|
206
|
+
<template v-if="(s.data as OHLCData).volume != null">
|
|
207
|
+
<span :style="{ opacity: 0.5 }">Volume</span>
|
|
208
|
+
<span :style="{ fontWeight: 600, color: theme.tooltip.textColor, textAlign: 'right' }">
|
|
209
|
+
{{ effectiveFormat((s.data as OHLCData).volume!, 'volume') }}
|
|
210
|
+
</span>
|
|
211
|
+
</template>
|
|
212
|
+
</div>
|
|
213
|
+
<div v-else :style="{ display: 'flex', alignItems: 'center', gap: '8px', padding: '2px 0' }">
|
|
214
|
+
<span
|
|
215
|
+
:style="{
|
|
216
|
+
width: '8px',
|
|
217
|
+
height: '8px',
|
|
218
|
+
borderRadius: '50%',
|
|
219
|
+
background: s.color,
|
|
220
|
+
flexShrink: 0,
|
|
221
|
+
}"
|
|
222
|
+
/>
|
|
223
|
+
<span :style="{ opacity: 0.6, flex: '1' }">{{ s.label ?? 'Value' }}</span>
|
|
224
|
+
<span :style="{ fontWeight: 600, color: s.color }">
|
|
225
|
+
{{ effectiveFormat((s.data as TimePoint).value, 'value') }}
|
|
226
|
+
</span>
|
|
227
|
+
</div>
|
|
228
|
+
</template>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div
|
|
232
|
+
v-if="crosshair && snapshots.length > 0 && floatingPos && hasCustomSlot"
|
|
233
|
+
ref="containerRef"
|
|
234
|
+
:data-measured="measured ? 'true' : 'false'"
|
|
235
|
+
:style="{
|
|
236
|
+
position: 'absolute',
|
|
237
|
+
left: floatingPos.left + 'px',
|
|
238
|
+
top: floatingPos.top + 'px',
|
|
239
|
+
pointerEvents: 'none',
|
|
240
|
+
background: theme.tooltip.background,
|
|
241
|
+
backdropFilter: 'blur(12px)',
|
|
242
|
+
WebkitBackdropFilter: 'blur(12px)',
|
|
243
|
+
border: '1px solid ' + theme.tooltip.borderColor,
|
|
244
|
+
borderRadius: '8px',
|
|
245
|
+
padding: '10px 14px',
|
|
246
|
+
boxShadow: '0 4px 16px rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.06)',
|
|
247
|
+
fontFamily: theme.typography.fontFamily,
|
|
248
|
+
fontSize: theme.typography.tooltipFontSize + 'px',
|
|
249
|
+
fontVariantNumeric: 'tabular-nums',
|
|
250
|
+
color: theme.tooltip.textColor,
|
|
251
|
+
width: 'max-content',
|
|
252
|
+
maxWidth: (chart.getMediaSize().width - chart.yAxisWidth) + 'px',
|
|
253
|
+
boxSizing: 'border-box',
|
|
254
|
+
zIndex: 10,
|
|
255
|
+
visibility: measuredSize !== null ? 'visible' : 'hidden',
|
|
256
|
+
}"
|
|
257
|
+
>
|
|
258
|
+
<slot :snapshots="snapshots" :time="displayTime" />
|
|
259
|
+
</div>
|
|
260
|
+
</template>
|
package/src/ui/YAxis.vue
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ValueFormatter } from '@wick-charts/core';
|
|
3
|
+
import { computed, onMounted, onUnmounted, watch } from 'vue';
|
|
4
|
+
|
|
5
|
+
import { useVisibleRange } from '../composables';
|
|
6
|
+
import { useChartInstance } from '../context';
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
/** Custom tick-label formatter. Overrides the built-in range-adaptive default. */
|
|
10
|
+
format?: ValueFormatter;
|
|
11
|
+
}>();
|
|
12
|
+
|
|
13
|
+
interface TrackedTick {
|
|
14
|
+
opacity: number;
|
|
15
|
+
addedAt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const chart = useChartInstance();
|
|
19
|
+
// Subscribe to visible range to trigger re-renders on viewport changes
|
|
20
|
+
const visibleRange = useVisibleRange(chart);
|
|
21
|
+
|
|
22
|
+
// Route the formatter through yScale so Crosshair / YLabel fallback use
|
|
23
|
+
// the same function as the axis labels.
|
|
24
|
+
const syncFormat = () => chart.yScale.setFormat(props.format ?? null);
|
|
25
|
+
onMounted(syncFormat);
|
|
26
|
+
watch(() => props.format, syncFormat);
|
|
27
|
+
onUnmounted(() => chart.yScale.setFormat(null));
|
|
28
|
+
|
|
29
|
+
const tickMap = new Map<number, TrackedTick>();
|
|
30
|
+
|
|
31
|
+
const theme = computed(() => chart.getTheme());
|
|
32
|
+
|
|
33
|
+
const allTicks = computed(() => {
|
|
34
|
+
// Access visibleRange.value to track dependency
|
|
35
|
+
void visibleRange.value;
|
|
36
|
+
|
|
37
|
+
const currentTicks = chart.yScale.niceTickValues();
|
|
38
|
+
const currentSet = new Set(currentTicks);
|
|
39
|
+
const now = performance.now();
|
|
40
|
+
|
|
41
|
+
// Mark current ticks as visible
|
|
42
|
+
for (const p of currentTicks) {
|
|
43
|
+
if (!tickMap.has(p)) {
|
|
44
|
+
tickMap.set(p, { opacity: 1, addedAt: now });
|
|
45
|
+
} else {
|
|
46
|
+
tickMap.get(p)!.opacity = 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Mark missing ticks for fade-out
|
|
51
|
+
for (const [p, entry] of tickMap) {
|
|
52
|
+
if (!currentSet.has(p)) {
|
|
53
|
+
entry.opacity = 0;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Clean up old faded-out ticks
|
|
58
|
+
for (const [p, entry] of tickMap) {
|
|
59
|
+
if (entry.opacity === 0 && now - entry.addedAt > 5000) {
|
|
60
|
+
tickMap.delete(p);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return Array.from(tickMap.entries());
|
|
65
|
+
});
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<div
|
|
70
|
+
:style="{
|
|
71
|
+
position: 'absolute',
|
|
72
|
+
right: '0',
|
|
73
|
+
top: '0',
|
|
74
|
+
bottom: chart.xAxisHeight + 'px',
|
|
75
|
+
width: chart.yAxisWidth + 'px',
|
|
76
|
+
pointerEvents: 'none',
|
|
77
|
+
}"
|
|
78
|
+
>
|
|
79
|
+
<span
|
|
80
|
+
v-for="[price, entry] in allTicks"
|
|
81
|
+
:key="price"
|
|
82
|
+
:style="{
|
|
83
|
+
position: 'absolute',
|
|
84
|
+
right: '8px',
|
|
85
|
+
top: chart.yScale.valueToY(price) + 'px',
|
|
86
|
+
transform: 'translateY(-50%)',
|
|
87
|
+
color: theme.axis.textColor,
|
|
88
|
+
fontSize: theme.typography.axisFontSize + 'px',
|
|
89
|
+
fontFamily: theme.typography.fontFamily,
|
|
90
|
+
fontVariantNumeric: 'tabular-nums',
|
|
91
|
+
userSelect: 'none',
|
|
92
|
+
opacity: entry.opacity,
|
|
93
|
+
transition: 'opacity 0.3s ease',
|
|
94
|
+
willChange: 'opacity',
|
|
95
|
+
}"
|
|
96
|
+
>
|
|
97
|
+
{{ chart.yScale.formatY(price) }}
|
|
98
|
+
</span>
|
|
99
|
+
</div>
|
|
100
|
+
</template>
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { ChartInstance, ValueFormatter } from '@wick-charts/core';
|
|
3
|
+
import { computed, onMounted, onUnmounted, ref, useSlots } from 'vue';
|
|
4
|
+
|
|
5
|
+
import { useChartInstance } from '../context';
|
|
6
|
+
import NumberFlow from './NumberFlow.vue';
|
|
7
|
+
|
|
8
|
+
/** Direction of the current value vs. previous close. Drives the badge color in the default UI. */
|
|
9
|
+
export type YLabelDirection = 'up' | 'down' | 'neutral';
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
/**
|
|
13
|
+
* Owning series id. **Optional** — when omitted, the first visible
|
|
14
|
+
* single-layer time series is picked, falling back to the first visible
|
|
15
|
+
* multi-layer time series.
|
|
16
|
+
*/
|
|
17
|
+
seriesId?: string;
|
|
18
|
+
color?: string;
|
|
19
|
+
/** Custom formatter; routed through NumberFlow's `formatter` so the digit animation still plays. */
|
|
20
|
+
format?: ValueFormatter;
|
|
21
|
+
}>();
|
|
22
|
+
|
|
23
|
+
defineSlots<{
|
|
24
|
+
default?(ctx: {
|
|
25
|
+
value: number;
|
|
26
|
+
y: number;
|
|
27
|
+
bgColor: string;
|
|
28
|
+
isLive: boolean;
|
|
29
|
+
direction: YLabelDirection;
|
|
30
|
+
format: ValueFormatter;
|
|
31
|
+
}): unknown;
|
|
32
|
+
}>();
|
|
33
|
+
|
|
34
|
+
const chart = useChartInstance();
|
|
35
|
+
const slots = useSlots();
|
|
36
|
+
const hasCustomSlot = computed(() => typeof slots.default === 'function');
|
|
37
|
+
|
|
38
|
+
// Single subscription covering data/visibility/theme/options changes, plus
|
|
39
|
+
// viewportChange for pixel-Y drift on pan/zoom where the value is unchanged
|
|
40
|
+
// but the badge must move.
|
|
41
|
+
const bump = ref(0);
|
|
42
|
+
const onChange = () => {
|
|
43
|
+
bump.value++;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
onMounted(() => {
|
|
47
|
+
chart.setYLabel(true);
|
|
48
|
+
chart.on('overlayChange', onChange);
|
|
49
|
+
chart.on('viewportChange', onChange);
|
|
50
|
+
if (chart.getSeriesIds().length > 0) bump.value++;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
onUnmounted(() => {
|
|
54
|
+
chart.setYLabel(false);
|
|
55
|
+
chart.off('overlayChange', onChange);
|
|
56
|
+
chart.off('viewportChange', onChange);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function resolveSeriesId(c: ChartInstance, explicit: string | undefined): string | null {
|
|
60
|
+
if (explicit !== undefined) return explicit;
|
|
61
|
+
|
|
62
|
+
const singleLayer = c.getSeriesIdsByType('time', { visibleOnly: true, singleLayerOnly: true });
|
|
63
|
+
if (singleLayer.length > 0) return singleLayer[0];
|
|
64
|
+
|
|
65
|
+
const anyTime = c.getSeriesIdsByType('time', { visibleOnly: true });
|
|
66
|
+
|
|
67
|
+
return anyTime.length > 0 ? anyTime[0] : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const resolvedId = computed<string | null>(() => {
|
|
71
|
+
void bump.value;
|
|
72
|
+
|
|
73
|
+
return resolveSeriesId(chart, props.seriesId);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const last = computed<{ value: number; isLive: boolean } | null>(() => {
|
|
77
|
+
void bump.value;
|
|
78
|
+
|
|
79
|
+
return resolvedId.value !== null ? chart.getStackedLastValue(resolvedId.value) : null;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const previousClose = computed<number | null>(() => {
|
|
83
|
+
void bump.value;
|
|
84
|
+
|
|
85
|
+
return resolvedId.value !== null ? chart.getPreviousClose(resolvedId.value) : null;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const theme = computed(() => {
|
|
89
|
+
void bump.value;
|
|
90
|
+
|
|
91
|
+
return chart.getTheme();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const y = computed(() => {
|
|
95
|
+
void bump.value;
|
|
96
|
+
if (last.value === null) return 0;
|
|
97
|
+
|
|
98
|
+
return chart.yScale.valueToY(last.value.value);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const direction = computed<YLabelDirection>(() => {
|
|
102
|
+
if (last.value === null || previousClose.value === null) return 'neutral';
|
|
103
|
+
if (last.value.value > previousClose.value) return 'up';
|
|
104
|
+
if (last.value.value < previousClose.value) return 'down';
|
|
105
|
+
|
|
106
|
+
return 'neutral';
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const bgColor = computed<string>(() => {
|
|
110
|
+
if (last.value === null) return theme.value.yLabel.neutralBackground;
|
|
111
|
+
if (!last.value.isLive) return theme.value.axis.textColor;
|
|
112
|
+
if (props.color) return props.color;
|
|
113
|
+
|
|
114
|
+
switch (direction.value) {
|
|
115
|
+
case 'up':
|
|
116
|
+
return theme.value.yLabel.upBackground;
|
|
117
|
+
case 'down':
|
|
118
|
+
return theme.value.yLabel.downBackground;
|
|
119
|
+
default:
|
|
120
|
+
return theme.value.yLabel.neutralBackground;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const fractionDigits = computed(() => {
|
|
125
|
+
void bump.value;
|
|
126
|
+
const yRange = chart.yScale.getRange();
|
|
127
|
+
const range = yRange.max - yRange.min;
|
|
128
|
+
if (range < 0.1) return 6;
|
|
129
|
+
if (range < 10) return 4;
|
|
130
|
+
if (range < 1000) return 2;
|
|
131
|
+
|
|
132
|
+
return 0;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const effectiveFormat = computed<ValueFormatter>(() => {
|
|
136
|
+
if (props.format) return props.format;
|
|
137
|
+
const nf = new Intl.NumberFormat('en-US', {
|
|
138
|
+
minimumFractionDigits: fractionDigits.value,
|
|
139
|
+
maximumFractionDigits: fractionDigits.value,
|
|
140
|
+
useGrouping: false,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return (v: number) => nf.format(v);
|
|
144
|
+
});
|
|
145
|
+
</script>
|
|
146
|
+
|
|
147
|
+
<template>
|
|
148
|
+
<template v-if="last !== null">
|
|
149
|
+
<slot
|
|
150
|
+
v-if="hasCustomSlot"
|
|
151
|
+
:value="last.value"
|
|
152
|
+
:y="y"
|
|
153
|
+
:bg-color="bgColor"
|
|
154
|
+
:is-live="last.isLive"
|
|
155
|
+
:direction="direction"
|
|
156
|
+
:format="effectiveFormat"
|
|
157
|
+
/>
|
|
158
|
+
<template v-else>
|
|
159
|
+
<div
|
|
160
|
+
:style="{
|
|
161
|
+
position: 'absolute',
|
|
162
|
+
left: '0',
|
|
163
|
+
right: chart.yAxisWidth + 'px',
|
|
164
|
+
top: y + 'px',
|
|
165
|
+
height: '0',
|
|
166
|
+
borderTop: '1px dashed ' + bgColor,
|
|
167
|
+
opacity: 0.5,
|
|
168
|
+
pointerEvents: 'none',
|
|
169
|
+
zIndex: 2,
|
|
170
|
+
}"
|
|
171
|
+
/>
|
|
172
|
+
<div
|
|
173
|
+
:style="{
|
|
174
|
+
position: 'absolute',
|
|
175
|
+
right: '4px',
|
|
176
|
+
top: y + 'px',
|
|
177
|
+
transform: 'translateY(-50%)',
|
|
178
|
+
pointerEvents: 'auto',
|
|
179
|
+
zIndex: 3,
|
|
180
|
+
background: bgColor,
|
|
181
|
+
color: theme.yLabel.textColor,
|
|
182
|
+
fontSize: theme.typography.yFontSize + 'px',
|
|
183
|
+
fontFamily: theme.typography.fontFamily,
|
|
184
|
+
padding: '3px 8px',
|
|
185
|
+
borderRadius: '3px',
|
|
186
|
+
whiteSpace: 'nowrap',
|
|
187
|
+
transition: 'background-color 0.3s ease',
|
|
188
|
+
}"
|
|
189
|
+
>
|
|
190
|
+
<NumberFlow :value="last.value" :format="effectiveFormat" :spin-duration="350" />
|
|
191
|
+
</div>
|
|
192
|
+
</template>
|
|
193
|
+
</template>
|
|
194
|
+
</template>
|