feat: complete SVG overlay removal and line endpoint dragging (tasks 6.1-7.3)
This commit is contained in:
parent
aea1791122
commit
73e07c9050
4 changed files with 159 additions and 601 deletions
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue