# AI 꾸러미 — 확대 · 끌기 지도 (슬리피 맵 · 타일 층) — Slippy map (tiled web map, z/x/y)
> 세계를 확대 단계(z)마다 2ᶻ × 2ᶻ 타일로 나눠 보이는 타일만 그때그때 만들어 끼우고, 아직 없는 타일은 윗단계 그림을 늘려 임시로 채운다.  
> 견본: https://ai-techstudio.web.app/#t/i272

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 확대 · 끌기 지도 (슬리피 맵 · 타일 층) — Slippy map (tiled web map, z/x/y)

#### 1. 목표
세계 지도를 확대 · 끌기 지도로 만들어 줘 — 확대 단계가 오를 때마다 더 자세한 타일로 바꿔 끼우고, 아직 없는 타일은 윗단계 그림을 늘려 임시로, 타일 번호(z/x/y)가 보이게. 분위기는 밝은 지도 색.

#### 2. 핵심 기술 용어
- **Slippy map (tiled web map, z/x/y)** — 확대 단계마다 타일을 바꿔 끼우는 지도
- **Level of detail (zoom levels)** — 가까이 볼수록 더 자세한 그림
- **Parent tile fallback** — 못 받은 타일은 윗단계 타일 일부를 늘려 대신
- **Tile cache with eviction** — 만든 타일 보관 · 오래된 것 버리기

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- 보이는 타일만 계산해 그리기 (x0~x1, y0~y1)
- 타일 만들기는 한 장면에 1~2장씩만 (한꺼번에 만들면 확대 순간 멈춘다)
- 없는 타일은 윗단계 타일을 늘려 임시로 — 빈 구멍이 보이지 않게
- 보관함 크기 상한과 버리기 규칙 (낮은 단계는 남기기)
- 휠 · 끌기로 확대 · 이동, 가만히 두면 자동 둘러보기

#### 5. 완성 기준 (이게 보이면 성공)
- 확대하면 처음엔 흐릿한 타일이 보이다가 곧 자세한 타일로 바뀌며 서서히 나타난다
- 타일 번호 z/x/y 와 경계선이 보이고, 단계가 오를 때마다 타일이 4장으로 쪼개진다
- 확대 단계가 오를수록 마을 이름이 더 많이 나타난다
- 휠 · 끌기로 움직여도 화면에 빈 구멍이 생기지 않는다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 확대 단계 z 에서 세계는 2ᶻ × 2ᶻ 장의 타일. 타일 이름은 「z/x/y」.
- 연속 확대값 Zc 에서 세계 크기 = 기준 × 2^Zc, 그리는 단계 z = floor(Zc + 0.25). 화면에 걸친 x0~x1 · y0~y1 타일만 그린다.
- 타일은 그 단계에 맞게 자세히(옥타브 3 + z) 만든다 — 확대할수록 잡음 결이 더 고와진다.
- 아직 없는 타일은 받기 줄(queue)에 넣고, 그동안 윗단계(z−1, z−2 …) 타일에서 해당 부분((tx − (ptx≪sh))·sub …)을 잘라 늘려 그린다.
- 새 타일은 0.35초 동안 서서히 나타나게, 보관함이 160장을 넘으면 0 · 1 단계 말고 오래된 것부터 버린다.

