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>
358 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|