psnc's picture
Sync from GitHub Actions
7a94836 verified
<!DOCTYPE html>
<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&amp;Company.
</footer>
</body>
</html>