# AI 꾸러미 — 열 지도 (히트맵) — Heatmap (kernel density)
> 누른 자리마다 가우스 번짐을 작은 격자에 더해 쌓고, 가장 뜨거운 값으로 나눈 세기를 색 사다리로 칠해 많이 눌린 곳이 빨갛게 빛나게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i278

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

## 주문서

### 만들어 줘: 열 지도 (히트맵) — Heatmap (kernel density)

#### 1. 목표
게임 화면 누른 자리 기록에 열 지도를 겹쳐 줘 — 누른 자리마다 번짐이 쌓여 많이 눌린 곳이 빨강 · 흰빛으로 뜨겁게, 색 사다리 범례와 누른 횟수가 보이게. 분위기는 어두운 바탕 형광 열빛.

#### 2. 핵심 기술 용어
- **Heatmap (kernel density)** — 점이 많은 곳이 뜨거운 열 지도
- **Gaussian kernel splatting** — 점마다 둥근 가우스 번짐을 더하기
- **Color ramp normalization** — 가장 큰 값으로 나눠 0~1 색 사다리에 맞추기
- **Additive blending (lighter)** — 더해 그리기 — 뜨거울수록 밝게

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

#### 4. 조건
- 번짐은 작은 격자(Float32Array)에 쌓고, 화면 크기로 늘려 그리기 — 화면 픽셀마다 계산하지 않기
- 색 세기는 가장 큰 값으로 나눠 맞추되, 그 최댓값은 천천히 따라가게(lerp 0.08)
- 차가운 곳은 투명하게 — 아래 화면 요소가 보여야 한다
- 번짐 반경 · 누르는 빠르기 · 누른 점 보기 · 색 사다리를 바꿀 수 있게

#### 5. 완성 기준 (이게 보이면 성공)
- 「시작」 단추 자리가 가장 뜨겁고(흰빛), 드물게 누른 곳은 파랗게 옅다
- 번짐 반경을 키우면 뜨거운 곳이 넓고 부드러워지고, 줄이면 점처럼 또렷하다
- 누를 때마다 작은 고리가 퍼지고 열 지도가 조금씩 자란다
- 색 사다리 범례 「적게 → 많이」가 화면 색과 맞다

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

## 원리
- 화면을 120×75 작은 격자로 두고, 점 하나가 오면 둘레 칸에 exp(−거리² / (반경² × 0.5)) 를 더한다 (가우스 번짐).
- 격자의 가장 큰 값 peak 로 나눠 0~1 로 맞추고, 0.7 제곱으로 약한 곳도 보이게 한 뒤 색 사다리(검정 → 파랑 → 초록 → 노랑 → 주황 → 흰빛)에서 색을 고른다.
- 알파도 세기에 비례하게 해서 차가운 곳은 투명 — 아래 화면이 비친다.
- 작은 격자 그림을 화면 크기로 부드럽게 늘려(imageSmoothing) lighter 로 더해 그린다.
- peak 는 lerp(peak, 지금 최대, 0.08) 로 천천히 따라가 색이 깜박이지 않게 한다.

## 핵심 코드 — 가우스 번짐 쌓기 + 색 사다리로 칠해 늘려 그리기
(발췌: demos/demosMapB.ts i278 addPoint · draw() 를 정리)
```ts
const GW = 120, GH = 75;
const heat = new Float32Array(GW * GH);
const small = document.createElement('canvas');
small.width = GW; small.height = GH;
const sg = small.getContext('2d')!, img = sg.createImageData(GW, GH);
let peak = 1;
// 점 하나 (x, y 는 0~1) → 둘레에 가우스 번짐 더하기
function addPoint(x: number, y: number, R = 4) {
  const gx = x * GW, gy = y * GH;
  for (let yy = Math.floor(gy - R * 2); yy <= gy + R * 2; yy++)
    for (let xx = Math.floor(gx - R * 2); xx <= gx + R * 2; xx++) {
      if (xx < 0 || yy < 0 || xx >= GW || yy >= GH) continue;
      const d2 = (xx + 0.5 - gx) ** 2 + (yy + 0.5 - gy) ** 2;
      heat[yy * GW + xx] += Math.exp(-d2 / (R * R * 0.5));
    }
}
// 매 프레임: 최댓값으로 나눠 색칠 → 늘려 더해 그리기
function drawHeat(g: CanvasRenderingContext2D, x0: number, y0: number, W: number, H: number, ramp: RGB[]) {
  let mx = 0;
  for (let i = 0; i < heat.length; i++) if (heat[i] > mx) mx = heat[i];
  peak = Math.max(peak + (mx - peak) * 0.08, 1);  // 천천히 따라가 깜박임 막기
  const d = img.data;
  for (let i = 0; i < heat.length; i++) {
    const kk = Math.pow(Math.min(1, heat[i] / peak), 0.7); // 약한 곳도 보이게
    const c = rampAt(ramp, kk);
    d[i * 4] = c[0]; d[i * 4 + 1] = c[1]; d[i * 4 + 2] = c[2];
    d[i * 4 + 3] = Math.min(1, kk * 1.6) * 215;            // 차가운 곳은 투명
  }
  sg.putImageData(img, 0, 0);
  g.imageSmoothingEnabled = true;
  g.globalCompositeOperation = 'lighter';
  g.drawImage(small, x0, y0, W, H);
  g.globalCompositeOperation = 'source-over';
}
```

## 흔한 실수 · 확인 목록
- [ ] **화면 픽셀마다 모든 점과의 거리를 재면 점이 늘수록 느려진다** — 작은 격자에 번짐을 미리 더해 두고, 그림만 늘려 그린다.
- [ ] **그때그때 최댓값으로 나누면 새 점이 올 때마다 전체 색이 출렁인다** — 최댓값을 lerp 로 천천히 따라가게 한다.
- [ ] **알파를 늘 불투명으로 두면 아래 화면이 가려진다** — 세기에 비례한 알파로 차가운 곳은 투명하게.

## 완성 기준 체크리스트
- [ ] 「시작」 단추 자리가 가장 뜨겁고(흰빛), 드물게 누른 곳은 파랗게 옅다
- [ ] 번짐 반경을 키우면 뜨거운 곳이 넓고 부드러워지고, 줄이면 점처럼 또렷하다
- [ ] 누를 때마다 작은 고리가 퍼지고 열 지도가 조금씩 자란다
- [ ] 색 사다리 범례 「적게 → 많이」가 화면 색과 맞다

## 이 기술 정보
- id: `i278` · 분류: 지도 · 길찾기 › 지도 보기 · 표현 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 점 하나 = 둘레 (4·반경)² 칸 더하기. 9천 칸 격자를 프레임마다 색칠해도 가볍고, 늘려 그리기는 GPU 가 한다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i278
- 쓰면 좋을 때: 사람들이 어디를 많이 눌렀나 · 많이 갔나를 보일 때 / 확률 실험 결과를 분포로 보일 때 / 지도 위 사건 빈도
- 쓰지 말 때: 지역마다 하나의 값 — 대신 등치 지도(i277) / 점이 몇 개뿐일 때 — 점을 그대로 찍는 편이 읽기 쉽다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i278 견본 항목 — `src/demos/demosMapB.ts:4089`
```ts
  i278: {
    kind: '2d',
    caption: '게임 화면에서 누른 자리를 모으면 — 많이 눌린 곳이 뜨겁게(빨강 · 흰빛) 번져요 · 「시작」 단추에 몰리는 게 한눈에',
    make() {
      const GW = 120;
      const GH = 75;
      const heat = new Float32Array(GW * GH);
      let radius = 4;
      let showDots = true;
      let rampI = 0;
      let rate = 45;
      let acc = 0;
      let count = 0;
      let round = 0;
      let peak = 1;
      const pings: { x: number; y: number; t: number }[] = [];
      const small = mk(GW, GH);
      const sg = small.getContext('2d')!;
      const img = sg.createImageData(GW, GH);
      const layer = new Layer();
      // 화면 요소 (0..1 좌표) — 누르는 자리 분포의 중심
      const spots = (): [number, number, number, number][] => {
        const r = rng(round * 3 + 1);
        return [
          [0.5, 0.78, 0.07, 0.38 + r() * 0.1], // 시작
          [0.18, 0.12, 0.04, 0.1], // 뒤로
          [0.86, 0.12, 0.04, 0.12 + r() * 0.08], // 상점
          [0.3, 0.42, 0.06, 0.1 + r() * 0.1], // 1단계
          [0.5, 0.42, 0.06, 0.08 + r() * 0.06],
          [0.7, 0.42, 0.06, 0.05 + r() * 0.05],
          [0.5, 0.42, 0.3, 0.06], // 아무 데나
        ];
      };
      let S = spots();
      const gauss = (r: () => number): number => {
        const a = Math.max(1e-6, r());
        return Math.sqrt(-2 * Math.log(a)) * Math.cos(TAU * r());
      };
      const rr2 = rng(99);
      const addPoint = (t: number): void => {
        const tot = S.reduce((a, s) => a + s[3], 0);
        let pick = rr2() * tot;
        let sp = S[0]!;
        for (const s of S) {
          pick -= s[3];
          if (pick <= 0) {
            sp = s;
            break;
          }
        }
        const x = clamp01(sp[0] + gauss(rr2) * sp[2]);
        const y = clamp01(sp[1] + gauss(rr2) * sp[2] * 1.2);
        pings.push({ x, y, t });
        if (pings.length > 260) pings.shift();
        const R = radius;
        const gx = x * GW;
        const gy = y * GH;
        for (let yy = Math.floor(gy - R * 2); yy <= gy + R * 2; yy++)
          for (let xx = Math.floor(gx - R * 2); xx <= gx + R * 2; xx++) {
            if (xx < 0 || yy < 0 || xx >= GW || yy >= GH) continue;
            const d2 = (xx + 0.5 - gx) ** 2 + (yy + 0.5 - gy) ** 2;
            heat[yy * GW + xx] = heat[yy * GW + xx]! + Math.exp(-d2 / (R * R * 0.5));
          }
        count++;
      };
      const restart = (): void => {
        heat.fill(0);
        count = 0;
        round++;
        S = spots();
        pings.length = 0;
        peak = 1;
      };
      const paintScreen = (lg: G, x0: number, y0: number, W: number, H: number, u: number): void => {
        const gr = lg.createLinearGradient(0, y0, 0, y0 + H);
        gr.addColorStop(0, '#3a2f6b');
        gr.addColorStop(1, '#1d2a52');
        rr(lg, x0, y0, W, H, 8 * u);
        lg.fillStyle = gr;
        lg.fill();
        lg.save();
        lg.clip();
        lg.fillStyle = 'rgba(255,255,255,0.05)';
        for (let i = 0; i < 30; i++) lg.fillRect(x0 + hash2(i, 1) * W, y0 + hash2(i, 2) * H, 2 * u, 2 * u);
        lg.restore();
        const btn = (cx: number, cy: number, bw: number, bh: number, label: string, col: string): void => {
          rr(lg, x0 + cx * W - bw / 2, y0 + cy * H - bh / 2 + 2 * u, bw, bh, bh * 0.3);
          lg.fillStyle = 'rgba(0,0,0,0.35)';
          lg.fill();
          rr(lg, x0 + cx * W - bw / 2, y0 + cy * H - bh / 2, bw, bh, bh * 0.3);
          lg.fillStyle = col;
          lg.fill();
          txt(lg, label, x0 + cx * W, y0 + cy * H, Math.min(bh * 0.42, 11 * u), '#fff', 'center', 900);
        };
        txt(lg, '별빛 모험', x0 + W / 2, y0 + H * 0.16, 13 * u, '#ffe08a', 'center', 900);
        btn(0.18, 0.12, 26 * u, 15 * u, '←', '#4a5590');
        btn(0.86, 0.12, 30 * u, 15 * u, '상점', '#d9488f');
        btn(0.3, 0.42, 30 * u, 30 * u, '1', '#3fbf7f');
        btn(0.5, 0.42, 30 * u, 30 * u, '2', '#3f9fdf');
        btn(0.7, 0.42, 30 * u, 30 * u, '3', '#8a6fdf');
        btn(0.5, 0.78, 76 * u, 22 * u, '시작', '#ff8a3d');
      };
      return {
        draw(g, w, h, t, dt) {
          reset(g);
          const u = scaleOf(w, h);
          const dpr = dprOf(g);
          acc += Math.min(dt, 0.1) * rate;
          while (acc >= 1) {
            acc -= 1;
            addPoint(t);
          }
          if (count > 420) restart();
          const x0 = 8 * u;
          const y0 = 8 * u;
          const W = w - 16 * u;
          const H = h - 16 * u;
          const L = layer.get(`${w}|${h}|${dpr}`, w, h, dpr, (lg) => {
            bgGrad(lg, w, h, '#0f1424', '#080b14');
            paintScreen(lg, x0, y0, W, H, u);
          });
          g.drawImage(L, 0, 0, w, h);
          g.fillStyle = 'rgba(5,8,18,0.45)';
          g.fillRect(x0, y0, W, H);
          // 색 입히기
          let mx = 0;
          for (let i = 0; i < heat.length; i++) if (heat[i]! > mx) mx = heat[i]!;
          peak = Math.max(lerp(peak, mx, 0.08), 1);
          const ramp = HEAT_RAMPS[rampI]!;
          const d = img.data;
          for (let i = 0; i < heat.length; i++) {
            const k = clamp01(heat[i]! / peak);
            const kk = Math.pow(k, 0.7);
            const c = rampAt(ramp, kk);
            d[i * 4] = c[0];
            d[i * 4 + 1] = c[1];
            d[i * 4 + 2] = c[2];
            d[i * 4 + 3] = clamp(kk * 1.6, 0, 1) * 215;
          }
          sg.putImageData(img, 0, 0);
          g.save();
          rr(g, x0, y0, W, H, 8 * u);
          g.clip();
          g.imageSmoothingEnabled = true;
          g.globalCompositeOperation = 'lighter';
          g.globalAlpha = 0.9;
          g.drawImage(small, x0, y0, W, H);
          g.globalCompositeOperation = 'source-over';
          g.globalAlpha = 1;
          if (showDots) {
            for (const p of pings) {
              const age = t - p.t;
              const X = x0 + p.x * W;
              const Y = y0 + p.y * H;
              if (age < 0.6) {
                g.strokeStyle = `rgba(255,255,255,${0.9 * (1 - age / 0.6)})`;
                g.lineWidth = 1.3 * u;
                g.beginPath();
                g.arc(X, Y, (2 + age * 18) * u, 0, TAU);
                g.stroke();
              }
              g.fillStyle = 'rgba(255,255,255,0.55)';
              g.fillRect(X - 0.8 * u, Y - 0.8 * u, 1.6 * u, 1.6 * u);
            }
          }
          g.restore();
          // 색 사다리
          const bw = 70 * u;
          const bx = w - bw - 14 * u;
          const by = h - 20 * u;
          rr(g, bx - 5 * u, by - 9 * u, bw + 10 * u, 22 * u, 5 * u);
          g.fillStyle = 'rgba(8,10,20,0.7)';
          g.fill();
          const gr = g.createLinearGradient(bx, 0, bx + bw, 0);
          ramp.forEach((c, i) => gr.addColorStop(i / (ramp.length - 1), css(c)));
          g.fillStyle = gr;
          g.fillRect(bx, by, bw, 5 * u);
          txt(g, '적게', bx, by - 4 * u, 6 * u, '#cfd6ee', 'left', 700);
          txt(g, '많이', bx + bw, by - 4 * u, 6 * u, '#cfd6ee', 'right', 700);
          pill(g, `누른 횟수 ${count}`, 14 * u, h - 17 * u, 7.5 * u, 'rgba(8,10,20,0.7)', '#fff', 'left');
        },
        controls: [
          { type: 'range', label: '번짐 반경', min: 1.5, max: 9, step: 0.5, value: 4, on: (v) => (radius = v) },
          { type: 'range', label: '누르는 빠르기', min: 5, max: 150, step: 5, value: 45, on: (v) => (rate = v) },
          { type: 'toggle', label: '누른 점 보기', value: true, on: (v) => (showDots = v) },
          { type: 'toggle', label: '색 사다리: 무지개 ↔ 마그마', value: false, on: (v) => (rampI = v ? 1 : 0) },
          { type: 'button', label: '다시 모으기', on: restart },
        ] as Control[],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [등치 지도 (색칠 통계 지도)](https://ai-techstudio.web.app/ai/t/i277.md) `i277`
- 다음에 해 볼 기술: [미니맵 (작은 지도 + 시야 틀)](https://ai-techstudio.web.app/ai/t/i271.md) `i271`
- 참고 문서: [Wikipedia — Kernel density estimation](https://en.wikipedia.org/wiki/Kernel_density_estimation)
