feat: implement section 9 - span annotation sidebar list
This commit is contained in:
parent
2f05136f20
commit
4089aab77c
4 changed files with 265 additions and 7 deletions
|
|
@ -63,13 +63,13 @@
|
||||||
|
|
||||||
## 9. Span Annotation Sidebar List
|
## 9. Span Annotation Sidebar List
|
||||||
|
|
||||||
- [ ] 9.1 Create `SpanAnnotationList.tsx` component: scrollable list of span annotations sorted by start_time desc, showing time range, label with colored badge, delete button
|
- [x] 9.1 Create `SpanAnnotationList.tsx` component: scrollable list of span annotations sorted by start_time desc, showing time range, label with colored badge, delete button
|
||||||
- [ ] 9.2 Add count summary grouped by label type (e.g., "Bull Flag: 3 | Bear Flag: 2")
|
- [x] 9.2 Add count summary grouped by label type (e.g., "Bull Flag: 3 | Bear Flag: 2")
|
||||||
- [ ] 9.3 Implement click-to-select in list: set selectedSpanId, highlight chart rectangle, scroll chart to center on span's time range
|
- [x] 9.3 Implement click-to-select in list: set selectedSpanId, highlight chart rectangle, scroll chart to center on span's time range
|
||||||
- [ ] 9.4 Highlight selected span entry in list (background/border)
|
- [x] 9.4 Highlight selected span entry in list (background/border)
|
||||||
- [ ] 9.5 Wire delete button per list item: DELETE API call, remove primitive, update state
|
- [x] 9.5 Wire delete button per list item: DELETE API call, remove primitive, update state
|
||||||
- [ ] 9.6 Show empty state message when no span annotations exist
|
- [x] 9.6 Show empty state message when no span annotations exist
|
||||||
- [ ] 9.7 Clear and reload span list when active chart changes
|
- [x] 9.7 Clear and reload span list when active chart changes
|
||||||
|
|
||||||
## 10. Hotkey Label Assignment
|
## 10. Hotkey Label Assignment
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,22 @@ export default function Home() {
|
||||||
setSelectedSpanId(spanId);
|
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) => {
|
const handleLabelDelete = async (id: number) => {
|
||||||
setAnnotations(annotations.filter((a) => a.id !== id));
|
setAnnotations(annotations.filter((a) => a.id !== id));
|
||||||
if (selectedLabelId === id) {
|
if (selectedLabelId === id) {
|
||||||
|
|
@ -258,6 +274,11 @@ export default function Home() {
|
||||||
onLabelSelect={handleLabelSelect}
|
onLabelSelect={handleLabelSelect}
|
||||||
onLabelDelete={handleLabelDelete}
|
onLabelDelete={handleLabelDelete}
|
||||||
activeChartId={activeChartId}
|
activeChartId={activeChartId}
|
||||||
|
spanAnnotations={spanAnnotations}
|
||||||
|
spanLabelTypes={spanLabelTypes}
|
||||||
|
selectedSpanId={selectedSpanId}
|
||||||
|
onSelectSpan={handleSelectedSpanChange}
|
||||||
|
onDeleteSpan={handleDeleteSpan}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
190
src/components/SpanAnnotationList.tsx
Normal file
190
src/components/SpanAnnotationList.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Trash2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
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 SpanAnnotationListProps {
|
||||||
|
spanAnnotations: SpanAnnotation[];
|
||||||
|
spanLabelTypes: SpanLabelType[];
|
||||||
|
selectedSpanId: number | null;
|
||||||
|
onSelectSpan: (spanId: number) => void;
|
||||||
|
onDeleteSpan: (spanId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpanAnnotationList({
|
||||||
|
spanAnnotations,
|
||||||
|
spanLabelTypes,
|
||||||
|
selectedSpanId,
|
||||||
|
onSelectSpan,
|
||||||
|
onDeleteSpan,
|
||||||
|
}: SpanAnnotationListProps) {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
|
// Format timestamp to readable date/time
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
return new Date(timestamp * 1000).toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get display name and color for label
|
||||||
|
const getLabelInfo = (labelName: string) => {
|
||||||
|
const labelType = spanLabelTypes.find((t) => t.name === labelName);
|
||||||
|
return {
|
||||||
|
displayName: labelType?.display_name || labelName.replace(/_/g, ' ').toUpperCase(),
|
||||||
|
color: labelType?.color || '#2196F3',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate count per label type
|
||||||
|
const labelCounts = spanAnnotations.reduce((acc, span) => {
|
||||||
|
acc[span.label] = (acc[span.label] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
// Sort by start_time descending (most recent first)
|
||||||
|
const sortedSpans = [...spanAnnotations].sort((a, b) => b.start_time - a.start_time);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{/* Header with collapse toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="w-full flex items-center justify-between px-2 py-2 hover:bg-secondary/50 rounded"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
Span Annotations ({spanAnnotations.length})
|
||||||
|
</span>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<>
|
||||||
|
{/* Count summary */}
|
||||||
|
{spanAnnotations.length > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground px-2 mb-2">
|
||||||
|
{Object.entries(labelCounts).map(([labelName, count], idx) => {
|
||||||
|
const { displayName } = getLabelInfo(labelName);
|
||||||
|
return (
|
||||||
|
<span key={labelName}>
|
||||||
|
{idx > 0 && ' | '}
|
||||||
|
{displayName}: {count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{spanAnnotations.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground px-2 py-4 text-center">
|
||||||
|
No span annotations yet. Use the Span tool to select candle ranges.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Span list */
|
||||||
|
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto">
|
||||||
|
{sortedSpans.map((span) => {
|
||||||
|
const { displayName, color } = getLabelInfo(span.label);
|
||||||
|
const isSelected = span.id === selectedSpanId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={span.id}
|
||||||
|
className={`
|
||||||
|
flex items-start gap-2 p-2 rounded cursor-pointer
|
||||||
|
transition-colors
|
||||||
|
${
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary/10 border border-primary'
|
||||||
|
: 'hover:bg-secondary/50 border border-transparent'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={() => onSelectSpan(span.id)}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Time range */}
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{formatTime(span.start_time)} → {formatTime(span.end_time)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label badge */}
|
||||||
|
<div className="mt-1">
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 rounded text-xs font-medium text-white"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional info */}
|
||||||
|
{(span.confidence || span.outcome) && (
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground flex gap-2">
|
||||||
|
{span.confidence && <span>Confidence: {span.confidence}</span>}
|
||||||
|
{span.outcome && <span>Outcome: {span.outcome}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes preview */}
|
||||||
|
{span.notes && (
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground truncate">
|
||||||
|
{span.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0 hover:bg-destructive/20 hover:text-destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteSpan(span.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Trash2, Download, ChevronDo
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||||
|
import SpanAnnotationList from '@/components/SpanAnnotationList';
|
||||||
|
|
||||||
export type Tool = string | 'delete' | null;
|
export type Tool = string | 'delete' | null;
|
||||||
|
|
||||||
|
|
@ -26,6 +27,31 @@ interface Annotation {
|
||||||
created_at: number;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
interface ToolboxProps {
|
interface ToolboxProps {
|
||||||
activeTool: Tool;
|
activeTool: Tool;
|
||||||
onToolChange: (tool: Tool) => void;
|
onToolChange: (tool: Tool) => void;
|
||||||
|
|
@ -37,6 +63,11 @@ interface ToolboxProps {
|
||||||
onLabelSelect?: (id: number) => void;
|
onLabelSelect?: (id: number) => void;
|
||||||
onLabelDelete?: (id: number) => void;
|
onLabelDelete?: (id: number) => void;
|
||||||
activeChartId?: number | null;
|
activeChartId?: number | null;
|
||||||
|
spanAnnotations?: SpanAnnotation[];
|
||||||
|
spanLabelTypes?: SpanLabelType[];
|
||||||
|
selectedSpanId?: number | null;
|
||||||
|
onSelectSpan?: (spanId: number) => void;
|
||||||
|
onDeleteSpan?: (spanId: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Toolbox({
|
export default function Toolbox({
|
||||||
|
|
@ -50,6 +81,11 @@ export default function Toolbox({
|
||||||
onLabelSelect,
|
onLabelSelect,
|
||||||
onLabelDelete,
|
onLabelDelete,
|
||||||
activeChartId,
|
activeChartId,
|
||||||
|
spanAnnotations = [],
|
||||||
|
spanLabelTypes = [],
|
||||||
|
selectedSpanId = null,
|
||||||
|
onSelectSpan,
|
||||||
|
onDeleteSpan,
|
||||||
}: ToolboxProps) {
|
}: ToolboxProps) {
|
||||||
const [labelsExpanded, setLabelsExpanded] = useState(true);
|
const [labelsExpanded, setLabelsExpanded] = useState(true);
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
|
|
@ -332,6 +368,17 @@ export default function Toolbox({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Span Annotations List */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<SpanAnnotationList
|
||||||
|
spanAnnotations={spanAnnotations}
|
||||||
|
spanLabelTypes={spanLabelTypes}
|
||||||
|
selectedSpanId={selectedSpanId}
|
||||||
|
onSelectSpan={onSelectSpan || (() => {})}
|
||||||
|
onDeleteSpan={onDeleteSpan || (() => {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Export button */}
|
{/* Export button */}
|
||||||
<div className="mt-auto pt-4 border-t border-border">
|
<div className="mt-auto pt-4 border-t border-border">
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue