@yangyongtao/gaea 1.1.9 → 1.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,9 +9,106 @@
9
9
  * await app.ready; // 可选,等待初始化完成
10
10
  */
11
11
 
12
+ import { Group, Tween, Easing } from '@tweenjs/tween.js';
13
+ window.TWEEN = { Group, Tween, Easing };
14
+
12
15
  import Gaea from '../public/index.js';
13
16
 
14
17
 
18
+ // ==================== Loading 动画 ====================
19
+
20
+ (function injectLoadingStyle() {
21
+ if (document.getElementById('gaea-loading-style')) return;
22
+ const style = document.createElement('style');
23
+ style.id = 'gaea-loading-style';
24
+ style.textContent = `
25
+ #gaea-loading-overlay {
26
+ position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
27
+ background: #0a1628; z-index: 99999;
28
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
29
+ font-family: 'Microsoft YaHei', sans-serif; transition: opacity 0.5s;
30
+ }
31
+ #gaea-loading-overlay .gaea-spinner {
32
+ width: 48px; height: 48px; border: 4px solid rgba(255,255,255,0.2);
33
+ border-top-color: #3b82f6; border-radius: 50%;
34
+ animation: gaea-spin 0.8s linear infinite;
35
+ }
36
+ @keyframes gaea-spin { to { transform: rotate(360deg); } }
37
+ #gaea-loading-overlay .gaea-text {
38
+ color: rgba(255,255,255,0.7); margin-top: 20px; font-size: 14px; letter-spacing: 2px;
39
+ }
40
+ #gaea-loading-overlay.gaea-fadeout { opacity: 0; pointer-events: none; }
41
+ #gaea-reset-btn {
42
+ position: fixed; top: 16px; right: 16px; z-index: 99990;
43
+ width: 36px; height: 36px; border-radius: 6px; border: none;
44
+ background: rgba(0,0,0,0.5); color: #fff; cursor: pointer;
45
+ font-size: 16px; display: flex; align-items: center; justify-content: center;
46
+ transition: background 0.2s; backdrop-filter: blur(4px);
47
+ }
48
+ #gaea-reset-btn:hover { background: rgba(0,0,0,0.7); }
49
+ /* 屏幕图标样式(对齐 ht_3dview) */
50
+ .gaea-locationLineStyle {
51
+ position: absolute; overflow: hidden; z-index: 999; pointer-events: auto;
52
+ user-select: none; -webkit-user-select: none;
53
+ }
54
+ .gaea-locationLineStyle .text-wrapper {
55
+ display: flex; justify-content: center; min-height: 1.5rem;
56
+ font-size: 0.8rem; background: rgba(29,32,45,0.85);
57
+ border-radius: 2px; padding: 2px 6px; margin-bottom: 4px; white-space: nowrap;
58
+ }
59
+ .gaea-locationLineStyle .text-content {
60
+ color: #fff; text-align: center; line-height: 1.2rem;
61
+ }
62
+ .gaea-locationLineStyle .image-wrapper {
63
+ display: flex; justify-content: center;
64
+ }
65
+ .gaea-locationLineStyle .image-wrapper img {
66
+ width: 48px; height: 48px; object-fit: contain; display: block;
67
+ }
68
+ .gaea-iconscreen {
69
+ position: absolute; overflow: hidden; width: 169px; height: 55px;
70
+ user-select: none; -webkit-user-select: none; z-index: 999; pointer-events: auto;
71
+ }
72
+ .gaea-iconscreen img {
73
+ position: absolute; width: 100%; height: 100%;
74
+ object-fit: cover; z-index: 1;
75
+ }
76
+ .gaea-iconscreen .content {
77
+ display: flex; position: relative; width: 100%; min-height: 100%;
78
+ z-index: 1; align-items: center;
79
+ }
80
+ .gaea-iconscreen .box { width: 35px; height: 100%; }
81
+ .gaea-iconscreen .text {
82
+ flex: 1; height: 100%; display: flex; align-items: center; justify-content: center;
83
+ color: #fff; font-size: 16px; text-align: center; white-space: nowrap;
84
+ }
85
+ .gaea-iconGif {
86
+ position: absolute; overflow: hidden; width: 48px; height: 48px;
87
+ user-select: none; -webkit-user-select: none; z-index: 999; pointer-events: auto;
88
+ }
89
+ .gaea-iconGif img {
90
+ width: 100%; height: 100%; object-fit: contain; display: block;
91
+ }
92
+ `;
93
+ document.head.appendChild(style);
94
+ })();
95
+
96
+ function showLoading() {
97
+ if (document.getElementById('gaea-loading-overlay')) return;
98
+ const overlay = document.createElement('div');
99
+ overlay.id = 'gaea-loading-overlay';
100
+ overlay.innerHTML = '<div class="gaea-spinner"></div><div class="gaea-text">GAEA 引擎加载中...</div>';
101
+ document.body.appendChild(overlay);
102
+ }
103
+
104
+ function hideLoading() {
105
+ const overlay = document.getElementById('gaea-loading-overlay');
106
+ if (!overlay) return;
107
+ overlay.classList.add('gaea-fadeout');
108
+ setTimeout(() => overlay.remove(), 600);
109
+ }
110
+
111
+
15
112
  // ==================== 配置 ====================
16
113
 
