Files
road-monitoring-ui/src/components/video-player-section.tsx

695 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useEffect, useRef, useState, useCallback } from "react"
import dynamic from "next/dynamic"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Target, AlertTriangle, Film, Activity, Gauge, Monitor, SignpostBig, Map as MapIcon } from "lucide-react"
// Dynamically import MapModal with SSR disabled (Leaflet requires window object)
const MapModal = dynamic(() => import("@/components/map-modal"), { ssr: false })
import { DetectionData, DetectionType } from "@/types"
import { cn } from "@/lib/utils"
import { projectService } from "@/services/api"
type VideoPlayerSectionProps = {
data: DetectionData
videoId: string
videoFile: File | null // null when loading from server (results page)
detectionType: DetectionType
projectId?: string // Optional project ID for fetching detailed summary
}
type DetectionLog = {
frame: number
detections: Array<{
id: number
type?: string // For signboards
bbox: { x1: number; y1: number; x2: number; y2: number }
confidence: number
latitude?: number
longitude?: number
}>
videoTime: string // Video timestamp (MM:SS)
}
type ChainageSummaryData = {
project: {
id: string
name: string
corridor_name: string | null
state: string | null
}
packages: {
[packageName: string]: {
package_id: string
region: string | null
chainages: {
[chainageName: string]: {
chainage_id: string
chainage: string | null
detection_count: number
detections: Array<{
id: number
video_id: string
type: string
class: string
confidence: number
latitude: number
longitude: number
frame_number: number
timestamp_ms: number
bounding_box: {
x1: number
y1: number
x2: number
y2: number
}
}>
}
}
}
}
}
function DetailedSummarySection({
projectId,
videoId,
show,
detectionType
}: {
projectId: string
videoId: string
show: boolean
detectionType: DetectionType
}) {
const [summaryData, setSummaryData] = useState<ChainageSummaryData | null>(null)
const [loading, setLoading] = useState(false)
const [showMap, setShowMap] = useState(false)
useEffect(() => {
if (!show || !videoId || !projectId) return
const fetchSummary = async () => {
setLoading(true)
try {
const data = await projectService.getProjectSummaryByVideo(projectId, videoId);
setSummaryData(data)
} catch (err) {
console.error("Failed to fetch summary:", err)
} finally {
setLoading(false)
}
}
fetchSummary()
}, [show, videoId, projectId])
if (!show || loading || !summaryData) return null
const isCombined = detectionType === "pot-sign-detection"
const isPothole = detectionType === "pothole-detection" || isCombined
// Flatten all detections for the scrollable list
const allDetections: Array<{
detection: any
chainageName: string
packageName: string
}> = []
Object.entries(summaryData.packages || {}).forEach(([packageName, packageData]) => {
Object.entries(packageData?.chainages || {}).forEach(([chainageName, chainageData]) => {
chainageData?.detections?.forEach(detection => {
allDetections.push({ detection, chainageName, packageName })
})
})
})
return (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
<Card className="flex flex-col lg:col-span-2 overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-secondary">
<MapIcon className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base font-bold">
Chainages
</CardTitle>
<CardDescription className="text-xs">
{isCombined ? "Potholes & Signboards" : isPothole ? "Potholes" : "Signboards"} detected
</CardDescription>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowMap(true)}
className="gap-2"
>
<MapIcon className="h-4 w-4" />
Map
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 p-0 overflow-hidden">
<ScrollArea className="h-[300px] p-4">
<div className="space-y-4">
{/* Project Info */}
<div className="pb-3 border-b">
<div className="flex flex-col mb-2">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Project</span>
<span className="text-sm font-bold">
{summaryData.project.name}
</span>
</div>
{summaryData.project.corridor_name && (
<div className="flex flex-col">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Corridor</span>
<span className="text-xs font-semibold text-muted-foreground">
{summaryData.project.corridor_name}
</span>
</div>
)}
</div>
{/* Packages and Chainages */}
{Object.entries(summaryData.packages).map(([packageName, packageData]) => (
<div key={packageData.package_id} className="space-y-2">
<div className="text-xs font-bold text-muted-foreground truncate">{packageName}</div>
<div className="pl-2 space-y-2 border-l-2 border-muted">
{Object.entries(packageData.chainages).map(([chainageName, chainageData]) => (
<div key={chainageData.chainage_id} className="text-xs p-3 rounded-md bg-muted/50 flex items-center justify-between gap-4">
<div className="font-semibold truncate">{chainageName}</div>
<div className="text-[10px] bg-background px-2 py-0.5 rounded border font-bold">
{chainageData.detection_count}
</div>
</div>
))}
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
<Card className="flex flex-col lg:col-span-3 overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-secondary">
<Activity className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base font-bold">
All Detections
</CardTitle>
<CardDescription className="text-xs">
Complete list with GPS coordinates
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 p-0 overflow-hidden">
<ScrollArea className="h-[300px] p-4">
<div className="space-y-2">
{allDetections.map(({ detection, chainageName }, idx) => (
<div
key={`${detection.id}-${idx}`}
className="text-xs p-3 bg-muted/30 border rounded-md hover:bg-muted/50 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<span className="font-bold">
{detection.type === "pothole" ? "Pothole" : (detection.class || detection.type || "").replace(/_/g, " ")} #{detection.id}
</span>
<Badge variant="outline" className="text-[10px] font-mono">
Frame {detection.frame_number}
</Badge>
</div>
<div className="grid grid-cols-2 gap-2 text-[10px] text-muted-foreground">
<div className="truncate">Chainage: <span className="text-foreground font-medium">{chainageName}</span></div>
<div>Confidence: <span className="text-foreground font-medium">{(detection.confidence * 100).toFixed(1)}%</span></div>
<div className="col-span-2 font-mono bg-muted/50 p-1 rounded border">
GPS: {detection.latitude}, {detection.longitude}
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Map Modal */}
<MapModal
open={showMap}
onClose={() => setShowMap(false)}
detections={allDetections.map(({ detection }) => detection)}
detectionType={detectionType}
/>
</div>
)
}
function SummarySection({ data, show, detectionType }: { data: DetectionData; show: boolean; detectionType: DetectionType }) {
if (!show) return null
const isCombined = detectionType === "pot-sign-detection"
const isPothole = detectionType === "pothole-detection" || isCombined
const stats = [
{ label: "Total Damage", value: data.summary.total_road_damage || 0, icon: Target, color: "text-purple-500", bgColor: "bg-purple-500/10" },
{ label: "Defected Signs", value: data.summary.unique_defected_sign_board || 0, icon: SignpostBig, color: "text-blue-500", bgColor: "bg-blue-500/10" },
{ label: "Unique Potholes", value: data.summary.unique_pothole || 0, icon: AlertTriangle, color: "text-red-500", bgColor: "bg-red-500/10" },
{ label: "Road Cracks", value: data.summary.unique_road_crack || 0, icon: AlertTriangle, color: "text-orange-500", bgColor: "bg-orange-500/10" },
{ label: "Markings", value: data.summary.unique_damaged_road_marking || 0, icon: Activity, color: "text-indigo-500", bgColor: "bg-indigo-500/10" },
{ label: "Good Signs", value: data.summary.unique_good_sign_board || 0, icon: SignpostBig, color: "text-emerald-500", bgColor: "bg-emerald-500/10" },
{ label: "Rate", value: `${(data.summary.detection_rate || 0).toFixed(1)}%`, icon: Activity, color: "text-green-500", bgColor: "bg-green-500/10" },
{ label: "Video FPS", value: (data.video_info.fps || 0).toFixed(1), icon: Gauge, color: "text-orange-500", bgColor: "bg-orange-500/10" },
{ label: "Resolution", value: `${data.video_info.width}×${data.video_info.height}`, icon: Monitor, color: "text-blue-500", bgColor: "bg-blue-500/10" },
{ label: "Total Frames", value: data.summary.total_frames || data.video_info.total_frames, icon: Film, color: "text-purple-500", bgColor: "bg-purple-500/10" },
]
return (
<Card className="overflow-hidden">
<CardHeader className="pb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-secondary">
<Activity className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base font-bold">Quick Stats</CardTitle>
<CardDescription className="text-xs">Detection analysis overview</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{stats.map((stat) => {
const Icon = stat.icon
return (
<div key={stat.label} className="p-4 rounded-lg bg-muted/30 border border-muted flex flex-col items-center text-center">
<div className={cn("p-2 rounded-md mb-2", stat.bgColor)}>
<Icon className={cn("h-5 w-5", stat.color)} />
</div>
<div className={cn("text-xl font-bold", stat.color)}>{stat.value}</div>
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground mt-1">{stat.label}</div>
</div>
)
})}
</div>
</CardContent>
</Card>
)
}
export default function VideoPlayerSection({ data, videoId, videoFile, detectionType, projectId }: VideoPlayerSectionProps) {
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [currentFrame, setCurrentFrame] = useState(0)
const [detectionsCount, setDetectionsCount] = useState(0)
const [logs, setLogs] = useState<DetectionLog[]>([])
const [showSummary, setShowSummary] = useState(false)
const [hasPlayedOnce, setHasPlayedOnce] = useState(false)
const [videoError, setVideoError] = useState<string | null>(null)
const [lastDetectedLat, setLastDetectedLat] = useState<number | null>(null)
const [lastDetectedLng, setLastDetectedLng] = useState<number | null>(null)
const [currentFrameCounts, setCurrentFrameCounts] = useState<Record<string, number>>({
defected_sign_board: 0,
pothole: 0,
road_crack: 0,
damaged_road_marking: 0,
good_sign_board: 0
})
const frameDetectionMap = useRef<Map<number, any[]>>(new Map())
const lastProcessedFrame = useRef(-1)
const loggedFrames = useRef<Set<number>>(new Set())
const cumulativeCountsMap = useRef<Map<number, Record<string, number>>>(new Map())
const sortedFrameIndices = useRef<number[]>([])
const detectedFrameSkip = useRef<number>(3)
const MAX_LOGS = 50
const isCombined = detectionType === "pot-sign-detection"
const isPothole = detectionType === "pothole-detection" || isCombined
const isSignboard = detectionType === "sign-board-detection" || isCombined
const gpsMap = useRef<Map<number, { lat: number; lng: number }>>(new Map())
useEffect(() => {
const map = new Map()
const addItemsToMap = (list?: any[]) => {
if (!list || !Array.isArray(list)) return
list.forEach(item => {
const id = (item as any).pothole_id ?? (item as any).signboard_id ?? (item as any).detection_id
if (item.lat !== undefined && item.lng !== undefined && id !== undefined) {
map.set(id, { lat: item.lat, lng: item.lng })
}
})
}
addItemsToMap(data.pothole_list)
addItemsToMap(data.signboard_list)
addItemsToMap(data.defected_sign_board_list)
addItemsToMap(data.road_crack_list)
addItemsToMap(data.damaged_road_marking_list)
addItemsToMap(data.good_sign_board_list)
gpsMap.current = map
}, [data])
const formatVideoTime = useCallback((frame: number, fps: number): string => {
const seconds = frame / fps
const minutes = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${minutes}:${secs.toString().padStart(2, '0')}`
}, [])
const seekToFrame = useCallback((frame: number) => {
const video = videoRef.current
if (!video) return
video.currentTime = frame / data.video_info.fps
}, [data.video_info.fps])
useEffect(() => {
const map = new Map()
if (data.frames && Array.isArray(data.frames)) {
data.frames.forEach((frameData) => {
const frameId = frameData.frame_id
const flatDetections = (frameData as any).detections
if (flatDetections && Array.isArray(flatDetections)) {
map.set(frameId, flatDetections.map((d: any) => ({
...d,
_detType: d.type === 'pothole' ? 'pothole' : 'signboard',
pothole_id: d.type === 'pothole' ? d.detection_id : undefined,
signboard_id: d.type !== 'pothole' ? d.detection_id : undefined,
})))
} else {
let detections: any[] = []
if (isPothole && frameData.potholes) detections = [...detections, ...frameData.potholes.map((p: any) => ({ ...p, _detType: 'pothole' }))]
if (isSignboard && frameData.signboards) detections = [...detections, ...frameData.signboards.map((s: any) => ({ ...s, _detType: 'signboard' }))]
if (detections.length > 0) map.set(frameId, detections)
}
})
}
frameDetectionMap.current = map
}, [data, isPothole, isSignboard])
useEffect(() => {
const map = new Map<number, Record<string, number>>()
let lastCounts = { defected_sign_board: 0, pothole: 0, road_crack: 0, damaged_road_marking: 0, good_sign_board: 0 }
if (data.frames && Array.isArray(data.frames)) {
const sortedFrames = [...data.frames].sort((a, b) => (a.frame_id || 0) - (b.frame_id || 0))
const indices: number[] = []
sortedFrames.forEach((frameData) => {
const frameId = frameData.frame_id
indices.push(frameId)
const detections = (frameData as any).detections
if (detections && detections.length > 0) {
const frameCounts = detections[0].count || { defected_sign_board: 0, pothole: 0, road_crack: 0, damaged_road_marking: 0, good_sign_board: 0 }
lastCounts = {
defected_sign_board: Math.max(lastCounts.defected_sign_board, frameCounts.defected_sign_board || 0),
pothole: Math.max(lastCounts.pothole, frameCounts.pothole || 0),
road_crack: Math.max(lastCounts.road_crack, frameCounts.road_crack || 0),
damaged_road_marking: Math.max(lastCounts.damaged_road_marking, frameCounts.damaged_road_marking || 0),
good_sign_board: Math.max(lastCounts.good_sign_board, frameCounts.good_sign_board || 0),
}
}
map.set(frameId, { ...lastCounts })
})
sortedFrameIndices.current = indices
}
cumulativeCountsMap.current = map
}, [data.frames])
const getStickyCounts = useCallback((frame: number) => {
const indices = sortedFrameIndices.current
let targetIndex = -1
let low = 0, high = indices.length - 1
while (low <= high) {
let mid = Math.floor((low + high) / 2)
if (indices[mid] <= frame) {
targetIndex = indices[mid]
low = mid + 1
} else {
high = mid - 1
}
}
return targetIndex !== -1 ? (cumulativeCountsMap.current.get(targetIndex) || { defected_sign_board: 0, pothole: 0, road_crack: 0, damaged_road_marking: 0, good_sign_board: 0 }) : { defected_sign_board: 0, pothole: 0, road_crack: 0, damaged_road_marking: 0, good_sign_board: 0 }
}, [])
const getNearestDetections = useCallback((frame: number): any[] | undefined => {
const exact = frameDetectionMap.current.get(frame)
if (exact) return exact
const indices = sortedFrameIndices.current
let low = 0, high = indices.length - 1, targetIndex = -1
while (low <= high) {
const mid = Math.floor((low + high) / 2)
if (indices[mid] <= frame) { targetIndex = indices[mid]; low = mid + 1 } else { high = mid - 1 }
}
return (targetIndex !== -1 && (frame - targetIndex) <= detectedFrameSkip.current) ? frameDetectionMap.current.get(targetIndex) : undefined
}, [])
const drawBoundingBoxes = useCallback((detections: any[]) => {
const canvas = canvasRef.current
const video = videoRef.current
if (!canvas || !video) return
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (!detections || detections.length === 0) return
const scaleX = canvas.width / data.video_info.width
const scaleY = canvas.height / data.video_info.height
detections.forEach((detection) => {
const bbox = detection.bbox
if (!bbox) return
const x1 = bbox.x1 * scaleX, y1 = bbox.y1 * scaleY, x2 = bbox.x2 * scaleX, y2 = bbox.y2 * scaleY
const type = (detection.type || detection._detType || '').toLowerCase()
const colors: Record<string, string> = { 'pothole': '#ef4444', 'defected_sign_board': '#3b82f6', 'road_crack': '#f59e0b', 'damaged_road_marking': '#6366f1', 'good_sign_board': '#10b981' }
const boxColor = colors[type] || '#3b82f6'
ctx.strokeStyle = boxColor
ctx.lineWidth = 3
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1)
ctx.fillStyle = boxColor + '20'
ctx.fillRect(x1, y1, x2 - x1, y2 - y1)
const id = detection.pothole_id ?? detection.signboard_id ?? detection.detection_id
const label = `${type.replace(/_/g, ' ')} #${id} ${(detection.confidence * 100).toFixed(0)}%`
ctx.font = 'bold 12px sans-serif'
const metrics = ctx.measureText(label)
ctx.fillStyle = boxColor
ctx.fillRect(x1, y1 - 20, metrics.width + 10, 20)
ctx.fillStyle = '#fff'
ctx.fillText(label, x1 + 5, y1 - 6)
})
}, [data.video_info])
const resizeCanvas = useCallback(() => {
if (!videoRef.current || !canvasRef.current) return
const rect = videoRef.current.getBoundingClientRect()
canvasRef.current.width = rect.width
canvasRef.current.height = rect.height
const detections = getNearestDetections(Math.round(videoRef.current.currentTime * data.video_info.fps))
if (detections) drawBoundingBoxes(detections)
}, [data.video_info.fps, drawBoundingBoxes, getNearestDetections])
useEffect(() => {
window.addEventListener('resize', resizeCanvas)
return () => window.removeEventListener('resize', resizeCanvas)
}, [resizeCanvas])
useEffect(() => {
const video = videoRef.current
if (!video) return
const videoUrl = videoFile ? URL.createObjectURL(videoFile) : (videoId ? `${process.env.NEXT_PUBLIC_API_URL}/video/${videoId}` : '')
if (videoUrl) {
video.src = videoUrl
video.onloadeddata = resizeCanvas
video.onerror = () => setVideoError("Failed to load video")
}
return () => { if (videoFile) URL.revokeObjectURL(videoUrl) }
}, [videoFile, videoId, resizeCanvas])
const addDetectionLog = useCallback((frame: number, detections: any[]) => {
if (loggedFrames.current.has(frame)) return
loggedFrames.current.add(frame)
if (detections.length > 0) {
const coords = gpsMap.current.get(detections[0].pothole_id ?? detections[0].signboard_id ?? detections[0].detection_id)
if (coords) { setLastDetectedLat(coords.lat); setLastDetectedLng(coords.lng) }
}
setLogs(prev => [{
frame,
videoTime: formatVideoTime(frame, data.video_info.fps),
detections: detections.map(d => ({
id: d.pothole_id ?? d.signboard_id ?? d.detection_id,
type: d.type || d._detType,
bbox: d.bbox,
confidence: d.confidence,
latitude: gpsMap.current.get(d.pothole_id ?? d.signboard_id ?? d.detection_id)?.lat,
longitude: gpsMap.current.get(d.pothole_id ?? d.signboard_id ?? d.detection_id)?.lng,
}))
}, ...prev].slice(0, MAX_LOGS))
}, [data.video_info.fps, formatVideoTime])
useEffect(() => {
let animId: number
const update = () => {
if (videoRef.current && !videoRef.current.paused) {
const frame = Math.round(videoRef.current.currentTime * data.video_info.fps)
if (frame !== lastProcessedFrame.current) {
lastProcessedFrame.current = frame
setCurrentFrame(frame)
const dets = getNearestDetections(frame)
setDetectionsCount(dets?.length || 0)
drawBoundingBoxes(dets || [])
setCurrentFrameCounts(getStickyCounts(frame))
if (dets?.length) addDetectionLog(frame, dets)
}
}
animId = requestAnimationFrame(update)
}
animId = requestAnimationFrame(update)
return () => cancelAnimationFrame(animId)
}, [data.video_info.fps, addDetectionLog, drawBoundingBoxes, getNearestDetections, getStickyCounts])
useEffect(() => {
const v = videoRef.current
if (!v) return
v.onended = () => { if (!hasPlayedOnce) { setHasPlayedOnce(true); setShowSummary(true) } }
v.onseeked = () => {
const frame = Math.round(v.currentTime * data.video_info.fps)
setCurrentFrame(frame)
const dets = frameDetectionMap.current.get(frame) || []
drawBoundingBoxes(dets)
setCurrentFrameCounts(getStickyCounts(frame))
}
}, [data.video_info.fps, drawBoundingBoxes, getStickyCounts, hasPlayedOnce])
return (
<div className="space-y-6">
<Card className="overflow-hidden">
<CardHeader className="pb-4 border-b">
<div className="flex items-center gap-3">
<div className="p-2 rounded bg-secondary">
<Film className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg font-bold">Detection Playback</CardTitle>
<CardDescription className="text-xs">Real-time object detection analysis</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{videoError && <Badge variant="destructive" className="mb-4">{videoError}</Badge>}
<div ref={containerRef} className="relative bg-black rounded-lg overflow-hidden border shadow-inner" style={{ aspectRatio: `${data.video_info.width}/${data.video_info.height}` }}>
<video ref={videoRef} controls className="w-full h-full" />
<canvas ref={canvasRef} className="absolute top-0 left-0 pointer-events-none w-full h-full" />
</div>
<div className="flex flex-wrap items-center gap-x-8 gap-y-3 py-3 px-6 bg-card border rounded-lg">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<div className="flex items-center gap-1.5 whitespace-nowrap">
<span className="text-[11px] font-bold text-red-500 uppercase tracking-tight">POTHOLE:</span>
<span className="text-sm font-bold text-red-600">{currentFrameCounts.pothole}</span>
</div>
<div className="flex items-center gap-1.5 whitespace-nowrap">
<span className="text-[11px] font-bold text-blue-500 uppercase tracking-tight">DEFECT SIGN BOARD:</span>
<span className="text-sm font-bold text-blue-600">{currentFrameCounts.defected_sign_board}</span>
</div>
<div className="flex items-center gap-1.5 whitespace-nowrap">
<span className="text-[11px] font-bold text-indigo-500 uppercase tracking-tight">DAMAGE ROAD MARK:</span>
<span className="text-sm font-bold text-indigo-600">{currentFrameCounts.damaged_road_marking}</span>
</div>
<div className="flex items-center gap-1.5 whitespace-nowrap">
<span className="text-[11px] font-bold text-orange-500 uppercase tracking-tight">ROADCRACK:</span>
<span className="text-sm font-bold text-orange-600">{currentFrameCounts.road_crack}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-6 border-l pl-6 border-border/80 ml-auto">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">FRAME:</span>
<span className="text-xs font-bold font-mono bg-secondary/50 px-2.5 py-1 rounded border border-border/60">{currentFrame}</span>
</div>
{lastDetectedLat && (
<>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">LAT:</span>
<span className="text-xs font-bold font-mono bg-secondary/50 px-2.5 py-1 rounded border border-border/60">{lastDetectedLat.toFixed(7)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">LNG:</span>
<span className="text-xs font-bold font-mono bg-secondary/50 px-2.5 py-1 rounded border border-border/60">{lastDetectedLng?.toFixed(7)}</span>
</div>
</>
)}
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-bold flex items-center gap-2">
<Activity className="h-4 w-4" />
Detection Logs
</h4>
<Badge variant="secondary" className="text-[10px] font-bold uppercase">Live Logs</Badge>
</div>
<ScrollArea className="h-[430px] rounded-lg border bg-muted/20 p-4">
<div className="space-y-3">
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Film className="h-8 w-8 mb-2 opacity-20" />
<p className="text-xs">Playback to see logs</p>
</div>
) : (
logs.map((log, i) => (
<div key={i} onClick={() => seekToFrame(log.frame)} className="p-4 rounded border bg-card hover:border-primary transition-all cursor-pointer group">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-bold">Frame: {log.frame}</span>
<span className="text-xs text-muted-foreground/80">{log.videoTime}</span>
</div>
<div className="space-y-4">
{log.detections.map((d, j) => (
<div key={j} className="space-y-1">
<div className="text-[13px] font-bold">
{(d.type || "").replace(/ /g, "_")} ID: {d.id} | Confidence: {(d.confidence * 100).toFixed(1)}%
</div>
{d.bbox && (
<div className="text-[12px] text-muted-foreground/80 font-medium">
Coordinates: ({Math.round(d.bbox.x1)}, {Math.round(d.bbox.y1)}) <span className="text-muted-foreground/40"></span> ({Math.round(d.bbox.x2)}, {Math.round(d.bbox.y2)})
</div>
)}
{d.latitude && (
<div className="text-[12px] text-muted-foreground/80 font-medium">
GPS: {d.latitude.toFixed(8)}, {d.longitude?.toFixed(8)}
</div>
)}
</div>
))}
</div>
</div>
))
)}
</div>
</ScrollArea>
</div>
</div>
</CardContent>
</Card>
{showSummary && (
<div className="space-y-6 pt-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
<SummarySection data={data} show={showSummary} detectionType={detectionType} />
<DetailedSummarySection projectId={projectId || ""} videoId={videoId} show={showSummary} detectionType={detectionType} />
</div>
)}
</div>
)
}