Загрузка данных
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
Logical,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
Time,
UTCTimestamp,
} from 'lightweight-charts';
import { Observable, Subscription } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
getAnchorFromPoint,
getPointerPoint as getPointerPointFromEvent,
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { TrendLinePaneView } from './paneView';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisLabel, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel } from '@src/types';
type TrendLineMode = 'idle' | 'drawing' | 'ready' | 'dragging-start' | 'dragging-end' | 'dragging-body';
type TimeLabelKind = 'start' | 'end';
type PriceLabelKind = 'start' | 'end';
interface TrendLineParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
}
interface TrendLineState {
hidden: boolean;
isActive: boolean;
mode: TrendLineMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
}
interface TrendLineGeometry {
startPoint: Point;
endPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
}
export interface TrendLineRenderData extends TrendLineGeometry {
showHandles: boolean;
}
const POINT_HIT_TOLERANCE = 8;
const LINE_HIT_TOLERANCE = 6;
const MIN_LINE_SIZE = 4;
const { colors } = getThemeStore();
export class TrendLine implements ISeriesDrawing {
private chart: IChartApi;
private series: SeriesApi;
private container: HTMLElement;
private removeSelf?: () => void;
private requestUpdate: (() => void) | null = null;
private subscriptions = new Subscription();
private isBound = false;
private hidden = false;
private isActive = false;
private mode: TrendLineMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: TrendLineState | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: TrendLinePaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly startPriceAxisView: CustomPriceAxisView;
private readonly endPriceAxisView: CustomPriceAxisView;
constructor(chart: IChartApi, series: SeriesApi, { container, formatObservable, removeSelf }: TrendLineParams) {
this.chart = chart;
this.series = series;
this.container = container;
this.removeSelf = removeSelf;
this.paneView = new TrendLinePaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.startTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'start',
});
this.endTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'end',
});
this.startPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'start',
});
this.endPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'end',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
}
public show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.showCrosshair();
this.render();
}
public destroy(): void {
this.showCrosshair();
this.unbindEvents();
this.subscriptions.unsubscribe();
this.series.detachPrimitive(this);
this.requestUpdate = null;
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this);
this.render();
}
public getState(): TrendLineState {
return {
hidden: this.hidden,
isActive: this.isActive,
mode: this.mode,
startAnchor: this.startAnchor,
endAnchor: this.endAnchor,
};
}
public setState(state: unknown): void {
const nextState = state as Partial<TrendLineState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('isActive' in nextState && typeof nextState.isActive === 'boolean') {
this.isActive = nextState.isActive;
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('startAnchor' in nextState) {
this.startAnchor = nextState.startAnchor ?? null;
}
if ('endAnchor' in nextState) {
this.endAnchor = nextState.endAnchor ?? null;
}
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.bindEvents();
}
public detached(): void {
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public 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 autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
public getRenderData(): TrendLineRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showHandles: this.isActive,
};
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (this.getPointTarget(point)) {
return {
cursorStyle: 'move',
externalId: 'trend-line',
zOrder: 'top',
};
}
if (!this.isPointNearLine(point)) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'trend-line',
zOrder: 'top',
};
}
public getTimeAxisSegments(): AxisSegment[] {
if (!this.isActive) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
from: geometry.left,
to: geometry.right,
color: colors.axisMarkerAreaFill,
},
];
}
public getPriceAxisSegments(): AxisSegment[] {
if (!this.isActive) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
from: geometry.top,
to: geometry.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
public getTimeAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive || (kind !== 'start' && kind !== 'end')) {
return null;
}
const coordinate = this.getTimeCoordinate(kind);
const text = this.getTimeText(kind);
if (coordinate === null || !text) {
return null;
}
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
public getPriceAxisLabel(kind: string): AxisLabel | null {
if (!this.isActive || (kind !== 'start' && kind !== 'end')) {
return null;
}
const coordinate = this.getPriceCoordinate(kind);
const text = this.getPriceText(kind);
if (coordinate === null || !text) {
return null;
}
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
private bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
this.container.addEventListener('pointerdown', this.handlePointerDown);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
}
private unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
this.container.removeEventListener('pointerdown', this.handlePointerDown);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
private handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
const pointTarget = this.getPointTarget(point);
if (!this.isActive) {
if (!pointTarget && !this.isPointNearLine(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive = true;
this.render();
return;
}
if (pointTarget === 'start') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-start', point, event.pointerId);
return;
}
if (pointTarget === 'end') {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-end', point, event.pointerId);
return;
}
if (this.isPointNearLine(point)) {
event.preventDefault();
event.stopPropagation();
this.startDragging('dragging-body', point, event.pointerId);
return;
}
this.isActive = false;
this.render();
};
private handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-end') {
event.preventDefault();
event.stopPropagation();
this.movePoint(point);
this.render();
return;
}
if (this.mode === 'dragging-body') {
event.preventDefault();
event.stopPropagation();
this.moveBody(point);
this.render();
}
};
private handlePointerUp = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging-start' || this.mode === 'dragging-end' || this.mode === 'dragging-body') {
this.finishDragging();
}
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startAnchor = anchor;
this.endAnchor = anchor;
this.isActive = true;
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.endAnchor = anchor;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry) {
return;
}
const lineSize = Math.hypot(geometry.endPoint.x - geometry.startPoint.x, geometry.endPoint.y - geometry.startPoint.y);
if (lineSize < MIN_LINE_SIZE) {
this.removeSelf?.();
return;
}
this.mode = 'ready';
this.render();
}
private startDragging(mode: TrendLineMode, point: Point, pointerId: number): void {
this.mode = mode;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.showCrosshair();
this.render();
}
private movePoint(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
if (this.mode === 'dragging-start') {
this.startAnchor = anchor;
}
if (this.mode === 'dragging-end') {
this.endAnchor = anchor;
}
}
private moveBody(point: Point): void {
const snapshot = this.dragStateSnapshot;
if (!snapshot?.startAnchor || !snapshot.endAnchor || !this.dragStartPoint) {
return;
}
const offsetX = point.x - this.dragStartPoint.x;
const priceOffset = getPriceDeltaFromCoordinates(this.series, this.dragStartPoint.y, point.y);
const nextStartTime = shiftTimeByPixels(this.chart, snapshot.startAnchor.time, offsetX);
const nextEndTime = shiftTimeByPixels(this.chart, snapshot.endAnchor.time, offsetX);
if (nextStartTime === null || nextEndTime === null) {
return;
}
this.startAnchor = {
time: nextStartTime,
price: snapshot.startAnchor.price + priceOffset,
};
this.endAnchor = {
time: nextEndTime,
price: snapshot.endAnchor.price + priceOffset,
};
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
private getGeometry(): TrendLineGeometry | null {
if (!this.startAnchor || !this.endAnchor) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startAnchor.time);
const endX = getXCoordinateFromTime(this.chart, this.endAnchor.time);
const startY = getYCoordinateFromPrice(this.series, this.startAnchor.price);
const endY = getYCoordinateFromPrice(this.series, this.endAnchor.price);
if (startX === null || endX === null || startY === null || endY === null) {
return null;
}
const startPoint = {
x: Math.round(Number(startX)),
y: Math.round(Number(startY)),
};
const endPoint = {
x: Math.round(Number(endX)),
y: Math.round(Number(endY)),
};
return {
startPoint,
endPoint,
left: Math.min(startPoint.x, endPoint.x),
right: Math.max(startPoint.x, endPoint.x),
top: Math.min(startPoint.y, endPoint.y),
bottom: Math.max(startPoint.y, endPoint.y),
};
}
private getPointTarget(point: Point): 'start' | 'end' | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.startPoint.x, geometry.startPoint.y, 8)) {
return 'start';
}
if (isNearPoint(point, geometry.endPoint.x, geometry.endPoint.y, 8)) {
return 'end';
}
return null;
}
private isPointNearLine(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return this.getDistanceToSegment(point, geometry.startPoint, geometry.endPoint) <= LINE_HIT_TOLERANCE;
}
private getDistanceToSegment(point: Point, startPoint: Point, endPoint: Point): number {
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
if (dx === 0 && dy === 0) {
return Math.hypot(point.x - startPoint.x, point.y - startPoint.y);
}
const t = Math.max(
0,
Math.min(1, ((point.x - startPoint.x) * dx + (point.y - startPoint.y) * dy) / (dx * dx + dy * dy)),
);
const projectionX = startPoint.x + t * dx;
const projectionY = startPoint.y + t * dy;
return Math.hypot(point.x - projectionX, point.y - projectionY);
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, anchor.time);
return coordinate === null ? null : Number(coordinate);
}
private getPriceCoordinate(kind: PriceLabelKind): number | null {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, anchor.price);
return coordinate === null ? null : Number(coordinate);
}
private getTimeText(kind: TimeLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor || typeof anchor.time !== 'number') {
return '';
}
return formatDate(
anchor.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const anchor = kind === 'start' ? this.startAnchor : this.endAnchor;
if (!anchor) {
return '';
}
return formatPrice(anchor.price) ?? '';
}
private hideCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Hidden,
},
});
}
private showCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Normal,
},
});
}
private getEventPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
private render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
}
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
Logical,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
Time,
UTCTimestamp,
} from 'lightweight-charts';
import { Observable, Subscription } from 'rxjs';
import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import {
getPointerPoint as getPointerPointFromEvent,
getPriceFromYCoordinate,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { Defaults } from '@src/types/defaults';
import { formatPrice } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { AxisLinePaneView } from './paneView';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel } from '@src/types';
export type AxisLineDirection = 'vertical' | 'horizontal';
type AxisLineMode = 'idle' | 'ready' | 'dragging';
interface AxisLineParams {
direction: AxisLineDirection;
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
}
interface AxisLineState {
hidden: boolean;
isActive: boolean;
mode: AxisLineMode;
time: Time | null;
price: number | null;
}
export interface AxisLineRenderData {
direction: AxisLineDirection;
coordinate: number;
handle: Point;
showHandle: boolean;
}
const HANDLE_HIT_TOLERANCE = 8;
const LINE_HIT_TOLERANCE = 6;
const { colors } = getThemeStore();
export class AxisLine implements ISeriesDrawing {
private chart: IChartApi;
private series: SeriesApi;
private container: HTMLElement;
private removeSelf?: () => void;
private requestUpdate: (() => void) | null = null;
private subscriptions = new Subscription();
private isBound = false;
private hidden = false;
private isActive = false;
private 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, formatObservable, removeSelf }: AxisLineParams) {
this.chart = chart;
this.series = series;
this.direction = direction;
this.container = container;
this.removeSelf = removeSelf;
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 show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.showCrosshair();
this.render();
}
public destroy(): void {
this.showCrosshair();
this.unbindEvents();
this.subscriptions.unsubscribe();
this.series.detachPrimitive(this);
this.requestUpdate = null;
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this);
this.render();
}
public getState(): AxisLineState {
return {
hidden: this.hidden,
isActive: this.isActive,
mode: this.mode,
time: this.time,
price: this.price,
};
}
public setState(state: unknown): void {
const nextState = state as Partial<AxisLineState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('isActive' in nextState && typeof nextState.isActive === 'boolean') {
this.isActive = nextState.isActive;
}
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;
}
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.bindEvents();
}
public detached(): void {
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public 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 autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
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.isActive,
};
}
public hitTest(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.isActive && 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',
};
}
public getTimeAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'main' || this.direction !== 'vertical' || !this.isActive || this.time === null) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, this.time);
if (coordinate === null || typeof this.time !== 'number') {
return null;
}
return {
coordinate,
text: formatDate(
this.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
public getPriceAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'main' || this.direction !== 'horizontal' || !this.isActive || this.price === null) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, this.price);
if (coordinate === null) {
return null;
}
return {
coordinate,
text: formatPrice(this.price) ?? '',
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
private bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
this.container.addEventListener('pointerdown', this.handlePointerDown);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
}
private unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
this.container.removeEventListener('pointerdown', this.handlePointerDown);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
private handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.updateLine(point);
this.isActive = true;
this.mode = 'ready';
this.render();
return;
}
if (this.mode !== 'ready') {
return;
}
const data = this.getRenderData();
if (!data) {
return;
}
const isNearHandle = this.isActive && isNearPoint(point, data.handle.x, data.handle.y, HANDLE_HIT_TOLERANCE);
const isNearLine = this.isPointNearLine(point, data.coordinate);
if (!this.isActive) {
if (!isNearLine) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive = true;
this.render();
return;
}
if (!isNearHandle && !isNearLine) {
this.isActive = false;
this.render();
return;
}
event.preventDefault();
event.stopPropagation();
this.mode = 'dragging';
this.dragPointerId = event.pointerId;
this.hideCrosshair();
this.render();
};
private handlePointerMove = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
event.stopPropagation();
this.updateLine(this.getEventPoint(event));
this.render();
};
private handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.mode = 'ready';
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);
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';
}
private hideCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Hidden,
},
});
}
private showCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Normal,
},
});
}
private getEventPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
private render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
}
import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';
import { AxisLinePaneRenderer } from './paneRenderer';
import { AxisLine } from './axisLine';
export class AxisLinePaneView implements IPrimitivePaneView {
private readonly axisLine: AxisLine;
constructor(axisLine: AxisLine) {
this.axisLine = axisLine;
}
public update(): void {}
public renderer(): IPrimitivePaneRenderer {
return new AxisLinePaneRenderer(this.axisLine);
}
public zOrder(): PrimitivePaneViewZOrder {
return 'top';
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import { AxisLine } from './axisLine';
const UI = {
lineWidth: 1,
handleSize: 10,
handleBorderWidth: 1,
};
const { colors } = getThemeStore();
export class AxisLinePaneRenderer implements IPrimitivePaneRenderer {
private readonly axisLine: AxisLine;
constructor(axisLine: AxisLine) {
this.axisLine = axisLine;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.axisLine.getRenderData();
if (!data) {
return;
}
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
context.save();
context.fillStyle = colors.chartLineColor;
if (data.direction === 'vertical') {
const x = Math.round(data.coordinate * horizontalPixelRatio);
const width = Math.max(1, UI.lineWidth * pixelRatio);
context.fillRect(x - width / 2, 0, width, bitmapSize.height);
}
if (data.direction === 'horizontal') {
const y = Math.round(data.coordinate * verticalPixelRatio);
const height = Math.max(1, UI.lineWidth * pixelRatio);
context.fillRect(0, y - height / 2, bitmapSize.width, height);
}
if (data.showHandle) {
drawHandle(
context,
data.handle.x * horizontalPixelRatio,
data.handle.y * verticalPixelRatio,
horizontalPixelRatio,
verticalPixelRatio,
);
}
context.restore();
});
}
}
function drawHandle(
context: CanvasRenderingContext2D,
x: number,
y: number,
horizontalPixelRatio: number,
verticalPixelRatio: number,
): void {
const width = UI.handleSize * horizontalPixelRatio;
const height = UI.handleSize * verticalPixelRatio;
const left = x - width / 2;
const top = y - height / 2;
context.save();
context.fillStyle = colors.chartBackground;
context.strokeStyle = colors.chartLineColor;
context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.beginPath();
context.rect(left, top, width, height);
context.fill();
context.stroke();
context.restore();
}