Unified Grid: merge all zones into single Excel-like grid
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 32s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-klausur (push) Failing after 2m35s
CI / test-python-agent-core (push) Successful in 31s
CI / test-nodejs-website (push) Successful in 33s

Backend (unified_grid.py):
- build_unified_grid(): merges content + box zones into one zone
- Dominant row height from median of content row spacings
- Full-width boxes: rows integrated directly
- Partial-width boxes: extra rows inserted when box has more text
  lines than standard rows fit (e.g., 7 lines in 5-row height)
- Box-origin cells tagged with source_zone_type + box_region metadata

Backend (grid_editor_api.py):
- POST /sessions/{id}/build-unified-grid → persists as unified_grid_result
- GET /sessions/{id}/unified-grid → retrieve persisted result

Frontend:
- GridEditorCell: added source_zone_type, box_region fields
- GridTable: box-origin cells get tinted background + left border
- StepAnsicht: split-view with original image (left) + editable
  unified GridTable (right). Auto-builds on first load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-14 23:37:55 +02:00
parent 7085c87618
commit c1a903537b
5 changed files with 623 additions and 448 deletions

View File

@@ -1,20 +1,16 @@
'use client'
/**
* StepAnsicht — Split-view page layout comparison.
* StepAnsicht — Unified Grid View.
*
* Left: Original scan with OCR word overlay
* Right: Reconstructed layout with averaged row heights per section
*
* 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).
* Right: Unified grid (single zone, boxes integrated) rendered via GridTable
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useGridEditor } from '@/components/grid-editor/useGridEditor'
import type { GridZone, GridEditorCell, GridRow } from '@/components/grid-editor/types'
import { GridTable } from '@/components/grid-editor/GridTable'
import type { GridZone } from '@/components/grid-editor/types'
const KLAUSUR_API = '/klausur-api'
@@ -23,491 +19,172 @@ interface StepAnsichtProps {
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 {
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 gridEditor = useGridEditor(sessionId)
const {
loading, error, selectedCell, setSelectedCell,
updateCellText, toggleColumnBold, toggleRowHeader,
getAdjacentCell, deleteColumn, addColumn, deleteRow, addRow,
commitUndoPoint, selectedCells, toggleCellSelection,
clearCellSelection, toggleSelectedBold, setCellColor,
saveGrid, saving, dirty, undo, redo, canUndo, canRedo,
} = gridEditor
const [unifiedGrid, setUnifiedGrid] = useState<any>(null)
const [building, setBuilding] = useState(false)
const [buildError, setBuildError] = useState<string | null>(null)
const leftRef = useRef<HTMLDivElement>(null)
const [panelWidth, setPanelWidth] = useState(0)
const [showGrid, setShowGrid] = useState(true)
const [gridSpacing, setGridSpacing] = useState(100)
const [leftHeight, setLeftHeight] = useState(600)
const [showGrid, setShowGrid] = useState(false)
// Build unified grid
const buildUnified = useCallback(async () => {
if (!sessionId) return
setBuilding(true)
setBuildError(null)
try {
const res = await fetch(
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/build-unified-grid`,
{ method: 'POST' },
)
if (!res.ok) {
const d = await res.json().catch(() => ({}))
throw new Error(d.detail || `HTTP ${res.status}`)
}
const data = await res.json()
setUnifiedGrid(data)
} catch (e) {
setBuildError(e instanceof Error ? e.message : String(e))
} finally {
setBuilding(false)
}
}, [sessionId])
// Load unified grid on mount (or build if missing)
useEffect(() => {
if (sessionId) loadGrid()
if (!sessionId) return
;(async () => {
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/unified-grid`)
if (res.ok) {
setUnifiedGrid(await res.json())
} else {
// Not built yet — build it
buildUnified()
}
} catch {
buildUnified()
}
})()
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
// Track left panel height for sync
useEffect(() => {
if (!leftRef.current) return
const ro = new ResizeObserver(([entry]) => setPanelWidth(entry.contentRect.width))
const ro = new ResizeObserver(([e]) => setLeftHeight(e.contentRect.height))
ro.observe(leftRef.current)
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)
const unifiedZone: GridZone | null = unifiedGrid?.zones?.[0] ?? null
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 only if they fall FULLY inside a box (both Y and X overlap).
// Small boxes (e.g. on the right half) don't cover left-side content rows.
const rowCells = contentZone!.cells.filter((c) => c.row_index === row.index)
const rowXMin = rowCells.length > 0
? Math.min(...rowCells.map((c) => c.bbox_px?.x ?? contentZone!.bbox_px.x))
: contentZone!.bbox_px.x
const insideBox = boxBounds.some((bb) => {
if (ry < bb.yStart || ry > bb.yEnd) return false
// Check horizontal overlap: row must be mostly inside box x-range
const boxXMin = bb.zone.bbox_px.x
const boxXMax = boxXMin + bb.zone.bbox_px.w
return rowXMin >= boxXMin - 20 && rowXMin <= boxXMax
})
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 || building) {
return (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-4 border-teal-500 border-t-transparent rounded-full animate-spin" />
<span className="ml-3 text-gray-500">Lade Vorschau...</span>
<span className="ml-3 text-gray-500">{building ? 'Baue Unified Grid...' : 'Lade...'}</span>
</div>
)
}
if (error || !grid) {
return (
<div className="p-8 text-center">
<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">Weiter </button>
</div>
)
}
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 contentZone = grid.zones.find((z) => z.zone_type === 'content')
// Dominant row height: median of row-to-row spacings (excluding box-gap jumps)
const dominantRowH = useMemo(() => {
const cz = grid.zones.find((z) => z.zone_type === 'content')
if (!cz || cz.rows.length < 2) return 47
const spacings: number[] = []
for (let i = 0; i < cz.rows.length - 1; i++) {
const y1 = cz.rows[i].y_min_px ?? (cz.rows[i] as any).y_min ?? 0
const y2 = cz.rows[i + 1].y_min_px ?? (cz.rows[i + 1] as any).y_min ?? 0
const d = y2 - y1
if (d > 0 && d < 100) spacings.push(d)
}
if (spacings.length === 0) return 47
spacings.sort((a, b) => a - b)
return spacings[Math.floor(spacings.length / 2)]
}, [grid])
return (
<div className="space-y-3">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Ansicht Original vs. Rekonstruktion</h3>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Ansicht Unified Grid</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Links: Original mit OCR. Rechts: Rekonstruktion mit gemittelten Zeilenhöhen.
Alle Inhalte in einem Grid. Boxen sind integriert (farbig markiert).
{unifiedGrid && (
<span className="ml-2 font-mono text-xs">
{unifiedGrid.summary?.total_rows} Zeilen × {unifiedGrid.summary?.total_columns} Spalten
{unifiedGrid.dominant_row_h && ` · Zeilenhöhe: ${Math.round(unifiedGrid.dominant_row_h)}px`}
</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs text-gray-500">
<input type="checkbox" checked={showGrid} onChange={(e) => setShowGrid(e.target.checked)} className="w-3.5 h-3.5 rounded" />
Gitter
</label>
<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">
<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 </button>
<button
onClick={buildUnified}
disabled={building}
className="px-3 py-1.5 bg-amber-600 text-white rounded-lg hover:bg-amber-700 text-xs font-medium disabled:opacity-50"
>
{building ? 'Baut...' : 'Neu aufbauen'}
</button>
<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>
</div>
</div>
{(error || buildError) && (
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg text-red-700 dark:text-red-300 text-sm">
{error || buildError}
</div>
)}
{/* Split view */}
<div className="flex gap-2" style={{ height: `${panelHeight + 40}px` }}>
<div className="flex gap-2">
{/* 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 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>
<div ref={leftRef} className="w-1/3 border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-900 flex-shrink-0">
<div className="px-2 py-1 bg-black/60 text-white text-[10px] font-medium">Original + OCR</div>
{sessionId && (
<img
src={`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/words-overlay`}
alt="Original + OCR"
className="absolute inset-0 w-full h-auto"
style={{ height: `${panelHeight}px`, objectFit: 'contain' }}
className="w-full h-auto"
/>
)}
{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>
{/* Render sections sequentially */}
{sections.map((sec, si) => {
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={dominantRowH}
/>
)
}
return null
})}
{showGrid && <CoordinateGrid imgW={imgW} imgH={imgH} scale={scale} spacing={gridSpacing} />}
</div>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeContentSection(rows: GridRow[]): PageSection {
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
let avgRowH = 35
if (rows.length >= 2) {
avgRowH = (ys[ys.length - 1] - ys[0]) / (rows.length - 1)
}
return { type: 'content', yStart: ys[0], yEnd, rows, avgRowH }
}
// ---------------------------------------------------------------------------
// Content section renderer — rows from content zone at absolute positions
// ---------------------------------------------------------------------------
function ContentSectionRenderer({ zone, rows, yStart, scale, avgRowH }: {
zone: GridZone; rows: GridRow[]; yStart: number; scale: number; avgRowH: number
}) {
const cellMap = new Map<string, GridEditorCell>()
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>
)
})
{/* RIGHT: Unified Grid Table */}
<div className="flex-1 border border-gray-300 dark:border-gray-600 rounded-lg overflow-auto bg-white dark:bg-gray-900" style={{ maxHeight: `${Math.max(600, leftHeight)}px` }}>
<div className="px-2 py-1 bg-teal-600/80 text-white text-[10px] font-medium sticky top-0 z-20">
Unified Grid
{unifiedGrid?.is_unified && (
<span className="ml-2 opacity-70">
({unifiedGrid.summary?.total_rows}×{unifiedGrid.summary?.total_columns})
</span>
)}
</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
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 rowH = avgRowH * scale
const fontSize = Math.max(7, rowH * 0.5)
const cellMap = new Map<string, GridEditorCell>()
for (const cell of zone.cells) {
cellMap.set(`${cell.row_index}_${cell.col_index}`, cell)
}
const colWidths = zone.columns.map((col) => Math.max(5, ((col.x_max_px ?? 0) - (col.x_min_px ?? 0)) * scale))
const totalColW = colWidths.reduce((s, w) => s + w, 0)
const colScale = totalColW > 0 ? width / totalColW : 1
const numCols = zone.columns.length
// Distribute box height proportionally by text line count per row
const rowLineCounts = zone.rows.map((row) => {
const maxLines = Math.max(1, ...zone.cells
.filter((c) => c.row_index === row.index)
.map((c) => (c.text ?? '').split('\n').length))
return maxLines
})
const totalLines = rowLineCounts.reduce((s, n) => s + n, 0)
const lineUnitH = totalLines > 0 ? height / totalLines : height
return (
<div
className="absolute"
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
border: `${Math.max(1.5, 2 * scale)}px solid ${boxColor}`,
backgroundColor: `${boxColor}0a`,
borderRadius: `${Math.max(1, 3 * scale)}px`,
fontSize: `${fontSize}px`,
lineHeight: '1.3',
}}
>
<div style={{ display: 'grid', gridTemplateColumns: colWidths.map((w) => `${(w * colScale).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')
// Height proportional to text line count
const rowLines = rowLineCounts[rowIdx] || 1
const cellRowH = lineUnitH * rowLines
return (
<div key={row.index} style={{ display: 'contents' }}>
{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 (
<div
key={cell.cell_id}
className={`px-1 overflow-hidden ${row.is_header ? 'font-bold' : ''}`}
style={{
gridColumn: `${cell.col_index + 1} / ${cell.col_index + 1 + colspan}`,
height: `${cellRowH}px`,
color: color || undefined,
whiteSpace: 'pre-wrap',
display: 'flex',
alignItems: 'center',
}}
>
{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
const text = cell?.text ?? ''
const isMultiLine = text.includes('\n')
return (
<div
key={col.index}
className={`px-1 overflow-hidden ${isBold ? 'font-bold' : ''}`}
style={{
height: `${cellRowH}px`,
color: color || undefined,
whiteSpace: isMultiLine ? 'pre-wrap' : 'nowrap',
textOverflow: isMultiLine ? undefined : 'ellipsis',
display: 'flex',
alignItems: isMultiLine ? 'flex-start' : 'center',
paddingLeft: isMultiLine ? `${fontSize * 0.5}px` : undefined,
}}
>
{text}
</div>
)
})
)}
{unifiedZone ? (
<GridTable
zone={unifiedZone}
selectedCell={selectedCell}
selectedCells={selectedCells}
onSelectCell={setSelectedCell}
onCellTextChange={updateCellText}
onToggleColumnBold={toggleColumnBold}
onToggleRowHeader={toggleRowHeader}
onNavigate={(cellId, dir) => {
const next = getAdjacentCell(cellId, dir)
if (next) setSelectedCell(next)
}}
onDeleteColumn={deleteColumn}
onAddColumn={addColumn}
onDeleteRow={deleteRow}
onAddRow={addRow}
onToggleCellSelection={toggleCellSelection}
onSetCellColor={setCellColor}
/>
) : (
<div className="p-8 text-center text-gray-400">
<p>Kein Unified Grid verfügbar.</p>
<button onClick={buildUnified} className="mt-2 text-teal-600 text-sm">Jetzt aufbauen</button>
</div>
)
})}
)}
</div>
</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}</>
}