From 5847df9552df200b01c66918594d865e7a788904 Mon Sep 17 00:00:00 2001 From: Andrii Ovcharenko Date: Wed, 13 Nov 2024 17:15:11 +0100 Subject: [PATCH 1/3] feature: set hovered source on all crosshair changes --- src/gui/pane-widget.ts | 6 +-- src/model/chart-model.ts | 15 ++++++- src/{gui => model}/pane-hit-test.ts | 11 ++--- .../series-markers/series-markers-arrow.ts | 41 +++++++++++++++++-- 4 files changed, 59 insertions(+), 14 deletions(-) rename src/{gui => model}/pane-hit-test.ts (94%) diff --git a/src/gui/pane-widget.ts b/src/gui/pane-widget.ts index 89744efd85..4deff67a99 100644 --- a/src/gui/pane-widget.ts +++ b/src/gui/pane-widget.ts @@ -20,6 +20,7 @@ import { IDataSourcePaneViews } from '../model/idata-source'; import { InvalidationLevel } from '../model/invalidate-mask'; import { KineticAnimation } from '../model/kinetic-animation'; import { Pane } from '../model/pane'; +import { hitTestPane, HitTestResult } from '../model/pane-hit-test'; import { Point } from '../model/point'; import { TimePointIndex } from '../model/time-data'; import { TouchMouseEventData } from '../model/touch-mouse-event-data'; @@ -31,7 +32,6 @@ import { createBoundCanvas, releaseCanvas } from './canvas-utils'; import { IChartWidgetBase } from './chart-widget'; import { drawBackground, drawForeground, DrawFunction, drawSourceViews, ViewsGetter } from './draw-functions'; import { MouseEventHandler, MouseEventHandlerEventBase, MouseEventHandlerMouseEvent, MouseEventHandlers, MouseEventHandlerTouchEvent, Position, TouchMouseEvent } from './mouse-event-handler'; -import { hitTestPane, HitTestResult } from './pane-hit-test'; import { PriceAxisWidget, PriceAxisWidgetSide } from './price-axis-widget'; const enum KineticScrollConstants { @@ -263,13 +263,9 @@ export class PaneWidget implements IDestroyable, MouseEventHandlers { return; } this._onMouseEvent(); - const x = event.localX; const y = event.localY; this._setCrosshairPosition(x, y, event); - const hitTest = this.hitTest(x, y); - this._chart.setCursorStyle(hitTest?.cursorStyle ?? null); - this._model().setHoveredSource(hitTest && { source: hitTest.source, object: hitTest.object }); } public mouseClickEvent(event: MouseEventHandlerMouseEvent): void { diff --git a/src/model/chart-model.ts b/src/model/chart-model.ts index 630307fa18..fabd324144 100644 --- a/src/model/chart-model.ts +++ b/src/model/chart-model.ts @@ -23,6 +23,7 @@ import { ColorType, LayoutOptions } from './layout-options'; import { LocalizationOptions, LocalizationOptionsBase } from './localization-options'; import { Magnet } from './magnet'; import { DEFAULT_STRETCH_FACTOR, MIN_PANE_HEIGHT, Pane } from './pane'; +import { hitTestPane } from './pane-hit-test'; import { Point } from './point'; import { PriceScale, PriceScaleOptions } from './price-scale'; import { ISeries, Series, SeriesOptionsInternal } from './series'; @@ -508,12 +509,16 @@ export class ChartModel implements IDestroyable, IChartModelBase } public setHoveredSource(source: HoveredSource | null): void { + if (this._hoveredSource?.source === source?.source && this._hoveredSource?.object?.externalId === source?.object?.externalId) { + return; + } const prevSource = this._hoveredSource; this._hoveredSource = source; if (prevSource !== null) { this.updateSource(prevSource.source); } - if (source !== null) { + // additional check to prevent unnecessary updates of same source + if (source !== null && source.source !== prevSource?.source) { this.updateSource(source.source); } } @@ -791,6 +796,7 @@ export class ChartModel implements IDestroyable, IChartModelBase this.cursorUpdate(); if (!skipEvent) { + this._updateHoveredSourceOnChange(pane, x, y); this._crosshairMoved.fire(this._crosshair.appliedIndex(), { x, y }, event); } } @@ -1049,6 +1055,13 @@ export class ChartModel implements IDestroyable, IChartModelBase return this._colorParser; } + private _updateHoveredSourceOnChange(pane: Pane, x: Coordinate, y: Coordinate): void { + if (pane) { + const hitTest = hitTestPane(pane, x, y); + this.setHoveredSource(hitTest && { source: hitTest.source, object: hitTest.object }); + } + } + private _getOrCreatePane(index: number): Pane { assert(index >= 0, 'Index should be greater or equal to 0'); index = Math.min(this._panes.length, index); diff --git a/src/gui/pane-hit-test.ts b/src/model/pane-hit-test.ts similarity index 94% rename from src/gui/pane-hit-test.ts rename to src/model/pane-hit-test.ts index 99969ae5df..b6e4a86e0b 100644 --- a/src/gui/pane-hit-test.ts +++ b/src/model/pane-hit-test.ts @@ -1,10 +1,11 @@ -import { HoveredObject } from '../model/chart-model'; -import { Coordinate } from '../model/coordinate'; -import { IDataSource, IPrimitiveHitTestSource } from '../model/idata-source'; -import { PrimitiveHoveredItem, PrimitivePaneViewZOrder } from '../model/ipane-primitive'; -import { Pane } from '../model/pane'; import { IPaneView } from '../views/pane/ipane-view'; +import { HoveredObject } from './chart-model'; +import { Coordinate } from './coordinate'; +import { IDataSource, IPrimitiveHitTestSource } from './idata-source'; +import { PrimitiveHoveredItem, PrimitivePaneViewZOrder } from './ipane-primitive'; +import { Pane } from './pane'; + export interface HitTestResult { source: IPrimitiveHitTestSource; object?: HoveredObject; diff --git a/src/plugins/series-markers/series-markers-arrow.ts b/src/plugins/series-markers/series-markers-arrow.ts index d0995cbba6..5c098edf60 100644 --- a/src/plugins/series-markers/series-markers-arrow.ts +++ b/src/plugins/series-markers/series-markers-arrow.ts @@ -2,7 +2,6 @@ import { ceiledOdd } from '../../helpers/mathex'; import { Coordinate } from '../../model/coordinate'; -import { hitTestSquare } from './series-markers-square'; import { BitmapShapeItemCoordinates, shapeSize } from './utils'; export function drawArrow( @@ -46,6 +45,42 @@ export function hitTestArrow( x: Coordinate, y: Coordinate ): boolean { - // TODO: implement arrow hit test - return hitTestSquare(centerX, centerY, size, x, y); + const arrowSize = shapeSize('arrowUp', size); + const halfArrowSize = (arrowSize - 1) / 2; + const baseSize = ceiledOdd(size / 2); + const halfBaseSize = (baseSize - 1) / 2; + + const triangleTolerance = 3; + const rectTolerance = 2; + + const baseLeft = centerX - halfBaseSize - rectTolerance; + const baseRight = centerX + halfBaseSize + rectTolerance; + const baseTop = up ? centerY : centerY - halfArrowSize; + const baseBottom = up ? centerY + halfArrowSize : centerY; + + if (x >= baseLeft && x <= baseRight && + y >= baseTop - rectTolerance && y <= baseBottom + rectTolerance) { + return true; + } + + const isInTriangleBounds = (): boolean => { + const headLeft = centerX - halfArrowSize - triangleTolerance; + const headRight = centerX + halfArrowSize + triangleTolerance; + const headTop = up ? centerY - halfArrowSize - triangleTolerance : centerY; + const headBottom = up ? centerY : centerY + halfArrowSize + triangleTolerance; + + if (x < headLeft || x > headRight || + y < headTop || y > headBottom) { + return false; + } + + const dx = Math.abs(x - centerX); + const dy = up + ? Math.abs(y - centerY) // up arrow + : Math.abs(y - centerY); // down arrow + + return dy + triangleTolerance >= dx / 2; + }; + + return isInTriangleBounds(); } From e1b6b8f37734b7b0d2f3933c16b5fb455632aa72 Mon Sep 17 00:00:00 2001 From: Andrii Ovcharenko Date: Thu, 14 Nov 2024 12:59:20 +0100 Subject: [PATCH 2/3] add interaction test --- .../hit-test-after-timescale-change.js | 102 ++++++++++++++++++ .../markers/hit-test-priceline-overlap.js | 99 +++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 tests/e2e/interactions/test-cases/markers/hit-test-after-timescale-change.js create mode 100644 tests/e2e/interactions/test-cases/markers/hit-test-priceline-overlap.js diff --git a/tests/e2e/interactions/test-cases/markers/hit-test-after-timescale-change.js b/tests/e2e/interactions/test-cases/markers/hit-test-after-timescale-change.js new file mode 100644 index 0000000000..c1bad98827 --- /dev/null +++ b/tests/e2e/interactions/test-cases/markers/hit-test-after-timescale-change.js @@ -0,0 +1,102 @@ +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 []; +} + +let markerX = 0; +let markerY = 0; +function finalInteractionsToPerform() { + return [{ + action: 'clickXY', + options: { + // set the cursor aside from the marker + x: markerX - 30, + y: markerY + 5, + }, + }]; +} + +let chart; +let lastHoveredObjectId = null; +function beforeInteractions(container) { + chart = LightweightCharts.createChart(container); + + const mainSeries = chart.addSeries(LightweightCharts.LineSeries); + + const mainSeriesData = generateData(); + const markerTime = mainSeriesData[450].time; + const price = mainSeriesData[450].value; + + mainSeries.setData(mainSeriesData); + LightweightCharts.createSeriesMarkers( + mainSeries, + [ + { + time: markerTime, + position: 'inBar', + color: '#2196F3', + size: 3, + shape: 'circle', + text: '', + id: 'TEST', + }, + ] + ); + mainSeries.createPriceLine({ + price: price, + color: '#000', + lineWidth: 2, + lineStyle: 2, + axisLabelVisible: false, + title: '', + id: 'LINE', + }); + chart.subscribeCrosshairMove(params => { + if (!params) { + return; + } + lastHoveredObjectId = params.hoveredObjectId; + }); + return new Promise(resolve => { + requestAnimationFrame(() => { + // get coordinates for marker bar + markerX = chart.timeScale().timeToCoordinate(markerTime); + markerY = mainSeries.priceToCoordinate(price); + + resolve(); + }); + }); +} + +function afterInitialInteractions() { + return new Promise(resolve => { + requestAnimationFrame(resolve); + }); +} + +function afterFinalInteractions() { + // scroll to the marker + chart.timeScale().scrollToPosition(chart.timeScale().scrollPosition() + 5, false); + return new Promise(resolve => { + requestAnimationFrame(() => { + const pass = lastHoveredObjectId === 'TEST'; + if (!pass) { + throw new Error("Expected hoveredObjectId to be equal to 'TEST'."); + } + resolve(); + }); + }); +} diff --git a/tests/e2e/interactions/test-cases/markers/hit-test-priceline-overlap.js b/tests/e2e/interactions/test-cases/markers/hit-test-priceline-overlap.js new file mode 100644 index 0000000000..830a419ddd --- /dev/null +++ b/tests/e2e/interactions/test-cases/markers/hit-test-priceline-overlap.js @@ -0,0 +1,99 @@ +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 []; +} + +let markerX = 0; +let markerY = 0; +function finalInteractionsToPerform() { + return [ + { + action: 'clickXY', + options: { + x: markerX, + y: markerY + 5, + }, + }, + ]; +} + +let chart; +let lastHoveredObjectId = null; +function beforeInteractions(container) { + chart = LightweightCharts.createChart(container); + + const mainSeries = chart.addSeries(LightweightCharts.LineSeries); + + const mainSeriesData = generateData(); + const markerTime = mainSeriesData[450].time; + const price = mainSeriesData[450].value; + + mainSeries.setData(mainSeriesData); + LightweightCharts.createSeriesMarkers( + mainSeries, + [ + { + time: markerTime, + position: 'inBar', + color: '#2196F3', + size: 3, + shape: 'circle', + text: '', + id: 'TEST', + }, + ] + ); + mainSeries.createPriceLine({ + price: price, + color: '#000', + lineWidth: 2, + lineStyle: 2, + axisLabelVisible: false, + title: '', + id: 'LINE', + }); + chart.subscribeClick(mouseParams => { + if (!mouseParams) { + return; + } + lastHoveredObjectId = mouseParams.hoveredObjectId; + }); + + return new Promise(resolve => { + requestAnimationFrame(() => { + // get coordinates for marker bar + markerX = chart.timeScale().timeToCoordinate(markerTime); + markerY = mainSeries.priceToCoordinate(price); + resolve(); + }); + }); +} + +function afterInitialInteractions() { + return new Promise(resolve => { + requestAnimationFrame(resolve); + }); +} + +function afterFinalInteractions() { + const pass = lastHoveredObjectId === 'TEST'; + // throw new Error(`Expected hoveredObjectId to be equal to 'TEST'. Actual: ${markerX} = ${markerY}`); + if (!pass) { + throw new Error("Expected hoveredObjectId to be equal to 'TEST'."); + } + + return Promise.resolve(); +} From 079f3c81f97765f3a754b6e05ddcb2fdf9bc65ff Mon Sep 17 00:00:00 2001 From: Andrii Ovcharenko Date: Thu, 21 Nov 2024 10:31:34 +0100 Subject: [PATCH 3/3] remove comment --- .../test-cases/markers/hit-test-priceline-overlap.js | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/interactions/test-cases/markers/hit-test-priceline-overlap.js b/tests/e2e/interactions/test-cases/markers/hit-test-priceline-overlap.js index 830a419ddd..6c4a104bf6 100644 --- a/tests/e2e/interactions/test-cases/markers/hit-test-priceline-overlap.js +++ b/tests/e2e/interactions/test-cases/markers/hit-test-priceline-overlap.js @@ -90,7 +90,6 @@ function afterInitialInteractions() { function afterFinalInteractions() { const pass = lastHoveredObjectId === 'TEST'; - // throw new Error(`Expected hoveredObjectId to be equal to 'TEST'. Actual: ${markerX} = ${markerY}`); if (!pass) { throw new Error("Expected hoveredObjectId to be equal to 'TEST'."); }