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>
550 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|