2525import org .springframework .transaction .annotation .Transactional ;
2626
2727import java .util .*;
28+ import java .util .stream .Collectors ;
2829
2930@ Service
3031@ RequiredArgsConstructor
@@ -52,13 +53,22 @@ public TreeDto getTreeForBaseLine(Long baseLineId) {
5253 List <BaseNode > orderedBase = support .getOrderedBaseNodes (baseLineId );
5354 List <BaseNodeDto > baseDtos = orderedBase .stream ().map (mappers .BASE_READ ::map ).toList ();
5455
55- // 전체 라인 공용 pivot 역인덱스
56+ // 가장 많이 사용하는: 전체 라인 공용 pivot 역인덱스
5657 Map <Long , PivotMark > pivotIndex = buildPivotIndex (baseLineId );
5758
59+ // ===== (1) 노드 수집 =====
60+ record Key (Long baseId , Integer age ) {}
61+ record View (DecisionNode dn , DecNodeDto dto , boolean isRoot , Long baseId , Integer age ,
62+ List <Long > childrenIds , Long pivotBaseId , Integer pivotSlot ) {}
63+
5864 List <DecNodeDto > decDtos = new ArrayList <>();
5965 List <DecisionLine > lines = decisionLineRepository .findByBaseLine_Id (baseLineId );
6066
67+ List <View > pool = new ArrayList <>();
68+ Map <Long , List <View >> byLine = new HashMap <>();
69+
6170 for (DecisionLine line : lines ) {
71+ // 가장 많이 사용하는 함수 호출 위에 한줄로만 요약 주석: 라인의 노드를 타임라인 정렬로 로드
6272 List <DecisionNode > ordered = decisionNodeRepository
6373 .findByDecisionLine_IdOrderByAgeYearAscIdAsc (line .getId ());
6474
@@ -67,29 +77,126 @@ public TreeDto getTreeForBaseLine(Long baseLineId) {
6777 for (DecisionNode dn : ordered ) {
6878 DecNodeDto base = mappers .DECISION_READ .map (dn );
6979
70- List <Long > childrenIds = childrenIndex .getOrDefault (dn .getId (), List .of ());
7180 boolean isRoot = (dn .getParent () == null );
81+ List <Long > childrenIds = childrenIndex .getOrDefault (dn .getId (), List .of ());
7282
7383 PivotMark mark = pivotIndex .get (base .id ());
74- Long pivotBaseId = (mark != null ) ? mark .baseNodeId () : null ;
84+ Long pivotBaseId = (mark != null ) ? mark .baseNodeId () :
85+ (dn .getBaseNode () != null ? dn .getBaseNode ().getId () : null );
7586 Integer pivotSlot = (mark != null ) ? mark .slotIndex () : null ;
7687
77- // effective*는 DECISION_READ에서 이미 계산/주입되어 있다면 그대로 사용
78- decDtos .add (new DecNodeDto (
79- base .id (), base .userId (), base .type (), base .category (),
80- base .situation (), base .decision (), base .ageYear (),
81- base .decisionLineId (), base .parentId (), base .baseNodeId (),
82- base .background (), base .options (), base .selectedIndex (),
83- base .parentOptionIndex (), base .description (),
84- base .aiNextSituation (), base .aiNextRecommendedOption (),
85- base .followPolicy (), base .pinnedCommitId (), base .virtual (),
86- base .effectiveCategory (), base .effectiveSituation (), base .effectiveDecision (),
87- base .effectiveOptions (), base .effectiveDescription (),
88- // ▼ 렌더 편의 필드
89- List .copyOf (childrenIds ), isRoot , pivotBaseId , pivotSlot
90- ));
88+ Long baseId = pivotBaseId ;
89+ Integer age = dn .getAgeYear ();
90+
91+ View v = new View (dn , base , isRoot , baseId , age , List .copyOf (childrenIds ), pivotBaseId , pivotSlot );
92+ pool .add (v );
93+ byLine .computeIfAbsent (line .getId (), k -> new ArrayList <>()).add (v );
9194 }
9295 }
96+
97+ Map <Key , List <View >> byKey = pool .stream ()
98+ .filter (v -> v .baseId != null && v .age != null )
99+ .collect (Collectors .groupingBy (v -> new Key (v .baseId , v .age )));
100+
101+ // ===== (2) 라인 간 포크 그래프 추론 → renderPhase 계산 =====
102+ Map <Long , View > rootCand = new HashMap <>(); // lineId -> 루트 후보
103+ for (Map .Entry <Long , List <View >> e : byLine .entrySet ()) {
104+ List <View > vs = e .getValue ().stream ()
105+ .sorted (Comparator .comparing ((View x ) -> x .age )
106+ .thenComparing (x -> x .dn .getId ()))
107+ .toList ();
108+
109+ View first = vs .get (0 );
110+ // 가장 중요한 함수: 라인 루트가 헤더면 다음 피벗을 루트 후보로 대체
111+ View cand = (first .isRoot && first .baseId == null && vs .size () > 1 ) ? vs .get (1 ) : first ;
112+ rootCand .put (e .getKey (), cand );
113+ }
114+
115+ Map <Long , Set <Long >> g = new HashMap <>(); // originLineId -> {forkLineId}
116+ Map <Long , Integer > indeg = new HashMap <>(); // lineId -> indegree
117+
118+ for (Map .Entry <Long , View > e : rootCand .entrySet ()) {
119+ Long lineId = e .getKey ();
120+ View me = e .getValue ();
121+ indeg .putIfAbsent (lineId , 0 );
122+
123+ if (me .baseId != null && me .age != null ) {
124+ List <View > same = byKey .getOrDefault (new Key (me .baseId , me .age ), List .of ());
125+ Optional <View > origin = same .stream ()
126+ .filter (o -> !o .dn .getDecisionLine ().getId ().equals (lineId ))
127+ .sorted (Comparator
128+ .comparing ((View o ) -> o .dn .getDecisionLine ().getId ())
129+ .thenComparing (o -> o .dn .getId ()))
130+ .findFirst ();
131+
132+ if (origin .isPresent ()) {
133+ Long originLineId = origin .get ().dn .getDecisionLine ().getId ();
134+ g .computeIfAbsent (originLineId , k -> new HashSet <>()).add (lineId );
135+ indeg .put (lineId , indeg .getOrDefault (lineId , 0 ) + 1 );
136+ indeg .putIfAbsent (originLineId , 0 );
137+ }
138+ }
139+ }
140+
141+ Map <Long , Integer > linePhase = new HashMap <>(); // lineId -> phase
142+ ArrayDeque <Long > q = new ArrayDeque <>();
143+ for (Map .Entry <Long , Integer > e : indeg .entrySet ()) {
144+ if (e .getValue () == 0 ) { // indegree==0 → from-base
145+ linePhase .put (e .getKey (), 1 );
146+ q .add (e .getKey ());
147+ }
148+ }
149+ while (!q .isEmpty ()) {
150+ Long u = q .poll ();
151+ int next = linePhase .get (u ) + 1 ;
152+ for (Long v : g .getOrDefault (u , Set .of ())) {
153+ indeg .put (v , indeg .get (v ) - 1 );
154+ linePhase .put (v , Math .max (linePhase .getOrDefault (v , 1 ), next ));
155+ if (indeg .get (v ) == 0 ) q .add (v );
156+ }
157+ }
158+
159+ // ===== (3) DTO 주입: renderPhase + incomingFromId/incomingEdgeType =====
160+ for (View v : pool ) {
161+ DecNodeDto b = v .dto ;
162+
163+ Long lineId = v .dn .getDecisionLine ().getId ();
164+ Integer renderPhase = linePhase .getOrDefault (lineId , 1 );
165+
166+ Long incomingFromId = (v .isRoot )
167+ ? byKey .getOrDefault (new Key (v .baseId , v .age ), List .of ()).stream ()
168+ .filter (o -> !o .dn .getDecisionLine ().getId ().equals (lineId ))
169+ .sorted (Comparator
170+ .comparing ((View o ) -> o .dn .getDecisionLine ().getId ())
171+ .thenComparing (o -> o .dn .getId ()))
172+ .map (o -> o .dn .getId ())
173+ .findFirst ()
174+ .orElse (null )
175+ : (v .dn .getParent () != null ? v .dn .getParent ().getId () : null );
176+
177+ String incomingEdgeType = (v .isRoot && incomingFromId != null ) ? "fork" : "normal" ;
178+
179+ decDtos .add (new DecNodeDto (
180+ b .id (), b .userId (), b .type (), b .category (),
181+ b .situation (), b .decision (), b .ageYear (),
182+ b .decisionLineId (), b .parentId (), b .baseNodeId (),
183+ b .background (), b .options (), b .selectedIndex (),
184+ b .parentOptionIndex (), b .description (),
185+ b .aiNextSituation (), b .aiNextRecommendedOption (),
186+ b .followPolicy (), b .pinnedCommitId (), b .virtual (),
187+ b .effectiveCategory (), b .effectiveSituation (), b .effectiveDecision (),
188+ b .effectiveOptions (), b .effectiveDescription (),
189+ v .childrenIds , v .isRoot , v .pivotBaseId , v .pivotSlot ,
190+ renderPhase , incomingFromId , incomingEdgeType
191+ ));
192+ }
193+
194+ // 출력 순서 보장: phase → ageYear → id
195+ decDtos .sort (Comparator
196+ .comparing (DecNodeDto ::renderPhase , Comparator .nullsLast (Integer ::compareTo ))
197+ .thenComparing (DecNodeDto ::ageYear , Comparator .nullsFirst (Integer ::compareTo ))
198+ .thenComparing (DecNodeDto ::id ));
199+
93200 return new TreeDto (baseDtos , decDtos );
94201 }
95202
@@ -142,7 +249,8 @@ public DecisionLineListDto getDecisionLines(Long userId) {
142249 // 가장 중요한: 특정 라인의 상세를 childrenIds/root/pivotLink*와 함께 반환
143250 public DecisionLineDetailDto getDecisionLineDetail (Long decisionLineId ) {
144251 DecisionLine line = decisionLineRepository .findById (decisionLineId )
145- .orElseThrow (() -> new ApiException (ErrorCode .DECISION_LINE_NOT_FOUND , "DecisionLine not found: " + decisionLineId ));
252+ .orElseThrow (() -> new ApiException (ErrorCode .DECISION_LINE_NOT_FOUND ,
253+ "DecisionLine not found: " + decisionLineId ));
146254
147255 Long baseLineId = line .getBaseLine ().getId ();
148256 Long baseBranchId = (line .getBaseBranch () != null ) ? line .getBaseBranch ().getId () : null ;
@@ -153,7 +261,7 @@ public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) {
153261
154262 // parent→children 인덱스 구성
155263 Map <Long , List <Long >> childrenIndex = buildChildrenIndex (ordered );
156- // 베이스 분기 슬롯 역인덱스 구성(altOpt1/2TargetDecisionId → (baseNodeId, slot))
264+ // 베이스 분기 슬롯 역인덱스 구성
157265 Map <Long , PivotMark > pivotIndex = buildPivotIndex (baseLineId );
158266
159267 List <DecNodeDto > nodes = ordered .stream ().map (n -> {
@@ -178,12 +286,12 @@ public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) {
178286 if (verId != null ) {
179287 NodeAtomVersion v = versionRepo .findById (verId ).orElse (null );
180288 if (v != null ) {
181- if (v .getCategory () != null ) effCategory = v .getCategory ();
182- if (v .getSituation () != null ) effSituation = v .getSituation ();
183- if (v .getDecision () != null ) effDecision = v .getDecision ();
289+ if (v .getCategory () != null ) effCategory = v .getCategory ();
290+ if (v .getSituation () != null ) effSituation = v .getSituation ();
291+ if (v .getDecision () != null ) effDecision = v .getDecision ();
184292 List <String > parsed = parseOptionsJson (v .getOptionsJson ());
185- if (parsed != null ) effOpts = parsed ;
186- if (v .getDescription () != null ) effDesc = v .getDescription ();
293+ if (parsed != null ) effOpts = parsed ;
294+ if (v .getDescription () != null ) effDesc = v .getDescription ();
187295 }
188296 }
189297
@@ -194,6 +302,12 @@ public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) {
194302 Long pivotBaseId = (mark != null ) ? mark .baseNodeId () : null ;
195303 Integer pivotSlot = (mark != null ) ? mark .slotIndex () : null ;
196304
305+ // ===== 라인 상세 전용 렌더 힌트 =====
306+ // 한줄 요약: 상세 화면은 한 라인만 보므로 phase=1 고정, incoming은 parent 기준(normal)
307+ Integer renderPhase = 1 ;
308+ Long incomingFromId = isRoot ? null : n .getParent ().getId ();
309+ String incomingEdgeType = "normal" ;
310+
197311 return new DecNodeDto (
198312 base .id (), base .userId (), base .type (), base .category (),
199313 base .situation (), base .decision (), base .ageYear (),
@@ -202,10 +316,11 @@ public DecisionLineDetailDto getDecisionLineDetail(Long decisionLineId) {
202316 base .parentOptionIndex (), base .description (),
203317 base .aiNextSituation (), base .aiNextRecommendedOption (),
204318 base .followPolicy (), base .pinnedCommitId (), base .virtual (),
205- // effective*
319+ // effective* (최종 해석 반영)
206320 effCategory , effSituation , effDecision , effOpts , effDesc ,
207- // ▼ 렌더 편의 필드
208- List .copyOf (childrenIds ), isRoot , pivotBaseId , pivotSlot
321+ // ▼ 렌더 편의 + 단일 패스 힌트(라인 내부 한정)
322+ List .copyOf (childrenIds ), isRoot , pivotBaseId , pivotSlot ,
323+ renderPhase , incomingFromId , incomingEdgeType
209324 );
210325 }).toList ();
211326
0 commit comments