Phase 1 — Python (klausur-service): 5 monoliths → 36 files - dsfa_corpus_ingestion.py (1,828 LOC → 5 files) - cv_ocr_engines.py (2,102 LOC → 7 files) - cv_layout.py (3,653 LOC → 10 files) - vocab_worksheet_api.py (2,783 LOC → 8 files) - grid_build_core.py (1,958 LOC → 6 files) Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files - staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3) - policy_handlers.go (700 → 2), repository.go (684 → 2) - search.go (592 → 2), ai_extraction_handlers.go (554 → 2) - seed_data.go (591 → 2), grade_service.go (646 → 2) Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files - sdk/types.ts (2,108 → 16 domain files) - ai/rag/page.tsx (2,686 → 14 files) - 22 page.tsx files split into _components/ + _hooks/ - 11 component files split into sub-components - 10 SDK data catalogs added to loc-exceptions - Deleted dead backup index_original.ts (4,899 LOC) All original public APIs preserved via re-export facades. Zero new errors: Python imports verified, Go builds clean, TypeScript tsc --noEmit shows only pre-existing errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
265 lines
9.6 KiB
TypeScript
265 lines
9.6 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import type { ExcludeRegion, StructureResult } from '@/app/(admin)/ai/ocr-kombi/types'
|
|
import {
|
|
KLAUSUR_API,
|
|
getDocLayoutColor,
|
|
imageToOverlayPct,
|
|
mouseToImageCoords,
|
|
} from './structure-detection-utils'
|
|
|
|
interface StructureImageComparisonProps {
|
|
sessionId: string
|
|
result: StructureResult
|
|
overlayTs: number
|
|
excludeRegions: ExcludeRegion[]
|
|
drawMode: boolean
|
|
onAddRegion: (region: ExcludeRegion) => void
|
|
onDeleteRegion: (index: number) => void
|
|
}
|
|
|
|
export function StructureImageComparison({
|
|
sessionId,
|
|
result,
|
|
overlayTs,
|
|
excludeRegions,
|
|
drawMode,
|
|
onAddRegion,
|
|
onDeleteRegion,
|
|
}: StructureImageComparisonProps) {
|
|
// Exclude region drawing state
|
|
const [drawing, setDrawing] = useState(false)
|
|
const [drawStart, setDrawStart] = useState<{ x: number; y: number } | null>(null)
|
|
const [drawCurrent, setDrawCurrent] = useState<{ x: number; y: number } | null>(null)
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const overlayContainerRef = useRef<HTMLDivElement>(null)
|
|
const [containerSize, setContainerSize] = useState({ w: 0, h: 0 })
|
|
const [overlayContainerSize, setOverlayContainerSize] = useState({ w: 0, h: 0 })
|
|
|
|
// Track container size for overlay positioning
|
|
useEffect(() => {
|
|
const el = containerRef.current
|
|
if (!el) return
|
|
const obs = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
setContainerSize({ w: entry.contentRect.width, h: entry.contentRect.height })
|
|
}
|
|
})
|
|
obs.observe(el)
|
|
return () => obs.disconnect()
|
|
}, [])
|
|
|
|
// Track overlay container size for PP-DocLayout region overlays
|
|
useEffect(() => {
|
|
const el = overlayContainerRef.current
|
|
if (!el) return
|
|
const obs = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
setOverlayContainerSize({ w: entry.contentRect.width, h: entry.contentRect.height })
|
|
}
|
|
})
|
|
obs.observe(el)
|
|
return () => obs.disconnect()
|
|
}, [])
|
|
|
|
// Mouse handlers for drawing exclude rectangles
|
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
if (!drawMode || !containerRef.current) return
|
|
const coords = mouseToImageCoords(e, containerRef.current, result.image_width, result.image_height)
|
|
if (coords) {
|
|
setDrawing(true)
|
|
setDrawStart(coords)
|
|
setDrawCurrent(coords)
|
|
}
|
|
}, [drawMode, result])
|
|
|
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
if (!drawing || !containerRef.current) return
|
|
const coords = mouseToImageCoords(e, containerRef.current, result.image_width, result.image_height)
|
|
if (coords) {
|
|
setDrawCurrent(coords)
|
|
}
|
|
}, [drawing, result])
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
if (!drawing || !drawStart || !drawCurrent) {
|
|
setDrawing(false)
|
|
return
|
|
}
|
|
|
|
const x = Math.min(drawStart.x, drawCurrent.x)
|
|
const y = Math.min(drawStart.y, drawCurrent.y)
|
|
const w = Math.abs(drawCurrent.x - drawStart.x)
|
|
const h = Math.abs(drawCurrent.y - drawStart.y)
|
|
|
|
// Minimum size to avoid accidental clicks
|
|
if (w > 10 && h > 10) {
|
|
const newRegion: ExcludeRegion = { x, y, w, h, label: `Bereich ${excludeRegions.length + 1}` }
|
|
onAddRegion(newRegion)
|
|
}
|
|
|
|
setDrawing(false)
|
|
setDrawStart(null)
|
|
setDrawCurrent(null)
|
|
}, [drawing, drawStart, drawCurrent, excludeRegions.length, onAddRegion])
|
|
|
|
const croppedUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
|
|
const overlayUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/structure-overlay${overlayTs ? `?t=${overlayTs}` : ''}`
|
|
|
|
// Current drag rectangle in image coords
|
|
const dragRect = drawing && drawStart && drawCurrent
|
|
? {
|
|
x: Math.min(drawStart.x, drawCurrent.x),
|
|
y: Math.min(drawStart.y, drawCurrent.y),
|
|
w: Math.abs(drawCurrent.x - drawStart.x),
|
|
h: Math.abs(drawCurrent.y - drawStart.y),
|
|
}
|
|
: null
|
|
|
|
return (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{/* Left: Original document with exclude region drawing */}
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Original {excludeRegions.length > 0 && `(${excludeRegions.length} Ausschlussbereich${excludeRegions.length !== 1 ? 'e' : ''})`}
|
|
</div>
|
|
<div
|
|
ref={containerRef}
|
|
className={`relative bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden ${
|
|
drawMode ? 'cursor-crosshair' : ''
|
|
}`}
|
|
style={{ aspectRatio: '210/297' }}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={() => {
|
|
if (drawing) {
|
|
handleMouseUp()
|
|
}
|
|
}}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={croppedUrl}
|
|
alt="Originaldokument"
|
|
className="w-full h-full object-contain pointer-events-none"
|
|
draggable={false}
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).style.display = 'none'
|
|
}}
|
|
/>
|
|
|
|
{/* Saved exclude regions overlay */}
|
|
{containerSize.w > 0 && excludeRegions.map((region, i) => {
|
|
const pos = imageToOverlayPct(region, containerSize.w, containerSize.h, result.image_width, result.image_height)
|
|
return (
|
|
<div
|
|
key={i}
|
|
className="absolute border-2 border-red-500 bg-red-500/20 group"
|
|
style={pos}
|
|
>
|
|
<div className="absolute -top-5 left-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<span className="text-[10px] bg-red-600 text-white px-1 rounded whitespace-nowrap">
|
|
{region.label || `Bereich ${i + 1}`}
|
|
</span>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onDeleteRegion(i) }}
|
|
className="w-4 h-4 bg-red-600 text-white rounded-full text-[10px] flex items-center justify-center hover:bg-red-700"
|
|
>
|
|
x
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Current drag rectangle */}
|
|
{dragRect && containerSize.w > 0 && (() => {
|
|
const pos = imageToOverlayPct(dragRect, containerSize.w, containerSize.h, result.image_width, result.image_height)
|
|
return (
|
|
<div
|
|
className="absolute border-2 border-red-500 border-dashed bg-red-500/15"
|
|
style={pos}
|
|
/>
|
|
)
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Structure overlay */}
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Erkannte Struktur
|
|
{result.detection_method && (
|
|
<span className="ml-2 text-[10px] font-normal normal-case">
|
|
({result.detection_method === 'ppdoclayout' ? 'PP-DocLayout' : 'OpenCV'})
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div
|
|
ref={overlayContainerRef}
|
|
className="relative bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden"
|
|
style={{ aspectRatio: '210/297' }}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={overlayUrl}
|
|
alt="Strukturerkennung"
|
|
className="w-full h-full object-contain"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).style.display = 'none'
|
|
}}
|
|
/>
|
|
|
|
{/* PP-DocLayout region overlays with class colors and labels */}
|
|
{result.layout_regions && overlayContainerSize.w > 0 && result.layout_regions.map((region, i) => {
|
|
const pos = imageToOverlayPct(region, overlayContainerSize.w, overlayContainerSize.h, result.image_width, result.image_height)
|
|
const color = getDocLayoutColor(region.class_name)
|
|
return (
|
|
<div
|
|
key={`layout-${i}`}
|
|
className="absolute border-2 pointer-events-none"
|
|
style={{
|
|
...pos,
|
|
borderColor: color,
|
|
backgroundColor: `${color}18`,
|
|
}}
|
|
>
|
|
<span
|
|
className="absolute -top-4 left-0 px-1 py-px text-[9px] font-medium text-white rounded-sm whitespace-nowrap leading-tight"
|
|
style={{ backgroundColor: color }}
|
|
>
|
|
{region.class_name} {Math.round(region.confidence * 100)}%
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* PP-DocLayout legend */}
|
|
{result.layout_regions && result.layout_regions.length > 0 && (() => {
|
|
const usedClasses = [...new Set(result.layout_regions!.map((r) => r.class_name.toLowerCase()))]
|
|
return (
|
|
<div className="flex flex-wrap gap-x-3 gap-y-1 px-1">
|
|
{usedClasses.sort().map((cls) => (
|
|
<span key={cls} className="inline-flex items-center gap-1 text-[10px] text-gray-500 dark:text-gray-400">
|
|
<span
|
|
className="w-2.5 h-2.5 rounded-sm border"
|
|
style={{
|
|
backgroundColor: `${getDocLayoutColor(cls)}30`,
|
|
borderColor: getDocLayoutColor(cls),
|
|
}}
|
|
/>
|
|
{cls}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|