Загрузка данных
import { CrosshairMode } from 'lightweight-charts';
import { 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 { getThemeStore } from '@src/theme';
import type { DrawingHandle } from '@core/Drawings/handles';
import type {
AxisLabel,
AxisSegment,
Point,
SeriesApi,
} from '@core/Drawings/types';
import type {
AutoscaleInfo,
IChartApi,
IPrimitivePaneView,
ISeriesApi,
ISeriesPrimitive,
ISeriesPrimitiveAxisView,
Logical,
MouseEventParams,
PrimitiveHoveredItem,
PrimitivePaneViewZOrder,
SeriesAttachedParameter,
SeriesOptionsMap,
SeriesType,
Time,
TouchMouseEventData,
} from 'lightweight-charts';
import type { Observable } from 'rxjs';
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 createAxisLabel(
coordinate: number | null,
text: string,
style?: Partial<
Pick<
AxisLabel,
'textColor' | 'backgroundColor'
>
>,
): AxisLabel | null {
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor:
style?.textColor ??
colors.chartPriceLineText,
backgroundColor:
style?.backgroundColor ??
colors.axisMarkerLabelFill,
};
}
protected createAxisSegment(
from: number,
to: number,
color?: string,
): AxisSegment {
const { colors } = getThemeStore();
return {
from,
to,
color: color ?? colors.axisMarkerAreaFill,
};
}
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,
),
);
}
}
export {
DrawingBase as SeriesDrawingBase,
} from './DrawingBase';
export type {
BaseDrawingParams,
DrawingInteraction,
ISeriesDrawing,
StartPoint,
} from './DrawingBase';
import { clamp } from 'lodash-es';
import type {
Anchor,
Bounds,
ContainerSize,
Point,
SeriesApi,
} from './types';
import type {
Coordinate,
IChartApi,
Time,
TouchMouseEventData,
} from 'lightweight-charts';
interface SeriesTimeItem {
time: Time;
}
interface SeriesTimePoint {
time: Time;
timestamp: number;
}
export function getPriceFromYCoordinate(
series: SeriesApi,
yCoordinate: number,
): number | null {
return series.coordinateToPrice(
yCoordinate as Coordinate,
);
}
export function getYCoordinateFromPrice(
series: SeriesApi,
price: number,
): Coordinate | null {
return series.priceToCoordinate(price);
}
export function getTimeFromXCoordinate(
chart: IChartApi,
xCoordinate: number,
): Time | null {
return (
chart.timeScale().coordinateToTime(
xCoordinate as Coordinate,
) ?? null
);
}
export function getXCoordinateFromTime(
chart: IChartApi,
time: Time,
series?: SeriesApi,
): Coordinate | null {
const coordinate =
chart.timeScale().timeToCoordinate(time);
if (isValidCoordinate(coordinate)) {
return coordinate;
}
if (!series) {
return null;
}
return getProjectedXCoordinate(
chart,
series,
time,
);
}
export function getContainerSize(
container: HTMLElement,
): ContainerSize {
const rect = container.getBoundingClientRect();
return {
width: rect.width,
height: rect.height,
};
}
export function clampPointToContainer(
point: Point,
container: HTMLElement,
): Point {
const { width, height } =
getContainerSize(container);
return {
x: clamp(point.x, 0, width),
y: clamp(point.y, 0, height),
};
}
export function getRawPointerPoint(
container: HTMLElement,
event: MouseEvent | TouchMouseEventData,
): Point {
const rect = container.getBoundingClientRect();
return {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
}
export function getPointerPoint(
container: HTMLElement,
event: MouseEvent | TouchMouseEventData,
): Point {
return clampPointToContainer(
getRawPointerPoint(
container,
event,
),
container,
);
}
export function isNearPoint(
point: Point,
x: number,
y: number,
tolerance: number,
): boolean {
return (
Math.abs(point.x - x) <= tolerance &&
Math.abs(point.y - y) <= tolerance
);
}
export function isPointInBounds(
point: Point,
bounds: Bounds,
tolerance = 0,
): boolean {
return (
point.x >= bounds.left - tolerance &&
point.x <= bounds.right + tolerance &&
point.y >= bounds.top - tolerance &&
point.y <= bounds.bottom + tolerance
);
}
export function normalizeBounds(
left: number,
right: number,
top: number,
bottom: number,
container: HTMLElement,
): Bounds {
const { width, height } =
getContainerSize(container);
return {
left: clamp(
Math.min(left, right),
0,
width,
),
right: clamp(
Math.max(left, right),
0,
width,
),
top: clamp(
Math.min(top, bottom),
0,
height,
),
bottom: clamp(
Math.max(top, bottom),
0,
height,
),
};
}
export function shiftTimeByPixels(
chart: IChartApi,
time: Time,
offsetX: number,
series?: SeriesApi,
): Time | null {
const coordinate = getXCoordinateFromTime(
chart,
time,
series,
);
if (!isValidCoordinate(coordinate)) {
return null;
}
return getTimeFromXCoordinate(
chart,
Number(coordinate) + offsetX,
);
}
export function getPriceDelta(
series: SeriesApi,
fromY: number,
toY: number,
): number {
const fromPrice = getPriceFromYCoordinate(
series,
fromY,
);
const toPrice = getPriceFromYCoordinate(
series,
toY,
);
if (
fromPrice === null ||
toPrice === null
) {
return 0;
}
return toPrice - fromPrice;
}
export function getPriceRangeInContainer(
series: SeriesApi,
container: HTMLElement,
): {
min: number;
max: number;
} | null {
const { height } =
getContainerSize(container);
if (!height) {
return null;
}
const topPrice =
getPriceFromYCoordinate(series, 0);
const bottomPrice =
getPriceFromYCoordinate(
series,
height,
);
if (
topPrice === null ||
bottomPrice === null
) {
return null;
}
return {
min: Math.min(
topPrice,
bottomPrice,
),
max: Math.max(
topPrice,
bottomPrice,
),
};
}
export function getAnchorFromPoint(
chart: IChartApi,
series: SeriesApi,
point: Point,
): Anchor | null {
const time = getTimeFromXCoordinate(
chart,
point.x,
);
const price = getPriceFromYCoordinate(
series,
point.y,
);
if (
time === null ||
price === null
) {
return null;
}
return {
time,
price,
};
}
function getProjectedXCoordinate(
chart: IChartApi,
series: SeriesApi,
time: Time,
): Coordinate | null {
const targetTime =
getNumericTime(time);
if (targetTime === null) {
return null;
}
const points =
getSeriesTimePoints(series);
if (!points.length) {
return null;
}
if (points.length === 1) {
return getCoordinateForTime(
chart,
points[0].time,
);
}
const lastIndex =
points.length - 1;
if (
targetTime <=
points[0].timestamp
) {
return interpolateCoordinate(
chart,
targetTime,
points[0],
points[1],
);
}
if (
targetTime >=
points[lastIndex].timestamp
) {
return interpolateCoordinate(
chart,
targetTime,
points[lastIndex - 1],
points[lastIndex],
);
}
let left = 0;
let right = lastIndex;
while (left <= right) {
const middleIndex =
Math.floor(
(left + right) / 2,
);
const middlePoint =
points[middleIndex];
if (
middlePoint.timestamp ===
targetTime
) {
return getCoordinateForTime(
chart,
middlePoint.time,
);
}
if (
middlePoint.timestamp <
targetTime
) {
left = middleIndex + 1;
} else {
right = middleIndex - 1;
}
}
return interpolateCoordinate(
chart,
targetTime,
points[right],
points[left],
);
}
function interpolateCoordinate(
chart: IChartApi,
targetTime: number,
startPoint: SeriesTimePoint,
endPoint: SeriesTimePoint,
): Coordinate | null {
const startCoordinate =
getCoordinateForTime(
chart,
startPoint.time,
);
const endCoordinate =
getCoordinateForTime(
chart,
endPoint.time,
);
if (
startCoordinate === null ||
endCoordinate === null
) {
return null;
}
const timeRange =
endPoint.timestamp -
startPoint.timestamp;
if (timeRange === 0) {
return startCoordinate;
}
const ratio =
(targetTime -
startPoint.timestamp) /
timeRange;
const coordinate =
Number(startCoordinate) +
(Number(endCoordinate) -
Number(startCoordinate)) *
ratio;
if (!Number.isFinite(coordinate)) {
return null;
}
return coordinate as Coordinate;
}
function getCoordinateForTime(
chart: IChartApi,
time: Time,
): Coordinate | null {
const coordinate =
chart.timeScale().timeToCoordinate(time);
return isValidCoordinate(coordinate)
? coordinate
: null;
}
function getSeriesTimePoints(
series: SeriesApi,
): SeriesTimePoint[] {
const data =
series.data() as readonly SeriesTimeItem[];
return data.reduce<SeriesTimePoint[]>(
(points, item) => {
const timestamp =
getNumericTime(item.time);
if (timestamp === null) {
return points;
}
points.push({
time: item.time,
timestamp,
});
return points;
},
[],
);
}
function getNumericTime(
time: Time,
): number | null {
if (typeof time !== 'number') {
return null;
}
return Number.isFinite(time)
? time
: null;
}
function isValidCoordinate(
coordinate: Coordinate | null,
): coordinate is Coordinate {
return (
coordinate !== null &&
Number.isFinite(Number(coordinate))
);
}
import {
getAnchorFromPoint,
getPriceDelta,
getXCoordinateFromTime,
getYCoordinateFromPrice,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { DrawingBase } from '@core/Drawings/DrawingBase';
import type {
Anchor,
Bounds,
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: Number(x),
y: 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 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,
),
};
}
}
import { TwoPointDrawingBase } from '@core/Drawings/TwoPointDrawingBase';
import { getDistanceToSegment } from '@core/Drawings/utils';
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 getLineHandleStrokeColor():
string | undefined {
return undefined;
}
protected getDrawingHandles():
readonly DrawingHandle<THandleId>[] {
const geometry =
this.getTwoPointGeometry();
if (!geometry) {
return [];
}
const strokeColor =
this.getLineHandleStrokeColor();
const handleStyle =
strokeColor
? {
strokeColor,
}
: {};
return [
{
id: this.getStartHandleId(),
...geometry.startPoint,
shape: 'circle',
...handleStyle,
},
{
id: this.getEndHandleId(),
...geometry.endPoint,
shape: 'circle',
...handleStyle,
},
];
}
protected getTimeAxisSegments():
AxisSegment[] {
if (!this.shouldShowInteractiveAxis()) {
return [];
}
const geometry =
this.getTwoPointGeometry();
if (!geometry) {
return [];
}
return [
this.createAxisSegment(
geometry.left,
geometry.right,
),
];
}
protected getPriceAxisSegments():
AxisSegment[] {
if (!this.shouldShowInteractiveAxis()) {
return [];
}
const geometry =
this.getTwoPointGeometry();
if (!geometry) {
return [];
}
return [
this.createAxisSegment(
geometry.top,
geometry.bottom,
),
];
}
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 {
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,
} from './settings';
import type {
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 getGeometry():
TwoPointGeometry | null {
return this.getTwoPointGeometry();
}
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;
return this.createAxisLabel(
this.getAnchorTimeCoordinate(
labelKind,
),
this.getTimeText(
labelKind,
),
);
}
protected getPriceAxisLabel(
kind: string,
): AxisLabel | null {
if (
!this.shouldShowInteractiveAxis() ||
(kind !== 'start' &&
kind !== 'end')
) {
return null;
}
const labelKind =
kind as PriceLabelKind;
return this.createAxisLabel(
this.getAnchorPriceCoordinate(
labelKind,
),
this.getPriceText(
labelKind,
),
);
}
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,
) ?? ''
);
}
}
import {
IPrimitivePaneView,
PrimitiveHoveredItem,
UTCTimestamp,
} from 'lightweight-charts';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import { LinearDrawingBase } from '@core/Drawings/LinearDrawingBase';
import {
getDistanceToSegment,
updateViews,
} from '@core/Drawings/utils';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { RayPaneView } from './paneView';
import {
createDefaultSettings,
getRaySettingTabs,
} from './settings';
import type {
RaySettings,
RayStyle,
RayTextStyle,
} from './settings';
import type {
Anchor,
AxisLabel,
Point,
} from '@core/Drawings/types';
import type {
BaseDrawingParams,
ISeriesDrawing,
} from '@core/Drawings/DrawingBase';
import type {
ChartOptionsModel,
SettingsTab,
} from '@src/types';
type RayMode =
| 'idle'
| 'drawing'
| 'ready'
| 'dragging-start'
| 'dragging-direction'
| 'dragging-body';
type RayHandleKey =
| 'start'
| 'direction';
type TimeLabelKind =
| 'start'
| 'direction';
type PriceLabelKind =
| 'start'
| 'direction';
type RayParams =
BaseDrawingParams;
interface RayState {
hidden: boolean;
mode: RayMode;
startAnchor: Anchor | null;
directionAnchor: Anchor | null;
settings: RaySettings;
}
interface RayGeometry {
startPoint: Point;
directionPoint: Point;
rayEndPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
interface RayIntersection {
point: Point;
t: number;
}
export interface RayRenderData
extends RayGeometry,
RayStyle,
RayTextStyle {}
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
export class Ray
extends LinearDrawingBase<
RaySettings,
RayHandleKey
>
implements ISeriesDrawing
{
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings:
RaySettings =
createDefaultSettings();
protected mode:
RayMode = 'idle';
private dragPointerId:
number | null = null;
private dragStartPoint:
Point | null = null;
private dragStateSnapshot:
RayState | null = null;
private displayFormat:
ChartOptionsModel = {
dateFormat:
Defaults.dateFormat,
timeFormat:
Defaults.timeFormat,
showTime:
Defaults.showTime,
};
private readonly paneView:
RayPaneView;
private readonly timeAxisPaneView:
CustomTimeAxisPaneView;
private readonly priceAxisPaneView:
CustomPriceAxisPaneView;
private readonly startTimeAxisView:
CustomTimeAxisView;
private readonly directionTimeAxisView:
CustomTimeAxisView;
private readonly startPriceAxisView:
CustomPriceAxisView;
private readonly directionPriceAxisView:
CustomPriceAxisView;
constructor({
chart,
series,
container,
interaction,
formatObservable,
removeSelf,
openSettings,
initialEvent,
}: RayParams) {
super({
chart,
series,
container,
interaction,
});
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView =
new RayPaneView(this);
this.timeAxisPaneView =
this.createTimeAxisPaneView();
this.priceAxisPaneView =
this.createPriceAxisPaneView();
this.startTimeAxisView =
this.createTimeAxisView('start');
this.directionTimeAxisView =
this.createTimeAxisView(
'direction',
);
this.startPriceAxisView =
this.createPriceAxisView(
'start',
);
this.directionPriceAxisView =
this.createPriceAxisView(
'direction',
);
this.subscribeFormat(
formatObservable,
(format) => {
this.displayFormat = format;
},
);
this.series.attachPrimitive(this);
if (initialEvent?.sourceEvent) {
this.startDrawing(
this.getEventPoint(
initialEvent.sourceEvent,
),
);
}
}
private get directionAnchor():
Anchor | null {
return this.endAnchor;
}
private set directionAnchor(
anchor: Anchor | null,
) {
this.endAnchor = anchor;
}
public isCreationPending(): boolean {
return (
this.mode === 'idle' ||
this.mode === 'drawing'
);
}
public getState(): RayState {
return {
hidden: this.hidden,
mode: this.mode,
startAnchor:
this.startAnchor,
directionAnchor:
this.directionAnchor,
settings: {
...this.settings,
},
};
}
public setState(
state: unknown,
): void {
if (
!state ||
typeof state !== 'object'
) {
return;
}
const nextState =
state as Partial<RayState>;
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 (
'directionAnchor' in nextState
) {
this.directionAnchor =
nextState.directionAnchor ??
null;
}
if (
'settings' in nextState &&
nextState.settings
) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs():
SettingsTab[] {
return getRaySettingTabs(
this.settings,
);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.directionTimeAxisView,
this.startPriceAxisView,
this.directionPriceAxisView,
]);
}
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.directionTimeAxisView,
];
}
public priceAxisViews() {
return [
this.startPriceAxisView,
this.directionPriceAxisView,
];
}
public getRenderData():
RayRenderData | null {
if (this.hidden) {
return null;
}
const geometry =
this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
...this.settings,
};
}
protected getStartHandleId():
RayHandleKey {
return 'start';
}
protected getEndHandleId():
RayHandleKey {
return 'direction';
}
protected getLineHandleStrokeColor():
string {
return this.settings.lineColor;
}
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: 'ray',
zOrder: 'top',
};
}
if (
!this.isPointNearRay(
point,
)
) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'ray',
zOrder: 'top',
};
}
protected getTimeAxisLabel(
kind: string,
): AxisLabel | null {
if (
!this.shouldShowInteractiveAxis() ||
(kind !== 'start' &&
kind !== 'direction')
) {
return null;
}
const labelKind =
kind as TimeLabelKind;
const anchorKind =
labelKind === 'start'
? 'start'
: 'end';
return this.createAxisLabel(
this.getAnchorTimeCoordinate(
anchorKind,
),
this.getTimeText(
labelKind,
),
);
}
protected getPriceAxisLabel(
kind: string,
): AxisLabel | null {
if (
!this.shouldShowInteractiveAxis() ||
(kind !== 'start' &&
kind !== 'direction')
) {
return null;
}
const labelKind =
kind as PriceLabelKind;
const anchorKind =
labelKind === 'start'
? 'start'
: 'end';
return this.createAxisLabel(
this.getAnchorPriceCoordinate(
anchorKind,
),
this.getPriceText(
labelKind,
),
);
}
protected getGeometry():
RayGeometry | null {
const geometry =
this.getTwoPointGeometry();
if (!geometry) {
return null;
}
const startPoint =
geometry.startPoint;
const directionPoint =
geometry.endPoint;
const rayEndPoint =
this.getRayEndPoint(
startPoint,
directionPoint,
);
if (!rayEndPoint) {
return null;
}
return {
startPoint,
directionPoint,
rayEndPoint,
left: Math.min(
startPoint.x,
rayEndPoint.x,
),
right: Math.max(
startPoint.x,
rayEndPoint.x,
),
top: Math.min(
startPoint.y,
rayEndPoint.y,
),
bottom: Math.max(
startPoint.y,
rayEndPoint.y,
),
};
}
protected handleDoubleClick = (
event: MouseEvent,
): void => {
if (
this.hidden ||
this.mode !== 'ready'
) {
return;
}
const point =
this.getEventPoint(
event as PointerEvent,
);
if (
!this.getDrawingHandleAtPoint(
point,
) &&
!this.isPointNearRay(
point,
)
) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (
event: PointerEvent,
): void => {
if (
this.hidden ||
event.button !== 0
) {
return;
}
const point =
this.getEventPoint(event);
if (this.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.getDrawingHandleAtPoint(
point,
)?.id ?? null;
const isNearRay =
this.isPointNearRay(point);
const isDrawingHit =
pointTarget !== null ||
isNearRay;
if (!this.isSelected()) {
if (!isDrawingHit) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
if (
pointTarget === 'start'
) {
event.preventDefault();
event.stopPropagation();
this.startDragging(
'dragging-start',
point,
event.pointerId,
);
return;
}
if (
pointTarget ===
'direction'
) {
event.preventDefault();
event.stopPropagation();
this.startDragging(
'dragging-direction',
point,
event.pointerId,
);
return;
}
if (isNearRay) {
event.preventDefault();
event.stopPropagation();
this.startDragging(
'dragging-body',
point,
event.pointerId,
);
return;
}
this.deselect();
};
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-direction'
) {
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-direction' ||
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.getTwoPointGeometry();
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: RayMode,
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-direction'
) {
this.setAnchorFromPoint(
'end',
point,
);
}
}
private moveBody(
point: Point,
): void {
const snapshot =
this.dragStateSnapshot;
if (!snapshot) {
return;
}
this.moveAnchorsByPixels(
snapshot.startAnchor,
snapshot.directionAnchor,
this.dragStartPoint,
point,
);
}
private getRayEndPoint(
startPoint: Point,
directionPoint: Point,
): Point | null {
const dx =
directionPoint.x -
startPoint.x;
const dy =
directionPoint.y -
startPoint.y;
if (
dx === 0 &&
dy === 0
) {
return null;
}
const { width } =
this.container.getBoundingClientRect();
const height =
this.series
.getPane()
.getHeight();
if (
width <= 0 ||
height <= 0
) {
return null;
}
const candidates:
RayIntersection[] = [];
if (dx !== 0) {
const leftT =
-startPoint.x / dx;
const rightT =
(width - startPoint.x) /
dx;
const leftY =
startPoint.y +
leftT * dy;
const rightY =
startPoint.y +
rightT * dy;
if (
leftT >= 0 &&
leftY >= 0 &&
leftY <= height
) {
candidates.push({
point: {
x: 0,
y: leftY,
},
t: leftT,
});
}
if (
rightT >= 0 &&
rightY >= 0 &&
rightY <= height
) {
candidates.push({
point: {
x: width,
y: rightY,
},
t: rightT,
});
}
}
if (dy !== 0) {
const topT =
-startPoint.y / dy;
const bottomT =
(height - startPoint.y) /
dy;
const topX =
startPoint.x +
topT * dx;
const bottomX =
startPoint.x +
bottomT * dx;
if (
topT >= 0 &&
topX >= 0 &&
topX <= width
) {
candidates.push({
point: {
x: topX,
y: 0,
},
t: topT,
});
}
if (
bottomT >= 0 &&
bottomX >= 0 &&
bottomX <= width
) {
candidates.push({
point: {
x: bottomX,
y: height,
},
t: bottomT,
});
}
}
if (!candidates.length) {
return null;
}
return candidates.reduce(
(farthest, candidate) => {
return (
candidate.t >
farthest.t
? candidate
: farthest
);
},
).point;
}
private isPointNearRay(
point: Point,
): boolean {
const geometry =
this.getGeometry();
if (!geometry) {
return false;
}
return (
getDistanceToSegment(
point,
geometry.startPoint,
geometry.rayEndPoint,
) <= LINE_HIT_TOLERANCE
);
}
private getTimeText(
kind: TimeLabelKind,
): string {
const anchor =
kind === 'start'
? this.startAnchor
: this.directionAnchor;
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.directionAnchor;
if (!anchor) {
return '';
}
return (
formatPrice(
anchor.price,
) ?? ''
);
}
}