## 핵심 코드 — 보이는 타일 고르기 + 윗단계 타일로 임시 채우기
(발췌: demos/demosMapB.ts i272 draw() 를 정리)
```ts
const z = Math.max(0, Math.min(4, Math.floor(Zc + 0.25)));   // 그릴 단계
const n = 1 << z;
const worldPx = BASE * Math.pow(2, Zc), tilePx = worldPx / n;
const ox = w / 2 - cx * worldPx, oy = h / 2 - cy * worldPx;  // cx, cy = 화면 가운데의 세계 좌표 (0~1)
const cl = (v: number) => Math.max(0, Math.min(n - 1, v));
const x0 = cl(Math.floor(-ox / tilePx)), x1 = cl(Math.floor((w - ox) / tilePx));
const y0 = cl(Math.floor(-oy / tilePx)), y1 = cl(Math.floor((h - oy) / tilePx));
for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) {
  const key = z + '/' + tx + '/' + ty;
  const X = ox + tx * tilePx, Y = oy + ty * tilePx;
  const tile = cache.get(key);
  if (!tile) {
    if (!queue.includes(key)) queue.push(key);
    for (let pz = z - 1; pz >= 0; pz--) {                    // 윗단계 타일의 해당 부분을 늘려 쓰기
      const sh = z - pz, ptx = tx >> sh, pty = ty >> sh;
      const pt = cache.get(pz + '/' + ptx + '/' + pty);
      if (!pt) continue;
      const sub = RES / (1 << sh);
      g.drawImage(pt.c, (tx - (ptx << sh)) * sub, (ty - (pty << sh)) * sub, sub, sub, X, Y, tilePx + 0.5, tilePx + 0.5);
      break;
    }
    continue;
  }
  g.globalAlpha = Math.min(1, (t - tile.born) / 0.35);       // 새 타일은 서서히
  g.drawImage(tile.c, X, Y, tilePx + 0.5, tilePx + 0.5);      // +0.5 = 타일 사이 틈 막기
  g.globalAlpha = 1;
}
// 한 장면에 1~2장만 만든다
for (let k = 0; k < 2 && queue.length; k++) {
  const key = queue.shift()!;
  const [zz, xx, yy] = key.split('/').map(Number);
  if (zz === z && !cache.has(key)) cache.set(key, { c: makeTile(zz, xx, yy), born: t });
}
```

## 흔한 실수 · 확인 목록
- [ ] **확대 순간 보이는 타일을 한꺼번에 만들면 화면이 멈춘다** — 받기 줄에 넣고 한 장면에 1~2장씩만 만든다.
- [ ] **타일 사이에 가는 틈이 보인다** — 타일을 0.5px 크게 그린다.
- [ ] **아직 없는 타일 자리가 비어 깜박인다** — 윗단계 타일의 해당 1/2ˢʰ 부분을 잘라 늘려 임시로 채운다.
- [ ] **오래 둘러보면 보관함이 끝없이 커진다** — 상한(160장)을 넘으면 낮은 단계(0 · 1)는 남기고 나머지를 버린다.

## 완성 기준 체크리스트
- [ ] 확대하면 처음엔 흐릿한 타일이 보이다가 곧 자세한 타일로 바뀌며 서서히 나타난다
- [ ] 타일 번호 z/x/y 와 경계선이 보이고, 단계가 오를 때마다 타일이 4장으로 쪼개진다
- [ ] 확대 단계가 오를수록 마을 이름이 더 많이 나타난다
- [ ] 휠 · 끌기로 움직여도 화면에 빈 구멍이 생기지 않는다

## 이 기술 정보
- id: `i272` · 분류: 지도 · 길찾기 › 지도 보기 · 표현 · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의) — 타일 하나(176×176) 만들기에 잡음 수만 번 — 그래서 한 장면에 1~2장씩만 만든다. 보관함 120~160장 × 176² × 4바이트 ≈ 15~20MB.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i272
- 쓰면 좋을 때: 화면보다 훨씬 큰 지도 · 그림을 확대해 봐야 할 때 / 확대할수록 더 자세한 내용(도시 이름 등)을 보여 줄 때 / 축척 · 2의 거듭제곱 개념을 보여 줄 때
- 쓰지 말 때: 한 장으로 충분한 작은 지도 — 대신 drawImage 확대 / 진짜 세계 지도 서비스 — 직접 만들지 말고 Leaflet · MapLibre 같은 라이브러리

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i272 견본 항목 — `src/demos/demosMapB.ts:2943`
```ts
  i272: {
    kind: '2d',
    caption: '확대 단계(z)가 오를 때마다 더 자세한 타일로 바꿔 끼워요 — 아직 못 받은 타일은 윗단계 그림을 늘려 임시로 (끌기 · 휠도 돼요)',
    make() {
      interface Tile {
        c: HTMLCanvasElement;
        born: number;
      }
      let RES = 96;
      const cache = new Map<string, Tile>();
      const queue: string[] = [];
      let auto = true;
      let showGrid = true;
      let zoomCtl = 2;
      let cx = 0.5;
      let cy = 0.5;
      let Zc = 0.5;
      let tour = 0;
      let idleUntil = 0;
      let now = 0;
      let attached: HTMLCanvasElement | null = null;
      let drag: V2 | null = null;
      const listeners: [string, EventListener][] = [];
      const towns: [number, number, string, number][] = [];
      const TN = ['한빛', '솔내', '별뫼', '가람', '누리', '다온', '바람골', '새벽', '해오름', '물빛', '달내', '구름재'];
      {
        const r = rng(42);
        for (let k = 0; k < 3000 && towns.length < TN.length; k++) {
          const x = 0.1 + r() * 0.8;
          const y = 0.1 + r() * 0.8;
          const hh = slipH(x, y, 6);
          if (hh > 0.04 && hh < 0.2 && towns.every((q) => Math.hypot(q[0] - x, q[1] - y) > 0.12)) towns.push([x, y, TN[towns.length]!, towns.length < 4 ? 1 : 2]);
        }
      }
      function slipH(x: number, y: number, oct: number): number {
        const d = Math.hypot(x - 0.5, y - 0.5);
        return fbm(x * 5, y * 5, 21, oct) * 1.1 - d * 1.25 + 0.06;
      }
      const makeTile = (z: number, tx: number, ty: number): HTMLCanvasElement => {
        const n = 1 << z;
        const c = mk(RES, RES);
        const tg = c.getContext('2d')!;
        const img = tg.createImageData(RES, RES);
        const oct = 3 + z;
        const H = new Float32Array((RES + 1) * (RES + 1));
        for (let y = 0; y <= RES; y++)
          for (let x = 0; x <= RES; x++) H[y * (RES + 1) + x] = slipH((tx + x / RES) / n, (ty + y / RES) / n, oct);
        for (let y = 0; y < RES; y++)
          for (let x = 0; x < RES; x++) {
            const hv = H[y * (RES + 1) + x]!;
            let col = worldColor(hv);
            if (hv > 0.02) {
              const gx = H[y * (RES + 1) + x + 1]! - hv;
              const gy = H[(y + 1) * (RES + 1) + x]! - hv;
              const sh = clamp(1 + (-gx - gy) * 6 * n * (96 / RES), 0.6, 1.35);
              col = [col[0] * sh, col[1] * sh, col[2] * sh];
            }
            const o = (y * RES + x) * 4;
            img.data[o] = clamp(col[0], 0, 255);
            img.data[o + 1] = clamp(col[1], 0, 255);
            img.data[o + 2] = clamp(col[2], 0, 255);
            img.data[o + 3] = 255;
          }
        tg.putImageData(img, 0, 0);
        return c;
      };
      const tourAt = (k: number): [number, number, number] => {
        const T = towns[1] ?? [0.5, 0.5];
        const T2 = towns[3] ?? [0.4, 0.6];
        const keys: [number, number, number][] = [
          [0.5, 0.5, 0.3],
          [T[0], T[1], 2.2],
          [T[0], T[1], 3.9],
          [T2[0], T2[1], 3.6],
          [T2[0], T2[1], 1.6],
          [0.5, 0.5, 0.3],
        ];
        const L = keys.length - 1;
        const x = (k % L + L) % L;
        const i = Math.floor(x);
        const f = ease(x - i);
        const a = keys[i]!;
        const b = keys[i + 1]!;
        return [lerp(a[0], b[0], f), lerp(a[1], b[1], f), lerp(a[2], b[2], f)];
      };
      const attach = (c: HTMLCanvasElement): void => {
        attached = c;
        const on = (n: string, f: EventListener): void => {
          c.addEventListener(n, f, { passive: false });
          listeners.push([n, f]);
        };
        on('wheel', ((e: WheelEvent) => {
          e.preventDefault();
          Zc = clamp(Zc - e.deltaY * 0.0025, 0, 4.4);
          idleUntil = now + 5;
        }) as EventListener);
        on('pointerdown', ((e: PointerEvent) => {
          drag = [e.clientX, e.clientY];
          idleUntil = now + 5;
        }) as EventListener);
        on('pointermove', ((e: PointerEvent) => {
          if (!drag) return;
          const scale = 190 * Math.sqrt(c.clientWidth / 280) * Math.pow(2, Zc);
          cx -= (e.clientX - drag[0]) / scale;
          cy -= (e.clientY - drag[1]) / scale;
          cx = clamp(cx, 0, 1);
          cy = clamp(cy, 0, 1);
          drag = [e.clientX, e.clientY];
          idleUntil = now + 5;
        }) as EventListener);
        const up = (): void => {
          drag = null;
        };
        on('pointerup', up as EventListener);
        on('pointerleave', up as EventListener);
      };
      return {
        draw(g, w, h, t, dt) {
          reset(g);
          now = t;
          const u = scaleOf(w, h);
          if (!attached && w > 500) attach(g.canvas);
          const wantRes = w > 500 ? 176 : 96;
          if (wantRes !== RES) {
            RES = wantRes;
            cache.clear();
            queue.length = 0;
          }
          if (auto && t > idleUntil) {
            tour += Math.min(dt, 0.1) / 2.6;
            const [a, b, z] = tourAt(tour);
            cx = lerp(cx, a, 0.08);
            cy = lerp(cy, b, 0.08);
            Zc = lerp(Zc, z, 0.08);
          } else if (!auto && t > idleUntil) Zc = lerp(Zc, zoomCtl, 0.08);
          const z = clamp(Math.floor(Zc + 0.25), 0, 4);
          const n = 1 << z;
          const worldPx = 190 * Math.sqrt(u) * Math.pow(2, Zc);
          const tilePx = worldPx / n;
          const ox = w / 2 - cx * worldPx;
          const oy = h / 2 - cy * worldPx;
          g.fillStyle = '#0d2a4d';
          g.fillRect(0, 0, w, h);
          const x0 = clamp(Math.floor(-ox / tilePx), 0, n - 1);
          const x1 = clamp(Math.floor((w - ox) / tilePx), 0, n - 1);
          const y0 = clamp(Math.floor(-oy / tilePx), 0, n - 1);
          const y1 = clamp(Math.floor((h - oy) / tilePx), 0, n - 1);
          let shown = 0;
          let loading = 0;
          g.imageSmoothingEnabled = true;
          for (let ty = y0; ty <= y1; ty++)
            for (let tx = x0; tx <= x1; tx++) {
              const key = `${z}/${tx}/${ty}`;
              const X = ox + tx * tilePx;
              const Y = oy + ty * tilePx;
              const tile = cache.get(key);
              shown++;
              const fadeK = tile ? clamp01((t - tile.born) / 0.35) : 0;
              if (fadeK < 1) {
                // 윗단계 타일 늘려 쓰기
                let drawn = false;
                for (let pz = z - 1; pz >= 0 && !drawn; pz--) {
                  const sh = z - pz;
                  const ptx = tx >> sh;
                  const pty = ty >> sh;
                  const pt = cache.get(`${pz}/${ptx}/${pty}`);
                  if (pt) {
                    const sub = RES / (1 << sh);
                    g.drawImage(pt.c, (tx - (ptx << sh)) * sub, (ty - (pty << sh)) * sub, sub, sub, X, Y, tilePx + 0.5, tilePx + 0.5);
                    drawn = true;
                  }
                }
                if (!drawn) {
                  g.fillStyle = '#1b3150';
                  g.fillRect(X, Y, tilePx, tilePx);
                }
                if (!tile) {
                  loading++;
                  if (!queue.includes(key)) queue.push(key);
                  const sp = (t * 3 + tx + ty) % 1;
                  g.strokeStyle = 'rgba(255,255,255,0.5)';
                  g.lineWidth = 2 * u;
                  g.beginPath();
                  g.arc(X + tilePx / 2, Y + tilePx / 2, Math.min(9 * u, tilePx * 0.12), sp * TAU, sp * TAU + 4);
                  g.stroke();
                }
              }
              if (tile) {
                g.globalAlpha = fadeK;
                g.drawImage(tile.c, X, Y, tilePx + 0.5, tilePx + 0.5);
                g.globalAlpha = 1;
              }
              if (showGrid) {
                g.strokeStyle = 'rgba(255,255,255,0.35)';
                g.lineWidth = 1;
                g.strokeRect(X + 0.5, Y + 0.5, tilePx - 1, tilePx - 1);
                if (tilePx > 60 * u * 0.8) txt(g, `${z}/${tx}/${ty}`, X + 4 * u, Y + 8 * u, 7 * u, 'rgba(255,255,255,0.75)', 'left', 700, 'ui-monospace, monospace');
              }
            }
          // 타일 받기 (한 장면에 두 장 — 받는 모습이 보이게)
          for (let k = 0; k < (RES > 100 ? 1 : 2) && queue.length; k++) {
            const key = queue.shift()!;
            if (cache.has(key)) continue;
            const [zz, xx, yy] = key.split('/').map(Number) as [number, number, number];
            if (zz !== z) continue;
            cache.set(key, { c: makeTile(zz, xx, yy), born: t });
          }
          if (queue.length > 40) queue.splice(0, queue.length - 40);
          if (cache.size > 160) {
            for (const k of cache.keys()) {
              if (cache.size <= 120) break;
              if (!k.startsWith('0/') && !k.startsWith('1/')) cache.delete(k);
            }
          }
          // 이름 (확대 단계에 따라 늘어남)
          for (const [x, y, name, lv] of towns) {
            if (lv > z) continue;
            const X = ox + x * worldPx;
            const Y = oy + y * worldPx;
            if (X < -40 || Y < -20 || X > w + 40 || Y > h + 20) continue;
            g.fillStyle = '#fff';
            g.strokeStyle = '#333';
            g.lineWidth = 1.2 * u;
            g.beginPath();
            g.arc(X, Y, (lv === 1 ? 3.2 : 2.4) * u, 0, TAU);
            g.fill();
            g.stroke();
            g.font = `800 ${(lv === 1 ? 9 : 7.5) * u}px ${F}`;
            g.textAlign = 'center';
            g.textBaseline = 'middle';
            g.lineWidth = 3 * u;
            g.strokeStyle = 'rgba(20,30,50,0.85)';
            g.strokeText(name, X, Y - 9 * u);
            g.fillStyle = '#fff';
            g.fillText(name, X, Y - 9 * u);
          }
          // 화면 단추 · 단계 표
          const bx = w - 16 * u;
          for (const [i, s] of ['+', '−'].entries()) {
            rr(g, bx - 9 * u, 10 * u + i * 20 * u, 18 * u, 18 * u, 4 * u);
            g.fillStyle = 'rgba(255,255,255,0.92)';
            g.fill();
            txt(g, s, bx, 19.5 * u + i * 20 * u, 12 * u, '#223', 'center', 800);
          }
          const barH = 70 * u;
          const by = 56 * u;
          rr(g, bx - 2 * u, by, 4 * u, barH, 2 * u);
          g.fillStyle = 'rgba(255,255,255,0.3)';
          g.fill();
          const kz = 1 - Zc / 4.4;
          g.fillStyle = '#ffd166';
          g.beginPath();
          g.arc(bx, by + kz * barH, 4.5 * u, 0, TAU);
          g.fill();
          pill(g, `z = ${z}`, 8 * u, 13 * u, 8.5 * u, 'rgba(15,20,35,0.8)', '#ffd166', 'left');
          pill(g, `타일 ${shown}장${loading ? ` · 받는 중 ${loading}` : ''}`, 8 * u, 31 * u, 7 * u, 'rgba(15,20,35,0.7)', '#e8ecff', 'left');
        },
        controls: [
          { type: 'toggle', label: '자동 여행', value: true, on: (v) => (auto = v) },
          { type: 'range', label: '확대 (자동 끔일 때)', min: 0, max: 4.4, step: 0.1, value: 2, on: (v) => ((zoomCtl = v), (auto = false)) },
          { type: 'toggle', label: '타일 경계 · 번호', value: true, on: (v) => (showGrid = v) },
          { type: 'button', label: '타일 다시 받기', on: () => cache.clear() },
        ] as Control[],
        dispose() {
          if (attached) for (const [n, f] of listeners) attached.removeEventListener(n, f);
          cache.clear();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [미니맵 (작은 지도 + 시야 틀)](https://ai-techstudio.web.app/ai/t/i271.md) `i271` · [잡음 섬 지도 (높이 → 생물군)](https://ai-techstudio.web.app/ai/t/i245.md) `i245`
- 다음에 해 볼 기술: [구면 → 평면 지도 투영](https://ai-techstudio.web.app/ai/t/i261.md) `i261` · [등치 지도 (색칠 통계 지도)](https://ai-techstudio.web.app/ai/t/i277.md) `i277`
- 참고 문서: [OpenStreetMap Wiki — Slippy map tilenames](https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames) · [Leaflet](https://leafletjs.com/)
