Загрузка данных
import { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
import { clamp } from 'lodash-es';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
import { SettingsTab } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { VolumeProfilePaneView } from './paneView';
import {
createDefaultSettings,
getVolumeProfileSettingsTabs,
VolumeProfileSettings,
VolumeProfileStyle,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel } from '@src/types';
export type VolumeProfileKind = 'fixedRange' | 'visibleRange';
type VolumeProfileMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DragTarget = 'body' | 'poc' | 'start' | 'end' | null;
type VolumeProfileHandleId = Exclude<DragTarget, 'body' | null>;
interface VolumeProfileParams extends BaseDrawingParams {
profileKind?: VolumeProfileKind;
}
interface SeriesCandleData {
time: Time;
open?: number;
high?: number;
low?: number;
close?: number;
value?: number;
volume?: number;
customValues?: {
open?: number;
high?: number;
low?: number;
close?: number;
value?: number;
volume?: number;
};
}
export interface VolumeProfileState {
hidden: boolean;
mode: VolumeProfileMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
visibleRangeStartRatio: number;
settings: VolumeProfileSettings;
}
interface VolumeProfileDataRow {
priceLow: number;
priceHigh: number;
buyVolume: number;
sellVolume: number;
totalVolume: number;
}
interface VolumeProfileGeometry extends Bounds {
width: number;
height: number;
startPoint: Point;
endPoint: Point;
}
interface VolumeProfileRenderRow {
top: number;
height: number;
buyWidth: number;
sellWidth: number;
}
export interface VolumeProfileRenderData extends VolumeProfileGeometry, VolumeProfileStyle {
profileKind: VolumeProfileKind;
rows: VolumeProfileRenderRow[];
pocY: number | null;
}
const PROFILE_ROW_COUNT = 24;
const BODY_HIT_TOLERANCE = 4;
const MIN_PROFILE_SIZE = 8;
const MAX_VISIBLE_RANGE_START_RATIO = 0.95;
export class VolumeProfile
extends SeriesDrawingBase<VolumeProfileSettings, VolumeProfileHandleId>
implements ISeriesDrawing
{
private removeSelf?: () => void;
private openSettings?: () => void;
private readonly profileKind: VolumeProfileKind;
protected settings: VolumeProfileSettings = createDefaultSettings();
protected mode: VolumeProfileMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private profileRows: VolumeProfileDataRow[] = [];
private profileMinPrice: number | null = null;
private profileMaxPrice: number | null = null;
private visibleRangeStartRatio = 0;
private activeDragTarget: DragTarget = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragGeometrySnapshot: VolumeProfileGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: VolumeProfilePaneView;
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,
series,
container,
interaction,
profileKind = 'fixedRange',
formatObservable,
removeSelf,
openSettings,
initialEvent,
}: VolumeProfileParams) {
super({ chart, series, container, interaction });
this.profileKind = profileKind;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
if (this.profileKind === 'visibleRange') {
this.mode = 'ready';
}
this.paneView = new VolumeProfilePaneView(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();
}),
);
}
if (this.profileKind === 'visibleRange') {
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
this.calculateProfile();
}
this.series.attachPrimitive(this);
if (initialEvent && initialEvent.sourceEvent) {
const point = this.getEventPoint(initialEvent.sourceEvent);
this.startDrawing(point);
}
}
public destroy(): void {
if (this.profileKind === 'visibleRange') {
this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this.handleVisibleLogicalRangeChange);
}
super.destroy();
}
public isCreationPending(): boolean {
if (this.profileKind === 'visibleRange') {
return false;
}
return this.mode === 'idle' || this.mode === 'drawing';
}
public shouldShowInObjectTree(): boolean {
if (this.profileKind === 'visibleRange') {
return true;
}
return super.shouldShowInObjectTree();
}
public getState(): VolumeProfileState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
visibleRangeStartRatio: this.visibleRangeStartRatio,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<VolumeProfileState>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
if (this.profileKind === 'fixedRange') {
this.mode = nextState.mode ?? this.mode;
this.startAnchor = nextState.startAnchor ?? this.startAnchor;
this.endAnchor = nextState.endAnchor ?? this.endAnchor;
}
if (this.profileKind === 'visibleRange') {
this.mode = 'ready';
this.resolveReady?.();
if (typeof nextState.visibleRangeStartRatio === 'number') {
this.visibleRangeStartRatio = clamp(nextState.visibleRangeStartRatio, 0, MAX_VISIBLE_RANGE_START_RATIO);
}
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.calculateProfile();
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getVolumeProfileSettingsTabs(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[] {
if (this.profileKind === 'visibleRange') {
return [];
}
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
if (this.profileKind === 'visibleRange') {
return [];
}
return [this.priceAxisPaneView];
}
public timeAxisViews() {
if (this.profileKind === 'visibleRange') {
return [];
}
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
if (this.profileKind === 'visibleRange') {
return [];
}
return [this.startPriceAxisView, this.endPriceAxisView];
}
public getRenderData(): VolumeProfileRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const { rows, pocY } = this.getProfileRenderRows(geometry);
return {
...geometry,
profileKind: this.profileKind,
rows,
pocY,
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<VolumeProfileHandleId>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
if (this.profileKind === 'visibleRange') {
const pocY = this.getPocY(geometry);
if (pocY === null) {
return [];
}
return [{ id: 'poc', x: geometry.left, y: pocY, shape: 'circle' }];
}
return [
{ id: 'end', ...geometry.endPoint, shape: 'circle' },
{ id: 'start', ...geometry.startPoint, shape: 'circle' },
];
}
protected getTimeAxisSegments(): AxisSegment[] {
if (this.profileKind === 'visibleRange' || (!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.profileKind === 'visibleRange' || (!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 getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const dragTarget = this.getDragTarget({ x, y });
if (!dragTarget) {
return null;
}
if (dragTarget === 'poc') {
return {
cursorStyle: 'ew-resize',
externalId: 'volume-profile',
zOrder: 'top',
};
}
return {
cursorStyle: this.isSelected() ? 'grab' : 'pointer',
externalId: 'volume-profile',
zOrder: 'top',
};
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (this.profileKind === 'visibleRange') {
return null;
}
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
return null;
}
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (this.profileKind === 'visibleRange') {
return null;
}
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
return null;
}
// const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
const price = kind === 'start' ? this.profileMinPrice : this.profileMaxPrice;
if (!price) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, price);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatPrice(price) ?? '',
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
private handleVisibleLogicalRangeChange = (): void => {
if (this.profileKind !== 'visibleRange') {
return;
}
this.calculateProfile();
this.render();
};
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready' || !this.isSelected()) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.getDragTarget(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.profileKind === 'visibleRange') {
this.handleVisibleRangePointerDown(event, point);
return;
}
this.handleFixedRangePointerDown(event, point);
};
protected handleVisibleRangePointerDown(event: PointerEvent, point: Point): void {
let dragTarget = this.getVisibleRangeDragTarget(point);
if (!dragTarget) {
if (this.isSelected()) {
this.deselect();
}
return;
}
event.preventDefault();
event.stopPropagation();
if (!this.isSelected()) {
this.select();
dragTarget = this.getVisibleRangeDragTarget(point);
}
if (dragTarget !== 'poc') {
return;
}
this.activeDragTarget = 'poc';
this.dragPointerId = event.pointerId;
this.mode = 'dragging';
this.hideCrosshair();
this.render();
}
private handleFixedRangePointerDown(event: PointerEvent, point: Point): void {
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 dragTarget = this.getFixedRangeDragTarget(point);
if (!this.isSelected()) {
if (!dragTarget) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
if (!dragTarget) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
}
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.profileKind === 'visibleRange') {
this.handleVisibleRangePointerMove(event, point);
return;
}
this.handleFixedRangePointerMove(event, point);
};
private handleVisibleRangePointerMove(event: PointerEvent, point: Point): void {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || this.activeDragTarget !== 'poc') {
return;
}
event.preventDefault();
this.moveVisibleRangeStart(point);
this.calculateProfile();
this.render();
}
private handleFixedRangePointerMove(event: PointerEvent, point: Point): void {
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (this.activeDragTarget === 'body') {
this.moveBody(point);
this.render();
return;
}
this.moveHandle(point);
this.render();
}
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.finishDragging();
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.mode = 'drawing';
this.calculateProfile();
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.endAnchor = anchor;
this.calculateProfile();
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (
this.profileKind === 'fixedRange' &&
(!geometry || geometry.width < MIN_PROFILE_SIZE || geometry.height < MIN_PROFILE_SIZE)
) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.showCrosshair();
this.render();
}
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<DragTarget, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragGeometrySnapshot = this.getGeometry();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private resetToIdle(): void {
this.hidden = false;
this.mode = 'idle';
this.startAnchor = null;
this.endAnchor = null;
this.profileRows = [];
this.profileMinPrice = null;
this.profileMaxPrice = null;
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private moveVisibleRangeStart(point: Point): void {
const { width } = this.getContainerSize();
if (width <= 0) {
return;
}
this.visibleRangeStartRatio = clamp(point.x / width, 0, MAX_VISIBLE_RANGE_START_RATIO);
}
private moveHandle(point: Point): void {
if (!this.activeDragTarget || this.activeDragTarget === 'body' || this.activeDragTarget === 'poc') {
return;
}
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
if (this.activeDragTarget === 'start') {
this.startAnchor = anchor;
}
if (this.activeDragTarget === 'end') {
this.endAnchor = anchor;
}
this.calculateProfile();
}
private moveBody(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const { dragStartPoint } = this;
if (!geometry || !dragStartPoint) {
return;
}
const { width, height } = this.getContainerSize();
const rawOffsetX = point.x - dragStartPoint.x;
const rawOffsetY = point.y - dragStartPoint.y;
const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);
this.setAnchorsFromPoints(
{
x: geometry.startPoint.x + offsetX,
y: geometry.startPoint.y + offsetY,
},
{
x: geometry.endPoint.x + offsetX,
y: geometry.endPoint.y + offsetY,
},
);
}
private setAnchorsFromPoints(startPoint: Point, endPoint: Point): void {
const startAnchor = this.createAnchor(this.clampPointToContainer(startPoint));
const endAnchor = this.createAnchor(this.clampPointToContainer(endPoint));
if (!startAnchor || !endAnchor) {
return;
}
this.startAnchor = startAnchor;
this.endAnchor = endAnchor;
this.calculateProfile();
}
private getDragTarget(point: Point): Exclude<DragTarget, null> | null {
if (this.profileKind === 'visibleRange') {
return this.getVisibleRangeDragTarget(point);
}
return this.getFixedRangeDragTarget(point);
}
private getVisibleRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
const handle = this.getDrawingHandleAtPoint(point);
if (handle?.id === 'poc') {
return 'poc';
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private getFixedRangeDragTarget(point: Point): Exclude<DragTarget, null> | null {
const handle = this.getDrawingHandleAtPoint(point);
if (handle) {
return handle.id;
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private calculateProfile(): void {
if (this.profileKind === 'visibleRange') {
this.calculateAnchoredVolumeProfile();
return;
}
this.calculateFixedRangeVolumeProfile();
}
private calculateAnchoredVolumeProfile(): void {
const visibleRange = this.chart.timeScale().getVisibleLogicalRange();
if (!visibleRange) {
this.clearProfile();
return;
}
const from = Number(visibleRange.from);
const to = Number(visibleRange.to);
const start = from + (to - from) * this.visibleRangeStartRatio;
this.calculateProfileByLogicalRange(start, to);
}
private calculateFixedRangeVolumeProfile(): void {
if (!this.startAnchor || !this.endAnchor) {
this.clearProfile();
return;
}
const leftFrameTime = Math.min(Number(this.startAnchor.time), Number(this.endAnchor.time));
const rightFrameTime = Math.max(Number(this.startAnchor.time), Number(this.endAnchor.time));
const candles = this.series.data() as SeriesCandleData[];
const selectedCandles: SeriesCandleData[] = [];
candles.forEach((candle) => {
const candleTime = Number(candle.time);
if (candleTime >= leftFrameTime && candleTime <= rightFrameTime) {
selectedCandles.push(candle);
}
});
this.calculateProfileByCandles(selectedCandles);
}
private calculateProfileByLogicalRange(fromLogical: number, toLogical: number): void {
const candles = this.series.data() as SeriesCandleData[];
if (!candles.length) {
this.clearProfile();
return;
}
const fromIndex = Math.max(0, Math.floor(Math.min(fromLogical, toLogical)));
const toIndex = Math.min(candles.length - 1, Math.ceil(Math.max(fromLogical, toLogical)));
if (fromIndex > toIndex) {
this.clearProfile();
return;
}
this.calculateProfileByCandles(candles.slice(fromIndex, toIndex + 1));
}
private calculateProfileByCandles(candles: SeriesCandleData[], fixedMinPrice?: number, fixedMaxPrice?: number): void {
if (!candles.length && (fixedMinPrice === undefined || fixedMaxPrice === undefined)) {
this.clearProfile();
return;
}
let minPrice = fixedMinPrice ?? Infinity;
let maxPrice = fixedMaxPrice ?? -Infinity;
if (fixedMinPrice === undefined || fixedMaxPrice === undefined) {
candles.forEach((candle) => {
const price = this.getCandlePrice(candle);
if (price === null) {
return;
}
minPrice = Math.min(minPrice, this.getCandleLow(candle, price));
maxPrice = Math.max(maxPrice, this.getCandleHigh(candle, price));
});
}
if (!Number.isFinite(minPrice) || !Number.isFinite(maxPrice)) {
this.clearProfile();
return;
}
if (minPrice === maxPrice) {
maxPrice = minPrice + Math.max(Math.abs(minPrice) * 0.001, 1);
}
this.profileMinPrice = minPrice;
this.profileMaxPrice = maxPrice;
const priceStep = (maxPrice - minPrice) / PROFILE_ROW_COUNT;
const profileRows = this.createEmptyProfileRows(minPrice, priceStep);
candles.forEach((candle) => {
const volume = this.getCandleVolume(candle);
if (volume <= 0) {
return;
}
const price = this.getCandlePrice(candle);
if (price === null) {
return;
}
const candleHigh = this.getCandleHigh(candle, price);
const candleLow = this.getCandleLow(candle, price);
if (candleHigh < minPrice || candleLow > maxPrice) {
return;
}
const isBuyVolume = this.isBuyVolume(candle);
if (candleHigh === candleLow) {
addPointVolume(profileRows, candleHigh, volume, isBuyVolume);
return;
}
const highInRange = Math.min(maxPrice, candleHigh);
const lowInRange = Math.max(minPrice, candleLow);
const candleRange = candleHigh - candleLow;
profileRows.forEach((row) => {
const overlap = Math.max(0, Math.min(row.priceHigh, highInRange) - Math.max(row.priceLow, lowInRange));
if (overlap <= 0) {
return;
}
addVolumeToRow(row, volume * (overlap / candleRange), isBuyVolume);
});
});
this.profileRows = profileRows;
}
private createEmptyProfileRows(minPrice: number, priceStep: number): VolumeProfileDataRow[] {
const rows: VolumeProfileDataRow[] = [];
for (let index = 0; index < PROFILE_ROW_COUNT; index += 1) {
rows.push({
priceLow: minPrice + priceStep * index,
priceHigh: minPrice + priceStep * (index + 1),
buyVolume: 0,
sellVolume: 0,
totalVolume: 0,
});
}
return rows;
}
private clearProfile(): void {
this.profileRows = [];
this.profileMinPrice = null;
this.profileMaxPrice = null;
}
private getCandleVolume(candle: SeriesCandleData): number {
return this.getNumber(candle.customValues?.volume) ?? this.getNumber(candle.volume) ?? 0;
}
private getCandlePrice(candle: SeriesCandleData): number | null {
return (
this.getNumber(candle.close) ??
this.getNumber(candle.customValues?.close) ??
this.getNumber(candle.value) ??
this.getNumber(candle.customValues?.value)
);
}
private getCandleOpen(candle: SeriesCandleData): number | null {
return this.getNumber(candle.open) ?? this.getNumber(candle.customValues?.open);
}
private getCandleClose(candle: SeriesCandleData): number | null {
return (
this.getNumber(candle.close) ??
this.getNumber(candle.customValues?.close) ??
this.getNumber(candle.value) ??
this.getNumber(candle.customValues?.value)
);
}
private getCandleHigh(candle: SeriesCandleData, fallbackPrice: number): number {
return this.getNumber(candle.high) ?? this.getNumber(candle.customValues?.high) ?? fallbackPrice;
}
private getCandleLow(candle: SeriesCandleData, fallbackPrice: number): number {
return this.getNumber(candle.low) ?? this.getNumber(candle.customValues?.low) ?? fallbackPrice;
}
private isBuyVolume(candle: SeriesCandleData): boolean {
const open = this.getCandleOpen(candle);
const close = this.getCandleClose(candle);
if (open === null || close === null) {
return true;
}
return close >= open;
}
private getNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
protected getGeometry(): VolumeProfileGeometry | null {
if (this.profileKind === 'visibleRange') {
return this.getAnchoredVolumeProfileGeometry();
}
return this.getFixedRangeVolumeProfileGeometry();
}
private getAnchoredVolumeProfileGeometry(): VolumeProfileGeometry | null {
if (this.profileMinPrice === null || this.profileMaxPrice === null) {
return null;
}
const { width, height } = this.getContainerSize();
const topCoordinate = getYCoordinateFromPrice(this.series, this.profileMaxPrice);
const bottomCoordinate = getYCoordinateFromPrice(this.series, this.profileMinPrice);
if (topCoordinate === null || bottomCoordinate === null) {
return null;
}
const left = clamp(Math.round(width * this.visibleRangeStartRatio), 0, width);
const right = width;
const top = clamp(Math.round(Math.min(Number(topCoordinate), Number(bottomCoordinate))), 0, height);
const bottom = clamp(Math.round(Math.max(Number(topCoordinate), Number(bottomCoordinate))), 0, height);
return {
startPoint: {
x: left,
y: top,
},
endPoint: {
x: right,
y: bottom,
},
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
};
}
private getFixedRangeVolumeProfileGeometry(): VolumeProfileGeometry | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
if (this.profileMinPrice === null || this.profileMaxPrice === null) {
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.profileMinPrice);
const endY = getYCoordinateFromPrice(this.series, this.profileMaxPrice);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const { width, height } = this.getContainerSize();
const startPoint = {
x: clamp(Math.round(Number(startX)), 0, width),
y: clamp(Math.round(Number(startY)), 0, height),
};
const endPoint = {
x: clamp(Math.round(Number(endX)), 0, width),
y: clamp(Math.round(Number(endY)), 0, height),
};
const left = Math.min(startPoint.x, endPoint.x);
const right = Math.max(startPoint.x, endPoint.x);
const top = Math.min(startPoint.y, endPoint.y);
const bottom = Math.max(startPoint.y, endPoint.y);
return {
startPoint,
endPoint,
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
};
}
private getPocY(geometry: VolumeProfileGeometry): number | null {
return this.getProfileRenderRows(geometry).pocY;
}
private getProfileRenderRows(geometry: VolumeProfileGeometry): {
rows: VolumeProfileRenderRow[];
pocY: number | null;
} {
if (!this.profileRows.length) {
return {
rows: [],
pocY: null,
};
}
const maxVolume = this.profileRows.reduce((max, row) => Math.max(max, row.totalVolume), 0);
if (maxVolume <= 0) {
return {
rows: [],
pocY: null,
};
}
let pocY: number | null = null;
let pocVolume = 0;
const rows = this.profileRows
.map((row) => {
const highY = getYCoordinateFromPrice(this.series, row.priceHigh);
const lowY = getYCoordinateFromPrice(this.series, row.priceLow);
if (highY === null || lowY === null) {
return null;
}
const top = clamp(Math.min(Number(highY), Number(lowY)), geometry.top, geometry.bottom);
const bottom = clamp(Math.max(Number(highY), Number(lowY)), geometry.top, geometry.bottom);
if (row.totalVolume > pocVolume) {
pocVolume = row.totalVolume;
pocY = (top + bottom) / 2;
}
return {
top,
height: Math.max(1, bottom - top),
buyWidth: (geometry.width * row.buyVolume) / maxVolume,
sellWidth: (geometry.width * row.sellVolume) / maxVolume,
};
})
.filter((row): row is VolumeProfileRenderRow => row !== null);
return {
rows,
pocY,
};
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
function addPointVolume(
profileRows: VolumeProfileDataRow[],
price: number,
volume: number,
isBuyVolume: boolean,
): void {
const row = profileRows.find((item, index) => {
const isLastRow = index === profileRows.length - 1;
return price >= item.priceLow && (price < item.priceHigh || isLastRow);
});
if (!row) {
return;
}
addVolumeToRow(row, volume, isBuyVolume);
}
function addVolumeToRow(row: VolumeProfileDataRow, volume: number, isBuyVolume: boolean): void {
if (isBuyVolume) {
row.buyVolume += volume;
} else {
row.sellVolume += volume;
}
row.totalVolume += volume;
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import type { VolumeProfile, VolumeProfileKind } from './volumeProfile';
const UI = {
rowGap: 1,
pocLineWidth: 2,
};
type DrawDirection = 'leftToRight' | 'rightToLeft';
export class VolumeProfilePaneRenderer implements IPrimitivePaneRenderer {
private readonly volumeProfile: VolumeProfile;
constructor(volumeProfile: VolumeProfile) {
this.volumeProfile = volumeProfile;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.volumeProfile.getRenderData();
if (!data) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const left = data.left * horizontalPixelRatio;
const right = data.right * horizontalPixelRatio;
const top = data.top * verticalPixelRatio;
const bottom = data.bottom * verticalPixelRatio;
context.save();
context.fillStyle = data.areaFillColor;
context.fillRect(left, top, right - left, bottom - top);
const direction: DrawDirection = data.profileKind === 'visibleRange' ? 'rightToLeft' : 'leftToRight';
const rowStartX = data.profileKind === 'visibleRange' ? right : left;
data.rows.forEach((row) => {
const rowTop = row.top * verticalPixelRatio;
const rowHeight = row.height * verticalPixelRatio;
const buyWidth = row.buyWidth * horizontalPixelRatio;
const sellWidth = row.sellWidth * horizontalPixelRatio;
drawVolumeRow(
context,
rowStartX,
rowTop,
rowHeight,
buyWidth,
sellWidth,
pixelRatio,
data.buyFillColor,
data.sellFillColor,
direction,
);
});
if (data.pocY !== null) {
const pocY = data.pocY * verticalPixelRatio;
drawPocLine(context, data.profileKind, left, right, pocY, pixelRatio, data.pocLineColor);
}
context.restore();
});
}
}
function drawVolumeRow(
context: CanvasRenderingContext2D,
startX: number,
top: number,
height: number,
buyWidth: number,
sellWidth: number,
pixelRatio: number,
buyFillColor: string,
sellFillColor: string,
direction: DrawDirection,
): void {
const safeHeight = Math.max(1, height - UI.rowGap * pixelRatio);
if (buyWidth <= 0 && sellWidth <= 0) {
return;
}
if (direction === 'rightToLeft') {
const buyLeft = startX - buyWidth;
const sellLeft = buyLeft - sellWidth;
if (sellWidth > 0) {
context.fillStyle = sellFillColor;
context.fillRect(sellLeft, top, sellWidth, safeHeight);
}
if (buyWidth > 0) {
context.fillStyle = buyFillColor;
context.fillRect(buyLeft, top, buyWidth, safeHeight);
}
return;
}
if (buyWidth > 0) {
context.fillStyle = buyFillColor;
context.fillRect(startX, top, buyWidth, safeHeight);
}
if (sellWidth > 0) {
context.fillStyle = sellFillColor;
context.fillRect(startX + buyWidth, top, sellWidth, safeHeight);
}
}
function drawPocLine(
context: CanvasRenderingContext2D,
profileKind: VolumeProfileKind,
left: number,
right: number,
y: number,
pixelRatio: number,
color: string,
): void {
const lineLeft = profileKind === 'visibleRange' ? 0 : left;
const lineRight = profileKind === 'visibleRange' ? context.canvas.width : right;
context.strokeStyle = color;
context.lineWidth = UI.pocLineWidth * pixelRatio;
context.beginPath();
context.moveTo(lineLeft, y);
context.lineTo(lineRight, y);
context.stroke();
}
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 { type Observable, Subscription } from 'rxjs';
import { DataSource } from '@core/DataSource';
import { DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { DrawingsManagerCollection } from '@core/DrawingsManagerCollection';
import { Pane, PaneParams } from '@core/Pane';
import { PriceAxisLabels } from '@core/PriceAxisLabels';
import { DrawingsNames } from '@src/constants';
import { ActiveDrawingTool, Direction } from '@src/types';
import { ISerializable, PaneSnapshot, PriceScaleSide, PriceScaleSnapshot } from '@src/types/snapshot';
import type { Indicator } from '@core/Indicator';
import type { IChartApi, LogicalRange, MouseEventParams } from 'lightweight-charts';
interface PaneManagerParams
extends Omit<
PaneParams,
'id' | 'isMainPane' | 'basedOn' | 'onDelete' | 'initialPriceScales' | CollectionDependencies
> {
panesSnapshot: PaneSnapshot[];
}
type CollectionDependencies =
| 'onPriceScaleStateChange'
| 'leftPriceScaleVisible'
| 'rightPriceScaleVisible'
| 'addDrawingManager'
| 'removeDrawingManager'
| 'setActiveTool'
| 'getActiveTool'
| 'getIsEndlessMode'
| 'continueDrawing';
interface PaneManagerStartParams {
compareEntities$: Observable<Indicator[]>;
indicatorEntities$: Observable<Indicator[]>;
}
type SharedPaneParams = Omit<PaneManagerParams, 'panesSnapshot'>;
// todo: PaneManager, регулирует порядок пейнов. Знает про MainPane.
// todo: Также перекинуть соответствующие/необходимые свойства из чарта, и из чарта удалить
// todo: в CompareManage, при создании нового пейна для сравнения - инициализируем новый dataSource, принадлежащий только конкретному пейну. Убираем возможность добавлять индикаторы на такие пейны
// todo: на каждый символ свой DataSource (учитывать что есть MainPane и "главный" DataSource, который инициализиурется во время старта moexChart)
// todo: сделать два разных представления для compare, в зависимости от отображения на главном пейне или на второстепенном
export class PaneManager implements ISerializable<PaneSnapshot[]> {
private readonly sharedPaneParams: SharedPaneParams;
private readonly lwcChart: IChartApi;
private readonly panesMap = new Map<number, Pane>();
private mainPane: Pane;
private nextPaneId: number;
private priceAxisLabels: PriceAxisLabels | null = null;
private leftPriceScaleVisible = false;
private rightPriceScaleVisible = true;
private drawingsManagerCollection: DrawingsManagerCollection;
private readonly subscriptions = new Subscription();
constructor({ panesSnapshot, ...sharedPaneParams }: PaneManagerParams) {
this.sharedPaneParams = {
...sharedPaneParams,
};
this.lwcChart = sharedPaneParams.lwcChart;
const mainPaneSnapshot = panesSnapshot.find((paneSnapshot) => paneSnapshot.isMain);
const mainPaneId = mainPaneSnapshot?.id ?? 0;
this.drawingsManagerCollection = new DrawingsManagerCollection({
hotkeys: sharedPaneParams.hotkeys,
});
this.mainPane = new Pane({
...this.sharedPaneParams,
...this.getCollectionDependencies(),
id: mainPaneId,
isMainPane: true,
onDelete: () => {},
initialPriceScales: mainPaneSnapshot?.priceScales, // todo: add to sharedParams?
});
this.panesMap.set(mainPaneId, this.mainPane);
if (mainPaneSnapshot) {
this.mainPane.setDrawingsSnapshot(mainPaneSnapshot.drawings);
}
const greatestPaneId = panesSnapshot.reduce(
(greatestId, paneSnapshot) => Math.max(greatestId, paneSnapshot.id),
mainPaneId,
);
this.nextPaneId = greatestPaneId + 1;
panesSnapshot.forEach((paneSnapshot) => {
if (paneSnapshot.isMain) {
return;
}
const pane = this.addPane(undefined, paneSnapshot.id, paneSnapshot.priceScales);
pane.setDrawingsSnapshot(paneSnapshot.drawings);
});
this.initClickListener();
this.syncPaneContainers();
}
public start({ compareEntities$, indicatorEntities$ }: PaneManagerStartParams): void {
this.priceAxisLabels?.destroy();
this.priceAxisLabels = new PriceAxisLabels({
mainSeries$: this.mainPane.getMainSerie().asObservable(),
mainSymbol$: this.sharedPaneParams.eventManager.symbol(),
compareEntities$,
indicatorEntities$,
});
}
public getDrawingsCollectionManager(): DrawingsManagerCollection {
return this.drawingsManagerCollection;
}
public setVisibleLogicalRange(logicalRange: LogicalRange | null): void {
this.priceAxisLabels?.setVisibleLogicalRange(logicalRange);
}
public invalidate(): void {
this.priceAxisLabels?.invalidate();
this.refreshPriceScaleControls();
}
public setPriceScaleSideVisible(side: PriceScaleSide, visible: boolean): void {
if (side === Direction.Left) {
this.leftPriceScaleVisible = visible;
} else {
this.rightPriceScaleVisible = visible;
}
this.panesMap.forEach((pane) => {
pane.getPriceScale(side).setVisible(visible);
});
}
public getPaneByIndex(index: number): Pane | undefined {
for (const pane of this.panesMap.values()) {
if (pane.paneIndex() === index) {
return pane;
}
}
return undefined;
}
public getPaneById(id: number): Pane | undefined {
return this.panesMap.get(id);
}
public getDrawingsSnapshot(): DrawingsManagerSnapshot {
return this.mainPane.getDrawingsSnapshot();
}
public setDrawingsSnapshot(snapshot: DrawingsManagerSnapshot): void {
this.mainPane.setDrawingsSnapshot(snapshot);
}
public getPanes(): Map<number, Pane> {
return this.panesMap;
}
public getMainPane = (): Pane => {
return this.mainPane;
};
public addPane(dataSource?: DataSource, paneId?: number, initialPriceScales?: PriceScaleSnapshot[]): Pane {
const id = paneId ?? this.nextPaneId++;
this.nextPaneId = Math.max(this.nextPaneId, id + 1);
const pane = new Pane({
...this.sharedPaneParams,
...this.getCollectionDependencies(),
id,
isMainPane: false,
dataSource: dataSource ?? null,
basedOn: dataSource ? undefined : this.mainPane,
onDelete: () => this.destroyPane(id),
initialPriceScales,
});
this.panesMap.set(id, pane);
this.syncPaneContainers();
this.priceAxisLabels?.invalidate();
return pane;
}
public resetPriceScalesAutoScale(): void {
this.panesMap.forEach((pane) => {
pane.resetPriceScalesAutoScale();
});
}
public getSnapshot(): PaneSnapshot[] {
const snapshot: PaneSnapshot[] = [];
this.panesMap.forEach((pane) => {
snapshot.push(pane.getSnapshot());
});
return snapshot;
}
public destroy(): void {
this.priceAxisLabels?.destroy();
this.priceAxisLabels = null;
this.subscriptions.unsubscribe();
this.panesMap.forEach((pane) => {
pane.destroy();
});
this.drawingsManagerCollection.destroy();
this.panesMap.clear();
}
private initClickListener(): void {
const handler = (param: MouseEventParams) => {
if (param.paneIndex === undefined) {
return;
}
const clickedPane = this.getPaneByIndex(param.paneIndex);
if (!clickedPane) {
return;
}
this.drawingsManagerCollection.handlePaneClick(clickedPane, param);
};
this.lwcChart.subscribeClick(handler);
this.subscriptions.add(() => this.lwcChart.unsubscribeClick(handler));
}
private refreshPriceScaleControls(): void {
this.panesMap.forEach((pane) => {
pane.refreshPriceScaleControls();
});
}
private destroyPane(id: number): void {
const pane = this.panesMap.get(id);
if (!pane) {
return;
}
const paneIndex = pane.paneIndex();
this.panesMap.delete(id);
pane.destroy();
if (paneIndex >= 0) {
this.sharedPaneParams.lwcChart.removePane(paneIndex);
}
this.syncPaneContainers();
this.priceAxisLabels?.invalidate();
}
private syncPaneContainers(): void {
this.panesMap.forEach((pane) => {
pane.schedulePaneContainerSync();
});
}
private getCollectionDependencies(): Pick<PaneParams, CollectionDependencies> {
return {
onPriceScaleStateChange: this.handlePriceScaleStateChange,
leftPriceScaleVisible: this.leftPriceScaleVisible,
rightPriceScaleVisible: this.rightPriceScaleVisible,
addDrawingManager: (manager, paneId) => this.drawingsManagerCollection.addDrawingManager(manager, paneId),
removeDrawingManager: (paneId: number) => this.drawingsManagerCollection.removeDrawingManager(paneId),
setActiveTool: (name: ActiveDrawingTool) => this.drawingsManagerCollection.setActiveTool(name),
getActiveTool: () => this.drawingsManagerCollection.getActiveToolValue(),
getIsEndlessMode: () => this.drawingsManagerCollection.getIsEndlessMode(),
continueDrawing: (name: DrawingsNames) => this.drawingsManagerCollection.activateDrawingTool(name),
};
}
private handlePriceScaleStateChange = (): void => {
this.priceAxisLabels?.invalidate();
};
}