Web上で全方位無限スクロールを作るための技術

# これは何？
全方位にスクロールするサイトの作り方を理解したので、共有する記事です。

こんなやつができます。
![マイムービー 6.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/303906/ccbdbfa0-9d6e-451f-b624-f3873cfa84f1.gif)

※画像は弊社サイトから拝借: 

https://generosity.co.jp/

# 前提条件
- viteでvanilla + TypeScriptの環境を作って実装（ちょっと変えれば他の環境でも動くはず）
- 画像は`src/assets/images`に連番で30枚配置（1 ~ 30.jpeg）

# コード全体
## index.html
```index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + TS</title>
  </head>
  <body>
    <canvas id="canvas" style="touch-action: none;"></canvas>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
```

## src/style.css
```src/style.css
body {
  margin: 0;
  padding: 0;
  overflow: hidden;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}

canvas {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  touch-action: none;
}
```

## src/main.ts
```src/main.ts
import './style.css';

// 画像の読み込みを Promise でラップする関数
const promiseImg = (src: string) => {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => {
      console.log('Image loaded:', src);
      resolve(img);
    };
    img.onerror = (e) => { 
      reject(e); 
    };
    img.src = src;
  });
};

// 定数の定義
const CELL_SIZE = 200;
const BUFFER_CELLS = 2; // 表示範囲外に描画するセル数
const TARGET_SCALE = 1.2;
const ANIMATION_SPEED = 0.1;

// 状態変数
let canvas: HTMLCanvasElement;
let cameraPosition = { x: 0, y: 0 };
let mousePosition = { x: 0, y: 0 };
let currentScale = 1.0;
let prevMouseCell = { row: -1, col: -1 };

// 画像関連
const images: HTMLImageElement[] = [];
type CellImageInfo = {
  index: number;
  filename: string;
  description: string;
};
const cellImages: Map<string, CellImageInfo> = new Map();
type ImageCellGrid = Array<Array<CellImageInfo | undefined>>;
let imageCellGrid: ImageCellGrid = [];

// キャンバスの初期化
function initCanvas() {
  const canvasElement = document.getElementById('canvas');
  if (canvasElement !== null) {
    canvas = canvasElement as HTMLCanvasElement;

    // キャンバスサイズをウィンドウに合わせる
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
  
    // イベントリスナーを追加
    canvas.addEventListener('wheel', handleScroll);
    canvas.addEventListener('mousemove', handleMouseMove);
  }  
}

// 画像の取得
function getImageForCell(row: number, col: number): HTMLImageElement {
  if (!imageCellGrid[row]) {
    imageCellGrid[row] = [];
  }
  
  if (!imageCellGrid[row][col]) {
    const randomIndex = Math.floor(Math.random() * images.length);
    const cellInfo: CellImageInfo = {
      index: randomIndex,
      filename: `${randomIndex + 1}.jpeg`,
      description: `画像${randomIndex + 1}の説明文がここに入ります。`
    };
    imageCellGrid[row][col] = cellInfo;
    
    cellImages.set(`${row},${col}`, cellInfo);
  }
  
  return images[imageCellGrid[row][col]!.index];
}

// 描画関数
function draw() {
  if (!canvas) return;
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 表示範囲の計算
  const startCol = Math.floor(cameraPosition.x / CELL_SIZE) - BUFFER_CELLS;
  const startRow = Math.floor(cameraPosition.y / CELL_SIZE) - BUFFER_CELLS;
  const endCol = startCol + Math.ceil(canvas.width / CELL_SIZE) + BUFFER_CELLS * 2;
  const endRow = startRow + Math.ceil(canvas.height / CELL_SIZE) + BUFFER_CELLS * 2;

  // マウス位置のセル座標を計算
  const mouseCellCol = Math.floor((mousePosition.x + cameraPosition.x) / CELL_SIZE);
  const mouseCellRow = Math.floor((mousePosition.y + cameraPosition.y) / CELL_SIZE);
  
  // Check if mouse has moved to a new cell
  if (mouseCellRow !== prevMouseCell.row || mouseCellCol !== prevMouseCell.col) {
    prevMouseCell = { row: mouseCellRow, col: mouseCellCol };
    currentScale = 1.0;
  }

  // 通常サイズの画像を先に描画
  for (let row = startRow; row <= endRow; row++) {
    for (let col = startCol; col <= endCol; col++) {
      // マウス位置のセルはスキップ
      if (row === mouseCellRow && col === mouseCellCol) continue; 

      const x = col * CELL_SIZE - cameraPosition.x;
      const y = row * CELL_SIZE - cameraPosition.y;
      const image = getImageForCell(row, col);

      if (image.complete) {
        ctx.drawImage(image, x, y, CELL_SIZE, CELL_SIZE);
      }
    }
  }

  // マウス位置の画像を最後に描画
  const mouseCol = Math.floor((mousePosition.x + cameraPosition.x) / CELL_SIZE);
  const mouseRow = Math.floor((mousePosition.y + cameraPosition.y) / CELL_SIZE);
  const x = mouseCol * CELL_SIZE - cameraPosition.x;
  const y = mouseRow * CELL_SIZE - cameraPosition.y;
  const image = getImageForCell(mouseRow, mouseCol);

  if (image.complete) {
    // 現在のスケールを目標に近づける
    currentScale += (TARGET_SCALE - currentScale) * ANIMATION_SPEED;

    const scaledSize = CELL_SIZE * currentScale;
    const offsetX = (scaledSize - CELL_SIZE) / 2;
    const offsetY = (scaledSize - CELL_SIZE) / 2;

    ctx.drawImage(image, x - offsetX, y - offsetY, scaledSize, scaledSize);
    requestAnimationFrame(draw);
  }
}

// スクロールハンドラー
function handleScroll(e: WheelEvent) {
  cameraPosition.x += e.deltaX;
  cameraPosition.y += e.deltaY;
}

// マウス移動のハンドラー
function handleMouseMove(e: MouseEvent) {
  mousePosition.x = e.clientX;
  mousePosition.y = e.clientY;
}
// アプリケーションの初期化
function init() {
  initCanvas();
  
  // 画像の読み込み
  const promiseImgs = Array.from({ length: 30 }, (_, i) => promiseImg(`src/assets/images/${i + 1}.jpeg`));
  
  Promise.all(promiseImgs).then((loadedImages) => {
    images.push(...loadedImages as HTMLImageElement[]);
    console.log('All images loaded');
    draw();
  });
}

// ウィンドウサイズが変更されたときの処理
window.addEventListener('resize', () => {
  if (canvas) {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
  }
});

window.addEventListener('DOMContentLoaded', () => {
  init();
});
```

# 各関数の説明

## 1. 画像の読み込み (非同期処理)

```typescript
const promiseImg = (src: string) => {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => {
      console.log('Image loaded:', src);
      resolve(img);
    };
    img.onerror = (e) => { 
      reject(e); 
    };
    img.src = src;
  });
};
```

画像ファイルを非同期で読み込みます。

## 2. Canvas の初期化とイベントリスナーの設定

```typescript
function initCanvas() {
  const canvasElement = document.getElementById('canvas');
  if (canvasElement !== null) {
    canvas = canvasElement as HTMLCanvasElement;

    // キャンバスサイズをウィンドウに合わせる
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
  
    // イベントリスナーを追加
    canvas.addEventListener('wheel', handleScroll);
    canvas.addEventListener('mousemove', handleMouseMove);
  }  
}
```
Canvas要素の初期化とイベントリスナーの設定を行います。

## 3. セルに対応する画像の取得とキャッシュ

```typescript
function getImageForCell(row: number, col: number): HTMLImageElement {
  if (!imageCellGrid[row]) {
    imageCellGrid[row] = [];
  }
  
  if (!imageCellGrid[row][col]) {
    const randomIndex = Math.floor(Math.random() * images.length);
    const cellInfo: CellImageInfo = {
      index: randomIndex,
      filename: `${randomIndex + 1}.jpeg`,
      description: `画像${randomIndex + 1}の説明文がここに入ります。`
    };
    imageCellGrid[row][col] = cellInfo;
    
    cellImages.set(`${row},${col}`, cellInfo);
  }
  
  return images[imageCellGrid[row][col]!.index];
}
```

指定されたセルに対応する画像を取得します
* **詳細**:
    * `row` と `col` に基づいて、`imageCellGrid` から画像情報を取得
    * `imageCellGrid`に情報がない場合はランダムにimagesから画像情報を生成し、`imageCellGrid`と`cellImages`に格納
    * 格納された情報を元に画像をリターン

## 4. Canvas への描画処理

```typescript
function draw() {
  if (!canvas) return;
  const ctx = canvas.getContext('2d');
  if (!ctx) return;

  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 表示範囲の計算
  // ...

  // 通常サイズの画像を先に描画
  // ...

  // マウス位置の画像を最後に描画
  // ...

  if (image.complete) {
    // 現在のスケールを目標に近づける
    // ...

    ctx.drawImage(image, x - offsetX, y - offsetY, scaledSize, scaledSize);
    requestAnimationFrame(draw);
  }
}
```

Canvas に画像を描画します
* **詳細**:
    * Canvas の描画コンテキスト (`ctx`) を取得
    * 表示範囲を計算し、範囲内のセルに対応する画像を `getImageForCell` で取得
    * マウス位置のセルを拡大表示
    * `requestAnimationFrame`を使用してアニメーションを次のフレームへ

## 5. アプリケーションの初期化

```typescript
function init() {
  initCanvas();
  
  // 画像の読み込み
  // ...
  
  Promise.all(promiseImgs).then((loadedImages) => {
    images.push(...loadedImages as HTMLImageElement[]);
    console.log('All images loaded');
    draw();
  });
}
```

アプリケーションの初期化を行います。
Promise.allで非同期に画像読み込みをし、終わったらcanvasに描画します。


# まとめ

- canvas使うので普段の考え方と違うけど、無限スクロール作るの楽しいよ
- こういうサイト作りたかったけどどう作ればいいかわからなかった人は参考にしてね