feat: complete candle annotator implementation

- Created CandleChart component with lightweight-charts integration
- Implemented SvgOverlay component for line drawing
- Integrated all components in main page
- Fixed TypeScript and Tailwind CSS compatibility issues
- Added comprehensive README.md with project documentation
- Created DEPLOYMENT.md with setup and troubleshooting guide
- Downgraded to stable versions (Tailwind v3, lightweight-charts v4)
- All 59 tasks from OpenSpec completed
This commit is contained in:
Marko Djordjevic 2026-02-12 11:20:29 +01:00
parent 8d1e72579e
commit 23f18f405a
11 changed files with 8166 additions and 24 deletions

165
DEPLOYMENT.md Normal file
View file

@ -0,0 +1,165 @@
# Deployment Guide
## Prerequisites
- Node.js 18.x or higher
- npm 9.x or higher
- Python and build tools (for native module compilation)
## Local Development Setup
### 1. Install Dependencies
```bash
npm install
```
Note: The `better-sqlite3` package requires native compilation. If you encounter build errors, ensure you have the necessary build tools:
**Linux:**
```bash
sudo apt-get install build-essential python3
```
**macOS:**
```bash
xcode-select --install
```
**Windows:**
```bash
npm install --global windows-build-tools
```
### 2. Database Setup
The SQLite database will be automatically created when you start the application. The database file is located at:
```
./data/candles.db
```
To run migrations manually:
```bash
npx drizzle-kit generate
npx drizzle-kit migrate
```
### 3. Start Development Server
```bash
npm run dev
```
The application will be available at:
- http://localhost:3000
### 4. Verify Setup
1. Open the application in your browser
2. Upload a sample CSV file with OHLC data (columns: time, open, high, low, close)
3. Verify the candlestick chart renders correctly
4. Test annotation tools (Break Up, Break Down, Draw Line, Delete)
5. Export annotations as CSV
## CSV File Format
The application expects CSV files with the following format:
```csv
time,open,high,low,close
1700000000,1.0500,1.0520,1.0490,1.0510
1700000060,1.0510,1.0530,1.0505,1.0525
```
**Time column formats:**
- Unix timestamp (seconds): `1700000000`
- Date string: `2024-01-15`
## Building for Production
```bash
npm run build
```
Note: Production builds with `better-sqlite3` require the native module to be compiled for the target platform.
## Running Production Build
```bash
npm run build
npm start
```
The production server will run on port 3000 by default.
## Troubleshooting
### better-sqlite3 Build Issues
If you encounter errors related to `better-sqlite3` not finding bindings:
1. Rebuild the module:
```bash
npm rebuild better-sqlite3
```
2. If that fails, reinstall:
```bash
npm uninstall better-sqlite3
npm install better-sqlite3
```
3. For development, you can use `npm run dev` which handles the module better than production builds.
### Database Issues
If the database becomes corrupted or you want to start fresh:
1. Stop the application
2. Delete the database file:
```bash
rm -f data/candles.db
```
3. Restart the application (it will recreate the database)
### Port Already in Use
If port 3000 is already in use, you can specify a different port:
```bash
PORT=3001 npm run dev
```
## Environment Variables
The application doesn't require any environment variables for local development. All configuration is hardcoded for simplicity.
## File Structure
```
.
├── src/
│ ├── app/ # Next.js app router
│ │ ├── api/ # API routes
│ │ ├── layout.tsx # Root layout
│ │ └── page.tsx # Main page
│ ├── components/ # React components
│ │ ├── CandleChart.tsx
│ │ ├── SvgOverlay.tsx
│ │ ├── Toolbox.tsx
│ │ └── FileUpload.tsx
│ └── lib/ # Utilities
│ └── db/ # Database configuration
├── data/ # SQLite database directory
├── drizzle/ # Database migrations
└── public/ # Static assets
```
## Notes
- This application is designed for **single-user local use** only
- There is no authentication or user management
- The SQLite database is stored locally and not intended for concurrent access
- For production multi-user deployments, consider migrating to PostgreSQL or similar

271
README.md Normal file
View file

@ -0,0 +1,271 @@
# Candle Annotator
A web-based tool for manually annotating candlestick charts with pattern labels and trend lines. Built for creating labeled training data for machine learning models in trading analysis.
## Overview
Candle Annotator provides a TradingView-like charting interface that allows traders and researchers to:
- Upload historical OHLC (Open, High, Low, Close) candle data from CSV files
- Visualize candlestick charts with interactive zoom and pan
- Mark breakout patterns (Break Up, Break Down) directly on candles
- Draw custom trend lines with two-click interaction
- Delete annotations with a dedicated tool
- Export all annotations as CSV for ML training pipelines
## Features
### Data Management
- **CSV Upload**: Import OHLC data with support for both Unix timestamps and date strings
- **SQLite Storage**: All candle data and annotations stored locally in SQLite database
- **Data Persistence**: Annotations and candles persist between sessions
### Chart Visualization
- **Interactive Candlestick Chart**: Powered by lightweight-charts library
- **Dark Theme**: Eye-friendly slate color scheme
- **Zoom & Pan**: Mouse wheel zoom and drag-to-pan functionality
- **Crosshair**: Precise price and time tracking
### Annotation Tools
- **Break Up Markers**: Green arrow markers below candles indicating upward breakouts
- **Break Down Markers**: Red arrow markers above candles indicating downward breakouts
- **Trend Lines**: Two-click line drawing with real-time preview
- **Delete Tool**: Remove any annotation (markers or lines) by clicking on them
- **Tool Toggle**: Click tool button again to deactivate
### Export
- **CSV Export**: Download all annotations with timestamp, label type, and price data
- **ML-Ready Format**: Structured data suitable for training ML models
## Tech Stack
- **Frontend**: Next.js 16 (App Router), React 19, TypeScript
- **Styling**: Tailwind CSS 3, shadcn/ui components
- **Charting**: lightweight-charts 4.x (TradingView)
- **Icons**: lucide-react
- **Backend**: Next.js API Routes
- **Database**: SQLite with better-sqlite3
- **ORM**: Drizzle ORM
- **CSV Parsing**: papaparse
## Getting Started
### Prerequisites
- Node.js 18.x or higher
- npm 9.x or higher
- Build tools for native modules (see DEPLOYMENT.md)
### Installation
1. Clone the repository:
```bash
git clone <repository-url>
cd candle_annotator
```
2. Install dependencies:
```bash
npm install
```
3. Start the development server:
```bash
npm run dev
```
4. Open http://localhost:3000 in your browser
### Usage
1. **Upload Data**: Click "Choose CSV File" and select a CSV with columns: `time,open,high,low,close`
2. **View Chart**: The candlestick chart renders automatically after upload
3. **Add Annotations**:
- Click "Label: Break Up" or "Label: Break Down" then click on a candle
- Click "Draw Line" then click two points to draw a trend line
- Press Escape to cancel line drawing
4. **Delete Annotations**: Click "Delete" tool, then click on markers or lines to remove them
5. **Export**: Click "Export CSV" to download all annotations
## CSV File Format
### Input Format
Your CSV file should have these columns:
```csv
time,open,high,low,close
1700000000,1.0500,1.0520,1.0490,1.0510
1700000060,1.0510,1.0530,1.0505,1.0525
```
**Time column** accepts:
- Unix timestamps (seconds): `1700000000`
- Date strings: `2024-01-15`, `2024-01-15 10:30:00`
### Export Format
The exported CSV includes:
```csv
timestamp,label_type,price
1700000000,break_up,1.0510
1700000120,break_down,1.0505
1700000000,line,1.0500
```
- **timestamp**: Unix timestamp of the annotation
- **label_type**: `break_up`, `break_down`, or `line`
- **price**: Close price for markers, start price for lines
## Database Schema
### Candles Table
```typescript
{
id: integer (PK, auto-increment),
time: integer (Unix timestamp, unique),
open: real,
high: real,
low: real,
close: real
}
```
### Annotations Table
```typescript
{
id: integer (PK, auto-increment),
timestamp: integer (Unix timestamp),
label_type: text ('break_up' | 'break_down' | 'line'),
geometry: text (JSON string for line coordinates, null for markers),
created_at: integer (Unix timestamp)
}
```
## API Endpoints
### POST /api/upload
Upload CSV file and store candle data
**Request**: multipart/form-data with `file` field
**Response**: `{ success: true, count: number }` or `{ error: string }`
### GET /api/candles
Retrieve all candle records
**Response**: Array of candle objects ordered by time
### GET /api/annotations
Retrieve all annotations
**Response**: Array of annotation objects with parsed geometry
### POST /api/annotations
Create a new annotation
**Request**: `{ timestamp: number, label_type: string, geometry?: object }`
**Response**: Created annotation object with ID
### DELETE /api/annotations/[id]
Delete an annotation by ID
**Response**: `{ success: true }` or `{ error: string }`
### GET /api/export
Export annotations as downloadable CSV
**Response**: CSV file download with Content-Disposition header
## Architecture
### Component Structure
- **page.tsx**: Main page composition, manages active tool state
- **Toolbox.tsx**: Sidebar with tool buttons and export functionality
- **FileUpload.tsx**: CSV upload component with status messages
- **CandleChart.tsx**: Core chart wrapper with lightweight-charts integration
- Initializes chart with dark theme
- Handles marker annotations (Break Up/Down)
- Manages click events for annotation creation
- Exposes `refreshData()` method for parent updates
- **SvgOverlay.tsx**: Transparent SVG layer for line drawing
- Coordinate transformation between data and pixels
- Two-click line drawing with preview
- Line hit detection for deletion
### Data Flow
1. User uploads CSV → POST /api/upload → SQLite storage
2. Chart mounts → GET /api/candles + GET /api/annotations → Render
3. User clicks with active tool → POST /api/annotations → Refresh chart
4. User deletes → DELETE /api/annotations/[id] → Refresh chart
5. User exports → GET /api/export → CSV download
## Development
### Project Structure
```
candle_annotator/
├── src/
│ ├── app/
│ │ ├── api/ # API route handlers
│ │ │ ├── upload/
│ │ │ ├── candles/
│ │ │ ├── annotations/
│ │ │ └── export/
│ │ ├── globals.css # Tailwind styles
│ │ ├── layout.tsx # Root layout with dark theme
│ │ └── page.tsx # Main page
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ ├── CandleChart.tsx
│ │ ├── SvgOverlay.tsx
│ │ ├── Toolbox.tsx
│ │ └── FileUpload.tsx
│ └── lib/
│ ├── db/
│ │ ├── index.ts # Drizzle client
│ │ ├── schema.ts # Table definitions
│ │ └── migrate.ts # Migration runner
│ └── utils.ts # Utility functions
├── data/ # SQLite database directory
├── drizzle/ # Migration files
├── DEPLOYMENT.md # Deployment instructions
└── README.md # This file
```
### Key Technical Decisions
1. **lightweight-charts v4**: Stable API with good candlestick and marker support
2. **SQLite with better-sqlite3**: Synchronous access, perfect for single-user local apps
3. **SVG Overlay for Lines**: Maintains separate rendering layer from chart, easier coordinate management
4. **Drizzle ORM**: Type-safe queries with minimal overhead
5. **Next.js App Router**: Server-side API routes co-located with frontend code
### Known Limitations
- **Single User**: No authentication or concurrent access support
- **No Undo**: Can only delete annotations, not undo placement
- **Memory**: Large CSV files (100k+ rows) may cause slow uploads
- **Line Snapping**: Lines don't snap to candles, free-form placement only
## Troubleshooting
See [DEPLOYMENT.md](./DEPLOYMENT.md) for detailed troubleshooting steps.
Common issues:
- **better-sqlite3 binding errors**: Run `npm rebuild better-sqlite3`
- **Port 3000 in use**: Use `PORT=3001 npm run dev`
- **Database corruption**: Delete `data/candles.db` and restart
## License
ISC
## Contributing
This is a focused tool for a specific use case. For questions or issues, please open a GitHub issue.

6
next-env.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

7037
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -12,7 +12,6 @@
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.2.3",
@ -20,13 +19,12 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"autoprefixer": "^10.4.24",
"better-sqlite3": "^12.6.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"eslint": "^9.39.2",
"eslint-config-next": "^16.1.6",
"lightweight-charts": "^5.1.0",
"lightweight-charts": "^4.2.3",
"lucide-react": "^0.563.0",
"next": "^16.1.6",
"papaparse": "^5.5.3",
@ -34,10 +32,10 @@
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
},
"devDependencies": {
"drizzle-kit": "^0.31.9"
"drizzle-kit": "^0.31.9",
"tailwindcss": "^3.4.19"
}
}

View file

@ -5,10 +5,11 @@ import { eq } from 'drizzle-orm';
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const id = parseInt(params.id);
const { id: idParam } = await params;
const id = parseInt(idParam);
if (isNaN(id)) {
return NextResponse.json(

View file

@ -3,7 +3,7 @@ import Papa from 'papaparse';
import { db } from '@/lib/db';
import { candles } from '@/lib/db/schema';
export async function POST(request: NextRequest) {
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
@ -17,7 +17,7 @@ export async function POST(request: NextRequest) {
const text = await file.text();
return new Promise((resolve) => {
return new Promise<NextResponse>((resolve) => {
Papa.parse(text, {
header: true,
dynamicTyping: true,
@ -115,7 +115,7 @@ export async function POST(request: NextRequest) {
);
}
},
error: (error) => {
error: (error: any) => {
resolve(
NextResponse.json(
{ error: `CSV parsing error: ${error.message}` },

View file

@ -1,19 +1,26 @@
'use client';
import { useState } from 'react';
import { useState, useRef } from 'react';
import Toolbox, { Tool } from '@/components/Toolbox';
import FileUpload from '@/components/FileUpload';
import CandleChart, { CandleChartHandle } from '@/components/CandleChart';
export default function Home() {
const [activeTool, setActiveTool] = useState<Tool>(null);
const chartRef = useRef<CandleChartHandle>(null);
const handleExport = () => {
window.location.href = '/api/export';
};
const handleUploadSuccess = () => {
// TODO: Trigger chart refresh
console.log('Upload successful - refresh chart');
// Refresh chart data after successful upload
chartRef.current?.refreshData();
};
const handleAnnotationChange = () => {
// Refresh chart when annotations change
chartRef.current?.refreshData();
};
return (
@ -34,11 +41,12 @@ export default function Home() {
</aside>
{/* Main chart area */}
<main className="flex-1 flex items-center justify-center bg-background">
<div className="text-muted-foreground text-center">
<p className="text-lg">Upload a CSV file to view the candlestick chart</p>
<p className="text-sm mt-2">CSV format: time, open, high, low, close</p>
</div>
<main className="flex-1 relative">
<CandleChart
ref={chartRef}
activeTool={activeTool}
onAnnotationChange={handleAnnotationChange}
/>
</main>
</div>
);

View file

@ -0,0 +1,284 @@
'use client';
import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
import { createChart, IChartApi, ISeriesApi, CandlestickData, Time } from 'lightweight-charts';
import SvgOverlay from './SvgOverlay';
interface Candle {
time: number;
open: number;
high: number;
low: number;
close: number;
}
interface Annotation {
id: number;
timestamp: number;
label_type: string;
geometry: {
startTime?: number;
startPrice?: number;
endTime?: number;
endPrice?: number;
} | null;
created_at: number;
}
interface CandleChartProps {
activeTool: string | null;
onAnnotationChange?: () => void;
}
export interface CandleChartHandle {
refreshData: () => Promise<void>;
}
const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
({ activeTool, onAnnotationChange }, ref) => {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
const [candles, setCandles] = useState<Candle[]>([]);
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [isEmpty, setIsEmpty] = useState(true);
// Fetch candles from API
const fetchCandles = async () => {
try {
const response = await fetch('/api/candles');
const data = await response.json();
setCandles(data);
setIsEmpty(data.length === 0);
return data;
} catch (error) {
console.error('Failed to fetch candles:', error);
return [];
}
};
// Fetch annotations from API
const fetchAnnotations = async () => {
try {
const response = await fetch('/api/annotations');
const data = await response.json();
setAnnotations(data);
return data;
} catch (error) {
console.error('Failed to fetch annotations:', error);
return [];
}
};
// Expose refresh method to parent
useImperativeHandle(ref, () => ({
refreshData: async () => {
await fetchCandles();
await fetchAnnotations();
},
}));
// Initialize chart
useEffect(() => {
if (!chartContainerRef.current || isEmpty) return;
const chart = createChart(chartContainerRef.current, {
width: chartContainerRef.current.clientWidth,
height: chartContainerRef.current.clientHeight,
layout: {
background: { color: '#0f172a' }, // Slate-900
textColor: '#e2e8f0', // Slate-200
},
grid: {
vertLines: { color: '#1e293b' }, // Subtle grid
horzLines: { color: '#1e293b' },
},
crosshair: {
mode: 1,
},
timeScale: {
timeVisible: true,
secondsVisible: false,
borderColor: '#334155',
},
rightPriceScale: {
borderColor: '#334155',
},
});
const candlestickSeries = chart.addCandlestickSeries({
upColor: '#22c55e', // Green
downColor: '#ef4444', // Red
borderVisible: false,
wickUpColor: '#22c55e',
wickDownColor: '#ef4444',
});
chartRef.current = chart;
seriesRef.current = candlestickSeries;
// Handle resize
const resizeObserver = new ResizeObserver((entries) => {
if (entries.length === 0 || !chartContainerRef.current) return;
const { width, height } = entries[0].contentRect;
chart.applyOptions({ width, height });
});
resizeObserver.observe(chartContainerRef.current);
return () => {
resizeObserver.disconnect();
chart.remove();
};
}, [isEmpty]);
// Load candle data into chart
useEffect(() => {
if (!seriesRef.current || candles.length === 0) return;
const chartData: CandlestickData[] = candles.map((candle) => ({
time: candle.time as Time,
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
}));
seriesRef.current.setData(chartData);
}, [candles]);
// Update markers from annotations
useEffect(() => {
if (!seriesRef.current) return;
const markerAnnotations = annotations.filter(
(a) => a.label_type === 'break_up' || a.label_type === 'break_down'
);
const markers = markerAnnotations.map((annotation) => ({
time: annotation.timestamp as Time,
position: annotation.label_type === 'break_up' ? ('belowBar' as const) : ('aboveBar' as const),
color: annotation.label_type === 'break_up' ? '#22c55e' : '#ef4444',
shape: annotation.label_type === 'break_up' ? ('arrowUp' as const) : ('arrowDown' as const),
text: annotation.label_type === 'break_up' ? 'Break Up' : 'Break Down',
}));
seriesRef.current.setMarkers(markers);
}, [annotations]);
// Handle chart clicks for annotation
useEffect(() => {
if (!chartRef.current || !seriesRef.current) return;
const handleClick = async (param: any) => {
if (!param.point || !activeTool) return;
const timeCoordinate = param.point.x;
const priceCoordinate = param.point.y;
const time = chartRef.current!.timeScale().coordinateToTime(timeCoordinate);
const price = seriesRef.current!.coordinateToPrice(priceCoordinate);
if (time === null || price === null) return;
// For break_up and break_down, snap to nearest candle
if (activeTool === 'break_up' || activeTool === 'break_down') {
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
// Find nearest candle
const nearestCandle = candles.reduce((prev, curr) => {
return Math.abs(curr.time - timestamp) < Math.abs(prev.time - timestamp) ? curr : prev;
}, candles[0]);
if (!nearestCandle) return;
// Create annotation
try {
const response = await fetch('/api/annotations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
timestamp: nearestCandle.time,
label_type: activeTool,
}),
});
if (response.ok) {
await fetchAnnotations();
onAnnotationChange?.();
}
} catch (error) {
console.error('Failed to create annotation:', error);
}
}
// For delete tool, find and delete marker at clicked position
if (activeTool === 'delete') {
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
// Find annotation at this timestamp (within tolerance)
const tolerance = 60; // 60 seconds tolerance
const annotation = annotations.find(
(a) =>
(a.label_type === 'break_up' || a.label_type === 'break_down') &&
Math.abs(a.timestamp - timestamp) < tolerance
);
if (annotation) {
try {
const response = await fetch(`/api/annotations/${annotation.id}`, {
method: 'DELETE',
});
if (response.ok) {
await fetchAnnotations();
onAnnotationChange?.();
}
} catch (error) {
console.error('Failed to delete annotation:', error);
}
}
}
};
chartRef.current.subscribeClick(handleClick);
return () => {
chartRef.current?.unsubscribeClick(handleClick);
};
}, [activeTool, candles, annotations, onAnnotationChange]);
// Fetch data on mount
useEffect(() => {
fetchCandles();
fetchAnnotations();
}, []);
if (isEmpty) {
return (
<div className="flex-1 flex items-center justify-center bg-background">
<div className="text-muted-foreground text-center">
<p className="text-lg">Upload a CSV file to view the candlestick chart</p>
<p className="text-sm mt-2">CSV format: time, open, high, low, close</p>
</div>
</div>
);
}
return (
<div className="flex-1 relative">
<div ref={chartContainerRef} className="absolute inset-0" />
<SvgOverlay
chart={chartRef.current}
series={seriesRef.current}
activeTool={activeTool}
onAnnotationChange={onAnnotationChange}
/>
</div>
);
}
);
CandleChart.displayName = 'CandleChart';
export default CandleChart;

View file

@ -0,0 +1,357 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { IChartApi, ISeriesApi } from 'lightweight-charts';
interface Annotation {
id: number;
timestamp: number;
label_type: string;
geometry: {
startTime?: number;
startPrice?: number;
endTime?: number;
endPrice?: number;
} | null;
created_at: number;
}
interface SvgOverlayProps {
chart: IChartApi | null;
series: ISeriesApi<'Candlestick'> | null;
activeTool: string | null;
onAnnotationChange?: () => void;
}
interface Point {
time: number;
price: number;
}
export default function SvgOverlay({
chart,
series,
activeTool,
onAnnotationChange,
}: SvgOverlayProps) {
const svgRef = useRef<SVGSVGElement>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [drawingLine, setDrawingLine] = useState<{ start: Point; current: Point } | null>(null);
const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null);
// Fetch annotations
const fetchAnnotations = async () => {
try {
const response = await fetch('/api/annotations');
const data = await response.json();
setAnnotations(data);
} catch (error) {
console.error('Failed to fetch annotations:', error);
}
};
// Update dimensions when chart resizes
useEffect(() => {
if (!chart) return;
const updateDimensions = () => {
const container = chart.chartElement();
if (container) {
setDimensions({
width: container.clientWidth,
height: container.clientHeight,
});
}
};
updateDimensions();
const resizeObserver = new ResizeObserver(updateDimensions);
const container = chart.chartElement();
if (container) {
resizeObserver.observe(container);
}
return () => {
resizeObserver.disconnect();
};
}, [chart]);
// Subscribe to visible range changes (zoom/pan)
useEffect(() => {
if (!chart) return;
const handleVisibleRangeChange = () => {
// Force re-render by updating state
setAnnotations((prev) => [...prev]);
};
chart.timeScale().subscribeVisibleTimeRangeChange(handleVisibleRangeChange);
return () => {
chart.timeScale().unsubscribeVisibleTimeRangeChange(handleVisibleRangeChange);
};
}, [chart]);
// Fetch annotations on mount and when notified
useEffect(() => {
fetchAnnotations();
}, [onAnnotationChange]);
// Convert data coordinates to pixel coordinates
const dataToPixel = (time: number, price: number): { x: number; y: number } | null => {
if (!chart || !series) return null;
const x = chart.timeScale().timeToCoordinate(time as any);
const y = series.priceToCoordinate(price);
if (x === null || y === null) return null;
return { x, y };
};
// Convert pixel coordinates to data coordinates
const pixelToData = (x: number, y: number): Point | null => {
if (!chart || !series) return null;
const time = chart.timeScale().coordinateToTime(x);
const price = series.coordinateToPrice(y);
if (time === null || price === null) return null;
return {
time: typeof time === 'string' ? Date.parse(time) / 1000 : (time as number),
price,
};
};
// Handle mouse move
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setMousePosition({ x, y });
if (drawingLine && activeTool === 'line') {
const dataPoint = pixelToData(x, y);
if (dataPoint) {
setDrawingLine((prev) => (prev ? { ...prev, current: dataPoint } : null));
}
}
};
// Handle click
const handleClick = async (e: React.MouseEvent<SVGSVGElement>) => {
if (!svgRef.current || !chart || !series) return;
const rect = svgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const dataPoint = pixelToData(x, y);
if (!dataPoint) return;
// Line drawing mode
if (activeTool === 'line') {
if (!drawingLine) {
// First click - start line
setDrawingLine({
start: dataPoint,
current: dataPoint,
});
} else {
// Second click - save line
try {
const response = await fetch('/api/annotations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
timestamp: drawingLine.start.time,
label_type: 'line',
geometry: {
startTime: drawingLine.start.time,
startPrice: drawingLine.start.price,
endTime: dataPoint.time,
endPrice: dataPoint.price,
},
}),
});
if (response.ok) {
await fetchAnnotations();
onAnnotationChange?.();
}
} catch (error) {
console.error('Failed to create line annotation:', error);
}
setDrawingLine(null);
}
}
// Delete mode - delete line
if (activeTool === 'delete') {
// Find line annotation near click point
const lineAnnotations = annotations.filter((a) => a.label_type === 'line' && a.geometry);
for (const annotation of lineAnnotations) {
if (!annotation.geometry) continue;
const start = dataToPixel(
annotation.geometry.startTime!,
annotation.geometry.startPrice!
);
const end = dataToPixel(annotation.geometry.endTime!, annotation.geometry.endPrice!);
if (!start || !end) continue;
// Calculate distance from point to line segment
const distance = distanceToLineSegment({ x, y }, start, end);
if (distance < 10) {
// Within 10 pixels
try {
const response = await fetch(`/api/annotations/${annotation.id}`, {
method: 'DELETE',
});
if (response.ok) {
await fetchAnnotations();
onAnnotationChange?.();
}
} catch (error) {
console.error('Failed to delete annotation:', error);
}
break;
}
}
}
};
// Calculate distance from point to line segment
const distanceToLineSegment = (
point: { x: number; y: number },
lineStart: { x: number; y: number },
lineEnd: { x: number; y: number }
): number => {
const A = point.x - lineStart.x;
const B = point.y - lineStart.y;
const C = lineEnd.x - lineStart.x;
const D = lineEnd.y - lineStart.y;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
let param = -1;
if (lenSq !== 0) param = dot / lenSq;
let xx, yy;
if (param < 0) {
xx = lineStart.x;
yy = lineStart.y;
} else if (param > 1) {
xx = lineEnd.x;
yy = lineEnd.y;
} else {
xx = lineStart.x + param * C;
yy = lineStart.y + param * D;
}
const dx = point.x - xx;
const dy = point.y - yy;
return Math.sqrt(dx * dx + dy * dy);
};
// Handle Escape key to cancel line drawing
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && drawingLine) {
setDrawingLine(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [drawingLine]);
// Render line annotations
const renderLines = () => {
const lineAnnotations = annotations.filter((a) => a.label_type === 'line' && a.geometry);
return lineAnnotations.map((annotation) => {
if (!annotation.geometry) return null;
const start = dataToPixel(
annotation.geometry.startTime!,
annotation.geometry.startPrice!
);
const end = dataToPixel(annotation.geometry.endTime!, annotation.geometry.endPrice!);
if (!start || !end) return null;
return (
<line
key={annotation.id}
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
stroke="#3b82f6"
strokeWidth="2"
style={{ cursor: activeTool === 'delete' ? 'pointer' : 'default' }}
/>
);
});
};
// Render preview line while drawing
const renderPreviewLine = () => {
if (!drawingLine) return null;
const start = dataToPixel(drawingLine.start.time, drawingLine.start.price);
const end = dataToPixel(drawingLine.current.time, drawingLine.current.price);
if (!start || !end) return null;
return (
<line
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
stroke="#3b82f6"
strokeWidth="2"
strokeDasharray="5,5"
opacity="0.6"
/>
);
};
if (!chart || !series) return null;
return (
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
style={{
position: 'absolute',
top: 0,
left: 0,
pointerEvents: activeTool === 'line' || activeTool === 'delete' ? 'auto' : 'none',
cursor: activeTool === 'line' ? 'crosshair' : activeTool === 'delete' ? 'pointer' : 'default',
}}
onMouseMove={handleMouseMove}
onClick={handleClick}
>
{renderLines()}
{renderPreviewLine()}
</svg>
);
}

View file

@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -10,7 +14,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@ -18,9 +22,20 @@
}
],
"paths": {
"@/*": ["./src/*"]
}
"@/*": [
"./src/*"
]
},
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}