A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
440 lines
15 KiB
TypeScript
440 lines
15 KiB
TypeScript
/**
|
|
* 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<WordBox | null>(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<HTMLDivElement>(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 (
|
|
<div className={`relative ${className}`}>
|
|
{/* Controls */}
|
|
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
|
<div className="flex items-center gap-2">
|
|
{toggleable && (
|
|
<button
|
|
onClick={() => setShowOverlay(prev => !prev)}
|
|
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
|
|
showOverlay
|
|
? 'bg-purple-600 text-white'
|
|
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
|
}`}
|
|
>
|
|
{showOverlay ? 'Overlay An' : 'Overlay Aus'}
|
|
</button>
|
|
)}
|
|
<div className="flex items-center gap-1 bg-slate-100 rounded p-1">
|
|
<button
|
|
onClick={() => setZoom(prev => Math.max(1, prev - 0.25))}
|
|
className="p-1 hover:bg-slate-200 rounded transition-colors"
|
|
title="Verkleinern"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
|
</svg>
|
|
</button>
|
|
<span className="text-xs font-mono w-12 text-center">{(zoom * 100).toFixed(0)}%</span>
|
|
<button
|
|
onClick={() => setZoom(prev => Math.min(3, prev + 0.25))}
|
|
className="p-1 hover:bg-slate-200 rounded transition-colors"
|
|
title="Vergroessern"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
</button>
|
|
{zoom > 1 && (
|
|
<button
|
|
onClick={resetView}
|
|
className="p-1 hover:bg-slate-200 rounded transition-colors ml-1"
|
|
title="Zuruecksetzen"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Overall confidence badge */}
|
|
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
|
|
confidence >= 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)}%
|
|
</div>
|
|
</div>
|
|
|
|
{/* Image container with overlay */}
|
|
<div
|
|
ref={containerRef}
|
|
className="relative overflow-hidden rounded-lg border border-slate-200 bg-slate-100"
|
|
style={{ cursor: zoom > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default' }}
|
|
onWheel={handleWheel}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseUp}
|
|
>
|
|
<div
|
|
className="relative transition-transform duration-200"
|
|
style={{
|
|
transform: `scale(${zoom}) translate(${pan.x}px, ${pan.y}px)`,
|
|
transformOrigin: 'center center'
|
|
}}
|
|
>
|
|
{/* Original image */}
|
|
<img
|
|
src={imageSrc}
|
|
alt="OCR Dokument"
|
|
className="w-full h-auto"
|
|
draggable={false}
|
|
/>
|
|
|
|
{/* SVG Overlay */}
|
|
{showOverlay && displayBoxes.length > 0 && (
|
|
<svg
|
|
className="absolute inset-0 w-full h-full pointer-events-none"
|
|
viewBox="0 0 100 100"
|
|
preserveAspectRatio="none"
|
|
>
|
|
{displayBoxes.map((box, idx) => (
|
|
<g
|
|
key={idx}
|
|
className="pointer-events-auto cursor-pointer"
|
|
onMouseEnter={() => setHoveredWord(box)}
|
|
onMouseLeave={() => setHoveredWord(null)}
|
|
onClick={() => onWordClick?.(box)}
|
|
>
|
|
{/* Background fill */}
|
|
<rect
|
|
x={box.bbox[0]}
|
|
y={box.bbox[1]}
|
|
width={box.bbox[2]}
|
|
height={box.bbox[3]}
|
|
fill={getConfidenceColor(box.confidence, 0.3)}
|
|
stroke={getConfidenceBorderColor(box.confidence)}
|
|
strokeWidth="0.3"
|
|
rx="0.5"
|
|
className="transition-all duration-150"
|
|
style={{
|
|
filter: hoveredWord === box ? 'brightness(1.2)' : 'none'
|
|
}}
|
|
/>
|
|
|
|
{/* Hover highlight */}
|
|
{hoveredWord === box && (
|
|
<rect
|
|
x={box.bbox[0] - 0.5}
|
|
y={box.bbox[1] - 0.5}
|
|
width={box.bbox[2] + 1}
|
|
height={box.bbox[3] + 1}
|
|
fill="none"
|
|
stroke="#7c3aed"
|
|
strokeWidth="0.5"
|
|
rx="0.5"
|
|
className="animate-pulse"
|
|
/>
|
|
)}
|
|
</g>
|
|
))}
|
|
</svg>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hovered word tooltip */}
|
|
{hoveredWord && (
|
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-slate-900 text-white text-sm rounded-lg shadow-lg z-10">
|
|
<div className="font-mono">"{hoveredWord.text}"</div>
|
|
<div className="text-slate-300 text-xs">
|
|
Konfidenz: {(hoveredWord.confidence * 100).toFixed(1)}%
|
|
</div>
|
|
<div
|
|
className="absolute top-full left-1/2 -translate-x-1/2 w-2 h-2 bg-slate-900 rotate-45 -mt-1"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Legend */}
|
|
{showLegend && (
|
|
<div className="mt-3 flex items-center justify-center gap-4 text-xs text-slate-600">
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(34, 197, 94, 0.5)' }} />
|
|
<span>>90%</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(234, 179, 8, 0.5)' }} />
|
|
<span>70-90%</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(249, 115, 22, 0.5)' }} />
|
|
<span>50-70%</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(239, 68, 68, 0.5)' }} />
|
|
<span><50%</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Instructions */}
|
|
<p className="mt-2 text-xs text-slate-400 text-center">
|
|
Fahre ueber markierte Bereiche fuer Details. Strg+Scroll zum Zoomen.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 <span className={className}>{text}</span>
|
|
}
|
|
|
|
return (
|
|
<span className={`font-mono ${className}`}>
|
|
{text.split('').map((char, idx) => {
|
|
const conf = charConfidences[idx] ?? 0.5
|
|
return (
|
|
<span
|
|
key={idx}
|
|
className="relative group"
|
|
style={{ backgroundColor: getConfidenceColor(conf, 0.3) }}
|
|
title={`'${char}': ${(conf * 100).toFixed(0)}%`}
|
|
>
|
|
{char}
|
|
</span>
|
|
)
|
|
})}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div className={`grid grid-cols-2 md:grid-cols-5 gap-3 ${className}`}>
|
|
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
|
<div className="text-lg font-bold text-slate-900">{(stats.avg * 100).toFixed(0)}%</div>
|
|
<div className="text-xs text-slate-500">Durchschnitt</div>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
|
<div className="text-lg font-bold text-slate-900">{(stats.min * 100).toFixed(0)}%</div>
|
|
<div className="text-xs text-slate-500">Minimum</div>
|
|
</div>
|
|
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
|
<div className="text-lg font-bold text-slate-900">{(stats.max * 100).toFixed(0)}%</div>
|
|
<div className="text-xs text-slate-500">Maximum</div>
|
|
</div>
|
|
<div className="bg-green-50 rounded-lg p-3 text-center">
|
|
<div className="text-lg font-bold text-green-700">{stats.highConf}</div>
|
|
<div className="text-xs text-slate-500">Sicher (>90%)</div>
|
|
</div>
|
|
<div className="bg-red-50 rounded-lg p-3 text-center">
|
|
<div className="text-lg font-bold text-red-700">{stats.lowConf}</div>
|
|
<div className="text-xs text-slate-500">Unsicher (<70%)</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ConfidenceHeatmap
|