diff --git a/CLAUDE.md b/CLAUDE.md index 3fb36f6..46c587a 100644 --- a/CLAUDE.md +++ b/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 # 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 # 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 # Compact HTTP responses (70%) rtk wget # 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 # 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. diff --git a/openspec/changes/line-rectangle-annotations/tasks.md b/openspec/changes/line-rectangle-annotations/tasks.md index 41c4373..8e2fd97 100644 --- a/openspec/changes/line-rectangle-annotations/tasks.md +++ b/openspec/changes/line-rectangle-annotations/tasks.md @@ -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 diff --git a/src/components/CandleChart.tsx b/src/components/CandleChart.tsx index 01b27a9..4470f30 100644 --- a/src/components/CandleChart.tsx +++ b/src/components/CandleChart.tsx @@ -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( const linePrimitivesRef = useRef>(new Map()); const rectanglePrimitivesRef = useRef>(new Map()); const [selectedRectangleId, setSelectedRectangleId] = useState(null); + + // Line selection and dragging state + const [selectedLineId, setSelectedLineId] = useState(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( 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( 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( } } - // 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( } } + // 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( }; }, [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( 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( } 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( return () => { chartRef.current?.unsubscribeCrosshairMove(handleCrosshairMove); }; - }, [drawingState]); + }, [drawingState, dragState]); // Handle Escape key to cancel drawing useEffect(() => { @@ -1017,14 +1161,6 @@ const CandleChart = forwardRef( return (
- | 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(null); - const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); - const [annotations, setAnnotations] = useState([]); - const [drawingLine, setDrawingLine] = useState<{ start: Point; current: Point } | null>(null); - const [mousePosition, setMousePosition] = useState<{ x: number; y: number } | null>(null); - const [selectedLineId, setSelectedLineId] = useState(null); - const [dragState, setDragState] = useState(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) => { - 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) => { - 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 ( - - ); - }); - }; - - // 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 ( - - ); - }; - - // Render cursor circle during line drawing - const renderCursorCircle = () => { - if (!drawingLine || !mousePosition) return null; - - return ( - - ); - }; - - // 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 ( - <> - handleHandleMouseDown(e, line.id, 'start')} - /> - handleHandleMouseDown(e, line.id, 'end')} - /> - - ); - }; - - if (!chart || !series) return null; - - return ( - - {renderLines()} - {renderPreviewLine()} - {renderCursorCircle()} - {renderHandles()} - - ); -}