# ASCII Image Reveal Effect

## Goal
Build a full-viewport editorial photo grid where, on page load, **every photo first materialises as live ASCII art painted on a `<canvas>`**, then swaps to the real photo. Each canvas fills itself cell-by-cell in **random order**; bright cells snap straight to their final glyph, while dark cells **scramble through a stream of dense random glyphs** for about a second before locking in. When a whole canvas has fully settled it dissolves and reveals the underlying photograph. Images kick off one after another in a staggered cascade across the grid.

## Tech
Vanilla HTML/CSS/JS. **No GSAP, no libraries, no framework, no npm dependencies** — the entire effect is hand-rolled with the **Canvas 2D API** plus `setTimeout` / `setInterval` timers. A single ES-module script (`<script type="module" src="./script.js">`) drives everything. Do not reach for GSAP or a rAF tween library; the original is intentionally timer-driven and per-cell.

## Layout / HTML
A single `<section class="gallery">` containing **15** `<div class="img">` wrappers. Each wrapper holds one `<img class="ascii-reveal" src="…" />`. The script inserts a `<canvas>` as a sibling of the `<img>` inside each `.img` at runtime.

```html
<section class="gallery">
  <div class="img"><img class="ascii-reveal" src="/…/img1.jpg" /></div>
  <div class="img"><img class="ascii-reveal" src="/…/img2.jpg" /></div>
  … 15 total …
</section>
```

The `.img` DOM order is what drives the load stagger (item 1 starts first, item 15 last).

## Styling

### Reset & page
- `* { margin:0; padding:0; box-sizing:border-box; }`
- `body { background:#111; }` — near-black. This exact `#111` is reused as the canvas cell background so the ASCII art sits seamlessly on the page.

### Grid (this defines the scattered look)
`.gallery`:
- `position:relative; width:100%; height:100svh; padding:2rem;`
- `display:grid; grid-template-columns:repeat(8, 1fr); grid-template-rows:repeat(4, 1fr); gap:2rem;`

An 8×4 grid, but only 15 cells are filled and the items are **scattered / sparse**, not sequential. Place each `.img:nth-child(n)` at an explicit `grid-column` / `grid-row` (note some columns exceed 8, which pushes those items into implicit tracks — keep them as-is for the scattered composition):

| item | column | row |
|------|--------|-----|
| 1 | 1 | 1 |
| 2 | 2 | 1 |
| 3 | 5 | 1 |
| 4 | 1 | 2 |
| 5 | 3 | 2 |
| 6 | 6 | 2 |
| 7 | 8 | 2 |
| 8 | 1 | 3 |
| 9 | 2 | 3 |
| 10 | 4 | 3 |
| 11 | 7 | 3 |
| 12 | 10 | 3 |
| 13 | 2 | 4 |
| 14 | 6 | 4 |
| 15 | 9 | 4 |

### Tiles, images, canvas
- `.img { position:relative; width:100%; aspect-ratio:4/5; align-self:center; overflow:hidden; }` — every tile is a **4:5 portrait** box.
- `.img img { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; display:none; }` — the real photo is **hidden by default** (`display:none`) and only shown after reveal.
- `.img canvas { position:absolute; inset:0; width:100%; height:100%; }` — the runtime canvas is stretched to fill the tile.
- Reveal swap classes: `.img.revealed canvas { display:none; }` and `.img.revealed img { display:block; }`. Adding the `revealed` class to a tile hides its canvas and shows the photo — this is the only "reveal" mechanism (a hard swap, no cross-fade).

### Responsive
`@media (max-width:1000px)`: `.gallery { height:auto; grid-template-columns:repeat(2,1fr); grid-template-rows:none; }` and `.img:nth-child(n) { grid-column:auto; grid-row:auto; }` — the scattered placement collapses to a plain 2-column auto-flow grid.

## The effect (be exact — this is the whole component)

There is **no timeline library**. The animation is a per-cell state machine driven by `setTimeout` (initial pop-in) and one shared `setInterval` (the scramble ticker) per image. Reproduce these constants and formulas precisely.

### Constants (top of the module)
```js
const ASCII_CHARS      = "........:::=+xX#0369"; // 20 chars: 8 dots, then :::=+xX#0369
const FONT_SIZE        = 14;   // px, monospace
const ASPECT_WIDTH     = 4;
const ASPECT_HEIGHT    = 5;
const ASCII_COLUMNS    = 25;   // sampling columns
const IMAGE_STAGGER_MS = 100;  // delay between images (by DOM index)
const CELL_APPEAR_MS   = 2;    // delay between cells popping in within one image
const SCRAMBLE_COUNT   = 10;   // how many scramble steps a dark cell goes through
const SCRAMBLE_SPEED_MS= 100;  // scramble ticker interval
const REVEAL_DELAY_MS  = 0;    // delay after full settle before showing the photo
```

### Derived values
- `denseCharIndex = ASCII_CHARS.lastIndexOf(".")` → **7** (the last of the 8 leading dots).
- `denseChars = ASCII_CHARS.slice(denseCharIndex + 1).split("")` → the **dark/dense ramp** `[":",":",":","=","+","x","X","#","0","3","6","9"]` (12 glyphs). These are the glyphs used for scrambling.
- Measure a monospace glyph once with an offscreen context set to `${FONT_SIZE}px monospace`: `charWidth = Math.ceil(measureText("M").width)` (≈ **9px**), `charHeight = FONT_SIZE` (**14px**).
- `ASCII_ROWS = Math.round(ASCII_COLUMNS * (ASPECT_HEIGHT/ASPECT_WIDTH) * (charWidth/charHeight))` → `round(25 * 1.25 * 9/14)` ≈ **20**. So the sampling grid is **25 columns × 20 rows = 500 cells** per image (matching the 4:5 tile aspect).

### Setup per image
`document.querySelectorAll("img.ascii-reveal").forEach((img, index) => { … })`:
- Create a `<canvas>`, append it into the `.img` wrapper (`img.closest(".img").appendChild(canvas)`).
- `staggerDelay = index * IMAGE_STAGGER_MS` (0, 100, 200, … 1400 ms) — each image's whole animation is offset by its DOM index.
- Start when the image is decoded: if `img.complete && img.naturalWidth`, run immediately; else attach a `load` listener. Then call `startEffect(img, canvas, staggerDelay)`.

### Step 1 — image → ASCII grid (`imageToAsciiGrid`)
1. **Center-crop to 4:5.** Compare `imageAspect = naturalWidth/naturalHeight` against `itemAspect = 4/5`. If the image is wider, crop width to `naturalHeight * 4/5` and center horizontally; else crop height to `naturalWidth / (4/5)` and center vertically. (This mirrors CSS `object-fit:cover`.)
2. **Downsample.** Draw the cropped region into a tiny offscreen canvas sized `ASCII_COLUMNS × ASCII_ROWS` (25×20) via `drawImage(img, cropX, cropY, cropW, cropH, 0,0, 25, 20)`, then `getImageData`.
3. **Per pixel → glyph.** For each of the 500 cells compute luminance `brightness = (R*0.299 + G*0.587 + B*0.114) / 255`, then `charIndex = Math.min(ASCII_CHARS.length - 1, Math.floor((1 - brightness) * ASCII_CHARS.length))`. Bright pixels → low index (dots / `:`), dark pixels → high index (`#0369`). Store two parallel grids: `asciiGrid[row][col] = ASCII_CHARS[charIndex]` (the final glyph) and `brightnessGrid[row][col] = charIndex` (its numeric index).

### Step 2 — prepare canvas (`prepareCanvas`)
- `dpr = 2`. Set `canvas.width = ASCII_COLUMNS * charWidth * dpr`, `canvas.height = ASCII_ROWS * charHeight * dpr` (backing store at 2×), while CSS stretches it to fill the tile.
- Fill the whole canvas with `#111` (the page background) so it starts as a solid dark block.

### Drawing a cell (`drawCharacter`)
Each cell paint = fill its `charWidth × charHeight` rect with **`#111`** (erase), then `fillText` the glyph in **`#c8c8c8`** (light gray) at `(col*charWidth, row*charHeight)`. Before animating, the render context is set: `ctx.setTransform(dpr,0,0,dpr,0,0)`, `ctx.font = "${charHeight}px monospace"`, `ctx.textBaseline = "top"`.

