# AI 꾸러미 — 구면 → 평면 지도 투영 — Map projection (equirectangular · Mercator · Mollweide)
> 같은 지구 윤곽을 등거리 원통 · 메르카토르 · 몰바이데 식으로 펴서 부드럽게 바꿔 보이고, 같은 크기 원과 넓이 비로 왜곡을 드러낸다.  
> 견본: https://ai-techstudio.web.app/#t/i261

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

## 주문서

### 만들어 줘: 구면 → 평면 지도 투영 — Map projection (equirectangular · Mercator · Mollweide)

#### 1. 목표
세계 지도를 세 가지 지도 투영으로 바꿔 가며 보여 줘 — 위경도선 · 같은 크기 원(티소 원) · 그린란드 : 아프리카 넓이 비가 함께 바뀌게. 분위기는 어두운 바탕 밝은 대륙.

#### 2. 핵심 기술 용어
- **Map projection (equirectangular · Mercator · Mollweide)** — 구를 평면으로 펴는 세 가지 식
- **Tissot indicatrix** — 지구 위 같은 크기 원 — 펴면 얼마나 늘어나는지 보임
- **Conformal vs equal-area projection** — 각도를 지키는 투영 · 넓이를 지키는 투영

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

#### 4. 조건
- 메르카토르는 위도 ±82° 처럼 끊기 (90° 는 무한대)
- 몰바이데 θ 는 뉴턴법 반복(최대 12번, 오차 1e-7)으로 — 극 근처는 위도를 ±89.999 로 자르기
- 대륙 윤곽은 2° 간격으로 점을 촘촘히 채운 뒤(densify) 투영 — 직선으로 이으면 휘어야 할 선이 곧다
- 투영마다 화면에 꽉 차도록 배율을 따로 맞추고, 바꿀 때 배율도 함께 섞기
- 그린란드 : 아프리카 화면 넓이 비를 신발끈 공식으로 계산해 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 세 투영이 자동으로 부드럽게 바뀌고, 지금 투영 이름이 보인다
- 티소 원이 등거리 · 메르카토르에서는 극으로 갈수록 커지고, 몰바이데에서는 넓이가 비슷하다
- 메르카토르에서 그린란드 : 아프리카 넓이 비가 1 : 몇 으로 확 줄어든다 (실제는 약 1 : 14)
- 슬라이더로 투영을 직접 고를 수 있다

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

## 원리
- 등거리 원통: (x, y) = (경도, 위도) 를 그대로 — 가장 쉽지만 극으로 갈수록 옆으로 늘어난다.
- 메르카토르: y = ln(tan(π/4 + 위도/2)) — 작은 모양 · 각도는 지키지만 극에서 넓이가 끝없이 커진다 (견본은 위도 82° 에서 자름).
- 몰바이데: 2θ + sin 2θ = π·sin(위도) 를 뉴턴법으로 풀어 x = (2√2/π)·경도·cos θ, y = √2·sin θ — 넓이를 지킨다.
- 두 투영 사이는 같은 점의 두 결과를 섞어(lerp) 부드럽게 바꾼다 (화면 크기도 각자 맞춘 배율끼리 섞음).
- 지구 위 반지름 6° 원을 구면 식으로 만든 뒤 함께 투영하면, 원이 얼마나 커지고 찌그러지는지 보인다.

## 핵심 코드 — 세 가지 투영 식 + 티소 원 만들기
(발췌: demos/demosMapA.ts i261 make() 를 정리)
```ts
type P2 = [number, number];
const R = Math.PI / 180, MAXLAT = 82;
const clamp = (x: number, a: number, b: number) => Math.min(b, Math.max(a, x));
const moll = (lon: number, lat: number): P2 => {
  const ph = clamp(lat, -89.999, 89.999) * R;
  let th = ph;
  for (let i = 0; i < 12; i++) { // 2θ + sin2θ = π sinφ 를 뉴턴법으로
    const d = (2 * th + Math.sin(2 * th) - Math.PI * Math.sin(ph)) / (2 + 2 * Math.cos(2 * th));
    th -= d;
    if (Math.abs(d) < 1e-7) break;
  }
  return [((2 * Math.SQRT2) / Math.PI) * lon * R * Math.cos(th), Math.SQRT2 * Math.sin(th)];
};
const PROJ: ((lon: number, lat: number) => P2)[] = [
  (lon, lat) => [lon * R, lat * R],                                                      // 등거리 원통
  (lon, lat) => [lon * R, Math.log(Math.tan(Math.PI / 4 + (clamp(lat, -MAXLAT, MAXLAT) * R) / 2))], // 메르카토르
  moll,                                                                                    // 몰바이데
];
// 티소 원: (lon, lat) 둘레 반지름 6° 인 지구 위 원
function tissot(lon: number, lat: number, rDeg = 6): [number, number][] {
  const r = rDeg * R, p1 = lat * R, ring: [number, number][] = [];
  for (let k = 0; k < 28; k++) {
    const b = (k / 28) * Math.PI * 2;
    const p2 = Math.asin(Math.sin(p1) * Math.cos(r) + Math.cos(p1) * Math.sin(r) * Math.cos(b));
    const l2 = lon * R + Math.atan2(Math.sin(b) * Math.sin(r) * Math.cos(p1), Math.cos(r) - Math.sin(p1) * Math.sin(p2));
    ring.push([l2 / R, p2 / R]);
  }
  return ring;
}
// 두 투영 사이 섞기 (sa, sb = 각 투영을 화면에 맞춘 배율)
const M = (a: P2, b: P2, k: number): P2 => [cx + (a[0] * sa + (b[0] * sb - a[0] * sa) * k), cy - (a[1] * sa + (b[1] * sb - a[1] * sa) * k)];
```

## 흔한 실수 · 확인 목록
- [ ] **메르카토르를 극까지 그리면 y 가 무한대로 튄다** — 위도를 ±82° 로 잘라 투영한다.
- [ ] **대륙 점을 그대로 투영해 직선으로 이으면 휘어야 할 경계가 곧다** — 먼저 2° 간격으로 점을 채운(densify) 뒤 투영한다.
- [ ] **투영마다 크기가 달라 바꾸는 순간 지도가 튄다** — 투영마다 화면에 맞춘 배율을 구해 두고 점과 함께 배율도 섞는다.

## 완성 기준 체크리스트
- [ ] 세 투영이 자동으로 부드럽게 바뀌고, 지금 투영 이름이 보인다
- [ ] 티소 원이 등거리 · 메르카토르에서는 극으로 갈수록 커지고, 몰바이데에서는 넓이가 비슷하다
- [ ] 메르카토르에서 그린란드 : 아프리카 넓이 비가 1 : 몇 으로 확 줄어든다 (실제는 약 1 : 14)
- [ ] 슬라이더로 투영을 직접 고를 수 있다

## 이 기술 정보
- id: `i261` · 분류: 지도 · 길찾기 › 격자 · 좌표 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 투영 셋을 처음에 한 번 다 계산해 두고(대륙 점 · 위경도선 · 티소 원), 프레임마다 섞어 그리기만 한다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i261
- 쓰면 좋을 때: 지도 투영 · 축척 · 넓이 왜곡을 설명할 때 / 세계 지도를 그려야 하는 게임 · 체험 / 「왜 그린란드가 커 보일까?」 같은 질문 화면
- 쓰지 말 때: 지구를 돌려 봐야 할 때 — 대신 구 지도(i262) / 나라 경계 자료가 정확해야 할 때 — 견본 윤곽은 단순화된 점이라 Natural Earth 같은 자료를 쓴다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i261 견본 항목 — `src/demos/demosMapA.ts:4215`
```ts
  i261: {
    kind: '2d',
    caption: '같은 지구를 등거리 원통 → 메르카토르 → 몰바이데로 펴기 — 빨간 원은 지구 위에서 모두 같은 크기, 그린란드가 아프리카만큼 커져요',
    make() {
      const MAXLAT = 82;
      type Proj = (lon: number, lat: number) => P2;
      const R = Math.PI / 180;
      const moll = (lon: number, lat: number): P2 => {
        const ph = clamp(lat, -89.999, 89.999) * R;
        let th = ph;
        for (let i = 0; i < 12; i++) {
          const d = (2 * th + Math.sin(2 * th) - Math.PI * Math.sin(ph)) / (2 + 2 * Math.cos(2 * th));
          th -= d;
          if (Math.abs(d) < 1e-7) break;
        }
        return [((2 * Math.SQRT2) / Math.PI) * lon * R * Math.cos(th), Math.SQRT2 * Math.sin(th)];
      };
      const PROJ: { name: string; f: Proj }[] = [
        { name: '등거리 원통 (위도 · 경도 그대로)', f: (lon, lat) => [lon * R, lat * R] },
        { name: '메르카토르 (각도는 그대로, 극으로 갈수록 커짐)', f: (lon, lat) => [lon * R, Math.log(Math.tan(Math.PI / 4 + (clamp(lat, -MAXLAT, MAXLAT) * R) / 2))] },
        { name: '몰바이데 (넓이는 그대로)', f: moll },
      ];
      const lands = WORLD.map((l) => ({ name: l.name, ice: !!l.ice, pts: densify(l.pts.map((p) => [p[0], clamp(p[1], -MAXLAT, MAXLAT)] as LL), 2) }));
      const grat: LL[][] = [];
      for (let lon = -180; lon <= 180; lon += 30) {
        const line: LL[] = [];
        for (let lat = -MAXLAT; lat <= MAXLAT; lat += 2) line.push([lon, lat]);
        grat.push(line);
      }
      for (let lat = -60; lat <= 60; lat += 30) {
        const line: LL[] = [];
        for (let lon = -180; lon <= 180; lon += 3) line.push([lon, lat]);
        grat.push(line);
      }
      const frame: LL[] = [];
      for (let lon = -180; lon <= 180; lon += 3) frame.push([lon, MAXLAT]);
      for (let lat = MAXLAT; lat >= -MAXLAT; lat -= 2) frame.push([180, lat]);
      for (let lon = 180; lon >= -180; lon -= 3) frame.push([lon, -MAXLAT]);
      for (let lat = -MAXLAT; lat <= MAXLAT; lat += 2) frame.push([-180, lat]);
      // 티소 원: 지구 위 같은 크기(반지름 6°)의 원
      const tissot: LL[][] = [];
      for (const lat of [-60, -30, 0, 30, 60])
        for (let lon = -150; lon <= 150; lon += 60) {
          const ring: LL[] = [];
          const r = 6 * R;
          const p1 = lat * R;
          for (let k = 0; k < 28; k++) {
            const b = (k / 28) * TAU;
            const p2 = Math.asin(Math.sin(p1) * Math.cos(r) + Math.cos(p1) * Math.sin(r) * Math.cos(b));
            const l2 = lon * R + Math.atan2(Math.sin(b) * Math.sin(r) * Math.cos(p1), Math.cos(r) - Math.sin(p1) * Math.sin(p2));
            ring.push([l2 / R, p2 / R]);
          }
          tissot.push(ring);
        }
      // 투영마다 미리 계산 + 크기
      const pre = PROJ.map((P) => {
        const proj = (list: LL[]): P2[] => list.map((q) => P.f(q[0], q[1]));
        const fr = proj(frame);
        let X = 0;
        let Y = 0;
        for (const q of fr) {
          X = Math.max(X, Math.abs(q[0]));
          Y = Math.max(Y, Math.abs(q[1]));
        }
        return { X, Y, lands: lands.map((l) => proj(l.pts)), grat: grat.map(proj), frame: fr, tissot: tissot.map(proj) };
      });
      let auto = true;
      let pick = 0;
      let showT = true;
      let showRatio = true;
      let ct = 0;
      let from = 0;
      let to = 0;
      let mk = 1;
      const HOLD = 2.3;
      const MORPH = 1.3;
      const gi = WORLD.findIndex((l) => l.name === '그린란드');
      const ai = WORLD.findIndex((l) => l.name === '아프리카');
      const areaOf = (pts: P2[]): number => {
        let a = 0;
        for (let k = 0; k < pts.length; k++) {
          const p = pts[k]!;
          const q = pts[(k + 1) % pts.length]!;
          a += p[0] * q[1] - q[0] * p[1];
        }
        return Math.abs(a / 2);
      };
      return {
        draw(g, w, h, _t, dt) {
          reset(g);
          const d = Math.min(dt, 0.1);
          if (auto) {
            ct += d;
            if (mk >= 1 && ct > HOLD) {
              from = to;
              to = (to + 1) % 3;
              mk = 0;
              ct = 0;
            }
          } else if (to !== pick && mk >= 1) {
            from = to;
            to = pick;
            mk = 0;
          }
          if (mk < 1) mk = Math.min(1, mk + d / MORPH);
          const k = ease(mk);
          const u = ui(scaleOf(w, h));
          g.fillStyle = '#0c1424';
          g.fillRect(0, 0, w, h);
          const top = 22 * u;
          const bot = showRatio ? 18 * u : 6 * u;
          const A = pre[from]!;
          const B = pre[to]!;
          const fit = (p: { X: number; Y: number }): number => Math.min((w - 12 * u) / (2 * p.X), (h - top - bot) / (2 * p.Y));
          const sa = fit(A);
          const sb = fit(B);
          const cx = w / 2;
          const cy = top + (h - top - bot) / 2;
          const M = (a: P2, b: P2): P2 => [cx + lerp(a[0] * sa, b[0] * sb, k), cy - lerp(a[1] * sa, b[1] * sb, k)];
          const path = (a: P2[], b: P2[], close: boolean): P2[] => {
            const out: P2[] = [];
            g.beginPath();
            for (let i = 0; i < a.length; i++) {
              const q = M(a[i]!, b[i]!);
              out.push(q);
              if (i) g.lineTo(q[0], q[1]);
              else g.moveTo(q[0], q[1]);
            }
            if (close) g.closePath();
            return out;
          };
          // 바다
          path(A.frame, B.frame, true);
          const og = g.createLinearGradient(0, top, 0, h - bot);
          og.addColorStop(0, '#2d6fa8');
          og.addColorStop(0.5, '#2a78b8');
          og.addColorStop(1, '#2d6fa8');
          g.fillStyle = og;
          g.fill();
          // 경위선
          g.strokeStyle = 'rgba(255,255,255,0.16)';
          g.lineWidth = 1;
          A.grat.forEach((l, i) => {
            path(l, B.grat[i]!, false);
            g.stroke();
          });
          // 땅
          let areaG = 0;
          let areaA = 0;
          lands.forEach((l, i) => {
            const pts = path(A.lands[i]!, B.lands[i]!, true);
            const isG = i === gi;
            const isA = i === ai;
            g.fillStyle = isG ? '#f2f7fb' : isA ? '#f0a54a' : l.ice ? '#e4edf3' : '#8cc36b';
            g.fill();
            g.strokeStyle = isG ? '#5ab4ff' : isA ? '#b86a14' : 'rgba(40,70,30,0.65)';
            g.lineWidth = isG || isA ? 1.6 * u : 0.8;
            g.stroke();
            if (isG) areaG = areaOf(pts);
            if (isA) areaA = areaOf(pts);
          });
          // 티소 원
          if (showT) {
            g.fillStyle = 'rgba(235,60,70,0.55)';
            g.strokeStyle = 'rgba(255,220,220,0.9)';
            g.lineWidth = 1;
            A.tissot.forEach((l, i) => {
              path(l, B.tissot[i]!, true);
              g.fill();
              g.stroke();
            });
          }
          // 테두리
          path(A.frame, B.frame, true);
          g.strokeStyle = 'rgba(255,255,255,0.7)';
          g.lineWidth = 1.4 * u;
          g.stroke();
          // 이름표
          const lab = (lon: number, lat: number, s: string, col: string): void => {
            const q = M(PROJ[from]!.f(lon, lat), PROJ[to]!.f(lon, lat));
            pill(g, s, q[0], q[1], 7.5 * u, col, '#fff', 'center');
          };
          lab(-42, 73, '그린란드', 'rgba(30,110,200,0.9)');
          lab(20, 5, '아프리카', 'rgba(180,100,20,0.9)');
          pill(g, PROJ[mk < 0.5 ? from : to]!.name, 8 * u, 11 * u, 8.5 * u, 'rgba(255,255,255,0.95)', '#10203a');
          if (showRatio && areaG > 0) {
            const ratio = areaA / areaG;
            const y = h - 9 * u;
            txt(g, `화면 속 넓이  그린란드 : 아프리카 = 1 : ${ratio.toFixed(1)}`, w / 2 - 6 * u, y, 8 * u, '#ffffff', 'right', 800);
            txt(g, '진짜는 1 : 14', w / 2 + 6 * u, y, 8 * u, '#ffd36b', 'left', 800);
          }
        },
        controls: [
          { type: 'toggle', label: '자동으로 바꾸기', value: true, on: (v) => { auto = v; } },
          { type: 'range', label: '투영 (등거리 · 메르카토르 · 몰바이데)', min: 0, max: 2, step: 1, value: 0, on: (v) => { pick = v; auto = false; } },
          { type: 'toggle', label: '티소 원 (같은 크기의 원)', value: true, on: (v) => { showT = v; } },
          { type: 'toggle', label: '넓이 비교', value: true, on: (v) => { showRatio = v; } },
        ],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [구 지도 (지구본 · 위도 경도)](https://ai-techstudio.web.app/ai/t/i262.md) `i262`
- 다음에 해 볼 기술: [등치 지도 (색칠 통계 지도)](https://ai-techstudio.web.app/ai/t/i277.md) `i277` · [확대 · 끌기 지도 (슬리피 맵 · 타일 층)](https://ai-techstudio.web.app/ai/t/i272.md) `i272`
- 참고 문서: [Wikipedia — Mercator projection](https://en.wikipedia.org/wiki/Mercator_projection) · [Wikipedia — Mollweide projection](https://en.wikipedia.org/wiki/Mollweide_projection) · [Wikipedia — Tissot's indicatrix](https://en.wikipedia.org/wiki/Tissot%27s_indicatrix)
