6932 lines
310 KiB
JavaScript
6932 lines
310 KiB
JavaScript
(() => {
|
||
"use strict";
|
||
|
||
const playoutSafety = globalThis.MbnPlayoutSafety;
|
||
if (!playoutSafety) throw new Error("Playout safety module is unavailable.");
|
||
const operatorWorkflow = globalThis.MbnOperatorWorkflow;
|
||
if (!operatorWorkflow) throw new Error("Operator workflow module is unavailable.");
|
||
const fixedWorkflow = globalThis.MbnLegacyFixedWorkflow;
|
||
if (!fixedWorkflow) throw new Error("Legacy fixed workflow module is unavailable.");
|
||
const industryWorkflow = globalThis.MbnIndustryWorkflow;
|
||
if (!industryWorkflow) throw new Error("Legacy industry workflow module is unavailable.");
|
||
const comparisonWorkflow = globalThis.MbnComparisonWorkflow;
|
||
if (!comparisonWorkflow) throw new Error("Legacy comparison workflow module is unavailable.");
|
||
const themeWorkflow = globalThis.MbnThemeWorkflow;
|
||
if (!themeWorkflow) throw new Error("Legacy theme workflow module is unavailable.");
|
||
const overseasWorkflow = globalThis.MbnOverseasWorkflow;
|
||
if (!overseasWorkflow) throw new Error("Legacy overseas workflow module is unavailable.");
|
||
const expertWorkflow = globalThis.MbnExpertWorkflow;
|
||
if (!expertWorkflow) throw new Error("Legacy expert workflow module is unavailable.");
|
||
const tradingHaltWorkflow = globalThis.MbnTradingHaltWorkflow;
|
||
if (!tradingHaltWorkflow) throw new Error("Legacy trading-halt workflow module is unavailable.");
|
||
const candleOptionsWorkflow = globalThis.MbnCandleOptionsWorkflow;
|
||
if (!candleOptionsWorkflow) throw new Error("Global candle-options workflow module is unavailable.");
|
||
const manualListsWorkflow = globalThis.MbnManualListsWorkflow;
|
||
if (!manualListsWorkflow) throw new Error("Manual lists workflow module is unavailable.");
|
||
const manualFinancialWorkflow = globalThis.MbnManualFinancialWorkflow;
|
||
if (!manualFinancialWorkflow) throw new Error("Manual financial workflow module is unavailable.");
|
||
const namedManualRestoreWorkflow = globalThis.MbnNamedManualRestoreWorkflow;
|
||
if (!namedManualRestoreWorkflow) throw new Error("Named manual restore workflow module is unavailable.");
|
||
const operatorCatalogWorkflow = globalThis.MbnOperatorCatalogWorkflow;
|
||
if (!operatorCatalogWorkflow) throw new Error("Operator catalog workflow module is unavailable.");
|
||
const manualListsUiModule = globalThis.MbnManualListsUi;
|
||
if (!manualListsUiModule) throw new Error("Manual lists UI module is unavailable.");
|
||
const manualFinancialUiModule = globalThis.MbnManualFinancialUi;
|
||
if (!manualFinancialUiModule) throw new Error("Manual financial UI module is unavailable.");
|
||
const operatorCatalogUiModule = globalThis.MbnOperatorCatalogUi;
|
||
if (!operatorCatalogUiModule) throw new Error("Operator catalog UI module is unavailable.");
|
||
const namedPlaylistWorkflow = globalThis.MbnNamedPlaylistWorkflow;
|
||
if (!namedPlaylistWorkflow) throw new Error("Named playlist workflow module is unavailable.");
|
||
const DEFAULT_FADE_DURATION = 6;
|
||
const namedPagedBuilderKeys = new Set(["s5074", "s5077", "s5088"]);
|
||
const namedWriteOperations = new Set(["create", "replace", "delete"]);
|
||
const namedInvalidBuilderKey = "s0";
|
||
|
||
const catalog = [
|
||
{ builderKey: "s5001", code: "5001", aliases: ["5001", "N5001"], title: "1열판 · 기본", detail: "국내·NXT·해외 현재가", category: "plate", market: "kospi" },
|
||
{ builderKey: "s5006", code: "5006", title: "1열판 · 시가 상세", detail: "현재·시가·고가·저가", category: "plate", market: "kosdaq" },
|
||
{ builderKey: "s5011", code: "5011", title: "1열판 · 상세", detail: "52주·시총·순위", category: "plate", market: "kospi" },
|
||
{ builderKey: "s5016", code: "5016", title: "3열판", detail: "주요 지수 3열 구성", category: "plate", market: "index" },
|
||
{ builderKey: "s50160", code: "50160", title: "2열판 · 보조", detail: "원면·금·국채 2열", category: "plate", market: "overseas" },
|
||
{ builderKey: "s5023", code: "5023", title: "매매동향 · 표", detail: "지수별 투자자 동향", category: "market", market: "index" },
|
||
{ builderKey: "s5024", code: "5024", title: "매매동향 · 막대", detail: "주체별 매매 동향", category: "chart", market: "kospi" },
|
||
{ builderKey: "s5025", code: "5025", title: "순매도 상위", detail: "수동 상위 종목 표", category: "market", market: "kospi" },
|
||
{ builderKey: "s5026", code: "5026", title: "비교분석 · 캔들", detail: "두 종목 주간 비교", category: "chart", market: "compare" },
|
||
{ builderKey: "s5029", code: "5029", title: "수익률 비교", detail: "두 종목 수익률", category: "chart", market: "compare" },
|
||
{ builderKey: "s5032", code: "5032", aliases: ["5032", "8018", "8032"], title: "2열판 · 선물", detail: "지수·선물 2열", category: "plate", market: "index" },
|
||
{ builderKey: "s5037", code: "5037", title: "거래원", detail: "매수·매도 거래원", category: "market", market: "kospi" },
|
||
{ builderKey: "s5074", code: "5074", title: "5단 표", detail: "5개 단위 · 최대 20페이지", category: "plate", market: "kosdaq" },
|
||
{ builderKey: "s5076", code: "5076", title: "주요매출 구성", detail: "매출 비중 원그래프", category: "chart", market: "kospi" },
|
||
{ builderKey: "s5077", code: "5077", title: "6종목 현재가", detail: "6개 단위 · 최대 20페이지", category: "plate", market: "kospi" },
|
||
{ builderKey: "s5078", code: "5078", title: "섹터 지수", detail: "미국·국내 섹터 막대", category: "chart", market: "overseas" },
|
||
{ builderKey: "s5079", code: "5079", title: "성장성 지표", detail: "복수 성장 지표 선그래프", category: "chart", market: "kospi" },
|
||
{ builderKey: "s5080", code: "5080", title: "매출액 지표", detail: "분기별 매출 막대", category: "chart", market: "kospi" },
|
||
{ builderKey: "s5081", code: "5081", title: "영업이익 지표", detail: "분기별 영업이익", category: "chart", market: "kospi" },
|
||
{ builderKey: "s5082", code: "5082", title: "주체별 매매동향", detail: "코스피·코스닥·200", category: "market", market: "index" },
|
||
{ builderKey: "s5083", code: "5083", title: "주체별 선그래프", detail: "개인·외국인·기관", category: "chart", market: "kospi" },
|
||
{ builderKey: "s5084", code: "5084", title: "매매동향 라인", detail: "코스피·코스닥", category: "chart", market: "index" },
|
||
{ builderKey: "s5085", code: "5085", title: "프로그램 매매", detail: "구분별 프로그램 금액", category: "market", market: "index" },
|
||
{ builderKey: "s5086", code: "5086", title: "수익률 그래프", detail: "지수·종목 기간 수익률", category: "chart", market: "compare" },
|
||
{ builderKey: "s50860", code: "50860", title: "라인 그래프", detail: "지수·종목 기간 추이", category: "chart", market: "compare" },
|
||
{ builderKey: "s5087", code: "5087", title: "비교분석 · 라인", detail: "두 종목 라인 비교", category: "chart", market: "compare" },
|
||
{ builderKey: "s5088", code: "5088", title: "12종목 현재가", detail: "12개 단위 · 최대 20페이지", category: "plate", market: "theme" },
|
||
{ builderKey: "s6001", code: "6001", title: "해외증시 이미지", detail: "해외 지수·원자재", category: "market", market: "overseas" },
|
||
{ builderKey: "s6067", code: "6067", title: "기관 순매수", detail: "기관 순매수 현황", category: "market", market: "kospi" },
|
||
{ builderKey: "s8001", code: "8001", aliases: ["8001", "8002"], title: "네모 그래프", detail: "업종별 등락 큐브", category: "chart", market: "index" },
|
||
{ builderKey: "s8003", code: "8003", title: "호가창", detail: "매수·매도 10단 호가", category: "market", market: "kospi" },
|
||
{ builderKey: "s8010", code: "8035", aliases: ["8035", "8061", "8040", "8046", "8051", "8056"], title: "캔들 그래프", detail: "8035·8061·8040·8046·8051·8056", category: "chart", market: "index" },
|
||
{ builderKey: "s8018", code: "8018", aliases: ["8018", "8032", "5032"], title: "2열판 · 시장", detail: "국내·NXT·해외 2열", category: "plate", market: "index" },
|
||
{ builderKey: "s8067", code: "8067", aliases: ["8067", "5068", "5070", "5072"], title: "글로벌 증시 지도", detail: "8067·5068·5070·5072", category: "market", market: "overseas" },
|
||
{ builderKey: "s8086", code: "8086", title: "유가·금값", detail: "원본 MainForm 미도달 장면", category: "market", market: "exchange", reachable: false }
|
||
];
|
||
if (catalog.length !== 35) throw new Error("Legacy scene catalog must contain exactly 35 builders.");
|
||
const sceneAliases = item => Array.isArray(item?.aliases) ? item.aliases : [String(item?.code || "")];
|
||
const reachableAliases = new Set(catalog
|
||
.filter(item => item.reachable !== false)
|
||
.flatMap(sceneAliases));
|
||
if (reachableAliases.size !== 45) throw new Error("Legacy scene catalog must expose all 45 active aliases.");
|
||
const operatorInputBuilderKeys = new Set([
|
||
"s5001", "s5006", "s5011", "s5026", "s5029", "s5037", "s5074",
|
||
"s5076", "s5077", "s5079", "s5080", "s5081", "s5086", "s5087",
|
||
"s5088", "s8003", "s8010"
|
||
]);
|
||
|
||
const sceneSelectionDefaults = Object.freeze({
|
||
s5001: ["KOSPI", "삼성전자", "", "CURRENT", ""],
|
||
s5006: ["KOSPI_STOCK", "삼성전자", "", "CURRENT", "005930"],
|
||
s5011: ["KOSPI_STOCK", "삼성전자", "", "Valuation", "005930"],
|
||
s5016: ["ALL", "INDEX", "", "UnitedStatesIndices", ""],
|
||
s50160: ["ALL", "INDEX", "", "Cotton", ""],
|
||
s5023: ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "TABLE_GRAPH", ""],
|
||
s5024: ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "BAR_GRAPH", ""],
|
||
s5025: ["INDIVIDUAL", "INDEX", "MANUAL_NET_SELL", "MANUAL_NET_SELL", ""],
|
||
s5026: ["STOCK", "삼성전자,SK하이닉스", "", "", ""],
|
||
s5029: ["STOCK", "삼성전자,SK하이닉스", "", "5일", ""],
|
||
s5032: ["INDEX", "FUTURES,Kospi", "", "CURRENT", ""],
|
||
s5037: ["KOSPI", "삼성전자", "TRADER", "TABLE_GRAPH", ""],
|
||
s5074: ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"],
|
||
s5076: ["KOSPI", "삼성전자", "", "", ""],
|
||
s5077: ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"],
|
||
s5078: ["FOREIGN_INDEX", "DOW", "US_SECTOR_INDEX", "", ""],
|
||
s5079: ["KOSPI", "삼성전자", "GROWTH_METRICS", "", ""],
|
||
s5080: ["KOSPI", "삼성전자", "SALES", "", ""],
|
||
s5081: ["KOSPI", "삼성전자", "", "", ""],
|
||
s5082: ["ALL", "INDEX", "", "", ""],
|
||
s5083: ["ALL", "INDEX", "INDIVIDUAL_TRADING_TREND", "LINE_GRAPH", ""],
|
||
s5084: ["KOSPI", "INDEX", "KOSPI_TRADING_TREND", "LINE_GRAPH", ""],
|
||
s5085: ["ALL", "INDEX", "PROGRAM_TRADING", "TABLE_GRAPH", ""],
|
||
s5086: ["EXCHANGE_RATE", "원달러", "", "20일", ""],
|
||
s50860: ["EXCHANGE_RATE", "원달러", "", "20일", ""],
|
||
s5087: ["STOCK", "삼성전자,SK하이닉스", "", "", ""],
|
||
s5088: ["PAGED_DOMESTIC_KOSPI", "INDEX", "", "MARKET_CAP", "$TODAY"],
|
||
s6001: ["FOREIGN_INDEX", "DOW", "", "DowJones", ""],
|
||
s6067: ["ALL", "INDEX", "INSTITUTION_NET_BUY", "TABLE_GRAPH", ""],
|
||
s8001: ["ALL", "KOSPI", "", "", ""],
|
||
s8003: ["KOSPI", "삼성전자", "ORDER_BOOK", "TABLE_GRAPH", "005930"],
|
||
s8010: ["KOSPI_INDEX", "INDEX", "", "PRICE", ""],
|
||
s8018: ["INDEX", "Kospi,Kosdaq", "", "CURRENT", ""],
|
||
s8067: ["ALL", "INDEX", "", "", ""]
|
||
});
|
||
if (Object.keys(sceneSelectionDefaults).length !== 34) {
|
||
throw new Error("Every reachable legacy builder must have a Web selection preset.");
|
||
}
|
||
|
||
function localDateText() {
|
||
const now = new Date();
|
||
const year = String(now.getFullYear()).padStart(4, "0");
|
||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||
const day = String(now.getDate()).padStart(2, "0");
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
function defaultSceneSelection(builderKey) {
|
||
const values = sceneSelectionDefaults[builderKey];
|
||
if (!values) return undefined;
|
||
return {
|
||
groupCode: values[0],
|
||
subject: values[1],
|
||
graphicType: values[2],
|
||
subtype: values[3],
|
||
dataCode: values[4] === "$TODAY" ? localDateText() : values[4]
|
||
};
|
||
}
|
||
|
||
const marketTitles = {
|
||
dashboard: "마이그레이션 대시보드", overseas: "해외 그래픽", exchange: "환율 그래픽",
|
||
index: "지수 그래픽", kospi: "코스피 그래픽", kosdaq: "코스닥 그래픽",
|
||
compare: "종목 비교", theme: "테마 관리", "foreign-stock": "해외종목 그래픽",
|
||
expert: "전문가 추천", halted: "거래 정지"
|
||
};
|
||
const liveDataViews = new Set(["index", "overseas"]);
|
||
const databaseBackedViews = new Set([
|
||
"kospi", "kosdaq", "index", "overseas", "theme", "foreign-stock", "expert", "halted"
|
||
]);
|
||
const sourceLabels = { oracle: "Oracle", mariaDb: "MariaDB" };
|
||
|
||
const storageKey = "mbn-stock-webview.playlist.v1";
|
||
const namedPlaylistBackupStorageKey = "mbn-stock-webview.playlist.before-db-load.v1";
|
||
const comparisonStorageKey = "mbn-stock-webview.comparison-pairs.v1";
|
||
const state = {
|
||
activeMarket: "dashboard",
|
||
activeCategory: "all",
|
||
stockWorkflowCollapsed: false,
|
||
fixedWorkflowCollapsed: false,
|
||
industryWorkflowCollapsed: false,
|
||
fixedSearch: "",
|
||
search: "",
|
||
playlist: [],
|
||
selectedRows: new Set(),
|
||
selectionAnchorIndex: -1,
|
||
currentIndex: -1,
|
||
dragIndex: -1,
|
||
candleOptions: {
|
||
ma5: true,
|
||
ma20: true,
|
||
invalidIds: []
|
||
},
|
||
manualFinancialRestore: {
|
||
entries: new Map()
|
||
},
|
||
namedPlaylist: {
|
||
open: false,
|
||
status: "idle",
|
||
pending: null,
|
||
pagePlanPending: null,
|
||
pagePlanError: null,
|
||
restoration: null,
|
||
definitions: [],
|
||
selectedProgramCode: "",
|
||
suggestedProgramCode: "",
|
||
canMutate: false,
|
||
writeQuarantined: false,
|
||
error: "",
|
||
loadedDefinition: null,
|
||
guardByEntryId: new Map(),
|
||
manualRestoreFinalized: true,
|
||
selectAfterRefresh: ""
|
||
},
|
||
playout: {
|
||
mode: "unknown",
|
||
engineState: "IDLE",
|
||
connectionState: "unavailable",
|
||
processDetected: false,
|
||
connected: false,
|
||
lastKtapConnectState: "not-attempted",
|
||
ktapConnectAttempted: false,
|
||
ktapConnectAccepted: false,
|
||
ktapHelloObserved: null,
|
||
networkMonitoringRecordExpected: false,
|
||
networkMonitoringCheckRequired: false,
|
||
commandAvailable: false,
|
||
liveTakeInAllowed: false,
|
||
nativeOutcomeUnknown: false,
|
||
browserCorrelationQuarantined: false,
|
||
sessionQuarantined: false,
|
||
operationTimeoutMs: 5000,
|
||
responseTimeoutMs: 10000,
|
||
message: "송출 어댑터 상태를 확인하고 있습니다.",
|
||
preparedCode: null,
|
||
onAirCode: null,
|
||
preparedCue: null,
|
||
currentCueIndex: -1,
|
||
currentEntryId: null,
|
||
builderKey: null,
|
||
pageIndex: 0,
|
||
pageCount: 0,
|
||
pageSize: 0,
|
||
itemCount: 0,
|
||
currentPageItemCount: 0,
|
||
isLastPage: true,
|
||
nextKind: "none",
|
||
previewFields: [],
|
||
refreshActive: false,
|
||
refreshNextAt: null,
|
||
refreshLastSuccessAt: null,
|
||
refreshFaulted: false,
|
||
refreshFaultCode: null,
|
||
refreshFaultMessage: null,
|
||
changedAt: null,
|
||
changedAtMs: 0,
|
||
latestStatusRequestId: null,
|
||
pending: null,
|
||
takeInAfterPrepare: false,
|
||
error: null,
|
||
lastSignature: ""
|
||
},
|
||
background: {
|
||
requestId: null,
|
||
enabled: false,
|
||
kind: "none",
|
||
fileName: "",
|
||
canChange: false,
|
||
pending: false,
|
||
message: "원본 F2/F3 배경 상태를 확인하고 있습니다.",
|
||
error: ""
|
||
},
|
||
database: { sources: [], loading: true, lastSignature: "", latestSequence: 0 },
|
||
liveData: {
|
||
requestId: null,
|
||
view: null,
|
||
status: "idle",
|
||
tables: [],
|
||
retrievedAt: null,
|
||
error: "",
|
||
timeoutId: null
|
||
},
|
||
stockSearch: {
|
||
requestId: null,
|
||
query: "",
|
||
status: "idle",
|
||
results: [],
|
||
selected: null,
|
||
totalRowCount: 0,
|
||
truncated: false,
|
||
error: "",
|
||
timeoutId: null
|
||
},
|
||
industry: {
|
||
requestId: null,
|
||
market: null,
|
||
status: "idle",
|
||
results: [],
|
||
totalRowCount: 0,
|
||
highlighted: null,
|
||
pairFirst: null,
|
||
pairSecond: null,
|
||
search: "",
|
||
error: "",
|
||
timeoutId: null
|
||
},
|
||
comparison: {
|
||
source: "domestic",
|
||
draftFirst: null,
|
||
draftSecond: null,
|
||
pairList: comparisonWorkflow.deleteAllPairs(),
|
||
selectedPairId: null,
|
||
domestic: {
|
||
requestId: null,
|
||
query: "",
|
||
status: "idle",
|
||
results: [],
|
||
totalRowCount: 0,
|
||
error: "",
|
||
timeoutId: null
|
||
},
|
||
world: {
|
||
requestId: null,
|
||
query: "",
|
||
status: "idle",
|
||
results: [],
|
||
totalRowCount: 0,
|
||
error: "",
|
||
timeoutId: null
|
||
}
|
||
},
|
||
theme: {
|
||
nxtSession: "preMarket",
|
||
sort: "INPUT_ORDER",
|
||
search: {
|
||
requestId: null,
|
||
query: "",
|
||
status: "idle",
|
||
results: [],
|
||
totalRowCount: 0,
|
||
truncated: false,
|
||
error: "",
|
||
timeoutId: null
|
||
},
|
||
selected: null,
|
||
preview: {
|
||
requestId: null,
|
||
status: "idle",
|
||
items: [],
|
||
totalRowCount: 0,
|
||
truncated: false,
|
||
error: "",
|
||
timeoutId: null,
|
||
catalogContext: null
|
||
}
|
||
},
|
||
overseas: {
|
||
source: "industry",
|
||
industry: {
|
||
request: null, status: "idle", results: [], selected: null,
|
||
totalRowCount: 0, truncated: false, error: "", timeoutId: null
|
||
},
|
||
stock: {
|
||
request: null, status: "idle", results: [], selected: null,
|
||
totalRowCount: 0, truncated: false, error: "", timeoutId: null
|
||
}
|
||
},
|
||
expert: {
|
||
search: {
|
||
request: null, status: "idle", results: [], totalRowCount: 0,
|
||
truncated: false, error: "", timeoutId: null
|
||
},
|
||
selected: null,
|
||
selectable: null,
|
||
preview: {
|
||
request: null, response: null, status: "idle", items: [],
|
||
totalRowCount: 0, truncated: false, error: "", timeoutId: null,
|
||
catalogContext: null
|
||
}
|
||
},
|
||
tradingHalt: {
|
||
request: null,
|
||
status: "idle",
|
||
results: [],
|
||
selected: null,
|
||
totalRowCount: 0,
|
||
truncated: false,
|
||
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"),
|
||
stockWorkflow: document.querySelector("#stockWorkflow"),
|
||
stockSearchForm: document.querySelector("#stockSearchForm"),
|
||
stockSearchInput: document.querySelector("#stockSearchInput"),
|
||
stockSearchButton: document.querySelector("#stockSearchButton"),
|
||
stockSearchBadge: document.querySelector("#stockSearchBadge"),
|
||
stockWorkflowToggle: document.querySelector("#stockWorkflowToggle"),
|
||
stockSearchState: document.querySelector("#stockSearchState"),
|
||
stockSearchResults: document.querySelector("#stockSearchResults"),
|
||
selectedStock: document.querySelector("#selectedStock"),
|
||
selectedStockName: document.querySelector("#selectedStockName"),
|
||
selectedStockMeta: document.querySelector("#selectedStockMeta"),
|
||
stockMa5: document.querySelector("#stockMa5"), stockMa20: document.querySelector("#stockMa20"),
|
||
stockCutList: document.querySelector("#stockCutList"),
|
||
manualStockActions: document.querySelector("#manualStockActions"),
|
||
legacyFixedWorkflow: document.querySelector("#legacyFixedWorkflow"),
|
||
legacyFixedWorkflowTitle: document.querySelector("#legacyFixedWorkflowTitle"),
|
||
legacyFixedCount: document.querySelector("#legacyFixedCount"),
|
||
legacyFixedToggle: document.querySelector("#legacyFixedToggle"),
|
||
legacyFixedSearch: document.querySelector("#legacyFixedSearch"),
|
||
legacyFixedState: document.querySelector("#legacyFixedState"),
|
||
legacyFixedSections: document.querySelector("#legacyFixedSections"),
|
||
industryWorkflow: document.querySelector("#industryWorkflow"),
|
||
industryWorkflowTitle: document.querySelector("#industryWorkflowTitle"),
|
||
industryCount: document.querySelector("#industryCount"),
|
||
industryRetry: document.querySelector("#industryRetry"),
|
||
industryWorkflowToggle: document.querySelector("#industryWorkflowToggle"),
|
||
industrySearch: document.querySelector("#industrySearch"),
|
||
industryState: document.querySelector("#industryState"),
|
||
industryResults: document.querySelector("#industryResults"),
|
||
industryPrimary: document.querySelector("#industryPrimary"),
|
||
industrySecondary: document.querySelector("#industrySecondary"),
|
||
industrySwap: document.querySelector("#industrySwap"),
|
||
industryClear: document.querySelector("#industryClear"),
|
||
industryCutList: document.querySelector("#industryCutList"),
|
||
comparisonWorkflow: document.querySelector("#comparisonWorkflow"),
|
||
comparisonPairCount: document.querySelector("#comparisonPairCount"),
|
||
comparisonSourceTabs: document.querySelector("#comparisonSourceTabs"),
|
||
comparisonDomesticPanel: document.querySelector("#comparisonDomesticPanel"),
|
||
comparisonDomesticSearchForm: document.querySelector("#comparisonDomesticSearchForm"),
|
||
comparisonDomesticSearch: document.querySelector("#comparisonDomesticSearch"),
|
||
comparisonDomesticSearchButton: document.querySelector("#comparisonDomesticSearchButton"),
|
||
comparisonDomesticState: document.querySelector("#comparisonDomesticState"),
|
||
comparisonDomesticResults: document.querySelector("#comparisonDomesticResults"),
|
||
comparisonFixedPanel: document.querySelector("#comparisonFixedPanel"),
|
||
comparisonFixedTargets: document.querySelector("#comparisonFixedTargets"),
|
||
comparisonWorldPanel: document.querySelector("#comparisonWorldPanel"),
|
||
comparisonWorldSearchForm: document.querySelector("#comparisonWorldSearchForm"),
|
||
comparisonWorldSearch: document.querySelector("#comparisonWorldSearch"),
|
||
comparisonWorldSearchButton: document.querySelector("#comparisonWorldSearchButton"),
|
||
comparisonWorldState: document.querySelector("#comparisonWorldState"),
|
||
comparisonWorldResults: document.querySelector("#comparisonWorldResults"),
|
||
comparisonFirst: document.querySelector("#comparisonFirst"),
|
||
comparisonSecond: document.querySelector("#comparisonSecond"),
|
||
comparisonSwap: document.querySelector("#comparisonSwap"),
|
||
comparisonClear: document.querySelector("#comparisonClear"),
|
||
comparisonSavePair: document.querySelector("#comparisonSavePair"),
|
||
comparisonMoveUp: document.querySelector("#comparisonMoveUp"),
|
||
comparisonMoveDown: document.querySelector("#comparisonMoveDown"),
|
||
comparisonDeletePair: document.querySelector("#comparisonDeletePair"),
|
||
comparisonDeleteAll: document.querySelector("#comparisonDeleteAll"),
|
||
comparisonPairList: document.querySelector("#comparisonPairList"),
|
||
comparisonYieldBatch: document.querySelector("#comparisonYieldBatch"),
|
||
comparisonActionList: document.querySelector("#comparisonActionList"),
|
||
themeWorkflow: document.querySelector("#themeWorkflow"),
|
||
themeCount: document.querySelector("#themeCount"),
|
||
themeSearchForm: document.querySelector("#themeSearchForm"),
|
||
themeSearch: document.querySelector("#themeSearch"),
|
||
themeNxtSession: document.querySelector("#themeNxtSession"),
|
||
themeSearchButton: document.querySelector("#themeSearchButton"),
|
||
themeSearchState: document.querySelector("#themeSearchState"),
|
||
themeResults: document.querySelector("#themeResults"),
|
||
selectedTheme: document.querySelector("#selectedTheme"),
|
||
selectedThemeTitle: document.querySelector("#selectedThemeTitle"),
|
||
selectedThemeMeta: document.querySelector("#selectedThemeMeta"),
|
||
themeSort: document.querySelector("#themeSort"),
|
||
themePreviewCount: document.querySelector("#themePreviewCount"),
|
||
themePreviewState: document.querySelector("#themePreviewState"),
|
||
themePreviewItems: document.querySelector("#themePreviewItems"),
|
||
themeActionList: document.querySelector("#themeActionList"),
|
||
themeCatalogManageButton: document.querySelector("#themeCatalogManageButton"),
|
||
overseasWorkflow: document.querySelector("#overseasWorkflow"),
|
||
overseasCount: document.querySelector("#overseasCount"),
|
||
overseasSourceTabs: document.querySelector("#overseasSourceTabs"),
|
||
overseasSearchForm: document.querySelector("#overseasSearchForm"),
|
||
overseasSearch: document.querySelector("#overseasSearch"),
|
||
overseasSearchButton: document.querySelector("#overseasSearchButton"),
|
||
overseasSearchState: document.querySelector("#overseasSearchState"),
|
||
overseasResults: document.querySelector("#overseasResults"),
|
||
selectedOverseas: document.querySelector("#selectedOverseas"),
|
||
selectedOverseasName: document.querySelector("#selectedOverseasName"),
|
||
selectedOverseasMeta: document.querySelector("#selectedOverseasMeta"),
|
||
overseasMa5: document.querySelector("#overseasMa5"),
|
||
overseasMa20: document.querySelector("#overseasMa20"),
|
||
overseasActionList: document.querySelector("#overseasActionList"),
|
||
expertWorkflow: document.querySelector("#expertWorkflow"),
|
||
expertCount: document.querySelector("#expertCount"),
|
||
expertSearchForm: document.querySelector("#expertSearchForm"),
|
||
expertSearch: document.querySelector("#expertSearch"),
|
||
expertSearchButton: document.querySelector("#expertSearchButton"),
|
||
expertSearchState: document.querySelector("#expertSearchState"),
|
||
expertResults: document.querySelector("#expertResults"),
|
||
selectedExpert: document.querySelector("#selectedExpert"),
|
||
selectedExpertName: document.querySelector("#selectedExpertName"),
|
||
selectedExpertMeta: document.querySelector("#selectedExpertMeta"),
|
||
expertPreviewCount: document.querySelector("#expertPreviewCount"),
|
||
expertPreviewState: document.querySelector("#expertPreviewState"),
|
||
expertPreviewItems: document.querySelector("#expertPreviewItems"),
|
||
expertActionList: document.querySelector("#expertActionList"),
|
||
expertCatalogManageButton: document.querySelector("#expertCatalogManageButton"),
|
||
tradingHaltWorkflow: document.querySelector("#tradingHaltWorkflow"),
|
||
tradingHaltCount: document.querySelector("#tradingHaltCount"),
|
||
tradingHaltSearchForm: document.querySelector("#tradingHaltSearchForm"),
|
||
tradingHaltSearch: document.querySelector("#tradingHaltSearch"),
|
||
tradingHaltSearchButton: document.querySelector("#tradingHaltSearchButton"),
|
||
tradingHaltSearchState: document.querySelector("#tradingHaltSearchState"),
|
||
tradingHaltResults: document.querySelector("#tradingHaltResults"),
|
||
selectedTradingHalt: document.querySelector("#selectedTradingHalt"),
|
||
selectedTradingHaltName: document.querySelector("#selectedTradingHaltName"),
|
||
selectedTradingHaltMeta: document.querySelector("#selectedTradingHaltMeta"),
|
||
tradingHaltMa5: document.querySelector("#tradingHaltMa5"),
|
||
tradingHaltMa20: document.querySelector("#tradingHaltMa20"),
|
||
tradingHaltActionList: document.querySelector("#tradingHaltActionList"),
|
||
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"),
|
||
toggleAllEnabled: document.querySelector("#toggleAllEnabledButton"),
|
||
selectAll: document.querySelector("#selectAllButton"),
|
||
moveUpButton: document.querySelector("#moveUpButton"),
|
||
moveDownButton: document.querySelector("#moveDownButton"),
|
||
deleteButton: document.querySelector("#deleteButton"),
|
||
deleteAllButton: document.querySelector("#deleteAllButton"),
|
||
globalMa5: document.querySelector("#globalMa5"),
|
||
globalMa20: document.querySelector("#globalMa20"),
|
||
loadButton: document.querySelector("#loadButton"),
|
||
saveButton: document.querySelector("#saveButton"),
|
||
namedPlaylistOpenButton: document.querySelector("#namedPlaylistOpenButton"),
|
||
namedPlaylistPanel: document.querySelector("#namedPlaylistPanel"),
|
||
namedPlaylistStateBadge: document.querySelector("#namedPlaylistStateBadge"),
|
||
namedPlaylistRefreshButton: document.querySelector("#namedPlaylistRefreshButton"),
|
||
namedPlaylistCloseButton: document.querySelector("#namedPlaylistCloseButton"),
|
||
namedPlaylistStatus: document.querySelector("#namedPlaylistStatus"),
|
||
namedPlaylistDefinitions: document.querySelector("#namedPlaylistDefinitions"),
|
||
namedPlaylistSelectionSummary: document.querySelector("#namedPlaylistSelectionSummary"),
|
||
namedPlaylistLoadButton: document.querySelector("#namedPlaylistLoadButton"),
|
||
namedPlaylistPageRetryButton: document.querySelector("#namedPlaylistPageRetryButton"),
|
||
namedPlaylistRestoreBackupButton: document.querySelector("#namedPlaylistRestoreBackupButton"),
|
||
namedPlaylistProgramCode: document.querySelector("#namedPlaylistProgramCode"),
|
||
namedPlaylistProgramTitle: document.querySelector("#namedPlaylistProgramTitle"),
|
||
namedPlaylistCreateButton: document.querySelector("#namedPlaylistCreateButton"),
|
||
namedPlaylistReplaceButton: document.querySelector("#namedPlaylistReplaceButton"),
|
||
namedPlaylistDeleteButton: document.querySelector("#namedPlaylistDeleteButton"),
|
||
namedPlaylistGuardSummary: document.querySelector("#namedPlaylistGuardSummary"),
|
||
previewTitle: document.querySelector("#previewTitle"), previewSubtitle: document.querySelector("#previewSubtitle"),
|
||
playoutState: document.querySelector("#playoutState"),
|
||
playoutSummary: document.querySelector("#playoutSummary"),
|
||
playoutSummaryIcon: document.querySelector("#playoutSummaryIcon"),
|
||
playoutSummaryDetail: document.querySelector("#playoutSummaryDetail"),
|
||
playoutSummaryBadge: document.querySelector("#playoutSummaryBadge"),
|
||
tornadoProcessDot: document.querySelector("#tornadoProcessDot"),
|
||
tornadoProcessState: document.querySelector("#tornadoProcessState"),
|
||
playoutConnectionDot: document.querySelector("#playoutConnectionDot"),
|
||
playoutConnectionState: document.querySelector("#playoutConnectionState"),
|
||
playoutSafetyMode: document.querySelector("#playoutSafetyMode"),
|
||
backgroundFileName: document.querySelector("#backgroundFileName"),
|
||
backgroundStateText: document.querySelector("#backgroundStateText"),
|
||
chooseBackgroundButton: document.querySelector("#chooseBackgroundButton"),
|
||
toggleBackgroundButton: document.querySelector("#toggleBackgroundButton"),
|
||
playoutStatusMessage: document.querySelector("#playoutStatusMessage"),
|
||
playoutSceneCode: document.querySelector("#playoutSceneCode"),
|
||
playoutEntryId: document.querySelector("#playoutEntryId"),
|
||
playoutBuilderKey: document.querySelector("#playoutBuilderKey"),
|
||
playoutPageNumber: document.querySelector("#playoutPageNumber"),
|
||
playoutPageSize: document.querySelector("#playoutPageSize"),
|
||
playoutItemCount: document.querySelector("#playoutItemCount"),
|
||
playoutCurrentRows: document.querySelector("#playoutCurrentRows"),
|
||
playoutLastPage: document.querySelector("#playoutLastPage"),
|
||
playoutNextKind: document.querySelector("#playoutNextKind"),
|
||
playoutRefreshState: document.querySelector("#playoutRefreshState"),
|
||
sceneDataPreview: document.querySelector("#sceneDataPreview"),
|
||
playoutError: document.querySelector("#playoutError"),
|
||
playoutErrorMessage: document.querySelector("#playoutErrorMessage"),
|
||
sceneSelectionForm: document.querySelector("#sceneSelectionForm"),
|
||
sceneGroupCode: document.querySelector("#sceneGroupCode"),
|
||
sceneSubject: document.querySelector("#sceneSubject"),
|
||
sceneGraphicType: document.querySelector("#sceneGraphicType"),
|
||
sceneSubtype: document.querySelector("#sceneSubtype"),
|
||
sceneDataCode: document.querySelector("#sceneDataCode"),
|
||
sceneCutAlias: document.querySelector("#sceneCutAlias"),
|
||
sceneFadeDuration: document.querySelector("#sceneFadeDuration"),
|
||
applySceneSelectionButton: document.querySelector("#applySceneSelectionButton"),
|
||
prepareButton: document.querySelector("#prepareButton"), takeInButton: document.querySelector("#takeInButton"),
|
||
nextButton: document.querySelector("#nextButton"), takeOutButton: document.querySelector("#takeOutButton"),
|
||
eventLog: document.querySelector("#eventLog"),
|
||
toast: document.querySelector("#toast")
|
||
};
|
||
|
||
const nativeBridge = window.chrome?.webview;
|
||
let manualListsUi = null;
|
||
let manualFinancialUi = null;
|
||
let operatorCatalogUi = null;
|
||
|
||
function createId() {
|
||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
function operatorUiLocked() {
|
||
return isPlaylistSnapshotLocked() || namedPlaylistBusy() ||
|
||
state.manualFinancialRestore.entries.size > 0;
|
||
}
|
||
|
||
function selectedStockForManualFinancial() {
|
||
const selected = state.stockSearch.selected;
|
||
if (!selected) return null;
|
||
return Object.freeze({
|
||
market: selected.isNxt ? `nxt-${selected.market}` : selected.market,
|
||
source: selected.isNxt ? "mariaDb" : "oracle",
|
||
name: selected.stockName,
|
||
code: selected.stockCode
|
||
});
|
||
}
|
||
|
||
function appendTrustedOperatorEntry(entry) {
|
||
if (operatorUiLocked()) throw new Error("The operator playlist is locked.");
|
||
if (!entry || typeof entry !== "object" || Array.isArray(entry) ||
|
||
typeof entry.id !== "string" || state.playlist.some(item => item.id === entry.id)) {
|
||
throw new TypeError("A unique trusted operator playlist entry is required.");
|
||
}
|
||
const source = entry.operator?.source;
|
||
if (source === "legacy-manual-lists-workflow") {
|
||
if (!manualListsWorkflow.restorePlaylistEntry(entry)) {
|
||
throw new TypeError("The manual-list entry failed strict restoration.");
|
||
}
|
||
} else if (source === "manual-financial-workflow") {
|
||
if (!manualFinancialWorkflow.isPlaylistEntryPlayable(entry)) {
|
||
throw new TypeError("The manual-financial entry is not native-confirmed.");
|
||
}
|
||
} else {
|
||
throw new TypeError("The operator UI attempted to append an unsupported entry source.");
|
||
}
|
||
const definition = catalog.find(value => value.builderKey === entry.builderKey &&
|
||
sceneAliases(value).includes(String(entry.code || "")));
|
||
if (!definition) throw new TypeError("The operator entry does not resolve to a registered scene.");
|
||
state.playlist.push(entry);
|
||
state.selectedRows.clear();
|
||
state.selectedRows.add(entry.id);
|
||
state.currentIndex = state.playlist.length - 1;
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
return true;
|
||
}
|
||
|
||
function isPlaylistSnapshotLocked() {
|
||
return playoutSafety.playlistSnapshotLocked({
|
||
pending: Boolean(state.playout.pending),
|
||
outcomeUnknown: state.playout.sessionQuarantined ||
|
||
state.playout.nativeOutcomeUnknown ||
|
||
state.playout.error?.outcomeUnknown === true,
|
||
engineState: state.playout.engineState,
|
||
preparedCode: state.playout.preparedCode,
|
||
onAirCode: state.playout.onAirCode
|
||
});
|
||
}
|
||
|
||
function requirePlaylistEditable() {
|
||
if (!isPlaylistSnapshotLocked()) return true;
|
||
showToast("PREPARE 이후에는 TAKE OUT까지 플레이리스트가 잠깁니다.");
|
||
return false;
|
||
}
|
||
|
||
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.disabled = item.reachable === false || isPlaylistSnapshotLocked();
|
||
add.title = item.reachable === false ? "원본 MainForm에서 도달하지 않는 장면" : "플레이리스트에 추가";
|
||
add.textContent = "+";
|
||
add.addEventListener("click", () => addToPlaylist(item));
|
||
|
||
row.append(code, copy, add);
|
||
elements.catalogList.append(row);
|
||
}
|
||
}
|
||
|
||
function fixedMarketTitle(market) {
|
||
return { overseas: "해외", exchange: "환율", index: "지수" }[market] || "원본";
|
||
}
|
||
|
||
function openFixedManualAction(action) {
|
||
if (!action || action.available !== false || operatorUiLocked()) return false;
|
||
if (action.builderKey === "s5025") {
|
||
manualListsUi?.openNetSell(action.selection?.groupCode);
|
||
return true;
|
||
}
|
||
if (action.builderKey === "s5074" && action.selection?.groupCode === "PAGED_VI") {
|
||
manualListsUi?.openVi();
|
||
return true;
|
||
}
|
||
showToast(action.prerequisite || "지원하는 수동 입력 화면을 찾지 못했습니다.");
|
||
return false;
|
||
}
|
||
|
||
function addFixedAction(action) {
|
||
if (action?.available === false) {
|
||
openFixedManualAction(action);
|
||
return;
|
||
}
|
||
if (!action?.available || !requirePlaylistEditable()) {
|
||
return;
|
||
}
|
||
try {
|
||
const created = fixedWorkflow.createFixedPlaylistEntry(action.id, {
|
||
id: createId(),
|
||
fadeDuration: DEFAULT_FADE_DURATION,
|
||
ma5: state.candleOptions.ma5,
|
||
ma20: state.candleOptions.ma20
|
||
});
|
||
const definition = catalog.find(item =>
|
||
item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
|
||
if (!definition) throw new Error("The fixed action does not resolve to a registered scene alias.");
|
||
const entry = { ...definition, ...created };
|
||
state.playlist.push(entry);
|
||
state.selectedRows.clear();
|
||
state.selectedRows.add(entry.id);
|
||
state.currentIndex = state.playlist.length - 1;
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`${entry.code} ${action.section} · ${action.label} 추가`);
|
||
showToast(`${action.label} 항목을 플레이리스트에 추가했습니다.`);
|
||
} catch {
|
||
showToast("원본 운영 항목 매핑을 확인할 수 없어 추가하지 않았습니다.");
|
||
}
|
||
}
|
||
|
||
function renderFixedWorkflow() {
|
||
const visible = fixedWorkflow.fixedMarkets.includes(state.activeMarket);
|
||
elements.legacyFixedWorkflow.hidden = !visible;
|
||
if (!visible) return;
|
||
|
||
const allActions = fixedWorkflow.actionsForMarket(state.activeMarket);
|
||
const search = state.fixedSearch.trim().toLocaleLowerCase("ko-KR");
|
||
const actions = allActions.filter(action => !search ||
|
||
`${action.section} ${action.label} ${action.code} ${action.builderKey}`
|
||
.toLocaleLowerCase("ko-KR").includes(search));
|
||
const available = allActions.filter(action => action.available).length;
|
||
const manual = allActions.length - available;
|
||
elements.legacyFixedWorkflow.classList.toggle("collapsed", state.fixedWorkflowCollapsed);
|
||
elements.legacyFixedToggle.textContent = state.fixedWorkflowCollapsed ? "펼치기" : "접기";
|
||
elements.legacyFixedToggle.setAttribute("aria-expanded", String(!state.fixedWorkflowCollapsed));
|
||
elements.legacyFixedWorkflowTitle.textContent = `${fixedMarketTitle(state.activeMarket)} 원본 운영 항목`;
|
||
elements.legacyFixedCount.textContent = `${allActions.length} ACTIONS`;
|
||
elements.legacyFixedState.textContent = search
|
||
? `${actions.length}개 검색 결과 · 원본 실행본 SHA 고정`
|
||
: `${available}개 즉시 사용${manual ? ` · 수동 전제 ${manual}개` : ""}`;
|
||
elements.legacyFixedSections.replaceChildren();
|
||
|
||
if (!actions.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "catalog-empty";
|
||
empty.textContent = "조건에 맞는 원본 운영 항목이 없습니다.";
|
||
elements.legacyFixedSections.append(empty);
|
||
return;
|
||
}
|
||
|
||
const sections = new Map();
|
||
for (const action of actions) {
|
||
if (!sections.has(action.section)) sections.set(action.section, []);
|
||
sections.get(action.section).push(action);
|
||
}
|
||
for (const [sectionName, sectionActions] of sections) {
|
||
const section = document.createElement("section");
|
||
section.className = "legacy-fixed-section";
|
||
const header = document.createElement("header");
|
||
const title = document.createElement("strong");
|
||
const count = document.createElement("span");
|
||
title.textContent = sectionName;
|
||
count.textContent = `${sectionActions.length}`;
|
||
header.append(title, count);
|
||
section.append(header);
|
||
|
||
for (const action of sectionActions) {
|
||
const row = document.createElement("article");
|
||
row.className = `legacy-fixed-row${action.available ? "" : " unavailable"}`;
|
||
const number = document.createElement("span");
|
||
number.className = "legacy-fixed-number";
|
||
number.textContent = action.id.slice(-3);
|
||
const copy = document.createElement("div");
|
||
copy.className = "legacy-fixed-copy";
|
||
const label = document.createElement("strong");
|
||
const meta = document.createElement("small");
|
||
label.textContent = action.label;
|
||
meta.textContent = action.available
|
||
? `${action.code} · ${action.builderKey}`
|
||
: `수동 입력 필요 · ${action.builderKey}`;
|
||
copy.append(label, meta);
|
||
const add = document.createElement("button");
|
||
add.className = "legacy-fixed-add";
|
||
add.type = "button";
|
||
add.textContent = action.available ? "+" : "입력";
|
||
add.disabled = operatorUiLocked();
|
||
add.title = action.available ? "플레이리스트에 추가" : "원본 수동 입력 화면 열기";
|
||
add.addEventListener("click", () => addFixedAction(action));
|
||
row.addEventListener("dblclick", event => {
|
||
if (event.target.closest("button")) return;
|
||
addFixedAction(action);
|
||
});
|
||
row.append(number, copy, add);
|
||
section.append(row);
|
||
}
|
||
elements.legacyFixedSections.append(section);
|
||
}
|
||
}
|
||
|
||
function synchronizeFixedEntries(showFeedback) {
|
||
let changed = false;
|
||
let invalid = false;
|
||
state.playlist = state.playlist.map(item => {
|
||
if (item?.operator?.source !== "legacy-fixed-workflow") return item;
|
||
const refreshed = fixedWorkflow.refreshFixedPlaylistEntry(item);
|
||
if (!refreshed) {
|
||
invalid = true;
|
||
return item;
|
||
}
|
||
const definition = catalog.find(value =>
|
||
value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
|
||
if (!definition) {
|
||
invalid = true;
|
||
return item;
|
||
}
|
||
if (refreshed.selection.dataCode !== item.selection?.dataCode ||
|
||
refreshed.selection.graphicType !== item.selection?.graphicType) changed = true;
|
||
return { ...definition, ...refreshed };
|
||
});
|
||
if (invalid) {
|
||
showToast("저장된 원본 운영 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
|
||
return false;
|
||
}
|
||
if (changed) {
|
||
renderPlaylist();
|
||
updatePreview();
|
||
if (showFeedback) showToast("날짜와 NXT 세션을 현재 운영 시점으로 갱신했습니다.");
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function clearIndustryTimeout() {
|
||
if (state.industry.timeoutId !== null) clearTimeout(state.industry.timeoutId);
|
||
state.industry.timeoutId = null;
|
||
}
|
||
|
||
function industryIdentity(industry) {
|
||
if (!industry) return "";
|
||
return [industry.market, industry.code, industry.name].join("|");
|
||
}
|
||
|
||
function reconcileIndustrySelection(value, results) {
|
||
const identity = industryIdentity(value);
|
||
return identity ? results.find(item => industryIdentity(item) === identity) || null : null;
|
||
}
|
||
|
||
function selectIndustry(industry) {
|
||
state.industry.highlighted = industry;
|
||
renderIndustryWorkflow();
|
||
addLog(`업종 선택 · ${industry.name} · ${industry.code}`);
|
||
}
|
||
|
||
function shiftIndustryPair(industry) {
|
||
state.industry.pairSecond = state.industry.pairFirst;
|
||
state.industry.pairFirst = industry;
|
||
renderIndustryWorkflow();
|
||
addLog(`업종 비교 선택 · ${industry.name}`);
|
||
}
|
||
|
||
function addIndustryCut(cutId) {
|
||
if (!requirePlaylistEditable()) return;
|
||
const industry = state.industry;
|
||
if (!industryWorkflow.isCutAllowed(
|
||
state.activeMarket,
|
||
cutId,
|
||
industry.highlighted,
|
||
industry.pairFirst,
|
||
industry.pairSecond)) {
|
||
showToast(cutId === "two-column"
|
||
? "업종 목록에서 두 업종을 차례로 더블클릭하세요."
|
||
: "업종 목록에서 한 업종을 선택하세요.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const created = industryWorkflow.createIndustryPlaylistEntry(state.activeMarket, cutId, {
|
||
id: createId(),
|
||
fadeDuration: DEFAULT_FADE_DURATION,
|
||
selected: industry.highlighted,
|
||
pair: [industry.pairFirst, industry.pairSecond],
|
||
ma5: state.candleOptions.ma5,
|
||
ma20: state.candleOptions.ma20
|
||
});
|
||
const definition = catalog.find(item =>
|
||
item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
|
||
if (!definition) throw new Error("The industry action does not resolve to a registered scene alias.");
|
||
const entry = { ...definition, ...created };
|
||
state.playlist.push(entry);
|
||
state.selectedRows.clear();
|
||
state.selectedRows.add(entry.id);
|
||
state.currentIndex = state.playlist.length - 1;
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`);
|
||
showToast(`${entry.detail} 항목을 플레이리스트에 추가했습니다.`);
|
||
} catch {
|
||
showToast("선택한 업종과 컷을 안전한 장면으로 구성하지 못했습니다.");
|
||
}
|
||
}
|
||
|
||
function renderIndustryWorkflow() {
|
||
const visible = industryWorkflow.markets.includes(state.activeMarket);
|
||
elements.industryWorkflow.hidden = !visible;
|
||
if (!visible) return;
|
||
|
||
const industry = state.industry;
|
||
const marketLabel = state.activeMarket === "kospi" ? "코스피" : "코스닥";
|
||
const playlistLocked = isPlaylistSnapshotLocked();
|
||
elements.industryWorkflow.classList.toggle("collapsed", state.industryWorkflowCollapsed);
|
||
elements.industryWorkflowToggle.textContent = state.industryWorkflowCollapsed ? "펼치기" : "접기";
|
||
elements.industryWorkflowToggle.setAttribute("aria-expanded", String(!state.industryWorkflowCollapsed));
|
||
elements.industryWorkflowTitle.textContent = `${marketLabel} 업종 선택`;
|
||
elements.industryCount.textContent = industry.status === "loading"
|
||
? "LOADING"
|
||
: `${industry.totalRowCount.toLocaleString("ko-KR")} DB`;
|
||
elements.industryRetry.disabled = industry.status === "loading";
|
||
elements.industryState.className = `industry-state ${industry.status}`;
|
||
if (industry.status === "loading") {
|
||
elements.industryState.textContent = `${marketLabel} 업종 목록을 Oracle에서 조회하고 있습니다.`;
|
||
} else if (industry.status === "error") {
|
||
elements.industryState.textContent = industry.error || "업종 목록을 불러오지 못했습니다.";
|
||
} else if (industry.status === "ready") {
|
||
elements.industryState.textContent = `${industry.totalRowCount.toLocaleString("ko-KR")}개 업종 · 클릭은 단일 선택, 더블클릭은 비교 선택`;
|
||
} else {
|
||
elements.industryState.textContent = "업종 목록을 조회할 준비가 되었습니다.";
|
||
}
|
||
|
||
const term = industry.search.trim().toLocaleLowerCase("ko-KR");
|
||
const results = industry.results.filter(item => !term ||
|
||
`${item.name} ${item.code}`.toLocaleLowerCase("ko-KR").includes(term));
|
||
elements.industryResults.replaceChildren();
|
||
if (!results.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "industry-results-empty";
|
||
empty.textContent = industry.status === "ready" ? "일치하는 업종이 없습니다." : "업종 목록이 여기에 표시됩니다.";
|
||
elements.industryResults.append(empty);
|
||
} else {
|
||
const selectedIdentity = industryIdentity(industry.highlighted);
|
||
for (const item of results) {
|
||
const row = document.createElement("button");
|
||
row.type = "button";
|
||
row.className = "industry-result";
|
||
row.setAttribute("role", "option");
|
||
const selected = industryIdentity(item) === selectedIdentity;
|
||
row.classList.toggle("selected", selected);
|
||
row.setAttribute("aria-selected", selected ? "true" : "false");
|
||
const name = document.createElement("strong");
|
||
const code = document.createElement("small");
|
||
name.textContent = item.name;
|
||
code.textContent = item.code;
|
||
row.append(name, code);
|
||
row.addEventListener("click", () => selectIndustry(item));
|
||
row.addEventListener("dblclick", () => shiftIndustryPair(item));
|
||
elements.industryResults.append(row);
|
||
}
|
||
}
|
||
|
||
elements.industryPrimary.textContent = industry.pairFirst?.name || "선택 없음";
|
||
elements.industrySecondary.textContent = industry.pairSecond?.name || "선택 없음";
|
||
elements.industrySwap.disabled = playlistLocked || !industry.pairFirst || !industry.pairSecond;
|
||
elements.industryClear.disabled = playlistLocked ||
|
||
(!industry.highlighted && !industry.pairFirst && !industry.pairSecond);
|
||
|
||
elements.industryCutList.replaceChildren();
|
||
let previousSection = null;
|
||
industryWorkflow.cuts.forEach((cut, index) => {
|
||
if (cut.section !== previousSection) {
|
||
const section = document.createElement("div");
|
||
section.className = "industry-cut-section";
|
||
section.textContent = cut.section;
|
||
elements.industryCutList.append(section);
|
||
previousSection = cut.section;
|
||
}
|
||
const allowed = industryWorkflow.isCutAllowed(
|
||
state.activeMarket, cut.id, industry.highlighted, industry.pairFirst, industry.pairSecond);
|
||
const row = document.createElement("div");
|
||
row.className = "industry-cut-row";
|
||
row.classList.toggle("disabled", !allowed);
|
||
const number = document.createElement("span");
|
||
number.className = "industry-cut-number";
|
||
number.textContent = String(index + 1).padStart(2, "0");
|
||
const copy = document.createElement("span");
|
||
copy.className = "industry-cut-copy";
|
||
const label = document.createElement("strong");
|
||
const detail = document.createElement("small");
|
||
label.textContent = cut.label;
|
||
detail.textContent = `${cut.builderKey} · ${cut.kind === "fixed" ? "시장 전체" : (cut.kind === "pair" ? "두 업종" : "선택 업종")}`;
|
||
copy.append(label, detail);
|
||
const add = document.createElement("button");
|
||
add.type = "button";
|
||
add.className = "industry-cut-add";
|
||
add.textContent = "+";
|
||
add.disabled = !allowed || playlistLocked;
|
||
add.title = allowed ? "플레이리스트에 추가" : (cut.kind === "pair" ? "두 업종을 더블클릭해 선택하세요." : "업종을 선택하세요.");
|
||
add.addEventListener("click", () => addIndustryCut(cut.id));
|
||
row.addEventListener("dblclick", event => {
|
||
if (!event.target.closest("button") && allowed) addIndustryCut(cut.id);
|
||
});
|
||
row.append(number, copy, add);
|
||
elements.industryCutList.append(row);
|
||
});
|
||
}
|
||
|
||
function requestIndustries(market = state.activeMarket) {
|
||
clearIndustryTimeout();
|
||
if (!industryWorkflow.markets.includes(market)) return;
|
||
const changingMarket = state.industry.market !== market;
|
||
if (changingMarket) {
|
||
state.industry.highlighted = null;
|
||
state.industry.pairFirst = null;
|
||
state.industry.pairSecond = null;
|
||
state.industry.search = "";
|
||
elements.industrySearch.value = "";
|
||
}
|
||
const requestId = createId();
|
||
state.industry.requestId = requestId;
|
||
state.industry.market = market;
|
||
state.industry.status = "loading";
|
||
state.industry.results = [];
|
||
state.industry.totalRowCount = 0;
|
||
state.industry.error = "";
|
||
renderIndustryWorkflow();
|
||
|
||
if (!nativeBridge) {
|
||
state.industry.status = "error";
|
||
state.industry.error = "브라우저 미리보기에서는 실제 업종 DB 조회를 사용할 수 없습니다.";
|
||
renderIndustryWorkflow();
|
||
return;
|
||
}
|
||
|
||
postNative("request-industries", { requestId, market });
|
||
state.industry.timeoutId = setTimeout(() => {
|
||
if (state.industry.requestId !== requestId || state.industry.status !== "loading") return;
|
||
state.industry.status = "error";
|
||
state.industry.error = "업종 조회 응답 시간이 초과되었습니다. DB 상태를 확인하고 다시 조회하세요.";
|
||
renderIndustryWorkflow();
|
||
addLog(`${marketLabelForIndustry(market)} 업종 조회 시간 초과`);
|
||
}, 30000);
|
||
}
|
||
|
||
function marketLabelForIndustry(market) {
|
||
return market === "kospi" ? "코스피" : "코스닥";
|
||
}
|
||
|
||
function failIndustryResponse(message) {
|
||
clearIndustryTimeout();
|
||
state.industry.status = "error";
|
||
state.industry.results = [];
|
||
state.industry.totalRowCount = 0;
|
||
state.industry.error = message;
|
||
renderIndustryWorkflow();
|
||
}
|
||
|
||
function handleIndustryResults(payload) {
|
||
if (!payload || payload.requestId !== state.industry.requestId || payload.market !== state.industry.market) return;
|
||
const rawResults = Array.isArray(payload.results) ? payload.results : null;
|
||
if (!rawResults || rawResults.length > 500 || !Number.isInteger(payload.totalRowCount) ||
|
||
payload.totalRowCount !== rawResults.length) {
|
||
failIndustryResponse("업종 목록 응답 형식이 올바르지 않습니다.");
|
||
return;
|
||
}
|
||
const results = [];
|
||
const names = new Set();
|
||
const codes = new Set();
|
||
for (const value of rawResults) {
|
||
const normalized = industryWorkflow.normalizeIndustry(value);
|
||
if (!normalized || normalized.market !== state.industry.market ||
|
||
names.has(normalized.name) || codes.has(normalized.code)) {
|
||
failIndustryResponse("업종 목록에 올바르지 않거나 중복된 항목이 있습니다.");
|
||
return;
|
||
}
|
||
names.add(normalized.name);
|
||
codes.add(normalized.code);
|
||
results.push(normalized);
|
||
}
|
||
clearIndustryTimeout();
|
||
state.industry.results = results;
|
||
state.industry.totalRowCount = results.length;
|
||
state.industry.highlighted = reconcileIndustrySelection(state.industry.highlighted, results);
|
||
state.industry.pairFirst = reconcileIndustrySelection(state.industry.pairFirst, results);
|
||
state.industry.pairSecond = reconcileIndustrySelection(state.industry.pairSecond, results);
|
||
state.industry.status = "ready";
|
||
state.industry.error = "";
|
||
renderIndustryWorkflow();
|
||
addLog(`${marketLabelForIndustry(payload.market)} 업종 조회 완료 · ${results.length}건`);
|
||
}
|
||
|
||
function handleIndustryError(payload) {
|
||
if (!payload || payload.requestId !== state.industry.requestId || payload.market !== state.industry.market) return;
|
||
failIndustryResponse(typeof payload.message === "string" && payload.message.trim()
|
||
? payload.message.trim()
|
||
: "업종 목록을 불러오지 못했습니다.");
|
||
addLog(`${marketLabelForIndustry(payload.market)} 업종 조회 실패`);
|
||
}
|
||
|
||
function synchronizeIndustryEntries(showFeedback) {
|
||
let changed = false;
|
||
let invalid = false;
|
||
state.playlist = state.playlist.map(item => {
|
||
if (item?.operator?.source !== "legacy-industry-workflow") return item;
|
||
const refreshed = industryWorkflow.refreshIndustryPlaylistEntry(item);
|
||
if (!refreshed) {
|
||
invalid = true;
|
||
return item;
|
||
}
|
||
const definition = catalog.find(value =>
|
||
value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
|
||
if (!definition) {
|
||
invalid = true;
|
||
return item;
|
||
}
|
||
if (JSON.stringify(refreshed.selection) !== JSON.stringify(item.selection)) changed = true;
|
||
return { ...definition, ...refreshed };
|
||
});
|
||
if (invalid) {
|
||
showToast("저장된 업종 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
|
||
return false;
|
||
}
|
||
if (changed) {
|
||
renderPlaylist();
|
||
updatePreview();
|
||
if (showFeedback) showToast("업종 페이지 날짜를 현재 운영 시점으로 갱신했습니다.");
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function clearComparisonSearchTimeout(kind) {
|
||
const search = state.comparison[kind];
|
||
if (search?.timeoutId !== null) clearTimeout(search.timeoutId);
|
||
if (search) search.timeoutId = null;
|
||
}
|
||
|
||
function comparisonTargetDisplay(target) {
|
||
return target?.displayName || target?.stockName || target?.inputName || target?.target || "선택 없음";
|
||
}
|
||
|
||
function comparisonTargetMeta(target) {
|
||
if (!target) return "";
|
||
if (target.kind === "market-target") return target.target;
|
||
if (target.kind === "world-stock") return `${target.nation} · ${target.symbol}`;
|
||
return `${target.market.toLocaleUpperCase("en-US")}${target.kind === "nxt-stock" ? " NXT" : " KRX"} · ${target.stockCode}`;
|
||
}
|
||
|
||
function comparisonTargetFromStockResult(value) {
|
||
const stock = operatorWorkflow.normalizeStockResult(value);
|
||
if (!stock) return null;
|
||
return comparisonWorkflow.normalizeTarget({
|
||
kind: stock.isNxt ? "nxt-stock" : "krx-stock",
|
||
market: stock.market,
|
||
stockName: stock.stockName,
|
||
stockCode: stock.stockCode,
|
||
displayName: stock.displayName
|
||
});
|
||
}
|
||
|
||
function comparisonTargetFromWorldResult(value) {
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||
return comparisonWorkflow.normalizeTarget({
|
||
kind: "world-stock",
|
||
inputName: value.inputName,
|
||
displayName: value.inputName,
|
||
symbol: value.symbol,
|
||
nation: value.nationCode
|
||
});
|
||
}
|
||
|
||
function rotateComparisonDraft(target) {
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
const rotated = comparisonWorkflow.rotatePair(
|
||
[state.comparison.draftFirst, state.comparison.draftSecond],
|
||
target);
|
||
if (!rotated) return;
|
||
[state.comparison.draftFirst, state.comparison.draftSecond] = rotated;
|
||
renderComparisonWorkflow();
|
||
addLog(`비교 대상 선택 · ${comparisonTargetDisplay(target)}`);
|
||
}
|
||
|
||
function selectedComparisonRecord() {
|
||
return state.comparison.pairList.pairs.find(record =>
|
||
record.id === state.comparison.selectedPairId) || null;
|
||
}
|
||
|
||
function selectedComparisonPair() {
|
||
return selectedComparisonRecord()?.pair || null;
|
||
}
|
||
|
||
function persistComparisonPairList(nextList, successMessage) {
|
||
try {
|
||
localStorage.setItem(comparisonStorageKey, JSON.stringify(nextList));
|
||
state.comparison.pairList = nextList;
|
||
if (successMessage) addLog(successMessage);
|
||
return true;
|
||
} catch {
|
||
showToast("비교쌍 로컬 저장소를 갱신하지 못했습니다.");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function loadComparisonPairList() {
|
||
try {
|
||
const raw = JSON.parse(localStorage.getItem(comparisonStorageKey) || "null");
|
||
state.comparison.pairList = comparisonWorkflow.normalizePairList(raw);
|
||
state.comparison.selectedPairId = state.comparison.pairList.pairs[0]?.id || null;
|
||
} catch {
|
||
state.comparison.pairList = comparisonWorkflow.deleteAllPairs();
|
||
state.comparison.selectedPairId = null;
|
||
}
|
||
}
|
||
|
||
function saveComparisonDraftPair() {
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
const pair = comparisonWorkflow.normalizePair([
|
||
state.comparison.draftFirst,
|
||
state.comparison.draftSecond
|
||
]);
|
||
if (!pair) {
|
||
showToast("비교 대상 두 개를 더블클릭해 선택하세요.");
|
||
return;
|
||
}
|
||
const id = createId();
|
||
const record = comparisonWorkflow.createPairRecord(id, pair);
|
||
if (!record) {
|
||
showToast("비교쌍 식별자를 안전하게 구성하지 못했습니다.");
|
||
return;
|
||
}
|
||
try {
|
||
const next = comparisonWorkflow.appendPair(state.comparison.pairList, record);
|
||
if (!persistComparisonPairList(next, `비교쌍 저장 · ${comparisonWorkflow.pairLabel(pair)}`)) return;
|
||
state.comparison.selectedPairId = id;
|
||
renderComparisonWorkflow();
|
||
showToast("비교쌍을 저장하고 선택했습니다.");
|
||
} catch {
|
||
showToast("비교쌍 저장 한도 또는 형식을 확인하세요.");
|
||
}
|
||
}
|
||
|
||
function moveComparisonPair(direction) {
|
||
if (isPlaylistSnapshotLocked() || !state.comparison.selectedPairId) return;
|
||
const next = comparisonWorkflow.reorderPairList(
|
||
state.comparison.pairList,
|
||
state.comparison.selectedPairId,
|
||
direction);
|
||
if (next === state.comparison.pairList ||
|
||
next.pairs.map(value => value.id).join("|") === state.comparison.pairList.pairs.map(value => value.id).join("|")) return;
|
||
if (persistComparisonPairList(next, "비교쌍 순서 변경")) renderComparisonWorkflow();
|
||
}
|
||
|
||
function deleteSelectedComparisonPair() {
|
||
if (isPlaylistSnapshotLocked() || !state.comparison.selectedPairId) return;
|
||
const previousId = state.comparison.selectedPairId;
|
||
const index = state.comparison.pairList.pairs.findIndex(record => record.id === previousId);
|
||
const next = comparisonWorkflow.deletePair(state.comparison.pairList, previousId);
|
||
if (!persistComparisonPairList(next, "비교쌍 삭제")) return;
|
||
state.comparison.selectedPairId = next.pairs[Math.min(Math.max(index, 0), next.pairs.length - 1)]?.id || null;
|
||
renderComparisonWorkflow();
|
||
}
|
||
|
||
function deleteAllComparisonPairs() {
|
||
if (isPlaylistSnapshotLocked() || !state.comparison.pairList.pairs.length) return;
|
||
if (!window.confirm("저장된 비교쌍을 모두 삭제할까요?")) return;
|
||
const next = comparisonWorkflow.deleteAllPairs();
|
||
if (!persistComparisonPairList(next, "비교쌍 전체 삭제")) return;
|
||
state.comparison.selectedPairId = null;
|
||
renderComparisonWorkflow();
|
||
}
|
||
|
||
function appendComparisonPlaylistEntries(createdEntries) {
|
||
if (!requirePlaylistEditable()) return false;
|
||
const entries = [];
|
||
for (const created of createdEntries) {
|
||
const definition = catalog.find(item =>
|
||
item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
|
||
if (!definition) {
|
||
showToast("비교 컷이 등록된 장면 alias와 일치하지 않습니다.");
|
||
return false;
|
||
}
|
||
entries.push({ ...definition, ...created });
|
||
}
|
||
const startIndex = state.playlist.length;
|
||
state.playlist.push(...entries);
|
||
state.selectedRows.clear();
|
||
entries.forEach(entry => state.selectedRows.add(entry.id));
|
||
state.currentIndex = startIndex;
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`비교 컷 ${entries.length}개 추가 · ${entries[0]?.title || "비교쌍"}`);
|
||
showToast(entries.length === 1 ? "비교 컷을 추가했습니다." : `비교 수익률 컷 ${entries.length}개를 추가했습니다.`);
|
||
return true;
|
||
}
|
||
|
||
function addComparisonAction(actionId) {
|
||
const pair = selectedComparisonPair();
|
||
if (!pair) {
|
||
showToast("저장 비교쌍에서 사용할 행을 선택하세요.");
|
||
return;
|
||
}
|
||
const compatibility = comparisonWorkflow.getCompatibility(actionId, pair);
|
||
if (!compatibility.supported) {
|
||
showToast(comparisonCompatibilityText(compatibility.reason));
|
||
return;
|
||
}
|
||
try {
|
||
const entry = comparisonWorkflow.createComparisonPlaylistEntry(actionId, pair, {
|
||
id: createId(),
|
||
fadeDuration: DEFAULT_FADE_DURATION
|
||
});
|
||
if (appendComparisonPlaylistEntries([entry]) && entry.warning) showToast(entry.warning);
|
||
} catch {
|
||
showToast("선택한 비교쌍과 컷을 안전한 장면으로 구성하지 못했습니다.");
|
||
}
|
||
}
|
||
|
||
function addComparisonYieldBatch() {
|
||
const pair = selectedComparisonPair();
|
||
if (!pair || !comparisonWorkflow.yieldActionIds.every(actionId =>
|
||
comparisonWorkflow.isActionAllowed(actionId, pair))) {
|
||
showToast("수익률 비교 5종은 국내 KRX 종목 두 개가 필요합니다.");
|
||
return;
|
||
}
|
||
try {
|
||
const entries = comparisonWorkflow.createYieldBatchEntries(pair, {
|
||
idPrefix: createId(),
|
||
fadeDuration: DEFAULT_FADE_DURATION
|
||
});
|
||
appendComparisonPlaylistEntries(entries);
|
||
} catch {
|
||
showToast("수익률 비교 5종을 안전하게 구성하지 못했습니다.");
|
||
}
|
||
}
|
||
|
||
function comparisonCompatibilityText(reason) {
|
||
return {
|
||
"pair-incomplete-or-invalid": "저장 비교쌍을 선택하세요.",
|
||
"comparison-graphs-require-two-krx-stocks": "비교 차트와 수익률은 국내 KRX 종목 두 개만 지원합니다.",
|
||
"two-futures-targets-are-unsupported": "선물 지수 두 개의 조합은 지원하지 않습니다.",
|
||
"futures-requires-a-market-target-counterpart": "선물은 다른 시장 지수와만 2열판으로 비교할 수 있습니다.",
|
||
"mixed-market-and-world-is-unsupported": "시장 지수와 해외 종목의 혼합 2열판은 안전한 DTO가 없어 지원하지 않습니다.",
|
||
"expected-mode-supports-only-krx-stocks-and-four-domestic-indices": "예상체결은 국내 KRX 종목과 국내 4개 지수만 지원합니다."
|
||
}[reason] || "현재 Core에서 안전하게 지원하지 않는 비교 조합입니다.";
|
||
}
|
||
|
||
function renderComparisonTargetList(container, values, emptyText) {
|
||
container.replaceChildren();
|
||
if (!values.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "comparison-target-empty";
|
||
empty.textContent = emptyText;
|
||
container.append(empty);
|
||
return;
|
||
}
|
||
const locked = isPlaylistSnapshotLocked();
|
||
for (const target of values) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "comparison-target";
|
||
button.disabled = locked;
|
||
button.title = "더블클릭하면 첫 번째 슬롯에 넣고 기존 첫 번째를 두 번째로 이동합니다.";
|
||
const name = document.createElement("strong");
|
||
const meta = document.createElement("small");
|
||
name.textContent = comparisonTargetDisplay(target);
|
||
meta.textContent = comparisonTargetMeta(target);
|
||
button.append(name, meta);
|
||
button.addEventListener("dblclick", () => rotateComparisonDraft(target));
|
||
button.addEventListener("keydown", event => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
rotateComparisonDraft(target);
|
||
}
|
||
});
|
||
container.append(button);
|
||
}
|
||
}
|
||
|
||
function renderComparisonSearchState(kind, element) {
|
||
const search = state.comparison[kind];
|
||
element.className = `comparison-search-state ${search.status}`;
|
||
if (search.status === "loading") element.textContent = `“${search.query}” 검색 중입니다.`;
|
||
else if (search.status === "error") element.textContent = search.error || "검색에 실패했습니다.";
|
||
else if (search.status === "ready") {
|
||
element.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}건 · 대상을 더블클릭하세요.`;
|
||
} else {
|
||
element.textContent = kind === "domestic" ? "국내·NXT 종목명을 입력하세요." : "미국·대만 종목명을 입력하세요.";
|
||
}
|
||
}
|
||
|
||
function renderComparisonWorkflow() {
|
||
const visible = state.activeMarket === "compare";
|
||
elements.comparisonWorkflow.hidden = !visible;
|
||
if (!visible) return;
|
||
const comparison = state.comparison;
|
||
const locked = isPlaylistSnapshotLocked();
|
||
const pairs = comparison.pairList.pairs;
|
||
const selectedRecord = selectedComparisonRecord();
|
||
const selectedPair = selectedRecord?.pair || null;
|
||
elements.comparisonPairCount.textContent = `${pairs.length} PAIRS`;
|
||
|
||
elements.comparisonSourceTabs.querySelectorAll("button[data-comparison-source]").forEach(button => {
|
||
button.classList.toggle("active", button.dataset.comparisonSource === comparison.source);
|
||
button.disabled = locked;
|
||
});
|
||
elements.comparisonDomesticPanel.hidden = comparison.source !== "domestic";
|
||
elements.comparisonFixedPanel.hidden = comparison.source !== "fixed";
|
||
elements.comparisonWorldPanel.hidden = comparison.source !== "world";
|
||
|
||
renderComparisonSearchState("domestic", elements.comparisonDomesticState);
|
||
renderComparisonSearchState("world", elements.comparisonWorldState);
|
||
elements.comparisonDomesticSearch.disabled = locked;
|
||
elements.comparisonDomesticSearchButton.disabled = locked || comparison.domestic.status === "loading";
|
||
elements.comparisonWorldSearch.disabled = locked;
|
||
elements.comparisonWorldSearchButton.disabled = locked || comparison.world.status === "loading";
|
||
renderComparisonTargetList(
|
||
elements.comparisonDomesticResults,
|
||
comparison.domestic.results,
|
||
comparison.domestic.status === "ready" ? "일치하는 국내·NXT 종목이 없습니다." : "검색 결과가 여기에 표시됩니다.");
|
||
renderComparisonTargetList(elements.comparisonFixedTargets, comparisonWorkflow.marketTargets, "고정 비교 대상이 없습니다.");
|
||
renderComparisonTargetList(
|
||
elements.comparisonWorldResults,
|
||
comparison.world.results,
|
||
comparison.world.status === "ready" ? "일치하는 해외 종목이 없습니다." : "검색 결과가 여기에 표시됩니다.");
|
||
|
||
elements.comparisonFirst.textContent = comparisonTargetDisplay(comparison.draftFirst);
|
||
elements.comparisonSecond.textContent = comparisonTargetDisplay(comparison.draftSecond);
|
||
elements.comparisonSwap.disabled = locked || !comparison.draftFirst || !comparison.draftSecond;
|
||
elements.comparisonClear.disabled = locked || (!comparison.draftFirst && !comparison.draftSecond);
|
||
elements.comparisonSavePair.disabled = locked ||
|
||
!comparisonWorkflow.normalizePair([comparison.draftFirst, comparison.draftSecond]);
|
||
|
||
elements.comparisonPairList.replaceChildren();
|
||
if (!pairs.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "comparison-target-empty";
|
||
empty.textContent = "저장된 비교쌍이 없습니다.";
|
||
elements.comparisonPairList.append(empty);
|
||
} else {
|
||
pairs.forEach((record, index) => {
|
||
const row = document.createElement("button");
|
||
row.type = "button";
|
||
row.className = "comparison-pair-row";
|
||
row.disabled = locked;
|
||
row.classList.toggle("selected", record.id === comparison.selectedPairId);
|
||
row.setAttribute("role", "option");
|
||
row.setAttribute("aria-selected", record.id === comparison.selectedPairId ? "true" : "false");
|
||
const number = document.createElement("span");
|
||
number.textContent = String(index + 1).padStart(2, "0");
|
||
const copy = document.createElement("span");
|
||
const title = document.createElement("strong");
|
||
const meta = document.createElement("small");
|
||
title.textContent = comparisonWorkflow.pairLabel(record.pair);
|
||
meta.textContent = record.pair.map(comparisonTargetMeta).join(" · ");
|
||
copy.append(title, meta);
|
||
const kind = document.createElement("span");
|
||
kind.textContent = record.pair.map(target => target.kind.replace("-stock", "")).join("/");
|
||
row.append(number, copy, kind);
|
||
row.addEventListener("click", () => {
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
comparison.selectedPairId = record.id;
|
||
renderComparisonWorkflow();
|
||
});
|
||
elements.comparisonPairList.append(row);
|
||
});
|
||
}
|
||
const selectedIndex = pairs.findIndex(record => record.id === comparison.selectedPairId);
|
||
elements.comparisonMoveUp.disabled = locked || selectedIndex <= 0;
|
||
elements.comparisonMoveDown.disabled = locked || selectedIndex < 0 || selectedIndex >= pairs.length - 1;
|
||
elements.comparisonDeletePair.disabled = locked || selectedIndex < 0;
|
||
elements.comparisonDeleteAll.disabled = locked || !pairs.length;
|
||
|
||
elements.comparisonActionList.replaceChildren();
|
||
let previousSection = null;
|
||
comparisonWorkflow.actions.forEach((action, index) => {
|
||
const sectionName = action.kind === "two-column"
|
||
? "2열판"
|
||
: (action.kind === "comparison" ? "종목별 비교분석" : "종목별 수익률 비교");
|
||
if (sectionName !== previousSection) {
|
||
const section = document.createElement("div");
|
||
section.className = "comparison-action-section";
|
||
section.textContent = sectionName;
|
||
elements.comparisonActionList.append(section);
|
||
previousSection = sectionName;
|
||
}
|
||
const compatibility = comparisonWorkflow.getCompatibility(action.id, selectedPair);
|
||
const row = document.createElement("div");
|
||
row.className = "comparison-action-row";
|
||
row.classList.toggle("disabled", !compatibility.supported);
|
||
const number = document.createElement("span");
|
||
number.textContent = String(index + 1).padStart(2, "0");
|
||
const copy = document.createElement("span");
|
||
copy.className = "comparison-action-copy";
|
||
const label = document.createElement("strong");
|
||
const detail = document.createElement("small");
|
||
label.textContent = action.label;
|
||
detail.textContent = compatibility.supported
|
||
? `${action.kind === "two-column" ? "s8018/s5032" : (action.kind === "return" ? "s5029" : action.builderKey)} · ${compatibility.effectiveMode || "GRAPH"}`
|
||
: comparisonCompatibilityText(compatibility.reason);
|
||
copy.append(label, detail);
|
||
const add = document.createElement("button");
|
||
add.type = "button";
|
||
add.className = "comparison-action-add";
|
||
add.textContent = "+";
|
||
add.disabled = locked || !compatibility.supported;
|
||
add.title = compatibility.supported ? "플레이리스트에 추가" : comparisonCompatibilityText(compatibility.reason);
|
||
add.addEventListener("click", () => addComparisonAction(action.id));
|
||
row.addEventListener("dblclick", event => {
|
||
if (!event.target.closest("button") && compatibility.supported) addComparisonAction(action.id);
|
||
});
|
||
row.append(number, copy, add);
|
||
elements.comparisonActionList.append(row);
|
||
});
|
||
elements.comparisonYieldBatch.disabled = locked || !selectedPair ||
|
||
!comparisonWorkflow.yieldActionIds.every(actionId => comparisonWorkflow.isActionAllowed(actionId, selectedPair));
|
||
}
|
||
|
||
function requestComparisonDomesticSearch(event) {
|
||
event?.preventDefault();
|
||
const search = state.comparison.domestic;
|
||
clearComparisonSearchTimeout("domestic");
|
||
const query = String(elements.comparisonDomesticSearch.value || "").trim();
|
||
if (!query || query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) {
|
||
search.status = "error";
|
||
search.error = "1~64자의 국내·NXT 종목명을 입력하세요.";
|
||
search.results = [];
|
||
renderComparisonWorkflow();
|
||
return;
|
||
}
|
||
if (!nativeBridge) {
|
||
search.status = "error";
|
||
search.error = "브라우저 미리보기에서는 실제 종목 DB 검색을 사용할 수 없습니다.";
|
||
renderComparisonWorkflow();
|
||
return;
|
||
}
|
||
const requestId = createId();
|
||
Object.assign(search, {
|
||
requestId, query, status: "loading", results: [], totalRowCount: 0, error: ""
|
||
});
|
||
renderComparisonWorkflow();
|
||
postNative("search-stocks", { requestId, query, maximumResults: 200 });
|
||
search.timeoutId = setTimeout(() => {
|
||
if (search.requestId !== requestId || search.status !== "loading") return;
|
||
search.status = "error";
|
||
search.error = "국내·NXT 종목 검색 응답 시간이 초과되었습니다.";
|
||
renderComparisonWorkflow();
|
||
}, 30000);
|
||
}
|
||
|
||
function handleComparisonDomesticResults(payload) {
|
||
const search = state.comparison.domestic;
|
||
if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return false;
|
||
clearComparisonSearchTimeout("domestic");
|
||
const raw = Array.isArray(payload.results) ? payload.results.slice(0, 500) : [];
|
||
const results = raw.flatMap(value => {
|
||
const target = comparisonTargetFromStockResult(value);
|
||
return target ? [target] : [];
|
||
});
|
||
if (results.length !== raw.length || new Set(results.map(comparisonWorkflow.targetKey)).size !== results.length) {
|
||
search.status = "error";
|
||
search.results = [];
|
||
search.error = "국내·NXT 종목 검색 결과 식별자가 올바르지 않습니다.";
|
||
} else {
|
||
search.status = "ready";
|
||
search.results = results;
|
||
search.totalRowCount = Number.isInteger(payload.totalRowCount) ? Math.max(0, payload.totalRowCount) : results.length;
|
||
search.error = "";
|
||
}
|
||
renderComparisonWorkflow();
|
||
return true;
|
||
}
|
||
|
||
function handleComparisonDomesticError(payload) {
|
||
const search = state.comparison.domestic;
|
||
if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return false;
|
||
clearComparisonSearchTimeout("domestic");
|
||
search.status = "error";
|
||
search.results = [];
|
||
search.error = typeof payload.message === "string" && payload.message.trim()
|
||
? payload.message.trim()
|
||
: "국내·NXT 종목 검색에 실패했습니다.";
|
||
renderComparisonWorkflow();
|
||
return true;
|
||
}
|
||
|
||
function requestComparisonWorldSearch(event) {
|
||
event?.preventDefault();
|
||
const search = state.comparison.world;
|
||
clearComparisonSearchTimeout("world");
|
||
const query = String(elements.comparisonWorldSearch.value || "").trim();
|
||
if (!query || query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) {
|
||
search.status = "error";
|
||
search.error = "1~64자의 미국·대만 종목명을 입력하세요.";
|
||
search.results = [];
|
||
renderComparisonWorkflow();
|
||
return;
|
||
}
|
||
if (!nativeBridge) {
|
||
search.status = "error";
|
||
search.error = "브라우저 미리보기에서는 실제 해외종목 DB 검색을 사용할 수 없습니다.";
|
||
renderComparisonWorkflow();
|
||
return;
|
||
}
|
||
const requestId = createId();
|
||
Object.assign(search, {
|
||
requestId, query, status: "loading", results: [], totalRowCount: 0, error: ""
|
||
});
|
||
renderComparisonWorkflow();
|
||
postNative("search-world-stocks", { requestId, query, maximumResults: 100 });
|
||
search.timeoutId = setTimeout(() => {
|
||
if (search.requestId !== requestId || search.status !== "loading") return;
|
||
search.status = "error";
|
||
search.error = "해외종목 검색 응답 시간이 초과되었습니다.";
|
||
renderComparisonWorkflow();
|
||
}, 30000);
|
||
}
|
||
|
||
function handleComparisonWorldResults(payload) {
|
||
const search = state.comparison.world;
|
||
if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return;
|
||
clearComparisonSearchTimeout("world");
|
||
const raw = Array.isArray(payload.results) ? payload.results.slice(0, 500) : [];
|
||
const results = raw.flatMap(value => {
|
||
const target = comparisonTargetFromWorldResult(value);
|
||
return target ? [target] : [];
|
||
});
|
||
if (results.length !== raw.length || new Set(results.map(comparisonWorkflow.targetKey)).size !== results.length) {
|
||
search.status = "error";
|
||
search.results = [];
|
||
search.error = "해외종목 검색 결과 식별자가 올바르지 않습니다.";
|
||
} else {
|
||
search.status = "ready";
|
||
search.results = results;
|
||
search.totalRowCount = Number.isInteger(payload.totalRowCount) ? Math.max(0, payload.totalRowCount) : results.length;
|
||
search.error = "";
|
||
}
|
||
renderComparisonWorkflow();
|
||
}
|
||
|
||
function handleComparisonWorldError(payload) {
|
||
const search = state.comparison.world;
|
||
if (!payload || payload.requestId !== search.requestId || payload.query !== search.query) return;
|
||
clearComparisonSearchTimeout("world");
|
||
search.status = "error";
|
||
search.results = [];
|
||
search.error = typeof payload.message === "string" && payload.message.trim()
|
||
? payload.message.trim()
|
||
: "해외종목 검색에 실패했습니다.";
|
||
renderComparisonWorkflow();
|
||
}
|
||
|
||
function synchronizeComparisonEntries() {
|
||
let invalid = false;
|
||
state.playlist = state.playlist.map(item => {
|
||
if (item?.operator?.source !== "legacy-comparison-workflow") return item;
|
||
const refreshed = comparisonWorkflow.refreshComparisonPlaylistEntry(item);
|
||
if (!refreshed) {
|
||
invalid = true;
|
||
return item;
|
||
}
|
||
const definition = catalog.find(value =>
|
||
value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
|
||
if (!definition) {
|
||
invalid = true;
|
||
return item;
|
||
}
|
||
return { ...definition, ...refreshed };
|
||
});
|
||
if (invalid) {
|
||
showToast("저장된 비교 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function clearThemeTimeout(kind) {
|
||
const value = state.theme[kind];
|
||
if (value?.timeoutId !== null) clearTimeout(value.timeoutId);
|
||
if (value) value.timeoutId = null;
|
||
}
|
||
|
||
function themeSessionWire(theme) {
|
||
if (theme?.market === "krx") return "notApplicable";
|
||
return theme?.session === "AFTER_MARKET" ? "afterMarket" : "preMarket";
|
||
}
|
||
|
||
function themeIdentity(theme) {
|
||
if (!theme) return "";
|
||
return [theme.market, themeSessionWire(theme), theme.themeCode, theme.title].join("|");
|
||
}
|
||
|
||
function selectedThemeWithItemCount(itemCount) {
|
||
const selected = state.theme.selected;
|
||
return selected ? themeWorkflow.normalizeTheme({ ...selected, itemCount }) : null;
|
||
}
|
||
|
||
function themeCompatibilityText(reason) {
|
||
return {
|
||
"invalid-theme": "테마를 선택하세요.",
|
||
"invalid-theme-sort": "테마 정렬 방식을 확인하세요.",
|
||
"theme-preview-is-empty": "종목이 없는 테마는 송출할 수 없습니다.",
|
||
"expected-theme-is-krx-only": "예상체결가는 KRX 테마 5단 표에서만 지원합니다.",
|
||
"nxt-session-required": "NXT 장전 또는 시간외 세션을 선택하세요."
|
||
}[reason] || "현재 테마와 컷 조합은 지원하지 않습니다.";
|
||
}
|
||
|
||
function addThemeAction(actionId) {
|
||
if (!requirePlaylistEditable()) return;
|
||
const theme = state.theme.selected;
|
||
const sort = state.theme.sort;
|
||
if (state.theme.preview.status !== "ready" || !theme) {
|
||
showToast("테마를 선택하고 종목 미리보기가 완료될 때까지 기다리세요.");
|
||
return;
|
||
}
|
||
const compatibility = themeWorkflow.getCompatibility(actionId, theme, sort);
|
||
if (!compatibility.supported) {
|
||
showToast(themeCompatibilityText(compatibility.reason));
|
||
return;
|
||
}
|
||
try {
|
||
const created = themeWorkflow.createThemePlaylistEntry(actionId, theme, sort, {
|
||
id: createId(),
|
||
fadeDuration: DEFAULT_FADE_DURATION
|
||
});
|
||
const definition = catalog.find(item =>
|
||
item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
|
||
if (!definition) throw new Error("The theme action does not resolve to a registered scene alias.");
|
||
const entry = { ...definition, ...created };
|
||
state.playlist.push(entry);
|
||
state.selectedRows.clear();
|
||
state.selectedRows.add(entry.id);
|
||
state.currentIndex = state.playlist.length - 1;
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`);
|
||
showToast("테마 컷을 플레이리스트에 추가했습니다.");
|
||
} catch {
|
||
showToast("테마와 컷을 안전한 페이지 장면으로 구성하지 못했습니다.");
|
||
}
|
||
}
|
||
|
||
function renderThemeWorkflow() {
|
||
const visible = state.activeMarket === "theme";
|
||
elements.themeWorkflow.hidden = !visible;
|
||
if (!visible) return;
|
||
const theme = state.theme;
|
||
const search = theme.search;
|
||
const preview = theme.preview;
|
||
const locked = isPlaylistSnapshotLocked();
|
||
elements.themeCatalogManageButton.disabled = operatorUiLocked();
|
||
elements.themeCatalogManageButton.textContent = preview.catalogContext ? "선택 테마 편집" : "테마 관리";
|
||
elements.themeCount.textContent = search.status === "loading"
|
||
? "LOADING"
|
||
: `${search.totalRowCount.toLocaleString("ko-KR")} THEMES`;
|
||
elements.themeSearch.disabled = locked;
|
||
elements.themeNxtSession.disabled = locked;
|
||
elements.themeSearchButton.disabled = locked || search.status === "loading";
|
||
elements.themeSearchState.className = `theme-search-state ${search.status}`;
|
||
if (search.status === "loading") {
|
||
elements.themeSearchState.textContent = "KRX(Oracle)와 NXT(MariaDB) 테마를 조회하고 있습니다.";
|
||
} else if (search.status === "error") {
|
||
elements.themeSearchState.textContent = search.error || "테마 검색에 실패했습니다.";
|
||
} else if (search.status === "ready") {
|
||
elements.themeSearchState.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}개 테마${search.truncated ? " · 결과 제한 적용" : ""}`;
|
||
} else {
|
||
elements.themeSearchState.textContent = "조회 버튼을 누르면 전체 테마를 불러옵니다.";
|
||
}
|
||
|
||
elements.themeResults.replaceChildren();
|
||
if (!search.results.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "comparison-target-empty";
|
||
empty.textContent = search.status === "ready" ? "일치하는 테마가 없습니다." : "테마 검색 결과가 여기에 표시됩니다.";
|
||
elements.themeResults.append(empty);
|
||
} else {
|
||
const selectedIdentity = themeIdentity(theme.selected);
|
||
for (const item of search.results) {
|
||
const row = document.createElement("button");
|
||
row.type = "button";
|
||
row.className = "theme-result";
|
||
row.disabled = locked;
|
||
const selected = themeIdentity(item) === selectedIdentity;
|
||
row.classList.toggle("selected", selected);
|
||
row.setAttribute("role", "option");
|
||
row.setAttribute("aria-selected", selected ? "true" : "false");
|
||
const copy = document.createElement("span");
|
||
const title = document.createElement("strong");
|
||
const meta = document.createElement("small");
|
||
title.textContent = item.displayTitle;
|
||
meta.textContent = `${item.themeCode} · ${themeSessionWire(item)}`;
|
||
copy.append(title, meta);
|
||
const market = document.createElement("span");
|
||
market.className = "theme-market-badge";
|
||
market.textContent = item.market.toLocaleUpperCase("en-US");
|
||
row.append(copy, market);
|
||
row.addEventListener("click", () => selectTheme(item));
|
||
elements.themeResults.append(row);
|
||
}
|
||
}
|
||
|
||
elements.selectedTheme.hidden = !theme.selected;
|
||
elements.themeSort.disabled = locked || !theme.selected;
|
||
if (theme.selected) {
|
||
elements.selectedThemeTitle.textContent = theme.selected.displayTitle;
|
||
elements.selectedThemeMeta.textContent = `${theme.selected.themeCode} · ${theme.selected.market.toLocaleUpperCase("en-US")} · ${themeSessionWire(theme.selected)}`;
|
||
}
|
||
|
||
elements.themePreviewCount.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")} ITEMS`;
|
||
elements.themePreviewState.className = `theme-preview-state ${preview.status}`;
|
||
if (preview.status === "loading") elements.themePreviewState.textContent = "선택 테마의 종목을 조회하고 있습니다.";
|
||
else if (preview.status === "error") elements.themePreviewState.textContent = preview.error || "테마 종목을 불러오지 못했습니다.";
|
||
else if (preview.status === "ready") {
|
||
elements.themePreviewState.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")}개 종목${preview.truncated ? " · 240개 상한 적용" : ""}`;
|
||
} else elements.themePreviewState.textContent = "테마를 선택하면 종목 구성을 확인합니다.";
|
||
elements.themePreviewItems.replaceChildren();
|
||
if (!preview.items.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "comparison-target-empty";
|
||
empty.textContent = preview.status === "ready" ? "테마에 송출 가능한 종목이 없습니다." : "미리보기 대기 중";
|
||
elements.themePreviewItems.append(empty);
|
||
} else {
|
||
for (const item of preview.items) {
|
||
const row = document.createElement("div");
|
||
row.className = "theme-preview-item";
|
||
const index = document.createElement("span");
|
||
const name = document.createElement("strong");
|
||
const code = document.createElement("small");
|
||
index.textContent = String(item.inputIndex).padStart(2, "0");
|
||
name.textContent = item.itemName;
|
||
code.textContent = item.itemCode;
|
||
row.append(index, name, code);
|
||
elements.themePreviewItems.append(row);
|
||
}
|
||
}
|
||
|
||
elements.themeActionList.replaceChildren();
|
||
themeWorkflow.actions.forEach((action, index) => {
|
||
const compatibility = preview.status === "ready"
|
||
? themeWorkflow.getCompatibility(action.id, theme.selected, theme.sort)
|
||
: { supported: false, reason: "invalid-theme" };
|
||
const row = document.createElement("div");
|
||
row.className = "theme-action-row";
|
||
row.classList.toggle("disabled", !compatibility.supported);
|
||
const number = document.createElement("span");
|
||
number.textContent = String(index + 1).padStart(2, "0");
|
||
const copy = document.createElement("span");
|
||
copy.className = "theme-action-copy";
|
||
const label = document.createElement("strong");
|
||
const detail = document.createElement("small");
|
||
label.textContent = action.label;
|
||
const page = theme.selected ? themeWorkflow.calculatePagePreview(action.id, theme.selected) : null;
|
||
detail.textContent = compatibility.supported
|
||
? `${action.code} · ${action.pageSize}개 × ${page?.pageCount ?? "?"}페이지${page?.isTruncated ? " · 20페이지 상한" : ""}`
|
||
: themeCompatibilityText(compatibility.reason);
|
||
copy.append(label, detail);
|
||
const add = document.createElement("button");
|
||
add.type = "button";
|
||
add.className = "theme-action-add";
|
||
add.textContent = "+";
|
||
add.disabled = locked || !compatibility.supported;
|
||
add.title = compatibility.supported ? "플레이리스트에 추가" : themeCompatibilityText(compatibility.reason);
|
||
add.addEventListener("click", () => addThemeAction(action.id));
|
||
row.addEventListener("dblclick", event => {
|
||
if (!event.target.closest("button") && compatibility.supported) addThemeAction(action.id);
|
||
});
|
||
row.append(number, copy, add);
|
||
elements.themeActionList.append(row);
|
||
});
|
||
}
|
||
|
||
function requestThemeSearch(event) {
|
||
event?.preventDefault();
|
||
const search = state.theme.search;
|
||
clearThemeTimeout("search");
|
||
clearThemeTimeout("preview");
|
||
const query = String(elements.themeSearch.value || "").trim();
|
||
if (query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) {
|
||
search.status = "error";
|
||
search.error = "테마 검색어는 64자 이하여야 합니다.";
|
||
search.results = [];
|
||
renderThemeWorkflow();
|
||
return;
|
||
}
|
||
state.theme.selected = null;
|
||
Object.assign(state.theme.preview, {
|
||
requestId: null, status: "idle", items: [], totalRowCount: 0, truncated: false, error: ""
|
||
});
|
||
if (!nativeBridge) {
|
||
search.status = "error";
|
||
search.error = "브라우저 미리보기에서는 실제 테마 DB 조회를 사용할 수 없습니다.";
|
||
renderThemeWorkflow();
|
||
return;
|
||
}
|
||
const requestId = createId();
|
||
Object.assign(search, {
|
||
requestId, query, status: "loading", results: [], totalRowCount: 0, truncated: false, error: ""
|
||
});
|
||
renderThemeWorkflow();
|
||
postNative("search-themes", {
|
||
requestId,
|
||
query,
|
||
nxtSession: state.theme.nxtSession,
|
||
maximumResults: 200
|
||
});
|
||
search.timeoutId = setTimeout(() => {
|
||
if (search.requestId !== requestId || search.status !== "loading") return;
|
||
search.status = "error";
|
||
search.error = "테마 검색 응답 시간이 초과되었습니다.";
|
||
renderThemeWorkflow();
|
||
}, 30000);
|
||
}
|
||
|
||
function handleThemeSearchResults(payload) {
|
||
const search = state.theme.search;
|
||
if (!payload || payload.requestId !== search.requestId || payload.query !== search.query ||
|
||
payload.nxtSession !== state.theme.nxtSession) return;
|
||
clearThemeTimeout("search");
|
||
const raw = Array.isArray(payload.results) ? payload.results.slice(0, 500) : [];
|
||
const results = raw.flatMap(value => {
|
||
const normalized = themeWorkflow.normalizeTheme({ ...value, itemCount: null });
|
||
return normalized ? [normalized] : [];
|
||
});
|
||
const identities = results.map(themeIdentity);
|
||
if (results.length !== raw.length || new Set(identities).size !== identities.length) {
|
||
search.status = "error";
|
||
search.results = [];
|
||
search.error = "테마 검색 결과 식별자가 올바르지 않습니다.";
|
||
} else {
|
||
search.status = "ready";
|
||
search.results = results;
|
||
search.totalRowCount = Number.isInteger(payload.totalRowCount) ? Math.max(0, payload.totalRowCount) : results.length;
|
||
search.truncated = payload.truncated === true;
|
||
search.error = "";
|
||
}
|
||
renderThemeWorkflow();
|
||
}
|
||
|
||
function handleThemeSearchError(payload) {
|
||
const search = state.theme.search;
|
||
if (!payload || payload.requestId !== search.requestId || payload.query !== search.query ||
|
||
payload.nxtSession !== state.theme.nxtSession) return;
|
||
clearThemeTimeout("search");
|
||
search.status = "error";
|
||
search.results = [];
|
||
search.error = typeof payload.message === "string" && payload.message.trim()
|
||
? payload.message.trim()
|
||
: "테마 검색에 실패했습니다.";
|
||
renderThemeWorkflow();
|
||
}
|
||
|
||
function selectTheme(theme) {
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
const normalized = themeWorkflow.normalizeTheme({ ...theme, itemCount: null });
|
||
if (!normalized) return;
|
||
state.theme.selected = normalized;
|
||
requestThemePreview(normalized);
|
||
addLog(`테마 선택 · ${normalized.displayTitle} · ${normalized.themeCode}`);
|
||
}
|
||
|
||
function requestThemePreview(theme) {
|
||
clearThemeTimeout("preview");
|
||
const preview = state.theme.preview;
|
||
const requestId = createId();
|
||
Object.assign(preview, {
|
||
requestId, status: "loading", items: [], totalRowCount: 0, truncated: false, error: "",
|
||
catalogContext: null
|
||
});
|
||
renderThemeWorkflow();
|
||
if (!nativeBridge) {
|
||
preview.status = "error";
|
||
preview.error = "브라우저 미리보기에서는 실제 테마 종목 조회를 사용할 수 없습니다.";
|
||
renderThemeWorkflow();
|
||
return;
|
||
}
|
||
postNative("request-theme-preview", {
|
||
requestId,
|
||
market: theme.market,
|
||
session: themeSessionWire(theme),
|
||
themeCode: theme.themeCode,
|
||
themeTitle: theme.title,
|
||
maximumItems: 240
|
||
});
|
||
preview.timeoutId = setTimeout(() => {
|
||
if (preview.requestId !== requestId || preview.status !== "loading") return;
|
||
preview.status = "error";
|
||
preview.error = "테마 종목 미리보기 응답 시간이 초과되었습니다.";
|
||
renderThemeWorkflow();
|
||
}, 30000);
|
||
}
|
||
|
||
function handleThemePreviewResults(payload) {
|
||
const preview = state.theme.preview;
|
||
if (!payload || payload.requestId !== preview.requestId || !state.theme.selected) return;
|
||
const rawSelection = payload.selection;
|
||
const responseTheme = themeWorkflow.normalizeTheme({
|
||
market: rawSelection?.market,
|
||
session: rawSelection?.session,
|
||
themeCode: rawSelection?.themeCode,
|
||
title: rawSelection?.title,
|
||
itemCount: Number.isInteger(payload.totalRowCount) ? payload.totalRowCount : null
|
||
});
|
||
if (!responseTheme || themeIdentity(responseTheme) !== themeIdentity(state.theme.selected)) return;
|
||
clearThemeTimeout("preview");
|
||
const rawItems = Array.isArray(payload.items) ? payload.items.slice(0, 240) : [];
|
||
const seenCodes = new Set();
|
||
const seenIndexes = new Set();
|
||
const items = [];
|
||
for (const value of rawItems) {
|
||
const inputIndex = value?.inputIndex;
|
||
const itemCode = typeof value?.itemCode === "string" ? value.itemCode.trim() : "";
|
||
const itemName = typeof value?.itemName === "string" ? value.itemName.trim() : "";
|
||
if (!Number.isInteger(inputIndex) || inputIndex < 0 || inputIndex > 9999 ||
|
||
!itemCode || itemCode.length > 32 || !itemName || itemName.length > 200 ||
|
||
/[\u0000-\u001f\u007f]/.test(`${itemCode}${itemName}`) ||
|
||
seenCodes.has(itemCode) || seenIndexes.has(inputIndex)) {
|
||
preview.status = "error";
|
||
preview.items = [];
|
||
preview.error = "테마 종목 미리보기 식별자가 올바르지 않습니다.";
|
||
renderThemeWorkflow();
|
||
return;
|
||
}
|
||
seenCodes.add(itemCode);
|
||
seenIndexes.add(inputIndex);
|
||
items.push({ inputIndex, itemCode, itemName });
|
||
}
|
||
if (!Number.isInteger(payload.totalRowCount) || payload.totalRowCount !== items.length) {
|
||
preview.status = "error";
|
||
preview.items = [];
|
||
preview.error = "테마 종목 미리보기 행 수가 올바르지 않습니다.";
|
||
renderThemeWorkflow();
|
||
return;
|
||
}
|
||
state.theme.selected = responseTheme;
|
||
preview.status = "ready";
|
||
preview.items = items;
|
||
preview.totalRowCount = items.length;
|
||
preview.truncated = payload.truncated === true;
|
||
preview.error = "";
|
||
const catalogSelection = {
|
||
market: responseTheme.market,
|
||
session: themeSessionWire(responseTheme),
|
||
themeCode: responseTheme.themeCode,
|
||
themeTitle: responseTheme.title,
|
||
source: responseTheme.market === "krx" ? "oracle" : "mariaDb"
|
||
};
|
||
const catalogPreview = {
|
||
requestId: payload.requestId,
|
||
selection: {
|
||
market: responseTheme.market,
|
||
session: themeSessionWire(responseTheme),
|
||
themeCode: responseTheme.themeCode,
|
||
title: responseTheme.title
|
||
},
|
||
retrievedAt: payload.retrievedAt,
|
||
totalRowCount: items.length,
|
||
truncated: payload.truncated === true,
|
||
items: items.map(value => ({ ...value }))
|
||
};
|
||
try {
|
||
operatorCatalogWorkflow.createThemeEditDraft(catalogSelection, catalogPreview);
|
||
preview.catalogContext = Object.freeze({
|
||
selection: Object.freeze({ ...catalogSelection }),
|
||
preview: Object.freeze({ ...catalogPreview, items: Object.freeze(catalogPreview.items) })
|
||
});
|
||
} catch {
|
||
preview.catalogContext = null;
|
||
}
|
||
renderThemeWorkflow();
|
||
addLog(`테마 종목 조회 완료 · ${responseTheme.displayTitle} · ${items.length}건`);
|
||
}
|
||
|
||
function handleThemePreviewError(payload) {
|
||
const preview = state.theme.preview;
|
||
if (!payload || payload.requestId !== preview.requestId || !state.theme.selected) return;
|
||
const selection = payload.selection;
|
||
const identity = [selection?.market, selection?.session, selection?.themeCode, selection?.title].join("|");
|
||
if (identity !== themeIdentity(state.theme.selected)) return;
|
||
clearThemeTimeout("preview");
|
||
preview.status = "error";
|
||
preview.items = [];
|
||
preview.catalogContext = null;
|
||
preview.error = typeof payload.message === "string" && payload.message.trim()
|
||
? payload.message.trim()
|
||
: "테마 종목을 불러오지 못했습니다.";
|
||
renderThemeWorkflow();
|
||
}
|
||
|
||
function synchronizeThemeEntries() {
|
||
let invalid = false;
|
||
state.playlist = state.playlist.map(item => {
|
||
if (item?.operator?.source !== "legacy-theme-workflow") return item;
|
||
const refreshed = themeWorkflow.refreshThemePlaylistEntry(item);
|
||
if (!refreshed) {
|
||
invalid = true;
|
||
return item;
|
||
}
|
||
const definition = catalog.find(value =>
|
||
value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
|
||
if (!definition) {
|
||
invalid = true;
|
||
return item;
|
||
}
|
||
return { ...definition, ...refreshed };
|
||
});
|
||
if (invalid) {
|
||
showToast("저장된 테마 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function clearOverseasTimeout(kind) {
|
||
const value = state.overseas[kind];
|
||
if (value?.timeoutId !== null) clearTimeout(value.timeoutId);
|
||
if (value) value.timeoutId = null;
|
||
}
|
||
|
||
function currentOverseasStore() {
|
||
return state.overseas[state.overseas.source];
|
||
}
|
||
|
||
function overseasIdentity(kind, value) {
|
||
if (!value) return "";
|
||
return kind === "industry"
|
||
? [value.koreanName, value.inputName, value.symbol, value.nationCode, value.fdtc].join("|")
|
||
: [value.inputName, value.symbol, value.nationCode, value.fdtc].join("|");
|
||
}
|
||
|
||
function selectOverseas(value) {
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
const kind = state.overseas.source;
|
||
const normalized = kind === "industry"
|
||
? overseasWorkflow.normalizeIndustry(value)
|
||
: overseasWorkflow.normalizeStock(value);
|
||
if (!normalized) return;
|
||
state.overseas[kind].selected = normalized;
|
||
renderOverseasWorkflow();
|
||
addLog(`해외 ${kind === "industry" ? "업종" : "종목"} 선택 · ${kind === "industry" ? normalized.koreanName : normalized.inputName} · ${normalized.symbol}`);
|
||
}
|
||
|
||
function addOverseasAction(actionId) {
|
||
if (!requirePlaylistEditable()) return;
|
||
const kind = state.overseas.source;
|
||
const selected = state.overseas[kind].selected;
|
||
if (!selected) {
|
||
showToast("먼저 해외업종 또는 해외종목을 선택하세요.");
|
||
elements.overseasSearch.focus();
|
||
return;
|
||
}
|
||
try {
|
||
const options = {
|
||
id: createId(),
|
||
fadeDuration: DEFAULT_FADE_DURATION,
|
||
ma5: state.candleOptions.ma5,
|
||
ma20: state.candleOptions.ma20
|
||
};
|
||
const created = kind === "industry"
|
||
? overseasWorkflow.createOverseasIndustryPlaylistEntry(selected, options)
|
||
: overseasWorkflow.createOverseasStockPlaylistEntry(selected, actionId, options);
|
||
const definition = catalog.find(item =>
|
||
item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
|
||
if (!definition) throw new Error("The overseas action does not resolve to a scene alias.");
|
||
const entry = { ...definition, ...created };
|
||
state.playlist.push(entry);
|
||
state.selectedRows.clear();
|
||
state.selectedRows.add(entry.id);
|
||
state.currentIndex = state.playlist.length - 1;
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`);
|
||
showToast("해외 대상 컷을 플레이리스트에 추가했습니다.");
|
||
} catch {
|
||
showToast("선택한 해외 대상과 컷을 안전하게 구성하지 못했습니다.");
|
||
}
|
||
}
|
||
|
||
function renderOverseasWorkflow() {
|
||
renderGlobalCandleOptions();
|
||
const visible = state.activeMarket === "foreign-stock";
|
||
elements.overseasWorkflow.hidden = !visible;
|
||
if (!visible) return;
|
||
const kind = state.overseas.source;
|
||
const store = currentOverseasStore();
|
||
const locked = isPlaylistSnapshotLocked();
|
||
elements.overseasSourceTabs.querySelectorAll("button[data-overseas-source]").forEach(button => {
|
||
button.classList.toggle("active", button.dataset.overseasSource === kind);
|
||
button.disabled = locked;
|
||
});
|
||
elements.overseasCount.textContent = store.status === "loading"
|
||
? "LOADING"
|
||
: `${store.totalRowCount.toLocaleString("ko-KR")} ${kind === "industry" ? "INDUSTRIES" : "STOCKS"}`;
|
||
elements.overseasSearch.disabled = locked;
|
||
elements.overseasSearchButton.disabled = locked || store.status === "loading";
|
||
elements.overseasSearchState.className = `overseas-search-state ${store.status}`;
|
||
if (store.status === "loading") {
|
||
elements.overseasSearchState.textContent = kind === "industry"
|
||
? "Oracle에서 미국 해외업종을 조회하고 있습니다."
|
||
: "Oracle에서 미국·대만 해외종목을 조회하고 있습니다.";
|
||
} else if (store.status === "error") {
|
||
elements.overseasSearchState.textContent = store.error || "해외 대상 조회에 실패했습니다.";
|
||
} else if (store.status === "ready") {
|
||
elements.overseasSearchState.textContent = `${store.totalRowCount.toLocaleString("ko-KR")}개 결과${store.truncated ? " · 결과 제한 적용" : ""}`;
|
||
} else {
|
||
elements.overseasSearchState.textContent = "조회 버튼을 누르면 전체 목록을 불러옵니다.";
|
||
}
|
||
|
||
elements.overseasResults.replaceChildren();
|
||
if (!store.results.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "comparison-target-empty";
|
||
empty.textContent = store.status === "ready" ? "일치하는 결과가 없습니다." : "검색 결과가 여기에 표시됩니다.";
|
||
elements.overseasResults.append(empty);
|
||
} else {
|
||
const selectedIdentity = overseasIdentity(kind, store.selected);
|
||
for (const item of store.results) {
|
||
const row = document.createElement("button");
|
||
row.type = "button";
|
||
row.className = "overseas-result";
|
||
row.disabled = locked;
|
||
const selected = overseasIdentity(kind, item) === selectedIdentity;
|
||
row.classList.toggle("selected", selected);
|
||
row.setAttribute("role", "option");
|
||
row.setAttribute("aria-selected", selected ? "true" : "false");
|
||
const copy = document.createElement("span");
|
||
const name = document.createElement("strong");
|
||
const meta = document.createElement("small");
|
||
name.textContent = kind === "industry" ? item.koreanName : item.inputName;
|
||
meta.textContent = kind === "industry"
|
||
? `${item.inputName} · ${item.symbol}`
|
||
: `${item.symbol} · FDTC ${item.fdtc}`;
|
||
copy.append(name, meta);
|
||
const nation = document.createElement("span");
|
||
nation.textContent = item.nationCode;
|
||
row.append(copy, nation);
|
||
row.addEventListener("click", () => selectOverseas(item));
|
||
elements.overseasResults.append(row);
|
||
}
|
||
}
|
||
|
||
elements.selectedOverseas.hidden = !store.selected;
|
||
elements.overseasMa5.disabled = locked || kind !== "stock" || !store.selected;
|
||
elements.overseasMa20.disabled = locked || kind !== "stock" || !store.selected;
|
||
if (store.selected) {
|
||
elements.selectedOverseasName.textContent = kind === "industry"
|
||
? store.selected.koreanName
|
||
: store.selected.inputName;
|
||
elements.selectedOverseasMeta.textContent = `${store.selected.symbol} · ${store.selected.nationCode} · FDTC ${store.selected.fdtc}`;
|
||
}
|
||
|
||
const actions = kind === "industry"
|
||
? overseasWorkflow.industryActions
|
||
: overseasWorkflow.stockActions;
|
||
elements.overseasActionList.replaceChildren();
|
||
actions.forEach((action, index) => {
|
||
const allowed = overseasWorkflow.isActionAllowed(kind, action.id, store.selected);
|
||
const row = document.createElement("div");
|
||
row.className = "overseas-action-row";
|
||
row.classList.toggle("disabled", !allowed);
|
||
const number = document.createElement("span");
|
||
number.textContent = String(index + 1).padStart(2, "0");
|
||
const copy = document.createElement("span");
|
||
copy.className = "overseas-action-copy";
|
||
const label = document.createElement("strong");
|
||
const detail = document.createElement("small");
|
||
label.textContent = action.label;
|
||
detail.textContent = `${action.code}${action.periodDays ? ` · ${action.periodDays}일` : " · 현재가"}`;
|
||
copy.append(label, detail);
|
||
const add = document.createElement("button");
|
||
add.type = "button";
|
||
add.className = "overseas-action-add";
|
||
add.textContent = "+";
|
||
add.disabled = locked || !allowed;
|
||
add.title = allowed ? "플레이리스트에 추가" : "해외 대상을 먼저 선택하세요.";
|
||
add.addEventListener("click", () => addOverseasAction(action.id));
|
||
row.addEventListener("dblclick", event => {
|
||
if (!event.target.closest("button") && allowed) addOverseasAction(action.id);
|
||
});
|
||
row.append(number, copy, add);
|
||
elements.overseasActionList.append(row);
|
||
});
|
||
}
|
||
|
||
function requestOverseasSearch(event) {
|
||
event?.preventDefault();
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
const kind = state.overseas.source;
|
||
const store = state.overseas[kind];
|
||
clearOverseasTimeout(kind);
|
||
let request;
|
||
try {
|
||
const createRequest = kind === "industry"
|
||
? overseasWorkflow.createIndustrySearchRequest
|
||
: overseasWorkflow.createStockSearchRequest;
|
||
request = createRequest(
|
||
createId(),
|
||
String(elements.overseasSearch.value || ""),
|
||
overseasWorkflow.bridgeContracts[kind].maximumResults);
|
||
} catch {
|
||
store.status = "error";
|
||
store.error = "검색어는 64자 이하의 안전한 문자여야 합니다.";
|
||
store.results = [];
|
||
store.totalRowCount = 0;
|
||
renderOverseasWorkflow();
|
||
return;
|
||
}
|
||
store.request = request;
|
||
store.status = "loading";
|
||
store.results = [];
|
||
store.selected = null;
|
||
store.totalRowCount = 0;
|
||
store.truncated = false;
|
||
store.error = "";
|
||
renderOverseasWorkflow();
|
||
if (!nativeBridge) {
|
||
store.status = "error";
|
||
store.error = "브라우저 미리보기에서는 실제 해외 DB 조회를 사용할 수 없습니다.";
|
||
renderOverseasWorkflow();
|
||
return;
|
||
}
|
||
postNative(overseasWorkflow.bridgeContracts[kind].requestType, request);
|
||
store.timeoutId = setTimeout(() => {
|
||
if (store.request !== request || store.status !== "loading") return;
|
||
store.status = "error";
|
||
store.error = "해외 대상 조회 응답 시간이 초과되었습니다.";
|
||
renderOverseasWorkflow();
|
||
}, 30000);
|
||
}
|
||
|
||
function handleOverseasSearchResults(kind, payload) {
|
||
const store = state.overseas[kind];
|
||
const request = store.request;
|
||
if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return false;
|
||
clearOverseasTimeout(kind);
|
||
const response = kind === "industry"
|
||
? overseasWorkflow.normalizeIndustrySearchResponse(payload, request)
|
||
: overseasWorkflow.normalizeStockSearchResponse(payload, request);
|
||
if (!response) {
|
||
store.status = "error";
|
||
store.results = [];
|
||
store.totalRowCount = 0;
|
||
store.error = "해외 대상 응답 형식 또는 식별자가 올바르지 않습니다.";
|
||
} else {
|
||
store.status = "ready";
|
||
store.results = [...response.results];
|
||
store.totalRowCount = response.totalRowCount;
|
||
store.truncated = response.truncated;
|
||
store.error = "";
|
||
}
|
||
renderOverseasWorkflow();
|
||
return true;
|
||
}
|
||
|
||
function handleOverseasSearchError(kind, payload) {
|
||
const store = state.overseas[kind];
|
||
const request = store.request;
|
||
if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return false;
|
||
clearOverseasTimeout(kind);
|
||
const error = kind === "industry"
|
||
? overseasWorkflow.normalizeIndustrySearchError(payload, request)
|
||
: overseasWorkflow.normalizeStockSearchError(payload, request);
|
||
store.status = "error";
|
||
store.results = [];
|
||
store.totalRowCount = 0;
|
||
store.error = error?.message || "해외 대상 조회에 실패했습니다.";
|
||
renderOverseasWorkflow();
|
||
return true;
|
||
}
|
||
|
||
function synchronizeOverseasEntries() {
|
||
let invalid = false;
|
||
state.playlist = state.playlist.map(item => {
|
||
if (item?.operator?.source !== "legacy-overseas-workflow") return item;
|
||
const refreshed = overseasWorkflow.refreshOverseasPlaylistEntry(item);
|
||
if (!refreshed) {
|
||
invalid = true;
|
||
return item;
|
||
}
|
||
const definition = catalog.find(value =>
|
||
value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
|
||
if (!definition) {
|
||
invalid = true;
|
||
return item;
|
||
}
|
||
return { ...definition, ...refreshed };
|
||
});
|
||
if (invalid) {
|
||
showToast("저장된 해외 대상 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function clearExpertTimeout(kind) {
|
||
const value = state.expert[kind];
|
||
if (value?.timeoutId !== null) clearTimeout(value.timeoutId);
|
||
if (value) value.timeoutId = null;
|
||
}
|
||
|
||
function expertIdentity(value) {
|
||
return value ? `${value.expertCode}|${value.expertName}` : "";
|
||
}
|
||
|
||
function selectExpert(value) {
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
const selected = expertWorkflow.normalizeExpert(value);
|
||
if (!selected) return;
|
||
state.expert.selected = selected;
|
||
state.expert.selectable = null;
|
||
requestExpertPreview(selected);
|
||
addLog(`전문가 선택 · ${selected.expertName} · ${selected.expertCode}`);
|
||
}
|
||
|
||
function addExpertAction() {
|
||
if (!requirePlaylistEditable()) return;
|
||
const selected = state.expert.selectable;
|
||
if (!selected || state.expert.preview.status !== "ready") {
|
||
showToast("전문가를 선택하고 추천 종목 미리보기가 완료될 때까지 기다리세요.");
|
||
return;
|
||
}
|
||
try {
|
||
const created = expertWorkflow.createExpertPlaylistEntry(selected, {
|
||
id: createId(),
|
||
fadeDuration: DEFAULT_FADE_DURATION
|
||
});
|
||
const definition = catalog.find(item =>
|
||
item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
|
||
if (!definition) throw new Error("The expert action does not resolve to a scene alias.");
|
||
const entry = { ...definition, ...created };
|
||
state.playlist.push(entry);
|
||
state.selectedRows.clear();
|
||
state.selectedRows.add(entry.id);
|
||
state.currentIndex = state.playlist.length - 1;
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`${entry.code} ${entry.title} · ${entry.pageCount}페이지 추가`);
|
||
showToast("전문가 추천 컷을 플레이리스트에 추가했습니다.");
|
||
} catch {
|
||
showToast("전문가 추천을 안전한 페이지 장면으로 구성하지 못했습니다.");
|
||
}
|
||
}
|
||
|
||
function renderExpertWorkflow() {
|
||
const visible = state.activeMarket === "expert";
|
||
elements.expertWorkflow.hidden = !visible;
|
||
if (!visible) return;
|
||
const expert = state.expert;
|
||
const search = expert.search;
|
||
const preview = expert.preview;
|
||
const locked = isPlaylistSnapshotLocked();
|
||
elements.expertCatalogManageButton.disabled = operatorUiLocked();
|
||
elements.expertCatalogManageButton.textContent = preview.catalogContext ? "선택 전문가 편집" : "전문가 관리";
|
||
elements.expertCount.textContent = search.status === "loading"
|
||
? "LOADING"
|
||
: `${search.totalRowCount.toLocaleString("ko-KR")} EXPERTS`;
|
||
elements.expertSearch.disabled = locked;
|
||
elements.expertSearchButton.disabled = locked || search.status === "loading";
|
||
elements.expertSearchState.className = `expert-search-state ${search.status}`;
|
||
if (search.status === "loading") elements.expertSearchState.textContent = "Oracle 전문가 목록을 조회하고 있습니다.";
|
||
else if (search.status === "error") elements.expertSearchState.textContent = search.error || "전문가 검색에 실패했습니다.";
|
||
else if (search.status === "ready") elements.expertSearchState.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}명${search.truncated ? " · 결과 제한 적용" : ""}`;
|
||
else elements.expertSearchState.textContent = "조회 버튼을 누르면 전체 전문가를 불러옵니다.";
|
||
|
||
elements.expertResults.replaceChildren();
|
||
if (!search.results.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "comparison-target-empty";
|
||
empty.textContent = search.status === "ready" ? "일치하는 전문가가 없습니다." : "전문가 검색 결과가 여기에 표시됩니다.";
|
||
elements.expertResults.append(empty);
|
||
} else {
|
||
const selectedIdentity = expertIdentity(expert.selected);
|
||
for (const item of search.results) {
|
||
const row = document.createElement("button");
|
||
row.type = "button";
|
||
row.className = "expert-result";
|
||
row.disabled = locked;
|
||
const selected = expertIdentity(item) === selectedIdentity;
|
||
row.classList.toggle("selected", selected);
|
||
row.setAttribute("role", "option");
|
||
row.setAttribute("aria-selected", selected ? "true" : "false");
|
||
const copy = document.createElement("span");
|
||
const name = document.createElement("strong");
|
||
const code = document.createElement("small");
|
||
name.textContent = item.expertName;
|
||
code.textContent = item.expertCode;
|
||
copy.append(name, code);
|
||
const source = document.createElement("span");
|
||
source.textContent = "ORACLE";
|
||
row.append(copy, source);
|
||
row.addEventListener("click", () => selectExpert(item));
|
||
elements.expertResults.append(row);
|
||
}
|
||
}
|
||
|
||
elements.selectedExpert.hidden = !expert.selected;
|
||
if (expert.selected) {
|
||
elements.selectedExpertName.textContent = expert.selected.expertName;
|
||
elements.selectedExpertMeta.textContent = `${expert.selected.expertCode} · ORACLE`;
|
||
}
|
||
elements.expertPreviewCount.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")} ITEMS`;
|
||
elements.expertPreviewState.className = `expert-preview-state ${preview.status}`;
|
||
if (preview.status === "loading") elements.expertPreviewState.textContent = "추천 종목을 PLAYINDEX 순서로 조회하고 있습니다.";
|
||
else if (preview.status === "error") elements.expertPreviewState.textContent = preview.error || "추천 종목을 불러오지 못했습니다.";
|
||
else if (preview.status === "ready") elements.expertPreviewState.textContent = `${preview.totalRowCount.toLocaleString("ko-KR")}개 추천${preview.truncated ? " · 100개 상한 적용" : ""}`;
|
||
else elements.expertPreviewState.textContent = "전문가를 선택하면 추천 종목을 확인합니다.";
|
||
elements.expertPreviewItems.replaceChildren();
|
||
if (!preview.items.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "comparison-target-empty";
|
||
empty.textContent = preview.status === "ready" ? "추천 종목이 없습니다." : "미리보기 대기 중";
|
||
elements.expertPreviewItems.append(empty);
|
||
} else {
|
||
for (const item of preview.items) {
|
||
const row = document.createElement("div");
|
||
row.className = "expert-preview-item";
|
||
const index = document.createElement("span");
|
||
const name = document.createElement("strong");
|
||
const amount = document.createElement("small");
|
||
index.textContent = String(item.playIndex).padStart(2, "0");
|
||
name.textContent = `${item.stockName} · ${item.stockCode}`;
|
||
amount.textContent = `${item.buyAmount.toLocaleString("ko-KR")}원`;
|
||
row.append(index, name, amount);
|
||
elements.expertPreviewItems.append(row);
|
||
}
|
||
}
|
||
|
||
elements.expertActionList.replaceChildren();
|
||
const action = expertWorkflow.actions[0];
|
||
const page = expert.selectable ? expertWorkflow.calculatePagePreview(expert.selectable) : null;
|
||
const allowed = Boolean(expert.selectable && preview.status === "ready" && preview.totalRowCount > 0);
|
||
const row = document.createElement("div");
|
||
row.className = "expert-action-row";
|
||
row.classList.toggle("disabled", !allowed);
|
||
const number = document.createElement("span");
|
||
number.textContent = "01";
|
||
const copy = document.createElement("span");
|
||
copy.className = "expert-action-copy";
|
||
const label = document.createElement("strong");
|
||
const detail = document.createElement("small");
|
||
label.textContent = action.label;
|
||
detail.textContent = allowed
|
||
? `${action.code} · 5개 × ${page?.pageCount ?? 0}페이지${page?.isTruncated ? " · 상한" : ""}`
|
||
: "전문가와 추천 종목을 먼저 확인하세요.";
|
||
copy.append(label, detail);
|
||
const add = document.createElement("button");
|
||
add.type = "button";
|
||
add.className = "expert-action-add";
|
||
add.textContent = "+";
|
||
add.disabled = locked || !allowed;
|
||
add.addEventListener("click", addExpertAction);
|
||
row.addEventListener("dblclick", event => {
|
||
if (!event.target.closest("button") && allowed) addExpertAction();
|
||
});
|
||
row.append(number, copy, add);
|
||
elements.expertActionList.append(row);
|
||
}
|
||
|
||
function requestExpertSearch(event) {
|
||
event?.preventDefault();
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
const search = state.expert.search;
|
||
clearExpertTimeout("search");
|
||
clearExpertTimeout("preview");
|
||
let request;
|
||
try {
|
||
request = expertWorkflow.createExpertSearchRequest(
|
||
createId(),
|
||
String(elements.expertSearch.value || ""),
|
||
expertWorkflow.bridgeContract.maximumResults);
|
||
} catch {
|
||
search.status = "error";
|
||
search.error = "검색어는 64자 이하의 안전한 문자여야 합니다.";
|
||
search.results = [];
|
||
search.totalRowCount = 0;
|
||
renderExpertWorkflow();
|
||
return;
|
||
}
|
||
state.expert.selected = null;
|
||
state.expert.selectable = null;
|
||
Object.assign(state.expert.preview, {
|
||
request: null, response: null, status: "idle", items: [], totalRowCount: 0,
|
||
truncated: false, error: ""
|
||
});
|
||
Object.assign(search, {
|
||
request, status: "loading", results: [], totalRowCount: 0, truncated: false, error: ""
|
||
});
|
||
renderExpertWorkflow();
|
||
if (!nativeBridge) {
|
||
search.status = "error";
|
||
search.error = "브라우저 미리보기에서는 실제 전문가 DB 조회를 사용할 수 없습니다.";
|
||
renderExpertWorkflow();
|
||
return;
|
||
}
|
||
postNative(expertWorkflow.bridgeContract.searchRequestType, request);
|
||
search.timeoutId = setTimeout(() => {
|
||
if (search.request !== request || search.status !== "loading") return;
|
||
search.status = "error";
|
||
search.error = "전문가 검색 응답 시간이 초과되었습니다.";
|
||
renderExpertWorkflow();
|
||
}, 30000);
|
||
}
|
||
|
||
function handleExpertSearchResults(payload) {
|
||
const search = state.expert.search;
|
||
const request = search.request;
|
||
if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return;
|
||
clearExpertTimeout("search");
|
||
const response = expertWorkflow.normalizeExpertSearchResponse(payload, request);
|
||
if (!response) {
|
||
search.status = "error";
|
||
search.results = [];
|
||
search.totalRowCount = 0;
|
||
search.error = "전문가 응답 형식 또는 식별자가 올바르지 않습니다.";
|
||
} else {
|
||
search.status = "ready";
|
||
search.results = [...response.results];
|
||
search.totalRowCount = response.totalRowCount;
|
||
search.truncated = response.truncated;
|
||
search.error = "";
|
||
}
|
||
renderExpertWorkflow();
|
||
}
|
||
|
||
function handleExpertSearchError(payload) {
|
||
const search = state.expert.search;
|
||
const request = search.request;
|
||
if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return;
|
||
clearExpertTimeout("search");
|
||
const error = expertWorkflow.normalizeExpertSearchError(payload, request);
|
||
search.status = "error";
|
||
search.results = [];
|
||
search.totalRowCount = 0;
|
||
search.error = error?.message || "전문가 검색에 실패했습니다.";
|
||
renderExpertWorkflow();
|
||
}
|
||
|
||
function requestExpertPreview(selected) {
|
||
clearExpertTimeout("preview");
|
||
const preview = state.expert.preview;
|
||
let request;
|
||
try {
|
||
request = expertWorkflow.createExpertPreviewRequest(
|
||
createId(), selected, expertWorkflow.bridgeContract.maximumPreviewItems);
|
||
} catch {
|
||
preview.status = "error";
|
||
preview.error = "전문가 식별자가 올바르지 않습니다.";
|
||
renderExpertWorkflow();
|
||
return;
|
||
}
|
||
Object.assign(preview, {
|
||
request, response: null, status: "loading", items: [], totalRowCount: 0,
|
||
truncated: false, error: "", catalogContext: null
|
||
});
|
||
state.expert.selectable = null;
|
||
renderExpertWorkflow();
|
||
if (!nativeBridge) {
|
||
preview.status = "error";
|
||
preview.error = "브라우저 미리보기에서는 실제 추천 종목 조회를 사용할 수 없습니다.";
|
||
renderExpertWorkflow();
|
||
return;
|
||
}
|
||
postNative(expertWorkflow.bridgeContract.previewRequestType, request);
|
||
preview.timeoutId = setTimeout(() => {
|
||
if (preview.request !== request || preview.status !== "loading") return;
|
||
preview.status = "error";
|
||
preview.error = "추천 종목 미리보기 응답 시간이 초과되었습니다.";
|
||
renderExpertWorkflow();
|
||
}, 30000);
|
||
}
|
||
|
||
function handleExpertPreviewResults(payload) {
|
||
const preview = state.expert.preview;
|
||
const request = preview.request;
|
||
if (!request || payload?.requestId !== request.requestId || !state.expert.selected) return;
|
||
clearExpertTimeout("preview");
|
||
const response = expertWorkflow.normalizeExpertPreviewResponse(payload, request);
|
||
const selectable = response
|
||
? expertWorkflow.createSelectableExpert(state.expert.selected, response)
|
||
: null;
|
||
if (!response || !selectable) {
|
||
preview.status = "error";
|
||
preview.items = [];
|
||
preview.totalRowCount = 0;
|
||
preview.error = "추천 종목 응답 형식, 순서 또는 식별자가 올바르지 않습니다.";
|
||
state.expert.selectable = null;
|
||
} else {
|
||
preview.status = "ready";
|
||
preview.response = response;
|
||
preview.items = [...response.items];
|
||
preview.totalRowCount = response.totalRowCount;
|
||
preview.truncated = response.truncated;
|
||
preview.error = "";
|
||
state.expert.selectable = selectable;
|
||
try {
|
||
operatorCatalogWorkflow.createExpertEditDraft(state.expert.selected, response);
|
||
preview.catalogContext = Object.freeze({
|
||
selection: state.expert.selected,
|
||
preview: response
|
||
});
|
||
} catch {
|
||
preview.catalogContext = null;
|
||
}
|
||
}
|
||
renderExpertWorkflow();
|
||
}
|
||
|
||
function handleExpertPreviewError(payload) {
|
||
const preview = state.expert.preview;
|
||
const request = preview.request;
|
||
if (!request || payload?.requestId !== request.requestId) return;
|
||
clearExpertTimeout("preview");
|
||
const error = expertWorkflow.normalizeExpertPreviewError(payload, request);
|
||
preview.status = "error";
|
||
preview.items = [];
|
||
preview.totalRowCount = 0;
|
||
preview.catalogContext = null;
|
||
preview.error = error?.message || "추천 종목을 불러오지 못했습니다.";
|
||
state.expert.selectable = null;
|
||
renderExpertWorkflow();
|
||
}
|
||
|
||
function synchronizeExpertEntries() {
|
||
let invalid = false;
|
||
state.playlist = state.playlist.map(item => {
|
||
if (item?.operator?.source !== "legacy-expert-workflow") return item;
|
||
const refreshed = expertWorkflow.refreshExpertPlaylistEntry(item);
|
||
if (!refreshed) {
|
||
invalid = true;
|
||
return item;
|
||
}
|
||
const definition = catalog.find(value =>
|
||
value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
|
||
if (!definition) {
|
||
invalid = true;
|
||
return item;
|
||
}
|
||
return { ...definition, ...refreshed };
|
||
});
|
||
if (invalid) {
|
||
showToast("저장된 전문가 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function clearTradingHaltTimeout() {
|
||
if (state.tradingHalt.timeoutId !== null) clearTimeout(state.tradingHalt.timeoutId);
|
||
state.tradingHalt.timeoutId = null;
|
||
}
|
||
|
||
function tradingHaltIdentity(value) {
|
||
if (!value) return "";
|
||
return [value.market, value.stockCode, value.displayName].join("|");
|
||
}
|
||
|
||
function selectTradingHalt(value) {
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
const selected = tradingHaltWorkflow.normalizeTradingHalt(value);
|
||
if (!selected) return;
|
||
state.tradingHalt.selected = selected;
|
||
renderTradingHaltWorkflow();
|
||
addLog(`거래 정지 종목 선택 · ${selected.displayName} · ${selected.stockCode}`);
|
||
}
|
||
|
||
function addTradingHaltAction(actionId) {
|
||
if (!requirePlaylistEditable()) return;
|
||
const selected = state.tradingHalt.selected;
|
||
if (!selected) {
|
||
showToast("먼저 거래 정지 종목을 선택하세요.");
|
||
elements.tradingHaltSearch.focus();
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const created = tradingHaltWorkflow.createTradingHaltPlaylistEntry(selected, actionId, {
|
||
id: createId(),
|
||
fadeDuration: DEFAULT_FADE_DURATION,
|
||
ma5: state.candleOptions.ma5,
|
||
ma20: state.candleOptions.ma20
|
||
});
|
||
const definition = catalog.find(item =>
|
||
item.builderKey === created.builderKey && sceneAliases(item).includes(created.code));
|
||
if (!definition) throw new Error("The trading-halt action does not resolve to a scene alias.");
|
||
const entry = { ...definition, ...created };
|
||
state.playlist.push(entry);
|
||
state.selectedRows.clear();
|
||
state.selectedRows.add(entry.id);
|
||
state.currentIndex = state.playlist.length - 1;
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`${entry.code} ${entry.title} · ${entry.detail} 추가`);
|
||
showToast("거래 정지 컷을 플레이리스트에 추가했습니다.");
|
||
} catch {
|
||
showToast("선택한 거래 정지 종목과 컷을 안전하게 구성하지 못했습니다.");
|
||
}
|
||
}
|
||
|
||
function renderTradingHaltWorkflow() {
|
||
renderGlobalCandleOptions();
|
||
const visible = state.activeMarket === "halted";
|
||
elements.tradingHaltWorkflow.hidden = !visible;
|
||
if (!visible) return;
|
||
|
||
const workflow = state.tradingHalt;
|
||
const locked = isPlaylistSnapshotLocked();
|
||
elements.tradingHaltCount.textContent = workflow.status === "loading"
|
||
? "LOADING"
|
||
: `${workflow.totalRowCount.toLocaleString("ko-KR")} HALTED`;
|
||
elements.tradingHaltSearch.disabled = locked;
|
||
elements.tradingHaltSearchButton.disabled = locked || workflow.status === "loading";
|
||
elements.tradingHaltSearchState.className = `trading-halt-search-state ${workflow.status}`;
|
||
if (workflow.status === "loading") {
|
||
elements.tradingHaltSearchState.textContent = "Oracle의 KOSPI·KOSDAQ 거래 정지 종목을 조회하고 있습니다.";
|
||
} else if (workflow.status === "error") {
|
||
elements.tradingHaltSearchState.textContent = workflow.error || "거래 정지 종목 조회에 실패했습니다.";
|
||
} else if (workflow.status === "ready") {
|
||
elements.tradingHaltSearchState.textContent = `${workflow.totalRowCount.toLocaleString("ko-KR")}개 종목${workflow.truncated ? " · 결과 제한 적용" : ""}`;
|
||
} else {
|
||
elements.tradingHaltSearchState.textContent = "조회 버튼을 누르면 현재 거래 정지 종목을 불러옵니다.";
|
||
}
|
||
|
||
elements.tradingHaltResults.replaceChildren();
|
||
if (!workflow.results.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "comparison-target-empty";
|
||
empty.textContent = workflow.status === "ready"
|
||
? "일치하는 거래 정지 종목이 없습니다."
|
||
: "거래 정지 종목 검색 결과가 여기에 표시됩니다.";
|
||
elements.tradingHaltResults.append(empty);
|
||
} else {
|
||
const selectedIdentity = tradingHaltIdentity(workflow.selected);
|
||
for (const item of workflow.results) {
|
||
const row = document.createElement("button");
|
||
row.type = "button";
|
||
row.className = "trading-halt-result";
|
||
row.disabled = locked;
|
||
const selected = tradingHaltIdentity(item) === selectedIdentity;
|
||
row.classList.toggle("selected", selected);
|
||
row.setAttribute("role", "option");
|
||
row.setAttribute("aria-selected", selected ? "true" : "false");
|
||
const copy = document.createElement("span");
|
||
const name = document.createElement("strong");
|
||
const code = document.createElement("small");
|
||
name.textContent = item.displayName;
|
||
code.textContent = item.stockCode;
|
||
copy.append(name, code);
|
||
const market = document.createElement("span");
|
||
market.className = "trading-halt-market";
|
||
market.textContent = item.market.toLocaleUpperCase("en-US");
|
||
row.append(copy, market);
|
||
row.addEventListener("click", () => selectTradingHalt(item));
|
||
elements.tradingHaltResults.append(row);
|
||
}
|
||
}
|
||
|
||
elements.selectedTradingHalt.hidden = !workflow.selected;
|
||
elements.tradingHaltMa5.disabled = locked || !workflow.selected;
|
||
elements.tradingHaltMa20.disabled = locked || !workflow.selected;
|
||
if (workflow.selected) {
|
||
elements.selectedTradingHaltName.textContent = workflow.selected.displayName;
|
||
elements.selectedTradingHaltMeta.textContent = `${workflow.selected.stockCode} · ${workflow.selected.market.toLocaleUpperCase("en-US")}`;
|
||
}
|
||
|
||
elements.tradingHaltActionList.replaceChildren();
|
||
tradingHaltWorkflow.actions.forEach((action, index) => {
|
||
const row = document.createElement("div");
|
||
row.className = "trading-halt-action-row";
|
||
row.classList.toggle("disabled", !workflow.selected);
|
||
const number = document.createElement("span");
|
||
number.textContent = String(index + 1).padStart(2, "0");
|
||
const copy = document.createElement("span");
|
||
copy.className = "trading-halt-action-copy";
|
||
const label = document.createElement("strong");
|
||
const detail = document.createElement("small");
|
||
label.textContent = action.label;
|
||
detail.textContent = action.kind === "candle"
|
||
? `${action.code} · 120일 · MA5/MA20 선택`
|
||
: `${action.code} · 1열판 현재가`;
|
||
copy.append(label, detail);
|
||
const add = document.createElement("button");
|
||
add.type = "button";
|
||
add.className = "trading-halt-action-add";
|
||
add.textContent = "+";
|
||
add.disabled = locked || !workflow.selected;
|
||
add.title = workflow.selected ? "플레이리스트에 추가" : "거래 정지 종목을 먼저 선택하세요.";
|
||
add.addEventListener("click", () => addTradingHaltAction(action.id));
|
||
row.addEventListener("dblclick", event => {
|
||
if (!event.target.closest("button") && workflow.selected) addTradingHaltAction(action.id);
|
||
});
|
||
row.append(number, copy, add);
|
||
elements.tradingHaltActionList.append(row);
|
||
});
|
||
}
|
||
|
||
function requestTradingHaltSearch(event) {
|
||
event?.preventDefault();
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
clearTradingHaltTimeout();
|
||
const workflow = state.tradingHalt;
|
||
let request;
|
||
try {
|
||
request = tradingHaltWorkflow.createTradingHaltSearchRequest(
|
||
createId(),
|
||
String(elements.tradingHaltSearch.value || ""),
|
||
tradingHaltWorkflow.bridgeContract.maximumResults);
|
||
} catch {
|
||
workflow.status = "error";
|
||
workflow.error = "검색어는 64자 이하의 안전한 문자여야 합니다.";
|
||
workflow.results = [];
|
||
workflow.totalRowCount = 0;
|
||
renderTradingHaltWorkflow();
|
||
return;
|
||
}
|
||
|
||
workflow.request = request;
|
||
workflow.status = "loading";
|
||
workflow.results = [];
|
||
workflow.selected = null;
|
||
workflow.totalRowCount = 0;
|
||
workflow.truncated = false;
|
||
workflow.error = "";
|
||
renderTradingHaltWorkflow();
|
||
if (!nativeBridge) {
|
||
workflow.status = "error";
|
||
workflow.error = "브라우저 미리보기에서는 실제 거래 정지 DB 조회를 사용할 수 없습니다.";
|
||
renderTradingHaltWorkflow();
|
||
return;
|
||
}
|
||
postNative(tradingHaltWorkflow.bridgeContract.requestType, request);
|
||
workflow.timeoutId = setTimeout(() => {
|
||
if (workflow.request !== request || workflow.status !== "loading") return;
|
||
workflow.status = "error";
|
||
workflow.error = "거래 정지 종목 조회 응답 시간이 초과되었습니다.";
|
||
renderTradingHaltWorkflow();
|
||
}, 30000);
|
||
}
|
||
|
||
function handleTradingHaltResults(payload) {
|
||
const workflow = state.tradingHalt;
|
||
const request = workflow.request;
|
||
if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return;
|
||
clearTradingHaltTimeout();
|
||
const response = tradingHaltWorkflow.normalizeTradingHaltSearchResponse(payload, request);
|
||
if (!response) {
|
||
workflow.status = "error";
|
||
workflow.results = [];
|
||
workflow.totalRowCount = 0;
|
||
workflow.error = "거래 정지 종목 응답 형식 또는 식별자가 올바르지 않습니다.";
|
||
} else {
|
||
workflow.status = "ready";
|
||
workflow.results = [...response.results];
|
||
workflow.totalRowCount = response.totalRowCount;
|
||
workflow.truncated = response.truncated;
|
||
workflow.error = "";
|
||
}
|
||
renderTradingHaltWorkflow();
|
||
}
|
||
|
||
function handleTradingHaltError(payload) {
|
||
const workflow = state.tradingHalt;
|
||
const request = workflow.request;
|
||
if (!request || payload?.requestId !== request.requestId || payload?.query !== request.query) return;
|
||
clearTradingHaltTimeout();
|
||
const error = tradingHaltWorkflow.normalizeTradingHaltError(payload, request);
|
||
workflow.status = "error";
|
||
workflow.results = [];
|
||
workflow.totalRowCount = 0;
|
||
workflow.error = error?.message || "거래 정지 종목 조회에 실패했습니다.";
|
||
renderTradingHaltWorkflow();
|
||
}
|
||
|
||
function synchronizeTradingHaltEntries() {
|
||
let invalid = false;
|
||
state.playlist = state.playlist.map(item => {
|
||
if (item?.operator?.source !== "legacy-trading-halt-workflow") return item;
|
||
const refreshed = tradingHaltWorkflow.refreshTradingHaltPlaylistEntry(item);
|
||
if (!refreshed) {
|
||
invalid = true;
|
||
return item;
|
||
}
|
||
const definition = catalog.find(value =>
|
||
value.builderKey === refreshed.builderKey && sceneAliases(value).includes(refreshed.code));
|
||
if (!definition) {
|
||
invalid = true;
|
||
return item;
|
||
}
|
||
return { ...definition, ...refreshed };
|
||
});
|
||
if (invalid) {
|
||
showToast("저장된 거래 정지 항목의 신뢰 매핑이 손상되어 PREPARE를 중단했습니다.");
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function clearStockSearchTimeout() {
|
||
if (state.stockSearch.timeoutId !== null) clearTimeout(state.stockSearch.timeoutId);
|
||
state.stockSearch.timeoutId = null;
|
||
}
|
||
|
||
function stockIdentity(stock) {
|
||
if (!stock) return "";
|
||
return [stock.market, stock.isNxt === true ? "nxt" : "krx", stock.stockCode, stock.stockName].join("|");
|
||
}
|
||
|
||
function selectStock(stock) {
|
||
state.stockSearch.selected = stock;
|
||
renderStockWorkflow();
|
||
addLog(`종목 선택 · ${stock.displayName} · ${stock.stockCode}`);
|
||
}
|
||
|
||
function addStockCut(cutId) {
|
||
if (!requirePlaylistEditable()) return;
|
||
const stock = state.stockSearch.selected;
|
||
if (!stock) {
|
||
showToast("먼저 종목을 검색하고 결과에서 한 종목을 선택하세요.");
|
||
elements.stockSearchInput.focus();
|
||
return;
|
||
}
|
||
|
||
if (!operatorWorkflow.isStockCutAllowed(stock, cutId)) {
|
||
showToast(stock.isNxt ? "NXT 종목은 1열판기본_현재가만 지원합니다." : "이 종목과 컷 조합은 지원하지 않습니다.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const created = operatorWorkflow.createStockPlaylistEntry(stock, cutId, {
|
||
id: createId(),
|
||
fadeDuration: DEFAULT_FADE_DURATION,
|
||
ma5: state.candleOptions.ma5,
|
||
ma20: state.candleOptions.ma20
|
||
});
|
||
const definition = catalog.find(item => item.builderKey === created.builderKey);
|
||
if (!definition || !sceneAliases(definition).includes(String(created.code || ""))) {
|
||
throw new Error("The operator cut does not resolve to a registered scene alias.");
|
||
}
|
||
const entry = {
|
||
...definition,
|
||
...created,
|
||
enabled: true
|
||
};
|
||
state.playlist.push(entry);
|
||
state.selectedRows.clear();
|
||
state.selectedRows.add(entry.id);
|
||
state.currentIndex = state.playlist.length - 1;
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`${entry.code} ${stock.displayName} · ${entry.operator?.legacyLabel || entry.title} 추가`);
|
||
showToast(`${stock.displayName} 컷을 플레이리스트에 추가했습니다.`);
|
||
} catch {
|
||
showToast("선택한 종목과 컷을 안전한 장면으로 구성하지 못했습니다.");
|
||
}
|
||
}
|
||
|
||
function renderGlobalCandleOptions() {
|
||
const { ma5, ma20 } = state.candleOptions;
|
||
for (const element of [elements.globalMa5, elements.stockMa5, elements.overseasMa5, elements.tradingHaltMa5]) {
|
||
if (element) element.checked = ma5;
|
||
}
|
||
for (const element of [elements.globalMa20, elements.stockMa20, elements.overseasMa20, elements.tradingHaltMa20]) {
|
||
if (element) element.checked = ma20;
|
||
}
|
||
const locked = isPlaylistSnapshotLocked();
|
||
elements.globalMa5.disabled = locked;
|
||
elements.globalMa20.disabled = locked;
|
||
}
|
||
|
||
function synchronizeGlobalCandleOptions(render = false) {
|
||
if (isPlaylistSnapshotLocked()) {
|
||
renderGlobalCandleOptions();
|
||
return false;
|
||
}
|
||
const { ma5, ma20 } = state.candleOptions;
|
||
const result = candleOptionsWorkflow.synchronizePlaylist(
|
||
state.playlist,
|
||
ma5,
|
||
ma20,
|
||
{
|
||
stock: operatorWorkflow,
|
||
fixed: fixedWorkflow,
|
||
industry: industryWorkflow,
|
||
overseas: overseasWorkflow,
|
||
tradingHalt: tradingHaltWorkflow
|
||
},
|
||
new Date());
|
||
state.candleOptions.invalidIds = [...result.invalidIds];
|
||
state.playlist = [...result.playlist].map(item => {
|
||
if (item?.builderKey !== candleOptionsWorkflow.candleBuilderKey) return item;
|
||
const definition = catalog.find(value =>
|
||
value.builderKey === item.builderKey && sceneAliases(value).includes(String(item.code || "")));
|
||
return definition ? { ...definition, ...item, enabled: item.enabled !== false } : item;
|
||
});
|
||
renderGlobalCandleOptions();
|
||
|
||
if (render && (result.changed || !result.valid)) {
|
||
renderPlaylist();
|
||
updatePreview();
|
||
}
|
||
if (!result.valid) {
|
||
if (render) {
|
||
addLog(`캔들 이동평균선 동기화 차단 · 신뢰 복원 실패 ${result.invalidIds.length}건`);
|
||
showToast("신뢰할 수 없는 캔들 항목이 있어 PREPARE를 차단했습니다. 해당 항목을 삭제하고 다시 추가하세요.");
|
||
}
|
||
return false;
|
||
}
|
||
if (result.changed && render) {
|
||
addLog(`전체 캔들 이동평균선 적용 · 5일선 ${ma5 ? "표시" : "숨김"} · 20일선 ${ma20 ? "표시" : "숨김"}`);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function applyGlobalCandleOptions(ma5, ma20, render = true) {
|
||
if (isPlaylistSnapshotLocked()) {
|
||
renderGlobalCandleOptions();
|
||
return false;
|
||
}
|
||
try {
|
||
const normalized = candleOptionsWorkflow.normalizeOptions(Boolean(ma5), Boolean(ma20));
|
||
state.candleOptions.ma5 = normalized.ma5;
|
||
state.candleOptions.ma20 = normalized.ma20;
|
||
return synchronizeGlobalCandleOptions(render);
|
||
} catch {
|
||
renderGlobalCandleOptions();
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function synchronizeStockCandleOptions(render = false) {
|
||
return applyGlobalCandleOptions(elements.stockMa5.checked, elements.stockMa20.checked, render);
|
||
}
|
||
|
||
function renderStockWorkflow() {
|
||
renderGlobalCandleOptions();
|
||
const visible = ["dashboard", "kospi", "kosdaq"].includes(state.activeMarket);
|
||
elements.stockWorkflow.hidden = !visible;
|
||
if (!visible) return;
|
||
elements.stockWorkflow.classList.toggle("collapsed", state.stockWorkflowCollapsed);
|
||
elements.stockWorkflowToggle.textContent = state.stockWorkflowCollapsed ? "펼치기" : "접기";
|
||
elements.stockWorkflowToggle.setAttribute("aria-expanded", String(!state.stockWorkflowCollapsed));
|
||
|
||
const search = state.stockSearch;
|
||
const playlistLocked = isPlaylistSnapshotLocked();
|
||
elements.stockMa5.disabled = playlistLocked;
|
||
elements.stockMa20.disabled = playlistLocked;
|
||
elements.stockSearchButton.disabled = search.status === "loading";
|
||
elements.stockSearchBadge.textContent = search.status === "loading" ? "SEARCHING" : "DB SEARCH";
|
||
elements.stockSearchState.className = `stock-search-state ${search.status}`;
|
||
if (search.status === "loading") {
|
||
elements.stockSearchState.textContent = `“${search.query}” 종목을 Oracle/MariaDB에서 조회하고 있습니다.`;
|
||
} else if (search.status === "error") {
|
||
elements.stockSearchState.textContent = search.error || "종목 검색에 실패했습니다.";
|
||
} else if (search.status === "ready") {
|
||
const suffix = search.truncated ? " · 결과 제한 적용" : "";
|
||
elements.stockSearchState.textContent = `${search.totalRowCount.toLocaleString("ko-KR")}개 종목 검색 완료${suffix}`;
|
||
} else {
|
||
elements.stockSearchState.textContent = "종목명을 입력하고 Enter를 누르세요.";
|
||
}
|
||
|
||
elements.stockSearchResults.replaceChildren();
|
||
if (!search.results.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "stock-results-empty";
|
||
empty.textContent = search.status === "ready" ? "일치하는 종목이 없습니다." : "검색 결과가 여기에 표시됩니다.";
|
||
elements.stockSearchResults.append(empty);
|
||
} else {
|
||
const selectedIdentity = stockIdentity(search.selected);
|
||
for (const stock of search.results) {
|
||
const row = document.createElement("button");
|
||
row.className = "stock-result";
|
||
row.type = "button";
|
||
row.setAttribute("role", "option");
|
||
const selected = stockIdentity(stock) === selectedIdentity;
|
||
row.classList.toggle("selected", selected);
|
||
row.setAttribute("aria-selected", selected ? "true" : "false");
|
||
|
||
const copy = document.createElement("span");
|
||
const name = document.createElement("strong");
|
||
const meta = document.createElement("small");
|
||
name.textContent = stock.displayName;
|
||
meta.textContent = `${stock.stockCode} · ${stock.sourceLabel || stock.source || "DB"}`;
|
||
copy.append(name, meta);
|
||
const market = document.createElement("span");
|
||
market.className = "stock-market-badge";
|
||
market.textContent = stock.marketLabel || `${stock.market}${stock.isNxt ? " NXT" : ""}`;
|
||
row.append(copy, market);
|
||
row.addEventListener("click", () => selectStock(stock));
|
||
elements.stockSearchResults.append(row);
|
||
}
|
||
}
|
||
|
||
elements.selectedStock.hidden = !search.selected;
|
||
if (search.selected) {
|
||
elements.selectedStockName.textContent = search.selected.displayName;
|
||
elements.selectedStockMeta.textContent = `${search.selected.marketLabel || search.selected.market} · ${search.selected.stockCode}`;
|
||
}
|
||
|
||
elements.stockCutList.replaceChildren();
|
||
let previousSection = null;
|
||
operatorWorkflow.stockCuts.forEach((cut, index) => {
|
||
if (cut.section !== previousSection) {
|
||
const section = document.createElement("div");
|
||
section.className = "stock-cut-section";
|
||
section.textContent = cut.section;
|
||
elements.stockCutList.append(section);
|
||
previousSection = cut.section;
|
||
}
|
||
const allowed = Boolean(search.selected) && operatorWorkflow.isStockCutAllowed(search.selected, cut.id);
|
||
const row = document.createElement("div");
|
||
row.className = "stock-cut-row";
|
||
row.classList.toggle("disabled", !allowed);
|
||
const number = document.createElement("span");
|
||
number.className = "stock-cut-number";
|
||
number.textContent = String(index + 1).padStart(2, "0");
|
||
const copy = document.createElement("span");
|
||
copy.className = "stock-cut-copy";
|
||
const label = document.createElement("strong");
|
||
const detail = document.createElement("small");
|
||
label.textContent = cut.label;
|
||
detail.textContent = `${cut.builderKey} · ${cut.detail || "원본 종목 컷"}`;
|
||
copy.append(label, detail);
|
||
const add = document.createElement("button");
|
||
add.className = "stock-cut-add";
|
||
add.type = "button";
|
||
add.textContent = "+";
|
||
add.disabled = !allowed || isPlaylistSnapshotLocked();
|
||
add.title = !search.selected
|
||
? "먼저 종목을 선택하세요."
|
||
: (!allowed ? "이 종목 시장에서는 지원하지 않는 컷입니다." : "플레이리스트에 추가");
|
||
add.addEventListener("click", () => addStockCut(cut.id));
|
||
row.addEventListener("dblclick", () => { if (allowed) addStockCut(cut.id); });
|
||
row.append(number, copy, add);
|
||
elements.stockCutList.append(row);
|
||
});
|
||
|
||
elements.manualStockActions.replaceChildren();
|
||
for (const action of operatorWorkflow.manualStockActions) {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.textContent = action.label;
|
||
button.disabled = !search.selected || operatorUiLocked();
|
||
button.title = !search.selected
|
||
? "먼저 종목을 검색하고 선택하세요."
|
||
: `${action.prerequisiteTable} 원본 수동 입력 화면 열기`;
|
||
button.addEventListener("click", () => manualFinancialUi?.open(action));
|
||
elements.manualStockActions.append(button);
|
||
}
|
||
}
|
||
|
||
function requestStockSearch(event) {
|
||
event?.preventDefault();
|
||
clearStockSearchTimeout();
|
||
const query = String(elements.stockSearchInput.value || "").trim();
|
||
if (!query || query.length > 64 || /[\u0000-\u001f\u007f]/.test(query)) {
|
||
state.stockSearch.status = "error";
|
||
state.stockSearch.error = "1~64자의 종목명을 입력하세요.";
|
||
state.stockSearch.results = [];
|
||
state.stockSearch.selected = null;
|
||
renderStockWorkflow();
|
||
return;
|
||
}
|
||
if (!nativeBridge) {
|
||
state.stockSearch.status = "error";
|
||
state.stockSearch.error = "브라우저 미리보기에서는 실제 종목 DB 검색을 사용할 수 없습니다.";
|
||
renderStockWorkflow();
|
||
return;
|
||
}
|
||
|
||
const requestId = createId();
|
||
state.stockSearch.requestId = requestId;
|
||
state.stockSearch.query = query;
|
||
state.stockSearch.status = "loading";
|
||
state.stockSearch.results = [];
|
||
state.stockSearch.selected = null;
|
||
state.stockSearch.totalRowCount = 0;
|
||
state.stockSearch.truncated = false;
|
||
state.stockSearch.error = "";
|
||
renderStockWorkflow();
|
||
postNative("search-stocks", { requestId, query, maximumResults: 200 });
|
||
state.stockSearch.timeoutId = setTimeout(() => {
|
||
if (state.stockSearch.requestId !== requestId || state.stockSearch.status !== "loading") return;
|
||
state.stockSearch.status = "error";
|
||
state.stockSearch.error = "종목 검색 응답 시간이 초과되었습니다. DB 상태를 확인하고 다시 검색하세요.";
|
||
renderStockWorkflow();
|
||
addLog(`종목 검색 시간 초과 · ${query}`);
|
||
}, 30000);
|
||
}
|
||
|
||
function handleStockSearchResults(payload) {
|
||
if (!payload || payload.requestId !== state.stockSearch.requestId || payload.query !== state.stockSearch.query) return;
|
||
clearStockSearchTimeout();
|
||
const results = Array.isArray(payload.results) ? payload.results : [];
|
||
state.stockSearch.results = results.slice(0, 500).flatMap(value => {
|
||
try {
|
||
const normalized = operatorWorkflow.normalizeStockResult(value);
|
||
return normalized ? [normalized] : [];
|
||
} catch { return []; }
|
||
});
|
||
state.stockSearch.status = "ready";
|
||
state.stockSearch.totalRowCount = Number.isInteger(payload.totalRowCount)
|
||
? Math.max(0, payload.totalRowCount)
|
||
: state.stockSearch.results.length;
|
||
state.stockSearch.truncated = payload.truncated === true;
|
||
state.stockSearch.error = "";
|
||
renderStockWorkflow();
|
||
addLog(`종목 검색 완료 · ${state.stockSearch.query} · ${state.stockSearch.results.length}건 표시`);
|
||
}
|
||
|
||
function handleStockSearchError(payload) {
|
||
if (!payload || payload.requestId !== state.stockSearch.requestId || payload.query !== state.stockSearch.query) return;
|
||
clearStockSearchTimeout();
|
||
state.stockSearch.status = "error";
|
||
state.stockSearch.results = [];
|
||
state.stockSearch.selected = null;
|
||
state.stockSearch.error = typeof payload.message === "string" && payload.message.trim()
|
||
? payload.message.trim()
|
||
: "종목 검색에 실패했습니다.";
|
||
renderStockWorkflow();
|
||
addLog(`종목 검색 실패 · ${state.stockSearch.query}`);
|
||
}
|
||
|
||
function addToPlaylist(item) {
|
||
if (item.reachable === false || !requirePlaylistEditable()) return;
|
||
if (operatorInputBuilderKeys.has(item.builderKey)) {
|
||
if (["s5026", "s5029", "s5087"].includes(item.builderKey)) {
|
||
showToast("비교 장면은 두 종목을 확정하는 전용 입력 화면에서 추가해야 합니다.");
|
||
} else if (["s5074", "s5077", "s5088"].includes(item.builderKey)) {
|
||
showToast("5·6·12행 페이지 장면은 지수·업종·테마 등 원본 전용 입력 화면에서 추가해야 합니다.");
|
||
} else if (["s5076", "s5079", "s5080", "s5081"].includes(item.builderKey)) {
|
||
showToast("이 장면은 원본 수동 재무 데이터 화면의 저장 항목에서 추가해야 합니다.");
|
||
} else {
|
||
showToast("종목 검색 결과와 종목용 컷을 선택해 추가하세요.");
|
||
elements.stockSearchInput.focus();
|
||
}
|
||
return;
|
||
}
|
||
const selection = defaultSceneSelection(item.builderKey);
|
||
const entry = {
|
||
...item,
|
||
id: createId(),
|
||
selection,
|
||
enabled: true,
|
||
fadeDuration: DEFAULT_FADE_DURATION,
|
||
graphicType: selection?.graphicType || "",
|
||
subtype: selection?.subtype || ""
|
||
};
|
||
state.playlist.push(entry);
|
||
state.selectedRows.clear();
|
||
state.selectedRows.add(entry.id);
|
||
state.currentIndex = state.playlist.length - 1;
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`${item.code} ${item.title} 항목 추가`);
|
||
showToast("플레이리스트에 추가했습니다.");
|
||
}
|
||
|
||
function renderPlaylist() {
|
||
renderGlobalCandleOptions();
|
||
elements.playlistBody.replaceChildren();
|
||
elements.playlistCount.textContent = `${state.playlist.length} CUTS`;
|
||
elements.playlistEmpty.classList.toggle("show", state.playlist.length === 0);
|
||
const allSelected = state.playlist.length > 0 &&
|
||
state.playlist.every(item => state.selectedRows.has(item.id));
|
||
const allEnabled = state.playlist.length > 0 &&
|
||
state.playlist.every(item => item.enabled !== false);
|
||
elements.selectAll.textContent = allSelected ? "작업 선택해제" : "작업 전체선택";
|
||
elements.toggleAllEnabled.textContent = allEnabled ? "전체 송출 해제" : "전체 송출";
|
||
|
||
state.playlist.forEach((item, index) => {
|
||
const row = document.createElement("tr");
|
||
const namedGuard = state.namedPlaylist.guardByEntryId.get(item.id);
|
||
const manualFinancialRestore = state.manualFinancialRestore.entries.get(item.id);
|
||
row.draggable = !isPlaylistSnapshotLocked();
|
||
row.dataset.index = String(index);
|
||
row.classList.toggle("active", index === state.currentIndex);
|
||
row.classList.toggle("selected", state.selectedRows.has(item.id));
|
||
row.classList.toggle("named-restore-invalid", namedGuard?.trusted === false);
|
||
row.classList.toggle("named-page-pending", namedGuard?.trusted === true &&
|
||
namedGuard.pageRecalculationRequired && !namedGuard.pageRecalculated);
|
||
row.classList.toggle("named-restore-invalid", manualFinancialRestore?.status === "error");
|
||
row.classList.toggle("named-page-pending", Boolean(manualFinancialRestore) &&
|
||
manualFinancialRestore.status !== "error");
|
||
row.setAttribute("aria-selected", state.selectedRows.has(item.id) ? "true" : "false");
|
||
|
||
const checkCell = document.createElement("td");
|
||
checkCell.className = "check-cell";
|
||
const checkbox = document.createElement("input");
|
||
checkbox.type = "checkbox";
|
||
checkbox.checked = item.enabled !== false;
|
||
checkbox.disabled = isPlaylistSnapshotLocked();
|
||
checkbox.setAttribute("aria-label", `${item.title} 송출 포함`);
|
||
checkbox.addEventListener("click", event => event.stopPropagation());
|
||
checkbox.addEventListener("change", event => {
|
||
event.stopPropagation();
|
||
if (!requirePlaylistEditable()) {
|
||
checkbox.checked = item.enabled !== false;
|
||
return;
|
||
}
|
||
item.enabled = checkbox.checked;
|
||
addLog(`${item.code} ${checkbox.checked ? "송출 포함" : "송출 제외"}`);
|
||
});
|
||
checkCell.append(checkbox);
|
||
|
||
const number = document.createElement("td");
|
||
number.className = "number-cell";
|
||
number.textContent = String(index + 1).padStart(2, "0");
|
||
|
||
const targetCell = document.createElement("td");
|
||
targetCell.className = "playlist-target-cell";
|
||
const target = document.createElement("strong");
|
||
const targetMeta = document.createElement("small");
|
||
target.textContent = item.selection?.subject || item.title;
|
||
targetMeta.textContent = [item.selection?.groupCode, item.selection?.dataCode]
|
||
.filter(Boolean).join(" · ") || item.detail;
|
||
targetCell.append(target, targetMeta);
|
||
|
||
const cutCell = document.createElement("td");
|
||
cutCell.className = "playlist-cut-cell";
|
||
const cutTitle = document.createElement("strong");
|
||
const cutMeta = document.createElement("small");
|
||
cutTitle.textContent = item.operator?.legacyLabel || item.title;
|
||
cutMeta.textContent = `${item.code} · ${item.builderKey}`;
|
||
cutCell.append(cutTitle, cutMeta);
|
||
|
||
const detailCell = document.createElement("td");
|
||
detailCell.className = "playlist-detail-cell";
|
||
const detailTitle = document.createElement("strong");
|
||
detailTitle.textContent = [item.selection?.graphicType, item.selection?.subtype]
|
||
.filter(Boolean).join(" · ") || item.detail;
|
||
const type = document.createElement("span");
|
||
type.className = "row-type";
|
||
let rowType = item.category.toUpperCase();
|
||
if (namedGuard?.pageRecalculationRequired && !namedGuard.pageRecalculated) rowType = "PAGE 재계산";
|
||
if (namedGuard?.recalculatedPagePlan?.isTruncated === true) rowType = "20 PAGE 제한";
|
||
if (namedGuard?.pageUnavailableReason === "empty-result") rowType = "DB 결과 없음";
|
||
if (namedGuard?.trusted === false) rowType = "복원 필요";
|
||
if (manualFinancialRestore) rowType = manualFinancialRestore.status === "error"
|
||
? "재무 복원 실패"
|
||
: "재무 DB 확인";
|
||
type.textContent = rowType;
|
||
const detailMeta = document.createElement("small");
|
||
detailMeta.append(type);
|
||
detailCell.append(detailTitle, detailMeta);
|
||
|
||
const page = document.createElement("td");
|
||
page.className = "page-cell";
|
||
const freshNamedPagePlan = namedGuard?.recalculatedPagePlan;
|
||
page.textContent = item.id === state.playout.currentEntryId && state.playout.pageCount > 0
|
||
? `${state.playout.pageIndex + 1}/${state.playout.pageCount}`
|
||
: (freshNamedPagePlan?.itemCount === 0
|
||
? "0건"
|
||
: (Number.isInteger(freshNamedPagePlan?.pageCount)
|
||
? `1/${freshNamedPagePlan.pageCount}`
|
||
: (item.pageText || "1/1")));
|
||
if (namedGuard?.rawRow) {
|
||
const historical = namedPlaylistWorkflow.pageStatusLabel(namedGuard.rawRow);
|
||
const current = freshNamedPagePlan
|
||
? `현재 DB ${freshNamedPagePlan.itemCount}건 · ${freshNamedPagePlan.pageCount}페이지` +
|
||
(freshNamedPagePlan.isTruncated ? ` · ${freshNamedPagePlan.accessibleItemCount}건까지만 송출` : "")
|
||
: "현재 DB 페이지 조회 전";
|
||
page.title = `${historical} / ${current}`;
|
||
}
|
||
|
||
const drag = document.createElement("td");
|
||
drag.className = "drag-cell";
|
||
drag.textContent = "⠿";
|
||
|
||
row.append(checkCell, number, targetCell, cutCell, detailCell, page, drag);
|
||
row.addEventListener("click", event => selectPlaylistRow(index, event));
|
||
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, event) {
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
const item = state.playlist[index];
|
||
if (!item) return;
|
||
if (event?.shiftKey) {
|
||
const anchor = state.selectionAnchorIndex >= 0 && state.selectionAnchorIndex < state.playlist.length
|
||
? state.selectionAnchorIndex
|
||
: (state.currentIndex >= 0 ? state.currentIndex : index);
|
||
if (!(event.ctrlKey || event.metaKey)) state.selectedRows.clear();
|
||
const from = Math.min(anchor, index);
|
||
const to = Math.max(anchor, index);
|
||
for (let value = from; value <= to; value++) state.selectedRows.add(state.playlist[value].id);
|
||
} else if (event?.ctrlKey || event?.metaKey) {
|
||
if (state.selectedRows.has(item.id)) state.selectedRows.delete(item.id);
|
||
else state.selectedRows.add(item.id);
|
||
} else {
|
||
state.selectedRows.clear();
|
||
state.selectedRows.add(item.id);
|
||
}
|
||
if (!event?.shiftKey) state.selectionAnchorIndex = index;
|
||
state.currentIndex = index;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
}
|
||
|
||
function updatePreview() {
|
||
const item = state.playlist[state.currentIndex];
|
||
if (!item) {
|
||
elements.previewTitle.textContent = "선택된 그래픽 없음";
|
||
elements.previewSubtitle.textContent = "플레이리스트 항목을 선택하세요.";
|
||
renderSceneSelectionForm(null);
|
||
renderPlayout();
|
||
return;
|
||
}
|
||
elements.previewTitle.textContent = item.title;
|
||
const selection = item.selection?.subject
|
||
? ` · ${item.selection.subject}${item.selection.dataCode ? ` (${item.selection.dataCode})` : ""}`
|
||
: "";
|
||
elements.previewSubtitle.textContent = `${item.code} · ${item.detail}${selection}`;
|
||
renderSceneSelectionForm(item);
|
||
renderPlayout();
|
||
}
|
||
|
||
function renderSceneSelectionForm(item) {
|
||
const selection = item?.selection || {};
|
||
const disabled = !item || isPlaylistSnapshotLocked() || Boolean(item.operator?.source);
|
||
elements.sceneGroupCode.value = String(selection.groupCode || "");
|
||
elements.sceneSubject.value = String(selection.subject || "");
|
||
elements.sceneGraphicType.value = String(selection.graphicType || "");
|
||
elements.sceneSubtype.value = String(selection.subtype || "");
|
||
elements.sceneDataCode.value = String(selection.dataCode || "");
|
||
elements.sceneCutAlias.replaceChildren();
|
||
for (const alias of item ? sceneAliases(item) : []) {
|
||
const option = document.createElement("option");
|
||
option.value = alias;
|
||
option.textContent = alias;
|
||
elements.sceneCutAlias.append(option);
|
||
}
|
||
elements.sceneCutAlias.value = String(item?.code || "");
|
||
elements.sceneFadeDuration.value = String(Number.isInteger(Number(item?.fadeDuration))
|
||
? Number(item.fadeDuration)
|
||
: DEFAULT_FADE_DURATION);
|
||
for (const input of [
|
||
elements.sceneGroupCode,
|
||
elements.sceneSubject,
|
||
elements.sceneGraphicType,
|
||
elements.sceneSubtype,
|
||
elements.sceneDataCode,
|
||
elements.sceneCutAlias,
|
||
elements.sceneFadeDuration
|
||
]) input.disabled = disabled;
|
||
elements.applySceneSelectionButton.disabled = disabled;
|
||
}
|
||
|
||
function applySceneSelection(event) {
|
||
event.preventDefault();
|
||
if (!requirePlaylistEditable()) return;
|
||
const item = state.playlist[state.currentIndex];
|
||
if (!item) return;
|
||
const values = [
|
||
elements.sceneGroupCode.value,
|
||
elements.sceneSubject.value,
|
||
elements.sceneGraphicType.value,
|
||
elements.sceneSubtype.value,
|
||
elements.sceneDataCode.value
|
||
];
|
||
if (values.some(value => value.length > 256 || /[\u0000-\u001f\u007f]/.test(value))) {
|
||
showToast("장면 조회값 형식이 올바르지 않습니다.");
|
||
return;
|
||
}
|
||
const fadeDuration = Number(elements.sceneFadeDuration.value);
|
||
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
|
||
showToast("FADE 값은 0~60 사이의 정수여야 합니다.");
|
||
return;
|
||
}
|
||
const cutAlias = String(elements.sceneCutAlias.value || "");
|
||
if (!sceneAliases(item).includes(cutAlias)) {
|
||
showToast("허용되지 않은 CUT alias입니다.");
|
||
return;
|
||
}
|
||
item.selection = {
|
||
groupCode: values[0].trim(),
|
||
subject: values[1].trim(),
|
||
graphicType: values[2].trim(),
|
||
subtype: values[3].trim(),
|
||
dataCode: values[4].trim()
|
||
};
|
||
item.code = cutAlias;
|
||
item.fadeDuration = fadeDuration;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`${item.code} 장면 조회값 적용`);
|
||
showToast("장면 조회값을 적용했습니다.");
|
||
}
|
||
|
||
function applyCutAliasPreset() {
|
||
const item = state.playlist[state.currentIndex];
|
||
if (!item) return;
|
||
const alias = String(elements.sceneCutAlias.value || "");
|
||
if (item.builderKey === "s5001") {
|
||
elements.sceneGroupCode.value = alias === "N5001" ? "NXT_KOSPI" : "KOSPI";
|
||
} else if (item.builderKey === "s8001") {
|
||
elements.sceneSubject.value = alias === "8002" ? "KOSDAQ" : "KOSPI";
|
||
}
|
||
}
|
||
|
||
function onDragStart(event) {
|
||
if (!requirePlaylistEditable()) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
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) {
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = "move";
|
||
event.currentTarget.classList.add("drag-over");
|
||
}
|
||
|
||
function onDrop(event) {
|
||
event.preventDefault();
|
||
if (!requirePlaylistEditable()) return;
|
||
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.selectionAnchorIndex = 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() {
|
||
if (!requirePlaylistEditable()) return;
|
||
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));
|
||
state.selectionAnchorIndex = !allSelected && state.playlist.length ? 0 : -1;
|
||
renderPlaylist();
|
||
}
|
||
|
||
function toggleAllEntriesEnabled() {
|
||
if (!requirePlaylistEditable()) return;
|
||
if (!state.playlist.length) return showToast("송출 여부를 바꿀 플레이리스트 항목이 없습니다.");
|
||
const enable = state.playlist.some(item => item.enabled === false);
|
||
state.playlist.forEach(item => { item.enabled = enable; });
|
||
renderPlaylist();
|
||
addLog(`플레이리스트 전체 ${enable ? "송출 포함" : "송출 제외"}`);
|
||
}
|
||
|
||
function moveSelected(direction) {
|
||
if (!requirePlaylistEditable()) return;
|
||
if (!state.selectedRows.size) return showToast("이동할 항목을 선택하세요.");
|
||
const currentId = state.playlist[state.currentIndex]?.id;
|
||
let changed = false;
|
||
if (direction < 0) {
|
||
for (let index = 1; index < state.playlist.length; index++) {
|
||
if (state.selectedRows.has(state.playlist[index].id) &&
|
||
!state.selectedRows.has(state.playlist[index - 1].id)) {
|
||
[state.playlist[index - 1], state.playlist[index]] = [state.playlist[index], state.playlist[index - 1]];
|
||
changed = true;
|
||
}
|
||
}
|
||
} else {
|
||
for (let index = state.playlist.length - 2; index >= 0; index--) {
|
||
if (state.selectedRows.has(state.playlist[index].id) &&
|
||
!state.selectedRows.has(state.playlist[index + 1].id)) {
|
||
[state.playlist[index], state.playlist[index + 1]] = [state.playlist[index + 1], state.playlist[index]];
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
if (!changed) return;
|
||
state.currentIndex = state.playlist.findIndex(item => item.id === currentId);
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`선택 항목 ${direction < 0 ? "위" : "아래"}로 이동`);
|
||
}
|
||
|
||
function deleteSelected() {
|
||
if (!requirePlaylistEditable()) return;
|
||
if (!state.selectedRows.size) return showToast("삭제할 항목을 선택하세요.");
|
||
const deleted = state.selectedRows.size;
|
||
for (const id of state.selectedRows) {
|
||
const restore = state.manualFinancialRestore.entries.get(id);
|
||
clearManualFinancialRestoreTimeout(restore);
|
||
state.manualFinancialRestore.entries.delete(id);
|
||
}
|
||
state.playlist = state.playlist.filter(item => !state.selectedRows.has(item.id));
|
||
state.selectedRows.clear();
|
||
state.currentIndex = Math.min(state.currentIndex, state.playlist.length - 1);
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`${deleted}개 항목 삭제`);
|
||
}
|
||
|
||
function deleteAllPlaylistEntries() {
|
||
if (!requirePlaylistEditable()) return;
|
||
if (!state.playlist.length) return;
|
||
if (!window.confirm("플레이리스트의 모든 항목을 삭제할까요?")) return;
|
||
const deleted = state.playlist.length;
|
||
installManualFinancialRestores([]);
|
||
state.playlist = [];
|
||
state.selectedRows.clear();
|
||
state.currentIndex = -1;
|
||
state.selectionAnchorIndex = -1;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`플레이리스트 전체 ${deleted}개 항목 삭제`);
|
||
}
|
||
|
||
function selectPlaylistBoundary(index, extend) {
|
||
if (!requirePlaylistEditable() || !state.playlist.length) return;
|
||
const target = Math.max(0, Math.min(state.playlist.length - 1, index));
|
||
selectPlaylistRow(target, { shiftKey: extend, ctrlKey: false, metaKey: false });
|
||
elements.playlistBody.querySelector(`tr[data-index="${target}"]`)?.scrollIntoView({ block: "nearest" });
|
||
}
|
||
|
||
function toggleCurrentEntryEnabled() {
|
||
if (!requirePlaylistEditable()) return;
|
||
const item = state.playlist[state.currentIndex];
|
||
if (!item) return showToast("송출 여부를 바꿀 플레이리스트 항목을 선택하세요.");
|
||
item.enabled = item.enabled === false;
|
||
renderPlaylist();
|
||
addLog(`${item.code} ${item.enabled ? "송출 포함" : "송출 제외"}`);
|
||
}
|
||
|
||
function savePlaylist() {
|
||
localStorage.setItem(storageKey, JSON.stringify(state.playlist));
|
||
addLog(`플레이리스트 ${state.playlist.length}개 로컬 저장`);
|
||
showToast("플레이리스트를 저장했습니다.");
|
||
}
|
||
|
||
function normalizeStoredSelection(value) {
|
||
if (!value || typeof value !== "object") return undefined;
|
||
const result = {};
|
||
for (const key of ["groupCode", "subject", "graphicType", "subtype", "dataCode"]) {
|
||
const text = typeof value[key] === "string" ? value[key] : "";
|
||
if (text.length > 256 || /[\u0000-\u001f\u007f]/.test(text)) return undefined;
|
||
result[key] = text;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function normalizeStoredPlaylist(value, options = {}) {
|
||
if (!Array.isArray(value)) return [];
|
||
return value.slice(0, 1000).flatMap(item => {
|
||
if (!item || typeof item !== "object") return [];
|
||
const restoredFixedEntry = fixedWorkflow.restoreFixedPlaylistEntry(item, { now: options.now });
|
||
if (restoredFixedEntry) {
|
||
const fixedDefinition = catalog.find(definition =>
|
||
definition.builderKey === restoredFixedEntry.builderKey &&
|
||
sceneAliases(definition).includes(restoredFixedEntry.code));
|
||
return fixedDefinition
|
||
? [{ ...fixedDefinition, ...restoredFixedEntry }]
|
||
: [];
|
||
}
|
||
const restoredIndustryEntry = industryWorkflow.restoreIndustryPlaylistEntry(item, { now: options.now });
|
||
if (restoredIndustryEntry) {
|
||
const industryDefinition = catalog.find(definition =>
|
||
definition.builderKey === restoredIndustryEntry.builderKey &&
|
||
sceneAliases(definition).includes(restoredIndustryEntry.code));
|
||
return industryDefinition
|
||
? [{ ...industryDefinition, ...restoredIndustryEntry }]
|
||
: [];
|
||
}
|
||
const restoredComparisonEntry = comparisonWorkflow.restoreComparisonPlaylistEntry(item);
|
||
if (restoredComparisonEntry) {
|
||
const comparisonDefinition = catalog.find(definition =>
|
||
definition.builderKey === restoredComparisonEntry.builderKey &&
|
||
sceneAliases(definition).includes(restoredComparisonEntry.code));
|
||
return comparisonDefinition
|
||
? [{ ...comparisonDefinition, ...restoredComparisonEntry }]
|
||
: [];
|
||
}
|
||
const restoredThemeEntry = themeWorkflow.restoreThemePlaylistEntry(item);
|
||
if (restoredThemeEntry) {
|
||
const themeDefinition = catalog.find(definition =>
|
||
definition.builderKey === restoredThemeEntry.builderKey &&
|
||
sceneAliases(definition).includes(restoredThemeEntry.code));
|
||
return themeDefinition
|
||
? [{ ...themeDefinition, ...restoredThemeEntry }]
|
||
: [];
|
||
}
|
||
const restoredOverseasEntry = overseasWorkflow.restoreOverseasPlaylistEntry(item);
|
||
if (restoredOverseasEntry) {
|
||
const overseasDefinition = catalog.find(definition =>
|
||
definition.builderKey === restoredOverseasEntry.builderKey &&
|
||
sceneAliases(definition).includes(restoredOverseasEntry.code));
|
||
return overseasDefinition
|
||
? [{ ...overseasDefinition, ...restoredOverseasEntry }]
|
||
: [];
|
||
}
|
||
const restoredExpertEntry = expertWorkflow.restoreExpertPlaylistEntry(item);
|
||
if (restoredExpertEntry) {
|
||
const expertDefinition = catalog.find(definition =>
|
||
definition.builderKey === restoredExpertEntry.builderKey &&
|
||
sceneAliases(definition).includes(restoredExpertEntry.code));
|
||
return expertDefinition
|
||
? [{ ...expertDefinition, ...restoredExpertEntry }]
|
||
: [];
|
||
}
|
||
const restoredTradingHaltEntry = tradingHaltWorkflow.restoreTradingHaltPlaylistEntry(item);
|
||
if (restoredTradingHaltEntry) {
|
||
const tradingHaltDefinition = catalog.find(definition =>
|
||
definition.builderKey === restoredTradingHaltEntry.builderKey &&
|
||
sceneAliases(definition).includes(restoredTradingHaltEntry.code));
|
||
return tradingHaltDefinition
|
||
? [{ ...tradingHaltDefinition, ...restoredTradingHaltEntry }]
|
||
: [];
|
||
}
|
||
const restoredManualListEntry = manualListsWorkflow.restorePlaylistEntry(item);
|
||
if (restoredManualListEntry) {
|
||
const manualListDefinition = catalog.find(definition =>
|
||
definition.builderKey === restoredManualListEntry.builderKey &&
|
||
sceneAliases(definition).includes(restoredManualListEntry.code));
|
||
return manualListDefinition
|
||
? [{ ...manualListDefinition, ...restoredManualListEntry }]
|
||
: [];
|
||
}
|
||
const pendingManualFinancialEntry = manualFinancialWorkflow.restorePlaylistEntry(item);
|
||
if (pendingManualFinancialEntry) {
|
||
const manualFinancialDefinition = catalog.find(definition =>
|
||
definition.builderKey === pendingManualFinancialEntry.entry.builderKey &&
|
||
sceneAliases(definition).includes(pendingManualFinancialEntry.entry.code));
|
||
if (!manualFinancialDefinition) return [];
|
||
if (Array.isArray(options.manualFinancialRestores)) {
|
||
options.manualFinancialRestores.push(pendingManualFinancialEntry);
|
||
}
|
||
return [{ ...manualFinancialDefinition, ...pendingManualFinancialEntry.entry }];
|
||
}
|
||
const restoredOperatorEntry = operatorWorkflow.restoreStockPlaylistEntry(item);
|
||
if (restoredOperatorEntry) {
|
||
const operatorDefinition = catalog.find(definition =>
|
||
definition.builderKey === restoredOperatorEntry.builderKey &&
|
||
sceneAliases(definition).includes(restoredOperatorEntry.code));
|
||
return operatorDefinition
|
||
? [{ ...operatorDefinition, ...restoredOperatorEntry }]
|
||
: [];
|
||
}
|
||
if (operatorInputBuilderKeys.has(String(item.builderKey || "")) || item.operator !== undefined) {
|
||
return [];
|
||
}
|
||
const storedCode = String(item.code || "");
|
||
const definitionIndex = playoutSafety.resolveStoredCatalogIndex(
|
||
catalog,
|
||
item.builderKey,
|
||
storedCode);
|
||
const definition = definitionIndex >= 0 ? catalog[definitionIndex] : null;
|
||
if (!definition) return [];
|
||
if (operatorInputBuilderKeys.has(definition.builderKey)) return [];
|
||
const id = typeof item.id === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(item.id)
|
||
? item.id
|
||
: createId();
|
||
const selection = normalizeStoredSelection(item.selection);
|
||
if (!selection) return [];
|
||
const fadeDuration = Number(item.fadeDuration);
|
||
return [{
|
||
...definition,
|
||
id,
|
||
code: sceneAliases(definition).includes(storedCode) ? storedCode : definition.code,
|
||
enabled: item.enabled !== false,
|
||
fadeDuration: Number.isInteger(fadeDuration) && fadeDuration >= 0 && fadeDuration <= 60
|
||
? fadeDuration
|
||
: DEFAULT_FADE_DURATION,
|
||
...(selection ? {
|
||
selection,
|
||
graphicType: selection.graphicType,
|
||
subtype: selection.subtype
|
||
} : {})
|
||
}];
|
||
});
|
||
}
|
||
|
||
function manualFinancialRestoreRecordForRequest(requestId, status) {
|
||
if (typeof requestId !== "string") return null;
|
||
for (const record of state.manualFinancialRestore.entries.values()) {
|
||
const expectedId = record.request?.requestId ?? record.request?.payload?.requestId;
|
||
if (record.status === status && expectedId === requestId) return record;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function clearManualFinancialRestoreTimeout(record) {
|
||
if (record?.timeoutId !== null && record?.timeoutId !== undefined) {
|
||
clearTimeout(record.timeoutId);
|
||
record.timeoutId = null;
|
||
}
|
||
}
|
||
|
||
function failManualFinancialRestore(record, message) {
|
||
if (!record || !state.manualFinancialRestore.entries.has(record.pending.entry.id)) return;
|
||
clearManualFinancialRestoreTimeout(record);
|
||
record.status = "error";
|
||
record.request = null;
|
||
record.error = typeof message === "string" && message.trim()
|
||
? message.trim()
|
||
: "수동 재무 항목을 현재 DB 값으로 다시 확인하지 못했습니다.";
|
||
renderPlaylist();
|
||
renderPlayout();
|
||
const restoreLabel = record.pending.identity?.stockName || record.pending.audience ||
|
||
(record.pending.kind === "vi" ? "VI" : "manual");
|
||
addLog(`수동 입력 복원 차단 · ${restoreLabel} · ${record.error}`);
|
||
requestNextManualFinancialRestore();
|
||
}
|
||
|
||
function armManualFinancialRestoreTimeout(record) {
|
||
clearManualFinancialRestoreTimeout(record);
|
||
const expectedRequestId = record.request?.requestId ?? record.request?.payload?.requestId;
|
||
record.timeoutId = setTimeout(() => {
|
||
if ((record.request?.requestId ?? record.request?.payload?.requestId) !== expectedRequestId) return;
|
||
failManualFinancialRestore(record, "수동 재무 복원 응답 시간이 초과되었습니다. 자동 재시도하지 않습니다.");
|
||
}, 30000);
|
||
}
|
||
|
||
function requestManualFinancialRestoreLoad(record) {
|
||
try {
|
||
if (record.restoreKind === "named-list") {
|
||
const envelope = namedManualRestoreWorkflow.createManualListReadRequest(
|
||
`named-manual-read-${createId()}`,
|
||
record.pending);
|
||
record.status = "manual-list";
|
||
record.request = envelope;
|
||
record.error = "";
|
||
postNative(envelope.type, envelope.payload);
|
||
armManualFinancialRestoreTimeout(record);
|
||
return;
|
||
}
|
||
const request = record.restoreKind === "named-financial"
|
||
? manualFinancialWorkflow.createLoadRequest(
|
||
`named-financial-load-${createId()}`,
|
||
record.pending.screen,
|
||
record.pending.stockName)
|
||
: manualFinancialWorkflow.createRestoreLoadRequest(
|
||
`manual-restore-load-${createId()}`,
|
||
record.pending);
|
||
record.status = "load";
|
||
record.request = request;
|
||
record.error = "";
|
||
postNative(manualFinancialWorkflow.bridgeContract.loadRequestType, request);
|
||
armManualFinancialRestoreTimeout(record);
|
||
} catch {
|
||
failManualFinancialRestore(record, "저장된 수동 재무 식별자가 현재 계약과 일치하지 않습니다.");
|
||
}
|
||
}
|
||
|
||
function requestNextManualFinancialRestore() {
|
||
const records = [...state.manualFinancialRestore.entries.values()];
|
||
if (records.some(record => ["load", "stock", "selection", "manual-list"].includes(record.status))) return;
|
||
const next = records.find(record => record.status === "queued");
|
||
if (next) {
|
||
requestManualFinancialRestoreLoad(next);
|
||
return;
|
||
}
|
||
finalizeNamedManualRestores();
|
||
}
|
||
|
||
function installManualFinancialRestores(pendingValues) {
|
||
for (const current of state.manualFinancialRestore.entries.values()) {
|
||
clearManualFinancialRestoreTimeout(current);
|
||
}
|
||
state.manualFinancialRestore.entries.clear();
|
||
for (const pending of pendingValues) {
|
||
if (!pending?.entry?.id || state.manualFinancialRestore.entries.has(pending.entry.id)) continue;
|
||
state.manualFinancialRestore.entries.set(pending.entry.id, {
|
||
pending,
|
||
restoreKind: namedManualRestoreWorkflow.isPending(pending)
|
||
? (pending.kind === "financial" ? "named-financial" : "named-list")
|
||
: "stored-financial",
|
||
status: nativeBridge ? "queued" : "error",
|
||
request: null,
|
||
loadResponse: null,
|
||
verifiedStock: null,
|
||
timeoutId: null,
|
||
error: nativeBridge ? "" : "Native Bridge가 없어 현재 DB 재검증을 실행할 수 없습니다."
|
||
});
|
||
}
|
||
if (nativeBridge) requestNextManualFinancialRestore();
|
||
}
|
||
|
||
function handleManualFinancialRestoreLoadResults(payload) {
|
||
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "load");
|
||
if (!record) return false;
|
||
clearManualFinancialRestoreTimeout(record);
|
||
const response = manualFinancialWorkflow.normalizeLoadResponse(payload, record.request);
|
||
if (!response) {
|
||
failManualFinancialRestore(record, "Native Bridge가 올바른 수동 재무 load 응답을 보내지 않았습니다.");
|
||
return true;
|
||
}
|
||
record.loadResponse = response;
|
||
const request = {
|
||
requestId: `manual-restore-stock-${createId()}`,
|
||
query: response.snapshot.stockName,
|
||
maximumResults: 200
|
||
};
|
||
record.status = "stock";
|
||
record.request = request;
|
||
postNative("search-stocks", request);
|
||
armManualFinancialRestoreTimeout(record);
|
||
return true;
|
||
}
|
||
|
||
function handleManualFinancialRestoreLoadError(payload) {
|
||
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "load");
|
||
if (!record) return false;
|
||
const error = manualFinancialWorkflow.normalizeLoadError(payload, record.request);
|
||
failManualFinancialRestore(record, error?.message || "수동 재무 DB load가 실패했습니다.");
|
||
return true;
|
||
}
|
||
|
||
function handleManualFinancialRestoreStockResults(payload) {
|
||
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "stock");
|
||
if (!record) return false;
|
||
clearManualFinancialRestoreTimeout(record);
|
||
const response = manualFinancialWorkflow.normalizeStockSearchResponse(payload);
|
||
const correlated = response && response.query === record.request.query &&
|
||
response.truncated === false && response.totalRowCount <= record.request.maximumResults;
|
||
const namedMatch = record.restoreKind === "named-financial" && correlated
|
||
? response.results.filter(item =>
|
||
item.name === record.pending.stockName && item.market === record.pending.market)
|
||
: [];
|
||
const expectedMarket = record.restoreKind === "named-financial"
|
||
? record.pending.market
|
||
: record.pending.verifiedStock.market;
|
||
const expectedCode = record.restoreKind === "named-financial"
|
||
? (namedMatch.length === 1 ? namedMatch[0].code : "")
|
||
: record.pending.verifiedStock.code;
|
||
const verified = correlated && manualFinancialWorkflow.verifyStockForRecord(
|
||
record.loadResponse.snapshot.record,
|
||
response,
|
||
expectedMarket,
|
||
expectedCode);
|
||
if (!verified) {
|
||
failManualFinancialRestore(record, "저장된 종목의 시장·코드를 현재 종목 마스터에서 유일하게 확인하지 못했습니다.");
|
||
return true;
|
||
}
|
||
record.verifiedStock = verified;
|
||
try {
|
||
const request = record.restoreKind === "named-financial"
|
||
? manualFinancialWorkflow.createSelectionRequest(
|
||
`named-financial-selection-${createId()}`,
|
||
record.loadResponse.snapshot,
|
||
verified)
|
||
: manualFinancialWorkflow.createRestoreSelectionRequest(
|
||
`manual-restore-selection-${createId()}`,
|
||
record.pending,
|
||
record.loadResponse,
|
||
verified);
|
||
record.status = "selection";
|
||
record.request = request;
|
||
postNative(manualFinancialWorkflow.bridgeContract.selectionRequestType, request);
|
||
armManualFinancialRestoreTimeout(record);
|
||
} catch {
|
||
failManualFinancialRestore(record, "수동 재무 행 또는 종목 마스터 값이 저장 시점과 달라졌습니다.");
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function handleManualFinancialRestoreStockError(payload) {
|
||
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "stock");
|
||
if (!record) return false;
|
||
const message = payload?.query === record.request.query && typeof payload?.message === "string"
|
||
? payload.message
|
||
: "종목 마스터 재검증에 실패했습니다.";
|
||
failManualFinancialRestore(record, message);
|
||
return true;
|
||
}
|
||
|
||
function handleManualFinancialRestoreSelectionResults(payload) {
|
||
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "selection");
|
||
if (!record) return false;
|
||
clearManualFinancialRestoreTimeout(record);
|
||
const selection = manualFinancialWorkflow.normalizeSelectionResponse(payload, record.request);
|
||
let entry = null;
|
||
if (selection) {
|
||
try {
|
||
entry = record.restoreKind === "named-financial"
|
||
? manualFinancialWorkflow.createPlaylistEntry(
|
||
record.loadResponse.snapshot,
|
||
record.verifiedStock,
|
||
selection,
|
||
{
|
||
id: record.pending.entry.id,
|
||
fadeDuration: record.pending.fadeDuration,
|
||
enabled: record.pending.enabled
|
||
})
|
||
: manualFinancialWorkflow.materializeRestoredPlaylistEntry(
|
||
record.pending,
|
||
record.loadResponse,
|
||
record.verifiedStock,
|
||
selection);
|
||
} catch {
|
||
entry = null;
|
||
}
|
||
}
|
||
if (!entry || !manualFinancialWorkflow.isPlaylistEntryPlayable(entry) ||
|
||
(record.restoreKind === "named-financial" &&
|
||
!namedManualRestoreWorkflow.matchesMaterializedEntry(record.pending, entry))) {
|
||
failManualFinancialRestore(record, "수동 재무 선택 결과가 저장된 행과 정확히 일치하지 않습니다.");
|
||
return true;
|
||
}
|
||
const index = state.playlist.findIndex(item => item.id === record.pending.entry.id);
|
||
if (index < 0 || isPlaylistSnapshotLocked()) {
|
||
failManualFinancialRestore(record, "복원 중 플레이리스트 상태가 바뀌어 항목을 적용하지 않았습니다.");
|
||
return true;
|
||
}
|
||
if (record.restoreKind === "named-financial" &&
|
||
!trustNamedManualRestoration(record.pending, entry)) {
|
||
failManualFinancialRestore(record, "DB raw 행과 현재 GraphE 선택을 안전하게 연결하지 못했습니다.");
|
||
return true;
|
||
}
|
||
state.playlist[index] = entry;
|
||
state.manualFinancialRestore.entries.delete(entry.id);
|
||
renderPlaylist();
|
||
updatePreview();
|
||
renderPlayout();
|
||
addLog(`수동 재무 복원 완료 · ${entry.title} · ${entry.code}`);
|
||
requestNextManualFinancialRestore();
|
||
return true;
|
||
}
|
||
|
||
function handleManualFinancialRestoreSelectionError(payload) {
|
||
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "selection");
|
||
if (!record) return false;
|
||
const error = manualFinancialWorkflow.normalizeSelectionError(payload, record.request);
|
||
failManualFinancialRestore(record, error?.message || "수동 재무 장면 선택 재검증이 실패했습니다.");
|
||
return true;
|
||
}
|
||
|
||
function handleManualFinancialRestoreRequestError(payload) {
|
||
const record = ["load", "selection"]
|
||
.map(status => manualFinancialRestoreRecordForRequest(payload?.requestId, status))
|
||
.find(Boolean);
|
||
if (!record) return false;
|
||
const expectedOperation = record.status === "selection" ? "selection" : "load";
|
||
const error = manualFinancialWorkflow.normalizeRequestError(payload, expectedOperation);
|
||
failManualFinancialRestore(record, error?.message || "수동 재무 복원 요청 형식이 거부되었습니다.");
|
||
return true;
|
||
}
|
||
|
||
function trustNamedManualRestoration(pending, entry) {
|
||
const restoration = state.namedPlaylist.restoration;
|
||
if (!restoration || !namedManualRestoreWorkflow.matchesMaterializedEntry(pending, entry)) return false;
|
||
const row = restoration.rows[pending.itemIndex];
|
||
if (!row || row.trusted || row.rawRow.enabled !== pending.enabled ||
|
||
!sameNamedSelection(row.rawRow, pending.rawSelection)) return false;
|
||
const rows = restoration.rows.map((value, index) => index === pending.itemIndex
|
||
? Object.freeze({ ...value, entry, trusted: true })
|
||
: value);
|
||
state.namedPlaylist.restoration = Object.freeze({
|
||
...restoration,
|
||
rows: Object.freeze(rows)
|
||
});
|
||
return true;
|
||
}
|
||
|
||
function finalizeNamedManualRestores() {
|
||
if (state.namedPlaylist.manualRestoreFinalized || !state.namedPlaylist.restoration) return;
|
||
const records = [...state.manualFinancialRestore.entries.values()];
|
||
if (records.some(record => ["queued", "load", "stock", "selection", "manual-list"].includes(record.status))) {
|
||
return;
|
||
}
|
||
const restoration = prepareNamedPagePreflight(state.namedPlaylist.restoration);
|
||
const materialized = materializeNamedRestoration(restoration);
|
||
state.namedPlaylist.restoration = restoration;
|
||
state.namedPlaylist.guardByEntryId = materialized.guards;
|
||
state.namedPlaylist.manualRestoreFinalized = true;
|
||
renderPlaylist();
|
||
renderNamedPlaylist();
|
||
renderPlayout();
|
||
requestNamedPlaylistPagePlans();
|
||
}
|
||
|
||
function handleNamedManualListResults(payload) {
|
||
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "manual-list");
|
||
if (!record || record.restoreKind !== "named-list") return false;
|
||
clearManualFinancialRestoreTimeout(record);
|
||
let entry = null;
|
||
try {
|
||
entry = namedManualRestoreWorkflow.materializeManualList(
|
||
record.pending,
|
||
payload,
|
||
record.request);
|
||
} catch {
|
||
entry = null;
|
||
}
|
||
const index = state.playlist.findIndex(item => item.id === record.pending.entry.id);
|
||
if (!entry || index < 0 || isPlaylistSnapshotLocked() ||
|
||
!trustNamedManualRestoration(record.pending, entry)) {
|
||
failManualFinancialRestore(record, "현재 신뢰 저장소 값이 DB raw 행과 정확히 일치하지 않습니다.");
|
||
return true;
|
||
}
|
||
state.playlist[index] = entry;
|
||
state.manualFinancialRestore.entries.delete(entry.id);
|
||
renderPlaylist();
|
||
updatePreview();
|
||
renderPlayout();
|
||
addLog(`수동 입력 복원 완료 · ${entry.title} · ${entry.code}`);
|
||
requestNextManualFinancialRestore();
|
||
return true;
|
||
}
|
||
|
||
function handleNamedManualListError(payload) {
|
||
const record = manualFinancialRestoreRecordForRequest(payload?.requestId, "manual-list");
|
||
if (!record || record.restoreKind !== "named-list") return false;
|
||
const expectedId = record.request?.payload?.requestId;
|
||
const error = manualListsWorkflow.normalizeError(payload, expectedId);
|
||
failManualFinancialRestore(record, error?.message || "신뢰 수동 저장소 읽기에 실패했습니다.");
|
||
return true;
|
||
}
|
||
|
||
function manualFinancialPrepareBlocked() {
|
||
if (state.manualFinancialRestore.entries.size > 0) return true;
|
||
return state.playlist.some(item => item?.operator?.source === "manual-financial-workflow" &&
|
||
!manualFinancialWorkflow.isPlaylistEntryPlayable(item));
|
||
}
|
||
|
||
function loadPlaylist() {
|
||
if (!requirePlaylistEditable()) return;
|
||
try {
|
||
const stored = JSON.parse(localStorage.getItem(storageKey) || "[]");
|
||
const pendingManualFinancial = [];
|
||
state.playlist = normalizeStoredPlaylist(stored, {
|
||
manualFinancialRestores: pendingManualFinancial
|
||
});
|
||
installManualFinancialRestores(pendingManualFinancial);
|
||
state.namedPlaylist.guardByEntryId.clear();
|
||
state.namedPlaylist.loadedDefinition = null;
|
||
synchronizeGlobalCandleOptions(false);
|
||
state.selectedRows.clear();
|
||
if (state.playlist.length) state.selectedRows.add(state.playlist[0].id);
|
||
state.currentIndex = state.playlist.length ? 0 : -1;
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`플레이리스트 ${state.playlist.length}개 불러오기`);
|
||
showToast("저장된 플레이리스트를 불러왔습니다.");
|
||
} catch {
|
||
showToast("저장 데이터를 읽을 수 없습니다.");
|
||
}
|
||
}
|
||
|
||
function sameNamedSelection(left, right) {
|
||
return Boolean(left && right) &&
|
||
["groupCode", "subject", "graphicType", "subtype", "dataCode"]
|
||
.every(key => typeof left[key] === "string" && left[key] === right[key]);
|
||
}
|
||
|
||
function namedNowForRawRow(rawRow) {
|
||
const dateMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(rawRow?.dataCode || ""));
|
||
const now = dateMatch
|
||
? new Date(Number(dateMatch[1]), Number(dateMatch[2]) - 1, Number(dateMatch[3]), 9, 0, 0, 0)
|
||
: new Date();
|
||
if (rawRow?.graphicType === "PRE_MARKET") now.setHours(9, 0, 0, 0);
|
||
if (rawRow?.graphicType === "AFTER_MARKET") now.setHours(14, 0, 0, 0);
|
||
return now;
|
||
}
|
||
|
||
function addNamedCandidate(candidates, factory) {
|
||
try {
|
||
const candidate = factory();
|
||
if (candidate) candidates.push(candidate);
|
||
} catch {
|
||
// A raw seven-field row is untrusted until a closed workflow can recreate it.
|
||
}
|
||
}
|
||
|
||
function namedMovingAverageOptions(rawRow) {
|
||
const flags = new Set(String(rawRow.graphicType || "").split(",").filter(Boolean));
|
||
return {
|
||
ma5: flags.has("MA5"),
|
||
ma20: flags.has("MA20")
|
||
};
|
||
}
|
||
|
||
function addFixedNamedCandidates(candidates, rawRow, itemIndex, now) {
|
||
for (const action of fixedWorkflow.actions) {
|
||
if (!action.available) continue;
|
||
const matches = ["groupCode", "subject", "graphicType", "subtype", "dataCode"].every(key => {
|
||
if (key === "dataCode" && action.dynamicDate) return /^\d{4}-\d{2}-\d{2}$/.test(rawRow.dataCode);
|
||
if (key === "graphicType" && action.dynamicNxtSession) {
|
||
return ["PRE_MARKET", "AFTER_MARKET"].includes(rawRow.graphicType);
|
||
}
|
||
return action.selection[key] === rawRow[key];
|
||
});
|
||
if (!matches) continue;
|
||
addNamedCandidate(candidates, () => fixedWorkflow.createFixedPlaylistEntry(action.id, {
|
||
id: `db-${itemIndex}-${action.id}`,
|
||
fadeDuration: DEFAULT_FADE_DURATION,
|
||
now
|
||
}));
|
||
}
|
||
}
|
||
|
||
function addStockNamedCandidates(candidates, rawRow, itemIndex) {
|
||
const market = operatorWorkflow.normalizeMarket(rawRow.groupCode);
|
||
if (!market || !rawRow.subject || !rawRow.dataCode) return;
|
||
const displayName = rawRow.subject;
|
||
const stockName = displayName.replace(/\s*\(NXT\)\s*/g, "").trim();
|
||
const stock = {
|
||
market: market.market,
|
||
stockName,
|
||
displayName,
|
||
stockCode: rawRow.dataCode,
|
||
isNxt: market.isNxt || displayName.includes("(NXT)")
|
||
};
|
||
const movingAverages = namedMovingAverageOptions(rawRow);
|
||
for (const cut of operatorWorkflow.stockCuts) {
|
||
addNamedCandidate(candidates, () => operatorWorkflow.createStockPlaylistEntry(stock, cut.id, {
|
||
id: `db-${itemIndex}-stock-${cut.id}`,
|
||
fadeDuration: DEFAULT_FADE_DURATION,
|
||
...movingAverages
|
||
}));
|
||
}
|
||
}
|
||
|
||
function addIndustryNamedCandidates(candidates, rawRow, itemIndex, now) {
|
||
const upperGroup = String(rawRow.groupCode || "").toLocaleUpperCase("en-US");
|
||
const market = upperGroup.includes("KOSDAQ") ? "kosdaq" : (upperGroup.includes("KOSPI") ? "kospi" : null);
|
||
if (!market) return;
|
||
industryWorkflow.cuts.forEach((cut, cutIndex) => {
|
||
const options = {
|
||
id: `db-${itemIndex}-industry-${cutIndex}`,
|
||
fadeDuration: DEFAULT_FADE_DURATION,
|
||
now
|
||
};
|
||
if (cut.kind === "pair") {
|
||
const names = String(rawRow.subject || "").split("-");
|
||
if (names.length !== 2 || names.some(value => !value)) return;
|
||
options.pair = names.map((name, index) => ({ market, name, code: `R${itemIndex}${index}` }));
|
||
} else if (cut.kind !== "fixed") {
|
||
options.selected = { market, name: rawRow.subject, code: `R${itemIndex}` };
|
||
}
|
||
addNamedCandidate(candidates, () =>
|
||
industryWorkflow.createIndustryPlaylistEntry(market, cut.id, options));
|
||
});
|
||
}
|
||
|
||
function namedComparisonTarget(token, itemIndex, tokenIndex) {
|
||
const marketTarget = comparisonWorkflow.getMarketTarget(token);
|
||
if (marketTarget) return marketTarget;
|
||
const isNxt = token.endsWith("(NXT)");
|
||
const stockName = token.replace(/\s*\(NXT\)\s*$/, "").trim();
|
||
return stockName ? {
|
||
kind: isNxt ? "nxt-stock" : "krx-stock",
|
||
market: "kospi",
|
||
stockName,
|
||
stockCode: `R${itemIndex}${tokenIndex}`
|
||
} : null;
|
||
}
|
||
|
||
function addComparisonNamedCandidates(candidates, rawRow, itemIndex) {
|
||
const tokens = String(rawRow.subject || "").split(",");
|
||
if (tokens.length !== 2 || tokens.some(value => !value)) return;
|
||
const pair = tokens.map((token, tokenIndex) => namedComparisonTarget(token, itemIndex, tokenIndex));
|
||
if (pair.some(value => !value)) return;
|
||
for (const action of comparisonWorkflow.actions) {
|
||
addNamedCandidate(candidates, () => comparisonWorkflow.createComparisonPlaylistEntry(action.id, pair, {
|
||
id: `db-${itemIndex}-comparison-${action.id}`,
|
||
fadeDuration: DEFAULT_FADE_DURATION
|
||
}));
|
||
}
|
||
}
|
||
|
||
function addThemeNamedCandidates(candidates, rawRow, itemIndex) {
|
||
let theme;
|
||
let sort;
|
||
if (rawRow.groupCode === "PAGED_THEME") {
|
||
theme = {
|
||
market: "krx",
|
||
themeCode: rawRow.dataCode,
|
||
title: rawRow.subject,
|
||
session: null,
|
||
itemCount: 1
|
||
};
|
||
sort = rawRow.graphicType;
|
||
} else if (rawRow.groupCode === "PAGED_NXT_THEME" && rawRow.subject.endsWith("(NXT)")) {
|
||
theme = {
|
||
market: "nxt",
|
||
themeCode: rawRow.dataCode,
|
||
title: rawRow.subject.slice(0, -5),
|
||
session: rawRow.graphicType,
|
||
itemCount: 1
|
||
};
|
||
sort = rawRow.subtype;
|
||
} else {
|
||
return;
|
||
}
|
||
for (const action of themeWorkflow.actions) {
|
||
addNamedCandidate(candidates, () => themeWorkflow.createThemePlaylistEntry(action.id, theme, sort, {
|
||
id: `db-${itemIndex}-theme-${action.id}`,
|
||
fadeDuration: DEFAULT_FADE_DURATION
|
||
}));
|
||
}
|
||
}
|
||
|
||
function addOverseasNamedCandidates(candidates, rawRow, itemIndex) {
|
||
const movingAverages = namedMovingAverageOptions(rawRow);
|
||
if (rawRow.groupCode === "FOREIGN_INDUSTRY") {
|
||
const parts = String(rawRow.dataCode || "").split("|");
|
||
if (parts.length !== 4) return;
|
||
addNamedCandidate(candidates, () => overseasWorkflow.createOverseasIndustryPlaylistEntry({
|
||
koreanName: rawRow.subject,
|
||
inputName: parts[0],
|
||
symbol: parts[1],
|
||
nationCode: parts[2],
|
||
fdtc: parts[3]
|
||
}, {
|
||
id: `db-${itemIndex}-overseas-industry`,
|
||
fadeDuration: DEFAULT_FADE_DURATION
|
||
}));
|
||
return;
|
||
}
|
||
if (rawRow.groupCode !== "FOREIGN_STOCK") return;
|
||
const parts = String(rawRow.dataCode || "").split("|");
|
||
if (parts.length !== 3) return;
|
||
const stock = {
|
||
inputName: rawRow.subject,
|
||
symbol: parts[0],
|
||
nationCode: parts[1],
|
||
fdtc: parts[2]
|
||
};
|
||
for (const action of overseasWorkflow.stockActions) {
|
||
addNamedCandidate(candidates, () => overseasWorkflow.createOverseasStockPlaylistEntry(stock, action.id, {
|
||
id: `db-${itemIndex}-overseas-${action.id}`,
|
||
fadeDuration: DEFAULT_FADE_DURATION,
|
||
...movingAverages
|
||
}));
|
||
}
|
||
}
|
||
|
||
function addExpertNamedCandidate(candidates, rawRow, itemIndex) {
|
||
if (rawRow.groupCode !== "PAGED_EXPERT") return;
|
||
addNamedCandidate(candidates, () => expertWorkflow.createExpertPlaylistEntry({
|
||
expertCode: rawRow.dataCode,
|
||
expertName: rawRow.subject,
|
||
source: "oracle",
|
||
itemCount: 1,
|
||
previewTruncated: false
|
||
}, {
|
||
id: `db-${itemIndex}-expert`,
|
||
fadeDuration: DEFAULT_FADE_DURATION
|
||
}));
|
||
}
|
||
|
||
function addTradingHaltNamedCandidates(candidates, rawRow, itemIndex) {
|
||
const upperGroup = String(rawRow.groupCode || "").toLocaleUpperCase("en-US");
|
||
const market = upperGroup.includes("KOSDAQ") ? "kosdaq" : (upperGroup.includes("KOSPI") ? "kospi" : null);
|
||
if (!market || !rawRow.subject || !rawRow.dataCode) return;
|
||
const selected = { market, stockCode: rawRow.dataCode, displayName: rawRow.subject };
|
||
const movingAverages = namedMovingAverageOptions(rawRow);
|
||
for (const action of tradingHaltWorkflow.actions) {
|
||
addNamedCandidate(candidates, () => tradingHaltWorkflow.createTradingHaltPlaylistEntry(selected, action.id, {
|
||
id: `db-${itemIndex}-halt-${action.id}`,
|
||
fadeDuration: DEFAULT_FADE_DURATION,
|
||
...movingAverages
|
||
}));
|
||
}
|
||
}
|
||
|
||
function addDefaultNamedCandidates(candidates, rawRow, itemIndex) {
|
||
for (const definition of catalog) {
|
||
if (definition.reachable === false || operatorInputBuilderKeys.has(definition.builderKey)) continue;
|
||
const selection = defaultSceneSelection(definition.builderKey);
|
||
if (!sameNamedSelection(selection, rawRow)) continue;
|
||
candidates.push({
|
||
...definition,
|
||
id: `db-${itemIndex}-default-${definition.builderKey}`,
|
||
selection: { ...selection },
|
||
enabled: rawRow.enabled,
|
||
fadeDuration: DEFAULT_FADE_DURATION,
|
||
graphicType: selection.graphicType,
|
||
subtype: selection.subtype
|
||
});
|
||
}
|
||
}
|
||
|
||
function resolveNamedPlaylistRawRow(rawRow, itemIndex) {
|
||
const candidates = [];
|
||
const now = namedNowForRawRow(rawRow);
|
||
addFixedNamedCandidates(candidates, rawRow, itemIndex, now);
|
||
addStockNamedCandidates(candidates, rawRow, itemIndex);
|
||
addIndustryNamedCandidates(candidates, rawRow, itemIndex, now);
|
||
addComparisonNamedCandidates(candidates, rawRow, itemIndex);
|
||
addThemeNamedCandidates(candidates, rawRow, itemIndex);
|
||
addOverseasNamedCandidates(candidates, rawRow, itemIndex);
|
||
addExpertNamedCandidate(candidates, rawRow, itemIndex);
|
||
addTradingHaltNamedCandidates(candidates, rawRow, itemIndex);
|
||
addDefaultNamedCandidates(candidates, rawRow, itemIndex);
|
||
|
||
const trusted = new Map();
|
||
for (const candidate of candidates) {
|
||
const restored = normalizeStoredPlaylist([{ ...candidate, enabled: rawRow.enabled }], { now });
|
||
const entry = restored.length === 1 ? restored[0] : null;
|
||
if (!entry || entry.enabled !== rawRow.enabled || !sameNamedSelection(entry.selection, rawRow)) continue;
|
||
const key = `${entry.builderKey}|${entry.code}|${JSON.stringify(entry.selection)}`;
|
||
if (!trusted.has(key)) trusted.set(key, entry);
|
||
}
|
||
return trusted.size === 1 ? [...trusted.values()][0] : null;
|
||
}
|
||
|
||
function prepareNamedPagePreflight(restoration) {
|
||
return namedPlaylistWorkflow.preparePagePreflight(
|
||
restoration,
|
||
entry => namedPagedBuilderKeys.has(entry.builderKey));
|
||
}
|
||
|
||
function namedInvalidEntry(definition, rawRow) {
|
||
return {
|
||
id: `db-invalid-${definition.programCode}-${rawRow.itemIndex}`,
|
||
builderKey: namedInvalidBuilderKey,
|
||
code: "0",
|
||
aliases: ["0"],
|
||
title: rawRow.subject || `DB 행 ${rawRow.itemIndex + 1}`,
|
||
detail: "DB 7필드에서 컷을 유일하게 복원할 수 없음",
|
||
category: "invalid",
|
||
market: rawRow.groupCode || "unknown",
|
||
selection: {
|
||
groupCode: rawRow.groupCode,
|
||
subject: rawRow.subject,
|
||
graphicType: rawRow.graphicType,
|
||
subtype: rawRow.subtype,
|
||
dataCode: rawRow.dataCode
|
||
},
|
||
enabled: rawRow.enabled,
|
||
fadeDuration: DEFAULT_FADE_DURATION,
|
||
graphicType: rawRow.graphicType,
|
||
subtype: rawRow.subtype,
|
||
pageText: rawRow.pageText
|
||
};
|
||
}
|
||
|
||
function materializeNamedRestoration(restoration) {
|
||
const entries = [];
|
||
const guards = new Map();
|
||
for (const row of restoration.rows) {
|
||
const trustedOperatorEntry = row.entry?.operator?.source === "manual-financial-workflow" ||
|
||
row.entry?.operator?.source === "legacy-manual-lists-workflow";
|
||
const entry = row.trusted
|
||
? (trustedOperatorEntry ? row.entry : { ...row.entry, pageText: row.rawRow.pageText })
|
||
: namedInvalidEntry(restoration.definition, row.rawRow);
|
||
entries.push(entry);
|
||
guards.set(entry.id, Object.freeze({
|
||
trusted: row.trusted,
|
||
pageRecalculationRequired: row.pageRecalculationRequired,
|
||
pageRecalculated: row.pageRecalculated,
|
||
pagePreflightCompleted: row.pagePreflightCompleted,
|
||
pageUnavailableReason: row.pageUnavailableReason,
|
||
recalculatedPagePlan: row.recalculatedPagePlan,
|
||
historicalPageOutOfBounds: row.rawRow.historicalPageOutOfBounds,
|
||
rawRow: row.rawRow
|
||
}));
|
||
}
|
||
return { entries, guards };
|
||
}
|
||
|
||
function namedPlaylistPrepareBlockers() {
|
||
const blockers = [];
|
||
state.playlist.forEach((entry, index) => {
|
||
const guard = state.namedPlaylist.guardByEntryId.get(entry.id);
|
||
if (!guard) return;
|
||
if (!guard.trusted) blockers.push({ itemIndex: index, reason: "trusted-restore-required" });
|
||
if (guard.pageRecalculationRequired && !guard.pageRecalculated) {
|
||
blockers.push({
|
||
itemIndex: index,
|
||
reason: guard.pagePreflightCompleted && guard.pageUnavailableReason === "empty-result"
|
||
? "page-result-empty"
|
||
: "page-recalculation-required"
|
||
});
|
||
}
|
||
});
|
||
return blockers;
|
||
}
|
||
|
||
function clearNamedPlaylistLoadState() {
|
||
state.namedPlaylist.guardByEntryId.clear();
|
||
state.namedPlaylist.loadedDefinition = null;
|
||
state.namedPlaylist.restoration = null;
|
||
state.namedPlaylist.pagePlanPending = null;
|
||
state.namedPlaylist.pagePlanError = null;
|
||
state.namedPlaylist.manualRestoreFinalized = true;
|
||
}
|
||
|
||
function saveNamedPlaylistBackup() {
|
||
if (!state.playlist.length) return true;
|
||
try {
|
||
localStorage.setItem(namedPlaylistBackupStorageKey, JSON.stringify({
|
||
savedAt: new Date().toISOString(),
|
||
playlist: state.playlist
|
||
}));
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function hasNamedPlaylistBackup() {
|
||
try {
|
||
return Boolean(localStorage.getItem(namedPlaylistBackupStorageKey));
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function restoreNamedPlaylistBackup() {
|
||
if (!requirePlaylistEditable() || namedPlaylistBusy()) return;
|
||
try {
|
||
const stored = JSON.parse(localStorage.getItem(namedPlaylistBackupStorageKey) || "null");
|
||
if (!stored || typeof stored !== "object" || !Array.isArray(stored.playlist)) throw new Error();
|
||
const pendingManualFinancial = [];
|
||
const restored = normalizeStoredPlaylist(stored.playlist, {
|
||
manualFinancialRestores: pendingManualFinancial
|
||
});
|
||
if (restored.length !== stored.playlist.length) throw new Error();
|
||
state.playlist = restored;
|
||
installManualFinancialRestores(pendingManualFinancial);
|
||
synchronizeGlobalCandleOptions(false);
|
||
clearNamedPlaylistLoadState();
|
||
state.selectedRows.clear();
|
||
if (state.playlist.length) state.selectedRows.add(state.playlist[0].id);
|
||
state.currentIndex = state.playlist.length ? 0 : -1;
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
localStorage.removeItem(namedPlaylistBackupStorageKey);
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`DB 불러오기 전 플레이리스트 ${state.playlist.length}개 복원`);
|
||
showToast("DB 불러오기 전 상태를 복원했습니다.");
|
||
} catch {
|
||
showToast("DB 불러오기 전 상태 백업을 복원할 수 없습니다.");
|
||
}
|
||
}
|
||
|
||
function namedPageTextForEntry(entry) {
|
||
if (!namedPagedBuilderKeys.has(entry.builderKey)) return "1/1";
|
||
const guard = state.namedPlaylist.guardByEntryId.get(entry.id);
|
||
const guardedCount = guard?.recalculatedPagePlan?.pageCount;
|
||
const previewCount = entry.pagePreview?.pageCount ?? entry.pageCount;
|
||
const pageCount = Number.isInteger(guardedCount) ? guardedCount : previewCount;
|
||
if (!Number.isInteger(pageCount) || pageCount < 1 ||
|
||
pageCount > namedPlaylistWorkflow.bridgeContract.maximumPageCount) {
|
||
throw new Error("5·6·12행 장면은 현재 DB 결과로 페이지를 다시 계산한 뒤 저장해야 합니다.");
|
||
}
|
||
return `1/${pageCount}`;
|
||
}
|
||
|
||
function isTrustedNamedPlaylistEntry(entry) {
|
||
const guard = state.namedPlaylist.guardByEntryId.get(entry?.id);
|
||
if (guard && (!guard.trusted || (guard.pageRecalculationRequired && !guard.pageRecalculated))) return false;
|
||
const selection = entry?.selection;
|
||
if (!selection) return false;
|
||
const now = namedNowForRawRow(selection);
|
||
const restored = normalizeStoredPlaylist([entry], { now });
|
||
if (restored.length !== 1 || restored[0].builderKey !== entry.builderKey ||
|
||
restored[0].code !== entry.code || !sameNamedSelection(restored[0].selection, selection)) return false;
|
||
let fields;
|
||
try {
|
||
fields = namedPlaylistWorkflow.toLegacySevenFields(entry, namedPageTextForEntry(entry));
|
||
} catch {
|
||
return false;
|
||
}
|
||
const roundTrip = resolveNamedPlaylistRawRow({
|
||
itemIndex: 0,
|
||
enabled: fields[0] === "1",
|
||
groupCode: fields[1],
|
||
subject: fields[2],
|
||
graphicType: fields[3],
|
||
subtype: fields[4],
|
||
pageText: fields[5],
|
||
dataCode: fields[6]
|
||
}, 0);
|
||
return Boolean(roundTrip && roundTrip.builderKey === entry.builderKey && roundTrip.code === entry.code);
|
||
}
|
||
|
||
function selectedNamedPlaylistDefinition() {
|
||
return state.namedPlaylist.definitions.find(
|
||
value => value.programCode === state.namedPlaylist.selectedProgramCode) || null;
|
||
}
|
||
|
||
function namedPlaylistBusy() {
|
||
return Boolean(state.namedPlaylist.pending || state.namedPlaylist.pagePlanPending);
|
||
}
|
||
|
||
function renderNamedPlaylist() {
|
||
const workflow = state.namedPlaylist;
|
||
const locked = isPlaylistSnapshotLocked();
|
||
const pending = workflow.pending || workflow.pagePlanPending;
|
||
const selected = selectedNamedPlaylistDefinition();
|
||
const blockers = namedPlaylistPrepareBlockers();
|
||
const pageRetryAvailable = workflow.pagePlanError?.retryable === true ||
|
||
blockers.some(value => value.reason === "page-result-empty");
|
||
const writeAllowed = Boolean(nativeBridge) && !locked && !pending &&
|
||
workflow.canMutate && !workflow.writeQuarantined;
|
||
|
||
elements.namedPlaylistPanel.hidden = !workflow.open;
|
||
elements.namedPlaylistOpenButton.setAttribute("aria-expanded", String(workflow.open));
|
||
elements.namedPlaylistOpenButton.textContent = workflow.open ? "DB 패널 닫기" : "DB 저장·불러오기";
|
||
|
||
elements.namedPlaylistDefinitions.replaceChildren();
|
||
for (const definition of workflow.definitions) {
|
||
const option = document.createElement("option");
|
||
option.value = definition.programCode;
|
||
option.textContent = `${definition.title} · ${definition.programCode}`;
|
||
option.selected = definition.programCode === workflow.selectedProgramCode;
|
||
elements.namedPlaylistDefinitions.append(option);
|
||
}
|
||
elements.namedPlaylistSelectionSummary.textContent = selected
|
||
? `${selected.title} · ${selected.programCode}${workflow.loadedDefinition?.programCode === selected.programCode ? " · 현재 불러온 정의" : ""}`
|
||
: "선택된 DB 정의 없음";
|
||
|
||
elements.namedPlaylistStatus.classList.remove("ready", "error", "quarantined");
|
||
elements.namedPlaylistStateBadge.classList.remove("neutral", "live", "dry-run");
|
||
let badge = "대기";
|
||
let status = "Oracle에 저장된 이름 목록을 조회하세요. 로컬 임시저장과는 별도입니다.";
|
||
if (!nativeBridge) {
|
||
badge = "미리보기";
|
||
status = "Native Bridge가 없어 Oracle DB 플레이리스트를 사용할 수 없습니다.";
|
||
} else if (workflow.writeQuarantined) {
|
||
badge = "WRITE 잠금";
|
||
status = workflow.error || "DB 쓰기 결과가 불명확합니다. 앱을 다시 시작하기 전까지 생성·덮어쓰기·삭제를 반복하지 않습니다.";
|
||
elements.namedPlaylistStatus.classList.add("quarantined");
|
||
elements.namedPlaylistStateBadge.classList.add("dry-run");
|
||
} else if (workflow.error) {
|
||
badge = "오류";
|
||
status = workflow.error;
|
||
elements.namedPlaylistStatus.classList.add("error");
|
||
elements.namedPlaylistStateBadge.classList.add("dry-run");
|
||
} else if (pending) {
|
||
badge = "처리 중";
|
||
status = pending.operation === "page-preflight"
|
||
? "현재 Oracle 조회 결과로 5·6·12행 장면의 페이지 수를 계산하고 있습니다. 이 조회는 송출 엔진을 호출하지 않습니다."
|
||
: `${pending.operation.toLocaleUpperCase("en-US")} 요청을 처리하고 있습니다. 완료 전에는 다른 DB 작업이나 PREPARE를 실행하지 않습니다.`;
|
||
} else if (workflow.loadedDefinition) {
|
||
badge = blockers.length ? "복원 확인" : "불러옴";
|
||
status = blockers.length
|
||
? `${workflow.loadedDefinition.title}을(를) 불러왔지만 ${blockers.length}개 안전 조건이 남았습니다. 표시된 행을 현재 전용 선택 화면에서 다시 구성하세요.`
|
||
: `${workflow.loadedDefinition.title} · ${state.playlist.length}개 행이 신뢰 복원과 페이지 검증을 통과했습니다.`;
|
||
elements.namedPlaylistStatus.classList.add(blockers.length ? "quarantined" : "ready");
|
||
elements.namedPlaylistStateBadge.classList.add(blockers.length ? "dry-run" : "live");
|
||
} else if (workflow.status === "ready") {
|
||
badge = "목록 준비";
|
||
status = `${workflow.definitions.length}개 DB 정의를 조회했습니다.${workflow.canMutate ? "" : " 현재 환경은 DB 쓰기를 허용하지 않습니다."}`;
|
||
elements.namedPlaylistStatus.classList.add("ready");
|
||
elements.namedPlaylistStateBadge.classList.add("live");
|
||
} else {
|
||
elements.namedPlaylistStateBadge.classList.add("neutral");
|
||
}
|
||
if (!elements.namedPlaylistStateBadge.classList.contains("live") &&
|
||
!elements.namedPlaylistStateBadge.classList.contains("dry-run")) {
|
||
elements.namedPlaylistStateBadge.classList.add("neutral");
|
||
}
|
||
elements.namedPlaylistStateBadge.textContent = badge;
|
||
elements.namedPlaylistStatus.textContent = locked
|
||
? "PREPARED/PROGRAM 상태에서는 DB 플레이리스트 UI가 TAKE OUT까지 잠깁니다."
|
||
: status;
|
||
|
||
elements.namedPlaylistRefreshButton.disabled = !nativeBridge || locked || Boolean(pending);
|
||
elements.namedPlaylistDefinitions.disabled = locked || Boolean(pending) || !workflow.definitions.length;
|
||
elements.namedPlaylistLoadButton.disabled = !nativeBridge || locked || Boolean(pending) || !selected;
|
||
elements.namedPlaylistPageRetryButton.hidden = !pageRetryAvailable;
|
||
elements.namedPlaylistPageRetryButton.disabled = !nativeBridge || locked || Boolean(pending) ||
|
||
!workflow.restoration;
|
||
elements.namedPlaylistRestoreBackupButton.disabled = locked || Boolean(pending) || !hasNamedPlaylistBackup();
|
||
elements.namedPlaylistProgramCode.disabled = !writeAllowed;
|
||
elements.namedPlaylistProgramTitle.disabled = !writeAllowed;
|
||
elements.namedPlaylistCreateButton.disabled = !writeAllowed;
|
||
elements.namedPlaylistReplaceButton.disabled = !writeAllowed || !selected;
|
||
elements.namedPlaylistDeleteButton.disabled = !writeAllowed || !selected;
|
||
for (const button of [
|
||
elements.namedPlaylistRefreshButton,
|
||
elements.namedPlaylistLoadButton,
|
||
elements.namedPlaylistPageRetryButton,
|
||
elements.namedPlaylistCreateButton,
|
||
elements.namedPlaylistReplaceButton,
|
||
elements.namedPlaylistDeleteButton
|
||
]) button.setAttribute("aria-busy", String(Boolean(pending)));
|
||
|
||
const unresolved = blockers.filter(value => value.reason === "trusted-restore-required").length;
|
||
const pagePending = blockers.filter(value => value.reason === "page-recalculation-required").length;
|
||
const pageEmpty = blockers.filter(value => value.reason === "page-result-empty").length;
|
||
elements.namedPlaylistGuardSummary.classList.toggle("blocked", blockers.length > 0);
|
||
elements.namedPlaylistGuardSummary.textContent = blockers.length
|
||
? `PREPARE 차단 · 컷 복원 ${unresolved}건 · 페이지 조회 ${pagePending}건 · DB 결과 없음 ${pageEmpty}건. 원본 7필드가 여러 컷과 겹치면 임의로 선택하지 않습니다.`
|
||
: "DB에서 읽은 각 행은 기존 trusted restore 체인과 현재 20페이지 상한 검증을 통과했습니다.";
|
||
}
|
||
|
||
function beginNamedPlaylistOperation(operation, request, programCode = "") {
|
||
if (namedPlaylistBusy()) return false;
|
||
state.namedPlaylist.pending = {
|
||
requestId: request.requestId,
|
||
operation,
|
||
programCode
|
||
};
|
||
state.namedPlaylist.status = "pending";
|
||
state.namedPlaylist.error = "";
|
||
postNative(namedPlaylistWorkflow.bridgeContract.requests[operation], request);
|
||
renderNamedPlaylist();
|
||
return true;
|
||
}
|
||
|
||
function requestNamedPlaylistList() {
|
||
if (!nativeBridge) return showToast("브라우저 미리보기에서는 Oracle DB 이름 목록을 조회하지 않습니다.");
|
||
if (isPlaylistSnapshotLocked()) return showToast("TAKE OUT 후 DB 플레이리스트를 조회하세요.");
|
||
try {
|
||
const request = namedPlaylistWorkflow.createListRequest(`named-list-${createId()}`, 1000);
|
||
beginNamedPlaylistOperation("list", request);
|
||
} catch {
|
||
showToast("DB 플레이리스트 목록 요청을 만들 수 없습니다.");
|
||
}
|
||
}
|
||
|
||
function requestNamedPlaylistLoad() {
|
||
if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy()) return;
|
||
const definition = selectedNamedPlaylistDefinition();
|
||
if (!definition) return showToast("불러올 DB 정의를 선택하세요.");
|
||
if (state.playlist.length && !window.confirm(
|
||
`${definition.title}을(를) 불러오면 현재 화면의 플레이리스트가 바뀝니다. 불러오기 직전 상태는 별도 로컬 백업으로 보존합니다.`)) return;
|
||
try {
|
||
const request = namedPlaylistWorkflow.createLoadRequest(
|
||
`named-load-${createId()}`,
|
||
definition.programCode);
|
||
beginNamedPlaylistOperation("load", request, definition.programCode);
|
||
} catch {
|
||
showToast("선택한 DB 플레이리스트 요청 형식이 올바르지 않습니다.");
|
||
}
|
||
}
|
||
|
||
function requestNamedPlaylistCreate() {
|
||
if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy() ||
|
||
state.namedPlaylist.writeQuarantined || !state.namedPlaylist.canMutate) return;
|
||
try {
|
||
const request = namedPlaylistWorkflow.createDefinitionRequest(
|
||
`named-create-${createId()}`,
|
||
elements.namedPlaylistProgramCode.value,
|
||
elements.namedPlaylistProgramTitle.value);
|
||
if (!window.confirm(`${request.programCode} · ${request.title} 이름을 새로 만들까요? 현재 순서는 만든 뒤 '선택 정의에 현재 순서 저장'으로 저장합니다.`)) return;
|
||
state.namedPlaylist.selectAfterRefresh = request.programCode;
|
||
beginNamedPlaylistOperation("create", request, request.programCode);
|
||
} catch {
|
||
showToast("새 코드는 8자리 숫자, 이름은 앞뒤 공백 없는 128자 이내로 입력하세요.");
|
||
}
|
||
}
|
||
|
||
function requestNamedPlaylistReplace() {
|
||
if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy() ||
|
||
state.namedPlaylist.writeQuarantined || !state.namedPlaylist.canMutate) return;
|
||
const definition = selectedNamedPlaylistDefinition();
|
||
if (!definition) return showToast("덮어쓸 DB 정의를 선택하세요.");
|
||
const blockers = namedPlaylistPrepareBlockers();
|
||
if (blockers.length) {
|
||
renderNamedPlaylist();
|
||
return showToast("복원 또는 페이지 재계산이 끝나지 않은 행은 DB에 덮어쓸 수 없습니다.");
|
||
}
|
||
try {
|
||
const request = namedPlaylistWorkflow.createReplaceRequest(
|
||
`named-replace-${createId()}`,
|
||
definition.programCode,
|
||
state.playlist,
|
||
{
|
||
isTrustedEntry: isTrustedNamedPlaylistEntry,
|
||
pageTextForEntry: namedPageTextForEntry
|
||
});
|
||
if (!window.confirm(`${definition.title}의 DB 플레이리스트를 현재 ${request.items.length}개 순서로 교체할까요? 이 작업은 한 트랜잭션으로 실행되며 자동 재시도하지 않습니다.`)) return;
|
||
beginNamedPlaylistOperation("replace", request, definition.programCode);
|
||
} catch (error) {
|
||
addLog(`DB 플레이리스트 저장 차단 · ${error?.message || "trusted restore 실패"}`);
|
||
showToast("DB 7필드로 유일하게 왕복 복원되지 않거나 현재 페이지 수가 없는 행이 있어 저장을 중단했습니다.");
|
||
}
|
||
}
|
||
|
||
function requestNamedPlaylistDelete() {
|
||
if (!nativeBridge || isPlaylistSnapshotLocked() || namedPlaylistBusy() ||
|
||
state.namedPlaylist.writeQuarantined || !state.namedPlaylist.canMutate) return;
|
||
const definition = selectedNamedPlaylistDefinition();
|
||
if (!definition) return showToast("삭제할 DB 정의를 선택하세요.");
|
||
if (!window.confirm(`${definition.title} · ${definition.programCode} 정의와 그 하위 PLAY_LIST 행을 삭제할까요?`)) return;
|
||
try {
|
||
const request = namedPlaylistWorkflow.createDeleteRequest(
|
||
`named-delete-${createId()}`,
|
||
definition.programCode);
|
||
beginNamedPlaylistOperation("delete", request, definition.programCode);
|
||
} catch {
|
||
showToast("선택한 DB 정의를 삭제할 수 없습니다.");
|
||
}
|
||
}
|
||
|
||
function namedPendingMatches(operation, payload) {
|
||
const pending = state.namedPlaylist.pending;
|
||
return Boolean(pending && pending.operation === operation && payload?.requestId === pending.requestId);
|
||
}
|
||
|
||
function failNamedPlaylistProtocol(operation) {
|
||
const pending = state.namedPlaylist.pending;
|
||
if (!pending || pending.operation !== operation) return;
|
||
state.namedPlaylist.pending = null;
|
||
state.namedPlaylist.status = "error";
|
||
state.namedPlaylist.error = "Native Bridge가 유효한 DB 플레이리스트 응답을 보내지 않았습니다.";
|
||
if (namedWriteOperations.has(operation)) {
|
||
state.namedPlaylist.writeQuarantined = true;
|
||
state.namedPlaylist.canMutate = false;
|
||
state.namedPlaylist.error += " 쓰기 결과를 확인할 수 없어 앱 재시작 전까지 추가 쓰기를 차단합니다.";
|
||
}
|
||
renderNamedPlaylist();
|
||
}
|
||
|
||
function handleNamedPlaylistListResults(payload) {
|
||
const result = namedPlaylistWorkflow.normalizeListResponse(payload);
|
||
if (!result) return failNamedPlaylistProtocol("list");
|
||
if (!namedPendingMatches("list", result)) return;
|
||
const previousSuggestion = state.namedPlaylist.suggestedProgramCode;
|
||
state.namedPlaylist.pending = null;
|
||
state.namedPlaylist.status = "ready";
|
||
state.namedPlaylist.definitions = [...result.definitions];
|
||
state.namedPlaylist.suggestedProgramCode = result.suggestedProgramCode;
|
||
state.namedPlaylist.canMutate = result.canMutate;
|
||
state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || result.writeQuarantined;
|
||
const preferred = state.namedPlaylist.selectAfterRefresh || state.namedPlaylist.selectedProgramCode;
|
||
state.namedPlaylist.selectedProgramCode = result.definitions.some(value => value.programCode === preferred)
|
||
? preferred
|
||
: (result.definitions[0]?.programCode || "");
|
||
state.namedPlaylist.selectAfterRefresh = "";
|
||
if (!elements.namedPlaylistProgramCode.value ||
|
||
elements.namedPlaylistProgramCode.value === previousSuggestion) {
|
||
elements.namedPlaylistProgramCode.value = result.suggestedProgramCode;
|
||
}
|
||
renderNamedPlaylist();
|
||
addLog(`DB 플레이리스트 이름 목록 ${result.definitions.length}개 조회`);
|
||
}
|
||
|
||
function handleNamedPlaylistLoadResults(payload) {
|
||
const load = namedPlaylistWorkflow.normalizeLoadResponse(payload);
|
||
if (!load) return failNamedPlaylistProtocol("load");
|
||
if (!namedPendingMatches("load", load)) return;
|
||
state.namedPlaylist.pending = null;
|
||
state.namedPlaylist.canMutate = load.canMutate;
|
||
state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || load.writeQuarantined;
|
||
if (isPlaylistSnapshotLocked()) {
|
||
state.namedPlaylist.status = "error";
|
||
state.namedPlaylist.error = "DB 응답 중 송출 상태가 잠겨 현재 플레이리스트를 교체하지 않았습니다.";
|
||
renderNamedPlaylist();
|
||
return;
|
||
}
|
||
if (!saveNamedPlaylistBackup()) {
|
||
state.namedPlaylist.status = "error";
|
||
state.namedPlaylist.error = "현재 플레이리스트를 로컬 백업하지 못해 DB 불러오기를 적용하지 않았습니다.";
|
||
renderNamedPlaylist();
|
||
return;
|
||
}
|
||
try {
|
||
let restoration = namedPlaylistWorkflow.restoreRawRows(load, resolveNamedPlaylistRawRow);
|
||
const pendingNamedManual = restoration.rows.flatMap(row => {
|
||
if (row.trusted) return [];
|
||
const pending = namedManualRestoreWorkflow.classify(row.rawRow, {
|
||
id: `db-invalid-${load.definition.programCode}-${row.rawRow.itemIndex}`,
|
||
fadeDuration: DEFAULT_FADE_DURATION
|
||
});
|
||
return pending ? [pending] : [];
|
||
});
|
||
state.namedPlaylist.manualRestoreFinalized = pendingNamedManual.length === 0;
|
||
if (state.namedPlaylist.manualRestoreFinalized) {
|
||
restoration = prepareNamedPagePreflight(restoration);
|
||
}
|
||
const materialized = materializeNamedRestoration(restoration);
|
||
state.playlist = materialized.entries;
|
||
synchronizeGlobalCandleOptions(false);
|
||
state.namedPlaylist.guardByEntryId = materialized.guards;
|
||
state.namedPlaylist.restoration = restoration;
|
||
state.namedPlaylist.pagePlanError = null;
|
||
state.namedPlaylist.loadedDefinition = load.definition;
|
||
state.namedPlaylist.selectedProgramCode = load.definition.programCode;
|
||
state.namedPlaylist.status = "loaded";
|
||
state.namedPlaylist.error = "";
|
||
state.selectedRows.clear();
|
||
if (state.playlist.length) state.selectedRows.add(state.playlist[0].id);
|
||
state.currentIndex = state.playlist.length ? 0 : -1;
|
||
state.selectionAnchorIndex = state.currentIndex;
|
||
installManualFinancialRestores(pendingNamedManual);
|
||
renderPlaylist();
|
||
updatePreview();
|
||
const blockers = namedPlaylistPrepareBlockers();
|
||
addLog(`DB 플레이리스트 ${load.definition.title} · ${state.playlist.length}개 불러오기 · 차단 ${blockers.length}건`);
|
||
showToast(blockers.length
|
||
? "DB 목록을 불러왔습니다. 표시된 복원·페이지 항목을 확인하세요."
|
||
: "DB 플레이리스트를 안전하게 불러왔습니다.");
|
||
if (state.namedPlaylist.manualRestoreFinalized) requestNamedPlaylistPagePlans();
|
||
} catch {
|
||
state.namedPlaylist.status = "error";
|
||
state.namedPlaylist.error = "DB 원시 행의 엄격한 복원 중 오류가 발생해 현재 플레이리스트를 교체하지 않았습니다.";
|
||
renderNamedPlaylist();
|
||
}
|
||
}
|
||
|
||
function requestNamedPlaylistPagePlans() {
|
||
const workflow = state.namedPlaylist;
|
||
if (!nativeBridge || isPlaylistSnapshotLocked() || workflow.pending ||
|
||
workflow.pagePlanPending || !workflow.restoration) return;
|
||
try {
|
||
const request = namedPlaylistWorkflow.createPagePlanRequest(
|
||
`named-page-plan-${createId()}`,
|
||
workflow.restoration);
|
||
if (!request) {
|
||
workflow.pagePlanError = null;
|
||
workflow.status = "loaded";
|
||
renderNamedPlaylist();
|
||
return;
|
||
}
|
||
workflow.pagePlanPending = {
|
||
operation: "page-preflight",
|
||
requestId: request.requestId,
|
||
request,
|
||
restoration: workflow.restoration
|
||
};
|
||
workflow.pagePlanError = null;
|
||
workflow.error = "";
|
||
workflow.status = "page-preflight";
|
||
postNative(namedPlaylistWorkflow.pagePlanBridgeContract.request, request);
|
||
renderNamedPlaylist();
|
||
renderPlayout();
|
||
} catch (error) {
|
||
workflow.status = "error";
|
||
workflow.pagePlanError = Object.freeze({
|
||
code: "INVALID_PAGE_PREFLIGHT",
|
||
message: error?.message || "페이지 조회 요청을 만들 수 없습니다.",
|
||
retryable: false
|
||
});
|
||
workflow.error = workflow.pagePlanError.message;
|
||
renderNamedPlaylist();
|
||
}
|
||
}
|
||
|
||
function failNamedPagePlanProtocol(pending) {
|
||
if (state.namedPlaylist.pagePlanPending !== pending) return;
|
||
state.namedPlaylist.pagePlanPending = null;
|
||
state.namedPlaylist.status = "error";
|
||
state.namedPlaylist.pagePlanError = Object.freeze({
|
||
code: "INVALID_RESPONSE",
|
||
message: "Native Bridge가 현재 요청과 정확히 일치하는 페이지 계산 결과를 보내지 않았습니다.",
|
||
retryable: true
|
||
});
|
||
state.namedPlaylist.error = state.namedPlaylist.pagePlanError.message;
|
||
renderNamedPlaylist();
|
||
renderPlayout();
|
||
}
|
||
|
||
function handleNamedPlaylistPagePlanResults(payload) {
|
||
const pending = state.namedPlaylist.pagePlanPending;
|
||
if (!pending || payload?.requestId !== pending.requestId) return;
|
||
const result = namedPlaylistWorkflow.normalizePagePlanResponse(payload, pending.request);
|
||
if (!result) return failNamedPagePlanProtocol(pending);
|
||
if (state.namedPlaylist.restoration !== pending.restoration) return;
|
||
try {
|
||
const restoration = namedPlaylistWorkflow.applyPagePlanResponse(
|
||
pending.restoration,
|
||
result);
|
||
const materialized = materializeNamedRestoration(restoration);
|
||
state.namedPlaylist.restoration = restoration;
|
||
state.namedPlaylist.guardByEntryId = materialized.guards;
|
||
state.namedPlaylist.pagePlanPending = null;
|
||
state.namedPlaylist.pagePlanError = null;
|
||
state.namedPlaylist.status = "loaded";
|
||
state.namedPlaylist.error = "";
|
||
renderPlaylist();
|
||
renderNamedPlaylist();
|
||
renderPlayout();
|
||
const empty = result.plans.filter(plan => plan.itemCount === 0).length;
|
||
const truncated = result.plans.filter(plan => plan.isTruncated).length;
|
||
addLog(`DB 페이지 사전조회 ${result.plans.length}개 완료 · 결과 없음 ${empty}개 · 20페이지 제한 ${truncated}개`);
|
||
showToast(empty
|
||
? `페이지 조회를 완료했지만 ${empty}개 장면은 DB 결과가 없어 PREPARE할 수 없습니다.`
|
||
: "현재 DB 결과로 페이지 계산을 완료했습니다.");
|
||
} catch {
|
||
failNamedPagePlanProtocol(pending);
|
||
}
|
||
}
|
||
|
||
function handleNamedPlaylistPagePlanError(payload) {
|
||
const pending = state.namedPlaylist.pagePlanPending;
|
||
if (!pending || payload?.requestId !== pending.requestId) return;
|
||
const error = namedPlaylistWorkflow.normalizePagePlanError(payload, pending.requestId);
|
||
if (!error) return failNamedPagePlanProtocol(pending);
|
||
state.namedPlaylist.pagePlanPending = null;
|
||
state.namedPlaylist.status = "error";
|
||
state.namedPlaylist.pagePlanError = error;
|
||
state.namedPlaylist.error = `${error.message} [${error.code}] 자동 재시도하지 않습니다.`;
|
||
renderNamedPlaylist();
|
||
renderPlayout();
|
||
addLog(`DB 페이지 사전조회 오류 · ${error.code}`);
|
||
showToast(state.namedPlaylist.error);
|
||
}
|
||
|
||
function handleNamedPlaylistMutationResults(payload) {
|
||
const result = namedPlaylistWorkflow.normalizeMutationResponse(payload);
|
||
if (!result) {
|
||
const operation = state.namedPlaylist.pending?.operation;
|
||
if (namedWriteOperations.has(operation)) failNamedPlaylistProtocol(operation);
|
||
return;
|
||
}
|
||
if (!namedPendingMatches(result.operation, result)) return;
|
||
state.namedPlaylist.pending = null;
|
||
state.namedPlaylist.status = "ready";
|
||
state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || result.writeQuarantined;
|
||
if (result.writeQuarantined) state.namedPlaylist.canMutate = false;
|
||
if (result.operation === "delete") {
|
||
state.namedPlaylist.definitions = state.namedPlaylist.definitions.filter(
|
||
value => value.programCode !== result.programCode);
|
||
if (state.namedPlaylist.loadedDefinition?.programCode === result.programCode) {
|
||
state.namedPlaylist.loadedDefinition = null;
|
||
}
|
||
state.namedPlaylist.selectedProgramCode = "";
|
||
} else {
|
||
state.namedPlaylist.selectAfterRefresh = result.programCode;
|
||
}
|
||
addLog(`DB 플레이리스트 ${result.operation.toLocaleUpperCase("en-US")} 완료 · ${result.programCode}`);
|
||
showToast("DB 플레이리스트 작업이 완료되었습니다.");
|
||
requestNamedPlaylistList();
|
||
}
|
||
|
||
function handleNamedPlaylistError(payload) {
|
||
const error = namedPlaylistWorkflow.normalizeError(payload);
|
||
const pending = state.namedPlaylist.pending;
|
||
if (!error || !pending || error.requestId !== pending.requestId || error.operation !== pending.operation) return;
|
||
state.namedPlaylist.pending = null;
|
||
state.namedPlaylist.status = "error";
|
||
state.namedPlaylist.writeQuarantined = state.namedPlaylist.writeQuarantined || error.writeQuarantined;
|
||
if (state.namedPlaylist.writeQuarantined) state.namedPlaylist.canMutate = false;
|
||
state.namedPlaylist.error = `${error.message} [${error.code}]${error.outcomeUnknown ? " 결과를 확인할 수 없어 자동 재시도하지 않습니다." : ""}`;
|
||
renderNamedPlaylist();
|
||
addLog(`DB 플레이리스트 ${error.operation.toLocaleUpperCase("en-US")} 오류 · ${error.code}`);
|
||
showToast(state.namedPlaylist.error);
|
||
}
|
||
|
||
function normalizePlayoutState(value) {
|
||
const normalized = String(value || "").trim().toLocaleLowerCase("en-US").replace(/[\s_-]+/g, "");
|
||
if (["prepared", "ready", "cued"].includes(normalized)) return "PREPARED";
|
||
if (["program", "onair", "takein", "live"].includes(normalized)) return "PROGRAM";
|
||
if (["dryrun", "preview", "simulation", "simulated"].includes(normalized)) return "DRY RUN";
|
||
return "IDLE";
|
||
}
|
||
|
||
function normalizePlayoutMode(value) {
|
||
return String(value || "unknown").trim().toLocaleLowerCase("en-US").replace(/[\s_]+/g, "-");
|
||
}
|
||
|
||
function normalizeScenePreviewFields(value) {
|
||
if (!Array.isArray(value) || value.length > 4096) return [];
|
||
return value.flatMap(field => {
|
||
if (!field || typeof field !== "object") return [];
|
||
const kind = typeof field.kind === "string" ? field.kind : "";
|
||
const name = typeof field.name === "string" ? field.name : "";
|
||
const text = typeof field.value === "string" ? field.value : "";
|
||
if (!/^[a-z-]{1,32}$/.test(kind) || !name || name.length > 128 ||
|
||
text.length > 16384 || /[\u0000\r\n]/.test(name + text)) return [];
|
||
return [{ kind, name, value: text }];
|
||
});
|
||
}
|
||
|
||
function applyRefreshState(payload) {
|
||
state.playout.refreshActive = payload.refreshActive === true;
|
||
state.playout.refreshNextAt = payload.refreshNextAt || null;
|
||
state.playout.refreshLastSuccessAt = payload.refreshLastSuccessAt || null;
|
||
state.playout.refreshFaulted = payload.refreshFaulted === true;
|
||
state.playout.refreshFaultCode = typeof payload.refreshFaultCode === "string"
|
||
? payload.refreshFaultCode
|
||
: null;
|
||
state.playout.refreshFaultMessage = typeof payload.refreshFaultMessage === "string"
|
||
? payload.refreshFaultMessage
|
||
: null;
|
||
if (state.playout.refreshFaulted && state.playout.error?.outcomeUnknown !== true) {
|
||
state.playout.error = {
|
||
code: state.playout.refreshFaultCode || "REFRESH_FAULT",
|
||
message: state.playout.refreshFaultMessage ||
|
||
"자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면을 확인하세요.",
|
||
retryable: false,
|
||
outcomeUnknown: false,
|
||
nonClearable: true,
|
||
isRefreshFault: true
|
||
};
|
||
} else if (playoutSafety.shouldClearRefreshError(
|
||
state.playout.error,
|
||
state.playout.refreshFaulted)) {
|
||
state.playout.error = null;
|
||
}
|
||
}
|
||
|
||
function renderSceneDataPreview() {
|
||
elements.sceneDataPreview.replaceChildren();
|
||
if (state.playout.previewFields.length === 0) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "scene-data-empty";
|
||
empty.textContent = "PREPARE 후 실제 DTO → mutation 값을 표시합니다.";
|
||
elements.sceneDataPreview.append(empty);
|
||
return;
|
||
}
|
||
|
||
const fragment = document.createDocumentFragment();
|
||
for (const field of state.playout.previewFields) {
|
||
const row = document.createElement("div");
|
||
row.className = "scene-data-row";
|
||
const kind = document.createElement("span");
|
||
kind.textContent = field.kind;
|
||
const name = document.createElement("strong");
|
||
name.textContent = field.name;
|
||
const value = document.createElement("code");
|
||
value.textContent = field.value;
|
||
row.append(kind, name, value);
|
||
fragment.append(row);
|
||
}
|
||
elements.sceneDataPreview.append(fragment);
|
||
}
|
||
|
||
function isDryRunMode(mode = state.playout.mode) {
|
||
return /dry-?run|preview|simulat/.test(normalizePlayoutMode(mode));
|
||
}
|
||
|
||
function isLiveMode(mode = state.playout.mode) {
|
||
return /live|program/.test(normalizePlayoutMode(mode));
|
||
}
|
||
|
||
function isTestMode(mode = state.playout.mode) {
|
||
return /test/.test(normalizePlayoutMode(mode));
|
||
}
|
||
|
||
function playoutDisplayState() {
|
||
if (isDryRunMode()) return "DRY RUN";
|
||
if (isTestMode() && state.playout.engineState === "PROGRAM") return "TEST ON AIR";
|
||
return state.playout.engineState;
|
||
}
|
||
|
||
function setStatusDot(element, status) {
|
||
element.classList.remove("pending", "healthy", "error", "warn", "ok", "unconfigured");
|
||
element.classList.add(status);
|
||
}
|
||
|
||
function cueFromItem(item) {
|
||
if (!item) return null;
|
||
return { code: String(item.code), title: String(item.title), detail: String(item.detail) };
|
||
}
|
||
|
||
function workflowEntryFromItem(item) {
|
||
const selection = item?.selection && typeof item.selection === "object"
|
||
? item.selection
|
||
: {
|
||
groupCode: String(item?.groupCode || item?.market || ""),
|
||
subject: String(item?.subject || item?.title || ""),
|
||
graphicType: String(item?.graphicType || item?.detail || ""),
|
||
subtype: String(item?.subtype || ""),
|
||
dataCode: String(item?.dataCode || "")
|
||
};
|
||
return {
|
||
id: String(item.id),
|
||
code: String(item.code),
|
||
enabled: item.enabled !== false,
|
||
fadeDuration: Number.isInteger(Number(item.fadeDuration))
|
||
? Number(item.fadeDuration)
|
||
: DEFAULT_FADE_DURATION,
|
||
selection: {
|
||
groupCode: String(selection.groupCode || ""),
|
||
subject: String(selection.subject || ""),
|
||
graphicType: String(selection.graphicType || ""),
|
||
subtype: String(selection.subtype || ""),
|
||
dataCode: String(selection.dataCode || "")
|
||
}
|
||
};
|
||
}
|
||
|
||
function findCueByCode(code) {
|
||
if (!code) return null;
|
||
const current = state.playlist[state.currentIndex];
|
||
if (String(current?.code) === String(code)) return cueFromItem(current);
|
||
return cueFromItem(state.playlist.find(item => String(item.code) === String(code)));
|
||
}
|
||
|
||
function renderPlayout() {
|
||
const displayState = playoutDisplayState();
|
||
const pending = state.playout.pending;
|
||
const dryRun = isDryRunMode();
|
||
const liveMode = isLiveMode();
|
||
const testMode = isTestMode();
|
||
const bridgeReady = Boolean(nativeBridge);
|
||
const outcomeUnknown = state.playout.nativeOutcomeUnknown ||
|
||
state.playout.sessionQuarantined ||
|
||
state.playout.error?.outcomeUnknown === true;
|
||
const commandReady = bridgeReady && !pending && !outcomeUnknown && state.playout.commandAvailable;
|
||
const mutationCommandReady = commandReady && !state.playout.refreshFaulted;
|
||
const namedPrepareBlocked = namedPlaylistBusy() ||
|
||
namedPlaylistPrepareBlockers().length > 0;
|
||
const candlePrepareBlocked = state.candleOptions.invalidIds.length > 0;
|
||
const manualFinancialBlocked = manualFinancialPrepareBlocked();
|
||
const currentCue = cueFromItem(state.playlist[state.currentIndex]);
|
||
const prepared = state.playout.engineState === "PREPARED" || Boolean(state.playout.preparedCode);
|
||
const onAir = state.playout.engineState === "PROGRAM" || Boolean(state.playout.onAirCode);
|
||
const takeInSafe = dryRun || ((testMode || liveMode) && state.playout.liveTakeInAllowed);
|
||
|
||
elements.playoutState.textContent = displayState;
|
||
elements.playoutState.classList.remove("neutral", "live", "test-on-air", "dry-run", "error");
|
||
if (displayState === "IDLE") elements.playoutState.classList.add("neutral");
|
||
if (displayState === "PROGRAM") elements.playoutState.classList.add("live");
|
||
if (displayState === "TEST ON AIR") elements.playoutState.classList.add("test-on-air");
|
||
if (displayState === "DRY RUN") elements.playoutState.classList.add("dry-run");
|
||
|
||
elements.tornadoProcessState.textContent = state.playout.processDetected ? "감지됨" : "미감지";
|
||
setStatusDot(elements.tornadoProcessDot, state.playout.processDetected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
|
||
const transitionalConnection = ["connecting", "reconnecting", "disconnecting"].includes(state.playout.connectionState);
|
||
const connectionLabels = {
|
||
"disabled": "Disabled",
|
||
"dry-run-ready": "DryRunReady",
|
||
"disconnected": "Disconnected",
|
||
"connecting": "Connecting",
|
||
"connected": "Connected",
|
||
"reconnecting": "Reconnecting",
|
||
"disconnecting": "Disconnecting",
|
||
"faulted": "Faulted",
|
||
"outcome-unknown": "OutcomeUnknown",
|
||
"disposed": "Disposed",
|
||
"unavailable": "Unavailable"
|
||
};
|
||
const lastKtapLabels = {
|
||
"attempted": "마지막 KTAP 결과 미확인",
|
||
"accepted-unconfirmed": "마지막 KTAP 수락 · OnHello 미확인",
|
||
"failed": "마지막 KTAP 실패"
|
||
};
|
||
const currentConnectionLabel = connectionLabels[state.playout.connectionState] || "Unavailable";
|
||
elements.playoutConnectionState.textContent = dryRun
|
||
? "KTAP 미호출"
|
||
: (state.playout.connected && state.playout.lastKtapConnectState === "accepted-unconfirmed"
|
||
? "KTAP 수락 · OnHello 미확인"
|
||
: `${currentConnectionLabel}${lastKtapLabels[state.playout.lastKtapConnectState]
|
||
? ` · ${lastKtapLabels[state.playout.lastKtapConnectState]}`
|
||
: ""}`);
|
||
setStatusDot(elements.playoutConnectionDot, state.playout.connected
|
||
? (state.playout.ktapHelloObserved === true ? "healthy" : "warn")
|
||
: (transitionalConnection ? "warn" : (bridgeReady ? "error" : "unconfigured")));
|
||
|
||
const safetyContainer = elements.playoutSafetyMode.closest(".playout-health-item");
|
||
safetyContainer.classList.remove("live-allowed", "live-locked");
|
||
if (dryRun) {
|
||
elements.playoutSafetyMode.textContent = "DRY RUN · PROGRAM 차단";
|
||
} else if (testMode && state.playout.liveTakeInAllowed) {
|
||
elements.playoutSafetyMode.textContent = "TEST · 전용 출력 허용";
|
||
safetyContainer.classList.add("live-allowed");
|
||
} else if (testMode) {
|
||
elements.playoutSafetyMode.textContent = "TEST TAKE IN 잠금";
|
||
safetyContainer.classList.add("live-locked");
|
||
} else if (liveMode && state.playout.liveTakeInAllowed) {
|
||
elements.playoutSafetyMode.textContent = "LIVE 허용";
|
||
safetyContainer.classList.add("live-allowed");
|
||
} else if (liveMode) {
|
||
elements.playoutSafetyMode.textContent = "LIVE TAKE IN 잠금";
|
||
safetyContainer.classList.add("live-locked");
|
||
} else {
|
||
elements.playoutSafetyMode.textContent = bridgeReady ? "모드 확인 중" : "미리보기 전용";
|
||
}
|
||
|
||
const changedLabel = state.playout.changedAtMs
|
||
? ` · ${new Date(state.playout.changedAtMs).toLocaleTimeString("ko-KR", { hour12: false })}`
|
||
: "";
|
||
const pageLabel = state.playout.pageCount > 0
|
||
? ` · 페이지 ${state.playout.pageIndex + 1}/${state.playout.pageCount}`
|
||
: "";
|
||
elements.playoutStatusMessage.textContent = pending
|
||
? `${pending.command.toUpperCase()} 요청 처리 중 · ${pending.cue?.code || "송출 엔진"}`
|
||
: `${state.playout.message || "송출 상태를 확인할 수 없습니다."}${pageLabel}${changedLabel}`;
|
||
elements.playoutStatusMessage.classList.toggle("pending", Boolean(pending));
|
||
elements.playoutStatusMessage.classList.toggle(
|
||
"error",
|
||
Boolean(state.playout.error) || state.playout.nativeOutcomeUnknown || state.playout.refreshFaulted);
|
||
elements.playoutSceneCode.textContent = state.playout.onAirCode || state.playout.preparedCode || "—";
|
||
elements.playoutEntryId.textContent = state.playout.currentEntryId || "—";
|
||
elements.playoutBuilderKey.textContent = state.playout.builderKey || "—";
|
||
elements.playoutPageNumber.textContent = state.playout.pageCount > 0
|
||
? `${state.playout.pageIndex + 1}/${state.playout.pageCount}`
|
||
: "—";
|
||
elements.playoutPageSize.textContent = state.playout.pageSize > 0
|
||
? String(state.playout.pageSize)
|
||
: "—";
|
||
elements.playoutItemCount.textContent = String(state.playout.itemCount);
|
||
elements.playoutCurrentRows.textContent = String(state.playout.currentPageItemCount);
|
||
elements.playoutLastPage.textContent = state.playout.isLastPage ? "YES" : "NO";
|
||
elements.playoutNextKind.textContent = state.playout.nextKind.replaceAll("-", " ").toUpperCase();
|
||
const refreshNextMs = parseChangedAt(state.playout.refreshNextAt);
|
||
elements.playoutRefreshState.textContent = state.playout.refreshFaulted
|
||
? "FAULT"
|
||
: (state.playout.refreshActive
|
||
? `ACTIVE · ${refreshNextMs ? new Date(refreshNextMs).toLocaleTimeString("ko-KR", { hour12: false }) : "WAIT"}`
|
||
: "STOPPED");
|
||
renderSceneDataPreview();
|
||
|
||
elements.playoutSummary.classList.remove("ready", "dry-run", "error");
|
||
if (outcomeUnknown) {
|
||
elements.playoutSummary.classList.add("error");
|
||
elements.playoutSummaryBadge.textContent = "UNKNOWN";
|
||
elements.playoutSummaryDetail.textContent = "출력 상태 확인 후 앱 재시작 필요";
|
||
} else if (state.playout.refreshFaulted) {
|
||
elements.playoutSummary.classList.add("error");
|
||
elements.playoutSummaryBadge.textContent = "REFRESH FAULT";
|
||
elements.playoutSummaryDetail.textContent = "자동 갱신 중단 · TAKE OUT 후 확인 필요";
|
||
} else if (dryRun) {
|
||
elements.playoutSummary.classList.add("dry-run");
|
||
elements.playoutSummaryBadge.textContent = "DRY RUN";
|
||
elements.playoutSummaryDetail.textContent = bridgeReady ? "KTAP 미호출 · 모니터 기록 없음(정상)" : "브라우저 미리보기 전용";
|
||
} else if (state.playout.connected) {
|
||
elements.playoutSummary.classList.add("ready");
|
||
elements.playoutSummaryBadge.textContent = testMode
|
||
? (state.playout.liveTakeInAllowed ? "TEST READY" : "TEST LOCKED")
|
||
: (state.playout.liveTakeInAllowed ? "LIVE READY" : "CONNECTED");
|
||
elements.playoutSummaryDetail.textContent = state.playout.lastKtapConnectState === "accepted-unconfirmed"
|
||
? "KTAP 수락 · OnHello/[R]/[S] 확인 필요"
|
||
: (testMode
|
||
? "테스트 전용 출력 · 안전 게이트 적용"
|
||
: (state.playout.processDetected ? "Tornado2 프로세스 · 엔진 연결됨" : "엔진 연결됨 · 프로세스 미감지"));
|
||
} else {
|
||
elements.playoutSummary.classList.add("error");
|
||
elements.playoutSummaryBadge.textContent = bridgeReady ? "OFFLINE" : "PREVIEW";
|
||
elements.playoutSummaryDetail.textContent = bridgeReady ? "Tornado 어댑터 연결 필요" : "Native Bridge 없음";
|
||
}
|
||
|
||
elements.prepareButton.disabled = !mutationCommandReady || !currentCue || namedPrepareBlocked ||
|
||
candlePrepareBlocked || manualFinancialBlocked;
|
||
elements.takeInButton.disabled = !mutationCommandReady || !currentCue || !takeInSafe || onAir;
|
||
elements.nextButton.disabled = !mutationCommandReady || !onAir || state.playlist.length === 0 ||
|
||
state.playout.nextKind === "end-of-playlist";
|
||
elements.takeOutButton.disabled = !commandReady || (state.playout.engineState === "IDLE" && !state.playout.preparedCode && !state.playout.onAirCode);
|
||
const playlistLocked = isPlaylistSnapshotLocked();
|
||
const operatorLocked = operatorUiLocked();
|
||
manualListsUi?.setLocked(operatorLocked);
|
||
manualFinancialUi?.setLocked(operatorLocked);
|
||
operatorCatalogUi?.setLocked(operatorLocked);
|
||
elements.toggleAllEnabled.disabled = playlistLocked;
|
||
elements.selectAll.disabled = playlistLocked;
|
||
elements.moveUpButton.disabled = playlistLocked;
|
||
elements.moveDownButton.disabled = playlistLocked;
|
||
elements.deleteButton.disabled = playlistLocked;
|
||
elements.deleteAllButton.disabled = playlistLocked;
|
||
elements.loadButton.disabled = playlistLocked || namedPlaylistBusy();
|
||
renderGlobalCandleOptions();
|
||
renderNamedPlaylist();
|
||
renderStockWorkflow();
|
||
renderFixedWorkflow();
|
||
renderIndustryWorkflow();
|
||
renderComparisonWorkflow();
|
||
renderThemeWorkflow();
|
||
renderOverseasWorkflow();
|
||
renderExpertWorkflow();
|
||
renderTradingHaltWorkflow();
|
||
renderBackgroundControls();
|
||
renderCatalog();
|
||
for (const button of [elements.prepareButton, elements.takeInButton, elements.nextButton, elements.takeOutButton]) {
|
||
button.setAttribute("aria-busy", String(Boolean(pending)));
|
||
}
|
||
|
||
elements.playoutError.hidden = !state.playout.error;
|
||
elements.playoutErrorMessage.textContent = state.playout.error?.message || "";
|
||
document.querySelector("#clearPlayoutErrorButton").textContent = outcomeUnknown
|
||
? "닫기 · 잠금 유지"
|
||
: "닫기";
|
||
}
|
||
|
||
function clearPlayoutError(render = true) {
|
||
if (!playoutSafety.canClearCommandError(
|
||
state.playout.error,
|
||
state.playout.sessionQuarantined || state.playout.nativeOutcomeUnknown)) {
|
||
if (render) showToast("결과 미확인 잠금은 앱을 다시 시작하기 전까지 해제할 수 없습니다.");
|
||
return;
|
||
}
|
||
state.playout.error = null;
|
||
if (render) renderPlayout();
|
||
}
|
||
|
||
function showPlayoutError({ code = "PLAYOUT_ERROR", message, retryable = false, outcomeUnknown = false }) {
|
||
const details = [message || "송출 명령을 완료하지 못했습니다."];
|
||
if (code) details.push(`[${code}]`);
|
||
if (outcomeUnknown) details.push("실제 송출 결과를 확인하세요.");
|
||
else if (retryable) details.push("상태 확인 후 재시도할 수 있습니다.");
|
||
state.playout.error = { code, message: details.join(" "), retryable, outcomeUnknown };
|
||
if (outcomeUnknown || code === "WEB_TIMEOUT") state.playout.sessionQuarantined = true;
|
||
renderPlayout();
|
||
showToast(state.playout.error.message);
|
||
}
|
||
|
||
function resolveCommand(command) {
|
||
const current = state.playlist[state.currentIndex];
|
||
if (command === "prepare") {
|
||
return current ? { cue: cueFromItem(current), targetIndex: state.currentIndex } : null;
|
||
}
|
||
if (command === "take-in") {
|
||
const cue = state.playout.preparedCue || findCueByCode(state.playout.preparedCode);
|
||
return cue ? { cue, targetIndex: state.currentIndex } : null;
|
||
}
|
||
if (command === "next") {
|
||
const cue = findCueByCode(state.playout.onAirCode) || current;
|
||
return cue ? { cue: cueFromItem(cue), targetIndex: state.currentIndex } : null;
|
||
}
|
||
if (command === "take-out") return { cue: null, targetIndex: state.currentIndex };
|
||
return null;
|
||
}
|
||
|
||
function requestPlayoutCommand(command) {
|
||
if (!nativeBridge) {
|
||
showToast("브라우저 미리보기에서는 송출 명령을 실행하지 않습니다.");
|
||
return false;
|
||
}
|
||
if (state.playout.pending) return false;
|
||
const blockReason = playoutSafety.commandBlockReason({
|
||
pending: state.playout.pending,
|
||
outcomeUnknown: state.playout.nativeOutcomeUnknown ||
|
||
state.playout.sessionQuarantined ||
|
||
state.playout.error?.outcomeUnknown === true,
|
||
commandAvailable: state.playout.commandAvailable,
|
||
refreshFaulted: state.playout.refreshFaulted,
|
||
engineState: state.playout.engineState,
|
||
preparedCode: state.playout.preparedCode,
|
||
onAirCode: state.playout.onAirCode
|
||
}, command);
|
||
if (blockReason) {
|
||
const message = {
|
||
"outcome-unknown": "출력 상태가 불명확합니다. 실제 출력 확인 후 앱을 다시 시작하세요.",
|
||
"unavailable": "Tornado 송출 명령을 사용할 수 없는 상태입니다.",
|
||
"refresh-fault": "자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면을 확인하세요.",
|
||
"next-requires-on-air": "TAKE IN 이후에만 NEXT를 실행할 수 있습니다.",
|
||
"take-in-requires-prepare": "먼저 PREPARE를 완료하세요.",
|
||
"nothing-to-take-out": "종료할 송출 장면이 없습니다."
|
||
}[blockReason] || "다른 송출 명령을 처리하고 있습니다.";
|
||
showToast(message);
|
||
return false;
|
||
}
|
||
|
||
if (command === "prepare") {
|
||
if (namedPlaylistBusy()) {
|
||
showToast("DB 플레이리스트 및 현재 페이지 조회가 끝난 뒤 PREPARE를 실행하세요.");
|
||
return false;
|
||
}
|
||
const namedBlockers = namedPlaylistPrepareBlockers();
|
||
if (namedBlockers.length) {
|
||
state.namedPlaylist.open = true;
|
||
renderNamedPlaylist();
|
||
showToast("DB에서 불러온 행의 컷 복원 또는 현재 페이지 재계산이 끝나지 않아 PREPARE를 차단했습니다.");
|
||
return false;
|
||
}
|
||
if (manualFinancialPrepareBlocked()) {
|
||
showToast("수동 재무 항목의 현재 DB 행·종목·장면 선택 재검증이 끝나지 않아 PREPARE를 차단했습니다.");
|
||
return false;
|
||
}
|
||
if (!synchronizeGlobalCandleOptions(false)) {
|
||
showToast("신뢰할 수 없는 캔들 항목이 있어 PREPARE를 차단했습니다.");
|
||
return false;
|
||
}
|
||
if (!synchronizeFixedEntries(false)) return false;
|
||
if (!synchronizeIndustryEntries(false)) return false;
|
||
if (!synchronizeComparisonEntries()) return false;
|
||
if (!synchronizeThemeEntries()) return false;
|
||
if (!synchronizeOverseasEntries()) return false;
|
||
if (!synchronizeExpertEntries()) return false;
|
||
if (!synchronizeTradingHaltEntries()) return false;
|
||
}
|
||
|
||
const resolved = resolveCommand(command);
|
||
if (!resolved) {
|
||
const message = command === "take-in" ? "먼저 PREPARE를 완료하세요." : "먼저 그래픽 항목을 선택하세요.";
|
||
showToast(message);
|
||
return false;
|
||
}
|
||
|
||
const requestId = createId();
|
||
const pending = {
|
||
requestId,
|
||
command,
|
||
cue: resolved.cue,
|
||
targetIndex: resolved.targetIndex,
|
||
timeoutId: null
|
||
};
|
||
state.playout.pending = pending;
|
||
clearPlayoutError(false);
|
||
renderPlayout();
|
||
addLog(`${resolved.cue?.code || "ENGINE"} ${command.toUpperCase()} 요청`);
|
||
|
||
const payload = { requestId, command };
|
||
if (command === "prepare") {
|
||
payload.playlist = state.playlist.map(workflowEntryFromItem);
|
||
payload.selectedIndex = state.currentIndex;
|
||
}
|
||
postNative("playout-command", payload);
|
||
|
||
pending.timeoutId = setTimeout(() => {
|
||
if (state.playout.pending?.requestId !== requestId) return;
|
||
state.playout.pending = null;
|
||
if (command === "prepare") state.playout.takeInAfterPrepare = false;
|
||
postNative("playout-timeout-quarantine", { requestId, command });
|
||
showPlayoutError({
|
||
code: "WEB_TIMEOUT",
|
||
message: "송출 엔진 응답 시간이 초과되었습니다.",
|
||
retryable: false,
|
||
outcomeUnknown: true
|
||
});
|
||
addLog(`${command.toUpperCase()} 응답 시간 초과 · 결과 미확인`);
|
||
requestPlayoutStatus();
|
||
}, state.playout.responseTimeoutMs);
|
||
return true;
|
||
}
|
||
|
||
function requestLegacyTakeInShortcut() {
|
||
const action = playoutSafety.legacyTakeInShortcutAction({
|
||
pending: Boolean(state.playout.pending),
|
||
takeInAllowed: isDryRunMode() ||
|
||
((isTestMode() || isLiveMode()) && state.playout.liveTakeInAllowed),
|
||
engineState: state.playout.engineState,
|
||
preparedCode: state.playout.preparedCode,
|
||
onAirCode: state.playout.onAirCode
|
||
});
|
||
if (action === "blocked") return false;
|
||
if (action === "take-in-locked") {
|
||
showToast("현재 검증 회차는 TAKE IN 승인이 잠겨 있어 PREPARE를 자동 실행하지 않습니다.");
|
||
return false;
|
||
}
|
||
if (action === "next-required") {
|
||
showToast("현재 장면이 송출 중입니다. 다음 장면은 NEXT를 사용하세요.");
|
||
return false;
|
||
}
|
||
if (action === "take-in") {
|
||
state.playout.takeInAfterPrepare = false;
|
||
return requestPlayoutCommand("take-in");
|
||
}
|
||
|
||
state.playout.takeInAfterPrepare = true;
|
||
const started = requestPlayoutCommand("prepare");
|
||
if (!started) state.playout.takeInAfterPrepare = false;
|
||
else addLog("F8/TAKE IN · PREPARE 성공 후 1회 TAKE IN 예약");
|
||
return started;
|
||
}
|
||
|
||
function renderBackgroundControls() {
|
||
const background = state.background;
|
||
const locked = isPlaylistSnapshotLocked();
|
||
elements.backgroundFileName.textContent = background.enabled
|
||
? (background.fileName || "선택된 배경")
|
||
: "배경 없음";
|
||
elements.backgroundStateText.textContent = background.error || background.message ||
|
||
(background.enabled ? `${background.kind.toLocaleUpperCase("en-US")} 배경 사용` : "배경 사용 안 함");
|
||
elements.chooseBackgroundButton.disabled = !nativeBridge || locked ||
|
||
background.pending || !background.canChange;
|
||
elements.toggleBackgroundButton.disabled = !nativeBridge || locked ||
|
||
background.pending || !background.canChange;
|
||
elements.chooseBackgroundButton.setAttribute("aria-busy", String(background.pending));
|
||
elements.toggleBackgroundButton.setAttribute("aria-busy", String(background.pending));
|
||
}
|
||
|
||
function beginBackgroundRequest(type) {
|
||
if (!nativeBridge) {
|
||
showToast("브라우저 미리보기에서는 네이티브 배경 파일을 선택할 수 없습니다.");
|
||
return false;
|
||
}
|
||
if (isPlaylistSnapshotLocked() || state.background.pending || !state.background.canChange) {
|
||
showToast("배경은 IDLE 상태에서 다음 PREPARE 전에만 변경할 수 있습니다.");
|
||
return false;
|
||
}
|
||
const requestId = createId();
|
||
state.background.requestId = requestId;
|
||
state.background.pending = true;
|
||
state.background.error = "";
|
||
state.background.message = type === "choose-background"
|
||
? "배경 파일 선택 창을 기다리고 있습니다."
|
||
: "배경 사용 상태를 변경하고 있습니다.";
|
||
renderBackgroundControls();
|
||
postNative(type, { requestId });
|
||
return true;
|
||
}
|
||
|
||
function requestBackgroundStatus() {
|
||
if (!nativeBridge) {
|
||
state.background.canChange = false;
|
||
state.background.pending = false;
|
||
state.background.message = "브라우저 미리보기 · 배경 변경 없음";
|
||
renderBackgroundControls();
|
||
return;
|
||
}
|
||
const requestId = createId();
|
||
state.background.requestId = requestId;
|
||
postNative("request-background-status", { requestId });
|
||
}
|
||
|
||
function requestBackgroundSelection() {
|
||
return beginBackgroundRequest("choose-background");
|
||
}
|
||
|
||
function requestBackgroundToggle() {
|
||
return beginBackgroundRequest("toggle-background");
|
||
}
|
||
|
||
function notifyLegacyBackgroundShortcut() {
|
||
return requestBackgroundSelection();
|
||
}
|
||
|
||
function handleBackgroundStatus(payload) {
|
||
if (!payload || typeof payload !== "object" ||
|
||
(payload.requestId && payload.requestId !== state.background.requestId)) return;
|
||
const kind = payload.kind;
|
||
const fileName = payload.fileName;
|
||
const message = payload.message;
|
||
const validText = value => typeof value === "string" && value.length <= 260 &&
|
||
!/[\u0000-\u001f\u007f]/.test(value);
|
||
if (typeof payload.enabled !== "boolean" ||
|
||
!["none", "texture", "video"].includes(kind) ||
|
||
!validText(fileName) || !validText(message) ||
|
||
typeof payload.canChange !== "boolean" ||
|
||
(payload.enabled && (kind === "none" || !fileName)) ||
|
||
(!payload.enabled && kind !== "none")) {
|
||
state.background.pending = false;
|
||
state.background.error = "배경 상태 응답 형식이 올바르지 않습니다.";
|
||
renderBackgroundControls();
|
||
return;
|
||
}
|
||
Object.assign(state.background, {
|
||
enabled: payload.enabled,
|
||
kind,
|
||
fileName,
|
||
canChange: payload.canChange,
|
||
pending: false,
|
||
message,
|
||
error: ""
|
||
});
|
||
renderBackgroundControls();
|
||
}
|
||
|
||
function handleBackgroundError(payload) {
|
||
if (!payload || typeof payload !== "object" ||
|
||
payload.requestId !== state.background.requestId) return;
|
||
const message = typeof payload.message === "string" && payload.message.length <= 512 &&
|
||
!/[\u0000-\u001f\u007f]/.test(payload.message)
|
||
? payload.message
|
||
: "배경 설정을 변경하지 못했습니다.";
|
||
state.background.pending = false;
|
||
state.background.error = message;
|
||
renderBackgroundControls();
|
||
showToast(message);
|
||
}
|
||
|
||
function requestPlayoutStatus() {
|
||
if (!nativeBridge) return;
|
||
const requestId = createId();
|
||
state.playout.latestStatusRequestId = requestId;
|
||
postNative("request-playout-status", { requestId });
|
||
}
|
||
|
||
function parseChangedAt(value) {
|
||
const parsed = typeof value === "string" || typeof value === "number" ? Date.parse(value) : NaN;
|
||
return Number.isFinite(parsed) ? parsed : 0;
|
||
}
|
||
|
||
function handlePlayoutStatus(payload) {
|
||
if (!payload || typeof payload !== "object") return;
|
||
if (payload.requestId && payload.requestId !== state.playout.latestStatusRequestId) return;
|
||
const previousCueIndex = state.currentIndex;
|
||
const previousSnapshotLocked = isPlaylistSnapshotLocked();
|
||
|
||
const changedAtMs = parseChangedAt(payload.changedAt);
|
||
if (changedAtMs && state.playout.changedAtMs && changedAtMs < state.playout.changedAtMs) return;
|
||
|
||
state.playout.mode = normalizePlayoutMode(payload.mode);
|
||
state.playout.engineState = normalizePlayoutState(payload.state);
|
||
state.playout.connectionState = playoutSafety.normalizeConnectionState(payload.connectionState);
|
||
state.playout.processDetected = payload.processDetected === true;
|
||
state.playout.connected = payload.connected === true;
|
||
state.playout.lastKtapConnectState = ["not-attempted", "attempted", "accepted-unconfirmed", "failed"]
|
||
.includes(payload.lastKtapConnectState) ? payload.lastKtapConnectState : "not-attempted";
|
||
state.playout.ktapConnectAttempted = payload.ktapConnectAttempted === true;
|
||
state.playout.ktapConnectAccepted = payload.ktapConnectAccepted === true
|
||
? true
|
||
: (payload.ktapConnectAccepted === false ? false : null);
|
||
state.playout.ktapHelloObserved = payload.ktapHelloObserved === true
|
||
? true
|
||
: (payload.ktapHelloObserved === false ? false : null);
|
||
state.playout.networkMonitoringRecordExpected = payload.networkMonitoringRecordExpected === true
|
||
? true
|
||
: (payload.networkMonitoringRecordExpected === false ? false : null);
|
||
state.playout.networkMonitoringCheckRequired = payload.networkMonitoringCheckRequired === true;
|
||
state.playout.commandAvailable = payload.commandAvailable === true;
|
||
state.playout.liveTakeInAllowed = payload.liveTakeInAllowed === true;
|
||
state.playout.browserCorrelationQuarantined =
|
||
payload.browserCorrelationQuarantined === true;
|
||
state.playout.nativeOutcomeUnknown = payload.outcomeUnknown === true ||
|
||
state.playout.browserCorrelationQuarantined ||
|
||
state.playout.connectionState === "outcome-unknown";
|
||
state.playout.operationTimeoutMs = Number.isFinite(Number(payload.operationTimeoutMilliseconds))
|
||
? Number(payload.operationTimeoutMilliseconds)
|
||
: state.playout.operationTimeoutMs;
|
||
state.playout.responseTimeoutMs = playoutSafety.responseTimeoutFromNative(payload.operationTimeoutMilliseconds);
|
||
state.playout.message = typeof payload.message === "string" && payload.message.trim()
|
||
? payload.message.trim()
|
||
: "Tornado 송출 상태를 수신했습니다.";
|
||
state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null;
|
||
state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null;
|
||
state.playout.preparedCue = findCueByCode(state.playout.preparedCode);
|
||
state.playout.currentCueIndex = Number.isInteger(payload.currentCueIndex) ? payload.currentCueIndex : -1;
|
||
state.playout.currentEntryId = typeof payload.currentEntryId === "string"
|
||
? payload.currentEntryId
|
||
: null;
|
||
state.playout.builderKey = /^s[0-9]+$/.test(String(payload.builderKey || ""))
|
||
? String(payload.builderKey)
|
||
: null;
|
||
state.playout.pageIndex = Number.isInteger(payload.pageIndex) && payload.pageIndex >= 0 ? payload.pageIndex : 0;
|
||
state.playout.pageCount = Number.isInteger(payload.pageCount) && payload.pageCount >= 0 ? payload.pageCount : 0;
|
||
state.playout.pageSize = [0, 5, 6, 12].includes(payload.pageSize) ? payload.pageSize : 0;
|
||
state.playout.itemCount = Number.isInteger(payload.itemCount) && payload.itemCount >= 0 ? payload.itemCount : 0;
|
||
state.playout.currentPageItemCount = Number.isInteger(payload.currentPageItemCount) &&
|
||
payload.currentPageItemCount >= 0 ? payload.currentPageItemCount : 0;
|
||
state.playout.isLastPage = payload.isLastPage !== false;
|
||
state.playout.nextKind = ["none", "page-next", "playlist-next", "end-of-playlist"]
|
||
.includes(payload.nextKind) ? payload.nextKind : "none";
|
||
state.playout.previewFields = normalizeScenePreviewFields(payload.previewFields);
|
||
applyRefreshState(payload);
|
||
const authoritativeIndex = state.playout.currentEntryId
|
||
? state.playlist.findIndex(item => item.id === state.playout.currentEntryId)
|
||
: state.playout.currentCueIndex;
|
||
if (authoritativeIndex >= 0 && authoritativeIndex < state.playlist.length) {
|
||
state.currentIndex = authoritativeIndex;
|
||
}
|
||
state.playout.changedAt = payload.changedAt || null;
|
||
state.playout.changedAtMs = changedAtMs || state.playout.changedAtMs;
|
||
if (payload.requestId === state.playout.latestStatusRequestId) state.playout.latestStatusRequestId = null;
|
||
|
||
if (state.playout.nativeOutcomeUnknown && state.playout.error?.outcomeUnknown !== true) {
|
||
state.playout.error = {
|
||
code: "OUTCOME_UNKNOWN",
|
||
message: "송출 결과를 확인할 수 없습니다. 실제 출력 확인 후 앱을 다시 시작하세요.",
|
||
retryable: false,
|
||
outcomeUnknown: true
|
||
};
|
||
}
|
||
|
||
if (state.currentIndex !== previousCueIndex ||
|
||
isPlaylistSnapshotLocked() !== previousSnapshotLocked) {
|
||
renderPlaylist();
|
||
updatePreview();
|
||
} else {
|
||
renderPlayout();
|
||
}
|
||
const signature = [state.playout.mode, state.playout.engineState, state.playout.connectionState, state.playout.processDetected, state.playout.connected, state.playout.lastKtapConnectState, state.playout.commandAvailable, state.playout.liveTakeInAllowed, state.playout.nativeOutcomeUnknown].join("|");
|
||
if (signature !== state.playout.lastSignature) {
|
||
addLog(`Tornado 상태 · ${playoutDisplayState()} · ${state.playout.connected ? "연결" : "미연결"}`);
|
||
state.playout.lastSignature = signature;
|
||
}
|
||
}
|
||
|
||
function finishPendingCommand(payload) {
|
||
const pending = state.playout.pending;
|
||
if (!playoutSafety.matchesPendingCommand(pending, payload)) return null;
|
||
clearTimeout(pending.timeoutId);
|
||
state.playout.pending = null;
|
||
return pending;
|
||
}
|
||
|
||
function handlePlayoutCommandResult(payload) {
|
||
const pending = finishPendingCommand(payload);
|
||
if (!pending) return;
|
||
const takeInAfterPrepare = pending.command === "prepare" && state.playout.takeInAfterPrepare;
|
||
if (pending.command === "prepare") state.playout.takeInAfterPrepare = false;
|
||
if (payload.succeeded !== true) {
|
||
showPlayoutError({ message: payload.message || "송출 명령이 거부되었습니다.", retryable: true });
|
||
addLog(`${pending.command.toUpperCase()} 실패 · ${payload.message || "명령 거부"}`);
|
||
requestPlayoutStatus();
|
||
return;
|
||
}
|
||
|
||
clearPlayoutError(false);
|
||
if (payload.dryRun === true) state.playout.mode = "dry-run";
|
||
state.playout.engineState = normalizePlayoutState(payload.state);
|
||
state.playout.message = typeof payload.message === "string" && payload.message.trim()
|
||
? payload.message.trim()
|
||
: `${pending.command.toUpperCase()} 완료`;
|
||
state.playout.preparedCode = payload.preparedCode ? String(payload.preparedCode) : null;
|
||
state.playout.onAirCode = payload.onAirCode ? String(payload.onAirCode) : null;
|
||
state.playout.currentCueIndex = Number.isInteger(payload.currentCueIndex)
|
||
? payload.currentCueIndex
|
||
: state.playout.currentCueIndex;
|
||
state.playout.currentEntryId = typeof payload.currentEntryId === "string"
|
||
? payload.currentEntryId
|
||
: state.playout.currentEntryId;
|
||
state.playout.builderKey = /^s[0-9]+$/.test(String(payload.builderKey || ""))
|
||
? String(payload.builderKey)
|
||
: state.playout.builderKey;
|
||
state.playout.pageIndex = Number.isInteger(payload.pageIndex) ? payload.pageIndex : state.playout.pageIndex;
|
||
state.playout.pageCount = Number.isInteger(payload.pageCount) ? payload.pageCount : state.playout.pageCount;
|
||
state.playout.pageSize = [0, 5, 6, 12].includes(payload.pageSize) ? payload.pageSize : state.playout.pageSize;
|
||
state.playout.itemCount = Number.isInteger(payload.itemCount) ? payload.itemCount : state.playout.itemCount;
|
||
state.playout.currentPageItemCount = Number.isInteger(payload.currentPageItemCount)
|
||
? payload.currentPageItemCount
|
||
: state.playout.currentPageItemCount;
|
||
state.playout.isLastPage = payload.isLastPage !== false;
|
||
state.playout.nextKind = ["none", "page-next", "playlist-next", "end-of-playlist"]
|
||
.includes(payload.nextKind) ? payload.nextKind : state.playout.nextKind;
|
||
state.playout.previewFields = normalizeScenePreviewFields(payload.previewFields);
|
||
applyRefreshState(payload);
|
||
|
||
if (pending.command === "prepare") {
|
||
state.playout.preparedCue = pending.cue;
|
||
} else if (!state.playout.preparedCode) {
|
||
state.playout.preparedCue = null;
|
||
}
|
||
const authoritativeIndex = state.playout.currentEntryId
|
||
? state.playlist.findIndex(item => item.id === state.playout.currentEntryId)
|
||
: state.playout.currentCueIndex;
|
||
if (authoritativeIndex >= 0 && authoritativeIndex < state.playlist.length) {
|
||
state.currentIndex = authoritativeIndex;
|
||
}
|
||
renderPlaylist();
|
||
updatePreview();
|
||
|
||
renderPlayout();
|
||
const safetyLabel = payload.dryRun === true ? "DRY RUN · " : "";
|
||
addLog(`${pending.cue?.code || "ENGINE"} ${safetyLabel}${pending.command.toUpperCase()} 완료`);
|
||
showToast(state.playout.message);
|
||
if (takeInAfterPrepare) {
|
||
addLog("PREPARE 확인 · 예약된 TAKE IN 1회 실행");
|
||
setTimeout(() => requestPlayoutCommand("take-in"), 0);
|
||
} else {
|
||
requestPlayoutStatus();
|
||
}
|
||
}
|
||
|
||
function handlePlayoutCommandError(payload) {
|
||
const pending = finishPendingCommand(payload);
|
||
if (!pending) return;
|
||
if (pending.command === "prepare") state.playout.takeInAfterPrepare = false;
|
||
if (payload.outcomeUnknown === true) state.playout.nativeOutcomeUnknown = true;
|
||
showPlayoutError({
|
||
code: payload.code,
|
||
message: payload.message,
|
||
retryable: payload.retryable === true,
|
||
outcomeUnknown: payload.outcomeUnknown === true
|
||
});
|
||
addLog(`${pending.command.toUpperCase()} 오류 · ${payload.code || "PLAYOUT_ERROR"} · ${payload.message || "명령 실패"}`);
|
||
requestPlayoutStatus();
|
||
}
|
||
|
||
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) {
|
||
const sequence = payload?.sequence;
|
||
if (!playoutSafety.isNewerDatabaseStatusSequence(
|
||
state.database.latestSequence,
|
||
sequence)) return;
|
||
|
||
state.database.latestSequence = sequence;
|
||
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 liveRowValues(row, columns) {
|
||
const values = new Map();
|
||
columns.forEach((column, index) => {
|
||
values.set(String(column.key).toLocaleUpperCase("en-US"), cellValue(row, column, index));
|
||
});
|
||
return values;
|
||
}
|
||
|
||
function firstLiveValue(values, keys) {
|
||
for (const key of keys) {
|
||
const value = values.get(key);
|
||
if (value !== null && value !== undefined && String(value).trim()) return String(value).trim();
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function liveSelectionGroup(table, item) {
|
||
const name = String(table?.name || "").toLocaleUpperCase("ko-KR");
|
||
let group;
|
||
if (name.includes("NXT") && state.activeMarket === "kospi") group = "NXT_KOSPI";
|
||
else if (name.includes("NXT") && state.activeMarket === "kosdaq") group = "NXT_KOSDAQ";
|
||
else if (state.activeMarket === "kospi") group = "KOSPI_STOCK";
|
||
else if (state.activeMarket === "kosdaq") group = "KOSDAQ_STOCK";
|
||
else if (state.activeMarket === "overseas") group = name.includes("종목") ? "FOREIGN_STOCK" : "FOREIGN_INDEX";
|
||
else if (name.includes("코스닥")) group = "KOSDAQ_INDEX";
|
||
else if (name.includes("200")) group = "KOSPI200_INDEX";
|
||
else if (name.includes("KRX100")) group = "KRX100_INDEX";
|
||
else group = "KOSPI_INDEX";
|
||
|
||
if (["s5037", "s5079", "s5080", "s8003"].includes(item?.builderKey)) {
|
||
if (group === "KOSPI_STOCK") return "KOSPI";
|
||
if (group === "KOSDAQ_STOCK") return "KOSDAQ";
|
||
}
|
||
return group;
|
||
}
|
||
|
||
function applyLiveRowToCurrentCut(table, row, columns) {
|
||
const item = state.playlist[state.currentIndex];
|
||
if (!item) {
|
||
showToast("먼저 적용할 플레이리스트 장면을 선택하세요.");
|
||
return;
|
||
}
|
||
|
||
const values = liveRowValues(row, columns);
|
||
const subject = firstLiveValue(values, [
|
||
"F_STOCK_WANNAME", "F_STOCK_NAME", "STOCK_NAME", "F_KNAM",
|
||
"F_INPUT_NAME", "NAME", "WANNAME"
|
||
]);
|
||
const dataCode = firstLiveValue(values, [
|
||
"F_STOCK_CODE", "STOCK_CODE", "F_SYMB", "SYMB", "F_PART_CODE", "CODE"
|
||
]);
|
||
if (!subject && !dataCode) {
|
||
showToast("이 행에는 장면 조회에 사용할 종목명이나 코드가 없습니다.");
|
||
return;
|
||
}
|
||
|
||
const previousSelection = item.selection || {};
|
||
item.selection = {
|
||
groupCode: liveSelectionGroup(table, item),
|
||
subject: subject || dataCode,
|
||
graphicType: String(previousSelection.graphicType || item.graphicType || ""),
|
||
subtype: String(previousSelection.subtype || item.subtype || ""),
|
||
dataCode
|
||
};
|
||
item.subject = item.selection.subject;
|
||
item.dataCode = dataCode;
|
||
renderPlaylist();
|
||
updatePreview();
|
||
addLog(`${item.code} DB 선택 적용 · ${item.selection.subject}`);
|
||
showToast("선택한 DB 행을 현재 장면에 적용했습니다.");
|
||
}
|
||
|
||
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");
|
||
tr.title = "시장 현황 조회 전용입니다. 종목 컷은 위 종목 검색에서 선택하세요.";
|
||
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;
|
||
|
||
if (manualFinancialUi?.handleMessage(message.type, message.payload)) return;
|
||
if (manualListsUi?.handleMessage(message.type, message.payload)) return;
|
||
if (operatorCatalogUi?.handleMessage(message.type, message.payload)) 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;
|
||
case "stock-search-results":
|
||
if (!handleManualFinancialRestoreStockResults(message.payload) &&
|
||
!handleComparisonDomesticResults(message.payload)) handleStockSearchResults(message.payload);
|
||
break;
|
||
case "stock-search-error":
|
||
if (!handleManualFinancialRestoreStockError(message.payload) &&
|
||
!handleComparisonDomesticError(message.payload)) handleStockSearchError(message.payload);
|
||
break;
|
||
case "manual-financial-load-results":
|
||
handleManualFinancialRestoreLoadResults(message.payload);
|
||
break;
|
||
case "manual-financial-load-error":
|
||
handleManualFinancialRestoreLoadError(message.payload);
|
||
break;
|
||
case "manual-financial-selection-results":
|
||
handleManualFinancialRestoreSelectionResults(message.payload);
|
||
break;
|
||
case "manual-financial-selection-error":
|
||
handleManualFinancialRestoreSelectionError(message.payload);
|
||
break;
|
||
case "manual-financial-request-error":
|
||
handleManualFinancialRestoreRequestError(message.payload);
|
||
break;
|
||
case "manual-net-sell-data":
|
||
case "vi-manual-list-data":
|
||
handleNamedManualListResults(message.payload);
|
||
break;
|
||
case "manual-operator-data-error":
|
||
handleNamedManualListError(message.payload);
|
||
break;
|
||
case "world-stock-search-results":
|
||
if (!handleOverseasSearchResults("stock", message.payload)) {
|
||
handleComparisonWorldResults(message.payload);
|
||
}
|
||
break;
|
||
case "world-stock-search-error":
|
||
if (!handleOverseasSearchError("stock", message.payload)) {
|
||
handleComparisonWorldError(message.payload);
|
||
}
|
||
break;
|
||
case "overseas-industry-results":
|
||
handleOverseasSearchResults("industry", message.payload);
|
||
break;
|
||
case "overseas-industry-error":
|
||
handleOverseasSearchError("industry", message.payload);
|
||
break;
|
||
case "expert-search-results":
|
||
handleExpertSearchResults(message.payload);
|
||
break;
|
||
case "expert-search-error":
|
||
handleExpertSearchError(message.payload);
|
||
break;
|
||
case "expert-preview-results":
|
||
handleExpertPreviewResults(message.payload);
|
||
break;
|
||
case "expert-preview-error":
|
||
handleExpertPreviewError(message.payload);
|
||
break;
|
||
case "theme-search-results":
|
||
handleThemeSearchResults(message.payload);
|
||
break;
|
||
case "theme-search-error":
|
||
handleThemeSearchError(message.payload);
|
||
break;
|
||
case "theme-preview-results":
|
||
handleThemePreviewResults(message.payload);
|
||
break;
|
||
case "theme-preview-error":
|
||
handleThemePreviewError(message.payload);
|
||
break;
|
||
case "trading-halt-results":
|
||
handleTradingHaltResults(message.payload);
|
||
break;
|
||
case "trading-halt-error":
|
||
handleTradingHaltError(message.payload);
|
||
break;
|
||
case "background-status":
|
||
handleBackgroundStatus(message.payload);
|
||
break;
|
||
case "background-error":
|
||
handleBackgroundError(message.payload);
|
||
break;
|
||
case "industry-results":
|
||
handleIndustryResults(message.payload);
|
||
break;
|
||
case "industry-error":
|
||
handleIndustryError(message.payload);
|
||
break;
|
||
case "named-playlist-list-results":
|
||
handleNamedPlaylistListResults(message.payload);
|
||
break;
|
||
case "named-playlist-load-results":
|
||
handleNamedPlaylistLoadResults(message.payload);
|
||
break;
|
||
case "named-playlist-mutation-results":
|
||
handleNamedPlaylistMutationResults(message.payload);
|
||
break;
|
||
case "named-playlist-error":
|
||
handleNamedPlaylistError(message.payload);
|
||
break;
|
||
case "playlist-page-plans-results":
|
||
handleNamedPlaylistPagePlanResults(message.payload);
|
||
break;
|
||
case "playlist-page-plans-error":
|
||
handleNamedPlaylistPagePlanError(message.payload);
|
||
break;
|
||
case "playout-status":
|
||
handlePlayoutStatus(message.payload);
|
||
break;
|
||
case "playout-command-result":
|
||
handlePlayoutCommandResult(message.payload);
|
||
break;
|
||
case "playout-command-error":
|
||
handlePlayoutCommandError(message.payload);
|
||
break;
|
||
}
|
||
}
|
||
|
||
function bindEvents() {
|
||
document.querySelector("#marketNav").addEventListener("click", event => {
|
||
const button = event.target.closest("button[data-market]");
|
||
if (!button) return;
|
||
const previousMarket = state.activeMarket;
|
||
document.querySelectorAll(".nav-item").forEach(item => item.classList.toggle("active", item === button));
|
||
state.activeMarket = button.dataset.market;
|
||
if (previousMarket !== state.activeMarket && industryWorkflow.markets.includes(state.activeMarket)) {
|
||
state.stockWorkflowCollapsed = true;
|
||
state.industryWorkflowCollapsed = false;
|
||
} else if (previousMarket !== state.activeMarket && state.activeMarket === "dashboard") {
|
||
state.stockWorkflowCollapsed = false;
|
||
}
|
||
elements.workspaceTitle.textContent = marketTitles[state.activeMarket];
|
||
renderStockWorkflow();
|
||
renderFixedWorkflow();
|
||
renderIndustryWorkflow();
|
||
renderComparisonWorkflow();
|
||
renderThemeWorkflow();
|
||
renderOverseasWorkflow();
|
||
renderExpertWorkflow();
|
||
renderTradingHaltWorkflow();
|
||
renderCatalog();
|
||
requestMarketData(state.activeMarket);
|
||
if (industryWorkflow.markets.includes(state.activeMarket)) requestIndustries(state.activeMarket);
|
||
if (state.activeMarket === "theme" && state.theme.search.status === "idle") requestThemeSearch();
|
||
if (state.activeMarket === "foreign-stock" && currentOverseasStore().status === "idle") requestOverseasSearch();
|
||
if (state.activeMarket === "expert" && state.expert.search.status === "idle") requestExpertSearch();
|
||
if (state.activeMarket === "halted" && state.tradingHalt.status === "idle") requestTradingHaltSearch();
|
||
if (databaseBackedViews.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.stockSearchForm.addEventListener("submit", requestStockSearch);
|
||
elements.stockWorkflowToggle.addEventListener("click", () => {
|
||
state.stockWorkflowCollapsed = !state.stockWorkflowCollapsed;
|
||
if (!state.stockWorkflowCollapsed && industryWorkflow.markets.includes(state.activeMarket)) {
|
||
state.industryWorkflowCollapsed = true;
|
||
renderIndustryWorkflow();
|
||
}
|
||
renderStockWorkflow();
|
||
});
|
||
elements.legacyFixedToggle.addEventListener("click", () => {
|
||
state.fixedWorkflowCollapsed = !state.fixedWorkflowCollapsed;
|
||
renderFixedWorkflow();
|
||
});
|
||
elements.legacyFixedSearch.addEventListener("input", event => {
|
||
state.fixedSearch = event.target.value;
|
||
renderFixedWorkflow();
|
||
});
|
||
elements.industryRetry.addEventListener("click", () => requestIndustries(state.activeMarket));
|
||
elements.industryWorkflowToggle.addEventListener("click", () => {
|
||
state.industryWorkflowCollapsed = !state.industryWorkflowCollapsed;
|
||
if (!state.industryWorkflowCollapsed) {
|
||
state.stockWorkflowCollapsed = true;
|
||
renderStockWorkflow();
|
||
}
|
||
renderIndustryWorkflow();
|
||
});
|
||
elements.industrySearch.addEventListener("input", event => {
|
||
state.industry.search = event.target.value;
|
||
renderIndustryWorkflow();
|
||
});
|
||
elements.industrySwap.addEventListener("click", () => {
|
||
if (isPlaylistSnapshotLocked() || !state.industry.pairFirst || !state.industry.pairSecond) return;
|
||
[state.industry.pairFirst, state.industry.pairSecond] =
|
||
[state.industry.pairSecond, state.industry.pairFirst];
|
||
renderIndustryWorkflow();
|
||
addLog("업종 비교 순서 교환");
|
||
});
|
||
elements.industryClear.addEventListener("click", () => {
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
state.industry.highlighted = null;
|
||
state.industry.pairFirst = null;
|
||
state.industry.pairSecond = null;
|
||
renderIndustryWorkflow();
|
||
});
|
||
elements.comparisonSourceTabs.addEventListener("click", event => {
|
||
const button = event.target.closest("button[data-comparison-source]");
|
||
if (!button || isPlaylistSnapshotLocked()) return;
|
||
state.comparison.source = button.dataset.comparisonSource;
|
||
renderComparisonWorkflow();
|
||
});
|
||
elements.comparisonDomesticSearchForm.addEventListener("submit", requestComparisonDomesticSearch);
|
||
elements.comparisonWorldSearchForm.addEventListener("submit", requestComparisonWorldSearch);
|
||
elements.comparisonSwap.addEventListener("click", () => {
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
const swapped = comparisonWorkflow.swapPair([
|
||
state.comparison.draftFirst,
|
||
state.comparison.draftSecond
|
||
]);
|
||
if (!swapped) return;
|
||
[state.comparison.draftFirst, state.comparison.draftSecond] = swapped;
|
||
renderComparisonWorkflow();
|
||
});
|
||
elements.comparisonClear.addEventListener("click", () => {
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
[state.comparison.draftFirst, state.comparison.draftSecond] = comparisonWorkflow.clearPair();
|
||
renderComparisonWorkflow();
|
||
});
|
||
elements.comparisonSavePair.addEventListener("click", saveComparisonDraftPair);
|
||
elements.comparisonMoveUp.addEventListener("click", () => moveComparisonPair("up"));
|
||
elements.comparisonMoveDown.addEventListener("click", () => moveComparisonPair("down"));
|
||
elements.comparisonDeletePair.addEventListener("click", deleteSelectedComparisonPair);
|
||
elements.comparisonDeleteAll.addEventListener("click", deleteAllComparisonPairs);
|
||
elements.comparisonYieldBatch.addEventListener("click", addComparisonYieldBatch);
|
||
elements.themeSearchForm.addEventListener("submit", requestThemeSearch);
|
||
elements.themeNxtSession.addEventListener("change", () => {
|
||
if (isPlaylistSnapshotLocked()) return;
|
||
state.theme.nxtSession = elements.themeNxtSession.value;
|
||
requestThemeSearch();
|
||
});
|
||
elements.themeSort.addEventListener("change", () => {
|
||
const sort = themeWorkflow.normalizeThemeSort(elements.themeSort.value);
|
||
if (!sort || isPlaylistSnapshotLocked()) return;
|
||
state.theme.sort = sort.id;
|
||
renderThemeWorkflow();
|
||
});
|
||
elements.themeCatalogManageButton.addEventListener("click", () => {
|
||
const context = state.theme.preview.catalogContext;
|
||
operatorCatalogUi?.openTheme(context || undefined);
|
||
});
|
||
elements.overseasSourceTabs.addEventListener("click", event => {
|
||
const button = event.target.closest("button[data-overseas-source]");
|
||
if (!button || isPlaylistSnapshotLocked()) return;
|
||
state.overseas.source = button.dataset.overseasSource;
|
||
elements.overseasSearch.value = currentOverseasStore().request?.query || "";
|
||
renderOverseasWorkflow();
|
||
if (currentOverseasStore().status === "idle") requestOverseasSearch();
|
||
});
|
||
elements.overseasSearchForm.addEventListener("submit", requestOverseasSearch);
|
||
elements.expertSearchForm.addEventListener("submit", requestExpertSearch);
|
||
elements.expertCatalogManageButton.addEventListener("click", () => {
|
||
const context = state.expert.preview.catalogContext;
|
||
operatorCatalogUi?.openExpert(context || undefined);
|
||
});
|
||
elements.tradingHaltSearchForm.addEventListener("submit", requestTradingHaltSearch);
|
||
elements.globalMa5.addEventListener("change", () =>
|
||
applyGlobalCandleOptions(elements.globalMa5.checked, elements.globalMa20.checked, true));
|
||
elements.globalMa20.addEventListener("change", () =>
|
||
applyGlobalCandleOptions(elements.globalMa5.checked, elements.globalMa20.checked, true));
|
||
elements.stockMa5.addEventListener("change", () => synchronizeStockCandleOptions(true));
|
||
elements.stockMa20.addEventListener("change", () => synchronizeStockCandleOptions(true));
|
||
elements.overseasMa5.addEventListener("change", () =>
|
||
applyGlobalCandleOptions(elements.overseasMa5.checked, elements.overseasMa20.checked, true));
|
||
elements.overseasMa20.addEventListener("change", () =>
|
||
applyGlobalCandleOptions(elements.overseasMa5.checked, elements.overseasMa20.checked, true));
|
||
elements.tradingHaltMa5.addEventListener("change", () =>
|
||
applyGlobalCandleOptions(elements.tradingHaltMa5.checked, elements.tradingHaltMa20.checked, true));
|
||
elements.tradingHaltMa20.addEventListener("change", () =>
|
||
applyGlobalCandleOptions(elements.tradingHaltMa5.checked, elements.tradingHaltMa20.checked, true));
|
||
elements.catalogSearch.addEventListener("input", event => { state.search = event.target.value; renderCatalog(); });
|
||
elements.toggleAllEnabled.addEventListener("click", toggleAllEntriesEnabled);
|
||
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);
|
||
elements.deleteAllButton.addEventListener("click", deleteAllPlaylistEntries);
|
||
elements.saveButton.addEventListener("click", savePlaylist);
|
||
elements.loadButton.addEventListener("click", loadPlaylist);
|
||
elements.namedPlaylistOpenButton.addEventListener("click", () => {
|
||
state.namedPlaylist.open = !state.namedPlaylist.open;
|
||
renderNamedPlaylist();
|
||
if (state.namedPlaylist.open && !namedPlaylistBusy() && !state.namedPlaylist.definitions.length) {
|
||
requestNamedPlaylistList();
|
||
}
|
||
});
|
||
elements.namedPlaylistCloseButton.addEventListener("click", () => {
|
||
state.namedPlaylist.open = false;
|
||
renderNamedPlaylist();
|
||
});
|
||
elements.namedPlaylistRefreshButton.addEventListener("click", requestNamedPlaylistList);
|
||
elements.namedPlaylistDefinitions.addEventListener("change", () => {
|
||
state.namedPlaylist.selectedProgramCode = elements.namedPlaylistDefinitions.value;
|
||
renderNamedPlaylist();
|
||
});
|
||
elements.namedPlaylistLoadButton.addEventListener("click", requestNamedPlaylistLoad);
|
||
elements.namedPlaylistPageRetryButton.addEventListener("click", requestNamedPlaylistPagePlans);
|
||
elements.namedPlaylistRestoreBackupButton.addEventListener("click", restoreNamedPlaylistBackup);
|
||
elements.namedPlaylistCreateButton.addEventListener("click", requestNamedPlaylistCreate);
|
||
elements.namedPlaylistReplaceButton.addEventListener("click", requestNamedPlaylistReplace);
|
||
elements.namedPlaylistDeleteButton.addEventListener("click", requestNamedPlaylistDelete);
|
||
elements.sceneSelectionForm.addEventListener("submit", applySceneSelection);
|
||
elements.sceneCutAlias.addEventListener("change", applyCutAliasPreset);
|
||
elements.chooseBackgroundButton.addEventListener("click", requestBackgroundSelection);
|
||
elements.toggleBackgroundButton.addEventListener("click", requestBackgroundToggle);
|
||
elements.prepareButton.addEventListener("click", () => requestPlayoutCommand("prepare"));
|
||
elements.takeInButton.addEventListener("click", requestLegacyTakeInShortcut);
|
||
elements.nextButton.addEventListener("click", () => requestPlayoutCommand("next"));
|
||
elements.takeOutButton.addEventListener("click", () => requestPlayoutCommand("take-out"));
|
||
document.querySelector("#clearPlayoutErrorButton").addEventListener("click", () => clearPlayoutError());
|
||
document.querySelector("#clearLogButton").addEventListener("click", () => elements.eventLog.replaceChildren());
|
||
document.querySelector("#requestInfoButton").addEventListener("click", () => {
|
||
postNative("request-app-info");
|
||
requestDatabaseStatus();
|
||
requestPlayoutStatus();
|
||
requestBackgroundStatus();
|
||
if (liveDataViews.has(state.activeMarket)) requestMarketData(state.activeMarket);
|
||
if (industryWorkflow.markets.includes(state.activeMarket)) requestIndustries(state.activeMarket);
|
||
if (state.activeMarket === "foreign-stock") requestOverseasSearch();
|
||
if (state.activeMarket === "expert") requestExpertSearch();
|
||
if (state.activeMarket === "halted") requestTradingHaltSearch();
|
||
});
|
||
document.querySelector("#retryLiveDataButton").addEventListener("click", () => {
|
||
requestDatabaseStatus();
|
||
requestMarketData(state.activeMarket);
|
||
});
|
||
|
||
document.addEventListener("keydown", event => {
|
||
if (["F2", "F3", "F8", "Escape"].includes(event.key)) {
|
||
event.preventDefault();
|
||
if (event.repeat) return;
|
||
if (event.key === "F2") notifyLegacyBackgroundShortcut();
|
||
else if (event.key === "F3") requestBackgroundToggle();
|
||
else if (event.key === "F8") requestLegacyTakeInShortcut();
|
||
else requestPlayoutCommand("take-out");
|
||
return;
|
||
}
|
||
if (event.ctrlKey && event.key.toLowerCase() === "k") {
|
||
event.preventDefault();
|
||
const comparisonTarget = state.comparison.source === "world"
|
||
? elements.comparisonWorldSearch
|
||
: (state.comparison.source === "domestic"
|
||
? elements.comparisonDomesticSearch
|
||
: elements.comparisonFixedTargets.querySelector("button"));
|
||
const target = !elements.tradingHaltWorkflow.hidden
|
||
? elements.tradingHaltSearch
|
||
: (!elements.expertWorkflow.hidden
|
||
? elements.expertSearch
|
||
: (!elements.overseasWorkflow.hidden
|
||
? elements.overseasSearch
|
||
: (!elements.themeWorkflow.hidden
|
||
? elements.themeSearch
|
||
: (!elements.comparisonWorkflow.hidden
|
||
? (comparisonTarget || elements.catalogSearch)
|
||
: (!elements.industryWorkflow.hidden && !state.industryWorkflowCollapsed
|
||
? elements.industrySearch
|
||
: (!elements.stockWorkflow.hidden && !state.stockWorkflowCollapsed
|
||
? elements.stockSearchInput
|
||
: (!elements.legacyFixedWorkflow.hidden ? elements.legacyFixedSearch : elements.catalogSearch)))))));
|
||
target.focus();
|
||
return;
|
||
}
|
||
if (event.target instanceof HTMLInputElement ||
|
||
event.target instanceof HTMLTextAreaElement ||
|
||
event.target instanceof HTMLSelectElement) return;
|
||
if (event.key === "Home") {
|
||
event.preventDefault();
|
||
selectPlaylistBoundary(0, event.shiftKey);
|
||
return;
|
||
}
|
||
if (event.key === "End") {
|
||
event.preventDefault();
|
||
selectPlaylistBoundary(state.playlist.length - 1, event.shiftKey);
|
||
return;
|
||
}
|
||
if (event.key === "Delete") { event.preventDefault(); deleteSelected(); }
|
||
if (event.key === " ") { event.preventDefault(); toggleCurrentEntryEnabled(); }
|
||
});
|
||
window.addEventListener("pagehide", () => operatorCatalogUi?.loseCorrelation());
|
||
}
|
||
|
||
function initializeOperatorUiControllers() {
|
||
const shared = {
|
||
postNative: (type, payload) => postNative(type, payload),
|
||
appendPlaylistEntry: appendTrustedOperatorEntry,
|
||
isLocked: operatorUiLocked,
|
||
showToast,
|
||
addLog,
|
||
createId
|
||
};
|
||
manualListsUi = manualListsUiModule.createController(shared);
|
||
manualFinancialUi = manualFinancialUiModule.createController({
|
||
...shared,
|
||
getSelectedStock: selectedStockForManualFinancial
|
||
});
|
||
operatorCatalogUi = operatorCatalogUiModule.createController({
|
||
postNative: shared.postNative,
|
||
isLocked: operatorUiLocked,
|
||
showToast,
|
||
addLog,
|
||
createId
|
||
});
|
||
manualListsUi.mount(document.body);
|
||
manualFinancialUi.mount(document.body);
|
||
operatorCatalogUi.mount(document.body);
|
||
}
|
||
|
||
function initialize() {
|
||
initializeOperatorUiControllers();
|
||
bindEvents();
|
||
loadComparisonPairList();
|
||
renderCatalog();
|
||
renderStockWorkflow();
|
||
renderFixedWorkflow();
|
||
renderIndustryWorkflow();
|
||
renderComparisonWorkflow();
|
||
renderThemeWorkflow();
|
||
renderOverseasWorkflow();
|
||
renderExpertWorkflow();
|
||
renderTradingHaltWorkflow();
|
||
renderBackgroundControls();
|
||
renderPlaylist();
|
||
renderNamedPlaylist();
|
||
updatePreview();
|
||
renderDatabaseStatus();
|
||
renderLiveData();
|
||
updateClock();
|
||
setInterval(updateClock, 1000);
|
||
addLog("WebView 운영 화면 시작");
|
||
|
||
if (nativeBridge) {
|
||
nativeBridge.addEventListener("message", handleNativeMessage);
|
||
postNative("ready");
|
||
postNative("request-app-info");
|
||
requestDatabaseStatus();
|
||
requestPlayoutStatus();
|
||
requestBackgroundStatus();
|
||
} else {
|
||
elements.bridgeState.textContent = "브라우저 미리보기";
|
||
state.database.loading = false;
|
||
state.playout.mode = "preview-dry-run";
|
||
state.playout.engineState = "IDLE";
|
||
state.playout.message = "Native Bridge 없음 · 미리보기 전용 DRY RUN (PROGRAM 출력 차단)";
|
||
renderPlayout();
|
||
renderDatabaseStatus();
|
||
addLog("Native Bridge 없이 미리보기 전용 DRY RUN으로 실행");
|
||
}
|
||
}
|
||
|
||
initialize();
|
||
})();
|