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


import type { PriceScaleMode } from 'lightweight-charts';

export enum SeasonalityViewType {
  Chart = 'chart',
  Table = 'table',
}

export type SeasonalityScale = PriceScaleMode.Normal | PriceScaleMode.Percentage;

export interface SeasonalityRange {
  fromYear: number;
  toYear: number;
}

export interface SeasonalityState extends SeasonalityRange {
  displayMode: SeasonalityViewType;
  tableRows: SeasonalityTableRow[];
  status: 'idle' | 'loading' | 'ready' | 'empty' | 'error';
  scale: SeasonalityScale;
  symbolId: string;
  minYear: number;
  maxYear: number;
  error: string | null;
}

export interface SeasonalityPoint {
  date: string;
  chartDate: string;
  price: number;
  percentage: number | null;
}

export interface SeasonalityYear {
  year: number;
  baselineDate: string;
  baselinePrice: number;
  points: SeasonalityPoint[];
}

export interface DailyClose {
  date: string;
  close: number;
}

export interface SeasonalityChange {
  absolute: number;
  percentage: number | null;
}

export interface SeasonalityTableRow {
  year: number;
  months: (SeasonalityChange | null)[];
  total: SeasonalityChange | null;
}

















import { PriceScaleMode } from 'lightweight-charts';

import { SeasonalityViewType } from '@src/types';

import type { SeasonalityState } from '@src/types';

export const DEFAULT_SEASONALITY_YEARS = 5;

export function createInitialSeasonalityState(): SeasonalityState {
  const year = new Date().getUTCFullYear();
  const fromYear = year - DEFAULT_SEASONALITY_YEARS + 1;

  return {
    displayMode: SeasonalityViewType.Chart,
    tableRows: [],
    status: 'idle',
    scale: PriceScaleMode.Normal,
    symbolId: '',
    error: null,
    minYear: fromYear,
    maxYear: year,
    fromYear,
    toYear: year,
  };
}

















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 lastPoint = year.points[year.points.length - 1];

    return {
      year: year.year,
      months: Array.from({ length: 12 }, (_, month) => {
        const key = firstMonth + month;

        return calculateChange(closes.get(key - 1), closes.get(key));
      }),
      total: calculateChange(closes.get(firstMonth - 1), 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 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>
  );
}

















@use '../../theme/mixins' as m;

.table {
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  max-width: 100%;
  max-height: 100%;
  overflow: auto;
  box-sizing: border-box;
  color: var(--table-text, var(--neutral-12));
  background: var(--table-background, var(--neutral-0));

  @include m.scrollbar;

  &_content {
    width: 100%;
    min-width: max-content;
    border-collapse: separate;
    border-spacing: 0;
  }

  &_cell {
    padding: var(--space-0750) var(--space-1000);
    border-right: 1px solid var(--table-border, var(--neutral-3));
    border-bottom: 1px solid var(--table-border, var(--neutral-3));
    background: var(--table-cell-background, var(--table-background, var(--neutral-0)));
    white-space: nowrap;
    font-weight: var(--table-cell-font-weight, 400);

    &:last-child {
      border-right: 0;
    }

    &_left {
      text-align: left;
    }

    &_center {
      text-align: center;
    }

    &_right {
      text-align: right;
    }
  }

  &_content thead &_cell {
    border-top: 1px solid var(--table-border, var(--neutral-3));
  }

  &_stickyHeader thead .table_cell {
    position: sticky;
    top: 0;
    z-index: 2;
  }

  &_stickyFirstColumn .table_cell:first-child {
    position: sticky;
    left: 0;
    z-index: 1;
  }

  &_stickyHeader.table_stickyFirstColumn thead .table_cell:first-child {
    z-index: 3;
  }

  &_empty {
    padding: var(--space-1000);
    text-align: center;
  }
}
















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 { 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>;
  precision?: number;
}

