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


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

import { CHART_DRAWING_TOOLTIP } from '@src/constants';

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

type TooltipProps = ComponentProps<typeof ExchangeTooltip>;

export function Tooltip({ tooltipClassName, direction = false, ...props }: TooltipProps) {
  return (
    <ExchangeTooltip
      {...props}
      direction={direction}
      tooltipClassName={classNames(styles.tooltip, CHART_DRAWING_TOOLTIP, tooltipClassName)}
    />
  );
}


















import classNames from 'classnames';

import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';

import { Observable } from 'rxjs';

import { Button } from '@src/components/Button';
import { Divider } from '@src/components/Divider';
import { MenuList } from '@src/components/Menu';
import SplitDropdown from '@src/components/SplitDropdown';

import {
  gannAndFibonacciTools,
  geometricShapes,
  measurementTools,
  trendLines,
} from '@src/components/Toolbar/constants';

import { Tooltip } from '@src/components/Tooltip';
import { DrawingsNames } from '@src/constants';
import { t } from '@src/translations';
import { ActiveDrawingTool } from '@src/types';
import { ensureDefined, useObservable } from '@src/utils';

import {
  CrossIcon,
  EyeBrushIcon,
  HeartIcon,
  LayersIcon,
  MagnetIcon,
  PencilLockerIcon,
  RulerIcon,
  SplineCurveIcon,
  TrashIcon,
  TypeIcon,
  UnlockIcon,
  ZoomInIcon,
} from '../Icon';

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

interface ToolbarProps {
  toggleDOM: () => void;
  addDrawing: (name: DrawingsNames) => void;
  setEndlessDrawingsMode: (value: boolean) => void;
  isEndlessDrawingsMode$: Observable<boolean>;
  activateCrosshair: () => void;
  activeTool$: Observable<ActiveDrawingTool>;
}

const implemented = {
  crosshair: true,
  lines: true,
  fib: true,
  rectangle: true,
  text: true,
  XABCD: false,
  position: true,
  icons: false,
};

export default function Toolbar({
  toggleDOM,
  addDrawing,
  setEndlessDrawingsMode,
  isEndlessDrawingsMode$,
  activateCrosshair,
  activeTool$,
}: ToolbarProps) {
  const [selectedLineType, setSelectedLineType] = useState<DrawingsNames>(DrawingsNames.trendLine);
  const [selectedMeasurementTool, setSelectedMeasurementTool] = useState(DrawingsNames.fixedRangeProfile);
  const [selectedGeometricShape, setSelectedGeometricShape] = useState(DrawingsNames.rectangle);
  const [selectedGannAndFibonacci, setSelectedGannAndFibonacci] = useState(DrawingsNames.fibonacciRetracement);

  const isEndlessDrawingsMode = useObservable(isEndlessDrawingsMode$);
  const activeTool = useObservable(activeTool$, 'crosshair');

  const toolbarRef = useRef<HTMLDivElement | null>(null);

  const createDrawingHandler = (setter: Dispatch<SetStateAction<DrawingsNames>>) => (value: DrawingsNames) => {
    setter(value);
    addDrawing(value);
  };

  const findOptionByValue = <T extends { value: string }>(options: T[], selectedValue: T['value']): T | undefined =>
    options.find((item) => item.value === selectedValue);

  const selectedTrendLineOption = findOptionByValue(trendLines(), selectedLineType);
  const selectedMeasurementToolOption = findOptionByValue(measurementTools(), selectedMeasurementTool);
  const selectedGeometricShapeOption = findOptionByValue(geometricShapes(), selectedGeometricShape);
  const selectedGannAndFibonacciOption = findOptionByValue(gannAndFibonacciTools(), selectedGannAndFibonacci);

  const isButtonPressed = (tool: ActiveDrawingTool): boolean => activeTool === tool;

  useEffect(() => {
    if (activeTool === 'crosshair') {
      return;
    }

    if (trendLines().some(({ value }) => value === activeTool)) {
      setSelectedLineType(activeTool);
      return;
    }
    if (gannAndFibonacciTools().some(({ value }) => value === activeTool)) {
      setSelectedGannAndFibonacci(activeTool);
      return;
    }
    if (geometricShapes().some(({ value }) => value === activeTool)) {
      setSelectedGeometricShape(activeTool);
      return;
    }
    if (measurementTools().some(({ value }) => value === activeTool)) {
      setSelectedMeasurementTool(activeTool);
    }
  }, [activeTool]);

  return (
    <div
      ref={toolbarRef}
      className={styles.toolbar}
    >
      <div className={classNames(styles.group)}>
        {implemented.crosshair && (
          <Tooltip
            showMessageOnFocus
            label={t('Crosshair')}
            location="right"
          >
            <Button
              onClick={activateCrosshair}
              pressed={isButtonPressed('crosshair')}
              label={<CrossIcon />}
            />
          </Tooltip>
        )}

        {implemented.lines && (
          <SplitDropdown
            anchorRef={toolbarRef}
            mainContent={ensureDefined(selectedTrendLineOption?.icon)}
            mainTooltip={ensureDefined(selectedTrendLineOption).label}
            onMainClick={() => addDrawing(selectedLineType)}
            menuTooltip={t('Trend lines')}
            mainButtonPressed={isButtonPressed(selectedLineType)}
          >
            <MenuList
              mode="single"
              value={selectedLineType}
              options={trendLines()}
              onClick={createDrawingHandler(setSelectedLineType)}
            />
          </SplitDropdown>
        )}

        {implemented.fib && (
          <SplitDropdown
            anchorRef={toolbarRef}
            mainContent={ensureDefined(selectedGannAndFibonacciOption?.icon)}
            mainTooltip={ensureDefined(selectedGannAndFibonacciOption).label}
            onMainClick={() => addDrawing(selectedGannAndFibonacci)}
            menuTooltip={t('Gann and Fibonacci')}
            mainButtonPressed={isButtonPressed(selectedGannAndFibonacci)}
          >
            <MenuList
              mode="single"
              value={selectedGannAndFibonacci}
              options={gannAndFibonacciTools()}
              onClick={createDrawingHandler(setSelectedGannAndFibonacci)}
            />
          </SplitDropdown>
        )}

        {implemented.rectangle && (
          <SplitDropdown
            anchorRef={toolbarRef}
            mainContent={ensureDefined(selectedGeometricShapeOption?.icon)}
            mainTooltip={ensureDefined(selectedGeometricShapeOption).label}
            onMainClick={() => addDrawing(selectedGeometricShape)}
            menuTooltip={t('Geometric shapes')}
            mainButtonPressed={isButtonPressed(selectedGeometricShape)}
          >
            <MenuList
              mode="single"
              value={selectedGeometricShape}
              options={geometricShapes()}
              onClick={createDrawingHandler(setSelectedGeometricShape)}
            />
          </SplitDropdown>
        )}

        {implemented.text && (
          <Tooltip
            showMessageOnFocus
            label={t('Text')}
            location="right"
          >
            <Button
              pressed={isButtonPressed(DrawingsNames.text)}
              onClick={() => addDrawing(DrawingsNames.text)}
              label={<TypeIcon />}
            />
          </Tooltip>
        )}

        {implemented.XABCD && (
          <Tooltip
            showMessageOnFocus
            label={t('XABCD template')}
            location="right"
          >
            <Button
              onClick={() => {}}
              label={<SplineCurveIcon />}
            />
          </Tooltip>
        )}

        {implemented.position && (
          <SplitDropdown
            anchorRef={toolbarRef}
            mainContent={ensureDefined(selectedMeasurementToolOption?.icon)}
            mainTooltip={ensureDefined(selectedMeasurementToolOption).label}
            menuTooltip={t('Measurement tools')}
            onMainClick={() => addDrawing(selectedMeasurementTool)}
            mainButtonPressed={isButtonPressed(selectedMeasurementTool)}
          >
            <MenuList
              mode="single"
              value={selectedMeasurementTool}
              options={measurementTools()}
              onClick={createDrawingHandler(setSelectedMeasurementTool)}
            />
          </SplitDropdown>
        )}

        {implemented.icons && (
          <Tooltip
            label={t('Pin')}
            location="right"
            showMessageOnFocus
          >
            <Button
              onClick={() => {}}
              label={<HeartIcon />}
            />
          </Tooltip>
        )}
      </div>

      <Divider classNameDivider={styles.divider} />

      <div className={classNames(styles.group)}>
        <Tooltip
          showMessageOnFocus
          label={t('Ruler')}
          location="right"
        >
          <Button
            pressed={isButtonPressed(DrawingsNames.ruler)}
            onClick={() => addDrawing(DrawingsNames.ruler)}
            label={<RulerIcon />}
          />
        </Tooltip>

        <Tooltip
          showMessageOnFocus
          label={t('Upscale')}
          location="right"
          className={styles.notImplemented}
        >
          <Button
            onClick={() => {}}
            label={<ZoomInIcon />}
          />
        </Tooltip>
      </div>

      <Divider classNameDivider={styles.divider} />

      <div className={classNames(styles.group)}>
        <Tooltip
          showMessageOnFocus
          label={t('Magnet allows you to attract objects points to the nearest bar prices')}
          location="right"
          className={styles.notImplemented}
        >
          <Button
            onClick={() => {}}
            label={<MagnetIcon />}
          />
        </Tooltip>

        <Tooltip
          showMessageOnFocus
          label={t('Endless drawing mode')}
          location="right"
        >
          <Button
            pressed={isEndlessDrawingsMode}
            onClick={() => setEndlessDrawingsMode(!isEndlessDrawingsMode)}
            label={<PencilLockerIcon />}
          />
        </Tooltip>

        <Tooltip
          showMessageOnFocus
          label={t('Fix all objects')}
          location="right"
          className={styles.notImplemented}
        >
          <Button
            onClick={() => {}}
            label={<UnlockIcon />}
          />
        </Tooltip>

        <Tooltip
          showMessageOnFocus
          label={t('Hide all drawing objects')}
          location="right"
          className={styles.notImplemented}
        >
          <Button
            onClick={() => {}}
            label={<EyeBrushIcon />}
          />
        </Tooltip>
      </div>

      <Divider classNameDivider={styles.divider} />

      <div className={classNames(styles.group)}>
        <Tooltip
          showMessageOnFocus
          label={t('Clear objects')}
          location="right"
          className={styles.notImplemented}
        >
          <Button
            onClick={() => {}}
            label={<TrashIcon />}
          />
        </Tooltip>

        <Tooltip
          showMessageOnFocus
          label={t('DOM tree')}
          location="right"
        >
          <Button
            onClick={toggleDOM}
            label={<LayersIcon />}
          />
        </Tooltip>
      </div>
    </div>
  );
}



















