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

@ -34,9 +34,6 @@ rtk git add . && rtk git commit -m "msg" && rtk git push
### Build & Compile (80-90% savings)
```bash
rtk cargo build # Cargo build output
rtk cargo check # Cargo check output
rtk cargo clippy # Clippy warnings grouped by file (80%)
rtk tsc # TypeScript errors grouped by file/code (83%)
rtk lint # ESLint/Biome violations grouped (84%)
rtk prettier --check # Files needing format only (70%)
@ -45,8 +42,6 @@ rtk next build # Next.js build with route metrics (87%)
### Test (90-99% savings)
```bash
rtk cargo test # Cargo test failures only (90%)
rtk vitest run # Vitest failures only (99.5%)
rtk playwright test # Playwright failures only (94%)
rtk test <cmd> # Generic test wrapper - failures only
```
@ -69,14 +64,7 @@ rtk git worktree # Compact worktree
Note: Git passthrough works for ALL subcommands, even those not explicitly listed.
### GitHub (26-87% savings)
```bash
rtk gh pr view <num> # Compact PR view (87%)
rtk gh pr checks # Compact PR checks (79%)
rtk gh run list # Compact workflow runs (82%)
rtk gh issue list # Compact issue list (80%)
rtk gh api # Compact API responses (26%)
```
### JavaScript/TypeScript Tooling (70-90% savings)
```bash
@ -122,28 +110,4 @@ rtk curl <url> # Compact HTTP responses (70%)
rtk wget <url> # Compact download output (65%)
```
### Meta Commands
```bash
rtk gain # View token savings statistics
rtk gain --history # View command history with savings
rtk discover # Analyze Claude Code sessions for missed RTK usage
rtk proxy <cmd> # Run command without filtering (for debugging)
rtk init # Add RTK instructions to CLAUDE.md
rtk init --global # Add RTK to ~/.claude/CLAUDE.md
```
## Token Savings Overview
| Category | Commands | Typical Savings |
|----------|----------|-----------------|
| Tests | vitest, playwright, cargo test | 90-99% |
| Build | next, tsc, lint, prettier | 70-87% |
| Git | status, log, diff, add, commit | 59-80% |
| GitHub | gh pr, gh run, gh issue | 26-87% |
| Package Managers | pnpm, npm, npx | 70-90% |
| Files | ls, read, grep, find | 60-75% |
| Infrastructure | docker, kubectl | 85% |
| Network | curl, wget | 65-70% |
Overall average: **60-90% token reduction** on common development operations.
<!-- /rtk-instructions -->

View file

@ -37,15 +37,15 @@
## 6. Remove SVG Overlay
- [ ] 6.1 Remove `SvgOverlay` import and JSX from `CandleChart.tsx`
- [ ] 6.2 Delete `src/components/SvgOverlay.tsx`
- [ ] 6.3 Move line annotation primitive management into CandleChart (replace what SvgOverlay was doing — loading saved lines, managing line primitives on annotation fetch/delete)
- [x] 6.1 Remove `SvgOverlay` import and JSX from `CandleChart.tsx`
- [x] 6.2 Delete `src/components/SvgOverlay.tsx`
- [x] 6.3 Move line annotation primitive management into CandleChart (replace what SvgOverlay was doing — loading saved lines, managing line primitives on annotation fetch/delete)
## 7. Line Endpoint Dragging
- [ ] 7.1 Implement drag detection — when a selected line's endpoint handle is clicked (via hitTest near endpoint), enter drag mode
- [ ] 7.2 On crosshair move during drag, call `trendLine.updatePoints()` to reposition the dragged endpoint in real-time
- [ ] 7.3 On click to release drag, persist updated geometry via PATCH /api/annotations/{id}
- [x] 7.1 Implement drag detection — when a selected line's endpoint handle is clicked (via hitTest near endpoint), enter drag mode
- [x] 7.2 On crosshair move during drag, call `trendLine.updatePoints()` to reposition the dragged endpoint in real-time
- [x] 7.3 On click to release drag, persist updated geometry via PATCH /api/annotations/{id}
## 8. Verification

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}

View file

@ -1,542 +0,0 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { IChartApi, ISeriesApi } from 'lightweight-charts';
interface Annotation {
id: number;
timestamp: number;
label_type: string;
geometry: {
startTime?: number;
startPrice?: number;
endTime?: number;
endPrice?: number;
} | null;
color?: string;
created_at: number;
}
interface SvgOverlayProps {
chart: IChartApi | null;
series: ISeriesApi<'Candlestick'> | null;
activeTool: string | null;
onAnnotationChange?: () => void;
selectedColor: string;
activeChartId?: number | null;
}
interface Point {
time: number;
price: number;
}
interface DragState {
lineId: number;
endpoint: 'start' | 'end';
originalPoint: Point;
}
export default function SvgOverlay({
chart,
series,
activeTool,
onAnnotationChange,
selectedColor,
activeChartId,
}: SvgOverlayProps) {
const svgRef = useRef<SVGSVGElement>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [annotations, setAnnotations] = useState<Annotation[]>([]);
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 () => {
try {
const url = activeChartId ? `/api/annotations?chartId=${activeChartId}` : '/api/annotations';
const response = await fetch(url);
const data = await response.json();
setAnnotations(data);
} catch (error) {
console.error('Failed to fetch annotations:', error);
}
};
// Update dimensions when SVG resizes
useEffect(() => {
if (!svgRef.current) return;
const updateDimensions = () => {
if (svgRef.current) {
const rect = svgRef.current.getBoundingClientRect();
setDimensions({
width: rect.width,
height: rect.height,
});
}
};
updateDimensions();
const resizeObserver = new ResizeObserver(updateDimensions);
resizeObserver.observe(svgRef.current);
return () => {
resizeObserver.disconnect();
};
}, []);
// Subscribe to visible range changes (zoom/pan)
useEffect(() => {
if (!chart) return;
const handleVisibleRangeChange = () => {
// Force re-render by updating state
setAnnotations((prev) => [...prev]);
};
chart.timeScale().subscribeVisibleTimeRangeChange(handleVisibleRangeChange);
return () => {
chart.timeScale().unsubscribeVisibleTimeRangeChange(handleVisibleRangeChange);
};
}, [chart]);
// Fetch annotations on mount and when notified
useEffect(() => {
fetchAnnotations();
}, [onAnnotationChange]);
// Convert data coordinates to pixel coordinates
const dataToPixel = (time: number, price: number): { x: number; y: number } | null => {
if (!chart || !series) return null;
const x = chart.timeScale().timeToCoordinate(time as any);
const y = series.priceToCoordinate(price);
if (x === null || y === null) return null;
return { x, y };
};
// Convert pixel coordinates to data coordinates
const pixelToData = (x: number, y: number): Point | null => {
if (!chart || !series) return null;
const time = chart.timeScale().coordinateToTime(x);
const price = series.coordinateToPrice(y);
if (time === null || price === null) return null;
return {
time: typeof time === 'string' ? Date.parse(time) / 1000 : (time as number),
price,
};
};
// Handle mouse move
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
if (!svgRef.current) return;
const rect = svgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
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
const handleClick = async (e: React.MouseEvent<SVGSVGElement>) => {
if (!svgRef.current || !chart || !series) return;
const rect = svgRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const dataPoint = pixelToData(x, y);
if (!dataPoint) return;
// Line drawing mode
if (activeTool === 'line') {
if (!drawingLine) {
// First click - check if clicking near existing line to select it
const lineAnnotations = annotations.filter((a) => a.label_type === 'line' && a.geometry);
let lineSelected = false;
for (const annotation of lineAnnotations) {
if (!annotation.geometry) continue;
const start = dataToPixel(
annotation.geometry.startTime!,
annotation.geometry.startPrice!
);
const end = dataToPixel(annotation.geometry.endTime!, annotation.geometry.endPrice!);
if (!start || !end) continue;
// Calculate distance from point to line segment
const distance = distanceToLineSegment({ x, y }, start, end);
if (distance < 10) {
// Within 10 pixels - select this line
setSelectedLineId(annotation.id);
lineSelected = true;
break;
}
}
// If no line was selected, start drawing a new line
if (!lineSelected) {
setDrawingLine({
start: dataPoint,
current: dataPoint,
});
setSelectedLineId(null); // Clear selection when starting new line
}
} else {
// Second click - save line
try {
const response = await fetch('/api/annotations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
timestamp: drawingLine.start.time,
label_type: 'line',
chart_id: activeChartId,
color: selectedColor,
geometry: {
startTime: drawingLine.start.time,
startPrice: drawingLine.start.price,
endTime: dataPoint.time,
endPrice: dataPoint.price,
},
}),
});
if (response.ok) {
await fetchAnnotations();
onAnnotationChange?.();
}
} catch (error) {
console.error('Failed to create line annotation:', error);
}
setDrawingLine(null);
}
}
// Delete mode - delete line
if (activeTool === 'delete') {
// Find line annotation near click point
const lineAnnotations = annotations.filter((a) => a.label_type === 'line' && a.geometry);
for (const annotation of lineAnnotations) {
if (!annotation.geometry) continue;
const start = dataToPixel(
annotation.geometry.startTime!,
annotation.geometry.startPrice!
);
const end = dataToPixel(annotation.geometry.endTime!, annotation.geometry.endPrice!);
if (!start || !end) continue;
// Calculate distance from point to line segment
const distance = distanceToLineSegment({ x, y }, start, end);
if (distance < 10) {
// Within 10 pixels
try {
const response = await fetch(`/api/annotations/${annotation.id}`, {
method: 'DELETE',
});
if (response.ok) {
await fetchAnnotations();
onAnnotationChange?.();
}
} catch (error) {
console.error('Failed to delete annotation:', error);
}
break;
}
}
}
};
// Calculate distance from point to line segment
const distanceToLineSegment = (
point: { x: number; y: number },
lineStart: { x: number; y: number },
lineEnd: { x: number; y: number }
): number => {
const A = point.x - lineStart.x;
const B = point.y - lineStart.y;
const C = lineEnd.x - lineStart.x;
const D = lineEnd.y - lineStart.y;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
let param = -1;
if (lenSq !== 0) param = dot / lenSq;
let xx, yy;
if (param < 0) {
xx = lineStart.x;
yy = lineStart.y;
} else if (param > 1) {
xx = lineEnd.x;
yy = lineEnd.y;
} else {
xx = lineStart.x + param * C;
yy = lineStart.y + param * D;
}
const dx = point.x - xx;
const dy = point.y - yy;
return Math.sqrt(dx * dx + dy * dy);
};
// Handle Escape key to cancel line drawing and deselect
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (drawingLine) {
setDrawingLine(null);
}
if (selectedLineId !== null) {
setSelectedLineId(null);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [drawingLine, selectedLineId]);
// Deselect line when tool changes
useEffect(() => {
setSelectedLineId(null);
}, [activeTool]);
// Render line annotations
const renderLines = () => {
const lineAnnotations = annotations.filter((a) => a.label_type === 'line' && a.geometry);
return lineAnnotations.map((annotation) => {
if (!annotation.geometry) return null;
const start = dataToPixel(
annotation.geometry.startTime!,
annotation.geometry.startPrice!
);
const end = dataToPixel(annotation.geometry.endTime!, annotation.geometry.endPrice!);
if (!start || !end) return null;
const isSelected = annotation.id === selectedLineId;
return (
<line
key={annotation.id}
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
stroke={annotation.color || '#3b82f6'}
strokeWidth={isSelected ? 3 : 2}
opacity={isSelected ? 1 : 0.85}
style={{ cursor: activeTool === 'delete' ? 'pointer' : activeTool === 'line' ? 'pointer' : 'default' }}
/>
);
});
};
// Render preview line while drawing
const renderPreviewLine = () => {
if (!drawingLine) return null;
const start = dataToPixel(drawingLine.start.time, drawingLine.start.price);
const end = dataToPixel(drawingLine.current.time, drawingLine.current.price);
if (!start || !end) return null;
return (
<line
x1={start.x}
y1={start.y}
x2={end.x}
y2={end.y}
stroke={selectedColor}
strokeWidth="2"
strokeDasharray="5,5"
opacity="0.6"
/>
);
};
// Render cursor circle during line drawing
const renderCursorCircle = () => {
if (!drawingLine || !mousePosition) return null;
return (
<circle
cx={mousePosition.x}
cy={mousePosition.y}
r={6}
fill="white"
fillOpacity={0.3}
stroke={selectedColor}
strokeWidth={2}
/>
);
};
// 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 (
<svg
ref={svgRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 1111,
pointerEvents: activeTool === 'line' || activeTool === 'delete' ? 'auto' : 'none',
cursor: activeTool === 'line' ? 'crosshair' : activeTool === 'delete' ? 'pointer' : 'default',
}}
onMouseMove={handleMouseMove}
onClick={handleClick}
onMouseUp={handleMouseUp}
>
{renderLines()}
{renderPreviewLine()}
{renderCursorCircle()}
{renderHandles()}
</svg>
);
}