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

576 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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.ISOP
{
/// <summary>
/// ISOP(조선소 공정 계획) 간트 차트를 표시하는 UI Toolkit 컴포넌트입니다.
///
/// <para><b>개요:</b></para>
/// <para>
/// JSON 파일에서 작업 데이터를 로드하여 간트 차트 형태로 시각화합니다.
/// 각 작업은 계획 일정(파란색)과 실적 일정(연두색) 두 개의 막대로 표시됩니다.
/// 3단 헤더(월/주/일)와 스크롤 동기화를 지원합니다.
/// </para>
///
/// <para><b>주요 기능:</b></para>
/// <list type="bullet">
/// <item>JSON 데이터 로드 및 파싱 (ISOPChartDataTask)</item>
/// <item>3단 타임라인 헤더 (월 → 주 → 일)</item>
/// <item>계획/실적 일정 막대 그래프 렌더링</item>
/// <item>수평 스크롤 시 헤더 동기화</item>
/// <item>작업 레벨(L1~L8) 기반 계층 표시</item>
/// </list>
///
/// <para><b>사용 예시:</b></para>
/// <code>
/// var chart = root.Q&lt;ISOPChart&gt;();
/// chart.Load("path/to/gantt_data.json");
/// chart.OnExpand += () => { /* 확장 처리 */ };
/// </code>
///
/// <para><b>관련 리소스:</b></para>
/// <list type="bullet">
/// <item>Resources/SHI/Modal/ISOP/ISOPChart.uxml - 메인 레이아웃</item>
/// <item>Resources/SHI/Modal/ISOP/ISOPChartRow.uxml - 행 템플릿</item>
/// </list>
/// </summary>
[UxmlElement]
public partial class ISOPChart : VisualElement, IDisposable
{
#region IDisposable
private bool _disposed = false;
#endregion
#region (Constants)
/// <summary>메인 UXML 파일 경로 (Resources 폴더 기준)</summary>
private const string UXML_PATH = "SHI/Modal/ISOP/ISOPChart";
/// <summary>행 템플릿 UXML 파일 경로 (Resources 폴더 기준)</summary>
private const string ROW_UXML_PATH = "SHI/Modal/ISOP/ISOPChartRow";
#endregion
#region (Public Events)
/// <summary>
/// 차트 확장 버튼이 클릭될 때 발생합니다.
/// ISOPModal에서 이 이벤트를 구독하여 차트를 전체 화면으로 확장합니다.
/// </summary>
public Action? OnExpand;
#endregion
#region UI (UI Component References)
/// <summary>확장 버튼</summary>
private Button? _expandBtn;
/// <summary>행 템플릿 (L1~L8 컬럼과 타임라인 셀 포함)</summary>
private VisualTreeAsset? taskRowTemplate;
/// <summary>루트 요소 참조</summary>
private VisualElement? root;
/// <summary>제목 헤더 (L1~L8 컬럼 헤더)</summary>
private VisualElement? headerTitle;
/// <summary>타임라인 헤더 컨테이너</summary>
private VisualElement? headerTimeline;
/// <summary>월 레이어 (3단 헤더 중 최상단)</summary>
private VisualElement? monthsLayer;
/// <summary>주 레이어 (3단 헤더 중 중간)</summary>
private VisualElement? weeksLayer;
/// <summary>일 레이어 (3단 헤더 중 최하단)</summary>
private VisualElement? daysLayer;
/// <summary>작업 행들이 추가되는 컨텐츠 영역</summary>
private VisualElement? timelineContent;
/// <summary>가로 스크롤뷰 (헤더 동기화에 사용)</summary>
private ScrollView? contentScroll;
#endregion
#region (Chart Configuration)
/// <summary>계획 일정 막대 색상 (파란색)</summary>
private Color planColor = new Color(0.2f, 0.4f, 0.8f, 0.8f);
/// <summary>실적 일정 막대 색상 (연두색)</summary>
private Color actualColor = new Color(0.2f, 0.9f, 0.3f, 0.9f);
/// <summary>하루당 픽셀 너비 (일 셀 크기)</summary>
private float pixelsPerDay = 18f;
#endregion
#region (Data State)
/// <summary>로드된 작업 데이터 목록</summary>
private List<ISOPChartDataTask>? tasks;
/// <summary>데이터에서 사용되는 최대 레벨 (L1~L8 중)</summary>
private int maxL = 1;
/// <summary>프로젝트 시작일 (차트 왼쪽 끝)</summary>
private DateTime projectStartDate;
/// <summary>프로젝트 종료일 (차트 오른쪽 끝)</summary>
private DateTime projectEndDate;
/// <summary>총 일수 (차트 너비 계산에 사용)</summary>
private int totalDays;
#endregion
#region (Constructor)
/// <summary>
/// ISOPChart 컴포넌트를 초기화합니다.
/// UXML 템플릿을 로드하고 확장 버튼 이벤트를 연결합니다.
/// </summary>
public ISOPChart()
{
// UXML 로드
var visualTree = Resources.Load<VisualTreeAsset>(UXML_PATH);
if (visualTree != null)
{
visualTree.CloneTree(this);
}
else
{
Debug.LogError($"Failed to load UXML at path: {UXML_PATH}");
}
// 행 템플릿 로드
taskRowTemplate = Resources.Load<VisualTreeAsset>(ROW_UXML_PATH);
if (taskRowTemplate == null)
{
Debug.LogError($"[ISOPChart] Row UXML not found at: {ROW_UXML_PATH}");
return;
}
// 확장 버튼 이벤트 연결
_expandBtn = this.Q<Button>("expand-btn");
if (_expandBtn != null)
{
_expandBtn.clicked += () =>
{
OnExpand?.Invoke();
};
}
}
#endregion
#region (Public Methods)
/// <summary>
/// JSON 파일에서 간트 차트 데이터를 로드하고 렌더링합니다.
/// </summary>
/// <param name="jsonFileName">JSON 파일 경로 (StreamingAssets 또는 절대 경로)</param>
public void Load(string jsonFileName)
{
root = this;
LoadData(jsonFileName); // 1. JSON 파싱
CalculateProjectRange(); // 2. 날짜 범위 계산
InitializeUI(); // 3. UI 초기화
SetupThreeTierHeader(); // 4. 3단 헤더 생성
RenderTasks(); // 5. 작업 행 렌더링
}
#endregion
#region (Data Loading)
/// <summary>
/// JSON 파일을 읽어 작업 데이터를 파싱합니다.
/// 유효한 계획 시작일(STDT21)이 있는 작업만 필터링합니다.
/// </summary>
/// <param name="jsonFileName">JSON 파일 경로</param>
void LoadData(string jsonFileName)
{
var json = File.ReadAllText(jsonFileName);
// Unity JsonUtility는 배열을 직접 파싱하지 못하므로 래퍼로 감싸기
var wrapper = JsonUtility.FromJson<ISOPChartDataWrapper>("{\"items\":" + json + "}");
// 유효한 계획 시작일이 있는 작업만 필터링
tasks = wrapper.items.Where(t => !string.IsNullOrEmpty(t.STDT21) && t.STDT21 != "null").ToList();
// 각 작업의 진행률 계산
foreach (var task in tasks)
task.CalculatedProgress = CalculateProgress(task);
maxL = CalculateMaxLevel();
}
/// <summary>
/// 데이터에서 사용되는 최대 레벨을 계산합니다.
/// L8부터 L1까지 역순으로 검사하여 데이터가 있는 최대 레벨을 반환합니다.
/// </summary>
/// <returns>최대 레벨 (1~8)</returns>
int CalculateMaxLevel()
{
if (tasks.Any(t => !string.IsNullOrEmpty(t.L8))) return 8;
if (tasks.Any(t => !string.IsNullOrEmpty(t.L7))) return 7;
if (tasks.Any(t => !string.IsNullOrEmpty(t.L6))) return 6;
if (tasks.Any(t => !string.IsNullOrEmpty(t.L5))) return 5;
if (tasks.Any(t => !string.IsNullOrEmpty(t.L4))) return 4;
if (tasks.Any(t => !string.IsNullOrEmpty(t.L3))) return 3;
if (tasks.Any(t => !string.IsNullOrEmpty(t.L2))) return 2;
if (tasks.Any(t => !string.IsNullOrEmpty(t.L1))) return 1;
return 0;
}
/// <summary>
/// 모든 작업의 날짜에서 프로젝트 시작/종료일을 계산합니다.
/// 차트 양쪽에 3일의 여백을 추가합니다.
/// </summary>
void CalculateProjectRange()
{
var dates = new List<DateTime>();
foreach (var task in tasks!)
{
if (task.GetPlanStart().HasValue) dates.Add(task.GetPlanStart()!.Value);
if (task.GetPlanEnd().HasValue) dates.Add(task.GetPlanEnd()!.Value);
if (task.GetActualStart().HasValue) dates.Add(task.GetActualStart()!.Value);
if (task.GetActualEnd().HasValue) dates.Add(task.GetActualEnd()!.Value);
}
// 양쪽에 3일 여백 추가
projectStartDate = dates.Any() ? dates.Min().AddDays(-3) : new DateTime(2025, 1, 6);
projectEndDate = dates.Any() ? dates.Max().AddDays(3) : new DateTime(2025, 2, 28);
totalDays = (int)(projectEndDate - projectStartDate).TotalDays + 1;
}
#endregion
#region UI (UI Initialization)
/// <summary>
/// UI 요소들을 초기화하고 스크롤 동기화를 설정합니다.
/// </summary>
void InitializeUI()
{
// 제목 헤더 너비 설정 (레벨 수 × 40px)
headerTitle = root.Q<VisualElement>("header-title");
headerTitle.style.width = maxL * 40;
// 컨텐츠 영역 (작업 행들이 추가됨)
timelineContent = root.Q<VisualElement>("content");
// 타임라인 헤더 너비 설정 (총 일수 × 일당 픽셀)
headerTimeline = root.Q<VisualElement>("header-timeline");
headerTimeline.style.width = totalDays * pixelsPerDay;
// 3단 헤더 레이어 참조
monthsLayer = headerTimeline.Q<VisualElement>("timeline-months");
weeksLayer = headerTimeline.Q<VisualElement>("timeline-weeks");
daysLayer = headerTimeline.Q<VisualElement>("timeline-days");
// 가로 스크롤 시 헤더 동기화
contentScroll = root.Q<ScrollView>("content-scroll");
if (contentScroll != null)
{
contentScroll.horizontalScroller.valueChanged += OnHorizontalScroll;
}
}
/// <summary>
/// 가로 스크롤 시 헤더를 동기화합니다.
/// 스크롤 값에 따라 헤더의 left 위치를 조정합니다.
/// </summary>
/// <param name="scrollValue">스크롤 값 (픽셀)</param>
void OnHorizontalScroll(float scrollValue)
{
var header = root.Q<VisualElement>("header");
if (header != null)
{
header.style.left = -scrollValue;
}
}
#endregion
#region 3 (Three-Tier Header)
/// <summary>
/// 3단 타임라인 헤더(월/주/일)를 생성합니다.
/// 프로젝트 기간에 따라 월, 주, 일 셀을 동적으로 생성합니다.
/// </summary>
void SetupThreeTierHeader()
{
// 그리드 선 색상
Color lineColor = ColorUtil.FromHex("#CACACA");
// 기존 내용 초기화
monthsLayer?.Clear();
weeksLayer?.Clear();
daysLayer?.Clear();
// 각 레이어 너비 설정
foreach (var layer in new[] { monthsLayer, weeksLayer, daysLayer })
{
layer!.style.width = totalDays * pixelsPerDay;
}
// 월/주 셀 생성
DateTime cursor = projectStartDate;
while (cursor <= projectEndDate)
{
DateTime monthStart = new DateTime(cursor.Year, cursor.Month, 1);
DateTime nextMonthStart = monthStart.AddMonths(1);
DateTime segmentStart = cursor;
DateTime segmentEnd = nextMonthStart.AddDays(-1) < projectEndDate ? nextMonthStart.AddDays(-1) : projectEndDate;
int startIndex = (int)(segmentStart - projectStartDate).TotalDays;
int spanDays = (int)(segmentEnd - segmentStart).TotalDays + 1;
// 월 레이블 추가
AddMonthLabel(monthsLayer!, segmentStart.Year, segmentStart.Month, startIndex, spanDays, lineColor);
// 해당 월 내의 주 셀 생성
DateTime weekCursor = segmentStart;
while (weekCursor <= segmentEnd)
{
DateTime weekStart = weekCursor;
// 주차 계산 (1일이 속한 주를 1주차로, 일요일 기준)
DateTime monthFirstDay = new DateTime(weekStart.Year, weekStart.Month, 1);
int firstDayOfWeek = (int)monthFirstDay.DayOfWeek;
int weekOfMonth = ((weekStart.Day + firstDayOfWeek - 1) / 7) + 1;
// 주 종료일 계산 (일요일 또는 월말)
DateTime tentativeEnd = weekStart.AddDays(7 - (int)weekStart.DayOfWeek);
DateTime weekEnd = tentativeEnd > segmentEnd ? segmentEnd : tentativeEnd;
int weekStartIndex = (int)(weekStart - projectStartDate).TotalDays;
int weekSpanDays = (int)(weekEnd - weekStart).TotalDays + 1;
AddWeekLabel(weeksLayer!, weekOfMonth, weekStartIndex, weekSpanDays, lineColor);
weekCursor = weekEnd.AddDays(1);
}
cursor = segmentEnd.AddDays(1);
}
// 일 셀 생성
for (int i = 0; i < totalDays; i++)
{
DateTime date = projectStartDate.AddDays(i);
AddDayCell(daysLayer!, date.Day, i, lineColor);
}
}
/// <summary>
/// 월 레이블 셀을 생성하여 부모에 추가합니다.
/// </summary>
void AddMonthLabel(VisualElement parent, int year, int month, int startIndex, int spanDays, Color lineColor)
{
var ve = new VisualElement();
ve.style.width = spanDays * pixelsPerDay;
ve.style.height = pixelsPerDay;
ve.style.borderRightColor = lineColor;
ve.style.borderRightWidth = 1;
var lab = new Label($"{year}년 {month}월");
lab.style.unityTextAlign = TextAnchor.MiddleCenter;
lab.style.unityFontStyleAndWeight = FontStyle.Normal;
lab.style.width = Length.Percent(100);
lab.style.height = Length.Percent(100);
lab.style.fontSize = 10;
lab.style.marginBottom = 0;
lab.style.marginLeft = 0;
lab.style.marginRight = 0;
lab.style.marginTop = 0;
lab.style.paddingBottom = 0;
lab.style.paddingLeft = 0;
lab.style.paddingRight = 0;
lab.style.paddingTop = 0;
ve.Add(lab);
parent.Add(ve);
}
void AddWeekLabel(VisualElement parent, int weekOfMonth, int startIndex, int spanDays, Color lineColor)
{
var ve = new VisualElement();
ve.style.width = spanDays * pixelsPerDay;
ve.style.height = pixelsPerDay;
ve.style.borderRightColor = lineColor;
ve.style.borderRightWidth = 1;
var lab = new Label($"{weekOfMonth}주");
lab.style.unityTextAlign = TextAnchor.MiddleCenter;
lab.style.width = Length.Percent(100);
lab.style.height = Length.Percent(100);
lab.style.fontSize = 10;
lab.style.marginBottom = 0;
lab.style.marginLeft = 0;
lab.style.marginRight = 0;
lab.style.marginTop = 0;
lab.style.paddingBottom = 0;
lab.style.paddingLeft = 0;
lab.style.paddingRight = 0;
lab.style.paddingTop = 0;
ve.Add(lab);
parent.Add(ve);
}
void AddDayCell(VisualElement parent, int day, int dayIndex, Color lineColor)
{
var ve = new VisualElement();
ve.style.width = pixelsPerDay;
ve.style.height = pixelsPerDay;
ve.style.borderRightColor = lineColor;
ve.style.borderRightWidth = 1;
var lab = new Label(day.ToString("00"));
lab.style.unityTextAlign = TextAnchor.MiddleCenter;
lab.style.width = Length.Percent(100);
lab.style.height = Length.Percent(100);
lab.style.fontSize = 10;
lab.style.marginBottom = 0;
lab.style.marginLeft = 0;
lab.style.marginRight = 0;
lab.style.marginTop = 0;
lab.style.paddingBottom = 0;
lab.style.paddingLeft = 0;
lab.style.paddingRight = 0;
lab.style.paddingTop = 0;
ve.Add(lab);
parent.Add(ve);
}
#endregion
#region (Task Rendering)
/// <summary>
/// 모든 작업을 행으로 렌더링합니다.
/// 각 작업에 대해 L1~L8 레이블과 계획/실적 막대를 생성합니다.
/// </summary>
void RenderTasks()
{
if (timelineContent == null) return;
timelineContent.Clear();
for (int i = 0; i < tasks!.Count; i++)
{
var task = tasks[i];
// 행 템플릿 복제
var row = taskRowTemplate!.CloneTree();
row.style.height = 40;
row.style.flexShrink = 0;
row.style.flexGrow = 0;
row.style.flexBasis = new StyleLength(StyleKeyword.Auto);
// L1~L8 레이블 설정
VisualElement lContainer = row.Q<VisualElement>("task-l");
Label txt = row.Q<Label>("task-txt");
txt.text = task.L1;
for (int j = 2; j <= maxL; j++)
{
string text = j == 2 ? task.L2 : j == 3 ? task.L3 : j == 4 ? task.L4 : j == 5 ? task.L5 : j == 6 ? task.L6 : j == 7 ? task.L7 : task.L8;
if (string.IsNullOrEmpty(text)) text = "";
var label = new Label($"{text}");
label.name = $"c{j}";
label.AddToClassList("task-txt");
lContainer.Add(label);
}
// 타임라인 셀 설정
VisualElement taskCell = row.Q<VisualElement>("task-cell");
taskCell.style.width = totalDays * pixelsPerDay;
// 계획 막대 (파란색, 상단)
if (task.GetPlanStart().HasValue && task.GetPlanEnd().HasValue)
taskCell.Add(CreateBar(task, task.GetPlanStart()!.Value, task.GetPlanEnd()!.Value, planColor, 4));
// 실적 막대 (연두색, 하단)
if (task.GetActualStart().HasValue && task.GetActualEnd().HasValue)
taskCell.Add(CreateBar(task, task.GetActualStart()!.Value, task.GetActualEnd()!.Value, actualColor, 10));
timelineContent.Add(row);
}
}
/// <summary>
/// 간트 차트 막대를 생성합니다.
/// </summary>
/// <param name="task">작업 데이터 (툴팁용)</param>
/// <param name="start">시작일</param>
/// <param name="end">종료일</param>
/// <param name="color">막대 색상</param>
/// <param name="top">상단 오프셋 (px)</param>
/// <returns>막대 VisualElement</returns>
VisualElement CreateBar(ISOPChartDataTask task, DateTime start, DateTime end, Color color, int top)
{
var bar = new VisualElement();
bar.style.position = Position.Absolute;
bar.style.backgroundColor = color;
bar.style.height = 6;
bar.style.top = top;
// 시작 위치와 너비 계산
float startX = (float)(start - projectStartDate).TotalDays * pixelsPerDay;
float width = Mathf.Max((float)(end - start).TotalDays * pixelsPerDay, 6);
bar.style.left = startX;
bar.style.width = width;
// 툴팁 설정
bar.tooltip = $"{task.GetDisplayName()}\n{start:MM/dd} ~ {end:MM/dd}";
return bar;
}
/// <summary>
/// 작업의 진행률을 계산합니다.
/// 계획 기간 대비 실적 기간의 비율로 계산합니다.
/// </summary>
/// <param name="task">작업 데이터</param>
/// <returns>진행률 (0~100)</returns>
float CalculateProgress(ISOPChartDataTask task)
{
var planStart = task.GetPlanStart();
var planEnd = task.GetPlanEnd();
var actualEnd = task.GetActualEnd();
if (!planStart.HasValue || !planEnd.HasValue) return 0;
float total = (float)(planEnd.Value - planStart.Value).TotalDays;
if (total <= 0) return 0;
if (actualEnd.HasValue)
{
float actualDays = (float)(actualEnd.Value - planStart.Value).TotalDays;
return Mathf.Clamp(actualDays / total * 100, 0, 100);
}
return 0;
}
#endregion
#region IDisposable
/// <summary>
/// 리소스를 해제하고 이벤트 핸들러를 정리합니다.
/// </summary>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// 스크롤 이벤트 해제
if (contentScroll != null)
{
contentScroll.horizontalScroller.valueChanged -= OnHorizontalScroll;
}
// 외부 이벤트 정리
OnExpand = null;
// 동적으로 생성된 UI 요소 정리
monthsLayer?.Clear();
weeksLayer?.Clear();
daysLayer?.Clear();
timelineContent?.Clear();
// 데이터 정리
tasks?.Clear();
tasks = null;
// UI 참조 정리
_expandBtn = null;
taskRowTemplate = null;
root = null;
headerTitle = null;
headerTimeline = null;
monthsLayer = null;
weeksLayer = null;
daysLayer = null;
timelineContent = null;
contentScroll = null;
}
#endregion
}
}