first commit

This commit is contained in:
hayato5246
2026-03-31 19:32:59 +09:00
commit d10b794c9f
78 changed files with 1671595 additions and 0 deletions

255
static/js/theme.js Normal file
View File

@@ -0,0 +1,255 @@
/**
* 테마 분석 페이지
* - 테마 목록 조회 (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();
})();