feat: wire up drawing interaction for line and rectangle tools (tasks 3.1-3.5)

- Add drawing state and preview primitive refs
- Implement two-click drawing flow via subscribeClick
- Add crosshair move handler to update preview in real-time
- Add Escape key handler to cancel drawing
- Manage TrendLine primitives for saved line annotations
This commit is contained in:
Marko Djordjevic 2026-02-16 11:55:06 +01:00
parent bec0aeb6ca
commit 82fd5ce819
2 changed files with 228 additions and 7 deletions

View file

@ -17,11 +17,11 @@
## 3. Wire Up Drawing Interaction in CandleChart ## 3. Wire Up Drawing Interaction in CandleChart
- [ ] 3.1 Add state for drawing mode: `drawingState: {tool: 'line'|'rectangle', firstPoint: {time, price}} | null` and `previewPrimitive: TrendLine | RectangleDrawingPrimitive | null` - [x] 3.1 Add state for drawing mode: `drawingState: {tool: 'line'|'rectangle', firstPoint: {time, price}} | null` and `previewPrimitive: TrendLine | RectangleDrawingPrimitive | null`
- [ ] 3.2 Subscribe to `chart.subscribeClick()` — on first click when line/rectangle tool active, record first point and attach preview primitive; on second click, save annotation via API, detach preview, attach permanent primitive - [x] 3.2 Subscribe to `chart.subscribeClick()` — on first click when line/rectangle tool active, record first point and attach preview primitive; on second click, save annotation via API, detach preview, attach permanent primitive
- [ ] 3.3 Subscribe to `chart.subscribeCrosshairMove()` — when drawing in progress, update preview primitive's endpoint via `updatePoints()` or equivalent - [x] 3.3 Subscribe to `chart.subscribeCrosshairMove()` — when drawing in progress, update preview primitive's endpoint via `updatePoints()` or equivalent
- [ ] 3.4 Handle Escape key — detach preview primitive and clear drawing state - [x] 3.4 Handle Escape key — detach preview primitive and clear drawing state
- [ ] 3.5 Manage TrendLine primitives for saved line annotations — create/attach on load, detach on delete, update on edit - [x] 3.5 Manage TrendLine primitives for saved line annotations — create/attach on load, detach on delete, update on edit
## 4. Wire Up Rectangle Primitives in CandleChart ## 4. Wire Up Rectangle Primitives in CandleChart

View file

@ -5,8 +5,20 @@ import { createChart, IChartApi, ISeriesApi, CandlestickData, HistogramData, Tim
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import SvgOverlay from './SvgOverlay'; import SvgOverlay from './SvgOverlay';
import SpanAnnotationManager from './SpanAnnotationManager'; import SpanAnnotationManager from './SpanAnnotationManager';
import { TrendLine } from '@/plugins/trend-line';
import { RectangleDrawingPrimitive, RectanglePoint } from '@/plugins/rectangle-drawing';
import type { PerCandlePrediction, PredictionSpan, ModelInfoResponse, PredictionSummary } from '@/types/predictions'; import type { PerCandlePrediction, PredictionSpan, ModelInfoResponse, PredictionSummary } from '@/types/predictions';
interface DrawingState {
tool: 'line' | 'rectangle';
firstPoint: { time: Time; price: number };
}
interface Point {
time: Time;
price: number;
}
interface Candle { interface Candle {
time: number; time: number;
open: number; open: number;
@ -128,6 +140,12 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
const { theme, resolvedTheme } = useTheme(); const { theme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
// Drawing state for line and rectangle tools
const [drawingState, setDrawingState] = useState<DrawingState | null>(null);
const previewPrimitiveRef = useRef<TrendLine | RectangleDrawingPrimitive | null>(null);
const linePrimitivesRef = useRef<Map<number, TrendLine>>(new Map());
const rectanglePrimitivesRef = useRef<Map<number, RectangleDrawingPrimitive>>(new Map());
// Track mounted state to avoid hydration mismatch // Track mounted state to avoid hydration mismatch
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
@ -533,7 +551,7 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
if (!chartRef.current || !seriesRef.current) return; if (!chartRef.current || !seriesRef.current) return;
const handleClick = async (param: any) => { const handleClick = async (param: any) => {
if (!param.point || !activeTool) return; if (!param.point) return;
const timeCoordinate = param.point.x; const timeCoordinate = param.point.x;
const priceCoordinate = param.point.y; const priceCoordinate = param.point.y;
@ -543,6 +561,96 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
if (time === null || price === null) return; if (time === null || price === null) return;
// Handle line and rectangle drawing
if (activeTool === 'line' || activeTool === 'rectangle') {
if (!drawingState) {
// First click: record first point and show preview
const firstPoint = { time, price };
setDrawingState({ tool: activeTool, firstPoint });
// Create preview primitive
if (seriesRef.current && chartRef.current) {
const previewPoint = { time, price };
if (activeTool === 'line') {
const preview = new TrendLine(
chartRef.current,
seriesRef.current,
firstPoint as Point,
previewPoint as Point,
{
lineColor: selectedColor,
width: 2,
showLabels: false,
isPreview: true,
}
);
seriesRef.current.attachPrimitive(preview);
previewPrimitiveRef.current = preview;
} else {
const preview = new RectangleDrawingPrimitive({
p1: firstPoint as RectanglePoint,
p2: previewPoint as RectanglePoint,
color: selectedColor,
isPreview: true,
});
seriesRef.current.attachPrimitive(preview);
previewPrimitiveRef.current = preview;
}
}
} else {
// Second click: save annotation and create permanent primitive
const secondPoint = { time, price };
try {
const timestamp = typeof drawingState.firstPoint.time === 'string'
? Date.parse(drawingState.firstPoint.time) / 1000
: (drawingState.firstPoint.time as number);
const response = await fetch('/api/annotations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
timestamp,
label_type: activeTool,
chart_id: activeChartId,
color: selectedColor,
geometry: {
startTime: typeof drawingState.firstPoint.time === 'string'
? Date.parse(drawingState.firstPoint.time) / 1000
: drawingState.firstPoint.time,
startPrice: drawingState.firstPoint.price,
endTime: typeof secondPoint.time === 'string'
? Date.parse(secondPoint.time) / 1000
: secondPoint.time,
endPrice: secondPoint.price,
},
}),
});
if (response.ok) {
// Detach preview
if (previewPrimitiveRef.current && seriesRef.current) {
seriesRef.current.detachPrimitive(previewPrimitiveRef.current);
previewPrimitiveRef.current = null;
}
// Clear drawing state
setDrawingState(null);
// Refresh annotations to load the permanent primitive
await fetchAnnotations();
onAnnotationChange?.();
}
} catch (error) {
console.error('Failed to create annotation:', error);
}
}
return; // Don't process other tools
}
if (!activeTool) return;
// Check if activeTool is a marker type // Check if activeTool is a marker type
const markerType = annotationTypes.find( const markerType = annotationTypes.find(
(t) => t.category === 'marker' && t.name === activeTool (t) => t.category === 'marker' && t.name === activeTool
@ -675,7 +783,120 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
return () => { return () => {
chartRef.current?.unsubscribeClick(handleClick); chartRef.current?.unsubscribeClick(handleClick);
}; };
}, [activeTool, candles, annotations, annotationTypes, onAnnotationChange, predictionVisible, predictionSpans, predictionSummary, onPredictionClick, onPredictionDismiss]); }, [activeTool, candles, annotations, annotationTypes, onAnnotationChange, predictionVisible, predictionSpans, predictionSummary, onPredictionClick, onPredictionDismiss, drawingState, selectedColor]);
// Handle crosshair move to update preview during drawing
useEffect(() => {
if (!chartRef.current || !seriesRef.current || !drawingState || !previewPrimitiveRef.current) return;
const handleCrosshairMove = (param: any) => {
if (!param.point) return;
const time = chartRef.current!.timeScale().coordinateToTime(param.point.x);
const price = seriesRef.current!.coordinateToPrice(param.point.y);
if (time === null || price === null) return;
const currentPoint = { time, price };
// Update preview primitive endpoint
if (previewPrimitiveRef.current) {
if (previewPrimitiveRef.current instanceof TrendLine) {
previewPrimitiveRef.current.updatePoints(
drawingState.firstPoint as Point,
currentPoint as Point
);
} else if (previewPrimitiveRef.current instanceof RectangleDrawingPrimitive) {
previewPrimitiveRef.current.updatePoints(
drawingState.firstPoint as RectanglePoint,
currentPoint as RectanglePoint
);
}
seriesRef.current!.applyOptions({});
}
};
chartRef.current.subscribeCrosshairMove(handleCrosshairMove);
return () => {
chartRef.current?.unsubscribeCrosshairMove(handleCrosshairMove);
};
}, [drawingState]);
// Handle Escape key to cancel drawing
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && drawingState && previewPrimitiveRef.current) {
// Detach preview primitive
if (seriesRef.current) {
seriesRef.current.detachPrimitive(previewPrimitiveRef.current);
previewPrimitiveRef.current = null;
}
// Clear drawing state
setDrawingState(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [drawingState]);
// Manage TrendLine primitives for saved line annotations
useEffect(() => {
if (!chartRef.current || !seriesRef.current || annotations.length === 0) return;
// Filter line annotations
const lineAnnotations = annotations.filter((a) => a.label_type === 'line' && a.geometry);
// Detach old primitives that no longer exist
const currentIds = new Set(lineAnnotations.map((a) => a.id));
linePrimitivesRef.current.forEach((primitive, id) => {
if (!currentIds.has(id)) {
seriesRef.current!.detachPrimitive(primitive);
linePrimitivesRef.current.delete(id);
}
});
// Create/update primitives for line annotations
lineAnnotations.forEach((annotation) => {
const geometry = annotation.geometry!;
if (!geometry.startTime || !geometry.endTime) return;
// Check if primitive already exists
if (!linePrimitivesRef.current.has(annotation.id)) {
// Create new TrendLine primitive
const p1: Point = {
time: geometry.startTime as Time,
price: geometry.startPrice!,
};
const p2: Point = {
time: geometry.endTime as Time,
price: geometry.endPrice!,
};
const color = annotationTypes.find((t) => t.name === 'line')?.color || selectedColor;
const trendLine = new TrendLine(
chartRef.current!,
seriesRef.current!,
p1,
p2,
{
lineColor: color,
width: 2,
showLabels: false,
annotationId: String(annotation.id),
}
);
seriesRef.current!.attachPrimitive(trendLine);
linePrimitivesRef.current.set(annotation.id, trendLine);
}
});
}, [annotations, annotationTypes, selectedColor]);
// Fetch data on mount // Fetch data on mount
useEffect(() => { useEffect(() => {