feat: implement sections 6-7 - span selection, preview, and label assignment popover
This commit is contained in:
parent
c9d2cbfc4b
commit
586f02ed69
11 changed files with 647 additions and 22483 deletions
2593
EURUSD.csv
2593
EURUSD.csv
File diff suppressed because it is too large
Load diff
18300
EURUSD.csv~
18300
EURUSD.csv~
File diff suppressed because it is too large
Load diff
1000
EURUSD_1000.csv
1000
EURUSD_1000.csv
File diff suppressed because it is too large
Load diff
|
|
@ -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
92
PLAN.md
|
|
@ -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, we’ll 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?**
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
347
src/components/SpanAnnotationManager.tsx
Normal file
347
src/components/SpanAnnotationManager.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
221
src/components/SpanPopover.tsx
Normal file
221
src/components/SpanPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue