Загрузка данных
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;
}
}
const previousPoint = points[right];
const nextPoint = points[left];
return interpolateCoordinate(
chart,
targetTime,
previousPoint,
nextPoint,
);
}
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 { 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,
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, height } = this.container.getBoundingClientRect();
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) ?? '';
}
}