candle-annotator/src/components/PredictionPanel.tsx
Marko Djordjevic 9fe833bcfc code-review-fix task 11.4: add aria-pressed to span drawing and prediction toggle buttons
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 20:33:41 +01:00

245 lines
9.7 KiB
TypeScript

'use client';
import { useState } from 'react';
import { ChevronDown } from 'lucide-react';
import type { PredictionState, ModelInfoResponse, PredictionSummary } from '@/types/predictions';
import ModelSelector from '@/components/ModelSelector';
interface PredictionPanelProps {
predictionState: PredictionState;
onToggleVisibility: () => void;
onFetchPredictions: () => void;
onFetchBatchPredictions: () => void;
onConfidenceChange: (threshold: number) => void;
onToggleLabelSelection: (label: string) => void;
predictionSummary: PredictionSummary | null;
isModelOnline: boolean;
showOnlyDisagreements?: boolean;
onToggleShowOnlyDisagreements?: () => void;
onModelLoaded?: () => void;
onModelLoadError?: (msg: string) => void;
onModelLoadStart?: () => void;
}
export default function PredictionPanel({
predictionState,
onToggleVisibility,
onFetchPredictions,
onFetchBatchPredictions,
onConfidenceChange,
onToggleLabelSelection,
predictionSummary,
isModelOnline,
showOnlyDisagreements = false,
onToggleShowOnlyDisagreements,
onModelLoaded,
onModelLoadError,
onModelLoadStart,
}: PredictionPanelProps) {
const [expanded, setExpanded] = useState(true);
const [modelLoadError, setModelLoadError] = useState<string | null>(null);
const {
visible,
isLoading,
error,
modelInfo,
confidenceThreshold,
selectedLabels,
spans,
} = predictionState;
return (
<div>
{/* Collapsible header */}
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-between py-1"
>
<div className="flex items-center gap-1.5">
<div
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: isModelOnline ? '#22c55e' : '#ef4444' }}
/>
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Predictions
</span>
</div>
<ChevronDown className={`w-3 h-3 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`} />
</button>
{expanded && (
<div className="mt-1 space-y-2">
{!isModelOnline ? (
<p className="text-[10px] text-muted-foreground">
Prediction service unavailable.
</p>
) : (
<>
{/* Model Info */}
{modelInfo && (
<div className="p-2 bg-secondary/30 rounded text-[10px] space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Model:</span>
<span className="font-mono text-foreground">{modelInfo.model_name}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Version:</span>
<span className="font-mono text-foreground">{modelInfo.model_version || 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Type:</span>
<span className="text-foreground">{modelInfo.model_type}</span>
</div>
</div>
)}
{/* Model Selector */}
{onModelLoaded && (
<div>
<ModelSelector
currentModelVersion={modelInfo?.model_version}
onModelLoaded={() => {
setModelLoadError(null);
onModelLoaded();
}}
onLoadError={(msg) => {
setModelLoadError(msg);
onModelLoadError?.(msg);
}}
onLoadStart={() => onModelLoadStart?.()}
disabled={isLoading}
/>
{modelLoadError && (
<p className="text-[10px] text-destructive mt-0.5">{modelLoadError}</p>
)}
</div>
)}
{/* Action Buttons */}
<div className="flex gap-1">
<button
onClick={onFetchPredictions}
disabled={isLoading}
className="flex-1 px-2 py-1.5 text-[10px] bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity disabled:opacity-50"
>
{isLoading ? 'Loading...' : 'Run on Visible'}
</button>
<button
onClick={onFetchBatchPredictions}
disabled={isLoading}
className="flex-1 px-2 py-1.5 text-[10px] bg-secondary text-secondary-foreground rounded hover:opacity-90 transition-opacity disabled:opacity-50"
>
Predict All
</button>
</div>
{/* Error */}
{error && (
<p className="text-[10px] text-destructive">{error}</p>
)}
{/* Confidence Slider */}
<div>
<div className="flex justify-between items-center mb-1">
<label className="text-[10px] text-muted-foreground">Confidence</label>
<span className="text-[10px] font-mono text-foreground">{(confidenceThreshold * 100).toFixed(0)}%</span>
</div>
<input
type="range"
min="0"
max="100"
value={confidenceThreshold * 100}
onChange={(e) => onConfidenceChange(Number(e.target.value) / 100)}
className="w-full h-1 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
{/* Show on chart toggle */}
<div className="flex items-center justify-between">
<label className="text-[10px] text-muted-foreground">Show on chart</label>
<button
onClick={onToggleVisibility}
aria-pressed={visible}
className={`px-2 py-0.5 text-[10px] rounded transition-colors ${
visible
? 'bg-primary/20 text-primary'
: 'bg-secondary text-muted-foreground'
}`}
>
{visible ? 'On' : 'Off'}
</button>
</div>
{/* Label Filter */}
{modelInfo && modelInfo.labels.length > 0 && (
<div>
<label className="text-[10px] text-muted-foreground mb-1 block">Labels</label>
<div className="space-y-0.5 max-h-24 overflow-y-auto scrollbar-thin">
{modelInfo.labels.map((label) => {
const metrics = modelInfo.per_class_metrics.find((m) => m.label === label);
return (
<label
key={label}
className="flex items-center gap-1.5 p-0.5 rounded hover:bg-secondary/50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedLabels.has(label)}
onChange={() => onToggleLabelSelection(label)}
className="w-3 h-3"
/>
<span className="text-[10px] text-foreground flex-1">{label}</span>
{metrics && (
<span className="text-[10px] text-muted-foreground font-mono">
F1:{(metrics.f1_score * 100).toFixed(0)}%
</span>
)}
</label>
);
})}
</div>
</div>
)}
{/* Disagreement Filter */}
{predictionSummary && predictionSummary.disagreements.length > 0 && onToggleShowOnlyDisagreements && (
<label className="flex items-center gap-1.5 p-1 bg-secondary/30 rounded cursor-pointer">
<input
type="checkbox"
checked={showOnlyDisagreements}
onChange={onToggleShowOnlyDisagreements}
className="w-3 h-3"
/>
<span className="text-[10px] text-foreground">Show only disagreements</span>
</label>
)}
{/* Summary */}
{visible && spans.length > 0 && predictionSummary ? (
<div className="p-2 bg-secondary/30 rounded text-[10px] space-y-0.5">
<div className="flex justify-between">
<span className="text-muted-foreground">Predictions:</span>
<span className="font-mono text-foreground">{predictionSummary.total_predictions}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Agreements:</span>
<span className="text-green-600 font-mono">{predictionSummary.agreements}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Disagreements:</span>
<span className="text-orange-500 font-mono">{predictionSummary.disagreements.length}</span>
</div>
</div>
) : (
<p className="text-[10px] text-muted-foreground text-center py-2">
No predictions loaded.
</p>
)}
</>
)}
</div>
)}
</div>
);
}