分割
分割の作り方
円が縦に割れ、2 つの半円がすれ違いながら全体が回り、また 1 つの円に閉じる。回転とスライドの二重の振り付けに見えるが、動かしている数値は進み具合 1 本だ。締めのぴたりとした一致は図形の性質が引き受けている — その分解を、組み立ての考え方から順に見ていく。
- Published
- 2026年6月12日
- Topics
- Shear · Rotation · Easing · SVG
半円 2 つと進み具合 1 本を用意する
- まん丸の円を縦の切り口で割った左右 2 つの半円 — 描くのに要るのは中心の置き場所・半径
discRadius・ふくらむ向きの 3 つ - 進み具合 u — 0 が割れる前の静止、1 が離れ切り、2 がもう一度閉じた静止で、キーフレームは
uKeysに打った 3 個 - u から毎フレーム計算で出す 2 つの数 — グループ全体の回転角(u に比例して進みつづける)と、すれ違いの距離(
slideMaxまで行って帰る) - 隙間予防の押し込み
seamInset— S 字のくびれが切れないよう、2 つの半円をわずかに相手へめり込ませる
作る関数は 2 つでいい。打ってあるキーフレームのあいだを補間して、いまのフレームの u を出す keyAt と、u を 2 つの半円の置き場所に変える piecesAt だ。
// 動かしている数値は「進み具合」u の 1 本だけ — 0 で静止、1 で離れ切り、2 でまた閉じる。
// 小文字ではじまる裸の名前(splitStart や slideMax)は、自分の画面に合わせて決める数 —
// この記事が持ち帰ってほしいのは値ではなく、この「形」だ。
const openCurve = cubicBezier(...openHandles); // 割れて離れるビートの緩急 — CSS の cubic-bezier() と同じ規格
const closeCurve = cubicBezier(...closeHandles); // 閉じて戻るビートの緩急
// u に打つキーフレーム: 0 → 1 → 2。値 1 までが行き、2 までが帰り。各キーフレームの curve は次のキーフレームまでの区間に効く
const uKeys = [
{ frame: splitStart, value: 0, curve: openCurve }, // ここまで円のまま静止
{ frame: apartPeak, value: 1, curve: closeCurve }, // 離れ切る — 回転はまだ道半ば
{ frame: sealEnd, value: 2, curve: closeCurve }, // 閉じ切る — 残りは静止
];
// 打ったキーフレームのあいだをカーブで埋めて補間する — この記事の動きはぜんぶこの形で書ける
function keyAt(keys, frame) {
if (frame <= keys[0].frame) return keys[0].value; // 最初のキー以前 — 最初の値のまま
if (frame > keys.at(-1).frame) return keys.at(-1).value; // 終わりより後 — 最後の値のまま
const next = keys.findIndex((key) => frame <= key.frame); // いま向かっている先のキーの番号
const a = keys[next - 1], b = keys[next]; // 手前のキーとの 2 個で区間が決まる
const t = (frame - a.frame) / (b.frame - a.frame); // 区間の進み具合 0〜1
return a.value + (b.value - a.value) * a.curve(t); // 差をカーブの形で進める
}
// フレーム番号を渡すと、2 つの半円の置き場所を返す関数(loopFrames ごとにループ)
function piecesAt(frame) {
const u = keyAt(uKeys, frame % loopFrames);
const turn = (turnPerLoop * u) / 2; // 回転は u に比例 — 自前の緩急を持たず、戻りもしない
const slide = slideMax * (1 - Math.abs(1 - u)); // すれ違いは山なり — 行って帰る
const inset = Math.min(slide, seamInset); // 相手側への押し込み — すぐ頭打ちになる
// 切り口の向き cutAngle(縦なら 90)に沿ってすれ違い、直角の向きに押し込む
const cut = (cutAngle * Math.PI) / 180;
const alongX = Math.cos(cut); // 切り口に沿う向き
const alongY = Math.sin(cut);
const intoX = alongY; // 半円 A が相手へめり込む向き
const intoY = -alongX;
// 半円 A のずれを作り、グループ回転で回してから中心に足す
const shiftX = intoX * inset - alongX * slide;
const shiftY = intoY * inset - alongY * slide;
const g = (turn * Math.PI) / 180;
const ax = centerX + Math.cos(g) * shiftX - Math.sin(g) * shiftY;
const ay = centerY + Math.sin(g) * shiftX + Math.cos(g) * shiftY;
// ふくらむ向き = 切り口と直角 + グループ回転ぶん。半円 B は A の点対称
const bulgeA = cutAngle + 90 + turn;
return [
{ x: ax, y: ay, r: discRadius, bulge: bulgeA },
{ x: 2 * centerX - ax, y: 2 * centerY - ay, r: discRadius, bulge: bulgeA + 180 },
];
}openCurve と closeCurve は CSS の cubic-bezier() と同じ規格で、openHandles に置く 4 つの数は bezier-easing のような既製のライブラリ にそのまま渡せる。キーフレームの範囲の外では keyAt が端の値を返すから、割れる前と閉じたあとの静止は何も書かなくても出る。
芯は piecesAt の最初の 3 行にある。回転は u に比例してためなく進み、すれ違いは山なり(1 - Math.abs(1 - u))で行って帰る。同じ u を読んでいるから、離れ切った瞬間 — u がちょうど 1 — に回転は必ず道のり半分まで来ている。2 つの動きを合わせる作業は、そもそも存在しない。
半円の描き方はひとつ覚えれば足りる。弧の両端は、中心から「ふくらむ向き」と直角に、両側へ半径ぶん進んだ 2 点 — この 2 点を結ぶ平らな縁が切り口だ。2 点を、ふくらむ向きの側へ張り出す半円弧でつなぐ。SVG なら円弧コマンドを 1 本入れた path で書け、回り方は弧がふくらむ向きの側へ出るほうを選ぶ。
ふくらむ向きには piecesAt が毎フレーム返す値をそのまま使う — グループ回転はそこに織り込み済みだ。半円 B は半円 A の点対称(中心をはさんで正反対)だから、piecesAt の最後の行のように座標を中心まわりに裏返し、ふくらむ向きに半回転を足すだけで出てくる。
押し込みの行が地味に効く。すれ違いの最中、2 つの半円は seamInset のぶんだけ相手側へめり込んでいて、S 字のくびれに髪の毛ほどの隙間が開かない。Math.min で頭打ちにしてあるから、止まっているときはゼロ — 円の形は歪まない。
requestAnimationFrame で、経過秒に毎秒のコマ数を掛けて frame を出し、loopFrames で余りを取ればループになる。ここで効くのが単色だ。ループの終わりに 2 つの半円は、切り口が縦から横へ直角ぶん回った姿勢で静止するが、1 色で塗った円は向きを持たない — 最初のフレームと見分けがつかず、継ぎ目が消える。
逆に言うと、2 色に塗り分けるとループの継ぎ目で色の置き場所ががくっと飛んで破綻する — 単色は飾りではなく構造の条件だ。図形は円でなくてもいい。研究リポの汎用デモでは、正方形を水平の切り口で割って半回転させても、同じ piecesAt がそのまま動いた。回りきった姿勢を最初と見分けられない図形なら、何にでも掛かる。