feat: implement sections 6-7 - span selection, preview, and label assignment popover

This commit is contained in:
Marko Djordjevic 2026-02-14 10:10:41 +01:00
parent c9d2cbfc4b
commit 586f02ed69
11 changed files with 647 additions and 22483 deletions

2593
EURUSD.csv

File diff suppressed because it is too large Load diff

18300
EURUSD.csv~

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,324 +0,0 @@
# Line Drawing Improvements Plan
## Context
The candle annotator app has a working line drawing feature, but it needs enhancements:
- Currently only draws blue lines
- No way to select or edit lines after creation
- Limited visual feedback during drawing
- Delete tool works by clicking near line
## Current State
- Line drawing is working: click to start, move mouse (dashed preview), click to finish
- Lines are stored in database with geometry (startTime, startPrice, endTime, endPrice)
- Lines maintain relative position to candles during zoom/pan (coordinate conversion working)
- SVG overlay is properly layered (z-index) and sized (CSS 100%)
## Approved Design Decisions
1. **Color**: Preset color buttons below "Draw Line" button (Option C)
2. **Selection**: Lines selectable only when "Draw Line" tool is active (Option B)
3. **Editing**: Drag endpoints only - no full line dragging (simplest approach)
4. **Visual Feedback**: Add small circle at mouse cursor during drawing
## Implementation Plan
### Phase 1: Color Support ✅ DONE
#### 1.1 Database Schema
**File**: `src/lib/db/schema.ts`
```typescript
// Add to annotations table:
color: text('color').default('#3b82f6'), // hex color code
```
- Need to run migration or recreate database
- Default color: `#3b82f6` (blue)
#### 1.2 Toolbox UI
**File**: `src/components/Toolbox.tsx`
- Add state: `const [selectedColor, setSelectedColor] = useState('#3b82f6')`
- Add preset color buttons below "Draw Line" button
- Colors: Red (#ef4444), Green (#22c55e), Blue (#3b82f6), Yellow (#eab308), White (#ffffff)
- Active color button should be highlighted (variant='default', others 'outline')
- Pass `selectedColor` up to parent via new prop: `onColorChange`
#### 1.3 Page Component
**File**: `src/app/page.tsx`
- Add state: `const [selectedColor, setSelectedColor] = useState('#3b82f6')`
- Pass to Toolbox: `onColorChange={setSelectedColor}`
- Pass to CandleChart: `selectedColor={selectedColor}`
#### 1.4 Chart Component
**File**: `src/components/CandleChart.tsx`
- Accept new prop: `selectedColor: string`
- Pass to SvgOverlay: `selectedColor={selectedColor}`
#### 1.5 SVG Overlay - Create with Color
**File**: `src/components/SvgOverlay.tsx`
- Accept new prop: `selectedColor: string`
- In `handleClick` when saving line (second click), add color to POST body:
```typescript
body: JSON.stringify({
timestamp: drawingLine.start.time,
label_type: 'line',
color: selectedColor, // NEW
geometry: { ... }
})
```
#### 1.6 SVG Overlay - Render with Color
**File**: `src/components/SvgOverlay.tsx`
- In `renderLines()`, read color from annotation:
```typescript
stroke={annotation.color || '#3b82f6'}
```
#### 1.7 API - Handle Color
**File**: `src/app/api/annotations/route.ts`
- In POST handler, extract `color` from body
- Save to database: `color: body.color || '#3b82f6'`
---
### Phase 2: Visual Feedback ✅ DONE
#### 2.1 Cursor Circle During Drawing
**File**: `src/components/SvgOverlay.tsx`
- Add new render function:
```typescript
const renderCursorCircle = () => {
if (!drawingLine || !mousePosition) return null;
return (
<circle
cx={mousePosition.x}
cy={mousePosition.y}
r={5}
fill="transparent"
stroke={selectedColor}
strokeWidth={2}
/>
);
};
```
- Call in SVG return: `{renderCursorCircle()}`
---
### Phase 3: Line Selection ✅ DONE
#### 3.1 Selection State
**File**: `src/components/SvgOverlay.tsx`
- Add state: `const [selectedLineId, setSelectedLineId] = useState<number | null>(null)`
#### 3.2 Click Logic Update
**File**: `src/components/SvgOverlay.tsx`
- In `handleClick`, when `activeTool === 'line'`:
- If NOT drawing line (drawingLine === null):
- First check if clicking near existing line
- If yes → select it (setSelectedLineId)
- If no → start new line
- If drawing line:
- Finish and save line (existing behavior)
#### 3.3 Visual Styling for Selected Line
**File**: `src/components/SvgOverlay.tsx`
- In `renderLines()`, check if line is selected:
```typescript
const isSelected = annotation.id === selectedLineId;
strokeWidth={isSelected ? 3 : 2}
opacity={isSelected ? 1 : 0.85}
```
#### 3.4 Deselect on Tool Change or Escape
**File**: `src/components/SvgOverlay.tsx`
- Reset `selectedLineId` when `activeTool` changes
- Add to existing Escape key handler: also clear `selectedLineId`
---
### Phase 4: Endpoint Dragging ✅ DONE
#### 4.1 Drag State
**File**: `src/components/SvgOverlay.tsx`
- Add state:
```typescript
interface DragState {
lineId: number;
endpoint: 'start' | 'end';
originalPoint: Point;
}
const [dragState, setDragState] = useState<DragState | null>(null);
```
#### 4.2 Render Endpoint Handles
**File**: `src/components/SvgOverlay.tsx`
- Add new render function:
```typescript
const renderHandles = () => {
if (!selectedLineId) return null;
const line = annotations.find(a => a.id === selectedLineId);
if (!line || !line.geometry) return null;
const start = dataToPixel(line.geometry.startTime, line.geometry.startPrice);
const end = dataToPixel(line.geometry.endTime, line.geometry.endPrice);
if (!start || !end) return null;
return (
<>
<circle
cx={start.x} cy={start.y} r={6}
fill="white"
stroke={line.color || '#3b82f6'}
strokeWidth={2}
style={{ cursor: 'move' }}
onMouseDown={(e) => handleHandleMouseDown(e, line.id, 'start')}
/>
<circle
cx={end.x} cy={end.y} r={6}
fill="white"
stroke={line.color || '#3b82f6'}
strokeWidth={2}
style={{ cursor: 'move' }}
onMouseDown={(e) => handleHandleMouseDown(e, line.id, 'end')}
/>
</>
);
};
```
#### 4.3 Handle Drag Interaction
**File**: `src/components/SvgOverlay.tsx`
- Implement handlers:
```typescript
const handleHandleMouseDown = (e: React.MouseEvent, lineId: number, endpoint: 'start' | 'end') => {
e.stopPropagation(); // Prevent line click
const line = annotations.find(a => a.id === lineId);
if (!line?.geometry) return;
const point = endpoint === 'start'
? { time: line.geometry.startTime, price: line.geometry.startPrice }
: { time: line.geometry.endTime, price: line.geometry.endPrice };
setDragState({ lineId, endpoint, originalPoint: point });
};
```
#### 4.4 Mouse Move During Drag
**File**: `src/components/SvgOverlay.tsx`
- Update `handleMouseMove` to handle drag preview:
```typescript
if (dragState && mousePosition) {
const dataPoint = pixelToData(mousePosition.x, mousePosition.y);
if (dataPoint) {
// Update local preview of line position
// (optimistically update annotations array temporarily)
}
}
```
#### 4.5 Mouse Up - Save to Database
**File**: `src/components/SvgOverlay.tsx`
- Add handler:
```typescript
const handleMouseUp = async () => {
if (!dragState || !mousePosition) return;
const dataPoint = pixelToData(mousePosition.x, mousePosition.y);
if (!dataPoint) return;
const line = annotations.find(a => a.id === dragState.lineId);
if (!line?.geometry) return;
const updatedGeometry = {
...line.geometry,
...(dragState.endpoint === 'start'
? { startTime: dataPoint.time, startPrice: dataPoint.price }
: { endTime: dataPoint.time, endPrice: dataPoint.price })
};
await fetch(`/api/annotations/${dragState.lineId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ geometry: updatedGeometry })
});
await fetchAnnotations();
setDragState(null);
onAnnotationChange?.();
};
```
- Add to SVG: `onMouseUp={handleMouseUp}`
#### 4.6 API - PATCH Endpoint
**File**: `src/app/api/annotations/[id]/route.ts`
- Add PATCH handler:
```typescript
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const body = await request.json();
const { geometry } = body;
const result = await db
.update(annotations)
.set({ geometry: JSON.stringify(geometry) })
.where(eq(annotations.id, parseInt(params.id)))
.returning();
const updated = result[0];
return NextResponse.json({
...updated,
geometry: updated.geometry ? JSON.parse(updated.geometry) : null
});
} catch (error: any) {
return NextResponse.json(
{ error: error.message || 'Failed to update annotation' },
{ status: 500 }
);
}
}
```
---
## Testing Checklist
After implementation, verify:
- [ ] Can select different colors before drawing line
- [ ] Lines are drawn in selected color
- [ ] Existing lines render with their stored color
- [ ] Small circle appears at cursor during line drawing
- [ ] Can click existing line (in line tool mode) to select it
- [ ] Selected line has visual highlight (thicker stroke)
- [ ] Selected line shows endpoint handles (white circles)
- [ ] Can drag endpoint handles to reposition line
- [ ] Line updates in database after dragging
- [ ] Lines maintain position during zoom/pan
- [ ] Delete tool still works (click near line)
- [ ] Can deselect by clicking empty space or pressing Escape
- [ ] Switching tools deselects line
---
## Files Modified Summary
1. `src/lib/db/schema.ts` - Add color field
2. `src/components/Toolbox.tsx` - Color picker UI
3. `src/app/page.tsx` - Pass color state
4. `src/components/CandleChart.tsx` - Pass color to overlay
5. `src/components/SvgOverlay.tsx` - Selection, dragging, visual feedback
6. `src/app/api/annotations/route.ts` - Handle color in POST
7. `src/app/api/annotations/[id]/route.ts` - NEW: PATCH handler
---
## Notes
- Lines use data coordinates (time/price) so they survive zoom/pan automatically
- SVG overlay has z-index and proper sizing - this is working
- Current delete functionality works by clicking near line - keep this behavior
- Color state is managed at page level, flows down to components
- Database migration needed for color field (or recreate dev database)

92
PLAN.md
View file

@ -1,92 +0,0 @@
This is a great project. Combining financial charting with manual labeling is the first step toward building a custom machine-learning model for trading.
To give you the best "TradingView" feel, we will use **Next.js** for the framework and **Lightweight Charts** (by TradingView) for the engine. For the drawing and labeling layer, well implement a custom "Overlay" system to handle clicks and coordinate mapping.
---
## 🛠 Technical Architecture
### 1. The Stack
* **Framework:** Next.js (App Router) for a full-stack React experience.
* **Charting Engine:** `lightweight-charts` (the library behind TradingView's lightweight version).
* **Database:** SQLite using **Drizzle ORM** (lightweight, fast, and type-safe).
* **State Management:** React `useState` and `useRef` to track the "Active Labeling Mode."
* **Data Ingestion:** `papaparse` for fast CSV parsing.
### 2. Data Schema
We need two main tables in SQLite:
* **`candles`**: Stores the OHLC data (Open, High, Low, Close, Time).
* **`annotations`**: Stores the labels.
* `id`: Primary key.
* `timestamp`: The exact candle time the label belongs to.
* `label_type`: "break_up", "trend_down", etc.
* `geometry`: JSON string (to store line coordinates if drawing lines).
### 3. The "Annotation Overlay" Logic
Lightweight Charts is optimized for performance, not "drawing" by default. To make it feel interactive:
1. **Coordinate Mapping:** We use the `chart.timeScale().coordinateToTime()` and `series.priceToCoordinate()` methods to convert a user's mouse click on the screen into a specific price and time in the database.
2. **Visual Markers:** We use the built-in `setMarkers` API to show icons (up/down arrows) for specific patterns like "Break Up."
3. **Drawing Lines:** For lines, we will use "Price Lines" or a transparent **SVG Overlay** that sits on top of the chart and scales as the user zooms.
---
## 📝 Coding Instructions for LLM
**Copy and paste the following prompt into your coding LLM (e.g., Claude 3.5 Sonnet or GPT-4o):**
---
### **Prompt for AI Developer**
**Objective:** Build a Next.js web application for EUR/USD candle annotation.
**Tech Stack:** Next.js (App Router), TypeScript, Tailwind CSS, `lightweight-charts`, `lucide-react`, `papaparse`, and SQLite (using Drizzle ORM).
**Core Features to Implement:**
**1. Data Ingestion:**
* Create a file upload component that accepts a CSV.
* CSV Format: `time, open, high, low, close`.
* Parse the CSV and store the records in a local SQLite database.
**2. The Chart Canvas:**
* Use `lightweight-charts` to render a candlestick chart.
* Implement a "Toolbox" sidebar with buttons: "Label: Break Up", "Label: Break Down", "Draw Line", "Delete".
* When a user clicks "Label: Break Up" and then clicks a candle on the chart, save that timestamp and label to the `annotations` table.
**3. Interaction Logic:**
* **Markers:** If an annotation exists at a timestamp, display it on the chart using the `series.setMarkers()` API (e.g., a green 'aboveBar' arrow for Break Up).
* **Line Drawing:** Implement a basic "two-click" line tool. Click 1 sets start point (Price/Time); Click 2 sets end point. Save coordinates as JSON in SQLite.
**4. Backend API:**
* `POST /api/upload`: Parse CSV and populate DB.
* `GET /api/annotations`: Fetch all saved labels for the current chart.
* `POST /api/annotations`: Save a new label or drawing.
**5. UI Requirements:**
* Dark mode theme (Slate-900).
* Sidebar for tool selection.
* Main area for the chart (responsive height).
* Simple "Export" button to download the `annotations` table as a new CSV (Timestamp, Label, Price).
**Please provide the code in a modular structure: `/components`, `/lib/db`, and `/app/api`.**
---
## 🚀 Next Steps
1. **Prepare your CSV:** Ensure your EUR/USD data has headers named `time` (in 'YYYY-MM-DD' or Unix format), `open`, `high`, `low`, and `close`.
2. **Initialize the project:** If you are running this locally, you will need to run `npx create-next-app@latest` first.
**Would you like me to generate the database schema file (Drizzle/SQLite) for you to get started?**

View file

@ -38,19 +38,19 @@
## 6. Two-Click Span Selection & Preview
- [ ] 6.1 Create `SpanAnnotationManager.tsx` component that manages span interaction state (idle / first-click-done / popover-open)
- [ ] 6.2 Implement first-click handler: snap to nearest candle, store start candle, render start marker via a preview primitive
- [ ] 6.3 Implement mouse-move preview: stretch preview rectangle from start candle to cursor candle, computing price range (min low to max high) of candles in range
- [ ] 6.4 Implement second-click handler: snap to nearest candle, finalize span range, swap if end < start, trigger popover
- [ ] 6.5 Implement Escape key to cancel span selection and clear preview
- [x] 6.1 Create `SpanAnnotationManager.tsx` component that manages span interaction state (idle / first-click-done / popover-open)
- [x] 6.2 Implement first-click handler: snap to nearest candle, store start candle, render start marker via a preview primitive
- [x] 6.3 Implement mouse-move preview: stretch preview rectangle from start candle to cursor candle, computing price range (min low to max high) of candles in range
- [x] 6.4 Implement second-click handler: snap to nearest candle, finalize span range, swap if end < start, trigger popover
- [x] 6.5 Implement Escape key to cancel span selection and clear preview
## 7. Label Assignment Popover
- [ ] 7.1 Create `SpanPopover.tsx` with shadcn Popover/Dialog: label dropdown (from span_label_types), confidence slider (1-5), outcome select (win/loss/breakeven/none), notes textarea, Save/Cancel buttons
- [ ] 7.2 Position popover near the end-click position with collision avoidance
- [ ] 7.3 Wire Save button: POST to API, attach SpanRectanglePrimitive to chart series, update spanAnnotations state, close popover
- [ ] 7.4 Wire Cancel button / Escape: discard span, clear preview, close popover
- [ ] 7.5 Disable Save when no label is selected (validation)
- [x] 7.1 Create `SpanPopover.tsx` with shadcn Popover/Dialog: label dropdown (from span_label_types), confidence slider (1-5), outcome select (win/loss/breakeven/none), notes textarea, Save/Cancel buttons
- [x] 7.2 Position popover near the end-click position with collision avoidance
- [x] 7.3 Wire Save button: POST to API, attach SpanRectanglePrimitive to chart series, update spanAnnotations state, close popover
- [x] 7.4 Wire Cancel button / Escape: discard span, clear preview, close popover
- [x] 7.5 Disable Save when no label is selected (validation)
## 8. Span Selection, Editing & Deletion

View file

@ -1,163 +0,0 @@
# Span Annotation Feature for Candlestick Pattern Labeling Tool
## What is Span Annotation?
Span annotation means selecting a **range of consecutive candles** on a candlestick chart that together form a recognizable pattern (e.g., bull flag, head and shoulders, double bottom). The user clicks a start candle and an end candle, assigns a pattern label, and optionally adds metadata. This is the standard approach for labeling multi-candle patterns in time series data.
## User Interaction Flow
1. User enters **annotation mode** (toggle or hotkey)
2. User **clicks a candle** → that candle is highlighted as the **span start**
3. User **clicks a second candle** → that becomes the **span end**
4. A **label selector** appears (dropdown or palette) with the user's predefined pattern categories
5. Optionally, user can add:
- **Sub-spans** (e.g., mark the "pole" and "flag" portions within a bull flag)
- **Outcome** (win/loss/breakeven, or the price move after the pattern)
- **Confidence** (how clear the pattern is, 1-5 scale)
- **Free-text notes**
6. The annotation is saved and **visually rendered** on the chart as a highlighted region with a label tag
7. User can **click an existing annotation** to edit or delete it
8. Annotations persist and are exportable
## Visual Rendering of Annotations
- Draw a **semi-transparent colored rectangle** behind the candles in the span range (color per label category)
- Show the **label name** as a small tag above or below the highlighted region
- Sub-spans get a **slightly different shade** or a thin divider line within the main span
- Overlapping annotations should be visually distinguishable (offset vertically or use border styles)
## Annotation Data Model
Each annotation is a JSON object:
```json
{
"id": "uuid-v4",
"pair": "EURUSD",
"timeframe": "1H",
"start_time": "2024-03-15T09:00:00Z",
"end_time": "2024-03-15T16:00:00Z",
"start_index": 142,
"end_index": 149,
"label": "bull_flag",
"sub_spans": [
{
"label": "pole",
"start_time": "2024-03-15T09:00:00Z",
"end_time": "2024-03-15T12:00:00Z"
},
{
"label": "consolidation",
"start_time": "2024-03-15T12:00:00Z",
"end_time": "2024-03-15T16:00:00Z"
}
],
"outcome": "win",
"confidence": 4,
"notes": "clean breakout on volume",
"created_at": "2024-03-16T10:30:00Z"
}
```
## Export Formats for ML Training
The tool must export annotations in multiple formats to support different model types. All exports should be triggered from a single "Export" button with format selection.
---
### Format 1: Windowed Classification (CSV)
One row per annotation. Used for training classifiers (XGBoost, CNN, LSTM) where each row is a labeled window of OHLC data.
```csv
pair,timeframe,start_time,end_time,label,outcome,confidence,window_length,open_0,high_0,low_0,close_0,volume_0,open_1,high_1,low_1,close_1,volume_1,...
EURUSD,1H,2024-03-15T09:00:00Z,2024-03-15T16:00:00Z,bull_flag,win,4,8,1.0921,1.0935,1.0918,1.0933,1200,1.0933,1.0948,1.0930,1.0945,1500,...
```
The OHLCV columns are **flattened**: `open_0` through `close_N` where N is the number of candles in the span. Pad shorter spans with NaN or truncate/resample to a fixed window size (user-configurable, e.g., 20 candles).
---
### Format 2: Sequence Labels / BIO Tags (CSV)
One row per candle across the entire dataset. Used for sequence labeling models (BiLSTM-CRF, Transformer encoder). Uses BIO tagging scheme:
- **B-{label}** = first candle of a pattern
- **I-{label}** = inside a pattern (continuation)
- **O** = outside any pattern (no pattern)
```csv
time,open,high,low,close,volume,bio_tag
2024-03-15T08:00:00Z,1.0915,1.0922,1.0910,1.0918,980,O
2024-03-15T09:00:00Z,1.0921,1.0935,1.0918,1.0933,1200,B-bull_flag
2024-03-15T10:00:00Z,1.0933,1.0948,1.0930,1.0945,1500,I-bull_flag
2024-03-15T11:00:00Z,1.0944,1.0950,1.0938,1.0941,1100,I-bull_flag
...
2024-03-15T16:00:00Z,1.0939,1.0960,1.0937,1.0958,1800,I-bull_flag
2024-03-15T17:00:00Z,1.0958,1.0965,1.0950,1.0962,900,O
```
For overlapping annotations, use multi-label columns: `bio_tag_1`, `bio_tag_2`, etc.
---
### Format 3: Raw Annotations JSON
The complete annotation list as-is, for custom pipelines or re-import.
```json
{
"metadata": {
"pair": "EURUSD",
"timeframe": "1H",
"export_date": "2024-03-20T12:00:00Z",
"total_annotations": 47,
"label_counts": {
"bull_flag": 12,
"head_and_shoulders": 8,
"double_bottom": 15,
"wedge": 12
}
},
"annotations": [
{ ... annotation objects as defined above ... }
]
}
```
---
## Notes
Format 2 (BIO tags) is probably the most versatile starting point — it works directly with sequence models and you can always derive Format 1 (windowed) from it by slicing. Format 1 (windowed CSV) is what you'd feed directly into XGBoost or a CNN. If you start with just one export format, go with the raw JSON (Format 3) since you can always transform it into the others with a script.
Make sure the export includes context candles — e.g., 10-20 candles before and after each pattern span. Models need to see the trend leading into the pattern, not just the pattern itself. You might want a configurable context_padding parameter on export.
## Label Configuration
The user should be able to define their own pattern categories in a config, e.g.:
```json
{
"labels": [
{ "name": "bull_flag", "color": "#4CAF50", "hotkey": "1" },
{ "name": "bear_flag", "color": "#F44336", "hotkey": "2" },
{ "name": "head_and_shoulders", "color": "#FF9800", "hotkey": "3" },
{ "name": "double_bottom", "color": "#2196F3", "hotkey": "4" },
{ "name": "wedge_up", "color": "#9C27B0", "hotkey": "5" },
{ "name": "wedge_down", "color": "#795548", "hotkey": "6" },
{ "name": "custom", "color": "#607D8B", "hotkey": "0" }
]
}
```
## Summary of Requirements
- Click-to-select span annotation on a TradingView Lightweight Charts candlestick chart
- Label assignment via dropdown or hotkey
- Optional sub-spans, outcome, confidence, notes
- Visual overlay of annotations on the chart
- Edit/delete existing annotations
- Export to: Windowed CSV, BIO-tagged CSV, Raw JSON, and optionally image crops
- User-configurable label categories with colors and hotkeys

View file

@ -161,6 +161,14 @@ export default function Home() {
await fetchAnnotations(activeChartId);
};
const handleSpanAnnotationsChange = async () => {
await fetchSpanAnnotations(activeChartId);
};
const handleSelectedSpanChange = (spanId: number | null) => {
setSelectedSpanId(spanId);
};
const handleLabelDelete = async (id: number) => {
setAnnotations(annotations.filter((a) => a.id !== id));
if (selectedLabelId === id) {
@ -264,6 +272,11 @@ export default function Home() {
selectedLabelId={selectedLabelId}
onLabelSelect={handleLabelSelect}
activeChartId={activeChartId}
spanAnnotations={spanAnnotations}
spanLabelTypes={spanLabelTypes}
selectedSpanId={selectedSpanId}
onSpanAnnotationsChange={handleSpanAnnotationsChange}
onSelectedSpanChange={handleSelectedSpanChange}
/>
</main>
</div>

View file

@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 're
import { createChart, IChartApi, ISeriesApi, CandlestickData, Time } from 'lightweight-charts';
import { useTheme } from 'next-themes';
import SvgOverlay from './SvgOverlay';
import SpanAnnotationManager from './SpanAnnotationManager';
interface Candle {
time: number;
@ -36,6 +37,31 @@ type AnnotationType = {
is_active: number;
};
interface SpanAnnotation {
id: number;
chart_id: number;
start_time: number;
end_time: number;
label: string;
confidence: number | null;
outcome: string | null;
notes: string | null;
sub_spans: any;
color: string;
created_at: number;
}
interface SpanLabelType {
id: number;
name: string;
display_name: string;
color: string;
hotkey: string | null;
is_active: number;
sort_order: number;
created_at: number;
}
interface CandleChartProps {
activeTool: string | null;
onAnnotationChange?: () => void;
@ -43,6 +69,11 @@ interface CandleChartProps {
selectedLabelId?: number | null;
onLabelSelect?: (id: number) => void;
activeChartId?: number | null;
spanAnnotations?: SpanAnnotation[];
spanLabelTypes?: SpanLabelType[];
selectedSpanId?: number | null;
onSpanAnnotationsChange?: () => void;
onSelectedSpanChange?: (spanId: number | null) => void;
}
export interface CandleChartHandle {
@ -50,7 +81,19 @@ export interface CandleChartHandle {
}
const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
({ activeTool, onAnnotationChange, selectedColor, selectedLabelId, onLabelSelect, activeChartId }, ref) => {
({
activeTool,
onAnnotationChange,
selectedColor,
selectedLabelId,
onLabelSelect,
activeChartId,
spanAnnotations = [],
spanLabelTypes = [],
selectedSpanId = null,
onSpanAnnotationsChange,
onSelectedSpanChange,
}, ref) => {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
@ -416,6 +459,18 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
selectedColor={selectedColor}
activeChartId={activeChartId}
/>
<SpanAnnotationManager
chart={chartRef.current}
series={seriesRef.current}
activeTool={activeTool}
candles={candles}
spanAnnotations={spanAnnotations}
spanLabelTypes={spanLabelTypes}
selectedSpanId={selectedSpanId}
onSpanAnnotationsChange={onSpanAnnotationsChange || (() => {})}
onSelectedSpanChange={onSelectedSpanChange || (() => {})}
activeChartId={activeChartId}
/>
</div>
);
}

View file

@ -0,0 +1,347 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { IChartApi, ISeriesApi, Time } from 'lightweight-charts';
import { SpanRectanglePrimitive, SpanData } from './SpanRectanglePrimitive';
import SpanPopover from './SpanPopover';
interface Candle {
time: number;
open: number;
high: number;
low: number;
close: number;
}
interface SpanAnnotation {
id: number;
chart_id: number;
start_time: number;
end_time: number;
label: string;
confidence: number | null;
outcome: string | null;
notes: string | null;
sub_spans: any;
color: string;
created_at: number;
}
interface SpanLabelType {
id: number;
name: string;
display_name: string;
color: string;
hotkey: string | null;
is_active: number;
sort_order: number;
created_at: number;
}
interface SpanAnnotationManagerProps {
chart: IChartApi | null;
series: ISeriesApi<'Candlestick'> | null;
activeTool: string | null;
candles: Candle[];
spanAnnotations: SpanAnnotation[];
spanLabelTypes: SpanLabelType[];
selectedSpanId: number | null;
onSpanAnnotationsChange: () => void;
onSelectedSpanChange: (spanId: number | null) => void;
activeChartId: number | null;
}
type InteractionState = 'idle' | 'first-click-done' | 'popover-open';
export default function SpanAnnotationManager({
chart,
series,
activeTool,
candles,
spanAnnotations,
spanLabelTypes,
selectedSpanId,
onSpanAnnotationsChange,
onSelectedSpanChange,
activeChartId,
}: SpanAnnotationManagerProps) {
const [interactionState, setInteractionState] = useState<InteractionState>('idle');
const [startCandle, setStartCandle] = useState<Candle | null>(null);
const [endCandle, setEndCandle] = useState<Candle | null>(null);
const [previewPrimitive, setPreviewPrimitive] = useState<SpanRectanglePrimitive | null>(null);
const [popoverOpen, setPopoverOpen] = useState(false);
const primitivesRef = useRef<Map<number, SpanRectanglePrimitive>>(new Map());
// Find nearest candle to a timestamp
const findNearestCandle = (timestamp: number): Candle | null => {
if (candles.length === 0) return null;
return candles.reduce((prev, curr) => {
return Math.abs(curr.time - timestamp) < Math.abs(prev.time - timestamp) ? curr : prev;
});
};
// Calculate price range for candles in a span
const calculatePriceRange = (start: Candle, end: Candle): { max_high: number; min_low: number } => {
const startIdx = candles.findIndex((c) => c.time === start.time);
const endIdx = candles.findIndex((c) => c.time === end.time);
if (startIdx === -1 || endIdx === -1) {
return { max_high: Math.max(start.high, end.high), min_low: Math.min(start.low, end.low) };
}
const [minIdx, maxIdx] = [Math.min(startIdx, endIdx), Math.max(startIdx, endIdx)];
const spanCandles = candles.slice(minIdx, maxIdx + 1);
const max_high = Math.max(...spanCandles.map((c) => c.high));
const min_low = Math.min(...spanCandles.map((c) => c.low));
return { max_high, min_low };
};
// Render span annotations as primitives
useEffect(() => {
if (!series || !chart) return;
// Clear existing primitives
primitivesRef.current.forEach((primitive) => {
series.detachPrimitive(primitive);
});
primitivesRef.current.clear();
// Create primitives for each span annotation
spanAnnotations.forEach((span) => {
const { max_high, min_low } = calculatePriceRange(
{ time: span.start_time, open: 0, high: 0, low: 0, close: 0 },
{ time: span.end_time, open: 0, high: 0, low: 0, close: 0 }
);
// If we can't calculate price range from candles, use sensible defaults
const spanData: SpanData = {
id: span.id,
start_time: span.start_time,
end_time: span.end_time,
label: span.label,
color: span.color,
max_high: max_high,
min_low: min_low,
};
const primitive = new SpanRectanglePrimitive({
data: spanData,
isSelected: span.id === selectedSpanId,
});
series.attachPrimitive(primitive);
primitivesRef.current.set(span.id, primitive);
});
// Request chart update
chart.timeScale().fitContent();
}, [spanAnnotations, selectedSpanId, series, chart, candles]);
// Handle clicks on chart for span tool
useEffect(() => {
if (!chart || !series || activeTool !== 'span') {
// Clean up preview if tool changes
if (previewPrimitive && series) {
series.detachPrimitive(previewPrimitive);
setPreviewPrimitive(null);
}
setInteractionState('idle');
setStartCandle(null);
setEndCandle(null);
return;
}
const handleClick = (param: any) => {
if (!param.point) return;
const time = chart.timeScale().coordinateToTime(param.point.x);
if (!time) return;
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
const nearestCandle = findNearestCandle(timestamp);
if (!nearestCandle) return;
if (interactionState === 'idle') {
// First click: set start candle
setStartCandle(nearestCandle);
setInteractionState('first-click-done');
} else if (interactionState === 'first-click-done') {
// Second click: set end candle and open popover
setEndCandle(nearestCandle);
setInteractionState('popover-open');
setPopoverOpen(true);
// Clean up preview primitive
if (previewPrimitive) {
series.detachPrimitive(previewPrimitive);
setPreviewPrimitive(null);
}
}
};
chart.subscribeClick(handleClick);
return () => {
chart.unsubscribeClick(handleClick);
};
}, [chart, series, activeTool, interactionState, candles, previewPrimitive]);
// Handle mouse move for preview
useEffect(() => {
if (!chart || !series || activeTool !== 'span' || interactionState !== 'first-click-done' || !startCandle) {
return;
}
const handleMouseMove = (param: any) => {
if (!param.point) return;
const time = chart.timeScale().coordinateToTime(param.point.x);
if (!time) return;
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
const cursorCandle = findNearestCandle(timestamp);
if (!cursorCandle) return;
// Calculate price range for preview
const { max_high, min_low } = calculatePriceRange(startCandle, cursorCandle);
// Swap if end < start
const [start_time, end_time] =
startCandle.time <= cursorCandle.time
? [startCandle.time, cursorCandle.time]
: [cursorCandle.time, startCandle.time];
const previewData: SpanData = {
id: -1, // Preview ID
start_time,
end_time,
label: 'PREVIEW',
color: '#888888',
max_high,
min_low,
};
// Remove old preview primitive
if (previewPrimitive) {
series.detachPrimitive(previewPrimitive);
}
// Create new preview primitive
const newPreview = new SpanRectanglePrimitive({
data: previewData,
isSelected: false,
});
series.attachPrimitive(newPreview);
setPreviewPrimitive(newPreview);
};
chart.subscribeCrosshairMove(handleMouseMove);
return () => {
chart.unsubscribeCrosshairMove(handleMouseMove);
};
}, [chart, series, activeTool, interactionState, startCandle, candles, previewPrimitive]);
// Handle Escape key to cancel span selection
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && activeTool === 'span') {
if (interactionState === 'first-click-done') {
// Cancel span selection
setInteractionState('idle');
setStartCandle(null);
setEndCandle(null);
// Clean up preview
if (previewPrimitive && series) {
series.detachPrimitive(previewPrimitive);
setPreviewPrimitive(null);
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [activeTool, interactionState, previewPrimitive, series]);
// Handle popover save
const handlePopoverSave = async (data: {
label: string;
confidence: number | null;
outcome: string | null;
notes: string | null;
}) => {
if (!startCandle || !endCandle || !activeChartId) return;
// Swap if end < start
const [start_time, end_time] =
startCandle.time <= endCandle.time
? [startCandle.time, endCandle.time]
: [endCandle.time, startCandle.time];
// Find label type color
const labelType = spanLabelTypes.find((t) => t.name === data.label);
const color = labelType?.color || '#2196F3';
try {
const response = await fetch('/api/span-annotations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chart_id: activeChartId,
start_time,
end_time,
label: data.label,
confidence: data.confidence,
outcome: data.outcome,
notes: data.notes,
color,
}),
});
if (response.ok) {
onSpanAnnotationsChange();
setPopoverOpen(false);
setInteractionState('idle');
setStartCandle(null);
setEndCandle(null);
} else {
console.error('Failed to create span annotation');
}
} catch (error) {
console.error('Error creating span annotation:', error);
}
};
// Handle popover cancel
const handlePopoverCancel = () => {
setPopoverOpen(false);
setInteractionState('idle');
setStartCandle(null);
setEndCandle(null);
};
return (
<SpanPopover
open={popoverOpen}
onOpenChange={setPopoverOpen}
spanLabelTypes={spanLabelTypes}
initialData={
startCandle && endCandle
? {
start_time: Math.min(startCandle.time, endCandle.time),
end_time: Math.max(startCandle.time, endCandle.time),
}
: undefined
}
onSave={handlePopoverSave}
onCancel={handlePopoverCancel}
/>
);
}

View file

@ -0,0 +1,221 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
interface SpanLabelType {
id: number;
name: string;
display_name: string;
color: string;
hotkey: string | null;
is_active: number;
sort_order: number;
created_at: number;
}
interface SpanData {
start_time: number;
end_time: number;
label?: string;
confidence?: number | null;
outcome?: string | null;
notes?: string | null;
}
interface SpanPopoverProps {
open: boolean;
onOpenChange: (open: boolean) => void;
spanLabelTypes: SpanLabelType[];
initialData?: SpanData;
onSave: (data: {
label: string;
confidence: number | null;
outcome: string | null;
notes: string | null;
}) => void;
onCancel: () => void;
}
export default function SpanPopover({
open,
onOpenChange,
spanLabelTypes,
initialData,
onSave,
onCancel,
}: SpanPopoverProps) {
const [label, setLabel] = useState<string>(initialData?.label || '');
const [confidence, setConfidence] = useState<number>(initialData?.confidence || 3);
const [outcome, setOutcome] = useState<string>(initialData?.outcome || 'none');
const [notes, setNotes] = useState<string>(initialData?.notes || '');
// Reset form when dialog opens with new initial data
useEffect(() => {
if (open && initialData) {
setLabel(initialData.label || '');
setConfidence(initialData.confidence || 3);
setOutcome(initialData.outcome || 'none');
setNotes(initialData.notes || '');
}
}, [open, initialData]);
const handleSave = () => {
if (!label) return; // Validation: label is required
onSave({
label,
confidence: confidence,
outcome: outcome === 'none' ? null : outcome,
notes: notes.trim() || null,
});
// Reset form
setLabel('');
setConfidence(3);
setOutcome('none');
setNotes('');
};
const handleCancel = () => {
// Reset form
setLabel('');
setConfidence(3);
setOutcome('none');
setNotes('');
onCancel();
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) {
handleCancel();
}
};
useEffect(() => {
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Span Annotation</DialogTitle>
<DialogDescription>
Assign a pattern label and optional metadata to this span.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Label Selection */}
<div className="grid gap-2">
<Label htmlFor="label">Pattern Label *</Label>
<Select value={label} onValueChange={setLabel}>
<SelectTrigger id="label">
<SelectValue placeholder="Select a pattern label" />
</SelectTrigger>
<SelectContent>
{spanLabelTypes
.filter((type) => type.is_active === 1)
.sort((a, b) => a.sort_order - b.sort_order)
.map((type) => (
<SelectItem key={type.id} value={type.name}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: type.color }}
/>
{type.display_name}
{type.hotkey && (
<span className="text-xs text-muted-foreground ml-auto">
({type.hotkey})
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Confidence Slider */}
<div className="grid gap-2">
<Label htmlFor="confidence">
Confidence: {confidence}
</Label>
<Slider
id="confidence"
min={1}
max={5}
step={1}
value={[confidence]}
onValueChange={(values) => setConfidence(values[0])}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>1 (Low)</span>
<span>5 (High)</span>
</div>
</div>
{/* Outcome Selection */}
<div className="grid gap-2">
<Label htmlFor="outcome">Outcome</Label>
<Select value={outcome} onValueChange={setOutcome}>
<SelectTrigger id="outcome">
<SelectValue placeholder="Select outcome" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="win">Win</SelectItem>
<SelectItem value="loss">Loss</SelectItem>
<SelectItem value="breakeven">Break Even</SelectItem>
</SelectContent>
</Select>
</div>
{/* Notes Textarea */}
<div className="grid gap-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
placeholder="Add any notes about this pattern..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!label}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}