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


import { CanvasRenderingTarget2D } from 'fancy-canvas';
import {
  IPrimitivePaneRenderer,
  IPrimitivePaneView,
  ISeriesPrimitiveAxisView,
  PrimitivePaneViewZOrder,
} from 'lightweight-charts';

import type { AxisLabel, AxisSegment } from '@core/Drawings/types';

interface CustomPriceAxisPaneViewParams {
  getAxisSegments(): AxisSegment[];
  zOrder?: PrimitivePaneViewZOrder;
}

interface CustomPriceAxisViewParams {
  getAxisLabel(labelKind: string): AxisLabel | null;
  labelKind: string;
}

export class CustomPriceAxisPaneView implements IPrimitivePaneView {
  private readonly rendererInstance: CustomPriceAxisPaneRenderer;
  private readonly paneZOrder: PrimitivePaneViewZOrder;

  constructor({ getAxisSegments, zOrder = 'bottom' }: CustomPriceAxisPaneViewParams) {
    this.rendererInstance = new CustomPriceAxisPaneRenderer(getAxisSegments);
    this.paneZOrder = zOrder;
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return this.rendererInstance;
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return this.paneZOrder;
  }
}

class CustomPriceAxisPaneRenderer implements IPrimitivePaneRenderer {
  constructor(private readonly getAxisSegments: () => AxisSegment[]) {}

  public draw(target: CanvasRenderingTarget2D): void {
    const axisSegments = this.getAxisSegments();

    if (!axisSegments.length) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, verticalPixelRatio, bitmapSize }) => {
      context.save();

      for (const axisSegment of axisSegments) {
        const segmentStart = Math.min(axisSegment.from, axisSegment.to) * verticalPixelRatio;
        const segmentEnd = Math.max(axisSegment.from, axisSegment.to) * verticalPixelRatio;

        context.fillStyle = axisSegment.color;
        context.fillRect(0, segmentStart, bitmapSize.width, segmentEnd - segmentStart);
      }

      context.restore();
    });
  }
}

export class CustomPriceAxisView implements ISeriesPrimitiveAxisView {
  private readonly getAxisLabel: (labelKind: string) => AxisLabel | null;
  private readonly labelKind: string;

  constructor({ getAxisLabel, labelKind }: CustomPriceAxisViewParams) {
    this.getAxisLabel = getAxisLabel;
    this.labelKind = labelKind;
  }

  public update(): void {}

  public visible(): boolean {
    return Boolean(this.getCurrentLabel()?.text);
  }

  public tickVisible(): boolean {
    return false;
  }

  public coordinate(): number {
    return this.getCurrentLabel()?.coordinate ?? 0;
  }

  public text(): string {
    return this.getCurrentLabel()?.text ?? '';
  }

  public textColor(): string {
    return this.getCurrentLabel()?.textColor ?? '';
  }

  public backColor(): string {
    return this.getCurrentLabel()?.backgroundColor ?? '';
  }

  private getCurrentLabel(): AxisLabel | null {
    return this.getAxisLabel(this.labelKind);
  }
}



import { CanvasRenderingTarget2D } from 'fancy-canvas';
import {
  IPrimitivePaneRenderer,
  IPrimitivePaneView,
  ISeriesPrimitiveAxisView,
  PrimitivePaneViewZOrder,
} from 'lightweight-charts';

import type { AxisLabel, AxisSegment } from '@core/Drawings/types';

interface CustomTimeAxisPaneViewParams {
  getAxisSegments(): AxisSegment[];
  zOrder?: PrimitivePaneViewZOrder;
}

interface CustomTimeAxisViewParams {
  getAxisLabel(labelKind: string): AxisLabel | null;
  labelKind: string;
}

export class CustomTimeAxisPaneView implements IPrimitivePaneView {
  private readonly rendererInstance: CustomTimeAxisPaneRenderer;
  private readonly paneZOrder: PrimitivePaneViewZOrder;

  constructor({ getAxisSegments, zOrder = 'bottom' }: CustomTimeAxisPaneViewParams) {
    this.rendererInstance = new CustomTimeAxisPaneRenderer(getAxisSegments);
    this.paneZOrder = zOrder;
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return this.rendererInstance;
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return this.paneZOrder;
  }
}

class CustomTimeAxisPaneRenderer implements IPrimitivePaneRenderer {
  constructor(private readonly getAxisSegments: () => AxisSegment[]) {}

  public draw(target: CanvasRenderingTarget2D): void {
    const axisSegments = this.getAxisSegments();

    if (!axisSegments.length) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, bitmapSize }) => {
      context.save();

      for (const axisSegment of axisSegments) {
        const segmentStart = Math.min(axisSegment.from, axisSegment.to) * horizontalPixelRatio;
        const segmentEnd = Math.max(axisSegment.from, axisSegment.to) * horizontalPixelRatio;

        context.fillStyle = axisSegment.color;
        context.fillRect(segmentStart, 0, segmentEnd - segmentStart, bitmapSize.height);
      }

      context.restore();
    });
  }
}

export class CustomTimeAxisView implements ISeriesPrimitiveAxisView {
  private readonly getAxisLabel: (labelKind: string) => AxisLabel | null;
  private readonly labelKind: string;

  constructor({ getAxisLabel, labelKind }: CustomTimeAxisViewParams) {
    this.getAxisLabel = getAxisLabel;
    this.labelKind = labelKind;
  }

  public update(): void {}

  public visible(): boolean {
    return Boolean(this.getCurrentLabel()?.text);
  }

  public tickVisible(): boolean {
    return false;
  }

  public coordinate(): number {
    return this.getCurrentLabel()?.coordinate ?? 0;
  }

  public text(): string {
    return this.getCurrentLabel()?.text ?? '';
  }

  public textColor(): string {
    return this.getCurrentLabel()?.textColor ?? '';
  }

  public backColor(): string {
    return this.getCurrentLabel()?.backgroundColor ?? '';
  }

  private getCurrentLabel(): AxisLabel | null {
    return this.getAxisLabel(this.labelKind);
  }
}



