Загрузка данных
import { clamp } from 'lodash-es';
import {
CustomPriceAxisPaneView,
CustomPriceAxisView,
CustomTimeAxisPaneView,
CustomTimeAxisView,
} from '@core/Drawings/axis';
import {
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { SeriesDrawingBase } from '@src/core/Drawings/SeriesDrawingBase';
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { Defaults } from '@src/types/defaults';
import { formatPercent, formatPrice, formatSignedNumber, formatVolume } from '@src/utils';
import { formatDate } from '@src/utils/formatter';
import { DiapsonPaneView } from './paneView';
import {
createDefaultSettings,
DiapsonSettings,
DiapsonStyle,
DiapsonTextStyle,
getDiapsonSettingsTabs,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { Anchor, AxisLabel, AxisSegment, Point } from '@core/Drawings/types';
import type { BaseDrawingParams, ISeriesDrawing } from '@src/core/Drawings/SeriesDrawingBase';
import type { ChartOptionsModel, SettingsTab } from '@src/types';
import type { IPrimitivePaneView, PrimitiveHoveredItem, Time, UTCTimestamp } from 'lightweight-charts';
export type DiapsonRangeMode = 'date' | 'price';
type DiapsonMode = 'idle' | 'drawing' | 'ready' | 'dragging';
type DiapsonHandle = 'body' | 'start' | 'end' | null;
type DiapsonHandleKey = Exclude<DiapsonHandle, 'body' | null>;
type TimeLabelKind = 'left' | 'right';
type PriceLabelKind = 'top' | 'bottom';
interface DiapsonParams extends BaseDrawingParams {
rangeMode: DiapsonRangeMode;
stepSize?: number;
stepLabel?: string;
}
export interface DiapsonState {
hidden: boolean;
mode: DiapsonMode;
rangeMode: DiapsonRangeMode;
startTime: Time | null;
endTime: Time | null;
startPrice: number | null;
endPrice: number | null;
settings: DiapsonSettings;
}
interface DiapsonGeometry {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
startPoint: Point;
endPoint: Point;
}
export interface DiapsonRenderData extends DiapsonGeometry, DiapsonStyle, DiapsonTextStyle {
rangeMode: DiapsonRangeMode;
showFill: boolean;
labelLines: string[];
}
interface DateMetrics {
barsCount: number;
elapsedText: string;
volumeText: string;
}
interface PriceMetrics {
delta: number;
percent: number;
steps: number;
}
const BODY_HIT_TOLERANCE = 6;
const MIN_RECTANGLE_WIDTH = 6;
const MIN_RECTANGLE_HEIGHT = 6;
export class Diapson extends SeriesDrawingBase<DiapsonSettings, DiapsonHandleKey> implements ISeriesDrawing {
private removeSelf?: () => void;
private openSettings?: () => void;
protected settings: DiapsonSettings = createDefaultSettings();
protected mode: DiapsonMode = 'idle';
private rangeMode: DiapsonRangeMode;
private startTime: Time | null = null;
private endTime: Time | null = null;
private startPrice: number | null = null;
private endPrice: number | null = null;
private activeDragTarget: DiapsonHandle = null;
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragGeometrySnapshot: DiapsonGeometry | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly stepSize: number;
private readonly stepLabel: string;
private readonly paneView: DiapsonPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
private readonly leftTimeAxisView: CustomTimeAxisView;
private readonly rightTimeAxisView: CustomTimeAxisView;
private readonly topPriceAxisView: CustomPriceAxisView;
private readonly bottomPriceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
container,
interaction,
rangeMode,
formatObservable,
removeSelf,
openSettings,
stepSize = 1,
stepLabel = '',
initialEvent,
}: DiapsonParams) {
super({
chart,
series,
container,
interaction,
});
this.rangeMode = rangeMode;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.stepSize = stepSize > 0 ? stepSize : 1;
this.stepLabel = stepLabel;
this.paneView = new DiapsonPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.leftTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'left',
});
this.rightTimeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'right',
});
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 setRangeMode(nextMode: DiapsonRangeMode): void {
if (this.rangeMode === nextMode) {
return;
}
this.rangeMode = nextMode;
this.resetToIdle();
}
public getState(): DiapsonState {
return {
hidden: this.hidden,
mode: this.mode,
rangeMode: this.rangeMode,
startTime: this.startTime,
endTime: this.endTime,
startPrice: this.startPrice,
endPrice: this.endPrice,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<DiapsonState>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
this.mode = nextState.mode ?? this.mode;
this.rangeMode = nextState.rangeMode ?? this.rangeMode;
this.startTime = 'startTime' in nextState ? (nextState.startTime ?? null) : this.startTime;
this.endTime = 'endTime' in nextState ? (nextState.endTime ?? null) : this.endTime;
this.startPrice = 'startPrice' in nextState ? (nextState.startPrice ?? null) : this.startPrice;
this.endPrice = 'endPrice' in nextState ? (nextState.endPrice ?? null) : this.endPrice;
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getDiapsonSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([
this.paneView,
this.timeAxisPaneView,
this.priceAxisPaneView,
this.leftTimeAxisView,
this.rightTimeAxisView,
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.leftTimeAxisView, this.rightTimeAxisView];
}
public priceAxisViews() {
return [this.topPriceAxisView, this.bottomPriceAxisView];
}
public getRenderData(): DiapsonRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
rangeMode: this.rangeMode,
showFill: true,
labelLines: this.getLabelLines(),
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<DiapsonHandleKey>[] {
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
return [
{
id: 'end',
...geometry.endPoint,
shape: 'circle',
size: 12,
borderWidth: 2,
},
{
id: 'start',
...geometry.startPoint,
shape: 'circle',
size: 12,
borderWidth: 2,
},
];
}
protected getTimeAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const bounds = this.getTimeBounds();
if (!bounds) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: bounds.left,
to: bounds.right,
color: colors.axisMarkerAreaFill,
},
];
}
protected getPriceAxisSegments(): AxisSegment[] {
if (!this.isSelected() && !this.isCreationPending()) {
return [];
}
const bounds = this.getPriceBounds();
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 !== 'left' && kind !== 'right')) {
return null;
}
const labelKind = kind as TimeLabelKind;
const coordinate = this.getTimeCoordinate(labelKind);
const text = this.getTimeText(labelKind);
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 labelKind = kind as PriceLabelKind;
const coordinate = this.getPriceCoordinate(labelKind);
const text = this.getPriceText(labelKind);
if (coordinate === null || !text) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text,
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
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()) {
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'move',
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
const handleTarget = this.getDrawingHandleAtPoint(point)?.id ?? null;
if (handleTarget) {
return {
cursorStyle: this.getCursorStyle(handleTarget),
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
if (!this.containsPoint(point)) {
return null;
}
return {
cursorStyle: 'move',
externalId: `diapson-${this.rangeMode}`,
zOrder: 'top',
};
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.getDrawingHandleAtPoint(point) && !this.containsPoint(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);
this.render();
return;
}
this.resizeRectangle(point);
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.finishDragging();
};
private startDrawing(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(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(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) {
this.resetToIdle();
return;
}
if (geometry.width < MIN_RECTANGLE_WIDTH || geometry.height < MIN_RECTANGLE_HEIGHT) {
if (this.removeSelf) {
this.removeSelf();
return;
}
this.resetToIdle();
return;
}
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private startDragging(point: Point, pointerId: number, dragTarget: Exclude<DiapsonHandle, null>): void {
this.mode = 'dragging';
this.activeDragTarget = dragTarget;
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragGeometrySnapshot = this.getGeometry();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.resolveReady?.();
this.clearInteractionState();
this.render();
}
private clearInteractionState(): void {
this.activeDragTarget = null;
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
}
private resetToIdle(): void {
this.hidden = false;
this.deselect();
this.mode = 'idle';
this.startTime = null;
this.endTime = null;
this.startPrice = null;
this.endPrice = null;
this.clearInteractionState();
this.render();
}
private getDragTarget(point: Point): Exclude<DiapsonHandle, null> | null {
return this.getDrawingHandleAtPoint(point)?.id ?? (this.containsPoint(point) ? 'body' : null);
}
private moveWhole(point: Point): void {
const geometry = this.dragGeometrySnapshot;
const { dragStartPoint } = this;
if (!geometry || !dragStartPoint) {
return;
}
const { width, height } = this.getContainerSize();
const rawOffsetX = point.x - dragStartPoint.x;
const rawOffsetY = point.y - dragStartPoint.y;
const offsetX = clamp(rawOffsetX, -geometry.left, width - geometry.right);
const offsetY = clamp(rawOffsetY, -geometry.top, height - geometry.bottom);
const nextStartPoint = this.clampPointToContainer({
x: geometry.startPoint.x + offsetX,
y: geometry.startPoint.y + offsetY,
});
const nextEndPoint = this.clampPointToContainer({
x: geometry.endPoint.x + offsetX,
y: geometry.endPoint.y + offsetY,
});
this.setAnchorsFromPoints(nextStartPoint, nextEndPoint);
}
private resizeRectangle(point: Point): void {
const geometry = this.dragGeometrySnapshot;
if (!geometry || !this.activeDragTarget || this.activeDragTarget === 'body') {
return;
}
const nextPoint = this.clampPointToContainer(point);
if (this.activeDragTarget === 'start') {
this.setAnchorsFromPoints(nextPoint, geometry.endPoint);
return;
}
this.setAnchorsFromPoints(geometry.startPoint, nextPoint);
}
private setAnchorsFromPoints(startPoint: Point, endPoint: Point): void {
const startAnchor = this.createAnchor(startPoint);
const endAnchor = this.createAnchor(endPoint);
if (!startAnchor || !endAnchor) {
return;
}
this.startTime = startAnchor.time;
this.startPrice = startAnchor.price;
this.endTime = endAnchor.time;
this.endPrice = endAnchor.price;
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
protected getGeometry(): DiapsonGeometry | 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 { width, height } = this.getContainerSize();
const startPoint = {
x: clamp(Math.round(Number(startX)), 0, width),
y: clamp(Math.round(Number(startY)), 0, height),
};
const endPoint = {
x: clamp(Math.round(Number(endX)), 0, width),
y: clamp(Math.round(Number(endY)), 0, height),
};
const left = Math.round(Math.min(startPoint.x, endPoint.x));
const right = Math.round(Math.max(startPoint.x, endPoint.x));
const top = Math.round(Math.min(startPoint.y, endPoint.y));
const bottom = Math.round(Math.max(startPoint.y, endPoint.y));
return {
left,
right,
top,
bottom,
width: right - left,
height: bottom - top,
startPoint,
endPoint,
};
}
private getLabelLines(): string[] {
if (this.rangeMode === 'date') {
const metrics = this.getDateMetrics();
if (!metrics) {
return [];
}
const firstLine = metrics.elapsedText
? `${metrics.barsCount} ${t('bars')}, ${metrics.elapsedText}`
: `${metrics.barsCount} ${t('bars')}`;
if (!metrics.volumeText) {
return [firstLine];
}
return [firstLine, `${t('Vol')} ${metrics.volumeText}`];
}
const metrics = this.getPriceMetrics();
if (!metrics) {
return [];
}
const percentText =
metrics.percent < 0 ? `-${formatPercent(Math.abs(metrics.percent))}` : formatPercent(Math.abs(metrics.percent));
const absSteps = Math.abs(metrics.steps);
let stepsText = '';
if (Number.isInteger(absSteps)) {
stepsText = absSteps.toString();
} else if (absSteps >= 1000) {
stepsText = absSteps.toFixed(0);
} else if (absSteps >= 100) {
stepsText = absSteps.toFixed(1);
} else {
stepsText = absSteps.toFixed(2);
}
if (metrics.steps < 0) {
stepsText = `-${stepsText}`;
}
const stepSuffix = this.stepLabel ? ` ${this.stepLabel}` : '';
return [`${formatSignedNumber(metrics.delta)} (${percentText}) ${stepsText}${stepSuffix}`];
}
private getDateMetrics(): DateMetrics | null {
const leftTime = this.getLeftTimeValue();
const rightTime = this.getRightTimeValue();
if (!leftTime || !rightTime) {
return null;
}
const barsCount = this.getBarsCount();
const durationSeconds = Math.max(0, Math.floor(Math.abs(Number(rightTime) - Number(leftTime))));
const days = Math.floor(durationSeconds / 86400);
const hours = Math.floor((durationSeconds % 86400) / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
const elapsedParts: string[] = [];
if (days > 0) {
elapsedParts.push(`${days}d`);
}
if (hours > 0) {
elapsedParts.push(`${hours}h`);
}
if (minutes > 0) {
elapsedParts.push(`${minutes}m`);
}
if (elapsedParts.length === 0) {
elapsedParts.push(`${seconds}s`);
}
const volume = this.getVolumeInRange();
return {
barsCount,
elapsedText: elapsedParts.slice(0, 2).join(' '),
volumeText: volume > 0 ? formatVolume(volume) : '',
};
}
private getPriceMetrics(): PriceMetrics | null {
if (this.startPrice === null || this.endPrice === null) {
return null;
}
const delta = this.endPrice - this.startPrice;
return {
delta,
percent: this.startPrice !== 0 ? (delta / Math.abs(this.startPrice)) * 100 : 0,
steps: delta / this.stepSize,
};
}
private getBarsCount(): number {
if (this.startTime === null || this.endTime === null) {
return 0;
}
const startIndex = this.findIndexByTime(this.startTime);
const endIndex = this.findIndexByTime(this.endTime);
if (startIndex < 0 || endIndex < 0) {
return 0;
}
return Math.abs(endIndex - startIndex);
}
private getVolumeInRange(): number {
if (this.startTime === null || this.endTime === null) {
return 0;
}
const data = this.series.data() ?? [];
if (!data.length) {
return 0;
}
const startIndex = this.findIndexByTime(this.startTime);
const endIndex = this.findIndexByTime(this.endTime);
if (startIndex < 0 || endIndex < 0) {
return 0;
}
const from = Math.min(startIndex, endIndex);
const to = Math.max(startIndex, endIndex);
let volume = 0;
for (let index = from; index <= to; index += 1) {
const item = data[index] as unknown as Record<string, unknown> | undefined;
if (!item) {
continue;
}
if (typeof item.volume === 'number') {
volume += item.volume;
continue;
}
const customValues = item.customValues as Record<string, unknown> | undefined;
if (customValues && typeof customValues.volume === 'number') {
volume += customValues.volume;
}
}
return volume;
}
private findIndexByTime(time: Time): number {
const data = this.series.data() ?? [];
return data.findIndex((item) => Number(item.time) === Number(time));
}
private getTimeBounds(): { left: number; right: number } | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
left: geometry.left,
right: geometry.right,
};
}
private getPriceBounds(): { top: number; bottom: number } | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
top: geometry.top,
bottom: geometry.bottom,
};
}
private getTimeCoordinate(kind: TimeLabelKind): number | null {
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return kind === 'left' ? geometry.left : geometry.right;
}
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 === 'left' ? this.getLeftTimeValue() : this.getRightTimeValue();
if (!time) {
return '';
}
return formatDate(
time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
);
}
private getPriceText(kind: PriceLabelKind): string {
const price = kind === 'top' ? this.getTopPriceValue() : this.getBottomPriceValue();
if (price === null) {
return '';
}
return formatPrice(price) ?? '';
}
private getLeftTimeValue(): Time | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
if (startX === null || endX === null) {
return this.startTime;
}
return Number(startX) <= Number(endX) ? this.startTime : this.endTime;
}
private getRightTimeValue(): Time | null {
if (this.startTime === null || this.endTime === null) {
return null;
}
const startX = getXCoordinateFromTime(this.chart, this.startTime, this.series);
const endX = getXCoordinateFromTime(this.chart, this.endTime, this.series);
if (startX === null || endX === null) {
return this.endTime;
}
return Number(startX) <= Number(endX) ? this.endTime : this.startTime;
}
private getTopPriceValue(): number | null {
if (this.startPrice === null || this.endPrice === null) {
return null;
}
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startY === null || endY === null) {
return Math.max(this.startPrice, this.endPrice);
}
return Number(startY) <= Number(endY) ? this.startPrice : this.endPrice;
}
private getBottomPriceValue(): number | null {
if (this.startPrice === null || this.endPrice === null) {
return null;
}
const startY = getYCoordinateFromPrice(this.series, this.startPrice);
const endY = getYCoordinateFromPrice(this.series, this.endPrice);
if (startY === null || endY === null) {
return Math.min(this.startPrice, this.endPrice);
}
return Number(startY) <= Number(endY) ? this.endPrice : this.startPrice;
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return isPointInBounds(point, geometry, BODY_HIT_TOLERANCE);
}
private getCursorStyle(handle: Exclude<DiapsonHandle, null>): PrimitiveHoveredItem['cursorStyle'] {
if (handle === 'body') {
return 'move';
}
const geometry = this.getGeometry();
if (!geometry) {
return 'default';
}
const sameDirection =
(geometry.endPoint.x - geometry.startPoint.x >= 0 && geometry.endPoint.y - geometry.startPoint.y >= 0) ||
(geometry.endPoint.x - geometry.startPoint.x < 0 && geometry.endPoint.y - geometry.startPoint.y < 0);
return sameDirection ? 'nwse-resize' : 'nesw-resize';
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { Diapson, DiapsonRenderData } from './diapson';
import type { DiapsonTextStyle } from './settings';
const UI = {
lineWidth: 1,
labelPadding: 4,
labelRadius: 2,
labelBottomOffset: 8,
arrowSize: 7,
lineHeightMultiplier: 1.2,
};
export class DiapsonPaneRenderer implements IPrimitivePaneRenderer {
private readonly diapson: Diapson;
constructor(diapson: Diapson) {
this.diapson = diapson;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.diapson.getRenderData();
if (!data) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const left = data.left * horizontalPixelRatio;
const right = data.right * horizontalPixelRatio;
const top = data.top * verticalPixelRatio;
const bottom = data.bottom * verticalPixelRatio;
const width = Math.max(right - left, 0);
const height = Math.max(bottom - top, 0);
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const lineWidth = UI.lineWidth * pixelRatio;
const arrowSize = UI.arrowSize * pixelRatio;
context.save();
if (data.showFill) {
context.fillStyle = data.fillColor;
context.fillRect(left, top, width, height);
}
context.strokeStyle = data.borderColor;
context.lineWidth = lineWidth;
if (data.rangeMode === 'date') {
drawVerticalBoundary(context, left, top, bottom);
drawVerticalBoundary(context, right, top, bottom);
drawHorizontalArrow(
context,
left,
right,
(top + bottom) / 2,
arrowSize,
data.startPoint.x <= data.endPoint.x ? 'right' : 'left',
);
} else {
drawHorizontalBoundary(context, top, left, right);
drawHorizontalBoundary(context, bottom, left, right);
drawVerticalArrow(
context,
(left + right) / 2,
top,
bottom,
arrowSize,
data.startPoint.y <= data.endPoint.y ? 'down' : 'up',
);
}
if (data.labelLines.length > 0) {
drawLabel(context, data, horizontalPixelRatio, verticalPixelRatio, data);
}
context.restore();
});
}
}
function drawVerticalBoundary(context: CanvasRenderingContext2D, x: number, top: number, bottom: number): void {
context.beginPath();
context.moveTo(x, top);
context.lineTo(x, bottom);
context.stroke();
}
function drawHorizontalBoundary(context: CanvasRenderingContext2D, y: number, left: number, right: number): void {
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
}
function drawHorizontalArrow(
context: CanvasRenderingContext2D,
left: number,
right: number,
y: number,
arrowSize: number,
direction: 'left' | 'right',
): void {
context.beginPath();
context.moveTo(left, y);
context.lineTo(right, y);
context.stroke();
if (direction === 'right') {
context.beginPath();
context.moveTo(right, y);
context.lineTo(right - arrowSize, y - arrowSize);
context.moveTo(right, y);
context.lineTo(right - arrowSize, y + arrowSize);
context.stroke();
return;
}
context.beginPath();
context.moveTo(left, y);
context.lineTo(left + arrowSize, y - arrowSize);
context.moveTo(left, y);
context.lineTo(left + arrowSize, y + arrowSize);
context.stroke();
}
function drawVerticalArrow(
context: CanvasRenderingContext2D,
x: number,
top: number,
bottom: number,
arrowSize: number,
direction: 'up' | 'down',
): void {
context.beginPath();
context.moveTo(x, top);
context.lineTo(x, bottom);
context.stroke();
if (direction === 'down') {
context.beginPath();
context.moveTo(x, bottom);
context.lineTo(x - arrowSize, bottom - arrowSize);
context.moveTo(x, bottom);
context.lineTo(x + arrowSize, bottom - arrowSize);
context.stroke();
return;
}
context.beginPath();
context.moveTo(x, top);
context.lineTo(x - arrowSize, top + arrowSize);
context.moveTo(x, top);
context.lineTo(x + arrowSize, top + arrowSize);
context.stroke();
}
function drawLabel(
context: CanvasRenderingContext2D,
data: DiapsonRenderData,
horizontalPixelRatio: number,
verticalPixelRatio: number,
textStyle: DiapsonTextStyle,
): void {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const fontSize = textStyle.fontSize * pixelRatio;
const padding = UI.labelPadding * pixelRatio;
const lineHeight = Math.round(textStyle.fontSize * UI.lineHeightMultiplier) * verticalPixelRatio;
const radius = UI.labelRadius * pixelRatio;
const bottomOffset = UI.labelBottomOffset * verticalPixelRatio;
const paneLeft = data.left * horizontalPixelRatio;
const paneRight = data.right * horizontalPixelRatio;
const paneBottom = data.bottom * verticalPixelRatio;
context.save();
context.font = getLabelFont(textStyle, fontSize);
const maxTextWidth = data.labelLines.reduce((maxWidth, line) => {
return Math.max(maxWidth, context.measureText(line).width);
}, 0);
const labelWidth = maxTextWidth + padding * 2;
const labelHeight = data.labelLines.length * lineHeight + padding * 2;
const rangeCenterX = (paneLeft + paneRight) / 2;
const maxLeft = Math.max(4, context.canvas.width - labelWidth - 4);
const maxTop = Math.max(4, context.canvas.height - labelHeight - 4);
const boxLeft = clampNumber(rangeCenterX - labelWidth / 2, 4, maxLeft);
const boxTop = clampNumber(paneBottom + bottomOffset, 4, maxTop);
context.fillStyle = textStyle.labelBackgroundColor;
fillRoundedRect(context, boxLeft, boxTop, labelWidth, labelHeight, radius);
context.fillStyle = textStyle.labelTextColor;
context.textAlign = 'center';
context.textBaseline = 'middle';
data.labelLines.forEach((line, index) => {
const lineY = boxTop + padding + lineHeight * index + lineHeight / 2;
context.fillText(line, boxLeft + labelWidth / 2, lineY);
});
context.restore();
}
function getLabelFont(style: DiapsonTextStyle, fontSize: number): string {
const italic = style.isItalic ? 'italic ' : '';
const bold = style.isBold ? '700 ' : '';
return `${italic}${bold}${fontSize}px Inter, sans-serif`;
}
function fillRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
context.beginPath();
context.moveTo(x + radius, y);
context.lineTo(x + width - radius, y);
context.quadraticCurveTo(x + width, y, x + width, y + radius);
context.lineTo(x + width, y + height - radius);
context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
context.lineTo(x + radius, y + height);
context.quadraticCurveTo(x, y + height, x, y + height - radius);
context.lineTo(x, y + radius);
context.quadraticCurveTo(x, y, x + radius, y);
context.closePath();
context.fill();
}
function clampNumber(value: number, min: number, max: number): number {
if (max < min) {
return min;
}
return Math.max(min, Math.min(value, max));
}