反比例
反比例の作り方
接し合う 2 つの円が、一方はふくらみ一方はしぼんで、大きさを交換し続ける。名前は「反比例」だが、ループを通して一定に保たれているのは半径の和のほうだ。キーフレームは左上の円の半径に打った 3 個で、右下の半径は和からの引き算で出し、2 つの中心は固定の接点から自分の半径ぶん退いた位置に計算で決まる。接点がループのあいだ固定されたままの理由ごと、短い関数に書き起こして読み解く。
- Published
- 2026年6月10日
- Topics
- Conservation · Complement · Easing · SVG
半径 1 本と引き算 1 行を書く
- 半径キーは 3 個 — 両端が
restRadius、折り返しがmidRadius、どのフレームに打つかはradiusKeysで自分で決める - イージングカーブは区間ごとに 1 本、計 2 本 —
shrinkHandlesとgrowHandlesに好みの数を置く - 右下の半径はキーゼロ —
radiusSumから左上を引くだけ - 2 つの中心もキーゼロ — 固定の接点から自分の半径ぶん退く
名前は「反比例」だが、一定なのは 2 つの半径を掛けた積ではなく、足した和 radiusSum だ — だから右下の半径は和から左上を引くだけで決まる。中心の置き方がこの作りの芯だ。対角線上に接点を 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); // 差をカーブの形で進める
}keyAt に渡すキーフレームは、それぞれ「次の区間を埋めるカーブ」を持つ。カーブは CSS の cubic-bezier() と同じ 4 つの数で、shrinkHandles と growHandles に置く数は bezier-easing のような既製のライブラリ にそのまま渡せる。しぼみとふくらみは似ているが別の 1 本ずつ — 試しに片方を両方に使い回すと、実測とのずれは数倍に広がった。
// 打ってあるのは左上の半径 1 本。右下は引き算で、中心 2 つは接点からの計算で決まる。
// 小文字ではじまる裸の名前(restRadius や contact)は、自分の画面に合わせて決める数 —
// この記事が持ち帰ってほしいのは値ではなく、この「形」だ。
const shrink = cubicBezier(...shrinkHandles); // CSS の cubic-bezier() と同じ規格 — しぼみ区間のカーブ
const grow = cubicBezier(...growHandles); // ふくらみ区間のカーブ — 似ているが別の 1 本
// 半径に打つキーフレーム: 値 = 左上の半径。打ってあるのはここだけで、どのフレームに打つかは自分で決める
const radiusKeys = [
{ frame: 0, value: restRadius, curve: shrink }, // → しぼむ
{ frame: midFrame, value: midRadius, curve: grow }, // → ふくらむ — 折り返し
{ frame: loopFrames, value: restRadius }, // 戻り切って静止 — ループの継ぎ目
];
// フレーム番号を渡すと 2 円の中心と半径を返す関数
function circlesAt(frame) {
const r1 = keyAt(radiusKeys, frame); // 左上の半径
const r2 = radiusSum - r1; // 右下はキーゼロ — 引き算 1 行
const d = Math.SQRT1_2; // 対角線の向きの成分(= 1 / √2)
return [
{ r: r1, x: contact - r1 * d, y: contact - r1 * d }, // 接点から左上へ退く
{ r: r2, x: contact + r2 * d, y: contact + r2 * d }, // 接点から右下へ退く
];
}半径が変わると中心はひとりでに滑り、2 円はどのフレームでも接点でぴったり接する — 接触を合わせるキーはどこにもない。冒頭で接点が動かなかったのは、動かないように作られているからだ。動かすのは requestAnimationFrame で、経過秒に毎秒のコマ数を掛けて frame を出し、loopFrames で余りを取れば、このデモと同じ作りのループになる。
遊ぶならキーを増やす。半径のキーを 3 個から 5 個(restRadius→midRadius→restRadius→midRadius→restRadius)にすれば、同じ関数のまま、ひと回りのあいだに交換が 2 往復起きる。radiusSum を別の数に変えれば 2 円の総量ごと変わるが、引き算 1 行の作りは崩れない。