Загрузка данных
@use '../../theme/mixins' as m;
[data-theme='mxt'],
[data-theme='mb'][data-mode='light'] {
--dd-item-text-selected: var(--neutral-2);
--dd-item-bg-selected: var(--neutral-12);
--color-field-border: var(--neutral-11);
--color-field-square: #00000024;
}
[data-theme='tr'],
[data-theme='mb'][data-mode='dark'] {
--dd-item-text-selected: var(--neutral-13);
--dd-item-bg-selected: var(--neutral-5);
--color-field-border: var(--neutral-9);
--color-field-square: #ffffff14;
}
$button-color-bottom: 5px;
.input {
color: var(--neutral-12);
input {
color: var(--neutral-12);
}
&_select {
&:focus-within {
background-color: transparent !important;
}
div {
background-color: transparent;
&:focus-within {
background-color: transparent !important;
}
.input {
background-color: transparent;
}
}
}
&_color {
display: flex;
flex-direction: column;
gap: var(--space-0250);
.input_wrapper {
position: relative;
width: 100%;
}
}
}
.dropdown {
li {
&[aria-selected='true'] {
background-color: var(--dd-item-bg-selected);
div[aria-label='Label'] {
color: var(--dd-item-text-selected);
}
}
svg {
color: transparent;
}
}
}
.button_color {
position: absolute;
bottom: $button-color-bottom;
right: var(--space-0500);
width: var(--space-1375);
height: var(--space-1375);
border: 1px solid var(--color-field-border);
border-radius: var(--space-0125);
}
.input_checkbox {
padding: var(--space-0375) 0 !important;
span {
font-size: var(--space-0875);
line-height: normal;
}
}
.input_textarea {
textarea {
font-size: var(--space-0875);
line-height: normal;
}
}
.range {
--range-input-height: var(--space-1000);
--range-track-height: var(--space-0750);
--range-thumb-size: var(--space-1000);
--range-pattern-size: var(--space-0500);
--range-label-font-size: var(--space-0875);
--range-value-font-size: var(--space-0750);
--range-wrapper-gap: var(--space-0500);
display: flex;
flex-direction: column;
gap: var(--space-0250);
&_sm {
--range-input-height: var(--space-0875);
--range-track-height: var(--space-0500);
--range-thumb-size: var(--space-0875);
--range-pattern-size: var(--space-0375);
--range-label-font-size: var(--space-0750);
--range-wrapper-gap: var(--space-0375);
}
&_label {
color: var(--neutral-12);
font-size: var(--range-label-font-size);
}
&_wrapper {
display: grid;
align-items: center;
grid-template: 1fr / 1fr auto;
gap: var(--range-wrapper-gap);
}
&_value {
min-width: var(--space-2000);
color: var(--neutral-12);
font-size: var(--range-value-font-size);
line-height: normal;
text-align: right;
}
&_input {
--range-color: var(--blue-1);
width: 100%;
height: var(--range-input-height);
appearance: none;
background: transparent;
cursor: pointer;
&::-webkit-slider-runnable-track {
height: var(--range-track-height);
border: 1px solid var(--color-field-border);
border-radius: 999px;
background: linear-gradient(to right, transparent 0%, var(--range-color) 100%),
linear-gradient(45deg, var(--color-field-square) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-field-square) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-field-square) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-field-square) 75%);
background-size:
100% 100%,
var(--range-pattern-size) var(--range-pattern-size),
var(--range-pattern-size) var(--range-pattern-size),
var(--range-pattern-size) var(--range-pattern-size),
var(--range-pattern-size) var(--range-pattern-size);
background-position:
0 0,
0 0,
0 calc(var(--range-pattern-size) / 2),
calc(var(--range-pattern-size) / 2) calc(var(--range-pattern-size) / -2),
calc(var(--range-pattern-size) / -2) 0;
}
&::-webkit-slider-thumb {
width: var(--range-thumb-size);
height: var(--range-thumb-size);
margin-top: calc((var(--range-track-height) - var(--range-thumb-size)) / 2 - 1px);
appearance: none;
border: 1px solid var(--color-field-border);
border-radius: 50%;
background-color: var(--neutral-panel);
}
&::-moz-range-track {
height: var(--range-track-height);
border: 1px solid var(--color-field-border);
border-radius: 999px;
background: linear-gradient(to right, transparent 0%, var(--range-color) 100%),
linear-gradient(45deg, var(--color-field-square) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-field-square) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-field-square) 75%),
linear-gradient(-45deg, transparent 75%, var(--color-field-square) 75%);
background-size:
100% 100%,
var(--range-pattern-size) var(--range-pattern-size),
var(--range-pattern-size) var(--range-pattern-size),
var(--range-pattern-size) var(--range-pattern-size),
var(--range-pattern-size) var(--range-pattern-size);
background-position:
0 0,
0 0,
0 calc(var(--range-pattern-size) / 2),
calc(var(--range-pattern-size) / 2) calc(var(--range-pattern-size) / -2),
calc(var(--range-pattern-size) / -2) 0;
}
&::-moz-range-progress {
background: transparent;
}
&::-moz-range-thumb {
width: var(--range-thumb-size);
height: var(--range-thumb-size);
border: 1px solid var(--color-field-border);
border-radius: 50%;
background-color: var(--neutral-panel);
}
}
&_interval {
min-width: 0;
}
&_disabled {
opacity: 0.5;
.range_selection {
cursor: default;
pointer-events: none;
}
}
&_track,
&_scale {
position: relative;
margin-inline: calc(var(--range-thumb-size) / 2);
}
&_track {
height: var(--range-thumb-size);
touch-action: none;
user-select: none;
}
&_rail,
&_selection,
&_handle,
&_tick {
position: absolute;
}
&_rail,
&_selection {
top: 50%;
height: var(--range-track-height);
transform: translateY(-50%);
border-radius: 999px;
}
&_rail {
inset-inline: 0;
background-color: var(--neutral-9);
}
&_selection {
z-index: 1;
background-color: var(--range-color);
cursor: grab;
touch-action: none;
&:active {
cursor: grabbing;
}
}
&_handle {
inset: 0;
box-sizing: border-box;
width: calc(100% + var(--range-thumb-size));
height: var(--range-thumb-size);
margin: 0;
margin-left: calc(var(--range-thumb-size) / -2);
padding: 0;
appearance: none;
border: 0;
outline: none;
background-color: transparent;
pointer-events: none;
&::-webkit-slider-runnable-track {
height: var(--range-track-height);
border: 0;
background-color: transparent;
}
&::-moz-range-track {
height: var(--range-track-height);
border: 0;
background-color: transparent;
}
&::-moz-range-progress {
background-color: transparent;
}
&::-webkit-slider-thumb {
box-sizing: border-box;
width: var(--range-thumb-size);
height: var(--range-thumb-size);
margin-top: calc((var(--range-track-height) - var(--range-thumb-size)) / 2);
appearance: none;
border: var(--borderWidth-md) solid var(--color-field-border);
border-radius: 50%;
background-color: var(--neutral-panel);
cursor: ew-resize;
pointer-events: auto;
}
&::-moz-range-thumb {
box-sizing: border-box;
width: var(--range-thumb-size);
height: var(--range-thumb-size);
border: var(--borderWidth-md) solid var(--color-field-border);
border-radius: 50%;
background-color: var(--neutral-panel);
cursor: ew-resize;
pointer-events: auto;
}
&_start {
z-index: 4;
}
&_end {
z-index: 3;
}
&_behind {
z-index: 2;
}
&:focus-visible::-webkit-slider-thumb {
outline: var(--space-0125) solid var(--range-color);
outline-offset: var(--space-0125);
}
&:focus-visible::-moz-range-thumb {
outline: var(--space-0125) solid var(--range-color);
outline-offset: var(--space-0125);
}
&:disabled::-webkit-slider-thumb {
cursor: default;
pointer-events: none;
}
&:disabled::-moz-range-thumb {
cursor: default;
pointer-events: none;
}
}
&_scale {
height: var(--space-1500);
color: var(--neutral-12);
font-size: var(--range-value-font-size);
line-height: var(--space-1000);
font-variant-numeric: tabular-nums;
opacity: 0.7;
pointer-events: none;
user-select: none;
}
&_values {
display: flex;
justify-content: space-between;
color: var(--neutral-12);
font-size: var(--range-value-font-size);
}
}
.color {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-1000);
&_label {
font-size: var(--space-0875);
color: var(--neutral-12);
font-weight: var(--font-medium);
}
&_menu {
--dropdown-width: max-content;
padding: var(--space-1000);
}
&_trigger {
min-width: 0;
width: var(--space-2000);
height: var(--space-2000);
display: flex;
justify-content: center;
align-items: center;
border-radius: var(--space-0375);
border: var(--borderWidth-md) solid var(--neutral-9);
p {
padding: 0;
}
}
&_preview {
width: var(--space-1375);
height: var(--space-1375);
border-radius: var(--space-0250);
}
}
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 {
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 = () => {
dragRef.current = null;
};
const handleDragStart = (event: PointerEvent<HTMLDivElement>) => {
if (isDisabled || event.button !== 0 || !trackRef.current) return;
const { width } = trackRef.current.getBoundingClientRect();
if (width <= 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
pointerX: event.clientX,
from,
to,
trackWidth: width,
};
};
const handleDragMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || isDisabled) return;
const pointerOffset = ((event.clientX - drag.pointerX) / drag.trackWidth) * span;
const steppedOffset = Math.round(pointerOffset / step) * step;
const offset = Math.max(min - drag.from, Math.min(max - drag.to, steppedOffset));
onRangeChange([drag.from + offset, drag.to + offset]);
};
const handleFromChange = (event: ChangeEvent<HTMLInputElement>) => {
onRangeChange([Math.min(event.currentTarget.valueAsNumber, to), to]);
};
const handleToChange = (event: ChangeEvent<HTMLInputElement>) => {
onRangeChange([from, Math.max(event.currentTarget.valueAsNumber, from)]);
};
return (
<div
className={classNames(styles.range, styles.range_interval, {
[styles.range_sm]: size === 'sm',
[styles.range_disabled]: isDisabled,
})}
>
{label && <span className={styles.range_label}>{label}</span>}
<div
ref={trackRef}
className={styles.range_track}
style={{ ['--range-color' as string]: color }}
>
<div className={styles.range_rail} />
<div
className={styles.range_selection}
style={{
left: `${left}%`,
width: `${right - left}%`,
}}
onPointerDown={handleDragStart}
onPointerMove={handleDragMove}
onPointerUp={handleDragEnd}
onPointerCancel={handleDragEnd}
onLostPointerCapture={handleDragEnd}
/>
<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}
/>
<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}
/>
</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>
);
};
.controls {
display: flex;
flex-direction: column;
min-width: 0;
gap: var(--space-0500);
&_header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-0500);
min-width: 0;
}
&_selectedRange {
font-size: var(--space-0750);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
&_range {
min-width: 0;
}
}
import { PriceScaleMode } from 'lightweight-charts';
import { Dropdown } from '@src/components/Dropdown';
import { RangeField } from '@src/components/FormFields';
import { SeasonalityScaleMenu } from '@src/components/Menu/SeasonalityScaleMenu';
import { t } from '@src/translations';
import { ChartViewType } from '@src/types/chartView';
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>;
setScale: (scale: SeasonalityScale) => void;
setRange: (range: SeasonalityRange) => void;
}
const MAX_YEAR_LABELS = 6;
function createYearMarks(minYear: number, maxYear: number): number[] {
const span = maxYear - minYear;
if (span <= 0) return [minYear];
let intervals = Math.min(MAX_YEAR_LABELS - 1, span);
while (span % intervals !== 0) {
intervals -= 1;
}
const step = span / intervals;
return Array.from({ length: intervals + 1 }, (_, index) => minYear + index * step);
}
export function SeasonalityControls({ stateObs, setScale, setRange }: SeasonalityControlsProps) {
const state = useObservable(stateObs);
if (!state || state.view !== ChartViewType.Seasonality) {
return null;
}
const selectedScaleLabel = state.scale === PriceScaleMode.Normal ? t('Price') : t('Percent');
const isRangeDisabled = state.status === 'loading' || state.status === 'error';
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}>
<Dropdown selectedValue={selectedScaleLabel}>
<SeasonalityScaleMenu
selectedScale={state.scale}
onClick={setScale}
/>
</Dropdown>
</div>
<div className={styles.controls_range}>
<RangeField
mode="range"
value={[state.fromYear, state.toYear]}
min={state.minYear}
max={state.maxYear}
marks={marks}
step={1}
suffix=""
size="sm"
disabled={isRangeDisabled}
onRangeChange={handleRangeChange}
/>
</div>
</div>
);
}