@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,126 @@
1
+ <script setup lang="ts">
2
+ import { type ChartInstance, type SliceInfo, type ValueFormatter, formatCompact } from '@wick-charts/core';
3
+ import { computed, onMounted, onUnmounted, ref, useSlots } from 'vue';
4
+
5
+ import { useChartInstance, useTheme } from '../context';
6
+
7
+ type PieLegendMode = 'value' | 'percent' | 'both';
8
+
9
+ const props = withDefaults(
10
+ defineProps<{
11
+ /**
12
+ * Owning series id. **Optional** — when omitted, the first visible pie
13
+ * series is picked.
14
+ */
15
+ seriesId?: string;
16
+ /** 'value' shows only the value, 'percent' only the percent, 'both' shows both. Default: 'both'. */
17
+ mode?: PieLegendMode;
18
+ /** Custom formatter for the absolute slice value. */
19
+ format?: ValueFormatter;
20
+ }>(),
21
+ {
22
+ mode: undefined,
23
+ format: undefined,
24
+ },
25
+ );
26
+
27
+ defineSlots<{
28
+ default?(ctx: { slices: readonly SliceInfo[]; mode: PieLegendMode; format: ValueFormatter }): unknown;
29
+ }>();
30
+
31
+ const resolvedMode = computed<PieLegendMode>(() => props.mode ?? 'both');
32
+ const resolvedFormat = computed<ValueFormatter>(() => props.format ?? formatCompact);
33
+
34
+ const chart = useChartInstance();
35
+ const themeRef = useTheme();
36
+ const slots = useSlots();
37
+ const hasCustomSlot = computed(() => typeof slots.default === 'function');
38
+
39
+ const bump = ref(0);
40
+ const handler = () => {
41
+ bump.value++;
42
+ };
43
+ onMounted(() => {
44
+ chart.on('overlayChange', handler);
45
+ if (chart.getSeriesIds().length > 0) bump.value++;
46
+ });
47
+ onUnmounted(() => {
48
+ chart.off('overlayChange', handler);
49
+ });
50
+
51
+ function resolvePieSeriesId(c: ChartInstance, explicit: string | undefined): string | null {
52
+ if (explicit !== undefined) return explicit;
53
+
54
+ const pies = c.getSeriesIdsByType('pie', { visibleOnly: true });
55
+
56
+ return pies.length > 0 ? pies[0] : null;
57
+ }
58
+
59
+ const theme = computed(() => themeRef.value);
60
+ const resolvedId = computed<string | null>(() => {
61
+ void bump.value;
62
+
63
+ return resolvePieSeriesId(chart, props.seriesId);
64
+ });
65
+ const slices = computed<readonly SliceInfo[]>(() => {
66
+ void bump.value;
67
+ if (resolvedId.value === null) return [];
68
+
69
+ return chart.getSliceInfo(resolvedId.value) ?? [];
70
+ });
71
+ </script>
72
+
73
+ <template>
74
+ <slot
75
+ v-if="hasCustomSlot && slices.length > 0"
76
+ :slices="slices"
77
+ :mode="resolvedMode"
78
+ :format="resolvedFormat"
79
+ />
80
+ <div
81
+ v-else-if="slices.length > 0"
82
+ :style="{
83
+ display: 'flex',
84
+ flexDirection: 'column',
85
+ gap: '6px',
86
+ padding: '8px 12px',
87
+ fontFamily: theme.typography.fontFamily,
88
+ fontSize: theme.typography.fontSize + 'px',
89
+ color: theme.tooltip.textColor,
90
+ pointerEvents: 'auto',
91
+ }"
92
+ >
93
+ <div
94
+ v-for="(slice, i) in slices"
95
+ :key="i"
96
+ :style="{ display: 'flex', alignItems: 'center', gap: '10px' }"
97
+ >
98
+ <span :style="{ width: '10px', height: '10px', borderRadius: '50%', background: slice.color, flexShrink: 0 }" />
99
+ <span :style="{ flex: 1, opacity: 0.8 }">{{ slice.label }}</span>
100
+ <span
101
+ v-if="resolvedMode === 'value' || resolvedMode === 'both'"
102
+ :style="{
103
+ fontWeight: 600,
104
+ fontVariantNumeric: 'tabular-nums',
105
+ minWidth: resolvedMode === 'value' ? '40px' : undefined,
106
+ textAlign: 'right',
107
+ }"
108
+ >
109
+ {{ resolvedFormat(slice.value) }}
110
+ </span>
111
+ <span
112
+ v-if="resolvedMode === 'percent' || resolvedMode === 'both'"
113
+ :style="{
114
+ opacity: resolvedMode === 'percent' ? 1 : 0.5,
115
+ fontWeight: resolvedMode === 'percent' ? 600 : 400,
116
+ fontSize: (resolvedMode === 'percent' ? theme.typography.fontSize : theme.axis.fontSize) + 'px',
117
+ fontVariantNumeric: 'tabular-nums',
118
+ minWidth: '40px',
119
+ textAlign: 'right',
120
+ }"
121
+ >
122
+ {{ slice.percent.toFixed(1) }}%
123
+ </span>
124
+ </div>
125
+ </div>
126
+ </template>
@@ -0,0 +1,200 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ type ChartInstance,
4
+ type HoverInfo,
5
+ type ValueFormatter,
6
+ computeTooltipPosition,
7
+ formatCompact,
8
+ } from '@wick-charts/core';
9
+ import { computed, onMounted, onUnmounted, ref, useSlots, watchEffect } from 'vue';
10
+
11
+ import { useCrosshairPosition } from '../composables';
12
+ import { useChartInstance } from '../context';
13
+
14
+ const DEFAULT_TOOLTIP_WIDTH = 160;
15
+ const DEFAULT_TOOLTIP_HEIGHT = 70;
16
+
17
+ const props = defineProps<{
18
+ /**
19
+ * Owning series id. **Optional** — when omitted, the first visible pie
20
+ * series is picked.
21
+ */
22
+ seriesId?: string;
23
+ /** Custom formatter for the slice value. Default: shared `formatCompact`. */
24
+ format?: ValueFormatter;
25
+ }>();
26
+
27
+ defineSlots<{
28
+ default?(ctx: { info: HoverInfo; format: ValueFormatter }): unknown;
29
+ }>();
30
+
31
+ const effectiveFormat = computed<ValueFormatter>(() => props.format ?? formatCompact);
32
+
33
+ const chart = useChartInstance();
34
+ const crosshair = useCrosshairPosition(chart);
35
+ const slots = useSlots();
36
+ const hasCustomSlot = computed(() => typeof slots.default === 'function');
37
+
38
+ const bump = ref(0);
39
+ const handler = () => {
40
+ bump.value++;
41
+ };
42
+ onMounted(() => {
43
+ chart.on('overlayChange', handler);
44
+ });
45
+ onUnmounted(() => {
46
+ chart.off('overlayChange', handler);
47
+ });
48
+
49
+ function resolvePieSeriesId(c: ChartInstance, explicit: string | undefined): string | null {
50
+ if (explicit !== undefined) return explicit;
51
+
52
+ const pies = c.getSeriesIdsByType('pie', { visibleOnly: true });
53
+
54
+ return pies.length > 0 ? pies[0] : null;
55
+ }
56
+
57
+ const theme = computed(() => {
58
+ // `setTheme` fires `overlayChange`; read `bump` so this computed picks up
59
+ // runtime theme swaps instead of freezing on the first-render snapshot.
60
+ void bump.value;
61
+
62
+ return chart.getTheme();
63
+ });
64
+ const resolvedId = computed<string | null>(() => {
65
+ void bump.value;
66
+
67
+ return resolvePieSeriesId(chart, props.seriesId);
68
+ });
69
+
70
+ const info = computed<HoverInfo | null>(() => {
71
+ void crosshair.value;
72
+ void bump.value;
73
+ if (resolvedId.value === null) return null;
74
+
75
+ return chart.getHoverInfo(resolvedId.value);
76
+ });
77
+
78
+ // Custom-slot measurement — unknown content dimensions mean we can't
79
+ // pre-compute `tooltipWidth/Height`. Observe the container, then recompute
80
+ // the position once measured so user-rendered tooltips flip/clamp correctly
81
+ // instead of overflowing with the hardcoded 160×70 defaults.
82
+ const containerRef = ref<HTMLDivElement | null>(null);
83
+ const measuredSize = ref<{ width: number; height: number } | null>(null);
84
+ let observer: ResizeObserver | null = null;
85
+
86
+ watchEffect((onCleanup) => {
87
+ if (!hasCustomSlot.value) return;
88
+ const node = containerRef.value;
89
+ if (!node || typeof ResizeObserver === 'undefined') return;
90
+
91
+ observer = new ResizeObserver((entries) => {
92
+ const box = entries[0]?.contentRect;
93
+ if (!box) return;
94
+ const prev = measuredSize.value;
95
+ if (!prev || prev.width !== box.width || prev.height !== box.height) {
96
+ measuredSize.value = { width: box.width, height: box.height };
97
+ }
98
+ });
99
+ observer.observe(node);
100
+
101
+ onCleanup(() => {
102
+ observer?.disconnect();
103
+ observer = null;
104
+ });
105
+ });
106
+
107
+ const tooltipPos = computed(() => {
108
+ if (!crosshair.value || !info.value) return null;
109
+ const mediaSize = chart.getMediaSize();
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: mediaSize.width,
119
+ chartHeight: mediaSize.height,
120
+ tooltipWidth: size.width,
121
+ tooltipHeight: size.height,
122
+ offsetX: 16,
123
+ offsetY: 16,
124
+ });
125
+ }
126
+
127
+ return computeTooltipPosition({
128
+ x: crosshair.value.mediaX,
129
+ y: crosshair.value.mediaY,
130
+ chartWidth: mediaSize.width,
131
+ chartHeight: mediaSize.height,
132
+ tooltipWidth: DEFAULT_TOOLTIP_WIDTH,
133
+ tooltipHeight: DEFAULT_TOOLTIP_HEIGHT,
134
+ offsetX: 16,
135
+ offsetY: 16,
136
+ });
137
+ });
138
+ </script>
139
+
140
+ <template>
141
+ <div
142
+ v-if="info && crosshair && tooltipPos && !hasCustomSlot"
143
+ :style="{
144
+ position: 'absolute',
145
+ left: tooltipPos.left + 'px',
146
+ top: tooltipPos.top + 'px',
147
+ pointerEvents: 'none',
148
+ background: theme.tooltip.background,
149
+ backdropFilter: 'blur(12px)',
150
+ WebkitBackdropFilter: 'blur(12px)',
151
+ border: '1px solid ' + theme.tooltip.borderColor,
152
+ borderRadius: '8px',
153
+ padding: '10px 14px',
154
+ boxShadow: '0 4px 16px rgba(0,0,0,0.1), 0 1px 4px rgba(0,0,0,0.06)',
155
+ fontSize: theme.typography.fontSize + 'px',
156
+ fontFamily: theme.typography.fontFamily,
157
+ color: theme.tooltip.textColor,
158
+ zIndex: 10,
159
+ display: 'flex',
160
+ flexDirection: 'column',
161
+ gap: '6px',
162
+ }"
163
+ >
164
+ <div :style="{ display: 'flex', alignItems: 'center', gap: '8px' }">
165
+ <span
166
+ :style="{
167
+ width: '10px',
168
+ height: '10px',
169
+ borderRadius: '50%',
170
+ background: info.color,
171
+ flexShrink: 0,
172
+ }"
173
+ />
174
+ <span :style="{ fontWeight: 600 }">{{ info.label }}</span>
175
+ </div>
176
+ <div :style="{ display: 'flex', justifyContent: 'space-between', gap: '16px' }">
177
+ <span :style="{ opacity: 0.6 }">{{ effectiveFormat(info.value) }}</span>
178
+ <span :style="{ fontWeight: 600 }">{{ info.percent.toFixed(1) }}%</span>
179
+ </div>
180
+ </div>
181
+
182
+ <div
183
+ v-if="info && crosshair && tooltipPos && hasCustomSlot"
184
+ ref="containerRef"
185
+ :data-measured="measuredSize !== null ? 'true' : 'false'"
186
+ :style="{
187
+ position: 'absolute',
188
+ left: tooltipPos.left + 'px',
189
+ top: tooltipPos.top + 'px',
190
+ pointerEvents: 'none',
191
+ zIndex: 10,
192
+ width: 'max-content',
193
+ maxWidth: chart.getMediaSize().width + 'px',
194
+ boxSizing: 'border-box',
195
+ visibility: measuredSize !== null ? 'visible' : 'hidden',
196
+ }"
197
+ >
198
+ <slot :info="info" :format="effectiveFormat" />
199
+ </div>
200
+ </template>
@@ -0,0 +1,92 @@
1
+ <script setup lang="ts">
2
+ import { formatTime, resolveAxisFontSize, resolveAxisTextColor } from '@wick-charts/core';
3
+ import { computed } from 'vue';
4
+
5
+ import { useVisibleRange } from '../composables';
6
+ import { useChartInstance } from '../context';
7
+
8
+ interface TrackedTick {
9
+ opacity: number;
10
+ addedAt: number;
11
+ }
12
+
13
+ const chart = useChartInstance();
14
+ const _visibleRange = useVisibleRange(chart);
15
+
16
+ const tickMap = new Map<number, TrackedTick>();
17
+
18
+ const theme = computed(() => chart.getTheme());
19
+
20
+ const tickData = computed(() => {
21
+ // Access _visibleRange.value to track dependency
22
+ void _visibleRange.value;
23
+
24
+ const dataInterval = chart.getDataInterval();
25
+ const { ticks: currentTicks, tickInterval } = chart.timeScale.niceTickValues(dataInterval);
26
+ const currentSet = new Set(currentTicks);
27
+ const now = performance.now();
28
+
29
+ // Mark current ticks as visible
30
+ for (const t of currentTicks) {
31
+ if (!tickMap.has(t)) {
32
+ tickMap.set(t, { opacity: 1, addedAt: now });
33
+ } else {
34
+ tickMap.get(t)!.opacity = 1;
35
+ }
36
+ }
37
+
38
+ // Mark missing ticks for fade-out
39
+ for (const [t, entry] of tickMap) {
40
+ if (!currentSet.has(t)) {
41
+ entry.opacity = 0;
42
+ }
43
+ }
44
+
45
+ // Clean up old faded-out ticks
46
+ for (const [t, entry] of tickMap) {
47
+ if (entry.opacity === 0 && now - entry.addedAt > 5000) {
48
+ tickMap.delete(t);
49
+ }
50
+ }
51
+
52
+ return {
53
+ allTicks: Array.from(tickMap.entries()),
54
+ tickInterval,
55
+ };
56
+ });
57
+ </script>
58
+
59
+ <template>
60
+ <div
61
+ :style="{
62
+ position: 'absolute',
63
+ left: '0',
64
+ bottom: '0',
65
+ right: chart.yAxisWidth + 'px',
66
+ height: chart.xAxisHeight + 'px',
67
+ pointerEvents: 'none',
68
+ display: 'flex',
69
+ alignItems: 'center',
70
+ }"
71
+ >
72
+ <span
73
+ v-for="[time, entry] in tickData.allTicks"
74
+ :key="time"
75
+ :style="{
76
+ position: 'absolute',
77
+ left: chart.timeScale.timeToX(time) + 'px',
78
+ transform: 'translateX(-50%)',
79
+ color: resolveAxisTextColor(theme, 'x'),
80
+ fontSize: resolveAxisFontSize(theme, 'x') + 'px',
81
+ fontFamily: theme.typography.fontFamily,
82
+ userSelect: 'none',
83
+ whiteSpace: 'nowrap',
84
+ opacity: entry.opacity,
85
+ transition: 'opacity 0.3s ease',
86
+ willChange: 'opacity',
87
+ }"
88
+ >
89
+ {{ formatTime(time, tickData.tickInterval) }}
90
+ </span>
91
+ </div>
92
+ </template>
@@ -0,0 +1,57 @@
1
+ <script setup lang="ts">
2
+ import { inject } from 'vue';
3
+
4
+ import { ThemeKey, TitleAnchorKey } from '../context';
5
+
6
+ withDefaults(
7
+ defineProps<{
8
+ sub?: string;
9
+ }>(),
10
+ {},
11
+ );
12
+
13
+ const anchor = inject(TitleAnchorKey);
14
+ const theme = inject(ThemeKey);
15
+
16
+ // Match the "loud failure" contract of other Vue overlays (`useChartInstance`,
17
+ // `useTheme`) — misuse outside `<ChartContainer>` should throw, not render
18
+ // nothing silently.
19
+ if (!anchor) {
20
+ throw new Error('<Title> must be used within <ChartContainer>: missing TitleAnchorKey.');
21
+ }
22
+ if (!theme) {
23
+ throw new Error('<Title> must be used within <ChartContainer>: missing ThemeKey.');
24
+ }
25
+ </script>
26
+
27
+ <template>
28
+ <Teleport v-if="anchor" :to="anchor">
29
+ <div
30
+ data-chart-title=""
31
+ :style="{
32
+ display: 'flex',
33
+ alignItems: 'baseline',
34
+ gap: '6px',
35
+ padding: '6px 8px 4px',
36
+ flexShrink: 0,
37
+ fontFamily: theme.typography.fontFamily,
38
+ fontSize: theme.typography.fontSize + 'px',
39
+ fontWeight: 600,
40
+ color: theme.tooltip.textColor,
41
+ pointerEvents: 'none',
42
+ }"
43
+ >
44
+ <span><slot /></span>
45
+ <span
46
+ v-if="sub"
47
+ :style="{
48
+ fontWeight: 400,
49
+ color: theme.axis.textColor,
50
+ fontSize: theme.axis.fontSize + 'px',
51
+ }"
52
+ >
53
+ {{ sub }}
54
+ </span>
55
+ </div>
56
+ </Teleport>
57
+ </template>