# Responsive Minimap Image Scrubber (vertical strip on desktop, horizontal on mobile)

## Goal
Build a full-viewport gallery navigator where **a minimap of 15 small thumbnails (a vertical strip on the right on desktop, a horizontal strip near the bottom on mobile) is scrubbed with the mouse wheel / touch drag: a fixed 1px-bordered indicator frame stays put while the thumbnail strip glides underneath it with lerped inertia; whichever thumbnail overlaps the indicator the most is dimmed to 30% opacity and instantly swapped into a large centered preview image. Clicking any thumbnail eases the strip so that thumbnail lands centered inside the indicator, using a slower (softer) lerp**. The star effect is the buttery lerp-driven scrubbing of the strip plus the live preview swap.

## Tech
Vanilla HTML/CSS/JS with an ES module script (`<script type="module" src="./script.js">`). **No GSAP, no plugins, no Lenis, no libraries at all** — the entire effect is a `requestAnimationFrame` loop with manual linear interpolation (lerp) writing `transform: translateX/Y` on the strip, plus `wheel`, `touchstart`/`touchmove`, `click` and `resize` listeners. There is no page scroll — the wheel is hijacked (`preventDefault`) to drive the minimap.

## Layout / HTML
```
.container                          (full-viewport stage)
  nav                               (fixed top bar)
    p  "Motionprompts"
    p  "Menu"
  .site-info
    p  "E427"
    p > span  "Responsive Minimap"
  .img-preview
    img                             (large preview, src = image 1 initially)
  .minimap
    .indicator                      (empty framed box, the fixed "lens")
    .items
      .item > img   × 15            (thumbnails, images 1…15 in order)
```

## Styling
Font: **"Neue Haas Grotesk Display Pro"** on `body` (a neutral grotesque sans; no import needed — system fallback is fine).

- `* { margin:0; padding:0; box-sizing:border-box; }`
- `img { width:100%; height:100%; object-fit:cover; transition: opacity 0.2s; user-select:none; }` — **the `transition: opacity 0.2s` on all images is what makes the active-thumbnail dim fade smoothly.**
- `p { font-size:14px; font-weight:600; -webkit-font-smoothing:antialiased; user-select:none; }`
- `.container`: `position:relative; width:100vw; height:100vh; overflow:hidden; background-color:#f1efe7;` (warm off-white / bone).
- `nav`: `position:fixed; top:0; left:0; width:100vw; padding:1.5em; display:flex; justify-content:space-between; align-items:center;`
- `.site-info`: `position:absolute; top:50%; left:1.5em; display:flex; gap:4px;` — the `span` ("Responsive Minimap") is `color:#9a9994` (muted grey).
- `.img-preview`: `position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:50%; height:75%; overflow:hidden;`
- `.img-preview img`: `position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; height:100%; object-fit:contain;` — **contain, not cover**, so the whole photo is always visible.
- `.minimap`: `position:absolute; top:50%; right:8em; width:80px;` (note: NO translateY — the strip's top edge starts at the vertical middle of the screen and the strip extends downward from there).
- `.indicator`: `position:absolute; top:0; left:0; width:100%; height:60px; border:1px solid #000; z-index:2;` — a fixed hairline rectangle sitting over the first visible thumbnail slot.
- `.items`: `position:relative; width:100%; height:100%; display:flex; flex-direction:column; gap:0; will-change:transform;`
- `.item`: `width:100%; height:60px; padding:5px; cursor:pointer;` — the 5px padding creates the visual gaps between thumbnails and insets them inside the indicator frame.

### Mobile (`@media (max-width: 900px)`)
- `body` and `.container`: `overflow:hidden; touch-action:none;`
- `.site-info`: `top:1.5em; left:50%; transform:translateX(-50%);` (moves to top-center).
- `.minimap`: `top:auto; right:auto; bottom:5em; left:50%; width:auto; height:80px; touch-action:none;` (note: NO translateX — the strip's left edge starts at the horizontal middle and extends rightward).
- `.indicator`: `width:60px; height:100%;`
- `.items`: `flex-direction:row; width:max-content; height:100%; touch-action:none;`
- `.item`: `width:60px; height:100%; padding:5px;`
- `.img-preview`: `top:45%; width:75%; height:50%;`

## The effect (exhaustive — vanilla JS, lerp + rAF)

### Orientation & dimensions
- `isHorizontal = window.innerWidth <= 900` (mobile = horizontal strip, desktop = vertical strip).
- `updateDimensions()` recomputes and returns:
  - **desktop (vertical):** `itemSize = firstItem.getBoundingClientRect().height` (60), `containerSize = items.getBoundingClientRect().height`, `indicatorSize = indicator.getBoundingClientRect().height` (60).
  - **mobile (horizontal):** `itemSize = firstItem.getBoundingClientRect().width` (60), `containerSize = items.scrollWidth` (**scrollWidth**, because the row overflows), `indicatorSize = indicator.getBoundingClientRect().width` (60).
- `maxTranslate = containerSize - indicatorSize` — the farthest the strip may slide (in the negative direction).

### State
- `currentTranslate = 0` (rendered offset), `targetTranslate = 0` (goal offset), both always clamped to `[-maxTranslate, 0]`.
- `isClickMove = false` — true while a click-initiated glide is in flight (selects the slower lerp factor).
- `currentImageIndex = 0`, `activeItemOpacity = 0.3`.
- `lerp(start, end, factor) = start + (end - start) * factor`.

### rAF loop `animate()` (runs forever)
1. `lerpFactor = isClickMove ? 0.05 : 0.075` — click glides are noticeably slower/softer than wheel scrubs.
2. `currentTranslate = lerp(currentTranslate, targetTranslate, lerpFactor)`.
3. If `Math.abs(currentTranslate - targetTranslate) > 0.01`:
   - write the transform on `.items`: `translateY(${currentTranslate}px)` on desktop, `translateX(${currentTranslate}px)` on mobile;
   - call `getItemInIndicator()` to find the active thumbnail, then `updatePreviewImage(activeIndex)`.
4. Else (settled): `isClickMove = false`. The loop itself never stops — `requestAnimationFrame(animate)` every frame.

### `getItemInIndicator()` — max-overlap picking + dim
1. Reset ALL thumbnail `<img>`s to `opacity = 1`.
2. The indicator's window in strip-space: `indicatorStart = -currentTranslate`, `indicatorEnd = indicatorStart + indicatorSize`.
3. For each item `index`: `itemStart = index * itemSize`, `itemEnd = itemStart + itemSize`; overlap = `max(0, min(indicatorEnd, itemEnd) - max(indicatorStart, itemStart))`. Track the index with the **greatest overlap**.
4. Set that item's `<img>` to `opacity = 0.3` (fades over the CSS 0.2s transition) and return the index.

### `updatePreviewImage(index)`
Only when `index !== currentImageIndex`: store it, read the winning thumbnail's `src` attribute and set it on the big `.img-preview img` — an **instant src swap** (no crossfade).

### Wheel input (desktop scrub)
`container.addEventListener("wheel", handler, { passive: false })`:
- `e.preventDefault()`; `isClickMove = false`.
- `scrollVelocity = clamp(e.deltaY * 0.5, -20, 20)` — half the wheel delta, capped at ±20px per event.
- `targetTranslate = clamp(targetTranslate - scrollVelocity, -maxTranslate, 0)` — scrolling down slides the strip up (negative translate).

### Touch input (mobile only)
- `touchstart`: if `isHorizontal`, record `touchStartY = e.touches[0].clientY`.
- `touchmove` (`{ passive: false }`): if `isHorizontal`, `deltaY = touchStartY - touchY`, then the exact same velocity math as the wheel (`clamp(deltaY * 0.5, -20, 20)` subtracted from `targetTranslate`, clamped to `[-maxTranslate, 0]`); update `touchStartY = touchY` each move and `e.preventDefault()`. Note: a **vertical** finger drag drives the **horizontal** strip.

### Click-to-center
On each `.item` click:
- `isClickMove = true` (slower 0.05 lerp for a gentle glide);
- `targetTranslate = -index * itemSize + (indicatorSize - itemSize) / 2`, then clamp to `[-maxTranslate, 0]` — this parks the clicked thumbnail exactly inside the indicator frame.

### Resize
On `window.resize`: re-run `updateDimensions()` (also refreshes `isHorizontal`), recompute `maxTranslate`, clamp `targetTranslate` to the new range, **snap** `currentTranslate = targetTranslate` (no glide), and immediately write the transform in the correct axis for the new orientation.

### Init (bottom of the module)
1. Dim thumbnail 0: `itemImages[0].style.opacity = 0.3`.
2. `updatePreviewImage(0)` (preview starts on image 1).
3. Start `animate()`.

## Assets / images
**15 portrait editorial photographs, aspect ratio ~3:4** (e.g. 900×1200) — a cohesive fashion/lifestyle series with varied subjects, colors and settings so consecutive thumbnails are clearly distinguishable at 70×50px. Each image plays two roles: minimap thumbnail (cropped by `object-fit: cover` inside its 60px cell) and, when active, the large centered preview (letterboxed by `object-fit: contain`). Name them `img1.jpeg` … `img15.jpeg` and reference each exactly twice: once in its `.item` and (for `img1.jpeg` only) as the initial `.img-preview` src. No real brand names anywhere — use the neutral demo strings from the Layout section.

## Behavior notes
- The page never scrolls: the container fills the viewport, `overflow: hidden`, and the wheel/touch events are captured with `preventDefault` to drive the minimap only.
- Fully responsive at the 900px breakpoint: desktop = vertical strip on the right driven by wheel; mobile = horizontal strip bottom-center driven by vertical touch drags. Resizing across the breakpoint keeps working (dimensions and axis are recomputed).
- The strip only moves between `0` (first item under the indicator) and `-maxTranslate` (last item under the indicator) — it can never scrub past either end.
- No `prefers-reduced-motion` branch. The only "loop" is the always-running rAF lerp; when settled (delta ≤ 0.01) it simply stops writing to the DOM.
- Because indicator and item are the same size (60px), the click-centering term `(indicatorSize - itemSize) / 2` evaluates to 0 — keep the formula anyway so it stays correct if sizes diverge.
