Skip to content

Commit

Permalink
feat: Improve X-labels generation time
Browse files Browse the repository at this point in the history
  • Loading branch information
KirillBobkov committed Nov 30, 2023
1 parent 328dbba commit a06480b
Show file tree
Hide file tree
Showing 15 changed files with 161 additions and 109 deletions.
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@
"dependencies": {
"color": "^4.2.3",
"date-fns": "^2.30.0",
"date-fns-tz": "^1.3.8",
"rxjs": "^7.5.7"
},
"peerDependencies": {
Expand Down
8 changes: 4 additions & 4 deletions src/chart/components/chart/chart.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,17 +270,17 @@ export class ChartComponent extends ChartBaseElement {
* @param {Timestamp} end - The end timestamp of the range.
* @returns {void}
*/
public setTimestampRange(start: Timestamp, end: Timestamp): void {
return this.chartModel.setTimestampRange(start, end);
public setTimestampRange(start: Timestamp, end: Timestamp, forceNoAnimation: boolean = true): void {
return this.chartModel.setTimestampRange(start, end, forceNoAnimation);
}

/**
* Moves the viewport to exactly xStart..xEnd place.
* @param xStart - viewport start in units
* @param xEnd - viewport end in units
*/
public setXScale(xStart: Unit, xEnd: Unit) {
return this.scale.setXScale(xStart, xEnd);
public setXScale(xStart: Unit, xEnd: Unit, forceNoAnimation: boolean = true) {
return this.scale.setXScale(xStart, xEnd, forceNoAnimation);
}

/**
Expand Down
5 changes: 2 additions & 3 deletions src/chart/components/chart/chart.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ export class ChartModel extends ChartBaseElement {
public readonly nextCandleTimeStampSubject: Subject<void> = new Subject<void>();
public readonly chartTypeChanged: Subject<BarType> = new Subject<BarType>();
public readonly mainInstrumentChangedSubject: Subject<ChartInstrument> = new Subject<ChartInstrument>();
public readonly scaleInversedSubject: Subject<void> = new Subject<void>();
public readonly offsetsChanged = new Subject<void>();
private candlesTransformersByChartType: Partial<Record<BarType, VisualCandleCalculator>> = {};
lastCandleLabelsByChartType: Partial<Record<BarType, LastCandleLabelHandler>> = {};
Expand Down Expand Up @@ -802,11 +801,11 @@ export class ChartModel extends ChartBaseElement {
* @param {Timestamp} end - The end timestamp of the range.
* @returns {void}
*/
setTimestampRange(start: Timestamp, end: Timestamp): void {
setTimestampRange(start: Timestamp, end: Timestamp, forceNoAnimation: boolean = true): void {
const startUnit = this.candleFromTimestamp(start).startUnit;
const endCandle = this.candleFromTimestamp(end);
const endUnit = endCandle.startUnit + endCandle.width;
return this.scale.setXScale(startUnit, endUnit);
return this.scale.setXScale(startUnit, endUnit, forceNoAnimation);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
isSunday,
startOfMonth,
endOfMonth,
} from 'date-fns/esm';
} from 'date-fns';
import {
ParsedCTimeFormat,
ParsedNCTimeFormat,
Expand Down
4 changes: 2 additions & 2 deletions src/chart/components/x_axis/time/x-axis-weights.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,14 @@ export const getWeightFromTimeFormat = (format: ParsedTimeFormat) => {
export const groupLabelsByWeight = (weightedLabels: XAxisLabelWeighted[]): Record<number, XAxisLabelWeighted[]> => {
const labelsGroupedByWeight: Record<number, XAxisLabelWeighted[]> = {};

weightedLabels.forEach(weightedLabel => {
for (const weightedLabel of weightedLabels) {
const labelsByWeight = labelsGroupedByWeight[weightedLabel.weight];
if (labelsByWeight === undefined) {
labelsGroupedByWeight[weightedLabel.weight] = [weightedLabel];
} else {
labelsByWeight.push(weightedLabel);
}
});
}

return labelsGroupedByWeight;
};
Expand Down
14 changes: 5 additions & 9 deletions src/chart/components/x_axis/time/x-axis-weights.generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,9 @@ const getWeightByDate = (
currentDate: Date,
previousDate: Date,
sortedWeights: [number, TimeFormatMatcher][],
tzOffset: (time: number) => Date,
): number => {
const offsetCurrentDate = tzOffset(currentDate.getTime());
const offsetPrevDate = tzOffset(previousDate.getTime());
for (const [weight, timeMatcher] of sortedWeights) {
if (timeMatcher(offsetCurrentDate, offsetPrevDate)) {
if (timeMatcher(currentDate, previousDate)) {
return weight;
}
}
Expand All @@ -52,16 +49,15 @@ export function mapCandlesToWeightedPoints(
tzOffset: (time: number) => Date,
): WeightedPoint[] {
const result: WeightedPoint[] = new Array(visualCandles.length);

// assume that candles` timestamp before the first visual candle is unreachable
let prevDate = new Date(0);
let prevDate = tzOffset(0);

for (let i = 0; i < visualCandles.length; i++) {
const currentCandle = visualCandles[i];
const currentDate = new Date(currentCandle.candle.timestamp);

// calculate timstamp to Date in acordance with provided timezone and time
const currentDate = tzOffset(currentCandle.candle.timestamp);
const currentWeightedPoint: WeightedPoint = {
weight: getWeightByDate(currentDate, prevDate, sortedWeights, tzOffset),
weight: getWeightByDate(currentDate, prevDate, sortedWeights),
};
result[i] = currentWeightedPoint;
prevDate = currentDate;
Expand Down
80 changes: 59 additions & 21 deletions src/chart/components/x_axis/x-axis-labels.generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* 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 { CanvasBoundsContainer, CanvasElement } from '../../canvas/canvas-bounds-container';
import { FullChartConfig } from '../../chart.config';
import EventBus from '../../events/event-bus';
import { CanvasModel } from '../../model/canvas.model';
Expand Down Expand Up @@ -68,9 +69,10 @@ export class XAxisTimeLabelsGenerator implements XAxisLabelsGenerator {
private labelsGroupedByWeight: Record<number, XAxisLabelWeighted[]> = {};
private weightedCache?: { labels: XAxisLabelWeighted[]; coverUpLevel: number };
private levelsCache: Record<number, XAxisLabelWeighted[]> = {};
private prevAnimationId = '';

get labels(): XAxisLabelWeighted[] {
return this.getLabelsFromChartType();
return this.filterLabelsInViewport(this.getLabelsFromChartType());
}

private formatsByWeightMap: Record<TimeFormatWithDuration, string>;
Expand All @@ -89,6 +91,7 @@ export class XAxisTimeLabelsGenerator implements XAxisLabelsGenerator {
private scale: ScaleModel,
private timeZoneModel: TimeZoneModel,
private canvasModel: CanvasModel,
private canvasBoundsContainer: CanvasBoundsContainer,
) {
this.formatsByWeightMap = config.components.xAxis.formatsForLabelsConfig;
const { weightToTimeFormatsDict, weightToTimeFormatMatcherDict } = generateWeightsMapForConfig(
Expand All @@ -100,6 +103,21 @@ export class XAxisTimeLabelsGenerator implements XAxisLabelsGenerator {
this.weightToTimeFormatsDict = weightToTimeFormatsDict;
}

private filterLabelsInViewport(labels: XAxisLabelWeighted[]) {
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;
}

private getLabelsFromChartType() {
const labels = this.weightedCache?.labels ?? [];
//@ts-ignore
Expand Down Expand Up @@ -143,7 +161,8 @@ export class XAxisTimeLabelsGenerator implements XAxisLabelsGenerator {
allCandlesWithFake: VisualCandle[],
): XAxisLabelWeighted[] {
const arr = new Array(weightedPoints.length);
weightedPoints.forEach((point, index) => {
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);
Expand All @@ -155,7 +174,7 @@ export class XAxisTimeLabelsGenerator implements XAxisLabelsGenerator {
time: visualCandle.candle.timestamp,
text: formattedLabel,
};
});
}
return arr;
}

Expand Down Expand Up @@ -334,35 +353,54 @@ export class XAxisTimeLabelsGenerator implements XAxisLabelsGenerator {
}

/**
* Recalculates cached labels based on the current configuration and zoom level.
* If there are no grouped labels, the cache is not set.
* Calculates the maximum label width based on the font size and the maximum format length.
* Calculates the cover up level based on the maximum label width and the mean candle width.
* 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.
* 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.
*/
private recalculateCachedLabels() {
// skip cache setting if we don't have grouped labels
private calculateCoverUpLevel = () => {
const animation = this.scale.currentAnimation;
const meanCandleWidthInUnits = this.chartModel.mainCandleSeries.meanCandleWidth;
let meanCandleWidthInPixels;
if (Object.getOwnPropertyNames(this.labelsGroupedByWeight).length === 0) {
return;
return -1;
}

// calculate coverUpLevel for target zoomX to prevent extra labels calculations on every animation tick
if (animation?.animationInProgress) {
if (animation.id !== this.prevAnimationId) {
this.prevAnimationId = animation.id;
}
meanCandleWidthInPixels = unitToPixels(meanCandleWidthInUnits, animation.animationConfig.targetZoomX);
} else {
meanCandleWidthInPixels = 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) => {
return Math.max(item.length, max);
}, 1);
const maxFormatLength = Object.values(this.formatsByWeightMap).reduce(
(max, item) => Math.max(item.length, max),
1,
);
const maxLabelWidth = fontSize * maxFormatLength;
const meanCandleWidthPx = unitToPixels(this.chartModel.mainCandleSeries.meanCandleWidth, this.scale.zoomX);

const coverUpLevel = Math.round(maxLabelWidth / meanCandleWidthPx);
return Math.round(maxLabelWidth / meanCandleWidthInPixels);
};

/**
* 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.
*/
private recalculateCachedLabels() {
const coverUpLevel = this.calculateCoverUpLevel();
// for some reason sometimes `this.scale.zoomX` is negative so we dont want to update labels
if (coverUpLevel < 0 && !isFinite(meanCandleWidthPx)) {
if (coverUpLevel < 0) {
return;
}

if (this.weightedCache === undefined || coverUpLevel !== this.weightedCache.coverUpLevel) {
const labelsFromCache = this.getLabelsFromCache(coverUpLevel);
if (labelsFromCache) {
Expand Down
4 changes: 0 additions & 4 deletions src/chart/components/x_axis/x-axis-time-labels.drawer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,6 @@ export class XAxisTimeLabelsDrawer implements Drawer {
ctx.fillStyle = color;
for (const label of labels) {
const x = this.viewportModel.toX(label.value) - calculateTextWidth(label.text, ctx, font) / 2;
// skip labels outside viewport
if (x < 0 || x > bounds.width) {
continue;
}
const y = bounds.y + fontHeight - 1 + offsetTop; // -1 for font drawing inconsistency
const labelText = label.text;
ctx.font = font;
Expand Down
17 changes: 6 additions & 11 deletions src/chart/components/x_axis/x-axis.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class XAxisComponent extends ChartBaseElement {
scale,
timeZoneModel,
this.canvasModel,
canvasBoundsContainer,
);
this.xAxisLabelsGenerator = xAxisLabelsGenerator;

Expand Down Expand Up @@ -121,18 +122,14 @@ export class XAxisComponent extends ChartBaseElement {
this.chartComponent.chartModel.candlesPrependSubject
.pipe(
filter(({ prependedCandles }) => prependedCandles.length !== 0),
map(({ prependedCandles }) => {
return this.chartComponent.chartModel.mainCandleSeries.visualPoints.slice(
0,
prependedCandles.length,
);
}),
map(({ prependedCandles }) =>
this.chartComponent.chartModel.mainCandleSeries.visualPoints.slice(0, prependedCandles.length),
),
)
.subscribe(newCandles => {
//@ts-ignore
if (availableBarTypes.includes(this.config.components.chart.type)) {
this.xAxisLabelsGenerator.updateHistoryLabels &&
this.xAxisLabelsGenerator.updateHistoryLabels(newCandles);
this.xAxisLabelsGenerator.updateHistoryLabels?.(newCandles);
}
}),
);
Expand All @@ -152,9 +149,7 @@ export class XAxisComponent extends ChartBaseElement {
distinctUntilChanged((a, b) => a?.candle?.timestamp === b?.candle?.timestamp),
filter(notEmpty),
)
.subscribe(x => {
this.xAxisLabelsGenerator.updateLastLabel && this.xAxisLabelsGenerator.updateLastLabel(x);
}),
.subscribe(x => this.xAxisLabelsGenerator?.updateLastLabel?.(x)),
);
}

Expand Down
2 changes: 2 additions & 0 deletions src/chart/components/y_axis/y-axis-scale.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export class YAxisScaleHandler extends ChartBaseElement {
}

private onYDragStart = () => {
// halt previous scale animation if drag is started
this.scale.haltAnimation();
this.lastYStart = this.scale.yStart;
this.lastYEnd = this.scale.yEnd;
this.lastYHeight = this.scale.yEnd - this.scale.yStart;
Expand Down
2 changes: 1 addition & 1 deletion src/chart/model/data-series.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export class DataSeriesModel<

protected doActivate(): void {
this.addRxSubscription(this.scale.xChanged.subscribe(() => this.recalculateDataViewportIndexes()));
this.addRxSubscription(this.scale.scaleInversedSubject.subscribe(() => this.recalculateVisualPoints()));
this.addRxSubscription(this.scale.scaleInversedSubject.subscribe(() => { this.recalculateVisualPoints(); }));
}

/**
Expand Down
22 changes: 17 additions & 5 deletions src/chart/model/scale.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ export class ScaleModel extends ViewportModel {
);
}

protected doActivate(): void {
this.addRxSubscription(
this.scaleInversedSubject.subscribe(() => {
this.fireChanged();
}),
);
}

/**
* 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.
Expand Down Expand Up @@ -181,19 +189,23 @@ export class ScaleModel extends ViewportModel {
* @param fireChanged
* @param forceNoAutoScale - force NOT apply auto-scaling (for lazy loading)
*/
public setXScale(xStart: Unit, xEnd: Unit) {
public setXScale(xStart: Unit, xEnd: Unit, forceNoAnimation: boolean = true) {
const initialState = this.export();
super.setXScale(xStart, xEnd, false);
const state = this.export();
const zoomX = this.calculateZoomX(xStart, xEnd);
const state = { ...initialState, zoomX, xStart, xEnd };
const constrainedState = this.scalePostProcessor(initialState, state);
if (this.state.lockPriceToBarRatio) {
changeYToKeepRatio(constrainedState, this.zoomXYRatio);
}
if (this.state.auto) {
this.autoScaleModel.doAutoYScale(constrainedState);
}

this.apply(constrainedState);
if (forceNoAnimation || this.config.scale.disableAnimations) {
this.haltAnimation();
this.apply(constrainedState);
} else {
startViewportModelAnimation(this.canvasAnimation, this, constrainedState);
}
}

public setYScale(yStart: Unit, yEnd: Unit, fire = false) {
Expand Down
Loading

0 comments on commit a06480b

Please sign in to comment.