feat: implement label management with sidebar, hacker theme, and Docker support
- Add label selection on chart with visual highlight (size 2x, color change) - Implement keyboard delete handler (Delete/Backspace keys) - Add comprehensive label management sidebar with: - Collapsible label annotations section - Search by timestamp - Filter by type (Break Up, Break Down, All) - Individual delete buttons - Count display - Click to select/highlight on chart - Transform UI with hacker theme: - Matrix green (#00ff41) on dark background (#0a0e0a) - Monospace font (JetBrains Mono) - Glow effects on button hover and active states - Custom scrollbar styling - Terminal-inspired aesthetic - Add Docker deployment: - Multi-stage Dockerfile with standalone output - docker-compose.yml with volume persistence - Non-root user (nextjs) for security - Health check endpoint integration - Tailwind and CSS enhancements: - Custom colors (matrix, matrixDim, neonRed, etc.) - Glow box shadows and animations - Selection and scrollbar styling
This commit is contained in:
parent
74b84073a9
commit
a1fa86fe55
14 changed files with 509 additions and 42 deletions
|
|
@ -29,6 +29,8 @@ interface CandleChartProps {
|
|||
activeTool: string | null;
|
||||
onAnnotationChange?: () => void;
|
||||
selectedColor: string;
|
||||
selectedLabelId?: number | null;
|
||||
onLabelSelect?: (id: number) => void;
|
||||
}
|
||||
|
||||
export interface CandleChartHandle {
|
||||
|
|
@ -36,7 +38,7 @@ export interface CandleChartHandle {
|
|||
}
|
||||
|
||||
const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
||||
({ activeTool, onAnnotationChange, selectedColor }, ref) => {
|
||||
({ activeTool, onAnnotationChange, selectedColor, selectedLabelId, onLabelSelect }, ref) => {
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
|
||||
|
|
@ -159,17 +161,23 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|||
);
|
||||
|
||||
const markers = markerAnnotations
|
||||
.map((annotation) => ({
|
||||
time: annotation.timestamp as Time,
|
||||
position: annotation.label_type === 'break_up' ? ('belowBar' as const) : ('aboveBar' as const),
|
||||
color: annotation.label_type === 'break_up' ? '#22c55e' : '#ef4444',
|
||||
shape: annotation.label_type === 'break_up' ? ('arrowUp' as const) : ('arrowDown' as const),
|
||||
text: annotation.label_type === 'break_up' ? 'Break Up' : 'Break Down',
|
||||
}))
|
||||
.map((annotation) => {
|
||||
const isSelected = annotation.id === selectedLabelId;
|
||||
return {
|
||||
time: annotation.timestamp as Time,
|
||||
position: annotation.label_type === 'break_up' ? ('belowBar' as const) : ('aboveBar' as const),
|
||||
color: isSelected
|
||||
? (annotation.label_type === 'break_up' ? '#00ff41' : '#ff0040')
|
||||
: (annotation.label_type === 'break_up' ? '#22c55e' : '#ef4444'),
|
||||
shape: annotation.label_type === 'break_up' ? ('arrowUp' as const) : ('arrowDown' as const),
|
||||
text: annotation.label_type === 'break_up' ? 'Break Up' : 'Break Down',
|
||||
size: isSelected ? 2 : 1,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (a.time as number) - (b.time as number));
|
||||
|
||||
seriesRef.current.setMarkers(markers);
|
||||
}, [annotations]);
|
||||
}, [annotations, selectedLabelId]);
|
||||
|
||||
// Handle chart clicks for annotation
|
||||
useEffect(() => {
|
||||
|
|
@ -244,6 +252,23 @@ const CandleChart = forwardRef<CandleChartHandle, CandleChartProps>(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Select/deselect label markers by clicking them
|
||||
if (!activeTool || activeTool === 'break_up' || activeTool === 'break_down') {
|
||||
const timestamp = typeof time === 'string' ? Date.parse(time) / 1000 : (time as number);
|
||||
|
||||
// Find annotation at this timestamp (within tolerance)
|
||||
const tolerance = 60; // 60 seconds tolerance
|
||||
const annotation = annotations.find(
|
||||
(a) =>
|
||||
(a.label_type === 'break_up' || a.label_type === 'break_down') &&
|
||||
Math.abs(a.timestamp - timestamp) < tolerance
|
||||
);
|
||||
|
||||
if (annotation) {
|
||||
onLabelSelect?.(annotation.id === selectedLabelId ? -1 : annotation.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chartRef.current.subscribeClick(handleClick);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,47 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Trash2, Download } from 'lucide-react';
|
||||
import { ArrowUpCircle, ArrowDownCircle, TrendingUp, Trash2, Download, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export type Tool = 'break_up' | 'break_down' | 'line' | 'delete' | null;
|
||||
|
||||
interface Annotation {
|
||||
id: number;
|
||||
timestamp: number;
|
||||
label_type: string;
|
||||
geometry: any;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface ToolboxProps {
|
||||
activeTool: Tool;
|
||||
onToolChange: (tool: Tool) => void;
|
||||
onExport: () => void;
|
||||
selectedColor: string;
|
||||
onColorChange: (color: string) => void;
|
||||
annotations?: Annotation[];
|
||||
selectedLabelId?: number | null;
|
||||
onLabelSelect?: (id: number) => void;
|
||||
onLabelDelete?: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function Toolbox({ activeTool, onToolChange, onExport, selectedColor, onColorChange }: ToolboxProps) {
|
||||
export default function Toolbox({
|
||||
activeTool,
|
||||
onToolChange,
|
||||
onExport,
|
||||
selectedColor,
|
||||
onColorChange,
|
||||
annotations = [],
|
||||
selectedLabelId = null,
|
||||
onLabelSelect,
|
||||
onLabelDelete,
|
||||
}: ToolboxProps) {
|
||||
const [labelsExpanded, setLabelsExpanded] = useState(true);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterType, setFilterType] = useState<'all' | 'break_up' | 'break_down'>('all');
|
||||
|
||||
const handleToolClick = (tool: Tool) => {
|
||||
// Toggle: if clicking the active tool, deactivate it
|
||||
if (activeTool === tool) {
|
||||
|
|
@ -24,14 +51,48 @@ export default function Toolbox({ activeTool, onToolChange, onExport, selectedCo
|
|||
}
|
||||
};
|
||||
|
||||
// Filter and sort annotations
|
||||
const labelAnnotations = annotations
|
||||
.filter((a) => a.label_type === 'break_up' || a.label_type === 'break_down')
|
||||
.filter((a) => (filterType === 'all' ? true : a.label_type === filterType))
|
||||
.sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
// Apply search filter
|
||||
const filteredAnnotations = labelAnnotations.filter((a) => {
|
||||
const formattedTime = new Date(a.timestamp * 1000).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return formattedTime.toLowerCase().includes(searchText.toLowerCase());
|
||||
});
|
||||
|
||||
const breakUpCount = labelAnnotations.filter((a) => a.label_type === 'break_up').length;
|
||||
const breakDownCount = labelAnnotations.filter((a) => a.label_type === 'break_down').length;
|
||||
|
||||
const handleLabelDelete = async (id: number, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const response = await fetch(`/api/annotations/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (response.ok) {
|
||||
onLabelDelete?.(id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete label:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-card border-r border-border p-4 flex flex-col gap-4">
|
||||
<div className="flex-1 flex flex-col gap-4 overflow-y-auto">
|
||||
<h2 className="text-lg font-semibold text-foreground">Annotation Tools</h2>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant={activeTool === 'break_up' ? 'default' : 'outline'}
|
||||
className="justify-start gap-2"
|
||||
className={`justify-start gap-2 ${activeTool === 'break_up' ? 'animate-glow-pulse shadow-glow' : 'hover:shadow-glow'}`}
|
||||
onClick={() => handleToolClick('break_up')}
|
||||
>
|
||||
<ArrowUpCircle className="w-5 h-5" />
|
||||
|
|
@ -40,7 +101,7 @@ export default function Toolbox({ activeTool, onToolChange, onExport, selectedCo
|
|||
|
||||
<Button
|
||||
variant={activeTool === 'break_down' ? 'default' : 'outline'}
|
||||
className="justify-start gap-2"
|
||||
className={`justify-start gap-2 ${activeTool === 'break_down' ? 'animate-glow-pulse shadow-glow' : 'hover:shadow-glow'}`}
|
||||
onClick={() => handleToolClick('break_down')}
|
||||
>
|
||||
<ArrowDownCircle className="w-5 h-5" />
|
||||
|
|
@ -49,14 +110,14 @@ export default function Toolbox({ activeTool, onToolChange, onExport, selectedCo
|
|||
|
||||
<Button
|
||||
variant={activeTool === 'line' ? 'default' : 'outline'}
|
||||
className="justify-start gap-2"
|
||||
className={`justify-start gap-2 ${activeTool === 'line' ? 'animate-glow-pulse shadow-glow' : 'hover:shadow-glow'}`}
|
||||
onClick={() => handleToolClick('line')}
|
||||
>
|
||||
<TrendingUp className="w-5 h-5" />
|
||||
Draw Line
|
||||
</Button>
|
||||
|
||||
{/* Color picker - shown when line tool is available */}
|
||||
{/* Color picker */}
|
||||
<div className="flex gap-1 px-1">
|
||||
{[
|
||||
{ color: '#ef4444', name: 'Red' },
|
||||
|
|
@ -90,7 +151,7 @@ export default function Toolbox({ activeTool, onToolChange, onExport, selectedCo
|
|||
|
||||
<Button
|
||||
variant={activeTool === 'delete' ? 'destructive' : 'outline'}
|
||||
className="justify-start gap-2"
|
||||
className={`justify-start gap-2 ${activeTool === 'delete' ? 'animate-glow-pulse shadow-[0_0_15px_#ff0040]' : 'hover:shadow-[0_0_15px_#ff0040]'}`}
|
||||
onClick={() => handleToolClick('delete')}
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
|
|
@ -98,6 +159,110 @@ export default function Toolbox({ activeTool, onToolChange, onExport, selectedCo
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Labels Section */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<button
|
||||
onClick={() => setLabelsExpanded(!labelsExpanded)}
|
||||
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">
|
||||
Label Annotations ({labelAnnotations.length})
|
||||
</span>
|
||||
{labelsExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{labelsExpanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{/* Count display */}
|
||||
<div className="text-xs text-muted-foreground px-2">
|
||||
Break Up: {breakUpCount} | Break Down: {breakDownCount}
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<Input
|
||||
placeholder="Search by timestamp..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
|
||||
{/* Filter dropdown */}
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value as 'all' | 'break_up' | 'break_down')}
|
||||
className="w-full h-8 px-2 text-sm rounded border border-border bg-card"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="break_up">Break Up Only</option>
|
||||
<option value="break_down">Break Down Only</option>
|
||||
</select>
|
||||
|
||||
{/* Labels list */}
|
||||
<div className="max-h-96 overflow-y-auto space-y-2 p-2 border border-border rounded bg-card/50">
|
||||
{filteredAnnotations.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground text-center py-4">
|
||||
{labelAnnotations.length === 0
|
||||
? 'No labels yet. Click Break Up or Break Down tools to add labels.'
|
||||
: 'No matching labels found.'}
|
||||
</div>
|
||||
) : (
|
||||
filteredAnnotations.map((annotation) => {
|
||||
const formattedTime = new Date(annotation.timestamp * 1000).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const isSelected = annotation.id === selectedLabelId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={annotation.id}
|
||||
onClick={() => onLabelSelect?.(annotation.id)}
|
||||
className={`p-2 rounded border text-xs cursor-pointer transition-colors ${
|
||||
isSelected
|
||||
? 'border-matrix bg-matrix/10'
|
||||
: 'border-border hover:bg-secondary/30'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-mono text-foreground">{formattedTime}</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-semibold ${
|
||||
annotation.label_type === 'break_up'
|
||||
? 'bg-green-900/50 text-matrix'
|
||||
: 'bg-red-900/50 text-neonRed'
|
||||
}`}
|
||||
>
|
||||
{annotation.label_type === 'break_up' ? 'BREAK UP' : 'BREAK DOWN'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => handleLabelDelete(annotation.id, e)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export button */}
|
||||
<div className="mt-auto pt-4 border-t border-border">
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue