From 50a756023938fb1f718da6819a19e8232bae1cc4 Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Wed, 6 Aug 2025 15:42:10 +0100 Subject: [PATCH 1/3] add simple mouse events for price and time scales --- src/api/iaxis-api.ts | 69 +++++++++++++++++++++++ src/api/iprice-scale-api.ts | 4 +- src/api/itime-scale-api.ts | 4 +- src/api/price-scale-api.ts | 21 +++++++ src/api/time-scale-api.ts | 27 ++++++++- src/gui/axis-mouse-event-helpers.ts | 38 +++++++++++++ src/gui/chart-widget.ts | 7 +++ src/gui/price-axis-widget.ts | 58 ++++++++++++++++++- src/gui/time-axis-widget.ts | 34 ++++++++++- src/model/axis-model.ts | 87 +++++++++++++++++++++++++++++ src/model/axis-widget.ts | 17 ++++++ 11 files changed, 359 insertions(+), 7 deletions(-) create mode 100644 src/api/iaxis-api.ts create mode 100644 src/gui/axis-mouse-event-helpers.ts create mode 100644 src/model/axis-model.ts create mode 100644 src/model/axis-widget.ts diff --git a/src/api/iaxis-api.ts b/src/api/iaxis-api.ts new file mode 100644 index 00000000000..ce520c35f17 --- /dev/null +++ b/src/api/iaxis-api.ts @@ -0,0 +1,69 @@ +import { AxisMouseEventHandler } from '../model/axis-model'; + +export interface IAxisApi { + /** + * Subscribe to the axis click event. + * + * @param handler - Handler to be called on mouse click. + * @example + * ```js + * function myClickHandler(param) { + * if (!param.point) { + * return; + * } + * + * console.log(`Click at ${param.point.x}, ${param.point.y}.`); + * } + * + * chart.timeScale().subscribeClick(myClickHandler); + * ``` + */ + subscribeClick(handler: AxisMouseEventHandler): void; + + /** + * Unsubscribe a handler that was previously subscribed using {@link subscribeClick}. + * + * @param handler - Previously subscribed handler + * @example + * ```js + * chart.timeScale().unsubscribeClick(myClickHandler); + * ``` + */ + unsubscribeClick(handler: AxisMouseEventHandler): void; + + /** + * Subscribe to the axis mouse move event. + * + * @param handler - Handler to be called on mouse move. + * @example + * ```js + * function myMoveHandler(param) { + * if (!param.point) { + * return; + * } + * + * console.log(`Mouse at ${param.point.x}, ${param.point.y}.`); + * } + * + * chart.timeScale().subscribeMouseMove(myMoveHandler); + * ``` + */ + subscribeMouseMove(handler: AxisMouseEventHandler): void; + + /** + * Unsubscribe a handler that was previously subscribed using {@link subscribeMouseMove}. + * + * @param handler - Previously subscribed handler + * @example + * ```js + * chart.timeScale().unsubscribeMouseMove(myMoveHandler); + * ``` + */ + unsubscribeMouseMove(handler: AxisMouseEventHandler): void; + + /** + * CSS cursor style as defined here: [MDN: CSS Cursor](https://developer.mozilla.org/en-US/docs/Web/CSS/cursor) or `undefined` + * if you want the library to use the default cursor style instead. + */ + overrideCursorStyle(cursor: string | undefined): void; +} diff --git a/src/api/iprice-scale-api.ts b/src/api/iprice-scale-api.ts index bd584470fd2..405b3240cf0 100644 --- a/src/api/iprice-scale-api.ts +++ b/src/api/iprice-scale-api.ts @@ -3,8 +3,10 @@ import { DeepPartial } from '../helpers/strict-type-checks'; import { PriceScaleOptions } from '../model/price-scale'; import { IRange } from '../model/time-data'; +import { IAxisApi } from './iaxis-api'; + /** Interface to control chart's price scale */ -export interface IPriceScaleApi { +export interface IPriceScaleApi extends IAxisApi { /** * Applies new options to the price scale * diff --git a/src/api/itime-scale-api.ts b/src/api/itime-scale-api.ts index 3a649849812..0a38f4d14a9 100644 --- a/src/api/itime-scale-api.ts +++ b/src/api/itime-scale-api.ts @@ -4,6 +4,8 @@ import { Coordinate } from '../model/coordinate'; import { IRange, Logical, LogicalRange, TimePointIndex } from '../model/time-data'; import { HorzScaleOptions } from '../model/time-scale'; +import { IAxisApi } from './iaxis-api'; + /** * A custom function used to handle changes to the time scale's time range. */ @@ -18,7 +20,7 @@ export type LogicalRangeChangeEventHandler = (logicalRange: LogicalRange | null) export type SizeChangeEventHandler = (width: number, height: number) => void; /** Interface to chart time scale */ -export interface ITimeScaleApi { +export interface ITimeScaleApi extends IAxisApi { /** * Return the distance from the right edge of the time scale to the lastest bar of the series measured in bars. */ diff --git a/src/api/price-scale-api.ts b/src/api/price-scale-api.ts index 3762dead9b5..c25ba01b48a 100644 --- a/src/api/price-scale-api.ts +++ b/src/api/price-scale-api.ts @@ -3,6 +3,7 @@ import { IChartWidgetBase } from '../gui/chart-widget'; import { ensureNotNull } from '../helpers/assertions'; import { DeepPartial } from '../helpers/strict-type-checks'; +import { AxisMouseEventHandler } from '../model/axis-model'; import { isDefaultPriceScale } from '../model/default-price-scale'; import { PriceRangeImpl } from '../model/price-range-impl'; import { PriceScale, PriceScaleOptions } from '../model/price-scale'; @@ -54,6 +55,26 @@ export class PriceScaleApi implements IPriceScaleApi { this.applyOptions({ autoScale: on }); } + public subscribeClick(handler: AxisMouseEventHandler): void { + this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).subscribeClick(handler); + } + + public unsubscribeClick(handler: AxisMouseEventHandler): void { + this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).unsubscribeClick(handler); + } + + public subscribeMouseMove(handler: AxisMouseEventHandler): void { + this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).subscribeMouseMove(handler); + } + + public unsubscribeMouseMove(handler: AxisMouseEventHandler): void { + this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).unsubscribeMouseMove(handler); + } + + public overrideCursorStyle(cursor: string | undefined): void { + this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).overrideCursorStyle(cursor); + } + private _priceScale(): PriceScale { return ensureNotNull(this._chartWidget.model().findPriceScale(this._priceScaleId, this._paneIndex)).priceScale; } diff --git a/src/api/time-scale-api.ts b/src/api/time-scale-api.ts index d1ebc4d2af1..3b20945e651 100644 --- a/src/api/time-scale-api.ts +++ b/src/api/time-scale-api.ts @@ -7,6 +7,7 @@ import { Delegate } from '../helpers/delegate'; import { IDestroyable } from '../helpers/idestroyable'; import { clone, DeepPartial } from '../helpers/strict-type-checks'; +import { AxisApi, AxisMouseEventHandler } from '../model/axis-model'; import { ChartModel } from '../model/chart-model'; import { Coordinate } from '../model/coordinate'; import { IHorzScaleBehavior, InternalHorzScaleItem } from '../model/ihorz-scale-behavior'; @@ -19,11 +20,12 @@ import { SizeChangeEventHandler, TimeRangeChangeEventHandler, } from './itime-scale-api'; + const enum Constants { AnimationDurationMs = 1000, } -export class TimeScaleApi implements ITimeScaleApi, IDestroyable { +export class TimeScaleApi extends AxisApi implements ITimeScaleApi, IDestroyable { private _model: ChartModel; private _timeScale: TimeScale; private readonly _timeAxisWidget: TimeAxisWidget; @@ -34,6 +36,7 @@ export class TimeScaleApi implements ITimeScaleApi private readonly _horzScaleBehavior: IHorzScaleBehavior; public constructor(model: ChartModel, timeAxisWidget: TimeAxisWidget, horzScaleBehavior: IHorzScaleBehavior) { + super(); this._model = model; this._timeScale = model.timeScale(); this._timeAxisWidget = timeAxisWidget; @@ -42,9 +45,11 @@ export class TimeScaleApi implements ITimeScaleApi this._timeAxisWidget.sizeChanged().subscribe(this._onSizeChanged.bind(this)); this._horzScaleBehavior = horzScaleBehavior; + this._setupMouseEvents(this._timeAxisWidget); } public destroy(): void { + this._removeMouseEvents(this._timeAxisWidget); this._timeScale.visibleBarsChanged().unsubscribeAll(this); this._timeScale.logicalRangeChanged().unsubscribeAll(this); this._timeAxisWidget.sizeChanged().unsubscribeAll(this); @@ -53,6 +58,26 @@ export class TimeScaleApi implements ITimeScaleApi this._sizeChanged.destroy(); } + public subscribeClick(handler: AxisMouseEventHandler): void { + this._subscribeClick(handler); + } + + public unsubscribeClick(handler: AxisMouseEventHandler): void { + this._unsubscribeClick(handler); + } + + public subscribeMouseMove(handler: AxisMouseEventHandler): void { + this._subscribeMouseMove(handler); + } + + public unsubscribeMouseMove(handler: AxisMouseEventHandler): void { + this._unsubscribeMouseMove(handler); + } + + public overrideCursorStyle(cursor: string | undefined): void { + this._timeAxisWidget.overrideCursorStyle(cursor); + } + public scrollPosition(): number { return this._timeScale.rightOffset(); } diff --git a/src/gui/axis-mouse-event-helpers.ts b/src/gui/axis-mouse-event-helpers.ts new file mode 100644 index 00000000000..cdfcd759a9c --- /dev/null +++ b/src/gui/axis-mouse-event-helpers.ts @@ -0,0 +1,38 @@ +import { Delegate } from '../helpers/delegate'; + +import { + AxisMouseEventParamsImpl, + AxisMouseEventParamsImplSupplier, +} from '../model/axis-widget'; +import { Point } from '../model/point'; + +import { MouseEventHandlerEventBase } from './mouse-event-handler'; + +export function fireNullMouseDelegate( + delegate: Delegate +): void { + if (delegate.hasListeners()) { + delegate.fire(() => getAxisMouseEventParamsImpl(null, null)); + } +} + +export function fireMouseDelegate( + delegate: Delegate, + event: MouseEventHandlerEventBase +): void { + const x = event.localX; + const y = event.localY; + if (delegate.hasListeners()) { + delegate.fire(() => getAxisMouseEventParamsImpl({ x, y }, event)); + } +} + +function getAxisMouseEventParamsImpl( + point: Point | null, + event: MouseEventHandlerEventBase | null +): AxisMouseEventParamsImpl { + return { + point: point ?? undefined, + touchMouseEventData: event ?? undefined, + }; +} diff --git a/src/gui/chart-widget.ts b/src/gui/chart-widget.ts index 45a439a1d8b..8fff2c0f86b 100644 --- a/src/gui/chart-widget.ts +++ b/src/gui/chart-widget.ts @@ -28,6 +28,7 @@ import { TouchMouseEventData } from '../model/touch-mouse-event-data'; import { suggestChartSize, suggestPriceScaleWidth, suggestTimeScaleHeight } from './internal-layout-sizes-hints'; import { PaneSeparator, SeparatorConstants } from './pane-separator'; import { PaneWidget } from './pane-widget'; +import { PriceAxisWidget } from './price-axis-widget'; import { TimeAxisWidget } from './time-axis-widget'; export interface MouseEventParamsImpl { @@ -51,6 +52,7 @@ export interface IChartWidgetBase { paneWidgets(): PaneWidget[]; options(): ChartOptionsInternalBase; setCursorStyle(style: string | null): void; + getPriceAxisWidget(paneIndex: number, priceScaleId: string): PriceAxisWidget; } export class ChartWidget implements IDestroyable, IChartWidgetBase { @@ -326,6 +328,11 @@ export class ChartWidget implements IDestroyable, IChartWidgetBas return ensureDefined(this._paneWidgets[paneIndex]).getSize(); } + public getPriceAxisWidget(paneIndex: number, priceScaleId: string): PriceAxisWidget { + const pane = ensureDefined(this._paneWidgets[paneIndex]); + return ensureNotNull(priceScaleId === 'left' ? pane.leftPriceAxisWidget() : pane.rightPriceAxisWidget()); + } + private _applyPanesOptions(): void { this._paneSeparators.forEach((separator: PaneSeparator) => { separator.update(); diff --git a/src/gui/price-axis-widget.ts b/src/gui/price-axis-widget.ts index c96f8abec1c..329d3450d28 100644 --- a/src/gui/price-axis-widget.ts +++ b/src/gui/price-axis-widget.ts @@ -11,9 +11,13 @@ import { import { ensureNotNull } from '../helpers/assertions'; import { clearRect, clearRectWithGradient } from '../helpers/canvas-helpers'; +import { Delegate } from '../helpers/delegate'; import { IDestroyable } from '../helpers/idestroyable'; +import { ISubscription } from '../helpers/isubscription'; import { makeFont } from '../helpers/make-font'; +import { AxisApi, AxisMouseEventHandler } from '../model/axis-model'; +import { AxisMouseEventParamsImplSupplier } from '../model/axis-widget'; import { ChartOptionsInternalBase } from '../model/chart-model'; import { Coordinate } from '../model/coordinate'; import { CrosshairMode, CrosshairOptions } from '../model/crosshair'; @@ -30,6 +34,7 @@ import { PriceAxisRendererOptionsProvider } from '../renderers/price-axis-render import { IAxisView } from '../views/pane/iaxis-view'; import { IPriceAxisView } from '../views/price-axis/iprice-axis-view'; +import { fireMouseDelegate, fireNullMouseDelegate } from './axis-mouse-event-helpers'; import { createBoundCanvas, releaseCanvas } from './canvas-utils'; import { ViewsGetter } from './draw-functions'; import { suggestPriceScaleWidth } from './internal-layout-sizes-hints'; @@ -124,7 +129,7 @@ function priceScaleCrosshairLabelVisible(crosshair: CrosshairOptions): boolean { return crosshair.mode !== CrosshairMode.Hidden && crosshair.horzLine.visible && crosshair.horzLine.labelVisible; } -export class PriceAxisWidget implements IDestroyable { +export class PriceAxisWidget extends AxisApi implements IDestroyable { private readonly _pane: PaneWidget; private readonly _options: Readonly; private readonly _layoutOptions: Readonly; @@ -152,7 +157,12 @@ export class PriceAxisWidget implements IDestroyable { private _sourceTopPaneViews: ViewsGetter; private _sourceBottomPaneViews: ViewsGetter; + private _cursorOverride: string | undefined = undefined; + private _clicked: Delegate = new Delegate(); + private _mouseMoved: Delegate = new Delegate(); + public constructor(pane: PaneWidget, options: Readonly, rendererOptionsProvider: PriceAxisRendererOptionsProvider, side: PriceAxisWidgetSide) { + super(); this._pane = pane; this._options = options; this._layoutOptions = options['layout']; @@ -194,7 +204,9 @@ export class PriceAxisWidget implements IDestroyable { mouseDownOutsideEvent: this._mouseDownOutsideEvent.bind(this), mouseUpEvent: this._mouseUpEvent.bind(this), touchEndEvent: this._mouseUpEvent.bind(this), + mouseMoveEvent: this._mouseMoveEvent.bind(this), mouseDoubleClickEvent: this._mouseDoubleClickEvent.bind(this), + mouseClickEvent: this._mouseClickEvent.bind(this), doubleTapEvent: this._mouseDoubleClickEvent.bind(this), mouseEnterEvent: this._mouseEnterEvent.bind(this), mouseLeaveEvent: this._mouseLeaveEvent.bind(this), @@ -207,9 +219,13 @@ export class PriceAxisWidget implements IDestroyable { treatHorzTouchDragAsPageScroll: () => true, } ); + this._setupMouseEvents(this); } public destroy(): void { + this._removeMouseEvents(this); + this._clicked.destroy(); + this._mouseMoved.destroy(); this._mouseEventHandler.destroy(); this._topCanvasBinding.unsubscribeSuggestedBitmapSizeChanged(this._topCanvasSuggestedBitmapSizeChangedHandler); @@ -227,6 +243,14 @@ export class PriceAxisWidget implements IDestroyable { this._priceScale = null; } + public clicked(): ISubscription { + return this._clicked; + } + + public mouseMoved(): ISubscription { + return this._mouseMoved; + } + public getElement(): HTMLElement { return this._cell; } @@ -404,6 +428,27 @@ export class PriceAxisWidget implements IDestroyable { this._priceScale?.marks(); } + public overrideCursorStyle(cursor: string | undefined): void { + this._cursorOverride = cursor; + this._setCursor(CursorType.Default); + } + + public subscribeClick(handler: AxisMouseEventHandler): void { + this._subscribeClick(handler); + } + + public unsubscribeClick(handler: AxisMouseEventHandler): void { + this._unsubscribeClick(handler); + } + + public subscribeMouseMove(handler: AxisMouseEventHandler): void { + this._subscribeMouseMove(handler); + } + + public unsubscribeMouseMove(handler: AxisMouseEventHandler): void { + this._unsubscribeMouseMove(handler); + } + private _mouseDownEvent(e: TouchMouseEvent): void { if (this._priceScale === null || this._priceScale.isEmpty() || !this._options['handleScale'].axisPressedMouseMove.price) { return; @@ -470,6 +515,15 @@ export class PriceAxisWidget implements IDestroyable { private _mouseLeaveEvent(e: TouchMouseEvent): void { this._setCursor(CursorType.Default); + fireNullMouseDelegate(this._mouseMoved); + } + + private _mouseMoveEvent(e: TouchMouseEvent): void { + fireMouseDelegate(this._mouseMoved, e); + } + + private _mouseClickEvent(e: TouchMouseEvent): void { + fireMouseDelegate(this._clicked, e); } private _backLabels(): IPriceAxisView[] { @@ -713,7 +767,7 @@ export class PriceAxisWidget implements IDestroyable { } private _setCursor(type: CursorType): void { - this._cell.style.cursor = type === CursorType.NsResize ? 'ns-resize' : 'default'; + this._cell.style.cursor = this._cursorOverride || (type === CursorType.NsResize ? 'ns-resize' : 'default'); } private _onMarksChanged(): void { diff --git a/src/gui/time-axis-widget.ts b/src/gui/time-axis-widget.ts index 1153716c8d4..21fbeb4e90a 100644 --- a/src/gui/time-axis-widget.ts +++ b/src/gui/time-axis-widget.ts @@ -15,6 +15,7 @@ import { IDestroyable } from '../helpers/idestroyable'; import { ISubscription } from '../helpers/isubscription'; import { makeFont } from '../helpers/make-font'; +import { AxisMouseEventParamsImplSupplier } from '../model/axis-widget'; import { IDataSource } from '../model/idata-source'; import { IHorzScaleBehavior } from '../model/ihorz-scale-behavior'; import { InvalidationLevel } from '../model/invalidate-mask'; @@ -26,11 +27,12 @@ import { IPaneRenderer } from '../renderers/ipane-renderer'; import { TimeAxisViewRendererOptions } from '../renderers/itime-axis-view-renderer'; import { IAxisView } from '../views/pane/iaxis-view'; +import { fireMouseDelegate, fireNullMouseDelegate } from './axis-mouse-event-helpers'; import { createBoundCanvas, releaseCanvas } from './canvas-utils'; import { ChartWidget } from './chart-widget'; import { drawBackground, drawForeground, drawSourceViews } from './draw-functions'; import { ITimeAxisViewsGetter } from './iaxis-view-getters'; -import { MouseEventHandler, MouseEventHandlers, MouseEventHandlerTouchEvent, TouchMouseEvent } from './mouse-event-handler'; +import { MouseEventHandler, MouseEventHandlerMouseEvent, MouseEventHandlers, MouseEventHandlerTouchEvent, TouchMouseEvent } from './mouse-event-handler'; import { PriceAxisStub, PriceAxisStubParams } from './price-axis-stub'; const enum Constants { @@ -69,6 +71,9 @@ export class TimeAxisWidget implements MouseEventHandlers, IDestr private readonly _sizeChanged: Delegate = new Delegate(); private readonly _widthCache: TextWidthCache = new TextWidthCache(5); private _isSettingSize: boolean = false; + private _clicked: Delegate = new Delegate(); + private _mouseMoved: Delegate = new Delegate(); + private _cursorOverride: string | undefined = undefined; private readonly _horzScaleBehavior: IHorzScaleBehavior; @@ -130,6 +135,8 @@ export class TimeAxisWidget implements MouseEventHandlers, IDestr } public destroy(): void { + this._clicked.destroy(); + this._mouseMoved.destroy(); this._mouseEventHandler.destroy(); if (this._leftStub !== null) { this._leftStub.destroy(); @@ -159,6 +166,11 @@ export class TimeAxisWidget implements MouseEventHandlers, IDestr return this._rightStub; } + public overrideCursorStyle(cursor: string | undefined): void { + this._cursorOverride = cursor; + this._setCursor(CursorType.Default); + } + public mouseDownEvent(event: TouchMouseEvent): void { if (this._mouseDown) { return; @@ -198,6 +210,11 @@ export class TimeAxisWidget implements MouseEventHandlers, IDestr public touchMoveEvent(event: MouseEventHandlerTouchEvent): void { this.pressedMouseMoveEvent(event); + fireMouseDelegate(this._mouseMoved, event); + } + + public mouseMoveEvent(event: MouseEventHandlerMouseEvent): void { + fireMouseDelegate(this._mouseMoved, event); } public mouseUpEvent(): void { @@ -232,6 +249,19 @@ export class TimeAxisWidget implements MouseEventHandlers, IDestr public mouseLeaveEvent(): void { this._setCursor(CursorType.Default); + fireNullMouseDelegate(this._mouseMoved); + } + + public mouseClickEvent(event: MouseEventHandlerMouseEvent): void { + fireMouseDelegate(this._clicked, event); + } + + public clicked(): ISubscription { + return this._clicked; + } + + public mouseMoved(): ISubscription { + return this._mouseMoved; } public getSize(): Size { @@ -514,7 +544,7 @@ export class TimeAxisWidget implements MouseEventHandlers, IDestr } private _setCursor(type: CursorType): void { - this._cell.style.cursor = type === CursorType.EwResize ? 'ew-resize' : 'default'; + this._cell.style.cursor = this._cursorOverride || (type === CursorType.EwResize ? 'ew-resize' : 'default'); } private _recreateStubs(): void { diff --git a/src/model/axis-model.ts b/src/model/axis-model.ts new file mode 100644 index 00000000000..df1a2e2a48f --- /dev/null +++ b/src/model/axis-model.ts @@ -0,0 +1,87 @@ +import { Delegate } from '../helpers/delegate'; + +import { Point } from '../model/point'; +import { TouchMouseEventData } from '../model/touch-mouse-event-data'; + +import { AxisMouseEventParamsImpl, AxisMouseEventParamsImplSupplier, IAxisWidget } from './axis-widget'; + +/** + * Represents a mouse event. + */ +export interface AxisMouseEventParams { + /** + * Location of the event in the chart. + * + * The value will be `undefined` if the event is fired outside the chart, for example a mouse leave event. + */ + point?: Point; + /** + * The underlying source mouse or touch event data, if available + */ + sourceEvent?: TouchMouseEventData; +} + +/** + * A custom function use to handle mouse events. + */ +export type AxisMouseEventHandler = (param: AxisMouseEventParams) => void; + +export abstract class AxisApi { + private readonly _clickedDelegate: Delegate = + new Delegate(); + private readonly _movedDelegate: Delegate = + new Delegate(); + + protected _subscribeClick(handler: AxisMouseEventHandler): void { + this._clickedDelegate.subscribe(handler); + } + + protected _unsubscribeClick(handler: AxisMouseEventHandler): void { + this._clickedDelegate.unsubscribe(handler); + } + + protected _subscribeMouseMove(handler: AxisMouseEventHandler): void { + this._movedDelegate.subscribe(handler); + } + + protected _unsubscribeMouseMove(handler: AxisMouseEventHandler): void { + this._movedDelegate.unsubscribe(handler); + } + + protected _removeMouseEvents(widget: IAxisWidget): void { + widget.clicked().unsubscribeAll(this); + this._clickedDelegate.destroy(); + widget.mouseMoved().unsubscribeAll(this); + this._movedDelegate.destroy(); + } + + protected _setupMouseEvents(widget: IAxisWidget): void { + widget + .clicked() + .subscribe( + (paramSupplier: AxisMouseEventParamsImplSupplier) => { + if (this._clickedDelegate.hasListeners()) { + this._clickedDelegate.fire(convertMouseParams(paramSupplier())); + } + }, + this + ); + widget + .mouseMoved() + .subscribe( + (paramSupplier: AxisMouseEventParamsImplSupplier) => { + if (this._movedDelegate.hasListeners()) { + this._movedDelegate.fire(convertMouseParams(paramSupplier())); + } + }, + this + ); + } +} + +function convertMouseParams(param: AxisMouseEventParamsImpl): AxisMouseEventParams { + return { + point: param.point, + sourceEvent: param.touchMouseEventData, + }; +} diff --git a/src/model/axis-widget.ts b/src/model/axis-widget.ts new file mode 100644 index 00000000000..93eb5ce83c6 --- /dev/null +++ b/src/model/axis-widget.ts @@ -0,0 +1,17 @@ +import { ISubscription } from '../helpers/isubscription'; + +import { Point } from './point'; +import { TouchMouseEventData } from './touch-mouse-event-data'; + +export interface IAxisWidget { + clicked(): ISubscription; + mouseMoved(): ISubscription; +} + +export interface AxisMouseEventParamsImpl { + point?: Point; + hoveredObject?: string; + touchMouseEventData?: TouchMouseEventData; +} + +export type AxisMouseEventParamsImplSupplier = () => AxisMouseEventParamsImpl; From 70c78f5976a66e23ecc2a4094cb088ac83c06f86 Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Wed, 6 Aug 2025 16:00:24 +0100 Subject: [PATCH 2/3] restrict new price scale methods to visible price scales only --- src/api/price-scale-api.ts | 29 ++++++++++++++++++++++++----- src/model/axis-model.ts | 2 +- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/api/price-scale-api.ts b/src/api/price-scale-api.ts index c25ba01b48a..042be68fa99 100644 --- a/src/api/price-scale-api.ts +++ b/src/api/price-scale-api.ts @@ -1,6 +1,7 @@ import { IChartWidgetBase } from '../gui/chart-widget'; import { ensureNotNull } from '../helpers/assertions'; +import { warn } from '../helpers/logger'; import { DeepPartial } from '../helpers/strict-type-checks'; import { AxisMouseEventHandler } from '../model/axis-model'; @@ -56,23 +57,41 @@ export class PriceScaleApi implements IPriceScaleApi { } public subscribeClick(handler: AxisMouseEventHandler): void { - this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).subscribeClick(handler); + if (this._checkDefaultPriceScale()) { + this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).subscribeClick(handler); + } } public unsubscribeClick(handler: AxisMouseEventHandler): void { - this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).unsubscribeClick(handler); + if (this._checkDefaultPriceScale()) { + this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).unsubscribeClick(handler); + } } public subscribeMouseMove(handler: AxisMouseEventHandler): void { - this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).subscribeMouseMove(handler); + if (this._checkDefaultPriceScale()) { + this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).subscribeMouseMove(handler); + } } public unsubscribeMouseMove(handler: AxisMouseEventHandler): void { - this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).unsubscribeMouseMove(handler); + if (this._checkDefaultPriceScale()) { + this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).unsubscribeMouseMove(handler); + } } public overrideCursorStyle(cursor: string | undefined): void { - this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).overrideCursorStyle(cursor); + if (this._checkDefaultPriceScale()) { + this._chartWidget.getPriceAxisWidget(this._paneIndex, this._priceScaleId).overrideCursorStyle(cursor); + } + } + + private _checkDefaultPriceScale(): boolean { + if (!isDefaultPriceScale(this._priceScaleId)) { + warn('Method only supported on visible price scales'); + return false; + } + return true; } private _priceScale(): PriceScale { diff --git a/src/model/axis-model.ts b/src/model/axis-model.ts index df1a2e2a48f..66f35b97fcd 100644 --- a/src/model/axis-model.ts +++ b/src/model/axis-model.ts @@ -50,8 +50,8 @@ export abstract class AxisApi { protected _removeMouseEvents(widget: IAxisWidget): void { widget.clicked().unsubscribeAll(this); - this._clickedDelegate.destroy(); widget.mouseMoved().unsubscribeAll(this); + this._clickedDelegate.destroy(); this._movedDelegate.destroy(); } From 48d99fe672b3367d6915553ad38948d97741f735 Mon Sep 17 00:00:00 2001 From: Mark Silverwood Date: Thu, 21 Aug 2025 11:40:51 +0100 Subject: [PATCH 3/3] Add mouse event E2E tests for price and time scales Introduces end-to-end tests for mouse interactions on the price scale and time scale. Tests verify that click and mouse move event handlers are invoked the expected number of times for both scales. --- .../mouse/pricescale-mouse-events.js | 83 +++++++++++++++++++ .../mouse/timescale-mouse-events.js | 81 ++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 tests/e2e/interactions/test-cases/mouse/pricescale-mouse-events.js create mode 100644 tests/e2e/interactions/test-cases/mouse/timescale-mouse-events.js diff --git a/tests/e2e/interactions/test-cases/mouse/pricescale-mouse-events.js b/tests/e2e/interactions/test-cases/mouse/pricescale-mouse-events.js new file mode 100644 index 00000000000..6677f72b6c1 --- /dev/null +++ b/tests/e2e/interactions/test-cases/mouse/pricescale-mouse-events.js @@ -0,0 +1,83 @@ +function generateData() { + const res = []; + const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0)); + for (let i = 0; i < 500; ++i) { + res.push({ + time: time.getTime() / 1000, + value: i, + }); + + time.setUTCDate(time.getUTCDate() + 1); + } + return res; +} + +function initialInteractionsToPerform() { + return [ + { action: 'moveMouseBottomRight', target: 'rightpricescale' }, + { action: 'moveMouseTopLeft', target: 'rightpricescale' }, + { action: 'moveMouseCenter', target: 'rightpricescale' }, + { action: 'click', target: 'rightpricescale' }, + ]; +} + +function finalInteractionsToPerform() { + return []; +} + +let chart; +let moveCount = 0; +let clickCount = 0; + +function beforeInteractions(container) { + chart = LightweightCharts.createChart(container); + + const mainSeries = chart.addSeries(LightweightCharts.LineSeries); + + const mainSeriesData = generateData(); + mainSeries.setData(mainSeriesData); + + mainSeries.priceScale().subscribeClick(mouseParams => { + if (!mouseParams) { + return; + } + clickCount += 1; + }); + mainSeries.priceScale().subscribeMouseMove(mouseParams => { + if (!mouseParams) { + return; + } + moveCount += 1; + }); + + return new Promise(resolve => { + requestAnimationFrame(() => { + resolve(); + }); + }); +} + +function afterInitialInteractions() { + return new Promise(resolve => { + requestAnimationFrame(resolve); + }); +} + +function afterFinalInteractions() { + if (clickCount < 1) { + throw new Error('Expected Click event handler to be evoked.'); + } + if (moveCount < 1) { + throw new Error('Expected MouseMove event handler to be evoked.'); + } + if (moveCount === 1) { + throw new Error( + 'Expected MouseMove event handler to be evoked more than once.' + ); + } + if (clickCount > 1) { + throw new Error('Expected click event handler to be evoked only once.'); + } + + return Promise.resolve(); +} diff --git a/tests/e2e/interactions/test-cases/mouse/timescale-mouse-events.js b/tests/e2e/interactions/test-cases/mouse/timescale-mouse-events.js new file mode 100644 index 00000000000..8d9c58adc69 --- /dev/null +++ b/tests/e2e/interactions/test-cases/mouse/timescale-mouse-events.js @@ -0,0 +1,81 @@ +function generateData() { + const res = []; + const time = new Date(Date.UTC(2018, 0, 1, 0, 0, 0, 0)); + for (let i = 0; i < 500; ++i) { + res.push({ + time: time.getTime() / 1000, + value: i, + }); + + time.setUTCDate(time.getUTCDate() + 1); + } + return res; +} + +function initialInteractionsToPerform() { + return [ + { action: 'moveMouseBottomRight', target: 'timescale' }, + { action: 'moveMouseTopLeft', target: 'timescale' }, + { action: 'moveMouseCenter', target: 'timescale' }, + { action: 'click', target: 'timescale' }, + ]; +} + +function finalInteractionsToPerform() { + return []; +} + +let chart; +let moveCount = 0; +let clickCount = 0; + +function beforeInteractions(container) { + chart = LightweightCharts.createChart(container); + + const mainSeries = chart.addSeries(LightweightCharts.LineSeries); + + const mainSeriesData = generateData(); + mainSeries.setData(mainSeriesData); + + chart.timeScale().subscribeClick(mouseParams => { + if (!mouseParams) { + return; + } + clickCount += 1; + }); + chart.timeScale().subscribeMouseMove(mouseParams => { + if (!mouseParams) { + return; + } + moveCount += 1; + }); + + return new Promise(resolve => { + requestAnimationFrame(() => { + resolve(); + }); + }); +} + +function afterInitialInteractions() { + return new Promise(resolve => { + requestAnimationFrame(resolve); + }); +} + +function afterFinalInteractions() { + if (clickCount < 1) { + throw new Error('Expected Click event handler to be evoked.'); + } + if (moveCount < 1) { + throw new Error('Expected MouseMove event handler to be evoked.'); + } + if (moveCount === 1) { + throw new Error('Expected MouseMove event handler to be evoked more than once.'); + } + if (clickCount > 1) { + throw new Error('Expected click event handler to be evoked only once.'); + } + + return Promise.resolve(); +}