/** * Confidence Heatmap Component * * Displays an OCR result with visual confidence overlay on the original image. * Shows word-level or character-level confidence using color gradients. * * Phase 3.1: Wow-Feature for Magic Help */ 'use client' import { useState, useRef, useEffect, useMemo } from 'react' interface WordBox { text: string confidence: number bbox: [number, number, number, number] // [x, y, width, height] as percentages (0-100) } interface ConfidenceHeatmapProps { /** Image source URL or data URL */ imageSrc: string /** Detected text result */ text: string /** Overall confidence score (0-1) */ confidence: number /** Word-level boxes with confidence */ wordBoxes?: WordBox[] /** Character-level confidences (aligned with text) */ charConfidences?: number[] /** Custom class names */ className?: string /** Show legend */ showLegend?: boolean /** Allow toggling overlay visibility */ toggleable?: boolean /** Callback when a word box is clicked */ onWordClick?: (word: WordBox) => void } /** * Get color based on confidence value * Green (high) -> Yellow (medium) -> Red (low) */ function getConfidenceColor(confidence: number, opacity = 0.5): string { if (confidence >= 0.9) { return `rgba(34, 197, 94, ${opacity})` // green-500 } else if (confidence >= 0.7) { return `rgba(234, 179, 8, ${opacity})` // yellow-500 } else if (confidence >= 0.5) { return `rgba(249, 115, 22, ${opacity})` // orange-500 } else { return `rgba(239, 68, 68, ${opacity})` // red-500 } } /** * Get border color (more saturated version) */ function getConfidenceBorderColor(confidence: number): string { if (confidence >= 0.9) return '#22c55e' // green-500 if (confidence >= 0.7) return '#eab308' // yellow-500 if (confidence >= 0.5) return '#f97316' // orange-500 return '#ef4444' // red-500 } /** * Confidence Heatmap Component */ export function ConfidenceHeatmap({ imageSrc, text, confidence, wordBoxes = [], charConfidences = [], className = '', showLegend = true, toggleable = true, onWordClick }: ConfidenceHeatmapProps) { const [showOverlay, setShowOverlay] = useState(true) const [hoveredWord, setHoveredWord] = useState(null) const [zoom, setZoom] = useState(1) const [pan, setPan] = useState({ x: 0, y: 0 }) const [isPanning, setIsPanning] = useState(false) const [lastPanPoint, setLastPanPoint] = useState({ x: 0, y: 0 }) const containerRef = useRef(null) // Generate simulated word boxes if not provided const displayBoxes = useMemo(() => { if (wordBoxes.length > 0) return wordBoxes // Simulate word boxes from text and char confidences if (!text || charConfidences.length === 0) return [] const words = text.split(/\s+/).filter(w => w.length > 0) const boxes: WordBox[] = [] let charIndex = 0 words.forEach((word, idx) => { // Calculate average confidence for this word const wordConfidences = charConfidences.slice(charIndex, charIndex + word.length) const avgConfidence = wordConfidences.length > 0 ? wordConfidences.reduce((a, b) => a + b, 0) / wordConfidences.length : confidence // Simulate bbox positions (simple grid layout for demo) const wordsPerRow = 5 const row = Math.floor(idx / wordsPerRow) const col = idx % wordsPerRow const wordWidth = Math.min(18, 5 + word.length * 1.5) boxes.push({ text: word, confidence: avgConfidence, bbox: [ 5 + col * 19, // x 10 + row * 12, // y wordWidth, // width 8 // height ] }) charIndex += word.length + 1 // +1 for space }) return boxes }, [text, wordBoxes, charConfidences, confidence]) // Handle mouse wheel zoom const handleWheel = (e: React.WheelEvent) => { if (e.ctrlKey || e.metaKey) { e.preventDefault() const delta = e.deltaY > 0 ? -0.1 : 0.1 setZoom(prev => Math.max(1, Math.min(3, prev + delta))) } } // Handle panning const handleMouseDown = (e: React.MouseEvent) => { if (zoom > 1) { setIsPanning(true) setLastPanPoint({ x: e.clientX, y: e.clientY }) } } const handleMouseMove = (e: React.MouseEvent) => { if (isPanning && zoom > 1) { const dx = e.clientX - lastPanPoint.x const dy = e.clientY - lastPanPoint.y setPan(prev => ({ x: Math.max(-100, Math.min(100, prev.x + dx / zoom)), y: Math.max(-100, Math.min(100, prev.y + dy / zoom)) })) setLastPanPoint({ x: e.clientX, y: e.clientY }) } } const handleMouseUp = () => { setIsPanning(false) } // Reset zoom and pan const resetView = () => { setZoom(1) setPan({ x: 0, y: 0 }) } return (
{/* Controls */}
{toggleable && ( )}
{(zoom * 100).toFixed(0)}% {zoom > 1 && ( )}
{/* Overall confidence badge */}
= 0.9 ? 'bg-green-100 text-green-700' : confidence >= 0.7 ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700' }`}> Gesamt: {(confidence * 100).toFixed(0)}%
{/* Image container with overlay */}
1 ? (isPanning ? 'grabbing' : 'grab') : 'default' }} onWheel={handleWheel} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} >
{/* Original image */} OCR Dokument {/* SVG Overlay */} {showOverlay && displayBoxes.length > 0 && ( {displayBoxes.map((box, idx) => ( setHoveredWord(box)} onMouseLeave={() => setHoveredWord(null)} onClick={() => onWordClick?.(box)} > {/* Background fill */} {/* Hover highlight */} {hoveredWord === box && ( )} ))} )}
{/* Hovered word tooltip */} {hoveredWord && (
"{hoveredWord.text}"
Konfidenz: {(hoveredWord.confidence * 100).toFixed(1)}%
)} {/* Legend */} {showLegend && (
>90%
70-90%
50-70%
<50%
)} {/* Instructions */}

Fahre ueber markierte Bereiche fuer Details. Strg+Scroll zum Zoomen.

) } /** * Inline character confidence display * Shows text with color-coded background for each character */ interface InlineConfidenceTextProps { text: string charConfidences: number[] className?: string } export function InlineConfidenceText({ text, charConfidences, className = '' }: InlineConfidenceTextProps) { if (charConfidences.length === 0) { return {text} } return ( {text.split('').map((char, idx) => { const conf = charConfidences[idx] ?? 0.5 return ( {char} ) })} ) } /** * Confidence Statistics Summary */ interface ConfidenceStatsProps { wordBoxes: WordBox[] className?: string } export function ConfidenceStats({ wordBoxes, className = '' }: ConfidenceStatsProps) { const stats = useMemo(() => { if (wordBoxes.length === 0) return null const confidences = wordBoxes.map(w => w.confidence) const avg = confidences.reduce((a, b) => a + b, 0) / confidences.length const min = Math.min(...confidences) const max = Math.max(...confidences) const highConf = confidences.filter(c => c >= 0.9).length const lowConf = confidences.filter(c => c < 0.7).length return { avg, min, max, highConf, lowConf, total: wordBoxes.length } }, [wordBoxes]) if (!stats) return null return (
{(stats.avg * 100).toFixed(0)}%
Durchschnitt
{(stats.min * 100).toFixed(0)}%
Minimum
{(stats.max * 100).toFixed(0)}%
Maximum
{stats.highConf}
Sicher (>90%)
{stats.lowConf}
Unsicher (<70%)
) } export default ConfidenceHeatmap