Загрузка данных
import {
clamp,
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getPointerPoint as getPointerPointFromEvent,
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { formatPrice } from '@src/utils';
import { FibonacciRetracementPaneView } from './paneView';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, Point, SeriesApi } from '@core/Drawings/types';
import type { SettingsTab, SettingsValues } from '@src/types';
import type {
AutoscaleInfo,
IChartApi,
IPrimitivePaneView,
Logical,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
Time,
} from 'lightweight-charts';
type FibonacciRetracementMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type FibonacciRetracementHandle = 'body' | 'start' | 'end' | null;
type FibonacciRetracementHandleKey = Exclude<FibonacciRetracementHandle, 'body' | null>;
interface FibonacciRetracementParams {
container: HTMLElement;
removeSelf?: () => void;
openSettings?: () => void;
}
interface FibonacciRetracementState {
hidden: boolean;
isActive: boolean;
mode: FibonacciRetracementMode;
startAnchor: Anchor | null;
endAnchor: Anchor | null;
}
export interface FibonacciRetracementLevelRenderData {
value: number;
price: number;
y: number;
text: string;
}
interface FibonacciRetracementGeometry {
startPoint: Point;
endPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
levels: FibonacciRetracementLevelRenderData[];
handles: Record<FibonacciRetracementHandleKey, Point>;
}
export interface FibonacciRetracementRenderData extends FibonacciRetracementGeometry {
showHandles: boolean;
}
const HANDLE_HIT_TOLERANCE = 8;
const LINE_HIT_TOLERANCE = 6;
const MIN_DISTANCE = 6;
const FIBONACCI_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1];
export class FibonacciRetracement implements ISeriesDrawing {
private chart: IChartApi;
private series: SeriesApi;
private container: HTMLElement;
private removeSelf?: () => void;
private openSettings?: () => void;
private requestUpdate: (() => void) | null = null;
private isBound = false;
private hidden = false;
private isActive = false;
private mode: FibonacciRetracementMode = 'idle';
private startAnchor: Anchor | null = null;
private endAnchor: Anchor | null = null;
private activeDragTarget: FibonacciRetracementHandle = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragStateSnapshot: FibonacciRetracementState | null = null;
private dragGeometrySnapshot: FibonacciRetracementGeometry | null = null;
private readonly paneView: FibonacciRetracementPaneView;
constructor(
chart: IChartApi,
series: SeriesApi,
{ container, removeSelf, openSettings }: FibonacciRetracementParams,
) {
this.chart = chart;
this.series = series;
this.container = container;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new FibonacciRetracementPaneView(this);
this.series.attachPrimitive(this);
}
public show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.render();
}
public destroy(): void {
this.unbindEvents();
this.series.detachPrimitive(this);
this.requestUpdate = null;
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.unbindEvents();
this.series.detachPrimitive(this);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this);
this.render();
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public shouldShowInObjectTree(): boolean {
return this.mode !== 'idle';
}
public getState(): FibonacciRetracementState {
return {
hidden: this.hidden,
isActive: this.isActive,
mode: this.mode,
startAnchor: this.startAnchor ? { ...this.startAnchor } : null,
endAnchor: this.endAnchor ? { ...this.endAnchor } : null,
};
}
public setState(state: unknown): void {
const nextState = state as Partial<FibonacciRetracementState>;
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 ? { ...nextState.startAnchor } : null;
}
if ('endAnchor' in nextState) {
this.endAnchor = nextState.endAnchor ? { ...nextState.endAnchor } : null;
}
this.render();
}
public getSettings(): SettingsValues {
return {};
}
public getSettingsTabs(): SettingsTab[] {
return [];
}
public updateSettings(_settings: SettingsValues): void {
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.bindEvents();
}
public detached(): void {
this.unbindEvents();
this.requestUpdate = null;
}
public updateAllViews(): void {
updateViews([this.paneView]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public timeAxisViews() {
return [];
}
public priceAxisViews() {
return [];
}
public autoscaleInfo(_start: Logical, _end: Logical): AutoscaleInfo | null {
return null;
}
public getRenderData(): FibonacciRetracementRenderData | 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.isActive) {
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'pointer',
externalId: 'fibonacci-retracement-position',
zOrder: 'top',
};
}
const handleTarget = this.getHandleTarget(point);
if (handleTarget) {
return {
cursorStyle: 'pointer',
externalId: 'fibonacci-retracement-position',
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'grab',
externalId: 'fibonacci-retracement-position',
zOrder: 'top',
};
}
private bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
this.container.addEventListener('dblclick', this.handleDoubleClick);
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('dblclick', this.handleDoubleClick);
this.container.removeEventListener('pointerdown', this.handlePointerDown);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
private handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const point = this.getMousePoint(event);
if (!this.containsPoint(point) && !this.getHandleTarget(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
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;
}
if (!this.isActive) {
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive = true;
this.render();
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.isActive = false;
this.render();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
};
private handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.mode === 'drawing') {
this.updateDrawing(point);
return;
}
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
if (this.activeDragTarget === 'body') {
this.moveWhole(point);
this.render();
return;
}
this.resize(point);
this.render();
};
private handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
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(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.endAnchor = anchor;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry || Math.max(geometry.width, geometry.height) < MIN_DISTANCE) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.render();
}
private startDragging(
point: Point,
pointerId: number,
dragTarget: Exclude<FibonacciRetracementHandle, null>,
): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragStateSnapshot = this.getState();
this.dragGeometrySnapshot = this.getGeometry();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.clearInteractionState();
this.render();
}
private clearInteractionState(): void {
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragStateSnapshot = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.isActive = false;
this.mode = 'idle';
this.startAnchor = null;
this.endAnchor = null;
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<FibonacciRetracementHandle, null> | null {
const handleTarget = this.getHandleTarget(point);
if (handleTarget) {
return handleTarget;
}
if (this.containsPoint(point)) {
return 'body';
}
return null;
}
private moveWhole(point: Point): void {
const snapshot = this.dragStateSnapshot;
const geometry = this.dragGeometrySnapshot;
if (!snapshot?.startAnchor || !snapshot.endAnchor || !geometry || !this.dragStartPoint) {
return;
}
const containerSize = this.getContainerSize();
const rawOffsetX = point.x - this.dragStartPoint.x;
const rawOffsetY = point.y - this.dragStartPoint.y;
const minOffsetX = -geometry.left;
const maxOffsetX = containerSize.width - geometry.right;
const clampedOffsetX = clamp(rawOffsetX, minOffsetX, maxOffsetX);
const minOffsetY = -geometry.top;
const maxOffsetY = containerSize.height - geometry.bottom;
const clampedOffsetY = clamp(rawOffsetY, minOffsetY, maxOffsetY);
const nextStartTime = this.shiftTime(snapshot.startAnchor.time, clampedOffsetX);
const nextEndTime = this.shiftTime(snapshot.endAnchor.time, clampedOffsetX);
if (nextStartTime === null || nextEndTime === null) {
return;
}
const priceOffset = this.getPriceDelta(this.dragStartPoint.y, this.dragStartPoint.y + clampedOffsetY);
this.startAnchor = {
time: nextStartTime,
price: snapshot.startAnchor.price + priceOffset,
};
this.endAnchor = {
time: nextEndTime,
price: snapshot.endAnchor.price + priceOffset,
};
}
private resize(point: Point): void {
if (!this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
if (this.activeDragTarget === 'start') {
this.startAnchor = anchor;
return;
}
this.endAnchor = anchor;
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
private getGeometry(): FibonacciRetracementGeometry | 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)),
};
const left = Math.min(startPoint.x, endPoint.x);
const right = Math.max(startPoint.x, endPoint.x);
const top = Math.min(startPoint.y, endPoint.y);
const bottom = Math.max(startPoint.y, endPoint.y);
const levels = FIBONACCI_LEVELS.reduce<FibonacciRetracementLevelRenderData[]>((result, level) => {
const price = this.startAnchor!.price + (this.endAnchor!.price - this.startAnchor!.price) * level;
const y = getYCoordinateFromPrice(this.series, price);
if (y === null) {
return result;
}
result.push({
value: level,
price,
y: Math.round(Number(y)),
text: this.getLevelText(level, price),
});
return result;
}, []);
return {
startPoint,
endPoint,
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
levels,
handles: {
start: startPoint,
end: endPoint,
},
};
}
private getLevelText(level: number, price: number): string {
const percent = `${this.formatLevel(level)}%`;
const formattedPrice = formatPrice(price) ?? String(price);
return `${percent} ${formattedPrice}`;
}
private formatLevel(level: number): string {
return String(Number((level * 100).toFixed(1))).replace('.', ',');
}
private getHandleTarget(point: Point): FibonacciRetracementHandleKey | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
if (isNearPoint(point, geometry.handles.start.x, geometry.handles.start.y, HANDLE_HIT_TOLERANCE)) {
return 'start';
}
if (isNearPoint(point, geometry.handles.end.x, geometry.handles.end.y, HANDLE_HIT_TOLERANCE)) {
return 'end';
}
return null;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
const xInRange = point.x >= geometry.left - LINE_HIT_TOLERANCE && point.x <= geometry.right + LINE_HIT_TOLERANCE;
if (!xInRange) {
return false;
}
return geometry.levels.some((level) => Math.abs(point.y - level.y) <= LINE_HIT_TOLERANCE);
}
private shiftTime(time: Time, offsetX: number): Time | null {
return shiftTimeByPixels(this.chart, time, offsetX);
}
private getPriceDelta(fromY: number, toY: number): number {
return getPriceDeltaFromCoordinates(this.series, fromY, toY);
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
private getEventPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
private getMousePoint(event: MouseEvent): Point {
const rect = this.container.getBoundingClientRect();
return this.clampPointToContainer({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
});
}
private render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
}
import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';
import { FibonacciRetracementPaneRenderer } from './paneRenderer';
import { FibonacciRetracement } from './fibonacciRetracement';
export class FibonacciRetracementPaneView implements IPrimitivePaneView {
private readonly fibonacciRetracement: FibonacciRetracement;
constructor(fibonacciRetracement: FibonacciRetracement) {
this.fibonacciRetracement = fibonacciRetracement;
}
public update(): void {}
public renderer(): IPrimitivePaneRenderer {
return new FibonacciRetracementPaneRenderer(this.fibonacciRetracement);
}
public zOrder(): PrimitivePaneViewZOrder {
return 'top';
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import { FibonacciRetracement } from './fibonacciRetracement';
const UI = {
lineWidth: 1,
handleSize: 8,
handleBorderWidth: 1,
textOffset: 4,
fontSize: 12,
};
export class FibonacciRetracementPaneRenderer implements IPrimitivePaneRenderer {
private readonly fibonacciRetracement: FibonacciRetracement;
constructor(fibonacciRetracement: FibonacciRetracement) {
this.fibonacciRetracement = fibonacciRetracement;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.fibonacciRetracement.getRenderData();
if (!data) {
return;
}
const { colors } = getThemeStore();
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const left = data.left * horizontalPixelRatio;
const right = data.right * horizontalPixelRatio;
context.save();
context.lineWidth = UI.lineWidth * pixelRatio;
context.strokeStyle = colors.chartLineColor;
context.fillStyle = colors.chartTextPrimary;
context.font = `${UI.fontSize * pixelRatio}px Inter, sans-serif`;
context.textAlign = 'left';
context.textBaseline = 'middle';
data.levels.forEach((level) => {
const y = level.y * verticalPixelRatio;
drawHorizontalLine(context, left, right, y);
context.fillText(level.text, right + UI.textOffset * horizontalPixelRatio, y);
});
drawControlLine(
context,
data.startPoint.x * horizontalPixelRatio,
data.startPoint.y * verticalPixelRatio,
data.endPoint.x * horizontalPixelRatio,
data.endPoint.y * verticalPixelRatio,
);
if (data.showHandles) {
drawHandle(
context,
data.startPoint.x * horizontalPixelRatio,
data.startPoint.y * verticalPixelRatio,
horizontalPixelRatio,
verticalPixelRatio,
colors.chartLineColor,
colors.chartBackground,
);
drawHandle(
context,
data.endPoint.x * horizontalPixelRatio,
data.endPoint.y * verticalPixelRatio,
horizontalPixelRatio,
verticalPixelRatio,
colors.chartLineColor,
colors.chartBackground,
);
}
context.restore();
});
}
}
function drawHorizontalLine(context: CanvasRenderingContext2D, left: number, right: number, y: number): void {
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
}
function drawControlLine(
context: CanvasRenderingContext2D,
startX: number,
startY: number,
endX: number,
endY: number,
): void {
context.save();
context.setLineDash([4, 4]);
context.beginPath();
context.moveTo(startX, startY);
context.lineTo(endX, endY);
context.stroke();
context.restore();
}
function drawHandle(
context: CanvasRenderingContext2D,
x: number,
y: number,
horizontalPixelRatio: number,
verticalPixelRatio: number,
strokeColor: string,
fillColor: string,
): 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 = fillColor;
context.strokeStyle = strokeColor;
context.lineWidth = UI.handleBorderWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.beginPath();
context.rect(left, top, width, height);
context.fill();
context.stroke();
context.restore();
}