Загрузка данных
import { Button } from 'exchange-elements/v2';
import { useEffect, useRef, useState } from 'react';
import { GearIcon, TrashIcon } from '@src/components/Icon';
import styles from './index.module.scss';
import type { Drawing } from '@core/Drawings';
import type { PointerEvent as ReactPointerEvent, SyntheticEvent } from 'react';
import type { Observable } from 'rxjs';
interface FloatingDrawingToolbarProps {
selectedDrawing$: Observable<Drawing | null>;
onOpenSettings: () => void;
onDelete: () => void;
}
interface Position {
x: number;
y: number;
}
interface DragState {
pointerId: number;
startX: number;
startY: number;
initialX: number;
initialY: number;
}
const INITIAL_POSITION: Position = {
x: 12,
y: 12,
};
export function FloatingDrawingToolbar({ selectedDrawing$, onOpenSettings, onDelete }: FloatingDrawingToolbarProps) {
const toolbarRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const [selectedDrawing, setSelectedDrawing] = useState<Drawing | null>(null);
const [position, setPosition] = useState<Position>(INITIAL_POSITION);
useEffect(() => {
const subscription = selectedDrawing$.subscribe(setSelectedDrawing);
return () => {
subscription.unsubscribe();
};
}, [selectedDrawing$]);
const clampPosition = (x: number, y: number): Position => {
const toolbar = toolbarRef.current;
const container = toolbar?.parentElement;
if (!toolbar || !container) {
return { x, y };
}
return {
x: Math.max(0, Math.min(x, container.clientWidth - toolbar.offsetWidth)),
y: Math.max(0, Math.min(y, container.clientHeight - toolbar.offsetHeight)),
};
};
const handleDragStart = (event: ReactPointerEvent<HTMLButtonElement>): void => {
if (event.button !== 0) {
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
initialX: position.x,
initialY: position.y,
};
};
const handleDrag = (event: ReactPointerEvent<HTMLButtonElement>): void => {
const dragState = dragStateRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
setPosition(
clampPosition(
dragState.initialX + event.clientX - dragState.startX,
dragState.initialY + event.clientY - dragState.startY,
),
);
};
const handleDragEnd = (event: ReactPointerEvent<HTMLButtonElement>): void => {
if (dragStateRef.current?.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
dragStateRef.current = null;
};
const stopPropagation = (event: SyntheticEvent): void => {
event.stopPropagation();
};
if (!selectedDrawing) {
return null;
}
return (
<div
ref={toolbarRef}
className={styles.toolbar}
style={{
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
}}
onClick={stopPropagation}
onContextMenu={stopPropagation}
onDoubleClick={stopPropagation}
onPointerCancel={stopPropagation}
onPointerDown={stopPropagation}
onPointerMove={stopPropagation}
onPointerUp={stopPropagation}
>
<Button
className={styles.toolbar_handle}
size="sm"
onPointerCancel={handleDragEnd}
onPointerDown={handleDragStart}
onPointerMove={handleDrag}
onPointerUp={handleDragEnd}
>
<span />
<span />
<span />
<span />
<span />
<span />
</Button>
{selectedDrawing.hasSettings() && (
<Button
size="sm"
className={styles.button}
onClick={onOpenSettings}
label={<GearIcon />}
/>
)}
<Button
size="sm"
className={styles.button}
onClick={onDelete}
label={<TrashIcon />}
/>
</div>
);
}
@use '../../theme/mixins' as m;
.toolbar {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
border-radius: var(--space-0250);
background: var(--neutral-0);
pointer-events: auto;
user-select: none;
touch-action: none;
white-space: nowrap;
&_handle {
display: grid;
grid-template-columns: repeat(2, 2px);
gap: 2px;
align-content: center;
justify-content: center;
align-self: stretch;
width: 24px;
padding: 0;
border: 0;
background: transparent;
cursor: grab;
touch-action: none;
&:active {
cursor: grabbing;
}
span {
width: 2px;
height: 2px;
border-radius: 50%;
background: var(--neutral-500);
}
}
.button {
@include m.buttonBase;
}
}
import { IChartApi, ISeriesApi, SeriesType } from 'lightweight-charts';
import { BehaviorSubject, Observable, Subscription } from 'rxjs';
import { EventManager } from '@core';
import { DOMModel } from '@core/DOMModel';
import { Drawing } from '@core/Drawings';
import { Hotkeys, Keys } from '@core/Hotkeys';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { drawingLabelById, drawingsMap, DrawingsNames } from '@src/constants';
import { ModalRenderer } from '@src/core/ModalRenderer';
import { SeriesStrategies } from '@src/modules/series-strategies/SeriesFactory';
import { ActiveDrawingTool } from '@src/types';
interface DrawingsManagerParams {
eventManager: EventManager;
mainSeries$: Observable<SeriesStrategies | null>;
lwcChart: IChartApi;
DOM: DOMModel;
container: HTMLElement;
modalRenderer: ModalRenderer;
paneId: number;
hotkeys: Hotkeys;
}
export interface DrawingSnapshotItem {
id: string;
drawingName: DrawingsNames;
state: unknown;
}
interface CreateDrawingOptions {
id?: string;
state?: unknown;
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 mainSeries: SeriesStrategies | null = null;
private subscriptions = new Subscription();
private drawings$ = new BehaviorSubject<Drawing[]>([]);
private selectedDrawing$ = new BehaviorSubject<Drawing | null>(null);
private activeTool$ = new BehaviorSubject<ActiveDrawingTool>('crosshair');
private endlessMode$ = new BehaviorSubject(false);
private recreateScheduled = false;
private pendingSnapshot: DrawingsManagerSnapshot | null = null;
private paneId: number;
private hotkeys: Hotkeys;
private copyPasteBuffer: DrawingSnapshotItem | null = null;
private escapeUnregisterHash: string | null = null;
constructor({
eventManager,
mainSeries$,
lwcChart,
DOM,
container,
modalRenderer,
paneId,
hotkeys,
}: DrawingsManagerParams) {
this.DOM = DOM;
this.eventManager = eventManager;
this.paneId = paneId;
this.lwcChart = lwcChart;
this.container = container;
this.modalRenderer = modalRenderer;
this.hotkeys = hotkeys;
this.subscriptions.add(
mainSeries$.subscribe((series) => {
if (!series) {
return;
}
this.mainSeries = series;
this.drawings$.value.forEach((drawing) => drawing.rebind(series));
if (this.pendingSnapshot) {
const snapshot = this.pendingSnapshot;
this.pendingSnapshot = null;
this.setSnapshot(snapshot);
}
}),
);
window.addEventListener('pointerup', this.handlePointerUp);
this.container.addEventListener('click', this.handleClick);
this.container.addEventListener('pointerdown', this.handlePointerDown);
// todo: implement ctrl+v
// hotkeys.register({
// keys: [Keys.control, Keys.v],
// callback: () => {
// const bufferWithAppliedPosition = {
// ...this.copyPasteBuffer,
// state: {
// ...this.copyPasteBuffer?.state,
// startAnchor: {
// price: 73.36210252637723,
// time: 1783679170
// }
// }
// }
//
// this.setSnapshot([
// ...this.getSnapshot(),
// bufferWithAppliedPosition
// ])
// // hotkeys.unregister({ // todo: unregister all else ctrl+c's
// // keys: [Keys.control, Keys.c]
// // })
// }
// })
}
private handlePointerDown = (): void => {
this.DOM.refreshEntities();
queueMicrotask(this.updateSelectedDrawing);
};
private handlePointerUp = (): void => {
this.DOM.refreshEntities();
this.updateActiveTool();
queueMicrotask(this.updateSelectedDrawing);
};
private handleClick = (): void => {
this.DOM.refreshEntities();
this.updateActiveTool();
this.updateSelectedDrawing();
};
private updateSelectedDrawing = (): void => {
const selectedDrawing =
this.drawings$.value.find((drawing) => drawing.isSelected() && !drawing.isCreationPending()) ?? null;
if (this.selectedDrawing$.value === selectedDrawing) {
return;
}
this.selectedDrawing$.next(selectedDrawing);
};
private updateActiveTool = (): void => {
const hasPendingDrawing = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (hasPendingDrawing) {
return;
}
const activeTool = this.activeTool$.value;
const isSingleInstanceTool = activeTool !== 'crosshair' && drawingsMap[activeTool]?.singleInstance;
if (activeTool !== 'crosshair' && this.endlessMode$.value && !isSingleInstanceTool) {
if (this.recreateScheduled) {
return;
}
this.recreateScheduled = true;
queueMicrotask(() => {
this.recreateScheduled = false;
const currentTool = this.activeTool$.value;
const hasPendingAfterTick = this.drawings$.value.some((drawing) => drawing.isCreationPending());
if (currentTool === 'crosshair') {
return;
}
if (!this.endlessMode$.value) {
return;
}
if (drawingsMap[currentTool]?.singleInstance) {
return;
}
if (hasPendingAfterTick) {
return;
}
this.createDrawing(currentTool);
});
return;
}
this.activeTool$.next('crosshair');
};
private removeDrawing = (id: string): void => {
const drawing = this.drawings$.value.find((item) => item.id === id);
if (!drawing) {
return;
}
this.removeDrawings([drawing]);
};
private removeDrawingsByName(name: DrawingsNames, shouldUpdateTool = true): void {
const drawingsToRemove = this.drawings$.value.filter((drawing) => drawing.getDrawingName() === name);
this.removeDrawings(drawingsToRemove, 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.updateSelectedDrawing();
this.DOM.refreshEntities();
}
public addDrawingForce = (name: DrawingsNames): Promise<void> => {
this.removePendingDrawings(false);
if (drawingsMap[name].singleInstance) {
this.removeDrawingsByName(name, false);
}
this.activeTool$.next(name);
const drawing = this.createDrawing(name);
this.DOM.refreshEntities();
return drawing.waitForCreation();
};
private createDrawing(name: DrawingsNames, options: CreateDrawingOptions = {}): Drawing {
if (!this.mainSeries) {
throw new Error('[Drawings] main series is not defined');
}
const { id, state, shouldUpdateDrawingsList = true } = options;
const config = drawingsMap[name];
const drawingId = id ?? crypto.randomUUID();
let createdDrawing: Drawing | null = null;
const construct = (chart: IChartApi, series: ISeriesApi<SeriesType>) =>
config.construct({
chart,
series,
eventManager: this.eventManager,
container: this.container,
removeSelf: () => this.removeDrawing(drawingId),
openSettings: () => {
if (createdDrawing) {
this.openSettings(createdDrawing);
}
},
});
const drawingFactory = (zIndex: number, moveUp: (id: string) => void, moveDown: (id: string) => void) =>
new Drawing({
lwcChart: this.lwcChart,
mainSeries: this.mainSeries as SeriesStrategies,
id: drawingId,
drawingName: name,
name: drawingLabelById()[name],
onDelete: this.removeDrawing,
zIndex,
moveDown,
moveUp,
construct,
paneId: this.paneId,
hotkeys: this.hotkeys,
setCopyPasteBuffer: (copyPasteBuffer) => {
this.copyPasteBuffer = copyPasteBuffer;
},
resetActiveTool: () => {
this.activeTool$.next('crosshair');
},
});
const entity = this.DOM.setEntity<Drawing>(drawingFactory);
createdDrawing = entity;
if (state !== undefined) {
entity.setState(state);
}
if (shouldUpdateDrawingsList) {
this.drawings$.next([...this.drawings$.value, entity]);
}
return entity;
}
public getSnapshot(): DrawingsManagerSnapshot {
return this.drawings$.value
.filter((drawing) => !drawing.isCreationPending())
.map((drawing) => ({
id: drawing.id,
drawingName: drawing.getDrawingName(),
state: drawing.getState(),
}));
}
public setSnapshot(snapshot: DrawingsManagerSnapshot): void {
if (!Array.isArray(snapshot)) {
return;
}
if (!this.mainSeries) {
this.pendingSnapshot = snapshot;
return;
}
this.removeDrawings(this.drawings$.value, false);
const restoredDrawings = snapshot.reduce<Drawing[]>((drawings, item) => {
if (!drawingsMap[item.drawingName]) {
return drawings;
}
drawings.push(
this.createDrawing(item.drawingName, {
id: item.id,
state: item.state,
shouldUpdateDrawingsList: false,
}),
);
return drawings;
}, []);
this.drawings$.next(restoredDrawings);
this.activeTool$.next('crosshair');
this.updateSelectedDrawing();
this.DOM.refreshEntities();
}
public setEndlessDrawingMode = (value: boolean): void => {
if (value) {
this.escapeUnregisterHash = this.hotkeys.register({
keys: [Keys.escape],
callback: () => {
this.setEndlessDrawingMode(false);
},
});
} else {
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
this.escapeUnregisterHash = null;
}
this.endlessMode$.next(value);
};
public isEndlessDrawingsMode(): Observable<boolean> {
return this.endlessMode$.asObservable();
}
public getActiveTool(): Observable<ActiveDrawingTool> {
return this.activeTool$.asObservable();
}
public activateCrosshair(): void {
this.removePendingDrawings(false);
this.activeTool$.next('crosshair');
this.DOM.refreshEntities();
}
public entities(): Observable<Drawing[]> {
return this.drawings$.asObservable();
}
public selectedDrawing(): Observable<Drawing | null> {
return this.selectedDrawing$.asObservable();
}
public 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);
}
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: () => drawing.updateSettings(settings),
},
);
};
public getDrawings(): Drawing[] {
return this.drawings$.value;
}
public hideAll(): void {
this.selectedDrawing$.next(null);
this.drawings$.value.forEach((drawing) => drawing.hide());
this.DOM.refreshEntities();
}
public destroy(): void {
this.hotkeys.unregister({
keys: [Keys.escape],
hash: this.escapeUnregisterHash,
});
window.removeEventListener('pointerup', this.handlePointerUp);
this.container.removeEventListener('click', this.handleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDown);
this.drawings$.value.forEach((drawing) => drawing.destroy());
this.subscriptions.unsubscribe();
this.drawings$.complete();
this.selectedDrawing$.complete();
this.activeTool$.complete();
this.endlessMode$.complete();
}
}
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
} from 'lightweight-charts';
import { BehaviorSubject, distinctUntilChanged, Subscription } from 'rxjs';
import { getPointerPoint as getPointerPointFromEvent } from '@core/Drawings/helpers';
import { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import { SettingsTab, SettingsValues } from '@src/types';
export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
show(): void;
hide(): void;
rebind(series: ISeriesApi<SeriesType>): void;
destroy(): void;
isCreationPending(): boolean;
waitTillReady(): Promise<void>;
shouldShowInObjectTree(): boolean;
getState(): unknown;
setState(state: unknown): void;
getSettings(): SettingsValues;
updateSettings(settings: SettingsValues): void;
getSettingsTabs(): SettingsTab[];
subscribeIsSelected(cb: (isSelected: boolean) => void): void;
isSelected(): boolean;
}
interface SeriesDrawingBaseParams {
container: HTMLElement;
chart: IChartApi;
series: SeriesApi;
}
export abstract class SeriesDrawingBase<TSettings extends SettingsValues = SettingsValues> implements ISeriesDrawing {
protected hidden = false;
protected chart: IChartApi;
protected series: SeriesApi;
protected subscriptions = new Subscription();
protected abstract mode: unknown;
protected abstract settings: TSettings;
protected readonly container: HTMLElement;
protected isActive: BehaviorSubject<boolean> = new BehaviorSubject(false);
protected readyPromise: null | Promise<void> = null;
protected resolveReady: null | (() => void) = null;
protected requestUpdate: (() => void) | null = null;
constructor({ chart, series, container }: SeriesDrawingBaseParams) {
this.chart = chart;
this.series = series;
this.container = container;
}
public subscribeIsSelected(cb: (isSelected: boolean) => void) {
return this.isActive.pipe(distinctUntilChanged()).subscribe(cb);
}
public isSelected(): boolean {
return this.isActive.value;
}
public show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.showCrosshair();
this.render();
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.render();
}
public destroy(): void {
this.showCrosshair();
this.unbindEvents();
this.subscriptions.unsubscribe();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.requestUpdate = null;
this.resolveReady?.();
}
public waitTillReady(): Promise<void> {
if (this.mode === 'ready') {
return Promise.resolve();
}
if (!this.readyPromise) {
this.readyPromise = new Promise((resolve) => {
this.resolveReady = resolve as () => void;
});
}
return this.readyPromise;
}
public shouldShowInObjectTree(): boolean {
return this.mode !== 'idle';
}
public getSettings(): SettingsValues {
return { ...this.settings };
}
public updateSettings(settings: SettingsValues): void {
this.settings = {
...this.settings,
...settings,
};
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.bindEvents();
}
public detached(): void {
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
protected render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
protected hideCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Hidden,
},
});
}
protected showCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Normal,
},
});
}
protected getEventPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
public abstract getRenderData(): any; // todo: make proper type // todo: добавить в интерфейс\точно ли паблик
public abstract hitTest(x: number, y: number): PrimitiveHoveredItem | null; // todo: добавить в интерфейс\точно ли паблик
public abstract getTimeAxisSegments(): AxisSegment[]; // todo: добавить в интерфейс\точно ли паблик
public abstract getPriceAxisSegments(): AxisSegment[]; // todo: добавить в интерфейс\точно ли паблик
public abstract getTimeAxisLabel(kind: string): AxisLabel | null; // todo: добавить в интерфейс\точно ли паблик
public abstract getPriceAxisLabel(kind: string): AxisLabel | null; // todo: добавить в интерфейс\точно ли паблик
protected abstract bindEvents(): void;
public abstract updateAllViews(): void;
protected abstract unbindEvents(): void;
protected abstract getGeometry(): any; // todo: make proper type // todo: добавить в интерфейс\точно ли паблик
public abstract getSettingsTabs(): SettingsTab[];
public abstract getState(): unknown;
public abstract isCreationPending(): boolean;
public abstract paneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisViews(): readonly ISeriesPrimitiveAxisView[];
public abstract setState(state: unknown): void;
public abstract timeAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract timeAxisViews(): readonly ISeriesPrimitiveAxisView[];
}
import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import { Observable } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
getAnchorFromPoint,
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { TrendLinePaneView } from './paneView';
import {
createDefaultSettings,
getTrendLineSettingsTabs,
TrendLineSettings,
TrendLineStyle,
TrendLineTextStyle,
} from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
type TrendLineMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';
interface TrendLineParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
interface TrendLineState {
hidden: boolean;
isActive: boolean;
mode: TrendLineMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
settings: TrendLineSettings;
}
interface TrendLineGeometry {
startPoint: Point;
endPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
export interface TrendLineRenderData extends TrendLineGeometry, TrendLineStyle, TrendLineTextStyle {
showHandles: boolean;
}
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
export class TrendLine extends SeriesDrawingBase<TrendLineSettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: TrendLineSettings = createDefaultSettings();
private isBound = false;
protected mode: TrendLineMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: TrendLineState | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: TrendLinePaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly startPriceAxisView: CustomPriceAxisView;
private readonly endPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ container, formatObservable, removeSelf, openSettings }: TrendLineParams,
) {
super({ chart, series, container });
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new TrendLinePaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'start',
});
this.endPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'end',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): TrendLineState {
return {
hidden: this.hidden,
isActive: this.isActive.value,
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
const nextState = state as Partial<TrendLineState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('isActive' in nextState && typeof nextState.isActive === 'boolean') {
this.isActive.next(nextState.isActive);
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ?? null;
}
if ('endAnchor' in nextState) {
this.endAnchor = nextState.endAnchor ?? null;
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getTrendLineSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.startPriceAxisView,
this.endPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [this.startPriceAxisView, this.endPriceAxisView];
}
public getRenderData(): TrendLineRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showHandles: this.isActive.value,
...this.settings,
};
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (this.getPointTarget(point)) {
return {
cursorStyle: 'move',
externalId: 'trend-line',
zOrder: 'top',
};
}
if (!this.isPointNearLine(point)) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'trend-line',
zOrder: 'top',
};
}
public getTimeAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.left,
to: geometry.right,
color: colors.axisMarkerAreaFill,
},
];
}
public getPriceAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.top,
to: geometry.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
public getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive.value || (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,
};
}
public getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive.value || (kind !== 'start' && kind !== 'end')) {
return null;
}
const coordinate = this.getPriceCoordinate(kind);
const text = this.getPriceText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
this.container.addEventListener('dblclick', this.handleDoubleClick);
this.container.addEventListener('pointerdown', this.handlePointerDown);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
}
protected unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
this.container.removeEventListener('dblclick', this.handleDoubleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDown);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
private handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return;
}
const rect = this.container.getBoundingClientRect();
const point = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
if (!this.getPointTarget(point) && !this.isPointNearLine(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
private handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
const pointTarget = this.getPointTarget(point);
if (!this.isActive.value) {
if (!pointTarget && !this.isPointNearLine(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive.next(true);
this.render();
return;
}
if (pointTarget === 'start') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-start', point, event.pointerId);
return;
}
if (pointTarget === 'end') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-end', point, event.pointerId);
return;
}
if (this.isPointNearLine(point)) {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-body', point, event.pointerId);
return;
}
this.isActive.next(false);
this.render();
};
private handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-end') {
event.preventDefault();
event.stopPropagation();
this.movePoint(point);
this.render();
return;
}
if (this.mode === 'dragging-body') {
event.preventDefault();
event.stopPropagation();
this.moveBody(point);
this.render();
}
};
private handlePointerUp = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-end' || this.mode === 'dragging-body') {
this.finishDragging();
}
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.isActive.next(true);
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.endAnchor = anchor;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry) {
return;
}
const lineSize = Math.hypot(
geometry.endPoint.x - geometry.startPoint.x,
geometry.endPoint.y - geometry.startPoint.y,
);
if (lineSize < MIN_LINE_SIZE) {
this.removeSelf?.();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(mode: TrendLineMode, point: Point, pointerId: number): void {
this.mode = mode;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.showCrosshair();
this.render();
}
private movePoint(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
if (this.mode === 'dragging-start') {
this.startAnchor = anchor;
}
if (this.mode === 'dragging-end') {
this.endAnchor = anchor;
}
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot?.startAnchor || !snapshot.endAnchor || !this.dragStartPoint) {
return;
}
const offsetX = point.x - this.dragStartPoint.x;
const priceOffset = getPriceDeltaFromCoordinates(this.series, this.dragStartPoint.y, point.y);
const nextStartTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX, this.series);
const nextEndTime = shiftTimeByPixels(this.chart, snapshot.endAnchor.time, offsetX, this.series);
if (nextStartTime === null || nextEndTime === null) {
return;
}
this.startAnchor = {
time: nextStartTime,
price: snapshot.startAnchor.price + priceOffset,
};
this.endAnchor = {
time: nextEndTime,
price: snapshot.endAnchor.price + priceOffset,
};
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): TrendLineGeometry | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endAnchor.time, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startAnchor.price);
const endY = getYCoordinateFromPrice(this.series, this.endAnchor.price);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const startPoint = {
x: Math.round(Number(startX)),
y: Math.round(Number(startY)),
};
const endPoint = {
x: Math.round(Number(endX)),
y: Math.round(Number(endY)),
};
return {
startPoint,
endPoint,
left: Math.min(startPoint.x, endPoint.x),
right: Math.max(startPoint.x, endPoint.x),
top: Math.min(startPoint.y, endPoint.y),
bottom: Math.max(startPoint.y, endPoint.y),
};
}
private getPointTarget(point: Point): 'start' | 'end' | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, 8)) {
return 'start';
}
if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, 8)) {
return 'end';
}
return null;
}
private isPointNearLine(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return this.getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE;
}
private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
if (dx === 0 && dy === 0) {
return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
}
const t = Math.max(
0,
Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
);
const projectionX = startPoint.x + t * dx;
const projectionY = startPoint.y + t * dy;
return Math.hypot(point.x - projectionX, point.y - projectionY);
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
return coordinate === null ? null : Number(coordinate);
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, anchor.price);
return coordinate === null ? null : Number(coordinate);
}
private getTimeText(kind: TimeLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return '';
}
return formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return '';
}
return formatPrice(anchor.price) ?? '';
}
}
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
export interface TrendLineStyle {
lineColor: string;
}
export interface TrendLineTextStyle {
fontSize: number;
text: string;
isBold: boolean;
isItalic: boolean;
textColor: string;
}
export type TrendLineSettings = TrendLineStyle & TrendLineTextStyle & SettingsValues;
export function createDefaultSettings(): TrendLineSettings {
const { colors } = getThemeStore();
return {
lineColor: colors.chartLineColor,
fontSize: 14,
text: '',
isBold: false,
isItalic: false,
textColor: colors.chartLineColor,
};
}
export function getTrendLineSettingsTabs(settings: TrendLineSettings): SettingsTab[] {
const styleFields: SettingField[] = [
{
key: 'lineColor',
label: t('Line color'),
type: 'color',
defaultValue: settings.lineColor,
},
];
const textFields: SettingField[] = [
{
key: 'fontSize',
label: t('Font size'),
type: 'number',
defaultValue: settings.fontSize,
min: 8,
max: 24,
},
{
key: 'text',
label: t('Text'),
type: 'textarea',
defaultValue: settings.text,
placeholder: t('Enter text'),
},
{
key: 'isBold',
label: t('Bold'),
type: 'boolean',
defaultValue: settings.isBold,
},
{
key: 'isItalic',
label: t('Italics'),
type: 'boolean',
defaultValue: settings.isItalic,
},
{
key: 'textColor',
label: t('Text color'),
type: 'color',
defaultValue: settings.textColor,
},
];
return [
{
key: 'style',
label: t('Style'),
fields: styleFields,
},
{
key: 'text',
label: t('Text'),
fields: textFields,
},
];
}
import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';
import { TrendLinePaneRenderer } from './paneRenderer';
import { TrendLine } from './trendLine';
export class TrendLinePaneView implements IPrimitivePaneView {
private readonly trendLine: TrendLine;
constructor(trendLine: TrendLine) {
this.trendLine = trendLine;
}
public update(): void {}
public renderer(): IPrimitivePaneRenderer {
return new TrendLinePaneRenderer(this.trendLine);
}
public zOrder(): PrimitivePaneViewZOrder {
return 'top';
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import { TrendLine } from './trendLine';
const UI = {
lineWidth: 2,
handleRadius: 5,
handleBorderWidth: 2,
textLineHeightMultiplier: 1.2,
textOffset: 4,
};
export class TrendLinePaneRenderer implements IPrimitivePaneRenderer {
private readonly trendLine: TrendLine;
constructor(trendLine: TrendLine) {
this.trendLine = trendLine;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.trendLine.getRenderData();
if (!data) {
return;
}
const { colors } = getThemeStore();
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const startX = data.startPoint.x * horizontalPixelRatio;
const startY = data.startPoint.y * verticalPixelRatio;
const endX = data.endPoint.x * horizontalPixelRatio;
const endY = data.endPoint.y * verticalPixelRatio;
context.save();
context.lineWidth = UI.lineWidth * pixelRatio;
context.strokeStyle = data.lineColor;
context.beginPath();
context.moveTo(startX, startY);
context.lineTo(endX, endY);
context.stroke();
if (data.text.trim()) {
drawTextAlongLine(context, {
startX,
startY,
endX,
endY,
text: data.text,
fontSize: data.fontSize,
isBold: data.isBold,
isItalic: data.isItalic,
textColor: data.textColor,
horizontalPixelRatio,
verticalPixelRatio,
});
}
if (data.showHandles) {
context.fillStyle = colors.chartBackground;
context.strokeStyle = colors.chartLineColor;
context.lineWidth = UI.handleBorderWidth * pixelRatio;
drawHandle(context, startX, startY, UI.handleRadius * pixelRatio);
drawHandle(context, endX, endY, UI.handleRadius * pixelRatio);
}
context.restore();
});
}
}
function drawHandle(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
context.stroke();
}
function drawTextAlongLine(
context: CanvasRenderingContext2D,
params: {
startX: number;
startY: number;
endX: number;
endY: number;
text: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
textColor: string;
horizontalPixelRatio: number;
verticalPixelRatio: number;
},
): void {
const { startX, startY, endX, endY, text, fontSize, isBold, isItalic, textColor, verticalPixelRatio } = params;
const lines = text.split('\n');
const safeFontSize = Math.max(1, fontSize);
const fontSizePx = safeFontSize * verticalPixelRatio;
const lineHeight = safeFontSize * UI.textLineHeightMultiplier * verticalPixelRatio;
const dx = endX - startX;
const dy = endY - startY;
let angle = Math.atan2(dy, dx);
if (angle > Math.PI / 2 || angle < -Math.PI / 2) {
angle += Math.PI;
}
const centerX = (startX + endX) / 2;
const centerY = (startY + endY) / 2;
const fontWeight = isBold ? '700 ' : '';
const fontStyle = isItalic ? 'italic ' : '';
context.save();
context.translate(centerX, centerY);
context.rotate(angle);
context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;
context.fillStyle = textColor;
context.textAlign = 'center';
context.textBaseline = 'middle';
const blockHeight = lines.length * lineHeight;
const textOffset = UI.textOffset * verticalPixelRatio;
const textCenterY = -(blockHeight / 2 + textOffset);
const startLineY = textCenterY - blockHeight / 2 + lineHeight / 2;
lines.forEach((line, index) => {
context.fillText(line, 0, startLineY + index * lineHeight);
});
context.restore();
}
import { Observable, skip } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
getPointerPoint as getPointerPointFromEvent,
getPriceDelta as getPriceDeltaFromCoordinates,
getPriceFromYCoordinate,
getPriceRangeInContainer,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
isPointInBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { Defaults } from '@src/types/defaults';
import { formatPercent, formatPrice, formatSignedNumber } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { SliderPaneView } from './paneView';
import {
createDefaultSettings,
getSliderPositionSettingsTabs,
SliderPositionSettings,
SliderPositionStyle,
SliderPositionTextStyle,
} from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, AxisSegment, Bounds, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type {
IChartApi,
IPrimitivePaneView,
MouseEventHandler,
MouseEventParams,
PrimitiveHoveredItem,
Time,
UTCTimestamp,
} from 'lightweight-charts';
type SliderSide = 'long' | 'short';
type SliderMode = 'idle' | 'ready' | 'dragging';
type DragTarget = 'body' | 'entry' | 'target' | 'stop' | 'end' | null;
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'target' | 'entry' | 'stop';
interface SliderPositionParams {
side: SliderSide;
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
resetTriggers?: Observable<unknown>[];
removeSelf?: () => void;
openSettings?: () => void;
}
interface SliderPositionState {
hidden: boolean;
active: boolean;
mode: SliderMode;
startTime: Time | null;
endTime: Time | null;
entryPrice: number | null;
stopPrice: number | null;
targetPrice: number | null;
riskRewardRatio: number;
amount: number;
tickSize: number;
settings: SliderPositionSettings;
}
interface SliderGeometry {
startX: number;
endX: number;
leftX: number;
rightX: number;
entryY: number;
stopY: number;
targetY: number;
entryPrice: number;
stopPrice: number;
targetPrice: number;
profitTop: number;
profitBottom: number;
lossTop: number;
lossBottom: number;
}
export interface SliderRenderData extends SliderGeometry, SliderPositionStyle, SliderPositionTextStyle {
targetText: string;
centerText: string;
stopText: string;
centerBoxColor: string;
showFill: boolean;
showHandles: boolean;
showLabels: boolean;
}
const HIT_TOLERANCE = 8;
const INITIAL_WIDTH_PX = 160;
const MIN_DISTANCE = 0.00000001;
export class SliderPosition extends SeriesDrawingBase<SliderPositionSettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: SliderPositionSettings = createDefaultSettings();
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
protected mode: SliderMode = 'idle';
private side: SliderSide;
private startTime: Time | null = null;
private endTime: Time | null = null;
private entryPrice: number | null = null;
private stopPrice: number | null = null;
private targetPrice: number | null = null;
private activeDragTarget: DragTarget = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: SliderPositionState | null = null;
private didDrag = false;
private defaultRiskRewardRatio = 1;
private amount = 1000;
private tickSize = 1;
private ignoreNextChartClick = false;
private isBound = false;
private readonly clickHandler: MouseEventHandler<Time>;
private readonly paneView: SliderPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly targetPriceAxisView: CustomPriceAxisView;
private readonly entryPriceAxisView: CustomPriceAxisView;
private readonly stopPriceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ side, container, formatObservable, resetTriggers = [], removeSelf, openSettings }: SliderPositionParams,
) {
super({ chart, series, container });
this.side = side;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.clickHandler = (params) => this.handleChartClick(params);
this.paneView = new SliderPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.targetPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'target',
});
this.entryPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'entry',
});
this.stopPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'stop',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
resetTriggers.forEach((trigger) => {
this.subscriptions.add(
trigger.pipe(skip(1)).subscribe(() => {
this.removeSelf?.();
}),
);
});
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public getState(): SliderPositionState {
return {
hidden: this.hidden,
active: this.isActive.value,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
entryPrice: this.entryPrice,
stopPrice: this.stopPrice,
targetPrice: this.targetPrice,
riskRewardRatio: this.getCurrentRiskRewardRatio(),
amount: this.amount,
tickSize: this.tickSize,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
const next = state as Partial<SliderPositionState>;
this.hidden = next.hidden ?? this.hidden;
if (typeof next.active === 'boolean') {
this.isActive.next(next.active);
}
this.mode = next.mode ?? this.mode;
this.startTime = next.startTime ?? this.startTime;
this.endTime = next.endTime ?? this.endTime;
this.entryPrice = next.entryPrice ?? this.entryPrice;
this.stopPrice = next.stopPrice ?? this.stopPrice;
this.amount = next.amount ?? this.amount;
if (typeof next.tickSize === 'number' && next.tickSize > 0) {
this.tickSize = next.tickSize;
}
if (next.targetPrice !== undefined) {
this.targetPrice = next.targetPrice;
} else if (
this.entryPrice !== null &&
this.stopPrice !== null &&
typeof next.riskRewardRatio === 'number' &&
next.riskRewardRatio > 0
) {
const risk = Math.abs(this.entryPrice - this.stopPrice);
this.targetPrice =
this.side === 'long'
? this.entryPrice + risk * next.riskRewardRatio
: this.entryPrice - risk * next.riskRewardRatio;
}
if ('settings' in next && next.settings) {
this.settings = {
...createDefaultSettings(),
...next.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getSliderPositionSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.targetPriceAxisView,
this.entryPriceAxisView,
this.stopPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [this.targetPriceAxisView, this.entryPriceAxisView, this.stopPriceAxisView];
}
public getRenderData(): SliderRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const reward = Math.abs(geometry.targetPrice - geometry.entryPrice);
const qty = reward > 0 ? this.amount / reward : 0;
const selectedPrice = this.getPriceAtTime(this.endTime);
const pnl =
selectedPrice === null
? 0
: this.side === 'long'
? (selectedPrice - geometry.entryPrice) * qty
: (geometry.entryPrice - selectedPrice) * qty;
const { colors } = getThemeStore();
return {
...geometry,
targetText: this.getTargetText(geometry),
centerText: this.getCenterText(qty, pnl),
stopText: this.getStopText(geometry),
centerBoxColor: pnl >= 0 ? colors.chartCandleUp : colors.chartCandleDown,
showFill: true,
showHandles: this.isActive.value,
showLabels: this.isActive.value,
...this.settings,
};
}
public getTimeAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
public getPriceAxisSegments(): AxisSegment[] {
if (!this.isActive.value) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.profitTop,
to: geometry.profitBottom,
color: colors.axisMarkerAreaFill,
},
{
from: geometry.lossTop,
to: geometry.lossBottom,
color: colors.axisMarkerAreaFill,
},
];
}
public getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive.value || (kind !== 'start' && kind !== 'end')) {
return null;
}
const labelKind = kind as TimeLabelKind;
const coordinate = this.getTimeCoordinate(labelKind);
const text = this.getTimeText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
public getPriceAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'target' && kind !== 'entry' && kind !== 'stop') {
return null;
}
const labelKind = kind as PriceLabelKind;
const coordinate = this.getPriceCoordinate(labelKind);
const text = this.getPriceText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
let backgroundColor = colors.axisMarkerLabelDefaultFill;
if (labelKind === 'target') {
backgroundColor = colors.axisMarkerLabelPositiveFill;
}
if (labelKind === 'stop') {
backgroundColor = colors.axisMarkerLabelNegativeFill;
}
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor,
};
}
public getTimeBounds(): { left: number; right: number } | null {
const start = this.getTimeCoordinate('start');
const end = this.getTimeCoordinate('end');
if (start === null || end === null) {
return null;
}
return {
left: Math.min(start, end),
right: Math.max(start, end),
};
}
public getTimeCoordinate(kind: TimeLabelKind): number | null {
const time = kind === 'start' ? this.startTime : this.endTime;
if (time === null) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, time, this.series);
return coordinate === null ? null : Number(coordinate);
}
public getTimeText(kind: TimeLabelKind): string {
const time = kind === 'start' ? this.startTime : this.endTime;
if (typeof time !== 'number') {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
public getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
switch (kind) {
case 'target':
return geometry.targetY;
case 'entry':
return geometry.entryY;
case 'stop':
return geometry.stopY;
default:
return null;
}
}
public getPriceText(kind: PriceLabelKind): string {
const geometry = this.getGeometry();
if (!geometry) {
return '';
}
switch (kind) {
case 'target':
return formatPrice(geometry.targetPrice) ?? '';
case 'entry':
return formatPrice(geometry.entryPrice) ?? '';
case 'stop':
return formatPrice(geometry.stopPrice) ?? '';
default:
return '';
}
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
const point = { x, y };
if (!this.isActive.value) {
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'pointer',
externalId: 'slider-position',
zOrder: 'top',
};
}
const dragTarget = this.getHandleTarget(point);
if (!dragTarget) {
return null;
}
let cursorStyle: PrimitiveHoveredItem['cursorStyle'] = 'grab';
if (dragTarget === 'target' || dragTarget === 'stop') {
cursorStyle = 'ns-resize';
}
if (dragTarget === 'end') {
cursorStyle = 'ew-resize';
}
return {
cursorStyle,
externalId: 'slider-position',
zOrder: 'top',
};
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
this.chart.subscribeClick(this.clickHandler);
this.container.addEventListener('dblclick', this.handleDoubleClick);
this.container.addEventListener('pointerdown', this.handlePointerDown);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
}
protected unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
this.chart.unsubscribeClick(this.clickHandler);
this.container.removeEventListener('dblclick', this.handleDoubleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDown);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
private handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready' || !this.isActive.value) {
return;
}
const point = this.getLocalPoint(event as PointerEvent);
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
private handleChartClick(params: MouseEventParams<Time>): void {
if (this.ignoreNextChartClick) {
this.ignoreNextChartClick = false;
return;
}
if (this.hidden || !params.point) {
return;
}
if (this.mode === 'idle') {
const anchor = this.createAnchor(params);
if (!anchor) {
return;
}
const distance = this.getInitialZoneDistance(anchor.price);
const stopDirection = this.side === 'long' ? -1 : 1;
const targetDirection = -stopDirection;
this.startTime = anchor.time;
this.endTime = this.shiftTime(anchor.time, INITIAL_WIDTH_PX) ?? anchor.time;
this.entryPrice = anchor.price;
this.stopPrice = this.normalizeStop(anchor.price, anchor.price + distance * stopDirection, distance);
this.targetPrice = this.normalizeTarget(
anchor.price,
anchor.price + distance * targetDirection * this.defaultRiskRewardRatio,
distance,
);
this.isActive.next(true);
this.mode = 'ready';
this.resolveReady?.();
this.render();
return;
}
this.isActive.next(this.containsPoint({ x: params.point.x, y: params.point.y }));
this.render();
}
private handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || this.mode !== 'ready' || !this.isActive.value) {
return;
}
const point = this.getLocalPoint(event);
const dragTarget = this.getHandleTarget(point);
if (!dragTarget) {
return;
}
event.preventDefault();
event.stopPropagation();
this.activeDragTarget = dragTarget;
this.dragPointerId = event.pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.didDrag = false;
this.mode = 'dragging';
};
private handlePointerMove = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId || !this.dragStateSnapshot) {
return;
}
event.preventDefault();
this.didDrag = true;
this.applyDrag(this.getLocalPoint(event));
this.render();
};
private handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.ignoreNextChartClick = this.didDrag;
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.didDrag = false;
this.mode = 'ready';
this.resolveReady?.();
this.render();
};
private applyDrag(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot) {
return;
}
switch (this.activeDragTarget) {
case 'body':
this.moveWhole(snapshot, point);
break;
case 'entry':
this.moveEntry(snapshot, point);
break;
case 'target':
this.moveTarget(snapshot, point);
break;
case 'stop':
this.moveStop(snapshot, point);
break;
case 'end':
this.resizeEnd(snapshot, point);
break;
default:
break;
}
}
private moveEntry(snapshot: SliderPositionState, point: Point): void {
if (snapshot.stopPrice === null || snapshot.targetPrice === null) {
return;
}
const nextPrice = getPriceFromYCoordinate(this.series, point.y);
if (nextPrice === null) {
return;
}
const minDistance = this.getEditMinimumDistance();
const low = Math.min(snapshot.stopPrice, snapshot.targetPrice) + minDistance;
const high = Math.max(snapshot.stopPrice, snapshot.targetPrice) - minDistance;
if (low > high) {
return;
}
this.entryPrice = Math.max(low, Math.min(nextPrice, high));
}
private moveWhole(snapshot: SliderPositionState, point: Point): void {
if (snapshot.startTime === null || snapshot.endTime === null) {
return;
}
if (snapshot.entryPrice === null || snapshot.stopPrice === null || snapshot.targetPrice === null) {
return;
}
if (!this.dragStartPoint) {
return;
}
const timeOffset = point.x - this.dragStartPoint.x;
const priceOffset = this.getPriceDelta(this.dragStartPoint.y, point.y);
const nextStartTime = this.shiftTime(snapshot.startTime, timeOffset);
const nextEndTime = this.shiftTime(snapshot.endTime, timeOffset);
if (nextStartTime === null || nextEndTime === null) {
return;
}
let nextEntryPrice = snapshot.entryPrice + priceOffset;
let nextStopPrice = snapshot.stopPrice + priceOffset;
let nextTargetPrice = snapshot.targetPrice + priceOffset;
const range = this.getPriceScaleRange();
if (range) {
const minValue = Math.min(nextEntryPrice, nextStopPrice, nextTargetPrice);
const maxValue = Math.max(nextEntryPrice, nextStopPrice, nextTargetPrice);
if (minValue < range.min) {
const shift = range.min - minValue;
nextEntryPrice += shift;
nextStopPrice += shift;
nextTargetPrice += shift;
}
if (maxValue > range.max) {
const shift = maxValue - range.max;
nextEntryPrice -= shift;
nextStopPrice -= shift;
nextTargetPrice -= shift;
}
}
this.startTime = nextStartTime;
this.endTime = nextEndTime;
this.entryPrice = nextEntryPrice;
this.stopPrice = nextStopPrice;
this.targetPrice = nextTargetPrice;
}
private moveStop(snapshot: SliderPositionState, point: Point): void {
if (snapshot.entryPrice === null) {
return;
}
const nextPrice = getPriceFromYCoordinate(this.series, point.y);
if (nextPrice === null) {
return;
}
this.stopPrice = this.normalizeStop(
snapshot.entryPrice,
this.clampPriceToRange(nextPrice),
this.getEditMinimumDistance(),
);
}
private moveTarget(snapshot: SliderPositionState, point: Point): void {
if (snapshot.entryPrice === null) {
return;
}
const nextPrice = getPriceFromYCoordinate(this.series, point.y);
if (nextPrice === null) {
return;
}
this.targetPrice = this.normalizeTarget(
snapshot.entryPrice,
this.clampPriceToRange(nextPrice),
this.getEditMinimumDistance(),
);
}
private resizeEnd(snapshot: SliderPositionState, point: Point): void {
const nextEndTime = getTimeFromXCoordinate(this.chart, point.x);
if (nextEndTime === null) {
return;
}
this.startTime = snapshot.startTime;
this.endTime = nextEndTime;
}
private createAnchor(params: MouseEventParams<Time>): { time: Time; price: number } | null {
if (!params.point || params.time === undefined) {
return null;
}
const price = getPriceFromYCoordinate(this.series, params.point.y);
if (price === null) {
return null;
}
return {
time: params.time,
price,
};
}
private normalizeStop(entryPrice: number, rawPrice: number, minDistance = this.getEditMinimumDistance()): number {
const distance = this.getAllowedMinimumDistance(entryPrice, 'stop', minDistance);
return this.side === 'long' ? Math.min(rawPrice, entryPrice - distance) : Math.max(rawPrice, entryPrice + distance);
}
private normalizeTarget(entryPrice: number, rawPrice: number, minDistance = this.getEditMinimumDistance()): number {
const distance = this.getAllowedMinimumDistance(entryPrice, 'target', minDistance);
return this.side === 'long' ? Math.max(rawPrice, entryPrice + distance) : Math.min(rawPrice, entryPrice - distance);
}
private getAllowedMinimumDistance(entryPrice: number, kind: 'stop' | 'target', minDistance: number): number {
const range = this.getPriceScaleRange();
if (!range) {
return minDistance;
}
const availableDistance =
this.side === 'long'
? kind === 'stop'
? Math.max(entryPrice - range.min, 0)
: Math.max(range.max - entryPrice, 0)
: kind === 'stop'
? Math.max(range.max - entryPrice, 0)
: Math.max(entryPrice - range.min, 0);
return Math.min(minDistance, availableDistance);
}
private clampPriceToRange(price: number): number {
const range = this.getPriceScaleRange();
if (!range) {
return price;
}
return Math.max(range.min, Math.min(price, range.max));
}
private getInitialZoneDistance(entryPrice: number): number {
const fallback = Math.max(Math.abs(entryPrice) * 0.0075, this.tickSize * 3, MIN_DISTANCE);
const range = this.getPriceScaleRange();
if (!range) {
return fallback;
}
const size = range.max - range.min;
if (size <= 0) {
return fallback;
}
return Math.max(size * 0.05, this.tickSize * 3, MIN_DISTANCE);
}
private getEditMinimumDistance(): number {
return Math.max(this.tickSize, MIN_DISTANCE);
}
private getPriceScaleRange(): { min: number; max: number } | null {
return getPriceRangeInContainer(this.series, this.container);
}
private getPriceDelta(fromY: number, toY: number): number {
return getPriceDeltaFromCoordinates(this.series, fromY, toY);
}
private shiftTime(time: Time, offsetX: number): Time | null {
return shiftTimeByPixels(this.chart, time, offsetX, this.series);
}
private getCurrentRiskRewardRatio(): number {
if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
return this.defaultRiskRewardRatio;
}
const risk = Math.abs(this.entryPrice - this.stopPrice);
if (!risk) {
return this.defaultRiskRewardRatio;
}
return Math.abs(this.targetPrice - this.entryPrice) / risk;
}
private getPriceAtTime(time: Time | null): number | null {
if (typeof time !== 'number') {
return null;
}
const data = this.series.data() ?? [];
let lastPrice: number | null = null;
for (const item of data) {
if (typeof item.time !== 'number') {
continue;
}
if (item.time > time) {
break;
}
if ('close' in item && typeof item.close === 'number') {
lastPrice = item.close;
continue;
}
if ('value' in item && typeof item.value === 'number') {
lastPrice = item.value;
}
}
return lastPrice;
}
protected getGeometry(): SliderGeometry | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
const entryY = getYCoordinateFromPrice(this.series, this.entryPrice);
const stopY = getYCoordinateFromPrice(this.series, this.stopPrice);
const targetY = getYCoordinateFromPrice(this.series, this.targetPrice);
if (startX === null || endX === null || entryY === null || stopY === null || targetY === null) {
return null;
}
const start = Number(startX);
const end = Number(endX);
const entry = Number(entryY);
const stop = Number(stopY);
const target = Number(targetY);
return {
startX: start,
endX: end,
leftX: Math.min(start, end),
rightX: Math.max(start, end),
entryY: entry,
stopY: stop,
targetY: target,
entryPrice: this.entryPrice,
stopPrice: this.stopPrice,
targetPrice: this.targetPrice,
profitTop: Math.min(target, entry),
profitBottom: Math.max(target, entry),
lossTop: Math.min(stop, entry),
lossBottom: Math.max(stop, entry),
};
}
private getTargetText(geometry: SliderGeometry): string {
const diff = Math.abs(geometry.targetPrice - geometry.entryPrice);
const percent = geometry.entryPrice !== 0 ? (diff / Math.abs(geometry.entryPrice)) * 100 : 0;
const ticks = this.tickSize > 0 ? diff / this.tickSize : 0;
const formattedDiff = formatPrice(diff) ?? '0';
const formattedTicks = formatPrice(ticks) ?? '0';
const formattedAmount = formatPrice(this.amount) ?? '0';
return `${t('Target')}: ${formattedDiff} (${formatPercent(percent)}) ${formattedTicks}, ${t('Amount')}: ${formattedAmount}`;
}
private getCenterText(qty: number, pnl: number): string {
const formattedQty = formatPrice(qty) ?? '0';
const formattedRatio = formatPrice(this.getCurrentRiskRewardRatio()) ?? '0';
return `${t('Open P&L')}: ${formatSignedNumber(pnl)}, ${t('Qty')}: ${formattedQty}\n${t('Risk/Reward Ratio')}: ${formattedRatio}`;
}
private getStopText(geometry: SliderGeometry): string {
const stopDiff = Math.abs(geometry.stopPrice - geometry.entryPrice);
const rewardDiff = Math.abs(geometry.targetPrice - geometry.entryPrice);
const percent = geometry.entryPrice !== 0 ? (stopDiff / Math.abs(geometry.entryPrice)) * 100 : 0;
const ticks = this.tickSize > 0 ? stopDiff / this.tickSize : 0;
const qty = rewardDiff > 0 ? this.amount / rewardDiff : 0;
const stopAmount = stopDiff * qty;
const formattedStopDiff = formatPrice(stopDiff) ?? '0';
const formattedTicks = formatPrice(ticks) ?? '0';
const formattedStopAmount = formatPrice(stopAmount) ?? '0';
return `${t('Stop')}: ${formattedStopDiff} (${formatPercent(percent)}) ${formattedTicks}, ${t('Amount')}: ${formattedStopAmount}`;
}
private getHandleTarget(point: Point): DragTarget {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.startX, geometry.entryY, HIT_TOLERANCE)) {
return 'entry';
}
if (isNearPoint(point, geometry.startX, geometry.targetY, HIT_TOLERANCE)) {
return 'target';
}
if (isNearPoint(point, geometry.startX, geometry.stopY, HIT_TOLERANCE)) {
return 'stop';
}
if (isNearPoint(point, geometry.endX, geometry.entryY, HIT_TOLERANCE)) {
return 'end';
}
const minY = Math.min(geometry.targetY, geometry.stopY);
const maxY = Math.max(geometry.targetY, geometry.stopY);
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
top: minY,
bottom: maxY,
};
if (isPointInBounds(point, bounds)) {
return 'body';
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
const minY = Math.min(geometry.targetY, geometry.stopY);
const maxY = Math.max(geometry.targetY, geometry.stopY);
const bounds: Bounds = {
left: geometry.leftX,
right: geometry.rightX,
top: minY,
bottom: maxY,
};
return isPointInBounds(point, bounds, HIT_TOLERANCE);
}
private getLocalPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
}
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
export interface SliderPositionStyle {
lineColor: string;
positiveFillColor: string;
negativeFillColor: string;
}
export interface SliderPositionTextStyle {
textColor: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
}
export type SliderPositionSettings = SettingsValues & SliderPositionStyle & SliderPositionTextStyle;
export function createDefaultSettings(): SliderPositionSettings {
const { colors } = getThemeStore();
return {
lineColor: colors.chartCrosshairLine,
positiveFillColor: colors.sliderPositiveFill,
negativeFillColor: colors.sliderNegativeFill,
textColor: colors.chartPriceLineText,
fontSize: 10,
isBold: false,
isItalic: false,
};
}
export function getSliderPositionSettingsTabs(settings: SliderPositionSettings): SettingsTab[] {
const styleFields: SettingField[] = [
{
key: 'lineColor',
label: t('Line color'),
type: 'color',
defaultValue: settings.lineColor,
},
{
key: 'positiveFillColor',
label: t('Profit zone color'),
type: 'color',
defaultValue: settings.positiveFillColor,
},
{
key: 'negativeFillColor',
label: t('Loss zone color'),
type: 'color',
defaultValue: settings.negativeFillColor,
},
];
const textFields: SettingField[] = [
{
key: 'fontSize',
label: t('Font size'),
type: 'number',
defaultValue: settings.fontSize,
min: 8,
max: 24,
},
{
key: 'isBold',
label: t('Bold'),
type: 'boolean',
defaultValue: settings.isBold,
},
{
key: 'isItalic',
label: t('Italics'),
type: 'boolean',
defaultValue: settings.isItalic,
},
{
key: 'textColor',
label: t('Text color'),
type: 'color',
defaultValue: settings.textColor,
},
];
return [
{
key: 'style',
label: t('Style'),
fields: styleFields,
},
{
key: 'text',
label: t('Text'),
fields: textFields,
},
];
}
import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';
import { SliderPaneRenderer } from './paneRenderer';
import type { SliderPosition } from './sliderPosition';
export class SliderPaneView implements IPrimitivePaneView {
private readonly slider: SliderPosition;
constructor(slider: SliderPosition) {
this.slider = slider;
}
public update(): void {}
public renderer(): IPrimitivePaneRenderer {
return new SliderPaneRenderer(this.slider);
}
public zOrder(): PrimitivePaneViewZOrder {
return 'top';
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import type { SliderPositionTextStyle } from './settings';
import type { SliderPosition } from './sliderPosition';
const UI = {
lineWidth: 1,
lineHeightMultiplier: 1.2,
mainBoxHeight: 28,
sideBoxHeight: 16,
padding: 4,
boxRadius: 4,
labelOffset: 10,
handleSize: 10,
handleRadius: 3,
handleBorderWidth: 1,
};
export class SliderPaneRenderer implements IPrimitivePaneRenderer {
private readonly slider: SliderPosition;
constructor(slider: SliderPosition) {
this.slider = slider;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.slider.getRenderData();
if (!data) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const startX = data.startX * horizontalPixelRatio;
const endX = data.endX * horizontalPixelRatio;
const left = data.leftX * horizontalPixelRatio;
const right = data.rightX * horizontalPixelRatio;
const entryY = data.entryY * verticalPixelRatio;
const stopY = data.stopY * verticalPixelRatio;
const targetY = data.targetY * verticalPixelRatio;
const profitTop = data.profitTop * verticalPixelRatio;
const profitBottom = data.profitBottom * verticalPixelRatio;
const lossTop = data.lossTop * verticalPixelRatio;
const lossBottom = data.lossBottom * verticalPixelRatio;
const centerX = (left + right) / 2;
const sideBoxHeightPx = UI.sideBoxHeight * verticalPixelRatio;
const mainBoxHeightPx = UI.mainBoxHeight * verticalPixelRatio;
const labelOffsetPx = UI.labelOffset * verticalPixelRatio;
const targetLabelCenterY =
targetY < entryY
? targetY - labelOffsetPx - sideBoxHeightPx / 2
: targetY + labelOffsetPx + sideBoxHeightPx / 2;
const stopLabelCenterY =
stopY < entryY ? stopY - labelOffsetPx - sideBoxHeightPx / 2 : stopY + labelOffsetPx + sideBoxHeightPx / 2;
context.save();
if (data.showFill) {
context.fillStyle = data.positiveFillColor;
context.fillRect(left, profitTop, right - left, profitBottom - profitTop);
context.fillStyle = data.negativeFillColor;
context.fillRect(left, lossTop, right - left, lossBottom - lossTop);
}
context.lineWidth = UI.lineWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.strokeStyle = data.lineColor;
drawHorizontalLine(context, left, right, entryY);
if (data.showHandles) {
drawHandle(context, startX, entryY, horizontalPixelRatio, verticalPixelRatio, 'circle');
drawHandle(context, endX, entryY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
drawHandle(context, startX, targetY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
drawHandle(context, startX, stopY, horizontalPixelRatio, verticalPixelRatio, 'rounded');
}
if (data.showLabels) {
drawTextBox(
context,
centerX,
targetLabelCenterY,
data.targetText,
data.positiveFillColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.sideBoxHeight,
);
drawTextBox(
context,
centerX,
entryY + labelOffsetPx + mainBoxHeightPx / 2,
data.centerText,
data.centerBoxColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.mainBoxHeight,
);
drawTextBox(
context,
centerX,
stopLabelCenterY,
data.stopText,
data.negativeFillColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.sideBoxHeight,
);
}
context.restore();
});
}
}
function drawHorizontalLine(context: CanvasRenderingContext2D, left: number, right: number, y: number): void {
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
}
function drawHandle(
context: CanvasRenderingContext2D,
x: number,
y: number,
horizontalPixelRatio: number,
verticalPixelRatio: number,
shape: 'circle' | 'rounded',
): void {
const width = UI.handleSize * horizontalPixelRatio;
const height = UI.handleSize * verticalPixelRatio;
const left = x - width / 2;
const top = y - height / 2;
const { colors } = getThemeStore();
context.save();
context.fillStyle = colors.chartBackground;
context.strokeStyle = colors.chartLineColor;
context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.beginPath();
if (shape === 'circle') {
context.arc(x, y, Math.min(width, height) / 2, 0, Math.PI * 2);
} else {
drawRoundedRect(
context,
left,
top,
width,
height,
UI.handleRadius * Math.max(horizontalPixelRatio, verticalPixelRatio),
);
}
context.fill();
context.stroke();
context.restore();
}
function drawTextBox(
context: CanvasRenderingContext2D,
centerX: number,
centerY: number,
text: string,
fillColor: string,
textStyle: SliderPositionTextStyle,
horizontalPixelRatio: number,
verticalPixelRatio: number,
fixedHeight: number,
): void {
context.save();
const lines = text.split('\n');
const fontSize = textStyle.fontSize * verticalPixelRatio;
const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
const paddingX = UI.padding * horizontalPixelRatio;
const paddingY = UI.padding * verticalPixelRatio;
const minBoxHeight = fixedHeight * verticalPixelRatio;
const boxHeight = Math.max(minBoxHeight, lines.length * lineHeight + paddingY * 2);
const radius = UI.boxRadius * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.font = getTextFont(textStyle, fontSize);
context.textAlign = 'center';
context.textBaseline = 'middle';
let maxTextWidth = 0;
for (const line of lines) {
maxTextWidth = Math.max(maxTextWidth, context.measureText(line).width);
}
const width = maxTextWidth + paddingX * 2;
const x = centerX - width / 2;
const y = centerY - boxHeight / 2;
context.fillStyle = fillColor;
context.beginPath();
drawRoundedRect(context, x, y, width, boxHeight, radius);
context.fill();
context.fillStyle = textStyle.textColor;
if (lines.length === 1) {
context.fillText(lines[0], centerX, centerY);
context.restore();
return;
}
const textBlockHeight = lines.length * lineHeight;
const firstLineCenterY = centerY - textBlockHeight / 2 + lineHeight / 2;
lines.forEach((line, index) => {
context.fillText(line, centerX, firstLineCenterY + index * lineHeight);
});
context.restore();
}
function getTextFont(style: SliderPositionTextStyle, fontSize: number): string {
const italic = style.isItalic ? 'italic ' : '';
const bold = style.isBold ? '700 ' : '';
return `${italic}${bold}${fontSize}px Inter, sans-serif`;
}
function drawRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
const safeRadius = Math.min(radius, width / 2, height / 2);
context.moveTo(x + safeRadius, y);
context.arcTo(x + width, y, x + width, y + height, safeRadius);
context.arcTo(x + width, y + height, x, y + height, safeRadius);
context.arcTo(x, y + height, x, y, safeRadius);
context.arcTo(x, y, x + width, y, safeRadius);
context.closePath();
}
import { PrimitiveHoveredItem } from 'lightweight-charts';
import { Observable, skip } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import { getPriceFromYCoordinate, getXCoordinateFromTime, getYCoordinateFromPrice } from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
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 { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type {
AutoscaleInfo,
Coordinate,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitiveAxisView,
Logical,
MouseEventHandler,
MouseEventParams,
SeriesAttachedParameter,
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 {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
resetTriggers?: Observable<unknown>[];
removeSelf?: () => void;
}
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 = {};
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
protected mode: RulerMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private readonly clickHandler: MouseEventHandler<Time>;
private readonly moveHandler: MouseEventHandler<Time>;
private isBound = false;
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: IChartApi,
series: SeriesApi,
{ resetTriggers = [], formatObservable, removeSelf, container }: RulerParams,
) {
super({ chart, series, container });
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);
}
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 getTimeAxisSegments(): AxisSegment[] {
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
public getPriceAxisSegments(): AxisSegment[] {
const bounds = this.getPriceBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.top,
to: bounds.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
public 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,
};
}
public 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,
};
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
return null;
}
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 {
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 {
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) {
return;
}
if (this.mode === 'ready') {
this.removeSelf?.();
return;
}
const anchor = this.createAnchor(params);
if (!anchor) {
return;
}
if (this.mode === 'idle') {
this.startAnchor = anchor;
this.endAnchor = anchor;
this.mode = 'placingEnd';
this.hideCrosshair();
this.render();
return;
}
if (this.mode === 'placingEnd') {
this.endAnchor = anchor;
this.mode = 'ready';
this.resolveReady?.();
this.showCrosshair();
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 { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';
import { RulerPaneRenderer } from './paneRenderer';
import type { Ruler } from './ruler';
export class RulerPaneView implements IPrimitivePaneView {
private readonly ruler: Ruler;
constructor(ruler: Ruler) {
this.ruler = ruler;
}
public update(): void {}
public renderer(): IPrimitivePaneRenderer {
return new RulerPaneRenderer(this.ruler);
}
public zOrder(): PrimitivePaneViewZOrder {
return 'top';
}
}
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();
}
import { DataSource } from '@core/DataSource';
import { DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { ChartSeriesType, DateFormat, IndicatorsIds, Intervals, Timeframes } from '@lib';
import { Direction } from '@src/types/chart';
import { IndicatorConfig } from '@src/types/indicator';
import { SymbolInfo, SymbolInfoInput } from '@src/types/symbol';
import { TimeFormat } from '@src/types/timeScale';
import type { PriceScaleMode } from 'lightweight-charts';
export type PriceScaleSide = Direction.Left | Direction.Right;
export interface ISerializable<T extends object> {
getSnapshot: () => T;
}
export interface InitialSnapshot extends SymbolInfoInput {
timeframe: Timeframes; // todo: move to snap
chartSeriesType: ChartSeriesType; // todo: move to snap
}
export interface MoexChartSnapshotInput {
// settings: ChartSettingsSnapshot;
charts: ChartSnapshotInput[];
}
export interface MoexChartSnapshot {
// settings: ChartSettingsSnapshot;
charts: ChartSnapshot[];
}
interface ChartSnapshotBase {
timeframe: Timeframes;
chartSeriesType: ChartSeriesType;
timeFormat?: TimeFormat;
dateFormat?: DateFormat;
interval?: Intervals | null;
panes: PaneSnapshot[];
}
export interface ChartSnapshotInput extends ChartSnapshotBase, SymbolInfoInput {}
export interface ChartSnapshot extends ChartSnapshotBase, SymbolInfo {}
export interface PriceScaleSnapshot {
side: PriceScaleSide;
mode: PriceScaleMode;
}
export interface PaneSnapshot {
isMain: boolean;
id: number;
indicators: IndicatorSnapshot[];
drawings: DrawingsManagerSnapshot;
priceScales?: PriceScaleSnapshot[];
}
export interface IndicatorSnapshot extends Partial<DOMObjectSnapshot> {
dataSource?: DataSource;
indicatorType: IndicatorsIds | undefined; // if indicatorType is undefined, then its compareIndicator
config?: IndicatorConfig;
}
// todo: move DrawingsManagerSnapshot here
export interface DOMObjectSnapshot {
id: string;
name: string;
zIndex: number;
hidden: boolean;
paneId: number;
}
export type SettingValue = string | number | boolean | any;
export type SettingsValues = Record<string, SettingValue>;
interface BaseSettingField {
key: string;
label: string;
}
export interface NumberSettingField extends BaseSettingField {
type: 'number';
defaultValue: number;
min?: number;
max?: number;
}
export interface RangeSettingField extends BaseSettingField {
type: 'range';
defaultValue: number;
min?: number;
max?: number;
step?: number;
color?: string;
suffix?: string;
}
export interface SelectSettingField extends BaseSettingField {
type: 'select';
defaultValue: string | number;
options: { label: string; value: string | number }[];
}
export interface ColorSettingField extends BaseSettingField {
type: 'color';
defaultValue: string;
}
export interface TextSettingField extends BaseSettingField {
type: 'text';
defaultValue: string;
placeholder?: string;
}
export interface TextAreaSettingField extends BaseSettingField {
type: 'textarea';
defaultValue: string;
placeholder?: string;
}
export interface BooleanSettingField extends BaseSettingField {
type: 'boolean';
defaultValue: boolean;
}
export type SettingField =
| NumberSettingField
| RangeSettingField
| SelectSettingField
| ColorSettingField
| TextSettingField
| TextAreaSettingField
| BooleanSettingField;
export interface SettingsTab<TField extends SettingField = SettingField> {
key: string;
label: string;
fields: TField[];
}
import { IChartApi, IPaneApi, PriceScaleMode, Time } from 'lightweight-charts';
import { BehaviorSubject, Subscription } from 'rxjs';
import { ChartTooltip } from '@components/ChartTooltip';
import { LegendComponent } from '@components/Legend';
import { ChartMouseEvents } from '@core/ChartMouseEvents';
import { ContainerManager } from '@core/ContainerManager';
import { DataSource } from '@core/DataSource';
import { DOMModel } from '@core/DOMModel';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { EventManager } from '@core/EventManager';
import { Hotkeys } from '@core/Hotkeys';
import { Indicator } from '@core/Indicator';
import { Legend } from '@core/Legend';
import { PriceScale, PriceScaleControls } from '@core/PriceScale';
import { ReactRenderer } from '@core/ReactRenderer';
import { TooltipService } from '@core/Tooltip';
import { UIRenderer } from '@core/UIRenderer';
import { EntitySettingsModal } from '@src/components/EntitySettingsModal';
import { FloatingDrawingToolbar } from '@src/components/FloatingToolbar';
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 { Direction, OHLCConfig, TooltipConfig } from '@src/types';
import {
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;
}
// 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
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 drawingToolbarContainer!: HTMLElement;
private drawingToolbarRenderer!: UIRenderer;
private readonly modalRenderer: ModalRenderer;
private readonly leftPriceScale: PriceScale;
private readonly rightPriceScale: PriceScale;
private readonly priceScaleControls: PriceScaleControls;
private mainSerieSub?: Subscription;
private readonly subscribeChartEvent: ChartMouseEvents['subscribe'];
private readonly onDelete: () => void;
private readonly onPriceScaleStateChange: () => void;
private readonly subscriptions = new Subscription();
private paneContainerSyncFrameId: number | null = null;
constructor({
lwcChart,
eventManager,
dataSource,
DOM,
isMainPane,
ohlcConfig,
id,
basedOn,
subscribeChartEvent,
tooltipConfig,
onDelete,
chartContainer,
modalRenderer,
initialPriceScales = [],
onPriceScaleStateChange,
leftPriceScaleVisible,
rightPriceScaleVisible,
hotkeys,
}: 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();
} else {
console.error('[Pane]: There is no any mainSerie for new pane');
}
this.drawingsManager = new DrawingsManager({
// todo: менеджер дровингов должен быть один на чарт, не на пейн
eventManager,
DOM,
mainSeries$: this.mainSeries.asObservable(),
lwcChart,
container: chartContainer,
modalRenderer: this.modalRenderer,
paneId: this.id,
hotkeys,
});
this.drawingToolbarRenderer = new ReactRenderer(this.drawingToolbarContainer);
this.drawingToolbarRenderer.renderComponent(
<FloatingDrawingToolbar
selectedDrawing$={this.drawingsManager.selectedDrawing()}
onOpenSettings={() => this.drawingsManager.openSelectedDrawingSettings()}
onDelete={() => this.drawingsManager.deleteSelectedDrawing()}
/>,
);
this.subscriptions.add(
this.drawingsManager.entities().subscribe((drawings) => {
const hasRuler = drawings.some((drawing) => drawing.getDrawingName() === DrawingsNames.ruler);
this.legendContainer.style.display = hasRuler ? 'none' : '';
}),
);
}
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 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)[] = [];
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.subscriptions.unsubscribe();
this.tooltip?.destroy();
this.legend?.destroy();
this.legendRenderer.destroy();
this.tooltipRenderer?.destroy();
this.priceScaleControls.destroy();
this.drawingToolbarRenderer.destroy();
this.legendContainer.remove();
this.paneOverlayContainer.remove();
this.drawingToolbarContainer.remove();
this.indicatorsMap.complete();
this.mainSerieSub?.unsubscribe();
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, drawingToolbarContainer } = ContainerManager.createPaneContainers();
this.legendContainer = legendContainer;
this.paneOverlayContainer = paneOverlayContainer;
this.drawingToolbarContainer = drawingToolbarContainer;
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.getSettings();
this.modalRenderer.renderComponent(
<EntitySettingsModal
tabs={[
{
key: 'arguments',
label: t('Arguments'),
fields: indicator.getSettingsConfig(),
},
]}
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 initializeMainSerie({ lwcChart, dataSource }: { lwcChart: IChartApi; dataSource: DataSource }): void {
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.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);
chartCell.appendChild(this.drawingToolbarContainer);
this.priceScaleControls.mount(lwcPaneElement);
}
}
import { DataSource } from '@core/DataSource';
import { DrawingsManager, DrawingsManagerSnapshot } from '@core/DrawingsManager';
import { Pane, PaneParams } from '@core/Pane';
import { PriceAxisLabels } from '@core/PriceAxisLabels';
import { Direction } from '@src/types';
import { ISerializable, PaneSnapshot, PriceScaleSide, PriceScaleSnapshot } from '@src/types/snapshot';
import type { Indicator } from '@core/Indicator';
import type { LogicalRange } from 'lightweight-charts';
import type { Observable } from 'rxjs';
interface PaneManagerParams
extends Omit<
PaneParams,
| 'id'
| 'isMainPane'
| 'basedOn'
| 'onDelete'
| 'initialPriceScales'
| 'onPriceScaleStateChange'
| 'leftPriceScaleVisible'
| 'rightPriceScaleVisible'
> {
panesSnapshot: PaneSnapshot[];
}
interface PriceAxisLabelsSources {
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 panesMap = new Map<number, Pane>();
private mainPane: Pane;
private nextPaneId: number;
private priceAxisLabels: PriceAxisLabels | null = null;
private leftPriceScaleVisible = false;
private rightPriceScaleVisible = true;
constructor({ panesSnapshot, ...sharedPaneParams }: PaneManagerParams) {
this.sharedPaneParams = sharedPaneParams;
const mainPaneSnapshot = panesSnapshot.find((paneSnapshot) => paneSnapshot.isMain);
const mainPaneId = mainPaneSnapshot?.id ?? 0;
this.mainPane = new Pane({
...this.sharedPaneParams,
id: mainPaneId,
isMainPane: true,
onDelete: () => {},
initialPriceScales: mainPaneSnapshot?.priceScales,
onPriceScaleStateChange: this.handlePriceScaleStateChange,
leftPriceScaleVisible: this.leftPriceScaleVisible,
rightPriceScaleVisible: this.rightPriceScaleVisible,
});
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.syncPaneContainers();
}
public start({ compareEntities$, indicatorEntities$ }: PriceAxisLabelsSources): void {
this.priceAxisLabels?.destroy();
this.priceAxisLabels = new PriceAxisLabels({
mainSeries$: this.mainPane.getMainSerie().asObservable(),
mainSymbol$: this.sharedPaneParams.eventManager.symbol(),
compareEntities$,
indicatorEntities$,
});
}
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 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,
id,
isMainPane: false,
dataSource: dataSource ?? null,
basedOn: dataSource ? undefined : this.mainPane,
onDelete: () => this.destroyPane(id),
initialPriceScales,
onPriceScaleStateChange: this.handlePriceScaleStateChange,
leftPriceScaleVisible: this.leftPriceScaleVisible,
rightPriceScaleVisible: this.rightPriceScaleVisible,
});
this.panesMap.set(id, pane);
this.syncPaneContainers();
this.priceAxisLabels?.invalidate();
return pane;
}
public resetPriceScalesAutoScale(): void {
this.panesMap.forEach((pane) => {
pane.resetPriceScalesAutoScale();
});
}
public getDrawingsManager(): DrawingsManager {
// todo: temp
return this.mainPane.getDrawingManager();
}
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.panesMap.forEach((pane) => {
pane.destroy();
});
this.panesMap.clear();
}
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 handlePriceScaleStateChange = (): void => {
this.priceAxisLabels?.invalidate();
};
}