export function SeasonalityTable({ stateObs, precision = 2 }: SeasonalityTableProps) {
  const state = useObservable(stateObs);
  const locale = getLocale();
  const fractionDigits = state?.scale === PriceScaleMode.Percentage ? 2 : precision;

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

















.seasonality {
  font-variant-numeric: tabular-nums;
}

.positive {
  --table-cell-background: color-mix(in srgb, #089981 25%, var(--table-background, var(--neutral-0)));
}

.negative {
  --table-cell-background: color-mix(in srgb, #f23645 35%, var(--table-background, var(--neutral-0)));
}

.total {
  --table-cell-font-weight: 600;
}

















import { PriceScaleMode } from 'lightweight-charts';

import { Button } from '@src/components/Button';
import { Dropdown } from '@src/components/Dropdown';
import { RangeField } from '@src/components/FormFields';
import { SeasonalityScaleMenu } from '@src/components/Menu/SeasonalityScaleMenu';

import { t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
import { useObservable } from '@src/utils';

import styles from './index.module.scss';

import type { SeasonalityRange, SeasonalityScale, SeasonalityState } from '@src/types/seasonality';
import type { Observable } from 'rxjs';

interface SeasonalityControlsProps {
  stateObs: Observable<SeasonalityState>;
  setDisplayMode: (displayMode: SeasonalityViewType) => void;
  setScale: (scale: SeasonalityScale) => void;
  setRange: (range: SeasonalityRange) => void;
}

const MAX_SCALE_LABELS = 5;

function createYearMarks(minYear: number, maxYear: number): number[] {
  const span = maxYear - minYear;

  if (span <= 0) return [minYear];

  const intervals = Math.min(MAX_SCALE_LABELS - 1, span);

  return Array.from({ length: intervals + 1 }, (_, index) => minYear + Math.floor((span * index) / intervals));
}

export function SeasonalityControls({
  stateObs,
  setDisplayMode,
  setScale,
  setRange,
}: SeasonalityControlsProps) {
  const state = useObservable(stateObs);

  if (!state || state.status === 'idle') {
    return null;
  }

  const selectedScaleLabel = state.scale === PriceScaleMode.Normal ? t('Price') : t('Percent');

  const isRangeDisabled = state.status === 'loading' || state.status === 'error';

  const marks = createYearMarks(state.minYear, state.maxYear);

  const handleRangeChange = ([fromYear, toYear]: [number, number]): void => {
    setRange({ fromYear, toYear });
  };

  return (
    <div className={styles.controls}>
      <div className={styles.controls_header}>
        <div className={styles.controls_views}>
          <Button
            label={t('Chart')}
            pressed={state.displayMode === SeasonalityViewType.Chart}
            onClick={() => setDisplayMode(SeasonalityViewType.Chart)}
          />

          <Button
            label={t('Table')}
            pressed={state.displayMode === SeasonalityViewType.Table}
            onClick={() => setDisplayMode(SeasonalityViewType.Table)}
          />
        </div>

        <div className={styles.controls_scale}>
          <Dropdown selectedValue={selectedScaleLabel}>
            <SeasonalityScaleMenu
              selectedScale={state.scale}
              onClick={setScale}
            />
          </Dropdown>
        </div>
      </div>

      <div className={styles.controls_range}>
        <RangeField
          mode="range"
          value={[state.fromYear, state.toYear]}
          min={state.minYear}
          max={state.maxYear}
          marks={marks}
          step={1}
          suffix=""
          size="sm"
          disabled={isRangeDisabled}
          onRangeChange={handleRangeChange}
        />
      </div>
    </div>
  );
}

















.controls {
  display: flex;
  flex-direction: column;
  min-width: 0;
  gap: var(--space-0500);

  &_header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: var(--space-0500);
    min-width: 0;
  }

  &_views {
    display: flex;
    align-items: center;
    gap: var(--space-0125);
  }

  &_scale {
    margin-left: auto;
    border: 1px solid var(--neutral-6);
    border-radius: var(--space-0500);
    background-color: var(--neutral-0);
    cursor: pointer;
  }

  &_range {
    min-width: 0;
  }
}

















import {
  CHART_DRAWING_TOOLBAR_CONTAINER,
  CHART_MODAL_CONTAINER_CLASSNAME,
  CHART_PANE_EMPTY_STATE_CONTAINER,
  CHART_PANE_LEGEND_CONTAINER,
  CHART_PANE_OVERLAY_CONTAINER,
  CHART_ROOT_CLASSNAME,
  CHART_SCROLLABLE_CLASSNAME,
} from '@src/constants';

interface CreateContainersOptions {
  parentContainer: HTMLElement;
  showBottomPanel?: boolean;
  showMenuButton?: boolean;
}

export enum ZIndex {
  Chart = '0',
  Base = '1',
  Legend = '2',
  Floating = '3',
  Modal = '1000',
}

const ContainerLayoutConfig = {
  headerHeight: 36,
  footerHeight: 32,
  toolbarWidth: 42,
  verticalGap: 8,
};

/**
 * Утилита для создания DOM контейнеров
 */
export class ContainerManager {
  private static injectFont(): void {
    const existingLink = document.querySelector('link[href*="fonts.googleapis.com"]');

    if (existingLink) {
      return;
    }

    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = 'https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap';
    document.head.appendChild(link);
  }

  /**
   * Создание контейнеров для графика и UI компонентов
   */
  static createContainers({ parentContainer, showBottomPanel, showMenuButton }: CreateContainersOptions) {
    this.injectFont();

    const { headerHeight, footerHeight, toolbarWidth, verticalGap } = ContainerLayoutConfig;

    parentContainer.innerHTML = '';
    parentContainer.classList.add(CHART_ROOT_CLASSNAME);

    Object.assign(parentContainer.style, {
      height: '100%',
      width: '100%',
      padding: 'var(--space-1000)',
      backgroundColor: 'var(--neutral-0)',
      borderRadius: 'var(--space-0500)',
      display: 'grid',
      rowGap: `${verticalGap}px`,
      gridTemplateRows: showBottomPanel
        ? `${headerHeight}px minmax(0, 1fr) ${footerHeight}px`
        : `${headerHeight}px minmax(0, 1fr)`,
    });

    const headerContainer = document.createElement('div');
    headerContainer.classList.add(CHART_SCROLLABLE_CLASSNAME);

    Object.assign(headerContainer.style, {
      width: '100%',
      height: `${headerHeight}px`,
      overflow: 'auto hidden',
    });

    const footerContainer = document.createElement('div');

    Object.assign(footerContainer.style, {
      width: '100%',
      height: `${footerHeight}px`,
    });

    const chartViewContainer = document.createElement('div');

    Object.assign(chartViewContainer.style, {
      position: 'relative',
      display: 'grid',
      minWidth: '0',
      minHeight: '0',
    });

    const chartContainer = document.createElement('div');

    Object.assign(chartContainer.style, {
      position: 'relative',
      gridArea: '1 / 1',
      height: '100%',
      width: '100%',
      minWidth: '0',
      minHeight: '0',
      display: 'grid',
      columnGap: 'var(--space-0500)',
      gridTemplateColumns: 'minmax(0, 1fr)',
    });

    const chartAreaContainer = document.createElement('div');
    chartAreaContainer.tabIndex = -1;

    Object.assign(chartAreaContainer.style, {
      position: 'relative',
      height: '100%',
      minHeight: '0',
      minWidth: '0',
      outline: 'none',
      cursor: 'crosshair',
    });

    const seasonalityContainer = document.createElement('div');
    seasonalityContainer.tabIndex = -1;
    seasonalityContainer.inert = true;

    Object.assign(seasonalityContainer.style, {
      position: 'relative',
      gridArea: '1 / 1',
      minWidth: '0',
      minHeight: '0',
      visibility: 'hidden',
      pointerEvents: 'none',
      zIndex: ZIndex.Base,
      overflow: 'hidden',
      display: 'grid',
      gridTemplateRows: 'auto minmax(0, 1fr)',
      rowGap: 'var(--space-0500)',
      outline: 'none',
      userSelect: 'none',
    });

    const seasonalityControlsContainer = document.createElement('div');

    Object.assign(seasonalityControlsContainer.style, {
      gridArea: '1 / 1',
      minWidth: '0',
    });

    const seasonalityPlotContainer = document.createElement('div');

    Object.assign(seasonalityPlotContainer.style, {
      position: 'relative',
      gridArea: '2 / 1',
      minWidth: '0',
      minHeight: '0',
    });

    const seasonalityTableContainer = document.createElement('div');
    seasonalityTableContainer.hidden = true;
    seasonalityTableContainer.inert = true;

    Object.assign(seasonalityTableContainer.style, {
      position: 'relative',
      gridArea: '2 / 1',
      minWidth: '0',
      minHeight: '0',
      overflow: 'hidden',
    });

    const toolBarContainer = document.createElement('div');

    Object.assign(toolBarContainer.style, {
      height: '100%',
      minHeight: '0',
      overflow: 'hidden',
    });

    const controlBarContainer = document.createElement('div');

    Object.assign(controlBarContainer.style, {
      position: 'absolute',
      left: '50%',
      transform: 'translateX(-50%)',
      bottom: 'var(--space-2000)',
      zIndex: ZIndex.Floating,
    });

    const drawingToolbarContainer = document.createElement('div');
    drawingToolbarContainer.classList.add(CHART_DRAWING_TOOLBAR_CONTAINER);

    Object.assign(drawingToolbarContainer.style, {
      position: 'absolute',
      inset: '0',
      zIndex: ZIndex.Floating,
      pointerEvents: 'none',
      overflow: 'hidden',
    });

    const modalContainer = document.createElement('div');
    modalContainer.classList.add(CHART_MODAL_CONTAINER_CLASSNAME);
    modalContainer.hidden = true;

    Object.assign(modalContainer.style, {
      position: 'absolute',
      inset: '0',
      zIndex: ZIndex.Modal,
    });

    seasonalityContainer.append(
      seasonalityControlsContainer,
      seasonalityPlotContainer,
      seasonalityTableContainer,
    );
    chartAreaContainer.append(controlBarContainer, drawingToolbarContainer);
    chartContainer.append(chartAreaContainer, modalContainer);
    chartViewContainer.append(chartContainer, seasonalityContainer);
    parentContainer.append(headerContainer, chartViewContainer);

    if (showBottomPanel) {
      parentContainer.append(footerContainer);
    }

    const toggleToolbar = () => {
      if (toolBarContainer.isConnected) {
        toolBarContainer.remove();

        Object.assign(chartContainer.style, {
          gridTemplateColumns: 'minmax(0, 1fr)',
        });

        return false;
      }

      chartContainer.insertBefore(toolBarContainer, chartAreaContainer);

      Object.assign(chartContainer.style, {
        gridTemplateColumns: `${toolbarWidth}px minmax(0, 1fr)`,
      });

      return true;
    };

    if (showMenuButton) {
      toggleToolbar();
    }

    return {
      headerContainer,
      footerContainer,
      chartContainer,
      seasonalityContainer,
      seasonalityControlsContainer,
      seasonalityPlotContainer,
      seasonalityTableContainer,
      chartAreaContainer,
      toolBarContainer,
      modalContainer,
      controlBarContainer,
      drawingToolbarContainer,
      toggleToolbar,
    };
  }

  /**
   * Очистка контейнеров
   */
  static clearContainers(parentContainer: HTMLElement): void {
    parentContainer.innerHTML = '';
  }

  static createPaneContainers() {
    const legendContainer = document.createElement('div');
    legendContainer.classList.add(CHART_PANE_LEGEND_CONTAINER);

    Object.assign(legendContainer.style, {
      position: 'absolute',
      top: '0',
      left: '0',
      zIndex: ZIndex.Legend,
      pointerEvents: 'none',
    });

    const paneOverlayContainer = document.createElement('div');
    paneOverlayContainer.classList.add(CHART_PANE_OVERLAY_CONTAINER);

    Object.assign(paneOverlayContainer.style, {
      position: 'absolute',
      inset: '0',
      zIndex: ZIndex.Base,
      pointerEvents: 'none',
    });

    const paneEmptyStateContainer = document.createElement('div');
    paneEmptyStateContainer.classList.add(CHART_PANE_EMPTY_STATE_CONTAINER);
    paneEmptyStateContainer.hidden = true;

    return {
      legendContainer,
      paneOverlayContainer,
      paneEmptyStateContainer,
    };
  }
}

















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, getThemeStore } 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,
      precision: config.chartCollectionPreset.ohlc.precision,
    });

    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()}
        precision={config.chartCollectionPreset.ohlc.precision}
      />,
    );

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

        const { colors } = getThemeStore();

        seasonalityTableContainer.style.setProperty('--table-background', colors.chartBackground);
        seasonalityTableContainer.style.setProperty('--table-text', colors.chartTextPrimary);
        seasonalityTableContainer.style.setProperty('--table-border', colors.chartGridLine);

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

















{
  "Chart": "График",
  "Table": "Таблица",
  "Date": "Дата",
  "Year": "Год",
  "January": "Январь",
  "February": "Февраль",
  "March": "Март",
  "April": "Апрель",
  "May": "Май",
  "June": "Июнь",
  "July": "Июль",
  "August": "Август",
  "September": "Сентябрь",
  "October": "Октябрь",
  "November": "Ноябрь",
  "December": "Декабрь"
}