358 lines
14 KiB
C#
358 lines
14 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using Unity.VisualScripting;
|
|
using UnityEngine;
|
|
using UnityEngine.TextCore.Text;
|
|
using UnityEngine.UIElements;
|
|
using UVC.Util;
|
|
|
|
namespace SHI.Modal.ISOP
|
|
{
|
|
[UxmlElement]
|
|
public partial class ISOPChart : VisualElement
|
|
{
|
|
|
|
// 리소스 경로 상수
|
|
private const string UXML_PATH = "SHI/Modal/ISOP/ISOPChart"; // Resources 폴더 기준 경로
|
|
private const string ROW_UXML_PATH = "SHI/Modal/ISOP/ISOPChartRow"; // Resources 폴더 기준 경로
|
|
|
|
/// <summary>
|
|
/// 모델 뷰가 확장 요청될 때 발생합니다.
|
|
/// </summary>
|
|
public Action? OnExpand;
|
|
|
|
private Button? _expandBtn;
|
|
|
|
private VisualTreeAsset taskRowTemplate; // row with 8 cells
|
|
private Color planColor = new Color(0.2f, 0.4f, 0.8f, 0.8f);
|
|
private Color actualColor = new Color(0.2f, 0.9f, 0.3f, 0.9f);
|
|
private float pixelsPerDay = 18f; // day cell width
|
|
|
|
private VisualElement root;
|
|
|
|
private VisualElement headerTitle;
|
|
private VisualElement headerTimeline;
|
|
private VisualElement monthsLayer;
|
|
private VisualElement weeksLayer;
|
|
private VisualElement daysLayer;
|
|
private VisualElement timelineContent;
|
|
private ScrollView? contentScroll;
|
|
private List<ISOPChartDataTask> tasks;
|
|
private int maxL;
|
|
private DateTime projectStartDate;
|
|
private DateTime projectEndDate;
|
|
private int totalDays;
|
|
|
|
|
|
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($"[TreeMenu] Item UXML not found at: {ROW_UXML_PATH}");
|
|
return;
|
|
}
|
|
|
|
_expandBtn = this.Q<Button>("expand-btn");
|
|
if (_expandBtn != null)
|
|
{
|
|
_expandBtn.clicked += () =>
|
|
{
|
|
OnExpand?.Invoke();
|
|
};
|
|
}
|
|
|
|
}
|
|
|
|
public void Load(string jsonFileName)
|
|
{
|
|
root = this;
|
|
|
|
LoadData(jsonFileName);
|
|
CalculateProjectRange();
|
|
InitializeUI();
|
|
SetupThreeTierHeader();
|
|
RenderTasks();
|
|
}
|
|
|
|
void LoadData(string jsonFileName)
|
|
{
|
|
var json = File.ReadAllText(jsonFileName);
|
|
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();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
|
|
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);
|
|
}
|
|
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;
|
|
}
|
|
|
|
void InitializeUI()
|
|
{
|
|
|
|
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;
|
|
|
|
monthsLayer = headerTimeline.Q<VisualElement>("timeline-months");
|
|
weeksLayer = headerTimeline.Q<VisualElement>("timeline-weeks");
|
|
daysLayer = headerTimeline.Q<VisualElement>("timeline-days");
|
|
|
|
// ScrollView 가로 스크롤 시 header-timeline 동기화
|
|
contentScroll = root.Q<ScrollView>("content-scroll");
|
|
if (contentScroll != null)
|
|
{
|
|
contentScroll.horizontalScroller.valueChanged += OnHorizontalScroll;
|
|
}
|
|
}
|
|
|
|
void OnHorizontalScroll(float scrollValue)
|
|
{
|
|
// header 전체를 스크롤 (header-title + header-timeline)
|
|
var header = root.Q<VisualElement>("header");
|
|
if (header != null)
|
|
{
|
|
header.style.left = -scrollValue;
|
|
}
|
|
}
|
|
|
|
void SetupThreeTierHeader()
|
|
{
|
|
// Background base grid style color
|
|
Color lineColor = ColorUtil.FromHex("#CACACA"); // light blue lines
|
|
|
|
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; // first day we actually display within month
|
|
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);
|
|
|
|
// Weeks inside month
|
|
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; // 0=일요일, 1=월요일...
|
|
int weekOfMonth = ((weekStart.Day + firstDayOfWeek - 1) / 7) + 1;
|
|
// Keep the existing segmentation logic (end at Sunday or month end)
|
|
DateTime tentativeEnd = weekStart.AddDays(7 - (int)weekStart.DayOfWeek); // until Sunday
|
|
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);
|
|
}
|
|
|
|
// Days line
|
|
for (int i = 0; i < totalDays; i++)
|
|
{
|
|
DateTime date = projectStartDate.AddDays(i);
|
|
AddDayCell(daysLayer, date.Day, i, lineColor);
|
|
}
|
|
}
|
|
|
|
|
|
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);
|
|
}
|
|
|
|
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; // TemplateContainer 높이 설정
|
|
row.style.flexShrink = 0;
|
|
row.style.flexGrow = 0;
|
|
row.style.flexBasis = new StyleLength(StyleKeyword.Auto);
|
|
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);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
|
|
}
|
|
}
|