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


import { combineLatest, Subscription } from 'rxjs';

import { ControlBar } from '@components/ControlBar';
import { Footer } from '@components/Footer';

import { Header } from '@components/Header';
import { DataSource, DataSourceParams } from '@core/DataSource';
import { ModalRenderer } from '@core/ModalRenderer';
import { SettingsModal } from '@src/components/SettingsModal';

import Toolbar from '@src/components/Toolbar';
import { CompareManager } from '@src/core/CompareManager';
import { FullscreenController } from '@src/core/Fullscreen';

import { IndicatorsIds } from '@src/core/Indicators';
import { configureThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import { ChartSeriesType, ChartTypeOptions, OHLCConfig, TooltipConfig } from '@src/types';
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';

// todo: forbid @lib in /src
export interface IMoexChart {
  container: HTMLElement;
  supportedTimeframes: Timeframes[];
  initialTimeframe: Timeframes;
  supportedChartSeriesTypes: ChartSeriesType[];
  initialChartSeriesTypes: ChartSeriesType;
  initialSymbol: string;
  getDataSource: DataSourceParams['getData'];
  theme: ThemeKey; // 'mb' | 'mxt' | 'tr'
  ohlc: OHLCConfig;
  initialIndicators?: IndicatorsIds[];
  size?:
    | {
        width: number;
        height: number;
      }
    | false;
  mode?: ThemeMode; // 'light' | 'dark'
  undoRedoEnabled?: boolean;
  showMenuButton?: boolean;
  showBottomPanel?: boolean;
  showControlBar?: boolean;
  showFullscreenButton?: boolean;
  showSettingsButton?: boolean;
  showCompareButton?: 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 }
   *    }
   *```
   */
  chartSettings?: ChartSettingsSource;
  chartOptions?: ChartTypeOptions; // todo: разнести по разным полям в соответствии с тиами графика
  tooltipConfig?: TooltipConfig;
  openCompareModal?: () => void;
}

export class MoexChart {
  private chart: Chart;
  private resizeObserver?: ResizeObserver;
  private eventManager: EventManager;
  private rootContainer: HTMLElement;

  private headerRenderer: UIRenderer;
  private modalRenderer: ModalRenderer;
  private toolbarRenderer: UIRenderer | undefined;
  private controlBarRenderer?: UIRenderer;
  private footerRenderer?: UIRenderer;

  private timeScaleHoverController: TimeScaleHoverController;
  private dataSource: DataSource;

  private subscriptions = new Subscription();

  private fullscreen: FullscreenController;

  constructor(config: IMoexChart) {
    setPricePrecision(config.ohlc.precision);

    this.eventManager = new EventManager({
      initialTimeframe: config.initialTimeframe,
      initialSeries: config.initialChartSeriesTypes,
      initialSymbol: config.initialSymbol,
      initialChartOptions: config.chartOptions,
      isMXT: config.theme === 'mxt',
    });

    if (config.initialIndicators) {
      this.eventManager.setUserIndicators(config.initialIndicators);
    }

    if (config.chartSettings) {
      this.setSettings(config.chartSettings);
    }

    this.dataSource = new DataSource({
      getData: config.getDataSource,
      eventManager: this.eventManager,
    });

    this.rootContainer = config.container;

    this.fullscreen = new FullscreenController(this.rootContainer);

    const store = configureThemeStore(config);

    const {
      chartAreaContainer,
      toolBarContainer,
      headerContainer,
      modalContainer,
      controlBarContainer,
      footerContainer,
      toggleToolbar, // todo: move this function to toolbarRenderer
    } = ContainerManager.createContainers({
      parentContainer: this.rootContainer,
      showBottomPanel: config.showBottomPanel, // todo: apply config.showBottomPanel in FullscreenController
      showMenuButton: config.showMenuButton,
    });

    this.modalRenderer = new ModalRenderer(modalContainer);

    this.chart = new Chart({
      eventManager: this.eventManager,
      modalRenderer: this.modalRenderer,
      container: chartAreaContainer,
      theme: store.theme,
      mode: store.mode,
      seriesTypes: config.supportedChartSeriesTypes,
      dataSource: this.dataSource,
      chartOptions: config.chartOptions, // todo: remove, use only model from eventManager
      ohlcConfig: config.ohlc, // todo: omptimize
      tooltipConfig: config.tooltipConfig ?? {},
    });

    this.subscriptions.add(
      combineLatest([store.theme$, store.mode$]).subscribe(([theme, mode]) => {
        this.chart.updateTheme(theme, mode);

        document.documentElement.dataset.theme = theme;
        document.documentElement.dataset.mode = mode;
      }),
    );

    this.headerRenderer = new ReactRenderer(headerContainer);
    this.toolbarRenderer = new ReactRenderer(toolBarContainer);

    if (config.showControlBar) {
      this.controlBarRenderer = new ReactRenderer(controlBarContainer);
    }

    if (config.showBottomPanel) {
      this.footerRenderer = new ReactRenderer(footerContainer);
    }

    this.headerRenderer.renderComponent(
      <Header
        timeframes={config.supportedTimeframes}
        selectedTimeframeObs={this.eventManager.getTimeframeObs()}
        setTimeframe={(value) => {
          this.eventManager.setTimeframe(value);
        }}
        seriesTypes={config.supportedChartSeriesTypes}
        selectedSeriesObs={this.eventManager.getSelectedSeries()}
        setSelectedSeries={(value) => {
          this.eventManager.setSeriesSelected(value);
        }}
        showSettingsModal={
          config.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: 'Настройки' },
                )
            : undefined
        }
        toggleUserIndicator={this.eventManager.toggleUserIndicator}
        activeIndicatorsObs={this.eventManager.getUserIndicatorsListArr()}
        showMenuButton={config.showMenuButton}
        showFullscreenButton={config.showFullscreenButton}
        fullscreen={this.fullscreen}
        undoRedo={config.undoRedoEnabled ? this.eventManager.getUndoRedo() : undefined}
        toggleToolbarVisible={toggleToolbar}
        showCompareButton={!!config.showCompareButton}
        openCompareModal={config.openCompareModal ? config.openCompareModal : undefined}
        isMXT={config.theme === 'mxt'}
      />,
    );

    this.timeScaleHoverController = new TimeScaleHoverController({
      eventManager: this.eventManager,
      controlBarContainer,
      chartContainer: chartAreaContainer,
    });

    if (config.showMenuButton) {
      this.toolbarRenderer.renderComponent(
        <Toolbar
          toggleDOM={this.chart.getDom().toggleDOM}
          addDrawing={(name) => {
            // todo: deal with new panes logic
            this.chart.getDrawingsManager().addDrawing(name);
          }}
        />,
      );
    }

    if (this.controlBarRenderer && config.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.showBottomPanel) {
      this.footerRenderer.renderComponent(
        <Footer
          supportedTimeframes={config.supportedTimeframes}
          setInterval={this.eventManager.setInterval}
          intervalObs={this.eventManager.getInterval()}
        />,
      );
    }
  }

  public setSettings(settings: ChartSettingsSource): void {
    this.eventManager.importChartSettings(settings);
  }

  public getSettings(): ChartSettings {
    return this.eventManager.exportChartSettings();
  }

  public getRealtimeApi() {
    return this.chart.getRealtimeApi();
  }

  // todo: описать в доке
  public getCompareManager(): CompareManager {
    return this.chart.getCompareManager();
  }

  public setSymbol(symbol: string): void {
    if (!symbol) return;

    this.eventManager.setSymbol(symbol);
  }

  /**
   * Уничтожение графика и очистка ресурсов
   * @returns void
   */
  destroy(): void {
    this.headerRenderer.destroy();
    this.subscriptions.unsubscribe();
    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();
    }

    if (this.eventManager) {
      this.eventManager.destroy();
    }

    ContainerManager.clearContainers(this.rootContainer);
  }
}




import dayjs from 'dayjs';

import {
  ChartOptions,
  createChart,
  CrosshairMode,
  DeepPartial,
  IChartApi,
  IRange,
  LocalizationOptionsBase,
  LogicalRange,
  Time,
  UTCTimestamp,
} from 'lightweight-charts';

import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
import { map, withLatestFrom } from 'rxjs/operators';

import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { IndicatorManager } from '@core/IndicatorManager';
import { ModalRenderer } from '@core/ModalRenderer';
import { PaneManager } from '@core/PaneManager';
import { CompareManager } from '@src/core/CompareManager';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { getThemeStore } from '@src/theme/store';
import { ThemeKey, ThemeMode } from '@src/theme/types';
import {
  Candle,
  ChartOptionsModel,
  ChartSeriesType,
  ChartTypeOptions,
  Direction,
  OHLCConfig,
  TooltipConfig,
} from '@src/types';
import { Defaults } from '@src/types/defaults';
import { DayjsOffset, Intervals, intervalsToDayjs } from '@src/types/intervals';
import { ensureDefined } from '@src/utils';

import { createTickMarkFormatter, formatDate } from '@src/utils/formatter';

export interface ChartConfig extends Partial<ChartOptionsModel> {
  container: HTMLElement;
  seriesTypes: ChartSeriesType[];
  theme: ThemeKey;
  mode?: ThemeMode;
  chartOptions?: ChartTypeOptions;
  localization?: LocalizationOptionsBase;
}

export enum Resize {
  Shrink,
  Expand,
}

const HISTORY_LOAD_THRESHOLD = 50;

interface ChartParams extends ChartConfig {
  dataSource: DataSource;
  eventManager: EventManager;
  modalRenderer: ModalRenderer;
  ohlcConfig: OHLCConfig;
  tooltipConfig: TooltipConfig;
}

/**
 * Абстракция над библиотекой для построения графиков
 */
export class Chart {
  private lwcChart!: IChartApi;
  private container: HTMLElement;
  private eventManager: EventManager;
  private paneManager!: PaneManager;
  private compareManager: CompareManager;
  private mouseEvents: ChartMouseEvents;
  private indicatorManager: IndicatorManager;
  private optionsSubscription: Subscription;
  private dataSource: DataSource;
  private chartConfig: Omit<ChartConfig, 'theme' | 'mode'>;
  private mainSeries: BehaviorSubject<SeriesStrategies | null> = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
  private DOM: DOMModel;

  private isPointerDown = false;
  private didResetOnDrag = false;

  private subscriptions = new Subscription();

  private currentInterval: Intervals | null = null;

  private activeSymbols: string[] = [];

  private historyBatchRunning = false;

  constructor({ eventManager, dataSource, modalRenderer, ohlcConfig, tooltipConfig, ...lwcConfig }: ChartParams) {
    this.eventManager = eventManager;
    this.dataSource = dataSource;
    this.container = lwcConfig.container;
    this.chartConfig = lwcConfig;

    this.lwcChart = createChart(this.container, getOptions(lwcConfig));

    this.optionsSubscription = this.eventManager
      .getChartOptionsModel()
      .subscribe(({ dateFormat, timeFormat, showTime }) => {
        const configToApply = { ...lwcConfig, dateFormat, timeFormat, showTime };

        this.lwcChart.applyOptions({
          ...getOptions(configToApply),
          localization: {
            timeFormatter: (time: UTCTimestamp) => formatDate(time, dateFormat, timeFormat, showTime),
          },
        });
      });

    this.subscriptions.add(this.optionsSubscription);

    this.mouseEvents = new ChartMouseEvents({ lwcChart: this.lwcChart, container: this.container });

    this.mouseEvents.subscribe('wheel', this.onWheel);
    this.mouseEvents.subscribe('pointerDown', this.onPointerDown);
    this.mouseEvents.subscribe('pointerMove', this.onPointerMove);
    this.mouseEvents.subscribe('pointerUp', this.onPointerUp);
    this.mouseEvents.subscribe('pointerCancel', this.onPointerUp);

    this.DOM = new DOMModel({ modalRenderer });

    this.paneManager = new PaneManager({
      eventManager: this.eventManager,
      lwcChart: this.lwcChart,
      dataSource,
      DOM: this.DOM,
      ohlcConfig,
      subscribeChartEvent: this.subscribeChartEvent,
      chartContainer: this.container,
      tooltipConfig,
    });

    this.indicatorManager = new IndicatorManager({
      eventManager,
      DOM: this.DOM,
      mainSerie$: this.mainSeries,
      dataSource: this.dataSource,
      lwcChart: this.lwcChart,
      paneManager: this.paneManager,
      chartOptions: lwcConfig.chartOptions,
    });

    this.compareManager = new CompareManager({
      chart: this.lwcChart,
      eventManager: this.eventManager,
      dataSource: this.dataSource,
      indicatorManager: this.indicatorManager,
      paneManager: this.paneManager,
    });

    this.setupDataSourceSubs();
    this.setupHistoricalDataLoading();
  }

  public getPriceScaleWidth(direction: Direction): number {
    try {
      const priceScale = this.lwcChart.priceScale(direction);

      return priceScale ? priceScale.width() : 0;
    } catch {
      return 0;
    }
  }

  public getDrawingsManager = (): DrawingsManager => {
    return this.paneManager.getDrawingsManager();
  };

  public getIndicatorManager = (): IndicatorManager => {
    return this.indicatorManager;
  };

  private onWheel = () => {
    this.eventManager.resetInterval({ history: false });
  };

  private onPointerDown = () => {
    this.isPointerDown = true;
    this.didResetOnDrag = false;
  };

  private onPointerMove = () => {
    if (!this.isPointerDown) return;
    if (this.didResetOnDrag) return;

    this.didResetOnDrag = true;
    this.eventManager.resetInterval({ history: false });
  };

  private onPointerUp = () => {
    this.isPointerDown = false;
  };

  public getDom(): DOMModel {
    return this.DOM;
  }

  public getMainSeries(): Observable<SeriesStrategies | null> {
    return this.mainSeries.asObservable();
  }

  public getCompareManager(): CompareManager {
    return this.compareManager;
  }

  public updateTheme(theme: ThemeKey, mode: ThemeMode) {
    this.lwcChart.applyOptions(getOptions({ ...this.chartConfig, theme, mode }));
  }

  public destroy(): void {
    this.mouseEvents.destroy();
    this.compareManager.clear();
    this.subscriptions.unsubscribe();
    this.lwcChart.remove();
  }

  public subscribeChartEvent: ChartMouseEvents['subscribe'] = (event, callback) =>
    this.mouseEvents.subscribe(event, callback);

  public unsubscribeChartEvent: ChartMouseEvents['unsubscribe'] = (event, callback) => {
    this.mouseEvents.unsubscribe(event, callback);
  };

  // todo: add/move to undo/redo model(eventManager)
  public scrollTimeScale = (direction: Direction) => {
    this.eventManager.resetInterval({ history: false });
    const diff = direction === Direction.Left ? -2 : 2;
    const currentPosition = this.lwcChart.timeScale().scrollPosition();
    this.lwcChart.timeScale().scrollToPosition(currentPosition + diff, false);
  };

  // todo: add/move to undo/redo model(eventManager)
  public zoomTimeScale = (resize: Resize) => {
    this.eventManager.resetInterval({ history: false });
    const diff = resize === Resize.Shrink ? -1 : 1;

    const currentRange = this.lwcChart.timeScale().getVisibleRange();
    if (!currentRange) return;

    const { from, to } = currentRange as IRange<number>;
    if (!from || !to) return;

    const next: IRange<Time> = {
      from: (from + (to - from) * 0.1 * diff) as Time,
      to: to as Time,
    };
    this.lwcChart.timeScale().setVisibleRange(next);
  };

  // todo: add to undo/redo model(eventManager)
  public resetZoom = () => {
    this.eventManager.resetInterval({ history: false });
    this.lwcChart.timeScale().resetTimeScale();
    this.lwcChart.priceScale(Direction.Right).setAutoScale(true);
    this.lwcChart.priceScale(Direction.Left).setAutoScale(true);
  };

  public getRealtimeApi() {
    return {
      getTimeframe: () => this.eventManager.getTimeframe(),
      getSymbols: () => this.activeSymbols,
      update: (symbol: string, candle: Candle) => {
        this.dataSource.updateRealtime(symbol, candle);
      },
    };
  }

  private scheduleHistoryBatch = () => {
    if (this.historyBatchRunning) return;

    this.historyBatchRunning = true;

    requestAnimationFrame(() => {
      const symbols = this.activeSymbols.slice();

      Promise.all(symbols.map((s) => this.dataSource.loadMoreHistory(s))).finally(() => {
        this.historyBatchRunning = false;

        const range = this.lwcChart.timeScale().getVisibleLogicalRange();
        if (range && range.from < HISTORY_LOAD_THRESHOLD) {
          this.scheduleHistoryBatch();
        }
      });
    });
  };

  private setupDataSourceSubs() {
    const getWarmupFrom = (): number => {
      if (this.currentInterval && this.currentInterval !== Intervals.All) {
        return getIntervalRange(this.currentInterval).from;
      }

      const range = this.lwcChart.timeScale().getVisibleRange();
      if (!range) return 0;

      const { from } = range as IRange<number>;
      return from as number;
    };

    const warmupSymbols = (symbols: string[]) => {
      if (!symbols.length) return;

      const from = getWarmupFrom();
      if (!from) return;

      Promise.all(symbols.map((symbol) => this.dataSource.loadTill(symbol, from))).catch((error) => {
        console.error('[Chart] Ошибка при прогреве символов:', error);
      });
    };
    const symbols$ = combineLatest([this.eventManager.symbol(), this.compareManager.itemsObs()]).pipe(
      map(([main, items]) => Array.from(new Set([main, ...items.map((i) => i.symbol)]))),
    );

    this.subscriptions.add(
      this.eventManager
        .getInterval()
        .pipe(withLatestFrom(symbols$))
        .subscribe(([interval, symbols]) => {
          this.currentInterval = interval;

          if (!interval) return;

          if (interval === Intervals.All) {
            Promise.all(symbols.map((s) => this.dataSource.loadAllHistory(s)))
              .then(() => {
                requestAnimationFrame(() => this.lwcChart.timeScale().fitContent());
              })
              .catch((error) => console.error('[Chart] Ошибка при загрузке всей истории:', error));

            return;
          }

          const { from, to } = getIntervalRange(interval);

          Promise.all(symbols.map((s) => this.dataSource.loadTill(s, from)))
            .then(() => {
              this.lwcChart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
            })
            .catch((error) => {
              console.error('[Chart] Ошибка при применении интервала:', error);
            });
        }),
    );

    this.subscriptions.add(
      symbols$.subscribe((symbols) => {
        const prevSymbols = this.activeSymbols;
        this.activeSymbols = symbols;

        this.dataSource.setSymbols(symbols);

        const prevSet = new Set(prevSymbols);
        const added: string[] = [];

        for (let i = 0; i < symbols.length; i += 1) {
          const s = symbols[i];
          if (!s) continue;
          if (prevSet.has(s)) continue;
          added.push(s);
        }

        if (added.length) {
          warmupSymbols(added);
        }
      }),
    );

    this.subscriptions.add(
      combineLatest([
        this.eventManager.symbol(),
        this.compareManager.itemsObs(),
        this.mainSeries.asObservable(),
      ]).subscribe(([main, items, serie]) => {
        if (!serie) return;

        const title = items.length ? main : '';
        serie.getLwcSeries().applyOptions({ title });
      }),
    );
  }

  private setupHistoricalDataLoading(): void {
    // todo (не)вызвать loadMoreHistory после проверки на необходимость дозагрузки после смены таймфрейма
    this.mouseEvents.subscribe('visibleLogicalRangeChange', (logicalRange: LogicalRange | null) => {
      if (!logicalRange) return;
      if (this.currentInterval === Intervals.All) return;

      const needsMoreData = logicalRange.from < HISTORY_LOAD_THRESHOLD;
      if (!needsMoreData) return;

      this.scheduleHistoryBatch();
    });
  }
}

function getIntervalRange(interval: Intervals): { from: number; to: number } {
  const { value, unit } = intervalsToDayjs[interval] as DayjsOffset;

  const from = Math.floor(dayjs().subtract(value, unit).valueOf() / 1000);
  const to = Math.floor(dayjs().valueOf() / 1000);

  return { from, to };
}

function getOptions(config: ChartConfig): DeepPartial<ChartOptions> {
  const timeFormat = config.timeFormat ?? Defaults.timeFormat;
  const showTime = config.showTime ?? Defaults.showTime;

  const use12HourFormat = timeFormat === '12h';
  const timeFormatString = use12HourFormat ? 'h:mm A' : 'HH:mm';

  const { colors } = getThemeStore();

  return {
    width: config.container.clientWidth,
    height: config.container.clientHeight,
    autoSize: true,
    layout: {
      background: { color: colors.chartBackground },
      textColor: colors.chartTextPrimary,
    },
    grid: {
      vertLines: { color: colors.chartGridLine },
      horzLines: { color: colors.chartGridLine },
    },
    crosshair: {
      mode: CrosshairMode.Normal,
      vertLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 0 },
      horzLine: { color: colors.chartCrosshairLine, labelBackgroundColor: colors.chartCrosshairLabel, style: 2 },
    },
    timeScale: {
      timeVisible: showTime,
      secondsVisible: false,
      tickMarkFormatter: createTickMarkFormatter(timeFormatString),
      borderVisible: false,
      allowBoldLabels: false,
    },
    rightPriceScale: {
      textColor: colors.chartTextPrimary,
      borderVisible: false,
    },
  };
}



import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, map, Observable, throttleTime } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorsIds } from '@core/Indicators';
import { PaneManager } from '@core/PaneManager';
import { DOMObject } from '@src/core/DOMObject';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions } from '@src/types';

interface SeriesParams {
  eventManager: EventManager;
  dataSource: DataSource;
  mainSerie$: BehaviorSubject<SeriesStrategies | null>;
  lwcChart: IChartApi;
  paneManager: PaneManager;
  DOM: DOMModel;
  chartOptions?: ChartTypeOptions;
}

export class IndicatorManager {
  private eventManager: EventManager;
  private lwcChart: IChartApi;
  private chartOptions?: ChartTypeOptions;

  private entities$: BehaviorSubject<Indicator[]> = new BehaviorSubject<Indicator[]>([]);
  private indicatorsMap$: BehaviorSubject<Map<IndicatorsIds, Indicator>> = new BehaviorSubject(new Map());
  private DOM: DOMModel;

  constructor({ eventManager, dataSource, lwcChart, DOM, chartOptions, mainSerie$, paneManager }: SeriesParams) {
    this.eventManager = eventManager;
    this.lwcChart = lwcChart;
    this.chartOptions = chartOptions;
    this.DOM = DOM;

    this.eventManager.getUserIndicatorsList().subscribe((ids: Set<IndicatorsIds>) => {
      const indicatorsMap = new Map(this.indicatorsMap$.value);

      ids.forEach((id: IndicatorsIds) => {
        if (!indicatorsMap.has(id)) {
          const ind = this.addEntity<Indicator>(
            (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
              new Indicator({
                id,
                lwcChart,
                mainSymbol$: this.eventManager.getSymbol(),
                dataSource,
                chartOptions,
                zIndex,
                onDelete: this.deleteIndicator,
                moveUp,
                moveDown,
                paneManager,
              }),
          );

          indicatorsMap.set(id, ind);
        }
      });

      // todo: move to deleteSeries
      const indicatorsIdsToDelete = Array.from(indicatorsMap.keys()).filter((existingKey) => {
        return !ids.has(existingKey);
      });

      indicatorsIdsToDelete.forEach((s: IndicatorsIds) => {
        // todo: move to deleteSeries
        const indicatorToDestroy = indicatorsMap.get(s);

        if (!indicatorToDestroy) return;

        this.removeEntity(indicatorToDestroy);

        indicatorToDestroy.destroy();
        indicatorsMap.delete(s);
      });

      this.indicatorsMap$.next(indicatorsMap);
      this.entities$.next(Array.from(indicatorsMap.values()));
    });
  }

  public addEntity<T extends Indicator>(
    factory: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
  ): T {
    return this.DOM.setEntity(factory);
  }

  public removeEntity(entity: DOMObject): void {
    this.DOM.removeEntity(entity);
  }

  public deleteIndicator = (id: string) => {
    this.eventManager.toggleUserIndicator(id as IndicatorsIds);
  };

  public entities(): Observable<Indicator[]> {
    return this.entities$.asObservable();
  }
}



import { IChartApi } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription, throttleTime } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { IndicatorConfig, IndicatorsIds, indicatorsMap } from '@core/Indicators';
import { Pane } from '@core/Pane';
import { PaneManager } from '@core/PaneManager';

import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ChartTypeOptions } from '@src/types';
import { ensureDefined } from '@src/utils';

type IIndicator = DOMObject;

export interface IndicatorParams extends DOMObjectParams {
  mainSymbol$: Observable<string>;
  lwcChart: IChartApi;
  dataSource: DataSource;
  paneManager: PaneManager;
  chartOptions?: ChartTypeOptions;
  config?: IndicatorConfig;
}

export class Indicator extends DOMObject implements IIndicator {
  private series: SeriesStrategies[] = [];
  private seriesMap: Map<string, SeriesStrategies> = new Map();
  private lwcChart: IChartApi;
  private associatedPane: Pane;

  private subscriptions = new Subscription();

  constructor({
    id,
    lwcChart,
    dataSource,
    zIndex,
    onDelete,
    moveUp,
    moveDown,
    mainSymbol$,
    paneManager,
    config,
  }: IndicatorParams) {
    super({ id: id as string, zIndex, onDelete, moveUp, moveDown });
    this.lwcChart = lwcChart;

    const indicatorConfig = indicatorsMap[id as IndicatorsIds] ?? config;

    const { series, newPane } = ensureDefined(indicatorConfig);

    this.associatedPane = newPane ? paneManager.addPane() : paneManager.getMainPane();

    series.forEach(({ name, id: serieId, dataFormatter, seriesOptions, priceScaleOptions }) => {
      const serie = SeriesFactory.create(name!)({
        lwcChart,
        dataSource,
        customFormatter: dataFormatter,
        seriesOptions,
        priceScaleOptions,
        mainSymbol$,
        mainSerie$: this.associatedPane.getMainSerie(),
        showSymbolLabel: false,
        paneIndex: this.associatedPane.getId(),
        indicatorReference: this,
      });

      this.seriesMap.set(serieId, serie);

      this.series.push(serie);
    });

    this.associatedPane.setIndicator(id as IndicatorsIds, this);
  }

  public subscribeDataChange(handler: () => void): Subscription {
    // todo: возможно нужно переписать завязавшись гна this.associatedPane.getMainSerie()
    const firer = new BehaviorSubject<number>(0);
    this.series.forEach((serie) => {
      const handle = () => {
        firer.next(firer.value + 1);
      };

      serie.subscribeDataChanged(handle);

      this.subscriptions.add(() => serie.unsubscribeDataChanged(handle));
    });

    return firer.pipe(throttleTime(500)).subscribe(() => {
      handler();
    });
  }

  public getPane(): Pane {
    return this.associatedPane;
  }

  public getSeriesMap(): Map<string, SeriesStrategies> {
    return this.seriesMap;
  }

  public show() {
    this.series.forEach((s) => {
      s.show();
    });
    super.show();
  }

  public hide() {
    this.series.forEach((s) => {
      s.hide();
    });
    super.hide();
  }

  // destroy и delete принципиально отличаются!
  // delete вызовет destroy в конце концов. По сути - это destroy с сайд-эффектом в eventManager
  public delete() {
    super.delete();
  }

  // destroy и delete принципиально отличаются!
  public destroy() {
    this.subscriptions.unsubscribe();
    this.series.forEach((s) => {
      s.destroy();
    });
    this.associatedPane.removeIndicator(this.id as IndicatorsIds);
  }
}



import {
  DeepPartial,
  PriceScaleMode,
  PriceScaleOptions,
  SeriesDataItemTypeMap,
  SeriesPartialOptionsMap,
  SeriesType,
  Time,
} from 'lightweight-charts';

import { Indicator } from '@core/Indicator';
import { emaIndicator } from '@core/Indicators/ema';
import { macdHist, macdLine, macdSignal } from '@core/Indicators/macd';
import { smaIndicator } from '@core/Indicators/sma';
import { volume } from '@core/Indicators/volume';
import { SerieData } from '@core/Series/BaseSeries';

import { Candle, ChartSeriesType } from '@lib';
import { getThemeStore } from '@src/theme';
import { Direction, LineCandle } from '@src/types';

export interface IndicatorConfig {
  series: {
    id: string;
    name: ChartSeriesType;
    seriesOptions?: SeriesPartialOptionsMap[ChartSeriesType];
    priceScaleOptions?: DeepPartial<PriceScaleOptions>;
    priceScaleId?: string;
    dataFormatter?<T extends SeriesType>(params: IndicatorDataFormatter<T>): SeriesDataItemTypeMap<Time>[T][];
  }[];
  newPane?: boolean;
}

export enum IndicatorsIds {
  'vol' = 'vol',
  'sma' = 'sma',
  'ema' = 'ema',
  'macd' = 'macd',
}

export type ChartTypeToCandleData = {
  ['Bar']: Candle;
  ['Candlestick']: Candle;
  ['Area']: LineCandle;
  ['Baseline']: LineCandle;
  ['Line']: LineCandle;
  ['Histogram']: LineCandle;
  ['Custom']: Candle;
};

export interface IndicatorDataFormatter<T extends SeriesType> {
  mainSeriesData: SerieData[];
  selfData: ChartTypeToCandleData[T][];
  candle?: SerieData;
  indicatorReference?: Indicator;
}

export const indicatorsMap: Partial<Record<IndicatorsIds, IndicatorConfig>> = {
  [IndicatorsIds.vol]: {
    series: [
      {
        name: 'Histogram',
        id: 'volume',
        priceScaleOptions: {
          scaleMargins: { top: 0.7, bottom: 0 },
        },
        seriesOptions: {
          priceScaleId: 'vol',
          priceFormat: {
            type: 'volume',
          },
        },
        dataFormatter: (params) => volume(params as IndicatorDataFormatter<'Histogram'>),
      },
    ],
  },
  [IndicatorsIds.sma]: {
    series: [
      {
        name: 'Line', // todo: change with enum
        id: 'sma',
        seriesOptions: {
          color: getThemeStore().colors.indicatorLineSma,
        },
        dataFormatter: (params) => smaIndicator(params as IndicatorDataFormatter<'Line'>, 10),
      },
    ],
  },
  [IndicatorsIds.ema]: {
    series: [
      {
        name: 'Line', // todo: change with enum
        id: 'ema',
        seriesOptions: {
          color: getThemeStore().colors.indicatorLineEma,
        },
        dataFormatter: (params) => {
          return emaIndicator(params as IndicatorDataFormatter<'Line'>, 25);
        },
      },
    ],
  },
  [IndicatorsIds.macd]: {
    newPane: true,
    series: [
      {
        name: 'Line', // todo: change with enum
        id: 'longEma',
        dataFormatter: (params) => {
          return emaIndicator(params as IndicatorDataFormatter<'Line'>, 26);
        },
        seriesOptions: {
          priceScaleId: 'macd_emas',
          visible: false,
          lastValueVisible: false,
          color: getThemeStore().colors.chartPriceLineText,
        },
      },
      {
        name: 'Line', // todo: change with enum
        id: 'shortEma',
        dataFormatter: (params) => {
          return emaIndicator(params as IndicatorDataFormatter<'Line'>, 12);
        },
        seriesOptions: {
          priceScaleId: 'macd_emas',
          visible: false,
          lastValueVisible: false,
          color: getThemeStore().colors.chartPriceLineText,
        },
      },
      {
        name: 'Line', // todo: change with enum
        id: 'macd',
        priceScaleOptions: {
          mode: PriceScaleMode.Normal,
        },
        dataFormatter: (params) => macdLine(params as IndicatorDataFormatter<'Line'>),
        seriesOptions: {
          priceScaleId: Direction.Right,
          lastValueVisible: false,
          color: getThemeStore().colors.indicatorLineSma,
        },
      },
      {
        name: 'Line', // todo: change with enum
        id: 'macd_signal',
        priceScaleOptions: {
          mode: PriceScaleMode.Normal,
        },
        dataFormatter: (params) => macdSignal(params as IndicatorDataFormatter<'Line'>),
        seriesOptions: {
          priceScaleId: Direction.Right,
          lastValueVisible: false,
          color: getThemeStore().colors.chartCandleWickUp,
        },
      },
      {
        name: 'Histogram', // todo: change with enum
        id: 'hist',
        priceScaleOptions: {
          autoScale: true,
        },
        seriesOptions: {
          priceScaleId: Direction.Right,
          lastValueVisible: false,
        },
        dataFormatter: (params) => macdHist(params as IndicatorDataFormatter<'Histogram'>),
      },
    ],
  },
};