Files

1285 lines
40 KiB
HTML

<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HM Biz Process Map</title>
<style>
:root {
color-scheme: dark;
--sm-chip-width: 102px;
--sm-chip-height: 42px;
--bg: #04140f;
--panel: rgba(7, 43, 33, 0.82);
--panel-border: rgba(132, 187, 161, 0.18);
--text: #f3f1e8;
--muted: #a8c0b3;
--line: rgba(132, 187, 161, 0.16);
--accent: #d3a45f;
--accent-soft: rgba(211, 164, 95, 0.16);
--warning: #ffe09b;
--warning-bg: rgba(79, 58, 24, 0.58);
--surface-soft: #f4f1e8;
--surface-plain: #fbf8f1;
--ink-strong: #183128;
--ink-soft: #55695f;
--shadow: 0 28px 64px rgba(0, 0, 0, 0.34);
}
* { box-sizing: border-box; }
html {
min-height: 100%;
overflow-y: auto;
}
body {
margin: 0;
font-family: "Pretendard Variable", "Pretendard", "Noto Sans KR", system-ui, sans-serif;
color: var(--text);
background:
linear-gradient(rgba(120, 186, 158, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(120, 186, 158, 0.08) 1px, transparent 1px),
radial-gradient(circle at 72% 22%, rgba(28, 110, 83, 0.38), transparent 28%),
radial-gradient(circle at 24% 68%, rgba(201, 152, 80, 0.12), transparent 24%),
linear-gradient(135deg, #04120d 0%, #0a241b 45%, #0f4534 100%);
background-size: 40px 40px, 40px 40px, auto, auto, auto;
position: relative;
overflow-x: auto;
overflow-y: auto;
}
body::before,
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
}
body::before {
inset: 0;
background:
radial-gradient(circle at 26% 24%, rgba(239, 192, 109, 0.12), transparent 18%),
linear-gradient(120deg, transparent 0 64%, rgba(255, 244, 220, 0.05) 64% 76%, transparent 76% 100%);
opacity: 1;
box-shadow: none;
}
body::after {
background:
linear-gradient(90deg, rgba(0, 0, 0, 0.34), transparent 22%, transparent 78%, rgba(0, 0, 0, 0.42)),
radial-gradient(circle at center, transparent 50%, rgba(0, 0, 0, 0.2) 100%);
}
.page {
min-height: 100vh;
padding: 18px;
position: relative;
z-index: 1;
overflow: visible;
}
.shell {
min-height: calc(100vh - 36px);
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px 18px 18px;
border: 1px solid var(--panel-border);
border-radius: 24px;
background: var(--panel);
box-shadow: var(--shadow);
overflow: visible;
backdrop-filter: blur(12px);
}
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.title-wrap {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.eyebrow {
margin: 0;
font-size: 12px;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
color: var(--accent);
}
h1 {
margin: 0;
font-size: 38px;
line-height: 1.08;
letter-spacing: -.03em;
text-transform: uppercase;
color: var(--accent);
text-shadow: 0 10px 22px rgba(0, 0, 0, 0.28);
}
.subcopy {
margin: 0;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
max-width: 560px;
}
.toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
min-width: 340px;
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(132, 187, 161, 0.22);
background: rgba(7, 41, 32, 0.92);
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.status::before {
content: "";
width: 8px;
height: 8px;
border-radius: 999px;
background: rgba(168, 192, 179, 0.9);
box-shadow: 0 0 0 5px rgba(168, 192, 179, 0.14);
}
.status[data-state="ready"]::before {
background: #6fe0ab;
box-shadow: 0 0 0 5px rgba(111, 224, 171, 0.14);
}
.status[data-state="loading"]::before {
background: #ffd28b;
box-shadow: 0 0 0 5px rgba(255, 210, 139, 0.14);
}
.status[data-state="error"]::before {
background: #ff9d8f;
box-shadow: 0 0 0 5px rgba(255, 157, 143, 0.14);
}
.stamp {
font-size: 12px;
color: var(--muted);
padding: 0 4px;
}
.btn,
select {
height: 42px;
border-radius: 14px;
border: 1px solid #d3dbd4;
background: #fffdf8;
color: var(--ink-strong);
font: inherit;
font-size: 13px;
font-weight: 700;
padding: 0 14px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
}
.btn:hover {
transform: translateY(-1px);
border-color: rgba(229, 188, 114, 0.48);
box-shadow: 0 12px 22px rgba(0, 0, 0, 0.2);
}
.btn.primary {
background: linear-gradient(135deg, #b8833d 0%, #efc06d 100%);
color: #10241c;
border-color: rgba(241, 202, 128, 0.7);
}
.filters {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
}
.filters-left {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.filters-right {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.sm-legend {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
min-height: 24px;
}
.sm-legend-item {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 14px;
border: 1px solid #d7ddd7;
background: linear-gradient(180deg, #fffdf9 0%, #f4f6f1 100%);
color: var(--ink-strong);
font-size: 12px;
font-weight: 800;
line-height: 1;
box-shadow:
0 6px 16px rgba(16, 36, 28, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.sm-legend-swatch {
width: 14px;
height: 14px;
border-radius: 5px;
border: 1px solid rgba(62, 86, 72, 0.18);
background: rgba(168, 192, 179, 0.55);
flex: 0 0 auto;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.45),
0 1px 2px rgba(0, 0, 0, 0.08);
}
.map-panel {
min-height: 0;
flex: 1 1 auto;
border: 1px solid #e2e7e1;
border-radius: 22px;
background: linear-gradient(180deg, #fcfaf5 0%, #f7f8f4 100%);
overflow: auto;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
}
.map-panel.loading {
opacity: .8;
}
.map-body {
min-width: 0;
padding: 22px 22px 28px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.78) 0%, rgba(255, 255, 255, 0.62) 100%);
}
.sitemap-full {
transform-origin: top left;
will-change: transform;
}
.empty-state {
display: grid;
place-items: center;
min-height: 320px;
text-align: center;
color: var(--ink-soft);
font-weight: 700;
}
.sm-section-title {
margin: 4px 0 0;
color: #7f683f;
font-size: 12px;
font-weight: 800;
letter-spacing: .03em;
text-transform: uppercase;
}
.sm-row {
display: grid;
grid-template-columns: minmax(128px, 146px) minmax(0, 1fr);
align-items: start;
gap: 20px;
min-width: 0;
padding: 15px 0;
border-bottom: 1px solid #d9dfd8;
background: transparent;
box-shadow: none;
}
.sm-main {
min-width: 146px;
font-weight: 900;
color: var(--ink-strong);
font-size: 18px;
line-height: 1.25;
padding-top: 3px;
align-self: flex-start;
}
.sm-main.active-focus {
color: #9d7640;
text-shadow: none;
}
.sm-chain {
display: flex;
align-items: center;
align-content: flex-start;
gap: 10px 0;
flex-wrap: wrap;
min-width: 0;
flex: 1 1 auto;
}
.sm-segment {
display: inline-flex;
align-items: center;
flex: 0 0 var(--flow-col-width, auto);
width: var(--flow-col-width, auto);
min-width: var(--flow-col-width, auto);
max-width: var(--flow-col-width, auto);
}
.sm-segment.is-first {
width: var(--sm-chip-width);
min-width: var(--sm-chip-width);
max-width: var(--sm-chip-width);
}
.sm-segment.has-manual-link {
gap: 0;
}
.sm-arrow {
position: relative;
display: inline-block;
width: 30px;
height: 2px;
margin: 0;
background: rgba(108, 129, 116, 0.58);
font-size: 0;
line-height: 0;
flex: 0 0 auto;
}
.sm-arrow::after {
content: "";
position: absolute;
right: -1px;
top: -4px;
width: 0;
height: 0;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-left: 8px solid rgba(108, 129, 116, 0.58);
}
.sm-link-wrap {
display: inline-flex;
align-items: center;
gap: 0;
width: var(--flow-link-width, 30px);
min-width: var(--flow-link-width, 30px);
max-width: var(--flow-link-width, 30px);
flex: 0 0 auto;
justify-content: stretch;
}
.sm-link-arrow-frag {
color: var(--accent);
font-size: 0;
line-height: 0;
flex: 1 1 12px;
min-width: 12px;
height: 0;
border-top: 2px dashed rgba(171, 134, 73, 0.86);
display: inline-block;
}
.sm-chip {
border: 1px solid #cfd7d0;
border-radius: 16px;
width: var(--sm-chip-width);
min-width: var(--sm-chip-width);
min-height: var(--sm-chip-height);
padding: 5px 7px;
background: #fffdf8;
font-size: 13px;
font-weight: 900;
color: #183026;
line-height: 1.25;
white-space: normal;
word-break: keep-all;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.92);
}
.sm-chip.system-colored {
background: var(--sys-bg, #f8fbff);
border-color: var(--sys-border, #d2ddeb);
color: var(--sys-text, #1f2937);
}
.sm-chip.multi-system {
background: linear-gradient(135deg, var(--sys-bg-a, #eef2ff) 0 49.5%, var(--sys-bg-b, #ecfeff) 50% 100%);
border-color: var(--sys-border-a, #b6c6e0);
color: var(--sys-text, #1f4f83);
box-shadow: inset -1px -1px 0 0 var(--sys-border-b, #b6c6e0);
}
.sm-chip.no-system {
background: var(--sys-bg, #edf3fb);
border-color: var(--sys-border, #9fb8d7);
color: var(--sys-text, #334155);
}
.sm-chip.match {
border-color: rgba(171, 134, 73, 0.72);
box-shadow: 0 0 0 2px rgba(171, 134, 73, 0.12) inset;
}
.sm-chip.active-focus,
.sm-chip.active-focus.system-colored,
.sm-chip.active-focus.multi-system,
.sm-chip.active-focus.no-system,
.sm-chip.active-focus.match {
border-color: rgba(171, 134, 73, 0.78) !important;
box-shadow:
0 0 0 4px rgba(171, 134, 73, 0.16),
0 12px 18px rgba(0, 0, 0, 0.08),
inset 0 0 0 1px rgba(255, 255, 255, 0.68) !important;
transform: translateY(-1px);
position: relative;
z-index: 1;
}
.sm-chip.dim,
.sm-arrow.dim,
.sm-main.dim,
.sm-link-wrap.dim,
.sm-shared-box.dim,
.sm-link-reason.dim {
opacity: 0.14;
filter: grayscale(0.2);
}
.sm-link-reason {
border: 0;
background: #f7edd7;
color: #5f4618;
border-radius: 12px;
padding: 3px 7px;
font-size: 10px;
font-weight: 800;
line-height: 1.2;
min-width: 0;
max-width: calc(var(--flow-link-width, 54px) - 20px);
white-space: normal;
overflow: visible;
text-overflow: clip;
text-align: center;
word-break: keep-all;
overflow-wrap: anywhere;
flex: 0 1 auto;
display: inline-flex;
align-items: center;
justify-content: center;
}
.sm-link-wrap.manual {
gap: 4px;
min-width: 0;
flex: 0 0 auto;
justify-content: center;
}
.sm-link-wrap.manual .sm-link-arrow-frag:last-child {
position: relative;
}
.sm-link-wrap.manual .sm-link-arrow-frag:last-child::after {
content: "";
position: absolute;
right: -2px;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
border-left: 8px solid rgba(171, 134, 73, 0.86);
}
.sm-link-wrap:not(.manual) .sm-arrow {
width: 100%;
flex: 1 1 auto;
}
.sm-shared-box {
display: inline-flex;
align-items: center;
gap: 0;
flex-wrap: wrap;
max-width: 100%;
border: 1px dashed rgba(171, 134, 73, 0.6);
border-radius: 12px;
padding: 5px 7px;
background: #f5ecda;
}
@media (max-width: 1120px) {
:root {
--sm-chip-width: 96px;
--sm-chip-height: 40px;
}
.page { padding: 10px; }
.shell {
min-height: calc(100vh - 20px);
padding: 14px;
border-radius: 18px;
}
.topbar { flex-direction: column; }
.toolbar { min-width: 0; justify-content: flex-start; }
.filters { align-items: flex-start; }
.map-body { padding: 16px; }
.sm-row { gap: 18px; }
.sm-main { min-width: 128px; font-size: 18px; }
.sm-chip { font-size: 13px; padding: 6px 8px; }
.sm-link-reason { min-width: 52px; max-width: 132px; font-size: 10px; padding: 3px 6px; }
.sm-segment.has-manual-link { gap: 5px; }
.sm-link-arrow-frag { min-width: 8px; }
.sm-arrow,
.sm-link-wrap {
min-width: 28px;
width: 28px;
}
}
@media (max-width: 820px) {
.sm-row {
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
.sm-main {
min-width: 0;
}
}
</style>
</head>
<body>
<div class="page">
<div class="shell">
<div class="topbar">
<div class="title-wrap">
<h1>Process Map</h1>
</div>
<div class="toolbar">
<div id="syncStatus" class="status" data-state="loading">DB 불러오는 중</div>
</div>
</div>
<div class="filters">
<div id="sitemapLegend" class="sm-legend"></div>
<div class="filters-right">
<select id="sitemapTeamSelect">
<option value="">전체 팀 필터</option>
</select>
<select id="sitemapSystemSelect">
<option value="">전체 시스템 필터</option>
</select>
</div>
</div>
<div id="mapPanel" class="map-panel loading">
<div id="mapBody" class="map-body">
<div id="sitemapFullContent" class="sitemap-full">
<div class="empty-state">데이터를 불러오는 중입니다.</div>
</div>
</div>
</div>
</div>
</div>
<script src="./app_client_config.js?v=20260327-1110"></script>
<script src="./process_map_shared.js"></script>
<script>
const APP_CONFIG = window.HMBIZ_CONFIG || {};
const API_BASE_URL = APP_CONFIG.apiBaseUrl || '';
const API_FLOW_DATA_URL = `${API_BASE_URL}/api/flow-data`;
const DEFAULT_JSON_CANDIDATES = APP_CONFIG.defaultJsonCandidates || [
'/flow-data-2026-03-23.json',
'/flow-data-2026-03-21_4.json'
];
const BROADCAST_CHANNEL_NAME = APP_CONFIG.processMapBroadcastChannel || 'hm-biz-process-map';
const STORAGE_SIGNAL_KEY = APP_CONFIG.processMapStorageSignalKey || 'hm-biz-process-map:refresh';
const NAVIGATION_SIGNAL_KEY = APP_CONFIG.processMapNavigationStorageKey || 'hm-biz-process-map:navigate';
const FOCUS_SIGNAL_KEY = APP_CONFIG.processMapFocusStorageKey || 'hm-biz-process-map:focus';
const POLL_INTERVAL_MS = APP_CONFIG.processMapPollIntervalMs || 15000;
const MAIN_APP_PATH = APP_CONFIG.mainAppPath || '/';
const URL_CONTEXT = new URL(window.location.href);
const MAIN_APP_ORIGIN = (() => {
const value = String(URL_CONTEXT.searchParams.get('mainOrigin') || '').trim();
if (!value) return window.location.origin;
try {
const parsed = new URL(value);
if (!/^https?:$/i.test(parsed.protocol)) return window.location.origin;
return parsed.origin;
} catch (error) {
return window.location.origin;
}
})();
const MAIN_APP_PATH_OVERRIDE = (() => {
const value = String(URL_CONTEXT.searchParams.get('mainPath') || '').trim();
if (!value) return '';
return value.startsWith('/') ? value : `/${value}`;
})();
const SHARED_SEGMENT = APP_CONFIG.sharedSegment || ['전표작성', '검토', '출금'];
const SITEMAP_AUTO_LINK_WIDTH = APP_CONFIG.sitemapAutoLinkWidth || 132;
const SITEMAP_MANUAL_LINK_WIDTH = APP_CONFIG.sitemapManualLinkWidth || 156;
const FORCE_REMOTE_SYNC = Boolean(APP_CONFIG.forceRemoteSync);
const API_FETCH_TIMEOUT_MS = Number(APP_CONFIG.apiFetchTimeoutMs || 4500);
const STEP_META_KEY = 'flow_step_meta_v1';
const FLOW_MODEL_KEY = 'flow_model_v1';
const syncStatusEl = document.getElementById('syncStatus');
const sitemapTeamSelectEl = document.getElementById('sitemapTeamSelect');
const sitemapSystemSelectEl = document.getElementById('sitemapSystemSelect');
const sitemapFullContentEl = document.getElementById('sitemapFullContent');
const sitemapLegendEl = document.getElementById('sitemapLegend');
const mapPanelEl = document.getElementById('mapPanel');
const mapBodyEl = document.getElementById('mapBody');
let MAIN_STEPS = [];
let SUB_FLOW = {};
let COMMON_ITEMS = [];
let COMMON_SUB_FLOW = {};
let stepMeta = {};
let subFlowLinks = {};
let loadingInFlight = false;
let pendingReload = false;
let currentUpdatedAt = '';
let activeFocus = null;
let fitTimer = null;
let fitObserver = null;
let hasRenderableData = false;
let lastPayloadSignature = '';
let lastInlineDataAt = 0;
let lastFocusRequestAt = 0;
function buildNoCacheUrl(url) {
const target = String(url || '').trim();
if (!target) return target;
const joiner = target.includes('?') ? '&' : '?';
return `${target}${joiner}_ts=${Date.now()}`;
}
function hashPayloadText(text) {
const value = String(text || '');
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = ((hash << 5) - hash) + value.charCodeAt(i);
hash |= 0;
}
return String(hash);
}
function computePayloadSignature(payload) {
if (!payload || typeof payload !== 'object') return '';
const flowModel = payload.flowModel && typeof payload.flowModel === 'object' ? payload.flowModel : {};
const stepMetaPayload = payload.stepMeta && typeof payload.stepMeta === 'object'
? payload.stepMeta
: {};
return [
hashPayloadText(JSON.stringify(flowModel)),
hashPayloadText(JSON.stringify(stepMetaPayload))
].join('|');
}
function applyPayloadToView(payload, sourceLabel = '') {
const signature = computePayloadSignature(payload);
const changed = !hasRenderableData || !signature || signature !== lastPayloadSignature;
const applied = applyRemotePayload(payload);
if (!applied) return false;
if (changed) {
processMapRenderer.refreshFilterOptions();
processMapRenderer.render();
scheduleMapFit();
}
hasRenderableData = true;
if (signature) lastPayloadSignature = signature;
if (sourceLabel) setSyncStatus('ready', sourceLabel);
return true;
}
async function fetchJsonWithTimeout(url, options = {}, timeoutMs = API_FETCH_TIMEOUT_MS) {
const target = String(url || '').trim();
if (!target) return null;
const finalUrl = buildNoCacheUrl(target);
if (typeof AbortController === 'undefined') {
const response = await fetch(finalUrl, options);
if (!response.ok) return null;
return response.json();
}
const controller = new AbortController();
const timerId = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(finalUrl, {
...options,
signal: controller.signal
});
if (!response.ok) return null;
return await response.json();
} finally {
window.clearTimeout(timerId);
}
}
const processMapRenderer = window.createProcessMapRenderer({
elements: {
fullContentEl: sitemapFullContentEl,
legendEl: sitemapLegendEl,
teamSelectEl: sitemapTeamSelectEl,
systemSelectEl: sitemapSystemSelectEl
},
getState: () => ({
mainSteps: MAIN_STEPS,
subFlow: SUB_FLOW,
commonItems: COMMON_ITEMS,
commonSubFlow: COMMON_SUB_FLOW,
stepMeta,
subFlowLinks
}),
onMainClick: jumpToMainDetail,
onStepClick: jumpToMainStep,
onCommonRootClick: jumpToCommonRoot,
isActiveFocus,
linkWidths: {
auto: SITEMAP_AUTO_LINK_WIDTH,
manual: SITEMAP_MANUAL_LINK_WIDTH
},
sharedSegment: SHARED_SEGMENT
});
function setActiveFocus(nextFocus) {
activeFocus = nextFocus;
}
function clearMapFit() {
if (!sitemapFullContentEl || !mapBodyEl) return;
sitemapFullContentEl.style.zoom = '';
sitemapFullContentEl.style.transform = '';
mapBodyEl.style.width = '';
mapBodyEl.style.height = '';
}
function fitMapToViewport() {
if (!sitemapFullContentEl || !mapPanelEl || !mapBodyEl) return;
clearMapFit();
if (!sitemapFullContentEl.children.length) return;
}
function scheduleMapFit() {
window.clearTimeout(fitTimer);
fitTimer = window.setTimeout(() => {
// Wait for any deferred layout work (e.g. column alignment) to settle.
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
fitMapToViewport();
});
});
}, 30);
}
function ensureFitObserver() {
if (fitObserver || typeof ResizeObserver === 'undefined') return;
fitObserver = new ResizeObserver(() => {
scheduleMapFit();
});
fitObserver.observe(sitemapFullContentEl);
fitObserver.observe(mapPanelEl);
}
function isSameFocus(left, right) {
if (!left || !right) return false;
return left.type === right.type
&& left.mainStep === right.mainStep
&& (left.step || '') === (right.step || '')
&& Boolean(left.isCommon) === Boolean(right.isCommon);
}
function applyExternalFocus(payload) {
if (!payload || payload.type !== 'focus-updated' || !payload.focus) return;
if (isSameFocus(activeFocus, payload.focus)) return;
setActiveFocus(payload.focus);
processMapRenderer.render();
}
window.applyProcessMapExternalFocus = applyExternalFocus;
function applyExternalData(payload) {
if (!payload || payload.type !== 'process-map-data' || !payload.payload) return false;
const applied = applyPayloadToView(payload.payload, '메인 화면 데이터 표시 중');
if (applied) lastInlineDataAt = Date.now();
return applied;
}
window.applyProcessMapExternalData = applyExternalData;
function syncFocusFromOpener() {
try {
if (!window.opener || window.opener.closed) return false;
if (typeof window.opener.getCurrentProcessMapFocusPayload !== 'function') return false;
const payload = window.opener.getCurrentProcessMapFocusPayload();
if (!payload || payload.type !== 'focus-updated' || !payload.focus) return false;
const changed = !isSameFocus(activeFocus, payload.focus);
if (changed) applyExternalFocus(payload);
return changed;
} catch (error) {
requestFocusFromMainViaPostMessage();
return false;
}
}
function requestDataFromMainViaPostMessage() {
try {
if (!window.opener || window.opener.closed) return false;
window.opener.postMessage({ type: 'process-map-request-data', at: Date.now() }, MAIN_APP_ORIGIN);
return true;
} catch (error) {
return false;
}
}
function pullDataFromOpenerSync() {
try {
if (!window.opener || window.opener.closed) return null;
if (typeof window.opener.getCurrentProcessMapDataPayload !== 'function') return null;
const payload = window.opener.getCurrentProcessMapDataPayload();
if (!payload || payload.type !== 'process-map-data') return null;
return payload;
} catch (error) {
requestDataFromMainViaPostMessage();
return null;
}
}
function requestFocusFromMainViaPostMessage() {
try {
if (!window.opener || window.opener.closed) return false;
const now = Date.now();
if (now - lastFocusRequestAt < 1200) return false;
lastFocusRequestAt = now;
window.opener.postMessage({ type: 'process-map-request-focus', at: Date.now() }, MAIN_APP_ORIGIN);
return true;
} catch (error) {
return false;
}
}
function isActiveFocus(type, mainStep, step = '', isCommon = false) {
if (!activeFocus) return false;
return activeFocus.type === type
&& activeFocus.mainStep === mainStep
&& (activeFocus.step || '') === (step || '')
&& Boolean(activeFocus.isCommon) === Boolean(isCommon);
}
function buildMainFocusUrl(focusType, mainStep, step = '', isCommon = false) {
const basePath = String(MAIN_APP_PATH_OVERRIDE || MAIN_APP_PATH || '/').trim() || '/';
const normalizedPath = basePath.startsWith('/') ? basePath : `/${basePath}`;
const url = new URL(normalizedPath, MAIN_APP_ORIGIN);
url.searchParams.set('focusType', focusType);
url.searchParams.set('mainStep', mainStep);
if (step) url.searchParams.set('step', step);
if (isCommon) url.searchParams.set('isCommon', '1');
return url.toString();
}
function buildMainFocusHash(focusType, mainStep, step = '', isCommon = false) {
const params = new URLSearchParams();
params.set('focusType', focusType);
params.set('mainStep', mainStep);
if (step) params.set('step', step);
if (isCommon) params.set('isCommon', '1');
params.set('at', String(Date.now()));
return `#${params.toString()}`;
}
function notifyMainNavigation(focusType, mainStep, step = '', isCommon = false) {
const payload = {
type: 'navigate-to-focus',
focusType,
mainStep,
step,
isCommon: Boolean(isCommon),
at: Date.now()
};
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(payload, MAIN_APP_ORIGIN);
}
} catch (error) {
// ignore and continue with the other channels below
}
try {
if (window.BroadcastChannel) {
const channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
channel.postMessage(payload);
channel.close();
}
} catch (error) {
// ignore and fall back to storage
}
try {
localStorage.setItem(NAVIGATION_SIGNAL_KEY, JSON.stringify(payload));
} catch (error) {
// ignore storage failures
}
}
function focusMainWindowOrOpen(url, hash = '') {
try {
if (window.opener && !window.opener.closed) {
try {
if (hash) {
window.opener.location.hash = hash;
}
} catch (innerError) {
// ignore hash assignment failures
}
window.opener.focus();
return true;
}
} catch (error) {
// ignore and open fallback below
}
window.open(url, '_blank');
return false;
}
function jumpToMainDetail(mainStep) {
setActiveFocus({ type: 'main', mainStep, step: '', isCommon: false });
processMapRenderer.render();
const fallbackUrl = buildMainFocusUrl('main', mainStep);
const focusHash = buildMainFocusHash('main', mainStep);
try {
if (window.opener && !window.opener.closed && typeof window.opener.jumpFromSitemapToMainDetail === 'function') {
window.opener.jumpFromSitemapToMainDetail(mainStep);
window.opener.focus();
return;
}
} catch (error) {
// use message and url fallback below
}
notifyMainNavigation('main', mainStep);
focusMainWindowOrOpen(fallbackUrl, focusHash);
}
function jumpToMainStep(mainStep, step, isCommon = false) {
setActiveFocus({ type: 'step', mainStep, step, isCommon });
processMapRenderer.render();
const fallbackUrl = buildMainFocusUrl('step', mainStep, step, isCommon);
const focusHash = buildMainFocusHash('step', mainStep, step, isCommon);
try {
if (window.opener && !window.opener.closed && typeof window.opener.jumpFromSitemapToStepDetail === 'function') {
window.opener.jumpFromSitemapToStepDetail(mainStep, step, isCommon);
window.opener.focus();
return;
}
} catch (error) {
// use message and url fallback below
}
notifyMainNavigation('step', mainStep, step, isCommon);
focusMainWindowOrOpen(fallbackUrl, focusHash);
}
function jumpToCommonRoot(item) {
setActiveFocus({ type: 'common-root', mainStep: item, step: '', isCommon: true });
processMapRenderer.render();
const fallbackUrl = buildMainFocusUrl('common-root', item);
const focusHash = buildMainFocusHash('common-root', item, '', true);
try {
if (window.opener && !window.opener.closed && typeof window.opener.openFirstSubStepFromCommonFlow === 'function') {
window.opener.openFirstSubStepFromCommonFlow(item, false);
window.opener.focus();
return;
}
} catch (error) {
// use message and url fallback below
}
notifyMainNavigation('common-root', item, '', true);
focusMainWindowOrOpen(fallbackUrl, focusHash);
}
function setSyncStatus(state, message) {
syncStatusEl.dataset.state = state;
syncStatusEl.textContent = message;
}
function applyFlowModelObject(parsed) {
MAIN_STEPS = Array.isArray(parsed.mainSteps) ? parsed.mainSteps.map((v) => String(v)) : [];
SUB_FLOW = {};
if (parsed.subFlow && typeof parsed.subFlow === 'object') {
Object.entries(parsed.subFlow).forEach(([key, value]) => {
if (Array.isArray(value)) SUB_FLOW[key] = value.map((item) => String(item));
});
}
COMMON_ITEMS = Array.isArray(parsed.commonItems) ? parsed.commonItems.map((v) => String(v)).filter(Boolean) : [];
COMMON_SUB_FLOW = {};
if (parsed.commonSubFlow && typeof parsed.commonSubFlow === 'object') {
Object.entries(parsed.commonSubFlow).forEach(([key, value]) => {
if (Array.isArray(value)) COMMON_SUB_FLOW[key] = value.map((item) => String(item));
});
}
subFlowLinks = {};
if (parsed.subFlowLinks && typeof parsed.subFlowLinks === 'object') {
Object.entries(parsed.subFlowLinks).forEach(([key, value]) => {
if (value && typeof value === 'object' && typeof value.reason === 'string') {
subFlowLinks[key] = { reason: value.reason };
}
});
}
}
function applyRemotePayload(payload) {
const flowModel = (payload.flowModel && typeof payload.flowModel === 'object')
? payload.flowModel
: ((Array.isArray(payload.mainSteps) || (payload.subFlow && typeof payload.subFlow === 'object')) ? payload : null);
if (!flowModel) return false;
applyFlowModelObject(flowModel);
stepMeta = (payload.stepMeta && typeof payload.stepMeta === 'object') ? payload.stepMeta : {};
currentUpdatedAt = payload.exportedAt || '';
return true;
}
async function fetchJsonFallback() {
for (const path of DEFAULT_JSON_CANDIDATES) {
try {
const parsed = await fetchJsonWithTimeout(path, { cache: 'no-store' });
if (parsed) return parsed;
} catch (error) {
// try next
}
}
return null;
}
function fetchLocalSnapshot() {
try {
const flowRaw = localStorage.getItem(FLOW_MODEL_KEY);
if (!flowRaw) return null;
const flowModel = JSON.parse(flowRaw);
if (!flowModel || typeof flowModel !== 'object') return null;
let meta = {};
try {
const metaRaw = localStorage.getItem(STEP_META_KEY);
if (metaRaw) {
const parsedMeta = JSON.parse(metaRaw);
if (parsedMeta && typeof parsedMeta === 'object') meta = parsedMeta;
}
} catch (error) {
// ignore malformed meta
}
return {
flowModel,
stepMeta: meta,
exportedAt: ''
};
} catch (error) {
return null;
}
}
async function loadAndRender() {
if (loadingInFlight) {
pendingReload = true;
return;
}
loadingInFlight = true;
if (!hasRenderableData) {
mapPanelEl.classList.add('loading');
setSyncStatus('loading', 'DB 불러오는 중');
} else {
setSyncStatus('loading', '기존 화면 유지 중, 백그라운드 동기화 중');
}
try {
const openerPayload = pullDataFromOpenerSync();
if (!hasRenderableData && openerPayload && applyExternalData(openerPayload)) {
mapPanelEl.classList.remove('loading');
}
const snapshotPayload = fetchLocalSnapshot();
if (!hasRenderableData && snapshotPayload && applyPayloadToView(snapshotPayload)) {
setSyncStatus('loading', '마지막 동기화 데이터 확인 중');
}
let payload = null;
let dataSource = 'remote';
try {
payload = await fetchJsonWithTimeout(API_FLOW_DATA_URL, {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache',
Pragma: 'no-cache'
}
});
if (payload) {
dataSource = 'remote';
}
} catch (error) {
// DB/API 접근 실패 시 로컬 스냅샷 또는 시드로 폴백한다.
}
if (!payload && openerPayload && applyPayloadToView(openerPayload.payload)) {
payload = openerPayload.payload;
dataSource = 'main-window';
}
if (!payload && snapshotPayload) {
payload = snapshotPayload;
dataSource = 'local';
}
if (!payload) {
payload = await fetchJsonFallback();
if (payload) dataSource = 'fallback';
}
if (!payload || !applyPayloadToView(payload)) {
throw new Error('invalid flow payload');
}
if (dataSource === 'local') {
setSyncStatus('ready', '마지막 동기화 데이터 표시 중');
} else if (dataSource === 'main-window') {
setSyncStatus('ready', '메인 화면 데이터 표시 중');
} else if (dataSource === 'fallback') {
setSyncStatus('ready', 'Gitea 데이터 불러옴');
} else {
setSyncStatus('ready', 'DB 연결됨');
}
} catch (error) {
console.warn('Process map load failed', error);
setSyncStatus('error', '동기화 실패');
if (!sitemapFullContentEl.children.length) {
const empty = document.createElement('div');
empty.className = 'empty-state';
empty.textContent = 'Process Map 데이터를 불러오지 못했습니다.';
sitemapFullContentEl.innerHTML = '';
sitemapFullContentEl.appendChild(empty);
}
} finally {
loadingInFlight = false;
mapPanelEl.classList.remove('loading');
if (pendingReload) {
pendingReload = false;
loadAndRender();
}
}
}
function triggerReloadSoon() {
if (Date.now() - lastInlineDataAt < 1500) return;
window.clearTimeout(triggerReloadSoon.timerId);
triggerReloadSoon.timerId = window.setTimeout(() => {
loadAndRender();
}, 450);
}
triggerReloadSoon.timerId = null;
sitemapFullContentEl.addEventListener('click', (event) => {
const target = event.target.closest('[data-map-action]');
if (!target || !sitemapFullContentEl.contains(target)) return;
const action = target.dataset.mapAction || '';
const mainStep = target.dataset.mainStep || '';
const step = target.dataset.step || '';
const isCommon = target.dataset.isCommon === '1';
if (!mainStep) return;
if (action === 'main') {
jumpToMainDetail(mainStep);
return;
}
if (action === 'step') {
jumpToMainStep(mainStep, step, isCommon);
return;
}
if (action === 'common-root') {
jumpToCommonRoot(mainStep);
}
});
sitemapTeamSelectEl.addEventListener('change', () => {
processMapRenderer.setFilters({ team: sitemapTeamSelectEl.value });
processMapRenderer.render();
scheduleMapFit();
});
sitemapSystemSelectEl.addEventListener('change', () => {
processMapRenderer.setFilters({ system: sitemapSystemSelectEl.value });
processMapRenderer.render();
scheduleMapFit();
});
if (window.BroadcastChannel) {
const channel = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
channel.addEventListener('message', (event) => {
if (event.data && event.data.type === 'process-map-data') {
applyExternalData(event.data);
return;
}
if (event.data && event.data.type === 'flow-data-updated') {
triggerReloadSoon();
return;
}
if (event.data && event.data.type === 'focus-updated') {
applyExternalFocus(event.data);
}
});
}
window.addEventListener('message', (event) => {
if (event.origin !== MAIN_APP_ORIGIN && event.origin !== window.location.origin) return;
if (!event.data || !event.data.type) return;
if (event.data.type === 'process-map-data') {
applyExternalData(event.data);
return;
}
if (event.data.type === 'focus-updated') {
applyExternalFocus(event.data);
return;
}
if (event.data.type === 'flow-data-updated') {
triggerReloadSoon();
}
});
window.addEventListener('storage', (event) => {
if (event.key === STORAGE_SIGNAL_KEY) {
triggerReloadSoon();
return;
}
if (event.key === FOCUS_SIGNAL_KEY && event.newValue) {
try {
applyExternalFocus(JSON.parse(event.newValue));
} catch (error) {
// ignore malformed payloads
}
}
});
window.addEventListener('focus', () => {
syncFocusFromOpener();
requestFocusFromMainViaPostMessage();
triggerReloadSoon();
});
window.addEventListener('resize', () => {
if (processMapRenderer && typeof processMapRenderer.realign === 'function') {
processMapRenderer.realign();
}
scheduleMapFit();
});
window.setInterval(() => {
if (!document.hidden) {
syncFocusFromOpener();
requestFocusFromMainViaPostMessage();
loadAndRender();
}
}, POLL_INTERVAL_MS);
try {
if (window.opener && !window.opener.closed && typeof window.opener.registerProcessMapPopup === 'function') {
window.opener.registerProcessMapPopup(window);
}
} catch (error) {
// ignore opener registration failures
}
ensureFitObserver();
syncFocusFromOpener();
requestDataFromMainViaPostMessage();
loadAndRender();
</script>
</body>
</html>