Загрузка данных
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
MouseEventParams,
PrimitiveHoveredItem,
PrimitivePaneViewZOrder,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
TouchMouseEventData,
} from 'lightweight-charts';
import { Observable, Subject, Subscription } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { DrawingHandlesPrimitive } from '@core/Drawings/handles';
import { getPointerPoint as getPointerPointFromEvent, getRawPointerPoint } from '@core/Drawings/helpers';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
export interface DrawingInteraction {
selected$: Observable<boolean>;
locked$: Observable<boolean>;
isSelected(): boolean;
isLocked(): boolean;
select(): void;
deselect(): void;
}
export interface ISeriesDrawing extends ISeriesPrimitive<Time> {
show(): void;
hide(): void;
rebind(series: ISeriesApi<SeriesType>): void;
destroy(): void;
waitTillReady(): Promise<void>;
isCreationPending(): boolean;
shouldShowInObjectTree(): boolean;
getState(): unknown;
setState(state: unknown): void;
getSettings(): SettingsValues;
getSettingsTabs(): SettingsTab[];
updateSettings(settings: SettingsValues): void;
subscribeSettings(callback: (settings: SettingsValues) => void): Subscription;
isHit(event: MouseEvent): boolean;
pointerDown(event: PointerEvent): void;
click(event: MouseEvent): void;
doubleClick(event: MouseEvent): void;
contextMenu(event: MouseEvent): void;
getRenderData(): unknown;
}
export type StartPoint = unknown;
interface DrawingBaseParams {
container: HTMLElement;
chart: IChartApi;
series: SeriesApi;
interaction: DrawingInteraction;
}
export interface BaseDrawingParams {
chart: IChartApi;
series: SeriesApi;
container: HTMLElement;
interaction: DrawingInteraction;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
initialEvent?: MouseEventParams;
}
export abstract class DrawingBase<
TSettings extends SettingsValues = SettingsValues,
THandleId extends string = string,
> implements ISeriesDrawing
{
protected hidden = false;
protected chart: IChartApi;
protected series: SeriesApi;
protected subscriptions = new Subscription();
protected abstract mode: unknown; // todo: хочется иметь единый mode
protected abstract settings: TSettings;
protected readonly container: HTMLElement;
protected isBound = false;
private readonly interaction: DrawingInteraction;
private readonly settingsSubject = new Subject<SettingsValues>();
private readonly handlesPrimitive: DrawingHandlesPrimitive<THandleId>;
private isInteractionBound = false;
protected readyPromise: Promise<void> | null = null;
protected resolveReady: (() => void) | null = null;
protected requestUpdate: (() => void) | null = null;
constructor({ chart, series, container, interaction }: DrawingBaseParams) {
this.chart = chart;
this.series = series;
this.container = container;
this.interaction = interaction;
this.handlesPrimitive = new DrawingHandlesPrimitive(() => {
if (this.hidden || !this.shouldShowHandles()) {
return [];
}
return this.getDrawingHandles();
});
}
public subscribeSettings(callback: (settings: SettingsValues) => void): Subscription {
callback(this.getSettings());
return this.settingsSubject.subscribe(callback);
}
public show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.showCrosshair();
this.render();
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.render();
}
public destroy(): void {
this.showCrosshair();
this.unbindEvents();
this.subscriptions.unsubscribe();
this.settingsSubject.complete();
this.series.detachPrimitive(this as unknown as ISeriesPrimitive<Time>);
this.requestUpdate = null;
this.resolveReady?.();
}
public waitTillReady(): Promise<void> {
if (this.mode === 'ready') {
return Promise.resolve();
}
if (!this.readyPromise) {
this.readyPromise = new Promise((resolve) => {
this.resolveReady = resolve;
});
}
return this.readyPromise;
}
public shouldShowInObjectTree(): boolean {
return this.mode !== 'idle';
}
public getSettings(): SettingsValues {
return { ...this.settings };
}
public updateSettings(settings: SettingsValues): void {
this.settings = {
...this.settings,
...settings,
};
this.settingsSubject.next(this.getSettings());
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.series.attachPrimitive(this.handlesPrimitive);
this.bindInteraction();
this.bindEvents();
}
public detached(): void {
this.series.detachPrimitive(this.handlesPrimitive);
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
const hoveredItem = this.getHoveredItem(x, y);
if (!hoveredItem || !this.isLocked()) {
return hoveredItem;
}
return {
...hoveredItem,
cursorStyle: 'pointer',
};
}
public isHit(event: MouseEvent): boolean {
if (!this.isEventInside(event)) {
return false;
}
const point = this.getEventPoint(event as PointerEvent);
return this.getHoveredItem(point.x, point.y) !== null;
}
public pointerDown(event: PointerEvent): void {
if (!this.isEventInside(event)) {
return;
}
if (!this.isLocked() || this.isCreationPending() || event.button !== 0) {
this.handlePointerDown(event);
return;
}
const point = this.getEventPoint(event);
if (this.getHoveredItem(point.x, point.y)) {
this.select();
return;
}
if (this.isSelected()) {
this.deselect();
}
}
public click(event: MouseEvent): void {
if (!this.isEventInside(event)) {
return;
}
this.handleClick(event);
}
public doubleClick(event: MouseEvent): void {
if (!this.isEventInside(event)) {
return;
}
this.handleDoubleClick(event);
}
public contextMenu(event: MouseEvent): void {
if (!this.isEventInside(event)) {
return;
}
this.handleContextMenu(event);
}
public abstract getRenderData(): unknown; // todo: make proper type
public abstract getState(): unknown;
public abstract getSettingsTabs(): SettingsTab[];
public abstract isCreationPending(): boolean;
public abstract setState(state: unknown): void;
public abstract updateAllViews(): void;
public abstract paneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract priceAxisViews(): readonly ISeriesPrimitiveAxisView[];
public abstract timeAxisPaneViews(): readonly IPrimitivePaneView[];
public abstract timeAxisViews(): readonly ISeriesPrimitiveAxisView[];
protected isSelected(): boolean {
return this.interaction.isSelected();
}
protected isLocked(): boolean {
return this.interaction.isLocked();
}
protected isAxisLabelAvailable(): boolean {
return !this.hidden && !this.isCreationPending();
}
protected shouldShowInteractiveAxis(): boolean {
return this.isSelected() || this.isCreationPending();
}
protected select(): void {
this.interaction.select();
}
protected deselect(): void {
this.interaction.deselect();
}
protected shouldShowHandles(): boolean {
return !this.isLocked() && (this.isSelected() || this.isCreationPending());
}
protected getDrawingHandles(): readonly DrawingHandle<THandleId>[] {
return [];
}
protected getDrawingHandleAtPoint(point: Point): DrawingHandle<THandleId> | null {
return this.handlesPrimitive.findHandle(point);
}
protected createTimeAxisView(labelKind: string): CustomTimeAxisView {
return new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind,
viewport: this.container,
});
}
protected createPriceAxisView(labelKind: string): CustomPriceAxisView {
return new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind,
viewport: this.container,
});
}
protected createTimeAxisPaneView(zOrder: PrimitivePaneViewZOrder = 'bottom'): CustomTimeAxisPaneView {
return new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
zOrder,
});
}
protected createPriceAxisPaneView(zOrder: PrimitivePaneViewZOrder = 'bottom'): CustomPriceAxisPaneView {
return new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
zOrder,
});
}
protected subscribeFormat(
formatObservable: Observable<ChartOptionsModel> | undefined,
callback: (format: ChartOptionsModel) => void,
): void {
if (!formatObservable) {
return;
}
this.subscriptions.add(
formatObservable.subscribe((format) => {
callback(format);
this.render();
}),
);
}
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 | TouchMouseEventData): Point {
return getPointerPointFromEvent(this.container, event);
}
protected bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
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;
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
// todo: хочется общую реализацию для каждой кнопки
protected handleClick(event: MouseEvent): void {}
protected handleContextMenu(event: MouseEvent): void {}
protected handleDoubleClick(event: MouseEvent): void {}
protected handlePointerMove(event: PointerEvent): void {}
protected handlePointerUp(event: PointerEvent): void {}
protected handlePointerDown(event: PointerEvent | TouchMouseEventData): void {}
protected abstract getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null;
protected abstract getGeometry(): unknown; // todo: make proper type
protected abstract getTimeAxisSegments(): AxisSegment[];
protected abstract getPriceAxisSegments(): AxisSegment[];
protected abstract getTimeAxisLabel(kind: string): AxisLabel | null;
protected abstract getPriceAxisLabel(kind: string): AxisLabel | null;
private bindInteraction(): void {
if (this.isInteractionBound) {
return;
}
this.isInteractionBound = true;
this.subscriptions.add(
this.interaction.selected$.subscribe(() => {
this.render();
}),
);
this.subscriptions.add(
this.interaction.locked$.subscribe((isLocked) => {
if (isLocked) {
this.showCrosshair();
}
this.render();
}),
);
}
private isEventInside(event: MouseEvent): boolean {
if (event.target instanceof Node && this.container.contains(event.target)) {
return true;
}
return this.handlesPrimitive.isTimeAxisHit(getRawPointerPoint(this.container, event));
}
}
import {
clampPointToContainer,
getAnchorFromPoint,
getContainerSize,
getPriceDelta,
getXCoordinateFromTime,
getYCoordinateFromPrice,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import type { Anchor, Bounds, ContainerSize, Point } from '@core/Drawings/types';
import type { SettingsValues } from '@src/types';
export type TwoPointAnchorKind = 'start' | 'end';
export interface TwoPointGeometry extends Bounds {
startPoint: Point;
endPoint: Point;
}
export abstract class TwoPointDrawingBase<
TSettings extends SettingsValues = SettingsValues,
THandleId extends string = string,
> extends DrawingBase<TSettings, THandleId> {
protected startAnchor: Anchor | null = null;
protected endAnchor: Anchor | null = null;
protected getAnchor(kind: TwoPointAnchorKind): Anchor | null {
return kind === 'start' ? this.startAnchor : this.endAnchor;
}
protected setAnchor(kind: TwoPointAnchorKind, anchor: Anchor | null): void {
if (kind === 'start') {
this.startAnchor = anchor;
return;
}
this.endAnchor = anchor;
}
protected setAnchors(startAnchor: Anchor | null, endAnchor: Anchor | null): void {
this.startAnchor = startAnchor;
this.endAnchor = endAnchor;
}
protected createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getPointFromAnchor(anchor: Anchor | null): Point | null {
if (!anchor) {
return 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: Math.round(Number(x)),
y: Math.round(Number(y)),
};
}
protected setAnchorFromPoint(kind: TwoPointAnchorKind, point: Point): boolean {
const anchor = this.createAnchor(point);
if (!anchor) {
return false;
}
this.setAnchor(kind, anchor);
return true;
}
protected setAnchorsFromPoints(startPoint: Point, endPoint: Point): boolean {
const startAnchor = this.createAnchor(startPoint);
const endAnchor = this.createAnchor(endPoint);
if (!startAnchor || !endAnchor) {
return false;
}
this.setAnchors(startAnchor, endAnchor);
return true;
}
protected getAnchorTimeCoordinate(kind: TwoPointAnchorKind): number | null {
const anchor = this.getAnchor(kind);
if (!anchor) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, anchor.time, this.series);
return coordinate === null ? null : Number(coordinate);
}
protected getAnchorPriceCoordinate(kind: TwoPointAnchorKind): number | null {
const anchor = this.getAnchor(kind);
if (!anchor) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, anchor.price);
return coordinate === null ? null : Number(coordinate);
}
protected moveAnchorsByPixels(
startAnchor: Anchor | null,
endAnchor: Anchor | null,
dragStartPoint: Point | null,
point: Point,
): boolean {
if (!startAnchor || !endAnchor || !dragStartPoint) {
return false;
}
const offsetX = point.x - dragStartPoint.x;
const priceOffset = getPriceDelta(this.series, dragStartPoint.y, point.y);
const nextStartTime = shiftTimeByPixels(this.chart, startAnchor.time, offsetX, this.series);
const nextEndTime = shiftTimeByPixels(this.chart, endAnchor.time, offsetX, this.series);
if (nextStartTime === null || nextEndTime === null) {
return false;
}
this.startAnchor = {
time: nextStartTime,
price: startAnchor.price + priceOffset,
};
this.endAnchor = {
time: nextEndTime,
price: endAnchor.price + priceOffset,
};
return true;
}
protected getContainerSize(): ContainerSize {
return getContainerSize(this.container);
}
protected clampPointToContainer(point: Point): Point {
return clampPointToContainer(point, this.container);
}
protected getTwoPointGeometry(): TwoPointGeometry | null {
const startPoint = this.getPointFromAnchor(this.startAnchor);
const endPoint = this.getPointFromAnchor(this.endAnchor);
if (!startPoint || !endPoint) {
return null;
}
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),
};
}
protected getGeometry(): TwoPointGeometry | null {
return this.getTwoPointGeometry();
}
}
import { TwoPointDrawingBase } from '@core/Drawings/TwoPointDrawingBase';
import { getDistanceToSegment } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisSegment, Point } from '@core/Drawings/types';
import type { SettingsValues } from '@src/types';
export abstract class LinearDrawingBase<
TSettings extends SettingsValues = SettingsValues,
THandleId extends string = string,
> extends TwoPointDrawingBase<TSettings, THandleId> {
protected abstract getStartHandleId(): THandleId;
protected abstract getEndHandleId(): THandleId;
protected getDrawingHandles(): readonly DrawingHandle<THandleId>[] {
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return [];
}
return [
{
id: this.getStartHandleId(),
...geometry.startPoint,
shape: 'circle',
},
{
id: this.getEndHandleId(),
...geometry.endPoint,
shape: 'circle',
},
];
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.left,
to: geometry.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.shouldShowInteractiveAxis()) {
return [];
}
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.top,
to: geometry.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected isPointNearLine(point: Point, tolerance: number): boolean {
const geometry = this.getTwoPointGeometry();
if (!geometry) {
return false;
}
return getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= tolerance;
}
}
import { IPrimitivePaneView, PrimitiveHoveredItem, UTCTimestamp } from 'lightweight-charts';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { LinearDrawingBase } from '@core/Drawings/LinearDrawingBase';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { type ChartOptionsModel, LineMarker, type SettingsTab } from '@src/types';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { LineDrawingPaneView } from './paneView';
import {
createDefaultSettings,
getLineDrawingSettingsTabs,
LineDrawingMarkers,
LineDrawingSettings,
LineDrawingStyle,
LineDrawingTextStyle,
} from './settings';
import type { Anchor, AxisLabel, Point } from '@core/Drawings/types';
import type { TwoPointGeometry } from '@core/Drawings/TwoPointDrawingBase';
import type { BaseDrawingParams, ISeriesDrawing } from '@core/Drawings/DrawingBase';
type LineDrawingMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
type LineDrawingHandleKey = 'start' | 'end';
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';
interface LineDrawingParams extends BaseDrawingParams {
defaultMarkers?: Partial<LineDrawingMarkers>;
}
interface LineDrawingState {
hidden: boolean;
mode: LineDrawingMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
settings: LineDrawingSettings;
}
export interface LineDrawingRenderData extends TwoPointGeometry, LineDrawingStyle, LineDrawingTextStyle {}
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
export class LineDrawing
extends LinearDrawingBase<LineDrawingSettings, LineDrawingHandleKey>
implements ISeriesDrawing
{
private removeSelf?: () => void;
private openSettings?: () => void;
private readonly defaultMarkers: LineDrawingMarkers;
protected settings: LineDrawingSettings;
protected mode: LineDrawingMode = 'idle';
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: LineDrawingState | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: LineDrawingPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly startPriceAxisView: CustomPriceAxisView;
private readonly endPriceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
container,
interaction,
formatObservable,
removeSelf,
openSettings,
initialEvent,
defaultMarkers = {},
}: LineDrawingParams) {
super({
chart,
series,
container,
interaction,
});
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.defaultMarkers = {
startMarker: LineMarker.normal,
endMarker: LineMarker.normal,
...defaultMarkers,
};
this.settings = createDefaultSettings(this.defaultMarkers);
this.paneView = new LineDrawingPaneView(this);
this.timeAxisPaneView = this.createTimeAxisPaneView();
this.priceAxisPaneView = this.createPriceAxisPaneView();
this.startTimeAxisView = this.createTimeAxisView('start');
this.endTimeAxisView = this.createTimeAxisView('end');
this.startPriceAxisView = this.createPriceAxisView('start');
this.endPriceAxisView = this.createPriceAxisView('end');
this.subscribeFormat(formatObservable, (format) => {
this.displayFormat = format;
});
this.series.attachPrimitive(this);
if (initialEvent?.sourceEvent) {
this.startDrawing(this.getEventPoint(initialEvent.sourceEvent));
}
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): LineDrawingState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<LineDrawingState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ?? null;
}
if ('endAnchor' in nextState) {
this.endAnchor = nextState.endAnchor ?? null;
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(this.defaultMarkers),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getLineDrawingSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.startPriceAxisView,
this.endPriceAxisView,
]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [this.startTimeAxisView, this.endTimeAxisView];
}
public priceAxisViews() {
return [this.startPriceAxisView, this.endPriceAxisView];
}
public getRenderData(): LineDrawingRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
...this.settings,
};
}
protected getStartHandleId(): LineDrawingHandleKey {
return 'start';
}
protected getEndHandleId(): LineDrawingHandleKey {
return 'end';
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (this.getDrawingHandleAtPoint(point)) {
return {
cursorStyle: 'move',
externalId: 'line-drawing',
zOrder: 'top',
};
}
if (!this.isPointNearLine(point, LINE_HIT_TOLERANCE)) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'line-drawing',
zOrder: 'top',
};
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowInteractiveAxis() || (kind !== 'start' && kind !== 'end')) {
return null;
}
const labelKind = kind as TimeLabelKind;
const coordinate = this.getAnchorTimeCoordinate(labelKind);
const text = this.getTimeText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.shouldShowInteractiveAxis() || (kind !== 'start' && kind !== 'end')) {
return null;
}
const labelKind = kind as PriceLabelKind;
const coordinate = this.getAnchorPriceCoordinate(labelKind);
const text = this.getPriceText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.getDrawingHandleAtPoint(point) && !this.isPointNearLine(point, LINE_HIT_TOLERANCE)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle' || this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
if (this.mode === 'idle') {
this.startDrawing(point);
} else {
this.updateDrawing(point);
this.finishDrawing();
}
return;
}
if (this.mode !== 'ready') {
return;
}
const pointTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
const isNearLine = this.isPointNearLine(point, LINE_HIT_TOLERANCE);
const isDrawingHit = pointTarget !== null || isNearLine;
const isSelected = this.isSelected();
if (!isDrawingHit) {
if (isSelected) {
this.deselect();
}
return;
}
event.preventDefault();
event.stopPropagation();
if (!isSelected) {
this.select();
return;
}
const dragMode: LineDrawingMode = `dragging-${pointTarget ?? 'body'}`;
this.startDragging(dragMode, point, event.pointerId);
};
protected handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-end') {
event.preventDefault();
event.stopPropagation();
this.movePoint(point);
this.render();
return;
}
if (this.mode === 'dragging-body') {
event.preventDefault();
event.stopPropagation();
this.moveBody(point);
this.render();
}
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-end' || this.mode === 'dragging-body') {
this.finishDragging();
}
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.setAnchors(anchor, anchor);
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
if (!this.setAnchorFromPoint('end', point)) {
return;
}
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry) {
return;
}
const lineSize = Math.hypot(
geometry.endPoint.x - geometry.startPoint.x,
geometry.endPoint.y - geometry.startPoint.y,
);
if (lineSize < MIN_LINE_SIZE) {
this.removeSelf?.();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(mode: LineDrawingMode, point: Point, pointerId: number): void {
this.mode = mode;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.showCrosshair();
this.render();
}
private movePoint(point: Point): void {
if (this.mode === 'dragging-start') {
this.setAnchorFromPoint('start', point);
return;
}
if (this.mode === 'dragging-end') {
this.setAnchorFromPoint('end', point);
}
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot) {
return;
}
this.moveAnchorsByPixels(snapshot.startAnchor, snapshot.endAnchor, this.dragStartPoint, point);
}
private getTimeText(kind: TimeLabelKind): string {
const anchor = this.getAnchor(kind);
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 = this.getAnchor(kind);
if (!anchor) {
return '';
}
return formatPrice(anchor.price) ?? '';
}
}