Загрузка данных
import { MouseEventParams, Point, Time } from 'lightweight-charts';
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorsIds } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { CompareMode, IndicatorLabel, OHLCConfig } from '@src/types';
export interface LegendParams {
config: OHLCConfig;
eventManager: EventManager;
indicators: BehaviorSubject<Map<string, Indicator>>;
subscribeChartEvent: ChartMouseEvents['subscribe'];
mainSeries: BehaviorSubject<SeriesStrategies | null> | null;
paneId: number;
paneIndex: () => number;
openIndicatorSettings: (id: IndicatorsIds, indicator: Indicator) => void;
}
export interface Ohlc {
time: Time;
open?: number;
high?: number;
low?: number;
close?: number;
value?: number;
absoluteChange?: number;
percentageChange?: number;
}
export interface CompareLegendItem {
symbolId: string;
symbol: string;
symbolName: string;
mode: CompareMode;
value: string;
color: string;
}
export interface LegendVM {
vm: LegendModel;
}
export type LegendModel = {
id: string;
name: IndicatorLabel | string;
isIndicator: boolean;
values: Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>>;
remove?: () => void;
settings?: () => void;
}[];
export const ohlcValuesToShowForMainSerie = [
// 'time',
'open',
'high',
'low',
'close',
'value',
// 'absoluteChange',
// 'percentageChange',
];
export const ohlcValuesToShowForIndicators = [
// 'time',
'open',
// 'high',
// 'low',
// 'close',
'value',
// 'absoluteChange',
// 'percentageChange'
];
export class Legend {
private eventManager: EventManager;
private indicators: Map<string, Indicator> = new Map();
private config: OHLCConfig;
private mainSeries!: SeriesStrategies;
private mainSymbolName = '';
private isChartHovered = false;
private model$ = new BehaviorSubject<LegendModel>([]);
private tooltipVisability = new BehaviorSubject<boolean>(false);
private tooltipPos = new BehaviorSubject<null | Point>(null);
private paneId: number;
private paneIndex: () => number;
private openIndicatorSettings: (id: IndicatorsIds, indicator: Indicator) => void;
private subscriptions = new Subscription();
private indicatorSubscriptions = new Subscription();
private mainSeriesSubscription = new Subscription();
constructor({
config,
eventManager,
indicators,
subscribeChartEvent,
mainSeries,
paneId,
paneIndex,
openIndicatorSettings,
}: LegendParams) {
this.config = config;
this.eventManager = eventManager;
this.paneId = paneId;
this.paneIndex = paneIndex;
this.openIndicatorSettings = openIndicatorSettings;
this.subscriptions.add(
this.eventManager.symbolName().subscribe((symbolName) => {
this.mainSymbolName = symbolName;
this.updateWithLastCandle();
}),
);
if (!mainSeries) {
this.subscriptions.add(
indicators.subscribe((value: Map<string, Indicator>) => {
this.indicators = value;
this.handleIndicatorSeriesDataChange();
}),
);
} else {
this.subscriptions.add(
combineLatest([mainSeries, indicators]).subscribe(([mainSerie, inds]) => {
if (!mainSerie) {
return;
}
this.mainSeries = mainSerie;
this.indicators = inds;
this.handleMainSeriesChange();
this.handleIndicatorSeriesDataChange();
}),
);
}
this.subscriptions.add(subscribeChartEvent('crosshairMove', this.handleCrosshairMove));
}
public subscribeCursorPosition(cb: (point: Point | null) => void) {
this.subscriptions.add(this.tooltipPos.subscribe(cb));
}
public subscribeCursorVisability(cb: (isVisible: boolean) => void) {
this.subscriptions.add(this.tooltipVisability.subscribe(cb));
}
private handleIndicatorSeriesDataChange = () => {
this.indicatorSubscriptions.unsubscribe();
this.indicatorSubscriptions = new Subscription();
for (const [_, indicator] of this.indicators) {
this.indicatorSubscriptions.add(indicator.subscribeDataChange(this.updateWithLastCandle));
}
this.updateWithLastCandle();
};
private handleMainSeriesChange = () => {
this.mainSeriesSubscription.unsubscribe();
this.mainSeriesSubscription = new Subscription();
const handler = () => this.updateWithLastCandle();
this.mainSeries?.subscribeDataChanged(handler);
this.mainSeriesSubscription.add(() => this.mainSeries?.unsubscribeDataChanged(handler));
};
private updateWithLastCandle = () => {
if (this.isChartHovered) return;
const model: LegendModel = [];
if (this.mainSeries) {
const series = new Map();
const serieData = this.mainSeries.getLegendData();
Object.entries(serieData).forEach(([key, sd]) => {
series.set(`${key}`, {
...sd,
});
});
model.push({
id: `main-series-${this.paneId}`,
name: this.mainSymbolName,
values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
isIndicator: false,
});
}
if (!this.indicators) {
return;
}
for (const [_, indicator] of this.indicators) {
const indicatorType = indicator.getType();
const indicatorSeries = new Map();
for (const [serieName, serie] of indicator.getSeriesMap()) {
if (!serie.isVisible()) {
continue;
}
const serieData = serie.getLegendData();
const value = serieData.value ?? serieData.close;
if (!value) continue;
indicatorSeries.set(serieName, { ...value, name: indicator.getSeriesLabel(serieName) });
}
model.push({
id: indicator.getId(),
name: indicator.getLabel(),
values: indicatorSeries as Partial<
Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>
>,
isIndicator: true,
remove: () => indicator.delete(),
settings:
indicatorType && indicator.hasSettings()
? () => this.openIndicatorSettings(indicatorType, indicator)
: undefined,
});
}
this.model$.next(model);
};
private handleCrosshairMove = (param: MouseEventParams) => {
// todo: есть одинаковый код с updateWithLastCandle
if (param.point === undefined || !param.time || param.point.x < 0 || param.point.y < 0) {
this.tooltipVisability.next(false);
this.isChartHovered = false;
this.updateWithLastCandle();
return;
}
if (param.seriesData.size === 0) {
this.tooltipVisability.next(false);
this.isChartHovered = false;
this.updateWithLastCandle();
return;
}
if (this.paneIndex() === param.paneIndex) {
this.tooltipVisability.next(true);
} else {
this.tooltipVisability.next(false);
}
this.isChartHovered = true;
const model: LegendModel = [];
if (this.mainSeries) {
const series = new Map();
const serieData = this.mainSeries.getLegendData(param);
Object.entries(serieData).forEach(([key, sd]) => {
series.set(`${key}`, {
...sd,
});
});
model.push({
id: `main-series-${this.paneId}`,
name: this.mainSymbolName,
values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
isIndicator: false,
});
}
if (!this.indicators) {
return;
}
for (const [_, indicator] of this.indicators) {
const indicatorType = indicator.getType();
const indicatorSeries = new Map();
for (const [serieName, serie] of indicator.getSeriesMap()) {
if (!serie.isVisible()) {
continue;
}
const serieData = serie.getLegendData(param);
const value = serieData.value ?? serieData.close;
if (!value) continue;
indicatorSeries.set(serieName, { ...value, name: indicator.getSeriesLabel(serieName) });
}
model.push({
id: indicator.getId(),
name: indicator.getLabel(),
values: indicatorSeries as Partial<
Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>
>,
isIndicator: true,
remove: () => indicator.delete(),
settings:
indicatorType && indicator.hasSettings()
? () => this.openIndicatorSettings(indicatorType, indicator)
: undefined,
});
}
this.model$.next(model);
this.tooltipPos.next(param.point);
};
public getConfig(): OHLCConfig {
return this.config;
}
public getLegendViewModel(): Observable<LegendModel> {
return this.model$;
}
public destroy = () => {
this.subscriptions.unsubscribe();
this.indicatorSubscriptions.unsubscribe();
this.mainSeriesSubscription.unsubscribe();
this.model$.complete();
this.tooltipVisability.complete();
this.tooltipPos.complete();
};
}
import { IChartApi, IPaneApi, PriceScaleMode, Time } from 'lightweight-charts';
import { BehaviorSubject, combineLatest, Subscription } from 'rxjs';
import { ChartTooltip } from '@components/ChartTooltip';
import { LegendComponent } from '@components/Legend';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { ContainerManager } from '@core/ContainerManager';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { Hotkeys } from '@core/Hotkeys';
import { Indicator } from '@core/Indicator';
import { Legend } from '@core/Legend';
import { PriceScale, PriceScaleControls } from '@core/PriceScale';
import { ReactRenderer } from '@core/ReactRenderer';
import { TooltipService } from '@core/Tooltip';
import { UIRenderer } from '@core/UIRenderer';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { DrawingsNames, indicatorLabelById, MAIN_PANE_INDEX } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { t } from '@src/translations';
import { ActiveDrawingTool, Direction, OHLCConfig, TooltipConfig } from '@src/types';
import {
CompareSnapshot,
DOMObjectSnapshot,
IndicatorSnapshot,
ISerializable,
PaneSnapshot,
PriceScaleSide,
PriceScaleSnapshot,
} from '@src/types/snapshot';
import { ensureDefined } from '@src/utils';
export interface PaneParams {
id: number;
lwcChart: IChartApi;
eventManager: EventManager;
DOM: DOMModel;
isMainPane: boolean;
ohlcConfig: OHLCConfig;
dataSource: DataSource | null; // todo: deal with dataSource. На каких то пейнах он нужен, на каких то нет
basedOn?: Pane; // Pane на котором находится главная серия, или серия, по которой строятся серии на текущем пейне
subscribeChartEvent: ChartMouseEvents['subscribe'];
tooltipConfig: TooltipConfig;
onDelete: () => void;
chartContainer: HTMLElement;
modalRenderer: ModalRenderer;
initialPriceScales?: PriceScaleSnapshot[];
onPriceScaleStateChange: () => void;
leftPriceScaleVisible: boolean;
rightPriceScaleVisible: boolean;
hotkeys: Hotkeys;
addDrawingManager: (manager: DrawingsManager, paneId: number) => void;
removeDrawingManager: (paneId: number) => void;
setActiveTool: (name: ActiveDrawingTool) => void;
getActiveTool: () => ActiveDrawingTool;
getIsEndlessMode: () => boolean;
continueDrawing: (name: DrawingsNames) => void;
deselectAllPanes: () => void;
}
// todo: Pane, ему должна принадлежать mainSerie, а также IndicatorManager и drawingsManager, mouseEvents. Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: Учитывать, что есть линейка, которая рисуется одна для всех пейнов
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class Pane implements ISerializable<PaneSnapshot> {
private readonly id: number;
private readonly isMain: boolean;
private mainSeries = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
// todo: Отвратительный нейминг. Нужно оставить главной только эту серию и переименовать её localMainSeries => mainSerie
// Если вдруг понадобится главная серия главного пейна, то брать из КоллекцииПейнов(-сейчас PaneManager)
// serie to attach drawings
private localMainSeries = new BehaviorSubject<SeriesStrategies | null>(null);
private legend!: Legend;
private tooltip: TooltipService | undefined;
private readonly indicatorsMap = new BehaviorSubject<Map<string, Indicator>>(new Map());
private readonly lwcPane: IPaneApi<Time>;
private readonly lwcChart: IChartApi;
private readonly eventManager: EventManager;
private readonly drawingsManager: DrawingsManager;
private legendContainer!: HTMLElement;
private paneOverlayContainer!: HTMLElement;
private paneEmptyStateContainer!: HTMLElement;
private legendRenderer!: UIRenderer;
private tooltipRenderer: UIRenderer | undefined;
private readonly modalRenderer: ModalRenderer;
private readonly leftPriceScale: PriceScale;
private readonly rightPriceScale: PriceScale;
private readonly priceScaleControls: PriceScaleControls;
private readonly subscribeChartEvent: ChartMouseEvents['subscribe'];
private readonly onDelete: () => void;
private readonly onPriceScaleStateChange: () => void;
private readonly subscriptions = new Subscription();
private paneContainerSyncFrameId: number | null = null;
private isSelected = new BehaviorSubject<boolean>(false);
private deselectAllPanes: () => void;
constructor({
lwcChart,
eventManager,
dataSource,
DOM,
isMainPane,
ohlcConfig,
id,
basedOn,
subscribeChartEvent,
tooltipConfig,
onDelete,
chartContainer,
modalRenderer,
initialPriceScales = [],
onPriceScaleStateChange,
leftPriceScaleVisible,
rightPriceScaleVisible,
hotkeys,
addDrawingManager,
removeDrawingManager,
setActiveTool,
getActiveTool,
getIsEndlessMode,
continueDrawing,
deselectAllPanes,
}: PaneParams) {
this.onDelete = onDelete;
this.onPriceScaleStateChange = onPriceScaleStateChange;
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.modalRenderer = modalRenderer;
this.subscribeChartEvent = subscribeChartEvent;
this.isMain = isMainPane;
this.id = id;
this.deselectAllPanes = deselectAllPanes;
if (isMainPane) {
this.lwcPane = this.lwcChart.panes()[MAIN_PANE_INDEX];
} else {
this.lwcPane = this.lwcChart.addPane(true);
}
this.leftPriceScale = this.createPriceScale(Direction.Left, initialPriceScales, leftPriceScaleVisible);
this.rightPriceScale = this.createPriceScale(Direction.Right, initialPriceScales, rightPriceScaleVisible);
// TODO: Перенести PriceScaleControls внутрь PriceScale, чтобы каждая шкала владела собственными контролами, а PriceScaleControls работал только с одной шкалой.
this.priceScaleControls = new PriceScaleControls({
leftPriceScale: this.leftPriceScale,
rightPriceScale: this.rightPriceScale,
onPriceScaleChange: this.handlePriceScaleStateChange,
});
this.initializeLegend({ ohlcConfig });
this.tooltip = new TooltipService({
config: tooltipConfig,
legend: this.legend,
paneOverlayContainer: this.paneOverlayContainer,
});
this.tooltipRenderer = new ReactRenderer(this.paneOverlayContainer);
this.tooltipRenderer.renderComponent(
<ChartTooltip
formatObs={this.eventManager.getChartOptionsModel()}
timeframeObs={this.eventManager.getTimeframeObs()}
viewModel={this.tooltip.getTooltipViewModel()}
tooltipConfig={this.tooltip.getConfig()}
/>,
);
if (dataSource) {
this.initializeMainSerie({ lwcChart, dataSource });
} else if (basedOn) {
this.mainSeries = basedOn.getMainSerie();
} else {
console.error('[Pane]: There is no any mainSerie for new pane');
}
this.subscriptions.add(
this.mainSeries.subscribe(() => {
this.rebindIndicators();
}),
);
this.subscriptions.add(
combineLatest([this.eventManager.hasSymbol(), this.indicatorsMap]).subscribe(([hasMainSymbol, indicators]) => {
const hasIndicator = Array.from(indicators.values()).some(
(indicator) => indicator.getIndicatorType() !== undefined,
);
this.paneEmptyStateContainer.hidden = hasMainSymbol || !hasIndicator;
}),
);
this.drawingsManager = new DrawingsManager({
// todo: менеджер дровингов должен быть один на чарт, не на пейн
pane: this,
eventManager,
DOM,
mainSeries$: this.localMainSeries.asObservable(),
lwcChart,
container: chartContainer,
modalRenderer: this.modalRenderer,
paneId: this.id,
hotkeys,
setActiveTool,
getActiveTool,
getIsEndlessMode,
continueDrawing,
});
addDrawingManager(this.drawingsManager, this.id);
this.subscriptions.add(() => removeDrawingManager(this.id));
this.subscriptions.add(
this.drawingsManager.entities().subscribe((drawings) => {
const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);
this.legendContainer.style.display = hasRuler ? 'none' : '';
}),
);
this.subscriptions.add(
this.drawingsManager.entities().subscribe((drawings) => {
const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);
this.legendContainer.style.display = hasRuler ? 'none' : '';
}),
);
}
public setSelected = () => {
this.deselectAllPanes();
this.isSelected.next(true);
};
public deselected = () => {
this.isSelected.next(false);
};
public getLocalMainSeries = () => {
return this.localMainSeries.value;
};
public setLocalMainSeries = (next: SeriesStrategies) => {
if (!this.isMain) {
this.localMainSeries.next(next);
}
};
public isMainPane = () => {
return this.isMain;
};
public getDrawingsSnapshot(): DrawingsManagerSnapshot {
return this.drawingsManager.getSnapshot();
}
public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
this.drawingsManager.setSnapshot(snapshot);
}
public getMainSerie = () => {
return this.mainSeries;
};
public getId = () => {
return this.id;
};
public isReady = async (interval = 50): Promise<void> => {
return new Promise((resolve) => {
const check = () => {
if (this.lwcPane.getHTMLElement() === null) {
setTimeout(check, interval);
} else {
resolve();
}
};
check();
});
};
public getHTMLElement = () => {
return this.lwcPane.getHTMLElement();
};
public paneIndex = () => {
return this.lwcPane.paneIndex();
};
public getPriceScale(side: PriceScaleSide): PriceScale {
return side === Direction.Left ? this.leftPriceScale : this.rightPriceScale;
}
public setIndicator(indicatorId: string, indicator: Indicator): void {
const indicators = new Map(this.indicatorsMap.value);
indicators.set(indicatorId, indicator);
this.indicatorsMap.next(indicators);
this.priceScaleControls.refresh();
}
public removeIndicator(indicatorId: string): void {
if (!this.indicatorsMap.value.has(indicatorId)) {
return;
}
const indicators = new Map(this.indicatorsMap.value);
indicators.delete(indicatorId);
this.indicatorsMap.next(indicators);
this.priceScaleControls.refresh();
if (indicators.size === 0 && !this.isMain) {
this.onDelete();
}
}
public getDrawingManager(): DrawingsManager {
return this.drawingsManager;
}
public schedulePaneContainerSync(): void {
if (this.paneContainerSyncFrameId !== null) {
return;
}
this.paneContainerSyncFrameId = requestAnimationFrame(() => {
this.paneContainerSyncFrameId = null;
this.syncPaneContainers();
});
}
public refreshPriceScaleControls(): void {
this.priceScaleControls.refresh();
}
public resetPriceScalesAutoScale(): void {
this.leftPriceScale.enableAutoScale();
this.rightPriceScale.enableAutoScale();
this.handlePriceScaleStateChange();
}
public getSnapshot(): PaneSnapshot {
const indicators: (DOMObjectSnapshot & (IndicatorSnapshot | CompareSnapshot))[] = [];
this.indicatorsMap.value.forEach((indicator) => {
indicators.push(indicator.getSnapshot());
});
return {
isMain: this.isMain,
id: this.id,
indicators,
drawings: this.getDrawingsSnapshot(),
priceScales: [this.leftPriceScale.getSnapshot(), this.rightPriceScale.getSnapshot()],
};
}
public destroy(): void {
if (this.paneContainerSyncFrameId !== null) {
cancelAnimationFrame(this.paneContainerSyncFrameId);
this.paneContainerSyncFrameId = null;
}
this.drawingsManager.destroy();
this.subscriptions.unsubscribe();
this.tooltip?.destroy();
this.legend?.destroy();
this.legendRenderer.destroy();
this.tooltipRenderer?.destroy();
this.priceScaleControls.destroy();
this.paneEmptyStateContainer.remove();
this.legendContainer.remove();
this.paneOverlayContainer.remove();
this.indicatorsMap.complete();
this.localMainSeries.complete();
if (this.isMain) {
this.mainSeries.value?.destroy();
this.mainSeries.complete();
}
}
private createPriceScale(
side: PriceScaleSide,
initialPriceScales: PriceScaleSnapshot[],
initialVisible: boolean,
): PriceScale {
const initialMode =
initialPriceScales.find((priceScaleSnapshot) => priceScaleSnapshot.side === side)?.mode ?? PriceScaleMode.Normal;
return new PriceScale({
paneId: this.id,
side,
pane: this.lwcPane,
initialMode,
initialVisible,
hasVisibleSeriesData: () => this.hasVisibleSeriesData(side),
});
}
private hasVisibleSeriesData(side: PriceScaleSide): boolean {
const mainSeries = this.mainSeries.value;
const defaultPriceScaleSide = this.lwcChart.options().defaultVisiblePriceScaleId as PriceScaleSide;
if (this.isMain && side === defaultPriceScaleSide && mainSeries?.isVisible() && mainSeries.data().length > 0) {
return true;
}
const indicators = Array.from(this.indicatorsMap.value.values());
for (let indicatorIndex = 0; indicatorIndex < indicators.length; indicatorIndex += 1) {
const series = Array.from(indicators[indicatorIndex].getSeriesMap().values());
for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) {
const currentSeries = series[seriesIndex];
const seriesPriceScaleSide = (currentSeries.options().priceScaleId ?? defaultPriceScaleSide) as PriceScaleSide;
if (currentSeries.isVisible() && currentSeries.data().length > 0 && seriesPriceScaleSide === side) {
return true;
}
}
}
return false;
}
private handlePriceScaleStateChange = (): void => {
this.priceScaleControls.refresh();
this.onPriceScaleStateChange();
};
private initializeLegend({ ohlcConfig }: { ohlcConfig: OHLCConfig }): void {
const { legendContainer, paneOverlayContainer, paneEmptyStateContainer } = ContainerManager.createPaneContainers();
this.legendContainer = legendContainer;
this.paneOverlayContainer = paneOverlayContainer;
this.paneEmptyStateContainer = paneEmptyStateContainer;
this.paneEmptyStateContainer.textContent = t('Add a main instrument to display indicators');
this.legendRenderer = new ReactRenderer(legendContainer);
this.schedulePaneContainerSync();
this.legend = new Legend({
config: ohlcConfig,
indicators: this.indicatorsMap,
eventManager: this.eventManager,
subscribeChartEvent: this.subscribeChartEvent,
mainSeries: this.isMain ? this.mainSeries : null,
paneId: this.id,
paneIndex: this.paneIndex,
openIndicatorSettings: (indicatorId, indicator) => {
let settings = indicator.getSettingsValues();
this.modalRenderer.renderComponent(
<EntitySettingsModal
tabs={[
{
key: 'arguments',
label: t('Arguments'),
fields: indicator.getSettingsFields(),
},
]}
values={settings}
onChange={(nextSettings) => {
settings = nextSettings;
}}
initialTabKey="arguments"
/>,
{
size: 'sm',
title: indicatorLabelById()[indicatorId],
onSave: () => indicator.updateSettings(settings),
},
);
},
// todo: throw isMainPane
});
this.legendRenderer.renderComponent(
<LegendComponent
ohlcConfig={this.legend.getConfig()}
viewModel={this.legend.getLegendViewModel()}
/>,
);
}
private rebindIndicators(): void {
for (const indicator of this.indicatorsMap.value.values()) {
indicator.recreateSeries();
}
}
private initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }): void {
this.localMainSeries = this.mainSeries;
this.subscriptions.add(
this.eventManager.subscribeSeriesSelected((nextSeries) => {
this.mainSeries.value?.destroy();
const next = ensureDefined(SeriesFactory.create(nextSeries))({
lwcChart,
dataSource,
mainSymbolId$: this.eventManager.symbolId(),
mainSymbol$: this.eventManager.symbol(),
mainSerie$: this.mainSeries,
});
this.mainSeries.next(next);
this.priceScaleControls.refresh();
}),
);
}
private syncPaneContainers(): void {
const lwcPaneElement = this.lwcPane.getHTMLElement();
if (!lwcPaneElement) {
this.schedulePaneContainerSync();
return;
}
/*
Внутри lightweight-chart DOM построен как таблица из 3 td
[0] left priceScale, [1] center chart, [2] right priceScale
Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
*/
const cells = lwcPaneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
const chartCell = cells.item(1);
if (!chartCell) {
this.schedulePaneContainerSync();
return;
}
chartCell.style.position = 'relative';
chartCell.appendChild(this.legendContainer);
chartCell.appendChild(this.paneEmptyStateContainer);
chartCell.appendChild(this.paneOverlayContainer);
this.priceScaleControls.mount(lwcPaneElement);
}
}
import { Observable } from 'rxjs';
import { GearIcon, TrashIcon } from '@components/Icon';
import { LegendModel, ohlcValuesToShowForMainSerie } from '@core/Legend';
import { Button } from '@src/components/Button';
import { OHLCConfig } from '@src/types';
import { formatDisplayText, useObservable } from '@src/utils';
import styles from './index.module.scss';
export interface LegendProps {
ohlcConfig?: OHLCConfig;
viewModel: Observable<LegendModel>;
}
const mainSerieKeys = new Set(ohlcValuesToShowForMainSerie);
function getEntries(values: LegendModel[number]['values']) {
return values instanceof Map ? Array.from(values.entries()) : Object.entries(values);
}
export const LegendComponent = ({ ohlcConfig, viewModel }: LegendProps) => {
const model = useObservable(viewModel);
const showMainSerieValues = Boolean(ohlcConfig?.show);
const shouldShowMainSerieValue = (key: string) => {
if (mainSerieKeys.has(key)) {
return showMainSerieValues;
}
if (key === 'absoluteChange') {
return ohlcConfig?.showAbsoluteChange ?? false;
}
if (key === 'percentageChange') {
return ohlcConfig?.showPercentageChange ?? true;
}
return false;
};
return (
<section className={styles.legend}>
{model?.map((item) => (
<div
key={item.id}
className={styles.row}
>
<div className={styles.symbol}>{item.name}</div>
{getEntries(item.values).map(([key, value]) => {
if (!item.isIndicator && !shouldShowMainSerieValue(key)) {
return null;
}
if (!value) {
return null;
}
const text = formatDisplayText(value.value);
if (!text) {
return null;
}
return (
<div
key={`${item.name}-${key}`}
className={styles.item}
>
{!item.isIndicator && value.name && <span>{value.name}</span>}
<span style={{ color: value.color }}>{text ?? '-'}</span>
</div>
);
})}
{item.settings && (
<Button
className={styles.button}
onClick={item.settings}
label={<GearIcon />}
/>
)}
{item.remove && (
<Button
className={styles.button}
onClick={item.remove}
label={<TrashIcon />}
/>
)}
</div>
))}
</section>
);
};
import dayjs from 'dayjs';
import {
ColorType,
createChart,
createSeriesMarkers,
CrosshairMode,
type IChartApi,
type ISeriesApi,
type ISeriesMarkersPluginApi,
LineSeries,
LineStyle,
type MouseEventParams,
PriceScaleMode,
type Time,
} from 'lightweight-charts';
import { BehaviorSubject } from 'rxjs';
import { createInitialSeasonalityState, DEFAULT_SEASONALITY_YEARS } from '@src/constants';
import { ZIndex } from '@src/core/ContainerManager';
import { COMPARE_COLOR_PALETTE } from '@src/theme';
import { getThemeStore } from '@src/theme/store';
import { getLocale, t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import {
buildSeasonality,
buildSeasonalityTable,
canvasToBlob,
drawTextLayer,
formatCompactNumber,
formatPercent,
getPercentPrecisionStep,
getPricePrecisionStep,
normalizeRange,
toSeriesData,
} from '@src/utils';
import { buildSeasonalityAverage } from '@src/utils/seasonality';
import type { DataSourceParams } from '@src/core/DataSource';
import type {
Candle,
DailyClose,
SeasonalityRange,
SeasonalityScale,
SeasonalityTableRow,
SeasonalityYear,
} from '@src/types';
interface SeasonalityManagerParams {
container: HTMLElement;
getData: DataSourceParams['getData'];
}
interface SeasonalityPlot {
year: SeasonalityYear;
series: ISeriesApi<'Line'>;
legend: HTMLSpanElement;
legendValue: HTMLSpanElement;
markers?: ISeriesMarkersPluginApi<Time>;
}
interface SeasonalityAveragePlot {
series: ISeriesApi<'Line'>;
legend: HTMLSpanElement;
legendValue: HTMLSpanElement;
data: ReturnType<typeof buildSeasonalityAverage>;
}
export class SeasonalityManager {
private readonly currentYear = dayjs().year();
private readonly state = new BehaviorSubject(createInitialSeasonalityState());
private readonly chartContainer: HTMLDivElement;
private readonly legend: HTMLDivElement;
private readonly statusElement: HTMLDivElement;
private readonly plots = new Map<number, SeasonalityPlot>();
private chart: IChartApi | null = null;
private averagePlot: SeasonalityAveragePlot | null = null;
private allYears: SeasonalityYear[] = [];
private tableRows: SeasonalityTableRow[] = [];
private requestId = 0;
private renderFrame: number | null = null;
private destroyed = false;
constructor(private readonly params: SeasonalityManagerParams) {
const { chartContainer, legend, statusElement } = createSeasonalityElements(params.container);
this.chartContainer = chartContainer;
this.legend = legend;
this.statusElement = statusElement;
this.updateTheme();
}
public getState() {
return this.state.asObservable();
}
public async show(symbolId: string, range?: SeasonalityRange): Promise<void> {
if (this.destroyed || this.reuseHistory(symbolId, range)) {
return;
}
const requestId = ++this.requestId;
this.reset();
this.showStatus(t('Loading seasonality'));
this.state.next({
...this.state.value,
symbolId,
status: 'loading',
tableRows: [],
error: null,
});
try {
if (!symbolId) {
throw new Error('Select an instrument');
}
const rows = await this.loadHistory(symbolId, requestId);
if (!this.isCurrent(requestId)) {
return;
}
this.allYears = buildSeasonality(rows);
this.tableRows = buildSeasonalityTable(this.allYears);
this.setInitialRange(range);
} catch (error) {
if (!this.isCurrent(requestId)) {
return;
}
this.reset();
this.showStatus(t('Unable to load seasonality'));
this.state.next({
...this.state.value,
status: 'error',
tableRows: [],
error: String(error),
});
console.error('[Seasonality] History loading failed:', error);
}
}
public setRange = (range: SeasonalityRange): void => {
if (this.destroyed || this.allYears.length === 0) {
return;
}
const state = this.state.value;
const next = normalizeRange(range, state.minYear, state.maxYear);
if (next.fromYear === state.fromYear && next.toYear === state.toYear) {
return;
}
this.state.next({ ...state, ...next });
if (this.renderFrame !== null) {
return;
}
this.renderFrame = requestAnimationFrame(() => {
this.renderFrame = null;
if (!this.destroyed && this.allYears.length > 0) {
this.renderRange();
}
});
};
public setDisplayMode = (displayMode: SeasonalityViewType): void => {
if (this.destroyed || this.state.value.displayMode === displayMode) {
return;
}
this.state.next({ ...this.state.value, displayMode });
};
public setAverageVisible = (showAverage: boolean): void => {
if (this.destroyed || this.state.value.showAverage === showAverage) {
return;
}
this.state.next({ ...this.state.value, showAverage });
this.updateAveragePlot(this.getSelectedYears());
this.renderLegend();
this.updateLegend();
};
public setScale = (scale: SeasonalityScale): void => {
if (this.destroyed || this.state.value.scale === scale) {
return;
}
this.state.next({ ...this.state.value, scale });
if (!this.chart) {
return;
}
this.plots.forEach((plot) => this.updateSeries(plot));
this.updateAveragePlot(this.getSelectedYears());
this.fitCalendar();
this.updateStatus();
this.updateLegend();
};
public clear = (): void => {
if (this.destroyed) {
return;
}
this.requestId += 1;
this.reset();
this.state.next({
...this.state.value,
status: 'idle',
tableRows: [],
error: null,
});
};
public updateTheme(): void {
const { colors } = getThemeStore();
Object.assign(this.params.container.style, {
backgroundColor: colors.chartBackground,
});
Object.assign(this.statusElement.style, {
color: colors.chartTextPrimary,
});
this.legend.style.color = colors.chartTextPrimary;
this.chart?.applyOptions(getThemeOptions());
if (this.averagePlot) {
this.averagePlot.series.applyOptions({ color: colors.chartTextPrimary });
this.averagePlot.legendValue.style.color = colors.chartTextPrimary;
}
}
public async copyImage(): Promise<void> {
if (
!this.chart ||
this.state.value.status !== 'ready' ||
this.state.value.displayMode !== SeasonalityViewType.Chart
) {
return;
}
const canvas = this.chart.takeScreenshot(true, false);
drawTextLayer(canvas, this.legend, this.chart.chartElement());
const blob = await canvasToBlob(canvas);
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
}
public destroy(): void {
if (this.destroyed) {
return;
}
this.destroyed = true;
this.requestId += 1;
this.reset();
this.chartContainer.remove();
this.legend.remove();
this.statusElement.remove();
this.state.complete();
}
private isCurrent(requestId: number): boolean {
return !this.destroyed && requestId === this.requestId;
}
private reuseHistory(symbolId: string, range?: SeasonalityRange): boolean {
const state = this.state.value;
if (state.symbolId !== symbolId) {
return false;
}
if (state.status === 'loading') {
return true;
}
if (this.allYears.length === 0) {
return false;
}
if (range) {
this.setRange(range);
}
return true;
}
private async loadHistory(symbolId: string, requestId: number): Promise<DailyClose[]> {
const candles = new Map<number, Candle>();
const loadPage = async (cursor?: Candle): Promise<void> => {
if (!this.isCurrent(requestId)) {
return;
}
const loaded = await this.params.getData(Timeframes['1d'], symbolId, cursor);
if (!this.isCurrent(requestId) || !loaded?.length) {
return;
}
const { oldest, newestTime } = mergeHistoryPage(loaded, candles);
if (cursor && oldest.time >= cursor.time) {
const isBoundaryOnly = oldest.time === cursor.time && newestTime === cursor.time;
if (isBoundaryOnly) {
return;
}
throw new Error(
`History pagination made no progress: cursor=${cursor.time}, ` +
`oldest=${oldest.time}, newest=${newestTime}, count=${loaded.length}`,
);
}
return loadPage(oldest);
};
await loadPage();
if (!this.isCurrent(requestId)) {
return [];
}
return Array.from(candles.values(), (candle) => ({
date: dayjs.unix(candle.time).format('YYYY-MM-DD'),
close: candle.close,
}));
}
private setInitialRange(range?: SeasonalityRange): void {
if (this.allYears.length === 0) {
this.showStatus(t('No seasonality data'));
this.state.next({
...this.state.value,
status: 'empty',
});
return;
}
const minYear = this.allYears[0].year;
const maxYear = this.allYears[this.allYears.length - 1].year;
const selected = normalizeRange(
range ?? {
fromYear: maxYear - DEFAULT_SEASONALITY_YEARS + 1,
toYear: maxYear,
},
minYear,
maxYear,
);
this.state.next({
...this.state.value,
...selected,
minYear,
maxYear,
});
this.renderRange();
}
private createChart(): IChartApi {
const chart = createChart(this.chartContainer, {
...getThemeOptions(),
autoSize: true,
handleScroll: false,
handleScale: false,
kineticScroll: {
mouse: false,
touch: false,
},
rightPriceScale: {
mode: PriceScaleMode.Normal,
autoScale: true,
alignLabels: true,
visible: true,
borderVisible: false,
},
leftPriceScale: {
visible: false,
},
timeScale: {
timeVisible: false,
secondsVisible: false,
borderVisible: false,
allowBoldLabels: false,
rightOffset: 0,
tickMarkFormatter: formatSeasonDate,
lockVisibleTimeRangeOnResize: true,
},
localization: {
locale: getLocale(),
timeFormatter: formatSeasonDate,
},
});
chart.subscribeCrosshairMove(this.onCrosshairMove);
this.chart = chart;
return chart;
}
private renderRange(): void {
const chart = this.chart ?? this.createChart();
const { fromYear, toYear } = this.state.value;
const years = this.getSelectedYears();
this.plots.forEach((plot, year) => {
if (year < fromYear || year > toYear) {
plot.markers?.detach();
chart.removeSeries(plot.series);
this.plots.delete(year);
}
});
const plots = years.map((year) => {
const existing = this.plots.get(year.year);
if (existing) {
return existing;
}
const plot = this.createPlot(chart, year);
this.plots.set(year.year, plot);
return plot;
});
this.updateAveragePlot(years);
this.renderLegend();
if (plots.length > 0) {
this.fitCalendar();
}
this.updateStatus();
this.updateLegend();
this.state.next({
...this.state.value,
status: plots.length > 0 ? 'ready' : 'empty',
tableRows: this.tableRows.filter((row) => row.year >= fromYear && row.year <= toYear),
});
}
private getSelectedYears(): SeasonalityYear[] {
const { fromYear, toYear } = this.state.value;
return this.allYears.filter((year) => year.year >= fromYear && year.year <= toYear);
}
private renderLegend(): void {
const plots = [...this.plots.values()].sort((a, b) => a.year.year - b.year.year);
const elements = plots.flatMap((plot) => [plot.legend, plot.legendValue]);
if (this.averagePlot) {
elements.push(this.averagePlot.legend, this.averagePlot.legendValue);
}
this.legend.replaceChildren(...elements);
}
private updateAveragePlot(years: readonly SeasonalityYear[]): void {
if (!this.chart) {
return;
}
if (!this.state.value.showAverage || years.length === 0) {
if (this.averagePlot) {
this.chart.removeSeries(this.averagePlot.series);
this.averagePlot = null;
}
return;
}
if (!this.averagePlot) {
const legend = document.createElement('span');
const legendValue = document.createElement('span');
const color = getThemeStore().colors.chartTextPrimary;
legend.textContent = t('Avg');
Object.assign(legendValue.style, { color, textAlign: 'right' });
this.averagePlot = {
legend,
legendValue,
data: [],
series: this.chart.addSeries(LineSeries, {
title: t('Avg'),
color,
lineStyle: LineStyle.Dashed,
lineWidth: 2,
priceLineVisible: false,
lastValueVisible: true,
pointMarkersVisible: false,
crosshairMarkerVisible: true,
}),
};
}
this.updatePriceFormat(this.averagePlot.series);
this.averagePlot.data = buildSeasonalityAverage(years, this.state.value.scale);
this.averagePlot.series.setData(this.averagePlot.data);
}
private fitCalendar(): void {
this.chart?.timeScale().setVisibleRange({
from: '2000-01-01',
to: '2000-12-31',
});
}
private createPlot(chart: IChartApi, year: SeasonalityYear): SeasonalityPlot {
const color = getYearColor(year.year);
const legend = document.createElement('span');
const legendValue = document.createElement('span');
legend.textContent = String(year.year);
Object.assign(legendValue.style, {
color,
textAlign: 'right',
});
const plot: SeasonalityPlot = {
year,
legend,
legendValue,
series: chart.addSeries(LineSeries, {
title: String(year.year),
color,
priceLineVisible: false,
lastValueVisible: true,
lineWidth: year.year === this.currentYear ? 3 : 1,
pointMarkersVisible: false,
crosshairMarkerVisible: true,
}),
};
if (year.year === this.currentYear) {
plot.markers = createSeriesMarkers(plot.series, [], {
autoScale: false,
});
}
this.updateSeries(plot);
return plot;
}
private updateSeries({ series, year, markers }: SeasonalityPlot): void {
const { scale } = this.state.value;
const isPercentage = scale === PriceScaleMode.Percentage;
this.updatePriceFormat(series);
series.setData(toSeriesData(year, scale));
if (markers) {
const lastPoint = year.points[year.points.length - 1];
const value = isPercentage ? lastPoint.percentage : lastPoint.price;
markers.setMarkers(
value === null
? []
: [
{
time: lastPoint.chartDate,
position: 'inBar',
shape: 'circle',
color: getYearColor(year.year),
size: 1,
},
],
);
}
}
private updatePriceFormat(series: ISeriesApi<'Line'>): void {
const isPercentage = this.state.value.scale === PriceScaleMode.Percentage;
series.applyOptions({
priceFormat: {
type: 'custom',
minMove: isPercentage ? getPercentPrecisionStep() : getPricePrecisionStep(),
formatter: (value: number) => (isPercentage ? formatPercent(value) : formatCompactNumber(value)),
},
});
}
private updateStatus(): void {
if (this.plots.size === 0) {
this.showStatus(t('No seasonality data'));
return;
}
const isPercentage = this.state.value.scale === PriceScaleMode.Percentage;
const hasBaseline = Array.from(this.plots.values()).some((plot) => plot.year.baselinePrice > 0);
if (isPercentage && !hasBaseline) {
this.showStatus(t('Percentage requires a positive baseline'));
return;
}
this.statusElement.style.display = 'none';
}
private onCrosshairMove = (event: MouseEventParams): void => {
this.updateLegend(event);
};
private updateLegend(event?: MouseEventParams): void {
const isPercentage = this.state.value.scale === PriceScaleMode.Percentage;
this.plots.forEach(({ year, series, legendValue }) => {
const lastPoint = year.points[year.points.length - 1];
let value = isPercentage ? lastPoint.percentage : lastPoint.price;
if (event?.time !== undefined) {
value = getCrosshairValue(event, series);
}
legendValue.textContent = value === null ? '—' : series.priceFormatter().format(value);
});
this.updateAverageLegend(event);
}
private updateAverageLegend(event?: MouseEventParams): void {
const average = this.averagePlot;
if (!average) {
return;
}
let value: number | null = null;
if (event?.time !== undefined) {
value = getCrosshairValue(event, average.series);
} else {
for (const point of average.data) {
if ('value' in point) {
value = point.value;
}
}
}
average.legend.textContent = t('Avg');
average.legendValue.textContent = value === null ? '—' : average.series.priceFormatter().format(value);
}
private showStatus(message: string): void {
this.statusElement.textContent = message;
this.statusElement.style.display = 'flex';
}
private reset(): void {
if (this.renderFrame !== null) {
cancelAnimationFrame(this.renderFrame);
this.renderFrame = null;
}
this.chart?.unsubscribeCrosshairMove(this.onCrosshairMove);
this.plots.forEach((plot) => plot.markers?.detach());
this.chart?.remove();
this.chart = null;
this.averagePlot = null;
this.allYears = [];
this.tableRows = [];
this.plots.clear();
this.legend.replaceChildren();
this.statusElement.style.display = 'none';
}
}
function getCrosshairValue(event: MouseEventParams, series: ISeriesApi<'Line'>): number | null {
const point = event.seriesData.get(series);
return point && 'value' in point && typeof point.value === 'number' ? point.value : null;
}
function mergeHistoryPage(loaded: Candle[], candles: Map<number, Candle>): { oldest: Candle; newestTime: number } {
let oldest = loaded[0];
let oldestTime = Infinity;
let newestTime = -Infinity;
for (let index = loaded.length - 1; index >= 0; index -= 1) {
const candle = loaded[index];
if (!Number.isFinite(candle.time) || !Number.isFinite(candle.close)) {
throw new Error('Invalid daily candle');
}
const time = Math.floor(candle.time > 1e10 ? candle.time / 1000 : candle.time);
const normalized = time === candle.time ? candle : { ...candle, time };
newestTime = Math.max(newestTime, time);
if (time < oldestTime) {
oldest = normalized;
oldestTime = time;
}
if (!candles.has(time)) {
candles.set(time, normalized);
}
}
return { oldest, newestTime };
}
function getThemeOptions() {
const { colors } = getThemeStore();
return {
layout: {
background: {
type: ColorType.Solid,
color: colors.chartBackground,
},
textColor: colors.chartTextPrimary,
panes: {
separatorColor: colors.chartPaneSeparator,
separatorHoverColor: colors.chartPaneSeparator,
enableResize: false,
},
},
grid: {
vertLines: {
color: colors.chartGridLine,
},
horzLines: {
color: colors.chartGridLine,
},
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: {
color: colors.chartCrosshairLine,
labelBackgroundColor: colors.chartCrosshairLabel,
style: LineStyle.Solid,
},
horzLine: {
color: colors.chartCrosshairLine,
labelBackgroundColor: colors.chartCrosshairLabel,
style: LineStyle.Dashed,
},
},
};
}
function formatSeasonDate(time: Time): string {
if (typeof time === 'object') {
const day = String(time.day).padStart(2, '0');
const month = String(time.month).padStart(2, '0');
return `${day}.${month}`;
}
const date = typeof time === 'number' ? new Date(time * 1000).toISOString() : time;
return `${date.slice(8, 10)}.${date.slice(5, 7)}`;
}
function getYearColor(year: number): string {
return COMPARE_COLOR_PALETTE[year % COMPARE_COLOR_PALETTE.length];
}
function createSeasonalityElements(container: HTMLElement) {
const chartContainer = document.createElement('div');
const legend = document.createElement('div');
const statusElement = document.createElement('div');
Object.assign(chartContainer.style, {
position: 'absolute',
inset: 0,
});
Object.assign(legend.style, {
width: 'max-content',
position: 'absolute',
top: 0,
left: 0,
display: 'grid',
gridTemplateColumns: 'max-content max-content',
justifyContent: 'start',
alignItems: 'center',
gap: 'var(--space-0125) var(--space-0500)',
fontSize: 'var(--space-0750)',
lineHeight: 'var(--space-1000)',
fontVariantNumeric: 'tabular-nums',
pointerEvents: 'none',
zIndex: ZIndex.Legend,
});
Object.assign(statusElement.style, {
position: 'absolute',
inset: 0,
display: 'none',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
pointerEvents: 'none',
});
container.append(chartContainer, legend, statusElement);
return { chartContainer, legend, statusElement };
}