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
38
CLAUDE.md
38
CLAUDE.md
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue