Spaces:
Sleeping
Sleeping
| import { useEffect, useRef, useState } from 'react'; | |
| import { AnalysisData, DarkPattern } from '../types/analysis'; | |
| import { Eye, EyeOff, Info } from 'lucide-react'; | |
| import { Switch } from './ui/switch'; | |
| import { Label } from './ui/label'; | |
| import { Badge } from './ui/badge'; | |
| interface HeatmapViewProps { | |
| data: AnalysisData; | |
| } | |
| export function HeatmapView({ data }: HeatmapViewProps) { | |
| const canvasRef = useRef<HTMLCanvasElement>(null); | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const [showOverlay, setShowOverlay] = useState(true); | |
| const [selectedPattern, setSelectedPattern] = useState<DarkPattern | null>(null); | |
| useEffect(() => { | |
| if (!canvasRef.current || !containerRef.current) return; | |
| const canvas = canvasRef.current; | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) return; | |
| const container = containerRef.current; | |
| canvas.width = container.clientWidth; | |
| canvas.height = container.clientHeight; | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| if (!showOverlay) return; | |
| data.darkPatterns.forEach((pattern) => { | |
| const x = (pattern.location.x / 100) * canvas.width; | |
| const y = (pattern.location.y / 100) * canvas.height; | |
| const width = (pattern.location.width / 100) * canvas.width; | |
| const height = (pattern.location.height / 100) * canvas.height; | |
| // Heatmap gradient | |
| const gradient = ctx.createRadialGradient( | |
| x + width / 2, | |
| y + height / 2, | |
| 0, | |
| x + width / 2, | |
| y + height / 2, | |
| Math.max(width, height) / 1.5 | |
| ); | |
| let color: string; | |
| switch (pattern.severity) { | |
| case 'critical': | |
| color = '244, 63, 94'; // rose-500 | |
| break; | |
| case 'high': | |
| color = '249, 115, 22'; // orange-500 | |
| break; | |
| case 'medium': | |
| color = '245, 158, 11'; // amber-500 | |
| break; | |
| default: | |
| color = '99, 102, 241'; // indigo-500 | |
| } | |
| gradient.addColorStop(0, `rgba(${color}, 0.55)`); | |
| gradient.addColorStop(0.5, `rgba(${color}, 0.25)`); | |
| gradient.addColorStop(1, `rgba(${color}, 0)`); | |
| ctx.fillStyle = gradient; | |
| ctx.fillRect(x - width / 4, y - height / 4, width * 1.5, height * 1.5); | |
| // Dash border box | |
| ctx.strokeStyle = `rgba(${color}, 0.8)`; | |
| ctx.lineWidth = 1.5; | |
| ctx.setLineDash([4, 4]); | |
| ctx.strokeRect(x, y, width, height); | |
| ctx.setLineDash([]); | |
| }); | |
| }, [data.darkPatterns, showOverlay]); | |
| const getSeverityBadgeClass = (severity: string) => { | |
| switch (severity) { | |
| case 'critical': | |
| return 'bg-rose-500/10 text-rose-400 border border-rose-500/20'; | |
| case 'high': | |
| return 'bg-orange-500/10 text-orange-400 border border-orange-500/20'; | |
| case 'medium': | |
| return 'bg-amber-500/10 text-amber-400 border border-amber-500/20'; | |
| default: | |
| return 'bg-indigo-500/10 text-indigo-400 border border-indigo-500/20'; | |
| } | |
| }; | |
| const getSeverityCircleClass = (severity: string) => { | |
| switch (severity) { | |
| case 'critical': | |
| return 'bg-rose-500 shadow-[0_0_8px_rgba(244,63,94,0.5)]'; | |
| case 'high': | |
| return 'bg-orange-50 shadow-[0_0_8px_rgba(249,115,22,0.5)]'; | |
| case 'medium': | |
| return 'bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.5)]'; | |
| default: | |
| return 'bg-indigo-500 shadow-[0_0_8px_rgba(99,102,241,0.5)]'; | |
| } | |
| }; | |
| return ( | |
| <div className="space-y-6"> | |
| {/* Controls */} | |
| <div className="flex items-center justify-between bg-slate-900/30 rounded-xl p-4 border border-border"> | |
| <div className="flex items-center gap-3"> | |
| <button | |
| onClick={() => setShowOverlay(!showOverlay)} | |
| className="text-slate-400 hover:text-indigo-400 transition-colors" | |
| > | |
| {showOverlay ? <Eye className="w-5 h-5" /> : <EyeOff className="w-5 h-5" />} | |
| </button> | |
| <Label htmlFor="heatmap-toggle" className="text-xs font-semibold text-slate-300 cursor-pointer"> | |
| Visual Bounding Heatmap | |
| </Label> | |
| <Switch | |
| id="heatmap-toggle" | |
| checked={showOverlay} | |
| onCheckedChange={setShowOverlay} | |
| /> | |
| </div> | |
| <div className="text-[10px] uppercase tracking-wider font-bold text-slate-400 flex items-center gap-1"> | |
| <Info className="w-3.5 h-3.5 text-indigo-400" /> | |
| Hover or click highlights for XAI insights | |
| </div> | |
| </div> | |
| {/* Heatmap Display */} | |
| <div className="grid lg:grid-cols-3 gap-6"> | |
| <div className="lg:col-span-2"> | |
| <div | |
| ref={containerRef} | |
| className="relative bg-slate-950 rounded-xl overflow-hidden border border-border shadow-2xl" | |
| style={{ aspectRatio: '16/9' }} | |
| > | |
| {/* Background Image */} | |
| <img | |
| src={data.imageUrl} | |
| alt="UI Screenshot" | |
| className="absolute inset-0 w-full h-full object-contain opacity-95" | |
| /> | |
| {/* Heatmap Canvas Overlay */} | |
| <canvas | |
| ref={canvasRef} | |
| className="absolute inset-0 w-full h-full cursor-crosshair z-10" | |
| style={{ pointerEvents: showOverlay ? 'auto' : 'none' }} | |
| /> | |
| {/* Interactive hotspots */} | |
| {showOverlay && data.darkPatterns.map((pattern) => ( | |
| <div | |
| key={pattern.id} | |
| className="absolute cursor-pointer hover:bg-white/5 transition-colors z-20" | |
| style={{ | |
| left: `${pattern.location.x}%`, | |
| top: `${pattern.location.y}%`, | |
| width: `${pattern.location.width}%`, | |
| height: `${pattern.location.height}%`, | |
| }} | |
| onClick={() => setSelectedPattern(pattern)} | |
| title={pattern.type} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Legend and Details */} | |
| <div className="space-y-4"> | |
| {/* Severity Legend */} | |
| <div className="bg-slate-900/30 rounded-xl p-4 border border-border"> | |
| <h4 className="font-bold text-xs text-slate-200 mb-3">Severity Legend</h4> | |
| <div className="grid grid-cols-2 gap-2"> | |
| {['critical', 'high', 'medium', 'low'].map((severity) => ( | |
| <div key={severity} className="flex items-center gap-2 bg-slate-950/20 px-3 py-2 rounded-lg border border-white/5"> | |
| <div className={`w-3 h-3 rounded-full shrink-0 ${getSeverityCircleClass(severity)}`} /> | |
| <span className="text-xs font-semibold capitalize text-slate-300">{severity}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Selected Pattern Details */} | |
| {selectedPattern ? ( | |
| <div className="bg-slate-900/30 rounded-xl p-4 border border-border relative overflow-hidden"> | |
| <div className="absolute top-0 right-0 w-24 h-24 bg-white/5 rounded-full blur-2xl pointer-events-none" /> | |
| <div className="flex items-center justify-between mb-3 border-b border-white/5 pb-2"> | |
| <h4 className="font-bold text-xs text-slate-200">Selected Violation</h4> | |
| <Badge className={`${getSeverityBadgeClass(selectedPattern.severity)} text-[9px] font-bold px-2 py-0.5 rounded-md`}> | |
| {selectedPattern.severity} | |
| </Badge> | |
| </div> | |
| <div className="space-y-3"> | |
| <div> | |
| <div className="text-[10px] uppercase font-bold text-slate-500">Deceptive Category</div> | |
| <div className="text-xs text-slate-200 font-semibold mt-0.5">{selectedPattern.type}</div> | |
| </div> | |
| <div> | |
| <div className="text-[10px] uppercase font-bold text-slate-500">Description</div> | |
| <div className="text-xs text-slate-300 leading-relaxed mt-0.5">{selectedPattern.description}</div> | |
| </div> | |
| <div> | |
| <div className="text-[10px] uppercase font-bold text-slate-500">Classification Confidence</div> | |
| <div className="text-xs text-slate-200 font-mono mt-0.5">{selectedPattern.confidence}% certainty</div> | |
| </div> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="bg-slate-900/20 rounded-xl p-6 border border-dashed border-slate-800 text-center flex flex-col items-center justify-center h-48"> | |
| <p className="text-xs text-slate-500 leading-relaxed max-w-[200px]"> | |
| Click on any glowing boundary block inside the viewport to view detailed NLP predictions | |
| </p> | |
| </div> | |
| )} | |
| {/* Pattern Count Summary */} | |
| <div className="bg-gradient-to-br from-indigo-500/10 via-indigo-500/5 to-transparent rounded-xl p-4 border border-indigo-500/20"> | |
| <div className="text-center"> | |
| <div className="text-2xl font-extrabold text-indigo-400"> | |
| {data.darkPatterns.length} | |
| </div> | |
| <div className="text-[10px] uppercase tracking-wider font-bold text-slate-400 mt-1"> | |
| Deceptive Blocks Highlighted | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* AI Explanation */} | |
| <div className="bg-indigo-500/5 border border-indigo-500/10 rounded-xl p-4"> | |
| <h4 className="font-bold text-xs text-indigo-300 mb-1.5">🤖 Visual Highlight Layers</h4> | |
| <p className="text-xs text-slate-400 leading-relaxed"> | |
| The heat boundaries are computed using coordinate percentages from Pytesseract OCR output. | |
| Borders represent language elements identified by the scikit-learn Logistic Regression model. | |
| </p> | |
| </div> | |
| </div> | |
| ); | |
| } | |