1// Free-floating image cloud 2const photos = [ 3 { 4 alt: "Bhavith", 5 rot: -2.5, 6 src: "gallery/bhavith.jpeg", 7 x: -155, 8 y: -80, 9 z: 2, 10 }, 11 { 12 alt: "CAI4CAI workshop", 13 rot: 1.8, 14 src: "gallery/cai4cai.jpg", 15 x: 30, 16 y: -30, 17 z: 5, 18 }, 19 { 20 alt: "ISBI 2024", 21 rot: -1.5, 22 src: "gallery/isbi.jpeg", 23 x: 150, 24 y: -75, 25 z: 3, 26 }, 27 { alt: "Pooja", rot: 2.2, src: "gallery/pooja.jpeg", x: -160, y: 72, z: 2 }, 28 { 29 alt: "Parents", 30 rot: -2.0, 31 src: "gallery/parents.jpeg", 32 x: -35, 33 y: 20, 34 z: 4, 35 }, 36 { 37 alt: "Pupil study", 38 rot: 0.9, 39 src: "gallery/pupil.jpeg", 40 x: 155, 41 y: 62, 42 z: 1, 43 }, 44 { alt: "", rot: -1.2, src: "gallery/wa1.jpeg", x: -85, y: -145, z: 3 }, 45 { alt: "", rot: 2.8, src: "gallery/wa2.jpeg", x: 100, y: -140, z: 2 }, 46 { alt: "", rot: 1.5, src: "gallery/wa3.jpeg", x: 230, y: -15, z: 2 }, 47 { alt: "", rot: -2.3, src: "gallery/wa4.jpeg", x: -230, y: 10, z: 2 }, 48 { alt: "", rot: -2.0, src: "gallery/wa5.jpeg", x: 115, y: 145, z: 3 }, 49 { alt: "", rot: 3.1, src: "gallery/wa6.jpeg", x: -50, y: 148, z: 1 }, 50 { alt: "", rot: 2.4, src: "gallery/wa7.jpeg", x: -270, y: -110, z: 1 }, 51 { alt: "", rot: -1.7, src: "gallery/wa8.jpeg", x: 265, y: 130, z: 2 }, 52 { alt: "", rot: 1.1, src: "gallery/wa9.jpeg", x: -10, y: -200, z: 1 }, 53 { alt: "", rot: -3.0, src: "gallery/wa10.jpeg", x: 205, y: -150, z: 1 }, 54 { alt: "", rot: 2.0, src: "gallery/wa11.jpeg", x: -160, y: -195, z: 6 }, 55]; 56 57const cloud = document.querySelector(".image-cloud"); 58const universe = document.createElement("div"); 59universe.className = "cloud-universe"; 60cloud.appendChild(universe); 61 62// Size the canvas so it always contains every photo (which is positioned from 63// the centre) plus margin â drop a new photo at any coordinate and the canvas 64// grows to include it; existing photos never move. Stays at least viewport-ish 65// so there is always room to pan. 66const margin = 260;
67const spanX = 2 * (Math.max(...photos.map((p) => Math.abs(p.x))) + margin); 68const spanY = 2 * (Math.max(...photos.map((p) => Math.abs(p.y))) + margin); 69universe.style.width = `max(${spanX}px, 140vw)`; 70universe.style.height = `max(${spanY}px, 140vh)`; 71 72let focused = null; 73 74// Centroid of the photo cluster, relative to the canvas centre (where each 75// photo is positioned from). Load-independent â uses fixed coordinates, not 76// rendered image sizes. 77const clusterX = 78 (Math.min(...photos.map((p) => p.x)) + Math.max(...photos.map((p) => p.x))) / 79 2; 80const clusterY = 81 (Math.min(...photos.map((p) => p.y)) + Math.max(...photos.map((p) => p.y))) / 82 2; 83 84// Scroll so the photo cluster sits at the true centre of the screen (both axes), 85// accounting for the cloud's header/footer inset. 86function centreCanvas(behavior) { 87 if (!cloud.clientWidth) { 88 return; 89 } 90 const rect = cloud.getBoundingClientRect(); 91 const contentX = universe.offsetWidth / 2 + clusterX; 92 const contentY = universe.offsetHeight / 2 + clusterY; 93 cloud.scrollTo({ 94 behavior: behavior || "auto", 95 left: contentX - (window.innerWidth / 2 - rect.left), 96 top: contentY - (window.innerHeight / 2 - rect.top), 97 }); 98} 99for (const photo of photos) { 100 const el = document.createElement("div"); 101 el.className = "cloud-photo"; 102 el.style.setProperty("--cx", `${photo.x}px`); 103 el.style.setProperty("--cy", `${photo.y}px`); 104 el.style.setProperty("--rot", `${photo.rot}deg`); 105 el.style.setProperty("--z", photo.z); 106 // Unique drift per photo: small random-ish offsets based on index 107 const i = photos.indexOf(photo); 108 const dx1 = `${((i * 7 + 3) % 11) - 5}px`; 109 const dy1 = `${((i * 5 + 2) % 9) - 4}px`; 110 const dx2 = `${((i * 3 + 8) % 11) - 5}px`; 111 const dy2 = `${((i * 9 + 1) % 9) - 4}px`; 112 el.style.setProperty("--dx1", dx1); 113 el.style.setProperty("--dy1", dy1); 114 el.style.setProperty("--dx2", dx2); 115 el.style.setProperty("--dy2", dy2); 116 el.style.setProperty("--dur", `${(7 + ((i * 1.3) % 5)).toFixed(1)}s`); 117 el.style.setProperty("--delay", `${(-(i * 1.7) % 6).toFixed(1)}s`); 118 el.setAttribute("role", "button"); 119 el.setAttribute("tabindex", "0"); 120 el.setAttribute("aria-label", `View photo: ${photo.alt}`); 121 122 const img = document.createElement("img"); 123 img.src = photo.src; 124 img.alt = photo.alt; 125 img.loading = "lazy"; 126 img.decoding = "async"; 127 128 el.appendChild(img); 129 universe.appendChild(el); 130 131 el.addEventListener("click", (e) => { 132 e.stopPropagation(); 133 // Ignore the click synthesised at the end of a drag-to-pan. 134 if (wasDragging) { 135 return; 136 } 137 if (focused === el) { 138 el.classList.remove("focused"); 139 cloud.classList.remove("has-focus"); 140 focused = null; 141 } else { 142 if (focused) { 143 focused.classList.remove("focused"); 144 } 145 el.classList.add("focused"); 146 cloud.classList.add("has-focus"); 147 focused = el; 148 // Bring the centred/zoomed photo into view from any pan offset. 149 centreCanvas("smooth"); 150 } 151 }); 152 153 el.addEventListener("keydown", (e) => { 154 if (e.key === "Enter" || e.key === " ") { 155 e.preventDefault(); 156 el.click(); 157 } 158 }); 159} 160 161// Dismiss on click outside or Escape 162cloud.addEventListener("click", () => { 163 if (focused) { 164 focused.classList.remove("focused"); 165 cloud.classList.remove("has-focus"); 166 focused = null; 167 } 168}); 169 170document.addEventListener("keydown", (e) => { 171 if (e.key === "Escape" && focused) { 172 focused.classList.remove("focused"); 173 cloud.classList.remove("has-focus"); 174 focused = null; 175 } 176}); 177 178// Mouse drag-to-pan. Touch/trackpad/wheel use native overflow scrolling. 179// Panning only begins once the pointer moves past a threshold, so a plain 180// click still reaches the photo (capturing the pointer up front would swallow 181// the click and break click-to-highlight). 182let wasDragging = false; 183let pointerDown = false; 184let panning = false; 185let startX = 0, 186 startY = 0, 187 startLeft = 0, 188 startTop = 0; 189 190cloud.addEventListener("pointerdown", (e) => { 191 if (e.pointerType !== "mouse" || e.button !== 0) { 192 return; 193 } 194 pointerDown = true; 195 panning = false;
196 wasDragging = false; 197 startX = e.clientX; 198 startY = e.clientY; 199 startLeft = cloud.scrollLeft; 200 startTop = cloud.scrollTop; 201}); 202 203cloud.addEventListener("pointermove", (e) => { 204 if (!pointerDown) { 205 return; 206 } 207 const dx = e.clientX - startX; 208 const dy = e.clientY - startY; 209 if (!panning) { 210 if (Math.abs(dx) <= 5 && Math.abs(dy) <= 5) { 211 return; 212 } 213 panning = true; 214 wasDragging = true; 215 cloud.classList.add("is-panning"); 216 cloud.setPointerCapture(e.pointerId); 217 } 218 cloud.scrollLeft = startLeft - dx; 219 cloud.scrollTop = startTop - dy; 220}); 221 222function endPan(e) { 223 if (!pointerDown) { 224 return; 225 } 226 pointerDown = false; 227 if (panning) { 228 panning = false; 229 cloud.classList.remove("is-panning"); 230 if (cloud.hasPointerCapture(e.pointerId)) { 231 cloud.releasePointerCapture(e.pointerId); 232 } 233 // Clear the drag guard after the click event has fired. 234 setTimeout(() => { 235 wasDragging = false; 236 }, 0); 237 } 238} 239 240cloud.addEventListener("pointerup", endPan); 241cloud.addEventListener("pointercancel", endPan); 242 243// Frame the photo cluster whenever the gallery becomes visible. The section is 244// display:none until its tab is opened, so we can't centre until it has size. 245new IntersectionObserver((entries) => { 246 for (const entry of entries) { 247 if (entry.isIntersecting && cloud.clientWidth > 0) { 248 centreCanvas(); 249 } 250 } 251}).observe(cloud);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.