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