import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import {
  getPriceFromYCoordinate,
  getTimeFromXCoordinate,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';

import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';

import { AxisLinePaneView } from './paneView';

import {
  AxisLineSettings,
  AxisLineStyle,
  AxisLineTextStyle,
  createDefaultSettings,
  getAxisLineSettingsTabs,
} from './settings';

import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';

export type AxisLineDirection = 'vertical' | 'horizontal';

type AxisLineMode = 'idle' | 'ready' | 'dragging';
type AxisLineHandleKey = 'main';

interface AxisLineParams extends BaseDrawingParams {
  direction: AxisLineDirection;
}

interface AxisLineState {
  hidden: boolean;
  mode: AxisLineMode;
  time: Time | null;
  price: number | null;
  settings: AxisLineSettings;
}

export interface AxisLineRenderData extends AxisLineStyle, AxisLineTextStyle {
  direction: AxisLineDirection;
  coordinate: number;
}

const LINE_HIT_TOLERANCE = 6;

export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandleKey> implements ISeriesDrawing {
  private readonly openSettings?: () => void;

  protected settings: AxisLineSettings = createDefaultSettings();
  protected mode: AxisLineMode = 'idle';

  private readonly direction: AxisLineDirection;
  private time: Time | null = null;
  private price: number | null = null;
  private dragPointerId: number | null = null;

  private displayFormat: ChartOptionsModel = {
    dateFormat: Defaults.dateFormat,
    timeFormat: Defaults.timeFormat,
    showTime: Defaults.showTime,
  };

  private readonly paneView: AxisLinePaneView;
  private readonly timeAxisView: CustomTimeAxisView;
  private readonly priceAxisView: CustomPriceAxisView;

  constructor({
    chart,
    series,
    direction,
    container,
    interaction,
    formatObservable,
    openSettings,
    initialEvent,
  }: AxisLineParams) {
    super({ chart, series, container, interaction });

    this.direction = direction;
    this.openSettings = openSettings;

    this.paneView = new AxisLinePaneView(this);

    this.timeAxisView = new CustomTimeAxisView({
      getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
      labelKind: 'main',
    });

    this.priceAxisView = new CustomPriceAxisView({
      getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
      labelKind: 'main',
    });

    if (formatObservable) {
      this.subscriptions.add(
        formatObservable.subscribe((format) => {
          this.displayFormat = format;
          this.render();
        }),
      );
    }

    this.series.attachPrimitive(this);

    if (initialEvent && initialEvent.sourceEvent) {
      const point = this.getEventPoint(initialEvent.sourceEvent);
      this.startDrawing(point);
    }
  }

  public isCreationPending(): boolean {
    return this.mode === 'idle';
  }

  public getState(): AxisLineState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      time: this.time,
      price: this.price,
      settings: { ...this.settings },
    };
  }

  public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
    return [];
  }

  public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
    return [];
  }

  public setState(state: unknown): void {
    const nextState = state as Partial<AxisLineState>;

    if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
      this.hidden = nextState.hidden;
    }

    if ('mode' in nextState && nextState.mode) {
      this.mode = nextState.mode;
    }

    if ('time' in nextState) {
      this.time = nextState.time ?? null;
    }

    if ('price' in nextState) {
      this.price = nextState.price ?? null;
    }

    if ('settings' in nextState && nextState.settings) {
      this.settings = {
        ...createDefaultSettings(),
        ...nextState.settings,
      };
    }

    this.render();
  }

  public getSettingsTabs(): SettingsTab[] {
    return getAxisLineSettingsTabs(this.settings);
  }

  public updateAllViews(): void {
    updateViews([this.paneView, this.timeAxisView, this.priceAxisView]);
  }

  public paneViews(): readonly IPrimitivePaneView[] {
    return [this.paneView];
  }

  public timeAxisViews() {
    return this.direction === 'vertical' ? [this.timeAxisView] : [];
  }

  public priceAxisViews() {
    return this.direction === 'horizontal' ? [this.priceAxisView] : [];
  }

  public getRenderData(): AxisLineRenderData | null {
    if (this.hidden) {
      return null;
    }

    const coordinate = this.getCoordinate();

    if (coordinate === null) {
      return null;
    }

    return {
      direction: this.direction,
      coordinate,
      ...this.settings,
    };
  }

  protected getDrawingHandles(): readonly DrawingHandle<AxisLineHandleKey>[] {
    const coordinate = this.getCoordinate();

    if (coordinate === null) {
      return [];
    }

    const { width, height } = this.container.getBoundingClientRect();

    return [
      {
        id: 'main',
        x: this.direction === 'vertical' ? coordinate : width / 2,
        y: this.direction === 'vertical' ? height / 2 : coordinate,
        shape: 'rounded',
      },
    ];
  }

  protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
    if (this.hidden || this.mode === 'idle') {
      return null;
    }

    const point = { x, y };
    const data = this.getRenderData();

    if (!data) {
      return null;
    }

    if (this.isSelected() && this.getDrawingHandleAtPoint(point)) {
      return {
        cursorStyle: this.getCursorStyle(),
        externalId: 'axis-line',
        zOrder: 'top',
      };
    }

    if (!this.isPointNearLine(point, data.coordinate)) {
      return null;
    }

    return {
      cursorStyle: this.getCursorStyle(),
      externalId: 'axis-line',
      zOrder: 'top',
    };
  }

  protected getTimeAxisLabel(kind: string): AxisLabel | null {
    if (kind !== 'main' || this.direction !== 'vertical' || !this.isSelected() || this.time === null) {
      return null;
    }

    const coordinate = getXCoordinateFromTime(this.chart, this.time, this.series);

    if (coordinate === null || typeof this.time !== 'number') {
      return null;
    }

    const { colors } = getThemeStore();

    return {
      coordinate,
      text: formatDate(
        this.time as UTCTimestamp,
        this.displayFormat.dateFormat,
        this.displayFormat.timeFormat,
        this.displayFormat.showTime,
      ),
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

  protected getPriceAxisLabel(kind: string): AxisLabel | null {
    if (kind !== 'main' || this.direction !== 'horizontal' || !this.isSelected() || this.price === null) {
      return null;
    }

    const coordinate = getYCoordinateFromPrice(this.series, this.price);

    if (coordinate === null) {
      return null;
    }

    const { colors } = getThemeStore();

    return {
      coordinate,
      text: formatPrice(this.price) ?? '',
      textColor: colors.chartPriceLineText,
      backgroundColor: colors.axisMarkerLabelFill,
    };
  }

  protected getTimeAxisSegments(): AxisSegment[] {
    return [];
  }

  protected getPriceAxisSegments(): AxisSegment[] {
    return [];
  }

  protected getGeometry(): null {
    return null;
  }

  protected handleDoubleClick = (event: MouseEvent): void => {
    if (this.hidden || this.mode !== 'ready') {
      return;
    }

    const data = this.getRenderData();

    if (!data) {
      return;
    }

    const point = this.getEventPoint(event as PointerEvent);
    const isNearHandle = this.isSelected() && Boolean(this.getDrawingHandleAtPoint(point));
    const isNearLine = this.isPointNearLine(point, data.coordinate);

    if (!isNearHandle && !isNearLine) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    this.openSettings?.();
  };

  protected handlePointerDown = (event: PointerEvent): void => {
    if (this.hidden || event.button !== 0) {
      return;
    }

    const point = this.getEventPoint(event);

    if (this.mode === 'idle') {
      event.preventDefault();
      event.stopPropagation();

      this.startDrawing(point);
      return;
    }

    if (this.mode !== 'ready') {
      return;
    }

    const data = this.getRenderData();

    if (!data) {
      return;
    }

    const isNearHandle = this.isSelected() && Boolean(this.getDrawingHandleAtPoint(point));
    const isNearLine = this.isPointNearLine(point, data.coordinate);

    if (!this.isSelected()) {
      if (!isNearLine) {
        return;
      }

      event.preventDefault();
      event.stopPropagation();

      this.select();
      return;
    }

    if (!isNearHandle && !isNearLine) {
      this.deselect();
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    this.mode = 'dragging';
    this.dragPointerId = event.pointerId;

    this.hideCrosshair();
    this.render();
  };

  protected handlePointerMove = (event: PointerEvent): void => {
    if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    this.updateLine(this.getEventPoint(event));
    this.render();
  };

  protected handlePointerUp = (event: PointerEvent): void => {
    if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
      return;
    }

    this.mode = 'ready';
    this.dragPointerId = null;

    this.resolveReady?.();
    this.showCrosshair();
    this.render();
  };

  private startDrawing(point: Point): void {
    this.updateLine(point);
    this.mode = 'ready';

    this.resolveReady?.();
    this.render();
  }

  private updateLine(point: Point): void {
    if (this.direction === 'vertical') {
      this.time = getTimeFromXCoordinate(this.chart, point.x);
      return;
    }

    this.price = getPriceFromYCoordinate(this.series, point.y);
  }

  private getCoordinate(): number | null {
    if (this.direction === 'vertical') {
      if (this.time === null) {
        return null;
      }

      const coordinate = getXCoordinateFromTime(this.chart, this.time, this.series);

      return coordinate === null ? null : Number(coordinate);
    }

    if (this.price === null) {
      return null;
    }

    const coordinate = getYCoordinateFromPrice(this.series, this.price);

    return coordinate === null ? null : Number(coordinate);
  }

  private isPointNearLine(point: Point, coordinate: number): boolean {
    return this.direction === 'vertical'
      ? Math.abs(point.x - coordinate) <= LINE_HIT_TOLERANCE
      : Math.abs(point.y - coordinate) <= LINE_HIT_TOLERANCE;
  }

  private getCursorStyle(): PrimitiveHoveredItem['cursorStyle'] {
    return this.direction === 'vertical' ? 'ew-resize' : 'ns-resize';
  }
}


import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';

import { AxisLine } from './axisLine';

const UI = {
  lineWidth: 2,
  textLineHeightMultiplier: 1.2,
  textOffset: 4,
};

export class AxisLinePaneRenderer implements IPrimitivePaneRenderer {
  private readonly axisLine: AxisLine;

  constructor(axisLine: AxisLine) {
    this.axisLine = axisLine;
  }

  public draw(target: CanvasRenderingTarget2D): void {
    const data = this.axisLine.getRenderData();

    if (!data) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
      const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);

      context.save();
      context.fillStyle = data.lineColor;

      if (data.direction === 'vertical') {
        const x = Math.round(data.coordinate * horizontalPixelRatio);
        const width = Math.max(1, UI.lineWidth * pixelRatio);

        context.fillRect(x - width / 2, 0, width, bitmapSize.height);
      }

      if (data.direction === 'horizontal') {
        const y = Math.round(data.coordinate * verticalPixelRatio);
        const height = Math.max(1, UI.lineWidth * pixelRatio);

        context.fillRect(0, y - height / 2, bitmapSize.width, height);
      }

      drawTextAlongAxisLine(context, {
        direction: data.direction,
        coordinate: data.coordinate,
        text: data.text,
        fontSize: data.fontSize,
        isBold: data.isBold,
        isItalic: data.isItalic,
        textColor: data.textColor,
        bitmapWidth: bitmapSize.width,
        bitmapHeight: bitmapSize.height,
        horizontalPixelRatio,
        verticalPixelRatio,
      });

      context.restore();
    });
  }
}

function drawTextAlongAxisLine(
  context: CanvasRenderingContext2D,
  params: {
    direction: 'vertical' | 'horizontal';
    coordinate: number;
    text: string;
    fontSize: number;
    isBold: boolean;
    isItalic: boolean;
    textColor: string;
    bitmapWidth: number;
    bitmapHeight: number;
    horizontalPixelRatio: number;
    verticalPixelRatio: number;
  },
): void {
  const {
    direction,
    coordinate,
    text,
    fontSize,
    isBold,
    isItalic,
    textColor,
    bitmapWidth,
    bitmapHeight,
    horizontalPixelRatio,
    verticalPixelRatio,
  } = params;

  if (!text.trim()) {
    return;
  }

  const lines = text.split('\n');
  const safeFontSize = Math.max(1, fontSize);
  const fontSizePx = safeFontSize * verticalPixelRatio;
  const lineHeight = safeFontSize * UI.textLineHeightMultiplier * verticalPixelRatio;
  const fontWeight = isBold ? '700 ' : '';
  const fontStyle = isItalic ? 'italic ' : '';
  const textOffset = UI.textOffset * Math.max(horizontalPixelRatio, verticalPixelRatio);

  const x = direction === 'vertical' ? coordinate * horizontalPixelRatio : bitmapWidth / 2;
  const y = direction === 'horizontal' ? coordinate * verticalPixelRatio : bitmapHeight / 2;
  const angle = direction === 'vertical' ? -Math.PI / 2 : 0;

  context.save();
  context.translate(x, y);
  context.rotate(angle);

  context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;
  context.fillStyle = textColor;
  context.textAlign = 'center';
  context.textBaseline = 'middle';

  const blockHeight = lines.length * lineHeight;
  const textCenterY = -(blockHeight / 2 + textOffset);
  const startLineY = textCenterY - blockHeight / 2 + lineHeight / 2;

  lines.forEach((line, index) => {
    context.fillText(line, 0, startLineY + index * lineHeight);
  });

  context.restore();
}



import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';

import { AxisLine } from './axisLine';
import { AxisLinePaneRenderer } from './paneRenderer';

export class AxisLinePaneView implements IPrimitivePaneView {
  private readonly axisLine: AxisLine;

  constructor(axisLine: AxisLine) {
    this.axisLine = axisLine;
  }

  public update(): void {}

  public renderer(): IPrimitivePaneRenderer {
    return new AxisLinePaneRenderer(this.axisLine);
  }

  public zOrder(): PrimitivePaneViewZOrder {
    return 'top';
  }
}


import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';

export interface AxisLineStyle {
  lineColor: string;
}

export interface AxisLineTextStyle {
  text: string;
  fontSize: number;
  isBold: boolean;
  isItalic: boolean;
  textColor: string;
}

export type AxisLineSettings = AxisLineStyle & AxisLineTextStyle & SettingsValues;

export function createDefaultSettings(): AxisLineSettings {
  const { colors } = getThemeStore();

  return {
    lineColor: colors.chartLineColor,
    text: '',
    fontSize: 14,
    isBold: false,
    isItalic: false,
    textColor: colors.chartLineColor,
  };
}

export function getAxisLineSettingsTabs(settings: AxisLineSettings): SettingsTab[] {
  const styleFields: SettingField[] = [
    {
      key: 'lineColor',
      label: t('Line'),
      type: 'color',
      defaultValue: settings.lineColor,
      toolbar: {
        control: 'color',
        role: 'line',
      },
    },
  ];

  const textFields: SettingField[] = [
    {
      key: 'fontSize',
      label: t('Font size'),
      type: 'number',
      defaultValue: settings.fontSize,
      min: 8,
      max: 24,
    },
    {
      key: 'text',
      label: t('Text'),
      type: 'textarea',
      defaultValue: settings.text,
      placeholder: t('Enter text'),
    },
    {
      key: 'isBold',
      label: t('Bold'),
      type: 'boolean',
      defaultValue: settings.isBold,
    },
    {
      key: 'isItalic',
      label: t('Italic'),
      type: 'boolean',
      defaultValue: settings.isItalic,
    },
    {
      key: 'textColor',
      label: t('Text'),
      type: 'color',
      defaultValue: settings.textColor,
      toolbar: {
        control: 'color',
        role: 'text',
      },
    },
  ];

  return [
    { key: 'style', label: t('Style'), fields: styleFields },
    { key: 'text', label: t('Text'), fields: textFields },
  ];
}