Загрузка данных
import { IChartApi, IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
import { Observable } from 'rxjs';
import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import { SeriesDrawingBase } from '@core/Drawings/common';
import {
getPriceFromYCoordinate,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
} from '@core/Drawings/helpers';
import { AxisSegment } from '@core/Drawings/types';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { AxisLinePaneView } from './paneView';
import {
AxisLineSettings,
AxisLineStyle,
AxisLineTextStyle,
createDefaultSettings,
getAxisLineSettingsTabs,
} from './settings';
import type { DrawingInteraction, ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
export type AxisLineDirection = 'vertical' | 'horizontal';
type AxisLineMode = 'idle' | 'ready' | 'dragging';
interface AxisLineParams {
direction: AxisLineDirection;
container: HTMLElement;
interaction: DrawingInteraction;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
interface AxisLineState {
hidden: boolean;
mode: AxisLineMode;
time: Time | null;
price: number | null;
settings: AxisLineSettings;
}
export interface AxisLineRenderData extends AxisLineStyle, AxisLineTextStyle {
direction: AxisLineDirection;
coordinate: number;
handle: Point;
showHandle: boolean;
}
const HANDLE_HIT_TOLERANCE = 8;
const LINE_HIT_TOLERANCE = 6;
export class AxisLine extends SeriesDrawingBase<AxisLineSettings> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: AxisLineSettings = createDefaultSettings();
protected mode: AxisLineMode = 'idle';
private direction: AxisLineDirection;
private time: Time | null = null;
private price: number | null = null;
private dragPointerId: number | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: AxisLinePaneView;
private readonly timeAxisView: CustomTimeAxisView;
private readonly priceAxisView: CustomPriceAxisView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ direction, container, interaction, formatObservable, removeSelf, openSettings }: AxisLineParams,
) {
super({ chart, series, container, interaction });
this.direction = direction;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new AxisLinePaneView(this);
this.timeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'main',
});
this.priceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'main',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public getState(): AxisLineState {
return {
hidden: this.hidden,
mode: this.mode,
time: this.time,
price: this.price,
settings: { ...this.settings },
};
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public setState(state: unknown): void {
const nextState = state as Partial<AxisLineState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('time' in nextState) {
this.time = nextState.time ?? null;
}
if ('price' in nextState) {
this.price = nextState.price ?? null;
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getAxisLineSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([this.paneView, this.timeAxisView, this.priceAxisView]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisViews() {
return this.direction === 'vertical' ? [this.timeAxisView] : [];
}
public priceAxisViews() {
return this.direction === 'horizontal' ? [this.priceAxisView] : [];
}
public getRenderData(): AxisLineRenderData | null {
if (this.hidden) {
return null;
}
const coordinate = this.getCoordinate();
if (coordinate === null) {
return null;
}
const { width, height } = this.container.getBoundingClientRect();
return {
direction: this.direction,
coordinate,
handle: this.direction === 'vertical' ? { x: coordinate, y: height / 2 } : { x: width / 2, y: coordinate },
showHandle: this.shouldShowHandles(),
...this.settings,
};
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle') {
return null;
}
const point = { x, y };
const data = this.getRenderData();
if (!data) {
return null;
}
if (this.isSelected() && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE)) {
return {
cursorStyle: this.getCursorStyle(),
externalId: 'axis-line',
zOrder: 'top',
};
}
if (!this.isPointNearLine(point, data.coordinate)) {
return null;
}
return {
cursorStyle: this.getCursorStyle(),
externalId: 'axis-line',
zOrder: 'top',
};
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'main' || this.direction !== 'vertical' || !this.isSelected() || this.time === null) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, this.time, this.series);
if (coordinate === null || typeof this.time !== 'number') {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatDate(
this.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'main' || this.direction !== 'horizontal' || !this.isSelected() || this.price === null) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, this.price);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatPrice(this.price) ?? '',
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getTimeAxisSegments(): AxisSegment[] {
return [];
}
protected getPriceAxisSegments(): AxisSegment[] {
return [];
}
protected getGeometry(): void {
console.log('stub');
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const data = this.getRenderData();
if (!data) {
return;
}
const rect = this.container.getBoundingClientRect();
const point = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
const isNearHandle =
this.isSelected() && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE);
const isNearLine = this.isPointNearLine(point, data.coordinate);
if (!isNearHandle && !isNearLine) {
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.updateLine(point);
this.mode = 'ready';
this.resolveReady?.();
this.render();
return;
}
if (this.mode !== 'ready') {
return;
}
const data = this.getRenderData();
if (!data) {
return;
}
const isNearHandle =
this.isSelected() && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE);
const isNearLine = this.isPointNearLine(point, data.coordinate);
if (!this.isSelected()) {
if (!isNearLine) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
if (!isNearHandle && !isNearLine) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.mode = 'dragging';
this.dragPointerId = event.pointerId;
this.hideCrosshair();
this.render();
};
protected handlePointerMove = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
event.stopPropagation();
this.updateLine(this.getEventPoint(event));
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.dragPointerId = null;
this.showCrosshair();
this.render();
};
private updateLine(point: Point): void {
if (this.direction === 'vertical') {
this.time = getTimeFromXCoordinate(this.chart, point.x);
return;
}
this.price = getPriceFromYCoordinate(this.series, point.y);
}
private getCoordinate(): number | null {
if (this.direction === 'vertical') {
if (this.time === null) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, this.time, this.series);
return coordinate === null ? null : Number(coordinate);
}
if (this.price === null) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, this.price);
return coordinate === null ? null : Number(coordinate);
}
private isPointNearLine(point: Point, coordinate: number): boolean {
return this.direction === 'vertical'
? Math.abs(point.x - coordinate) <= LINE_HIT_TOLERANCE
: Math.abs(point.y - coordinate) <= LINE_HIT_TOLERANCE;
}
private getCursorStyle(): PrimitiveHoveredItem['cursorStyle'] {
return this.direction === 'vertical' ? 'ew-resize' : 'ns-resize';
}
}