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


import { Divider } from 'exchange-elements/v2';
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 { TimeFormat, Timeframes, TooltipConfig } from '@src/types';
import { Defaults } from '@src/types/defaults';

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

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

import type { Time, UTCTimestamp } from 'lightweight-charts';
import type { DateFormat } from '@src/utils/formatter';

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

const TOOLTIP_MARGIN = 15;
const MAIN_FIELDS = ['close', 'value', 'open'] as const;
const ADDITIONAL_FIELDS = ['high', 'low', 'absoluteChange', 'percentageChange'] as const;

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

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

  return (
    <div
      className={styles.tooltip}
      style={{
        backgroundColor: colors.chartTooltipBackground,
        left: `${coords.left}px`,
        top: `${coords.top}px`,
      }}
      ref={refTooltip}
    >
      {vmLegend?.map((indicator) => {
        const values = getEntries(indicator.values);

        if (indicator.isIndicator) {
          const entries = values.filter(([, entry]) => Boolean(entry));
          const indicatorName = String(indicator.name);
          const showSeriesLabel = entries.length > 1;

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

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

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

        const mainFields = MAIN_FIELDS.filter(
          (key) => tooltipConfig[key]?.visible && entries[key]?.value != null,
        );

        const additionalFields = ADDITIONAL_FIELDS.filter(
          (key) => tooltipConfig[key]?.visible && entries[key]?.value != null,
        );

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

            {mainFields.length > 0 && (
              <Divider
                direction="horizontal"
                pt={{ divider: { className: styles.divider } }}
              />
            )}

            {mainFields.map((key) =>
              renderRow(`${indicator.id}-${key}`, tooltipConfig[key]?.label, entries[key]?.value),
            )}

            {additionalFields.length > 0 && (
              <Divider
                direction="horizontal"
                pt={{ divider: { className: styles.divider } }}
              />
            )}

            {additionalFields.map((key) =>
              renderRow(`${indicator.id}-${key}`, tooltipConfig[key]?.label, entries[key]?.value),
            )}
          </Fragment>
        );
      })}
    </div>
  );
}


import { render, screen } from '@testing-library/react';

import { BehaviorSubject } from 'rxjs';

import { ChartTooltip } from '@components/ChartTooltip';
import { TooltipVM } from '@core/Tooltip';

import type { TimeFormat, Timeframes, TooltipConfig } from '@src/types';
import type { DateFormat } from '@src/utils/formatter';

jest.mock('exchange-elements/v2', () => ({
  Divider: () => <hr data-testid="divider" />,
}));

jest.mock('@src/theme', () => ({
  getThemeStore: () => ({
    colors: {
      chartTooltipBackground: '#000000',
    },
  }),
}));

jest.mock('@src/types/defaults', () => ({
  Defaults: {
    dateFormat: 'Mon 29/09/1997',
    timeFormat: '24h',
    showTime: true,
  },
}));

jest.mock('@src/utils', () => ({
  calcTooltipPosition: () => ({
    left: 10,
    top: 20,
  }),
  shouldShowTime: () => true,
  useObservable: (observable: { getValue: () => unknown }) => observable.getValue(),
}));

jest.mock('@src/utils/formatter', () => ({
  formatDate: () => '01.01.2026',
  formatDisplayText: (value: unknown) => (value === null || value === undefined ? '' : String(value)),
}));

const formatObs = new BehaviorSubject<{
  dateFormat: DateFormat;
  timeFormat: TimeFormat;
}>({
  dateFormat: 'Mon 29/09/1997' as DateFormat,
  timeFormat: '24h' as TimeFormat,
});

const timeframeObs = new BehaviorSubject('1m' as Timeframes);

function createTooltipConfig(): TooltipConfig {
  return {
    time: {
      visible: false,
      label: 'Time',
    },
    close: {
      visible: false,
      label: 'Close',
    },
    value: {
      visible: false,
      label: 'Value',
    },
    open: {
      visible: false,
      label: 'Open',
    },
    high: {
      visible: false,
      label: 'High',
    },
    low: {
      visible: false,
      label: 'Low',
    },
    absoluteChange: {
      visible: false,
      label: 'Absolute Change',
    },
    percentageChange: {
      visible: false,
      label: 'Percentage Change',
    },
  };
}

