@devexperts/dxcharts-lite
Version:
463 lines (462 loc) • 20 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 { 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();
}
}