# AI 꾸러미 — 등치 지도 (색칠 통계 지도) — Choropleth map
> 지역마다 값을 같은 간격 몇 단계로 나눠 색칠하고 범례로 읽게 하며, 자료를 바꿀 때 색이 부드럽게 넘어가고 마우스를 댄 지역 값이 뜬다.  
> 견본: https://ai-techstudio.web.app/#t/i277

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

## 주문서

### 만들어 줘: 등치 지도 (색칠 통계 지도) — Choropleth map

#### 1. 목표
지역별 인구 · 강수량 지도를 등치 지도로 그려 줘 — 지역마다 값을 단계 색으로 칠하고, 범례 · 지역 이름 · 마우스를 댄 지역 값이 보이며, 자료를 바꾸면 색이 부드럽게 바뀌게. 분위기는 어두운 바탕 밝은 색 사다리.

#### 2. 핵심 기술 용어
- **Choropleth map** — 지역마다 값으로 색칠한 통계 지도
- **Class breaks (equal interval)** — 최솟값~최댓값을 같은 간격 단계로 나누기
- **Sequential color ramp + legend** — 연한 색 → 진한 색 사다리와 범례
- **Region id raster (lookup table)** — 픽셀마다 지역 번호 → 색 표로 한 번에 칠하기

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

#### 4. 조건
- 지역 번호 그림(Int16Array)은 한 번 만들고, 색칠은 지역 번호 → 색 표(LUT)로
- 색 키(자료 · 전환 진행 · 마우스 지역 · 단계 수)가 바뀔 때만 다시 칠하기
- 단계 색 사다리는 연한 → 진한 한 방향 색 (무지개 색 쓰지 않기), 범례에 단계 경계값 표시
- 단계 수(3 ~ 7) · 연속 색 켬/끔 · 자료 바꾸기를 조절할 수 있게

#### 5. 완성 기준 (이게 보이면 성공)
- 지역마다 다른 단계 색이 칠해지고, 범례의 색 · 값 범위와 맞는다
- 자료를 바꾸면 색이 0.7초 동안 부드럽게 넘어간다
- 마우스(가짜 커서)가 지나가는 지역이 밝아지고 값이 뜬다
- 단계 수를 3 으로 하면 큰 덩어리, 7 이면 촘촘한 차이가 보인다

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

## 원리
- 지역 나누기: 땅 픽셀마다 가장 가까운 씨앗(보로노이)을 찾아 지역 번호를 적는다 — 경계를 잡음으로 살짝 흔들어 자연스럽게.
- 단계: (값 − 최솟값) / (최댓값 − 최솟값) × 단계 수 를 내림 → 0 ~ 단계 수 − 1. 단계 가운데 색을 색 사다리에서 고른다.
- 칠하기: 지역 번호 → 색 표(Uint32 LUT)를 만들고, 픽셀 배열을 한 번 훑어 표에서 색을 꺼내 넣는다. 이웃 번호가 다르면 흰 경계, 바다와 닿으면 진한 해안선.
- 자료를 바꿀 때는 지역마다 옛 색과 새 색을 0.7초 동안 섞는다.
- 마우스가 있는 픽셀의 지역 번호로 그 지역을 밝히고 값을 띄운다.

## 핵심 코드 — 같은 간격 단계 + 지역 번호 → 색 표로 한 번에 칠하기
(발췌: demos/demosMapB.ts i277 classOf · colorFor · 칠하기 부분을 정리)
```ts
const classOf = (v: number, mn: number, mx: number) =>
  Math.max(0, Math.min(classes - 1, Math.floor(((v - mn) / (mx - mn + 1e-9)) * classes)));
const colorFor = (d: number, v: number): RGB => {
  const arr = vals[d], mn = Math.min(...arr), mx = Math.max(...arr);
  const k = continuous ? (v - mn) / (mx - mn) : (classOf(v, mn, mx) + 0.5) / classes; // 단계 가운데 색
  return rampAt(DATASETS[d].ramp, k);
};
// 지역 번호 → 색 표 (옛 자료 → 새 자료 tw 만큼 섞기, 마우스 지역은 밝게)
const lut = new Uint32Array(NREG + 1);
for (let i = 0; i < NREG; i++) {
  const a = colorFor(prevDs, vals[prevDs][i]), b = colorFor(ds, vals[ds][i]);
  let c: RGB = [a[0] + (b[0] - a[0]) * tw, a[1] + (b[1] - a[1]) * tw, a[2] + (b[2] - a[2]) * tw];
  if (i === hov) c = [c[0] + (255 - c[0]) * 0.25, c[1] + (255 - c[1]) * 0.25, c[2] + (255 - c[2]) * 0.25];
  lut[i] = (255 << 24) | ((c[2] & 255) << 16) | ((c[1] & 255) << 8) | (c[0] & 255); // 리틀 엔디언 RGBA
}
// 픽셀 한 번 훑기: rid = 지역 번호(바다 −1), edge = 1 지역 경계 · 2 해안선
const d32 = new Uint32Array(img.data.buffer);
const WHITE = (220 << 24) | (255 << 16) | (255 << 8) | 255;
const COAST = (255 << 24) | (60 << 16) | (40 << 8) | 30;
for (let i = 0; i < d32.length; i++) {
  const r = rid[i];
  if (r < 0) { d32[i] = 0; continue; }
  d32[i] = edge[i] === 2 ? COAST : edge[i] === 1 ? WHITE : lut[r];
}
ctx.putImageData(img, 0, 0);
```

## 흔한 실수 · 확인 목록
- [ ] **지역을 픽셀마다 따로 계산해 색칠하면 프레임마다 느리다** — 지역 번호 그림을 한 번 만들어 두고, 색은 번호 → 색 표로 칠한다. 바뀔 때만.
- [ ] **무지개 색 사다리를 쓰면 어느 쪽이 큰 값인지 헷갈린다** — 연한 → 진한 한 방향 색 사다리를 쓰고 범례에 경계값을 적는다.
- [ ] **Uint32Array 로 색을 넣을 때 바이트 순서를 반대로 쓴다** — 브라우저는 리틀 엔디언이라 (A << 24) | (B << 16) | (G << 8) | R 순서.

## 완성 기준 체크리스트
- [ ] 지역마다 다른 단계 색이 칠해지고, 범례의 색 · 값 범위와 맞는다
- [ ] 자료를 바꾸면 색이 0.7초 동안 부드럽게 넘어간다
- [ ] 마우스(가짜 커서)가 지나가는 지역이 밝아지고 값이 뜬다
- [ ] 단계 수를 3 으로 하면 큰 덩어리, 7 이면 촘촘한 차이가 보인다

## 이 기술 정보
- id: `i277` · 분류: 지도 · 길찾기 › 지도 보기 · 표현 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 지역 번호 그림은 크기가 바뀔 때만 만들고, 색은 자료 · 단계 · 마우스 지역이 바뀔 때만 다시 칠한다 (픽셀 수십만 개를 Uint32 로 한 번 훑기).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i277
- 쓰면 좋을 때: 지역마다 하나의 값(인구 · 비율)을 비교할 때 / 통계 · 자료 해석 체험 / 단계 수에 따라 지도 인상이 바뀌는 것을 보여 줄 때
- 쓰지 말 때: 점이 찍힌 위치 · 빈도 — 대신 열 지도(i278) / 지역 크기가 값과 상관없이 너무 다를 때 — 큰 지역이 과장되니 비율 값으로 바꾸거나 기호 지도로

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### clamp — `src/demos/demosMapB.ts:17`
```ts
const clamp = (x: number, a: number, b: number): number => (x < a ? a : x > b ? b : x);
```

### i277 견본 항목 — `src/demos/demosMapB.ts:3798`
```ts
  i277: {
    kind: '2d',
    caption: '지역마다 값을 색으로 — 5단계로 나눠 칠하고 범례로 읽어요 · 마우스(가짜 커서)를 대면 그 지역 값이 떠요',
    make() {
      let classes = 5;
      let continuous = false;
      let auto = true;
      let ds = 0;
      let prevDs = 0;
      let swapT = -9;
      let lastSwap = 0;
      let nextReq = false;
      let seedPts: V2[] = [];
      let vals: number[][] = [];
      let resKey = '';
      let RW = 0;
      let RH = 0;
      let rid = new Int16Array(0);
      let edge = new Uint8Array(0);
      let cvs: HTMLCanvasElement | null = null;
      let img: ImageData | null = null;
      let colorKey = '';
      let cent: V2[] = [];
      const NREG = 14;
      {
        const r = rng(11);
        for (let k = 0; k < 4000 && seedPts.length < NREG; k++) {
          const p: V2 = [0.12 + r() * 0.76, 0.12 + r() * 0.76];
          if (landAt(p[0], p[1]) && seedPts.every((q) => Math.hypot(q[0] - p[0], q[1] - p[1]) > 0.14)) seedPts.push(p);
        }
        vals = DATASETS.map((d) => seedPts.map((p, i) => d.f(p[0], p[1], i)));
      }
      function landAt(x: number, y: number): boolean {
        const d = Math.hypot((x - 0.5) * 1.05, (y - 0.5) * 1.15);
        return fbm(x * 3.2, y * 3.2, 33, 4) * 0.55 + 0.45 - d * 1.05 > 0.3;
      }
      const build = (W: number, H: number): void => {
        RW = W;
        RH = H;
        rid = new Int16Array(W * H).fill(-1);
        const sx = new Float32Array(NREG * 2);
        seedPts.forEach((p, i) => ((sx[i * 2] = p[0]), (sx[i * 2 + 1] = p[1])));
        const cx = new Float64Array(NREG);
        const cy = new Float64Array(NREG);
        const cn = new Float64Array(NREG);
        for (let y = 0; y < H; y++)
          for (let x = 0; x < W; x++) {
            const fx = x / W;
            const fy = y / H;
            if (!landAt(fx, fy)) continue;
            // 경계를 살짝 흔들어 자연스러운 땅 모양으로
            const jx = fx + (vnoise(fx * 18, fy * 18, 4) - 0.5) * 0.035;
            const jy = fy + (vnoise(fx * 18 + 9, fy * 18, 4) - 0.5) * 0.035;
            let best = 0;
            let bd = Infinity;
            for (let i = 0; i < seedPts.length; i++) {
              const d = (jx - sx[i * 2]!) ** 2 + ((jy - sx[i * 2 + 1]!) * 0.85) ** 2;
              if (d < bd) {
                bd = d;
                best = i;
              }
            }
            rid[y * W + x] = best;
            cx[best] = cx[best]! + x;
            cy[best] = cy[best]! + y;
            cn[best] = cn[best]! + 1;
          }
        cent = seedPts.map((_, i) => [cx[i]! / Math.max(1, cn[i]!), cy[i]! / Math.max(1, cn[i]!)]);
        edge = new Uint8Array(W * H);
        for (let y = 1; y < H - 1; y++)
          for (let x = 1; x < W - 1; x++) {
            const i = y * W + x;
            const a = rid[i]!;
            if (a < 0) continue;
            const r2 = rid[i + 1]!;
            const b = rid[i + W]!;
            const l = rid[i - 1]!;
            const tp = rid[i - W]!;
            if (r2 < 0 || b < 0 || l < 0 || tp < 0) edge[i] = 2;
            else if (r2 !== a || b !== a) edge[i] = 1;
          }
        cvs = mk(W, H);
        img = cvs.getContext('2d')!.createImageData(W, H);
        colorKey = '';
      };
      const classOf = (v: number, mn: number, mx: number): number => clamp(Math.floor(((v - mn) / (mx - mn + 1e-9)) * classes), 0, classes - 1);
      const colorFor = (d: number, v: number): RGB => {
        const arr = vals[d]!;
        const mn = Math.min(...arr);
        const mx = Math.max(...arr);
        const k = continuous ? (v - mn) / (mx - mn) : (classOf(v, mn, mx) + 0.5) / classes;
        return rampAt(DATASETS[d]!.ramp, k);
      };
      const layer = new Layer();
      return {
        draw(g, w, h, t) {
          reset(g);
          const u = scaleOf(w, h);
          const dpr = dprOf(g);
          if (nextReq || (auto && t - lastSwap > 4 && t > swapT + 1)) {
            nextReq = false;
            prevDs = ds;
            ds = (ds + 1) % DATASETS.length;
            swapT = t;
            lastSwap = t;
          }
          const tw = smooth(0, 0.7, t - swapT);
          const headH = 18 * u;
          const legW = 64 * u;
          const mapW = w - legW - 14 * u;
          const mapH = h - headH - 8 * u;
          const side = Math.min(mapW, mapH * 1.25);
          const mw = Math.round(side);
          const mh = Math.round(side / 1.25);
          const mx0 = 6 * u + (mapW - mw) / 2;
          const my0 = headH + (mapH - mh) / 2;
          const res = Math.min(1.5, dpr);
          const key = `${mw}|${mh}|${res}`;
          if (key !== resKey) {
            build(Math.round(mw * res), Math.round(mh * res));
            resKey = key;
          }
          // 가짜 커서
          const cxp = 0.5 + 0.36 * Math.sin(t * 0.53) * Math.cos(t * 0.21);
          const cyp = 0.5 + 0.32 * Math.sin(t * 0.37 + 1);
          const hx = Math.floor(cxp * RW);
          const hy = Math.floor(cyp * RH);
          const hov = rid[clamp(hy, 0, RH - 1) * RW + clamp(hx, 0, RW - 1)] ?? -1;
          const ck = `${ds}|${prevDs}|${tw.toFixed(2)}|${hov}|${classes}|${continuous}`;
          if (ck !== colorKey && img && cvs) {
            colorKey = ck;
            const lut = new Uint32Array(NREG + 1);
            for (let i = 0; i < seedPts.length; i++) {
              const a = colorFor(prevDs, vals[prevDs]![i]!);
              const b = colorFor(ds, vals[ds]![i]!);
              let c: RGB = [lerp(a[0], b[0], tw), lerp(a[1], b[1], tw), lerp(a[2], b[2], tw)];
              if (i === hov) c = [lerp(c[0], 255, 0.25), lerp(c[1], 255, 0.25), lerp(c[2], 255, 0.25)];
              lut[i] = (255 << 24) | ((c[2] & 255) << 16) | ((c[1] & 255) << 8) | (c[0] & 255);
            }
            const d32 = new Uint32Array(img.data.buffer);
            const WHITE = (220 << 24) | (255 << 16) | (255 << 8) | 255;
            const COAST = (255 << 24) | (60 << 16) | (40 << 8) | 30;
            for (let i = 0; i < d32.length; i++) {
              const r2 = rid[i]!;
              if (r2 < 0) {
                d32[i] = 0;
                continue;
              }
              const e = edge[i]!;
              d32[i] = e === 2 ? COAST : e === 1 ? (r2 === hov || rid[i + 1] === hov || rid[i + RW] === hov ? 0xffffffff : WHITE) : lut[r2]!;
            }
            cvs.getContext('2d')!.putImageData(img, 0, 0);
          }
          // 바다 · 그림자 (고정 층)
          const L = layer.get(`${key}|${w}|${h}|${dpr}`, w, h, dpr, (lg) => {
            bgGrad(lg, w, h, '#14203a', '#0b1222');
            lg.strokeStyle = 'rgba(120,170,255,0.06)';
            lg.lineWidth = 1;
            for (let x = 0; x < w; x += 14 * u) {
              lg.beginPath();
              lg.moveTo(x, 0);
              lg.lineTo(x, h);
              lg.stroke();
            }
            for (let y = 0; y < h; y += 14 * u) {
              lg.beginPath();
              lg.moveTo(0, y);
              lg.lineTo(w, y);
              lg.stroke();
            }
            if (cvs) {
              lg.save();
              lg.filter = `blur(${5 * u}px) brightness(0)`;
              lg.globalAlpha = 0.6;
              lg.drawImage(cvs, mx0 + 3 * u, my0 + 5 * u, mw, mh);
              lg.restore();
            }
          });
          g.drawImage(L, 0, 0, w, h);
          if (cvs) {
            g.imageSmoothingEnabled = true;
            g.drawImage(cvs, mx0, my0, mw, mh);
          }
          // 값 글씨 (크게 볼 때)
          const D = DATASETS[ds]!;
          if (u > 1.4) {
            cent.forEach((c, i) => {
              const x = mx0 + (c[0] / RW) * mw;
              const y = my0 + (c[1] / RH) * mh;
              const v = vals[ds]![i]!;
              g.font = `800 ${6.5 * u}px ${F}`;
              g.textAlign = 'center';
              g.textBaseline = 'middle';
              g.lineWidth = 2.5 * u;
              g.strokeStyle = 'rgba(0,0,0,0.35)';
              g.strokeText(comma(v), x, y);
              g.fillStyle = '#fff';
              g.fillText(comma(v), x, y);
            });
          }
          // 범례
          const arr = vals[ds]!;
          const mn = Math.min(...arr);
          const mxv = Math.max(...arr);
          const lx = w - legW - 4 * u;
          let ly = headH + 6 * u;
          rr(g, lx, ly - 4 * u, legW, h - ly - 6 * u, 6 * u);
          g.fillStyle = 'rgba(255,255,255,0.07)';
          g.fill();
          txt(g, D.unit, lx + legW / 2, ly + 3 * u, 6.5 * u, 'rgba(230,236,255,0.7)', 'center', 700);
          ly += 10 * u;
          const hovClass = hov >= 0 ? classOf(arr[hov]!, mn, mxv) : -1;
          if (continuous) {
            const bh = h - ly - 16 * u;
            const gr = g.createLinearGradient(0, ly + bh, 0, ly);
            D.ramp.forEach((c, i) => gr.addColorStop(i / (D.ramp.length - 1), css(c)));
            rr(g, lx + 6 * u, ly, 10 * u, bh, 3 * u);
            g.fillStyle = gr;
            g.fill();
            txt(g, comma(mxv), lx + 20 * u, ly + 3 * u, 6.5 * u, '#e8ecff', 'left', 700);
            txt(g, comma(mn), lx + 20 * u, ly + bh - 3 * u, 6.5 * u, '#e8ecff', 'left', 700);
            if (hov >= 0) {
              const yy = ly + bh * (1 - (arr[hov]! - mn) / (mxv - mn));
              g.fillStyle = '#fff';
              g.beginPath();
              g.moveTo(lx + 4 * u, yy);
              g.lineTo(lx, yy - 3 * u);
              g.lineTo(lx, yy + 3 * u);
              g.fill();
            }
          } else {
            const rowH = Math.min(14 * u, (h - ly - 10 * u) / classes);
            for (let c = classes - 1; c >= 0; c--) {
              const y = ly + (classes - 1 - c) * rowH;
              const col = rampAt(D.ramp, (c + 0.5) / classes);
              rr(g, lx + 6 * u, y, 11 * u, rowH - 2 * u, 2 * u);
              g.fillStyle = css(col);
              g.fill();
              if (c === hovClass) {
                g.strokeStyle = '#fff';
                g.lineWidth = 1.5 * u;
                g.stroke();
              }
              const a = mn + ((mxv - mn) * c) / classes;
              txt(g, `${comma(a)}~`, lx + 20 * u, y + (rowH - 2 * u) / 2, 6.2 * u, c === hovClass ? '#fff' : 'rgba(230,236,255,0.8)', 'left', 700);
            }
          }
          // 커서 · 말풍선
          const px = mx0 + cxp * mw;
          const py = my0 + cyp * mh;
          if (hov >= 0) {
            const s1 = REGION_NAMES[hov] ?? '?';
            const s2 = `${comma(arr[hov]!)} ${D.unit}`;
            g.font = `800 ${7.5 * u}px ${F}`;
            const bw = Math.max(g.measureText(s1).width, g.measureText(s2).width) + 12 * u;
            const bx = clamp(px + 8 * u, 2, w - bw - 2);
            const by = clamp(py - 30 * u, 2, h - 30 * u);
            g.save();
            g.shadowColor = 'rgba(0,0,0,0.4)';
            g.shadowBlur = 6 * u;
            rr(g, bx, by, bw, 24 * u, 5 * u);
            g.fillStyle = 'rgba(255,255,255,0.96)';
            g.fill();
            g.restore();
            txt(g, s1, bx + 6 * u, by + 7.5 * u, 7 * u, '#556', 'left', 800);
            txt(g, s2, bx + 6 * u, by + 17 * u, 7.5 * u, '#111', 'left', 900);
          }
          g.fillStyle = '#fff';
          g.strokeStyle = '#111';
          g.lineWidth = 1 * u;
          g.beginPath();
          g.moveTo(px, py);
          g.lineTo(px, py + 11 * u);
          g.lineTo(px + 3 * u, py + 8 * u);
          g.lineTo(px + 7.5 * u, py + 8 * u);
          g.closePath();
          g.fill();
          g.stroke();
          txt(g, D.name, 8 * u, headH / 2 + 1 * u, 10.5 * u, css(D.ramp[2]!), 'left', 900);
          pill(g, continuous ? '연속 색' : `${classes}단계`, w - 6 * u, headH / 2 + 1 * u, 7 * u, 'rgba(255,255,255,0.12)', '#e8ecff', 'right');
        },
        controls: [
          { type: 'range', label: '단계 수', min: 3, max: 7, step: 1, value: 5, on: (v) => (classes = v) },
          { type: 'toggle', label: '연속 색 (단계 없이)', value: false, on: (v) => (continuous = v) },
          { type: 'toggle', label: '자료 자동으로 바꾸기', value: true, on: (v) => (auto = v) },
          { type: 'button', label: '다음 자료', on: () => (nextReq = true) },
        ] as Control[],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [보로노이 지역 · 나라 나누기](https://ai-techstudio.web.app/ai/t/i246.md) `i246`
- 다음에 해 볼 기술: [열 지도 (히트맵)](https://ai-techstudio.web.app/ai/t/i278.md) `i278`
- 참고 문서: [Wikipedia — Choropleth map](https://en.wikipedia.org/wiki/Choropleth_map)
