Files
XRLib/Assets/Scripts/SHI/modal/NW/NWModal.cs
2025-12-02 21:09:37 +09:00

781 lines
28 KiB
C#

#nullable enable
using Cysharp.Threading.Tasks;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using UnityEngine;
using UnityEngine.UIElements;
namespace SHI.Modal.NW
{
/// <summary>
/// NW(네트워크 다이어그램) 모달 창을 관리하는 메인 컨트롤러입니다.
///
/// <para><b>개요:</b></para>
/// <para>
/// TreeList, NWModelView, NWChart 세 가지 뷰를 통합 관리하며,
/// 뷰 간의 선택 동기화, 드래그를 통한 뷰 크기 조절, 확장/축소 기능을 제공합니다.
/// ISOPModal과 유사하나, 수직 레이아웃(ModelView 위, Chart 아래)으로 구성됩니다.
/// </para>
///
/// <para><b>주요 기능:</b></para>
/// <list type="bullet">
/// <item>glTF 모델과 네트워크 차트 데이터 동시 로드</item>
/// <item>TreeList ↔ ModelView ↔ Chart 선택 동기화</item>
/// <item>드래그 버튼으로 ModelView/Chart 크기 비율 조절 (수직)</item>
/// <item>개별 뷰 전체화면 확장/복원</item>
/// <item>TreeList 가시성 토글 시 3D 모델 가시성 동기화</item>
/// </list>
///
/// <para><b>UI 구조:</b></para>
/// <code>
/// NWModal (UXML)
/// ├── TreeList (왼쪽 패널) - 모델 계층 구조
/// └── right-panel (오른쪽)
/// ├── NWModelView (상단) - 3D 모델 뷰어
/// ├── drag-btn (구분선) - 수직 드래그로 크기 조절
/// └── NWChart (하단) - 네트워크 다이어그램
/// </code>
///
/// <para><b>ISOPModal과의 차이점:</b></para>
/// <list type="bullet">
/// <item>레이아웃: 수평(ISOP) vs 수직(NW)</item>
/// <item>차트 타입: 간트 차트(ISOP) vs 네트워크 다이어그램(NW)</item>
/// <item>드래그 방향: X축(ISOP) vs Y축(NW)</item>
/// </list>
/// </summary>
public class NWModal : MonoBehaviour
{
[SerializeField]
public UIDocument uiDocument;
private TreeList? listView;
private NWModelView? modelView;
private NWChart? chartView;
private PlayBar? _playBar;
private Button? closeBtn;
private Button? showTreeBtn;
private Button? dragBtn;
private VisualElement? rightPanel;
private CancellationTokenSource? _cts;
private bool _suppressSelection = false;
// key<->id 매핑(차트-리스트/모델 동기화)
private readonly Dictionary<string, int> _keyToId = new Dictionary<string, int>();
private readonly Dictionary<int, string> _idToKey = new Dictionary<int, string>();
private int selectedItemId = -1;
private enum ExpandedSide { None, Model, Chart }
private ExpandedSide _expanded = ExpandedSide.None;
// 드래그 상태 저장
private bool _isDragging = false;
private int _activePointerId = -1;
private float _dragOffset = 0f; // 포인터와 drag 버튼 중심 간 오프셋
// 확장 전 비율 저장
private float _lastModelFlexGrow = 1f;
private float _lastChartFlexGrow = 1f;
// GeometryChangedEvent 콜백 (해제용)
private EventCallback<GeometryChangedEvent>? _rightPanelGeometryChangedCallback;
// 로딩 UI
private VisualElement? _loadingOverlay;
private VisualElement? _loadingSpinner;
private IVisualElementScheduledItem? _spinnerAnimation;
private void OnEnable()
{
OnDestroy(); // 중복 초기화 방지
var root = uiDocument.rootVisualElement;
// UXML에서 <TreeList> 태그로 추가했다면 Query로 찾음
listView = root.Q<TreeList>();
if (listView != null)
{
listView.OnSelectionChanged += OnListItemSelectionChanged;
listView.OnClosed += OnListClosed;
listView.OnVisibilityChanged += OnListVisibilityChanged;
// listView가 다른 요소 위에 표시되도록 설정
listView.style.unityOverflowClipBox = OverflowClipBox.ContentBox;
// 더미 데이터 생성
// var data = GenerateDummyData();
// listView.SetData(data);
}
modelView = root.Q<NWModelView>();
if (modelView != null)
{
// 선택 동기화: 모델 -> 리스트/차트
modelView.OnItemSelected += OnModelItemSelected;
modelView.OnExpand += ToggleExpandModel;
// modelView의 내용이 범위를 벗어나지 않도록 overflow 설정
modelView.style.overflow = Overflow.Hidden;
}
chartView = root.Q<NWChart>();
if (chartView != null)
{
chartView.OnExpand += ToggleExpandChart;
}
rightPanel = root.Q<VisualElement>("right-panel");
showTreeBtn = root.Q<Button>("show-tree-btn");
if (showTreeBtn != null)
{
showTreeBtn.clicked += OnClickShowTree;
showTreeBtn.style.display = DisplayStyle.None;
}
closeBtn = root.Q<Button>("closeButton");
if (closeBtn != null)
{
closeBtn.clicked += OnClickClose;
}
_playBar = root.Q<PlayBar>("play-bar");
if (_playBar != null)
{
_playBar.SetTimeRange(DateTime.Now, DateTime.Now.AddHours(1));
_playBar.OnPlayProgress += OnPlayProgressHandler;
_playBar.OnPositionChanged += OnPlayPositionChangedHandler;
}
initDrag(root);
_expanded = ExpandedSide.None;
// 로딩 UI 참조
_loadingOverlay = root.Q<VisualElement>("loading-overlay");
_loadingSpinner = root.Q<VisualElement>("loading-spinner");
}
private void OnPlayProgressHandler(DateTime time)
{
if(chartView != null && listView != null)
{
List<string> models = chartView.GetModelNamesByDate(time.ToString("yyyyMMdd"));
Debug.Log($"Models at {time:yyyyMMdd}: {string.Join(", ", models)}");
if(models.Count > 0) listView.ShowItems(models);
}
}
private void OnPlayPositionChangedHandler(DateTime time)
{
if(chartView != null && listView != null)
{
List<string> models = chartView.GetModelNamesByDate(time.ToString("yyyyMMdd"));
Debug.Log($"Models at {time:yyyyMMdd}: {string.Join(", ", models)}");
if(models.Count > 0) listView.ShowItems(models);
}
}
private void initDrag(VisualElement root)
{
dragBtn = root.Q<Button>("drag-btn");
if (dragBtn != null && rightPanel != null)
{
if (modelView != null)
{
// dragBtn이 절대 위치로 움직일 수 있도록 설정
dragBtn.style.position = Position.Absolute;
dragBtn.pickingMode = PickingMode.Position;
// 드래그 이벤트 등록
dragBtn.RegisterCallback<PointerDownEvent>(OnDragPointerDown, TrickleDown.TrickleDown);
dragBtn.RegisterCallback<PointerMoveEvent>(OnDragPointerMove, TrickleDown.TrickleDown);
dragBtn.RegisterCallback<PointerUpEvent>(OnDragPointerUp, TrickleDown.TrickleDown);
dragBtn.RegisterCallback<PointerCancelEvent>(OnDragPointerCancel, TrickleDown.TrickleDown);
// 초기화 및 레이아웃 변경 시 재계산
_rightPanelGeometryChangedCallback = OnRightPanelGeometryChanged;
rightPanel.RegisterCallback(_rightPanelGeometryChangedCallback);
}
}
}
private void OnDragPointerDown(PointerDownEvent evt)
{
// 좌클릭만 처리 (0)
if (evt.button != 0) return;
Debug.Log("Drag Started (PointerDown) - captured");
// 포인터 캡처
_isDragging = true;
_activePointerId = evt.pointerId;
dragBtn?.CapturePointer(_activePointerId);
// 포인터가 drag 버튼의 어느 위치를 눌렀는지 계산 (center 기준, Y축)
if (dragBtn != null)
{
var dragCenterY = dragBtn.layout.y + dragBtn.layout.height * 0.5f;
_dragOffset = evt.position.y - dragCenterY;
}
evt.StopImmediatePropagation();
}
private void OnDragPointerMove(PointerMoveEvent evt)
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
// evt.position은 rightPanel 기준 좌표
float pointerY = evt.position.y;
float centerY = pointerY - _dragOffset;
if (rightPanel != null && dragBtn != null)
{
ApplyDragPosition(rightPanel, dragBtn, centerY);
}
evt.StopImmediatePropagation();
}
private void OnDragPointerUp(PointerUpEvent evt)
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
Debug.Log("Drag Ended");
_isDragging = false;
if (_activePointerId != -1)
{
try { dragBtn?.ReleasePointer(_activePointerId); } catch { }
}
_activePointerId = -1;
evt.StopImmediatePropagation();
}
private void OnDragPointerCancel(PointerCancelEvent evt)
{
if (!_isDragging) return;
if (evt.pointerId != _activePointerId) return;
_isDragging = false;
if (_activePointerId != -1)
{
try { dragBtn?.ReleasePointer(_activePointerId); } catch { }
}
_activePointerId = -1;
evt.StopImmediatePropagation();
}
private bool _geometryInitialized = false;
private void OnRightPanelGeometryChanged(GeometryChangedEvent evt)
{
// 드래그 중에는 GeometryChanged 이벤트 무시
if (_isDragging) return;
// 초기화: rightPanel의 레이아웃이 계산될 때까지 대기
if (!_geometryInitialized)
{
if (rightPanel == null || rightPanel.layout.height <= 0)
{
return; // 아직 레이아웃이 계산되지 않음
}
_geometryInitialized = true;
}
if (rightPanel != null && dragBtn != null)
{
UpdateDragAndPanels(rightPanel, dragBtn);
}
}
/// <summary>
/// glTF 모델과 간트 데이터를 하위 뷰에 로드하고, 선택 동기화를 위한 매핑을 구축합니다.
/// </summary>
/// <param name="gltfPaths">glTF/glb 파일 경로 목록.</param>
/// <param name="ganttPath">간트 데이터셋 경로.</param>
/// <param name="externalCt">외부 취소 토큰.</param>
public async UniTask LoadData(List<string> gltfPaths, string ganttPath, CancellationToken externalCt = default)
{
if(modelView == null)
{
//대기
while(modelView == null)
{
await UniTask.Yield();
}
}
Debug.Log($"NWModal.LoadData: gltfPath:{string.Join(", ", gltfPaths)}, ganttPath={ganttPath}");
// 로딩 표시
ShowLoading(true);
try
{
// 이전 작업 취소
if (_cts != null)
{
try { _cts.Cancel(); } catch { }
_cts.Dispose();
}
_cts = CancellationTokenSource.CreateLinkedTokenSource(externalCt);
var ct = _cts.Token;
// 모델/리스트 로드
IEnumerable<TreeListItemData> items = Array.Empty<TreeListItemData>();
if (modelView != null)
{
try
{
items = await modelView.LoadModelAsync(gltfPaths, ct);
}
catch (OperationCanceledException) { }
}
BuildKeyMaps(items);
if (listView != null) listView.SetData(items.ToList());
if (chartView != null) chartView.Load(ganttPath);
if (_playBar != null && chartView != null)
{
_playBar.SetTimeRange(chartView.ProjectStartDate, chartView.ProjectEndDate);
}
}
finally
{
// 로딩 숨기기
ShowLoading(false);
}
}
#region UI (Loading UI)
/// <summary>
/// 로딩 오버레이와 스피너 애니메이션을 표시하거나 숨깁니다.
/// </summary>
private void ShowLoading(bool show)
{
if (_loadingOverlay == null) return;
if (show)
{
_loadingOverlay.AddToClassList("visible");
StartSpinnerAnimation();
}
else
{
_loadingOverlay.RemoveFromClassList("visible");
StopSpinnerAnimation();
}
}
/// <summary>
/// 스피너 회전 애니메이션 시작
/// </summary>
private void StartSpinnerAnimation()
{
if (_loadingSpinner == null) return;
StopSpinnerAnimation();
float angle = 0f;
_spinnerAnimation = _loadingSpinner.schedule.Execute(() =>
{
angle = (angle + 10f) % 360f;
_loadingSpinner.style.rotate = new Rotate(Angle.Degrees(angle));
}).Every(16); // ~60fps
}
/// <summary>
/// 스피너 회전 애니메이션 중지
/// </summary>
private void StopSpinnerAnimation()
{
if (_spinnerAnimation != null)
{
_spinnerAnimation.Pause();
_spinnerAnimation = null;
}
if (_loadingSpinner != null)
{
_loadingSpinner.style.rotate = new Rotate(Angle.Degrees(0));
}
}
#endregion
private void BuildKeyMaps(IEnumerable<TreeListItemData> items)
{
_keyToId.Clear();
_idToKey.Clear();
if (items == null) return;
var stack = new Stack<TreeListItemData>();
foreach (var it in items)
{
if (it == null) continue;
stack.Push(it);
while (stack.Count > 0)
{
var cur = stack.Pop();
if (!string.IsNullOrEmpty(cur.ExternalKey))
{
_keyToId[cur.ExternalKey] = cur.id;
_idToKey[cur.id] = cur.ExternalKey;
}
if (cur.children != null)
{
for (int i = 0; i < cur.children.Count; i++)
{
var child = cur.children[i] as TreeListItemData;
if (child != null) stack.Push(child);
}
}
}
}
}
private void OnClickClose()
{
//this.gameObject.SetActive(false);
Destroy(this.gameObject);
}
private void OnClickShowTree()
{
if (listView != null)
{
listView.Show();
showTreeBtn.style.display = DisplayStyle.None;
//1frame 후에 위치 갱신
if (rightPanel != null)
{
rightPanel.schedule.Execute(() =>
{
UpdateDragAndPanels(rightPanel, dragBtn);
}).ExecuteLater(1);
}
}
}
private void OnListItemSelectionChanged(TreeListItemData data)
{
if (data == null) return;
Debug.Log($"NWModal.OnListItemSelectionChanged: id={data.id}, isSelected={data.isSelected}");
if (data.isSelected)
{
HandleSelection(data.id, "ListView");
}
else
{
HandleDeselection(data.id, "ListView");
}
}
private void OnListClosed()
{
showTreeBtn.style.display = DisplayStyle.Flex;
if (rightPanel != null) UpdateDragAndPanels(rightPanel, dragBtn);
}
private void OnListVisibilityChanged(TreeListItemData itemData)
{
if (modelView != null) modelView.SetVisibility(itemData.id, itemData.IsVisible);
}
private void OnModelItemSelected(TreeListItemData data)
{
if (data == null) return;
HandleSelection(data.id, "ModelView");
}
private void HandleSelection(int itemId, string source)
{
if (_suppressSelection) return;
_suppressSelection = true;
try
{
selectedItemId = itemId;
if (source != "ListView" && listView != null) listView.SelectByItemId(itemId);
if (source != "ModelView" && modelView != null) modelView.FocusItemById(itemId);
if (source != "ChartView" && chartView != null)
{
//if (_idToKey.TryGetValue(itemId, out var key)) chartView.SelectByItemKey(key);
//else chartView.SelectByItemId(itemId);
}
}
finally
{
_suppressSelection = false;
}
}
private void HandleDeselection(int itemId, string source)
{
if (_suppressSelection) return;
if (source != "ModelView" && modelView != null) modelView.UnfocusItem();
}
private void ToggleExpandModel()
{
if (_expanded == ExpandedSide.Model)
{
// 확장 해제: 저장된 비율로 복원
_expanded = ExpandedSide.None;
modelView.style.display = DisplayStyle.Flex;
chartView.style.display = DisplayStyle.Flex;
dragBtn.style.display = DisplayStyle.Flex;
// 저장된 비율로 복원
modelView.style.flexGrow = _lastModelFlexGrow;
chartView.style.flexGrow = _lastChartFlexGrow;
// 1프레임 후 dragBtn 위치 업데이트
rightPanel?.schedule.Execute(() =>
{
if (rightPanel != null) UpdateDragAndPanels(rightPanel, dragBtn);
}).ExecuteLater(1);
return;
}
// 확장 전 비율 저장
_lastModelFlexGrow = modelView.resolvedStyle.flexGrow;
_lastChartFlexGrow = chartView.resolvedStyle.flexGrow;
_expanded = ExpandedSide.Model;
//modelView 확장
modelView.style.display = DisplayStyle.Flex;
modelView.style.flexGrow = 1;
chartView.style.display = DisplayStyle.None;
dragBtn.style.display = DisplayStyle.None;
}
private void ToggleExpandChart()
{
if (_expanded == ExpandedSide.Chart)
{
// 확장 해제: 저장된 비율로 복원
_expanded = ExpandedSide.None;
modelView.style.display = DisplayStyle.Flex;
chartView.style.display = DisplayStyle.Flex;
dragBtn.style.display = DisplayStyle.Flex;
// 저장된 비율로 복원
modelView.style.flexGrow = _lastModelFlexGrow;
chartView.style.flexGrow = _lastChartFlexGrow;
// 1프레임 후 dragBtn 위치 업데이트
rightPanel?.schedule.Execute(() =>
{
if (rightPanel != null) UpdateDragAndPanels(rightPanel, dragBtn);
}).ExecuteLater(1);
return;
}
// 확장 전 비율 저장
_lastModelFlexGrow = modelView.resolvedStyle.flexGrow;
_lastChartFlexGrow = chartView.resolvedStyle.flexGrow;
_expanded = ExpandedSide.Chart;
// chartView 확장
modelView.style.display = DisplayStyle.None;
chartView.style.display = DisplayStyle.Flex;
chartView.style.flexGrow = 1;
dragBtn.style.display = DisplayStyle.None;
}
// dragBtn 중심 Y를 주면 flex-grow를 조절하여 modelView와 chartView 비율 조정 (수직)
private void ApplyDragPosition(VisualElement root, VisualElement drag, float centerY)
{
if (modelView == null || chartView == null) return;
// 확장 모드일 때는 비율 조정하지 않음
if (_expanded != ExpandedSide.None) return;
float dragHalf = Mathf.Max(1f, drag.layout.height * 0.5f);
// 드래그 가능 범위 계산 (수직)
float topBound = 0f;
float bottomBound = root.layout.height;
// centerY를 범위 내로 제한
centerY = Mathf.Clamp(centerY, topBound, bottomBound);
// 사용 가능한 높이
float availableHeight = bottomBound - topBound;
// centerY를 topBound 기준 상대 좌표로 변환
float relativeY = centerY - topBound;
// flex 비율 계산 (relativeY : (availableHeight - relativeY))
float modelFlexGrow = relativeY;
float chartFlexGrow = availableHeight - relativeY;
// 양 끝 판정을 위한 threshold
float threshold = 2f;
bool isAtTopEdge = relativeY < threshold;
bool isAtBottomEdge = relativeY > (availableHeight - threshold);
// 가시성 및 flex 처리
if (isAtTopEdge)
{
// 위쪽 끝: modelView 숨김, chartView만 표시
modelView.style.display = DisplayStyle.None;
chartView.style.display = DisplayStyle.Flex;
chartView.style.flexGrow = 1;
}
else if (isAtBottomEdge)
{
// 아래쪽 끝: chartView 숨김, modelView만 표시
modelView.style.display = DisplayStyle.Flex;
chartView.style.display = DisplayStyle.None;
modelView.style.flexGrow = 1;
}
else
{
// 중간: 둘 다 표시, flex 비율로 조정
modelView.style.display = DisplayStyle.Flex;
chartView.style.display = DisplayStyle.Flex;
modelView.style.flexGrow = modelFlexGrow;
chartView.style.flexGrow = chartFlexGrow;
// 비율 저장 (중간 위치에서만)
_lastModelFlexGrow = modelFlexGrow;
_lastChartFlexGrow = chartFlexGrow;
}
// dragBtn 위치 조정 (수직)
float newDragTop = centerY - dragHalf;
drag.style.top = new Length(newDragTop, LengthUnit.Pixel);
}
private void UpdateDragAndPanels(VisualElement root, VisualElement drag)
{
if (modelView == null || chartView == null) return;
// modelView의 현재 layout을 사용해서 dragBtn을 배치 (수직)
var mv = modelView.layout;
// modelView의 아래쪽 끝이 dragBtn의 중심
float centerY = mv.height;
// 적용
ApplyDragPosition(root, drag, centerY);
}
// 테스트용 데이터 생성
List<TreeListItemData> GenerateDummyData()
{
var root1 = new TreeListItemData { id = 1, name = "모델", isExpanded = true };
root1.Add(new TreeListItemData { id = 2, name = "모델1", parent = root1 });
root1.Add(new TreeListItemData { id = 3, name = "모델2", parent = root1, IsVisible = false });
var child3 = new TreeListItemData { id = 4, name = "모델3", parent = root1 };
child3.Add(new TreeListItemData { id = 5, name = "메쉬 A", parent = child3 });
root1.Add(child3);
return new List<TreeListItemData> { root1 };
}
private void OnDestroy()
{
// CancellationTokenSource 정리
if (_cts != null)
{
try { _cts.Cancel(); } catch { }
_cts.Dispose();
_cts = null;
}
// listView 이벤트 해제 및 Dispose
if (listView != null)
{
listView.OnSelectionChanged -= OnListItemSelectionChanged;
listView.OnClosed -= OnListClosed;
listView.OnVisibilityChanged -= OnListVisibilityChanged;
listView.Dispose();
}
// modelView 이벤트 해제 및 Dispose
if (modelView != null)
{
modelView.OnItemSelected -= OnModelItemSelected;
modelView.OnExpand -= ToggleExpandModel;
modelView.Dispose();
}
// chartView 이벤트 해제 및 Dispose
if (chartView != null)
{
chartView.OnExpand -= ToggleExpandChart;
chartView.Dispose();
}
// 버튼 이벤트 해제
if (showTreeBtn != null) showTreeBtn.clicked -= OnClickShowTree;
if (closeBtn != null) closeBtn.clicked -= OnClickClose;
// dragBtn 포인터 이벤트 해제
if (dragBtn != null)
{
dragBtn.UnregisterCallback<PointerDownEvent>(OnDragPointerDown, TrickleDown.TrickleDown);
dragBtn.UnregisterCallback<PointerMoveEvent>(OnDragPointerMove, TrickleDown.TrickleDown);
dragBtn.UnregisterCallback<PointerUpEvent>(OnDragPointerUp, TrickleDown.TrickleDown);
dragBtn.UnregisterCallback<PointerCancelEvent>(OnDragPointerCancel, TrickleDown.TrickleDown);
}
// rightPanel GeometryChangedEvent 해제
if (rightPanel != null && _rightPanelGeometryChangedCallback != null)
{
rightPanel.UnregisterCallback(_rightPanelGeometryChangedCallback);
_rightPanelGeometryChangedCallback = null;
}
if( _playBar != null)
{
_playBar.OnPlayProgress -= OnPlayProgressHandler;
_playBar.OnPositionChanged -= OnPlayPositionChangedHandler;
_playBar.Dispose();
}
// 딕셔너리 정리
_keyToId.Clear();
_idToKey.Clear();
// 드래그 상태 초기화
_isDragging = false;
_activePointerId = -1;
_dragOffset = 0f;
_geometryInitialized = false;
// 로딩 UI 정리
StopSpinnerAnimation();
_loadingOverlay = null;
_loadingSpinner = null;
// UI 참조 정리
rightPanel = null;
listView = null;
modelView = null;
chartView = null;
closeBtn = null;
showTreeBtn = null;
dragBtn = null;
}
}
}