Загрузка данных
import { getThemeStore } from '@src/theme';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
export interface TextStyle {
text: string;
fontSize: number;
isBold: boolean;
isItalic: boolean;
textColor: string;
hasBackground: boolean;
backgroundColor: string;
hasBorder: boolean;
borderColor: string;
}
export type TextSettings = SettingsValues & TextStyle;
export function createDefaultSettings(): TextSettings {
const { colors } = getThemeStore();
return {
text: 'Текст',
fontSize: 14,
isBold: false,
isItalic: false,
textColor: colors.chartPriceLineText,
hasBackground: false,
backgroundColor: colors.axisRangeTooltipFill,
hasBorder: false,
borderColor: colors.chartLineColor,
};
}
export function getTextSettingsTabs(settings: TextSettings): SettingsTab[] {
const fields: SettingField[] = [
{
key: 'text',
label: 'Текст',
type: 'textarea',
defaultValue: settings.text,
placeholder: 'Введите текст',
},
{
key: 'fontSize',
label: 'Размер шрифта',
type: 'number',
defaultValue: settings.fontSize,
min: 8,
max: 72,
},
{
key: 'isBold',
label: 'Жирный',
type: 'checkbox',
defaultValue: settings.isBold,
},
{
key: 'isItalic',
label: 'Курсив',
type: 'checkbox',
defaultValue: settings.isItalic,
},
{
key: 'textColor',
label: 'Цвет текста',
type: 'color',
defaultValue: settings.textColor,
},
{
key: 'hasBackground',
label: 'Фон',
type: 'checkbox',
defaultValue: settings.hasBackground,
},
{
key: 'backgroundColor',
label: 'Цвет фона',
type: 'color',
defaultValue: settings.backgroundColor,
},
{
key: 'hasBorder',
label: 'Граница',
type: 'checkbox',
defaultValue: settings.hasBorder,
},
{
key: 'borderColor',
label: 'Цвет границы',
type: 'color',
defaultValue: settings.borderColor,
},
];
return [{ key: 'style', label: 'Стиль', fields }];
}
import { CanvasRenderingTarget2D } from 'fancy-canvas';
import { IPrimitivePaneRenderer } from 'lightweight-charts';
import { getThemeStore } from '@src/theme';
import { Text } from './text';
const UI = {
handleRadius: 5,
handleBorderWidth: 2,
borderWidth: 1,
borderRadius: 4,
padding: 6,
};
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 handleRadius = UI.handleRadius * pixelRatio;
const handleBorderWidth = UI.handleBorderWidth * pixelRatio;
const borderWidth = UI.borderWidth * pixelRatio;
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;
context.save();
if (data.hasBackground) {
context.fillStyle = data.backgroundColor;
fillRoundedRect(context, left, top, width, height, UI.borderRadius * pixelRatio);
}
if (data.hasBorder) {
context.strokeStyle = data.borderColor;
context.lineWidth = borderWidth;
strokeRoundedRect(context, left, top, width, height, UI.borderRadius * pixelRatio);
}
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.showHandles) {
const { colors } = getThemeStore();
context.save();
context.fillStyle = colors.chartBackground;
context.strokeStyle = colors.chartLineColor;
context.lineWidth = handleBorderWidth;
drawHandle(context, left, top, handleRadius);
context.restore();
}
context.restore();
});
}
}
function drawHandle(context: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
context.stroke();
}
function fillRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
): void {
context.beginPath();
buildRoundedRectPath(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();
buildRoundedRectPath(context, x, y, width, height, radius);
context.stroke();
}
function buildRoundedRectPath(
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();
}
import {
AutoscaleInfo,
CrosshairMode,
IChartApi,
IPrimitivePaneView,
PrimitiveHoveredItem,
SeriesAttachedParameter,
SeriesOptionsMap,
Time,
} from 'lightweight-charts';
import { Observable } from 'rxjs';
import { CustomPriceAxisPaneView, CustomTimeAxisPaneView } from '@core/Drawings/axis';
import {
clamp,
clampPointToContainer as clampPointToContainerInElement,
getAnchorFromPoint,
getContainerSize as getElementContainerSize,
getPointerPoint as getPointerPointFromEvent,
getXCoordinateFromTime,
getYCoordinateFromPrice,
isPointInBounds,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { SettingField, SettingsTab, SettingsValues } from '@src/types';
import { TextPaneView } from './paneView';
import { createDefaultSettings, getTextSettingsTabs, TextSettings } from './settings';
import type { ISeriesDrawing } from '@core/Drawings/common';
import type { Anchor, AxisSegment, Point, SeriesApi } from '@core/Drawings/types';
import type { ChartOptionsModel } from '@src/types';
type TextMode = 'idle' | 'dragging' | 'ready';
export interface TextState {
hidden: boolean;
isActive: boolean;
mode: TextMode;
point: Anchor | null;
settings: TextSettings;
}
interface TextGeometry {
point: Point;
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
lines: string[];
font: string;
lineHeight: number;
}
export interface TextRenderData extends TextGeometry {
textColor: string;
backgroundColor: string;
borderColor: string;
hasBackground: boolean;
hasBorder: boolean;
showHandles: boolean;
}
interface TextParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
openSettings?: () => void;
}
const UI = {
padding: 6,
lineHeightMultiplier: 1.2,
};
let measureCanvas: HTMLCanvasElement | null = null;
export class Text implements ISeriesDrawing {
private chart: IChartApi;
private series: SeriesApi;
private readonly container: HTMLElement;
private readonly removeSelf?: () => void;
private readonly openSettings?: () => void;
private requestUpdate: (() => void) | null = null;
private isBound = false;
private hidden = false;
private isActive = false;
private mode: TextMode = 'idle';
private point: Anchor | null = null;
private settings: TextSettings = createDefaultSettings();
private dragPointerId: number | null = null;
private dragStartPoint: Point | null = null;
private dragGeometrySnapshot: TextGeometry | null = null;
private readonly paneView: TextPaneView;
private readonly timeAxisPaneView: CustomTimeAxisPaneView;
private readonly priceAxisPaneView: CustomPriceAxisPaneView;
constructor(chart: IChartApi, series: SeriesApi, params: TextParams) {
const { container, removeSelf, openSettings } = params;
this.chart = chart;
this.series = series;
this.container = container;
this.removeSelf = removeSelf;
this.openSettings = openSettings;
this.paneView = new TextPaneView(this);
this.timeAxisPaneView = new CustomTimeAxisPaneView({
getAxisSegments: () => this.getTimeAxisSegments(),
});
this.priceAxisPaneView = new CustomPriceAxisPaneView({
getAxisSegments: () => this.getPriceAxisSegments(),
});
this.series.attachPrimitive(this);
}
public show(): void {
this.hidden = false;
this.render();
}
public hide(): void {
this.hidden = true;
this.showCrosshair();
this.render();
}
public destroy(): void {
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this);
this.requestUpdate = null;
}
public rebind(series: SeriesApi): void {
if (this.series === series) {
return;
}
this.showCrosshair();
this.unbindEvents();
this.series.detachPrimitive(this);
this.series = series;
this.requestUpdate = null;
this.series.attachPrimitive(this);
this.render();
}
public isCreationPending(): boolean {
return this.mode === 'idle';
}
public shouldShowInObjectTree(): boolean {
return this.mode !== 'idle';
}
public getSettingsValues(): TextSettings {
return { ...this.settings };
}
public getSettingsTabs(): SettingsTab[] {
return getTextSettingsTabs(this.settings);
}
public updateSettings(settings: SettingsValues): void {
this.settings = {
...this.settings,
...(settings as Partial<TextSettings>),
};
this.render();
}
public getState(): TextState {
return {
hidden: this.hidden,
isActive: this.isActive,
mode: this.mode,
point: this.point,
settings: { ...this.settings },
};
}
public setState(state: unknown): void {
if (!state || typeof state !== 'object') {
return;
}
const nextState = state as Partial<TextState>;
this.hidden = typeof nextState.hidden === 'boolean' ? nextState.hidden : this.hidden;
this.isActive = typeof nextState.isActive === 'boolean' ? nextState.isActive : this.isActive;
this.mode = nextState.mode ?? this.mode;
this.point = nextState.point ?? this.point;
if (nextState.settings) {
this.settings = {
...createDefaultSettings(),
...nextState.settings,
};
}
this.render();
}
public attached(param: SeriesAttachedParameter<Time, keyof SeriesOptionsMap>): void {
this.requestUpdate = param.requestUpdate;
this.bindEvents();
}
public detached(): void {
this.showCrosshair();
this.unbindEvents();
this.requestUpdate = null;
}
public updateAllViews(): void {
updateViews([this.paneView, this.timeAxisPaneView, this.priceAxisPaneView]);
}
public paneViews(): readonly IPrimitivePaneView[] {
return [this.paneView];
}
public timeAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.timeAxisPaneView];
}
public priceAxisPaneViews(): readonly IPrimitivePaneView[] {
return [this.priceAxisPaneView];
}
public timeAxisViews() {
return [];
}
public priceAxisViews() {
return [];
}
public autoscaleInfo(): AutoscaleInfo | null {
return null;
}
public getRenderData(): TextRenderData | null {
if (this.hidden) {
return null;
}
const geometry = this.getGeometry();
if (!geometry) {
return null;
}
return {
...geometry,
textColor: this.settings.textColor,
backgroundColor: this.settings.backgroundColor,
borderColor: this.settings.borderColor,
hasBackground: this.settings.hasBackground,
hasBorder: this.settings.hasBorder,
showHandles: this.isActive,
};
}
public getTimeAxisSegments(): AxisSegment[] {
if (!this.isActive) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.left,
to: geometry.right,
color: colors.axisMarkerAreaFill,
},
];
}
public getPriceAxisSegments(): AxisSegment[] {
if (!this.isActive) {
return [];
}
const geometry = this.getGeometry();
if (!geometry) {
return [];
}
const { colors } = getThemeStore();
return [
{
from: geometry.top,
to: geometry.bottom,
color: colors.axisMarkerAreaFill,
},
];
}
public hitTest(x: number, y: number): PrimitiveHoveredItem | null {
if (this.hidden || this.mode === 'idle') {
return null;
}
if (!this.containsPoint({ x, y })) {
return null;
}
return {
cursorStyle: 'move',
externalId: 'text',
zOrder: 'top',
};
}
private bindEvents(): void {
if (this.isBound) {
return;
}
this.isBound = true;
this.container.addEventListener('pointerdown', this.handlePointerDown);
this.container.addEventListener('dblclick', this.handleDoubleClick);
window.addEventListener('pointermove', this.handlePointerMove);
window.addEventListener('pointerup', this.handlePointerUp);
window.addEventListener('pointercancel', this.handlePointerUp);
}
private unbindEvents(): void {
if (!this.isBound) {
return;
}
this.isBound = false;
this.container.removeEventListener('pointerdown', this.handlePointerDown);
this.container.removeEventListener('dblclick', this.handleDoubleClick);
window.removeEventListener('pointermove', this.handlePointerMove);
window.removeEventListener('pointerup', this.handlePointerUp);
window.removeEventListener('pointercancel', this.handlePointerUp);
}
private handlePointerDown = (event: PointerEvent): void => {
if (this.hidden || event.button !== 0) {
return;
}
const point = this.getEventPoint(event);
if (this.mode === 'idle') {
event.preventDefault();
event.stopPropagation();
this.setPoint(point);
return;
}
if (this.mode !== 'ready') {
return;
}
const containsPoint = this.containsPoint(point);
if (!this.isActive) {
if (!containsPoint) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive = true;
this.render();
return;
}
if (containsPoint) {
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId);
return;
}
this.isActive = false;
this.render();
};
private handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden || this.mode !== 'ready' || !this.isActive) {
return;
}
const point = this.getEventPoint(event as PointerEvent);
if (!this.containsPoint(point)) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openSettings?.();
};
private handlePointerMove = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId || this.mode !== 'dragging') {
return;
}
event.preventDefault();
this.movePoint(this.getEventPoint(event));
this.render();
};
private handlePointerUp = (event: PointerEvent): void => {
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging') {
this.finishDragging();
}
};
private setPoint(point: Point): void {
const anchor = this.createAnchor(this.clampPointToContainer(point));
if (!anchor) {
return;
}
this.point = anchor;
this.isActive = true;
this.mode = 'ready';
this.render();
}
private startDragging(point: Point, pointerId: number): void {
this.mode = 'dragging';
this.dragPointerId = pointerId;
this.dragStartPoint = point;
this.dragGeometrySnapshot = this.getGeometry();
this.hideCrosshair();
this.render();
}
private finishDragging(): void {
this.mode = 'ready';
this.dragPointerId = null;
this.dragStartPoint = null;
this.dragGeometrySnapshot = null;
this.showCrosshair();
this.render();
}
private movePoint(eventPoint: Point): void {
const geometry = this.dragGeometrySnapshot;
const dragStartPoint = this.dragStartPoint;
if (!geometry || !dragStartPoint) {
return;
}
const { width, height } = this.getContainerSize();
const offsetX = eventPoint.x - dragStartPoint.x;
const offsetY = eventPoint.y - dragStartPoint.y;
const nextLeft = clamp(geometry.left + offsetX, 0, Math.max(0, width - geometry.width));
const nextTop = clamp(geometry.top + offsetY, 0, Math.max(0, height - geometry.height));
const anchor = this.createAnchor({ x: nextLeft, y: nextTop });
if (!anchor) {
return;
}
this.point = anchor;
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
private getGeometry(): TextGeometry | null {
if (!this.point) {
return null;
}
const { width: containerWidth, height: containerHeight } = this.getContainerSize();
const x = getXCoordinateFromTime(this.chart, this.point.time);
const y = getYCoordinateFromPrice(this.series, this.point.price);
if (x === null || y === null) {
return null;
}
const anchorPoint: Point = {
x: clamp(Math.round(Number(x)), 0, containerWidth),
y: clamp(Math.round(Number(y)), 0, containerHeight),
};
const lines = getTextLines(this.settings.text);
const font = getFont(this.settings);
const lineHeight = Math.round(this.settings.fontSize * UI.lineHeightMultiplier);
const measured = measureTextBlock(lines, font, lineHeight);
const width = Math.min(containerWidth, measured.width + UI.padding * 2);
const height = Math.min(containerHeight, measured.height + UI.padding * 2);
const left = clamp(anchorPoint.x, 0, Math.max(0, containerWidth - width));
const top = clamp(anchorPoint.y, 0, Math.max(0, containerHeight - height));
const right = left + width;
const bottom = top + height;
return {
point: { x: left, y: top },
left,
right,
top,
bottom,
width,
height,
lines,
font,
lineHeight,
};
}
private containsPoint(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return isPointInBounds(point, geometry, 2);
}
private hideCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Hidden,
},
});
}
private showCrosshair(): void {
this.chart.applyOptions({
crosshair: {
mode: CrosshairMode.Normal,
},
});
}
private getContainerSize(): { width: number; height: number } {
return getElementContainerSize(this.container);
}
private clampPointToContainer(point: Point): Point {
return clampPointToContainerInElement(point, this.container);
}
private getEventPoint(event: PointerEvent): Point {
return getPointerPointFromEvent(this.container, event);
}
private render(): void {
this.updateAllViews();
this.requestUpdate?.();
}
}
function getTextLines(text: string): string[] {
const lines = text.split('\n');
return lines.length ? lines : [''];
}
function getFont(settings: TextSettings): string {
const italic = settings.isItalic ? 'italic ' : '';
const bold = settings.isBold ? '700 ' : '';
return `${italic}${bold}${settings.fontSize}px Inter, sans-serif`;
}
function measureTextBlock(lines: string[], font: string, lineHeight: number): { width: number; height: number } {
const context = getMeasureContext();
if (!context) {
const estimatedWidth = Math.max(...lines.map((line) => Math.max(1, line.length))) * 8;
return {
width: estimatedWidth,
height: lines.length * lineHeight,
};
}
context.font = font;
const width = lines.reduce((maxWidth, line) => {
return Math.max(maxWidth, context.measureText(line || ' ').width);
}, 0);
return {
width: Math.ceil(width),
height: lines.length * lineHeight,
};
}
function getMeasureContext(): CanvasRenderingContext2D | null {
if (!measureCanvas) {
measureCanvas = document.createElement('canvas');
}
return measureCanvas.getContext('2d');
}