UNPKG

@devexperts/dxcharts-lite

Version:
939 lines (938 loc) 42.5 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 { BehaviorSubject, Subject } from 'rxjs'; import { distinctUntilChanged, map } from 'rxjs/operators'; import { arrayCompare, arrayRemove2, flat, moveInArrayMutable, reorderArray } from '../utils/array.utils'; import { isMobile } from '../utils/device/browser.utils'; import { calcTimeLabelBounds } from '../components/navigation_map/navigation-map.model'; import { calculateSymbolHeight } from '../utils/canvas/canvas-font-measure-tool.utils'; import { YAxisBoundsContainer } from './y-axis-bounds.container'; export const CHART_UUID = 'CHART'; export class CanvasElement { } CanvasElement.CANVAS = 'CANVAS'; CanvasElement.N_MAP = 'N_MAP'; CanvasElement.X_AXIS = 'X_AXIS'; CanvasElement.N_MAP_KNOT_L = 'N_MAP_KNOT_L'; CanvasElement.N_MAP_KNOT_R = 'N_MAP_KNOT_R'; CanvasElement.N_MAP_BTN_L = 'N_MAP_BTN_L'; CanvasElement.N_MAP_BTN_R = 'N_MAP_BTN_R'; CanvasElement.N_MAP_SLIDER_WINDOW = 'N_MAP_SLIDER_WINDOW'; CanvasElement.N_MAP_CHART = 'N_MAP_CHART'; CanvasElement.N_MAP_LABEL_R = 'N_MAP_LABEL_R'; CanvasElement.N_MAP_LABEL_L = 'N_MAP_LABEL_L'; CanvasElement.PANE_UUID = (uuid) => 'PANE_' + uuid; CanvasElement.PANE_UUID_Y_AXIS = (uuid, idx = 0) => 'PANE_' + uuid + '_Y_AXIS_' + idx; CanvasElement.PANE_UUID_RESIZER = (uuid) => 'PANE_' + uuid + '_RESIZER'; CanvasElement.ALL_PANES = 'ALL_PANES'; CanvasElement.CHART_WITH_Y_AXIS = 'CHART_WITH_Y_AXIS'; CanvasElement.EVENTS = 'EVENTS'; /** * @returns pane bounds for the main chart */ CanvasElement.CHART = CanvasElement.PANE_UUID(CHART_UUID); /** * @returns y-axis bounds for the main chart */ CanvasElement.Y_AXIS = CanvasElement.PANE_UUID_Y_AXIS(CHART_UUID); export const DEFAULT_BOUNDS = { x: 0, y: 0, pageX: 0, pageY: 0, width: 0, height: 0 }; export const DEFAULT_MIN_PANE_HEIGHT = 20; const N_MAP_H = 35; const N_MAP_BUTTON_W = 15; const KNOTS_W_MOBILE_MULTIPLIER = 1.5; const N_MAP_KNOT_W = isMobile() ? 8 * KNOTS_W_MOBILE_MULTIPLIER : 8; // additional x axis height padding for mobiles, used for better usability on mobile devices export const X_AXIS_MOBILE_PADDING = isMobile() ? 5 : 0; /** * we need to check that: heightRatios - 1 < 0.000001 after calculations between decimals */ const PRECISION_DIFFERENCE = 0.000001; /** * This component listens EVENT_DRAW and recalculates bounds of canvas chart elements. * {@link getBounds} method will always give actual placement of element you want. * * NOTE: this class was designed exclusively for {@link getBounds} method * please think twice when adding additional logic here (does it affect element bounds? or it's smth else) */ export class CanvasBoundsContainer { get graphsHeightRatio() { return this._graphsHeightRatio; } constructor(config, eventBus, canvasModel, formatterFactory, chartResizeHandler) { this.config = config; this.eventBus = eventBus; this.canvasModel = canvasModel; this.formatterFactory = formatterFactory; // holds all canvas element bounds this.bounds = {}; // position of canvas on the whole page this.canvasOnPageLocation = { x: 0, y: 0, pageX: 0, pageY: 0, width: 0, height: 0 }; // holds ordered "top to bottom" array of panes UUID's (studies in past) this.panesOrder = []; this.panesOrderChangedSubject = new Subject(); this.paneMovedSubject = new Subject(); this.paneVisibilityChangedSubject = new Subject(); // both will be calculated based on font/content size this.xAxisHeight = undefined; this.yAxisWidths = { right: [0], left: [0], }; this.leftRatio = 0; this.rightRatio = 0; this.boundsChangedSubject = new Subject(); this.barResizerChangedSubject = new Subject(); this._graphsHeightRatio = { [CHART_UUID]: 1 }; this.graphsHeightRatioChangedSubject = new Subject(); this.boundsChangedSubscriptions = {}; chartResizeHandler.canvasResized.subscribe(bcr => { let calculatedBCR = bcr; if (!calculatedBCR) { calculatedBCR = this.canvasModel.canvas.getBoundingClientRect(); } this.updateCanvasOnPageLocation(calculatedBCR); this.recalculateBounds(); }); this.yAxisBoundsContainer = new YAxisBoundsContainer(this.config, this.canvasModel); } updateYAxisWidths() { const widths = this.yAxisBoundsContainer.getYAxisWidths(); this.setYAxisWidths(widths); } /** * Adds a pane to the list of panes and recalculates the height ratios of all panes. * @param {string} uuid - The unique identifier of the pane to be added. * @param {number} [order] - The order in which the pane should be added. If not provided, the pane will be added to the end of the list. */ addPaneBounds(uuid, order) { if (this.panesOrder.indexOf(uuid) === -1) { this.panesOrder.push(uuid); if (order !== undefined) { const idx = this.panesOrder.indexOf(uuid); moveInArrayMutable(this.panesOrder, idx, order); } this.recalculatePanesHeightRatios(); this.panesOrderChangedSubject.next(this.panesOrder); } } /** * Overrides the height ratios of the chart with the provided height ratios. * @param {Record<string, number>} heightRatios - An object containing the height ratios to be set. * @returns {void} * @throws {Error} If the sum of the height ratios is not equal to 1. */ overrideChartHeightRatios(heightRatios) { const resultRatio = Object.assign(Object.assign({}, this.graphsHeightRatio), heightRatios); const ratioSum = Object.values(resultRatio).reduce((sum, ratio) => sum + ratio, 0); if (Math.abs(ratioSum - 1) < PRECISION_DIFFERENCE) { this._graphsHeightRatio = resultRatio; this.recalculateBounds(); } else { console.error(`Result ratio should be equal 1, but equal ${ratioSum}`); } } /** * Moves a pane up in the panesOrder array. * @param {string} uuid - The unique identifier of the pane to be moved. * @returns {void} */ movePaneUp(uuid) { const idx = this.panesOrder.indexOf(uuid); if (idx !== -1) { moveInArrayMutable(this.panesOrder, idx, idx - 1); this.recalculateBounds(); this.eventBus.fireDraw(); this.panesOrderChangedSubject.next(this.panesOrder); this.paneMovedSubject.next(); } } /** * Moves the pane down in the panesOrder array. * @param {string} uuid - The unique identifier of the pane to be moved. * @returns {void} */ movePaneDown(uuid) { const idx = this.panesOrder.indexOf(uuid); if (idx !== -1) { moveInArrayMutable(this.panesOrder, idx, idx + 1); this.recalculateBounds(); this.eventBus.fireDraw(); this.panesOrderChangedSubject.next(this.panesOrder); this.paneMovedSubject.next(); } } /** * Reorders current panes according to newPanesOrder * if element in newPanesOrder doesn't exist in real panes order - it will ignored * Example: * panesOrder: ['1', '2', '3'] * newPanesOrder: ['3', '1'] * result: ['3', '2', '1'] */ reorderPanes(newPanesOrder) { this.panesOrder = reorderArray(this.panesOrder, newPanesOrder); this.recalculateBounds(); this.panesOrderChangedSubject.next(this.panesOrder); } hidePaneBounds(uuid) { this.graphsHeightRatio[uuid] = 0; this.recalculatePanesHeightRatios(); this.paneVisibilityChangedSubject.next(); } showPaneBounds(uuid) { if (uuid === CHART_UUID) { const [defaultChartHeightRatio] = getHeightRatios(this.panesOrder.length - 1); this.graphsHeightRatio[uuid] = defaultChartHeightRatio; } else { // when pane is hidden it has ratio of 0 // when we want pane to be visible again we want `recalculatePanesHeightRatios` function // to treat it as a new pane // to do so we need to delete its ratio (which is 0 because it is hidden) from graphsHeightRatio // NOTE: CHART_UUID pane is exception, it is treated differently and should always have some ratio delete this.graphsHeightRatio[uuid]; } this.recalculatePanesHeightRatios(); this.paneVisibilityChangedSubject.next(); } /** * Removes the bounds of a pane with the given uuid from the canvas element. * @param {string} uuid - The uuid of the pane to remove. * @returns {void} */ removePaneBounds(uuid) { arrayRemove2(this.panesOrder, uuid); delete this.graphsHeightRatio[uuid]; delete this.bounds[CanvasElement.PANE_UUID(uuid)]; this.yAxisWidths.left .concat(this.yAxisWidths.right) .forEach((_, idx) => delete this.bounds[CanvasElement.PANE_UUID_Y_AXIS(uuid, idx)]); delete this.bounds[CanvasElement.PANE_UUID_RESIZER(uuid)]; this.recalculatePanesHeightRatios(); this.panesOrderChangedSubject.next(this.panesOrder); } /** * Recalculates the bounds of the chart elements based on the current configuration and canvas size. * The function updates the bounds of the canvas, the main chart, the panes, the y-axis, and the chart with y-axis. * It also updates the navigation map element bounds, all bounds page coordinates, and notifies the bounds subscribers. * @function * @name recalculateBounds * @memberof ChartWidget * @returns {void} */ recalculateBounds() { const canvasW = this.canvasOnPageLocation.width; const canvasH = this.canvasOnPageLocation.height; const paneResizerHeight = this.config.components.paneResizer.height; // whole canvas bounds const canvas = this.getBounds(CanvasElement.CANVAS); canvas.x = 0; canvas.y = 0; canvas.width = canvasW; canvas.height = canvasH; const yAxisWidths = this.yAxisWidths; const nMap = this.getNavMapBounds(canvas); const xAxis = this.getXAxisBounds(nMap, canvas); const chartHeight = canvasH - xAxis.height - nMap.height; const totalYAxisWidthLeft = yAxisWidths.left.reduce((sum, width) => sum + width, 0); const totalYAxisWidthRight = yAxisWidths.right.reduce((sum, width) => sum + width, 0); // main chart const yAxisXLeft = 0; const yAxisXRight = canvas.width - totalYAxisWidthRight; const paneXStart = yAxisXLeft + totalYAxisWidthLeft; const initialY = 0; const chartWidth = canvas.width - totalYAxisWidthLeft - totalYAxisWidthRight; let nextY = initialY; // panes const firstVisiblePaneIdx = this.panesOrder.findIndex(uuid => this.graphsHeightRatio[uuid] > 0); this.panesOrder.forEach((uuid, index) => { var _a; const paneHeightRatio = this.graphsHeightRatio[this.panesOrder[index]]; // hide resizer for the first visible pane const resizerUUID = CanvasElement.PANE_UUID_RESIZER(uuid); const paneUUID = CanvasElement.PANE_UUID(uuid); const resizerVisible = this.config.components.paneResizer.visible && index > firstVisiblePaneIdx && paneHeightRatio > 0; if (resizerVisible) { upsertBounds(this.bounds, resizerUUID, 0, nextY, canvas.width, paneResizerHeight, this.canvasOnPageLocation); } else { upsertBounds(this.bounds, resizerUUID, 0, 0, 0, 0, this.canvasOnPageLocation); } const paneYStart = nextY + (resizerVisible ? paneResizerHeight : 0); const paneBounds = upsertBounds(this.bounds, paneUUID, paneXStart, paneYStart, chartWidth, chartHeight * paneHeightRatio - (resizerVisible ? paneResizerHeight : 0), this.canvasOnPageLocation); // y axis if (this.config.components.yAxis.visible) { const extents = this.yAxisBoundsContainer.extentsOrder.get(uuid); if (extents === undefined) { return; } const height = chartHeight * paneHeightRatio - (resizerVisible ? paneResizerHeight : 0); let startXLeft = paneXStart - ((_a = yAxisWidths.left[0]) !== null && _a !== void 0 ? _a : 0); // we need to provide right to left order on the left y-axis side let startXRight = yAxisXRight; extents.left.forEach((extentIdx, i) => { var _a; upsertBounds(this.bounds, CanvasElement.PANE_UUID_Y_AXIS(uuid, extentIdx), startXLeft, paneYStart, yAxisWidths.left[i], height, this.canvasOnPageLocation); startXLeft -= (_a = yAxisWidths.left[i + 1]) !== null && _a !== void 0 ? _a : 0; }); extents.right.forEach((extentIdx, i) => { upsertBounds(this.bounds, CanvasElement.PANE_UUID_Y_AXIS(uuid, extentIdx), startXRight, paneYStart, yAxisWidths.right[i], height, this.canvasOnPageLocation); startXRight += yAxisWidths.right[i]; }); } else { upsertBounds(this.bounds, CanvasElement.PANE_UUID_Y_AXIS(uuid), 0, 0, 0, 0, this.canvasOnPageLocation); } nextY = paneBounds.y + paneBounds.height; }); const allPanesBounds = this.getBounds(CanvasElement.ALL_PANES); allPanesBounds.x = paneXStart; allPanesBounds.y = initialY; allPanesBounds.width = chartWidth; allPanesBounds.height = nextY; // chart with Y axis const chartWithYAxis = this.getBounds(CanvasElement.CHART_WITH_Y_AXIS); const chartBounds = this.getBounds(CanvasElement.CHART); this.getEventsBounds(chartBounds); this.copyBounds(chartBounds, chartWithYAxis); chartWithYAxis.width = canvas.width; this.recalculateNavigationMapElementBounds(); this.updateAllBoundsPageCoordinates(); this.notifyBoundsSubscribers(); } /** * Updates the canvasOnPageLocation property with the provided PickedDOMRect object. * @param {PickedDOMRect} bcr - The PickedDOMRect object containing the new values for x, y, width, and height. * @private */ updateCanvasOnPageLocation(bcr) { this.canvasOnPageLocation = Object.assign(Object.assign({}, this.canvasOnPageLocation), { x: bcr.x, y: bcr.y, width: bcr.width, height: bcr.height }); } /** * Updates the page coordinates of all bounds. * @private */ updateAllBoundsPageCoordinates() { for (const name of Object.keys(this.bounds)) { const bound = this.bounds[name]; bound.pageX = bound.x + this.canvasOnPageLocation.x; bound.pageY = bound.y + this.canvasOnPageLocation.y; } } /** * Returns the bounds of the events component. * @private * @param {Bounds} chartPane - The bounds of the chart pane. * @returns {Bounds} - The bounds of the events component. */ getEventsBounds(chartPane) { const events = this.getBounds(CanvasElement.EVENTS); if (this.config.components.events.visible) { events.x = 0; events.y = chartPane.y + chartPane.height - this.config.components.events.height; events.width = chartPane.width; events.height = this.config.components.events.height; } else { this.applyDefaultBounds(events); } return events; } /** * Returns the bounds of the navigation map element. * @param {Bounds} canvas - The bounds of the canvas element. * @returns {Bounds} - The bounds of the navigation map element. * @private */ getNavMapBounds(canvas) { const nMap = this.getBounds(CanvasElement.N_MAP); if (this.config.components.navigationMap.visible) { nMap.x = 0; nMap.y = canvas.height - N_MAP_H; nMap.width = canvas.width; nMap.height = N_MAP_H; } else { this.applyDefaultBounds(nMap); } return nMap; } /** * Returns the bounds of the X axis based on the provided parameters. * @private * @param {Bounds} nMap - The bounds of the nMap. * @param {Bounds} canvas - The bounds of the canvas. * @returns {Bounds} - The bounds of the X axis. */ getXAxisBounds(nMap, canvas) { const xAxis = this.getBounds(CanvasElement.X_AXIS); const isSingleYAxis = this.yAxisWidths.left.length + this.yAxisWidths.right.length === 1; const leftOffset = isSingleYAxis ? 0 : this.yAxisWidths.left.reduce((acc, width) => (acc += width), 0); const rightOffset = isSingleYAxis ? 0 : this.yAxisWidths.right.reduce((acc, width) => (acc += width), 0); if (this.config.components.xAxis.visible) { const xAxisHeight = this.getXAxisHeight(); xAxis.x = leftOffset; xAxis.y = canvas.height - xAxisHeight - nMap.height; xAxis.width = canvas.width - rightOffset; xAxis.height = xAxisHeight; } else { this.applyDefaultBounds(xAxis); } return xAxis; } /** * Calculates the height of the X axis based on the font size, font family and padding values. * If the height has already been calculated, it returns the cached value. * @returns {number} The height of the X axis. */ getXAxisHeight() { var _a, _b; if (!this.xAxisHeight) { const font = this.config.components.xAxis.fontSize + 'px ' + this.config.components.xAxis.fontFamily; const fontHeight = calculateSymbolHeight(font, this.canvasModel.ctx); this.xAxisHeight = fontHeight + ((_a = this.config.components.xAxis.padding.top) !== null && _a !== void 0 ? _a : 0) + ((_b = this.config.components.xAxis.padding.bottom) !== null && _b !== void 0 ? _b : 0) + X_AXIS_MOBILE_PADDING; } return this.xAxisHeight; } /** * Sets the width of the Y axis. * * @param {YAxisWidths} yAxisWidths - The width of the Y axis. * @returns {void} */ setYAxisWidths(yAxisWidths) { if (!arrayCompare(this.yAxisWidths.left, yAxisWidths.left) || !arrayCompare(this.yAxisWidths.right, yAxisWidths.right)) { this.yAxisWidths = yAxisWidths; this.recalculateBounds(); } } /** * * Sets the height of the X axis. * @param {number} xAxisHeight - The height of the X axis. * @returns {void} */ setXAxisHeight(xAxisHeight) { if (xAxisHeight !== this.xAxisHeight) { this.xAxisHeight = xAxisHeight; this.recalculateBounds(); } } /** * Sets the order of the panes. * @param {string[]} panesOrder - An array of strings representing the order of the panes. * @returns {void} */ setPanesOrder(panesOrder) { this.panesOrder = panesOrder; this.recalculateBounds(); } /** * Recalculates the height ratios of the panes by calling the calculateGraphsHeightRatios() and recalculateBounds() methods. */ recalculatePanesHeightRatios() { this.calculateGraphsHeightRatios(); this.recalculateBounds(); } /** * Calculates the height ratios of the graphs in the chart. * It first gets the height ratio of the main chart and then calculates the height ratios of the other graphs. * It then calculates the free space available for the other graphs and distributes it among them based on their previous height ratios. * If there are new graphs added, it calculates their height ratios based on the number of new graphs and the total number of graphs. * @private */ calculateGraphsHeightRatios() { let chartRatio = this.graphsHeightRatio[CHART_UUID]; // NOTE: pec stands for panesExceptMainChart const pec = []; pec.push(...this.panesOrder.filter(p => p !== CHART_UUID)); const pecRatios = pec.map(uuid => this.graphsHeightRatio[uuid] === undefined ? undefined : this.graphsHeightRatio[uuid]); // we should count only visible panes, to escape wheight distribution for hidden panes const visiblePecRatios = pecRatios.filter(ratio => ratio !== 0); const visiblePecNumber = visiblePecRatios.length; // we don't count as an old PEC panes that are not visible, because they don't whey in the final result const oldPecNumber = visiblePecRatios.filter(ratio => ratio !== undefined).length; // if ratio in undefined for a given pane it means that it's a new pane const newPecNumber = pecRatios.filter(ratio => ratio === undefined).length; const paneIsAdded = newPecNumber > 0; let freeRatioForPec = 0; let freeRatio = 0; let ratioForOldPec = 1; let ratioForNewPec = 0; if (paneIsAdded) { [ratioForOldPec, ratioForNewPec] = getHeightRatios(visiblePecNumber); } //#region chart height ratio logic if (!this.graphsHeightRatio[CHART_UUID] || this.graphsHeightRatio[CHART_UUID] === 0) { chartRatio = 0; } if (this.graphsHeightRatio[CHART_UUID] && this.graphsHeightRatio[CHART_UUID] !== 0) { if (paneIsAdded) { chartRatio = 1 * ratioForOldPec; } else { // pec ratio values before recalculating and new chart ratio const currentHeightRatioValues = Object.values(this.graphsHeightRatio).reduce((prev, curr) => (curr += prev)); // chart is hidden and one of the pec is removed // since the chart height ratio is new, the currentHeightRatioValues could be > 1, it happens if make chart visible again if (currentHeightRatioValues < 1 && this.graphsHeightRatio[CHART_UUID] !== 0) { chartRatio += 1 - currentHeightRatioValues; } } } //#endregion // this means we should keep in mind only new panes if (oldPecNumber === 0) { chartRatio = 1 - ratioForNewPec * newPecNumber; } freeRatio = 1 - chartRatio - ratioForNewPec * newPecNumber; visiblePecRatios.forEach(ratio => { if (ratio) { freeRatio -= ratio * ratioForOldPec; } }); // || 1 to escape division by zero // because there's might be no visible panes except CHART freeRatioForPec = freeRatio / (visiblePecNumber || 1); // distribute left free ratio between new and old panes const proportions = pecRatios.map(ratio => { // if ratio === 0 it means, that it's hidden if (ratio === 0) { return ratio; } if (!ratio) { return ratioForNewPec + freeRatioForPec; } return ratio * ratioForOldPec + freeRatioForPec; }); this._graphsHeightRatio = {}; this.graphsHeightRatio[CHART_UUID] = chartRatio; proportions.forEach((ratio, index) => { const name = pec[index]; this.graphsHeightRatio[name] = ratio; }); } /** * Recalculates the bounds of the navigation map elements based on the current configuration and viewport. * If the navigation map is visible, it calculates the bounds of the following elements: * - Navigation map * - Time labels * - Left and right buttons * - Knots * - Slider * - Chart * * The bounds of each element are calculated based on the current viewport and configuration values. * If the time labels are visible, it calculates the width of the left and right time labels based on the first and last visible candle timestamps. * The width of the time labels is used to calculate the position and size of the left and right buttons, as well as the position of the knots. * The position and size of the knots are calculated based on the left and right ratios, which represent the position of the knots relative to the left and right buttons. * The position and size of the slider and chart are calculated based on the position and size of the knots and buttons. */ recalculateNavigationMapElementBounds() { var _a, _b, _c, _d; if (!this.config.components.navigationMap.visible) { return; } const nMap = this.getBounds(CanvasElement.N_MAP); const { height, width } = this.config.components.navigationMap.knots; const knotHeightFromConfig = height !== null && height !== void 0 ? height : 0; const knotWidthFromConfig = isMobile() ? width * KNOTS_W_MOBILE_MULTIPLIER : (width !== null && width !== void 0 ? width : 0); const knotY = !knotHeightFromConfig ? nMap.y : nMap.y + (nMap.height - knotHeightFromConfig) / 2; // time labels const timeLabelsVisible = (_b = (_a = this.config.components.navigationMap) === null || _a === void 0 ? void 0 : _a.timeLabels) === null || _b === void 0 ? void 0 : _b.visible; const calcLabelBounds = (timestamp) => { return calcTimeLabelBounds(this.canvasModel.ctx, timestamp, this.formatterFactory, this.config)[0]; }; const candleSource = flat((_d = (_c = this.mainCandleSeries) === null || _c === void 0 ? void 0 : _c.getSeriesInViewport()) !== null && _d !== void 0 ? _d : []); const leftTimeLabelWidth = timeLabelsVisible && candleSource.length ? calcLabelBounds(candleSource[0].candle.timestamp) : 0; const rightTimeLabelWidth = timeLabelsVisible && candleSource.length ? calcLabelBounds(candleSource[candleSource.length - 1].candle.timestamp) : 0; const timeLabelWidth = Math.max(leftTimeLabelWidth, rightTimeLabelWidth); if (timeLabelsVisible) { const nMapLabelL = this.getBounds(CanvasElement.N_MAP_LABEL_L); nMapLabelL.x = nMap.x; nMapLabelL.y = nMap.y; nMapLabelL.width = timeLabelWidth; nMapLabelL.height = nMap.height; const nMapLabelR = this.getBounds(CanvasElement.N_MAP_LABEL_R); nMapLabelR.x = nMap.x + nMap.width - timeLabelWidth; nMapLabelR.y = nMap.y; nMapLabelR.width = timeLabelWidth; nMapLabelR.height = nMap.height; } // buttons left and right const nMapBtnL = this.getBounds(CanvasElement.N_MAP_BTN_L); nMapBtnL.x = nMap.x + timeLabelWidth; nMapBtnL.y = nMap.y; nMapBtnL.width = N_MAP_BUTTON_W; nMapBtnL.height = nMap.height; const nMapBtnR = this.getBounds(CanvasElement.N_MAP_BTN_R); nMapBtnR.x = nMap.x + nMap.width - N_MAP_BUTTON_W - timeLabelWidth; nMapBtnR.y = nMap.y; nMapBtnR.width = N_MAP_BUTTON_W; nMapBtnR.height = nMap.height; // knots const navMapChartStart = nMapBtnL.x + nMapBtnL.width; const navMapChartWidth = nMapBtnR.x - navMapChartStart; const navMapChartEnd = navMapChartStart + navMapChartWidth; const minSliderW = this.config.components.navigationMap.minSliderWindowWidth; const knotW = knotWidthFromConfig !== null && knotWidthFromConfig !== void 0 ? knotWidthFromConfig : N_MAP_KNOT_W; const knotH = knotHeightFromConfig !== null && knotHeightFromConfig !== void 0 ? knotHeightFromConfig : nMap.height; const minDistanceBetweenKnotsX = knotW + minSliderW; // Left drag button const knotL = this.getBounds(CanvasElement.N_MAP_KNOT_L); knotL.x = navMapChartStart + navMapChartWidth * this.leftRatio; // limit left knot to min distance from right border knotL.x = Math.min(knotL.x, navMapChartEnd - minDistanceBetweenKnotsX); knotL.y = knotY; knotL.width = knotW; knotL.height = knotH; // Right drag button const knotR = this.getBounds(CanvasElement.N_MAP_KNOT_R); knotR.x = navMapChartStart + navMapChartWidth * this.rightRatio - N_MAP_KNOT_W; // limit right knot to min distance from left border knotR.x = Math.max(knotR.x, navMapChartStart + minDistanceBetweenKnotsX); knotR.y = knotY; knotR.width = knotW; knotR.height = knotH; const distanceDiff = minDistanceBetweenKnotsX - (knotR.x - knotL.x); // if distance between knots is less than min distance - move left knot start if (distanceDiff > 0) { knotL.x -= distanceDiff; } // slider const slider = this.getBounds(CanvasElement.N_MAP_SLIDER_WINDOW); slider.x = knotL.x + knotL.width; slider.y = nMap.y; slider.width = knotR.x - slider.x; slider.height = nMap.height; // chart const nMapChart = this.getBounds(CanvasElement.N_MAP_CHART); nMapChart.x = navMapChartStart; nMapChart.y = nMap.y; nMapChart.width = navMapChartWidth; nMapChart.height = nMap.height; } /** * Checks if the volumes are set to be visible and if they should be shown in a separate pane * * @returns {boolean} - Returns true if the volumes are set to be visible and if they should be shown in a separate pane, otherwise returns false */ isVolumesInSeparatePane() { return this.config.components.volumes.visible && this.config.components.volumes.showSeparately; } /** * Gets current canvas element bounds. * @param {string} el - CanvasElement.ELEMENT_NAME * @return {Bounds} bounds of element */ getBounds(el) { let bounds = this.bounds[el]; if (bounds === undefined) { bounds = this.copyOf(DEFAULT_BOUNDS); this.bounds[el] = bounds; } return bounds; } /** * Returns the position of CANVAS on whole page. * Use it to calculate relative coordinates for mouse movement hit test - crosstool for example. */ getCanvasOnPageLocation() { return this.canvasOnPageLocation; } /** * Gets current panes bounds. */ getBoundsPanes() { return this.panesOrder.reduce((acc, uuid) => (Object.assign(Object.assign({}, acc), { [uuid]: this.bounds[CanvasElement.PANE_UUID(uuid)] })), {}); } /** * Gets hit-test fn for canvas element. * @param {string} el - CanvasElement.ELEMENT_NAME * @param {Object} options - An object containing options for the hit test. * @param {number} [options.extensionX=0] - The amount of extension in the x-axis. * @param {number} [options.extensionY=0] - The amount of extension in the y-axis. * @param {boolean} [options.wholePage=false] - Whether to test against the whole page or just the bounds object. * @return {HitBoundsTest} hit-test fn */ getBoundsHitTest(el, options = DEFAULT_HIT_TEST_OPTIONS) { const { extensionX, extensionY, wholePage } = Object.assign(Object.assign({}, DEFAULT_HIT_TEST_OPTIONS), options); if (wholePage) { return (x, y) => { const bounds = this.getBounds(el); const hit = x > bounds.pageX - extensionX && x < bounds.pageX + bounds.width + extensionX && y > bounds.pageY - extensionY && y < bounds.pageY + bounds.height + extensionY; return hit; }; } else { return (x, y) => { const bounds = this.getBounds(el); const hit = x > bounds.x - extensionX && x < bounds.x + bounds.width + extensionX && y > bounds.y - extensionY && y < bounds.y + bounds.height + extensionY; return hit; }; } } /** * Returns a function that tests if a given point is inside a given bounds object. * @param {Bounds} bounds - The bounds object to test against. * @param {Object} options - An object containing options for the hit test. * @param {number} [options.extensionX=0] - The amount of extension in the x-axis. * @param {number} [options.extensionY=0] - The amount of extension in the y-axis. * @param {boolean} [options.wholePage=false] - Whether to test against the whole page or just the bounds object. * @returns {HitBoundsTest} - A function that takes in x and y coordinates and returns true if the point is inside the bounds object. */ static hitTestOf(bounds, options = DEFAULT_HIT_TEST_OPTIONS) { const { extensionX, extensionY, wholePage } = Object.assign(Object.assign({}, DEFAULT_HIT_TEST_OPTIONS), options); if (!wholePage) { return (x, y) => { const hit = x > bounds.x - extensionX && x < bounds.x + bounds.width + extensionX && y > bounds.y - extensionY && y < bounds.y + bounds.height + extensionY; return hit; }; } else { return (x, y) => { const hit = x > bounds.pageX - extensionX && x < bounds.pageX + bounds.width + extensionX && y > bounds.pageY - extensionY && y < bounds.pageY + bounds.height + extensionY; return hit; }; } } /** * This method indicates whether it's possible to render the chart, in particular if its width and height > 0. */ isChartBoundsAvailable() { const canvasBounds = this.getBounds(CanvasElement.CANVAS); return canvasBounds.width > 0 && canvasBounds.height > 0; } isAllBoundsAvailable() { return Object.values(this.bounds).every(el => el.width >= 0 && el.height >= 0); } /** * Resizes a pane vertically. * @param {string} uuid - The unique identifier of the pane. * @param {number} y - The amount of pixels to resize the pane by. * @returns {void} */ resizePaneVertically(uuid, y) { const idx = this.panesOrder.indexOf(uuid); const bounds = this.getBounds(CanvasElement.PANE_UUID_RESIZER(uuid)); this.doResizePaneVertically(idx, bounds.y - y); this.barResizerChangedSubject.next(); } /** * Resizes a pane vertically based on the provided index and delta in pixels. * @param {number} idx - The index of the pane to be resized. * @param {number} yDeltaPixels - The delta in pixels to resize the pane. * @returns {void} */ doResizePaneVertically(idx, yDeltaPixels) { // get prev visible pane index let prevVisiblePaneIdx = idx - 1; const prevPaneUUID = this.panesOrder[prevVisiblePaneIdx]; if (this._graphsHeightRatio[prevPaneUUID] <= 0) { for (let i = 0; i < idx; i++) { if (this._graphsHeightRatio[this.panesOrder[i]] > 0) { prevVisiblePaneIdx = i; } } } const allPanesHeight = this.getBounds(CanvasElement.ALL_PANES).height; const minAllowedPaneHeight = DEFAULT_MIN_PANE_HEIGHT; const resultPaneHeight = allPanesHeight * this.graphsHeightRatio[this.panesOrder[idx]]; const dependResultPaneHeight = allPanesHeight * this.graphsHeightRatio[this.panesOrder[prevVisiblePaneIdx]]; // check if changes fit allowed minimal pane height const fitPane = resultPaneHeight + yDeltaPixels > minAllowedPaneHeight; const fitDependPane = dependResultPaneHeight - yDeltaPixels > minAllowedPaneHeight; if (fitPane && fitDependPane) { // convert pixels to percent const yDeltaPercent = yDeltaPixels / allPanesHeight; this.graphsHeightRatio[this.panesOrder[idx]] += yDeltaPercent; this.graphsHeightRatio[this.panesOrder[prevVisiblePaneIdx]] -= yDeltaPercent; this.recalculateBounds(); } } /** * Notifies all subscribers about bounds changes * @function * @name notifyBoundsSubscribers * @returns {void} */ notifyBoundsSubscribers() { Object.keys(this.boundsChangedSubscriptions).forEach(el => { const subject = this.boundsChangedSubscriptions[el]; subject.next(this.getBounds(el)); }); this.boundsChangedSubject.next(); } /** * Subscribe to element bounds changes. * Multiple subscriptions will share the same subject. * @param el - element name */ observeBoundsChanged(el) { let sub = this.boundsChangedSubscriptions[el]; if (!sub) { sub = new BehaviorSubject(this.getBounds(el)); this.boundsChangedSubscriptions[el] = sub; } return sub.pipe(map(b => this.copyOf(b)), distinctUntilChanged((b1, b2) => this.sameBounds(b1, b2))); } /** * Returns an observable that emits when the bounds of the object change. * @returns {Observable} An observable that emits when the bounds of the object change. */ observeAnyBoundsChanged() { return this.boundsChangedSubject.asObservable(); } /** * Creates a copy of the provided bounds object. * * @private * @param {Bounds} bounds - The bounds object to be copied. * @returns {Bounds} - A new bounds object with the same properties as the original. */ copyOf(bounds) { return Object.assign({}, bounds); } /** * Copies the values of the `from` object to the `to` object. * @param {Bounds} from - The object to copy the values from. * @param {Bounds} to - The object to copy the values to. */ copyBounds(from, to) { to.x = from.x; to.y = from.y; to.width = from.width; to.height = from.height; } /** * Checks if two Bounds objects have the same x, y, width and height values. * @param {Bounds} b1 - The first Bounds object to compare. * @param {Bounds} b2 - The second Bounds object to compare. * @returns {boolean} - Returns true if both Bounds objects have the same x, y, width and height values, otherwise returns false. */ sameBounds(b1, b2) { return b1.x === b2.x && b1.y === b2.y && b1.width === b2.width && b1.height === b2.height; } /** * Applies default bounds to the provided bounds object. * @param {Bounds} bounds - The bounds object to apply default bounds to. * @returns {void} * @private */ applyDefaultBounds(bounds) { this.copyBounds(DEFAULT_BOUNDS, bounds); } /** * Sets the main candle series for the CanvasBoundsContainer. * @param {CandleSeriesModel} candleSeries - The candle series to be set as the main candle series. * @returns {void} * @deprecated This method should be removed as candleSeries is not a part of CanvasBoundsContainer. */ setMainCandleSeries(candleSeries) { this.mainCandleSeries = candleSeries; } /** * Returns the effective width of the Y axis. * * @function * @name getEffectiveYAxisWidth * @returns {number} The effective width of the Y axis. */ getEffectiveYAxisWidth() { const yAxis = this.getBounds(CanvasElement.PANE_UUID_Y_AXIS(CHART_UUID)); return yAxis.width; } /** * Returns the effective width of the chart. * * @function * @returns {number} The effective width of the chart. */ getEffectiveChartWidth() { const chart = this.getBounds(CanvasElement.PANE_UUID(CHART_UUID)); return chart.width; } /** * Returns the effective height of the chart. * * @returns {number} The effective height of the chart. */ getEffectiveChartHeight() { const chart = this.getBounds(CanvasElement.PANE_UUID(CHART_UUID)); return chart.height; } } // paneCounter=chartHeightRatio: 0=1, 1=0.8, 2=0.6, 3=0.5, 4=0.4, 5=0.4 // ratios requirements table: https://confluence.in.devexperts.com/display/UI/Chart+Navigation#ChartNavigation-2Graphsadjustablesizes const DEFAULT_RATIOS = { 0: 1, 1: 0.8, 2: 0.6, 3: 0.5, 4: 0.4, 5: 0.4, 6: 0.4, }; // NOTE: pec stands for panes except main chart export const getHeightRatios = (pecLength) => { var _a; const chartHeightRatio = (_a = DEFAULT_RATIOS[pecLength]) !== null && _a !== void 0 ? _a : 0.4; const singlePecHeightRatio = (1 - chartHeightRatio) / pecLength; return [chartHeightRatio, singlePecHeightRatio]; }; export const isInBounds = (point, bounds) => point.x > bounds.x && point.x < bounds.x + bounds.width && point.y > bounds.y && point.y < bounds.y + bounds.height; export const isInVerticalBounds = (y, bounds) => y > bounds.y && y < bounds.y + bounds.height; const upsertBounds = (storage, uuid, x, y, width, height, canvasOnPageLocation) => { const existing = storage[uuid]; if (existing) { existing.x = x; existing.y = y; existing.pageX = x + canvasOnPageLocation.x; existing.pageY = y + canvasOnPageLocation.y; existing.width = width; existing.height = height; return existing; } const newly = { x, y, pageX: x + canvasOnPageLocation.x, pageY: y + canvasOnPageLocation.y, width, height, }; storage[uuid] = newly; return newly; }; export const limitYToBounds = (y, bounds) => Math.min(Math.max(y, bounds.y), bounds.y + bounds.height); export const DEFAULT_HIT_TEST_OPTIONS = { extensionX: 0, extensionY: 0, wholePage: false, }; export const areBoundsChanged = (prev, next) => { return prev.width === next.width && prev.height === next.height; };