Загрузка данных
export interface SliderRenderData extends SliderGeometry, SliderPositionStyle, SliderPositionTextStyle {
targetText: string;
centerText: string;
stopText: string;
centerBoxColor: string;
targetLabelDirection: 'up' | 'down';
stopLabelDirection: 'up' | 'down';
showFill: boolean;
showHandles: boolean;
showLabels: boolean;
}
public getRenderData(): SliderRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
const reward = Math.abs(geometry.targetPrice - geometry.entryPrice);
const qty = reward > 0 ? this.amount / reward : 0;
const selectedPrice = this.getPriceAtTime(this.endTime);
const pnl =
selectedPrice === null
? 0
: this.side === 'long'
? (selectedPrice - geometry.entryPrice) * qty
: (geometry.entryPrice - selectedPrice) * qty;
const { colors } = getThemeStore();
return {
...geometry,
targetText: this.getTargetText(geometry),
centerText: this.getCenterText(qty, pnl),
stopText: this.getStopText(geometry),
centerBoxColor: pnl >= 0 ? colors.chartCandleUp : colors.chartCandleDown,
targetLabelDirection: this.side === 'long' ? 'up' : 'down',
stopLabelDirection: this.side === 'long' ? 'down' : 'up',
showFill: true,
showHandles: this.shouldShowHandles(),
showLabels: this.isSelected(),
...this.settings,
};
}
private moveEntry(snapshot: SliderPositionState, point: Point): void {
if (snapshot.stopPrice === null || snapshot.targetPrice === null) {
return;
}
const nextPrice = getPriceFromYCoordinate(this.series, point.y);
if (nextPrice === null) {
return;
}
const minPrice = Math.min(snapshot.stopPrice, snapshot.targetPrice);
const maxPrice = Math.max(snapshot.stopPrice, snapshot.targetPrice);
this.entryPrice = Math.max(minPrice, Math.min(nextPrice, maxPrice));
}
private moveStop(snapshot: SliderPositionState, point: Point): void {
if (snapshot.entryPrice === null) {
return;
}
const nextPrice = getPriceFromYCoordinate(this.series, point.y);
if (nextPrice === null) {
return;
}
this.stopPrice = this.normalizeStop(
snapshot.entryPrice,
this.clampPriceToRange(nextPrice),
);
}
private normalizeStop(entryPrice: number, rawPrice: number, minDistance = 0): number {
const distance = this.getAllowedMinimumDistance(entryPrice, 'stop', minDistance);
return this.side === 'long'
? Math.min(rawPrice, entryPrice - distance)
: Math.max(rawPrice, entryPrice + distance);
}
private normalizeTarget(entryPrice: number, rawPrice: number, minDistance = 0): number {
const distance = this.getAllowedMinimumDistance(entryPrice, 'target', minDistance);
return this.side === 'long'
? Math.max(rawPrice, entryPrice + distance)
: Math.min(rawPrice, entryPrice - distance);
}
private getCurrentRiskRewardRatio(): number {
if (this.entryPrice === null || this.stopPrice === null || this.targetPrice === null) {
return this.defaultRiskRewardRatio;
}
const risk = Math.abs(this.entryPrice - this.stopPrice);
const reward = Math.abs(this.targetPrice - this.entryPrice);
if (risk <= MIN_DISTANCE || reward <= MIN_DISTANCE) {
return 0;
}
return reward / risk;
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
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,
handleSize: 10,
handleRadius: 3,
handleBorderWidth: 1,
};
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 startX = data.startX * horizontalPixelRatio;
const endX = data.endX * horizontalPixelRatio;
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();
if (data.showFill) {
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.showHandles) {
const handleHeight = UI.handleSize * verticalPixelRatio;
const visibleEntryY = clampVisibleHandleCoordinate(
entryY,
handleHeight,
bitmapSize.height,
);
const visibleTargetY = clampVisibleHandleCoordinate(
targetY,
handleHeight,
bitmapSize.height,
);
const visibleStopY = clampVisibleHandleCoordinate(
stopY,
handleHeight,
bitmapSize.height,
);
drawHandle(
context,
startX,
visibleEntryY,
horizontalPixelRatio,
verticalPixelRatio,
'circle',
);
drawHandle(
context,
endX,
visibleEntryY,
horizontalPixelRatio,
verticalPixelRatio,
'rounded',
);
drawHandle(
context,
startX,
visibleTargetY,
horizontalPixelRatio,
verticalPixelRatio,
'rounded',
);
drawHandle(
context,
startX,
visibleStopY,
horizontalPixelRatio,
verticalPixelRatio,
'rounded',
);
}
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;
}
const centerY = entryY + direction * (labelOffset + boxHeight / 2);
return clampTextBoxCenter(centerY, 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 clampVisibleHandleCoordinate(
coordinate: number,
size: number,
viewportSize: number,
): number {
const halfSize = size / 2;
if (coordinate < -halfSize || coordinate > viewportSize + halfSize) {
return coordinate;
}
return Math.max(
halfSize,
Math.min(coordinate, viewportSize - halfSize),
);
}
function drawHorizontalLine(
context: CanvasRenderingContext2D,
left: number,
right: number,
y: number,
): void {
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
}
function drawHandle(
context: CanvasRenderingContext2D,
x: number,
y: number,
horizontalPixelRatio: number,
verticalPixelRatio: number,
shape: 'circle' | 'rounded',
): void {
const width = UI.handleSize * horizontalPixelRatio;
const height = UI.handleSize * verticalPixelRatio;
const left = x - width / 2;
const top = y - height / 2;
const { colors } = getThemeStore();
context.save();
context.fillStyle = colors.chartBackground;
context.strokeStyle = colors.chartLineColor;
context.lineWidth =
UI.handleBorderWidth *
Math.max(horizontalPixelRatio, verticalPixelRatio);
context.beginPath();
if (shape === 'circle') {
context.arc(
x,
y,
Math.min(width, height) / 2,
0,
Math.PI * 2,
);
} else {
drawRoundedRect(
context,
left,
top,
width,
height,
UI.handleRadius *
Math.max(horizontalPixelRatio, verticalPixelRatio),
);
}
context.fill();
context.stroke();
context.restore();
}
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`;
}
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();
}