メインコンテンツへスキップ
見出し画像

【コピペで試せる】AIと2時間で食事管理アプリを爆速開発した話

    レコーディングダイエット、続いたためしがありますか?僕は、ないです。

    健康診断の結果を見るたびに「今日から食事を記録するぞ!」と意気込むものの、自分でアプリを作るのも面倒で、つい後回しに…。そんな僕がずっと夢見ていたのが、「写真を撮るだけで、全部よしなにやってくれるアプリ」でした。

    そんな夢みたいなアプリ、GoogleのAI「Gemini」を相棒にしたら、驚くほど短時間で実現できてしまったんです。かかった時間は、なんと、わずか2時間。

    これは、面倒くさがりな僕がAIとのペアプログラミングで、自分だけの食事管理アプリを爆速開発した、ちょっと未来な体験談です。

    写真を撮るだけ!ズボラ専用「カロリー分析AI」

    僕が作ったアプリの使い方は、とにかくシンプル。

    1. スマホで食事の写真を撮る。

    2. 「分析」ボタンを押す。

    画像
    画像選択

    たったこれだけ。すると、Geminiが写真をもとにカロリーや栄養素を計算して、こんなレポートを出してくれます。

    画像
    分析結果1
    画像
    分析結果2

    これなら、僕でも楽しく続けられそうです。

    開発の裏側:AIとのペアプログラミング

    「アプリ開発って時間がかかるんじゃ…?」と思いますよね。

    驚くことに、このアプリの骨格は、ほとんどGeminiに「こんな感じのアプリが欲しい」と伝えて作ってもらいました。

    僕がやったのは、AIが生成したコードの細かい部分を調整したり、機能を追加したりするだけ。アプリの心臓部、つまり栄養を分析してくれる“頭脳”はもちろんGeminiです。

    AIとの開発は、まさに「ペアプログラミング」。僕がやりたいことの設計図を伝えると、AIがコードを書き、僕はそれレビューして修正する、という流れです。この「お願い」の部分が、AIアプリ開発の面白いところで、「プロンプト」と呼ばれています。

    例えば、実際に使っている「お願い文(プロンプト)」はこんな感じです。

    この食事の画像を分析して、以下の情報をJSON形式で返してください:
    
    1.  検出された食材リスト(名前、量、個別カロリー)
    2.  合計栄養成分(カロリー、タンパク質、炭水化物、脂質、食物繊維、塩分)
    3.  食事内容の詳細分析(簡潔に2-3文で)
    4.  栄養バランスや健康面でのアドバイス(3-4文で具体的に)
    
    JSONは以下の形式で返してください:
    {
      "foods": [
        {
          "name": "食材名",
          "quantity": "分量(例:100g、1個など)",
          "calories": 120
        }
      ],
      "totalNutrition": {
        "calories": 500,
        "protein": 25,
        "carbs": 60,
        "fat": 20,
        "fiber": 8,
        "sodium": 600
      },
      "analysis": "この食事についての簡潔な分析コメント(2-3文、Markdown形式可)",
      "advice": "栄養バランスや健康面でのアドバイス(3-4文、Markdown形式可)。不足している栄養素や改善点、おすすめの食材追加などを具体的に提案してください。"
    }
    
    (後略)
    
    

    ポイントは、ただ「分析して」と頼むんじゃなくて、「こういう項目を、こういう順番で、こんな形式(JSONっていうらしい)で教えてね」と、APIの仕様書を書くように、具体的に指示を出すこと。

    こうすることで、AIは迷わず僕が欲しい情報を正確に返してくれます。これをうまくやるのが「プロンプトエンジニアリング」ってやつで、AIと仕事をする上で一番大事なスキルなんだなと実感しました。

    AIは優秀な“相棒”だった

    今回、開発の「バディ」にGeminiを選んでみて、本当に驚かされました。

    すごいなと思ったのは、ただの画像認識じゃないところ。ラーメンの写真を見せると、ただ「ラーメンですね」で終わらない。「チャーシュー、メンマ、ネギ、麺が入ってますね」と、具材までちゃんと分解して、それぞれのカロリーを推測してくれるんです。写真と言葉を一緒に理解できるから、こんな芸当ができるんですね。まるで、隣に座ってる優秀なアシスタントに「これ、データにしといて」って頼んでる感覚でした。

    もちろん、万能ではありません。たまに見当違いなことを言ってくるので、「そうじゃなくて!」とプロンプトを何度も書き直したり。でも、そんな試行錯誤も含めて、AIと二人三脚でものを作るのは、これまでにないスピード感で、めちゃくちゃ楽しかったです。

    アイデアさえあれば、誰でも開発者になれる時代

    プログラミングは専門家だけのスキル、なんて時代はもう終わりなのかもしれません。

    「こんなのあったら便利なのに」というアイデアさえあれば、AIに相談しながら、誰でも自分のためのツールを作れる。今回のアプリ開発で、そんな未来の入り口をちょっとだけ覗けた気がします。

    この体験がすごく面白かったので、Googleとnoteがやっているコンテスト「 #AIとやってみた 」に応募してみることにしました。

    この記事を読んで、「自分も何か作ってみたいかも」と思ってくれる人が一人でもいたら、嬉しいです。

    ぜひ、あなたの食事で試してみてください

    ちなみに、僕が作ったこのアプリ、下のコードを全部コピーしてGeminiに貼り付ければ、誰でもすぐに使えます。

    画像
    Canvasで実行1
    画像
    Canvasで実行2

    ぜひ、今日のランチの写真でも撮って、遊んでみてください!

    <!DOCTYPE html>
    <html lang="ja">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>カロリー分析AI - 食事の写真から栄養成分を分析</title>
        <meta name="description" content="写真をアップロードするだけで、AIが食事のカロリーや栄養成分を自動分析します。">
        <script src="https://cdn.tailwindcss.com"></script>
        <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
        <style>
            /* General styling for a smooth experience */
            html, body {
                height: 100%;
            }
            body {
                display: flex;
                flex-direction: column;
                min-height: 100vh;
                min-height: -webkit-fill-available;
            }
            main {
                flex-grow: 1;
            }
            /* Hide elements by default */
            .hidden {
                display: none !important;
            }
            /* Lucide icons custom styling */
            .lucide {
                display: inline-block;
                width: 1em;
                height: 1em;
                stroke-width: 2;
                stroke: currentColor;
                fill: none;
                stroke-linecap: round;
                stroke-linejoin: round;
            }
            /* Custom loader animation */
            .loader {
                width: 48px;
                height: 48px;
                border: 5px solid #FFF;
                border-bottom-color: transparent;
                border-radius: 50%;
                display: inline-block;
                box-sizing: border-box;
                animation: rotation 1s linear infinite;
            }
            @keyframes rotation {
                0% { transform: rotate(0deg); }
                100% { transform: rotate(360deg); }
            }
        </style>
    </head>
    <body class="bg-gray-50">
    
        <!-- Header -->
        <header class="bg-gradient-to-r from-emerald-600 to-teal-600 text-white py-4 sm:py-6 shadow-lg flex-shrink-0">
            <div class="max-w-4xl mx-auto px-4 sm:px-6">
                <div class="flex items-center justify-between">
                    <div class="flex items-center space-x-3">
                        <div class="p-2 bg-white bg-opacity-20 rounded-lg backdrop-blur-sm">
                            <svg class="lucide sm:w-7 sm:h-7" width="24" height="24" viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg>
                        </div>
                        <h1 class="text-xl sm:text-2xl font-bold">カロリー分析AI</h1>
                    </div>
                    <button id="new-analysis-button" class="hidden flex items-center space-x-2 px-4 py-2 bg-white bg-opacity-20 hover:bg-opacity-30 backdrop-blur-sm text-white rounded-lg transition-all duration-200 hover:scale-105 border border-white border-opacity-30 shadow-lg">
                        <svg class="lucide sm:w-5 sm:h-5" width="16" height="16" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>
                        <span class="text-sm sm:text-base font-medium">新しい分析</span>
                    </button>
                </div>
            </div>
        </header>
    
        <!-- Main Content -->
        <main class="max-w-4xl mx-auto px-4 sm:px-6 py-6 sm:py-8 w-full">
            <!-- Initial Loading State -->
            <div id="initializer" class="h-full flex items-center justify-center">
                 <div class="text-center">
                    <div class="animate-spin w-10 h-10 border-4 border-emerald-500 border-t-transparent rounded-full mx-auto mb-4"></div>
                    <p class="text-lg text-gray-600">サービスを準備中...</p>
                </div>
            </div>
    
            <!-- Error Message Container -->
            <div id="error-container" class="hidden mb-4 sm:mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex flex-col sm:flex-row items-start sm:items-center space-y-3 sm:space-y-0 sm:space-x-3">
                 <svg class="lucide text-red-600 flex-shrink-0" width="20" height="20" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
                <p id="error-message" class="flex-1 min-w-0 text-red-800 text-sm sm:text-base"></p>
                <button id="retry-button" class="px-3 py-1 text-sm bg-red-600 text-white rounded hover:bg-red-700 transition-colors flex-shrink-0">再試行</button>
            </div>
    
            <!-- App View: Initial Upload Screen -->
            <div id="upload-view" class="hidden space-y-6">
                <div class="text-center max-w-2xl mx-auto">
                    <h2 class="text-lg sm:text-xl font-semibold text-gray-800 flex items-center justify-center mb-4">
                        <svg class="lucide text-emerald-600 mr-2" width="20" height="20" viewBox="0 0 24 24"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.582a.5.5 0 0 1 0 .962L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
                        AI による食事分析
                    </h2>
                    <p class="text-gray-600 text-sm sm:text-base leading-relaxed mb-6">食事の写真をアップロードするだけで、AI が自動的にカロリーや栄養成分を分析します。健康的な食生活をサポートする、簡単で便利なツールです。</p>
                </div>
                <div id="image-upload-section" class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-8">
                    <!-- Image upload component will be rendered here by JS -->
                </div>
                <div class="bg-amber-50 border border-amber-200 rounded-lg p-4 max-w-2xl mx-auto">
                    <div class="flex items-start space-x-3">
                        <svg class="lucide text-amber-600 flex-shrink-0 mt-0.5" width="18" height="18" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
                        <p class="text-amber-800 text-sm sm:text-base leading-relaxed">AI による分析結果は参考値です。正確な栄養情報については専門家にご相談ください。</p>
                    </div>
                </div>
            </div>
    
            <!-- App View: Analysis Result Screen -->
            <div id="result-view" class="hidden space-y-4 sm:space-y-6">
                <!-- Analyzed Image -->
                <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
                    <h2 class="text-xl sm:text-2xl font-bold text-gray-800 mb-4">分析した画像</h2>
                    <img id="result-image" src="" alt="分析した食事" class="w-full h-auto max-h-80 object-cover rounded-lg shadow-lg"/>
                </div>
                <!-- AI Analysis Text -->
                <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
                    <h3 class="text-lg font-semibold text-gray-800 mb-3">AI分析結果</h3>
                    <div id="analysis-text" class="text-sm sm:text-base text-gray-700 leading-relaxed prose prose-sm sm:prose max-w-none"></div>
                </div>
                <!-- AI Health Advice -->
                <div class="bg-gradient-to-r from-emerald-50 to-teal-50 rounded-xl shadow-sm border border-emerald-200 p-4 sm:p-6">
                     <h3 class="text-lg font-semibold text-emerald-800 mb-3 flex items-center">
                        <svg class="lucide mr-2 sm:w-5 sm:h-5" width="18" height="18" viewBox="0 0 24 24"><path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7z"/></svg>
                        健康アドバイス
                    </h3>
                    <div id="advice-text" class="text-sm sm:text-base text-emerald-700 leading-relaxed prose prose-sm sm:prose max-w-none"></div>
                </div>
                <!-- Nutrition Details Grid -->
                <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
                    <div class="space-y-4 sm:space-y-6">
                        <!-- Nutrition Card -->
                        <div id="nutrition-card-container" class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6"></div>
                        <!-- Radar Chart -->
                        <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
                            <h3 class="text-lg font-semibold text-gray-800 mb-4 text-center">栄養バランス</h3>
                            <div class="h-64 sm:h-80"><canvas id="nutrition-chart"></canvas></div>
                            <p class="mt-4 text-center text-xs sm:text-sm text-gray-600">各栄養素の推奨摂取量に対する割合の目安</p>
                        </div>
                    </div>
                    <!-- Food List -->
                    <div id="food-list-container" class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6"></div>
                </div>
            </div>
        </main>
    
        <!-- Footer -->
        <footer class="bg-white border-t border-gray-200 py-4 sm:py-6 mt-auto flex-shrink-0">
            <div class="max-w-4xl mx-auto px-4 sm:px-6 text-center text-gray-600">
                <p class="text-sm sm:text-base">© 2025 カロリー分析AI. All rights reserved.</p>
            </div>
        </footer>
    
        <!-- Confirmation Modal -->
        <div id="confirm-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
            <div class="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
                <div class="p-6">
                    <div class="flex items-center justify-between mb-4">
                         <div class="flex items-center space-x-3">
                            <div class="p-2 bg-amber-100 rounded-full"><svg class="lucide text-amber-600" width="20" height="20" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg></div>
                            <h3 id="modal-title" class="text-lg font-semibold text-gray-800"></h3>
                        </div>
                        <button id="modal-close-button" class="p-1 hover:bg-gray-100 rounded-full transition-colors"><svg class="lucide text-gray-500" width="20" height="20" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
                    </div>
                    <p id="modal-message" class="text-gray-600 mb-6 leading-relaxed"></p>
                    <div class="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3">
                        <button id="modal-cancel-button" class="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"></button>
                        <button id="modal-confirm-button" class="flex-1 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"></button>
                    </div>
                </div>
            </div>
        </div>
        
        <script type="module">
            // Firebase SDK imports
            import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
            import { getAuth, signInAnonymously, signInWithCustomToken, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
            import { getFirestore, doc, onSnapshot, setDoc, deleteDoc, serverTimestamp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
    
            // --- DOM Elements ---
            const DOMElements = {
                initializer: document.getElementById('initializer'),
                uploadView: document.getElementById('upload-view'),
                resultView: document.getElementById('result-view'),
                errorContainer: document.getElementById('error-container'),
                errorMessage: document.getElementById('error-message'),
                retryButton: document.getElementById('retry-button'),
                newAnalysisButton: document.getElementById('new-analysis-button'),
                imageUploadSection: document.getElementById('image-upload-section'),
                resultImage: document.getElementById('result-image'),
                analysisText: document.getElementById('analysis-text'),
                adviceText: document.getElementById('advice-text'),
                nutritionCardContainer: document.getElementById('nutrition-card-container'),
                foodListContainer: document.getElementById('food-list-container'),
                chartCanvas: document.getElementById('nutrition-chart'),
                modal: document.getElementById('confirm-modal'),
                modalTitle: document.getElementById('modal-title'),
                modalMessage: document.getElementById('modal-message'),
                modalConfirmButton: document.getElementById('modal-confirm-button'),
                modalCancelButton: document.getElementById('modal-cancel-button'),
                modalCloseButton: document.getElementById('modal-close-button'),
            };
    
            // --- State ---
            let db, auth, userId;
            let chartInstance = null;
            let currentAnalysis = null;
            const appId = typeof __app_id !== 'undefined' ? __app_id : 'default-app-id';
    
    
            // --- UI Rendering ---
    
            function renderUploader(state = 'idle', previewUrl = null) {
                DOMElements.imageUploadSection.innerHTML = '';
                const container = document.createElement('div');
    
                if (state === 'reading') {
                    container.className = 'relative border-2 border-dashed rounded-lg p-6 sm:p-8 flex items-center justify-center h-48 sm:h-64';
                    container.innerHTML = `
                        <div class="text-center">
                            <div class="loader mx-auto mb-2 !border-emerald-500 !border-b-transparent" style="border-top-color: #10B981; border-right-color: #10B981; border-left-color: #10B981; border-bottom-color: transparent;"></div>
                            <p class="text-sm sm:text-base text-gray-600">画像を読み込み中...</p>
                        </div>
                    `;
                    DOMElements.imageUploadSection.appendChild(container);
                    return;
                }
    
                if (previewUrl) {
                    const isAnalyzing = state === 'analyzing';
                    container.className = 'relative group';
                    container.innerHTML = `
                        <img src="${previewUrl}" alt="Preview" class="w-full h-48 sm:h-64 object-cover rounded-lg shadow-lg" />
                        ${!isAnalyzing ? `<button id="clear-preview-button" class="absolute top-2 right-2 p-2 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200 hover:bg-red-600">
                            <svg class="lucide" width="16" height="16" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
                        </button>` : ''}
                        ${isAnalyzing ? `<div class="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center rounded-lg">
                            <div class="text-white text-center">
                                <div class="loader mx-auto mb-2"></div>
                                <p class="text-sm sm:text-base">解析中...</p>
                            </div>
                        </div>` : ''}
                    `;
                    DOMElements.imageUploadSection.appendChild(container);
    
                    if (!isAnalyzing) {
                        document.getElementById('clear-preview-button').addEventListener('click', () => {
                            renderUploader('idle');
                        });
                    }
                    return;
                }
                
                // Default 'idle' state
                container.className = 'relative border-2 border-dashed rounded-lg p-6 sm:p-8 transition-all duration-200 border-gray-300 hover:border-emerald-400 hover:bg-gray-50';
                container.innerHTML = `
                    <div class="text-center">
                        <svg class="lucide text-gray-400 mx-auto mb-4 sm:w-12 sm:h-12" width="40" height="40" viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></svg>
                        <p class="text-base sm:text-lg font-medium text-gray-700 mb-2">食事の写真をアップロード</p>
                        <p class="text-xs sm:text-sm text-gray-500 mb-6">ドラッグ&ドロップまたはクリックして画像を選択</p>
                        <label class="inline-flex items-center px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors cursor-pointer text-sm sm:text-base">
                            <svg class="lucide mr-2 sm:w-5 sm:h-5" width="18" height="18" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7,10 12,5 17,10"/><line x1="12" x2="12" y1="5" y2="15"/></svg>
                            画像を選択
                            <input id="file-input" type="file" accept="image/*" class="hidden" />
                        </label>
                    </div>
                `;
                DOMElements.imageUploadSection.appendChild(container);
                
                const fileInput = document.getElementById('file-input');
                container.addEventListener('dragenter', (e) => { e.preventDefault(); e.stopPropagation(); container.classList.add('border-emerald-500', 'bg-emerald-50'); });
                container.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); container.classList.remove('border-emerald-500', 'bg-emerald-50'); });
                container.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); });
                container.addEventListener('drop', (e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    container.classList.remove('border-emerald-500', 'bg-emerald-50');
                    const files = e.dataTransfer.files;
                    if (files && files[0]) handleFileSelect(files[0]);
                });
                fileInput.addEventListener('change', (e) => {
                    if (e.target.files && e.target.files[0]) handleFileSelect(e.target.files[0]);
                });
            }
            
            function renderResultView(analysis) {
                DOMElements.resultImage.src = analysis.imageUrl;
                
                const renderMarkdown = (text) => text ? text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>').replace(/\*(.*?)\*/g, '<em>$1</em>').replace(/\n/g, '<br />') : '';
                DOMElements.analysisText.innerHTML = renderMarkdown(analysis.analysis);
                DOMElements.adviceText.innerHTML = renderMarkdown(analysis.advice);
    
                const nutrition = analysis.totalNutrition || {};
                const nutritionItems = [
                    { label: 'カロリー', value: `${nutrition.calories || 0}kcal`, icon: '<svg class="lucide" width="18" height="18" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>', color: 'text-orange-600' },
                    { label: 'タンパク質', value: `${nutrition.protein || 0}g`, icon: '<svg class="lucide" width="18" height="18" viewBox="0 0 24 24"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/></svg>', color: 'text-red-600' },
                    { label: '炭水化物', value: `${nutrition.carbs || 0}g`, icon: '<svg class="lucide" width="18" height="18" viewBox="0 0 24 24"><path d="M2 12h20"/><path d="M6 8c0 2-2 4-2 4s2 2 2 4"/><path d="M10 8c0 2-2 4-2 4s2 2 2 4"/><path d="M14 8c0 2-2 4-2 4s2 2 2 4"/><path d="M18 8c0 2-2 4-2 4s2 2 2 4"/></svg>', color: 'text-yellow-600' },
                    { label: '脂質', value: `${nutrition.fat || 0}g`, icon: '<svg class="lucide" width="18" height="18" viewBox="0 0 24 24"><path d="M7 16.3c2.2 0 4-1.83 4-4.05 0-1.16-.57-2.26-1.71-3.19S7.29 6.75 7 5.3c-.29 1.45-1.14 2.84-2.29 3.76S3 11.1 3 12.25c0 2.22 1.8 4.05 4 4.05z"/><path d="M12.56 6.6A10.97 10.97 0 0 0 14 3.02c.5 2.5 2.04 4.6 4.14 5.93a10.97 10.97 0 0 1 2.83 3.4 10.97 10.97 0 0 1-2.83 3.4C16.04 17.08 14.5 19.18 14 21.68A10.97 10.97 0 0 1 12.56 6.6z"/></svg>', color: 'text-blue-600' },
                ];
                DOMElements.nutritionCardContainer.innerHTML = `
                    <h3 class="text-lg font-semibold text-gray-800 mb-4">栄養成分</h3>
                    <div class="grid grid-cols-2 gap-3 sm:gap-4">
                        ${nutritionItems.map(item => `
                            <div class="flex items-center space-x-2 sm:space-x-3 p-2 sm:p-3 bg-gray-50 rounded-lg">
                                <div class="${item.color} flex-shrink-0">${item.icon}</div>
                                <div class="min-w-0 flex-1">
                                    <p class="text-xs sm:text-sm text-gray-600 truncate">${item.label}</p>
                                    <p class="font-semibold text-gray-800 text-sm sm:text-base truncate">${item.value}</p>
                                </div>
                            </div>
                        `).join('')}
                    </div>
                    <div class="mt-4 pt-4 border-t border-gray-200">
                        <div class="flex flex-col sm:flex-row justify-between text-xs sm:text-sm text-gray-600 space-y-1 sm:space-y-0">
                            <span>食物繊維: ${nutrition.fiber || 0}g</span>
                            <span>塩分: ${nutrition.sodium || 0}mg</span>
                        </div>
                    </div>
                `;
                
                const foods = analysis.foods || [];
                DOMElements.foodListContainer.innerHTML = `
                    <h3 class="text-lg font-semibold text-gray-800 mb-4 flex items-center">
                        <svg class="lucide mr-2 text-emerald-600 sm:w-5 sm:h-5" width="18" height="18" viewBox="0 0 24 24"><path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3z"/></svg>
                        検出された食材
                    </h3>
                    <div class="space-y-3 max-h-96 overflow-y-auto pr-2">
                        ${foods.length > 0 ? foods.map(food => `
                            <div class="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
                                <div class="flex-1 min-w-0 pr-3">
                                    <p class="font-medium text-gray-800 text-sm sm:text-base truncate">${food.name}</p>
                                    <p class="text-xs sm:text-sm text-gray-600 truncate">${food.quantity}</p>
                                </div>
                                <div class="text-right flex-shrink-0">
                                    <p class="font-semibold text-emerald-600 text-sm sm:text-base">${food.calories}kcal</p>
                                </div>
                            </div>
                        `).join('') : '<p class="text-gray-500">食材が見つかりませんでした。</p>'}
                    </div>
                `;
                
                renderRadarChart(nutrition);
            }
            
            function renderRadarChart(nutrition) {
                if (chartInstance) {
                    chartInstance.destroy();
                }
                const normalize = (val, max) => Math.min((val / max) * 100, 100);
                const data = {
                    labels: ['カロリー', 'タンパク質', '炭水化物', '脂質', '食物繊維', '塩分'],
                    datasets: [{
                        label: '栄養バランス',
                        data: [
                            normalize(nutrition.calories, 800),
                            normalize(nutrition.protein, 60),
                            normalize(nutrition.carbs, 100),
                            normalize(nutrition.fat, 50),
                            normalize(nutrition.fiber, 25),
                            normalize((nutrition.sodium || 0) / 1000, 8)
                        ],
                        backgroundColor: 'rgba(5, 150, 105, 0.2)',
                        borderColor: 'rgb(5, 150, 105)',
                        pointBackgroundColor: 'rgb(5, 150, 105)',
                        pointBorderColor: '#fff',
                        pointHoverBackgroundColor: '#fff',
                        pointHoverBorderColor: 'rgb(5, 150, 105)'
                    }]
                };
                chartInstance = new Chart(DOMElements.chartCanvas, {
                    type: 'radar',
                    data: data,
                    options: {
                        responsive: true,
                        maintainAspectRatio: false,
                        scales: {
                            r: {
                                angleLines: { display: true },
                                suggestedMin: 0,
                                suggestedMax: 100,
                                pointLabels: { font: { size: 12 } },
                                ticks: { backdropColor: 'transparent' }
                            }
                        },
                        plugins: {
                            legend: { display: false }
                        }
                    }
                });
            }
            
            function switchView(view) {
                DOMElements.uploadView.classList.add('hidden');
                DOMElements.resultView.classList.add('hidden');
                DOMElements.errorContainer.classList.add('hidden');
                DOMElements.initializer.classList.add('hidden');
    
                if (view === 'upload') {
                    DOMElements.uploadView.classList.remove('hidden');
                    DOMElements.newAnalysisButton.classList.add('hidden');
                    renderUploader('idle');
                } else if (view === 'result' && currentAnalysis) {
                    DOMElements.resultView.classList.remove('hidden');
                    DOMElements.newAnalysisButton.classList.remove('hidden');
                    renderResultView(currentAnalysis);
                } else if (view === 'error') {
                    DOMElements.errorContainer.classList.remove('hidden');
                     DOMElements.newAnalysisButton.classList.remove('hidden');
                } else if (view === 'loading') {
                     DOMElements.initializer.classList.remove('hidden');
                }
            }
            
            function showModal({ title, message, confirmText, cancelText, onConfirm, onCancel }) {
                DOMElements.modalTitle.textContent = title;
                DOMElements.modalMessage.textContent = message;
                DOMElements.modalConfirmButton.textContent = confirmText;
                DOMElements.modalCancelButton.textContent = cancelText;
    
                const newConfirmButton = DOMElements.modalConfirmButton.cloneNode(true);
                DOMElements.modalConfirmButton.parentNode.replaceChild(newConfirmButton, DOMElements.modalConfirmButton);
                DOMElements.modalConfirmButton = newConfirmButton;
    
                const newCancelButton = DOMElements.modalCancelButton.cloneNode(true);
                DOMElements.modalCancelButton.parentNode.replaceChild(newCancelButton, DOMElements.modalCancelButton);
                DOMElements.modalCancelButton = newCancelButton;
    
                const newCloseButton = DOMElements.modalCloseButton.cloneNode(true);
                DOMElements.modalCloseButton.parentNode.replaceChild(newCloseButton, DOMElements.modalCloseButton);
                DOMElements.modalCloseButton = newCloseButton;
    
                newConfirmButton.addEventListener('click', () => {
                    onConfirm();
                    hideModal();
                });
                const cancelAction = () => {
                    if(onCancel) onCancel();
                    hideModal();
                };
                newCancelButton.addEventListener('click', cancelAction);
                newCloseButton.addEventListener('click', cancelAction);
                
                DOMElements.modal.classList.remove('hidden');
            }
    
            function hideModal() {
                DOMElements.modal.classList.add('hidden');
            }
            
            async function convertImageToBase64(file) {
                return new Promise((resolve, reject) => {
                    const reader = new FileReader();
                    reader.onload = () => resolve(reader.result);
                    reader.onerror = err => reject(err);
                    reader.readAsDataURL(file);
                });
            }
            
            async function performAnalysis(file) {
                if (!db || !userId) {
                    DOMElements.errorMessage.textContent = "データベースに接続できません。";
                    switchView('error');
                    return;
                }
                
                try {
                    const previewUrl = await convertImageToBase64(file);
                    renderUploader('analyzing', previewUrl);
                    
                    const base64Data = previewUrl.split(',')[1];
                    const apiKey = ""; // Provided by environment
                    const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`;
                    const promptText = `この食事の画像を分析して、以下の情報をJSON形式で返してください:
    
    1.  検出された食材リスト(名前、量、個別カロリー)
    2.  合計栄養成分(カロリー、タンパク質、炭水化物、脂質、食物繊維、塩分)
    3.  食事内容の詳細分析(簡潔に2-3文で)
    4.  栄養バランスや健康面でのアドバイス(3-4文で具体的に)
    
    JSONは以下の形式で返してください:
    {
      "foods": [
        {
          "name": "食材名",
          "quantity": "分量(例:100g、1個など)",
          "calories": 120
        }
      ],
      "totalNutrition": {
        "calories": 500,
        "protein": 25,
        "carbs": 60,
        "fat": 20,
        "fiber": 8,
        "sodium": 600
      },
      "analysis": "この食事についての簡潔な分析コメント(2-3文、Markdown形式可)",
      "advice": "栄養バランスや健康面でのアドバイス(3-4文、Markdown形式可)。不足している栄養素や改善点、おすすめの食材追加などを具体的に提案してください。"
    }
    
    分析コメントとアドバイスは簡潔にし、必要に応じてMarkdown形式(**太字**、*斜体*、リストなど)を使用してください。
    アドバイスでは、栄養バランスの改善点、不足している栄養素の補完方法、健康的な食事のための具体的な提案を含めてください。
    日本語で回答し、できるだけ正確な栄養情報を提供してください。`;
    
                    const payload = {
                        contents: [{ parts: [ { text: promptText }, { inlineData: { mimeType: file.type, data: base64Data } } ] }],
                        generationConfig: { responseMimeType: "application/json" }
                    };
                    const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
                    if (!response.ok) throw new Error(`Gemini API error: ${response.status}`);
                    const result = await response.json();
                    const textContent = result.candidates?.[0]?.content?.parts?.[0]?.text;
                    if (!textContent) throw new Error('APIからの応答が無効です。');
                    
                    const analysisData = JSON.parse(textContent);
                    const finalData = {
                        id: crypto.randomUUID(),
                        imageUrl: previewUrl,
                        foods: analysisData.foods || [],
                        totalNutrition: analysisData.totalNutrition || {},
                        analysis: analysisData.analysis || "分析結果を取得できませんでした。",
                        advice: analysisData.advice || "アドバイスを取得できませんでした。",
                    };
    
                    const docRef = doc(db, 'artifacts', appId, 'users', userId, 'analysis', 'latest');
                    await setDoc(docRef, { ...finalData, createdAt: serverTimestamp() });
                } catch (err) {
                    console.error('Analysis error:', err);
                    DOMElements.errorMessage.textContent = '画像の分析に失敗しました。もう一度お試しください。';
                    switchView('error');
                }
            }
    
            function handleFileSelect(file) {
                if (!file) return;
    
                const start = (selectedFile) => {
                    switchView('upload');
                    renderUploader('reading');
                    performAnalysis(selectedFile);
                };
    
                if (currentAnalysis) {
                    showModal({
                        title: '新しい分析を開始',
                        message: '現在の分析結果が削除されます。新しい分析を開始しますか?',
                        confirmText: '開始する',
                        cancelText: 'キャンセル',
                        onConfirm: async () => {
                            await resetAnalysis();
                            start(file);
                        },
                        onCancel: () => {
                           switchView('result');
                        }
                    });
                } else {
                    start(file);
                }
            }
    
            async function resetAnalysis() {
                 if (!db || !userId) {
                    DOMElements.errorMessage.textContent = "データベースに接続できません。リセットできません。";
                    switchView('error');
                    return;
                }
                const docRef = doc(db, 'artifacts', appId, 'users', userId, 'analysis', 'latest');
                try {
                    await deleteDoc(docRef);
                } catch (err) {
                    console.error("Error deleting document:", err);
                    DOMElements.errorMessage.textContent = 'リセットに失敗しました。';
                    switchView('error');
                }
            }
            
            function initialize() {
                try {
                    const firebaseConfig = typeof __firebase_config !== 'undefined' ? JSON.parse(__firebase_config) : null;
                    if (!firebaseConfig) throw new Error("Firebase config not available.");
                    
                    const app = initializeApp(firebaseConfig);
                    auth = getAuth(app);
                    db = getFirestore(app);
    
                    DOMElements.newAnalysisButton.addEventListener('click', () => {
                         showModal({
                            title: '分析結果をリセット',
                            message: '現在の分析結果を削除して、新しい画像をアップロードできるようにしますか?',
                            confirmText: 'リセットする',
                            cancelText: 'キャンセル',
                            onConfirm: resetAnalysis
                        });
                    });
                    DOMElements.retryButton.addEventListener('click', resetAnalysis);
    
                    onAuthStateChanged(auth, user => {
                        if (user) {
                            userId = user.uid;
                            listenForAnalysis();
                        } else {
                            switchView('error');
                            DOMElements.errorMessage.textContent = "認証に失敗しました。ページをリロードしてください。";
                        }
                    });
    
                    (async () => {
                        try {
                            if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
                                await signInWithCustomToken(auth, __initial_auth_token);
                            } else {
                                await signInAnonymously(auth);
                            }
                        } catch (error) {
                             console.error("Authentication failed:", error);
                             DOMElements.errorMessage.textContent = "認証に失敗しました。";
                             switchView('error');
                        }
                    })();
    
                } catch (error) {
                    console.error("Initialization failed:", error);
                    DOMElements.errorMessage.textContent = "アプリの初期化に失敗しました。";
                    switchView('error');
                }
            }
            
            function listenForAnalysis() {
                if (!db || !userId) return;
                const docRef = doc(db, 'artifacts', appId, 'users', userId, 'analysis', 'latest');
                onSnapshot(docRef, (docSnap) => {
                    if (docSnap.exists()) {
                        currentAnalysis = docSnap.data();
                        switchView('result');
                    } else {
                        currentAnalysis = null;
                        if (chartInstance) chartInstance.destroy();
                        switchView('upload');
                    }
                }, (err) => {
                    console.error("Firestore snapshot error:", err);
                    DOMElements.errorMessage.textContent = "データの読み込みに失敗しました。";
                    switchView('error');
                });
            }
            
            document.addEventListener('DOMContentLoaded', initialize);
    
        </script>
    </body>
    </html>
    
     
     

    shoto

     
     
    田舎で働く、とあるエンジニア

    あなたへのおすすめ