BioWave は、生年月日から身体 23 日・感情 28 日・知性 33 日の 3 つの正弦波を描くバイオリズム占いです。その日の調子、今後の「要注意日」、2 人の相性までを出します。計算は 3 行で終わる代わりに、日付の扱いを間違えると全部ずれるタイプのサービスでした。
#🎯 計算そのものは 1 行で終わる
バイオリズムの定義は単純です。生まれた日からの経過日数を周期で割って、正弦波に乗せるだけ。
export function biorhythmAt(birth: number, target: number): Values {
const days = dayDiff(birth, target)
const v = (period: number) => Math.sin((2 * Math.PI * days) / period)
return { body: v(23), emotion: v(28), intellect: v(33) }
}sin に渡す値が 2π のときちょうど 1 周期。23 日周期なら 2π × 経過日数 / 23 です。これで -1 〜 +1 の値が出て、+1 が絶好調、-1 が低調、0 が切り替わり点になります。
難しいのは計算ではなく、「経過日数」を正しく出すことでした。
#🕛 日付をすべて UTC の 0 時に正規化する
素朴に new Date(誕生日) と new Date() の差を取ると、こういう問題が出ます。
- ローカルタイムだと、日をまたぐ瞬間が実行環境ごとに違う
- 夏時間のある地域では 1 日が 23 時間や 25 時間になる
- 「今日」を時刻込みで持つと、朝と夜で経過日数が変わりうる
なので入力も現在日も、UTC の 0 時ちょうどに揃えてから引き算しています。
export function todayUTC(): number {
const n = new Date()
return Date.UTC(n.getFullYear(), n.getMonth(), n.getDate())
}
function dayDiff(a: number, b: number): number {
return Math.round((b - a) / DAY)
}Date.UTC() で「ローカルの今日の年月日」を UTC 0 時として作り直すのがポイントです。これで差は必ず 86,400,000 の整数倍になり、Math.round は保険として残しています。
入力の検証も、正規表現で形を見るだけでは足りません。2026-02-31 は形式としては正しいのに存在しない日付で、Date.UTC は 3 月 3 日に繰り上げてしまいます。
const t = Date.UTC(y, mo - 1, d)
const dt = new Date(t)
// 作り直した日付が入力と一致しなければ 存在しない日
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== mo - 1 || dt.getUTCDate() !== d) return null作ってから読み戻して一致するか確かめる、という書き方に落ち着きました。うるう年の 2/29 もこれで正しく通ります。
#⚠️ 「要注意日」はゼロ交差で拾う
バイオリズムで実際に使われるのは、高調でも低調でもなく 0 をまたぐ日です。ここが一番不安定とされます。
判定は符号の変化を見るだけです。
const v0 = biorhythmAt(birth, d0)
const v1 = biorhythmAt(birth, d0 + DAY)
if (v0.body === 0 || v0.body * v1.body < 0) cy.push("身体")掛けて負なら符号が変わった ── つまりその間に 0 を通った、という判定です。ちょうど 0 になる日は掛け算だと 0 になって negative にならないので、=== 0 を別に見ています。
表示側では Math.abs(v) < 0.15 を「要注意」の帯として扱っています。ぴったり 0 の日だけを指すより、前後を含めたほうが実用に合うためです。
export function stateOf(v: number): "高調" | "低調" | "要注意" {
if (Math.abs(v) < 0.15) return "要注意"
return v > 0 ? "高調" : "低調"
}#💞 相性は誕生日の差だけで決まる
2 人のバイオリズムがどれくらい揃っているかは、誕生日の差だけで決まります。日々変わりません。
const diff = dayDiff(birth1, birth2)
const c = (period: number) => Math.round(((1 + Math.cos((2 * Math.PI * diff) / period)) / 2) * 100)位相差が 0 なら cos(0) = 1 で 100%、半周期ずれていれば cos(π) = -1 で 0%。(1 + cos) / 2 で -1〜1 を 0〜1 に写しています。
ここは実装しながら気づいたことですが、相性が日によって変わらないのは仕様として正しいです。周期が同じである以上、2 つの波の位相差は永遠に一定だからです。「今日の相性」を出したくなりますが、それをやると定義から外れるのでやめました。
#😓 苦労したところ
波を SVG でどう見せるか。 3 本の正弦波を重ねると、色だけでは追えません。今日を示す縦線を引き、各波が今日どの高さにあるかを点で示して、数値(%)を併記する形にしました。前後 15 日を表示するのは、23 日周期のうち「山と谷がどちらも見える」最小の幅だからです。
当たる占いにしない。 バイオリズムは 20 世紀初頭に提唱されたもので、科学的な裏付けは確立していません。「今日は事故に注意」のような断定は書かず、体調管理のきっかけとして読める温度に統一しました。数字を出すサービスは、その数字がどこから来たかを説明できる範囲でしか断定しないのが線引きだと思っています。
#🔭 今後の拡張
- 第 2 世代の周期(直感 38 日 / 美的 43 日 / 意識 48 日)の追加
- 1 か月・1 年単位の俯瞰表示(現在は前後 15 日)
- 「今月の高調日ベスト 3」のような、予定を立てるための切り口
- 画像として保存・共有
#💡 このサービスから言えること
計算が 1 行のサービスほど、周辺の作り込みが全部になります。 Math.sin を呼ぶところは誰が書いても同じで、差が出るのは日付の正規化、存在しない日付の弾き方、ゼロ交差の拾い方、そして「何を断定しないか」でした。
とくに日付は、UTC の 0 時に揃える と 作ってから読み戻して検証する の 2 つを最初に決めておくと、後から出るずれの大半が防げます。
[ ./next_action ]
読んだら、 BioWave を実際に動かす。
この開発ログは BioWave をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。