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


diff --git a/src/components/Toolbar/index.tsx b/src/components/Toolbar/index.tsx
index 2303daa91f59fbd30dfe6e37f3a01d8396a4d6a3..c9b0e4e0fe7fc02815c50bac5cf50d7ff887f5c3 100644
--- a/src/components/Toolbar/index.tsx
+++ b/src/components/Toolbar/index.tsx
@@ -46,7 +46,7 @@ const implemented = {
   lines: true,
   fib: false,
   rectangle: true,
-  text: false,
+  text: true,
   XABCD: false,
   position: true,
   icons: false,
@@ -167,7 +167,7 @@ export default function Toolbar({
             <Button
               size="sm"
               className={styles.button}
-              onClick={() => {}}
+              onClick={() => addDrawing(DrawingsNames.text)}
               label={<TypeIcon />}
             />
           </Tooltip>
@@ -320,7 +320,7 @@ export default function Toolbar({
       </div>
       <Divider
         direction="horizontal"
-        pt={{ divider: { className: classNames(styles.divider, styles.notImplemented) } }}
+        pt={{ divider: { className: classNames(styles.divider) } }}
       />
       <div className={classNames(styles.group)}>
         <Tooltip
diff --git a/src/constants/drawing.ts b/src/constants/drawing.ts
index 5f96e5b1e7f94718496568baf660220356b62069..076219380d2f856fdf4746287e54d82018c19c1a 100644
--- a/src/constants/drawing.ts
+++ b/src/constants/drawing.ts
@@ -14,6 +14,8 @@ export enum DrawingsNames {
 
   'rectangle' = 'rectangle',
   'traectory' = 'traectory',
+
+  'text' = 'text',
 }
 
 export const drawingLabelById: Record<DrawingsNames, string> = {
@@ -30,4 +32,5 @@ export const drawingLabelById: Record<DrawingsNames, string> = {
   [DrawingsNames.fixedProfile]: 'Профиль объёма',
   [DrawingsNames.rectangle]: 'Прямоугольник',
   [DrawingsNames.traectory]: 'Траектория',
+  [DrawingsNames.text]: 'Текст',
 };
diff --git a/src/core/Drawings/text/index.ts b/src/core/Drawings/text/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..fb243e3afd9e3cf1dfc5f047cf17e78ac5743d5e
--- /dev/null
+++ b/src/core/Drawings/text/index.ts
@@ -0,0 +1,6 @@
+import { TextPaneRenderer } from './paneRenderer';
+import { TextPaneView } from './paneView';
+import { Text, TextRenderData, TextState } from './text';
+
+export { Text, TextPaneRenderer, TextPaneView };
+export type { TextRenderData, TextState };
diff --git a/src/core/Drawings/text/paneRenderer.ts b/src/core/Drawings/text/paneRenderer.ts
new file mode 100644
index 0000000000000000000000000000000000000000..aae237208586fdc5a9aa0fd16da18fd8c9dc8d03
--- /dev/null
+++ b/src/core/Drawings/text/paneRenderer.ts
@@ -0,0 +1,88 @@
+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,
+};
+
+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;
+    }
+
+    const { colors } = getThemeStore();
+
+    target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
+      const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
+      const handleRadius = UI.handleRadius * pixelRatio;
+      const handleBorderWidth = UI.handleBorderWidth * pixelRatio;
+
+      context.save();
+
+      if (!data.point) {
+        return;
+      }
+
+      if (data.point) {
+        context.save();
+        const options = {
+          // todo: take this from this.text.getRenderData();
+          font: '24px Arial',
+          color: 'white',
+          align: 'left',
+          baseline: 'alphabetic',
+          stroke: false,
+          strokeColor: 'white',
+          lineWidth: 1,
+          shadow: null,
+        };
+
+        context.font = options.font;
+        context.fillStyle = options.color;
+        context.textAlign = options.align as CanvasTextAlign;
+        context.textBaseline = options.baseline as CanvasTextBaseline;
+
+        const x = data.point.x * horizontalPixelRatio;
+        const y = data.point.y * verticalPixelRatio;
+
+        context.fillText('text', x, y); // todo: take text from this.text.getRenderData();
+
+        context.restore();
+      }
+
+      if (data.showHandles) {
+        context.save();
+
+        context.fillStyle = colors.chartBackground;
+        context.strokeStyle = colors.chartLineColor;
+        context.lineWidth = handleBorderWidth;
+        drawHandle(context, data.point.x * horizontalPixelRatio, data.point.y * verticalPixelRatio, 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();
+}
diff --git a/src/core/Drawings/text/paneView.ts b/src/core/Drawings/text/paneView.ts
new file mode 100644
index 0000000000000000000000000000000000000000..520b95a65e2049994b7bdc088cb8997abebf9d53
--- /dev/null
+++ b/src/core/Drawings/text/paneView.ts
@@ -0,0 +1,22 @@
+import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';
+
+import { TextPaneRenderer } from './paneRenderer';
+import { Text } from './text';
+
+export class TextPaneView implements IPrimitivePaneView {
+  private readonly paneRenderer: TextPaneRenderer;
+
+  constructor(text: Text) {
+    this.paneRenderer = new TextPaneRenderer(text);
+  }
+
+  public update(): void {}
+
+  public renderer(): IPrimitivePaneRenderer {
+    return this.paneRenderer;
+  }
+
+  public zOrder(): PrimitivePaneViewZOrder {
+    return 'top';
+  }
+}
diff --git a/src/core/Drawings/text/text.ts b/src/core/Drawings/text/text.ts
new file mode 100644
index 0000000000000000000000000000000000000000..1147dadf88ba489bc1a196dcfa6ff1091150436c
--- /dev/null
+++ b/src/core/Drawings/text/text.ts
@@ -0,0 +1,525 @@
+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,
+  isNearPoint,
+} from '@core/Drawings/helpers';
+import { updateViews } from '@core/Drawings/utils';
+
+import { getThemeStore } from '@src/theme';
+
+import { TextPaneView } from './paneView';
+
+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;
+}
+
+interface TextGeometry {
+  point: Point;
+  left: number;
+  right: number;
+  top: number;
+  bottom: number;
+}
+
+export interface TextRenderData extends TextGeometry {
+  showHandles: boolean;
+}
+
+const POINT_HIT_TOLERANCE = 8;
+
+interface TextParams {
+  container: HTMLElement;
+  formatObservable?: Observable<ChartOptionsModel>;
+  removeSelf?: () => void;
+}
+
+export class Text implements ISeriesDrawing {
+  private chart: IChartApi;
+  private series: SeriesApi;
+  private readonly container: HTMLElement;
+  private readonly removeSelf?: () => 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 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 } = params;
+
+    this.chart = chart;
+    this.series = series;
+    this.container = container;
+    this.removeSelf = removeSelf;
+
+    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 getState(): TextState {
+    return {
+      hidden: this.hidden,
+      isActive: this.isActive,
+      mode: this.mode,
+      point: this.point,
+    };
+  }
+
+  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;
+
+    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,
+      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;
+    }
+
+    const point = { x, y };
+    const isClickedOnThisDrawing = this.isClickedOnThisDrawing(point);
+
+    if (!isClickedOnThisDrawing) {
+      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 isClickedOnThisDrawing = this.isClickedOnThisDrawing(point);
+
+    if (!this.isActive) {
+      if (!isClickedOnThisDrawing) {
+        return;
+      }
+
+      event.preventDefault();
+      event.stopPropagation();
+
+      this.isActive = true;
+      this.render();
+      return;
+    }
+
+    if (isClickedOnThisDrawing) {
+      event.preventDefault();
+      event.stopPropagation();
+
+      this.startDragging(point, event.pointerId);
+      return;
+    }
+    this.isActive = false;
+    this.render();
+  };
+
+  private handleDoubleClick = (event: MouseEvent): void => {
+    if (this.hidden) {
+      return;
+    }
+
+    event.preventDefault();
+    event.stopPropagation();
+
+    this.openModalOptions();
+  };
+
+  private openModalOptions = (): void => {
+    // todo: open modal with settings (text just for now)
+    console.log('openModalOptions');
+  };
+
+  private handlePointerMove = (event: PointerEvent): void => {
+    const point = this.getEventPoint(event);
+
+    if (this.dragPointerId !== event.pointerId) {
+      return;
+    }
+
+    if (this.mode === 'dragging') {
+      event.preventDefault();
+      this.movePoint(point);
+      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;
+
+    if (!geometry) {
+      return;
+    }
+
+    const nextPoint = this.clampPointToContainer(eventPoint);
+
+    this.point = this.createAnchor(nextPoint);
+  }
+
+  private createAnchor(point: Point): Anchor | null {
+    return getAnchorFromPoint(this.chart, this.series, point);
+  }
+
+  private getGeometry(): TextGeometry | null {
+    if (!this.point) {
+      return null;
+    }
+
+    const { width, height } = this.getContainerSize();
+
+    const x = getXCoordinateFromTime(this.chart, this.point.time);
+    const y = getYCoordinateFromPrice(this.series, this.point.price);
+
+    const screenPoint: Point = {
+      x: clamp(Math.round(Number(x)), 0, width),
+      y: clamp(Math.round(Number(y)), 0, height),
+    };
+
+    const left = Math.round(screenPoint.x);
+    const right = Math.round(screenPoint.x);
+    const top = Math.round(screenPoint.y);
+    const bottom = Math.round(screenPoint.y);
+
+    return {
+      point: screenPoint,
+      left,
+      right,
+      top,
+      bottom,
+    };
+  }
+
+  private isClickedOnThisDrawing(point: Point): boolean {
+    const geometry = this.getGeometry();
+
+    if (!geometry) {
+      return false;
+    }
+
+    return isNearPoint(point, geometry.point.x, geometry.point.y, POINT_HIT_TOLERANCE);
+  }
+
+  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?.();
+  }
+}
diff --git a/src/core/DrawingsManager.ts b/src/core/DrawingsManager.ts
index 06a5d675c8067bda26048bac3f9116cfd264a951..13f4f10fa389aefdc2e867a46c8dc859b9cdc4d4 100644
--- a/src/core/DrawingsManager.ts
+++ b/src/core/DrawingsManager.ts
@@ -6,6 +6,7 @@ import { DOMModel } from '@core/DOMModel';
 import { Drawing } from '@core/Drawings';
 import { ISeriesDrawing } from '@core/Drawings/common';
 import { Ray } from '@core/Drawings/ray';
+import { Text } from '@core/Drawings/text/text';
 import { TrendLine } from '@core/Drawings/trendLine';
 import { VolumeProfile } from '@core/Drawings/volumeProfile';
 
@@ -167,6 +168,15 @@ export const drawingsMap: Record<DrawingsNames, DrawingConfig> = {
       });
     },
   },
+  [DrawingsNames.text]: {
+    construct: ({ chart, series, eventManager, removeSelf, container }) => {
+      return new Text(chart, series, {
+        formatObservable: eventManager.getChartOptionsModel(),
+        container,
+        removeSelf,
+      });
+    },
+  },
 };
 
 export class DrawingsManager {