Загрузка данных
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();
});
});