# AI 꾸러미 — 나무 구조 펼치기 (기술 나무 · 마인드맵) — Tree layout expand / collapse animation
> 자식 마디가 부모 자리에서 나와 제자리로 가며 가지가 늘어나고, 열린 길을 따라 금빛이 번지며, 가지를 접으면 부모 속으로 쏙 들어간다.  
> 견본: https://ai-techstudio.web.app/#t/i553

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

## 주문서

### 만들어 줘: 나무 구조 펼치기 (기술 나무 · 마인드맵) — Tree layout expand / collapse animation

#### 1. 목표
게임 기술 나무 (공격 · 방어 · 이동)을(를) 펼쳐지는 나무 구조로 만들어 줘 — 자식이 부모 자리에서 나와 제자리로 가며 가지가 늘어나고, 열린 길을 따라 금빛이 번지고, 가지를 접으면 부모 속으로 들어가게. 분위기는 어두운 남색 · 가지마다 다른 색.

#### 2. 핵심 기술 용어
- **Tree layout expand / collapse animation** — 나무 구조 펼치기 · 접기
- **Skill tree / mind map** — 기술 나무 · 생각 지도
- **Animate from parent position (enter / exit transitions)** — 새 마디는 부모 자리에서 나오고, 없어질 땐 부모로 돌아감
- **pathLength="1" normalized dash** — 경로 길이를 1 로 정해 대시 오프셋을 0~1 로 다루기

#### 3. 환경
- 플랫폼: TypeScript + HTML · CSS · SVG (DOM), 라이브러리 없이 — 움직임은 transform · opacity 로
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, 60fps 목표
- 마디는 SVG g 의 transform(translate · scale), 가지는 매 프레임 부모 · 자식 현재 자리로 path d 를 다시 만든다.

#### 4. 조건
- 자식은 늘 「부모의 지금 자리」에서 제자리 사이를 보간할 것 (부모의 원래 자리가 아니라) — 접힐 때 손자가 따라 들어가게
- 펼침 · 접힘은 깊이 순서대로(펼칠 땐 위에서 아래, 접을 땐 아래에서 위) 0.05~0.1초 차례
- 가지는 매 프레임 두 마디의 지금 자리로 다시 그리기 — 마디와 가지가 어긋나지 않게
- 접힌 마디에는 숨은 자식 수 표시(+2)
- 열린 길은 금빛이 부모에서 자식 쪽으로 번지고, 닿은 마디에 고리 퍼짐

#### 5. 완성 기준 (이게 보이면 성공)
- 뿌리가 통 나오고 자식들이 뿌리 자리에서 쏙 나와 제자리로 가며 가지가 늘어난다 (살짝 지나쳤다 돌아옴)
- 「기본기 → 이동 → 이단 뛰기」 길로 금빛이 번지고, 닿은 마디에 고리가 퍼지며 금색이 된다
- 「공격」 가지를 접으면 연타 · 강타가 공격 속으로 들어가고 +2 표시가 생기며, 다시 펴면 나온다
- 마지막에 전체가 아래에서부터 뿌리로 모여 사라진다

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

## 원리
- 배치는 먼저 고정해 둔다(깊이마다 줄, 잎 수만큼 가로 나눔). 애니는 「펼침 k」 하나로: 자식 자리 = lerp(부모의 지금 자리, 제자리, k), 크기 = k.
- 부모도 움직이면 자식은 「부모의 지금 자리」를 따라가므로 접힐 때 손자까지 한꺼번에 딸려 들어간다.
- k 는 outBack — 제자리를 살짝 지나쳤다 돌아온다. 깊이마다 · 형제마다 0.05~0.1초씩 늦춰 물결처럼 퍼진다.
- 가지는 부모 아래 → 자식 위를 잇는 S 자 베지어. 열린 길은 같은 경로를 금빛으로 한 번 더 그리고 pathLength="1" 대시 오프셋 1 → 0 으로 번지게, 닿은 마디는 고리가 퍼지며 금빛.

## 핵심 코드 — 부모의 지금 자리에서 펼치기 + 가지 다시 그리기
(발췌: demos/demosMotionD.ts i553 의 update 를 정리)
```ts
// N: [x, y, 부모 번호] — 부모가 자식보다 앞에 오게 (깊이 순)
const pos: number[][] = [];
N.forEach((n, i) => {
  const k = K[i];                                   // 펼침 정도 (outBack, 1 을 살짝 넘음)
  if (n[2] < 0) pos[i] = [n[0], n[1]];
  else {
    const pp = pos[n[2]];                           // 부모의 「지금」 자리
    pos[i] = [pp[0] + (n[0] - pp[0]) * k, pp[1] + (n[1] - pp[1]) * k];
  }
  nodes[i].setAttribute('transform', 'translate(' + pos[i][0] + ' ' + pos[i][1] + ') scale(' + Math.max(0, k) + ')');
});

// 가지: 부모 아래 → 자식 위, 가운데 높이에서 꺾이는 S 자
function link(a: number[], b: number[], ra: number, rb: number) {
  const my = (a[1] + b[1]) / 2;
  return 'M' + a[0] + ' ' + (a[1] + ra) + ' C' + a[0] + ' ' + my + ' ' + b[0] + ' ' + my + ' ' + b[0] + ' ' + (b[1] - rb);
}
N.forEach((n, i) => { if (i) links[i - 1].setAttribute('d', link(pos[n[2]], pos[i], 10 * K[n[2]], 10 * K[i])); });

// 금빛 길: <path pathLength="1" stroke-dasharray="1 1"> 의 오프셋 1 → 0
gold.style.strokeDashoffset = String(1 - g);
```

## 흔한 실수 · 확인 목록
- [ ] **자식이 부모의 원래 자리에서 보간하면 접힐 때 허공으로 간다** — 부모가 접히며 움직이는 중이면 자식도 「부모의 지금 자리」를 기준으로 해야 같이 빨려 든다. 그래서 부모를 먼저 계산하는 깊이 순서로 돈다.
- [ ] **가지를 처음 한 번만 그리면 마디와 어긋난다** — 마디가 움직이는 동안은 매 프레임 path d 를 다시 만든다 — 문자열 몇 개라 가볍다.
- [ ] **금빛 길 길이를 매번 재면 번거롭다** — pathLength="1" 을 주면 dasharray "1 1" 과 오프셋 0~1 로 길이와 상관없이 번지게 할 수 있다.
- [ ] **outBack 의 1 넘는 값으로 크기를 키우면 마디가 순간 커 보인다** — 자리는 1 을 넘어도 되지만(지나쳤다 돌아옴) 크기는 1.1 정도로 막거나 그대로 두고 확인한다.

## 완성 기준 체크리스트
- [ ] 뿌리가 통 나오고 자식들이 뿌리 자리에서 쏙 나와 제자리로 가며 가지가 늘어난다 (살짝 지나쳤다 돌아옴)
- [ ] 「기본기 → 이동 → 이단 뛰기」 길로 금빛이 번지고, 닿은 마디에 고리가 퍼지며 금색이 된다
- [ ] 「공격」 가지를 접으면 연타 · 강타가 공격 속으로 들어가고 +2 표시가 생기며, 다시 펴면 나온다
- [ ] 마지막에 전체가 아래에서부터 뿌리로 모여 사라진다

## 이 기술 정보
- id: `i553` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 마디 수십 개의 transform 과 가지 path 문자열을 매 프레임 갱신 — 100개 아래면 폰에서도 1ms 안팎.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i553
- 쓰면 좋을 때: 기술 나무 · 업그레이드 화면 / 분류 · 계층을 차근차근 펼쳐 설명할 때
- 쓰지 말 때: 마디가 수백 개 — 대신 한 가지만 펼치고 나머지는 접어 두기 / 이어짐이 나무가 아닌 그물(여러 부모) — 대신 흐름도(i552) · 힘 배치 그래프

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i553 — `src/demos/demosMotionD.ts:159`
```ts
const i553 = {
  kind: 'dom' as const,
  caption: '부모에서 자식이 쏙 나와 제자리로 가며 가지가 늘어나요 — 길 따라 금빛이 번지며 기술이 열리고, 가지를 접으면 부모 속으로 쏙',
  make(box: HTMLElement) {
    const o = { s: 1 };
    // [x, y, 부모, 이름, 색]
    const N: [number, number, number, string, string][] = [
      [140, 22, -1, '기본기', '#ffb800'],
      [56, 76, 0, '공격', '#ff5c6c'],
      [140, 76, 0, '방어', '#3a8bff'],
      [224, 76, 0, '이동', '#1fbf6a'],
      [30, 132, 1, '연타', '#ff5c6c'],
      [82, 132, 1, '강타', '#ff5c6c'],
      [114, 132, 2, '막기', '#3a8bff'],
      [166, 132, 2, '반격', '#3a8bff'],
      [198, 132, 3, '달리기', '#1fbf6a'],
      [250, 132, 3, '이단 뛰기', '#1fbf6a'],
    ];
    const GOLD = [0, 3, 9]; // 열리는 길
    let s = '';
    N.forEach((n, i) => {
      if (i) s += `<path class="ln" style="stroke:${n[4]}"/>`;
    });
    s += '<path class="gl" pathLength="1"/><path class="gl" pathLength="1"/>';
    N.forEach((n, i) => {
      const r = i ? 10 : 13;
      s += `<g class="nd"><circle class="br" r="${r}" fill="none" stroke="#ffb800" stroke-width="2"/><circle class="cc" r="${r}" style="stroke:${n[4]}"/><text class="ic" style="fill:${n[4]}">${n[3][0]}</text><text class="nm" y="${r + 9}">${n[3]}</text>${i === 1 ? '<g class="pl"><circle cx="9" cy="-9" r="5.5" fill="#ff5c6c"/><text x="9" y="-9" class="pt">+2</text></g>' : ''}</g>`;
    });
    const S = stage(
      box,
      `.mc53 .ln{fill:none;stroke-width:2;opacity:.55}
      .mc53 .gl{fill:none;stroke:#ffb800;stroke-width:3.2;stroke-linecap:round;stroke-dasharray:1 1}
      .mc53 .cc{fill:#fff;stroke-width:2}
      .mc53 .ic{font:900 9px ${F};text-anchor:middle;dominant-baseline:central}
      .mc53 .nm{font:700 7.5px ${F};fill:#cfd8f5;text-anchor:middle;dominant-baseline:central}
      .mc53 .pt{font:800 6px ${F};fill:#fff;text-anchor:middle;dominant-baseline:central}
      .mc53 .ttl{font:800 9px ${F};fill:#8fa0d8}`,
      `<svg class="mc53" width="280" height="175" viewBox="0 0 280 175" style="position:absolute;left:0;top:0;overflow:visible"><text class="ttl" x="10" y="14">기술 나무</text>${s}</svg>`,
      'radial-gradient(ellipse at 50% 30%,#2b3a78,#121a3d)',
    );
    const lns = [...S.st.querySelectorAll<SVGPathElement>('.ln')];
    const gls = [...S.st.querySelectorAll<SVGPathElement>('.gl')];
    const nds = [...S.st.querySelectorAll<SVGGElement>('.nd')];
    const ccs = [...S.st.querySelectorAll<SVGCircleElement>('.cc')];
    const brs = [...S.st.querySelectorAll<SVGCircleElement>('.br')];
    const plus = S.st.querySelector<SVGGElement>('.pl')!;
    const link = (a: number[], b: number[], ra: number, rb: number): string => {
      const my = (a[1]! + b[1]!) / 2;
      return `M${a[0]!.toFixed(1)} ${(a[1]! + ra).toFixed(1)} C${a[0]!.toFixed(1)} ${my.toFixed(1)} ${b[0]!.toFixed(1)} ${my.toFixed(1)} ${b[0]!.toFixed(1)} ${(b[1]! - rb).toFixed(1)}`;
    };
    let tt = 0;
    return {
      controls: [speedCtl(o)] as Control[],
      update(_t: number, dt: number) {
        tt += Math.max(0, Math.min(dt, 0.1)) * o.s;
        const T = 8.2;
        const p = tt % T;
        // 펼침 정도 k (outBack 이라 1 을 살짝 넘었다 돌아옴) — 자식 자리 = 부모 자리와 제자리 사이를 k 로
        const fold = p < 5.6 ? 1 - inOut(seg(p, 4.6, 5.0)) : outBack(seg(p, 5.6, 6.05), 1.3);
        const K = N.map((n, i) => {
          if (i === 0) return outBack(seg(p, 0.05, 0.4), 2) * (1 - inOut(seg(p, 7.55, 7.85)));
          if (n[2] === 0) return outBack(seg(p, 0.45 + 0.1 * (i - 1), 0.9 + 0.1 * (i - 1)), 1.3) * (1 - inOut(seg(p, 7.2, 7.55)));
          const j = n[2] - 1;
          const m = (i - 4) % 2;
          const e = outBack(seg(p, 1.0 + 0.1 * j + 0.05 * m, 1.45 + 0.1 * j + 0.05 * m), 1.3);
          return e * (n[2] === 1 ? fold : 1) * (1 - inOut(seg(p, 6.85, 7.2)));
        });
        const pos: number[][] = [];
        N.forEach((n, i) => {
          if (n[2] < 0) pos[i] = [n[0], n[1]];
          else {
            const pp = pos[n[2]]!;
            pos[i] = [lerp(pp[0]!, n[0], K[i]!), lerp(pp[1]!, n[1], K[i]!)];
          }
        });
        // 금빛 길: 기본기 → 이동 → 이단 뛰기
        const g1 = seg(p, 2.3, 2.8);
        const g2 = seg(p, 2.95, 3.45);
        const unlocked = [1, p >= 2.8 ? 1 : 0, p >= 3.45 ? 1 : 0];
        const burst = [9, p - 2.8, p - 3.45];
        N.forEach((n, i) => {
          const k = K[i]!;
          nds[i]!.setAttribute('transform', `translate(${pos[i]![0]!.toFixed(2)} ${pos[i]![1]!.toFixed(2)}) scale(${Math.max(0, k).toFixed(4)})`);
          nds[i]!.style.opacity = clamp01(k * 1.5).toFixed(3);
          if (i) {
            const pi = n[2];
            lns[i - 1]!.setAttribute('d', link(pos[pi]!, pos[i]!, (pi ? 10 : 13) * Math.max(0, K[pi]!), 10 * Math.max(0, k)));
            lns[i - 1]!.style.opacity = (0.55 * clamp01(k * 1.5)).toFixed(3);
          }
          const gi = GOLD.indexOf(i);
          const on = gi >= 0 && unlocked[gi]!;
          ccs[i]!.style.fill = on ? '#fff3c4' : '';
          const b = gi >= 0 && on ? burst[gi]! : 9;
          brs[i]!.setAttribute('r', ((i ? 10 : 13) + 14 * clamp01(b / 0.45)).toFixed(2));
          brs[i]!.style.opacity = b >= 0 && b < 0.45 ? (1 - b / 0.45).toFixed(3) : '0';
        });
        gls[0]!.setAttribute('d', link(pos[0]!, pos[3]!, 13 * Math.max(0, K[0]!), 10 * Math.max(0, K[3]!)));
        gls[1]!.setAttribute('d', link(pos[3]!, pos[9]!, 10 * Math.max(0, K[3]!), 10 * Math.max(0, K[9]!)));
        gls[0]!.style.strokeDashoffset = (1 - g1).toFixed(4);
        gls[1]!.style.strokeDashoffset = (1 - g2).toFixed(4);
        gls[0]!.style.opacity = g1 > 0 ? clamp01(K[3]!).toFixed(3) : '0';
        gls[1]!.style.opacity = g2 > 0 ? clamp01(K[9]!).toFixed(3) : '0';
        plus.style.opacity = (clamp01(1 - fold * 2) * clamp01(K[1]!)).toFixed(3);
      },
      dispose() {
        S.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [흐름도 차례 그리기 (상자 · 선 그리기 · 흐르는 점)](https://ai-techstudio.web.app/ai/t/i552.md) `i552` · [이징 곡선 모음 (트윈 비교)](https://ai-techstudio.web.app/ai/t/i292.md) `i292`
- 다음에 해 볼 기술: [공유 요소 전환 (FLIP · 목록 → 상세)](https://ai-techstudio.web.app/ai/t/i548.md) `i548` · [격자 물결 (stagger grid)](https://ai-techstudio.web.app/ai/t/i101.md) `i101`
- 참고 문서: [MDN — pathLength](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/pathLength) · [Wikipedia — Tree drawing](https://en.wikipedia.org/wiki/Tree_drawing)
