Created
December 14, 2025 10:57
-
-
Save lunamoth/5569e670985c16e25a17e25570218ea5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="ko"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>💪 다이어트 챌린지 2025 (Updated)</title> | |
| <!-- 필수 라이브러리: Chart.js 및 Date Adapter (순서 중요) --> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/date-fns"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script> | |
| <style> | |
| :root { | |
| --primary: #4CAF50; | |
| --primary-dark: #388E3C; | |
| --secondary: #2196F3; | |
| --secondary-dark: #1976D2; | |
| --accent: #FF9800; | |
| --danger: #F44336; | |
| --bg: #f0f2f5; | |
| --card-bg: #ffffff; | |
| --text: #333333; | |
| --text-light: #757575; | |
| /* 히트맵 색상 */ | |
| --heatmap-empty: #ebedf0; | |
| --heatmap-1: #9be9a8; /* 약한 감량 */ | |
| --heatmap-2: #40c463; | |
| --heatmap-3: #30a14e; | |
| --heatmap-4: #216e39; /* 강한 감량 */ | |
| --heatmap-gain: #ffcdd2; /* 증량 */ | |
| /* 뱃지 색상 */ | |
| --gold: #FFD700; | |
| --silver: #C0C0C0; | |
| --bronze: #CD7F32; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| background-color: var(--bg); | |
| color: var(--text); | |
| margin: 0; | |
| padding: 20px; | |
| line-height: 1.6; | |
| } | |
| .container { max-width: 900px; margin: 0 auto; } | |
| /* 헤더 */ | |
| header { | |
| text-align: center; | |
| margin-bottom: 30px; | |
| background: linear-gradient(135deg, var(--primary), var(--secondary)); | |
| padding: 30px; | |
| border-radius: 20px; | |
| color: white; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.1); | |
| } | |
| h1 { margin: 0; font-size: 2.2rem; } | |
| .subtitle { opacity: 0.9; margin-top: 5px; font-size: 1rem; } | |
| /* 카드 공통 */ | |
| .card { | |
| background: var(--card-bg); | |
| border-radius: 16px; | |
| padding: 20px; | |
| margin-bottom: 20px; | |
| box-shadow: 0 2px 10px rgba(0,0,0,0.05); | |
| transition: transform 0.2s; | |
| } | |
| .card h3 { margin-top: 0; border-bottom: 2px solid #f0f0f0; padding-bottom: 10px; margin-bottom: 15px; font-size: 1.2rem; display: flex; justify-content: space-between; align-items: center; } | |
| /* 입력 폼 */ | |
| .input-group { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; } | |
| .input-wrapper { flex: 1; min-width: 120px; } | |
| label { display: block; font-size: 0.85rem; color: var(--text-light); margin-bottom: 5px; font-weight: bold; } | |
| input, select { width: 100%; padding: 10px; border: 2px solid #e0e0e0; border-radius: 10px; font-size: 1rem; box-sizing: border-box; outline: none; } | |
| input:focus { border-color: var(--primary); } | |
| button.add-btn { | |
| background-color: var(--primary); color: white; border: none; padding: 10px 24px; border-radius: 10px; | |
| font-size: 1rem; cursor: pointer; font-weight: bold; min-width: 100px; box-shadow: 0 4px 6px rgba(76, 175, 80, 0.3); | |
| } | |
| button.add-btn:active { transform: scale(0.98); } | |
| /* 설정 패널 */ | |
| .settings-toggle { text-align: right; margin-bottom: 10px; cursor: pointer; color: var(--text-light); font-size: 0.9rem; } | |
| #settingsPanel { display: none; background: #fafafa; border: 1px dashed #ccc; } | |
| .danger-zone { border: 1px solid var(--danger); background: #fff5f5; padding: 15px; border-radius: 10px; margin-top: 20px; } | |
| /* 프로그레스 바 (방향 수정됨: 오른쪽->왼쪽) */ | |
| .progress-card { position: relative; padding: 25px 20px; } | |
| .progress-track { | |
| background: #e0e0e0; height: 10px; border-radius: 5px; position: relative; | |
| margin: 40px 15px 50px 15px; /* 이모지와 텍스트 공간 확보 */ | |
| } | |
| .progress-fill { | |
| background: linear-gradient(90deg, var(--primary), var(--secondary)); | |
| height: 100%; border-radius: 5px; width: 0%; transition: width 0.5s ease-out; | |
| position: absolute; top: 0; | |
| right: 0; /* 왼쪽 기준(left:0)에서 오른쪽 기준(right:0)으로 변경 */ | |
| } | |
| .progress-marker { | |
| position: absolute; top: 50%; transform: translate(-50%, -50%); | |
| width: 14px; height: 14px; background: white; border: 3px solid #ccc; border-radius: 50%; z-index: 2; | |
| } | |
| /* 마커 위치 변경: 시작(우측), 목표(좌측) */ | |
| .progress-marker.start { left: 100%; border-color: var(--text-light); } | |
| .progress-marker.goal { left: 0%; border-color: var(--secondary); } | |
| /* 이모지와 텍스트 팝업 (Right 기준 좌표계에 맞춰 transform 변경) */ | |
| .progress-indicator-emoji { | |
| position: absolute; top: -35px; | |
| transform: translateX(50%); /* -50%에서 50%로 변경 (우측 기준 정렬 보정) */ | |
| font-size: 1.8rem; transition: right 0.5s ease-out; z-index: 3; | |
| } | |
| .progress-info-text { | |
| position: absolute; top: 20px; | |
| transform: translateX(50%); /* -50%에서 50%로 변경 (우측 기준 정렬 보정) */ | |
| font-size: 0.8rem; color: var(--text); background: rgba(255,255,255,0.95); | |
| padding: 4px 8px; border-radius: 6px; box-shadow: 0 2px 6px rgba(0,0,0,0.1); | |
| white-space: nowrap; text-align: center; border: 1px solid #eee; transition: right 0.5s ease-out; z-index: 3; | |
| } | |
| .progress-labels { display: flex; justify-content: space-between; font-size: 0.85rem; color: var(--text-light); font-weight: bold; margin-top: -35px; padding: 0 10px; } | |
| /* 대시보드 그리드 */ | |
| .dashboard-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 15px; margin-bottom: 20px; } | |
| .stat-card { background: var(--card-bg); padding: 15px; border-radius: 12px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.05); } | |
| .stat-icon { font-size: 1.8rem; margin-bottom: 5px; display: block; } | |
| .stat-value { font-size: 1.4rem; font-weight: bold; color: var(--text); } | |
| .stat-label { font-size: 0.8rem; color: var(--text-light); } | |
| .stat-sub { font-size: 0.75rem; color: var(--secondary); margin-top: 2px; } | |
| /* 텍스트 리포트 */ | |
| .text-report { background: #e3f2fd; color: #0d47a1; padding: 12px 18px; border-radius: 10px; font-size: 0.95rem; margin-bottom: 15px; display: flex; align-items: center; gap: 10px; } | |
| /* 상세 분석 그리드 */ | |
| .analysis-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 15px; margin-bottom: 20px; } | |
| /* 테이블 */ | |
| .data-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } | |
| .data-table th, .data-table td { padding: 10px; text-align: center; border-bottom: 1px solid #eee; } | |
| .data-table th { background-color: #f9f9f9; color: var(--text-light); font-weight: 600; } | |
| .data-table td.pos { color: var(--danger); font-weight: bold; } /* 증량 */ | |
| .data-table td.neg { color: var(--primary); font-weight: bold; } /* 감량 */ | |
| /* 히트맵 */ | |
| .heatmap-container { display: flex; flex-wrap: wrap; gap: 4px; justify-content: center; padding: 10px 0; max-width: 100%; } | |
| .heatmap-cell { width: 12px; height: 12px; border-radius: 2px; background-color: var(--heatmap-empty); cursor: pointer; position: relative; } | |
| .heatmap-legend { display: flex; justify-content: flex-end; align-items: center; gap: 5px; font-size: 0.75rem; color: var(--text-light); margin-top: 5px; } | |
| .legend-box { width: 10px; height: 10px; border-radius: 2px; } | |
| /* 차트 컨테이너 */ | |
| .mini-chart-container { height: 200px; width: 100%; position: relative; } | |
| .chart-container { position: relative; height: 350px; width: 100%; } | |
| /* 필터 */ | |
| .filter-row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 15px; } | |
| .filter-group { display: flex; gap: 5px; } | |
| .filter-btn { background: #f0f0f0; border: none; padding: 6px 12px; border-radius: 15px; font-size: 0.85rem; cursor: pointer; color: var(--text-light); } | |
| .filter-btn.active { background: var(--secondary); color: white; font-weight: bold; } | |
| .date-range-picker { display: flex; gap: 5px; align-items: center; font-size: 0.85rem; } | |
| /* 뱃지 시스템 스타일 (New) */ | |
| .badge-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 10px; text-align: center; } | |
| .badge-item { background: #fafafa; padding: 10px; border-radius: 10px; border: 1px solid #eee; opacity: 0.4; filter: grayscale(1); transition: all 0.3s; } | |
| .badge-item.unlocked { opacity: 1; filter: grayscale(0); border-color: var(--accent); background: #fff8e1; box-shadow: 0 2px 5px rgba(0,0,0,0.1); } | |
| .badge-icon { font-size: 2rem; display: block; margin-bottom: 5px; } | |
| .badge-name { font-size: 0.75rem; font-weight: bold; color: var(--text); } | |
| @media (max-width: 600px) { | |
| h1 { font-size: 1.6rem; } | |
| .dashboard-grid { grid-template-columns: 1fr 1fr; } | |
| .filter-row { flex-direction: column; align-items: flex-start; } | |
| .input-group { flex-direction: column; } | |
| .input-wrapper, button.add-btn { width: 100%; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <header> | |
| <div style="font-size: 3rem; margin-bottom: 10px;">🏃♂️🥗💧</div> | |
| <h1>다이어트 챌린지 2025</h1> | |
| <div class="subtitle">데이터 기반 체중 관리 시스템</div> | |
| </header> | |
| <!-- 설정 영역 --> | |
| <div class="settings-toggle" onclick="toggleSettings()">⚙️ 설정 / 백업 / 초기화 / 내보내기</div> | |
| <div class="card" id="settingsPanel"> | |
| <h4 style="margin-top:0;">기본 설정</h4> | |
| <div class="input-group" style="margin-bottom: 20px;"> | |
| <div class="input-wrapper"> | |
| <label>📏 키 (cm)</label> | |
| <input type="number" id="userHeight" value="175"> | |
| </div> | |
| <div class="input-wrapper"> | |
| <label>🏁 시작 체중 (kg)</label> | |
| <input type="number" id="startWeight" value="78.5" step="0.1"> | |
| </div> | |
| <div class="input-wrapper"> | |
| <label>🎯 목표 체중 (kg)</label> | |
| <input type="number" id="goal1Weight" value="70" step="0.1"> | |
| </div> | |
| <button class="add-btn" style="background-color: var(--secondary);" onclick="saveSettings()">저장</button> | |
| </div> | |
| <h4 style="margin-top:0;">📂 데이터 관리</h4> | |
| <div class="input-group" style="margin-bottom: 20px; border: 1px dashed #ccc; padding: 10px; border-radius: 10px;"> | |
| <div class="input-wrapper"> | |
| <label>CSV 불러오기</label> | |
| <input type="file" id="csvFileInput" accept=".csv" style="border:none;"> | |
| </div> | |
| <button class="add-btn" style="background-color: var(--text-light); padding: 8px 15px; font-size: 0.9rem;" onclick="importCSV()">불러오기</button> | |
| <button class="add-btn" style="background-color: var(--primary); padding: 8px 15px; font-size: 0.9rem;" onclick="exportCSV()">내보내기 (다운로드)</button> | |
| </div> | |
| <div class="danger-zone"> | |
| <h4 style="color: var(--danger); margin-top:0;">⚠️ 위험 구역</h4> | |
| <p style="font-size: 0.9rem; margin-bottom: 10px;">모든 데이터를 삭제하려면 <strong>"초기화"</strong>라고 입력하세요.</p> | |
| <div style="display: flex; gap: 10px;"> | |
| <input type="text" id="resetConfirmInput" placeholder="초기화 입력" style="max-width: 150px;"> | |
| <button onclick="safeResetData()" style="background-color: var(--danger); color: white; border: none; padding: 8px 16px; border-radius: 5px; cursor: pointer;">삭제</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 입력 영역 --> | |
| <div class="card"> | |
| <h3>📅 오늘의 기록</h3> | |
| <div class="input-group"> | |
| <div class="input-wrapper"> | |
| <label>날짜</label> | |
| <input type="date" id="dateInput"> | |
| </div> | |
| <div class="input-wrapper"> | |
| <label>현재 체중 (kg)</label> | |
| <input type="number" id="weightInput" step="0.1" placeholder="예: 78.0"> | |
| </div> | |
| <button class="add-btn" onclick="addRecord()">기록하기 📝</button> | |
| </div> | |
| </div> | |
| <!-- 주간/월간 리포트 텍스트 --> | |
| <div class="text-report"> | |
| <span style="font-size: 1.2rem;">📢</span> | |
| <span id="analysisText">데이터를 입력하면 분석 리포트가 여기에 표시됩니다.</span> | |
| </div> | |
| <!-- 목표 달성 프로그레스 바 (방향 수정됨) --> | |
| <div class="card progress-card"> | |
| <h3 style="border:none; margin-bottom:10px;">🛤️ 목표 달성 현황</h3> | |
| <div class="progress-track"> | |
| <div class="progress-fill" id="progressBarFill"></div> | |
| <!-- 마커 --> | |
| <div class="progress-marker start"></div> | |
| <div class="progress-marker goal"></div> | |
| <!-- 현재 위치 표시 (이모지 & 텍스트) --> | |
| <div class="progress-indicator-emoji" id="progressEmoji">👇</div> | |
| <div class="progress-info-text" id="progressText"> | |
| 정보 로딩중... | |
| </div> | |
| </div> | |
| <div class="progress-labels"> | |
| <!-- 라벨 순서 변경: 목표(좌), 시작(우) --> | |
| <span id="labelGoal">목표: -kg</span> | |
| <span id="labelStart">시작: -kg</span> | |
| </div> | |
| </div> | |
| <!-- 🏆 업적 및 뱃지 (New) --> | |
| <div class="card"> | |
| <h3 onclick="toggleBadges()" style="cursor:pointer;">🏆 나의 업적 & 뱃지 <span style="font-size:0.8rem; color:var(--text-light);">(클릭하여 펼치기)</span></h3> | |
| <div id="badgeGrid" class="badge-grid" style="display:none;"> | |
| <!-- JS로 렌더링 됩니다 --> | |
| </div> | |
| </div> | |
| <!-- 핵심 통계 대시보드 (Expanded) --> | |
| <div class="dashboard-grid"> | |
| <div class="stat-card"> | |
| <span class="stat-icon">⚖️</span> | |
| <div class="stat-value" id="currentWeightDisplay">-</div> | |
| <div class="stat-label">현재 체중</div> | |
| <div class="stat-sub" id="bmiDisplay">BMI: -</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-icon">📉</span> | |
| <div class="stat-value" id="totalLostDisplay">-</div> | |
| <div class="stat-label">총 감량</div> | |
| <div class="stat-sub" id="percentLostDisplay">(-%)</div> | |
| </div> | |
| <!-- 남은 감량 --> | |
| <div class="stat-card"> | |
| <span class="stat-icon">🏔️</span> | |
| <div class="stat-value" id="remainingWeightDisplay">-</div> | |
| <div class="stat-label">남은 감량</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-icon">🚀</span> | |
| <div class="stat-value" id="progressPercent">-</div> | |
| <div class="stat-label">목표 달성률</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-icon">🔮</span> | |
| <div class="stat-value" id="predictedDate" style="font-size: 1.1rem; line-height: 1.8rem;">-</div> | |
| <div class="stat-label">예상 완료일</div> | |
| <div class="stat-sub" id="predictionRange" style="font-size:0.65rem;">(빠름~느림)</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-icon">🔥</span> | |
| <div class="stat-value" id="streakDisplay">-</div> | |
| <div class="stat-label">연속 감량 (Streak)</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-icon">📊</span> | |
| <div class="stat-value" id="successRateDisplay">-</div> | |
| <div class="stat-label">감량 성공률 (일일)</div> | |
| </div> | |
| <!-- New Metrics --> | |
| <div class="stat-card"> | |
| <span class="stat-icon">📏</span> | |
| <div class="stat-value" id="minMaxWeightDisplay" style="font-size: 1rem;">-</div> | |
| <div class="stat-label">최고/최저 체중</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-icon">🎢</span> | |
| <div class="stat-value" id="dailyVolatilityDisplay" style="font-size: 1rem;">-</div> | |
| <div class="stat-label">일일 최대 변동</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-icon">🗓️</span> | |
| <div class="stat-value" id="weeklyAvgDisplay">-</div> | |
| <div class="stat-label">주간 평균 감량</div> | |
| </div> | |
| <div class="stat-card"> | |
| <span class="stat-icon">🌙</span> | |
| <div class="stat-value" id="monthCompareDisplay">-</div> | |
| <div class="stat-label">지난달 대비 평균</div> | |
| </div> | |
| </div> | |
| <!-- 메인 차트 영역 --> | |
| <div class="card"> | |
| <h3>📈 체중 변화 그래프 (시계열)</h3> | |
| <div class="filter-row"> | |
| <div class="filter-group"> | |
| <button class="filter-btn" id="btn-1m" onclick="setChartFilter('1M')">1개월</button> | |
| <button class="filter-btn" id="btn-3m" onclick="setChartFilter('3M')">3개월</button> | |
| <button class="filter-btn" id="btn-all" onclick="setChartFilter('ALL')">전체</button> | |
| </div> | |
| <div class="date-range-picker"> | |
| <input type="date" id="chartStartDate" onchange="applyCustomDateRange()"> | |
| <span>~</span> | |
| <input type="date" id="chartEndDate" onchange="applyCustomDateRange()"> | |
| </div> | |
| </div> | |
| <div class="chart-container"> | |
| <canvas id="mainChart"></canvas> | |
| </div> | |
| <div style="text-align: center; margin-top: 10px; font-size: 0.85rem; color: var(--text-light);"> | |
| <label><input type="checkbox" id="showTrend" checked onchange="updateMainChart()"> 7일 이동평균선(추세) 표시</label> | |
| <span style="margin-left:15px;">※ 배경색 띠는 BMI 구간(정상/과체중/비만)을 나타냅니다.</span> | |
| </div> | |
| </div> | |
| <!-- 추가 분석 차트 (New) --> | |
| <div class="analysis-grid"> | |
| <div class="card"> | |
| <h3>📉 누적 감량 추이 (Area)</h3> | |
| <div class="mini-chart-container"> | |
| <canvas id="cumulativeChart"></canvas> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h3>📊 월별 순수 변화량</h3> | |
| <div class="mini-chart-container"> | |
| <canvas id="monthlyChangeChart"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 상세 분석 영역 --> | |
| <div class="analysis-grid"> | |
| <!-- 1. 감량 속도 --> | |
| <div class="card"> | |
| <h3>⚡ 감량 페이스 & 주간 비교</h3> | |
| <table class="data-table"> | |
| <tr><th>구분</th><th>속도 (일평균)</th></tr> | |
| <tr><td>최근 7일</td><td id="rate7Days">-</td></tr> | |
| <tr><td>최근 30일</td><td id="rate30Days">-</td></tr> | |
| </table> | |
| <div style="margin-top: 20px; text-align: center;"> | |
| <h4 style="font-size: 0.9rem; color:var(--text-light); margin-bottom: 5px;">지난주 vs 이번주 (평균 체중)</h4> | |
| <div id="weeklyCompareDisplay" style="font-size: 1.3rem; font-weight: bold;">-</div> | |
| </div> | |
| </div> | |
| <!-- 2. 요일별 통계 --> | |
| <div class="card"> | |
| <h3>📅 요일별 평균 변화량</h3> | |
| <div class="mini-chart-container"> | |
| <canvas id="dayOfWeekChart"></canvas> | |
| </div> | |
| <p style="font-size: 0.75rem; text-align: center; color: var(--text-light); margin-top:5px;">막대가 아래(녹색)일수록 살이 잘 빠지는 요일입니다.</p> | |
| </div> | |
| <!-- 3. 히스토그램 --> | |
| <div class="card"> | |
| <h3>📊 체중 구간별 빈도</h3> | |
| <div class="mini-chart-container"> | |
| <canvas id="histogramChart"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 히트맵 --> | |
| <div class="card"> | |
| <h3>🌿 감량 히트맵 (최근 1년)</h3> | |
| <div class="heatmap-container" id="heatmapGrid"></div> | |
| <div class="heatmap-legend"> | |
| <span>증량</span> <div class="legend-box" style="background: var(--heatmap-gain)"></div> | |
| <div class="legend-box" style="background: var(--heatmap-empty)"></div> | |
| <div class="legend-box" style="background: var(--heatmap-1)"></div> | |
| <div class="legend-box" style="background: var(--heatmap-2)"></div> | |
| <div class="legend-box" style="background: var(--heatmap-3)"></div> | |
| <div class="legend-box" style="background: var(--heatmap-4)"></div> <span>감량 성공</span> | |
| </div> | |
| </div> | |
| <!-- 데이터 테이블 탭 --> | |
| <div class="card"> | |
| <h3>📑 상세 리포트 & 기록</h3> | |
| <div class="filter-row"> | |
| <div class="filter-group"> | |
| <button class="filter-btn active" id="tab-btn-monthly" onclick="switchTab('tab-monthly')">월별 결산</button> | |
| <button class="filter-btn" id="tab-btn-weekly" onclick="switchTab('tab-weekly')">주별 결산</button> | |
| <button class="filter-btn" id="tab-btn-milestone" onclick="switchTab('tab-milestone')">마일스톤</button> | |
| <button class="filter-btn" id="tab-btn-history" onclick="switchTab('tab-history')">전체 기록</button> | |
| </div> | |
| </div> | |
| <!-- 탭 내용 --> | |
| <div id="tab-monthly" class="tab-content"> | |
| <table class="data-table"> | |
| <thead><tr><th>월</th><th>시작</th><th>종료</th><th>변화량</th><th>평균</th></tr></thead> | |
| <tbody id="monthlyTableBody"></tbody> | |
| </table> | |
| </div> | |
| <div id="tab-weekly" class="tab-content" style="display:none;"> | |
| <table class="data-table"> | |
| <thead><tr><th>주차 (시작일)</th><th>평균 체중</th><th>변화</th></tr></thead> | |
| <tbody id="weeklyTableBody"></tbody> | |
| </table> | |
| </div> | |
| <div id="tab-milestone" class="tab-content" style="display:none;"> | |
| <table class="data-table"> | |
| <thead><tr><th>달성 목표</th><th>달성일</th><th>소요 기간</th></tr></thead> | |
| <tbody id="milestoneTableBody"></tbody> | |
| </table> | |
| </div> | |
| <div id="tab-history" class="tab-content" style="display:none; max-height: 400px; overflow-y: auto;"> | |
| <table class="data-table"> | |
| <thead><tr><th>날짜</th><th>체중</th><th>변화</th><th>관리</th></tr></thead> | |
| <tbody id="historyList"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // --- 1. 전역 변수 및 설정 --- | |
| const STORAGE_KEY = 'diet_pro_records'; | |
| const SETTINGS_KEY = 'diet_pro_settings'; | |
| const FILTER_KEY = 'diet_pro_filter_mode'; // 필터 저장 키 | |
| let records = JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; | |
| let settings = JSON.parse(localStorage.getItem(SETTINGS_KEY)) || { | |
| height: 175, startWeight: 78.5, goal1: 70 | |
| }; | |
| // 차트 인스턴스 전역 변수 | |
| let mainChart = null; | |
| let dowChart = null; | |
| let histChart = null; | |
| let cumulativeChart = null; // New | |
| let monthlyChangeChart = null; // New | |
| // 차트 필터 모드 (저장된 값 로드) | |
| let chartFilterMode = localStorage.getItem(FILTER_KEY) || 'ALL'; | |
| let customStart = null; | |
| let customEnd = null; | |
| // --- 2. 초기화 --- | |
| window.onload = function() { | |
| // 날짜 입력창 오늘로 설정 | |
| document.getElementById('dateInput').valueAsDate = new Date(); | |
| // 설정값 로드 | |
| document.getElementById('userHeight').value = settings.height; | |
| document.getElementById('startWeight').value = settings.startWeight; | |
| document.getElementById('goal1Weight').value = settings.goal1; | |
| // 저장된 필터 버튼 활성화 | |
| updateFilterButtons(); | |
| // UI 렌더링 시작 | |
| updateUI(); | |
| }; | |
| // --- 3. 기본 기능 (저장, 삭제, CSV) --- | |
| function toggleSettings() { | |
| const panel = document.getElementById('settingsPanel'); | |
| panel.style.display = panel.style.display === 'block' ? 'none' : 'block'; | |
| } | |
| function toggleBadges() { | |
| const grid = document.getElementById('badgeGrid'); | |
| grid.style.display = grid.style.display === 'grid' ? 'none' : 'grid'; | |
| } | |
| function saveSettings() { | |
| settings.height = parseFloat(document.getElementById('userHeight').value); | |
| settings.startWeight = parseFloat(document.getElementById('startWeight').value); | |
| settings.goal1 = parseFloat(document.getElementById('goal1Weight').value); | |
| localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); | |
| toggleSettings(); | |
| updateUI(); | |
| alert('설정이 저장되었습니다.'); | |
| } | |
| function addRecord() { | |
| const date = document.getElementById('dateInput').value; | |
| const weight = parseFloat(document.getElementById('weightInput').value); | |
| if (!date || isNaN(weight)) return alert('날짜와 체중을 올바르게 입력해주세요.'); | |
| const existingIndex = records.findIndex(r => r.date === date); | |
| if (existingIndex >= 0) records[existingIndex].weight = weight; | |
| else records.push({ date, weight }); | |
| // 날짜순 정렬 | |
| records.sort((a, b) => new Date(a.date) - new Date(b.date)); | |
| saveRecords(); | |
| document.getElementById('weightInput').value = ''; | |
| updateUI(); | |
| } | |
| function deleteRecord(date) { | |
| if(confirm('이 날짜의 기록을 삭제하시겠습니까?')) { | |
| records = records.filter(r => r.date !== date); | |
| saveRecords(); | |
| updateUI(); | |
| } | |
| } | |
| function safeResetData() { | |
| const input = document.getElementById('resetConfirmInput').value; | |
| if (input === "초기화") { | |
| localStorage.removeItem(STORAGE_KEY); | |
| records = []; | |
| document.getElementById('resetConfirmInput').value = ''; | |
| updateUI(); | |
| alert('초기화되었습니다.'); | |
| } else { | |
| alert('"초기화"라고 정확히 입력해주세요.'); | |
| } | |
| } | |
| function saveRecords() { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify(records)); | |
| } | |
| function importCSV() { | |
| const file = document.getElementById('csvFileInput').files[0]; | |
| if (!file) return alert('파일을 선택해주세요.'); | |
| const reader = new FileReader(); | |
| reader.onload = function(e) { | |
| const lines = e.target.result.split('\n'); | |
| let count = 0; | |
| for(let i=1; i<lines.length; i++) { // 헤더 스킵 | |
| const parts = lines[i].split(','); | |
| if(parts.length >= 2) { | |
| const d = parts[0].trim(); | |
| const w = parseFloat(parts[1]); | |
| if(d && !isNaN(w)) { | |
| const idx = records.findIndex(r => r.date === d); | |
| if(idx >= 0) records[idx].weight = w; | |
| else records.push({ date: d, weight: w }); | |
| count++; | |
| } | |
| } | |
| } | |
| records.sort((a, b) => new Date(a.date) - new Date(b.date)); | |
| saveRecords(); | |
| updateUI(); | |
| alert(`${count}건의 데이터를 불러왔습니다.`); | |
| }; | |
| reader.readAsText(file); | |
| } | |
| function exportCSV() { | |
| if (records.length === 0) return alert('내보낼 데이터가 없습니다.'); | |
| let csvContent = "data:text/csv;charset=utf-8,Date,Weight\n"; | |
| records.forEach(row => { | |
| csvContent += `${row.date},${row.weight}\n`; | |
| }); | |
| const encodedUri = encodeURI(csvContent); | |
| const link = document.createElement("a"); | |
| link.setAttribute("href", encodedUri); | |
| link.setAttribute("download", "diet_records_backup.csv"); | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| } | |
| // --- 4. 메인 렌더링 함수 (updateUI) --- | |
| function updateUI() { | |
| // 데이터가 없어도 기본 값 표시를 위해 실행 | |
| renderStats(); | |
| renderAnalysisText(); | |
| // 차트 업데이트 | |
| updateMainChart(); | |
| updateDayOfWeekChart(); | |
| updateHistogram(); | |
| updateCumulativeChart(); // New | |
| updateMonthlyChangeChart(); // New | |
| // 테이블, 히트맵, 뱃지 | |
| renderHeatmap(); | |
| renderAllTables(); | |
| renderBadges(); // New | |
| } | |
| // --- 5. 통계 및 텍스트 리포트 --- | |
| function renderStats() { | |
| const currentW = records.length > 0 ? records[records.length-1].weight : settings.startWeight; | |
| const totalLost = (settings.startWeight - currentW).toFixed(1); | |
| document.getElementById('currentWeightDisplay').innerText = currentW + 'kg'; | |
| document.getElementById('totalLostDisplay').innerText = `${totalLost}kg`; | |
| document.getElementById('totalLostDisplay').style.color = totalLost > 0 ? 'var(--primary-dark)' : (totalLost < 0 ? 'var(--danger)' : 'var(--text)'); | |
| let pct = 0; | |
| const totalGap = settings.startWeight - settings.goal1; | |
| const currentGap = settings.startWeight - currentW; | |
| if(totalGap !== 0) pct = Math.max(0, Math.min(100, (currentGap / totalGap) * 100)); | |
| document.getElementById('progressPercent').innerText = pct.toFixed(1) + '%'; | |
| // 남은 감량 계산 | |
| const remaining = (currentW - settings.goal1).toFixed(1); | |
| const remainingDisplay = document.getElementById('remainingWeightDisplay'); | |
| remainingDisplay.innerText = `${remaining > 0 ? remaining : 0}kg`; | |
| remainingDisplay.style.color = remaining <= 0 ? 'var(--secondary-dark)' : 'var(--text)'; | |
| // BMI 계산 | |
| const hMeter = settings.height / 100; | |
| const bmi = (currentW / (hMeter * hMeter)).toFixed(1); | |
| let bmiLabel = '정상'; | |
| if(bmi < 18.5) bmiLabel = '저체중'; | |
| else if(bmi >= 23 && bmi < 25) bmiLabel = '과체중'; | |
| else if(bmi >= 25) bmiLabel = '비만'; | |
| document.getElementById('bmiDisplay').innerText = `BMI: ${bmi} (${bmiLabel})`; | |
| // 감량 퍼센트 | |
| const percentLost = ((settings.startWeight - currentW) / settings.startWeight * 100).toFixed(1); | |
| document.getElementById('percentLostDisplay').innerText = `(${percentLost > 0 ? '-' : '+'}${Math.abs(percentLost)}%)`; | |
| // 상단 프로그레스 바 업데이트 | |
| updateProgressBar(currentW, totalLost, pct, remaining); | |
| document.getElementById('streakDisplay').innerText = calculateStreak() + '일'; | |
| document.getElementById('successRateDisplay').innerText = calculateSuccessRate() + '%'; | |
| // 예측 시나리오 | |
| const pred = calculateScenarios(currentW); | |
| document.getElementById('predictedDate').innerText = pred.avg; | |
| document.getElementById('predictionRange').innerText = pred.range; | |
| // 속도 표 | |
| document.getElementById('rate7Days').innerText = getRate(7); | |
| document.getElementById('rate30Days').innerText = getRate(30); | |
| document.getElementById('weeklyCompareDisplay').innerText = getWeeklyComparison(); | |
| // New Metrics | |
| const extremes = calculateExtremes(); | |
| document.getElementById('minMaxWeightDisplay').innerHTML = ` | |
| <span style="color:var(--danger)">${extremes.max}</span> / | |
| <span style="color:var(--primary)">${extremes.min}</span> | |
| `; | |
| const vol = calculateDailyVolatility(); | |
| document.getElementById('dailyVolatilityDisplay').innerHTML = ` | |
| <span style="color:var(--primary)">▼${vol.drop}</span> / | |
| <span style="color:var(--danger)">▲${vol.gain}</span> | |
| `; | |
| document.getElementById('weeklyAvgDisplay').innerText = calculateWeeklyAvg() + 'kg'; | |
| const monComp = calculateMonthlyComparison(); | |
| document.getElementById('monthCompareDisplay').innerText = monComp; | |
| document.getElementById('monthCompareDisplay').style.color = monComp.includes('▼') ? 'var(--primary)' : (monComp.includes('▲') ? 'var(--danger)' : 'var(--text)'); | |
| } | |
| // 프로그레스 바 렌더링 함수 | |
| function updateProgressBar(current, lost, percent, remaining) { | |
| const fill = document.getElementById('progressBarFill'); | |
| const emoji = document.getElementById('progressEmoji'); | |
| const text = document.getElementById('progressText'); | |
| const labelStart = document.getElementById('labelStart'); | |
| const labelGoal = document.getElementById('labelGoal'); | |
| // 라벨 업데이트 | |
| labelStart.innerText = `시작: ${settings.startWeight}kg`; | |
| labelGoal.innerText = `목표: ${settings.goal1}kg`; | |
| // 퍼센트 Clamp (0~100) | |
| let visualPercent = percent; | |
| if(visualPercent < 0) visualPercent = 0; | |
| if(visualPercent > 100) visualPercent = 100; | |
| // 스타일 적용 (오른쪽에서부터 채움) | |
| fill.style.width = `${visualPercent}%`; | |
| // 이모지 및 텍스트 위치: 오른쪽 끝 기준 | |
| emoji.style.left = 'auto'; | |
| emoji.style.right = `${visualPercent}%`; | |
| text.style.left = 'auto'; | |
| text.style.right = `${visualPercent}%`; | |
| // 텍스트 업데이트 (내용은 동일) | |
| const lostP = percent.toFixed(1); | |
| const remainP = (100 - percent).toFixed(1); | |
| const safeRemain = remaining > 0 ? remaining : 0; | |
| const safeRemainP = remainP > 0 ? remainP : 0; | |
| text.innerHTML = ` | |
| <strong>${current}kg</strong><br> | |
| 감량: ${lost}kg (${lostP}%)<br> | |
| 남은: ${safeRemain}kg (${safeRemainP}%) | |
| `; | |
| } | |
| function renderAnalysisText() { | |
| const el = document.getElementById('analysisText'); | |
| if (records.length < 2) { | |
| el.innerText = "데이터가 2개 이상 쌓이면 분석을 시작합니다. 화이팅!"; | |
| return; | |
| } | |
| const last = records[records.length-1]; | |
| const prev = records[records.length-2]; | |
| const diff = (last.weight - prev.weight).toFixed(1); | |
| if (diff < 0) el.innerText = `어제보다 ${Math.abs(diff)}kg 빠졌습니다! 이대로 쭉 가봅시다! 🔥`; | |
| else if (diff > 0) el.innerText = `약간 증량(${diff}kg)했지만 괜찮습니다. 장기적인 추세가 중요합니다.`; | |
| else el.innerText = `체중 유지 중입니다. 꾸준함이 답입니다.`; | |
| } | |
| // --- 6. 분석 계산 로직 --- | |
| function calculateStreak() { | |
| if (records.length < 2) return 0; | |
| let maxS = 0, curS = 0; | |
| for(let i=1; i<records.length; i++) { | |
| if(records[i].weight < records[i-1].weight) curS++; | |
| else curS = 0; | |
| if(curS > maxS) maxS = curS; | |
| } | |
| return maxS; | |
| } | |
| function calculateSuccessRate() { | |
| if (records.length < 2) return 0; | |
| let success = 0; | |
| for(let i=1; i<records.length; i++) { | |
| if(records[i].weight < records[i-1].weight) success++; | |
| } | |
| return Math.round((success / (records.length - 1)) * 100); | |
| } | |
| function calculateScenarios(currentW) { | |
| if(currentW <= settings.goal1) return { avg: "달성 완료! 🎉", range: "" }; | |
| if(records.length < 5) return { avg: "데이터 수집 중...", range: "" }; | |
| const recent = records.slice(-30); // 최근 30개 | |
| if(recent.length < 2) return { avg: "분석 중...", range: "" }; | |
| // 평균 속도, 최고 속도(상위 20%), 최저 속도(하위 20%) 계산을 위한 일별 변화량 배열 | |
| let diffs = []; | |
| for(let i=1; i<recent.length; i++) { | |
| const dayDiff = (new Date(recent[i].date) - new Date(recent[i-1].date))/(1000*3600*24); | |
| if(dayDiff > 0) diffs.push((recent[i-1].weight - recent[i].weight) / dayDiff); // 양수면 감량 | |
| } | |
| // 감량한 날만 고려하거나 전체 평균 고려. 보수적으로 전체 평균 사용. | |
| const first = recent[0]; | |
| const last = recent[recent.length-1]; | |
| const days = (new Date(last.date) - new Date(first.date)) / (1000*3600*24); | |
| const totalDiff = first.weight - last.weight; | |
| const avgRate = totalDiff / days; // kg per day | |
| if(avgRate <= 0) return { avg: "증량/유지세 🤔", range: "식단 조절 필요" }; | |
| const remain = currentW - settings.goal1; | |
| const daysLeftAvg = Math.ceil(remain / avgRate); | |
| // Optimistic: 최근 가장 빨랐던 7일 구간 속도 | |
| // Pessimistic: avgRate * 0.7 | |
| const fastRate = avgRate * 1.5; | |
| const slowRate = avgRate * 0.7; | |
| const dAvg = new Date(); dAvg.setDate(dAvg.getDate() + daysLeftAvg); | |
| const dFast = new Date(); dFast.setDate(dFast.getDate() + Math.ceil(remain / fastRate)); | |
| const dSlow = new Date(); dSlow.setDate(dSlow.getDate() + Math.ceil(remain / slowRate)); | |
| const formatDate = (d) => `${d.getMonth()+1}/${d.getDate()}`; | |
| return { | |
| avg: `${formatDate(dAvg)} (${daysLeftAvg}일 후)`, | |
| range: `최적 ${formatDate(dFast)} ~ 보수 ${formatDate(dSlow)}` | |
| }; | |
| } | |
| function calculateExtremes() { | |
| if(records.length === 0) return { min: '-', max: '-' }; | |
| const weights = records.map(r => r.weight); | |
| return { min: Math.min(...weights).toFixed(1) + 'kg', max: Math.max(...weights).toFixed(1) + 'kg' }; | |
| } | |
| function calculateDailyVolatility() { | |
| if(records.length < 2) return { drop: '-', gain: '-' }; | |
| let maxDrop = 0; | |
| let maxGain = 0; | |
| for(let i=1; i<records.length; i++) { | |
| const diff = records[i].weight - records[i-1].weight; | |
| const dayDiff = (new Date(records[i].date) - new Date(records[i-1].date))/(1000*3600*24); | |
| if(dayDiff === 1) { // 연속된 날짜만 계산 | |
| if(diff < 0 && Math.abs(diff) > maxDrop) maxDrop = Math.abs(diff); | |
| if(diff > 0 && diff > maxGain) maxGain = diff; | |
| } | |
| } | |
| return { drop: maxDrop.toFixed(1), gain: maxGain.toFixed(1) }; | |
| } | |
| function calculateWeeklyAvg() { | |
| if(records.length < 7) return '-'; | |
| // 전체 기간 주간 평균 감량 | |
| const first = records[0]; | |
| const last = records[records.length-1]; | |
| const weeks = (new Date(last.date) - new Date(first.date)) / (1000*3600*24*7); | |
| if(weeks < 1) return '-'; | |
| const totalLost = first.weight - last.weight; | |
| return (totalLost / weeks).toFixed(1); | |
| } | |
| function calculateMonthlyComparison() { | |
| if(records.length === 0) return '-'; | |
| const now = new Date(); | |
| const thisMonthKey = now.toISOString().slice(0, 7); | |
| const lastMonthDate = new Date(); lastMonthDate.setMonth(now.getMonth()-1); | |
| const lastMonthKey = lastMonthDate.toISOString().slice(0, 7); | |
| const thisMonthRecs = records.filter(r => r.date.startsWith(thisMonthKey)); | |
| const lastMonthRecs = records.filter(r => r.date.startsWith(lastMonthKey)); | |
| if(thisMonthRecs.length === 0 || lastMonthRecs.length === 0) return '-'; | |
| const avgThis = thisMonthRecs.reduce((a,b)=>a+b.weight,0)/thisMonthRecs.length; | |
| const avgLast = lastMonthRecs.reduce((a,b)=>a+b.weight,0)/lastMonthRecs.length; | |
| const diff = avgThis - avgLast; | |
| return `${diff > 0 ? '▲' : '▼'} ${Math.abs(diff).toFixed(1)}kg`; | |
| } | |
| function getRate(d) { | |
| const startD = new Date(); startD.setDate(startD.getDate()-d); | |
| const rel = records.filter(r => new Date(r.date) >= startD); | |
| if(rel.length < 2) return "-"; | |
| const diff = rel[rel.length-1].weight - rel[0].weight; | |
| const days = (new Date(rel[rel.length-1].date) - new Date(rel[0].date))/(1000*3600*24); | |
| if(days===0) return "-"; | |
| const g = ((diff/days)*1000).toFixed(0); | |
| return `${g > 0 ? '+' : ''}${g}g / 일`; | |
| } | |
| function getWeeklyComparison() { | |
| const now = new Date(); | |
| const d7 = new Date(now); d7.setDate(now.getDate()-7); | |
| const d14 = new Date(now); d14.setDate(now.getDate()-14); | |
| const thisW = records.filter(r => new Date(r.date) >= d7); | |
| const lastW = records.filter(r => { const d = new Date(r.date); return d >= d14 && d < d7; }); | |
| if(thisW.length === 0 || lastW.length === 0) return "데이터 부족"; | |
| const avgT = thisW.reduce((a,b)=>a+b.weight,0)/thisW.length; | |
| const avgL = lastW.reduce((a,b)=>a+b.weight,0)/lastW.length; | |
| const diff = (avgT - avgL).toFixed(2); | |
| const icon = diff < 0 ? '🔻' : (diff > 0 ? '🔺' : '➖'); | |
| return `${icon} ${Math.abs(diff)}kg`; | |
| } | |
| // --- 7. 차트 그리기 함수들 --- | |
| function updateFilterButtons() { | |
| document.getElementById('btn-1m').className = 'filter-btn' + (chartFilterMode==='1M'?' active':''); | |
| document.getElementById('btn-3m').className = 'filter-btn' + (chartFilterMode==='3M'?' active':''); | |
| document.getElementById('btn-all').className = 'filter-btn' + (chartFilterMode==='ALL'?' active':''); | |
| } | |
| function setChartFilter(mode) { | |
| chartFilterMode = mode; | |
| localStorage.setItem(FILTER_KEY, mode); // 필터 저장 | |
| updateFilterButtons(); | |
| updateMainChart(); | |
| } | |
| function applyCustomDateRange() { | |
| const s = document.getElementById('chartStartDate').value; | |
| const e = document.getElementById('chartEndDate').value; | |
| if(s && e) { | |
| chartFilterMode = 'CUSTOM'; | |
| customStart = s; customEnd = e; | |
| localStorage.setItem(FILTER_KEY, 'CUSTOM'); | |
| document.querySelectorAll('.filter-group .filter-btn').forEach(b=>b.classList.remove('active')); | |
| updateMainChart(); | |
| } | |
| } | |
| function getFilteredData() { | |
| if(records.length === 0) return []; | |
| let start = new Date(records[0].date); | |
| let end = new Date(); // 오늘 | |
| if(chartFilterMode === '1M') { start = new Date(); start.setMonth(start.getMonth()-1); } | |
| else if(chartFilterMode === '3M') { start = new Date(); start.setMonth(start.getMonth()-3); } | |
| else if(chartFilterMode === 'CUSTOM' && customStart) { start = new Date(customStart); end = new Date(customEnd); } | |
| return records.filter(r => { | |
| const d = new Date(r.date); | |
| return d >= start && d <= end; | |
| }); | |
| } | |
| function updateMainChart() { | |
| const ctx = document.getElementById('mainChart').getContext('2d'); | |
| const data = getFilteredData(); | |
| const showTrend = document.getElementById('showTrend').checked; | |
| const points = data.map(r => ({ x: r.date, y: r.weight })); | |
| // BMI Bands Calculation | |
| const h = settings.height / 100; | |
| const w185 = 18.5 * h * h; // 저체중 경계 | |
| const w23 = 23 * h * h; // 정상/과체중 경계 (아시아 기준 통상 23) | |
| const w25 = 25 * h * h; // 비만 경계 | |
| // 날짜 범위 전체에 밴드를 그리기 위해 시작/끝점 필요 | |
| const chartStart = points.length ? points[0].x : new Date(); | |
| const chartEnd = points.length ? points[points.length-1].x : new Date(); | |
| // 추세선(이동평균) | |
| const trend = []; | |
| if(showTrend) { | |
| for(let i=0; i<points.length; i++) { | |
| let sum=0, cnt=0; | |
| for(let j=0; j<7; j++) { | |
| if(i-j >= 0) { sum+=points[i-j].y; cnt++; } | |
| } | |
| trend.push({ x: points[i].x, y: sum/cnt }); | |
| } | |
| } | |
| if(mainChart) mainChart.destroy(); | |
| mainChart = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| datasets: [ | |
| // BMI 배경 밴드 (순서 중요: 맨 먼저 그려서 뒤로 보냄) | |
| { | |
| label: '비만', | |
| data: [{x: chartStart, y: 150}, {x: chartEnd, y: 150}], // 상한선 크게 | |
| fill: { target: {value: w25}, above: 'rgba(244, 67, 54, 0.05)' }, // 25 이상 | |
| borderColor: 'transparent', pointRadius: 0 | |
| }, | |
| { | |
| label: '과체중', | |
| data: [{x: chartStart, y: w25}, {x: chartEnd, y: w25}], | |
| fill: { target: {value: w23}, above: 'rgba(255, 152, 0, 0.05)' }, // 23~25 | |
| borderColor: 'transparent', pointRadius: 0 | |
| }, | |
| { | |
| label: '정상', | |
| data: [{x: chartStart, y: w23}, {x: chartEnd, y: w23}], | |
| fill: { target: {value: w185}, above: 'rgba(76, 175, 80, 0.05)' }, // 18.5~23 | |
| borderColor: 'transparent', pointRadius: 0 | |
| }, | |
| // 실제 데이터 | |
| { | |
| label: '체중', | |
| data: points, | |
| borderColor: '#4CAF50', | |
| backgroundColor: 'rgba(76, 175, 80, 0.1)', | |
| fill: false, | |
| tension: 0.1, | |
| pointRadius: 3 | |
| }, | |
| ...(showTrend && trend.length > 0 ? [{ | |
| label: '7일 추세', | |
| data: trend, | |
| borderColor: '#FF9800', | |
| borderWidth: 2, | |
| pointRadius: 0, | |
| fill: false, | |
| tension: 0.4 | |
| }] : []), | |
| { | |
| label: '목표', | |
| data: data.length ? [{x: data[0].date, y: settings.goal1}, {x: data[data.length-1].date, y: settings.goal1}] : [], | |
| borderColor: '#2196F3', | |
| borderDash: [5,5], | |
| pointRadius: 0, | |
| borderWidth: 1 | |
| } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { | |
| x: { | |
| type: 'time', | |
| time: { unit: 'day', displayFormats: { day: 'MM/dd' } } | |
| }, | |
| y: { | |
| // 사용자 실제 데이터 중 최대값 찾기 (없으면 시작 체중 사용) | |
| max: points.length > 0 | |
| ? Math.ceil(Math.max(...points.map(p => p.y), settings.startWeight)) + 1 | |
| : settings.startWeight + 1, | |
| // 최소값은 목표 체중보다 조금 아래로 | |
| suggestedMin: settings.goal1 - 2 | |
| } | |
| }, | |
| plugins: { | |
| tooltip: { mode: 'index', intersect: false }, | |
| legend: { | |
| labels: { | |
| filter: function(item, chart) { | |
| // BMI 배경 범례 숨기기 | |
| return !['비만', '과체중', '정상'].includes(item.text); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| function updateDayOfWeekChart() { | |
| if(records.length < 2) return; | |
| const sums = [0,0,0,0,0,0,0]; // 일~토 | |
| const counts = [0,0,0,0,0,0,0]; | |
| for(let i=1; i<records.length; i++) { | |
| const diff = records[i].weight - records[i-1].weight; | |
| const day = new Date(records[i].date).getDay(); | |
| sums[day] += diff; | |
| counts[day]++; | |
| } | |
| const avgs = sums.map((s, i) => counts[i] ? s/counts[i] : 0); | |
| if(dowChart) dowChart.destroy(); | |
| const ctx = document.getElementById('dayOfWeekChart').getContext('2d'); | |
| dowChart = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: ['일','월','화','수','목','금','토'], | |
| datasets: [{ | |
| label: '평균 변화(kg)', | |
| data: avgs, | |
| backgroundColor: avgs.map(v => v>0 ? '#ffcdd2':'#c8e6c9'), | |
| borderColor: avgs.map(v => v>0 ? '#e57373':'#81c784'), | |
| borderWidth: 1 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { legend: { display: false } } | |
| } | |
| }); | |
| } | |
| function updateHistogram() { | |
| if(records.length === 0) return; | |
| const weights = records.map(r => r.weight); | |
| const min = Math.floor(Math.min(...weights)); | |
| const max = Math.ceil(Math.max(...weights)); | |
| const labels = []; | |
| const data = []; | |
| for(let i=min; i<=max; i++) { | |
| labels.push(i + 'kg대'); | |
| data.push(weights.filter(w => Math.floor(w) === i).length); | |
| } | |
| if(histChart) histChart.destroy(); | |
| const ctx = document.getElementById('histogramChart').getContext('2d'); | |
| histChart = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: labels, | |
| datasets: [{ | |
| label: '일수', | |
| data: data, | |
| backgroundColor: '#2196F3', | |
| borderRadius: 4 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { legend: { display: false } } | |
| } | |
| }); | |
| } | |
| function updateCumulativeChart() { | |
| if(records.length === 0) return; | |
| const ctx = document.getElementById('cumulativeChart').getContext('2d'); | |
| const points = records.map(r => ({ | |
| x: r.date, | |
| y: (settings.startWeight - r.weight).toFixed(2) // 누적 감량량 | |
| })); | |
| if(cumulativeChart) cumulativeChart.destroy(); | |
| cumulativeChart = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| datasets: [{ | |
| label: '누적 감량(kg)', | |
| data: points, | |
| borderColor: '#9C27B0', | |
| backgroundColor: 'rgba(156, 39, 176, 0.2)', | |
| fill: true, | |
| tension: 0.2, | |
| pointRadius: 1 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { | |
| x: { type: 'time', time: { unit: 'month' } }, | |
| y: { beginAtZero: true } | |
| }, | |
| plugins: { legend: { display: false } } | |
| } | |
| }); | |
| } | |
| function updateMonthlyChangeChart() { | |
| if(records.length === 0) return; | |
| const ctx = document.getElementById('monthlyChangeChart').getContext('2d'); | |
| const months = {}; | |
| records.forEach(r => { | |
| const key = r.date.substring(0, 7); | |
| if(!months[key]) months[key] = []; | |
| months[key].push(r.weight); | |
| }); | |
| const labels = []; | |
| const data = []; | |
| const bgColors = []; | |
| Object.keys(months).sort().forEach(m => { | |
| const arr = months[m]; | |
| const change = arr[arr.length-1] - arr[0]; // 월말 - 월초 (단순화) | |
| // 더 정확히는 해당 월의 첫 기록 vs 마지막 기록 | |
| labels.push(m); | |
| data.push(change); | |
| bgColors.push(change > 0 ? '#ffcdd2' : '#bbdefb'); // 빨강/파랑 | |
| }); | |
| if(monthlyChangeChart) monthlyChangeChart.destroy(); | |
| monthlyChangeChart = new Chart(ctx, { | |
| type: 'bar', | |
| data: { | |
| labels: labels, | |
| datasets: [{ | |
| label: '월별 변화(kg)', | |
| data: data, | |
| backgroundColor: bgColors, | |
| borderWidth: 0 | |
| }] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { y: { beginAtZero: true } }, | |
| plugins: { legend: { display: false } } | |
| } | |
| }); | |
| } | |
| // --- 8. 테이블 & 히트맵 & 뱃지 렌더링 --- | |
| function renderHeatmap() { | |
| const container = document.getElementById('heatmapGrid'); | |
| container.innerHTML = ''; | |
| if(records.length === 0) return; | |
| const deltaMap = {}; | |
| for(let i=1; i<records.length; i++) { | |
| const diff = records[i].weight - records[i-1].weight; | |
| deltaMap[records[i].date] = diff; | |
| } | |
| const end = new Date(); | |
| const start = new Date(); start.setFullYear(start.getFullYear()-1); | |
| for(let d=start; d<=end; d.setDate(d.getDate()+1)) { | |
| const dateStr = d.toISOString().split('T')[0]; | |
| const div = document.createElement('div'); | |
| div.className = 'heatmap-cell'; | |
| div.title = dateStr; | |
| if(deltaMap[dateStr] !== undefined) { | |
| const val = deltaMap[dateStr]; | |
| div.title += ` (${val>0?'+':''}${val.toFixed(1)}kg)`; | |
| if(val > 0) div.style.background = 'var(--heatmap-gain)'; | |
| else if(val > -0.1) div.style.background = 'var(--heatmap-1)'; | |
| else if(val > -0.3) div.style.background = 'var(--heatmap-2)'; | |
| else if(val > -0.5) div.style.background = 'var(--heatmap-3)'; | |
| else div.style.background = 'var(--heatmap-4)'; | |
| } | |
| container.appendChild(div); | |
| } | |
| } | |
| function renderAllTables() { | |
| renderMonthlyTable(); | |
| renderWeeklyTable(); | |
| renderMilestoneTable(); | |
| renderHistoryTable(); | |
| } | |
| function renderMonthlyTable() { | |
| const months = {}; | |
| records.forEach(r => { | |
| const key = r.date.substring(0, 7); | |
| if(!months[key]) months[key] = []; | |
| months[key].push(r.weight); | |
| }); | |
| let html = ''; | |
| Object.keys(months).sort().reverse().forEach(m => { | |
| const arr = months[m]; | |
| const start = arr[0]; | |
| const end = arr[arr.length-1]; | |
| const diff = (end - start).toFixed(1); | |
| const avg = (arr.reduce((a,b)=>a+b,0)/arr.length).toFixed(1); | |
| html += `<tr><td>${m}</td><td>${start}</td><td>${end}</td><td class="${diff<=0?'neg':'pos'}">${diff}</td><td>${avg}</td></tr>`; | |
| }); | |
| document.getElementById('monthlyTableBody').innerHTML = html; | |
| } | |
| function renderWeeklyTable() { | |
| // 주차별 그룹화 (단순화: 월요일 시작 기준) | |
| const weeks = {}; | |
| records.forEach(r => { | |
| const d = new Date(r.date); | |
| // 해당 날짜의 주의 월요일 계산 | |
| const day = d.getDay(), diff = d.getDate() - day + (day == 0 ? -6:1); | |
| const monday = new Date(d.setDate(diff)); | |
| const key = monday.toISOString().split('T')[0]; | |
| if(!weeks[key]) weeks[key] = []; | |
| weeks[key].push(r.weight); | |
| }); | |
| let html = ''; | |
| Object.keys(weeks).sort().reverse().forEach(w => { | |
| const arr = weeks[w]; | |
| const avg = (arr.reduce((a,b)=>a+b,0)/arr.length).toFixed(1); | |
| // 변화량은 이 주차의 마지막 - 첫번째 | |
| const diff = (arr[arr.length-1] - arr[0]).toFixed(1); | |
| html += `<tr><td>${w} 주</td><td>${avg}kg</td><td class="${diff<=0?'neg':'pos'}">${diff}</td></tr>`; | |
| }); | |
| document.getElementById('weeklyTableBody').innerHTML = html; | |
| } | |
| function renderMilestoneTable() { | |
| let html = ''; | |
| if(records.length > 0) { | |
| let currentInt = Math.floor(records[0].weight); | |
| let startDate = new Date(records[0].date); | |
| for(let i=1; i<records.length; i++) { | |
| const w = Math.floor(records[i].weight); | |
| if(w < currentInt) { | |
| const nowD = new Date(records[i].date); | |
| const days = Math.ceil((nowD - startDate)/(1000*3600*24)); | |
| html += `<tr><td>🎉 ${w}kg대 진입</td><td>${records[i].date}</td><td>${days}일 소요</td></tr>`; | |
| currentInt = w; | |
| startDate = nowD; | |
| } | |
| } | |
| } | |
| document.getElementById('milestoneTableBody').innerHTML = html || '<tr><td colspan="3">아직 기록된 마일스톤이 없습니다.</td></tr>'; | |
| } | |
| function renderHistoryTable() { | |
| let html = ''; | |
| const rev = [...records].reverse(); | |
| rev.forEach(r => { | |
| // 원본 인덱스 찾아서 변화량 계산 | |
| const idx = records.findIndex(o => o.date === r.date); | |
| let diffStr = '-'; | |
| let cls = ''; | |
| if(idx > 0) { | |
| const d = r.weight - records[idx-1].weight; | |
| diffStr = (d>0?'+':'') + d.toFixed(1); | |
| cls = d>0?'pos':(d<0?'neg':''); | |
| } | |
| html += `<tr><td>${r.date}</td><td>${r.weight}kg</td><td class="${cls}">${diffStr}</td> | |
| <td><button style="border:none;background:none;cursor:pointer;" onclick="deleteRecord('${r.date}')">🗑️</button></td></tr>`; | |
| }); | |
| document.getElementById('historyList').innerHTML = html; | |
| } | |
| function renderBadges() { | |
| if(records.length === 0) return; | |
| const currentW = records[records.length-1].weight; | |
| const totalLost = settings.startWeight - currentW; | |
| const streak = calculateStreak(); | |
| const badges = [ | |
| { id: 'start', name: '시작이 반', icon: '🐣', condition: records.length >= 1 }, | |
| { id: 'loss3', name: '3kg 감량', icon: '🥉', condition: totalLost >= 3 }, | |
| { id: 'loss5', name: '5kg 감량', icon: '🥈', condition: totalLost >= 5 }, | |
| { id: 'loss10', name: '10kg 감량', icon: '🥇', condition: totalLost >= 10 }, | |
| { id: 'streak3', name: '작심삼일 탈출', icon: '🔥', condition: streak >= 3 }, | |
| { id: 'streak7', name: '일주일 연속', icon: '⚡', condition: streak >= 7 }, | |
| { id: 'digit', name: '앞자리 체인지', icon: '✨', condition: Math.floor(currentW/10) < Math.floor(settings.startWeight/10) }, | |
| { id: 'goal', name: '목표 달성', icon: '👑', condition: currentW <= settings.goal1 } | |
| ]; | |
| let html = ''; | |
| badges.forEach(b => { | |
| const cls = b.condition ? 'badge-item unlocked' : 'badge-item'; | |
| html += `<div class="${cls}"> | |
| <span class="badge-icon">${b.icon}</span> | |
| <span class="badge-name">${b.name}</span> | |
| </div>`; | |
| }); | |
| document.getElementById('badgeGrid').innerHTML = html; | |
| } | |
| function switchTab(tabId) { | |
| document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none'); | |
| document.getElementById(tabId).style.display = 'block'; | |
| // 탭 버튼 스타일 | |
| document.querySelectorAll('.filter-group button[id^="tab-btn"]').forEach(b => b.classList.remove('active')); | |
| // 맵핑 (간단히 구현) | |
| if(tabId.includes('monthly')) document.getElementById('tab-btn-monthly').classList.add('active'); | |
| if(tabId.includes('weekly')) document.getElementById('tab-btn-weekly').classList.add('active'); | |
| if(tabId.includes('milestone')) document.getElementById('tab-btn-milestone').classList.add('active'); | |
| if(tabId.includes('history')) document.getElementById('tab-btn-history').classList.add('active'); | |
| } | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment