@wick-charts/vue 0.6.4 → 0.8.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.
Files changed (55) hide show
  1. package/README.md +21 -7
  2. package/dist/BandSeries.vue.js +20 -18
  3. package/dist/BarSeries.vue.js +15 -13
  4. package/dist/CandlestickSeries.vue.js +31 -29
  5. package/dist/ChartContainer.vue.js +77 -70
  6. package/dist/LineSeries.vue.js +19 -17
  7. package/dist/composables.js +46 -36
  8. package/dist/core/dist/animation/viewport-engine.js +155 -96
  9. package/dist/core/dist/axis/dom-labels.js +56 -51
  10. package/dist/core/dist/chart/scale-guards.js +23 -5
  11. package/dist/core/dist/chart/x-guards.js +37 -0
  12. package/dist/core/dist/chart.js +1286 -843
  13. package/dist/core/dist/data/sync.js +37 -31
  14. package/dist/core/dist/navigator/controller.js +37 -37
  15. package/dist/core/dist/perf/perf-hud.js +3 -3
  16. package/dist/core/dist/scales/tick-tracker.js +2 -2
  17. package/dist/core/dist/scales/x-scale.js +167 -53
  18. package/dist/core/dist/scales/y-scale.js +36 -34
  19. package/dist/core/dist/series/band.js +266 -213
  20. package/dist/core/dist/series/bar.js +297 -291
  21. package/dist/core/dist/series/base-multi-layer.js +283 -237
  22. package/dist/core/dist/series/candlestick.js +1 -0
  23. package/dist/core/dist/series/line.js +21 -20
  24. package/dist/core/dist/snapshots.js +55 -43
  25. package/dist/index.d.ts +323 -58
  26. package/dist/index.js +9 -9
  27. package/dist/ui/Crosshair.vue.js +51 -27
  28. package/dist/ui/InfoBar.vue.js +37 -37
  29. package/dist/ui/Marker.vue.js +14 -11
  30. package/dist/ui/Navigator.vue.js +23 -23
  31. package/dist/ui/ReferenceLine.vue.js +19 -16
  32. package/dist/ui/Tooltip.vue.js +91 -86
  33. package/dist/ui/XAxis.vue.js +48 -0
  34. package/dist/ui/XAxis.vue2.js +4 -0
  35. package/dist/ui/YAxis.vue.js +44 -28
  36. package/dist/ui/YLabel.vue.js +78 -74
  37. package/package.json +9 -3
  38. package/src/BandSeries.vue +11 -6
  39. package/src/BarSeries.vue +13 -6
  40. package/src/CandlestickSeries.vue +9 -5
  41. package/src/ChartContainer.vue +20 -0
  42. package/src/LineSeries.vue +13 -6
  43. package/src/composables.ts +23 -4
  44. package/src/index.ts +8 -1
  45. package/src/ui/Crosshair.vue +41 -1
  46. package/src/ui/InfoBar.vue +2 -1
  47. package/src/ui/Marker.vue +5 -1
  48. package/src/ui/Navigator.vue +2 -1
  49. package/src/ui/ReferenceLine.vue +5 -1
  50. package/src/ui/Tooltip.vue +7 -4
  51. package/src/ui/{TimeAxis.vue → XAxis.vue} +18 -5
  52. package/src/ui/YAxis.vue +66 -12
  53. package/src/ui/YLabel.vue +35 -5
  54. package/dist/ui/TimeAxis.vue.js +0 -44
  55. package/dist/ui/TimeAxis.vue2.js +0 -4
