# Infinite Draggable Image Gallery

## Goal
Build a full-viewport, **infinitely draggable image gallery**: an endless grid of small portrait thumbnails on a pale canvas that you grab and pan in any direction forever (DOM items are virtualized — created/destroyed as they enter/leave a buffered viewport), with a **lerp-smoothed drag and velocity-based momentum** on release. Clicking a thumbnail is the star moment: the tile hides, every other tile fades out, a pale overlay closes in, and a **fixed clone of the image expands from the thumbnail's exact spot to a large centered frame via a single GSAP `fromTo` tween on a custom "hop" ease**, while the project's title **staggers up word-by-word from behind a clip mask** (SplitType). Clicking the expanded image or the overlay reverses everything back into the grid.

## Tech
Vanilla HTML/CSS/JS with ES module imports. Use `gsap` (npm) plus the GSAP plugin **`CustomEase`**, and **`split-type`** (npm, default export `SplitType`) for the word splitting. No ScrollTrigger, no Lenis — the page never scrolls (`body { overflow: hidden }`); all motion is a custom `requestAnimationFrame` lerp loop plus GSAP tweens. Ship `index.html`, `styles.css`, an ES-module `script.js`, and a tiny `items.js` data module.

Register and create the custom ease once at startup:
```js
gsap.registerPlugin(CustomEase);
CustomEase.create("hop", "0.9, 0, 0.1, 1");
```
`"hop"` = cubic-bezier(0.9, 0, 0.1, 1): a heavy slow-in / slow-out snap used for both the expand and the collapse.

## Layout / HTML
```html
<nav>
  <div class="logo"><a href="#">Motionprompts</a></div>
  <div class="links">
    <a href="#">About</a>
    <a href="#">Contact</a>
    <div class="socials">
      <a href="#">FB</a><a href="#">IG</a><a href="#">YT</a>
    </div>
  </div>
</nav>

<footer>
  <p>Experiment 445</p>
  <p>Drag to Explore</p>
</footer>

<div class="container">
  <div class="canvas" id="canvas"></div>
  <div class="overlay" id="overlay"></div>
</div>

<div class="project-title"><p></p></div>

<script type="module" src="./script.js"></script>
```
- `.container` — the full-viewport drag surface (all mouse/touch listeners hang off it).
- `.canvas` — the infinite plane; the JS translates this element and appends/removes `.item` tiles inside it.
- `.overlay` — a full-screen pale scrim toggled with a CSS class during the expanded state.
- `.project-title p` — starts empty; the JS injects the clicked project's title and splits it into words.

`items.js` exports a default array of 20 short, evocative two-word project titles (index i pairs with image i+1), e.g.:
```js
const items = [
  "Chromatic Loopscape", "Solar Bloom", "Neon Handscape", "Echo Discs",
  "Void Gaze", "Gravity Sync", "Heat Core", "Fractal Mirage",
  "Nova Pulse", "Sonic Horizon", "Dream Circuit", "Lunar Mesh",
  "Radiant Dusk", "Pixel Drift", "Vortex Bloom", "Shadow Static",
  "Crimson Phase", "Retro Cascade", "Photon Fold", "Zenith Flow",
];
export default items;
```

## Styling
- Import the Google font **Inter** (variable weights). `* { margin:0; padding:0; box-sizing:border-box; user-select:none; }`
- `body`: `font-family:"Inter",sans-serif; background-color:#e3e3db; overflow:hidden;` — a warm pale-grey/bone canvas.
- `a, p`: `display:block; text-decoration:none; color:#fff; font-size:14px; font-weight:600; letter-spacing:-0.01rem; -webkit-font-smoothing:antialiased;`
- `nav, footer`: `position:absolute; left:0; width:100vw; padding:1em; display:flex; justify-content:space-between; gap:2em; mix-blend-mode:difference; z-index:10000;` — nav pinned `top:0`, footer `bottom:0`. The `mix-blend-mode:difference` makes the white UI text invert over whatever passes beneath (dark thumbnails vs pale background).
- `.links, .socials`: `display:flex; gap:2em;` and `nav > *, .links a { flex:1; }`
- `.container`: `position:relative; width:100vw; height:100vh; overflow:hidden; cursor:grab;`
- `.canvas`: `position:absolute; will-change:transform;`
- `.item`: `position:absolute; width:120px; height:160px; overflow:hidden; background-color:#000; cursor:pointer;` — small 3:4 portrait tiles.
- `.expanded-item`: `position:fixed; z-index:100; top:50%; left:50%; transform:translate(-50%,-50%); background-color:#e3e3db; overflow:hidden; cursor:pointer;` — the clone lives dead-center; GSAP `x`/`y` offsets move it relative to the viewport center.
- `img`: `width:100%; height:100%; object-fit:cover; pointer-events:none;`
- `.overlay`: `position:fixed; top:0; left:0; width:100%; height:100%; background-color:#e3e3db; pointer-events:none; transition:opacity 0.3s ease; opacity:0; z-index:2;` — with `.overlay.active { pointer-events:auto; opacity:1; }`. Note z-index 2: it covers the grid but sits *under* the `.expanded-item` (z 100) and the nav/footer/title (z 10000).
- `.project-title`: `position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; text-align:center; pointer-events:none; z-index:10000;`
- `.project-title p`: `position:relative; height:42px; color:#fff; clip-path:polygon(0 0, 100% 0, 100% 100%, 0% 100%);` — the fixed-height box + clip-path is the **mask** the words rise out of.
- `.project-title p .word`: `position:relative; display:inline-block; font-family:"Inter"; font-size:36px; letter-spacing:-0.02rem; margin-right:0.1em; transform:translateY(0%); will-change:transform;` (the `.word` class is what SplitType produces).

## GSAP effect — be exhaustive

### Constants & state
```js
const itemCount = 20;   // distinct images
const itemGap   = 150;  // px between tiles
const columns   = 4;    // used only in the image-index formula
const itemWidth = 120, itemHeight = 160;
```
State variables: `isDragging`, `startX/startY`, `targetX/targetY` (where the canvas wants to be), `currentX/currentY` (where it is), `dragVelocityX/Y`, `lastDragTime`, `mouseHasMoved`, `visibleItems` (a `Set` of ids), `lastUpdateTime`, `lastX/lastY`, `isExpanded`, `activeItem`, `canDrag` (starts `true`), `originalPosition`, `expandedItem`, `activeItemId`, `titleSplit`.

### 1) The infinite pan — rAF lerp loop
An endless `requestAnimationFrame` loop `animate()`:
- Only while `canDrag`: lerp with factor **0.075** —
  `currentX += (targetX - currentX) * 0.075;` (same for Y), then
  `canvas.style.transform = translate(currentX px, currentY px)`.
- Track how far the canvas moved since the last virtualization pass; call `updateVisibleItems()` when **distance moved > 100px** OR **more than 120ms** elapsed, then store `lastX/lastY/lastUpdateTime`.
- Always re-queue `requestAnimationFrame(animate)`.

**Mouse drag** (listeners: `mousedown` on `.container`, `mousemove`/`mouseup` on `window`):
- `mousedown` (ignored unless `canDrag`): `isDragging = true`, `mouseHasMoved = false`, record `startX/startY = e.clientX/Y`, set `container.style.cursor = "grabbing"`.
- `mousemove` (only while dragging and `canDrag`): `dx/dy` = movement since the previous event; if `|dx| > 5 || |dy| > 5` set `mouseHasMoved = true` (this later suppresses the click-to-expand). Compute `dt = max(10, now - lastDragTime)` in ms and per-ms velocities `dragVelocityX = dx/dt`, `dragVelocityY = dy/dt`. Then **add** the deltas to the target: `targetX += dx; targetY += dy;` and reset `startX/startY` to the current pointer.
- `mouseup`: stop dragging, restore `cursor:"grab"`; **momentum** — if `|dragVelocityX| > 0.1 || |dragVelocityY| > 0.1`, throw the canvas: `targetX += dragVelocityX * 200; targetY += dragVelocityY * 200;` (momentumFactor = 200). The 0.075 lerp turns that offset into a smooth glide-out.

