Skip to content

Commit f4f4533

Browse files
feat: guidance
1 parent 7388d22 commit f4f4533

6 files changed

Lines changed: 193 additions & 5 deletions

File tree

src/model/abstract.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
DescriptionState,
1212
HighlightState,
1313
TextState,
14+
TouchGuidanceState,
1415
TraceState,
1516
} from '@type/state';
1617
import type { Trace } from './plot';
@@ -298,6 +299,18 @@ export abstract class AbstractPlot<State> implements Movable, Observable<State>,
298299
public dataModeName(): string {
299300
return Constant.DATA_MODE;
300301
}
302+
303+
/**
304+
* Gets directional touch/pointer guidance near the active data geometry.
305+
* Default behavior returns null for non-trace contexts.
306+
*
307+
* @param _x - Screen-space x position of the pointer/finger
308+
* @param _y - Screen-space y position of the pointer/finger
309+
* @returns Null when guidance is unavailable
310+
*/
311+
public getTouchGuidance(_x: number, _y: number): TouchGuidanceState | null {
312+
return null;
313+
}
301314
}
302315

303316
export abstract class AbstractTrace extends AbstractPlot<TraceState> implements Trace {
@@ -760,6 +773,31 @@ export abstract class AbstractTrace extends AbstractPlot<TraceState> implements
760773
}
761774
}
762775

