diff --git a/LINE_DRAWING_IMPROVEMENTS.md b/LINE_DRAWING_IMPROVEMENTS.md index 3ee21e8..799416d 100644 --- a/LINE_DRAWING_IMPROVEMENTS.md +++ b/LINE_DRAWING_IMPROVEMENTS.md @@ -135,7 +135,7 @@ opacity={isSelected ? 1 : 0.85} --- -### Phase 4: Endpoint Dragging +### Phase 4: Endpoint Dragging ✅ DONE #### 4.1 Drag State **File**: `src/components/SvgOverlay.tsx` diff --git a/src/app/api/annotations/[id]/route.ts b/src/app/api/annotations/[id]/route.ts index f5c8c8d..971f071 100644 --- a/src/app/api/annotations/[id]/route.ts +++ b/src/app/api/annotations/[id]/route.ts @@ -3,6 +3,57 @@ import { db } from '@/lib/db'; import { annotations } from '@/lib/db/schema'; import { eq } from 'drizzle-orm'; +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: idParam } = await params; + const id = parseInt(idParam); + + if (isNaN(id)) { + return NextResponse.json( + { error: 'Invalid annotation ID' }, + { status: 400 } + ); + } + + const body = await request.json(); + const { geometry } = body; + + if (!geometry) { + return NextResponse.json( + { error: 'Geometry data is required' }, + { status: 400 } + ); + } + + const result = await db + .update(annotations) + .set({ geometry: JSON.stringify(geometry) }) + .where(eq(annotations.id, id)) + .returning(); + + if (result.length === 0) { + return NextResponse.json( + { error: 'Annotation not found' }, + { status: 404 } + ); + } + + const updated = result[0]; + return NextResponse.json({ + ...updated, + geometry: updated.geometry ? JSON.parse(updated.geometry as string) : null, + }); + } catch (error: any) { + return NextResponse.json( + { error: error.message || 'Failed to update annotation' }, + { status: 500 } + ); + } +} + export async function DELETE( request: NextRequest, { params }: { params: Promise<{ id: string }> } diff --git a/src/components/SvgOverlay.tsx b/src/components/SvgOverlay.tsx index 9d56c93..2c14698 100644 --- a/src/components/SvgOverlay.tsx +++ b/src/components/SvgOverlay.tsx @@ -30,6 +30,12 @@ interface Point { price: number; } +interface DragState { + lineId: number; + endpoint: 'start' | 'end'; + originalPoint: Point; +} + export default function SvgOverlay({ chart, series, @@ -43,6 +49,7 @@ export default function SvgOverlay({ const [drawingLine, setDrawingLine] = useState<{ start: Point; current: Point } | null>(null); const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null); const [selectedLineId, setSelectedLineId] = useState(null); + const [dragState, setDragState] = useState(null); // Fetch annotations const fetchAnnotations = async () => { @@ -137,12 +144,35 @@ export default function SvgOverlay({ 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 @@ -394,6 +424,92 @@ export default function SvgOverlay({ ); }; + // 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 ( + <> + handleHandleMouseDown(e, line.id, 'start')} + /> + handleHandleMouseDown(e, line.id, 'end')} + /> + + ); + }; + if (!chart || !series) return null; return ( @@ -411,10 +527,12 @@ export default function SvgOverlay({ }} onMouseMove={handleMouseMove} onClick={handleClick} + onMouseUp={handleMouseUp} > {renderLines()} {renderPreviewLine()} {renderCursorCircle()} + {renderHandles()} ); }