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


import type { SingleValueData, Time } from 'lightweight-charts';

export function isSingleValueData(data: unknown): data is SingleValueData<Time> {
  if (typeof data !== 'object' || data === null) {
    return false;
  }

  return 'time' in data && 'value' in data && typeof data.value === 'number';
}









export function calcCandleChange(
  prev: BarData | LineData | HistogramData | CustomData | null,
  current: BarData | LineData | HistogramData | CustomData | null,
):
  | (Ohlc & {
      customValues?: Record<string, unknown>;
    })
  | null {
  if (!current) {
    return null;
  }

  if (!prev) {
    return current;
  }

  if (isBarData(prev) && isBarData(current)) {
    const absoluteChange = current.close - prev.close;
    const percentageChange = ((current.close - prev.close) / prev.close) * 100;

    return {
      ...current,
      absoluteChange,
      percentageChange: Number.isNaN(percentageChange) ? 0 : percentageChange,
    };
  }

  if (isSingleValueData(prev) && isSingleValueData(current)) {
    const absoluteChange = current.value - prev.value;
    const percentageChange = ((current.value - prev.value) / prev.value) * 100;
    const high = current.customValues?.high;
    const low = current.customValues?.low;

    return {
      time: current.time,
      value: current.value,
      high: typeof high === 'number' ? high : current.value,
      low: typeof low === 'number' ? low : current.value,
      absoluteChange,
      percentageChange: Number.isNaN(percentageChange) ? 0 : percentageChange,
      customValues: current.customValues,
    };
  }

  return null;
}


















import type {
  BarData,
  CustomData,
  HistogramData,
  LineData,
  SeriesDataItemTypeMap,
  SingleValueData,
  Time,
} from 'lightweight-charts';

import type { Ohlc } from '@core/Legend';
import { BaseSeries, BaseSeriesParams, calcCandleChange } from '@core/Series/BaseSeries';

import type { ISeries } from '@src/modules/series-strategies';
import { t } from '@src/translations';
import type { Candle, LineCandle } from '@src/types';
import { formatCompactNumber } from '@src/utils';
import { isSingleValueData } from '@src/utils/isSingleValueData';
import { removeAlphaFromHex } from '@src/utils/removeAlphaFromHex';

type SingleValueSeriesType = 'Line' | 'Histogram' | 'Baseline';

type LegendData = BarData | LineData | HistogramData | CustomData | null;

type LegendValues = Partial<
  Record<
    keyof Ohlc,
    {
      value: number | string | Time;
      color: string;
      name: string;
    }
  >
>;

export abstract class SingleValueSeriesStrategy<TSeries extends SingleValueSeriesType>
  extends BaseSeries<TSeries>
  implements ISeries<TSeries>
{
  constructor(params: BaseSeriesParams<TSeries>) {
    super(params);

    this.subscribeDataSource(params.dataSource);
  }

  public validateData(data: (Partial<Candle> & Partial<LineCandle>)[]): boolean {
    return (
      Array.isArray(data) &&
      data.every((point) => typeof point.time === 'number' && typeof point.close === 'number')
    );
  }

  protected dataSourceSubscription = (dataToSet: Candle[]): void => {
    if (!this.validateData(dataToSet)) {
      console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);

      return;
    }

    this.setData(this.formatData(dataToSet));
  };

  protected dataSourceRealtimeSubscription = (dataToSet: Candle): void => {
    if (!this.validateData([dataToSet])) {
      console.error(`LightweightAPI: Invalid data format for ${this.getTypeName()} chart`);

      return;
    }

    const formattedData = this.formatData([dataToSet]);

    this.update(formattedData[0], true);
  };

  protected formatMainSerie(inputData: Candle[]): SeriesDataItemTypeMap<Time>[TSeries][] {
    return inputData.map((point) => ({
      time: point.time as Time,
      value: point.close,
      customValues: point as unknown as Record<string, unknown>,
    })) as SeriesDataItemTypeMap<Time>[TSeries][];
  }

  protected formatLegendValues(currentBar: LegendData, prevBar: LegendData): LegendValues {
    if (!isSingleValueData(currentBar)) {
      return {};
    }

    const change = calcCandleChange(prevBar, currentBar);

    if (!change) {
      return {};
    }

    const color = this.getLegendColor(currentBar);

    return {
      value: {
        value: formatCompactNumber(currentBar.value) ?? '',
        name: '',
        color,
      },
      absoluteChange: {
        value: formatCompactNumber(change.absoluteChange ?? 0),
        name: t('Change'),
        color,
      },
      percentageChange: {
        value:
          change.percentageChange !== undefined
            ? `${formatCompactNumber(change.percentageChange)}%`
            : '',
        name: t('Change'),
        color,
      },
      time: {
        value: change.time,
        name: t('Time'),
        color,
      },
    };
  }

  protected getLegendColor(currentBar: SingleValueData<Time>): string {
    if ('color' in currentBar && typeof currentBar.color === 'string') {
      return removeAlphaFromHex(currentBar.color);
    }

    const options = this.options();

    if ('color' in options && typeof options.color === 'string') {
      return options.color;
    }

    return '';
  }

  public abstract getTypeName(): string;
}

















import {
  LineSeries,
  LineStyle,
  SeriesDefinition,
  SeriesPartialOptionsMap,
} from 'lightweight-charts';

import { SingleValueSeriesStrategy } from '@core/Series/SingleValueSeriesStrategy';

import { getThemeStore } from '@src/theme/store';

export class LineSeriesStrategy extends SingleValueSeriesStrategy<'Line'> {
  protected seriesDefinition(): SeriesDefinition<'Line'> {
    return LineSeries;
  }

  protected getDefaultOptions(): SeriesPartialOptionsMap['Line'] {
    const { colors } = getThemeStore();

    return {
      color: colors.chartLineColor,
      lineWidth: 2,
      lineStyle: LineStyle.Solid,
    };
  }

  public getTypeName(): string {
    return 'Line';
  }
}

















import {
  HistogramSeries,
  SeriesDefinition,
  SeriesPartialOptionsMap,
} from 'lightweight-charts';

import { SingleValueSeriesStrategy } from '@core/Series/SingleValueSeriesStrategy';

export class HistogramSeriesStrategy extends SingleValueSeriesStrategy<'Histogram'> {
  protected seriesDefinition(): SeriesDefinition<'Histogram'> {
    return HistogramSeries;
  }

  protected getDefaultOptions(): SeriesPartialOptionsMap['Histogram'] {
    return {};
  }

  public getTypeName(): string {
    return 'Histogram';
  }
}

















import {
  BaselineSeries,
  SeriesDefinition,
  SeriesPartialOptionsMap,
  SingleValueData,
  Time,
} from 'lightweight-charts';

import { SingleValueSeriesStrategy } from '@core/Series/SingleValueSeriesStrategy';

export class BaselineSeriesStrategy extends SingleValueSeriesStrategy<'Baseline'> {
  protected seriesDefinition(): SeriesDefinition<'Baseline'> {
    return BaselineSeries;
  }

  protected getDefaultOptions(): SeriesPartialOptionsMap['Baseline'] {
    return {};
  }

  protected getLegendColor(currentBar: SingleValueData<Time>): string {
    const options = this.options();

    if (currentBar.value >= options.baseValue.price) {
      if ('topLineColor' in currentBar && typeof currentBar.topLineColor === 'string') {
        return currentBar.topLineColor;
      }

      return options.topLineColor;
    }

    if ('bottomLineColor' in currentBar && typeof currentBar.bottomLineColor === 'string') {
      return currentBar.bottomLineColor;
    }

    return options.bottomLineColor;
  }

  public getTypeName(): string {
    return 'Baseline';
  }
}

















import { BarSeriesStrategy } from '@core/Series/BarSeriesStrategy';
import { BaselineSeriesStrategy } from '@core/Series/BaselineSeriesStrategy';
import { BaseSeriesParams } from '@core/Series/BaseSeries';
import { CandlestickSeriesStrategy } from '@core/Series/CandlestickSeriesStrategy';
import { HistogramSeriesStrategy } from '@core/Series/HistogramSeriesStrategy';
import { LineSeriesStrategy } from '@core/Series/LineSeriesStrategy';

import { ChartSeriesType } from '@src/types';

export type SeriesStrategies =
  | CandlestickSeriesStrategy
  | LineSeriesStrategy
  | HistogramSeriesStrategy
  | BarSeriesStrategy
  | BaselineSeriesStrategy;

type SeriesStrategyFactory =
  | ((params: BaseSeriesParams<'Candlestick'>) => CandlestickSeriesStrategy)
  | ((params: BaseSeriesParams<'Histogram'>) => HistogramSeriesStrategy)
  | ((params: BaseSeriesParams<'Line'>) => LineSeriesStrategy)
  | ((params: BaseSeriesParams<'Bar'>) => BarSeriesStrategy)
  | ((params: BaseSeriesParams<'Baseline'>) => BaselineSeriesStrategy);

export class SeriesFactory {
  static create(type: ChartSeriesType): SeriesStrategyFactory {
    switch (type) {
      case 'Candlestick':
        return (params: BaseSeriesParams<'Candlestick'>) => new CandlestickSeriesStrategy(params);

      case 'Histogram':
        return (params: BaseSeriesParams<'Histogram'>) => new HistogramSeriesStrategy(params);

      case 'Line':
        return (params: BaseSeriesParams<'Line'>) => new LineSeriesStrategy(params);

      case 'Bar':
        return (params: BaseSeriesParams<'Bar'>) => new BarSeriesStrategy(params);

      case 'Baseline':
        return (params: BaseSeriesParams<'Baseline'>) => new BaselineSeriesStrategy(params);

      default:
        throw new Error(`Unsupported chart type: ${type}`);
    }
  }
}

















import {
  BarIcon,
  CandleStickIcon,
  LineIcon,
} from '@src/components/Icon';

import { ChartSeriesType } from '@src/types';

interface SeriesTypeIconProps {
  type?: ChartSeriesType;
}

export function SeriesTypeIcon({ type }: SeriesTypeIconProps) {
  switch (type) {
    case 'Line':
    case 'Baseline':
      return <LineIcon />;

    case 'Bar':
      return <BarIcon />;

    case 'Candlestick':
    default:
      return <CandleStickIcon />;
  }
}

















import { MenuList, MenuOption } from '@src/components/Menu/MenuList';
import { SeriesTypeIcon } from '@src/components/SeriesTypeIcon';

import { t } from '@src/translations';
import { ChartSeriesType } from '@src/types';

interface SeriesMenuProps {
  selectedType?: ChartSeriesType;
  seriesTypes: ChartSeriesType[];
  onClick: (next: ChartSeriesType) => void;
}

const seriesOptions = (): Partial<Record<ChartSeriesType, MenuOption<ChartSeriesType>>> => ({
  Candlestick: {
    icon: <SeriesTypeIcon type="Candlestick" />,
    value: 'Candlestick',
    label: t('Candles'),
  },
  Line: {
    icon: <SeriesTypeIcon type="Line" />,
    value: 'Line',
    label: t('Line'),
  },
  Bar: {
    icon: <SeriesTypeIcon type="Bar" />,
    value: 'Bar',
    label: t('Bars'),
  },
  Baseline: {
    icon: <SeriesTypeIcon type="Baseline" />,
    value: 'Baseline',
    label: t('Baseline'),
  },
});

