import React from 'react' import type { ExamplesData } from './Examples' import { groupByNameAndVariant } from './galleryUtils' import ExampleVariantMetricsTable from './ExampleVariantMetricsTable' import ExampleDetailsSection from './ExampleDetailsSection' import ExampleVariantSelector from './ExampleVariantSelector' import ExampleVariantToggle, { handleVariantToggleClick } from './ExampleVariantToggle' interface GalleryProps { selectedModel: string selectedAttack: string examples: { [model: string]: { [attack: string]: ExamplesData[] } } } const ImageGallery: React.FC = ({ selectedModel, selectedAttack, examples }) => { const exampleItems = examples[selectedModel][selectedAttack] // Group by image name (name after removing variant prefix), and for each, map variant to metrics const grouped = groupByNameAndVariant(exampleItems) const imageNames = Object.keys(grouped) const [selectedImage, setSelectedImage] = React.useState(imageNames[0] || '') const variants = grouped[selectedImage] || {} const variantKeys = Object.keys(variants) const [selectedVariant, setSelectedVariant] = React.useState(variantKeys[0] || '') const [toggleMode, setToggleMode] = React.useState<'wmd' | 'attacked'>('wmd') const [zoom, setZoom] = React.useState<{ x: number; y: number } | null>(null) const [imgDims, setImgDims] = React.useState<{ width: number; height: number }>({ width: 0, height: 0, }) const imgRef = React.useRef(null) // Set a fixed zoom box size for all variants const ZOOM_BOX_SIZE = 300 const [zoomLevel, setZoomLevel] = React.useState(2) // 2x default zoom // Calculate the minimum width and height across all variants for the selected image const variantImages = variantKeys.map((v) => variants[v]?.image_url).filter(Boolean) const [naturalDims, setNaturalDims] = React.useState<{ width: number; height: number } | null>( null ) React.useEffect(() => { let isMounted = true if (variantImages.length > 0) { Promise.all( variantImages.map( (src) => new Promise<{ width: number; height: number }>((resolve) => { const img = new window.Image() img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }) img.src = src! }) ) ).then((dimsArr) => { if (!isMounted) return const first = dimsArr[0] const allSame = dimsArr.every((d) => d.width === first.width && d.height === first.height) if (!allSame) { setNaturalDims(null) } else { setNaturalDims(first) } }) } return () => { isMounted = false } }, [variantImages.join(','), selectedImage]) // Place the zoom level slider outside of the zoom hover logic const zoomSlider = (
setZoomLevel(Number(e.target.value))} className="range range-xs w-48" />
) // If no image is selected, show a message if (!variantImages.length) { return (
No images available. Please select another model and attack.
) } // Ensure the main container allows scrolling and images retain their natural size return (
Image
{selectedImage && selectedVariant && variants[selectedVariant] && ( <> [v, variants[v]?.metadata || {}]) )} /> {zoomSlider}
{/* Original image and overlay */} {naturalDims ? ( {selectedImage} { const rect = (e.target as HTMLImageElement).getBoundingClientRect() const x = e.clientX - rect.left const y = e.clientY - rect.top setZoom({ x, y }) }} onMouseLeave={() => setZoom(null)} onClick={() => handleVariantToggleClick( toggleMode, selectedVariant, setSelectedVariant, variantKeys ) } /> ) : (
Image sizes do not match across variants!
)} {/* Zoomed box - now overlays on top of the cursor position */} {zoom && naturalDims && (() => { const { width, height } = naturalDims const halfBox = ZOOM_BOX_SIZE / (2 * zoomLevel) let centerX = zoom.x let centerY = zoom.y centerX = Math.max(halfBox, Math.min(width - halfBox, centerX)) centerY = Math.max(halfBox, Math.min(height - halfBox, centerY)) const bgX = centerX * zoomLevel - ZOOM_BOX_SIZE / 2 const bgY = centerY * zoomLevel - ZOOM_BOX_SIZE / 2 return (
) })()}
)}
) } export default ImageGallery