256 lines
12 KiB
JavaScript
256 lines
12 KiB
JavaScript
/**
|
|
* 테마 분석 페이지
|
|
* - 테마 목록 조회 (ka90001)
|
|
* - 테마 구성종목 조회 (ka90002)
|
|
*/
|
|
(function () {
|
|
let currentDate = '1';
|
|
let currentSort = '3'; // 3=등락률순, 1=기간수익률순
|
|
let selectedCode = null;
|
|
let selectedName = null;
|
|
let allThemes = [];
|
|
let clientSortCol = null; // 헤더 클릭 정렬 컬럼 (null=서버 순서 유지)
|
|
let clientSortDesc = true;
|
|
|
|
const listEl = document.getElementById('themeList');
|
|
const countEl = document.getElementById('themeCount');
|
|
const searchEl = document.getElementById('themeSearch');
|
|
const emptyEl = document.getElementById('themeDetailEmpty');
|
|
const contentEl = document.getElementById('themeDetailContent');
|
|
const loadingEl = document.getElementById('themeDetailLoading');
|
|
const nameEl = document.getElementById('detailThemeName');
|
|
const fluRtEl = document.getElementById('detailFluRt');
|
|
const periodRtEl = document.getElementById('detailPeriodRt');
|
|
const stockListEl = document.getElementById('detailStockList');
|
|
|
|
// ── 포맷 유틸 ────────────────────────────────────────────────
|
|
function fmtRate(f) {
|
|
if (f == null) return '-';
|
|
const sign = f >= 0 ? '+' : '';
|
|
return sign + f.toFixed(2) + '%';
|
|
}
|
|
function fmtNum(n) {
|
|
if (n == null) return '-';
|
|
return Math.abs(n).toLocaleString('ko-KR');
|
|
}
|
|
function rateClass(f) {
|
|
if (f > 0) return 'text-red-500';
|
|
if (f < 0) return 'text-blue-500';
|
|
return 'text-gray-500';
|
|
}
|
|
function rateBg(f) {
|
|
if (f > 0) return 'bg-red-50 text-red-500';
|
|
if (f < 0) return 'bg-blue-50 text-blue-500';
|
|
return 'bg-gray-100 text-gray-500';
|
|
}
|
|
|
|
// ── 테마 목록 행 렌더 ────────────────────────────────────────
|
|
function makeRow(t, rank) {
|
|
const fluCls = rateClass(t.fluRt);
|
|
const periodCls = t.periodRt >= 0 ? 'text-purple-600' : 'text-blue-500';
|
|
const isSelected = t.code === selectedCode;
|
|
|
|
return `
|
|
<div data-code="${t.code}" data-name="${t.name}"
|
|
class="theme-row grid grid-cols-[2fr_1fr_80px_80px_80px_80px] gap-0
|
|
px-4 py-3 cursor-pointer transition-colors text-sm
|
|
${isSelected ? 'bg-blue-50' : 'hover:bg-gray-50'}">
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<span class="text-xs text-gray-400 w-5 shrink-0">${rank}</span>
|
|
<span class="font-medium text-gray-800 truncate">${t.name}</span>
|
|
</div>
|
|
<div class="text-xs text-gray-500 truncate self-center">${t.mainStock || '-'}</div>
|
|
<div class="text-right self-center font-semibold ${fluCls}">${fmtRate(t.fluRt)}</div>
|
|
<div class="text-right self-center font-semibold ${periodCls}">${fmtRate(t.periodRt)}</div>
|
|
<div class="text-center self-center text-gray-500">${t.stockCount}</div>
|
|
<div class="text-center self-center text-xs">
|
|
<span class="text-red-400">${t.risingCount}▲</span>
|
|
<span class="text-gray-300 mx-0.5">/</span>
|
|
<span class="text-blue-400">${t.fallCount}▼</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ── 헤더 정렬 화살표 갱신 ────────────────────────────────────
|
|
function updateThemeColHeaders() {
|
|
document.querySelectorAll('.theme-col-sort').forEach(el => {
|
|
const arrow = el.querySelector('.sort-arrow');
|
|
if (!arrow) return;
|
|
if (el.dataset.col === clientSortCol) {
|
|
arrow.textContent = clientSortDesc ? '▼' : '▲';
|
|
arrow.className = 'sort-arrow text-blue-400';
|
|
el.classList.add('text-blue-500');
|
|
el.classList.remove('text-gray-500');
|
|
} else {
|
|
arrow.textContent = '';
|
|
arrow.className = 'sort-arrow';
|
|
el.classList.remove('text-blue-500');
|
|
el.classList.add('text-gray-500');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── 클라이언트 정렬 적용 ─────────────────────────────────────
|
|
function applyClientSort(themes) {
|
|
if (!clientSortCol) return themes;
|
|
return [...themes].sort((a, b) => {
|
|
const av = a[clientSortCol], bv = b[clientSortCol];
|
|
if (typeof av === 'string') {
|
|
const cmp = av.localeCompare(bv, 'ko');
|
|
return clientSortDesc ? cmp : -cmp;
|
|
}
|
|
const diff = bv - av;
|
|
return clientSortDesc ? diff : -diff;
|
|
});
|
|
}
|
|
|
|
// ── 테마 목록 렌더 ───────────────────────────────────────────
|
|
function renderList(themes) {
|
|
const q = searchEl.value.trim();
|
|
let filtered = q
|
|
? themes.filter(t => t.name.includes(q))
|
|
: themes;
|
|
|
|
filtered = applyClientSort(filtered);
|
|
|
|
countEl.textContent = `총 ${filtered.length}개 테마`;
|
|
|
|
if (filtered.length === 0) {
|
|
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-gray-400">검색 결과가 없습니다.</div>`;
|
|
return;
|
|
}
|
|
|
|
listEl.innerHTML = filtered.map((t, i) => makeRow(t, i + 1)).join('');
|
|
|
|
// 행 클릭 이벤트
|
|
listEl.querySelectorAll('.theme-row').forEach(row => {
|
|
row.addEventListener('click', () => {
|
|
const code = row.dataset.code;
|
|
const name = row.dataset.name;
|
|
if (code === selectedCode) return;
|
|
selectedCode = code;
|
|
selectedName = name;
|
|
// 선택 상태 표시 갱신
|
|
listEl.querySelectorAll('.theme-row').forEach(r => {
|
|
r.classList.toggle('bg-blue-50', r.dataset.code === code);
|
|
r.classList.toggle('hover:bg-gray-50', r.dataset.code !== code);
|
|
});
|
|
loadDetail(code, name);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ── 테마 목록 조회 ───────────────────────────────────────────
|
|
async function loadThemes() {
|
|
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-gray-400 animate-pulse">데이터를 불러오는 중...</div>`;
|
|
try {
|
|
const resp = await fetch(`/api/themes?date=${currentDate}&sort=${currentSort}`);
|
|
if (!resp.ok) throw new Error('조회 실패');
|
|
allThemes = await resp.json();
|
|
renderList(allThemes);
|
|
} catch (e) {
|
|
listEl.innerHTML = `<div class="px-4 py-10 text-center text-sm text-red-400">데이터를 불러오지 못했습니다.</div>`;
|
|
console.error('테마 목록 조회 실패:', e);
|
|
}
|
|
}
|
|
|
|
// ── 구성종목 조회 ─────────────────────────────────────────────
|
|
async function loadDetail(code, name) {
|
|
emptyEl.classList.add('hidden');
|
|
contentEl.classList.add('hidden');
|
|
loadingEl.classList.remove('hidden');
|
|
|
|
try {
|
|
const resp = await fetch(`/api/themes/${code}?date=${currentDate}`);
|
|
if (!resp.ok) throw new Error('조회 실패');
|
|
const data = await resp.json();
|
|
|
|
nameEl.textContent = name;
|
|
fluRtEl.textContent = fmtRate(data.fluRt);
|
|
fluRtEl.className = 'font-semibold ' + rateClass(data.fluRt);
|
|
periodRtEl.textContent = fmtRate(data.periodRt);
|
|
|
|
const stocks = data.stocks || [];
|
|
if (stocks.length === 0) {
|
|
stockListEl.innerHTML = `<div class="px-4 py-8 text-center text-xs text-gray-400">구성종목이 없습니다.</div>`;
|
|
} else {
|
|
stockListEl.innerHTML = stocks.map(s => {
|
|
const cls = rateClass(s.fluRt);
|
|
const bgCls = rateBg(s.fluRt);
|
|
const sign = s.predPre >= 0 ? '+' : '';
|
|
return `
|
|
<a href="/stock/${s.code}"
|
|
class="flex items-center justify-between px-4 py-2.5 hover:bg-gray-50 transition-colors">
|
|
<div class="min-w-0">
|
|
<p class="text-sm font-medium text-gray-800 truncate">${s.name}</p>
|
|
<p class="text-xs text-gray-400 font-mono">${s.code}</p>
|
|
</div>
|
|
<div class="text-right shrink-0 ml-3">
|
|
<p class="text-sm font-semibold ${cls}">${fmtNum(s.curPrc)}원</p>
|
|
<span class="text-xs px-1.5 py-0.5 rounded ${bgCls}">${sign}${fmtRate(s.fluRt)}</span>
|
|
</div>
|
|
</a>`;
|
|
}).join('');
|
|
}
|
|
|
|
loadingEl.classList.add('hidden');
|
|
contentEl.classList.remove('hidden');
|
|
} catch (e) {
|
|
loadingEl.classList.add('hidden');
|
|
emptyEl.classList.remove('hidden');
|
|
emptyEl.innerHTML = '<p class="text-red-400">구성종목을 불러오지 못했습니다.</p>';
|
|
console.error('테마 구성종목 조회 실패:', e);
|
|
}
|
|
}
|
|
|
|
// ── 날짜 탭 이벤트 ───────────────────────────────────────────
|
|
document.querySelectorAll('.date-tab').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
currentDate = btn.dataset.date;
|
|
document.querySelectorAll('.date-tab').forEach(b => {
|
|
b.className = b.dataset.date === currentDate
|
|
? 'date-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
|
|
: 'date-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
|
|
});
|
|
loadThemes();
|
|
});
|
|
});
|
|
|
|
// ── 정렬 탭 이벤트 (서버 사이드) ─────────────────────────────
|
|
document.querySelectorAll('.sort-tab').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
currentSort = btn.dataset.sort;
|
|
// 헤더 클라이언트 정렬 초기화 (서버 순서 우선)
|
|
clientSortCol = null;
|
|
document.querySelectorAll('.sort-tab').forEach(b => {
|
|
b.className = b.dataset.sort === currentSort
|
|
? 'sort-tab px-3 py-1.5 bg-blue-500 text-white text-xs font-medium'
|
|
: 'sort-tab px-3 py-1.5 bg-white text-gray-600 hover:bg-gray-50 border-l border-gray-200 text-xs font-medium';
|
|
});
|
|
updateThemeColHeaders();
|
|
loadThemes();
|
|
});
|
|
});
|
|
|
|
// ── 헤더 컬럼 클릭 정렬 ──────────────────────────────────────
|
|
document.querySelectorAll('.theme-col-sort').forEach(el => {
|
|
el.addEventListener('click', () => {
|
|
const col = el.dataset.col;
|
|
if (clientSortCol === col) {
|
|
clientSortDesc = !clientSortDesc;
|
|
} else {
|
|
clientSortCol = col;
|
|
clientSortDesc = true;
|
|
}
|
|
updateThemeColHeaders();
|
|
renderList(allThemes);
|
|
});
|
|
});
|
|
|
|
// ── 검색 필터 이벤트 ─────────────────────────────────────────
|
|
searchEl.addEventListener('input', () => renderList(allThemes));
|
|
|
|
// ── 초기 로드 ────────────────────────────────────────────────
|
|
loadThemes();
|
|
})();
|