Загрузка данных
import { BehaviorSubject, Observable } from 'rxjs';
import { DrawingsNames } from '@src/constants';
import { ActiveDrawingTool, SettingsValues } from '@src/types';
import { Drawing } from './Drawings';
import { DrawingsManager } from './DrawingsManager';
import { Hotkeys, Keys } from './Hotkeys';
import { PaneManager } from './PaneManager';
// todo: нужно дописывать класс)
export class DrawingsManagerCollection {
private managersMap: Map<number, DrawingsManager> = new Map();
private paneCollection: PaneManager;
private hotkeys: Hotkeys;
private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
private endlessMode$ = new BehaviorSubject(false);
private paneClickListenerUnsub = () => {};
private escapeUnregisterHash: string | null = null;
constructor({ paneCollection, hotkeys }: { paneCollection: PaneManager; hotkeys: Hotkeys }) {
this.paneCollection = paneCollection;
this.hotkeys = hotkeys;
}
public getIsEndlessMode(): boolean {
return this.endlessMode$.value;
}
public getActiveToolValue(): ActiveDrawingTool {
return this.activeTool$.value;
}
public setActiveTool(next: ActiveDrawingTool) {
this.activeTool$.next(next);
}
public removeDrawingManager(paneId: number) {
this.managersMap.delete(paneId);
}
public addDrawingManager(manager: DrawingsManager, paneId: number) {
this.managersMap.set(paneId, manager);
}
public addDrawingForce = async (name: DrawingsNames): Promise<void> => {
this.paneClickListenerUnsub();
this.setActiveTool(name);
this.paneClickListenerUnsub = this.paneCollection.listenPanesToAddDrawing(name);
};
public setEndlessDrawingMode = (value: boolean): void => {
if (value) {
this.escapeUnregisterHash = this.hotkeys.register({
keys: [Keys.escape],
callback: () => {
this.setEndlessDrawingMode(false);
},
});
} else {
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.escapeUnregisterHash = null;
}
this.endlessMode$.next(value);
};
public isEndlessDrawingsMode(): Observable<boolean> {
return this.endlessMode$.asObservable();
}
public activateCrosshair(): void {
this.paneClickListenerUnsub();
Array.from(this.managersMap.values()).forEach((manager) => {
manager.activateCrosshair();
});
}
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 => {
Array.from(this.managersMap.values()).forEach((manager) => {
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() {
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.activeTool$.complete();
this.paneClickListenerUnsub();
}
}
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';
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({
paneCollection: this,
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 listenPanesToAddDrawing(name: DrawingsNames): () => void {
const panes = Array.from(this.panesMap.values());
const unsub = () => {
panes.forEach((pane) => {
pane.unsubscribeInitialDrawingClick();
});
};
panes.forEach((pane) => {
pane.subscribeInitialDrawingClick(unsub, name);
});
return unsub;
}
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 {
return Array.from(this.panesMap.values()).find((pane) => pane.paneIndex() === index);
}
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);
clickedPane?.fireClick(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.subscriptions.unsubscribe();
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(),
};
}
private handlePriceScaleStateChange = (): void => {
this.priceAxisLabels?.invalidate();
};
}
import { IChartApi, IPaneApi, MouseEventParams, 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;
}
// 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;
private initialDrawingClickListener: (event: MouseEventParams) => void = () => {};
constructor({
lwcChart,
eventManager,
dataSource,
DOM,
isMainPane,
ohlcConfig,
id,
basedOn,
subscribeChartEvent,
tooltipConfig,
onDelete,
chartContainer,
modalRenderer,
initialPriceScales = [],
onPriceScaleStateChange,
leftPriceScaleVisible,
rightPriceScaleVisible,
hotkeys,
addDrawingManager,
removeDrawingManager,
setActiveTool,
getActiveTool,
getIsEndlessMode,
}: 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,
});
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 fireClick = (event: MouseEventParams) => {
this.initialDrawingClickListener(event);
};
public unsubscribeInitialDrawingClick = () => {
this.initialDrawingClickListener = () => {};
};
public subscribeInitialDrawingClick = (cb: () => void, name: DrawingsNames) => {
this.initialDrawingClickListener = (event: MouseEventParams) => {
this.drawingsManager.addDrawingForce(name, event);
cb();
};
};
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;
if (this.isMain && side === Direction.Right && 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 options = currentSeries.options();
const seriesPriceScaleSide = options.priceScaleId ?? Direction.Right;
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 { 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 { PrimitiveHoveredItem } from 'lightweight-charts';
import { Observable, skip } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
getAnchorFromPoint,
getPriceFromYCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
} 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 { ChartOptionsModel, Direction } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { SettingsTab, SettingsValues } from '@src/types/settings';
import { formatPrice, formatVolume } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { RulerPaneView } from './paneView';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type {
AutoscaleInfo,
Coordinate,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitiveAxisView,
Logical,
MouseEventHandler,
MouseEventParams,
SeriesOptionsMap,
Time,
UTCTimestamp,
} from 'lightweight-charts';
type SeriesApi = ISeriesApi<keyof SeriesOptionsMap, Time>;
type RulerMode = 'idle' | 'placingEnd' | 'ready';
interface RulerState {
hidden: boolean;
mode: RulerMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
}
interface RulerParams extends BaseDrawingParams {
resetTriggers?: Observable<unknown>[];
}
export interface RulerRenderData {
hidden: boolean;
startPoint: Point | null;
endPoint: Point | null;
lineColor: string;
fillColor: string;
textColor: string;
infoLines: string[];
horizontalArrowSide: Direction.Left | Direction.Right | null;
verticalArrowSide: Direction.Top | Direction.Bottom | null;
}
export class Ruler extends SeriesDrawingBase implements ISeriesDrawing {
private removeSelf?: () => void;
protected settings: SettingsValues = {};
protected mode: RulerMode = 'idle';
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private readonly clickHandler: MouseEventHandler<Time>;
private readonly moveHandler: MouseEventHandler<Time>;
private readonly paneView: RulerPaneView;
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,
resetTriggers = [],
formatObservable,
removeSelf,
container,
interaction,
initialEvent,
}: RulerParams) {
super({
chart,
series,
container,
interaction,
});
this.removeSelf = removeSelf;
this.clickHandler = (params) => this.handleClick(params);
this.moveHandler = (params) => this.handleMove(params);
this.paneView = new RulerPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (labelKind) => this.getTimeAxisLabel(labelKind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (labelKind) => this.getTimeAxisLabel(labelKind),
labelKind: 'end',
});
this.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (labelKind) => this.getPriceAxisLabel(labelKind),
labelKind: 'start',
});
this.endPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (labelKind) => this.getPriceAxisLabel(labelKind),
labelKind: 'end',
});
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 && initialEvent.sourceEvent) {
const point = this.getEventPoint(initialEvent.sourceEvent);
this.startDrawing(point);
}
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'placingEnd';
}
public getSettingsTabs(): SettingsTab[] {
return [];
}
public getState(): RulerState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
};
}
public setState(state: unknown): void {
const nextState = state as Partial<RulerState>;
this.hidden = nextState.hidden ?? this.hidden;
this.mode = nextState.mode ?? this.mode;
this.startAnchor = nextState.startAnchor ?? this.startAnchor;
this.endAnchor = nextState.endAnchor ?? this.endAnchor;
this.render();
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.startPriceAxisView,
this.endPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews(): readonly ISeriesPrimitiveAxisView[] {
return [this.startPriceAxisView, this.endPriceAxisView];
}
public autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
if (this.hidden || this.mode === 'placingEnd') {
return null;
}
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const startCoordinate = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
const endCoordinate = getXCoordinateFromTime(this.chart, this.endAnchor.time, this.series);
if (startCoordinate === null || endCoordinate === null) {
return null;
}
const startLogical = this.chart.timeScale().coordinateToLogical(startCoordinate as Coordinate);
const endLogical = this.chart.timeScale().coordinateToLogical(endCoordinate as Coordinate);
if (startLogical === null || endLogical === null) {
return null;
}
const leftLogical = Math.min(Number(startLogical), Number(endLogical));
const rightLogical = Math.max(Number(startLogical), Number(endLogical));
if (endTimePoint < leftLogical || startTimePoint > rightLogical) {
return null;
}
return {
priceRange: {
minValue: Math.min(Number(this.startAnchor.price), Number(this.endAnchor.price)),
maxValue: Math.max(Number(this.startAnchor.price), Number(this.endAnchor.price)),
},
};
}
public getRenderData(): RulerRenderData {
const startPoint = this.startAnchor ? this.getPoint(this.startAnchor) : null;
const endPoint = this.endAnchor ? this.getPoint(this.endAnchor) : null;
const startPrice = this.startAnchor ? Number(this.startAnchor.price) : 0;
const endPrice = this.endAnchor ? Number(this.endAnchor.price) : 0;
const priceDiff = endPrice - startPrice;
const percentDiff = startPrice !== 0 ? (priceDiff / startPrice) * 100 : null;
const startIndex = this.startAnchor ? this.findIndexByTime(this.startAnchor.time) : -1;
const endIndex = this.endAnchor ? this.findIndexByTime(this.endAnchor.time) : -1;
const barsCount = startIndex >= 0 && endIndex >= 0 ? Math.abs(endIndex - startIndex) : 0;
const volume = this.getVolumeInRange();
const isLong = priceDiff >= 0;
const horizontalArrowSide =
startPoint && endPoint && startPoint.x !== endPoint.x
? endPoint.x > startPoint.x
? Direction.Right
: Direction.Left
: null;
const verticalArrowSide =
startPoint && endPoint && startPoint.y !== endPoint.y
? endPoint.y > startPoint.y
? Direction.Bottom
: Direction.Top
: null;
const { colors } = getThemeStore();
return {
hidden: this.hidden,
startPoint,
endPoint,
lineColor: isLong ? colors.chartLineColor : colors.chartLineColorAlternative,
fillColor: isLong ? colors.rulerPositiveFill : colors.rulerNegativeFill,
textColor: colors.chartPriceLineText,
infoLines: [
`${formatPrice(Math.abs(priceDiff))} (${percentDiff === null ? '-' : `${formatPrice(Math.abs(percentDiff))}%`})`,
`${barsCount} ${t('bars')},`,
`${t('Vol')} ${formatVolume(volume)}`,
],
horizontalArrowSide,
verticalArrowSide,
};
}
public getTimeCoordinate(kind: 'start' | 'end'): Coordinate | null {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return null;
}
return getXCoordinateFromTime(this.chart, anchor.time, this.series);
}
public getPriceCoordinate(kind: 'start' | 'end'): Coordinate | null {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return null;
}
return getYCoordinateFromPrice(this.series, anchor.price);
}
public getTimeBounds(): { left: number; right: number } | null {
const startX = this.getTimeCoordinate('start');
const endX = this.getTimeCoordinate('end');
if (startX === null || endX === null) {
return null;
}
return {
left: Math.min(startX, endX),
right: Math.max(startX, endX),
};
}
public getPriceBounds(): { top: number; bottom: number } | null {
const startY = this.getPriceCoordinate('start');
const endY = this.getPriceCoordinate('end');
if (startY === null || endY === null) {
return null;
}
return {
top: Math.min(startY, endY),
bottom: Math.max(startY, endY),
};
}
public getTimeText(kind: 'start' | 'end'): string {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return '';
}
return formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
public getPriceText(kind: 'start' | 'end'): string {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return '';
}
return formatPrice(Number(anchor.price)) ?? '';
}
public shouldShowInObjectTree(): boolean {
return false;
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
return null;
}
protected getTimeAxisSegments(): AxisSegment[] {
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
const bounds = this.getPriceBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.top,
to: bounds.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'start' && kind !== 'end') {
return null;
}
const coordinate = this.getTimeCoordinate(kind);
const text = this.getTimeText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'start' && kind !== 'end') {
return null;
}
const coordinate = this.getPriceCoordinate(kind);
const text = this.getPriceText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
private findIndexByTime(time: Time): number {
const data = this.series.data() ?? [];
return data.findIndex((item) => {
if (typeof item.time === 'number' && typeof time === 'number') {
return item.time === time;
}
return false;
});
}
protected bindEvents(): void {
// todo: попробовать привести к виду базового класса
if (this.isBound) {
return;
}
this.isBound = true;
this.chart.subscribeClick(this.clickHandler);
this.chart.subscribeCrosshairMove(this.moveHandler);
}
protected getGeometry(): void {
console.log('stub');
}
protected unbindEvents(): void {
// todo: попробовать привести к виду базового класса
if (!this.isBound) {
return;
}
this.isBound = false;
this.chart.unsubscribeClick(this.clickHandler);
this.chart.unsubscribeCrosshairMove(this.moveHandler);
}
private handleClick(params: MouseEventParams<Time>): void {
if (this.hidden || !params.point || !params.sourceEvent) {
return;
}
if (this.mode === 'ready') {
this.removeSelf?.();
return;
}
if (!params.sourceEvent) {
return;
}
const anchor = this.createAnchor(params);
if (!anchor) {
return;
}
const point = this.getEventPoint(params.sourceEvent);
if (this.mode === 'idle') {
this.startDrawing(point);
return;
}
if (this.mode === 'placingEnd') {
this.endAnchor = anchor;
this.mode = 'ready';
this.resolveReady?.();
this.showCrosshair();
this.render();
}
}
private startDrawing(point: Point): void {
const anchor = getAnchorFromPoint(this.chart, this.series, point);
this.startAnchor = anchor;
this.endAnchor = anchor;
this.mode = 'placingEnd';
this.hideCrosshair();
this.render();
}
private handleMove(params: MouseEventParams<Time>): void {
if (this.hidden || !params.point) {
return;
}
if (this.mode !== 'placingEnd') {
return;
}
const anchor = this.createAnchor(params);
if (!anchor) {
return;
}
this.endAnchor = anchor;
this.render();
}
private createAnchor({ time, point }: MouseEventParams<Time>): Anchor | null {
if (!point || time === undefined) {
return null;
}
const price = getPriceFromYCoordinate(this.series, point.y);
if (price === null) {
return null;
}
return {
price,
time,
};
}
private getPoint(anchor: Anchor): Point | null {
const x = getXCoordinateFromTime(this.chart, anchor.time, this.series);
const y = getYCoordinateFromPrice(this.series, anchor.price);
if (x === null || y === null) {
return null;
}
return {
x,
y,
};
}
private getVolumeInRange(): number {
if (!this.startAnchor || !this.endAnchor) {
return 0;
}
const data = this.series.data() ?? [];
if (!data.length) {
return 0;
}
const startIndex = this.findIndexByTime(this.startAnchor.time);
const endIndex = this.findIndexByTime(this.endAnchor.time);
if (startIndex < 0 || endIndex < 0) {
return 0;
}
const from = Math.min(startIndex, endIndex);
const to = Math.max(startIndex, endIndex);
let volume = 0;
for (let index = from; index <= to; index += 1) {
const item = data[index] as unknown as Record<string, unknown> | undefined;
if (!item) {
continue;
}
if (typeof item.volume === 'number') {
volume += item.volume;
continue;
}
const customValues = item.customValues as Record<string, unknown> | undefined;
if (customValues && typeof customValues.volume === 'number') {
volume += customValues.volume;
}
}
return volume;
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { Direction } from '@src/types';
import type { Ruler } from './ruler';
import type { Bounds, Point } from '@core/Drawings/types';
const UI = {
lineWidth: 2,
arrowSize: 10,
infoFont: '12px Inter, sans-serif',
infoPadding: 4,
infoOffset: 8,
infoRadius: 2,
infoLineHeight: 14,
infoGap: 2,
};
export class RulerPaneRenderer implements IPrimitivePaneRenderer {
private readonly ruler: Ruler;
constructor(ruler: Ruler) {
this.ruler = ruler;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.ruler.getRenderData();
if (data.hidden || !data.startPoint || !data.endPoint) {
return;
}
const bounds = getBounds(data.startPoint, data.endPoint);
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const left = bounds.left * horizontalPixelRatio;
const right = bounds.right * horizontalPixelRatio;
const top = bounds.top * verticalPixelRatio;
const bottom = bounds.bottom * verticalPixelRatio;
const centerX = (left + right) / 2;
const centerY = (top + bottom) / 2;
context.save();
context.fillStyle = data.fillColor;
context.fillRect(left, top, right - left, bottom - top);
context.lineWidth = UI.lineWidth * pixelRatio;
context.strokeStyle = data.lineColor;
drawHorizontalArrow(context, left, right, centerY, UI.arrowSize * pixelRatio, data.horizontalArrowSide);
drawVerticalArrow(context, centerX, top, bottom, UI.arrowSize * pixelRatio, data.verticalArrowSide);
drawInfoBox(
context,
centerX,
top - UI.infoOffset * pixelRatio,
data.infoLines,
data.fillColor,
data.textColor,
pixelRatio,
verticalPixelRatio,
);
context.restore();
});
}
}
function getBounds(startPoint: Point, endPoint: Point): Bounds {
return {
left: Math.min(startPoint.x, endPoint.x),
right: Math.max(startPoint.x, endPoint.x),
top: Math.min(startPoint.y, endPoint.y),
bottom: Math.max(startPoint.y, endPoint.y),
};
}
function drawHorizontalArrow(
context: CanvasRenderingContext2D,
left: number,
right: number,
y: number,
size: number,
side: Direction.Left | Direction.Right | null,
): void {
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
if (!side) {
return;
}
context.beginPath();
if (side === Direction.Left) {
context.moveTo(left, y);
context.lineTo(left + size, y - size);
context.moveTo(left, y);
context.lineTo(left + size, y + size);
}
if (side === Direction.Right) {
context.moveTo(right, y);
context.lineTo(right - size, y - size);
context.moveTo(right, y);
context.lineTo(right - size, y + size);
}
context.stroke();
}
function drawVerticalArrow(
context: CanvasRenderingContext2D,
x: number,
top: number,
bottom: number,
size: number,
side: Direction.Top | Direction.Bottom | null,
): void {
context.beginPath();
context.moveTo(x, top);
context.lineTo(x, bottom);
context.stroke();
if (!side) {
return;
}
context.beginPath();
if (side === Direction.Top) {
context.moveTo(x, top);
context.lineTo(x - size, top + size);
context.moveTo(x, top);
context.lineTo(x + size, top + size);
}
if (side === Direction.Bottom) {
context.moveTo(x, bottom);
context.lineTo(x - size, bottom - size);
context.moveTo(x, bottom);
context.lineTo(x + size, bottom - size);
}
context.stroke();
}
function drawInfoBox(
context: CanvasRenderingContext2D,
centerX: number,
topY: number,
lines: readonly string[],
fillColor: string,
textColor: string,
pixelRatio: number,
verticalPixelRatio: number,
): void {
context.save();
context.font = UI.infoFont;
context.textAlign = 'center';
const padding = UI.infoPadding * pixelRatio;
const lineHeight = UI.infoLineHeight * verticalPixelRatio;
const gap = UI.infoGap * verticalPixelRatio;
let maxWidth = 0;
for (const line of lines) {
maxWidth = Math.max(maxWidth, context.measureText(line).width);
}
const boxWidth = maxWidth + padding * 2;
const boxHeight = lines.length * lineHeight + (lines.length - 1) * gap + padding * 2;
const boxX = centerX - boxWidth / 2;
const boxY = topY - boxHeight;
context.fillStyle = fillColor;
context.beginPath();
drawRoundedRect(context, boxX, boxY, boxWidth, boxHeight, UI.infoRadius * pixelRatio);
context.fill();
context.fillStyle = textColor;
let textY = boxY + padding + lineHeight * 0.8;
for (const line of lines) {
context.fillText(line, centerX, textY);
textY += lineHeight + gap;
}
context.restore();
}
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();
}