@devexperts/dxcharts-lite
Version:
265 lines (264 loc) • 12.9 kB
JavaScript
/*
* Copyright (C) 2019 - 2025 Devexperts Solutions IE Limited
* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
* If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { merge, Subject, animationFrameScheduler, BehaviorSubject } from 'rxjs';
import { map, throttleTime } from 'rxjs/operators';
import { CanvasElement } from '../canvas/canvas-bounds-container';
import { CanvasModel, initCanvasWithConfig } from './canvas.model';
import { animationFrameId } from '../utils/performance/request-animation-frame-throttle.utils';
const bigPrimeNumber = 317;
export const HIT_TEST_ID_RANGE = {
DRAWINGS: [1, 4999],
NEWS: [5000, 5999],
DATA_SERIES: [6000, 9999],
EVENTS: [10000, 12999],
EXECUTED_ORDERS: [13000, 15999],
};
/** HitTestCanvasModel
* Canvas layer for testing mouse events over the models such as Charts, Drawings, Volumes and etc.
* !!! always add new drawers to hit-test drawingManager BEFORE the DrawerType.HIT_TEST_DRAWINGS to save the hierarchy
*
* @doc-tags chart-core,hit-test
*/
export class HitTestCanvasModel extends CanvasModel {
constructor(eventBus, canvas, canvasInputListener, canvasBoundsContainer, drawingManager, chartConfig, canvasModels, resizer) {
super(eventBus, canvas, drawingManager, canvasModels, resizer, {
willReadFrequently: true,
// set to false to visually see hit test drawers objects (the canvas should also be visible)
desynchronized: true,
});
this.canvasInputListener = canvasInputListener;
this.canvasBoundsContainer = canvasBoundsContainer;
this.hitTestSubscribers = [];
this.eventsSubscriptions = [];
this.hoverSubject = new Subject();
this.touchStartSubject = new Subject();
this.dblClickSubject = new Subject();
this.rightClickSubject = new Subject();
// This predicate is used to detect whenever hit test should or shouldn't redraw hit test canvas image objects
this.hitTestDrawersPredicateSubject = new BehaviorSubject(true);
this.curImgData = new Uint8ClampedArray(4);
this.prevAnimationFrameId = -1;
initCanvasWithConfig(this, chartConfig);
canvas.style.visibility = 'hidden';
this.enableUserControls();
}
/**
* Enables HitTestCanvasModel events listeners.
*/
enableUserControls() {
if (this.eventsSubscriptions.length === 0) {
const bounds = this.canvasBoundsContainer.getBoundsHitTest(CanvasElement.ALL_PANES);
const hoverSub = this.canvasInputListener
.observeMouseMove()
.pipe(throttleTime(100, animationFrameScheduler, { trailing: true }))
.subscribe(point => this.eventHandler(point, 'hover'));
const touchStartSub = this.canvasInputListener
.observeTouchStart()
.pipe(map(() => this.canvasInputListener.currentPoint))
.subscribe(point => this.eventHandler(point, 'touchstart'));
const clickSub = merge(this.canvasInputListener.observeMouseDown(bounds),
// should probably be deleted, touch event is a separate event and sometimes it doesn't work while being a part of click event
this.canvasInputListener.observeTouchStart().pipe(map(() => this.canvasInputListener.currentPoint))).subscribe(point => this.eventHandler(point, 'mousedown'));
const mouseUpSub = merge(this.canvasInputListener.observeMouseUp(bounds),
// should probably be deleted, touch event is a separate event and sometimes it doesn't work while being a part of click event
this.canvasInputListener
.observeTouchEndDocument()
.pipe(map(() => this.canvasInputListener.currentPoint))).subscribe(point => this.eventHandler(point, 'mouseup'));
const dblClickSub = this.canvasInputListener
.observeDbClick(bounds)
.subscribe(point => this.eventHandler(point, 'dblclick'));
const rightClickSub = this.canvasInputListener
.observeContextMenu(bounds)
.pipe(map(() => (Object.assign({}, this.canvasInputListener.currentPoint))))
.subscribe(point => {
this.eventHandler(point, 'contextmenu');
});
const zoomSub = this.canvasInputListener
.observeWheel(bounds)
.subscribe(point => setTimeout(() => this.eventHandler(point, 'zoom'), 0));
this.eventsSubscriptions.push(hoverSub, clickSub, dblClickSub, rightClickSub, zoomSub, touchStartSub, mouseUpSub);
}
}
/**
* Disables HitTestCanvasModel events listeners.
*/
disableUserControls() {
this.eventsSubscriptions.forEach(sub => sub.unsubscribe());
this.eventsSubscriptions = [];
}
/**
* Adds a new subscriber to the list of hit test subscribers.
* @param {HitTestSubscriber<unknown>} subscriber - The subscriber to be added.
* @returns {void}
*/
addSubscriber(subscriber) {
this.hitTestSubscribers.push(subscriber);
}
/**
* Removes a subscriber from the list of hit test subscribers.
*
* @param {HitTestSubscriber<unknown>} subscriber - The subscriber to be removed.
* @returns {void}
*/
removeSubscriber(subscriber) {
this.hitTestSubscribers = this.hitTestSubscribers.filter(sub => sub === subscriber);
}
/**
* Converts a number to a hexadecimal color code.
* @param {number} id - The number to be converted.
* @returns {string} - The hexadecimal color code.
*/
idToColor(id) {
const hex = (id * bigPrimeNumber).toString(16);
return '#000000'.substr(0, 7 - hex.length) + hex;
}
/**
* This function takes a number representing a color and returns the corresponding ID by dividing it by a big prime number.
*
* @param {number} color - The number representing the color.
* @returns {number} - The ID corresponding to the color.
*/
colorToId(color) {
return color / bigPrimeNumber;
}
/**
* Observes hovered on element event, provides hovered element model when move in.
*/
observeHoverOnElement() {
return this.hoverSubject.asObservable();
}
/**
* Observes touch start on element event, provides element model.
*/
observeTouchStartOnElement() {
return this.touchStartSubject.asObservable();
}
/**
* Observes dblclicked on element event, provides dblclicked element model.
*/
observeDblClickOnElement() {
return this.dblClickSubject.asObservable();
}
/**
* Observes rightclicked on element event, provides rightclicked element model.
*/
observeRightClickOnElement() {
return this.rightClickSubject.asObservable();
}
/**
* Retrieves the pixel data at the specified coordinates.
*
* @private
* @param {number} x - The x-coordinate of the pixel.
* @param {number} y - The y-coordinate of the pixel.
* @returns {Uint8ClampedArray} - The pixel data at the specified coordinates.
*/
getPixel(x, y) {
const dpr = window.devicePixelRatio;
// it's heavy operation, so use cached value if possible
if (this.prevAnimationFrameId !== animationFrameId) {
this.curImgData = this.ctx.getImageData(x * dpr, y * dpr, 1, 1).data;
this.prevAnimationFrameId = animationFrameId;
}
return this.curImgData;
}
/**
* Resolves ht model based on the provided point
* @param point - The point for which to resolve model
*/
resolveModel(point) {
const data = this.getPixel(point.x, point.y);
const id = this.colorToId(data[0] * 65536 + data[1] * 256 + data[2]);
const idNumber = Number(id);
const [subscriberToHit] = sortSubscribers(this.hitTestSubscribers, idNumber);
const model = subscriberToHit === null || subscriberToHit === void 0 ? void 0 : subscriberToHit.lookup(id);
return model;
}
/**
* Resolves cursor type based on the provided point
* @param point - The point for which to resolve cursor type
* @returns - The resolved cursor type, if any
*/
resolveCursor(point) {
var _a;
// do not spend time on resolving cursor if there are no subscribers that need it
if (!this.hitTestSubscribers.some(s => s.resolveCursor !== undefined)) {
return undefined;
}
const data = this.getPixel(point.x, point.y);
const id = this.colorToId(data[0] * 65536 + data[1] * 256 + data[2]);
const idNumber = Number(id);
const [subscriberToHit] = sortSubscribers(this.hitTestSubscribers, idNumber);
const model = subscriberToHit === null || subscriberToHit === void 0 ? void 0 : subscriberToHit.lookup(id);
return (_a = subscriberToHit === null || subscriberToHit === void 0 ? void 0 : subscriberToHit.resolveCursor) === null || _a === void 0 ? void 0 : _a.call(subscriberToHit, point, model);
}
/**
* Private method that handles hit test events.
* @param {Point} point - The point where the event occurred.
* @param {HitTestEvents} event - The type of event that occurred.
* @returns {void}
*/
eventHandler(point, event) {
var _a, _b, _c, _d, _e, _f, _g;
const data = this.getPixel(point.x, point.y);
const id = this.colorToId(data[0] * 65536 + data[1] * 256 + data[2]);
const idNumber = Number(id);
const [subscriberToHit, restSubs] = sortSubscribers(this.hitTestSubscribers, idNumber);
const model = subscriberToHit === null || subscriberToHit === void 0 ? void 0 : subscriberToHit.lookup(id);
const hitTestEvent = {
point,
model,
};
switch (event) {
case 'mousedown':
model && ((_a = subscriberToHit === null || subscriberToHit === void 0 ? void 0 : subscriberToHit.onMouseDown) === null || _a === void 0 ? void 0 : _a.call(subscriberToHit, model, point));
restSubs.forEach(sub => sub.onMouseDown && sub.onMouseDown(null, point));
break;
case 'mouseup':
model && ((_b = subscriberToHit === null || subscriberToHit === void 0 ? void 0 : subscriberToHit.onMouseUp) === null || _b === void 0 ? void 0 : _b.call(subscriberToHit, model, point));
restSubs.forEach(sub => sub.onMouseUp && sub.onMouseUp(null, point));
break;
case 'hover':
model && ((_c = subscriberToHit === null || subscriberToHit === void 0 ? void 0 : subscriberToHit.onHover) === null || _c === void 0 ? void 0 : _c.call(subscriberToHit, model, point));
restSubs.forEach(sub => sub.onHover && sub.onHover(null, point));
this.hoverSubject.next(hitTestEvent);
break;
case 'touchstart':
model && ((_d = subscriberToHit === null || subscriberToHit === void 0 ? void 0 : subscriberToHit.onTouchStart) === null || _d === void 0 ? void 0 : _d.call(subscriberToHit, model, point));
restSubs.forEach(sub => sub.onTouchStart && sub.onTouchStart(null, point));
this.touchStartSubject.next(hitTestEvent);
break;
case 'dblclick':
model && ((_e = subscriberToHit === null || subscriberToHit === void 0 ? void 0 : subscriberToHit.onDblClick) === null || _e === void 0 ? void 0 : _e.call(subscriberToHit, model, point));
this.dblClickSubject.next(hitTestEvent);
break;
case 'contextmenu':
model && ((_f = subscriberToHit === null || subscriberToHit === void 0 ? void 0 : subscriberToHit.onRightClick) === null || _f === void 0 ? void 0 : _f.call(subscriberToHit, model, point));
this.rightClickSubject.next(hitTestEvent);
break;
case 'zoom':
model && ((_g = subscriberToHit === null || subscriberToHit === void 0 ? void 0 : subscriberToHit.onZoom) === null || _g === void 0 ? void 0 : _g.call(subscriberToHit, model, point));
restSubs.forEach(sub => sub.onZoom && sub.onZoom(null, point));
break;
default:
break;
}
}
}
const sortSubscribers = (subs, id) => {
let mainSubscriber = undefined;
const restSubs = [];
subs.forEach(sub => {
const [start, end] = sub.getIdRange();
if (id >= start && id <= end) {
mainSubscriber = sub;
}
else {
restSubs.push(sub);
}
});
return [mainSubscriber, restSubs];
};