増減
増減の作り方
ドットは 6 個から 19 個へ増え、また 6 個へ戻って見える。この数の変化の正体は、格子全体がいっせいに開いて閉じる動きに、グループごとの時間差をつけた出現を重ねたものだ。現れるタイミングをずらすと、数のうねりはひとりでに生まれる。短い関数に書き起こして読み解く。
- Published
- 2026年6月10日
- Topics
- Lattice · Emergence · Easing · SVG
格子と呼吸を用意する
- 常に見えている 6 個 — 六角形の頂点に立つ、格子の土台
- 中心 1 個・内側の輪 6 個・外縁 6 個 — 出現を待つ 13 個
- 外縁は頂点と頂点のあいだの角度に立つ
動力は、格子がどれだけ開いているかを表す数値がひとつ。閉じた状態の 0 から開ききった 1 まで上がり、しばらく保って、また 0 へ戻る。何フレームで開いて、どこで保って、いつ閉じるか — その区間割りは、下のコードの breathKeys にキーを打つ場所として自分で決める。これを呼吸と呼ぶ。
呼吸が直接動かすのは、六角形の半径(中心から頂点までの距離)だ。閉じたときの radiusRest と開ききった radiusPeak のあいだを、呼吸の値がそのまま埋める。格子全体の回転も同じ区間で動くが、呼吸の値は使わず、自分のキーとカーブを持つ — コードの spinKeys がそれだ。コードに起こすための部品は、キーの間をカーブで埋める関数がひとつあれば足りる。
// 打ったキーフレームのあいだをカーブで埋めて補間する — この記事の動きはぜんぶこの形で書ける
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 つの数で指定する。コードの cubicBezier は import cubicBezier from "bezier-easing" で手に入り、breathOpenHandles のような名前に置いた 4 つの数をそのまま受け取る。キーの範囲の外では keyAt が端の値を返すから、動きはじめる前と終わったあとの静止は何も書かなくても出る。
// 動かしているのは呼吸 1 本。半径も回転も出現も、打ったキーフレームのあいだを keyAt で補間するだけ。
// 小文字ではじまる裸の名前(holdStart や radiusRest)は、自分の画面に合わせて決める数 —
// この記事が持ち帰ってほしいのは値ではなく、この「形」だ。
const linear = (t) => t; // 値が同じ区間用 — どんなカーブでも動かない
// カーブは全部 CSS の cubic-bezier() と同じ規格 — 置く数は好みで決める
const breathOpen = cubicBezier(...breathOpenHandles); // 呼吸の開き
const breathClose = cubicBezier(...breathCloseHandles); // 呼吸の閉じ — 開きと別の顔
const spinRise = cubicBezier(...spinRiseHandles); // 回転の行き
const spinFall = cubicBezier(...spinFallHandles); // 回転の帰り
const railOut = cubicBezier(...railOutHandles); // 外縁レールの行き
const railBack = cubicBezier(...railBackHandles); // 外縁レールの帰り
// 呼吸 — 開きと閉じで別カーブ、値が同じ区間は保ちになる
const breathKeys = [
{ frame: openStart, value: 0, curve: breathOpen }, // ここから開きはじめる
{ frame: holdStart, value: 1, curve: linear }, // 開き切る — ここから保ち
{ frame: holdEnd, value: 1, curve: breathClose }, // 保ちおわり — 閉じへ
{ frame: closeEnd, value: 0 }, // 閉じ切って静止
];
// 回転 — フレーム区間は呼吸と同じ、カーブだけ自分の 2 本を持つ
const spinKeys = [
{ frame: openStart, value: 0, curve: spinRise },
{ frame: holdStart, value: 1 / 2, curve: linear }, // 保ちまでに半分回る
{ frame: holdEnd, value: 1 / 2, curve: spinFall },
{ frame: closeEnd, value: 1 }, // 残り半分は帰り道で
];
// 外縁専用レール — 半径の往復を自分のフレーム区間で持つ
const railKeys = [
{ frame: railStart, value: railRest, curve: railOut },
{ frame: railHoldStart, value: railPeak, curve: linear },
{ frame: railHoldEnd, value: railPeak, curve: railBack },
{ frame: railEnd, value: railRest },
];
const center = { x: stageSize / 2, y: stageSize / 2 }; // 一辺 stageSize のキャンバスの中心
const rad = (deg) => (deg * Math.PI) / 180; // 度 → ラジアン
// フレーム番号を渡すと 19 個ぶんの位置と大きさを返す関数
function latticeAt(frame) {
const breath = keyAt(breathKeys, frame);
const radius = radiusRest + (radiusPeak - radiusRest) * breath; // 半径だけが呼吸に乗る
const spin = spinPerLoop * keyAt(spinKeys, frame);
return dots.map((dot) => ({
angle: dot.angle + spin, // 全ドットが同じ回転に乗る
dist: dot.role === "arm" ? radius // 土台 — 呼吸の半径
: dot.role === "edge" ? keyAt(railKeys, frame) // 外縁 — 専用レール
: dot.dist, // 中心と内側の輪 — 行の距離のまま
scale: dot.grow ? keyAt(dot.grow, frame) : 1, // 出現も退場も scale のキー
}));
}
// 角度と距離から画面の座標へ
function dotXY(dot) {
return {
x: center.x + dot.dist * Math.cos(rad(dot.angle)),
y: center.y - dot.dist * Math.sin(rad(dot.angle)), // 画面の y 軸は下向きなので −sin
};
}dots は 19 行の設計図だ。19 個のドットは最初のフレームからすべて存在していて、新しく作られるドットは無い — 各ドットは決められたフレームで scale を立ち上げるだけで、その立ち上がりが中心 → 外縁 → 内側の輪の順に数フレームずつずれている。出現の時間差が、数が増えて見える正体だ。1 行が持つのは役割・角度・距離・出現と退場に打つキーフレーム grow で、土台の 6 個だけは grow を持たず常に立っている。
行に書いた距離がそのまま使われるのは中心と内側の輪だけ — 土台の 6 個は呼吸の半径が、外縁の 6 個は専用レール railKeys が、毎フレーム距離を決める。角度は六角形の頂点の 6 方向が基本で、土台と内側の輪は同じ方向に立ち、外縁だけが頂点と頂点のあいだに立つ。
置き方はコードの dotXY のとおり — 一辺 stageSize の正方形をキャンバスにして、その中心 center から角度の方向へ距離ぶん進んだ点に円を描くだけだ。画面の y 軸は下向きなので、Math.sin の符号だけマイナスになる。
requestAnimationFrame で、経過秒に毎秒のコマ数を掛けてフレーム番号を出し、ひとループの長さで余りを取って latticeAt に渡せば、このデモと同じ作りのループになる。継ぎ目が見えないのは、ひとループの回転量 spinPerLoop のおかげだ — 六角格子は隣の頂点まで回すと元の姿に戻るので、spinPerLoop をその倍数に選べば、最終フレームが先頭にぴったり重なる。
外縁の 6 個だけは、もうひとつ独立した動きを持つ — 半径の往復を、呼吸とは別の自分のフレーム区間で滑る。その行き先と区間は、上のコードの railKeys に自分でキーを打って決める。
設計図 dots を書き換えれば、格子は別の絵になる。並びを四角に変えても、出現フレームの時間差さえ残せば「数が増えて見える」理屈はそのまま通用するし、呼吸で動かす相手を半径から不透明度に替えても、同じ仕組みのまま別の表情になる。