• #Vue 3
  • #Composition API
  • #SVG
  • #アニメーション
  • #教材
  • #幾何
  • #純粋関数
  • #リファクタリング
開発mdx-playground完了

多角形の外角の和ページを React/JSX から Vue へ移植してアニメーションを4段階で改良した記録

別プロジェクトで作っていた exterior-angles.jsx という教材コンポーネント(多角形の外角を1点に集めて、和が 360° になる様子をアニメーションで見せるやつ)を、mdx-playground の Nuxt 環境に持ってきて Vue 3 の Composition API に書き換えた。アニメーションの設計を4回ひっくり返した結果、最終的にスライダーで手動操作する形に落ち着いた、というのが今日の話。

新規ページ /exterior-angles を切り、トップページの「学習・クイズ」セクションからリンクを張った。最後に Simplify レビューを3エージェント並列で走らせて、不要な手書きパスや使われていない CSS を剥がした。


0. 移植の方針

React/JSX 版はフックの中に計算ロジックと描画と副作用が同居していた。Vue 化するついでに、以下の方針で書き直した。

  • 純粋関数(computeVertices, computeEdgeDirs, computeSectors, formatAngle 等)は <script setup> の外、moduleレベルに置く
  • ref / computed は値を保持するだけ
  • 副作用(RAF を回すアニメーション、DOM測定)は watch の中に隔離する
  • props で受ける値(頂点数 n、半径 R 等)はリアクティブに反応するが、計算自体は引数だけで完結するようにする

要は CLAUDE.md にも書いてある「ロジックは純粋関数、副作用は薄いシェル」のルールをそのまま当てはめた形。

// moduleレベル — 純粋関数。テストもしやすい
const computeSectors = (
  vertices: Point[],
  edgeDirs: EdgeDir[],
  progress: number,
): Sector[] => {
  // 頂点ごとに「外角の弧」をどこに描くかを返す
  // progress は 0〜1 で、0=各頂点に分散、1=中心に集約
  // ...
}

// <script setup> 内 — refで値を持ち、computedで派生
const polygonN = ref(5)
const progress = ref(0)
const vertices = computed(() => computeVertices(polygonN.value, R))
const sectors = computed(() => computeSectors(vertices.value, edgeDirs.value, progress.value))

最初から純粋関数として切り出しておくと、後でアニメーション設計を作り直すときに描画ロジックだけ差し替えれば済むので、結果的にこの方針が4回の作り直しを支えてくれた。


1. 数値表示のバグ — 72.00° の末尾ゼロ

書き換えた直後、五角形(n=5)の外角を表示したら 72.00° と出てきた。割り切れる値に末尾ゼロが付くのは見栄えが悪い。

// Before — 常に小数2桁
const formatAngle = (deg: number) => `${deg.toFixed(2)}°`

// After — 整数なら整数、そうでなければ小数2桁
const formatAngle = (deg: number) => {
  const rounded = Math.round(deg * 100) / 100
  return Number.isInteger(rounded) ? `${rounded}°` : `${rounded.toFixed(2)}°`
}

n=5 だと 72°、n=7 だと 51.43° のように、必要なときだけ小数を出すようにした。教材なので「割り切れる」という事実そのものが情報になる。72.00 だと、生徒に「ちゃんと割り切れているのか」と聞かれそうな顔をしている。


2. アニメーション設計の4段階の変遷

ここが今日いちばん時間を吸われたところ。「外角を1点に集めると360°になる」というメッセージを、どう動かして見せるか、という設計を4回作り直した。

段階1: 各頂点を中心に向かって移動させて外角を集約する

最初に書いたのは「五角形の各頂点を、図形の中心点に向かって引き寄せる」アニメーション。progress: 0 で五角形が普通に表示されていて、progress: 1 で5つの頂点が中心の1点に重なる。外角の弧(セクター)も頂点と一緒に動かしたので、最終的に5つの弧が中心で円を描く。

実装してブラウザで再生したら、途中で図形がぐにゃっと潰れて、もはや五角形ではない歪な多角形になった。頂点をそれぞれ独立に中心へ引っ張ると、辺の長さが個別に縮んで形が崩れる。「途中で図形の輪郭が消える」と言ったほうが正確かもしれない。教材としては最悪で、何が起きているのか伝わらない。

段階2: 図形を相似縮小しながら頂点を中心に集める

ユーザーから「図形の形を保ったまま、相似形で縮小しながら頂点を中心に集めてほしい」と指摘を受けた。なるほど、と思って polygonPointsanimatedVertices に差し替えて、半径 R を R * (1 - progress) で縮めるようにした。

const animatedVertices = computed(() =>
  computeVertices(polygonN.value, R * (1 - progress.value))
)

これで五角形は形を保ったまま縮んで、最後は中心の1点に潰れる。頂点とセクターは一緒に中心に集まるので、見た目もスッキリした。よし、と思って次に進んだ。

段階3: セクターが回転してしまう問題

