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. Two-Click Span Selection & Preview
|
||||||
|
|
||||||
- [ ] 6.1 Create `SpanAnnotationManager.tsx` component that manages span interaction state (idle / first-click-done / popover-open)
|
- [x] 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
|
- [x] 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
|
- [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
|
||||||
- [ ] 6.4 Implement second-click handler: snap to nearest candle, finalize span range, swap if end < start, trigger popover
|
- [x] 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.5 Implement Escape key to cancel span selection and clear preview
|
||||||
|
|
||||||
## 7. Label Assignment Popover
|
## 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
|
- [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
|
||||||
- [ ] 7.2 Position popover near the end-click position with collision avoidance
|
- [x] 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
|
- [x] 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
|
- [x] 7.4 Wire Cancel button / Escape: discard span, clear preview, close popover
|
||||||
- [ ] 7.5 Disable Save when no label is selected (validation)
|
- [x] 7.5 Disable Save when no label is selected (validation)
|
||||||
|
|
||||||
## 8. Span Selection, Editing & Deletion
|
## 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);
|
await fetchAnnotations(activeChartId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSpanAnnotationsChange = async () => {
|
||||||
|
await fetchSpanAnnotations(activeChartId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectedSpanChange = (spanId: number | null) => {
|
||||||
|
setSelectedSpanId(spanId);
|
||||||
|
};
|
||||||
|
|
||||||
const handleLabelDelete = async (id: number) => {
|
const handleLabelDelete = async (id: number) => {
|
||||||
setAnnotations(annotations.filter((a) => a.id !== id));
|
setAnnotations(annotations.filter((a) => a.id !== id));
|
||||||
if (selectedLabelId === id) {
|
if (selectedLabelId === id) {
|
||||||
|
|
@ -264,6 +272,11 @@ export default function Home() {
|
||||||
selectedLabelId={selectedLabelId}
|
selectedLabelId={selectedLabelId}
|
||||||
onLabelSelect={handleLabelSelect}
|
onLabelSelect={handleLabelSelect}
|
||||||
activeChartId={activeChartId}
|
activeChartId={activeChartId}
|
||||||
|
spanAnnotations={spanAnnotations}
|
||||||
|
spanLabelTypes={spanLabelTypes}
|
||||||
|
selectedSpanId={selectedSpanId}
|
||||||
|
onSpanAnnotationsChange={handleSpanAnnotationsChange}
|
||||||
|
onSelectedSpanChange={handleSelectedSpanChange}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 're
|
||||||
import { createChart, IChartApi, ISeriesApi, CandlestickData, Time } from 'lightweight-charts';
|
import { createChart, IChartApi, ISeriesApi, CandlestickData, Time } from 'lightweight-charts';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import SvgOverlay from './SvgOverlay';
|
import SvgOverlay from './SvgOverlay';
|
||||||
|
import SpanAnnotationManager from './SpanAnnotationManager';
|
||||||
|
|
||||||
interface Candle {
|
interface Candle {
|
||||||
time: number;
|
time: number;
|
||||||
|
|
@ -36,6 +37,31 @@ type AnnotationType = {
|
||||||
is_active: number;
|
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 {
|
interface CandleChartProps {
|
||||||
activeTool: string | null;
|
activeTool: string | null;
|
||||||
onAnnotationChange?: () => void;
|
onAnnotationChange?: () => void;
|
||||||
|
|
@ -43,6 +69,11 @@ interface CandleChartProps {
|
||||||
selectedLabelId?: number | null;
|
selectedLabelId?: number | null;
|
||||||
onLabelSelect?: (id: number) => void;
|
onLabelSelect?: (id: number) => void;
|
||||||
activeChartId?: number | null;
|
activeChartId?: number | null;
|
||||||
|
spanAnnotations?: SpanAnnotation[];
|
||||||
|
spanLabelTypes?: SpanLabelType[];
|
||||||
|
selectedSpanId?: number | null;
|
||||||
|
onSpanAnnotationsChange?: () => void;
|
||||||
|
onSelectedSpanChange?: (spanId: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CandleChartHandle {
|
export interface CandleChartHandle {
|
||||||
|
|
@ -50,7 +81,19 @@ export interface CandleChartHandle {
|
||||||
}
|
}
|
||||||
|
|
||||||
const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
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 chartContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const chartRef = useRef<IChartApi | null>(null);
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
|
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
|
||||||
|
|
@ -416,6 +459,18 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
selectedColor={selectedColor}
|
selectedColor={selectedColor}
|
||||||
activeChartId={activeChartId}
|
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>
|
</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