@wick-charts/vue 0.8.0 → 0.8.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.
Files changed (48) hide show
  1. package/README.md +2 -0
  2. package/dist/BandSeries.vue.js +43 -32
  3. package/dist/BarSeries.vue.js +44 -33
  4. package/dist/CandlestickSeries.vue.js +24 -23
  5. package/dist/ChartContainer.vue.js +1 -1
  6. package/dist/HeatmapSeries.vue.js +21 -20
  7. package/dist/LineSeries.vue.js +43 -32
  8. package/dist/PieSeries.vue.js +25 -24
  9. package/dist/composables.js +63 -36
  10. package/dist/core/dist/axis/dom-labels.js +64 -53
  11. package/dist/core/dist/chart/badge-animator.js +38 -28
  12. package/dist/core/dist/chart/options.js +1 -1
  13. package/dist/core/dist/chart/y-target.js +38 -32
  14. package/dist/core/dist/chart.js +852 -709
  15. package/dist/core/dist/data/decimation.js +52 -51
  16. package/dist/core/dist/data/store.js +38 -20
  17. package/dist/core/dist/data/sync.js +97 -59
  18. package/dist/core/dist/navigator/controller.js +11 -11
  19. package/dist/core/dist/perf/perf-hud.js +3 -3
  20. package/dist/core/dist/scales/tick-tracker.js +2 -1
  21. package/dist/core/dist/scales/time-ticks.js +99 -0
  22. package/dist/core/dist/scales/x-scale.js +33 -27
  23. package/dist/core/dist/series/band.js +83 -83
  24. package/dist/core/dist/series/bar.js +240 -211
  25. package/dist/core/dist/series/base-multi-layer.js +288 -240
  26. package/dist/core/dist/series/candlestick.js +168 -168
  27. package/dist/core/dist/series/line.js +545 -455
  28. package/dist/core/dist/snapshots.js +109 -86
  29. package/dist/core/dist/utils/point.js +4 -0
  30. package/dist/core/dist/utils/time.js +42 -26
  31. package/dist/index.d.ts +67 -28
  32. package/dist/ui/InfoBar.vue.js +69 -69
  33. package/dist/ui/NumberFlow.vue.js +36 -27
  34. package/dist/ui/Tooltip.vue.js +128 -131
  35. package/dist/ui/YLabel.vue.js +47 -47
  36. package/package.json +2 -2
  37. package/src/BandSeries.vue +20 -5
  38. package/src/BarSeries.vue +20 -5
  39. package/src/CandlestickSeries.vue +5 -4
  40. package/src/ChartContainer.vue +15 -9
  41. package/src/HeatmapSeries.vue +5 -4
  42. package/src/LineSeries.vue +20 -5
  43. package/src/PieSeries.vue +6 -5
  44. package/src/composables.ts +64 -1
  45. package/src/ui/InfoBar.vue +11 -3
  46. package/src/ui/NumberFlow.vue +17 -3
  47. package/src/ui/Tooltip.vue +23 -12
  48. package/src/ui/YLabel.vue +28 -27
package/src/BarSeries.vue CHANGED
@@ -3,6 +3,7 @@ import type { BarSeriesOptions, MultiLayerData, SeriesSyncState } from '@wick-ch
3
3
  import { BarSeriesDef, EMPTY_SYNC_STATE, syncLayers, toLayers } from '@wick-charts/core';
4
4
  import { onMounted, onUnmounted, ref, watch } from 'vue';
5
5
 
6
+ import { rawSeriesData, seriesDataSource } from './composables';
6
7
  import { useChartInstance } from './context';
7
8
 
8
9
  const props = withDefaults(
@@ -32,13 +33,14 @@ const seriesId = ref<string | null>(null);
32
33
  // reconciliation (plus per-layer labels/colors) so streaming eases instead of
33
34
  // snapping Y every tick.
34
35
  let prevSync: SeriesSyncState[] = [];
36
+ let layerCount = 0;
35
37
 
36
38
  function applyData(id: string, data: MultiLayerData): void {
37
39
  prevSync = syncLayers({ chart, id, data, prev: prevSync });
38
40
  }
39
41
 
40
- onMounted(() => {
41
- const layerCount = toLayers(props.data).length;
42
+ function createSeries(data: MultiLayerData): void {
43
+ layerCount = toLayers(data).length;
42
44
  const id = chart.addSeries(BarSeriesDef, {
43
45
  ...props.options,
44
46
  scale: props.scale,
@@ -48,8 +50,12 @@ onMounted(() => {
48
50
  seriesId.value = id;
49
51
  prevSync = new Array(layerCount).fill(EMPTY_SYNC_STATE);
50
52
  // Lazy watcher — apply initial data here so static-data mounts render without a no-op first frame.
51
- applyData(id, props.data);
53
+ applyData(id, data);
52
54
  chart.setSeriesVisible(id, props.visible);
55
+ }
56
+
57
+ onMounted(() => {
58
+ createSeries(rawSeriesData(props.data));
53
59
  });
54
60
 
55
61
  onUnmounted(() => {
@@ -57,11 +63,20 @@ onUnmounted(() => {
57
63
  });
58
64
 
59
65
  watch(
60
- () => props.data,
61
- (data) => {
66
+ seriesDataSource(() => props.data),
67
+ () => {
68
+ const data = rawSeriesData(props.data);
62
69
  const id = seriesId.value;
63
70
  if (!id) return;
64
71
 
72
+ // The layer count is fixed when a series is created; a new count re-creates it, as in React.
73
+ if (toLayers(data).length !== layerCount) {
74
+ chart.removeSeries(id);
75
+ createSeries(data);
76
+
77
+ return;
78
+ }
79
+
65
80
  applyData(id, data);
66
81
  },
67
82
  );
@@ -3,6 +3,7 @@ import type { CandlestickData, CandlestickSeriesOptions, OHLCInput, SeriesSyncSt
3
3
  import { CandlestickSeriesDef, EMPTY_SYNC_STATE, syncSeriesLayer, toLayers } from '@wick-charts/core';
4
4
  import { onMounted, onUnmounted, ref, watch } from 'vue';
5
5
 
6
+ import { rawSeriesData, seriesDataSource } from './composables';
6
7
  import { useChartInstance } from './context';
7
8
 
8
9
  const props = withDefaults(
@@ -47,7 +48,7 @@ onMounted(() => {
47
48
  // Initial data load — Vue's `watch` is lazy by default, so the watcher
48
49
  // below only fires on subsequent `data` prop mutations. Explicitly apply
49
50
  // the first value here so components with static data render immediately.
50
- applyData(id, props.data);
51
+ applyData(id, rawSeriesData(props.data));
51
52
  chart.setSeriesVisible(id, props.visible);
52
53
  });
53
54
 
@@ -56,12 +57,12 @@ onUnmounted(() => {
56
57
  });
57
58
 
58
59
  watch(
59
- () => props.data,
60
- (data) => {
60
+ seriesDataSource(() => props.data),
61
+ () => {
61
62
  const id = seriesId.value;
62
63
  if (!id) return;
63
64
 
64
- applyData(id, data);
65
+ applyData(id, rawSeriesData(props.data));
65
66
  },
66
67
  );
67
68
 
@@ -48,7 +48,7 @@ const props = withDefaults(
48
48
  * Viewport padding. `top`/`bottom` are in pixels. `left`/`right` accept either pixels (`50`)
49
49
  * or data intervals (`{ intervals: 3 }`). Set to 0 for edge-to-edge sparklines. Updates are
50
50
  * applied reactively when this prop or the measured header height changes.
51
- * Defaults: `{ top: 20, bottom: 20, right: { intervals: 3 }, left: { intervals: 0 } }`.
51
+ * Defaults: `{ top: 20, bottom: 20, right: { intervals: 3 }, left: { intervals: 0.5 } }`.
52
52
  */
53
53
  padding?: {
54
54
  top?: number;
@@ -62,19 +62,23 @@ const props = withDefaults(
62
62
  */
63
63
  viewport?: {
64
64
  /**
65
- * Width of the visible window in data bars, set on the first data load
66
- * to `maxVisibleBars * dataInterval`. While the dataset is smaller than
67
- * this width, streaming ticks render into the empty right-side gap and
68
- * the viewport stays put; once the data reaches the right edge, the
69
- * viewport pans forward to keep the latest bar pinned (tail-scroll).
70
- * Default: 200.
65
+ * Most bars the chart's own fit shows. The first data load fits every bar
66
+ * up to this count; while the data holds fewer, streamed bars widen the
67
+ * window, and once it is full the window keeps its width and follows the
68
+ * latest bar. `fitContent()` and an explicit bar count are not capped.
69
+ * Default: 200. Values below 2 are clamped.
71
70
  */
72
71
  maxVisibleBars?: number;
73
72
  /**
74
73
  * Initial visible range applied before the first paint with data. Same
75
74
  * shape as the imperative `chart.setVisibleRange` — pass a bar count
76
75
  * (e.g. `35`), an explicit `{from, to}` window, or `{from, bars}` for
77
- * a warm-up pair. Captured at mount.
76
+ * a warm-up pair.
77
+ *
78
+ * It stays the chart's window until the user zooms or pans or the app
79
+ * sets a range, so a series that loads later or data set again keeps it.
80
+ * A bar count widens with streamed bars until it holds that many; a
81
+ * `{from, …}` window ends once streaming moves it. Captured at mount.
78
82
  */
79
83
  initialRange?: VisibleRangeSpec;
80
84
  };
@@ -86,7 +90,7 @@ const props = withDefaults(
86
90
  * two-way binding; a same-value literal (compared structurally) is a
87
91
  * no-op, so an inline object doesn't re-apply on every render.
88
92
  *
89
- * For a one-shot initial window that isn't re-applied on every prop
93
+ * For an initial window that isn't re-applied on every prop
90
94
  * change, use `viewport.initialRange` instead.
91
95
  */
92
96
  visibleRange?: VisibleRangeSpec;
@@ -211,6 +215,8 @@ const props = withDefaults(
211
215
  // distinguishable from an explicit `:fade="false"` all-off opt-out.
212
216
  // Declaring a default (even `undefined`) suppresses the cast.
213
217
  fade: undefined,
218
+ // Absent must reach the core as `undefined` (animations on), as in React and Svelte.
219
+ animations: undefined,
214
220
  },
215
221
  );
216
222
 
@@ -3,6 +3,7 @@ import type { HeatmapCellData, HeatmapSeriesOptions } from '@wick-charts/core';
3
3
  import { HeatmapSeriesDef } from '@wick-charts/core';
4
4
  import { onMounted, onUnmounted, ref, watch } from 'vue';
5
5
 
6
+ import { rawSeriesData, seriesDataSource } from './composables';
6
7
  import { useChartInstance } from './context';
7
8
 
8
9
  const props = withDefaults(
@@ -28,7 +29,7 @@ onMounted(() => {
28
29
  seriesId.value = id;
29
30
  // Lazy watcher — apply initial data here so static-data mounts render without a no-op first frame.
30
31
  if (props.data.length > 0) {
31
- chart.setSeriesData(id, props.data);
32
+ chart.setSeriesData(id, rawSeriesData(props.data));
32
33
  }
33
34
  chart.setSeriesVisible(id, props.visible);
34
35
  });
@@ -38,12 +39,12 @@ onUnmounted(() => {
38
39
  });
39
40
 
40
41
  watch(
41
- () => props.data,
42
- (data) => {
42
+ seriesDataSource(() => props.data),
43
+ () => {
43
44
  // No length guard: an empty array is a legitimate update (filter yields
44
45
  // no rows) and must clear the previous cells, matching the React wrapper.
45
46
  if (seriesId.value) {
46
- chart.setSeriesData(seriesId.value, data);
47
+ chart.setSeriesData(seriesId.value, rawSeriesData(props.data));
47
48
  }
48
49
  },
49
50
  );
@@ -3,6 +3,7 @@ import type { LineSeriesOptions, MultiLayerData, SeriesSyncState } from '@wick-c
3
3
  import { EMPTY_SYNC_STATE, LineSeriesDef, syncLayers, toLayers } from '@wick-charts/core';
4
4
  import { onMounted, onUnmounted, ref, watch } from 'vue';
5
5
 
6
+ import { rawSeriesData, seriesDataSource } from './composables';
6
7
  import { useChartInstance } from './context';
7
8
 
8
9
  const props = withDefaults(
@@ -32,13 +33,14 @@ const seriesId = ref<string | null>(null);
32
33
  // reconciliation (plus per-layer labels/colors) so streaming eases instead of
33
34
  // snapping Y every tick.
34
35
  let prevSync: SeriesSyncState[] = [];
36
+ let layerCount = 0;
35
37
 
36
38
  function applyData(id: string, data: MultiLayerData): void {
37
39
  prevSync = syncLayers({ chart, id, data, prev: prevSync });
38
40
  }
39
41
 
40
- onMounted(() => {
41
- const layerCount = toLayers(props.data).length;
42
+ function createSeries(data: MultiLayerData): void {
43
+ layerCount = toLayers(data).length;
42
44
  const id = chart.addSeries(LineSeriesDef, {
43
45
  ...props.options,
44
46
  scale: props.scale,
@@ -48,8 +50,12 @@ onMounted(() => {
48
50
  seriesId.value = id;
49
51
  prevSync = new Array(layerCount).fill(EMPTY_SYNC_STATE);
50
52
  // Lazy watcher — apply initial data here so static-data mounts render without a no-op first frame.
51
- applyData(id, props.data);
53
+ applyData(id, data);
52
54
  chart.setSeriesVisible(id, props.visible);
55
+ }
56
+
57
+ onMounted(() => {
58
+ createSeries(rawSeriesData(props.data));
53
59
  });
54
60
 
55
61
  onUnmounted(() => {
@@ -57,11 +63,20 @@ onUnmounted(() => {
57
63
  });
58
64
 
59
65
  watch(
60
- () => props.data,
61
- (data) => {
66
+ seriesDataSource(() => props.data),
67
+ () => {
68
+ const data = rawSeriesData(props.data);
62
69
  const id = seriesId.value;
63
70
  if (!id) return;
64
71
 
72
+ // The layer count is fixed when a series is created; a new count re-creates it, as in React.
73
+ if (toLayers(data).length !== layerCount) {
74
+ chart.removeSeries(id);
75
+ createSeries(data);
76
+
77
+ return;
78
+ }
79
+
65
80
  applyData(id, data);
66
81
  },
67
82
  );
package/src/PieSeries.vue CHANGED
@@ -3,6 +3,7 @@ import type { PieSeriesOptions, PieSliceData } from '@wick-charts/core';
3
3
  import { PieSeriesDef } from '@wick-charts/core';
4
4
  import { onMounted, onUnmounted, ref, watch } from 'vue';
5
5
 
6
+ import { rawSeriesData, seriesDataSource } from './composables';
6
7
  import { useChartInstance } from './context';
7
8
 
8
9
  const props = withDefaults(
@@ -28,7 +29,7 @@ onMounted(() => {
28
29
  seriesId.value = id;
29
30
  // Lazy watcher — apply initial data here so static-data mounts render without a no-op first frame.
30
31
  if (props.data.length > 0) {
31
- chart.setSeriesData(id, props.data);
32
+ chart.setSeriesData(id, rawSeriesData(props.data));
32
33
  }
33
34
  chart.setSeriesVisible(id, props.visible);
34
35
  });
@@ -38,10 +39,10 @@ onUnmounted(() => {
38
39
  });
39
40
 
40
41
  watch(
41
- () => props.data,
42
- (data) => {
43
- if (seriesId.value && data.length > 0) {
44
- chart.setSeriesData(seriesId.value, data);
42
+ seriesDataSource(() => props.data),
43
+ () => {
44
+ if (seriesId.value && props.data.length > 0) {
45
+ chart.setSeriesData(seriesId.value, rawSeriesData(props.data));
45
46
  }
46
47
  },
47
48
  );
@@ -1,5 +1,68 @@
1
1
  import type { ChartInstance, CrosshairPosition, VisibleRange, YAxisPosition, YRange } from '@wick-charts/core';
2
- import { type Ref, onMounted, onUnmounted, ref } from 'vue';
2
+ import { type Ref, onMounted, onUnmounted, ref, toRaw } from 'vue';
3
+
4
+ /** A `{ label?, color?, data }` layer — an object carrying a `data` array. */
5
+ function isLayerObject(value: unknown): value is { data: unknown[] } {
6
+ return (
7
+ typeof value === 'object' && value !== null && !Array.isArray(value) && Array.isArray(Reflect.get(value, 'data'))
8
+ );
9
+ }
10
+
11
+ /** Each layer's point array, read through whatever Proxy the prop arrived in. */
12
+ function layerArrays(data: unknown): unknown[][] {
13
+ if (isLayerObject(data)) return [data.data];
14
+ if (!Array.isArray(data)) return [];
15
+
16
+ const layered = Array.isArray(data[0]) || isLayerObject(data[0]);
17
+ if (!layered) return [data];
18
+
19
+ return data.map((layer) => (isLayerObject(layer) ? layer.data : (layer as unknown[])));
20
+ }
21
+
22
+ /**
23
+ * A series `data` prop with Vue's reactive Proxies taken off the top level and
24
+ * each layer, so the chart reads plain arrays: a `ref` / `reactive` array of
25
+ * 100k points read through its Proxy costs the engine several times more.
26
+ */
27
+ export function rawSeriesData<D>(data: D): D {
28
+ const raw = toRaw(data);
29
+ const rawLayer = (layer: unknown) => {
30
+ if (!isLayerObject(layer)) return toRaw(layer);
31
+
32
+ const plain = toRaw(layer);
33
+
34
+ return { ...plain, data: toRaw(plain.data) };
35
+ };
36
+
37
+ if (!Array.isArray(raw)) return rawLayer(raw) as D;
38
+
39
+ const layered = Array.isArray(raw[0]) || isLayerObject(raw[0]);
40
+ if (!layered) return raw;
41
+
42
+ return raw.map(rawLayer) as D;
43
+ }
44
+
45
+ /**
46
+ * Watch source for a series `data` prop. Besides a new array, it re-fires on
47
+ * an in-place `push` / `splice` or an edit of a layer's last point on a
48
+ * reactive array — it reads each layer's length and last point through the
49
+ * Proxy instead of deep-watching every point. A `shallowRef` still needs a
50
+ * new array (or `triggerRef`).
51
+ */
52
+ export function seriesDataSource(getData: () => unknown): () => unknown[] {
53
+ return () => {
54
+ const data = getData();
55
+ const deps: unknown[] = [data];
56
+ for (const points of layerArrays(data)) {
57
+ deps.push(points, points.length);
58
+
59
+ const last = points[points.length - 1];
60
+ if (typeof last === 'object' && last !== null) deps.push(...Object.values(last));
61
+ }
62
+
63
+ return deps;
64
+ };
65
+ }
3
66
 
4
67
  export function useVisibleRange(chart: ChartInstance): Ref<VisibleRange> {
5
68
  const range = ref<VisibleRange>(chart.getVisibleRange()) as Ref<VisibleRange>;
@@ -4,13 +4,13 @@ import {
4
4
  type OHLCData,
5
5
  type SeriesSnapshot,
6
6
  type SnapshotSort,
7
- type TimePoint,
8
7
  type TooltipFormatter,
9
8
  buildHoverSnapshots,
10
9
  buildLastSnapshots,
11
10
  formatCompact,
12
11
  formatHeaderX,
13
12
  formatPriceAdaptive,
13
+ formatRowValue,
14
14
  resolveCandlestickBodyColor,
15
15
  } from '@wick-charts/core';
16
16
  import { computed, inject, onMounted, onUnmounted, ref, useSlots } from 'vue';
@@ -56,12 +56,15 @@ const onOverlayChange = () => {
56
56
  };
57
57
  onMounted(() => {
58
58
  chart.on('overlayChange', onOverlayChange);
59
+ // Rows read animated values (smoothing, fades, percent shares).
60
+ chart.on('seriesFrame', onOverlayChange);
59
61
  // Catch-up: a sibling series' mount effect may have registered data in the
60
62
  // same synchronous flush, so poke the computed now.
61
63
  if (chart.getSeriesIds().length > 0) bump.value++;
62
64
  });
63
65
  onUnmounted(() => {
64
66
  chart.off('overlayChange', onOverlayChange);
67
+ chart.off('seriesFrame', onOverlayChange);
65
68
  });
66
69
 
67
70
  // Hover-over-the-y-axis gap: the overlay canvas includes the y-axis strip,
@@ -89,7 +92,12 @@ const snapshots = computed<readonly SeriesSnapshot[]>(() =>
89
92
  isHover.value ? (hoverSnapshots.value as readonly SeriesSnapshot[]) : lastSnapshots.value,
90
93
  );
91
94
 
92
- const dataInterval = computed(() => chart.getDataInterval());
95
+ // `chart` is not reactive: `bump` re-reads the interval once data lands after mount.
96
+ const dataInterval = computed(() => {
97
+ void bump.value;
98
+
99
+ return chart.getDataInterval();
100
+ });
93
101
  const displayTime = computed(() => {
94
102
  if (snapshots.value.length === 0) return 0;
95
103
  if (isHover.value && crosshair.value) return crosshair.value.time;
@@ -205,7 +213,7 @@ function isBand(data: SeriesSnapshot['data']): data is BandPoint {
205
213
  }"
206
214
  />
207
215
  <span :style="{ color: s.color, fontWeight: 500 }">
208
- {{ effectiveFormat((s.data as TimePoint).value, 'value') }}
216
+ {{ formatRowValue(s, (v) => effectiveFormat(v, 'value')) }}
209
217
  </span>
210
218
  </span>
211
219
  </template>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, ref } from 'vue';
2
+ import { computed, onMounted, ref, watch } from 'vue';
3
3
 
4
4
  const props = withDefaults(
5
5
  defineProps<{
@@ -11,6 +11,7 @@ const props = withDefaults(
11
11
  */
12
12
  format?: ((value: number) => string) | Intl.NumberFormatOptions;
13
13
  locale?: string;
14
+ /** Per-digit spin duration in ms. A value that arrives sooner than this after the previous one snaps instead. */
14
15
  spinDuration?: number;
15
16
  }>(),
16
17
  {
@@ -50,6 +51,19 @@ const parts = computed<CharPart[]>(() => {
50
51
  return result;
51
52
  });
52
53
 
54
+ // A spin retargeted mid-turn shows two half digits, so values faster than a spin snap.
55
+ const duration = ref(props.spinDuration);
56
+ let changedAt = Number.NEGATIVE_INFINITY;
57
+ watch(
58
+ () => effectiveFormat.value(props.value),
59
+ () => {
60
+ const now = performance.now();
61
+ const tooFast = now - changedAt < props.spinDuration;
62
+ changedAt = now;
63
+ duration.value = tooFast ? 0 : props.spinDuration;
64
+ },
65
+ );
66
+
53
67
  const digits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
54
68
  </script>
55
69
 
@@ -77,8 +91,8 @@ const digits = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];
77
91
  display: 'flex',
78
92
  flexDirection: 'column',
79
93
  transform: 'translateY(' + (-parseInt(part.value) * 1.2) + 'em)',
80
- transition: mounted
81
- ? 'transform ' + spinDuration + 'ms cubic-bezier(0.16, 1, 0.3, 1)'
94
+ transition: mounted && duration > 0
95
+ ? 'transform ' + duration + 'ms cubic-bezier(0.16, 1, 0.3, 1)'
82
96
  : 'none',
83
97
  }"
84
98
  >
@@ -4,14 +4,13 @@ import {
4
4
  type OHLCData,
5
5
  type SeriesSnapshot,
6
6
  type SnapshotSort,
7
- type TimePoint,
8
7
  type TooltipFormatter,
9
8
  buildHoverSnapshots,
10
9
  computeTooltipPosition,
11
10
  formatCompact,
12
- formatDate,
13
- formatHeaderX,
14
11
  formatPriceAdaptive,
12
+ formatRowValue,
13
+ formatTooltipHeader,
15
14
  resolveCandlestickBodyColor,
16
15
  } from '@wick-charts/core';
17
16
  import { computed, onMounted, onUnmounted, ref, useSlots, watchEffect } from 'vue';
@@ -47,14 +46,26 @@ const onOverlayChange = () => {
47
46
  };
48
47
  onMounted(() => {
49
48
  chart.on('overlayChange', onOverlayChange);
49
+ // Rows read animated values (smoothing, fades, percent shares).
50
+ chart.on('seriesFrame', onOverlayChange);
50
51
  if (chart.getSeriesIds().length > 0) bump.value++;
51
52
  });
52
53
  onUnmounted(() => {
53
54
  chart.off('overlayChange', onOverlayChange);
55
+ chart.off('seriesFrame', onOverlayChange);
54
56
  });
55
57
 
56
- const theme = computed(() => chart.getTheme());
57
- const dataInterval = computed(() => chart.getDataInterval());
58
+ const theme = computed(() => {
59
+ void bump.value;
60
+
61
+ return chart.getTheme();
62
+ });
63
+ // `chart` is not reactive: `bump` re-reads the interval once data lands after mount.
64
+ const dataInterval = computed(() => {
65
+ void bump.value;
66
+
67
+ return chart.getDataInterval();
68
+ });
58
69
 
59
70
  const snapshots = computed<readonly SeriesSnapshot[]>(() => {
60
71
  void bump.value;
@@ -127,9 +138,12 @@ const floatingPos = computed<{ left: number; top: number } | null>(() => {
127
138
 
128
139
  const hasOHLC = snapshots.value.some((s) => 'open' in s.data);
129
140
  const lineCount = snapshots.value.filter((s) => !('open' in s.data)).length;
130
- // A band row carries two numbers, so it needs a wider box than a line row.
141
+ // A band row carries two numbers, and a percent-stack row a share and a value, so both need a wider box.
131
142
  const hasBand = snapshots.value.some((s) => 'high' in s.data && !('open' in s.data));
132
- const tooltipWidth = hasBand ? 220 : 160;
143
+ const hasShare = snapshots.value.some((s) => s.share !== undefined);
144
+ let tooltipWidth = 160;
145
+ if (hasBand) tooltipWidth = 220;
146
+ if (hasShare) tooltipWidth = 240;
133
147
  const tooltipHeight = hasOHLC ? 140 : 40 + lineCount * 22;
134
148
 
135
149
  return computeTooltipPosition({
@@ -188,10 +202,7 @@ function isBand(data: SeriesSnapshot['data']): data is BandPoint {
188
202
  letterSpacing: '0.02em',
189
203
  }"
190
204
  >
191
- <template v-if="chart.timeScale.getType() === 'time'">
192
- {{ formatDate(displayTime, { locale: chart.timeScale.getLocale(), timeZone: chart.timeScale.getTimeZone() }) }}
193
- </template>
194
- {{ formatHeaderX(chart, { time: displayTime, snapshots, interval: dataInterval }) }}
205
+ {{ formatTooltipHeader(chart, { time: displayTime, snapshots, interval: dataInterval }) }}
195
206
  </div>
196
207
 
197
208
  <template v-for="s in snapshots" :key="s.id">
@@ -264,7 +275,7 @@ function isBand(data: SeriesSnapshot['data']): data is BandPoint {
264
275
  />
265
276
  <span :style="{ opacity: 0.6, flex: '1' }">{{ s.label ?? 'Value' }}</span>
266
277
  <span :style="{ fontWeight: 600, color: s.color }">
267
- {{ effectiveFormat((s.data as TimePoint).value, 'value') }}
278
+ {{ formatRowValue(s, (v) => effectiveFormat(v, 'value')) }}
268
279
  </span>
269
280
  </div>
270
281
  </template>
package/src/ui/YLabel.vue CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  prefersReducedMotion,
8
8
  resolveAnimate,
9
9
  resolveYLabelSeriesId,
10
+ yLabelFractionDigits,
10
11
  } from '@wick-charts/core';
11
12
  import { computed, onMounted, onUnmounted, ref, useSlots } from 'vue';
12
13
 
@@ -21,28 +22,32 @@ export type { YLabelAnimate, YLabelAnimateOptions } from '@wick-charts/core';
21
22
  /** Direction of the current value vs. previous close. Drives the badge color in the default UI. */
22
23
  export type YLabelDirection = 'up' | 'down' | 'neutral';
23
24
 
24
- const props = defineProps<{
25
- /**
26
- * Owning series id. **Optional** — when omitted, the first visible
27
- * single-layer time series is picked, falling back to the first visible
28
- * multi-layer time series.
29
- */
30
- seriesId?: string;
31
- color?: string;
32
- /** Custom formatter; routed through NumberFlow's `formatter` so the digit animation still plays. Default: the scale's `<YAxis format>`, else a range-adaptive number. */
33
- format?: ValueFormatter;
34
- /**
35
- * Badge motion. `true` (default) glides + counts up + fades in with the
36
- * intro; `false` snaps with no easing; an object tunes individual parts.
37
- */
38
- animate?: YLabelAnimate;
39
- /**
40
- * Named Y scale (`ChartContainer` `scales`) the value maps through. Default:
41
- * the series' own scale. The badge sits on that scale's axis side — the
42
- * default axis side for an overlay scale.
43
- */
44
- scale?: string;
45
- }>();
25
+ const props = withDefaults(
26
+ defineProps<{
27
+ /**
28
+ * Owning series id. **Optional** — when omitted, the first visible
29
+ * single-layer time series is picked, falling back to the first visible
30
+ * multi-layer time series.
31
+ */
32
+ seriesId?: string;
33
+ color?: string;
34
+ /** Custom formatter; routed through NumberFlow's `formatter` so the digit animation still plays. Default: the scale's `<YAxis format>`, else a range-adaptive number. */
35
+ format?: ValueFormatter;
36
+ /**
37
+ * Badge motion. `true` (default) glides + counts up + fades in with the
38
+ * intro; `false` snaps with no easing; an object tunes individual parts.
39
+ */
40
+ animate?: YLabelAnimate;
41
+ /**
42
+ * Named Y scale (`ChartContainer` `scales`) the value maps through. Default:
43
+ * the series' own scale. The badge sits on that scale's axis side — the
44
+ * default axis side for an overlay scale.
45
+ */
46
+ scale?: string;
47
+ }>(),
48
+ // Vue casts an absent Boolean prop to `false`; an absent `animate` must stay `undefined` (on).
49
+ { animate: undefined },
50
+ );
46
51
 
47
52
  defineSlots<{
48
53
  default?(ctx: {
@@ -250,12 +255,8 @@ const bgColor = computed<string>(() => {
250
255
  const fractionDigits = computed(() => {
251
256
  void bump.value;
252
257
  const yRange = yScale.value.getRange();
253
- const range = yRange.max - yRange.min;
254
- if (range < 0.1) return 6;
255
- if (range < 10) return 4;
256
- if (range < 1000) return 2;
257
258
 
258
- return 0;
259
+ return yLabelFractionDigits(yRange.max - yRange.min);
259
260
  });
260
261
 
261
262
  const effectiveFormat = computed<ValueFormatter>(() => {