Загрузка данных


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}
        aria-hidden="true"
      >
        {scaleMarks.map((value, index) => (
          <span
            key={value}
            className={styles.range_tick}
            style={{
              left: `${span > 0 ? ((value - min) / span) * 100 : 0}%`,
            }}
          >
            <span
              className={classNames(styles.range_tick_label, {
                [styles.range_tick_label_start]: index === 0,
                [styles.range_tick_label_end]:
                  scaleMarks.length > 1 &&
                  index === scaleMarks.length - 1,
              })}
            >
              {value}
              {suffix}
            </span>
          </span>
        ))}
      </div>
    </div>
  );
};

















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 YEAR_MARKS_COUNT = 5;

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 = Array.from(
    { length: YEAR_MARKS_COUNT },
    (_, index) =>
      state.minYear +
      Math.floor(
        ((state.maxYear - state.minYear) * index) /
          (YEAR_MARKS_COUNT - 1),
      ),
  );

  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>

        <span className={styles.controls_selectedRange}>
          {state.fromYear === state.toYear
            ? state.fromYear
            : `${state.fromYear} — ${state.toYear}`}
        </span>
      </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>
  );
}
















.controls_header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-1000);
}

.controls_selectedRange {
  font-size: var(--space-0750);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}