**Touch drag**: `touchstart` on the container and `touchmove`/`touchend` on window mirror the mouse logic (same 5px `mouseHasMoved` threshold, `targetX/Y += dx/dy`), but **no momentum** is applied on `touchend`.

### 2) Virtualized infinite grid — `updateVisibleItems()`
The grid is conceptually infinite in all four directions; only tiles near the viewport exist in the DOM.
- `buffer = 2.5`; `viewWidth = innerWidth * 3.5`, `viewHeight = innerHeight * 3.5`.
- Determine travel direction: `movingRight = targetX > currentX`, `movingDown = targetY > currentY`; direction buffers `directionBufferX = movingRight ? -300 : 300` (same for Y) extend the window toward where you're heading.
- Column/row range (cell pitch = `itemWidth + itemGap` = 270 horizontally, `itemHeight + itemGap` = 310 vertically):
  ```js
  startCol = Math.floor((-currentX - viewWidth/2 + (movingRight ? directionBufferX : 0)) / (itemWidth + itemGap));
  endCol   = Math.ceil ((-currentX + viewWidth*1.5 + (!movingRight ? directionBufferX : 0)) / (itemWidth + itemGap));
  // startRow/endRow identical with currentY, viewHeight, movingDown, itemHeight
  ```
- For every `(col,row)` in range: id = `"${col},${row}"`. Skip if already in `visibleItems`, or if it is the currently-expanded tile (`activeItemId === id && isExpanded`). Otherwise create `<div class="item" id="col,row">` at `left = col * (itemWidth+itemGap)`, `top = row * (itemHeight+itemGap)` (store `dataset.col/row`), containing an `<img>` whose image number is
  ```js
  const itemNum = (Math.abs(row * columns + col) % itemCount) + 1;  // 1..20
  img.src = `/path/to/img${itemNum}.jpg`;
  ```
  so the 20 images tile deterministically across the infinite plane. Attach a `click` listener that bails if `mouseHasMoved || isDragging`, else calls `handleItemClick(item)`. Append to the canvas and register in `visibleItems`.
- Finally sweep `visibleItems`: remove any DOM item whose id fell out of the current range (or that is the active expanded tile) and delete it from the set.

### 3) Click → expand (the hero tween)
`handleItemClick` toggles: if already expanded → close; else `expandItem(item)`:
1. Flags: `isExpanded = true`, `activeItem = item`, `activeItemId = item.id`, `canDrag = false`, `container.style.cursor = "auto"`.
2. Parse the image number out of the tile's `img.src` (regex `/img(\d+)\.jpg/`); `titleIndex = (imgNum - 1) % items.length` → the matching title from `items.js`.
3. **Title setup** — `setAndAnimateTitle(title)`: if a previous `SplitType` instance exists, `revert()` it; set `projectTitleElement.textContent = title`; `titleSplit = new SplitType(el, { types: "words" })`; then `gsap.set(titleSplit.words, { y: "100%" })` so every word starts fully below the 42px clip mask.
4. Hide the source tile: `item.style.visibility = "hidden"`, and stash `originalPosition = { id, rect: item.getBoundingClientRect(), imgSrc }`.
5. `overlay.classList.add("active")` → the pale scrim fades in via its CSS `opacity 0.3s ease` transition and starts intercepting clicks.
6. Build the clone: `<div class="expanded-item">` sized `120 × 160` px with the same `<img>`, appended to `<body>`; clicking it closes.
7. **Fade the grid**: every other `.item` → `gsap.to(el, { opacity: 0, duration: 0.3, ease: "power2.out" })`.
8. Target size: `targetWidth = window.innerWidth * 0.4`, `targetHeight = targetWidth * 1.2` (keeps the 3:4 portrait ratio, 40% of viewport width).
9. `gsap.delayedCall(0.5, animateTitleIn)` — the title starts rising halfway through the expansion.
10. **The expansion tween** (remember the clone is `position:fixed` centered with `translate(-50%,-50%)`, so `x/y` are offsets from the viewport center):
    ```js
    gsap.fromTo(expandedItem,
      { width: itemWidth, height: itemHeight,
        x: rect.left + itemWidth/2  - window.innerWidth/2,
        y: rect.top  + itemHeight/2 - window.innerHeight/2 },
      { width: targetWidth, height: targetHeight, x: 0, y: 0,
        duration: 1, ease: "hop" });
    ```
    i.e. it starts exactly over the clicked thumbnail (position + size) and lands dead-center at 40vw wide — one tween animating `width`, `height`, `x`, `y` together on the `"hop"` CustomEase.
