Загрузка данных
'Seasonality chart': 'График сезонности',
'Seasonality table': 'Таблица сезонности',
'Average for selected years': 'Среднее по выбранным годам',
Scale: 'Шкала',
import type { SVGProps } from 'react';
export function DragHandleIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" {...props}>
<circle cx="5" cy="3" r="1" />
<circle cx="11" cy="3" r="1" />
<circle cx="5" cy="8" r="1" />
<circle cx="11" cy="8" r="1" />
<circle cx="5" cy="13" r="1" />
<circle cx="11" cy="13" r="1" />
</svg>
);
}
import classNames from 'classnames';
import { type ChangeEvent, type PointerEvent, useId, useRef } from 'react';
import { DragHandleIcon } from '@src/components/Icon/DragHandleIcon';
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}
>
<DragHandleIcon className={styles.range_dragIcon} />
</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 { DragHandleIcon } from '@src/components/Icon/DragHandleIcon';
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}
>
<DragHandleIcon />
</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)),
};
}
.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: flex;
align-items: center;
justify-content: center;
align-self: stretch;
width: var(--space-1500);
padding: 0;
border: 0;
color: var(--neutral-700);
background-color: transparent;
cursor: grab;
touch-action: none;
&:active {
cursor: grabbing;
}
svg {
pointer-events: none;
}
}
}