candle-annotator/src/app/page.tsx

305 lines
9.2 KiB
TypeScript

'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import Toolbox, { Tool } from '@/components/Toolbox';
import FileUpload from '@/components/FileUpload';
import CandleChart, { CandleChartHandle } from '@/components/CandleChart';
import ChartSelector from '@/components/ChartSelector';
interface Chart {
id: number;
name: string;
created_at: number;
}
interface Annotation {
id: number;
chart_id: number;
timestamp: number;
label_type: string;
geometry: any;
created_at: 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;
}
export default function Home() {
const [activeTool, setActiveTool] = useState<Tool | 'span'>(null);
const [selectedColor, setSelectedColor] = useState('#3b82f6');
const [selectedLabelId, setSelectedLabelId] = useState<number | null>(null);
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [charts, setCharts] = useState<Chart[]>([]);
const [activeChartId, setActiveChartId] = useState<number | null>(null);
const chartRef = useRef<CandleChartHandle>(null);
// Span annotation state
const [spanAnnotations, setSpanAnnotations] = useState<SpanAnnotation[]>([]);
const [selectedSpanId, setSelectedSpanId] = useState<number | null>(null);
const [spanLabelTypes, setSpanLabelTypes] = useState<SpanLabelType[]>([]);
// Fetch charts list
const fetchCharts = useCallback(async () => {
try {
const response = await fetch('/api/charts');
const data = await response.json();
setCharts(data);
return data as Chart[];
} catch (error) {
console.error('Failed to fetch charts:', error);
return [];
}
}, []);
// Fetch annotations for active chart
const fetchAnnotations = useCallback(async (chartId: number | null) => {
if (!chartId) {
setAnnotations([]);
return;
}
try {
const response = await fetch(`/api/annotations?chartId=${chartId}`);
const data = await response.json();
setAnnotations(data);
} catch (error) {
console.error('Failed to fetch annotations:', error);
}
}, []);
// Fetch span annotations for active chart
const fetchSpanAnnotations = useCallback(async (chartId: number | null) => {
if (!chartId) {
setSpanAnnotations([]);
return;
}
try {
const response = await fetch(`/api/span-annotations?chartId=${chartId}`);
const data = await response.json();
setSpanAnnotations(data);
} catch (error) {
console.error('Failed to fetch span annotations:', error);
}
}, []);
// Fetch span label types
const fetchSpanLabelTypes = useCallback(async () => {
try {
const response = await fetch('/api/span-label-types');
const data = await response.json();
setSpanLabelTypes(data);
} catch (error) {
console.error('Failed to fetch span label types:', error);
}
}, []);
// Fetch charts and span label types on mount, auto-select the most recent chart
useEffect(() => {
const init = async () => {
const chartList = await fetchCharts();
await fetchSpanLabelTypes();
if (chartList.length > 0) {
setActiveChartId(chartList[0].id); // sorted by created_at desc
}
};
init();
}, [fetchCharts, fetchSpanLabelTypes]);
// When activeChartId changes, refetch data
useEffect(() => {
if (activeChartId !== null) {
chartRef.current?.refreshData();
fetchAnnotations(activeChartId);
fetchSpanAnnotations(activeChartId);
setSelectedLabelId(null);
setSelectedSpanId(null);
}
}, [activeChartId, fetchAnnotations, fetchSpanAnnotations]);
const handleExport = () => {
if (activeChartId) {
window.location.href = `/api/export?chartId=${activeChartId}`;
} else {
window.location.href = '/api/export';
}
};
const handleUploadSuccess = (chart: { id: number; name: string }) => {
// Add new chart to list and select it
const newChart: Chart = {
id: chart.id,
name: chart.name,
created_at: Math.floor(Date.now() / 1000),
};
setCharts((prev) => [newChart, ...prev]);
setActiveChartId(chart.id);
};
const handleAnnotationChange = async () => {
await chartRef.current?.refreshData();
await fetchAnnotations(activeChartId);
};
const handleSpanAnnotationsChange = async () => {
await fetchSpanAnnotations(activeChartId);
};
const handleSelectedSpanChange = (spanId: number | null) => {
setSelectedSpanId(spanId);
};
const handleDeleteSpan = async (spanId: number) => {
try {
const response = await fetch(`/api/span-annotations/${spanId}`, {
method: 'DELETE',
});
if (response.ok) {
await fetchSpanAnnotations(activeChartId);
if (selectedSpanId === spanId) {
setSelectedSpanId(null);
}
}
} catch (error) {
console.error('Failed to delete span annotation:', error);
}
};
const handleLabelDelete = async (id: number) => {
setAnnotations(annotations.filter((a) => a.id !== id));
if (selectedLabelId === id) {
setSelectedLabelId(null);
}
};
const handleLabelSelect = (id: number) => {
setSelectedLabelId(id === -1 ? null : id);
};
const handleSelectChart = (chartId: number) => {
setActiveChartId(chartId);
};
const handleDeleteChart = async (chartId: number) => {
try {
const response = await fetch(`/api/charts/${chartId}`, { method: 'DELETE' });
if (response.ok) {
const remaining = charts.filter((c) => c.id !== chartId);
setCharts(remaining);
if (activeChartId === chartId) {
setActiveChartId(remaining.length > 0 ? remaining[0].id : null);
}
}
} catch (error) {
console.error('Failed to delete chart:', error);
}
};
// Keyboard handler for Delete/Backspace key
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedLabelId !== null) {
try {
const response = await fetch(`/api/annotations/${selectedLabelId}`, {
method: 'DELETE',
});
if (response.ok) {
setSelectedLabelId(null);
chartRef.current?.refreshData();
}
} catch (error) {
console.error('Failed to delete label:', error);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedLabelId]);
return (
<div className="flex h-screen bg-background">
{/* Sidebar */}
<aside className="w-72 flex-shrink-0 flex flex-col border-r border-border bg-card">
<div className="p-6 border-b border-border">
<h1 className="text-2xl font-semibold text-foreground">Candle Annotator</h1>
<p className="text-sm text-muted-foreground mt-1">Chart annotation tool</p>
<a
href="/annotation-types"
className="mt-3 inline-block text-sm text-muted-foreground hover:text-foreground"
>
Manage Annotation Types
</a>
</div>
<div className="p-6 pb-3">
<ChartSelector
charts={charts}
activeChartId={activeChartId}
onSelectChart={handleSelectChart}
onDeleteChart={handleDeleteChart}
/>
</div>
<div className="px-6 pb-3">
<FileUpload onUploadSuccess={handleUploadSuccess} />
</div>
<div className="px-6 pb-6">
<Toolbox
activeTool={activeTool}
onToolChange={setActiveTool}
onExport={handleExport}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
annotations={annotations}
selectedLabelId={selectedLabelId}
onLabelSelect={handleLabelSelect}
onLabelDelete={handleLabelDelete}
activeChartId={activeChartId}
spanAnnotations={spanAnnotations}
spanLabelTypes={spanLabelTypes}
selectedSpanId={selectedSpanId}
onSelectSpan={handleSelectedSpanChange}
onDeleteSpan={handleDeleteSpan}
/>
</div>
</aside>
{/* Main chart area */}
<main className="flex-1 relative bg-background">
<CandleChart
ref={chartRef}
activeTool={activeTool}
onAnnotationChange={handleAnnotationChange}
selectedColor={selectedColor}
selectedLabelId={selectedLabelId}
onLabelSelect={handleLabelSelect}
activeChartId={activeChartId}
spanAnnotations={spanAnnotations}
spanLabelTypes={spanLabelTypes}
selectedSpanId={selectedSpanId}
onSpanAnnotationsChange={handleSpanAnnotationsChange}
onSelectedSpanChange={handleSelectedSpanChange}
/>
</main>
</div>
);
}