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. SpanRectanglePrimitive (Chart Rendering)
|
||||||
|
|
||||||
- [ ] 4.1 Create `SpanRectanglePrimitive.ts` implementing `ISeriesPrimitive` with data-space rectangle coordinates (start_time, end_time, max_high, min_low)
|
- [x] 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`
|
- [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`
|
||||||
- [ ] 4.3 Implement label tag text rendering above the rectangle (pattern name)
|
- [x] 4.3 Implement label tag text rendering above the rectangle (pattern name)
|
||||||
- [ ] 4.4 Implement `hitTest()` to detect clicks within the rectangle bounds
|
- [x] 4.4 Implement `hitTest()` to detect clicks within the rectangle bounds
|
||||||
- [ ] 4.5 Add highlight state (thicker border / increased opacity) for selected spans
|
- [x] 4.5 Add highlight state (thicker border / increased opacity) for selected spans
|
||||||
- [ ] 4.6 Set `zOrder: 'bottom'` so rectangles render behind candlesticks
|
- [x] 4.6 Set `zOrder: 'bottom'` so rectangles render behind candlesticks
|
||||||
|
|
||||||
## 5. Span Tool State & Integration
|
## 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