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


import { Intervals, MoexChart, Timeframes } from 'moex-chart';
import { useCallback, useEffect, useRef, useState } from 'react';

import { useChangeProperties, useSelectProperties } from '@modules/widgetProperties';
import { WidgetProperties } from '@widgets/Chart/properties/types';
import { ChartIndicativeData } from '@widgets/Chart/types';

import { MOEX_CHART_CONFIG } from '../constants';
import { DataSourceProvider } from '../dataSourceProvide';

import type { __CompareManager__, IMoexChart, SymbolInfoInput } from 'moex-chart';

interface TUseMoexChartProps {
  symbolInfo: SymbolInfoInput;
  indicativeData?: ChartIndicativeData;
}

interface CircularReferenceInfo {
  path: string;
  reference: string;
  type: string;
}

const findCircularReference = (value: unknown): CircularReferenceInfo | null => {
  const visited = new WeakMap<object, string>();

  const visit = (current: unknown, path: string): CircularReferenceInfo | null => {
    if (current === null || typeof current !== 'object') {
      return null;
    }

    const previousPath = visited.get(current);

    if (previousPath) {
      return {
        path,
        reference: previousPath,
        type: current.constructor?.name ?? 'Object',
      };
    }

    visited.set(current, path);

    for (const [key, nestedValue] of Object.entries(current)) {
      const result = visit(nestedValue, `${path}.${key}`);

      if (result) {
        return result;
      }
    }

    return null;
  };

  return visit(value, 'snapshot');
};

const getValueType = (value: unknown): string => {
  if (value === null) {
    return 'null';
  }

  if (typeof value !== 'object') {
    return typeof value;
  }

  return value.constructor?.name ?? 'Object';
};

const getValueKeys = (value: unknown): string[] => {
  if (value === null || typeof value !== 'object') {
    return [];
  }

  return Object.keys(value);
};

export const useMoexChart = ({ symbolInfo, indicativeData }: TUseMoexChartProps) => {
  const moexChartState = useSelectProperties(
    (widgetProperties: Partial<WidgetProperties>) => widgetProperties.moexChartState,
  );

  const { updateProperties } = useChangeProperties<WidgetProperties>();

  const [isCompareOpen, setIsCompareOpen] = useState(false);
  const [isSymbolSearchOpen, setIsSymbolSearchOpen] = useState(false);

  const containerRef = useRef<HTMLDivElement | null>(null);
  const chartRef = useRef<MoexChart | null>(null);
  const compareManagerRef = useRef<__CompareManager__ | null>(null);

  const currentSymbolInfoRef = useRef<SymbolInfoInput>(symbolInfo);
  const timeframeRef = useRef<Timeframes | undefined>(moexChartState?.timeframe);
  const savedDataRef = useRef<string | undefined>(moexChartState?.savedData);
  const initialIntervalRef = useRef<Intervals | undefined>(moexChartState?.initialInterval);
  const updateTimeframeRef = useRef<((timeframe: Timeframes) => void) | null>(null);

  useEffect(() => {
    const currentSymbolInfo = currentSymbolInfoRef.current;

    if (
      currentSymbolInfo.symbolId === symbolInfo.symbolId &&
      currentSymbolInfo.symbol === symbolInfo.symbol &&
      currentSymbolInfo.symbolName === symbolInfo.symbolName
    ) {
      return;
    }

    currentSymbolInfoRef.current = symbolInfo;
    chartRef.current?.setSymbol(symbolInfo);
  }, [symbolInfo]);

  useEffect(() => {
    timeframeRef.current = moexChartState?.timeframe;
    savedDataRef.current = moexChartState?.savedData;
  }, [moexChartState?.savedData, moexChartState?.timeframe]);

  updateTimeframeRef.current = (timeframe: Timeframes): void => {
    if (timeframeRef.current === timeframe) {
      return;
    }

    timeframeRef.current = timeframe;

    updateProperties((state) => {
      state.moexChartState = {
        ...state.moexChartState,
        timeframe,
      };
    });
  };

  const saveSnapshot = useCallback((): void => {
    const snapshot = chartRef.current?.getSnapshot();

    if (!snapshot) {
      return;
    }

    const circularReference = findCircularReference(snapshot);

    if (circularReference) {
      console.error(
        '[MoexChart][snapshot circular reference]',
        JSON.stringify(circularReference, null, 2),
      );

      console.log(
        '[MoexChart][snapshot structure]',
        JSON.stringify(
          {
            snapshotKeys: Object.keys(snapshot),
            charts: snapshot.charts.map((chart, chartIndex) => ({
              chartIndex,
              keys: Object.keys(chart),
              symbolId: chart.symbolId,
              symbol: chart.symbol,
              symbolName: chart.symbolName,
              timeframe: chart.timeframe,
              panes: chart.panes.map((pane, paneIndex) => ({
                paneIndex,
                keys: Object.keys(pane),
                id: pane.id,
                isMain: pane.isMain,
                indicators: pane.indicators.map((indicator, indicatorIndex) => ({
                  indicatorIndex,
                  keys: Object.keys(indicator),
                  type: 'indicatorType' in indicator ? indicator.indicatorType : 'compare',
                  valueTypes: Object.fromEntries(
                    Object.entries(indicator).map(([key, value]) => [key, getValueType(value)]),
                  ),
                })),
                drawings: pane.drawings.map((drawing, drawingIndex) => ({
                  drawingIndex,
                  keys: Object.keys(drawing),
                  id: drawing.id,
                  drawingName: drawing.drawingName,
                  stateType: getValueType(drawing.state),
                  stateKeys: getValueKeys(drawing.state),
                })),
              })),
            })),
          },
          null,
          2,
        ),
      );

      return;
    }

    const savedData = JSON.stringify(snapshot);

    if (savedDataRef.current === savedData) {
      return;
    }

    savedDataRef.current = savedData;

    updateProperties((state) => {
      state.moexChartState = {
        ...state.moexChartState,
        timeframe: timeframeRef.current ?? Timeframes['1m'],
        savedData,
      };
    });
  }, [updateProperties]);

  const getSnapshotWithCurrentSettings = (
    snapshot: typeof MOEX_CHART_CONFIG.snapshot | IMoexChart['snapshot'],
    timeframe: Timeframes,
    initialInterval?: Intervals,
  ): IMoexChart['snapshot'] => ({
    ...snapshot,
    charts: snapshot.charts.map((chartSnapshot) => ({
      ...chartSnapshot,
      ...currentSymbolInfoRef.current,
      timeframe,
      ...(initialInterval ? { interval: initialInterval } : {}),
    })),
  });

  const applySnapshot = (): void => {
    if (!savedDataRef.current || !chartRef.current) {
      return;
    }

    const savedSnapshot = JSON.parse(savedDataRef.current) as IMoexChart['snapshot'];
    const timeframe = timeframeRef.current ?? Timeframes['1m'];

    chartRef.current.setSnapshot(getSnapshotWithCurrentSettings(savedSnapshot, timeframe));
    compareManagerRef.current = chartRef.current.getCompareManager();
  };

  useEffect(() => {
    const container = containerRef.current;

    if (!container) {
      return undefined;
    }

    const timeframe = timeframeRef.current ?? Timeframes['1m'];
    const initialInterval = initialIntervalRef.current;

    const savedSnapshot = savedDataRef.current
      ? (JSON.parse(savedDataRef.current) as IMoexChart['snapshot'])
      : MOEX_CHART_CONFIG.snapshot;

    const dataProvider = new DataSourceProvider();

    const chart = new MoexChart({
      ...MOEX_CHART_CONFIG,
      container,
      snapshot: getSnapshotWithCurrentSettings(savedSnapshot, timeframe, initialInterval),
      chartCollectionPreset: {
        ...MOEX_CHART_CONFIG.chartCollectionPreset,
        openCompareModal: () => setIsCompareOpen(true),
        openSymbolSearchModal: () => setIsSymbolSearchOpen(true),
        getDataSource: dataProvider.getDataSource(indicativeData, (nextTimeframe) => {
          updateTimeframeRef.current?.(nextTimeframe);
        }),
        startRealtime: (getSymbols, getTimeframe, update) =>
          dataProvider.startRealtime({
            getSymbols,
            getTimeframe,
            update,
            indicativeData,
          }),
      },
    });

    chartRef.current = chart;
    compareManagerRef.current = chart.getCompareManager();

    if (initialInterval) {
      initialIntervalRef.current = undefined;

      updateProperties((state) => {
        state.moexChartState = {
          ...state.moexChartState,
          initialInterval: undefined,
        };
      });
    }

    const intervalId = setInterval(() => {
      saveSnapshot();
    }, 1000);

    return () => {
      clearInterval(intervalId);
      saveSnapshot();

      chartRef.current = null;
      compareManagerRef.current = null;
      chart.destroy();
    };

    // eslint-disable-next-line react-hooks/exhaustive-deps -- график пересоздаётся только при изменении источника индикативных данных
  }, [indicativeData?.key]);

  return {
    containerRef,
    isCompareOpen,
    isSymbolSearchOpen,
    compareManagerRef,
    setIsCompareOpen,
    setIsSymbolSearchOpen,
    saveSnapshot,
    applySnapshot,
    hasSavedSnapshot: Boolean(moexChartState?.savedData),
  };
};