feat: complete SVG overlay removal and line endpoint dragging (tasks 6.1-7.3)

This commit is contained in:
Marko Djordjevic 2026-02-16 12:13:29 +01:00
parent aea1791122
commit 73e07c9050
4 changed files with 159 additions and 601 deletions

View file

@ -3,7 +3,6 @@
import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 'react';
import { createChart, IChartApi, ISeriesApi, CandlestickData, HistogramData, Time, SeriesMarker } from 'lightweight-charts';
import { useTheme } from 'next-themes';
import SvgOverlay from './SvgOverlay';
import SpanAnnotationManager from './SpanAnnotationManager';
import { TrendLine } from '@/plugins/trend-line';
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 rectanglePrimitivesRef = useRef<Map<number, RectangleDrawingPrimitive>>(new Map());
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
useEffect(() => {
@ -551,6 +554,20 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
useEffect(() => {
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) => {
if (!param.point) return;
@ -562,6 +579,59 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
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
if (activeTool === 'line' || activeTool === 'rectangle') {
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') {
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;
rectanglePrimitivesRef.current.forEach((primitive, id) => {
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
if (!activeTool || activeTool === 'delete') {
// 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]);
// Handle crosshair move to update preview during drawing
// Handle crosshair move to update preview during drawing and dragging
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) => {
if (!param.point) return;
@ -849,8 +977,8 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
const currentPoint = { time, price };
// Update preview primitive endpoint
if (previewPrimitiveRef.current) {
// Update preview primitive endpoint during drawing
if (drawingState && previewPrimitiveRef.current) {
if (previewPrimitiveRef.current instanceof TrendLine) {
previewPrimitiveRef.current.updatePoints(
drawingState.firstPoint as Point,
@ -864,6 +992,22 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
}
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);
@ -871,7 +1015,7 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
return () => {
chartRef.current?.unsubscribeCrosshairMove(handleCrosshairMove);
};
}, [drawingState]);
}, [drawingState, dragState]);
// Handle Escape key to cancel drawing
useEffect(() => {
@ -1017,14 +1161,6 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
return (
<div className="w-full h-full relative">
<div ref={chartContainerRef} className="absolute inset-0" />
<SvgOverlay
chart={chartRef.current}
series={seriesRef.current}
activeTool={activeTool}
onAnnotationChange={onAnnotationChange}
selectedColor={selectedColor}
activeChartId={activeChartId}
/>
<SpanAnnotationManager
chart={chartRef.current}
series={seriesRef.current}