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


import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import { clamp } from 'lodash-es';
import { Observable } from 'rxjs';

import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
  clampPointToContainer as clampPointToContainerInElement,
  getAnchorFromPoint,
  getContainerSize as getElementContainerSize,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isPointInBounds,
} from '@core/Drawings/helpers';
import { AxisSegment } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';

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

import { TextPaneView } from './paneView';
import { createDefaultSettings, getTextSettingsTabs, TextContentStyle, TextSettings, TextStyle } from './settings';

import type { DrawingInteraction, ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel } from '@src/types';

type TextMode = 'idle' | 'dragging' | 'ready';

export interface TextState {
  hidden: boolean;
  mode: TextMode;
  point: Anchor | null;
  settings: TextSettings;
}

interface TextGeometry {
  point: Point;
  left: number;
  right: number;
  top: number;
  bottom: number;
  width: number;
  height: number;
  lines: string[];
  font: string;
  lineHeight: number;
}

export interface TextRenderData extends TextGeometry, TextStyle, TextContentStyle {
  showSelectionBorder: boolean;
}

interface TextParams {
  container: HTMLElement;
  interaction: DrawingInteraction;
  formatObservable?: Observable<ChartOptionsModel>;
  removeSelf?: () => void;
  openSettings?: () => void;
}

const UI = {
  padding: 6,
};

let measureCanvas: HTMLCanvasElement | null = null;

export class Text extends SeriesDrawingBase<TextSettings> implements ISeriesDrawing {
  private readonly removeSelf?: () => void;
  private readonly openSettings?: () => void;

  protected mode: TextMode = 'idle';

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

  private point: Anchor | null = null;
  protected settings: TextSettings = createDefaultSettings();

  private dragPointerId: number | null = null;
  private dragStartPoint: Point | null = null;
  private dragGeometrySnapshot: TextGeometry | null = null;

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

  constructor(chart: IChartApi, series: SeriesApi, params: TextParams) {
    super({
      chart,
      series,
      container: params.container,
      interaction: params.interaction,
    });

    const { formatObservable, removeSelf, openSettings } = params;

    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    this.paneView = new TextPaneView(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);
  }

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

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

  public getState(): TextState {
    return {
      hidden: this.hidden,
      mode: this.mode,
      point: this.point,
      settings: { ...this.settings },
    };
  }

  public setState(state: unknown): void {
    if (!state || typeof state !== 'object') {
      return;
    }

    const nextState = state as Partial<TextState>;

    this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
    this.mode = nextState.mode ?? this.mode;
    this.point = nextState.point ?? this.point;

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

    this.render();
  }

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

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

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

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

  public timeAxisViews() {
    return [this.timeAxisView];
  }