17
114
  const defaultConfig = {
@@ -23,21 +120,141 @@ const defaultConfig = {
23
120
  canvasResizePolicy: 2,
24
121
  experimentalVK: false,
25
122
  focusCanvas: true,
26
- /** 是否使用天地图(true 时跳过 getAllServe,直接加载天地图影像) */
27
- useTianditu: false,
28
- /** 天地图 token */
123
+ /**
124
+ * 服务模式:
125
+ * 'platform' - 服务平台:影像走 serveUrl 的 WMTS,并加载地形、贴地线
126
+ * 'online' - 在线:仅加载天地图影像,不加载地形和贴地线
127
+ */
128
+ serviceMode: 'platform',
129
+ /** 天地图 token(在线模式使用) */
29
130
  tiandituToken: '63201cfd7df1e1bff7d88bb8baa32151',
30
131
  /** 天地图最小/最大层级 */
31
132
  tiandituLevelRange: [0, 18],
32
- /** 默认加载的影像图层(useTianditu=false 时生效) */
133
+ /** 默认加载的影像图层(服务平台模式生效) */
33
134
  defaultLayers: [
34
135
  { layerName: 'earthland', forMat: 'image/png', min: 0, max: 18, transparency: 0.3 },
35
136
  { layerName: '苍南L19', forMat: 'image/png', min: 5, max: 17 },
36
137
  ],
138
+ /** 默认加载的地形服务(走 serveUrl,url 为相对路径或完整地址;空数组则不加载) */
139
+ defaultTerrains: [
140
+ { name: '苍南县地形', url: 'htc/service/tms/1.0.0/苍南县地形@EPSG%3A4326@terrain/' },
141
+ ],
142
+ /** 默认加载的贴地线(矢量线服务,依赖 WMTS 服务列表;空数组则不加载) */
143
+ defaultGroundLines: [
144
+ { name: '苍南县边界', range: [0, 9], color: [255, 255, 0], index: 100 },
145
+ { name: '苍南县镇边界', range: [9, 15], color: [0, 255, 0], index: 80 },
146
+ { name: '苍南县村边界', range: [15, 19], color: [255, 255, 255], index: 60 },
147
+ { name: '沿浦海塘工程范围线', range: [0, 21], color: [0, 255, 255], index: 120 },
148
+ ],
37
149
  /** 默认相机视角 */
38
150
  defaultCameraPosition: '27.3680928977018,120.414139621887,0,109379.690132486,0,0',
39
151
  /** 引擎加载进度回调 */
40
152
  onProgress: null,
153
+ /** DataHub API 地址(图层树数据接口),为空则只用默认数据 */
154
+ dataHubApiUrl: '',
155
+ /** 图层树分类数据(默认数据,接口数据会合并进来) */
156
+ layerCategories: [
157
+ { key: 'seawall', text: '海塘', visible: true, children: [
158
+ { text: '沿浦海塘', position: { x: 27.220705, y: 120.457998, z: 20 } },
159
+ { text: '霞关海塘', position: { x: 27.171059, y: 120.467901, z: 20 } },
160
+ { text: '三茆海塘', position: { x: 27.193284, y: 120.464411, z: 20 } },
161
+ { text: '大渔海塘', position: { x: 27.39027, y: 120.599554, z: 20 } },
162
+ { text: '信智海塘', position: { x: 27.300806, y: 120.544077, z: 20 } },
163
+ { text: '下在海塘', position: { x: 27.215789, y: 120.445849, z: 10 } },
164
+ { text: '木林海塘', position: { x: 27.193183, y: 120.424486, z: 20 } },
165
+ { text: '雾城海塘', position: { x: 27.27682, y: 120.518917, z: 20 } },
166
+ { text: '渔岙海塘', position: { x: 27.403744, y: 120.557879, z: 20 } },
167
+ { text: '流岐岙海塘', position: { x: 27.318176, y: 120.542761, z: 20 } },
168
+ ]},
169
+ { key: 'hydrometricStations', text: '水文站', visible: true, children: [
170
+ { text: '灵溪', stcd: '7010CK01', sttp: 'ZQ', position: { x: 27.5014, y: 120.3847, z: 100 } },
171
+ { text: '流石', stcd: '7010CK02', sttp: 'ZQ', position: { x: 27.5029, y: 120.4615, z: 100 } },
172
+ { text: '观美', stcd: '7050CK02', sttp: 'ZQ', position: { x: 27.4658, y: 120.364, z: 100 } },
173
+ { text: '桂兰溪', stcd: '7061CK68', sttp: 'ZQ', position: { x: 27.4584, y: 120.3071, z: 100 } },
174
+ ]},
175
+ { key: 'tideStations', text: '潮位站', visible: true, children: [] },
176
+ { key: 'riverStations', text: '河道水位站', visible: true, children: [] },
177
+ { key: 'rainStations', text: '雨量站', visible: true, children: [] },
178
+ { key: 'reservoirs', text: '水库', visible: true, children: [] },
179
+ { key: 'sluice', text: '水闸', visible: true, children: [
180
+ { text: '联盟水闸', position: { x: 27.200462, y: 120.465635, z: 50 } },
181
+ { text: '沿浦水闸', position: { x: 27.222987, y: 120.453175, z: 20 } },
182
+ { text: '下在水闸', position: { x: 27.221816, y: 120.445182, z: 50 } },
183
+ { text: '下在海塘纳排闸', position: { x: 27.219978, y: 120.445672, z: 50 } },
184
+ { text: '岭尾水闸', position: { x: 27.215762, y: 120.462235, z: 40 } },
185
+ { text: '岭尾新闸', position: { x: 27.210369, y: 120.458532, z: 10 } },
186
+ ]},
187
+ { key: 'secData', text: '断面数据', visible: true, children: [
188
+ { text: 'YPDP0+600', position: { x: 27.217692, y: 120.461823, z: 30 },
189
+ indexList: {
190
+ 1: { label: '素填土', details: ['成分: 人工回填黏性土', '厚度: 约1.5m', '作用: 表层覆盖防护'] },
191
+ 2: { label: '淤泥', details: ['类别: 海相淤泥', '颜色: 灰黑色', '状态: 流塑', '含水率: 约55%'] },
192
+ 3: { label: '6.89', details: ['类型: 堤顶设计高程', '数值: 6.89m', '作用: 防洪堤坝顶部标高'] },
193
+ 4: { label: '6.00', details: ['类型: 设计洪水位', '数值: 6.00m', '标准: 重现期P=5%'] },
194
+ 5: { label: '植被护坡', details: ['类型: 草皮护坡', '坡度: 1:2', '作用: 防止坡面雨水冲刷'] },
195
+ 6: { label: '3.00', details: ['类型: 常水位', '数值: 3.00m', '作用: 日常运行水位线'] },
196
+ 7: { label: '砖石混合料填筑', details: ['成分: 砖石与黏土混合', '厚度: 约2.5m', '作用: 堤身填筑主体'] },
197
+ 8: { label: '2.00', details: ['类型: 堤脚高程', '数值: 2.00m', '作用: 堤坝底部基础标高'] },
198
+ 9: { label: '3.94设计洪水P=5%', details: ['类型: 设计洪水位', '频率: 5%(20年一遇)', '数值: 3.94m', '作用: 堤防设计标准水位'] },
199
+ 10: { label: '4.5', details: ['类型: 设计高潮位', '标准: 50年一遇', '数值: 4.45m', '作用: 海堤防潮标准'] },
200
+ 11: { label: '50年一遇设计高潮位4.45m', details: ['类型: 设计高潮位', '重现期: 50年', '数值: 4.45m', '作用: 校核堤顶高程'] },
201
+ },
202
+ favorite: '27.2201168867619,120.460489990829,152.923049719807,297.140390154652,50.2192727515776,0.00126576050689737',
203
+ secAnimation: [{x:27.217703, y:120.46207, z:32}, {x:27.217626, y:120.461815, z:32}, {x:27.217433, y:120.461448, z:32}] },
204
+ { text: 'YPDP0+100', position: { x: 27.223027, y: 120.454653, z: 30 },
205
+ indexList: {
206
+ 1: { label: '淤泥', details: ['类别: 海相淤泥', '颜色: 灰黑色', '状态: 流塑'] },
207
+ 2: { label: '素土', details: ['类别: 天然沉积土', '颜色: 黄褐色', '状态: 可塑', '承载力: 一般'] },
208
+ 3: { label: '砖石混合材料填筑', details: ['成分: 砖石与黏土混合', '厚度: 约2m', '作用: 堤身填筑主体'] },
209
+ 4: { label: '6.98', details: ['类型: 堤顶设计高程', '数值: 6.98m'] },
210
+ 5: { label: '6.00', details: ['类型: 设计洪水位', '数值: 6.00m', '标准: P=5%'] },
211
+ 6: { label: '3.94', details: ['类型: 设计洪水位', '频率: 5%', '数值: 3.94m'] },
212
+ 7: { label: '2.00', details: ['类型: 堤脚高程', '数值: 2.00m'] },
213
+ 8: { label: '4.5', details: ['类型: 设计高潮位', '标准: 50年一遇', '数值: 4.45m'] },
214
+ },
215
+ favorite: '27.2241401069656,120.45184648674,114.05854266096,366.355033916145,42.1285030727423,359.999914392757',
216
+ secAnimation: [{x:27.223145, y:120.454946, z:30}, {x:27.223085, y:120.454756, z:30}, {x:27.222844, y:120.454466, z:30}] },
217
+ { text: 'YPDP0+200', position: { x: 27.222541, y: 120.455453, z: 30 },
218
+ indexList: {
219
+ 0: { label: '淤泥', details: ['类别: 海相淤泥', '颜色: 灰黑色', '状态: 流塑'] },
220
+ 1: { label: '素土', details: ['类别: 天然沉积土', '颜色: 黄褐色', '状态: 可塑'] },
221
+ 2: { label: '砖石混合材料填筑', details: ['成分: 砖石与黏土混合', '作用: 堤身填筑主体'] },
222
+ 3: { label: '6.98', details: ['类型: 堤顶设计高程', '数值: 6.98m'] },
223
+ 4: { label: '6.00', details: ['类型: 设计洪水位', '数值: 6.00m', '标准: P=5%'] },
224
+ 5: { label: '3.94', details: ['类型: 设计洪水位', '频率: 5%', '数值: 3.94m'] },
225
+ 6: { label: '2.00', details: ['类型: 堤脚高程', '数值: 2.00m'] },
226
+ 7: { label: '4.5', details: ['类型: 设计高潮位', '标准: 50年一遇', '数值: 4.45m'] },
227
+ },
228
+ favorite: '27.2239199251089,120.452312356798,114.058543530715,366.288499980401,42.128503423954,359.999913220015',
229
+ secAnimation: [{x:27.222801, y:120.455528, z:30}, {x:27.222681, y:120.455458, z:30}, {x:27.22226, y:120.455318, z:30}] },
230
+ { text: 'YPDP0+750', position: { x: 27.220289, y: 120.459753, z: 30 },
231
+ indexList: {
232
+ 0: { label: '淤泥', details: ['类别: 海相淤泥', '颜色: 灰黑色', '状态: 流塑'] },
233
+ 1: { label: '砖石混合料填筑', details: ['成分: 砖石与黏土混合', '厚度: 约2.5m', '作用: 堤身填筑主体'] },
234
+ 2: { label: '7.00', details: ['类型: 堤顶设计高程', '数值: 7.00m'] },
235
+ 3: { label: '6.00', details: ['类型: 设计洪水位', '数值: 6.00m', '标准: P=5%'] },
236
+ 4: { label: '3.94', details: ['类型: 设计洪水位', '频率: 5%', '数值: 3.94m'] },
237
+ 5: { label: '2.00', details: ['类型: 堤脚高程', '数值: 2.00m'] },
238
+ 6: { label: '4.5', details: ['类型: 设计高潮位', '标准: 50年一遇', '数值: 4.45m'] },
239
+ 7: { label: '植被护坡', details: ['类型: 草皮护坡', '坡度: 1:2', '作用: 防止坡面冲刷'] },
240
+ },
241
+ favorite: '27.2220502555847,120.457317110875,125.021048589548,347.301009802588,44.1512225983844,0.0020422112185327',
242
+ secAnimation: [{x:27.220482, y:120.459976, z:30}, {x:27.220375, y:120.459853, z:30}, {x:27.220072, y:120.459602, z:30}] },
243
+ ]},
244
+ { key: 'uavNests', text: '无人机巢站', visible: true, children: [] },
245
+ { key: 'rescue', text: '隐患', visible: false, children: [
246
+ { text: '隐患1', position: { x: 27.381728, y: 120.587895, z: 20 } },
247
+ { text: '隐患2', position: { x: 27.183096, y: 120.48657, z: 20 } },
248
+ { text: '隐患3', position: { x: 27.313019, y: 120.538779, z: 20 } },
249
+ { text: '隐患4', position: { x: 27.219926, y: 120.455543, z: 20 } },
250
+ { text: '隐患5', position: { x: 27.266007, y: 120.52789, z: 20 } },
251
+ ]},
252
+ { key: 'materials', text: '物资仓库', visible: false, children: [
253
+ { text: '仓库1', position: { x: 27.220674, y: 120.460029, z: 1.179 } },
254
+ { text: '仓库2', position: { x: 27.227569, y: 120.458844, z: 4.265 } },
255
+ { text: '仓库3', position: { x: 27.23543, y: 120.452171, z: 3.29 } },
256
+ ]},
257
+ ],
41
258
  };
42
259
 
43
260
  const _config = { ...defaultConfig };
@@ -53,8 +270,42 @@ export class GaeaApp {
53
270
  this._options = options;
54
271
  this.layers = [];
55
272
  this.tLayers = [];
273
+ this.layerCategories = this.config.layerCategories || [];
274
+ this._screenIconList = [];
275
+ this._secIsClickArr = [];
276
+ this._secScreenIconKeyValue = {};
277
+ this.tweenGroup = new TWEEN.Group();
278
+ this._tourConfig = {
279
+ '沿浦水闸': { direction: [-0.5, 0.3, 0.3], positionList: [
280
+ { name: '位置点0', pos: { x: 27.222747, y: 120.453035, z: 21.4 }, lockOut: [-0.5, 0.3, 0], lockOutAngle: 1.5 },
281
+ { name: '位置点1', pos: { x: 27.222827, y: 120.453089, z: 21.4 }, lockIn: [-0.5, 0.3, 0], lockInAngle: -1.5, lockOut: [-0.5, 0.3, 0], lockOutAngle: 1.5 },
282
+ { name: '位置点2', pos: { x: 27.222909, y: 120.453142, z: 21.4 }, lockIn: [-0.5, 0.3, 0], lockInAngle: -1.5, lockOut: [-0.5, 0.3, 0], lockOutAngle: 1.5 },
283
+ { name: '位置点3', pos: { x: 27.222982, y: 120.453192, z: 21.4 }, lockIn: [-0.5, 0.3, 0], lockInAngle: -1.5 },
284
+ ]},
285
+ '联盟水闸': { direction: [1.5, 0, 0], positionList: [
286
+ { name: '位置点1', pos: { x: 27.200467, y: 120.465716, z: 26 }, lockIn: [0, -0.5, 0], lockInAngle: -1.5, lockOut: [0.5, 0, 0], lockOutAngle: -1.5 },
287
+ { name: '位置点2', pos: { x: 27.200486, y: 120.465716, z: 26 }, lockIn: [0.5, 0, 0], lockInAngle: 1.5, lockOut: [0.5, 0, 0], lockOutAngle: -1.5 },
288
+ { name: '位置点3', pos: { x: 27.200513, y: 120.465716, z: 26 }, lockIn: [0.5, 0, 0], lockInAngle: 1.5, lockOut: [0, 0.5, 0], lockOutAngle: -1.5 },
289
+ { name: '位置点4', pos: { x: 27.200513, y: 120.46561, z: 26 }, lockIn: [0, 0.5, 0], lockInAngle: 1.5, lockOut: [0, 0.5, 0], lockOutAngle: -1.5 },
290
+ { name: '位置点5', pos: { x: 27.200513, y: 120.465524, z: 26 }, lockIn: [0, 0.5, 0], lockInAngle: 1.5, lockOut: [-0.5, 0, 0], lockOutAngle: -1.5 },
291
+ { name: '位置点6', pos: { x: 27.200463, y: 120.465521, z: 26 }, lockIn: [-0.5, 0, 0], lockInAngle: 1.5, lockOut: [0, -0.5, 0], lockOutAngle: -1.5 },
292
+ { name: '位置点7', pos: { x: 27.200467, y: 120.465616, z: 26 }, lockIn: [0, -0.5, 0], lockInAngle: 1.5, lockOut: [0, -0.5, 0], lockOutAngle: -1.5 },
293
+ { name: '位置点8', pos: { x: 27.200467, y: 120.465716, z: 26 }, lockIn: [0, -0.5, 0], lockInAngle: 1.5, lockOut: [0.5, 0, 0], lockOutAngle: -1.5 },
294
+ ]},
295
+ '下在水闸': { direction: [0.5, 0, 0], positionList: [
296
+ { name: '位置点1', pos: { x: 27.221763, y: 120.4452, z: 27.6 }, lockOut: [0, -0.5, 0], lockOutAngle: 1.5 },
297
+ { name: '位置点2', pos: { x: 27.221763, y: 120.445142, z: 27.6 }, lockIn: [0, -0.5, 0], lockInAngle: -1.5, lockOut: [0, -0.5, 0], lockOutAngle: 1.5 },
298
+ { name: '位置点3', pos: { x: 27.221763, y: 120.445084, z: 27.6 }, lockIn: [0, -0.5, 0], lockInAngle: -1.5 },
299
+ ]},
300
+ '岭尾新闸': { direction: [-0.5, 0.5, 0.5], positionList: [] },
301
+ '岭尾水闸': { direction: [-0.4, 0.5, 0.3], positionList: [] },
302
+ };
303
+ console.log('[GaeaApp] constructor layerCategories:', this.layerCategories.length);
56
304
  window.Gaea = Gaea;
57
305
 
306
+ // 显示 loading
307
+ showLoading();
308
+
58
309
  // 自动启动初始化,catch 防止未处理的 Promise rejection
59
310
  this.ready = this._autoInit().catch(err => {
60
311
  console.error('[GaeaApp] 初始化失败:', err);
@@ -82,6 +333,48 @@ export class GaeaApp {
82
333
 
83
334
  // 4. 加载默认图层
84
335
  await this._initLayers();
336
+ console.log('[GaeaApp] 图层加载完成');
337
+
338
+ // 5. 调整模型位置
339
+ console.log('[GaeaApp] 开始调整模型位置...');
340
+ const models = [
341
+ { nodeName: '沿浦水闸', position: '-2876603.788, 4892658.352, 2900208.622', rotation: '-37.24, 162.42, -17.17' },
342
+ { nodeName: '下在水闸', position: '-2875959.024, 4893109.058, 2900101.919', rotation: '-23.42, -165.20, -34.52' },
343
+ { nodeName: '联盟水闸', position: '-2878251.995, 4893011.199, 2897998.160', rotation: '-23.43, -165.98, -33.40' },
344
+ { nodeName: '岭尾新闸', position: '-2877382.212, 4892920.226, 2898965.484', rotation: '7.79, 36.98, 40.83' },
345
+ { nodeName: '岭尾水闸', position: '-2877582.576, 4892520.352, 2899499.184', rotation: '-41.04, 126.37, 6.29' },
346
+ ];
347
+ for (const model of models) {
348
+ try {
349
+ const node = Gaea.World.Instance.GetNode(model.nodeName);
350
+ if (!node) { console.warn(`[GaeaApp] 未找到节点: ${model.nodeName}`); continue; }
351
+ const inst = Gaea.MeshInstance.ConvertBy(node);
352
+ const pos = model.position.split(',').map(v => parseFloat(v.trim()));
353
+ const rot = model.rotation.split(',').map(v => parseFloat(v.trim()));
354
+ if (inst.Translation) inst.Translation = new Gaea.Vector3(pos[0], pos[1], pos[2]);
355
+ if (inst.RotationDegrees) inst.RotationDegrees = new Gaea.Vector3(rot[0], rot[1], rot[2]);
356
+ console.log(`[GaeaApp] 模型 ${model.nodeName} 位置调整完成`);
357
+ } catch (e) {
358
+ console.warn(`[GaeaApp] 调整模型 ${model.nodeName} 失败:`, e.message);
359
+ }
360
+ }
361
+ console.log('[GaeaApp] 所有模型位置调整完成');
362
+
363
+ // 6. 加载 GLTF 模型
364
+ await this._loadGltfModels();
365
+
366
+ // 7. 获取图层树数据(有接口走接口,没接口用默认)
367
+ await this._fetchLayerTreeData();
368
+
369
+ // 同步 layerCategories(修复数据更新未同步的问题)
370
+ this.layerCategories = this.config.layerCategories || [];
371
+ console.log('[GaeaApp] layerCategories 更新后:', this.layerCategories.length);
372
+
373
+ // 8. 初始化屏幕图标
374
+ this._initScreenIcons();
375
+
376
+ hideLoading();
377
+ this._addResetButton();
85
378
  console.log('[GaeaApp] 初始化完成');
86
379
  }
87
380
 
@@ -150,11 +443,11 @@ export class GaeaApp {
150
443
  async _initLayers() {
151
444
  const cfg = this.config;
152
445
 
153
- if (cfg.useTianditu) {
154
- // 天地图模式:不走 getAllServe,直接加载天地图
446
+ if (cfg.serviceMode === 'online') {
447
+ // 在线模式:仅加载天地图影像,不加载地形/贴地线
155
448
  this.addTianditu();
156
449
  } else {
157
- // WMTS 服务模式
450
+ // 服务平台模式:WMTS 影像 + 贴地线 + 地形
158
451
  await this.getAllServe();
159
452
  const list = cfg.defaultLayers;
160
453
  if (list?.length) {
@@ -162,6 +455,10 @@ export class GaeaApp {
162
455
  this.addLayer(layer);
163
456
  }
164
457
  }
458
+ // 贴地线(矢量线服务,依赖 this.layers)
459
+ try { this.addGroundLines(); } catch (e) { console.error('加载贴地线失败:', e); }
460
+ // 地形服务
461
+ try { await this.addInitTerrainServer(); } catch (e) { console.error('加载地形失败:', e); }
165
462
  }
166
463
 
167
464
  const pos = cfg.defaultCameraPosition;
@@ -189,6 +486,338 @@ export class GaeaApp {
189
486
  return store;
190
487
  }
191
488
 
489
+ /** 添加右上角重置按钮 */
490
+ _addResetButton() {
491
+ if (document.getElementById('gaea-reset-btn')) return;
492
+ const btn = document.createElement('button');
493
+ btn.id = 'gaea-reset-btn';
494
+ btn.title = '重置视角';
495
+ btn.innerHTML = '⟳';
496
+ btn.onclick = () => {
497
+ const pos = this.config.defaultCameraPosition;
498
+ if (pos) this.SetPosition(pos);
499
+ // 解锁视角(内部会调 UseAutoVisible 恢复非断面图标)
500
+ this.lockedModesSwitch(false);
501
+ // 恢复被 hiddenScreenWithClass 隐藏的图标 DOM
502
+ document.querySelectorAll('.gaea-locationLineStyle, .gaea-iconscreen, .gaea-iconGif').forEach(el => {
503
+ el.style.display = '';
504
+ });
505
+ // 断面模型缩回
506
+ if (this._raisedSection) {
507
+ this._raisedSection.TranslateObjectLocal(new Gaea.Vector3(0, -44, 0));
508
+ this._raisedSection = null;
509
+ }
510
+ // 重置游览模型位置到初始位置
511
+ if (this._currentTourModel) {
512
+ const name = this._currentTourModel?.Root?.Name;
513
+ const cfg = this._gltfCfgMap?.[name];
514
+ if (cfg) {
515
+ this.updateElementPosition({ element: this._currentTourModel, position: cfg.position, rotate: cfg.rotate, size: cfg.size });
516
+ }
517
+ this._currentTourModel = null;
518
+ this._currentTourIndexPos = 0;
519
+ }
520
+ // 重置断面状态
521
+ this._secIsClickArr = [];
522
+ this._secScreenIconKeyValue = {};
523
+ // 隐藏断面标签
524
+ this._screenIconList.forEach(item => {
525
+ if (item.type === 'dabaSec') {
526
+ item.element.ForceSetVisible(false);
527
+ item.element.EnablePick = false;
528
+ }
529
+ });
530
+ document.querySelectorAll('.waterLevel, .damScreen').forEach(el => el.style.display = 'none');
531
+ // 通知 SecTourView 隐藏
532
+ document.dispatchEvent(new CustomEvent('gaea-reset-view', { bubbles: true }));
533
+ };
534
+ document.body.appendChild(btn);
535
+ }
536
+
537
+ // ---- GLTF 模型 ----
538
+
539
+ async _loadGltfModels() {
540
+ const gltfList = [
541
+ { name: 'YPDP0+600', position: { x: 27.217692, y: 120.461823, z: -44 }, rotate: [0, 198, 0], size: [1, 1, 1], show: true, url: 'gltf/大坝断面600.gltf' },
542
+ { name: 'YPDP0+750', position: { x: 27.220289, y: 120.459753, z: -44 }, rotate: [0, 226, 0], size: [1, 1, 1], show: true, url: 'gltf/大坝断面750.gltf' },
543
+ { name: 'YPDP0+100', position: { x: 27.223027, y: 120.454653, z: -44 }, rotate: [0, 222, 0], size: [1, 1, 1], show: true, url: 'gltf/大坝断面100.gltf' },
544
+ { name: 'YPDP0+200', position: { x: 27.222541, y: 120.455453, z: -44 }, rotate: [0, 246, 0], size: [1, 1, 1], show: true, url: 'gltf/大坝断面200.gltf' },
545
+ { name: '沿浦水闸', position: { x: 27.222866, y: 120.453113, z: 13.25 }, rotate: [0, -30, 0], size: [1, 1, 1], show: false, url: 'gltf/沿浦水闸.gltf', enablePick: true },
546
+ { name: '沿浦泵站', position: { x: 27.222001, y: 120.452509, z: 17 }, rotate: [0, -38, 0], size: [1, 1, 1], show: true, url: 'gltf/沿浦泵站.gltf', enablePick: true },
547
+ { name: '下在水闸', position: { x: 27.221816, y: 120.445182, z: 20 }, rotate: [0, 0, 0], size: [1, 1, 1], show: false, url: 'gltf/下在水闸.gltf', enablePick: true },
548
+ { name: '下在海塘纳排闸', position: { x: 27.219978, y: 120.445672, z: 15 }, rotate: [0, 0, 0], size: [1, 1, 1], show: true, url: 'gltf/下在海塘纳排闸.gltf', enablePick: true },
549
+ { name: '联盟水闸', position: { x: 27.200462, y: 120.465635, z: 20 }, rotate: [0, 0, 0], size: [1, 1, 1], show: false, url: 'gltf/联盟水闸.gltf', enablePick: true },
550
+ { name: '岭尾水闸', position: { x: 27.215762, y: 120.462235, z: 30 }, rotate: [0, -63, 0], size: [1, 1, 1], show: false, url: 'gltf/岭尾水闸.gltf', enablePick: true },
551
+ { name: '岭尾新闸', position: { x: 27.210369, y: 120.458532, z: 0.5 }, rotate: [0, -153, 0], size: [1, 1, 1], show: false, url: 'gltf/岭尾新闸.gltf' },
552
+ { name: '盒子模型', position: { x: 27.241836, y: 120.462750, z: 50 }, rotate: [0, 90, 0], size: [10, 10, 10], show: true, url: 'gltf/box.gltf' },
553
+ { name: 'YPDP0+600游览模型', position: { x: 27.217433, y: 120.461448, z: 32 }, rotate: [0, 90, 0], size: [1, 1, 1], show: true, url: 'gltf/box.gltf' },
554
+ { name: 'YPDP0+750游览模型', position: { x: 27.220072, y: 120.459602, z: 30 }, rotate: [0, 90, 0], size: [1, 1, 1], show: true, url: 'gltf/box.gltf' },
555
+ { name: 'YPDP0+100游览模型', position: { x: 27.222844, y: 120.454466, z: 30 }, rotate: [0, 90, 0], size: [1, 1, 1], show: true, url: 'gltf/box.gltf' },
556
+ { name: 'YPDP0+200游览模型', position: { x: 27.22226, y: 120.455318, z: 30 }, rotate: [0, 90, 0], size: [1, 1, 1], show: true, url: 'gltf/box.gltf' },
557
+ { name: '沿浦水闸游览模型', position: { x: 27.222747, y: 120.453035, z: 21.8 }, rotate: [0, -30, 0], size: [1, 1, 1], show: false, url: 'gltf/box.gltf' },
558
+ { name: '沿浦水闸定位模型', position: { x: 27.230788, y: 120.453009, z: 487.5 }, rotate: [0, 0, 0], size: [1, 1, 1], show: false, url: 'gltf/box.gltf' },
559
+ { name: '下在水闸游览模型', position: { x: 27.221763, y: 120.4452, z: 27.6 }, rotate: [0, 0, 0], size: [1, 1, 1], show: false, url: 'gltf/box.gltf' },
560
+ { name: '联盟水闸游览模型', position: { x: 27.200467, y: 120.465716, z: 26 }, rotate: [0, 180, 0], size: [0.1, 0.1, 0.1], show: false, url: 'gltf/box.gltf' },
561
+ { name: '岭尾水闸游览模型', position: { x: 27.215661, y: 120.462325, z: 34 }, rotate: [0, -66, 0], size: [1, 1, 1], show: false, url: 'gltf/box.gltf' },
562
+ { name: '岭尾新闸游览模型', position: { x: 27.21038, y: 120.458513, z: 4 }, rotate: [0, 30, 0], size: [1, 1, 1], show: false, url: 'gltf/box.gltf' },
563
+ { name: '盒子模型-yanpu', position: { x: 27.231665, y: 120.456095, z: 48.608 }, rotate: [0, 90, 0], size: [10, 10, 10], show: true, url: 'gltf/box.gltf' },
564
+ ];
565
+
566
+ this._gltfCfgMap = {};
567
+ this._gltfElements = {};
568
+ for (const item of gltfList) {
569
+ this._gltfCfgMap[item.name] = item;
570
+ }
571
+
572
+ console.log('[GaeaApp] 开始加载 GLTF 模型...');
573
+ for (const item of gltfList) {
574
+ item.url = encodeURI(item.url);
575
+ try {
576
+ const el = await this.AddGltf(item);
577
+ if (el) this._gltfElements[item.name] = el;
578
+ } catch (e) { console.warn(`[GaeaApp] 模型 ${item.name} 加载失败:`, e.message); }
579
+ }
580
+ console.log('[GaeaApp] GLTF 模型加载完成');
581
+ }
582
+
583
+ AddGltf = ({
584
+ name, url, position, rotate, size, show = true, enablePick = false, useAbsoluteHeight = true,
585
+ children, billboard, clickChilrenNameArray, initFun = () => {},
586
+ clickCallBack = () => {}, clickChilrenCallBack = () => {},
587
+ }) => {
588
+ return new Promise(async (res) => {
589
+ const element = new Gaea.GLTFElement();
590
+ element.UseAbsoluteHeight = useAbsoluteHeight;
591
+ element.ResourceName = url;
592
+ console.log(`模型开始加载: ${name}`);
593
+
594
+ element.OnModelInit(async () => {
595
+ // 大坝断面模型设置 Unshaded
596
+ if (name.includes('YPDP0')) {
597
+ const n = element.Root.GetChildCount();
598
+ for (let i = 0; i < n; i++) {
599
+ const mi = Gaea.MeshInstance.ConvertBy(element.Root.GetChild(i));
600
+ const cnt = mi.Mesh.GetSurfaceCount();
601
+ for (let j = 0; j < cnt; j++) {
602
+ const mtl = Gaea.SpatialMaterial.ConvertBy(mi.Mesh.SurfaceGetMaterial(j));
603
+ mtl.FlagsUnshaded = true;
604
+ }
605
+ }
606
+ }
607
+ console.log(`模型加载完毕: ${name}`);
608
+ initFun();
609
+ element.EnablePick = enablePick;
610
+ Gaea.QueryServer.Instance.Enable = enablePick;
611
+ element.GeographyPosition = new Gaea.Vector3(position.x, position.y, position.z);
612
+ element.TiltHeadingBank = new Gaea.Vector3(rotate[0], rotate[1], rotate[2]);
613
+ element.Root.Scale = new Gaea.Vector3(size[0], size[1], size[2]);
614
+ element.Root.Name = name;
615
+ element.OnHitElement((childName, pos) => clickCallBack(childName, pos));
616
+ element.OnHitChildElement((childName, pos) => clickChilrenCallBack(childName));
617
+ element.Root.Visible = show;
618
+ res(element);
619
+ });
620
+
621
+ Gaea.World.Instance.RenderableObjectList.AddLast(element);
622
+ setTimeout(() => res(element), 20000);
623
+ });
624
+ };
625
+
626
+ // ---- 图层树数据 ----
627
+
628
+ async _fetchLayerTreeData() {
629
+ const cfg = this.config;
630
+ const apiUrl = cfg.dataHubApiUrl;
631
+ if (!apiUrl) {
632
+ console.log('[GaeaApp] 未配置 dataHubApiUrl,使用默认图层树数据');
633
+ return;
634
+ }
635
+ console.log('[GaeaApp] 从 DataHub 获取图层树数据...');
636
+ try {
637
+ const res = await fetch(`${apiUrl}/open/api/5rLGPS6E8Zvr`, { headers: this._dataHubHeaders(cfg) });
638
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
639
+ const json = await res.json();
640
+ if (json.data) this._mergeStationData(json.data);
641
+ console.log('[GaeaApp] 图层树数据更新完成');
642
+ } catch (e) {
643
+ console.warn('[GaeaApp] 接口获取失败,使用默认数据:', e.message);
644
+ }
645
+ }
646
+
647
+ /** DataHub API 签名头(HmacSHA256) */
648
+ _dataHubHeaders(cfg) {
649
+ const appKey = cfg.dataHubAppKey || 'testAppKey';
650
+ const appSecret = cfg.dataHubAppSecret || 'testAppSecret';
651
+ const nonce = Math.random().toString(36).substring(2, 10);
652
+ const timestamp = Date.now();
653
+ const signStr = `${appKey}${timestamp}${nonce}`;
654
+ // 简化版:用 btoa 替代 HmacSHA256(生产环境需用 Web Crypto API)
655
+ const signature = btoa(signStr + appSecret);
656
+ return {
657
+ 'Content-Type': 'application/json',
658
+ 'X-App-Key': appKey,
659
+ 'X-Timestamp': String(timestamp),
660
+ 'X-Nonce': nonce,
661
+ 'X-Signature': signature,
662
+ };
663
+ }
664
+
665
+ /** 将API测站数据合并到默认分类中 */
666
+ _mergeStationData(apiData) {
667
+ const cfg = this.config;
668
+ const sttpMap = {
669
+ ZQ: 'hydrometricStations', RR: 'rainStations',
670
+ ZZ: 'riverStations', TT: 'tideStations',
671
+ DD: 'reservoirs',
672
+ };
673
+ const stations = Array.isArray(apiData) ? apiData : [];
674
+ for (const st of stations) {
675
+ const catKey = sttpMap[st.sttp];
676
+ if (!catKey) continue;
677
+ const cat = cfg.layerCategories.find(c => c.key === catKey);
678
+ if (!cat) continue;
679
+ const exists = cat.children.find(c => c.text === st.stnm || c.stcd === st.stcd);
680
+ if (!exists) {
681
+ cat.children.push({
682
+ text: st.stnm || st.stcd,
683
+ stcd: st.stcd,
684
+ sttp: st.sttp,
685
+ position: { x: st.lttd || 0, y: st.lgtd || 0, z: 100 },
686
+ });
687
+ }
688
+ }
689
+ }
690
+
691
+ /** 从图层树数据创建屏幕图标 */
692
+ _initScreenIcons() {
693
+ const cats = this.layerCategories || [];
694
+ const catConfig = {
695
+ seawall: {
696
+ img: '/image/screenIcon/海塘-红.png', method: 'textImage',
697
+ visibleRange: [5000, 150000], offset: [-40, -35], anchorType: 6,
698
+ onClick: (name, data) => {
699
+ this.flyToNode(data);
700
+ document.dispatchEvent(new CustomEvent('gaea-icon-click', { detail: { category: 'seawall', name, data } }));
701
+ },
702
+ },
703
+ hydrometricStations: {
704
+ img: '/image/screenIcon/潮位站-正常.png', method: 'textImage',
705
+ visibleRange: [10, 5000], offset: [-40, -35],
706
+ onClick: (name, data) => {
707
+ console.log('[GaeaApp] hydrometricStations onClick:', name, data);
708
+ const eventData = {
709
+ category: 'hydrometricStations',
710
+ name,
711
+ data: { ...data, stcd: data.stcd || '', sttp: data.sttp || 'ZQ' }
712
+ };
713
+ document.dispatchEvent(new CustomEvent('gaea-icon-click', { detail: eventData, bubbles: true, cancelable: true }));
714
+ if (window.__gaeaIconClickHandler) {
715
+ window.__gaeaIconClickHandler({ detail: eventData });
716
+ }
717
+ console.log('[GaeaApp] hydrometricStations 事件已派发');
718
+ },
719
+ },
720
+ tideStations: {
721
+ img: '/image/screenIcon/潮位站-正常.png', method: 'textImage',
722
+ visibleRange: [10, 5000], offset: [-40, -35],
723
+ onClick: (name, data) => {
724
+ document.dispatchEvent(new CustomEvent('gaea-icon-click', { detail: { category: 'tideStations', name, data } }));
725
+ },
726
+ },
727
+ riverStations: {
728
+ img: '/image/screenIcon/河道站-正常.png', method: 'textImage',
729
+ visibleRange: [10, 5000], offset: [-40, -35],
730
+ onClick: (name, data) => {
731
+ this.flyToNode(data);
732
+ document.dispatchEvent(new CustomEvent('gaea-icon-click', { detail: { category: 'riverStations', name, data } }));
733
+ },
734
+ },
735
+ rainStations: {
736
+ img: '/image/screenIcon/雨量站-绿.png', method: 'textImage',
737
+ visibleRange: [10, 5000], offset: [-40, -35],
738
+ onClick: (name, data) => {
739
+ this.flyToNode(data);
740
+ document.dispatchEvent(new CustomEvent('gaea-icon-click', { detail: { category: 'rainStations', name, data } }));
741
+ },
742
+ },
743
+ reservoirs: {
744
+ img: '/image/screenIcon/水库站.png', method: 'styled',
745
+ visibleRange: [10, 5000], offset: [-40, -35],
746
+ onClick: (name, data) => {
747
+ this.flyToNode(data);
748
+ document.dispatchEvent(new CustomEvent('gaea-icon-click', { detail: { category: 'reservoirs', name, data } }));
749
+ },
750
+ },
751
+ sluice: {
752
+ img: '/image/screenIcon/闸门-绿.png', method: 'textImage',
753
+ visibleRange: [10, 3000], offset: [-40, -35],
754
+ onClick: (name, data) => {
755
+ this.flyToNode(data);
756
+ document.dispatchEvent(new CustomEvent('gaea-icon-click', { detail: { category: 'sluice', name, data } }));
757
+ },
758
+ },
759
+ secData: {
760
+ img: '/image/screenIcon/断面.png', method: 'styled',
761
+ visibleRange: [10, 3000], offset: [-40, -35],
762
+ onClick: (name, data) => {
763
+ this._onSecDataClick(name, data);
764
+ },
765
+ },
766
+ uavNests: {
767
+ img: '/image/screenIcon/无人机.png', method: 'imageOnly',
768
+ visibleRange: [10, 5000], offset: [-25, -80],
769
+ onClick: (name, data) => {
770
+ document.dispatchEvent(new CustomEvent('gaea-icon-click', { detail: { category: 'uavNests', name, data } }));
771
+ },
772
+ },
773
+ rescue: {
774
+ img: '/image/screenIcon/隐患.png', method: 'imageOnly',
775
+ visibleRange: [10, 5000], offset: [-25, -80],
776
+ onClick: (name, data) => {
777
+ this.flyToNode(data);
778
+ document.dispatchEvent(new CustomEvent('gaea-icon-click', { detail: { category: 'rescue', name, data } }));
779
+ },
780
+ },
781
+ materials: {
782
+ img: '/image/screenIcon/物资仓库.png', method: 'imageOnly',
783
+ visibleRange: [10, 5000], offset: [-25, -80],
784
+ onClick: (name, data) => {
785
+ this.flyToNode(data);
786
+ document.dispatchEvent(new CustomEvent('gaea-icon-click', { detail: { category: 'materials', name, data } }));
787
+ },
788
+ },
789
+ };
790
+ console.log('[GaeaApp] 开始创建屏幕图标... categories:', cats.length);
791
+ let count = 0;
792
+ for (const cat of cats) {
793
+ if (!cat.children?.length) continue;
794
+ const cfg = catConfig[cat.key] || { img: '', method: 'textImage', visibleRange: [10, 50000], offset: [-40, -35], drawLine: false };
795
+ for (const child of cat.children) {
796
+ if (!child.position) continue;
797
+ count++;
798
+ try {
799
+ this.addScreenIcon({
800
+ name: child.text,
801
+ text: child.text,
802
+ imgUrl: cfg.img,
803
+ method: cfg.method,
804
+ position: child.position,
805
+ offset: cfg.offset,
806
+ visibleRange: cfg.visibleRange,
807
+ anchorType: cfg.anchorType,
808
+ drawLine: cfg.drawLine,
809
+ onClick: (name, data) => {
810
+ if (cfg.onClick) cfg.onClick(name, { ...child, ...data });
811
+ },
812
+ });
813
+ } catch (e) {
814
+ console.warn(`[GaeaApp] 图标 ${child.text} 创建失败:`, e.message);
815
+ }
816
+ }
817
+ }
818
+ console.log('[GaeaApp] 屏幕图标创建完成, 共:', count);
819
+ }
820
+
192
821
  // ---- 公开方法 ----
193
822
 
194
823
  /** 初始化默认图层和相机视角(手动调用,可传参覆盖配置) */
@@ -248,12 +877,333 @@ export class GaeaApp {
248
877
  return store;
249
878
  }
250
879
 
880
+ // ---- 地形服务 ----
881
+
882
+ /** 加载单个地形服务 */
883
+ _initTerrainServer(url) {
884
+ return new Promise((resolve) => {
885
+ Gaea.World.Instance.InitTerrainServer2(url, () => resolve());
886
+ });
887
+ }
888
+
889
+ /** 加载配置中的地形服务(cfg.defaultTerrains,url 为相对 serveUrl 的路径或完整地址) */
890
+ async addInitTerrainServer(terrains) {
891
+ const cfg = this.config;
892
+ const list = terrains ?? cfg.defaultTerrains;
893
+ if (!list?.length) return;
894
+ for (const t of list) {
895
+ const url = /^https?:\/\//.test(t.url) ? t.url : `${cfg.serveUrl}${t.url}`;
896
+ try {
897
+ await this._initTerrainServer(url);
898
+ console.log(`地形 "${t.name}" 加载成功`);
899
+ } catch (e) {
900
+ console.warn(`地形 "${t.name}" 加载失败:`, e?.message);
901
+ }
902
+ }
903
+ }
904
+
905
+ // ---- 贴地线(矢量线服务) ----
906
+
907
+ /** 从 WMTS 服务列表创建矢量要素服务 */
908
+ CreateFeature(serviceName, levelRange, render, alias) {
909
+ if (!this.layers?.length) return console.warn('矢量服务列表为空,请先调用 getAllServe()');
910
+ const cap = this.layers.find(i => i.Title === serviceName);
911
+ if (!cap) return console.warn(`矢量服务 "${serviceName}" 未找到`);
912
+ const store = new Gaea.WMTSVectorStore();
913
+ store.Name = alias == null ? serviceName : alias;
914
+ store.Init(cap);
915
+ store.ImageExtension = Gaea.ImageExtensionEnum.GEOJSON;
916
+ store.LevelRange = levelRange;
917
+ if (render != null) store.DefaultRenderer = render;
918
+ Gaea.World.Instance.DefaultVectorPyramidTileSet.AddImageStore(store);
919
+ return store;
920
+ }
921
+
922
+ /** 添加单条贴地线 */
923
+ AddVTSLine({ name, range, color, index }) {
924
+ const symbol = new Gaea.GroundPolylineSymbol();
925
+ symbol.Width = 2;
926
+ symbol.LineColor = new Gaea.Color(color[0] / 255, color[1] / 255, color[2] / 255, 1);
927
+ symbol.Priority = index;
928
+ const render = new Gaea.SimpleFeatureRenderer();
929
+ render.Symbol = symbol;
930
+ this.CreateFeature(name, new Gaea.Vector2(range[0], range[1]), render);
931
+ return render;
932
+ }
933
+
934
+ /** 加载配置中的贴地线(cfg.defaultGroundLines,依赖 getAllServe 的服务列表) */
935
+ addGroundLines(groundLines) {
936
+ const cfg = this.config;
937
+ const list = groundLines ?? cfg.defaultGroundLines;
938
+ if (!list?.length) return;
939
+ if (!this.layers?.length) return console.warn('贴地线需要先加载 WMTS 服务列表(getAllServe)');
940
+ for (const item of list) {
941
+ if (item.isInitLoad === false) continue;
942
+ try {
943
+ this.AddVTSLine(item);
944
+ console.log(`贴地线 "${item.name}" 加载成功`);
945
+ } catch (e) {
946
+ console.warn(`贴地线 "${item.name}" 加载失败:`, e?.message);
947
+ }
948
+ }
949
+ }
950
+
251
951
  /** 定位到指定视角(经纬度高程 + 相机参数) */
252
952
  SetPosition(numStr) {
253
953
  const arr = numStr.split(',').map(s => Number(s.trim()));
254
954
  Gaea.World.Instance.DefaultCamera.SetPosition2(arr[0], arr[1], arr[2], arr[3], arr[4], arr[5]);
255
955
  }
256
956
 
957
+ /** 飞到图层树节点位置 */
958
+ flyToNode(node) {
959
+ if (!node?.position) return;
960
+ const { x, y, z } = node.position;
961
+ const h = z + 500;
962
+ this.SetPosition(`${x},${y},${z},${h},0,0`);
963
+ }
964
+
965
+ // ---- 沿线飞行 ----
966
+
967
+ /**
968
+ * 沿线飞行 - 相机沿路径自动飞行
969
+ * @param {Array} lineData - 路径点数组,每个点 {x: 纬度, y: 经度, z?: 高度}
970
+ * @param {Object} options - 飞行配置(height/step/duration/xrotation/lockEye/minZoom/maxZoom)
971
+ * @returns {Promise} 飞行完成后 resolve
972
+ */
973
+ flyAlongPath = async (lineData, options = {}) => {
974
+ const opts = {
975
+ height: 200,
976
+ step: 1000,
977
+ duration: 20.0,
978
+ xrotation: 30,
979
+ lockEye: false,
980
+ minZoom: 200,
981
+ maxZoom: 1000,
982
+ ...options,
983
+ };
984
+
985
+ if (!lineData || lineData.length < 2) {
986
+ console.error('flyAlongPath: 至少需要2个路径点');
987
+ return Promise.reject(new Error('至少需要2个路径点'));
988
+ }
989
+
990
+ try {
991
+ this._cleanupFlyResources();
992
+
993
+ const bezierCurve = new Gaea.UniformCubicSpline();
994
+ for (let i = 0; i < lineData.length; i++) {
995
+ const control = Gaea.GaeaMath.SphericalToCartesianDeg(
996
+ new Gaea.Vector3(lineData[i].x, lineData[i].y, lineData[i].z ?? opts.height)
997
+ );
998
+ bezierCurve.AddValue(i, control);
999
+ }
1000
+ bezierCurve.Step = opts.step;
1001
+ bezierCurve.Interpolate(true);
1002
+ bezierCurve.CalculateLengthAfterInterpolation();
1003
+
1004
+ const startPos = Gaea.GaeaMath.SphericalToCartesianDeg(
1005
+ new Gaea.Vector3(lineData[0].x, lineData[0].y, lineData[0].z ?? opts.height)
1006
+ );
1007
+
1008
+ const mesh = new Gaea.CubeMesh();
1009
+ mesh.Size = new Gaea.Vector3(1, 1, 1);
1010
+ const mtl = new Gaea.SpatialMaterial();
1011
+ mtl.AlbedoColor = new Gaea.Color(1, 0, 0, 1);
1012
+ mesh.SurfaceSetMaterial(0, mtl);
1013
+
1014
+ this._flyModelInstance = new Gaea.MeshInstance();
1015
+ this._flyModelInstance.Name = 'flyPathModel';
1016
+ this._flyModelInstance.Visible = false;
1017
+ this._flyModelInstance.Translation = startPos;
1018
+ Gaea.World.Instance.AddChild(this._flyModelInstance, false);
1019
+
1020
+ this._flyEntityView = new Gaea.EntityView();
1021
+ this._flyEntityView.Offset3D = new Gaea.Vector3(-1, 0, Math.tan(opts.xrotation / 180 * Math.PI));
1022
+ this._flyEntityView.LockEye = opts.lockEye;
1023
+ this._flyEntityView.MinMaxZoom3D = new Gaea.Vector2(opts.minZoom, opts.maxZoom);
1024
+ this._flyEntityView.Init(this._flyModelInstance);
1025
+ Gaea.World.Instance.CameraController.LockedEntity = this._flyEntityView;
1026
+
1027
+ this._flyAnimation = bezierCurve.ToAnimationByTime('flyPathAnimation', true, opts.duration, new Gaea.Vector3(1, 1, 1));
1028
+ this._flyAnimation.TrackSetPath(0, '/root/World/flyPathModel');
1029
+
1030
+ this._flyAnimationPlayer = new Gaea.GaeaAnimationPlayer();
1031
+ this._flyAnimationPlayer.AddAnimation('flyPathAnimation', this._flyAnimation);
1032
+ Gaea.World.Instance.AddChild(this._flyAnimationPlayer, false);
1033
+
1034
+ return new Promise((resolve) => {
1035
+ this._flyAnimationPlayer.PlayWithCallback('flyPathAnimation', Gaea.CallbackType.AnimatioFinished, () => {
1036
+ console.log('沿线飞行动画播放完成');
1037
+ resolve();
1038
+ });
1039
+ });
1040
+ } catch (error) {
1041
+ console.error('flyAlongPath 启动失败:', error);
1042
+ this._cleanupFlyResources();
1043
+ return Promise.reject(error);
1044
+ }
1045
+ };
1046
+
1047
+ /** 停止沿线飞行 */
1048
+ stopFlyAlongPath = () => {
1049
+ try {
1050
+ // Godot AnimationPlayer.Stop(reset:bool),参数是布尔而非动画名
1051
+ if (this._flyAnimationPlayer) this._flyAnimationPlayer.Stop(false);
1052
+ } catch (error) {
1053
+ console.error('停止飞行失败:', error);
1054
+ }
1055
+ this._cleanupFlyResources();
1056
+ };
1057
+
1058
+ /** 清理飞行相关资源 */
1059
+ _cleanupFlyResources = () => {
1060
+ try {
1061
+ if (this._flyAnimationPlayer) {
1062
+ Gaea.World.Instance.RemoveChild(this._flyAnimationPlayer);
1063
+ this._flyAnimationPlayer = null;
1064
+ }
1065
+ if (this._flyEntityView) this._flyEntityView = null;
1066
+ if (this._flyModelInstance) {
1067
+ Gaea.World.Instance.RemoveChild(this._flyModelInstance);
1068
+ this._flyModelInstance = null;
1069
+ }
1070
+ Gaea.World.Instance.CameraController.LockedEntity = null;
1071
+ this._flyAnimation = null;
1072
+ } catch (error) {
1073
+ console.error('清理飞行资源失败:', error);
1074
+ }
1075
+ };
1076
+
1077
+ /** 相机复位到默认视角 */
1078
+ cameraReset = () => {
1079
+ this._cleanupFlyResources();
1080
+ const pos = this.config.defaultCameraPosition;
1081
+ if (pos) this.SetPosition(pos);
1082
+ };
1083
+
1084
+ /**
1085
+ * 添加屏幕图标(参考 ht_3dview 三种创建方式)
1086
+ * @param {string} opts.method - 'textImage' | 'styled' | 'imageOnly'
1087
+ */
1088
+ addScreenIcon({ name, text, imgUrl, position, method = 'textImage', offset = [0, 0], visibleRange = [10, 50000], anchorType = 6, drawLine = false, onClick, type = null, divId: externalDivId, elementName, elementChildrenIndex } = {}) {
1089
+ const safeId = name.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_');
1090
+ const divId = externalDivId || 'gaea-icon-' + safeId;
1091
+
1092
+ // 如果是外部传入的 divId,直接使用已有的 DOM 元素
1093
+ let container;
1094
+ if (externalDivId) {
1095
+ container = document.getElementById(externalDivId);
1096
+ if (!container) return null;
1097
+ } else {
1098
+ container = document.createElement('div');
1099
+ container.id = divId;
1100
+
1101
+ if (method === 'imageOnly') {
1102
+ container.className = 'gaea-iconGif';
1103
+ const img = document.createElement('img');
1104
+ img.src = imgUrl || '';
1105
+ container.appendChild(img);
1106
+ } else if (method === 'styled') {
1107
+ container.className = 'gaea-iconscreen';
1108
+ const img = document.createElement('img');
1109
+ img.src = imgUrl || '';
1110
+ container.appendChild(img);
1111
+ const content = document.createElement('div');
1112
+ content.className = 'content';
1113
+ const box = document.createElement('div');
1114
+ box.className = 'box';
1115
+ content.appendChild(box);
1116
+ const textArea = document.createElement('div');
1117
+ textArea.className = 'text';
1118
+ textArea.textContent = text || '';
1119
+ content.appendChild(textArea);
1120
+ container.appendChild(content);
1121
+ } else {
1122
+ container.className = 'gaea-locationLineStyle';
1123
+ const textWrapper = document.createElement('div');
1124
+ textWrapper.className = 'text-wrapper';
1125
+ const textEl = document.createElement('div');
1126
+ textEl.className = 'text-content';
1127
+ textEl.textContent = text || name;
1128
+ textWrapper.appendChild(textEl);
1129
+ container.appendChild(textWrapper);
1130
+ if (imgUrl) {
1131
+ const imgWrapper = document.createElement('div');
1132
+ imgWrapper.className = 'image-wrapper';
1133
+ const img = document.createElement('img');
1134
+ img.src = imgUrl;
1135
+ imgWrapper.appendChild(img);
1136
+ container.appendChild(imgWrapper);
1137
+ }
1138
+ }
1139
+
1140
+ } // end else (externalDivId)
1141
+
1142
+ if (!externalDivId) {
1143
+ document.body.appendChild(container);
1144
+ }
1145
+
1146
+ container.style.position = 'absolute';
1147
+ container.style.zIndex = '9999';
1148
+ container.style.pointerEvents = 'auto';
1149
+ container.style.cursor = 'pointer';
1150
+
1151
+ // 在 DOM 上绑点击事件(Gaea ScreenGUIElement 的 OnHitElement 对 DOM 型图标不生效)
1152
+ container.addEventListener('click', (e) => {
1153
+ e.stopPropagation();
1154
+ e.preventDefault();
1155
+ console.log('[GaeaApp] 图标点击:', name);
1156
+ if (onClick) { onClick(name, { text, position }); }
1157
+ else if (position) { this.flyToNode({ position }); }
1158
+ });
1159
+
1160
+ const screen = new Gaea.ScreenGUIElement();
1161
+ screen.Name = name;
1162
+ screen.Visible = true;
1163
+ screen.UseAutoVisible();
1164
+ screen.DivID = divId;
1165
+ const root = new Gaea.Control();
1166
+ root.MouseFilter = 2;
1167
+ root.RectSize = new Gaea.Vector2(container.clientWidth, container.clientHeight);
1168
+ screen.Root = root;
1169
+ screen.ConnectAnchor = anchorType;
1170
+ screen.DrawLine = drawLine;
1171
+ if (drawLine) {
1172
+ screen.LineWidth = screen.AnchorPointSize = 3;
1173
+ screen.LineColor = new Gaea.Color(1, 0.3, 0.3, 1);
1174
+ screen.AnchorPointColor = new Gaea.Color(1, 0.3, 0.3, 1);
1175
+ }
1176
+ if (offset.length) screen.ScreenOffset = new Gaea.Vector2(offset[0], offset[1]);
1177
+ screen.EnablePick = true;
1178
+ if (visibleRange.length) screen.VisibleRange = new Gaea.Vector2(visibleRange[0], visibleRange[1]);
1179
+
1180
+ // 如果有 elementName,将图标挂到 GLTF 模型子节点上(位置由父节点决定)
1181
+ if (elementName && elementChildrenIndex !== undefined) {
1182
+ const el = this._gltfElements?.[elementName];
1183
+ console.log('[GaeaApp] addScreenIcon element:', elementName, 'el:', !!el, 'childrenIndex:', elementChildrenIndex);
1184
+ if (el && el.Root) {
1185
+ const childCount = el.Root.GetChildCount();
1186
+ console.log('[GaeaApp] root child count:', childCount);
1187
+ const child = el.Root.GetChild(elementChildrenIndex);
1188
+ console.log('[GaeaApp] child:', !!child);
1189
+ if (child) {
1190
+ child.AddChild(screen, false);
1191
+ const screenItem = { element: screen, type: type || null };
1192
+ this._screenIconList.push(screenItem);
1193
+ return screen;
1194
+ }
1195
+ }
1196
+ // 子节点未找到,回退到绝对位置
1197
+ console.warn('[GaeaApp] elementName/childIndex 未找到,使用 position 回退:', elementName, elementChildrenIndex);
1198
+ }
1199
+
1200
+ screen.GeographyPosition = new Gaea.Vector3(position.x, position.y, position.z || 0);
1201
+ Gaea.World.Instance.RenderableObjectList.AddLast(screen);
1202
+ const screenItem = { element: screen, type: type || null };
1203
+ this._screenIconList.push(screenItem);
1204
+ return screen;
1205
+ }
1206
+
257
1207
  /** 记录当前视角,返回 URI 字符串 */
258
1208
  favorite() {
259
1209
  return Gaea.GaeaCamera.ConvertBy(Gaea.World.Instance.DefaultCamera).Favorite();
@@ -263,6 +1213,522 @@ export class GaeaApp {
263
1213
  favoritePosition(uri) {
264
1214
  Gaea.World.Instance.DefaultCamera.GotoFavorite(uri);
265
1215
  }
1216
+
1217
+ // ---- 引擎动画方法 ----
1218
+
1219
+ hiddenScreenWithClass(className) {
1220
+ const els = document.querySelectorAll(className);
1221
+ els.forEach(el => { el.style.display = 'none'; });
1222
+ }
1223
+
1224
+ _createSecLabel(text, className, imgUrl, options = {}) {
1225
+ const id = 'gaea-sec-' + Math.random().toString(36).slice(2);
1226
+ const container = document.createElement('div');
1227
+ container.id = id;
1228
+ if (className) container.className = className;
1229
+ const img = document.createElement('img');
1230
+ img.src = imgUrl || '';
1231
+ container.appendChild(img);
1232
+ const content = document.createElement('div');
1233
+ content.className = 'content';
1234
+ const box = document.createElement('div');
1235
+ box.className = 'box';
1236
+ content.appendChild(box);
1237
+ const textEl = document.createElement('div');
1238
+ textEl.className = 'text';
1239
+ const inner = document.createElement('div');
1240
+ inner.textContent = text || '';
1241
+ inner.style.color = 'white';
1242
+ textEl.appendChild(inner);
1243
+ content.appendChild(textEl);
1244
+ container.appendChild(content);
1245
+ if (options.onMouseEnter) container.addEventListener('mouseenter', options.onMouseEnter);
1246
+ if (options.onMouseLeave) container.addEventListener('mouseleave', options.onMouseLeave);
1247
+ document.body.appendChild(container);
1248
+ return id;
1249
+ }
1250
+
1251
+ _tooltipFormatter(key, value, indexList, name) {
1252
+ const label = typeof value === 'object' && value.label ? value.label : value;
1253
+ const details = typeof value === 'object' && value.details ? value.details : [];
1254
+ let html = '';
1255
+ if (details.length) {
1256
+ details.forEach(item => { html += `<div class="daba-tooltip-item">${item}</div>`; });
1257
+ } else {
1258
+ html = '<div class="daba-tooltip-item">暂无详情</div>';
1259
+ }
1260
+ return `<div class="daba-tooltip-title">${name} - ${label}</div>${html}`;
1261
+ }
1262
+
1263
+ /** 断面数据点击处理 */
1264
+ _onSecDataClick(name, data) {
1265
+ const sectionModel = this._gltfElements?.[name];
1266
+ const tourModelName = name + '游览模型';
1267
+ const tourModel = this._gltfElements?.[tourModelName];
1268
+
1269
+ document.dispatchEvent(new CustomEvent('gaea-icon-click', {
1270
+ detail: { category: 'secData', name, data, sectionModel: sectionModel || null, tourModel: tourModel || null },
1271
+ bubbles: true,
1272
+ }));
1273
+
1274
+ if (!sectionModel) {
1275
+ this.flyToNode(data);
1276
+ return;
1277
+ }
1278
+
1279
+ // 首次点击断面时注入标签样式
1280
+ if (!document.getElementById('gaea-sec-styles')) {
1281
+ const style = document.createElement('style');
1282
+ style.id = 'gaea-sec-styles';
1283
+ style.textContent = `
1284
+ .waterLevel{position:absolute;z-index:1000;width:112px;top:16%;left:30%}
1285
+ .waterLevel img{width:100%}
1286
+ .waterLevel .content{position:absolute;top:16%;left:25%;white-space:nowrap}
1287
+ .waterLevel .content .text{font-size:20px}
1288
+ .damScreen{position:absolute;box-sizing:border-box;top:431.312px;left:630.192px;width:288px;height:48px;padding:0;margin:0;overflow:hidden;display:block;z-index:3}
1289
+ .damScreen img{position:absolute;width:100%;height:100%;object-fit:cover;z-index:1}
1290
+ .damScreen .content{display:flex;position:relative;width:100%;min-height:100%;z-index:2;align-items:center}
1291
+ .damScreen .content .box{width:35.2px;height:100%;box-sizing:border-box}
1292
+ .damScreen .content .text{flex:1 1 0%;height:100%;display:flex;font-size:18px;align-items:center;justify-content:center;box-sizing:border-box}
1293
+ .daba-tooltip{position:fixed;z-index:99999;transform:translate(-50%,-100%);pointer-events:none;display:none;min-width:200px;max-width:320px;background:rgba(2,62,138,0.95);border:1px solid rgba(14,165,233,0.6);border-radius:6px;padding:14px 18px;box-shadow:0 4px 20px rgba(0,50,100,0.4);font-size:18px;animation:dabaTooltipFadeIn 0.15s ease-out}
1294
+ .daba-tooltip::after{content:'';position:absolute;bottom:-6px;left:50%;transform:translateX(-50%);width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid rgba(2,62,138,0.95)}
1295
+ @keyframes dabaTooltipFadeIn{from{opacity:0;transform:translate(-50%,calc(-100% - 4px))}to{opacity:1;transform:translate(-50%,-100%)}}
1296
+ .daba-tooltip-title{font-size:17px;font-weight:600;color:#e0f7ff;padding-bottom:8px;margin-bottom:10px;border-bottom:1px solid rgba(255,255,255,0.15)}
1297
+ .daba-tooltip-item{position:relative;padding:3px 0 3px 14px;font-size:15px;line-height:1.7;color:#cbd5e1;word-break:break-word}
1298
+ .daba-tooltip-item::before{content:'';position:absolute;left:0;top:11px;width:5px;height:5px;border-radius:50%;background:#38bdf8}
1299
+ `;
1300
+ document.head.appendChild(style);
1301
+ }
1302
+
1303
+ const gltfCfg = this._gltfCfgMap?.[tourModelName];
1304
+ if (tourModel && gltfCfg) {
1305
+ this.updateElementPosition({ element: tourModel, position: gltfCfg.position, rotate: gltfCfg.rotate, size: gltfCfg.size });
1306
+ }
1307
+
1308
+ // this.RemoveVts({ vtsName: '沿浦海塘工程范围线' });
1309
+ // 通知隐藏图层树
1310
+ document.dispatchEvent(new CustomEvent('gaea-hide-layertree', { bubbles: true }));
1311
+ this.FlyOver(() => {
1312
+ this.hiddenScreenWithClass('.waterLevel');
1313
+ this.hiddenScreenWithClass('.damScreen');
1314
+
1315
+ this.gltfPlayer(sectionModel, { x: 0, y: 44, z: 0 }, 1).then(() => {
1316
+ // 记录已升起的断面模型,供重置时缩回
1317
+ this._raisedSection = sectionModel;
1318
+ if (tourModel) {
1319
+ this.lockedModesSwitch(true, tourModel, [-10, 50, 0], [50, 50]);
1320
+ // 锁定模型后再通知显示断面滚动条
1321
+ document.dispatchEvent(new CustomEvent('gaea-sec-locked', { detail: { name }, bubbles: true }));
1322
+ }
1323
+
1324
+ if (!this._secIsClickArr.includes(name)) {
1325
+ if (!document.getElementById('daba-tooltip')) {
1326
+ const tt = document.createElement('div');
1327
+ tt.id = 'daba-tooltip';
1328
+ tt.className = 'daba-tooltip';
1329
+ document.body.appendChild(tt);
1330
+ }
1331
+ const dabaTooltip = document.getElementById('daba-tooltip');
1332
+ const divIdList = [];
1333
+ const hasNumber = (str) => typeof str === 'string' && /[+-]?\d+(\.\d+)?/.test(str);
1334
+
1335
+ Object.entries(data.indexList || {}).forEach(([key, value]) => {
1336
+ const entryLabel = typeof value === 'object' && value.label ? value.label : String(value);
1337
+ let cls, imgUrl, offset;
1338
+ if (hasNumber(entryLabel)) {
1339
+ cls = 'waterLevel'; offset = [0, -54]; imgUrl = '/image/labelImg/水位.png';
1340
+ } else {
1341
+ cls = 'damScreen'; offset = [0, -19]; imgUrl = '/image/labelImg/左边.png';
1342
+ }
1343
+ const divid = this._createSecLabel(entryLabel, cls, imgUrl, {
1344
+ onMouseEnter: (e) => {
1345
+ const rect = e.currentTarget.getBoundingClientRect();
1346
+ dabaTooltip.innerHTML = this._tooltipFormatter(key, value, data.indexList, name);
1347
+ dabaTooltip.style.left = `${rect.left + rect.width / 2}px`;
1348
+ dabaTooltip.style.top = `${rect.top - 8}px`;
1349
+ dabaTooltip.style.display = 'block';
1350
+ },
1351
+ onMouseLeave: () => { dabaTooltip.style.display = 'none'; },
1352
+ });
1353
+ divIdList.push(divid);
1354
+ this.addScreenIcon({
1355
+ name: entryLabel, divId: divid, position: data.position,
1356
+ visibleRange: [0, 100], offset, imgUrl, type: 'dabaSec',
1357
+ anchorType: Gaea.AnchorType.LeftDown,
1358
+ elementName: name, elementChildrenIndex: Number(key),
1359
+ });
1360
+ });
1361
+ this._secIsClickArr.push(name);
1362
+ this._secScreenIconKeyValue[name] = divIdList;
1363
+ }
1364
+
1365
+ this._screenIconList.forEach(item => {
1366
+ if (!item.type || item.type !== 'dabaSec') {
1367
+ item.element.ForceSetVisible(false);
1368
+ item.element.EnablePick = false;
1369
+ }
1370
+ });
1371
+ this.hiddenScreenWithClass('.gaea-iconscreen');
1372
+ this.hiddenScreenWithClass('.gaea-locationLineStyle');
1373
+ this.hiddenScreenWithClass('.gaea-iconGif');
1374
+ }).catch(err => console.warn('[GaeaApp] 断面动画失败:', err.message));
1375
+ });
1376
+ this.SetPosition(data.favorite || `${data.position.x},${data.position.y},${data.position.z},${data.position.z + 500},0,0`);
1377
+ }
1378
+
1379
+ /** 飞行结束回调 */
1380
+ FlyOver(callback) {
1381
+ const camera = Gaea.World.Instance.DefaultCamera;
1382
+ camera.OnFlyOverCallBack(() => {
1383
+ if (callback) callback();
1384
+ });
1385
+ }
1386
+
1387
+ /** GLTF 模型平移动画 */
1388
+ gltfPlayer = (element, targetPos, time = 5) => {
1389
+ return new Promise((resolve, reject) => {
1390
+ if (!element || !element.TranslateObjectLocal) {
1391
+ reject(new Error('无效的元素对象:缺少 TranslateObjectLocal 方法'));
1392
+ return;
1393
+ }
1394
+ if (!targetPos || typeof targetPos.x !== 'number' || typeof targetPos.y !== 'number' || typeof targetPos.z !== 'number') {
1395
+ reject(new Error('无效的目标位置:必须包含数字类型的 x/y/z 属性'));
1396
+ return;
1397
+ }
1398
+ const frameRate = 60;
1399
+ const totalFrames = time * frameRate;
1400
+ let currentFrame = 0;
1401
+ const totalRelativeOffset = { ...targetPos };
1402
+ let currentCumulativeOffset = { x: 0, y: 0, z: 0 };
1403
+ try {
1404
+ element.TranslateObjectLocal(new Gaea.Vector3(-currentCumulativeOffset.x, -currentCumulativeOffset.y, -currentCumulativeOffset.z));
1405
+ currentCumulativeOffset = { x: 0, y: 0, z: 0 };
1406
+ const fixedStep = {
1407
+ x: totalRelativeOffset.x / totalFrames,
1408
+ y: totalRelativeOffset.y / totalFrames,
1409
+ z: totalRelativeOffset.z / totalFrames,
1410
+ };
1411
+ const moveStep = () => {
1412
+ if (currentFrame >= totalFrames) {
1413
+ const finalCompensate = {
1414
+ x: totalRelativeOffset.x - currentCumulativeOffset.x,
1415
+ y: totalRelativeOffset.y - currentCumulativeOffset.y,
1416
+ z: totalRelativeOffset.z - currentCumulativeOffset.z,
1417
+ };
1418
+ element.TranslateObjectLocal(new Gaea.Vector3(finalCompensate.x, finalCompensate.y, finalCompensate.z));
1419
+ currentCumulativeOffset = { ...totalRelativeOffset };
1420
+ resolve({ success: true, targetPos, actualPos: currentCumulativeOffset });
1421
+ return;
1422
+ }
1423
+ element.TranslateObjectLocal(new Gaea.Vector3(fixedStep.x, fixedStep.y, fixedStep.z));
1424
+ currentCumulativeOffset.x += fixedStep.x;
1425
+ currentCumulativeOffset.y += fixedStep.y;
1426
+ currentCumulativeOffset.z += fixedStep.z;
1427
+ currentFrame++;
1428
+ requestAnimationFrame(moveStep);
1429
+ };
1430
+ moveStep();
1431
+ } catch (error) {
1432
+ reject(new Error(`动画执行出错:${error.message}`));
1433
+ }
1434
+ });
1435
+ };
1436
+
1437
+ /** 锁定/解锁相机视角到模型 */
1438
+ lockedModesSwitch = (mode, element, Offset3D = [0, 1.1, 0], MinMaxZoom3D = [1.1, 1.1], LockEye = false) => {
1439
+ if (mode) {
1440
+ const ev = new Gaea.EntityView();
1441
+ ev.Offset3D = new Gaea.Vector3(Offset3D[0], Offset3D[1], Offset3D[2]);
1442
+ ev.LockEye = LockEye;
1443
+ ev.MinMaxZoom3D = new Gaea.Vector2(MinMaxZoom3D[0], MinMaxZoom3D[1]);
1444
+ ev.Init(element);
1445
+ if (element) {
1446
+ Gaea.World.Instance.CameraController.LockedEntity = ev;
1447
+ }
1448
+ return ev;
1449
+ } else {
1450
+ Gaea.World.Instance.CameraController.LockedEntity = null;
1451
+ // 解锁时恢复非断面图标的自动可见
1452
+ this._screenIconList.forEach(item => {
1453
+ if (!item.type) {
1454
+ item.element.UseAutoVisible();
1455
+ item.element.EnablePick = true;
1456
+ }
1457
+ });
1458
+ }
1459
+ };
1460
+
1461
+ /** 修改模型位置/旋转 */
1462
+ updateElementPosition = ({ element, position, rotate, size }) => {
1463
+ element.GeographyPosition = new Gaea.Vector3(position.x, position.y, position.z);
1464
+ if (rotate?.length > 0) {
1465
+ element.TiltHeadingBank = new Gaea.Vector3(rotate[0], rotate[1], rotate[2]);
1466
+ }
1467
+ };
1468
+
1469
+ /** 移除 VTS 矢量图层 */
1470
+ RemoveVts = ({ vtsName }) => {
1471
+ Gaea.World.Instance.DefaultVectorPyramidTileSet.RemoveImageStore(
1472
+ Gaea.WMTSVectorStore.ConvertBy(Gaea.World.Instance.DefaultVectorPyramidTileSet.GetImageStore(vtsName))
1473
+ );
1474
+ };
1475
+
1476
+ /** GLTF 轨迹动画播放(用于断面游览) */
1477
+ gtlfAnimationPlayer = (trackList, animationConfig, element) => {
1478
+ element.GeographyPosition = new Gaea.Vector3(trackList[0].x, trackList[0].y, trackList[0].z);
1479
+ const { animationName, animationType, animationPlaySpeed } = animationConfig;
1480
+ const line = trackList.map(item => new Gaea.Vector3(item.x, item.y, item.z));
1481
+ let bezierCurve = new Gaea.LineCurve();
1482
+ for (let i = 0; i < line.length; i++) {
1483
+ let control = Gaea.GaeaMath.SphericalToCartesianDeg1(line[i].x, line[i].y, line[i].z);
1484
+ bezierCurve.AddValue(i, control);
1485
+ }
1486
+ bezierCurve.Step = 20;
1487
+ bezierCurve.Interpolate(true);
1488
+ bezierCurve.CalculateLengthAfterInterpolation();
1489
+ let animation;
1490
+ if (animationType === 'speed') {
1491
+ animation = bezierCurve.ToAnimationBySpeed(animationName, animationPlaySpeed, element.Scale);
1492
+ } else {
1493
+ animation = bezierCurve.ToAnimationByTime(animationName, true, animationPlaySpeed, element.Scale);
1494
+ }
1495
+ animation.TrackSetPath(0, element.GetPath());
1496
+ const animationPlayer = new Gaea.AnimationPlayer();
1497
+ animationPlayer.AddAnimation(animationName, animation);
1498
+ element.AddChild(animationPlayer, false);
1499
+ Gaea.World.Instance.PlayAnimation(animationPlayer, animationName);
1500
+ return animationPlayer;
1501
+ };
1502
+
1503
+ /** 环绕飞行 */
1504
+ FlyAroundPoint = (element, targetAngle, time, callbacks = {}) => {
1505
+ const { onStart = () => {}, onUpdate = () => {}, onComplete = () => {} } = callbacks;
1506
+ if (!targetAngle || !element) { onComplete(null); return; }
1507
+ let lastAngle = 0;
1508
+ let currentAngle = 0;
1509
+ const tween = new TWEEN.Tween({ angle: 0 })
1510
+ .to({ angle: targetAngle }, time)
1511
+ .easing(TWEEN.Easing.Quadratic.InOut)
1512
+ .onStart(() => { onStart(); })
1513
+ .onUpdate((obj) => {
1514
+ currentAngle = obj.angle;
1515
+ element.RotateObjectLocal(new Gaea.Vector3(0, 1, 0), currentAngle - lastAngle);
1516
+ lastAngle = currentAngle;
1517
+ onUpdate(currentAngle);
1518
+ })
1519
+ .onComplete(() => { onComplete(targetAngle); this.tweenGroup.remove(tween); });
1520
+ this.tweenGroup.add(tween);
1521
+ this._startTweenLoop();
1522
+ tween.start();
1523
+ };
1524
+
1525
+ /** 启动 TWEEN 更新循环(首次调用时启动) */
1526
+ _startTweenLoop() {
1527
+ if (this._tweenLoopStarted) return;
1528
+ this._tweenLoopStarted = true;
1529
+ const animate = (time) => {
1530
+ requestAnimationFrame(animate);
1531
+ this.tweenGroup.update(time);
1532
+ };
1533
+ requestAnimationFrame(animate);
1534
+ }
1535
+
1536
+ /** 提取索引区间的 pos 数组,起始>目标时反序 */
1537
+ getPosArrayBetweenIndexes(list, currentIdx, targetIdx) {
1538
+ if (!Array.isArray(list) || currentIdx < 0 || targetIdx < 0 ||
1539
+ currentIdx >= list.length || targetIdx >= list.length) {
1540
+ return [];
1541
+ }
1542
+ const isReverse = currentIdx > targetIdx;
1543
+ const start = Math.min(currentIdx, targetIdx);
1544
+ const end = Math.max(currentIdx, targetIdx);
1545
+ let result = list.slice(start, end + 1).map(item => item.pos ? { ...item.pos } : null).filter(Boolean);
1546
+ if (isReverse) result = result.reverse();
1547
+ return result;
1548
+ }
1549
+
1550
+ /** 室内游览初始化:隐藏图标 + 锁定游览模型 */
1551
+ initModelTour(modelName, direction = [-0.5, 0, 0]) {
1552
+ // 隐藏所有屏幕图标
1553
+ this._screenIconList.forEach(item => {
1554
+ item.element.ForceSetVisible(false);
1555
+ item.element.EnablePick = false;
1556
+ });
1557
+ this.hiddenScreenWithClass('.gaea-locationLineStyle');
1558
+ this.hiddenScreenWithClass('.gaea-iconscreen');
1559
+ this.hiddenScreenWithClass('.gaea-iconGif');
1560
+ // 隐藏图层树
1561
+ document.dispatchEvent(new CustomEvent('gaea-hide-layertree', { bubbles: true }));
1562
+
1563
+ const tourModel = this._gltfElements?.[`${modelName}游览模型`];
1564
+ if (!tourModel) {
1565
+ console.warn('[GaeaApp] 游览模型未找到:', modelName);
1566
+ return null;
1567
+ }
1568
+ this._currentTourModel = tourModel;
1569
+ this._currentTourIndexPos = 0;
1570
+ this.lockedModesSwitch(true, tourModel, [direction[0], direction[1], direction[2]], [0.5, 0.5], false);
1571
+ return tourModel;
1572
+ }
1573
+
1574
+ /** 室内游览路径节点点击:沿路径移动 + 转头 */
1575
+ tourNodeClick(positionList, index) {
1576
+ const tourModel = this._currentTourModel;
1577
+ if (!tourModel) return;
1578
+ this.lockedModesSwitch(true, tourModel, [-0.5, 0, 0], [0.5, 0.5], true);
1579
+ const currentIdx = this._currentTourIndexPos || 0;
1580
+ const currentPosList = this.getPosArrayBetweenIndexes(positionList, currentIdx, index);
1581
+ const time = 2 * (currentPosList.length - 1);
1582
+ this.gtlfAnimationPlayer(currentPosList, {
1583
+ animationName: `动画${index}`, animationType: 'time', animationPlaySpeed: time, animationLoop: false,
1584
+ }, tourModel);
1585
+ const status = currentIdx > index ? 'out' : 'in';
1586
+ this._currentTourIndexPos = index;
1587
+ setTimeout(() => {
1588
+ let angle, direction;
1589
+ if (status === 'in') {
1590
+ angle = positionList[index]?.lockInAngle;
1591
+ direction = positionList[index]?.lockIn;
1592
+ } else {
1593
+ angle = positionList[index]?.lockOutAngle;
1594
+ direction = positionList[index]?.lockOut;
1595
+ }
1596
+ if (angle) {
1597
+ this.FlyAroundPoint(tourModel, angle, 2000, {
1598
+ onComplete: () => { this.lockedModesSwitch(true, tourModel, direction, [0.5, 0.5]); },
1599
+ });
1600
+ }
1601
+ }, time * 1000);
1602
+ }
1603
+
1604
+ /** 通过 URL 加载资源 */
1605
+ LoadResourceFromUrl = (url, name) => {
1606
+ return new Promise((resolve, reject) => {
1607
+ Gaea.GaeaResourceLoader.Instance.LoadResourceFromUrl1(url, name, (code, msg) => {
1608
+ if (code == 200) resolve(true);
1609
+ else { console.warn(msg); reject(false); }
1610
+ });
1611
+ });
1612
+ };
1613
+
1614
+ /** 添加单个点图标 */
1615
+ addPointIcon = async (options = {}) => {
1616
+ const { lat, lng, alt = 0, iconSize = 500, enablePick = true } = options;
1617
+ if (lat == null || lng == null) {
1618
+ console.error('addPointIcon: lat 和 lng 不能为空');
1619
+ return null;
1620
+ }
1621
+ // 资源名带版本号:npm 包早期附带的 1111.res 不完整,会被引擎按名缓存进持久化文件系统,
1622
+ // 换新名可强制重新下载注册,避免命中旧的无效缓存
1623
+ const resName = 'pointIconV3.res';
1624
+ if (!this._pointIconLoaded) {
1625
+ const resUrl = `${this.config.serveLocal}/1111.res?v=3`;
1626
+ try {
1627
+ await this.LoadResourceFromUrl(resUrl, resName);
1628
+ } catch (e) {
1629
+ console.error('addPointIcon: 加载 .res 图标文件失败');
1630
+ return null;
1631
+ }
1632
+ this._pointIconLoaded = true;
1633
+ }
1634
+ const texId = Gaea.GaeaResourceLoader.Instance.GetResource(resName);
1635
+ const tex = new Gaea.Texture(false);
1636
+ tex.InstanceID = texId + '';
1637
+ const pos = new Gaea.Vector3(lat, lng, alt);
1638
+ const shape = Gaea.Geometry.Create1(Gaea.GeometryType.Point, [pos]);
1639
+ const element = new Gaea.PointGeometryElement();
1640
+ element.Shape = shape;
1641
+ const iconMarkSymbol = new Gaea.IconMarkSymbol();
1642
+ iconMarkSymbol.MarkSize = iconSize;
1643
+ iconMarkSymbol.Icon = tex;
1644
+ element.Symbol = iconMarkSymbol;
1645
+ element.EnablePick = enablePick;
1646
+ element.EnableEdit = false;
1647
+ Gaea.World.Instance.RenderableObjectList.AddLast(element);
1648
+ if (!this._pointIconElements) this._pointIconElements = [];
1649
+ this._pointIconElements.push(element);
1650
+ return element;
1651
+ };
1652
+
1653
+ /** 清除所有点图标 */
1654
+ clearPointIcons = () => {
1655
+ if (this._pointIconElements && this._pointIconElements.length > 0) {
1656
+ this._pointIconElements.forEach
1657
+ (el => Gaea.World.Instance.RenderableObjectList.Remove(el));
1658
+ this._pointIconElements = [];
1659
+ }
1660
+ this.clearTyphoonRoute();
1661
+ };
1662
+
1663
+ /** 加载台风路线:创建图标并沿路径运动(点位数组 [{lat,lng,alt}]) */
1664
+ addTyphoonRoute = async (pointList = [], options = {}) => {
1665
+ const { animationPlaySpeed = 20, animationLoop = true } = options;
1666
+ this.clearTyphoonRoute();
1667
+ if (!pointList || pointList.length < 2) {
1668
+ console.warn('addTyphoonRoute: 至少需要2个点位');
1669
+ return;
1670
+ }
1671
+ const element = await this.addPointIcon({ lat: pointList[0].lat, lng: pointList[0].lng, alt: pointList[0].alt || 0 });
1672
+ if (!element) {
1673
+ console.error('addTyphoonRoute: 创建图标失败');
1674
+ return;
1675
+ }
1676
+ if (this._pointIconElements) {
1677
+ const idx = this._pointIconElements.indexOf(element);
1678
+ if (idx >= 0) this._pointIconElements.splice(idx, 1);
1679
+ }
1680
+ this._typhoonElement = element;
1681
+
1682
+ const steps = animationPlaySpeed * 30;
1683
+ const path = [];
1684
+ for (let i = 0; i < pointList.length - 1; i++) {
1685
+ const segSteps = Math.max(1, Math.round(steps / (pointList.length - 1)));
1686
+ for (let j = 0; j < segSteps; j++) {
1687
+ const t = j / segSteps;
1688
+ path.push({
1689
+ lat: pointList[i].lat + (pointList[i + 1].lat - pointList[i].lat) * t,
1690
+ lng: pointList[i].lng + (pointList[i + 1].lng - pointList[i].lng) * t,
1691
+ alt: (pointList[i].alt || 0) + ((pointList[i + 1].alt || 0) - (pointList[i].alt || 0)) * t,
1692
+ });
1693
+ }
1694
+ }
1695
+
1696
+ const _log = console.log;
1697
+ let idx = 0;
1698
+ const animate = () => {
1699
+ if (!this._typhoonElement || this._typhoonCancelled) return;
1700
+ console.log = () => {};
1701
+ const pt = path[idx];
1702
+ element.Shape = Gaea.Geometry.Create1(Gaea.GeometryType.Point, [new Gaea.Vector3(pt.lat, pt.lng, pt.alt)]);
1703
+ console.log = _log;
1704
+ if (animationLoop) {
1705
+ idx = (idx + 1) % path.length;
1706
+ } else {
1707
+ idx++;
1708
+ if (idx >= path.length) {
1709
+ console.log = () => {};
1710
+ const last = path[path.length - 1];
1711
+ element.Shape = Gaea.Geometry.Create1(Gaea.GeometryType.Point, [new Gaea.Vector3(last.lat, last.lng, last.alt)]);
1712
+ console.log = _log;
1713
+ return;
1714
+ }
1715
+ }
1716
+ this._typhoonTimer = setTimeout(() => animate(), 1000 / 30);
1717
+ };
1718
+ this._typhoonCancelled = false;
1719
+ animate();
1720
+ return { element };
1721
+ };
1722
+
1723
+ /** 清除台风路线 */
1724
+ clearTyphoonRoute = () => {
1725
+ this._typhoonCancelled = true;
1726
+ if (this._typhoonTimer) { clearTimeout(this._typhoonTimer); this._typhoonTimer = null; }
1727
+ if (this._typhoonElement) {
1728
+ try { Gaea.World.Instance.RenderableObjectList.Remove(this._typhoonElement); } catch (e) { /* ignore */ }
1729
+ this._typhoonElement = null;
1730
+ }
1731
+ };
266
1732
  }
267
1733
 
268
1734
  export default GaeaApp;