### Step 3 — animate cells (`animateCells`) — the core
- `totalCells = 25*20 = 500`. Track `scrambleState = new Array(500).fill(null)` and `settledCount = 0`.
- **Random pop-in order:** build `cellOrder = [0..499]` and **Fisher–Yates shuffle** it. Cells appear in this shuffled order, so the picture assembles as random scattered speckle, not row-by-row.
- **Schedule each cell** with `setTimeout(fn, staggerDelay + i * CELL_APPEAR_MS)` (i = position in the shuffled order, so cells within an image fire 2 ms apart on top of the image's stagger). When a cell fires:
  - Compute `row`, `col` from its index. `isDark = brightnessGrid[row][col] > denseCharIndex` (i.e. `charIndex > 7`).
  - **Bright cell** (`!isDark`): draw its final glyph immediately, set `scrambleState = 0`, increment `settledCount`. It's done in one shot.
  - **Dark cell**: draw a **random glyph from `denseChars`** and set `scrambleState = SCRAMBLE_COUNT` (10) — this cell now enters the scramble loop.
- **Scramble ticker:** one `setInterval(…, SCRAMBLE_SPEED_MS)` (every 100 ms). Each tick, for every cell whose `scrambleState` is `> 0`:
  - If `remaining === 1`: draw the **final** glyph (`asciiGrid[row][col]`), set state `0`, increment `settledCount` (cell settles).
  - Else: draw a **new random `denseChars` glyph** and decrement `scrambleState` by 1.
  - So a dark cell flickers through ~9 random dense glyphs over ~1 s (10 steps × 100 ms) before snapping to its true value. Bright cells never scramble.
  - When no cells are still scrambling **and** `settledCount === totalCells`, `clearInterval` the ticker.
- **Reveal:** the moment `settledCount === totalCells` (reached in either the pop-in path or the ticker), call `scheduleImageReveal(canvas)` → `setTimeout(() => canvas.closest(".img").classList.add("revealed"), REVEAL_DELAY_MS)`. The `revealed` class hides the canvas and shows the real photo (instant swap).

### Net timing feel
- Image 1 begins at load; each subsequent image begins 100 ms later (staggered cascade across the sparse grid).
- Within an image: 500 cells pop in over ~1 s (500 × 2 ms) in random order; dark regions keep flickering through dense glyphs for ~1 s each; once the last cell settles the canvas instantly becomes the photograph.
- No easing curves, no interpolation, no scroll/hover triggers — it's a pure **on-load** autoplay, once.

## Assets / images
**15 images**, a cohesive editorial **fashion & cosmetics** set that pairs clean studio product still lifes with grainy motion-blur portraiture, held together by a **muted neutral palette** (warm beige/taupe backgrounds, creamy off-whites, brushed silver/grey, soft blush-pink accents, plus a couple of near-black grounds and warm skin tones). All tiles are rendered as **4:5 portraits**; the script center-crops to 4:5 regardless of source aspect (sources range from tall portrait through square to wide landscape) and downsamples heavily, so fine detail is lost and overall composition, tone and contrast matter far more than resolution. The set mixes these roles:
- **Product / packaging still lifes** — the bulk of the set, softly studio-lit: a brushed-aluminium screw-top tin on a pale grey seamless; a cropped cluster of skincare/makeup on a warm beige backdrop (frosted cream jar, a rose-gold lipstick with a soft pink bullet, a frosted pump bottle); a cream cosmetic bottle with a glossy black cap staged on a stone slab among dried beige gypsophila (a wide landscape source); and an open silver compact with a nude/peach pressed powder shot on a **dark near-black** ground for high contrast.
- **Fashion / product-on-body detail** — a cropped studio shot of a beige suede cowboy boot with a dark stacked heel mid-stride against a pale off-white seamless.
- **Motion-blur editorial portraits** — grainy, moody figures caught in movement: a mid-body figure in a grey top and white trousers against cool grey; a high-key, washed-out pale-pink close-up of a fair-haired face; and a warm-toned, near-black nightlife frame of blurred faces and skin.

Any set of ~15 minimal, softly-lit fashion/cosmetics images (neutral product still lifes plus a few blurred editorial portraits) at 4:5 works. Do not use real brand imagery. Because dark cells scramble and light cells settle instantly, frames with clear light/dark separation (pale subject on a soft ground, or a dark subject on a light ground — e.g. the silver compact on black) read best; the mostly light, low-contrast product shots settle quickly with only small pockets of scramble in the shadows.

## Behavior notes
- **Desktop-first.** The scattered 8×4 grid is designed for wide viewports; below 1000px it collapses to a simple 2-column flow (see Responsive).
- **Runs once on load** — there is no reduced-motion branch, no loop, no scroll/hover/click interaction in the original.
- `100svh` height and `svh`-safe sizing keep the grid within one viewport on mobile browser chrome.
- The `#111` used for the page background, canvas fill, and per-cell erase must all match, or the ASCII art won't sit invisibly on the page. Glyph color is `#c8c8c8`.
