# AI 꾸러미 — 흐름도 차례 그리기 (상자 · 선 그리기 · 흐르는 점) — Animated flowchart / diagram
> 순서도 상자가 차례로 튀어나오고 선이 그려져 화살촉이 꽂힌 뒤, 데이터 점이 선을 따라 돌며 지나간 상자가 반짝 — 규칙 · 과정이 「흐름」으로 읽힌다.  
> 견본: https://ai-techstudio.web.app/#t/i552

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

## 주문서

### 만들어 줘: 흐름도 차례 그리기 (상자 · 선 그리기 · 흐르는 점) — Animated flowchart / diagram

#### 1. 목표
퀴즈 진행 규칙 (입력 → 판정 → 점수/힌트)을(를) 움직이는 순서도로 만들어 줘 — 상자가 차례로 튀어나오고 선이 그려져 화살촉이 꽂힌 뒤, 데이터 점이 선을 따라 돌며 지나간 상자가 반짝이게. 분위기는 밝은 모눈 바탕 · 파란 선.

#### 2. 핵심 기술 용어
- **Animated flowchart / diagram** — 상자 · 선이 차례로 나타나고 흐름이 움직이는 도표
- **SVG stroke-dashoffset line drawing** — 대시 길이 = 선 길이로 두고 오프셋을 줄여 선을 그려 나가기
- **getTotalLength · getPointAtLength** — SVG 경로 길이 · 길이 위치의 점 — 점을 선 따라 움직이기
- **Traveling packet / data flow dots** — 선을 따라 흐르는 점 — 무엇이 어디로 가는지

#### 3. 환경
- 플랫폼: TypeScript + HTML · CSS · SVG (DOM), 라이브러리 없이 — 움직임은 transform · opacity 로
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, 60fps 목표
- SVG path 마다 stroke-dasharray = 길이, stroke-dashoffset 을 길이 → 0. 화살촉은 marker 대신 따로 그린 삼각형(marker 는 대시와 상관없이 처음부터 보인다).

#### 4. 조건
- 선은 stroke-dasharray = 경로 길이, stroke-dashoffset 을 줄여 그리기 (길이는 getTotalLength 로 한 번 재 두기)
- 화살촉은 선이 거의 다 그려진 뒤에 나타나게 — marker-end 를 쓰면 처음부터 끝에 보이므로 따로 그린다
- 흐르는 점의 자리는 「지금 시각」에서 계산 (선 길이 ÷ 빠르기, 상자에서 잠깐 머묾) — 속도가 바뀌어도 어긋나지 않게
- 지나간 상자 반짝 · 갈림길 색(예 초록 · 아니오 빨강)으로 어느 길로 갔는지 보이게
- 글자 크기는 폰(390px 폭)에서도 읽히게 — 상자 글 최소 11px

#### 5. 완성 기준 (이게 보이면 성공)
- 상자가 시작부터 차례로 통 튀어나오고, 상자 사이 선이 시작에서 끝 방향으로 그려진 뒤 화살촉이 꽂힌다
- 노란 점이 「답 입력 → 맞았나? → 예/아니오 → 다시」를 돌고, 지나간 상자 테두리가 잠깐 노랗게 굵어진다
- 「예」로 가면 그 선이 초록, 「아니오」면 빨강으로 물든다
- 흐르는 점 수를 1 → 3 으로 바꾸면 점이 셋 돌고, 속도를 바꿔도 점이 선을 벗어나지 않는다

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

## 원리
- 선 그리기: 경로 길이 L 을 재서 stroke-dasharray 를 「L L」로 — 대시 하나가 선 전체. dashoffset 을 L → 0 으로 줄이면 시작에서 끝으로 그려진다.
- 차례: 상자 → 그 상자에서 나가는 선 → 다음 상자. 상자는 outBack 으로 통, 선이 85% 쯤 그려졌을 때 화살촉이 나타난다.
- 흐름: 점마다 「지금까지 걸은 시간」으로 어느 선 위 어디인지 계산한다 — 선 길이 ÷ 빠르기 = 걸리는 시간, 상자에서 0.15초 머묾. 갈림길은 정해 둔 무늬(예/아니오)로.
- 점이 상자에 닿은 뒤 0.4초 동안 상자 테두리가 굵고 노랗게 — 「여기서 처리됨」. 갈림길 선은 예 = 초록, 아니오 = 빨강으로 잠깐 물든다.

## 핵심 코드 — 선 그리기 + 선 따라 점 옮기기
(발췌: demos/demosMotionD.ts i552 의 measure · update 를 정리)
```ts
const paths = [...svg.querySelectorAll<SVGPathElement>('path.edge')];
const lens = paths.map((p) => p.getTotalLength());
paths.forEach((p, i) => (p.style.strokeDasharray = lens[i] + ' ' + lens[i]));

// 화살촉: 끝 바로 앞 점 → 끝 점 방향으로 돌려 끝에 놓기
paths.forEach((p, i) => {
  const a = p.getPointAtLength(lens[i] - 1.5), b = p.getPointAtLength(lens[i]);
  heads[i].setAttribute('transform', 'translate(' + b.x + ' ' + b.y + ') rotate(' + Math.atan2(b.y - a.y, b.x - a.x) * 180 / Math.PI + ')');
});

function drawEdge(i: number, k: number) {             // k: 0 → 1
  paths[i].style.strokeDashoffset = String(lens[i] * (1 - k));
  heads[i].style.opacity = String(Math.min(1, Math.max(0, (k - 0.85) / 0.15)));
}

// 점: 경로 목록을 따라 걷기 (V = 빠르기, WAIT = 상자에서 머묾)
function dotAt(t: number, route: number[]): DOMPoint | null {
  for (const ei of route) {
    const dur = lens[ei] / V;
    if (t < dur) return paths[ei].getPointAtLength(lens[ei] * t / dur);
    t -= dur;
    if (t < WAIT) return paths[ei].getPointAtLength(lens[ei]);  // 상자에서 잠깐 머묾
    t -= WAIT;
  }
  return null;
}
```

## 흔한 실수 · 확인 목록
- [ ] **marker-end 화살촉은 선이 그려지기 전부터 끝에 떠 있다** — marker 는 대시와 상관없이 경로 끝에 그려진다. 화살촉을 따로 그리고 선이 85% 넘게 그려지면 나타나게 했다.
- [ ] **화면에 붙기 전에 getTotalLength 를 부르면 0 이 나올 수 있다** — 요소가 문서에 들어간 뒤 재고, 0 이면 다음 프레임에 다시 잰다 (견본은 update 첫머리에서 확인).
- [ ] **모양을 확대 · 축소하면 dasharray 가 안 맞는다** — vector-effect 나 transform 으로 크기가 바뀌면 길이도 바뀐다. 길이를 다시 재거나 pathLength="1" 로 정규화한다.
- [ ] **점을 setInterval 로 한 칸씩 옮기면 속도 · 되감기가 깨진다** — 지금 시각에서 걸은 거리를 계산해 바로 자리를 구한다.

## 완성 기준 체크리스트
- [ ] 상자가 시작부터 차례로 통 튀어나오고, 상자 사이 선이 시작에서 끝 방향으로 그려진 뒤 화살촉이 꽂힌다
- [ ] 노란 점이 「답 입력 → 맞았나? → 예/아니오 → 다시」를 돌고, 지나간 상자 테두리가 잠깐 노랗게 굵어진다
- [ ] 「예」로 가면 그 선이 초록, 「아니오」면 빨강으로 물든다
- [ ] 흐르는 점 수를 1 → 3 으로 바꾸면 점이 셋 돌고, 속도를 바꿔도 점이 선을 벗어나지 않는다

