Загрузка данных
import { CrosshairMode } from 'lightweight-charts';
import { Subject, Subscription } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
import {
getAnchorFromPoint,
getPointerPoint as getPointerPointFromEvent,
getRawPointerPoint,
getXCoordinateFromTime,
getYCoordinateFromPrice,
} from '@core/Drawings/helpers';
import { getThemeStore } from '@src/theme';
import type { DrawingHandle } from '@core/Drawings/handles';
import type {
Anchor,
AxisLabel,
AxisSegment,
Point,
SeriesApi,
} from '@core/Drawings/types';
import type {
AutoscaleInfo,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
MouseEventParams,
PrimitiveHoveredItem,
PrimitivePaneViewZOrder,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
TouchMouseEventData,
} from 'lightweight-charts';
import type { Observable } from 'rxjs';
import type {
ChartOptionsModel,
SettingsTab,
SettingsValues,
} from '@src/types';
export interface DrawingInteraction {
selected$: Observable<boolean>;
locked$: Observable<boolean>;
isSelected(): boolean;
isLocked(): boolean;
select(): void;
deselect(): void;
}
export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
show(): void;
hide(): void;
rebind(series: ISeriesApi<SeriesType>): void;
destroy(): void;
waitTillReady(): Promise<void>;
isCreationPending(): boolean;
shouldShowInObjectTree(): boolean;
getState(): unknown;
setState(state: unknown): void;
getSettings(): SettingsValues;
getSettingsTabs(): SettingsTab[];
updateSettings(settings: SettingsValues): void;
subscribeSettings(callback: (settings: SettingsValues) => void): Subscription;
isHit(event: MouseEvent): boolean;
pointerDown(event: PointerEvent): void;
click(event: MouseEvent): void;
doubleClick(event: MouseEvent): void;
contextMenu(event: MouseEvent): void;
getRenderData(): unknown;
}
export type StartPoint = unknown;
interface DrawingBaseParams {
container: HTMLElement;
chart: IChartApi;
series: SeriesApi;
interaction: DrawingInteraction;
}
export interface BaseDrawingParams {
chart: IChartApi;
series: SeriesApi;
container: HTMLElement;
interaction: DrawingInteraction;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
initialEvent?: MouseEventParams;
}
export abstract class DrawingBase<
TSettings extends SettingsValues = SettingsValues,
THandleId extends string = string,
> implements ISeriesDrawing
{
protected hidden = false;
protected chart: IChartApi;
protected series: SeriesApi;
protected subscriptions = new Subscription();
protected abstract mode: unknown; // todo: хочется иметь единый mode
protected abstract settings: TSettings;
protected readonly container: HTMLElement;
protected isBound = false;
private readonly interaction: DrawingInteraction;
private readonly settingsSubject = new Subject<SettingsValues>();
private readonly handlesPrimitive: DrawingHandlesPrimitive<THandleId>;
private isInteractionBound = false;
protected readyPromise: Promise<void> | null = null;
protected resolveReady: (() => void) | null = null;
protected requestUpdate: (() => void) | null = null;
constructor({ chart, series, container, interaction }: DrawingBaseParams) {
this.chart = chart;
this.series = series;
this.container = container;
this.interaction = interaction;
this.handlesPrimitive = new DrawingHandlesPrimitive(() => {
if (this.hidden || !this.shouldShowHandles()) {
return [];
}
return this.getDrawingHandles();
});
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
callback(this.getSettings());
return this.settingsSubject.subscribe(callback);
}
public show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.showCrosshair();
this.render();
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.render();
}
public destroy(): void {
this.showCrosshair();
this.unbindEvents();
this.subscriptions.unsubscribe();
this.settingsSubject.complete();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.requestUpdate = null;
this.resolveReady?.();
}
public waitTillReady(): Promise<void> {
if (this.mode === 'ready') {
return Promise.resolve();
}
if (!this.readyPromise) {
this.readyPromise = new Promise((resolve) => {
this.resolveReady = resolve;
});
}
return this.readyPromise;
}
public shouldShowInObjectTree(): boolean {
return this.mode !== 'idle';
}
public getSettings(): SettingsValues {
return { ...this.settings };
}
public updateSettings(settings: SettingsValues): void {
this.settings = {
...this.settings,
...settings,
};
this.settingsSubject.next(this.getSettings());
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.series.attachPrimitive(this.handlesPrimitive);
this.bindInteraction();
this.bindEvents();
}
public detached(): void {
this.series.detachPrimitive(this.handlesPrimitive);
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
const hoveredItem = this.getHoveredItem(x, y);
if (!hoveredItem || !this.isLocked()) {
return hoveredItem;
}
return {
...hoveredItem,
cursorStyle: 'pointer',
};
}
public isHit(event: MouseEvent): boolean {
if (!this.isEventInside(event)) {
return false;
}
const point = this.getEventPoint(event as PointerEvent);
return this.getHoveredItem(point.x, point.y) !== null;
}
public pointerDown(event: PointerEvent): void {
if (!this.isEventInside(event)) {
return;
}
if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
this.handlePointerDown(event);
return;
}
const point = this.getEventPoint(event);
if (this.getHoveredItem(point.x, point.y)) {
this.select();
return;
}
if (this.isSelected()) {
this.deselect();
}
}
public click(event: MouseEvent): void {
if (!this.isEventInside(event)) {
return;
}
this.handleClick(event);
}
public doubleClick(event: MouseEvent): void {
if (!this.isEventInside(event)) {
return;
}
this.handleDoubleClick(event);
}
public contextMenu(event: MouseEvent): void {
if (!this.isEventInside(event)) {
return;
}
this.handleContextMenu(event);
}
public abstract getRenderData(): unknown; // todo: make proper type
public abstract getState(): unknown;
public abstract getSettingsTabs(): SettingsTab[];
public abstract isCreationPending(): boolean;
public abstract setState(state: unknown): void;
public abstract updateAllViews(): void;
public abstract paneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisViews(): readonly ISeriesPrimitiveAxisView[];
public abstract timeAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract timeAxisViews(): readonly ISeriesPrimitiveAxisView[];
protected isSelected(): boolean {
return this.interaction.isSelected();
}
protected isLocked(): boolean {
return this.interaction.isLocked();
}
protected isAxisLabelAvailable(): boolean {
return !this.hidden && !this.isCreationPending();
}
protected shouldShowInteractiveAxis(): boolean {
return this.isSelected() || this.isCreationPending();
}
protected select(): void {
this.interaction.select();
}
protected deselect(): void {
this.interaction.deselect();
}
protected shouldShowHandles(): boolean {
return !this.isLocked() && (this.isSelected() || this.isCreationPending());
}
protected getDrawingHandles(): readonly DrawingHandle<THandleId>[] {
return [];
}
protected getDrawingHandleAtPoint(point: Point): DrawingHandle<THandleId> | null {
return this.handlesPrimitive.findHandle(point);
}
protected createAnchorFromPoint(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getPointFromAnchor(anchor: Anchor | null): Point | null {
if (!anchor) {
return null;
}
const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
const y = getYCoordinateFromPrice(this.series, anchor.price);
if (x === null || y === null) {
return null;
}
return {
x: Number(x),
y: Number(y),
};
}
protected createTimeAxisView(labelKind: string): CustomTimeAxisView {
return new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind,
viewport: this.container,
});
}
protected createPriceAxisView(labelKind: string): CustomPriceAxisView {
return new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind,
viewport: this.container,
});
}
protected createTimeAxisPaneView(
zOrder: PrimitivePaneViewZOrder = 'bottom',
): CustomTimeAxisPaneView {
return new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
zOrder,
});
}
protected createPriceAxisPaneView(
zOrder: PrimitivePaneViewZOrder = 'bottom',
): CustomPriceAxisPaneView {
return new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
zOrder,
});
}
protected createAxisLabel(
coordinate: number | null,
text: string,
style?: Partial<Pick<AxisLabel, 'textColor' | 'backgroundColor'>>,
): AxisLabel | null {
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: style?.textColor ?? colors.chartPriceLineText,
backgroundColor: style?.backgroundColor ?? colors.axisMarkerLabelFill,
};
}
protected createAxisSegment(from: number, to: number, color?: string): AxisSegment {
const { colors } = getThemeStore();
return {
from,
to,
color: color ?? colors.axisMarkerAreaFill,
};
}
protected subscribeFormat(
formatObservable: Observable<ChartOptionsModel> | undefined,
callback: (format: ChartOptionsModel) => void,
): void {
if (!formatObservable) {
return;
}
this.subscriptions.add(
formatObservable.subscribe((format) => {
callback(format);
this.render();
}),
);
}
protected render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
protected hideCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Hidden,
},
});
}
protected showCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Normal,
},
});
}
protected getEventPoint(event: PointerEvent | TouchMouseEventData): Point {
return getPointerPointFromEvent(this.container, event);
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
}
protected unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
// todo: хочется общую реализацию для каждой кнопки
protected handleClick(event: MouseEvent): void {}
protected handleContextMenu(event: MouseEvent): void {}
protected handleDoubleClick(event: MouseEvent): void {}
protected handlePointerMove(event: PointerEvent): void {}
protected handlePointerUp(event: PointerEvent): void {}
protected handlePointerDown(event: PointerEvent | TouchMouseEventData): void {}
protected abstract getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null;
protected abstract getGeometry(): unknown; // todo: make proper type
protected abstract getTimeAxisSegments(): AxisSegment[];
protected abstract getPriceAxisSegments(): AxisSegment[];
protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;
private bindInteraction(): void {
if (this.isInteractionBound) {
return;
}
this.isInteractionBound = true;
this.subscriptions.add(
this.interaction.selected$.subscribe(() => {
this.render();
}),
);
this.subscriptions.add(
this.interaction.locked$.subscribe((isLocked) => {
if (isLocked) {
this.showCrosshair();
}
this.render();
}),
);
}
private isEventInside(event: MouseEvent): boolean {
if (event.target instanceof Node && this.container.contains(event.target)) {
return true;
}
return this.handlesPrimitive.isTimeAxisHit(
getRawPointerPoint(this.container, event),
);
}
}
import { clamp } from 'lodash-es';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import {
clampPointToContainer as clampPointToContainerInElement,
getContainerSize as getElementContainerSize,
getPriceDelta,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import type {
Anchor,
Bounds,
ContainerSize,
Point,
} from '@core/Drawings/types';
import type { SettingsValues } from '@src/types';
export type TwoPointAnchorKind = 'start' | 'end';
export interface TwoPointGeometry extends Bounds {
startPoint: Point;
endPoint: Point;
}
export abstract class TwoPointDrawingBase<
TSettings extends SettingsValues = SettingsValues,
THandleId extends string = string,
> extends DrawingBase<TSettings, THandleId> {
protected startAnchor: Anchor | null = null;
protected endAnchor: Anchor | null = null;
protected getAnchor(kind: TwoPointAnchorKind): Anchor | null {
return kind === 'start' ? this.startAnchor : this.endAnchor;
}
protected setAnchor(kind: TwoPointAnchorKind, anchor: Anchor | null): void {
if (kind === 'start') {
this.startAnchor = anchor;
return;
}
this.endAnchor = anchor;
}
protected setAnchors(
startAnchor: Anchor | null,
endAnchor: Anchor | null,
): void {
this.startAnchor = startAnchor;
this.endAnchor = endAnchor;
}
protected createAnchor(point: Point): Anchor | null {
return this.createAnchorFromPoint(point);
}
protected setAnchorFromPoint(
kind: TwoPointAnchorKind,
point: Point,
): boolean {
const anchor = this.createAnchor(point);
if (!anchor) {
return false;
}
this.setAnchor(kind, anchor);
return true;
}
protected setAnchorsFromPoints(
startPoint: Point,
endPoint: Point,
): boolean {
const startAnchor = this.createAnchor(startPoint);
const endAnchor = this.createAnchor(endPoint);
if (!startAnchor || !endAnchor) {
return false;
}
this.setAnchors(startAnchor, endAnchor);
return true;
}
protected getAnchorTimeCoordinate(kind: TwoPointAnchorKind): number | null {
const point = this.getPointFromAnchor(this.getAnchor(kind));
return point?.x ?? null;
}
protected getAnchorPriceCoordinate(kind: TwoPointAnchorKind): number | null {
const point = this.getPointFromAnchor(this.getAnchor(kind));
return point?.y ?? null;
}
protected moveAnchorsByPixels(
startAnchor: Anchor | null,
endAnchor: Anchor | null,
dragStartPoint: Point | null,
point: Point,
): boolean {
if (!dragStartPoint) {
return false;
}
const offsetX = point.x - dragStartPoint.x;
const priceOffset = getPriceDelta(
this.series,
dragStartPoint.y,
point.y,
);
return this.moveAnchors(
startAnchor,
endAnchor,
offsetX,
priceOffset,
);
}
protected moveAnchorsWithinContainer(
startAnchor: Anchor | null,
endAnchor: Anchor | null,
dragStartPoint: Point | null,
point: Point,
bounds: Bounds,
): boolean {
if (!dragStartPoint) {
return false;
}
const { width, height } = this.getContainerSize();
const rawOffsetX = point.x - dragStartPoint.x;
const rawOffsetY = point.y - dragStartPoint.y;
const offsetX = clamp(rawOffsetX, -bounds.left, width - bounds.right);
const offsetY = clamp(rawOffsetY, -bounds.top, height - bounds.bottom);
const priceOffset = getPriceDelta(
this.series,
dragStartPoint.y,
dragStartPoint.y + offsetY,
);
return this.moveAnchors(
startAnchor,
endAnchor,
offsetX,
priceOffset,
);
}
protected getContainerSize(): ContainerSize {
return getElementContainerSize(this.container);
}
protected clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
protected getTwoPointGeometry(): TwoPointGeometry | null {
const startPoint = this.getPointFromAnchor(this.startAnchor);
const endPoint = this.getPointFromAnchor(this.endAnchor);
if (!startPoint || !endPoint) {
return null;
}
return {
startPoint,
endPoint,
left: Math.min(startPoint.x, endPoint.x),
right: Math.max(startPoint.x, endPoint.x),
top: Math.min(startPoint.y, endPoint.y),
bottom: Math.max(startPoint.y, endPoint.y),
};
}
private moveAnchors(
startAnchor: Anchor | null,
endAnchor: Anchor | null,
offsetX: number,
priceOffset: number,
): boolean {
if (!startAnchor || !endAnchor) {
return false;
}
const nextStartTime = shiftTimeByPixels(
this.chart,
startAnchor.time,
offsetX,
this.series,
);
const nextEndTime = shiftTimeByPixels(
this.chart,
endAnchor.time,
offsetX,
this.series,
);
if (nextStartTime === null || nextEndTime === null) {
return false;
}
this.startAnchor = {
time: nextStartTime,
price: startAnchor.price + priceOffset,
};
this.endAnchor = {
time: nextEndTime,
price: endAnchor.price + priceOffset,
};
return true;
}
}
import { clamp } from 'lodash-es';
import {
IPrimitivePaneView,
PrimitiveHoveredItem,
UTCTimestamp,
} from 'lightweight-charts';
import {
CustomPriceAxisView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import {
clampPointToContainer,
getContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
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 {
Anchor,
AxisLabel,
AxisSegment,
Point,
} from '@core/Drawings/types';
import type {
BaseDrawingParams,
ISeriesDrawing,
} from '@core/Drawings/DrawingBase';
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;
}
type TextParams = BaseDrawingParams;
const UI = {
padding: 6,
};
let measureCanvas: HTMLCanvasElement | null = null;
export class Text extends DrawingBase<TextSettings> implements ISeriesDrawing {
private readonly openSettings?: () => void;
protected mode: TextMode = 'idle';
protected settings: TextSettings = createDefaultSettings();
private point: Anchor | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragGeometrySnapshot: TextGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: TextPaneView;
private readonly timeAxisView: CustomTimeAxisView;
private readonly priceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
formatObservable,
openSettings,
container,
interaction,
initialEvent,
}: TextParams) {
super({
chart,
series,
container,
interaction,
});
this.openSettings = openSettings;
this.paneView = new TextPaneView(this);
this.timeAxisView = this.createTimeAxisView('main');
this.priceAxisView = this.createPriceAxisView('main');
this.subscribeFormat(formatObservable, (format) => {
this.displayFormat = format;
});
this.series.attachPrimitive(this);
if (initialEvent?.sourceEvent) {
this.startDrawing(
this.getEventPoint(initialEvent.sourceEvent),
);
}
}
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;
if ('point' in nextState) {
this.point = nextState.point ?? null;
}
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' ||
!this.containsPoint({ x, y })
) {
return null;
}
return {
cursorStyle: 'move',
externalId: 'text',
zOrder: 'top',
};
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (
!this.isAxisLabelAvailable() ||
kind !== 'main' ||
!this.isSelected() ||
!this.point ||
typeof this.point.time !== 'number'
) {
return null;
}
const coordinate = getXCoordinateFromTime(
this.chart,
this.point.time,
this.series,
);
return this.createAxisLabel(
coordinate === null
? null
: Number(coordinate),
formatDate(
this.point.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
);
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (
!this.isAxisLabelAvailable() ||
kind !== 'main' ||
!this.isSelected() ||
!this.point
) {
return null;
}
const coordinate = getYCoordinateFromPrice(
this.series,
this.point.price,
);
return this.createAxisLabel(
coordinate === null
? null
: Number(coordinate),
formatPrice(this.point.price) ?? '',
);
}
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.startDrawing(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 ||
this.mode !== 'dragging'
) {
return;
}
this.finishDragging();
};
private startDrawing(point: Point): void {
// todo: вынести в абстрактный класс абстрактным методом (и в соседних классах)
const anchor = this.createAnchorFromPoint(
clampPointToContainer(
point,
this.container,
),
);
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.dragStartPoint;
if (!geometry || !dragStartPoint) {
return;
}
const { width, height } =
getContainerSize(this.container);
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.createAnchorFromPoint({
x: nextLeft,
y: nextTop,
});
if (!anchor) {
return;
}
this.point = anchor;
}
protected getGeometry(): TextGeometry | null {
const projectedPoint =
this.getPointFromAnchor(this.point);
if (!projectedPoint) {
return null;
}
const {
width: containerWidth,
height: containerHeight,
} = getContainerSize(this.container);
const anchorPoint: Point = {
x: clamp(
Math.round(projectedPoint.x),
0,
containerWidth,
),
y: clamp(
Math.round(projectedPoint.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,
),
);
return {
point: {
x: left,
y: top,
},
left,
right: left + width,
top,
bottom: top + height,
width,
height,
lines,
font,
lineHeight,
};
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
return (
geometry !== null &&
isPointInBounds(
point,
geometry,
2,
)
);
}
}
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');
}
import { clamp } from 'lodash-es';
import {
IPrimitivePaneView,
PrimitiveHoveredItem,
} from 'lightweight-charts';
import {
CustomPriceAxisPaneView,
CustomTimeAxisPaneView,
} from '@core/Drawings/axis';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import {
clampPointToContainer,
getContainerSize,
isNearPoint,
} from '@core/Drawings/helpers';
import {
getDistanceToSegment,
updateViews,
} from '@core/Drawings/utils';
import { TraectoryPaneView } from './paneView';
import {
createDefaultSettings,
getTraectorySettingsTabs,
TraectorySettings,
TraectoryStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type {
Anchor,
AxisLabel,
AxisSegment,
Point,
} from '@core/Drawings/types';
import type {
BaseDrawingParams,
ISeriesDrawing,
} from '@core/Drawings/DrawingBase';
import type { SettingsTab } from '@src/types';
type TraectoryMode =
| 'idle'
| 'drawing'
| 'ready'
| 'dragging-point'
| 'dragging-body';
type TraectoryHandleId = `${number}`;
type TraectoryParams = BaseDrawingParams;
export interface TraectoryState {
hidden: boolean;
mode: TraectoryMode;
points: Anchor[];
settings: TraectorySettings;
}
interface TraectoryGeometry {
points: Point[];
left: number;
right: number;
top: number;
bottom: number;
}
export interface TraectoryRenderData
extends TraectoryGeometry,
TraectoryStyle {
previewPoint: Point | null;
showArrow: boolean;
}
const POINT_HIT_TOLERANCE = 8;
const SEGMENT_HIT_TOLERANCE = 6;
const MIN_POINTS_COUNT = 2;
export class Traectory
extends DrawingBase<
TraectorySettings,
TraectoryHandleId
>
implements ISeriesDrawing
{
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: TraectorySettings =
createDefaultSettings();
protected mode: TraectoryMode = 'idle';
private points: Anchor[] = [];
private previewAnchor: Anchor | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragPointIndex: number | null = null;
private dragGeometrySnapshot: TraectoryGeometry | null = null;
private readonly paneView: TraectoryPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
constructor({
chart,
series,
container,
interaction,
removeSelf,
openSettings,
initialEvent,
}: TraectoryParams) {
super({
chart,
series,
container,
interaction,
});
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView =
new TraectoryPaneView(this);
this.timeAxisPaneView =
this.createTimeAxisPaneView();
this.priceAxisPaneView =
this.createPriceAxisPaneView();
this.series.attachPrimitive(this);
if (initialEvent?.sourceEvent) {
this.startDrawing(
this.getEventPoint(
initialEvent.sourceEvent,
),
);
}
}
public isCreationPending(): boolean {
return (
this.mode === 'idle' ||
this.mode === 'drawing'
);
}
public getState(): TraectoryState {
return {
hidden: this.hidden,
mode: this.mode,
points: this.points,
settings: {
...this.settings,
},
};
}
public setState(state: unknown): void {
if (
!state ||
typeof state !== 'object'
) {
return;
}
const nextState =
state as Partial<TraectoryState>;
this.hidden =
typeof nextState.hidden === 'boolean'
? nextState.hidden
: this.hidden;
this.mode =
nextState.mode ??
this.mode;
this.points =
Array.isArray(nextState.points)
? nextState.points
: this.points;
if (
'settings' in nextState &&
nextState.settings
) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getTraectorySettingsTabs(
this.settings,
);
}
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 getRenderData():
TraectoryRenderData | null {
if (this.hidden) {
return null;
}
const geometry =
this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
previewPoint:
this.getPreviewPoint(),
showArrow:
this.mode !== 'drawing' &&
geometry.points.length > 1,
...this.settings,
};
}
protected getDrawingHandles():
readonly DrawingHandle<TraectoryHandleId>[] {
const geometry =
this.getGeometry();
if (!geometry) {
return [];
}
return geometry.points
.map<
DrawingHandle<TraectoryHandleId>
>((point, index) => ({
id: `${index}`,
...point,
shape: 'circle',
}))
.reverse();
}
protected getHoveredItem(
x: number,
y: number,
): PrimitiveHoveredItem | null {
if (
this.hidden ||
this.mode === 'idle' ||
this.mode === 'drawing'
) {
return null;
}
const point = {
x,
y,
};
if (!this.isSelected()) {
if (
!this.isPointNearTraectory(
point,
)
) {
return null;
}
return {
cursorStyle: 'move',
externalId: 'traectory',
zOrder: 'top',
};
}
if (
this.getPointIndexAt(
point,
) !== null
) {
return {
cursorStyle: 'move',
externalId: 'traectory',
zOrder: 'top',
};
}
if (
!this.isPointNearTraectory(
point,
)
) {
return null;
}
return {
cursorStyle: 'move',
externalId: 'traectory',
zOrder: 'top',
};
}
protected getTimeAxisSegments():
AxisSegment[] {
if (
!this.shouldShowInteractiveAxis()
) {
return [];
}
const geometry =
this.getGeometry();
if (!geometry) {
return [];
}
return [
this.createAxisSegment(
geometry.left,
geometry.right,
),
];
}
protected getPriceAxisSegments():
AxisSegment[] {
if (
!this.shouldShowInteractiveAxis()
) {
return [];
}
const geometry =
this.getGeometry();
if (!geometry) {
return [];
}
return [
this.createAxisSegment(
geometry.top,
geometry.bottom,
),
];
}
protected getPriceAxisLabel(
_kind: string,
): AxisLabel | null {
return null;
}
protected getTimeAxisLabel(
_kind: string,
): AxisLabel | null {
return null;
}
protected handleClick = (
event: MouseEvent,
): void => {
if (
this.hidden ||
this.mode !== 'drawing' ||
event.detail !== 1
) {
return;
}
event.preventDefault();
event.stopPropagation();
this.appendPoint(
this.getEventPoint(
event as PointerEvent,
),
);
};
protected handleDoubleClick = (
event: MouseEvent,
): void => {
if (this.hidden) {
return;
}
if (
this.mode === 'drawing'
) {
event.preventDefault();
event.stopPropagation();
this.finishDrawing();
return;
}
if (
this.mode !== 'ready' ||
!this.isSelected()
) {
return;
}
const point =
this.getEventPoint(
event as PointerEvent,
);
if (
this.getPointIndexAt(
point,
) === null &&
!this.isPointNearTraectory(
point,
)
) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handleContextMenu = (
event: MouseEvent,
): void => {
if (
this.hidden ||
this.mode !== 'drawing'
) {
return;
}
event.preventDefault();
event.stopPropagation();
this.finishDrawing();
};
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 === 'drawing'
) {
return;
}
if (
this.mode !== 'ready'
) {
return;
}
if (!this.isSelected()) {
if (
!this.isPointNearTraectory(
point,
)
) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
const pointIndex =
this.getPointIndexAt(point);
if (pointIndex !== null) {
event.preventDefault();
event.stopPropagation();
this.startDraggingPoint(
point,
event.pointerId,
pointIndex,
);
return;
}
if (
!this.isPointNearTraectory(
point,
)
) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDraggingBody(
point,
event.pointerId,
);
};
protected handlePointerMove = (
event: PointerEvent,
): void => {
const point =
this.getEventPoint(event);
if (
this.mode === 'drawing'
) {
this.updatePreview(point);
return;
}
if (
this.dragPointerId !==
event.pointerId
) {
return;
}
if (
this.mode ===
'dragging-point'
) {
event.preventDefault();
this.movePoint(point);
this.render();
return;
}
if (
this.mode ===
'dragging-body'
) {
event.preventDefault();
this.moveBody(point);
this.render();
}
};
protected handlePointerUp = (
event: PointerEvent,
): void => {
if (
this.dragPointerId !==
event.pointerId
) {
return;
}
if (
this.mode ===
'dragging-point' ||
this.mode ===
'dragging-body'
) {
this.finishDragging();
}
};
private startDrawing(
point: Point,
): void {
const anchor =
this.createAnchorFromPoint(
clampPointToContainer(
point,
this.container,
),
);
if (!anchor) {
return;
}
this.points = [
anchor,
];
this.previewAnchor =
anchor;
this.mode = 'drawing';
this.render();
}
private appendPoint(
point: Point,
): void {
const anchor =
this.createAnchorFromPoint(
clampPointToContainer(
point,
this.container,
),
);
if (!anchor) {
return;
}
const lastPoint =
this.points[
this.points.length - 1
];
if (
lastPoint &&
Number(lastPoint.time) ===
Number(anchor.time) &&
lastPoint.price ===
anchor.price
) {
this.previewAnchor =
anchor;
this.render();
return;
}
this.points = [
...this.points,
anchor,
];
this.previewAnchor =
anchor;
this.render();
}
private updatePreview(
point: Point,
): void {
const anchor =
this.createAnchorFromPoint(
clampPointToContainer(
point,
this.container,
),
);
if (!anchor) {
return;
}
this.previewAnchor =
anchor;
this.render();
}
private finishDrawing(): void {
if (
this.points.length <
MIN_POINTS_COUNT
) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.previewAnchor = null;
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDraggingPoint(
point: Point,
pointerId: number,
pointIndex: number,
): void {
this.mode =
'dragging-point';
this.dragPointerId =
pointerId;
this.dragStartPoint =
point;
this.dragPointIndex =
pointIndex;
this.dragGeometrySnapshot =
this.getGeometry();
this.hideCrosshair();
this.render();
}
private startDraggingBody(
point: Point,
pointerId: number,
): void {
this.mode =
'dragging-body';
this.dragPointerId =
pointerId;
this.dragStartPoint =
point;
this.dragPointIndex = null;
this.dragGeometrySnapshot =
this.getGeometry();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragPointIndex = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private resetToIdle(): void {
this.mode = 'idle';
this.points = [];
this.previewAnchor = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragPointIndex = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private movePoint(
point: Point,
): void {
const geometry =
this.dragGeometrySnapshot;
const pointIndex =
this.dragPointIndex;
if (
!geometry ||
pointIndex === null
) {
return;
}
const nextPoints = [
...geometry.points,
];
nextPoints[pointIndex] =
clampPointToContainer(
point,
this.container,
);
this.setAnchorsFromPoints(
nextPoints,
);
}
private moveBody(
point: Point,
): void {
const geometry =
this.dragGeometrySnapshot;
const dragStartPoint =
this.dragStartPoint;
if (
!geometry ||
!dragStartPoint
) {
return;
}
const { width, height } =
getContainerSize(
this.container,
);
const rawOffsetX =
point.x -
dragStartPoint.x;
const rawOffsetY =
point.y -
dragStartPoint.y;
const offsetX = clamp(
rawOffsetX,
-geometry.left,
width - geometry.right,
);
const offsetY = clamp(
rawOffsetY,
-geometry.top,
height - geometry.bottom,
);
const nextPoints =
geometry.points.map(
(item) => {
return clampPointToContainer(
{
x:
item.x +
offsetX,
y:
item.y +
offsetY,
},
this.container,
);
},
);
this.setAnchorsFromPoints(
nextPoints,
);
}
private setAnchorsFromPoints(
points: Point[],
): void {
const nextAnchors:
Anchor[] = [];
for (const point of points) {
const anchor =
this.createAnchorFromPoint(
point,
);
if (!anchor) {
return;
}
nextAnchors.push(
anchor,
);
}
this.points =
nextAnchors;
}
protected getGeometry():
TraectoryGeometry | null {
if (!this.points.length) {
return null;
}
const { width, height } =
getContainerSize(
this.container,
);
const screenPoints:
Point[] = [];
for (
const anchor of this.points
) {
const point =
this.getPointFromAnchor(
anchor,
);
if (!point) {
return null;
}
screenPoints.push({
x: clamp(
point.x,
0,
width,
),
y: clamp(
point.y,
0,
height,
),
});
}
return {
points: screenPoints,
left: Math.min(
...screenPoints.map(
(point) => point.x,
),
),
right: Math.max(
...screenPoints.map(
(point) => point.x,
),
),
top: Math.min(
...screenPoints.map(
(point) => point.y,
),
),
bottom: Math.max(
...screenPoints.map(
(point) => point.y,
),
),
};
}
private getPreviewPoint():
Point | null {
if (
this.mode !== 'drawing' ||
!this.previewAnchor
) {
return null;
}
const point =
this.getPointFromAnchor(
this.previewAnchor,
);
if (!point) {
return null;
}
const { width, height } =
getContainerSize(
this.container,
);
return {
x: clamp(
point.x,
0,
width,
),
y: clamp(
point.y,
0,
height,
),
};
}
private getPointIndexAt(
point: Point,
): number | null {
const handle =
this.getDrawingHandleAtPoint(
point,
);
return handle
? Number(handle.id)
: null;
}
private isPointNearTraectory(
point: Point,
): boolean {
const geometry =
this.getGeometry();
if (!geometry) {
return false;
}
if (
geometry.points.length === 1
) {
const firstPoint =
geometry.points[0];
return isNearPoint(
point,
firstPoint.x,
firstPoint.y,
POINT_HIT_TOLERANCE,
);
}
for (
let index = 0;
index <
geometry.points.length - 1;
index += 1
) {
const startPoint =
geometry.points[index];
const endPoint =
geometry.points[
index + 1
];
if (
getDistanceToSegment(
point,
startPoint,
endPoint,
) <=
SEGMENT_HIT_TOLERANCE
) {
return true;
}
}
return false;
}
}