Apple Healthの全データを「1枚のHTML」にまとめる健康ダッシュボードの作り方


前の記事で、Apple Healthのデータを16枚のグラフに詰め込んだダッシュボードを使って自分の体を分析した。その「ダッシュボードそのもの」をどう作ったかをまとめておく。

実は以前もApple Watchのデータをブログに載せる技術記事を書いていて、そのときはChart.js + Astroだった。今回はあえて まったく別のアプローチ にしている。理由も含めて書く。

完成品

先に現物を貼っておく。これが毎月自動で作り直される、自分用の健康ダッシュボードだ。

👉 別タブで全画面表示する

体重の年次比較、体組成の分解、血圧、ランニングフォーム、心拍効率、活動量カレンダー(GitHubの草風)、自律神経の象限マップ……と、16セクションが縦に並んでいる。

方針:1つのHTMLファイルに全部入れる

今回いちばんこだわったのは 「単一HTMLファイルで完結させる」 こと。

  • グラフはすべて画像(PNG)として base64でHTMLに直接埋め込む
  • 外部CSS・外部JS・画像ファイル・CDN——何も参照しない
  • 結果、health_dashboard.html という1.7MBのファイル1個になる。ダブルクリックすれば、ネットがなくても開く

ブログ(Chart.js)と作り分けたのは、用途が違うからだ。

ブログのグラフ手元のダッシュボード
見る人他人自分だけ
求めるもの軽量・インタラクティブ1ファイル・オフライン・作るのが楽
技術Chart.js + AstroPython(matplotlib) → 単一HTML

「自分が毎月ざっと眺めるだけ」なら、インタラクティブである必要はない。それより、1ファイルで完結していてオフラインで開ける方が圧倒的に楽だし、matplotlibの方がレーダーチャートやヒートマップみたいな凝ったグラフの自由度が高い。

全体の流れ

iPhone「ヘルスケア」
    ↓ エクスポート(XML)→ パースしてCSV化
ローカルのCSV群(体重・心拍・ランフォーム…)
    ↓ Python(matplotlib)でグラフ描画
各グラフを PNG → base64 文字列に変換
    ↓ <img src="data:image/png;base64,..."> として
1枚の health_dashboard.html に全部埋め込む

コア:グラフをbase64でHTMLに埋め込む

仕組みの中心はこれだけ。matplotlibの図をメモリ上でPNGにして、base64エンコードして<img>タグのdata URIにする。

import io, base64

def fig_to_b64(fig):
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=110, bbox_inches="tight")
    return base64.b64encode(buf.getvalue()).decode("ascii")

# HTML側
section = f'<img src="data:image/png;base64,{fig_to_b64(fig)}">'

これを16グラフ分くり返して、1つのHTMLテンプレートに流し込むだけ。画像ファイルが1個も生成されないので、HTMLが文字通り「全部入り」になる。

HTMLの組み立て

埋め込み先のHTMLは、str.format() で穴埋めするだけのベタなテンプレートにした。各グラフを <section> でくるむ小さな関数と、ページ全体のガワを用意しておく。

def section(title, b64):
    if not b64:  # データが無いグラフは「データなし」と表示
        return f'<section><h2>{title}</h2><p>データなし</p></section>'
    return f'<section><h2>{title}</h2><img src="data:image/png;base64,{b64}"></section>'

PAGE = """<!DOCTYPE html><html lang="ja"><head><meta charset="utf-8">
<title>ヘルスダッシュボード</title><style> ... </style></head>
<body>
  <header><h1>🏃 ヘルスダッシュボード</h1></header>
  <main>{cards}{sections}</main>
</body></html>"""

main() 側は、各グラフ関数を呼んでbase64を受け取り、section() で包んで連結し、最後に1ファイルとして書き出す。

def main():
    weight_b64 = chart_weight()           # 各グラフはbase64文字列を返す
    bp_b64     = chart_blood_pressure()
    # ... 16個ぶん

    sections = "\n".join([
        section("体重・体脂肪率", weight_b64),
        section("血圧", bp_b64),
        # ...
    ])
    html = PAGE.format(cards=cards_html, sections=sections)
    OUT_HTML.write_text(html, encoding="utf-8")

グラフを増やしたいときは、base64を返す関数を1つ書いて section(...) を1行足すだけ。構造がフラットなので拡張が雑にできるのがいい。

データを読む:地味だが大事な前処理

グラフより手前で意外と効くのが、CSVを読む部分。Appleのエクスポートは1行目に sep=, という余計な行が入っていることがあって、そのまま読むとヘッダーがずれる。先頭を覗いて出し分ける。

def load(pattern):
    frames = []
    for f in sorted(HEALTH_DIR.glob(pattern)):
        first = f.open(encoding="utf-8").readline()
        skip = 1 if first.startswith("sep=") else 0   # ← この1行が地味に効く
        df = pd.read_csv(f, skiprows=skip,
                         usecols=["startDate", "value"], dtype={"value": str})
        df["dt"] = pd.to_datetime(df["startDate"].str[:19],
                                  format="%Y-%m-%d %H:%M:%S", errors="coerce")
        df["value"] = pd.to_numeric(df["value"], errors="coerce")
        frames.append(df.dropna(subset=["dt"]))
    return pd.concat(frames, ignore_index=True).sort_values("dt")

ハマった地雷たち

ここからが本題。「動いたように見えて壊れている」系の罠が多かった。

1. 日本語が全部「□□□」になる

matplotlibはデフォルトで日本語フォントを持っていないので、何もしないとタイトルもラベルも豆腐(□)になる。しかも エラーは出ない。グラフはできるので、画像を目で見るまで気づかない。

import matplotlib
matplotlib.use("Agg")           # ← これも重要(後述)
import matplotlib.pyplot as plt
plt.rcParams["font.family"] = "Yu Gothic"   # Windows標準で入っている
plt.rcParams["axes.unicode_minus"] = False  # マイナス記号の文字化け対策

2. Agg バックエンドを pyplot より先に指定する

matplotlib.use("Agg")import matplotlib.pyplot より に書く。これを忘れると、画面のない環境(後述のタスクスケジューラの自動実行)で落ちたり固まったりする。手動で動かしているときは再現しないので、いちばんハマりやすい。

3. 体重と血圧が「2つのデータソース」にまたがっている

体重・血圧は、昔スプレッドシートで手入力していた記録(2023年〜)と、最近のApple Health(疎)の2系統がある。単純に結合すると重複したり、同じ日に矛盾した点が2つ乗ったりする。

「古い記録をベースに、その最終日より後だけ新しいソースを継ぎ足す」というルールで縫い合わせた。

def stitched_daily(metric):
    legacy = load(f"...{metric}_*_legacy.csv")        # 手入力の旧記録
    apple  = load(f"...{metric}_*_apple_export.csv")  # Apple Health
    parts = [legacy]
    cutoff = legacy["dt"].max()                       # 旧記録の最終日
    parts.append(apple[apple["dt"] > cutoff])         # その後だけ継ぎ足す
    comb = pd.concat(parts)
    comb["day"] = comb["dt"].dt.normalize()
    return comb.groupby("day")["value"].mean()        # 同じ日は平均して1点に

cutoff で線引きしているのがミソで、重複期間を旧記録側に寄せることで二重計上を防いでいる。

4. 疎なデータを折れ線にしない

睡眠データは、Apple Watchを着けて寝た夜しか取れていない(全期間で99夜しかない)。これを月平均の折れ線にすると、データのない期間を長い直線で補間してしまい、いかにも毎日測ったかのような嘘グラフになる。

なので睡眠は折れ線をやめて、夜ごとの実測点の散布図+移動平均にした。点がない期間は点がないまま見せる。データの「疎さ」自体を正直に出すのが大事だった。

移動平均の線も、間が大きく空いたところでは繋がないようにした。点と点の間隔が一定日数を超えたら、あいだに NaN を差し込んで線を分断する。matplotlibは NaN のところで線を切ってくれる。

roll = nightly.rolling("30D", min_periods=4).mean().dropna()

xs, ys, prev = [], [], None
for t, v in roll.items():
    if prev is not None and (t - prev).days > 35:  # 35日以上空いたら
        xs.append(prev); ys.append(float("nan"))   # NaNで線を切る
    xs.append(t); ys.append(v)
    prev = t

ax.plot(xs, ys)   # 空白期間をまたいで直線を引かない

地味だけど、これをやるかどうかで「正直なグラフ」と「それっぽいだけのグラフ」が分かれる。

自動化:月初に勝手に作り直す

最後に、Windowsのタスクスケジューラで 毎月1日にスクリプトを自動実行 している。これで、月が変わるたびに最新データで全グラフが描き直される。

データ自体も、iOSのショートカット自動化で毎朝iCloud経由でPCに同期 → 取り込み、という別の仕組みで日々更新している。つまり「データの更新」も「ダッシュボードの再生成」も放っておけば回る。手作業はゼロだ。

まとめ

データ収集  : Apple Watch + ヘルスケアアプリ
データ加工  : Python(CSVパース)
グラフ描画  : matplotlib
配布形態    : base64埋め込みの単一HTML
自動化      : タスクスケジューラ(月次)+ iOSショートカット(日次)

Chart.jsの記事のときに「データ取得から記事化まで会話の流れでできてしまう」と書いたけど、今回の「自分専用の道具」は、もっと割り切って 1ファイルで完結・オフラインで開ける ことを優先した。見せる道具と、自分が使う道具は、作りが違っていい。

道具としては気に入っているので、しばらくこれで自分の体を眺めてみる。