'use client' /** * StepAnsicht — Split-view page layout comparison. * * Left: Original scan with OCR word overlay (red) + coordinate grid * Right: Reconstructed layout with all zones + coordinate grid * * Both sides share the same coordinate system for easy visual comparison. */ import { useEffect, useRef, useState } from 'react' import { useGridEditor } from '@/components/grid-editor/useGridEditor' import type { GridZone, GridEditorCell } from '@/components/grid-editor/types' const KLAUSUR_API = '/klausur-api' interface StepAnsichtProps { sessionId: string | null onNext: () => void } function getCellColor(cell: GridEditorCell | undefined): string | null { if (!cell) return null if (cell.color_override) return cell.color_override const colored = cell.word_boxes?.find((wb) => wb.color_name && wb.color_name !== 'black') return colored?.color ?? null } export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) { const { grid, loading, error, loadGrid } = useGridEditor(sessionId) const leftRef = useRef(null) const [panelWidth, setPanelWidth] = useState(0) const [showGrid, setShowGrid] = useState(true) const [gridSpacing, setGridSpacing] = useState(100) // px in original coordinates useEffect(() => { if (sessionId) loadGrid() }, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps // Track panel width useEffect(() => { if (!leftRef.current) return const ro = new ResizeObserver(([entry]) => setPanelWidth(entry.contentRect.width)) ro.observe(leftRef.current) return () => ro.disconnect() }, []) if (loading) { return (
Lade Vorschau...
) } if (error || !grid) { return (

{error || 'Keine Grid-Daten.'}

) } const imgW = grid.image_width || 1 const imgH = grid.image_height || 1 const scale = panelWidth > 0 ? panelWidth / imgW : 0.5 const panelHeight = imgH * scale const baseFontPx = (grid as any).layout_metrics?.font_size_suggestion_px || 14 const avgRowH = (grid as any).layout_metrics?.avg_row_height_px || 31 const scaledFont = Math.max(7, baseFontPx * scale) return (
{/* Header */}

Ansicht — Original vs. Rekonstruktion

Links: Original mit OCR-Overlay. Rechts: Rekonstruierte Seite. Koordinatengitter zum Abgleich.

{/* Split view */}
0 ? `${panelHeight + 40}px` : '600px' }}> {/* LEFT: Original + OCR overlay */}
Original + OCR
{/* Server-rendered OCR overlay image (scan + red snapped letters) */} {sessionId && ( Original + OCR Overlay )} {/* Coordinate grid */} {showGrid && }
{/* RIGHT: Reconstruction */}
Rekonstruktion
{/* Rendered zones */} {grid.zones.map((zone) => ( ))} {/* Coordinate grid */} {showGrid && }
) } // --------------------------------------------------------------------------- // Coordinate grid overlay // --------------------------------------------------------------------------- function CoordinateGrid({ imgW, imgH, scale, spacing }: { imgW: number; imgH: number; scale: number; spacing: number }) { const lines: JSX.Element[] = [] // Vertical lines for (let x = 0; x <= imgW; x += spacing) { const px = x * scale lines.push(
{x}
) } // Horizontal lines for (let y = 0; y <= imgH; y += spacing) { const px = y * scale lines.push(
{y}
) } return <>{lines} } // --------------------------------------------------------------------------- // Zone renderer (reconstruction side) // --------------------------------------------------------------------------- function ZoneRenderer({ zone, scale, fontSize, avgRowH }: { zone: GridZone; scale: number; fontSize: number; avgRowH: number }) { const isBox = zone.zone_type === 'box' const boxColor = (zone as any).box_bg_hex || '#6b7280' if (!zone.cells || zone.cells.length === 0) return null const left = zone.bbox_px.x * scale const top = zone.bbox_px.y * scale const width = zone.bbox_px.w * scale const height = zone.bbox_px.h * scale const cellMap = new Map() for (const cell of zone.cells) { cellMap.set(`${cell.row_index}_${cell.col_index}`, cell) } // Column widths scaled to zone const colWidths = zone.columns.map((col) => { const w = (col.x_max_px ?? 0) - (col.x_min_px ?? 0) return Math.max(5, w * scale) }) const totalColW = colWidths.reduce((s, w) => s + w, 0) const colScale = totalColW > 0 ? width / totalColW : 1 const scaledColWidths = colWidths.map((w) => w * colScale) const numCols = zone.columns.length return (
`${w.toFixed(1)}px`).join(' ') }}> {zone.rows.map((row, rowIdx) => { const isSpanning = zone.cells.some((c) => c.row_index === row.index && c.col_type === 'spanning_header') // Row height = distance to next row's start (not text height) // This produces correct line spacing matching the original const nextRow = rowIdx + 1 < zone.rows.length ? zone.rows[rowIdx + 1] : null const rowStartY = row.y_min_px ?? row.y_min ?? 0 const nextStartY = nextRow ? (nextRow.y_min_px ?? nextRow.y_min ?? 0) : rowStartY + avgRowH const rowSpacing = nextStartY - rowStartY const rowH = Math.max(fontSize * 1.3, rowSpacing * scale) // Multi-line cells need more height const maxLines = Math.max(1, ...zone.cells .filter((c) => c.row_index === row.index) .map((c) => (c.text ?? '').split('\n').length)) const effectiveRowH = rowH * Math.max(1, maxLines) return (
{isSpanning ? ( zone.cells .filter((c) => c.row_index === row.index && c.col_type === 'spanning_header') .sort((a, b) => a.col_index - b.col_index) .map((cell) => { const colspan = cell.colspan || numCols const color = getCellColor(cell) return (
{cell.text}
) }) ) : ( zone.columns.map((col) => { const cell = cellMap.get(`${row.index}_${col.index}`) if (!cell) return
const color = getCellColor(cell) const isBold = col.bold || cell.is_bold || row.is_header const text = cell.text ?? '' return (
{text}
) }) )}
) })}
) }