window.createFlowRenderer = function createFlowRenderer(options) { const { elements, getState, setSelectedChain, openEditor, closeEditor, saveFlowModel, closeSitemapModal, askStepLabel, getNextFlow, getPivotLabel, getSubFlowLinkInfo, formatConnectorReason, setSubFlowLinkReason, remapSubFlowLinksOnRename, syncSubFlowLinks, getSubFlowLinks, setSubFlowLinks, getMainBarMode = () => false, getLv2BarMode = () => false, setMainBarMode = () => {}, setLv2BarMode = () => {}, onBarModeLayoutChange = () => {} } = options || {}; const { mainFlowEl, mainPanelEl, mainCommonSectionEl, drillColumnsEl } = elements || {}; let barHoverTooltipEl = null; function ensureBarHoverTooltip() { if (barHoverTooltipEl && document.body.contains(barHoverTooltipEl)) { return barHoverTooltipEl; } const existing = document.getElementById('barStepHoverTooltip'); if (existing) { barHoverTooltipEl = existing; return barHoverTooltipEl; } const tooltip = document.createElement('div'); tooltip.id = 'barStepHoverTooltip'; tooltip.className = 'bar-step-tooltip'; tooltip.setAttribute('role', 'tooltip'); document.body.appendChild(tooltip); barHoverTooltipEl = tooltip; return barHoverTooltipEl; } function hideBarStepTooltip() { const tooltip = ensureBarHoverTooltip(); tooltip.classList.remove('show'); } function positionBarStepTooltip(btn) { const tooltip = ensureBarHoverTooltip(); const rect = btn.getBoundingClientRect(); let left = rect.right + 10; const top = rect.top + (rect.height / 2); tooltip.style.left = `${Math.round(left)}px`; tooltip.style.top = `${Math.round(top)}px`; const tooltipRect = tooltip.getBoundingClientRect(); const viewportPadding = 8; if ((left + tooltipRect.width + viewportPadding) > window.innerWidth) { left = Math.max(viewportPadding, rect.left - tooltipRect.width - 10); tooltip.style.left = `${Math.round(left)}px`; } } function showBarStepTooltip(btn, label) { const barPanel = btn.closest('.flow-panel.flow-bar-mode'); if (!barPanel) return; const text = String(label || '').trim(); if (!text) return; const tooltip = ensureBarHoverTooltip(); tooltip.textContent = text; tooltip.classList.add('show'); positionBarStepTooltip(btn); } function bindBarStepTooltip(btn, label) { btn.addEventListener('mouseenter', () => showBarStepTooltip(btn, label)); btn.addEventListener('mousemove', () => positionBarStepTooltip(btn)); btn.addEventListener('mouseleave', hideBarStepTooltip); btn.addEventListener('focus', () => showBarStepTooltip(btn, label)); btn.addEventListener('blur', hideBarStepTooltip); } function formatUpdatedAtText(value) { const text = String(value || '').trim(); if (!text) return ''; const parsed = new Date(text); if (Number.isNaN(parsed.getTime())) return text; return `최종 수정 ${parsed.toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`; } function getEntityMetaValue(mapName, key) { const state = getState(); const entityMeta = state && state.entityMeta ? state.entityMeta : {}; const map = entityMeta[mapName]; if (!map || typeof map !== 'object') return ''; return String(map[key] || '').trim(); } function getStepMetaText(stepKey) { const stableId = getEntityMetaValue('stepStableIdByKey', stepKey); const parts = []; if (stableId) parts.push(`ID: ${stableId}`); return parts.join('\n'); } function getFlowMetaText(flowKey) { const stableId = getEntityMetaValue('flowStableIdByKey', flowKey); const contentHash = getEntityMetaValue('flowContentHashByKey', flowKey); const parts = []; if (stableId) parts.push(`ID: ${stableId}`); if (contentHash) parts.push(`HASH: ${contentHash}`); return parts.join('\n'); } function getLinkMetaText(mainStep, fromStep, toStep) { const linkKey = `${mainStep}||${fromStep}>>${toStep}`; const stableId = getEntityMetaValue('linkStableIdByKey', linkKey); const parts = []; if (stableId) parts.push(`ID: ${stableId}`); return parts.join('\n'); } function renderVerticalFlow(container, list, activeValue, onSelect, renderOptions) { const listEl = document.createElement('div'); listEl.className = 'flow-list'; const { allowEdit = false, onEdit = null, onDelete = null, onMoveUp = null, onMoveDown = null, onInsertAfter = null, onAddEnd = null, getMetaText = null, getConnectorInfo = null, onEditConnector = null, onClearConnector = null } = renderOptions || {}; list.forEach((step, idx) => { if (allowEdit) { const row = document.createElement('div'); row.className = 'step-row'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'step-btn'; if (activeValue === step) btn.classList.add('active'); const metaText = typeof getMetaText === 'function' ? getMetaText(step, idx) : ''; if (metaText) { btn.classList.add('with-meta'); // 편집 모드에서는 메타 정보를 버튼 하단(step-edit-meta)에만 표시한다. // 버튼 내부 중복 표시는 제거한다. btn.innerHTML = `${step}`; } else { btn.textContent = step; } btn.setAttribute('data-step-label', String(step)); btn.setAttribute('aria-label', String(step)); btn.removeAttribute('title'); bindBarStepTooltip(btn, step); btn.addEventListener('click', () => onSelect(step)); row.appendChild(btn); if (metaText) { const meta = document.createElement('div'); meta.className = 'step-edit-meta'; meta.textContent = metaText; row.appendChild(meta); } const actions = document.createElement('div'); actions.className = 'step-actions'; const upBtn = document.createElement('button'); upBtn.type = 'button'; upBtn.className = 'icon-btn'; upBtn.title = '위로 이동'; upBtn.textContent = '↑'; upBtn.disabled = idx === 0; upBtn.style.opacity = idx === 0 ? '0.4' : '1'; upBtn.addEventListener('click', () => onMoveUp && onMoveUp(idx)); actions.appendChild(upBtn); const downBtn = document.createElement('button'); downBtn.type = 'button'; downBtn.className = 'icon-btn'; downBtn.title = '아래로 이동'; downBtn.textContent = '↓'; downBtn.disabled = idx === list.length - 1; downBtn.style.opacity = idx === list.length - 1 ? '0.4' : '1'; downBtn.addEventListener('click', () => onMoveDown && onMoveDown(idx)); actions.appendChild(downBtn); const editBtn = document.createElement('button'); editBtn.type = 'button'; editBtn.className = 'icon-btn'; editBtn.title = '이름 수정'; editBtn.textContent = '✎'; editBtn.addEventListener('click', () => onEdit && onEdit(idx)); actions.appendChild(editBtn); const delBtn = document.createElement('button'); delBtn.type = 'button'; delBtn.className = 'icon-btn delete'; delBtn.title = '삭제'; delBtn.textContent = '×'; delBtn.addEventListener('click', () => onDelete && onDelete(idx)); actions.appendChild(delBtn); row.appendChild(actions); listEl.appendChild(row); } else { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'step-btn'; if (activeValue === step) btn.classList.add('active'); const metaText = typeof getMetaText === 'function' ? getMetaText(step, idx) : ''; if (metaText) { btn.classList.add('with-meta'); btn.innerHTML = `${step}${metaText}`; } else { btn.textContent = step; } btn.setAttribute('data-step-label', String(step)); btn.setAttribute('aria-label', String(step)); btn.removeAttribute('title'); bindBarStepTooltip(btn, step); btn.addEventListener('click', () => onSelect(step)); listEl.appendChild(btn); } if (idx < list.length - 1) { const connectorInfo = typeof getConnectorInfo === 'function' ? (getConnectorInfo(step, idx, list[idx + 1]) || { broken: false, reason: '' }) : { broken: false, reason: '' }; if (allowEdit) { const insertWrap = document.createElement('div'); insertWrap.className = 'insert-row'; const arrow = document.createElement('div'); arrow.className = `down-arrow${connectorInfo.broken ? ' broken' : ''}`; arrow.textContent = '↓'; insertWrap.appendChild(arrow); if (connectorInfo.reason) { const reason = document.createElement('div'); reason.className = 'link-reason'; reason.textContent = formatConnectorReason(connectorInfo.reason); insertWrap.appendChild(reason); } const connectorMetaText = String(connectorInfo.metaText || '').trim(); if (connectorMetaText) { const meta = document.createElement('div'); meta.className = 'step-edit-meta'; meta.textContent = connectorMetaText; insertWrap.appendChild(meta); } if (typeof onEditConnector === 'function') { const linkBtn = document.createElement('button'); linkBtn.type = 'button'; linkBtn.className = 'link-edit-btn'; linkBtn.textContent = connectorInfo.broken ? '수동 처리 수정' : '수동 처리 추가'; linkBtn.addEventListener('click', () => onEditConnector(idx)); insertWrap.appendChild(linkBtn); if (connectorInfo.broken && typeof onClearConnector === 'function') { const clearBtn = document.createElement('button'); clearBtn.type = 'button'; clearBtn.className = 'link-clear-btn'; clearBtn.textContent = '자동 복구'; clearBtn.addEventListener('click', () => onClearConnector(idx)); insertWrap.appendChild(clearBtn); } } const insertBtn = document.createElement('button'); insertBtn.type = 'button'; insertBtn.className = 'insert-btn'; insertBtn.title = '아래에 삽입'; insertBtn.textContent = '+'; insertBtn.addEventListener('click', () => onInsertAfter && onInsertAfter(idx)); insertWrap.appendChild(insertBtn); listEl.appendChild(insertWrap); } else { const arrow = document.createElement('div'); arrow.className = `down-arrow${connectorInfo.broken ? ' broken' : ''}`; arrow.textContent = '↓'; listEl.appendChild(arrow); if (connectorInfo.reason) { const reason = document.createElement('div'); reason.className = 'link-reason'; reason.textContent = connectorInfo.reason; listEl.appendChild(reason); } } } }); if (allowEdit) { const addEndWrap = document.createElement('div'); addEndWrap.className = 'insert-row'; const addEndBtn = document.createElement('button'); addEndBtn.type = 'button'; addEndBtn.className = 'add-end-btn'; addEndBtn.textContent = '+ 마지막에 추가'; addEndBtn.addEventListener('click', () => onAddEnd && onAddEnd()); addEndWrap.appendChild(addEndBtn); listEl.appendChild(addEndWrap); } container.appendChild(listEl); } function renderCommonSection() { const state = getState(); const { editMode, commonItems, commonSubFlow, selectedChain } = state; mainCommonSectionEl.innerHTML = ''; const head = document.createElement('div'); head.className = 'common-head'; const title = document.createElement('p'); title.className = 'common-title'; title.textContent = '공통'; head.appendChild(title); if (editMode) { const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.className = 'common-add-btn'; addBtn.textContent = '+ 공통 추가'; addBtn.addEventListener('click', () => { const next = askStepLabel(''); if (!next) return; commonItems.push(next); if (!commonSubFlow[next]) commonSubFlow[next] = []; saveFlowModel(); renderCommonSection(); }); head.appendChild(addBtn); } mainCommonSectionEl.appendChild(head); const list = document.createElement('div'); list.className = 'common-list'; if (!commonItems.length) { const empty = document.createElement('div'); empty.className = 'common-empty'; empty.textContent = '공통 항목이 없습니다.'; list.appendChild(empty); } else { commonItems.forEach((item, idx) => { const commonMetaText = editMode ? getStepMetaText(`common|${item}`) : ''; const isActiveCommon = selectedChain[0] === `common::${item}`; if (editMode) { const row = document.createElement('div'); row.className = 'common-row'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'common-btn'; if (isActiveCommon) btn.classList.add('active'); btn.textContent = item; btn.setAttribute('data-step-label', String(item)); btn.setAttribute('aria-label', String(item)); btn.removeAttribute('title'); bindBarStepTooltip(btn, item); btn.addEventListener('click', () => { api.openFirstSubStepFromCommonFlow(item); }); row.appendChild(btn); if (commonMetaText) { const meta = document.createElement('div'); meta.className = 'step-edit-meta'; meta.textContent = commonMetaText; row.appendChild(meta); } const actions = document.createElement('div'); actions.className = 'step-actions'; const upBtn = document.createElement('button'); upBtn.type = 'button'; upBtn.className = 'icon-btn'; upBtn.title = '위로 이동'; upBtn.textContent = '↑'; upBtn.disabled = idx === 0; upBtn.style.opacity = idx === 0 ? '0.4' : '1'; upBtn.addEventListener('click', () => { if (idx <= 0) return; [commonItems[idx - 1], commonItems[idx]] = [commonItems[idx], commonItems[idx - 1]]; saveFlowModel(); renderCommonSection(); }); actions.appendChild(upBtn); const downBtn = document.createElement('button'); downBtn.type = 'button'; downBtn.className = 'icon-btn'; downBtn.title = '아래로 이동'; downBtn.textContent = '↓'; downBtn.disabled = idx === commonItems.length - 1; downBtn.style.opacity = idx === commonItems.length - 1 ? '0.4' : '1'; downBtn.addEventListener('click', () => { if (idx >= commonItems.length - 1) return; [commonItems[idx + 1], commonItems[idx]] = [commonItems[idx], commonItems[idx + 1]]; saveFlowModel(); renderCommonSection(); }); actions.appendChild(downBtn); const editBtn = document.createElement('button'); editBtn.type = 'button'; editBtn.className = 'icon-btn'; editBtn.title = '이름 수정'; editBtn.textContent = '✎'; editBtn.addEventListener('click', () => { const next = askStepLabel(item); if (!next || next === item) return; commonItems[idx] = next; if (commonSubFlow[item]) { commonSubFlow[next] = commonSubFlow[item]; delete commonSubFlow[item]; } saveFlowModel(); renderCommonSection(); }); actions.appendChild(editBtn); const delBtn = document.createElement('button'); delBtn.type = 'button'; delBtn.className = 'icon-btn delete'; delBtn.title = '삭제'; delBtn.textContent = '×'; delBtn.addEventListener('click', () => { if (!window.confirm(`'${item}' 공통 항목을 삭제할까요?`)) return; commonItems.splice(idx, 1); delete commonSubFlow[item]; if (selectedChain[0] === `common::${item}`) { setSelectedChain([]); closeEditor(); } saveFlowModel(); renderCommonSection(); }); actions.appendChild(delBtn); row.appendChild(actions); list.appendChild(row); } else { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'common-btn'; if (isActiveCommon) btn.classList.add('active'); btn.textContent = item; btn.setAttribute('data-step-label', String(item)); btn.setAttribute('aria-label', String(item)); btn.removeAttribute('title'); bindBarStepTooltip(btn, item); btn.addEventListener('click', () => { api.openFirstSubStepFromCommonFlow(item); }); list.appendChild(btn); } }); } mainCommonSectionEl.appendChild(list); } function openFirstSubStepFromCommonFlow(item, fromSitemap = false) { const targetCommonItem = String(item || '').trim(); if (!targetCommonItem) return; const { commonSubFlow } = getState(); const subSteps = commonSubFlow[targetCommonItem] || []; if (subSteps.length) { const firstSubStep = subSteps[0]; setSelectedChain([`common::${targetCommonItem}`, firstSubStep]); api.renderAll(); openEditor(`0|${targetCommonItem}|${firstSubStep}`, firstSubStep); } else { setSelectedChain([`common::${targetCommonItem}`]); api.renderAll(); openEditor(`common|${targetCommonItem}`, targetCommonItem); } if (fromSitemap) closeSitemapModal(); } function openFirstSubStepFromProjectFlow(mainStep) { const targetMainStep = String(mainStep || '').trim(); if (!targetMainStep) return; const { subFlow } = getState(); const subSteps = subFlow[targetMainStep] || []; if (subSteps.length) { const firstSubStep = subSteps[0]; setSelectedChain([targetMainStep, firstSubStep]); api.renderAll(); openEditor(`0|${targetMainStep}|${firstSubStep}`, firstSubStep); return; } setSelectedChain([targetMainStep]); api.renderAll(); openEditor(`main|${targetMainStep}`, targetMainStep); } function renderMain() { const { mainSteps, selectedChain, editMode, subFlow } = getState(); const mainBarMode = Boolean(!editMode && getMainBarMode()); mainFlowEl.innerHTML = ''; mainFlowEl.scrollTop = 0; mainPanelEl.scrollTop = 0; mainPanelEl.classList.toggle('flow-bar-mode', mainBarMode); mainPanelEl.removeAttribute('data-bar-level'); const mainTitleEl = mainPanelEl.querySelector('.panel-head .title'); const mainHeadEl = mainPanelEl.querySelector('.panel-head'); if (mainHeadEl) { const existingBtn = mainHeadEl.querySelector('.bar-collapse-btn'); if (existingBtn) existingBtn.remove(); if (!editMode && !mainBarMode) { const collapseBtn = document.createElement('button'); collapseBtn.type = 'button'; collapseBtn.className = 'bar-collapse-btn'; collapseBtn.title = '바 보기로 전환'; collapseBtn.setAttribute('aria-label', '바 보기로 전환'); collapseBtn.textContent = '−'; collapseBtn.addEventListener('click', () => { setMainBarMode(true); onBarModeLayoutChange(); api.renderAll(); }); mainHeadEl.appendChild(collapseBtn); } } if (mainTitleEl) { mainTitleEl.classList.remove('bar-toggle-title'); mainTitleEl.title = editMode ? getFlowMetaText('main') : ''; mainTitleEl.onclick = null; } if (mainBarMode) { const restoreBtn = document.createElement('button'); restoreBtn.type = 'button'; restoreBtn.className = 'bar-restore-btn'; restoreBtn.textContent = '+'; restoreBtn.addEventListener('click', () => { setMainBarMode(false); onBarModeLayoutChange(); api.renderAll(); }); mainFlowEl.appendChild(restoreBtn); } renderVerticalFlow(mainFlowEl, mainSteps, selectedChain[0], (step) => { openFirstSubStepFromProjectFlow(step); }, { allowEdit: editMode, getMetaText: editMode ? (step) => getStepMetaText(`main|${step}`) : null, onEdit: (idx) => { const oldStep = mainSteps[idx]; const next = askStepLabel(oldStep); if (!next || next === oldStep) return; mainSteps[idx] = next; if (subFlow[oldStep]) { subFlow[next] = subFlow[oldStep]; delete subFlow[oldStep]; const migrated = {}; Object.entries(getSubFlowLinks()).forEach(([k, v]) => { if (k.startsWith(`${oldStep}||`)) { migrated[k.replace(`${oldStep}||`, `${next}||`)] = v; } else { migrated[k] = v; } }); setSubFlowLinks(migrated); } else if (!subFlow[next]) { subFlow[next] = []; } if (selectedChain[0] === oldStep) { const nextChain = [...selectedChain]; nextChain[0] = next; setSelectedChain(nextChain); } saveFlowModel(); api.renderAll(); }, onDelete: (idx) => { const target = mainSteps[idx]; if (!window.confirm(`'${target}' 스텝을 삭제할까요?`)) return; mainSteps.splice(idx, 1); delete subFlow[target]; const nextLinks = { ...getSubFlowLinks() }; Object.keys(nextLinks).forEach((k) => { if (k.startsWith(`${target}||`)) delete nextLinks[k]; }); setSubFlowLinks(nextLinks); if (selectedChain[0] === target) { setSelectedChain([]); closeEditor(); } saveFlowModel(); api.renderAll(); }, onMoveUp: (idx) => { if (idx <= 0) return; [mainSteps[idx - 1], mainSteps[idx]] = [mainSteps[idx], mainSteps[idx - 1]]; saveFlowModel(); api.renderAll(); }, onMoveDown: (idx) => { if (idx >= mainSteps.length - 1) return; [mainSteps[idx + 1], mainSteps[idx]] = [mainSteps[idx], mainSteps[idx + 1]]; saveFlowModel(); api.renderAll(); }, onInsertAfter: (idx) => { const next = askStepLabel(''); if (!next) return; mainSteps.splice(idx + 1, 0, next); if (!subFlow[next]) subFlow[next] = []; saveFlowModel(); api.renderAll(); }, onAddEnd: () => { const next = askStepLabel(''); if (!next) return; mainSteps.push(next); if (!subFlow[next]) subFlow[next] = []; saveFlowModel(); api.renderAll(); } }); renderCommonSection(); } function renderDrillColumns() { const state = getState(); const { selectedChain, editMode, commonSubFlow, subFlow, drillFlow, stepMeta } = state; drillColumnsEl.innerHTML = ''; if (!selectedChain[0]) return; let level = 0; while (true) { if (level >= 1) break; const pivot = selectedChain[level]; const list = getNextFlow(level, pivot); if (!list.length) break; const panel = document.createElement('section'); panel.className = 'flow-panel'; const panelLevel = level; const lv2BarMode = Boolean(!editMode && panelLevel === 0 && getLv2BarMode()); panel.classList.toggle('flow-bar-mode', lv2BarMode); panel.removeAttribute('data-bar-level'); const panelPivot = getPivotLabel(pivot); const isCommonPivot = String(pivot).startsWith('common::'); const titleRow = document.createElement('div'); titleRow.className = 'panel-head'; const title = document.createElement('h2'); title.className = 'title'; title.textContent = panelLevel === 0 ? 'STEP FLOW' : `${panelPivot} FLOW`; if (editMode && panelLevel === 0) { title.title = getFlowMetaText(`0|${panelPivot}`); } if (!editMode && panelLevel === 0) { title.classList.remove('bar-toggle-title'); title.title = ''; } titleRow.appendChild(title); if (!editMode && panelLevel === 0 && !lv2BarMode) { const collapseBtn = document.createElement('button'); collapseBtn.type = 'button'; collapseBtn.className = 'bar-collapse-btn'; collapseBtn.title = '바 보기로 전환'; collapseBtn.setAttribute('aria-label', '바 보기로 전환'); collapseBtn.textContent = '−'; collapseBtn.addEventListener('click', () => { setLv2BarMode(true); onBarModeLayoutChange(); api.renderAll(); }); titleRow.appendChild(collapseBtn); } panel.appendChild(titleRow); const subtitleSpacer = document.createElement('p'); subtitleSpacer.className = 'project-title'; subtitleSpacer.textContent = panelLevel === 0 ? panelPivot : '프로젝트'; panel.appendChild(subtitleSpacer); if (lv2BarMode) { const restoreBtn = document.createElement('button'); restoreBtn.type = 'button'; restoreBtn.className = 'bar-restore-btn'; restoreBtn.textContent = '+'; restoreBtn.addEventListener('click', () => { setLv2BarMode(false); onBarModeLayoutChange(); api.renderAll(); }); panel.appendChild(restoreBtn); } renderVerticalFlow(panel, list, selectedChain[panelLevel + 1], (step) => { const nextChain = selectedChain.slice(0, panelLevel + 1); nextChain[panelLevel + 1] = step; setSelectedChain(nextChain); api.renderAll(); openEditor(`${panelLevel}|${panelPivot}|${step}`, step); }, { getMetaText: editMode ? (step) => getStepMetaText(`${panelLevel}|${panelPivot}|${step}`) : (panelLevel === 0 && !isCommonPivot ? (step) => { const key = `0|${panelPivot}|${step}`; const info = stepMeta[key] || {}; const team = info.team || '-'; const system = info.system || '-'; return `${team} | ${system}`; } : null), getConnectorInfo: panelLevel === 0 ? (fromStep, idx, toStep) => { const link = getSubFlowLinkInfo(panelPivot, fromStep, toStep); return { broken: Boolean(link.reason), reason: link.reason || '', metaText: editMode ? getLinkMetaText(panelPivot, fromStep, toStep) : '' }; } : null, onEditConnector: panelLevel === 0 ? (idx) => { const fromStep = list[idx]; const toStep = list[idx + 1]; if (!fromStep || !toStep) return; const current = getSubFlowLinkInfo(panelPivot, fromStep, toStep).reason || ''; const value = window.prompt(`'${fromStep}' -> '${toStep}' 사이의 수동 처리 내용을 입력하세요.\n(비우면 자동 연결로 복구)`, current); if (value === null) return; setSubFlowLinkReason(panelPivot, fromStep, toStep, value); saveFlowModel(); api.renderAll(); } : null, onClearConnector: panelLevel === 0 ? (idx) => { const fromStep = list[idx]; const toStep = list[idx + 1]; if (!fromStep || !toStep) return; setSubFlowLinkReason(panelPivot, fromStep, toStep, ''); saveFlowModel(); api.renderAll(); } : null, allowEdit: editMode, onEdit: (idx) => { const targetList = panelLevel === 0 ? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || [])) : (drillFlow[panelPivot] || []); const oldStep = targetList[idx]; const next = askStepLabel(oldStep); if (!next || next === oldStep) return; targetList[idx] = next; if (panelLevel === 0) remapSubFlowLinksOnRename(panelPivot, oldStep, next); if (drillFlow[oldStep]) { drillFlow[next] = drillFlow[oldStep]; delete drillFlow[oldStep]; } if (selectedChain[panelLevel + 1] === oldStep) { const nextChain = [...selectedChain]; nextChain[panelLevel + 1] = next; setSelectedChain(nextChain); } if (panelLevel === 0) syncSubFlowLinks(panelPivot); saveFlowModel(); api.renderAll(); }, onDelete: (idx) => { const targetList = panelLevel === 0 ? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || [])) : (drillFlow[panelPivot] || []); const target = targetList[idx]; if (!window.confirm(`'${target}' 스텝을 삭제할까요?`)) return; targetList.splice(idx, 1); if (selectedChain[panelLevel + 1] === target) { setSelectedChain(selectedChain.slice(0, panelLevel + 1)); closeEditor(); } if (panelLevel === 0) syncSubFlowLinks(panelPivot); saveFlowModel(); api.renderAll(); }, onMoveUp: (idx) => { const targetList = panelLevel === 0 ? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || [])) : (drillFlow[panelPivot] || []); if (idx <= 0) return; [targetList[idx - 1], targetList[idx]] = [targetList[idx], targetList[idx - 1]]; if (panelLevel === 0) syncSubFlowLinks(panelPivot); saveFlowModel(); api.renderAll(); }, onMoveDown: (idx) => { const targetList = panelLevel === 0 ? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || [])) : (drillFlow[panelPivot] || []); if (idx >= targetList.length - 1) return; [targetList[idx + 1], targetList[idx]] = [targetList[idx], targetList[idx + 1]]; if (panelLevel === 0) syncSubFlowLinks(panelPivot); saveFlowModel(); api.renderAll(); }, onInsertAfter: (idx) => { const targetList = panelLevel === 0 ? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || [])) : (drillFlow[panelPivot] || []); const next = askStepLabel(''); if (!next) return; targetList.splice(idx + 1, 0, next); if (!drillFlow[next]) drillFlow[next] = []; if (panelLevel === 0) syncSubFlowLinks(panelPivot); saveFlowModel(); api.renderAll(); }, onAddEnd: () => { const targetList = panelLevel === 0 ? (isCommonPivot ? (commonSubFlow[panelPivot] || []) : (subFlow[panelPivot] || [])) : (drillFlow[panelPivot] || []); const next = askStepLabel(''); if (!next) return; targetList.push(next); if (!drillFlow[next]) drillFlow[next] = []; if (panelLevel === 0) syncSubFlowLinks(panelPivot); saveFlowModel(); api.renderAll(); } }); drillColumnsEl.appendChild(panel); if (!selectedChain[panelLevel + 1]) break; level += 1; } } function renderAll() { renderMain(); renderDrillColumns(); } const api = { renderVerticalFlow, renderCommonSection, openFirstSubStepFromCommonFlow, openFirstSubStepFromProjectFlow, renderMain, renderDrillColumns, renderAll }; return api; };