Загрузка данных
import {
BarData,
BarSeries,
CustomData,
HistogramData,
LineData,
SeriesDataItemTypeMap,
SeriesDefinition,
SeriesPartialOptionsMap,
Time,
} from 'lightweight-charts';
import { Ohlc } from '@core/Legend';
import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@src/core/Series/BaseSeries';
import { ISeries } from '@src/modules/series-strategies';
import { getThemeStore } from '@src/theme';
import { Candle, LineCandle } from '@src/types';
import { ensureDefined, formatPrice, isBarData } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
export class BarSeriesStrategy extends BaseSeries<'Bar'> implements ISeries<'Bar'> {
constructor(params: BaseSeriesParams<'Bar'>) {
super(params);
this.subscribeDataSource(params.dataSource);
}
protected seriesDefinition(): SeriesDefinition<'Bar'> {
return BarSeries;
}
getDefaultOptions(): SeriesPartialOptionsMap['Bar'] {
const { colors } = getThemeStore();
return {
upColor: colors.chartCandleUp,
downColor: colors.chartCandleDown,
};
}
getTypeName(): string {
return 'Bar';
}
public validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean {
if (!Array.isArray(data)) {
return false;
}
return data.every(
(point) =>
typeof point.time === 'number' &&
typeof point.open === 'number' &&
typeof point.high === 'number' &&
typeof point.low === 'number' &&
typeof point.close === 'number',
);
}
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]);
};
protected formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>['Bar'][] {
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: formatPrice(currentBar.open) ?? '',
name: 'Откр.',
color,
},
high: {
value: formatPrice(currentBar.high) ?? '',
name: 'Макс.',
color,
},
low: {
value: formatPrice(currentBar.low) ?? '',
name: 'Мин.',
color,
},
close: {
value: formatPrice(currentBar.close) ?? '',
name: 'Закр.',
color,
},
absoluteChange: {
value: formatPrice(absoluteChange) ?? '',
name: 'Изм.',
color,
},
percentageChange: {
value: percentageChange !== undefined ? `${formatPrice(percentageChange)}%` : '',
name: 'Изм.',
color,
},
time: {
value: time,
name: 'Время',
color,
},
};
}
}
import {
BarData,
BarPrice,
BarsInfo,
Coordinate,
CreatePriceLineOptions,
CustomData,
DataChangedHandler,
DeepPartial,
HistogramData,
IChartApi,
IPaneApi,
IPriceFormatter,
IPriceLine,
IPriceScaleApi,
IRange,
ISeriesApi,
ISeriesPrimitive,
LineData,
MismatchDirection,
MouseEventParams,
PriceScaleOptions,
SeriesDataItemTypeMap,
SeriesDefinition,
SeriesOptionsMap,
SeriesPartialOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { BehaviorSubject, distinctUntilChanged, Observable, Subscription } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { Indicator } from '@core/Indicator';
import { ChartTypeToCandleData, IndicatorDataFormatter } from '@core/Indicators';
import { Ohlc } from '@core/Legend';
import { MAIN_PANE_INDEX } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { Candle, Direction } from '@src/types';
import { formatPrice, getPricePrecisionStep, isBarData, isLineData, normalizeSeriesData } from '@src/utils';
export interface SerieData {
time: Time;
customValues: Candle;
}
export interface CreateSeriesParams<TSeries extends SeriesType> {
chart: IChartApi;
seriesOptions?: SeriesPartialOptionsMap[TSeries];
paneIndex?: number;
priceScaleOptions?: DeepPartial<PriceScaleOptions>;
}
export interface IBaseSeries<TSeries extends SeriesType> extends ISeriesApi<TSeries> {
getLwcSeries: () => ISeriesApi<TSeries>;
getLegendData: (
param?: MouseEventParams,
) => Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;
}
export interface BaseSeriesParams<TSeries extends SeriesType = SeriesType> {
lwcChart: IChartApi;
dataSource: DataSource;
mainSymbol$: Observable<string>;
mainSerie$: BehaviorSubject<SeriesStrategies | null>;
customFormatter?: (params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][];
seriesOptions?: SeriesPartialOptionsMap[TSeries];
priceScaleOptions?: DeepPartial<PriceScaleOptions>;
showSymbolLabel?: boolean;
paneIndex?: number;
indicatorReference?: Indicator;
}
function applyMoscowTimezone(candles: Candle[]): ChartTypeToCandleData['Candlestick'][] {
// todo this approach is too slow, for timezones impl we should shift timeScale instead of mutating the data
const offsetMinutes = -180; // utc.time - moscow.time in minutes
const secondsInMinute = 60;
return candles
.filter((c) => typeof c.time === 'number' && Number.isFinite(c.time))
.map((c) => ({
...c,
time: (c.time as number) - offsetMinutes * secondsInMinute,
}));
}
export abstract class BaseSeries<TSeries extends SeriesType> implements IBaseSeries<TSeries> {
protected lwcSeries: ISeriesApi<TSeries>;
protected customFormatter:
| undefined
| ((params: IndicatorDataFormatter<TSeries>) => SeriesDataItemTypeMap<Time>[TSeries][]);
protected lwcChart: IChartApi;
protected mainSymbol$: Observable<string>;
protected mainSerie$: BehaviorSubject<SeriesStrategies | null>;
protected paneIndex: number | null = null;
protected indicatorReference: Indicator | null = null;
protected showSymbolLabel: boolean;
private subscriptions = new Subscription();
private dataSub: Subscription | null = null;
private realtimeSub: Subscription | null = null;
constructor({
lwcChart,
mainSymbol$,
mainSerie$,
customFormatter,
seriesOptions,
priceScaleOptions,
showSymbolLabel = true,
paneIndex,
indicatorReference,
}: BaseSeriesParams<TSeries>) {
this.lwcSeries = this.createSeries({
chart: lwcChart,
seriesOptions,
paneIndex,
priceScaleOptions,
});
this.lwcChart = lwcChart;
this.customFormatter = customFormatter;
this.mainSymbol$ = mainSymbol$;
this.mainSerie$ = mainSerie$;
this.showSymbolLabel = showSymbolLabel;
this.indicatorReference = indicatorReference ?? null;
}
public getLegendData = (
param?: MouseEventParams,
): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>> => {
if (!param) {
const seriesData = this.data();
if (seriesData.length < 1) return {};
const dataToFormat = seriesData[seriesData.length - 1];
const prevBarData = seriesData.length > 1 ? seriesData[seriesData.length - 2] : null;
return this.formatLegendValues(dataToFormat, prevBarData);
}
const dataToFormat = param?.seriesData.get(this.getLwcSeries()) ?? null;
const prevBarData = this.dataByIndex(param.logical! - 1) ?? null;
return this.formatLegendValues(dataToFormat, prevBarData);
};
public show(): void {
this.lwcSeries.applyOptions({ ...this.lwcSeries.options(), visible: true });
}
public hide(): void {
this.lwcSeries.applyOptions({ ...this.lwcSeries.options(), visible: false });
}
public isVisible(): boolean {
return this.lwcSeries.options().visible;
}
public destroy(): void {
this.dataSub?.unsubscribe();
this.realtimeSub?.unsubscribe();
this.subscriptions.unsubscribe();
this.lwcChart.removeSeries(this.lwcSeries);
}
public getLwcSeries(): ISeriesApi<TSeries> {
return this.lwcSeries;
}
public applyOptions(options: SeriesPartialOptionsMap[TSeries]): void {
this.lwcSeries.applyOptions(options);
}
public attachPrimitive(primitive: ISeriesPrimitive<Time>): void {
this.lwcSeries.attachPrimitive(primitive);
}
public barsInLogicalRange(range: IRange<number>): BarsInfo<Time> | null {
return this.lwcSeries.barsInLogicalRange(range);
}
public coordinateToPrice(coordinate: number): BarPrice | null {
return this.lwcSeries.coordinateToPrice(coordinate);
}
public createPriceLine(options: CreatePriceLineOptions): IPriceLine {
return this.lwcSeries.createPriceLine(options);
}
public data(): readonly SeriesDataItemTypeMap<Time>[TSeries][] {
return this.lwcSeries.data();
}
public dataByIndex(
logicalIndex: number,
mismatchDirection?: MismatchDirection,
): SeriesDataItemTypeMap<Time>[TSeries] | null {
return this.lwcSeries.dataByIndex(logicalIndex, mismatchDirection);
}
public detachPrimitive(primitive: ISeriesPrimitive<Time>): void {
this.lwcSeries.detachPrimitive(primitive);
}
public getPane(): IPaneApi<Time> {
return this.lwcSeries.getPane();
}
public moveToPane(paneIndex: number): void {
this.lwcSeries.moveToPane(paneIndex);
}
public options(): Readonly<SeriesOptionsMap[TSeries]> {
return this.lwcSeries.options();
}
public priceFormatter(): IPriceFormatter {
return this.lwcSeries.priceFormatter();
}
public priceLines(): IPriceLine[] {
return this.lwcSeries.priceLines();
}
public priceScale(): IPriceScaleApi {
return this.lwcSeries.priceScale();
}
public priceToCoordinate(price: number): Coordinate | null {
return this.lwcSeries.priceToCoordinate(price);
}
public removePriceLine(line: IPriceLine): void {
this.lwcSeries.removePriceLine(line);
}
public seriesOrder(): number {
return this.lwcSeries.seriesOrder();
}
public seriesType(): TSeries {
return this.lwcSeries.seriesType();
}
public setData(data: SeriesDataItemTypeMap<Time>[TSeries][]): void {
const normalizedData = normalizeSeriesData(data);
this.lwcSeries.setData(normalizedData);
}
public setSeriesOrder(order: number): void {
this.lwcSeries.setSeriesOrder(order);
}
public subscribeDataChanged(handler: DataChangedHandler): void {
this.lwcSeries.subscribeDataChanged(handler);
}
public unsubscribeDataChanged(handler: DataChangedHandler): void {
this.lwcSeries.unsubscribeDataChanged(handler);
}
public update(bar: SeriesDataItemTypeMap<Time>[TSeries], historicalUpdate?: boolean): void {
const data = this.lwcSeries.data();
const last = data.length ? data[data.length - 1] : null;
if (!last) {
this.lwcSeries.update(bar, false);
return;
}
const lastTime = last.time;
const nextTime = bar.time;
const isHist =
historicalUpdate ?? (typeof lastTime === 'number' && typeof nextTime === 'number' && nextTime < lastTime);
this.lwcSeries.update(bar, isHist);
}
protected createSeries({
chart,
seriesOptions,
paneIndex = MAIN_PANE_INDEX,
priceScaleOptions = {},
}: CreateSeriesParams<TSeries>): ISeriesApi<TSeries> {
this.paneIndex = paneIndex;
const defaultOptions = this.getDefaultOptions();
const mergedOptions = {
...defaultOptions,
...seriesOptions,
};
const series = chart.addSeries<TSeries>(this.seriesDefinition(), mergedOptions, paneIndex);
chart.priceScale(mergedOptions.priceScaleId ?? Direction.Right, paneIndex).applyOptions(priceScaleOptions);
return series;
}
protected abstract dataSourceSubscription(next: Candle[]): void;
protected abstract seriesDefinition(): SeriesDefinition<TSeries>;
protected abstract dataSourceRealtimeSubscription(next: Candle): void;
protected abstract getDefaultOptions(): SeriesPartialOptionsMap[TSeries];
protected abstract formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][];
protected abstract formatLegendValues(
currentBar: null | BarData | LineData | HistogramData | CustomData,
prevBar: null | BarData | LineData | HistogramData | CustomData,
): Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>;
protected applyTimezone(data: Candle[]): Candle[] {
return applyMoscowTimezone(data);
}
protected formatData(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
const formatter = this.customFormatter;
const data = this.applyTimezone(inputData);
if (formatter) {
const mainSeriesData = (this.mainSerie$.value?.data() ?? []) as unknown as SerieData[];
if (data.length === 1) {
return formatter({
mainSeriesData,
selfData: this.data() as unknown as ChartTypeToCandleData[TSeries][],
candle: this.formatMainSerie(data)[0] as unknown as SerieData,
indicatorReference: this.indicatorReference ?? undefined,
});
}
return formatter({
mainSeriesData,
indicatorReference: this.indicatorReference ?? undefined,
selfData: this.data() as unknown as ChartTypeToCandleData[TSeries][],
});
}
return this.formatMainSerie(data);
}
protected subscribeDataSource = (dataSource: DataSource): void => {
const minMove = getPricePrecisionStep();
this.subscriptions.add(
this.mainSymbol$.pipe(distinctUntilChanged()).subscribe((symbol) => {
this.lwcSeries.applyOptions({
// todo: на каждый апдейт dataSource сеттим options. Не оптимально
title: this.showSymbolLabel ? symbol : '',
priceFormat: {
type: 'custom',
minMove,
formatter: (price: number) => formatPrice(price),
},
});
this.dataSub?.unsubscribe();
this.realtimeSub?.unsubscribe();
this.dataSub = dataSource.subscribe(symbol, (next) => {
this.dataSourceSubscription(next);
});
this.realtimeSub = dataSource.subscribeRealtime(symbol, (next: Candle) => {
this.dataSourceRealtimeSubscription(next);
});
}),
);
};
}
export function calcCandleChange(
prev: BarData | LineData | HistogramData | CustomData | null,
current: BarData | LineData | HistogramData | CustomData | null,
): (Ohlc & { customValues?: Record<string, any> }) | null {
if (!current) {
return null;
}
if (!prev) {
return current;
}
if (isBarData(prev) && isBarData(current)) {
const absoluteChange = current.close - prev.close;
const percentageChange = ((current.close - prev.close) / prev.close) * 100;
return {
...current,
absoluteChange,
percentageChange,
};
}
if (isLineData(prev) && isLineData(current)) {
const absoluteChange = current.value - prev.value;
const percentageChange = ((current.value - prev.value) / prev.value) * 100;
return {
time: current.time,
value: current.value,
high: current.customValues?.high as number,
low: current.customValues?.low as number,
absoluteChange,
percentageChange,
customValues: current.customValues,
};
}
return null;
}
import {
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 { Candle, LineCandle } from '@src/types';
import { ensureDefined, formatPrice, 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' || typeof point.close !== 'number') {
return false;
}
// Если указаны OHLC, проверяем их корректность
if (point.open !== undefined && point.high !== undefined && point.low !== 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]);
};
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: formatPrice(currentBar.open) ?? '',
name: 'Откр.',
color,
},
high: {
value: formatPrice(currentBar.high) ?? '',
name: 'Макс.',
color,
},
low: {
value: formatPrice(currentBar.low) ?? '',
name: 'Мин.',
color,
},
close: {
value: formatPrice(currentBar.close) ?? '',
name: 'Закр.',
color,
},
absoluteChange: {
value: formatPrice(absoluteChange) ?? '',
name: 'Изм.',
color,
},
percentageChange: {
value: percentageChange !== undefined ? `${formatPrice(percentageChange)}%` : '',
name: 'Изм.',
color,
},
time: {
value: time,
name: 'Время',
color,
},
};
}
}
import {
BarData,
CustomData,
HistogramData,
HistogramSeries,
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 { Candle, LineCandle } from '@src/types';
import { ensureDefined, formatPrice, isHistogramData } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
export class HistogramSeriesStrategy extends BaseSeries<'Histogram'> implements ISeries<'Histogram'> {
constructor(params: BaseSeriesParams<'Histogram'>) {
super(params);
this.subscribeDataSource(params.dataSource);
}
protected seriesDefinition(): SeriesDefinition<'Histogram'> {
return HistogramSeries;
}
protected getDefaultOptions(): SeriesPartialOptionsMap['Histogram'] {
return {};
}
public validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean {
if (!Array.isArray(data)) {
return false;
}
return data.every(
(point) => typeof point.time === 'number' && typeof point.volume === 'number' && !Number.isNaN(point.volume),
);
}
public getTypeName(): string {
return 'Histogram';
}
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]);
};
protected formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>['Histogram'][] {
return inputData.map((point) => ({
time: point.time as Time,
value: 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 || !isHistogramData(currentBar)) {
return {};
}
const { absoluteChange, percentageChange, time } = ensureDefined(calcCandleChange(prevBar, currentBar));
const color = currentBar.color ? removeAlphaFromHex(currentBar.color) : this.options().color;
return {
value: {
value: formatPrice(currentBar.value) ?? '',
name: '',
color,
},
absoluteChange: {
value: formatPrice(absoluteChange) ?? '',
name: 'Изм.',
color,
},
percentageChange: {
value: percentageChange !== undefined ? `${formatPrice(percentageChange)}%` : '',
name: 'Изм.',
color,
},
time: {
value: time,
name: 'Время',
color,
},
};
}
}
import {
BarData,
CustomData,
HistogramData,
LineData,
LineSeries,
LineStyle,
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 { Candle, LineCandle } from '@src/types';
import { ensureDefined, formatPrice, isLineData } from '@src/utils';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';
export class LineSeriesStrategy extends BaseSeries<'Line'> implements ISeries<'Line'> {
constructor(params: BaseSeriesParams<'Line'>) {
super(params);
this.subscribeDataSource(params.dataSource);
}
protected seriesDefinition(): SeriesDefinition<'Line'> {
return LineSeries;
}
protected getDefaultOptions(): SeriesPartialOptionsMap['Line'] {
const { colors } = getThemeStore();
return {
color: colors.chartLineColor,
lineWidth: 2,
lineStyle: LineStyle.Solid,
};
}
public validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean {
if (!Array.isArray(data)) {
return false;
}
return data.every(
(point) => typeof point.time === 'number' && typeof point.close === 'number' && !Number.isNaN(point.close),
);
}
public getTypeName(): string {
return 'Line';
}
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]);
};
protected formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>['Line'][] {
return inputData.map((point) => ({
time: point.time as Time,
value: point.close, // Для line графика используем close как value
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 || !isLineData(currentBar)) {
return {};
}
const { absoluteChange, percentageChange, time } = ensureDefined(calcCandleChange(prevBar, currentBar));
const color = currentBar.color ? removeAlphaFromHex(currentBar.color) : this.options().color;
return {
value: {
value: formatPrice(currentBar.value) ?? '',
name: '',
color,
},
absoluteChange: {
value: formatPrice(absoluteChange) ?? '',
name: 'Изм.',
color,
},
percentageChange: {
value: percentageChange !== undefined ? `${formatPrice(percentageChange)}%` : '',
name: 'Изм.',
color,
},
time: {
value: time,
name: 'Время',
color,
},
};
}
}
import { SeriesType } from 'lightweight-charts';
import { IBaseSeries } from '@core/Series/BaseSeries';
import { Candle, Destroyable, LineCandle } from '../../types/chart';
export interface ISeries<TSeries extends SeriesType> extends IBaseSeries<TSeries>, Destroyable {
show(): void;
isVisible(): boolean;
hide(): void;
validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean;
getTypeName(): string;
}
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 { DataSource } from '@core/DataSource';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { Pane, PaneParams } from '@core/Pane';
type PaneManagerParams = Omit<PaneParams, 'isMainPane' | 'id' | 'basedOn' | 'onDelete'>;
// todo: PaneManager, регулирует порядок пейнов. Знает про MainPane.
// todo: Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class PaneManager {
private mainPane: Pane;
private paneChartInheritedParams: PaneManagerParams & { isMainPane: boolean };
private panesMap: Map<number, Pane> = new Map<number, Pane>();
private panesIdIterator = 0;
constructor(params: PaneManagerParams) {
this.paneChartInheritedParams = { ...params, isMainPane: false };
this.mainPane = new Pane({ ...params, isMainPane: true, id: 0, onDelete: () => {} });
this.panesMap.set(this.panesIdIterator++, this.mainPane);
}
public getDrawingsSnapshot(): DrawingsManagerSnapshot {
return this.mainPane.getDrawingsSnapshot();
}
public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
this.mainPane.setDrawingsSnapshot(snapshot);
}
public getPanes() {
return this.panesMap;
}
public getMainPane: () => Pane = () => {
return this.mainPane;
};
public addPane(dataSource?: DataSource): Pane {
const id = this.panesIdIterator++;
const pane = new Pane({
...this.paneChartInheritedParams,
id,
dataSource: dataSource ?? null,
basedOn: dataSource ? undefined : this.mainPane,
onDelete: () => {
this.panesIdIterator--;
this.panesMap.delete(id);
},
});
this.panesMap.set(id, pane);
return pane;
}
public getDrawingsManager(): DrawingsManager {
// todo: temp
return this.mainPane.getDrawingManager();
}
}
import { IChartApi, IPaneApi, Time } from 'lightweight-charts';
import { BehaviorSubject, 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 { Indicator } from '@core/Indicator';
import { Legend } from '@core/Legend';
import { ReactRenderer } from '@core/ReactRenderer';
import { TooltipService } from '@core/Tooltip';
import { UIRenderer } from '@core/UIRenderer';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { DrawingsNames, indicatorLabelById } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { OHLCConfig, TooltipConfig } from '@src/types';
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;
}
// todo: Pane, ему должна принадлежать mainSerie, а также IndicatorManager и drawingsManager, mouseEvents. Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: Учитывать, что есть линейка, которая рисуется одна для всех пейнов
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class Pane {
private readonly id: number;
private isMain: boolean;
private mainSeries: BehaviorSubject<SeriesStrategies | null> = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
private legend!: Legend;
private tooltip: TooltipService | undefined;
private indicatorsMap: BehaviorSubject<Map<string, Indicator>> = new BehaviorSubject<Map<string, Indicator>>(
new Map(),
);
private lwcPane: IPaneApi<Time>;
private lwcChart: IChartApi;
private eventManager: EventManager;
private drawingsManager: DrawingsManager;
private legendContainer!: HTMLElement;
private paneOverlayContainer!: HTMLElement;
private legendRenderer!: UIRenderer;
private tooltipRenderer: UIRenderer | undefined;
private modalRenderer: ModalRenderer;
private mainSerieSub!: Subscription;
private subscribeChartEvent: ChartMouseEvents['subscribe'];
private onDelete: () => void;
private subscriptions = new Subscription();
constructor({
lwcChart,
eventManager,
dataSource,
DOM,
isMainPane,
ohlcConfig,
id,
basedOn,
subscribeChartEvent,
tooltipConfig,
onDelete,
chartContainer,
modalRenderer,
}: PaneParams) {
this.onDelete = onDelete;
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.modalRenderer = modalRenderer;
this.subscribeChartEvent = subscribeChartEvent;
this.isMain = isMainPane ?? false;
this.id = id;
this.initializeLegend({ ohlcConfig });
if (isMainPane) {
this.lwcPane = this.lwcChart.panes()[this.id];
} else {
this.lwcPane = this.lwcChart.addPane(true);
}
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()}
// ohlcConfig={this.legend.getConfig()}
ohlcConfig={ohlcConfig}
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.drawingsManager = new DrawingsManager({
// todo: менеджер дровингов должен быть один на чарт, не на пейн
eventManager,
DOM,
mainSeries$: this.mainSeries.asObservable(),
lwcChart,
container: chartContainer,
modalRenderer: this.modalRenderer,
});
this.subscriptions.add(
// todo: переедет в пейн
this.drawingsManager.entities().subscribe((drawings) => {
const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);
this.legendContainer.style.display = hasRuler ? 'none' : '';
}),
);
}
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 setIndicator(indicatorId: string, indicator: Indicator): void {
const map = this.indicatorsMap.value;
map.set(indicatorId, indicator);
this.indicatorsMap.next(map);
}
public removeIndicator(indicatorId: string): void {
const map = this.indicatorsMap.value;
map.delete(indicatorId);
this.indicatorsMap.next(map);
if (map.size === 0 && !this.isMain) {
this.destroy();
}
}
public getDrawingManager(): DrawingsManager {
return this.drawingsManager;
}
private initializeLegend({ ohlcConfig }: { ohlcConfig: OHLCConfig }) {
const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();
this.legendContainer = legendContainer;
this.paneOverlayContainer = paneOverlayContainer;
this.legendRenderer = new ReactRenderer(legendContainer);
requestAnimationFrame(() => {
setTimeout(() => {
const lwcPaneElement = this.lwcPane.getHTMLElement();
if (!lwcPaneElement) return;
lwcPaneElement.style.position = 'relative';
lwcPaneElement.appendChild(legendContainer);
lwcPaneElement.appendChild(paneOverlayContainer);
}, 0);
});
// todo: переписать код ниже под логику пейнов
// /*
// Внутри lightweight-chart DOM построен как таблица из 3 td
// [0] left priceScale, [1] center chart, [2] right priceScale
// Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
// */
// requestAnimationFrame(() => {
// const root = chartAreaContainer.querySelector('.tv-lightweight-charts');
// console.log(root)
// const table = root?.querySelector('table');
// console.log(table)
//
// const htmlCollectionOfPanes = table?.getElementsByTagName('td')
// console.log(htmlCollectionOfPanes)
//
// const centerId = htmlCollectionOfPanes?.[1];
// console.log(centerId)
//
// if (centerId && legendContainer && legendContainer.parentElement !== centerId) {
// centerId.appendChild(legendContainer);
// }
// });
// /*
// Внутри lightweight-chart DOM построен как таблица из 3 td
// [0] left priceScale, [1] center chart, [2] right priceScale
// Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
// */
// requestAnimationFrame(() => {
// const root = chartAreaContainer.querySelector('.tv-lightweight-charts');
// const table = root?.querySelector('table');
// const centerId = table?.getElementsByTagName('td')?.[1];
//
// if (centerId && legendContainer && legendContainer.parentElement !== centerId) {
// centerId.appendChild(legendContainer);
// }
// });
this.legend = new Legend({
config: ohlcConfig,
indicators: this.indicatorsMap,
eventManager: this.eventManager,
subscribeChartEvent: this.subscribeChartEvent,
mainSeries: this.isMain ? this.mainSeries : null,
paneId: this.id,
openIndicatorSettings: (indicatorId, indicator) => {
let settings = indicator.getSettings();
this.modalRenderer.renderComponent(
<EntitySettingsModal
tabs={[{ key: 'arguments', label: 'Аргументы', fields: indicator.getSettingsConfig() }]}
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 initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }) {
this.mainSerieSub = this.eventManager.subscribeSeriesSelected((nextSeries) => {
this.mainSeries.value?.destroy();
const next = ensureDefined(SeriesFactory.create(nextSeries))({
lwcChart,
dataSource,
mainSymbol$: this.eventManager.getSymbol(),
mainSerie$: this.mainSeries,
});
this.mainSeries.next(next);
});
}
public destroy() {
this.subscriptions.unsubscribe();
this.tooltip?.destroy();
this.legend?.destroy();
this.legendRenderer.destroy();
this.tooltipRenderer?.destroy();
this.indicatorsMap.complete();
this.mainSerieSub?.unsubscribe();
try {
this.lwcChart.removePane(this.id);
} catch (e) {
console.log(e);
}
this.onDelete();
}
}
import dayjs from 'dayjs';
import {
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 { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
import { IndicatorsIds } from '@src/constants';
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 {
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 { createTickMarkFormatter, formatDate } from '@src/utils/formatter';
export interface ChartConfig extends Partial<ChartOptionsModel> {
container: HTMLElement;
seriesTypes: ChartSeriesType[];
theme: ThemeKey;
mode?: ThemeMode;
chartOptions?: ChartTypeOptions;
localization?: LocalizationOptionsBase;
}
export enum Resize {
Shrink,
Expand,
}
const HISTORY_LOAD_THRESHOLD = 50;
interface ChartParams extends ChartConfig {
dataSource: DataSource;
eventManager: EventManager;
modalRenderer: ModalRenderer;
ohlcConfig: OHLCConfig;
tooltipConfig: TooltipConfig;
initialIndicators?: IndicatorsIds[];
}
/**
* Абстракция над библиотекой для построения графиков
*/
export class Chart {
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: Omit<ChartConfig, 'theme' | 'mode'>;
private mainSeries: BehaviorSubject<SeriesStrategies | null> = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
private DOM: DOMModel;
private isPointerDown = false;
private didResetOnDrag = false;
private subscriptions = new Subscription();
private currentInterval: Intervals | null = null;
private activeSymbols: string[] = [];
private historyBatchRunning = false;
constructor({
eventManager,
dataSource,
modalRenderer,
ohlcConfig,
tooltipConfig,
initialIndicators,
...lwcConfig
}: ChartParams) {
this.eventManager = eventManager;
this.dataSource = dataSource;
this.container = lwcConfig.container;
this.chartConfig = lwcConfig;
this.lwcChart = createChart(this.container, getOptions(lwcConfig));
this.optionsSubscription = this.eventManager
.getChartOptionsModel()
.subscribe(({ dateFormat, timeFormat, showTime }) => {
const configToApply = { ...lwcConfig, dateFormat, timeFormat, showTime };
this.lwcChart.applyOptions({
...getOptions(configToApply),
localization: {
timeFormatter: (time: UTCTimestamp) => formatDate(time, dateFormat, timeFormat, showTime),
},
});
});
this.subscriptions.add(this.optionsSubscription);
this.mouseEvents = new ChartMouseEvents({ lwcChart: this.lwcChart, container: this.container });
this.mouseEvents.subscribe('wheel', this.onWheel);
this.mouseEvents.subscribe('pointerDown', this.onPointerDown);
this.mouseEvents.subscribe('pointerMove', this.onPointerMove);
this.mouseEvents.subscribe('pointerUp', this.onPointerUp);
this.mouseEvents.subscribe('pointerCancel', this.onPointerUp);
this.DOM = new DOMModel({ modalRenderer });
this.paneManager = new PaneManager({
eventManager: this.eventManager,
lwcChart: this.lwcChart,
dataSource,
DOM: this.DOM,
ohlcConfig,
subscribeChartEvent: this.subscribeChartEvent,
chartContainer: this.container,
tooltipConfig,
modalRenderer,
});
this.indicatorManager = new IndicatorManager({
eventManager,
initialIndicators,
DOM: this.DOM,
dataSource: this.dataSource,
lwcChart: this.lwcChart,
paneManager: this.paneManager,
chartOptions: lwcConfig.chartOptions,
});
this.compareManager = new CompareManager({
chart: this.lwcChart,
eventManager: this.eventManager,
dataSource: this.dataSource,
indicatorManager: this.indicatorManager,
paneManager: this.paneManager,
});
this.setupDataSourceSubs();
this.setupHistoricalDataLoading();
}
public getPriceScaleWidth(direction: Direction): number {
try {
const priceScale = this.lwcChart.priceScale(direction);
return priceScale ? priceScale.width() : 0;
} catch {
return 0;
}
}
public getDrawingsManager = (): DrawingsManager => {
return this.paneManager.getDrawingsManager();
};
public getIndicatorManager = (): IndicatorManager => {
return this.indicatorManager;
};
private onWheel = () => {
this.eventManager.resetInterval({ history: false });
};
private onPointerDown = () => {
this.isPointerDown = true;
this.didResetOnDrag = false;
};
private onPointerMove = () => {
if (!this.isPointerDown) return;
if (this.didResetOnDrag) return;
this.didResetOnDrag = true;
this.eventManager.resetInterval({ history: false });
};
private onPointerUp = () => {
this.isPointerDown = false;
};
public getDom(): DOMModel {
return this.DOM;
}
public getMainSeries(): Observable<SeriesStrategies | null> {
return this.mainSeries.asObservable();
}
public getCompareManager(): CompareManager {
return this.compareManager;
}
public updateTheme(theme: ThemeKey, mode: ThemeMode) {
this.lwcChart.applyOptions(getOptions({ ...this.chartConfig, theme, mode }));
}
public destroy(): void {
this.mouseEvents.destroy();
this.compareManager.clear();
this.subscriptions.unsubscribe();
this.lwcChart.remove();
}
public subscribeChartEvent: ChartMouseEvents['subscribe'] = (event, callback) =>
this.mouseEvents.subscribe(event, callback);
public unsubscribeChartEvent: ChartMouseEvents['unsubscribe'] = (event, callback) => {
this.mouseEvents.unsubscribe(event, callback);
};
// todo: add/move to undo/redo model(eventManager)
public scrollTimeScale = (direction: Direction) => {
this.eventManager.resetInterval({ history: false });
const diff = direction === Direction.Left ? -2 : 2;
const currentPosition = this.lwcChart.timeScale().scrollPosition();
this.lwcChart.timeScale().scrollToPosition(currentPosition + diff, false);
};
// todo: add/move to undo/redo model(eventManager)
public zoomTimeScale = (resize: Resize) => {
this.eventManager.resetInterval({ history: false });
const diff = resize === Resize.Shrink ? -1 : 1;
const currentRange = this.lwcChart.timeScale().getVisibleRange();
if (!currentRange) return;
const { from, to } = currentRange as IRange<number>;
if (!from || !to) return;
const next: IRange<Time> = {
from: (from + (to - from) * 0.1 * diff) as Time,
to: to as Time,
};
this.lwcChart.timeScale().setVisibleRange(next);
};
// todo: add to undo/redo model(eventManager)
public resetZoom = () => {
this.eventManager.resetInterval({ history: false });
this.lwcChart.timeScale().resetTimeScale();
this.lwcChart.priceScale(Direction.Right).setAutoScale(true);
this.lwcChart.priceScale(Direction.Left).setAutoScale(true);
};
public getRealtimeApi() {
return {
getTimeframe: () => this.eventManager.getTimeframe(),
getSymbols: () => this.activeSymbols,
update: (symbol: string, candle: Candle) => {
this.dataSource.updateRealtime(symbol, candle);
},
};
}
private scheduleHistoryBatch = () => {
if (this.historyBatchRunning) return;
this.historyBatchRunning = true;
requestAnimationFrame(() => {
const symbols = this.activeSymbols.slice();
Promise.all(symbols.map((s) => this.dataSource.loadMoreHistory(s))).finally(() => {
this.historyBatchRunning = false;
const range = this.lwcChart.timeScale().getVisibleLogicalRange();
if (range && range.from < HISTORY_LOAD_THRESHOLD) {
this.scheduleHistoryBatch();
}
});
});
};
private setupDataSourceSubs() {
const getWarmupFrom = (): number => {
if (this.currentInterval && this.currentInterval !== Intervals.All) {
return getIntervalRange(this.currentInterval).from;
}
const range = this.lwcChart.timeScale().getVisibleRange();
if (!range) return 0;
const { from } = range as IRange<number>;
return from as number;
};
const warmupSymbols = (symbols: string[]) => {
if (!symbols.length) return;
const from = getWarmupFrom();
if (!from) return;
Promise.all(symbols.map((symbol) => this.dataSource.loadTill(symbol, from))).catch((error) => {
console.error('[Chart] Ошибка при прогреве символов:', error);
});
};
const symbols$ = combineLatest([this.eventManager.symbol(), this.compareManager.itemsObs()]).pipe(
map(([main, items]) => Array.from(new Set([main, ...items.map((i) => i.symbol)]))),
);
this.subscriptions.add(
this.eventManager
.getInterval()
.pipe(withLatestFrom(symbols$))
.subscribe(([interval, symbols]) => {
this.currentInterval = interval;
if (!interval) return;
if (interval === Intervals.All) {
Promise.all(symbols.map((s) => this.dataSource.loadAllHistory(s)))
.then(() => {
requestAnimationFrame(() => this.lwcChart.timeScale().fitContent());
})
.catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));
return;
}
const { from, to } = getIntervalRange(interval);
Promise.all(symbols.map((s) => this.dataSource.loadTill(s, from)))
.then(() => {
this.lwcChart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
})
.catch((error) => {
console.error('[Chart] Ошибка при применении интервала:', error);
});
}),
);
this.subscriptions.add(
symbols$.subscribe((symbols) => {
const prevSymbols = this.activeSymbols;
this.activeSymbols = symbols;
this.dataSource.setSymbols(symbols);
const prevSet = new Set(prevSymbols);
const added: string[] = [];
for (let i = 0; i < symbols.length; i += 1) {
const s = symbols[i];
if (!s) continue;
if (prevSet.has(s)) continue;
added.push(s);
}
if (added.length) {
warmupSymbols(added);
}
}),
);
this.subscriptions.add(
combineLatest([
this.eventManager.symbol(),
this.compareManager.itemsObs(),
this.mainSeries.asObservable(),
]).subscribe(([main, items, serie]) => {
if (!serie) return;
const title = items.length ? main : '';
serie.getLwcSeries().applyOptions({ title });
}),
);
}
private setupHistoricalDataLoading(): void {
// todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
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();
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,
},
rightPriceScale: {
textColor: colors.chartTextPrimary,
borderVisible: false,
},
};
}