function createViewModel(): BehaviorSubject<TooltipVM> {
  return new BehaviorSubject({
    visible: true,
    position: {
      x: 100,
      y: 100,
    },
    viewport: {
      width: 500,
      height: 300,
    },
    vm: [
      {
        id: 'main-series',
        name: 'AAPL',
        isIndicator: false,
        values: {
          time: {
            value: 1_700_000_000,
            color: 'white',
            name: 'Time',
          },
          open: {
            value: 100,
            color: 'white',
            name: 'Open',
          },
          high: {
            value: 110,
            color: 'white',
            name: 'High',
          },
          low: {
            value: 90,
            color: 'white',
            name: 'Low',
          },
          close: {
            value: 105,
            color: 'white',
            name: 'Close',
          },
          value: {
            value: 107,
            color: 'white',
            name: 'Value',
          },
          absoluteChange: {
            value: 10,
            color: 'green',
            name: 'Absolute Change',
          },
          percentageChange: {
            value: '10%',
            color: 'green',
            name: 'Percentage Change',
          },
        },
      },
    ],
  } as TooltipVM);
}

describe('ChartTooltip', () => {
  it('не должен отображаться при выключенной видимости', () => {
    const viewModel = createViewModel();

    viewModel.next({
      ...viewModel.getValue(),
      visible: false,
    });

    const { container } = render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={createTooltipConfig()}
        viewModel={viewModel}
      />,
    );

    expect(container.firstChild).toBeNull();
  });

  it('должен отображать время при включенной настройке', () => {
    const tooltipConfig = createTooltipConfig();

    tooltipConfig.time = {
      visible: true,
      label: 'Time',
    };

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={createViewModel()}
      />,
    );

    expect(screen.getByText('Time')).toBeTruthy();
    expect(screen.getByText('01.01.2026')).toBeTruthy();
  });

  it('должен отображать основные поля согласно конфигурации', () => {
    const tooltipConfig = createTooltipConfig();

    tooltipConfig.close = {
      visible: true,
      label: 'Close',
    };

    tooltipConfig.value = {
      visible: true,
      label: 'Value',
    };

    tooltipConfig.open = {
      visible: true,
      label: 'Open',
    };

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={createViewModel()}
      />,
    );

    expect(screen.getByText('Close')).toBeTruthy();
    expect(screen.getByText('105')).toBeTruthy();
    expect(screen.getByText('Value')).toBeTruthy();
    expect(screen.getByText('107')).toBeTruthy();
    expect(screen.getByText('Open')).toBeTruthy();
    expect(screen.getByText('100')).toBeTruthy();
  });

  it('должен отображать дополнительные поля согласно конфигурации', () => {
    const tooltipConfig = createTooltipConfig();

    tooltipConfig.high = {
      visible: true,
      label: 'High',
    };

    tooltipConfig.low = {
      visible: true,
      label: 'Low',
    };

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={createViewModel()}
      />,
    );

    expect(screen.getByText('High')).toBeTruthy();
    expect(screen.getByText('110')).toBeTruthy();
    expect(screen.getByText('Low')).toBeTruthy();
    expect(screen.getByText('90')).toBeTruthy();
  });

  it('должен отдельно отображать абсолютное изменение', () => {
    const tooltipConfig = createTooltipConfig();

    tooltipConfig.absoluteChange = {
      visible: true,
      label: 'Absolute Change',
    };

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={createViewModel()}
      />,
    );

    expect(screen.getByText('Absolute Change')).toBeTruthy();
    expect(screen.getByText('10')).toBeTruthy();

    expect(screen.queryByText('Percentage Change')).toBeNull();
    expect(screen.queryByText('10%')).toBeNull();
  });

  it('должен отдельно отображать относительное изменение', () => {
    const tooltipConfig = createTooltipConfig();

    tooltipConfig.percentageChange = {
      visible: true,
      label: 'Percentage Change',
    };

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={createViewModel()}
      />,
    );

    expect(screen.getByText('Percentage Change')).toBeTruthy();
    expect(screen.getByText('10%')).toBeTruthy();

    expect(screen.queryByText('Absolute Change')).toBeNull();
    expect(screen.queryByText('10')).toBeNull();
  });

  it('должен одновременно отображать абсолютное и относительное изменение', () => {
    const tooltipConfig = createTooltipConfig();

    tooltipConfig.absoluteChange = {
      visible: true,
      label: 'Absolute Change',
    };

    tooltipConfig.percentageChange = {
      visible: true,
      label: 'Percentage Change',
    };

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={createViewModel()}
      />,
    );

    expect(screen.getByText('Absolute Change')).toBeTruthy();
    expect(screen.getByText('10')).toBeTruthy();
    expect(screen.getByText('Percentage Change')).toBeTruthy();
    expect(screen.getByText('10%')).toBeTruthy();
  });

  it('должен использовать подписи изменений из конфигурации', () => {
    const tooltipConfig = createTooltipConfig();

    tooltipConfig.absoluteChange = {
      visible: true,
      label: 'Custom absolute',
    };

    tooltipConfig.percentageChange = {
      visible: true,
      label: 'Custom percentage',
    };

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={createViewModel()}
      />,
    );

    expect(screen.getByText('Custom absolute')).toBeTruthy();
    expect(screen.getByText('Custom percentage')).toBeTruthy();
  });

  it('не должен отображать поле без label', () => {
    const tooltipConfig = createTooltipConfig();

    tooltipConfig.absoluteChange = {
      visible: true,
    };

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={createViewModel()}
      />,
    );

    expect(screen.queryByText('10')).toBeNull();
  });

  it('должен отображать нулевое значение изменения', () => {
    const tooltipConfig = createTooltipConfig();
    const viewModel = createViewModel();

    tooltipConfig.absoluteChange = {
      visible: true,
      label: 'Absolute Change',
    };

    viewModel.next({
      ...viewModel.getValue(),
      vm: [
        {
          id: 'main-series',
          name: 'AAPL',
          isIndicator: false,
          values: {
            absoluteChange: {
              value: 0,
              color: 'green',
              name: 'Absolute Change',
            },
          },
        },
      ],
    } as TooltipVM);

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={viewModel}
      />,
    );

    expect(screen.getByText('Absolute Change')).toBeTruthy();
    expect(screen.getByText('0')).toBeTruthy();
  });

  it('должен отображать divider только для групп с видимыми значениями', () => {
    const tooltipConfig = createTooltipConfig();

    tooltipConfig.open = {
      visible: true,
      label: 'Open',
    };

    tooltipConfig.absoluteChange = {
      visible: true,
      label: 'Absolute Change',
    };

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={tooltipConfig}
        viewModel={createViewModel()}
      />,
    );

    expect(screen.getAllByTestId('divider')).toHaveLength(2);
  });

  it('не должен отображать divider для скрытых групп', () => {
    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={createTooltipConfig()}
        viewModel={createViewModel()}
      />,
    );

    expect(screen.queryByTestId('divider')).toBeNull();
  });

  it('должен отображать значение индикатора', () => {
    const viewModel = createViewModel();

    viewModel.next({
      ...viewModel.getValue(),
      vm: [
        {
          id: 'rsi',
          name: 'RSI',
          isIndicator: true,
          values: {
            value: {
              value: 55,
              color: 'white',
              name: 'RSI',
            },
          },
        },
      ],
    } as TooltipVM);

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={createTooltipConfig()}
        viewModel={viewModel}
      />,
    );

    expect(screen.getByText('RSI')).toBeTruthy();
    expect(screen.getByText('55')).toBeTruthy();
  });

  it('должен отображать названия серий индикатора при нескольких значениях', () => {
    const viewModel = createViewModel();

    viewModel.next({
      ...viewModel.getValue(),
      vm: [
        {
          id: 'ema',
          name: 'EMA',
          isIndicator: true,
          values: {
            open: {
              value: 100,
              color: 'white',
              name: 'Fast',
            },
            value: {
              value: 105,
              color: 'white',
              name: 'Slow',
            },
          },
        },
      ],
    } as TooltipVM);

    render(
      <ChartTooltip
        formatObs={formatObs}
        timeframeObs={timeframeObs}
        tooltipConfig={createTooltipConfig()}
        viewModel={viewModel}
      />,
    );

    expect(screen.getByText('Fast')).toBeTruthy();
    expect(screen.getByText('Slow')).toBeTruthy();
    expect(screen.getByText('100')).toBeTruthy();
    expect(screen.getByText('105')).toBeTruthy();
  });
});