Complete legacy operator UI and playout migration
This commit is contained in:
326
Web/industry-workflow.js
Normal file
326
Web/industry-workflow.js
Normal file
@@ -0,0 +1,326 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnIndustryWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_FADE_DURATION = 6;
|
||||
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const codePattern = /^[A-Za-z0-9]{1,32}$/;
|
||||
const controlCharacterPattern = /[\u0000-\u001f\u007f]/;
|
||||
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const markets = Object.freeze(["kospi", "kosdaq"]);
|
||||
const runtimeAssets = Object.freeze({
|
||||
kospi: Object.freeze({
|
||||
relativePath: "bin/Debug/Res/업종_코스피.ini",
|
||||
sha256: "F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52"
|
||||
}),
|
||||
kosdaq: Object.freeze({
|
||||
relativePath: "bin/Debug/Res/업종_코스닥.ini",
|
||||
sha256: "F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4"
|
||||
})
|
||||
});
|
||||
|
||||
const periodCuts = [
|
||||
["5일", "8061", "FiveDays"], ["20일", "8040", "TwentyDays"],
|
||||
["60일", "8046", "SixtyDays"], ["120일", "8051", "OneHundredTwentyDays"],
|
||||
["240일", "8056", "TwoHundredFortyDays"]
|
||||
];
|
||||
const candleCuts = [["일봉", "8035"], ...periodCuts.map(([label, code]) => [label, code])];
|
||||
|
||||
function cut(id, label, section, kind, details = {}) {
|
||||
return Object.freeze({ id, label, section, kind, ...details });
|
||||
}
|
||||
|
||||
const cuts = Object.freeze([
|
||||
cut("one-column", "1열판", "업종 판", "single", { builderKey: "s5001" }),
|
||||
cut("two-column", "2열판", "업종 판", "pair", { builderKey: "s8018" }),
|
||||
...periodCuts.map(([label, , period]) =>
|
||||
cut(`yield-${label}`, `수익률그래프_${label}`, "수익률 그래프", "yield", {
|
||||
builderKey: "s5086", period
|
||||
})),
|
||||
...candleCuts.map(([label, code]) =>
|
||||
cut(`candle-${label}`, `캔들그래프_${label}`, "캔들 그래프", "candle", {
|
||||
builderKey: "s8010", code, mode: "PRICE"
|
||||
})),
|
||||
...candleCuts.map(([label, code]) =>
|
||||
cut(`candle-volume-${label}`, `캔들그래프(거래량)_${label}`, "캔들 그래프 · 거래량", "candle", {
|
||||
builderKey: "s8010", code, mode: "VOLUME"
|
||||
})),
|
||||
cut("five-row", "5단 표그래프", "시장 전체", "fixed", { builderKey: "s5074" }),
|
||||
cut("square", "네모그래프", "시장 전체", "fixed", { builderKey: "s8001" }),
|
||||
cut("sector", "섹터지수", "시장 전체", "fixed", { builderKey: "s5078" })
|
||||
]);
|
||||
if (cuts.length !== 22 || new Set(cuts.map(value => value.id)).size !== 22) {
|
||||
throw new Error("Each legacy industry tab must contain exactly 22 cut actions.");
|
||||
}
|
||||
const cutsById = new Map(cuts.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 markets.includes(text) ? text : null;
|
||||
}
|
||||
|
||||
function normalizeIndustry(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const market = normalizeMarket(value.market);
|
||||
const name = cleanText(value.name ?? value.industryName);
|
||||
const code = cleanText(value.code ?? value.industryCode, 32);
|
||||
if (!market || !name || !code || !codePattern.test(code)) return null;
|
||||
return Object.freeze({ market, name, code });
|
||||
}
|
||||
|
||||
function localDateText(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 requireOptions(options) {
|
||||
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
||||
throw new TypeError("Industry playlist options are required.");
|
||||
}
|
||||
const id = cleanText(options.id);
|
||||
if (!id || !identifierPattern.test(id)) throw new TypeError("A safe industry 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.");
|
||||
}
|
||||
const now = options.now instanceof Date && !Number.isNaN(options.now.valueOf()) ? options.now : new Date();
|
||||
const ma5 = options.ma5 === undefined ? false : options.ma5;
|
||||
const ma20 = options.ma20 === undefined ? false : options.ma20;
|
||||
if (typeof ma5 !== "boolean" || typeof ma20 !== "boolean") {
|
||||
throw new TypeError("Moving-average flags must be boolean values.");
|
||||
}
|
||||
return { id, fadeDuration, now, ma5, ma20 };
|
||||
}
|
||||
|
||||
function mappingFor(market, selectedCut, selected, primary, secondary, now, movingAverages) {
|
||||
const industryGroup = market === "kospi" ? "INDUSTRY_KOSPI" : "INDUSTRY_KOSDAQ";
|
||||
const candleGroup = market === "kospi" ? "KOSPI_INDUSTRY" : "KOSDAQ_INDUSTRY";
|
||||
const pagedGroup = market === "kospi" ? "PAGED_DOMESTIC_KOSPI" : "PAGED_DOMESTIC_KOSDAQ";
|
||||
const marketName = market === "kospi" ? "KOSPI" : "KOSDAQ";
|
||||
switch (selectedCut.kind) {
|
||||
case "single":
|
||||
if (!selected) throw new RangeError("Select one industry first.");
|
||||
return {
|
||||
code: "5001",
|
||||
selection: {
|
||||
groupCode: industryGroup, subject: selected.name,
|
||||
graphicType: "1열판", subtype: "CURRENT", dataCode: ""
|
||||
}
|
||||
};
|
||||
case "pair":
|
||||
if (!primary || !secondary || primary.name.includes("-") || secondary.name.includes("-")) {
|
||||
throw new RangeError("Select two delimiter-safe industries first.");
|
||||
}
|
||||
return {
|
||||
code: market === "kospi" ? "8018" : "8032",
|
||||
selection: {
|
||||
groupCode: industryGroup, subject: `${primary.name}-${secondary.name}`,
|
||||
graphicType: "2열판", subtype: "", dataCode: ""
|
||||
}
|
||||
};
|
||||
case "yield":
|
||||
if (!selected) throw new RangeError("Select one industry first.");
|
||||
return {
|
||||
code: "5086",
|
||||
selection: {
|
||||
groupCode: industryGroup, subject: selected.name,
|
||||
graphicType: "YIELD_GRAPH", subtype: selectedCut.period, dataCode: ""
|
||||
}
|
||||
};
|
||||
case "candle":
|
||||
if (!selected) throw new RangeError("Select one industry first.");
|
||||
const flags = [];
|
||||
if (movingAverages.ma5) flags.push("MA5");
|
||||
if (movingAverages.ma20) flags.push("MA20");
|
||||
return {
|
||||
code: selectedCut.code,
|
||||
selection: {
|
||||
groupCode: candleGroup, subject: selected.name,
|
||||
graphicType: flags.join(","), subtype: selectedCut.mode, dataCode: ""
|
||||
}
|
||||
};
|
||||
case "fixed":
|
||||
if (selectedCut.id === "five-row") {
|
||||
return {
|
||||
code: "5074",
|
||||
selection: {
|
||||
groupCode: pagedGroup, subject: "INDEX",
|
||||
graphicType: "", subtype: "INDUSTRY", dataCode: localDateText(now)
|
||||
}
|
||||
};
|
||||
}
|
||||
if (selectedCut.id === "square") {
|
||||
return {
|
||||
code: market === "kospi" ? "8001" : "8002",
|
||||
selection: {
|
||||
groupCode: industryGroup, subject: marketName,
|
||||
graphicType: "네모그래프", subtype: "", dataCode: ""
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: "5078",
|
||||
selection: {
|
||||
groupCode: industryGroup, subject: marketName,
|
||||
graphicType: "SECTOR_INDEX", subtype: "", dataCode: ""
|
||||
}
|
||||
};
|
||||
default:
|
||||
throw new Error("The selected industry cut is unsupported.");
|
||||
}
|
||||
}
|
||||
|
||||
function isCutAllowed(marketValue, cutId, selectedValue, primaryValue, secondaryValue) {
|
||||
const market = normalizeMarket(marketValue);
|
||||
const selectedCut = cutsById.get(cutId);
|
||||
const selected = normalizeIndustry(selectedValue);
|
||||
const primary = normalizeIndustry(primaryValue);
|
||||
const secondary = normalizeIndustry(secondaryValue);
|
||||
if (!market || !selectedCut) return false;
|
||||
if (selected && selected.market !== market) return false;
|
||||
if (primary && primary.market !== market) return false;
|
||||
if (secondary && secondary.market !== market) return false;
|
||||
if (selectedCut.kind === "fixed") return true;
|
||||
if (selectedCut.kind === "pair") {
|
||||
return Boolean(primary && secondary && !primary.name.includes("-") && !secondary.name.includes("-"));
|
||||
}
|
||||
return Boolean(selected);
|
||||
}
|
||||
|
||||
function createIndustryPlaylistEntry(marketValue, cutId, options) {
|
||||
const market = normalizeMarket(marketValue);
|
||||
const selectedCut = cutsById.get(cutId);
|
||||
if (!market || !selectedCut) throw new RangeError("The industry action is unknown.");
|
||||
const selected = normalizeIndustry(options?.selected);
|
||||
const pair = Array.isArray(options?.pair) ? options.pair : [];
|
||||
const primary = normalizeIndustry(pair[0]);
|
||||
const secondary = normalizeIndustry(pair[1]);
|
||||
if ((selected && selected.market !== market) || (primary && primary.market !== market) ||
|
||||
(secondary && secondary.market !== market)) {
|
||||
throw new RangeError("The selected industry market is inconsistent.");
|
||||
}
|
||||
if (!isCutAllowed(market, cutId, selected, primary, secondary)) {
|
||||
throw new RangeError(selectedCut.kind === "pair" ? "Select two industries first." : "Select one industry first.");
|
||||
}
|
||||
const normalized = requireOptions(options);
|
||||
const movingAverages = Object.freeze({
|
||||
ma5: selectedCut.kind === "candle" && normalized.ma5,
|
||||
ma20: selectedCut.kind === "candle" && normalized.ma20
|
||||
});
|
||||
const mapping = mappingFor(
|
||||
market,
|
||||
selectedCut,
|
||||
selected,
|
||||
primary,
|
||||
secondary,
|
||||
normalized.now,
|
||||
movingAverages);
|
||||
const selection = Object.freeze(mapping.selection);
|
||||
return {
|
||||
id: normalized.id,
|
||||
builderKey: selectedCut.builderKey,
|
||||
code: mapping.code,
|
||||
aliases: [mapping.code],
|
||||
title: selectedCut.kind === "fixed"
|
||||
? `${market === "kospi" ? "코스피" : "코스닥"} ${selectedCut.label}`
|
||||
: (selectedCut.kind === "pair" ? `${primary.name}-${secondary.name}` : selected.name),
|
||||
detail: selectedCut.label,
|
||||
category: selectedCut.kind === "candle" || selectedCut.kind === "yield" ? "chart" : "plate",
|
||||
market,
|
||||
selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalized.fadeDuration,
|
||||
graphicType: selection.graphicType,
|
||||
subtype: selection.subtype,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-industry-workflow",
|
||||
cutId: selectedCut.id,
|
||||
market,
|
||||
...(selectedCut.kind === "pair" ? { pair: Object.freeze([primary, secondary]) } : {}),
|
||||
...(selectedCut.kind !== "pair" && selectedCut.kind !== "fixed" ? { selected } : {}),
|
||||
assetPath: runtimeAssets[market].relativePath,
|
||||
assetSha256: runtimeAssets[market].sha256,
|
||||
movingAverages
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function sameSelection(left, right, allowDynamicDate) {
|
||||
if (!left || typeof left !== "object" || !right || typeof right !== "object") return false;
|
||||
return ["groupCode", "subject", "graphicType", "subtype", "dataCode"].every(key => {
|
||||
if (allowDynamicDate && key === "dataCode") return typeof left[key] === "string" && datePattern.test(left[key]);
|
||||
return typeof left[key] === "string" && left[key] === right[key];
|
||||
});
|
||||
}
|
||||
|
||||
function restoreIndustryPlaylistEntry(value, options = {}) {
|
||||
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-industry-workflow") return null;
|
||||
const market = normalizeMarket(operator.market);
|
||||
const selectedCut = cutsById.get(operator.cutId);
|
||||
if (!market || !selectedCut || operator.assetPath !== runtimeAssets[market].relativePath ||
|
||||
operator.assetSha256 !== runtimeAssets[market].sha256) return null;
|
||||
let movingAverages = operator.movingAverages;
|
||||
if (movingAverages === undefined) {
|
||||
if (selectedCut.kind === "candle") {
|
||||
const graphicType = value?.selection?.graphicType;
|
||||
if (!["", "MA5", "MA20", "MA5,MA20"].includes(graphicType)) return null;
|
||||
movingAverages = {
|
||||
ma5: graphicType === "MA5" || graphicType === "MA5,MA20",
|
||||
ma20: graphicType === "MA20" || graphicType === "MA5,MA20"
|
||||
};
|
||||
} else {
|
||||
movingAverages = { ma5: false, ma20: false };
|
||||
}
|
||||
}
|
||||
if (!movingAverages || typeof movingAverages !== "object" || Array.isArray(movingAverages) ||
|
||||
typeof movingAverages.ma5 !== "boolean" || typeof movingAverages.ma20 !== "boolean" ||
|
||||
Object.keys(movingAverages).length !== 2 ||
|
||||
(selectedCut.kind !== "candle" && (movingAverages.ma5 || movingAverages.ma20))) return null;
|
||||
let recreated;
|
||||
try {
|
||||
recreated = createIndustryPlaylistEntry(market, selectedCut.id, {
|
||||
id: value.id,
|
||||
fadeDuration: value.fadeDuration,
|
||||
selected: operator.selected,
|
||||
pair: operator.pair,
|
||||
now: options.now,
|
||||
ma5: movingAverages.ma5,
|
||||
ma20: movingAverages.ma20
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const scalars = ["id", "builderKey", "code", "title", "detail", "category", "market", "fadeDuration"];
|
||||
if (!scalars.every(key => value[key] === recreated[key]) ||
|
||||
!Array.isArray(value.aliases) || value.aliases.length !== 1 || value.aliases[0] !== recreated.code ||
|
||||
!sameSelection(value.selection, recreated.selection, selectedCut.id === "five-row")) return null;
|
||||
return { ...recreated, enabled: value.enabled };
|
||||
}
|
||||
|
||||
return {
|
||||
markets,
|
||||
runtimeAssets,
|
||||
cuts,
|
||||
normalizeIndustry,
|
||||
isCutAllowed,
|
||||
localDateText,
|
||||
createIndustryPlaylistEntry,
|
||||
restoreIndustryPlaylistEntry,
|
||||
refreshIndustryPlaylistEntry: restoreIndustryPlaylistEntry
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user