Загрузка данных
import {
createChart,
CrosshairMode,
type IChartApi,
type ISeriesApi,
type LineData,
LineSeries,
type MouseEventParams,
PriceScaleMode,
type Time,
type WhitespaceData,
} from 'lightweight-charts';
import { BehaviorSubject, type Observable } from 'rxjs';
import { createInitialSeasonalityState } from '@src/constants';
import { DataSourceParams } from '@src/core/DataSource';
import { COMPARE_COLOR_PALETTE } from '@src/theme';
import { getThemeStore } from '@src/theme/store';
import { getLocale, t } from '@src/translations';
import { Timeframes } from '@src/types/timeframes';
import { canvasToBlob, drawTextLayer, formatCompactNumber } from '@src/utils';
import type { Candle, DailyClose, SeasonalityRange, SeasonalityState, SeasonalityYear } from '@src/types';
interface SeasonalityManagerParams {
container: HTMLElement;
getData: DataSourceParams['getData'];
precision?: number;
onOpen: () => void;
onBack: () => void;
}
const DEFAULT_YEARS = 5;
const DAILY_TIMEFRAME = Timeframes['1d'];
const MAX_HISTORY_PAGES = 1000;
const CALENDAR = Array.from({ length: 366 }, (_, day) =>
new Date(Date.UTC(2000, 0, day + 1)).toISOString().slice(0, 10),
);
type SeriesDatum = LineData<Time> | WhitespaceData<Time>;
export class SeasonalityManager {
private readonly state = new BehaviorSubject<SeasonalityState>(createInitialSeasonalityState());
private readonly chartContainer = document.createElement('div');
private readonly legend = document.createElement('div');
private readonly statusElement = document.createElement('div');
private chart: IChartApi | null = null;
private series: ISeriesApi<'Line'>[] = [];
private allYears: SeasonalityYear[] = [];
private data: SeasonalityYear[] = [];
private requestId = 0;
private renderFrame: number | null = null;
private destroyed = false;
constructor(private readonly params: SeasonalityManagerParams) {
Object.assign(this.chartContainer.style, { position: 'absolute', inset: '0' });
Object.assign(this.legend.style, {
position: 'absolute',
top: '8px',
left: '8px',
right: '70px',
display: 'flex',
flexWrap: 'wrap',
gap: '6px 16px',
pointerEvents: 'none',
fontSize: '12px',
zIndex: '2',
});
Object.assign(this.statusElement.style, {
position: 'absolute',
inset: '0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '16px',
textAlign: 'center',
pointerEvents: 'none',
});
params.container.append(this.chartContainer, this.legend, this.statusElement);
}
public getState(): Observable<SeasonalityState> {
return this.state.asObservable();
}
public isActive(): boolean {
return this.state.value.view === 'seasonality';
}
public async show(symbolId: string, range?: SeasonalityRange): Promise<void> {
if (this.destroyed) return;
if (this.isActive() && this.state.value.symbolId === symbolId) {
if (this.state.value.status === 'loading') return;
if (this.allYears.length > 0) {
if (range) this.setRange(range);
return;
}
}
const requestId = ++this.requestId;
const isCurrent = () => !this.destroyed && requestId === this.requestId;
if (!this.isActive()) this.params.onOpen();
this.cancelRender();
this.clearChart();
this.allYears = [];
this.showStatus(t('Loading seasonality'));
this.state.next({ ...this.state.value, symbolId, view: 'seasonality', status: 'loading', error: null });
try {
if (!symbolId) throw new Error('Select an instrument');
const rows = await this.loadHistory(symbolId, isCurrent);
if (!isCurrent()) return;
this.allYears = buildSeasonality(rows);
if (this.allYears.length === 0) {
this.showStatus(t('No seasonality data'));
this.state.next({ ...this.state.value, status: 'empty' });
return;
}
const minYear = this.allYears[0].year;
const maxYear = this.allYears[this.allYears.length - 1].year;
const selected = normalizeRange(
range ?? {
fromYear: Math.max(minYear, maxYear - DEFAULT_YEARS + 1),
toYear: maxYear,
},
minYear,
maxYear,
);
this.state.next({ ...this.state.value, ...selected, minYear, maxYear });
this.renderRange();
} catch (error) {
if (!isCurrent()) return;
this.clearChart();
this.allYears = [];
this.showStatus(t('Unable to load seasonality'));
this.state.next({ ...this.state.value, status: 'error', error: String(error) });
console.error('[Seasonality] History loading failed:', error);
}
}
public setRange = (range: SeasonalityRange): void => {
if (this.destroyed || !this.isActive() || this.allYears.length === 0) return;
const state = this.state.value;
const next = normalizeRange(range, state.minYear, state.maxYear);
if (next.fromYear === state.fromYear && next.toYear === state.toYear) return;
this.state.next({ ...state, ...next });
if (this.renderFrame !== null) return;
this.renderFrame = requestAnimationFrame(() => {
this.renderFrame = null;
if (!this.destroyed && this.isActive()) this.renderRange();
});
};
public setScale = (scale: SeasonalityScale): void => {
if (this.destroyed || !this.isActive() || this.state.value.scale === scale) return;
this.state.next({ ...this.state.value, scale });
if (this.chart) this.applyScale();
};
public back = (): void => {
if (this.destroyed || !this.isActive()) return;
this.requestId += 1;
this.cancelRender();
this.clearChart();
this.allYears = [];
this.statusElement.style.display = 'none';
this.state.next({ ...this.state.value, view: 'chart', status: 'idle', error: null });
this.params.onBack();
};
public updateTheme(): void {
const { colors } = getThemeStore();
this.params.container.style.background = colors.chartBackground;
this.statusElement.style.color = colors.chartTextPrimary;
this.chart?.applyOptions({
layout: { background: { color: colors.chartBackground }, textColor: colors.chartTextPrimary },
grid: { vertLines: { color: colors.chartGridLine }, horzLines: { color: colors.chartGridLine } },
});
}
public async copyImage(): Promise<void> {
if (!this.chart || this.state.value.status !== 'ready') return;
const canvas = this.chart.takeScreenshot(true, false);
drawTextLayer(canvas, this.legend, this.chart.chartElement());
const blob = await canvasToBlob(canvas);
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
}
public destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.requestId += 1;
this.cancelRender();
this.clearChart();
this.allYears = [];
this.chartContainer.remove();
this.legend.remove();
this.statusElement.remove();
this.state.complete();
}
private async loadHistory(symbolId: string, isCurrent: () => boolean): Promise<DailyClose[]> {
const rows = new Map<string, DailyClose & { time: number }>();
const loadPage = async (cursor?: Candle, page = 0): Promise<DailyClose[]> => {
if (!isCurrent()) return [];
if (page === MAX_HISTORY_PAGES) throw new Error('History page limit exceeded');
const loaded = await this.params.getData(DAILY_TIMEFRAME, symbolId, cursor);
if (!isCurrent()) return [];
if (!loaded?.length) return [...rows.values()];
let oldest = loaded[0];
let oldestTime = Infinity;
for (let index = loaded.length - 1; index >= 0; index -= 1) {
const candle = loaded[index];
if (!Number.isFinite(candle.time) || !Number.isFinite(candle.close)) throw new Error('Invalid daily candle');
const time = Math.floor(candle.time > 1e10 ? candle.time / 1000 : candle.time);
if (time < oldestTime) {
oldest = candle;
oldestTime = time;
}
const date = new Date(time * 1000).toISOString().slice(0, 10);
const previous = rows.get(date);
if (previous && previous.time !== time) throw new Error(`Multiple daily candles on ${date}`);
if (!previous) rows.set(date, { date, close: candle.close, time });
}
if (cursor && oldestTime >= cursor.time) throw new Error('History pagination made no progress');
return loadPage({ ...oldest, time: oldestTime }, page + 1);
};
return loadPage();
}
private renderRange(): void {
const state = this.state.value;
const visibleRange = this.chart?.timeScale().getVisibleLogicalRange();
this.data = this.allYears.filter((year) => year.year >= state.fromYear && year.year <= state.toYear);
if (this.data.length === 0) {
this.clearChart();
this.showStatus(t('No seasonality data'));
this.state.next({ ...state, status: 'empty' });
return;
}
if (!this.chart) {
this.chart = createChart(this.chartContainer, {
autoSize: true,
rightPriceScale: { mode: PriceScaleMode.Normal, visible: true, borderVisible: false },
leftPriceScale: { visible: false },
crosshair: { mode: CrosshairMode.Normal },
timeScale: {
timeVisible: false,
secondsVisible: false,
rightOffset: 0,
tickMarkFormatter: formatSeasonDate,
lockVisibleTimeRangeOnResize: true,
},
localization: { locale: getLocale(), timeFormatter: formatSeasonDate },
});
this.updateTheme();
this.chart.subscribeCrosshairMove(this.onCrosshairMove);
}
for (const series of this.series) this.chart.removeSeries(series);
this.series = this.data.map((year) =>
this.chart!.addSeries(LineSeries, {
title: String(year.year),
color: getYearColor(year.year),
priceLineVisible: false,
lastValueVisible: false,
}),
);
this.legend.replaceChildren(
...this.data.map((year) => {
const item = document.createElement('span');
item.style.color = getYearColor(year.year);
return item;
}),
);
this.applyScale();
if (visibleRange) this.chart.timeScale().setVisibleLogicalRange(visibleRange);
else this.chart.timeScale().setVisibleRange({ from: '2000-01-01', to: '2000-12-31' });
this.state.next({ ...this.state.value, status: 'ready' });
}
private applyScale(): void {
const { scale } = this.state.value;
const visibleRange = this.chart?.timeScale().getVisibleLogicalRange();
this.series.forEach((series, index) => {
series.applyOptions({
priceFormat: {
type: 'custom',
minMove: scale === 'percentage' ? 0.01 : 10 ** -(this.params.precision ?? 2),
formatter:
scale === 'percentage'
? (value: number) => `${value.toFixed(2)}%`
: (value: number) => formatCompactNumber(value),
},
});
series.setData(toSeriesData(this.data[index], scale));
});
if (visibleRange) this.chart?.timeScale().setVisibleLogicalRange(visibleRange);
if (scale === 'percentage' && !this.data.some((year) => year.baselinePrice > 0)) {
this.showStatus(t('Percentage requires a positive baseline'));
} else this.statusElement.style.display = 'none';
this.updateLegend();
}
private onCrosshairMove = (event: MouseEventParams): void => {
this.updateLegend(event.time === undefined ? undefined : toDateKey(event.time));
};
private updateLegend(chartDate?: string): void {
this.data.forEach((year, index) => {
const point = chartDate
? year.points.find((item) => item.chartDate === chartDate)
: year.points[year.points.length - 1];
const value = this.state.value.scale === 'absolute' ? point?.price : point?.percentage;
const item = this.legend.children[index];
const formatted = value == null ? '—' : this.series[index].priceFormatter().format(value);
item.textContent = `${year.year} · ${point?.date ?? '—'} · ${formatted}`;
});
}
private showStatus(message: string): void {
this.statusElement.textContent = message;
this.statusElement.style.display = 'flex';
}
private cancelRender(): void {
if (this.renderFrame !== null) cancelAnimationFrame(this.renderFrame);
this.renderFrame = null;
}
private clearChart(): void {
this.chart?.unsubscribeCrosshairMove(this.onCrosshairMove);
this.chart?.remove();
this.chart = null;
this.series = [];
this.data = [];
this.legend.replaceChildren();
}
}
export function normalizeRange(range: SeasonalityRange, minYear: number, maxYear: number): SeasonalityRange {
if (!Number.isFinite(range.fromYear) || !Number.isFinite(range.toYear)) throw new Error('Invalid year range');
const fromYear = Math.max(minYear, Math.min(maxYear, Math.round(range.fromYear)));
const toYear = Math.max(fromYear, Math.min(maxYear, Math.round(range.toYear)));
return { fromYear, toYear };
}
export function buildSeasonality(rows: readonly DailyClose[]): SeasonalityYear[] {
const result: SeasonalityYear[] = [];
for (const row of [...rows].sort((a, b) => a.date.localeCompare(b.date))) {
const year = Number(row.date.slice(0, 4));
let group = result[result.length - 1];
if (!group || group.year !== year) {
group = { year, baselineDate: row.date, baselinePrice: row.close, points: [] };
result.push(group);
}
const percentage = group.baselinePrice > 0 ? (row.close / group.baselinePrice - 1) * 100 : null;
if (percentage !== null && !Number.isFinite(percentage)) throw new Error('Invalid percentage');
group.points.push({ date: row.date, chartDate: `2000-${row.date.slice(5)}`, price: row.close, percentage });
}
return result;
}
export function toSeriesData(year: SeasonalityYear, scale: SeasonalityScale): SeriesDatum[] {
let index = 0;
return CALENDAR.map((time) => {
const point = year.points[index];
if (point?.chartDate !== time) return { time };
index += 1;
const value = scale === 'absolute' ? point.price : point.percentage;
return value === null ? { time } : { time, value };
});
}
function toDateKey(time: Time): string {
if (typeof time === 'string') return time;
if (typeof time === 'number') return new Date(time * 1000).toISOString().slice(0, 10);
return `${time.year}-${String(time.month).padStart(2, '0')}-${String(time.day).padStart(2, '0')}`;
}
function formatSeasonDate(time: Time): string {
const date = toDateKey(time);
return `${date.slice(8, 10)}.${date.slice(5, 7)}`;
}
function getYearColor(index: number): string {
return COMPARE_COLOR_PALETTE[index % COMPARE_COLOR_PALETTE.length] ?? '#2962ff';
}
import dayjs from 'dayjs';
import {
BarPrice,
ChartOptions,
createChart,
CrosshairMode,
DeepPartial,
IChartApi,
IRange,
LocalizationOptionsBase,
LogicalRange,
Time,
UTCTimestamp,
} from 'lightweight-charts';
import { BehaviorSubject, combineLatest, defer, EMPTY, Observable, Subscription } from 'rxjs';
import { catchError, distinctUntilChanged, filter, ignoreElements, map, switchMap, tap } from 'rxjs/operators';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManagerCollection } from '@core/DrawingsManagerCollection';
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 { CHART_PANE_LEGEND_CONTAINER } 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 { 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 { canvasToBlob, drawTextLayer, 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((snapshot) => isIndicatorSnapshot(snapshot)),
compareSnapshots: snapshots.filter((snapshot) => isCompareSnapshot(snapshot)),
};
}
/**
* Абстракция над библиотекой для построения графиков
*/
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 activeSymbolIds: string[] = [];
private historyBatchRunning = false;
private active = true;
private savedViewport: {
logicalRange: LogicalRange | null;
timeRange: IRange<Time> | null;
oldestTimes: (number | null)[];
shiftVisibleRangeOnNewBar: boolean;
allowShiftVisibleRangeOnWhitespaceReplacement: boolean;
} | null = null;
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.preserveInactiveTimeScale();
});
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 getDrawingsCollectionManager = (): DrawingsManagerCollection => {
return this.paneManager.getDrawingsCollectionManager();
};
public getIndicatorManager = (): IndicatorManager => {
return this.indicatorManager;
};
private onWheel = () => {
if (!this.active) return;
this.eventManager.resetInterval({
history: false,
});
};
private onPointerDown = (event: PointerEvent) => {
if (!this.active) return;
if (event.composedPath().includes(this.lwcChart.chartElement())) {
this.container.focus({ preventScroll: true });
}
this.isPointerDown = true;
this.didResetOnDrag = false;
};
private onPointerMove = () => {
if (!this.active) return;
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.preserveInactiveTimeScale();
this.paneManager.invalidate();
}
public setActive(active: boolean): void {
if (this.active === active) return;
this.active = active;
this.isPointerDown = false;
const timeScale = this.lwcChart.timeScale();
if (!active) {
const options = timeScale.options();
this.savedViewport = {
logicalRange: timeScale.getVisibleLogicalRange(),
timeRange: timeScale.getVisibleRange(),
oldestTimes: this.activeSymbolIds.map((symbolId) => this.dataSource.getOldestTime(symbolId)),
shiftVisibleRangeOnNewBar: options.shiftVisibleRangeOnNewBar,
allowShiftVisibleRangeOnWhitespaceReplacement: options.allowShiftVisibleRangeOnWhitespaceReplacement,
};
this.preserveInactiveTimeScale();
return;
}
const saved = this.savedViewport;
this.savedViewport = null;
if (saved) {
timeScale.applyOptions({
shiftVisibleRangeOnNewBar: saved.shiftVisibleRangeOnNewBar,
allowShiftVisibleRangeOnWhitespaceReplacement: saved.allowShiftVisibleRangeOnWhitespaceReplacement,
});
const historyChanged = this.activeSymbolIds.some(
(symbolId, index) => this.dataSource.getOldestTime(symbolId) !== saved.oldestTimes[index],
);
if (historyChanged && saved.timeRange) {
timeScale.setVisibleRange(saved.timeRange);
} else if (saved.logicalRange) {
timeScale.setVisibleLogicalRange(saved.logicalRange);
}
}
}
private preserveInactiveTimeScale(): void {
if (this.active) return;
this.lwcChart.timeScale().applyOptions({
shiftVisibleRangeOnNewBar: false,
allowShiftVisibleRangeOnWhitespaceReplacement: false,
});
}
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) => {
if (!this.active) return;
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) => {
if (!this.active) return;
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 = () => {
if (!this.active) return;
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,
};
}
public async copyImage(): Promise<void> {
const canvas = this.lwcChart.takeScreenshot(true, false);
const chartElement = this.lwcChart.chartElement();
chartElement.querySelectorAll<HTMLElement>(`.${CHART_PANE_LEGEND_CONTAINER}`).forEach((legend) => {
if (legend.hidden || window.getComputedStyle(legend).display === 'none') {
return;
}
drawTextLayer(canvas, legend, chartElement);
});
const blob = await canvasToBlob(canvas);
await navigator.clipboard.write([
new ClipboardItem({
'image/png': blob,
}),
]);
}
private scheduleHistoryBatch(): void {
if (!this.active || this.subscriptions.closed || this.historyBatchRunning || this.activeSymbolIds.length === 0) {
return;
}
this.historyBatchRunning = true;
requestAnimationFrame(() => {
if (!this.active || this.subscriptions.closed) {
this.historyBatchRunning = false;
return;
}
const symbolIds = this.activeSymbolIds.slice();
const timeframe = this.eventManager.getTimeframe();
const oldestTimes = symbolIds.map((symbolId) => this.dataSource.getOldestTime(symbolId));
Promise.all(symbolIds.map((symbolId) => this.dataSource.loadMoreHistory(symbolId)))
.catch((error) => console.error('[Chart] History batch failed:', error))
.finally(() => this.finishHistoryBatch(symbolIds, oldestTimes, timeframe));
});
}
private finishHistoryBatch(
symbolIds: string[],
oldestTimes: (number | null)[],
timeframe: ReturnType<EventManager['getTimeframe']>,
): void {
this.historyBatchRunning = false;
if (!this.active || this.subscriptions.closed) {
return;
}
const contextChanged =
timeframe !== this.eventManager.getTimeframe() || isUniqueSymbolId(symbolIds, this.activeSymbolIds);
const hasProgress = this.hasHistoryProgress(symbolIds, oldestTimes);
if (!contextChanged && !hasProgress) {
return;
}
const range = this.lwcChart.timeScale().getVisibleLogicalRange();
if (range && range.from < HISTORY_LOAD_THRESHOLD) {
this.scheduleHistoryBatch();
}
}
private hasHistoryProgress(symbolIds: string[], oldestTimes: (number | null)[]): boolean {
return symbolIds.some((symbolId, index) => {
if (!this.activeSymbolIds.includes(symbolId)) {
return false;
}
const previousOldestTime = oldestTimes[index];
const currentOldestTime = this.dataSource.getOldestTime(symbolId);
return currentOldestTime !== null && (previousOldestTime === null || currentOldestTime < previousOldestTime);
});
}
private async warmupSymbols(symbolIds: string[]): Promise<void> {
const range = this.lwcChart.timeScale().getVisibleRange() as IRange<number> | null;
if (range === null) {
return;
}
await Promise.all(symbolIds.map((symbolId) => this.dataSource.loadTill(symbolId, range.from)));
}
private async loadIntervalRange(interval: Intervals, symbolIds: string[]): Promise<IRange<Time> | null> {
if (interval === Intervals.All) {
await Promise.all(symbolIds.map((symbolId) => this.dataSource.loadAllHistory(symbolId)));
const firstTimes = symbolIds
.map((symbolId) => this.dataSource.getOldestTime(symbolId))
.filter((time): time is number => time !== null);
const lastTimes = symbolIds
.map((symbolId) => this.dataSource.getLastCandle(symbolId)?.time)
.filter((time): time is number => time !== undefined);
if (firstTimes.length === 0 || lastTimes.length === 0) {
return null;
}
return {
from: Math.min(...firstTimes) as Time,
to: Math.max(...lastTimes) as Time,
};
}
const { from, to } = getIntervalRange(interval);
await Promise.all(symbolIds.map((symbolId) => this.dataSource.loadTill(symbolId, from)));
const hasData = symbolIds.some((symbolId) => this.dataSource.getOldestTime(symbolId) !== null);
if (!hasData) {
return null;
}
return {
from: from as Time,
to: to as Time,
};
}
private setupDataSourceSubs(): void {
let symbolsRegistered = false;
const symbols$ = combineLatest([this.eventManager.symbolId(), this.compareManager.itemsObs()]).pipe(
map(([mainSymbolId, items]) => {
const symbolIds = Array.from(
new Set([mainSymbolId, ...items.map(({ symbolId }) => symbolId)].filter((symbolId) => symbolId !== '')),
);
return {
mainSymbolId,
symbolIds,
};
}),
distinctUntilChanged(
(previous, next) =>
previous.mainSymbolId === next.mainSymbolId && !isUniqueSymbolId(previous.symbolIds, next.symbolIds),
),
);
this.subscriptions.add(
combineLatest([this.eventManager.getInterval().pipe(distinctUntilChanged()), symbols$])
.pipe(
switchMap(([interval, { symbolIds }]) => {
const previousSymbolIds = this.activeSymbolIds;
const previousSymbolIdsSet = new Set(previousSymbolIds);
const addedSymbolIds = symbolIds.filter((symbolId) => !previousSymbolIdsSet.has(symbolId));
this.activeSymbolIds = symbolIds;
if (!symbolsRegistered || isUniqueSymbolId(previousSymbolIds, symbolIds)) {
symbolsRegistered = true;
this.dataSource.setSymbols(symbolIds);
}
if (symbolIds.length === 0) {
return EMPTY;
}
if (interval === null) {
if (addedSymbolIds.length === 0) {
return EMPTY;
}
return defer(() => this.warmupSymbols(addedSymbolIds)).pipe(
ignoreElements(),
catchError((error) => {
console.error('[Chart] Ошибка при прогреве символов:', error);
return EMPTY;
}),
);
}
return defer(() => this.loadIntervalRange(interval, symbolIds)).pipe(
filter((range): range is IRange<Time> => range !== null),
tap((range) => {
if (this.active) this.lwcChart.timeScale().setVisibleRange(range);
}),
catchError((error) => {
console.error('[Chart] Ошибка при применении интервала:', error);
return EMPTY;
}),
);
}),
)
.subscribe(),
);
}
private setupHistoricalDataLoading(): void {
// todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
this.paneManager.setVisibleLogicalRange(logicalRange);
if (!logicalRange) return;
const needsMoreData = logicalRange.from < HISTORY_LOAD_THRESHOLD;
if (!needsMoreData) return;
this.scheduleHistoryBatch();
});
}
}
function isUniqueSymbolId(previous: string[], next: string[]): boolean {
if (previous.length !== next.length) {
return true;
}
const nextSet = new Set(next);
return previous.some((symbolId) => !nextSet.has(symbolId));
}
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,
defaultVisiblePriceScaleId: Direction.Right,
layout: {
background: {
color: colors.chartBackground,
},
textColor: colors.chartTextPrimary,
panes: {
separatorColor: colors.chartPaneSeparator,
separatorHoverColor: colors.chartPaneSeparator,
enableResize: true,
},
},
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: 10,
shiftVisibleRangeOnNewBar: true,
allowShiftVisibleRangeOnWhitespaceReplacement: true,
},
rightPriceScale: {
minimumWidth: 60,
borderVisible: false,
},
leftPriceScale: {
minimumWidth: 60,
borderVisible: false,
},
localization,
};
}