StepAnsicht: section-based layout with averaged row heights
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 38s
CI / test-go-edu-search (push) Successful in 38s
CI / test-python-klausur (push) Failing after 2m28s
CI / test-python-agent-core (push) Successful in 34s
CI / test-nodejs-website (push) Successful in 40s
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 38s
CI / test-go-edu-search (push) Successful in 38s
CI / test-python-klausur (push) Failing after 2m28s
CI / test-python-agent-core (push) Successful in 34s
CI / test-nodejs-website (push) Successful in 40s
Major rewrite of reconstruction rendering: - Page split into vertical sections (content/box) around box boundaries - Content sections: uniform row height = (last_row - first_row) / (n-1) - Box sections: rows evenly distributed within box height - Content rows positioned absolutely at original y-coordinates - Font size derived from row height (55% of row height) - Multi-line cells (bullets) get expanded height with indentation - Boxes render at exact bbox position with colored border - Preparation for unified grid where boxes become part of main grid Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,15 +3,18 @@
|
|||||||
/**
|
/**
|
||||||
* StepAnsicht — Split-view page layout comparison.
|
* StepAnsicht — Split-view page layout comparison.
|
||||||
*
|
*
|
||||||
* Left: Original scan with OCR word overlay (red) + coordinate grid
|
* Left: Original scan with OCR word overlay
|
||||||
* Right: Reconstructed layout with all zones + coordinate grid
|
* Right: Reconstructed layout with averaged row heights per section
|
||||||
*
|
*
|
||||||
* Both sides share the same coordinate system for easy visual comparison.
|
* Layout principle: the page is divided into vertical sections separated
|
||||||
|
* by boxes. Each section gets a uniform row height calculated from
|
||||||
|
* (last_row_y - first_row_y) / (num_rows - 1). Boxes are rendered
|
||||||
|
* inline between sections (not as floating overlays).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useMemo, 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, GridRow } from '@/components/grid-editor/types'
|
||||||
|
|
||||||
const KLAUSUR_API = '/klausur-api'
|
const KLAUSUR_API = '/klausur-api'
|
||||||
|
|
||||||
@@ -20,6 +23,16 @@ interface StepAnsichtProps {
|
|||||||
onNext: () => void
|
onNext: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A vertical section of the page: either content rows or a box zone. */
|
||||||
|
interface PageSection {
|
||||||
|
type: 'content' | 'box'
|
||||||
|
yStart: number // pixel y in original image
|
||||||
|
yEnd: number // pixel y end
|
||||||
|
zone?: GridZone // for box sections
|
||||||
|
rows?: GridRow[] // for content sections — subset of content zone rows
|
||||||
|
avgRowH: number // averaged row height in original pixels
|
||||||
|
}
|
||||||
|
|
||||||
function getCellColor(cell: GridEditorCell | undefined): string | null {
|
function getCellColor(cell: GridEditorCell | undefined): string | null {
|
||||||
if (!cell) return null
|
if (!cell) return null
|
||||||
if (cell.color_override) return cell.color_override
|
if (cell.color_override) return cell.color_override
|
||||||
@@ -33,13 +46,12 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
|
|||||||
const leftRef = useRef<HTMLDivElement>(null)
|
const leftRef = useRef<HTMLDivElement>(null)
|
||||||
const [panelWidth, setPanelWidth] = useState(0)
|
const [panelWidth, setPanelWidth] = useState(0)
|
||||||
const [showGrid, setShowGrid] = useState(true)
|
const [showGrid, setShowGrid] = useState(true)
|
||||||
const [gridSpacing, setGridSpacing] = useState(100) // px in original coordinates
|
const [gridSpacing, setGridSpacing] = useState(100)
|
||||||
|
|
||||||
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 panel width
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!leftRef.current) return
|
if (!leftRef.current) return
|
||||||
const ro = new ResizeObserver(([entry]) => setPanelWidth(entry.contentRect.width))
|
const ro = new ResizeObserver(([entry]) => setPanelWidth(entry.contentRect.width))
|
||||||
@@ -47,6 +59,85 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
|
|||||||
return () => ro.disconnect()
|
return () => ro.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Build page sections: split content rows around box zones
|
||||||
|
const sections = useMemo(() => {
|
||||||
|
if (!grid) return []
|
||||||
|
const contentZone = grid.zones.find((z) => z.zone_type === 'content')
|
||||||
|
const boxZones = grid.zones.filter((z) => z.zone_type === 'box')
|
||||||
|
.sort((a, b) => a.bbox_px.y - b.bbox_px.y)
|
||||||
|
|
||||||
|
if (!contentZone) return []
|
||||||
|
|
||||||
|
const allRows = contentZone.rows
|
||||||
|
const result: PageSection[] = []
|
||||||
|
|
||||||
|
// Box boundaries sorted by y
|
||||||
|
const boxBounds = boxZones.map((bz) => ({
|
||||||
|
zone: bz,
|
||||||
|
yStart: bz.bbox_px.y,
|
||||||
|
yEnd: bz.bbox_px.y + bz.bbox_px.h,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Split content rows into sections around boxes
|
||||||
|
let currentRows: GridRow[] = []
|
||||||
|
let boxIdx = 0
|
||||||
|
|
||||||
|
for (const row of allRows) {
|
||||||
|
const ry = row.y_min_px ?? (row as any).y_min ?? 0
|
||||||
|
|
||||||
|
// Check if we've passed a box boundary — insert box section
|
||||||
|
while (boxIdx < boxBounds.length && ry >= boxBounds[boxIdx].yStart) {
|
||||||
|
// Flush current content section
|
||||||
|
if (currentRows.length > 0) {
|
||||||
|
result.push(makeContentSection(currentRows))
|
||||||
|
currentRows = []
|
||||||
|
}
|
||||||
|
// Insert box section
|
||||||
|
const bb = boxBounds[boxIdx]
|
||||||
|
const bRows = bb.zone.rows || []
|
||||||
|
let bAvgH = 35
|
||||||
|
if (bRows.length >= 2) {
|
||||||
|
const bys = bRows.map((r) => r.y_min_px ?? (r as any).y_min ?? 0)
|
||||||
|
bAvgH = (bys[bys.length - 1] - bys[0]) / (bRows.length - 1)
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
type: 'box',
|
||||||
|
yStart: bb.yStart,
|
||||||
|
yEnd: bb.yEnd,
|
||||||
|
zone: bb.zone,
|
||||||
|
avgRowH: bAvgH,
|
||||||
|
})
|
||||||
|
boxIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip rows that fall inside a box boundary
|
||||||
|
const insideBox = boxBounds.some((bb) => ry >= bb.yStart && ry <= bb.yEnd)
|
||||||
|
if (!insideBox) {
|
||||||
|
currentRows.push(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush remaining content rows
|
||||||
|
if (currentRows.length > 0) {
|
||||||
|
result.push(makeContentSection(currentRows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert remaining boxes (if any rows didn't trigger them)
|
||||||
|
while (boxIdx < boxBounds.length) {
|
||||||
|
const bb = boxBounds[boxIdx]
|
||||||
|
const bRows = bb.zone.rows || []
|
||||||
|
let bAvgH = 35
|
||||||
|
if (bRows.length >= 2) {
|
||||||
|
const bys = bRows.map((r) => r.y_min_px ?? (r as any).y_min ?? 0)
|
||||||
|
bAvgH = (bys[bys.length - 1] - bys[0]) / (bRows.length - 1)
|
||||||
|
}
|
||||||
|
result.push({ type: 'box', yStart: bb.yStart, yEnd: bb.yEnd, zone: bb.zone, avgRowH: bAvgH })
|
||||||
|
boxIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [grid])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16">
|
<div className="flex items-center justify-center py-16">
|
||||||
@@ -69,82 +160,79 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
|
|||||||
const imgH = grid.image_height || 1
|
const imgH = grid.image_height || 1
|
||||||
const scale = panelWidth > 0 ? panelWidth / imgW : 0.5
|
const scale = panelWidth > 0 ? panelWidth / imgW : 0.5
|
||||||
const panelHeight = imgH * scale
|
const panelHeight = imgH * scale
|
||||||
|
const contentZone = grid.zones.find((z) => z.zone_type === 'content')
|
||||||
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 (
|
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 — Original vs. Rekonstruktion</h3>
|
||||||
Ansicht — Original vs. Rekonstruktion
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Links: Original mit OCR-Overlay. Rechts: Rekonstruierte Seite. Koordinatengitter zum Abgleich.
|
Links: Original mit OCR. Rechts: Rekonstruktion mit gemittelten Zeilenhöhen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="flex items-center gap-1.5 text-xs text-gray-500">
|
<label className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
<input
|
<input type="checkbox" checked={showGrid} onChange={(e) => setShowGrid(e.target.checked)} className="w-3.5 h-3.5 rounded" />
|
||||||
type="checkbox"
|
|
||||||
checked={showGrid}
|
|
||||||
onChange={(e) => setShowGrid(e.target.checked)}
|
|
||||||
className="w-3.5 h-3.5 rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
Gitter
|
Gitter
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select value={gridSpacing} onChange={(e) => setGridSpacing(Number(e.target.value))} className="text-xs px-1.5 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700">
|
||||||
value={gridSpacing}
|
|
||||||
onChange={(e) => setGridSpacing(Number(e.target.value))}
|
|
||||||
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={50}>50px</option>
|
||||||
<option value={100}>100px</option>
|
<option value={100}>100px</option>
|
||||||
<option value={200}>200px</option>
|
<option value={200}>200px</option>
|
||||||
</select>
|
</select>
|
||||||
<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 hover:bg-teal-700 text-sm font-medium">Weiter →</button>
|
||||||
Weiter →
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Split view */}
|
{/* Split view */}
|
||||||
<div className="flex gap-2" style={{ height: panelHeight > 0 ? `${panelHeight + 40}px` : '600px' }}>
|
<div className="flex gap-2" style={{ height: `${panelHeight + 40}px` }}>
|
||||||
{/* LEFT: Original + OCR overlay */}
|
{/* LEFT: Original + OCR overlay */}
|
||||||
<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">
|
<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">
|
||||||
<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">
|
<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</div>
|
||||||
Original + OCR
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Server-rendered OCR overlay image (scan + red snapped letters) */}
|
|
||||||
{sessionId && (
|
{sessionId && (
|
||||||
<img
|
<img
|
||||||
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/words-overlay`}
|
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/words-overlay`}
|
||||||
alt="Original + OCR Overlay"
|
alt="Original + OCR"
|
||||||
className="absolute inset-0 w-full h-auto"
|
className="absolute inset-0 w-full h-auto"
|
||||||
style={{ height: `${panelHeight}px`, objectFit: 'contain' }}
|
style={{ height: `${panelHeight}px`, objectFit: 'contain' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Coordinate grid */}
|
|
||||||
{showGrid && <CoordinateGrid imgW={imgW} imgH={imgH} scale={scale} spacing={gridSpacing} />}
|
{showGrid && <CoordinateGrid imgW={imgW} imgH={imgH} scale={scale} spacing={gridSpacing} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT: Reconstruction */}
|
{/* 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="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">
|
<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>
|
||||||
Rekonstruktion
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rendered zones */}
|
{/* Render sections sequentially */}
|
||||||
{grid.zones.map((zone) => (
|
{sections.map((sec, si) => {
|
||||||
<ZoneRenderer key={zone.zone_index} zone={zone} scale={scale} fontSize={scaledFont} avgRowH={avgRowH} />
|
if (sec.type === 'box' && sec.zone) {
|
||||||
))}
|
return (
|
||||||
|
<BoxSectionRenderer
|
||||||
|
key={`box-${si}`}
|
||||||
|
zone={sec.zone}
|
||||||
|
scale={scale}
|
||||||
|
avgRowH={sec.avgRowH}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (sec.type === 'content' && sec.rows && contentZone) {
|
||||||
|
return (
|
||||||
|
<ContentSectionRenderer
|
||||||
|
key={`sec-${si}`}
|
||||||
|
zone={contentZone}
|
||||||
|
rows={sec.rows}
|
||||||
|
yStart={sec.yStart}
|
||||||
|
scale={scale}
|
||||||
|
avgRowH={sec.avgRowH}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Coordinate grid */}
|
|
||||||
{showGrid && <CoordinateGrid imgW={imgW} imgH={imgH} scale={scale} spacing={gridSpacing} />}
|
{showGrid && <CoordinateGrid imgW={imgW} imgH={imgH} scale={scale} spacing={gridSpacing} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,70 +241,133 @@ export function StepAnsicht({ sessionId, onNext }: StepAnsichtProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Coordinate grid overlay
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function CoordinateGrid({ imgW, imgH, scale, spacing }: {
|
function makeContentSection(rows: GridRow[]): PageSection {
|
||||||
imgW: number; imgH: number; scale: number; spacing: number
|
const ys = rows.map((r) => r.y_min_px ?? (r as any).y_min ?? 0)
|
||||||
}) {
|
const yEnd = rows[rows.length - 1].y_max_px ?? (rows[rows.length - 1] as any).y_max ?? ys[ys.length - 1] + 30
|
||||||
const lines: JSX.Element[] = []
|
let avgRowH = 35
|
||||||
|
if (rows.length >= 2) {
|
||||||
// Vertical lines
|
avgRowH = (ys[ys.length - 1] - ys[0]) / (rows.length - 1)
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
return { type: 'content', yStart: ys[0], yEnd, rows, avgRowH }
|
||||||
// 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)
|
// Content section renderer — rows from content zone at absolute positions
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function ZoneRenderer({ zone, scale, fontSize, avgRowH }: {
|
function ContentSectionRenderer({ zone, rows, yStart, scale, avgRowH }: {
|
||||||
zone: GridZone; scale: number; fontSize: number; avgRowH: number
|
zone: GridZone; rows: GridRow[]; yStart: number; scale: number; avgRowH: number
|
||||||
}) {
|
}) {
|
||||||
const isBox = zone.zone_type === 'box'
|
const cellMap = new Map<string, GridEditorCell>()
|
||||||
const boxColor = (zone as any).box_bg_hex || '#6b7280'
|
for (const cell of zone.cells) {
|
||||||
|
cellMap.set(`${cell.row_index}_${cell.col_index}`, cell)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowH = avgRowH * scale
|
||||||
|
const fontSize = Math.max(7, rowH * 0.55)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{rows.map((row, ri) => {
|
||||||
|
const rowY = (row.y_min_px ?? (row as any).y_min ?? 0) * scale
|
||||||
|
const isSpanning = zone.cells.some((c) => c.row_index === row.index && c.col_type === 'spanning_header')
|
||||||
|
|
||||||
|
// Column widths
|
||||||
|
const colWidths = zone.columns.map((col) => Math.max(5, ((col.x_max_px ?? 0) - (col.x_min_px ?? 0)) * scale))
|
||||||
|
const zoneLeft = zone.bbox_px.x * scale
|
||||||
|
const zoneWidth = zone.bbox_px.w * scale
|
||||||
|
const totalColW = colWidths.reduce((s, w) => s + w, 0)
|
||||||
|
const colScale = totalColW > 0 ? zoneWidth / totalColW : 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={row.index}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: `${zoneLeft}px`,
|
||||||
|
top: `${rowY}px`,
|
||||||
|
width: `${zoneWidth}px`,
|
||||||
|
height: `${rowH}px`,
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: colWidths.map((w) => `${(w * colScale).toFixed(1)}px`).join(' '),
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
lineHeight: `${rowH}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 || zone.columns.length
|
||||||
|
const color = getCellColor(cell)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={cell.cell_id}
|
||||||
|
className={`overflow-hidden ${row.is_header ? 'font-bold' : ''}`}
|
||||||
|
style={{ gridColumn: `${cell.col_index + 1} / ${cell.col_index + 1 + colspan}`, color: color || undefined }}
|
||||||
|
>
|
||||||
|
{cell.text}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
zone.columns.map((col) => {
|
||||||
|
const cell = cellMap.get(`${row.index}_${col.index}`)
|
||||||
|
const color = getCellColor(cell)
|
||||||
|
const isBold = col.bold || cell?.is_bold || row.is_header
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.index}
|
||||||
|
className={`overflow-hidden text-ellipsis whitespace-nowrap ${isBold ? 'font-bold' : ''}`}
|
||||||
|
style={{ color: color || undefined }}
|
||||||
|
>
|
||||||
|
{cell?.text ?? ''}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Box section renderer — box zone at absolute position with border
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function BoxSectionRenderer({ zone, scale, avgRowH }: {
|
||||||
|
zone: GridZone; scale: number; avgRowH: number
|
||||||
|
}) {
|
||||||
|
const boxColor = (zone as any).box_bg_hex || '#6b7280'
|
||||||
if (!zone.cells || zone.cells.length === 0) return null
|
if (!zone.cells || zone.cells.length === 0) return null
|
||||||
|
|
||||||
const left = zone.bbox_px.x * scale
|
const left = zone.bbox_px.x * scale
|
||||||
const top = zone.bbox_px.y * scale
|
const top = zone.bbox_px.y * scale
|
||||||
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
|
||||||
|
const rowH = avgRowH * scale
|
||||||
|
const fontSize = Math.max(7, rowH * 0.5)
|
||||||
|
|
||||||
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 scaled to zone
|
const colWidths = zone.columns.map((col) => Math.max(5, ((col.x_max_px ?? 0) - (col.x_min_px ?? 0)) * scale))
|
||||||
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 totalColW = colWidths.reduce((s, w) => s + w, 0)
|
||||||
const colScale = totalColW > 0 ? width / totalColW : 1
|
const colScale = totalColW > 0 ? width / totalColW : 1
|
||||||
const scaledColWidths = colWidths.map((w) => w * colScale)
|
|
||||||
|
|
||||||
const numCols = zone.columns.length
|
const numCols = zone.columns.length
|
||||||
|
|
||||||
|
// Evenly distribute rows within the box
|
||||||
|
const numRows = zone.rows.length
|
||||||
|
const evenRowH = numRows > 0 ? height / numRows : rowH
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute overflow-hidden"
|
className="absolute overflow-hidden"
|
||||||
@@ -224,31 +375,23 @@ function ZoneRenderer({ zone, scale, fontSize, avgRowH }: {
|
|||||||
left: `${left}px`,
|
left: `${left}px`,
|
||||||
top: `${top}px`,
|
top: `${top}px`,
|
||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
minHeight: `${height}px`,
|
height: `${height}px`,
|
||||||
border: isBox ? `${Math.max(1.5, 2.5 * scale)}px solid ${boxColor}` : undefined,
|
border: `${Math.max(1.5, 2 * scale)}px solid ${boxColor}`,
|
||||||
backgroundColor: isBox ? `${boxColor}08` : undefined,
|
backgroundColor: `${boxColor}0a`,
|
||||||
borderRadius: isBox ? `${Math.max(1, 3 * scale)}px` : undefined,
|
borderRadius: `${Math.max(1, 3 * scale)}px`,
|
||||||
fontSize: `${fontSize}px`,
|
fontSize: `${fontSize}px`,
|
||||||
lineHeight: '1.25',
|
lineHeight: '1.3',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: scaledColWidths.map((w) => `${w.toFixed(1)}px`).join(' ') }}>
|
<div style={{ display: 'grid', gridTemplateColumns: colWidths.map((w) => `${(w * colScale).toFixed(1)}px`).join(' ') }}>
|
||||||
{zone.rows.map((row, rowIdx) => {
|
{zone.rows.map((row) => {
|
||||||
const isSpanning = zone.cells.some((c) => c.row_index === row.index && c.col_type === 'spanning_header')
|
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)
|
// Multi-line 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
|
const maxLines = Math.max(1, ...zone.cells
|
||||||
.filter((c) => c.row_index === row.index)
|
.filter((c) => c.row_index === row.index)
|
||||||
.map((c) => (c.text ?? '').split('\n').length))
|
.map((c) => (c.text ?? '').split('\n').length))
|
||||||
const effectiveRowH = rowH * Math.max(1, maxLines)
|
const cellRowH = evenRowH * (maxLines > 1 ? maxLines * 0.7 : 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={row.index} style={{ display: 'contents' }}>
|
<div key={row.index} style={{ display: 'contents' }}>
|
||||||
@@ -262,12 +405,14 @@ function ZoneRenderer({ zone, scale, fontSize, avgRowH }: {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={cell.cell_id}
|
key={cell.cell_id}
|
||||||
className={`px-0.5 overflow-hidden ${row.is_header ? 'font-bold' : ''}`}
|
className={`px-1 overflow-hidden ${row.is_header ? 'font-bold' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
gridColumn: `${cell.col_index + 1} / ${cell.col_index + 1 + colspan}`,
|
gridColumn: `${cell.col_index + 1} / ${cell.col_index + 1 + colspan}`,
|
||||||
minHeight: `${effectiveRowH}px`,
|
height: `${cellRowH}px`,
|
||||||
color: color || undefined,
|
color: color || undefined,
|
||||||
whiteSpace: 'pre-wrap',
|
whiteSpace: 'pre-wrap',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cell.text}
|
{cell.text}
|
||||||
@@ -277,20 +422,23 @@ function ZoneRenderer({ zone, scale, fontSize, avgRowH }: {
|
|||||||
) : (
|
) : (
|
||||||
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) 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-0.5 overflow-hidden ${isBold ? 'font-bold' : ''}`}
|
className={`px-1 overflow-hidden ${isBold ? 'font-bold' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
minHeight: `${rowH}px`,
|
height: `${cellRowH}px`,
|
||||||
color: color || undefined,
|
color: color || undefined,
|
||||||
whiteSpace: text.includes('\n') ? 'pre-wrap' : 'nowrap',
|
whiteSpace: isMultiLine ? 'pre-wrap' : 'nowrap',
|
||||||
textOverflow: text.includes('\n') ? undefined : 'ellipsis',
|
textOverflow: isMultiLine ? undefined : 'ellipsis',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: isMultiLine ? 'flex-start' : 'center',
|
||||||
|
paddingLeft: isMultiLine ? `${fontSize * 0.5}px` : undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
@@ -305,3 +453,30 @@ function ZoneRenderer({ zone, scale, fontSize, avgRowH }: {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Coordinate grid
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function CoordinateGrid({ imgW, imgH, scale, spacing }: {
|
||||||
|
imgW: number; imgH: number; scale: number; spacing: number
|
||||||
|
}) {
|
||||||
|
const lines: JSX.Element[] = []
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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}</>
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user