@wick-charts/vue 0.6.0 → 0.6.1

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.
@@ -1,9 +1,11 @@
1
- function d(r, s, l, x) {
2
- const { context: t, bitmapSize: e, horizontalPixelRatio: o, verticalPixelRatio: c } = r, a = Math.max(1, Math.round(c)), i = Math.max(1, Math.round(o));
3
- t.strokeStyle = x.crosshair.color, t.lineWidth = Math.max(a, i), t.setLineDash([4 * o, 4 * o]);
4
- const h = Math.round(s) + (i % 2 === 1 ? 0.5 : 0), n = Math.round(l) + (a % 2 === 1 ? 0.5 : 0);
5
- t.beginPath(), t.moveTo(h, 0), t.lineTo(h, e.height), t.moveTo(0, n), t.lineTo(e.width, n), t.stroke(), t.setLineDash([]);
1
+ function u(m) {
2
+ const { scope: x, bitmapX: o, bitmapY: i, theme: M, pane: a } = m, { context: t, bitmapSize: h, horizontalPixelRatio: e, verticalPixelRatio: p } = x, n = o <= a.width, r = i <= a.height;
3
+ if (!n && !r) return;
4
+ const s = Math.max(1, Math.round(p)), c = Math.max(1, Math.round(e));
5
+ t.strokeStyle = M.crosshair.color, t.lineWidth = Math.max(s, c), t.setLineDash([4 * e, 4 * e]);
6
+ const l = Math.round(o) + (c % 2 === 1 ? 0.5 : 0), d = Math.round(i) + (s % 2 === 1 ? 0.5 : 0);
7
+ t.beginPath(), n && (t.moveTo(l, 0), t.lineTo(l, h.height)), r && (t.moveTo(0, d), t.lineTo(h.width, d)), t.stroke(), t.setLineDash([]);
6
8
  }
7
9
  export {
8
- d as renderCrosshair
10
+ u as renderCrosshair
9
11
  };
@@ -6,8 +6,8 @@ function i(o, e) {
6
6
  const t = o.clientX - e.clientX, s = o.clientY - e.clientY;
7
7
  return Math.hypot(t, s);
8
8
  }
9
- const d = 5, v = 500;
10
- class M extends a {
9
+ const d = 5, m = 500;
10
+ class T extends a {
11
11
  zoom;
12
12
  pan;
13
13
  canvas;
@@ -15,13 +15,27 @@ class M extends a {
15
15
  yScale;
16
16
  target;
17
17
  constructor(e, t, s, n) {
18
- super(), this.canvas = e, this.target = t, this.timeScale = s, this.yScale = n, this.zoom = new r(t, s), this.pan = new u(t, s, e), e.style.cursor = "crosshair", e.style.touchAction = "none", e.addEventListener("wheel", this.onWheel, { passive: !1 }), e.addEventListener("mousedown", this.onMouseDown), e.addEventListener("mousemove", this.onMouseMove), e.addEventListener("mouseup", this.onMouseUp), e.addEventListener("mouseleave", this.onMouseLeave), e.addEventListener("click", this.onClick), e.addEventListener("dblclick", this.onDblClick), e.addEventListener("touchstart", this.onTouchStart, { passive: !1 }), e.addEventListener("touchmove", this.onTouchMove, { passive: !1 }), e.addEventListener("touchend", this.onTouchEnd), e.addEventListener("touchcancel", this.onTouchCancel);
18
+ super(), this.canvas = e, this.target = t, this.timeScale = s, this.yScale = n, this.zoom = new r(t, s), this.pan = new u(t, s, e), e.style.cursor = "crosshair", this.syncPanZoomMode(), e.addEventListener("wheel", this.onWheel, { passive: !1 }), e.addEventListener("mousedown", this.onMouseDown), e.addEventListener("mousemove", this.onMouseMove), e.addEventListener("mouseup", this.onMouseUp), e.addEventListener("mouseleave", this.onMouseLeave), e.addEventListener("click", this.onClick), e.addEventListener("dblclick", this.onDblClick), e.addEventListener("touchstart", this.onTouchStart, { passive: !1 }), e.addEventListener("touchmove", this.onTouchMove, { passive: !1 }), e.addEventListener("touchend", this.onTouchEnd), e.addEventListener("touchcancel", this.onTouchCancel);
19
+ }
20
+ /** Live read of the target's capability — series can be added or removed at any time. */
21
+ panZoomEnabled() {
22
+ return this.target.canPanZoom?.() ?? !0;
23
+ }
24
+ /**
25
+ * Reflect the target's pan/zoom capability into the canvas CSS. While
26
+ * pan/zoomable, `touch-action: none` hands every touch gesture to the
27
+ * chart; on a spatial-only target native scrolling stays enabled so the
28
+ * chart doesn't trap the page. The host re-invokes this whenever the
29
+ * series mix changes.
30
+ */
31
+ syncPanZoomMode() {
32
+ this.canvas.style.touchAction = this.panZoomEnabled() ? "none" : "auto";
19
33
  }
20
34
  onWheel = (e) => {
21
- this.zoom.handleWheel(e);
35
+ this.panZoomEnabled() && this.zoom.handleWheel(e);
22
36
  };
23
37
  onMouseDown = (e) => {
24
- this.zoom.cancelPendingRebound(), this.pan.handleMouseDown(e), this.downClient = { x: e.clientX, y: e.clientY };
38
+ this.zoom.cancelPendingRebound(), this.panZoomEnabled() && this.pan.handleMouseDown(e), this.downClient = { x: e.clientX, y: e.clientY };
25
39
  };
26
40
  onMouseMove = (e) => {
27
41
  this.pan.isDragging() && this.pan.handleMouseMove(e), this.emitCrosshair(e.offsetX, e.offsetY);
@@ -54,36 +68,38 @@ class M extends a {
54
68
  // synthesize a phantom 'click'.
55
69
  gestureHadTwoFingers = !1;
56
70
  onTouchStart = (e) => {
57
- e.preventDefault(), this.zoom.cancelPendingRebound(), this.touchCount = e.touches.length, e.touches.length === 1 ? (this.pan.handleMouseDown({
71
+ this.panZoomEnabled() && (e.preventDefault(), this.zoom.cancelPendingRebound(), this.touchCount = e.touches.length, e.touches.length === 1 ? (this.pan.handleMouseDown({
58
72
  button: 0,
59
73
  clientX: e.touches[0].clientX
60
- }), this.downClient = { x: e.touches[0].clientX, y: e.touches[0].clientY }, this.touchDownTime = Date.now(), this.gestureHadTwoFingers = !1) : e.touches.length === 2 && (this.lastTouchDist = i(e.touches[0], e.touches[1]), this.gestureHadTwoFingers = !0);
74
+ }), this.downClient = { x: e.touches[0].clientX, y: e.touches[0].clientY }, this.touchDownTime = Date.now(), this.gestureHadTwoFingers = !1) : e.touches.length === 2 && (this.lastTouchDist = i(e.touches[0], e.touches[1]), this.gestureHadTwoFingers = !0));
61
75
  };
62
76
  onTouchMove = (e) => {
63
- if (e.preventDefault(), e.touches.length === 1 && this.touchCount === 1)
64
- this.pan.handleMouseMove({
65
- clientX: e.touches[0].clientX
66
- });
67
- else if (e.touches.length === 2) {
68
- const t = i(e.touches[0], e.touches[1]), s = (e.touches[0].clientX + e.touches[1].clientX) / 2, n = this.canvas.getBoundingClientRect();
69
- if (t > 0 && this.lastTouchDist > 0) {
70
- const h = l(this.lastTouchDist / t, 0.1, 10), c = this.timeScale.xToTime(s - n.left);
71
- this.target.zoomAt(c, h, this.timeScale.getMediaWidth());
77
+ if (this.panZoomEnabled()) {
78
+ if (e.preventDefault(), e.touches.length === 1 && this.touchCount === 1)
79
+ this.pan.handleMouseMove({
80
+ clientX: e.touches[0].clientX
81
+ });
82
+ else if (e.touches.length === 2) {
83
+ const t = i(e.touches[0], e.touches[1]), s = (e.touches[0].clientX + e.touches[1].clientX) / 2, n = this.canvas.getBoundingClientRect();
84
+ if (t > 0 && this.lastTouchDist > 0) {
85
+ const h = l(this.lastTouchDist / t, 0.1, 10), c = this.timeScale.xToTime(s - n.left);
86
+ this.target.zoomAt(c, h, this.timeScale.getMediaWidth());
87
+ }
88
+ this.lastTouchDist = t;
72
89
  }
73
- this.lastTouchDist = t;
74
90
  }
75
91
  };
76
92
  onTouchEnd = (e) => {
77
93
  if (e.touches.length === 0) {
78
94
  const t = e.changedTouches[0];
79
- if (this.touchCount === 1 && !this.gestureHadTwoFingers && t && !this.movedBeyondClickTolerance(t.clientX, t.clientY) && Date.now() - this.touchDownTime <= v) {
95
+ if (this.touchCount === 1 && !this.gestureHadTwoFingers && t && !this.movedBeyondClickTolerance(t.clientX, t.clientY) && Date.now() - this.touchDownTime <= m) {
80
96
  const s = this.canvas.getBoundingClientRect();
81
97
  this.emit("click", this.posFromOffset(t.clientX - s.left, t.clientY - s.top));
82
98
  }
83
99
  this.pan.handleMouseUp(), this.touchCount = 0, this.lastTouchDist = 0, this.downClient = null, this.gestureHadTwoFingers = !1, this.emit("crosshairMove", null);
84
100
  return;
85
101
  }
86
- e.touches.length === 1 && (this.touchCount = 1, this.lastTouchDist = 0, this.pan.handleMouseDown({ button: 0, clientX: e.touches[0].clientX }), this.downClient = { x: e.touches[0].clientX, y: e.touches[0].clientY }, this.touchDownTime = Date.now());
102
+ e.touches.length === 1 && this.touchCount === 2 && (this.touchCount = 1, this.lastTouchDist = 0, this.pan.handleMouseDown({ button: 0, clientX: e.touches[0].clientX }), this.downClient = { x: e.touches[0].clientX, y: e.touches[0].clientY }, this.touchDownTime = Date.now());
87
103
  };
88
104
  onTouchCancel = () => {
89
105
  this.pan.handleMouseUp(), this.touchCount = 0, this.lastTouchDist = 0, this.downClient = null, this.gestureHadTwoFingers = !1, this.emit("crosshairMove", null);
@@ -104,5 +120,5 @@ class M extends a {
104
120
  }
105
121
  }
106
122
  export {
107
- M as InteractionHandler
123
+ T as InteractionHandler
108
124
  };
package/dist/index.d.ts CHANGED
@@ -6,6 +6,12 @@ import { Ref } from 'vue';
6
6
  import { ShallowRef } from 'vue';
7
7
 
8
8
  declare const __VLS_component: DefineComponent<__VLS_Props_3, {}, {}, {}, {}, ComponentOptionsMixin, ComponentOptionsMixin, {}, string, PublicProps, Readonly<__VLS_Props_3> & Readonly<{}>, {
9
+ fade: boolean | {
10
+ top?: number;
11
+ overlap?: number;
12
+ right?: number;
13
+ left?: number;
14
+ };
9
15
  theme: ChartTheme;
10
16
  gradient: boolean;
11
17
  interactive: boolean;
@@ -275,6 +281,35 @@ declare type __VLS_Props_3 = {
275
281
  grid?: {
276
282
  visible: boolean;
277
283
  };
284
+ /**
285
+ * Soft fade-out at the top of the plot area, so series content dissolves
286
+ * under a floating `<Title>` / `<InfoBar>` instead of colliding with it.
287
+ * The mask erases canvas alpha rather than painting a cover color, so it
288
+ * stays correct over the default background gradient. Live.
289
+ *
290
+ * Two directions, independently controlled:
291
+ *
292
+ * **X (under the axis)** — panning content slides under the Y-axis
293
+ * column and dissolves instead of hard-clipping at the pane edge. **On
294
+ * by default** as a 60px ramp that finishes just inside the axis
295
+ * column, before the label glyphs; `{ right }` overrides the total
296
+ * ramp width in CSS px (`0` disables). `{ left }` adds the mirror zone
297
+ * at the left pane edge (default off).
298
+ *
299
+ * **Top (under the header)** — opt-in. `true` enables the auto zone
300
+ * (measured header + 24px run-out, half the header fold-in released so
301
+ * data rides up into the zone at rest), `{ top }` sets the zone height
302
+ * explicitly, and `{ overlap }` tunes how many px of the fold-in to
303
+ * release (0 keeps the strict fold-in). Off unless one of those is set.
304
+ *
305
+ * `false` — every mask off, strict edges everywhere.
306
+ */
307
+ fade?: boolean | {
308
+ top?: number;
309
+ overlap?: number;
310
+ right?: number;
311
+ left?: number;
312
+ };
278
313
  /**
279
314
  * How `<Title>` and `<InfoBar>` are positioned relative to the canvas. Live.
280
315
  * - `'overlay'` (default): absolute overlays on top of the canvas.
@@ -2117,6 +2152,13 @@ export declare class ChartInstance extends EventEmitter<ChartEvents> implements
2117
2152
  color: string;
2118
2153
  }[] | null;
2119
2154
  getSeriesIds(): string[];
2155
+ /**
2156
+ * Whether pan/zoom gestures currently apply — true when any visible series
2157
+ * has a time axis. Part of the {@link PanZoomTarget} surface: on a
2158
+ * spatial-only chart (pie, heatmap) the interaction layer stops capturing
2159
+ * wheel and touch events, so the page scrolls normally under the cursor.
2160
+ */
2161
+ canPanZoom(): boolean;
2120
2162
  /**
2121
2163
  * Filter `getSeriesIds()` by renderer type. A concrete kind (`'pie'`,
2122
2164
  * `'heatmap'`, a custom kind, …) returns series of that exact kind;
@@ -2234,6 +2276,14 @@ export declare class ChartInstance extends EventEmitter<ChartEvents> implements
2234
2276
  setGrid(grid: {
2235
2277
  visible: boolean;
2236
2278
  }): void;
2279
+ /**
2280
+ * Set or clear the edge fade masks (see {@link ChartOptions.fade}).
2281
+ * Content the main layer draws inside a zone dissolves toward that edge.
2282
+ * `right` omitted re-arms its default (track the Y-axis width); pass
2283
+ * `{ top: 0, right: 0, left: 0 }` to turn every mask off. Takes effect on
2284
+ * the next render frame.
2285
+ */
2286
+ setFade(fade: ChartOptions['fade']): void;
2237
2287
  /**
2238
2288
  * Set the visual state for one side of the chart. Typically called in
2239
2289
  * response to the `onEdgeReached` callback:
@@ -2374,6 +2424,18 @@ export declare interface ChartOptions {
2374
2424
  grid?: {
2375
2425
  visible: boolean;
2376
2426
  };
2427
+ /**
2428
+ * Soft alpha fade applied at the edges of the plot area. Anything the main
2429
+ * layer draws inside a zone (series, grid, time-region bands) dissolves to
2430
+ * transparent as it approaches the edge: horizontally sliding under the
2431
+ * Y-axis labels on the right (`right`, **on by default**), toward a
2432
+ * floating Title / InfoBar at the top (`top`), or out the left edge
2433
+ * (`left`). The mask *erases* pixels rather than painting a cover color,
2434
+ * so it stays correct over any container background — including the
2435
+ * default CSS gradient. Live — `chart.setFade()` updates it after
2436
+ * construction.
2437
+ */
2438
+ fade?: FadeConfig;
2377
2439
  /**
2378
2440
  * Animation control. Grouped as `axis: { y, x, ticks }` (axis-side
2379
2441
  * behaviour), `toggle` (series visibility — alpha + Y refit), and
@@ -2741,6 +2803,27 @@ declare class EventEmitter<Events = Record<string, Listener>> {
2741
2803
  removeAllListeners(): void;
2742
2804
  }
2743
2805
 
2806
+ /** Shape of {@link ChartOptions.fade}. */
2807
+ declare interface FadeConfig {
2808
+ /** Fade-zone height in CSS pixels, measured down from the top of the plot
2809
+ * area. `0` / omitted disables the mask. */
2810
+ top?: number;
2811
+ /**
2812
+ * Total width in CSS pixels of the horizontal dissolve at the right edge.
2813
+ * The ramp finishes just inside the Y-axis column — 12px past the pane
2814
+ * edge, before the right-anchored label glyphs start — so content melts
2815
+ * on approach (pan / tail-scroll exit) and is fully gone before it can
2816
+ * cross any axis text; the rest of the width runs backward into the pane
2817
+ * as a soft lead-in. **Defaults to a 60px ramp** — on out of the box;
2818
+ * `0` disables. A no-op while the Y axis is hidden.
2819
+ */
2820
+ right?: number;
2821
+ /** Fade-zone width in CSS pixels at the left pane edge — content panning
2822
+ * out to the left dissolves instead of hard-clipping at the canvas
2823
+ * boundary. `0` / omitted disables. */
2824
+ left?: number;
2825
+ }
2826
+
2744
2827
  /**
2745
2828
  * Plain staggered fade-in, no geometry motion. Works for both bar and
2746
2829
  * candlestick series.
@@ -3680,6 +3763,13 @@ declare interface PanZoomTarget {
3680
3763
  * `chartWidth` participates in soft-bound math the same way as in `pan`.
3681
3764
  */
3682
3765
  zoomAt(centerTime: number, factor: number, chartWidth?: number): void;
3766
+ /**
3767
+ * Whether pan/zoom gestures currently apply to this target. A spatial-only
3768
+ * chart (pie, heatmap) has no time axis to gesture on, so the interaction
3769
+ * layer leaves wheel and touch events to the page — scrolling past the
3770
+ * chart keeps working. Targets that omit the hook are always pan/zoomable.
3771
+ */
3772
+ canPanZoom?(): boolean;
3683
3773
  }
3684
3774
 
3685
3775
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wick-charts/vue",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
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.6.0"
48
+ "@wick-charts/core": "^0.6.1"
49
49
  },
50
50
  "scripts": {
51
51
  "build": "vite build"
@@ -7,6 +7,7 @@ import {
7
7
  type ChartTheme,
8
8
  type CrosshairPosition,
9
9
  type EdgeReachedInfo,
10
+ type FadeConfig,
10
11
  type PointClickInfo,
11
12
  type SeriesHoverInfo,
12
13
  type VisibleRange,
@@ -91,6 +92,30 @@ const props = withDefaults(
91
92
  interactive?: boolean;
92
93
  /** Background grid configuration. Live. Default: `{ visible: true }`. */
93
94
  grid?: { visible: boolean };
95
+ /**
96
+ * Soft fade-out at the top of the plot area, so series content dissolves
97
+ * under a floating `<Title>` / `<InfoBar>` instead of colliding with it.
98
+ * The mask erases canvas alpha rather than painting a cover color, so it
99
+ * stays correct over the default background gradient. Live.
100
+ *
101
+ * Two directions, independently controlled:
102
+ *
103
+ * **X (under the axis)** — panning content slides under the Y-axis
104
+ * column and dissolves instead of hard-clipping at the pane edge. **On
105
+ * by default** as a 60px ramp that finishes just inside the axis
106
+ * column, before the label glyphs; `{ right }` overrides the total
107
+ * ramp width in CSS px (`0` disables). `{ left }` adds the mirror zone
108
+ * at the left pane edge (default off).
109
+ *
110
+ * **Top (under the header)** — opt-in. `true` enables the auto zone
111
+ * (measured header + 24px run-out, half the header fold-in released so
112
+ * data rides up into the zone at rest), `{ top }` sets the zone height
113
+ * explicitly, and `{ overlap }` tunes how many px of the fold-in to
114
+ * release (0 keeps the strict fold-in). Off unless one of those is set.
115
+ *
116
+ * `false` — every mask off, strict edges everywhere.
117
+ */
118
+ fade?: boolean | { top?: number; overlap?: number; right?: number; left?: number };
94
119
  /**
95
120
  * How `<Title>` and `<InfoBar>` are positioned relative to the canvas. Live.
96
121
  * - `'overlay'` (default): absolute overlays on top of the canvas.
@@ -168,6 +193,11 @@ const props = withDefaults(
168
193
  // because it already had a default.
169
194
  interactive: true,
170
195
  headerLayout: 'overlay',
196
+ // Same Boolean-cast trap, opposite goal: an absent `fade` must stay
197
+ // `undefined` (keep the core's default X mask armed) and remain
198
+ // distinguishable from an explicit `:fade="false"` all-off opt-out.
199
+ // Declaring a default (even `undefined`) suppresses the cast.
200
+ fade: undefined,
171
201
  },
172
202
  );
173
203
 
@@ -199,9 +229,49 @@ provide(NavigatorAnchorKey, navigatorAnchor);
199
229
  let resizeObserver: ResizeObserver | null = null;
200
230
  const topOverlayHeight = ref(0);
201
231
 
232
+ /** Run-out below the measured header (CSS px) for the auto `fade` zone —
233
+ * content starts dissolving this far before it slides under the header. */
234
+ const FADE_AUTO_BAND = 24;
235
+
236
+ // Fade resolution — shared by the header fold-in below and `applyFade`.
237
+ // Vue casts an absent Boolean-typed prop to `false`, which conveniently is
238
+ // also the "off" value here. `overlap` deliberately applies only while the
239
+ // fade is on: without the mask, letting data ride under the header would
240
+ // just recreate the collision the strict fold-in exists to prevent.
241
+ // The top mask is opt-in: `true`, `{ top }`, or `{ overlap }` arms it (a
242
+ // pure X config like `{ right: 0 }` does not).
243
+ const fadeEnabled = computed(() => {
244
+ if (props.fade === false) return false;
245
+
246
+ return typeof props.fade === 'object'
247
+ ? props.fade.top !== undefined || props.fade.overlap !== undefined
248
+ : props.fade === true;
249
+ });
250
+ // Auto overlap: half the measured header — with the full fold-in intact the
251
+ // zone would cover only padding and enabling the fade would change nothing
252
+ // visible at rest. Explicit `overlap` (including 0) wins.
253
+ const fadeOverlap = computed(() => {
254
+ if (!fadeEnabled.value) return 0;
255
+
256
+ const explicit = typeof props.fade === 'object' ? props.fade.overlap : undefined;
257
+ if (explicit !== undefined) return Math.max(0, explicit);
258
+
259
+ return topOverlayHeight.value / 2;
260
+ });
261
+ const resolvedFadeTop = computed(() => {
262
+ if (!fadeEnabled.value) return 0;
263
+ if (typeof props.fade === 'object' && props.fade.top !== undefined) return props.fade.top;
264
+
265
+ const measured = props.headerLayout === 'overlay' ? topOverlayHeight.value : 0;
266
+
267
+ return measured > 0 ? measured + FADE_AUTO_BAND : FADE_AUTO_BAND;
268
+ });
269
+
202
270
  // Inline mode: browser flex already reserves header height, so folding it
203
271
  // into padding.top would double-shift the data. Only overlay needs the fold.
204
- const headerExtra = computed(() => (props.headerLayout === 'overlay' ? topOverlayHeight.value : 0));
272
+ const headerExtra = computed(() =>
273
+ props.headerLayout === 'overlay' ? Math.max(0, topOverlayHeight.value - fadeOverlap.value) : 0,
274
+ );
205
275
 
206
276
  function applyPadding() {
207
277
  if (!chart.value) return;
@@ -213,6 +283,43 @@ function applyPadding() {
213
283
  chart.value.setPadding(merged);
214
284
  }
215
285
 
286
+ /** Seed the constructor's fade config so the very first frame is already
287
+ * correct — `applyFade` lands the header-measured top zone right after, but
288
+ * an opted-out chart must not flash the default X mask for one paint. */
289
+ function seedFadeOption(options: ChartOptions): void {
290
+ if (props.fade === false) {
291
+ options.fade = { top: 0, right: 0, left: 0 };
292
+
293
+ return;
294
+ }
295
+ if (typeof props.fade !== 'object') return;
296
+
297
+ const seed: FadeConfig = {};
298
+ if (props.fade.top !== undefined) seed.top = props.fade.top;
299
+ if (props.fade.right !== undefined) seed.right = props.fade.right;
300
+ if (props.fade.left !== undefined) seed.left = props.fade.left;
301
+ options.fade = seed;
302
+ }
303
+
304
+ function applyFade() {
305
+ if (!chart.value) return;
306
+
307
+ if (props.fade === false) {
308
+ chart.value.setFade({ top: 0, right: 0, left: 0 });
309
+
310
+ return;
311
+ }
312
+
313
+ // Omitting `right` keeps the core's under-the-axis default armed; only an
314
+ // explicit value (or the all-off branch above) overrides it.
315
+ const next: FadeConfig = { top: resolvedFadeTop.value };
316
+ if (typeof props.fade === 'object') {
317
+ if (props.fade.right !== undefined) next.right = props.fade.right;
318
+ if (props.fade.left !== undefined) next.left = props.fade.left;
319
+ }
320
+ chart.value.setFade(next);
321
+ }
322
+
216
323
  // Wires up (or tears down) the header-height observer for the current
217
324
  // `headerLayout`. Called on mount AND whenever `headerLayout` flips at
218
325
  // runtime — overlay needs a live measurement, inline lets browser flex
@@ -268,6 +375,7 @@ onMounted(async () => {
268
375
  if (perfAtMount !== undefined) options.perf = perfAtMount;
269
376
  if (onEdgeReachedAtMount) options.onEdgeReached = onEdgeReachedAtMount;
270
377
  if (props.animations !== undefined) options.animations = props.animations;
378
+ seedFadeOption(options);
271
379
  chart.value = new ChartInstance(containerRef.value, options);
272
380
  subscribeDeclarativeEvents(chart.value);
273
381
  props.onReady?.(chart.value);
@@ -352,6 +460,7 @@ watch(
352
460
  if (perfAtMount !== undefined) opts.perf = perfAtMount;
353
461
  if (onEdgeReachedAtMount) opts.onEdgeReached = onEdgeReachedAtMount;
354
462
  if (next !== undefined) opts.animations = next;
463
+ seedFadeOption(opts);
355
464
  chart.value = new ChartInstance(containerRef.value, opts);
356
465
  subscribeDeclarativeEvents(chart.value);
357
466
  props.onReady?.(chart.value);
@@ -404,6 +513,12 @@ watch(
404
513
  applyPadding,
405
514
  );
406
515
 
516
+ // Fade rides the chart identity too, so a rebuilt instance (an `animations`
517
+ // change) receives the current zones again; `setFade` no-ops on unchanged
518
+ // values, so the extra fires are free. `immediate` covers the mount path —
519
+ // the first run lands right after `chart.value` is set.
520
+ watch(() => [chart.value, resolvedFadeTop.value, props.fade] as const, applyFade, { immediate: true });
521
+
407
522
  const rootStyle = computed(() => {
408
523
  const t = themeRef.value;
409
524
  const [gtop, gbot] = t?.chartGradient ?? ['transparent', 'transparent'];