@devexperts/dxcharts-lite
Version:
366 lines (365 loc) • 17.8 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 { CanvasElement } from '../../canvas/canvas-bounds-container';
import { unitToPixels } from '../../model/scaling/viewport.model';
import { cloneUnsafe, typedEntries_UNSAFE } from '../../utils/object.utils';
import { fakeVisualCandle } from '../chart/fake-visual-candle';
import { parseTimeFormatsFromKey } from './time/parser/time-formats-parser.functions';
import { filterMapGroupedLabelsByCoverUpLevel, getWeightFromTimeFormat, groupLabelsByWeight, overlappingPredicate, } from './time/x-axis-weights.functions';
import { generateWeightsMapForConfig, mapCandlesToWeightedPoints, } from './time/x-axis-weights.generator';
export class XAxisTimeLabelsGenerator {
get labels() {
return this.filterLabelsInViewport(this.getLabelsFromChartType());
}
constructor(eventBus, config, chartModel, scale, timeZoneModel, canvasModel, canvasBoundsContainer) {
this.eventBus = eventBus;
this.config = config;
this.chartModel = chartModel;
this.scale = scale;
this.timeZoneModel = timeZoneModel;
this.canvasModel = canvasModel;
this.canvasBoundsContainer = canvasBoundsContainer;
this.labelsGroupedByWeight = {};
this.levelsCache = {};
this.weightToTimeFormatMatcherArray = [];
this.weightToTimeFormatsDict = {};
this.extendedLabelsFilterConfig = {
minute_1: (coverUpLevel) => coverUpLevel >= 2,
};
/**
* Calculates the cover up level based on the maximum label width and the mean candle width.
* Cover up level is based on maximum label width, which is based on the font size and the maximum format length.
* Used to group labels.
*/
this.calculateCoverUpLevel = () => {
const animation = this.scale.currentAnimation;
const meanCandleWidthInUnits = this.chartModel.mainCandleSeries.meanCandleWidth;
if (Object.getOwnPropertyNames(this.labelsGroupedByWeight).length === 0) {
return -1;
}
// calculate coverUpLevel for target zoomX to prevent extra labels calculations on every animation tick
const meanCandleWidthInPixels = (animation === null || animation === void 0 ? void 0 : animation.animationInProgress)
? unitToPixels(meanCandleWidthInUnits, animation.animationConfig.targetZoomX)
: unitToPixels(meanCandleWidthInUnits, this.scale.zoomX);
if (!isFinite(meanCandleWidthInPixels)) {
return -1;
}
const fontSize = this.config.components.xAxis.fontSize;
const maxFormatLength = Object.values(this.formatsByWeightMap).reduce((max, item) => Math.max(item.length, max), 1);
const maxLabelWidth = fontSize * maxFormatLength;
return Math.round(maxLabelWidth / meanCandleWidthInPixels);
};
this.formatsByWeightMap = config.components.xAxis.formatsForLabelsConfig;
const { weightToTimeFormatsDict, weightToTimeFormatMatcherDict } = generateWeightsMapForConfig(this.formatsByWeightMap);
this.weightToTimeFormatMatcherArray = Object.entries(weightToTimeFormatMatcherDict)
.map(([k, v]) => [parseInt(k, 10), v])
.sort(([a], [b]) => b - a);
this.weightToTimeFormatsDict = weightToTimeFormatsDict;
}
filterLabelsInViewport(labels) {
const bounds = this.canvasBoundsContainer.getBounds(CanvasElement.X_AXIS);
const filteredLabels = [];
for (const label of labels) {
const x = this.scale.toX(label.value);
// skip labels outside viewport
if (x < 0 || x > bounds.width) {
continue;
}
filteredLabels.push(label);
}
return filteredLabels;
}
getLabelsFromChartType() {
var _a, _b;
const labels = (_b = (_a = this.weightedCache) === null || _a === void 0 ? void 0 : _a.labels) !== null && _b !== void 0 ? _b : [];
//@ts-ignore
if (this.config.components.chart.type === 'equivolume') {
return this.postProcessing(labels);
}
else {
return labels;
}
}
/**
* Make a prediction(750) about how many candles we need to fake to fill all X axis by labels
*/
getAllCandlesWithFake(prependedCandles = []) {
const visualCandles = this.chartModel.mainCandleSeries.visualPoints;
if (visualCandles.length === 0) {
return [];
}
const fakeCandlesForSides = Array.from({ length: 750 });
const appendFakeCandle = fakeCandlesForSides.map((_, idx) => fakeVisualCandle(this.chartModel.mainCandleSeries.dataPoints, this.chartModel.mainCandleSeries.visualPoints, this.chartModel.mainCandleSeries.meanCandleWidth, visualCandles.length + idx, this.chartModel.getPeriod()));
return [...prependedCandles, ...visualCandles, ...appendFakeCandle];
}
/**
* Maps an array of weighted points to an array of XAxisLabelWeighted objects.
* @param {WeightedPoint[]} weightedPoints - An array of weighted points to be mapped.
* @param {VisualCandle[]} allCandlesWithFake - An array of visual candles to be used for formatting the labels.
* @returns {XAxisLabelWeighted[]} An array of XAxisLabelWeighted objects.
*/
mapWeightedPointsToLabels(weightedPoints, allCandlesWithFake) {
var _a;
const arr = new Array(weightedPoints.length);
for (let index = 0; index < weightedPoints.length; ++index) {
const point = weightedPoints[index];
const visualCandle = allCandlesWithFake[index];
const labelFormat = this.weightToTimeFormatsDict[point.weight];
const formattedLabel = this.timeZoneModel.getDateTimeFormatter(labelFormat)(visualCandle.candle.timestamp);
arr[index] = {
weight: point.weight,
idx: (_a = visualCandle.candle.idx) !== null && _a !== void 0 ? _a : 0,
value: visualCandle.centerUnit,
time: visualCandle.candle.timestamp,
text: formattedLabel,
};
}
return arr;
}
/**
* Sets the formats for labels configuration.
* @param {Record<TimeFormatWithDuration, string>} newFormatsByWeightMap - The new formats by weight map.
* @returns {void}
*/
setFormatsForLabelsConfig(newFormatsByWeightMap) {
const { weightToTimeFormatsDict, weightToTimeFormatMatcherDict } = generateWeightsMapForConfig(newFormatsByWeightMap);
this.formatsByWeightMap = newFormatsByWeightMap;
this.weightToTimeFormatMatcherArray = Object.entries(weightToTimeFormatMatcherDict)
.map(([k, v]) => [parseInt(k, 10), v])
.sort(([a], [b]) => b - a);
this.weightToTimeFormatsDict = weightToTimeFormatsDict;
this.generateWeightedLabels();
}
/**
* Generates weighted labels based on allCandlesWithFake, weightToTimeFormatMatcherDict and timeZoneModel.
* @private
* @function
* @returns {void}
*/
generateWeightedLabels(prependedCandles = []) {
const allCandlesWithFake = this.getAllCandlesWithFake(prependedCandles);
const weightedPoints = mapCandlesToWeightedPoints(allCandlesWithFake, this.weightToTimeFormatMatcherArray, this.timeZoneModel.tzOffset(this.config.timezone));
const weightedLabels = this.mapWeightedPointsToLabels(weightedPoints, allCandlesWithFake);
this.labelsGroupedByWeight = groupLabelsByWeight(weightedLabels);
this.weightedCache = undefined;
this.levelsCache = {};
this.recalculateCachedLabels();
}
/**
* Retrieves the labels from the cache for a given coverUpLevel.
* @param {number} coverUpLevel - The coverUpLevel to retrieve the labels from the cache.
* @returns {Array<string>|undefined} - The labels for the given coverUpLevel if they exist in the cache, otherwise undefined.
*/
getLabelsFromCache(coverUpLevel) {
if (this.levelsCache[coverUpLevel]) {
return this.levelsCache[coverUpLevel];
}
return undefined;
}
/**
* Updates label of new appeared candle
* @param {VisualCandle} candle - new updated candle
* @returns {void}
*/
updateLastLabel(candle) {
const prevCandle = this.chartModel.mainCandleSeries.visualPoints[this.chartModel.mainCandleSeries.visualPoints.length - 2];
if (prevCandle) {
const newCandleWithprevious = [prevCandle, candle];
const weightedPoints = mapCandlesToWeightedPoints(newCandleWithprevious, this.weightToTimeFormatMatcherArray, this.timeZoneModel.tzOffset(this.config.timezone));
const weightedLabels = this.mapWeightedPointsToLabels([weightedPoints[1]], [candle]);
const [newWeightedLabel] = weightedLabels;
this.labelsGroupedByWeight = Object.entries(this.labelsGroupedByWeight).reduce((acc, [weight, labels]) => {
const isLabelFoundHere = labels.findIndex(item => item.idx === newWeightedLabel.idx);
let filteredLabels = labels;
// we need to check it bsc the array from which we delete and the array into which we insert the value will most likely not be the same array
if (isLabelFoundHere !== -1) {
filteredLabels = labels.filter(item => item.idx !== newWeightedLabel.idx);
}
if (parseInt(weight, 10) === newWeightedLabel.weight) {
const idx = filteredLabels.findIndex(item => item.idx > newWeightedLabel.idx);
if (idx !== -1) {
filteredLabels = [
...filteredLabels.slice(0, idx),
newWeightedLabel,
...filteredLabels.slice(idx),
];
}
else {
filteredLabels.push(newWeightedLabel);
}
}
acc[weight] = filteredLabels;
return acc;
}, {});
this.weightedCache = undefined;
this.levelsCache = {};
this.recalculateCachedLabels();
}
}
/**
* Creates labels for history candles and merge it with existing
* @param {VisualCandle} candle - new history candles
* @returns {void}
*/
updateHistoryLabels(candles) {
const candlesPlusOne = candles.concat(this.chartModel.mainCandleSeries.visualPoints[candles.length]);
const weightedPoints = mapCandlesToWeightedPoints(candlesPlusOne, this.weightToTimeFormatMatcherArray, this.timeZoneModel.tzOffset(this.config.timezone));
const weightedLabels = this.mapWeightedPointsToLabels(weightedPoints, candlesPlusOne);
const historyLabelsByWeight = groupLabelsByWeight(weightedLabels);
const copyOfNewGroupedLabels = cloneUnsafe(historyLabelsByWeight);
for (const weight in this.labelsGroupedByWeight) {
if (copyOfNewGroupedLabels[weight] === undefined) {
copyOfNewGroupedLabels[weight] = this.labelsGroupedByWeight[weight];
}
}
const lastCandle = this.chartModel.mainCandleSeries.visualPoints[candles.length - 1];
const allLabelsByWeight = Object.entries(copyOfNewGroupedLabels)
.sort(([a], [b]) => parseInt(b, 10) - parseInt(a, 10))
.reduce((acc, [weight, labels]) => {
const parsedWeight = parseInt(weight, 10);
const existingLabels = this.labelsGroupedByWeight[parsedWeight];
if (parsedWeight === this.weightToTimeFormatMatcherArray[0][0]) {
//we need to delete last label in old labels array
// this is label from the array with biggest weight, so that's why we use sorting
existingLabels.shift();
}
if (existingLabels) {
const restLabels = existingLabels.map(label => {
label.idx = label.idx + candles.length;
label.value = lastCandle.startUnit + lastCandle.width + label.value;
return label;
});
if (historyLabelsByWeight[parsedWeight]) {
acc[weight] = labels.concat(restLabels);
}
else {
acc[weight] = restLabels;
}
}
else {
acc[weight] = labels;
}
return acc;
}, {});
this.labelsGroupedByWeight = allLabelsByWeight;
this.weightedCache = undefined;
this.levelsCache = {};
this.recalculateCachedLabels();
}
/**
* Calls the method generateWeightedLabels to generate labels.
* @returns {void}
*/
generateLabels(prependedCandles) {
this.generateWeightedLabels(prependedCandles);
}
/**
* Recalculates the labels by calling the method recalculateCachedLabels.
* @returns {void}
*/
recalculateLabels() {
this.recalculateCachedLabels();
}
/**
* Recalculates cached labels based on the current configuration and zoom level.
* If there are no grouped labels, the cache is not set.
* Recalculating depends on cover up level.
* If the cover up level is negative, the cache is not updated.
* If the cover up level has not changed, the cached labels are returned.
* Otherwise, the labels are filtered by extended rules and grouped by cover up level.
* The filtered labels are then cached and returned.
*/
recalculateCachedLabels() {
const coverUpLevel = this.calculateCoverUpLevel();
// for some reason sometimes `this.scale.zoomX` is negative so we dont want to update labels
if (coverUpLevel < 0) {
return;
}
if (this.weightedCache === undefined || coverUpLevel !== this.weightedCache.coverUpLevel) {
const labelsFromCache = this.getLabelsFromCache(coverUpLevel);
if (labelsFromCache) {
this.weightedCache = {
labels: labelsFromCache,
coverUpLevel,
};
return;
}
const labelsToCache = filterMapGroupedLabelsByCoverUpLevel(this.filterLabelsByExtendedRules(this.labelsGroupedByWeight, coverUpLevel), coverUpLevel);
this.levelsCache[coverUpLevel] = labelsToCache;
this.weightedCache = {
labels: labelsToCache,
coverUpLevel,
};
this.eventBus.fireDraw();
}
}
/**
* Post filtering for equivolume case
* {@link overlappingPredicate} is used
* @param {XAxisLabelWeighted[]} labels - array of labels filtered by weights algorithm
* @returns {XAxisLabelWeighted[]} - array of filtered labels by overlaping predicate
*/
postProcessing(labels) {
var _a, _b;
const filteredLabels = [];
// tricky double-iterator to check multiple consequent overlapping labels
let i = 0;
let j = 1;
while (j <= labels.length - 1) {
const label = labels[i];
const nextLabel = labels[j];
const overlappingCondition = overlappingPredicate(this.canvasModel.ctx, this.config, label, nextLabel, this.scale, 30);
if (overlappingCondition) {
if (((_a = nextLabel.weight) !== null && _a !== void 0 ? _a : 0) > ((_b = label.weight) !== null && _b !== void 0 ? _b : 0)) {
i = j;
}
}
else {
filteredLabels.push(label);
i = j;
if (j === labels.length - 1) {
filteredLabels.push(nextLabel);
}
}
j++;
}
return filteredLabels;
}
/**
* Filters the labels by extended rules.
* @private
*
* @param {Record<number, XAxisLabelWeighted[]>} labelsGroupedByWeight - Object containing the labels grouped by weight.
* @param {number} coverUpLevel - The cover up level.
*
* @returns {Record<number, XAxisLabelWeighted[]>} - Object containing the filtered labels grouped by weight.
*/
filterLabelsByExtendedRules(labelsGroupedByWeight, coverUpLevel) {
// transform {minute_1: () => boolean} to {215: () => boolean}
const mappedExtendedFilterConfig = typedEntries_UNSAFE(this.extendedLabelsFilterConfig).reduce((acc, item) => {
if (!item) {
return acc;
}
const [timeFormat, filterFn] = item;
if (filterFn) {
const parsedTimeFormat = parseTimeFormatsFromKey(timeFormat);
if (parsedTimeFormat) {
const weightFromValue = getWeightFromTimeFormat(parsedTimeFormat);
acc[weightFromValue] = filterFn;
}
}
return acc;
}, {});
// filter by cover up level
return typedEntries_UNSAFE(labelsGroupedByWeight).reduce((acc, [weight, labels]) => {
if (mappedExtendedFilterConfig[weight] && mappedExtendedFilterConfig[weight](coverUpLevel)) {
return acc;
}
acc[weight] = labels;
return acc;
}, {});
}
}