- `animateTitleIn()`: `gsap.to(titleSplit.words, { y: "0%", duration: 1, stagger: 0.1, ease: "power3.out" })` — words rise out of the clip mask left-to-right, 0.1s apart.

### 4) Close → collapse back
`closeExpandedItem()` (fired by clicking the expanded image or the overlay):
1. `animateTitleOut()`: `gsap.to(titleSplit.words, { y: "-100%", duration: 1, stagger: 0.1, ease: "power3.out" })` — words exit **upward** through the mask (in from below, out through the top).
2. `overlay.classList.remove("active")` (CSS fades the scrim out over 0.3s).
3. Restore the grid: every `.item` except the active one → `gsap.to(el, { opacity: 1, duration: 0.5, delay: 0.5, ease: "power2.out" })` — they wait half a second, then fade back while the clone shrinks.
4. Reverse tween on the clone, back to the stored original rect:
   ```js
   gsap.to(expandedItem, {
     width: itemWidth, height: itemHeight,
     x: originalRect.left + itemWidth/2  - window.innerWidth/2,
     y: originalRect.top  + itemHeight/2 - window.innerHeight/2,
     duration: 1, ease: "hop",
     onComplete: () => { /* cleanup */ } });
   ```
   In `onComplete`: remove the clone from the DOM, set the original tile (looked up by `activeItemId`) back to `visibility:"visible"`, null out `expandedItem/activeItem/originalPosition/activeItemId`, `isExpanded = false`, `canDrag = true`, `container.style.cursor = "grab"`, and zero both drag velocities.

### 5) Resize
On `window.resize`: if currently expanded, re-fit the clone — `gsap.to(expandedItem, { width: innerWidth*0.4, height: innerWidth*0.4*1.2, duration: 0.3, ease: "power2.out" })`; otherwise just call `updateVisibleItems()`.

### 6) Boot
Call `updateVisibleItems()` once, then start `animate()`.

## Assets / images
**20 portrait images (3:4 aspect, e.g. 480×640+), named `img1.jpg` … `img20.jpg`**, tiled endlessly across the grid. They share one art direction: **abstract glossy 3D renders on black backgrounds** — iridescent chrome and holographic materials (stacked ribbon loops, twisted mobius waves, torus rings, coiled spring helixes, chrome chain links and a padlock, intersecting discs and cones, bundles of tubes, spiral drip forms, stylized reaching hands, an anatomical heart with an orbiting ring). Palette: oil-slick purples, magentas, oranges, golds and greens against deep black — the black tiles pop hard against the `#e3e3db` page and drive the `mix-blend-mode:difference` UI. Each image i pairs with title i in `items.js`. No text, no logos in the images.

## Behavior notes
- The page never scrolls; the entire experience is the drag plane. Cursor: `grab` → `grabbing` while dragging → `auto` while expanded.
- Works with mouse **and** touch; momentum-throw applies to mouse only.
- The 5px `mouseHasMoved` threshold guarantees a drag never accidentally triggers an expand.
- While expanded, dragging is fully disabled (`canDrag=false`) and the virtualization loop pauses (it runs inside the `canDrag` branch).
- Item ids are `"col,row"` strings; virtualization keeps DOM size roughly constant no matter how far you pan.
- Nav ("Motionprompts", About/Contact, FB/IG/YT) and footer ("Experiment 445" / "Drag to Explore") are static chrome in white, inverted by `mix-blend-mode:difference`.
