(() => { "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 storageKey = "mbn-stock-webview.playlist.v1"; const state = { activeMarket: "dashboard", activeCategory: "all", search: "", playlist: [], selectedRows: new Set(), currentIndex: -1, dragIndex: -1, playout: "IDLE" }; const elements = { bridgeState: document.querySelector("#bridgeState"), databaseState: document.querySelector("#databaseState"), 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"), 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 handleNativeMessage(event) { const message = event.data; if (!message || message.type !== "app-info") return; const info = message.payload; elements.bridgeState.textContent = "연결됨"; elements.databaseState.textContent = info.databaseConfigured ? "연결됨" : "미설정"; elements.runtimeLabel.textContent = `${info.framework} · ${info.architecture}`; elements.packageLabel.textContent = `${info.package} · v${info.version}`; addLog(`Native Bridge 연결 · Windows App SDK ${info.windowsAppSdk}`); } 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(); }); 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")); 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(); updateClock(); setInterval(updateClock, 1000); addLog("WebView 운영 화면 시작"); if (nativeBridge) { nativeBridge.addEventListener("message", handleNativeMessage); postNative("ready"); } else { elements.bridgeState.textContent = "브라우저 미리보기"; addLog("Native Bridge 없이 브라우저 모드로 실행"); } } initialize(); })();