'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( ) } // 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( ) } return {lines} } /** * 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(null) return (
{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 (
setHoveredCell(cellKey)} onMouseLeave={() => setHoveredCell(null)} > {editable ? ( { const newText = e.currentTarget.textContent ?? '' if (newText !== cell.text && onTextChange) { onTextChange(cell, newText) } }} > {cell.text} ) : ( {cell.text} )}
) })}
) } 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 (
{/* Background image */} {imageUrl && ( Document )} {/* SVG overlay */} {/* 1mm Grid */} {showMmGrid && } {/* Column type labels */} {showLabels && grid.column_types.length > 0 && ( {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 ( {label} ) })} )} {/* 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 ( handleCellClick(cell)} className={isClickable ? 'cursor-pointer' : ''} > {showNumbers && cell.status !== 'empty' && ( <> {blockNumber} )} {!showNumbers && !showTextLabels && cell.status !== 'empty' && ( )} {showTextLabels && (cell.status === 'recognized' || cell.status === 'manual') && cell.text && ( {cell.text.length > 15 ? cell.text.slice(0, 15) + '\u2026' : cell.text} )} {cell.status === 'recognized' && cell.confidence < 0.7 && ( ! )} {isSelected && ( )} ) })} {/* Show cell outlines when in positioned text mode */} {showTextAtPosition && flatCells.map((cell) => { if (cell.status === 'empty') return null return ( ) })} {/* Row boundaries */} {grid.row_boundaries.map((y, idx) => ( ))} {/* Column boundaries */} {grid.column_boundaries.map((x, idx) => ( ))} {/* Positioned text HTML overlay (outside SVG for proper text rendering) */} {showTextAtPosition && ( c.status !== 'empty' && c.text)} editable={editableText} onTextChange={onCellTextChange} /> )}
) } /** * 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 (
Erkannt: {stats.recognized}
{(stats.manual ?? 0) > 0 && (
Manuell: {stats.manual}
)} {stats.problematic > 0 && (
Problematisch: {stats.problematic}
)}
Leer: {stats.empty}
Abdeckung: {coveragePercent}%
{deskewAngle !== undefined && deskewAngle !== 0 && (
Begradigt: {deskewAngle.toFixed(1)}
)} {source && (
Quelle: {source === 'tesseract+grid_service' ? 'Tesseract' : 'Vision LLM'}
)}
) } /** * Legend Component for GridOverlay */ export function GridLegend({ className }: { className?: string }) { return (
Erkannt
Problematisch
Manuell korrigiert
Leer
) }