Загрузка данных
import { clamp } from 'lodash-es';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getPriceDelta as getPriceDeltaFromCoordinates,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
shiftTimeByPixels,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
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,
FibonacciRetracementSettings,
formatLevelLabel,
getFibonacciRetracementSettingsTabs,
getFibonacciRetracementSettingsValues,
getVisibleFibonacciLevels,
mergeFibonacciRetracementSettings,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { AxisLabel, AxisSegment, Bounds, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel, SettingsTab, SettingsValues } from '@src/types';
import type { IPrimitivePaneView, PrimitiveHoveredItem, 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';
type PriceLabelKind = 'top' | 'bottom';
type FibonacciRetracementParams = BaseDrawingParams;
interface FibonacciRetracementState {
hidden: 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[];
}
type FibonacciRetracementRenderSettings = Omit<FibonacciRetracementSettings, 'levels' | 'backgroundOpacity'>;
export interface FibonacciRetracementRenderData
extends FibonacciRetracementGeometry,
FibonacciRetracementRenderSettings {
backgroundOpacity: number;
}
const BODY_HIT_TOLERANCE = 6;
const LINE_HIT_TOLERANCE = 6;
const MIN_DISTANCE = 6;
const PERCENT_DIVIDER = 100;
export class FibonacciRetracement
extends SeriesDrawingBase<FibonacciRetracementSettings, FibonacciRetracementHandleKey>
implements ISeriesDrawing
{
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: FibonacciRetracementSettings = createDefaultSettings();
protected 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 topPriceAxisView: CustomPriceAxisView;
private readonly bottomPriceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
container,
interaction,
formatObservable,
removeSelf,
openSettings,
initialEvent,
}: FibonacciRetracementParams) {
super({ chart, series, container, interaction });
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.topPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'top',
});
this.bottomPriceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'bottom',
});
if (formatObservable) {
this.subscriptions.add(
formatObservable.subscribe((format) => {
this.displayFormat = format;
this.render();
}),
);
}
this.series.attachPrimitive(this);
if (initialEvent && initialEvent.sourceEvent) {
const point = this.getEventPoint(initialEvent.sourceEvent);
this.startDrawing(point);
}
}
public isCreationPending(): boolean {
return this.mode === 'idle' || this.mode === 'drawing';
}
public getState(): FibonacciRetracementState {
return {
hidden: this.hidden,
mode: this.mode,
startTime: this.startTime,
endTime: this.endTime,
startPrice: this.startPrice,
endPrice: this.endPrice,
settings: cloneFibonacciRetracementSettings(this.settings),
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const next = state as Partial<FibonacciRetracementState>;
this.hidden = next.hidden ?? this.hidden;
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 updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.startTimeAxisView,
this.endTimeAxisView,
this.topPriceAxisView,
this.bottomPriceAxisView,
]);
}
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.topPriceAxisView, this.bottomPriceAxisView];
}
public getRenderData(): FibonacciRetracementRenderData | null {
const geometry = this.hidden ? null : this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
showBackground: this.settings.showBackground,
backgroundOpacity: this.settings.backgroundOpacity / PERCENT_DIVIDER,
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,
};
}
protected getDrawingHandles(): readonly DrawingHandle<FibonacciRetracementHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'end',
...geometry.endPoint,
},
{
id: 'start',
...geometry.startPoint,
},
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle' || this.mode === 'drawing') {
return null;
}
const point = { x, y };
if (!this.isSelected() && !this.containsPoint(point)) {
return null;
}
if (this.isSelected() && this.getDrawingHandleAtPoint(point)) {
return {
cursorStyle: 'pointer',
externalId: 'fibonacci-retracement-position',
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: this.isSelected() ? 'grab' : 'pointer',
externalId: 'fibonacci-retracement-position',
zOrder: 'top',
};
}
protected getTimeAxisSegments(): AxisSegment[] {
const bounds = this.isSelected() || this.isCreationPending() ? this.getTimeBounds() : null;
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
const bounds = this.isSelected() || this.isCreationPending() ? this.getPriceBounds() : null;
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.top,
to: bounds.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'start' && kind !== 'end')) {
return null;
}
const coordinate = this.getTimeCoordinate(kind);
const text = this.getTimeText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if ((!this.isSelected() && !this.isCreationPending()) || (kind !== 'top' && kind !== 'bottom')) {
return null;
}
const coordinate = this.getPriceCoordinate(kind);
const text = this.getPriceText(kind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.containsPoint(point) && !this.getDrawingHandleAtPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
protected handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.startDrawing(point);
return;
}
if (this.mode === 'drawing') {
event.preventDefault();
event.stopPropagation();
this.updateDrawing(point);
this.finishDrawing();
return;
}
if (this.mode !== 'ready') {
return;
}
if (!this.isSelected()) {
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
const dragTarget = this.getDragTarget(point);
if (!dragTarget) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId, dragTarget);
};
protected 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();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.mode = 'ready';
this.resolveReady?.();
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.mode = 'drawing';
this.render();
}
private updateDrawing(point: Point): void {
const anchor = this.createAnchor(clampPointToContainerInElement(point, this.container));
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
) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
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.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.getDrawingHandleAtPoint(point)?.id ?? (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 = getElementContainerSize(this.container);
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 = shiftTimeByPixels(this.chart, snapshot.startTime, clampedOffsetX, this.series);
const nextEndTime = shiftTimeByPixels(this.chart, snapshot.endTime, clampedOffsetX, this.series);
if (nextStartTime === null || nextEndTime === null) {
return;
}
const priceOffset = getPriceDeltaFromCoordinates(
this.series,
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(clampPointToContainerInElement(point, this.container));
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);
}
protected 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),
};
}
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 orderedLevels = [...levels].sort((a, b) => a.value - b.value);
return orderedLevels.slice(0, -1).map((level, index) => {
const nextLevel = orderedLevels[index + 1];
return {
top: Math.min(level.y, nextLevel.y),
bottom: Math.max(level.y, nextLevel.y),
color: nextLevel.color,
};
});
}
private getLevelText(value: number, price: number): string {
const parts: string[] = [];
if (this.settings.showLevelValues) {
parts.push(formatLevelLabel(value));
}
if (this.settings.showPrices) {
parts.push(`(${formatPrice(price) ?? String(price)})`);
}
return parts.join(' ');
}
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 getPriceCoordinate(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'top' ? geometry.top : geometry.bottom;
}
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 getPriceText(kind: PriceLabelKind): string {
const price = this.getPriceValueForLabel(kind);
return price === null ? '' : (formatPrice(price) ?? '');
}
private getPriceValueForLabel(kind: PriceLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const targetY = kind === 'top' ? geometry.top : geometry.bottom;
const edgeLevel = geometry.levels.find((level) => level.y === targetY);
if (edgeLevel) {
return edgeLevel.price;
}
return kind === 'top'
? Math.max(this.startPrice ?? 0, this.endPrice ?? 0)
: Math.min(this.startPrice ?? 0, this.endPrice ?? 0);
}
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);
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { drawRoundedRect } from '@core/Drawings/utils';
import { Direction } from '@src/types';
import type { Ruler } from './ruler';
import type { Bounds, Point } from '@core/Drawings/types';
const UI = {
lineWidth: 2,
arrowSize: 10,
infoFont: '12px Inter, sans-serif',
infoPadding: 4,
infoOffset: 8,
infoRadius: 2,
infoLineHeight: 14,
infoGap: 2,
};
export class RulerPaneRenderer implements IPrimitivePaneRenderer {
private readonly ruler: Ruler;
constructor(ruler: Ruler) {
this.ruler = ruler;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.ruler.getRenderData();
if (data.hidden || !data.startPoint || !data.endPoint) {
return;
}
const bounds = getBounds(data.startPoint, data.endPoint);
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const left = bounds.left * horizontalPixelRatio;
const right = bounds.right * horizontalPixelRatio;
const top = bounds.top * verticalPixelRatio;
const bottom = bounds.bottom * verticalPixelRatio;
const centerX = (left + right) / 2;
const centerY = (top + bottom) / 2;
context.save();
context.fillStyle = data.fillColor;
context.fillRect(left, top, right - left, bottom - top);
context.lineWidth = UI.lineWidth * pixelRatio;
context.strokeStyle = data.lineColor;
drawHorizontalArrow(context, left, right, centerY, UI.arrowSize * pixelRatio, data.horizontalArrowSide);
drawVerticalArrow(context, centerX, top, bottom, UI.arrowSize * pixelRatio, data.verticalArrowSide);
drawInfoBox(
context,
centerX,
top - UI.infoOffset * pixelRatio,
data.infoLines,
data.fillColor,
data.textColor,
pixelRatio,
verticalPixelRatio,
);
context.restore();
});
}
}
function getBounds(startPoint: Point, endPoint: Point): Bounds {
return {
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),
};
}
function drawHorizontalArrow(
context: CanvasRenderingContext2D,
left: number,
right: number,
y: number,
size: number,
side: Direction.Left | Direction.Right | null,
): void {
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
if (!side) {
return;
}
context.beginPath();
if (side === Direction.Left) {
context.moveTo(left, y);
context.lineTo(left + size, y - size);
context.moveTo(left, y);
context.lineTo(left + size, y + size);
}
if (side === Direction.Right) {
context.moveTo(right, y);
context.lineTo(right - size, y - size);
context.moveTo(right, y);
context.lineTo(right - size, y + size);
}
context.stroke();
}
function drawVerticalArrow(
context: CanvasRenderingContext2D,
x: number,
top: number,
bottom: number,
size: number,
side: Direction.Top | Direction.Bottom | null,
): void {
context.beginPath();
context.moveTo(x, top);
context.lineTo(x, bottom);
context.stroke();
if (!side) {
return;
}
context.beginPath();
if (side === Direction.Top) {
context.moveTo(x, top);
context.lineTo(x - size, top + size);
context.moveTo(x, top);
context.lineTo(x + size, top + size);
}
if (side === Direction.Bottom) {
context.moveTo(x, bottom);
context.lineTo(x - size, bottom - size);
context.moveTo(x, bottom);
context.lineTo(x + size, bottom - size);
}
context.stroke();
}
function drawInfoBox(
context: CanvasRenderingContext2D,
centerX: number,
topY: number,
lines: readonly string[],
fillColor: string,
textColor: string,
pixelRatio: number,
verticalPixelRatio: number,
): void {
context.save();
context.font = UI.infoFont;
context.textAlign = 'center';
const padding = UI.infoPadding * pixelRatio;
const lineHeight = UI.infoLineHeight * verticalPixelRatio;
const gap = UI.infoGap * verticalPixelRatio;
let maxWidth = 0;
for (const line of lines) {
maxWidth = Math.max(maxWidth, context.measureText(line).width);
}
const boxWidth = maxWidth + padding * 2;
const boxHeight = lines.length * lineHeight + (lines.length - 1) * gap + padding * 2;
const boxX = centerX - boxWidth / 2;
const boxY = topY - boxHeight;
context.fillStyle = fillColor;
context.beginPath();
drawRoundedRect(context, boxX, boxY, boxWidth, boxHeight, UI.infoRadius * pixelRatio);
context.fill();
context.fillStyle = textColor;
let textY = boxY + padding + lineHeight * 0.8;
for (const line of lines) {
context.fillText(line, centerX, textY);
textY += lineHeight + gap;
}
context.restore();
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { drawRoundedRect } from '@core/Drawings/utils';
import type { SliderPositionTextStyle } from './settings';
import type { SliderPosition } from './sliderPosition';
const UI = {
lineWidth: 1,
lineHeightMultiplier: 1.2,
mainBoxHeight: 28,
sideBoxHeight: 16,
padding: 4,
boxRadius: 4,
labelOffset: 10,
};
export class SliderPaneRenderer implements IPrimitivePaneRenderer {
private readonly slider: SliderPosition;
constructor(slider: SliderPosition) {
this.slider = slider;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.slider.getRenderData();
if (!data) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio, bitmapSize }) => {
const left = data.leftX * horizontalPixelRatio;
const right = data.rightX * horizontalPixelRatio;
const entryY = data.entryY * verticalPixelRatio;
const stopY = data.stopY * verticalPixelRatio;
const targetY = data.targetY * verticalPixelRatio;
const profitTop = data.profitTop * verticalPixelRatio;
const profitBottom = data.profitBottom * verticalPixelRatio;
const lossTop = data.lossTop * verticalPixelRatio;
const lossBottom = data.lossBottom * verticalPixelRatio;
const centerX = (left + right) / 2;
const labelOffsetPx = UI.labelOffset * verticalPixelRatio;
const targetBoxHeight = getTextBoxHeight(data.targetText, data, UI.sideBoxHeight, verticalPixelRatio);
const centerBoxHeight = getTextBoxHeight(data.centerText, data, UI.mainBoxHeight, verticalPixelRatio);
const stopBoxHeight = getTextBoxHeight(data.stopText, data, UI.sideBoxHeight, verticalPixelRatio);
const targetLabelCenterY = getOuterLabelCenterY(
targetY,
data.targetLabelDirection,
targetBoxHeight,
labelOffsetPx,
);
const stopLabelCenterY = getOuterLabelCenterY(stopY, data.stopLabelDirection, stopBoxHeight, labelOffsetPx);
const centerLabelCenterY = getCenterLabelCenterY(
entryY,
targetY,
stopY,
centerBoxHeight,
labelOffsetPx,
bitmapSize.height,
);
context.save();
context.fillStyle = data.positiveFillColor;
context.fillRect(left, profitTop, right - left, profitBottom - profitTop);
context.fillStyle = data.negativeFillColor;
context.fillRect(left, lossTop, right - left, lossBottom - lossTop);
context.lineWidth = UI.lineWidth * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.strokeStyle = data.lineColor;
drawHorizontalLine(context, left, right, entryY);
if (data.showLabels) {
drawTextBox(
context,
centerX,
targetLabelCenterY,
data.targetText,
data.positiveFillColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.sideBoxHeight,
);
drawTextBox(
context,
centerX,
centerLabelCenterY,
data.centerText,
data.centerBoxColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.mainBoxHeight,
);
drawTextBox(
context,
centerX,
stopLabelCenterY,
data.stopText,
data.negativeFillColor,
data,
horizontalPixelRatio,
verticalPixelRatio,
UI.sideBoxHeight,
);
}
context.restore();
});
}
}
function getOuterLabelCenterY(
coordinate: number,
direction: 'up' | 'down',
boxHeight: number,
labelOffset: number,
): number {
const offset = labelOffset + boxHeight / 2;
return direction === 'up' ? coordinate - offset : coordinate + offset;
}
function getCenterLabelCenterY(
entryY: number,
targetY: number,
stopY: number,
boxHeight: number,
labelOffset: number,
viewportHeight: number,
): number {
const targetSpace = Math.abs(targetY - entryY);
const stopSpace = Math.abs(stopY - entryY);
let direction: -1 | 1;
if (targetSpace < stopSpace) {
direction = stopY < entryY ? -1 : 1;
} else {
direction = targetY < entryY ? -1 : 1;
}
return clampTextBoxCenter(entryY + direction * (labelOffset + boxHeight / 2), boxHeight, viewportHeight);
}
function clampTextBoxCenter(coordinate: number, boxHeight: number, viewportHeight: number): number {
if (viewportHeight <= boxHeight) {
return viewportHeight / 2;
}
const halfHeight = boxHeight / 2;
return Math.max(halfHeight, Math.min(coordinate, viewportHeight - halfHeight));
}
function drawHorizontalLine(context: CanvasRenderingContext2D, left: number, right: number, y: number): void {
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
}
function drawTextBox(
context: CanvasRenderingContext2D,
centerX: number,
centerY: number,
text: string,
fillColor: string,
textStyle: SliderPositionTextStyle,
horizontalPixelRatio: number,
verticalPixelRatio: number,
fixedHeight: number,
): void {
context.save();
const lines = text.split('\n');
const fontSize = textStyle.fontSize * verticalPixelRatio;
const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
const paddingX = UI.padding * horizontalPixelRatio;
const boxHeight = getTextBoxHeight(text, textStyle, fixedHeight, verticalPixelRatio);
const radius = UI.boxRadius * Math.max(horizontalPixelRatio, verticalPixelRatio);
context.font = getTextFont(textStyle, fontSize);
context.textAlign = 'center';
context.textBaseline = 'middle';
let maxTextWidth = 0;
for (const line of lines) {
maxTextWidth = Math.max(maxTextWidth, context.measureText(line).width);
}
const width = maxTextWidth + paddingX * 2;
const x = centerX - width / 2;
const y = centerY - boxHeight / 2;
context.fillStyle = fillColor;
context.beginPath();
drawRoundedRect(context, x, y, width, boxHeight, radius);
context.fill();
context.fillStyle = textStyle.textColor;
if (lines.length === 1) {
context.fillText(lines[0], centerX, centerY);
context.restore();
return;
}
const textBlockHeight = lines.length * lineHeight;
const firstLineCenterY = centerY - textBlockHeight / 2 + lineHeight / 2;
lines.forEach((line, index) => {
context.fillText(line, centerX, firstLineCenterY + index * lineHeight);
});
context.restore();
}
function getTextBoxHeight(
text: string,
textStyle: SliderPositionTextStyle,
fixedHeight: number,
verticalPixelRatio: number,
): number {
const linesCount = text.split('\n').length;
const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
const paddingY = UI.padding * verticalPixelRatio;
const minBoxHeight = fixedHeight * verticalPixelRatio;
return Math.max(minBoxHeight, linesCount * lineHeight + paddingY * 2);
}
function getTextFont(style: SliderPositionTextStyle, fontSize: number): string {
const italic = style.isItalic ? 'italic ' : '';
const bold = style.isBold ? '700 ' : '';
return `${italic}${bold}${fontSize}px Inter, sans-serif`;
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { drawRoundedRect } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { Text } from './text';
const UI = {
borderWidth: 1,
borderRadius: 4,
padding: 6,
selectionBorderWidth: 1,
};
export class TextPaneRenderer implements IPrimitivePaneRenderer {
private readonly text: Text;
constructor(text: Text) {
this.text = text;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.text.getRenderData();
if (!data) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const left = data.left * horizontalPixelRatio;
const top = data.top * verticalPixelRatio;
const width = data.width * horizontalPixelRatio;
const height = data.height * verticalPixelRatio;
const paddingX = UI.padding * horizontalPixelRatio;
const paddingY = UI.padding * verticalPixelRatio;
const borderRadius = UI.borderRadius * pixelRatio;
context.save();
context.fillStyle = data.backgroundColor;
fillRoundedRect(context, left, top, width, height, borderRadius);
context.strokeStyle = data.borderColor;
context.lineWidth = UI.borderWidth * pixelRatio;
strokeRoundedRect(context, left, top, width, height, borderRadius);
context.save();
context.beginPath();
context.rect(left, top, width, height);
context.clip();
context.font = data.font;
context.fillStyle = data.textColor;
context.textAlign = 'left';
context.textBaseline = 'top';
data.lines.forEach((line, index) => {
context.fillText(line, left + paddingX, top + paddingY + index * data.lineHeight * verticalPixelRatio);
});
context.restore();
if (data.showSelectionBorder) {
drawSelectionBorder(context, left, top, width, height, borderRadius, pixelRatio);
}
context.restore();
});
}
}
function drawSelectionBorder(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
pixelRatio: number,
): void {
const { colors } = getThemeStore();
context.save();
context.strokeStyle = colors.chartLineColor;
context.lineWidth = UI.selectionBorderWidth * pixelRatio;
strokeRoundedRect(context, x, y, width, height, radius);
context.restore();
}
function fillRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
context.beginPath();
drawRoundedRect(context, x, y, width, height, radius);
context.fill();
}
function strokeRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
context.beginPath();
drawRoundedRect(context, x, y, width, height, radius);
context.stroke();
}
import { isNearPoint } from '@core/Drawings/helpers';
import { drawRoundedRect } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import type { Point } from '@core/Drawings/types';
import type { CanvasRenderingTarget2D } from 'fancy-canvas';
import type {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitive,
SeriesAttachedParameter,
Time,
} from 'lightweight-charts';
export type DrawingHandleShape = 'circle' | 'rounded';
export interface DrawingHandle<TId extends string = string> {
id: TId;
x: number;
y: number;
shape?: DrawingHandleShape;
size?: number;
borderWidth?: number;
strokeColor?: string;
}
const DEFAULT_HANDLE_SIZE = 12;
const DEFAULT_HANDLE_BORDER_WIDTH = 2;
const HANDLE_HIT_TOLERANCE = 8;
const HANDLE_RADIUS = 2;
export class DrawingHandlesPrimitive<TId extends string = string> implements ISeriesPrimitive<Time> {
private chart: SeriesAttachedParameter<Time>['chart'] | null = null;
private series: SeriesAttachedParameter<Time>['series'] | null = null;
private readonly paneRenderer: IPrimitivePaneRenderer = {
draw: (target) => this.drawPane(target),
};
private readonly timeAxisRenderer: IPrimitivePaneRenderer = {
draw: (target) => this.drawTimeAxis(target),
};
private readonly paneViewsList: readonly IPrimitivePaneView[] = [
{
renderer: () => this.paneRenderer,
zOrder: () => 'top',
},
];
private readonly timeAxisPaneViewsList: readonly IPrimitivePaneView[] = [
{
renderer: () => this.timeAxisRenderer,
zOrder: () => 'top',
},
];
constructor(private readonly getHandles: () => readonly DrawingHandle<TId>[]) {}
public attached({ chart, series }: SeriesAttachedParameter<Time>): void {
this.chart = chart;
this.series = series;
}
public detached(): void {
this.chart = null;
this.series = null;
}
public paneViews(): readonly IPrimitivePaneView[] {
return this.paneViewsList;
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return this.timeAxisPaneViewsList;
}
public findHandle(point: Point): DrawingHandle<TId> | null {
const handles = this.getHandles();
for (let index = handles.length - 1; index >= 0; index -= 1) {
const handle = handles[index];
if (isNearPoint(point, handle.x, handle.y, getHitTolerance(handle))) {
return handle;
}
}
return null;
}
public isTimeAxisHit(point: Point): boolean {
if (!this.isTimeAxisAdjacent()) {
return false;
}
const paneHeight = this.getPaneHeight();
if (paneHeight <= 0 || point.y < paneHeight) {
return false;
}
const handle = this.findHandle(point);
return handle !== null && intersectsTimeAxis(handle, paneHeight);
}
private drawPane(target: CanvasRenderingTarget2D): void {
const handles = this.getHandles();
if (!handles.length) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
for (const handle of handles) {
drawHandle(context, handle, horizontalPixelRatio, verticalPixelRatio);
}
});
}
private drawTimeAxis(target: CanvasRenderingTarget2D): void {
if (!this.isTimeAxisAdjacent()) {
return;
}
const paneHeight = this.getPaneHeight();
if (paneHeight <= 0) {
return;
}
const handles = this.getHandles();
if (!handles.length) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio, bitmapSize }) => {
const axisHeight = bitmapSize.height / verticalPixelRatio;
for (const handle of handles) {
if (!intersectsTimeAxis(handle, paneHeight)) {
continue;
}
const axisY = handle.y - paneHeight;
const halfSize = getHandleSize(handle) / 2;
if (axisY + halfSize <= 0 || axisY - halfSize >= axisHeight) {
continue;
}
drawHandle(context, { ...handle, y: axisY }, horizontalPixelRatio, verticalPixelRatio);
}
});
}
private getPaneHeight(): number {
return this.series?.getPane().getHeight() ?? 0;
}
private isTimeAxisAdjacent(): boolean {
if (!this.chart || !this.series) {
return false;
}
return this.series.getPane().paneIndex() === this.chart.panes().length - 1;
}
}
function getHandleSize(handle: DrawingHandle): number {
return handle.size ?? DEFAULT_HANDLE_SIZE;
}
function getHitTolerance(handle: DrawingHandle): number {
return Math.max(HANDLE_HIT_TOLERANCE, getHandleSize(handle) / 2);
}
function intersectsTimeAxis(handle: DrawingHandle, paneHeight: number): boolean {
const halfSize = getHandleSize(handle) / 2;
return handle.y - halfSize < paneHeight && handle.y + halfSize > paneHeight;
}
function drawHandle(
context: CanvasRenderingContext2D,
handle: DrawingHandle,
horizontalPixelRatio: number,
verticalPixelRatio: number,
): void {
const size = getHandleSize(handle);
const shape = handle.shape ?? 'circle';
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const lineWidth = (handle.borderWidth ?? DEFAULT_HANDLE_BORDER_WIDTH) * pixelRatio;
const width = size * horizontalPixelRatio;
const height = size * verticalPixelRatio;
const x = handle.x * horizontalPixelRatio;
const y = handle.y * verticalPixelRatio;
const inset = lineWidth / 2;
const { colors } = getThemeStore();
context.save();
context.fillStyle = colors.chartBackground;
context.strokeStyle = handle.strokeColor ?? colors.chartLineColor;
context.lineWidth = lineWidth;
context.beginPath();
if (shape === 'circle') {
const radius = Math.max(Math.min(width, height) / 2 - inset, 0);
context.arc(x, y, radius, 0, Math.PI * 2);
} else {
const left = x - width / 2 + inset;
const top = y - height / 2 + inset;
const drawWidth = Math.max(width - lineWidth, 0);
const drawHeight = Math.max(height - lineWidth, 0);
drawRoundedRect(context, left, top, drawWidth, drawHeight, HANDLE_RADIUS * pixelRatio);
}
context.fill();
context.stroke();
context.restore();
}
import type { UpdatableView } from './types';
export function updateViews(views: readonly UpdatableView[]): void {
for (const view of views) {
view.update();
}
}
export function getOrderedSideValue<T>(
startValue: T | null,
endValue: T | null,
startCoordinate: number | null,
endCoordinate: number | null,
side: 'start' | 'end',
): T | null {
if (startValue === null || endValue === null) {
return null;
}
if (startCoordinate === null || endCoordinate === null) {
return side === 'start' ? startValue : endValue;
}
const startFirst = startCoordinate <= endCoordinate;
if (side === 'start') {
return startFirst ? startValue : endValue;
}
return startFirst ? endValue : startValue;
}
export function drawRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
const safeRadius = Math.min(radius, width / 2, height / 2);
context.moveTo(x + safeRadius, y);
context.arcTo(x + width, y, x + width, y + height, safeRadius);
context.arcTo(x + width, y + height, x, y + height, safeRadius);
context.arcTo(x, y + height, x, y, safeRadius);
context.arcTo(x, y, x + width, y, safeRadius);
context.closePath();
}