798 lines
34 KiB
JavaScript
798 lines
34 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const catalog = [
|
|
{ code: "5001", title: "1열판 · 기본", detail: "종목 현재가 / 등락", category: "plate", market: "kospi" },
|
|
{ code: "5006", title: "1열판 · 시가", detail: "시가 정보", category: "plate", market: "kosdaq" },
|
|
{ code: "5016", title: "3열판 · 주요 지수", detail: "국내외 지수 3열 구성", category: "plate", market: "index" },
|
|
{ code: "5023", title: "매매동향 · 지수별", detail: "코스피 / 코스닥", category: "market", market: "index" },
|
|
{ code: "5024", title: "매매동향 · 막대그래프", detail: "주체별 매매 동향", category: "chart", market: "kospi" },
|
|
{ code: "5026", title: "비교분석 · 캔들형", detail: "두 종목 비교", category: "chart", market: "compare" },
|
|
{ code: "5074", title: "5단표", detail: "상승률 / 하락률 / 거래량", category: "plate", market: "kosdaq" },
|
|
{ code: "5083", title: "선그래프 · 주체별", detail: "개인 / 외국인 / 기관", category: "chart", market: "kospi" },
|
|
{ code: "5086", title: "수익률 그래프", detail: "기간별 수익률", category: "chart", market: "compare" },
|
|
{ code: "6001", title: "해외증시", detail: "미국 / 유럽 / 아시아", category: "market", market: "overseas" },
|
|
{ code: "8035", title: "캔들 그래프 · 5분", detail: "지수 / 종목 5분봉", category: "chart", market: "index" },
|
|
{ code: "8086", title: "유가 · 금값", detail: "WTI / 브렌트 / 국제금", category: "market", market: "exchange" },
|
|
{ code: "THEME", title: "테마 종목", detail: "테마별 5·6·12 종목", category: "plate", market: "theme" },
|
|
{ code: "EXPERT", title: "전문가 추천", detail: "추천 종목 그래픽", category: "plate", market: "expert" },
|
|
{ code: "HALT", title: "거래 정지", detail: "정지 종목 검색", category: "market", market: "halted" }
|
|
];
|
|
|
|
const marketTitles = {
|
|
dashboard: "마이그레이션 대시보드", overseas: "해외 그래픽", exchange: "환율 그래픽",
|
|
index: "지수 그래픽", kospi: "코스피 그래픽", kosdaq: "코스닥 그래픽",
|
|
compare: "종목 비교", theme: "테마 관리", expert: "전문가 추천", halted: "거래 정지"
|
|
};
|
|
const liveDataViews = new Set(["kospi", "kosdaq", "index", "overseas"]);
|
|
const sourceLabels = { oracle: "Oracle", mariaDb: "MariaDB" };
|
|
|
|
const storageKey = "mbn-stock-webview.playlist.v1";
|
|
const state = {
|
|
activeMarket: "dashboard",
|
|
activeCategory: "all",
|
|
search: "",
|
|
playlist: [],
|
|
selectedRows: new Set(),
|
|
currentIndex: -1,
|
|
dragIndex: -1,
|
|
playout: "IDLE",
|
|
database: { sources: [], loading: true, lastSignature: "" },
|
|
liveData: {
|
|
requestId: null,
|
|
view: null,
|
|
status: "idle",
|
|
tables: [],
|
|
retrievedAt: null,
|
|
error: "",
|
|
timeoutId: null
|
|
}
|
|
};
|
|
|
|
const elements = {
|
|
bridgeState: document.querySelector("#bridgeState"), databaseState: document.querySelector("#databaseState"),
|
|
databaseStateDot: document.querySelector("#databaseStateDot"),
|
|
databaseSummary: document.querySelector("#databaseSummary"),
|
|
databaseSummaryIcon: document.querySelector("#databaseSummaryIcon"),
|
|
databaseSummaryDetail: document.querySelector("#databaseSummaryDetail"),
|
|
databaseSummaryBadge: document.querySelector("#databaseSummaryBadge"),
|
|
runtimeLabel: document.querySelector("#runtimeLabel"), packageLabel: document.querySelector("#packageLabel"),
|
|
workspaceTitle: document.querySelector("#workspaceTitle"), clock: document.querySelector("#clock"),
|
|
catalogSearch: document.querySelector("#catalogSearch"), catalogTabs: document.querySelector("#catalogTabs"),
|
|
catalogList: document.querySelector("#catalogList"), catalogModeBadge: document.querySelector("#catalogModeBadge"),
|
|
liveDataPanel: document.querySelector("#liveDataPanel"), liveDataTitle: document.querySelector("#liveDataTitle"),
|
|
liveDataState: document.querySelector("#liveDataState"), liveDataTables: document.querySelector("#liveDataTables"),
|
|
databaseSources: document.querySelector("#databaseSources"),
|
|
playlistBody: document.querySelector("#playlistBody"),
|
|
playlistCount: document.querySelector("#playlistCount"), playlistEmpty: document.querySelector("#playlistEmpty"),
|
|
previewTitle: document.querySelector("#previewTitle"), previewSubtitle: document.querySelector("#previewSubtitle"),
|
|
playoutState: document.querySelector("#playoutState"), eventLog: document.querySelector("#eventLog"),
|
|
toast: document.querySelector("#toast")
|
|
};
|
|
|
|
const nativeBridge = window.chrome?.webview;
|
|
|
|
function createId() {
|
|
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function updateClock() {
|
|
elements.clock.textContent = new Intl.DateTimeFormat("ko-KR", {
|
|
hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
|
|
}).format(new Date());
|
|
}
|
|
|
|
function filteredCatalog() {
|
|
const term = state.search.trim().toLocaleLowerCase("ko-KR");
|
|
return catalog.filter(item => {
|
|
const marketMatches = state.activeMarket === "dashboard" || item.market === state.activeMarket;
|
|
const categoryMatches = state.activeCategory === "all" || item.category === state.activeCategory;
|
|
const searchMatches = !term || `${item.code} ${item.title} ${item.detail}`.toLocaleLowerCase("ko-KR").includes(term);
|
|
return marketMatches && categoryMatches && searchMatches;
|
|
});
|
|
}
|
|
|
|
function renderCatalog() {
|
|
elements.catalogList.replaceChildren();
|
|
const items = filteredCatalog();
|
|
|
|
if (!items.length) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "catalog-empty";
|
|
empty.textContent = "조건에 맞는 그래픽 항목이 없습니다.";
|
|
elements.catalogList.append(empty);
|
|
return;
|
|
}
|
|
|
|
for (const item of items) {
|
|
const row = document.createElement("article");
|
|
row.className = "catalog-item";
|
|
|
|
const code = document.createElement("span");
|
|
code.className = "cut-code";
|
|
code.textContent = item.code;
|
|
|
|
const copy = document.createElement("div");
|
|
const title = document.createElement("strong");
|
|
const detail = document.createElement("small");
|
|
title.textContent = item.title;
|
|
detail.textContent = item.detail;
|
|
copy.append(title, detail);
|
|
|
|
const add = document.createElement("button");
|
|
add.className = "add-cut";
|
|
add.type = "button";
|
|
add.title = "플레이리스트에 추가";
|
|
add.textContent = "+";
|
|
add.addEventListener("click", () => addToPlaylist(item));
|
|
|
|
row.append(code, copy, add);
|
|
elements.catalogList.append(row);
|
|
}
|
|
}
|
|
|
|
function addToPlaylist(item) {
|
|
const entry = { ...item, id: createId() };
|
|
state.playlist.push(entry);
|
|
state.currentIndex = state.playlist.length - 1;
|
|
renderPlaylist();
|
|
updatePreview();
|
|
addLog(`${item.code} ${item.title} 항목 추가`);
|
|
showToast("플레이리스트에 추가했습니다.");
|
|
}
|
|
|
|
function renderPlaylist() {
|
|
elements.playlistBody.replaceChildren();
|
|
elements.playlistCount.textContent = `${state.playlist.length} CUTS`;
|
|
elements.playlistEmpty.classList.toggle("show", state.playlist.length === 0);
|
|
|
|
state.playlist.forEach((item, index) => {
|
|
const row = document.createElement("tr");
|
|
row.draggable = true;
|
|
row.dataset.index = String(index);
|
|
row.classList.toggle("active", index === state.currentIndex);
|
|
|
|
const checkCell = document.createElement("td");
|
|
checkCell.className = "check-cell";
|
|
const checkbox = document.createElement("input");
|
|
checkbox.type = "checkbox";
|
|
checkbox.checked = state.selectedRows.has(item.id);
|
|
checkbox.setAttribute("aria-label", `${item.title} 선택`);
|
|
checkbox.addEventListener("click", event => event.stopPropagation());
|
|
checkbox.addEventListener("change", () => {
|
|
checkbox.checked ? state.selectedRows.add(item.id) : state.selectedRows.delete(item.id);
|
|
});
|
|
checkCell.append(checkbox);
|
|
|
|
const number = document.createElement("td");
|
|
number.className = "number-cell";
|
|
number.textContent = String(index + 1).padStart(2, "0");
|
|
|
|
const titleCell = document.createElement("td");
|
|
const title = document.createElement("strong");
|
|
const detail = document.createElement("small");
|
|
title.textContent = item.title;
|
|
detail.textContent = `${item.code} · ${item.detail}`;
|
|
titleCell.append(title, detail);
|
|
|
|
const typeCell = document.createElement("td");
|
|
const type = document.createElement("span");
|
|
type.className = "row-type";
|
|
type.textContent = item.category.toUpperCase();
|
|
typeCell.append(type);
|
|
|
|
const drag = document.createElement("td");
|
|
drag.className = "drag-cell";
|
|
drag.textContent = "⠿";
|
|
|
|
row.append(checkCell, number, titleCell, typeCell, drag);
|
|
row.addEventListener("click", () => selectPlaylistRow(index));
|
|
row.addEventListener("dragstart", onDragStart);
|
|
row.addEventListener("dragover", onDragOver);
|
|
row.addEventListener("dragleave", () => row.classList.remove("drag-over"));
|
|
row.addEventListener("drop", onDrop);
|
|
row.addEventListener("dragend", onDragEnd);
|
|
elements.playlistBody.append(row);
|
|
});
|
|
}
|
|
|
|
function selectPlaylistRow(index) {
|
|
state.currentIndex = index;
|
|
renderPlaylist();
|
|
updatePreview();
|
|
}
|
|
|
|
function updatePreview() {
|
|
const item = state.playlist[state.currentIndex];
|
|
if (!item) {
|
|
elements.previewTitle.textContent = "선택된 그래픽 없음";
|
|
elements.previewSubtitle.textContent = "플레이리스트 항목을 선택하세요.";
|
|
return;
|
|
}
|
|
elements.previewTitle.textContent = item.title;
|
|
elements.previewSubtitle.textContent = `${item.code} · ${item.detail}`;
|
|
}
|
|
|
|
function onDragStart(event) {
|
|
state.dragIndex = Number(event.currentTarget.dataset.index);
|
|
event.currentTarget.classList.add("dragging");
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("text/plain", String(state.dragIndex));
|
|
}
|
|
|
|
function onDragOver(event) {
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
event.currentTarget.classList.add("drag-over");
|
|
}
|
|
|
|
function onDrop(event) {
|
|
event.preventDefault();
|
|
const targetIndex = Number(event.currentTarget.dataset.index);
|
|
if (state.dragIndex < 0 || state.dragIndex === targetIndex) return;
|
|
const [moved] = state.playlist.splice(state.dragIndex, 1);
|
|
state.playlist.splice(targetIndex, 0, moved);
|
|
state.currentIndex = targetIndex;
|
|
state.dragIndex = -1;
|
|
renderPlaylist();
|
|
updatePreview();
|
|
addLog("플레이리스트 순서 변경");
|
|
}
|
|
|
|
function onDragEnd() {
|
|
state.dragIndex = -1;
|
|
document.querySelectorAll("tr.dragging, tr.drag-over").forEach(row => row.classList.remove("dragging", "drag-over"));
|
|
}
|
|
|
|
function toggleSelectAll() {
|
|
const allSelected = state.playlist.length > 0 && state.playlist.every(item => state.selectedRows.has(item.id));
|
|
state.selectedRows.clear();
|
|
if (!allSelected) state.playlist.forEach(item => state.selectedRows.add(item.id));
|
|
renderPlaylist();
|
|
}
|
|
|
|
function moveSelected(direction) {
|
|
const index = state.playlist.findIndex(item => state.selectedRows.has(item.id));
|
|
if (index < 0) return showToast("이동할 항목을 선택하세요.");
|
|
const target = Math.max(0, Math.min(state.playlist.length - 1, index + direction));
|
|
if (index === target) return;
|
|
[state.playlist[index], state.playlist[target]] = [state.playlist[target], state.playlist[index]];
|
|
state.currentIndex = target;
|
|
renderPlaylist();
|
|
updatePreview();
|
|
}
|
|
|
|
function deleteSelected() {
|
|
if (!state.selectedRows.size) return showToast("삭제할 항목을 선택하세요.");
|
|
const deleted = state.selectedRows.size;
|
|
state.playlist = state.playlist.filter(item => !state.selectedRows.has(item.id));
|
|
state.selectedRows.clear();
|
|
state.currentIndex = Math.min(state.currentIndex, state.playlist.length - 1);
|
|
renderPlaylist();
|
|
updatePreview();
|
|
addLog(`${deleted}개 항목 삭제`);
|
|
}
|
|
|
|
function savePlaylist() {
|
|
localStorage.setItem(storageKey, JSON.stringify(state.playlist));
|
|
addLog(`플레이리스트 ${state.playlist.length}개 로컬 저장`);
|
|
showToast("플레이리스트를 저장했습니다.");
|
|
}
|
|
|
|
function loadPlaylist() {
|
|
try {
|
|
const stored = JSON.parse(localStorage.getItem(storageKey) || "[]");
|
|
state.playlist = Array.isArray(stored) ? stored : [];
|
|
state.selectedRows.clear();
|
|
state.currentIndex = state.playlist.length ? 0 : -1;
|
|
renderPlaylist();
|
|
updatePreview();
|
|
addLog(`플레이리스트 ${state.playlist.length}개 불러오기`);
|
|
showToast("저장된 플레이리스트를 불러왔습니다.");
|
|
} catch {
|
|
showToast("저장 데이터를 읽을 수 없습니다.");
|
|
}
|
|
}
|
|
|
|
function setPlayout(nextState, message) {
|
|
state.playout = nextState;
|
|
elements.playoutState.textContent = nextState;
|
|
elements.playoutState.classList.toggle("neutral", nextState === "IDLE");
|
|
addLog(message);
|
|
}
|
|
|
|
function prepare() {
|
|
if (!state.playlist[state.currentIndex]) return showToast("먼저 그래픽 항목을 선택하세요.");
|
|
setPlayout("PREPARED", `${state.playlist[state.currentIndex].code} PREPARE`);
|
|
}
|
|
|
|
function takeIn() {
|
|
if (state.playout !== "PREPARED") return showToast("PREPARE가 먼저 필요합니다.");
|
|
setPlayout("PROGRAM", `${state.playlist[state.currentIndex].code} TAKE IN · Tornado 어댑터 대기`);
|
|
showToast("UI 동작 확인 완료 — 실제 송출 어댑터는 아직 연결되지 않았습니다.");
|
|
}
|
|
|
|
function next() {
|
|
if (!state.playlist.length) return showToast("플레이리스트가 비어 있습니다.");
|
|
state.currentIndex = (state.currentIndex + 1) % state.playlist.length;
|
|
renderPlaylist();
|
|
updatePreview();
|
|
setPlayout("PREPARED", `${state.playlist[state.currentIndex].code} NEXT / PREPARE`);
|
|
}
|
|
|
|
function takeOut() {
|
|
setPlayout("IDLE", "TAKE OUT");
|
|
}
|
|
|
|
function addLog(message) {
|
|
const item = document.createElement("li");
|
|
const time = document.createElement("time");
|
|
time.textContent = new Date().toLocaleTimeString("ko-KR", { hour12: false });
|
|
item.append(time, document.createTextNode(message));
|
|
elements.eventLog.prepend(item);
|
|
while (elements.eventLog.children.length > 30) elements.eventLog.lastElementChild?.remove();
|
|
}
|
|
|
|
let toastTimer;
|
|
function showToast(message) {
|
|
elements.toast.textContent = message;
|
|
elements.toast.classList.add("show");
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => elements.toast.classList.remove("show"), 2600);
|
|
}
|
|
|
|
function postNative(type, payload = {}) {
|
|
nativeBridge?.postMessage({ type, payload });
|
|
}
|
|
|
|
function normalizeSourceName(source) {
|
|
const value = String(source || "").toLocaleLowerCase("en-US");
|
|
if (value === "oracle") return "oracle";
|
|
if (value === "mariadb" || value === "maria_db" || value === "maria-db") return "mariaDb";
|
|
return String(source || "unknown");
|
|
}
|
|
|
|
function normalizedDatabaseSources(sources) {
|
|
const received = new Map((Array.isArray(sources) ? sources : []).map(source => [normalizeSourceName(source?.source), source]));
|
|
return ["oracle", "mariaDb"].map(name => {
|
|
const source = received.get(name);
|
|
return {
|
|
source: name,
|
|
configured: Boolean(source?.configured),
|
|
healthy: Boolean(source?.healthy),
|
|
message: typeof source?.message === "string" ? source.message : "",
|
|
checkedAt: source?.checkedAt || null,
|
|
latencyMs: source?.latencyMs !== null && source?.latencyMs !== undefined && Number.isFinite(Number(source.latencyMs))
|
|
? Number(source.latencyMs)
|
|
: null
|
|
};
|
|
});
|
|
}
|
|
|
|
function sourceHealthClass(source) {
|
|
if (state.database.loading && !state.database.sources.length) return "pending";
|
|
if (!source.configured) return "unconfigured";
|
|
return source.healthy ? "healthy" : "error";
|
|
}
|
|
|
|
function sourceHealthText(source) {
|
|
if (state.database.loading && !state.database.sources.length) return "확인 중";
|
|
if (!source.configured) return "미설정";
|
|
if (!source.healthy) return "연결 오류";
|
|
return source.latencyMs === null ? "정상" : `정상 · ${Math.round(source.latencyMs)}ms`;
|
|
}
|
|
|
|
function renderDatabaseSources() {
|
|
elements.databaseSources.replaceChildren();
|
|
const sources = state.database.sources.length
|
|
? state.database.sources
|
|
: normalizedDatabaseSources([]);
|
|
|
|
for (const source of sources) {
|
|
const chip = document.createElement("div");
|
|
const healthClass = sourceHealthClass(source);
|
|
chip.className = `database-source ${healthClass}`;
|
|
if (source.message) chip.title = source.message;
|
|
|
|
const dot = document.createElement("span");
|
|
dot.className = "source-dot";
|
|
const label = document.createElement("strong");
|
|
label.textContent = sourceLabels[source.source] || source.source;
|
|
const status = document.createElement("small");
|
|
status.textContent = sourceHealthText(source);
|
|
chip.append(dot, label, status);
|
|
elements.databaseSources.append(chip);
|
|
}
|
|
}
|
|
|
|
function renderDatabaseStatus() {
|
|
const sources = state.database.sources;
|
|
const configured = sources.filter(source => source.configured);
|
|
const healthy = configured.filter(source => source.healthy);
|
|
let stateClass = "pending";
|
|
let stateText = "확인 중";
|
|
let detail = "연결 상태 확인 중";
|
|
let badge = "CHECKING";
|
|
|
|
if (!state.database.loading || sources.length) {
|
|
if (!configured.length) {
|
|
stateClass = "unconfigured";
|
|
stateText = "미설정";
|
|
detail = "Oracle 미설정 · MariaDB 미설정";
|
|
badge = "SETUP";
|
|
} else if (healthy.length === sources.length && sources.length === 2) {
|
|
stateClass = "healthy";
|
|
stateText = "정상 (2/2)";
|
|
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
|
|
badge = "CONNECTED";
|
|
} else if (configured.some(source => !source.healthy)) {
|
|
stateClass = "error";
|
|
stateText = `오류 (${healthy.length}/${configured.length})`;
|
|
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
|
|
badge = "ERROR";
|
|
} else {
|
|
stateClass = "partial";
|
|
stateText = `일부 연결 (${healthy.length}/2)`;
|
|
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
|
|
badge = "PARTIAL";
|
|
}
|
|
}
|
|
|
|
elements.databaseState.textContent = stateText;
|
|
elements.databaseStateDot.className = `status-dot ${stateClass}`;
|
|
elements.databaseSummary.className = `database-summary ${stateClass}`;
|
|
elements.databaseSummaryIcon.className = `metric-icon database-icon ${stateClass}`;
|
|
elements.databaseSummaryDetail.textContent = detail;
|
|
elements.databaseSummaryBadge.textContent = badge;
|
|
renderDatabaseSources();
|
|
}
|
|
|
|
function requestDatabaseStatus() {
|
|
if (!nativeBridge) return;
|
|
state.database.loading = true;
|
|
renderDatabaseStatus();
|
|
postNative("request-database-status", { requestId: createId() });
|
|
}
|
|
|
|
function handleDatabaseStatus(payload) {
|
|
state.database.sources = normalizedDatabaseSources(payload?.sources);
|
|
state.database.loading = false;
|
|
renderDatabaseStatus();
|
|
|
|
const signature = state.database.sources.map(source => `${source.source}:${source.configured}:${source.healthy}`).join("|");
|
|
if (signature !== state.database.lastSignature) {
|
|
const message = state.database.sources
|
|
.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`)
|
|
.join(" · ");
|
|
addLog(`DB 상태 · ${message}`);
|
|
state.database.lastSignature = signature;
|
|
}
|
|
}
|
|
|
|
function clearLiveDataTimeout() {
|
|
if (state.liveData.timeoutId !== null) clearTimeout(state.liveData.timeoutId);
|
|
state.liveData.timeoutId = null;
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
if (!value) return "";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return String(value);
|
|
return new Intl.DateTimeFormat("ko-KR", {
|
|
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
|
|
}).format(date);
|
|
}
|
|
|
|
function normalizeColumns(table) {
|
|
let columns = Array.isArray(table?.columns) ? table.columns : [];
|
|
const firstRow = Array.isArray(table?.rows) ? table.rows[0] : null;
|
|
if (!columns.length && firstRow && !Array.isArray(firstRow) && typeof firstRow === "object") {
|
|
columns = Object.keys(firstRow);
|
|
}
|
|
if (!columns.length && Array.isArray(firstRow)) {
|
|
columns = firstRow.map((_, index) => `열 ${index + 1}`);
|
|
}
|
|
return columns.map((column, index) => {
|
|
if (typeof column === "string") return { key: column, label: column };
|
|
const key = column?.key ?? column?.name ?? column?.field ?? column?.dataPropertyName ?? `column-${index}`;
|
|
const label = column?.label ?? column?.displayName ?? column?.title ?? column?.name ?? key;
|
|
return { key: String(key), label: String(label) };
|
|
});
|
|
}
|
|
|
|
function cellValue(row, column, index) {
|
|
if (Array.isArray(row)) return row[index];
|
|
if (row && typeof row === "object") return row[column.key];
|
|
return index === 0 ? row : null;
|
|
}
|
|
|
|
function formatCellValue(value) {
|
|
if (value === null || value === undefined || value === "") return "—";
|
|
if (typeof value === "number") return value.toLocaleString("ko-KR");
|
|
if (typeof value === "boolean") return value ? "예" : "아니요";
|
|
if (typeof value === "object") {
|
|
try { return JSON.stringify(value); } catch { return String(value); }
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function renderLiveTables() {
|
|
elements.liveDataTables.replaceChildren();
|
|
for (const table of state.liveData.tables) {
|
|
const section = document.createElement("section");
|
|
section.className = "live-table-card";
|
|
|
|
const header = document.createElement("header");
|
|
const heading = document.createElement("div");
|
|
const name = document.createElement("h4");
|
|
name.textContent = table?.name || "시장 데이터";
|
|
const meta = document.createElement("small");
|
|
const rows = Array.isArray(table?.rows) ? table.rows : [];
|
|
const total = Number.isFinite(Number(table?.totalRowCount)) ? Number(table.totalRowCount) : rows.length;
|
|
meta.textContent = `전체 ${total.toLocaleString("ko-KR")}건 · 표시 ${rows.length.toLocaleString("ko-KR")}건`;
|
|
heading.append(name, meta);
|
|
|
|
const source = document.createElement("span");
|
|
source.className = "live-source-badge";
|
|
const sourceName = normalizeSourceName(table?.source);
|
|
source.textContent = sourceLabels[sourceName] || table?.source || "DB";
|
|
header.append(heading, source);
|
|
section.append(header);
|
|
|
|
if (!rows.length) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "live-table-empty";
|
|
empty.textContent = "조회된 행이 없습니다.";
|
|
section.append(empty);
|
|
elements.liveDataTables.append(section);
|
|
continue;
|
|
}
|
|
|
|
const columns = normalizeColumns(table);
|
|
const scroll = document.createElement("div");
|
|
scroll.className = "live-table-scroll";
|
|
const grid = document.createElement("table");
|
|
grid.className = "live-table";
|
|
const head = document.createElement("thead");
|
|
const headRow = document.createElement("tr");
|
|
for (const column of columns) {
|
|
const th = document.createElement("th");
|
|
th.textContent = column.label;
|
|
headRow.append(th);
|
|
}
|
|
head.append(headRow);
|
|
|
|
const body = document.createElement("tbody");
|
|
for (const row of rows) {
|
|
const tr = document.createElement("tr");
|
|
columns.forEach((column, index) => {
|
|
const td = document.createElement("td");
|
|
td.textContent = formatCellValue(cellValue(row, column, index));
|
|
tr.append(td);
|
|
});
|
|
body.append(tr);
|
|
}
|
|
grid.append(head, body);
|
|
scroll.append(grid);
|
|
section.append(scroll);
|
|
elements.liveDataTables.append(section);
|
|
}
|
|
}
|
|
|
|
function renderLiveData() {
|
|
const visible = liveDataViews.has(state.activeMarket);
|
|
elements.liveDataPanel.hidden = !visible;
|
|
elements.catalogModeBadge.textContent = visible ? "DB LIVE" : "Web UI";
|
|
elements.catalogModeBadge.classList.toggle("live", visible);
|
|
if (!visible) return;
|
|
|
|
elements.liveDataTitle.textContent = `${marketTitles[state.activeMarket]} · DB 조회`;
|
|
elements.liveDataState.replaceChildren();
|
|
elements.liveDataState.className = `live-data-state ${state.liveData.status}`;
|
|
|
|
if (state.liveData.status === "loading") {
|
|
const spinner = document.createElement("span");
|
|
spinner.className = "live-spinner";
|
|
const copy = document.createElement("div");
|
|
const strong = document.createElement("strong");
|
|
const small = document.createElement("small");
|
|
strong.textContent = "데이터베이스에서 조회 중입니다.";
|
|
small.textContent = "연결 상태와 시장 데이터를 확인하고 있습니다.";
|
|
copy.append(strong, small);
|
|
elements.liveDataState.append(spinner, copy);
|
|
} else if (state.liveData.status === "error") {
|
|
const icon = document.createElement("span");
|
|
icon.className = "live-error-icon";
|
|
icon.textContent = "!";
|
|
const copy = document.createElement("div");
|
|
const strong = document.createElement("strong");
|
|
const small = document.createElement("small");
|
|
strong.textContent = "시장 데이터를 조회하지 못했습니다.";
|
|
small.textContent = state.liveData.error || "데이터베이스 연결을 확인한 뒤 다시 조회하세요.";
|
|
copy.append(strong, small);
|
|
elements.liveDataState.append(icon, copy);
|
|
} else if (state.liveData.status === "ready") {
|
|
const totalRows = state.liveData.tables.reduce((sum, table) => {
|
|
const count = Number(table?.totalRowCount);
|
|
return sum + (Number.isFinite(count) ? count : (Array.isArray(table?.rows) ? table.rows.length : 0));
|
|
}, 0);
|
|
const strong = document.createElement("strong");
|
|
const small = document.createElement("small");
|
|
strong.textContent = state.liveData.tables.length
|
|
? `${state.liveData.tables.length}개 데이터셋 · 전체 ${totalRows.toLocaleString("ko-KR")}건`
|
|
: "조회 결과가 없습니다.";
|
|
small.textContent = state.liveData.retrievedAt ? `조회 시각 ${formatDateTime(state.liveData.retrievedAt)}` : "조회 완료";
|
|
elements.liveDataState.append(strong, small);
|
|
}
|
|
|
|
renderDatabaseSources();
|
|
renderLiveTables();
|
|
}
|
|
|
|
function requestMarketData(view = state.activeMarket) {
|
|
clearLiveDataTimeout();
|
|
if (!liveDataViews.has(view)) {
|
|
state.liveData.requestId = null;
|
|
state.liveData.view = null;
|
|
state.liveData.status = "idle";
|
|
state.liveData.tables = [];
|
|
renderLiveData();
|
|
return;
|
|
}
|
|
|
|
const requestId = createId();
|
|
state.liveData.requestId = requestId;
|
|
state.liveData.view = view;
|
|
state.liveData.status = "loading";
|
|
state.liveData.tables = [];
|
|
state.liveData.retrievedAt = null;
|
|
state.liveData.error = "";
|
|
renderLiveData();
|
|
|
|
if (!nativeBridge) {
|
|
state.liveData.status = "error";
|
|
state.liveData.error = "브라우저 미리보기에서는 실제 DB 데이터를 조회할 수 없습니다.";
|
|
renderLiveData();
|
|
return;
|
|
}
|
|
|
|
postNative("request-market-data", { requestId, view, maximumRowsPerTable: 250 });
|
|
state.liveData.timeoutId = setTimeout(() => {
|
|
if (state.liveData.requestId !== requestId || state.liveData.status !== "loading") return;
|
|
state.liveData.status = "error";
|
|
state.liveData.error = "DB 응답 시간이 초과되었습니다. 연결 상태를 확인하고 다시 조회하세요.";
|
|
renderLiveData();
|
|
addLog(`${marketTitles[view]} DB 조회 시간 초과`);
|
|
}, 30000);
|
|
}
|
|
|
|
function handleMarketData(payload) {
|
|
if (!payload || payload.requestId !== state.liveData.requestId || payload.view !== state.liveData.view) return;
|
|
clearLiveDataTimeout();
|
|
state.liveData.status = "ready";
|
|
state.liveData.tables = Array.isArray(payload.tables) ? payload.tables : [];
|
|
state.liveData.retrievedAt = payload.retrievedAt || new Date().toISOString();
|
|
state.liveData.error = "";
|
|
renderLiveData();
|
|
const shownRows = state.liveData.tables.reduce((sum, table) => sum + (Array.isArray(table?.rows) ? table.rows.length : 0), 0);
|
|
addLog(`${marketTitles[payload.view]} DB 조회 완료 · ${shownRows.toLocaleString("ko-KR")}행 표시`);
|
|
}
|
|
|
|
function handleMarketDataError(payload) {
|
|
if (!payload || payload.requestId !== state.liveData.requestId || payload.view !== state.liveData.view) return;
|
|
clearLiveDataTimeout();
|
|
state.liveData.status = "error";
|
|
state.liveData.tables = [];
|
|
state.liveData.error = typeof payload.message === "string" && payload.message.trim()
|
|
? payload.message.trim()
|
|
: "데이터베이스 연결을 확인한 뒤 다시 조회하세요.";
|
|
renderLiveData();
|
|
addLog(`${marketTitles[payload.view]} DB 조회 실패 · ${state.liveData.error}`);
|
|
}
|
|
|
|
function handleNativeMessage(event) {
|
|
let message = event.data;
|
|
if (typeof message === "string") {
|
|
try { message = JSON.parse(message); } catch { return; }
|
|
}
|
|
if (!message || typeof message.type !== "string") return;
|
|
|
|
switch (message.type) {
|
|
case "app-info": {
|
|
const info = message.payload || {};
|
|
elements.bridgeState.textContent = "연결됨";
|
|
elements.runtimeLabel.textContent = `${info.framework || ".NET 8"} · ${info.architecture || "x64"}`;
|
|
elements.packageLabel.textContent = `${info.package || "MSIX"}${info.version ? ` · v${info.version}` : ""}`;
|
|
addLog(`Native Bridge 연결${info.windowsAppSdk ? ` · Windows App SDK ${info.windowsAppSdk}` : ""}`);
|
|
break;
|
|
}
|
|
case "database-status":
|
|
handleDatabaseStatus(message.payload);
|
|
break;
|
|
case "market-data":
|
|
handleMarketData(message.payload);
|
|
break;
|
|
case "market-data-error":
|
|
handleMarketDataError(message.payload);
|
|
break;
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
document.querySelector("#marketNav").addEventListener("click", event => {
|
|
const button = event.target.closest("button[data-market]");
|
|
if (!button) return;
|
|
document.querySelectorAll(".nav-item").forEach(item => item.classList.toggle("active", item === button));
|
|
state.activeMarket = button.dataset.market;
|
|
elements.workspaceTitle.textContent = marketTitles[state.activeMarket];
|
|
renderCatalog();
|
|
requestMarketData(state.activeMarket);
|
|
if (liveDataViews.has(state.activeMarket)) requestDatabaseStatus();
|
|
});
|
|
|
|
elements.catalogTabs.addEventListener("click", event => {
|
|
const button = event.target.closest("button[data-category]");
|
|
if (!button) return;
|
|
elements.catalogTabs.querySelectorAll("button").forEach(item => item.classList.toggle("active", item === button));
|
|
state.activeCategory = button.dataset.category;
|
|
renderCatalog();
|
|
});
|
|
|
|
elements.catalogSearch.addEventListener("input", event => { state.search = event.target.value; renderCatalog(); });
|
|
document.querySelector("#selectAllButton").addEventListener("click", toggleSelectAll);
|
|
document.querySelector("#moveUpButton").addEventListener("click", () => moveSelected(-1));
|
|
document.querySelector("#moveDownButton").addEventListener("click", () => moveSelected(1));
|
|
document.querySelector("#deleteButton").addEventListener("click", deleteSelected);
|
|
document.querySelector("#saveButton").addEventListener("click", savePlaylist);
|
|
document.querySelector("#loadButton").addEventListener("click", loadPlaylist);
|
|
document.querySelector("#prepareButton").addEventListener("click", prepare);
|
|
document.querySelector("#takeInButton").addEventListener("click", takeIn);
|
|
document.querySelector("#nextButton").addEventListener("click", next);
|
|
document.querySelector("#takeOutButton").addEventListener("click", takeOut);
|
|
document.querySelector("#clearLogButton").addEventListener("click", () => elements.eventLog.replaceChildren());
|
|
document.querySelector("#requestInfoButton").addEventListener("click", () => {
|
|
postNative("request-app-info");
|
|
requestDatabaseStatus();
|
|
if (liveDataViews.has(state.activeMarket)) requestMarketData(state.activeMarket);
|
|
});
|
|
document.querySelector("#retryLiveDataButton").addEventListener("click", () => {
|
|
requestDatabaseStatus();
|
|
requestMarketData(state.activeMarket);
|
|
});
|
|
|
|
document.addEventListener("keydown", event => {
|
|
if (event.ctrlKey && event.key.toLowerCase() === "k") {
|
|
event.preventDefault(); elements.catalogSearch.focus(); return;
|
|
}
|
|
if (event.key === "F2") { event.preventDefault(); prepare(); }
|
|
if (event.key === "F8") { event.preventDefault(); takeIn(); }
|
|
if (event.key === "Escape") { event.preventDefault(); takeOut(); }
|
|
});
|
|
}
|
|
|
|
function initialize() {
|
|
bindEvents();
|
|
renderCatalog();
|
|
renderPlaylist();
|
|
renderDatabaseStatus();
|
|
renderLiveData();
|
|
updateClock();
|
|
setInterval(updateClock, 1000);
|
|
addLog("WebView 운영 화면 시작");
|
|
|
|
if (nativeBridge) {
|
|
nativeBridge.addEventListener("message", handleNativeMessage);
|
|
postNative("ready");
|
|
postNative("request-app-info");
|
|
requestDatabaseStatus();
|
|
} else {
|
|
elements.bridgeState.textContent = "브라우저 미리보기";
|
|
state.database.loading = false;
|
|
renderDatabaseStatus();
|
|
addLog("Native Bridge 없이 브라우저 모드로 실행");
|
|
}
|
|
}
|
|
|
|
initialize();
|
|
})();
|