Files
breakpilot-lehrer/admin-lehrer/components/ocr/GridOverlay.tsx
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:26 +01:00

550 lines
16 KiB
TypeScript

'use client'
/**
* GridOverlay Component
*
* SVG overlay for displaying detected OCR grid structure on document images.
* Features:
* - Cell status visualization (recognized/problematic/manual/empty)
* - 1mm grid overlay for A4 pages (210x297mm)
* - Text at original bounding-box positions
* - Editable text (contentEditable) at original positions
* - Click-to-edit for cells
*/
import { useCallback, useState } 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'
x_mm?: number
y_mm?: number
width_mm?: number
height_mm?: number
}
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
}
page_dimensions?: {
width_mm: number
height_mm: number
format: string
}
source?: string
}
interface GridOverlayProps {
grid: GridData
imageUrl?: string
onCellClick?: (cell: GridCell) => void
onCellTextChange?: (cell: GridCell, newText: string) => void
selectedCell?: GridCell | null
showEmpty?: boolean
showLabels?: boolean
showNumbers?: boolean
showTextLabels?: boolean
showMmGrid?: boolean
showTextAtPosition?: boolean
editableText?: boolean
highlightedBlockNumber?: number | null
className?: string
}
// Status colors
const STATUS_COLORS = {
recognized: {
fill: 'rgba(34, 197, 94, 0.2)',
stroke: '#22c55e',
hoverFill: 'rgba(34, 197, 94, 0.3)',
},
problematic: {
fill: 'rgba(249, 115, 22, 0.3)',
stroke: '#f97316',
hoverFill: 'rgba(249, 115, 22, 0.4)',
},
manual: {
fill: 'rgba(59, 130, 246, 0.2)',
stroke: '#3b82f6',
hoverFill: 'rgba(59, 130, 246, 0.3)',
},
empty: {
fill: 'transparent',
stroke: 'rgba(148, 163, 184, 0.3)',
hoverFill: 'rgba(148, 163, 184, 0.1)',
},
}
// A4 dimensions for mm grid
const A4_WIDTH_MM = 210
const A4_HEIGHT_MM = 297
// 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
}
/**
* 1mm Grid SVG Lines for A4 format.
* Renders inside a viewBox="0 0 100 100" (percentage-based).
*/
function MmGridLines() {
const lines: React.ReactNode[] = []
// Vertical lines: 210 lines for 210mm
for (let mm = 0; mm <= A4_WIDTH_MM; mm++) {
const x = (mm / A4_WIDTH_MM) * 100
const isCm = mm % 10 === 0
lines.push(
<line
key={`v-${mm}`}
x1={x}
y1={0}
x2={x}
y2={100}
stroke={isCm ? 'rgba(59, 130, 246, 0.25)' : 'rgba(59, 130, 246, 0.1)'}
strokeWidth={isCm ? 0.08 : 0.03}
/>
)
}
// Horizontal lines: 297 lines for 297mm
for (let mm = 0; mm <= A4_HEIGHT_MM; mm++) {
const y = (mm / A4_HEIGHT_MM) * 100
const isCm = mm % 10 === 0
lines.push(
<line
key={`h-${mm}`}
x1={0}
y1={y}
x2={100}
y2={y}
stroke={isCm ? 'rgba(59, 130, 246, 0.25)' : 'rgba(59, 130, 246, 0.1)'}
strokeWidth={isCm ? 0.08 : 0.03}
/>
)
}
return <g style={{ pointerEvents: 'none' }}>{lines}</g>
}
/**
* Positioned text overlay using absolute-positioned HTML divs.
* Each cell's text appears at its bounding-box position with matching font size.
*/
function PositionedTextLayer({
cells,
editable,
onTextChange,
}: {
cells: GridCell[]
editable: boolean
onTextChange?: (cell: GridCell, text: string) => void
}) {
const [hoveredCell, setHoveredCell] = useState<string | null>(null)
return (
<div className="absolute inset-0" style={{ pointerEvents: editable ? 'auto' : 'none' }}>
{cells.map((cell) => {
if (cell.status === 'empty' || !cell.text) return null
const cellKey = `pos-${cell.row}-${cell.col}`
const isHovered = hoveredCell === cellKey
// Estimate font size from cell height: height_pct maps to roughly pt size
// A4 at 100% = 297mm height. Cell height in % * 297mm / 100 = height_mm
// Font size ~= height_mm * 2.2 (roughly matching print)
const heightMm = cell.height_mm ?? (cell.height / 100 * A4_HEIGHT_MM)
const fontSizePt = Math.max(6, Math.min(18, heightMm * 2.2))
return (
<div
key={cellKey}
className={cn(
'absolute overflow-hidden transition-colors duration-100',
editable && 'cursor-text hover:bg-yellow-100/40',
isHovered && !editable && 'bg-blue-100/30',
)}
style={{
left: `${cell.x}%`,
top: `${cell.y}%`,
width: `${cell.width}%`,
height: `${cell.height}%`,
fontSize: `${fontSizePt}pt`,
fontFamily: '"Georgia", "Times New Roman", serif',
lineHeight: 1.1,
color: cell.status === 'manual' ? '#1e40af' : '#1a1a1a',
padding: '0 1px',
display: 'flex',
alignItems: 'center',
}}
onMouseEnter={() => setHoveredCell(cellKey)}
onMouseLeave={() => setHoveredCell(null)}
>
{editable ? (
<span
contentEditable
suppressContentEditableWarning
className="outline-none w-full"
style={{ minHeight: '1em' }}
onBlur={(e) => {
const newText = e.currentTarget.textContent ?? ''
if (newText !== cell.text && onTextChange) {
onTextChange(cell, newText)
}
}}
>
{cell.text}
</span>
) : (
<span className="truncate">{cell.text}</span>
)}
</div>
)
})}
</div>
)
}
export function GridOverlay({
grid,
imageUrl,
onCellClick,
onCellTextChange,
selectedCell,
showEmpty = false,
showLabels = true,
showNumbers = false,
showTextLabels = false,
showMmGrid = false,
showTextAtPosition = false,
editableText = 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"
>
{/* 1mm Grid */}
{showMmGrid && <MmGridLines />}
{/* 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 (skip if showing text at position to avoid double rendering) */}
{!showTextAtPosition && 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
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' : ''}
>
<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'
)}
/>
{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>
</>
)}
{!showNumbers && !showTextLabels && 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}
/>
)}
{showTextLabels && (cell.status === 'recognized' || cell.status === 'manual') && cell.text && (
<text
x={cell.x + cell.width / 2}
y={cell.y + cell.height / 2 + Math.min(cell.height * 0.2, 0.5)}
textAnchor="middle"
fontSize={Math.min(cell.height * 0.5, 1.4)}
fill={cell.status === 'manual' ? '#1e40af' : '#166534'}
fontWeight="500"
style={{ pointerEvents: 'none' }}
>
{cell.text.length > 15 ? cell.text.slice(0, 15) + '\u2026' : cell.text}
</text>
)}
{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>
)}
{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>
)
})}
{/* Show cell outlines when in positioned text mode */}
{showTextAtPosition && flatCells.map((cell) => {
if (cell.status === 'empty') return null
return (
<rect
key={`outline-${cell.row}-${cell.col}`}
x={cell.x}
y={cell.y}
width={cell.width}
height={cell.height}
fill="none"
stroke="rgba(99, 102, 241, 0.2)"
strokeWidth={0.08}
rx={0.1}
style={{ pointerEvents: 'none' }}
/>
)
})}
{/* Row boundaries */}
{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>
{/* Positioned text HTML overlay (outside SVG for proper text rendering) */}
{showTextAtPosition && (
<PositionedTextLayer
cells={flatCells.filter(c => c.status !== 'empty' && c.text)}
editable={editableText}
onTextChange={onCellTextChange}
/>
)}
</div>
)
}
/**
* GridStats Component
*/
interface GridStatsProps {
stats: GridData['stats']
deskewAngle?: number
source?: string
className?: string
}
export function GridStats({ stats, deskewAngle, source, 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>
)}
{source && (
<div className="px-3 py-1.5 bg-cyan-50 text-cyan-700 rounded-lg text-sm font-medium">
Quelle: {source === 'tesseract+grid_service' ? 'Tesseract' : 'Vision LLM'}
</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>
)
}