Загрузка данных
import { type BehaviorSubject, distinctUntilChanged, type Observable, Subscription } from 'rxjs';
import { MAIN_PANE_INDEX } from '@src/constants';
import { type Candle, Direction } from '@src/types';
import { formatPrice, getPricePrecisionStep, isBarData, isLineData, normalizeSeriesData } from '@src/utils';
import type { DataSource } from '@core/DataSource';
import type { Indicator } from '@core/Indicator';
import type { ChartTypeToCandleData, IndicatorDataFormatter } from '@core/Indicators';
import type { Ohlc } from '@core/Legend';
import type { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import type {
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';
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;
mainSymbolId$: Observable<string>;
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.flatMap((candle) => {
if (typeof candle.time !== 'number' || !Number.isFinite(candle.time)) {
return [];
}
return [
{
...candle,
time: candle.time - offsetMinutes * secondsInMinute,
},
];
});
}
export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSeries<TSeries> {
protected lwcSeries: ISeriesApi<TSeries>;
protected customFormatter?: (params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][];
protected lwcChart: IChartApi;
protected mainSymbolId$: Observable<string>;
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,
mainSymbolId$,
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.mainSymbolId$ = mainSymbolId$;
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();
const currentBar = seriesData[seriesData.length - 1];
if (!currentBar) {
return {};
}
return this.formatLegendValues(currentBar, seriesData[seriesData.length - 2] ?? null);
}
const currentBar = param.seriesData.get(this.lwcSeries) ?? null;
const previousBar = param.logical === null ? null : this.dataByIndex(param.logical! - 1);
return this.formatLegendValues(currentBar, previousBar);
};
public show(): void {
this.lwcSeries.applyOptions({
visible: true,
});
}
public hide(): void {
this.lwcSeries.applyOptions({
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 {
this.lwcSeries.setData(normalizeSeriesData(data));
}
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 lastBar = data[data.length - 1];
if (!lastBar) {
this.lwcSeries.update(bar, false);
return;
}
const isHistoricalUpdate =
historicalUpdate ?? (typeof lastBar.time === 'number' && typeof bar.time === 'number' && bar.time < lastBar.time);
this.lwcSeries.update(bar, isHistoricalUpdate);
}
protected createSeries({
chart,
seriesOptions,
paneIndex = MAIN_PANE_INDEX,
priceScaleOptions = {},
}: CreateSeriesParams<TSeries>): ISeriesApi<TSeries> {
this.paneIndex = paneIndex;
const options = {
...this.getDefaultOptions(),
...seriesOptions,
};
const series = chart.addSeries<TSeries>(this.seriesDefinition(), options, paneIndex);
chart.priceScale(options.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: BarData | LineData | HistogramData | CustomData | null,
prevBar: BarData | LineData | HistogramData | CustomData | null,
): 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 data = this.applyTimezone(inputData);
if (!this.customFormatter) {
return this.formatMainSerie(data);
}
const mainSeriesData = (this.mainSerie$.value?.data() ?? []) as unknown as SerieData[];
const selfData = this.data() as unknown as ChartTypeToCandleData[TSeries][];
if (data.length !== 1) {
return this.customFormatter({
mainSeriesData,
selfData,
indicatorReference: this.indicatorReference ?? undefined,
});
}
const candle = this.formatMainSerie(data)[0];
if (!candle) {
return [];
}
return this.customFormatter({
mainSeriesData,
selfData,
candle: candle as unknown as SerieData,
indicatorReference: this.indicatorReference ?? undefined,
});
}
protected subscribeDataSource = (dataSource: DataSource): void => {
const minMove = getPricePrecisionStep();
this.lwcSeries.applyOptions({
priceFormat: {
type: 'custom',
minMove,
formatter: (price: number) => formatPrice(price) ?? String(price),
},
});
this.subscriptions.add(
this.mainSymbol$.pipe(distinctUntilChanged()).subscribe((symbol) => {
this.lwcSeries.applyOptions({
title: this.showSymbolLabel ? symbol : '',
});
}),
);
this.subscriptions.add(
this.mainSymbolId$.pipe(distinctUntilChanged()).subscribe((symbolId) => {
this.dataSub?.unsubscribe();
this.realtimeSub?.unsubscribe();
this.dataSub = dataSource.subscribe(symbolId, (next) => {
this.dataSourceSubscription(next);
});
this.realtimeSub = dataSource.subscribeRealtime(symbolId, (next: Candle) => {
this.dataSourceRealtimeSubscription(next);
});
}),
);
};
}
export function calcCandleChange(
prev: BarData | LineData | HistogramData | CustomData | null,
current: BarData | LineData | HistogramData | CustomData | null,
):
| (Ohlc & {
customValues?: Record<string, unknown>;
})
| 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: Number.isNaN(percentageChange) ? 0 : percentageChange,
};
}
if (isLineData(prev) && isLineData(current)) {
const absoluteChange = current.value - prev.value;
const percentageChange = ((current.value - prev.value) / prev.value) * 100;
const high = current.customValues?.high;
const low = current.customValues?.low;
return {
time: current.time,
value: current.value,
high: typeof high === 'number' ? high : current.value,
low: typeof low === 'number' ? low : current.value,
absoluteChange,
percentageChange: Number.isNaN(percentageChange) ? 0 : percentageChange,
customValues: current.customValues,
};
}
return null;
}
import {
BarData,
CandlestickSeries,
CustomData,
HistogramData,
LineData,
SeriesDataItemTypeMap,
SeriesDefinition,
SeriesPartialOptionsMap,
Time,
} from 'lightweight-charts';
import { Ohlc } from '@core/Legend';
import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@core/Series/BaseSeries';
import { ISeries } from '@src/modules/series-strategies';
import { getThemeStore } from '@src/theme/store';
import { t } from '@src/translations';
import { Candle, LineCandle } from '@src/types';
import { ensureDefined, formatCompactNumber, isBarData } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
export class CandlestickSeriesStrategy extends BaseSeries<'Candlestick'> implements ISeries<'Candlestick'> {
constructor(params: BaseSeriesParams<'Candlestick'>) {
super(params);
this.subscribeDataSource(params.dataSource);
}
protected seriesDefinition(): SeriesDefinition<'Candlestick'> {
return CandlestickSeries;
}
public getDefaultOptions(): SeriesPartialOptionsMap['Candlestick'] {
const { colors } = getThemeStore();
return {
upColor: colors.chartCandleUp,
downColor: colors.chartCandleDown,
borderVisible: false,
wickUpColor: colors.chartCandleWickUp,
wickDownColor: colors.chartCandleWickDown,
};
}
public validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean {
// todo: should be private
if (!Array.isArray(data)) {
return false;
}
return data.every((point) => {
if (!point) {
return false;
}
// Проверяем обязательные поля
if (typeof point.time !== 'number') {
return false;
}
// Если указаны OHLC, проверяем их корректность
if (
point.open !== undefined &&
point.high !== undefined &&
point.low !== undefined &&
point.close !== undefined
) {
return point.high >= Math.max(point.open, point.close) && point.low <= Math.min(point.open, point.close);
}
return true;
});
}
public getTypeName(): string {
return 'Candlestick';
}
protected dataSourceSubscription = (dataToSet: Candle[]): void => {
if (!this.validateData(dataToSet)) {
console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
return;
}
this.setData(this.formatData(dataToSet));
};
protected dataSourceRealtimeSubscription = (dataToSet: Candle): void => {
if (!this.validateData([dataToSet])) {
console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);
return;
}
const formattedData = this.formatData([dataToSet]);
this.update(formattedData[0], true);
};
protected formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>['Candlestick'][] {
return inputData.map((point) => ({
time: point.time as Time,
open: point.open,
high: point.high,
low: point.low,
close: point.close,
customValues: point as unknown as Record<string, unknown>,
}));
}
protected 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 }>> {
if (!currentBar || !isBarData(currentBar)) {
return {};
}
const { colors } = getThemeStore();
const color = removeAlphaFromHex(
currentBar.close < currentBar.open ? colors.chartCandleWickDown : colors.chartCandleWickUp,
);
const { absoluteChange, percentageChange, time } = ensureDefined(calcCandleChange(prevBar, currentBar));
return {
open: {
value: formatCompactNumber(currentBar.open) ?? '',
name: t('Open'),
color,
},
high: {
value: formatCompactNumber(currentBar.high) ?? '',
name: t('High'),
color,
},
low: {
value: formatCompactNumber(currentBar.low) ?? '',
name: t('Low'),
color,
},
close: {
value: formatCompactNumber(currentBar.close) ?? '',
name: t('Close'),
color,
},
absoluteChange: {
value: formatCompactNumber(absoluteChange ?? 0),
name: t('Change'),
color,
},
percentageChange: {
value: percentageChange !== undefined ? `${formatCompactNumber(percentageChange)}%` : '',
name: t('Change'),
color,
},
time: {
value: time,
name: t('Time'),
color,
},
};
}
}
import { CandlestickSeriesStrategy, LineSeriesStrategy } from '@core';
import { BaseSeriesParams } from '@core/Series/BaseSeries';
import { BarSeriesStrategy } from '@src/core/Series/BarSeriesStrategy';
import { HistogramSeriesStrategy } from '@src/core/Series/HistogramSeriesStrategy';
import { ISeries } from '@src/modules/series-strategies/ISeries';
import { ChartSeriesType } from '@src/types';
export type SeriesStrategies =
| CandlestickSeriesStrategy
| LineSeriesStrategy
| HistogramSeriesStrategy
| BarSeriesStrategy
| ISeries<'Baseline'>
| ISeries<'Area'>
| ISeries<'Custom'>;
/**
x * Фабрика для создания стратегий серий
* Реализует паттерн Factory для создания нужной стратегии по типу графика
*/
export class SeriesFactory {
static create(
type: ChartSeriesType,
):
| ((params: BaseSeriesParams<'Candlestick'>) => CandlestickSeriesStrategy)
| ((params: BaseSeriesParams<'Histogram'>) => HistogramSeriesStrategy)
| ((params: BaseSeriesParams<'Line'>) => LineSeriesStrategy)
| ((params: BaseSeriesParams<'Bar'>) => BarSeriesStrategy) {
if (type === 'Candlestick') {
return ((params) => new CandlestickSeriesStrategy(params)) as (
params: BaseSeriesParams<'Candlestick'>,
) => CandlestickSeriesStrategy;
}
if (type === 'Histogram') {
return ((params) => new HistogramSeriesStrategy(params)) as (
params: BaseSeriesParams<'Histogram'>,
) => HistogramSeriesStrategy;
}
if (type === 'Line') {
return ((params) => new LineSeriesStrategy(params)) as (params: BaseSeriesParams<'Line'>) => LineSeriesStrategy;
}
if (type === 'Bar') {
return ((params) => new BarSeriesStrategy(params)) as (params: BaseSeriesParams<'Bar'>) => BarSeriesStrategy;
}
throw new Error(`Unsupported chart type: ${type}`);
}
}
import { BehaviorSubject, combineLatest, firstValueFrom, Observable, Subject, Subscription } from 'rxjs';
import { filter, map, take } from 'rxjs/operators';
import { EventManager } from '@core/EventManager';
import { SymbolSource } from '@src/core/SymbolSource';
import { Candle } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { normalizeSymbol } from '@src/utils';
export interface DataSourceParams {
getData: (timeframe: Timeframes, symbol: string, until?: Candle) => Promise<Candle[] | null>;
eventManager: EventManager;
}
export interface RealtimeEvent {
symbol: string;
candle: Candle;
}
const EMPTY_CANDLES$ = new BehaviorSubject<Candle[]>([]);
const EMPTY_LAST_CANDLE$ = new BehaviorSubject<Candle | null>(null);
const EMPTY_REALTIME$ = new Subject<Candle>();
export class DataSource {
private readonly getData: DataSourceParams['getData'];
private readonly eventManager: EventManager;
private readonly states = new Map<string, SymbolSource>();
private readonly activeSymbols$ = new BehaviorSubject<Set<string>>(new Set());
private realtimeSub: Subscription | null = null;
private readonly subscriptions = new Subscription();
constructor({ getData, eventManager }: DataSourceParams) {
this.getData = getData;
this.eventManager = eventManager;
this.initSubscriptions();
}
public setSymbols(symbols: string[]): void {
const nextSet = new Set<string>();
for (const raw of symbols) {
const s = normalizeSymbol(raw);
if (s) nextSet.add(s);
}
for (const s of nextSet) {
if (!this.states.has(s)) {
this.ensureState(s);
}
}
for (const existingKey of this.states.keys()) {
if (!nextSet.has(existingKey)) {
this.dropState(existingKey);
}
}
this.activeSymbols$.next(nextSet);
}
public symbolsObs(): Observable<string[]> {
return this.activeSymbols$.pipe(map((set) => Array.from(set)));
}
public bindRealtime(stream$: Observable<RealtimeEvent>): void {
this.unbindRealtime();
this.realtimeSub = stream$.subscribe(({ symbol, candle }) => {
const s = normalizeSymbol(symbol);
if (s && this.activeSymbols$.value.has(s)) {
this.states.get(s)?.pushRealtime(candle);
}
});
}
public unbindRealtime(): void {
if (this.realtimeSub) {
this.realtimeSub.unsubscribe();
this.realtimeSub = null;
}
}
public subscribe(symbolRaw: string, cb: (next: Candle[]) => void): Subscription {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) return new Subscription();
return this.ensureState(symbol).data$().subscribe(cb);
}
public subscribeRealtime(symbolRaw: string, cb: (next: Candle) => void): Subscription {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) return new Subscription();
return this.ensureState(symbol).realtime$().subscribe(cb);
}
public data$(symbolRaw: string): Observable<Candle[]> {
const symbol = normalizeSymbol(symbolRaw);
return symbol ? this.ensureState(symbol).data$() : EMPTY_CANDLES$.asObservable();
}
public realtime$(symbolRaw: string): Observable<Candle> {
const symbol = normalizeSymbol(symbolRaw);
return symbol ? this.ensureState(symbol).realtime$() : EMPTY_REALTIME$.asObservable();
}
public lastCandle$(symbolRaw: string): Observable<Candle | null> {
const symbol = normalizeSymbol(symbolRaw);
return symbol ? this.ensureState(symbol).lastCandle$() : EMPTY_LAST_CANDLE$.asObservable();
}
public getLastCandle(symbolRaw: string): Candle | null {
const symbol = normalizeSymbol(symbolRaw);
return symbol ? (this.states.get(symbol)?.getLastValue() ?? null) : null;
}
public isReady = async (symbolRaw: string): Promise<void> => {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol) return;
const st = this.ensureState(symbol);
await firstValueFrom(st.isInitialized$().pipe(filter(Boolean), take(1)));
};
public updateRealtime(symbolRaw: string, next: Candle): void {
const symbol = normalizeSymbol(symbolRaw);
if (symbol && this.activeSymbols$.value.has(symbol)) {
this.ensureState(symbol).pushRealtime(next);
}
}
public async loadTill(symbolRaw: string, time: number): Promise<void> {
const symbol = normalizeSymbol(symbolRaw);
if (symbol && this.activeSymbols$.value.has(symbol)) {
await this.ensureState(symbol).loadTill(time);
}
}
public loadMoreHistory = async (symbolRaw: string): Promise<void> => {
const symbol = normalizeSymbol(symbolRaw);
if (symbol && this.activeSymbols$.value.has(symbol)) {
await this.ensureState(symbol).loadMoreHistory();
}
};
public loadAllHistory = async (symbolRaw: string): Promise<void> => {
const symbol = normalizeSymbol(symbolRaw);
if (!symbol || !this.activeSymbols$.value.has(symbol)) return;
const st = this.ensureState(symbol);
await st.loadAllHistory();
};
public getIsLoading(symbolRaw: string): boolean {
const symbol = normalizeSymbol(symbolRaw);
return symbol ? (this.states.get(symbol)?.isLoadingValue() ?? false) : false;
}
public getOldestTime(symbolRaw: string): number | null {
const symbol = normalizeSymbol(symbolRaw);
return symbol ? (this.states.get(symbol)?.getOldestTime() ?? null) : null;
}
public destroy(): void {
this.unbindRealtime();
this.subscriptions.unsubscribe();
for (const key of this.states.keys()) {
this.dropState(key);
}
this.activeSymbols$.complete();
}
private initSubscriptions(): void {
this.subscriptions.add(
combineLatest([this.eventManager.getSelectedSeries()]).subscribe(() => {
this.states.forEach((st) => st.saveRealtimeCache());
}),
);
this.subscriptions.add(
this.eventManager.timeframe().subscribe((tf) => {
const symbols = Array.from(this.activeSymbols$.value);
Promise.all(
symbols.map((s) => {
const st = this.states.get(s);
return st ? st.reload(tf) : Promise.resolve();
}),
).catch((error) => console.error('[DataSource] Global timeframe reload error:', error));
}),
);
}
private ensureState(symbol: string): SymbolSource {
let st = this.states.get(symbol);
if (!st) {
st = new SymbolSource({
symbol,
getData: this.getData,
getTimeframe: () => this.eventManager.getTimeframe(),
});
this.states.set(symbol, st);
st.init();
}
return st;
}
private dropState(symbol: string): void {
const st = this.states.get(symbol);
if (st) {
st.destroy();
this.states.delete(symbol);
}
}
}
import dayjs from 'dayjs';
import {
BarPrice,
ChartOptions,
createChart,
CrosshairMode,
DeepPartial,
IChartApi,
IRange,
LocalizationOptionsBase,
LogicalRange,
Time,
UTCTimestamp,
} from 'lightweight-charts';
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 { Hotkeys } from '@core/Hotkeys';
import { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
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, CompareSnapshot, IndicatorSnapshot, 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 = 500;
interface ChartParams {
params: {
dataSource: DataSource;
eventManager: EventManager;
modalRenderer: ModalRenderer;
ohlcConfig: OHLCConfig;
tooltipConfig: TooltipConfig;
panes: PaneSnapshot[];
hotkeys: Hotkeys;
};
lwcChartConfig: ChartConfig;
}
function splitIndicatorSnapshots(panes: PaneSnapshot[]): {
compareSnapshots: CompareSnapshot[];
indicatorSnapshots: IndicatorSnapshot[];
} {
const snapshots = panes.flatMap(({ id, indicators }) =>
indicators.map((indicator) => ({
...indicator,
paneId: id,
})),
);
function isIndicatorSnapshot(
input: (IndicatorSnapshot | CompareSnapshot) & { indicatorType?: unknown },
): input is IndicatorSnapshot {
return input.indicatorType !== undefined;
}
function isCompareSnapshot(
input: (IndicatorSnapshot | CompareSnapshot) & { indicatorType?: unknown },
): input is CompareSnapshot {
return input.indicatorType === undefined;
}
return {
indicatorSnapshots: snapshots.filter((x) => isIndicatorSnapshot(x)),
compareSnapshots: snapshots.filter((x) => isCompareSnapshot(x)),
};
}
/**
* Абстракция над библиотекой для построения графиков
*/
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 optionsSubscription: Subscription;
private dataSource: DataSource;
private chartConfig: ChartConfig;
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 activeSymbolIds: 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 }) => {
this.chartConfig = {
...this.chartConfig,
dateFormat,
timeFormat,
showTime,
};
this.lwcChart.applyOptions({
...getOptions(this.chartConfig),
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,
hotkeys: params.hotkeys,
});
this.mainSeries = this.paneManager.getMainPane().getMainSerie();
const { indicatorSnapshots, compareSnapshots } = splitIndicatorSnapshots(panesSnapshot);
this.indicatorManager = new IndicatorManager({
lwcChart: this.lwcChart,
eventManager,
dataSource: this.dataSource,
paneManager: this.paneManager,
initialIndicators: indicatorSnapshots,
DOM: this.DOM,
chartOptions: lwcChartConfig.chartOptions,
});
this.compareManager = new CompareManager({
chart: this.lwcChart,
eventManager,
dataSource: this.dataSource,
paneManager: this.paneManager,
initialIndicators: compareSnapshots,
indicatorManager: this.indicatorManager,
});
this.paneManager.start({
compareEntities$: this.compareManager.entities(),
indicatorEntities$: this.indicatorManager.entities(),
});
this.paneManager.setVisibleLogicalRange(this.lwcChart.timeScale().getVisibleLogicalRange());
this.paneManager.invalidate();
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.chartConfig = {
...this.chartConfig,
theme,
mode,
};
this.lwcChart.applyOptions(getOptions(this.chartConfig));
this.paneManager.invalidate();
}
public destroy(): void {
this.subscriptions.unsubscribe();
this.mouseEvents.destroy();
this.compareManager.destroy();
this.paneManager.destroy();
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.paneManager.resetPriceScalesAutoScale();
};
public getRealtimeApi() {
return {
getTimeframe: () => this.eventManager.getTimeframe(),
getSymbols: () => this.activeSymbolIds,
update: (symbolId: string, candle: Candle) => {
this.dataSource.updateRealtime(symbolId, candle);
},
};
}
public getSnapshot(): ChartSnapshot {
const { seriesSelected, timeframe, dateFormat, timeFormat, interval, symbolInfo } =
this.eventManager.exportChartSettings();
return {
panes: this.paneManager.getSnapshot(),
chartSeriesType: seriesSelected,
timeframe,
dateFormat,
timeFormat,
interval,
...symbolInfo,
};
}
private scheduleHistoryBatch = () => {
if (this.historyBatchRunning) return;
this.historyBatchRunning = true;
requestAnimationFrame(() => {
const symbolIds = this.activeSymbolIds.slice();
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadMoreHistory(symbolId))).finally(() => {
this.historyBatchRunning = false;
const range = this.lwcChart.timeScale().getVisibleLogicalRange();
if (range && range.from < HISTORY_LOAD_THRESHOLD) {
this.scheduleHistoryBatch();
}
});
});
};
private setupDataSourceSubs(): void {
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;
};
const warmupSymbolIds = (symbolIds: string[]): void => {
const from = getWarmupFrom();
if (!from) return;
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadTill(symbolId, from))).catch((error) => {
console.error('[Chart] Ошибка при прогреве символов:', error);
});
};
const symbolIds$ = combineLatest([this.eventManager.symbolId(), this.compareManager.itemsObs()]).pipe(
map(([mainSymbolId, items]) => Array.from(new Set([mainSymbolId, ...items.map(({ symbolId }) => symbolId)]))),
);
this.subscriptions.add(
this.eventManager
.getInterval()
.pipe(withLatestFrom(symbolIds$))
.subscribe(([interval, symbolIds]) => {
this.currentInterval = interval;
if (!interval) return;
if (interval === Intervals.All) {
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadAllHistory(symbolId)))
.then(() => {
requestAnimationFrame(() => this.lwcChart.timeScale().fitContent());
})
.catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));
return;
}
const { from, to } = getIntervalRange(interval);
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadTill(symbolId, from)))
.then(() => {
this.lwcChart.timeScale().setVisibleRange({
from: from as Time,
to: to as Time,
});
})
.catch((error) => {
console.error('[Chart] Ошибка при применении интервала:', error);
});
}),
);
this.subscriptions.add(
symbolIds$.subscribe((symbolIds) => {
const previousSymbolIds = new Set(this.activeSymbolIds);
this.activeSymbolIds = symbolIds;
this.dataSource.setSymbols(symbolIds);
const addedSymbolIds = symbolIds.filter((symbolId) => !previousSymbolIds.has(symbolId));
if (addedSymbolIds.length) {
warmupSymbolIds(addedSymbolIds);
}
}),
);
}
private setupHistoricalDataLoading(): void {
// todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
this.paneManager.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,
shiftVisibleRangeOnNewBar: true,
allowShiftVisibleRangeOnWhitespaceReplacement: true,
},
rightPriceScale: {
textColor: colors.chartTextPrimary,
borderVisible: false,
},
localization,
};
}
import { combineLatest, Subscription } from 'rxjs';
import { ControlBar } from '@components/ControlBar';
import { Footer } from '@components/Footer';
import { Header } from '@components/Header';
import { DataSource, DataSourceParams } from '@core/DataSource';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { ModalRenderer } from '@core/ModalRenderer';
import { FloatingDrawingToolbar } from '@src/components/FloatingToolbar';
import { SettingsModal } from '@src/components/SettingsModal';
import Toolbar from '@src/components/Toolbar';
import { IndicatorsIds } from '@src/constants';
import { CompareManager } from '@src/core/CompareManager';
import { FullscreenController } from '@src/core/Fullscreen';
import { configureThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { Locale, setLocale, t } from '@src/translations';
import { Candle, ChartSeriesType, ChartTypeOptions, OHLCConfig, SymbolInfoInput, TooltipConfig } from '@src/types';
import { ISerializable, MoexChartSnapshot, MoexChartSnapshotInput } from '@src/types/snapshot';
import { Timeframes } from '@src/types/timeframes';
import { setPricePrecision } from '@src/utils';
import { Chart } from './Chart';
import { ChartSettings, ChartSettingsSource } from './ChartSettings';
import { ContainerManager } from './ContainerManager';
import { EventManager } from './EventManager';
import { ReactRenderer } from './ReactRenderer';
import { TimeScaleHoverController } from './TimescaleHoverController';
import { UIRenderer } from './UIRenderer';
import 'exchange-elements/dist/fonts/inter/font.css';
import 'exchange-elements/dist/style.css';
import 'exchange-elements/dist/tokens/moex.css';
import '../styles/global.scss';
// todo: forbid @lib in /src
export interface ChartCollectionPreset {
undoRedoEnabled?: boolean;
showMenuButton?: boolean;
showBottomPanel?: boolean;
showControlBar?: boolean;
showFullscreenButton?: boolean;
showSettingsButton?: boolean;
showCompareButton?: boolean;
showSymbolSearchButton?: boolean;
/**
* Дефолтная конфигурация тултипа - всегда показывается по умолчанию.
* При добавлении/изменении полей в конфиге - они объединяются с дефолтными значениями.
*
* Полная кастомизация:
* @example
* ```typescript
* tooltipConfig: {
* time: { visible: true, label: 'Дата и время' },
* symbol: { visible: true, label: 'Инструмент' },
* close: { visible: true, label: 'Курс' },
* change: { visible: true, label: 'Изменение' },
* volume: { visible: true, label: 'Объем' },
* open: { visible: false },
* high: { visible: false },
* low: { visible: false }
* }
*```
*/
tooltipConfig?: TooltipConfig;
size?:
| {
width: number;
height: number;
}
| false;
supportedTimeframes: Timeframes[];
supportedChartSeriesTypes: ChartSeriesType[];
getDataSource: DataSourceParams['getData'];
startRealtime: (
getSymbols: () => string[],
getTimeframe: () => Timeframes,
update: (symbolId: string, candle: Candle) => void,
periodMs?: number,
) => () => void;
theme: ThemeKey; // 'mb' | 'mxt' | 'tr'
ohlc: OHLCConfig;
locale: Locale;
mode?: ThemeMode; // 'light' | 'dark'
openCompareModal?: () => void;
openSymbolSearchModal?: () => void;
}
export interface IMoexChart {
snapshot: MoexChartSnapshotInput;
chartCollectionPreset: ChartCollectionPreset;
container: HTMLElement;
lwcInheritedChartOptions?: ChartTypeOptions;
}
export class MoexChart implements ISerializable<MoexChartSnapshot> {
private chart!: Chart;
private resizeObserver?: ResizeObserver;
private eventManager!: EventManager;
private hotkeys!: Hotkeys;
private rootContainer!: HTMLElement;
private headerRenderer!: UIRenderer;
private modalRenderer!: ModalRenderer;
private toolbarRenderer: UIRenderer | undefined;
private controlBarRenderer?: UIRenderer;
private footerRenderer?: UIRenderer;
private drawingToolbarRenderer!: UIRenderer;
private timeScaleHoverController!: TimeScaleHoverController;
private dataSource!: DataSource;
private subscriptions = new Subscription();
private fullscreen!: FullscreenController;
private chartCollectionPresetSettings!: ChartCollectionPreset;
constructor(config: IMoexChart) {
setLocale(config.chartCollectionPreset.locale);
this.setup(config);
}
private setup = (config: IMoexChart) => {
this.chartCollectionPresetSettings = config.chartCollectionPreset;
setPricePrecision(config.chartCollectionPreset.ohlc.precision);
const { chartSeriesType, symbolId, symbol, symbolName, timeframe, interval, dateFormat, timeFormat } =
config.snapshot.charts[0];
this.eventManager = new EventManager({
initialTimeframe: timeframe,
initialSeries: chartSeriesType,
initialSymbolInfo: {
symbolId,
symbol,
symbolName,
},
initialTimeFormat: timeFormat,
initialDateFormat: dateFormat,
initialInterval: interval,
});
// todo: сюда прокидывается не подходящий под сигнатуру интерфейс. Функция не работает
// if (config.lwcInheritedChartOptions) {
// this.setSettings(config.lwcInheritedChartOptions);
// }
this.dataSource = new DataSource({
getData: config.chartCollectionPreset.getDataSource,
eventManager: this.eventManager,
});
this.rootContainer = config.container;
this.fullscreen = new FullscreenController(this.rootContainer);
const store = configureThemeStore(config.chartCollectionPreset);
const {
chartAreaContainer,
toolBarContainer,
headerContainer,
modalContainer,
controlBarContainer,
drawingToolbarContainer,
footerContainer,
toggleToolbar, // todo: move this function to toolbarModel
} = ContainerManager.createContainers({
parentContainer: this.rootContainer,
showBottomPanel: config.chartCollectionPreset.showBottomPanel, // todo: apply config.showBottomPanel in FullscreenController
showMenuButton: config.chartCollectionPreset.showMenuButton,
});
this.hotkeys = new Hotkeys();
if (config.chartCollectionPreset.undoRedoEnabled) {
const undoRedo = this.eventManager.getUndoRedo();
this.hotkeys.register({
keys: [Keys.mod, Keys.z],
callback: undoRedo.undo,
});
this.hotkeys.register({
keys: [Keys.mod, Keys.shift, Keys.z],
callback: undoRedo.redo,
});
}
this.modalRenderer = new ModalRenderer(modalContainer);
this.chart = new Chart({
params: {
dataSource: this.dataSource,
eventManager: this.eventManager,
modalRenderer: this.modalRenderer,
ohlcConfig: config.chartCollectionPreset.ohlc, // todo: omptimize
tooltipConfig: config.chartCollectionPreset.tooltipConfig ?? {},
panes: config.snapshot.charts[0].panes,
hotkeys: this.hotkeys,
},
lwcChartConfig: {
container: chartAreaContainer,
seriesTypes: config.chartCollectionPreset.supportedChartSeriesTypes,
theme: store.theme,
mode: store.mode,
chartOptions: config.lwcInheritedChartOptions, // todo: remove, use only model from eventManager
},
});
this.subscriptions.add(
combineLatest([store.theme$, store.mode$]).subscribe(([theme, mode]) => {
this.chart.updateTheme(theme, mode);
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.mode = mode;
}),
);
const realtimeParams = this.chart.getRealtimeApi();
this.subscriptions.add(
config.chartCollectionPreset.startRealtime(
realtimeParams.getSymbols,
realtimeParams.getTimeframe,
realtimeParams.update,
),
);
this.headerRenderer = new ReactRenderer(headerContainer);
this.toolbarRenderer = new ReactRenderer(toolBarContainer);
this.drawingToolbarRenderer = new ReactRenderer(drawingToolbarContainer);
if (config.chartCollectionPreset.showControlBar) {
this.controlBarRenderer = new ReactRenderer(controlBarContainer);
}
if (config.chartCollectionPreset.showBottomPanel) {
this.footerRenderer = new ReactRenderer(footerContainer);
}
this.timeScaleHoverController = new TimeScaleHoverController({
eventManager: this.eventManager,
controlBarContainer,
chartContainer: chartAreaContainer,
});
this.renderAttachments(config, toggleToolbar);
};
public setSettings(settings: ChartSettingsSource): void {
this.eventManager.importChartSettings(settings);
}
public getSettings(): ChartSettings {
return this.eventManager.exportChartSettings();
}
// todo: описать подробнее в доке. Точно ли public?
public getRealtimeApi() {
return this.chart.getRealtimeApi();
}
// todo: описать подробнее в доке
public getCompareManager(): CompareManager {
return this.chart.getCompareManager();
}
public setSnapshot(snapshot: MoexChartSnapshotInput) {
const configConstructorLike: IMoexChart = {
snapshot,
chartCollectionPreset: this.chartCollectionPresetSettings,
container: this.rootContainer,
};
this.destroy();
this.setup(configConstructorLike);
}
// todo: описать в доке
public getSnapshot(): MoexChartSnapshot {
const res = {
settings: this.getSettings(),
charts: [this.chart.getSnapshot()], // todo: в будущем может быть несколько инстансов чартов
};
return res;
}
public setSymbol(symbolInfo: SymbolInfoInput): void {
this.eventManager.setSymbol(symbolInfo);
}
private renderAttachments(config: IMoexChart, toggleToolbar: () => boolean) {
const drawingsManager = this.chart.getDrawingsManager();
this.drawingToolbarRenderer.renderComponent(
<FloatingDrawingToolbar
selectedDrawing$={drawingsManager.selectedDrawing()}
onUpdateSettings={drawingsManager.updateSelectedDrawingSettings}
onToggleLock={() => drawingsManager.toggleSelectedDrawingLock()}
onOpenSettings={() => drawingsManager.openSelectedDrawingSettings()}
onDelete={() => drawingsManager.deleteSelectedDrawing()}
/>,
);
this.headerRenderer.renderComponent(
<Header
timeframes={config.chartCollectionPreset.supportedTimeframes}
selectedTimeframeObs={this.eventManager.getTimeframeObs()}
setTimeframe={(value) => {
this.eventManager.setTimeframe(value);
}}
seriesTypes={config.chartCollectionPreset.supportedChartSeriesTypes}
selectedSeriesObs={this.eventManager.getSelectedSeries()}
setSelectedSeries={(value) => {
this.eventManager.setSeriesSelected(value);
}}
showSettingsModal={
config.chartCollectionPreset.showSettingsButton
? () =>
this.modalRenderer.renderComponent(
<SettingsModal
// todo: deal with onSave
changeTimeFormat={(format) => this.eventManager.setTimeFormat(format)}
changeDateFormat={(format) => this.eventManager.setDateFormat(format)}
chartDateTimeFormatObs={this.eventManager.getChartOptionsModel()}
/>,
{ title: t('Settings') },
)
: undefined
}
addIndicatorToChart={(indicatorType: IndicatorsIds) =>
this.chart.getIndicatorManager().addIndicator({ indicatorType })
}
showMenuButton={!!config.chartCollectionPreset.showMenuButton}
showFullscreenButton={!!config.chartCollectionPreset.showFullscreenButton}
fullscreen={this.fullscreen}
undoRedo={config.chartCollectionPreset.undoRedoEnabled ? this.eventManager.getUndoRedo() : undefined}
toggleToolbarVisible={toggleToolbar}
showCompareButton={!!config.chartCollectionPreset.showCompareButton}
openCompareModal={
config.chartCollectionPreset.openCompareModal ? config.chartCollectionPreset.openCompareModal : undefined
}
showSymbolSearchButton={!!config.chartCollectionPreset.openSymbolSearchModal}
openSymbolSearchModal={config.chartCollectionPreset.openSymbolSearchModal}
isMXT={config.chartCollectionPreset.theme === 'mxt'}
/>,
);
if (this.toolbarRenderer && config.chartCollectionPreset.showMenuButton) {
this.toolbarRenderer.renderComponent(
<Toolbar
toggleDOM={this.chart.getDom().toggleDOM}
addDrawing={this.chart.getDrawingsManager().addDrawingForce} // todo: deal with new panes logic
setEndlessDrawingsMode={this.chart.getDrawingsManager().setEndlessDrawingMode}
isEndlessDrawingsMode$={this.chart.getDrawingsManager().isEndlessDrawingsMode()}
activateCrosshair={() => this.chart.getDrawingsManager().activateCrosshair()}
activeTool$={this.chart.getDrawingsManager().getActiveTool()}
hotkeys={this.hotkeys}
/>,
);
}
if (this.controlBarRenderer && config.chartCollectionPreset.showControlBar) {
this.controlBarRenderer.renderComponent(
<ControlBar
scroll={this.chart.scrollTimeScale}
zoom={this.chart.zoomTimeScale}
reset={this.chart.resetZoom}
visible={this.eventManager.getControlBarVisible()}
/>,
);
}
if (this.footerRenderer && config.chartCollectionPreset.showBottomPanel) {
this.footerRenderer.renderComponent(
<Footer
supportedTimeframes={config.chartCollectionPreset.supportedTimeframes}
setInterval={this.eventManager.setInterval}
intervalObs={this.eventManager.getInterval()}
/>,
);
}
}
/**
* Уничтожение графика и очистка ресурсов
* @returns void
*/
destroy(): void {
this.headerRenderer.destroy();
this.drawingToolbarRenderer.destroy();
this.subscriptions.unsubscribe();
this.timeScaleHoverController.destroy();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = undefined;
}
if (this.controlBarRenderer) {
this.controlBarRenderer.destroy();
}
if (this.footerRenderer) {
this.footerRenderer.destroy();
}
if (this.chart) {
this.chart.destroy();
}
if (this.eventManager) {
this.eventManager.destroy();
}
if (this.toolbarRenderer) {
this.toolbarRenderer.destroy();
}
this.dataSource.destroy();
ContainerManager.clearContainers(this.rootContainer);
}
}
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import utc from 'dayjs/plugin/utc';
import 'dayjs/locale/ru';
import { TickMarkType, UTCTimestamp } from 'lightweight-charts';
import { TimeFormat, Timeframes } from '@src/types';
// плагины для работы с UTC и локализацией
dayjs.extend(utc);
dayjs.extend(localizedFormat);
dayjs.extend(duration);
export enum DateFormat {
DOW_Q_YY = 'Mon Q3 \'97',
DOW_Q_YYYY = 'Mon Q3 1997',
DOW_D_MMM_YY = 'Mon 29 Sep \'97',
DOW_MMM_YY = 'Mon Sep \'97',
DOW_MMM_D_YYYY = 'Mon Sep 29, 1997',
DOW_MMM_YYYY = 'Mon Sep 1997',
DOW_MMM_D = 'Mon Sep 29',
DOW_D_MMM = 'Mon 29 Sep',
DOW_YYYY_MM_DD_DASH = 'Mon 1997-09-29',
DOW_YY_MM_DD_DASH = 'Mon 97-09-29',
DOW_YY_MM_DD_SLASH = 'Mon 97/09/29',
DOW_YYYY_MM_DD_SLASH = 'Mon 1997/09/29',
DOW_DD_MM_YYYY_DASH = 'Mon 29-09-1997',
DOW_DD_MM_YY_DASH = 'Mon 29-09-97',
DOW_DD_MM_YY_SLASH = 'Mon 29/09/97',
DOW_DD_MM_YYYY_SLASH = 'Mon 29/09/1997',
DOW_MM_DD_YY_SLASH = 'Mon 09/29/97',
DOW_MM_DD_YYYY_SLASH = 'Mon 09/29/1997',
DD_MM_YYYY_HH_mm_ss = '09.29.1997 00:00:00',
}
export const dateFormatOptions = Object.entries(DateFormat).map(([_, value]) => ({
label: value,
value,
}));
const customTimeFormatter = (time: UTCTimestamp, timeFormat: string, locale: string) => {
const d = dayjs.unix(time).utc().locale(locale);
if (timeFormat === '12h') {
return d.format('h:mm:ss A'); // 12-часовой формат
}
return d.format('HH:mm:ss'); // 24-часовой формат
};
export function shouldShowTime(tf: Timeframes): boolean {
return !(tf.endsWith('d') || tf.endsWith('w') || tf.endsWith('M') || tf.endsWith('М') || tf.endsWith('Y'));
}
/**
* Форматирует UTC timestamp в строку согласно выбранному формату.
* @param time - UTCTimestamp (секунды)
* @param format - Значение из enum DateFormat
* @param timeFormat - Значение из enum TimeFormat
* @param showTime - Отображать ли время в строке даты
* @param locale - Языковая локаль (например, 'en-US', 'ru-RU')
* @returns Отформатированная строка с датой
*/
export function formatDate(
time: UTCTimestamp,
format: DateFormat,
timeFormat: TimeFormat,
showTime = true,
locale = 'ru-RU',
): string {
const d = dayjs.unix(time).utc().locale(locale);
const findPart = (type: string) => d.format(type);
let dateString: string;
switch (format) {
case DateFormat.DOW_Q_YY: {
const quarter = Math.floor(d.month() / 3) + 1;
dateString = `${findPart('ddd')} Q${quarter} '${d.format('YY')}`;
break;
}
case DateFormat.DOW_Q_YYYY: {
const quarter = Math.floor(d.month() / 3) + 1;
dateString = `${findPart('ddd')} Q${quarter} ${d.format('YYYY')}`;
break;
}
case DateFormat.DOW_D_MMM_YY:
dateString = `${findPart('ddd')} ${d.date()} ${d.format('MMM')} '${d.format('YY')}`;
break;
case DateFormat.DOW_MMM_YY:
dateString = `${findPart('ddd')} ${d.format('MMM')} '${d.format('YY')}`;
break;
case DateFormat.DOW_MMM_D_YYYY:
dateString = `${findPart('ddd')} ${d.format('MMM')} ${d.date()}, ${d.format('YYYY')}`;
break;
case DateFormat.DOW_MMM_YYYY:
dateString = `${findPart('ddd')} ${d.format('MMM')} ${d.format('YYYY')}`;
break;
case DateFormat.DOW_MMM_D:
dateString = `${findPart('ddd')} ${d.format('MMM')} ${d.date()}`;
break;
case DateFormat.DOW_D_MMM:
dateString = `${findPart('ddd')} ${d.date()} ${d.format('MMM')}`;
break;
case DateFormat.DOW_YYYY_MM_DD_DASH:
dateString = `${findPart('ddd')} ${d.format('YYYY-MM-DD')}`;
break;
case DateFormat.DOW_YY_MM_DD_DASH:
dateString = `${findPart('ddd')} ${d.format('YY-MM-DD')}`;
break;
case DateFormat.DOW_YY_MM_DD_SLASH:
dateString = `${findPart('ddd')} ${d.format('YY/MM/DD')}`;
break;
case DateFormat.DOW_YYYY_MM_DD_SLASH:
dateString = `${findPart('ddd')} ${d.format('YYYY/MM/DD')}`;
break;
case DateFormat.DOW_DD_MM_YYYY_DASH:
dateString = `${findPart('ddd')} ${d.format('DD-MM-YYYY')}`;
break;
case DateFormat.DOW_DD_MM_YY_DASH:
dateString = `${findPart('ddd')} ${d.format('DD-MM-YY')}`;
break;
case DateFormat.DOW_DD_MM_YY_SLASH:
dateString = `${findPart('ddd')} ${d.format('DD/MM/YY')}`;
break;
case DateFormat.DOW_DD_MM_YYYY_SLASH:
dateString = `${findPart('ddd')} ${d.format('DD/MM/YYYY')}`;
break;
case DateFormat.DOW_MM_DD_YY_SLASH:
dateString = `${findPart('ddd')} ${d.format('MM/DD/YY')}`;
break;
case DateFormat.DOW_MM_DD_YYYY_SLASH:
dateString = `${findPart('ddd')} ${d.format('MM/DD/YYYY')}`;
break;
case DateFormat.DD_MM_YYYY_HH_mm_ss:
dateString = d.format('DD.MM.YYYY');
break;
default:
dateString = `${findPart('ddd')} ${d.format('DD-MM-YYYY')}`;
}
if (!showTime) return dateString;
const timeString = customTimeFormatter(time, timeFormat, locale);
return `${dateString} ${timeString}`;
}
export function createTickMarkFormatter(
timeFormatString: string,
locale = 'ru-RU',
): (time: UTCTimestamp, tickMarkType: TickMarkType) => string {
return (time, tickMarkType) => {
const d = dayjs.unix(time).utc().locale(locale);
switch (tickMarkType) {
case TickMarkType.Year:
return d.format('YYYY');
case TickMarkType.Month:
return d.format('MMM');
case TickMarkType.DayOfMonth:
return d.format('DD');
case TickMarkType.Time:
return d.format(timeFormatString);
default:
return '';
}
};
}
export function formatUtcOffset(date = dayjs()): string {
const offsetMinutes = date.utcOffset();
const sign = offsetMinutes >= 0 ? '+' : '-';
const abs = Math.abs(offsetMinutes);
const hours = Math.floor(abs / 60);
const minutes = abs % 60;
if (minutes === 0) return `${sign}${hours}`;
return `${sign}${hours}:${String(minutes).padStart(2, '0')}`;
}
export function formatDisplayText(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return String(value);
}
if (typeof value === 'object' && 'year' in value && 'month' in value && 'day' in value) {
const businessDay = value as { year: number; month: number; day: number };
return `${businessDay.year}.${String(businessDay.month).padStart(2, '0')}.${String(businessDay.day).padStart(2, '0')}`;
}
return String(value);
}
import dayjs from 'dayjs';
import { Timeframes } from '@src/types/timeframes';
import { parseTimeframe } from './parseTimeframe';
export const timeframeToSeconds = (timeframe: Timeframes): number => {
const { candleWidth, dayjsUnit } = parseTimeframe(timeframe);
return dayjs.duration(candleWidth, dayjsUnit).asSeconds();
};
import dayjs, { ManipulateType } from 'dayjs';
import { Timeframes } from '@src/types/timeframes';
import { timeframeToSeconds } from './timeframeToSeconds';
export function parseTimeframe(timeframe: Timeframes, date?: number) {
const match = timeframe.match(/^(\d+)(\w)$/);
if (!match) {
throw new Error(`Invalid timeframe format: ${timeframe}`);
}
const candleWidth = parseInt(match[1], 10);
const order = match[2];
const dayjsUnit = toDayjs[order];
const startOfUnit = dayjs(date ?? undefined).startOf(dayjsUnit);
return { candleWidth, dayjsUnit, startOfUnit };
}
export function getStartTime(timeframe: Timeframes, date: number) {
const { startOfUnit } = parseTimeframe(timeframe, date);
const sec = timeframeToSeconds(timeframe);
return startOfUnit.unix() - (startOfUnit.unix() % sec);
}
export const toDayjs: Record<string, ManipulateType> = {
s: 'seconds',
h: 'hours',
d: 'days',
m: 'minutes',
w: 'weeks',
M: 'months',
};
import { Time } from 'lightweight-charts';
export function normalizeSeriesData<T extends { time: Time | number }>(items: T[]): T[] {
const sorted = [...items].sort((a, b) => {
if (a.time < b.time) return -1;
if (a.time > b.time) return 1;
return 0;
});
const result: T[] = [];
for (const item of sorted) {
const last = result[result.length - 1];
if (!last || item.time > last.time) result.push(item);
else if (item.time === last.time) result[result.length - 1] = item;
}
return result;
}
import { Intervals, IntervalsToTimeframe, Timeframes } from '@src/types';
export function getTimeframeByInterval(interval: Intervals): Timeframes {
const timeframe = IntervalsToTimeframe[interval];
if (!timeframe) {
throw new Error(`Нет Timeframe для Interval: ${interval}`);
}
return timeframe;
}