UNPKG

@devexperts/dxcharts-lite

Version:
463 lines (462 loc) 20 kB
/* * 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 { Subject } from 'rxjs'; import { throttleTime } from 'rxjs/operators'; import { animationFrameScheduler } from 'rxjs'; import { cloneUnsafe } from '../utils/object.utils'; import { AutoScaleViewportSubModel } from './scaling/auto-scale.model'; import { changeXToKeepRatio, changeYToKeepRatio } from './scaling/lock-ratio.model'; import { moveXStart, moveYStart } from './scaling/move-chart.functions'; import { ViewportModel, calculateZoom, compareStates, } from './scaling/viewport.model'; import { zoomXToEndViewportCalculator, zoomXToPercentViewportCalculator } from './scaling/x-zooming.functions'; import { VIEWPORT_ANIMATION_ID } from '../animation/canvas-animation'; import { startViewportModelAnimation, startViewportModelAnimationSafari } from '../animation/viewport-model-animation'; import { ONE_FRAME_MS } from '../utils/numeric-constants.utils'; import { isSafari } from '../utils/device/touchpad.utils'; const autoScaleZoomSubject = new Subject(); const autoScalePanSubject = new Subject(); export const getDefaultHighLowWithIndex = () => ({ high: Number.NEGATIVE_INFINITY, low: Number.POSITIVE_INFINITY, highIdx: 0, lowIdx: 0, }); /** * The ScaleModel class represents the state of a chart's scale, including the current viewport, zoom level, and auto-scaling settings. * It extends the ViewportModel class, which provides the underlying implementation for handling viewports and zoom levels. * Controls current visible CHART viewport. * Has additional logic: * - auto-scale * - locked scale * - zooming functions * - history */ export class ScaleModel extends ViewportModel { constructor(config, getBounds, canvasAnimation) { super(); this.config = config; this.getBounds = getBounds; this.canvasAnimation = canvasAnimation; this.scaleInversedSubject = new Subject(); // y-axis component needs this subject in order to halt prev animation if axis type is percent this.beforeStartAnimationSubject = new Subject(); this.zoomReached = { zoomIn: false, zoomOut: false }; // TODO rework, make a new history based on units this.history = []; this.xConstraints = []; this.autoScaleZoomSubscription = null; this.autoScalePanSubscription = null; this.scalePostProcessor = (initialState, state) => { // for now <s>reduceRight<s/> reduce bcs ChartModel#getZoomConstrait should be invoked first // if we will need more complex order handling -> add some managing return this.xConstraints.reduce((acc, cur) => cur(initialState, acc), state); }; this.state = cloneUnsafe(config.scale); this.autoScaleModel = new AutoScaleViewportSubModel(this); this.offsets = this.config.components.offsets; this.initAutoScaleThrottling(); } doActivate() { super.doActivate(); this.scaleInversedSubject = new Subject(); this.beforeStartAnimationSubject = new Subject(); this.zoomReached = this.calculateZoomReached(this.export().zoomX); this.addRxSubscription(this.scaleInversedSubject.subscribe(() => { this.fireChanged(); })); } doDeactivate() { // Clean up auto-scale throttling subscriptions to prevent memory leaks if (this.autoScaleZoomSubscription) { this.autoScaleZoomSubscription.unsubscribe(); this.autoScaleZoomSubscription = null; } if (this.autoScalePanSubscription) { this.autoScalePanSubscription.unsubscribe(); this.autoScalePanSubscription = null; } super.doDeactivate(); this.scaleInversedSubject.complete(); this.beforeStartAnimationSubject.complete(); } /** * The method adds a new "constraint" to the existing list of x-axis constraints for charting. * The "constraint" is expected to be an object containing information about the constraints, such as the minimum and maximum values for the x-axis. * @param constraint */ addXConstraint(constraint) { this.xConstraints = [...this.xConstraints, constraint]; } /** * The method updates the offsets for the scale model based on the provided "offsets" object. * Note that the method modifies the offsets and triggers an autoscale * @param offsets */ updateOffsets(offsets) { this.offsets = Object.assign(Object.assign({}, this.offsets), offsets); this.doAutoScale(true); } /** * @returns current offsets */ getOffsets() { return this.offsets; } /** * Zooms the X axis of the chart to a specified percentage of the viewport. * @param viewportPercent The percentage of the viewport width to zoom to. * @param zoomIn Whether to zoom in or out. * @param forceNoAnimation Whether to skip animation. * @param zoomSensitivity The sensitivity of the zoom. */ zoomXToPercent(viewportPercent, zoomIn, forceNoAnimation = false, zoomSensitivity) { const disabledAnimations = this.config.scale.disableAnimations || forceNoAnimation; if (disabledAnimations) { this.haltAnimation(); } this.beforeStartAnimationSubject.next(); const state = this.export(); zoomXToPercentViewportCalculator(this, state, viewportPercent, zoomSensitivity, zoomIn); this.zoomXTo(state, zoomIn, disabledAnimations); } /** * Zooms the X axis of the chart relativly to the end of the data range. * @param zoomIn - If true, the chart will be zoomed in. If false, the chart will be zoomed out. * @param zoomSensitivity - The sensitivity of the zoom. Default value is taken from the configuration object. */ zoomXToEnd(zoomIn, zoomSensitivity) { if (this.config.scale.disableAnimations) { this.haltAnimation(); } this.beforeStartAnimationSubject.next(); const state = this.export(); zoomXToEndViewportCalculator(this, state, zoomSensitivity, zoomIn); this.zoomXTo(state, zoomIn, this.config.scale.disableAnimations); } haltAnimation() { var _a; // Stop current animation if it exists if ((_a = this.currentAnimation) === null || _a === void 0 ? void 0 : _a.animationInProgress) { this.currentAnimation.finishAnimation(); // Clear the current animation reference this.currentAnimation = undefined; // CRITICAL: Force stop all viewport animations in the animation system to prevent accumulation this.canvasAnimation.forceStopAnimation(VIEWPORT_ANIMATION_ID); this.doAutoScale(); } } zoomXTo(state, zoomIn, forceNoAnimation) { var _a; const initialStateCopy = this.export(); const constrainedState = this.scalePostProcessor(initialStateCopy, state); this.zoomReached = this.calculateZoomReached(constrainedState.zoomX, zoomIn); if (this.zoomReached.zoomIn || this.zoomReached.zoomOut) { return; } if (this.state.lockPriceToBarRatio) { changeYToKeepRatio(initialStateCopy, constrainedState); } if (this.state.auto) { this.autoScaleModel.doAutoYScale(constrainedState); } if (forceNoAnimation) { this.apply(constrainedState); } else { (_a = this.currentAnimation) === null || _a === void 0 ? void 0 : _a.tick(); startViewportModelAnimation(this.canvasAnimation, this, constrainedState); } } calculateZoomReached(zoomX, zoomIn = true) { const chartWidth = this.getBounds().width; const delta = 0.001; // zoom values are very precise and should be compared with some precision delta if (chartWidth > 0) { const maxZoomIn = calculateZoom(this.config.components.chart.minCandles, chartWidth); const maxZoomInReached = zoomX !== maxZoomIn && zoomX - maxZoomIn <= delta; // max zoom in reached and trying to zoom in further const maxZoomInDisabled = maxZoomInReached && zoomIn; const maxZoomOut = calculateZoom(chartWidth / this.config.components.chart.minWidth, chartWidth); const maxZoomOutReached = zoomX - maxZoomOut >= delta; // max zoom out reached and trying to zoom out further const maxZoomOutDisabled = maxZoomOutReached && !zoomIn; return { zoomIn: maxZoomInDisabled, zoomOut: maxZoomOutDisabled }; } return { zoomIn: false, zoomOut: false }; } /** * Moves the viewport to exactly xStart..xEnd place. * (you need to fire DRAW event after this) * @param xStart - viewport start in units * @param xEnd - viewport end in units * @param fireChanged * @param forceNoAutoScale - force NOT apply auto-scaling (for lazy loading) */ setXScale(xStart, xEnd, forceNoAnimation = true) { var _a; const initialState = this.export(); const zoomX = this.calculateZoomX(xStart, xEnd); if (initialState.xStart === xStart && initialState.xEnd === xEnd && initialState.zoomX > 0) { return; } const state = Object.assign(Object.assign({}, initialState), { zoomX, xStart, xEnd }); const constrainedState = this.scalePostProcessor(initialState, state); const zoomIn = constrainedState.xEnd - constrainedState.xStart < initialState.xEnd - initialState.xStart; this.zoomReached = this.calculateZoomReached(zoomX, zoomIn); if (this.zoomReached.zoomIn || this.zoomReached.zoomOut) { return; } if (this.state.lockPriceToBarRatio) { changeYToKeepRatio(initialState, constrainedState); } if (this.state.auto) { this.autoScaleModel.doAutoYScale(constrainedState); } if (forceNoAnimation || this.config.scale.disableAnimations) { this.haltAnimation(); this.apply(constrainedState); } else { (_a = this.currentAnimation) === null || _a === void 0 ? void 0 : _a.tick(); // Big profit in performance for safari isSafari ? startViewportModelAnimationSafari(this.canvasAnimation, this, constrainedState, this.state.auto ? this.autoScaleModel : undefined) : startViewportModelAnimation(this.canvasAnimation, this, constrainedState); } } setXScaleWithoutYScale(visualCandleSource) { var _a; const initialStateCopy = this.export(); const vCandles = visualCandleSource.slice(Math.max(visualCandleSource.length - this.state.defaultViewportItems, 0)); const endCandle = vCandles[vCandles.length - 1]; const xEnd = endCandle.startUnit + endCandle.width + this.offsets.right; const xStart = xEnd - (this.getBounds().width * this.zoomX); const state = Object.assign(Object.assign({}, initialStateCopy), { xStart, xEnd }); const constrainedState = this.scalePostProcessor(initialStateCopy, state); if (this.state.auto) { this.autoScaleModel.doAutoYScale(constrainedState); } (_a = this.currentAnimation) === null || _a === void 0 ? void 0 : _a.tick(); startViewportModelAnimation(this.canvasAnimation, this, constrainedState); } setYScale(yStart, yEnd, fire = false) { const initialState = this.export(); if (initialState.yStart === yStart && initialState.yEnd === yEnd && initialState.zoomY > 0) { return; } if (this.state.lockPriceToBarRatio && this.initialViewportValidSubject.getValue()) { this.setLockedYScale(yStart, yEnd, fire, initialState); return; } super.setYScale(yStart, yEnd, fire); const state = this.export(); const constrainedState = this.scalePostProcessor(initialState, state); if (this.state.auto) { this.autoScaleModel.doAutoYScale(constrainedState); } this.apply(constrainedState); } setLockedYScale(yStart, yEnd, fire = false, initialState) { const zoomIn = yEnd < initialState.yEnd; if ((this.zoomReached.zoomOut && zoomIn === false) || (this.zoomReached.zoomIn && zoomIn === true)) { return; } super.setYScale(yStart, yEnd, fire); const state = this.export(); const constrainedState = this.scalePostProcessor(initialState, state); changeXToKeepRatio(initialState, constrainedState); this.zoomReached = this.calculateZoomReached(constrainedState.zoomX, zoomIn); this.apply(constrainedState); this.fireChanged(); } /** * Moves both xStart and xEnd without changing the viewport width (zoom). * Works without animation. * WILL CHANGE the Y axis if scale.auto=true. * @param xStart - starting point in units */ moveXStart(xStart) { const state = this.export(); const initialStateCopy = Object.assign({}, state); this.haltAnimation(); moveXStart(state, xStart); // there we need only candles constraint const constrainedState = this.scalePostProcessor(initialStateCopy, state); if (this.state.auto) { this.autoScaleModel.doAutoYScale(constrainedState); } this.apply(constrainedState); } /** * Moves both yStart and yEnd without changing the viewport height (zoom). * Works without animation. * Will not move viewport if scale.auto=true * @param yStart - starting point in units */ moveYStart(yStart) { this.haltAnimation(); if (this.state.auto) { return; } else { const state = this.export(); moveYStart(state, yStart); this.apply(state); } } /** * Automatically scales the chart to fit the data range. * @param forceApply - If true, the chart will be forcefully auto-scaled even if animation is in progress. */ doAutoScale(forceApply = false) { // dont auto-scale if animation, otherwise - forced or config if ((!this.isViewportAnimationInProgress() && this.state.auto) || forceApply) { const state = this.export(); this.autoScaleModel.doAutoYScale(state); if (!compareStates(state, this.export())) { this.apply(state); } } } /** * Checks if viewport animation is currently in progress. * @returns returns true if viewport animation is in progress, false otherwise. */ isViewportAnimationInProgress() { const animation = this.currentAnimation; return animation === null || animation === void 0 ? void 0 : animation.animationInProgress; } /** * Adds an item to the scale history. * @param item - The item to add to the history. */ pushToHistory(item) { this.history.push(item); } /** * Removes the most recent item from the scale history and returns it. * @returns - The most recent item from the history, or undefined if the history is empty. */ popFromHistory() { return this.history.pop(); } /** * Clears the scale history. */ clearHistory() { this.history = []; } /** * Checks if the X axis bounds are the default values. * @returns if false - it means there are candles and it's possible to do scaling and add drawings */ isDefaultXBounds() { return this.xStart === 0 && this.xEnd === 0; } /** * Checks if the Y axis bounds are the default values. * @returns if false - it means there are candles and it's possible to do scaling and add drawings */ isDefaultYBounds() { return this.yStart === 0 && this.yEnd === 0; } /** * Checks if the scale is ready to be used. * @returns - Returns true if the scale is ready, false otherwise. */ isScaleReady() { return !this.isDefaultXBounds() && !this.isDefaultYBounds(); } /** * Enables or disables auto-scaling of the chart. * @param auto - If true, the chart will be automatically scaled. If false, auto-scaling will be disabled. */ autoScale(auto = true) { // TODO rework, make this a separate feature toggle, describe in docs; this should be a business-logic level if (this.config.components.yAxis.type === 'percent') { this.state.auto = true; } else { this.state.auto = auto; } if (auto) { this.clearHistory(); this.doAutoScale(); } } /** * Sets whether the price-to-bar ratio should be locked or not. * @param value - If true, the price-to-bar ratio will be locked. If false, it will not be locked. */ setLockPriceToBarRatio(value = false) { const { type } = this.config.components.yAxis; // TODO rework, why such logic? same as above, if we have business-logic like this one => make it separate code if (type === 'percent' || type === 'logarithmic') { this.state.lockPriceToBarRatio = false; return; } this.state.lockPriceToBarRatio = value; } initAutoScaleThrottling() { // Throttle zoom auto-scale to ONE_FRAME_MS this.autoScaleZoomSubscription = autoScaleZoomSubject .pipe(throttleTime(ONE_FRAME_MS, animationFrameScheduler, { trailing: true, leading: false })) .subscribe(() => { const state = this.export(); const initialStateCopy = Object.assign({}, state); const constrainedState = this.scalePostProcessor(initialStateCopy, state); this.autoScaleModel.doAutoYScale(constrainedState); }); // Throttle pan auto-scale to 250ms this.autoScalePanSubscription = autoScalePanSubject .pipe(throttleTime(ONE_FRAME_MS, animationFrameScheduler, { trailing: true, leading: false })) .subscribe(() => { const state = this.export(); const initialStateCopy = Object.assign({}, state); const constrainedState = this.scalePostProcessor(initialStateCopy, state); this.autoScaleModel.doAutoYScale(constrainedState); }); } } /** * The SyncedByXScaleModel class extends the ScaleModel class and adds support for synchronization with other ScaleModel instance, so both instances maintain the same X-axis bounds. * This is useful for scenarios where multiple charts need to display the same X-axis data, but with different Y-axis scales. */ export class SyncedByXScaleModel extends ScaleModel { constructor(delegate, config, getBounds, canvasAnimation) { super(config, getBounds, canvasAnimation); this.delegate = delegate; this.config = config; this.getBounds = getBounds; } doActivate() { this.addRxSubscription(this.delegate.xChanged.subscribe(() => this.doAutoScale(this.state.auto))); } get xStart() { return this.delegate.xStart; } set xStart(value) { this.delegate.xStart = value; } get xEnd() { return this.delegate.xEnd; } set xEnd(value) { this.delegate.xEnd = value; } get zoomX() { return this.delegate.zoomX; } set zoomX(value) { this.delegate.zoomX = value; } observeXChanged() { return this.delegate.xChanged; } fireChanged() { this.delegate.changed.next(); this.changed.next(); } }