## 이 기술 정보
- id: `i552` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — SVG 요소 수십 개. getPointAtLength 는 매 프레임 점 몇 개만. 길이 측정은 처음 한 번.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i552
- 쓰면 좋을 때: 게임 규칙 · 진행 순서를 말 대신 보여 줄 때 / 알고리즘 · 과정 설명 영상, 튜토리얼
- 쓰지 말 때: 상자가 20개 넘는 큰 도표 — 한 번에 다 그리면 눈이 못 따라간다. 대신 부분씩 확대(i104) / 선이 서로 많이 겹치는 그래프 — 대신 나무 구조(i553) 나 층 배치

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i552 — `src/demos/demosMotionD.ts:11`
```ts
const i552 = {
  kind: 'dom' as const,
  caption: '상자가 통 튀어나오고 선이 그려지며 화살촉이 꽂힌 뒤, 데이터 점이 선을 따라 돌고 지나간 상자가 반짝 — 순서도 설명 영상',
  make(box: HTMLElement) {
    const o = { s: 1, dots: 2 };
    // 상자: [가운데 x, y, 너비, 높이, 꼴(0 알약 · 1 네모 · 2 마름모), 글, 나타나는 시각]
    const NODES: [number, number, number, number, number, string, number][] = [
      [34, 88, 44, 22, 0, '시작', 0.2],
      [96, 88, 54, 26, 1, '답 입력', 0.7],
      [162, 88, 56, 40, 2, '맞았나?', 1.2],
      [238, 48, 54, 26, 1, '점수 +1', 1.85],
      [238, 128, 54, 26, 1, '힌트 보기', 1.85],
    ];
    // 선: [경로, 시작 상자, 끝 상자, 그리기 시작 시각, 이름표, 이름표 x, y]
    const EDGES: [string, number, number, number, string, number, number][] = [
      ['M56 88 L69 88', 0, 1, 0.5, '', 0, 0],
      ['M123 88 L134 88', 1, 2, 1.0, '', 0, 0],
      ['M162 68 C162 48 182 48 211 48', 2, 3, 1.5, '예', 176, 42],
      ['M162 108 C162 128 182 128 211 128', 2, 4, 1.5, '아니오', 180, 137],
      ['M238 141 C238 168 96 168 96 101', 4, 1, 2.1, '다시', 167, 163],
      ['M238 35 C238 8 96 8 96 75', 3, 1, 2.1, '다음 문제', 167, 13],
    ];
    const shape = (n: (typeof NODES)[number]): string => {
      const [, , w, h, k] = n;
      if (k === 2) return `<polygon class="sh" points="0,${-h / 2} ${w / 2},0 0,${h / 2} ${-w / 2},0"/>`;
      return `<rect class="sh" x="${-w / 2}" y="${-h / 2}" width="${w}" height="${h}" rx="${k === 0 ? h / 2 : 5}"/>`;
    };
    let svg = '<g class="all">';
    EDGES.forEach((e) => {
      svg += `<path class="e" d="${e[0]}"/><path class="ah" d="M-4.5 -3.2 L1 0 L-4.5 3.2 Z"/>`;
      if (e[4]) svg += `<text class="lb" x="${e[5]}" y="${e[6]}">${e[4]}</text>`;
    });
    NODES.forEach((n) => (svg += `<g transform="translate(${n[0]} ${n[1]})"><g class="nd">${shape(n)}<text class="tx">${n[5]}</text></g></g>`));
    svg += '<circle class="dg" r="6"/><circle class="dt" r="3"/><circle class="dg" r="6"/><circle class="dt" r="3"/><circle class="dg" r="6"/><circle class="dt" r="3"/></g>';
    const S = stage(
      box,
      `.mc52 .sh{fill:#fff;stroke:#4b6bff;stroke-width:1.5}
      .mc52 .tx{font:800 8px ${F};fill:#1d2b4f;text-anchor:middle;dominant-baseline:central}
      .mc52 .e{fill:none;stroke:#8aa4ff;stroke-width:1.6}
      .mc52 .ah{fill:#8aa4ff}
      .mc52 .lb{font:700 7px ${F};fill:#6b7799;text-anchor:middle}
      .mc52 .dt{fill:#ffb800}.mc52 .dg{fill:rgba(255,200,40,.3)}`,
      `<svg class="mc52" width="280" height="175" viewBox="0 0 280 175" style="position:absolute;left:0;top:0;overflow:visible">${svg}</svg>`,
      '#f3f6ff radial-gradient(circle,#d7def5 1px,transparent 1.2px) 0 0/12px 12px',
    );
    const all = S.st.querySelector<SVGGElement>('.all')!;
    const paths = [...S.st.querySelectorAll<SVGPathElement>('.e')];
    const heads = [...S.st.querySelectorAll<SVGPathElement>('.ah')];
    const labels = [...S.st.querySelectorAll<SVGTextElement>('.lb')];
    const nds = [...S.st.querySelectorAll<SVGGElement>('.nd')];
    const shs = [...S.st.querySelectorAll<SVGElement>('.sh')];
    const dts = [...S.st.querySelectorAll<SVGCircleElement>('.dt')];
    const dgs = [...S.st.querySelectorAll<SVGCircleElement>('.dg')];
    let lens: number[] = [];
    const measure = (): void => {
      lens = paths.map((p) => p.getTotalLength());
      paths.forEach((p, i) => {
        // 화살촉은 선 끝 방향으로 — 끝 바로 앞 점과 끝 점으로 각도
        const L = lens[i]!;
        const a = p.getPointAtLength(Math.max(0, L - 1.5));
        const b = p.getPointAtLength(L);
        heads[i]!.setAttribute('transform', `translate(${b.x.toFixed(2)} ${b.y.toFixed(2)}) rotate(${((Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI).toFixed(1)})`);
        p.style.strokeDasharray = `${L} ${L}`;
      });
    };
    const PAT = [1, 0, 1, 1, 0, 1, 0, 0];
    const V = 85; // 점 빠르기 (무대 단위/초)
    const WAIT = 0.15; // 상자에서 잠깐 머묾 (처리 중)
    const RUN0 = 2.7;
    const T = 9.4;
    let tt = 0;
    return {
      controls: [speedCtl(o), { type: 'range', label: '흐르는 점', min: 1, max: 3, step: 1, value: 2, on: (v: number) => (o.dots = v) }] as Control[],
      update(_t: number, dt: number) {
        if (!lens.length || !lens[0]) measure();
        tt += Math.max(0, Math.min(dt, 0.1)) * o.s;
        const p = tt % T;
        all.style.opacity = (1 - seg(p, 8.7, 9.1)).toFixed(3);
        const glow = NODES.map(() => 0);
        const hot = EDGES.map(() => 0);
        // 데이터 점 — 시작에서 나와 「입력 → 판정 → 예/아니오 → 입력」을 돈다. 시각에서 바로 위치를 계산(걸어 보기)
        for (let k = 0; k < 3; k++) {
          const st = RUN0 + k * 1.3;
          let t = p - st;
          let pos: DOMPoint | null = null;
          if (k < o.dots && t >= 0 && p < 8.7) {
            const route = (j: number): number[] => (j < 0 ? [0] : PAT[(k * 3 + j) % PAT.length] ? [1, 2, 5] : [1, 3, 4]);
            for (let j = -1; j < 40 && !pos; j++) {
              for (const ei of route(j)) {
                const L = lens[ei] || 1;
                const dur = L / V;
                if (t < dur) {
                  pos = paths[ei]!.getPointAtLength((L * t) / dur);
                  hot[ei] = 1;
                  break;
                }
                t -= dur;
                const nd = EDGES[ei]![2];
                if (t < 0.4) glow[nd] = Math.max(glow[nd]!, 1 - t / 0.4);
                if (t < WAIT) {
                  pos = new DOMPoint(NODES[nd]![0], NODES[nd]![1]);
                  break;
                }
                t -= WAIT;
              }
            }
          }
          const vis = pos ? '1' : '0';
          dts[k]!.style.opacity = dgs[k]!.style.opacity = vis;
          if (pos) {
            dts[k]!.setAttribute('cx', pos.x.toFixed(2));
            dts[k]!.setAttribute('cy', pos.y.toFixed(2));
            dgs[k]!.setAttribute('cx', pos.x.toFixed(2));
            dgs[k]!.setAttribute('cy', pos.y.toFixed(2));
          }
        }
        // 상자: 통 튀어나오고, 점이 지나가면 반짝
        NODES.forEach((n, i) => {
          const s = outBack(seg(p, n[6], n[6] + 0.35), 2);
          const g = glow[i]!;
          nds[i]!.setAttribute('transform', `scale(${(s * (1 + 0.08 * g)).toFixed(4)})`);
          shs[i]!.style.stroke = g > 0.05 ? '#ffb800' : '';
          shs[i]!.style.strokeWidth = (1.5 + 2 * g).toFixed(2);
        });
        // 선: 대시 오프셋으로 그려 나가고, 다 그려지면 화살촉이 꽂힘
        EDGES.forEach((e, i) => {
          const k = inOut(seg(p, e[3], e[3] + 0.35));
          const L = lens[i] || 0;
          paths[i]!.style.strokeDashoffset = (L * (1 - k)).toFixed(2);
          heads[i]!.style.opacity = seg(k, 0.85, 1).toFixed(3);
          const c = hot[i] ? (i === 2 ? '#1fbf6a' : i === 3 ? '#ff5c6c' : '#ffb800') : '';
          paths[i]!.style.stroke = c;
          heads[i]!.style.fill = c;
        });
        let li = 0;
        EDGES.forEach((e) => {
          if (!e[4]) return;
          labels[li++]!.style.opacity = seg(p, e[3] + 0.25, e[3] + 0.5).toFixed(3);
        });
      },
      dispose() {
        S.dispose();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [SVG 선 그리기 (stroke-dashoffset) · 손떨림](https://ai-techstudio.web.app/ai/t/i80.md) `i80` · [화이트보드 그리기 애니메이션](https://ai-techstudio.web.app/ai/t/i79.md) `i79`
- 다음에 해 볼 기술: [나무 구조 펼치기 (기술 나무 · 마인드맵)](https://ai-techstudio.web.app/ai/t/i553.md) `i553` · [내레이션 동기화 (낱말 시각표)](https://ai-techstudio.web.app/ai/t/i81.md) `i81`
- 참고 문서: [MDN — stroke-dashoffset](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dashoffset) · [MDN — SVGGeometryElement.getPointAtLength()](https://developer.mozilla.org/en-US/docs/Web/API/SVGGeometryElement/getPointAtLength)
