radiant-charts-core 0.2.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.
@@ -0,0 +1,305 @@
1
+ import { Group, Circle, Marker, MarkerShape, Text } from '../scene/Shapes';
2
+ import { formatAxisValue } from '../axes/CartesianAxis';
3
+ import { Scale, BandScale, safeBandwidth } from '../scale/Scale';
4
+ import { Animator } from '../core/Animator';
5
+
6
+ export interface ScatterSeriesOptions {
7
+ xKey: string;
8
+ yKey: string;
9
+ sizeKey?: string;
10
+ fill?: string;
11
+ stroke?: string;
12
+ strokeWidth?: number;
13
+ marker?: {
14
+ size?: number;
15
+ fill?: string;
16
+ };
17
+ jitter?: number; // 0 to 1, fraction of bandwidth to jitter (default 0)
18
+ labels?: import('../RadiantChart').DataLabelOptions;
19
+ animation?: import('../RadiantChart').AnimationOptions;
20
+ shadow?: import('../RadiantChart').DropShadow;
21
+ markerShape?: 'circle' | 'square' | 'cross' | 'diamond' | 'triangle' | 'plus';
22
+ }
23
+
24
+ /**
25
+ * Point animation state: start and target values for a single marker.
26
+ */
27
+ interface PointState {
28
+ startX: number; targetX: number;
29
+ startY: number; targetY: number;
30
+ startRadius: number; targetRadius: number;
31
+ startOpacity: number; targetOpacity: number;
32
+ }
33
+
34
+ /**
35
+ * ScatterSeries: Renders individual points (Scatter or Bubble charts).
36
+ *
37
+ * Uses an object-pool / diff strategy: existing Circle shapes are reused and
38
+ * their target positions are updated in-place, so the scene graph is not torn
39
+ * down and rebuilt on every update call. This makes streaming / real-time data
40
+ * significantly cheaper — only shape creation/removal happens at the boundary.
41
+ */
42
+ export class ScatterSeries {
43
+ private group = new Group();
44
+ private xKey: string;
45
+ private yKey: string;
46
+ private sizeKey?: string;
47
+ private fill: string = '#4e79a7';
48
+ private stroke: string = '#fff';
49
+ private strokeWidth: number = 1;
50
+ private markerSize: number = 8;
51
+ private labelsOpts?: import('../RadiantChart').DataLabelOptions;
52
+ private animationOpts?: import('../RadiantChart').AnimationOptions;
53
+ private shadowOpts?: import('../RadiantChart').DropShadow;
54
+
55
+ private animator?: Animator;
56
+
57
+ /** Pooled marker nodes — one per data point, in data order. Polymorphic so
58
+ * the markerShape option can switch between dot/square/cross/etc. */
59
+ private markers: Marker[] = [];
60
+ private markerShape: MarkerShape = 'circle';
61
+ /** Pooled label Text nodes (only populated when labelsOpts.enabled is true). */
62
+ private labelTexts: Text[] = [];
63
+ /** Per-marker animation state, parallel to this.markers. */
64
+ private points: PointState[] = [];
65
+
66
+ private jitter: number = 0;
67
+ /**
68
+ * Stable per-index jitter offsets so points don't jump laterally when data
69
+ * updates. Grown on demand; shrunk by truncation to preserve existing offsets.
70
+ */
71
+ private jitterOffsets: number[] = [];
72
+
73
+ constructor(options: ScatterSeriesOptions) {
74
+ this.xKey = options.xKey;
75
+ this.yKey = options.yKey;
76
+ this.sizeKey = options.sizeKey;
77
+ this.jitter = options.jitter ?? 0;
78
+ if (options.fill) this.fill = options.fill;
79
+ if (options.stroke) this.stroke = options.stroke;
80
+ if (options.strokeWidth !== undefined) this.strokeWidth = options.strokeWidth;
81
+ if (options.marker?.size !== undefined) this.markerSize = options.marker.size;
82
+ this.labelsOpts = options.labels;
83
+ this.animationOpts = options.animation;
84
+ this.shadowOpts = options.shadow;
85
+ if (options.markerShape) this.markerShape = options.markerShape;
86
+ }
87
+
88
+ getGroup() {
89
+ return this.group;
90
+ }
91
+
92
+ update(
93
+ data: readonly any[],
94
+ xScale: Scale<any, number>,
95
+ yScale: Scale<number, number>,
96
+ seriesRect: { x: number; y: number; width: number; height: number },
97
+ animate: boolean = true,
98
+ options?: ScatterSeriesOptions
99
+ ) {
100
+ if (options) {
101
+ this.xKey = options.xKey;
102
+ this.yKey = options.yKey;
103
+ if (options.sizeKey !== undefined) this.sizeKey = options.sizeKey;
104
+ if (options.fill) this.fill = options.fill;
105
+ if (options.stroke) this.stroke = options.stroke;
106
+ if (options.strokeWidth !== undefined) this.strokeWidth = options.strokeWidth;
107
+ if (options.marker?.size !== undefined) this.markerSize = options.marker.size;
108
+ if (options.jitter !== undefined) this.jitter = options.jitter;
109
+ if (options.labels) this.labelsOpts = options.labels;
110
+ if (options.animation) this.animationOpts = options.animation;
111
+ if (options.shadow !== undefined) this.shadowOpts = options.shadow;
112
+ if (options.markerShape) this.markerShape = options.markerShape;
113
+ }
114
+
115
+ // Stop any in-flight animation before reading current positions so the
116
+ // start values we capture reflect where each point actually is on screen.
117
+ if (this.animator) this.animator.stop();
118
+
119
+ if (data.length === 0) {
120
+ for (const m of this.markers) this.group.remove(m);
121
+ for (const l of this.labelTexts) this.group.remove(l);
122
+ this.markers = [];
123
+ this.labelTexts = [];
124
+ this.points = [];
125
+ return;
126
+ }
127
+
128
+ // ── Jitter offsets: grow if needed, shrink by truncation ─────────────────
129
+ while (this.jitterOffsets.length < data.length) {
130
+ this.jitterOffsets.push((Math.random() - 0.5) * 2);
131
+ }
132
+ if (this.jitterOffsets.length > data.length) {
133
+ this.jitterOffsets.length = data.length;
134
+ }
135
+
136
+ // ── Size scale for bubble charts ─────────────────────────────────────────
137
+ let sizeScale = (_val: number) => this.markerSize / 2;
138
+ if (this.sizeKey) {
139
+ const sizes = data.map(d => d[this.sizeKey!] || 0);
140
+ const maxSize = Math.max(...sizes);
141
+ sizeScale = (val: number) => (val / (maxSize || 1)) * this.markerSize;
142
+ }
143
+
144
+ const animType = this.animationOpts?.type || 'pop';
145
+ const isInitial = this.points.length === 0;
146
+ const newPoints: PointState[] = [];
147
+
148
+ // ── Diff pass: update existing markers, create new ones ──────────────────
149
+ data.forEach((datum, i) => {
150
+ const xVal = datum[this.xKey];
151
+ const yVal = datum[this.yKey];
152
+ const sVal = this.sizeKey ? datum[this.sizeKey] : 0;
153
+
154
+ // Target pixel position
155
+ let tx: number;
156
+ if ('getBandwidth' in xScale) {
157
+ const bw = safeBandwidth(xScale);
158
+ const joff = this.jitterOffsets[i] * (bw * this.jitter / 2);
159
+ tx = (xScale as BandScale).convert(xVal) + bw / 2 + joff;
160
+ } else {
161
+ tx = xScale.convert(xVal);
162
+ }
163
+ const ty = yScale.convert(yVal);
164
+ const tr = sizeScale(sVal);
165
+
166
+ let marker: Marker;
167
+ let sx: number, sy: number, sr: number, so: number;
168
+
169
+ if (i < this.markers.length) {
170
+ // ── Reuse existing marker ────────────────────────────────────────────
171
+ // Capture current rendered position as the animation start so that a
172
+ // mid-flight transition begins smoothly from where the dot actually is.
173
+ marker = this.markers[i];
174
+ sx = marker.centerX;
175
+ sy = marker.centerY;
176
+ sr = marker.radius;
177
+ so = marker.opacity;
178
+
179
+ // Update non-animated style properties
180
+ marker.fill = this.fill;
181
+ marker.stroke = this.stroke;
182
+ marker.strokeWidth = this.strokeWidth;
183
+ marker.shape = this.markerShape;
184
+ marker.datum = datum;
185
+ marker.seriesId = this.yKey;
186
+ marker.shadow = this.shadowOpts ?? null;
187
+ } else {
188
+ // ── Create and pool a new marker (enter transition) ──────────────────
189
+ marker = new Marker();
190
+ marker.fill = this.fill;
191
+ marker.stroke = this.stroke;
192
+ marker.strokeWidth = this.strokeWidth;
193
+ marker.shape = this.markerShape;
194
+ marker.datum = datum;
195
+ marker.seriesId = this.yKey;
196
+ marker.shadow = this.shadowOpts ?? null;
197
+ this.markers.push(marker);
198
+ this.group.add(marker);
199
+
200
+ // Entry start state — new points animate in from their entry position
201
+ if (isInitial && animType === 'grow') {
202
+ sx = tx; sy = seriesRect.height; sr = tr; so = 1;
203
+ } else if (isInitial && animType === 'fade') {
204
+ sx = tx; sy = ty; sr = tr; so = 0;
205
+ } else if (isInitial && animType === 'slide') {
206
+ sx = tx; sy = seriesRect.height + 20; sr = tr; so = 1;
207
+ } else {
208
+ // 'pop' (default) — and for new points added to an existing series
209
+ sx = tx; sy = ty; sr = 0; so = 0;
210
+ }
211
+
212
+ marker.centerX = sx;
213
+ marker.centerY = sy;
214
+ marker.radius = sr;
215
+ marker.opacity = so;
216
+ }
217
+
218
+ newPoints.push({
219
+ startX: sx, targetX: tx,
220
+ startY: sy, targetY: ty,
221
+ startRadius: sr, targetRadius: tr,
222
+ startOpacity: so, targetOpacity: 1,
223
+ });
224
+ });
225
+
226
+ // ── Remove excess markers from the pool ──────────────────────────────────
227
+ if (this.markers.length > data.length) {
228
+ for (let i = data.length; i < this.markers.length; i++) {
229
+ this.group.remove(this.markers[i]);
230
+ }
231
+ this.markers.length = data.length;
232
+ }
233
+
234
+ // ── Sync label pool (parallel logic to markers) ───────────────────────────
235
+ const labelsEnabled = this.labelsOpts?.enabled ?? false;
236
+
237
+ if (labelsEnabled) {
238
+ // Grow label pool
239
+ while (this.labelTexts.length < data.length) {
240
+ const lbl = new Text();
241
+ lbl.textAlign = 'center';
242
+ lbl.textBaseline = 'bottom';
243
+ lbl.x = 0;
244
+ lbl.y = 0;
245
+ this.labelTexts.push(lbl);
246
+ this.group.add(lbl);
247
+ }
248
+ // Shrink label pool
249
+ if (this.labelTexts.length > data.length) {
250
+ for (let i = data.length; i < this.labelTexts.length; i++) {
251
+ this.group.remove(this.labelTexts[i]);
252
+ }
253
+ this.labelTexts.length = data.length;
254
+ }
255
+ // Apply label style (may have changed with options update)
256
+ this.labelTexts.forEach((lbl, i) => {
257
+ const yVal = data[i][this.yKey];
258
+ lbl.text = formatAxisValue(yVal);
259
+ lbl.fontSize = this.labelsOpts?.fontSize ?? 10;
260
+ lbl.fontFamily = this.labelsOpts?.fontFamily ?? 'sans-serif';
261
+ lbl.fontWeight = this.labelsOpts?.fontWeight ?? '600';
262
+ lbl.rotation = ((this.labelsOpts?.rotation ?? 0) * Math.PI) / 180;
263
+ lbl.fill = this.labelsOpts?.fill ?? this.fill;
264
+ });
265
+ } else {
266
+ // Labels disabled — clear the pool if it had items
267
+ if (this.labelTexts.length > 0) {
268
+ for (const l of this.labelTexts) this.group.remove(l);
269
+ this.labelTexts = [];
270
+ }
271
+ }
272
+
273
+ this.points = newPoints;
274
+
275
+ // ── Animate or snap to target ─────────────────────────────────────────────
276
+ const applyProgress = (p: number) => {
277
+ this.points.forEach((pt, i) => {
278
+ const marker = this.markers[i];
279
+ if (!marker) return;
280
+ const cx = pt.startX + (pt.targetX - pt.startX) * p;
281
+ const cy = pt.startY + (pt.targetY - pt.startY) * p;
282
+ const cr = pt.startRadius + (pt.targetRadius - pt.startRadius) * p;
283
+ marker.centerX = cx;
284
+ marker.centerY = cy;
285
+ marker.radius = cr;
286
+ marker.opacity = pt.startOpacity + (pt.targetOpacity - pt.startOpacity) * p;
287
+
288
+ if (labelsEnabled && this.labelTexts[i]) {
289
+ this.labelTexts[i].translation = { x: cx, y: cy - (cr + 5) };
290
+ }
291
+ });
292
+ };
293
+
294
+ if (animate && this.animationOpts?.enabled !== false) {
295
+ const duration = this.animationOpts?.duration ?? 600;
296
+ const easing = Animator.getEasing(
297
+ this.animationOpts?.easing ?? (animType === 'pop' ? 'back' : 'easeOut')
298
+ );
299
+ this.animator = new Animator({ duration, easing, onUpdate: applyProgress });
300
+ this.animator.start();
301
+ } else {
302
+ applyProgress(1);
303
+ }
304
+ }
305
+ }
@@ -0,0 +1,22 @@
1
+ // Radiant Charts — Tooltip React Context
2
+ // Provides the TooltipStore to the React component tree.
3
+
4
+ import { createContext, useContext } from 'react';
5
+ import { TooltipStore } from './TooltipStore';
6
+
7
+ export const TooltipStoreContext = createContext<TooltipStore | null>(null);
8
+
9
+ /**
10
+ * Internal hook to read the TooltipStore from context.
11
+ * Throws if used outside of a TooltipStoreContext provider.
12
+ */
13
+ export function useTooltipStore(): TooltipStore {
14
+ const store = useContext(TooltipStoreContext);
15
+ if (!store) {
16
+ throw new Error(
17
+ 'useTooltipStore must be used within a RadiantChart or Chart component. ' +
18
+ 'Make sure your component is wrapped in a chart provider.'
19
+ );
20
+ }
21
+ return store;
22
+ }
@@ -0,0 +1,169 @@
1
+ // Radiant Charts — Tooltip State Store
2
+ // Framework-agnostic pub/sub store consumable by React (useSyncExternalStore) and the canvas engine.
3
+
4
+ import { TooltipState, TooltipOptions, TooltipDatumEntry, DEFAULT_TOOLTIP_STATE } from './types';
5
+
6
+ type Listener = () => void;
7
+
8
+ export class TooltipStore {
9
+ private _state: TooltipState = { ...DEFAULT_TOOLTIP_STATE };
10
+ private _listeners = new Set<Listener>();
11
+ private _options: TooltipOptions = {};
12
+ private _showTimer: ReturnType<typeof setTimeout> | null = null;
13
+ private _hideTimer: ReturnType<typeof setTimeout> | null = null;
14
+ private _pendingActivation: (() => void) | null = null;
15
+
16
+ // ── Public API ──────────────────────────────────────────────────────────────
17
+
18
+ getState(): TooltipState {
19
+ return this._state;
20
+ }
21
+
22
+ getOptions(): TooltipOptions {
23
+ return this._options;
24
+ }
25
+
26
+ setOptions(options: TooltipOptions) {
27
+ this._options = options;
28
+ }
29
+
30
+ subscribe(listener: Listener): () => void {
31
+ this._listeners.add(listener);
32
+ return () => { this._listeners.delete(listener); };
33
+ }
34
+
35
+ /**
36
+ * Activate the tooltip with the given entries and position.
37
+ * Respects delay.show if configured.
38
+ */
39
+ activate(
40
+ entries: TooltipDatumEntry[],
41
+ pointer: { x: number; y: number },
42
+ anchor: { x: number; y: number },
43
+ local: { x: number; y: number },
44
+ sharedLabel: string | null,
45
+ ) {
46
+ if (this._options.enabled === false) return;
47
+
48
+ this._clearHideTimer();
49
+
50
+ const doActivate = () => {
51
+ this._setState({
52
+ active: true,
53
+ pointerX: pointer.x,
54
+ pointerY: pointer.y,
55
+ anchorX: anchor.x,
56
+ anchorY: anchor.y,
57
+ localX: local.x,
58
+ localY: local.y,
59
+ entries,
60
+ sharedLabel,
61
+ });
62
+ };
63
+
64
+ const showDelay = this._options.delay?.show ?? 0;
65
+ if (showDelay > 0) {
66
+ this._clearShowTimer();
67
+ this._pendingActivation = doActivate;
68
+ this._showTimer = setTimeout(() => {
69
+ this._pendingActivation?.();
70
+ this._pendingActivation = null;
71
+ this._showTimer = null;
72
+ }, showDelay);
73
+ } else {
74
+ doActivate();
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Deactivate the tooltip. Respects delay.hide if configured.
80
+ */
81
+ deactivate() {
82
+ this._clearShowTimer();
83
+ this._pendingActivation = null;
84
+
85
+ const hideDelay = this._options.delay?.hide ?? 0;
86
+ if (hideDelay > 0 && this._state.active) {
87
+ this._hideTimer = setTimeout(() => {
88
+ this._doDeactivate();
89
+ this._hideTimer = null;
90
+ }, hideDelay);
91
+ } else {
92
+ this._doDeactivate();
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Update only the pointer position (for follow-pointer mode).
98
+ */
99
+ updatePointer(x: number, y: number) {
100
+ if (!this._state.active) return;
101
+ this._setState({
102
+ ...this._state,
103
+ pointerX: x,
104
+ pointerY: y,
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Notify subscribers that the chart has resized.
110
+ * The portal will recompute its position on next paint.
111
+ */
112
+ notifyResize() {
113
+ if (this._state.active) {
114
+ this._notify();
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Force-hide the tooltip regardless of state.
120
+ */
121
+ hide() {
122
+ this._clearShowTimer();
123
+ this._clearHideTimer();
124
+ this._pendingActivation = null;
125
+ this._doDeactivate(true);
126
+ }
127
+
128
+ /**
129
+ * Clean up timers and listeners.
130
+ */
131
+ destroy() {
132
+ this.hide();
133
+ this._listeners.clear();
134
+ }
135
+
136
+ // ── Private ─────────────────────────────────────────────────────────────────
137
+
138
+ private _setState(next: TooltipState) {
139
+ this._state = next;
140
+ this._notify();
141
+ }
142
+
143
+ private _notify() {
144
+ for (const listener of this._listeners) {
145
+ listener();
146
+ }
147
+ }
148
+
149
+ private _doDeactivate(force = false) {
150
+ if (!this._state.active && !force) return;
151
+ this._setState({
152
+ ...DEFAULT_TOOLTIP_STATE,
153
+ });
154
+ }
155
+
156
+ private _clearShowTimer() {
157
+ if (this._showTimer !== null) {
158
+ clearTimeout(this._showTimer);
159
+ this._showTimer = null;
160
+ }
161
+ }
162
+
163
+ private _clearHideTimer() {
164
+ if (this._hideTimer !== null) {
165
+ clearTimeout(this._hideTimer);
166
+ this._hideTimer = null;
167
+ }
168
+ }
169
+ }
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { TooltipStore } from '../TooltipStore';
3
+ import { TooltipDatumEntry } from '../types';
4
+
5
+ function makeEntry(overrides?: Partial<TooltipDatumEntry>): TooltipDatumEntry {
6
+ return {
7
+ seriesIndex: 0,
8
+ seriesId: 's0',
9
+ seriesType: 'line',
10
+ title: 'Series 0',
11
+ color: '#6366f1',
12
+ datum: { x: 'Jan', y: 42 },
13
+ dataIndex: 0,
14
+ xValue: 'Jan',
15
+ yValue: 42,
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ const POINTER = { x: 100, y: 200 };
21
+ const ANCHOR = { x: 110, y: 210 };
22
+ const LOCAL = { x: 50, y: 60 };
23
+
24
+ describe('TooltipStore', () => {
25
+ let store: TooltipStore;
26
+
27
+ beforeEach(() => {
28
+ store = new TooltipStore();
29
+ vi.useFakeTimers();
30
+ });
31
+
32
+ afterEach(() => {
33
+ store.destroy();
34
+ vi.useRealTimers();
35
+ });
36
+
37
+ // ── Basic activate/deactivate ──────────────────────────
38
+
39
+ it('starts inactive', () => {
40
+ const s = store.getState();
41
+ expect(s.active).toBe(false);
42
+ expect(s.entries).toEqual([]);
43
+ });
44
+
45
+ it('activates with entries and positions', () => {
46
+ const entry = makeEntry();
47
+ store.activate([entry], POINTER, ANCHOR, LOCAL, 'Jan');
48
+ const s = store.getState();
49
+ expect(s.active).toBe(true);
50
+ expect(s.entries).toHaveLength(1);
51
+ expect(s.entries[0].yValue).toBe(42);
52
+ expect(s.pointerX).toBe(100);
53
+ expect(s.anchorX).toBe(110);
54
+ expect(s.localX).toBe(50);
55
+ expect(s.sharedLabel).toBe('Jan');
56
+ });
57
+
58
+ it('deactivates', () => {
59
+ store.activate([makeEntry()], POINTER, ANCHOR, LOCAL, null);
60
+ store.deactivate();
61
+ expect(store.getState().active).toBe(false);
62
+ expect(store.getState().entries).toEqual([]);
63
+ });
64
+
65
+ it('does not activate when enabled=false', () => {
66
+ store.setOptions({ enabled: false });
67
+ store.activate([makeEntry()], POINTER, ANCHOR, LOCAL, null);
68
+ expect(store.getState().active).toBe(false);
69
+ });
70
+
71
+ // ── Subscribe / Notify ─────────────────────────────────
72
+
73
+ it('notifies subscribers on activate', () => {
74
+ const listener = vi.fn();
75
+ store.subscribe(listener);
76
+ store.activate([makeEntry()], POINTER, ANCHOR, LOCAL, null);
77
+ expect(listener).toHaveBeenCalledTimes(1);
78
+ });
79
+
80
+ it('unsubscribe stops notifications', () => {
81
+ const listener = vi.fn();
82
+ const unsub = store.subscribe(listener);
83
+ unsub();
84
+ store.activate([makeEntry()], POINTER, ANCHOR, LOCAL, null);
85
+ expect(listener).toHaveBeenCalledTimes(0);
86
+ });
87
+
88
+ // ── Delay ──────────────────────────────────────────────
89
+
90
+ it('show delay defers activation', () => {
91
+ store.setOptions({ delay: { show: 200 } });
92
+ store.activate([makeEntry()], POINTER, ANCHOR, LOCAL, null);
93
+ expect(store.getState().active).toBe(false);
94
+
95
+ vi.advanceTimersByTime(200);
96
+ expect(store.getState().active).toBe(true);
97
+ });
98
+
99
+ it('hide delay defers deactivation', () => {
100
+ store.setOptions({ delay: { hide: 150 } });
101
+ store.activate([makeEntry()], POINTER, ANCHOR, LOCAL, null);
102
+ store.deactivate();
103
+ expect(store.getState().active).toBe(true);
104
+
105
+ vi.advanceTimersByTime(150);
106
+ expect(store.getState().active).toBe(false);
107
+ });
108
+
109
+ it('activating during show delay resets the timer', () => {
110
+ store.setOptions({ delay: { show: 200 } });
111
+ store.activate([makeEntry({ yValue: 1 })], POINTER, ANCHOR, LOCAL, null);
112
+ vi.advanceTimersByTime(100);
113
+ expect(store.getState().active).toBe(false);
114
+
115
+ store.activate([makeEntry({ yValue: 2 })], POINTER, ANCHOR, LOCAL, null);
116
+ vi.advanceTimersByTime(100);
117
+ expect(store.getState().active).toBe(false);
118
+
119
+ vi.advanceTimersByTime(100);
120
+ expect(store.getState().active).toBe(true);
121
+ });
122
+
123
+ // ── updatePointer ──────────────────────────────────────
124
+
125
+ it('updatePointer changes pointerX/Y when active', () => {
126
+ store.activate([makeEntry()], POINTER, ANCHOR, LOCAL, null);
127
+ store.updatePointer(300, 400);
128
+ expect(store.getState().pointerX).toBe(300);
129
+ expect(store.getState().pointerY).toBe(400);
130
+ });
131
+
132
+ it('updatePointer is no-op when inactive', () => {
133
+ const listener = vi.fn();
134
+ store.subscribe(listener);
135
+ store.updatePointer(300, 400);
136
+ expect(listener).not.toHaveBeenCalled();
137
+ });
138
+
139
+ // ── notifyResize ───────────────────────────────────────
140
+
141
+ it('notifyResize notifies when active', () => {
142
+ store.activate([makeEntry()], POINTER, ANCHOR, LOCAL, null);
143
+ const listener = vi.fn();
144
+ store.subscribe(listener);
145
+ store.notifyResize();
146
+ expect(listener).toHaveBeenCalledTimes(1);
147
+ });
148
+
149
+ it('notifyResize is silent when inactive', () => {
150
+ const listener = vi.fn();
151
+ store.subscribe(listener);
152
+ store.notifyResize();
153
+ expect(listener).not.toHaveBeenCalled();
154
+ });
155
+
156
+ // ── hide (force) ───────────────────────────────────────
157
+
158
+ it('hide force-deactivates', () => {
159
+ store.activate([makeEntry()], POINTER, ANCHOR, LOCAL, null);
160
+ store.hide();
161
+ expect(store.getState().active).toBe(false);
162
+ });
163
+
164
+ // ── destroy ────────────────────────────────────────────
165
+
166
+ it('destroy clears listeners and timers', () => {
167
+ const listener = vi.fn();
168
+ store.subscribe(listener);
169
+ store.setOptions({ delay: { show: 100 } });
170
+ store.activate([makeEntry()], POINTER, ANCHOR, LOCAL, null);
171
+ store.destroy();
172
+
173
+ vi.advanceTimersByTime(200);
174
+ expect(listener).not.toHaveBeenCalled();
175
+ });
176
+ });