UNPKG

@devexperts/dxcharts-lite

Version:
265 lines (264 loc) 12.9 kB
/* * Copyright (C) 2019 - 2026 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'; export 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; } } } export 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]; };