Загрузка данных
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 {