# AI 꾸러미 — 길 따라 흐르는 글자 — SVG textPath
> SVG textPath 로 문장을 원 둘레 길에 얹고 시작 위치(startOffset)만 계속 바꿔, 글자가 궤도를 따라 끝없이 흐르게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i92

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

## 주문서

### 만들어 줘: 길 따라 흐르는 글자 — SVG textPath

#### 1. 목표
원 둘레 · 행성 궤도 문구 (원의 둘레 = 2πr)에 길을 따라 흐르는 글자를 넣어 줘 — SVG textPath 의 startOffset 만 움직여서, 이음새 없이 돌게. 분위기는 밤하늘 우주.

#### 2. 핵심 기술 용어
- **SVG textPath** — 글자를 경로 위에 얹는 SVG 요소
- **startOffset** — 경로 위 글자 시작 위치 — 이것만 바꾸면 흐른다
- **textLength + lengthAdjust** — 글자 전체 길이를 경로 길이에 꼭 맞추기

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 2D · 브라우저 — PC · 폰 모두

#### 4. 조건
- 경로는 두 바퀴, 글자는 정확히 한 바퀴 길이 (textLength = 2πr) — 그래야 한 바퀴 넘어갈 때 튀지 않는다
- startOffset 은 둘레로 나눈 나머지(mod)로
- 문장 끝에 구분 기호(★ · ✦)를 넣어 처음과 끝이 이어져 보이게
- viewBox + preserveAspectRatio="xMidYMid meet" 로 어떤 크기에서도 잘리지 않게

#### 5. 완성 기준 (이게 보이면 성공)
- 원 둘레를 따라 「원의 둘레 = 2 × π × 반지름」 이 끊김 없이 돈다
- 안쪽 원의 「π = 3.14159…」 는 다른 빠르기로 돈다
- 한 바퀴 넘어가는 순간에도 글자가 튀거나 겹치지 않는다
- 「속도」 슬라이더로 빠르기가 바로 바뀐다

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

## 원리
- textPath 는 글자를 경로(path) 위에 차례로 얹는다. startOffset 을 늘리면 글자들이 길을 따라 미끄러진다.
- 경로를 두 바퀴로 그리고, 글자 길이를 정확히 한 바퀴(2πr)로 맞추면 한 바퀴 옮긴 모습이 처음과 똑같다 → 이음새 없음.
- textLength 를 둘레 길이로 주고 lengthAdjust="spacing" 으로 글자 사이만 늘려 맞춘다.
- startOffset = (시간 × 빠르기) mod 둘레. 안쪽 원은 다른 빠르기로 돌리면 깊이감이 생긴다.

## 핵심 코드 — 두 바퀴 경로 + 한 바퀴 글자 + startOffset 돌리기
(발췌: demos/demosMotionA.ts i92 를 정리)
```ts
const R1 = 66, cx = 140, cy = 88;
const C1 = 2 * Math.PI * R1;                       // 둘레 = 글자 전체 길이
// 반원 호 둘 = 한 바퀴. 두 번 이어 두 바퀴 길을 만든다
const lap = (r: number) => 'a ' + r + ' ' + r + ' 0 1 1 0 ' + 2 * r + ' a ' + r + ' ' + r + ' 0 1 1 0 ' + -2 * r;
const d = 'M ' + cx + ' ' + (cy - R1) + ' ' + lap(R1) + ' ' + lap(R1);

box.innerHTML =
  '<svg viewBox="0 0 280 175" preserveAspectRatio="xMidYMid meet" style="width:100%;height:100%">' +
  '<defs><path id="ring" d="' + d + '"/></defs>' +
  '<text style="font:800 12px sans-serif;fill:#ffe27a">' +
  '<textPath id="tp" href="#ring" textLength="' + C1.toFixed(2) + '" lengthAdjust="spacing">' +
  '원의 둘레 = 2 × π × 반지름 ★ 원의 둘레 = 2 × π × 반지름 ★ </textPath></text></svg>';
const tp = box.querySelector('#tp')!;

let tt = 0;
function update(dt: number): void {
  tt += dt * speed;
  const mod = (a: number, b: number) => ((a % b) + b) % b;
  tp.setAttribute('startOffset', mod(tt * 30, C1).toFixed(2)); // 한 바퀴 옮기면 처음과 똑같다
}
```

## 흔한 실수 · 확인 목록
- [ ] **경로를 한 바퀴만 그리면 글자가 끝에서 사라졌다 처음에 나타난다** — 경로는 두 바퀴, 글자는 한 바퀴. 넘친 글자가 두 번째 바퀴에 그려져 끊김이 없다.
- [ ] **글자 길이가 둘레와 다르면 한 바퀴마다 툭 튄다** — textLength 로 둘레 길이에 꼭 맞추고 lengthAdjust="spacing".
- [ ] **같은 페이지에 견본이 여럿이면 path id 가 겹친다** — 견본은 id 에 번호를 붙여(ma92-1, ma92-2 …) 겹치지 않게 했다.

## 완성 기준 체크리스트
- [ ] 원 둘레를 따라 「원의 둘레 = 2 × π × 반지름」 이 끊김 없이 돈다
- [ ] 안쪽 원의 「π = 3.14159…」 는 다른 빠르기로 돈다
- [ ] 한 바퀴 넘어가는 순간에도 글자가 튀거나 겹치지 않는다
- [ ] 「속도」 슬라이더로 빠르기가 바로 바뀐다

