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:
parent
28e3f83cf7
commit
bec0aeb6ca
14 changed files with 391 additions and 850 deletions
269
src/plugins/rectangle-drawing.ts
Normal file
269
src/plugins/rectangle-drawing.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import {
|
||||
ISeriesPrimitive,
|
||||
ISeriesPrimitivePaneView,
|
||||
ISeriesPrimitivePaneRenderer,
|
||||
SeriesAttachedParameter,
|
||||
Time,
|
||||
Logical,
|
||||
PrimitiveHoveredItem,
|
||||
} from 'lightweight-charts';
|
||||
|
||||
export interface RectanglePoint {
|
||||
time: Time;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface RectangleDrawingOptions {
|
||||
p1: RectanglePoint;
|
||||
p2: RectanglePoint;
|
||||
color: string;
|
||||
isPreview?: boolean;
|
||||
annotationId?: string;
|
||||
}
|
||||
|
||||
class RectangleDrawingPaneView implements ISeriesPrimitivePaneView {
|
||||
private _options: RectangleDrawingOptions;
|
||||
private _isSelected: boolean;
|
||||
private _series: SeriesAttachedParameter<Time> | null = null;
|
||||
|
||||
constructor(options: RectangleDrawingOptions, isSelected: boolean = false) {
|
||||
this._options = options;
|
||||
this._isSelected = isSelected;
|
||||
}
|
||||
|
||||
update(options: RectangleDrawingOptions, isSelected: boolean, series: SeriesAttachedParameter<Time> | null): void {
|
||||
this._options = options;
|
||||
this._isSelected = isSelected;
|
||||
this._series = series;
|
||||
}
|
||||
|
||||
renderer() {
|
||||
return new RectangleDrawingRenderer(this._options, this._isSelected, this._series);
|
||||
}
|
||||
|
||||
zOrder(): 'bottom' | 'normal' | 'top' {
|
||||
return 'bottom'; // Render behind candlesticks
|
||||
}
|
||||
}
|
||||
|
||||
class RectangleDrawingRenderer implements ISeriesPrimitivePaneRenderer {
|
||||
private _options: RectangleDrawingOptions;
|
||||
private _isSelected: boolean;
|
||||
private _series: SeriesAttachedParameter<Time> | null;
|
||||
|
||||
constructor(options: RectangleDrawingOptions, isSelected: boolean, series: SeriesAttachedParameter<Time> | null) {
|
||||
this._options = options;
|
||||
this._isSelected = isSelected;
|
||||
this._series = series;
|
||||
}
|
||||
|
||||
draw(target: any): void {
|
||||
if (!this._series) return;
|
||||
|
||||
target.useBitmapCoordinateSpace((scope: any) => {
|
||||
const ctx = scope.context;
|
||||
const scalingFactor = scope.horizontalPixelRatio;
|
||||
|
||||
const timeScale = this._series!.chart.timeScale();
|
||||
const series = this._series!.series;
|
||||
|
||||
// Convert time coordinates to pixel x coordinates
|
||||
const x1 = timeScale.timeToCoordinate(this._options.p1.time);
|
||||
const x2 = timeScale.timeToCoordinate(this._options.p2.time);
|
||||
|
||||
// Convert price coordinates to pixel y coordinates
|
||||
const y1 = series.priceToCoordinate(this._options.p1.price);
|
||||
const y2 = series.priceToCoordinate(this._options.p2.price);
|
||||
|
||||
// Check if coordinates are valid
|
||||
if (x1 === null || x2 === null || y1 === null || y2 === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Scale coordinates for HiDPI
|
||||
const scaledX1 = x1 * scalingFactor;
|
||||
const scaledX2 = x2 * scalingFactor;
|
||||
const scaledY1 = y1 * scalingFactor;
|
||||
const scaledY2 = y2 * scalingFactor;
|
||||
|
||||
// Calculate rectangle dimensions
|
||||
const rectX = Math.min(scaledX1, scaledX2);
|
||||
const rectY = Math.min(scaledY1, scaledY2);
|
||||
const rectWidth = Math.abs(scaledX2 - scaledX1);
|
||||
const rectHeight = Math.abs(scaledY2 - scaledY1);
|
||||
|
||||
// Determine opacity based on preview and selection state
|
||||
let fillOpacity = 0.2;
|
||||
let borderOpacity = 0.5;
|
||||
let borderWidth = 1 * scalingFactor;
|
||||
|
||||
if (this._options.isPreview) {
|
||||
fillOpacity = 0.1;
|
||||
borderOpacity = 0.3;
|
||||
} else if (this._isSelected) {
|
||||
fillOpacity = 0.3;
|
||||
borderOpacity = 0.8;
|
||||
borderWidth = 2 * scalingFactor;
|
||||
}
|
||||
|
||||
const fillColor = this._hexToRgba(this._options.color, fillOpacity);
|
||||
const borderColor = this._hexToRgba(this._options.color, borderOpacity);
|
||||
|
||||
// Draw filled rectangle
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fillRect(rectX, rectY, rectWidth, rectHeight);
|
||||
|
||||
// Draw border with optional dashed style for preview
|
||||
ctx.strokeStyle = borderColor;
|
||||
ctx.lineWidth = borderWidth;
|
||||
|
||||
if (this._options.isPreview) {
|
||||
ctx.setLineDash([5 * scalingFactor, 5 * scalingFactor]);
|
||||
} else {
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
ctx.strokeRect(rectX, rectY, rectWidth, rectHeight);
|
||||
ctx.setLineDash([]);
|
||||
});
|
||||
}
|
||||
|
||||
private _hexToRgba(hex: string, alpha: number): string {
|
||||
// Remove # if present
|
||||
hex = hex.replace('#', '');
|
||||
|
||||
// Parse hex to RGB
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
}
|
||||
|
||||
export class RectangleDrawingPrimitive implements ISeriesPrimitive<Time> {
|
||||
private _options: RectangleDrawingOptions;
|
||||
private _paneView: RectangleDrawingPaneView;
|
||||
private _series: SeriesAttachedParameter<Time> | null = null;
|
||||
private _isSelected: boolean = false;
|
||||
|
||||
constructor(options: RectangleDrawingOptions) {
|
||||
this._options = options;
|
||||
this._paneView = new RectangleDrawingPaneView(options, false);
|
||||
}
|
||||
|
||||
attached(param: SeriesAttachedParameter<Time>): void {
|
||||
this._series = param;
|
||||
this.updateAllViews();
|
||||
}
|
||||
|
||||
detached(): void {
|
||||
this._series = null;
|
||||
}
|
||||
|
||||
updateAllViews(): void {
|
||||
this._paneView.update(this._options, this._isSelected, this._series);
|
||||
}
|
||||
|
||||
paneViews() {
|
||||
return [this._paneView];
|
||||
}
|
||||
|
||||
updatePoints(p1: RectanglePoint, p2: RectanglePoint): void {
|
||||
this._options.p1 = p1;
|
||||
this._options.p2 = p2;
|
||||
this.updateAllViews();
|
||||
}
|
||||
|
||||
setSelected(isSelected: boolean): void {
|
||||
this._isSelected = isSelected;
|
||||
this.updateAllViews();
|
||||
}
|
||||
|
||||
hitTest(x: number, y: number): PrimitiveHoveredItem | null {
|
||||
if (!this._series) return null;
|
||||
|
||||
const chart = this._series.chart;
|
||||
const timeScale = chart.timeScale();
|
||||
|
||||
// Convert pixel x to time coordinate
|
||||
const time = timeScale.coordinateToTime(x);
|
||||
if (!time) return null;
|
||||
|
||||
const timeValue = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
||||
|
||||
// Convert pixel y to price coordinate
|
||||
const price = this._series.series.coordinateToPrice(y);
|
||||
if (!price) return null;
|
||||
|
||||
// Get time values for both corners
|
||||
const time1 = typeof this._options.p1.time === 'string'
|
||||
? Date.parse(this._options.p1.time) / 1000
|
||||
: (this._options.p1.time as number);
|
||||
const time2 = typeof this._options.p2.time === 'string'
|
||||
? Date.parse(this._options.p2.time) / 1000
|
||||
: (this._options.p2.time as number);
|
||||
|
||||
const minTime = Math.min(time1, time2);
|
||||
const maxTime = Math.max(time1, time2);
|
||||
const minPrice = Math.min(this._options.p1.price, this._options.p2.price);
|
||||
const maxPrice = Math.max(this._options.p1.price, this._options.p2.price);
|
||||
|
||||
// Check if click is within rectangle bounds
|
||||
const withinTimeRange = timeValue >= minTime && timeValue <= maxTime;
|
||||
const withinPriceRange = price >= minPrice && price <= maxPrice;
|
||||
|
||||
if (withinTimeRange && withinPriceRange) {
|
||||
return {
|
||||
cursorStyle: 'pointer',
|
||||
externalId: this._options.annotationId || '',
|
||||
zOrder: 'bottom',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
autoscaleInfo(startTimePoint: Logical, endTimePoint: Logical) {
|
||||
if (!this._series) return null;
|
||||
|
||||
const timeScale = this._series.chart.timeScale();
|
||||
const startTime = timeScale.coordinateToTime(startTimePoint as any);
|
||||
const endTime = timeScale.coordinateToTime(endTimePoint as any);
|
||||
|
||||
if (!startTime || !endTime) return null;
|
||||
|
||||
const startValue = typeof startTime === 'string' ? Date.parse(startTime) / 1000 : (startTime as number);
|
||||
const endValue = typeof endTime === 'string' ? Date.parse(endTime) / 1000 : (endTime as number);
|
||||
|
||||
// Get time values for both corners
|
||||
const time1 = typeof this._options.p1.time === 'string'
|
||||
? Date.parse(this._options.p1.time) / 1000
|
||||
: (this._options.p1.time as number);
|
||||
const time2 = typeof this._options.p2.time === 'string'
|
||||
? Date.parse(this._options.p2.time) / 1000
|
||||
: (this._options.p2.time as number);
|
||||
|
||||
const minTime = Math.min(time1, time2);
|
||||
const maxTime = Math.max(time1, time2);
|
||||
|
||||
// Check if rectangle overlaps with visible time range
|
||||
const overlaps =
|
||||
(minTime >= startValue && minTime <= endValue) ||
|
||||
(maxTime >= startValue && maxTime <= endValue) ||
|
||||
(minTime <= startValue && maxTime >= endValue);
|
||||
|
||||
if (!overlaps) return null;
|
||||
|
||||
const minPrice = Math.min(this._options.p1.price, this._options.p2.price);
|
||||
const maxPrice = Math.max(this._options.p1.price, this._options.p2.price);
|
||||
|
||||
// Return price range of the rectangle
|
||||
return {
|
||||
priceRange: {
|
||||
minValue: minPrice,
|
||||
maxValue: maxPrice,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue