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

1059 lines
39 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEngine;
using UnityEngine.UIElements;
using UVC.Util;
namespace SHI.Modal.NW
{
/// <summary>
/// 네트워크 다이어그램 노드의 드래그 모드를 정의합니다.
/// </summary>
public enum NodeDragMode
{
/// <summary>드래그 불가 (고정 위치)</summary>
None,
/// <summary>수평(X축) 이동만 가능</summary>
HorizontalOnly,
/// <summary>수직(Y축) 이동만 가능 (기본값)</summary>
VerticalOnly,
/// <summary>자유롭게 이동 가능</summary>
Free
}
/// <summary>
/// NW(네트워크 다이어그램) 차트를 표시하는 UI Toolkit 컴포넌트입니다.
///
/// <para><b>개요:</b></para>
/// <para>
/// JSON 파일에서 작업 데이터를 로드하여 네트워크 다이어그램 형태로 시각화합니다.
/// 각 작업은 노드로 표시되며, ERE_NW_NXT_ACTV_CD 필드를 통해 노드 간 연결선이 그려집니다.
/// 노드를 드래그하여 위치를 조정할 수 있습니다.
/// </para>
///
/// <para><b>주요 기능:</b></para>
/// <list type="bullet">
/// <item>JSON 데이터 로드 및 파싱 (NWChartDataTask)</item>
/// <item>노드 자동 배치 (겹침 방지 알고리즘)</item>
/// <item>노드 간 연결선 렌더링 (충돌 우회 경로)</item>
/// <item>노드 드래그 이동 (수직/수평/자유 모드)</item>
/// <item>타임라인 헤더 (날짜 기반)</item>
/// </list>
///
/// <para><b>네트워크 구조:</b></para>
/// <list type="bullet">
/// <item>ERE_NW_ACTV_CD - 현재 노드의 활동 코드</item>
/// <item>ERE_NW_NXT_ACTV_CD - 다음 노드로의 연결 (화살표)</item>
/// <item>STDT - 시작일 (노드의 X 위치 결정)</item>
/// </list>
///
/// <para><b>사용 예시:</b></para>
/// <code>
/// var chart = root.Q&lt;NWChart&gt;();
/// chart.DragMode = NodeDragMode.VerticalOnly;
/// chart.Load("path/to/nw_data.json");
/// chart.OnExpand += () => { /* 확장 처리 */ };
/// </code>
///
/// <para><b>관련 리소스:</b></para>
/// <list type="bullet">
/// <item>Resources/SHI/Modal/NW/NWChart.uxml - 메인 레이아웃</item>
/// </list>
/// </summary>
[UxmlElement]
public partial class NWChart : VisualElement, IDisposable
{
#region IDisposable
private bool _disposed = false;
#endregion
private const string UXML_PATH = "SHI/Modal/NW/NWChart";
public Action? OnExpand;
/// <summary>
/// 노드 드래그 모드 설정
/// </summary>
public NodeDragMode DragMode { get; set; } = NodeDragMode.VerticalOnly;
private Button? _expandBtn;
// Network diagram settings
private float pixelsPerDay = 18f;
private float nodeWidth = 60f;
private float nodeHeight = 30f;
private Color nodeColor = new Color(0.2f, 0.6f, 0.9f, 1f);
private Color nodeEndColor = new Color(0.9f, 0.8f, 0.2f, 1f);
private Color lineColor = new Color(0.3f, 0.3f, 0.3f, 1f);
private VisualElement? root;
private VisualElement? headerTimeline;
private VisualElement? monthsLayer;
private VisualElement? daysLayer;
private VisualElement? networkCanvas;
private VisualElement? nodesLayer;
private VisualElement? linesLayer;
private ScrollView? contentScroll;
private List<NWChartDataTask>? tasks;
private Dictionary<string, NWChartDataTask> tasksByActivityCode = new();
private Dictionary<string, VisualElement> nodeElements = new();
private Dictionary<string, Vector2> nodePositions = new();
// 연결선 캐싱 (fromCode_toCode -> ConnectionElements)
private Dictionary<string, ConnectionElements> connectionCache = new();
// 연결선 요소들을 묶어서 관리
private class ConnectionElements
{
public VisualElement[] Lines = Array.Empty<VisualElement>();
public VisualElement? Arrow;
public string FromCode = "";
public string ToCode = "";
public int Ptch;
}
// 노드 외곽 우회를 위한 여백
private float routingPadding = 8f;
// 드래그 관련 변수
private VisualElement? draggingNode;
private string? draggingActivityCode;
private Vector2 dragStartMousePos;
private Vector2 dragStartNodePos;
private bool isDragging;
private DateTime projectStartDate;
public DateTime ProjectStartDate => projectStartDate;
private DateTime projectEndDate;
public DateTime ProjectEndDate => projectEndDate;
private int totalDays;
private float canvasHeight;
public NWChart()
{
var visualTree = Resources.Load<VisualTreeAsset>(UXML_PATH);
if (visualTree != null)
{
visualTree.CloneTree(this);
}
else
{
Debug.LogError($"Failed to load UXML at path: {UXML_PATH}");
}
_expandBtn = this.Q<Button>("expand-btn");
if (_expandBtn != null)
{
_expandBtn.clicked += OnExpandBtnClicked;
}
}
private void OnExpandBtnClicked()
{
OnExpand?.Invoke();
}
public void Load(string jsonFileName)
{
root = this;
LoadData(jsonFileName);
CalculateProjectRange();
CalculateNodePositions();
InitializeUI();
SetupHeader();
RenderNetwork();
}
/// <summary>
/// 지정한 날짜가 STDT~FNDT 범위에 포함되는 작업들의 BLK_NO 목록을 반환합니다.
/// </summary>
/// <param name="yyyymmdd">조회할 날짜 (yyyyMMdd 형식, 예: "20250818")</param>
/// <returns>해당 날짜에 진행 중인 블록 번호 목록</returns>
public List<string> GetModelNamesByDate(string yyyymmdd)
{
var result = new List<string>();
if (tasks == null || string.IsNullOrEmpty(yyyymmdd)) return result;
foreach (var task in tasks)
{
// BLK_NO가 없으면 스킵
if (string.IsNullOrEmpty(task.BLK_NO)) continue;
// STDT, FNDT 가져오기
string stdt = task.STDT;
string fndt = task.FNDT;
// STDT가 없으면 스킵
if (string.IsNullOrEmpty(stdt) || stdt == "null") continue;
// FNDT가 없으면 STDT와 동일하게 처리
if (string.IsNullOrEmpty(fndt) || fndt == "null") fndt = stdt;
// 문자열 비교로 범위 체크 (yyyyMMdd 형식은 문자열 비교로 날짜 비교 가능)
if (string.CompareOrdinal(stdt, yyyymmdd) <= 0 && string.CompareOrdinal(yyyymmdd, fndt) <= 0)
{
// 중복 방지
if (!result.Contains(task.BLK_NO))
{
result.Add(task.BLK_NO);
}
}
}
return result;
}
void LoadData(string jsonFileName)
{
var json = File.ReadAllText(jsonFileName);
var wrapper = JsonUtility.FromJson<NWChartDataWrapper>("{\"items\":" + json + "}");
tasks = wrapper.items.Where(t => !string.IsNullOrEmpty(t.STDT) && t.STDT != "null").ToList();
// Group by ERE_NW_ACTV_CD to get unique activities
tasksByActivityCode.Clear();
foreach (var task in tasks)
{
if (!string.IsNullOrEmpty(task.ERE_NW_ACTV_CD) && !tasksByActivityCode.ContainsKey(task.ERE_NW_ACTV_CD))
{
tasksByActivityCode[task.ERE_NW_ACTV_CD] = task;
}
}
}
void CalculateProjectRange()
{
var dates = new List<DateTime>();
foreach (var task in tasks!)
{
if (task.GetStartDate().HasValue) dates.Add(task.GetStartDate()!.Value);
if (task.GetEndDate().HasValue) dates.Add(task.GetEndDate()!.Value);
}
projectStartDate = dates.Any() ? dates.Min().AddDays(-3) : new DateTime(2025, 8, 1);
projectEndDate = dates.Any() ? dates.Max().AddDays(3) : new DateTime(2025, 12, 31);
totalDays = (int)(projectEndDate - projectStartDate).TotalDays + 1;
}
void CalculateNodePositions()
{
nodePositions.Clear();
// 노드 간격 설정
float nodeSpacingY = nodeHeight + 10f; // 노드 높이 + 여백
float nodeSpacingX = 10f; // 노드 간 수평 여백
// 1. 연결 체인 찾기 (시작 노드부터 끝까지 연결된 노드들)
var chains = FindConnectedChains();
// 2. 각 노드를 개별적으로 배치 (겹침 방지)
int maxRow = 0;
var placedNodes = new Dictionary<string, Rect>(); // activityCode -> Rect
foreach (var chain in chains)
{
// 체인의 첫 노드를 기준 행에 배치 시도
int baseRow = FindAvailableRowForNode(chain[0], placedNodes, nodeSpacingY, nodeSpacingX, maxRow);
foreach (var activityCode in chain)
{
if (!tasksByActivityCode.ContainsKey(activityCode)) continue;
var task = tasksByActivityCode[activityCode];
var startDate = task.GetStartDate();
if (!startDate.HasValue) continue;
float x = (float)(startDate.Value - projectStartDate).TotalDays * pixelsPerDay + nodeWidth / 2;
// 이 노드가 배치 가능한 행 찾기 (기준 행부터 시도)
int nodeRow = FindAvailableRowForNodeAt(x, placedNodes, nodeSpacingY, nodeSpacingX, baseRow, maxRow);
float y = nodeRow * nodeSpacingY + nodeHeight / 2 + 20;
nodePositions[activityCode] = new Vector2(x, y);
// 충돌 감지용 Rect 저장
var newRect = new Rect(x - nodeWidth / 2 - nodeSpacingX / 2, y - nodeHeight / 2 - 2, nodeWidth + nodeSpacingX, nodeHeight + 4);
placedNodes[activityCode] = newRect;
maxRow = Math.Max(maxRow, nodeRow);
}
}
// 캔버스 높이 계산
canvasHeight = Math.Max((maxRow + 1) * nodeSpacingY + 50, 400);
}
// 특정 노드가 배치 가능한 행 찾기
int FindAvailableRowForNode(string activityCode, Dictionary<string, Rect> placedNodes, float nodeSpacingY, float nodeSpacingX, int maxRow)
{
if (!tasksByActivityCode.ContainsKey(activityCode)) return 0;
var task = tasksByActivityCode[activityCode];
var startDate = task.GetStartDate();
if (!startDate.HasValue) return 0;
float x = (float)(startDate.Value - projectStartDate).TotalDays * pixelsPerDay + nodeWidth / 2;
return FindAvailableRowForNodeAt(x, placedNodes, nodeSpacingY, nodeSpacingX, 0, maxRow);
}
// 특정 X 좌표에서 배치 가능한 행 찾기 (startRow부터 시도)
int FindAvailableRowForNodeAt(float x, Dictionary<string, Rect> placedNodes, float nodeSpacingY, float nodeSpacingX, int startRow, int maxRow)
{
for (int tryRow = startRow; tryRow <= maxRow + 10; tryRow++)
{
float y = tryRow * nodeSpacingY + nodeHeight / 2 + 20;
var newRect = new Rect(x - nodeWidth / 2 - nodeSpacingX / 2, y - nodeHeight / 2 - 2, nodeWidth + nodeSpacingX, nodeHeight + 4);
bool canPlace = true;
foreach (var placedRect in placedNodes.Values)
{
if (newRect.Overlaps(placedRect))
{
canPlace = false;
break;
}
}
if (canPlace) return tryRow;
}
return maxRow + 1;
}
// 연결된 노드 체인 찾기
List<List<string>> FindConnectedChains()
{
var chains = new List<List<string>>();
var visited = new HashSet<string>();
// 시작 노드 찾기 (들어오는 연결이 없는 노드)
var hasIncoming = new HashSet<string>();
foreach (var task in tasks!)
{
if (!string.IsNullOrEmpty(task.ERE_NW_NXT_ACTV_CD))
{
hasIncoming.Add(task.ERE_NW_NXT_ACTV_CD);
}
}
// 시작 노드들을 날짜순으로 정렬
var startNodes = tasksByActivityCode.Keys
.Where(code => !hasIncoming.Contains(code))
.OrderBy(code => tasksByActivityCode[code].STDT)
.ToList();
// 각 시작 노드에서 체인 추적
foreach (var startNode in startNodes)
{
if (visited.Contains(startNode)) continue;
var chain = new List<string>();
TraceChain(startNode, chain, visited);
if (chain.Count > 0)
{
chains.Add(chain);
}
}
// 방문하지 않은 노드 처리 (순환 또는 고립된 노드)
foreach (var code in tasksByActivityCode.Keys)
{
if (!visited.Contains(code))
{
chains.Add(new List<string> { code });
visited.Add(code);
}
}
return chains;
}
// 체인 추적 (시작 노드에서 연결된 모든 노드 찾기)
void TraceChain(string currentCode, List<string> chain, HashSet<string> visited)
{
if (visited.Contains(currentCode)) return;
if (!tasksByActivityCode.ContainsKey(currentCode)) return;
visited.Add(currentCode);
chain.Add(currentCode);
// 다음 노드 찾기
var task = tasksByActivityCode[currentCode];
if (!string.IsNullOrEmpty(task.ERE_NW_NXT_ACTV_CD))
{
TraceChain(task.ERE_NW_NXT_ACTV_CD, chain, visited);
}
}
void InitializeUI()
{
headerTimeline = root!.Q<VisualElement>("header-timeline");
headerTimeline!.style.width = totalDays * pixelsPerDay;
monthsLayer = headerTimeline.Q<VisualElement>("timeline-months");
daysLayer = headerTimeline.Q<VisualElement>("timeline-days");
networkCanvas = root.Q<VisualElement>("network-canvas");
networkCanvas!.style.width = totalDays * pixelsPerDay;
networkCanvas.style.height = canvasHeight;
linesLayer = root.Q<VisualElement>("lines-layer");
linesLayer!.style.width = totalDays * pixelsPerDay;
linesLayer.style.height = canvasHeight;
nodesLayer = root.Q<VisualElement>("nodes-layer");
nodesLayer!.style.width = totalDays * pixelsPerDay;
nodesLayer.style.height = canvasHeight;
contentScroll = root.Q<ScrollView>("content-scroll");
if (contentScroll != null)
{
contentScroll.horizontalScroller.valueChanged += OnHorizontalScroll;
}
}
void OnHorizontalScroll(float scrollValue)
{
var header = root!.Q<VisualElement>("header");
if (header != null)
{
header.style.left = -scrollValue;
}
}
void SetupHeader()
{
Color borderColor = ColorUtil.FromHex("#CACACA");
monthsLayer?.Clear();
daysLayer?.Clear();
foreach (var layer in new[] { monthsLayer, daysLayer })
{
layer!.style.width = totalDays * pixelsPerDay;
}
for (int i = 0; i < totalDays; i++)
{
DateTime date = projectStartDate.AddDays(i);
AddMonthLabel(monthsLayer!, date.Year, date.Month, date.Day, borderColor);
AddDayCell(daysLayer!, date, borderColor);
}
}
void AddMonthLabel(VisualElement parent, int year, int month, int day, Color borderColor)
{
var ve = new VisualElement();
ve.style.width = pixelsPerDay;
ve.style.height = pixelsPerDay * 2;
ve.style.borderRightColor = borderColor;
ve.style.borderRightWidth = 1;
var lab = new Label($"{year.ToString().Substring(2)}\n{month.ToString("D2")}\n{day.ToString("D2")}");
lab.style.unityTextAlign = TextAnchor.MiddleRight;
lab.style.unityFontStyleAndWeight = FontStyle.Normal;
lab.style.width = Length.Percent(100);
lab.style.height = Length.Percent(100);
lab.style.fontSize = 9;
lab.style.marginBottom = 0;
lab.style.marginTop = 0;
lab.style.marginLeft = 0;
lab.style.marginRight = 0;
lab.style.paddingBottom = 0;
lab.style.paddingTop = 0;
lab.style.paddingLeft = 0;
lab.style.paddingRight = 0;
ve.Add(lab);
parent.Add(ve);
}
void AddDayCell(VisualElement parent, DateTime date, Color borderColor)
{
var ve = new VisualElement();
ve.style.width = pixelsPerDay;
ve.style.height = pixelsPerDay;
ve.style.borderRightColor = borderColor;
ve.style.borderRightWidth = 1;
var task = tasks!.Find( (item) => item.STDT == date.ToString("yyyyMMdd"));
if(task != null)
{
// Debug.Log($"Found task for date {date:yyyyMMdd}: REL_TP={task.REL_TP}, PROJ_TP={task.PROJ_TP}");
var lab = new Label($"{task.REL_TP}\n{task.PROJ_TP}");
lab.style.unityTextAlign = TextAnchor.MiddleRight;
lab.style.width = Length.Percent(100);
lab.style.height = Length.Percent(100);
lab.style.fontSize = 9;
lab.style.marginBottom = 0;
lab.style.marginTop = 0;
lab.style.marginLeft = 0;
lab.style.marginRight = 0;
lab.style.paddingBottom = 0;
lab.style.paddingTop = 0;
lab.style.paddingLeft = 0;
lab.style.paddingRight = 0;
ve.Add(lab);
}
parent.Add(ve);
}
void RenderNetwork()
{
if (nodesLayer == null || linesLayer == null) return;
nodesLayer.Clear();
linesLayer.Clear();
nodeElements.Clear();
connectionCache.Clear();
// Render connection lines first (behind nodes)
CreateConnections();
// Render nodes
foreach (var kvp in tasksByActivityCode)
{
var activityCode = kvp.Key;
var task = kvp.Value;
if (!nodePositions.ContainsKey(activityCode)) continue;
var pos = nodePositions[activityCode];
var node = CreateNode(task, pos, activityCode);
nodeElements[activityCode] = node;
nodesLayer.Add(node);
}
}
// 연결선 초기 생성 (캐싱)
void CreateConnections()
{
foreach (var task in tasks!)
{
if (string.IsNullOrEmpty(task.ERE_NW_ACTV_CD) || string.IsNullOrEmpty(task.ERE_NW_NXT_ACTV_CD))
continue;
var fromCode = task.ERE_NW_ACTV_CD;
var toCode = task.ERE_NW_NXT_ACTV_CD;
if (!nodePositions.ContainsKey(fromCode) || !nodePositions.ContainsKey(toCode))
continue;
var key = $"{fromCode}_{toCode}";
if (connectionCache.ContainsKey(key)) continue;
var conn = new ConnectionElements
{
FromCode = fromCode,
ToCode = toCode,
Ptch = task.PTCH ?? 0
};
// 최대 8개의 선 (노드 우회 시 더 많은 세그먼트 필요)
var lines = new List<VisualElement>();
for (int i = 0; i < 8; i++)
{
var line = CreateLineElement();
lines.Add(line);
linesLayer!.Add(line);
}
conn.Lines = lines.ToArray();
// 화살표 생성
conn.Arrow = CreateArrowElement();
linesLayer!.Add(conn.Arrow);
connectionCache[key] = conn;
}
// 초기 위치 설정
UpdateAllConnectionPositions();
}
VisualElement CreateLineElement()
{
var line = new VisualElement();
line.AddToClassList("connection-line");
line.style.position = Position.Absolute;
line.style.backgroundColor = lineColor;
line.style.display = DisplayStyle.None;
return line;
}
VisualElement CreateArrowElement()
{
var arrow = new VisualElement();
arrow.AddToClassList("connection-arrow");
arrow.style.position = Position.Absolute;
arrow.style.width = 10;
arrow.style.height = 10;
// VectorAPI로 삼각형 그리기
arrow.generateVisualContent += (ctx) =>
{
var painter = ctx.painter2D;
painter.fillColor = lineColor;
painter.BeginPath();
// 오른쪽을 향하는 삼각형 (화살표)
painter.MoveTo(new Vector2(0, 0));
painter.LineTo(new Vector2(10, 5));
painter.LineTo(new Vector2(0, 10));
painter.ClosePath();
painter.Fill();
};
return arrow;
}
// 모든 연결선 위치 업데이트
void UpdateAllConnectionPositions()
{
foreach (var conn in connectionCache.Values)
{
UpdateConnectionPosition(conn);
}
}
// 특정 노드와 관련된 연결선만 업데이트
void UpdateConnectionsForNode(string activityCode)
{
foreach (var conn in connectionCache.Values)
{
if (conn.FromCode == activityCode || conn.ToCode == activityCode)
{
UpdateConnectionPosition(conn);
}
}
}
void UpdateConnectionPosition(ConnectionElements conn)
{
if (!nodePositions.ContainsKey(conn.FromCode) || !nodePositions.ContainsKey(conn.ToCode))
return;
var fromPos = nodePositions[conn.FromCode];
var toPos = nodePositions[conn.ToCode];
float startX = fromPos.x + nodeWidth / 2;
float startY = fromPos.y;
float endX = toPos.x - nodeWidth / 2;
float endY = toPos.y;
// 모든 선 숨기기
foreach (var line in conn.Lines)
{
line.style.display = DisplayStyle.None;
}
// 경로 계산 (노드 외곽 우회)
var path = CalculateRoutingPath(startX, startY, endX, endY, conn.FromCode, conn.ToCode);
// 경로를 선으로 그리기
int lineIndex = 0;
for (int i = 0; i < path.Count - 1 && lineIndex < conn.Lines.Length; i++)
{
var p1 = path[i];
var p2 = path[i + 1];
SetLinePosition(conn.Lines[lineIndex], p1.x, p1.y, p2.x, p2.y);
conn.Lines[lineIndex].style.display = DisplayStyle.Flex;
lineIndex++;
}
// 화살표 위치 (마지막 세그먼트의 끝)
if (conn.Arrow != null && path.Count >= 2)
{
var lastPoint = path[path.Count - 1];
conn.Arrow.style.left = lastPoint.x - 10;
conn.Arrow.style.top = lastPoint.y - 5;
}
}
// 노드를 피해서 경로 계산
List<Vector2> CalculateRoutingPath(float startX, float startY, float endX, float endY, string fromCode, string toCode)
{
var path = new List<Vector2>();
path.Add(new Vector2(startX, startY));
// 화살표 앞 수평선 길이 (10px)
float arrowPadding = 10f;
// 같은 높이면 직선 가능한지 확인
if (Math.Abs(startY - endY) < 1)
{
// 직선 경로에 노드가 있는지 확인
var blockingNode = FindBlockingNodeOnHorizontalPath(startX, endX, startY, fromCode, toCode);
if (blockingNode == null)
{
// 직선으로 연결
path.Add(new Vector2(endX, endY));
}
else
{
// 노드 우회 (위 또는 아래로)
var nodePos = nodePositions[blockingNode];
float detourY = nodePos.y - nodeHeight / 2 - routingPadding; // 항상 노드 위로
path.Add(new Vector2(nodePos.x - nodeWidth / 2 - routingPadding, startY));
path.Add(new Vector2(nodePos.x - nodeWidth / 2 - routingPadding, detourY));
path.Add(new Vector2(nodePos.x + nodeWidth / 2 + routingPadding, detourY));
path.Add(new Vector2(nodePos.x + nodeWidth / 2 + routingPadding, endY));
path.Add(new Vector2(endX, endY));
}
}
else
{
// 다른 높이 - 수직선 후 화살표 앞에 수평선 추가
float verticalX = endX - routingPadding - arrowPadding;
// 수직선 경로에 노드가 있는지 확인
var blockingNode = FindBlockingNodeOnVerticalPath(verticalX, startY, endY, fromCode, toCode);
if (blockingNode == null)
{
// 노드 없음 - 기본 경로 (수평 -> 수직 -> 수평)
path.Add(new Vector2(verticalX, startY));
path.Add(new Vector2(verticalX, endY));
path.Add(new Vector2(endX, endY));
}
else
{
// 충돌 노드 우회 - 노드 외곽으로 돌아감
var nodePos = nodePositions[blockingNode];
// 노드 왼쪽으로 우회
float detourX = nodePos.x - nodeWidth / 2 - routingPadding - arrowPadding;
// 시작점에서 우회 지점까지 수평
path.Add(new Vector2(detourX, startY));
// 우회 지점에서 끝점 높이까지 수직
path.Add(new Vector2(detourX, endY));
// 끝점까지 수평
path.Add(new Vector2(endX, endY));
}
}
return path;
}
// 수평 경로에서 충돌하는 노드 찾기
string? FindBlockingNodeOnHorizontalPath(float x1, float x2, float y, string excludeFrom, string excludeTo)
{
float minX = Math.Min(x1, x2);
float maxX = Math.Max(x1, x2);
float halfH = nodeHeight / 2;
foreach (var kvp in nodePositions)
{
if (kvp.Key == excludeFrom || kvp.Key == excludeTo) continue;
var pos = kvp.Value;
float nodeLeft = pos.x - nodeWidth / 2;
float nodeRight = pos.x + nodeWidth / 2;
// 수평선이 노드 영역을 지나가는지 확인
if (nodeLeft < maxX && nodeRight > minX)
{
// Y 범위도 확인 (노드 내부를 통과하는지)
if (y >= pos.y - halfH && y <= pos.y + halfH)
{
return kvp.Key;
}
}
}
return null;
}
// 수직 경로에서 충돌하는 노드 찾기
string? FindBlockingNodeOnVerticalPath(float x, float y1, float y2, string excludeFrom, string excludeTo)
{
float minY = Math.Min(y1, y2);
float maxY = Math.Max(y1, y2);
float halfW = nodeWidth / 2;
foreach (var kvp in nodePositions)
{
if (kvp.Key == excludeFrom || kvp.Key == excludeTo) continue;
var pos = kvp.Value;
float nodeTop = pos.y - nodeHeight / 2;
float nodeBottom = pos.y + nodeHeight / 2;
// 수직선이 노드 영역을 지나가는지 확인 (노드 내부를 통과하는지)
if (nodeTop < maxY && nodeBottom > minY)
{
// X 범위도 확인
if (x >= pos.x - halfW && x <= pos.x + halfW)
{
return kvp.Key;
}
}
}
return null;
}
void SetLinePosition(VisualElement line, float x1, float y1, float x2, float y2)
{
if (Math.Abs(y1 - y2) < 1)
{
// 수평선
float left = Math.Min(x1, x2);
float width = Math.Abs(x2 - x1);
line.style.left = left;
line.style.top = y1 - 1;
line.style.width = Math.Max(width, 1);
line.style.height = 2;
}
else
{
// 수직선
float top = Math.Min(y1, y2);
float height = Math.Abs(y2 - y1);
line.style.left = x1 - 1;
line.style.top = top;
line.style.width = 2;
line.style.height = Math.Max(height, 1);
}
}
VisualElement CreateNode(NWChartDataTask task, Vector2 position, string activityCode)
{
var node = new VisualElement();
node.AddToClassList("nw-node");
node.style.position = Position.Absolute;
node.style.left = position.x - nodeWidth / 2;
node.style.top = position.y - nodeHeight / 2;
node.style.width = nodeWidth;
node.style.height = nodeHeight;
// 드래그를 위한 activity code 저장
node.userData = activityCode;
// Determine node color based on whether it has next activity
bool isEndNode = string.IsNullOrEmpty(task.ERE_NW_NXT_ACTV_CD);
node.style.backgroundColor = isEndNode ? nodeEndColor : nodeColor;
node.style.borderTopLeftRadius = 4;
node.style.borderTopRightRadius = 4;
node.style.borderBottomLeftRadius = 4;
node.style.borderBottomRightRadius = 4;
node.style.borderTopWidth = 1;
node.style.borderRightWidth = 1;
node.style.borderBottomWidth = 1;
node.style.borderLeftWidth = 1;
node.style.borderTopColor = new Color(0.2f, 0.2f, 0.2f, 0.5f);
node.style.borderRightColor = new Color(0.2f, 0.2f, 0.2f, 0.5f);
node.style.borderBottomColor = new Color(0.2f, 0.2f, 0.2f, 0.5f);
node.style.borderLeftColor = new Color(0.2f, 0.2f, 0.2f, 0.5f);
// Node label
var label = new Label(task.BLK_NO ?? task.ERE_NW_ACTV_CD);
label.AddToClassList("nw-node-label");
label.style.unityTextAlign = TextAnchor.MiddleCenter;
label.style.width = Length.Percent(100);
label.style.height = Length.Percent(100);
label.style.fontSize = 9;
label.style.color = Color.white;
label.style.marginBottom = 0;
label.style.marginTop = 0;
label.style.marginLeft = 0;
label.style.marginRight = 0;
label.style.paddingBottom = 0;
label.style.paddingTop = 0;
label.style.paddingLeft = 0;
label.style.paddingRight = 0;
label.style.unityFontStyleAndWeight = FontStyle.Bold;
label.style.overflow = Overflow.Hidden;
label.style.textOverflow = TextOverflow.Ellipsis;
label.pickingMode = PickingMode.Ignore; // 라벨은 이벤트 무시
node.Add(label);
// Tooltip
var startDate = task.GetStartDate();
var endDate = task.GetEndDate();
string dateInfo = "";
if (startDate.HasValue) dateInfo = $"{startDate.Value:yyyy-MM-dd}";
if (endDate.HasValue && endDate != startDate) dateInfo += $" ~ {endDate.Value:yyyy-MM-dd}";
node.tooltip = $"{task.BLK_NO}\n{task.ERE_NW_ACTV_CD}\n{dateInfo}\nWKA: {task.WKA_NM}";
// 드래그 이벤트 등록
node.RegisterCallback<PointerDownEvent>(OnNodePointerDown);
node.RegisterCallback<PointerMoveEvent>(OnNodePointerMove);
node.RegisterCallback<PointerUpEvent>(OnNodePointerUp);
return node;
}
// 노드 드래그 시작
void OnNodePointerDown(PointerDownEvent evt)
{
if (evt.button != 0) return; // 좌클릭만
// 드래그 불가 모드면 무시
if (DragMode == NodeDragMode.None) return;
var node = evt.currentTarget as VisualElement;
if (node == null) return;
draggingNode = node;
draggingActivityCode = node.userData as string;
isDragging = true;
// 마우스 시작 위치 (nodesLayer 기준)
dragStartMousePos = evt.localPosition;
dragStartMousePos = nodesLayer!.WorldToLocal(evt.position);
// 노드 시작 위치
if (draggingActivityCode != null && nodePositions.ContainsKey(draggingActivityCode))
{
dragStartNodePos = nodePositions[draggingActivityCode];
}
// 드래그 중 스타일 변경
node.AddToClassList("nw-node-dragging");
// 포인터 캡처
node.CapturePointer(evt.pointerId);
evt.StopPropagation();
}
// 드래그 중
void OnNodePointerMove(PointerMoveEvent evt)
{
if (!isDragging || draggingNode == null || draggingActivityCode == null) return;
// 현재 마우스 위치 (nodesLayer 기준)
Vector2 currentMousePos = nodesLayer!.WorldToLocal(evt.position);
// 이동량 계산
Vector2 delta = currentMousePos - dragStartMousePos;
// 드래그 모드에 따라 이동 제한
switch (DragMode)
{
case NodeDragMode.HorizontalOnly:
delta.y = 0; // 수평 이동만
break;
case NodeDragMode.VerticalOnly:
delta.x = 0; // 수직 이동만
break;
case NodeDragMode.Free:
// 제한 없음
break;
case NodeDragMode.None:
return; // 드래그 불가
}
// 새 위치 계산
Vector2 newPos = dragStartNodePos + delta;
// 경계 제한
newPos.x = Math.Max(nodeWidth / 2, Math.Min(newPos.x, totalDays * pixelsPerDay - nodeWidth / 2));
newPos.y = Math.Max(nodeHeight / 2, Math.Min(newPos.y, canvasHeight - nodeHeight / 2));
// 위치 업데이트
nodePositions[draggingActivityCode] = newPos;
// 노드 위치 업데이트
draggingNode.style.left = newPos.x - nodeWidth / 2;
draggingNode.style.top = newPos.y - nodeHeight / 2;
// 연결선 다시 그리기
RedrawConnections();
evt.StopPropagation();
}
// 드래그 종료
void OnNodePointerUp(PointerUpEvent evt)
{
if (!isDragging || draggingNode == null) return;
// 드래그 종료 스타일 복원
draggingNode.RemoveFromClassList("nw-node-dragging");
// 포인터 릴리즈
draggingNode.ReleasePointer(evt.pointerId);
// 상태 초기화
draggingNode = null;
draggingActivityCode = null;
isDragging = false;
evt.StopPropagation();
}
// 드래그 중인 노드의 연결선만 업데이트
void RedrawConnections()
{
if (draggingActivityCode == null) return;
UpdateConnectionsForNode(draggingActivityCode);
}
#region IDisposable
/// <summary>
/// 리소스를 해제하고 이벤트 핸들러를 정리합니다.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// 버튼 이벤트 해제
if (_expandBtn != null)
{
_expandBtn.clicked -= OnExpandBtnClicked;
}
// 스크롤 이벤트 해제
if (contentScroll != null)
{
contentScroll.horizontalScroller.valueChanged -= OnHorizontalScroll;
}
// 노드 드래그 이벤트 해제
foreach (var node in nodeElements.Values)
{
node.UnregisterCallback<PointerDownEvent>(OnNodePointerDown);
node.UnregisterCallback<PointerMoveEvent>(OnNodePointerMove);
node.UnregisterCallback<PointerUpEvent>(OnNodePointerUp);
}
// 외부 이벤트 정리
OnExpand = null;
// 동적으로 생성된 UI 요소 정리
monthsLayer?.Clear();
daysLayer?.Clear();
nodesLayer?.Clear();
linesLayer?.Clear();
// 데이터 정리
tasks?.Clear();
tasks = null;
tasksByActivityCode.Clear();
nodeElements.Clear();
nodePositions.Clear();
connectionCache.Clear();
// 드래그 상태 정리
draggingNode = null;
draggingActivityCode = null;
isDragging = false;
// UI 참조 정리
_expandBtn = null;
root = null;
headerTimeline = null;
monthsLayer = null;
daysLayer = null;
networkCanvas = null;
nodesLayer = null;
linesLayer = null;
contentScroll = null;
}
#endregion
}
}