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


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


















import { PriceScaleMode } from 'lightweight-charts';

import { Button } from '@src/components/Button';
import { Dropdown } from '@src/components/Dropdown';
import { RangeField } from '@src/components/FormFields';
import { LineIcon, TableIcon } from '@src/components/Icon';
import { SeasonalityScaleMenu } from '@src/components/Menu/SeasonalityScaleMenu';

import { t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
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>;
  setDisplayMode: (displayMode: SeasonalityViewType) => void;
  setScale: (scale: SeasonalityScale) => void;
  setRange: (range: SeasonalityRange) => void;
}

const MAX_SCALE_LABELS = 5;

function createYearMarks(minYear: number, maxYear: number): number[] {
  const span = maxYear - minYear;

  if (span <= 0) return [minYear];

  const intervals = Math.min(MAX_SCALE_LABELS - 1, span);

  return Array.from({ length: intervals + 1 }, (_, index) => minYear + Math.floor((span * index) / intervals));
}

export function SeasonalityControls({ stateObs, setDisplayMode, setScale, setRange }: SeasonalityControlsProps) {
  const state = useObservable(stateObs);

  if (!state || state.status === 'idle') {
    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}>
        <div className={styles.controls_views}>
          <Button
            label={<LineIcon />}
            pressed={state.displayMode === SeasonalityViewType.Chart}
            onClick={() => setDisplayMode(SeasonalityViewType.Chart)}
          />
          <Button
            label={<TableIcon />}
            pressed={state.displayMode === SeasonalityViewType.Table}
            onClick={() => setDisplayMode(SeasonalityViewType.Table)}
          />
        </div>

        <div className={styles.controls_scale}>
          <Dropdown selectedValue={selectedScaleLabel}>
            <SeasonalityScaleMenu
              selectedScale={state.scale}
              onClick={setScale}
            />
          </Dropdown>
        </div>
      </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 {
  display: flex;
  flex-direction: column;
  min-width: 0;
  gap: var(--space-0500);

  &_header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: var(--space-0500);
    min-width: 0;
  }

  &_views {
    display: flex;
    align-items: center;
    gap: var(--space-0125);
    border: var(--borderWidth-md) solid var(--neutral-7);
    border-radius: var(--space-0250);
  }

  &_scale {
    border: var(--borderWidth-md) solid var(--neutral-7);
    border-radius: var(--space-0250);
  }

  &_range {
    min-width: 0;
  }
}

















import classNames from 'classnames';
import { Button as ExchangeButton } from 'exchange-elements/v2';
import { type ComponentProps } from 'react';

import styles from './index.module.scss';

export type ExchangeButtonProps = ComponentProps<typeof ExchangeButton>;

export interface ButtonProps extends ExchangeButtonProps {
  pressed?: boolean;
  selected?: boolean;
}

export function Button({ size = 'sm', pressed = false, selected = false, className, ...props }: ButtonProps) {
  return (
    <ExchangeButton
      {...props}
      size={size}
      className={classNames(styles.button, className, {
        [styles.pressed]: pressed,
        [styles.selected]: selected,
      })}
    />
  );
}


















import { PriceScaleMode } from 'lightweight-charts';
import { useMemo } from 'react';

import { Table, type TableColumn } from '@src/components/Table';
import { getLocale, t } from '@src/translations';
import { SeasonalityViewType } from '@src/types';
import { getPercentPrecision, getPricePrecision, useObservable } from '@src/utils';

import styles from './index.module.scss';

import type { SeasonalityChange, SeasonalityState, SeasonalityTableRow } from '@src/types';
import type { ReactNode } from 'react';
import type { Observable } from 'rxjs';

const MONTHS = [
  'January',
  'February',
  'March',
  'April',
  'May',
  'June',
  'July',
  'August',
  'September',
  'October',
  'November',
  'December',
] as const;

interface SeasonalityTableProps {
  stateObs: Observable<SeasonalityState>;
}

export function SeasonalityTable({ stateObs }: SeasonalityTableProps) {
  const state = useObservable(stateObs);
  const locale = getLocale();
  const fractionDigits = state?.scale === PriceScaleMode.Percentage ? getPercentPrecision() : getPricePrecision();

  const formatter = useMemo(
    () =>
      new Intl.NumberFormat(locale, {
        minimumFractionDigits: fractionDigits,
        maximumFractionDigits: fractionDigits,
      }),
    [locale, fractionDigits],
  );

  if (!state || state.status === 'idle' || state.displayMode !== SeasonalityViewType.Table) {
    return null;
  }

  const valueKey = state.scale === PriceScaleMode.Percentage ? 'percentage' : 'absolute';
  const suffix = state.scale === PriceScaleMode.Percentage ? '%' : '';

  const formatChange = (change: SeasonalityChange | null): string => {
    const value = change?.[valueKey] ?? null;

    return value === null ? '—' : `${formatter.format(value)}${suffix}`;
  };

  const getChangeClassName = (change: SeasonalityChange | null): string | undefined => {
    const value = change?.[valueKey] ?? null;

    if (value !== null && value > 0) {
      return styles.positive;
    }

    if (value !== null && value < 0) {
      return styles.negative;
    }

    return undefined;
  };

  const createChangeColumn = (
    id: string,
    header: ReactNode,
    getChange: (row: SeasonalityTableRow) => SeasonalityChange | null,
    className?: string,
  ): TableColumn<SeasonalityTableRow> => ({
    id,
    header,
    align: 'right',
    className,
    renderCell: (row) => formatChange(getChange(row)),
    getCellClassName: (row) => getChangeClassName(getChange(row)),
  });

  const columns: TableColumn<SeasonalityTableRow>[] = [
    {
      id: 'date',
      header: t('Date'),
      rowHeader: true,
      renderCell: (row) => row.year,
    },
    ...MONTHS.map((month, index) => createChangeColumn(month, t(month), (row) => row.months[index])),
    createChangeColumn('year', t('Year'), (row) => row.total, styles.total),
  ];

  const emptyContent = getEmptyContent(state.status);

  return (
    <Table
      className={styles.seasonality}
      columns={columns}
      rows={state.tableRows}
      getRowKey={(row) => row.year}
      emptyContent={emptyContent}
      stickyFirstColumn
    />
  );
}

