APIなしで筋トレ食事提案ツールを作った話 — WordPressでD3.jsを動かすまでの実装記録
作ったもの
体重・トレーニング目的・アレルギー・食費予算を入力すると、文部科学省食品成分表(八訂)準拠の食事メニューとサプリメントデータを表示するWebツールです。
スペック:
- 食品データ: 157件(肉類32件、魚介類27件、乳製品・卵15件、大豆製品12件、炭水化物16件、野菜・果物17件、コンビニ食品16件、サプリ22品目)
- 実行環境: ブラウザのみ(APIなし、登録なし、インストールなし)
- 更新頻度: 毎月(食材価格データは総務省「小売物価統計調査」の月次公表に連動)
- 動作環境: WordPress(SWELLテーマ)上のHTMLブロック
→ https://gozen-ai.com/tools/muscle-meal-planner/
実装上のポイント
1. WordPressでの && 演算子問題
これが最初に嵌まりました。
WordPressはコンテンツ内のHTMLをフィルタリングする wpautop という処理があり、&& を && にHTMLエンティティ変換します。結果としてJavaScriptが構文エラーになりスクリプト全体が停止します。
// NG: WordPressが && に変換してJSクラッシュ
if (a && b) { doSomething(); }
// OK: 三項演算子で代替
var result = a ? (b ? doSomething() : null) : null;
// OK: ネストif
if (a) {
if (b) {
doSomething();
}
}
全コードの && を置換してから動作するようになりました。
2. D3.jsの読み込み問題(<script src> タグが無視される)
WordPress(SWELLテーマ)の投稿コンテンツ内で <script src="CDN"> を書いても読み込まれません。投稿コンテンツ内の外部スクリプトタグはテーマ側で無視されます。
解決策はD3ブートストラップパターンです。
function initAll() {
// D3を使った初期化処理
}
if (typeof d3 !== 'undefined') {
// 既にロード済み(例: 他のツールが先にロードした)
initAll();
} else {
var s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js';
s.onload = initAll;
document.head.appendChild(s);
}
typeof d3 !== 'undefined' のチェックも && を使わない書き方にするのを忘れずに。
3. WordPressのデプロイ構造(タイプAとタイプB)
WordPressへのデプロイには2つの構造があり、使い方を間違えるとスクリプトが壊れます。
タイプA(推奨): <!-- wp:html -->ラッパー
<!-- wp:html -->
<div class="aji-muscle-meal-wrap">
<style>/* スコープ付きCSS */</style>
<!-- HTML -->
<script>/* JavaScript(&&なし) */</script>
</div>
<!-- /wp:html -->
タイプB(非推奨): <!DOCTYPE html> フルHTML文書
wpautop フィルターが <script> タグ内に <p> タグを挿入してJSを破壊します。新規ツールは必ずタイプAで作ります。
4. CSSのグローバルセレクタ汚染
/* NG: SWELLテーマのスタイルを上書きしてサイト全体が崩れる */
* { box-sizing: border-box; }
body { background: #0f0e17; }
h1 { font-size: 24px; }
/* OK: ツール固有クラスでスコープ */
.aji-muscle-meal-wrap * { box-sizing: border-box; }
.aji-muscle-meal-wrap .result-header { font-size: 20px; }
ツール固有のラッパークラス(aji-muscle-meal-wrap)を全セレクタの先頭に付けることを徹底します。
5. スマホでのY軸ラベル問題
D3.jsのY軸に日本語の食品名(10〜15文字)を表示しようとすると、スマホの画面幅では確実に切れます。マージンを広げても根本解決にはなりません。
採用した解決策:スマホではY軸ラベルを非表示にし、バーをタップすると下部固定パネルに詳細を表示する。
var isMobile = window.innerWidth <= 767;
// Y軸ラベルはPC専用
if (!isMobile) {
svg.append('g').attr('class', 'y-axis')
.call(d3.axisLeft(yScale));
}
// スマホ用左マージンはラベルがない分だけ最小化
var margin = { left: isMobile ? 15 : 160 };
// バーのタッチイベント
bars.on('touchstart', function(e, d) {
e.preventDefault();
e.stopPropagation(); // 省略するとパネルが即閉じる
showMobilePanel(d);
});
スマホ用下部固定パネルの仕様:
.aji-mobile-panel {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: #1a1a2e;
border-top: 2px solid var(--aji-accent);
max-height: 40vh;
overflow-y: auto;
z-index: 10000;
}
フィルタリングロジックの実装
アレルギーフィルター
function filterByAllergens(foods, selectedAllergens) {
if (selectedAllergens.length === 0) { return foods; }
return foods.filter(function(food) {
var i, len;
for (i = 0, len = selectedAllergens.length; i < len; i++) {
if (food.allergens.indexOf(selectedAllergens[i]) !== -1) {
return false;
}
}
return true;
});
}
Array.some() の代わりに for ループを使っているのは、古いブラウザへの対応と && を避けるためです(Array.some() のコールバック内でも && を使いたくなる場面がある)。
予算フィルター
// 予算区分から1日あたりの最大食材費を逆算
var BUDGET_MAP = {
'under800': 800,
'800to1500': 1500,
'1500to2500': 2500,
'over2500': 999999
};
function filterByBudget(foods, budgetKey) {
var maxBudget = BUDGET_MAP[budgetKey] || 999999;
return foods.filter(function(food) {
// 1日3食で使うと仮定した場合のコスト概算
var dailyCost = food.price_per_100g * food.typical_serving_g / 100 * 3;
return dailyCost <= maxBudget / 3; // 1食あたりの予算内かどうか
});
}
シード値ベースの週替わりメニュー
// 週番号を決定論的に計算
function getCurrentWeekSeed() {
var now = new Date();
var base = new Date(2024, 0, 1); // 2024-01-01を基点
return Math.floor((now - base) / (7 * 24 * 60 * 60 * 1000));
}
// シード値から疑似乱数を生成(線形合同法)
function seededRand(seed) {
var x = Math.sin(seed) * 10000;
return x - Math.floor(x);
}
// 週単位でシャッフルされた食品リストを返す
function getWeeklyMenuOrder(foods) {
var seed = getCurrentWeekSeed();
var indexed = foods.map(function(f, i) {
return { food: f, sort: seededRand(seed * 31 + i) };
});
indexed.sort(function(a, b) { return a.sort - b.sort; });
return indexed.map(function(item) { return item.food; });
}
同じ週内では何度アクセスしても同じメニューが出て、週が変わると自動で切り替わります。ユーザーが意図的に「更新したい」場合は入力条件を少し変えることで対応できます。
データ構造の設計
食品データの1エントリの構造:
{
id: "chicken_breast", // 一意ID
name: "鶏むね肉(皮なし・生)",
category: "meat", // 食品カテゴリ
calories: 105, // kcal/100g
protein: 23.3, // g/100g
fat: 1.9, // g/100g
carbs: 0.1, // g/100g
fiber: 0, // g/100g
allergens: [], // 含むアレルゲン
price_per_100g: 80, // 円/100g(目安)
meal_types: ["lunch", "dinner"], // 利用シーン
ease: "medium", // 調理難易度
source_url: "https://fooddb.mext.go.jp/details/...", // 出典
last_verified: "2026-03-29" // データ確認日
}
全エントリに last_verified を持たせることで、データ鮮度の管理が可能になります。スクリプトで古いエントリを検出して更新対象リストを出力できます。
実装前チェックリスト(嵌まりやすい順)
これらはコードを書き始める前に把握しておくと時間を節約できます。
-
&&演算子禁止(WordPressがHTMLエンティティ変換してJSクラッシュ) -
<script src="CDN">禁止(SWELLが無視する)→ D3ブートストラップを使う - グローバルCSSセレクタ禁止 → ラッパークラスでスコープ
- スマホでY軸日本語ラベルを出さない → タップで下部パネルに表示
- スマホでX軸ラベルは最大3本まで(「最初・中間・最後」のみ表示)
-
touchstartにはe.stopPropagation()を必ずつける - バー内の数値ラベルはスマホでは非表示にする
- デプロイ構造はタイプA(
<!-- wp:html -->)で統一
