import { useEffect, useRef } from 'react'; import collection from './collection.json'; import { dimensions, fieldPosition, fieldCount, scatter, edgePosition, seed } from './layout.js'; import { sampleMotion, transition } from './motion.js'; import { relevanceAppearance } from './relevance.js'; import { imageURL } from './assets.js'; import { averageColor, createImageLoader, createPlaceholderLoader, thumbnailWidth } from './image-loader.js'; /** A single population of photographs, one coordinate plane, one animation clock. */ export default function ImageField({ results, onSelect, paused }) { const surface = useRef(null); const engine = useRef(null); const selection = useRef(onSelect); selection.current = onSelect; useEffect(() => { const root = surface.current; const nodes = new Map(); const thumbnails = createImageLoader(); const placeholders = createPlaceholderLoader(); const motion = matchMedia('(prefers-reduced-motion: reduce)'); const state = { width: root.clientWidth, height: root.clientHeight, results: [], pool: [], paused: false, pointer: { x: 0, y: 0 }, x: 0, y: 0 }; let frame; let previous = performance.now(), time = 0; function create(image, target, initial = false) { const button = document.createElement('button'); button.className = 'image-tile'; button.type = 'button'; button.dataset.imageId = image.id; button.style.backgroundColor = averageColor(image.blur); // Stack order belongs to the photograph, never to its search rank. button.style.zIndex = 1 + Math.floor(seed(image.id) * 100); root.append(button); const home = initial ? target : edgePosition(image, state.width, state.height, target); const node = { image, button, ...home, target: home, motion: null, exit: false, phase: seed(image.id) * Math.PI * 2 }; resize(node); button.style.transform = `translate3d(${home.x - node.width / 2}px,${home.y - node.height / 2}px,0) scale(${home.scale})`; nodes.set(image.id, node); node.cancelPlaceholder = placeholders.request(image.blur, url => { if (url && nodes.get(image.id) === node && !node.photo) button.style.backgroundImage = `url("${url}")`; }); button.onclick = () => selection.current({ ...image, previewURL: node.photo?.currentSrc || node.photo?.src }, button.getBoundingClientRect().toJSON()); return node; } function resize(node) { const { width, height } = dimensions(node.image, state.width); if (node.width === width && node.height === height) return; node.width = width; node.height = height; node.button.style.width = `${width}px`; node.button.style.height = `${height}px`; } function remove(node) { node.load?.cancel(); node.cancelPlaceholder?.(); node.button.remove(); if (node.photo) { node.photo.remove(); thumbnails.release(node.photo); } nodes.delete(node.image.id); } function loadThumbnail(node, priority) { const url = imageURL(node.image, thumbnailWidth(node.width, node.target.scale, window.devicePixelRatio || 1)); node.load = thumbnails.request(url, photo => { node.load = null; if (node.exit || nodes.get(node.image.id) !== node) { thumbnails.release(photo); return; } photo.alt = ''; photo.draggable = false; photo.className = ''; node.photo = photo; node.button.append(photo); if (!motion.matches) photo.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 180, easing: 'ease-out' }); photo.classList.add('loaded'); }, priority); } function layout(initial = false) { const now = performance.now(); const active = new Set(state.pool.map(image => image.id)); const ranks = new Map(state.results.map((image, rank) => [image.id, rank])); const targets = state.pool.map((image, index) => state.results.length ? fieldPosition(image, index, state.pool.length, state.width, state.height) : scatter(image, index, state.pool.length, state.width, state.height)); const appearance = relevanceAppearance(state.results); state.pool.forEach((image, index) => { const target = targets[index]; const node = nodes.get(image.id) || create(image, target, initial); node.target = target; node.exit = false; node.button.removeAttribute('aria-hidden'); const rank = ranks.get(image.id); resize(node); if (!node.photo && !node.load) loadThumbnail(node, rank ?? 200 + index); node.load?.prioritize(rank ?? 200 + index); node.button.setAttribute('aria-label', rank == null ? 'Phóng to ảnh' : `Phóng to kết quả ${rank + 1}`); node.button.setAttribute('aria-haspopup', 'dialog'); const { opacity, blur } = appearance(image.id); node.button.style.setProperty('--relevance-opacity', opacity); node.button.style.setProperty('--relevance-blur', `${blur}px`); node.button.dataset.rank = rank == null ? '' : rank + 1; node.button.tabIndex = state.results.length ? (rank != null && rank < 12 ? 0 : -1) : (index < 12 ? 0 : -1); transition(node, target, now, initial || motion.matches); }); for (const [id, node] of nodes) { if (active.has(id) || node.exit) continue; node.exit = true; node.load?.cancel(); node.load = null; node.button.setAttribute('aria-hidden', 'true'); node.button.dataset.rank = ''; node.button.tabIndex = -1; transition(node, edgePosition(node.image, state.width, state.height, node), now, motion.matches); } } function update(results) { const count = fieldCount(state.width); const seen = new Set(); // Retain existing photographs between searches instead of restoring a background. const candidates = results.length ? [...results, ...state.pool, ...collection] : collection; const pool = []; for (const image of candidates) { if (seen.has(image.id)) continue; seen.add(image.id); pool.push(image); if (pool.length === count) break; } // Move immediately. BlurHash covers image downloads without holding up the field. state.results = results; state.pool = pool; layout(); } function animate(now) { const elapsed = now - previous; const dt = Math.min(elapsed / 1000, 0.04); previous = now; if (state.paused) { for (const node of nodes.values()) if (node.motion) node.motion.started += elapsed; frame = requestAnimationFrame(animate); return; } if (!document.hidden) time += dt; const blend = 1 - Math.exp(-4 * dt); state.x += (state.pointer.x - state.x) * blend; state.y += (state.pointer.y - state.y) * blend; for (const node of nodes.values()) { let done = true; if (node.motion) { const sampled = sampleMotion(node.motion, motion.matches ? Infinity : now); Object.assign(node, sampled.value); done = sampled.done; if (done) node.motion = null; } if (node.exit && done) { remove(node); continue; } // Shared ambient flow: equal parallax keeps everything on the same visual plane. const phase = node.phase; const dx = motion.matches ? 0 : Math.sin(time * 0.22 + phase) * 3 + state.x * 7; const dy = motion.matches ? 0 : Math.cos(time * 0.18 + phase) * 3 + state.y * 7; node.button.style.transform = `translate3d(${(node.x - node.width / 2 + dx).toFixed(2)}px,${(node.y - node.height / 2 + dy).toFixed(2)}px,0) scale(${node.scale.toFixed(4)})`; node.button.style.pointerEvents = node.exit ? 'none' : ''; } frame = requestAnimationFrame(animate); } state.pool = collection.slice(0, fieldCount(state.width)); layout(true); const observer = new ResizeObserver(([entry]) => { const { width, height } = entry.contentRect; if (width === state.width && height === state.height) return; state.width = width; state.height = height; update(state.results); }); observer.observe(root); const move = event => { if (event.pointerType === 'touch') return; state.pointer.x = (event.clientX / state.width - 0.5) * 2; state.pointer.y = (event.clientY / state.height - 0.5) * 2; }; const leave = () => { state.pointer.x = state.pointer.y = 0; }; root.addEventListener('pointermove', move); root.addEventListener('pointerleave', leave); frame = requestAnimationFrame(animate); engine.current = { state, update }; return () => { cancelAnimationFrame(frame); observer.disconnect(); root.removeEventListener('pointermove', move); root.removeEventListener('pointerleave', leave); thumbnails.dispose(); placeholders.dispose(); root.replaceChildren(); engine.current = null; }; }, []); useEffect(() => { engine.current?.update(results); }, [results]); useEffect(() => { if (!engine.current) return; engine.current.state.paused = paused; }, [paused]); return
; }