Загрузка данных
export { PriceAxisLabelsController } from './PriceAxisLabelsController';
export { PriceAxisLabelsPrimitive } from './PriceAxisLabelsPrimitive';
export { layoutPriceAxisLabels } from './layout';
export type { LaidOutPriceAxisLabel, PriceAxisLabel, PriceAxisLabelsLayoutOptions, PriceAxisLabelStyle } from './types';
#####
import { LaidOutPriceAxisLabel, PRICE_AXIS_LABEL_HEIGHT, PriceAxisLabel, PriceAxisLabelsLayoutOptions } from './types';
const DEFAULT_LABEL_GAP = 2;
const DEFAULT_DUPLICATE_TOLERANCE = 1;
function compareLabels(left: PriceAxisLabel, right: PriceAxisLabel): number {
if (left.desiredCoordinate !== right.desiredCoordinate) {
return left.desiredCoordinate - right.desiredCoordinate;
}
const priorityDiff = (right.priority ?? 0) - (left.priority ?? 0);
if (priorityDiff !== 0) {
return priorityDiff;
}
return left.id.localeCompare(right.id);
}
function isDuplicate(left: PriceAxisLabel, right: PriceAxisLabel, tolerance: number): boolean {
return (
left.text === right.text &&
left.symbol === right.symbol &&
Math.abs(left.desiredCoordinate - right.desiredCoordinate) <= tolerance
);
}
function deduplicateLabels(labels: readonly PriceAxisLabel[], tolerance: number): PriceAxisLabel[] {
const sortedLabels = [...labels].sort((left, right) => {
const priorityDiff = (right.priority ?? 0) - (left.priority ?? 0);
if (priorityDiff !== 0) {
return priorityDiff;
}
return left.id.localeCompare(right.id);
});
const result: PriceAxisLabel[] = [];
sortedLabels.forEach((label) => {
const duplicateExists = result.some((currentLabel) => isDuplicate(currentLabel, label, tolerance));
if (!duplicateExists) {
result.push(label);
}
});
return result;
}
function createLaidOutLabel(label: PriceAxisLabel, coordinate: number): LaidOutPriceAxisLabel {
return {
...label,
coordinate,
height: label.height ?? PRICE_AXIS_LABEL_HEIGHT,
};
}
function distributeLabelsInsideSmallAxis(
labels: readonly PriceAxisLabel[],
axisHeight: number,
): LaidOutPriceAxisLabel[] {
if (labels.length === 0) {
return [];
}
if (labels.length === 1) {
const label = labels[0];
const height = label.height ?? PRICE_AXIS_LABEL_HEIGHT;
const minCoordinate = height / 2;
const maxCoordinate = Math.max(minCoordinate, axisHeight - height / 2);
return [createLaidOutLabel(label, Math.min(Math.max(label.desiredCoordinate, minCoordinate), maxCoordinate))];
}
const firstHeight = labels[0].height ?? PRICE_AXIS_LABEL_HEIGHT;
const lastHeight = labels[labels.length - 1].height ?? PRICE_AXIS_LABEL_HEIGHT;
const minCoordinate = firstHeight / 2;
const maxCoordinate = Math.max(minCoordinate, axisHeight - lastHeight / 2);
const step = Math.max(0, (maxCoordinate - minCoordinate) / (labels.length - 1));
return labels.map((label, index) => createLaidOutLabel(label, minCoordinate + step * index));
}
function resolveCollisionGroup(
labels: readonly PriceAxisLabel[],
axisHeight: number,
gap: number,
): LaidOutPriceAxisLabel[] {
const sortedLabels = [...labels].sort(compareLabels);
if (sortedLabels.length === 0) {
return [];
}
const totalLabelsHeight = sortedLabels.reduce((total, label) => total + (label.height ?? PRICE_AXIS_LABEL_HEIGHT), 0);
if (totalLabelsHeight > axisHeight) {
return distributeLabelsInsideSmallAxis(sortedLabels, axisHeight);
}
const maxAvailableGap =
sortedLabels.length > 1 ? Math.max(0, (axisHeight - totalLabelsHeight) / (sortedLabels.length - 1)) : gap;
const effectiveGap = Math.min(gap, maxAvailableGap);
const resolvedLabels = sortedLabels.map((label) => {
const height = label.height ?? PRICE_AXIS_LABEL_HEIGHT;
const minCoordinate = height / 2;
const maxCoordinate = Math.max(minCoordinate, axisHeight - height / 2);
return createLaidOutLabel(label, Math.min(Math.max(label.desiredCoordinate, minCoordinate), maxCoordinate));
});
for (let index = 1; index < resolvedLabels.length; index += 1) {
const previousLabel = resolvedLabels[index - 1];
const currentLabel = resolvedLabels[index];
const minCoordinate = previousLabel.coordinate + previousLabel.height / 2 + currentLabel.height / 2 + effectiveGap;
if (currentLabel.coordinate < minCoordinate) {
currentLabel.coordinate = minCoordinate;
}
}
const lastLabel = resolvedLabels[resolvedLabels.length - 1];
const lastMaxCoordinate = axisHeight - lastLabel.height / 2;
if (lastLabel.coordinate > lastMaxCoordinate) {
const offset = lastLabel.coordinate - lastMaxCoordinate;
resolvedLabels.forEach((label) => {
label.coordinate -= offset;
});
}
for (let index = resolvedLabels.length - 2; index >= 0; index -= 1) {
const currentLabel = resolvedLabels[index];
const nextLabel = resolvedLabels[index + 1];
const maxCoordinate = nextLabel.coordinate - nextLabel.height / 2 - currentLabel.height / 2 - effectiveGap;
if (currentLabel.coordinate > maxCoordinate) {
currentLabel.coordinate = maxCoordinate;
}
}
const firstLabel = resolvedLabels[0];
const firstMinCoordinate = firstLabel.height / 2;
if (firstLabel.coordinate < firstMinCoordinate) {
const offset = firstMinCoordinate - firstLabel.coordinate;
resolvedLabels.forEach((label) => {
label.coordinate += offset;
});
}
return resolvedLabels;
}
export function layoutPriceAxisLabels(
labels: readonly PriceAxisLabel[],
axisHeight: number,
options: PriceAxisLabelsLayoutOptions = {},
): LaidOutPriceAxisLabel[] {
if (axisHeight <= 0 || labels.length === 0) {
return [];
}
const gap = options.gap ?? DEFAULT_LABEL_GAP;
const duplicateTolerance = options.duplicateTolerance ?? DEFAULT_DUPLICATE_TOLERANCE;
const labelsByCollisionGroup = new Map<string, PriceAxisLabel[]>();
labels.forEach((label) => {
if (!Number.isFinite(label.desiredCoordinate)) {
return;
}
const group = labelsByCollisionGroup.get(label.collisionGroup);
if (group) {
group.push(label);
return;
}
labelsByCollisionGroup.set(label.collisionGroup, [label]);
});
const result: LaidOutPriceAxisLabel[] = [];
labelsByCollisionGroup.forEach((groupLabels) => {
const uniqueLabels = deduplicateLabels(groupLabels, duplicateTolerance);
result.push(...resolveCollisionGroup(uniqueLabels, axisHeight, gap));
});
return result.sort((left, right) => {
const priorityDiff = (left.priority ?? 0) - (right.priority ?? 0);
if (priorityDiff !== 0) {
return priorityDiff;
}
return left.id.localeCompare(right.id);
});
}
####
import { IPriceLine, LogicalRange, MismatchDirection, PriceScaleMode } from 'lightweight-charts';
import { Observable, Subscription } from 'rxjs';
import { Indicator } from '@core/Indicator';
import { IndicatorsIds, MAIN_PANE_INDEX } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme';
import { Direction } from '@src/types';
import { formatCompactNumber } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
import { PriceAxisLabelsPrimitive } from './PriceAxisLabelsPrimitive';
import { PRICE_AXIS_LABEL_HEIGHT, PriceAxisLabel } from './types';
type EntityCollection = 'compare' | 'indicator';
type SourceRole = 'main' | 'compare' | 'indicator' | 'volume';
type PriceAxisSide = Direction.Left | Direction.Right;
interface PriceAxisLabelsControllerParams {
mainSeries$: Observable<SeriesStrategies | null>;
mainSymbol$: Observable<string>;
compareEntities$: Observable<Indicator[]>;
indicatorEntities$: Observable<Indicator[]>;
}
interface PriceLabelSource {
id: string;
role: SourceRole;
series: SeriesStrategies;
collisionGroup: string;
priority: number;
symbol?: string;
}
interface SourceDefaults {
lastValueVisible: boolean;
priceLineVisible: boolean;
}
interface AxisLabelsGroup {
paneIndex: number;
side: PriceAxisSide;
labels: PriceAxisLabel[];
}
interface AxisLayer {
host: SeriesStrategies;
primitive: PriceAxisLabelsPrimitive;
}
const SOURCE_PRIORITY: Record<SourceRole, number> = {
main: 100,
compare: 80,
indicator: 50,
volume: 30,
};
function getDataPrice(data: unknown): number | null {
if (!data || typeof data !== 'object') {
return null;
}
if ('close' in data && typeof data.close === 'number') {
return data.close;
}
if ('value' in data && typeof data.value === 'number') {
return data.value;
}
return null;
}
function toOpaqueColor(color: string): string {
return /^#[0-9a-fA-F]{8}$/.test(color) ? removeAlphaFromHex(color) : color;
}
function getSeriesColor(series: SeriesStrategies, data: unknown): string {
if (data && typeof data === 'object' && 'color' in data && typeof data.color === 'string') {
return toOpaqueColor(data.color);
}
const options = series.options() as unknown as Record<string, unknown>;
if (
data &&
typeof data === 'object' &&
'open' in data &&
'close' in data &&
typeof data.open === 'number' &&
typeof data.close === 'number'
) {
const candleColor = data.close >= data.open ? options.upColor : options.downColor;
if (typeof candleColor === 'string') {
return toOpaqueColor(candleColor);
}
}
const color = [options.color, options.lineColor, options.topLineColor, options.bottomLineColor].find(
(value): value is string => typeof value === 'string',
);
return color ? toOpaqueColor(color) : toOpaqueColor(getThemeStore().colors.chartLineColor);
}
function getContrastTextColor(color: string): string {
const normalizedColor = toOpaqueColor(color).replace('#', '').slice(0, 6);
if (normalizedColor.length !== 6) {
return '#FFFFFF';
}
const red = Number.parseInt(normalizedColor.slice(0, 2), 16);
const green = Number.parseInt(normalizedColor.slice(2, 4), 16);
const blue = Number.parseInt(normalizedColor.slice(4, 6), 16);
if (Number.isNaN(red) || Number.isNaN(green) || Number.isNaN(blue)) {
return '#FFFFFF';
}
const luminance = (red * 0.299 + green * 0.587 + blue * 0.114) / 255;
return luminance > 0.6 ? '#000000' : '#FFFFFF';
}
function getShortSymbol(symbol: string): string {
const parts = symbol.split(':');
return parts[parts.length - 1] || symbol;
}
function getAxisSide(source: PriceLabelSource): PriceAxisSide {
if (source.role === 'volume') {
return Direction.Right;
}
return source.series.options().priceScaleId === Direction.Left ? Direction.Left : Direction.Right;
}
function getAxisKey(paneIndex: number, side: PriceAxisSide): string {
return `${paneIndex}:${side}`;
}
function getEntityKey(collection: EntityCollection, entity: Indicator): string {
return `${collection}:${entity.getId()}`;
}
function areSeriesListsEqual(
currentSeries: readonly SeriesStrategies[] | undefined,
nextSeries: readonly SeriesStrategies[],
): boolean {
return (
currentSeries?.length === nextSeries.length && currentSeries.every((series, index) => series === nextSeries[index])
);
}
export class PriceAxisLabelsController {
private readonly subscriptions = new Subscription();
private readonly entitySubscriptions = new Map<string, Subscription>();
private readonly entitySeries = new Map<string, readonly SeriesStrategies[]>();
private readonly sourceDefaults = new WeakMap<SeriesStrategies, SourceDefaults>();
private readonly layers = new Map<string, AxisLayer>();
private mainSeries: SeriesStrategies | null = null;
private mainSeriesDataHandler: (() => void) | null = null;
private compareEntities: Indicator[] = [];
private indicatorEntities: Indicator[] = [];
private visibleLogicalRange: LogicalRange | null = null;
private currentPriceLine: IPriceLine | null = null;
private currentPriceLineHost: SeriesStrategies | null = null;
private mainSymbol = '';
private isHistoryMode = false;
private updateFrame: number | null = null;
constructor({ mainSeries$, mainSymbol$, compareEntities$, indicatorEntities$ }: PriceAxisLabelsControllerParams) {
this.subscriptions.add(
mainSymbol$.subscribe((symbol) => {
this.mainSymbol = getShortSymbol(symbol);
this.scheduleUpdate();
}),
);
this.subscriptions.add(
mainSeries$.subscribe((series) => {
this.setMainSeries(series);
}),
);
this.subscriptions.add(
compareEntities$.subscribe((entities) => {
this.setEntities('compare', entities);
}),
);
this.subscriptions.add(
indicatorEntities$.subscribe((entities) => {
this.setEntities('indicator', entities);
}),
);
}
public setVisibleLogicalRange(logicalRange: LogicalRange | null): void {
this.visibleLogicalRange = logicalRange;
this.refreshHistoryMode();
this.scheduleUpdate();
}
public invalidate(): void {
this.scheduleUpdate();
}
public destroy(): void {
if (this.updateFrame !== null && typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(this.updateFrame);
this.updateFrame = null;
}
this.unsubscribeMainSeries();
this.subscriptions.unsubscribe();
this.entitySubscriptions.forEach((subscription) => {
subscription.unsubscribe();
});
this.entitySubscriptions.clear();
this.entitySeries.clear();
this.getSources().forEach((source) => {
this.restoreSourceOptions(source.series);
});
this.layers.forEach(({ host, primitive }) => {
try {
host.detachPrimitive(primitive);
} catch {
// Серия могла быть удалена раньше контроллера.
}
});
this.layers.clear();
this.removeCurrentPriceLine();
}
private setMainSeries(series: SeriesStrategies | null): void {
if (this.mainSeries === series) {
return;
}
const previousSeries = this.mainSeries;
this.unsubscribeMainSeries();
if (previousSeries) {
this.restoreSourceOptions(previousSeries);
}
this.mainSeries = series;
if (series) {
this.ensureSourceDefaults(series);
this.mainSeriesDataHandler = () => {
this.refreshHistoryMode();
this.scheduleUpdate();
};
series.subscribeDataChanged(this.mainSeriesDataHandler);
}
if (this.currentPriceLineHost && this.currentPriceLineHost !== series) {
this.removeCurrentPriceLine();
}
this.refreshHistoryMode();
this.applyDisplayMode();
this.scheduleUpdate();
}
private unsubscribeMainSeries(): void {
if (!this.mainSeries || !this.mainSeriesDataHandler) {
this.mainSeriesDataHandler = null;
return;
}
try {
this.mainSeries.unsubscribeDataChanged(this.mainSeriesDataHandler);
} catch {
// Серия могла быть удалена раньше контроллера.
}
this.mainSeriesDataHandler = null;
}
private setEntities(collection: EntityCollection, entities: Indicator[]): void {
if (collection === 'compare') {
this.compareEntities = entities;
} else {
this.indicatorEntities = entities;
}
this.syncEntitySubscriptions(collection, entities);
this.applyDisplayMode();
this.scheduleUpdate();
}
private syncEntitySubscriptions(collection: EntityCollection, entities: Indicator[]): void {
const activeKeys = new Set(entities.map((entity) => getEntityKey(collection, entity)));
this.entitySubscriptions.forEach((subscription, key) => {
if (!key.startsWith(`${collection}:`) || activeKeys.has(key)) {
return;
}
subscription.unsubscribe();
this.entitySubscriptions.delete(key);
this.entitySeries.delete(key);
});
entities.forEach((entity) => {
const key = getEntityKey(collection, entity);
const nextSeries = Array.from(entity.getSeriesMap().values());
const currentSeries = this.entitySeries.get(key);
if (!areSeriesListsEqual(currentSeries, nextSeries)) {
this.entitySeries.set(key, nextSeries);
this.applyDisplayModeToSources(this.getEntitySources(collection, entity));
}
if (this.entitySubscriptions.has(key)) {
return;
}
const subscription = entity.subscribeDataChange(() => {
const updatedSeries = Array.from(entity.getSeriesMap().values());
const registeredSeries = this.entitySeries.get(key);
if (!areSeriesListsEqual(registeredSeries, updatedSeries)) {
this.entitySeries.set(key, updatedSeries);
this.applyDisplayModeToSources(this.getEntitySources(collection, entity));
}
this.scheduleUpdate();
});
this.entitySubscriptions.set(key, subscription);
});
}
private getSources(): PriceLabelSource[] {
const sources: PriceLabelSource[] = [];
if (this.mainSeries) {
sources.push({
id: 'main',
role: 'main',
series: this.mainSeries,
collisionGroup: 'main',
priority: SOURCE_PRIORITY.main,
});
}
this.compareEntities.forEach((entity) => {
sources.push(...this.getEntitySources('compare', entity));
});
this.indicatorEntities.forEach((entity) => {
sources.push(...this.getEntitySources('indicator', entity));
});
return sources;
}
private getEntitySources(collection: EntityCollection, entity: Indicator): PriceLabelSource[] {
const isVolume = collection === 'indicator' && entity.getType() === IndicatorsIds.Volume;
const role: SourceRole = collection === 'compare' ? 'compare' : isVolume ? 'volume' : 'indicator';
const collisionGroup =
role === 'volume' ? 'volume' : role === 'compare' ? `compare:${entity.getId()}` : `indicator:${entity.getId()}`;
const symbol = role === 'compare' ? getShortSymbol(entity.getLabel()) : undefined;
return Array.from(entity.getSeriesMap().entries()).map(([seriesId, series]) => ({
id: `${collection}:${entity.getId()}:${seriesId}`,
role,
series,
collisionGroup,
priority: SOURCE_PRIORITY[role],
symbol,
}));
}
private ensureSourceDefaults(series: SeriesStrategies): SourceDefaults {
const currentDefaults = this.sourceDefaults.get(series);
if (currentDefaults) {
return currentDefaults;
}
const options = series.options();
const defaults = {
lastValueVisible: options.lastValueVisible,
priceLineVisible: options.priceLineVisible,
};
this.sourceDefaults.set(series, defaults);
return defaults;
}
private restoreSourceOptions(series: SeriesStrategies): void {
const defaults = this.sourceDefaults.get(series);
if (!defaults) {
return;
}
try {
series.applyOptions(defaults);
} catch {
// Серия могла быть удалена раньше контроллера.
}
}
private applyDisplayMode(): void {
this.applyDisplayModeToSources(this.getSources());
if (!this.isHistoryMode) {
this.hideCurrentPriceLine();
}
}
private applyDisplayModeToSources(sources: readonly PriceLabelSource[]): void {
sources.forEach((source) => {
const defaults = this.ensureSourceDefaults(source.series);
try {
if (source.role === 'volume') {
source.series.applyOptions({
lastValueVisible: false,
priceLineVisible: false,
});
return;
}
source.series.applyOptions({
lastValueVisible: this.isHistoryMode ? false : defaults.lastValueVisible,
});
} catch {
// Серия могла быть удалена раньше контроллера.
}
});
}
private getHistoryMode(logicalRange: LogicalRange | null): boolean {
if (!logicalRange || !this.mainSeries) {
return false;
}
const barsInfo = this.mainSeries.barsInLogicalRange(logicalRange);
return (barsInfo?.barsAfter ?? 0) > 0;
}
private refreshHistoryMode(): void {
const nextHistoryMode = this.getHistoryMode(this.visibleLogicalRange);
if (this.isHistoryMode === nextHistoryMode) {
return;
}
this.isHistoryMode = nextHistoryMode;
this.applyDisplayMode();
}
private scheduleUpdate(): void {
if (this.updateFrame !== null) {
return;
}
if (typeof requestAnimationFrame !== 'function') {
this.update();
return;
}
this.updateFrame = requestAnimationFrame(() => {
this.updateFrame = null;
this.update();
});
}
private update(): void {
const sources = this.getSources();
this.updateCurrentPriceLine(sources);
this.updateAxisLayers(this.collectAxisGroups(sources), sources);
}
private collectAxisGroups(sources: readonly PriceLabelSource[]): Map<string, AxisLabelsGroup> {
const groups = new Map<string, AxisLabelsGroup>();
sources.forEach((source) => {
if (!source.series.isVisible()) {
return;
}
if (source.role !== 'volume' && !this.isHistoryMode) {
return;
}
const label = this.createLabel(source);
if (!label) {
return;
}
const paneIndex = source.series.getPane().paneIndex();
const side = getAxisSide(source);
const axisKey = getAxisKey(paneIndex, side);
const group = groups.get(axisKey);
if (group) {
group.labels.push(label);
return;
}
groups.set(axisKey, {
paneIndex,
side,
labels: [label],
});
});
return groups;
}
private createLabel(source: PriceLabelSource): PriceAxisLabel | null {
const data = this.getSourceData(source);
const price = getDataPrice(data);
if (price === null) {
return null;
}
const coordinate = source.series.priceToCoordinate(price);
if (coordinate === null) {
return null;
}
if (source.role === 'main') {
const currentCoordinate = this.getCurrentMainPriceCoordinate();
if (currentCoordinate !== null && Math.abs(currentCoordinate - coordinate) <= PRICE_AXIS_LABEL_HEIGHT + 2) {
return null;
}
}
return {
id: source.id,
collisionGroup: source.collisionGroup,
desiredCoordinate: coordinate,
text:
source.role === 'volume' ? String(formatCompactNumber(price) ?? price) : this.formatValue(source.series, price),
color: getSeriesColor(source.series, data),
style: this.isHistoryMode ? 'outlined' : 'filled',
symbol: source.role === 'main' ? this.mainSymbol : source.role === 'compare' ? source.symbol : undefined,
priority: source.priority,
height: PRICE_AXIS_LABEL_HEIGHT,
};
}
private getSourceData(source: PriceLabelSource): unknown {
if (this.isHistoryMode && this.visibleLogicalRange) {
return source.series.dataByIndex(Math.floor(this.visibleLogicalRange.to), MismatchDirection.NearestLeft);
}
const data = source.series.data();
return data.length > 0 ? data[data.length - 1] : null;
}
private updateAxisLayers(groups: Map<string, AxisLabelsGroup>, sources: readonly PriceLabelSource[]): void {
const activeLayerKeys = new Set<string>();
groups.forEach((group, axisKey) => {
const host = this.getAxisHost(group.paneIndex, group.side, sources);
if (!host) {
return;
}
activeLayerKeys.add(axisKey);
this.getOrCreateLayer(axisKey, host).primitive.setLabels(group.labels);
});
this.layers.forEach((layer, axisKey) => {
if (activeLayerKeys.has(axisKey)) {
return;
}
try {
layer.host.detachPrimitive(layer.primitive);
} catch {
// Серия могла быть удалена раньше контроллера.
}
this.layers.delete(axisKey);
});
}
private getAxisHost(
paneIndex: number,
side: PriceAxisSide,
sources: readonly PriceLabelSource[],
): SeriesStrategies | null {
if (paneIndex === MAIN_PANE_INDEX && side === Direction.Right && this.mainSeries) {
return this.mainSeries;
}
const regularSource = sources.find(
(source) =>
source.role !== 'volume' && source.series.getPane().paneIndex() === paneIndex && getAxisSide(source) === side,
);
if (regularSource) {
return regularSource.series;
}
const fallbackSource = sources.find(
(source) => source.series.getPane().paneIndex() === paneIndex && getAxisSide(source) === side,
);
return fallbackSource?.series ?? null;
}
private getOrCreateLayer(axisKey: string, host: SeriesStrategies): AxisLayer {
const currentLayer = this.layers.get(axisKey);
if (currentLayer?.host === host) {
return currentLayer;
}
if (currentLayer) {
try {
currentLayer.host.detachPrimitive(currentLayer.primitive);
} catch {
// Серия могла быть удалена раньше контроллера.
}
}
const primitive = new PriceAxisLabelsPrimitive();
host.attachPrimitive(primitive);
const nextLayer = { host, primitive };
this.layers.set(axisKey, nextLayer);
return nextLayer;
}
private updateCurrentPriceLine(sources: readonly PriceLabelSource[]): void {
const mainSource = sources.find((source) => source.role === 'main');
if (!this.isHistoryMode || !mainSource || !mainSource.series.isVisible()) {
this.hideCurrentPriceLine();
return;
}
const data = mainSource.series.data();
const lastData = data.length > 0 ? data[data.length - 1] : null;
const price = getDataPrice(lastData);
if (price === null) {
this.hideCurrentPriceLine();
return;
}
const color = getSeriesColor(mainSource.series, lastData);
const priceLine = this.getCurrentPriceLine(mainSource.series, color);
priceLine.applyOptions({
price,
color,
lineVisible: false,
axisLabelVisible: true,
axisLabelColor: color,
axisLabelTextColor: getContrastTextColor(color),
title: this.mainSymbol,
});
}
private getCurrentMainPriceCoordinate(): number | null {
if (!this.mainSeries) {
return null;
}
const data = this.mainSeries.data();
const lastData = data.length > 0 ? data[data.length - 1] : null;
const price = getDataPrice(lastData);
return price === null ? null : this.mainSeries.priceToCoordinate(price);
}
private getCurrentPriceLine(series: SeriesStrategies, color: string): IPriceLine {
if (this.currentPriceLine && this.currentPriceLineHost === series) {
return this.currentPriceLine;
}
this.removeCurrentPriceLine();
this.currentPriceLine = series.createPriceLine({
price: 0,
color,
lineVisible: false,
axisLabelVisible: false,
title: '',
});
this.currentPriceLineHost = series;
return this.currentPriceLine;
}
private hideCurrentPriceLine(): void {
this.currentPriceLine?.applyOptions({
lineVisible: false,
axisLabelVisible: false,
title: '',
});
}
private removeCurrentPriceLine(): void {
if (this.currentPriceLine && this.currentPriceLineHost) {
try {
this.currentPriceLineHost.removePriceLine(this.currentPriceLine);
} catch {
// Серия могла быть удалена раньше контроллера.
}
}
this.currentPriceLine = null;
this.currentPriceLineHost = null;
}
private formatValue(series: SeriesStrategies, price: number): string {
const mode = series.priceScale().options().mode;
if (mode !== PriceScaleMode.Percentage && mode !== PriceScaleMode.IndexedTo100) {
return series.priceFormatter().format(price);
}
const logicalRange = this.visibleLogicalRange;
if (!logicalRange) {
return series.priceFormatter().format(price);
}
const firstVisibleData = series.dataByIndex(Math.ceil(logicalRange.from), MismatchDirection.NearestRight);
const firstVisiblePrice = getDataPrice(firstVisibleData);
if (firstVisiblePrice === null || firstVisiblePrice === 0) {
return series.priceFormatter().format(price);
}
if (mode === PriceScaleMode.Percentage) {
const percentage = ((price - firstVisiblePrice) / firstVisiblePrice) * 100;
return `${percentage.toFixed(2)}%`;
}
return ((price / firstVisiblePrice) * 100).toFixed(2);
}
}
####
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
PrimitivePaneViewZOrder,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import { layoutPriceAxisLabels } from './layout';
import { LaidOutPriceAxisLabel, PriceAxisLabel } from './types';
const HORIZONTAL_PADDING = 6;
const SYMBOL_MIN_WIDTH_RATIO = 0.3;
const SYMBOL_MAX_WIDTH_RATIO = 0.48;
const FONT_SIZE = 11;
const FONT_WEIGHT = 500;
function getContrastTextColor(color: string): string {
const normalizedColor = color.replace('#', '').slice(0, 6);
if (normalizedColor.length !== 6) {
return '#FFFFFF';
}
const red = Number.parseInt(normalizedColor.slice(0, 2), 16);
const green = Number.parseInt(normalizedColor.slice(2, 4), 16);
const blue = Number.parseInt(normalizedColor.slice(4, 6), 16);
if (Number.isNaN(red) || Number.isNaN(green) || Number.isNaN(blue)) {
return '#FFFFFF';
}
const luminance = (red * 0.299 + green * 0.587 + blue * 0.114) / 255;
return luminance > 0.6 ? '#000000' : '#FFFFFF';
}
function fitText(context: CanvasRenderingContext2D, text: string, maxWidth: number): string {
if (maxWidth <= 0) {
return '';
}
if (context.measureText(text).width <= maxWidth) {
return text;
}
const ellipsis = '…';
let result = text;
while (result.length > 0 && context.measureText(`${result}${ellipsis}`).width > maxWidth) {
result = result.slice(0, -1);
}
return result.length > 0 ? `${result}${ellipsis}` : '';
}
function areLabelsEqual(currentLabels: readonly PriceAxisLabel[], nextLabels: readonly PriceAxisLabel[]): boolean {
if (currentLabels.length !== nextLabels.length) {
return false;
}
return currentLabels.every((currentLabel, index) => {
const nextLabel = nextLabels[index];
return (
currentLabel.id === nextLabel.id &&
currentLabel.collisionGroup === nextLabel.collisionGroup &&
currentLabel.desiredCoordinate === nextLabel.desiredCoordinate &&
currentLabel.text === nextLabel.text &&
currentLabel.color === nextLabel.color &&
currentLabel.style === nextLabel.style &&
currentLabel.symbol === nextLabel.symbol &&
currentLabel.priority === nextLabel.priority &&
currentLabel.height === nextLabel.height
);
});
}
function drawLabel(
context: CanvasRenderingContext2D,
label: LaidOutPriceAxisLabel,
width: number,
horizontalPixelRatio: number,
verticalPixelRatio: number,
): void {
const { colors } = getThemeStore();
const coordinate = Math.round(label.coordinate * verticalPixelRatio);
const labelHeight = Math.round(label.height * verticalPixelRatio);
const top = Math.round(coordinate - labelHeight / 2);
const horizontalPadding = Math.round(HORIZONTAL_PADDING * horizontalPixelRatio);
const backgroundColor = label.style === 'outlined' ? colors.chartBackground : label.color;
const textColor = label.style === 'outlined' ? label.color : getContrastTextColor(label.color);
context.fillStyle = backgroundColor;
context.fillRect(0, top, width, labelHeight);
if (label.style === 'outlined') {
const borderWidth = Math.max(1, Math.round(horizontalPixelRatio));
context.strokeStyle = label.color;
context.lineWidth = borderWidth;
context.strokeRect(
borderWidth / 2,
top + borderWidth / 2,
Math.max(0, width - borderWidth),
Math.max(0, labelHeight - borderWidth),
);
}
context.fillStyle = textColor;
if (!label.symbol) {
const maxTextWidth = width - horizontalPadding * 2;
const text = fitText(context, label.text, maxTextWidth);
context.textAlign = 'center';
context.fillText(text, width / 2, coordinate);
return;
}
const availableWidth = width - horizontalPadding * 2;
const measuredSymbolWidth = context.measureText(label.symbol).width + horizontalPadding * 2;
const symbolWidth = Math.min(
Math.max(measuredSymbolWidth, availableWidth * SYMBOL_MIN_WIDTH_RATIO),
availableWidth * SYMBOL_MAX_WIDTH_RATIO,
);
const separatorX = Math.round(horizontalPadding + symbolWidth);
const separatorWidth = Math.max(1, Math.round(horizontalPixelRatio));
const symbolMaxWidth = symbolWidth - horizontalPadding * 2;
const textAreaWidth = width - separatorX;
const textMaxWidth = textAreaWidth - horizontalPadding * 2;
const symbol = fitText(context, label.symbol, symbolMaxWidth);
const text = fitText(context, label.text, textMaxWidth);
context.textAlign = 'center';
context.fillText(symbol, horizontalPadding + symbolWidth / 2, coordinate);
context.globalAlpha = 0.5;
context.fillRect(
separatorX,
top + Math.round(3 * verticalPixelRatio),
separatorWidth,
labelHeight - Math.round(6 * verticalPixelRatio),
);
context.globalAlpha = 1;
context.fillText(text, separatorX + textAreaWidth / 2, coordinate);
}
class PriceAxisLabelsRenderer implements IPrimitivePaneRenderer {
constructor(private readonly getLabels: () => readonly PriceAxisLabel[]) {}
public draw(target: CanvasRenderingTarget2D): void {
const labels = this.getLabels();
if (labels.length === 0) {
return;
}
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
if (bitmapSize.width <= 0 || bitmapSize.height <= 0) {
return;
}
const laidOutLabels = layoutPriceAxisLabels(labels, bitmapSize.height / verticalPixelRatio);
if (laidOutLabels.length === 0) {
return;
}
context.save();
context.font = `${FONT_WEIGHT} ${Math.round(FONT_SIZE * verticalPixelRatio)}px Inter, sans-serif`;
context.textBaseline = 'middle';
laidOutLabels.forEach((label) => {
drawLabel(context, label, bitmapSize.width, horizontalPixelRatio, verticalPixelRatio);
});
context.restore();
});
}
}
class PriceAxisLabelsPaneView implements IPrimitivePaneView {
private readonly rendererInstance: PriceAxisLabelsRenderer;
constructor(private readonly getLabels: () => readonly PriceAxisLabel[]) {
this.rendererInstance = new PriceAxisLabelsRenderer(getLabels);
}
public renderer(): IPrimitivePaneRenderer | null {
return this.getLabels().length > 0 ? this.rendererInstance : null;
}
public zOrder(): PrimitivePaneViewZOrder {
return 'top';
}
}
export class PriceAxisLabelsPrimitive implements ISeriesPrimitive<Time> {
private readonly priceAxisPaneView: PriceAxisLabelsPaneView;
private readonly priceAxisPaneViewList: readonly IPrimitivePaneView[];
private labels: readonly PriceAxisLabel[] = [];
private requestUpdate: (() => void) | null = null;
constructor() {
this.priceAxisPaneView = new PriceAxisLabelsPaneView(() => this.labels);
this.priceAxisPaneViewList = [this.priceAxisPaneView];
}
public attached({ requestUpdate }: SeriesAttachedParameter<Time>): void {
this.requestUpdate = requestUpdate;
this.requestUpdate();
}
public detached(): void {
this.requestUpdate = null;
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.priceAxisPaneViewList;
}
public updateAllViews(): void {}
public setLabels(labels: readonly PriceAxisLabel[]): void {
if (areLabelsEqual(this.labels, labels)) {
return;
}
this.labels = labels;
this.requestUpdate?.();
}
public clear(): void {
if (this.labels.length === 0) {
return;
}
this.labels = [];
this.requestUpdate?.();
}
}
#####
export const PRICE_AXIS_LABEL_HEIGHT = 20;
export type PriceAxisLabelStyle = 'filled' | 'outlined';
export interface PriceAxisLabel {
id: string;
collisionGroup: string;
desiredCoordinate: number;
text: string;
color: string;
style: PriceAxisLabelStyle;
symbol?: string;
priority?: number;
height?: number;
}
export interface LaidOutPriceAxisLabel extends PriceAxisLabel {
coordinate: number;
height: number;
}
export interface PriceAxisLabelsLayoutOptions {
gap?: number;
duplicateTolerance?: number;
}
###
import {
BarData,
BarPrice,
BarsInfo,
Coordinate,
CreatePriceLineOptions,
CustomData,
DataChangedHandler,
DeepPartial,
HistogramData,
IChartApi,
IPaneApi,
IPriceFormatter,
IPriceLine,
IPriceScaleApi,
IRange,
ISeriesApi,
ISeriesPrimitive,
LineData,
MismatchDirection,
MouseEventParams,
PriceScaleOptions,
SeriesDataItemTypeMap,
SeriesDefinition,
SeriesOptionsMap,
SeriesPartialOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { BehaviorSubject, distinctUntilChanged, Observable, Subscription } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { Indicator } from '@core/Indicator';
import { ChartTypeToCandleData, IndicatorDataFormatter } from '@core/Indicators';
import { Ohlc } from '@core/Legend';
import { MAIN_PANE_INDEX } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { Candle, Direction } from '@src/types';
import { formatPrice, getPricePrecisionStep, isBarData, isLineData, normalizeSeriesData } from '@src/utils';
export interface SerieData {
time: Time;
customValues: Candle;
}
export interface CreateSeriesParams<TSeries extends SeriesType> {
chart: IChartApi;
seriesOptions?: SeriesPartialOptionsMap[TSeries];
paneIndex?: number;
priceScaleOptions?: DeepPartial<PriceScaleOptions>;
}
export interface IBaseSeries<TSeries extends SeriesType> extends ISeriesApi<TSeries> {
getLwcSeries: () => ISeriesApi<TSeries>;
getLegendData: (
param?: MouseEventParams,
) => Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;
}
export interface BaseSeriesParams<TSeries extends SeriesType = SeriesType> {
lwcChart: IChartApi;
dataSource: DataSource;
mainSymbol$: Observable<string>;
mainSerie$: BehaviorSubject<SeriesStrategies | null>;
customFormatter?: (params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][];
seriesOptions?: SeriesPartialOptionsMap[TSeries];
priceScaleOptions?: DeepPartial<PriceScaleOptions>;
showSymbolLabel?: boolean;
paneIndex?: number;
indicatorReference?: Indicator;
}
function applyMoscowTimezone(candles: Candle[]): ChartTypeToCandleData['Candlestick'][] {
// todo this approach is too slow, for timezones impl we should shift timeScale instead of mutating the data
const offsetMinutes = -180; // utc.time - moscow.time in minutes
const secondsInMinute = 60;
return candles
.filter((c) => typeof c.time === 'number' && Number.isFinite(c.time))
.map((c) => ({
...c,
time: (c.time as number) - offsetMinutes * secondsInMinute,
}));
}
export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSeries<TSeries> {
protected lwcSeries: ISeriesApi<TSeries>;
protected customFormatter:
| undefined
| ((params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][]);
protected lwcChart: IChartApi;
protected mainSymbol$: Observable<string>;
protected mainSerie$: BehaviorSubject<SeriesStrategies | null>;
protected paneIndex: number | null = null;
protected indicatorReference: Indicator | null = null;
protected showSymbolLabel: boolean;
private subscriptions = new Subscription();
private dataSub: Subscription | null = null;
private realtimeSub: Subscription | null = null;
constructor({
lwcChart,
mainSymbol$,
mainSerie$,
customFormatter,
seriesOptions,
priceScaleOptions,
showSymbolLabel = true,
paneIndex,
indicatorReference,
}: BaseSeriesParams<TSeries>) {
this.lwcSeries = this.createSeries({
chart: lwcChart,
seriesOptions,
paneIndex,
priceScaleOptions,
});
this.lwcChart = lwcChart;
this.customFormatter = customFormatter;
this.mainSymbol$ = mainSymbol$;
this.mainSerie$ = mainSerie$;
this.showSymbolLabel = showSymbolLabel;
this.indicatorReference = indicatorReference ?? null;
}
public getLegendData = (
param?: MouseEventParams,
): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>> => {
if (!param) {
const seriesData = this.data();
if (seriesData.length < 1) return {};
const dataToFormat = seriesData[seriesData.length - 1];
const prevBarData = seriesData.length > 1 ? seriesData[seriesData.length - 2] : null;
return this.formatLegendValues(dataToFormat, prevBarData);
}
const dataToFormat = param?.seriesData.get(this.getLwcSeries()) ?? null;
const prevBarData = this.dataByIndex(param.logical! - 1) ?? null;
return this.formatLegendValues(dataToFormat, prevBarData);
};
public show(): void {
this.lwcSeries.applyOptions({ ...this.lwcSeries.options(), visible: true });
}
public hide(): void {
this.lwcSeries.applyOptions({ ...this.lwcSeries.options(), visible: false });
}
public isVisible(): boolean {
return this.lwcSeries.options().visible;
}
public destroy(): void {
this.dataSub?.unsubscribe();
this.realtimeSub?.unsubscribe();
this.subscriptions.unsubscribe();
this.lwcChart.removeSeries(this.lwcSeries);
}
public getLwcSeries(): ISeriesApi<TSeries> {
return this.lwcSeries;
}
public applyOptions(options: SeriesPartialOptionsMap[TSeries]): void {
this.lwcSeries.applyOptions(options);
}
public attachPrimitive(primitive: ISeriesPrimitive<Time>): void {
this.lwcSeries.attachPrimitive(primitive);
}
public barsInLogicalRange(range: IRange<number>): BarsInfo<Time> | null {
return this.lwcSeries.barsInLogicalRange(range);
}
public coordinateToPrice(coordinate: number): BarPrice | null {
return this.lwcSeries.coordinateToPrice(coordinate);
}
public createPriceLine(options: CreatePriceLineOptions): IPriceLine {
return this.lwcSeries.createPriceLine(options);
}
public data(): readonly SeriesDataItemTypeMap<Time>[TSeries][] {
return this.lwcSeries.data();
}
public dataByIndex(
logicalIndex: number,
mismatchDirection?: MismatchDirection,
): SeriesDataItemTypeMap<Time>[TSeries] | null {
return this.lwcSeries.dataByIndex(logicalIndex, mismatchDirection);
}
public detachPrimitive(primitive: ISeriesPrimitive<Time>): void {
this.lwcSeries.detachPrimitive(primitive);
}
public getPane(): IPaneApi<Time> {
return this.lwcSeries.getPane();
}
public moveToPane(paneIndex: number): void {
this.lwcSeries.moveToPane(paneIndex);
}
public options(): Readonly<SeriesOptionsMap[TSeries]> {
return this.lwcSeries.options();
}
public priceFormatter(): IPriceFormatter {
return this.lwcSeries.priceFormatter();
}
public priceLines(): IPriceLine[] {
return this.lwcSeries.priceLines();
}
public priceScale(): IPriceScaleApi {
return this.lwcSeries.priceScale();
}
public priceToCoordinate(price: number): Coordinate | null {
return this.lwcSeries.priceToCoordinate(price);
}
public removePriceLine(line: IPriceLine): void {
this.lwcSeries.removePriceLine(line);
}
public seriesOrder(): number {
return this.lwcSeries.seriesOrder();
}
public seriesType(): TSeries {
return this.lwcSeries.seriesType();
}
public setData(data: SeriesDataItemTypeMap<Time>[TSeries][]): void {
const normalizedData = normalizeSeriesData(data);
this.lwcSeries.setData(normalizedData);
}
public setSeriesOrder(order: number): void {
this.lwcSeries.setSeriesOrder(order);
}
public subscribeDataChanged(handler: DataChangedHandler): void {
this.lwcSeries.subscribeDataChanged(handler);
}
public unsubscribeDataChanged(handler: DataChangedHandler): void {
this.lwcSeries.unsubscribeDataChanged(handler);
}
public update(bar: SeriesDataItemTypeMap<Time>[TSeries], historicalUpdate?: boolean): void {
const data = this.lwcSeries.data();
const last = data.length ? data[data.length - 1] : null;
if (!last) {
this.lwcSeries.update(bar, false);
return;
}
const lastTime = last.time;
const nextTime = bar.time;
const isHist =
historicalUpdate ?? (typeof lastTime === 'number' && typeof nextTime === 'number' && nextTime < lastTime);
this.lwcSeries.update(bar, isHist);
}
protected createSeries({
chart,
seriesOptions,
paneIndex = MAIN_PANE_INDEX,
priceScaleOptions = {},
}: CreateSeriesParams<TSeries>): ISeriesApi<TSeries> {
this.paneIndex = paneIndex;
const defaultOptions = this.getDefaultOptions();
const mergedOptions = {
...defaultOptions,
...seriesOptions,
};
const series = chart.addSeries<TSeries>(this.seriesDefinition(), mergedOptions, paneIndex);
chart.priceScale(mergedOptions.priceScaleId ?? Direction.Right, paneIndex).applyOptions(priceScaleOptions);
return series;
}
protected abstract dataSourceSubscription(next: Candle[]): void;
protected abstract seriesDefinition(): SeriesDefinition<TSeries>;
protected abstract dataSourceRealtimeSubscription(next: Candle): void;
protected abstract getDefaultOptions(): SeriesPartialOptionsMap[TSeries];
protected abstract formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][];
protected abstract formatLegendValues(
currentBar: null | BarData | LineData | HistogramData | CustomData,
prevBar: null | BarData | LineData | HistogramData | CustomData,
): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;
protected applyTimezone(data: Candle[]): Candle[] {
return applyMoscowTimezone(data);
}
protected formatData(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
const formatter = this.customFormatter;
const data = this.applyTimezone(inputData);
if (formatter) {
const mainSeriesData = (this.mainSerie$.value?.data() ?? []) as unknown as SerieData[];
if (data.length === 1) {
return formatter({
mainSeriesData,
selfData: this.data() as unknown as ChartTypeToCandleData[TSeries][],
candle: this.formatMainSerie(data)[0] as unknown as SerieData,
indicatorReference: this.indicatorReference ?? undefined,
});
}
return formatter({
mainSeriesData,
indicatorReference: this.indicatorReference ?? undefined,
selfData: this.data() as unknown as ChartTypeToCandleData[TSeries][],
});
}
return this.formatMainSerie(data);
}
protected subscribeDataSource = (dataSource: DataSource): void => {
const minMove = getPricePrecisionStep();
this.subscriptions.add(
this.mainSymbol$.pipe(distinctUntilChanged()).subscribe((symbol) => {
this.lwcSeries.applyOptions({
// todo: на каждый апдейт dataSource сеттим options. Не оптимально
title: this.showSymbolLabel ? symbol : '',
priceFormat: {
type: 'custom',
minMove,
formatter: (price: number) => formatPrice(price),
},
});
this.dataSub?.unsubscribe();
this.realtimeSub?.unsubscribe();
this.dataSub = dataSource.subscribe(symbol, (next) => {
this.dataSourceSubscription(next);
});
this.realtimeSub = dataSource.subscribeRealtime(symbol, (next: Candle) => {
this.dataSourceRealtimeSubscription(next);
});
}),
);
};
}
export function calcCandleChange(
prev: BarData | LineData | HistogramData | CustomData | null,
current: BarData | LineData | HistogramData | CustomData | null,
): (Ohlc & { customValues?: Record<string, any> }) | null {
if (!current) {
return null;
}
if (!prev) {
return current;
}
if (isBarData(prev) && isBarData(current)) {
const absoluteChange = current.close - prev.close;
const percentageChange = ((current.close - prev.close) / prev.close) * 100;
return {
...current,
absoluteChange,
percentageChange,
};
}
if (isLineData(prev) && isLineData(current)) {
const absoluteChange = current.value - prev.value;
const percentageChange = ((current.value - prev.value) / prev.value) * 100;
return {
time: current.time,
value: current.value,
high: current.customValues?.high as number,
low: current.customValues?.low as number,
absoluteChange,
percentageChange,
customValues: current.customValues,
};
}
return null;
}
####
import dayjs from 'dayjs';
import {
BarPrice,
ChartOptions,
createChart,
CrosshairMode,
DeepPartial,
IChartApi,
IRange,
LocalizationOptionsBase,
LogicalRange,
Time,
UTCTimestamp,
} from 'lightweight-charts';
import flatten from 'lodash-es/flatten';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { map, withLatestFrom } from 'rxjs/operators';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
import { PriceAxisLabelsController } from '@core/PriceAxisLabels';
import { CompareManager } from '@src/core/CompareManager';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { getLocale } from '@src/translations';
import {
Candle,
ChartOptionsModel,
ChartSeriesType,
ChartTypeOptions,
Direction,
OHLCConfig,
TooltipConfig,
} from '@src/types';
import { Defaults } from '@src/types/defaults';
import { DayjsOffset, Intervals, intervalsToDayjs } from '@src/types/intervals';
import { ChartSnapshot, ISerializable, PaneSnapshot } from '@src/types/snapshot';
import { formatCompactNumber } from '@src/utils';
import { createTickMarkFormatter, formatDate } from '@src/utils/formatter';
export interface ChartConfig extends Partial<ChartOptionsModel> {
container: HTMLElement;
seriesTypes: ChartSeriesType[];
theme: ThemeKey;
mode?: ThemeMode;
chartOptions?: ChartTypeOptions;
localization?: LocalizationOptionsBase;
}
export enum Resize {
Shrink,
Expand,
}
const HISTORY_LOAD_THRESHOLD = 50;
interface ChartParams {
params: {
dataSource: DataSource;
eventManager: EventManager;
modalRenderer: ModalRenderer;
ohlcConfig: OHLCConfig;
tooltipConfig: TooltipConfig;
panes: PaneSnapshot[];
};
lwcChartConfig: ChartConfig;
}
/**
* Абстракция над библиотекой для построения графиков
*/
export class Chart implements ISerializable<ChartSnapshot> {
private lwcChart!: IChartApi;
private container: HTMLElement;
private eventManager: EventManager;
private paneManager!: PaneManager;
private compareManager: CompareManager;
private mouseEvents: ChartMouseEvents;
private indicatorManager: IndicatorManager;
private priceAxisLabelsController: PriceAxisLabelsController;
private optionsSubscription: Subscription;
private dataSource: DataSource;
private chartConfig: Omit<ChartConfig, 'theme' | 'mode'>;
private mainSeries!: BehaviorSubject<SeriesStrategies | null>; // Main Series. Exists in a single copy
private DOM: DOMModel;
private isPointerDown = false;
private didResetOnDrag = false;
private subscriptions = new Subscription();
private currentInterval: Intervals | null = null;
private activeSymbols: string[] = [];
private historyBatchRunning = false;
constructor({ params, lwcChartConfig }: ChartParams) {
const { eventManager, dataSource, modalRenderer, ohlcConfig, tooltipConfig, panes: panesSnapshot } = params;
this.eventManager = eventManager;
this.dataSource = dataSource;
this.container = lwcChartConfig.container;
this.chartConfig = lwcChartConfig;
this.lwcChart = createChart(this.container, getOptions(lwcChartConfig));
this.optionsSubscription = this.eventManager
.getChartOptionsModel()
.subscribe(({ dateFormat, timeFormat, showTime }) => {
const configToApply = { ...lwcChartConfig, dateFormat, timeFormat, showTime };
this.lwcChart.applyOptions({
...getOptions(configToApply),
localization: {
timeFormatter: (time: UTCTimestamp) => formatDate(time, dateFormat, timeFormat, showTime),
},
});
});
this.subscriptions.add(this.optionsSubscription);
this.mouseEvents = new ChartMouseEvents({ lwcChart: this.lwcChart, container: this.container });
this.mouseEvents.subscribe('wheel', this.onWheel);
this.mouseEvents.subscribe('pointerDown', this.onPointerDown);
this.mouseEvents.subscribe('pointerMove', this.onPointerMove);
this.mouseEvents.subscribe('pointerUp', this.onPointerUp);
this.mouseEvents.subscribe('pointerCancel', this.onPointerUp);
this.DOM = new DOMModel({ modalRenderer });
this.paneManager = new PaneManager({
eventManager: this.eventManager,
panesSnapshot,
lwcChart: this.lwcChart,
dataSource,
DOM: this.DOM,
ohlcConfig,
subscribeChartEvent: this.subscribeChartEvent,
chartContainer: this.container,
tooltipConfig,
modalRenderer,
});
this.mainSeries = this.paneManager.getMainPane().getMainSerie();
this.indicatorManager = new IndicatorManager({
eventManager,
initialIndicators: flatten(
panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id }))),
),
DOM: this.DOM,
dataSource: this.dataSource,
lwcChart: this.lwcChart,
paneManager: this.paneManager,
chartOptions: lwcChartConfig.chartOptions,
});
this.compareManager = new CompareManager({
chart: this.lwcChart,
initialIndicators: flatten(
panesSnapshot.map((pane) => pane.indicators.map((ind) => ({ ...ind, paneId: pane.id }))),
),
eventManager: this.eventManager,
dataSource: this.dataSource,
indicatorManager: this.indicatorManager,
paneManager: this.paneManager,
});
this.priceAxisLabelsController = new PriceAxisLabelsController({
mainSeries$: this.mainSeries.asObservable(),
mainSymbol$: this.eventManager.symbol(),
compareEntities$: this.compareManager.entities(),
indicatorEntities$: this.indicatorManager.entities(),
});
this.priceAxisLabelsController.setVisibleLogicalRange(this.lwcChart.timeScale().getVisibleLogicalRange());
this.setupDataSourceSubs();
this.setupHistoricalDataLoading();
}
public getPriceScaleWidth(direction: Direction): number {
try {
const priceScale = this.lwcChart.priceScale(direction);
return priceScale ? priceScale.width() : 0;
} catch {
return 0;
}
}
public getDrawingsManager = (): DrawingsManager => {
return this.paneManager.getDrawingsManager();
};
public getIndicatorManager = (): IndicatorManager => {
return this.indicatorManager;
};
private onWheel = () => {
this.eventManager.resetInterval({ history: false });
};
private onPointerDown = () => {
this.isPointerDown = true;
this.didResetOnDrag = false;
};
private onPointerMove = () => {
if (!this.isPointerDown) return;
if (this.didResetOnDrag) return;
this.didResetOnDrag = true;
this.eventManager.resetInterval({ history: false });
};
private onPointerUp = () => {
this.isPointerDown = false;
};
public getDom(): DOMModel {
return this.DOM;
}
public getMainSeries(): Observable<SeriesStrategies | null> {
return this.mainSeries.asObservable();
}
public getCompareManager(): CompareManager {
return this.compareManager;
}
public updateTheme(theme: ThemeKey, mode: ThemeMode) {
this.lwcChart.applyOptions(getOptions({ ...this.chartConfig, theme, mode }));
this.priceAxisLabelsController.invalidate();
}
public destroy(): void {
this.priceAxisLabelsController.destroy();
this.mouseEvents.destroy();
this.compareManager.clear();
this.subscriptions.unsubscribe();
this.lwcChart.remove();
}
public subscribeChartEvent: ChartMouseEvents['subscribe'] = (event, callback) =>
this.mouseEvents.subscribe(event, callback);
public unsubscribeChartEvent: ChartMouseEvents['unsubscribe'] = (event, callback) => {
this.mouseEvents.unsubscribe(event, callback);
};
// todo: add/move to undo/redo model(eventManager)
public scrollTimeScale = (direction: Direction) => {
this.eventManager.resetInterval({ history: false });
const diff = direction === Direction.Left ? -2 : 2;
const currentPosition = this.lwcChart.timeScale().scrollPosition();
this.lwcChart.timeScale().scrollToPosition(currentPosition + diff, false);
};
// todo: add/move to undo/redo model(eventManager)
public zoomTimeScale = (resize: Resize) => {
this.eventManager.resetInterval({ history: false });
const diff = resize === Resize.Shrink ? -1 : 1;
const currentRange = this.lwcChart.timeScale().getVisibleRange();
if (!currentRange) return;
const { from, to } = currentRange as IRange<number>;
if (!from || !to) return;
const next: IRange<Time> = {
from: (from + (to - from) * 0.1 * diff) as Time,
to: to as Time,
};
this.lwcChart.timeScale().setVisibleRange(next);
};
// todo: add to undo/redo model(eventManager)
public resetZoom = () => {
this.eventManager.resetInterval({ history: false });
this.lwcChart.timeScale().resetTimeScale();
this.lwcChart.priceScale(Direction.Right).setAutoScale(true);
this.lwcChart.priceScale(Direction.Left).setAutoScale(true);
};
public getRealtimeApi() {
return {
getTimeframe: () => this.eventManager.getTimeframe(),
getSymbols: () => this.activeSymbols,
update: (symbol: string, candle: Candle) => {
this.dataSource.updateRealtime(symbol, candle);
},
};
}
public getSnapshot(): ChartSnapshot {
return {
panes: this.paneManager.getSnapshot(),
chartSeriesType: this.eventManager.exportChartSettings().seriesSelected,
timeframe: this.eventManager.exportChartSettings().timeframe,
symbol: this.activeSymbols[0],
};
}
private scheduleHistoryBatch = () => {
if (this.historyBatchRunning) return;
this.historyBatchRunning = true;
requestAnimationFrame(() => {
const symbols = this.activeSymbols.slice();
Promise.all(symbols.map((s) => this.dataSource.loadMoreHistory(s))).finally(() => {
this.historyBatchRunning = false;
const range = this.lwcChart.timeScale().getVisibleLogicalRange();
if (range && range.from < HISTORY_LOAD_THRESHOLD) {
this.scheduleHistoryBatch();
}
});
});
};
private setupDataSourceSubs() {
const getWarmupFrom = (): number => {
if (this.currentInterval && this.currentInterval !== Intervals.All) {
return getIntervalRange(this.currentInterval).from;
}
const range = this.lwcChart.timeScale().getVisibleRange();
if (!range) return 0;
const { from } = range as IRange<number>;
return from as number;
};
const warmupSymbols = (symbols: string[]) => {
if (!symbols.length) return;
const from = getWarmupFrom();
if (!from) return;
Promise.all(symbols.map((symbol) => this.dataSource.loadTill(symbol, from))).catch((error) => {
console.error('[Chart] Ошибка при прогреве символов:', error);
});
};
const symbols$ = combineLatest([this.eventManager.symbol(), this.compareManager.itemsObs()]).pipe(
map(([main, items]) => Array.from(new Set([main, ...items.map((i) => i.symbol)]))),
);
this.subscriptions.add(
this.eventManager
.getInterval()
.pipe(withLatestFrom(symbols$))
.subscribe(([interval, symbols]) => {
this.currentInterval = interval;
if (!interval) return;
if (interval === Intervals.All) {
Promise.all(symbols.map((s) => this.dataSource.loadAllHistory(s)))
.then(() => {
requestAnimationFrame(() => this.lwcChart.timeScale().fitContent());
})
.catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));
return;
}
const { from, to } = getIntervalRange(interval);
Promise.all(symbols.map((s) => this.dataSource.loadTill(s, from)))
.then(() => {
this.lwcChart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
})
.catch((error) => {
console.error('[Chart] Ошибка при применении интервала:', error);
});
}),
);
this.subscriptions.add(
symbols$.subscribe((symbols) => {
const prevSymbols = this.activeSymbols;
this.activeSymbols = symbols;
this.dataSource.setSymbols(symbols);
const prevSet = new Set(prevSymbols);
const added: string[] = [];
for (let i = 0; i < symbols.length; i += 1) {
const s = symbols[i];
if (!s) continue;
if (prevSet.has(s)) continue;
added.push(s);
}
if (added.length) {
warmupSymbols(added);
}
}),
);
this.subscriptions.add(
combineLatest([
this.eventManager.symbol(),
this.compareManager.itemsObs(),
this.mainSeries.asObservable(),
]).subscribe(([main, items, serie]) => {
if (!serie) return;
const title = items.length ? main : '';
serie.getLwcSeries().applyOptions({ title });
}),
);
}
private setupHistoricalDataLoading(): void {
// todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
this.priceAxisLabelsController.setVisibleLogicalRange(logicalRange);
if (!logicalRange) return;
if (this.currentInterval === Intervals.All) return;
const needsMoreData = logicalRange.from < HISTORY_LOAD_THRESHOLD;
if (!needsMoreData) return;
this.scheduleHistoryBatch();
});
}
}
function getIntervalRange(interval: Intervals): { from: number; to: number } {
const { value, unit } = intervalsToDayjs[interval] as DayjsOffset;
const from = Math.floor(dayjs().subtract(value, unit).valueOf() / 1000);
const to = Math.floor(dayjs().valueOf() / 1000);
return { from, to };
}
function getOptions(config: ChartConfig): DeepPartial<ChartOptions> {
const timeFormat = config.timeFormat ?? Defaults.timeFormat;
const showTime = config.showTime ?? Defaults.showTime;
const use12HourFormat = timeFormat === '12h';
const timeFormatString = use12HourFormat ? 'h:mm A' : 'HH:mm';
const { colors } = getThemeStore();
const localization: LocalizationOptionsBase = {
locale: getLocale(),
priceFormatter: (priceValue: BarPrice) => {
return formatCompactNumber(priceValue);
},
};
return {
width: config.container.clientWidth,
height: config.container.clientHeight,
autoSize: true,
layout: {
background: { color: colors.chartBackground },
textColor: colors.chartTextPrimary,
},
grid: {
vertLines: { color: colors.chartGridLine },
horzLines: { color: colors.chartGridLine },
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 0 },
horzLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 2 },
},
timeScale: {
timeVisible: showTime,
secondsVisible: false,
tickMarkFormatter: createTickMarkFormatter(timeFormatString),
borderVisible: false,
allowBoldLabels: false,
rightOffset: 25,
},
rightPriceScale: {
textColor: colors.chartTextPrimary,
borderVisible: false,
},
localization,
};
}