@wick-charts/vue 0.3.1 → 0.3.2
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/dist/index.d.ts +336 -30
- package/dist/index.js +2987 -2312
- package/package.json +2 -2
- package/src/ChartContainer.vue +153 -33
- package/src/context.ts +1 -0
- package/src/index.ts +8 -0
- package/src/ui/Navigator.vue +78 -0
- package/src/ui/NumberFlow.vue +12 -6
- package/src/ui/PieLegend.vue +75 -2
- package/src/ui/TimeAxis.vue +23 -4
- package/src/ui/Title.vue +30 -3
- package/src/ui/YAxis.vue +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wick-charts/vue",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "High-performance canvas timeseries charts for Vue — candlestick, line, bar, pie. Tree-shakeable, zero runtime deps.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"vue": ">=3.3.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@wick-charts/core": "^0.3.
|
|
48
|
+
"@wick-charts/core": "^0.3.2"
|
|
49
49
|
},
|
|
50
50
|
"scripts": {
|
|
51
51
|
"build": "vite build"
|
package/src/ChartContainer.vue
CHANGED
|
@@ -1,18 +1,63 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type AxisConfig,
|
|
4
|
+
ChartInstance,
|
|
5
|
+
type ChartOptions,
|
|
6
|
+
type ChartTheme,
|
|
7
|
+
darkTheme,
|
|
8
|
+
} from '@wick-charts/core';
|
|
3
9
|
import { computed, nextTick, onMounted, onUnmounted, provide, ref, shallowRef, watch } from 'vue';
|
|
4
10
|
|
|
5
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
ChartKey,
|
|
13
|
+
InfoBarAnchorKey,
|
|
14
|
+
LegendAnchorKey,
|
|
15
|
+
LegendRightAnchorKey,
|
|
16
|
+
NavigatorAnchorKey,
|
|
17
|
+
ThemeKey,
|
|
18
|
+
TitleAnchorKey,
|
|
19
|
+
} from './context';
|
|
20
|
+
|
|
21
|
+
type PerfOption = NonNullable<ChartOptions['perf']>;
|
|
6
22
|
|
|
7
23
|
const props = withDefaults(
|
|
8
24
|
defineProps<{
|
|
9
25
|
theme?: ChartTheme;
|
|
10
26
|
axis?: AxisConfig;
|
|
27
|
+
/**
|
|
28
|
+
* Viewport padding. `top`/`bottom` are in pixels. `left`/`right` accept either pixels (`50`)
|
|
29
|
+
* or data intervals (`{ intervals: 3 }`). Set to 0 for edge-to-edge sparklines. Updates are
|
|
30
|
+
* applied reactively when this prop or the measured header height changes.
|
|
31
|
+
* Defaults: `{ top: 20, bottom: 20, right: { intervals: 3 }, left: { intervals: 0 } }`.
|
|
32
|
+
*/
|
|
33
|
+
padding?: {
|
|
34
|
+
top?: number;
|
|
35
|
+
bottom?: number;
|
|
36
|
+
right?: number | { intervals: number };
|
|
37
|
+
left?: number | { intervals: number };
|
|
38
|
+
};
|
|
39
|
+
/** Show the chart background gradient. Defaults to true. */
|
|
40
|
+
gradient?: boolean;
|
|
41
|
+
/** Enable zoom, pan, and crosshair interactions. Defaults to true. */
|
|
42
|
+
interactive?: boolean;
|
|
11
43
|
/** Background grid configuration. Default: `{ visible: true }`. */
|
|
12
44
|
grid?: { visible: boolean };
|
|
45
|
+
/**
|
|
46
|
+
* How `<Title>` and `<InfoBar>` are positioned relative to the canvas.
|
|
47
|
+
* - `'overlay'` (default): absolute overlays on top of the canvas.
|
|
48
|
+
* - `'inline'`: flex siblings above the canvas — the canvas (and grid) shift down.
|
|
49
|
+
*/
|
|
50
|
+
headerLayout?: 'overlay' | 'inline';
|
|
51
|
+
/**
|
|
52
|
+
* Enable runtime performance instrumentation. Off by default. Read at mount only;
|
|
53
|
+
* later changes are ignored.
|
|
54
|
+
*/
|
|
55
|
+
perf?: PerfOption;
|
|
13
56
|
}>(),
|
|
14
57
|
{
|
|
15
58
|
theme: () => darkTheme,
|
|
59
|
+
gradient: true,
|
|
60
|
+
headerLayout: 'overlay',
|
|
16
61
|
},
|
|
17
62
|
);
|
|
18
63
|
|
|
@@ -20,16 +65,16 @@ const containerRef = ref<HTMLDivElement>();
|
|
|
20
65
|
const chart = shallowRef<ChartInstance | null>(null);
|
|
21
66
|
const themeRef = shallowRef<ChartTheme>(props.theme);
|
|
22
67
|
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
// so data stays below the header.
|
|
68
|
+
// Capture perf at mount only — mirrors React's perfRef so a new object
|
|
69
|
+
// identity later doesn't recreate the chart.
|
|
70
|
+
const perfAtMount = props.perf;
|
|
71
|
+
|
|
28
72
|
const topOverlayRef = ref<HTMLElement | null>(null);
|
|
29
73
|
const titleAnchor = ref<HTMLElement | null>(null);
|
|
30
74
|
const infoBarAnchor = ref<HTMLElement | null>(null);
|
|
31
75
|
const legendAnchor = ref<HTMLElement | null>(null);
|
|
32
76
|
const legendRightAnchor = ref<HTMLElement | null>(null);
|
|
77
|
+
const navigatorAnchor = ref<HTMLElement | null>(null);
|
|
33
78
|
|
|
34
79
|
provide(ChartKey, chart);
|
|
35
80
|
provide(ThemeKey, themeRef);
|
|
@@ -37,32 +82,66 @@ provide(TitleAnchorKey, titleAnchor);
|
|
|
37
82
|
provide(InfoBarAnchorKey, infoBarAnchor);
|
|
38
83
|
provide(LegendAnchorKey, legendAnchor);
|
|
39
84
|
provide(LegendRightAnchorKey, legendRightAnchor);
|
|
85
|
+
provide(NavigatorAnchorKey, navigatorAnchor);
|
|
40
86
|
|
|
41
87
|
let resizeObserver: ResizeObserver | null = null;
|
|
42
|
-
|
|
88
|
+
const topOverlayHeight = ref(0);
|
|
89
|
+
|
|
90
|
+
// Inline mode: browser flex already reserves header height, so folding it
|
|
91
|
+
// into padding.top would double-shift the data. Only overlay needs the fold.
|
|
92
|
+
const headerExtra = computed(() => (props.headerLayout === 'overlay' ? topOverlayHeight.value : 0));
|
|
43
93
|
|
|
44
94
|
function applyPadding() {
|
|
45
95
|
if (!chart.value) return;
|
|
46
|
-
|
|
96
|
+
const userTop = props.padding?.top ?? 20;
|
|
97
|
+
const merged: ChartOptions['padding'] = { top: userTop + headerExtra.value };
|
|
98
|
+
if (props.padding?.bottom !== undefined) merged.bottom = props.padding.bottom;
|
|
99
|
+
if (props.padding?.right !== undefined) merged.right = props.padding.right;
|
|
100
|
+
if (props.padding?.left !== undefined) merged.left = props.padding.left;
|
|
101
|
+
chart.value.setPadding(merged);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Wires up (or tears down) the header-height observer for the current
|
|
105
|
+
// `headerLayout`. Called on mount AND whenever `headerLayout` flips at
|
|
106
|
+
// runtime — overlay needs a live measurement, inline lets browser flex
|
|
107
|
+
// layout reserve the height directly.
|
|
108
|
+
function syncHeaderObserver() {
|
|
109
|
+
resizeObserver?.disconnect();
|
|
110
|
+
resizeObserver = null;
|
|
111
|
+
|
|
112
|
+
const headerEl = props.headerLayout === 'overlay' ? topOverlayRef.value : null;
|
|
113
|
+
if (!headerEl) {
|
|
114
|
+
// Reset stale measurement so the next applyPadding call drops back to the
|
|
115
|
+
// user's configured `padding.top` instead of carrying inline-mode height.
|
|
116
|
+
topOverlayHeight.value = 0;
|
|
117
|
+
applyPadding();
|
|
118
|
+
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const measure = () => {
|
|
123
|
+
topOverlayHeight.value = headerEl.getBoundingClientRect().height;
|
|
124
|
+
applyPadding();
|
|
125
|
+
};
|
|
126
|
+
measure();
|
|
127
|
+
resizeObserver = new ResizeObserver(measure);
|
|
128
|
+
resizeObserver.observe(headerEl);
|
|
47
129
|
}
|
|
48
130
|
|
|
49
131
|
onMounted(async () => {
|
|
50
132
|
if (!containerRef.value) return;
|
|
51
|
-
|
|
133
|
+
|
|
134
|
+
const options: ChartOptions = {};
|
|
52
135
|
if (props.axis) options.axis = props.axis;
|
|
53
136
|
if (props.theme) options.theme = props.theme;
|
|
137
|
+
if (props.padding) options.padding = props.padding;
|
|
138
|
+
if (props.interactive !== undefined) options.interactive = props.interactive;
|
|
54
139
|
if (props.grid !== undefined) options.grid = props.grid;
|
|
140
|
+
if (perfAtMount !== undefined) options.perf = perfAtMount;
|
|
55
141
|
chart.value = new ChartInstance(containerRef.value, options);
|
|
56
142
|
|
|
57
143
|
await nextTick();
|
|
58
|
-
|
|
59
|
-
const measure = () => {
|
|
60
|
-
topOverlayHeight = topOverlayRef.value?.getBoundingClientRect().height ?? 0;
|
|
61
|
-
applyPadding();
|
|
62
|
-
};
|
|
63
|
-
measure();
|
|
64
|
-
resizeObserver = new ResizeObserver(measure);
|
|
65
|
-
resizeObserver.observe(topOverlayRef.value);
|
|
144
|
+
syncHeaderObserver();
|
|
66
145
|
});
|
|
67
146
|
|
|
68
147
|
onUnmounted(() => {
|
|
@@ -72,6 +151,17 @@ onUnmounted(() => {
|
|
|
72
151
|
chart.value = null;
|
|
73
152
|
});
|
|
74
153
|
|
|
154
|
+
// Re-attach the observer when `headerLayout` toggles after mount, so an
|
|
155
|
+
// overlay → inline → overlay round-trip keeps measuring the new header.
|
|
156
|
+
watch(
|
|
157
|
+
() => props.headerLayout,
|
|
158
|
+
async () => {
|
|
159
|
+
if (!chart.value) return;
|
|
160
|
+
await nextTick();
|
|
161
|
+
syncHeaderObserver();
|
|
162
|
+
},
|
|
163
|
+
);
|
|
164
|
+
|
|
75
165
|
watch(
|
|
76
166
|
() => props.theme,
|
|
77
167
|
(newTheme) => {
|
|
@@ -97,13 +187,31 @@ watch(
|
|
|
97
187
|
},
|
|
98
188
|
);
|
|
99
189
|
|
|
190
|
+
// Re-apply padding on any input that affects it — including `headerExtra`,
|
|
191
|
+
// so a runtime `headerLayout` toggle re-runs even when `padding` is undefined
|
|
192
|
+
// (the default top=20 still needs the fold-in/out).
|
|
193
|
+
watch(
|
|
194
|
+
() => [
|
|
195
|
+
props.padding?.top,
|
|
196
|
+
props.padding?.bottom,
|
|
197
|
+
typeof props.padding?.right === 'object' ? props.padding.right.intervals : props.padding?.right,
|
|
198
|
+
typeof props.padding?.left === 'object' ? props.padding.left.intervals : props.padding?.left,
|
|
199
|
+
headerExtra.value,
|
|
200
|
+
],
|
|
201
|
+
applyPadding,
|
|
202
|
+
);
|
|
203
|
+
|
|
100
204
|
const rootStyle = computed(() => {
|
|
101
205
|
const t = themeRef.value;
|
|
102
206
|
const [gtop, gbot] = t?.chartGradient ?? ['transparent', 'transparent'];
|
|
103
207
|
const bg = t?.background ?? 'transparent';
|
|
208
|
+
const background = props.gradient
|
|
209
|
+
? `linear-gradient(to bottom, ${gtop} 0%, ${bg} 70%, ${gbot} 100%)`
|
|
210
|
+
: bg;
|
|
211
|
+
|
|
104
212
|
return (
|
|
105
213
|
'position: relative; display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden; ' +
|
|
106
|
-
`background:
|
|
214
|
+
`background: ${background}`
|
|
107
215
|
);
|
|
108
216
|
});
|
|
109
217
|
</script>
|
|
@@ -111,31 +219,43 @@ const rootStyle = computed(() => {
|
|
|
111
219
|
<template>
|
|
112
220
|
<div :style="rootStyle">
|
|
113
221
|
<div style="display: flex; flex-direction: row; flex: 1; min-height: 0">
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
>
|
|
118
|
-
<!-- Top overlay (Title + InfoBar) — stacked absolute inside the
|
|
119
|
-
canvas block so the grid extends full-height behind them. -->
|
|
222
|
+
<!-- Canvas-block column. In inline mode the header is a flex sibling
|
|
223
|
+
above the canvas inside this column; in overlay mode it's absolute
|
|
224
|
+
over the canvas (rendered inside `containerRef`). -->
|
|
225
|
+
<div style="display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0">
|
|
120
226
|
<div
|
|
121
|
-
|
|
122
|
-
data-chart-
|
|
123
|
-
style="
|
|
227
|
+
v-if="headerLayout === 'inline'"
|
|
228
|
+
data-chart-header=""
|
|
229
|
+
style="flex-shrink: 0; display: flex; flex-direction: column; pointer-events: none"
|
|
124
230
|
>
|
|
125
231
|
<div ref="titleAnchor" data-chart-title-anchor="" />
|
|
126
232
|
<div ref="infoBarAnchor" data-tooltip-legend-anchor="" />
|
|
127
233
|
</div>
|
|
128
234
|
<div
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
style="position: absolute; inset: 0; pointer-events: none; z-index: 3"
|
|
235
|
+
ref="containerRef"
|
|
236
|
+
style="position: relative; flex: 1; min-width: 0; min-height: 0; overflow: hidden"
|
|
132
237
|
>
|
|
133
|
-
<
|
|
238
|
+
<div
|
|
239
|
+
v-if="headerLayout === 'overlay'"
|
|
240
|
+
ref="topOverlayRef"
|
|
241
|
+
data-chart-top-overlay=""
|
|
242
|
+
style="position: absolute; top: 0; left: 0; right: 0; z-index: 2; pointer-events: none; display: flex; flex-direction: column"
|
|
243
|
+
>
|
|
244
|
+
<div ref="titleAnchor" data-chart-title-anchor="" />
|
|
245
|
+
<div ref="infoBarAnchor" data-tooltip-legend-anchor="" />
|
|
246
|
+
</div>
|
|
247
|
+
<div
|
|
248
|
+
v-if="chart"
|
|
249
|
+
data-chart-series-overlay=""
|
|
250
|
+
style="position: absolute; inset: 0; pointer-events: none; z-index: 3"
|
|
251
|
+
>
|
|
252
|
+
<slot />
|
|
253
|
+
</div>
|
|
134
254
|
</div>
|
|
135
255
|
</div>
|
|
136
256
|
<div ref="legendRightAnchor" data-legend-right-anchor="" style="flex: 0 0 auto" />
|
|
137
257
|
</div>
|
|
138
258
|
<div ref="legendAnchor" data-legend-anchor="" style="flex: 0 0 auto" />
|
|
259
|
+
<div ref="navigatorAnchor" data-navigator-anchor="" style="flex: 0 0 auto" />
|
|
139
260
|
</div>
|
|
140
261
|
</template>
|
|
141
|
-
|
package/src/context.ts
CHANGED
|
@@ -13,6 +13,7 @@ export const TitleAnchorKey: InjectionKey<Ref<HTMLElement | null>> = Symbol('wic
|
|
|
13
13
|
export const InfoBarAnchorKey: InjectionKey<Ref<HTMLElement | null>> = Symbol('wick-info-bar-anchor');
|
|
14
14
|
export const LegendAnchorKey: InjectionKey<Ref<HTMLElement | null>> = Symbol('wick-legend-anchor');
|
|
15
15
|
export const LegendRightAnchorKey: InjectionKey<Ref<HTMLElement | null>> = Symbol('wick-legend-right-anchor');
|
|
16
|
+
export const NavigatorAnchorKey: InjectionKey<Ref<HTMLElement | null>> = Symbol('wick-navigator-anchor');
|
|
16
17
|
|
|
17
18
|
export function useChartInstance(): ChartInstance {
|
|
18
19
|
const chartRef = inject(ChartKey);
|
package/src/index.ts
CHANGED
|
@@ -26,6 +26,12 @@ export type {
|
|
|
26
26
|
/** @deprecated Use {@link TimePoint} instead. */
|
|
27
27
|
LineData,
|
|
28
28
|
LineSeriesOptions,
|
|
29
|
+
NavigatorCandlePoint,
|
|
30
|
+
NavigatorControllerParams,
|
|
31
|
+
NavigatorData,
|
|
32
|
+
NavigatorLinePoint,
|
|
33
|
+
NavigatorOptions,
|
|
34
|
+
NavigatorSeriesType,
|
|
29
35
|
OHLCData,
|
|
30
36
|
OHLCInput,
|
|
31
37
|
PieSeriesOptions,
|
|
@@ -53,6 +59,7 @@ export type {
|
|
|
53
59
|
} from '@wick-charts/core';
|
|
54
60
|
export {
|
|
55
61
|
ChartInstance,
|
|
62
|
+
NavigatorController,
|
|
56
63
|
andromeda,
|
|
57
64
|
autoGradient,
|
|
58
65
|
ayuMirage,
|
|
@@ -105,6 +112,7 @@ export { default as PieSeries } from './PieSeries.vue';
|
|
|
105
112
|
export { default as Crosshair } from './ui/Crosshair.vue';
|
|
106
113
|
export { default as InfoBar } from './ui/InfoBar.vue';
|
|
107
114
|
export { default as Legend } from './ui/Legend.vue';
|
|
115
|
+
export { default as Navigator } from './ui/Navigator.vue';
|
|
108
116
|
export { default as NumberFlow } from './ui/NumberFlow.vue';
|
|
109
117
|
export { default as PieLegend } from './ui/PieLegend.vue';
|
|
110
118
|
export { default as PieTooltip } from './ui/PieTooltip.vue';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { NavigatorController, type NavigatorData } from '@wick-charts/core';
|
|
3
|
+
import { computed, inject, onBeforeUnmount, ref, shallowRef, watch } from 'vue';
|
|
4
|
+
|
|
5
|
+
import { ChartKey, NavigatorAnchorKey, ThemeKey } from '../context';
|
|
6
|
+
|
|
7
|
+
const props = withDefaults(
|
|
8
|
+
defineProps<{
|
|
9
|
+
data: NavigatorData;
|
|
10
|
+
/** Strip height in CSS pixels. Defaults to `theme.navigator.height` (60). */
|
|
11
|
+
height?: number;
|
|
12
|
+
}>(),
|
|
13
|
+
{},
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const anchor = inject(NavigatorAnchorKey);
|
|
17
|
+
const chartRef = inject(ChartKey);
|
|
18
|
+
const themeRef = inject(ThemeKey);
|
|
19
|
+
|
|
20
|
+
if (!anchor || !chartRef || !themeRef) {
|
|
21
|
+
throw new Error('<Navigator> must be used within <ChartContainer>.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const stripRef = ref<HTMLDivElement | null>(null);
|
|
25
|
+
const controller = shallowRef<NavigatorController | null>(null);
|
|
26
|
+
|
|
27
|
+
const resolvedHeight = computed(() => props.height ?? themeRef.value.navigator.height);
|
|
28
|
+
|
|
29
|
+
// Mount the controller once the strip div exists and the chart is available.
|
|
30
|
+
// Watch both — either may arrive after the other across Teleport boundaries.
|
|
31
|
+
watch(
|
|
32
|
+
[stripRef, () => chartRef.value],
|
|
33
|
+
([strip, chart]) => {
|
|
34
|
+
if (!strip || !chart || controller.value) return;
|
|
35
|
+
controller.value = new NavigatorController({
|
|
36
|
+
container: strip,
|
|
37
|
+
chart,
|
|
38
|
+
data: props.data,
|
|
39
|
+
options: props.height !== undefined ? { height: props.height } : undefined,
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
{ flush: 'post', immediate: true },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
watch(
|
|
46
|
+
() => props.data,
|
|
47
|
+
(next) => {
|
|
48
|
+
controller.value?.setData(next);
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
watch(
|
|
53
|
+
() => props.height,
|
|
54
|
+
(next) => {
|
|
55
|
+
controller.value?.setOptions(next !== undefined ? { height: next } : {});
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
onBeforeUnmount(() => {
|
|
60
|
+
controller.value?.destroy();
|
|
61
|
+
controller.value = null;
|
|
62
|
+
});
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<template>
|
|
66
|
+
<Teleport v-if="anchor" :to="anchor">
|
|
67
|
+
<div
|
|
68
|
+
ref="stripRef"
|
|
69
|
+
data-chart-navigator=""
|
|
70
|
+
:style="{
|
|
71
|
+
position: 'relative',
|
|
72
|
+
width: '100%',
|
|
73
|
+
height: resolvedHeight + 'px',
|
|
74
|
+
flexShrink: 0,
|
|
75
|
+
}"
|
|
76
|
+
/>
|
|
77
|
+
</Teleport>
|
|
78
|
+
</template>
|
package/src/ui/NumberFlow.vue
CHANGED
|
@@ -4,8 +4,12 @@ import { computed, onMounted, ref } from 'vue';
|
|
|
4
4
|
const props = withDefaults(
|
|
5
5
|
defineProps<{
|
|
6
6
|
value: number;
|
|
7
|
-
/**
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Value-to-string formatter. Defaults to the current locale's
|
|
9
|
+
* `Intl.NumberFormat` when omitted. `Intl.NumberFormatOptions` is also
|
|
10
|
+
* accepted (legacy) — routed through the built-in `Intl.NumberFormat`.
|
|
11
|
+
*/
|
|
12
|
+
format?: ((value: number) => string) | Intl.NumberFormatOptions;
|
|
9
13
|
locale?: string;
|
|
10
14
|
spinDuration?: number;
|
|
11
15
|
}>(),
|
|
@@ -20,8 +24,11 @@ onMounted(() => {
|
|
|
20
24
|
mounted.value = true;
|
|
21
25
|
});
|
|
22
26
|
|
|
23
|
-
const
|
|
24
|
-
const
|
|
27
|
+
const effectiveFormat = computed<(v: number) => string>(() => {
|
|
28
|
+
const f = props.format;
|
|
29
|
+
if (typeof f === 'function') return f;
|
|
30
|
+
const nf = new Intl.NumberFormat(props.locale, typeof f === 'object' ? f : undefined);
|
|
31
|
+
|
|
25
32
|
return (v: number) => nf.format(v);
|
|
26
33
|
});
|
|
27
34
|
|
|
@@ -31,8 +38,7 @@ interface CharPart {
|
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
const parts = computed<CharPart[]>(() => {
|
|
34
|
-
const
|
|
35
|
-
const formatted = fn(props.value);
|
|
41
|
+
const formatted = effectiveFormat.value(props.value);
|
|
36
42
|
const result: CharPart[] = [];
|
|
37
43
|
for (const char of formatted) {
|
|
38
44
|
if (char >= '0' && char <= '9') {
|
package/src/ui/PieLegend.vue
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { type ChartInstance, type SliceInfo, type ValueFormatter, formatCompact } from '@wick-charts/core';
|
|
3
|
-
import { computed, onMounted, onUnmounted, ref, useSlots } from 'vue';
|
|
3
|
+
import { computed, inject, onMounted, onUnmounted, ref, useSlots } from 'vue';
|
|
4
4
|
|
|
5
|
-
import { useChartInstance, useTheme } from '../context';
|
|
5
|
+
import { LegendAnchorKey, LegendRightAnchorKey, useChartInstance, useTheme } from '../context';
|
|
6
6
|
|
|
7
7
|
type PieLegendMode = 'value' | 'percent' | 'both';
|
|
8
|
+
type PieLegendPosition = 'bottom' | 'right' | 'overlay';
|
|
8
9
|
|
|
9
10
|
const props = withDefaults(
|
|
10
11
|
defineProps<{
|
|
@@ -17,13 +18,31 @@ const props = withDefaults(
|
|
|
17
18
|
mode?: PieLegendMode;
|
|
18
19
|
/** Custom formatter for the absolute slice value. */
|
|
19
20
|
format?: ValueFormatter;
|
|
21
|
+
/** Layout placement. Default: `'bottom'`. */
|
|
22
|
+
position?: PieLegendPosition;
|
|
20
23
|
}>(),
|
|
21
24
|
{
|
|
22
25
|
mode: undefined,
|
|
23
26
|
format: undefined,
|
|
27
|
+
position: 'bottom',
|
|
24
28
|
},
|
|
25
29
|
);
|
|
26
30
|
|
|
31
|
+
const bottomAnchor = inject(LegendAnchorKey);
|
|
32
|
+
const rightAnchor = inject(LegendRightAnchorKey);
|
|
33
|
+
if (!bottomAnchor || !rightAnchor) {
|
|
34
|
+
throw new Error('<PieLegend> must be used within <ChartContainer>: missing legend anchors.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Overlay mode renders inline (no teleport) — same behaviour as React, where
|
|
38
|
+
// `position='overlay'` keeps PieLegend in the series-overlay layer.
|
|
39
|
+
const teleportTarget = computed(() => {
|
|
40
|
+
if (props.position === 'right') return rightAnchor.value;
|
|
41
|
+
if (props.position === 'bottom') return bottomAnchor.value;
|
|
42
|
+
|
|
43
|
+
return null;
|
|
44
|
+
});
|
|
45
|
+
|
|
27
46
|
defineSlots<{
|
|
28
47
|
default?(ctx: { slices: readonly SliceInfo[]; mode: PieLegendMode; format: ValueFormatter }): unknown;
|
|
29
48
|
}>();
|
|
@@ -77,8 +96,62 @@ const slices = computed<readonly SliceInfo[]>(() => {
|
|
|
77
96
|
:mode="resolvedMode"
|
|
78
97
|
:format="resolvedFormat"
|
|
79
98
|
/>
|
|
99
|
+
<Teleport
|
|
100
|
+
v-else-if="slices.length > 0 && teleportTarget"
|
|
101
|
+
:to="teleportTarget"
|
|
102
|
+
>
|
|
103
|
+
<div
|
|
104
|
+
data-chart-pie-legend=""
|
|
105
|
+
:data-chart-pie-legend-position="position"
|
|
106
|
+
:style="{
|
|
107
|
+
display: 'flex',
|
|
108
|
+
flexDirection: 'column',
|
|
109
|
+
gap: '6px',
|
|
110
|
+
padding: '6px 10px',
|
|
111
|
+
fontFamily: theme.typography.fontFamily,
|
|
112
|
+
fontSize: theme.typography.fontSize + 'px',
|
|
113
|
+
color: theme.tooltip.textColor,
|
|
114
|
+
pointerEvents: 'auto',
|
|
115
|
+
}"
|
|
116
|
+
>
|
|
117
|
+
<div
|
|
118
|
+
v-for="(slice, i) in slices"
|
|
119
|
+
:key="i"
|
|
120
|
+
:style="{ display: 'flex', alignItems: 'center', gap: '10px' }"
|
|
121
|
+
>
|
|
122
|
+
<span :style="{ width: '10px', height: '10px', borderRadius: '50%', background: slice.color, flexShrink: 0 }" />
|
|
123
|
+
<span :style="{ flex: 1, opacity: 0.8 }">{{ slice.label }}</span>
|
|
124
|
+
<span
|
|
125
|
+
v-if="resolvedMode === 'value' || resolvedMode === 'both'"
|
|
126
|
+
:style="{
|
|
127
|
+
fontWeight: 600,
|
|
128
|
+
fontVariantNumeric: 'tabular-nums',
|
|
129
|
+
minWidth: resolvedMode === 'value' ? '40px' : undefined,
|
|
130
|
+
textAlign: 'right',
|
|
131
|
+
}"
|
|
132
|
+
>
|
|
133
|
+
{{ resolvedFormat(slice.value) }}
|
|
134
|
+
</span>
|
|
135
|
+
<span
|
|
136
|
+
v-if="resolvedMode === 'percent' || resolvedMode === 'both'"
|
|
137
|
+
:style="{
|
|
138
|
+
opacity: resolvedMode === 'percent' ? 1 : 0.5,
|
|
139
|
+
fontWeight: resolvedMode === 'percent' ? 600 : 400,
|
|
140
|
+
fontSize: (resolvedMode === 'percent' ? theme.typography.fontSize : theme.axis.fontSize) + 'px',
|
|
141
|
+
fontVariantNumeric: 'tabular-nums',
|
|
142
|
+
minWidth: '40px',
|
|
143
|
+
textAlign: 'right',
|
|
144
|
+
}"
|
|
145
|
+
>
|
|
146
|
+
{{ slice.percent.toFixed(1) }}%
|
|
147
|
+
</span>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
</Teleport>
|
|
80
151
|
<div
|
|
81
152
|
v-else-if="slices.length > 0"
|
|
153
|
+
data-chart-pie-legend=""
|
|
154
|
+
data-chart-pie-legend-position="overlay"
|
|
82
155
|
:style="{
|
|
83
156
|
display: 'flex',
|
|
84
157
|
flexDirection: 'column',
|
package/src/ui/TimeAxis.vue
CHANGED
|
@@ -1,25 +1,44 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { formatTime, resolveAxisFontSize, resolveAxisTextColor } from '@wick-charts/core';
|
|
3
|
-
import { computed } from 'vue';
|
|
3
|
+
import { computed, onUnmounted, watch } from 'vue';
|
|
4
4
|
|
|
5
5
|
import { useVisibleRange } from '../composables';
|
|
6
6
|
import { useChartInstance } from '../context';
|
|
7
7
|
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
/** Desired number of labels (≥ 2). Overrides chart-level `axis.x.labelCount`. */
|
|
10
|
+
labelCount?: number;
|
|
11
|
+
/** Minimum pixel gap between adjacent labels (hard floor). Overrides chart-level. */
|
|
12
|
+
minLabelSpacing?: number;
|
|
13
|
+
}>();
|
|
14
|
+
|
|
8
15
|
interface TrackedTick {
|
|
9
16
|
opacity: number;
|
|
10
17
|
addedAt: number;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
const chart = useChartInstance();
|
|
14
|
-
const
|
|
21
|
+
const visibleRange = useVisibleRange(chart);
|
|
22
|
+
|
|
23
|
+
const applyDensity = () => {
|
|
24
|
+
chart.setTimeAxisLabelDensity({
|
|
25
|
+
labelCount: props.labelCount ?? null,
|
|
26
|
+
minLabelSpacing: props.minLabelSpacing ?? null,
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
applyDensity();
|
|
30
|
+
watch(() => [props.labelCount, props.minLabelSpacing], applyDensity);
|
|
31
|
+
onUnmounted(() => {
|
|
32
|
+
chart.setTimeAxisLabelDensity({ labelCount: null, minLabelSpacing: null });
|
|
33
|
+
});
|
|
15
34
|
|
|
16
35
|
const tickMap = new Map<number, TrackedTick>();
|
|
17
36
|
|
|
18
37
|
const theme = computed(() => chart.getTheme());
|
|
19
38
|
|
|
20
39
|
const tickData = computed(() => {
|
|
21
|
-
// Access
|
|
22
|
-
void
|
|
40
|
+
// Access visibleRange.value to track dependency
|
|
41
|
+
void visibleRange.value;
|
|
23
42
|
|
|
24
43
|
const dataInterval = chart.getDataInterval();
|
|
25
44
|
const { ticks: currentTicks, tickInterval } = chart.timeScale.niceTickValues(dataInterval);
|
package/src/ui/Title.vue
CHANGED
|
@@ -1,17 +1,44 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { inject } from 'vue';
|
|
2
|
+
import { computed, inject, useSlots } from 'vue';
|
|
3
3
|
|
|
4
4
|
import { ThemeKey, TitleAnchorKey } from '../context';
|
|
5
5
|
|
|
6
6
|
withDefaults(
|
|
7
7
|
defineProps<{
|
|
8
|
+
/**
|
|
9
|
+
* @deprecated Use the named `sub` slot instead — it accepts arbitrary
|
|
10
|
+
* inline markup (icons, badges, formatted text) to mirror React's
|
|
11
|
+
* `sub: ReactNode`. The string-only prop is kept as a back-compat shim
|
|
12
|
+
* for callers upgrading from 0.3.1; the slot wins when both are provided.
|
|
13
|
+
*
|
|
14
|
+
* Note: the title strip is rendered with `pointer-events: none` so it
|
|
15
|
+
* doesn't intercept canvas hover events. Interactive children (`<a>`,
|
|
16
|
+
* `<button>`) need to opt back in with `pointer-events: auto`.
|
|
17
|
+
*/
|
|
8
18
|
sub?: string;
|
|
9
19
|
}>(),
|
|
10
20
|
{},
|
|
11
21
|
);
|
|
12
22
|
|
|
23
|
+
defineSlots<{
|
|
24
|
+
default?(): unknown;
|
|
25
|
+
/**
|
|
26
|
+
* Secondary label rendered in a muted colour next to the primary one. Use a
|
|
27
|
+
* named slot (instead of the legacy `sub` prop) so consumers can pass
|
|
28
|
+
* arbitrary inline markup — icons, badges, formatted text — to mirror
|
|
29
|
+
* React's `sub: ReactNode`. Interactive children (`<a>`, `<button>`) need
|
|
30
|
+
* `pointer-events: auto` to override the title strip's default
|
|
31
|
+
* `pointer-events: none`.
|
|
32
|
+
*/
|
|
33
|
+
sub?(): unknown;
|
|
34
|
+
}>();
|
|
35
|
+
|
|
13
36
|
const anchor = inject(TitleAnchorKey);
|
|
14
37
|
const theme = inject(ThemeKey);
|
|
38
|
+
const slots = useSlots();
|
|
39
|
+
// Slot wins when both are present — same precedence rule React uses for
|
|
40
|
+
// children vs explicit props.
|
|
41
|
+
const hasSubSlot = computed(() => typeof slots.sub === 'function');
|
|
15
42
|
|
|
16
43
|
// Match the "loud failure" contract of other Vue overlays (`useChartInstance`,
|
|
17
44
|
// `useTheme`) — misuse outside `<ChartContainer>` should throw, not render
|
|
@@ -43,14 +70,14 @@ if (!theme) {
|
|
|
43
70
|
>
|
|
44
71
|
<span><slot /></span>
|
|
45
72
|
<span
|
|
46
|
-
v-if="sub"
|
|
73
|
+
v-if="hasSubSlot || sub"
|
|
47
74
|
:style="{
|
|
48
75
|
fontWeight: 400,
|
|
49
76
|
color: theme.axis.textColor,
|
|
50
77
|
fontSize: theme.axis.fontSize + 'px',
|
|
51
78
|
}"
|
|
52
79
|
>
|
|
53
|
-
{{ sub }}
|
|
80
|
+
<slot name="sub">{{ sub }}</slot>
|
|
54
81
|
</span>
|
|
55
82
|
</div>
|
|
56
83
|
</Teleport>
|