Загрузка данных
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitiveAxisView,
PrimitivePaneViewZOrder,
} from 'lightweight-charts';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
interface CustomPriceAxisPaneViewParams {
getAxisSegments(): AxisSegment[];
zOrder?: PrimitivePaneViewZOrder;
}
interface CustomPriceAxisViewParams {
getAxisLabel(labelKind: string): AxisLabel | null;
labelKind: string;
}
export class CustomPriceAxisPaneView implements IPrimitivePaneView {
private readonly rendererInstance: CustomPriceAxisPaneRenderer;
private readonly paneZOrder: PrimitivePaneViewZOrder;
constructor({ getAxisSegments, zOrder = 'bottom' }: CustomPriceAxisPaneViewParams) {
this.rendererInstance = new CustomPriceAxisPaneRenderer(getAxisSegments);
this.paneZOrder = zOrder;
}
public update(): void {}
public renderer(): IPrimitivePaneRenderer {
return this.rendererInstance;
}
public zOrder(): PrimitivePaneViewZOrder {
return this.paneZOrder;
}
}
class CustomPriceAxisPaneRenderer implements IPrimitivePaneRenderer {
constructor(private readonly getAxisSegments: () => AxisSegment[]) {}
public draw(target: CanvasRenderingTarget2D): void {
const axisSegments = this.getAxisSegments();
if (!axisSegments.length) {
return;
}
target.useBitmapCoordinateSpace(({ context, verticalPixelRatio, bitmapSize }) => {
context.save();
for (const axisSegment of axisSegments) {
const segmentStart = Math.min(axisSegment.from, axisSegment.to) * verticalPixelRatio;
const segmentEnd = Math.max(axisSegment.from, axisSegment.to) * verticalPixelRatio;
context.fillStyle = axisSegment.color;
context.fillRect(0, segmentStart, bitmapSize.width, segmentEnd - segmentStart);
}
context.restore();
});
}
}
export class CustomPriceAxisView implements ISeriesPrimitiveAxisView {
private readonly getAxisLabel: (labelKind: string) => AxisLabel | null;
private readonly labelKind: string;
constructor({ getAxisLabel, labelKind }: CustomPriceAxisViewParams) {
this.getAxisLabel = getAxisLabel;
this.labelKind = labelKind;
}
public update(): void {}
public visible(): boolean {
return Boolean(this.getCurrentLabel()?.text);
}
public tickVisible(): boolean {
return false;
}
public coordinate(): number {
return this.getCurrentLabel()?.coordinate ?? 0;
}
public text(): string {
return this.getCurrentLabel()?.text ?? '';
}
public textColor(): string {
return this.getCurrentLabel()?.textColor ?? '';
}
public backColor(): string {
return this.getCurrentLabel()?.backgroundColor ?? '';
}
private getCurrentLabel(): AxisLabel | null {
return this.getAxisLabel(this.labelKind);
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import {
IPrimitivePaneRenderer,
IPrimitivePaneView,
ISeriesPrimitiveAxisView,
PrimitivePaneViewZOrder,
} from 'lightweight-charts';
import type { AxisLabel, AxisSegment } from '@core/Drawings/types';
interface CustomTimeAxisPaneViewParams {
getAxisSegments(): AxisSegment[];
zOrder?: PrimitivePaneViewZOrder;
}
interface CustomTimeAxisViewParams {
getAxisLabel(labelKind: string): AxisLabel | null;
labelKind: string;
}
export class CustomTimeAxisPaneView implements IPrimitivePaneView {
private readonly rendererInstance: CustomTimeAxisPaneRenderer;
private readonly paneZOrder: PrimitivePaneViewZOrder;
constructor({ getAxisSegments, zOrder = 'bottom' }: CustomTimeAxisPaneViewParams) {
this.rendererInstance = new CustomTimeAxisPaneRenderer(getAxisSegments);
this.paneZOrder = zOrder;
}
public update(): void {}
public renderer(): IPrimitivePaneRenderer {
return this.rendererInstance;
}
public zOrder(): PrimitivePaneViewZOrder {
return this.paneZOrder;
}
}
class CustomTimeAxisPaneRenderer implements IPrimitivePaneRenderer {
constructor(private readonly getAxisSegments: () => AxisSegment[]) {}
public draw(target: CanvasRenderingTarget2D): void {
const axisSegments = this.getAxisSegments();
if (!axisSegments.length) {
return;
}
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, bitmapSize }) => {
context.save();
for (const axisSegment of axisSegments) {
const segmentStart = Math.min(axisSegment.from, axisSegment.to) * horizontalPixelRatio;
const segmentEnd = Math.max(axisSegment.from, axisSegment.to) * horizontalPixelRatio;
context.fillStyle = axisSegment.color;
context.fillRect(segmentStart, 0, segmentEnd - segmentStart, bitmapSize.height);
}
context.restore();
});
}
}
export class CustomTimeAxisView implements ISeriesPrimitiveAxisView {
private readonly getAxisLabel: (labelKind: string) => AxisLabel | null;
private readonly labelKind: string;
constructor({ getAxisLabel, labelKind }: CustomTimeAxisViewParams) {
this.getAxisLabel = getAxisLabel;
this.labelKind = labelKind;
}
public update(): void {}
public visible(): boolean {
return Boolean(this.getCurrentLabel()?.text);
}
public tickVisible(): boolean {
return false;
}
public coordinate(): number {
return this.getCurrentLabel()?.coordinate ?? 0;
}
public text(): string {
return this.getCurrentLabel()?.text ?? '';
}
public textColor(): string {
return this.getCurrentLabel()?.textColor ?? '';
}
public backColor(): string {
return this.getCurrentLabel()?.backgroundColor ?? '';
}
private getCurrentLabel(): AxisLabel | null {
return this.getAxisLabel(this.labelKind);
}
}
import { CustomPriceAxisView, CustomTimeAxisView } from '@core/Drawings/axis';
import {
getPriceFromYCoordinate,
getTimeFromXCoordinate,
getXCoordinateFromTime,
getYCoordinateFromPrice,
} 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 { AxisLinePaneView } from './paneView';
import {
AxisLineSettings,
AxisLineStyle,
AxisLineTextStyle,
createDefaultSettings,
getAxisLineSettingsTabs,
} from './settings';
import type { DrawingHandle } from '@core/Drawings/handles';
import type { 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 AxisLineDirection = 'vertical' | 'horizontal';
type AxisLineMode = 'idle' | 'ready' | 'dragging';
type AxisLineHandleKey = 'main';
interface AxisLineParams extends BaseDrawingParams {
direction: AxisLineDirection;
}
interface AxisLineState {
hidden: boolean;
mode: AxisLineMode;
time: Time | null;
price: number | null;
settings: AxisLineSettings;
}
export interface AxisLineRenderData extends AxisLineStyle, AxisLineTextStyle {
direction: AxisLineDirection;
coordinate: number;
}
const LINE_HIT_TOLERANCE = 6;
export class AxisLine extends SeriesDrawingBase<AxisLineSettings, AxisLineHandleKey> implements ISeriesDrawing {
private readonly openSettings?: () => void;
protected settings: AxisLineSettings = createDefaultSettings();
protected mode: AxisLineMode = 'idle';
private readonly direction: AxisLineDirection;
private time: Time | null = null;
private price: number | null = null;
private dragPointerId: number | null = null;
private displayFormat: ChartOptionsModel = {
dateFormat: Defaults.dateFormat,
timeFormat: Defaults.timeFormat,
showTime: Defaults.showTime,
};
private readonly paneView: AxisLinePaneView;
private readonly timeAxisView: CustomTimeAxisView;
private readonly priceAxisView: CustomPriceAxisView;
constructor({
chart,
series,
direction,
container,
interaction,
formatObservable,
openSettings,
initialEvent,
}: AxisLineParams) {
super({ chart, series, container, interaction });
this.direction = direction;
this.openSettings = openSettings;
this.paneView = new AxisLinePaneView(this);
this.timeAxisView = new CustomTimeAxisView({
getAxisLabel: (kind) => this.getTimeAxisLabel(kind),
labelKind: 'main',
});
this.priceAxisView = new CustomPriceAxisView({
getAxisLabel: (kind) => this.getPriceAxisLabel(kind),
labelKind: 'main',
});
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';
}
public getState(): AxisLineState {
return {
hidden: this.hidden,
mode: this.mode,
time: this.time,
price: this.price,
settings: { ...this.settings },
};
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [];
}
public setState(state: unknown): void {
const nextState = state as Partial<AxisLineState>;
if ('hidden' in nextState && typeof nextState.hidden === 'boolean') {
this.hidden = nextState.hidden;
}
if ('mode' in nextState && nextState.mode) {
this.mode = nextState.mode;
}
if ('time' in nextState) {
this.time = nextState.time ?? null;
}
if ('price' in nextState) {
this.price = nextState.price ?? null;
}
if ('settings' in nextState && nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public getSettingsTabs(): SettingsTab[] {
return getAxisLineSettingsTabs(this.settings);
}
public updateAllViews(): void {
updateViews([this.paneView, this.timeAxisView, this.priceAxisView]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisViews() {
return this.direction === 'vertical' ? [this.timeAxisView] : [];
}
public priceAxisViews() {
return this.direction === 'horizontal' ? [this.priceAxisView] : [];
}
public getRenderData(): AxisLineRenderData | null {
if (this.hidden) {
return null;
}
const coordinate = this.getCoordinate();
if (coordinate === null) {
return null;
}
return {
direction: this.direction,
coordinate,
...this.settings,
};
}
protected getDrawingHandles(): readonly DrawingHandle<AxisLineHandleKey>[] {
const coordinate = this.getCoordinate();
if (coordinate === null) {
return [];
}
const { width, height } = this.container.getBoundingClientRect();
return [
{
id: 'main',
x: this.direction === 'vertical' ? coordinate : width / 2,
y: this.direction === 'vertical' ? height / 2 : coordinate,
shape: 'rounded',
},
];
}
protected getHoveredItem(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle') {
return null;
}
const point = { x, y };
const data = this.getRenderData();
if (!data) {
return null;
}
if (this.isSelected() && this.getDrawingHandleAtPoint(point)) {
return {
cursorStyle: this.getCursorStyle(),
externalId: 'axis-line',
zOrder: 'top',
};
}
if (!this.isPointNearLine(point, data.coordinate)) {
return null;
}
return {
cursorStyle: this.getCursorStyle(),
externalId: 'axis-line',
zOrder: 'top',
};
}
protected getTimeAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'main' || this.direction !== 'vertical' || !this.isSelected() || this.time === null) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, this.time, this.series);
if (coordinate === null || typeof this.time !== 'number') {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatDate(
this.time as UTCTimestamp,
this.displayFormat.dateFormat,
this.displayFormat.timeFormat,
this.displayFormat.showTime,
),
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getPriceAxisLabel(kind: string): AxisLabel | null {
if (kind !== 'main' || this.direction !== 'horizontal' || !this.isSelected() || this.price === null) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, this.price);
if (coordinate === null) {
return null;
}
const { colors } = getThemeStore();
return {
coordinate,
text: formatPrice(this.price) ?? '',
textColor: colors.chartPriceLineText,
backgroundColor: colors.axisMarkerLabelFill,
};
}
protected getTimeAxisSegments(): AxisSegment[] {
return [];
}
protected getPriceAxisSegments(): AxisSegment[] {
return [];
}
protected getGeometry(): null {
return null;
}
protected handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready') {
return;
}
const data = this.getRenderData();
if (!data) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
const isNearHandle = this.isSelected() && Boolean(this.getDrawingHandleAtPoint(point));
const isNearLine = this.isPointNearLine(point, data.coordinate);
if (!isNearHandle && !isNearLine) {
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 !== 'ready') {
return;
}
const data = this.getRenderData();
if (!data) {
return;
}
const isNearHandle = this.isSelected() && Boolean(this.getDrawingHandleAtPoint(point));
const isNearLine = this.isPointNearLine(point, data.coordinate);
if (!this.isSelected()) {
if (!isNearLine) {
return;
}
event.preventDefault();
event.stopPropagation();
this.select();
return;
}
if (!isNearHandle && !isNearLine) {
this.deselect();
return;
}
event.preventDefault();
event.stopPropagation();
this.mode = 'dragging';
this.dragPointerId = event.pointerId;
this.hideCrosshair();
this.render();
};
protected handlePointerMove = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
event.preventDefault();
event.stopPropagation();
this.updateLine(this.getEventPoint(event));
this.render();
};
protected handlePointerUp = (event: PointerEvent): void => {
if (this.mode !== 'dragging' || this.dragPointerId !== event.pointerId) {
return;
}
this.mode = 'ready';
this.dragPointerId = null;
this.resolveReady?.();
this.showCrosshair();
this.render();
};
private startDrawing(point: Point): void {
this.updateLine(point);
this.mode = 'ready';
this.resolveReady?.();
this.render();
}
private updateLine(point: Point): void {
if (this.direction === 'vertical') {
this.time = getTimeFromXCoordinate(this.chart, point.x);
return;
}
this.price = getPriceFromYCoordinate(this.series, point.y);
}
private getCoordinate(): number | null {
if (this.direction === 'vertical') {
if (this.time === null) {
return null;
}
const coordinate = getXCoordinateFromTime(this.chart, this.time, this.series);
return coordinate === null ? null : Number(coordinate);
}
if (this.price === null) {
return null;
}
const coordinate = getYCoordinateFromPrice(this.series, this.price);
return coordinate === null ? null : Number(coordinate);
}
private isPointNearLine(point: Point, coordinate: number): boolean {
return this.direction === 'vertical'
? Math.abs(point.x - coordinate) <= LINE_HIT_TOLERANCE
: Math.abs(point.y - coordinate) <= LINE_HIT_TOLERANCE;
}
private getCursorStyle(): PrimitiveHoveredItem['cursorStyle'] {
return this.direction === 'vertical' ? 'ew-resize' : 'ns-resize';
}
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { AxisLine } from './axisLine';
const UI = {
lineWidth: 2,
textLineHeightMultiplier: 1.2,
textOffset: 4,
};
export class AxisLinePaneRenderer implements IPrimitivePaneRenderer {
private readonly axisLine: AxisLine;
constructor(axisLine: AxisLine) {
this.axisLine = axisLine;
}
public draw(target: CanvasRenderingTarget2D): void {
const data = this.axisLine.getRenderData();
if (!data) {
return;
}
target.useBitmapCoordinateSpace(({ context, bitmapSize, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
context.save();
context.fillStyle = data.lineColor;
if (data.direction === 'vertical') {
const x = Math.round(data.coordinate * horizontalPixelRatio);
const width = Math.max(1, UI.lineWidth * pixelRatio);
context.fillRect(x - width / 2, 0, width, bitmapSize.height);
}
if (data.direction === 'horizontal') {
const y = Math.round(data.coordinate * verticalPixelRatio);
const height = Math.max(1, UI.lineWidth * pixelRatio);
context.fillRect(0, y - height / 2, bitmapSize.width, height);
}
drawTextAlongAxisLine(context, {
direction: data.direction,
coordinate: data.coordinate,
text: data.text,
fontSize: data.fontSize,
isBold: data.isBold,
isItalic: data.isItalic,
textColor: data.textColor,
bitmapWidth: bitmapSize.width,
bitmapHeight: bitmapSize.height,
horizontalPixelRatio,
verticalPixelRatio,
});
context.restore();
});
}
}
function drawTextAlongAxisLine(
context: CanvasRenderingContext2D,
params: {
direction: 'vertical' | 'horizontal';
coordinate: number;
text: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
textColor: string;
bitmapWidth: number;
bitmapHeight: number;
horizontalPixelRatio: number;
verticalPixelRatio: number;
},
): void {
const {
direction,
coordinate,
text,
fontSize,
isBold,
isItalic,
textColor,
bitmapWidth,
bitmapHeight,
horizontalPixelRatio,
verticalPixelRatio,
} = params;
if (!text.trim()) {
return;
}
const lines = text.split('\n');
const safeFontSize = Math.max(1, fontSize);
const fontSizePx = safeFontSize * verticalPixelRatio;
const lineHeight = safeFontSize * UI.textLineHeightMultiplier * verticalPixelRatio;
const fontWeight = isBold ? '700 ' : '';
const fontStyle = isItalic ? 'italic ' : '';
const textOffset = UI.textOffset * Math.max(horizontalPixelRatio, verticalPixelRatio);
const x = direction === 'vertical' ? coordinate * horizontalPixelRatio : bitmapWidth / 2;
const y = direction === 'horizontal' ? coordinate * verticalPixelRatio : bitmapHeight / 2;
const angle = direction === 'vertical' ? -Math.PI / 2 : 0;
context.save();
context.translate(x, y);
context.rotate(angle);
context.font = `${fontStyle}${fontWeight}${fontSizePx}px Inter, sans-serif`;
context.fillStyle = textColor;
context.textAlign = 'center';
context.textBaseline = 'middle';
const blockHeight = lines.length * lineHeight;
const textCenterY = -(blockHeight / 2 + textOffset);
const startLineY = textCenterY - blockHeight / 2 + lineHeight / 2;
lines.forEach((line, index) => {
context.fillText(line, 0, startLineY + index * lineHeight);
});
context.restore();
}
import { IPrimitivePaneRenderer, IPrimitivePaneView, PrimitivePaneViewZOrder } from 'lightweight-charts';
import { AxisLine } from './axisLine';
import { AxisLinePaneRenderer } from './paneRenderer';
export class AxisLinePaneView implements IPrimitivePaneView {
private readonly axisLine: AxisLine;
constructor(axisLine: AxisLine) {
this.axisLine = axisLine;
}
public update(): void {}
public renderer(): IPrimitivePaneRenderer {
return new AxisLinePaneRenderer(this.axisLine);
}
public zOrder(): PrimitivePaneViewZOrder {
return 'top';
}
}
import { getThemeStore } from '@src/theme';
import { t } from '@src/translations';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
export interface AxisLineStyle {
lineColor: string;
}
export interface AxisLineTextStyle {
text: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
textColor: string;
}
export type AxisLineSettings = AxisLineStyle & AxisLineTextStyle & SettingsValues;
export function createDefaultSettings(): AxisLineSettings {
const { colors } = getThemeStore();
return {
lineColor: colors.chartLineColor,
text: '',
fontSize: 14,
isBold: false,
isItalic: false,
textColor: colors.chartLineColor,
};
}
export function getAxisLineSettingsTabs(settings: AxisLineSettings): SettingsTab[] {
const styleFields: SettingField[] = [
{
key: 'lineColor',
label: t('Line'),
type: 'color',
defaultValue: settings.lineColor,
toolbar: {
control: 'color',
role: 'line',
},
},
];
const textFields: SettingField[] = [
{
key: 'fontSize',
label: t('Font size'),
type: 'number',
defaultValue: settings.fontSize,
min: 8,
max: 24,
},
{
key: 'text',
label: t('Text'),
type: 'textarea',
defaultValue: settings.text,
placeholder: t('Enter text'),
},
{
key: 'isBold',
label: t('Bold'),
type: 'boolean',
defaultValue: settings.isBold,
},
{
key: 'isItalic',
label: t('Italic'),
type: 'boolean',
defaultValue: settings.isItalic,
},
{
key: 'textColor',
label: t('Text'),
type: 'color',
defaultValue: settings.textColor,
toolbar: {
control: 'color',
role: 'text',
},
},
];
return [
{ key: 'style', label: t('Style'), fields: styleFields },
{ key: 'text', label: t('Text'), fields: textFields },
];
}