Загрузка данных
import { PriceScaleMode } from 'lightweight-charts';
import { useMemo } from 'react';
import { Table, type TableColumn } from '@src/components/Table';
import { getLocale, t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
import { getPercentPrecision, getPricePrecision, useObservable } from '@src/utils';
import styles from './index.module.scss';
import type { SeasonalityChange, SeasonalityState, SeasonalityTableRow } from '@src/types';
import type { ReactNode } from 'react';
import type { Observable } from 'rxjs';
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
] as const;
interface SeasonalityTableProps {
stateObs: Observable<SeasonalityState>;
}
export function SeasonalityTable({ stateObs }: SeasonalityTableProps) {
const state = useObservable(stateObs);
const locale = getLocale();
const fractionDigits = state?.scale === PriceScaleMode.Percentage ? getPercentPrecision() : getPricePrecision();
const formatter = useMemo(
() =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}),
[locale, fractionDigits],
);
if (!state || state.status === 'idle' || state.displayMode !== SeasonalityViewType.Table) {
return null;
}
const valueKey = state.scale === PriceScaleMode.Percentage ? 'percentage' : 'absolute';
const suffix = state.scale === PriceScaleMode.Percentage ? '%' : '';
const formatChange = (change: SeasonalityChange | null): string => {
const value = change?.[valueKey] ?? null;
return value === null ? '—' : `${formatter.format(value)}${suffix}`;
};
const getChangeClassName = (change: SeasonalityChange | null): string | undefined => {
const value = change?.[valueKey] ?? null;
if (value !== null && value > 0) {
return styles.positive;
}
if (value !== null && value < 0) {
return styles.negative;
}
return undefined;
};
const createChangeColumn = (
id: string,
header: ReactNode,
getChange: (row: SeasonalityTableRow) => SeasonalityChange | null,
className?: string,
): TableColumn<SeasonalityTableRow> => ({
id,
header,
align: 'right',
className,
renderCell: (row) => formatChange(getChange(row)),
getCellClassName: (row) => getChangeClassName(getChange(row)),
});
const columns: TableColumn<SeasonalityTableRow>[] = [
{
id: 'date',
header: t('Date'),
rowHeader: true,
renderCell: (row) => row.year,
},
...MONTHS.map((month, index) => createChangeColumn(month, t(month), (row) => row.months[index])),
createChangeColumn('year', t('Year'), (row) => row.total, styles.total),
];
const emptyContent = getEmptyContent(state.status);
return (
<Table
className={styles.seasonality}
columns={columns}
rows={state.tableRows}
getRowKey={(row) => row.year}
emptyContent={emptyContent}
stickyFirstColumn
/>
);
}
function getEmptyContent(status: SeasonalityState['status']): string {
if (status === 'loading') {
return t('Loading seasonality');
}
if (status === 'error') {
return t('Unable to load seasonality');
}
return t('No seasonality data');
}
import { type LineData, PriceScaleMode, type WhitespaceData } from 'lightweight-charts';
import type {
DailyClose,
SeasonalityChange,
SeasonalityRange,
SeasonalityScale,
SeasonalityTableRow,
SeasonalityYear,
} from '@src/types';
type SeriesDatum = LineData | WhitespaceData;
const CALENDAR = Array.from({ length: 366 }, (_, day) =>
new Date(Date.UTC(2000, 0, day + 1)).toISOString().slice(0, 10),
);
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 years: SeasonalityYear[] = [];
const sorted = [...rows].sort((a, b) => a.date.localeCompare(b.date));
for (const row of sorted) {
const year = Number(row.date.slice(0, 4));
let current = years[years.length - 1];
if (!current || current.year !== year) {
current = {
year,
baselineDate: row.date,
baselinePrice: row.close,
points: [],
};
years.push(current);
}
if (current.points[current.points.length - 1]?.date === row.date) {
throw new Error(`Multiple daily candles on ${row.date}`);
}
const percentage = current.baselinePrice > 0 ? (row.close / current.baselinePrice - 1) * 100 : null;
if (percentage !== null && !Number.isFinite(percentage)) {
throw new Error('Invalid percentage');
}
current.points.push({
date: row.date,
chartDate: `2000-${row.date.slice(5)}`,
price: row.close,
percentage,
});
}
return years;
}
export function toSeriesData(year: SeasonalityYear, scale: SeasonalityScale): SeriesDatum[] {
const isPercentage = scale === PriceScaleMode.Percentage;
let index = 0;
return CALENDAR.map((time) => {
const point = year.points[index];
if (point?.chartDate !== time) {
return { time };
}
index += 1;
const value = isPercentage ? point.percentage : point.price;
return value === null ? { time } : { time, value };
});
}
export function buildSeasonalityTable(years: readonly SeasonalityYear[]): SeasonalityTableRow[] {
const closes = new Map<number, number>();
for (const year of years) {
for (const point of year.points) {
const month = Number(point.date.slice(5, 7)) - 1;
closes.set(year.year * 12 + month, point.price);
}
}
return years
.map((year) => {
const firstMonth = year.year * 12;
const baselineMonth = Number(year.baselineDate.slice(5, 7)) - 1;
const lastPoint = year.points[year.points.length - 1];
const baselinePrice = closes.get(firstMonth - 1) ?? year.baselinePrice;
return {
year: year.year,
months: Array.from({ length: 12 }, (_, month) => {
const key = firstMonth + month;
const fromPrice = closes.get(key - 1) ?? (month === baselineMonth ? year.baselinePrice : undefined);
return calculateChange(fromPrice, closes.get(key));
}),
total: calculateChange(baselinePrice, lastPoint.price),
};
})
.reverse();
}
function calculateChange(from: number | undefined, to: number | undefined): SeasonalityChange | null {
if (from === undefined || to === undefined) {
return null;
}
return {
absolute: to - from,
percentage: from > 0 ? (to / from - 1) * 100 : null,
};
}
import { PriceScaleMode } from 'lightweight-charts';
import { buildSeasonality, normalizeRange, toSeriesData } from '@src/utils/seasonality';
describe('normalizeRange', () => {
it.each([
[
{ fromYear: 2010, toYear: 2040 },
{ fromYear: 2020, toYear: 2025 },
],
[
{ fromYear: 2022.6, toYear: 2024.4 },
{ fromYear: 2023, toYear: 2024 },
],
[
{ fromYear: 2024, toYear: 2021 },
{ fromYear: 2024, toYear: 2024 },
],
[
{ fromYear: 2010, toYear: 2011 },
{ fromYear: 2020, toYear: 2020 },
],
])('должен ограничивать и упорядочивать диапазон %j', (input, expected) => {
expect(normalizeRange(input, 2020, 2025)).toEqual(expected);
});
it.each([NaN, Infinity, -Infinity])('должен отклонять некорректную границу %s', (value) => {
expect(() => normalizeRange({ fromYear: value, toYear: 2024 }, 2020, 2025)).toThrow('Invalid year range');
expect(() => normalizeRange({ fromYear: 2020, toYear: value }, 2020, 2025)).toThrow('Invalid year range');
});
});
describe('buildSeasonality', () => {
it('должен возвращать пустой результат для пустой истории', () => {
expect(buildSeasonality([])).toEqual([]);
});
it('должен сортировать данные без изменения исходного массива и выбирать базу отдельно для каждого года', () => {
const rows = [
{ date: '2024-01-03', close: 125 },
{ date: '2023-12-31', close: 50 },
{ date: '2024-01-02', close: 100 },
];
const snapshot = rows.map((row) => ({ ...row }));
const years = buildSeasonality(rows);
expect(rows).toEqual(snapshot);
expect(years.map(({ year, baselinePrice }) => [year, baselinePrice])).toEqual([
[2023, 50],
[2024, 100],
]);
expect(years[1].baselineDate).toBe('2024-01-02');
expect(years[1].points.map(({ percentage }) => percentage)).toEqual([0, 25]);
expect(years[1].points[0].chartDate).toBe('2000-01-02');
});
it('должен рассчитывать отрицательную доходность', () => {
const [year] = buildSeasonality([
{ date: '2024-01-01', close: 100 },
{ date: '2024-01-02', close: 75 },
]);
expect(year.points[1].percentage).toBe(-25);
});
it.each([0, -10])('должен сохранять цены без процентов при неположительной базе %s', (close) => {
const [year] = buildSeasonality([
{ date: '2024-01-01', close },
{ date: '2024-01-02', close: 10 },
]);
expect(year.points.map((point) => point.percentage)).toEqual([null, null]);
expect(year.points[1].price).toBe(10);
});
it('должен отклонять повторяющиеся даты независимо от порядка данных', () => {
expect(() =>
buildSeasonality([
{ date: '2024-01-01', close: 100 },
{ date: '2023-01-01', close: 90 },
{ date: '2024-01-01', close: 101 },
]),
).toThrow('Multiple daily candles');
});
it('должен отклонять переполнение при расчёте доходности', () => {
expect(() =>
buildSeasonality([
{ date: '2024-01-01', close: Number.MIN_VALUE },
{ date: '2024-01-02', close: Number.MAX_VALUE },
]),
).toThrow('Invalid percentage');
});
});
describe('toSeriesData', () => {
it('должен совмещать годы по високосному календарю и сохранять пропуски торговых дней', () => {
const [year] = buildSeasonality([
{ date: '2023-02-28', close: 100 },
{ date: '2023-03-01', close: 110 },
]);
const data = toSeriesData(year, PriceScaleMode.Normal);
expect(data).toHaveLength(366);
expect(data[0]).toEqual({ time: '2000-01-01' });
expect(data[data.length - 1]).toEqual({ time: '2000-12-31' });
expect(data.find((point) => point.time === '2000-02-28')).toEqual({
time: '2000-02-28',
value: 100,
});
expect(data.find((point) => point.time === '2000-02-29')).toEqual({
time: '2000-02-29',
});
expect(data.find((point) => point.time === '2000-03-01')).toEqual({
time: '2000-03-01',
value: 110,
});
});
it('должен сохранять 29 февраля и рассчитывать процентные значения', () => {
const [year] = buildSeasonality([
{ date: '2024-02-29', close: 10 },
{ date: '2024-03-01', close: 15 },
]);
const data = toSeriesData(year, PriceScaleMode.Percentage);
expect(data.find((point) => point.time === '2000-02-29')).toEqual({
time: '2000-02-29',
value: 0,
});
expect(data.find((point) => point.time === '2000-03-01')).toEqual({
time: '2000-03-01',
value: 50,
});
});
it('должен оставлять пропуски вместо процентов при отсутствии положительной базы', () => {
const [year] = buildSeasonality([{ date: '2024-01-01', close: 0 }]);
expect(toSeriesData(year, PriceScaleMode.Percentage)[0]).toEqual({
time: '2000-01-01',
});
expect(toSeriesData(year, PriceScaleMode.Normal)[0]).toEqual({
time: '2000-01-01',
value: 0,
});
});
});
import styles from './index.module.scss';
import type { Key, ReactNode } from 'react';
export interface TableColumn<Row> {
id: string;
header: ReactNode;
renderCell: (row: Row) => ReactNode;
align?: 'left' | 'center' | 'right';
rowHeader?: boolean;
className?: string;
getCellClassName?: (row: Row) => string | undefined;
}
export interface TableProps<Row> {
columns: readonly TableColumn<Row>[];
rows: readonly Row[];
getRowKey: (row: Row) => Key;
className?: string;
stickyHeader?: boolean;
stickyFirstColumn?: boolean;
emptyContent?: ReactNode;
}
export function Table<Row>({
columns,
rows,
getRowKey,
className,
stickyHeader = true,
stickyFirstColumn = false,
emptyContent = '—',
}: TableProps<Row>) {
const wrapperClassName = [
styles.table,
stickyHeader && styles.table_stickyHeader,
stickyFirstColumn && styles.table_stickyFirstColumn,
className,
]
.filter(Boolean)
.join(' ');
const columnClassNames = columns.map((column) =>
[styles.table_cell, styles[`table_cell_${column.align ?? 'left'}`], column.className].filter(Boolean).join(' '),
);
return (
<div className={wrapperClassName}>
<table className={styles.table_content}>
<thead>
<tr>
{columns.map((column, index) => (
<th
key={column.id}
scope="col"
className={columnClassNames[index]}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={getRowKey(row)}>
{columns.map((column, index) => {
const Cell = column.rowHeader ? 'th' : 'td';
const cellClassName = [columnClassNames[index], column.getCellClassName?.(row)]
.filter(Boolean)
.join(' ');
return (
<Cell
key={column.id}
scope={column.rowHeader ? 'row' : undefined}
className={cellClassName}
>
{column.renderCell(row)}
</Cell>
);
})}
</tr>
))}
{rows.length === 0 && (
<tr>
<td
colSpan={columns.length}
className={styles.table_empty}
>
{emptyContent}
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}
import { BehaviorSubject, combineLatest, type Observable, Subscription } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
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 { SeasonalityControls } from '@src/components/SeasonalityControls';
import { SeasonalityTable } from '@src/components/SeasonalityTable';
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 { SeasonalityManager } from '@src/core/SeasonalityManager';
import { configureThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { Locale, setLocale, t } from '@src/translations';
import {
Candle,
ChartSeriesType,
ChartViewType,
OHLCConfig,
SeasonalityRange,
SeasonalityViewType,
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;
showSnapshotButton?: boolean;
showSeasonalityButton?: 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'];
getSeasonalityDataSource?: 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;
}
export class MoexChart implements ISerializable<MoexChartSnapshot> {
private chart!: Chart;
private seasonalityManager!: SeasonalityManager;
private chartView!: BehaviorSubject<ChartViewType>;
private canCopyImageObs!: Observable<boolean>;
private seasonalityLayout!: SeasonalityLayout;
private resizeObserver?: ResizeObserver;
private eventManager!: EventManager;
private hotkeys!: Hotkeys;
private rootContainer!: HTMLElement;
private seasonalityControlsRenderer!: UIRenderer;
private seasonalityTableRenderer!: UIRenderer;
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.subscriptions = new Subscription();
this.chartView = new BehaviorSubject<ChartViewType>(ChartViewType.Chart);
this.toolbarRenderer = undefined;
this.controlBarRenderer = undefined;
this.footerRenderer = undefined;
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: symbolId ?? '',
symbol,
symbolName,
},
initialTimeFormat: timeFormat,
initialDateFormat: dateFormat,
initialInterval: interval,
});
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,
chartContainer,
seasonalityContainer,
seasonalityControlsContainer,
seasonalityPlotContainer,
seasonalityTableContainer,
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(this.rootContainer, () => this.chartView.value === ChartViewType.Chart);
this.registerUndoRedoHotkeys(config.chartCollectionPreset.undoRedoEnabled);
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,
},
});
this.seasonalityLayout = {
rootContainer: this.rootContainer,
chartContainer,
chartAreaContainer,
footerContainer,
seasonalityContainer,
seasonalityPlotContainer,
seasonalityTableContainer,
chartGridTemplateRows: this.rootContainer.style.gridTemplateRows,
};
this.seasonalityManager = new SeasonalityManager({
container: seasonalityPlotContainer,
getData: config.chartCollectionPreset.getSeasonalityDataSource ?? config.chartCollectionPreset.getDataSource,
});
this.canCopyImageObs = combineLatest([this.chartView, this.seasonalityManager.getState()]).pipe(
map(
([view, state]) =>
view === ChartViewType.Chart || (state.status === 'ready' && state.displayMode === SeasonalityViewType.Chart),
),
distinctUntilChanged(),
);
this.subscriptions.add(
this.seasonalityManager
.getState()
.pipe(
map((state) => state.displayMode),
distinctUntilChanged(),
)
.subscribe((displayMode) => setSeasonalityDisplayMode(this.seasonalityLayout, displayMode)),
);
this.seasonalityControlsRenderer = new ReactRenderer(seasonalityControlsContainer);
this.seasonalityTableRenderer = new ReactRenderer(seasonalityTableContainer);
this.seasonalityControlsRenderer.renderComponent(
<SeasonalityControls
stateObs={this.seasonalityManager.getState()}
setDisplayMode={this.seasonalityManager.setDisplayMode}
setScale={this.seasonalityManager.setScale}
setRange={this.seasonalityManager.setRange}
/>,
);
this.seasonalityTableRenderer.renderComponent(<SeasonalityTable stateObs={this.seasonalityManager.getState()} />);
this.subscriptions.add(
this.hotkeys.register({
keys: [Keys.escape],
enabled: () => this.chartView.value === ChartViewType.Seasonality && modalContainer.hidden,
callback: this.backToChart,
}),
);
this.subscriptions.add(this.eventManager.symbolId().subscribe(this.backToChart));
this.subscriptions.add(
combineLatest([store.theme$, store.mode$]).subscribe(([theme, mode]) => {
this.chart.updateTheme(theme, mode);
this.seasonalityManager.updateTheme();
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.createRenderers(config.chartCollectionPreset, {
headerContainer,
toolBarContainer,
drawingToolbarContainer,
controlBarContainer,
footerContainer,
});
this.timeScaleHoverController = new TimeScaleHoverController({
eventManager: this.eventManager,
controlBarContainer,
chartContainer: chartAreaContainer,
});
this.renderAttachments(config, toggleToolbar);
};
private registerUndoRedoHotkeys(enabled = false): void {
if (!enabled) return;
const undoRedo = this.eventManager.getUndoRedo();
this.hotkeys.register({ keys: [Keys.mod, Keys.z], callback: undoRedo.undo });
this.hotkeys.register({ keys: [Keys.mod, Keys.y], callback: undoRedo.redo });
this.hotkeys.register({ keys: [Keys.mod, Keys.shift, Keys.z], callback: undoRedo.redo });
}
private createRenderers(
preset: ChartCollectionPreset,
containers: Pick<
ReturnType<typeof ContainerManager.createContainers>,
'headerContainer' | 'toolBarContainer' | 'drawingToolbarContainer' | 'controlBarContainer' | 'footerContainer'
>,
): void {
const { headerContainer, toolBarContainer, drawingToolbarContainer, controlBarContainer, footerContainer } =
containers;
this.headerRenderer = new ReactRenderer(headerContainer);
this.toolbarRenderer = new ReactRenderer(toolBarContainer);
this.drawingToolbarRenderer = new ReactRenderer(drawingToolbarContainer);
if (preset.showControlBar) {
this.controlBarRenderer = new ReactRenderer(controlBarContainer);
}
if (preset.showBottomPanel) {
this.footerRenderer = new ReactRenderer(footerContainer);
}
}
public setSettings(settings: ChartSettingsSource): void {
this.backToChart();
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.backToChart();
this.eventManager.setSymbol(symbolInfo);
}
public clearSymbol(): void {
this.backToChart();
this.eventManager.clearSymbol();
}
public showSeasonality(range?: SeasonalityRange): Promise<void> {
const { symbolInfo } = this.eventManager.exportChartSettings();
if (this.chartView.value !== ChartViewType.Seasonality) {
this.chart.setSleeping(true);
setSeasonalityVisible(this.seasonalityLayout, true);
this.chartView.next(ChartViewType.Seasonality);
this.seasonalityLayout.seasonalityContainer.focus({ preventScroll: true });
}
return this.seasonalityManager.show(symbolInfo.symbolId, range);
}
public backToChart = (): void => {
if (this.chartView.value === ChartViewType.Chart) {
return;
}
this.seasonalityManager.clear();
setSeasonalityVisible(this.seasonalityLayout, false);
this.chart.setSleeping(false);
this.chartView.next(ChartViewType.Chart);
this.seasonalityLayout.chartAreaContainer.focus({ preventScroll: true });
};
private setChartView = (view: ChartViewType): void => {
if (view === ChartViewType.Chart) {
this.backToChart();
return;
}
this.showSeasonality();
};
private renderHeader(config: IMoexChart, toggleToolbar: () => boolean): void {
this.headerRenderer.renderComponent(
<Header
chartViewObs={this.chartView}
canCopyImageObs={this.canCopyImageObs}
setChartView={config.chartCollectionPreset.showSeasonalityButton ? this.setChartView : undefined}
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 })
}
copyImage={
config.chartCollectionPreset.showSnapshotButton
? () =>
this.chartView.value === ChartViewType.Seasonality
? this.seasonalityManager.copyImage()
: this.chart.copyImage()
: undefined
}
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'}
/>,
);
}
private renderAttachments(config: IMoexChart, toggleToolbar: () => boolean) {
const drawingsCollectionManager = this.chart.getDrawingsCollectionManager();
this.drawingToolbarRenderer.renderComponent(
// этот тулбар к drawingManger бы прибить, сильно на него завязан
<FloatingDrawingToolbar
selectedDrawing$={drawingsCollectionManager.selectedDrawing()}
onUpdateSettings={drawingsCollectionManager.updateSelectedDrawingSettings}
onToggleLock={() => drawingsCollectionManager.toggleSelectedDrawingLock()}
onOpenSettings={() => drawingsCollectionManager.openSelectedDrawingSettings()}
onDelete={() => drawingsCollectionManager.deleteSelectedDrawing()}
/>,
);
this.renderHeader(config, toggleToolbar);
if (this.toolbarRenderer && config.chartCollectionPreset.showMenuButton) {
const drawingCollection = this.chart.getDrawingsCollectionManager();
this.toolbarRenderer.renderComponent(
<Toolbar
toggleDOM={this.chart.getDom().toggleDOM}
addDrawing={drawingCollection.activateDrawingTool}
setEndlessDrawingsMode={drawingCollection.setEndlessDrawingMode}
isEndlessDrawingsMode$={drawingCollection.isEndlessDrawingsMode()}
activateCrosshair={() => drawingCollection.activateCrosshair()}
activeTool$={drawingCollection.getActiveTool()}
/>,
);
}
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.seasonalityControlsRenderer.destroy();
this.seasonalityTableRenderer.destroy();
this.headerRenderer.destroy();
this.seasonalityManager.destroy();
this.drawingToolbarRenderer.destroy();
this.subscriptions.unsubscribe();
this.chartView.complete();
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();
}
this.hotkeys.destroy();
if (this.eventManager) {
this.eventManager.destroy();
}
if (this.toolbarRenderer) {
this.toolbarRenderer.destroy();
}
this.dataSource.destroy();
ContainerManager.clearContainers(this.rootContainer);
}
}
interface SeasonalityLayout {
rootContainer: HTMLElement;
chartContainer: HTMLElement;
chartAreaContainer: HTMLElement;
footerContainer: HTMLElement;
seasonalityContainer: HTMLElement;
seasonalityPlotContainer: HTMLElement;
seasonalityTableContainer: HTMLElement;
chartGridTemplateRows: string;
}
function setSeasonalityVisible(layout: SeasonalityLayout, visible: boolean): void {
Object.assign(layout.rootContainer.style, {
gridTemplateRows: visible ? 'auto minmax(0, 1fr)' : layout.chartGridTemplateRows,
});
Object.assign(layout.chartContainer.style, {
visibility: visible ? 'hidden' : 'visible',
});
layout.chartContainer.inert = visible;
Object.assign(layout.footerContainer.style, {
display: visible ? 'none' : '',
});
Object.assign(layout.seasonalityContainer.style, {
visibility: visible ? 'visible' : 'hidden',
pointerEvents: visible ? 'auto' : 'none',
});
layout.seasonalityContainer.inert = !visible;
}
function setSeasonalityDisplayMode(layout: SeasonalityLayout, displayMode: SeasonalityViewType): void {
const isTable = displayMode === SeasonalityViewType.Table;
Object.assign(layout.seasonalityPlotContainer.style, {
visibility: isTable ? 'hidden' : '',
pointerEvents: isTable ? 'none' : '',
});
layout.seasonalityPlotContainer.inert = isTable;
layout.seasonalityTableContainer.hidden = !isTable;
layout.seasonalityTableContainer.inert = !isTable;
}
import dayjs from 'dayjs';
import {
ColorType,
createChart,
createSeriesMarkers,
CrosshairMode,
type IChartApi,
type ISeriesApi,
type ISeriesMarkersPluginApi,
LineSeries,
LineStyle,
type MouseEventParams,
PriceScaleMode,
type Time,
} from 'lightweight-charts';
import { BehaviorSubject } from 'rxjs';
import { createInitialSeasonalityState, DEFAULT_SEASONALITY_YEARS } from '@src/constants';
import { ZIndex } from '@src/core/ContainerManager';
import { COMPARE_COLOR_PALETTE } from '@src/theme';
import { getThemeStore } from '@src/theme/store';
import { getLocale, t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import {
buildSeasonality,
buildSeasonalityTable,
canvasToBlob,
drawTextLayer,
formatCompactNumber,
formatPercent,
getPercentPrecisionStep,
getPricePrecisionStep,
normalizeRange,
toSeriesData,
} from '@src/utils';
import type { DataSourceParams } from '@src/core/DataSource';
import type {
Candle,
DailyClose,
SeasonalityRange,
SeasonalityScale,
SeasonalityTableRow,
SeasonalityYear,
} from '@src/types';
interface SeasonalityManagerParams {
container: HTMLElement;
getData: DataSourceParams['getData'];
}
interface SeasonalityPlot {
year: SeasonalityYear;
series: ISeriesApi<'Line'>;
legend: HTMLSpanElement;
legendValue: HTMLSpanElement;
markers?: ISeriesMarkersPluginApi<Time>;
}
export class SeasonalityManager {
private readonly currentYear = dayjs().year();
private readonly state = new BehaviorSubject(createInitialSeasonalityState());
private readonly chartContainer: HTMLDivElement;
private readonly legend: HTMLDivElement;
private readonly statusElement: HTMLDivElement;
private readonly plots = new Map<number, SeasonalityPlot>();
private chart: IChartApi | null = null;
private allYears: SeasonalityYear[] = [];
private tableRows: SeasonalityTableRow[] = [];
private requestId = 0;
private renderFrame: number | null = null;
private destroyed = false;
constructor(private readonly params: SeasonalityManagerParams) {
const { chartContainer, legend, statusElement } = createSeasonalityElements(params.container);
this.chartContainer = chartContainer;
this.legend = legend;
this.statusElement = statusElement;
this.updateTheme();
}
public getState() {
return this.state.asObservable();
}
public async show(symbolId: string, range?: SeasonalityRange): Promise<void> {
if (this.destroyed || this.reuseHistory(symbolId, range)) {
return;
}
const requestId = ++this.requestId;
this.reset();
this.showStatus(t('Loading seasonality'));
this.state.next({
...this.state.value,
symbolId,
status: 'loading',
tableRows: [],
error: null,
});
try {
if (!symbolId) {
throw new Error('Select an instrument');
}
const rows = await this.loadHistory(symbolId, requestId);
if (!this.isCurrent(requestId)) {
return;
}
this.allYears = buildSeasonality(rows);
this.tableRows = buildSeasonalityTable(this.allYears);
this.setInitialRange(range);
} catch (error) {
if (!this.isCurrent(requestId)) {
return;
}
this.reset();
this.showStatus(t('Unable to load seasonality'));
this.state.next({
...this.state.value,
status: 'error',
tableRows: [],
error: String(error),
});
console.error('[Seasonality] History loading failed:', error);
}
}
public setRange = (range: SeasonalityRange): void => {
if (this.destroyed || this.allYears.length === 0) {
return;
}
const state = this.state.value;
const next = normalizeRange(range, state.minYear, state.maxYear);
if (next.fromYear === state.fromYear && next.toYear === state.toYear) {
return;
}
this.state.next({ ...state, ...next });
if (this.renderFrame !== null) {
return;
}
this.renderFrame = requestAnimationFrame(() => {
this.renderFrame = null;
if (!this.destroyed && this.allYears.length > 0) {
this.renderRange();
}
});
};
public setDisplayMode = (displayMode: SeasonalityViewType): void => {
if (this.destroyed || this.state.value.displayMode === displayMode) {
return;
}
this.state.next({ ...this.state.value, displayMode });
};
public setScale = (scale: SeasonalityScale): void => {
if (this.destroyed || this.state.value.scale === scale) {
return;
}
this.state.next({ ...this.state.value, scale });
if (!this.chart) {
return;
}
this.plots.forEach((plot) => this.updateSeries(plot));
this.fitCalendar();
this.updateStatus();
this.updateLegend();
};
public clear = (): void => {
if (this.destroyed) {
return;
}
this.requestId += 1;
this.reset();
this.state.next({
...this.state.value,
status: 'idle',
tableRows: [],
error: null,
});
};
public updateTheme(): void {
const { colors } = getThemeStore();
Object.assign(this.params.container.style, {
backgroundColor: colors.chartBackground,
});
Object.assign(this.statusElement.style, {
color: colors.chartTextPrimary,
});
this.legend.style.color = colors.chartTextPrimary;
this.chart?.applyOptions(getThemeOptions());
}
public async copyImage(): Promise<void> {
if (
!this.chart ||
this.state.value.status !== 'ready' ||
this.state.value.displayMode !== SeasonalityViewType.Chart
) {
return;
}
const canvas = this.chart.takeScreenshot(true, false);
drawTextLayer(canvas, this.legend, this.chart.chartElement());
const blob = await canvasToBlob(canvas);
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
}
public destroy(): void {
if (this.destroyed) {
return;
}
this.destroyed = true;
this.requestId += 1;
this.reset();
this.chartContainer.remove();
this.legend.remove();
this.statusElement.remove();
this.state.complete();
}
private isCurrent(requestId: number): boolean {
return !this.destroyed && requestId === this.requestId;
}
private reuseHistory(symbolId: string, range?: SeasonalityRange): boolean {
const state = this.state.value;
if (state.symbolId !== symbolId) {
return false;
}
if (state.status === 'loading') {
return true;
}
if (this.allYears.length === 0) {
return false;
}
if (range) {
this.setRange(range);
}
return true;
}
private async loadHistory(symbolId: string, requestId: number): Promise<DailyClose[]> {
const candles = new Map<number, Candle>();
const loadPage = async (cursor?: Candle): Promise<void> => {
if (!this.isCurrent(requestId)) {
return;
}
const loaded = await this.params.getData(Timeframes['1d'], symbolId, cursor);
if (!this.isCurrent(requestId) || !loaded?.length) {
return;
}
const { oldest, newestTime } = mergeHistoryPage(loaded, candles);
if (cursor && oldest.time >= cursor.time) {
const isBoundaryOnly = oldest.time === cursor.time && newestTime === cursor.time;
if (isBoundaryOnly) {
return;
}
throw new Error(
`History pagination made no progress: cursor=${cursor.time}, ` +
`oldest=${oldest.time}, newest=${newestTime}, count=${loaded.length}`,
);
}
return loadPage(oldest);
};
await loadPage();
if (!this.isCurrent(requestId)) {
return [];
}
return Array.from(candles.values(), (candle) => ({
date: dayjs.unix(candle.time).format('YYYY-MM-DD'),
close: candle.close,
}));
}
private setInitialRange(range?: SeasonalityRange): void {
if (this.allYears.length === 0) {
this.showStatus(t('No seasonality data'));
this.state.next({
...this.state.value,
status: 'empty',
});
return;
}
const minYear = this.allYears[0].year;
const maxYear = this.allYears[this.allYears.length - 1].year;
const selected = normalizeRange(
range ?? {
fromYear: maxYear - DEFAULT_SEASONALITY_YEARS + 1,
toYear: maxYear,
},
minYear,
maxYear,
);
this.state.next({
...this.state.value,
...selected,
minYear,
maxYear,
});
this.renderRange();
}
private createChart(): IChartApi {
const chart = createChart(this.chartContainer, {
...getThemeOptions(),
autoSize: true,
handleScroll: false,
handleScale: false,
kineticScroll: {
mouse: false,
touch: false,
},
rightPriceScale: {
mode: PriceScaleMode.Normal,
autoScale: true,
alignLabels: true,
visible: true,
borderVisible: false,
},
leftPriceScale: {
visible: false,
},
timeScale: {
timeVisible: false,
secondsVisible: false,
borderVisible: false,
allowBoldLabels: false,
rightOffset: 0,
tickMarkFormatter: formatSeasonDate,
lockVisibleTimeRangeOnResize: true,
},
localization: {
locale: getLocale(),
timeFormatter: formatSeasonDate,
},
});
chart.subscribeCrosshairMove(this.onCrosshairMove);
this.chart = chart;
return chart;
}
private renderRange(): void {
const chart = this.chart ?? this.createChart();
const { fromYear, toYear } = this.state.value;
const years = this.allYears.filter((year) => year.year >= fromYear && year.year <= toYear);
this.plots.forEach((plot, year) => {
if (year < fromYear || year > toYear) {
plot.markers?.detach();
chart.removeSeries(plot.series);
this.plots.delete(year);
}
});
const plots = years.map((year) => {
const existing = this.plots.get(year.year);
if (existing) {
return existing;
}
const plot = this.createPlot(chart, year);
this.plots.set(year.year, plot);
return plot;
});
this.legend.replaceChildren(...plots.flatMap((plot) => [plot.legend, plot.legendValue]));
if (plots.length > 0) {
this.fitCalendar();
}
this.updateStatus();
this.updateLegend();
this.state.next({
...this.state.value,
status: plots.length > 0 ? 'ready' : 'empty',
tableRows: this.tableRows.filter((row) => row.year >= fromYear && row.year <= toYear),
});
}
private fitCalendar(): void {
this.chart?.timeScale().setVisibleRange({
from: '2000-01-01',
to: '2000-12-31',
});
}
private createPlot(chart: IChartApi, year: SeasonalityYear): SeasonalityPlot {
const color = getYearColor(year.year);
const legend = document.createElement('span');
const legendValue = document.createElement('span');
legend.textContent = String(year.year);
Object.assign(legendValue.style, {
color,
textAlign: 'right',
});
const plot: SeasonalityPlot = {
year,
legend,
legendValue,
series: chart.addSeries(LineSeries, {
title: String(year.year),
color,
priceLineVisible: false,
lastValueVisible: true,
lineWidth: year.year === this.currentYear ? 3 : 1,
pointMarkersVisible: false,
crosshairMarkerVisible: true,
}),
};
if (year.year === this.currentYear) {
plot.markers = createSeriesMarkers(plot.series, [], {
autoScale: false,
});
}
this.updateSeries(plot);
return plot;
}
private updateSeries({ series, year, markers }: SeasonalityPlot): void {
const { scale } = this.state.value;
const isPercentage = scale === PriceScaleMode.Percentage;
series.applyOptions({
priceFormat: {
type: 'custom',
minMove: isPercentage ? getPercentPrecisionStep() : getPricePrecisionStep(),
formatter: (value: number) => (isPercentage ? formatPercent(value) : formatCompactNumber(value)),
},
});
series.setData(toSeriesData(year, scale));
if (markers) {
const lastPoint = year.points[year.points.length - 1];
const value = isPercentage ? lastPoint.percentage : lastPoint.price;
markers.setMarkers(
value === null
? []
: [
{
time: lastPoint.chartDate,
position: 'inBar',
shape: 'circle',
color: getYearColor(year.year),
size: 1,
},
],
);
}
}
private updateStatus(): void {
if (this.plots.size === 0) {
this.showStatus(t('No seasonality data'));
return;
}
const isPercentage = this.state.value.scale === PriceScaleMode.Percentage;
const hasBaseline = Array.from(this.plots.values()).some((plot) => plot.year.baselinePrice > 0);
if (isPercentage && !hasBaseline) {
this.showStatus(t('Percentage requires a positive baseline'));
return;
}
this.statusElement.style.display = 'none';
}
private onCrosshairMove = (event: MouseEventParams): void => {
this.updateLegend(event);
};
private updateLegend(event?: MouseEventParams): void {
const isPercentage = this.state.value.scale === PriceScaleMode.Percentage;
this.plots.forEach(({ year, series, legendValue }) => {
const lastPoint = year.points[year.points.length - 1];
let value = isPercentage ? lastPoint.percentage : lastPoint.price;
if (event?.time !== undefined) {
const point = event.seriesData.get(series);
value = point && 'value' in point && typeof point.value === 'number' ? point.value : null;
}
legendValue.textContent = value === null ? '—' : series.priceFormatter().format(value);
});
}
private showStatus(message: string): void {
this.statusElement.textContent = message;
this.statusElement.style.display = 'flex';
}
private reset(): void {
if (this.renderFrame !== null) {
cancelAnimationFrame(this.renderFrame);
this.renderFrame = null;
}
this.chart?.unsubscribeCrosshairMove(this.onCrosshairMove);
this.plots.forEach((plot) => plot.markers?.detach());
this.chart?.remove();
this.chart = null;
this.allYears = [];
this.tableRows = [];
this.plots.clear();
this.legend.replaceChildren();
this.statusElement.style.display = 'none';
}
}
function mergeHistoryPage(loaded: Candle[], candles: Map<number, Candle>): { oldest: Candle; newestTime: number } {
let oldest = loaded[0];
let oldestTime = Infinity;
let newestTime = -Infinity;
for (let index = loaded.length - 1; index >= 0; index -= 1) {
const candle = loaded[index];
if (!Number.isFinite(candle.time) || !Number.isFinite(candle.close)) {
throw new Error('Invalid daily candle');
}
const time = Math.floor(candle.time > 1e10 ? candle.time / 1000 : candle.time);
const normalized = time === candle.time ? candle : { ...candle, time };
newestTime = Math.max(newestTime, time);
if (time < oldestTime) {
oldest = normalized;
oldestTime = time;
}
if (!candles.has(time)) {
candles.set(time, normalized);
}
}
return { oldest, newestTime };
}
function getThemeOptions() {
const { colors } = getThemeStore();
return {
layout: {
background: {
type: ColorType.Solid,
color: colors.chartBackground,
},
textColor: colors.chartTextPrimary,
panes: {
separatorColor: colors.chartPaneSeparator,
separatorHoverColor: colors.chartPaneSeparator,
enableResize: false,
},
},
grid: {
vertLines: {
color: colors.chartGridLine,
},
horzLines: {
color: colors.chartGridLine,
},
},
crosshair: {
mode: CrosshairMode.Normal,
vertLine: {
color: colors.chartCrosshairLine,
labelBackgroundColor: colors.chartCrosshairLabel,
style: LineStyle.Solid,
},
horzLine: {
color: colors.chartCrosshairLine,
labelBackgroundColor: colors.chartCrosshairLabel,
style: LineStyle.Dashed,
},
},
};
}
function formatSeasonDate(time: Time): string {
if (typeof time === 'object') {
const day = String(time.day).padStart(2, '0');
const month = String(time.month).padStart(2, '0');
return `${day}.${month}`;
}
const date = typeof time === 'number' ? new Date(time * 1000).toISOString() : time;
return `${date.slice(8, 10)}.${date.slice(5, 7)}`;
}
function getYearColor(year: number): string {
return COMPARE_COLOR_PALETTE[year % COMPARE_COLOR_PALETTE.length];
}
function createSeasonalityElements(container: HTMLElement) {
const chartContainer = document.createElement('div');
const legend = document.createElement('div');
const statusElement = document.createElement('div');
Object.assign(chartContainer.style, {
position: 'absolute',
inset: 0,
});
Object.assign(legend.style, {
width: 'max-content',
position: 'absolute',
top: 0,
left: 0,
display: 'grid',
gridTemplateColumns: 'max-content max-content',
justifyContent: 'start',
alignItems: 'center',
gap: 'var(--space-0125) var(--space-0500)',
fontSize: 'var(--space-0750)',
lineHeight: 'var(--space-1000)',
fontVariantNumeric: 'tabular-nums',
pointerEvents: 'none',
zIndex: ZIndex.Legend,
});
Object.assign(statusElement.style, {
position: 'absolute',
inset: 0,
display: 'none',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
pointerEvents: 'none',
});
container.append(chartContainer, legend, statusElement);
return { chartContainer, legend, statusElement };
}
import { BehaviorSubject } from 'rxjs';
import { Keys } from '@core/Hotkeys';
import { type ChartCollectionPreset, type IMoexChart, MoexChart } from '@core/MoexChart';
import { ReactRenderer } from '@core/ReactRenderer';
import { ChartViewType } from '@src/types';
import type { SeasonalityRange } from '@src/types';
import type { ReactElement } from 'react';
import type { Observable } from 'rxjs';
interface HeaderTestProps {
copyImage?: () => Promise<void>;
setChartView?: (view: ChartViewType) => void;
chartViewObs: Observable<ChartViewType>;
canCopyImageObs: Observable<boolean>;
}
interface HeaderTestOptions {
showSnapshotButton?: boolean;
showSeasonalityButton?: boolean;
seasonalityActive?: boolean;
}
function createTestContext() {
const drawingsCollectionManager = {
selectedDrawing: jest.fn(),
updateSelectedDrawingSettings: jest.fn(),
toggleSelectedDrawingLock: jest.fn(),
openSelectedDrawingSettings: jest.fn(),
deleteSelectedDrawing: jest.fn(),
};
const mainChart = {
copyImage: jest.fn().mockResolvedValue(undefined),
setSleeping: jest.fn(),
getDrawingsCollectionManager: () => drawingsCollectionManager,
};
const seasonalityManager = {
clear: jest.fn(),
show: jest.fn().mockResolvedValue(undefined),
copyImage: jest.fn().mockResolvedValue(undefined),
};
const eventManager = {
exportChartSettings: jest.fn(() => ({
symbolInfo: {
symbolId: 'SBER',
},
})),
importChartSettings: jest.fn(),
clearSymbol: jest.fn(),
setSymbol: jest.fn(),
getTimeframeObs: jest.fn(),
setTimeframe: jest.fn(),
getSelectedSeries: jest.fn(),
setSeriesSelected: jest.fn(),
};
const headerRenderer = {
renderComponent: jest.fn(),
};
const setChartView = jest.fn();
const backToChart = jest.fn();
const chartView = new BehaviorSubject(ChartViewType.Chart);
const canCopyImageObs = new BehaviorSubject(true);
const rootContainer = document.createElement('div');
const chartContainer = document.createElement('div');
const chartAreaContainer = document.createElement('div');
const footerContainer = document.createElement('div');
const seasonalityContainer = document.createElement('div');
rootContainer.style.gridTemplateRows = '36px minmax(0, 1fr) 32px';
seasonalityContainer.style.visibility = 'hidden';
seasonalityContainer.tabIndex = -1;
rootContainer.append(chartContainer, footerContainer, seasonalityContainer);
chartContainer.append(chartAreaContainer);
document.body.append(rootContainer);
const seasonalityLayout = {
rootContainer,
chartContainer,
chartAreaContainer,
footerContainer,
seasonalityContainer,
chartGridTemplateRows: rootContainer.style.gridTemplateRows,
};
const chart = Object.assign(Object.create(MoexChart.prototype), {
chart: mainChart,
chartView,
canCopyImageObs,
seasonalityLayout,
backToChart,
seasonalityManager,
eventManager,
headerRenderer,
drawingToolbarRenderer: {
renderComponent: jest.fn(),
},
toolbarRenderer: undefined,
controlBarRenderer: undefined,
footerRenderer: undefined,
modalRenderer: {},
fullscreen: {},
setChartView,
}) as MoexChart;
return {
chart,
mainChart,
seasonalityManager,
eventManager,
headerRenderer,
setChartView,
chartView,
canCopyImageObs,
seasonalityLayout,
backToChart,
};
}
function renderHeader({
showSnapshotButton,
showSeasonalityButton,
seasonalityActive = false,
}: HeaderTestOptions = {}) {
const context = createTestContext();
context.chartView.next(seasonalityActive ? ChartViewType.Seasonality : ChartViewType.Chart);
const config = {
chartCollectionPreset: {
supportedTimeframes: [],
supportedChartSeriesTypes: [],
showSettingsButton: false,
showMenuButton: false,
showFullscreenButton: false,
undoRedoEnabled: false,
showCompareButton: false,
showSnapshotButton,
showSeasonalityButton,
theme: 'tr',
},
} as unknown as IMoexChart;
const internals = context.chart as unknown as {
renderAttachments: (config: IMoexChart, toggleToolbar: () => boolean) => void;
};
internals.renderAttachments(
config,
jest.fn(() => false),
);
const header = context.headerRenderer.renderComponent.mock.calls[0][0] as ReactElement<HeaderTestProps>;
return { ...context, header };
}
afterEach(() => {
document.body.replaceChildren();
jest.restoreAllMocks();
});
describe('MoexChart', () => {
it('должен выйти из сезонности перед очисткой основного инструмента', () => {
const { chart, eventManager, backToChart } = createTestContext();
chart.clearSymbol();
expect(backToChart).toHaveBeenCalledTimes(1);
expect(eventManager.clearSymbol).toHaveBeenCalledTimes(1);
expect(backToChart.mock.invocationCallOrder[0]).toBeLessThan(eventManager.clearSymbol.mock.invocationCallOrder[0]);
});
it('должен выйти из сезонности перед установкой основного инструмента', () => {
const { chart, eventManager, backToChart } = createTestContext();
const symbol = {
symbolId: 'SBER',
symbol: 'SBER',
symbolName: 'Sberbank',
};
chart.setSymbol(symbol);
expect(backToChart).toHaveBeenCalledTimes(1);
expect(eventManager.setSymbol).toHaveBeenCalledTimes(1);
expect(eventManager.setSymbol).toHaveBeenCalledWith(symbol);
expect(backToChart.mock.invocationCallOrder[0]).toBeLessThan(eventManager.setSymbol.mock.invocationCallOrder[0]);
});
it('должен копировать основной график, если сезонность неактивна', async () => {
const { header, mainChart, seasonalityManager } = renderHeader({
showSnapshotButton: true,
});
expect(header.props.copyImage).toEqual(expect.any(Function));
await header.props.copyImage?.();
expect(mainChart.copyImage).toHaveBeenCalledTimes(1);
expect(seasonalityManager.copyImage).not.toHaveBeenCalled();
});
it('должен копировать сезонность, если она активна', async () => {
const { header, mainChart, seasonalityManager } = renderHeader({
showSnapshotButton: true,
seasonalityActive: true,
});
expect(header.props.copyImage).toEqual(expect.any(Function));
await header.props.copyImage?.();
expect(seasonalityManager.copyImage).toHaveBeenCalledTimes(1);
expect(mainChart.copyImage).not.toHaveBeenCalled();
});
it.each([undefined, false])('не должен передавать copyImage при showSnapshotButton=%s', (showSnapshotButton) => {
const { header, mainChart, seasonalityManager } = renderHeader({
showSnapshotButton,
});
expect(header.props.copyImage).toBeUndefined();
expect(mainChart.copyImage).not.toHaveBeenCalled();
expect(seasonalityManager.copyImage).not.toHaveBeenCalled();
});
it('должен передавать переключение вида при включённой сезонности', () => {
const { header, setChartView } = renderHeader({
showSeasonalityButton: true,
});
expect(header.props.setChartView).toBe(setChartView);
});
it.each([undefined, false])(
'не должен передавать переключение вида при showSeasonalityButton=%s',
(showSeasonalityButton) => {
const { header } = renderHeader({ showSeasonalityButton });
expect(header.props.setChartView).toBeUndefined();
},
);
it('должен передавать потоки текущего вида и доступности снимка в Header', () => {
const { header, chartView, canCopyImageObs } = renderHeader();
expect(header.props.chartViewObs).toBe(chartView);
expect(header.props.canCopyImageObs).toBe(canCopyImageObs);
});
it('должен выбирать источник снимка по текущему режиму в момент нажатия', async () => {
const { header, chartView, mainChart, seasonalityManager } = renderHeader({
showSnapshotButton: true,
});
chartView.next(ChartViewType.Seasonality);
await header.props.copyImage?.();
chartView.next(ChartViewType.Chart);
await header.props.copyImage?.();
expect(seasonalityManager.copyImage).toHaveBeenCalledTimes(1);
expect(mainChart.copyImage).toHaveBeenCalledTimes(1);
});
it('должен выйти из сезонности перед применением настроек', () => {
const { chart, eventManager, backToChart } = createTestContext();
const settings = {} as Parameters<MoexChart['setSettings']>[0];
chart.setSettings(settings);
expect(backToChart).toHaveBeenCalledTimes(1);
expect(eventManager.importChartSettings).toHaveBeenCalledWith(settings);
expect(backToChart.mock.invocationCallOrder[0]).toBeLessThan(
eventManager.importChartSettings.mock.invocationCallOrder[0],
);
});
it('должен скрывать основной график и футер перед загрузкой сезонности', async () => {
const { chart, chartView, seasonalityManager, mainChart, seasonalityLayout } = createTestContext();
const range: SeasonalityRange = {
fromYear: 2021,
toYear: 2025,
};
const focus = jest.spyOn(seasonalityLayout.seasonalityContainer, 'focus');
await chart.showSeasonality(range);
expect(mainChart.setSleeping).toHaveBeenCalledWith(true);
expect(chartView.value).toBe(ChartViewType.Seasonality);
expect(seasonalityLayout.rootContainer.style.gridTemplateRows).toBe('auto minmax(0, 1fr)');
expect(seasonalityLayout.chartContainer.style.visibility).toBe('hidden');
expect(seasonalityLayout.chartContainer.inert).toBe(true);
expect(seasonalityLayout.footerContainer.style.display).toBe('none');
expect(seasonalityLayout.seasonalityContainer.style.visibility).toBe('visible');
expect(seasonalityLayout.seasonalityContainer.style.pointerEvents).toBe('auto');
expect(seasonalityLayout.seasonalityContainer.inert).toBe(false);
expect(focus).toHaveBeenCalledTimes(1);
expect(seasonalityManager.show).toHaveBeenCalledWith('SBER', range);
expect(mainChart.setSleeping.mock.invocationCallOrder[0]).toBeLessThan(
seasonalityManager.show.mock.invocationCallOrder[0],
);
});
it('не должен повторно переводить график в сон при обновлении диапазона сезонности', async () => {
const { chart, mainChart, seasonalityManager, seasonalityLayout } = createTestContext();
const focus = jest.spyOn(seasonalityLayout.seasonalityContainer, 'focus');
const range: SeasonalityRange = {
fromYear: 2023,
toYear: 2025,
};
await chart.showSeasonality();
await chart.showSeasonality(range);
expect(mainChart.setSleeping).toHaveBeenCalledTimes(1);
expect(focus).toHaveBeenCalledTimes(1);
expect(seasonalityManager.show).toHaveBeenLastCalledWith('SBER', range);
});
});
jest.mock('@core/ReactRenderer', () => ({
ReactRenderer: jest.fn(() => ({
renderComponent: jest.fn(),
destroy: jest.fn(),
})),
}));
interface RendererContainers {
headerContainer: HTMLElement;
toolBarContainer: HTMLElement;
drawingToolbarContainer: HTMLElement;
controlBarContainer: HTMLElement;
footerContainer: HTMLElement;
}
interface ChartSetupInternals {
registerUndoRedoHotkeys: (enabled?: boolean) => void;
createRenderers: (preset: ChartCollectionPreset, containers: RendererContainers) => void;
headerRenderer?: object;
toolbarRenderer?: object;
drawingToolbarRenderer?: object;
controlBarRenderer?: object;
footerRenderer?: object;
}
function createSetupContext() {
const register = jest.fn();
const undo = jest.fn();
const redo = jest.fn();
const getUndoRedo = jest.fn(() => ({ undo, redo }));
const containers: RendererContainers = {
headerContainer: document.createElement('div'),
toolBarContainer: document.createElement('div'),
drawingToolbarContainer: document.createElement('div'),
controlBarContainer: document.createElement('div'),
footerContainer: document.createElement('div'),
};
const chart = Object.assign(Object.create(MoexChart.prototype), {
hotkeys: { register },
eventManager: { getUndoRedo },
headerRenderer: undefined,
toolbarRenderer: undefined,
drawingToolbarRenderer: undefined,
controlBarRenderer: undefined,
footerRenderer: undefined,
}) as ChartSetupInternals;
return { chart, containers, register, getUndoRedo, undo, redo };
}
describe('MoexChart: настройка интерфейса', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it.each([undefined, false])('не должен регистрировать историю действий при enabled=%s', (enabled) => {
const { chart, register, getUndoRedo } = createSetupContext();
chart.registerUndoRedoHotkeys(enabled);
expect(getUndoRedo).not.toHaveBeenCalled();
expect(register).not.toHaveBeenCalled();
});
it('должен регистрировать undo и оба варианта redo', () => {
const { chart, register, undo, redo } = createSetupContext();
chart.registerUndoRedoHotkeys(true);
expect(register).toHaveBeenCalledTimes(3);
expect(register).toHaveBeenNthCalledWith(1, {
keys: [Keys.mod, Keys.z],
callback: undo,
});
expect(register).toHaveBeenNthCalledWith(2, {
keys: [Keys.mod, Keys.y],
callback: redo,
});
expect(register).toHaveBeenNthCalledWith(3, {
keys: [Keys.mod, Keys.shift, Keys.z],
callback: redo,
});
});
it('должен создавать только обязательные рендереры при выключенных панелях', () => {
const { chart, containers } = createSetupContext();
chart.createRenderers({} as ChartCollectionPreset, containers);
expect(ReactRenderer).toHaveBeenCalledTimes(3);
expect(ReactRenderer).toHaveBeenNthCalledWith(1, containers.headerContainer);
expect(ReactRenderer).toHaveBeenNthCalledWith(2, containers.toolBarContainer);
expect(ReactRenderer).toHaveBeenNthCalledWith(3, containers.drawingToolbarContainer);
expect(chart.headerRenderer).toBeDefined();
expect(chart.toolbarRenderer).toBeDefined();
expect(chart.drawingToolbarRenderer).toBeDefined();
expect(chart.controlBarRenderer).toBeUndefined();
expect(chart.footerRenderer).toBeUndefined();
});
it('должен создавать обе дополнительные панели при включённых флагах', () => {
const { chart, containers } = createSetupContext();
chart.createRenderers(
{
showControlBar: true,
showBottomPanel: true,
} as ChartCollectionPreset,
containers,
);
expect(ReactRenderer).toHaveBeenCalledTimes(5);
expect(ReactRenderer).toHaveBeenCalledWith(containers.controlBarContainer);
expect(ReactRenderer).toHaveBeenCalledWith(containers.footerContainer);
expect(chart.controlBarRenderer).toBeDefined();
expect(chart.footerRenderer).toBeDefined();
});
it.each([
{ showControlBar: true, showBottomPanel: false },
{ showControlBar: false, showBottomPanel: true },
])('должен независимо учитывать флаги панелей %j', (preset) => {
const { chart, containers } = createSetupContext();
chart.createRenderers(preset as ChartCollectionPreset, containers);
expect(ReactRenderer).toHaveBeenCalledTimes(4);
expect(Boolean(chart.controlBarRenderer)).toBe(preset.showControlBar);
expect(Boolean(chart.footerRenderer)).toBe(preset.showBottomPanel);
});
});
import { createChart, createSeriesMarkers, PriceScaleMode } from 'lightweight-charts';
import { SeasonalityManager } from '@core/SeasonalityManager';
import { createInitialSeasonalityState } from '@src/constants';
import { getThemeStore } from '@src/theme/store';
import { SeasonalityViewType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { canvasToBlob, drawTextLayer } from '@src/utils';
import { setPricePrecision } from '@src/utils/precision';
import type { Candle } from '@src/types';
import type { ChartOptions, DeepPartial, LineData, LineSeriesPartialOptions, WhitespaceData } from 'lightweight-charts';
interface SeriesTestContext {
options: LineSeriesPartialOptions;
applyOptions: jest.Mock;
setData: jest.Mock;
priceFormatter: () => {
format: (value: number) => string;
};
}
interface ChartTestContext {
options: DeepPartial<ChartOptions>;
series: SeriesTestContext[];
addSeries: jest.Mock;
applyOptions: jest.Mock;
removeSeries: jest.Mock;
remove: jest.Mock;
timeScale: () => {
setVisibleRange: jest.Mock;
};
subscribeCrosshairMove: jest.Mock;
unsubscribeCrosshairMove: jest.Mock;
takeScreenshot: jest.Mock;
chartElement: () => HTMLElement;
}
interface MarkersTestContext {
setMarkers: jest.Mock;
detach: jest.Mock;
}
const mocks = {
charts: [] as ChartTestContext[],
markers: [] as MarkersTestContext[],
};
function createSeriesContext(options: LineSeriesPartialOptions): SeriesTestContext {
return {
options,
applyOptions: jest.fn(),
setData: jest.fn(),
priceFormatter: () => ({
format: (value: number) => value.toFixed(2),
}),
};
}
function createChartContext(container: HTMLElement, options: DeepPartial<ChartOptions>): ChartTestContext {
const timeScale = {
setVisibleRange: jest.fn(),
};
const series: SeriesTestContext[] = [];
return {
options,
series,
addSeries: jest.fn((_type: unknown, seriesOptions: LineSeriesPartialOptions) => {
const item = createSeriesContext(seriesOptions);
series.push(item);
return item;
}),
applyOptions: jest.fn(),
removeSeries: jest.fn(),
remove: jest.fn(),
timeScale: () => timeScale,
subscribeCrosshairMove: jest.fn(),
unsubscribeCrosshairMove: jest.fn(),
takeScreenshot: jest.fn(() => document.createElement('canvas')),
chartElement: () => container,
};
}
jest.mock('lightweight-charts', () => ({
...jest.requireActual('lightweight-charts'),
createChart: jest.fn(),
createSeriesMarkers: jest.fn(),
}));
jest.mock('@src/theme/store', () => ({
getThemeStore: jest.fn(),
}));
jest.mock('@src/translations', () => ({
t: (key: string) => key,
getLocale: () => 'en',
}));
jest.mock('@src/utils', () => ({
...jest.requireActual('@src/utils/seasonality'),
...jest.requireActual('@src/utils/precision'),
formatCompactNumber: (value: number) => String(value),
formatPercent: (value: number) => `${value.toFixed(2)}%`,
canvasToBlob: jest.fn(),
drawTextLayer: jest.fn(),
}));
function getLastCall(mock: jest.Mock) {
return mock.mock.calls[mock.mock.calls.length - 1];
}
function createCandle(date: string, close = 100, milliseconds = false): Candle {
return {
time: Date.parse(`${date}T12:00:00Z`) / (milliseconds ? 1 : 1000),
open: close,
high: close,
low: close,
close,
volume: 1,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
let container: HTMLDivElement;
let manager: SeasonalityManager;
let getData: jest.Mock;
let state: ReturnType<typeof createInitialSeasonalityState>;
let frames: Map<number, FrameRequestCallback>;
let nextFrame: number;
let clipboardDescriptor: PropertyDescriptor | undefined;
let clipboardItemDescriptor: PropertyDescriptor | undefined;
const colors = {
chartBackground: '#ffffff',
chartTextPrimary: '#111111',
chartPaneSeparator: '#222222',
chartGridLine: '#333333',
chartCrosshairLine: '#444444',
chartCrosshairLabel: '#555555',
};
function createManager() {
manager = new SeasonalityManager({
container,
getData,
});
manager.getState().subscribe((value) => {
state = value;
});
}
function flushAnimationFrames() {
const callbacks = [...frames.values()];
frames.clear();
callbacks.forEach((callback) => callback(0));
}
async function loadSampleHistory() {
getData
.mockResolvedValueOnce([
createCandle('2020-01-01'),
createCandle('2023-01-01'),
createCandle('2025-01-01'),
createCandle('2025-01-02', 125),
])
.mockResolvedValueOnce(null);
await manager.show('AAA', {
fromYear: 2020,
toYear: 2025,
});
}
beforeEach(() => {
clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
clipboardItemDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'ClipboardItem');
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-06-01T12:00:00Z'));
setPricePrecision(3);
jest.spyOn(console, 'error').mockImplementation(() => {});
mocks.charts.length = 0;
mocks.markers.length = 0;
(createChart as jest.Mock).mockImplementation((element: HTMLElement, options: DeepPartial<ChartOptions>) => {
const chart = createChartContext(element, options);
mocks.charts.push(chart);
return chart;
});
(createSeriesMarkers as jest.Mock).mockImplementation(() => {
const markers = {
setMarkers: jest.fn(),
detach: jest.fn(),
};
mocks.markers.push(markers);
return markers;
});
frames = new Map();
nextFrame = 0;
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => {
frames.set(++nextFrame, callback);
return nextFrame;
});
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => {
frames.delete(id);
});
jest.mocked(getThemeStore).mockReturnValue({
colors,
} as ReturnType<typeof getThemeStore>);
getData = jest.fn();
container = document.createElement('div');
document.body.append(container);
state = createInitialSeasonalityState();
createManager();
});
afterEach(() => {
manager.destroy();
container.remove();
setPricePrecision();
if (clipboardDescriptor) {
Object.defineProperty(navigator, 'clipboard', clipboardDescriptor);
} else {
Reflect.deleteProperty(navigator, 'clipboard');
}
if (clipboardItemDescriptor) {
Object.defineProperty(globalThis, 'ClipboardItem', clipboardItemDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'ClipboardItem');
}
jest.restoreAllMocks();
jest.clearAllMocks();
jest.useRealTimers();
});
describe('SeasonalityManager: загрузка истории', () => {
it('должен загружать дневные страницы, нормализовать время и удалять граничные дубликаты', async () => {
const boundary = createCandle('2025-01-02', 125);
getData
.mockResolvedValueOnce([boundary, createCandle('2025-01-03', 150, true)])
.mockResolvedValueOnce([createCandle('2025-01-01', 100), boundary])
.mockResolvedValueOnce(null);
await manager.show('AAA');
expect(state.status).toBe('ready');
expect(getData.mock.calls.map((args) => args.slice(0, 2))).toEqual(Array(3).fill([Timeframes['1d'], 'AAA']));
expect(getData.mock.calls[1][2].time).toBe(boundary.time);
manager.setScale(PriceScaleMode.Normal);
const data = getLastCall(mocks.charts[0].series[0].setData)[0];
expect(data.filter((point: LineData | WhitespaceData) => 'value' in point)).toEqual([
{ time: '2000-01-01', value: 100 },
{ time: '2000-01-02', value: 125 },
{ time: '2000-01-03', value: 150 },
]);
});
it('должен завершать загрузку при повторении только граничной свечи', async () => {
const first = createCandle('2025-01-01');
getData.mockResolvedValueOnce([first]).mockResolvedValueOnce([first]);
await manager.show('AAA');
expect(state.status).toBe('ready');
expect(getData).toHaveBeenCalledTimes(2);
});
it('должен показывать ошибку при отсутствии прогресса пагинации', async () => {
getData.mockResolvedValueOnce([createCandle('2025-01-01')]).mockResolvedValueOnce([createCandle('2025-01-02')]);
await manager.show('AAA');
expect(state.status).toBe('error');
expect(state.error).toContain('made no progress');
});
it.each([{ page: null }, { page: [] }])('должен показывать пустое состояние для страницы $page', async ({ page }) => {
getData.mockResolvedValueOnce(page);
await manager.show('AAA');
expect(state.status).toBe('empty');
expect(mocks.charts).toHaveLength(0);
expect(container.textContent).toContain('No seasonality data');
});
it('не должен запрашивать историю без выбранного инструмента', async () => {
await manager.show('');
expect(state.status).toBe('error');
expect(getData).not.toHaveBeenCalled();
});
it.each(['time', 'close'] as const)('должен отклонять некорректное поле %s', async (field) => {
getData.mockResolvedValueOnce([
{
...createCandle('2025-01-01'),
[field]: NaN,
},
]);
await manager.show('AAA');
expect(state.status).toBe('error');
expect(state.error).toContain('Invalid daily candle');
});
it('должен повторно загружать историю после ошибки', async () => {
getData.mockRejectedValueOnce(new Error('network'));
await manager.show('AAA');
expect(state.status).toBe('error');
await loadSampleHistory();
expect(state.status).toBe('ready');
expect(state.error).toBeNull();
});
it('не должен применять ответ после очистки сезонности', async () => {
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise);
const loading = manager.show('AAA');
expect(state.status).toBe('loading');
manager.clear();
request.resolve([createCandle('2025-01-01')]);
await loading;
expect(state.status).toBe('idle');
expect(state.tableRows).toEqual([]);
expect(mocks.charts).toHaveLength(0);
});
it('не должен применять ошибку предыдущего инструмента', async () => {
const old = createDeferred<Candle[]>();
getData.mockReturnValueOnce(old.promise);
const loading = manager.show('OLD');
getData.mockResolvedValueOnce([createCandle('2025-01-01')]).mockResolvedValueOnce(null);
await manager.show('NEW');
old.reject(new Error('late failure'));
await loading;
expect(state.symbolId).toBe('NEW');
expect(state.status).toBe('ready');
expect(state.tableRows.map((row) => row.year)).toEqual([2025]);
expect(console.error).not.toHaveBeenCalled();
});
it('не должен применять данные предыдущего инструмента', async () => {
const old = createDeferred<Candle[]>();
getData.mockReturnValueOnce(old.promise);
const loading = manager.show('OLD');
getData.mockResolvedValueOnce([createCandle('2025-01-01')]).mockResolvedValueOnce(null);
await manager.show('NEW');
old.resolve([createCandle('2020-01-01')]);
await loading;
expect(state.symbolId).toBe('NEW');
expect(state.minYear).toBe(2025);
expect(state.tableRows.map((row) => row.year)).toEqual([2025]);
expect(mocks.charts).toHaveLength(1);
});
it('не должен повторно запрашивать загружаемый инструмент', async () => {
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise);
const loading = manager.show('AAA');
await manager.show('AAA');
expect(getData).toHaveBeenCalledTimes(1);
request.resolve([]);
await loading;
});
it('должен использовать загруженную историю при изменении диапазона', async () => {
await loadSampleHistory();
const count = getData.mock.calls.length;
await manager.show('AAA', {
fromYear: 2025,
toYear: 2025,
});
flushAnimationFrames();
expect(getData).toHaveBeenCalledTimes(count);
expect(state.fromYear).toBe(2025);
expect(mocks.charts[0].removeSeries).toHaveBeenCalledTimes(2);
});
it('должен выбирать последние пять календарных лет по умолчанию', async () => {
getData.mockResolvedValueOnce([createCandle('2020-01-01'), createCandle('2025-01-01')]).mockResolvedValueOnce(null);
await manager.show('AAA');
expect(state.fromYear).toBe(2021);
expect(state.toYear).toBe(2025);
});
});
describe('SeasonalityManager: отображение и очистка', () => {
it('должен объединять изменения диапазона в один кадр и применять последнее значение', async () => {
await loadSampleHistory();
manager.setRange({
fromYear: 2020,
toYear: 2023,
});
manager.setRange({
fromYear: 2023,
toYear: 2023,
});
expect(frames.size).toBe(1);
flushAnimationFrames();
expect(state.status).toBe('ready');
expect(container.textContent).toContain('2023');
expect(container.textContent).not.toContain('2025');
expect(mocks.markers[0].detach).toHaveBeenCalledTimes(1);
});
it('не должен перерисовывать прежний диапазон и должен поддерживать диапазон без данных', async () => {
await loadSampleHistory();
manager.setRange({
fromYear: 2020,
toYear: 2025,
});
expect(frames.size).toBe(0);
manager.setRange({
fromYear: 2021,
toYear: 2022,
});
flushAnimationFrames();
expect(state.status).toBe('empty');
expect(container.textContent).toContain('No seasonality data');
});
it('должен переключать формат значений без пересоздания графика', async () => {
await loadSampleHistory();
manager.setScale(PriceScaleMode.Normal);
manager.setScale(PriceScaleMode.Percentage);
const series = mocks.charts[0].series[mocks.charts[0].series.length - 1];
const { priceFormat } = getLastCall(series.applyOptions)[0];
expect(priceFormat.formatter(12.5)).toBe('12.50%');
expect(priceFormat.minMove).toBe(0.01);
manager.setScale(PriceScaleMode.Normal);
expect(getLastCall(series.applyOptions)[0].priceFormat.minMove).toBe(0.001);
expect(mocks.charts).toHaveLength(1);
expect(mocks.charts[0].timeScale().setVisibleRange).toHaveBeenCalledWith({
from: '2000-01-01',
to: '2000-12-31',
});
expect(getLastCall(mocks.markers[0].setMarkers)[0][0].time).toBe('2000-01-02');
});
it.each([0, 6])('должен брать шаг цены из общей точности %s', async (precision) => {
await loadSampleHistory();
const series = mocks.charts[0].series[mocks.charts[0].series.length - 1];
const count = getData.mock.calls.length;
setPricePrecision(precision);
manager.setScale(PriceScaleMode.Percentage);
manager.setScale(PriceScaleMode.Normal);
expect(getLastCall(series.applyOptions)[0].priceFormat.minMove).toBe(10 ** -precision);
manager.setScale(PriceScaleMode.Percentage);
const { priceFormat } = getLastCall(series.applyOptions)[0];
expect(priceFormat.minMove).toBe(0.01);
expect(priceFormat.formatter(12.5)).toBe('12.50%');
expect(getData).toHaveBeenCalledTimes(count);
expect(mocks.charts).toHaveLength(1);
});
it('должен показывать предупреждение о базе только для процентной шкалы', async () => {
getData.mockResolvedValueOnce([createCandle('2025-01-01', 0)]).mockResolvedValueOnce(null);
await manager.show('AAA');
manager.setScale(PriceScaleMode.Percentage);
expect(container.textContent).toContain('Percentage requires a positive baseline');
expect(getLastCall(mocks.markers[0].setMarkers)[0]).toEqual([]);
manager.setScale(PriceScaleMode.Normal);
expect((container.lastElementChild as HTMLElement).style.display).toBe('none');
});
it('должен применять актуальные цвета темы при создании и обновлении графика', async () => {
await loadSampleHistory();
expect(mocks.charts[0].options.crosshair).toMatchObject({
vertLine: {
color: colors.chartCrosshairLine,
labelBackgroundColor: colors.chartCrosshairLabel,
},
horzLine: {
color: colors.chartCrosshairLine,
labelBackgroundColor: colors.chartCrosshairLabel,
},
});
jest.mocked(getThemeStore).mockReturnValue({
colors: {
...colors,
chartTextPrimary: '#999999',
chartCrosshairLine: '#aabbcc',
chartCrosshairLabel: '#ddeeff',
},
} as ReturnType<typeof getThemeStore>);
manager.updateTheme();
expect(getLastCall(mocks.charts[0].applyOptions)[0]).toMatchObject({
layout: {
textColor: '#999999',
},
crosshair: {
vertLine: {
color: '#aabbcc',
labelBackgroundColor: '#ddeeff',
},
horzLine: {
color: '#aabbcc',
labelBackgroundColor: '#ddeeff',
},
},
});
});
it('должен форматировать все варианты времени как день и месяц', async () => {
await loadSampleHistory();
const formatter = mocks.charts[0].options.localization?.timeFormatter;
if (typeof formatter !== 'function') {
throw new Error('Time formatter is not configured');
}
expect(
formatter({
year: 2000,
month: 2,
day: 9,
}),
).toBe('09.02');
expect(formatter('2000-02-09')).toBe('09.02');
expect(formatter(Date.UTC(2000, 1, 9) / 1000)).toBe('09.02');
});
it('должен обновлять легенду по перекрестию и обозначать отсутствующие значения', async () => {
await loadSampleHistory();
const chart = mocks.charts[0];
const listener = chart.subscribeCrosshairMove.mock.calls[0][0];
listener({
time: '2000-01-01',
seriesData: new Map([[chart.series[0], { value: 42 }]]),
});
expect(container.textContent).toContain('42.00');
expect(container.textContent).toContain('—');
listener({
time: undefined,
seriesData: new Map(),
});
expect(container.textContent).not.toContain('—');
});
it('должен копировать готовый график вместе с легендой', async () => {
const blob = new Blob(['image'], {
type: 'image/png',
});
const write = jest.fn().mockResolvedValue(undefined);
jest.mocked(canvasToBlob).mockResolvedValueOnce(blob);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { write },
});
Object.defineProperty(globalThis, 'ClipboardItem', {
configurable: true,
value: class {
constructor(public data: Record<string, Blob>) {}
},
});
await manager.copyImage();
expect(write).not.toHaveBeenCalled();
await loadSampleHistory();
await manager.copyImage();
expect(mocks.charts[0].takeScreenshot).toHaveBeenCalledWith(true, false);
expect(drawTextLayer).toHaveBeenCalledTimes(1);
expect(canvasToBlob).toHaveBeenCalledTimes(1);
expect(write).toHaveBeenCalledTimes(1);
const items = write.mock.calls[0][0];
expect(items[0].data['image/png']).toBe(blob);
});
it('должен отменять кадры и освобождать ресурсы при очистке сезонности', async () => {
await loadSampleHistory();
manager.setRange({
fromYear: 2023,
toYear: 2025,
});
const chart = mocks.charts[0];
manager.clear();
manager.clear();
expect(frames.size).toBe(0);
expect(state.status).toBe('idle');
flushAnimationFrames();
expect(mocks.charts).toHaveLength(1);
expect(chart.remove).toHaveBeenCalledTimes(1);
expect(chart.unsubscribeCrosshairMove).toHaveBeenCalledWith(chart.subscribeCrosshairMove.mock.calls[0][0]);
expect(mocks.markers[0].detach).toHaveBeenCalledTimes(1);
});
it('должен завершать состояние и игнорировать ответ после уничтожения', async () => {
const complete = jest.fn();
manager.getState().subscribe({ complete });
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise);
const loading = manager.show('AAA');
manager.destroy();
manager.destroy();
request.resolve([createCandle('2025-01-01')]);
await loading;
await manager.show('BBB');
manager.setRange({
fromYear: 2020,
toYear: 2025,
});
manager.setScale(PriceScaleMode.Normal);
manager.setScale(PriceScaleMode.Percentage);
manager.setDisplayMode(SeasonalityViewType.Table);
expect(complete).toHaveBeenCalledTimes(1);
expect(state.displayMode).toBe(SeasonalityViewType.Chart);
expect(state.scale).toBe(PriceScaleMode.Normal);
expect(state.tableRows).toEqual([]);
expect(container.children).toHaveLength(0);
expect(mocks.charts).toHaveLength(0);
expect(getData).toHaveBeenCalledTimes(1);
});
it('не должен изменять диапазон без загруженной истории', () => {
const initial = state;
manager.setRange({
fromYear: 2020,
toYear: 2025,
});
manager.setScale(PriceScaleMode.Normal);
expect(state).toBe(initial);
expect(frames.size).toBe(0);
});
it('должен применять шкалу, выбранную во время загрузки', async () => {
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise).mockResolvedValueOnce(null);
const loading = manager.show('AAA');
manager.setScale(PriceScaleMode.Percentage);
request.resolve([createCandle('2025-01-01'), createCandle('2025-01-02', 125)]);
await loading;
const data = getLastCall(mocks.charts[0].series[0].setData)[0];
expect(state.scale).toBe(PriceScaleMode.Percentage);
expect(data.filter((point: LineData | WhitespaceData) => 'value' in point)).toEqual([
{ time: '2000-01-01', value: 0 },
{ time: '2000-01-02', value: 25 },
]);
});
it('должен восстанавливать отображение после выбора пустого диапазона', async () => {
await loadSampleHistory();
const count = getData.mock.calls.length;
manager.setRange({
fromYear: 2021,
toYear: 2022,
});
flushAnimationFrames();
expect(state.status).toBe('empty');
manager.setRange({
fromYear: 2023,
toYear: 2025,
});
flushAnimationFrames();
expect(state.status).toBe('ready');
expect(getData).toHaveBeenCalledTimes(count);
expect(mocks.charts).toHaveLength(1);
expect((container.lastElementChild as HTMLElement).style.display).toBe('none');
});
it('должен сбрасывать статус и повторно загружать инструмент после очистки', async () => {
await loadSampleHistory();
manager.setScale(PriceScaleMode.Percentage);
manager.setDisplayMode(SeasonalityViewType.Table);
manager.clear();
expect(state.status).toBe('idle');
expect(state.error).toBeNull();
expect(state.scale).toBe(PriceScaleMode.Percentage);
expect(state.displayMode).toBe(SeasonalityViewType.Table);
expect(state.tableRows).toEqual([]);
const count = getData.mock.calls.length;
await loadSampleHistory();
expect(getData).toHaveBeenCalledTimes(count + 2);
expect(state.status).toBe('ready');
expect(state.tableRows.map((row) => row.year)).toEqual([2025, 2023, 2020]);
expect(mocks.charts).toHaveLength(2);
});
});
describe('SeasonalityManager: табличный режим', () => {
it('должен показывать изменение первого неполного месяца и годовой итог', async () => {
getData
.mockResolvedValueOnce([
createCandle('2021-10-15', 100),
createCandle('2021-10-29', 101),
createCandle('2021-11-30', 105),
createCandle('2021-12-30', 106),
])
.mockResolvedValueOnce(null);
await manager.show('AAA');
const [row] = state.tableRows;
expect(row.months.slice(0, 9)).toEqual(Array(9).fill(null));
expect(row.months[9]?.absolute).toBe(1);
expect(row.months[9]?.percentage).toBeCloseTo(1, 10);
expect(row.total?.absolute).toBe(6);
expect(row.total?.percentage).toBeCloseTo(6, 10);
});
it('должен использовать декабрь с предыдущей страницы за пределами выбранного диапазона', async () => {
getData
.mockResolvedValueOnce([
createCandle('2024-01-02', 105),
createCandle('2024-01-31', 110),
createCandle('2024-02-29', 121),
])
.mockResolvedValueOnce([createCandle('2023-12-29', 100)])
.mockResolvedValueOnce(null);
await manager.show('AAA', {
fromYear: 2024,
toYear: 2024,
});
expect(state.tableRows).toHaveLength(1);
const [row] = state.tableRows;
expect(row.year).toBe(2024);
expect(row.months[0]?.absolute).toBe(10);
expect(row.months[0]?.percentage).toBeCloseTo(10, 10);
expect(row.total?.absolute).toBe(21);
expect(row.total?.percentage).toBeCloseTo(21, 10);
expect(getData).toHaveBeenCalledTimes(3);
});
it('должен рассчитывать месячные и годовые изменения по загруженной истории', async () => {
getData
.mockResolvedValueOnce([
createCandle('2023-12-29', 100),
createCandle('2024-01-02', 104),
createCandle('2024-01-31', 110),
createCandle('2024-02-29', 121),
createCandle('2024-12-31', 120),
createCandle('2025-01-31', 132),
])
.mockResolvedValueOnce(null);
await manager.show('AAA', {
fromYear: 2024,
toYear: 2025,
});
expect(state.tableRows.map((row) => row.year)).toEqual([2025, 2024]);
const row = state.tableRows[1];
expect(row.months).toHaveLength(12);
expect(row.months[0]?.absolute).toBe(10);
expect(row.months[0]?.percentage).toBeCloseTo(10);
expect(row.months[1]?.absolute).toBe(11);
expect(row.months[1]?.percentage).toBeCloseTo(10);
expect(row.total?.absolute).toBe(20);
expect(row.total?.percentage).toBeCloseTo(20);
const count = getData.mock.calls.length;
manager.setDisplayMode(SeasonalityViewType.Table);
manager.setRange({
fromYear: 2025,
toYear: 2025,
});
flushAnimationFrames();
expect(state.tableRows).toHaveLength(1);
expect(state.tableRows[0].year).toBe(2025);
expect(state.tableRows[0].months[0]?.absolute).toBe(12);
expect(state.tableRows[0].months[0]?.percentage).toBeCloseTo(10);
expect(state.tableRows[0].total?.absolute).toBe(12);
expect(state.tableRows[0].total?.percentage).toBeCloseTo(10);
expect(getData).toHaveBeenCalledTimes(count);
});
it('должен отличать отсутствующую базу, пропущенный месяц и нулевое изменение', async () => {
getData
.mockResolvedValueOnce([
createCandle('2024-01-31', 0),
createCandle('2024-02-29', 10),
createCandle('2024-04-30', 15),
createCandle('2024-05-31', 15),
])
.mockResolvedValueOnce(null);
await manager.show('AAA');
const row = state.tableRows[0];
expect(row.months[0]).toEqual({
absolute: 0,
percentage: null,
});
expect(row.months[1]).toEqual({
absolute: 10,
percentage: null,
});
expect(row.months[2]).toBeNull();
expect(row.months[3]).toBeNull();
expect(row.months[4]).toEqual({
absolute: 0,
percentage: 0,
});
expect(row.months.slice(5)).toEqual(Array(7).fill(null));
expect(row.total).toEqual({
absolute: 15,
percentage: null,
});
});
it('должен переключать вид без повторной загрузки и пересоздания графика', async () => {
await loadSampleHistory();
const count = getData.mock.calls.length;
const chart = mocks.charts[0];
const rows = state.tableRows;
manager.setDisplayMode(SeasonalityViewType.Table);
expect(state.displayMode).toBe(SeasonalityViewType.Table);
expect(state.status).toBe('ready');
expect(state.tableRows).toBe(rows);
manager.setScale(PriceScaleMode.Percentage);
manager.setDisplayMode(SeasonalityViewType.Chart);
expect(state.displayMode).toBe(SeasonalityViewType.Chart);
expect(state.scale).toBe(PriceScaleMode.Percentage);
expect(state.fromYear).toBe(2020);
expect(state.toYear).toBe(2025);
expect(state.tableRows).toBe(rows);
expect(getData).toHaveBeenCalledTimes(count);
expect(mocks.charts).toHaveLength(1);
expect(chart.remove).not.toHaveBeenCalled();
});
it('должен сохранять вид и шкалу, выбранные во время загрузки', async () => {
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise).mockResolvedValueOnce(null);
const loading = manager.show('AAA');
manager.setDisplayMode(SeasonalityViewType.Table);
manager.setScale(PriceScaleMode.Percentage);
expect(state.status).toBe('loading');
expect(state.tableRows).toEqual([]);
request.resolve([createCandle('2024-12-31', 100), createCandle('2025-01-31', 125)]);
await loading;
expect(state.status).toBe('ready');
expect(state.displayMode).toBe(SeasonalityViewType.Table);
expect(state.scale).toBe(PriceScaleMode.Percentage);
expect(state.tableRows[0].months[0]).toEqual({
absolute: 25,
percentage: 25,
});
expect(state.tableRows[0].total).toEqual({
absolute: 25,
percentage: 25,
});
});
it('не должен копировать снимок графика в табличном режиме', async () => {
await loadSampleHistory();
manager.setDisplayMode(SeasonalityViewType.Table);
await manager.copyImage();
expect(mocks.charts[0].takeScreenshot).not.toHaveBeenCalled();
expect(drawTextLayer).not.toHaveBeenCalled();
expect(canvasToBlob).not.toHaveBeenCalled();
});
it('должен очищать строки при загрузке нового инструмента и ошибке запроса', async () => {
await loadSampleHistory();
manager.setDisplayMode(SeasonalityViewType.Table);
const request = createDeferred<Candle[]>();
getData.mockReturnValueOnce(request.promise);
const loading = manager.show('NEW');
expect(state.symbolId).toBe('NEW');
expect(state.status).toBe('loading');
expect(state.tableRows).toEqual([]);
request.reject(new Error('network'));
await loading;
expect(state.status).toBe('error');
expect(state.displayMode).toBe(SeasonalityViewType.Table);
expect(state.tableRows).toEqual([]);
});
it('должен восстанавливать строки после выбора пустого диапазона', async () => {
await loadSampleHistory();
const count = getData.mock.calls.length;
manager.setDisplayMode(SeasonalityViewType.Table);
manager.setRange({
fromYear: 2021,
toYear: 2022,
});
flushAnimationFrames();
expect(state.status).toBe('empty');
expect(state.tableRows).toEqual([]);
manager.setRange({
fromYear: 2023,
toYear: 2025,
});
flushAnimationFrames();
expect(state.status).toBe('ready');
expect(state.displayMode).toBe(SeasonalityViewType.Table);
expect(state.tableRows.map((row) => row.year)).toEqual([2025, 2023]);
expect(getData).toHaveBeenCalledTimes(count);
});
});