StepAnsicht: split-view with coordinate grid for comparison
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 45s
CI / test-go-edu-search (push) Successful in 40s
CI / test-python-klausur (push) Failing after 2m37s
CI / test-python-agent-core (push) Successful in 32s
CI / test-nodejs-website (push) Successful in 36s

Left panel: Original scan + OCR word overlay (red text at exact
word_box positions) + coordinate grid
Right panel: Reconstructed layout + same coordinate grid

Features:
- Coordinate grid toggle with 50/100/200px spacing options
- Grid lines labeled with pixel coordinates in original image space
- Both panels share the same scale for direct visual comparison
- OCR overlay shows detected text in red mono font at original positions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-13 23:00:22 +02:00
parent cd8eb6ce46
commit 18213f0bde

View File

@@ -1,13 +1,15 @@
'use client' 'use client'
/** /**
* StepAnsicht — Read-only page layout preview. * StepAnsicht — Split-view page layout comparison.
* *
* Shows the reconstructed page with all zones (content grid + embedded boxes) * Left: Original scan with OCR word overlay (red) + coordinate grid
* positioned at their original coordinates. Pure CSS positioning, no canvas. * Right: Reconstructed layout with all zones + coordinate grid
*
* Both sides share the same coordinate system for easy visual comparison.
*/ */
import { useCallback, useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useGridEditor } from '@/components/grid-editor/useGridEditor' import { useGridEditor } from '@/components/grid-editor/useGridEditor'
import type { GridZone, GridEditorCell } from '@/components/grid-editor/types' import type { GridZone, GridEditorCell } from '@/components/grid-editor/types'
@@ -18,38 +20,30 @@ interface StepAnsichtProps {
onNext: () => void onNext: () => void
} }
/** Get dominant color from a cell's word_boxes or color_override. */
function getCellColor(cell: GridEditorCell | undefined): string | null { function getCellColor(cell: GridEditorCell | undefined): string | null {
if (!cell) return null if (!cell) return null
if ((cell as any).color_override) return (cell as any).color_override if (cell.color_override) return cell.color_override
const colored = cell.word_boxes?.find((wb) => wb.color_name && wb.color_name !== 'black') const colored = cell.word_boxes?.find((wb) => wb.color_name && wb.color_name !== 'black')
return colored?.color ?? null return colored?.color ?? null
} }
export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) { export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
const { const { grid, loading, error, loadGrid } = useGridEditor(sessionId)
grid,
loading,
error,
loadGrid,
} = useGridEditor(sessionId)
const containerRef = useRef<HTMLDivElement>(null) const leftRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0) const [panelWidth, setPanelWidth] = useState(0)
const [showOriginal, setShowOriginal] = useState(false) const [showGrid, setShowGrid] = useState(true)
const [gridSpacing, setGridSpacing] = useState(100) // px in original coordinates
// Load grid on mount
useEffect(() => { useEffect(() => {
if (sessionId) loadGrid() if (sessionId) loadGrid()
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps }, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
// Track container width // Track panel width
useEffect(() => { useEffect(() => {
if (!containerRef.current) return if (!leftRef.current) return
const ro = new ResizeObserver(([entry]) => { const ro = new ResizeObserver(([entry]) => setPanelWidth(entry.contentRect.width))
setContainerWidth(entry.contentRect.width) ro.observe(leftRef.current)
})
ro.observe(containerRef.current)
return () => ro.disconnect() return () => ro.disconnect()
}, []) }, [])
@@ -64,93 +58,158 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
if (error || !grid) { if (error || !grid) {
return ( return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-8 text-center"> <div className="p-8 text-center">
<p className="text-red-500 mb-4">{error || 'Keine Grid-Daten vorhanden.'}</p> <p className="text-red-500 mb-4">{error || 'Keine Grid-Daten.'}</p>
<button onClick={onNext} className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium"> <button onClick={onNext} className="px-5 py-2 bg-teal-600 text-white rounded-lg">Weiter </button>
Weiter
</button>
</div> </div>
) )
} }
const imgW = grid.image_width || 1 const imgW = grid.image_width || 1
const imgH = grid.image_height || 1 const imgH = grid.image_height || 1
const scale = containerWidth > 0 ? containerWidth / imgW : 1 const scale = panelWidth > 0 ? panelWidth / imgW : 0.5
const containerHeight = imgH * scale const panelHeight = imgH * scale
// Font size: scale from original, with minimum
const baseFontPx = (grid as any).layout_metrics?.font_size_suggestion_px || 14 const baseFontPx = (grid as any).layout_metrics?.font_size_suggestion_px || 14
const scaledFont = Math.max(7, baseFontPx * scale * 0.85) const scaledFont = Math.max(7, baseFontPx * scale * 0.85)
// Collect all word boxes for OCR overlay
const allWordBoxes = grid.zones.flatMap((z) =>
z.cells.flatMap((c) => (c.word_boxes || []).map((wb) => ({ ...wb, zone: z })))
)
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> <h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Ansicht Seitenrekonstruktion Ansicht Original vs. Rekonstruktion
</h3> </h3>
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
Vorschau der rekonstruierten Seite mit allen Zonen und Boxen an Originalpositionen. Links: Original mit OCR-Overlay. Rechts: Rekonstruierte Seite. Koordinatengitter zum Abgleich.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <label className="flex items-center gap-1.5 text-xs text-gray-500">
onClick={() => setShowOriginal(!showOriginal)} <input
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${ type="checkbox"
showOriginal checked={showGrid}
? 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300' onChange={(e) => setShowGrid(e.target.checked)}
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' className="w-3.5 h-3.5 rounded border-gray-300"
}`} />
> Gitter
{showOriginal ? 'Original ausblenden' : 'Original einblenden'} </label>
</button> <select
<button value={gridSpacing}
onClick={onNext} onChange={(e) => setGridSpacing(Number(e.target.value))}
className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium" className="text-xs px-1.5 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700"
> >
<option value={50}>50px</option>
<option value={100}>100px</option>
<option value={200}>200px</option>
</select>
<button onClick={onNext} className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 text-sm font-medium">
Weiter Weiter
</button> </button>
</div> </div>
</div> </div>
{/* Page container */} {/* Split view */}
<div <div className="flex gap-2" style={{ height: panelHeight > 0 ? `${panelHeight + 40}px` : '600px' }}>
ref={containerRef} {/* LEFT: Original + OCR overlay */}
className="relative bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden shadow-lg" <div ref={leftRef} className="flex-1 relative border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-900">
style={{ height: containerHeight > 0 ? `${containerHeight}px` : 'auto' }} <div className="absolute top-0 left-0 px-2 py-0.5 bg-black/60 text-white text-[10px] font-medium rounded-br z-20">
> Original + OCR
{/* Original image background (toggleable) */} </div>
{showOriginal && sessionId && (
<img
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`}
alt="Original"
className="absolute inset-0 w-full h-full object-contain opacity-15"
/>
)}
{/* Render zones */} {/* Scan image */}
{grid.zones.map((zone) => ( {sessionId && (
<ZoneRenderer <img
key={zone.zone_index} src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`}
zone={zone} alt="Original"
scale={scale} className="absolute inset-0 w-full h-auto"
fontSize={scaledFont} style={{ height: `${panelHeight}px`, objectFit: 'contain' }}
/> />
))} )}
{/* OCR word overlay (red text) */}
{allWordBoxes.map((wb, i) => (
<div
key={i}
className="absolute text-red-500 font-mono leading-none pointer-events-none"
style={{
left: `${wb.left * scale}px`,
top: `${wb.top * scale}px`,
fontSize: `${Math.max(6, wb.height * scale * 0.75)}px`,
whiteSpace: 'nowrap',
}}
>
{wb.text}
</div>
))}
{/* Coordinate grid */}
{showGrid && <CoordinateGrid imgW={imgW} imgH={imgH} scale={scale} spacing={gridSpacing} />}
</div>
{/* RIGHT: Reconstruction */}
<div className="flex-1 relative border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-900">
<div className="absolute top-0 left-0 px-2 py-0.5 bg-teal-600/80 text-white text-[10px] font-medium rounded-br z-20">
Rekonstruktion
</div>
{/* Rendered zones */}
{grid.zones.map((zone) => (
<ZoneRenderer key={zone.zone_index} zone={zone} scale={scale} fontSize={scaledFont} />
))}
{/* Coordinate grid */}
{showGrid && <CoordinateGrid imgW={imgW} imgH={imgH} scale={scale} spacing={gridSpacing} />}
</div>
</div> </div>
</div> </div>
) )
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Zone renderer // 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(
<div key={`v${x}`} className="absolute top-0 bottom-0 pointer-events-none" style={{ left: `${px}px`, width: '1px', background: 'rgba(0,150,255,0.2)' }}>
<span className="absolute top-0 left-1 text-[8px] text-blue-400 font-mono">{x}</span>
</div>
)
}
// Horizontal lines
for (let y = 0; y <= imgH; y += spacing) {
const px = y * scale
lines.push(
<div key={`h${y}`} className="absolute left-0 right-0 pointer-events-none" style={{ top: `${px}px`, height: '1px', background: 'rgba(0,150,255,0.2)' }}>
<span className="absolute left-1 top-0.5 text-[8px] text-blue-400 font-mono">{y}</span>
</div>
)
}
return <>{lines}</>
}
// ---------------------------------------------------------------------------
// Zone renderer (reconstruction side)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function ZoneRenderer({ zone, scale, fontSize }: { function ZoneRenderer({ zone, scale, fontSize }: {
zone: GridZone zone: GridZone; scale: number; fontSize: number
scale: number
fontSize: number
}) { }) {
const isBox = zone.zone_type === 'box' const isBox = zone.zone_type === 'box'
const boxColor = (zone as any).box_bg_hex || '#6b7280' const boxColor = (zone as any).box_bg_hex || '#6b7280'
@@ -162,23 +221,20 @@ function ZoneRenderer({ zone, scale, fontSize }: {
const width = zone.bbox_px.w * scale const width = zone.bbox_px.w * scale
const height = zone.bbox_px.h * scale const height = zone.bbox_px.h * scale
// Build cell map
const cellMap = new Map<string, GridEditorCell>() const cellMap = new Map<string, GridEditorCell>()
for (const cell of zone.cells) { for (const cell of zone.cells) {
cellMap.set(`${cell.row_index}_${cell.col_index}`, cell) cellMap.set(`${cell.row_index}_${cell.col_index}`, cell)
} }
// Column widths (relative to zone) // Column widths scaled to zone
const colWidths = zone.columns.map((col) => { const colWidths = zone.columns.map((col) => {
const w = (col.x_max_px ?? 0) - (col.x_min_px ?? 0) const w = (col.x_max_px ?? 0) - (col.x_min_px ?? 0)
return Math.max(10, w * scale) return Math.max(5, w * scale)
}) })
const totalColW = colWidths.reduce((s, w) => s + w, 0) const totalColW = colWidths.reduce((s, w) => s + w, 0)
// Scale columns to fit zone width
const colScale = totalColW > 0 ? width / totalColW : 1 const colScale = totalColW > 0 ? width / totalColW : 1
const scaledColWidths = colWidths.map((w) => w * colScale) const scaledColWidths = colWidths.map((w) => w * colScale)
const gridTemplateCols = scaledColWidths.map((w) => `${w.toFixed(1)}px`).join(' ')
const numCols = zone.columns.length const numCols = zone.columns.length
return ( return (
@@ -189,47 +245,34 @@ function ZoneRenderer({ zone, scale, fontSize }: {
top: `${top}px`, top: `${top}px`,
width: `${width}px`, width: `${width}px`,
minHeight: `${height}px`, minHeight: `${height}px`,
border: isBox ? `${Math.max(2, 3 * scale)}px solid ${boxColor}` : undefined, border: isBox ? `${Math.max(1.5, 2.5 * scale)}px solid ${boxColor}` : undefined,
backgroundColor: isBox ? `${boxColor}08` : undefined, backgroundColor: isBox ? `${boxColor}08` : undefined,
borderRadius: isBox ? `${Math.max(2, 4 * scale)}px` : undefined, borderRadius: isBox ? `${Math.max(1, 3 * scale)}px` : undefined,
fontSize: `${fontSize}px`, fontSize: `${fontSize}px`,
lineHeight: '1.3', lineHeight: '1.25',
}} }}
> >
<div <div style={{ display: 'grid', gridTemplateColumns: scaledColWidths.map((w) => `${w.toFixed(1)}px`).join(' ') }}>
className="w-full"
style={{
display: 'grid',
gridTemplateColumns: gridTemplateCols,
}}
>
{zone.rows.map((row) => { {zone.rows.map((row) => {
const isSpanning = zone.cells.some( const isSpanning = zone.cells.some((c) => c.row_index === row.index && c.col_type === 'spanning_header')
(c) => c.row_index === row.index && c.col_type === 'spanning_header',
)
// Row height from measured px
const measuredH = (row.y_max_px ?? 0) - (row.y_min_px ?? 0) const measuredH = (row.y_max_px ?? 0) - (row.y_min_px ?? 0)
const rowH = Math.max(fontSize * 1.4, measuredH * scale) const rowH = Math.max(fontSize * 1.3, measuredH * scale)
return ( return (
<div key={row.index} style={{ display: 'contents' }}> <div key={row.index} style={{ display: 'contents' }}>
{isSpanning ? ( {isSpanning ? (
// Render spanning cells
zone.cells zone.cells
.filter((c) => c.row_index === row.index && c.col_type === 'spanning_header') .filter((c) => c.row_index === row.index && c.col_type === 'spanning_header')
.sort((a, b) => a.col_index - b.col_index) .sort((a, b) => a.col_index - b.col_index)
.map((cell) => { .map((cell) => {
const colspan = (cell as any).colspan || numCols const colspan = cell.colspan || numCols
const gridColStart = cell.col_index + 1
const gridColEnd = gridColStart + colspan
const color = getCellColor(cell) const color = getCellColor(cell)
return ( return (
<div <div
key={cell.cell_id} key={cell.cell_id}
className={`px-1 overflow-hidden ${row.is_header ? 'font-bold' : ''}`} className={`px-0.5 overflow-hidden ${row.is_header ? 'font-bold' : ''}`}
style={{ style={{
gridColumn: `${gridColStart} / ${gridColEnd}`, gridColumn: `${cell.col_index + 1} / ${cell.col_index + 1 + colspan}`,
minHeight: `${rowH}px`, minHeight: `${rowH}px`,
color: color || undefined, color: color || undefined,
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
@@ -240,26 +283,22 @@ function ZoneRenderer({ zone, scale, fontSize }: {
) )
}) })
) : ( ) : (
// Render normal columns
zone.columns.map((col) => { zone.columns.map((col) => {
const cell = cellMap.get(`${row.index}_${col.index}`) const cell = cellMap.get(`${row.index}_${col.index}`)
if (!cell) { if (!cell) return <div key={col.index} style={{ minHeight: `${rowH}px` }} />
return <div key={col.index} style={{ minHeight: `${rowH}px` }} />
}
const color = getCellColor(cell) const color = getCellColor(cell)
const isBold = col.bold || cell.is_bold || row.is_header const isBold = col.bold || cell.is_bold || row.is_header
const text = cell.text ?? '' const text = cell.text ?? ''
const isMultiLine = text.includes('\n')
return ( return (
<div <div
key={col.index} key={col.index}
className={`px-1 overflow-hidden ${isBold ? 'font-bold' : ''}`} className={`px-0.5 overflow-hidden ${isBold ? 'font-bold' : ''}`}
style={{ style={{
minHeight: `${rowH}px`, minHeight: `${rowH}px`,
color: color || undefined, color: color || undefined,
whiteSpace: isMultiLine ? 'pre-wrap' : 'nowrap', whiteSpace: text.includes('\n') ? 'pre-wrap' : 'nowrap',
textOverflow: isMultiLine ? undefined : 'ellipsis', textOverflow: text.includes('\n') ? undefined : 'ellipsis',
}} }}
> >
{text} {text}