Загрузка данных
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);
const shouldShowMainSerieValue = (key: string) => {
if (mainSerieKeys.has(key)) {
return showMainSerieValues;
}
if (key === 'absoluteChange') {
return ohlcConfig?.showAbsoluteChange ?? false;
}
if (key === 'percentageChange') {
return ohlcConfig?.showPercentageChange ?? true;
}
return false;
};
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 && !shouldShowMainSerieValue(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>
);
};
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.absoluteChange?.visible && entries.absoluteChange?.value) ||
(tooltipConfig.percentageChange?.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.absoluteChange?.visible &&
renderRow(
`${indicator.id}-absolute-change`,
tooltipConfig.absoluteChange.label,
entries.absoluteChange?.value,
)}
{tooltipConfig.percentageChange?.visible &&
renderRow(
`${indicator.id}-percentage-change`,
tooltipConfig.percentageChange.label,
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 {
CHART_DRAWING_TOOLBAR_CONTAINER,
CHART_MODAL_CONTAINER_CLASSNAME,
CHART_PANE_OVERLAY_CONTAINER,
CHART_ROOT_CLASSNAME,
} from '@src/constants';
import { ContainerManager } from '../ContainerManager';
jest.mock('@src/constants', () => ({
CHART_DRAWING_TOOLBAR_CONTAINER: 'chart-drawing-toolbar-container',
CHART_MODAL_CONTAINER_CLASSNAME: 'chart-modal-container',
CHART_PANE_OVERLAY_CONTAINER: 'chart-pane-overlay-container',
CHART_ROOT_CLASSNAME: 'chart-root',
}));
describe('ContainerManager', () => {
let parentContainer: HTMLDivElement;
beforeEach(() => {
document.querySelectorAll('link[href*="fonts.googleapis.com"]').forEach((link) => link.remove());
parentContainer = document.createElement('div');
document.body.appendChild(parentContainer);
});
afterEach(() => {
parentContainer.remove();
});
describe('createContainers', () => {
it('должен создавать основные контейнеры графика с корректной структурой', () => {
const {
headerContainer,
footerContainer,
chartContainer,
chartAreaContainer,
toolBarContainer,
modalContainer,
controlBarContainer,
drawingToolbarContainer,
} = ContainerManager.createContainers({
parentContainer,
showBottomPanel: true,
showMenuButton: true,
});
expect(parentContainer.classList.contains(CHART_ROOT_CLASSNAME)).toBe(true);
expect(parentContainer.children[0]).toBe(headerContainer);
expect(parentContainer.children[1]).toBe(chartContainer);
expect(parentContainer.children[2]).toBe(footerContainer);
expect(chartContainer.contains(toolBarContainer)).toBe(true);
expect(chartContainer.contains(chartAreaContainer)).toBe(true);
expect(chartContainer.contains(modalContainer)).toBe(true);
expect(chartAreaContainer.contains(controlBarContainer)).toBe(true);
expect(chartAreaContainer.contains(drawingToolbarContainer)).toBe(true);
expect(drawingToolbarContainer.classList.contains(CHART_DRAWING_TOOLBAR_CONTAINER)).toBe(true);
expect(modalContainer.classList.contains(CHART_MODAL_CONTAINER_CLASSNAME)).toBe(true);
expect(modalContainer.hidden).toBe(true);
});
it('должен создавать сетку с нижней панелью', () => {
ContainerManager.createContainers({
parentContainer,
showBottomPanel: true,
showMenuButton: true,
});
expect(parentContainer.style.gridTemplateRows).toBe('36px minmax(0, 1fr) 32px');
});
it('должен создавать сетку без нижней панели', () => {
const { footerContainer } = ContainerManager.createContainers({
parentContainer,
showBottomPanel: false,
showMenuButton: true,
});
expect(parentContainer.style.gridTemplateRows).toBe('36px minmax(0, 1fr)');
expect(parentContainer.contains(footerContainer)).toBe(false);
});
it('должен отображать панель инструментов при включённой кнопке меню', () => {
const { chartContainer, toolBarContainer } = ContainerManager.createContainers({
parentContainer,
showMenuButton: true,
});
expect(chartContainer.contains(toolBarContainer)).toBe(true);
expect(chartContainer.style.gridTemplateColumns).toBe('42px minmax(0, 1fr)');
});
it('должен скрывать панель инструментов при выключенной кнопке меню', () => {
const { chartContainer, toolBarContainer } = ContainerManager.createContainers({
parentContainer,
showMenuButton: false,
});
expect(chartContainer.contains(toolBarContainer)).toBe(false);
expect(chartContainer.style.gridTemplateColumns).toBe('minmax(0, 1fr)');
});
it('должен переключать видимость панели инструментов', () => {
const { chartContainer, toolBarContainer, toggleToolbar } = ContainerManager.createContainers({
parentContainer,
showMenuButton: true,
});
expect(toggleToolbar()).toBe(false);
expect(chartContainer.contains(toolBarContainer)).toBe(false);
expect(chartContainer.style.gridTemplateColumns).toBe('minmax(0, 1fr)');
expect(toggleToolbar()).toBe(true);
expect(chartContainer.contains(toolBarContainer)).toBe(true);
expect(chartContainer.style.gridTemplateColumns).toBe('42px minmax(0, 1fr)');
});
it('должен удалять старое содержимое родительского контейнера перед созданием графика', () => {
const oldElement = document.createElement('div');
parentContainer.appendChild(oldElement);
ContainerManager.createContainers({
parentContainer,
showMenuButton: true,
});
expect(parentContainer.contains(oldElement)).toBe(false);
});
it('должен добавлять шрифт только один раз', () => {
ContainerManager.createContainers({
parentContainer,
showMenuButton: true,
});
ContainerManager.createContainers({
parentContainer,
showMenuButton: true,
});
const fontLinks = document.querySelectorAll('link[href*="fonts.googleapis.com"]');
expect(fontLinks).toHaveLength(1);
expect(fontLinks[0]?.getAttribute('href')).toBe(
'https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap',
);
});
});
describe('createPaneContainers', () => {
it('должен создавать контейнеры легенды и оверлея панели', () => {
const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();
expect(legendContainer.style.position).toBe('absolute');
expect(legendContainer.style.pointerEvents).toBe('none');
expect(paneOverlayContainer.classList.contains(CHART_PANE_OVERLAY_CONTAINER)).toBe(true);
expect(paneOverlayContainer.style.position).toBe('absolute');
expect(paneOverlayContainer.style.pointerEvents).toBe('none');
});
it('должен размещать слои графика в корректном порядке', () => {
const { controlBarContainer, drawingToolbarContainer, modalContainer } = ContainerManager.createContainers({
parentContainer,
showMenuButton: true,
});
const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();
const controlBarZIndex = Number(controlBarContainer.style.zIndex);
const paneOverlayZIndex = Number(paneOverlayContainer.style.zIndex);
const drawingZIndex = Number(drawingToolbarContainer.style.zIndex);
const legendZIndex = Number(legendContainer.style.zIndex);
const modalZIndex = Number(modalContainer.style.zIndex);
expect(controlBarZIndex).toBe(paneOverlayZIndex);
expect(legendZIndex).toBeGreaterThan(paneOverlayZIndex);
expect(drawingZIndex).toBeGreaterThan(legendZIndex);
expect(modalZIndex).toBeGreaterThan(drawingZIndex);
});
});
describe('clearContainers', () => {
it('должен очищать содержимое родительского контейнера', () => {
parentContainer.innerHTML = '<div>content</div>';
ContainerManager.clearContainers(parentContainer);
expect(parentContainer.innerHTML).toBe('');
});
});
});