feat: enhance TrendLine plugin and create RectangleDrawingPrimitive

- 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
This commit is contained in:
Marko Djordjevic 2026-02-16 11:51:07 +01:00
parent 28e3f83cf7
commit bec0aeb6ca
14 changed files with 391 additions and 850 deletions

View file

@ -11,6 +11,8 @@ import {
SeriesOptionsMap,
SeriesType,
Time,
SeriesAttachedParameter,
PrimitiveHoveredItem,
} from 'lightweight-charts';
class TrendLinePaneRenderer implements IPrimitivePaneRenderer {
@ -19,13 +21,15 @@ class TrendLinePaneRenderer implements IPrimitivePaneRenderer {
_text1: string;
_text2: string;
_options: TrendLineOptions;
_selected: boolean;
constructor(p1: ViewPoint, p2: ViewPoint, text1: string, text2: string, options: TrendLineOptions) {
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) {
@ -42,12 +46,50 @@ class TrendLinePaneRenderer implements IPrimitivePaneRenderer {
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);
ctx.lineWidth = this._options.width;
// 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);
@ -101,7 +143,8 @@ class TrendLinePaneView implements IPrimitivePaneView {
this._p2,
'' + this._source._p1.price.toFixed(1),
'' + this._source._p2.price.toFixed(1),
this._source._options
this._source._options,
this._source._selected
);
}
}
@ -117,6 +160,8 @@ export interface TrendLineOptions {
showLabels: boolean;
labelBackgroundColor: string;
labelTextColor: string;
isPreview?: boolean;
annotationId?: string;
}
const defaultOptions: TrendLineOptions = {
@ -136,6 +181,8 @@ export class TrendLine implements ISeriesPrimitive<Time> {
_options: TrendLineOptions;
_minPrice: number;
_maxPrice: number;
_selected: boolean = false;
_attachedParam: SeriesAttachedParameter<Time> | null = null;
constructor(
chart: IChartApi,
@ -201,4 +248,65 @@ export class TrendLine implements ISeriesPrimitive<Time> {
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));
}
}