function getEmptyContent(status: SeasonalityState['status']): string {
  if (status === 'loading') {
    return t('Loading seasonality');
  }

  if (status === 'error') {
    return t('Unable to load seasonality');
  }

  return t('No seasonality data');
}

















[data-theme='mxt'] {
  --cell-background-positive: #24a249;
  --cell-background-negative: #d71d38;
}

[data-theme='mb'] {
  --cell-background-positive: #3cae0b;
  --cell-background-negative: #d71d38;
}

[data-theme='tr'] {
  --cell-background-positive: #27ef0085;
  --cell-background-negative: #ff000085;
}

.seasonality {
  font-variant-numeric: tabular-nums;
}

.positive {
  --table-cell-background: color-mix(
    in srgb,
    var(--cell-background-positive) 35%,
    var(--table-background, var(--neutral-0))
  );
}

.negative {
  --table-cell-background: color-mix(
    in srgb,
    var(--cell-background-negative) 35%,
    var(--table-background, var(--neutral-0))
  );
}

.total {
  --table-cell-font-weight: 600;
}

















import styles from './index.module.scss';

import type { Key, ReactNode } from 'react';

export interface TableColumn<Row> {
  id: string;
  header: ReactNode;
  renderCell: (row: Row) => ReactNode;
  align?: 'left' | 'center' | 'right';
  rowHeader?: boolean;
  className?: string;
  getCellClassName?: (row: Row) => string | undefined;
}

export interface TableProps<Row> {
  columns: readonly TableColumn<Row>[];
  rows: readonly Row[];
  getRowKey: (row: Row) => Key;
  className?: string;
  stickyHeader?: boolean;
  stickyFirstColumn?: boolean;
  emptyContent?: ReactNode;
}

export function Table<Row>({
  columns,
  rows,
  getRowKey,
  className,
  stickyHeader = true,
  stickyFirstColumn = false,
  emptyContent = '—',
}: TableProps<Row>) {
  const wrapperClassName = [
    styles.table,
    stickyHeader && styles.table_stickyHeader,
    stickyFirstColumn && styles.table_stickyFirstColumn,
    className,
  ]
    .filter(Boolean)
    .join(' ');

  const columnClassNames = columns.map((column) =>
    [styles.table_cell, styles[`table_cell_${column.align ?? 'left'}`], column.className].filter(Boolean).join(' '),
  );

  return (
    <div className={wrapperClassName}>
      <table className={styles.table_content}>
        <thead>
          <tr>
            {columns.map((column, index) => (
              <th
                key={column.id}
                scope="col"
                className={columnClassNames[index]}
              >
                {column.header}
              </th>
            ))}
          </tr>
        </thead>

        <tbody>
          {rows.map((row) => (
            <tr key={getRowKey(row)}>
              {columns.map((column, index) => {
                const Cell = column.rowHeader ? 'th' : 'td';
                const cellClassName = [columnClassNames[index], column.getCellClassName?.(row)]
                  .filter(Boolean)
                  .join(' ');

                return (
                  <Cell
                    key={column.id}
                    scope={column.rowHeader ? 'row' : undefined}
                    className={cellClassName}
                  >
                    {column.renderCell(row)}
                  </Cell>
                );
              })}
            </tr>
          ))}

          {rows.length === 0 && (
            <tr>
              <td
                colSpan={columns.length}
                className={styles.table_empty}
              >
                {emptyContent}
              </td>
            </tr>
          )}
        </tbody>
      </table>
    </div>
  );
}


















@use '../../theme/mixins' as m;

.table {
  width: 100%;
  height: 100%;
  min-width: 0;
  min-height: 0;
  max-width: 100%;
  max-height: 100%;
  overflow: auto;
  color: var(--table-text, var(--neutral-12));
  background-color: var(--table-background, var(--neutral-0));

  @include m.scrollbar;

  &_content {
    width: 100%;
    min-width: max-content;
    border-collapse: separate;
    border-spacing: 0;
  }

  &_cell {
    font-size: var(--space-0750);
    padding: var(--space-0500);
    border-right: var(--borderWidth-md) solid var(--table-border, var(--neutral-7));
    border-bottom: var(--borderWidth-md) solid var(--table-border, var(--neutral-7));
    background-color: var(--table-cell-background, var(--table-background, var(--neutral-0)));
    white-space: nowrap;
    font-weight: var(--table-cell-font-weight, var(--fontWeight-regular));

    &:last-child {
      border-right: 0;
    }

    &_left {
      text-align: left;
    }

    &_center {
      text-align: center;
    }

    &_right {
      text-align: right;
    }
  }

  &_content thead &_cell {
    border-top: var(--borderWidth-md) solid var(--table-border, var(--neutral-7));
  }

  &_stickyHeader thead .table_cell {
    position: sticky;
    top: 0;
    z-index: 2;
  }

  &_stickyFirstColumn .table_cell:first-child {
    position: sticky;
    left: 0;
    z-index: 1;
  }

  &_stickyHeader.table_stickyFirstColumn thead .table_cell:first-child {
    z-index: 3;
  }

  &_empty {
    padding: var(--space-0500);
    text-align: center;
  }
}