|
// The painting moves between its grid tile and the artwork page's header image: |
|
// it grows out of the tile that was clicked, and on the way back it shrinks |
|
// into that artwork's tile in the grid. |
|
// |
|
// The site swaps pages itself (see fast.js), so this flies a copy of the image |
|
// above the page: lifted at the clicked tile while the next page loads, then |
|
// moved to where the new page shows the painting once that page is in place. |
|
(() => { |
|
const DURATION_MS = 380; |
|
const EASING = "cubic-bezier(.2, 0, 0, 1)"; |
|
const LANDING_WAIT_MS = 1500; |
|
const NAVIGATION_WAIT_MS = 10_000; |
|
const PRELOAD_HOVER_MS = 150; // longer than the page prefetch: these images are large |
|
const ART_PATH = /^\/[a-z]{2}\/(?:[\w-]+\/)?collecti(?:on|e)\/[a-z]\d/i; // also /en/prints/collection/..., /nl/collectie/... |
|
|
|
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)"); |
|
let flight = null; // { overlay, aspect, to: "header" | "tile", path, startedAt } |
|
let currentPath = location.pathname; |
|
|
|
const aspectOf = (img) => (img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 0); |
|
|
|
// The rectangle the painting covers, also for object-fit: contain images. |
|
const paintingRect = (img, aspect) => { |
|
const box = img.getBoundingClientRect(); |
|
if (!aspect || getComputedStyle(img).objectFit !== "contain") return box; |
|
let { width, height } = box; |
|
if (width / height > aspect) width = height * aspect; |
|
else height = width / aspect; |
|
return new DOMRect(box.x + (box.width - width) / 2, box.y + (box.height - height) / 2, width, height); |
|
}; |
|
|
|
const isOnScreen = (rect) => |
|
rect.width > 0 && rect.height > 0 && rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth; |
|
|
|
const place = (el, rect) => Object.assign(el.style, { |
|
left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.width}px`, height: `${rect.height}px`, |
|
}); |
|
|
|
const launch = (img, to, path) => { |
|
const aspect = aspectOf(img); |
|
const rect = paintingRect(img, aspect); |
|
if (!aspect || !isOnScreen(rect)) return; |
|
const overlay = document.createElement("div"); |
|
overlay.className = "vgm-flight"; |
|
const copy = new Image(); |
|
copy.src = img.currentSrc; |
|
overlay.append(copy); |
|
place(overlay, rect); |
|
document.body.append(overlay); |
|
const thisFlight = { overlay, aspect, to, path, startedAt: performance.now(), landing: false }; |
|
flight = thisFlight; |
|
document.documentElement.classList.add("vgm-flying"); |
|
// The site falls back to a full page load when its fetch fails, but don't |
|
// leave the copy hanging over the page if the swap never happens. |
|
setTimeout(() => { if (flight === thisFlight && !thisFlight.landing) fadeAway(); }, NAVIGATION_WAIT_MS); |
|
}; |
|
|
|
const finish = () => { |
|
if (!flight) return; |
|
const { overlay } = flight; |
|
flight = null; |
|
document.documentElement.classList.remove("vgm-flying"); |
|
requestAnimationFrame(() => overlay.remove()); |
|
}; |
|
|
|
const fadeAway = () => { |
|
if (!flight) return; |
|
flight.overlay.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 150, fill: "forwards" }).finished.then(finish); |
|
}; |
|
|
|
// FLIP: put the overlay where it lands, then animate it in from where it is. |
|
const fly = (target, targetRect) => { |
|
const { overlay } = flight; |
|
const from = overlay.getBoundingClientRect(); |
|
place(overlay, targetRect); |
|
target.dataset.vgmLanded = ""; |
|
const hiRes = target.currentSrc && target.currentSrc !== overlay.firstChild.src && target.complete ? target.currentSrc : null; |
|
if (hiRes) { |
|
const sharp = new Image(); |
|
sharp.src = hiRes; |
|
sharp.decode().then(() => overlay.append(sharp), () => {}); |
|
} |
|
overlay.animate([ |
|
{ |
|
transform: `translate(${from.x - targetRect.x}px, ${from.y - targetRect.y}px) scale(${from.width / targetRect.width}, ${from.height / targetRect.height})`, |
|
}, |
|
{ transform: "none" }, |
|
], { duration: DURATION_MS, easing: EASING }).finished.then(finish, finish); |
|
}; |
|
|
|
// Where the pointer is, so a flight back to the grid knows whether the tile |
|
// it lands on is about to show the site's hover zoom. |
|
let pointer = null; |
|
document.addEventListener("pointermove", (event) => { pointer = { x: event.clientX, y: event.clientY }; }, { capture: true, passive: true }); |
|
document.documentElement.addEventListener("pointerleave", () => { pointer = null; }); |
|
|
|
// A grid tile under the pointer zooms in (the site's hover effect), but only |
|
// once the browser notices the hover, a moment after the grid appears, and |
|
// then over 300 ms. Landing on such a tile, apply that zoom at once so the |
|
// painting lands at the size the tile will have, and hand the tile back to |
|
// the site's own hover effect once the pointer leaves it. |
|
const holdHoverZoom = (link) => { |
|
const item = link.querySelector(".collection-art-object-item"); |
|
const zoomed = link.querySelector(".collection-art-object-item-single-image"); |
|
if (!pointer || !item || !zoomed || "vgmHover" in zoomed.dataset) return; |
|
const isOver = () => { |
|
const box = item.getBoundingClientRect(); |
|
return pointer && pointer.x >= box.left && pointer.x <= box.right && pointer.y >= box.top && pointer.y <= box.bottom; |
|
}; |
|
if (!isOver()) return; |
|
zoomed.dataset.vgmHover = ""; |
|
const release = () => { |
|
if (isOver() && zoomed.isConnected) return; |
|
delete zoomed.dataset.vgmHover; |
|
document.removeEventListener("pointermove", release, true); |
|
removeEventListener("scroll", release, true); |
|
}; |
|
document.addEventListener("pointermove", release, true); |
|
addEventListener("scroll", release, true); |
|
}; |
|
|
|
// Waits (a frame at a time) for the new page to lay out the painting. |
|
const land = () => { |
|
if (!flight) return; |
|
let target = null; |
|
if (flight.to === "header") { |
|
const container = document.querySelector(".art-object-page .image-and-buttons-container.image-loaded"); |
|
target = container?.querySelector(".art-object-header-image img") ?? null; |
|
} else { |
|
const link = [...document.querySelectorAll("a.collection-art-object-wrapper[href]")] |
|
.find((a) => new URL(a.href).pathname === flight.path); |
|
target = link?.querySelector("img") ?? null; |
|
if (link) holdHoverZoom(link); |
|
} |
|
const rect = target && paintingRect(target, flight.aspect); |
|
if (rect && isOnScreen(rect)) return fly(target, rect); |
|
if (flight.to === "tile" && target && rect?.width > 0) return fadeAway(); // its tile is scrolled away |
|
if (performance.now() - flight.startedAt > LANDING_WAIT_MS) return fadeAway(); |
|
requestAnimationFrame(land); |
|
}; |
|
|
|
// The artwork page lays the painting out only once its large image has |
|
// loaded, so the flight has to wait for that download. Start it while the |
|
// pointer is on the tile, asking for the same candidates as the page does |
|
// (its header uses the tile's WebP sizes from 600 px up at 90vw), so the |
|
// browser picks the same file and the page finds it in the cache. |
|
const preloaded = new Set(); |
|
const preloadHeaderImage = (link) => { |
|
const source = link.querySelector('picture source[type="image/webp"]'); |
|
const srcset = source?.getAttribute("srcset") || source?.getAttribute("data-srcset"); |
|
if (!srcset || preloaded.has(link.pathname)) return; |
|
// Split between candidates only: the IIIF URLs themselves contain commas. |
|
const candidates = srcset.split(/,(?=\s*https?:)/).map((c) => c.trim()).filter((c) => parseInt(c.split(/\s+/)[1], 10) >= 600); |
|
if (!candidates.length) return; |
|
preloaded.add(link.pathname); |
|
const img = new Image(); |
|
img.sizes = "90vw"; |
|
img.srcset = candidates.join(", "); |
|
}; |
|
|
|
const tileLink = (target) => { |
|
const link = target instanceof Element ? target.closest("a.collection-art-object-wrapper[href]") : null; |
|
return link && link.host === location.host && ART_PATH.test(link.pathname) ? link : null; |
|
}; |
|
|
|
let preloadTimer = 0; |
|
document.addEventListener("pointerover", (event) => { |
|
clearTimeout(preloadTimer); |
|
const link = tileLink(event.target); |
|
if (link) preloadTimer = setTimeout(() => preloadHeaderImage(link), PRELOAD_HOVER_MS); |
|
}, { capture: true, passive: true }); |
|
document.addEventListener("pointerdown", (event) => { |
|
const link = tileLink(event.target); |
|
if (link) preloadHeaderImage(link); |
|
}, { capture: true, passive: true }); |
|
|
|
// Grid tile (on the collection page, or related works on an artwork page) to the artwork. |
|
document.addEventListener("click", (event) => { |
|
if (reducedMotion.matches || flight) return; |
|
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; |
|
const link = event.target instanceof Element ? event.target.closest("a.collection-art-object-wrapper[href]") : null; |
|
const img = link?.querySelector("img"); |
|
if (!img || link.host !== location.host || !ART_PATH.test(link.pathname)) return; |
|
launch(img, "header", link.pathname); |
|
link.querySelector(".collection-art-object-item-single-image")?.style.setProperty("visibility", "hidden"); |
|
}, true); |
|
|
|
// Leaving an artwork page by any route (a link or the back button). |
|
window.addEventListener("page-transition-loading", () => { |
|
if (reducedMotion.matches || flight) return; |
|
const img = document.querySelector(".art-object-page .art-object-header-image img"); |
|
if (img && ART_PATH.test(currentPath)) launch(img, "tile", currentPath); |
|
}); |
|
|
|
window.addEventListener("page-transition-entering", () => { |
|
if (!flight) return; |
|
flight.landing = true; |
|
flight.startedAt = performance.now(); |
|
requestAnimationFrame(land); |
|
}); |
|
|
|
window.addEventListener("page-transition-entered", () => { |
|
currentPath = location.pathname; |
|
}); |
|
})(); |