576 lines
23 KiB
C#
576 lines
23 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.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<ISOPChart>();
|
||
/// 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
|
||
}
|
||
}
|