Загрузка данных
import classNames from 'classnames';
import { type ChangeEvent, type PointerEvent, useId, useRef } from 'react';
import styles from './index.module.scss';
interface CommonRangeFieldProps {
label?: string;
min?: number;
max?: number;
step?: number;
color?: string;
suffix?: string;
size?: 'sm' | 'md';
disabled?: boolean;
}
interface SingleRangeFieldProps extends CommonRangeFieldProps {
mode?: 'single';
value: number;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
}
interface IntervalRangeFieldProps extends CommonRangeFieldProps {
mode: 'range';
value: [number, number];
marks?: readonly number[];
onRangeChange: (value: [number, number]) => void;
}
interface RangeDragState {
mode: 'range' | 'from' | 'to';
pointerId: number;
pointerX: number;
from: number;
to: number;
trackWidth: number;
}
type RangeFieldProps = SingleRangeFieldProps | IntervalRangeFieldProps;
export const RangeField = (props: RangeFieldProps) => {
const {
label,
min = 0,
max = 100,
step = 1,
color = 'var(--blue-1)',
suffix = '%',
size = 'md',
disabled = false,
} = props;
const inputId = useId();
const trackRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<RangeDragState | null>(null);
if (props.mode !== 'range') {
return (
<div
className={classNames(styles.range, {
[styles.range_sm]: size === 'sm',
})}
>
{label && (
<label
htmlFor={inputId}
className={styles.range_label}
>
{label}
</label>
)}
<div className={styles.range_wrapper}>
<input
id={inputId}
type="range"
min={min}
max={max}
step={step}
value={props.value}
className={styles.range_input}
style={{ ['--range-color' as string]: color }}
onChange={props.onChange}
disabled={disabled}
/>
<span className={styles.range_value}>
{props.value}
{suffix}
</span>
</div>
</div>
);
}
const {
value: [from, to],
marks = [],
onRangeChange,
} = props;
const span = max - min;
const left = span > 0 ? ((from - min) / span) * 100 : 0;
const right = span > 0 ? ((to - min) / span) * 100 : 0;
const isDisabled = disabled || span <= 0;
const scaleMarks =
span > 0
? [
min,
...Array.from(new Set(marks))
.filter((value) => value > min && value < max)
.sort((a, b) => a - b),
max,
]
: [min];
const handleDragEnd = (event: PointerEvent<HTMLElement>) => {
if (dragRef.current?.pointerId === event.pointerId) {
dragRef.current = null;
}
};
const handleDragStart = (event: PointerEvent<HTMLElement>, mode: RangeDragState['mode']) => {
if (isDisabled || event.button !== 0 || dragRef.current || !trackRef.current) return;
const { width } = trackRef.current.getBoundingClientRect();
if (width <= 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
mode,
pointerId: event.pointerId,
pointerX: event.clientX,
from,
to,
trackWidth: width,
};
};
const handleDragMove = (event: PointerEvent<HTMLElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId || isDisabled) return;
const pointerOffset = ((event.clientX - drag.pointerX) / drag.trackWidth) * span;
const steppedOffset = Math.round(pointerOffset / step) * step;
let nextFrom = drag.from;
let nextTo = drag.to;
if (drag.mode === 'range') {
const offset = Math.max(min - drag.from, Math.min(max - drag.to, steppedOffset));
nextFrom += offset;
nextTo += offset;
} else if (drag.from === drag.to) {
const value = Math.max(min, Math.min(max, drag.from + steppedOffset));
nextFrom = Math.min(drag.from, value);
nextTo = Math.max(drag.to, value);
} else if (drag.mode === 'from') {
nextFrom = Math.max(min, Math.min(drag.to, drag.from + steppedOffset));
} else {
nextTo = Math.max(drag.from, Math.min(max, drag.to + steppedOffset));
}
if (nextFrom !== from || nextTo !== to) {
onRangeChange([nextFrom, nextTo]);
}
};
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (
isDisabled ||
from !== to ||
event.button !== 0 ||
event.defaultPrevented ||
event.target instanceof HTMLInputElement ||
!trackRef.current
) {
return;
}
const { left: trackLeft, width } = trackRef.current.getBoundingClientRect();
if (width <= 0) return;
const value = min + Math.round((((event.clientX - trackLeft) / width) * span) / step) * step;
const next = Math.max(min, Math.min(max, value));
event.preventDefault();
if (next !== from) {
onRangeChange([Math.min(from, next), Math.max(to, next)]);
}
};
const handleFromChange = (event: ChangeEvent<HTMLInputElement>) => {
if (dragRef.current) return;
onRangeChange([Math.min(event.currentTarget.valueAsNumber, to), to]);
};
const handleToChange = (event: ChangeEvent<HTMLInputElement>) => {
if (dragRef.current) return;
onRangeChange([from, Math.max(event.currentTarget.valueAsNumber, from)]);
};
const dragHandlers = {
onPointerMove: handleDragMove,
onPointerUp: handleDragEnd,
onPointerCancel: handleDragEnd,
onLostPointerCapture: handleDragEnd,
};
return (
<div
className={classNames(styles.range, styles.range_interval, {
[styles.range_sm]: size === 'sm',
[styles.range_disabled]: isDisabled,
[styles.range_collapsed]: from === to,
})}
>
{label && <span className={styles.range_label}>{label}</span>}
<div
ref={trackRef}
className={styles.range_track}
style={{ ['--range-color' as string]: color }}
onPointerDown={handleTrackPointerDown}
>
<div className={styles.range_rail} />
<div
className={styles.range_selection}
style={{
left: `${left}%`,
width: `${right - left}%`,
}}
onPointerDown={(event) => handleDragStart(event, 'range')}
{...dragHandlers}
/>
<input
id={`${inputId}-from`}
type="range"
min={min}
max={max}
step={step}
value={from}
disabled={isDisabled}
className={classNames(styles.range_handle, styles.range_handle_start, {
[styles.range_handle_behind]: from === to && from === min,
})}
onChange={handleFromChange}
onPointerDown={(event) => handleDragStart(event, 'from')}
{...dragHandlers}
/>
<input
id={`${inputId}-to`}
type="range"
min={min}
max={max}
step={step}
value={to}
disabled={isDisabled}
className={classNames(styles.range_handle, styles.range_handle_end)}
onChange={handleToChange}
onPointerDown={(event) => handleDragStart(event, 'to')}
{...dragHandlers}
/>
<button
type="button"
tabIndex={-1}
className={styles.range_dragHandle}
style={{ left: `${(left + right) / 2}%` }}
disabled={isDisabled}
onPointerDown={(event) => handleDragStart(event, 'range')}
{...dragHandlers}
>
<svg
width="12"
height="8"
viewBox="0 0 12 8"
fill="currentColor"
>
<circle
cx="2"
cy="2"
r="1"
/>
<circle
cx="6"
cy="2"
r="1"
/>
<circle
cx="10"
cy="2"
r="1"
/>
<circle
cx="2"
cy="6"
r="1"
/>
<circle
cx="6"
cy="6"
r="1"
/>
<circle
cx="10"
cy="6"
r="1"
/>
</svg>
</button>
</div>
<div className={styles.range_scale}>
{scaleMarks.map((value) => (
<span
key={value}
className={styles.range_scale_label}
style={{
left: `${span > 0 ? ((value - min) / span) * 100 : 0}%`,
}}
>
{value}
{suffix}
</span>
))}
</div>
</div>
);
};
import classNames from 'classnames';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Button } from '@src/components/Button';
import { GearIcon, LockIcon, LockOpenIcon, TrashIcon } from '@src/components/Icon';
import { ToolbarColorControl } from '@src/components/ToolbarColorControl';
import { Tooltip } from '@src/components/Tooltip';
import { t } from '@src/translations';
import styles from './index.module.scss';
import type { Drawing } from '@core/Drawings';
import type { SettingsValues } from '@src/types/settings';
import type { PointerEvent, SyntheticEvent } from 'react';
import type { Observable } from 'rxjs';
interface FloatingDrawingToolbarProps {
selectedDrawing$: Observable<Drawing | null>;
onUpdateSettings: (settings: SettingsValues) => void;
onToggleLock: () => void;
onOpenSettings: () => void;
onDelete: () => void;
}
interface Position {
x: number;
y: number;
}
interface DragState {
pointerId: number;
startX: number;
startY: number;
initialX: number;
initialY: number;
}
const TOOLBAR_TOP_OFFSET = 12;
export function FloatingDrawingToolbar({
selectedDrawing$,
onUpdateSettings,
onToggleLock,
onOpenSettings,
onDelete,
}: FloatingDrawingToolbarProps) {
const toolbarRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const positionRef = useRef<Position | null>(null);
const [selectedDrawing, setSelectedDrawing] = useState<Drawing | null>(null);
const [settings, setSettings] = useState<SettingsValues>({});
const [isLocked, setIsLocked] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState<Position | null>(null);
const shouldShowToolbar = selectedDrawing?.hasSettings() ?? false;
useEffect(() => {
const subscription = selectedDrawing$.subscribe(setSelectedDrawing);
return () => {
subscription.unsubscribe();
};
}, [selectedDrawing$]);
useEffect(() => {
if (!selectedDrawing || !shouldShowToolbar) {
setIsLocked(false);
return;
}
const subscription = selectedDrawing.subscribeIsLocked(setIsLocked);
return () => {
subscription.unsubscribe();
};
}, [selectedDrawing, shouldShowToolbar]);
useEffect(() => {
if (!selectedDrawing || !shouldShowToolbar) {
setSettings({});
return;
}
const subscription = selectedDrawing.subscribeSettings(setSettings);
return () => {
subscription.unsubscribe();
};
}, [selectedDrawing, shouldShowToolbar]);
useLayoutEffect(() => {
if (!selectedDrawing || !shouldShowToolbar) {
return;
}
const toolbar = toolbarRef.current;
const container = toolbar?.parentElement;
if (!toolbar || !container) {
return;
}
const currentPosition = positionRef.current;
if (currentPosition) {
updatePosition(clampPosition(currentPosition.x, currentPosition.y, toolbar, container));
return;
}
updatePosition({
x: Math.round((container.clientWidth - toolbar.offsetWidth) / 2),
y: TOOLBAR_TOP_OFFSET,
});
}, [selectedDrawing, shouldShowToolbar]);
const handleDragStart = (event: PointerEvent<HTMLButtonElement>): void => {
const currentPosition = positionRef.current;
if (event.button !== 0 || !currentPosition) {
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
initialX: currentPosition.x,
initialY: currentPosition.y,
};
setIsDragging(true);
};
const handleDrag = (event: PointerEvent<HTMLButtonElement>): void => {
const dragState = dragStateRef.current;
const toolbar = toolbarRef.current;
const container = toolbar?.parentElement;
if (!dragState || dragState.pointerId !== event.pointerId || !toolbar || !container) {
return;
}
event.preventDefault();
updatePosition(
clampPosition(
dragState.initialX + event.clientX - dragState.startX,
dragState.initialY + event.clientY - dragState.startY,
toolbar,
container,
),
);
};
const handleDragEnd = (event: PointerEvent<HTMLButtonElement>): void => {
if (dragStateRef.current?.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
dragStateRef.current = null;
setIsDragging(false);
};
const stopPropagation = (event: SyntheticEvent): void => {
event.stopPropagation();
};
function updatePosition(nextPosition: Position): void {
positionRef.current = nextPosition;
setPosition((currentPosition) => {
if (currentPosition?.x === nextPosition.x && currentPosition.y === nextPosition.y) {
return currentPosition;
}
return nextPosition;
});
}
if (!selectedDrawing || !shouldShowToolbar) {
return null;
}
const toolbarSettings = selectedDrawing.getToolbarSettings();
const lockLabel = isLocked ? t('Unlock') : t('Lock');
return (
<div
ref={toolbarRef}
className={styles.toolbar}
style={{
visibility: position ? 'visible' : 'hidden',
transform: `translate3d(${position?.x ?? 0}px, ${position?.y ?? 0}px, 0)`,
}}
onPointerDown={stopPropagation}
onClick={stopPropagation}
onDoubleClick={stopPropagation}
onContextMenu={stopPropagation}
>
<button
type="button"
className={classNames(styles.toolbar_handle, {
[styles.dragging]: isDragging,
})}
onPointerDown={handleDragStart}
onPointerMove={handleDrag}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
>
<span />
<span />
<span />
<span />
<span />
<span />
</button>
{toolbarSettings.map((field) => {
if (field.type !== 'color' || field.toolbar.control !== 'color') {
return null;
}
return (
<ToolbarColorControl
key={field.key}
role={field.toolbar.role}
value={String(settings[field.key] ?? field.defaultValue)}
onChange={(value) => {
onUpdateSettings({
[field.key]: value,
});
}}
/>
);
})}
<Tooltip
showMessageOnFocus
label={t('Settings')}
location="top"
>
<Button
onClick={onOpenSettings}
label={<GearIcon />}
/>
</Tooltip>
<Tooltip
showMessageOnFocus
label={lockLabel}
location="top"
>
<Button
pressed={isLocked}
onClick={onToggleLock}
label={isLocked ? <LockIcon /> : <LockOpenIcon />}
/>
</Tooltip>
<Tooltip
showMessageOnFocus
label={t('Remove')}
location="top"
>
<Button
onClick={onDelete}
label={<TrashIcon />}
/>
</Tooltip>
</div>
);
}
function clampPosition(x: number, y: number, toolbar: HTMLElement, container: HTMLElement): Position {
return {
x: Math.max(0, Math.min(Math.round(x), container.clientWidth - toolbar.offsetWidth)),
y: Math.max(0, Math.min(Math.round(y), container.clientHeight - toolbar.offsetHeight)),
};
}
$handle-size: 3px;
.toolbar {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
border-radius: var(--space-0250);
background-color: var(--neutral-2);
pointer-events: auto;
user-select: none;
touch-action: none;
white-space: nowrap;
&_handle {
display: grid;
grid-template-columns: repeat(2, $handle-size);
gap: $handle-size;
align-content: center;
justify-content: center;
align-self: stretch;
width: var(--space-1500);
padding: 0;
border: 0;
background-color: transparent;
cursor: grab;
touch-action: none;
&:active {
cursor: grabbing;
}
span {
width: var(--space-0125);
height: var(--space-0125);
border-radius: 50%;
background-color: var(--neutral-700);
}
}
}
export const russian = {
Indicators: 'Индикаторы',
Settings: 'Настройки',
'Add volume indicator': 'Добавить индикатор объёма',
tick: 'тик',
ticks: 'тики',
tick_few: 'тика',
tick_many: 'тиков',
second: 'секунда',
seconds: 'секунды',
second_few: 'секунды',
second_many: 'секунд',
minute: 'минута',
minutes: 'минуты',
minute_few: 'минуты',
minute_many: 'минут',
hour: 'час',
hours: 'часы',
hour_few: 'часа',
hour_many: 'часов',
day: 'день',
days: 'дни',
day_few: 'дня',
day_many: 'дней',
week: 'неделя',
weeks: 'недели',
week_few: 'недели',
week_many: 'недель',
month: 'месяц',
months: 'месяцы',
month_few: 'месяца',
month_many: 'месяцев',
Bars: 'Бары',
Line: 'Линия',
Baseline: 'Базовая линия',
Candles: 'Свечи',
'Time format': 'Формат времени',
'Date format': 'Формат даты',
'Line start': 'Начало линии',
'Line end': 'Конец линии',
Normal: 'Обычный',
Crosshair: 'Перекрестие',
Arrow: 'Стрелка',
'Trend line': 'Линия тренда',
'Trend lines': 'Линии тренда',
Ray: 'Луч',
'Horizontal line': 'Горизонтальная линия',
'Horizontal ray': 'Горизонтальный луч',
'Vertical line': 'Вертикальная линия',
'Vertical ray': 'Вертикальный луч',
'Long position': 'Длинная позиция',
'Short position': 'Короткая позиция',
'Dates range': 'Диапазон дат',
'Prices range': 'Диапазон цен',
'Fixed range volume profile': 'Профиль объема в заданном диапазоне',
'Anchored volume profile': 'Профиль объема с привязкой',
'Fibonacci retracement': 'Коррекция Фибоначчи',
Rectangle: 'Прямоугольник',
Traectory: 'Траектория',
'Geometric shapes': 'Геометрические фигуры',
Text: 'Текст',
'XABCD template': 'Шаблон XABCD',
'Measurement tools': 'Инструменты для измерения и прогнозирования',
Ruler: 'Линейка',
Upscale: 'Увеличить масштаб',
Downscale: 'Уменьшить масштаб',
'Gann and Fibonacci': 'Инструменты Ганна и Фибоначчи',
'Endless drawing mode': 'Продолжить в режиме рисования',
'Fix all objects': 'Зафиксировать все объекты',
'Hide all drawing objects': 'Скрыть все объекты рисования',
'Clear objects': 'Удалить объекты',
'DOM tree': 'Дерево объектов',
'Magnet allows you to attract objects points to the nearest bar prices':
'Магнит позволяет притягивать точки объектов к ближайшим ценам (откр., макс., мин., закр.) баров',
Pin: 'Значок',
Open: 'Откр.',
High: 'Макс.',
Low: 'Мин.',
Close: 'Закр.',
Change: 'Изм.',
Time: 'Время',
Arguments: 'Аргументы',
'Account size': 'Размер счёта',
'Lot size': 'Размер лота',
Risk: 'Риск',
'Entry price': 'Цена открытия',
Leverage: 'Кредитное плечо',
'Profit price': 'Цена цели',
'Stop price': 'Цена стоп-уровня',
'QTY precision': 'Точность количества',
'Line width': 'Толщина линии',
'Stop color': 'Цвет стоп-уровня',
'Target color': 'Цвет цели',
// settings
Area: 'Область',
Border: 'Граница',
'Font size': 'Размер текста',
Bold: 'Жирный',
Italic: 'Курсив',
'Text color': 'Цвет текста',
'Background color': 'Цвет фона',
Lock: 'Заблокировать',
Unlock: 'Разблокировать',
Remove: 'Удалить',
Style: 'Стиль',
'Background transparency': 'Прозрачность фона',
Background: 'Фон',
Opacity: 'Прозрачность',
Reverse: 'Переворот',
'Labels position': 'Положение меток',
'Show prices': 'Отображать цену',
'Show level values': 'Отображать уровень',
Color: 'Цвет',
Levels: 'Уровни',
Level: 'Уровень',
Left: 'Слева',
Right: 'Справа',
'Enter text': 'Введите текст',
'Profit zone': 'Зона прибыли',
'Loss zone': 'Зона убытка',
'Line color': 'Цвет линии',
'Growing volume': 'Растущий объём',
'Decreasing volume': 'Снижающий объём',
'POC-line': 'POC-линия',
'Parallel channel': 'Параллельный канал',
'Regression trend': 'Регрессионный тренд',
'Middle line': 'Средняя линия',
'Channel line': 'Граница канала',
'Upper area': 'Верхняя зона',
'Lower area': 'Нижняя зона',
'Show middle line': 'Показывать среднюю линию',
'Show correlation': 'Показывать корреляцию',
Length: 'Длинна',
Data: 'Данные',
'Open price': 'Цена открытия',
Max: 'Максимум',
Min: 'Минимум',
'Close price': 'Цена закрытия',
Offset: 'Отступ',
'Fast length': 'Длина Fast',
'Slow length': 'Длина Slow',
'Signal length': 'Длина сигнала',
'Oscillator MA type': 'Тип осцилятора MA',
'Signal MA type': 'Тип сигнала MA',
'MA length': 'Длина MA',
Save: 'Сохранить',
Reject: 'Отменить',
Volume: 'Объём',
'1D': '1Д',
'5D': '5Д',
'1M': '1М',
'3M': '3М',
'6M': '6М',
'1Y': '1Г',
All: 'Всё',
'12h': '12ч',
'24h': '24ч',
"Mon Q3 '97": "Пн кв3 '97",
'Mon Q3 1997': 'Пн кв3 1997',
"Mon 29 Sep '97": "Пн 29 сен. '97",
"Mon Sep '97": "Пн сен. '97",
'Mon Sep 29, 1997': 'Пн сен. 29, 1997',
'Mon Sep 1997': 'Пн сен. 1997',
'Mon Sep 29': 'Пн сен. 29',
'Mon 29 Sep': 'Пн 29 сен.',
'Mon 1997-09-29': 'Пн 1997-09-29',
'Mon 97-09-29': 'Пн 97-09-29',
'Mon 97/09/29': 'Пн 97/09/29',
'Mon 1997/09/29': 'Пн 1997/09/29',
'Mon 29-09-1997': 'Пн 29-09-1997',
'Mon 29-09-97': 'Пн 29-09-97',
'Mon 29/09/97': 'Пн 29/09/97',
'Mon 29/09/1997': 'Пн 29/09/1997',
'Mon 09/29/97': 'Пн 09/29/97',
'Mon 09/29/1997': 'Пн 09/29/1997',
'09.29.1997 00:00:00': '09.29.1997 00:00:00',
'interval for': 'интервал для',
timeframe: 'таймфрейма',
'Open P&L': 'Откр. поз. П/Л',
Qty: 'Кол-во',
'Risk/Reward Ratio': 'Соотношение риск/прибыль',
Target: 'Цель',
Amount: 'Количество',
Stop: 'Стоп',
Vol: 'Объем',
bars: 'бары',
AutoScale: 'Автомасштаб',
Logarithmic: 'Логарифмическая',
'1t': '1т',
'10t': '10т',
'100t': '100т',
'1000t': '1000т',
'1s': '1с',
'5s': '5с',
'10s': '10с',
'15s': '15с',
'30s': '30с',
'45s': '45с',
'1m': '1м',
'2m': '2м',
'3m': '3м',
'5m': '5м',
'10m': '10м',
'15m': '15м',
'30m': '30м',
'45m': '45м',
'1h': '1ч',
'2h': '2ч',
'3h': '3ч',
'4h': '4ч',
'8h': '8ч',
'1d': '1д',
'3d': '3д',
'5d': '5д',
'1w': '1н',
'1М': '1M',
'3М': '3M',
'Add a main instrument to display indicators': 'Добавьте основной инструмент для отображения индикаторов',
'Show drawing toolbar': 'Показать панель рисования',
'Hide drawing toolbar': 'Скрыть панель рисования',
'Symbol search': 'Поиск инструмента',
'Compare symbols': 'Сравнить инструменты',
Timeframe: 'Таймфрейм',
'Chart type': 'Тип графика',
Undo: 'Отменить',
Redo: 'Повторить',
Snapshot: 'Снимок графика',
'Copy image': 'Копировать изображение',
Fullscreen: 'Во весь экран',
'Exit fullscreen': 'Выйти из полноэкранного режима',
Price: 'Цена',
Percent: 'Процент',
'Loading seasonality': 'Загрузка сезонности…',
'No seasonality data': 'Нет данных за выбранные годы',
'Unable to load seasonality': 'Не удалось загрузить сезонность',
'Percentage requires a positive baseline': 'Для процентов нужна положительная базовая цена года',
Chart: 'График',
Seasonality: 'Сезонность',
Average: 'Среднее',
Avg: 'Средн.',
Date: 'Дата',
Year: 'Год',
January: 'Январь',
February: 'Февраль',
March: 'Март',
April: 'Апрель',
May: 'Май',
June: 'Июнь',
July: 'Июль',
August: 'Август',
September: 'Сентябрь',
October: 'Октябрь',
November: 'Ноябрь',
December: 'Декабрь',
};