Canvas で描く動的な空 — 時刻×天気連動の背景アニメーション
俺のブログの背景に、現在時刻と実際の天気に連動して変化する空を HTML Canvas で描いている話。
夜は星が瞬いて月が浮かび、朝になると空がオレンジに染まって太陽が昇る。雨の日は雨粒が降り、雷雨なら稲妻が走る。全部 canvas 2D API の手描き。
いずれはシェーダーとかでも書いてみたいぜ。
どんな機能?
- 時刻に応じて空のグラデーションが変わる(夜 → 朝焼け → 昼 → 夕焼け → 夜)
- フェーズの切り替わりは滑らかにブレンドされる
- OpenWeatherMap API から実際の天気を取得して、雨・雪・雲・雷をリアルタイム表示
- 位置情報は IP ベース(許可ダイアログなし)で自動取得
- 天気の手動オーバーライド UI(yaogoromo.exe)でいつでも天気を変えられる
prefers-reduced-motion対応、モバイルではパーティクル数を半減- タブ非表示時はアニメーション一時停止
アーキテクチャ
layout.tsx
├── WeatherOverrideProvider (天気の手動オーバーライド)
│ └── SkyBackgroundWrapper (next/dynamic, ssr: false)
│ └── SkyBackground
│ ├── useSkyPhase() — 時刻 → フェーズ + 進行度
│ ├── useWeatherData() — IP位置 → 天気API
│ ├── useWeatherOverride() — 手動オーバーライド
│ └── SkyCanvas — canvas に全部描く
├── PostsCacheProvider
└── {children}SkyBackground が hooks とコンテキストを束ねて、計算済みの props を SkyCanvas に渡すだけ。SkyCanvas は純粋な描画マシン。
各レイヤーの解説
1. 天気 API プロキシ
// app/api/weather/route.ts
// OpenWeatherMap の condition ID を自前の型にマッピング
function mapCondition(id: number): WeatherCondition {
if (id >= 200 && id < 300) return "thunderstorm";
if (id >= 300 && id < 400) return "drizzle";
if (id >= 500 && id < 600) return "rain";
if (id >= 600 && id < 700) return "snow";
if (id >= 801) return "clouds";
return "clear";
}Next.js の Route Handler でサーバー側から叩く。API キーをクライアントに露出させない(それはそう)
レスポンスは { condition, conditionId, sunrise, sunset, temp } で、Cache-Control: max-age=600 の 10 分キャッシュ付き。
一応API叩けなかった時は晴れ。大抵の場合は晴れてた方が嬉しいからね。
2. 型定義
// types/weather.ts
export type SkyPhase = "sunrise" | "day" | "sunset" | "night";
export type WeatherCondition =
| "clear" | "clouds" | "rain"
| "snow" | "drizzle" | "thunderstorm";
export type WeatherData = {
condition: WeatherCondition;
conditionId: number;
sunrise: number; // Unix timestamp
sunset: number;
temp: number;
};空のフェーズと天気の種類を型で縛ることで、描画関数のパターンマッチが安全になる。
3. 時刻フック — useSkyPhase
// app/hooks/useSkyPhase.ts
function calcPhase(now, sunriseUnix?, sunsetUnix?): { phase, progress } {
// 天気APIの日の出/日の入りがあればそれを使う、なければ 6:00/18:00
// 各フェーズの時間帯:
// sunrise: 日の出30分前 〜 日の出60分後
// day: 朝焼け終了 〜 夕焼け開始
// sunset: 日の入り60分前 〜 日の入り30分後
// night: それ以外
// progress は各フェーズ内の進行度 (0〜1)
}60 秒間隔で setInterval 更新。空の変化はゆっくりなのでこれで十分。
progress がキモで、例えば朝焼けの progress: 0.1 なら「まだ夜っぽい空にオレンジが混ざり始めた」、0.9 なら「ほぼ昼の空」になる。
この機能がかなり気に入っていて、夕方とかの質感が結構いい感じ。美味しそうじゃない?

4. 天気データフック — useWeatherData
// app/hooks/useWeatherData.ts
// 1. ipapi.co/json/ で IP から緯度経度を取得(キー不要、許可ダイアログなし)
// 2. /api/weather?lat=...&lon=... で天気を取得
// 3. sessionStorage に 10 分キャッシュ
// 4. 5 秒でタイムアウト → 失敗したら null(時刻だけの空にフォールバック)Geolocation API(ブラウザの位置情報許可)は使わない。許可ダイアログみんな嫌いっしょ?
5. 天気オーバーライド — WeatherOverride Context
// app/contexts/WeatherOverride.tsx
type WeatherOverrideContextType = {
override: WeatherCondition | null;
setOverride: (condition: WeatherCondition | null) => void;
};シンプルな Context + useState。null ならAPI の天気をそのまま使い、値があればそっちを優先。
もっぱら天気書き換え機能のためのやつ。
UI は yaogoromo.exe ウィンドウに 5 つの絵文字ボタン(☀️ ☁️ ☔️ ❄️ ⚡️)を並べてトグル式で切り替える。
6. SkyBackground — オーケストレーション層
// app/components/SkyBackground/SkyBackground.tsx
export default function SkyBackground() {
const { weatherData, isLoading } = useWeatherData();
const { override } = useWeatherOverride();
const { phase, progress } = useSkyPhase(weatherData?.sunrise, weatherData?.sunset);
// オーバーライド優先、なければ API、それもなければ clear
const weatherCondition =
override ?? (!isLoading && weatherData ? weatherData.condition : "clear");
return (
<div className="fixed inset-0 -z-10 pointer-events-none" aria-hidden="true">
<SkyCanvas phase={phase} phaseProgress={progress} weatherCondition={weatherCondition} />
</div>
);
}hooks を束ねて「今の空はどんな状態か」を1つに解決して SkyCanvas に渡すだけの薄い層。
pointer-events-none でコンテンツへのクリックを阻害しない。-z-10 で背景に沈む。
layout.tsx からは next/dynamic + ssr: false でラップされた SkyBackgroundWrapper 経由で読み込む。canvas は SSR で意味ないので初回 HTML を軽くするため。
7. SkyCanvas — 描画の核心
ここが本体。全部 canvas 2D API で手描き。
ちなみにめっちゃ肥大化している。やばい。かなりデブ。
描画レイヤーの重ね順
1. 空グラデーション — フェーズに応じた3色の縦グラデ + 天気ティント
2. 星 — sin波で明滅する点。夜と朝焼け序盤・夕焼け終盤で見える
3. 太陽 — フェーズごとにアーチを描いて移動。glow 付き
4. 月 — 三日月マスク付き。夜のみ
5. 雲 — 楕円の重ね合わせ。横にゆっくりドリフト
6. 雨 — 斜めの線。雷雨時は速度1.3倍+風ジッター増加
7. 雪 — sin波で横に揺れる小さい円
8. 雷 — ランダム間隔で画面フラッシュ+ジグザグ稲妻ライン空グラデーション
const SKY_COLORS: Record<SkyPhase, [string, string, string]> = {
night: ["#0d1b2a", "#1a2240", "#232946"],
sunrise: ["#232946", "#8a6b7a", "#eebbc3"],
day: ["#4a90d9", "#87CEEB", "#b8c1ec"],
sunset: ["#232946", "#a0637a", "#f2c57c"],
};サイトのカラーパレット(#232946, #b8c1ec, #eebbc3, #f2c57c)に合わせた色設計。夜の空がサイトの背景色 #232946 と一致するので、空が読み込まれる前でも違和感がない。
フェーズの切り替わりは progress < 0.2 の区間で前フェーズの色とブレンドする:
const blendT = progress < 0.2 ? progress / 0.2 : 1;
let color = lerpColor(prevColors[i], currentColors[i], blendT);天気が雨や雪のときは空にどんよりティントを掛ける:
const WEATHER_TINT = {
drizzle: { tint: "#8a8a9a", amount: 0.25 },
rain: { tint: "#6b6b7d", amount: 1.2 },
thunderstorm: { tint: "#4a4a5c", amount: 0.55 },
snow: { tint: "#a0a0b0", amount: 1.1 },
};これまだちゃんとうまくいっていなくて、一部の天候にしか使用していない。
画面が想定よりかなり暗くなったり明るくなったりする。難しい。
星の瞬き
const twinkle = 0.3 + 0.7 * Math.sin(time * 0.002 + star.twinkleOffset);
ctx.fillStyle = `rgba(255, 255, 255, ${twinkle * opacity})`;各星に twinkleOffset(ランダム位相)を持たせて、sin 波でアルファを揺らす。全部同じタイミングで点滅すると不自然なので、位相をズラすとランダムっぽくていい感じだった。
星の表示は夜に完全表示、朝焼け序盤で消え始め、夕焼け終盤で出始める:
if (phase === "night") opacity = 1;
else if (phase === "sunrise") opacity = Math.max(0, 1 - progress * 3);
else if (phase === "sunset") opacity = Math.max(0, (progress - 0.6) * 2.5);太陽の軌道
if (phase === "day") {
// sin 波でアーチを描く
const angle = Math.PI * (0.1 + progress * 0.8);
x = w * (0.2 + progress * 0.6); // 左20% → 右80%
y = h * (0.4 - Math.sin(angle) * 0.25); // sin カーブで上に膨らむ
}朝焼けで右側から昇り、昼にアーチを描いて横断、夕焼けで左に沈む。shadowBlur: 60 で太陽のグロー感を出してる。
月(三日月マスク)
// 満月を描く
ctx.arc(x, y, 25, 0, Math.PI * 2);
ctx.fillStyle = "#b8c1ec";
ctx.fill();
// destination-out で右上にずらした円をくり抜く → 三日月
ctx.globalCompositeOperation = "destination-out";
ctx.arc(x + 10, y - 5, 20, 0, Math.PI * 2);
ctx.fill();destination-out で重なった部分を透明にするテクニック。円を2つ描くだけで三日月が作れる。
いやでも月はもっと凝った方がいいかもしれん。今NASAのアルテミスでアツいし。
雲の生成
実は雲って楕円の組み合わせなんですよ
雲は「楕円の重ね合わせ」で作るプロシージャル生成:
function generateCloudBlobs(width: number): CloudBlob[] {
// 1. 芯になる大きい楕円 (3〜5個)
// 2. 上側にモコモコの楕円 (30〜80個)
// 3. 下側にちょっとはみ出す楕円 (10〜30個)
}画像を使わずに楕円パーツの座標だけで雲っぽい形を作る。毎フレーム cloud.x += cloud.speed でドリフトさせて、画面外に出たら反対側から再入場。
おもんないから再生成してもいいかもしれない?
雷エフェクト
2〜7秒のランダム間隔で発火する2段構え:
type LightningState = {
active: boolean;
frameCount: number; // 4フレームだけ光る
nextStrikeIn: number; // 次の発火まで
bolts: Bolt[]; // ジグザグの頂点配列
flashOpacity: number; // 画面フラッシュの透明度
};画面フラッシュ: canvas 全体を rgba(255,255,255, 0.12〜0.20) で塗って 0.7 倍ずつ急速フェードアウト。
稲妻ライン: 画面上部からジグザグの折れ線を生成。各ステップで x を -30〜+30 ランダム、y を +20〜+40 進める。shadowBlur: 20 + #b8c1ec のグローでぼんやり光らせる。30% の確率で途中の頂点からサブボルト(分岐)が生える。
雷雨時は雨も強化: 速度 1.3 倍、風のジッター増加、線を太く。
8. パフォーマンス対策
// モバイルではパーティクル数を半減
const isMobile = window.innerWidth < 768;
starsRef.current = createStars(isMobile ? 60 : 150, w, h);
rainRef.current = createRain(isMobile ? 80 : 200, w, h);
snowRef.current = createSnow(isMobile ? 60 : 150, w, h);
cloudsRef.current = createClouds(isMobile ? 3 : 7, w, h);
// パーティクル配列は useRef で1回だけ確保
if (!initedRef.current) { /* 初期化 */ initedRef.current = true; }
// prefers-reduced-motion ならグラデーションだけ描いてループしない
if (reducedMotion) {
drawSkyGradient(...);
return;
}
// タブ非表示時は rAF を止める
document.addEventListener("visibilitychange", handleVisibility);
// devicePixelRatio 対応
canvas.width = window.innerWidth * dpr;
ctx.scale(dpr, dpr);9. SSR 回避
// app/components/SkyBackground/SkyBackgroundWrapper.tsx
const SkyBackground = dynamic(() => import("@/app/components/SkyBackground"), {
ssr: false,
});canvas はサーバーで意味ないので next/dynamic + ssr: false でクライアントのみロード。
bg-elements-background が body に付いてるので、読み込み完了まではサイトの通常背景色が表示される。
ファイル構成
| ファイル | 役割 |
|---|---|
types/weather.ts | SkyPhase, WeatherCondition, WeatherData の型定義 |
app/api/weather/route.ts | OpenWeatherMap プロキシ API |
app/hooks/useSkyPhase.ts | 時刻 → フェーズ + 進行度の算出 |
app/hooks/useWeatherData.ts | IP 位置推定 + 天気取得 + キャッシュ |
app/contexts/WeatherOverride.tsx | 天気の手動オーバーライド Context |
app/components/SkyBackground/SkyCanvas.tsx | canvas 描画の本体 |
app/components/SkyBackground/SkyBackground.tsx | hooks を束ねるオーケストレーション |
app/components/SkyBackground/SkyBackgroundWrapper.tsx | dynamic import ラッパー |
app/components/SkyBackground/index.tsx | barrel export |
app/components/SkyBackground/SkyCanvas.stories.tsx | Storybook (13+ バリエーション) |
app/_sections/WeatherControl/WeatherControl.tsx | 天気切り替え UI (yaogoromo.exe) |
設計で意識したこと
関心の分離
データ取得(hooks)→ 状態解決(SkyBackground)→ 描画(SkyCanvas)を明確に分けた。SkyCanvas は phase / phaseProgress / weatherCondition の 3 つの props だけで完全に動く純粋な描画コンポーネント。Storybook で任意の組み合わせをしたいがためにこの形になってると言っても過言ではない。
IP ベースの位置推定
Geolocation API は許可ダイアログが出る。このブログを初めて訪れた人に「位置情報を共有しますか?」は体験を壊す。ipapi.co なら無料・キー不要・ダイアログなしでざっくりの緯度経度が取れるので、天気を出すには十分。取得失敗しても時刻ベースの空にフォールバックするので何も壊れない。
キャッシュの二重構造
天気データは 2 箇所でキャッシュしてる:
- クライアント: sessionStorage に 10 分(SPA 内遷移でリフェッチしない)
- サーバー:
Cache-Control: max-age=600(API Route レベル)
天気がそんなに頻繁に変わるわけないので、10 分キャッシュで API 呼び出しを最小限に。OpenWeatherMap 無料枠の 1000 回/日もいまんとこ余裕。
アクセシビリティ
prefers-reduced-motion: reduce が有効なら、グラデーション 1 回描くだけでアニメーションループに入らない。視覚的な装飾が苦手な人にもちゃんと配慮。aria-hidden="true" で背景全体をスクリーンリーダーから隠してる。
プロシージャル生成
雲のテクスチャ画像を用意する代わりに、楕円の重ね合わせで動的に生成してる。アセット管理不要、ランダム性で毎回違う雲ができる、サイズも自由自在。ただし blob 数が多い(雲1つにつき 50〜100 個の楕円)ので、モバイルでは雲の数自体を絞ってる。
書いてる最中に思いついたのでこの変更入れた: https://github.com/ryu1-1uyr/ryu-reu.me/pull/119
雲をオフスクリーンCanvasに一度キャッシュして、描画時はdrawImage一発で表示する。そうすれば毎回計算せんでもよいんじゃないかと言うやつ。