diff --git a/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx b/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx index a16ce001..b7d55773 100644 --- a/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx +++ b/tools/sprite-generation/gui/src/pages/SpriteTheaterPage.tsx @@ -21,6 +21,7 @@ const MIN_CARD_WIDTH = 220 const GRID_GAP = 12 const GRID_PADDING = 16 const FALLBACK_INFO_HEIGHT = 68 +const HEADER_HEIGHT = 49 const CARD_ENTER_MS = 400 const CARD_SHIFT_MS = 300 @@ -71,38 +72,54 @@ interface GridDims { function useGridDimensions( containerRef: RefObject, + cardRefs: RefObject>, onResize: () => void, -): GridDims { +): GridDims & { recompute: () => void } { const [dims, setDims] = useState({ cols: 0, rows: 0, cardWidth: 0 }) const onResizeRef = useRef(onResize) onResizeRef.current = onResize + const measuredCardHeight = useRef(0) + + const compute = useCallback((): void => { + const el = containerRef.current + if (!el) return + + const rect = el.getBoundingClientRect() + const usableW = rect.width - 2 * GRID_PADDING + const usableH = rect.height - 2 * GRID_PADDING + if (usableW <= 0 || usableH <= 0) return + + const cols = Math.max(1, Math.floor((usableW + GRID_GAP) / (MIN_CARD_WIDTH + GRID_GAP))) + const cardWidth = (usableW - (cols - 1) * GRID_GAP) / cols + + // Measure actual card height from a rendered card if available + const firstCard = cardRefs.current?.values().next().value as HTMLDivElement | undefined + if (firstCard) { + measuredCardHeight.current = firstCard.getBoundingClientRect().height + } + const cardHeight = measuredCardHeight.current > 0 + ? measuredCardHeight.current + : cardWidth + FALLBACK_INFO_HEIGHT + + const rows = Math.max(1, Math.floor((usableH + GRID_GAP) / (cardHeight + GRID_GAP))) + setDims(prev => { + if (prev.cols === cols && prev.rows === rows && Math.abs(prev.cardWidth - cardWidth) < 1) return prev + return { cols, rows, cardWidth } + }) + onResizeRef.current() + }, [containerRef, cardRefs]) useEffect(() => { const el = containerRef.current if (!el) return - const compute = (): void => { - const rect = el.getBoundingClientRect() - const usableW = rect.width - 2 * GRID_PADDING - const usableH = rect.height - 2 * GRID_PADDING - const cols = Math.max(1, Math.floor((usableW + GRID_GAP) / (MIN_CARD_WIDTH + GRID_GAP))) - const cardWidth = (usableW - (cols - 1) * GRID_GAP) / cols - const cardHeight = cardWidth + INFO_HEIGHT - const rows = Math.max(1, Math.floor((usableH + GRID_GAP) / (cardHeight + GRID_GAP))) - setDims(prev => { - if (prev.cols === cols && prev.rows === rows && Math.abs(prev.cardWidth - cardWidth) < 1) return prev - return { cols, rows, cardWidth } - }) - onResizeRef.current() - } - const ro = new ResizeObserver(compute) ro.observe(el) compute() return () => ro.disconnect() - }, [containerRef]) + }, [containerRef, compute]) - return dims + return { ...dims, recompute: compute } } /* ── score badge ───────────────────────────────────────────────── */ @@ -129,10 +146,9 @@ interface CardProps { v: RecentVariant isNew: boolean index: number - cardWidth: number } -const Card = memo(forwardRef(function Card({ v, isNew, index, cardWidth }, ref) { +const Card = memo(forwardRef(function Card({ v, isNew, index }, ref) { const labeled = isLabeled(v) const conf = useMemo(() => confidence(v), [v.notes]) const scores = useMemo(() => parseScores(v.notes), [v.notes]) @@ -164,7 +180,6 @@ const Card = memo(forwardRef(function Card({ v, isNew border: cardBorder, position: 'relative', boxShadow: cardShadow, - width: cardWidth, willChange: 'transform', ...animStyle, }} @@ -241,11 +256,9 @@ const Card = memo(forwardRef(function Card({ v, isNew function LeavingCard({ v, rect, - cardWidth, }: { v: RecentVariant rect: DOMRect - cardWidth: number }): ReactNode { return (
- +
) } @@ -302,9 +315,37 @@ export default function SpriteTheaterPage(): ReactNode { variantsRef.current = variants const onResize = useCallback(() => { isResizeRef.current = true }, []) - const { cols, rows, cardWidth } = useGridDimensions(gridRef, onResize) - const visibleCount = cols * rows + const { cols, rows, recompute } = useGridDimensions(gridRef, cardRefs, onResize) const getCardRef = useCardRefCallbacks(cardRefs) + const hasRecomputedRef = useRef(false) + const [loadedRows, setLoadedRows] = useState(0) + const sentinelRef = useRef(null) + const totalCountRef = useRef(0) + + // Reset loaded rows when viewport rows change (resize), seed with viewport fit + useEffect(() => { + if (rows > 0) setLoadedRows(rows) + }, [rows]) + + // Lazy-load more rows when sentinel enters viewport + useEffect(() => { + const sentinel = sentinelRef.current + if (!sentinel || cols === 0) return + + const io = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + const totalRows = Math.ceil(totalCountRef.current / cols) + setLoadedRows(prev => Math.min(prev + rows, totalRows)) + } + }, + { root: gridRef.current, rootMargin: '200px' }, + ) + io.observe(sentinel) + return () => io.disconnect() + }, [cols, rows]) + + const visibleCount = cols > 0 ? loadedRows * cols : 20 // Clear "new" flags after animation useEffect(() => { @@ -320,6 +361,13 @@ export default function SpriteTheaterPage(): ReactNode { return () => clearTimeout(timer) }, [leaving]) + // Recompute grid once actual card height is measurable + useEffect(() => { + if (hasRecomputedRef.current || cardRefs.current.size === 0) return + hasRecomputedRef.current = true + requestAnimationFrame(recompute) + }, [variants, recompute]) + // Capture current rects before state update const captureRects = useCallback((): void => { const rects = new Map() @@ -360,14 +408,18 @@ export default function SpriteTheaterPage(): ReactNode { // Memoize sorted + visible to prevent effect re-fire const sorted = useMemo( - () => [...variants].sort((a, b) => - new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - ), + () => { + const s = [...variants].sort((a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ) + totalCountRef.current = s.length + return s + }, [variants], ) const visible = useMemo( - () => visibleCount > 0 ? sorted.slice(0, visibleCount) : sorted.slice(0, 20), + () => sorted.slice(0, Math.min(visibleCount, sorted.length)), [sorted, visibleCount], ) @@ -486,7 +538,7 @@ export default function SpriteTheaterPage(): ReactNode {
- {cols}×{rows} grid · {visibleCount} visible + {cols}×{rows} grid · {visible.length}/{sorted.length} Dashboard @@ -494,24 +546,32 @@ export default function SpriteTheaterPage(): ReactNode {
- {variants.length === 0 ? ( -
-
-
Waiting for sprites...
-
Generate sprites with ./run tools spritegen generate
-
New images will appear here in real-time
+
+ {variants.length === 0 ? ( +
+
+
Waiting for sprites...
+
Generate sprites with ./run tools spritegen generate
+
New images will appear here in real-time
+
-
- ) : ( -
+ ) : (
0 ? `repeat(${cols}, 1fr)` : `repeat(auto-fill, minmax(${MIN_CARD_WIDTH}px, 1fr))`, @@ -525,19 +585,21 @@ export default function SpriteTheaterPage(): ReactNode { v={v} isNew={newIds.has(v.variant_id)} index={i} - cardWidth={cardWidth > 0 ? cardWidth : MIN_CARD_WIDTH} /> ))}
-
- )} + )} + {/* Sentinel — triggers lazy loading of next batch of rows */} + {visible.length < sorted.length && ( +
+ )} +
{leaving.map(({ v, rect }) => ( 0 ? cardWidth : MIN_CARD_WIDTH} /> ))}