Загрузка данных
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,
};
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;
}
const { colors } = getThemeStore();
target.useBitmapCoordinateSpace(({ context, horizontalPixelRatio, verticalPixelRatio }) => {
const pixelRatio = Math.max(horizontalPixelRatio, verticalPixelRatio);
const handleRadius = UI.handleRadius * pixelRatio;
const handleBorderWidth = UI.handleBorderWidth * pixelRatio;
context.save();
if (!data.point) {
return;
}
if (data.point) {
context.save();
const options = {
// todo: take this from this.text.getRenderData();
font: '24px Arial',
color: 'white',
align: 'left',
baseline: 'alphabetic',
stroke: false,
strokeColor: 'white',
lineWidth: 1,
shadow: null,
};
context.font = options.font;
context.fillStyle = options.color;
context.textAlign = options.align as CanvasTextAlign;
context.textBaseline = options.baseline as CanvasTextBaseline;
const x = data.point.x * horizontalPixelRatio;
const y = data.point.y * verticalPixelRatio;
context.fillText('text', x, y); // todo: take text from this.text.getRenderData();
context.restore();
}
if (data.showHandles) {
context.save();
context.fillStyle = colors.chartBackground;
context.strokeStyle = colors.chartLineColor;
context.lineWidth = handleBorderWidth;
drawHandle(context, data.point.x * horizontalPixelRatio, data.point.y * verticalPixelRatio, 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();
}
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,
isNearPoint,
} from '@core/Drawings/helpers';
import { updateViews } from '@core/Drawings/utils';
import { getThemeStore } from '@src/theme';
import { TextPaneView } from './paneView';
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;
}
interface TextGeometry {
point: Point;
left: number;
right: number;
top: number;
bottom: number;
}
export interface TextRenderData extends TextGeometry {
showHandles: boolean;
}
const POINT_HIT_TOLERANCE = 8;
interface TextParams {
container: HTMLElement;
formatObservable?: Observable<ChartOptionsModel>;
removeSelf?: () => void;
}
export class Text implements ISeriesDrawing {
private chart: IChartApi;
private series: SeriesApi;
private readonly container: HTMLElement;
private readonly removeSelf?: () => 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 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 } = params;
this.chart = chart;
this.series = series;
this.container = container;
this.removeSelf = removeSelf;
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 getState(): TextState {
return {
hidden: this.hidden,
isActive: this.isActive,
mode: this.mode,
point: this.point,
};
}
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;
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,
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;
}
const point = { x, y };
const isClickedOnThisDrawing = this.isClickedOnThisDrawing(point);
if (!isClickedOnThisDrawing) {
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 isClickedOnThisDrawing = this.isClickedOnThisDrawing(point);
if (!this.isActive) {
if (!isClickedOnThisDrawing) {
return;
}
event.preventDefault();
event.stopPropagation();
this.isActive = true;
this.render();
return;
}
if (isClickedOnThisDrawing) {
event.preventDefault();
event.stopPropagation();
this.startDragging(point, event.pointerId);
return;
}
this.isActive = false;
this.render();
};
private handleDoubleClick = (event: MouseEvent): void => {
if (this.hidden) {
return;
}
event.preventDefault();
event.stopPropagation();
this.openModalOptions();
};
private openModalOptions = (): void => {
// todo: open modal with settings (text just for now)
console.log('openModalOptions');
};
private handlePointerMove = (event: PointerEvent): void => {
const point = this.getEventPoint(event);
if (this.dragPointerId !== event.pointerId) {
return;
}
if (this.mode === 'dragging') {
event.preventDefault();
this.movePoint(point);
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;
if (!geometry) {
return;
}
const nextPoint = this.clampPointToContainer(eventPoint);
this.point = this.createAnchor(nextPoint);
}
private createAnchor(point: Point): Anchor | null {
return getAnchorFromPoint(this.chart, this.series, point);
}
private getGeometry(): TextGeometry | null {
if (!this.point) {
return null;
}
const { width, height } = this.getContainerSize();
const x = getXCoordinateFromTime(this.chart, this.point.time);
const y = getYCoordinateFromPrice(this.series, this.point.price);
const screenPoint: Point = {
x: clamp(Math.round(Number(x)), 0, width),
y: clamp(Math.round(Number(y)), 0, height),
};
const left = Math.round(screenPoint.x);
const right = Math.round(screenPoint.x);
const top = Math.round(screenPoint.y);
const bottom = Math.round(screenPoint.y);
return {
point: screenPoint,
left,
right,
top,
bottom,
};
}
private isClickedOnThisDrawing(point: Point): boolean {
const geometry = this.getGeometry();
if (!geometry) {
return false;
}
return isNearPoint(point, geometry.point.x, geometry.point.y, POINT_HIT_TOLERANCE);
}
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?.();
}
}