  public priceAxisViews() {
    return [this.priceAxisView];
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      ...this.settings,
      showSelectionBorder: this.isSelected(),
    };
  }

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

    if (!this.containsPoint({ x, y })) {
      return null;
    }

    return {
      cursorStyle: 'move',
      externalId: 'text',
      zOrder: 'top',
    };
  }

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

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

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

    const { colors } = getThemeStore();

    return {
      coordinate,
      text: formatDate(
        this.point.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.isSelected() || !this.point) {
      return null;
    }

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

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

    const { colors } = getThemeStore();

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

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

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

  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.setPoint(point);
      return;
    }

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

    const containsPoint = this.containsPoint(point);

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

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

      this.select();
      return;
    }

    if (containsPoint) {
      event.preventDefault();
      event.stopPropagation();

      this.startDragging(point, event.pointerId);
      return;
    }

    this.deselect();
  };

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

    const point = this.getEventPoint(event as PointerEvent);

    if (!this.containsPoint(point)) {
      return;
    }

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

    this.openSettings?.();
  };

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

    event.preventDefault();

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

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

    if (this.mode === 'dragging') {
      this.finishDragging();
    }
  };

  private setPoint(point: Point): void {
    const anchor = this.createAnchor(this.clampPointToContainer(point));

    if (!anchor) {
      return;
    }

    this.point = anchor;
    this.mode = 'ready';
    this.resolveReady?.();

    this.render();
  }

  private startDragging(point: Point, pointerId: number): void {
    this.mode = 'dragging';
    this.dragPointerId = pointerId;
    this.dragStartPoint = point;
    this.dragGeometrySnapshot = this.getGeometry();

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

  private finishDragging(): void {
    this.mode = 'ready';
    this.resolveReady?.();

    this.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragGeometrySnapshot = null;

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

  private movePoint(eventPoint: Point): void {
    const geometry = this.dragGeometrySnapshot;
    const { dragStartPoint } = this;

    if (!geometry || !dragStartPoint) {
      return;
    }

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

    const offsetX = eventPoint.x - dragStartPoint.x;
    const offsetY = eventPoint.y - dragStartPoint.y;

    const nextLeft = clamp(geometry.left + offsetX, 0, Math.max(0, width - geometry.width));
    const nextTop = clamp(geometry.top + offsetY, 0, Math.max(0, height - geometry.height));

    const anchor = this.createAnchor({
      x: nextLeft,
      y: nextTop,
    });

    if (!anchor) {
      return;
    }

    this.point = anchor;
  }

  private createAnchor(point: Point): Anchor | null {
    return getAnchorFromPoint(this.chart, this.series, point);
  }

  protected getGeometry(): TextGeometry | null {
    if (!this.point) {
      return null;
    }

    const { width: containerWidth, height: containerHeight } = this.getContainerSize();

    const x = getXCoordinateFromTime(this.chart, this.point.time, this.series);
    const y = getYCoordinateFromPrice(this.series, this.point.price);

    if (x === null || y === null) {
      return null;
    }

    const anchorPoint: Point = {
      x: clamp(Math.round(Number(x)), 0, containerWidth),
      y: clamp(Math.round(Number(y)), 0, containerHeight),
    };

    const lines = getTextLines(this.settings.text);
    const font = getFont(this.settings);
    const lineHeight = this.settings.fontSize;
    const measured = measureTextBlock(lines, font, lineHeight);

    const width = Math.min(containerWidth, measured.width + UI.padding * 2);
    const height = Math.min(containerHeight, measured.height + UI.padding * 2);

    const left = clamp(anchorPoint.x, 0, Math.max(0, containerWidth - width));
    const top = clamp(anchorPoint.y, 0, Math.max(0, containerHeight - height));
    const right = left + width;
    const bottom = top + height;

    return {
      point: {
        x: left,
        y: top,
      },
      left,
      right,
      top,
      bottom,
      width,
      height,
      lines,
      font,
      lineHeight,
    };
  }

  private containsPoint(point: Point): boolean {
    const geometry = this.getGeometry();

    if (!geometry) {
      return false;
    }

    return isPointInBounds(point, geometry, 2);
  }

  private getContainerSize(): { width: number; height: number } {
    return getElementContainerSize(this.container);
  }

  private clampPointToContainer(point: Point): Point {
    return clampPointToContainerInElement(point, this.container);
  }
}

function getTextLines(text: string): string[] {
  const lines = text.split('\n');

  return lines.length ? lines : [''];
}

function getFont(settings: TextSettings): string {
  const italic = settings.isItalic ? 'italic ' : '';
  const bold = settings.isBold ? '700 ' : '';

  return `${italic}${bold}${settings.fontSize}px Inter, sans-serif`;
}

function measureTextBlock(
  lines: string[],
  font: string,
  lineHeight: number,
): { width: number; height: number } {
  const context = getMeasureContext();

  if (!context) {
    const estimatedWidth = Math.max(...lines.map((line) => Math.max(1, line.length))) * 8;

    return {
      width: estimatedWidth,
      height: lines.length * lineHeight,
    };
  }

  context.font = font;

  const width = lines.reduce((maxWidth, line) => {
    return Math.max(maxWidth, context.measureText(line || ' ').width);
  }, 0);

  return {
    width: Math.ceil(width),
    height: lines.length * lineHeight,
  };
}

function getMeasureContext(): CanvasRenderingContext2D | null {
  if (!measureCanvas) {
    measureCanvas = document.createElement('canvas');
  }

  return measureCanvas.getContext('2d');
}