循環
循環の作り方
∞ の道を、太い線の頭とドットが 1 周のほとんどをひと息に駆け、残りをゆっくり這う。二段変速に見えるこの動きで、自分で打つ速さのキーは鞭のひと振りぶんの 2 つ。這いの速さは「決めたフレーム数でちょうど 1 周して戻る」という約束から自動で決まる。線の正体は固定長の窓で、それが道の上を滑ることで頭と尾が動いて見える。
- Published
- 2026年6月11日
- Topics
- Path · Dash · Easing · SVG
道と窓とドットを用意する
- ∞ の閉じた道 — このデモはロブがまん丸の理想形、原典はペンツールのアンカー 4 つで描いた少し歪んだ ∞
- 道の全周を 1 として
drawnFractionぶんを描く太い線(stageSizepx 四方のキャンバスで幅strokeWidthpx) — 描かないgapFractionが切れ目になる - 頭に乗るドット(半径
dotRadiuspx) — 切れ目はドットの前後にまたがる - 頭の進み具合 u(0 が出発点・1 でちょうど 1 周) — キーは 2 つ、ループ内
whipStartFrameフレーム目にuAtWhipStartから鞭が始まりwhipDurFramesフレームで 1 周のwhipSpanぶんを駆ける
作るものは 2 つ。フレーム番号から u を返す関数と、u を画面に映す置き方だ。まず関数のほうから。
// 動かすのは「頭の進み具合」u 1 本。0 が道の出発点、1 でちょうど 1 周。
// 小文字ではじまる裸の名前(whipSpan や periodFrames)は、自分の画面に合わせて決める数 —
// この記事が持ち帰ってほしいのは値ではなく、この「形」だ。
const whip = cubicBezier(...whipHandles); // CSS の cubic-bezier() と同じ規格 — 鞭の顔は好みの 4 つの数で
const linear = (t) => t; // 這いはまっすぐ進むだけ
// 這いは余り — 自分では決められない
const crawlSpan = 1 - whipSpan; // 這いが受け持つ残りの割合
const crawlDurFrames = periodFrames - whipDurFrames; // 這いに残るフレーム数
// 画面に映すほうのつまみ: stageSize 四方のキャンバス・幅 strokeWidth の線・半径 dotRadius のドット。
// paintHeadLag は切れ目がドットの前後にまたがるよう目で合わせるずらし
const gapFraction = 1 - drawnFraction; // 窓が描く割合 drawnFraction の残りが切れ目
// u に打つキーフレーム: 鞭が whipSpan を食べ、残りは「1 周して戻る」ための直線
const uKeys = [
{ frame: 0, value: uAtWhipStart, curve: whip }, // 鞭の始まり = 出発位置
{ frame: whipDurFrames, value: uAtWhipStart + whipSpan, curve: linear }, // 鞭の終わり
{ frame: periodFrames, value: uAtWhipStart + 1 }, // 1 周して同じ場所 — ここは選べない
];
// 打ったキーフレームのあいだをカーブで埋めて補間する — この記事の動きはぜんぶこの形で書ける
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); // 差をカーブの形で進める
}
// フレーム番号 → 頭の進み具合。鞭の開始 whipStartFrame を 0 に合わせてから、打ってあるキーフレームのあいだを補間する
function uAt(frame) {
const local = ((frame - whipStartFrame) % periodFrames + periodFrames) % periodFrames;
return keyAt(uKeys, local) % 1; // 1 を超えたぶんは次の周回へ巻き戻す
}whip は CSS の cubic-bezier() とまったく同じ計算で、whipHandles に置く 4 つの数は bezier-easing のような既製のライブラリ にそのまま渡せる。出だしを溜めて中盤で一気に駆け、終わり際は速度を落として這いへ手渡す。
芯は最後のキーの値 uAtWhipStart + 1 だ。periodFrames フレームで 1 周して戻ると決めた瞬間、この区間は「残りの crawlSpan を、残りの crawlDurFrames フレームで」としか書けなくなる。這いの速さのつまみはどこにもない — 変えたければ鞭が食べる whipSpan を増減させる。余りが変われば、這いは勝手に変わる。
画面に映す変換は 2 つ。線は path に pathLength={1} を付けて道の長さを 1 とし、stroke-dasharray を drawnFraction gapFraction、stroke-dashoffset を drawnFraction + paintHeadLag - u にする。窓が u に引かれて滑り、切れ目は paintHeadLag のぶんドットへ寄る。
ドットの座標は getPointAtLength(u × 全長) で取れる。道のりから座標への変換はブラウザが持っている — 自分で書くのは u までだ。
回すのは requestAnimationFrame で、経過秒に毎秒のコマ数を掛けて frame を出し、periodFrames で余りを取ればフレーム番号になる。ループの継ぎ目は這いの終わり近くにあり、継ぎ目をまたぐ速さは這いの速さそのものだから、境目で動きは跳ねない。
道は ∞ でなくていい。u は「道のどこまで来たか」しか言っていないから、閉じた道なら円でもハートでも同じ uKeys が使える。窓の drawnFraction を短くすれば、描きかけの線が道を走る見た目になる。