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


import { Divider } from 'exchange-elements/v2';
import { Time, UTCTimestamp } from 'lightweight-charts';
import { Fragment, useLayoutEffect, useRef, useState } from 'react';

import { Observable } from 'rxjs';

import { LegendModel, Ohlc } from '@core/Legend';
import { TooltipVM } from '@core/Tooltip';

import { getThemeStore } from '@src/theme';
import { OHLCConfig, TimeFormat, Timeframes, TooltipConfig } from '@src/types';
import { Defaults } from '@src/types/defaults';

import { calcTooltipPosition, shouldShowTime, useObservable } from '@src/utils';
import { DateFormat, formatDate, formatDisplayText } from '@src/utils/formatter';

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

export interface TooltipProps {
  formatObs: Observable<{ dateFormat: DateFormat; timeFormat: TimeFormat }>;
  timeframeObs: Observable<Timeframes>;
  tooltipConfig: TooltipConfig;
  viewModel: Observable<TooltipVM>;
  ohlcConfig: OHLCConfig;
}

const TOOLTIP_MARGIN = 15;

export function ChartTooltip({ formatObs, timeframeObs, tooltipConfig, viewModel: vm }: TooltipProps) {
  const viewModel = useObservable(vm);
  const format = useObservable<{ dateFormat: DateFormat; timeFormat: TimeFormat }>(formatObs);
  const timeframe = useObservable<Timeframes>(timeframeObs);

  const refTooltip = useRef<HTMLDivElement | null>(null);
  const [tooltipSize, setTooltipSize] = useState({ width: 0, height: 0 });

  const { colors } = getThemeStore();

  const { visible, position, viewport, vm: vmLegend } = viewModel ?? {};
  const showTime = timeframe ? shouldShowTime(timeframe) : true;

  useLayoutEffect(() => {
    const element = refTooltip.current;

    if (!element || !visible) {
      return;
    }

    setTooltipSize({
      width: element.offsetWidth,
      height: element.offsetHeight,
    });
  }, [visible, vmLegend]);

  if (!visible || !position || !viewport) {
    return null;
  }

  const coords = calcTooltipPosition({
    position,
    viewport,
    tooltipSize,
    margin: TOOLTIP_MARGIN,
  });

  const style = {
    backgroundColor: colors.chartTooltipBackground,
    left: `${coords.left}px`,
    top: `${coords.top}px`,
  };

  const renderRow = (key: string, label: string | undefined, value: unknown) => {
    const text = formatDisplayText(value);

    if (!label || !text) {
      return null;
    }

    return (
      <div
        key={key}
        className={styles.row}
      >
        <span className={styles.label}>{label}</span>
        <span className={styles.value}>{text}</span>
      </div>
    );
  };

  const getEntries = (values: LegendModel[number]['values']) => {
    return values instanceof Map ? Array.from(values.entries()) : Object.entries(values);
  };

  return (
    <div
      className={styles.tooltip}
      style={style}
      ref={refTooltip}
    >
      {vmLegend?.map((indicator) => {
        if (indicator.isIndicator) {
          const entries = getEntries(indicator.values).filter(([, entry]) => Boolean(entry));
          const showSeriesLabel = entries.length > 1;

          return entries.map(([key, entry]) => {
            if (!entry) {
              return null;
            }

            return renderRow(
              `${indicator.id}-${String(key)}`,
              showSeriesLabel ? entry.name || String(indicator.name) : String(indicator.name),
              entry.value,
            );
          });
        }

        const entries = Object.fromEntries(getEntries(indicator.values)) as Partial<
          Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>
        >;

        return (
          <Fragment key={indicator.id}>
            {tooltipConfig.time?.visible &&
              entries.time?.value &&
              renderRow(
                `${indicator.id}-time`,
                tooltipConfig.time.label,
                formatDate(
                  entries.time.value as UTCTimestamp,
                  format?.dateFormat ?? Defaults.dateFormat,
                  format?.timeFormat ?? Defaults.timeFormat,
                  showTime,
                ),
              )}

            {((tooltipConfig.close?.visible && entries.close?.value) ||
              (tooltipConfig.open?.visible && entries.open?.value) ||
              (tooltipConfig.value?.visible && entries.value?.value)) && (
              <Divider
                direction="horizontal"
                pt={{ divider: { className: styles.divider } }}
              />
            )}

            {tooltipConfig.close?.visible &&
              renderRow(`${indicator.id}-close`, tooltipConfig.close.label, entries.close?.value)}

            {tooltipConfig.value?.visible &&
              renderRow(`${indicator.id}-value`, tooltipConfig.value.label, entries.value?.value)}

            {tooltipConfig.open?.visible &&
              renderRow(`${indicator.id}-open`, tooltipConfig.open.label, entries.open?.value)}

            {((tooltipConfig.high?.visible && entries.high?.value) ||
              (tooltipConfig.low?.visible && entries.low?.value) ||
              (tooltipConfig.change?.visible && entries.absoluteChange?.value) ||
              (tooltipConfig.change?.visible && entries.percentageChange?.value)) && (
              <Divider
                direction="horizontal"
                pt={{ divider: { className: styles.divider } }}
              />
            )}

            {tooltipConfig.high?.visible &&
              renderRow(`${indicator.id}-high`, tooltipConfig.high.label, entries.high?.value)}

            {tooltipConfig.low?.visible &&
              renderRow(`${indicator.id}-low`, tooltipConfig.low.label, entries.low?.value)}

            {tooltipConfig.change?.visible &&
              renderRow(`${indicator.id}-absolute-change`, 'Абсолютное изменение', entries.absoluteChange?.value)}

            {tooltipConfig.change?.visible &&
              renderRow(
                `${indicator.id}-percentage-change`,
                'Относительное изменение',
                entries.percentageChange?.value,
              )}
          </Fragment>
        );
      })}
    </div>
  );
}



