feat: complete SVG overlay removal and line endpoint dragging (tasks 6.1-7.3)
This commit is contained in:
parent
aea1791122
commit
73e07c9050
4 changed files with 159 additions and 601 deletions
38
CLAUDE.md
38
CLAUDE.md
|
|
@ -34,9 +34,6 @@ rtk git add . && rtk git commit -m "msg" && rtk git push
|
||||||
|
|
||||||
### Build & Compile (80-90% savings)
|
### Build & Compile (80-90% savings)
|
||||||
```bash
|
```bash
|
||||||
rtk cargo build # Cargo build output
|
|
||||||
rtk cargo check # Cargo check output
|
|
||||||
rtk cargo clippy # Clippy warnings grouped by file (80%)
|
|
||||||
rtk tsc # TypeScript errors grouped by file/code (83%)
|
rtk tsc # TypeScript errors grouped by file/code (83%)
|
||||||
rtk lint # ESLint/Biome violations grouped (84%)
|
rtk lint # ESLint/Biome violations grouped (84%)
|
||||||
rtk prettier --check # Files needing format only (70%)
|
rtk prettier --check # Files needing format only (70%)
|
||||||
|
|
@ -45,8 +42,6 @@ rtk next build # Next.js build with route metrics (87%)
|
||||||
|
|
||||||
### Test (90-99% savings)
|
### Test (90-99% savings)
|
||||||
```bash
|
```bash
|
||||||
rtk cargo test # Cargo test failures only (90%)
|
|
||||||
rtk vitest run # Vitest failures only (99.5%)
|
|
||||||
rtk playwright test # Playwright failures only (94%)
|
rtk playwright test # Playwright failures only (94%)
|
||||||
rtk test <cmd> # Generic test wrapper - failures only
|
rtk test <cmd> # Generic test wrapper - failures only
|
||||||
```
|
```
|
||||||
|
|
@ -69,14 +64,7 @@ rtk git worktree # Compact worktree
|
||||||
|
|
||||||
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
|
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
|
||||||
|
|
||||||
### GitHub (26-87% savings)
|
|
||||||
```bash
|
|
||||||
rtk gh pr view <num> # Compact PR view (87%)
|
|
||||||
rtk gh pr checks # Compact PR checks (79%)
|
|
||||||
rtk gh run list # Compact workflow runs (82%)
|
|
||||||
rtk gh issue list # Compact issue list (80%)
|
|
||||||
rtk gh api # Compact API responses (26%)
|
|
||||||
```
|
|
||||||
|
|
||||||
### JavaScript/TypeScript Tooling (70-90% savings)
|
### JavaScript/TypeScript Tooling (70-90% savings)
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -122,28 +110,4 @@ rtk curl <url> # Compact HTTP responses (70%)
|
||||||
rtk wget <url> # Compact download output (65%)
|
rtk wget <url> # Compact download output (65%)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Meta Commands
|
|
||||||
```bash
|
|
||||||
rtk gain # View token savings statistics
|
|
||||||
rtk gain --history # View command history with savings
|
|
||||||
rtk discover # Analyze Claude Code sessions for missed RTK usage
|
|
||||||
rtk proxy <cmd> # Run command without filtering (for debugging)
|
|
||||||
rtk init # Add RTK instructions to CLAUDE.md
|
|
||||||
rtk init --global # Add RTK to ~/.claude/CLAUDE.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Token Savings Overview
|
|
||||||
|
|
||||||
| Category | Commands | Typical Savings |
|
|
||||||
|----------|----------|-----------------|
|
|
||||||
| Tests | vitest, playwright, cargo test | 90-99% |
|
|
||||||
| Build | next, tsc, lint, prettier | 70-87% |
|
|
||||||
| Git | status, log, diff, add, commit | 59-80% |
|
|
||||||
| GitHub | gh pr, gh run, gh issue | 26-87% |
|
|
||||||
| Package Managers | pnpm, npm, npx | 70-90% |
|
|
||||||
| Files | ls, read, grep, find | 60-75% |
|
|
||||||
| Infrastructure | docker, kubectl | 85% |
|
|
||||||
| Network | curl, wget | 65-70% |
|
|
||||||
|
|
||||||
Overall average: **60-90% token reduction** on common development operations.
|
|
||||||
<!-- /rtk-instructions -->
|
<!-- /rtk-instructions -->
|
||||||
|
|
|
||||||
|
|
@ -37,15 +37,15 @@
|
||||||
|
|
||||||
## 6. Remove SVG Overlay
|
## 6. Remove SVG Overlay
|
||||||
|
|
||||||
- [ ] 6.1 Remove `SvgOverlay` import and JSX from `CandleChart.tsx`
|
- [x] 6.1 Remove `SvgOverlay` import and JSX from `CandleChart.tsx`
|
||||||
- [ ] 6.2 Delete `src/components/SvgOverlay.tsx`
|
- [x] 6.2 Delete `src/components/SvgOverlay.tsx`
|
||||||
- [ ] 6.3 Move line annotation primitive management into CandleChart (replace what SvgOverlay was doing — loading saved lines, managing line primitives on annotation fetch/delete)
|
- [x] 6.3 Move line annotation primitive management into CandleChart (replace what SvgOverlay was doing — loading saved lines, managing line primitives on annotation fetch/delete)
|
||||||
|
|
||||||
## 7. Line Endpoint Dragging
|
## 7. Line Endpoint Dragging
|
||||||
|
|
||||||
- [ ] 7.1 Implement drag detection — when a selected line's endpoint handle is clicked (via hitTest near endpoint), enter drag mode
|
- [x] 7.1 Implement drag detection — when a selected line's endpoint handle is clicked (via hitTest near endpoint), enter drag mode
|
||||||
- [ ] 7.2 On crosshair move during drag, call `trendLine.updatePoints()` to reposition the dragged endpoint in real-time
|
- [x] 7.2 On crosshair move during drag, call `trendLine.updatePoints()` to reposition the dragged endpoint in real-time
|
||||||
- [ ] 7.3 On click to release drag, persist updated geometry via PATCH /api/annotations/{id}
|
- [x] 7.3 On click to release drag, persist updated geometry via PATCH /api/annotations/{id}
|
||||||
|
|
||||||
## 8. Verification
|
## 8. Verification
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
|
import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
|
||||||
import { createChart, IChartApi, ISeriesApi, CandlestickData, HistogramData, Time, SeriesMarker } from 'lightweight-charts';
|
import { createChart, IChartApi, ISeriesApi, CandlestickData, HistogramData, Time, SeriesMarker } from 'lightweight-charts';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import SvgOverlay from './SvgOverlay';
|
|
||||||
import SpanAnnotationManager from './SpanAnnotationManager';
|
import SpanAnnotationManager from './SpanAnnotationManager';
|
||||||
import { TrendLine } from '@/plugins/trend-line';
|
import { TrendLine } from '@/plugins/trend-line';
|
||||||
import { RectangleDrawingPrimitive, RectanglePoint } from '@/plugins/rectangle-drawing';
|
import { RectangleDrawingPrimitive, RectanglePoint } from '@/plugins/rectangle-drawing';
|
||||||
|
|
@ -146,6 +145,10 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
const linePrimitivesRef = useRef<Map<number, TrendLine>>(new Map());
|
const linePrimitivesRef = useRef<Map<number, TrendLine>>(new Map());
|
||||||
const rectanglePrimitivesRef = useRef<Map<number, RectangleDrawingPrimitive>>(new Map());
|
const rectanglePrimitivesRef = useRef<Map<number, RectangleDrawingPrimitive>>(new Map());
|
||||||
const [selectedRectangleId, setSelectedRectangleId] = useState<number | null>(null);
|
const [selectedRectangleId, setSelectedRectangleId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Line selection and dragging state
|
||||||
|
const [selectedLineId, setSelectedLineId] = useState<number | null>(null);
|
||||||
|
const [dragState, setDragState] = useState<{ lineId: number; endpoint: 'p1' | 'p2' } | null>(null);
|
||||||
|
|
||||||
// Track mounted state to avoid hydration mismatch
|
// Track mounted state to avoid hydration mismatch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -551,6 +554,20 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chartRef.current || !seriesRef.current) return;
|
if (!chartRef.current || !seriesRef.current) return;
|
||||||
|
|
||||||
|
// Helper function to check if click is near a line endpoint
|
||||||
|
const isNearEndpoint = (clickX: number, clickY: number, endpointTime: Time, endpointPrice: number): boolean => {
|
||||||
|
const endpointX = chartRef.current!.timeScale().timeToCoordinate(endpointTime);
|
||||||
|
const endpointY = seriesRef.current!.priceToCoordinate(endpointPrice);
|
||||||
|
|
||||||
|
if (endpointX === null || endpointY === null) return false;
|
||||||
|
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
Math.pow(clickX - endpointX, 2) + Math.pow(clickY - endpointY, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
return distance <= 8; // 8 pixel tolerance for endpoint handles
|
||||||
|
};
|
||||||
|
|
||||||
const handleClick = async (param: any) => {
|
const handleClick = async (param: any) => {
|
||||||
if (!param.point) return;
|
if (!param.point) return;
|
||||||
|
|
||||||
|
|
@ -562,6 +579,59 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
|
|
||||||
if (time === null || price === null) return;
|
if (time === null || price === null) return;
|
||||||
|
|
||||||
|
// Check if clicking on a selected line's endpoint handle to start dragging
|
||||||
|
if (selectedLineId !== null && !activeTool) {
|
||||||
|
const linePrimitive = linePrimitivesRef.current.get(selectedLineId);
|
||||||
|
if (linePrimitive) {
|
||||||
|
const p1 = linePrimitive.getP1();
|
||||||
|
const p2 = linePrimitive.getP2();
|
||||||
|
|
||||||
|
if (isNearEndpoint(timeCoordinate, priceCoordinate, p1.time, p1.price)) {
|
||||||
|
setDragState({ lineId: selectedLineId, endpoint: 'p1' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNearEndpoint(timeCoordinate, priceCoordinate, p2.time, p2.price)) {
|
||||||
|
setDragState({ lineId: selectedLineId, endpoint: 'p2' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If currently dragging, complete the drag operation
|
||||||
|
if (dragState) {
|
||||||
|
const linePrimitive = linePrimitivesRef.current.get(dragState.lineId);
|
||||||
|
if (linePrimitive) {
|
||||||
|
const annotation = annotations.find(a => a.id === dragState.lineId);
|
||||||
|
if (annotation) {
|
||||||
|
// Persist the updated geometry
|
||||||
|
const p1 = linePrimitive.getP1();
|
||||||
|
const p2 = linePrimitive.getP2();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/annotations/${annotation.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
geometry: {
|
||||||
|
startTime: typeof p1.time === 'string' ? Date.parse(p1.time) / 1000 : p1.time,
|
||||||
|
startPrice: p1.price,
|
||||||
|
endTime: typeof p2.time === 'string' ? Date.parse(p2.time) / 1000 : p2.time,
|
||||||
|
endPrice: p2.price,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await fetchAnnotations();
|
||||||
|
onAnnotationChange?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update line annotation:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setDragState(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle line and rectangle drawing
|
// Handle line and rectangle drawing
|
||||||
if (activeTool === 'line' || activeTool === 'rectangle') {
|
if (activeTool === 'line' || activeTool === 'rectangle') {
|
||||||
if (!drawingState) {
|
if (!drawingState) {
|
||||||
|
|
@ -688,11 +758,42 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For delete tool, find and delete marker or rectangle at clicked position
|
// For delete tool, find and delete line, rectangle, or marker at clicked position
|
||||||
if (activeTool === 'delete') {
|
if (activeTool === 'delete') {
|
||||||
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
||||||
|
|
||||||
// First, check for rectangle hit using primitives' hitTest
|
// First, check for line hit using primitives' hitTest
|
||||||
|
let lineHit: { id: number; primitive: TrendLine } | null = null;
|
||||||
|
linePrimitivesRef.current.forEach((primitive, id) => {
|
||||||
|
const hit = primitive.hitTest(timeCoordinate, priceCoordinate);
|
||||||
|
if (hit) {
|
||||||
|
lineHit = { id, primitive };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lineHit) {
|
||||||
|
// Delete the clicked line
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/annotations/${lineHit.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
seriesRef.current!.detachPrimitive(lineHit.primitive);
|
||||||
|
linePrimitivesRef.current.delete(lineHit.id);
|
||||||
|
if (selectedLineId === lineHit.id) {
|
||||||
|
setSelectedLineId(null);
|
||||||
|
}
|
||||||
|
await fetchAnnotations();
|
||||||
|
onAnnotationChange?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete line annotation:', error);
|
||||||
|
}
|
||||||
|
return; // Don't process rectangle/marker deletion
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next, check for rectangle hit using primitives' hitTest
|
||||||
let rectangleHit: { id: number; primitive: RectangleDrawingPrimitive } | null = null;
|
let rectangleHit: { id: number; primitive: RectangleDrawingPrimitive } | null = null;
|
||||||
rectanglePrimitivesRef.current.forEach((primitive, id) => {
|
rectanglePrimitivesRef.current.forEach((primitive, id) => {
|
||||||
const hit = primitive.hitTest(timeCoordinate, priceCoordinate);
|
const hit = primitive.hitTest(timeCoordinate, priceCoordinate);
|
||||||
|
|
@ -749,6 +850,31 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle line selection when no tool is active or delete tool is active
|
||||||
|
if (!activeTool || activeTool === 'delete') {
|
||||||
|
// Check if a line was clicked
|
||||||
|
let lineHit: { id: number; primitive: TrendLine } | null = null;
|
||||||
|
linePrimitivesRef.current.forEach((primitive, id) => {
|
||||||
|
const hit = primitive.hitTest(timeCoordinate, priceCoordinate);
|
||||||
|
if (hit && activeTool !== 'delete') {
|
||||||
|
lineHit = { id, primitive };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lineHit && activeTool !== 'delete') {
|
||||||
|
// Toggle selection
|
||||||
|
const newSelectedId = selectedLineId === lineHit.id ? null : lineHit.id;
|
||||||
|
setSelectedLineId(newSelectedId);
|
||||||
|
|
||||||
|
// Update all lines' selection state
|
||||||
|
linePrimitivesRef.current.forEach((p, pid) => {
|
||||||
|
p.setSelected(pid === newSelectedId);
|
||||||
|
});
|
||||||
|
seriesRef.current!.applyOptions({});
|
||||||
|
return; // Don't process rectangle/marker selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle rectangle selection when no tool is active or delete tool is active
|
// Handle rectangle selection when no tool is active or delete tool is active
|
||||||
if (!activeTool || activeTool === 'delete') {
|
if (!activeTool || activeTool === 'delete') {
|
||||||
// Check if a rectangle was clicked
|
// Check if a rectangle was clicked
|
||||||
|
|
@ -835,9 +961,11 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
};
|
};
|
||||||
}, [activeTool, candles, annotations, annotationTypes, onAnnotationChange, predictionVisible, predictionSpans, predictionSummary, onPredictionClick, onPredictionDismiss, drawingState, selectedColor]);
|
}, [activeTool, candles, annotations, annotationTypes, onAnnotationChange, predictionVisible, predictionSpans, predictionSummary, onPredictionClick, onPredictionDismiss, drawingState, selectedColor]);
|
||||||
|
|
||||||
// Handle crosshair move to update preview during drawing
|
// Handle crosshair move to update preview during drawing and dragging
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chartRef.current || !seriesRef.current || !drawingState || !previewPrimitiveRef.current) return;
|
if (!chartRef.current || !seriesRef.current) return;
|
||||||
|
if (!drawingState && !dragState) return;
|
||||||
|
if (drawingState && !previewPrimitiveRef.current) return;
|
||||||
|
|
||||||
const handleCrosshairMove = (param: any) => {
|
const handleCrosshairMove = (param: any) => {
|
||||||
if (!param.point) return;
|
if (!param.point) return;
|
||||||
|
|
@ -849,8 +977,8 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
|
|
||||||
const currentPoint = { time, price };
|
const currentPoint = { time, price };
|
||||||
|
|
||||||
// Update preview primitive endpoint
|
// Update preview primitive endpoint during drawing
|
||||||
if (previewPrimitiveRef.current) {
|
if (drawingState && previewPrimitiveRef.current) {
|
||||||
if (previewPrimitiveRef.current instanceof TrendLine) {
|
if (previewPrimitiveRef.current instanceof TrendLine) {
|
||||||
previewPrimitiveRef.current.updatePoints(
|
previewPrimitiveRef.current.updatePoints(
|
||||||
drawingState.firstPoint as Point,
|
drawingState.firstPoint as Point,
|
||||||
|
|
@ -864,6 +992,22 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
}
|
}
|
||||||
seriesRef.current!.applyOptions({});
|
seriesRef.current!.applyOptions({});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update line endpoint during dragging
|
||||||
|
if (dragState) {
|
||||||
|
const linePrimitive = linePrimitivesRef.current.get(dragState.lineId);
|
||||||
|
if (linePrimitive) {
|
||||||
|
const p1 = linePrimitive.getP1();
|
||||||
|
const p2 = linePrimitive.getP2();
|
||||||
|
|
||||||
|
if (dragState.endpoint === 'p1') {
|
||||||
|
linePrimitive.updatePoints(currentPoint as Point, p2);
|
||||||
|
} else {
|
||||||
|
linePrimitive.updatePoints(p1, currentPoint as Point);
|
||||||
|
}
|
||||||
|
seriesRef.current!.applyOptions({});
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
chartRef.current.subscribeCrosshairMove(handleCrosshairMove);
|
chartRef.current.subscribeCrosshairMove(handleCrosshairMove);
|
||||||
|
|
@ -871,7 +1015,7 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
return () => {
|
return () => {
|
||||||
chartRef.current?.unsubscribeCrosshairMove(handleCrosshairMove);
|
chartRef.current?.unsubscribeCrosshairMove(handleCrosshairMove);
|
||||||
};
|
};
|
||||||
}, [drawingState]);
|
}, [drawingState, dragState]);
|
||||||
|
|
||||||
// Handle Escape key to cancel drawing
|
// Handle Escape key to cancel drawing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1017,14 +1161,6 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full relative">
|
<div className="w-full h-full relative">
|
||||||
<div ref={chartContainerRef} className="absolute inset-0" />
|
<div ref={chartContainerRef} className="absolute inset-0" />
|
||||||
<SvgOverlay
|
|
||||||
chart={chartRef.current}
|
|
||||||
series={seriesRef.current}
|
|
||||||
activeTool={activeTool}
|
|
||||||
onAnnotationChange={onAnnotationChange}
|
|
||||||
selectedColor={selectedColor}
|
|
||||||
activeChartId={activeChartId}
|
|
||||||
/>
|
|
||||||
<SpanAnnotationManager
|
<SpanAnnotationManager
|
||||||
chart={chartRef.current}
|
chart={chartRef.current}
|
||||||
series={seriesRef.current}
|
series={seriesRef.current}
|
||||||
|
|
|
||||||
|
|
@ -1,542 +0,0 @@
|
||||||
'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;
|
|
||||||
color?: string;
|
|
||||||
created_at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SvgOverlayProps {
|
|
||||||
chart: IChartApi | null;
|
|
||||||
series: ISeriesApi<'Candlestick'> | null;
|
|
||||||
activeTool: string | null;
|
|
||||||
onAnnotationChange?: () => void;
|
|
||||||
selectedColor: string;
|
|
||||||
activeChartId?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Point {
|
|
||||||
time: number;
|
|
||||||
price: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DragState {
|
|
||||||
lineId: number;
|
|
||||||
endpoint: 'start' | 'end';
|
|
||||||
originalPoint: Point;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SvgOverlay({
|
|
||||||
chart,
|
|
||||||
series,
|
|
||||||
activeTool,
|
|
||||||
onAnnotationChange,
|
|
||||||
selectedColor,
|
|
||||||
activeChartId,
|
|
||||||
}: 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);
|
|
||||||
const [selectedLineId, setSelectedLineId] = useState<number | null>(null);
|
|
||||||
const [dragState, setDragState] = useState<DragState | null>(null);
|
|
||||||
|
|
||||||
// Fetch annotations
|
|
||||||
const fetchAnnotations = async () => {
|
|
||||||
try {
|
|
||||||
const url = activeChartId ? `/api/annotations?chartId=${activeChartId}` : '/api/annotations';
|
|
||||||
const response = await fetch(url);
|
|
||||||
const data = await response.json();
|
|
||||||
setAnnotations(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch annotations:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update dimensions when SVG resizes
|
|
||||||
useEffect(() => {
|
|
||||||
if (!svgRef.current) return;
|
|
||||||
|
|
||||||
const updateDimensions = () => {
|
|
||||||
if (svgRef.current) {
|
|
||||||
const rect = svgRef.current.getBoundingClientRect();
|
|
||||||
setDimensions({
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updateDimensions();
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(updateDimensions);
|
|
||||||
resizeObserver.observe(svgRef.current);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 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 });
|
|
||||||
|
|
||||||
// Handle line drawing
|
|
||||||
if (drawingLine && activeTool === 'line') {
|
|
||||||
const dataPoint = pixelToData(x, y);
|
|
||||||
if (dataPoint) {
|
|
||||||
setDrawingLine((prev) => (prev ? { ...prev, current: dataPoint } : null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle endpoint dragging
|
|
||||||
if (dragState && mousePosition) {
|
|
||||||
const dataPoint = pixelToData(x, y);
|
|
||||||
if (dataPoint) {
|
|
||||||
// Update annotations array optimistically for preview
|
|
||||||
setAnnotations((prev) =>
|
|
||||||
prev.map((ann) => {
|
|
||||||
if (ann.id === dragState.lineId && ann.geometry) {
|
|
||||||
const updatedGeometry = {
|
|
||||||
...ann.geometry,
|
|
||||||
...(dragState.endpoint === 'start'
|
|
||||||
? { startTime: dataPoint.time, startPrice: dataPoint.price }
|
|
||||||
: { endTime: dataPoint.time, endPrice: dataPoint.price }),
|
|
||||||
};
|
|
||||||
return { ...ann, geometry: updatedGeometry };
|
|
||||||
}
|
|
||||||
return ann;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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 - check if clicking near existing line to select it
|
|
||||||
const lineAnnotations = annotations.filter((a) => a.label_type === 'line' && a.geometry);
|
|
||||||
let lineSelected = false;
|
|
||||||
|
|
||||||
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 - select this line
|
|
||||||
setSelectedLineId(annotation.id);
|
|
||||||
lineSelected = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no line was selected, start drawing a new line
|
|
||||||
if (!lineSelected) {
|
|
||||||
setDrawingLine({
|
|
||||||
start: dataPoint,
|
|
||||||
current: dataPoint,
|
|
||||||
});
|
|
||||||
setSelectedLineId(null); // Clear selection when starting new line
|
|
||||||
}
|
|
||||||
} 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',
|
|
||||||
chart_id: activeChartId,
|
|
||||||
color: selectedColor,
|
|
||||||
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 and deselect
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
if (drawingLine) {
|
|
||||||
setDrawingLine(null);
|
|
||||||
}
|
|
||||||
if (selectedLineId !== null) {
|
|
||||||
setSelectedLineId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [drawingLine, selectedLineId]);
|
|
||||||
|
|
||||||
// Deselect line when tool changes
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedLineId(null);
|
|
||||||
}, [activeTool]);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
const isSelected = annotation.id === selectedLineId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<line
|
|
||||||
key={annotation.id}
|
|
||||||
x1={start.x}
|
|
||||||
y1={start.y}
|
|
||||||
x2={end.x}
|
|
||||||
y2={end.y}
|
|
||||||
stroke={annotation.color || '#3b82f6'}
|
|
||||||
strokeWidth={isSelected ? 3 : 2}
|
|
||||||
opacity={isSelected ? 1 : 0.85}
|
|
||||||
style={{ cursor: activeTool === 'delete' ? 'pointer' : activeTool === 'line' ? '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={selectedColor}
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeDasharray="5,5"
|
|
||||||
opacity="0.6"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render cursor circle during line drawing
|
|
||||||
const renderCursorCircle = () => {
|
|
||||||
if (!drawingLine || !mousePosition) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<circle
|
|
||||||
cx={mousePosition.x}
|
|
||||||
cy={mousePosition.y}
|
|
||||||
r={6}
|
|
||||||
fill="white"
|
|
||||||
fillOpacity={0.3}
|
|
||||||
stroke={selectedColor}
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle mouse down on endpoint handle
|
|
||||||
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 });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle mouse up - save dragged endpoint
|
|
||||||
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 }),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/annotations/${dragState.lineId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ geometry: updatedGeometry }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
await fetchAnnotations();
|
|
||||||
onAnnotationChange?.();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update annotation:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
setDragState(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render endpoint handles for selected line
|
|
||||||
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')}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!chart || !series) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
ref={svgRef}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
zIndex: 1111,
|
|
||||||
pointerEvents: activeTool === 'line' || activeTool === 'delete' ? 'auto' : 'none',
|
|
||||||
cursor: activeTool === 'line' ? 'crosshair' : activeTool === 'delete' ? 'pointer' : 'default',
|
|
||||||
}}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onClick={handleClick}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
>
|
|
||||||
{renderLines()}
|
|
||||||
{renderPreviewLine()}
|
|
||||||
{renderCursorCircle()}
|
|
||||||
{renderHandles()}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue