VSEARCH / src /ImageField.jsx
bep40's picture
fix: eurogold/gold false-hit + feature top keyword hits & fresh-added products larger/on-top (#4)
703f8d3
Raw History Blame Contribute Delete
20.7 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, tileURL, shareURL } from './assets.js';
import { averageColor, createImageLoader, createPlaceholderLoader, thumbnailWidth } from './image-loader.js';
/**
* z-index given to the tile the pointer is over.
*
* Clears every seeded value above (1 + floor(seed(id) * 100), so 1..100) and
* every other z-index the field uses -- vignette 300, grain 301, search pill
* 400, footer 401 -- so the lifted tile sits above its neighbours without
* pushing the search box under the user's photographs.
*/
const LIFTED_Z = 200;
/** A single population of photographs, one coordinate plane, one animation clock. */
export default function ImageField({ results, onSelect, paused, query = '', products = [] }) {
const surface = useRef(null);
const engine = useRef(null);
const selection = useRef(onSelect);
selection.current = onSelect;
// The query and products are read inside the long-lived effect, so they are
// mirrored into a ref rather than becoming effect dependencies. Making them
// dependencies would tear down and rebuild every tile -- and re-request every
// thumbnail -- on every keystroke.
const controls = useRef({ query, products });
controls.current = { query, products };
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');
// `featured` is set here, at creation, rather than toggled per frame like
// `exact`: it comes from the record itself and does not change for a
// given tile while it is on screen, so there is nothing to re-evaluate.
button.className = image.featured ? 'image-tile featured' : 'image-tile';
button.type = 'button';
button.dataset.imageId = image.id;
button.style.backgroundColor = averageColor(image.blur);
/**
* A share link on the tile itself, so a photograph can be sent without
* opening it first.
*
* The tile stays a <button> -- it has to, for keyboard activation and for
* aria-haspopup="dialog" to mean anything -- and the anchor is appended
* inside it. Nesting an <a> in a <button> is not valid HTML, so the anchor
* is aria-hidden and tabIndex -1: it is a pointer affordance, and the
* reachable way to share is the link in the viewer, which is a real link
* with a real name. A keyboard user is not offered something that behaves
* differently from its accessible name.
*
* Clicking it must not also open the viewer, so its handler stops
* propagation; the tile's own onclick never sees the event.
*/
const share = document.createElement('a');
share.className = 'image-share';
share.tabIndex = -1;
share.setAttribute('aria-hidden', 'true');
/**
* Opens in a new tab, like the viewer's own share link. Navigating the
* current tab away would destroy the search the user is standing in, and
* the field cannot be rebuilt from history -- there is no route to come
* back to.
*/
share.target = '_blank';
share.rel = 'noopener noreferrer';
share.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">'
+ '<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" /></svg>';
share.addEventListener('click', event => event.stopPropagation());
button.append(share);
button._share = share;
// Stack order belongs to the photograph, never to its search rank -- EXCEPT
// that a featured tile -- the exact answer, a top keyword hit, or a just
// added avatar photograph -- must sit visibly ON TOP of the field around
// it, which is the whole point of enlarging it. So featured tiles draw in
// a layer above every ordinary tile (which can at most reach 100; 300 is
// safely clear of the 1..100 seed range and below the 400 reserved for the
// search pill glass in index.css).
const baseZ = image.featured ? 300 + Math.floor(seed(image.id) * 40) : 1 + Math.floor(seed(image.id) * 100);
button.style.zIndex = baseZ;
root.append(button);
const home = initial ? target : edgePosition(image, state.width, state.height, target);
// baseZ is kept on the node so liftUnderPointer can restore it verbatim
// when the pointer moves on.
const node = { image, button, baseZ, lifted: false, ...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 = event => {
/**
* The share anchor is a child of this button, so its click reaches here
* too. stopPropagation on the anchor is not enough on its own: the tile
* drifts under the pointer every frame, so by the time the click lands
* the browser may have re-targeted the event at the button itself, and
* the photograph opens behind a tab that was supposed to be shared.
* Testing the composed path covers both cases.
*/
const path = event.composedPath ? event.composedPath() : [event.target];
if (path.includes(share)) return;
selection.current(
// A product carries its full gallery so the viewer can page through it,
// exactly as the old product strip did. Interiors have no `images` and
// are unchanged.
//
// Read `node.image` at click time rather than closing over `image`: a
// node is created once and then reused across searches, and the record
// it was created from may have been an ambient tile from
// collection.json -- id/url/ar/blur only. Closing over the original
// meant a photograph that later matched the query kept opening with
// none of its title, categories or source, because the richer search
// result was never written back to the node. layout() assigns
// node.image on every pass, so the click always sees the current one.
{ ...node.image, previewURL: node.photo?.currentSrc || node.photo?.src },
button.getBoundingClientRect().toJSON(),
);
};
return node;
}
function resize(node) {
// A featured tile -- the exact answer to the query, or a photograph just
// added on vai-avatar -- carries its own scale multiplier. Read from the
// record rather than passed down, so a tile that becomes featured keeps
// the same identity (and therefore the same place in the layout) it had
// as an ordinary tile.
const scale = node.image?.featuredScale || 1;
const { width, height } = dimensions(node.image, state.width, scale);
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 = tileURL(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;
/**
* insertBefore rather than append, so the share anchor stays the tile's
* last child.
*
* The tile has no stacking context of its own, and both children are
* absolutely positioned -- so with the <img> appended last, it paints
* over the anchor and every click on the share button opens the viewer
* instead of sharing. z-index cannot fix it either: it is set in a
* stylesheet, and re-appending the photo reorders the DOM, so the two
* swap paint order on each cached thumbnail. Keeping the anchor last in
* the DOM fixes it by construction, for every image, loaded or not.
*/
node.button.insertBefore(photo, node.button._share);
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]));
// Read the controls once per pass: every tile in a pass must agree on
// where the plane sits, or the collection visibly tears apart mid-drag.
const { offsetX: shiftX, offsetY: shiftY } = controls.current;
// fieldPosition() whenever a query is active OR anything is featured, and
// scatter() only for a plain, featureless browse.
//
// The reason is that scatter() lays tiles out on a jittered grid with no
// notion of the search pill, so index 0 lands in a corner and a featured
// tile can end up underneath the pill with the pill's own glass sitting on
// top of it. fieldPosition() spirals out from the centre and already has
// the clearance term that pushes tiles around the pill. Using it for the
// idle state too is what puts the ten newest photographs in a readable
// arc below the search box instead of wherever the grid happens to put
// them.
const ordered = state.results.length || state.pool.some(image => image.featured);
const targets = state.pool.map((image, index) => ordered
? fieldPosition(image, index, state.pool.length, state.width, state.height, shiftX, shiftY)
: scatter(image, index, state.pool.length, state.width, state.height, shiftX, shiftY));
// The query and the product hits go in so relevance.js can decide which
// tiles are EXACT matches (see exactMatches) and get the neon outline.
const appearance = relevanceAppearance(state.results, controls.current.query, controls.current.products);
state.pool.forEach((image, index) => {
const target = targets[index];
const node = nodes.get(image.id) || create(image, target, initial);
// Keep the node's record current, not just its position: `image` here is
// whatever the pool now holds, which for a photograph that has become a
// search result is the full row (title, categories, source) rather than
// the metadata-free collection entry the node was first built from.
// Without this, opening a matched photograph shows nothing about it.
node.image = image;
/**
* Point the tile's share link at the current record, and hide it when
* there is nothing to share. Updated alongside node.image for the same
* reason: the node may have been built from a record that carried no
* shareable identity, and a photograph's address is derived from it.
*/
const share = node.button._share;
if (share) {
const href = shareURL(image);
if (href) {
share.href = href;
share.dataset.shareFor = String(image.id);
share.removeAttribute('hidden');
} else {
share.removeAttribute('href');
share.removeAttribute('data-share-for');
share.hidden = true;
}
}
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');
// `exact` comes back on the same appearance object as the fade, so the
// outline and the dimming can never disagree about which tile is the
// answer. classList.toggle (not add) because a tile keeps its node
// across searches and would otherwise keep a stale outline.
const { opacity, blur, exact } = appearance(image.id);
node.button.style.setProperty('--relevance-opacity', opacity);
node.button.style.setProperty('--relevance-blur', `${blur}px`);
node.button.classList.toggle('exact', !!exact);
// An exact hit is never allowed to fade with the tail, whatever its
// cosine: if the outline says "this is the one", it has to be readable.
if (exact) {
node.button.style.setProperty('--relevance-opacity', 1);
node.button.style.setProperty('--relevance-blur', '0px');
}
node.button.dataset.rank = rank == null ? '' : rank + 1;
node.button.dataset.exact = exact ? 'true' : '';
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 = '';
// Clear the outline on the way out: a tile drifting off-screen must not
// keep advertising itself as the answer to the previous query.
node.button.dataset.exact = '';
node.button.classList.remove('exact');
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.
// `results` already carries the merged interiors + products, so the pool
// is filled from it first; the ambient collection only backs the idle
// field and the tail beyond the current results.
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;
liftUnderPointer(event.target);
};
/**
* Raise the photograph under the pointer above its neighbours.
*
* The field overlaps tiles deliberately and each tile's z-index is a stable
* seed of its id, so a tile can end up permanently buried under whichever
* neighbour hashed higher. CSS :hover cannot rescue it, because a buried
* element never matches :hover -- which is the chicken-and-egg the share
* button was stuck in. Measured on the idle field: of 79 in-viewport share
* buttons, not one was the topmost element at its own centre; 13 were
* covered by another tile's photograph and 8 by another tile outright.
*
* Lifting whichever tile the pointer is over makes the next hover land on
* the tile the user is aiming at, and then its own :hover reveals the
* share button normally. The seeded resting order is restored when the
* pointer leaves, so the composition stays deterministic across reloads.
*/
function liftUnderPointer(target) {
const tile = target?.closest?.('.image-tile');
for (const node of nodes.values()) {
if (node.lifted && node.button !== tile) restoreZ(node);
}
if (!tile) return;
const node = [...nodes.values()].find(n => n.button === tile);
if (node && !node.lifted) {
node.lifted = true;
tile.style.zIndex = LIFTED_Z;
}
}
function restoreZ(node) {
node.lifted = false;
node.button.style.zIndex = node.baseZ;
}
const leave = () => {
state.pointer.x = state.pointer.y = 0;
// Restore the seeded resting order. Leaving the field is the only
// unambiguous signal that the user is done arranging it, and doing it
// here is what keeps the composition identical on every reload.
for (const node of nodes.values()) if (node.lifted) restoreZ(node);
};
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" />;
}