UNPKG

@devexperts/dxcharts-lite

Version:
430 lines (429 loc) 20.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 { 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.labelCache = new Map(); 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; } } getVisualCandleAtIndex(idx, dataPoints, visualPoints, meanCandleWidth, period) { if (idx >= 0 && idx < visualPoints.length) { return visualPoints[idx]; } // generate fake candle for out-of-range index return fakeVisualCandle(dataPoints, visualPoints, meanCandleWidth, idx, period); } getVisualCandlesWithFake(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]; } getViewportCandlesWithFake() { var _a; const visualCandles = this.chartModel.mainCandleSeries.visualPoints; if (visualCandles.length === 0 || this.scale.xEnd - this.scale.xStart <= 1) { return []; } const meanCandleWidth = this.chartModel.mainCandleSeries.meanCandleWidth; const period = this.chartModel.getPeriod(); const dataPoints = this.chartModel.mainCandleSeries.dataPoints; // calculate visible pixel range for the chart area const bounds = this.canvasBoundsContainer.getBounds(CanvasElement.CHART); const leftPx = 0; const rightPx = bounds.width; // convert to units const leftUnit = this.scale.fromX(leftPx); const rightUnit = this.scale.fromX(rightPx); // anchor everything to index 0 (absolute index) // find the X position of index 0 (anchor) let anchorX = 0; if (visualCandles.length > 0) { // if we have visible candles, use the X of the first visible candle at its idx const firstVisible = visualCandles[0]; anchorX = firstVisible.centerUnit - ((_a = firstVisible.candle.idx) !== null && _a !== void 0 ? _a : 0) * meanCandleWidth; } // buffer is needed to to avoid empty space when moving viewport const buffer = 500; // allows to round labels better const modulus = 500; // use anchorX and meanCandleWidth to map units to indices const rawFirstIdx = Math.floor((leftUnit - anchorX) / meanCandleWidth) - buffer; const rawLastIdx = Math.ceil((rightUnit - anchorX) / meanCandleWidth) + buffer; const firstIdx = Math.floor(rawFirstIdx / modulus) * modulus; const lastIdx = Math.ceil(rawLastIdx / modulus) * modulus; const allCandles = []; for (let idx = firstIdx; idx <= lastIdx; idx++) { allCandles.push(this.getVisualCandleAtIndex(idx, dataPoints, visualCandles, meanCandleWidth, period)); } return [...allCandles]; } getAllCandlesWithFake(prependedCandles = []) { if (this.config.components.chart.minCandlesOffset) { return this.getVisualCandlesWithFake(prependedCandles); } else { return this.getViewportCandlesWithFake(); } } /** * 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) { this.clearLabelCache(); 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.generateLabels(); } /** * 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(); } /** * Generates labels based on allCandlesWithFake, weightToTimeFormatMatcherDict and timeZoneModel. * @returns {void} */ generateLabels(prependedCandles, generateFromCache = false) { var _a; const allCandlesWithFake = this.getAllCandlesWithFake(prependedCandles); const weightedPoints = mapCandlesToWeightedPoints(allCandlesWithFake, this.weightToTimeFormatMatcherArray, this.timeZoneModel.tzOffset(this.config.timezone)); let weightedLabels = []; if (generateFromCache) { for (let i = 0; i < allCandlesWithFake.length; i++) { const idx = (_a = allCandlesWithFake[i].candle.idx) !== null && _a !== void 0 ? _a : i; const ts = allCandlesWithFake[i].candle.timestamp; const cacheKey = `${idx}_${ts}`; let label = this.labelCache.get(cacheKey); if (!label) { label = this.mapWeightedPointsToLabels([weightedPoints[i]], [allCandlesWithFake[i]])[0]; this.labelCache.set(cacheKey, label); } weightedLabels.push(label); } } else { weightedLabels = this.mapWeightedPointsToLabels(weightedPoints, allCandlesWithFake); } this.labelsGroupedByWeight = groupLabelsByWeight(weightedLabels); this.weightedCache = undefined; this.levelsCache = {}; this.recalculateCachedLabels(); } /** * 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; }, {}); } clearLabelCache() { this.labelCache.clear(); } }