feat: implement SpanRectanglePrimitive for span annotation rendering
- Created SpanRectanglePrimitive.ts implementing ISeriesPrimitive interface - Implemented updateAllViews() with coordinate conversion from data-space to pixel-space - Added semi-transparent rectangle rendering with configurable opacity - Implemented label tag rendering above rectangles showing pattern names - Added hitTest() for click detection within span bounds - Implemented highlight state with thicker borders and increased opacity - Set zOrder to 'bottom' to render rectangles behind candlesticks - Completed Section 4 tasks (4.1-4.6) from span-annotation specification Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7c1007f712
commit
5ea63a613e
2 changed files with 246 additions and 6 deletions
|
|
@ -21,12 +21,12 @@
|
|||
|
||||
## 4. SpanRectanglePrimitive (Chart Rendering)
|
||||
|
||||
- [ ] 4.1 Create `SpanRectanglePrimitive.ts` implementing `ISeriesPrimitive` with data-space rectangle coordinates (start_time, end_time, max_high, min_low)
|
||||
- [ ] 4.2 Implement `updateAllViews()` with a pane renderer that converts time/price to pixel coordinates and draws a filled semi-transparent rectangle using `useBitmapCoordinateSpace`
|
||||
- [ ] 4.3 Implement label tag text rendering above the rectangle (pattern name)
|
||||
- [ ] 4.4 Implement `hitTest()` to detect clicks within the rectangle bounds
|
||||
- [ ] 4.5 Add highlight state (thicker border / increased opacity) for selected spans
|
||||
- [ ] 4.6 Set `zOrder: 'bottom'` so rectangles render behind candlesticks
|
||||
- [x] 4.1 Create `SpanRectanglePrimitive.ts` implementing `ISeriesPrimitive` with data-space rectangle coordinates (start_time, end_time, max_high, min_low)
|
||||
- [x] 4.2 Implement `updateAllViews()` with a pane renderer that converts time/price to pixel coordinates and draws a filled semi-transparent rectangle using `useBitmapCoordinateSpace`
|
||||
- [x] 4.3 Implement label tag text rendering above the rectangle (pattern name)
|
||||
- [x] 4.4 Implement `hitTest()` to detect clicks within the rectangle bounds
|
||||
- [x] 4.5 Add highlight state (thicker border / increased opacity) for selected spans
|
||||
- [x] 4.6 Set `zOrder: 'bottom'` so rectangles render behind candlesticks
|
||||
|
||||
## 5. Span Tool State & Integration
|
||||
|
||||
|
|
|
|||
240
src/components/SpanRectanglePrimitive.ts
Normal file
240
src/components/SpanRectanglePrimitive.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import {
|
||||
ISeriesPrimitive,
|
||||
ISeriesPrimitivePaneView,
|
||||
SeriesAttachedParameter,
|
||||
Time,
|
||||
Logical,
|
||||
} from 'lightweight-charts';
|
||||
|
||||
export interface SpanData {
|
||||
id: number;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
label: string;
|
||||
color: string;
|
||||
max_high: number;
|
||||
min_low: number;
|
||||
}
|
||||
|
||||
export interface SpanRectanglePrimitiveOptions {
|
||||
data: SpanData;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
class SpanRectanglePaneView implements ISeriesPrimitivePaneView {
|
||||
private _data: SpanData;
|
||||
private _isSelected: boolean;
|
||||
private _series: SeriesAttachedParameter<Time> | null = null;
|
||||
|
||||
constructor(data: SpanData, isSelected: boolean = false) {
|
||||
this._data = data;
|
||||
this._isSelected = isSelected;
|
||||
}
|
||||
|
||||
update(data: SpanData, isSelected: boolean, series: SeriesAttachedParameter<Time> | null): void {
|
||||
this._data = data;
|
||||
this._isSelected = isSelected;
|
||||
this._series = series;
|
||||
}
|
||||
|
||||
renderer() {
|
||||
return new SpanRectangleRenderer(this._data, this._isSelected, this._series);
|
||||
}
|
||||
|
||||
zOrder(): 'bottom' | 'normal' | 'top' {
|
||||
return 'bottom'; // Render behind candlesticks
|
||||
}
|
||||
}
|
||||
|
||||
class SpanRectangleRenderer {
|
||||
private _data: SpanData;
|
||||
private _isSelected: boolean;
|
||||
private _series: SeriesAttachedParameter<Time> | null;
|
||||
|
||||
constructor(data: SpanData, isSelected: boolean, series: SeriesAttachedParameter<Time> | null) {
|
||||
this._data = data;
|
||||
this._isSelected = isSelected;
|
||||
this._series = series;
|
||||
}
|
||||
|
||||
draw(target: CanvasRenderingContext2D): void {
|
||||
if (!this._series) return;
|
||||
|
||||
target.save();
|
||||
|
||||
const timeScale = this._series.chart.timeScale();
|
||||
const series = this._series.series;
|
||||
|
||||
// Convert time coordinates to pixel x coordinates
|
||||
const x1 = timeScale.timeToCoordinate(this._data.start_time as Time);
|
||||
const x2 = timeScale.timeToCoordinate(this._data.end_time as Time);
|
||||
|
||||
// Convert price coordinates to pixel y coordinates
|
||||
const y1 = series.priceToCoordinate(this._data.max_high);
|
||||
const y2 = series.priceToCoordinate(this._data.min_low);
|
||||
|
||||
// Check if coordinates are valid
|
||||
if (x1 === null || x2 === null || y1 === null || y2 === null) {
|
||||
target.restore();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse color and apply transparency
|
||||
const baseOpacity = this._isSelected ? 0.3 : 0.2;
|
||||
const fillColor = this._hexToRgba(this._data.color, baseOpacity);
|
||||
const borderOpacity = this._isSelected ? 0.8 : 0.5;
|
||||
const borderColor = this._hexToRgba(this._data.color, borderOpacity);
|
||||
const borderWidth = this._isSelected ? 2 : 1;
|
||||
|
||||
// Calculate rectangle dimensions
|
||||
const rectX = Math.min(x1, x2);
|
||||
const rectY = Math.min(y1, y2);
|
||||
const rectWidth = Math.abs(x2 - x1);
|
||||
const rectHeight = Math.abs(y2 - y1);
|
||||
|
||||
// Draw filled rectangle
|
||||
target.fillStyle = fillColor;
|
||||
target.fillRect(rectX, rectY, rectWidth, rectHeight);
|
||||
|
||||
// Draw border
|
||||
target.strokeStyle = borderColor;
|
||||
target.lineWidth = borderWidth;
|
||||
target.strokeRect(rectX, rectY, rectWidth, rectHeight);
|
||||
|
||||
// Draw label tag above rectangle
|
||||
this._drawLabel(target, rectX, rectY, rectWidth);
|
||||
|
||||
target.restore();
|
||||
}
|
||||
|
||||
private _drawLabel(ctx: CanvasRenderingContext2D, rectX: number, rectY: number, rectWidth: number): void {
|
||||
const label = this._data.label.replace(/_/g, ' ').toUpperCase();
|
||||
const fontSize = 11;
|
||||
const padding = 4;
|
||||
|
||||
ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
|
||||
const textMetrics = ctx.measureText(label);
|
||||
const textWidth = textMetrics.width;
|
||||
const textHeight = fontSize;
|
||||
|
||||
// Position label at top-left of rectangle, slightly offset
|
||||
const labelX = rectX + 4;
|
||||
const labelY = rectY - 2;
|
||||
|
||||
// Draw background
|
||||
ctx.fillStyle = this._data.color;
|
||||
ctx.fillRect(
|
||||
labelX - padding,
|
||||
labelY - textHeight - padding,
|
||||
textWidth + padding * 2,
|
||||
textHeight + padding * 2
|
||||
);
|
||||
|
||||
// Draw text
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(label, labelX, labelY - textHeight);
|
||||
}
|
||||
|
||||
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 SpanRectanglePrimitive implements ISeriesPrimitive<Time> {
|
||||
private _options: SpanRectanglePrimitiveOptions;
|
||||
private _paneView: SpanRectanglePaneView;
|
||||
private _series: SeriesAttachedParameter<Time> | null = null;
|
||||
|
||||
constructor(options: SpanRectanglePrimitiveOptions) {
|
||||
this._options = options;
|
||||
this._paneView = new SpanRectanglePaneView(options.data, options.isSelected ?? false);
|
||||
}
|
||||
|
||||
attached(param: SeriesAttachedParameter<Time>): void {
|
||||
this._series = param;
|
||||
this.updateAllViews();
|
||||
}
|
||||
|
||||
detached(): void {
|
||||
this._series = null;
|
||||
}
|
||||
|
||||
updateAllViews(): void {
|
||||
this._paneView.update(this._options.data, this._options.isSelected ?? false, this._series);
|
||||
}
|
||||
|
||||
paneViews() {
|
||||
return [this._paneView];
|
||||
}
|
||||
|
||||
updateData(data: SpanData): void {
|
||||
this._options.data = data;
|
||||
this.updateAllViews();
|
||||
}
|
||||
|
||||
setSelected(isSelected: boolean): void {
|
||||
this._options.isSelected = isSelected;
|
||||
this.updateAllViews();
|
||||
}
|
||||
|
||||
hitTest(x: number, y: number): boolean {
|
||||
if (!this._series) return false;
|
||||
|
||||
const chart = this._series.chart;
|
||||
const timeScale = chart.timeScale();
|
||||
|
||||
// Convert pixel x to time coordinate
|
||||
const time = timeScale.coordinateToTime(x);
|
||||
if (!time) return false;
|
||||
|
||||
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 false;
|
||||
|
||||
// Check if click is within span bounds
|
||||
const withinTimeRange = timeValue >= this._options.data.start_time && timeValue <= this._options.data.end_time;
|
||||
const withinPriceRange = price >= this._options.data.min_low && price <= this._options.data.max_high;
|
||||
|
||||
return withinTimeRange && withinPriceRange;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Check if span overlaps with visible time range
|
||||
const overlaps =
|
||||
(this._options.data.start_time >= startValue && this._options.data.start_time <= endValue) ||
|
||||
(this._options.data.end_time >= startValue && this._options.data.end_time <= endValue) ||
|
||||
(this._options.data.start_time <= startValue && this._options.data.end_time >= endValue);
|
||||
|
||||
if (!overlaps) return null;
|
||||
|
||||
// Return price range of the span
|
||||
return {
|
||||
priceRange: {
|
||||
minValue: this._options.data.min_low,
|
||||
maxValue: this._options.data.max_high,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue