Skip to content

Instantly share code, notes, and snippets.

@lunamoth
Created December 8, 2025 14:45
Show Gist options
  • Select an option

  • Save lunamoth/9abc95e39dc8e9c32b01f90100684ea9 to your computer and use it in GitHub Desktop.

Select an option

Save lunamoth/9abc95e39dc8e9c32b01f90100684ea9 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>💪 다이어트 챌린지 2025</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; /* 증량 */
}
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; }
/* 대시보드 그리드 */
.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); }
/* 텍스트 리포트 */
.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; }
@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;">📂 백업 데이터 불러오기 (CSV)</h4>
<div class="input-group" style="margin-bottom: 20px; border: 1px dashed #ccc; padding: 10px; border-radius: 10px;">
<input type="file" id="csvFileInput" accept=".csv" style="border:none;">
<button class="add-btn" style="background-color: var(--text-light); padding: 8px 15px; font-size: 0.9rem;" onclick="importCSV()">불러오기</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="dashboard-grid">
<div class="stat-card">
<span class="stat-icon">⚖️</span>
<div class="stat-value" id="currentWeightDisplay">-</div>
<div class="stat-label">현재 체중</div>
</div>
<div class="stat-card">
<span class="stat-icon">📉</span>
<div class="stat-value" id="totalLostDisplay">-</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>
<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>
</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 active" 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>
</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';
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 chartFilterMode = '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;
// UI 렌더링 시작
updateUI();
};
// --- 3. 기본 기능 (저장, 삭제, CSV) ---
function toggleSettings() {
const panel = document.getElementById('settingsPanel');
panel.style.display = panel.style.display === 'block' ? 'none' : 'block';
}
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);
}
// --- 4. 메인 렌더링 함수 (updateUI) ---
function updateUI() {
// 데이터가 없어도 기본 값 표시를 위해 실행
renderStats();
renderAnalysisText();
// 차트 업데이트 (오류 방지를 위해 try-catch 처리 가능하나 로직 수정함)
updateMainChart();
updateDayOfWeekChart();
updateHistogram();
// 테이블 및 히트맵
renderHeatmap();
renderAllTables();
}
// --- 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) + '%';
document.getElementById('streakDisplay').innerText = calculateStreak() + '일';
document.getElementById('successRateDisplay').innerText = calculateSuccessRate() + '%';
document.getElementById('predictedDate').innerText = calculatePrediction(currentW);
// 속도 표
document.getElementById('rate7Days').innerText = getRate(7);
document.getElementById('rate30Days').innerText = getRate(30);
document.getElementById('weeklyCompareDisplay').innerText = getWeeklyComparison();
}
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 calculatePrediction(currentW) {
if(currentW <= settings.goal1) return "달성 완료! 🎉";
if(records.length < 5) return "데이터 수집 중...";
const day30 = new Date(); day30.setDate(day30.getDate()-30);
const recent = records.filter(r => new Date(r.date) >= day30);
if(recent.length < 2) return "분석 중...";
const first = recent[0];
const last = recent[recent.length-1];
const days = (new Date(last.date) - new Date(first.date)) / (1000*3600*24);
const diff = last.weight - first.weight;
if(diff >= 0 || days === 0) return "증량/유지세 🤔";
const rate = Math.abs(diff / days); // kg per day
const remain = currentW - settings.goal1;
const daysLeft = Math.ceil(remain / rate);
const pDate = new Date();
pDate.setDate(pDate.getDate() + daysLeft);
return `${pDate.getMonth()+1}/${pDate.getDate()} (${daysLeft}일 후)`;
}
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 setChartFilter(mode) {
chartFilterMode = mode;
document.getElementById('btn-1m').className = 'filter-btn' + (mode==='1M'?' active':'');
document.getElementById('btn-3m').className = 'filter-btn' + (mode==='3M'?' active':'');
document.getElementById('btn-all').className = 'filter-btn' + (mode==='ALL'?' active':'');
updateMainChart();
}
function applyCustomDateRange() {
const s = document.getElementById('chartStartDate').value;
const e = document.getElementById('chartEndDate').value;
if(s && e) {
chartFilterMode = 'CUSTOM';
customStart = s; customEnd = e;
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;
// 날짜 파싱 어댑터가 작동하도록 x값을 문자열 날짜로 전달
const points = data.map(r => ({ x: r.date, y: r.weight }));
// 추세선(이동평균)
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: [
{
label: '체중',
data: points,
borderColor: '#4CAF50',
backgroundColor: 'rgba(76, 175, 80, 0.1)',
fill: true,
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' }
}
}
},
plugins: {
tooltip: { mode: 'index', intersect: false }
}
}
});
}
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 } }
}
});
}
// --- 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 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