This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/admin-v2/components/ocr/GridOverlay.tsx
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
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>
2026-02-09 09:51:32 +01:00

358 lines
11 KiB
TypeScript

'use client'
/**
* GridOverlay Component
*
* SVG overlay for displaying detected OCR grid structure on document images.
* Shows recognized (green), problematic (orange), manual (blue), and empty (transparent) cells.
* Supports click-to-edit for problematic cells.
*/
import { useCallback } from 'react'
import { cn } from '@/lib/utils'
export type CellStatus = 'empty' | 'recognized' | 'problematic' | 'manual'
export interface GridCell {
row: number
col: number
x: number // X position as percentage (0-100)
y: number // Y position as percentage (0-100)
width: number // Width as percentage (0-100)
height: number // Height as percentage (0-100)
text: string
confidence: number
status: CellStatus
column_type?: 'english' | 'german' | 'example' | 'unknown'
}
export interface GridData {
rows: number
columns: number
cells: GridCell[][]
column_types: string[]
column_boundaries: number[]
row_boundaries: number[]
deskew_angle: number
stats: {
recognized: number
problematic: number
empty: number
manual?: number
total: number
coverage: number
}
}
interface GridOverlayProps {
grid: GridData
imageUrl?: string
onCellClick?: (cell: GridCell) => void
selectedCell?: GridCell | null
showEmpty?: boolean
showLabels?: boolean
showNumbers?: boolean // Show block numbers in cells
highlightedBlockNumber?: number | null // Highlight specific block
className?: string
}
// Status colors
const STATUS_COLORS = {
recognized: {
fill: 'rgba(34, 197, 94, 0.2)', // green-500 with opacity
stroke: '#22c55e', // green-500
hoverFill: 'rgba(34, 197, 94, 0.3)',
},
problematic: {
fill: 'rgba(249, 115, 22, 0.3)', // orange-500 with opacity
stroke: '#f97316', // orange-500
hoverFill: 'rgba(249, 115, 22, 0.4)',
},
manual: {
fill: 'rgba(59, 130, 246, 0.2)', // blue-500 with opacity
stroke: '#3b82f6', // blue-500
hoverFill: 'rgba(59, 130, 246, 0.3)',
},
empty: {
fill: 'transparent',
stroke: 'rgba(148, 163, 184, 0.3)', // slate-400 with opacity
hoverFill: 'rgba(148, 163, 184, 0.1)',
},
}
// Helper to calculate block number (1-indexed, row-by-row)
export function getCellBlockNumber(cell: GridCell, grid: GridData): number {
return cell.row * grid.columns + cell.col + 1
}
export function GridOverlay({
grid,
imageUrl,
onCellClick,
selectedCell,
showEmpty = false,
showLabels = true,
showNumbers = false,
highlightedBlockNumber,
className,
}: GridOverlayProps) {
const handleCellClick = useCallback(
(cell: GridCell) => {
if (onCellClick && cell.status !== 'empty') {
onCellClick(cell)
}
},
[onCellClick]
)
const flatCells = grid.cells.flat()
return (
<div className={cn('relative', className)}>
{/* Background image */}
{imageUrl && (
<img
src={imageUrl}
alt="Document"
className="w-full h-auto"
/>
)}
{/* SVG overlay */}
<svg
className="absolute inset-0 w-full h-full"
style={{ pointerEvents: 'none' }}
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
{/* Column type labels */}
{showLabels && grid.column_types.length > 0 && (
<g>
{grid.column_types.map((type, idx) => {
const x = grid.column_boundaries[idx]
const width = grid.column_boundaries[idx + 1] - x
const label = type === 'english' ? 'EN' : type === 'german' ? 'DE' : type === 'example' ? 'Ex' : '?'
return (
<text
key={`col-label-${idx}`}
x={x + width / 2}
y={1.5}
textAnchor="middle"
fontSize="1.5"
fill="#64748b"
fontWeight="bold"
style={{ pointerEvents: 'none' }}
>
{label}
</text>
)
})}
</g>
)}
{/* Grid cells */}
{flatCells.map((cell) => {
const colors = STATUS_COLORS[cell.status]
const isSelected = selectedCell?.row === cell.row && selectedCell?.col === cell.col
const isClickable = cell.status !== 'empty' && onCellClick
const blockNumber = getCellBlockNumber(cell, grid)
const isHighlighted = highlightedBlockNumber === blockNumber
// Skip empty cells if not showing them
if (!showEmpty && cell.status === 'empty') {
return null
}
return (
<g
key={`cell-${cell.row}-${cell.col}`}
style={{ pointerEvents: isClickable ? 'auto' : 'none' }}
onClick={() => handleCellClick(cell)}
className={isClickable ? 'cursor-pointer' : ''}
>
{/* Cell rectangle */}
<rect
x={cell.x}
y={cell.y}
width={cell.width}
height={cell.height}
fill={isHighlighted ? 'rgba(99, 102, 241, 0.3)' : colors.fill}
stroke={isSelected || isHighlighted ? '#4f46e5' : colors.stroke}
strokeWidth={isSelected || isHighlighted ? 0.3 : 0.15}
rx={0.2}
className={cn(
'transition-all duration-150',
isClickable && 'hover:fill-opacity-40'
)}
/>
{/* Block number badge */}
{showNumbers && cell.status !== 'empty' && (
<>
<rect
x={cell.x + 0.3}
y={cell.y + 0.3}
width={2.5}
height={1.8}
fill={isHighlighted ? '#4f46e5' : '#374151'}
rx={0.3}
/>
<text
x={cell.x + 1.55}
y={cell.y + 1.5}
textAnchor="middle"
fontSize="1.2"
fill="white"
fontWeight="bold"
style={{ pointerEvents: 'none' }}
>
{blockNumber}
</text>
</>
)}
{/* Status indicator dot (only when not showing numbers) */}
{!showNumbers && cell.status !== 'empty' && (
<circle
cx={cell.x + 0.8}
cy={cell.y + 0.8}
r={0.5}
fill={colors.stroke}
stroke="white"
strokeWidth={0.1}
/>
)}
{/* Confidence indicator (for recognized cells) */}
{cell.status === 'recognized' && cell.confidence < 0.7 && (
<text
x={cell.x + cell.width - 0.5}
y={cell.y + 1.2}
fontSize="0.8"
fill="#f97316"
style={{ pointerEvents: 'none' }}
>
!
</text>
)}
{/* Selection highlight */}
{isSelected && (
<rect
x={cell.x}
y={cell.y}
width={cell.width}
height={cell.height}
fill="none"
stroke="#4f46e5"
strokeWidth={0.4}
strokeDasharray="0.5,0.3"
rx={0.2}
/>
)}
</g>
)
})}
{/* Row boundaries (optional grid lines) */}
{grid.row_boundaries.map((y, idx) => (
<line
key={`row-line-${idx}`}
x1={0}
y1={y}
x2={100}
y2={y}
stroke="rgba(148, 163, 184, 0.2)"
strokeWidth={0.05}
style={{ pointerEvents: 'none' }}
/>
))}
{/* Column boundaries */}
{grid.column_boundaries.map((x, idx) => (
<line
key={`col-line-${idx}`}
x1={x}
y1={0}
x2={x}
y2={100}
stroke="rgba(148, 163, 184, 0.2)"
strokeWidth={0.05}
style={{ pointerEvents: 'none' }}
/>
))}
</svg>
</div>
)
}
/**
* GridStats Component
*
* Displays statistics about the grid detection results.
*/
interface GridStatsProps {
stats: GridData['stats']
deskewAngle?: number
className?: string
}
export function GridStats({ stats, deskewAngle, className }: GridStatsProps) {
const coveragePercent = Math.round(stats.coverage * 100)
return (
<div className={cn('flex flex-wrap gap-3', className)}>
<div className="px-3 py-1.5 bg-green-50 text-green-700 rounded-lg text-sm font-medium">
Erkannt: {stats.recognized}
</div>
{(stats.manual ?? 0) > 0 && (
<div className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-sm font-medium">
Manuell: {stats.manual}
</div>
)}
{stats.problematic > 0 && (
<div className="px-3 py-1.5 bg-orange-50 text-orange-700 rounded-lg text-sm font-medium">
Problematisch: {stats.problematic}
</div>
)}
<div className="px-3 py-1.5 bg-slate-50 text-slate-600 rounded-lg text-sm font-medium">
Leer: {stats.empty}
</div>
<div className="px-3 py-1.5 bg-indigo-50 text-indigo-700 rounded-lg text-sm font-medium">
Abdeckung: {coveragePercent}%
</div>
{deskewAngle !== undefined && deskewAngle !== 0 && (
<div className="px-3 py-1.5 bg-purple-50 text-purple-700 rounded-lg text-sm font-medium">
Begradigt: {deskewAngle.toFixed(1)}
</div>
)}
</div>
)
}
/**
* Legend Component for GridOverlay
*/
export function GridLegend({ className }: { className?: string }) {
return (
<div className={cn('flex flex-wrap gap-4 text-sm', className)}>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-green-500 bg-green-500/20" />
<span className="text-slate-600">Erkannt</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-orange-500 bg-orange-500/30" />
<span className="text-slate-600">Problematisch</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-blue-500 bg-blue-500/20" />
<span className="text-slate-600">Manuell korrigiert</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-slate-300 bg-transparent" />
<span className="text-slate-600">Leer</span>
</div>
</div>
)
}