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
|
#### 4.1 Drag State
|
||||||
**File**: `src/components/SvgOverlay.tsx`
|
**File**: `src/components/SvgOverlay.tsx`
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,57 @@ import { db } from '@/lib/db';
|
||||||
import { annotations } from '@/lib/db/schema';
|
import { annotations } from '@/lib/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
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(
|
export async function DELETE(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,12 @@ interface Point {
|
||||||
price: number;
|
price: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
lineId: number;
|
||||||
|
endpoint: 'start' | 'end';
|
||||||
|
originalPoint: Point;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SvgOverlay({
|
export default function SvgOverlay({
|
||||||
chart,
|
chart,
|
||||||
series,
|
series,
|
||||||
|
|
@ -43,6 +49,7 @@ export default function SvgOverlay({
|
||||||
const [drawingLine, setDrawingLine] = useState<{ start: Point; current: Point } | null>(null);
|
const [drawingLine, setDrawingLine] = useState<{ start: Point; current: Point } | null>(null);
|
||||||
const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null);
|
const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [selectedLineId, setSelectedLineId] = useState<number | null>(null);
|
const [selectedLineId, setSelectedLineId] = useState<number | null>(null);
|
||||||
|
const [dragState, setDragState] = useState<DragState | null>(null);
|
||||||
|
|
||||||
// Fetch annotations
|
// Fetch annotations
|
||||||
const fetchAnnotations = async () => {
|
const fetchAnnotations = async () => {
|
||||||
|
|
@ -137,12 +144,35 @@ export default function SvgOverlay({
|
||||||
|
|
||||||
setMousePosition({ x, y });
|
setMousePosition({ x, y });
|
||||||
|
|
||||||
|
// Handle line drawing
|
||||||
if (drawingLine && activeTool === 'line') {
|
if (drawingLine && activeTool === 'line') {
|
||||||
const dataPoint = pixelToData(x, y);
|
const dataPoint = pixelToData(x, y);
|
||||||
if (dataPoint) {
|
if (dataPoint) {
|
||||||
setDrawingLine((prev) => (prev ? { ...prev, current: dataPoint } : null));
|
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
|
// 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;
|
if (!chart || !series) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -411,10 +527,12 @@ export default function SvgOverlay({
|
||||||
}}
|
}}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
>
|
>
|
||||||
{renderLines()}
|
{renderLines()}
|
||||||
{renderPreviewLine()}
|
{renderPreviewLine()}
|
||||||
{renderCursorCircle()}
|
{renderCursorCircle()}
|
||||||
|
{renderHandles()}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue