# AI 꾸러미 — 그래프 지도 (노선도 · 지하철) — Transit map (schematic diagram)
> 역을 점 · 노선을 선으로 둔 그래프에서 환승이 가장 적은 길을 찾고, 구불구불한 실제 지도를 곧은 노선도로 부드럽게 바꿔 보인다.  
> 견본: https://ai-techstudio.web.app/#t/i270

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

## 주문서

### 만들어 줘: 그래프 지도 (노선도 · 지하철) — Transit map (schematic diagram)

#### 1. 목표
지하철 노선도를 노선도 그래프로 만들어 줘 — 실제 지도의 구불구불한 노선이 곧은 노선도로 바뀌고, 두 역 사이 환승이 가장 적은 길을 열차가 따라가게. 분위기는 밝은 노선도 (색 선 · 흰 역).

#### 2. 핵심 기술 용어
- **Transit map (schematic diagram)** — 거리 대신 연결만 보이는 노선도
- **Graph search on (station, line) states** — 「역 + 지금 탄 노선」을 한 상태로 둔 탐색
- **Minimum transfers (lexicographic cost)** — 환승 1000 · 역 1 비용으로 환승 먼저 줄이기

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

#### 4. 조건
- 상태 열쇠는 역 × 8 + 노선 (한 역에 여러 노선이 지나므로 역만으로는 환승을 못 센다)
- 환승 비용을 역 이동보다 훨씬 크게(1000) 두어 환승 수가 먼저 비교되게
- 같은 역에서 노선만 바뀐 상태는 되짚을 때 역을 두 번 넣지 않기
- 실제 지도 ↔ 노선도 바꾸기와 새 길 찾기 단추

#### 5. 완성 기준 (이게 보이면 성공)
- 구불구불한 실제 노선이 가로 · 세로 곧은 노선도로 부드럽게 바뀐다
- 고른 두 역 사이 길이 빛나고 열차가 그 길을 따라간다
- 「환승 n번」이 표시되고, 실제로 그보다 적게 갈아타는 길이 없다
- 노선마다 색이 달라 환승역에서 색이 바뀌는 것이 보인다

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

## 원리
- 역 18개 = 점, 노선 4개 = 역 번호 목록. 실제 지도 위치(구불구불)와 노선도 위치(격자 위 곧은 자리) 두 벌을 둔다.
- 두 위치를 섞어(lerp) 바꾸면 거리는 버리고 「연결」만 남는다는 것이 눈에 보인다.
- 길찾기 상태는 (역, 지금 탄 노선). 같은 노선으로 옆 역 = 비용 1, 같은 역에서 다른 노선으로 갈아타기 = 비용 1000.
- 비용이 가장 작은 길을 다익스트라로 찾으면 「환승이 가장 적고, 같으면 역 수가 적은」 길이 된다.

## 핵심 코드 — (역, 노선) 상태로 환승 최소 길 찾기
(발췌: demos/demosMapB.ts subwayRoute 를 정리 (Heap 은 같은 파일의 이진 힙))
```ts
const LINES = [
  { name: '1호선', st: [0, 1, 2, 3, 4, 5, 6] },
  { name: '2호선', st: [7, 8, 3, 9, 10] },
  { name: '3호선', st: [11, 2, 9, 12, 13, 14] },
  { name: '4호선', st: [15, 16, 5, 17, 13] },
];
function subwayRoute(a: number, b: number) {
  const key = (s: number, l: number) => s * 8 + l;       // 상태 = (역, 지금 탄 노선)
  const dist = new Map<number, number>(), par = new Map<number, number>(), heap = new Heap();
  for (let l = 0; l < LINES.length; l++) if (LINES[l].st.includes(a)) { dist.set(key(a, l), 0); heap.push(0, key(a, l)); }
  let endK = -1;
  while (heap.size) {
    const k = heap.pop(), s = (k / 8) | 0, l = k % 8, d = dist.get(k)!;
    if (s === b) { endK = k; break; }
    const st = LINES[l].st, p = st.indexOf(s);
    for (const q of [p - 1, p + 1]) {                     // 같은 노선 옆 역: 비용 1
      if (q < 0 || q >= st.length) continue;
      const nk = key(st[q], l);
      if (d + 1 < (dist.get(nk) ?? Infinity)) { dist.set(nk, d + 1); par.set(nk, k); heap.push(d + 1, nk); }
    }
    for (let l2 = 0; l2 < LINES.length; l2++) {           // 갈아타기: 비용 1000
      if (l2 === l || !LINES[l2].st.includes(s)) continue;
      const nk = key(s, l2);
      if (d + 1000 < (dist.get(nk) ?? Infinity)) { dist.set(nk, d + 1000); par.set(nk, k); heap.push(d + 1000, nk); }
    }
  }
  if (endK < 0) return null;
  const st: number[] = [], ln: number[] = [];
  for (let k: number | undefined = endK; k !== undefined; k = par.get(k)) {
    const s = (k / 8) | 0;
    if (st[0] === s) { ln[0] = k % 8; continue; }         // 같은 역에서 노선만 바뀐 상태
    st.unshift(s); ln.unshift(k % 8);
  }
  let transfers = 0;
  for (let i = 1; i < ln.length; i++) if (ln[i] !== ln[i - 1]) transfers++;
  return { st, ln, transfers };
}
// 그리기: 역 i 의 위치 = lerp(실제 지도 ST_GEO[i], 노선도 ST[i], m) — m 을 0 ↔ 1 로 바꾸면 지도 ↔ 노선도
```

## 흔한 실수 · 확인 목록
- [ ] **역만 상태로 두면 환승 횟수를 셀 수 없다** — (역, 노선) 짝을 상태로 두고 갈아타기를 따로 비용 매긴다.
- [ ] **환승 비용을 역 이동과 비슷하게 두면 역 수가 적은 대신 환승이 많은 길이 나온다** — 환승 비용을 역 수보다 훨씬 크게(1000) 둔다.
- [ ] **노선도 자리를 실제 좌표에서 자동으로 만들면 선이 겹치고 엇갈린다** — 견본처럼 노선도 자리(격자 위 곧은 위치)를 따로 정해 둔다.

## 완성 기준 체크리스트
- [ ] 구불구불한 실제 노선이 가로 · 세로 곧은 노선도로 부드럽게 바뀐다
- [ ] 고른 두 역 사이 길이 빛나고 열차가 그 길을 따라간다
- [ ] 「환승 n번」이 표시되고, 실제로 그보다 적게 갈아타는 길이 없다
- [ ] 노선마다 색이 달라 환승역에서 색이 바뀌는 것이 보인다

## 이 기술 정보
- id: `i270` · 분류: 지도 · 길찾기 › 길찾기 · 이동 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 역 18 × 노선 4 = 상태 72개. 길찾기는 순간이고, 그리기는 선 몇 개뿐이다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i270
- 쓰면 좋을 때: 노선 · 연결 관계가 중요하고 실제 거리는 덜 중요할 때 / 환승 최소 · 그래프 개념을 보여 줄 때 / 단계 지도 · 기술 나무처럼 연결만 보이면 되는 지도
- 쓰지 말 때: 실제 거리 · 시간이 중요할 때 — 대신 비용 지도(i266) / 역이 수백 개라 자동 배치가 필요할 때 — 노선도 자리는 견본처럼 손으로 정한다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i270 견본 항목 — `src/demos/demosMapB.ts:2567`
```ts
  i270: {
    kind: '2d',
    caption: '실제 지도의 구불구불한 노선이 곧은 노선도로 바뀌어요 — 거리는 버리고 「연결」만 남겨 환승이 가장 적은 길 찾기',
    make() {
      let auto = true;
      let schem = true;
      let routeSeed = 1;
      let route = subwayRoute(0, 12);
      let cycleT = 0;
      let lastCycle = -1;
      const newRoute = (): void => {
        const r = rng(routeSeed++ * 17);
        for (let k = 0; k < 30; k++) {
          const a = Math.floor(r() * ST.length);
          const b = Math.floor(r() * ST.length);
          if (a === b) continue;
          const rt = subwayRoute(a, b);
          if (rt && rt.transfers >= 1 && rt.st.length >= 4) {
            route = rt;
            break;
          }
        }
        cycleT = 0;
      };
      newRoute();
      const PER = 9;
      return {
        draw(g, w, h, _t, dt) {
          reset(g);
          const u = scaleOf(w, h);
          cycleT += Math.min(dt, 0.1);
          const ph = cycleT % PER;
          const cyc = Math.floor(cycleT / PER);
          if (cyc !== lastCycle) {
            if (lastCycle >= 0) newRoute();
            lastCycle = cyc;
          }
          const m = auto ? smooth(1.4, 2.8, ph) * (1 - smooth(7.6, 8.9, ph)) : schem ? 1 : 0;
          // 배경 — 실제 지도(땅 · 공원 · 강) ↔ 노선도(어두운 판)
          bgGrad(g, w, h, '#151b2b', '#0c111d');
          const headH = 16 * u;
          const k = Math.min((w - 24 * u) / 13, (h - headH - 16 * u) / 9);
          const ox = (w - 12 * k) / 2;
          const oy = headH + (h - headH - 8 * k) / 2;
          const pos = (i: number): V2 => {
            const s = ST[i]!;
            const gp = ST_GEO[i]!;
            return [ox + lerp(gp[0], s[0], m) * k, oy + lerp(gp[1], s[1], m) * k];
          };
          if (m < 1) {
            g.globalAlpha = 1 - m;
            const r = rng(9);
            for (let q = 0; q < 7; q++) {
              const x = ox + (-0.5 + r() * 13) * k;
              const y = oy + (-0.5 + r() * 9) * k;
              g.fillStyle = q % 3 ? 'rgba(70,120,80,0.35)' : 'rgba(90,85,70,0.35)';
              g.beginPath();
              g.ellipse(x, y, k * (0.8 + r()), k * (0.5 + r() * 0.6), r() * 3, 0, TAU);
              g.fill();
            }
            // 도시 길
            g.strokeStyle = 'rgba(255,255,255,0.06)';
            g.lineWidth = 1;
            g.beginPath();
            for (let q = 0; q < 14; q++) {
              const y = oy + (q * 0.7 - 0.5) * k;
              g.moveTo(ox - k, y + Math.sin(q) * k * 0.3);
              g.bezierCurveTo(ox + 3 * k, y - k * 0.6, ox + 8 * k, y + k * 0.6, ox + 13 * k, y);
            }
            g.stroke();
            g.globalAlpha = 1;
          }
          // 강 (구불구불 → 곧은 띠)
          g.strokeStyle = 'rgba(60,130,220,0.35)';
          g.lineWidth = k * 0.55;
          g.lineCap = 'round';
          g.beginPath();
          const rv: V2[] = [];
          for (let q = 0; q <= 24; q++) {
            const x = -1 + (q / 24) * 14;
            const yg = 5.2 + Math.sin(x * 0.8) * 0.9 + Math.sin(x * 2.1) * 0.25;
            rv.push([ox + x * k, oy + lerp(yg, 5, m) * k]);
          }
          rv.forEach(([x, y], q) => (q ? g.lineTo(x, y) : g.moveTo(x, y)));
          g.stroke();
          // 노선
          const onRoute = new Set<string>();
          if (route) for (let i = 1; i < route.st.length; i++) onRoute.add(`${route.ln[i]}:${Math.min(route.st[i - 1]!, route.st[i]!)}-${Math.max(route.st[i - 1]!, route.st[i]!)}`);
          const routeK = clamp01((ph - (auto ? 3 : 0.2)) / 2.2);
          const showRoute = route && m > 0.97 && (auto ? ph > 3 && ph < 7.8 : true);
          const lw = Math.max(2.2, k * 0.24);
          g.lineCap = 'round';
          g.lineJoin = 'round';
          LINES.forEach((ln) => {
            g.strokeStyle = ln.col;
            g.globalAlpha = showRoute ? 0.35 : 1;
            g.lineWidth = lw;
            g.beginPath();
            const P = ln.st.map(pos);
            const ten = 1 - m;
            g.moveTo(P[0]![0], P[0]![1]);
            for (let i = 0; i + 1 < P.length; i++) {
              const p0 = P[Math.max(0, i - 1)]!;
              const p1 = P[i]!;
              const p2 = P[i + 1]!;
              const p3 = P[Math.min(P.length - 1, i + 2)]!;
              g.bezierCurveTo(p1[0] + ((p2[0] - p0[0]) / 6) * ten, p1[1] + ((p2[1] - p0[1]) / 6) * ten, p2[0] - ((p3[0] - p1[0]) / 6) * ten, p2[1] - ((p3[1] - p1[1]) / 6) * ten, p2[0], p2[1]);
            }
            g.stroke();
          });
          g.globalAlpha = 1;
          // 길 빛내기
          let trainP: V2 | null = null;
          if (showRoute && route) {
            const P = route.st.map(pos);
            g.strokeStyle = '#ffffff';
            g.globalAlpha = 0.18;
            g.lineWidth = lw * 3.4;
            strokePathPart(g, P, routeK);
            g.globalAlpha = 1;
            for (let i = 1; i < P.length; i++) {
              const seg = (i - 1) / (P.length - 1);
              const seg2 = i / (P.length - 1);
              if (routeK <= seg) break;
              const kk = clamp01((routeK - seg) / (seg2 - seg));
              g.strokeStyle = LINES[route.ln[i]!]!.col;
              g.lineWidth = lw * 1.6;
              g.beginPath();
              g.moveTo(P[i - 1]![0], P[i - 1]![1]);
              g.lineTo(lerp(P[i - 1]![0], P[i]![0], kk), lerp(P[i - 1]![1], P[i]![1], kk));
              g.stroke();
              if (kk < 1) trainP = [lerp(P[i - 1]![0], P[i]![0], kk), lerp(P[i - 1]![1], P[i]![1], kk)];
            }
          }
          // 역
          const lineCount = ST.map((_, i) => LINES.filter((l) => l.st.includes(i)).length);
          const bigLabels = u > 1.5;
          ST.forEach((s, i) => {
            const [x, y] = pos(i);
            const inter = lineCount[i]! > 1;
            const onR = route?.st.includes(i) && showRoute;
            g.fillStyle = '#fff';
            g.strokeStyle = inter ? '#1a1f2e' : LINES.find((l) => l.st.includes(i))!.col;
            g.lineWidth = Math.max(1.2, k * 0.08);
            g.beginPath();
            g.arc(x, y, inter ? k * 0.2 : k * 0.12, 0, TAU);
            g.fill();
            g.stroke();
            if (inter || bigLabels || onR) {
              g.font = `800 ${Math.max(7, 7 * u)}px ${F}`;
              g.textAlign = 'left';
              g.textBaseline = 'middle';
              g.lineWidth = 3;
              g.strokeStyle = '#0c111d';
              g.strokeText(s[2], x + k * 0.26, y - k * 0.28);
              g.fillStyle = onR ? '#fff' : 'rgba(225,232,255,0.85)';
              g.fillText(s[2], x + k * 0.26, y - k * 0.28);
            }
          });
          if (trainP) {
            g.fillStyle = 'rgba(255,255,255,0.35)';
            g.beginPath();
            g.arc(trainP[0], trainP[1], k * 0.35, 0, TAU);
            g.fill();
            g.fillStyle = '#fff';
            g.beginPath();
            g.arc(trainP[0], trainP[1], k * 0.18, 0, TAU);
            g.fill();
          }
          // 환승 표시
          if (showRoute && route && routeK > 0.3) {
            for (let i = 1; i + 1 < route.st.length; i++) {
              if (route.ln[i] === route.ln[i + 1]) continue;
              const [x, y] = pos(route.st[i]!);
              pill(g, '환승', x, y + k * 0.55, 6.5 * u, '#ffffff', '#111');
            }
          }
          txt(g, m > 0.5 ? '노선도' : '실제 지도', 7 * u, headH / 2 + 1 * u, 10.5 * u, '#cfe0ff', 'left', 900);
          if (route && showRoute) {
            const a = ST[route.st[0]!]![2];
            const b = ST[route.st[route.st.length - 1]!]![2];
            pill(g, `${a} → ${b} · 환승 ${route.transfers}번 · ${route.st.length - 1}개 역`, w - 6 * u, headH / 2 + 1 * u, 7.5 * u, 'rgba(255,255,255,0.14)', '#fff', 'right');
          } else {
            let x = w - 6 * u;
            for (let l = LINES.length - 1; l >= 0; l--) x -= pill(g, LINES[l]!.name, x, headH / 2 + 1 * u, 6.5 * u, LINES[l]!.col, '#111', 'right') + 3 * u;
          }
        },
        controls: [
          { type: 'toggle', label: '자동으로 바꾸기', value: true, on: (v) => (auto = v) },
          { type: 'toggle', label: '노선도로 보기 (자동 끔일 때)', value: true, on: (v) => (schem = v) },
          { type: 'button', label: '새 길 찾기', on: () => ((lastCycle = -1), (cycleT = auto ? 3 : 0), newRoute(), auto && (cycleT = 3)) },
        ] as Control[],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [A* 길찾기 (열린 · 닫힌 칸 보기)](https://ai-techstudio.web.app/ai/t/i264.md) `i264` · [다익스트라 비용 지도 (지형 비용)](https://ai-techstudio.web.app/ai/t/i266.md) `i266`
- 다음에 해 볼 기술: [미니맵 (작은 지도 + 시야 틀)](https://ai-techstudio.web.app/ai/t/i271.md) `i271`
- 참고 문서: [Wikipedia — Transit map](https://en.wikipedia.org/wiki/Transit_map)
