ui(sprite-theater): 💄 Add interactive zoom/pan controls for sprite visualization in SpriteTheaterPage
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
1b145610f1
commit
3e79f0901e
1 changed files with 114 additions and 52 deletions
|
|
@ -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<HTMLDivElement | null>,
|
||||
cardRefs: RefObject<Map<number, HTMLDivElement>>,
|
||||
onResize: () => void,
|
||||
): GridDims {
|
||||
): GridDims & { recompute: () => void } {
|
||||
const [dims, setDims] = useState<GridDims>({ 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<HTMLDivElement, CardProps>(function Card({ v, isNew, index, cardWidth }, ref) {
|
||||
const Card = memo(forwardRef<HTMLDivElement, CardProps>(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<HTMLDivElement, CardProps>(function Card({ v, isNew
|
|||
border: cardBorder,
|
||||
position: 'relative',
|
||||
boxShadow: cardShadow,
|
||||
width: cardWidth,
|
||||
willChange: 'transform',
|
||||
...animStyle,
|
||||
}}
|
||||
|
|
@ -241,11 +256,9 @@ const Card = memo(forwardRef<HTMLDivElement, CardProps>(function Card({ v, isNew
|
|||
function LeavingCard({
|
||||
v,
|
||||
rect,
|
||||
cardWidth,
|
||||
}: {
|
||||
v: RecentVariant
|
||||
rect: DOMRect
|
||||
cardWidth: number
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
|
|
@ -259,7 +272,7 @@ function LeavingCard({
|
|||
animation: `fadeOut ${FADE_OUT_MS}ms ease forwards`,
|
||||
}}
|
||||
>
|
||||
<Card v={v} isNew={false} index={0} cardWidth={cardWidth} />
|
||||
<Card v={v} isNew={false} index={0} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>(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<number, DOMRect>()
|
||||
|
|
@ -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 {
|
|||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<span style={{ fontSize: 11, color: colors.muted }}>
|
||||
{cols}×{rows} grid · {visibleCount} visible
|
||||
{cols}×{rows} grid · {visible.length}/{sorted.length}
|
||||
</span>
|
||||
<a href="/" style={{ color: colors.muted, textDecoration: 'none', fontSize: 13 }}>
|
||||
Dashboard
|
||||
|
|
@ -494,24 +546,32 @@ export default function SpriteTheaterPage(): ReactNode {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{variants.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center', color: colors.muted }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>Waiting for sprites...</div>
|
||||
<div>Generate sprites with <code style={{ color: colors.text }}>./run tools spritegen generate</code></div>
|
||||
<div style={{ marginTop: 8 }}>New images will appear here in real-time</div>
|
||||
<div
|
||||
ref={gridRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
padding: GRID_PADDING,
|
||||
boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{variants.length === 0 ? (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', color: colors.muted }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>Waiting for sprites...</div>
|
||||
<div>Generate sprites with <code style={{ color: colors.text }}>./run tools spritegen generate</code></div>
|
||||
<div style={{ marginTop: 8 }}>New images will appear here in real-time</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
ref={gridRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
padding: GRID_PADDING,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
) : (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: cols > 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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{/* Sentinel — triggers lazy loading of next batch of rows */}
|
||||
{visible.length < sorted.length && (
|
||||
<div ref={sentinelRef} style={{ height: 1 }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{leaving.map(({ v, rect }) => (
|
||||
<LeavingCard
|
||||
key={`leave-${v.variant_id}`}
|
||||
v={v}
|
||||
rect={rect}
|
||||
cardWidth={cardWidth > 0 ? cardWidth : MIN_CARD_WIDTH}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue