fintechdarkpatterns / src /app /components /HeatmapView.tsx
Vezolu Vero
Initial commit with live data and Docker config
743409d
Raw
History Blame Contribute Delete
9.98 kB
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>
);
}