import { MouseEventParams, Point, Time } from 'lightweight-charts';

import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';

import { ChartMouseEvents } from '@core/ChartMouseEvents';

import { EventManager } from '@core/EventManager';
import { Indicator } from '@core/Indicator';
import { IndicatorsIds } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { CompareMode, IndicatorLabel, OHLCConfig } from '@src/types';

export interface LegendParams {
  config: OHLCConfig;
  eventManager: EventManager;
  indicators: BehaviorSubject<Map<string, Indicator>>;
  subscribeChartEvent: ChartMouseEvents['subscribe'];
  mainSeries: BehaviorSubject<SeriesStrategies | null> | null;
  paneId: number;
  paneIndex: () => number;
  openIndicatorSettings: (id: IndicatorsIds, indicator: Indicator) => void;
}

export interface Ohlc {
  time: Time;
  open?: number;
  high?: number;
  low?: number;
  close?: number;
  value?: number;
  absoluteChange?: number;
  percentageChange?: number;
}

export interface CompareLegendItem {
  symbolId: string;
  symbol: string;
  symbolName: string;
  mode: CompareMode;
  value: string;
  color: string;
}

export interface LegendVM {
  vm: LegendModel;
}

export type LegendModel = {
  id: string;
  name: IndicatorLabel | string;
  isIndicator: boolean;
  values: Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>>;
  remove?: () => void;
  settings?: () => void;
}[];

export const ohlcValuesToShowForMainSerie = [
  // 'time',
  'open',
  'high',
  'low',
  'close',
  'value',
  // 'absoluteChange',
  'percentageChange',
];

export const ohlcValuesToShowForIndicators = [
  // 'time',
  'open',
  // 'high',
  // 'low',
  // 'close',
  'value',
  // 'absoluteChange',
  // 'percentageChange'
];

export class Legend {
  private eventManager: EventManager;
  private indicators: Map<string, Indicator> = new Map();
  private config: OHLCConfig;

  private mainSeries!: SeriesStrategies;
  private mainSymbolName = '';
  private isChartHovered = false;
  private model$ = new BehaviorSubject<LegendModel>([]);
  private tooltipVisability = new BehaviorSubject<boolean>(false);
  private tooltipPos = new BehaviorSubject<null | Point>(null);
  private paneId: number;
  private paneIndex: () => number;

  private openIndicatorSettings: (id: IndicatorsIds, indicator: Indicator) => void;
  private subscriptions = new Subscription();
  private indicatorSubscriptions = new Subscription();
  private mainSeriesSubscription = new Subscription();

  constructor({
    config,
    eventManager,
    indicators,
    subscribeChartEvent,
    mainSeries,
    paneId,
    paneIndex,
    openIndicatorSettings,
  }: LegendParams) {
    this.config = config;
    this.eventManager = eventManager;
    this.paneId = paneId;
    this.paneIndex = paneIndex;
    this.openIndicatorSettings = openIndicatorSettings;

    this.subscriptions.add(
      this.eventManager.symbolName().subscribe((symbolName) => {
        this.mainSymbolName = symbolName;
        this.updateWithLastCandle();
      }),
    );

    if (!mainSeries) {
      this.subscriptions.add(
        indicators.subscribe((value: Map<string, Indicator>) => {
          this.indicators = value;
          this.handleIndicatorSeriesDataChange();
        }),
      );
    } else {
      this.subscriptions.add(
        combineLatest([mainSeries, indicators]).subscribe(([mainSerie, inds]) => {
          if (!mainSerie) {
            return;
          }
          this.mainSeries = mainSerie;
          this.indicators = inds;
          this.handleMainSeriesChange();
          this.handleIndicatorSeriesDataChange();
        }),
      );
    }
    this.subscriptions.add(subscribeChartEvent('crosshairMove', this.handleCrosshairMove));
  }

  public subscribeCursorPosition(cb: (point: Point | null) => void) {
    this.subscriptions.add(this.tooltipPos.subscribe(cb));
  }

  public subscribeCursorVisability(cb: (isVisible: boolean) => void) {
    this.subscriptions.add(this.tooltipVisability.subscribe(cb));
  }

  private handleIndicatorSeriesDataChange = () => {
    this.indicatorSubscriptions.unsubscribe();
    this.indicatorSubscriptions = new Subscription();

    for (const [_, indicator] of this.indicators) {
      this.indicatorSubscriptions.add(indicator.subscribeDataChange(this.updateWithLastCandle));
    }

    this.updateWithLastCandle();
  };

  private handleMainSeriesChange = () => {
    this.mainSeriesSubscription.unsubscribe();
    this.mainSeriesSubscription = new Subscription();

    const handler = () => this.updateWithLastCandle();

    this.mainSeries?.subscribeDataChanged(handler);

    this.mainSeriesSubscription.add(() => this.mainSeries?.unsubscribeDataChanged(handler));
  };

  private updateWithLastCandle = () => {
    if (this.isChartHovered) return;

    const model: LegendModel = [];

    if (this.mainSeries) {
      const series = new Map();

      const serieData = this.mainSeries.getLegendData();

      Object.entries(serieData).forEach(([key, sd]) => {
        series.set(`${key}`, {
          ...sd,
        });
      });

      model.push({
        id: `main-series-${this.paneId}`,
        name: this.mainSymbolName,
        values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
        isIndicator: false,
      });
    }

    if (!this.indicators) {
      return;
    }

    for (const [_, indicator] of this.indicators) {
      const indicatorType = indicator.getType();
      const indicatorSeries = new Map();

      for (const [serieName, serie] of indicator.getSeriesMap()) {
        if (!serie.isVisible()) {
          continue;
        }

        const serieData = serie.getLegendData();
        const value = serieData.value ?? serieData.close;

        if (!value) continue;

        indicatorSeries.set(serieName, { ...value, name: indicator.getSeriesLabel(serieName) });
      }

      model.push({
        id: indicator.getId(),
        name: indicator.getLabel(),
        values: indicatorSeries as Partial<
          Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>
        >,
        isIndicator: true,
        remove: () => indicator.delete(),
        settings:
          indicatorType && indicator.hasSettings()
            ? () => this.openIndicatorSettings(indicatorType, indicator)
            : undefined,
      });
    }

    this.model$.next(model);
  };

  private handleCrosshairMove = (param: MouseEventParams) => {
    // todo: есть одинаковый код с updateWithLastCandle
    if (param.point === undefined || !param.time || param.point.x < 0 || param.point.y < 0) {
      this.tooltipVisability.next(false);

      this.isChartHovered = false;
      this.updateWithLastCandle();
      return;
    }

    if (param.seriesData.size === 0) {
      this.updateWithLastCandle();
      return;
    }

    if (this.paneIndex() === param.paneIndex) {
      this.tooltipVisability.next(true);
    } else {
      this.tooltipVisability.next(false);
    }

    this.isChartHovered = true;

    const model: LegendModel = [];

    if (this.mainSeries) {
      const series = new Map();
      const serieData = this.mainSeries.getLegendData(param);

      Object.entries(serieData).forEach(([key, sd]) => {
        series.set(`${key}`, {
          ...sd,
        });
      });

      model.push({
        id: `main-series-${this.paneId}`,
        name: this.mainSymbolName,
        values: series as Partial<Record<keyof Ohlc, { value: number | string | Time; color: string; name: string }>>,
        isIndicator: false,
      });
    }

    if (!this.indicators) {
      return;
    }

    for (const [_, indicator] of this.indicators) {
      const indicatorType = indicator.getType();
      const indicatorSeries = new Map();

      for (const [serieName, serie] of indicator.getSeriesMap()) {
        if (!serie.isVisible()) {
          continue;
        }

        const serieData = serie.getLegendData(param);
        const value = serieData.value ?? serieData.close;

        if (!value) continue;

        indicatorSeries.set(serieName, { ...value, name: indicator.getSeriesLabel(serieName) });
      }

      model.push({
        id: indicator.getId(),
        name: indicator.getLabel(),
        values: indicatorSeries as Partial<
          Record<keyof Ohlc, { value: number | string | Time; color: string; name?: string }>
        >,
        isIndicator: true,
        remove: () => indicator.delete(),
        settings:
          indicatorType && indicator.hasSettings()
            ? () => this.openIndicatorSettings(indicatorType, indicator)
            : undefined,
      });
    }

    this.model$.next(model);
    this.tooltipPos.next(param.point);
  };

  public getConfig(): OHLCConfig {
    return this.config;
  }

  public getLegendViewModel(): Observable<LegendModel> {
    return this.model$;
  }

  public destroy = () => {
    this.subscriptions.unsubscribe();
    this.indicatorSubscriptions.unsubscribe();
    this.mainSeriesSubscription.unsubscribe();

    this.model$.complete();
    this.tooltipVisability.complete();
    this.tooltipPos.complete();
  };
}



import { Button } from 'exchange-elements/v2';

import { Observable } from 'rxjs';

import { GearIcon, TrashIcon } from '@components/Icon';
import { LegendModel, ohlcValuesToShowForMainSerie } from '@core/Legend';

import { OHLCConfig } from '@src/types';
import { formatDisplayText, useObservable } from '@src/utils';

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

export interface LegendProps {
  ohlcConfig?: OHLCConfig;
  viewModel: Observable<LegendModel>;
}

const mainSerieKeys = new Set(ohlcValuesToShowForMainSerie);

function getEntries(values: LegendModel[number]['values']) {
  return values instanceof Map ? Array.from(values.entries()) : Object.entries(values);
}

export const LegendComponent = ({ ohlcConfig, viewModel }: LegendProps) => {
  const model = useObservable(viewModel);
  const showMainSerieValues = Boolean(ohlcConfig?.show);

  return (
    <section className={styles.legend}>
      {model?.map((item) => (
        <div
          key={item.id}
          className={styles.row}
        >
          <div className={styles.symbol}>{item.name}</div>

          {getEntries(item.values).map(([key, value]) => {
            if (!item.isIndicator && (!showMainSerieValues || !mainSerieKeys.has(key))) {
              return null;
            }

            if (!value) {
              return null;
            }

            const text = formatDisplayText(value.value);

            if (!text) {
              return null;
            }

            return (
              <div
                key={`${item.name}-${key}`}
                className={styles.item}
              >
                {!item.isIndicator && value.name && <span>{value.name}</span>}
                <span style={{ color: value.color }}>{text ?? '-'}</span>
              </div>
            );
          })}

          {item.settings && (
            <Button
              size="sm"
              className={styles.button}
              onClick={item.settings}
              label={<GearIcon />}
            />
          )}

          {item.remove && (
            <Button
              size="sm"
              className={styles.button}
              onClick={item.remove}
              label={<TrashIcon />}
            />
          )}
        </div>
      ))}
    </section>
  );
};