VSEARCH / src /ImageField.jsx
bep40's picture
Deploy Vietnamese interior image field (1222 imgs, multilingual CLIP text tower)
c9e39f3 verified
Raw History Blame
9.25 kB
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 <div className="image-field" ref={surface} aria-label="Photograph collection" />;
}