export function SeriesMenu({ selectedType, seriesTypes, onClick }: SeriesMenuProps) {
  const options: MenuOption<ChartSeriesType>[] = seriesTypes.map((type) => ({
    value: type,
    icon: seriesOptions()[type]?.icon,
    label: seriesOptions()[type]?.label,
  }));

  return (
    <MenuList
      mode="single"
      value={selectedType}
      options={options}
      onClick={onClick}
    />
  );
}

















import { Divider } from 'exchange-elements/v2';
import { useEffect, useState } from 'react';

import { type Observable, of } from 'rxjs';

import { IndicatorsSelect } from '@components/IndicatorsSelect';

import { Button } from '@src/components/Button';
import { SeriesTypeIcon } from '@src/components/SeriesTypeIcon';
import { Tooltip } from '@src/components/Tooltip';
import { IndicatorsIds } from '@src/constants';
import { FullscreenController } from '@src/core/Fullscreen';
import { UndoRedo, type UndoRedoState } from '@src/core/UndoRedo';
import { t } from '@src/translations';
import { ChartSeriesType } from '@src/types';
import { Timeframes } from '@src/types/timeframes';
import { useObservable } from '@src/utils';

import { Dropdown } from '../Dropdown';
import {
  BurgerMenuIcon,
  CameraIcon,
  FullscreenIcon,
  PlusCircleIcon,
  RedoIcon,
  SearchIcon,
  UndoIcon,
} from '../Icon';
import { SeriesMenu, SnapshotMenu, TimeframesMenu } from '../Menu';

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

const defaultUndoRedoState: UndoRedoState = {
  canUndo: false,
  canRedo: false,
};

const defaultUndoRedoState$ = of(defaultUndoRedoState);

interface HeaderProps {
  timeframes: Timeframes[];
  selectedTimeframeObs: Observable<Timeframes>;
  setTimeframe: (value: Timeframes) => void;
  seriesTypes: ChartSeriesType[];
  setSelectedSeries: (next: ChartSeriesType) => void;
  selectedSeriesObs: Observable<ChartSeriesType>;
  showSettingsModal: (() => void) | undefined;
  addIndicatorToChart: (id: IndicatorsIds) => void;
  toggleToolbarVisible: () => boolean;
  showCompareButton: boolean;
  showSymbolSearchButton: boolean;
  undoRedo: UndoRedo | undefined;
  showMenuButton?: boolean;
  showFullscreenButton?: boolean;
  fullscreen: FullscreenController;
  openCompareModal?: () => void;
  openSymbolSearchModal?: () => void;
  copyImage?: () => Promise<void>;
  isMXT: boolean;
}

