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. 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -127,6 +139,12 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||||
const [isEmpty, setIsEmpty] = useState(true);
|
const [isEmpty, setIsEmpty] = useState(true);
|
||||||
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(() => {
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue