feat: implement Phase 4 endpoint dragging with visual handles

This commit is contained in:
Marko Djordjevic 2026-02-12 14:32:00 +01:00
parent 91c516999d
commit 37c3adf42f
3 changed files with 170 additions and 1 deletions

View file

@ -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`

View file

@ -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 }> }

View file

@ -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<number | null>(null);
const [dragState, setDragState] = useState<DragState | null>(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 (
<>
<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 (
@ -411,10 +527,12 @@ export default function SvgOverlay({
}}
onMouseMove={handleMouseMove}
onClick={handleClick}
onMouseUp={handleMouseUp}
>
{renderLines()}
{renderPreviewLine()}
{renderCursorCircle()}
{renderHandles()}
</svg>
);
}