- Add hitTest, setSelected, attached/detached lifecycle methods to TrendLine - Add preview mode support with dashed lines and reduced opacity - Draw selection handles on endpoints when selected - Create RectangleDrawingPrimitive plugin with full ISeriesPrimitive implementation - Support preview mode, selection, hit testing, and autoscaling for rectangles - Set z-order to bottom for rectangles to render behind candlesticks Tasks completed: 1.1-1.4, 2.1-2.7
312 lines
8.2 KiB
TypeScript
312 lines
8.2 KiB
TypeScript
import { BitmapCoordinatesRenderingScope, CanvasRenderingTarget2D } from 'fancy-canvas';
|
|
import {
|
|
AutoscaleInfo,
|
|
Coordinate,
|
|
IChartApi,
|
|
ISeriesApi,
|
|
ISeriesPrimitive,
|
|
IPrimitivePaneRenderer,
|
|
IPrimitivePaneView,
|
|
Logical,
|
|
SeriesOptionsMap,
|
|
SeriesType,
|
|
Time,
|
|
SeriesAttachedParameter,
|
|
PrimitiveHoveredItem,
|
|
} from 'lightweight-charts';
|
|
|
|
class TrendLinePaneRenderer implements IPrimitivePaneRenderer {
|
|
_p1: ViewPoint;
|
|
_p2: ViewPoint;
|
|
_text1: string;
|
|
_text2: string;
|
|
_options: TrendLineOptions;
|
|
_selected: boolean;
|
|
|
|
constructor(p1: ViewPoint, p2: ViewPoint, text1: string, text2: string, options: TrendLineOptions, selected: boolean) {
|
|
this._p1 = p1;
|
|
this._p2 = p2;
|
|
this._text1 = text1;
|
|
this._text2 = text2;
|
|
this._options = options;
|
|
this._selected = selected;
|
|
}
|
|
|
|
draw(target: CanvasRenderingTarget2D) {
|
|
target.useBitmapCoordinateSpace(scope => {
|
|
if (
|
|
this._p1.x === null ||
|
|
this._p1.y === null ||
|
|
this._p2.x === null ||
|
|
this._p2.y === null
|
|
)
|
|
return;
|
|
const ctx = scope.context;
|
|
const x1Scaled = Math.round(this._p1.x * scope.horizontalPixelRatio);
|
|
const y1Scaled = Math.round(this._p1.y * scope.verticalPixelRatio);
|
|
const x2Scaled = Math.round(this._p2.x * scope.horizontalPixelRatio);
|
|
const y2Scaled = Math.round(this._p2.y * scope.verticalPixelRatio);
|
|
|
|
// Apply preview styling if needed
|
|
const lineWidth = this._options.isPreview ? this._options.width : (this._selected ? this._options.width * 1.5 : this._options.width);
|
|
const opacity = this._options.isPreview ? 0.5 : 1.0;
|
|
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.strokeStyle = this._options.lineColor;
|
|
ctx.globalAlpha = opacity;
|
|
|
|
// Apply dashed style for preview
|
|
if (this._options.isPreview) {
|
|
ctx.setLineDash([5 * scope.horizontalPixelRatio, 5 * scope.horizontalPixelRatio]);
|
|
} else {
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1Scaled, y1Scaled);
|
|
ctx.lineTo(x2Scaled, y2Scaled);
|
|
ctx.stroke();
|
|
|
|
ctx.setLineDash([]);
|
|
ctx.globalAlpha = 1.0;
|
|
|
|
// Draw endpoint handles when selected
|
|
if (this._selected) {
|
|
const handleRadius = 6 * scope.horizontalPixelRatio;
|
|
ctx.fillStyle = 'white';
|
|
ctx.strokeStyle = this._options.lineColor;
|
|
ctx.lineWidth = 2 * scope.horizontalPixelRatio;
|
|
|
|
// Handle at p1
|
|
ctx.beginPath();
|
|
ctx.arc(x1Scaled, y1Scaled, handleRadius, 0, 2 * Math.PI);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
// Handle at p2
|
|
ctx.beginPath();
|
|
ctx.arc(x2Scaled, y2Scaled, handleRadius, 0, 2 * Math.PI);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
}
|
|
|
|
if (this._options.showLabels) {
|
|
this._drawTextLabel(scope, this._text1, x1Scaled, y1Scaled, true);
|
|
this._drawTextLabel(scope, this._text2, x2Scaled, y2Scaled, false);
|
|
}
|
|
});
|
|
}
|
|
|
|
_drawTextLabel(scope: BitmapCoordinatesRenderingScope, text: string, x: number, y: number, left: boolean) {
|
|
scope.context.font = '24px Arial';
|
|
scope.context.beginPath();
|
|
const offset = 5 * scope.horizontalPixelRatio;
|
|
const textWidth = scope.context.measureText(text);
|
|
const leftAdjustment = left ? textWidth.width + offset * 4 : 0;
|
|
scope.context.fillStyle = this._options.labelBackgroundColor;
|
|
scope.context.roundRect(x + offset - leftAdjustment, y - 24, textWidth.width + offset * 2, 24 + offset, 5);
|
|
scope.context.fill();
|
|
scope.context.beginPath();
|
|
scope.context.fillStyle = this._options.labelTextColor;
|
|
scope.context.fillText(text, x + offset * 2 - leftAdjustment, y);
|
|
}
|
|
}
|
|
|
|
interface ViewPoint {
|
|
x: Coordinate | null;
|
|
y: Coordinate | null;
|
|
}
|
|
|
|
class TrendLinePaneView implements IPrimitivePaneView {
|
|
_source: TrendLine;
|
|
_p1: ViewPoint = { x: null, y: null };
|
|
_p2: ViewPoint = { x: null, y: null };
|
|
|
|
constructor(source: TrendLine) {
|
|
this._source = source;
|
|
}
|
|
|
|
update() {
|
|
const series = this._source._series;
|
|
const y1 = series.priceToCoordinate(this._source._p1.price);
|
|
const y2 = series.priceToCoordinate(this._source._p2.price);
|
|
const timeScale = this._source._chart.timeScale();
|
|
const x1 = timeScale.timeToCoordinate(this._source._p1.time);
|
|
const x2 = timeScale.timeToCoordinate(this._source._p2.time);
|
|
this._p1 = { x: x1, y: y1 };
|
|
this._p2 = { x: x2, y: y2 };
|
|
}
|
|
|
|
renderer() {
|
|
return new TrendLinePaneRenderer(
|
|
this._p1,
|
|
this._p2,
|
|
'' + this._source._p1.price.toFixed(1),
|
|
'' + this._source._p2.price.toFixed(1),
|
|
this._source._options,
|
|
this._source._selected
|
|
);
|
|
}
|
|
}
|
|
|
|
interface Point {
|
|
time: Time;
|
|
price: number;
|
|
}
|
|
|
|
export interface TrendLineOptions {
|
|
lineColor: string;
|
|
width: number;
|
|
showLabels: boolean;
|
|
labelBackgroundColor: string;
|
|
labelTextColor: string;
|
|
isPreview?: boolean;
|
|
annotationId?: string;
|
|
}
|
|
|
|
const defaultOptions: TrendLineOptions = {
|
|
lineColor: 'rgb(0, 0, 0)',
|
|
width: 2,
|
|
showLabels: false,
|
|
labelBackgroundColor: 'rgba(255, 255, 255, 0.85)',
|
|
labelTextColor: 'rgb(0, 0, 0)',
|
|
};
|
|
|
|
export class TrendLine implements ISeriesPrimitive<Time> {
|
|
_chart: IChartApi;
|
|
_series: ISeriesApi<keyof SeriesOptionsMap>;
|
|
_p1: Point;
|
|
_p2: Point;
|
|
_paneViews: TrendLinePaneView[];
|
|
_options: TrendLineOptions;
|
|
_minPrice: number;
|
|
_maxPrice: number;
|
|
_selected: boolean = false;
|
|
_attachedParam: SeriesAttachedParameter<Time> | null = null;
|
|
|
|
constructor(
|
|
chart: IChartApi,
|
|
series: ISeriesApi<SeriesType>,
|
|
p1: Point,
|
|
p2: Point,
|
|
options?: Partial<TrendLineOptions>
|
|
) {
|
|
this._chart = chart;
|
|
this._series = series;
|
|
this._p1 = p1;
|
|
this._p2 = p2;
|
|
this._minPrice = Math.min(this._p1.price, this._p2.price);
|
|
this._maxPrice = Math.max(this._p1.price, this._p2.price);
|
|
this._options = {
|
|
...defaultOptions,
|
|
...options,
|
|
};
|
|
this._paneViews = [new TrendLinePaneView(this)];
|
|
}
|
|
|
|
updatePoints(p1: Point, p2: Point) {
|
|
this._p1 = p1;
|
|
this._p2 = p2;
|
|
this._minPrice = Math.min(this._p1.price, this._p2.price);
|
|
this._maxPrice = Math.max(this._p1.price, this._p2.price);
|
|
}
|
|
|
|
getP1(): Point {
|
|
return this._p1;
|
|
}
|
|
|
|
getP2(): Point {
|
|
return this._p2;
|
|
}
|
|
|
|
autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical): AutoscaleInfo | null {
|
|
const p1Index = this._pointIndex(this._p1);
|
|
const p2Index = this._pointIndex(this._p2);
|
|
if (p1Index === null || p2Index === null) return null;
|
|
if (endTimePoint < p1Index || startTimePoint > p2Index) return null;
|
|
return {
|
|
priceRange: {
|
|
minValue: this._minPrice,
|
|
maxValue: this._maxPrice,
|
|
},
|
|
};
|
|
}
|
|
|
|
updateAllViews() {
|
|
this._paneViews.forEach(pw => pw.update());
|
|
}
|
|
|
|
paneViews() {
|
|
return this._paneViews;
|
|
}
|
|
|
|
_pointIndex(p: Point): number | null {
|
|
const coordinate = this._chart
|
|
.timeScale()
|
|
.timeToCoordinate(p.time);
|
|
if (coordinate === null) return null;
|
|
const index = this._chart.timeScale().coordinateToLogical(coordinate);
|
|
return index;
|
|
}
|
|
|
|
attached(param: SeriesAttachedParameter<Time>): void {
|
|
this._attachedParam = param;
|
|
}
|
|
|
|
detached(): void {
|
|
this._attachedParam = null;
|
|
}
|
|
|
|
setSelected(isSelected: boolean): void {
|
|
this._selected = isSelected;
|
|
this._series.applyOptions({});
|
|
}
|
|
|
|
hitTest(x: number, y: number): PrimitiveHoveredItem | null {
|
|
if (!this._attachedParam) return null;
|
|
|
|
const timeScale = this._chart.timeScale();
|
|
const x1 = timeScale.timeToCoordinate(this._p1.time);
|
|
const x2 = timeScale.timeToCoordinate(this._p2.time);
|
|
const y1 = this._series.priceToCoordinate(this._p1.price);
|
|
const y2 = this._series.priceToCoordinate(this._p2.price);
|
|
|
|
if (x1 === null || x2 === null || y1 === null || y2 === null) {
|
|
return null;
|
|
}
|
|
|
|
// Calculate perpendicular distance from point to line segment
|
|
const distance = this._distanceToSegment(x, y, x1, y1, x2, y2);
|
|
const tolerance = 10; // 10 CSS pixels
|
|
|
|
if (distance <= tolerance) {
|
|
return {
|
|
cursorStyle: 'pointer',
|
|
externalId: this._options.annotationId || '',
|
|
zOrder: 'normal',
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
_distanceToSegment(px: number, py: number, x1: number, y1: number, x2: number, y2: number): number {
|
|
const dx = x2 - x1;
|
|
const dy = y2 - y1;
|
|
const lengthSquared = dx * dx + dy * dy;
|
|
|
|
if (lengthSquared === 0) {
|
|
// Line segment is a point
|
|
return Math.sqrt((px - x1) * (px - x1) + (py - y1) * (py - y1));
|
|
}
|
|
|
|
// Project point onto line segment
|
|
let t = ((px - x1) * dx + (py - y1) * dy) / lengthSquared;
|
|
t = Math.max(0, Math.min(1, t));
|
|
|
|
const projX = x1 + t * dx;
|
|
const projY = y1 + t * dy;
|
|
|
|
return Math.sqrt((px - projX) * (px - projX) + (py - projY) * (py - projY));
|
|
}
|
|
}
|