"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import justifiedLayout from "justified-layout"; import { useStore } from "@/store/useStore"; import { Panel } from "./Panel"; import { CheckIcon } from "./icons"; import type { Sample } from "@/types"; interface ImageGridProps { samples: Sample[]; onLoadMore?: () => void; hasMore?: boolean; } // Justified layout config const BOX_SPACING = 2; // Tight spacing between images const TARGET_ROW_HEIGHT = 180; // Target height for rows const DEFAULT_ASPECT_RATIO = 1; // Fallback for samples without dimensions /** * Get aspect ratio from sample, with fallback */ function getAspectRatio(sample: Sample): number { if (sample.width && sample.height && sample.height > 0) { return sample.width / sample.height; } return DEFAULT_ASPECT_RATIO; } /** * Compute justified layout geometry for samples */ function computeLayout( samples: Sample[], containerWidth: number ): { boxes: Array<{ width: number; height: number; top: number; left: number }>; containerHeight: number } { if (samples.length === 0 || containerWidth <= 0) { return { boxes: [], containerHeight: 0 }; } const aspectRatios = samples.map(getAspectRatio); const geometry = justifiedLayout(aspectRatios, { containerWidth, containerPadding: 0, boxSpacing: BOX_SPACING, targetRowHeight: TARGET_ROW_HEIGHT, targetRowHeightTolerance: 0.25, showWidows: true, // Always show last row even if incomplete }); return { boxes: geometry.boxes, containerHeight: geometry.containerHeight, }; } /** * Group boxes into rows for virtualization */ interface RowData { startIndex: number; endIndex: number; // exclusive top: number; height: number; } function groupIntoRows( boxes: Array<{ width: number; height: number; top: number; left: number }> ): RowData[] { if (boxes.length === 0) return []; const rows: RowData[] = []; let currentRowTop = boxes[0].top; let currentRowStart = 0; let currentRowHeight = boxes[0].height; for (let i = 1; i < boxes.length; i++) { const box = boxes[i]; // New row if top position changes significantly if (Math.abs(box.top - currentRowTop) > 1) { rows.push({ startIndex: currentRowStart, endIndex: i, top: currentRowTop, height: currentRowHeight, }); currentRowStart = i; currentRowTop = box.top; currentRowHeight = box.height; } else { // Same row - take max height in case of slight variations currentRowHeight = Math.max(currentRowHeight, box.height); } } // Push final row rows.push({ startIndex: currentRowStart, endIndex: boxes.length, top: currentRowTop, height: currentRowHeight, }); return rows; } export function ImageGrid({ samples, onLoadMore, hasMore }: ImageGridProps) { const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const { selectedIds, isLassoSelection, selectionSource, toggleSelection, addToSelection, setHoveredId, hoveredId, labelFilter, } = useStore(); // Track container width for layout computation useEffect(() => { const container = containerRef.current; if (!container) return; const updateWidth = () => { const width = container.clientWidth; if (width > 0 && width !== containerWidth) { setContainerWidth(width); } }; updateWidth(); const resizeObserver = new ResizeObserver(() => { requestAnimationFrame(updateWidth); }); resizeObserver.observe(container); return () => resizeObserver.disconnect(); }, [containerWidth]); // Compute justified layout const { boxes, containerHeight } = useMemo( () => computeLayout(samples, containerWidth), [samples, containerWidth] ); // Group into rows for virtualization const rows = useMemo(() => groupIntoRows(boxes), [boxes]); // Virtualizer for rows const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => containerRef.current, estimateSize: (index) => rows[index]?.height ?? TARGET_ROW_HEIGHT, overscan: 3, getItemKey: (index) => { const row = rows[index]; if (!row) return `row-${index}`; // Create stable key from sample IDs in this row const rowSamples = samples.slice(row.startIndex, row.endIndex); return rowSamples.map((s) => s.id).join("-") || `row-${index}`; }, }); // Load more when scrolling near bottom useEffect(() => { const container = containerRef.current; if (!container || !onLoadMore || !hasMore) return; const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = container; if (scrollHeight - scrollTop - clientHeight < 500) { onLoadMore(); } }; container.addEventListener("scroll", handleScroll); return () => container.removeEventListener("scroll", handleScroll); }, [onLoadMore, hasMore]); // Reset scroll on filter change useEffect(() => { containerRef.current?.scrollTo({ top: 0 }); }, [labelFilter]); // Scroll to top when scatter selection made useEffect(() => { if (isLassoSelection) return; if (selectionSource !== "scatter") return; if (selectedIds.size === 0) return; try { virtualizer.scrollToIndex(0, { align: "start" }); } catch { containerRef.current?.scrollTo({ top: 0 }); } }, [isLassoSelection, selectedIds, selectionSource, virtualizer]); // Handle click with selection logic const handleClick = useCallback( (sample: Sample, event: React.MouseEvent) => { if (event.metaKey || event.ctrlKey) { toggleSelection(sample.id); } else if (event.shiftKey && selectedIds.size > 0) { const selectedArray = Array.from(selectedIds); const lastSelected = selectedArray[selectedArray.length - 1]; const lastIndex = samples.findIndex((s) => s.id === lastSelected); const currentIndex = samples.findIndex((s) => s.id === sample.id); if (lastIndex !== -1 && currentIndex !== -1) { const start = Math.min(lastIndex, currentIndex); const end = Math.max(lastIndex, currentIndex); const rangeIds = samples.slice(start, end + 1).map((s) => s.id); addToSelection(rangeIds); } } else { const newSet = new Set(); newSet.add(sample.id); useStore.getState().setSelectedIds(newSet, "grid"); } }, [samples, selectedIds, toggleSelection, addToSelection] ); const virtualRows = virtualizer.getVirtualItems(); return (
{virtualRows.map((virtualRow) => { const row = rows[virtualRow.index]; if (!row) return null; const rowSamples = samples.slice(row.startIndex, row.endIndex); const rowBoxes = boxes.slice(row.startIndex, row.endIndex); return (
{rowSamples.map((sample, i) => { const box = rowBoxes[i]; if (!box) return null; const isSelected = isLassoSelection ? true : selectedIds.has(sample.id); const isHovered = hoveredId === sample.id; return (
handleClick(sample, e)} onMouseEnter={() => setHoveredId(sample.id)} onMouseLeave={() => setHoveredId(null)} > {/* Image container - justified layout sizes tile to preserve aspect ratio */} {/* Future: overlays (segmentations, bboxes) will be absolutely positioned here */} {sample.thumbnail ? ( // eslint-disable-next-line @next/next/no-img-element {sample.filename} ) : (
No image
)} {/* Label badge */} {sample.label && (
{sample.label}
)} {/* Selection indicator */} {isSelected && (
)}
); })}
); })}
); }