## 이 기술 정보
- id: `i92` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 속성 하나(startOffset)를 매 프레임 바꾸는 것뿐. 글자 배치는 브라우저가 한다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i92
- 쓰면 좋을 때: 원 · 궤도 · 나선을 다루는 장면에 설명을 겹칠 때 / 배지 · 로고 둘레에 글자가 돌게 할 때
- 쓰지 말 때: 글자를 하나하나 따로 움직여야 할 때 — 대신 캔버스로 글자마다 그리기 (키네틱 타이포) / 아주 긴 문장 — 곡선 위에선 읽기 어렵다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i92 — `src/demos/demosMotionA.ts:383`
```ts
const i92 = {
  kind: 'dom' as const,
  caption: '원 둘레를 따라 문장이 흘러요 — SVG textPath 의 시작 위치(startOffset)만 계속 바꾼 것',
  make(box: HTMLElement) {
    const id = `ma92-${++uid}`;
    const o = { s: 1 };
    const R1 = 66;
    const R2 = 40;
    const C1 = TAU * R1;
    const C2 = TAU * R2;
    const cx = 140;
    const cy = 88;
    // 두 바퀴 길: 글자 길이가 정확히 한 바퀴라 시작점을 한 바퀴 옮기면 처음과 똑같다
    const lap = (r: number, sweep: number): string => `a ${r} ${r} 0 1 ${sweep} 0 ${2 * r} a ${r} ${r} 0 1 ${sweep} 0 ${-2 * r}`;
    const p1 = `M ${cx} ${cy - R1} ${lap(R1, 1)} ${lap(R1, 1)}`;
    const p2 = `M ${cx} ${cy - R2} a ${R2} ${R2} 0 1 0 0 ${2 * R2} a ${R2} ${R2} 0 1 0 0 ${-2 * R2} a ${R2} ${R2} 0 1 0 0 ${2 * R2} a ${R2} ${R2} 0 1 0 0 ${-2 * R2}`;
    let st = '';
    for (let i = 0; i < 46; i++) st += `<circle cx="${(hsh(i) * 280).toFixed(1)}" cy="${(hsh(i + 9) * 175).toFixed(1)}" r="${(0.4 + hsh(i + 3) * 0.8).toFixed(2)}" fill="#fff" opacity="${(0.3 + hsh(i + 5) * 0.5).toFixed(2)}"/>`;
    const { root } = svgRoot(
      box,
      `.ma-92t{font:800 12px ${F};fill:#ffe27a;letter-spacing:.5px}.ma-92s{font:700 10px ${F};fill:#9fe6ff}`,
      `<defs>
        <linearGradient id="${id}-bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#25306e"/><stop offset="1" stop-color="#0c1132"/></linearGradient>
        <radialGradient id="${id}-pl" cx=".35" cy=".35" r=".8"><stop offset="0" stop-color="#9ff0ff"/><stop offset=".55" stop-color="#3b8cff"/><stop offset="1" stop-color="#2a3ab8"/></radialGradient>
        <path id="${id}-a" d="${p1}"/><path id="${id}-b" d="${p2}"/>
      </defs>
      <rect x="-200" y="-200" width="680" height="575" fill="url(#${id}-bg)"/>
      ${st}
      <circle cx="${cx}" cy="${cy}" r="${R1}" fill="none" stroke="rgba(255,255,255,.12)" stroke-width="16"/>
      <circle cx="${cx}" cy="${cy}" r="${R2}" fill="none" stroke="rgba(120,220,255,.1)" stroke-width="12"/>
      <line class="ma-92r" x1="${cx}" y1="${cy}" x2="${cx + R2 - 8}" y2="${cy}" stroke="#fff" stroke-width="1" stroke-dasharray="2 2" opacity=".7"/>
      <circle cx="${cx}" cy="${cy}" r="17" fill="url(#${id}-pl)"/>
      <ellipse cx="${cx}" cy="${cy}" rx="26" ry="6" fill="none" stroke="#ffd166" stroke-width="2" opacity=".85" transform="rotate(-18 ${cx} ${cy})"/>
      <text class="ma-92t"><textPath class="ma-92a" href="#${id}-a" textLength="${C1.toFixed(2)}" lengthAdjust="spacing">원의 둘레 = 2 × π × 반지름 ★ 원의 둘레 = 2 × π × 반지름 ★ </textPath></text>
      <text class="ma-92s"><textPath class="ma-92b" href="#${id}-b" textLength="${C2.toFixed(2)}" lengthAdjust="spacing">π = 3.14159… ✦ π = 3.14159… ✦ </textPath></text>`,
    );
    const ta = root.querySelector('.ma-92a')!;
    const tb = root.querySelector('.ma-92b')!;
    const rl = root.querySelector('.ma-92r')!;
    let tt = 0;
    return {
      controls: [speedCtl(o)],
      update(_t: number, dt: number) {
        tt += Math.max(0, dt) * o.s;
        ta.setAttribute('startOffset', (mod(tt * 30, C1)).toFixed(2));
        tb.setAttribute('startOffset', (mod(tt * 22, C2)).toFixed(2));
        rl.setAttribute('transform', `rotate(${((tt * 40) % 360).toFixed(1)} ${cx} ${cy})`);
      },
      dispose() {
        root.remove();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [코드로 그린 SVG 아이콘 · 도장](https://ai-techstudio.web.app/ai/t/u57.md) `u57`
- 다음에 해 볼 기술: [글자 차례 등장 (키네틱 타이포)](https://ai-techstudio.web.app/ai/t/i90.md) `i90` · [경로 따라 움직이기 (방향 포함)](https://ai-techstudio.web.app/ai/t/i103.md) `i103`
- 참고 문서: [MDN — <textPath>](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/textPath)
