@wick-charts/vue 0.3.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wick-charts/vue",
3
- "version": "0.3.0",
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.0"
48
+ "@wick-charts/core": "^0.3.2"
49
49
  },
50
50
  "scripts": {
51
51
  "build": "vite build"
@@ -1,18 +1,63 @@
1
1
  <script setup lang="ts">
2
- import { type AxisConfig, ChartInstance, type ChartTheme, darkTheme } from '@wick-charts/core';
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 { ChartKey, InfoBarAnchorKey, LegendAnchorKey, LegendRightAnchorKey, ThemeKey, TitleAnchorKey } from './context';
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
- // Anchors for hoisted overlays. Title / InfoBar teleport into a top
24
- // overlay anchor that is positioned *absolute* at the top of the canvas
25
- // block, so the canvas (and its grid) spans the full container height. The
26
- // combined anchor height is measured and fed into `chart.setPadding({ top })`
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
- let topOverlayHeight = 0;
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
- chart.value.setPadding({ top: 20 + topOverlayHeight });
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
- const options: any = {};
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
- if (!topOverlayRef.value) return;
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: linear-gradient(to bottom, ${gtop} 0%, ${bg} 70%, ${gbot} 100%)`
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
- <div
115
- ref="containerRef"
116
- style="position: relative; flex: 1; min-width: 0; min-height: 0; overflow: hidden"
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
- ref="topOverlayRef"
122
- data-chart-top-overlay=""
123
- style="position: absolute; top: 0; left: 0; right: 0; z-index: 2; pointer-events: none; display: flex; flex-direction: column"
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
- v-if="chart"
130
- data-chart-series-overlay=""
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
- <slot />
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>
@@ -4,8 +4,12 @@ import { computed, onMounted, ref } from 'vue';
4
4
  const props = withDefaults(
5
5
  defineProps<{
6
6
  value: number;
7
- /** Value-to-string formatter. Defaults to the current locale's `Intl.NumberFormat`. */
8
- format?: (value: number) => string;
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 defaultFormat = computed(() => {
24
- const nf = new Intl.NumberFormat(props.locale);
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 fn = props.format ?? defaultFormat.value;
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') {
@@ -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',
@@ -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 _visibleRange = useVisibleRange(chart);
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 _visibleRange.value to track dependency
22
- void _visibleRange.value;
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>