776+
/**
777+
* Computes directional guidance for pointer/touch exploration near curves.
778+
*
779+
* @param x - Screen-space x position
780+
* @param y - Screen-space y position
781+
* @returns Guidance state relative to nearest point, or null when unavailable
782+
*/
783+
public override getTouchGuidance(x: number, y: number): TouchGuidanceState | null {
784+
const nearest = this.findNearestPoint(x, y);
785+
if (!nearest) {
786+
return null;
787+
}
788+
789+
const bbox = nearest.element.getBoundingClientRect();
790+
const centerX = bbox.x + bbox.width / 2;
791+
const centerY = bbox.y + bbox.height / 2;
792+
793+
return {
794+
onCurve: this.isPointInBounds(x, y, nearest),
795+
distancePx: Math.hypot(centerX - x, centerY - y),
796+
verticalRelation: y < centerY ? 'above' : 'below',
797+
horizontalRelation: x < centerX ? 'left' : 'right',
798+
};
799+
}
800+
763801
/**
764802
* Checks if the specified coordinates are within bounds of the element.
765803
* @param x - The x-coordinate

src/model/context.ts

Lines changed: 12 additions & 1 deletion
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, SubplotSummary, TouchGuidanceState } from '@type/state';
44
import type { Figure, Subplot, Trace } from './plot';
55
import { NavigationService } from '@service/navigation';
66
import { Scope } from '@type/event';
@@ -200,6 +200,17 @@ export class Context implements Disposable {
200200
this.active.moveToPoint(x, y);
201201
}
202202

203+
/**
204+
* Gets directional guidance for pointer/touch exploration near the active trace.
205+
*
206+
* @param x - Screen-space x position
207+
* @param y - Screen-space y position
208+
* @returns Guidance state, or null when unavailable for current scope
209+
*/
210+
public getTouchGuidance(x: number, y: number): TouchGuidanceState | null {
211+
return this.active.getTouchGuidance(x, y);
212+
}
213+
203214
public stepTrace(direction: MovableDirection): void {
204215
if (this.plotContext.size() > 1) {
205216
const previousTrace = this.active as Trace;

src/model/plot.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ import type { ExtremaTarget } from '@type/extrema';
33
import type { Maidr, MaidrSubplot } from '@type/grammar';
44
import type { Movable, MovableDirection } from '@type/movable';
55
import type { Observable } from '@type/observable';
6-
import type { FigureState, HighlightState, SubplotState, SubplotSummary, TraceState } from '@type/state';
6+
import type {
7+
FigureState,
8+
HighlightState,
9+
SubplotState,
10+
SubplotSummary,
11+
TouchGuidanceState,
12+
TraceState,
13+
} from '@type/state';
714
import type { SubplotLayout } from '@util/subplotLayout';
815
import type { Dimension } from './abstract';
916
import { TraceType } from '@type/grammar';
@@ -587,6 +594,13 @@ export interface Trace extends Movable, Observable<TraceState>, Disposable {
587594
*/
588595
moveToPoint: (x: number, y: number) => void;
589596

597+
/**
598+
* Computes directional guidance for pointer/touch exploration near this trace.
599+
* @param x - Screen-space x position
600+
* @param y - Screen-space y position
601+
* @returns Guidance state or null if unavailable
602+
*/
603+
getTouchGuidance: (x: number, y: number) => TouchGuidanceState | null;
590604
/**
591605
* Gets extrema targets for navigation.
592606
* Optional method implemented by traces that support extrema navigation.

src/service/audio.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Disposable } from '@type/disposable';
22
import type { Observer } from '@type/observable';
3-
import type { AudioState, PlotState } from '@type/state';
3+
import type { AudioState, PlotState, TouchGuidanceState } from '@type/state';
44
import type { AudioPaletteEntry } from './audioPalette';
55
import type { NotificationService } from './notification';
66
import type { SettingsService } from './settings';
@@ -40,6 +40,15 @@ const WARNING_FREQUENCY = 180;
4040
const WARNING_DURATION = 0.2;
4141
const WARNING_SPACE = 0.1;
4242

43+
const TOUCH_GUIDANCE_MAX_DISTANCE_PX = 160;
44+
const TOUCH_GUIDANCE_MIN_INTERVAL = 0.08;
45+
const TOUCH_GUIDANCE_MAX_INTERVAL = 0.55;
46+
const TOUCH_GUIDANCE_HIGH_FREQUENCY = 1280;
47+
const TOUCH_GUIDANCE_LOW_FREQUENCY = 420;
48+
const TOUCH_GUIDANCE_BEEP_DURATION = 0.06;
49+
const TOUCH_GUIDANCE_PAN_MAGNITUDE = 0.7;
50+
const TOUCH_GUIDANCE_VOLUME = 0.35;
51+
4352
const DEFAULT_DURATION = 0.3;
4453
const DEFAULT_PALETTE_INDEX = AudioPaletteIndex.SINE_BASIC;
4554

@@ -81,6 +90,7 @@ export class AudioService implements Observer<PlotState>, Disposable {
8190
private maxFrequency: number;
8291
private readonly audioContext: AudioContext;
8392
private readonly compressor: DynamicsCompressorNode;
93+
private nextTouchGuidanceBeepAt: number;
8494

8595
/**
8696
* Creates an instance of AudioService.
@@ -117,6 +127,7 @@ export class AudioService implements Observer<PlotState>, Disposable {
117127

118128
this.audioContext = new AudioContext();
119129
this.compressor = this.initCompressor();
130+
this.nextTouchGuidanceBeepAt = 0;
120131
}
121132

122133
/**
@@ -627,6 +638,88 @@ export class AudioService implements Observer<PlotState>, Disposable {
627638
this.playWarningTone();
628639
}
629640

641+
/**
642+
* Plays directional touch/pointer guidance beeps for nearby curve exploration.
643+
*
644+
* Behavior:
645+
* - No guidance beep when on-curve (regular sonification continues to apply).
646+
* - Off-curve beeps repeat faster when closer and slower when farther.
647+
* - High pitch when pointer is below the curve, low pitch when above.
648+
* - Pan left when pointer is right of curve, pan right when left of curve.
649+
*
650+
* @param guidance - Touch guidance state from the active trace, or null to reset guidance
651+
*/
652+
public playTouchGuidance(guidance: TouchGuidanceState | null): void {
653+
if (
654+
this.mode === AudioMode.OFF
655+
|| !guidance
656+
|| guidance.onCurve
657+
|| guidance.distancePx > TOUCH_GUIDANCE_MAX_DISTANCE_PX
658+
) {
659+
this.nextTouchGuidanceBeepAt = 0;
660+
return;
661+
}
662+
663+
const now = this.audioContext.currentTime;
664+
if (now < this.nextTouchGuidanceBeepAt) {
665+
return;
666+
}
667+
668+
const distanceNorm = this.clamp(
669+
guidance.distancePx / TOUCH_GUIDANCE_MAX_DISTANCE_PX,
670+
0,
671+
1,
672+
);
673+
const interval = this.interpolate(
674+
distanceNorm,
675+
{ min: 0, max: 1 },
676+
{ min: TOUCH_GUIDANCE_MIN_INTERVAL, max: TOUCH_GUIDANCE_MAX_INTERVAL },
677+
);
678+
679+
const frequency = guidance.verticalRelation === 'below'
680+
? TOUCH_GUIDANCE_HIGH_FREQUENCY
681+
: TOUCH_GUIDANCE_LOW_FREQUENCY;
682+
const pan = guidance.horizontalRelation === 'right'
683+
? -TOUCH_GUIDANCE_PAN_MAGNITUDE
684+
: TOUCH_GUIDANCE_PAN_MAGNITUDE;
685+
686+
this.playTouchGuidanceBeep(frequency, pan, now);
687+
this.nextTouchGuidanceBeepAt = now + interval;
688+
}
689+
690+
private playTouchGuidanceBeep(
691+
frequency: number,
692+
pan: number,
693+
startTime: number,
694+
): void {
695+
const oscillator = this.audioContext.createOscillator();
696+
oscillator.type = 'sine';
697+
oscillator.frequency.value = frequency;
698+
699+
const gainNode = this.audioContext.createGain();
700+
const guidanceVolume = Math.max(this.volume * TOUCH_GUIDANCE_VOLUME, 0.05);
701+
gainNode.gain.setValueAtTime(guidanceVolume, startTime);
702+
gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + TOUCH_GUIDANCE_BEEP_DURATION);
703+
704+
const stereoPanner = this.audioContext.createStereoPanner();
705+
stereoPanner.pan.value = this.clamp(pan, -1, 1);
706+
707+
oscillator.connect(gainNode);
708+
gainNode.connect(stereoPanner);
709+
stereoPanner.connect(this.compressor);
710+
711+
oscillator.start(startTime);
712+
oscillator.stop(startTime + TOUCH_GUIDANCE_BEEP_DURATION);
713+
714+
const audioId = setTimeout(() => {
715+
oscillator.disconnect();
716+
gainNode.disconnect();
717+
stereoPanner.disconnect();
718+
this.activeAudioIds.delete(audioId);
719+
}, TOUCH_GUIDANCE_BEEP_DURATION * 1000 * 2);
720+
this.activeAudioIds.set(audioId, oscillator);
721+
}
722+
630723
private playZeroTone(panning: Panning): AudioId {
631724
const xPos = this.clamp(this.interpolate(panning.x, { min: 0, max: panning.cols - 1 }, { min: -1, max: 1 }), -1, 1);
632725
// Y-axis not used for stereo panning

src/service/keybinding.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,8 @@ export class KeybindingService {
406406
* Service for managing mouse interactions with plot elements based on hover settings.
407407
*/
408408
export class Mousebindingservice implements Observer<Settings>, Disposable {
409-
private mouseListener!: (event: MouseEvent) => void;
409+
private mouseListener!: (event: MouseEvent | PointerEvent) => void;
410+
private pointerLeaveListener!: () => void;
410411

411412
private readonly commandContext: CommandContext;
412413
private hoverMode: string = 'none';
@@ -440,11 +441,20 @@ export class Mousebindingservice implements Observer<Settings>, Disposable {
440441
public registerEvents(): void {
441442
// Create the mouse listener if it doesn't exist
442443
if (!this.mouseListener) {
443-
this.mouseListener = (event: MouseEvent) => {
444+
this.mouseListener = (event: MouseEvent | PointerEvent) => {
444445
const x = event.clientX;
445446
const y = event.clientY;
446447

447448
this.commandContext.context.moveToPoint(x, y);
449+
450+
const guidance = this.commandContext.context.getTouchGuidance(x, y);
451+
this.commandContext.audioService.playTouchGuidance(guidance);
452+
};
453+
}
454+
455+
if (!this.pointerLeaveListener) {
456+
this.pointerLeaveListener = () => {
457+
this.commandContext.audioService.playTouchGuidance(null);
448458
};
449459
}
450460

@@ -454,8 +464,12 @@ export class Mousebindingservice implements Observer<Settings>, Disposable {
454464
// Add appropriate listeners based on hover mode
455465
if (this.hoverMode === 'pointermove') {
456466
this.plot.addEventListener('pointermove', this.mouseListener);
467+
this.plot.addEventListener('pointerleave', this.pointerLeaveListener);
457468
} else if (this.hoverMode === 'click') {
458469
this.plot.addEventListener('click', this.mouseListener);
470+
this.plot.addEventListener('pointerleave', this.pointerLeaveListener);
471+
} else {
472+
this.commandContext.audioService.playTouchGuidance(null);
459473
}
460474
}
461475

@@ -467,6 +481,10 @@ export class Mousebindingservice implements Observer<Settings>, Disposable {
467481
this.plot.removeEventListener('pointermove', this.mouseListener);
468482
this.plot.removeEventListener('click', this.mouseListener);
469483
}
484+
if (this.pointerLeaveListener) {
485+
this.plot.removeEventListener('pointerleave', this.pointerLeaveListener);
486+
}
487+
this.commandContext.audioService.playTouchGuidance(null);
470488
}
471489

472490
/**

src/type/state.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,20 @@ export interface AudioState {
181181
volumeScale?: number;
182182
}
183183

184+
/**
185+
* Directional guidance state for pointer/touch exploration near a curve.
186+
*/
187+
export interface TouchGuidanceState {
188+
/** True when pointer/finger is touching the curve's active point region. */
189+
onCurve: boolean;
190+
/** Distance in screen pixels from pointer/finger to the nearest curve point center. */
191+
distancePx: number;
192+
/** Relative vertical relation of pointer/finger to the curve. */
193+
verticalRelation: 'above' | 'below';
194+
/** Relative horizontal relation of pointer/finger to the curve. */
195+
horizontalRelation: 'left' | 'right';
196+
}
197+
184198
/**
185199
* Union type for all braille display states across different plot types.
186200
*/

0 commit comments

Comments
 (0)