@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,262 @@
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
+ resolveCandlestickBodyColor,
15
+ } from '@wick-charts/core';
16
+ import { computed, onMounted, onUnmounted, ref, useSlots, watchEffect } from 'vue';
17
+
18
+ import { useCrosshairPosition } from '../composables';
19
+ import { useChartInstance } from '../context';
20
+
21
+ const props = defineProps<{
22
+ sort?: SnapshotSort;
23
+ format?: TooltipFormatter;
24
+ }>();
25
+
26
+ defineSlots<{
27
+ default?(ctx: { snapshots: readonly SeriesSnapshot[]; time: number }): unknown;
28
+ }>();
29
+
30
+ // Vue's `withDefaults` is inconsistent for function-typed props (sometimes
31
+ // treats the default as a factory, sometimes as the value). Use a computed
32
+ // fallback instead.
33
+ const effectiveSort = computed<SnapshotSort>(() => props.sort ?? 'none');
34
+ const effectiveFormat = computed<TooltipFormatter>(
35
+ () =>
36
+ props.format ?? ((v: number, field: string) => (field === 'volume' ? formatCompact(v) : formatPriceAdaptive(v))),
37
+ );
38
+
39
+ const chart = useChartInstance();
40
+ const crosshair = useCrosshairPosition(chart);
41
+
42
+ // `bump` re-runs computed values on any overlay-relevant chart mutation.
43
+ const bump = ref(0);
44
+ const onOverlayChange = () => {
45
+ bump.value++;
46
+ };
47
+ onMounted(() => {
48
+ chart.on('overlayChange', onOverlayChange);
49
+ if (chart.getSeriesIds().length > 0) bump.value++;
50
+ });
51
+ onUnmounted(() => {
52
+ chart.off('overlayChange', onOverlayChange);
53
+ });
54
+
55
+ const theme = computed(() => chart.getTheme());
56
+ const dataInterval = computed(() => chart.getDataInterval());
57
+
58
+ const snapshots = computed<readonly SeriesSnapshot[]>(() => {
59
+ void bump.value;
60
+ if (!crosshair.value) return [];
61
+
62
+ return buildHoverSnapshots(chart, {
63
+ time: crosshair.value.time,
64
+ sort: effectiveSort.value,
65
+ cacheKey: 'tooltip',
66
+ });
67
+ });
68
+
69
+ // `crosshair.time` is the semantic truth — `snapshots[0].data.time` would
70
+ // shift with `sort` and, for ragged multi-layer data, disagree with the
71
+ // actual hover moment.
72
+ const displayTime = computed(() => crosshair.value?.time ?? 0);
73
+
74
+ const slots = useSlots();
75
+ const hasCustomSlot = computed(() => typeof slots.default === 'function');
76
+
77
+ const containerRef = ref<HTMLDivElement | null>(null);
78
+ const measuredSize = ref<{ width: number; height: number } | null>(null);
79
+
80
+ // `ResizeObserver` on the custom-render container — unknown content dimensions
81
+ // mean we can't pre-compute `tooltipWidth/Height` for `computeTooltipPosition`.
82
+ // Measure on mount + any content change, then flip `visibility` to visible.
83
+ let observer: ResizeObserver | null = null;
84
+ watchEffect((onCleanup) => {
85
+ if (!hasCustomSlot.value) return;
86
+ const node = containerRef.value;
87
+ if (!node || typeof ResizeObserver === 'undefined') return;
88
+
89
+ observer = new ResizeObserver((entries) => {
90
+ const box = entries[0]?.contentRect;
91
+ if (!box) return;
92
+ const prev = measuredSize.value;
93
+ if (!prev || prev.width !== box.width || prev.height !== box.height) {
94
+ measuredSize.value = { width: box.width, height: box.height };
95
+ }
96
+ });
97
+ observer.observe(node);
98
+
99
+ onCleanup(() => {
100
+ observer?.disconnect();
101
+ observer = null;
102
+ });
103
+ });
104
+
105
+ const floatingPos = computed<{ left: number; top: number } | null>(() => {
106
+ if (!crosshair.value || snapshots.value.length === 0) return null;
107
+ const mediaSize = chart.getMediaSize();
108
+ const chartWidth = mediaSize.width - chart.yAxisWidth;
109
+ const chartHeight = mediaSize.height - chart.xAxisHeight;
110
+
111
+ if (hasCustomSlot.value) {
112
+ const size = measuredSize.value;
113
+ if (!size) return { left: 0, top: 0 };
114
+
115
+ return computeTooltipPosition({
116
+ x: crosshair.value.mediaX,
117
+ y: crosshair.value.mediaY,
118
+ chartWidth,
119
+ chartHeight,
120
+ tooltipWidth: size.width,
121
+ tooltipHeight: size.height,
122
+ });
123
+ }
124
+
125
+ const hasOHLC = snapshots.value.some((s) => 'open' in s.data);
126
+ const lineCount = snapshots.value.filter((s) => !('open' in s.data)).length;
127
+ const tooltipWidth = 160;
128
+ const tooltipHeight = hasOHLC ? 140 : 40 + lineCount * 22;
129
+
130
+ return computeTooltipPosition({
131
+ x: crosshair.value.mediaX,
132
+ y: crosshair.value.mediaY,
133
+ chartWidth,
134
+ chartHeight,
135
+ tooltipWidth,
136
+ tooltipHeight,
137
+ });
138
+ });
139
+
140
+ const measured = computed(() => !hasCustomSlot.value || measuredSize.value !== null);
141
+
142
+ function isOHLC(data: OHLCData | TimePoint): data is OHLCData {
143
+ return 'open' in data;
144
+ }
145
+ </script>
146
+
147
+ <template>
148
+ <div
149
+ v-if="crosshair && snapshots.length > 0 && floatingPos && !hasCustomSlot"
150
+ :style="{
151
+ position: 'absolute',
152
+ left: floatingPos.left + 'px',
153
+ top: floatingPos.top + 'px',
154
+ pointerEvents: 'none',
155
+ background: theme.tooltip.background,
156
+ backdropFilter: 'blur(12px)',
157
+ WebkitBackdropFilter: 'blur(12px)',
158
+ border: '1px solid ' + theme.tooltip.borderColor,
159
+ borderRadius: '8px',
160
+ padding: '10px 14px',
161
+ boxShadow: '0 4px 16px rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.06)',
162
+ fontSize: theme.tooltip.fontSize + 'px',
163
+ fontFamily: theme.typography.fontFamily,
164
+ fontVariantNumeric: 'tabular-nums',
165
+ color: theme.tooltip.textColor,
166
+ minWidth: '140px',
167
+ zIndex: 10,
168
+ transition: 'opacity 0.15s ease',
169
+ }"
170
+ >
171
+ <div
172
+ :style="{
173
+ fontSize: theme.axis.fontSize + 'px',
174
+ color: theme.axis.textColor,
175
+ marginBottom: '8px',
176
+ paddingBottom: '6px',
177
+ borderBottom: '1px solid ' + theme.tooltip.borderColor,
178
+ letterSpacing: '0.02em',
179
+ }"
180
+ >
181
+ {{ formatDate(displayTime) }} {{ formatTime(displayTime, dataInterval) }}
182
+ </div>
183
+
184
+ <template v-for="s in snapshots" :key="s.id">
185
+ <div v-if="isOHLC(s.data)" :style="{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 12px' }">
186
+ <template
187
+ v-for="row in [
188
+ { label: 'Open', val: (s.data as OHLCData).open, field: 'open' as const },
189
+ { label: 'High', val: (s.data as OHLCData).high, field: 'high' as const },
190
+ { label: 'Low', val: (s.data as OHLCData).low, field: 'low' as const },
191
+ { label: 'Close', val: (s.data as OHLCData).close, field: 'close' as const },
192
+ ]"
193
+ :key="row.label"
194
+ >
195
+ <span :style="{ opacity: 0.5 }">{{ row.label }}</span>
196
+ <span
197
+ :style="{
198
+ fontWeight: 600,
199
+ color: resolveCandlestickBodyColor(
200
+ (s.data as OHLCData).close >= (s.data as OHLCData).open
201
+ ? theme.candlestick.up.body
202
+ : theme.candlestick.down.body,
203
+ ),
204
+ textAlign: 'right',
205
+ }"
206
+ >{{ effectiveFormat(row.val, row.field) }}</span>
207
+ </template>
208
+ <template v-if="(s.data as OHLCData).volume != null">
209
+ <span :style="{ opacity: 0.5 }">Volume</span>
210
+ <span :style="{ fontWeight: 600, color: theme.tooltip.textColor, textAlign: 'right' }">
211
+ {{ effectiveFormat((s.data as OHLCData).volume!, 'volume') }}
212
+ </span>
213
+ </template>
214
+ </div>
215
+ <div v-else :style="{ display: 'flex', alignItems: 'center', gap: '8px', padding: '2px 0' }">
216
+ <span
217
+ :style="{
218
+ width: '8px',
219
+ height: '8px',
220
+ borderRadius: '50%',
221
+ background: s.color,
222
+ flexShrink: 0,
223
+ }"
224
+ />
225
+ <span :style="{ opacity: 0.6, flex: '1' }">{{ s.label ?? 'Value' }}</span>
226
+ <span :style="{ fontWeight: 600, color: s.color }">
227
+ {{ effectiveFormat((s.data as TimePoint).value, 'value') }}
228
+ </span>
229
+ </div>
230
+ </template>
231
+ </div>
232
+
233
+ <div
234
+ v-if="crosshair && snapshots.length > 0 && floatingPos && hasCustomSlot"
235
+ ref="containerRef"
236
+ :data-measured="measured ? 'true' : 'false'"
237
+ :style="{
238
+ position: 'absolute',
239
+ left: floatingPos.left + 'px',
240
+ top: floatingPos.top + 'px',
241
+ pointerEvents: 'none',
242
+ background: theme.tooltip.background,
243
+ backdropFilter: 'blur(12px)',
244
+ WebkitBackdropFilter: 'blur(12px)',
245
+ border: '1px solid ' + theme.tooltip.borderColor,
246
+ borderRadius: '8px',
247
+ padding: '10px 14px',
248
+ boxShadow: '0 4px 16px rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.06)',
249
+ fontFamily: theme.typography.fontFamily,
250
+ fontSize: theme.tooltip.fontSize + 'px',
251
+ fontVariantNumeric: 'tabular-nums',
252
+ color: theme.tooltip.textColor,
253
+ width: 'max-content',
254
+ maxWidth: (chart.getMediaSize().width - chart.yAxisWidth) + 'px',
255
+ boxSizing: 'border-box',
256
+ zIndex: 10,
257
+ visibility: measuredSize !== null ? 'visible' : 'hidden',
258
+ }"
259
+ >
260
+ <slot :snapshots="snapshots" :time="displayTime" />
261
+ </div>
262
+ </template>
@@ -0,0 +1,100 @@
1
+ <script setup lang="ts">
2
+ import { resolveAxisFontSize, resolveAxisTextColor, 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: resolveAxisTextColor(theme, 'y'),
88
+ fontSize: resolveAxisFontSize(theme, 'y') + '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.yLabel.fontSize + '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>