From 99e56d238adc1fb5b54a52cbc2e1209af9fcb35e Mon Sep 17 00:00:00 2001 From: monitor1394 Date: Wed, 25 Mar 2026 22:46:26 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0`Chart`=E7=9A=84`Json`?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Documentation~/zh/changelog.md | 6 + Editor/Charts/BaseChartEditor.cs | 50 + Editor/MainComponents/UIComponentEditor.cs | 42 + Editor/Windows/ChartJsonImportWindow.cs | 201 +++ Editor/Windows/ChartJsonImportWindow.cs.meta | 11 + Editor/Windows/UIComponentJsonImportWindow.cs | 193 ++ .../UIComponentJsonImportWindow.cs.meta | 11 + Runtime/Internal/BaseChart.API.cs | 22 + Runtime/Internal/UIComponent.cs | 20 + Runtime/Utilities/ChartJsonKit.cs | 1562 +++++++++++++++++ Runtime/Utilities/ChartJsonKit.cs.meta | 11 + Runtime/Utilities/ResourceRefHandler.cs | 291 +++ Runtime/Utilities/ResourceRefHandler.cs.meta | 11 + Runtime/Utilities/UIComponentJsonKit.cs | 299 ++++ Runtime/Utilities/UIComponentJsonKit.cs.meta | 11 + 15 files changed, 2741 insertions(+) create mode 100644 Editor/Windows/ChartJsonImportWindow.cs create mode 100644 Editor/Windows/ChartJsonImportWindow.cs.meta create mode 100644 Editor/Windows/UIComponentJsonImportWindow.cs create mode 100644 Editor/Windows/UIComponentJsonImportWindow.cs.meta create mode 100644 Runtime/Utilities/ChartJsonKit.cs create mode 100644 Runtime/Utilities/ChartJsonKit.cs.meta create mode 100644 Runtime/Utilities/ResourceRefHandler.cs create mode 100644 Runtime/Utilities/ResourceRefHandler.cs.meta create mode 100644 Runtime/Utilities/UIComponentJsonKit.cs create mode 100644 Runtime/Utilities/UIComponentJsonKit.cs.meta diff --git a/Documentation~/zh/changelog.md b/Documentation~/zh/changelog.md index 910d6b98..9047891f 100644 --- a/Documentation~/zh/changelog.md +++ b/Documentation~/zh/changelog.md @@ -81,6 +81,12 @@ slug: /changelog ## master +* (2026.03.25) 增加`Chart`的`Json`导出导入 +* (2026.03.10) 增加`Sankey`的线条tooltip触发显示 +* (2026.03.10) 增加`UITable`的排序功能支持 +* (2026.03.07) 修复`UITable`在尺寸变化时背景没有实时刷新的问题 +* (2026.03.06) 优化`UITable`支持万级以上数据量不卡顿 + ## v3.15.0 版本要点: diff --git a/Editor/Charts/BaseChartEditor.cs b/Editor/Charts/BaseChartEditor.cs index 371c4e34..3b0c6bb2 100644 --- a/Editor/Charts/BaseChartEditor.cs +++ b/Editor/Charts/BaseChartEditor.cs @@ -19,6 +19,8 @@ namespace XCharts.Editor public static readonly GUIContent btnSaveAsImage = new GUIContent("Save As Image", ""); public static readonly GUIContent btnCheckWarning = new GUIContent("Check Warning", ""); public static readonly GUIContent btnHideWarning = new GUIContent("Hide Warning", ""); + public static readonly GUIContent btnImportJsonData = new GUIContent("Import Json", ""); + public static readonly GUIContent btnExportJsonData = new GUIContent("Export Json", ""); } protected BaseChart m_Chart; protected SerializedProperty m_Script; @@ -36,6 +38,7 @@ namespace XCharts.Editor private bool m_BaseFoldout; private bool m_CheckWarning = false; + private bool m_ExportPending = false; private int m_LastComponentCount = 0; private int m_LastSerieCount = 0; private string m_VersionString = ""; @@ -300,6 +303,14 @@ namespace XCharts.Editor m_CheckWarning = false; } EditorGUILayout.EndHorizontal(); + if (GUILayout.Button(Styles.btnImportJsonData)) + { + ChartJsonImportWindow.ShowWindow(m_Chart); + } + if (GUILayout.Button(Styles.btnExportJsonData)) + { + RequestExportJsonData(); + } sb.Length = 0; sb.AppendFormat("v{0}", XChartsMgr.fullVersion); if (!string.IsNullOrEmpty(m_Chart.warningInfo)) @@ -321,8 +332,47 @@ namespace XCharts.Editor m_CheckWarning = true; m_Chart.CheckWarning(); } + if (GUILayout.Button(Styles.btnImportJsonData)) + { + ChartJsonImportWindow.ShowWindow(m_Chart); + } + if (GUILayout.Button(Styles.btnExportJsonData)) + { + RequestExportJsonData(); + } } } + + private void RequestExportJsonData() + { + if (m_ExportPending) return; + m_ExportPending = true; + var chart = m_Chart; + EditorApplication.delayCall += delegate() + { + m_ExportPending = false; + ExportJsonData(chart); + }; + GUIUtility.ExitGUI(); + } + + private static void ExportJsonData(BaseChart chart) + { + if (chart == null) return; + var json = chart.ExportToJson(true); + var defaultName = chart.gameObject.name + ".json"; + var path = EditorUtility.SaveFilePanel("Save Chart JSON", "", defaultName, "json"); + if (string.IsNullOrEmpty(path)) return; + try + { + System.IO.File.WriteAllText(path, json); + Debug.Log("[XCharts] JSON exported to: " + path); + } + catch (Exception ex) + { + Debug.LogError("[XCharts] Failed to save JSON: " + ex.Message); + } + } } } \ No newline at end of file diff --git a/Editor/MainComponents/UIComponentEditor.cs b/Editor/MainComponents/UIComponentEditor.cs index 66609fe6..e0f2435e 100644 --- a/Editor/MainComponents/UIComponentEditor.cs +++ b/Editor/MainComponents/UIComponentEditor.cs @@ -13,10 +13,13 @@ namespace XCharts.Editor public static readonly GUIContent btnAddComponent = new GUIContent("Add Main Component", ""); public static readonly GUIContent btnRebuildChartObject = new GUIContent("Rebuild Object", ""); public static readonly GUIContent btnSaveAsImage = new GUIContent("Save As Image", ""); + public static readonly GUIContent btnImportJsonData = new GUIContent("Import Json", ""); + public static readonly GUIContent btnExportJsonData = new GUIContent("Export Json", ""); public static readonly GUIContent btnCheckWarning = new GUIContent("Check Warning", ""); public static readonly GUIContent btnHideWarning = new GUIContent("Hide Warning", ""); } public UIComponent m_UIComponent; + private bool m_ExportPending; public static T AddUIComponent(string chartName) where T : UIComponent { @@ -56,6 +59,14 @@ namespace XCharts.Editor { m_UIComponent.SaveAsImage("png", "", 4f); } + if (GUILayout.Button(Styles.btnImportJsonData)) + { + UIComponentJsonImportWindow.ShowWindow(m_UIComponent); + } + if (GUILayout.Button(Styles.btnExportJsonData)) + { + RequestExportJsonData(); + } OnDebugEndInspectorGUI(); } @@ -89,6 +100,37 @@ namespace XCharts.Editor EditorGUILayout.PropertyField(property, title); } + private void RequestExportJsonData() + { + if (m_ExportPending) return; + m_ExportPending = true; + var target = m_UIComponent; + EditorApplication.delayCall += delegate () + { + m_ExportPending = false; + ExportJsonData(target); + }; + GUIUtility.ExitGUI(); + } + + private static void ExportJsonData(UIComponent target) + { + if (target == null) return; + var json = target.ExportToJson(true); + var defaultName = target.gameObject.name + ".json"; + var path = EditorUtility.SaveFilePanel("Save UI Component JSON", "", defaultName, "json"); + if (string.IsNullOrEmpty(path)) return; + try + { + System.IO.File.WriteAllText(path, json); + Debug.Log("[XCharts] UI JSON exported to: " + path); + } + catch (System.Exception ex) + { + Debug.LogError("[XCharts] Failed to save UI JSON: " + ex.Message); + } + } + protected void PropertyListField(string relativePropName, bool showOrder = true, params HeaderMenuInfo[] menus) { var m_DrawRect = GUILayoutUtility.GetRect(1f, 17f); diff --git a/Editor/Windows/ChartJsonImportWindow.cs b/Editor/Windows/ChartJsonImportWindow.cs new file mode 100644 index 00000000..c5c71f73 --- /dev/null +++ b/Editor/Windows/ChartJsonImportWindow.cs @@ -0,0 +1,201 @@ +using UnityEditor; +using UnityEngine; +using XCharts.Runtime; + +namespace XCharts.Editor +{ + public class ChartJsonImportWindow : EditorWindow + { + private const int TEXTAREA_SAFE_CHAR_LIMIT = 8000; + private const int LARGE_JSON_PREVIEW_CHAR_LIMIT = 4000; + + private static BaseChart s_TargetChart; + private string m_JsonInput = ""; + private Vector2 m_ScrollPos; + private bool m_ShowPreview = false; + private string m_PreviewText = ""; + private bool m_OpenFilePending = false; + private bool m_PreviewPending = false; + private bool m_ImportPending = false; + + public static void ShowWindow(BaseChart targetChart) + { + s_TargetChart = targetChart; + var window = GetWindow("Import Chart JSON"); + window.minSize = new Vector2(600, 400); + window.Show(); + } + + private void OnGUI() + { + if (s_TargetChart == null) + { + EditorGUILayout.HelpBox("Target chart is null. Please select a chart first.", MessageType.Error); + if (GUILayout.Button("Close")) Close(); + return; + } + if (m_JsonInput == null) m_JsonInput = ""; + EditorGUILayout.LabelField("Target Chart: " + s_TargetChart.gameObject.name, EditorStyles.boldLabel); + GUILayout.Space(10); + EditorGUILayout.LabelField("Paste JSON Data:", EditorStyles.boldLabel); + using (var scroll = new EditorGUILayout.ScrollViewScope(m_ScrollPos, GUILayout.Height(250))) + { + m_ScrollPos = scroll.scrollPosition; + if (m_JsonInput.Length <= TEXTAREA_SAFE_CHAR_LIMIT) + { + m_JsonInput = EditorGUILayout.TextArea(m_JsonInput, GUILayout.ExpandHeight(true), GUILayout.ExpandWidth(true)); + } + else + { + var preview = m_JsonInput.Substring(0, LARGE_JSON_PREVIEW_CHAR_LIMIT); + EditorGUILayout.HelpBox("JSON content is very large. To avoid editor text rendering limits, only a preview is shown below. Import uses full content.", MessageType.Info); + EditorGUILayout.TextArea(preview, GUILayout.ExpandHeight(true), GUILayout.ExpandWidth(true)); + } + } + EditorGUILayout.HelpBox("Paste JSON directly, or click Open Json File.", MessageType.Info); + GUILayout.Space(10); + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Open Json File", GUILayout.Width(120))) + { + RequestOpenJsonFile(); + } + if (GUILayout.Button("Preview", GUILayout.Width(100))) + { + RequestPreviewJson(); + } + } + if (m_ShowPreview && !string.IsNullOrEmpty(m_PreviewText)) + { + EditorGUILayout.TextArea(m_PreviewText, GUILayout.Height(150)); + } + GUILayout.Space(10); + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Import", GUILayout.Height(40), GUILayout.Width(150))) + { + RequestImportJson(); + } + if (GUILayout.Button("Cancel", GUILayout.Height(40), GUILayout.Width(150))) Close(); + } + } + + private void RequestOpenJsonFile() + { + if (m_OpenFilePending) return; + m_OpenFilePending = true; + EditorApplication.delayCall += delegate () + { + m_OpenFilePending = false; + if (this == null) return; + OpenJsonFile(); + Repaint(); + }; + GUIUtility.ExitGUI(); + } + + private void RequestPreviewJson() + { + if (m_PreviewPending) return; + m_PreviewPending = true; + EditorApplication.delayCall += delegate () + { + m_PreviewPending = false; + if (this == null) return; + PreviewJson(); + Repaint(); + }; + GUIUtility.ExitGUI(); + } + + private void RequestImportJson() + { + if (m_ImportPending) return; + m_ImportPending = true; + EditorApplication.delayCall += delegate () + { + m_ImportPending = false; + if (this == null) return; + ImportJson(); + }; + GUIUtility.ExitGUI(); + } + + private void PreviewJson() + { + if (string.IsNullOrEmpty(m_JsonInput)) + { + EditorUtility.DisplayDialog("Error", "JSON input is empty.", "OK"); + return; + } + try + { + var json = JsonUtility.FromJson(m_JsonInput); + if (json == null) + { + m_PreviewText = "Invalid JSON or unsupported schema."; + } + else + { + var componentCount = json.components != null ? json.components.Count : 0; + var seriesCount = json.series != null ? json.series.Count : 0; + m_PreviewText = "Chart Type: " + json.chartType + "\nComponents: " + componentCount + "\nSeries: " + seriesCount + "\n(Full validation on import)"; + } + m_ShowPreview = true; + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("Preview Error", "Invalid JSON: " + ex.Message, "OK"); + } + } + + private void ImportJson() + { + if (string.IsNullOrEmpty(m_JsonInput)) + { + EditorUtility.DisplayDialog("Error", "JSON input is empty. Please paste JSON data.", "OK"); + return; + } + try + { + Undo.RecordObject(s_TargetChart, "Import Chart JSON"); + s_TargetChart.ImportFromJson(m_JsonInput); + s_TargetChart.RebuildChartObject(); + s_TargetChart.RefreshAllComponent(); + s_TargetChart.RefreshChart(); + EditorUtility.SetDirty(s_TargetChart); + UnityEditor.SceneView.RepaintAll(); + var chart = s_TargetChart; + EditorApplication.delayCall += delegate () + { + if (chart == null) return; + chart.RefreshAllComponent(); + chart.RefreshChart(); + UnityEditor.SceneView.RepaintAll(); + }; + EditorUtility.DisplayDialog("Success", "Chart '" + s_TargetChart.gameObject.name + "' imported successfully!", "OK"); + Close(); + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("Import Error", "Failed to import JSON:\n" + ex.Message + "\n\n" + ex.StackTrace, "OK"); + } + } + + private void OpenJsonFile() + { + var path = EditorUtility.OpenFilePanel("Open Chart JSON", "", "json"); + if (string.IsNullOrEmpty(path)) return; + try + { + m_JsonInput = System.IO.File.ReadAllText(path); + m_ShowPreview = false; + m_PreviewText = ""; + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("Open File Error", "Failed to read JSON file:\n" + ex.Message, "OK"); + } + } + } +} diff --git a/Editor/Windows/ChartJsonImportWindow.cs.meta b/Editor/Windows/ChartJsonImportWindow.cs.meta new file mode 100644 index 00000000..d92a50a6 --- /dev/null +++ b/Editor/Windows/ChartJsonImportWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 21f2eafb07ab34d4abf575784acc56a3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Windows/UIComponentJsonImportWindow.cs b/Editor/Windows/UIComponentJsonImportWindow.cs new file mode 100644 index 00000000..c8ea7d4f --- /dev/null +++ b/Editor/Windows/UIComponentJsonImportWindow.cs @@ -0,0 +1,193 @@ +using UnityEditor; +using UnityEngine; +using XCharts.Runtime; + +namespace XCharts.Editor +{ + public class UIComponentJsonImportWindow : EditorWindow + { + private const int TEXTAREA_SAFE_CHAR_LIMIT = 8000; + private const int LARGE_JSON_PREVIEW_CHAR_LIMIT = 4000; + + private static UIComponent s_TargetComponent; + private string m_JsonInput = ""; + private Vector2 m_ScrollPos; + private bool m_ShowPreview = false; + private string m_PreviewText = ""; + private bool m_OpenFilePending = false; + private bool m_PreviewPending = false; + private bool m_ImportPending = false; + + public static void ShowWindow(UIComponent target) + { + s_TargetComponent = target; + var window = GetWindow("Import UI JSON"); + window.minSize = new Vector2(600, 400); + window.Show(); + } + + private void OnGUI() + { + if (s_TargetComponent == null) + { + EditorGUILayout.HelpBox("Target UI component is null. Please select a component first.", MessageType.Error); + if (GUILayout.Button("Close")) Close(); + return; + } + + if (m_JsonInput == null) m_JsonInput = ""; + EditorGUILayout.LabelField("Target: " + s_TargetComponent.gameObject.name + " (" + s_TargetComponent.GetType().Name + ")", EditorStyles.boldLabel); + GUILayout.Space(10); + EditorGUILayout.LabelField("Paste JSON Data:", EditorStyles.boldLabel); + + using (var scroll = new EditorGUILayout.ScrollViewScope(m_ScrollPos, GUILayout.Height(250))) + { + m_ScrollPos = scroll.scrollPosition; + if (m_JsonInput.Length <= TEXTAREA_SAFE_CHAR_LIMIT) + { + m_JsonInput = EditorGUILayout.TextArea(m_JsonInput, GUILayout.ExpandHeight(true), GUILayout.ExpandWidth(true)); + } + else + { + var preview = m_JsonInput.Substring(0, LARGE_JSON_PREVIEW_CHAR_LIMIT); + EditorGUILayout.HelpBox("JSON content is very large. Only a preview is shown below. Import uses full content.", MessageType.Info); + EditorGUILayout.TextArea(preview, GUILayout.ExpandHeight(true), GUILayout.ExpandWidth(true)); + } + } + + EditorGUILayout.HelpBox("Paste JSON directly, or click Open Json File.", MessageType.Info); + GUILayout.Space(10); + + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Open Json File", GUILayout.Width(120))) RequestOpenJsonFile(); + if (GUILayout.Button("Preview", GUILayout.Width(100))) RequestPreviewJson(); + } + + if (m_ShowPreview && !string.IsNullOrEmpty(m_PreviewText)) + { + EditorGUILayout.TextArea(m_PreviewText, GUILayout.Height(120)); + } + + GUILayout.Space(10); + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Import", GUILayout.Height(40), GUILayout.Width(150))) RequestImportJson(); + if (GUILayout.Button("Cancel", GUILayout.Height(40), GUILayout.Width(150))) Close(); + } + } + + private void RequestOpenJsonFile() + { + if (m_OpenFilePending) return; + m_OpenFilePending = true; + EditorApplication.delayCall += delegate () + { + m_OpenFilePending = false; + if (this == null) return; + OpenJsonFile(); + Repaint(); + }; + GUIUtility.ExitGUI(); + } + + private void RequestPreviewJson() + { + if (m_PreviewPending) return; + m_PreviewPending = true; + EditorApplication.delayCall += delegate () + { + m_PreviewPending = false; + if (this == null) return; + PreviewJson(); + Repaint(); + }; + GUIUtility.ExitGUI(); + } + + private void RequestImportJson() + { + if (m_ImportPending) return; + m_ImportPending = true; + EditorApplication.delayCall += delegate () + { + m_ImportPending = false; + if (this == null) return; + ImportJson(); + }; + GUIUtility.ExitGUI(); + } + + private void PreviewJson() + { + if (string.IsNullOrEmpty(m_JsonInput)) + { + EditorUtility.DisplayDialog("Error", "JSON input is empty.", "OK"); + return; + } + + try + { + var json = JsonUtility.FromJson(m_JsonInput); + if (json == null) + m_PreviewText = "Invalid JSON or unsupported schema."; + else + m_PreviewText = "Component Type: " + json.componentType + "\nSchema: " + json.schemaVersion + "\nVersion: " + json.componentVersion; + m_ShowPreview = true; + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("Preview Error", "Invalid JSON: " + ex.Message, "OK"); + } + } + + private void ImportJson() + { + if (string.IsNullOrEmpty(m_JsonInput)) + { + EditorUtility.DisplayDialog("Error", "JSON input is empty. Please paste JSON data.", "OK"); + return; + } + try + { + Undo.RecordObject(s_TargetComponent, "Import UI Component JSON"); + s_TargetComponent.ImportFromJson(m_JsonInput); + s_TargetComponent.RebuildChartObject(); + s_TargetComponent.RefreshAllComponent(); + s_TargetComponent.RefreshGraph(); + EditorUtility.SetDirty(s_TargetComponent); + SceneView.RepaintAll(); + var target = s_TargetComponent; + EditorApplication.delayCall += delegate () + { + if (target == null) return; + target.RefreshAllComponent(); + target.RefreshGraph(); + SceneView.RepaintAll(); + }; + EditorUtility.DisplayDialog("Success", "UI component imported successfully!", "OK"); + Close(); + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("Import Error", "Failed to import JSON:\n" + ex.Message + "\n\n" + ex.StackTrace, "OK"); + } + } + + private void OpenJsonFile() + { + var path = EditorUtility.OpenFilePanel("Open UI Component JSON", "", "json"); + if (string.IsNullOrEmpty(path)) return; + try + { + m_JsonInput = System.IO.File.ReadAllText(path); + m_ShowPreview = false; + m_PreviewText = ""; + } + catch (System.Exception ex) + { + EditorUtility.DisplayDialog("Open File Error", "Failed to read JSON file:\n" + ex.Message, "OK"); + } + } + } +} diff --git a/Editor/Windows/UIComponentJsonImportWindow.cs.meta b/Editor/Windows/UIComponentJsonImportWindow.cs.meta new file mode 100644 index 00000000..80c24134 --- /dev/null +++ b/Editor/Windows/UIComponentJsonImportWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 68157a6f7d4e94ccc8ccbb4913d187f3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Internal/BaseChart.API.cs b/Runtime/Internal/BaseChart.API.cs index 41aacb9b..2d52770a 100644 --- a/Runtime/Internal/BaseChart.API.cs +++ b/Runtime/Internal/BaseChart.API.cs @@ -778,5 +778,27 @@ namespace XCharts.Runtime foreach (var component in m_Components) component.ResetStatus(); foreach (var handler in m_SerieHandlers) handler.ForceUpdateSerieContext(); } + + /// + /// Export chart configuration and data to JSON string. + /// ||导出图表配置和数据为JSON字符串。 + /// + [Since("v3.16.0")] + public string ExportToJson(bool prettyPrint = true) + { + return XCharts.Runtime.ChartJsonSerializer.Serialize(this, prettyPrint); + } + + /// + /// Import JSON and update current chart configuration. + /// ||导入JSON并更新当前图表配置。 + /// + [Since("v3.16.0")] + public void ImportFromJson(string json) + { + XCharts.Runtime.ChartJsonDeserializer.Deserialize(json, this); + RefreshAllComponent(); + RefreshChart(); + } } } \ No newline at end of file diff --git a/Runtime/Internal/UIComponent.cs b/Runtime/Internal/UIComponent.cs index 43916eeb..e0319c36 100644 --- a/Runtime/Internal/UIComponent.cs +++ b/Runtime/Internal/UIComponent.cs @@ -154,5 +154,25 @@ namespace XCharts.Runtime } protected virtual void OnThemeChanged() { } + + /// + /// Export UI component configuration to JSON string. + /// + [Since("v3.16.0")] + public string ExportToJson(bool prettyPrint = true) + { + return UIComponentJsonSerializer.Serialize(this, prettyPrint); + } + + /// + /// Import JSON and update current UI component configuration. + /// + [Since("v3.16.0")] + public void ImportFromJson(string json) + { + UIComponentJsonDeserializer.Deserialize(json, this); + RefreshAllComponent(); + RefreshGraph(); + } } } \ No newline at end of file diff --git a/Runtime/Utilities/ChartJsonKit.cs b/Runtime/Utilities/ChartJsonKit.cs new file mode 100644 index 00000000..e2bb336b --- /dev/null +++ b/Runtime/Utilities/ChartJsonKit.cs @@ -0,0 +1,1562 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text.RegularExpressions; +using UnityEngine; +#if dUI_TextMeshPro +using TMPro; +#endif + +namespace XCharts.Runtime +{ + // ════════════════════════════════════════════════════════════════════════════════ + // DTO Definitions + // ════════════════════════════════════════════════════════════════════════════════ + + /// Root DTO for chart JSON export/import. + [Serializable] + public class ChartJson + { + public string schemaVersion = "1.1"; + public string chartType; + public string chartVersion; + public string exportedAt; + public List components = new List(); + public List series = new List(); + public ThemeSnapshotJson theme; + public ChartSettingsJson settings; + } + + [Serializable] + public class ComponentJson + { + /// Type name (simplified, e.g. "Background", "Tooltip"). + public string type; + public bool enabled = true; + /// JSON-serialized component fields (via JsonUtility). + public string data; + } + + [Serializable] + public class SerieJson + { + /// Type name (simplified, e.g. "Line", "Bar"). + public string type; + public int index; + public bool enabled = true; + /// JSON-serialized serie fields (via JsonUtility). + public string data; + } + + [Serializable] + public class ThemeSnapshotJson + { + public string themeName; + public int themeType; + public string data; + } + + [Serializable] + public class ChartSettingsJson + { + public string chartName; + public bool useUtc; + public float width; + public float height; + public string data; + } + + // ════════════════════════════════════════════════════════════════════════════════ + // Serializer: BaseChart → JSON string + // ════════════════════════════════════════════════════════════════════════════════ + + /// + /// Exports a BaseChart instance to a portable JSON string (ChartJson schema v1.0). + /// Only serialized fields are exported; runtime-only / [NonSerialized] fields are skipped. + /// + public static class ChartJsonSerializer + { + /// + /// Serialize to a JSON string. + /// + public static string Serialize(BaseChart chart, bool prettyPrint = true) + { + if (chart == null) throw new ArgumentNullException("chart"); + + EnsureChartRuntimeLists(chart); + + var dto = new ChartJson + { + schemaVersion = "1.1", + chartType = chart.GetType().Name, + chartVersion = GetChartVersion(chart), + exportedAt = DateTime.UtcNow.ToString("o"), + theme = SerializeTheme(chart.theme), + settings = new ChartSettingsJson + { + chartName = chart.chartName, + useUtc = chart.useUtc, + width = chart.chartWidth, + height = chart.chartHeight, + data = SerializeSettings(chart) + } + }; + + var components = CollectComponents(chart); + var series = CollectSeries(chart); + + foreach (var comp in components) + { + var compJson = SerializeComponent(comp); + if (compJson != null) + dto.components.Add(compJson); + } + + foreach (var serie in series) + { + var serieJson = SerializeSerie(serie); + if (serieJson != null) + dto.series.Add(serieJson); + } + + var json = JsonUtility.ToJson(dto, prettyPrint); + json = ChartJsonDataFieldCodec.ConvertEscapedDataStringToRawObject(json); + if (prettyPrint) + { + object parsedJson; + if (SimpleJson.TryParse(json, out parsedJson)) + json = SimpleJson.Stringify(parsedJson, true); + } + return json; + } + + // ─── Component ──────────────────────────────────────────────────────── + + private static ComponentJson SerializeComponent(MainComponent comp) + { + if (comp == null) return null; + try + { + var defaultComp = CreateDefaultInstance(comp.GetType()) as MainComponent; + var dataJson = JsonDiffPruner.PruneDefaults(JsonUtility.ToJson(comp), JsonUtility.ToJson(defaultComp)); + return new ComponentJson + { + type = comp.GetType().Name, + enabled = true, + data = dataJson + }; + } + catch (Exception ex) + { + Debug.LogWarning(string.Format("[XCharts] ChartJsonSerializer: Failed to serialize component {0}: {1}", comp.GetType().Name, ex.Message)); + return null; + } + } + + // ─── Serie ──────────────────────────────────────────────────────────── + + private static SerieJson SerializeSerie(Serie serie) + { + if (serie == null) return null; + try + { + var defaultSerie = CreateDefaultInstance(serie.GetType()) as Serie; + var dataJson = JsonDiffPruner.PruneDefaults(JsonUtility.ToJson(serie), JsonUtility.ToJson(defaultSerie)); + dataJson = PruneSerieDataDefaults(dataJson); + return new SerieJson + { + type = serie.GetType().Name, + index = serie.index, + enabled = serie.show, + data = dataJson + }; + } + catch (Exception ex) + { + Debug.LogWarning(string.Format("[XCharts] ChartJsonSerializer: Failed to serialize serie {0}: {1}", serie.GetType().Name, ex.Message)); + return null; + } + } + + private static string PruneSerieDataDefaults(string serieJson) + { + if (string.IsNullOrEmpty(serieJson)) return serieJson; + + object serieObj; + if (!SimpleJson.TryParse(serieJson, out serieObj)) + return serieJson; + + var serieDict = serieObj as Dictionary; + if (serieDict == null) + return serieJson; + + object rawDataList; + if (!serieDict.TryGetValue("m_Data", out rawDataList)) + return serieJson; + + var dataList = rawDataList as List; + if (dataList == null || dataList.Count == 0) + return serieJson; + + object defaultSerieDataObj; + if (!SimpleJson.TryParse(JsonUtility.ToJson(new SerieData()), out defaultSerieDataObj)) + return serieJson; + + var prunedList = new List(); + for (int i = 0; i < dataList.Count; i++) + { + var item = dataList[i]; + var prunedItem = JsonDiffPruner.PruneParsedDefaults(item, defaultSerieDataObj); + if (prunedItem != null) + prunedList.Add(prunedItem); + else + prunedList.Add(item); + } + serieDict["m_Data"] = prunedList; + return SimpleJson.Stringify(serieDict); + } + + // ─── Theme ──────────────────────────────────────────────────────────── + + private static ThemeSnapshotJson SerializeTheme(ThemeStyle themeStyle) + { + if (themeStyle == null) return new ThemeSnapshotJson(); + + var theme = themeStyle.sharedTheme; + var snapshot = new ThemeSnapshotJson + { + themeName = theme != null ? theme.themeName : "Default", + themeType = theme != null ? (int)theme.themeType : 0, + data = SerializeThemeStyleData(themeStyle) + }; + + return snapshot; + } + + private static string SerializeThemeStyleData(ThemeStyle themeStyle) + { + if (themeStyle == null) return "{}"; + try + { + var defaultThemeStyle = new ThemeStyle(); + return JsonDiffPruner.PruneDefaults(JsonUtility.ToJson(themeStyle), JsonUtility.ToJson(defaultThemeStyle)); + } + catch + { + return JsonUtility.ToJson(themeStyle); + } + } + + // ─── Helpers ────────────────────────────────────────────────────────── + + private static string GetChartVersion(BaseChart chart) + { + string moduleVersion; + string coreVersion = XChartsMgr.version; + string moduleName = GetModuleNameFromAssembly(chart == null ? null : chart.GetType()); + if (!TryGetVersionFromModulePackageJson(moduleName, out moduleVersion)) + { + // No module-specific package found: fallback to core package version + if (!TryGetVersionFromCorePackageJson(out moduleVersion)) + moduleVersion = coreVersion; + } + return coreVersion + "/" + moduleVersion; + } + + private static string GetModuleNameFromAssembly(Type chartType) + { + if (chartType == null) return string.Empty; + string asmName = chartType.Assembly.GetName().Name; + if (string.IsNullOrEmpty(asmName)) return string.Empty; + if (asmName == "XCharts.Runtime") return string.Empty; + + const string prefix = "XCharts."; + const string suffix = ".Runtime"; + if (asmName.StartsWith(prefix, StringComparison.Ordinal) && asmName.EndsWith(suffix, StringComparison.Ordinal)) + { + int start = prefix.Length; + int len = asmName.Length - prefix.Length - suffix.Length; + if (len > 0) + return asmName.Substring(start, len); + } + return string.Empty; + } + + private static bool TryGetVersionFromModulePackageJson(string moduleName, out string version) + { + version = null; + if (string.IsNullOrEmpty(moduleName)) + return false; + + var candidates = new List(); + string projectRoot = Directory.GetCurrentDirectory(); + + // embedded/source mode + candidates.Add(Path.Combine(Path.Combine(Path.Combine(projectRoot, "Assets"), "XCharts-" + moduleName), "package.json")); + + // package manager mode (known naming convention) + string modulePkgName = "com.monitor1394.xcharts." + moduleName.ToLower(); + candidates.Add(Path.Combine(Path.Combine(Path.Combine(projectRoot, "Packages"), modulePkgName), "package.json")); + +#if UNITY_EDITOR + // If core package root is known, also probe sibling package folders + try + { + string coreRoot = XChartsMgr.GetPackageFullPath(); + if (!string.IsNullOrEmpty(coreRoot)) + { + string parent = Path.GetDirectoryName(coreRoot); + if (!string.IsNullOrEmpty(parent)) + candidates.Add(Path.Combine(Path.Combine(parent, modulePkgName), "package.json")); + } + } + catch { } +#endif + + for (int i = 0; i < candidates.Count; i++) + { + if (TryReadVersionFromPackageJson(candidates[i], out version)) + return true; + } + return false; + } + + private static bool TryGetVersionFromCorePackageJson(out string version) + { + version = null; + var candidates = new List(); + string projectRoot = Directory.GetCurrentDirectory(); + + // embedded/source mode + candidates.Add(Path.Combine(Path.Combine(Path.Combine(projectRoot, "Assets"), "XCharts"), "package.json")); + + // package manager mode + candidates.Add(Path.Combine(Path.Combine(Path.Combine(projectRoot, "Packages"), "com.monitor1394.xcharts"), "package.json")); + +#if UNITY_EDITOR + try + { + string coreRoot = XChartsMgr.GetPackageFullPath(); + if (!string.IsNullOrEmpty(coreRoot)) + candidates.Add(Path.Combine(coreRoot, "package.json")); + } + catch { } +#endif + + for (int i = 0; i < candidates.Count; i++) + { + if (TryReadVersionFromPackageJson(candidates[i], out version)) + return true; + } + return false; + } + + private static bool TryReadVersionFromPackageJson(string packageJsonPath, out string version) + { + version = null; + try + { + if (string.IsNullOrEmpty(packageJsonPath) || !File.Exists(packageJsonPath)) + return false; + string content = File.ReadAllText(packageJsonPath); + var match = Regex.Match(content, "\"version\"\\s*:\\s*\"([^\"]+)\""); + if (!match.Success) + return false; + version = match.Groups[1].Value; + return !string.IsNullOrEmpty(version); + } + catch + { + return false; + } + } + + private static string ColorToHex(Color32 color) + { + return string.Format("#{0:X2}{1:X2}{2:X2}{3:X2}", color.r, color.g, color.b, color.a); + } + + private static string SerializeSettings(BaseChart chart) + { + if (chart == null || chart.settings == null) + return "{}"; + try + { + var defaultSettings = CreateDefaultInstance(typeof(Settings)) as Settings; + if (defaultSettings == null) + return JsonUtility.ToJson(chart.settings); + return JsonDiffPruner.PruneDefaults(JsonUtility.ToJson(chart.settings), JsonUtility.ToJson(defaultSettings)); + } + catch + { + return JsonUtility.ToJson(chart.settings); + } + } + + + private static void EnsureChartRuntimeLists(BaseChart chart) + { + if (chart == null) return; + if (chart.series != null && chart.series.Count > 0) return; + try + { + chart.OnAfterDeserialize(); + } + catch { } + } + + private static List CollectComponents(BaseChart chart) + { + var result = new List(); + if (chart.components != null && chart.components.Count > 0) + { + result.AddRange(chart.components); + return result; + } + + foreach (var kv in chart.typeListForComponent) + { + var field = kv.Value; + var count = ReflectionUtil.InvokeListCount(chart, field); + for (int i = 0; i < count; i++) + { + var comp = ReflectionUtil.InvokeListGet(chart, field, i); + if (comp != null) + result.Add(comp); + } + } + result.Sort(); + return result; + } + + private static List CollectSeries(BaseChart chart) + { + var result = new List(); + if (chart.series != null && chart.series.Count > 0) + { + result.AddRange(chart.series); + return result; + } + + foreach (var kv in chart.typeListForSerie) + { + var field = kv.Value; + var count = ReflectionUtil.InvokeListCount(chart, field); + for (int i = 0; i < count; i++) + { + var serie = ReflectionUtil.InvokeListGet(chart, field, i); + if (serie != null) + result.Add(serie); + } + } + result.Sort(); + return result; + } + + private static object CreateDefaultInstance(Type type) + { + if (type == null) return null; + object instance = null; + try + { + instance = Activator.CreateInstance(type); + var method = type.GetMethod("SetDefaultValue", BindingFlags.Public | BindingFlags.Instance); + if (method != null) + method.Invoke(instance, new object[] { }); + } + catch + { + // fall back to raw Activator result or null + } + return instance; + } + } + + // ════════════════════════════════════════════════════════════════════════════════ + // Deserializer: JSON string → BaseChart + // ════════════════════════════════════════════════════════════════════════════════ + + /// + /// Imports a ChartJson (v1.0) into an existing BaseChart instance or creates a new chart. + /// + public static class ChartJsonDeserializer + { + private const string LOG_TAG = "[XCharts] ChartJsonDeserializer"; + + /// + /// Deserialize and apply the configuration to . + /// Existing components/series are updated or replaced. Calls Refresh at the end. + /// + /// Thrown when json is invalid or schema version is unsupported. + public static void Deserialize(string json, BaseChart chart) + { + if (string.IsNullOrEmpty(json)) throw new ArgumentNullException("json"); + if (chart == null) throw new ArgumentNullException("chart"); + + EnsureTypeMapsInitialized(chart); + + // Support both formats: + // 1) legacy: "data":"{...escaped...}" + // 2) v1.1+: "data":{...raw object...} + json = ChartJsonDataFieldCodec.ConvertRawObjectDataToEscapedString(json); + + ChartJson dto; + try + { + dto = JsonUtility.FromJson(json); + } + catch (Exception ex) + { + throw new ArgumentException(string.Format("Invalid JSON format: {0}", ex.Message), ex); + } + + if (dto == null || string.IsNullOrEmpty(dto.schemaVersion)) + throw new ArgumentException("JSON does not appear to be a valid XCharts chart export (missing schemaVersion)."); + + ValidateSchema(dto); + + // Apply components + ImportComponents(dto.components, chart); + + // Apply series + ImportSeries(dto.series, chart); + + // Apply chart base fields/settings + if (dto.settings != null) + ImportSettings(dto.settings, chart); + + // Apply theme + if (dto.theme != null) + ImportTheme(dto.theme, chart); + + chart.RefreshChart(); + +#if UNITY_EDITOR + UnityEditor.EditorUtility.SetDirty(chart); +#endif + Debug.Log(string.Format("{0}: Import complete - {1} component(s), {2} serie(s).", LOG_TAG, dto.components.Count, dto.series.Count)); + } + + private static void ImportSettings(ChartSettingsJson settingsJson, BaseChart chart) + { + if (settingsJson == null || chart == null) return; + try + { + if (!string.IsNullOrEmpty(settingsJson.chartName)) + SetChartNameRaw(chart, settingsJson.chartName); + + chart.useUtc = settingsJson.useUtc; + + if (!string.IsNullOrEmpty(settingsJson.data) && settingsJson.data != "{}") + { + var settingsTarget = chart.settings; + if (settingsTarget == null) + { + settingsTarget = Activator.CreateInstance(typeof(Settings)) as Settings; + if (settingsTarget != null) + SetChartSettingsRaw(chart, settingsTarget); + } + if (settingsTarget != null) + JsonUtility.FromJsonOverwrite(settingsJson.data, settingsTarget); + } + } + catch (Exception ex) + { + Debug.LogWarning(string.Format("{0}: Failed to import chart settings: {1}", LOG_TAG, ex.Message)); + } + } + + private static void SetChartNameRaw(BaseChart chart, string name) + { + if (chart == null) return; + try + { + var field = chart.GetType().GetField("m_ChartName", BindingFlags.NonPublic | BindingFlags.Instance); + if (field != null) + field.SetValue(chart, name); + else + chart.chartName = name; + } + catch + { + chart.chartName = name; + } + } + + private static void SetChartSettingsRaw(BaseChart chart, Settings settings) + { + if (chart == null) return; + try + { + var field = chart.GetType().GetField("m_Settings", BindingFlags.NonPublic | BindingFlags.Instance); + if (field != null) + field.SetValue(chart, settings); + } + catch + { + } + } + + private static void EnsureTypeMapsInitialized(BaseChart chart) + { + if (chart == null) return; + if (chart.typeListForSerie != null && chart.typeListForSerie.Count > 0 && + chart.typeListForComponent != null && chart.typeListForComponent.Count > 0) + return; + try + { + chart.OnAfterDeserialize(); + } + catch + { + } + } + + // ─── Validation ─────────────────────────────────────────────────────── + + private static void ValidateSchema(ChartJson dto) + { + // Only v1.0 is supported in this version + if (!dto.schemaVersion.StartsWith("1.")) + throw new ArgumentException(string.Format("Unsupported schema version '{0}'. This version only supports '1.x'.", dto.schemaVersion)); + } + + // ─── Components ─────────────────────────────────────────────────────── + + private static void ImportComponents(List componentJsons, BaseChart chart) + { + if (componentJsons == null || componentJsons.Count == 0) return; + foreach (var compJson in componentJsons) + { + if (string.IsNullOrEmpty(compJson.type)) + { + Debug.LogWarning(string.Format("{0}: Skipping component with empty type.", LOG_TAG)); + continue; + } + var type = ResolveType(compJson.type, typeof(MainComponent)); + if (type == null) + { + Debug.LogWarning(string.Format("{0}: Component type not found: '{1}'. Skipping.", LOG_TAG, compJson.type)); + continue; + } + if (!typeof(MainComponent).IsAssignableFrom(type)) + { + Debug.LogWarning(string.Format("{0}: Type '{1}' is not a MainComponent. Skipping.", LOG_TAG, type.Name)); + continue; + } + try + { + // Find or add the component + MainComponent target = null; + foreach (var comp in chart.components) + { + if (comp.GetType() == type) + { + target = comp; + break; + } + } + if (target == null) + { + if (!chart.CanAddChartComponent(type)) + { + Debug.LogWarning(string.Format("{0}: Cannot add component '{1}'. Skipping.", LOG_TAG, type.Name)); + continue; + } + target = chart.AddChartComponent(type); + } + if (target != null && !string.IsNullOrEmpty(compJson.data)) + JsonUtility.FromJsonOverwrite(compJson.data, target); + } + catch (Exception ex) + { + Debug.LogWarning(string.Format("{0}: Failed to import component '{1}': {2}", LOG_TAG, compJson.type, ex.Message)); + } + } + } + + // ─── Series ─────────────────────────────────────────────────────────── + + private static void ImportSeries(List serieJsons, BaseChart chart) + { + if (serieJsons == null || serieJsons.Count == 0) return; + // Remove all series + for (int i = chart.series.Count - 1; i >= 0; i--) + chart.RemoveSerie(chart.series[i]); + foreach (var serieJson in serieJsons) + { + if (string.IsNullOrEmpty(serieJson.type)) + { + Debug.LogWarning(string.Format("{0}: Skipping serie with empty type.", LOG_TAG)); + continue; + } + var type = ResolveType(serieJson.type, typeof(Serie)); + if (type == null) + { + Debug.LogWarning(string.Format("{0}: Serie type not found: '{1}'. The extension module may not be installed. Skipping.", LOG_TAG, serieJson.type)); + continue; + } + if (!typeof(Serie).IsAssignableFrom(type)) + { + Debug.LogWarning(string.Format("{0}: Type '{1}' is not a Serie. Skipping.", LOG_TAG, type.Name)); + continue; + } + try + { + if (!chart.CanAddSerie(type)) + { + Debug.LogWarning(string.Format("{0}: Cannot add serie '{1}'. Skipping.", LOG_TAG, type.Name)); + continue; + } + // Use reflection to call AddSerie() + var method = chart.GetType().GetMethod("AddSerie", BindingFlags.Public | BindingFlags.Instance); + var genericMethod = method.MakeGenericMethod(type); + var serie = genericMethod.Invoke(chart, new object[] { null, true, false }) as Serie; + if (serie != null && !string.IsNullOrEmpty(serieJson.data)) + { + JsonUtility.FromJsonOverwrite(serieJson.data, serie); + serie.show = serieJson.enabled; + } + } + catch (Exception ex) + { + Debug.LogWarning(string.Format("{0}: Failed to import serie '{1}': {2}", LOG_TAG, serieJson.type, ex.Message)); + } + } + } + + // ─── Theme ──────────────────────────────────────────────────────────── + + private static void ImportTheme(ThemeSnapshotJson snapshot, BaseChart chart) + { + var themeStyle = chart.theme; + if (themeStyle == null) return; + + try + { + if (!string.IsNullOrEmpty(snapshot.data) && snapshot.data != "{}") + JsonUtility.FromJsonOverwrite(snapshot.data, themeStyle); + } + catch (Exception ex) + { + Debug.LogWarning(string.Format("{0}: Failed to restore theme style data: {1}", LOG_TAG, ex.Message)); + } + + if (themeStyle.sharedTheme == null) + { + try + { + if (!string.IsNullOrEmpty(snapshot.themeName)) + themeStyle.sharedTheme = XCThemeMgr.GetTheme(snapshot.themeName); + if (themeStyle.sharedTheme == null) + themeStyle.sharedTheme = XCThemeMgr.GetTheme((ThemeType)snapshot.themeType); + } + catch + { + themeStyle.sharedTheme = XCThemeMgr.GetTheme(ThemeType.Default); + } + if (themeStyle.sharedTheme == null) + themeStyle.sharedTheme = XCThemeMgr.GetTheme(ThemeType.Default); + } + themeStyle.SetAllDirty(); + } + + // ─── Type resolution ────────────────────────────────────────────────── + + /// + /// Resolves an assembly-qualified type name, with fallback to short name or namespace search. + /// + private static Type ResolveType(string typeName, Type expectedBaseType) + { + if (string.IsNullOrEmpty(typeName)) return null; + + // 1. Direct resolution (same project / same assembly) + var type = Type.GetType(typeName); + if (IsExpectedType(type, expectedBaseType)) return type; + + // 2. Try resolving by short name across all loaded assemblies + var shortName = typeName.Split(',')[0].Trim(); + // Strip namespace prefix for a simple name match + var simpleName = shortName.Contains(".") ? shortName.Substring(shortName.LastIndexOf(".") + 1) : shortName; + + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + // Try by full type name first + type = asm.GetType(shortName); + if (IsExpectedType(type, expectedBaseType)) return type; + + // Try by simple name in XCharts.Runtime namespace + type = asm.GetType("XCharts.Runtime." + simpleName); + if (IsExpectedType(type, expectedBaseType)) return type; + + // Try by simple type name across all types in the assembly + var types = asm.GetTypes(); + for (int i = 0; i < types.Length; i++) + { + var candidate = types[i]; + if (candidate == null) continue; + if (!string.Equals(candidate.Name, simpleName, StringComparison.Ordinal)) + continue; + if (!IsExpectedType(candidate, expectedBaseType)) + continue; + return candidate; + } + } + catch (ReflectionTypeLoadException rtle) + { + var partialTypes = rtle.Types; + if (partialTypes == null) continue; + for (int i = 0; i < partialTypes.Length; i++) + { + var candidate = partialTypes[i]; + if (candidate == null) continue; + if (!string.Equals(candidate.Name, simpleName, StringComparison.Ordinal)) + continue; + if (!IsExpectedType(candidate, expectedBaseType)) + continue; + return candidate; + } + } + catch { } + } + + return null; + } + + private static bool IsExpectedType(Type candidate, Type expectedBaseType) + { + if (candidate == null) return false; + if (expectedBaseType == null) return true; + return expectedBaseType.IsAssignableFrom(candidate); + } + + } + + internal static class ChartJsonDataFieldCodec + { + private const string DATA_KEY = "\"data\""; + + public static string ConvertEscapedDataStringToRawObject(string json) + { + if (string.IsNullOrEmpty(json)) return json; + var sb = new System.Text.StringBuilder(json.Length + 64); + int i = 0; + while (i < json.Length) + { + int keyIndex = json.IndexOf(DATA_KEY, i, StringComparison.Ordinal); + if (keyIndex < 0) + { + sb.Append(json, i, json.Length - i); + break; + } + + sb.Append(json, i, keyIndex - i); + int colonIndex = FindNextNonWhitespace(json, keyIndex + DATA_KEY.Length); + if (colonIndex < 0 || json[colonIndex] != ':') + { + sb.Append(DATA_KEY); + i = keyIndex + DATA_KEY.Length; + continue; + } + sb.Append(DATA_KEY); + sb.Append(':'); + + int valueIndex = FindNextNonWhitespace(json, colonIndex + 1); + if (valueIndex < 0) + { + i = json.Length; + break; + } + + if (json[valueIndex] != '"') + { + int copyEnd = FindValueEnd(json, valueIndex); + sb.Append(json, valueIndex, copyEnd - valueIndex); + i = copyEnd; + continue; + } + + int stringEnd; + string stringContent = ReadJsonStringContent(json, valueIndex, out stringEnd); + string decoded = DecodeJsonString(stringContent); + string trimmed = decoded == null ? string.Empty : decoded.TrimStart(); + if (trimmed.StartsWith("{") || trimmed.StartsWith("[")) + sb.Append(decoded); + else + sb.Append('"').Append(EncodeJsonString(decoded)).Append('"'); + i = stringEnd; + } + return sb.ToString(); + } + + public static string ConvertRawObjectDataToEscapedString(string json) + { + if (string.IsNullOrEmpty(json)) return json; + var sb = new System.Text.StringBuilder(json.Length + 64); + int i = 0; + while (i < json.Length) + { + int keyIndex = json.IndexOf(DATA_KEY, i, StringComparison.Ordinal); + if (keyIndex < 0) + { + sb.Append(json, i, json.Length - i); + break; + } + + sb.Append(json, i, keyIndex - i); + int colonIndex = FindNextNonWhitespace(json, keyIndex + DATA_KEY.Length); + if (colonIndex < 0 || json[colonIndex] != ':') + { + sb.Append(DATA_KEY); + i = keyIndex + DATA_KEY.Length; + continue; + } + sb.Append(DATA_KEY); + sb.Append(':'); + + int valueIndex = FindNextNonWhitespace(json, colonIndex + 1); + if (valueIndex < 0) + { + i = json.Length; + break; + } + + char start = json[valueIndex]; + if (start == '"') + { + int stringEnd; + ReadJsonStringContent(json, valueIndex, out stringEnd); + sb.Append(json, valueIndex, stringEnd - valueIndex); + i = stringEnd; + continue; + } + + if (start == '{' || start == '[') + { + int valueEnd = ReadJsonCompositeEnd(json, valueIndex); + string raw = json.Substring(valueIndex, valueEnd - valueIndex); + sb.Append('"').Append(EncodeJsonString(raw)).Append('"'); + i = valueEnd; + continue; + } + + int plainEnd = FindValueEnd(json, valueIndex); + sb.Append('"').Append(EncodeJsonString(json.Substring(valueIndex, plainEnd - valueIndex))).Append('"'); + i = plainEnd; + } + return sb.ToString(); + } + + private static int FindNextNonWhitespace(string text, int start) + { + for (int i = start; i < text.Length; i++) + { + char c = text[i]; + if (c != ' ' && c != '\t' && c != '\n' && c != '\r') + return i; + } + return -1; + } + + private static int FindValueEnd(string text, int start) + { + int i = start; + while (i < text.Length) + { + char c = text[i]; + if (c == ',' || c == '}' || c == ']') + return i; + i++; + } + return text.Length; + } + + private static string ReadJsonStringContent(string json, int quoteStart, out int endIndex) + { + var sb = new System.Text.StringBuilder(); + int i = quoteStart + 1; + bool escaping = false; + while (i < json.Length) + { + char c = json[i++]; + if (escaping) + { + sb.Append(c); + escaping = false; + continue; + } + if (c == '\\') + { + sb.Append(c); + escaping = true; + continue; + } + if (c == '"') + { + endIndex = i; + return sb.ToString(); + } + sb.Append(c); + } + endIndex = json.Length; + return sb.ToString(); + } + + private static int ReadJsonCompositeEnd(string json, int start) + { + int depth = 0; + bool inString = false; + bool escaping = false; + for (int i = start; i < json.Length; i++) + { + char c = json[i]; + if (inString) + { + if (escaping) + { + escaping = false; + } + else if (c == '\\') + { + escaping = true; + } + else if (c == '"') + { + inString = false; + } + continue; + } + + if (c == '"') + { + inString = true; + continue; + } + if (c == '{' || c == '[') depth++; + else if (c == '}' || c == ']') + { + depth--; + if (depth == 0) return i + 1; + } + } + return json.Length; + } + + private static string DecodeJsonString(string s) + { + if (string.IsNullOrEmpty(s)) return string.Empty; + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + if (c != '\\') + { + sb.Append(c); + continue; + } + if (i + 1 >= s.Length) + { + sb.Append('\\'); + break; + } + char n = s[++i]; + switch (n) + { + case '"': sb.Append('"'); break; + case '\\': sb.Append('\\'); break; + case '/': sb.Append('/'); break; + case 'b': sb.Append('\b'); break; + case 'f': sb.Append('\f'); break; + case 'n': sb.Append('\n'); break; + case 'r': sb.Append('\r'); break; + case 't': sb.Append('\t'); break; + case 'u': + if (i + 4 < s.Length) + { + string hex = s.Substring(i + 1, 4); + int code; + if (int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out code)) + { + sb.Append((char) code); + i += 4; + } + else sb.Append("\\u").Append(hex); + } + else sb.Append("\\u"); + break; + default: + sb.Append(n); + break; + } + } + return sb.ToString(); + } + + private static string EncodeJsonString(string s) + { + if (string.IsNullOrEmpty(s)) return string.Empty; + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < s.Length; i++) + { + char c = s[i]; + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < 32) + sb.Append("\\u").Append(((int)c).ToString("x4")); + else + sb.Append(c); + break; + } + } + return sb.ToString(); + } + } + + internal static class JsonDiffPruner + { + public static string PruneDefaults(string currentJson, string defaultJson) + { + if (string.IsNullOrEmpty(currentJson)) return "{}"; + object currentObj; + object defaultObj; + if (!SimpleJson.TryParse(currentJson, out currentObj)) return currentJson; + if (!SimpleJson.TryParse(defaultJson, out defaultObj)) return currentJson; + + var pruned = DiffNode(currentObj, defaultObj); + if (pruned == null) + return "{}"; + return SimpleJson.Stringify(pruned); + } + + public static object PruneParsedDefaults(object currentObj, object defaultObj) + { + return DiffNode(currentObj, defaultObj); + } + + private static object DiffNode(object current, object defaults) + { + if (current == null) + return null; + + var currentDict = current as Dictionary; + var defaultDict = defaults as Dictionary; + if (currentDict != null) + { + var result = new Dictionary(); + foreach (var kv in currentDict) + { + object defaultValue = null; + if (defaultDict != null) + defaultDict.TryGetValue(kv.Key, out defaultValue); + var diff = DiffNode(kv.Value, defaultValue); + if (diff != null) + result[kv.Key] = diff; + } + if (result.Count == 0) + return null; + return result; + } + + var currentList = current as List; + var defaultList = defaults as List; + if (currentList != null) + { + if (currentList.Count == 0) + return null; + + if (defaultList != null && AreListsEqual(currentList, defaultList)) + return null; + + var result = new List(); + for (int i = 0; i < currentList.Count; i++) + { + object defaultItem = null; + if (defaultList != null && i < defaultList.Count) + defaultItem = defaultList[i]; + var diff = DiffNode(currentList[i], defaultItem); + if (diff != null) + result.Add(diff); + } + + if (result.Count == 0) + return null; + return result; + } + + if (AreValuesEqual(current, defaults)) + return null; + return current; + } + + private static bool AreListsEqual(List a, List b) + { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + if (a.Count != b.Count) return false; + for (int i = 0; i < a.Count; i++) + { + if (!AreValuesEqual(a[i], b[i])) + return false; + } + return true; + } + + private static bool AreValuesEqual(object a, object b) + { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + + var aDict = a as Dictionary; + var bDict = b as Dictionary; + if (aDict != null && bDict != null) + { + if (aDict.Count != bDict.Count) return false; + foreach (var kv in aDict) + { + object bv; + if (!bDict.TryGetValue(kv.Key, out bv)) return false; + if (!AreValuesEqual(kv.Value, bv)) return false; + } + return true; + } + + var aList = a as List; + var bList = b as List; + if (aList != null && bList != null) + return AreListsEqual(aList, bList); + + return string.Equals(Convert.ToString(a), Convert.ToString(b), StringComparison.Ordinal); + } + } + + internal static class SimpleJson + { + public static bool TryParse(string json, out object result) + { + result = null; + if (string.IsNullOrEmpty(json)) return false; + try + { + int index = 0; + result = ParseValue(json, ref index); + return true; + } + catch + { + return false; + } + } + + public static string Stringify(object obj) + { + var sb = new System.Text.StringBuilder(); + WriteValue(sb, obj, false, 0); + return sb.ToString(); + } + + public static string Stringify(object obj, bool pretty) + { + var sb = new System.Text.StringBuilder(); + WriteValue(sb, obj, pretty, 0); + return sb.ToString(); + } + + private static object ParseValue(string s, ref int i) + { + SkipWhitespace(s, ref i); + if (i >= s.Length) return null; + char c = s[i]; + if (c == '{') return ParseObject(s, ref i); + if (c == '[') return ParseArray(s, ref i); + if (c == '"') return ParseString(s, ref i); + if (c == 't' || c == 'f') return ParseBool(s, ref i); + if (c == 'n') return ParseNull(s, ref i); + return ParseNumber(s, ref i); + } + + private static Dictionary ParseObject(string s, ref int i) + { + var dict = new Dictionary(); + i++; // { + while (true) + { + SkipWhitespace(s, ref i); + if (i < s.Length && s[i] == '}') + { + i++; + break; + } + var key = ParseString(s, ref i); + SkipWhitespace(s, ref i); + if (i < s.Length && s[i] == ':') i++; + var value = ParseValue(s, ref i); + dict[key] = value; + SkipWhitespace(s, ref i); + if (i < s.Length && s[i] == ',') + { + i++; + continue; + } + if (i < s.Length && s[i] == '}') + { + i++; + break; + } + } + return dict; + } + + private static List ParseArray(string s, ref int i) + { + var list = new List(); + i++; // [ + while (true) + { + SkipWhitespace(s, ref i); + if (i < s.Length && s[i] == ']') + { + i++; + break; + } + list.Add(ParseValue(s, ref i)); + SkipWhitespace(s, ref i); + if (i < s.Length && s[i] == ',') + { + i++; + continue; + } + if (i < s.Length && s[i] == ']') + { + i++; + break; + } + } + return list; + } + + private static string ParseString(string s, ref int i) + { + var sb = new System.Text.StringBuilder(); + i++; // opening quote + while (i < s.Length) + { + var c = s[i++]; + if (c == '"') break; + if (c == '\\' && i < s.Length) + { + var e = s[i++]; + switch (e) + { + case '"': sb.Append('"'); break; + case '\\': sb.Append('\\'); break; + case '/': sb.Append('/'); break; + case 'b': sb.Append('\b'); break; + case 'f': sb.Append('\f'); break; + case 'n': sb.Append('\n'); break; + case 'r': sb.Append('\r'); break; + case 't': sb.Append('\t'); break; + case 'u': + if (i + 3 < s.Length) + { + var hex = s.Substring(i, 4); + int code; + if (int.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out code)) + { + sb.Append((char)code); + i += 4; + } + } + break; + default: sb.Append(e); break; + } + } + else + { + sb.Append(c); + } + } + return sb.ToString(); + } + + private static object ParseNumber(string s, ref int i) + { + int start = i; + while (i < s.Length) + { + var c = s[i]; + if ((c >= '0' && c <= '9') || c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E') + i++; + else + break; + } + var token = s.Substring(start, i - start); + double d; + if (double.TryParse(token, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out d)) + return d; + return 0d; + } + + private static bool ParseBool(string s, ref int i) + { + if (s.IndexOf("true", i, StringComparison.Ordinal) == i) + { + i += 4; + return true; + } + i += 5; + return false; + } + + private static object ParseNull(string s, ref int i) + { + i += 4; + return null; + } + + private static void SkipWhitespace(string s, ref int i) + { + while (i < s.Length) + { + var c = s[i]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\r') i++; + else break; + } + } + + private static void WriteValue(System.Text.StringBuilder sb, object value, bool pretty, int indent) + { + if (value == null) + { + sb.Append("null"); + return; + } + var dict = value as Dictionary; + if (dict != null) + { + WriteObject(sb, dict, pretty, indent); + return; + } + var list = value as List; + if (list != null) + { + WriteArray(sb, list, pretty, indent); + return; + } + var str = value as string; + if (str != null) + { + WriteString(sb, str); + return; + } + if (value is bool) + { + sb.Append((bool)value ? "true" : "false"); + return; + } + sb.Append(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture)); + } + + private static void WriteObject(System.Text.StringBuilder sb, Dictionary dict, bool pretty, int indent) + { + sb.Append('{'); + if (dict.Count == 0) + { + sb.Append('}'); + return; + } + + bool first = true; + foreach (var kv in dict) + { + if (!first) sb.Append(','); + if (pretty) + { + sb.Append('\n'); + AppendIndent(sb, indent + 1); + } + first = false; + WriteString(sb, kv.Key); + sb.Append(pretty ? ": " : ":"); + WriteValue(sb, kv.Value, pretty, indent + 1); + } + if (pretty) + { + sb.Append('\n'); + AppendIndent(sb, indent); + } + sb.Append('}'); + } + + private static void WriteArray(System.Text.StringBuilder sb, List list, bool pretty, int indent) + { + sb.Append('['); + if (list.Count == 0) + { + sb.Append(']'); + return; + } + + for (int i = 0; i < list.Count; i++) + { + if (i > 0) sb.Append(','); + if (pretty) + { + sb.Append('\n'); + AppendIndent(sb, indent + 1); + } + WriteValue(sb, list[i], pretty, indent + 1); + } + if (pretty) + { + sb.Append('\n'); + AppendIndent(sb, indent); + } + sb.Append(']'); + } + + private static void AppendIndent(System.Text.StringBuilder sb, int indent) + { + for (int i = 0; i < indent; i++) + { + sb.Append(" "); + } + } + + private static void WriteString(System.Text.StringBuilder sb, string s) + { + sb.Append('"'); + for (int i = 0; i < s.Length; i++) + { + var c = s[i]; + switch (c) + { + case '"': sb.Append("\\\""); break; + case '\\': sb.Append("\\\\"); break; + case '\b': sb.Append("\\b"); break; + case '\f': sb.Append("\\f"); break; + case '\n': sb.Append("\\n"); break; + case '\r': sb.Append("\\r"); break; + case '\t': sb.Append("\\t"); break; + default: + if (c < 32) + sb.Append("\\u").Append(((int)c).ToString("x4")); + else + sb.Append(c); + break; + } + } + sb.Append('"'); + } + } +} diff --git a/Runtime/Utilities/ChartJsonKit.cs.meta b/Runtime/Utilities/ChartJsonKit.cs.meta new file mode 100644 index 00000000..ca5adf57 --- /dev/null +++ b/Runtime/Utilities/ChartJsonKit.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 529ec33cd4fb6466784b3a682204428c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Utilities/ResourceRefHandler.cs b/Runtime/Utilities/ResourceRefHandler.cs new file mode 100644 index 00000000..c5f302f6 --- /dev/null +++ b/Runtime/Utilities/ResourceRefHandler.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +#if UNITY_EDITOR +using UnityEditor; +#endif +#if dUI_TextMeshPro +using TMPro; +#endif + +namespace XCharts.Runtime +{ + /// + /// Portable resource reference that supports a multi-level lookup strategy: + /// GUID → path → name → fallbackName → null/default + /// + [Serializable] + public class ResourceRef + { + /// Unity AssetDatabase GUID (editor-only, same project). + public string guid; + /// Asset path relative to project root e.g. "Assets/Fonts/Arial.ttf". + public string path; + /// Asset.name used for cross-project name search. + public string name; + /// Optional secondary name (system font alias, built-in default, etc.). + public string fallbackName; + /// Optional Base64-encoded asset bytes for full portability (< 100 KB recommended). + public string base64; + + public bool IsEmpty() + { + return string.IsNullOrEmpty(guid) + && string.IsNullOrEmpty(path) + && string.IsNullOrEmpty(name) + && string.IsNullOrEmpty(fallbackName) + && string.IsNullOrEmpty(base64); + } + + public override string ToString() + { + return string.Format("ResourceRef{{name={0}, path={1}, guid={2}}}", name, path, guid); + } + } + + /// + /// Handles serialization and resolution of Unity asset references for chart JSON export/import. + /// Supports Font, TMP_FontAsset, Sprite, Material, Texture2D. + /// + public static class ResourceRefHandler + { + // ─── Serialize ───────────────────────────────────────────────────────────── + + /// + /// Serializes a Unity Object into a portable ResourceRef. + /// + public static ResourceRef Serialize(UnityEngine.Object asset, bool includeBase64 = false) + { + if (asset == null) return null; + + var refData = new ResourceRef + { + name = asset.name + }; + +#if UNITY_EDITOR + var assetPath = AssetDatabase.GetAssetPath(asset); + if (!string.IsNullOrEmpty(assetPath)) + { + refData.path = assetPath; + refData.guid = AssetDatabase.AssetPathToGUID(assetPath); + } +#endif + + // Optional base64 embedding for portability + if (includeBase64) + { + var b64 = TryEncodeBase64(asset); + if (!string.IsNullOrEmpty(b64)) + refData.base64 = b64; + } + + return refData; + } + + /// + /// Serializes a Font with a fallback name hint. + /// + public static ResourceRef SerializeFont(Font font, string fallbackName = null) + { + if (font == null) return null; + var refData = Serialize(font) ?? new ResourceRef(); + refData.fallbackName = fallbackName ?? "Arial"; + return refData; + } + +#if dUI_TextMeshPro + /// + /// Serializes a TMP_FontAsset with a fallback name hint. + /// + public static ResourceRef SerializeTMPFont(TMP_FontAsset font, string fallbackName = null) + { + if (font == null) return null; + var refData = Serialize(font) ?? new ResourceRef(); + refData.fallbackName = fallbackName ?? "LiberationSans SDF"; + return refData; + } +#endif + + // ─── Resolve ──────────────────────────────────────────────────────────────── + + /// + /// Attempts to resolve a ResourceRef back to a Unity asset using the fallback chain:
+ /// GUID → path → name → fallbackName → null + ///
+ public static T TryResolve(ResourceRef refData) where T : UnityEngine.Object + { + if (refData == null || refData.IsEmpty()) return null; + +#if UNITY_EDITOR + // 1. GUID lookup (same project, exact) + if (!string.IsNullOrEmpty(refData.guid)) + { + var guidPath = AssetDatabase.GUIDToAssetPath(refData.guid); + if (!string.IsNullOrEmpty(guidPath)) + { + var asset = AssetDatabase.LoadAssetAtPath(guidPath); + if (asset != null) return asset; + } + } + + // 2. Path-based lookup + if (!string.IsNullOrEmpty(refData.path)) + { + var asset = AssetDatabase.LoadAssetAtPath(refData.path); + if (asset != null) return asset; + } + + // 3. Name-based search across project + if (!string.IsNullOrEmpty(refData.name)) + { + var found = FindAssetByName(refData.name); + if (found != null) return found; + } +#endif + + // 4. Resources.Load by name + if (!string.IsNullOrEmpty(refData.name)) + { + var asset = Resources.Load(refData.name); + if (asset != null) return asset; + } + + // 5. Fallback name + if (!string.IsNullOrEmpty(refData.fallbackName)) + { +#if UNITY_EDITOR + var found = FindAssetByName(refData.fallbackName); + if (found != null) return found; +#endif + var asset = Resources.Load(refData.fallbackName); + if (asset != null) return asset; + } + + // 6. Base64 decode + if (!string.IsNullOrEmpty(refData.base64)) + { + var decoded = TryDecodeBase64(refData.base64, refData.name ?? "imported_asset"); + if (decoded != null) return decoded; + } + + if (!IsUnityBuiltinDefaultResourceRef(refData)) + Debug.LogWarning(string.Format("[XCharts] ResourceRefHandler: Could not resolve asset '{0}'. Using default.", refData)); + return null; + } + + private static bool IsUnityBuiltinDefaultResourceRef(ResourceRef refData) + { + if (refData == null) return false; + + bool pathIsBuiltin = !string.IsNullOrEmpty(refData.path) + && refData.path.IndexOf("Library/unity default resources", StringComparison.OrdinalIgnoreCase) >= 0; + + bool guidIsBuiltin = !string.IsNullOrEmpty(refData.guid) + && string.Equals(refData.guid, "0000000000000000e000000000000000", StringComparison.OrdinalIgnoreCase); + + bool nameIsBuiltinFont = string.Equals(refData.name, "Arial", StringComparison.OrdinalIgnoreCase) + || string.Equals(refData.fallbackName, "Arial", StringComparison.OrdinalIgnoreCase) +#if dUI_TextMeshPro + || string.Equals(refData.name, "LiberationSans SDF", StringComparison.OrdinalIgnoreCase) + || string.Equals(refData.fallbackName, "LiberationSans SDF", StringComparison.OrdinalIgnoreCase) +#endif + ; + + return pathIsBuiltin || guidIsBuiltin || nameIsBuiltinFont; + } + +#if UNITY_EDITOR + private static T FindAssetByName(string assetName) where T : UnityEngine.Object + { + string typeName = typeof(T).Name; + var guids = AssetDatabase.FindAssets(string.Format("{0} t:{1}", assetName, typeName)); + foreach (var g in guids) + { + var p = AssetDatabase.GUIDToAssetPath(g); + var asset = AssetDatabase.LoadAssetAtPath(p); + if (asset != null && asset.name == assetName) + return asset; + } + // Looser match (name contains) + foreach (var g in guids) + { + var p = AssetDatabase.GUIDToAssetPath(g); + var asset = AssetDatabase.LoadAssetAtPath(p); + if (asset != null) return asset; + } + return null; + } +#endif + + // ─── Base64 helpers ────────────────────────────────────────────────────────── + + private static string TryEncodeBase64(UnityEngine.Object asset) + { + var texture = asset as Texture2D; + if (texture != null) + return Convert.ToBase64String(texture.EncodeToPNG()); + // Font/Material: not trivially serializable as bytes at runtime; skip. + return null; + } + + private static T TryDecodeBase64(string base64, string assetName) where T : UnityEngine.Object + { + try + { + if (typeof(T) == typeof(Texture2D) || typeof(T) == typeof(Sprite)) + { + var bytes = Convert.FromBase64String(base64); + var tex = new Texture2D(2, 2); + if (tex.LoadImage(bytes)) + { + tex.name = assetName; + if (typeof(T) == typeof(Sprite)) + { + var sprite = Sprite.Create(tex, + new Rect(0, 0, tex.width, tex.height), + new Vector2(0.5f, 0.5f)); + sprite.name = assetName; + return sprite as T; + } + return tex as T; + } + } + } + catch (Exception ex) + { + Debug.LogWarning(string.Format("[XCharts] ResourceRefHandler: Base64 decode failed: {0}", ex.Message)); + } + return null; + } + + // ─── Convenience overloads ─────────────────────────────────────────────────── + + public static Font TryResolveFont(ResourceRef refData) + { + return TryResolve(refData); + } + + public static Sprite TryResolveSprite(ResourceRef refData) + { + return TryResolve(refData); + } + + public static Material TryResolveMaterial(ResourceRef refData) + { + return TryResolve(refData); + } + + public static Texture2D TryResolveTexture(ResourceRef refData) + { + return TryResolve(refData); + } + +#if dUI_TextMeshPro + public static TMP_FontAsset TryResolveTMPFont(ResourceRef refData) + { + return TryResolve(refData); + } +#endif + } +} diff --git a/Runtime/Utilities/ResourceRefHandler.cs.meta b/Runtime/Utilities/ResourceRefHandler.cs.meta new file mode 100644 index 00000000..6863d189 --- /dev/null +++ b/Runtime/Utilities/ResourceRefHandler.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9c3e7340177cb43e488f9f9547ceea7b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Utilities/UIComponentJsonKit.cs b/Runtime/Utilities/UIComponentJsonKit.cs new file mode 100644 index 00000000..ddaa2e45 --- /dev/null +++ b/Runtime/Utilities/UIComponentJsonKit.cs @@ -0,0 +1,299 @@ +using System; +using System.Reflection; +using UnityEngine; + +namespace XCharts.Runtime +{ + [Serializable] + public class UIComponentJson + { + public string schemaVersion = "1.0"; + public string componentType; + public string componentVersion; + public string exportedAt; + public string data; + } + + public static class UIComponentJsonSerializer + { + public static string Serialize(UIComponent component, bool prettyPrint = true) + { + if (component == null) throw new ArgumentNullException("component"); + + var currentJson = JsonUtility.ToJson(component); + var defaultJson = GetDefaultInstanceJson(component.GetType()); + var dataJson = BuildDataJson(component.GetType(), currentJson, defaultJson); + + var dto = new UIComponentJson + { + schemaVersion = "1.0", + componentType = component.GetType().Name, + componentVersion = XChartsMgr.version, + exportedAt = DateTime.UtcNow.ToString("o"), + data = dataJson + }; + + var json = JsonUtility.ToJson(dto, prettyPrint); + json = ChartJsonDataFieldCodec.ConvertEscapedDataStringToRawObject(json); + if (prettyPrint) + { + object parsedJson; + if (SimpleJson.TryParse(json, out parsedJson)) + json = SimpleJson.Stringify(parsedJson, true); + } + return json; + } + + private static string BuildDataJson(Type componentType, string currentJson, string defaultJson) + { + if (componentType == null) + return JsonDiffPruner.PruneDefaults(currentJson, defaultJson); + + if (string.Equals(componentType.Name, "UITable", StringComparison.Ordinal)) + return PruneUITableData(componentType, currentJson, defaultJson); + + return JsonDiffPruner.PruneDefaults(currentJson, defaultJson); + } + + private static string PruneUITableData(Type tableType, string currentJson, string defaultJson) + { + object currentParsed; + object defaultParsed; + if (!SimpleJson.TryParse(currentJson, out currentParsed)) + return JsonDiffPruner.PruneDefaults(currentJson, defaultJson); + if (!SimpleJson.TryParse(defaultJson, out defaultParsed)) + return JsonDiffPruner.PruneDefaults(currentJson, defaultJson); + + var currentRoot = currentParsed as System.Collections.Generic.Dictionary; + var defaultRoot = defaultParsed as System.Collections.Generic.Dictionary; + if (currentRoot == null) + return JsonDiffPruner.PruneDefaults(currentJson, defaultJson); + + var prunedRootObj = JsonDiffPruner.PruneParsedDefaults(currentRoot, defaultRoot); + var prunedRoot = prunedRootObj as System.Collections.Generic.Dictionary; + if (prunedRoot == null) + prunedRoot = new System.Collections.Generic.Dictionary(); + + object currentRowsObj; + if (!currentRoot.TryGetValue("m_Data", out currentRowsObj)) + return SimpleJson.Stringify(prunedRoot); + + var currentRows = currentRowsObj as System.Collections.Generic.List; + if (currentRows == null) + return SimpleJson.Stringify(prunedRoot); + + var rowDefaultObj = CreateDefaultParsedObject(tableType.Assembly, "XCharts.Runtime.UI.TableRow"); + if (rowDefaultObj == null) + return JsonDiffPruner.PruneDefaults(currentJson, defaultJson); + + var cellDefaultObj = CreateDefaultParsedObject(tableType.Assembly, "XCharts.Runtime.UI.TableCell"); + + var prunedRows = new System.Collections.Generic.List(currentRows.Count); + for (int i = 0; i < currentRows.Count; i++) + { + var prunedRow = PruneTableRowKeepCellShape(currentRows[i], rowDefaultObj, cellDefaultObj); + prunedRows.Add(prunedRow ?? new System.Collections.Generic.Dictionary()); + } + + prunedRoot["m_Data"] = prunedRows; + return SimpleJson.Stringify(prunedRoot); + } + + private static object PruneTableRowKeepCellShape(object rowObj, object rowDefaultObj, object cellDefaultObj) + { + var rowDict = rowObj as System.Collections.Generic.Dictionary; + if (rowDict == null) + return JsonDiffPruner.PruneParsedDefaults(rowObj, rowDefaultObj); + + object rawCells; + rowDict.TryGetValue("m_Data", out rawCells); + var currentCells = rawCells as System.Collections.Generic.List; + + var prunedRowObj = JsonDiffPruner.PruneParsedDefaults(rowObj, rowDefaultObj); + var prunedRowDict = prunedRowObj as System.Collections.Generic.Dictionary; + if (prunedRowDict == null) + prunedRowDict = new System.Collections.Generic.Dictionary(); + + if (currentCells != null) + { + var prunedCells = new System.Collections.Generic.List(currentCells.Count); + for (int i = 0; i < currentCells.Count; i++) + { + var prunedCell = cellDefaultObj != null + ? JsonDiffPruner.PruneParsedDefaults(currentCells[i], cellDefaultObj) + : JsonDiffPruner.PruneParsedDefaults(currentCells[i], null); + prunedCells.Add(prunedCell ?? new System.Collections.Generic.Dictionary()); + } + prunedRowDict["m_Data"] = prunedCells; + } + + if (prunedRowDict.Count == 0) + return null; + return prunedRowDict; + } + + private static object CreateDefaultParsedObject(Assembly assembly, string fullTypeName) + { + if (assembly == null || string.IsNullOrEmpty(fullTypeName)) + return null; + + try + { + var type = assembly.GetType(fullTypeName); + if (type == null) + return null; + + var instance = Activator.CreateInstance(type); + if (instance == null) + return null; + + var json = JsonUtility.ToJson(instance); + object parsed; + if (!SimpleJson.TryParse(json, out parsed)) + return null; + return parsed; + } + catch + { + return null; + } + } + + private static string GetDefaultInstanceJson(Type type) + { + if (type == null) return "{}"; + + GameObject tempObject = null; + try + { + if (typeof(MonoBehaviour).IsAssignableFrom(type)) + { + tempObject = new GameObject("__XCharts_UIJson_Default__"); + tempObject.hideFlags = HideFlags.HideAndDontSave; + var defaultComponent = tempObject.AddComponent(type) as UIComponent; + if (defaultComponent != null) + return JsonUtility.ToJson(defaultComponent); + return "{}"; + } + + var instance = Activator.CreateInstance(type); + if (instance == null) + return "{}"; + return JsonUtility.ToJson(instance); + } + catch + { + return "{}"; + } + finally + { + if (tempObject != null) + { +#if UNITY_EDITOR + UnityEngine.Object.DestroyImmediate(tempObject); +#else + UnityEngine.Object.Destroy(tempObject); +#endif + } + } + } + } + + public static class UIComponentJsonDeserializer + { + private const string LOG_TAG = "[XCharts] UIComponentJsonDeserializer"; + + public static void Deserialize(string json, UIComponent target) + { + if (string.IsNullOrEmpty(json)) throw new ArgumentNullException("json"); + if (target == null) throw new ArgumentNullException("target"); + + json = ChartJsonDataFieldCodec.ConvertRawObjectDataToEscapedString(json); + + UIComponentJson dto; + try + { + dto = JsonUtility.FromJson(json); + } + catch (Exception ex) + { + throw new ArgumentException(string.Format("Invalid JSON format: {0}", ex.Message), ex); + } + + if (dto == null || string.IsNullOrEmpty(dto.schemaVersion)) + throw new ArgumentException("JSON does not appear to be a valid XCharts UI component export (missing schemaVersion)."); + if (!dto.schemaVersion.StartsWith("1.")) + throw new ArgumentException(string.Format("Unsupported schema version '{0}'. This version only supports '1.x'.", dto.schemaVersion)); + + ValidateComponentType(dto.componentType, target); + + if (!string.IsNullOrEmpty(dto.data)) + JsonUtility.FromJsonOverwrite(dto.data, target); + + target.RefreshAllComponent(); + target.RefreshGraph(); + +#if UNITY_EDITOR + UnityEditor.EditorUtility.SetDirty(target); +#endif + Debug.Log(string.Format("{0}: Import complete for '{1}'.", LOG_TAG, target.GetType().Name)); + } + + private static void ValidateComponentType(string typeName, UIComponent target) + { + if (string.IsNullOrEmpty(typeName) || target == null) return; + + var resolved = ResolveType(typeName); + if (resolved == null) return; + + if (!resolved.IsAssignableFrom(target.GetType())) + throw new ArgumentException(string.Format("JSON is for UI component '{0}', target is '{1}'.", resolved.Name, target.GetType().Name)); + } + + private static Type ResolveType(string typeName) + { + if (string.IsNullOrEmpty(typeName)) return null; + + var type = Type.GetType(typeName); + if (type != null) return type; + + var shortName = typeName.Split(',')[0].Trim(); + var simpleName = shortName.Contains(".") ? shortName.Substring(shortName.LastIndexOf(".") + 1) : shortName; + + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + type = asm.GetType(shortName); + if (type != null) return type; + + var types = asm.GetTypes(); + for (int i = 0; i < types.Length; i++) + { + var candidate = types[i]; + if (candidate == null) continue; + if (string.Equals(candidate.Name, simpleName, StringComparison.Ordinal)) + return candidate; + } + } + catch (ReflectionTypeLoadException rtle) + { + var partialTypes = rtle.Types; + if (partialTypes == null) continue; + for (int i = 0; i < partialTypes.Length; i++) + { + var candidate = partialTypes[i]; + if (candidate == null) continue; + if (string.Equals(candidate.Name, simpleName, StringComparison.Ordinal)) + return candidate; + } + } + catch + { + } + } + + return null; + } + } +} diff --git a/Runtime/Utilities/UIComponentJsonKit.cs.meta b/Runtime/Utilities/UIComponentJsonKit.cs.meta new file mode 100644 index 00000000..522e6f3b --- /dev/null +++ b/Runtime/Utilities/UIComponentJsonKit.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 20febe04d2ef346e196ab69da9c1d7d4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: