chart 개발 중

This commit is contained in:
김형인
2025-11-17 19:30:05 +09:00
parent 33ca18aa05
commit f58b456ccb
25 changed files with 1275 additions and 229 deletions

View File

@@ -1,9 +1,13 @@
using Best.HTTP.JSON.LitJson;
using Cysharp.Threading.Tasks;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Threading;
using UnityEngine;
using UnityEngine.UI;
using UVC.Json;
namespace SHI.modal
{
@@ -192,9 +196,9 @@ namespace SHI.modal
/// glTF 모델과 간트 데이터를 하위 뷰에 로드하고, 선택 동기화를 위한 매핑을 구축합니다.
/// </summary>
/// <param name="gltfPath">glTF/glb 파일 경로.</param>
/// <param name="gantt">간트 데이터셋.</param>
/// <param name="ganttPath">간트 데이터셋 경로.</param>
/// <param name="externalCt">외부 취소 토큰.</param>
public async UniTask LoadData(string gltfPath, GanttChartData gantt, CancellationToken externalCt = default)
public async UniTask LoadData(string gltfPath, string ganttPath, CancellationToken externalCt = default)
{
Debug.Log($"BlockDetailModal: LoadData {gltfPath}");
@@ -224,7 +228,7 @@ namespace SHI.modal
BuildKeyMaps(items);
if (listView != null) listView.SetupData(items);
if (chartView != null) chartView.LoadData(gantt);
if (chartView != null) chartView.LoadFromStreamingAssets(ganttPath);
}
private void BuildKeyMaps(IEnumerable<ModelDetailListItemData> items)

View File

@@ -23,12 +23,86 @@ namespace SHI.modal
public string Type { get; set; } = string.Empty;
}
/// <summary>
/// 기간/마커 정보(Structured 행 모드용).
/// </summary>
public class DateRange
{
public DateTime? Start { get; set; }
public DateTime? End { get; set; }
public int? Duration { get; set; }
public bool IsMarker => Start.HasValue && !End.HasValue;
}
/// <summary>
/// 블록/행 단위 일정 레코드.
/// </summary>
public class BlockScheduleRow
{
public string? ProjNo { get; set; }
public string? BlockNo { get; set; }
public string? L1 { get; set; }
public string? L2 { get; set; }
public string? L3 { get; set; }
public string? L4 { get; set; }
public string? L5 { get; set; }
public string? L6 { get; set; }
public string? L7 { get; set; }
public string? L8 { get; set; }
/// <summary>코드(예:43,4B 등)별 기간/마커.</summary>
public Dictionary<string, DateRange> Periods { get; } = new Dictionary<string, DateRange>();
// 추가 메타 정보(JSON 컬럼 매핑)
public string? BlkDsc { get; set; } // BLK_DSC
public string? ShipType { get; set; } // SHIP_TYPE
public string? ShrGb { get; set; } // SHR_GB
public float? Lth { get; set; } // LTH
public float? Bth { get; set; } // BTH
public float? Hgt { get; set; } // HGT
public float? NetWgt { get; set; } // NET_WGT
public float? EqupWgt { get; set; } // EQUP_WGT
public float? LftWgt { get; set; } // LFT_WGT
public string? WkType { get; set; } // WK_TYPE
public string? Wc31 { get; set; } // WC31
// 편의 접근자: 자주 쓰는 코드 맵핑
/// <summary>STDT01: 파란색 @ 표시 일(yyyymmdd)</summary>
public DateTime? STDT01 { get => GetStart("01"); set => SetStart("01", value); }
/// <summary>STDT21: 파란색 막대 시작일(yyyymmdd)</summary>
public DateTime? STDT21 { get => GetStart("21"); set => SetStart("21", value); }
/// <summary>FNDT21: 파란색 막대 종료일(yyyymmdd)</summary>
public DateTime? FNDT21 { get => GetEnd("21"); set => SetEnd("21", value); }
/// <summary>STDT23: 연두색 막대 시작일(yyyymmdd)</summary>
public DateTime? STDT23 { get => GetStart("23"); set => SetStart("23", value); }
/// <summary>FNDT23: 연두색 막대 종료일(yyyymmdd)</summary>
public DateTime? FNDT23 { get => GetEnd("23"); set => SetEnd("23", value); }
private DateRange GetOrCreateRange(string code)
{
if (!Periods.TryGetValue(code, out var dr) || dr == null)
{
dr = new DateRange();
Periods[code] = dr;
}
return dr;
}
private DateTime? GetStart(string code) => Periods.TryGetValue(code, out var dr) ? dr.Start : null;
private DateTime? GetEnd(string code) => Periods.TryGetValue(code, out var dr) ? dr.End : null;
private void SetStart(string code, DateTime? v) { var dr = GetOrCreateRange(code); dr.Start = v; }
private void SetEnd(string code, DateTime? v) { var dr = GetOrCreateRange(code); dr.End = v; }
}
/// <summary>
/// 간단한 간트 차트 데이터셋입니다.
/// 기존 Segments 기반(Flat) + Rows 기반(Structured) 동시 지원.
/// </summary>
public class GanttChartData
{
/// <summary>표시 순서대로의 세그먼트 컬렉션.</summary>
/// <summary>표시 순서대로의 세그먼트 컬렉션 (Flat 모드).</summary>
public List<ScheduleSegment> Segments { get; set; } = new List<ScheduleSegment>();
/// <summary>Structured 행 모드 컬렉션.</summary>
public List<BlockScheduleRow> Rows { get; set; } = new List<BlockScheduleRow>();
public DateTime? MinDate { get; set; }
public DateTime? MaxDate { get; set; }
}
}

View File

@@ -0,0 +1,202 @@
// GanttJsonParser: 행 기반(STDTxx/FNDTxx/DURxx) 간트 데이터 파서
// 요구: 오류가 있어도 삭제하지 말 것.
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using UnityEngine;
namespace SHI.modal
{
/// <summary>
/// isop_chart.json과 같은 행 배열(딕셔너리)의 STDTxx / FNDTxx / DURxx 패턴을 해석하여 <see cref="GanttChartData"/>를 생성합니다.
/// </summary>
public static class GanttJsonParser
{
private static readonly Regex StartRegex = new Regex("^STDT([0-9A-FM]{2})$", RegexOptions.Compiled);
private static readonly Regex EndRegex = new Regex("^FNDT([0-9A-FM]{2})$", RegexOptions.Compiled);
private static readonly Regex DurRegex = new Regex("^DUR([0-9A-FM]{2})$", RegexOptions.Compiled);
private const string DateFormat = "yyyyMMdd";
/// <summary>
/// JSON 배열 문자열을 파싱해 Rows/Segments/MinDate/MaxDate를 채웁니다.
/// Duration 누락 시 (End-Start)+1을 사용합니다.
/// </summary>
public static GanttChartData Parse(string json, bool computeDurationIfMissing = true)
{
var data = new GanttChartData();
if (string.IsNullOrWhiteSpace(json)) return data;
List<Dictionary<string, object>>? rows;
try
{
// Newtonsoft.Json 사용 (Assembly 내 참조 필요). 실패 시 경고 후 빈 데이터 반환.
rows = Newtonsoft.Json.JsonConvert.DeserializeObject<List<Dictionary<string, object>>>(json);
}
catch (Exception ex)
{
Debug.LogWarning($"GanttJsonParser.Parse: JSON deserialize failed: {ex.Message}");
return data;
}
if (rows == null) return data;
DateTime? min = null; DateTime? max = null;
foreach (var raw in rows)
{
if (raw == null) continue;
var row = new BlockScheduleRow
{
ProjNo = GetStr(raw, "PROJ_NO"),
BlockNo = GetStr(raw, "BLK_NO"),
L1 = GetStr(raw, "L1"),
L2 = GetStr(raw, "L2"),
L3 = GetStr(raw, "L3"),
L4 = GetStr(raw, "L4"),
L5 = GetStr(raw, "L5"),
L6 = GetStr(raw, "L6"),
L7 = GetStr(raw, "L7"),
L8 = GetStr(raw, "L8"),
// 추가 메타
BlkDsc = GetStr(raw, "BLK_DSC"),
ShipType = GetStr(raw, "SHIP_TYPE"),
ShrGb = GetStr(raw, "SHR_GB"),
Lth = GetFloat(raw, "LTH"),
Bth = GetFloat(raw, "BTH"),
Hgt = GetFloat(raw, "HGT"),
NetWgt = GetFloat(raw, "NET_WGT"),
EqupWgt = GetFloat(raw, "EQUP_WGT"),
LftWgt = GetFloat(raw, "LFT_WGT"),
WkType = GetStr(raw, "WK_TYPE"),
Wc31 = GetStr(raw, "WC31"),
};
var startMap = new Dictionary<string, DateTime>();
var endMap = new Dictionary<string, DateTime>();
var durMap = new Dictionary<string, int>();
foreach (var kv in raw)
{
if (kv.Key == null || kv.Value == null) continue;
var val = kv.Value.ToString();
if (string.IsNullOrWhiteSpace(val)) continue;
var sm = StartRegex.Match(kv.Key);
if (sm.Success)
{
if (TryParseDate(val, out var sdt))
{
var code = sm.Groups[1].Value;
if (startMap.ContainsKey(code)) Debug.Log($"GanttJsonParser: duplicate STDT for code={code}, last value wins.");
startMap[code] = sdt; UpdateMinMax(ref min, ref max, sdt); continue;
}
else { Debug.LogWarning($"GanttJsonParser: start date parse failed key={kv.Key} value={val}"); continue; }
}
var em = EndRegex.Match(kv.Key);
if (em.Success)
{
if (TryParseDate(val, out var edt))
{
var code = em.Groups[1].Value;
if (endMap.ContainsKey(code)) Debug.Log($"GanttJsonParser: duplicate FNDT for code={code}, last value wins.");
endMap[code] = edt; UpdateMinMax(ref min, ref max, edt); continue;
}
else { Debug.LogWarning($"GanttJsonParser: end date parse failed key={kv.Key} value={val}"); continue; }
}
var dm = DurRegex.Match(kv.Key);
if (dm.Success)
{
var code = dm.Groups[1].Value;
if (int.TryParse(val, out var dur))
{
if (durMap.ContainsKey(code)) Debug.Log($"GanttJsonParser: duplicate DUR for code={code}, last value wins.");
durMap[code] = dur; continue;
}
else { Debug.LogWarning($"GanttJsonParser: duration parse failed key={kv.Key} value={val}"); continue; }
}
}
var codes = new HashSet<string>(startMap.Keys);
codes.UnionWith(endMap.Keys);
codes.UnionWith(durMap.Keys);
foreach (var code in codes)
{
var range = new DateRange();
if (startMap.TryGetValue(code, out var s)) range.Start = s;
if (endMap.TryGetValue(code, out var e)) range.End = e;
if (durMap.TryGetValue(code, out var d)) range.Duration = d;
else if (computeDurationIfMissing && range.Start.HasValue && range.End.HasValue)
range.Duration = (int)(range.End.Value.Date - range.Start.Value.Date).TotalDays + 1;
// Start > End 정책: Skip + Warning
if (range.Start.HasValue && range.End.HasValue && range.End.Value.Date < range.Start.Value.Date)
{
Debug.LogWarning($"GanttJsonParser: Start > End. Skip record block={row.BlockNo} code={code} start={range.Start:yyyyMMdd} end={range.End:yyyyMMdd}");
continue;
}
row.Periods[code] = range;
if (range.Start.HasValue && range.End.HasValue)
{
// Flat segment 동시 생성 (유효한 경우만)
data.Segments.Add(new ScheduleSegment
{
ItemKey = (row.BlockNo ?? string.Empty) + "|" + code,
ItemId = Guid.Empty,
Start = range.Start.Value,
End = range.End.Value,
Progress = 0f,
Type = code
});
}
}
data.Rows.Add(row);
}
data.MinDate = min; data.MaxDate = max;
return data;
}
private static void UpdateMinMax(ref DateTime? min, ref DateTime? max, DateTime v)
{
if (!min.HasValue || v < min.Value) min = v;
if (!max.HasValue || v > max.Value) max = v;
}
private static bool TryParseDate(string s, out DateTime dt)
=> DateTime.TryParseExact(s, DateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out dt);
private static string? GetStr(Dictionary<string, object> dict, string key)
=> dict.TryGetValue(key, out var v) ? v?.ToString() : null;
private static float? GetFloat(Dictionary<string, object> dict, string key)
{
if (!dict.TryGetValue(key, out var v) || v == null) return null;
try
{
switch (v)
{
case float f: return f;
case double d: return (float)d;
case decimal m: return (float)m;
case int i: return i;
case long l: return l;
case short s: return s;
case string str:
if (string.IsNullOrWhiteSpace(str)) return null;
if (float.TryParse(str, NumberStyles.Any, CultureInfo.InvariantCulture, out var pf)) return pf;
if (float.TryParse(str, NumberStyles.Any, CultureInfo.CurrentCulture, out pf)) return pf;
return null;
default:
// 마지막 시도: System.Convert
return (float)Convert.ToDouble(v, CultureInfo.InvariantCulture);
}
}
catch { return null; }
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0472849d1587ac84491dbf3f1b4c68ee

View File

@@ -1,61 +1,513 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using UnityEngine;
using UnityEngine.UIElements;
namespace SHI.modal
{
/// <summary>
/// 차트 패널: 간트 데이터 바인딩/선택 동기화용 경량 래퍼.
/// 실제 UI Toolkit 간트 구현 전까지 스텁 동작을 수행합니다.
/// 간트 데이터 로드 및(임시) UI Toolkit 렌더 스텁.
/// </summary>
public class ModelDetailChartView : MonoBehaviour
{
public Action<string>? OnRowClickedByKey;
public Action<Guid>? OnRowClicked; // backward compat
public Action<Guid>? OnRowClicked;
private GanttChartData? _data;
[SerializeField] private UIDocument? uiDocument; // UI Toolkit 루트 문서
[SerializeField] private string chartRootName = "gantt-root"; // 동적 삽입 컨테이너 이름
[SerializeField] private float dayWidth = 16f;
[SerializeField] private int rowHeight = 24;
[SerializeField] private StyleSheet? defaultStyleSheet; // USS 연결용 (Inspector 가능)
[SerializeField] private bool showTodayLine = false;
[SerializeField] private bool logAlignmentDebug = false; // 위치 동기화 디버그 로그
// 선택 상태 관리
private readonly Dictionary<string, VisualElement> _rowElements = new Dictionary<string, VisualElement>();
private readonly Dictionary<string, VisualElement> _segmentElements = new Dictionary<string, VisualElement>();
private string? _selectedKey;
private string? _selectedSegmentKey;
// 필터 상태
private DateTime? _filterFrom;
private DateTime? _filterTo;
private string? _filterCode;
private string? _filterBlockNo;
// 헤더 스크롤 동기화 참조
private VisualElement? _monthRowRef;
private VisualElement? _weekRowRef;
private VisualElement? _dayRowRef;
private ScrollView? _bodyScrollRef;
public void LoadFromStreamingAssets(string fileName = "isop_chart.json")
{
try
{
// Ensure a UIDocument exists so we can render
EnsureUIDocument();
var path = Path.Combine(Application.streamingAssetsPath, fileName);
if (!File.Exists(path))
{
Debug.LogError($"File not found: {path}");
// 더미 데이터 로드 정책
_data = CreateDummyData();
BuildStubUI();
return;
}
//5MB 초과 경고
try
{ var fi = new FileInfo(path); if (fi.Exists && fi.Length > 5 * 1024 * 1024) Debug.LogWarning($"Chart JSON is large ({fi.Length / (1024 * 1024f):F1} MB). Consider streaming parser."); }
catch { }
var json = File.ReadAllText(path);
// 행 기반(STDT/FNDT/DUR) 시도
var structured = TryParseStructured(json);
if (structured != null)
{
LoadData(structured);
BuildStubUI();
return;
}
Debug.LogWarning("Unsupported chart JSON format.");
}
catch (Exception ex) { Debug.LogError($"LoadFromStreamingAssets failed: {ex.Message}"); }
}
/// <summary>
/// 간트 데이터를 바인딩합니다(스텁 구현).
/// </summary>
public void LoadData(GanttChartData data)
{
_data = data;
Debug.Log($"ModelDetailChartView.LoadData: segments={data?.Segments?.Count ??0}");
Debug.Log($"ChartView.LoadData: rows={data.Rows.Count} seg={data.Segments.Count} range={data.MinDate}->{data.MaxDate}");
}
/// <summary>
/// 외부 키로 행을 하이라이트합니다.
/// </summary>
public void SelectByItemKey(string key)
{
if (_data == null) { Debug.Log("ChartView.SelectByItemKey: no data"); return; }
Debug.Log($"Chart highlight by key: {key}");
if (_data == null) { Debug.Log("SelectByItemKey: no data"); return; }
_selectedKey = key ?? string.Empty;
ApplySelectionVisuals();
}
/// <summary>
/// Guid 식별자로 행을 하이라이트합니다.
/// </summary>
public void SelectByItemId(Guid id)
{ if (_data == null) { Debug.Log("SelectByItemId: no data"); return; } Debug.Log($"Highlight row id={id}"); }
public void SimulateRowClickKey(string key) => OnRowClickedByKey?.Invoke(key);
public void SimulateRowClick(string id) { if (Guid.TryParse(id, out var g)) OnRowClicked?.Invoke(g); }
public void Dispose() { _data = null; ClearUI(); _rowElements.Clear(); _segmentElements.Clear(); _selectedKey = null; _selectedSegmentKey = null; }
public void ToggleTodayLine(bool enabled) { showTodayLine = enabled; BuildStubUI(); }
public void SetZoom(float newDayWidth) { var clamped = Mathf.Clamp(newDayWidth, 8f, 32f); if (Mathf.Approximately(clamped, dayWidth)) return; dayWidth = clamped; BuildStubUI(); }
public void ApplyFilter(DateTime? from, DateTime? to, string? code = null, string? blockNo = null)
{ _filterFrom = from; _filterTo = to; _filterCode = string.IsNullOrEmpty(code) ? null : code; _filterBlockNo = string.IsNullOrEmpty(blockNo) ? null : blockNo; BuildStubUI(); }
private void ClearUI()
{
if (_data == null) { Debug.Log("ChartView.SelectByItemId: no data"); return; }
Debug.Log($"Chart highlight by id: {id}");
if (!EnsureUIDocument()) return;
var root = uiDocument!.rootVisualElement?.Q(chartRootName);
root?.Clear();
_rowElements.Clear();
_segmentElements.Clear();
_monthRowRef = _weekRowRef = _dayRowRef = null;
_bodyScrollRef = null;
}
// UI 시뮬레이션 콜백
public void SimulateRowClickKey(string key)
private GanttChartData? TryParseStructured(string json)
{
OnRowClickedByKey?.Invoke(key);
try
{
var data = GanttJsonParser.Parse(json, computeDurationIfMissing: true);
// 구조적 파싱 성공 조건: 최소 한 행 또는 최소 한 기간
if (data != null && (data.Rows.Count > 0 || data.Segments.Count > 0))
return data;
}
catch (Exception ex)
{
Debug.LogWarning($"Structured parse failed: {ex.Message}");
}
return null;
}
public void SimulateRowClick(string id)
// 간단 렌더(헤더+행 막대) 스텁
private void BuildStubUI()
{
if (Guid.TryParse(id, out var guid)) OnRowClicked?.Invoke(guid);
if (!EnsureUIDocument()) { Debug.Log("BuildStubUI: UIDocument missing and could not be created"); return; }
if (_data == null) { Debug.Log("BuildStubUI: no data"); return; }
var root = uiDocument!.rootVisualElement;
root.style.backgroundColor = StyleKeyword.Null; // 전체 패널은 투명 유지
var container = root.Q(chartRootName) ?? new VisualElement { name = chartRootName };
if (container.parent == null) root.Add(container);
container.Clear();
container.style.flexDirection = FlexDirection.Column;
container.style.position = Position.Absolute;
container.style.backgroundColor = Color.white; // 뷰 영역만 흰색
container.style.overflow = Overflow.Hidden; // 뷰 밖으로 나가는 내용 클리핑
EnsureStyleSheet(container);
// Sync size and position to RectTransform
SyncContainerSize();
SyncContainerPosition();
var min = _data.MinDate ?? DateTime.Today;
var max = _data.MaxDate ?? min;
var totalDays = Math.Max(1, (int)(max.Date - min.Date).TotalDays + 1);
var timelineWidth = totalDays * dayWidth;
// Header container (Month/Week/Day)
var header = new VisualElement { name = "gantt-header" };
header.AddToClassList("header-container");
header.style.flexDirection = FlexDirection.Column;
header.style.backgroundColor = new Color(0.13f, 0.13f, 0.15f, 1f);
header.style.height = 60;
header.style.position = Position.Relative;
header.style.width = timelineWidth;
BuildHeaderRows(header, min, max, totalDays, showTodayLine);
container.Add(header);
_rowElements.Clear(); _segmentElements.Clear();
// Body scroll
var bodyScroll = new ScrollView(ScrollViewMode.VerticalAndHorizontal) { name = "body-scroll" };
bodyScroll.contentContainer.style.flexDirection = FlexDirection.Column;
container.Add(bodyScroll);
_bodyScrollRef = bodyScroll;
var rowsContainer = new VisualElement { name = "rows-container" };
rowsContainer.AddToClassList("rows-container");
rowsContainer.style.flexDirection = FlexDirection.Column;
rowsContainer.style.width = timelineWidth; // 수평 스크롤 기준 폭
bodyScroll.Add(rowsContainer);
// Rows만 렌더 (Segments Fallback 제거)
foreach (var row in _data.Rows)
{
if (_filterBlockNo != null && !string.Equals(row.BlockNo ?? string.Empty, _filterBlockNo, StringComparison.OrdinalIgnoreCase)) { bool hasAlt = false; if (!hasAlt) continue; }
BuildRow(rowsContainer, row.BlockNo ?? row.L1 ?? "", min, timelineWidth, row);
}
HookHeaderScrollSync();
// 선택 상태 반영
ApplySelectionVisuals();
ApplySegmentSelectionVisuals();
}
/// <summary>
/// 바인딩된 데이터를 해제합니다.
/// </summary>
public void Dispose() { _data = null; }
private void EnsureStyleSheet(VisualElement container)
{
try
{
if (!EnsureUIDocument()) return;
var root = uiDocument!.rootVisualElement;
if (defaultStyleSheet != null)
{
if (!root.styleSheets.Contains(defaultStyleSheet)) root.styleSheets.Add(defaultStyleSheet);
if (!container.styleSheets.Contains(defaultStyleSheet)) container.styleSheets.Add(defaultStyleSheet);
return;
}
// Resources/Styles/GanttChart.uss 를 시도
var ss = Resources.Load<StyleSheet>("SHI/Styles/GanttChart");
if (ss != null)
{
if (!root.styleSheets.Contains(ss)) root.styleSheets.Add(ss);
if (!container.styleSheets.Contains(ss)) container.styleSheets.Add(ss);
}
}
catch (Exception ex) { Debug.LogWarning($"EnsureStyleSheet failed: {ex.Message}"); }
}
private void BuildHeaderRows(VisualElement header, DateTime min, DateTime max, int totalDays, bool addToday)
{
var timelineWidth = totalDays * dayWidth;
// Month row
var monthRow = new VisualElement { name = "month-row" };
monthRow.AddToClassList("month-row");
monthRow.style.flexDirection = FlexDirection.Row;
monthRow.style.height = 20;
header.Add(monthRow);
// iterate months
var cur = new DateTime(min.Year, min.Month, 1);
var end = new DateTime(max.Year, max.Month, 1).AddMonths(1).AddDays(-1);
if (end < max) end = max;
while (cur <= max)
{
var monthStart = cur < min ? min : cur;
var lastDayOfMonth = new DateTime(cur.Year, cur.Month, DateTime.DaysInMonth(cur.Year, cur.Month));
var monthEnd = lastDayOfMonth > max ? max : lastDayOfMonth;
var days = Math.Max(1, (int)(monthEnd.Date - monthStart.Date).TotalDays + 1);
var cell = new Label(cur.ToString("yyyy MMM", CultureInfo.InvariantCulture));
cell.AddToClassList("span-cell");
cell.style.width = days * dayWidth;
cell.style.unityTextAlign = TextAnchor.MiddleCenter;
cell.style.color = new Color(0.9f, 0.9f, 0.9f, 1f); // 밝은 헤더 텍스트
monthRow.Add(cell);
cur = cur.AddMonths(1);
}
// Week row (ISO Week, Mon-Sun)
var weekRow = new VisualElement { name = "week-row" };
weekRow.AddToClassList("week-row");
weekRow.style.flexDirection = FlexDirection.Row;
weekRow.style.height = 20;
header.Add(weekRow);
var culture = CultureInfo.InvariantCulture;
var cal = culture.Calendar;
var weekRule = CalendarWeekRule.FirstFourDayWeek;
var firstDay = DayOfWeek.Monday;
var d = min.Date;
while (d <= max.Date)
{
var isoWeek = cal.GetWeekOfYear(d, weekRule, firstDay);
// span until week end or max
var weekEnd = d.AddDays((7 + (int)DayOfWeek.Sunday - (int)d.DayOfWeek) % 7); // upcoming Sunday (inclusive)
if (weekEnd > max.Date) weekEnd = max.Date;
var days = Math.Max(1, (int)(weekEnd - d).TotalDays + 1);
var cell = new Label($"W{isoWeek}");
cell.AddToClassList("span-cell");
cell.style.width = days * dayWidth;
cell.style.unityTextAlign = TextAnchor.MiddleCenter;
cell.style.color = new Color(0.85f, 0.85f, 0.85f, 1f);
weekRow.Add(cell);
d = weekEnd.AddDays(1);
}
// Day row
var dayRow = new VisualElement { name = "day-row" };
dayRow.AddToClassList("day-row");
dayRow.style.flexDirection = FlexDirection.Row;
dayRow.style.height = 20;
dayRow.style.width = timelineWidth;
var dc = min.Date;
while (dc <= max.Date)
{
var cell = new Label(dc.Day.ToString()) { name = $"day-{dc:yyyyMMdd}" };
cell.AddToClassList("day-cell");
cell.style.width = dayWidth;
cell.style.unityTextAlign = TextAnchor.MiddleCenter;
cell.style.color = new Color(0.8f, 0.8f, 0.8f, 1f);
dayRow.Add(cell);
dc = dc.AddDays(1);
}
header.Add(dayRow);
// Today line on header
if (addToday)
{
var today = DateTime.Today.Date;
if (today >= min.Date && today <= max.Date)
{
var leftDays = (int)(today - min.Date).TotalDays;
var line = new VisualElement { name = "today-line-header" };
line.AddToClassList("today-line");
line.style.position = Position.Absolute;
line.style.left = leftDays * dayWidth;
line.style.width = 1;
line.style.top = 0;
line.style.bottom = 0;
line.style.backgroundColor = new Color(1f, 0.42f, 0f, 1f);
header.Add(line);
}
}
_monthRowRef = monthRow; _weekRowRef = weekRow; _dayRowRef = dayRow;
}
private void HookHeaderScrollSync()
{
if (_bodyScrollRef == null) return;
void Sync()
{ try { var off = _bodyScrollRef.scrollOffset; float x = off.x; if (_monthRowRef != null) _monthRowRef.style.marginLeft = -x; if (_weekRowRef != null) _weekRowRef.style.marginLeft = -x; if (_dayRowRef != null) _dayRowRef.style.marginLeft = -x; } catch { } }
// 초기1회
Sync();
try { if (_bodyScrollRef.horizontalScroller != null) _bodyScrollRef.horizontalScroller.valueChanged += _ => Sync(); } catch { }
// fallback: schedule polling (lightweight)
_bodyScrollRef.schedule.Execute(() => Sync()).Every(100);
}
private void BuildRow(VisualElement container, string key, DateTime min, float timelineWidth, BlockScheduleRow row)
{
var rowVE = new VisualElement { name = "row-" + key };
rowVE.AddToClassList("chart-row"); rowVE.style.flexDirection = FlexDirection.Row; rowVE.style.height = rowHeight; rowVE.style.borderBottomWidth = 1; rowVE.style.borderBottomColor = new Color(0, 0, 0, 0.4f);
var label = new Label(string.IsNullOrEmpty(row.BlockNo) ? (row.L1 ?? key) : row.BlockNo) { name = "row-label" };
label.AddToClassList("hierarchy-cell"); label.style.width = 240; rowVE.Add(label);
// Segment layer
var segLayer = new VisualElement { name = "segments" };
segLayer.AddToClassList("segments-layer"); segLayer.style.flexGrow = 1; segLayer.style.position = Position.Relative; segLayer.style.width = timelineWidth; rowVE.Add(segLayer);
bool anyRendered = false;
foreach (var kv in row.Periods)
{
var code = kv.Key; var dr = kv.Value;
if (_filterCode != null && !string.Equals(code, _filterCode, StringComparison.OrdinalIgnoreCase)) continue;
if (_filterFrom.HasValue || _filterTo.HasValue)
{ var start = dr.Start; var end = dr.End ?? dr.Start; if (start.HasValue) { var s = start.Value.Date; var e = end.HasValue ? end.Value.Date : s; if (!IsOverlapping(_filterFrom, _filterTo, s, e)) continue; } }
if (!dr.Start.HasValue) continue;
if (dr.End.HasValue && dr.End.Value.Date < dr.Start.Value.Date) { Debug.LogWarning($"Start > End detected. Skip: key={key} code={code} start={dr.Start:yyyyMMdd} end={dr.End:yyyyMMdd}"); continue; }
var segKey = (row.BlockNo ?? key) + "|" + code;
if (dr.Start.HasValue && dr.End.HasValue)
{
var leftDays = (int)(dr.Start.Value.Date - min.Date).TotalDays; var durDays = Math.Max(1, (int)(dr.End.Value.Date - dr.Start.Value.Date).TotalDays + 1);
var seg = new VisualElement { name = $"seg-{code}" };
seg.AddToClassList("segment"); seg.AddToClassList($"seg-code-{code}");
seg.style.position = Position.Absolute; seg.style.left = leftDays * dayWidth; seg.style.width = durDays * dayWidth; seg.style.height = rowHeight - 4; seg.style.backgroundColor = new Color(0.25f, 0.55f, 0.85f, 0.6f);
seg.style.borderTopLeftRadius = 3; seg.style.borderBottomLeftRadius = 3; seg.style.borderTopRightRadius = 3; seg.style.borderBottomRightRadius = 3;
seg.tooltip = $"{row.BlockNo ?? key}|{code}: {dr.Start:yyyy-MM-dd} ~ {dr.End:yyyy-MM-dd}";
seg.RegisterCallback<ClickEvent>(_ => { _selectedKey = row.BlockNo ?? key; _selectedSegmentKey = segKey; ApplySelectionVisuals(); ApplySegmentSelectionVisuals(); OnRowClickedByKey?.Invoke(row.BlockNo ?? key); });
segLayer.Add(seg); if (!_segmentElements.ContainsKey(segKey)) _segmentElements[segKey] = seg; anyRendered = true;
}
else
{
var leftDays = (int)(dr.Start.Value.Date - min.Date).TotalDays;
var marker = new Label("@") { name = $"mark-{code}" };
marker.AddToClassList("marker"); marker.AddToClassList($"marker-code-{code}");
marker.style.position = Position.Absolute; marker.style.left = leftDays * dayWidth; marker.style.width = dayWidth; marker.style.height = rowHeight - 4; marker.style.unityTextAlign = TextAnchor.MiddleCenter; marker.style.color = new Color(0.2f, 0.8f, 1f, 1f);
marker.tooltip = $"{row.BlockNo ?? key}|{code}: {dr.Start:yyyy-MM-dd}";
marker.RegisterCallback<ClickEvent>(_ => { _selectedKey = row.BlockNo ?? key; _selectedSegmentKey = segKey; ApplySelectionVisuals(); ApplySegmentSelectionVisuals(); OnRowClickedByKey?.Invoke(row.BlockNo ?? key); });
segLayer.Add(marker); if (!_segmentElements.ContainsKey(segKey)) _segmentElements[segKey] = marker; anyRendered = true;
}
}
if (!anyRendered) return; // 필터에 의해 비어지면 행 숨김
container.Add(rowVE);
if (!string.IsNullOrEmpty(key) && !_rowElements.ContainsKey(key)) _rowElements[key] = rowVE;
}
private static bool IsOverlapping(DateTime? from, DateTime? to, DateTime s, DateTime e)
{ if (!from.HasValue && !to.HasValue) return true; if (from.HasValue && e < from.Value.Date) return false; if (to.HasValue && s > to.Value.Date) return false; return true; }
private void ApplySelectionVisuals()
{
// 모든 하이라이트 제거
foreach (var kv in _rowElements)
{
kv.Value.RemoveFromClassList("row-selected");
// 백업 인라인 스타일 제거/초기화
kv.Value.style.backgroundColor = StyleKeyword.Null;
}
if (string.IsNullOrEmpty(_selectedKey)) return;
if (_rowElements.TryGetValue(_selectedKey, out var ve))
{
ve.AddToClassList("row-selected");
// USS 없을 때 가시성 확보용 백업 스타일
ve.style.backgroundColor = new Color(1f, 1f, 0f, 0.15f);
}
else
{
Debug.Log($"ApplySelectionVisuals: row not found for key={_selectedKey}");
}
}
private void ApplySegmentSelectionVisuals()
{
foreach (var kv in _segmentElements) kv.Value.RemoveFromClassList("segment-selected");
if (string.IsNullOrEmpty(_selectedSegmentKey)) return;
if (_segmentElements.TryGetValue(_selectedSegmentKey, out var ve)) ve.AddToClassList("segment-selected");
}
private static GanttChartData CreateDummyData()
{
var min = DateTime.Today.AddDays(-3); var max = DateTime.Today.AddDays(10);
var row1 = new BlockScheduleRow { BlockNo = "DUMMY-1" }; row1.Periods["21"] = new DateRange { Start = min.AddDays(2), End = min.AddDays(6) }; row1.Periods["23"] = new DateRange { Start = min.AddDays(8) };
var row2 = new BlockScheduleRow { BlockNo = "DUMMY-2" }; row2.Periods["21"] = new DateRange { Start = min.AddDays(1), End = min.AddDays(3) }; row2.Periods["23"] = new DateRange { Start = min.AddDays(5), End = max };
return new GanttChartData { Rows = new List<BlockScheduleRow> { row1, row2 }, MinDate = min, MaxDate = max };
}
// Try to ensure a UIDocument exists so the view can render even if not wired in inspector
private bool EnsureUIDocument()
{
if (uiDocument != null) return true;
// Try get existing on this GameObject
var ud = GetComponent<UIDocument>();
if (ud == null)
{
// Create a new UIDocument component
try { ud = gameObject.AddComponent<UIDocument>(); }
catch (Exception ex) { Debug.LogWarning($"Failed to add UIDocument: {ex.Message}"); return false; }
}
// Try find or create PanelSettings
try
{
if (ud.panelSettings == null)
{
// Try Resources first
var ps = Resources.Load<PanelSettings>("SHI/PanelSettings/DefaultPanelSettings");
if (ps == null)
{
// Create runtime instance as fallback (not saved as asset)
ps = ScriptableObject.CreateInstance<PanelSettings>();
}
ud.panelSettings = ps;
}
}
catch { }
uiDocument = ud;
return uiDocument != null;
}
// Keep UI Toolkit container sized to this RectTransform
private void SyncContainerSize()
{
if (!EnsureUIDocument()) return;
var rt = GetComponent<RectTransform>();
if (rt == null) return;
var size = rt.rect.size;
var root = uiDocument!.rootVisualElement;
var container = root.Q(chartRootName) ?? new VisualElement { name = chartRootName };
if (container.parent == null) root.Add(container);
container.style.position = Position.Absolute;
container.style.width = size.x;
container.style.height = size.y;
// Adjust body scroll height if available (header fixed height60)
var body = container.Q("body-scroll");
if (body != null)
{
var h = Mathf.Max(0, size.y - 60f);
body.style.height = h;
}
}
private void SyncContainerPosition()
{
if (!EnsureUIDocument()) return;
var rt = GetComponent<RectTransform>(); if (rt == null) return;
var canvas = GetComponentInParent<Canvas>();
Camera cam = null;
if (canvas != null)
{
if (canvas.renderMode == RenderMode.ScreenSpaceCamera || canvas.renderMode == RenderMode.WorldSpace)
cam = canvas.worldCamera != null ? canvas.worldCamera : Camera.main;
}
var corners = new Vector3[4];
rt.GetWorldCorners(corners); //0:BL,1:TL,2:TR,3:BR
var tlWorld = corners[1];
var tlScreen = RectTransformUtility.WorldToScreenPoint(cam, tlWorld);
var left = tlScreen.x;
var top = Screen.height - tlScreen.y; // convert to top-left origin
var root = uiDocument!.rootVisualElement;
var container = root.Q(chartRootName) ?? new VisualElement { name = chartRootName };
if (container.parent == null) root.Add(container);
container.style.position = Position.Absolute;
container.style.left = left;
container.style.top = top;
if (logAlignmentDebug)
Debug.Log($"Align container to RectTransform TL screen=({left:F1},{top:F1}) size=({rt.rect.width:F1},{rt.rect.height:F1})");
}
// React to RectTransform size changes
private void OnRectTransformDimensionsChange()
{
SyncContainerSize();
SyncContainerPosition();
}
}
}