import classNames from 'classnames';
import { PropsWithChildren, RefObject } from 'react';

import { Button } from '@src/components/Button';
import { DropdownPosition } from '@src/components/Dropdown/types';
import SmallChevronLeftIcon from '@src/components/Icon/SmallChevronLeft';
import { Tooltip } from '@src/components/Tooltip';

import { Dropdown } from '../Dropdown';

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

interface SplitDropdownProps extends PropsWithChildren {
  position?: DropdownPosition;

  mainContent: JSX.Element;
  menuButtonContent?: JSX.Element;

  mainTooltip?: string;
  menuTooltip?: string;

  anchorRef?: RefObject<HTMLDivElement>;
  onMainClick?: () => void;

  mainButtonClassName?: string;
  mainButtonPressed?: boolean;
  tooltipClassName?: string;
}

export default function SplitDropdown({
  position = 'right',
  mainContent,
  menuButtonContent = <SmallChevronLeftIcon />,
  mainTooltip,
  menuTooltip,
  anchorRef,
  onMainClick,
  mainButtonClassName = '',
  mainButtonPressed = false,
  tooltipClassName,
  children,
}: SplitDropdownProps) {
  return (
    <Dropdown
      position={position}
      anchorRef={anchorRef}
      buttonClassName={mainButtonClassName}
      renderTrigger={({ isOpen, onToggle }) => (
        <div
          className={classNames(styles.dropdown, {
            [styles.open]: isOpen,
          })}
        >
          <Tooltip
            tooltipClassName={classNames(styles.tooltip__main, tooltipClassName)}
            showMessageOnFocus={!isOpen}
            location="right"
            label={mainTooltip}
          >
            <Button
              className={mainButtonClassName}
              pressed={mainButtonPressed || isOpen}
              onClick={onMainClick}
              label={mainContent}
            />
          </Tooltip>
          <Tooltip
            tooltipClassName={tooltipClassName}
            showMessageOnFocus={!isOpen}
            location="right"
            label={menuTooltip}
          >
            <Button
              className={styles.menu_button}
              pressed={isOpen}
              onClick={onToggle}
              label={menuButtonContent}
            />
          </Tooltip>
        </div>
      )}
    >
      {children}
    </Dropdown>
  );
}


















import { useEffect, useLayoutEffect, useRef, useState } from 'react';

import { Button } from '@src/components/Button';

import { Portal } from '@src/components/Portal';

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

import { getDropdownCoords } from './utils';

import type { Coords, DropdownProps } from './types';

export function Dropdown({
  children,
  selectedValue,
  position = 'bottom',
  horizontalAlign = 'auto',
  className = '',
  buttonClassName = '',
  menuClassName = '',
  anchorRef,
  renderTrigger,
}: DropdownProps) {
  const [isOpenMenu, setIsOpenMenu] = useState(false);
  const [coords, setCoords] = useState<Coords | null>(null);

  const rootRef = useRef<HTMLDivElement>(null);
  const triggerRef = useRef<HTMLDivElement>(null);
  const menuRef = useRef<HTMLDivElement>(null);

  const handleToggle = (): void => {
    if (isOpenMenu) {
      handleClose();

      return;
    }

    setIsOpenMenu(true);
  };

  const handleClose = (): void => {
    setIsOpenMenu(false);
    setCoords(null);
  };

  useLayoutEffect(() => {
    if (!isOpenMenu) {
      return;
    }

    const updatePosition = (): void => {
      const triggerElement = triggerRef.current;
      const menuElement = menuRef.current;

      if (!triggerElement || !menuElement) {
        return;
      }

      const triggerRect = triggerElement.getBoundingClientRect();
      const anchorRect = anchorRef?.current?.getBoundingClientRect() ?? triggerRect;
      const menuRect = menuElement.getBoundingClientRect();

      setCoords(
        getDropdownCoords({
          position,
          horizontalAlign,
          triggerRect,
          anchorRect,
          menuRect,
        }),
      );
    };

    updatePosition();

    const resizeObserver = new ResizeObserver(updatePosition);

    if (menuRef.current) {
      resizeObserver.observe(menuRef.current);
    }

    window.addEventListener('resize', updatePosition);
    window.addEventListener('scroll', updatePosition, true);

    return () => {
      resizeObserver.disconnect();

      window.removeEventListener('resize', updatePosition);
      window.removeEventListener('scroll', updatePosition, true);
    };
  }, [anchorRef, isOpenMenu, position]);

  useEffect(() => {
    if (!isOpenMenu) {
      return;
    }

    const handlePointerDown = (event: PointerEvent): void => {
      const target = event.target as Node;

      const isInsideRoot = rootRef.current?.contains(target);
      const isInsideMenu = menuRef.current?.contains(target);

      if (!isInsideRoot && !isInsideMenu) {
        handleClose();
      }
    };

    const handleKeyDown = (event: KeyboardEvent): void => {
      if (event.key === 'Escape') {
        handleClose();
      }
    };

    document.addEventListener('pointerdown', handlePointerDown, true);
    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('pointerdown', handlePointerDown, true);
      document.removeEventListener('keydown', handleKeyDown);
    };
  }, [isOpenMenu]);

  return (
    <div
      ref={rootRef}
      className={`${styles.dropdown} ${className}`}
    >
      <div ref={triggerRef}>
        {renderTrigger ? (
          renderTrigger({
            isOpen: isOpenMenu,
            onToggle: handleToggle,
            onClose: handleClose,
          })
        ) : (
          <Button
            onClick={handleToggle}
            className={buttonClassName}
            pressed={isOpenMenu}
            label={selectedValue}
          />
        )}
      </div>

      {isOpenMenu && (
        <Portal>
          <div
            ref={menuRef}
            className={`${styles.menu} ${menuClassName}`}
            style={{
              top: coords?.top,
              left: coords?.left,
              visibility: coords ? 'visible' : 'hidden',
            }}
          >
            {children}
          </div>
        </Portal>
      )}
    </div>
  );
}

















