Загрузка данных
import classNames from 'classnames';
import { Tooltip as ExchangeTooltip } from 'exchange-elements/v2';
import { type ComponentProps } from 'react';
import { CHART_DRAWING_TOOLTIP } from '@src/constants';
import styles from './index.module.scss';
type TooltipProps = ComponentProps<typeof ExchangeTooltip>;
export function Tooltip({ tooltipClassName, direction = false, ...props }: TooltipProps) {
return (
<ExchangeTooltip
{...props}
direction={direction}
tooltipClassName={classNames(styles.tooltip, CHART_DRAWING_TOOLTIP, tooltipClassName)}
/>
);
}
import classNames from 'classnames';
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
import { Observable } from 'rxjs';
import { Button } from '@src/components/Button';
import { Divider } from '@src/components/Divider';
import { MenuList } from '@src/components/Menu';
import SplitDropdown from '@src/components/SplitDropdown';
import {
gannAndFibonacciTools,
geometricShapes,
measurementTools,
trendLines,
} from '@src/components/Toolbar/constants';
import { Tooltip } from '@src/components/Tooltip';
import { DrawingsNames } from '@src/constants';
import { t } from '@src/translations';
import { ActiveDrawingTool } from '@src/types';
import { ensureDefined, useObservable } from '@src/utils';
import {
CrossIcon,
EyeBrushIcon,
HeartIcon,
LayersIcon,
MagnetIcon,
PencilLockerIcon,
RulerIcon,
SplineCurveIcon,
TrashIcon,
TypeIcon,
UnlockIcon,
ZoomInIcon,
} from '../Icon';
import styles from './index.module.scss';
interface ToolbarProps {
toggleDOM: () => void;
addDrawing: (name: DrawingsNames) => void;
setEndlessDrawingsMode: (value: boolean) => void;
isEndlessDrawingsMode$: Observable<boolean>;
activateCrosshair: () => void;
activeTool$: Observable<ActiveDrawingTool>;
}
const implemented = {
crosshair: true,
lines: true,
fib: true,
rectangle: true,
text: true,
XABCD: false,
position: true,
icons: false,
};
export default function Toolbar({
toggleDOM,
addDrawing,
setEndlessDrawingsMode,
isEndlessDrawingsMode$,
activateCrosshair,
activeTool$,
}: ToolbarProps) {
const [selectedLineType, setSelectedLineType] = useState<DrawingsNames>(DrawingsNames.trendLine);
const [selectedMeasurementTool, setSelectedMeasurementTool] = useState(DrawingsNames.fixedRangeProfile);
const [selectedGeometricShape, setSelectedGeometricShape] = useState(DrawingsNames.rectangle);
const [selectedGannAndFibonacci, setSelectedGannAndFibonacci] = useState(DrawingsNames.fibonacciRetracement);
const isEndlessDrawingsMode = useObservable(isEndlessDrawingsMode$);
const activeTool = useObservable(activeTool$, 'crosshair');
const toolbarRef = useRef<HTMLDivElement | null>(null);
const createDrawingHandler = (setter: Dispatch<SetStateAction<DrawingsNames>>) => (value: DrawingsNames) => {
setter(value);
addDrawing(value);
};
const findOptionByValue = <T extends { value: string }>(options: T[], selectedValue: T['value']): T | undefined =>
options.find((item) => item.value === selectedValue);
const selectedTrendLineOption = findOptionByValue(trendLines(), selectedLineType);
const selectedMeasurementToolOption = findOptionByValue(measurementTools(), selectedMeasurementTool);
const selectedGeometricShapeOption = findOptionByValue(geometricShapes(), selectedGeometricShape);
const selectedGannAndFibonacciOption = findOptionByValue(gannAndFibonacciTools(), selectedGannAndFibonacci);
const isButtonPressed = (tool: ActiveDrawingTool): boolean => activeTool === tool;
useEffect(() => {
if (activeTool === 'crosshair') {
return;
}
if (trendLines().some(({ value }) => value === activeTool)) {
setSelectedLineType(activeTool);
return;
}
if (gannAndFibonacciTools().some(({ value }) => value === activeTool)) {
setSelectedGannAndFibonacci(activeTool);
return;
}
if (geometricShapes().some(({ value }) => value === activeTool)) {
setSelectedGeometricShape(activeTool);
return;
}
if (measurementTools().some(({ value }) => value === activeTool)) {
setSelectedMeasurementTool(activeTool);
}
}, [activeTool]);
return (
<div
ref={toolbarRef}
className={styles.toolbar}
>
<div className={classNames(styles.group)}>
{implemented.crosshair && (
<Tooltip
showMessageOnFocus
label={t('Crosshair')}
location="right"
>
<Button
onClick={activateCrosshair}
pressed={isButtonPressed('crosshair')}
label={<CrossIcon />}
/>
</Tooltip>
)}
{implemented.lines && (
<SplitDropdown
anchorRef={toolbarRef}
mainContent={ensureDefined(selectedTrendLineOption?.icon)}
mainTooltip={ensureDefined(selectedTrendLineOption).label}
onMainClick={() => addDrawing(selectedLineType)}
menuTooltip={t('Trend lines')}
mainButtonPressed={isButtonPressed(selectedLineType)}
>
<MenuList
mode="single"
value={selectedLineType}
options={trendLines()}
onClick={createDrawingHandler(setSelectedLineType)}
/>
</SplitDropdown>
)}
{implemented.fib && (
<SplitDropdown
anchorRef={toolbarRef}
mainContent={ensureDefined(selectedGannAndFibonacciOption?.icon)}
mainTooltip={ensureDefined(selectedGannAndFibonacciOption).label}
onMainClick={() => addDrawing(selectedGannAndFibonacci)}
menuTooltip={t('Gann and Fibonacci')}
mainButtonPressed={isButtonPressed(selectedGannAndFibonacci)}
>
<MenuList
mode="single"
value={selectedGannAndFibonacci}
options={gannAndFibonacciTools()}
onClick={createDrawingHandler(setSelectedGannAndFibonacci)}
/>
</SplitDropdown>
)}
{implemented.rectangle && (
<SplitDropdown
anchorRef={toolbarRef}
mainContent={ensureDefined(selectedGeometricShapeOption?.icon)}
mainTooltip={ensureDefined(selectedGeometricShapeOption).label}
onMainClick={() => addDrawing(selectedGeometricShape)}
menuTooltip={t('Geometric shapes')}
mainButtonPressed={isButtonPressed(selectedGeometricShape)}
>
<MenuList
mode="single"
value={selectedGeometricShape}
options={geometricShapes()}
onClick={createDrawingHandler(setSelectedGeometricShape)}
/>
</SplitDropdown>
)}
{implemented.text && (
<Tooltip
showMessageOnFocus
label={t('Text')}
location="right"
>
<Button
pressed={isButtonPressed(DrawingsNames.text)}
onClick={() => addDrawing(DrawingsNames.text)}
label={<TypeIcon />}
/>
</Tooltip>
)}
{implemented.XABCD && (
<Tooltip
showMessageOnFocus
label={t('XABCD template')}
location="right"
>
<Button
onClick={() => {}}
label={<SplineCurveIcon />}
/>
</Tooltip>
)}
{implemented.position && (
<SplitDropdown
anchorRef={toolbarRef}
mainContent={ensureDefined(selectedMeasurementToolOption?.icon)}
mainTooltip={ensureDefined(selectedMeasurementToolOption).label}
menuTooltip={t('Measurement tools')}
onMainClick={() => addDrawing(selectedMeasurementTool)}
mainButtonPressed={isButtonPressed(selectedMeasurementTool)}
>
<MenuList
mode="single"
value={selectedMeasurementTool}
options={measurementTools()}
onClick={createDrawingHandler(setSelectedMeasurementTool)}
/>
</SplitDropdown>
)}
{implemented.icons && (
<Tooltip
label={t('Pin')}
location="right"
showMessageOnFocus
>
<Button
onClick={() => {}}
label={<HeartIcon />}
/>
</Tooltip>
)}
</div>
<Divider classNameDivider={styles.divider} />
<div className={classNames(styles.group)}>
<Tooltip
showMessageOnFocus
label={t('Ruler')}
location="right"
>
<Button
pressed={isButtonPressed(DrawingsNames.ruler)}
onClick={() => addDrawing(DrawingsNames.ruler)}
label={<RulerIcon />}
/>
</Tooltip>
<Tooltip
showMessageOnFocus
label={t('Upscale')}
location="right"
className={styles.notImplemented}
>
<Button
onClick={() => {}}
label={<ZoomInIcon />}
/>
</Tooltip>
</div>
<Divider classNameDivider={styles.divider} />
<div className={classNames(styles.group)}>
<Tooltip
showMessageOnFocus
label={t('Magnet allows you to attract objects points to the nearest bar prices')}
location="right"
className={styles.notImplemented}
>
<Button
onClick={() => {}}
label={<MagnetIcon />}
/>
</Tooltip>
<Tooltip
showMessageOnFocus
label={t('Endless drawing mode')}
location="right"
>
<Button
pressed={isEndlessDrawingsMode}
onClick={() => setEndlessDrawingsMode(!isEndlessDrawingsMode)}
label={<PencilLockerIcon />}
/>
</Tooltip>
<Tooltip
showMessageOnFocus
label={t('Fix all objects')}
location="right"
className={styles.notImplemented}
>
<Button
onClick={() => {}}
label={<UnlockIcon />}
/>
</Tooltip>
<Tooltip
showMessageOnFocus
label={t('Hide all drawing objects')}
location="right"
className={styles.notImplemented}
>
<Button
onClick={() => {}}
label={<EyeBrushIcon />}
/>
</Tooltip>
</div>
<Divider classNameDivider={styles.divider} />
<div className={classNames(styles.group)}>
<Tooltip
showMessageOnFocus
label={t('Clear objects')}
location="right"
className={styles.notImplemented}
>
<Button
onClick={() => {}}
label={<TrashIcon />}
/>
</Tooltip>
<Tooltip
showMessageOnFocus
label={t('DOM tree')}
location="right"
>
<Button
onClick={toggleDOM}
label={<LayersIcon />}
/>
</Tooltip>
</div>
</div>
);
}
import classNames from 'classnames';
import { PropsWithChildren, RefObject } from 'react';
import { Button } from '@src/components/Button';
import { DropdownPosition } from '@src/components/Dropdown/types';
import SmallChevronLeftIcon from '@src/components/Icon/SmallChevronLeft';
import { Tooltip } from '@src/components/Tooltip';
import { Dropdown } from '../Dropdown';
import styles from './index.module.scss';
interface SplitDropdownProps extends PropsWithChildren {
position?: DropdownPosition;
mainContent: JSX.Element;
menuButtonContent?: JSX.Element;
mainTooltip?: string;
menuTooltip?: string;
anchorRef?: RefObject<HTMLDivElement>;
onMainClick?: () => void;
mainButtonClassName?: string;
mainButtonPressed?: boolean;
tooltipClassName?: string;
}
export default function SplitDropdown({
position = 'right',
mainContent,
menuButtonContent = <SmallChevronLeftIcon />,
mainTooltip,
menuTooltip,
anchorRef,
onMainClick,
mainButtonClassName = '',
mainButtonPressed = false,
tooltipClassName,
children,
}: SplitDropdownProps) {
return (
<Dropdown
position={position}
anchorRef={anchorRef}
buttonClassName={mainButtonClassName}
renderTrigger={({ isOpen, onToggle }) => (
<div
className={classNames(styles.dropdown, {
[styles.open]: isOpen,
})}
>
<Tooltip
tooltipClassName={classNames(styles.tooltip__main, tooltipClassName)}
showMessageOnFocus={!isOpen}
location="right"
label={mainTooltip}
>
<Button
className={mainButtonClassName}
pressed={mainButtonPressed || isOpen}
onClick={onMainClick}
label={mainContent}
/>
</Tooltip>
<Tooltip
tooltipClassName={tooltipClassName}
showMessageOnFocus={!isOpen}
location="right"
label={menuTooltip}
>
<Button
className={styles.menu_button}
pressed={isOpen}
onClick={onToggle}
label={menuButtonContent}
/>
</Tooltip>
</div>
)}
>
{children}
</Dropdown>
);
}
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Button } from '@src/components/Button';
import { Portal } from '@src/components/Portal';
import styles from './index.module.scss';
import { getDropdownCoords } from './utils';
import type { Coords, DropdownProps } from './types';
export function Dropdown({
children,
selectedValue,
position = 'bottom',
horizontalAlign = 'auto',
className = '',
buttonClassName = '',
menuClassName = '',
anchorRef,
renderTrigger,
}: DropdownProps) {
const [isOpenMenu, setIsOpenMenu] = useState(false);
const [coords, setCoords] = useState<Coords | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const handleToggle = (): void => {
if (isOpenMenu) {
handleClose();
return;
}
setIsOpenMenu(true);
};
const handleClose = (): void => {
setIsOpenMenu(false);
setCoords(null);
};
useLayoutEffect(() => {
if (!isOpenMenu) {
return;
}
const updatePosition = (): void => {
const triggerElement = triggerRef.current;
const menuElement = menuRef.current;
if (!triggerElement || !menuElement) {
return;
}
const triggerRect = triggerElement.getBoundingClientRect();
const anchorRect = anchorRef?.current?.getBoundingClientRect() ?? triggerRect;
const menuRect = menuElement.getBoundingClientRect();
setCoords(
getDropdownCoords({
position,
horizontalAlign,
triggerRect,
anchorRect,
menuRect,
}),
);
};
updatePosition();
const resizeObserver = new ResizeObserver(updatePosition);
if (menuRef.current) {
resizeObserver.observe(menuRef.current);
}
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
resizeObserver.disconnect();
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [anchorRef, isOpenMenu, position]);
useEffect(() => {
if (!isOpenMenu) {
return;
}
const handlePointerDown = (event: PointerEvent): void => {
const target = event.target as Node;
const isInsideRoot = rootRef.current?.contains(target);
const isInsideMenu = menuRef.current?.contains(target);
if (!isInsideRoot && !isInsideMenu) {
handleClose();
}
};
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
handleClose();
}
};
document.addEventListener('pointerdown', handlePointerDown, true);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpenMenu]);
return (
<div
ref={rootRef}
className={`${styles.dropdown} ${className}`}
>
<div ref={triggerRef}>
{renderTrigger ? (
renderTrigger({
isOpen: isOpenMenu,
onToggle: handleToggle,
onClose: handleClose,
})
) : (
<Button
onClick={handleToggle}
className={buttonClassName}
pressed={isOpenMenu}
label={selectedValue}
/>
)}
</div>
{isOpenMenu && (
<Portal>
<div
ref={menuRef}
className={`${styles.menu} ${menuClassName}`}
style={{
top: coords?.top,
left: coords?.left,
visibility: coords ? 'visible' : 'hidden',
}}
>
{children}
</div>
</Portal>
)}
</div>
);
}
import { ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { getPortalHost } from '@src/utils';
interface PortalProps {
children: ReactNode;
container?: HTMLElement | null;
}
export function Portal({ children, container }: PortalProps) {
return createPortal(children, container ?? getPortalHost());
}
import { PriceScaleMode } from 'lightweight-charts';
import { Button } from '@src/components/Button';
import { Dropdown } from '@src/components/Dropdown';
import { RangeField } from '@src/components/FormFields';
import { CameraIcon, LineIcon, TableIcon } from '@src/components/Icon';
import { SnapshotMenu } from '@src/components/Menu';
import { SeasonalityScaleMenu } from '@src/components/Menu/SeasonalityScaleMenu';
import { Tooltip } from '@src/components/Tooltip';
import { t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
import { useObservable } from '@src/utils';
import styles from './index.module.scss';
import type { SeasonalityRange, SeasonalityScale, SeasonalityState } from '@src/types/seasonality';
import type { Observable } from 'rxjs';
interface SeasonalityControlsProps {
stateObs: Observable<SeasonalityState>;
setDisplayMode: (displayMode: SeasonalityViewType) => void;
setScale: (scale: SeasonalityScale) => void;
setRange: (range: SeasonalityRange) => void;
setAverageVisible: (visible: boolean) => void;
copyImage?: () => Promise<void>;
}
const MAX_SCALE_LABELS = 5;
function createYearMarks(minYear: number, maxYear: number): number[] {
const span = maxYear - minYear;
if (span <= 0) return [minYear];
const intervals = Math.min(MAX_SCALE_LABELS - 1, span);
return Array.from({ length: intervals + 1 }, (_, index) => minYear + Math.floor((span * index) / intervals));
}
export function SeasonalityControls({
stateObs,
setDisplayMode,
setScale,
setRange,
setAverageVisible,
copyImage,
}: SeasonalityControlsProps) {
const state = useObservable(stateObs);
if (!state || state.status === 'idle') {
return null;
}
const selectedScaleLabel = state.scale === PriceScaleMode.Normal ? t('Price') : t('Percent');
const isRangeDisabled = state.status === 'loading' || state.status === 'error';
const canCopyImage = state.status === 'ready' && state.displayMode === SeasonalityViewType.Chart;
const marks = createYearMarks(state.minYear, state.maxYear);
const handleRangeChange = ([fromYear, toYear]: [number, number]): void => {
setRange({ fromYear, toYear });
};
return (
<div className={styles.controls}>
<div className={styles.controls_header}>
<div className={styles.controls_views}>
<Tooltip label={t('Seasonality chart')}>
<Button
label={<LineIcon />}
pressed={state.displayMode === SeasonalityViewType.Chart}
onClick={() => setDisplayMode(SeasonalityViewType.Chart)}
/>
</Tooltip>
<Tooltip label={t('Seasonality table')}>
<Button
label={<TableIcon />}
pressed={state.displayMode === SeasonalityViewType.Table}
onClick={() => setDisplayMode(SeasonalityViewType.Table)}
/>
</Tooltip>
</div>
<div className={styles.controls_actions}>
<Tooltip label={t('Average for selected years')}>
<Button
label={
<span className={styles.controls_button}>
<LineIcon />
<span>{t('Average')}</span>
</span>
}
pressed={state.showAverage}
disabled={state.status !== 'ready'}
onClick={() => setAverageVisible(!state.showAverage)}
/>
</Tooltip>
<div className={styles.controls_scale}>
<Dropdown
className={styles.controls_dropdown}
selectedValue={selectedScaleLabel}
renderTrigger={({ isOpen, onToggle }) => (
<Tooltip label={t('Scale')}>
<Button
pressed={isOpen}
onClick={onToggle}
label={selectedScaleLabel}
/>
</Tooltip>
)}
>
<SeasonalityScaleMenu
selectedScale={state.scale}
onClick={setScale}
/>
</Dropdown>
</div>
{copyImage && canCopyImage && (
<Dropdown
className={styles.controls_dropdown}
selectedValue={<CameraIcon />}
renderTrigger={({ isOpen, onToggle }) => (
<Tooltip label={t('Snapshot')}>
<Button
label={<CameraIcon />}
pressed={isOpen}
onClick={onToggle}
/>
</Tooltip>
)}
>
<SnapshotMenu onCopyImage={copyImage} />
</Dropdown>
)}
</div>
</div>
<div className={styles.controls_range}>
<RangeField
mode="range"
value={[state.fromYear, state.toYear]}
min={state.minYear}
max={state.maxYear}
marks={marks}
marksPosition="top"
step={1}
suffix=""
size="sm"
disabled={isRangeDisabled}
onRangeChange={handleRangeChange}
/>
</div>
</div>
);
}
.controls {
display: flex;
flex-direction: column;
min-width: 0;
gap: var(--space-0500);
&_header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-0500);
min-width: 0;
}
&_views {
display: flex;
align-items: center;
gap: var(--space-0125);
border: var(--borderWidth-md) solid var(--neutral-7);
border-radius: var(--space-0250);
}
&_scale {
border: var(--borderWidth-md) solid var(--neutral-7);
border-radius: var(--space-0250);
}
&_actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--space-0250);
}
&_button {
display: inline-flex;
align-items: center;
gap: var(--space-0250);
white-space: nowrap;
}
&_range {
min-width: 0;
}
}