diff --git a/src/App.jsx b/src/App.jsx index 43cde90..83f4807 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,8 @@ import { Edit3, Layers3, Map, + Maximize2, + Minimize2, Mountain, Plus, Route, @@ -218,6 +220,7 @@ const defaultContent = { successors: [], linkLabel: '천지인 산출 모델을 WayPrimal 설계 입력으로 연계' }, + comparisons: [], extraPrograms: [] }; @@ -246,6 +249,7 @@ function normalizeStoredContent(parsed) { ? defaultContent.wayPrimal.format : (parsed.wayPrimal?.format ?? defaultContent.wayPrimal.format) }, + comparisons: parsed.comparisons ?? [], extraPrograms: (parsed.extraPrograms ?? []).map((program, index, programs) => ({ ...program, programType: getProgramType(program.programType), @@ -1146,55 +1150,6 @@ function DetailPopup({ } function RelationPopup({ programs, onToggleRelation, onClose }) { - const [isRelationEditorOpen, setIsRelationEditorOpen] = useState(false); - const relations = programs.flatMap((program) => - (program.successors ?? []) - .map((successorId) => ({ - from: program, - to: programs.find((item) => item.id === successorId) - })) - .filter((relation) => relation.to) - ); - const connectedProgramIds = new Set(relations.flatMap((relation) => [relation.from.id, relation.to.id])); - const isolatedPrograms = programs.filter((program) => !connectedProgramIds.has(program.id)); - const levelMap = programs.reduce((levels, program) => ({ ...levels, [program.id]: 0 }), {}); - - for (let pass = 0; pass < programs.length; pass += 1) { - relations.forEach((relation) => { - levelMap[relation.to.id] = Math.max(levelMap[relation.to.id], levelMap[relation.from.id] + 1); - }); - } - - const graphPrograms = programs.filter((program) => connectedProgramIds.has(program.id)); - const graphLevels = graphPrograms.reduce((levels, program) => { - const level = levelMap[program.id] ?? 0; - levels[level] = [...(levels[level] ?? []), program]; - return levels; - }, []); - const nodeWidth = 160; - const nodeHeight = 48; - const columnGap = 42; - const rowGap = 132; - const graphPadding = 54; - const sideLaneWidth = 240; - const maxLevelCount = Math.max(1, ...graphLevels.map((level) => level?.length ?? 0)); - const graphWidth = Math.max(560, graphPadding * 2 + sideLaneWidth + maxLevelCount * nodeWidth + Math.max(0, maxLevelCount - 1) * columnGap); - const graphHeight = Math.max(620, graphPadding * 2 + graphLevels.length * nodeHeight + Math.max(0, graphLevels.length - 1) * rowGap); - const nodePositions = Object.fromEntries( - graphLevels.flatMap((levelPrograms = [], levelIndex) => { - const rowWidth = levelPrograms.length * nodeWidth + Math.max(0, levelPrograms.length - 1) * columnGap; - const startX = (graphWidth - rowWidth) / 2; - const y = graphPadding + levelIndex * (nodeHeight + rowGap); - return levelPrograms.map((program, rowIndex) => [ - program.id, - { - x: startX + rowIndex * (nodeWidth + columnGap), - y - } - ]); - }) - ); - return (
@@ -1203,7 +1158,7 @@ function RelationPopup({ programs, onToggleRelation, onClose }) {

프로그램 연관관계

-

전체 프로그램 연결도 및 관계 수정

+

프로그램 선행·후행 설정

-
-
-
-
-

연결도

-

- 입력한 선행/후행 관계를 화살표로 표시합니다 -

-
-
- - {relations.length}개 연결 - - -
-
-
- {relations.length > 0 ? ( -
-
- - - - - - - - - - {relations.map((relation) => { - const from = nodePositions[relation.from.id]; - const to = nodePositions[relation.to.id]; - if (!from || !to) return null; - const fromLevel = levelMap[relation.from.id] ?? 0; - const toLevel = levelMap[relation.to.id] ?? 0; - const isSkipEdge = toLevel - fromLevel > 1; - const normalOutgoing = relations.filter( - (item) => - item.from.id === relation.from.id && - (levelMap[item.to.id] ?? 0) - (levelMap[item.from.id] ?? 0) === 1 - ); - const normalIncoming = relations.filter( - (item) => - item.to.id === relation.to.id && - (levelMap[item.to.id] ?? 0) - (levelMap[item.from.id] ?? 0) === 1 - ); - const outgoingIndex = normalOutgoing.findIndex((item) => item.to.id === relation.to.id); - const incomingIndex = normalIncoming.findIndex((item) => item.from.id === relation.from.id); - const fromCenterX = from.x + nodeWidth / 2; - const toCenterX = to.x + nodeWidth / 2; - const startY = from.y + nodeHeight; - const endY = to.y; - const isCenterAligned = Math.abs(fromCenterX - toCenterX) < 6; - const useStraightLine = !isSkipEdge && isCenterAligned; - const branchDirection = Math.sign(toCenterX - fromCenterX); - const mergeDirection = Math.sign(fromCenterX - toCenterX); - const startOffset = useStraightLine - ? 0 - : branchDirection !== 0 && normalOutgoing.length > 1 - ? branchDirection * 28 - : (outgoingIndex - (normalOutgoing.length - 1) / 2) * 22; - const endOffset = useStraightLine - ? 0 - : mergeDirection !== 0 && normalIncoming.length > 1 - ? mergeDirection * 20 - : (incomingIndex - (normalIncoming.length - 1) / 2) * 18; - const startX = fromCenterX + startOffset; - const endX = toCenterX + endOffset; - const midY = startY + Math.max(38, (endY - startY) / 2); - const skipEdges = relations.filter((item) => (levelMap[item.to.id] ?? 0) - (levelMap[item.from.id] ?? 0) > 1); - const skipFromLevels = [...new Set(skipEdges.map((item) => levelMap[item.from.id] ?? 0))].sort((a, b) => a - b); - const skipFromLevelIndex = skipFromLevels.indexOf(fromLevel); - const leftMostNodeX = Math.min(...Object.values(nodePositions).map((position) => position.x)); - const outerLaneX = 24; - const innerLaneX = Math.max(outerLaneX + 42, leftMostNodeX - 86); - const getSkipLaneX = (level) => { - const levelIndex = Math.max(0, skipFromLevels.indexOf(level)); - const laneRatio = skipFromLevels.length <= 1 ? 1 : levelIndex / (skipFromLevels.length - 1); - return outerLaneX + (innerLaneX - outerLaneX) * laneRatio; - }; - const skipIncoming = skipEdges.filter((item) => item.to.id === relation.to.id); - const innermostSkipLevel = Math.max(...skipIncoming.map((item) => levelMap[item.from.id] ?? 0)); - const isInnermostSkipEdge = fromLevel === innermostSkipLevel; - const innermostLaneX = getSkipLaneX(innermostSkipLevel); - const laneX = getSkipLaneX(fromLevel); - const skipStartX = from.x; - const skipStartY = from.y + nodeHeight / 2; - const skipEndX = to.x; - const skipEndY = to.y + nodeHeight / 2; - const pathD = isSkipEdge - ? `M ${skipStartX} ${skipStartY} H ${laneX} V ${skipEndY} H ${isInnermostSkipEdge ? skipEndX - 10 : innermostLaneX}` - : useStraightLine - ? `M ${fromCenterX} ${startY} L ${toCenterX} ${endY - 10}` - : `M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY - 10}`; - return ( - - - - ); - })} - - {graphPrograms.map((program) => { - const position = nodePositions[program.id]; - if (!position) return null; - const typeMeta = getProgramTypeMeta(program.programType); - return ( -
-

{program.name}

- - {typeMeta.shortLabel} - -
- ); - })} -
-
- ) : ( -
- 아직 연결된 프로그램이 없습니다. 아래에서 선행/후행을 선택하세요. -
- )} - {isolatedPrograms.length > 0 && ( -
- 미연결 - {isolatedPrograms.map((program) => ( - - {program.name} - - ))} -
- )} -
+
+
+

프로그램 연결 수정

+

+ 각 프로그램의 선행/후행을 선택하면 메인 연결도에 바로 반영됩니다. +

- - {isRelationEditorOpen && ( -
-
-
+
+ {programs.map((program) => { + const candidates = programs.filter((item) => item.id !== program.id); + return ( +
-

프로그램 연결 수정

-

- 각 프로그램의 선행/후행을 선택하면 연결도가 바로 반영됩니다 -

+

프로그램

+

{program.name}

- -
-
- {programs.map((program) => { - const candidates = programs.filter((item) => item.id !== program.id); - return ( -
-
-

프로그램

-

{program.name}

-
-
-

선행

-
- {candidates.map((candidate) => ( - - ))} -
-
-
-

후행

-
- {candidates.map((candidate) => ( - - ))} -
-
+
+

선행

+
+ {candidates.map((candidate) => ( + + ))}
- ); - })} -
-
-
- )} +
+
+

후행

+
+ {candidates.map((candidate) => ( + + ))} +
+
+
+ ); + })} +
); } -function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) { +function ProgramComparePopup({ programs, comparisons, onComparisonChange, onClose }) { + const internalProgram = programs.find((program) => getProgramType(program.programType) === 'internal'); + const commercialProgram = programs.find((program) => getProgramType(program.programType) === 'commercial'); + const [leftProgramId, setLeftProgramId] = useState(internalProgram?.id ?? programs[0]?.id ?? ''); + const [rightProgramId, setRightProgramId] = useState( + commercialProgram?.id ?? programs.find((program) => program.id !== (internalProgram?.id ?? programs[0]?.id))?.id ?? '' + ); + const leftProgram = programs.find((program) => program.id === leftProgramId); + const rightProgram = programs.find((program) => program.id === rightProgramId); + const comparison = + comparisons.find((item) => item.leftProgramId === leftProgramId && item.rightProgramId === rightProgramId) ?? {}; + + const updateComparison = (field, value) => { + if (!leftProgramId || !rightProgramId || leftProgramId === rightProgramId) return; + onComparisonChange(leftProgramId, rightProgramId, field, value); + }; + + const renderProgramSummary = (program) => { + if (!program) return null; + const typeMeta = getProgramTypeMeta(program.programType); + return ( +
+
+
+

선택 프로그램

+

{program.name}

+
+ + {typeMeta.shortLabel} + +
+

{program.description}

+
+ {(program.deliverables ?? []).map((deliverable) => ( + + {deliverable} + + ))} +
+

+ 포맷: {program.format || '-'} +

+
+ ); + }; + + return ( +
+
+
+
+

+ 1:1 프로그램 비교 +

+

사내 한계와 상용 활용 기능 정리

+
+ +
+
+
+ + +
+ +
+ {renderProgramSummary(leftProgram)} + {renderProgramSummary(rightProgram)} +
+ +
+