feat: implement Phase 4 endpoint dragging with visual handles
This commit is contained in:
parent
91c516999d
commit
37c3adf42f
3 changed files with 170 additions and 1 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 }> }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue