Spaces:
Running
Running
| <html lang="ko"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>ETS - 시계열 예측</title> | |
| <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>"> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg: #0f0f12; | |
| --surface: #1a1a1f; | |
| --surface-hover: #222228; | |
| --border: #2a2a32; | |
| --text: #e8e8ed; | |
| --text-muted: #8b8b96; | |
| --accent: #10b981; | |
| --accent-hover: #34d399; | |
| --success: #22c55e; | |
| --error: #ef4444; | |
| --warning: #f59e0b; | |
| --radius: 12px; | |
| } | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { font-family: 'Outfit', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; line-height: 1.6; } | |
| .top-nav { background: var(--surface); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 100; } | |
| .nav-inner { max-width: 1060px; margin: 0 auto; padding: 0 2rem; display: flex; align-items: center; gap: 2rem; height: 56px; overflow-x: auto; } | |
| .nav-brand { font-weight: 700; font-size: 1rem; color: var(--text); text-decoration: none; letter-spacing: -0.01em; white-space: nowrap; } | |
| .nav-links { display: flex; gap: 0.25rem; list-style: none; } | |
| .nav-links a { display: flex; align-items: center; gap: 0.4rem; padding: 0.5rem 0.75rem; border-radius: 8px; text-decoration: none; font-size: 0.82rem; font-weight: 500; color: var(--text-muted); transition: all 0.15s; white-space: nowrap; } | |
| .nav-links a:hover { color: var(--text); background: var(--surface-hover); } | |
| .nav-links a.active { color: var(--accent); background: rgba(16, 185, 129, 0.1); } | |
| .nav-links .model-tag { font-size: 0.6rem; padding: 0.1rem 0.35rem; border-radius: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; } | |
| .tag-dl { background: rgba(99, 102, 241, 0.15); color: #a5b4fc; } | |
| .tag-stat { background: rgba(34, 197, 94, 0.15); color: #86efac; } | |
| .tag-ml { background: rgba(245, 158, 11, 0.15); color: #fcd34d; } | |
| .tag-classic { background: rgba(6, 182, 212, 0.15); color: #67e8f9; } | |
| .tag-ets { background: rgba(16, 185, 129, 0.15); color: #6ee7b7; } | |
| .tag-theta { background: rgba(168, 85, 247, 0.15); color: #c4b5fd; } | |
| .container { max-width: 1060px; margin: 0 auto; padding: 2rem; } | |
| header { margin-bottom: 2rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--border); } | |
| h1 { font-size: 1.75rem; font-weight: 700; letter-spacing: -0.02em; background: linear-gradient(135deg, #fff 0%, #6ee7b7 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } | |
| .subtitle { margin-top: 0.25rem; font-size: 0.875rem; color: var(--text-muted); } | |
| .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.5rem; margin-bottom: 1.25rem; } | |
| .card-title { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 1rem; } | |
| .upload-zone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 1.75rem; text-align: center; cursor: pointer; transition: all 0.2s; background: rgba(16, 185, 129, 0.03); } | |
| .upload-zone:hover, .upload-zone.dragover { border-color: var(--accent); background: rgba(16, 185, 129, 0.08); } | |
| .upload-zone input { display: none; } | |
| .upload-zone .icon { font-size: 2.25rem; margin-bottom: 0.35rem; opacity: 0.7; } | |
| .upload-zone p { color: var(--text-muted); font-size: 0.875rem; } | |
| .upload-zone .file-name { margin-top: 0.4rem; font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: var(--accent); } | |
| .options-row { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-top: 1rem; align-items: flex-end; } | |
| .opt-group { display: flex; flex-direction: column; gap: 0.3rem; } | |
| .opt-group label { font-size: 0.75rem; color: var(--text-muted); font-weight: 500; } | |
| .opt-group input[type="number"], .opt-group select { | |
| padding: 0.5rem 0.7rem; border: 1px solid var(--border); border-radius: 8px; | |
| background: var(--bg); color: var(--text); font-size: 0.9rem; font-family: inherit; width: 110px; | |
| } | |
| .opt-group input:focus, .opt-group select:focus { outline: none; border-color: var(--accent); } | |
| .opt-check { display: flex; align-items: center; gap: 0.4rem; height: 38px; } | |
| .opt-check input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; flex-shrink: 0; } | |
| .opt-check label { font-size: 0.8rem; color: var(--text); cursor: pointer; white-space: nowrap; } | |
| .actions { display: flex; gap: 0.75rem; margin-top: 1rem; flex-wrap: wrap; } | |
| .btn { padding: 0.6rem 1.2rem; border: none; border-radius: 8px; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: all 0.2s; font-family: inherit; text-decoration: none; display: inline-flex; align-items: center; } | |
| .btn-primary { background: var(--accent); color: white; } | |
| .btn-primary:hover:not(:disabled) { background: var(--accent-hover); transform: translateY(-1px); } | |
| .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .btn-secondary { background: var(--surface-hover); color: var(--text); border: 1px solid var(--border); } | |
| .btn-secondary:hover { background: var(--border); } | |
| .status { margin-top: 1rem; padding: 0.7rem 1rem; border-radius: 8px; font-size: 0.875rem; display: none; } | |
| .status.error { display: block; background: rgba(239,68,68,0.12); color: var(--error); border: 1px solid rgba(239,68,68,0.25); } | |
| .status.loading { display: block; background: rgba(16,185,129,0.08); color: var(--accent); border: 1px solid rgba(16,185,129,0.2); } | |
| .status.success { display: block; background: rgba(34,197,94,0.08); color: var(--success); border: 1px solid rgba(34,197,94,0.2); } | |
| .results-section { margin-top: 1.5rem; display: none; } | |
| .results-section.visible { display: block; } | |
| .model-info { margin-bottom: 0.75rem; padding: 0.6rem 1rem; background: rgba(16, 185, 129, 0.06); border: 1px solid rgba(16, 185, 129, 0.15); border-radius: 8px; font-size: 0.85rem; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; } | |
| .metrics-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.6rem; } | |
| @media (max-width: 800px) { .metrics-grid { grid-template-columns: repeat(3, 1fr); } } | |
| .metric-card { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 0.5rem; text-align: center; overflow: hidden; } | |
| .metric-card .label { font-size: 0.65rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.2rem; } | |
| .metric-card .value { font-family: 'JetBrains Mono', monospace; font-size: 0.95rem; font-weight: 600; color: var(--accent); word-break: break-all; } | |
| .chart-container { height: 340px; } | |
| .result-table-wrap { overflow: hidden; } | |
| .result-table-wrap .card-title { padding: 0 0 0.75rem 0; margin-bottom: 0; border-bottom: 1px solid var(--border); } | |
| .result-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } | |
| .result-table th, .result-table td { padding: 0.6rem 1rem; border-bottom: 1px solid var(--border); } | |
| .result-table th { font-weight: 600; color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; text-align: left; } | |
| .result-table td:last-child, .result-table th:last-child { text-align: right; font-family: 'JetBrains Mono', monospace; } | |
| .result-table tr:last-child td { border-bottom: none; } | |
| .result-table tr:hover td { background: rgba(255,255,255,0.02); } | |
| .spinner { display: inline-block; width: 1em; height: 1em; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; vertical-align: -0.15em; margin-right: 0.4em; } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| </style> | |
| </head> | |
| <body> | |
| <nav class="top-nav"> | |
| <div class="nav-inner"> | |
| <a href="/" class="nav-brand">시계열 예측</a> | |
| <ul class="nav-links"> | |
| <li><a href="/">N-BEATS <span class="model-tag tag-dl">DL</span></a></li> | |
| <li><a href="/prophet">Prophet <span class="model-tag tag-stat">Stat</span></a></li> | |
| <li><a href="/xgboost">XGBoost <span class="model-tag tag-ml">ML</span></a></li> | |
| <li><a href="/lightgbm">LightGBM <span class="model-tag tag-ml">ML</span></a></li> | |
| <li><a href="/arima">ARIMA <span class="model-tag tag-classic">Classical</span></a></li> | |
| <li><a href="/theta">Theta <span class="model-tag tag-theta">Classical</span></a></li> | |
| <li><a href="/ets" class="active">ETS <span class="model-tag tag-ets">Smoothing</span></a></li> | |
| <li><a href="/hybrid">Hybrid <span class="model-tag" style="background: rgba(236, 72, 153, 0.15); color: #f472b6;">LLM</span></a></li> | |
| <li><a href="/timesfm">TimesFM <span class="model-tag" style="background: rgba(56, 189, 248, 0.15); color: #7dd3fc;">FM</span></a></li> | |
| </ul> | |
| </div> | |
| </nav> | |
| <div class="container"> | |
| <header> | |
| <h1>ETS (Holt-Winters) 시계열 예측</h1> | |
| <p class="subtitle">지수 평활법 기반 — 트렌드·계절성 분해, 가법/승법 모드, 감쇠 트렌드 지원 (ds, y 컬럼 필수)</p> | |
| </header> | |
| <section class="card"> | |
| <div class="card-title">데이터 업로드 및 옵션</div> | |
| <div class="upload-zone" id="uploadZone"> | |
| <input type="file" id="fileInput" accept=".csv,.xlsx,.xls,.json"> | |
| <div class="icon">📁</div> | |
| <p>CSV, Excel, JSON 파일을 드래그하거나 클릭하여 선택</p> | |
| <p class="file-name" id="fileName"></p> | |
| </div> | |
| <div class="options-row"> | |
| <div class="opt-group"> | |
| <label for="forecastPeriods">예측 기간</label> | |
| <input type="number" id="forecastPeriods" value="12" min="1" max="120"> | |
| </div> | |
| <div class="opt-group"> | |
| <label for="trainRecentN">최근 N개 학습</label> | |
| <input type="number" id="trainRecentN" value="0" min="0" max="500"> | |
| </div> | |
| <div class="opt-group"> | |
| <label for="seasonalPeriods">계절 주기</label> | |
| <input type="number" id="seasonalPeriods" value="0" min="0" max="52"> | |
| </div> | |
| <div class="opt-group"> | |
| <label for="trendType">트렌드</label> | |
| <select id="trendType"> | |
| <option value="add">가법 (Add)</option> | |
| <option value="mul">승법 (Mul)</option> | |
| <option value="none">없음</option> | |
| </select> | |
| </div> | |
| <div class="opt-group"> | |
| <label for="seasonalType">계절성</label> | |
| <select id="seasonalType"> | |
| <option value="add">가법 (Add)</option> | |
| <option value="mul">승법 (Mul)</option> | |
| <option value="none">없음</option> | |
| </select> | |
| </div> | |
| <div class="opt-check"> | |
| <input type="checkbox" id="includeEvaluation" checked> | |
| <label for="includeEvaluation">성능 평가</label> | |
| </div> | |
| <div class="opt-check"> | |
| <input type="checkbox" id="dampedTrend"> | |
| <label for="dampedTrend">감쇠 트렌드</label> | |
| </div> | |
| <div class="opt-check"> | |
| <input type="checkbox" id="autoAdjust" checked> | |
| <label for="autoAdjust">자동 조정</label> | |
| </div> | |
| <div class="opt-check"> | |
| <input type="checkbox" id="useLogScale"> | |
| <label for="useLogScale">로그 변환</label> | |
| </div> | |
| </div> | |
| <div class="actions"> | |
| <button class="btn btn-primary" id="predictBtn" disabled>예측 실행</button> | |
| <button class="btn btn-secondary" id="resetBtn">초기화</button> | |
| <button class="btn btn-secondary" id="exportBtn" disabled>엑셀 내보내기</button> | |
| <a class="btn btn-secondary" href="/download_template?file_format=csv" download="template.csv">템플릿 다운로드</a> | |
| </div> | |
| <div class="status" id="status"></div> | |
| </section> | |
| <section class="results-section" id="resultsSection"> | |
| <div class="model-info" id="modelInfo" style="display:none;"></div> | |
| <div class="card" id="metricsContainer" style="display:none;"> | |
| <div class="card-title">ETS 성능 지표 (백테스트)</div> | |
| <div class="metrics-grid" id="metricsGrid"></div> | |
| </div> | |
| <div class="card chart-container"> | |
| <div class="card-title">시계열 및 예측 결과</div> | |
| <canvas id="chart"></canvas> | |
| </div> | |
| <div class="card result-table-wrap"> | |
| <div class="card-title">예측 결과</div> | |
| <table class="result-table"> | |
| <thead><tr><th>날짜</th><th>예측값 (yhat)</th></tr></thead> | |
| <tbody id="resultTable"></tbody> | |
| </table> | |
| </div> | |
| </section> | |
| </div> | |
| <script> | |
| const uploadZone = document.getElementById('uploadZone'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const fileName = document.getElementById('fileName'); | |
| const predictBtn = document.getElementById('predictBtn'); | |
| const statusEl = document.getElementById('status'); | |
| const resultsSection = document.getElementById('resultsSection'); | |
| const resultTable = document.getElementById('resultTable'); | |
| const metricsContainer = document.getElementById('metricsContainer'); | |
| const metricsGrid = document.getElementById('metricsGrid'); | |
| const modelInfo = document.getElementById('modelInfo'); | |
| const exportBtn = document.getElementById('exportBtn'); | |
| let selectedFile = null; | |
| let chartInstance = null; | |
| let lastForecastData = null; | |
| let lastHistoricalData = null; | |
| let lastMetrics = null; | |
| uploadZone.addEventListener('click', () => fileInput.click()); | |
| fileInput.addEventListener('change', (e) => handleFile(e.target.files[0])); | |
| uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); }); | |
| uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover')); | |
| uploadZone.addEventListener('drop', (e) => { e.preventDefault(); uploadZone.classList.remove('dragover'); handleFile(e.dataTransfer.files[0]); }); | |
| function handleFile(file) { | |
| if (!file) return; | |
| const ext = file.name.split('.').pop().toLowerCase(); | |
| if (!['csv','xlsx','xls','json'].includes(ext)) { showStatus('CSV, Excel, JSON 파일만 지원합니다.', 'error'); return; } | |
| selectedFile = file; | |
| fileName.textContent = file.name; | |
| predictBtn.disabled = false; | |
| hideStatus(); | |
| } | |
| function showStatus(msg, type = 'loading') { | |
| statusEl.className = 'status ' + type; | |
| statusEl.innerHTML = type === 'loading' ? '<span class="spinner"></span>' + msg : msg; | |
| } | |
| function hideStatus() { statusEl.className = 'status'; statusEl.style.display = 'none'; } | |
| function fmtNum(v, decimals) { | |
| if (v == null || isNaN(v)) return '-'; | |
| if (Math.abs(v) >= 1000) return Number(v.toFixed(0)).toLocaleString(); | |
| return Number(v).toFixed(decimals ?? 4); | |
| } | |
| async function runPrediction() { | |
| if (!selectedFile) { showStatus('파일을 먼저 선택해 주세요.', 'error'); return; } | |
| predictBtn.disabled = true; | |
| showStatus('ETS (Holt-Winters) 예측 실행 중...', 'loading'); | |
| const formData = new FormData(); | |
| formData.append('file', selectedFile); | |
| const periods = document.getElementById('forecastPeriods').value || 12; | |
| const evalParam = document.getElementById('includeEvaluation').checked ? '&include_evaluation=true' : ''; | |
| const modelParams = { | |
| trend: document.getElementById('trendType').value, | |
| seasonal: document.getElementById('seasonalType').value, | |
| seasonal_periods: parseInt(document.getElementById('seasonalPeriods').value) || 0, | |
| damped_trend: document.getElementById('dampedTrend').checked, | |
| auto_adjust: document.getElementById('autoAdjust').checked, | |
| }; | |
| if (document.getElementById('useLogScale').checked) modelParams.use_log_scale = true; | |
| const recentN = parseInt(document.getElementById('trainRecentN').value) || 0; | |
| if (recentN > 0) modelParams.train_on_recent_n = recentN; | |
| formData.append('model_params', JSON.stringify(modelParams)); | |
| try { | |
| const res = await fetch('/api/ets/predict?forecast_periods=' + periods + evalParam, { method: 'POST', body: formData }); | |
| const data = await res.json(); | |
| if (!res.ok) throw new Error(data.detail || data.message || '예측 실패'); | |
| if (!data.success || !data.data) throw new Error(data.message || '예측 결과 없음'); | |
| showStatus(data.data.length + '개 기간 예측 완료', 'success'); | |
| displayResults(data.data, selectedFile, data.metrics); | |
| } catch (err) { | |
| showStatus(err.message || '예측 중 오류가 발생했습니다.', 'error'); | |
| } finally { | |
| predictBtn.disabled = false; | |
| } | |
| } | |
| function parseCSV(file) { | |
| return new Promise((resolve, reject) => { | |
| const processText = (text) => { | |
| const lines = text.trim().split('\n'); | |
| if (lines.length < 2) return []; | |
| const headers = lines[0].split(',').map(h => h.trim().toLowerCase()); | |
| const dsIdx = headers.indexOf('ds'), yIdx = headers.indexOf('y'); | |
| if (dsIdx < 0 || yIdx < 0) return []; | |
| const rows = []; | |
| for (let i = 1; i < lines.length; i++) { | |
| const cols = lines[i].split(','); | |
| const ds = cols[dsIdx]?.trim(), y = parseFloat(cols[yIdx]); | |
| if (ds && !isNaN(y)) rows.push({ ds, y }); | |
| } | |
| return rows; | |
| }; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| let text = e.target.result; | |
| if (text.includes('\uFFFD')) { | |
| const readerEuc = new FileReader(); | |
| readerEuc.onload = (e2) => resolve(processText(e2.target.result)); | |
| readerEuc.onerror = () => reject(new Error('파일 읽기 실패(euc-kr)')); | |
| readerEuc.readAsText(file, 'euc-kr'); | |
| } else { | |
| resolve(processText(text)); | |
| } | |
| }; | |
| reader.onerror = () => reject(new Error('파일 읽기 실패(utf-8)')); | |
| reader.readAsText(file, 'UTF-8'); | |
| }); | |
| } | |
| function exportToExcel() { | |
| if (!lastForecastData) return; | |
| const wb = XLSX.utils.book_new(); | |
| const forecastRows = lastForecastData.map(r => ({ '날짜': r.ds, '예측값': r.yhat != null ? Number(r.yhat.toFixed(2)) : '' })); | |
| const ws1 = XLSX.utils.json_to_sheet(forecastRows); | |
| ws1['!cols'] = [{ wch: 14 }, { wch: 18 }]; | |
| XLSX.utils.book_append_sheet(wb, ws1, '예측 결과'); | |
| if (lastHistoricalData && lastHistoricalData.length > 0) { | |
| const histRows = lastHistoricalData.map(r => ({ '날짜': r.ds, '실제값': r.y })); | |
| const ws2 = XLSX.utils.json_to_sheet(histRows); | |
| ws2['!cols'] = [{ wch: 14 }, { wch: 18 }]; | |
| XLSX.utils.book_append_sheet(wb, ws2, '원본 데이터'); | |
| } | |
| if (lastMetrics) { | |
| const labels = { mae:'MAE', rmse:'RMSE', mape:'MAPE (%)', smape:'sMAPE (%)', mase:'MASE', r2:'R²' }; | |
| const metricRows = Object.entries(labels).filter(([k]) => lastMetrics[k] !== undefined).map(([k, label]) => ({ '지표': label, '값': lastMetrics[k] })); | |
| if (metricRows.length > 0) { | |
| const ws3 = XLSX.utils.json_to_sheet(metricRows); | |
| ws3['!cols'] = [{ wch: 14 }, { wch: 18 }]; | |
| XLSX.utils.book_append_sheet(wb, ws3, '성능 지표'); | |
| } | |
| } | |
| XLSX.writeFile(wb, (selectedFile ? selectedFile.name.replace(/\.[^.]+$/, '') : 'forecast') + '_ets.xlsx'); | |
| } | |
| exportBtn.addEventListener('click', exportToExcel); | |
| async function displayResults(forecastData, file, metrics) { | |
| lastForecastData = forecastData; | |
| lastMetrics = metrics?.evaluation || null; | |
| exportBtn.disabled = false; | |
| const evalMetrics = metrics?.evaluation; | |
| if (metrics?.model_type) { | |
| let infoText = 'ETS — trend=' + (metrics.trend || 'None') + ', seasonal=' + (metrics.seasonal || 'None') + ', period=' + (metrics.seasonal_periods || 'auto'); | |
| modelInfo.textContent = infoText; | |
| modelInfo.style.display = 'block'; | |
| } else { | |
| modelInfo.style.display = 'none'; | |
| } | |
| if (evalMetrics) { | |
| metricsContainer.style.display = 'block'; | |
| const labels = { mae:'MAE', rmse:'RMSE', mape:'MAPE (%)', smape:'sMAPE (%)', mase:'MASE', r2:'R²' }; | |
| const order = ['mae','rmse','mape','smape','mase','r2']; | |
| metricsGrid.innerHTML = order | |
| .filter(k => evalMetrics[k] !== undefined) | |
| .map(k => { | |
| const v = evalMetrics[k]; | |
| const display = (k === 'mae' || k === 'rmse') ? fmtNum(v, 0) : fmtNum(v, 4); | |
| return '<div class="metric-card"><div class="label">' + labels[k] + '</div><div class="value">' + display + '</div></div>'; | |
| }).join(''); | |
| } else { | |
| metricsContainer.style.display = 'none'; | |
| } | |
| let historicalData = []; | |
| if (file && /\.(csv|txt)$/i.test(file.name)) { | |
| try { historicalData = await parseCSV(file); } catch(_) {} | |
| } | |
| lastHistoricalData = historicalData; | |
| resultTable.innerHTML = forecastData.map(row => | |
| '<tr><td>' + row.ds + '</td><td>' + (row.yhat != null ? fmtNum(row.yhat, 0) : '-') + '</td></tr>' | |
| ).join(''); | |
| const allLabels = [...historicalData.map(d => d.ds), ...forecastData.map(d => d.ds)]; | |
| const histForChart = historicalData.map(d => d.y); | |
| const forecastForChart = forecastData.map(d => d.yhat); | |
| if (chartInstance) chartInstance.destroy(); | |
| const ctx = document.getElementById('chart').getContext('2d'); | |
| chartInstance = new Chart(ctx, { | |
| type: 'line', | |
| data: { | |
| labels: allLabels, | |
| datasets: [ | |
| { label: '실제값', data: [...histForChart, ...Array(forecastData.length).fill(null)], | |
| borderColor: '#10b981', backgroundColor: 'rgba(16,185,129,0.08)', fill: true, tension: 0.3, pointRadius: 1.5, borderWidth: 2 }, | |
| { label: '예측값', data: [...Array(historicalData.length).fill(null), ...forecastForChart], | |
| borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.08)', fill: true, tension: 0.3, pointRadius: 1.5, borderWidth: 2 }, | |
| ], | |
| }, | |
| options: { | |
| responsive: true, maintainAspectRatio: false, | |
| plugins: { legend: { position: 'top', labels: { color: '#8b8b96', usePointStyle: true, padding: 16, font: { size: 12 } } } }, | |
| scales: { | |
| x: { grid: { color: '#2a2a32' }, ticks: { color: '#8b8b96', maxRotation: 45, maxTicksLimit: 15, font: { size: 11 } } }, | |
| y: { grid: { color: '#2a2a32' }, ticks: { color: '#8b8b96', font: { size: 11 }, callback: v => v >= 1000 ? (v/1000000).toFixed(1) + 'M' : v } }, | |
| }, | |
| }, | |
| }); | |
| resultsSection.classList.add('visible'); | |
| } | |
| function resetAll() { | |
| selectedFile = null; | |
| lastForecastData = null; | |
| lastHistoricalData = null; | |
| lastMetrics = null; | |
| fileName.textContent = ''; | |
| fileInput.value = ''; | |
| predictBtn.disabled = true; | |
| exportBtn.disabled = true; | |
| hideStatus(); | |
| resultsSection.classList.remove('visible'); | |
| metricsContainer.style.display = 'none'; | |
| metricsGrid.innerHTML = ''; | |
| modelInfo.style.display = 'none'; | |
| resultTable.innerHTML = ''; | |
| if (chartInstance) { chartInstance.destroy(); chartInstance = null; } | |
| document.getElementById('forecastPeriods').value = 12; | |
| document.getElementById('trainRecentN').value = 0; | |
| document.getElementById('seasonalPeriods').value = 0; | |
| document.getElementById('trendType').value = 'add'; | |
| document.getElementById('seasonalType').value = 'add'; | |
| document.getElementById('includeEvaluation').checked = true; | |
| document.getElementById('dampedTrend').checked = false; | |
| document.getElementById('autoAdjust').checked = true; | |
| document.getElementById('useLogScale').checked = false; | |
| } | |
| predictBtn.addEventListener('click', runPrediction); | |
| document.getElementById('resetBtn').addEventListener('click', resetAll); | |
| </script> | |
| <footer style="text-align:center;padding:2rem 1rem 1.5rem;font-size:0.75rem;color:#6b6b76;border-top:1px solid #2a2a32;margin-top:2rem;"> | |
| Copyright (c) 2026 JKPFS | Licensed under the MIT License | This project is developed for PS&Company. | |
| </footer> | |
| </body> | |
| </html> | |