Files
MBN_STOCK_WEBVIEW/Web/theme-workflow.js

326 lines
13 KiB
JavaScript

(function (root, factory) {
"use strict";
const api = Object.freeze(factory());
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnThemeWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function () {
"use strict";
const DEFAULT_FADE_DURATION = 6;
const SCHEMA_VERSION = 1;
const MAXIMUM_PAGE_COUNT = 20;
const MAXIMUM_PREVIEW_ITEMS = 240;
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const themeCodePattern = /^[0-9]{8}$/;
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
const sourceContract = Object.freeze({
originalControl: "Control/UC4.cs",
krxResolverGroup: "PAGED_THEME",
nxtResolverGroup: "PAGED_NXT_THEME",
maximumPageCount: MAXIMUM_PAGE_COUNT,
maximumPreviewItems: MAXIMUM_PREVIEW_ITEMS
});
const themeSorts = Object.freeze([
Object.freeze({ id: "INPUT_ORDER", label: "입력순" }),
Object.freeze({ id: "GAIN_DESC", label: "상승률순" }),
Object.freeze({ id: "GAIN_ASC", label: "하락률순" })
]);
const themeSortsById = new Map(themeSorts.map(value => [value.id, value]));
const nxtSessions = Object.freeze([
Object.freeze({ id: "PRE_MARKET", label: "프리마켓" }),
Object.freeze({ id: "AFTER_MARKET", label: "애프터마켓" })
]);
function action(id, label, builderKey, code, pageSize, valueKind) {
return Object.freeze({
id,
label,
builderKey,
code,
aliases: Object.freeze([code]),
pageSize,
valueKind
});
}
const actions = Object.freeze([
action("five-row-current", "5단 표그래프 · 현재가", "s5074", "5074", 5, "CURRENT"),
action("five-row-expected", "5단 표그래프 · 예상체결가", "s5074", "5074", 5, "EXPECTED"),
action("six-row-current", "6종목 현재가", "s5077", "5077", 6, "CURRENT"),
action("twelve-row-current", "12종목 현재가", "s5088", "5088", 12, "CURRENT")
]);
const actionsById = new Map(actions.map(value => [value.id, value]));
function cleanText(value, maximumLength = 128) {
if (typeof value !== "string") return null;
const text = value.trim();
return text && text.length <= maximumLength && !controlCharacterPattern.test(text) ? text : null;
}
function normalizeMarket(value) {
const text = String(value || "").trim().toLocaleLowerCase("en-US");
return text === "krx" || text === "nxt" ? text : null;
}
function normalizeNxtSession(value) {
const text = String(value || "")
.trim()
.replace(/([a-z])([A-Z])/g, "$1_$2")
.replace(/-/g, "_")
.toLocaleUpperCase("en-US");
return text === "PRE_MARKET" || text === "AFTER_MARKET" ? text : null;
}
function isKrxEmptySession(value) {
if (value === undefined || value === null || value === "") return true;
const text = String(value)
.trim()
.replace(/([a-z])([A-Z])/g, "$1_$2")
.replace(/-/g, "_")
.toLocaleUpperCase("en-US");
return text === "NOT_APPLICABLE";
}
function themeValidationReason(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return "invalid-theme";
const market = normalizeMarket(value.market ?? value.Market);
if (!market) return "invalid-theme-market";
const rawThemeCode = value.themeCode ?? value.ThemeCode ?? value.code;
const themeCode = cleanText(rawThemeCode, 8);
if (!themeCode || themeCode !== rawThemeCode || !themeCodePattern.test(themeCode)) {
return "invalid-theme-code";
}
const title = cleanText(value.title ?? value.themeTitle ?? value.ThemeTitle);
if (!title || title !== String(value.title ?? value.themeTitle ?? value.ThemeTitle) ||
title.includes("(NXT)")) return "invalid-theme-title";
const sessionValue = value.session ?? value.Session;
if (market === "nxt" && !normalizeNxtSession(sessionValue)) return "nxt-session-required";
if (market === "krx" && !isKrxEmptySession(sessionValue)) return "krx-session-must-be-empty";
const itemCountValue = value.itemCount ?? value.previewItemCount ??
(Array.isArray(value.items) ? value.items.length : null);
if (itemCountValue !== null && itemCountValue !== undefined &&
(!Number.isInteger(itemCountValue) || itemCountValue < 0 || itemCountValue > MAXIMUM_PREVIEW_ITEMS)) {
return "invalid-theme-item-count";
}
const canonicalDisplay = market === "nxt" ? `${title}(NXT)` : title;
if (value.displayTitle !== undefined && value.displayTitle !== canonicalDisplay) {
return "invalid-theme-display-title";
}
return null;
}
function normalizeTheme(value) {
if (themeValidationReason(value)) return null;
const market = normalizeMarket(value.market ?? value.Market);
const themeCode = value.themeCode ?? value.ThemeCode ?? value.code;
const title = value.title ?? value.themeTitle ?? value.ThemeTitle;
const itemCountValue = value.itemCount ?? value.previewItemCount ??
(Array.isArray(value.items) ? value.items.length : null);
const session = market === "nxt" ? normalizeNxtSession(value.session ?? value.Session) : null;
return Object.freeze({
market,
themeCode,
title,
displayTitle: market === "nxt" ? `${title}(NXT)` : title,
session,
itemCount: itemCountValue === undefined ? null : itemCountValue
});
}
function normalizeThemeSort(value) {
if (value && typeof value === "object" && !Array.isArray(value)) value = value.id;
const id = String(value || "").trim().toLocaleUpperCase("en-US");
return themeSortsById.get(id) ?? null;
}
function unsupported(actionId, reason) {
return Object.freeze({ actionId, supported: false, reason });
}
function getCompatibility(actionId, themeValue, sortValue) {
const selectedAction = actionsById.get(actionId);
if (!selectedAction) return unsupported(actionId, "unknown-action");
const reason = themeValidationReason(themeValue);
if (reason) return unsupported(actionId, reason);
const theme = normalizeTheme(themeValue);
if (!normalizeThemeSort(sortValue)) return unsupported(actionId, "invalid-theme-sort");
if (theme.itemCount === 0) return unsupported(actionId, "theme-preview-is-empty");
if (selectedAction.valueKind === "EXPECTED" && theme.market !== "krx") {
return unsupported(actionId, "expected-theme-is-krx-only");
}
return Object.freeze({ actionId, supported: true, reason: null });
}
function isActionAllowed(actionId, themeValue, sortValue) {
return getCompatibility(actionId, themeValue, sortValue).supported;
}
function calculatePagePreview(actionId, themeValue) {
const selectedAction = actionsById.get(actionId);
const theme = normalizeTheme(themeValue);
if (!selectedAction || !theme) return null;
const maximumItems = selectedAction.pageSize * MAXIMUM_PAGE_COUNT;
if (theme.itemCount === null) {
return Object.freeze({
itemCount: null,
accessibleItemCount: null,
pageSize: selectedAction.pageSize,
pageCount: null,
maximumPageCount: MAXIMUM_PAGE_COUNT,
isTruncated: false
});
}
const accessibleItemCount = Math.min(theme.itemCount, maximumItems);
return Object.freeze({
itemCount: theme.itemCount,
accessibleItemCount,
pageSize: selectedAction.pageSize,
pageCount: Math.ceil(accessibleItemCount / selectedAction.pageSize),
maximumPageCount: MAXIMUM_PAGE_COUNT,
isTruncated: theme.itemCount > maximumItems
});
}
function buildThemeSelection(actionId, themeValue, sortValue) {
const selectedAction = actionsById.get(actionId);
const theme = normalizeTheme(themeValue);
const sort = normalizeThemeSort(sortValue);
const compatibility = getCompatibility(actionId, themeValue, sortValue);
if (!selectedAction || !theme || !sort || !compatibility.supported) {
throw new RangeError(`The theme action is unavailable: ${compatibility.reason || "invalid-selection"}.`);
}
const selection = theme.market === "krx"
? Object.freeze({
groupCode: "PAGED_THEME",
subject: theme.title,
graphicType: sort.id,
subtype: selectedAction.valueKind,
dataCode: theme.themeCode
})
: Object.freeze({
groupCode: "PAGED_NXT_THEME",
subject: theme.displayTitle,
graphicType: theme.session,
subtype: sort.id,
dataCode: theme.themeCode
});
return Object.freeze({
builderKey: selectedAction.builderKey,
code: selectedAction.code,
aliases: selectedAction.aliases,
selection,
pageSize: selectedAction.pageSize,
pagePreview: calculatePagePreview(actionId, theme)
});
}
function requireOptions(options) {
if (!options || typeof options !== "object" || Array.isArray(options)) {
throw new TypeError("Theme playlist options are required.");
}
const id = cleanText(options.id);
if (!id || !identifierPattern.test(id)) throw new TypeError("A safe theme playlist id is required.");
const fadeDuration = options.fadeDuration === undefined ? DEFAULT_FADE_DURATION : Number(options.fadeDuration);
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
throw new RangeError("Fade duration must be an integer from 0 through 60.");
}
return { id, fadeDuration };
}
function createThemePlaylistEntry(actionId, themeValue, sortValue, options) {
const selectedAction = actionsById.get(actionId);
if (!selectedAction) throw new RangeError("The theme action is unknown.");
const theme = normalizeTheme(themeValue);
if (!theme) throw new RangeError(`The theme selection is unavailable: ${themeValidationReason(themeValue)}.`);
const sort = normalizeThemeSort(sortValue);
if (!sort) throw new RangeError("The theme selection is unavailable: invalid-theme-sort.");
const normalized = requireOptions(options);
const mapping = buildThemeSelection(actionId, theme, sort);
return {
id: normalized.id,
builderKey: mapping.builderKey,
code: mapping.code,
aliases: mapping.aliases,
title: theme.displayTitle,
detail: `${selectedAction.label} · ${sort.label}`,
category: "plate",
market: "theme",
selection: mapping.selection,
enabled: true,
fadeDuration: normalized.fadeDuration,
graphicType: mapping.selection.graphicType,
subtype: mapping.selection.subtype,
pageSize: mapping.pageSize,
pagePreview: mapping.pagePreview,
operator: Object.freeze({
source: "legacy-theme-workflow",
schemaVersion: SCHEMA_VERSION,
actionId,
theme,
sort: sort.id,
resolverGroup: mapping.selection.groupCode
})
};
}
function sameSelection(left, right) {
if (!left || typeof left !== "object" || !right || typeof right !== "object") return false;
return ["groupCode", "subject", "graphicType", "subtype", "dataCode"]
.every(key => typeof left[key] === "string" && left[key] === right[key]);
}
function samePagePreview(left, right) {
if (!left || typeof left !== "object" || !right || typeof right !== "object") return false;
return ["itemCount", "accessibleItemCount", "pageSize", "pageCount", "maximumPageCount", "isTruncated"]
.every(key => left[key] === right[key]);
}
function restoreThemePlaylistEntry(value) {
if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.enabled !== "boolean") return null;
const operator = value.operator;
if (!operator || typeof operator !== "object" || operator.source !== "legacy-theme-workflow" ||
operator.schemaVersion !== SCHEMA_VERSION || !actionsById.has(operator.actionId)) return null;
let recreated;
try {
recreated = createThemePlaylistEntry(operator.actionId, operator.theme, operator.sort, {
id: value.id,
fadeDuration: value.fadeDuration
});
} catch {
return null;
}
const scalars = [
"id", "builderKey", "code", "title", "detail", "category", "market",
"fadeDuration", "graphicType", "subtype", "pageSize"
];
if (!scalars.every(key => value[key] === recreated[key]) ||
!Array.isArray(value.aliases) || value.aliases.length !== recreated.aliases.length ||
!value.aliases.every((alias, index) => alias === recreated.aliases[index]) ||
!sameSelection(value.selection, recreated.selection) ||
!samePagePreview(value.pagePreview, recreated.pagePreview) ||
operator.resolverGroup !== recreated.operator.resolverGroup) return null;
return { ...recreated, enabled: value.enabled };
}
return {
sourceContract,
themeSorts,
nxtSessions,
actions,
themeActions: actions,
normalizeTheme,
normalizeThemeSort,
getCompatibility,
isActionAllowed,
calculatePagePreview,
buildThemeSelection,
createThemePlaylistEntry,
restoreThemePlaylistEntry,
refreshThemePlaylistEntry: restoreThemePlaylistEntry
};
});