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


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

export interface TextStyle {
  text: string;
  fontSize: number;
  isBold: boolean;
  isItalic: boolean;
  textColor: string;
  hasBackground: boolean;
  backgroundColor: string;
  hasBorder: boolean;
  borderColor: string;
}

export type TextSettings = SettingsValues & TextStyle;

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

  return {
    text: 'Текст',
    fontSize: 14,
    isBold: false,
    isItalic: false,
    textColor: colors.chartPriceLineText,
    hasBackground: false,
    backgroundColor: colors.axisRangeTooltipFill,
    hasBorder: false,
    borderColor: colors.chartLineColor,
  };
}

export function getTextSettingsTabs(settings: TextSettings): SettingsTab[] {
  const fields: SettingField[] = [
    {
      key: 'text',
      label: 'Текст',
      type: 'textarea',
      defaultValue: settings.text,
      placeholder: 'Введите текст',
    },
    {
      key: 'fontSize',
      label: 'Размер шрифта',
      type: 'number',
      defaultValue: settings.fontSize,
      min: 8,
      max: 72,
    },
    {
      key: 'isBold',
      label: 'Жирный',
      type: 'checkbox',
      defaultValue: settings.isBold,
    },
    {
      key: 'isItalic',
      label: 'Курсив',
      type: 'checkbox',
      defaultValue: settings.isItalic,
    },
    {
      key: 'textColor',
      label: 'Цвет текста',
      type: 'color',
      defaultValue: settings.textColor,
    },
    {
      key: 'hasBackground',
      label: 'Фон',
      type: 'checkbox',
      defaultValue: settings.hasBackground,
    },
    {
      key: 'backgroundColor',
      label: 'Цвет фона',
      type: 'color',
      defaultValue: settings.backgroundColor,
    },
    {
      key: 'hasBorder',
      label: 'Граница',
      type: 'checkbox',
      defaultValue: settings.hasBorder,
    },
    {
      key: 'borderColor',
      label: 'Цвет границы',
      type: 'color',
      defaultValue: settings.borderColor,
    },
  ];

  return [{ key: 'style', label: 'Стиль', fields }];
}




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

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

import { Text } from './text';

const UI = {
  handleRadius: 5,
  handleBorderWidth: 2,
  borderWidth: 1,
  borderRadius: 4,
  padding: 6,
};

export class TextPaneRenderer implements IPrimitivePaneRenderer {
  private readonly text: Text;

  constructor(text: Text) {
    this.text = text;
  }

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

    if (!data) {
      return;
    }

    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
      const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
      const handleRadius = UI.handleRadius * pixelRatio;
      const handleBorderWidth = UI.handleBorderWidth * pixelRatio;
      const borderWidth = UI.borderWidth * pixelRatio;

      const left = data.left * horizontalPixelRatio;
      const top = data.top * verticalPixelRatio;
      const width = data.width * horizontalPixelRatio;
      const height = data.height * verticalPixelRatio;
      const paddingX = UI.padding * horizontalPixelRatio;
      const paddingY = UI.padding * verticalPixelRatio;

      context.save();

      if (data.hasBackground) {
        context.fillStyle = data.backgroundColor;
        fillRoundedRect(context, left, top, width, height, UI.borderRadius * pixelRatio);
      }

      if (data.hasBorder) {
        context.strokeStyle = data.borderColor;
        context.lineWidth = borderWidth;
        strokeRoundedRect(context, left, top, width, height, UI.borderRadius * pixelRatio);
      }

      context.save();
      context.beginPath();
      context.rect(left, top, width, height);
      context.clip();

      context.font = data.font;
      context.fillStyle = data.textColor;
      context.textAlign = 'left';
      context.textBaseline = 'top';

      data.lines.forEach((line, index) => {
        context.fillText(
          line,
          left + paddingX,
          top + paddingY + index * data.lineHeight * verticalPixelRatio,
        );
      });

      context.restore();

      if (data.showHandles) {
        const { colors } = getThemeStore();

        context.save();
        context.fillStyle = colors.chartBackground;
        context.strokeStyle = colors.chartLineColor;
        context.lineWidth = handleBorderWidth;
        drawHandle(context, left, top, handleRadius);
        context.restore();
      }

      context.restore();
    });
  }
}

function drawHandle(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
  context.beginPath();
  context.arc(x, y, radius, 0, Math.PI * 2);
  context.fill();
  context.stroke();
}

function fillRoundedRect(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
): void {
  context.beginPath();
  buildRoundedRectPath(context, x, y, width, height, radius);
  context.fill();
}

function strokeRoundedRect(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
): void {
  context.beginPath();
  buildRoundedRectPath(context, x, y, width, height, radius);
  context.stroke();
}

function buildRoundedRectPath(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
): void {
  const safeRadius = Math.min(radius, width / 2, height / 2);

  context.moveTo(x + safeRadius, y);
  context.arcTo(x + width, y, x + width, y + height, safeRadius);
  context.arcTo(x + width, y + height, x, y + height, safeRadius);
  context.arcTo(x, y + height, x, y, safeRadius);
  context.arcTo(x, y, x + width, y, safeRadius);
  context.closePath();
}





import {
  AutoscaleInfo,
  CrosshairMode,
  IChartApi,
  IPrimitivePaneView,
  PrimitiveHoveredItem,
  SeriesAttachedParameter,
  SeriesOptionsMap,
  Time,
} from 'lightweight-charts';
import { Observable } from 'rxjs';

import { CustomPriceAxisPaneView, CustomTimeAxisPaneView } from '@core/Drawings/axis';
import {
  clamp,
  clampPointToContainer as clampPointToContainerInElement,
  getAnchorFromPoint,
  getContainerSize as getElementContainerSize,
  getPointerPoint as getPointerPointFromEvent,
  getXCoordinateFromTime,
  getYCoordinateFromPrice,
  isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';

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

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

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

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

export interface TextState {
  hidden: boolean;
  isActive: 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 {
  textColor: string;
  backgroundColor: string;
  borderColor: string;
  hasBackground: boolean;
  hasBorder: boolean;
  showHandles: boolean;
}

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

const UI = {
  padding: 6,
  lineHeightMultiplier: 1.2,
};

let measureCanvas: HTMLCanvasElement | null = null;

export class Text implements ISeriesDrawing {
  private chart: IChartApi;
  private series: SeriesApi;
  private readonly container: HTMLElement;
  private readonly removeSelf?: () => void;
  private readonly openSettings?: () => void;

  private requestUpdate: (() => void) | null = null;
  private isBound = false;

  private hidden = false;
  private isActive = false;
  private mode: TextMode = 'idle';

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

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

  private readonly paneView: TextPaneView;
  private readonly timeAxisPaneView: CustomTimeAxisPaneView;
  private readonly priceAxisPaneView: CustomPriceAxisPaneView;

  constructor(chart: IChartApi, series: SeriesApi, params: TextParams) {
    const { container, removeSelf, openSettings } = params;

    this.chart = chart;
    this.series = series;
    this.container = container;
    this.removeSelf = removeSelf;
    this.openSettings = openSettings;

    this.paneView = new TextPaneView(this);

    this.timeAxisPaneView = new CustomTimeAxisPaneView({
      getAxisSegments: () => this.getTimeAxisSegments(),
    });

    this.priceAxisPaneView = new CustomPriceAxisPaneView({
      getAxisSegments: () => this.getPriceAxisSegments(),
    });

    this.series.attachPrimitive(this);
  }

  public show(): void {
    this.hidden = false;
    this.render();
  }

  public hide(): void {
    this.hidden = true;
    this.showCrosshair();
    this.render();
  }

  public destroy(): void {
    this.showCrosshair();
    this.unbindEvents();
    this.series.detachPrimitive(this);
    this.requestUpdate = null;
  }

  public rebind(series: SeriesApi): void {
    if (this.series === series) {
      return;
    }

    this.showCrosshair();
    this.unbindEvents();
    this.series.detachPrimitive(this);

    this.series = series;
    this.requestUpdate = null;

    this.series.attachPrimitive(this);
    this.render();
  }

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

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

  public getSettingsValues(): TextSettings {
    return { ...this.settings };
  }

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

  public updateSettings(settings: SettingsValues): void {
    this.settings = {
      ...this.settings,
      ...(settings as Partial<TextSettings>),
    };

    this.render();
  }

  public getState(): TextState {
    return {
      hidden: this.hidden,
      isActive: this.isActive,
      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.isActive = typeof nextState.isActive === 'boolean' ? nextState.isActive : this.isActive;
    this.mode = nextState.mode ?? this.mode;
    this.point = nextState.point ?? this.point;

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

    this.render();
  }

  public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
    this.requestUpdate = param.requestUpdate;
    this.bindEvents();
  }

  public detached(): void {
    this.showCrosshair();
    this.unbindEvents();
    this.requestUpdate = null;
  }

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

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

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

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

  public timeAxisViews() {
    return [];
  }

  public priceAxisViews() {
    return [];
  }

  public autoscaleInfo(): AutoscaleInfo | null {
    return null;
  }

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

    const geometry = this.getGeometry();

    if (!geometry) {
      return null;
    }

    return {
      ...geometry,
      textColor: this.settings.textColor,
      backgroundColor: this.settings.backgroundColor,
      borderColor: this.settings.borderColor,
      hasBackground: this.settings.hasBackground,
      hasBorder: this.settings.hasBorder,
      showHandles: this.isActive,
    };
  }

  public getTimeAxisSegments(): AxisSegment[] {
    if (!this.isActive) {
      return [];
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: geometry.left,
        to: geometry.right,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  public getPriceAxisSegments(): AxisSegment[] {
    if (!this.isActive) {
      return [];
    }

    const geometry = this.getGeometry();

    if (!geometry) {
      return [];
    }

    const { colors } = getThemeStore();

    return [
      {
        from: geometry.top,
        to: geometry.bottom,
        color: colors.axisMarkerAreaFill,
      },
    ];
  }

  public hitTest(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',
    };
  }

  private bindEvents(): void {
    if (this.isBound) {
      return;
    }

    this.isBound = true;

    this.container.addEventListener('pointerdown', this.handlePointerDown);
    this.container.addEventListener('dblclick', this.handleDoubleClick);
    window.addEventListener('pointermove', this.handlePointerMove);
    window.addEventListener('pointerup', this.handlePointerUp);
    window.addEventListener('pointercancel', this.handlePointerUp);
  }

  private unbindEvents(): void {
    if (!this.isBound) {
      return;
    }

    this.isBound = false;

    this.container.removeEventListener('pointerdown', this.handlePointerDown);
    this.container.removeEventListener('dblclick', this.handleDoubleClick);
    window.removeEventListener('pointermove', this.handlePointerMove);
    window.removeEventListener('pointerup', this.handlePointerUp);
    window.removeEventListener('pointercancel', this.handlePointerUp);
  }

  private 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.isActive) {
      if (!containsPoint) {
        return;
      }

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

      this.isActive = true;
      this.render();
      return;
    }

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

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

    this.isActive = false;
    this.render();
  };

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

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

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

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

    this.openSettings?.();
  };

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

    event.preventDefault();

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

  private 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.isActive = true;
    this.mode = 'ready';
    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.dragPointerId = null;
    this.dragStartPoint = null;
    this.dragGeometrySnapshot = null;

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

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

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

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

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

    const x = getXCoordinateFromTime(this.chart, this.point.time);
    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 = Math.round(this.settings.fontSize * UI.lineHeightMultiplier);
    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 hideCrosshair(): void {
    this.chart.applyOptions({
      crosshair: {
        mode: CrosshairMode.Hidden,
      },
    });
  }

  private showCrosshair(): void {
    this.chart.applyOptions({
      crosshair: {
        mode: CrosshairMode.Normal,
      },
    });
  }

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

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

  private getEventPoint(event: PointerEvent): Point {
    return getPointerPointFromEvent(this.container, event);
  }

  private render(): void {
    this.updateAllViews();
    this.requestUpdate?.();
  }
}

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