Skip to content

Commit 301109b

Browse files
feat: add auditory directional guidance towards svg element with mouse (#587)
* feat: guidance * chore: address feedback * chore: address follow-up review feedback - Avoid double findNearestPoint per pointer event by merging moveToPoint and getTouchGuidance into moveToPointAndGetPointerGuidance - Move volume guard before Web Audio node creation in playPointerGuidanceBeep to prevent orphan node allocation - Rename TouchGuidance -> PointerGuidance for consistency (the feature serves mouse and touch input) - Document BarTrace.findNearestPoint hit-test asymmetry vs other traces * chore: address PR review round 3 - Fix click hover mode regression: split into pointermove (guidance) and click (navigate-only) listeners so clicks no longer emit guidance beeps; added executeNavigateOnly on PointerGuidanceCommand - Remove now-dead moveToPoint method from Trace interface, AbstractPlot, AbstractTrace, Figure, and Subplot (no remaining callers since the pointer flow uses moveToPointAndGetPointerGuidance) - Dedupe clamp/interpolate: added to MathUtil, used in audio.ts and pointerGuidance.ts - Document why PointerGuidanceCommand is instantiated directly rather than through CommandFactory - Reword the "inverted pan" comment in pointerGuidance.ts to describe intent ("pan toward the curve") rather than implementation - Add unit tests for PointerGuidanceCommand.execute and executeNavigateOnly covering with/without event and coordinates Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: address PR review round 4 - Rename PointerGuidanceState fields to cursorVerticalPosition / cursorHorizontalPosition; the new names anchor the reference frame to the cursor so call sites read unambiguously - Add PointerGuidanceCommand.reset() and route pointerleave + hover-mode changes through it instead of execute() with no arg - Restore full parameter list on Box/ViolinBox moveToNearest overrides so the override matches the base contract at a glance - Document AudioContext.currentTime behaviour when the context is suspended (before first user gesture) - Add unit tests for AudioService.playPointerGuidance covering AudioMode.OFF skip, on-curve bypass, off-curve playback, throttle window, throttle release after time advance, and reset on null guidance Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: address PR review round 5 - Decouple throttle reset from AudioMode in playPointerGuidance: null / on-curve guidance still resets nextPointerGuidanceBeepAt, but AudioMode.OFF no longer does, so re-enabling audio mid-hover does not fire an immediate beep - Add Scope.TRACE guard to PointerGuidanceCommand so guidance does not fire while modals or other navigation scopes are active; pointer-leave reset still runs regardless of scope to keep throttle state clean - Document the 2x multiplier on the beep cleanup timeout (lets the exponential ramp tail finish before disconnecting) - Strengthen PointerGuidanceState docs to make the cursor-relative frame explicit and harder to invert at call sites - Add contract test for the moveToNearest hook used by BoxTrace and ViolinBoxTrace, plus a new AudioService test verifying that AudioMode.OFF preserves the throttle, plus scope-guard tests on the command Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: address review feedback (typing, contracts, comments) - Tighten executeNavigateOnly parameter to PointerEvent | MouseEvent so the call site signals the supported event shapes explicitly - Make moveToNearest onCurve parameter non-optional and tighten NearestPoint to non-null so subclass overrides must accept the contract instead of silently recomputing or ignoring it - Drop unused jest import + jest.fn() linter workaround in moveToNearestOverride test - Document tie-break behavior at the exact center for cursor position relations - Clarify that pointer guidance is intentionally mode-agnostic across non-OFF audio modes * chore: address PR review round 5 - Add StereoPannerNode feature detection in playPointerGuidanceBeep so guidance degrades to mono on Safari < 14.5 instead of throwing - Document the intentional asymmetric pointerleave listener registration (only added for pointermove mode, not click mode) - Add scatter-style "side effect before delegating" contract test to lock down ScatterTrace.moveToNearest mode-switch semantics - Drop redundant public modifier on PointerGuidanceCommand constructor - Replace `globalThis as any` cast in audio.pointerGuidance test with a typed accessor - Standardize on it() within describe() for new pointer-guidance tests to match the lint rule * chore: address PR review round 6 - Stop zeroing the guidance throttle on out-of-range pointer events (resolvePointerGuidanceBeep returning null), which would otherwise unblock a beep on every crossing of maxDistancePx - Note in PointerGuidanceCommand that isInTraceScope duplicates CommandExecutor.isValidForScope and must stay in sync - Trim the over-long class/method JSDoc in PointerGuidanceCommand - Add unit tests for MathUtil.clamp and MathUtil.interpolate - Add coverage for the out-of-range throttle path - Clarify the volume=100 settings mock in audio.pointerGuidance * chore: address PR review round 7 — blockers Resolves the two review items flagged as merge-blockers. #3 Naming inversion (cursor frame → curve frame) The field formerly named `cursorVerticalPosition` described where the *cursor* sat, but the audio mapping inverts the frame: cursor below the curve plays a *high* pitch because the point is above. A contributor reading `cursorVerticalPosition: 'below'` would naturally expect a low sound — the opposite of intended. Renamed to describe the curve point's position relative to the cursor, which lines up 1:1 with the audio output: - `curveVertical: 'above'` → high pitch (point is up there) - `curveVertical: 'below'` → low pitch (point is down there) - `curveHorizontal: 'left'` → pan left (point is to the left) - `curveHorizontal: 'right'` → pan right (point is to the right) While here, also converted `PointerGuidanceState` to a discriminated union — `distancePx` and the direction fields no longer exist on the on-curve branch, so the previous "callers should check onCurve before using this" JSDoc smell is now enforced by the type checker. #2 Histogram / Segmented / Smooth `findNearestPoint` return type Verified: none of `Histogram`, `SegmentedTrace`, `SmoothTrace`, or `SmoothTraceSvgXY` override `findNearestPoint`. They inherit from `AbstractBarPlot` / `LineTrace`, both of which return the updated `NearestPoint` shape with `centerX` / `centerY`. Build passes as independent confirmation. * chore: address PR review round 8 #1 Throttle consumed at volume=0 (blocker) playPointerGuidance advanced nextPointerGuidanceBeepAt unconditionally, but playPointerGuidanceBeep returned early when volume*POINTER_GUIDANCE_VOLUME was 0 — the throttle slot was burnt without a beep firing, so turning volume back up left the user with one silent throttle window. Changed playPointerGuidanceBeep to return a boolean and only bump the throttle when a beep actually emitted. New test simulates volume=0 → 100 mid-flow to lock the contract down. #2 Scope mismatch leaves stale throttle armed execute() now calls reset() on out-of-scope events too (not just on missing event / missing coordinates), so a hover that started in Scope.TRACE and continued through a scope change does not delay the first beep after the user returns to trace scope. Updated the relevant unit test. #4 ARIA announcement on hover (verified, no change) The hover-driven moveToIndex → observer notification flow predates this PR — old AbstractTrace.moveToPoint already called moveToIndex when the cursor was in bounds, with identical observer fanout. The refactor into moveToNearest preserves the behavior 1:1, so screen-reader semantics are unchanged. #6 / #7 polish Trimmed restate-the-type JSDoc on PointerGuidanceBeep / PointerGuidanceConfig and noted in resolvePointerGuidanceBeep that "above" follows screen-space y. * chore: clarify pointer-guidance scope-check rationale PR review round 9. Action item: extract the duplicated scope-check or document the link to its peer. There is no peer. The prior JSDoc cited a non-existent `CommandExecutor.isValidForScope`; in this codebase keyboard commands rely on hotkeys-js scope routing (only the active scope receives the event, so commands don't self-validate), while DOM pointer listeners receive every event regardless of scope. PointerGuidanceCommand is the only path that needs a self-check, so there is nothing to keep in sync. Rewrote the comment to describe the actual architecture instead of inventing a sync hazard. Action item: confirm type-check passes for all trace types. `npm run build` (which runs vite's TS check + vite-plugin-dts rollup across every trace) is green. `Histogram` and `SegmentedTrace` inherit `findNearestPoint` from `AbstractBarPlot`; `SmoothTrace` / `SmoothTraceSvgXY` inherit from `LineTrace` — no overrides in those files, so the updated `NearestPoint` shape is picked up automatically. Type formatting (leading `|` style): kept as-is. The convention matches `FigureState`, `SubplotState`, and `TraceState` defined immediately below in the same file. * test(pointer-guidance): import jest globals explicitly Future-proofs the test against a Jest config switch to `injectGlobals: false`, and matches every other PR test file which already imports describe/expect/it explicitly. * chore: address PR review round 9 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: address PR review round 10 - audio.ts (playPointerGuidanceBeep): scale exponential gain ramp target with guidanceVolume so the fade-out stays below the start value at any user volume (fixed 0.001 inverted the ramp at low volumes) - audio.ts (playPointerGuidanceBeep): early-return when AudioContext state is not 'running' to avoid scheduling start(0)/stop(0.06) on a suspended context, which would fire an unexpected beep on resume - audio.ts: use MathUtil.clamp directly at the new guidance pan callsite - audio.ts: rewrite pointer-guidance JSDoc to use curve-relative framing matching PointerGuidanceState.curveVertical/curveHorizontal semantics - scatter.ts (moveToNearest): guard NavMode.COL mutation behind onCurve so hover guidance probes no longer override a ROW mode set by keyboard - scatter.ts: rewrite stale JSDoc that referenced the deleted moveToPoint * chore: address PR review round 11 - audio.ts: stop resetting guidance throttle on onCurve. The isPointInBounds boundary oscillates on/off at frame rate; resetting on each on-curve frame let every following off-curve frame bypass the rate limit, producing a 60 Hz buzz instead of discrete beeps. Only null guidance (pointer left the trace) clears the throttle now. - state.ts / abstract.ts / pointerGuidance.ts: add 'center' variant to curveHorizontal for the x === centerX tie. Resolver returns pan = 0 for 'center' instead of falling through to 'left' and panning audio away from a curve the cursor is already horizontally aligned with (heatmap-center case). - audio.ts: inline MathUtil.clamp/interpolate at all 7 call sites and drop the private Range-object wrappers + Range interface. - audio.ts / command/pointerGuidance.ts: drop JSDoc that restated what the code does (the 'Behavior:' bullet list on playPointerGuidance and the mechanical summary on PointerGuidanceCommand.execute) per CLAUDE.md guidance against what-not-why comments. - audio.ts / pointerGuidance.ts: drop the redundant DEFAULT_POINTER_GUIDANCE_CONFIG argument at the only production resolver call site; the default parameter already covers it. - abstract.ts: reword 'puts the point below the cursor' to 'puts the curve center below the cursor' to remove ambiguity between the curve point and the cursor itself. - Tests: cover the throttle non-reset on onCurve, the curveHorizontal 'center' emission from abstract.ts, and the pan = 0 resolver result. * chore: address PR review round 12 - pointerGuidance.ts: drop the redundant MathUtil.clamp in resolvePointerGuidanceBeep. The early-return on line 38 guarantees distancePx <= maxDistancePx and Math.hypot is non-negative, so the ratio is already in [0, 1]. - bar.ts / box.ts / candlestick.ts / heatmap.ts / line.ts / scatter.ts / violin.ts / violinBox.ts: use the named NearestPoint interface for findNearestPoint return types. The inline structural types were identical today but would silently drift if the contract grows a required field; the named alias surfaces such changes at the override signature instead of only at the return statement. - audio.ts: cast stopAudioNode's argument to AudioScheduledSourceNode (the common parent of OscillatorNode / AudioBufferSourceNode / ConstantSourceNode) rather than OscillatorNode. Today only oscillators reach this path, but the narrower cast would mislead a future contributor adding a sample-based palette entry. --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 7ce2b25 commit 301109b

23 files changed

Lines changed: 1446 additions & 163 deletions

src/command/factory.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ import {
9999

100100
/**
101101
* Factory for creating command instances based on key input.
102+
*
103+
* Note: `PointerGuidanceCommand` is intentionally not produced here. It is
104+
* wired directly by `Mousebindingservice` because its contract carries raw
105+
* pointer coordinates (`clientX`/`clientY`) per event, which doesn't fit the
106+
* keyword-based `Keys` lookup this factory uses.
102107
*/
103108
export class CommandFactory {
104109
private readonly context: Context;

src/command/pointerGuidance.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { Context } from '@model/context';
2+
import type { AudioService } from '@service/audio';
3+
import type { Command } from './command';
4+
import { Scope } from '@type/event';
5+
6+
/**
7+
* Routes pointer/touch movement into guidance sonification.
8+
*
9+
* Constructed directly by `Mousebindingservice` rather than through
10+
* `CommandFactory` because pointer events carry coordinates the
11+
* keyboard-oriented factory contract does not accept.
12+
*
13+
* Keyboard commands rely on hotkeys-js scope routing — only the active
14+
* scope receives the event, so commands don't self-validate. DOM
15+
* pointer listeners receive every event regardless of scope, so this
16+
* command must filter via {@link isInTraceScope}. The check is unique
17+
* to this code path; there is no peer implementation to keep in sync.
18+
*/
19+
export class PointerGuidanceCommand implements Command {
20+
private readonly context: Context;
21+
private readonly audioService: AudioService;
22+
23+
constructor(context: Context, audioService: AudioService) {
24+
this.context = context;
25+
this.audioService = audioService;
26+
}
27+
28+
/**
29+
* No-ops outside `Scope.TRACE` so guidance stays silent while modals or
30+
* other scopes own input.
31+
*/
32+
public execute(event?: Event): void {
33+
// Pointer-leave / missing-coordinate / out-of-scope events all need to
34+
// clear in-flight guidance: otherwise a throttle slot armed during a
35+
// prior trace-scope hover would silently delay the next beep when the
36+
// user returns to trace scope.
37+
if (!event || !this.hasClientCoordinates(event) || !this.isInTraceScope()) {
38+
this.reset();
39+
return;
40+
}
41+
42+
const { clientX, clientY } = event;
43+
const guidance = this.context.moveToPointAndGetPointerGuidance(clientX, clientY);
44+
this.audioService.playPointerGuidance(guidance);
45+
}
46+
47+
/** Clears in-flight guidance and the throttle so a fresh entry starts clean. */
48+
public reset(): void {
49+
this.audioService.playPointerGuidance(null);
50+
}
51+
52+
/**
53+
* Click-mode entry point: snaps to the nearest point without a guidance
54+
* beep, leaving the regular Observer-chain sonification to fire. The
55+
* runtime coordinate check stays despite the narrowed parameter type
56+
* because exotic event subclasses can still arrive at the listener.
57+
*/
58+
public executeNavigateOnly(event: PointerEvent | MouseEvent): void {
59+
if (!this.isInTraceScope() || !this.hasClientCoordinates(event)) {
60+
return;
61+
}
62+
this.context.moveToPointAndGetPointerGuidance(event.clientX, event.clientY);
63+
}
64+
65+
private isInTraceScope(): boolean {
66+
return this.context.scope === Scope.TRACE;
67+
}
68+
69+
private hasClientCoordinates(
70+
event: Event,
71+
): event is Event & { clientX: number; clientY: number } {
72+
const candidate = event as Partial<{ clientX: unknown; clientY: unknown }>;
73+
return typeof candidate.clientX === 'number' && typeof candidate.clientY === 'number';
74+
}
75+
}

src/model/abstract.ts

Lines changed: 91 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
BrailleState,
1111
DescriptionState,
1212
HighlightState,
13+
PointerGuidanceState,
1314
TextState,
1415
TraceState,
1516
} from '@type/state';
@@ -53,6 +54,8 @@ export interface NearestPoint {
5354
element: SVGElement;
5455
row: number;
5556
col: number;
57+
centerX: number;
58+
centerY: number;
5659
}
5760

5861
export abstract class AbstractPlot<State> implements Movable, Observable<State>, Disposable {
@@ -281,20 +284,6 @@ export abstract class AbstractPlot<State> implements Movable, Observable<State>,
281284
throw new Error('Move right function is not defined for this trace');
282285
}
283286

284-
/**
285-
* Moves the element to the specified (x, y) point.
286-
*
287-
* This base implementation is intentionally left empty. Subclasses should override
288-
* this method to provide specific logic for moving to a point, such as updating
289-
* highlight values or managing selection boxes.
290-
*
291-
* @param _x - The x-coordinate to move to.
292-
* @param _y - The y-coordinate to move to.
293-
*/
294-
public moveToPoint(_x: number, _y: number): void {
295-
// implement basic stuff, assuming something like highlightValues that holds the points and boxes
296-
}
297-
298287
/**
299288
* Returns true if this trace supports compare (lower/higher value) navigation.
300289
* Override to false for trace types that don't use compare modes (e.g., scatter, which is all we
@@ -311,6 +300,25 @@ export abstract class AbstractPlot<State> implements Movable, Observable<State>,
311300
public dataModeName(): string {
312301
return Constant.DATA_MODE;
313302
}
303+
304+
/**
305+
* Moves the active point to the (x, y) pointer location and returns
306+
* directional guidance toward the nearest data geometry.
307+
*
308+
* Combines navigation and guidance into a single call so traces compute
309+
* `findNearestPoint` only once per pointer event. Default returns null
310+
* for non-trace contexts.
311+
*
312+
* @param _x - Screen-space x position of the pointer/finger
313+
* @param _y - Screen-space y position of the pointer/finger
314+
* @returns Guidance state, or null when unavailable
315+
*/
316+
public moveToPointAndGetPointerGuidance(
317+
_x: number,
318+
_y: number,
319+
): PointerGuidanceState | null {
320+
return null;
321+
}
314322
}
315323

316324
export abstract class AbstractTrace extends AbstractPlot<TraceState> implements Trace {
@@ -795,21 +803,78 @@ export abstract class AbstractTrace extends AbstractPlot<TraceState> implements
795803
): NearestPoint | null;
796804

797805
/**
798-
* Moves to the nearest point at the specified coordinates (used for hover functionality).
799-
* @param x - The x-coordinate
800-
* @param y - The y-coordinate
806+
* Moves the active point to the pointer location and returns directional
807+
* guidance toward the nearest data geometry in a single call.
808+
*
809+
* Combining both operations avoids running `findNearestPoint` twice per
810+
* `pointermove` event — important on dense plots where the scan is the
811+
* hot path.
812+
*
813+
* @param x - Screen-space x position of the pointer/finger
814+
* @param y - Screen-space y position of the pointer/finger
815+
* @returns Guidance state relative to nearest point, or null when unavailable
801816
*/
802-
public moveToPoint(x: number, y: number): void {
817+
public override moveToPointAndGetPointerGuidance(
818+
x: number,
819+
y: number,
820+
): PointerGuidanceState | null {
803821
const nearest = this.findNearestPoint(x, y);
804-
if (nearest) {
805-
if (this.isPointInBounds(x, y, nearest)) {
806-
// don't move if we're already there
807-
if (this.row === nearest.row && this.col === nearest.col) {
808-
return;
809-
}
810-
this.moveToIndex(nearest.row, nearest.col);
811-
}
822+
if (!nearest) {
823+
return null;
824+
}
825+
826+
const onCurve = this.isPointInBounds(x, y, nearest);
827+
this.moveToNearest(x, y, nearest, onCurve);
828+
829+
if (onCurve) {
830+
return { onCurve: true };
831+
}
832+
833+
// Fields describe where the curve center sits relative to the cursor.
834+
// Screen-space: y grows downward, so a smaller y is higher on screen —
835+
// `y < centerY` puts the curve center below the cursor. The vertical
836+
// tie-break collapses to 'above'; at single-pixel precision the user
837+
// can't perceive the difference, and forcing strict inequality avoids
838+
// a third "centered" state that pitch mapping would need to handle.
839+
// Horizontally we DO distinguish ties: heatmap centers are computed
840+
// pixel integers users can hit exactly, and panning left at the moment
841+
// the cursor crosses centerX would be a misleading directional cue.
842+
return {
843+
onCurve: false,
844+
distancePx: Math.hypot(nearest.centerX - x, nearest.centerY - y),
845+
curveVertical: y < nearest.centerY ? 'below' : 'above',
846+
curveHorizontal: x === nearest.centerX
847+
? 'center'
848+
: x < nearest.centerX ? 'right' : 'left',
849+
};
850+
}
851+
852+
/**
853+
* Moves the trace to the nearest point when the pointer is within its
854+
* bounds and the trace is not already focused on that point.
855+
*
856+
* `onCurve` is intentionally non-optional: the caller has already paid
857+
* the cost of {@link isPointInBounds} to assemble guidance state, and
858+
* forcing subclasses to accept the value makes the contract explicit so a
859+
* future override cannot silently recompute (or worse, ignore) it.
860+
*
861+
* Subclasses override this to customise hover-driven navigation:
862+
* - Box / ViolinBox no-op the move while still surfacing guidance.
863+
* - Scatter switches into column navigation mode before delegating.
864+
*/
865+
protected moveToNearest(
866+
_x: number,
867+
_y: number,
868+
nearest: NearestPoint,
869+
onCurve: boolean,
870+
): void {
871+
if (!onCurve) {
872+
return;
873+
}
874+
if (this.row === nearest.row && this.col === nearest.col) {
875+
return;
812876
}
877+
this.moveToIndex(nearest.row, nearest.col);
813878
}
814879

815880
/**

src/model/bar.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ExtremaTarget } from '@type/extrema';
22
import type { BarPoint, MaidrLayer } from '@type/grammar';
33
import type { Movable } from '@type/movable';
44
import type { AudioState, BrailleState, DescriptionState, TextState } from '@type/state';
5-
import type { Dimension } from './abstract';
5+
import type { Dimension, NearestPoint } from './abstract';
66
import { Orientation } from '@type/grammar';
77
import { MathUtil } from '@util/math';
88
import { Svg } from '@util/svg';
@@ -239,15 +239,24 @@ export abstract class AbstractBarPlot<T extends BarPoint> extends AbstractTrace
239239
}
240240

241241
/**
242-
* Finds the nearest bar element at the specified coordinates.
242+
* Finds the bar element under the specified coordinates.
243+
*
244+
* Unlike line/scatter/box traces (which return the nearest center regardless
245+
* of cursor position), bar charts hit-test against each bar's bounding box
246+
* and return null when the cursor is between bars. As a consequence, pointer
247+
* guidance beeps fire only when the pointer is inside a bar — there is no
248+
* directional guidance toward the closest bar from outside. This is
249+
* intentional: bars are area marks (not points), and "nearest bar" from
250+
* an arbitrary position is rarely the user's intent.
251+
*
243252
* @param x - The x-coordinate
244253
* @param y - The y-coordinate
245254
* @returns Object containing the element and its position, or null if not found
246255
*/
247256
public findNearestPoint(
248257
x: number,
249258
y: number,
250-
): { element: SVGElement; row: number; col: number } | null {
259+
): NearestPoint | null {
251260
// we differ from the base implementation (which is to loop through centers and return one),
252261
// as sometimes the closest center is not the bar we clicked on
253262
// so instead, we just do the hard thing and loop through all highlightValues
@@ -284,7 +293,13 @@ export abstract class AbstractBarPlot<T extends BarPoint> extends AbstractTrace
284293
&& y >= bbox.y
285294
&& y <= bbox.y + bbox.height
286295
) {
287-
return { element: targetElement, row, col };
296+
return {
297+
element: targetElement,
298+
row,
299+
col,
300+
centerX: bbox.x + bbox.width / 2,
301+
centerY: bbox.y + bbox.height / 2,
302+
};
288303
}
289304
}
290305
}

src/model/box.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { BoxPoint, BoxSelector, MaidrLayer } from '@type/grammar';
22
import type { Movable } from '@type/movable';
33
import type { AudioState, BrailleState, DescriptionState, TextState } from '@type/state';
4-
import type { Dimension } from './abstract';
4+
import type { Dimension, NearestPoint } from './abstract';
55
import { BoxplotSection } from '@type/boxplotSection';
66
import { Orientation } from '@type/grammar';
77
import { Constant } from '@util/constant';
@@ -496,7 +496,7 @@ export class BoxTrace extends AbstractTrace {
496496
public findNearestPoint(
497497
x: number,
498498
y: number,
499-
): { element: SVGElement; row: number; col: number } | null {
499+
): NearestPoint | null {
500500
if (!this.highlightCenters) {
501501
return null;
502502
}
@@ -521,13 +521,25 @@ export class BoxTrace extends AbstractTrace {
521521
element: this.highlightCenters[nearestIndex].element,
522522
row: this.highlightCenters[nearestIndex].row,
523523
col: this.highlightCenters[nearestIndex].col,
524+
centerX: this.highlightCenters[nearestIndex].x,
525+
centerY: this.highlightCenters[nearestIndex].y,
524526
};
525527
}
526528

527529
/**
528-
* Moves to the nearest point at the specified coordinates (disabled for boxplots).
530+
* Hover-driven movement is disabled for boxplots, but pointer guidance
531+
* still surfaces directional cues toward the nearest box element.
532+
*
533+
* Parameters retained on the signature (rather than the zero-arg form
534+
* TypeScript permits) so the override matches the base contract at a
535+
* glance.
529536
*/
530-
public moveToPoint(_x: number, _y: number): void {
537+
protected override moveToNearest(
538+
_x: number,
539+
_y: number,
540+
_nearest: NearestPoint,
541+
_onCurve: boolean,
542+
): void {
531543
// Disabled for boxplots
532544
}
533545
}

src/model/candlestick.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Dimension } from '@model/abstract';
1+
import type { Dimension, NearestPoint } from '@model/abstract';
22
import type { ExtremaTarget } from '@type/extrema';
33
import type {
44
CandlestickPoint,
@@ -1046,7 +1046,7 @@ export class Candlestick extends AbstractTrace {
10461046
public findNearestPoint(
10471047
x: number,
10481048
y: number,
1049-
): { element: SVGElement; row: number; col: number } | null {
1049+
): NearestPoint | null {
10501050
// loop through highlightCenters to find nearest point
10511051
if (!this.highlightCenters) {
10521052
return null;
@@ -1072,6 +1072,8 @@ export class Candlestick extends AbstractTrace {
10721072
element: this.highlightCenters[nearestIndex].element,
10731073
row: this.highlightCenters[nearestIndex].row,
10741074
col: this.highlightCenters[nearestIndex].col,
1075+
centerX: this.highlightCenters[nearestIndex].x,
1076+
centerY: this.highlightCenters[nearestIndex].y,
10751077
};
10761078
}
10771079

src/model/context.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Disposable } from '@type/disposable';
22
import type { MovableDirection } from '@type/movable';
3-
import type { PlotState, SubplotSummary } from '@type/state';
3+
import type { PlotState, PointerGuidanceState, SubplotSummary } from '@type/state';
44
import type { Figure, Subplot, Trace } from './plot';
55
import { NavigationService } from '@service/navigation';
66
import { Scope } from '@type/event';
@@ -420,20 +420,15 @@ export class Context implements Disposable {
420420
}
421421

422422
/**
423-
* Moves the active plot element to the specified (x, y) point.
423+
* Moves to the nearest point at (x, y) and returns directional guidance
424+
* toward the nearest data geometry in a single call.
424425
*
425-
* @param x - The x-coordinate to move to.
426-
* @param y - The y-coordinate to move to.
427-
* @remarks
428-
* This method assumes that `this.active` is a valid object with a `moveToPoint` method.
429-
* If `this.active` is `null` or does not implement `moveToPoint`, this method will do nothing.
430-
*
431-
* Limitations:
432-
* - If `this.active` is `null` or `undefined`, the method will not perform any action.
433-
* - If `this.active` does not implement `moveToPoint`, the method will not perform any action.
426+
* @param x - Screen-space x position
427+
* @param y - Screen-space y position
428+
* @returns Guidance state, or null when unavailable for current scope
434429
*/
435-
public moveToPoint(x: number, y: number): void {
436-
this.active.moveToPoint(x, y);
430+
public moveToPointAndGetPointerGuidance(x: number, y: number): PointerGuidanceState | null {
431+
return this.active.moveToPointAndGetPointerGuidance(x, y);
437432
}
438433

439434
public stepTrace(direction: MovableDirection): void {

0 commit comments

Comments
 (0)