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


import { useEffect, useRef, useState } from 'react';

import { DateFormat, IMoexChart, Locale, MoexChart, Timeframes } from '@lib';

import { Portal } from '@lib/components/Portal';
import { IndicatorsIds } from '@lib/constants';
import { CompareManager } from '@lib/core/CompareManager';
import { CompareMode } from '@lib/types';

import { dataSourceProvider } from '../common';

import type { Meta, StoryObj } from '@storybook/react';

/**
 * ## MB story
 * describes an entry point of MoexChart for MB user
 */

type MBProps = Omit<IMoexChart, 'container'>;

// todo: переписат ьпод новую сигнатуру
const MBEntry = (props: MBProps) => {
  const [isCompareOpen, setIsCompareOpen] = useState(false);
  const [moexChart, setMoexChart] = useState<MoexChart | undefined>(undefined);

  const containerRef = useRef<HTMLDivElement | null>(null);

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

    if (!container) {
      return;
    }

    const chart = new MoexChart({
      ...props,
      container,
      chartCollectionPreset: {
        ...props.chartCollectionPreset,
        openCompareModal: () => setIsCompareOpen(true),
      },
    });

    setMoexChart(chart);

    return () => {
      chart.destroy();
    };
  }, [props]);

  return (
    <div
      style={{
        width: '100%',
        height: '100dvh',
        display: 'grid',
        gridTemplateRows: 'auto minmax(0, 1fr)',
        gap: 10,
        padding: 20,
        boxSizing: 'border-box',
      }}
    >
      <h3
        style={{
          margin: 0,
        }}
      >
        MB usage
      </h3>
      <div ref={containerRef} />
      {isCompareOpen && (
        <Portal>
          <Modal
            onClose={() => setIsCompareOpen(false)}
            compareManager={moexChart?.getCompareManager() ?? null}
          />
        </Portal>
      )}
    </div>
  );
};

const meta: Meta<MBProps> = {
  title: 'MB',
  component: MBEntry,
  // argTypes, // todo: пофиксить вместе с переписыванием доки
  parameters: {
    layout: 'fullscreen',
    docs: {
      description: {
        component: `## MB story
  describes an entry point of MoexChart for MB user`,
      },
    },
  },
};

export default meta;

type Story = StoryObj<typeof meta>;

const args: MBProps = {
  snapshot: {
    charts: [
      {
        timeframe: Timeframes['10s'],
        chartSeriesType: 'Candlestick',
        symbol: 'APPL',
        panes: [
          {
            // empty panes deletes automatically
            isMain: true, // Be careful. There is only one main pane can be present
            id: 0,
            indicators: [
              {
                indicatorType: IndicatorsIds.Volume,
              },
            ],
            drawings: [],
          },
        ],
      },
    ],
  },
  chartCollectionPreset: {
    undoRedoEnabled: true,
    showMenuButton: true,
    showBottomPanel: true,
    showControlBar: true,
    showFullscreenButton: true,
    showSettingsButton: true,
    showCompareButton: false,
    tooltipConfig: {
      showTooltip: false,
      time: { visible: true, label: 'Время' },
      close: { visible: true, label: 'Закр.' },
      change: { visible: true, label: 'Изм.' },
      volume: { visible: true, label: 'Объем' },
      open: { visible: true, label: 'Откр.' },
      high: { visible: true, label: 'Макс.' },
      low: { visible: true, label: 'Мин.' },
    },

    supportedTimeframes: [
      Timeframes['1s'],
      Timeframes['5s'],
      Timeframes['10s'],
      Timeframes['1m'],
      Timeframes['2m'],
      Timeframes['30m'],
      Timeframes['1h'],
      Timeframes['2h'],
      Timeframes['1d'],
      Timeframes['1w'],
    ],
    supportedChartSeriesTypes: ['Candlestick', 'Line', 'Bar'],
    getDataSource: dataSourceProvider.generateCandles.bind(dataSourceProvider),
    startRealtime: (getSymbols, getTimeframe, update) =>
      dataSourceProvider.startRealtime(getSymbols, getTimeframe, update), // should return unsub
    theme: 'mb',
    ohlc: {
      show: true,
      precision: 2,
    },
    mode: 'dark',
    locale: Locale.rus,
  },

  lwcInheritedChartOptions: {
    timeVisible: true,
    secondsVisible: false,
    timeFormat: '24h',
    dateFormat: DateFormat.DD_MM_YYYY_HH_mm_ss,
  },
};

export const MB: Story = {
  args,
  parameters: {
    controls: {
      expanded: true, // отвечает за расширение колонок(+Description, +Default) в табе controls
    },
  },
};

const Modal = ({ onClose, compareManager }: { onClose: () => void; compareManager: CompareManager | null }) => {
  const [isNewScaleDisabled, setIsNewScaleDisabled] = useState(false);

  useEffect(() => {
    if (!compareManager) {
      setIsNewScaleDisabled(false);
      return;
    }

    setIsNewScaleDisabled(compareManager.isNewScaleDisabled());

    const subscription = compareManager.isNewScaleDisabledObservable().subscribe(setIsNewScaleDisabled);

    return () => subscription.unsubscribe();
  }, [compareManager]);

  const { mode = 'light' } = args.chartCollectionPreset;
  const containerStyles =
    mode === 'light' ? { backgroundColor: 'white', color: 'black' } : { backgroundColor: 'black', color: 'white' };
  const buttonStyles =
    mode === 'light'
      ? { backgroundColor: 'lightgray', color: 'black', padding: '2px 8px' }
      : { backgroundColor: 'darkgray', color: 'black', padding: '2px 8px' };

  return (
    <div
      onClick={onClose}
      style={{
        width: '100%',
        height: '100%',
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
        backgroundColor: '#0000004D',
        position: 'absolute',
        top: '50%',
        left: '50%',
        transform: 'translate(-50%, -50%)',
        zIndex: 999,
        cursor: 'pointer',
      }}
    >
      <div
        onClick={(e) => e.stopPropagation()}
        style={{
          display: 'grid',
          gap: 16,
          padding: 16,
          ...containerStyles,
        }}
      >
        {['SBER', 'APAX', 'SOL'].map((symbol) => (
          <div
            key={symbol}
            style={{ display: 'flex', justifyContent: 'space-between', gap: 16 }}
          >
            <span>{symbol}</span>
            <div style={{ display: 'flex', gap: 8 }}>
              <button
                onClick={() => compareManager?.setSymbolMode('Line', symbol, CompareMode.Percentage)}
                style={buttonStyles}
                type="button"
              >
                %
              </button>
              <button
                onClick={() => compareManager?.setSymbolMode('Line', symbol, CompareMode.NewScale)}
                style={{
                  backgroundColor: isNewScaleDisabled ? 'darkgray' : 'lightgray',
                  padding: '2px 8px',
                  cursor: isNewScaleDisabled ? 'not-allowed' : 'cursor',
                }}
                disabled={isNewScaleDisabled}
                type="button"
              >
                Новая шкала
              </button>
              <button
                onClick={() => compareManager?.setSymbolMode('Line', symbol, CompareMode.NewPane)}
                style={buttonStyles}
                type="button"
              >
                Новая панель
              </button>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
};



import { IChartApi, PriceScaleMode, SeriesType } from 'lightweight-charts';

import { flatten } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, Observable, Subscription } from 'rxjs';

import { DataSource } from '@core/DataSource';
import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorManager } from '@core/IndicatorManager';
import { PaneManager } from '@core/PaneManager';
import { PriceScale } from '@core/PriceScale';
import { COMPARE_COLOR_PALETTE } from '@src/theme';
import { CompareInstrument, CompareItem, CompareMode, Direction, IndicatorConfig } from '@src/types';
import { IndicatorSnapshot } from '@src/types/snapshot';
import { createFallbackColor, normalizeColor, normalizeSymbol } from '@src/utils';

interface CompareEntry extends CompareItem {
  key: string;
  symbol$: BehaviorSubject<string>;
  entity: Indicator;
}

interface CompareManagerParams {
  chart: IChartApi;
  eventManager: EventManager;
  dataSource: DataSource;
  indicatorManager: IndicatorManager;
  paneManager: PaneManager;
  initialIndicators?: IndicatorSnapshot[];
}

export class CompareManager {
  private readonly chart: IChartApi;
  private readonly eventManager: EventManager;
  private readonly dataSource: DataSource;
  private readonly indicatorManager: IndicatorManager;
  private readonly paneManager: PaneManager;
  private readonly entries = new Map<string, CompareEntry>();
  private readonly itemsSubject = new BehaviorSubject<CompareItem[]>([]);
  private readonly entitiesSubject = new BehaviorSubject<Indicator[]>([]);
  private readonly subscriptions = new Subscription();

  private percentageComparisonActive = false;
  private restoringInitialIndicators = false;

  constructor({
    chart,
    eventManager,
    dataSource,
    indicatorManager,
    paneManager,
    initialIndicators = [],
  }: CompareManagerParams) {
    this.chart = chart;
    this.eventManager = eventManager;
    this.dataSource = dataSource;
    this.indicatorManager = indicatorManager;
    this.paneManager = paneManager;

    this.subscriptions.add(
      this.eventManager.timeframe().subscribe(() => {
        this.applyPolicy();
      }),
    );

    this.setup(initialIndicators);
  }

  public itemsObs(): Observable<CompareItem[]> {
    return this.itemsSubject.asObservable();
  }

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

  public clear(): void {
    const keys = Array.from(this.entries.keys());

    for (let index = 0; index < keys.length; index += 1) {
      this.removeEntry(keys[index]);
    }

    this.commitEntriesChange();
  }

  public async setSymbolMode(
    seriesType: SeriesType,
    instrument: CompareInstrument,
    mode: CompareMode,
    paneId?: number,
  ): Promise<void> {
    const symbol = normalizeSymbol(instrument.symbol);

    if (!symbol) {
      return;
    }

    const symbolName = instrument.symbolName.trim() || symbol;

    if (mode === CompareMode.NewScale && this.isNewScaleDisabled() && !this.restoringInitialIndicators) {
      return;
    }

    const key = makeKey(symbol, mode);

    if (this.entries.has(key)) {
      return;
    }

    const symbol$ = new BehaviorSubject(symbol);

    const entity = this.indicatorManager.addEntity<Indicator>((zIndex, moveUp, moveDown) => {
      const usedColorsByCompare = this.entitiesSubject.value.map(
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        (indicator) => indicator.getConfig().series?.[0]?.seriesOptions?.color,
      );

      const existingIndicators = Array.from(this.indicatorManager.getIndicators().value.values());

      const usedColorsByIndicatorsRaw = existingIndicators.map((indicator) =>
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        indicator.config?.series?.map((series) => series.seriesOptions?.color),
      );

      const usedColorsByIndicators = flatten(usedColorsByIndicatorsRaw).filter((color) => color !== undefined);
      const usedColors = usedColorsByCompare.concat(usedColorsByIndicators);
      const config = getDefaultCompareIndicatorConfig(seriesType, symbol, symbolName, usedColors);

      const associatedPane =
        mode === CompareMode.NewPane
          ? paneId !== undefined
            ? (this.paneManager.getPaneById(paneId) ?? this.paneManager.addPane())
            : this.paneManager.addPane()
          : this.paneManager.getMainPane();

      return new Indicator({
        id: key,
        lwcChart: this.chart,
        mainSymbol$: symbol$,
        dataSource: this.dataSource,
        associatedPane,
        config: {
          ...config,
          series: [
            {
              ...config.series[0],
              seriesOptions: {
                ...config.series[0]?.seriesOptions,
                priceScaleId: mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
              },
            },
          ],
          newPane: mode === CompareMode.NewPane,
        },
        zIndex,
        onDelete: () => {
          if (this.removeEntry(key)) {
            this.commitEntriesChange();
          }
        },
        moveUp,
        moveDown,
        paneId: associatedPane.getId(),
      });
    });

    this.entries.set(key, {
      key,
      symbol,
      symbolName,
      mode,
      symbol$,
      entity,
    });

    this.commitEntriesChange();

    await this.dataSource.isReady(symbol);
  }

  public removeSymbolMode(symbolRaw: string, mode: CompareMode): void {
    const symbol = normalizeSymbol(symbolRaw);

    if (!symbol) {
      return;
    }

    if (this.removeEntry(makeKey(symbol, mode))) {
      this.commitEntriesChange();
    }
  }

  public removeSymbol(symbolRaw: string): void {
    const symbol = normalizeSymbol(symbolRaw);

    if (!symbol) {
      return;
    }

    const entries = Array.from(this.entries.entries());
    let removed = false;

    for (let index = 0; index < entries.length; index += 1) {
      const [key, entry] = entries[index];

      if (entry.symbol !== symbol) {
        continue;
      }

      removed = this.removeEntry(key) || removed;
    }

    if (removed) {
      this.commitEntriesChange();
    }
  }

  public isNewScaleDisabled(): boolean {
    return this.itemsSubject.value.length > 0;
  }

  public isNewScaleDisabledObservable(): Observable<boolean> {
    return this.itemsSubject.pipe(
      map((items) => items.length > 0),
      distinctUntilChanged(),
    );
  }

  public getAllEntities() {
    return Array.from(this.entries.values()).map(({ symbol, symbolName, entity, mode }) => ({
      symbol,
      symbolName,
      entity,
      mode,
    }));
  }

  public destroy(): void {
    this.subscriptions.unsubscribe();
    this.clear();
    this.itemsSubject.complete();
    this.entitiesSubject.complete();
  }

  private async setup(initialIndicators: IndicatorSnapshot[]): Promise<void> {
    this.restoringInitialIndicators = true;

    try {
      for (const indicator of initialIndicators) {
        if (indicator.indicatorType !== undefined) {
          continue;
        }

        if (!indicator.config?.label) {
          continue;
        }

        const series = indicator.config.series[0];
        const symbol = indicator.config.symbol ?? indicator.config.label;

        const compareMode =
          series.seriesOptions?.priceScaleId === Direction.Left
            ? CompareMode.NewScale
            : indicator.config.newPane
              ? CompareMode.NewPane
              : CompareMode.Percentage;

        // eslint-disable-next-line no-await-in-loop
        await this.setSymbolMode(
          series.name,
          {
            symbol,
            symbolName: indicator.config.label,
          },
          compareMode,
          indicator.paneId,
        );
      }
    } finally {
      this.restoringInitialIndicators = false;
    }
  }

  private removeEntry(key: string): boolean {
    const entry = this.entries.get(key);

    if (!entry) {
      return false;
    }

    this.entries.delete(key);
    this.indicatorManager.removeEntity(entry.entity);
    entry.entity.destroy();
    entry.symbol$.complete();

    return true;
  }

  private commitEntriesChange(): void {
    this.applyPolicy();
    this.publish();
  }

  private publish(): void {
    const values = Array.from(this.entries.values());
    const items: CompareItem[] = [];
    const entities: Indicator[] = [];

    for (let index = 0; index < values.length; index += 1) {
      items.push({
        symbol: values[index].symbol,
        symbolName: values[index].symbolName,
        mode: values[index].mode,
      });

      entities.push(values[index].entity);
    }

    this.itemsSubject.next(items);
    this.entitiesSubject.next(entities);
  }

  private syncPercentageMode(priceScale: PriceScale, shouldEnablePercentageMode: boolean): void {
    if (this.restoringInitialIndicators) {
      this.percentageComparisonActive = shouldEnablePercentageMode;
      return;
    }

    if (this.percentageComparisonActive === shouldEnablePercentageMode) {
      return;
    }

    this.percentageComparisonActive = shouldEnablePercentageMode;

    if (shouldEnablePercentageMode) {
      priceScale.setMode(PriceScaleMode.Percentage);
      return;
    }

    if (priceScale.getMode() === PriceScaleMode.Percentage) {
      priceScale.setMode(PriceScaleMode.Normal);
    }
  }

  private applyPolicy(): void {
    const entries = Array.from(this.entries.values());

    let percentageComparisonActive = false;
    let newScaleComparisonActive = false;

    for (let index = 0; index < entries.length; index += 1) {
      if (entries[index].mode === CompareMode.Percentage) {
        percentageComparisonActive = true;
      }

      if (entries[index].mode === CompareMode.NewScale) {
        newScaleComparisonActive = true;
      }
    }

    for (let index = 0; index < entries.length; index += 1) {
      const entry = entries[index];
      const pane = entry.entity.getPane();

      // [0 - в индикаторах compare сущности может быть только одна серия] [1 - entry]
      const series = Array.from(entry.entity.getSeriesMap().values())[0];

      if (!series) {
        continue;
      }

      series.applyOptions({
        priceScaleId: pane.isMainPane() && entry.mode === CompareMode.NewScale ? Direction.Left : Direction.Right,
      });
    }

    this.paneManager.setPriceScaleSideVisible(Direction.Left, newScaleComparisonActive);
    this.paneManager.setPriceScaleSideVisible(Direction.Right, true);

    const mainRightPriceScale = this.paneManager.getMainPane().getPriceScale(Direction.Right);

    this.syncPercentageMode(mainRightPriceScale, percentageComparisonActive);

    this.paneManager.invalidate();
  }
}

function makeKey(symbol: string, mode: CompareMode): string {
  return `${symbol}|${mode}`;
}

function getPaletteColorFromIndex(usedColors: Set<string>, startIndex: number): string {
  for (let offset = 0; offset < COMPARE_COLOR_PALETTE.length; offset += 1) {
    const color = COMPARE_COLOR_PALETTE[(startIndex + offset) % COMPARE_COLOR_PALETTE.length];

    if (!usedColors.has(normalizeColor(color))) {
      return color;
    }
  }

  return createFallbackColor(usedColors.size);
}

const getDefaultCompareIndicatorConfig = (
  seriesType: SeriesType,
  symbol: string,
  symbolName: string,
  usedColors: string[],
): IndicatorConfig => {
  const reservedColors = new Set(usedColors.map(normalizeColor));

  return {
    symbol,
    newPane: true,
    label: symbolName,
    series: [
      {
        name: 'Line', // todo: change with enum
        id: `compare-${crypto.randomUUID()}`,
        seriesOptions: {
          visible: true,
          color: getPaletteColorFromIndex(reservedColors, 0),
        },
      },
    ],
  };
};

export enum CompareMode {
  Percentage = 'PCT',
  NewScale = 'SCALE',
  NewPane = 'PANE',
}

export interface CompareInstrument {
  symbol: string;
  symbolName: string;
  symbolTicker: string;
}
export interface CompareItem extends CompareInstrument {
  mode: CompareMode;
}