1059 lines
39 KiB
C#
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<NWChart>();
|
|
/// 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
|
|
}
|
|
}
|