psnc's picture
Sync from GitHub Actions
98214c6 verified
Raw
History Blame Contribute Delete
26.4 kB
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ARIMA/SARIMA - ์‹œ๊ณ„์—ด ์˜ˆ์ธก</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: #06b6d4;
--accent-hover: #22d3ee;
--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; }
.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%, #67e8f9 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(6, 182, 212, 0.03); }
.upload-zone:hover, .upload-zone.dragover { border-color: var(--accent); background: rgba(6, 182, 212, 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 input[type="text"], .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: 100px;
}
.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; }
.order-group { display: flex; gap: 0.4rem; align-items: flex-end; }
.order-group .opt-group input { width: 55px; text-align: center; }
.order-separator { font-size: 0.75rem; color: var(--text-muted); padding-bottom: 0.55rem; font-weight: 600; }
.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(6,182,212,0.08); color: var(--accent); border: 1px solid rgba(6,182,212,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(6, 182, 212, 0.06); border: 1px solid rgba(6, 182, 212, 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); } }
.disabled-fields input, .disabled-fields select { opacity: 0.35; }
#manualOrderRow input:disabled { cursor: not-allowed; background: var(--surface); }
</style>
<script defer src="/static/nav.js"></script>
</head>
<body>
<div class="container">
<header>
<h1>ARIMA / SARIMA ์‹œ๊ณ„์—ด ์˜ˆ์ธก</h1>
<p class="subtitle">์ž๊ธฐํšŒ๊ท€ ๋ˆ„์  ์ด๋™ํ‰๊ท  ๋ชจ๋ธ โ€” AIC ๊ธฐ๋ฐ˜ ์ž๋™ ์ฐจ์ˆ˜ ์„ ํƒ, ๊ณ„์ ˆ ์„ฑ๋ถ„ ์ง€์› (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="seasonalPeriod">๊ณ„์ ˆ ์ฃผ๊ธฐ</label>
<input type="number" id="seasonalPeriod" value="12" min="1" max="52">
</div>
<div class="opt-check">
<input type="checkbox" id="includeEvaluation" checked>
<label for="includeEvaluation">์„ฑ๋Šฅ ํ‰๊ฐ€</label>
</div>
<div class="opt-check">
<input type="checkbox" id="autoOrder" checked>
<label for="autoOrder">์ž๋™ ์ฐจ์ˆ˜</label>
</div>
<div class="opt-check">
<input type="checkbox" id="useLogScale">
<label for="useLogScale">๋กœ๊ทธ ๋ณ€ํ™˜</label>
</div>
</div>
<div class="options-row" id="manualOrderRow">
<div class="order-group">
<div class="opt-group"><label>p (AR)</label><input type="number" id="paramP" value="1" min="0" max="5"></div>
<div class="opt-group"><label>d (์ฐจ๋ถ„)</label><input type="number" id="paramD" value="1" min="0" max="2"></div>
<div class="opt-group"><label>q (MA)</label><input type="number" id="paramQ" value="1" min="0" max="5"></div>
</div>
<div class="order-separator">ร—</div>
<div class="order-group">
<div class="opt-group"><label>P (๊ณ„์ ˆAR)</label><input type="number" id="paramSP" value="1" min="0" max="3"></div>
<div class="opt-group"><label>D (๊ณ„์ ˆ์ฐจ๋ถ„)</label><input type="number" id="paramSD" value="1" min="0" max="2"></div>
<div class="opt-group"><label>Q (๊ณ„์ ˆMA)</label><input type="number" id="paramSQ" value="1" min="0" max="3"></div>
</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">ARIMA/SARIMA ์„ฑ๋Šฅ ์ง€ํ‘œ (๋ฐฑํ…Œ์ŠคํŠธ)</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 autoOrderCb = document.getElementById('autoOrder');
const manualOrderRow = document.getElementById('manualOrderRow');
const exportBtn = document.getElementById('exportBtn');
let selectedFile = null;
let chartInstance = null;
let lastForecastData = null;
let lastHistoricalData = null;
let lastMetrics = null;
function toggleManualFields() {
const isAuto = autoOrderCb.checked;
const inputs = manualOrderRow.querySelectorAll('input');
inputs.forEach(inp => inp.disabled = isAuto);
if (isAuto) {
manualOrderRow.classList.add('disabled-fields');
} else {
manualOrderRow.classList.remove('disabled-fields');
}
}
autoOrderCb.addEventListener('change', toggleManualFields);
toggleManualFields();
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('ARIMA/SARIMA ์˜ˆ์ธก ์‹คํ–‰ ์ค‘... (์ž๋™ ์ฐจ์ˆ˜ ์„ ํƒ ์‹œ 1~2๋ถ„ ์†Œ์š”๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค)', '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 isAuto = autoOrderCb.checked;
const modelParams = {
auto_order: isAuto,
s: parseInt(document.getElementById('seasonalPeriod').value) || 12,
};
if (!isAuto) {
const v = (id, fallback) => { const n = parseInt(document.getElementById(id).value); return isNaN(n) ? fallback : n; };
modelParams.p = v('paramP', 1);
modelParams.d = v('paramD', 1);
modelParams.q = v('paramQ', 1);
modelParams.P = v('paramSP', 1);
modelParams.D = v('paramSD', 1);
modelParams.Q = v('paramSQ', 1);
}
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/arima/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 => {
const row = { '๋‚ ์งœ': r.ds, '์˜ˆ์ธก๊ฐ’': r.yhat != null ? Number(r.yhat.toFixed(2)) : '' };
if (r.yhat_lower !== undefined && r.yhat_lower != null) {
row['ํ•˜ํ•œ(80%)'] = Number(r.yhat_lower.toFixed(2));
row['์ƒํ•œ(80%)'] = Number(r.yhat_upper.toFixed(2));
}
return row;
});
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, '์„ฑ๋Šฅ ์ง€ํ‘œ');
}
}
const fname = (selectedFile ? selectedFile.name.replace(/\.[^.]+$/, '') : 'forecast') + '_arima.xlsx';
XLSX.writeFile(wb, fname);
}
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 || metrics?.order) {
const order = evalMetrics?.order || metrics?.order || [];
const seasonal = evalMetrics?.seasonal_order || metrics?.seasonal_order || [];
const mtype = metrics?.model_type || 'ARIMA';
let infoText = mtype + ' โ€” order=(' + order.slice(0, 3).join(',') + ')';
if (seasonal.length >= 4 && (seasonal[0] || seasonal[1] || seasonal[2])) {
infoText += ' ร— seasonal=(' + seasonal.join(',') + ')';
}
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;
if (forecastData.length > 0 && forecastData[0].yhat_lower !== undefined && forecastData[0].yhat_lower !== null) {
document.querySelector('.result-table thead tr').innerHTML = '<th>๋‚ ์งœ</th><th>์˜ˆ์ธก๊ฐ’ (yhat)</th><th>์‹ ๋ขฐ๊ตฌ๊ฐ„ (80%)</th>';
} else {
document.querySelector('.result-table thead tr').innerHTML = '<th>๋‚ ์งœ</th><th>์˜ˆ์ธก๊ฐ’ (yhat)</th>';
}
resultTable.innerHTML = forecastData.map(row => {
let cols = '<td>' + row.ds + '</td><td>' + (row.yhat != null ? fmtNum(row.yhat, 0) : '-') + '</td>';
if (row.yhat_lower !== undefined && row.yhat_lower != null) {
cols += '<td>' + fmtNum(row.yhat_lower, 0) + ' ~ ' + fmtNum(row.yhat_upper, 0) + '</td>';
}
return '<tr>' + cols + '</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);
const datasets = [
{ label: '์‹ค์ œ๊ฐ’', data: [...histForChart, ...Array(forecastData.length).fill(null)],
borderColor: '#06b6d4', backgroundColor: 'rgba(6,182,212,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 },
];
if (forecastData.length > 0 && forecastData[0].yhat_lower !== undefined && forecastData[0].yhat_lower !== null) {
const lowerData = forecastData.map(d => d.yhat_lower);
const upperData = forecastData.map(d => d.yhat_upper);
datasets.push({
label: '์˜ˆ์ธก ํ•˜ํ•œ', data: [...Array(historicalData.length).fill(null), ...lowerData],
borderColor: 'transparent', backgroundColor: 'rgba(245, 158, 11, 0.15)', fill: false, pointRadius: 0, borderWidth: 0
});
datasets.push({
label: '์˜ˆ์ธก ์ƒํ•œ', data: [...Array(historicalData.length).fill(null), ...upperData],
borderColor: 'transparent', backgroundColor: 'rgba(245, 158, 11, 0.15)', fill: '-1', pointRadius: 0, borderWidth: 0
});
}
if (chartInstance) chartInstance.destroy();
const ctx = document.getElementById('chart').getContext('2d');
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: allLabels,
datasets: datasets,
},
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('seasonalPeriod').value = 12;
document.getElementById('includeEvaluation').checked = true;
document.getElementById('autoOrder').checked = true;
document.getElementById('useLogScale').checked = false;
document.getElementById('paramP').value = 1;
document.getElementById('paramD').value = 1;
document.getElementById('paramQ').value = 1;
document.getElementById('paramSP').value = 1;
document.getElementById('paramSD').value = 1;
document.getElementById('paramSQ').value = 1;
toggleManualFields();
}
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&amp;Company.
</footer>
</body>
</html>