Загрузка данных
import { Observable, skip } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
getPriceRangeInContainer,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
isPointInBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { Defaults } from '@src/types/defaults';
import { formatPercent, formatPrice, formatSignedNumber } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { SliderPaneView } from './paneView';
import {
createDefaultSettings,
getSliderPositionSettingsTabs,
SliderPositionSettings,
SliderPositionStyle,
SliderPositionTextStyle,
} from './settings';
import type { AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type {
IPrimitivePaneView,
MouseEventHandler,
MouseEventParams,
PrimitiveHoveredItem,
Time,
UTCTimestamp,
} from 'lightweight-charts';
type SliderSide = 'long' | 'short';
type SliderMode = 'idle' | 'ready' | 'dragging';
type DragTarget = 'body' | 'entry' | 'target' | 'stop' | 'end' | null;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'target' | 'entry' | 'stop';
interface SliderPositionParams extends BaseDrawingParams {
side: SliderSide;
resetTriggers?: Observable<unknown>[];
}
interface SliderPositionState {
hidden: boolean;
mode: SliderMode;
startTime: Time | null;
endTime: Time | null;
entryPrice: number | null;
stopPrice: number | null;
targetPrice: number | null;
riskRewardRatio: number;
amount: number;
tickSize: number;
settings: SliderPositionSettings;
}
interface SliderGeometry {
startX: number;
endX: number;
leftX: number;
rightX: number;
entryY: number;
stopY: number;
targetY: number;
entryPrice: number;
stopPrice: number;
targetPrice: number;
profitTop: number;
profitBottom: number;
lossTop: number;
lossBottom: number;
}
export interface SliderRenderData extends SliderGeometry, SliderPositionStyle, SliderPositionTextStyle {
targetText: string;
centerText: string;
stopText: string;
centerBoxColor: string;
showFill: boolean;
showHandles: boolean;
showLabels: boolean;
}
const HIT_TOLERANCE = 8;
const INITIAL_WIDTH_PX = 160;
const MIN_DISTANCE = 0.00000001;
export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: SliderPositionSettings = createDefaultSettings();
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
protected mode: SliderMode = 'idle';
private side: SliderSide;
private startTime: Time | null = null;
private endTime: Time | null = null;
private entryPrice: number | null = null;
private stopPrice: number | null = null;
private targetPrice: number | null = null;
private activeDragTarget: DragTarget = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: SliderPositionState | null = null;
private defaultRiskRewardRatio = 1;
private amount = 1000;
private tickSize = 1;
private clickHandler: MouseEventHandler<Time>;
private paneView: SliderPaneView;
private timeAxisPaneView: CustomTimeAxisPaneView;
private priceAxisPaneView: CustomPriceAxisPaneView;
private startTimeAxisView: CustomTimeAxisView;
private endTimeAxisView: CustomTimeAxisView;
private targetPriceAxisView: CustomPriceAxisView;
private entryPriceAxisView: CustomPriceAxisView;
private stopPriceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
side,
container,
interaction,
formatObservable,
resetTriggers = [],
removeSelf,
openSettings,
initialEvent,
}: SliderPositionParams) {
super({
chart,
series,
container,
interaction,
});
this.side = side;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.clickHandler = (params) => this.handleChartClick(params);
this.paneView = new SliderPaneView(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.targetPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'target',
});
this.entryPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'entry',
});
this.stopPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'stop',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
resetTriggers.forEach((trigger) => {
this.subscriptions.add(
trigger.pipe(skip(1)).subscribe(() => {
this.removeSelf?.();
}),
);
});
this.series.attachPrimitive(this);
if (initialEvent) {
this.handleChartClick(initialEvent);
}
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public getState(): SliderPositionState {
return {
hidden: this.hidden,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
entryPrice: this.entryPrice,
stopPrice: this.stopPrice,
targetPrice: this.targetPrice,
riskRewardRatio: this.getCurrentRiskRewardRatio(),
amount: this.amount,
tickSize: this.tickSize,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const next = state as Partial<SliderPositionState>;
this.hidden = next.hidden ?? this.hidden;
this.mode = next.mode ?? this.mode;
this.startTime = next.startTime ?? this.startTime;
this.endTime = next.endTime ?? this.endTime;
this.entryPrice = next.entryPrice ?? this.entryPrice;
this.stopPrice = next.stopPrice ?? this.stopPrice;
this.amount = next.amount ?? this.amount;
if (typeof next.tickSize === 'number' && next.tickSize > 0) {
this.tickSize = next.tickSize;
}
if (next.targetPrice !== undefined) {
this.targetPrice = next.targetPrice;
} else if (
this.entryPrice !== null &&
this.stopPrice !== null &&
typeof next.riskRewardRatio === 'number' &&
next.riskRewardRatio > 0
) {
const risk = Math.abs(this.entryPrice - this.stopPrice);
this.targetPrice =
this.side === 'long'
? this.entryPrice + risk * next.riskRewardRatio
: this.entryPrice - risk * next.riskRewardRatio;
}
if ('settings' in next && next.settings) {
this.settings = {
...createDefaultSettings(),
...next.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getSliderPositionSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.targetPriceAxisView,
this.entryPriceAxisView,
this.stopPriceAxisView,
]);
}
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.targetPriceAxisView, this.entryPriceAxisView, this.stopPriceAxisView];
}
public getRenderData(): SliderRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const reward = Math.abs(geometry.targetPrice - geometry.entryPrice);
const qty = reward > 0 ? this.amount / reward : 0;
const selectedPrice = this.getPriceAtTime(this.endTime);
const pnl =
selectedPrice === null
? 0
: this.side === 'long'
? (selectedPrice - geometry.entryPrice) * qty
: (geometry.entryPrice - selectedPrice) * qty;
const { colors } = getThemeStore();
return {
...geometry,
targetText: this.getTargetText(geometry),
centerText: this.getCenterText(qty, pnl),
stopText: this.getStopText(geometry),
centerBoxColor: pnl >= 0 ? colors.chartCandleUp : colors.chartCandleDown,
showFill: true,
showHandles: this.shouldShowHandles(),
showLabels: this.isSelected(),
...this.settings,
};
}
public getTimeBounds(): { left: number; right: number } | null {
const start = this.getTimeCoordinate('start');
const end = this.getTimeCoordinate('end');
if (start === null || end === null) {
return null;
}
return {
left: Math.min(start, end),
right: Math.max(start, end),
};
}
public getTimeCoordinate(kind: TimeLabelKind): number | null {
const time = kind === 'start' ? this.startTime : this.endTime;
if (time === null) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, time, this.series);
return coordinate === null ? null : Number(coordinate);
}
public getTimeText(kind: TimeLabelKind): string {
const time = kind === 'start' ? this.startTime : this.endTime;
if (typeof time !== 'number') {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
public getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
switch (kind) {
case 'target':
return geometry.targetY;
case 'entry':
return geometry.entryY;
case 'stop':
return geometry.stopY;
default:
return null;
}
}
public getPriceText(kind: PriceLabelKind): string {
const geometry = this.getGeometry();
if (!geometry) {
return '';
}
switch (kind) {
case 'target':
return formatPrice(geometry.targetPrice) ?? '';
case 'entry':
return formatPrice(geometry.entryPrice) ?? '';
case 'stop':
return formatPrice(geometry.stopPrice) ?? '';
default:
return '';
}
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
const point = { x, y };
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'pointer',
externalId: 'slider-position',
zOrder: 'top',
};
}
const dragTarget = this.getHandleTarget(point);
if (!dragTarget) {
return null;
}
let cursorStyle: PrimitiveHoveredItem['cursorStyle'] = 'grab';
if (dragTarget === 'target' || dragTarget === 'stop') {
cursorStyle = 'ns-resize';
}
if (dragTarget === 'end') {
cursorStyle = 'ew-resize';
}
return {
cursorStyle,
externalId: 'slider-position',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isSelected()) {
return [];
}
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isSelected()) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.profitTop,
to: geometry.profitBottom,
color: colors.axisMarkerAreaFill,
},
{
from: geometry.lossTop,
to: geometry.lossBottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.isSelected() || (kind !== 'start' && kind !== 'end')) {
return null;
}
const labelKind = kind as TimeLabelKind;
const coordinate = this.getTimeCoordinate(labelKind);
const text = this.getTimeText(labelKind);
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 (kind !== 'target' && kind !== 'entry' && kind !== 'stop') {
return null;
}
const labelKind = kind as PriceLabelKind;
const coordinate = this.getPriceCoordinate(labelKind);
const text = this.getPriceText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
let backgroundColor = colors.axisMarkerLabelDefaultFill;
if (labelKind === 'target') {
backgroundColor = colors.axisMarkerLabelPositiveFill;
}
if (labelKind === 'stop') {
backgroundColor = colors.axisMarkerLabelNegativeFill;
}
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor,
};
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
super.bindEvents();
this.chart.subscribeClick(this.clickHandler);
}
protected unbindEvents(): void {
if (!this.isBound) {
return;
}
this.chart.unsubscribeClick(this.clickHandler);
super.unbindEvents();
}
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?.();
};
private handleChartClick(params: MouseEventParams<Time>): void {
// todo: привести к общему виду дровингов как handleChartClick => startDrawing
if (this.hidden || !params.point || this.mode !== 'idle') {
return;
}
const anchor = this.createAnchor(params);
if (!anchor) {
return;
}
const distance = this.getInitialZoneDistance(anchor.price);
const stopDirection = this.side === 'long' ? -1 : 1;
const targetDirection = -stopDirection;
this.startTime = anchor.time;
this.endTime = this.shiftTime(anchor.time, INITIAL_WIDTH_PX) ?? anchor.time;
this.entryPrice = anchor.price;
this.stopPrice = this.normalizeStop(anchor.price, anchor.price + distance * stopDirection, distance);
this.targetPrice = this.normalizeTarget(
anchor.price,
anchor.price + distance * targetDirection * this.defaultRiskRewardRatio,
distance,
);
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || this.mode !== 'ready' || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
const dragTarget = this.getHandleTarget(point);
if (!dragTarget) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.activeDragTarget = dragTarget;
this.dragPointerId = event.pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.mode = 'dragging';
};
protected handlePointerMove = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || !this.dragStateSnapshot) {
return;
}
event.preventDefault();
this.applyDrag(this.getEventPoint(event));
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.mode = 'ready';
this.resolveReady?.();
this.render();
};
private applyDrag(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot) {
return;
}
switch (this.activeDragTarget) {
case 'body':
this.moveWhole(snapshot, point);
break;
case 'entry':
this.moveEntry(snapshot, point);
break;
case 'target':
this.moveTarget(snapshot, point);
break;
case 'stop':
this.moveStop(snapshot, point);
break;
case 'end':
this.resizeEnd(snapshot, point);
break;
default:
break;
}
}
private moveEntry(snapshot: SliderPositionState, point: Point): void {
if (snapshot.stopPrice === null || snapshot.targetPrice === null) {
return;
}
const nextPrice = getPriceFromYCoordinate(this.series, point.y);
if (nextPrice === null) {
return;
}
const minDistance = this.getEditMinimumDistance();
const low = Math.min(snapshot.stopPrice, snapshot.targetPrice) + minDistance;
const high = Math.max(snapshot.stopPrice, snapshot.targetPrice) - minDistance;
if (low > high) {
return;
}
this.entryPrice = Math.max(low, Math.min(nextPrice, high));
}
private moveWhole(snapshot: SliderPositionState, point: Point): void {
if (snapshot.startTime === null || snapshot.endTime === null) {
return;
}
if (snapshot.entryPrice === null || snapshot.stopPrice === null || snapshot.targetPrice === null) {
return;
}
if (!this.dragStartPoint) {
return;
}
const timeOffset = point.x - this.dragStartPoint.x;
const priceOffset = this.getPriceDelta(this.dragStartPoint.y, point.y);
const nextStartTime = this.shiftTime(snapshot.startTime, timeOffset);
const nextEndTime = this.shiftTime(snapshot.endTime, timeOffset);
if (nextStartTime === null || nextEndTime === null) {
return;
}
let nextEntryPrice = snapshot.entryPrice + priceOffset;
let nextStopPrice = snapshot.stopPrice + priceOffset;
let nextTargetPrice = snapshot.targetPrice + priceOffset;
const range = this.getPriceScaleRange();
if (range) {
const minValue = Math.min(nextEntryPrice, nextStopPrice, nextTargetPrice);
const maxValue = Math.max(nextEntryPrice, nextStopPrice, nextTargetPrice);
if (minValue < range.min) {
const shift = range.min - minValue;
nextEntryPrice += shift;
nextStopPrice += shift;
nextTargetPrice += shift;
}
if (maxValue > range.max) {
const shift = maxValue - range.max;
nextEntryPrice -= shift;
nextStopPrice -= shift;
nextTargetPrice -= shift;
}
}
this.startTime = nextStartTime;
this.endTime = nextEndTime;
this.entryPrice = nextEntryPrice;
this.stopPrice = nextStopPrice;
this.targetPrice = nextTargetPrice;
}
private moveStop(snapshot: SliderPositionState, point: Point): void {
if (snapshot.entryPrice === null) {
return;
}
const nextPrice = getPriceFromYCoordinate(this.series, point.y);
if (nextPrice === null) {
return;
}
this.stopPrice = this.normalizeStop(
snapshot.entryPrice,
this.clampPriceToRange(nextPrice),
this.getEditMinimumDistance(),
);
}
private moveTarget(snapshot: SliderPositionState, point: Point): void {
if (snapshot.entryPrice === null) {
return;
}
const nextPrice = getPriceFromYCoordinate(this.series, point.y);
if (nextPrice === null) {
return;
}
this.targetPrice = this.normalizeTarget(
snapshot.entryPrice,
this.clampPriceToRange(nextPrice),
this.getEditMinimumDistance(),
);
}
private resizeEnd(snapshot: SliderPositionState, point: Point): void {
const nextEndTime = getTimeFromXCoordinate(this.chart, point.x);
if (nextEndTime === null) {
return;
}
this.startTime = snapshot.startTime;
this.endTime = nextEndTime;
}
private createAnchor(params: MouseEventParams<Time>): { time: Time; price: number } | null {
if (!params.point || params.time === undefined) {
return null;
}
const price = getPriceFromYCoordinate(this.series, params.point.y);
if (price === null) {
return null;
}
return {
time: params.time,
price,
};
}
private normalizeStop(entryPrice: number, rawPrice: number, minDistance = this.getEditMinimumDistance()): number {
const distance = this.getAllowedMinimumDistance(entryPrice, 'stop', minDistance);
return this.side === 'long' ? Math.min(rawPrice, entryPrice - distance) : Math.max(rawPrice, entryPrice + distance);
}
private normalizeTarget(entryPrice: number, rawPrice: number, minDistance = this.getEditMinimumDistance()): number {
const distance = this.getAllowedMinimumDistance(entryPrice, 'target', minDistance);
return this.side === 'long' ? Math.max(rawPrice, entryPrice + distance) : Math.min(rawPrice, entryPrice - distance);
}
private getAllowedMinimumDistance(entryPrice: number, kind: 'stop' | 'target', minDistance: number): number {
const range = this.getPriceScaleRange();
if (!range) {
return minDistance;
}
const availableDistance =
this.side === 'long'
? kind === 'stop'
? Math.max(entryPrice - range.min, 0)
: Math.max(range.max - entryPrice, 0)
: kind === 'stop'
? Math.max(range.max - entryPrice, 0)
: Math.max(entryPrice - range.min, 0);
return Math.min(minDistance, availableDistance);
}
private clampPriceToRange(price: number): number {
const range = this.getPriceScaleRange();
if (!range) {
return price;
}
return Math.max(range.min, Math.min(price, range.max));
}
private getInitialZoneDistance(entryPrice: number): number {
const fallback = Math.max(Math.abs(entryPrice) * 0.0075, this.tickSize * 3, MIN_DISTANCE);
const range = this.getPriceScaleRange();
if (!range) {
return fallback;
}
const size = range.max - range.min;
if (size <= 0) {
return fallback;
}
return Math.max(size * 0.05, this.tickSize * 3, MIN_DISTANCE);
}
private getEditMinimumDistance(): number {
return Math.max(this.tickSize, MIN_DISTANCE);
}
private getPriceScaleRange(): { min: number; max: number } | null {
return getPriceRangeInContainer(this.series, this.container);
}
private getPriceDelta(fromY: number, toY: number): number {
return getPriceDeltaFromCoordinates(this.series, fromY, toY);
}
private shiftTime(time: Time, offsetX: number): Time | null {
return shiftTimeByPixels(this.chart, time, offsetX, this.series);
}
private getCurrentRiskRewardRatio(): number {
if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
return this.defaultRiskRewardRatio;
}
const risk = Math.abs(this.entryPrice - this.stopPrice);
if (!risk) {
return this.defaultRiskRewardRatio;
}
return Math.abs(this.targetPrice - this.entryPrice) / risk;
}
private getPriceAtTime(time: Time | null): number | null {
if (typeof time !== 'number') {
return null;
}
const data = this.series.data() ?? [];
let lastPrice: number | null = null;
for (const item of data) {
if (typeof item.time !== 'number') {
continue;
}
if (item.time > time) {
break;
}
if ('close' in item && typeof item.close === 'number') {
lastPrice = item.close;
continue;
}
if ('value' in item && typeof item.value === 'number') {
lastPrice = item.value;
}
}
return lastPrice;
}
protected getGeometry(): SliderGeometry | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
const entryY = getYCoordinateFromPrice(this.series, this.entryPrice);
const stopY = getYCoordinateFromPrice(this.series, this.stopPrice);
const targetY = getYCoordinateFromPrice(this.series, this.targetPrice);
if (startX === null || endX === null || entryY === null || stopY === null || targetY === null) {
return null;
}
const start = Number(startX);
const end = Number(endX);
const entry = Number(entryY);
const stop = Number(stopY);
const target = Number(targetY);
return {
startX: start,
endX: end,
leftX: Math.min(start, end),
rightX: Math.max(start, end),
entryY: entry,
stopY: stop,
targetY: target,
entryPrice: this.entryPrice,
stopPrice: this.stopPrice,
targetPrice: this.targetPrice,
profitTop: Math.min(target, entry),
profitBottom: Math.max(target, entry),
lossTop: Math.min(stop, entry),
lossBottom: Math.max(stop, entry),
};
}
private getTargetText(geometry: SliderGeometry): string {
const diff = Math.abs(geometry.targetPrice - geometry.entryPrice);
const percent = geometry.entryPrice !== 0 ? (diff / Math.abs(geometry.entryPrice)) * 100 : 0;
const ticks = this.tickSize > 0 ? diff / this.tickSize : 0;
const formattedDiff = formatPrice(diff) ?? '0';
const formattedTicks = formatPrice(ticks) ?? '0';
const formattedAmount = formatPrice(this.amount) ?? '0';
return `${t('Target')}: ${formattedDiff} (${formatPercent(percent)}) ${formattedTicks}, ${t('Amount')}: ${formattedAmount}`;
}
private getCenterText(qty: number, pnl: number): string {
const formattedQty = formatPrice(qty) ?? '0';
const formattedRatio = formatPrice(this.getCurrentRiskRewardRatio()) ?? '0';
return `${t('Open P&L')}: ${formatSignedNumber(pnl)}, ${t('Qty')}: ${formattedQty}\n${t('Risk/Reward Ratio')}: ${formattedRatio}`;
}
private getStopText(geometry: SliderGeometry): string {
const stopDiff = Math.abs(geometry.stopPrice - geometry.entryPrice);
const rewardDiff = Math.abs(geometry.targetPrice - geometry.entryPrice);
const percent = geometry.entryPrice !== 0 ? (stopDiff / Math.abs(geometry.entryPrice)) * 100 : 0;
const ticks = this.tickSize > 0 ? stopDiff / this.tickSize : 0;
const qty = rewardDiff > 0 ? this.amount / rewardDiff : 0;
const stopAmount = stopDiff * qty;
const formattedStopDiff = formatPrice(stopDiff) ?? '0';
const formattedTicks = formatPrice(ticks) ?? '0';
const formattedStopAmount = formatPrice(stopAmount) ?? '0';
return `${t('Stop')}: ${formattedStopDiff} (${formatPercent(percent)}) ${formattedTicks}, ${t('Amount')}: ${formattedStopAmount}`;
}
private getHandleTarget(point: Point): DragTarget {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.startX, geometry.entryY, HIT_TOLERANCE)) {
return 'entry';
}
if (isNearPoint(point, geometry.startX, geometry.targetY, HIT_TOLERANCE)) {
return 'target';
}
if (isNearPoint(point, geometry.startX, geometry.stopY, HIT_TOLERANCE)) {
return 'stop';
}
if (isNearPoint(point, geometry.endX, geometry.entryY, HIT_TOLERANCE)) {
return 'end';
}
const minY = Math.min(geometry.targetY, geometry.stopY);
const maxY = Math.max(geometry.targetY, geometry.stopY);
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
top: minY,
bottom: maxY,
};
if (isPointInBounds(point, bounds)) {
return 'body';
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
const minY = Math.min(geometry.targetY, geometry.stopY);
const maxY = Math.max(geometry.targetY, geometry.stopY);
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
top: minY,
bottom: maxY,
};
return isPointInBounds(point, bounds, HIT_TOLERANCE);
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import type { SliderPositionTextStyle } from './settings';
import type { SliderPosition } from './sliderPosition';
const UI = {
lineWidth: 1,
lineHeightMultiplier: 1.2,
mainBoxHeight: 28,
sideBoxHeight: 16,
padding: 4,
boxRadius: 4,
labelOffset: 10,
handleSize: 10,
handleRadius: 3,
handleBorderWidth: 1,
};
export class SliderPaneRenderer implements IPrimitivePaneRenderer {
private readonly slider: SliderPosition;
constructor(slider: SliderPosition) {
this.slider = slider;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.slider.getRenderData();
if (!data) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const startX = data.startX * horizontalPixelRatio;
const endX = data.endX * horizontalPixelRatio;
const left = data.leftX * horizontalPixelRatio;
const right = data.rightX * horizontalPixelRatio;
const entryY = data.entryY * verticalPixelRatio;
const stopY = data.stopY * verticalPixelRatio;
const targetY = data.targetY * verticalPixelRatio;
const profitTop = data.profitTop * verticalPixelRatio;
const profitBottom = data.profitBottom * verticalPixelRatio;
const lossTop = data.lossTop * verticalPixelRatio;
const lossBottom = data.lossBottom * verticalPixelRatio;
const centerX = (left + right) / 2;
const sideBoxHeightPx = UI.sideBoxHeight * verticalPixelRatio;
const mainBoxHeightPx = UI.mainBoxHeight * verticalPixelRatio;
const labelOffsetPx = UI.labelOffset * verticalPixelRatio;
const targetLabelCenterY =
targetY < entryY
? targetY - labelOffsetPx - sideBoxHeightPx / 2
: targetY + labelOffsetPx + sideBoxHeightPx / 2;
const stopLabelCenterY =
stopY < entryY ? stopY - labelOffsetPx - sideBoxHeightPx / 2 : stopY + labelOffsetPx + sideBoxHeightPx / 2;
context.save();
if (data.showFill) {
context.fillStyle = data.positiveFillColor;
context.fillRect(left, profitTop, right - left, profitBottom - profitTop);
context.fillStyle = data.negativeFillColor;
context.fillRect(left, lossTop, right - left, lossBottom - lossTop);
}
context.lineWidth = UI.lineWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.strokeStyle = data.lineColor;
drawHorizontalLine(context, left, right, entryY);
if (data.showHandles) {
drawHandle(context, startX, entryY, horizontalPixelRatio, verticalPixelRatio, 'circle');
drawHandle(context, endX, entryY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
drawHandle(context, startX, targetY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
drawHandle(context, startX, stopY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
}
if (data.showLabels) {
drawTextBox(
context,
centerX,
targetLabelCenterY,
data.targetText,
data.positiveFillColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.sideBoxHeight,
);
drawTextBox(
context,
centerX,
entryY + labelOffsetPx + mainBoxHeightPx / 2,
data.centerText,
data.centerBoxColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.mainBoxHeight,
);
drawTextBox(
context,
centerX,
stopLabelCenterY,
data.stopText,
data.negativeFillColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.sideBoxHeight,
);
}
context.restore();
});
}
}
function drawHorizontalLine(context: CanvasRenderingContext2D, left: number, right: number, y: number): void {
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
}
function drawHandle(
context: CanvasRenderingContext2D,
x: number,
y: number,
horizontalPixelRatio: number,
verticalPixelRatio: number,
shape: 'circle' | 'rounded',
): void {
const width = UI.handleSize * horizontalPixelRatio;
const height = UI.handleSize * verticalPixelRatio;
const left = x - width / 2;
const top = y - height / 2;
const { colors } = getThemeStore();
context.save();
context.fillStyle = colors.chartBackground;
context.strokeStyle = colors.chartLineColor;
context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.beginPath();
if (shape === 'circle') {
context.arc(x, y, Math.min(width, height) / 2, 0, Math.PI * 2);
} else {
drawRoundedRect(
context,
left,
top,
width,
height,
UI.handleRadius * Math.max(horizontalPixelRatio, verticalPixelRatio),
);
}
context.fill();
context.stroke();
context.restore();
}
function drawTextBox(
context: CanvasRenderingContext2D,
centerX: number,
centerY: number,
text: string,
fillColor: string,
textStyle: SliderPositionTextStyle,
horizontalPixelRatio: number,
verticalPixelRatio: number,
fixedHeight: number,
): void {
context.save();
const lines = text.split('\n');
const fontSize = textStyle.fontSize * verticalPixelRatio;
const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
const paddingX = UI.padding * horizontalPixelRatio;
const paddingY = UI.padding * verticalPixelRatio;
const minBoxHeight = fixedHeight * verticalPixelRatio;
const boxHeight = Math.max(minBoxHeight, lines.length * lineHeight + paddingY * 2);
const radius = UI.boxRadius * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.font = getTextFont(textStyle, fontSize);
context.textAlign = 'center';
context.textBaseline = 'middle';
let maxTextWidth = 0;
for (const line of lines) {
maxTextWidth = Math.max(maxTextWidth, context.measureText(line).width);
}
const width = maxTextWidth + paddingX * 2;
const x = centerX - width / 2;
const y = centerY - boxHeight / 2;
context.fillStyle = fillColor;
context.beginPath();
drawRoundedRect(context, x, y, width, boxHeight, radius);
context.fill();
context.fillStyle = textStyle.textColor;
if (lines.length === 1) {
context.fillText(lines[0], centerX, centerY);
context.restore();
return;
}
const textBlockHeight = lines.length * lineHeight;
const firstLineCenterY = centerY - textBlockHeight / 2 + lineHeight / 2;
lines.forEach((line, index) => {
context.fillText(line, centerX, firstLineCenterY + index * lineHeight);
});
context.restore();
}
function getTextFont(style: SliderPositionTextStyle, fontSize: number): string {
const italic = style.isItalic ? 'italic ' : '';
const bold = style.isBold ? '700 ' : '';
return `${italic}${bold}${fontSize}px Inter, sans-serif`;
}
function drawRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
const safeRadius = Math.min(radius, width / 2, height / 2);
context.moveTo(x + safeRadius, y);
context.arcTo(x + width, y, x + width, y + height, safeRadius);
context.arcTo(x + width, y + height, x, y + height, safeRadius);
context.arcTo(x, y + height, x, y, safeRadius);
context.arcTo(x, y, x + width, y, safeRadius);
context.closePath();
}
import { AxisLine } from '@src/core/Drawings/axisLine';
import { Diapson } from '@src/core/Drawings/diapson';
import { FibonacciRetracement } from '@src/core/Drawings/fibonacciRetracement';
import { LineDrawing } from '@src/core/Drawings/line';
import { ParallelChannel } from '@src/core/Drawings/parallelChannel';
import { Ray } from '@src/core/Drawings/ray';
import { Rectangle } from '@src/core/Drawings/rectangle';
import { RegressionTrend } from '@src/core/Drawings/regressionTrend';
import { Ruler } from '@src/core/Drawings/ruler';
import { SliderPosition } from '@src/core/Drawings/sliderPosition';
import { Text } from '@src/core/Drawings/text';
import { Traectory } from '@src/core/Drawings/traectory';
import { VolumeProfile } from '@src/core/Drawings/volumeProfile';
import { t } from '@src/translations';
import { DrawingConfig, DrawingParams, LineMarker } from '@src/types';
export enum DrawingsNames {
'trendLine' = 'trendLine',
'arrow' = 'arrow',
'parallelChannel' = 'parallelChannel',
'regressionTrend' = 'regressionTrend',
'ray' = 'ray',
'horizontalLine' = 'horizontalLine',
'horizontalRay' = 'horizontalRay',
'verticalLine' = 'verticalLine',
'ruler' = 'ruler',
'fibonacciRetracement' = 'fibonacciRetracement',
'sliderLong' = 'sliderLong',
'sliderShort' = 'sliderShort',
'diapsonDates' = 'diapsonDates',
'diapsonPrices' = 'diapsonPrices',
'fixedRangeProfile' = 'fixedRangeProfile',
'visibleRangeProfile' = 'visibleRangeProfile',
'rectangle' = 'rectangle',
'traectory' = 'traectory',
'text' = 'text',
}
export const drawingLabelById = (): Record<DrawingsNames, string> => ({
[DrawingsNames.trendLine]: t('Trend line'),
[DrawingsNames.arrow]: t('Arrow'),
[DrawingsNames.parallelChannel]: t('Parallel channel'),
[DrawingsNames.regressionTrend]: t('Regression trend'),
[DrawingsNames.ray]: t('Ray'),
[DrawingsNames.horizontalLine]: t('Horizontal line'),
[DrawingsNames.horizontalRay]: t('Horizontal ray'),
[DrawingsNames.verticalLine]: t('Vertical line'),
[DrawingsNames.fibonacciRetracement]: t('Fibonacci retracement'),
[DrawingsNames.ruler]: t('Ruler'),
[DrawingsNames.sliderLong]: t('Long position'),
[DrawingsNames.sliderShort]: t('Short position'),
[DrawingsNames.diapsonDates]: t('Dates range'),
[DrawingsNames.diapsonPrices]: t('Prices range'),
[DrawingsNames.fixedRangeProfile]: t('Fixed range volume profile'),
[DrawingsNames.visibleRangeProfile]: t('Anchored volume profile'),
[DrawingsNames.rectangle]: t('Rectangle'),
[DrawingsNames.traectory]: t('Traectory'),
[DrawingsNames.text]: t('Text'),
});
export const drawingsMap: Record<DrawingsNames, DrawingConfig> = {
[DrawingsNames.trendLine]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new LineDrawing({
...rest,
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.arrow]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new LineDrawing({
...rest,
formatObservable: eventManager.getChartOptionsModel(),
defaultMarkers: {
endMarker: LineMarker.arrow,
},
});
},
},
[DrawingsNames.parallelChannel]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new ParallelChannel({
...rest,
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.regressionTrend]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new RegressionTrend({
...rest,
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.ray]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new Ray({
...rest,
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.horizontalLine]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new AxisLine({
...rest,
direction: 'horizontal',
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.horizontalRay]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new LineDrawing({
...rest,
formatObservable: eventManager.getChartOptionsModel(),
defaultMarkers: {
endMarker: LineMarker.arrow,
},
});
},
},
[DrawingsNames.verticalLine]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new AxisLine({
...rest,
direction: 'vertical',
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.sliderLong]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new SliderPosition({
...rest,
side: 'long',
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.fibonacciRetracement]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new FibonacciRetracement({
...rest,
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.sliderShort]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new SliderPosition({
...rest,
side: 'short',
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.diapsonDates]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new Diapson({
...rest,
rangeMode: 'date',
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.diapsonPrices]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new Diapson({
...rest,
rangeMode: 'price',
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.fixedRangeProfile]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new VolumeProfile({
...rest,
profileKind: 'fixedRange',
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.visibleRangeProfile]: {
singleInstance: true,
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new VolumeProfile({
...rest,
profileKind: 'visibleRange',
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.rectangle]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new Rectangle({
...rest,
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.ruler]: {
singleInstance: true,
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new Ruler({
...rest,
formatObservable: eventManager.getChartOptionsModel(),
resetTriggers: [eventManager.getTimeframeObs(), eventManager.getInterval()],
});
},
},
[DrawingsNames.traectory]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new Traectory({
...rest,
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
[DrawingsNames.text]: {
construct: (params: DrawingParams) => {
const { eventManager, ...rest } = params;
return new Text({
...rest,
formatObservable: eventManager.getChartOptionsModel(),
});
},
},
};
import { IChartApi, ISeriesApi, MouseEventParams, 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 } 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 { Pane } from './Pane';
import type { DrawingInteraction } from '@src/core/Drawings/SeriesDrawingBase';
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;
pane: Pane;
setActiveTool: (name: ActiveDrawingTool) => void;
getActiveTool: () => ActiveDrawingTool;
getIsEndlessMode: () => boolean;
continueDrawing: (name: DrawingsNames) => void;
}
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); // todo: переместить в DrawingsManagerCollection
private pendingSnapshot: DrawingsManagerSnapshot | null = null;
private selectedDrawingSnapshot: DrawingSnapshotItem | null = null;
private pane: Pane;
private copyPasteBuffer: DrawingSnapshotItem | null = null;
private setActiveTool: (name: ActiveDrawingTool) => void;
private getActiveTool: () => ActiveDrawingTool;
private getIsEndlessMode: () => boolean;
private continueDrawing: (name: DrawingsNames) => void;
constructor({
eventManager,
mainSeries$,
lwcChart,
DOM,
container,
modalRenderer,
paneId,
hotkeys,
pane,
setActiveTool,
getActiveTool,
getIsEndlessMode,
continueDrawing,
}: DrawingsManagerParams) {
this.DOM = DOM;
this.eventManager = eventManager;
this.paneId = paneId;
this.pane = pane;
this.lwcChart = lwcChart;
this.container = container;
this.modalRenderer = modalRenderer;
this.hotkeys = hotkeys;
this.setActiveTool = setActiveTool;
this.getActiveTool = getActiveTool;
this.getIsEndlessMode = getIsEndlessMode;
this.continueDrawing = continueDrawing;
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;
doAfterPromise(() => this.setSnapshot(snapshot), this.pane.isReady());
}
}),
);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
this.container.addEventListener('click', this.handleClick);
this.container.addEventListener('pointerdown', this.handlePointerDown);
this.container.addEventListener('dblclick', this.handleDoubleClick);
this.container.addEventListener('contextmenu', this.handleContextMenu);
// 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]
// // })
// }
// })
}
// TODO: handlePointerDown конфликтует с DrawingsManagerCollection.handlePaneClick
private handlePointerDown = (event: PointerEvent): void => {
if (!this.isEventInPane(event)) {
return;
}
this.selectedDrawingSnapshot = null;
if (event.button === 0) {
const drawings = this.drawings$.value;
const pendingDrawing = drawings.find((drawing) => drawing.isCreationPending());
if (!pendingDrawing && this.getActiveTool() !== 'crosshair') {
return;
}
const selectedDrawing = this.selectedDrawing$.value;
const drawing =
pendingDrawing ??
(selectedDrawing?.getSeriesDrawing().isHit(event) ? selectedDrawing : null) ??
this.findTopDrawing(event);
if (drawing && !drawing.isCreationPending()) {
this.selectedDrawingSnapshot = this.createDrawingSnapshot(drawing);
}
(drawing ?? selectedDrawing)?.getSeriesDrawing().pointerDown(event);
}
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();
};
private handleDoubleClick = (event: MouseEvent): void => {
if (!this.isEventInPane(event)) {
return;
}
const pendingDrawing = this.drawings$.value.find((drawing) => drawing.isCreationPending());
const drawing = pendingDrawing ?? this.findTopDrawing(event);
if (!drawing) {
return;
}
if (!drawing.isCreationPending() && drawing !== this.selectedDrawing$.value) {
this.selectedDrawing$.next(drawing);
}
drawing.getSeriesDrawing().doubleClick(event);
this.DOM.refreshEntities();
};
private handleContextMenu = (event: MouseEvent): void => {
if (!this.isEventInPane(event)) {
return;
}
const pendingDrawing = this.drawings$.value.find((drawing) => drawing.isCreationPending());
const drawing = pendingDrawing ?? this.findTopDrawing(event);
drawing?.getSeriesDrawing().contextMenu(event);
};
private handleClick = (event: MouseEvent): void => {
if (!this.isEventInPane(event)) {
return;
}
const pendingDrawing = this.drawings$.value.find((drawing) => drawing.isCreationPending());
pendingDrawing?.getSeriesDrawing().click(event);
this.DOM.refreshEntities();
};
private isEventInPane(event: MouseEvent): boolean {
const paneElement = this.pane.getHTMLElement();
return paneElement !== null && event.target instanceof Node && paneElement.contains(event.target);
}
private findTopDrawing(event: MouseEvent): Drawing | null {
let topDrawing: Drawing | null = null;
for (const drawing of this.drawings$.value) {
if (!drawing.getSeriesDrawing().isHit(event)) {
continue;
}
if (!topDrawing || drawing.zIndex > topDrawing.zIndex) {
topDrawing = drawing;
}
}
return topDrawing;
}
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.setActiveTool('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({
name: snapshot.drawingName,
options: {
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.getActiveTool();
if (activeTool !== 'crosshair' && this.getIsEndlessMode()) {
this.continueDrawing(activeTool);
return;
}
this.setActiveTool('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 startDrawing = async (name: DrawingsNames, event?: MouseEventParams): 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);
}
if (this.selectedDrawing$.value) {
this.selectedDrawing$.next(null);
}
this.setActiveTool(name);
const drawing = this.createDrawing({
name,
event,
});
this.DOM.refreshEntities();
await drawing.waitForCreation();
if (!this.findDrawing(drawing.id)) {
if (previousSnapshot) {
this.restoreDrawing(previousSnapshot);
}
return;
}
this.pushDrawingChange(previousSnapshot, this.createDrawingSnapshot(drawing));
if (this.getActiveTool() === name) {
this.selectedDrawing$.next(drawing);
this.updateActiveTool();
}
this.DOM.refreshEntities();
};
private createDrawing({
name,
options = {},
event,
}: {
name: DrawingsNames;
options?: CreateDrawingOptions;
event?: MouseEventParams;
}): 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 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 = this.pane.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);
}
},
initialEvent: event,
});
};
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,
});
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));
}
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({
name: item.drawingName,
options: {
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.setActiveTool('crosshair');
this.DOM.refreshEntities();
}
public cancelPendingDrawing(): void {
this.removePendingDrawings(false);
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 {
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
this.container.removeEventListener('click', this.handleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDown);
this.container.removeEventListener('dblclick', this.handleDoubleClick);
this.container.removeEventListener('contextmenu', this.handleContextMenu);
this.drawings$.value.forEach((drawing) => drawing.destroy());
this.selectedDrawingSnapshot = null;
this.copyPasteBuffer = null;
this.subscriptions.unsubscribe();
this.drawings$.complete();
this.selectedDrawing$.complete();
}
}
async function doAfterPromise(cb: () => void, waiter: Promise<void>) {
await waiter;
cb();
}
import { BehaviorSubject, Observable } from 'rxjs';
import { DrawingsNames } from '@src/constants';
import { DRAWING_KEYBOARD_SHORTCUTS, findDrawingPointerShortcut } from '@src/core/Drawings/shortcuts';
import { ActiveDrawingTool, SettingsValues } from '@src/types';
import { Drawing } from './Drawings';
import { DrawingsManager } from './DrawingsManager';
import { Hotkeys, Keys } from './Hotkeys';
import type { Pane } from './Pane';
import type { MouseEventParams } from 'lightweight-charts';
// todo: нужно дописывать класс)
export class DrawingsManagerCollection {
private managersMap: Map<number, DrawingsManager> = new Map();
private hotkeys: Hotkeys;
private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
private endlessMode$ = new BehaviorSubject(false);
private isAwaitingDrawingStart = false;
private unregisterHotkeys: (() => void)[] = [];
constructor({ hotkeys }: { hotkeys: Hotkeys }) {
this.hotkeys = hotkeys;
for (const { keys, drawingName } of DRAWING_KEYBOARD_SHORTCUTS) {
this.unregisterHotkeys.push(
this.hotkeys.register({
keys,
callback: () => {
this.activateDrawingTool(drawingName);
},
}),
);
}
this.unregisterHotkeys.push(
this.hotkeys.register({
keys: [Keys.escape],
callback: this.activateCrosshair,
}),
);
}
public getIsEndlessMode(): boolean {
return this.endlessMode$.value;
}
public getActiveToolValue(): ActiveDrawingTool {
return this.activeTool$.value;
}
public setActiveTool(next: ActiveDrawingTool): void {
this.activeTool$.next(next);
if (next === 'crosshair') {
this.isAwaitingDrawingStart = false;
}
}
public removeDrawingManager(paneId: number): void {
this.managersMap.delete(paneId);
}
public addDrawingManager(manager: DrawingsManager, paneId: number): void {
this.managersMap.set(paneId, manager);
}
public activateDrawingTool = (name: DrawingsNames): void => {
this.cancelPendingDrawings();
this.setActiveTool(name);
this.isAwaitingDrawingStart = true;
};
public handlePaneClick(pane: Pane, event: MouseEventParams): void {
const activeTool = this.activeTool$.value;
if (activeTool === 'crosshair') {
const pointerShortcut = findDrawingPointerShortcut(event);
if (pointerShortcut) {
pane.getDrawingManager().startDrawing(pointerShortcut.drawingName, event);
}
return;
}
if (!this.isAwaitingDrawingStart) {
return;
}
this.isAwaitingDrawingStart = false;
pane.getDrawingManager().startDrawing(activeTool, event);
}
public setEndlessDrawingMode = (value: boolean): void => {
if (this.endlessMode$.value === value) {
return;
}
this.endlessMode$.next(value);
};
public isEndlessDrawingsMode(): Observable<boolean> {
return this.endlessMode$.asObservable();
}
public activateCrosshair = (): void => {
this.cancelPendingDrawings();
this.setActiveTool('crosshair');
};
public getActiveTool(): Observable<ActiveDrawingTool> {
return this.activeTool$.asObservable();
}
public selectedDrawing(): Observable<Drawing | null> {
return Array.from(this.managersMap.values())[0].selectedDrawing();
}
public updateSelectedDrawingSettings = (settings: SettingsValues): void => {
for (const manager of this.managersMap.values()) {
manager.updateSelectedDrawingSettings(settings);
}
};
public toggleSelectedDrawingLock = (): void => {
Array.from(this.managersMap.values())[0].toggleSelectedDrawingLock();
};
public openSelectedDrawingSettings = (): void => {
Array.from(this.managersMap.values())[0].openSelectedDrawingSettings();
};
public deleteSelectedDrawing = (): void => {
Array.from(this.managersMap.values())[0].deleteSelectedDrawing();
};
public destroy(): void {
for (const unregisterHotkey of this.unregisterHotkeys) {
unregisterHotkey();
}
this.unregisterHotkeys = [];
this.activeTool$.complete();
this.endlessMode$.complete();
}
private cancelPendingDrawings(): void {
for (const manager of this.managersMap.values()) {
manager.cancelPendingDrawing();
}
}
}
import { IChartApi, IPaneApi, PriceScaleMode, Time } from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';
import { ChartTooltip } from '@components/ChartTooltip';
import { LegendComponent } from '@components/Legend';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { ContainerManager } from '@core/ContainerManager';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { Hotkeys } from '@core/Hotkeys';
import { Indicator } from '@core/Indicator';
import { Legend } from '@core/Legend';
import { PriceScale, PriceScaleControls } from '@core/PriceScale';
import { ReactRenderer } from '@core/ReactRenderer';
import { TooltipService } from '@core/Tooltip';
import { UIRenderer } from '@core/UIRenderer';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { DrawingsNames, indicatorLabelById, MAIN_PANE_INDEX } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesFactory, SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { t } from '@src/translations';
import { ActiveDrawingTool, Direction, OHLCConfig, TooltipConfig } from '@src/types';
import {
CompareSnapshot,
DOMObjectSnapshot,
IndicatorSnapshot,
ISerializable,
PaneSnapshot,
PriceScaleSide,
PriceScaleSnapshot,
} from '@src/types/snapshot';
import { ensureDefined } from '@src/utils';
export interface PaneParams {
id: number;
lwcChart: IChartApi;
eventManager: EventManager;
DOM: DOMModel;
isMainPane: boolean;
ohlcConfig: OHLCConfig;
dataSource: DataSource | null; // todo: deal with dataSource. На каких то пейнах он нужен, на каких то нет
basedOn?: Pane; // Pane на котором находится главная серия, или серия, по которой строятся серии на текущем пейне
subscribeChartEvent: ChartMouseEvents['subscribe'];
tooltipConfig: TooltipConfig;
onDelete: () => void;
chartContainer: HTMLElement;
modalRenderer: ModalRenderer;
initialPriceScales?: PriceScaleSnapshot[];
onPriceScaleStateChange: () => void;
leftPriceScaleVisible: boolean;
rightPriceScaleVisible: boolean;
hotkeys: Hotkeys;
addDrawingManager: (manager: DrawingsManager, paneId: number) => void;
removeDrawingManager: (paneId: number) => void;
setActiveTool: (name: ActiveDrawingTool) => void;
getActiveTool: () => ActiveDrawingTool;
getIsEndlessMode: () => boolean;
continueDrawing: (name: DrawingsNames) => void;
}
// todo: Pane, ему должна принадлежать mainSerie, а также IndicatorManager и drawingsManager, mouseEvents. Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: Учитывать, что есть линейка, которая рисуется одна для всех пейнов
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class Pane implements ISerializable<PaneSnapshot> {
private readonly id: number;
private readonly isMain: boolean;
private mainSeries = new BehaviorSubject<SeriesStrategies | null>(null); // Main Series. Exists in a single copy
// todo: Отвратительный нейминг. Нужно оставить главной только эту серию и переименовать её localMainSeries => mainSerie
// Если вдруг понадобится главная серия главного пейна, то брать из КоллекцииПейнов(-сейчас PaneManager)
// serie to attach drawings
private localMainSeries = new BehaviorSubject<SeriesStrategies | null>(null);
private legend!: Legend;
private tooltip: TooltipService | undefined;
private readonly indicatorsMap = new BehaviorSubject<Map<string, Indicator>>(new Map());
private readonly lwcPane: IPaneApi<Time>;
private readonly lwcChart: IChartApi;
private readonly eventManager: EventManager;
private readonly drawingsManager: DrawingsManager;
private legendContainer!: HTMLElement;
private paneOverlayContainer!: HTMLElement;
private legendRenderer!: UIRenderer;
private tooltipRenderer: UIRenderer | undefined;
private readonly modalRenderer: ModalRenderer;
private readonly leftPriceScale: PriceScale;
private readonly rightPriceScale: PriceScale;
private readonly priceScaleControls: PriceScaleControls;
private mainSerieSub?: Subscription;
private readonly subscribeChartEvent: ChartMouseEvents['subscribe'];
private readonly onDelete: () => void;
private readonly onPriceScaleStateChange: () => void;
private readonly subscriptions = new Subscription();
private paneContainerSyncFrameId: number | null = null;
constructor({
lwcChart,
eventManager,
dataSource,
DOM,
isMainPane,
ohlcConfig,
id,
basedOn,
subscribeChartEvent,
tooltipConfig,
onDelete,
chartContainer,
modalRenderer,
initialPriceScales = [],
onPriceScaleStateChange,
leftPriceScaleVisible,
rightPriceScaleVisible,
hotkeys,
addDrawingManager,
removeDrawingManager,
setActiveTool,
getActiveTool,
getIsEndlessMode,
continueDrawing,
}: PaneParams) {
this.onDelete = onDelete;
this.onPriceScaleStateChange = onPriceScaleStateChange;
this.eventManager = eventManager;
this.lwcChart = lwcChart;
this.modalRenderer = modalRenderer;
this.subscribeChartEvent = subscribeChartEvent;
this.isMain = isMainPane;
this.id = id;
if (isMainPane) {
this.lwcPane = this.lwcChart.panes()[MAIN_PANE_INDEX];
} else {
this.lwcPane = this.lwcChart.addPane(true);
}
this.leftPriceScale = this.createPriceScale(Direction.Left, initialPriceScales, leftPriceScaleVisible);
this.rightPriceScale = this.createPriceScale(Direction.Right, initialPriceScales, rightPriceScaleVisible);
// TODO: Перенести PriceScaleControls внутрь PriceScale, чтобы каждая шкала владела собственными контролами, а PriceScaleControls работал только с одной шкалой.
this.priceScaleControls = new PriceScaleControls({
leftPriceScale: this.leftPriceScale,
rightPriceScale: this.rightPriceScale,
onPriceScaleChange: this.handlePriceScaleStateChange,
});
this.initializeLegend({ ohlcConfig });
this.tooltip = new TooltipService({
config: tooltipConfig,
legend: this.legend,
paneOverlayContainer: this.paneOverlayContainer,
});
this.tooltipRenderer = new ReactRenderer(this.paneOverlayContainer);
this.tooltipRenderer.renderComponent(
<ChartTooltip
formatObs={this.eventManager.getChartOptionsModel()}
timeframeObs={this.eventManager.getTimeframeObs()}
viewModel={this.tooltip.getTooltipViewModel()}
// ohlcConfig={this.legend.getConfig()}
ohlcConfig={ohlcConfig}
tooltipConfig={this.tooltip.getConfig()}
/>,
);
if (dataSource) {
this.initializeMainSerie({ lwcChart, dataSource });
} else if (basedOn) {
this.mainSeries = basedOn.getMainSerie();
this.mainSeries.subscribe(() => {
this.rebindIndicators();
});
} else {
console.error('[Pane]: There is no any mainSerie for new pane');
}
this.drawingsManager = new DrawingsManager({
// todo: менеджер дровингов должен быть один на чарт, не на пейн
pane: this,
eventManager,
DOM,
mainSeries$: this.localMainSeries.asObservable(),
lwcChart,
container: chartContainer,
modalRenderer: this.modalRenderer,
paneId: this.id,
hotkeys,
setActiveTool,
getActiveTool,
getIsEndlessMode,
continueDrawing,
});
addDrawingManager(this.drawingsManager, this.id);
this.subscriptions.add(() => removeDrawingManager(this.id));
this.subscriptions.add(
this.drawingsManager.entities().subscribe((drawings) => {
const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);
this.legendContainer.style.display = hasRuler ? 'none' : '';
}),
);
}
public getLocalMainSeries = () => {
return this.localMainSeries.value;
};
public setLocalMainSeries = (next: SeriesStrategies) => {
if (!this.isMain) {
this.localMainSeries.next(next);
}
};
public isMainPane = () => {
return this.isMain;
};
public getDrawingsSnapshot(): DrawingsManagerSnapshot {
return this.drawingsManager.getSnapshot();
}
public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
this.drawingsManager.setSnapshot(snapshot);
}
public getMainSerie = () => {
return this.mainSeries;
};
public getId = () => {
return this.id;
};
public isReady = async (interval = 50): Promise<void> => {
return new Promise((resolve) => {
const check = () => {
if (this.lwcPane.getHTMLElement() === null) {
setTimeout(check, interval);
} else {
resolve();
}
};
check();
});
};
public getHTMLElement = () => {
return this.lwcPane.getHTMLElement();
};
public paneIndex = () => {
return this.lwcPane.paneIndex();
};
public getPriceScale(side: PriceScaleSide): PriceScale {
return side === Direction.Left ? this.leftPriceScale : this.rightPriceScale;
}
public setIndicator(indicatorId: string, indicator: Indicator): void {
const map = this.indicatorsMap.value;
map.set(indicatorId, indicator);
this.indicatorsMap.next(map);
this.priceScaleControls.refresh();
}
public removeIndicator(indicatorId: string): void {
const map = this.indicatorsMap.value;
map.delete(indicatorId);
this.indicatorsMap.next(map);
this.priceScaleControls.refresh();
if (map.size === 0 && !this.isMain) {
this.onDelete();
}
}
public getDrawingManager(): DrawingsManager {
return this.drawingsManager;
}
public schedulePaneContainerSync(): void {
if (this.paneContainerSyncFrameId !== null) {
return;
}
this.paneContainerSyncFrameId = requestAnimationFrame(() => {
this.paneContainerSyncFrameId = null;
this.syncPaneContainers();
});
}
public refreshPriceScaleControls(): void {
this.priceScaleControls.refresh();
}
public resetPriceScalesAutoScale(): void {
this.leftPriceScale.enableAutoScale();
this.rightPriceScale.enableAutoScale();
this.handlePriceScaleStateChange();
}
public getSnapshot(): PaneSnapshot {
const indicators: (DOMObjectSnapshot & (IndicatorSnapshot | CompareSnapshot))[] = [];
this.indicatorsMap.value.forEach((indicator) => {
indicators.push(indicator.getSnapshot());
});
return {
isMain: this.isMain,
id: this.id,
indicators,
drawings: this.getDrawingsSnapshot(),
priceScales: [this.leftPriceScale.getSnapshot(), this.rightPriceScale.getSnapshot()],
};
}
public destroy(): void {
if (this.paneContainerSyncFrameId !== null) {
cancelAnimationFrame(this.paneContainerSyncFrameId);
this.paneContainerSyncFrameId = null;
}
this.drawingsManager.destroy();
this.subscriptions.unsubscribe();
this.tooltip?.destroy();
this.legend?.destroy();
this.legendRenderer.destroy();
this.tooltipRenderer?.destroy();
this.priceScaleControls.destroy();
this.legendContainer.remove();
this.paneOverlayContainer.remove();
this.indicatorsMap.complete();
this.mainSerieSub?.unsubscribe();
this.localMainSeries.complete();
if (this.isMain) {
this.mainSeries.value?.destroy();
this.mainSeries.complete();
}
}
private createPriceScale(
side: PriceScaleSide,
initialPriceScales: PriceScaleSnapshot[],
initialVisible: boolean,
): PriceScale {
const initialMode =
initialPriceScales.find((priceScaleSnapshot) => priceScaleSnapshot.side === side)?.mode ?? PriceScaleMode.Normal;
return new PriceScale({
paneId: this.id,
side,
pane: this.lwcPane,
initialMode,
initialVisible,
hasVisibleSeriesData: () => this.hasVisibleSeriesData(side),
});
}
private hasVisibleSeriesData(side: PriceScaleSide): boolean {
const mainSeries = this.mainSeries.value;
const defaultPriceScaleSide = this.lwcChart.options().defaultVisiblePriceScaleId as PriceScaleSide;
if (this.isMain && side === defaultPriceScaleSide && mainSeries?.isVisible() && mainSeries.data().length > 0) {
return true;
}
const indicators = Array.from(this.indicatorsMap.value.values());
for (let indicatorIndex = 0; indicatorIndex < indicators.length; indicatorIndex += 1) {
const series = Array.from(indicators[indicatorIndex].getSeriesMap().values());
for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) {
const currentSeries = series[seriesIndex];
const seriesPriceScaleSide = (currentSeries.options().priceScaleId ?? defaultPriceScaleSide) as PriceScaleSide;
if (currentSeries.isVisible() && currentSeries.data().length > 0 && seriesPriceScaleSide === side) {
return true;
}
}
}
return false;
}
private handlePriceScaleStateChange = (): void => {
this.priceScaleControls.refresh();
this.onPriceScaleStateChange();
};
private initializeLegend({ ohlcConfig }: { ohlcConfig: OHLCConfig }): void {
const { legendContainer, paneOverlayContainer } = ContainerManager.createPaneContainers();
this.legendContainer = legendContainer;
this.paneOverlayContainer = paneOverlayContainer;
this.legendRenderer = new ReactRenderer(legendContainer);
this.schedulePaneContainerSync();
this.legend = new Legend({
config: ohlcConfig,
indicators: this.indicatorsMap,
eventManager: this.eventManager,
subscribeChartEvent: this.subscribeChartEvent,
mainSeries: this.isMain ? this.mainSeries : null,
paneId: this.id,
paneIndex: this.paneIndex,
openIndicatorSettings: (indicatorId, indicator) => {
let settings = indicator.getSettingsValues();
this.modalRenderer.renderComponent(
<EntitySettingsModal
tabs={[
{
key: 'arguments',
label: t('Arguments'),
fields: indicator.getSettingsFields(),
},
]}
values={settings}
onChange={(nextSettings) => {
settings = nextSettings;
}}
initialTabKey="arguments"
/>,
{
size: 'sm',
title: indicatorLabelById()[indicatorId],
onSave: () => indicator.updateSettings(settings),
},
);
},
// todo: throw isMainPane
});
this.legendRenderer.renderComponent(
<LegendComponent
ohlcConfig={this.legend.getConfig()}
viewModel={this.legend.getLegendViewModel()}
/>,
);
}
private rebindIndicators(): void {
for (const indicator of this.indicatorsMap.value.values()) {
indicator.recreateSeries();
}
}
private initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }): void {
this.localMainSeries = this.mainSeries;
this.mainSerieSub = this.eventManager.subscribeSeriesSelected((nextSeries) => {
this.mainSeries.value?.destroy();
const next = ensureDefined(SeriesFactory.create(nextSeries))({
lwcChart,
dataSource,
mainSymbolId$: this.eventManager.symbolId(),
mainSymbol$: this.eventManager.symbol(),
mainSerie$: this.mainSeries,
});
this.mainSeries.next(next);
this.rebindIndicators();
this.priceScaleControls.refresh();
});
}
private syncPaneContainers(): void {
const lwcPaneElement = this.lwcPane.getHTMLElement();
if (!lwcPaneElement) {
this.schedulePaneContainerSync();
return;
}
/*
Внутри lightweight-chart DOM построен как таблица из 3 td
[0] left priceScale, [1] center chart, [2] right priceScale
Кладём легенду в td[1] и тогда легенда сама будет адаптироваться при изменении ширины шкал
*/
const cells = lwcPaneElement.querySelectorAll<HTMLTableCellElement>(':scope > td');
const chartCell = cells.item(1);
if (!chartCell) {
this.schedulePaneContainerSync();
return;
}
chartCell.style.position = 'relative';
chartCell.appendChild(this.legendContainer);
chartCell.appendChild(this.paneOverlayContainer);
this.priceScaleControls.mount(lwcPaneElement);
}
}
import {
CHART_DRAWING_TOOLBAR_CONTAINER,
CHART_MODAL_CONTAINER_CLASSNAME,
CHART_PANE_OVERLAY_CONTAINER,
CHART_ROOT_CLASSNAME,
} from '@src/constants';
import styles from './styles.module.scss';
interface CreateContainersOptions {
parentContainer: HTMLElement;
showBottomPanel?: boolean;
showMenuButton?: boolean;
}
enum ZIndex {
Chart = '0',
Base = '1',
Floating = '2',
Legend = '3',
Modal = '100',
}
const ContainerLayoutConfig = {
headerHeight: 36,
footerHeight: 32,
toolbarWidth: 42,
verticalGap: 8,
};
/**
* Утилита для создания DOM контейнеров
*/
export class ContainerManager {
private static injectFont(): void {
const existingLink = document.querySelector('link[href*="fonts.googleapis.com"]');
if (existingLink) {
return;
}
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,100..900&display=swap';
document.head.appendChild(link);
}
/**
* Создание контейнеров для графика и UI компонентов
*/
static createContainers({ parentContainer, showBottomPanel, showMenuButton }: CreateContainersOptions) {
this.injectFont();
const { headerHeight, footerHeight, toolbarWidth, verticalGap } = ContainerLayoutConfig;
parentContainer.innerHTML = '';
parentContainer.classList.add(CHART_ROOT_CLASSNAME);
parentContainer.style.height = '100%';
parentContainer.style.width = '100%';
parentContainer.style.padding = 'var(--space-1000)';
parentContainer.style.backgroundColor = 'var(--neutral-0)';
parentContainer.style.borderRadius = 'var(--space-0500)';
parentContainer.style.display = 'grid';
parentContainer.style.rowGap = `${verticalGap}px`;
parentContainer.style.gridTemplateRows = showBottomPanel
? `${headerHeight}px minmax(0, 1fr) ${footerHeight}px`
: `${headerHeight}px minmax(0, 1fr)`;
const headerContainer = document.createElement('div');
headerContainer.style.width = '100%';
headerContainer.style.height = `${headerHeight}px`;
headerContainer.style.overflow = 'auto hidden';
headerContainer.className = styles.scrollableBox;
const footerContainer = document.createElement('div');
footerContainer.style.width = '100%';
footerContainer.style.height = `${footerHeight}px`;
const chartContainer = document.createElement('div');
chartContainer.style.position = 'relative';
chartContainer.style.height = '100%';
chartContainer.style.width = '100%';
chartContainer.style.minWidth = '0';
chartContainer.style.minHeight = '0';
chartContainer.style.display = 'grid';
chartContainer.style.columnGap = 'var(--space-0500)';
chartContainer.style.gridTemplateColumns = 'minmax(0, 1fr)';
const chartAreaContainer = document.createElement('div');
chartAreaContainer.style.position = 'relative';
chartAreaContainer.style.height = '100%';
chartAreaContainer.style.minHeight = '0';
chartAreaContainer.style.minWidth = '0';
chartAreaContainer.style.cursor = 'crosshair';
const toolBarContainer = document.createElement('div');
toolBarContainer.style.height = '100%';
toolBarContainer.style.minHeight = '0';
toolBarContainer.style.overflow = 'hidden';
const controlBarContainer = document.createElement('div');
controlBarContainer.style.position = 'absolute';
controlBarContainer.style.left = '50%';
controlBarContainer.style.transform = 'translateX(-50%)';
controlBarContainer.style.bottom = 'var(--space-2000)';
controlBarContainer.style.zIndex = ZIndex.Base;
const drawingToolbarContainer = document.createElement('div');
drawingToolbarContainer.classList.add(CHART_DRAWING_TOOLBAR_CONTAINER);
drawingToolbarContainer.style.position = 'absolute';
drawingToolbarContainer.style.inset = '0';
drawingToolbarContainer.style.zIndex = ZIndex.Floating;
drawingToolbarContainer.style.pointerEvents = 'none';
drawingToolbarContainer.style.overflow = 'hidden';
const modalContainer = document.createElement('div');
modalContainer.classList.add(CHART_MODAL_CONTAINER_CLASSNAME);
modalContainer.style.position = 'absolute';
modalContainer.style.inset = '0';
modalContainer.style.zIndex = ZIndex.Modal;
modalContainer.hidden = true;
chartAreaContainer.append(controlBarContainer, drawingToolbarContainer);
chartContainer.append(chartAreaContainer, modalContainer);
parentContainer.append(headerContainer, chartContainer);
if (showBottomPanel) {
parentContainer.append(footerContainer);
}
const toggleToolbar = () => {
const mounted = toolBarContainer.isConnected;
if (mounted) {
toolBarContainer.remove();
chartContainer.style.gridTemplateColumns = 'minmax(0, 1fr)';
return false;
}
chartContainer.insertBefore(toolBarContainer, chartAreaContainer);
chartContainer.style.gridTemplateColumns = `${toolbarWidth}px minmax(0, 1fr)`;
return true;
};
chartContainer.insertBefore(toolBarContainer, chartAreaContainer);
chartContainer.style.gridTemplateColumns = `${toolbarWidth}px minmax(0, 1fr)`;
if (!showMenuButton) {
toggleToolbar();
}
return {
headerContainer,
footerContainer,
chartContainer,
chartAreaContainer,
toolBarContainer,
modalContainer,
controlBarContainer,
drawingToolbarContainer,
toggleToolbar,
};
}
/**
* Очистка контейнеров
*/
static clearContainers(parentContainer: HTMLElement): void {
parentContainer.innerHTML = '';
}
static createPaneContainers() {
const legendContainer = document.createElement('div');
legendContainer.style.width = '80%';
legendContainer.style.position = 'absolute';
legendContainer.style.top = '0';
legendContainer.style.left = '0';
legendContainer.style.zIndex = ZIndex.Legend;
legendContainer.style.pointerEvents = 'none';
const paneOverlayContainer = document.createElement('div');
paneOverlayContainer.classList.add(CHART_PANE_OVERLAY_CONTAINER);
paneOverlayContainer.style.position = 'absolute';
paneOverlayContainer.style.inset = '0';
paneOverlayContainer.style.zIndex = ZIndex.Base;
paneOverlayContainer.style.pointerEvents = 'none';
return {
legendContainer,
paneOverlayContainer,
};
}
}
import { IPaneApi, IPriceScaleApi, PriceScaleMode, Time } from 'lightweight-charts';
import type { PriceScaleSide, PriceScaleSnapshot } from '@src/types/snapshot';
interface PriceScaleParams {
paneId: number;
side: PriceScaleSide;
pane: IPaneApi<Time>;
initialMode?: PriceScaleMode;
initialVisible: boolean;
hasVisibleSeriesData: () => boolean;
}
export class PriceScale {
public readonly paneId: number;
public readonly side: PriceScaleSide;
private readonly pane: IPaneApi<Time>;
private readonly hasVisibleSeriesDataCallback: () => boolean;
private mode: PriceScaleMode;
private visible: boolean;
constructor({
paneId,
side,
pane,
initialMode = PriceScaleMode.Normal,
initialVisible,
hasVisibleSeriesData,
}: PriceScaleParams) {
this.paneId = paneId;
this.side = side;
this.pane = pane;
this.mode = initialMode;
this.visible = initialVisible;
this.hasVisibleSeriesDataCallback = hasVisibleSeriesData;
const priceScale = this.getPriceScaleApi();
priceScale.applyOptions({
mode: this.mode,
autoScale: priceScale.options().autoScale ?? true,
visible: this.visible,
borderVisible: false,
});
}
public getMode(): PriceScaleMode {
return this.mode;
}
public setMode(mode: PriceScaleMode): void {
if (this.mode === mode) {
return;
}
this.mode = mode;
this.getPriceScaleApi().applyOptions({
mode,
});
}
public toggleLogarithmic(): void {
const nextMode = this.mode === PriceScaleMode.Logarithmic ? PriceScaleMode.Normal : PriceScaleMode.Logarithmic;
this.setMode(nextMode);
}
public isAutoScaleEnabled(): boolean {
return this.getPriceScaleApi().options().autoScale ?? true;
}
public toggleAutoScale(): void {
const priceScale = this.getPriceScaleApi();
const autoScaleEnabled = priceScale.options().autoScale ?? true;
priceScale.setAutoScale(!autoScaleEnabled);
}
public enableAutoScale(): void {
const priceScale = this.getPriceScaleApi();
if (priceScale.options().autoScale ?? true) {
return;
}
priceScale.setAutoScale(true);
}
public isVisible(): boolean {
return this.visible;
}
public setVisible(visible: boolean): void {
if (this.visible === visible) {
return;
}
this.visible = visible;
this.getPriceScaleApi().applyOptions({
visible,
borderVisible: false,
});
}
public hasVisibleSeriesData(): boolean {
return this.hasVisibleSeriesDataCallback();
}
public getSnapshot(): PriceScaleSnapshot {
return {
side: this.side,
mode: this.mode,
};
}
private getPriceScaleApi(): IPriceScaleApi {
return this.pane.priceScale(this.side);
}
}