ところが今度は「セクターが途中で回転している」とユーザーから指摘が入った。確認すると、外角の弧(円弧の開始角・終了角)を頂点位置と一緒に補間していたせいで、頂点が中心に近づくにつれて角度がぐるぐる回っていた。外角の「向き」自体は頂点の位置に依存しないはずなのに、補間ロジックの都合で角度まで動いていた。

修正としては、角度補間を全部やめて、セクターは平行移動だけさせるようにした。各セクターの向き(開始角・終了角)は最初に計算した値を固定で保持し、中心点 (cx, cy) だけを progress で動かす。

// セクターの向きは固定。中心点だけを progress で動かす
const animatedSectors = computed(() =>
  staticSectors.value.map((s) => ({
    ...s,
    cx: lerp(s.cx, CX, progress.value),
    cy: lerp(s.cy, CY, progress.value),
    // startAngle, endAngle は触らない
  }))
)

これで弧の向きが保たれたまま、5つの弧が中心の1点に集まる動きになった。集まった瞬間、5つの弧がぴったり繋がって360°の円を描く。やっと意図した見え方になった。

段階4: スライダーに一本化

ここまで来てもう一つ問題があって、RAFで自動再生していると「途中の状態」を観察しづらい。progress: 0.3 あたりで止めてじっくり見たい、という需要に応えにくい。再生・一時停止ボタンも考えたが、教材として一番使いやすいのは「スライダーで自分で動かす」だった。

自動再生をバッサリ削って、<input type="range" min="0" max="100" v-model.number="progressPercent" /> 1本に置き換えた。progressprogressPercent / 100 で computed する。RAF も watch も全部消した。コード量が一気に減った。

副作用が watch 内に閉じ込められていたので、削除も追加もこの場所の編集だけで済んだ。純粋関数を最初に切り出しておいたのが効いた瞬間だった。


3. 4図形のグリッド表示に拡張

ここまでで n=5 の単一表示が完成したので、「他の頂点数でも同時に見たい」という拡張に進んだ。n=4(正方形)、n=6(六角形)、n=8(八角形)、n=10(正十角形)の4つを 2×2 グリッドで並べて、共通スライダー1本で全部を同期させる。

<template>
  <div class="grid grid-cols-2 gap-4">
    <PolygonCanvas v-for="n in [4, 6, 8, 10]" :key="n" :n="n" :progress="progress" />
  </div>
  <input type="range" min="0" max="100" v-model.number="progressPercent" />
</template>

PolygonCanvas は props だけで動く純粋なコンポーネントになっているので、グリッド化は v-for を1行書くだけで済んだ。スライダーを動かすと4つの多角形が同期して縮み、4つとも中心で360°の円を描く。「頂点の数に関係なく外角の和は360°」というメッセージが、4つの図形が同じ瞬間に同じ円を完成させる絵で伝わる。

ついでにパンくず(Home › 多角形の外角の和)を追加して、トップページの「学習・クイズ」セクションに /exterior-angles へのリンクを差し込んだ。


4. Simplify レビュー(3エージェント並列)

仕上げに simplify スキルを3エージェント並列で走らせた。指摘されたのは以下。

  • スライダーの input ハンドラが @input で値を取り出して別の ref に詰め直していたが、v-model.number 1行で済む。書き換えた
  • セクターの SVG パスを毎フレーム手で組み立てていたが、computeSectors 側で d を返すようにすれば、テンプレートは :d="sec.d" を渡すだけになる。差し替えた
  • 自動再生時代の名残で .is-playing .paused といった CSS クラスが残っていた。削除した

3エージェント並列でレビューを回すと、お互いに違う観点を出してくる(v-modelの簡素化、データ構造の責務移動、不要CSSの掃除)ので、1人より見落としが減る。指摘の重複もあったが、3つとも同じ箇所を指摘してきたところは「本当に直すべき」というシグナルになって判断が楽だった。


5. 学び

  • 純粋関数を最初に切り出すと、後の作り直しが軽い。今日アニメーション設計を4回作り直したが、computeSectors の引数を増やしただけで済んだ回が多かった。watch の中身を全削除した最後の段階でも、純粋関数側は無傷だった
  • 教材アニメーションは「自動再生」より「スライダーで手動」のほうが伝わることが多い。0.3秒の中に意味が詰まっている瞬間は、止めて見せないと頭に入らない
  • 形を保ったまま縮めるのと、頂点を独立に動かすのでは、視覚的なメッセージが全く違う。同じ「中心に集まる」動きでも、相似縮小なら「形が保たれている」というメタメッセージが乗る
  • 角度の補間は罠。位置を補間するときに、向きを表すパラメータまで巻き込んで動かしてしまうと、意図しない回転が生まれる。並進と回転を分けて考える

6. 明日以降にやりたいこと

  • 内角の和(180°×(n-2))の教材ページも同じ枠組みで作る
  • スライダーの値を URL クエリに反映して、特定の progress をシェアできるようにする
  • モバイルで4グリッド表示が窮屈なので、breakpoint 以下では1列に折り返す