export function Header({
  setTimeframe,
  selectedTimeframeObs,
  setSelectedSeries,
  selectedSeriesObs,
  timeframes,
  seriesTypes,
  showSettingsModal,
  addIndicatorToChart,
  toggleToolbarVisible,
  showCompareButton,
  showSymbolSearchButton,
  fullscreen,
  undoRedo,
  showFullscreenButton,
  showMenuButton,
  openCompareModal,
  openSymbolSearchModal,
  copyImage,
  isMXT,
}: HeaderProps) {
  const [isToolbarOpen, setIsToolbarOpen] = useState(showMenuButton ?? false);
  const [isFullscreen, setIsFullscreen] = useState(fullscreen.isFullscreen);

  const undoRedoState = useObservable(
    undoRedo?.getState() ?? defaultUndoRedoState$,
    defaultUndoRedoState,
  );

  useEffect(() => {
    const unsubscribe = fullscreen.onChange(() => {
      setIsFullscreen(fullscreen.isFullscreen);
    });

    return unsubscribe;
  }, [fullscreen]);

  const selectedTimeframe = useObservable(selectedTimeframeObs);
  const selectedSeries = useObservable(selectedSeriesObs);

  const handleOpenToolbar = (): void => {
    setIsToolbarOpen(toggleToolbarVisible());
  };

  return (
    <header className={styles.header}>
      <div className={styles.group}>
        {showMenuButton && (
          <Tooltip
            location="bottom"
            label={t(isToolbarOpen ? 'Hide drawing toolbar' : 'Show drawing toolbar')}
          >
            <Button
              pressed={isToolbarOpen}
              onClick={handleOpenToolbar}
              label={<BurgerMenuIcon />}
            />
          </Tooltip>
        )}

        {showSymbolSearchButton && (
          <Tooltip
            location="bottom"
            label={t('Symbol search')}
          >
            <Button
              onClick={openSymbolSearchModal}
              label={<SearchIcon />}
            />
          </Tooltip>
        )}

        {showCompareButton && (
          <Tooltip
            location="bottom"
            label={t('Compare symbols')}
          >
            <Button
              onClick={openCompareModal}
              label={<PlusCircleIcon />}
            />
          </Tooltip>
        )}
      </div>

      {(showMenuButton || showCompareButton || showSymbolSearchButton) && (
        <Divider
          direction="vertical"
          pt={{ divider: { className: styles.divider } }}
        />
      )}

      <Tooltip
        location="bottom"
        label={t('Timeframe')}
      >
        <Dropdown
          menuClassName={styles.menu}
          selectedValue={selectedTimeframe ? t(selectedTimeframe) : selectedTimeframe}
        >
          <TimeframesMenu
            onClick={setTimeframe}
            {...{ selectedTimeframe, timeframes }}
          />
        </Dropdown>
      </Tooltip>

      <Divider
        direction="vertical"
        pt={{ divider: { className: styles.divider } }}
      />

      <div className={styles.group}>
        <IndicatorsSelect
          addIndicatorToChart={addIndicatorToChart}
          isMXT={isMXT}
        />

        {showSettingsModal && (
          <Button
            onClick={showSettingsModal}
            label={t('Settings')}
          />
        )}

        <Tooltip
          location="bottom"
          label={t('Chart type')}
        >
          <Dropdown
            menuClassName={styles.menu}
            selectedValue={<SeriesTypeIcon type={selectedSeries} />}
          >
            <SeriesMenu
              selectedType={selectedSeries}
              seriesTypes={seriesTypes}
              onClick={setSelectedSeries}
            />
          </Dropdown>
        </Tooltip>
      </div>

      {undoRedo && (
        <>
          <Divider
            direction="vertical"
            pt={{ divider: { className: styles.divider } }}
          />

          <div className={styles.group}>
            <Tooltip
              location="bottom"
              label={t('Undo')}
            >
              <Button
                onClick={undoRedo.undo}
                disabled={!undoRedoState?.canUndo}
                label={<UndoIcon />}
              />
            </Tooltip>

            <Tooltip
              location="bottom"
              label={t('Redo')}
            >
              <Button
                onClick={undoRedo.redo}
                disabled={!undoRedoState?.canRedo}
                label={<RedoIcon />}
              />
            </Tooltip>
          </div>
        </>
      )}

      {(copyImage || showFullscreenButton) && (
        <>
          <Divider
            direction="vertical"
            pt={{ divider: { className: styles.divider } }}
          />

          <div className={styles.group}>
            {copyImage && (
              <Tooltip
                location="bottom"
                label={t('Snapshot')}
              >
                <Dropdown
                  menuClassName={styles.menu}
                  selectedValue={<CameraIcon />}
                >
                  <SnapshotMenu onCopyImage={copyImage} />
                </Dropdown>
              </Tooltip>
            )}

            {showFullscreenButton && (
              <Tooltip
                location="bottom"
                label={t(isFullscreen ? 'Exit fullscreen' : 'Fullscreen')}
              >
                <Button
                  pressed={isFullscreen}
                  onClick={() => fullscreen.toggle()}
                  label={<FullscreenIcon />}
                />
              </Tooltip>
            )}
          </div>
        </>
      )}
    </header>
  );
}