Загрузка данных
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { Observable, Subject, Subscription } from 'rxjs';
import { getPointerPoint as getPointerPointFromEvent } from '@core/Drawings/helpers';
import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import { 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;
getRenderData(): unknown;
}
interface SeriesDrawingBaseParams {
container: HTMLElement;
chart: IChartApi;
series: SeriesApi;
interaction: DrawingInteraction;
}
export abstract class SeriesDrawingBase<TSettings extends SettingsValues = SettingsValues> 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 isInteractionBound = false;
protected readyPromise: Promise<void> | null = null;
protected resolveReady: (() => void) | null = null;
protected requestUpdate: (() => void) | null = null;
constructor({ chart, series, container, interaction }: SeriesDrawingBaseParams) {
this.chart = chart;
this.series = series;
this.container = container;
this.interaction = interaction;
}
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.bindInteraction();
this.bindEvents();
}
public detached(): void {
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 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 select(): void {
this.interaction.select();
}
protected deselect(): void {
this.interaction.deselect();
}
protected shouldShowHandles(): boolean {
return !this.isLocked() && (this.isSelected() || this.isCreationPending());
}
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): Point {
return getPointerPointFromEvent(this.container, event);
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
this.container.addEventListener('dblclick', this.handleDoubleClick);
this.container.addEventListener('pointerdown', this.handlePointerDownEvent);
this.container.addEventListener('contextmenu', this.handleContextMenu);
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;
this.container.removeEventListener('dblclick', this.handleDoubleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDownEvent);
this.container.removeEventListener('contextmenu', this.handleContextMenu);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
// todo: хочется общую реализацию для каждой кнопки
protected handleContextMenu(event: MouseEvent): void {}
protected handleDoubleClick(event: MouseEvent): void {}
protected handlePointerDown(event: PointerEvent): void {}
protected handlePointerMove(event: PointerEvent): void {}
protected handlePointerUp(event: PointerEvent): 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 handlePointerDownEvent = (event: PointerEvent): void => {
if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
this.handlePointerDown(event);
return;
}
const point = this.getEventPoint(event);
const isDrawingHit = this.getHoveredItem(point.x, point.y) !== null;
if (isDrawingHit) {
this.select();
return;
}
if (this.isSelected()) {
this.deselect();
}
};
}
import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import { Observable } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
getAnchorFromPoint,
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { type ChartOptionsModel, LineMarker, type SettingsTab } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { LineDrawingPaneView } from './paneView';
import {
createDefaultSettings,
getLineDrawingSettingsTabs,
LineDrawingMarkers,
LineDrawingSettings,
LineDrawingStyle,
LineDrawingTextStyle,
} from './settings';
import type { DrawingInteraction, ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
type LineDrawingMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';
interface LineDrawingParams {
container: HTMLElement;
interaction: DrawingInteraction;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
defaultMarkers?: Partial<LineDrawingMarkers>;
}
interface LineDrawingState {
hidden: boolean;
mode: LineDrawingMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
settings: LineDrawingSettings;
}
interface LineDrawingGeometry {
startPoint: Point;
endPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
export interface LineDrawingRenderData extends LineDrawingGeometry, LineDrawingStyle, LineDrawingTextStyle {
showHandles: boolean;
}
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
export class LineDrawing extends SeriesDrawingBase<LineDrawingSettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
private readonly defaultMarkers: LineDrawingMarkers;
protected settings: LineDrawingSettings;
protected mode: LineDrawingMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: LineDrawingState | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: LineDrawingPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly startPriceAxisView: CustomPriceAxisView;
private readonly endPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ container, interaction, formatObservable, removeSelf, openSettings, defaultMarkers = {} }: LineDrawingParams,
) {
super({ chart, series, container, interaction });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.defaultMarkers = {
startMarker: LineMarker.normal,
endMarker: LineMarker.normal,
...defaultMarkers,
};
this.settings = createDefaultSettings(this.defaultMarkers);
this.paneView = new LineDrawingPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'start',
});
this.endPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'end',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): LineDrawingState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<LineDrawingState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ?? null;
}
if ('endAnchor' in nextState) {
this.endAnchor = nextState.endAnchor ?? null;
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(this.defaultMarkers),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getLineDrawingSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.startPriceAxisView,
this.endPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [this.startPriceAxisView, this.endPriceAxisView];
}
public getRenderData(): LineDrawingRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showHandles: this.shouldShowHandles(),
...this.settings,
};
}
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.getPointTarget(point)) {
return {
cursorStyle: 'move',
externalId: 'line-drawing',
zOrder: 'top',
};
}
if (!this.isPointNearLine(point)) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'line-drawing',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.left,
to: geometry.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.top,
to: geometry.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
return null;
}
const coordinate = this.getTimeCoordinate(kind);
const text = this.getTimeText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
return null;
}
const coordinate = this.getPriceCoordinate(kind);
const text = this.getPriceText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return;
}
const rect = this.container.getBoundingClientRect();
const point = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
if (!this.getPointTarget(point) && !this.isPointNearLine(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
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') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
const pointTarget = this.getPointTarget(point);
const isNearLine = this.isPointNearLine(point);
const isDrawingHit = pointTarget !== null || isNearLine;
if (!this.isSelected()) {
if (!isDrawingHit) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
if (pointTarget === 'start') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-start', point, event.pointerId);
return;
}
if (pointTarget === 'end') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-end', point, event.pointerId);
return;
}
if (isNearLine) {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-body', point, event.pointerId);
return;
}
this.deselect();
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-end') {
event.preventDefault();
event.stopPropagation();
this.movePoint(point);
this.render();
return;
}
if (this.mode === 'dragging-body') {
event.preventDefault();
event.stopPropagation();
this.moveBody(point);
this.render();
}
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-end' || this.mode === 'dragging-body') {
this.finishDragging();
}
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.endAnchor = anchor;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry) {
return;
}
const lineSize = Math.hypot(
geometry.endPoint.x - geometry.startPoint.x,
geometry.endPoint.y - geometry.startPoint.y,
);
if (lineSize < MIN_LINE_SIZE) {
this.removeSelf?.();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(mode: LineDrawingMode, point: Point, pointerId: number): void {
this.mode = mode;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.showCrosshair();
this.render();
}
private movePoint(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
if (this.mode === 'dragging-start') {
this.startAnchor = anchor;
}
if (this.mode === 'dragging-end') {
this.endAnchor = anchor;
}
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot?.startAnchor || !snapshot.endAnchor || !this.dragStartPoint) {
return;
}
const offsetX = point.x - this.dragStartPoint.x;
const priceOffset = getPriceDeltaFromCoordinates(this.series, this.dragStartPoint.y, point.y);
const nextStartTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX, this.series);
const nextEndTime = shiftTimeByPixels(this.chart, snapshot.endAnchor.time, offsetX, this.series);
if (nextStartTime === null || nextEndTime === null) {
return;
}
this.startAnchor = {
time: nextStartTime,
price: snapshot.startAnchor.price + priceOffset,
};
this.endAnchor = {
time: nextEndTime,
price: snapshot.endAnchor.price + priceOffset,
};
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): LineDrawingGeometry | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endAnchor.time, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startAnchor.price);
const endY = getYCoordinateFromPrice(this.series, this.endAnchor.price);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const startPoint = {
x: Math.round(Number(startX)),
y: Math.round(Number(startY)),
};
const endPoint = {
x: Math.round(Number(endX)),
y: Math.round(Number(endY)),
};
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 getPointTarget(point: Point): 'start' | 'end' | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, 8)) {
return 'start';
}
if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, 8)) {
return 'end';
}
return null;
}
private isPointNearLine(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return this.getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE;
}
private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
if (dx === 0 && dy === 0) {
return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
}
const t = Math.max(
0,
Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
);
const projectionX = startPoint.x + t * dx;
const projectionY = startPoint.y + t * dy;
return Math.hypot(point.x - projectionX, point.y - projectionY);
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
return coordinate === null ? null : Number(coordinate);
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, anchor.price);
return coordinate === null ? null : Number(coordinate);
}
private getTimeText(kind: TimeLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return '';
}
return formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return '';
}
return formatPrice(anchor.price) ?? '';
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import { LineMarker } from '@src/types';
import { LineDrawing } from './lineDrawing';
const UI = {
lineWidth: 2,
handleRadius: 5,
handleBorderWidth: 2,
arrowLength: 12,
arrowAngle: Math.PI / 6,
textLineHeightMultiplier: 1.2,
textOffset: 4,
};
export class LineDrawingPaneRenderer implements IPrimitivePaneRenderer {
private readonly lineDrawing: LineDrawing;
constructor(lineDrawing: LineDrawing) {
this.lineDrawing = lineDrawing;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.lineDrawing.getRenderData();
if (!data) {
return;
}
const { colors } = getThemeStore();
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const startX = data.startPoint.x * horizontalPixelRatio;
const startY = data.startPoint.y * verticalPixelRatio;
const endX = data.endPoint.x * horizontalPixelRatio;
const endY = data.endPoint.y * verticalPixelRatio;
context.save();
context.lineWidth = UI.lineWidth * pixelRatio;
context.strokeStyle = data.lineColor;
context.beginPath();
context.moveTo(startX, startY);
context.lineTo(endX, endY);
context.stroke();
if (data.startMarker === LineMarker.arrow) {
drawArrowHead(context, {
fromX: endX,
fromY: endY,
toX: startX,
toY: startY,
pixelRatio,
});
}
if (data.endMarker === LineMarker.arrow) {
drawArrowHead(context, {
fromX: startX,
fromY: startY,
toX: endX,
toY: endY,
pixelRatio,
});
}
if (data.text.trim()) {
drawTextAlongLine(context, {
startX,
startY,
endX,
endY,
text: data.text,
fontSize: data.fontSize,
isBold: data.isBold,
isItalic: data.isItalic,
textColor: data.textColor,
horizontalPixelRatio,
verticalPixelRatio,
});
}
if (data.showHandles) {
context.fillStyle = colors.chartBackground;
context.strokeStyle = colors.chartLineColor;
context.lineWidth = UI.handleBorderWidth * pixelRatio;
drawHandle(context, startX, startY, UI.handleRadius * pixelRatio);
drawHandle(context, endX, endY, UI.handleRadius * pixelRatio);
}
context.restore();
});
}
}
function drawArrowHead(
context: CanvasRenderingContext2D,
{
fromX,
fromY,
toX,
toY,
pixelRatio,
}: {
fromX: number;
fromY: number;
toX: number;
toY: number;
pixelRatio: number;
},
): void {
const angle = Math.atan2(toY - fromY, toX - fromX);
const length = UI.arrowLength * pixelRatio;
const leftX = toX - length * Math.cos(angle - UI.arrowAngle);
const leftY = toY - length * Math.sin(angle - UI.arrowAngle);
const rightX = toX - length * Math.cos(angle + UI.arrowAngle);
const rightY = toY - length * Math.sin(angle + UI.arrowAngle);
context.beginPath();
context.moveTo(leftX, leftY);
context.lineTo(toX, toY);
context.lineTo(rightX, rightY);
context.stroke();
}
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 drawTextAlongLine(
context: CanvasRenderingContext2D,
params: {
startX: number;
startY: number;
endX: number;
endY: number;
text: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
textColor: string;
horizontalPixelRatio: number;
verticalPixelRatio: number;
},
): void {
const { startX, startY, endX, endY, text, fontSize, isBold, isItalic, textColor, verticalPixelRatio } = params;
const lines = text.split('\n');
const safeFontSize = Math.max(1, fontSize);
const fontSizePx = safeFontSize * verticalPixelRatio;
const lineHeight = safeFontSize * UI.textLineHeightMultiplier * verticalPixelRatio;
const dx = endX - startX;
const dy = endY - startY;
let angle = Math.atan2(dy, dx);
if (angle > Math.PI / 2 || angle < -Math.PI / 2) {
angle += Math.PI;
}
const centerX = (startX + endX) / 2;
const centerY = (startY + endY) / 2;
const fontWeight = isBold ? '700 ' : '';
const fontStyle = isItalic ? 'italic ' : '';
context.save();
context.translate(centerX, centerY);
context.rotate(angle);
context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;
context.fillStyle = textColor;
context.textAlign = 'center';
context.textBaseline = 'middle';
const blockHeight = lines.length * lineHeight;
const textOffset = UI.textOffset * verticalPixelRatio;
const textCenterY = -(blockHeight / 2 + textOffset);
const startLineY = textCenterY - blockHeight / 2 + lineHeight / 2;
lines.forEach((line, index) => {
context.fillText(line, 0, startLineY + index * lineHeight);
});
context.restore();
}
import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { DOMObject, DOMObjectParams } from '@core/DOMObject';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { DrawingsNames } from '@src/constants';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { SettingsTab, SettingsValues, ToolbarSettingField } from '@src/types/settings';
import type { DrawingInteraction, ISeriesDrawing } from '@core/Drawings/common';
interface DrawingParams extends DOMObjectParams {
drawingName: DrawingsNames;
lwcChart: IChartApi;
mainSeries: SeriesStrategies;
onDelete: (id: string) => void;
onCopy: () => void;
construct: (chart: IChartApi, series: ISeriesApi<SeriesType>, interaction: DrawingInteraction) => ISeriesDrawing;
selected$: Observable<boolean>;
isSelected: () => boolean;
select: () => void;
deselect: () => void;
isLocked?: boolean;
hotkeys: Hotkeys;
resetActiveTool: () => void;
}
export class Drawing extends DOMObject {
private lwcDrawing: ISeriesDrawing;
private mainSeries: SeriesStrategies;
private drawingName: DrawingsNames;
private hotkeys: Hotkeys;
private lockedSubject: BehaviorSubject<boolean>;
private settingsSubject: BehaviorSubject<SettingsValues>;
private subscriptions = new Subscription();
private escapeUnregisterHash: string | null = null;
private deleteUnregisterHash: string | null = null;
private copyUnregisterHash: string | null = null;
constructor({
lwcChart,
name,
mainSeries,
drawingName,
id,
onDelete,
onCopy,
zIndex,
moveUp,
moveDown,
construct,
selected$,
isSelected,
select,
deselect,
isLocked = false,
paneId,
hotkeys,
resetActiveTool,
}: DrawingParams) {
super({
id,
name,
zIndex,
onDelete,
moveUp,
moveDown,
paneId,
});
this.hotkeys = hotkeys;
this.mainSeries = mainSeries;
this.drawingName = drawingName;
this.lockedSubject = new BehaviorSubject(isLocked);
const interaction: DrawingInteraction = {
selected$,
locked$: this.lockedSubject.asObservable(),
isSelected,
isLocked: () => this.lockedSubject.value,
select,
deselect,
};
this.lwcDrawing = construct(lwcChart, mainSeries, interaction);
this.settingsSubject = new BehaviorSubject(this.lwcDrawing.getSettings());
this.subscriptions.add(
this.lwcDrawing.subscribeSettings((settings) => {
this.settingsSubject.next(settings);
}),
);
this.escapeUnregisterHash = hotkeys.register({
keys: [Keys.escape],
callback: () => {
this.delete();
resetActiveTool();
},
});
this.subscriptions.add(
selected$.subscribe((isSelectedDrawing) => {
if (isSelectedDrawing) {
this.deleteUnregisterHash = hotkeys.register({
keys: [Keys.delete],
callback: () => {
this.delete();
},
});
this.copyUnregisterHash = hotkeys.register({
keys: [Keys.mod, Keys.c],
callback: () => {
if (!this.isCreationPending()) {
onCopy();
}
},
});
return;
}
this.unregisterSelectedDrawingHotkeys();
}),
);
this.waitForCreation().then(() => {
hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.escapeUnregisterHash = null;
});
}
public getDrawingName(): DrawingsNames {
return this.drawingName;
}
public getLwcDrawing(): ISeriesDrawing {
return this.lwcDrawing;
}
public show(): void {
this.lwcDrawing.show();
super.show();
}
public hide(): void {
this.lwcDrawing.hide();
super.hide();
}
public rebind = (nextMainSeries: SeriesStrategies): void => {
this.lwcDrawing.rebind(nextMainSeries);
this.mainSeries = nextMainSeries;
};
public isCreationPending(): boolean {
return this.lwcDrawing.isCreationPending();
}
public subscribeIsLocked(callback: (isLocked: boolean) => void): Subscription {
return this.lockedSubject.subscribe(callback);
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
return this.settingsSubject.subscribe(callback);
}
public isLocked(): boolean {
return this.lockedSubject.value;
}
public setLocked(isLocked: boolean): void {
if (this.lockedSubject.value === isLocked) {
return;
}
this.lockedSubject.next(isLocked);
}
public toggleLock(): void {
this.setLocked(!this.isLocked());
}
public waitForCreation(): Promise<void> {
return this.lwcDrawing.waitTillReady();
}
public shouldShowInObjectTree(): boolean {
return this.lwcDrawing.shouldShowInObjectTree();
}
public getState(): unknown {
return this.lwcDrawing.getState();
}
public setState(state: unknown): void {
this.lwcDrawing.setState(state);
this.settingsSubject.next(this.lwcDrawing.getSettings());
}
public getSettings(): SettingsValues {
return this.lwcDrawing.getSettings();
}
public updateSettings(settings: SettingsValues): void {
this.lwcDrawing.updateSettings(settings);
}
public getSettingsTabs(): SettingsTab[] {
return this.lwcDrawing.getSettingsTabs();
}
public getToolbarSettings(): ToolbarSettingField[] {
return this.getSettingsTabs()
.flatMap((tab) => tab.fields)
.filter((field): field is ToolbarSettingField => field.toolbar !== undefined);
}
public hasSettings(): boolean {
return this.getSettingsTabs().some((tab) => tab.fields.length > 0);
}
public destroy(): void {
this.subscriptions.unsubscribe();
this.unregisterSelectedDrawingHotkeys();
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.escapeUnregisterHash = null;
this.lockedSubject.complete();
this.settingsSubject.complete();
this.mainSeries.detachPrimitive(this.lwcDrawing);
this.lwcDrawing.destroy();
}
private unregisterSelectedDrawingHotkeys(): void {
this.hotkeys.unregister({
keys: [Keys.delete],
hash: this.deleteUnregisterHash,
});
this.hotkeys.unregister({
keys: [Keys.mod, Keys.c],
hash: this.copyUnregisterHash,
});
this.deleteUnregisterHash = null;
this.copyUnregisterHash = null;
}
}
import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { cloneDeep, isEqual } from 'lodash-es';
import { BehaviorSubject, distinctUntilChanged, map, Observable, Subscription } from 'rxjs';
import { EventManager } from '@core';
import { DOMModel } from '@core/DOMModel';
import { Drawing } from '@core/Drawings';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { drawingLabelById, drawingsMap, DrawingsNames } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ActiveDrawingTool, DOMObjectSnapshot } from '@src/types';
import type { DrawingInteraction } from '@src/core/Drawings/common';
import type { SettingsValues } from '@src/types/settings';
interface DrawingsManagerParams {
eventManager: EventManager;
mainSeries$: Observable<SeriesStrategies | null>;
lwcChart: IChartApi;
DOM: DOMModel;
container: HTMLElement;
modalRenderer: ModalRenderer;
paneId: number;
hotkeys: Hotkeys;
}
export interface DrawingSnapshotItem extends Partial<DOMObjectSnapshot> {
id: string;
drawingName: DrawingsNames;
state: unknown;
isLocked?: boolean;
zIndex?: number;
}
interface CreateDrawingOptions {
id?: string;
state?: unknown;
isLocked?: boolean;
zIndex?: number;
shouldUpdateDrawingsList?: boolean;
}
export type DrawingsManagerSnapshot = DrawingSnapshotItem[];
export class DrawingsManager {
private eventManager: EventManager;
private lwcChart: IChartApi;
private DOM: DOMModel;
private container: HTMLElement;
private modalRenderer: ModalRenderer;
private paneId: number;
private hotkeys: Hotkeys;
private mainSeries: SeriesStrategies | null = null;
private subscriptions = new Subscription();
private drawings$ = new BehaviorSubject<Drawing[]>([]);
private selectedDrawing$ = new BehaviorSubject<Drawing | null>(null);
private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
private endlessMode$ = new BehaviorSubject(false);
private pendingSnapshot: DrawingsManagerSnapshot | null = null;
private selectedDrawingSnapshot: DrawingSnapshotItem | null = null;
private recreateScheduled = false;
private copyPasteBuffer: DrawingSnapshotItem | null = null;
private escapeUnregisterHash: string | null = null;
constructor({
eventManager,
mainSeries$,
lwcChart,
DOM,
container,
modalRenderer,
paneId,
hotkeys,
}: DrawingsManagerParams) {
this.DOM = DOM;
this.eventManager = eventManager;
this.paneId = paneId;
this.lwcChart = lwcChart;
this.container = container;
this.modalRenderer = modalRenderer;
this.hotkeys = hotkeys;
this.subscriptions.add(
mainSeries$.subscribe((series) => {
if (!series) {
return;
}
this.mainSeries = series;
this.drawings$.value.forEach((drawing) => drawing.rebind(series));
if (this.pendingSnapshot) {
const snapshot = this.pendingSnapshot;
this.pendingSnapshot = null;
this.setSnapshot(snapshot);
}
}),
);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
this.container.addEventListener('click', this.handleClick);
this.container.addEventListener('pointerdown', this.handlePointerDown);
// todo: implement ctrl+v
// hotkeys.register({
// keys: [Keys.control, Keys.v],
// callback: () => {
// const bufferWithAppliedPosition = {
// ...this.copyPasteBuffer,
// state: {
// ...this.copyPasteBuffer?.state,
// startAnchor: {
// price: 73.36210252637723,
// time: 1783679170
// }
// }
// }
//
// this.setSnapshot([
// ...this.getSnapshot(),
// bufferWithAppliedPosition
// ])
// // hotkeys.unregister({ // todo: unregister all else ctrl+c's
// // keys: [Keys.control, Keys.c]
// // })
// }
// })
}
private handlePointerDown = (): void => {
this.selectedDrawingSnapshot = null;
queueMicrotask(() => {
const drawing = this.selectedDrawing$.value;
if (!drawing || drawing.isCreationPending()) {
return;
}
this.selectedDrawingSnapshot = this.createDrawingSnapshot(drawing);
});
this.DOM.refreshEntities();
};
private handlePointerUp = (): void => {
const previousSnapshot = this.selectedDrawingSnapshot;
this.selectedDrawingSnapshot = null;
queueMicrotask(() => {
if (!previousSnapshot) {
return;
}
const drawing = this.findDrawing(previousSnapshot.id);
if (!drawing || drawing.isCreationPending()) {
return;
}
this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));
});
this.DOM.refreshEntities();
this.updateActiveTool();
};
private handleClick = (): void => {
this.DOM.refreshEntities();
this.updateActiveTool();
};
private findDrawing(id: string): Drawing | undefined {
return this.drawings$.value.find((drawing) => drawing.id === id);
}
private createDrawingSnapshot(drawing: Drawing): DrawingSnapshotItem {
return {
...drawing.getSnapshot(),
drawingName: drawing.getDrawingName(),
state: cloneDeep(drawing.getState()),
isLocked: drawing.isLocked(),
};
}
private updateDrawing(drawing: Drawing, update: () => void): void {
if (drawing.isCreationPending()) {
return;
}
const previousSnapshot = this.createDrawingSnapshot(drawing);
update();
this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));
}
private pushDrawingChange(
previousSnapshot: DrawingSnapshotItem | null,
nextSnapshot: DrawingSnapshotItem | null,
): void {
if (isEqual(previousSnapshot, nextSnapshot)) {
return;
}
const previous = cloneDeep(previousSnapshot);
const next = cloneDeep(nextSnapshot);
// todo: объединять последовательные изменения одного дровинга в одну запись истории
this.eventManager.getUndoRedo().pushCommand({
undo: () => {
this.replaceDrawingSnapshot(next, previous);
},
redo: () => {
this.replaceDrawingSnapshot(previous, next);
},
});
}
private replaceDrawingSnapshot(
currentSnapshot: DrawingSnapshotItem | null,
nextSnapshot: DrawingSnapshotItem | null,
): void {
if (currentSnapshot && nextSnapshot && currentSnapshot.id === nextSnapshot.id) {
const drawing = this.findDrawing(nextSnapshot.id);
if (drawing) {
drawing.setState(cloneDeep(nextSnapshot.state));
drawing.setLocked(nextSnapshot.isLocked ?? false);
this.DOM.refreshEntities();
return;
}
}
if (currentSnapshot) {
this.removeDrawingInternal(currentSnapshot.id, false);
}
if (nextSnapshot) {
this.restoreDrawing(nextSnapshot);
}
this.activeTool$.next('crosshair');
}
private restoreDrawing(snapshot: DrawingSnapshotItem): Drawing {
const existingDrawing = this.findDrawing(snapshot.id);
if (existingDrawing) {
existingDrawing.setState(cloneDeep(snapshot.state));
existingDrawing.setLocked(snapshot.isLocked ?? false);
if (snapshot.zIndex !== undefined) {
existingDrawing.setZIndex(snapshot.zIndex);
}
this.drawings$.next([...this.drawings$.value].sort((left, right) => left.zIndex - right.zIndex));
this.DOM.refreshEntities();
return existingDrawing;
}
return this.createDrawing(snapshot.drawingName, {
id: snapshot.id,
state: cloneDeep(snapshot.state),
isLocked: snapshot.isLocked,
zIndex: snapshot.zIndex,
});
}
private updateActiveTool = (): void => {
const hasPendingDrawing = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (hasPendingDrawing) {
return;
}
const activeTool = this.activeTool$.value;
const isSingleInstanceTool = activeTool !== 'crosshair' && drawingsMap[activeTool]?.singleInstance;
if (activeTool !== 'crosshair' && this.endlessMode$.value && !isSingleInstanceTool) {
if (this.recreateScheduled) {
return;
}
this.recreateScheduled = true;
queueMicrotask(() => {
this.recreateScheduled = false;
const currentTool = this.activeTool$.value;
const hasPendingAfterTick = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (currentTool === 'crosshair') {
return;
}
if (!this.endlessMode$.value) {
return;
}
if (drawingsMap[currentTool]?.singleInstance) {
return;
}
if (hasPendingAfterTick) {
return;
}
this.addDrawingForce(currentTool);
});
return;
}
this.activeTool$.next('crosshair');
};
private removeDrawing = (id: string): void => {
const drawing = this.findDrawing(id);
if (!drawing) {
return;
}
if (drawing.isCreationPending()) {
this.removeDrawingInternal(id);
return;
}
const snapshot = this.createDrawingSnapshot(drawing);
this.removeDrawingInternal(id);
this.pushDrawingChange(snapshot, null);
};
private removeDrawingInternal(id: string, shouldUpdateTool = true): void {
const drawing = this.findDrawing(id);
if (!drawing) {
return;
}
this.removeDrawings([drawing], shouldUpdateTool);
}
private removePendingDrawings(shouldUpdateTool = true): void {
const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.isCreationPending());
this.removeDrawings(drawingsToRemove, shouldUpdateTool);
}
private removeDrawings(drawingsToRemove: Drawing[], shouldUpdateTool = true): void {
if (!drawingsToRemove.length) {
return;
}
const selectedDrawing = this.selectedDrawing$.value;
if (selectedDrawing && drawingsToRemove.includes(selectedDrawing)) {
this.selectedDrawing$.next(null);
}
drawingsToRemove.forEach((drawing) => {
drawing.destroy();
this.DOM.removeEntity(drawing);
});
this.drawings$.next(this.drawings$.value.filter((drawing) => !drawingsToRemove.includes(drawing)));
if (shouldUpdateTool) {
this.updateActiveTool();
}
this.DOM.refreshEntities();
}
public addDrawingForce = async (name: DrawingsNames): Promise<void> => {
this.removePendingDrawings(false);
const previousDrawing = drawingsMap[name].singleInstance
? this.drawings$.value.find((drawing) => drawing.getDrawingName() === name)
: undefined;
const previousSnapshot = previousDrawing ? this.createDrawingSnapshot(previousDrawing) : null;
if (previousDrawing) {
this.removeDrawingInternal(previousDrawing.id, false);
}
this.activeTool$.next(name);
const drawing = this.createDrawing(name);
this.DOM.refreshEntities();
await drawing.waitForCreation();
if (!this.findDrawing(drawing.id)) {
if (previousSnapshot) {
this.restoreDrawing(previousSnapshot);
}
return;
}
this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));
};
private createDrawing(name: DrawingsNames, options: CreateDrawingOptions = {}): Drawing {
const { mainSeries } = this;
if (!mainSeries) {
throw new Error('[Drawings] main series is not defined');
}
const { id, state, isLocked = false, zIndex, shouldUpdateDrawingsList = true } = options;
const shouldSelectAfterCreation = state === undefined;
if (shouldSelectAfterCreation && this.selectedDrawing$.value) {
this.selectedDrawing$.next(null);
}
const config = drawingsMap[name];
const drawingId = id ?? crypto.randomUUID();
let createdDrawing: Drawing | null = null;
const selected$ = this.selectedDrawing$.pipe(
map((drawing) => drawing?.id === drawingId),
distinctUntilChanged(),
);
const construct = (chart: IChartApi, series: ISeriesApi<SeriesType>, interaction: DrawingInteraction) => {
const paneElement = series.getPane().getHTMLElement();
if (!paneElement) {
throw new Error('[Drawing Manager]: cannot place drawing, there is no pane');
}
const cells = paneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
const canvasElement = cells.item(1);
return config.construct({
chart,
series,
eventManager: this.eventManager,
container: canvasElement,
interaction,
removeSelf: () => this.removeDrawing(drawingId),
openSettings: () => {
if (createdDrawing) {
this.openSettings(createdDrawing);
}
},
});
};
const drawingFactory = (entityZIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
new Drawing({
lwcChart: this.lwcChart,
mainSeries,
id: drawingId,
drawingName: name,
name: drawingLabelById()[name],
onDelete: this.removeDrawing,
onCopy: () => {
if (createdDrawing) {
this.copyPasteBuffer = this.createDrawingSnapshot(createdDrawing);
}
},
zIndex: entityZIndex,
moveDown,
moveUp,
construct,
selected$,
isSelected: () => this.selectedDrawing$.value?.id === drawingId,
select: () => {
if (!createdDrawing || createdDrawing.isCreationPending() || this.selectedDrawing$.value === createdDrawing) {
return;
}
this.selectedDrawing$.next(createdDrawing);
},
deselect: () => {
if (!createdDrawing || this.selectedDrawing$.value !== createdDrawing) {
return;
}
this.selectedDrawing$.next(null);
},
isLocked,
paneId: this.paneId,
hotkeys: this.hotkeys,
resetActiveTool: () => {
this.activeTool$.next('crosshair');
},
});
const entity = this.DOM.setEntity<Drawing>(drawingFactory, zIndex);
createdDrawing = entity;
if (state !== undefined) {
entity.setState(cloneDeep(state));
}
if (shouldUpdateDrawingsList) {
this.drawings$.next([...this.drawings$.value, entity].sort((left, right) => left.zIndex - right.zIndex));
}
if (shouldSelectAfterCreation) {
entity.waitForCreation().then(() => {
if (!this.drawings$.value.includes(entity)) {
return;
}
this.selectedDrawing$.next(entity);
this.updateActiveTool();
this.DOM.refreshEntities();
});
}
return entity;
}
public getSnapshot(): DrawingsManagerSnapshot {
return this.drawings$.value
.filter((drawing) => !drawing.isCreationPending())
.map((drawing) => this.createDrawingSnapshot(drawing));
}
public setSnapshot(snapshot: DrawingsManagerSnapshot): void {
if (!Array.isArray(snapshot)) {
return;
}
if (!this.mainSeries) {
this.pendingSnapshot = cloneDeep(snapshot);
return;
}
this.selectedDrawingSnapshot = null;
this.removeDrawings(this.drawings$.value, false);
const restoredDrawings = snapshot.reduce<Drawing[]>((drawings, item) => {
if (!drawingsMap[item.drawingName]) {
return drawings;
}
drawings.push(
this.createDrawing(item.drawingName, {
id: item.id,
state: cloneDeep(item.state),
isLocked: item.isLocked,
zIndex: item.zIndex,
shouldUpdateDrawingsList: false,
}),
);
return drawings;
}, []);
this.drawings$.next(restoredDrawings.sort((left, right) => left.zIndex - right.zIndex));
this.activeTool$.next('crosshair');
this.DOM.refreshEntities();
}
public setEndlessDrawingMode = (value: boolean): void => {
if (value) {
this.escapeUnregisterHash = this.hotkeys.register({
keys: [Keys.escape],
callback: () => {
this.setEndlessDrawingMode(false);
},
});
} else {
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.escapeUnregisterHash = null;
}
this.endlessMode$.next(value);
};
public isEndlessDrawingsMode(): Observable<boolean> {
return this.endlessMode$.asObservable();
}
public getActiveTool(): Observable<ActiveDrawingTool> {
return this.activeTool$.asObservable();
}
public activateCrosshair(): void {
this.removePendingDrawings(false);
this.activeTool$.next('crosshair');
this.DOM.refreshEntities();
}
public entities(): Observable<Drawing[]> {
return this.drawings$.asObservable();
}
public selectedDrawing(): Observable<Drawing | null> {
return this.selectedDrawing$.asObservable();
}
public updateSelectedDrawingSettings = (settings: SettingsValues): void => {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.updateDrawing(drawing, () => {
drawing.updateSettings(settings);
});
};
public openSelectedDrawingSettings(): void {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.openSettings(drawing);
}
public deleteSelectedDrawing(): void {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.removeDrawing(drawing.id);
}
public toggleSelectedDrawingLock(): void {
const drawing = this.selectedDrawing$.value;
if (!drawing) {
return;
}
this.updateDrawing(drawing, () => {
drawing.toggleLock();
});
}
private openSettings = (drawing: Drawing): void => {
const tabs = drawing.getSettingsTabs();
if (!tabs.length || tabs.every((tab) => tab.fields.length === 0)) {
return;
}
let settings = drawing.getSettings();
this.modalRenderer.renderComponent(
<EntitySettingsModal
tabs={tabs}
values={settings}
onChange={(nextSettings) => {
settings = nextSettings;
}}
initialTabKey={tabs[0]?.key}
/>,
{
size: 'sm',
title: drawing.name,
onSave: () => {
if (!this.findDrawing(drawing.id)) {
return;
}
this.updateDrawing(drawing, () => {
drawing.updateSettings(settings);
});
},
},
);
};
public getDrawings(): Drawing[] {
return this.drawings$.value;
}
public hideAll(): void {
if (this.selectedDrawing$.value) {
this.selectedDrawing$.next(null);
}
this.drawings$.value.forEach((drawing) => drawing.hide());
this.DOM.refreshEntities();
}
public destroy(): void {
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
this.container.removeEventListener('click', this.handleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDown);
this.drawings$.value.forEach((drawing) => drawing.destroy());
this.selectedDrawingSnapshot = null;
this.copyPasteBuffer = null;
this.subscriptions.unsubscribe();
this.drawings$.complete();
this.selectedDrawing$.complete();
this.activeTool$.complete();
this.endlessMode$.complete();
}
}
import { BehaviorSubject, map, Observable } from 'rxjs';
import { DOM } from '@components/DOM';
import { IDOMObject } from '@core/DOMObject';
import { ModalRenderer } from '@core/ModalRenderer';
import { t } from '@src/translations';
interface DOMModelParams {
modalRenderer: ModalRenderer;
}
/**
* Абстракция над библиотекой для построения графиков
*/
export class DOMModel {
// ∈ symbol&pane
private modalRenderer: ModalRenderer;
private lastZIndex = 0;
private entities: BehaviorSubject<IDOMObject[]> = new BehaviorSubject<IDOMObject[]>([]); // drawings/indicators/series
// private panes: Panes[]; // todo: пока что на каждый пейн будет один objectTree
constructor({ modalRenderer }: DOMModelParams) {
this.modalRenderer = modalRenderer;
}
public removeEntity = <T extends IDOMObject>(entity: T): void => {
this.entities.next(this.entities.value.filter((item) => item.id !== entity.id));
};
public setEntity = <T extends IDOMObject>(
callback: (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) => T,
zIndex?: number,
): T => {
const entityZIndex = zIndex ?? this.lastZIndex;
const entity = callback(entityZIndex, this.moveUp, this.moveDown);
this.lastZIndex = Math.max(this.lastZIndex, entityZIndex + 1);
this.entities.next([...this.entities.value, entity].sort((left, right) => left.zIndex - right.zIndex));
return entity;
};
private moveUp = (id: string): void => {
this.moveEntity(id, 1);
};
private moveDown = (id: string): void => {
this.moveEntity(id, -1);
};
private moveEntity(id: string, direction: -1 | 1): void {
const entities = [...this.entities.value].sort((left, right) => left.zIndex - right.zIndex);
const currentIndex = entities.findIndex((entity) => entity.id === id);
if (currentIndex === -1) {
return;
}
const target = entities[currentIndex + direction];
if (!target) {
return;
}
const current = entities[currentIndex];
const currentZIndex = current.zIndex;
current.setZIndex(target.zIndex);
target.setZIndex(currentZIndex);
this.entities.next(entities.sort((left, right) => left.zIndex - right.zIndex));
}
public getEntities = (): Observable<IDOMObject[]> => {
return this.entities.pipe(map((entities) => entities.filter((entity) => entity.shouldShowInObjectTree())));
};
public refreshEntities = (): void => {
this.entities.next([...this.entities.value].sort((left, right) => left.zIndex - right.zIndex));
};
public toggleDOM = () => {
this.modalRenderer.renderComponent(<DOM elementsObs={this.getEntities()} />, {
title: t('DOM tree'),
onSave: () => console.warn('dom state saved'),
acceptLabel: '',
rejectLabel: '',
});
};
public destroy(): void {
// todo implement
}
}
import { BehaviorSubject } from 'rxjs';
import { DOMObjectSnapshot, IndicatorSnapshot, ISerializable } from '@src/types/snapshot';
enum DOMObjectType {
Drawing = 'Drawing',
Indicator = 'Indicator',
}
export interface IDOMObject {
id: string;
hidden: BehaviorSubject<boolean>;
zIndex: number;
type: DOMObjectType;
name: string;
delete(): void;
hide(): void;
show(): void;
lastUpdated(): void;
moveUp(): void;
moveDown(): void;
setZIndex(next: number): void;
shouldShowInObjectTree(): boolean;
}
export interface DOMObjectParams {
id: string;
paneId: number; // todo: implement for drawings
zIndex: number;
onDelete: (id: string) => void;
moveUp: (id: string) => void;
moveDown: (id: string) => void;
name?: string;
}
export class DOMObject implements IDOMObject, ISerializable<DOMObjectSnapshot> {
public readonly id: string;
public name: string;
public zIndex: number;
public hidden = new BehaviorSubject(false);
public moveUp: () => void;
public moveDown: () => void;
public paneId: number;
protected onDelete: (id: string) => void;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
type: DOMObjectType;
constructor({ id, name, zIndex, onDelete, moveUp, moveDown, paneId }: DOMObjectParams) {
this.id = id;
this.name = name ?? id;
this.zIndex = zIndex;
this.paneId = paneId;
this.onDelete = onDelete;
this.moveUp = () => moveUp(this.id);
this.moveDown = () => moveDown(this.id);
}
delete(): void {
this.onDelete(this.id);
}
hide(): void {
this.hidden.next(true);
}
show(): void {
this.hidden.next(false);
}
lastUpdated(): void {}
setZIndex(next: number): void {
this.zIndex = next;
}
shouldShowInObjectTree(): boolean {
return true;
}
public getSnapshot(): DOMObjectSnapshot {
return {
id: this.id,
name: this.name,
zIndex: this.zIndex,
hidden: this.hidden.value,
paneId: this.paneId,
};
}
}