diff --git a/Assets/Resources/SHI/Prefabs/BlockDetailModal.prefab b/Assets/Resources/SHI/Prefabs/BlockDetailModal.prefab
index 850997e2..dd8121dd 100644
--- a/Assets/Resources/SHI/Prefabs/BlockDetailModal.prefab
+++ b/Assets/Resources/SHI/Prefabs/BlockDetailModal.prefab
@@ -311,9 +311,9 @@ RectTransform:
m_Father: {fileID: 7604279544518364067}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 1, y: 0}
- m_AnchorMax: {x: 1, y: 0}
+ m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
- m_SizeDelta: {x: 6, y: 0}
+ m_SizeDelta: {x: 6, y: -3}
m_Pivot: {x: 1, y: 1}
--- !u!222 &725070725676732581
CanvasRenderer:
@@ -397,7 +397,7 @@ MonoBehaviour:
m_HandleRect: {fileID: 2751071246484970067}
m_Direction: 2
m_Value: 0
- m_Size: 1
+ m_Size: 0.9322155
m_NumberOfSteps: 0
m_OnValueChanged:
m_PersistentCalls:
@@ -511,7 +511,7 @@ RectTransform:
m_Father: {fileID: 5368123886321672438}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
- m_AnchorMax: {x: 0, y: 0}
+ m_AnchorMax: {x: 0.203125, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0, y: 1}
@@ -725,6 +725,61 @@ RectTransform:
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0, y: 1}
+--- !u!1 &2990183197483319577
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 4804535188045471244}
+ - component: {fileID: 1865200477437870815}
+ m_Layer: 5
+ m_Name: UIDocument
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!224 &4804535188045471244
+RectTransform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 2990183197483319577}
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 9025896673595153456}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+ m_AnchorMin: {x: 0, y: 0}
+ m_AnchorMax: {x: 1, y: 1}
+ m_AnchoredPosition: {x: 0, y: 0}
+ m_SizeDelta: {x: 0, y: 0}
+ m_Pivot: {x: 0, y: 1}
+--- !u!114 &1865200477437870815
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 2990183197483319577}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 19102, guid: 0000000000000000e000000000000000, type: 0}
+ m_Name:
+ m_EditorClassIdentifier:
+ m_PanelSettings: {fileID: 11400000, guid: d0483162e9c37f142808df93bbff57df, type: 2}
+ m_ParentUI: {fileID: 0}
+ sourceAsset: {fileID: 0}
+ m_SortingOrder: 0
+ m_WorldSpaceSizeMode: 1
+ m_WorldSpaceWidth: 1920
+ m_WorldSpaceHeight: 1080
--- !u!1 &3961259076805554449
GameObject:
m_ObjectHideFlags: 0
@@ -760,9 +815,9 @@ RectTransform:
m_Father: {fileID: 7604279544518364067}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
- m_AnchorMax: {x: 0, y: 0}
+ m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
- m_SizeDelta: {x: 0, y: 0}
+ m_SizeDelta: {x: -3, y: -3}
m_Pivot: {x: 0, y: 1}
--- !u!222 &1518543331700824407
CanvasRenderer:
@@ -1051,6 +1106,8 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 4cdfc0facccb5164e87bd49a9a9be1e3, type: 3}
m_Name:
m_EditorClassIdentifier:
+ minLeftWeight: 0.1
+ maxLeftWeight: 0.9
--- !u!222 &97109830855792030
CanvasRenderer:
m_ObjectHideFlags: 0
@@ -1167,7 +1224,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
- m_AnchoredPosition: {x: 0, y: 0.00021767142}
+ m_AnchoredPosition: {x: 0, y: 69.000015}
m_SizeDelta: {x: 1920, y: 1020}
m_Pivot: {x: 0, y: 1}
--- !u!222 &7045571067825403265
@@ -1243,9 +1300,9 @@ RectTransform:
m_Father: {fileID: 7604279544518364067}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
- m_AnchorMax: {x: 0, y: 0}
+ m_AnchorMax: {x: 1, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
- m_SizeDelta: {x: 0, y: 6}
+ m_SizeDelta: {x: -3, y: 6}
m_Pivot: {x: 0, y: 0}
--- !u!222 &5169180487801724270
CanvasRenderer:
@@ -1329,7 +1386,7 @@ MonoBehaviour:
m_HandleRect: {fileID: 2403078154567262100}
m_Direction: 0
m_Value: 0
- m_Size: 0.19557291
+ m_Size: 0.203125
m_NumberOfSteps: 0
m_OnValueChanged:
m_PersistentCalls:
@@ -1638,6 +1695,7 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 7604279544518364067}
+ - {fileID: 4804535188045471244}
- {fileID: 4167651103787393564}
m_Father: {fileID: 906658657234234412}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
@@ -1658,6 +1716,13 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 1af5017c83fc5cf4d8fbd1d2a801a095, type: 3}
m_Name:
m_EditorClassIdentifier:
+ uiDocument: {fileID: 1865200477437870815}
+ chartRootName: gantt-root
+ dayWidth: 16
+ rowHeight: 24
+ defaultStyleSheet: {fileID: 0}
+ showTodayLine: 0
+ logAlignmentDebug: 0
--- !u!114 &5206902728958016667
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -1696,7 +1761,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
- m_IsActive: 1
+ m_IsActive: 0
--- !u!224 &7604279544518364067
RectTransform:
m_ObjectHideFlags: 0
@@ -1881,7 +1946,7 @@ RectTransform:
m_Father: {fileID: 8165087109326168183}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
- m_AnchorMax: {x: 0, y: 0}
+ m_AnchorMax: {x: 1, y: 0.9322155}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0, y: 1}
@@ -1931,10 +1996,18 @@ PrefabInstance:
serializedVersion: 3
m_TransformParent: {fileID: 906658657234234412}
m_Modifications:
+ - target: {fileID: 212658739084426523, guid: c6c35cdcefd487f4b910ceed76b50a8f, type: 3}
+ propertyPath: m_IsActive
+ value: 1
+ objectReference: {fileID: 0}
- target: {fileID: 709763408010213073, guid: c6c35cdcefd487f4b910ceed76b50a8f, type: 3}
propertyPath: m_AnchoredPosition.y
value: 0
objectReference: {fileID: 0}
+ - target: {fileID: 1085624549214928409, guid: c6c35cdcefd487f4b910ceed76b50a8f, type: 3}
+ propertyPath: m_IsActive
+ value: 1
+ objectReference: {fileID: 0}
- target: {fileID: 1574318677252675885, guid: c6c35cdcefd487f4b910ceed76b50a8f, type: 3}
propertyPath: m_Pivot.x
value: 0
@@ -2015,6 +2088,22 @@ PrefabInstance:
propertyPath: m_LocalEulerAnglesHint.z
value: 0
objectReference: {fileID: 0}
+ - target: {fileID: 1765160980165362397, guid: c6c35cdcefd487f4b910ceed76b50a8f, type: 3}
+ propertyPath: m_AnchorMax.y
+ value: 0
+ objectReference: {fileID: 0}
+ - target: {fileID: 1765160980165362397, guid: c6c35cdcefd487f4b910ceed76b50a8f, type: 3}
+ propertyPath: m_AnchorMin.y
+ value: 0
+ objectReference: {fileID: 0}
+ - target: {fileID: 1765160980165362397, guid: c6c35cdcefd487f4b910ceed76b50a8f, type: 3}
+ propertyPath: m_AnchoredPosition.x
+ value: 0
+ objectReference: {fileID: 0}
+ - target: {fileID: 1765160980165362397, guid: c6c35cdcefd487f4b910ceed76b50a8f, type: 3}
+ propertyPath: m_AnchoredPosition.y
+ value: 0
+ objectReference: {fileID: 0}
- target: {fileID: 1883121240313448487, guid: c6c35cdcefd487f4b910ceed76b50a8f, type: 3}
propertyPath: m_SizeDelta.x
value: 0
@@ -2027,6 +2116,18 @@ PrefabInstance:
propertyPath: m_Name
value: ShiHierarchyWindow
objectReference: {fileID: 0}
+ - target: {fileID: 2713569951654231804, guid: c6c35cdcefd487f4b910ceed76b50a8f, type: 3}
+ propertyPath: m_IsActive
+ value: 1
+ objectReference: {fileID: 0}
+ - target: {fileID: 2903664282734591486, guid: c6c35cdcefd487f4b910ceed76b50a8f, type: 3}
+ propertyPath: m_IsActive
+ value: 1
+ objectReference: {fileID: 0}
+ - target: {fileID: 3351413207641142582, guid: c6c35cdcefd487f4b910ceed76b50a8f, type: 3}
+ propertyPath: m_SizeDelta.y
+ value: 2
+ objectReference: {fileID: 0}
- target: {fileID: 3351413207641142582, guid: c6c35cdcefd487f4b910ceed76b50a8f, type: 3}
propertyPath: m_AnchoredPosition.y
value: 0
diff --git a/Assets/Resources/SHI/Styles.meta b/Assets/Resources/SHI/Styles.meta
new file mode 100644
index 00000000..c52f115a
--- /dev/null
+++ b/Assets/Resources/SHI/Styles.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 64fb2d86b4390f04287dd53425bd2316
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Resources/SHI/Styles/GanttChart.uss b/Assets/Resources/SHI/Styles/GanttChart.uss
new file mode 100644
index 00000000..58d638d7
--- /dev/null
+++ b/Assets/Resources/SHI/Styles/GanttChart.uss
@@ -0,0 +1,16 @@
+/* Basic USS for Gantt */
+.header-container { }
+.month-row { }
+.week-row { }
+.day-row { }
+.rows-container { }
+.chart-row { flex-direction: row; height:28px; position: relative; border-bottom:1px solid #3A3A3A; }
+.hierarchy-cell { font-size:11px; width:240px; padding-left:4px; }
+.segments-layer { flex-grow:1; position: relative; }
+.segment { border-radius:3px; height:22px; background-color: rgba(64,128,220,0.6); }
+.marker { color:#2AA3FF; font-size:12px; }
+.row-selected { background-color: rgba(255,255,0,0.15); }
+.segment-selected { outline:2px solid #FFD500; }
+.span-cell { text-align:center; font-size:11px; border-right:1px solid #222; }
+.day-cell { text-align:center; font-size:10px; border-right:1px solid #333; width:16px; }
+.today-line { position:absolute; width:1px; background:#ff6b00; top:0; bottom:0; }
diff --git a/Assets/Resources/SHI/Styles/GanttChart.uss.meta b/Assets/Resources/SHI/Styles/GanttChart.uss.meta
new file mode 100644
index 00000000..d971a3d2
--- /dev/null
+++ b/Assets/Resources/SHI/Styles/GanttChart.uss.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4557a013dc4822f499286c5a202a41a5
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0}
+ disableValidation: 0
diff --git a/Assets/Resources/Styles.meta b/Assets/Resources/Styles.meta
new file mode 100644
index 00000000..670fc65d
--- /dev/null
+++ b/Assets/Resources/Styles.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 2f462e493bcdb4149ad987a8e92e24dc
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Scenes/Sample/ShiPopupSample.cs b/Assets/Scenes/Sample/ShiPopupSample.cs
index 49781f87..caa8acb7 100644
--- a/Assets/Scenes/Sample/ShiPopupSample.cs
+++ b/Assets/Scenes/Sample/ShiPopupSample.cs
@@ -38,21 +38,7 @@ public class ShiPopupSample : MonoBehaviour
}
}
- [Serializable]
- public class RawScheduleSegment
- {
- public string ItemId;
- public string Start;
- public string End;
- public float Progress;
- public string Type;
- }
-
- [Serializable]
- public class RawGanttChartData
- {
- public RawScheduleSegment[] Segments;
- }
+
///
/// StreamingAssets에서 샘플 간트 JSON과 모델을 읽어 모달에 적용합니다.
@@ -67,43 +53,10 @@ public class ShiPopupSample : MonoBehaviour
string sa = Application.streamingAssetsPath;
string glbPath = Path.Combine(sa, "block.glb");
- string jsonPath = Path.Combine(sa, "sample_gantt_data.json");
+ string jsonPath = "isop_chart_short.json";
- // 플랫폼에 따라 UnityWebRequest 사용이 필요할 수 있음(여기선 Editor/Standalone 가정)
- RawGanttChartData raw = null;
- try
- {
- if (File.Exists(jsonPath))
- raw = JsonUtility.FromJson(File.ReadAllText(jsonPath));
- else
- Debug.LogWarning($"Sample JSON not found: {jsonPath}");
- }
- catch (Exception ex)
- {
- Debug.LogError($"Failed reading JSON: {ex}");
- }
-
- var gantt = new GanttChartData();
- if (raw?.Segments != null)
- {
- foreach (var s in raw.Segments)
- {
- DateTime start, end;
- if (!DateTime.TryParse(s.Start, null, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out start))
- start = DateTime.UtcNow;
- if (!DateTime.TryParse(s.End, null, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out end))
- end = start.AddDays(1);
- gantt.Segments.Add(new ScheduleSegment
- {
- ItemKey = s.ItemId,
- Start = start,
- End = end,
- Progress = s.Progress,
- Type = s.Type ?? string.Empty
- });
- }
- }
- Debug.Log($"Loaded blockDetailModal:{blockDetailModal} {gantt.Segments.Count} gantt segments.");
- await blockDetailModal.LoadData(glbPath, gantt);
+
+ Debug.Log($"Loaded blockDetailModal:{blockDetailModal}");
+ await blockDetailModal.LoadData(glbPath, jsonPath);
}
}
diff --git a/Assets/Scenes/Sample/ShiPopupSample.unity b/Assets/Scenes/Sample/ShiPopupSample.unity
index 0e678279..a0de6975 100644
--- a/Assets/Scenes/Sample/ShiPopupSample.unity
+++ b/Assets/Scenes/Sample/ShiPopupSample.unity
@@ -285,7 +285,6 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: d549b7af14589244f84abc73c089c735, type: 3}
m_Name:
m_EditorClassIdentifier:
- blockDetailModal: {fileID: 0}
blockDetailModalPrefab: {fileID: 8096719646118634716, guid: 468ba7fb3e976344eb340d05295e56e6, type: 3}
openModalButton: {fileID: 1154598113}
--- !u!4 &717482005
@@ -504,7 +503,7 @@ Canvas:
m_AdditionalShaderChannelsFlag: 25
m_UpdateRectTransformForStandalone: 0
m_SortingLayerID: 0
- m_SortingOrder: 0
+ m_SortingOrder: 1
m_TargetDisplay: 0
--- !u!224 &1304768793
RectTransform:
diff --git a/Assets/Scripts/SHI/modal/BlockDetailModal.cs b/Assets/Scripts/SHI/modal/BlockDetailModal.cs
index 0685ab89..d7dc9b37 100644
--- a/Assets/Scripts/SHI/modal/BlockDetailModal.cs
+++ b/Assets/Scripts/SHI/modal/BlockDetailModal.cs
@@ -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 모델과 간트 데이터를 하위 뷰에 로드하고, 선택 동기화를 위한 매핑을 구축합니다.
///
/// glTF/glb 파일 경로.
- /// 간트 데이터셋.
+ /// 간트 데이터셋 경로.
/// 외부 취소 토큰.
- 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 items)
diff --git a/Assets/Scripts/SHI/modal/GanttChartData.cs b/Assets/Scripts/SHI/modal/GanttChartData.cs
index 25ca7a13..b9db7385 100644
--- a/Assets/Scripts/SHI/modal/GanttChartData.cs
+++ b/Assets/Scripts/SHI/modal/GanttChartData.cs
@@ -23,12 +23,86 @@ namespace SHI.modal
public string Type { get; set; } = string.Empty;
}
+ ///
+ /// 기간/마커 정보(Structured 행 모드용).
+ ///
+ 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;
+ }
+
+ ///
+ /// 블록/행 단위 일정 레코드.
+ ///
+ 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; }
+ /// 코드(예:43,4B 등)별 기간/마커.
+ public Dictionary Periods { get; } = new Dictionary();
+ // 추가 메타 정보(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
+
+ // 편의 접근자: 자주 쓰는 코드 맵핑
+ /// STDT01: 파란색 @ 표시 일(yyyymmdd)
+ public DateTime? STDT01 { get => GetStart("01"); set => SetStart("01", value); }
+ /// STDT21: 파란색 막대 시작일(yyyymmdd)
+ public DateTime? STDT21 { get => GetStart("21"); set => SetStart("21", value); }
+ /// FNDT21: 파란색 막대 종료일(yyyymmdd)
+ public DateTime? FNDT21 { get => GetEnd("21"); set => SetEnd("21", value); }
+ /// STDT23: 연두색 막대 시작일(yyyymmdd)
+ public DateTime? STDT23 { get => GetStart("23"); set => SetStart("23", value); }
+ /// FNDT23: 연두색 막대 종료일(yyyymmdd)
+ 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; }
+ }
+
///
/// 간단한 간트 차트 데이터셋입니다.
+ /// 기존 Segments 기반(Flat) + Rows 기반(Structured) 동시 지원.
///
public class GanttChartData
{
- /// 표시 순서대로의 세그먼트 컬렉션.
+ /// 표시 순서대로의 세그먼트 컬렉션 (Flat 모드).
public List Segments { get; set; } = new List();
+ /// Structured 행 모드 컬렉션.
+ public List Rows { get; set; } = new List();
+ public DateTime? MinDate { get; set; }
+ public DateTime? MaxDate { get; set; }
}
+
}
diff --git a/Assets/Scripts/SHI/modal/GanttJsonParser.cs b/Assets/Scripts/SHI/modal/GanttJsonParser.cs
new file mode 100644
index 00000000..791528e3
--- /dev/null
+++ b/Assets/Scripts/SHI/modal/GanttJsonParser.cs
@@ -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
+{
+ ///
+ /// isop_chart.json과 같은 행 배열(딕셔너리)의 STDTxx / FNDTxx / DURxx 패턴을 해석하여 를 생성합니다.
+ ///
+ 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";
+
+ ///
+ /// JSON 배열 문자열을 파싱해 Rows/Segments/MinDate/MaxDate를 채웁니다.
+ /// Duration 누락 시 (End-Start)+1을 사용합니다.
+ ///
+ public static GanttChartData Parse(string json, bool computeDurationIfMissing = true)
+ {
+ var data = new GanttChartData();
+ if (string.IsNullOrWhiteSpace(json)) return data;
+
+ List>? rows;
+ try
+ {
+ // Newtonsoft.Json 사용 (Assembly 내 참조 필요). 실패 시 경고 후 빈 데이터 반환.
+ rows = Newtonsoft.Json.JsonConvert.DeserializeObject>>(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();
+ var endMap = new Dictionary();
+ var durMap = new Dictionary();
+
+ 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(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 dict, string key)
+ => dict.TryGetValue(key, out var v) ? v?.ToString() : null;
+
+ private static float? GetFloat(Dictionary 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; }
+ }
+ }
+}
diff --git a/Assets/Scripts/SHI/modal/GanttJsonParser.cs.meta b/Assets/Scripts/SHI/modal/GanttJsonParser.cs.meta
new file mode 100644
index 00000000..7012680f
--- /dev/null
+++ b/Assets/Scripts/SHI/modal/GanttJsonParser.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0472849d1587ac84491dbf3f1b4c68ee
\ No newline at end of file
diff --git a/Assets/Scripts/SHI/modal/ModelDetailChartView.cs b/Assets/Scripts/SHI/modal/ModelDetailChartView.cs
index 3a617cb9..937781ce 100644
--- a/Assets/Scripts/SHI/modal/ModelDetailChartView.cs
+++ b/Assets/Scripts/SHI/modal/ModelDetailChartView.cs
@@ -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
{
///
- /// 차트 패널: 간트 데이터 바인딩/선택 동기화용 경량 래퍼.
- /// 실제 UI Toolkit 간트 구현 전까지 스텁 동작을 수행합니다.
+ /// 간트 데이터 로드 및(임시) UI Toolkit 렌더 스텁.
///
public class ModelDetailChartView : MonoBehaviour
{
public Action? OnRowClickedByKey;
- public Action? OnRowClicked; // backward compat
-
+ public Action? 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 _rowElements = new Dictionary();
+ private readonly Dictionary _segmentElements = new Dictionary();
+ 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}"); }
+ }
- ///
- /// 간트 데이터를 바인딩합니다(스텁 구현).
- ///
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}");
}
- ///
- /// 외부 키로 행을 하이라이트합니다.
- ///
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();
}
-
- ///
- /// Guid 식별자로 행을 하이라이트합니다.
- ///
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();
}
- ///
- /// 바인딩된 데이터를 해제합니다.
- ///
- 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("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(_ => { _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(_ => { _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 { 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();
+ if (ud == null)
+ {
+ // Create a new UIDocument component
+ try { ud = gameObject.AddComponent(); }
+ 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("SHI/PanelSettings/DefaultPanelSettings");
+ if (ps == null)
+ {
+ // Create runtime instance as fallback (not saved as asset)
+ ps = ScriptableObject.CreateInstance();
+ }
+ 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();
+ 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(); if (rt == null) return;
+ var canvas = GetComponentInParent