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:
Marko Djordjevic 2026-02-14 06:35:01 +01:00
parent 7c1007f712
commit 5ea63a613e
2 changed files with 246 additions and 6 deletions

View file

@ -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

View 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,
},
};
}
}