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:
parent
bec0aeb6ca
commit
82fd5ce819
2 changed files with 228 additions and 7 deletions
|
|
@ -17,11 +17,11 @@
|
|||
|
||||
## 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`
|
||||
- [ ] 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
|
||||
- [ ] 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.1 Add state for drawing mode: `drawingState: {tool: 'line'|'rectangle', firstPoint: {time, price}} | null` and `previewPrimitive: TrendLine | RectangleDrawingPrimitive | null`
|
||||
- [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
|
||||
- [x] 3.3 Subscribe to `chart.subscribeCrosshairMove()` — when drawing in progress, update preview primitive's endpoint via `updatePoints()` or equivalent
|
||||
- [x] 3.4 Handle Escape key — detach preview primitive and clear drawing state
|
||||
- [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
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,20 @@ import { createChart, IChartApi, ISeriesApi, CandlestickData, HistogramData, Tim
|
|||
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';
|
||||
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 {
|
||||
time: number;
|
||||
open: number;
|
||||
|
|
@ -127,6 +139,12 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const { theme, resolvedTheme } = useTheme();
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
@ -533,7 +551,7 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|||
if (!chartRef.current || !seriesRef.current) return;
|
||||
|
||||
const handleClick = async (param: any) => {
|
||||
if (!param.point || !activeTool) return;
|
||||
if (!param.point) return;
|
||||
|
||||
const timeCoordinate = param.point.x;
|
||||
const priceCoordinate = param.point.y;
|
||||
|
|
@ -543,6 +561,96 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|||
|
||||
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
|
||||
const markerType = annotationTypes.find(
|
||||
(t) => t.category === 'marker' && t.name === activeTool
|
||||
|
|
@ -675,7 +783,120 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|||
return () => {
|
||||
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
|
||||
useEffect(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue