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 + Astro | Python(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ファイルで完結・オフラインで開ける ことを優先した。見せる道具と、自分が使う道具は、作りが違っていい。
道具としては気に入っているので、しばらくこれで自分の体を眺めてみる。