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


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>
  );
}

















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}
          aria-label={label ? `${label}: minimum` : 'Minimum'}
          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}
          aria-label={label ? `${label}: maximum` : 'Maximum'}
          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) => (
          <span
            key={value}
            className={styles.range_scale_label}
            style={{
              left: `${span > 0 ? ((value - min) / span) * 100 : 0}%`,
            }}
          >
            {value}
            {suffix}
          </span>
        ))}
      </div>
    </div>
  );
};

















seasonalityContainer.tabIndex = -1;
chartAreaContainer.tabIndex = -1;

Object.assign(seasonalityContainer.style, {
  outline: 'none',
  userSelect: 'none',
});

chartAreaContainer.style.outline = 'none';