@@ -1,95 +1,99 @@
1
- import { defineComponent as $, useSlots as j, computed as a, ref as I, onMounted as G, onUnmounted as O, openBlock as w, createElementBlock as A, Fragment as B, renderSlot as W, createElementVNode as _, normalizeStyle as Y, unref as g, createVNode as H, createCommentVNode as J } from "vue";
2
- import { prefersReducedMotion as K } from "../core/dist/animation/reduced-motion.js";
3
- import { resolveAnimate as Q, resolveYLabelSeriesId as X, firstVisibleValue as Z, BadgeAnimator as ee } from "../core/dist/chart/badge-animator.js";
4
- import { useChartInstance as te } from "../context.js";
5
- import ae from "./NumberFlow.vue.js";
6
- const ie = /* @__PURE__ */ $({
1
+ import { defineComponent as O, useSlots as W, computed as t, ref as w, onMounted as H, onUnmounted as J, openBlock as A, createElementBlock as B, Fragment as Y, renderSlot as K, createElementVNode as _, normalizeStyle as z, unref as M, createVNode as Q, createCommentVNode as X } from "vue";
2
+ import { prefersReducedMotion as Z } from "../core/dist/animation/reduced-motion.js";
3
+ import { resolveAnimate as ee, resolveYLabelSeriesId as te, firstVisibleValue as ae, BadgeAnimator as le } from "../core/dist/chart/badge-animator.js";
4
+ import { useChartInstance as ne } from "../context.js";
5
+ import oe from "./NumberFlow.vue.js";
6
+ const ce = /* @__PURE__ */ O({
7
7
  __name: "YLabel",
8
8
  props: {
9
9
  seriesId: {},
10
10
  color: {},
11
11
  format: { type: Function },
12
- animate: { type: [Boolean, Object] }
12
+ animate: { type: [Boolean, Object] },
13
+ scale: {}
13
14
  },
14
- setup(z) {
15
- const c = z, e = te(), M = j(), E = a(() => typeof M.default == "function"), i = I(0), o = I(null);
16
- let p = null, y = null, u = null;
17
- const N = a(() => Q(c.animate));
18
- function L() {
19
- u = null;
20
- const t = s.value, n = t !== null ? e.getStackedLastValue(t) : null;
21
- if (t === null || !n) {
22
- p = null, y = null;
15
+ setup(E) {
16
+ const r = E, e = ne(), N = W(), R = t(() => typeof N.default == "function"), l = w(0), i = w(null);
17
+ let m = null, g = null, s = null;
18
+ const T = t(() => ee(r.animate));
19
+ function S() {
20
+ s = null;
21
+ const a = o.value, u = a !== null ? e.getStackedLastValue(a) : null;
22
+ if (a === null || !u) {
23
+ m = null, g = null;
23
24
  return;
24
25
  }
25
- const m = n.value;
26
- (p === null || y !== t) && (p = new ee(m), y = t);
27
- const { glide: P, countUp: U, settleMs: q } = N.value, S = e.getIntroFront(t), v = p.frame({
28
- target: m,
26
+ const f = u.value;
27
+ (m === null || g !== a) && (m = new le(f), g = a);
28
+ const { glide: $, countUp: j, settleMs: G } = T.value, I = e.getIntroFront(a), c = m.frame({
29
+ target: f,
29
30
  // Only needed for the intro count-up; skip the visible-range binary search
30
31
  // entirely once the reveal has settled (the hot streaming path).
31
- firstVisible: S < 1 ? Z(e, t, m) : m,
32
- introFront: S,
32
+ firstVisible: I < 1 ? ae(e, a, f) : f,
33
+ introFront: I,
33
34
  now: performance.now(),
34
- glide: P,
35
- countUp: U,
36
- settleMs: q,
37
- reducedMotion: K()
38
- }), d = o.value;
39
- (d === null || d.positionValue !== v.positionValue || d.textValue !== v.textValue || d.opacity !== v.opacity) && (o.value = { positionValue: v.positionValue, textValue: v.textValue, opacity: v.opacity }), v.animating && u === null && (u = requestAnimationFrame(L));
35
+ glide: $,
36
+ countUp: j,
37
+ settleMs: G,
38
+ reducedMotion: Z()
39
+ }), d = i.value;
40
+ (d === null || d.positionValue !== c.positionValue || d.textValue !== c.textValue || d.opacity !== c.opacity) && (i.value = { positionValue: c.positionValue, textValue: c.textValue, opacity: c.opacity }), c.animating && s === null && (s = requestAnimationFrame(S));
40
41
  }
41
- function R() {
42
- u === null && (u = requestAnimationFrame(L));
42
+ function D() {
43
+ s === null && (s = requestAnimationFrame(S));
43
44
  }
44
- const f = () => {
45
- i.value++, R();
45
+ const p = () => {
46
+ l.value++, D();
46
47
  };
47
- G(() => {
48
- e.setYLabel(!0), e.on("overlayChange", f), e.on("viewportChange", f), e.getSeriesIds().length > 0 && f();
49
- }), O(() => {
50
- e.setYLabel(!1), e.off("overlayChange", f), e.off("viewportChange", f), u !== null && cancelAnimationFrame(u), u = null;
48
+ H(() => {
49
+ e.setYLabel(!0), e.on("overlayChange", p), e.on("viewportChange", p), e.getSeriesIds().length > 0 && p();
50
+ }), J(() => {
51
+ e.setYLabel(!1), e.off("overlayChange", p), e.off("viewportChange", p), s !== null && cancelAnimationFrame(s), s = null;
51
52
  });
52
- const s = a(() => (i.value, X(e, c.seriesId))), l = a(() => (i.value, s.value !== null ? e.getStackedLastValue(s.value) : null)), x = a(() => (i.value, s.value !== null ? e.getPreviousClose(s.value) : null)), r = a(() => (i.value, e.getTheme())), T = a(() => o.value !== null ? o.value.positionValue : l.value?.value ?? 0), D = a(() => o.value !== null ? o.value.textValue : l.value?.value ?? 0), b = a(() => o.value !== null ? o.value.opacity : s.value === null ? 1 : e.getIntroFront(s.value) < 1 ? 0 : 1), h = a(() => (i.value, l.value === null ? 0 : e.yScale.valueToY(T.value))), k = a(() => l.value === null || x.value === null ? "neutral" : l.value.value > x.value ? "up" : l.value.value < x.value ? "down" : "neutral"), V = a(() => {
53
- if (l.value === null) return r.value.yLabel.neutralBackground;
54
- if (!l.value.isLive) return r.value.axis.textColor;
55
- if (c.color) return c.color;
56
- switch (k.value) {
53
+ const o = t(() => (l.value, te(e, r.seriesId))), n = t(() => (l.value, o.value !== null ? e.getStackedLastValue(o.value) : null)), y = t(() => (l.value, o.value !== null ? e.getPreviousClose(o.value) : null)), v = t(() => (l.value, e.getTheme())), C = t(() => (l.value, r.scale !== void 0 ? r.scale : o.value !== null ? e.getSeriesScale(o.value) : void 0)), x = t(() => (l.value, e.getYScale(C.value))), U = t(() => (l.value, e.getYScaleColumn(C.value)?.position ?? e.yAxisPosition)), q = t(() => i.value !== null ? i.value.positionValue : n.value?.value ?? 0), P = t(() => i.value !== null ? i.value.textValue : n.value?.value ?? 0), b = t(() => i.value !== null ? i.value.opacity : o.value === null ? 1 : e.getIntroFront(o.value) < 1 ? 0 : 1), h = t(() => (l.value, n.value === null ? 0 : x.value.valueToY(q.value))), F = t(() => n.value === null || y.value === null ? "neutral" : n.value.value > y.value ? "up" : n.value.value < y.value ? "down" : "neutral"), V = t(() => {
54
+ if (n.value === null) return v.value.yLabel.neutralBackground;
55
+ if (!n.value.isLive) return v.value.axis.textColor;
56
+ if (r.color) return r.color;
57
+ switch (F.value) {
57
58
  case "up":
58
- return r.value.yLabel.upBackground;
59
+ return v.value.yLabel.upBackground;
59
60
  case "down":
60
- return r.value.yLabel.downBackground;
61
+ return v.value.yLabel.downBackground;
61
62
  default:
62
- return r.value.yLabel.neutralBackground;
63
+ return v.value.yLabel.neutralBackground;
63
64
  }
64
- }), C = a(() => {
65
- i.value;
66
- const t = e.yScale.getRange(), n = t.max - t.min;
67
- return n < 0.1 ? 6 : n < 10 ? 4 : n < 1e3 ? 2 : 0;
68
- }), F = a(() => {
69
- if (c.format) return c.format;
70
- const t = new Intl.NumberFormat("en-US", {
71
- minimumFractionDigits: C.value,
72
- maximumFractionDigits: C.value,
65
+ }), L = t(() => {
66
+ l.value;
67
+ const a = x.value.getRange(), u = a.max - a.min;
68
+ return u < 0.1 ? 6 : u < 10 ? 4 : u < 1e3 ? 2 : 0;
69
+ }), k = t(() => {
70
+ if (r.format) return r.format;
71
+ l.value;
72
+ const a = x.value.getFormat();
73
+ if (a) return a;
74
+ const u = new Intl.NumberFormat("en-US", {
75
+ minimumFractionDigits: L.value,
76
+ maximumFractionDigits: L.value,
73
77
  useGrouping: !1
74
78
  });
75
- return (n) => t.format(n);
79
+ return (f) => u.format(f);
76
80
  });
77
- return (t, n) => l.value !== null ? (w(), A(B, { key: 0 }, [
78
- E.value ? W(t.$slots, "default", {
81
+ return (a, u) => n.value !== null ? (A(), B(Y, { key: 0 }, [
82
+ R.value ? K(a.$slots, "default", {
79
83
  key: 0,
80
- value: l.value.value,
84
+ value: n.value.value,
81
85
  y: h.value,
82
86
  bgColor: V.value,
83
- isLive: l.value.isLive,
84
- direction: k.value,
85
- format: F.value,
87
+ isLive: n.value.isLive,
88
+ direction: F.value,
89
+ format: k.value,
86
90
  opacity: b.value
87
- }) : (w(), A(B, { key: 1 }, [
91
+ }) : (A(), B(Y, { key: 1 }, [
88
92
  _("div", {
89
- style: Y({
93
+ style: z({
90
94
  position: "absolute",
91
- left: g(e).plotLeft + "px",
92
- right: (g(e).yAxisPosition === "left" ? 0 : g(e).yAxisWidth) + "px",
95
+ left: M(e).plotLeft + "px",
96
+ right: M(e).getAxisColumnWidth("right") + "px",
93
97
  top: h.value + "px",
94
98
  height: "0",
95
99
  borderTop: "1px dashed " + V.value,
@@ -99,34 +103,34 @@ const ie = /* @__PURE__ */ $({
99
103
  })
100
104
  }, null, 4),
101
105
  _("div", {
102
- style: Y({
106
+ style: z({
103
107
  position: "absolute",
104
- [g(e).yAxisPosition]: "4px",
108
+ [U.value]: "4px",
105
109
  top: h.value + "px",
106
110
  transform: "translateY(-50%)",
107
111
  opacity: b.value,
108
112
  pointerEvents: "auto",
109
113
  zIndex: 3,
110
114
  background: V.value,
111
- color: r.value.yLabel.textColor,
112
- fontSize: r.value.yLabel.fontSize + "px",
113
- fontFamily: r.value.typography.fontFamily,
115
+ color: v.value.yLabel.textColor,
116
+ fontSize: v.value.yLabel.fontSize + "px",
117
+ fontFamily: v.value.typography.fontFamily,
114
118
  padding: "3px 8px",
115
119
  borderRadius: "3px",
116
120
  whiteSpace: "nowrap",
117
121
  transition: "background-color 0.3s ease"
118
122
  })
119
123
  }, [
120
- H(ae, {
121
- value: D.value,
122
- format: F.value,
124
+ Q(oe, {
125
+ value: P.value,
126
+ format: k.value,
123
127
  "spin-duration": 350
124
128
  }, null, 8, ["value", "format"])
125
129
  ], 4)
126
130
  ], 64))
127
- ], 64)) : J("", !0);
131
+ ], 64)) : X("", !0);
128
132
  }
129
133
  });
130
134
  export {
131
- ie as default
135
+ ce as default
132
136
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wick-charts/vue",
3
- "version": "0.6.4",
4
- "description": "High-performance canvas timeseries charts for Vue — candlestick, line, bar, pie. Tree-shakeable, zero runtime deps.",
3
+ "version": "0.8.0",
4
+ "description": "Realtime, animated canvas charts for Vue: candlestick, line, bar, heatmap, pie and more. Tree-shakeable, zero runtime deps.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -14,10 +14,16 @@
14
14
  "charts",
15
15
  "charting",
16
16
  "canvas",
17
+ "realtime",
18
+ "streaming",
19
+ "animation",
17
20
  "candlestick",
18
21
  "line-chart",
22
+ "area-chart",
19
23
  "bar-chart",
24
+ "heatmap",
20
25
  "pie-chart",
26
+ "sparkline",
21
27
  "timeseries",
22
28
  "vue",
23
29
  "visualization"
@@ -45,7 +51,7 @@
45
51
  "vue": ">=3.3.0"
46
52
  },
47
53
  "devDependencies": {
48
- "@wick-charts/core": "^0.6.4"
54
+ "@wick-charts/core": "^0.8.0"
49
55
  },
50
56
  "scripts": {
51
57
  "build": "vite build"
@@ -11,7 +11,8 @@ const props = withDefaults(
11
11
  * Band data — one band per layer. A single band passes a flat
12
12
  * `{ time, low, high }[]`; several bands pass `BandPoint[][]`. To name/color
13
13
  * a band, pass `{ label, color?, data }` (or an array of them). Time accepts
14
- * ms or `Date`.
14
+ * ms or `Date`. On a numeric or category X axis (`axis.x.type`) samples
15
+ * carry `x` in place of `time`: `{ x: 'EU', low, high }`.
15
16
  */
16
17
  data: BandData;
17
18
  /** Visual options override — fill, edge stroke, curve, entrance / intro / smoothing. */
@@ -20,6 +21,8 @@ const props = withDefaults(
20
21
  id?: string;
21
22
  /** Show/hide the series without unmounting it — excludes it from the Y-range fit and tooltip/legend. Default `true`. Live. */
22
23
  visible?: boolean;
24
+ /** Named Y scale from the container's `scales` — autoscales on its own, sharing the time axis. Default: the default scale. Live. */
25
+ scale?: string;
23
26
  }>(),
24
27
  // Vue's type-based `defineProps` casts an absent Boolean-typed prop to
25
28
  // `false` (not `undefined`) without an explicit default — see the
@@ -42,6 +45,7 @@ onMounted(() => {
42
45
  const layerCount = toLayers(props.data).length;
43
46
  const id = chart.addSeries(BandSeriesDef, {
44
47
  ...props.options,
48
+ scale: props.scale,
45
49
  layers: layerCount,
46
50
  id: props.id,
47
51
  });
@@ -66,12 +70,13 @@ watch(
66
70
  },
67
71
  );
68
72
 
73
+ // `scale` rides the same update, so a stacking change is checked against the new scale.
69
74
  watch(
70
- () => props.options,
71
- (options) => {
72
- if (seriesId.value && options) {
73
- chart.updateSeriesOptions(seriesId.value, options);
74
- }
75
+ () => [props.options, props.scale] as const,
76
+ ([options, scale]) => {
77
+ if (!seriesId.value) return;
78
+
79
+ chart.updateSeriesOptions(seriesId.value, options ? { ...options, scale } : { scale });
75
80
  },
76
81
  { deep: true },
77
82
  );
package/src/BarSeries.vue CHANGED
@@ -7,13 +7,18 @@ import { useChartInstance } from './context';
7
7
 
8
8
  const props = withDefaults(
9
9
  defineProps<{
10
- /** Flat `TimePoint[]`, `TimePoint[][]`, or named `{ label, color?, data }` layers. Time accepts ms or `Date`. */
10
+ /**
11
+ * Flat `TimePoint[]`, `TimePoint[][]`, or named `{ label, color?, data }` layers. Time accepts ms or `Date`.
12
+ * On a numeric or category X axis (`axis.x.type`) points carry `x` in place of `time`.
13
+ */
11
14
  data: MultiLayerData;
12
15
  options?: Partial<BarSeriesOptions>;
13
16
  /** Stable series ID — same value across remounts. */
14
17
  id?: string;
15
18
  /** Show/hide the series without unmounting it — excludes it from the Y-range fit and tooltip/legend. Default `true`. Live. */
16
19
  visible?: boolean;
20
+ /** Named Y scale from the container's `scales` — autoscales on its own, sharing the time axis. Default: the default scale. Live. */
21
+ scale?: string;
17
22
  }>(),
18
23
  // Vue's type-based `defineProps` casts an absent Boolean-typed prop to
19
24
  // `false` (not `undefined`) without an explicit default — see the
@@ -36,6 +41,7 @@ onMounted(() => {
36
41
  const layerCount = toLayers(props.data).length;
37
42
  const id = chart.addSeries(BarSeriesDef, {
38
43
  ...props.options,
44
+ scale: props.scale,
39
45
  layers: layerCount,
40
46
  id: props.id,
41
47
  });
@@ -60,12 +66,13 @@ watch(
60
66
  },
61
67
  );
62
68
 
69
+ // `scale` rides the same update, so a stacking change is checked against the new scale.
63
70
  watch(
64
- () => props.options,
65
- (options) => {
66
- if (seriesId.value && options) {
67
- chart.updateSeriesOptions(seriesId.value, options);
68
- }
71
+ () => [props.options, props.scale] as const,
72
+ ([options, scale]) => {
73
+ if (!seriesId.value) return;
74
+
75
+ chart.updateSeriesOptions(seriesId.value, options ? { ...options, scale } : { scale });
69
76
  },
70
77
  { deep: true },
71
78
  );
@@ -14,6 +14,8 @@ const props = withDefaults(
14
14
  id?: string;
15
15
  /** Show/hide the series without unmounting it — excludes it from the Y-range fit and tooltip/legend. Default `true`. Live. */
16
16
  visible?: boolean;
17
+ /** Named Y scale from the container's `scales` — autoscales on its own, sharing the time axis. Default: the default scale. Live. */
18
+ scale?: string;
17
19
  }>(),
18
20
  // Vue's type-based `defineProps` casts an absent Boolean-typed prop to
19
21
  // `false` (not `undefined`) without an explicit default — see the
@@ -37,6 +39,7 @@ function applyData(id: string, data: CandlestickData): void {
37
39
  onMounted(() => {
38
40
  const id = chart.addSeries(CandlestickSeriesDef, {
39
41
  ...props.options,
42
+ scale: props.scale,
40
43
  id: props.id,
41
44
  label: toLayers<OHLCInput>(props.data)[0].label,
42
45
  });
@@ -62,12 +65,13 @@ watch(
62
65
  },
63
66
  );
64
67
 
68
+ // `scale` rides the same update, so a stacking change is checked against the new scale.
65
69
  watch(
66
- () => props.options,
67
- (options) => {
68
- if (seriesId.value && options) {
69
- chart.updateSeriesOptions(seriesId.value, options);
70
- }
70
+ () => [props.options, props.scale] as const,
71
+ ([options, scale]) => {
72
+ if (!seriesId.value) return;
73
+
74
+ chart.updateSeriesOptions(seriesId.value, options ? { ...options, scale } : { scale });
71
75
  },
72
76
  { deep: true },
73
77
  );
@@ -12,6 +12,7 @@ import {
12
12
  type SeriesHoverInfo,
13
13
  type VisibleRange,
14
14
  type VisibleRangeSpec,
15
+ type YAxisConfig,
15
16
  catppuccin,
16
17
  deepEqual,
17
18
  } from '@wick-charts/core';
@@ -35,6 +36,14 @@ const props = withDefaults(
35
36
  theme?: ChartTheme;
36
37
  /** Grouped axis configuration (Y/X visibility, bounds, sizing). Live. */
37
38
  axis?: AxisConfig;
39
+ /**
40
+ * Named secondary Y scales, keyed by id — a series opts in with its `scale`
41
+ * prop and autoscales on its own, sharing the time axis. Same fields as
42
+ * `axis.y`; `position` defaults to the side opposite the default axis. One
43
+ * axis column fits on each side — render it with `<YAxis scale="id" />`.
44
+ * Live.
45
+ */
46
+ scales?: Record<string, YAxisConfig>;
38
47
  /**
39
48
  * Viewport padding. `top`/`bottom` are in pixels. `left`/`right` accept either pixels (`50`)
40
49
  * or data intervals (`{ intervals: 3 }`). Set to 0 for edge-to-edge sparklines. Updates are
@@ -379,6 +388,7 @@ onMounted(async () => {
379
388
 
380
389
  const options: ChartOptions = {};
381
390
  if (props.axis) options.axis = props.axis;
391
+ if (props.scales) options.scales = props.scales;
382
392
  if (props.theme) options.theme = props.theme;
383
393
  if (props.padding) options.padding = props.padding;
384
394
  if (props.viewport) options.viewport = props.viewport;
@@ -432,6 +442,15 @@ watch(
432
442
  { deep: true },
433
443
  );
434
444
 
445
+ // The core skips a deep-equal replay, so a fresh inline literal doesn't refit.
446
+ watch(
447
+ () => props.scales,
448
+ (newScales) => {
449
+ chart.value?.setScales(newScales);
450
+ },
451
+ { deep: true },
452
+ );
453
+
435
454
  watch(
436
455
  () => props.grid?.visible,
437
456
  () => {
@@ -464,6 +483,7 @@ watch(
464
483
  await nextTick();
465
484
  const opts: ChartOptions = {};
466
485
  if (props.axis) opts.axis = props.axis;
486
+ if (props.scales) opts.scales = props.scales;
467
487
  if (props.theme) opts.theme = props.theme;
468
488
  if (props.padding) opts.padding = props.padding;
469
489
  if (props.viewport) opts.viewport = props.viewport;
@@ -7,13 +7,18 @@ import { useChartInstance } from './context';
7
7
 
8
8
  const props = withDefaults(
9
9
  defineProps<{
10
- /** Flat `TimePoint[]`, `TimePoint[][]`, or named `{ label, color?, data }` layers. Time accepts ms or `Date`. */
10
+ /**
11
+ * Flat `TimePoint[]`, `TimePoint[][]`, or named `{ label, color?, data }` layers. Time accepts ms or `Date`.
12
+ * On a numeric or category X axis (`axis.x.type`) points carry `x` in place of `time`.
13
+ */
11
14
  data: MultiLayerData;
12
15
  options?: Partial<LineSeriesOptions>;
13
16
  /** Stable series ID — same value across remounts. */
14
17
  id?: string;
15
18
  /** Show/hide the series without unmounting it — excludes it from the Y-range fit and tooltip/legend. Default `true`. Live. */
16
19
  visible?: boolean;
20
+ /** Named Y scale from the container's `scales` — autoscales on its own, sharing the time axis. Default: the default scale. Live. */
21
+ scale?: string;
17
22
  }>(),
18
23
  // Vue's type-based `defineProps` casts an absent Boolean-typed prop to
19
24
  // `false` (not `undefined`) without an explicit default — see the
@@ -36,6 +41,7 @@ onMounted(() => {
36
41
  const layerCount = toLayers(props.data).length;
37
42
  const id = chart.addSeries(LineSeriesDef, {
38
43
  ...props.options,
44
+ scale: props.scale,
39
45
  layers: layerCount,
40
46
  id: props.id,
41
47
  });
@@ -60,12 +66,13 @@ watch(
60
66
  },
61
67
  );
62
68
 
69
+ // `scale` rides the same update, so a stacking change is checked against the new scale.
63
70
  watch(
64
- () => props.options,
65
- (options) => {
66
- if (seriesId.value && options) {
67
- chart.updateSeriesOptions(seriesId.value, options);
68
- }
71
+ () => [props.options, props.scale] as const,
72
+ ([options, scale]) => {
73
+ if (!seriesId.value) return;
74
+
75
+ chart.updateSeriesOptions(seriesId.value, options ? { ...options, scale } : { scale });
69
76
  },
70
77
  { deep: true },
71
78
  );
@@ -25,10 +25,24 @@ export interface AxisLayout {
25
25
  yAxisPosition: YAxisPosition;
26
26
  yAxisWidth: number;
27
27
  xAxisHeight: number;
28
+ /** Width of the Y-axis column on each side — the default axis's or a named scale's, `0` for none. */
29
+ leftAxisWidth: number;
30
+ rightAxisWidth: number;
31
+ /** Named scale owning each side's column, `null` for the default axis or none — a swap moves the columns. */
32
+ leftAxisScale: string | null;
33
+ rightAxisScale: string | null;
28
34
  }
29
35
 
30
36
  function readAxisLayout(chart: ChartInstance): AxisLayout {
31
- return { yAxisPosition: chart.yAxisPosition, yAxisWidth: chart.yAxisWidth, xAxisHeight: chart.xAxisHeight };
37
+ return {
38
+ yAxisPosition: chart.yAxisPosition,
39
+ yAxisWidth: chart.yAxisWidth,
40
+ xAxisHeight: chart.xAxisHeight,
41
+ leftAxisWidth: chart.getAxisColumnWidth('left'),
42
+ rightAxisWidth: chart.getAxisColumnWidth('right'),
43
+ leftAxisScale: chart.getAxisColumnScale('left'),
44
+ rightAxisScale: chart.getAxisColumnScale('right'),
45
+ };
32
46
  }
33
47
 
34
48
  /** The axis gutters overlays anchor to — refreshed on a Y-axis side or gutter size change. */
@@ -40,7 +54,11 @@ export function useAxisLayout(chart: ChartInstance): Ref<AxisLayout> {
40
54
  const unchanged =
41
55
  prev.yAxisPosition === next.yAxisPosition &&
42
56
  prev.yAxisWidth === next.yAxisWidth &&
43
- prev.xAxisHeight === next.xAxisHeight;
57
+ prev.xAxisHeight === next.xAxisHeight &&
58
+ prev.leftAxisWidth === next.leftAxisWidth &&
59
+ prev.rightAxisWidth === next.rightAxisWidth &&
60
+ prev.leftAxisScale === next.leftAxisScale &&
61
+ prev.rightAxisScale === next.rightAxisScale;
44
62
  if (unchanged) return;
45
63
 
46
64
  layout.value = next;
@@ -57,15 +75,16 @@ export function useAxisLayout(chart: ChartInstance): Ref<AxisLayout> {
57
75
 
58
76
  export function useLastYValue(chart: ChartInstance, seriesId: string): Ref<{ value: number; isLive: boolean } | null> {
59
77
  let snapshot = chart.getLastValue(seriesId);
78
+ const yScale = () => chart.getYScale(chart.getSeriesScale(seriesId));
60
79
  // Track the pixel Y the snapshot maps to so a pan/zoom/resize that leaves the
61
80
  // value unchanged but shifts the badge still re-emits — a consumer-positioned
62
81
  // badge reads `yScale.valueToY` off this ref. Comparing prev/next against the
63
82
  // live yScale alone would always be equal.
64
- let lastY = snapshot ? chart.yScale.valueToY(snapshot.value) : null;
83
+ let lastY = snapshot ? yScale().valueToY(snapshot.value) : null;
65
84
  const val = ref<{ value: number; isLive: boolean } | null>(snapshot);
66
85
  const handler = () => {
67
86
  const next = chart.getLastValue(seriesId);
68
- const nextY = next ? chart.yScale.valueToY(next.value) : null;
87
+ const nextY = next ? yScale().valueToY(next.value) : null;
69
88
  if (snapshot?.value === next?.value && snapshot?.isLive === next?.isLive && lastY === nextY) return;
70
89
 
71
90
  snapshot = next;
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export type {
17
17
  BandEntryAnimation,
18
18
  BandPoint,
19
19
  BandPointInput,
20
+ BandSampleInput,
20
21
  BandSeriesOptions,
21
22
  BarElementTransform,
22
23
  BarEntryAnimation,
@@ -64,6 +65,7 @@ export type {
64
65
  LineIntroValueArgs,
65
66
  LinePaintArgs,
66
67
  LinePainter,
68
+ LinePointInput,
67
69
  LineSeriesOptions,
68
70
  LoadingIndicatorArgs,
69
71
  LoadingIndicatorFn,
@@ -135,6 +137,11 @@ export type {
135
137
  WaveIntroFrame,
136
138
  WaveIntroTransform,
137
139
  XAxisConfig,
140
+ XAxisFormatter,
141
+ XAxisPadding,
142
+ XAxisType,
143
+ XBandPointInput,
144
+ XPointInput,
138
145
  YAxisConfig,
139
146
  YAxisPosition,
140
147
  YRange,
@@ -247,10 +254,10 @@ export { default as NumberFlow } from './ui/NumberFlow.vue';
247
254
  export { default as PieLegend } from './ui/PieLegend.vue';
248
255
  export { default as PieTooltip } from './ui/PieTooltip.vue';
249
256
  export { default as ReferenceLine } from './ui/ReferenceLine.vue';
250
- export { default as TimeAxis, default as XAxis } from './ui/TimeAxis.vue';
251
257
  export { default as TimeRegion } from './ui/TimeRegion.vue';
252
258
  export { default as Title } from './ui/Title.vue';
253
259
  // UI overlays
254
260
  export { default as Tooltip } from './ui/Tooltip.vue';
261
+ export { default as TimeAxis, default as XAxis } from './ui/XAxis.vue';
255
262
  export { default as YAxis } from './ui/YAxis.vue';
256
263
  export { default as YLabel } from './ui/YLabel.vue';
@@ -35,6 +35,32 @@ const tickInterval = computed(() => {
35
35
  return chart.timeScale.niceTickValues(dataInterval.value).tickInterval || dataInterval.value;
36
36
  });
37
37
 
38
+ // One value label per named scale's axis column, each read through its own scale.
39
+ // `bump` re-labels a still crosshair after a formatter or scale change.
40
+ const defaultLabel = computed(() => {
41
+ void bump.value;
42
+ const pos = position.value;
43
+
44
+ return pos === null ? '' : chart.yScale.formatY(pos.y);
45
+ });
46
+
47
+ const scaleLabels = computed(() => {
48
+ void bump.value;
49
+ const pos = position.value;
50
+ if (pos === null) return [];
51
+
52
+ return chart.getYScaleIds().flatMap((scale) => {
53
+ const column = chart.getYScaleColumn(scale);
54
+ if (column === null) return [];
55
+
56
+ const yScale = chart.getYScale(scale);
57
+
58
+ return [{ scale, position: column.position, text: yScale.formatY(yScale.yToValue(pos.mediaY)) }];
59
+ });
60
+ });
61
+ // A named scale can own the default axis's side when the default axis is hidden.
62
+ const defaultSideTaken = computed(() => scaleLabels.value.some((label) => label.position === chart.yAxisPosition));
63
+
38
64
  const labelStyle = computed(() => ({
39
65
  // Blend the theme's labelBackground at 80% opacity so the axis grid
40
66
  // shows through — keeps the badge from looking like an opaque block
@@ -58,6 +84,7 @@ const labelStyle = computed(() => ({
58
84
  <template v-if="position">
59
85
  <!-- Y label on the value axis -->
60
86
  <div
87
+ v-if="!defaultSideTaken"
61
88
  :style="{
62
89
  position: 'absolute',
63
90
  [chart.yAxisPosition]: '0',
@@ -66,7 +93,20 @@ const labelStyle = computed(() => ({
66
93
  ...labelStyle,
67
94
  }"
68
95
  >
69
- {{ chart.yScale.formatY(position.y) }}
96
+ {{ defaultLabel }}
97
+ </div>
98
+ <div
99
+ v-for="label in scaleLabels"
100
+ :key="label.scale"
101
+ :style="{
102
+ position: 'absolute',
103
+ [label.position]: '0',
104
+ top: position.mediaY + 'px',
105
+ transform: 'translateY(-50%)',
106
+ ...labelStyle,
107
+ }"
108
+ >
109
+ {{ label.text }}
70
110
  </div>
71
111
  <!-- Time label on bottom axis -->
72
112
  <div
@@ -9,6 +9,7 @@ import {
9
9
  buildHoverSnapshots,
10
10
  buildLastSnapshots,
11
11
  formatCompact,
12
+ formatHeaderX,
12
13
  formatPriceAdaptive,
13
14
  resolveCandlestickBodyColor,
14
15
  } from '@wick-charts/core';
@@ -144,7 +145,7 @@ function isBand(data: SeriesSnapshot['data']): data is BandPoint {
144
145
  <slot v-if="hasCustomSlot" :snapshots="snapshots" :time="displayTime" :is-hover="isHover" />
145
146
  <template v-else>
146
147
  <span :style="{ color: theme.axis.textColor, marginRight: '2px' }">
147
- {{ chart.timeScale.formatX(displayTime, dataInterval) }}
148
+ {{ formatHeaderX(chart, { time: displayTime, snapshots, interval: dataInterval }) }}
148
149
  </span>
149
150
  <template v-for="s in snapshots" :key="s.id">
150
151
  <span v-if="isOHLC(s.data)" :style="{ display: 'inline-flex', alignItems: 'center', gap: '4px' }">