Загрузка данных


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);
  });
});