# Zoomable Image Wall — random-stagger mosaic reveal + click-zoom explosion + drag-to-pan

## Goal
Build a full-viewport, pitch-black **mosaic wall of 1200 tiny image tiles** that fills a canvas larger than the screen. On load, every tile **pops in from `scale:0` to `scale:1` with a random grid stagger** (the mosaic materialises tile-by-tile in no particular order). A floating pill of two buttons (zoom-out / zoom-in) toggles a **2.5s zoom**: clicking zoom-in makes every tile **explode radially outward from the viewport centre and blow up to 5×** (you dive into the wall); clicking zoom-out reverses it. While zoomed, a transparent drag layer lets you **click-drag to pan** the enlarged wall, smoothed by a manual `requestAnimationFrame` lerp loop writing `translate3d`. No scroll, no ScrollTrigger, no GSAP plugins — just core `gsap`, a click state machine, and a rAF pan loop.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use **`gsap` (npm) only** — no plugins (no ScrollTrigger, no SplitText), no Lenis. Import as:
```js
import gsap from "gsap";
```
All logic runs inside a `DOMContentLoaded` listener. The load reveal and the two zoom transitions use `gsap.to` / `gsap.timeline`; the drag-pan uses a plain `lerp` + `requestAnimationFrame` loop (not GSAP).

## Layout / HTML
```html
<body>
  <div class="logo"><p>Motionprompts</p></div>

  <div class="pads">
    <button id="zoom-out" class="active"><!-- magnifier-minus SVG --></button>
    <button id="zoom-in"><!-- magnifier-plus SVG --></button>
  </div>

  <div id="drag-layer"></div>

  <div class="container">
    <div class="gallery"></div>
  </div>

  <script type="module" src="./script.js"></script>
</body>
```
- `.logo` — small frosted pill fixed top-centre with a short uppercase wordmark (neutral text, e.g. "MOTIONPROMPTS").
- `.pads` — frosted pill fixed bottom-centre holding two icon buttons. `#zoom-out` starts with class `active` (it is the current state on load — the wall begins zoomed OUT). Each button holds an 18×18 white-stroked SVG: `#zoom-out` a magnifier with a minus, `#zoom-in` a magnifier with a plus.
- `#drag-layer` — full-viewport transparent overlay, `display:none` by default; shown only while zoomed to capture drag input.
- `.container` → `.gallery` — the oversized stage and the flex-wrap mosaic. **The 1200 tile `<div class="img">` elements (each wrapping an `<img>`) are created in JS, not in the HTML.**

## Styling
Global reset `* { margin:0; padding:0; box-sizing:border-box; }`. `html, body { width:100%; height:100%; background:#000; overflow:hidden; }` — the whole page is black and never scrolls. `img { width:100%; height:100%; object-fit:cover; user-select:none; }`.

- `.logo`: `position:fixed; left:50%; transform:translateX(-50%); width:20%; margin:2em auto; padding:8px 0; text-align:center; background-color:rgba(255,255,255,0.7); backdrop-filter:blur(20px); border-radius:8px; z-index:2;`. Its `p`: `text-transform:uppercase; font-family:"PP Neue Montreal", sans-serif;` (any clean neutral grotesque sans is fine) `font-size:12px; font-weight:500;`.
- `.pads`: `position:fixed; left:50%; bottom:2em; transform:translateX(-50%); padding:0 4px; display:flex; gap:0.2em; background-color:rgba(255,255,255,0.25); border:1px solid rgba(255,255,255,0.125); backdrop-filter:blur(20px); border-radius:8px; z-index:2;`.
- `.pads button`: `opacity:1; outline:none; border:none; background:none; padding:8px; transition:0.5s opacity; pointer-events:all;`.
- `.pads button.active`: `opacity:0.5; pointer-events:none;` — the active (current) button is dimmed and un-clickable; the other one is the live control.
- `.container`: `position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:150vw; height:150vh; overflow:visible;` — **a stage 1.5× the viewport in both axes**, centred, so the mosaic bleeds well past the screen edges.
- `.gallery`: `position:relative; width:100%; height:100%; display:flex; flex-wrap:wrap; justify-content:center; align-items:flex-start; gap:4px; padding:4px; transform-origin:center center; will-change:transform;` — a centred flex-wrap grid of the tiles. **The gallery's own `transform` is used exclusively by the drag-pan loop.**
- `.img` (each tile): `width:calc((100% - 236px) / 60); transform:scale(0); transform-origin:center center; will-change:transform; pointer-events:none; opacity:0;` — starts invisible and collapsed. The width math packs **exactly 60 tiles per row**: `236px = 59 gaps × 4px`, so `(100% − total gaps) / 60` = one tile width. Each tile's **height is set inline in JS to a random 30–40px** (so rows have slightly ragged, editorial heights).
- `#drag-layer`: `position:fixed; top:0; left:0; width:100vw; height:100vh; display:none; touch-action:none; z-index:1;`.

## Building the mosaic (JS — do this first)
```js
const totalRows = 20;
const imagesPerRow = 60;
const totalImages = totalRows * imagesPerRow; // 1200
```
Loop `i` from `0` to `1199`:
- Create `div.img`; set `img.style.height = ${randomInt(30,40)}px` (inclusive random).
- Create an `<img>`; `src = "/c/sarah-bassett/img" + (randomInt(1,50)) + ".jpg"` — **each tile is randomly assigned one of 50 source images**, so the wall is a repeating-but-shuffled mosaic. (In a fresh project, point these at your own image folder.)
- Append `<img>` → `div.img` → `.gallery`; push each `div.img` into an `images[]` array.

## GSAP effect (the core — be exhaustive)

### 1. Load reveal — random grid stagger (fires once, on load)
```js
gsap.to(images, {
  scale: 1,
  opacity: 1,
  duration: 0.5,
  delay: 1,
  ease: "power1.out",
  stagger: {
    amount: 1.5,          // total spread of all 1200 start times = 1.5s
    grid: [totalRows, imagesPerRow], // [20, 60] — GSAP treats the array as a 2D grid
    from: "random",       // start times assigned in random grid order
  },
});
```
- Every tile animates `scale: 0 → 1` and `opacity: 0 → 1` over `0.5s` with `power1.out`.
- **`delay: 1`** — the whole wall waits 1s (blank black), then begins.
- **`stagger.amount: 1.5`** spreads the 1200 start times across a 1.5s window; **`from: "random"` with `grid: [20, 60]`** means tiles pop in in a scattered, non-sequential order (the mosaic "develops" like film). Total reveal ≈ 1s delay + 1.5s spread + 0.5s tail ≈ 3s.
- No timeline — a single tween across the `images[]` array.

### 2. Zoom IN — radial explosion to 5× (on `#zoom-in` click)
Guard: if already zoomed, return. Set `isZoomed = true`, show `#drag-layer` (`display:block`). Then **per tile**, read its current on-screen position and tween it outward from the viewport centre:
```js
images.forEach((img) => {
  const rect = img.getBoundingClientRect();
  const centerX = window.innerWidth / 2;
  const centerY = window.innerHeight / 2;
  const distX = (rect.left + rect.width / 2 - centerX) / 100; // signed, /100
  const distY = (rect.top  + rect.height / 2 - centerY) / 100;

  gsap.to(img, {
    x: distX * 1200,   // horizontal blast is 2× stronger than vertical
    y: distY * 600,
    scale: 5,
    duration: 2.5,
    ease: "power4.inOut",
  });
});
```
- Each tile's displacement is **proportional to its distance from the viewport centre** — centre tiles barely move, edge tiles fly far — producing a radial "dive into the wall" explosion. Net horizontal factor `= (dist/100)*1200 = dist*12`; vertical `= dist*6` (so the blast is anamorphic, wider than tall).
- All tiles scale `1 → 5`, over `2.5s`, `power4.inOut` (slow-in / slow-out, punchy middle).
- Swap button states: remove `active` from `#zoom-out`, add `active` to `#zoom-in`.

### 3. Zoom OUT — collapse back home (on `#zoom-out` click)
Guard: if not zoomed, return. Set `isZoomed = false`, hide `#drag-layer`. Capture and neutralise any drag offset, then reverse both the tile explosion and the gallery pan simultaneously:
```js
const currentTransform = getComputedStyle(gallery).transform; // whatever the drag left
gsap.set(gallery, { clearProps: "transform" });

const tl = gsap.timeline({ defaults: { duration: 2.5, ease: "power4.inOut" } });
tl.fromTo(gallery, { transform: currentTransform }, { x: 0, y: 0 })   // pan the wall back to origin
  .to(images, { scale: 1, x: 0, y: 0 }, 0);                            // AND collapse tiles home, at position 0
```
- **Position param `0`** on the second tween → both run **together** from the timeline start (the gallery slides back to `translate(0,0)` while every tile shrinks `5 → 1` and returns to `x:0, y:0`).
- Same `2.5s` / `power4.inOut` as zoom-in.
- Reset the pan state variables (`currentX = currentY = targetX = targetY = 0; isDragging = false;`).
- Swap button states back: add `active` to `#zoom-out`, remove from `#zoom-in`.

### 4. Drag-to-pan — manual lerp loop (active only while zoomed)
Not GSAP — a hand-rolled interpolation loop that writes the **gallery's** transform:
```js
function lerp(start, end, factor) { return start + (end - start) * factor; }

let isDragging=false, startX=0, startY=0, initialX=0, initialY=0,
    targetX=0, targetY=0, currentX=0, currentY=0;

function animate() {
  if (isDragging || Math.abs(targetX-currentX) > 0.01 || Math.abs(targetY-currentY) > 0.01) {
    currentX = lerp(currentX, targetX, 0.075);   // lerp factor 0.075 → smooth, weighty glide
    currentY = lerp(currentY, targetY, 0.075);
    requestAnimationFrame(() => {
      gallery.style.transform = `translate3d(${currentX}px, ${currentY}px, 0)`;
    });
  }
  requestAnimationFrame(animate);
}
animate();
```
Pointer/touch handlers on `#drag-layer` (mouse + touch):
- **drag start** (`mousedown` / `touchstart`): only if `isZoomed`. Record `startX/startY` from the pointer; read the gallery's current translate via `new DOMMatrix(getComputedStyle(gallery).transform)` → `m41`/`m42` into `initialX/initialY`, and seed `currentX/Y = targetX/Y = initialX/Y`. Attach move/end listeners (`passive:false` on move so `preventDefault` works).
- **drag move**: `targetX = initialX + (pointerX − startX)`, `targetY = initialY + (pointerY − startY)`. The lerp loop chases the target, so the wall follows the cursor with inertia.
- **drag end**: `isDragging = false`, detach the move/end listeners. The loop keeps easing to rest, then idles.

No CustomEase; the only easings are GSAP's `power1.out` (reveal) and `power4.inOut` (both zooms), plus the manual `0.075` lerp for panning.

## Assets / images
**50 interchangeable source images** (`img1…img50`), each rendered inside a tiny `object-fit:cover` tile roughly **~24×30–40px** (portrait-ish, ~2:3-to-3:4), and each randomly reused many times across the 1200-tile wall — so at wall scale they read as a dense, colourful editorial mosaic, and only become legible photographs once you zoom in 5×. They form one cohesive high-fashion **studio-editorial** set: bold, saturated, warm-lit fashion photography — models embracing/posing, cropped accessory and still-life shots (handbags, sunglasses, a single high heel, a vintage phone/radio, a disco ball), and moody light-beam portraits. Recurring palette of **deep reds, teals, greens, warm yellows and neutral studio tones**. Provide 50 files named sequentially; if fewer are available, repeat them — the mosaic is intentionally random and repeating anyway. No logos or brand marks.

## Behavior notes
- **State machine:** exactly two states, out (default) and in. The dimmed `.active` button marks the current state and is un-clickable; the live button is the other one. Clicks are ignored if already in that state (the early-return guards).
- **Drag only works while zoomed in** (`#drag-layer` is `display:none` when zoomed out, and handlers early-return unless `isZoomed`). Both mouse and touch are supported (`touch-action:none` on the layer).
- **No scroll anywhere** — `body { overflow:hidden }`, the stage is `position:absolute`. Nothing is scroll-driven.
- Heavy tile count (1200 DOM nodes); `will-change:transform` on tiles and gallery, and the reveal/zoom lean on GPU transforms. Reasonable on desktop; on mobile it still functions (touch drag works) but is the perf-sensitive part.
- No reduced-motion handling in the original; the load reveal autoplays once, everything else is user-triggered.