import { ReactNode } from 'react';
import { createPortal } from 'react-dom';

import { getPortalHost } from '@src/utils';

interface PortalProps {
  children: ReactNode;
  container?: HTMLElement | null;
}

export function Portal({ children, container }: PortalProps) {
  return createPortal(children, container ?? getPortalHost());
}

















import { PriceScaleMode } from 'lightweight-charts';

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

import { Tooltip } from '@src/components/Tooltip';
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;
  setAverageVisible: (visible: boolean) => void;
  copyImage?: () => Promise<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,
  setAverageVisible,
  copyImage,
}: 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 canCopyImage = state.status === 'ready' && state.displayMode === SeasonalityViewType.Chart;
  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}>
          <Tooltip label={t('Seasonality chart')}>
            <Button
              label={<LineIcon />}
              pressed={state.displayMode === SeasonalityViewType.Chart}
              onClick={() => setDisplayMode(SeasonalityViewType.Chart)}
            />
          </Tooltip>
          <Tooltip label={t('Seasonality table')}>
            <Button
              label={<TableIcon />}
              pressed={state.displayMode === SeasonalityViewType.Table}
              onClick={() => setDisplayMode(SeasonalityViewType.Table)}
            />
          </Tooltip>
        </div>

        <div className={styles.controls_actions}>
          <Tooltip label={t('Average for selected years')}>
            <Button
              label={
                <span className={styles.controls_button}>
                  <LineIcon />
                  <span>{t('Average')}</span>
                </span>
              }
              pressed={state.showAverage}
              disabled={state.status !== 'ready'}
              onClick={() => setAverageVisible(!state.showAverage)}
            />
          </Tooltip>

          <div className={styles.controls_scale}>
            <Dropdown
              className={styles.controls_dropdown}
              selectedValue={selectedScaleLabel}
              renderTrigger={({ isOpen, onToggle }) => (
                <Tooltip label={t('Scale')}>
                  <Button
                    pressed={isOpen}
                    onClick={onToggle}
                    label={selectedScaleLabel}
                  />
                </Tooltip>
              )}
            >
              <SeasonalityScaleMenu
                selectedScale={state.scale}
                onClick={setScale}
              />
            </Dropdown>
          </div>

          {copyImage && canCopyImage && (
            <Dropdown
              className={styles.controls_dropdown}
              selectedValue={<CameraIcon />}
              renderTrigger={({ isOpen, onToggle }) => (
                <Tooltip label={t('Snapshot')}>
                  <Button
                    label={<CameraIcon />}
                    pressed={isOpen}
                    onClick={onToggle}
                  />
                </Tooltip>
              )}
            >
              <SnapshotMenu onCopyImage={copyImage} />
            </Dropdown>
          )}
        </div>
      </div>

      <div className={styles.controls_range}>
        <RangeField
          mode="range"
          value={[state.fromYear, state.toYear]}
          min={state.minYear}
          max={state.maxYear}
          marks={marks}
          marksPosition="top"
          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);
  }

  &_actions {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    flex-wrap: wrap;
    gap: var(--space-0250);
  }

  &_button {
    display: inline-flex;
    align-items: center;
    gap: var(--space-0250);
    white-space: nowrap;
  }

  &_range {
    min-width: 0;
  }
}