diff --git a/src/App.jsx b/src/App.jsx index e995f06..43cde90 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1452,10 +1452,78 @@ function RelationPopup({ programs, onToggleRelation, onClose }) { function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) { const [expandedProgramIds, setExpandedProgramIds] = useState(() => new Set(programs.map((program) => program.id))); + const [mapOffset, setMapOffset] = useState(0); const programMap = Object.fromEntries(programs.map((program) => [program.id, program])); + 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 linkedChildIds = new Set(programs.flatMap((program) => program.successors ?? [])); const rootPrograms = programs.filter((program) => !linkedChildIds.has(program.id)); const visibleRoots = rootPrograms.length ? rootPrograms : programs.slice(0, 1); + 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 miniNodeWidth = 128; + const miniNodeHeight = 38; + const miniColumnGap = 52; + const miniRowGap = 78; + const miniPadding = 34; + const miniSideLaneWidth = 96; + const miniViewportWidth = 248; + const miniMaxLevelCount = Math.max(1, ...graphLevels.map((level) => level?.length ?? 0)); + const miniGraphWidth = Math.max( + 560, + miniPadding * 2 + miniSideLaneWidth + miniMaxLevelCount * miniNodeWidth + Math.max(0, miniMaxLevelCount - 1) * miniColumnGap + ); + const miniGraphHeight = Math.max( + 500, + miniPadding * 2 + graphLevels.length * miniNodeHeight + Math.max(0, graphLevels.length - 1) * miniRowGap + ); + const miniNodePositions = Object.fromEntries( + graphLevels.flatMap((levelPrograms = [], levelIndex) => { + const rowWidth = levelPrograms.length * miniNodeWidth + Math.max(0, levelPrograms.length - 1) * miniColumnGap; + const startX = (miniGraphWidth - rowWidth) / 2; + const y = miniPadding + levelIndex * (miniNodeHeight + miniRowGap); + return levelPrograms.map((program, rowIndex) => [ + program.id, + { + x: startX + rowIndex * (miniNodeWidth + miniColumnGap), + y + } + ]); + }) + ); + const maxMapOffset = Math.max(0, miniGraphWidth - miniViewportWidth); + const defaultMapOffset = Math.max(0, Math.round((miniGraphWidth - miniViewportWidth) / 2)); + + useEffect(() => { + setMapOffset((current) => ( + current === 0 + ? Math.min(defaultMapOffset, maxMapOffset) + : Math.min(current, maxMapOffset) + )); + }, [defaultMapOffset, maxMapOffset]); + + const moveMap = (direction) => { + setMapOffset((current) => Math.min(maxMapOffset, Math.max(0, current + direction * 150))); + }; const toggleExpanded = (programId) => { setExpandedProgramIds((current) => { @@ -1547,7 +1615,7 @@ function RelationTreePanel({ programs, onProgramClick, onOpenRelationPopup }) {

Program Map

프로그램 연결

- 선행·후행 관계를 세로 트리로 봅니다. + 화살표 흐름을 좌우로 밀어 확인합니다.

+ + + + {relations.length > 0 ? ( +
+
+ + + + + + + + + + {relations.map((relation) => { + const from = miniNodePositions[relation.from.id]; + const to = miniNodePositions[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 fromCenterX = from.x + miniNodeWidth / 2; + const toCenterX = to.x + miniNodeWidth / 2; + const startY = from.y + miniNodeHeight; + const endY = to.y; + const midY = startY + Math.max(30, (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 leftMostNodeX = Math.min(...Object.values(miniNodePositions).map((position) => position.x)); + const outerLaneX = 16; + const innerLaneX = Math.max(outerLaneX + 28, leftMostNodeX - 42); + 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 pathD = isSkipEdge + ? `M ${from.x} ${from.y + miniNodeHeight / 2} H ${laneX} V ${to.y + miniNodeHeight / 2} H ${isInnermostSkipEdge ? to.x - 8 : innermostLaneX}` + : Math.abs(fromCenterX - toCenterX) < 6 + ? `M ${fromCenterX} ${startY} L ${toCenterX} ${endY - 8}` + : `M ${fromCenterX} ${startY} C ${fromCenterX} ${midY}, ${toCenterX} ${midY}, ${toCenterX} ${endY - 8}`; + return ( + + ); + })} + + {graphPrograms.map((program) => { + const position = miniNodePositions[program.id]; + if (!position) return null; + const typeMeta = getProgramTypeMeta(program.programType); + return ( + + ); + })} +
+
+ ) : ( +
+ 아직 연결된 프로그램이 없습니다. +
+ )} );