From 4d7354b8f3d3462fe9ddfc52c71a0e0deec0fc9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=ED=98=9C=EC=9D=B8?= Date: Fri, 26 Jun 2026 16:24:46 +0900 Subject: [PATCH] Update program flow interactions --- src/App.jsx | 1388 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 1026 insertions(+), 362 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 64cf6ba..b0bff02 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -213,6 +213,35 @@ const programTypeOptions = { const getProgramType = (type) => (type === 'commercial' ? 'commercial' : 'internal'); const getProgramTypeMeta = (type) => programTypeOptions[getProgramType(type)]; +const programRoleOptions = { + flow: { + label: '업무 프로그램', + shortLabel: '업무', + badgeClass: 'bg-sky-50 text-sky-700 ring-sky-100' + }, + reference: { + label: '참고 프로그램', + shortLabel: '참고', + badgeClass: 'bg-slate-100 text-slate-600 ring-slate-200' + } +}; +const getProgramRole = (role) => (role === 'reference' ? 'reference' : 'flow'); +const getProgramRoleMeta = (role) => programRoleOptions[getProgramRole(role)]; +const normalizeAuxiliaryTargets = (program) => { + const targets = Array.isArray(program?.auxiliaryTargets) ? program.auxiliaryTargets : []; + const legacyTargets = targets.length === 0 && Array.isArray(program?.auxiliaryTargetIds) + ? program.auxiliaryTargetIds.map((programId) => ({ programId, stepId: '' })) + : []; + const mergedTargets = [...targets, ...legacyTargets] + .map((target) => ({ + programId: target?.programId ?? target, + stepId: target?.stepId ?? '' + })) + .filter((target) => target.programId); + return mergedTargets.filter((target, index, list) => + list.findIndex((item) => item.programId === target.programId && (item.stepId ?? '') === (target.stepId ?? '')) === index + ); +}; const defaultContent = { cheonjiin: { @@ -223,9 +252,12 @@ const defaultContent = { format: 'glb', engine: '', programType: 'internal', + programRole: 'flow', programNote: '', predecessors: [], successors: [], + auxiliaryTargetIds: [], + auxiliaryTargets: [], mergeGroup: '' }, wayPrimal: { @@ -236,9 +268,12 @@ const defaultContent = { engine: '', deliverables: ['기본설계 모델'], programType: 'internal', + programRole: 'flow', programNote: '', predecessors: [], successors: [], + auxiliaryTargetIds: [], + auxiliaryTargets: [], mergeGroup: '', linkLabel: '천지인 산출 모델을 WayPrimal 설계 입력으로 연계' }, @@ -253,10 +288,13 @@ function normalizeStoredContent(parsed) { ...defaultContent.cheonjiin, ...(parsed.cheonjiin ?? {}), programType: getProgramType(parsed.cheonjiin?.programType ?? defaultContent.cheonjiin.programType), + programRole: getProgramRole(parsed.cheonjiin?.programRole ?? defaultContent.cheonjiin.programRole), programNote: parsed.cheonjiin?.programNote ?? '', engine: parsed.cheonjiin?.engine ?? '', predecessors: parsed.cheonjiin?.predecessors ?? [], successors: parsed.cheonjiin?.successors ?? [], + auxiliaryTargetIds: parsed.cheonjiin?.auxiliaryTargetIds ?? [], + auxiliaryTargets: normalizeAuxiliaryTargets(parsed.cheonjiin), mergeGroup: parsed.cheonjiin?.mergeGroup ?? '', format: !parsed.cheonjiin?.format || parsed.cheonjiin.format === '예: DXF, SHP, GeoTIFF, 수치지형도 v2 등' @@ -267,10 +305,13 @@ function normalizeStoredContent(parsed) { ...defaultContent.wayPrimal, ...(parsed.wayPrimal ?? {}), programType: getProgramType(parsed.wayPrimal?.programType ?? defaultContent.wayPrimal.programType), + programRole: getProgramRole(parsed.wayPrimal?.programRole ?? defaultContent.wayPrimal.programRole), programNote: parsed.wayPrimal?.programNote ?? '', engine: parsed.wayPrimal?.engine ?? '', predecessors: parsed.wayPrimal?.predecessors ?? [], successors: parsed.wayPrimal?.successors ?? [], + auxiliaryTargetIds: parsed.wayPrimal?.auxiliaryTargetIds ?? [], + auxiliaryTargets: normalizeAuxiliaryTargets(parsed.wayPrimal), mergeGroup: parsed.wayPrimal?.mergeGroup ?? '', format: parsed.wayPrimal?.format === '예: DWG, LandXML, XLSX, 도공계산서, 기본설계 모델 등' @@ -285,10 +326,13 @@ function normalizeStoredContent(parsed) { extraPrograms: (parsed.extraPrograms ?? []).map((program, index, programs) => ({ ...program, programType: getProgramType(program.programType), + programRole: getProgramRole(program.programRole), programNote: program.programNote ?? '', engine: program.engine ?? '', predecessors: program.predecessors ?? [], successors: program.successors ?? [], + auxiliaryTargetIds: program.auxiliaryTargetIds ?? [], + auxiliaryTargets: normalizeAuxiliaryTargets(program), mergeGroup: program.mergeGroup ?? '', linkLabel: program.linkLabel ?? `이전 프로그램 산출물을 ${program.name} 입력으로 연계` })) @@ -484,14 +528,16 @@ function splitDeliverableText(item) { .filter(Boolean); } -function FlowCard({ step, index, total, accent, status, isEditing, onChange, searchMatched = false }) { +function FlowCard({ step, index, total, accent, status, isEditing, onChange, searchMatched = false, searchTargetMatched = false, auxiliaryPrograms = [] }) { const Icon = step.icon ?? pickStepIcon(step); return (
- - {searchMatched ? '검색' : status === 'active' ? '재진입' : status === 'completed' ? '통과' : `${String(index + 1).padStart(2, '0')} / ${String(total).padStart(2, '0')}`} - + {(searchMatched || status === 'active' || status !== 'completed') && ( + + {searchMatched ? '검색' : status === 'active' ? '재진입' : `${String(index + 1).padStart(2, '0')} / ${String(total).padStart(2, '0')}`} + + )}
{isEditing ? (
@@ -555,6 +605,15 @@ function FlowCard({ step, index, total, accent, status, isEditing, onChange, sea

)} + {!isEditing && auxiliaryPrograms.length > 0 && ( +
+ {auxiliaryPrograms.map((program) => ( + + 참고 입력 · {program.name} + + ))} +
+ )}
); @@ -586,7 +645,10 @@ function FlowRow({ onMoveStep, onProgramDelete, rowRef, - searchMatchedStepIds = [] + searchMatchedStepIds = [], + searchTargetStepIds = [], + auxiliaryPrograms = [], + stepAuxiliaryProgramsByStepId = {} }) { const activeIndex = steps.findIndex((step) => step.id === activeStep); @@ -637,6 +699,8 @@ function FlowRow({ isEditing={isEditing} onChange={onStepChange} searchMatched={searchMatchedStepIds.includes(step.id)} + searchTargetMatched={searchTargetStepIds.includes(step.id)} + auxiliaryPrograms={stepAuxiliaryProgramsByStepId[step.id] ?? []} /> {isEditing && (onMoveStep || onRemoveStep) && (
@@ -764,8 +828,8 @@ function FlowRow({ className="mt-2 w-full resize-none rounded-xl border border-amber-100 bg-amber-50/70 px-3 py-2 text-sm font-bold leading-5 text-slate-800 outline-none focus:border-amber-300" /> ) : programNote ? ( -
- NOTE +
+ NOTE {programNote}
) : null} @@ -846,6 +910,25 @@ function FlowRow({ )}
+ {auxiliaryPrograms.length > 0 && ( +
+ {auxiliaryPrograms.map((program) => { + const typeMeta = getProgramTypeMeta(program.programType); + return ( +
+
+ 보조 입력 + + {typeMeta.shortLabel} + +
+

{program.name}

+

{program.description}

+
+ ); + })} +
+ )}
{steps.length > maxVisibleSteps && ( <> @@ -1210,7 +1293,8 @@ function DetailPopup({ ); } -function RelationPopup({ programs, onToggleRelation, onMergeGroupChange, onClose }) { +function RelationPopup({ programs, onToggleRelation, onMergeGroupChange, onMergeGroupMembersChange, onProgramRoleChange, onAuxiliaryTargetsChange, onClose }) { + const flowPrograms = programs.filter((program) => getProgramRole(program.programRole) === 'flow'); return (
@@ -1239,13 +1323,35 @@ function RelationPopup({ programs, onToggleRelation, onMergeGroupChange, onClose
{programs.map((program) => { - const candidates = programs.filter((item) => item.id !== program.id); + const programRole = getProgramRole(program.programRole); + const isReferenceProgram = programRole === 'reference'; + const candidates = flowPrograms.filter((item) => item.id !== program.id); + const currentGroupKey = program.mergeGroup || program.id; + const mergedPrograms = flowPrograms.filter((item) => (item.mergeGroup || item.id) === currentGroupKey); + const mergedProgramNames = mergedPrograms.map((item) => item.name).join(', '); return ( -
+

프로그램

{program.name}

+
+ {Object.entries(programRoleOptions).map(([roleKey, roleMeta]) => ( + + ))} +
+ {!isReferenceProgram ? (

선행

@@ -1262,6 +1368,12 @@ function RelationPopup({ programs, onToggleRelation, onMergeGroupChange, onClose ))}
+ ) : ( +
+ 참고 프로그램은 선행/후행 흐름에는 포함하지 않습니다. +
+ )} + {!isReferenceProgram ? (

후행

@@ -1278,27 +1390,76 @@ function RelationPopup({ programs, onToggleRelation, onMergeGroupChange, onClose ))}
+ ) : (
-

하나로 인식

- onAuxiliaryTargetsChange(program.id, candidate.id, event.target.checked, '')} + className="h-3.5 w-3.5 accent-sky-500" + /> + {candidate.name} + + {isChecked && ( + + )} +
); })} - +
+
+ )} + {!isReferenceProgram ? ( +
+

하나로 인식

+
+ {mergedPrograms.length > 1 ? mergedProgramNames : '개별 프로그램'} +
+
+ {candidates.map((candidate) => { + const candidateGroupKey = candidate.mergeGroup || candidate.id; + const isMerged = currentGroupKey === candidateGroupKey && (program.mergeGroup || candidate.mergeGroup); + return ( + + ); + })} +

- 같은 단계로 보는 프로그램을 묶으면 연결도에서는 하나의 박스로 표시됩니다. + 체크한 프로그램들이 연결도에서 하나의 비교 프로그램 박스로 표시됩니다.

+ ) : ( +
+ 대상 업무 프로그램 카드에 작은 보조 입력 카드로 표시됩니다. +
+ )}
); })} @@ -1312,58 +1473,86 @@ function RelationPopup({ programs, onToggleRelation, onMergeGroupChange, onClose function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonSave, onComparisonDelete, onClose }) { const internalProgram = programs.find((program) => getProgramType(program.programType) === 'internal'); const commercialProgram = programs.find((program) => getProgramType(program.programType) === 'commercial'); + const isValidProgramId = (programId) => programs.some((program) => program.id === programId); + const getComparisonProgramIds = (comparison) => { + const ids = Array.isArray(comparison?.programIds) && comparison.programIds.length > 0 + ? comparison.programIds + : [comparison?.leftProgramId, comparison?.rightProgramId]; + return ids.filter((programId, index, list) => isValidProgramId(programId) && list.indexOf(programId) === index); + }; + const createComparisonId = (programIds) => `comparison-${programIds.slice().sort().join('__')}`; + const programSetMatches = (comparison, programIds) => + getComparisonProgramIds(comparison).slice().sort().join('__') === programIds.slice().sort().join('__'); const savedComparisons = comparisons .filter( (comparison) => - programs.some((program) => program.id === comparison.leftProgramId) && - programs.some((program) => program.id === comparison.rightProgramId) && + getComparisonProgramIds(comparison).length >= 2 && (comparison.title?.trim() || (comparison.stepMatches ?? []).some((match) => match.reason?.trim()) || comparison.note?.trim()) ) .sort((left, right) => (right.updatedAt ?? '').localeCompare(left.updatedAt ?? '')); - const createComparisonId = (leftProgramId, rightProgramId) => `comparison-${[leftProgramId, rightProgramId].sort().join('__')}`; - const isValidProgramId = (programId) => programs.some((program) => program.id === programId); - const pairMatches = (comparison, leftProgramId, rightProgramId) => - [comparison.leftProgramId, comparison.rightProgramId].sort().join('__') === [leftProgramId, rightProgramId].sort().join('__'); const defaultLeftProgramId = isValidProgramId(initialPair?.leftProgramId) ? initialPair.leftProgramId : internalProgram?.id ?? programs[0]?.id ?? ''; const defaultRightProgramId = isValidProgramId(initialPair?.rightProgramId) && initialPair.rightProgramId !== defaultLeftProgramId ? initialPair.rightProgramId : commercialProgram?.id ?? programs.find((program) => program.id !== defaultLeftProgramId)?.id ?? ''; - const findSavedComparisonByPair = (leftProgramId, rightProgramId) => - savedComparisons.find((comparison) => pairMatches(comparison, leftProgramId, rightProgramId)); + const initialGroupProgramIds = (initialPair?.programIds ?? []) + .filter((programId, index, ids) => isValidProgramId(programId) && ids.indexOf(programId) === index); + const defaultProgramIds = initialGroupProgramIds.length >= 2 + ? initialGroupProgramIds + : [defaultLeftProgramId, defaultRightProgramId].filter(Boolean); + const findSavedComparisonByPrograms = (programIds) => + savedComparisons.find((comparison) => programSetMatches(comparison, programIds)); const createEmptyDraft = (leftProgramId = defaultLeftProgramId, rightProgramId = defaultRightProgramId) => ({ - id: createComparisonId(leftProgramId, rightProgramId), + id: createComparisonId(defaultProgramIds), title: '', leftProgramId, rightProgramId, + programIds: defaultProgramIds, stepMatches: [], note: '' }); - const normalizeStepMatches = (matches = []) => - matches.map((match, index) => ({ - id: match.id ?? `match-${index}-${match.leftStepIndex ?? ''}-${match.rightStepIndex ?? ''}`, - leftStepIndex: match.leftStepIndex !== undefined ? String(match.leftStepIndex) : '', - rightStepIndex: match.rightStepIndex !== undefined ? String(match.rightStepIndex) : '', - reason: match.reason ?? '' - })); + const normalizeStepMatches = (matches = [], ownerDraft = {}) => + matches.map((match, index) => { + const leftStepIndex = match.leftStepIndex !== undefined ? String(match.leftStepIndex) : ''; + const rightStepIndex = match.rightStepIndex !== undefined ? String(match.rightStepIndex) : ''; + return { + id: match.id ?? `match-${index}-${leftStepIndex}-${rightStepIndex}`, + leftStepIndex, + rightStepIndex, + stepIndexes: { + ...(match.stepIndexes ?? {}), + ...(ownerDraft?.leftProgramId ? { [ownerDraft.leftProgramId]: leftStepIndex } : {}), + ...(ownerDraft?.rightProgramId ? { [ownerDraft.rightProgramId]: rightStepIndex } : {}) + }, + reason: match.reason ?? '' + }; + }); const [draft, setDraft] = useState(() => { - const pairComparison = initialPair ? findSavedComparisonByPair(defaultLeftProgramId, defaultRightProgramId) : null; + const pairComparison = initialPair ? findSavedComparisonByPrograms(defaultProgramIds) : null; const initialDraft = pairComparison ?? (initialPair ? createEmptyDraft(defaultLeftProgramId, defaultRightProgramId) : savedComparisons[0] ?? createEmptyDraft()); - return { ...initialDraft, stepMatches: normalizeStepMatches(initialDraft.stepMatches) }; + const initialProgramIds = getComparisonProgramIds(initialDraft); + return { ...initialDraft, programIds: initialProgramIds, stepMatches: normalizeStepMatches(initialDraft.stepMatches, initialDraft) }; }); const [loadTargetId, setLoadTargetId] = useState(() => { - const pairComparison = initialPair ? findSavedComparisonByPair(defaultLeftProgramId, defaultRightProgramId) : null; + const pairComparison = initialPair ? findSavedComparisonByPrograms(defaultProgramIds) : null; return pairComparison?.id ?? (initialPair ? '' : savedComparisons[0]?.id ?? ''); }); const [isCompareEditing, setIsCompareEditing] = useState(() => { - const pairComparison = initialPair ? findSavedComparisonByPair(defaultLeftProgramId, defaultRightProgramId) : null; + const pairComparison = initialPair ? findSavedComparisonByPrograms(defaultProgramIds) : null; return !pairComparison && !savedComparisons[0]; }); const [formatMenu, setFormatMenu] = useState(null); const [lastTextSelection, setLastTextSelection] = useState(null); const leftProgram = programs.find((program) => program.id === draft.leftProgramId); const rightProgram = programs.find((program) => program.id === draft.rightProgramId); + const comparisonProgramIds = getComparisonProgramIds(draft).length >= 2 + ? getComparisonProgramIds(draft) + : [draft.leftProgramId, draft.rightProgramId].filter(Boolean); + const comparisonPrograms = comparisonProgramIds + .map((programId) => programs.find((program) => program.id === programId)) + .filter(Boolean); + const comparisonProgramSteps = Object.fromEntries(comparisonPrograms.map((program) => [program.id, program.steps ?? []])); const leftSteps = leftProgram?.steps ?? []; const rightSteps = rightProgram?.steps ?? []; const stepMatches = draft.stepMatches ?? []; @@ -1377,6 +1566,18 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS const updateDraft = (field, value) => { setDraft((current) => ({ ...current, [field]: value })); }; + const updateCompareProgram = (side, programId) => { + setDraft((current) => { + if (side === 'leftProgramId') { + const nextRightProgramId = programId === current.rightProgramId + ? programs.find((program) => program.id !== programId)?.id ?? '' + : current.rightProgramId; + return { ...current, leftProgramId: programId, rightProgramId: nextRightProgramId, programIds: [programId, nextRightProgramId].filter(Boolean) }; + } + if (programId === current.leftProgramId) return current; + return { ...current, rightProgramId: programId, programIds: [current.leftProgramId, programId].filter(Boolean) }; + }); + }; const startNewComparison = () => { const nextDraft = createEmptyDraft(); setDraft(nextDraft); @@ -1388,7 +1589,8 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS if (!selected) return; setDraft({ ...selected, - stepMatches: normalizeStepMatches(selected.stepMatches), + programIds: getComparisonProgramIds(selected), + stepMatches: normalizeStepMatches(selected.stepMatches, selected), note: selected.note ?? '', title: selected.title ?? '' }); @@ -1396,18 +1598,22 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS }; const saveDraft = () => { if (!draft.leftProgramId || !draft.rightProgramId || draft.leftProgramId === draft.rightProgramId) return; - const nextComparisonId = createComparisonId(draft.leftProgramId, draft.rightProgramId); + const nextProgramIds = comparisonProgramIds.length >= 2 ? comparisonProgramIds : [draft.leftProgramId, draft.rightProgramId]; + const nextComparisonId = createComparisonId(nextProgramIds); onComparisonSave({ ...draft, id: nextComparisonId, + programIds: nextProgramIds, + leftProgramId: nextProgramIds[0], + rightProgramId: nextProgramIds[1], title: draft.title ?? '', - stepMatches: normalizeStepMatches(draft.stepMatches).filter( - (match) => match.reason?.trim() || match.leftStepIndex !== '' || match.rightStepIndex !== '' + stepMatches: normalizeStepMatches(draft.stepMatches, draft).filter( + (match) => match.reason?.trim() || Object.values(match.stepIndexes ?? {}).some((value) => value !== '') ), note: draft.note ?? '', updatedAt: new Date().toISOString() }); - setDraft((current) => ({ ...current, id: nextComparisonId })); + setDraft((current) => ({ ...current, id: nextComparisonId, programIds: nextProgramIds })); setLoadTargetId(nextComparisonId); setIsCompareEditing(false); }; @@ -1422,16 +1628,26 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS const addComparisonRow = () => { setDraft((current) => { const currentMatches = current.stepMatches ?? []; - const usedLeftIndexes = new Set(currentMatches.map((match) => String(match.leftStepIndex ?? '')).filter(Boolean)); - const usedRightIndexes = new Set(currentMatches.map((match) => String(match.rightStepIndex ?? '')).filter(Boolean)); + const currentProgramIds = getComparisonProgramIds(current).length >= 2 + ? getComparisonProgramIds(current) + : [current.leftProgramId, current.rightProgramId].filter(Boolean); + const nextStepIndexes = Object.fromEntries(currentProgramIds.map((programId) => { + const program = programs.find((item) => item.id === programId); + const steps = program?.steps ?? []; + const usedIndexes = new Set(currentMatches.map((match) => String(match.stepIndexes?.[programId] ?? '')).filter(Boolean)); + return [programId, getNextStepIndex(steps, usedIndexes)]; + })); + const leftStepIndex = nextStepIndexes[current.leftProgramId] ?? ''; + const rightStepIndex = nextStepIndexes[current.rightProgramId] ?? ''; return { ...current, stepMatches: [ ...currentMatches, { id: `match-${Date.now()}-${currentMatches.length}`, - leftStepIndex: getNextStepIndex(leftSteps, usedLeftIndexes), - rightStepIndex: getNextStepIndex(rightSteps, usedRightIndexes), + leftStepIndex, + rightStepIndex, + stepIndexes: nextStepIndexes, reason: '' } ] @@ -1455,10 +1671,28 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS }; }); }; + const updateComparisonProgramStep = (matchId, programId, value) => { + setDraft((current) => ({ + ...current, + stepMatches: (current.stepMatches ?? []).map((match) => { + if (match.id !== matchId) return match; + const nextMatch = { + ...match, + stepIndexes: { + ...(match.stepIndexes ?? {}), + [programId]: value + } + }; + if (programId === current.leftProgramId) nextMatch.leftStepIndex = value; + if (programId === current.rightProgramId) nextMatch.rightStepIndex = value; + return nextMatch; + }) + })); + }; const getStepLabel = (step, index) => `${index + 1}. ${step?.title ?? '-'}`; const getProgramName = (programId) => programs.find((program) => program.id === programId)?.name ?? programId; const getComparisonTitle = (comparison) => - comparison.title?.trim() || `${getProgramName(comparison.leftProgramId)} ↔ ${getProgramName(comparison.rightProgramId)}`; + comparison.title?.trim() || `${getComparisonProgramIds(comparison).map(getProgramName).join(' ↔ ')} 비교`; const getComparisonMemoCount = (comparison) => (comparison.stepMatches ?? []).filter((match) => match.reason?.trim()).length; const stripFormatTags = (text = '') => text.replace(/\[(red|redbold|blue|bold|mark)\]([\s\S]*?)\[\/\1\]/g, '$2').trim(); @@ -1752,15 +1986,7 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS 비교 프로그램 A updateDraft('rightProgramId', event.target.value)} + onChange={(event) => updateCompareProgram('rightProgramId', event.target.value)} className="mt-1 w-full rounded-2xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm font-extrabold text-slate-800 outline-none focus:border-blue-400" > {programs.map((program) => ( @@ -1781,12 +2007,44 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS
+ {initialGroupProgramIds.length > 2 && ( +
+

같은 비교 프로그램 묶음

+
+ {initialGroupProgramIds.map((programId) => { + const program = programs.find((item) => item.id === programId); + if (!program) return null; + const isSelected = draft.leftProgramId === programId || draft.rightProgramId === programId; + return ( + + ); + })} +
+

+ 묶음 안 프로그램이 3개 이상이면 비교표에 모두 함께 표시됩니다. +

+
+ )} ) : (

비교 보고서

- {draft.title?.trim() || `${leftProgram?.name ?? '-'} ↔ ${rightProgram?.name ?? '-'} 비교`} + {draft.title?.trim() || `${comparisonPrograms.map((program) => program.name).join(' ↔ ')} 비교`}

두 프로그램의 동일/연계 스텝에서 사내 한계와 상용 프로그램 활용 사유를 정리합니다. @@ -1794,9 +2052,15 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS

)} -
- {renderProgramSummary(leftProgram)} - {renderProgramSummary(rightProgram)} +
+ {comparisonPrograms.map((program) => ( +
+ {renderProgramSummary(program)} +
+ ))}
@@ -1804,7 +2068,7 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS

선택 스텝 비교

- 비교 행을 만들고, 왼쪽/오른쪽 프로그램에서 비교할 스텝을 각각 선택합니다. + 비교 행을 만들고, 각 프로그램에서 비교할 스텝을 선택합니다.

{isCompareEditing && ( @@ -1824,51 +2088,72 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS {isCompareEditing ? '비교 행을 추가해서 좌우 스텝을 선택하세요.' : '작성된 비교 행이 없습니다.'}
) : ( - comparisonRows.map((match, rowIndex) => { - const leftStepIndex = match.leftStepIndex === '' ? -1 : Number(match.leftStepIndex); - const rightStepIndex = match.rightStepIndex === '' ? -1 : Number(match.rightStepIndex); - const leftStep = leftSteps[leftStepIndex]; - const rightStep = rightSteps[rightStepIndex]; - return ( -
-
-
- {leftProgram?.name ?? 'A'} 스텝 - - #{rowIndex + 1} - -
- {isCompareEditing ? ( - - ) : ( -

- {leftStep ? getStepLabel(leftStep, leftStepIndex) : '선택된 스텝 없음'} -

- )} -

- {leftStep?.feature || '해당 스텝 없음'} -

- {leftStep?.note && ( -

{leftStep.note}

- )} -
- {isCompareEditing ? ( -
- ); +

+
+ )} + + ); }) )} @@ -2023,11 +2281,13 @@ function ProgramComparePopup({ programs, comparisons, initialPair, onComparisonS function RelationTreePanel({ programs, + referencePrograms = [], onProgramClick, onOpenComparePair, onRelationSelect, onRelationReset, selectedRelationId, + selectedRelationIds = [], searchedRelationIds = [], stepSearchQuery = '', onStepSearchChange, @@ -2125,6 +2385,7 @@ function RelationTreePanel({ }); const miniNodeWidth = fullPage ? 128 : 108; const miniNodeHeight = fullPage ? 38 : 34; + const satelliteNodeHeight = fullPage ? 22 : 18; const miniColumnGap = fullPage ? 42 : 14; const miniRowGap = fullPage ? 96 : 72; const miniPadding = 34; @@ -2154,6 +2415,38 @@ function RelationTreePanel({ ]); }) ); + const auxiliarySatellites = Object.entries( + referencePrograms.reduce((groups, program) => { + normalizeAuxiliaryTargets(program).forEach((target) => { + const alreadyAdded = groups[target.programId]?.some((item) => item.id === program.id); + if (!alreadyAdded) { + groups[target.programId] = [...(groups[target.programId] ?? []), program]; + } + }); + return groups; + }, {}) + ).flatMap(([targetId, satellites]) => { + const targetPosition = miniNodePositions[targetId]; + if (!targetPosition) return []; + return satellites.map((program, index) => { + const satelliteNodeWidth = Math.min(fullPage ? 110 : 92, Math.max(fullPage ? 34 : 28, (program.name?.length ?? 0) * (fullPage ? 7 : 6.2) + 12)); + const baseX = targetPosition.x + miniNodeWidth - satelliteNodeWidth / 2; + const y = Math.max( + 8, + Math.min( + miniGraphHeight - satelliteNodeHeight - 8, + targetPosition.y - satelliteNodeHeight / 2 + index * (satelliteNodeHeight + 3) + ) + ); + return { + program, + targetId, + estimatedWidth: satelliteNodeWidth, + x: Math.max(8, Math.min(miniGraphWidth - satelliteNodeWidth - 8, baseX)), + y + }; + }); + }); const mergeGroupBoxes = Object.entries(groupMap) .map(([groupKey, groupPrograms]) => { const positionedPrograms = groupPrograms @@ -2328,14 +2621,16 @@ function RelationTreePanel({ }; return ( -