| import type { AngleSummary, AngleLabel } from "@/types"; |
| import { CheckCircle2, XCircle } from "lucide-react"; |
|
|
| interface CarAngleDiagramProps { |
| summary: AngleSummary[]; |
| } |
|
|
| type PositionKey = |
| | "Front View" |
| | "Rear View" |
| | "Driver Side View" |
| | "Passenger Side View" |
| | "Front Driver Side Corner View" |
| | "Front Passenger Side Corner View" |
| | "Rear Driver Side Corner View" |
| | "Rear Passenger Side Corner View"; |
|
|
| const POSITIONS: Record<PositionKey, { x: number; y: number; labelX: number; labelY: number; anchor: "start" | "middle" | "end" }> = { |
| "Front View": { x: 180, y: 52, labelX: 180, labelY: 18, anchor: "middle" }, |
| "Front Driver Side Corner View": { x: 72, y: 100, labelX: 20, labelY: 90, anchor: "middle" }, |
| "Front Passenger Side Corner View": { x: 288, y: 100, labelX: 340, labelY: 90, anchor: "middle" }, |
| "Driver Side View": { x: 42, y: 192, labelX: -18, labelY: 192, anchor: "end" }, |
| "Passenger Side View": { x: 318, y: 192, labelX: 378, labelY: 192, anchor: "start" }, |
| "Rear Driver Side Corner View": { x: 72, y: 284, labelX: 20, labelY: 296, anchor: "middle" }, |
| "Rear Passenger Side Corner View": { x: 288, y: 284, labelX: 340, labelY: 296, anchor: "middle" }, |
| "Rear View": { x: 180, y: 332, labelX: 180, labelY: 368, anchor: "middle" }, |
| }; |
|
|
| const SHORT: Record<PositionKey, string[]> = { |
| "Front View": ["Front"], |
| "Rear View": ["Rear"], |
| "Driver Side View": ["Driver", "Side"], |
| "Passenger Side View": ["Passenger", "Side"], |
| "Front Driver Side Corner View": ["Front-Left"], |
| "Front Passenger Side Corner View": ["Front-Right"], |
| "Rear Driver Side Corner View": ["Rear-Left"], |
| "Rear Passenger Side Corner View": ["Rear-Right"], |
| }; |
|
|
| const summaryByAngle = (summary: AngleSummary[], angle: AngleLabel) => |
| summary.find((s) => s.angle === angle); |
|
|
| export function CarAngleDiagram({ summary }: CarAngleDiagramProps) { |
| const detected = summary.filter((s) => s.detected).length; |
| const total = summary.length; |
|
|
| return ( |
| <div className="flex flex-col items-center gap-6"> |
| {/* Score ring */} |
| <div className="flex items-center gap-4"> |
| <div className="relative w-20 h-20"> |
| <svg viewBox="0 0 80 80" className="w-full h-full -rotate-90"> |
| <circle cx="40" cy="40" r="32" fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="8" /> |
| <circle |
| cx="40" cy="40" r="32" |
| fill="none" |
| stroke={detected === total ? "#10b981" : detected > 0 ? "#f59e0b" : "#ef4444"} |
| strokeWidth="8" |
| strokeDasharray={`${(detected / total) * 201.06} 201.06`} |
| strokeLinecap="round" |
| className="transition-all duration-700" |
| /> |
| </svg> |
| <div className="absolute inset-0 flex flex-col items-center justify-center"> |
| <span className="text-xl font-bold text-white leading-none">{detected}</span> |
| <span className="text-xs text-slate-400">/{total}</span> |
| </div> |
| </div> |
| <div> |
| <p className="text-white font-semibold text-lg"> |
| {detected === total |
| ? "All angles captured!" |
| : detected === 0 |
| ? "No angles detected" |
| : `${detected} of ${total} angles found`} |
| </p> |
| <p className="text-slate-400 text-sm"> |
| {total - detected === 0 |
| ? "Perfect coverage" |
| : `${total - detected} angle${total - detected !== 1 ? "s" : ""} missing`} |
| </p> |
| </div> |
| </div> |
| |
| {/* SVG Car Diagram */} |
| <div className="w-full max-w-[420px]"> |
| <svg |
| viewBox="-55 0 470 390" |
| className="w-full h-auto" |
| xmlns="http://www.w3.org/2000/svg" |
| > |
| {/* Dashed guide lines from car center to angle points */} |
| {(Object.keys(POSITIONS) as PositionKey[]).map((angle) => { |
| const pos = POSITIONS[angle]; |
| const s = summaryByAngle(summary, angle as AngleLabel); |
| const color = s?.detected ? "#10b981" : "#ef4444"; |
| return ( |
| <line |
| key={angle} |
| x1="180" y1="192" |
| x2={pos.x} y2={pos.y} |
| stroke={color} |
| strokeWidth="1" |
| strokeDasharray="4 4" |
| opacity="0.35" |
| /> |
| ); |
| })} |
| |
| {/* Car top-down SVG silhouette */} |
| <g transform="translate(100, 90)"> |
| {/* Car body */} |
| <rect x="0" y="8" width="160" height="200" rx="20" ry="20" fill="rgba(30,37,48,0.9)" stroke="rgba(255,255,255,0.12)" strokeWidth="1.5" /> |
| {/* Hood */} |
| <rect x="16" y="0" width="128" height="30" rx="12" ry="12" fill="rgba(37,45,58,0.95)" stroke="rgba(255,255,255,0.1)" strokeWidth="1" /> |
| {/* Trunk */} |
| <rect x="16" y="186" width="128" height="30" rx="12" ry="12" fill="rgba(37,45,58,0.95)" stroke="rgba(255,255,255,0.1)" strokeWidth="1" /> |
| {/* Windshield */} |
| <rect x="22" y="30" width="116" height="40" rx="4" fill="rgba(26,107,255,0.12)" stroke="rgba(26,107,255,0.3)" strokeWidth="1" /> |
| {/* Rear windshield */} |
| <rect x="22" y="146" width="116" height="40" rx="4" fill="rgba(26,107,255,0.08)" stroke="rgba(26,107,255,0.2)" strokeWidth="1" /> |
| {/* Doors center line */} |
| <line x1="80" y1="70" x2="80" y2="146" stroke="rgba(255,255,255,0.06)" strokeWidth="1" /> |
| {/* Side mirrors */} |
| <rect x="-14" y="38" width="14" height="20" rx="3" fill="rgba(37,45,58,0.95)" stroke="rgba(255,255,255,0.1)" strokeWidth="1" /> |
| <rect x="160" y="38" width="14" height="20" rx="3" fill="rgba(37,45,58,0.95)" stroke="rgba(255,255,255,0.1)" strokeWidth="1" /> |
| {/* Wheels */} |
| <ellipse cx="8" cy="58" rx="12" ry="16" fill="rgba(15,18,24,0.9)" stroke="rgba(255,255,255,0.15)" strokeWidth="1.5" /> |
| <ellipse cx="152" cy="58" rx="12" ry="16" fill="rgba(15,18,24,0.9)" stroke="rgba(255,255,255,0.15)" strokeWidth="1.5" /> |
| <ellipse cx="8" cy="158" rx="12" ry="16" fill="rgba(15,18,24,0.9)" stroke="rgba(255,255,255,0.15)" strokeWidth="1.5" /> |
| <ellipse cx="152" cy="158" rx="12" ry="16" fill="rgba(15,18,24,0.9)" stroke="rgba(255,255,255,0.15)" strokeWidth="1.5" /> |
| {/* Headlights */} |
| <rect x="18" y="4" width="34" height="10" rx="3" fill="rgba(255,240,150,0.25)" stroke="rgba(255,220,50,0.5)" strokeWidth="1" /> |
| <rect x="108" y="4" width="34" height="10" rx="3" fill="rgba(255,240,150,0.25)" stroke="rgba(255,220,50,0.5)" strokeWidth="1" /> |
| {/* Tail lights */} |
| <rect x="18" y="202" width="34" height="10" rx="3" fill="rgba(255,80,80,0.2)" stroke="rgba(255,80,80,0.5)" strokeWidth="1" /> |
| <rect x="108" y="202" width="34" height="10" rx="3" fill="rgba(255,80,80,0.2)" stroke="rgba(255,80,80,0.5)" strokeWidth="1" /> |
| {/* Direction arrow (front indicator) */} |
| <polygon points="75,0 80,-6 85,0" fill="rgba(26,107,255,0.6)" /> |
| </g> |
| |
| {/* Angle indicator dots + labels */} |
| {(Object.keys(POSITIONS) as PositionKey[]).map((angle) => { |
| const pos = POSITIONS[angle]; |
| const s = summaryByAngle(summary, angle as AngleLabel); |
| const detected = s?.detected ?? false; |
| const color = detected ? "#10b981" : "#ef4444"; |
| const bgColor = detected ? "rgba(16,185,129,0.15)" : "rgba(239,68,68,0.15)"; |
| const lines = SHORT[angle]; |
| |
| return ( |
| <g key={angle}> |
| {/* Dot circle */} |
| <circle cx={pos.x} cy={pos.y} r="16" fill={bgColor} stroke={color} strokeWidth="1.5" /> |
| {detected ? ( |
| <CheckCircle2 |
| x={pos.x - 9} y={pos.y - 9} |
| width="18" height="18" |
| color="#10b981" |
| strokeWidth={2} |
| /> |
| ) : ( |
| <XCircle |
| x={pos.x - 9} y={pos.y - 9} |
| width="18" height="18" |
| color="#ef4444" |
| strokeWidth={2} |
| /> |
| )} |
| |
| {/* Label */} |
| {lines.map((line, i) => ( |
| <text |
| key={i} |
| x={pos.labelX} |
| y={pos.labelY + i * 13} |
| textAnchor={pos.anchor} |
| fill={color} |
| fontSize="10" |
| fontWeight="600" |
| fontFamily="Inter, sans-serif" |
| > |
| {line} |
| </text> |
| ))} |
| </g> |
| ); |
| })} |
| </svg> |
| </div> |
| |
| {/* Legend */} |
| <div className="flex items-center gap-6 text-sm"> |
| <div className="flex items-center gap-2"> |
| <CheckCircle2 className="w-4 h-4 text-emerald-400" /> |
| <span className="text-slate-300">Detected</span> |
| </div> |
| <div className="flex items-center gap-2"> |
| <XCircle className="w-4 h-4 text-red-400" /> |
| <span className="text-slate-300">Missing</span> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|