feat: implement sections 6-7 - span selection, preview, and label assignment popover

This commit is contained in:
Marko Djordjevic 2026-02-14 10:10:41 +01:00
parent c9d2cbfc4b
commit 586f02ed69
11 changed files with 647 additions and 22483 deletions

View file

@ -161,6 +161,14 @@ export default function Home() {
await fetchAnnotations(activeChartId);
};
const handleSpanAnnotationsChange = async () => {
await fetchSpanAnnotations(activeChartId);
};
const handleSelectedSpanChange = (spanId: number | null) => {
setSelectedSpanId(spanId);
};
const handleLabelDelete = async (id: number) => {
setAnnotations(annotations.filter((a) => a.id !== id));
if (selectedLabelId === id) {
@ -264,6 +272,11 @@ export default function Home() {
selectedLabelId={selectedLabelId}
onLabelSelect={handleLabelSelect}
activeChartId={activeChartId}
spanAnnotations={spanAnnotations}
spanLabelTypes={spanLabelTypes}
selectedSpanId={selectedSpanId}
onSpanAnnotationsChange={handleSpanAnnotationsChange}
onSelectedSpanChange={handleSelectedSpanChange}
/>
</main>
</div>

View file

@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useImperativeHandle, forwardRef } from 're
import { createChart, IChartApi, ISeriesApi, CandlestickData, Time } from 'lightweight-charts';
import { useTheme } from 'next-themes';
import SvgOverlay from './SvgOverlay';
import SpanAnnotationManager from './SpanAnnotationManager';
interface Candle {
time: number;
@ -36,6 +37,31 @@ type AnnotationType = {
is_active: number;
};
interface SpanAnnotation {
id: number;
chart_id: number;
start_time: number;
end_time: number;
label: string;
confidence: number | null;
outcome: string | null;
notes: string | null;
sub_spans: any;
color: string;
created_at: number;
}
interface SpanLabelType {
id: number;
name: string;
display_name: string;
color: string;
hotkey: string | null;
is_active: number;
sort_order: number;
created_at: number;
}
interface CandleChartProps {
activeTool: string | null;
onAnnotationChange?: () => void;
@ -43,6 +69,11 @@ interface CandleChartProps {
selectedLabelId?: number | null;
onLabelSelect?: (id: number) => void;
activeChartId?: number | null;
spanAnnotations?: SpanAnnotation[];
spanLabelTypes?: SpanLabelType[];
selectedSpanId?: number | null;
onSpanAnnotationsChange?: () => void;
onSelectedSpanChange?: (spanId: number | null) => void;
}
export interface CandleChartHandle {
@ -50,7 +81,19 @@ export interface CandleChartHandle {
}
const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
({ activeTool, onAnnotationChange, selectedColor, selectedLabelId, onLabelSelect, activeChartId }, ref) => {
({
activeTool,
onAnnotationChange,
selectedColor,
selectedLabelId,
onLabelSelect,
activeChartId,
spanAnnotations = [],
spanLabelTypes = [],
selectedSpanId = null,
onSpanAnnotationsChange,
onSelectedSpanChange,
}, ref) => {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
@ -416,6 +459,18 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
selectedColor={selectedColor}
activeChartId={activeChartId}
/>
<SpanAnnotationManager
chart={chartRef.current}
series={seriesRef.current}
activeTool={activeTool}
candles={candles}
spanAnnotations={spanAnnotations}
spanLabelTypes={spanLabelTypes}
selectedSpanId={selectedSpanId}
onSpanAnnotationsChange={onSpanAnnotationsChange || (() => {})}
onSelectedSpanChange={onSelectedSpanChange || (() => {})}
activeChartId={activeChartId}
/>
</div>
);
}

View file

@ -0,0 +1,347 @@
'use client';
import { useEffect, useState, useRef } from 'react';
import { IChartApi, ISeriesApi, Time } from 'lightweight-charts';
import { SpanRectanglePrimitive, SpanData } from './SpanRectanglePrimitive';
import SpanPopover from './SpanPopover';
interface Candle {
time: number;
open: number;
high: number;
low: number;
close: number;
}
interface SpanAnnotation {
id: number;
chart_id: number;
start_time: number;
end_time: number;
label: string;
confidence: number | null;
outcome: string | null;
notes: string | null;
sub_spans: any;
color: string;
created_at: number;
}
interface SpanLabelType {
id: number;
name: string;
display_name: string;
color: string;
hotkey: string | null;
is_active: number;
sort_order: number;
created_at: number;
}
interface SpanAnnotationManagerProps {
chart: IChartApi | null;
series: ISeriesApi<'Candlestick'> | null;
activeTool: string | null;
candles: Candle[];
spanAnnotations: SpanAnnotation[];
spanLabelTypes: SpanLabelType[];
selectedSpanId: number | null;
onSpanAnnotationsChange: () => void;
onSelectedSpanChange: (spanId: number | null) => void;
activeChartId: number | null;
}
type InteractionState = 'idle' | 'first-click-done' | 'popover-open';
export default function SpanAnnotationManager({
chart,
series,
activeTool,
candles,
spanAnnotations,
spanLabelTypes,
selectedSpanId,
onSpanAnnotationsChange,
onSelectedSpanChange,
activeChartId,
}: SpanAnnotationManagerProps) {
const [interactionState, setInteractionState] = useState<InteractionState>('idle');
const [startCandle, setStartCandle] = useState<Candle | null>(null);
const [endCandle, setEndCandle] = useState<Candle | null>(null);
const [previewPrimitive, setPreviewPrimitive] = useState<SpanRectanglePrimitive | null>(null);
const [popoverOpen, setPopoverOpen] = useState(false);
const primitivesRef = useRef<Map<number, SpanRectanglePrimitive>>(new Map());
// Find nearest candle to a timestamp
const findNearestCandle = (timestamp: number): Candle | null => {
if (candles.length === 0) return null;
return candles.reduce((prev, curr) => {
return Math.abs(curr.time - timestamp) < Math.abs(prev.time - timestamp) ? curr : prev;
});
};
// Calculate price range for candles in a span
const calculatePriceRange = (start: Candle, end: Candle): { max_high: number; min_low: number } => {
const startIdx = candles.findIndex((c) => c.time === start.time);
const endIdx = candles.findIndex((c) => c.time === end.time);
if (startIdx === -1 || endIdx === -1) {
return { max_high: Math.max(start.high, end.high), min_low: Math.min(start.low, end.low) };
}
const [minIdx, maxIdx] = [Math.min(startIdx, endIdx), Math.max(startIdx, endIdx)];
const spanCandles = candles.slice(minIdx, maxIdx + 1);
const max_high = Math.max(...spanCandles.map((c) => c.high));
const min_low = Math.min(...spanCandles.map((c) => c.low));
return { max_high, min_low };
};
// Render span annotations as primitives
useEffect(() => {
if (!series || !chart) return;
// Clear existing primitives
primitivesRef.current.forEach((primitive) => {
series.detachPrimitive(primitive);
});
primitivesRef.current.clear();
// Create primitives for each span annotation
spanAnnotations.forEach((span) => {
const { max_high, min_low } = calculatePriceRange(
{ time: span.start_time, open: 0, high: 0, low: 0, close: 0 },
{ time: span.end_time, open: 0, high: 0, low: 0, close: 0 }
);
// If we can't calculate price range from candles, use sensible defaults
const spanData: SpanData = {
id: span.id,
start_time: span.start_time,
end_time: span.end_time,
label: span.label,
color: span.color,
max_high: max_high,
min_low: min_low,
};
const primitive = new SpanRectanglePrimitive({
data: spanData,
isSelected: span.id === selectedSpanId,
});
series.attachPrimitive(primitive);
primitivesRef.current.set(span.id, primitive);
});
// Request chart update
chart.timeScale().fitContent();
}, [spanAnnotations, selectedSpanId, series, chart, candles]);
// Handle clicks on chart for span tool
useEffect(() => {
if (!chart || !series || activeTool !== 'span') {
// Clean up preview if tool changes
if (previewPrimitive && series) {
series.detachPrimitive(previewPrimitive);
setPreviewPrimitive(null);
}
setInteractionState('idle');
setStartCandle(null);
setEndCandle(null);
return;
}
const handleClick = (param: any) => {
if (!param.point) return;
const time = chart.timeScale().coordinateToTime(param.point.x);
if (!time) return;
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
const nearestCandle = findNearestCandle(timestamp);
if (!nearestCandle) return;
if (interactionState === 'idle') {
// First click: set start candle
setStartCandle(nearestCandle);
setInteractionState('first-click-done');
} else if (interactionState === 'first-click-done') {
// Second click: set end candle and open popover
setEndCandle(nearestCandle);
setInteractionState('popover-open');
setPopoverOpen(true);
// Clean up preview primitive
if (previewPrimitive) {
series.detachPrimitive(previewPrimitive);
setPreviewPrimitive(null);
}
}
};
chart.subscribeClick(handleClick);
return () => {
chart.unsubscribeClick(handleClick);
};
}, [chart, series, activeTool, interactionState, candles, previewPrimitive]);
// Handle mouse move for preview
useEffect(() => {
if (!chart || !series || activeTool !== 'span' || interactionState !== 'first-click-done' || !startCandle) {
return;
}
const handleMouseMove = (param: any) => {
if (!param.point) return;
const time = chart.timeScale().coordinateToTime(param.point.x);
if (!time) return;
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
const cursorCandle = findNearestCandle(timestamp);
if (!cursorCandle) return;
// Calculate price range for preview
const { max_high, min_low } = calculatePriceRange(startCandle, cursorCandle);
// Swap if end < start
const [start_time, end_time] =
startCandle.time <= cursorCandle.time
? [startCandle.time, cursorCandle.time]
: [cursorCandle.time, startCandle.time];
const previewData: SpanData = {
id: -1, // Preview ID
start_time,
end_time,
label: 'PREVIEW',
color: '#888888',
max_high,
min_low,
};
// Remove old preview primitive
if (previewPrimitive) {
series.detachPrimitive(previewPrimitive);
}
// Create new preview primitive
const newPreview = new SpanRectanglePrimitive({
data: previewData,
isSelected: false,
});
series.attachPrimitive(newPreview);
setPreviewPrimitive(newPreview);
};
chart.subscribeCrosshairMove(handleMouseMove);
return () => {
chart.unsubscribeCrosshairMove(handleMouseMove);
};
}, [chart, series, activeTool, interactionState, startCandle, candles, previewPrimitive]);
// Handle Escape key to cancel span selection
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && activeTool === 'span') {
if (interactionState === 'first-click-done') {
// Cancel span selection
setInteractionState('idle');
setStartCandle(null);
setEndCandle(null);
// Clean up preview
if (previewPrimitive && series) {
series.detachPrimitive(previewPrimitive);
setPreviewPrimitive(null);
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [activeTool, interactionState, previewPrimitive, series]);
// Handle popover save
const handlePopoverSave = async (data: {
label: string;
confidence: number | null;
outcome: string | null;
notes: string | null;
}) => {
if (!startCandle || !endCandle || !activeChartId) return;
// Swap if end < start
const [start_time, end_time] =
startCandle.time <= endCandle.time
? [startCandle.time, endCandle.time]
: [endCandle.time, startCandle.time];
// Find label type color
const labelType = spanLabelTypes.find((t) => t.name === data.label);
const color = labelType?.color || '#2196F3';
try {
const response = await fetch('/api/span-annotations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chart_id: activeChartId,
start_time,
end_time,
label: data.label,
confidence: data.confidence,
outcome: data.outcome,
notes: data.notes,
color,
}),
});
if (response.ok) {
onSpanAnnotationsChange();
setPopoverOpen(false);
setInteractionState('idle');
setStartCandle(null);
setEndCandle(null);
} else {
console.error('Failed to create span annotation');
}
} catch (error) {
console.error('Error creating span annotation:', error);
}
};
// Handle popover cancel
const handlePopoverCancel = () => {
setPopoverOpen(false);
setInteractionState('idle');
setStartCandle(null);
setEndCandle(null);
};
return (
<SpanPopover
open={popoverOpen}
onOpenChange={setPopoverOpen}
spanLabelTypes={spanLabelTypes}
initialData={
startCandle && endCandle
? {
start_time: Math.min(startCandle.time, endCandle.time),
end_time: Math.max(startCandle.time, endCandle.time),
}
: undefined
}
onSave={handlePopoverSave}
onCancel={handlePopoverCancel}
/>
);
}

View file

@ -0,0 +1,221 @@
'use client';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Slider } from '@/components/ui/slider';
interface SpanLabelType {
id: number;
name: string;
display_name: string;
color: string;
hotkey: string | null;
is_active: number;
sort_order: number;
created_at: number;
}
interface SpanData {
start_time: number;
end_time: number;
label?: string;
confidence?: number | null;
outcome?: string | null;
notes?: string | null;
}
interface SpanPopoverProps {
open: boolean;
onOpenChange: (open: boolean) => void;
spanLabelTypes: SpanLabelType[];
initialData?: SpanData;
onSave: (data: {
label: string;
confidence: number | null;
outcome: string | null;
notes: string | null;
}) => void;
onCancel: () => void;
}
export default function SpanPopover({
open,
onOpenChange,
spanLabelTypes,
initialData,
onSave,
onCancel,
}: SpanPopoverProps) {
const [label, setLabel] = useState<string>(initialData?.label || '');
const [confidence, setConfidence] = useState<number>(initialData?.confidence || 3);
const [outcome, setOutcome] = useState<string>(initialData?.outcome || 'none');
const [notes, setNotes] = useState<string>(initialData?.notes || '');
// Reset form when dialog opens with new initial data
useEffect(() => {
if (open && initialData) {
setLabel(initialData.label || '');
setConfidence(initialData.confidence || 3);
setOutcome(initialData.outcome || 'none');
setNotes(initialData.notes || '');
}
}, [open, initialData]);
const handleSave = () => {
if (!label) return; // Validation: label is required
onSave({
label,
confidence: confidence,
outcome: outcome === 'none' ? null : outcome,
notes: notes.trim() || null,
});
// Reset form
setLabel('');
setConfidence(3);
setOutcome('none');
setNotes('');
};
const handleCancel = () => {
// Reset form
setLabel('');
setConfidence(3);
setOutcome('none');
setNotes('');
onCancel();
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) {
handleCancel();
}
};
useEffect(() => {
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [open]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Span Annotation</DialogTitle>
<DialogDescription>
Assign a pattern label and optional metadata to this span.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Label Selection */}
<div className="grid gap-2">
<Label htmlFor="label">Pattern Label *</Label>
<Select value={label} onValueChange={setLabel}>
<SelectTrigger id="label">
<SelectValue placeholder="Select a pattern label" />
</SelectTrigger>
<SelectContent>
{spanLabelTypes
.filter((type) => type.is_active === 1)
.sort((a, b) => a.sort_order - b.sort_order)
.map((type) => (
<SelectItem key={type.id} value={type.name}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: type.color }}
/>
{type.display_name}
{type.hotkey && (
<span className="text-xs text-muted-foreground ml-auto">
({type.hotkey})
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Confidence Slider */}
<div className="grid gap-2">
<Label htmlFor="confidence">
Confidence: {confidence}
</Label>
<Slider
id="confidence"
min={1}
max={5}
step={1}
value={[confidence]}
onValueChange={(values) => setConfidence(values[0])}
className="w-full"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>1 (Low)</span>
<span>5 (High)</span>
</div>
</div>
{/* Outcome Selection */}
<div className="grid gap-2">
<Label htmlFor="outcome">Outcome</Label>
<Select value={outcome} onValueChange={setOutcome}>
<SelectTrigger id="outcome">
<SelectValue placeholder="Select outcome" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value="win">Win</SelectItem>
<SelectItem value="loss">Loss</SelectItem>
<SelectItem value="breakeven">Break Even</SelectItem>
</SelectContent>
</Select>
</div>
{/* Notes Textarea */}
<div className="grid gap-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
placeholder="Add any notes about this pattern..."
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!label}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}