Загрузка данных
import cloneDeep from 'lodash-es/cloneDeep';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
export type FibonacciRetracementLabelPosition = 'left' | 'right';
export interface FibonacciRetracementLevelSettings {
id: string;
value: number;
visible: boolean;
color: string;
}
export interface FibonacciRetracementSettings {
levels: FibonacciRetracementLevelSettings[];
showBackground: boolean;
backgroundOpacity: number;
reverse: boolean;
labelsPosition: FibonacciRetracementLabelPosition;
showPrices: boolean;
showLevelValues: boolean;
fontSize: number;
isBold: boolean;
isItalic: boolean;
}
const FIBONACCI_LEVEL_COLORS = {
gray: '#787B86',
red: '#F23645',
orange: '#FF9800',
green: '#4CAF50',
teal: '#089981',
cyan: '#00BCD4',
blue: '#2962FF',
pink: '#E91E63',
purple: '#9C27B0',
};
const DEFAULT_LEVELS: FibonacciRetracementLevelSettings[] = [
{ id: '0', value: 0, visible: true, color: FIBONACCI_LEVEL_COLORS.gray },
{ id: '0.236', value: 0.236, visible: true, color: FIBONACCI_LEVEL_COLORS.red },
{ id: '0.382', value: 0.382, visible: true, color: FIBONACCI_LEVEL_COLORS.orange },
{ id: '0.5', value: 0.5, visible: true, color: FIBONACCI_LEVEL_COLORS.green },
{ id: '0.618', value: 0.618, visible: true, color: FIBONACCI_LEVEL_COLORS.teal },
{ id: '0.786', value: 0.786, visible: true, color: FIBONACCI_LEVEL_COLORS.cyan },
{ id: '1', value: 1, visible: true, color: FIBONACCI_LEVEL_COLORS.gray },
{ id: '1.618', value: 1.618, visible: true, color: FIBONACCI_LEVEL_COLORS.blue },
{ id: '2.618', value: 2.618, visible: true, color: FIBONACCI_LEVEL_COLORS.pink },
{ id: '3.618', value: 3.618, visible: true, color: FIBONACCI_LEVEL_COLORS.purple },
{ id: '4.236', value: 4.236, visible: true, color: FIBONACCI_LEVEL_COLORS.red },
];
export function createDefaultSettings(): FibonacciRetracementSettings {
return {
levels: cloneDeep(DEFAULT_LEVELS),
showBackground: true,
backgroundOpacity: 0.18,
reverse: false,
labelsPosition: 'left',
showPrices: true,
showLevelValues: true,
fontSize: 12,
isBold: false,
isItalic: false,
};
}
export function cloneFibonacciRetracementSettings(
settings: FibonacciRetracementSettings,
): FibonacciRetracementSettings {
return cloneDeep(settings);
}
export function mergeFibonacciRetracementSettings(
current: FibonacciRetracementSettings,
next: Partial<FibonacciRetracementSettings> | SettingsValues,
): FibonacciRetracementSettings {
const patch = next as Partial<FibonacciRetracementSettings>;
const values = next as SettingsValues;
const settings: FibonacciRetracementSettings = {
...current,
showBackground: patch.showBackground ?? current.showBackground,
backgroundOpacity: patch.backgroundOpacity ?? current.backgroundOpacity,
reverse: patch.reverse ?? current.reverse,
labelsPosition: patch.labelsPosition ?? current.labelsPosition,
showPrices: patch.showPrices ?? current.showPrices,
showLevelValues: patch.showLevelValues ?? current.showLevelValues,
fontSize: patch.fontSize ?? current.fontSize,
isBold: patch.isBold ?? current.isBold,
isItalic: patch.isItalic ?? current.isItalic,
levels: mergeLevels(current.levels, patch.levels),
};
settings.levels = settings.levels.map((level) => ({
...level,
visible: (values[getLevelVisibleFieldKey(level.id)] as boolean | undefined) ?? level.visible,
color: (values[getLevelColorFieldKey(level.id)] as string | undefined) ?? level.color,
}));
return settings;
}
export function getFibonacciRetracementSettingsValues(settings: FibonacciRetracementSettings): SettingsValues {
return {
showBackground: settings.showBackground,
backgroundOpacity: settings.backgroundOpacity,
reverse: settings.reverse,
labelsPosition: settings.labelsPosition,
showPrices: settings.showPrices,
showLevelValues: settings.showLevelValues,
fontSize: settings.fontSize,
isBold: settings.isBold,
isItalic: settings.isItalic,
...settings.levels.reduce<SettingsValues>((result, level) => {
result[getLevelVisibleFieldKey(level.id)] = level.visible;
result[getLevelColorFieldKey(level.id)] = level.color;
return result;
}, {}),
};
}
export function getFibonacciRetracementSettingsTabs(settings: FibonacciRetracementSettings): SettingsTab[] {
const styleFields: SettingField[] = [
{
key: 'backgroundOpacity',
label: 'Прозрачность фона',
type: 'range',
defaultValue: settings.backgroundOpacity,
min: 0,
max: 1,
},
{
key: 'showBackground',
label: 'Фон',
type: 'boolean',
defaultValue: settings.showBackground,
},
{
key: 'reverse',
label: 'Переворот',
type: 'boolean',
defaultValue: settings.reverse,
},
];
const textFields: SettingField[] = [
{
key: 'labelsPosition',
label: 'Положение меток',
type: 'select',
defaultValue: settings.labelsPosition,
options: [
{ label: 'Слева', value: 'left' },
{ label: 'Справа', value: 'right' },
],
},
{
key: 'showPrices',
label: 'Отображать цену',
type: 'boolean',
defaultValue: settings.showPrices,
},
{
key: 'showLevelValues',
label: 'Отображать уровень',
type: 'boolean',
defaultValue: settings.showLevelValues,
},
{
key: 'fontSize',
label: 'Размер текста',
type: 'number',
defaultValue: settings.fontSize,
min: 8,
max: 24,
},
{
key: 'isBold',
label: 'Жирный',
type: 'boolean',
defaultValue: settings.isBold,
},
{
key: 'isItalic',
label: 'Курсив',
type: 'boolean',
defaultValue: settings.isItalic,
},
];
const levelFields: SettingField[] = settings.levels.flatMap((level) => [
{
key: getLevelVisibleFieldKey(level.id),
label: formatLevelLabel(level.value),
type: 'boolean',
defaultValue: level.visible,
},
{
key: getLevelColorFieldKey(level.id),
label: `Цвет ${formatLevelLabel(level.value)}`,
type: 'color',
defaultValue: level.color,
},
]);
return [
{
key: 'style',
label: 'Стиль',
fields: styleFields,
},
{
key: 'text',
label: 'Текст',
fields: textFields,
},
{
key: 'levels',
label: 'Уровни',
fields: levelFields,
},
];
}
export function getVisibleFibonacciLevels(
settings: FibonacciRetracementSettings,
): FibonacciRetracementLevelSettings[] {
return settings.levels.filter((level) => level.visible);
}
export function formatLevelLabel(value: number): string {
return String(Number(value.toFixed(3))).replace('.', ',');
}
function mergeLevels(
currentLevels: FibonacciRetracementLevelSettings[],
nextLevels?: FibonacciRetracementLevelSettings[],
): FibonacciRetracementLevelSettings[] {
if (!nextLevels) {
return cloneDeep(currentLevels);
}
const nextLevelsMap = new Map(nextLevels.map((level) => [level.id, level]));
return currentLevels.map((level) => ({
...level,
...nextLevelsMap.get(level.id),
}));
}
function getLevelVisibleFieldKey(id: string): string {
return `level_${normalizeLevelId(id)}_visible`;
}
function getLevelColorFieldKey(id: string): string {
return `level_${normalizeLevelId(id)}_color`;
}
function normalizeLevelId(id: string): string {
return id.replace(/\./g, '_');
}
import { Observable, Subscription } from 'rxjs';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
clamp,
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getPointerPoint as getPointerPointFromEvent,
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isNearPoint,
isPointInBounds,
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 { FibonacciRetracementPaneView } from './paneView';
import {
cloneFibonacciRetracementSettings,
createDefaultSettings,
formatLevelLabel,
getFibonacciRetracementSettingsTabs,
getFibonacciRetracementSettingsValues,
getVisibleFibonacciLevels,
mergeFibonacciRetracementSettings,
FibonacciRetracementSettings,
} from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { AxisLabel, AxisSegment, Bounds, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import type {
AutoscaleInfo,
IChartApi,
IPrimitivePaneView,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
Time,
UTCTimestamp,
} from 'lightweight-charts';
type FibonacciRetracementMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type FibonacciRetracementHandle = 'body' | 'start' | 'end' | null;
type FibonacciRetracementHandleKey = Exclude<FibonacciRetracementHandle, 'body' | null>;
type TimeLabelKind = 'start' | 'end';
interface FibonacciRetracementParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
interface FibonacciRetracementState {
hidden: boolean;
isActive: boolean;
mode: FibonacciRetracementMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: FibonacciRetracementSettings;
}
export interface FibonacciRetracementLevelRenderData {
id: string;
value: number;
price: number;
y: number;
color: string;
text: string;
}
export interface FibonacciRetracementAreaRenderData {
top: number;
bottom: number;
color: string;
}
interface FibonacciRetracementGeometry {
startPoint: Point;
endPoint: Point;
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
levels: FibonacciRetracementLevelRenderData[];
areas: FibonacciRetracementAreaRenderData[];
handles: Record<FibonacciRetracementHandleKey, Point>;
}
type FibonacciRetracementRenderSettings = Omit<FibonacciRetracementSettings, 'levels'>;
export interface FibonacciRetracementRenderData
extends FibonacciRetracementGeometry,
FibonacciRetracementRenderSettings {
showHandles: boolean;
}
const HANDLE_HIT_TOLERANCE = 8;
const BODY_HIT_TOLERANCE = 6;
const LINE_HIT_TOLERANCE = 6;
const MIN_DISTANCE = 6;
export class FibonacciRetracement implements ISeriesDrawing {
private chart: IChartApi;
private series: SeriesApi;
private container: HTMLElement;
private removeSelf?: () => void;
private openSettings?: () => void;
private settings: FibonacciRetracementSettings = createDefaultSettings();
private requestUpdate: (() => void) | null = null;
private isBound = false;
private subscriptions = new Subscription();
private hidden = false;
private isActive = false;
private mode: FibonacciRetracementMode = 'idle';
private startTime: Time | null = null;
private endTime: Time | null = null;
private startPrice: number | null = null;
private endPrice: number | 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 displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: FibonacciRetracementPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly startTimeAxisView: CustomTimeAxisView;
private readonly endTimeAxisView: CustomTimeAxisView;
private readonly priceAxisViewsList: CustomPriceAxisView[];
constructor(
chart: IChartApi,
series: SeriesApi,
{ container, formatObservable, removeSelf, openSettings }: FibonacciRetracementParams,
) {
this.chart = chart;
this.series = series;
this.container = container;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new FibonacciRetracementPaneView(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.priceAxisViewsList = createDefaultSettings().levels.map(
(level) =>
new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: level.id,
}),
);
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.render();
}
public destroy(): void {
this.unbindEvents();
this.subscriptions.unsubscribe();
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,
startTime: this.startTime,
endTime: this.endTime,
startPrice: this.startPrice,
endPrice: this.endPrice,
settings: cloneFibonacciRetracementSettings(this.settings),
};
}
public setState(state: unknown): void {
const next = state as Partial<FibonacciRetracementState>;
this.hidden = next.hidden ?? this.hidden;
this.isActive = next.isActive ?? this.isActive;
this.mode = next.mode ?? this.mode;
this.startTime = next.startTime ?? this.startTime;
this.endTime = next.endTime ?? this.endTime;
this.startPrice = next.startPrice ?? this.startPrice;
this.endPrice = next.endPrice ?? this.endPrice;
if (next.settings) {
this.settings = mergeFibonacciRetracementSettings(createDefaultSettings(), next.settings);
}
this.render();
}
public getSettings(): SettingsValues {
return getFibonacciRetracementSettingsValues(this.settings);
}
public getSettingsTabs(): SettingsTab[] {
return getFibonacciRetracementSettingsTabs(this.settings);
}
public updateSettings(settings: SettingsValues): void {
this.settings = mergeFibonacciRetracementSettings(this.settings, settings);
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,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
...this.priceAxisViewsList,
]);
}
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.priceAxisViewsList;
}
public autoscaleInfo(): AutoscaleInfo | null {
return null;
}
public getRenderData(): FibonacciRetracementRenderData | null {
const geometry = this.hidden ? null : this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showHandles: this.isActive,
showBackground: this.settings.showBackground,
backgroundOpacity: this.settings.backgroundOpacity,
reverse: this.settings.reverse,
labelsPosition: this.settings.labelsPosition,
showPrices: this.settings.showPrices,
showLevelValues: this.settings.showLevelValues,
fontSize: this.settings.fontSize,
isBold: this.settings.isBold,
isItalic: this.settings.isItalic,
};
}
public getTimeAxisSegments(): AxisSegment[] {
const bounds = this.isActive ? this.getTimeBounds() : null;
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
public getPriceAxisSegments(): AxisSegment[] {
const bounds = this.isActive ? this.getPriceBounds() : null;
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.top,
to: bounds.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
public getTimeAxisLabel(kind: string): AxisLabel | null {
const coordinate = this.isActive ? this.getTimeCoordinate(kind as TimeLabelKind) : null;
const text = this.getTimeText(kind as TimeLabelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
public getPriceAxisLabel(kind: string): AxisLabel | null {
const level = this.isActive && this.settings.showPrices ? this.getLevelById(kind) : null;
if (!level) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate: level.y,
text: formatPrice(level.price) ?? '',
textColor: colors.chartPriceLineText,
backgroundColor: level.color,
};
}
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 && !this.containsPoint(point)) {
return null;
}
const handleTarget = this.isActive ? this.getHandleTarget(point) : null;
if (handleTarget) {
return {
cursorStyle: 'pointer',
externalId: 'fibonacci-retracement-position',
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: this.isActive ? 'grab' : 'pointer',
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 => {
const point = this.getMousePoint(event);
if (this.hidden || this.mode !== 'ready' || (!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) {
this.isActive = this.containsPoint(point);
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);
} else {
this.resize(point);
}
this.render();
};
private handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.mode = 'ready';
this.clearInteractionState();
this.render();
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(point);
if (!anchor) {
return;
}
this.startTime = anchor.time;
this.endTime = anchor.time;
this.startPrice = anchor.price;
this.endPrice = anchor.price;
this.isActive = true;
this.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.endTime = anchor.time;
this.endPrice = anchor.price;
this.render();
}
private finishDrawing(): void {
const geometry = this.getGeometry();
if (!geometry || geometry.width < MIN_DISTANCE || Math.abs(geometry.startPoint.y - geometry.endPoint.y) < MIN_DISTANCE) {
this.removeSelf?.();
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 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.startTime = null;
this.endTime = null;
this.startPrice = null;
this.endPrice = null;
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<FibonacciRetracementHandle, null> | null {
return this.getHandleTarget(point) ?? (this.containsPoint(point) ? 'body' : null);
}
private moveWhole(point: Point): void {
const snapshot = this.dragStateSnapshot;
const geometry = this.dragGeometrySnapshot;
if (!snapshot || !geometry || !this.dragStartPoint) {
return;
}
if (
snapshot.startTime === null ||
snapshot.endTime === null ||
snapshot.startPrice === null ||
snapshot.endPrice === null
) {
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.startTime, clampedOffsetX);
const nextEndTime = this.shiftTime(snapshot.endTime, clampedOffsetX);
if (nextStartTime === null || nextEndTime === null) {
return;
}
const priceOffset = this.getPriceDelta(this.dragStartPoint.y, this.dragStartPoint.y + clampedOffsetY);
this.startTime = nextStartTime;
this.endTime = nextEndTime;
this.startPrice = snapshot.startPrice + priceOffset;
this.endPrice = snapshot.endPrice + 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.startTime = anchor.time;
this.startPrice = anchor.price;
return;
}
this.endTime = anchor.time;
this.endPrice = anchor.price;
}
private createAnchor(point: Point): { time: Time; price: number } | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
private getGeometry(): FibonacciRetracementGeometry | null {
if (this.startTime === null || this.endTime === null || this.startPrice === null || this.endPrice === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
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.round(Math.min(startPoint.x, endPoint.x));
const right = Math.round(Math.max(startPoint.x, endPoint.x));
const levels = this.getLevels();
const top = Math.min(startPoint.y, endPoint.y, ...levels.map((level) => level.y));
const bottom = Math.max(startPoint.y, endPoint.y, ...levels.map((level) => level.y));
return {
startPoint,
endPoint,
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
levels,
areas: this.getAreas(levels),
handles: {
start: startPoint,
end: endPoint,
},
};
}
private getLevels(): FibonacciRetracementLevelRenderData[] {
if (this.startPrice === null || this.endPrice === null) {
return [];
}
return getVisibleFibonacciLevels(this.settings).reduce<FibonacciRetracementLevelRenderData[]>((result, level) => {
const price = this.getLevelPrice(level.value);
const y = getYCoordinateFromPrice(this.series, price);
if (y === null) {
return result;
}
result.push({
id: level.id,
value: level.value,
price,
y: Math.round(Number(y)),
color: level.color,
text: this.getLevelText(level.value, price),
});
return result;
}, []);
}
private getLevelPrice(value: number): number {
const startPrice = this.startPrice ?? 0;
const endPrice = this.endPrice ?? 0;
return this.settings.reverse
? startPrice + (endPrice - startPrice) * value
: endPrice + (startPrice - endPrice) * value;
}
private getAreas(levels: FibonacciRetracementLevelRenderData[]): FibonacciRetracementAreaRenderData[] {
if (!this.settings.showBackground || levels.length < 2) {
return [];
}
const sortedLevels = [...levels].sort((a, b) => a.y - b.y);
return sortedLevels.slice(0, -1).map((level, index) => {
const nextLevel = sortedLevels[index + 1];
return {
top: level.y,
bottom: nextLevel.y,
color: nextLevel.color,
};
});
}
private getLevelText(value: number, price: number): string {
const parts = [];
if (this.settings.showLevelValues) {
parts.push(formatLevelLabel(value));
}
if (this.settings.showPrices) {
parts.push(`(${formatPrice(price) ?? String(price)})`);
}
return parts.join(' ');
}
private getLevelById(id: string): FibonacciRetracementLevelRenderData | null {
return this.getGeometry()?.levels.find((level) => level.id === id) ?? null;
}
private getTimeBounds(): { left: number; right: number } | null {
const geometry = this.getGeometry();
return geometry ? { left: geometry.left, right: geometry.right } : null;
}
private getPriceBounds(): { top: number; bottom: number } | null {
const geometry = this.getGeometry();
return geometry ? { top: geometry.top, bottom: geometry.bottom } : null;
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'start' ? geometry.startPoint.x : geometry.endPoint.x;
}
private getTimeText(kind: TimeLabelKind): string {
const time = kind === 'start' ? this.startTime : this.endTime;
if (typeof time !== 'number') {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
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 bounds: Bounds = {
left: geometry.left,
right: geometry.right,
top: geometry.top,
bottom: geometry.bottom,
};
if (this.settings.showBackground && isPointInBounds(point, bounds, BODY_HIT_TOLERANCE)) {
return true;
}
const xInRange = point.x >= geometry.left - LINE_HIT_TOLERANCE && point.x <= geometry.right + LINE_HIT_TOLERANCE;
return xInRange && 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, this.series);
}
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 { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import { FibonacciRetracement } from './fibonacciRetracement';
const UI = {
lineWidth: 1,
handleSize: 10,
handleBorderWidth: 1,
textOffset: 4,
controlLineWidth: 1,
};
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();
if (data.showBackground) {
data.areas.forEach((area) => {
drawArea(context, {
left,
right,
top: area.top * verticalPixelRatio,
bottom: area.bottom * verticalPixelRatio,
color: area.color,
opacity: data.backgroundOpacity,
});
});
}
data.levels.forEach((level) => {
const y = level.y * verticalPixelRatio;
drawHorizontalLine(context, {
left,
right,
y,
color: level.color,
pixelRatio,
});
if (level.text) {
drawText(context, {
text: level.text,
x:
data.labelsPosition === 'left'
? left - UI.textOffset * horizontalPixelRatio
: right + UI.textOffset * horizontalPixelRatio,
y,
color: level.color,
fontSize: data.fontSize * pixelRatio,
isBold: data.isBold,
isItalic: data.isItalic,
align: data.labelsPosition === 'left' ? 'right' : 'left',
});
}
});
drawControlLine(context, {
startX: data.startPoint.x * horizontalPixelRatio,
startY: data.startPoint.y * verticalPixelRatio,
endX: data.endPoint.x * horizontalPixelRatio,
endY: data.endPoint.y * verticalPixelRatio,
color: colors.chartCrosshairLine,
pixelRatio,
});
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 drawArea(
context: CanvasRenderingContext2D,
params: {
left: number;
right: number;
top: number;
bottom: number;
color: string;
opacity: number;
},
): void {
const { left, right, top, bottom, color, opacity } = params;
if (bottom <= top) {
return;
}
context.save();
context.globalAlpha = opacity;
context.fillStyle = color;
context.fillRect(left, top, right - left, bottom - top);
context.restore();
}
function drawHorizontalLine(
context: CanvasRenderingContext2D,
params: {
left: number;
right: number;
y: number;
color: string;
pixelRatio: number;
},
): void {
const { left, right, y, color, pixelRatio } = params;
context.save();
context.strokeStyle = color;
context.lineWidth = UI.lineWidth * pixelRatio;
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
context.restore();
}
function drawControlLine(
context: CanvasRenderingContext2D,
params: {
startX: number;
startY: number;
endX: number;
endY: number;
color: string;
pixelRatio: number;
},
): void {
const { startX, startY, endX, endY, color, pixelRatio } = params;
context.save();
context.strokeStyle = color;
context.lineWidth = UI.controlLineWidth * pixelRatio;
context.setLineDash([6 * pixelRatio, 6 * pixelRatio]);
context.beginPath();
context.moveTo(startX, startY);
context.lineTo(endX, endY);
context.stroke();
context.restore();
}
function drawText(
context: CanvasRenderingContext2D,
params: {
text: string;
x: number;
y: number;
color: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
align: CanvasTextAlign;
},
): void {
const { text, x, y, color, fontSize, isBold, isItalic, align } = params;
const fontWeight = isBold ? '700 ' : '';
const fontStyle = isItalic ? 'italic ' : '';
context.save();
context.font = `${fontStyle}${fontWeight}${fontSize}px Inter, sans-serif`;
context.fillStyle = color;
context.textAlign = align;
context.textBaseline = 'middle';
context.fillText(text, x, y);
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();
}