cesium-xs-sdk 1.0.0 → 1.0.1

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.
@@ -1005,12 +1005,13 @@ class PlotGeometryUtil {
1005
1005
  let angleDiff = endAngle - startAngle;
1006
1006
  angleDiff = angleDiff < 0 ? angleDiff + Math.PI * 2 : angleDiff;
1007
1007
 
1008
- const points = [startPoint];
1009
- for (let i = 1; i < segments; i++) {
1008
+ // 所有圆弧点必须严格在 radiusMeters 为半径的圆上,不能直接使用输入控制点,
1009
+ // 否则鼠标位置到圆心的距离可能与半径不一致,导致扇形闭合边出现三角形或穿入内部。
1010
+ const points = [];
1011
+ for (let i = 0; i <= segments; i++) {
1010
1012
  const angle = startAngle + (angleDiff * i) / segments;
1011
1013
  points.push(this._getPointAtBearingAndDistance(center, radiusMeters, angle));
1012
1014
  }
1013
- points.push(endPoint);
1014
1015
  return points;
1015
1016
  }
1016
1017
 
@@ -1450,11 +1451,14 @@ class PlotGeometryUtil {
1450
1451
  // 使用球面距离作为半径,与圆弧插值函数保持一致
1451
1452
  const radius = this._calcSphericalDistance(pnt2, center);
1452
1453
 
1453
- // 弧线精确经过两个控制点,避免端点漂移
1454
+ // 弧线精确经过两个控制点方向,半径统一为圆心到起始控制点的球面距离,
1455
+ // 避免结束控制点(鼠标位置)到圆心距离与半径不一致导致扇形畸变。
1454
1456
  const arcPoints = this._getGeoArcPointsBetween(center, pnt2, pnt3, radius);
1455
- arcPoints.push(center);
1456
1457
 
1457
- return this.toPositions(arcPoints, height);
1458
+ // 扇形轮廓:圆心 -> 圆弧起点 -> 圆弧终点 -> 回到圆心(Cesium polygon 自动闭合)
1459
+ const sectorPoints = [center, ...arcPoints];
1460
+
1461
+ return this.toPositions(sectorPoints, height);
1458
1462
  }
1459
1463
 
1460
1464
  /**
@@ -4387,7 +4391,7 @@ class EditTool extends BaseTool {
4387
4391
  const screenPos = movement.position || (movement && movement.endPosition);
4388
4392
  if (!screenPos) return;
4389
4393
 
4390
- const picked = this._viewer.scene.pick({ x: screenPos.x, y: screenPos.y });
4394
+ const picked = this._viewer.scene.pick(screenPos);
4391
4395
  console.log('EditTool _onClick:', { picked, screenPos });
4392
4396
 
4393
4397
  if (Cesium.defined(picked) && Cesium.defined(picked.id)) {
@@ -4425,7 +4429,7 @@ class EditTool extends BaseTool {
4425
4429
 
4426
4430
  // 如果已经选中了图元,检查是否点击了控制点
4427
4431
  if (this._selectedEntity && !this._isDragging) {
4428
- const picked = this._viewer.scene.pick({ x: screenPos.x, y: screenPos.y });
4432
+ const picked = this._viewer.scene.pick(screenPos);
4429
4433
  if (Cesium.defined(picked) && Cesium.defined(picked.id)) {
4430
4434
  const entityId = picked.id.id;
4431
4435
  if (entityId && (entityId.startsWith('__edit_handle_') || entityId.includes('__edit_handle_'))) {
@@ -4478,7 +4482,7 @@ class EditTool extends BaseTool {
4478
4482
  this._lastMoveTime = now;
4479
4483
 
4480
4484
  // 检测是否悬停在控制点上
4481
- const picked = this._viewer.scene.pick({ x: screenPos.x, y: screenPos.y });
4485
+ const picked = this._viewer.scene.pick(screenPos);
4482
4486
  const isOverHandle = Cesium.defined(picked) && Cesium.defined(picked.id) &&
4483
4487
  picked.id.id && picked.id.id.startsWith('__edit_handle_');
4484
4488
  this._setCursorStyle(isOverHandle ? 'move' : 'default');
@@ -5970,7 +5974,7 @@ class SelectTool extends BaseTool {
5970
5974
  const screenPos = movement.position || (movement && movement.endPosition);
5971
5975
  if (!screenPos) return;
5972
5976
 
5973
- const picked = this._viewer.scene.pick({ x: screenPos.x, y: screenPos.y });
5977
+ const picked = this._viewer.scene.pick(screenPos);
5974
5978
 
5975
5979
  if (Cesium.defined(picked) && Cesium.defined(picked.id)) {
5976
5980
  const entityId = picked.id.id;
@@ -6024,6 +6028,7 @@ class SelectTool extends BaseTool {
6024
6028
  }
6025
6029
 
6026
6030
  _updateSelectionRectPreview(currentPos) {
6031
+ this._currentEndPos = currentPos;
6027
6032
  if (this._selectType === SelectType.RECTANGLE) {
6028
6033
  this._updateRectPreview(currentPos);
6029
6034
  } else if (this._selectType === SelectType.CIRCLE) {
@@ -6041,7 +6046,10 @@ class SelectTool extends BaseTool {
6041
6046
  this._selectionRect = this.createTempEntity(this.generateId('sel_rect'), {
6042
6047
  polyline: {
6043
6048
  positions: new Cesium.CallbackProperty(() => {
6044
- const pos = this._getRectPositions(corner1, corner2);
6049
+ const c1 = this._startPos;
6050
+ const c2 = this._currentEndPos;
6051
+ if (!c1 || !c2) return [];
6052
+ const pos = this._getRectPositions(c1, c2);
6045
6053
  return Cesium.Cartesian3.fromDegreesArrayHeights(pos.flatMap(c => [c[0], c[1], 0]));
6046
6054
  }, false),
6047
6055
  material: Cesium.Color.CYAN,
@@ -6049,7 +6057,10 @@ class SelectTool extends BaseTool {
6049
6057
  },
6050
6058
  polygon: {
6051
6059
  hierarchy: new Cesium.CallbackProperty(() => {
6052
- const pos = this._getRectPositions(corner1, corner2);
6060
+ const c1 = this._startPos;
6061
+ const c2 = this._currentEndPos;
6062
+ if (!c1 || !c2) return new Cesium.PolygonHierarchy([]);
6063
+ const pos = this._getRectPositions(c1, c2);
6053
6064
  return new Cesium.PolygonHierarchy(
6054
6065
  Cesium.Cartesian3.fromDegreesArrayHeights(pos.flatMap(c => [c[0], c[1], 0]))
6055
6066
  );
@@ -6072,8 +6083,11 @@ class SelectTool extends BaseTool {
6072
6083
  this._selectionRect = this.createTempEntity(this.generateId('sel_circle'), {
6073
6084
  polygon: {
6074
6085
  hierarchy: new Cesium.CallbackProperty(() => {
6075
- const radius = this._calcDistance(center, currentPos);
6076
- const positions = this._generateCirclePositions(center, radius, 36);
6086
+ const c = this._startPos;
6087
+ const edge = this._currentEndPos;
6088
+ if (!c || !edge) return new Cesium.PolygonHierarchy([]);
6089
+ const radius = this._calcDistance(c, edge);
6090
+ const positions = this._generateCirclePositions(c, radius, 36);
6077
6091
  return new Cesium.PolygonHierarchy(
6078
6092
  Cesium.Cartesian3.fromDegreesArrayHeights(
6079
6093
  positions.flatMap(c => [c[0], c[1], 0])
@@ -6459,7 +6473,7 @@ class DeleteTool extends BaseTool {
6459
6473
  const screenPos = movement.position || (movement && movement.endPosition);
6460
6474
  if (!screenPos) return;
6461
6475
 
6462
- const picked = this._viewer.scene.pick({ x: screenPos.x, y: screenPos.y });
6476
+ const picked = this._viewer.scene.pick(screenPos);
6463
6477
 
6464
6478
  if (Cesium.defined(picked) && Cesium.defined(picked.id)) {
6465
6479
  const entityId = picked.id.id;
@@ -6481,7 +6495,7 @@ class DeleteTool extends BaseTool {
6481
6495
  const screenPos = data.position || data.endPosition;
6482
6496
  if (!screenPos) return;
6483
6497
 
6484
- const picked = this._viewer.scene.pick({ x: screenPos.x, y: screenPos.y });
6498
+ const picked = this._viewer.scene.pick(screenPos);
6485
6499
 
6486
6500
  let hoveredId = null;
6487
6501
 
@@ -7022,6 +7036,1213 @@ class EditorModule {
7022
7036
  }
7023
7037
  }
7024
7038
 
7039
+ /**
7040
+ * 控件基类
7041
+ * 所有地图控件的抽象基类,提供 DOM 容器管理、挂载/移除、事件绑定等通用能力。
7042
+ * 参考 Mars3D BaseControl 设计,但保持轻量,不依赖 jQuery。
7043
+ */
7044
+ class BaseControl {
7045
+ /**
7046
+ * @param {object} options 控件配置
7047
+ * @param {string} options.type 控件类型标识
7048
+ * @param {string} [options.className] 容器额外 CSS 类名
7049
+ * @param {string|HTMLElement} [options.container] 自定义父容器,默认挂到 viewer.container
7050
+ * @param {number} [options.bottom=0] 底部偏移(px)
7051
+ * @param {number} [options.left] 左侧偏移(px)
7052
+ * @param {number} [options.right] 右侧偏移(px)
7053
+ * @param {number} [options.top] 顶部偏移(px)
7054
+ * @param {boolean} [options.visible=true] 初始是否可见
7055
+ */
7056
+ constructor(options = {}) {
7057
+ this._type = options.type || 'base';
7058
+ this._options = options;
7059
+ this._viewer = null;
7060
+ this._container = null;
7061
+ this._parentContainer = null;
7062
+ this._isAdded = false;
7063
+ this._handlers = [];
7064
+ }
7065
+
7066
+ /**
7067
+ * 控件类型
7068
+ */
7069
+ get type() {
7070
+ return this._type;
7071
+ }
7072
+
7073
+ /**
7074
+ * 获取控件 DOM 容器
7075
+ */
7076
+ get container() {
7077
+ return this._container;
7078
+ }
7079
+
7080
+ /**
7081
+ * 创建控件 DOM 容器
7082
+ * 子类可重写以自定义容器结构
7083
+ */
7084
+ _createContainer() {
7085
+ const div = document.createElement('div');
7086
+ div.className = `xs3d-control xs3d-control-${this._type}`;
7087
+ if (this._options.className) {
7088
+ div.className += ` ${this._options.className}`;
7089
+ }
7090
+ div.style.position = 'absolute';
7091
+ div.style.zIndex = '1000';
7092
+ div.style.pointerEvents = 'auto';
7093
+
7094
+ const positions = ['left', 'right', 'top', 'bottom'];
7095
+ positions.forEach(key => {
7096
+ if (this._options[key] !== undefined) {
7097
+ div.style[key] = typeof this._options[key] === 'number'
7098
+ ? `${this._options[key]}px`
7099
+ : this._options[key];
7100
+ }
7101
+ });
7102
+
7103
+ if (this._options.visible === false) {
7104
+ div.style.display = 'none';
7105
+ }
7106
+
7107
+ return div;
7108
+ }
7109
+
7110
+ /**
7111
+ * 获取父容器
7112
+ * 子类可重写,例如挂到 Cesium 工具栏
7113
+ */
7114
+ _getParentContainer() {
7115
+ if (this._options.container) {
7116
+ if (typeof this._options.container === 'string') {
7117
+ return document.querySelector(this._options.container);
7118
+ }
7119
+ return this._options.container;
7120
+ }
7121
+ return this._viewer?.container;
7122
+ }
7123
+
7124
+ /**
7125
+ * 获取或创建 SDK 统一工具栏(xs3d-viewer-toolbar)
7126
+ * 供内置控件共享一个右上角的垂直工具栏容器。
7127
+ */
7128
+ _getOrCreateToolbar() {
7129
+ const viewerContainer = this._viewer?.container;
7130
+ if (!viewerContainer) return null;
7131
+
7132
+ let toolbar = viewerContainer.querySelector('.xs3d-viewer-toolbar');
7133
+ if (!toolbar) {
7134
+ toolbar = document.createElement('div');
7135
+ toolbar.className = 'xs3d-viewer-toolbar';
7136
+ toolbar.style.position = 'absolute';
7137
+ toolbar.style.top = '5px';
7138
+ toolbar.style.right = '5px';
7139
+ toolbar.style.display = 'flex';
7140
+ toolbar.style.flexDirection = 'column';
7141
+ toolbar.style.alignItems = 'center';
7142
+ toolbar.style.gap = '4px';
7143
+ toolbar.style.padding = '4px';
7144
+ toolbar.style.background = 'rgba(63, 72, 84, 0.95)';
7145
+ toolbar.style.borderRadius = '4px';
7146
+ toolbar.style.boxShadow = '0 1px 4px rgba(0,0,0,0.35)';
7147
+ toolbar.style.zIndex = '1001';
7148
+ viewerContainer.appendChild(toolbar);
7149
+ }
7150
+ return toolbar;
7151
+ }
7152
+
7153
+ /**
7154
+ * 若当前父容器是统一工具栏,清除子容器的 absolute 定位
7155
+ */
7156
+ _clearAbsolutePositionIfInToolbar() {
7157
+ if (this._parentContainer?.classList?.contains('xs3d-viewer-toolbar') ||
7158
+ this._parentContainer?.classList?.contains('cesium-viewer-toolbar')) {
7159
+ this._container.style.position = 'static';
7160
+ this._container.style.top = '';
7161
+ this._container.style.right = '';
7162
+ this._container.style.bottom = '';
7163
+ this._container.style.left = '';
7164
+ }
7165
+ }
7166
+
7167
+ /**
7168
+ * 挂载到 Cesium Viewer
7169
+ * @param {Cesium.Viewer} viewer
7170
+ */
7171
+ addTo(viewer) {
7172
+ if (this._isAdded || !viewer) return this;
7173
+
7174
+ this._viewer = viewer;
7175
+ this._parentContainer = this._getParentContainer();
7176
+ this._container = this._createContainer();
7177
+
7178
+ // 若父容器是统一工具栏,子容器取消 absolute 定位,避免被自身 top/right 撑出工具栏
7179
+ if (this._parentContainer?.classList?.contains('xs3d-viewer-toolbar') ||
7180
+ this._parentContainer?.classList?.contains('cesium-viewer-toolbar')) {
7181
+ this._container.style.position = 'static';
7182
+ this._container.style.top = '';
7183
+ this._container.style.right = '';
7184
+ this._container.style.bottom = '';
7185
+ this._container.style.left = '';
7186
+ }
7187
+
7188
+ // 确保父容器有定位,否则 absolute 子元素会相对于 body 定位
7189
+ if (this._parentContainer) {
7190
+ const style = window.getComputedStyle(this._parentContainer);
7191
+ if (style.position === 'static') {
7192
+ this._parentContainer.style.position = 'relative';
7193
+ }
7194
+ }
7195
+
7196
+ // 子类挂载 DOM 内容
7197
+ this._mount();
7198
+
7199
+ if (this._parentContainer) {
7200
+ this._parentContainer.appendChild(this._container);
7201
+ }
7202
+
7203
+ // 绑定地图事件
7204
+ this._bindEvents();
7205
+
7206
+ this._isAdded = true;
7207
+ return this;
7208
+ }
7209
+
7210
+ /**
7211
+ * 从地图移除
7212
+ * @param {boolean} destroy 是否销毁控件
7213
+ */
7214
+ remove(destroy = true) {
7215
+ if (!this._isAdded) return this;
7216
+
7217
+ this._unbindEvents();
7218
+
7219
+ if (this._container && this._container.parentNode) {
7220
+ this._container.parentNode.removeChild(this._container);
7221
+ }
7222
+
7223
+ if (destroy) {
7224
+ this._destroy();
7225
+ }
7226
+
7227
+ this._container = null;
7228
+ this._parentContainer = null;
7229
+ this._viewer = null;
7230
+ this._isAdded = false;
7231
+ return this;
7232
+ }
7233
+
7234
+ /**
7235
+ * 显示控件
7236
+ */
7237
+ show() {
7238
+ if (this._container) {
7239
+ this._container.style.display = '';
7240
+ }
7241
+ return this;
7242
+ }
7243
+
7244
+ /**
7245
+ * 隐藏控件
7246
+ */
7247
+ hide() {
7248
+ if (this._container) {
7249
+ this._container.style.display = 'none';
7250
+ }
7251
+ return this;
7252
+ }
7253
+
7254
+ /**
7255
+ * 注册 Viewer/Cesium 事件处理器
7256
+ * @param {Cesium.Event} event Cesium 事件对象
7257
+ * @param {Function} callback 回调
7258
+ */
7259
+ _addCesiumHandler(event, callback) {
7260
+ const handler = event.addEventListener(callback);
7261
+ this._handlers.push({ type: 'cesium', event, handler });
7262
+ return handler;
7263
+ }
7264
+
7265
+ /**
7266
+ * 注册原生 DOM 事件
7267
+ */
7268
+ _addDomEvent(element, eventType, callback, options = false) {
7269
+ element.addEventListener(eventType, callback, options);
7270
+ this._handlers.push({ type: 'dom', element, eventType, callback, options });
7271
+ return this;
7272
+ }
7273
+
7274
+ /**
7275
+ * 解除所有事件绑定
7276
+ */
7277
+ _unbindEvents() {
7278
+ this._handlers.forEach(h => {
7279
+ if (h.type === 'cesium' && h.event && typeof h.handler === 'function') {
7280
+ h.handler();
7281
+ } else if (h.type === 'dom' && h.element) {
7282
+ h.element.removeEventListener(h.eventType, h.callback, h.options);
7283
+ }
7284
+ });
7285
+ this._handlers = [];
7286
+ }
7287
+
7288
+ /**
7289
+ * 子类重写:创建控件内部 DOM
7290
+ */
7291
+ _mount() { }
7292
+
7293
+ /**
7294
+ * 子类重写:绑定地图事件
7295
+ */
7296
+ _bindEvents() { }
7297
+
7298
+ /**
7299
+ * 子类重写:销毁内部资源
7300
+ */
7301
+ _destroy() { }
7302
+ }
7303
+
7304
+ /**
7305
+ * 状态栏控件
7306
+ * 显示鼠标位置经纬度、海拔、相机方向/俯仰角/视高、地图层级、FPS 等信息。
7307
+ * 支持自定义模板和显示字段。
7308
+ */
7309
+ class LocationBar extends BaseControl {
7310
+ /**
7311
+ * @param {object} options
7312
+ * @param {string} [options.template='经度:{lng} 纬度:{lat} 海拔:{alt}米 方向:{heading}° 俯仰:{pitch}° 视高:{cameraHeight}米']
7313
+ * @param {number} [options.latDecimal=6] 纬度小数位
7314
+ * @param {number} [options.lngDecimal=6] 经度小数位
7315
+ * @param {boolean} [options.showFPS=false] 是否显示 FPS
7316
+ * @param {number} [options.bottom=0] 底部偏移
7317
+ * @param {number} [options.left=0] 左侧偏移
7318
+ * @param {number} [options.right] 右侧偏移
7319
+ */
7320
+ constructor(options = {}) {
7321
+ super({
7322
+ type: 'locationBar',
7323
+ bottom: 0,
7324
+ right: 0,
7325
+ ...options
7326
+ });
7327
+
7328
+ this._template = options.template || '经度:{lng} 纬度:{lat} 海拔:{alt}米 方向:{heading}° 俯仰:{pitch}° 视高:{cameraHeight}米 层级:{level}';
7329
+ this._latDecimal = options.latDecimal ?? 6;
7330
+ this._lngDecimal = options.lngDecimal ?? 6;
7331
+ this._showFPS = options.showFPS ?? false;
7332
+ this._lastCameraData = {};
7333
+ this._lastMouseData = {};
7334
+ this._fps = 0;
7335
+ }
7336
+
7337
+ _createContainer() {
7338
+ const div = super._createContainer();
7339
+ div.className += ' xs3d-locationbar';
7340
+ div.style.display = 'flex';
7341
+ div.style.alignItems = 'center';
7342
+ div.style.gap = '12px';
7343
+ div.style.padding = '6px 12px';
7344
+ div.style.background = 'rgba(0, 0, 0, 0.55)';
7345
+ div.style.color = '#fff';
7346
+ div.style.fontSize = '12px';
7347
+ div.style.fontFamily = 'Microsoft YaHei, sans-serif';
7348
+ div.style.pointerEvents = 'none';
7349
+ div.style.userSelect = 'none';
7350
+ div.style.whiteSpace = 'nowrap';
7351
+ return div;
7352
+ }
7353
+
7354
+ _mount() {
7355
+ this._updateContent();
7356
+ }
7357
+
7358
+ _bindEvents() {
7359
+ // 鼠标移动:更新鼠标位置信息
7360
+ const canvas = this._viewer.scene.canvas;
7361
+ this._addDomEvent(canvas, 'mousemove', this._onMouseMove.bind(this));
7362
+ this._addDomEvent(canvas, 'mouseleave', this._onMouseLeave.bind(this));
7363
+
7364
+ // 相机变化:更新方向/俯仰/视高/层级
7365
+ this._addCesiumHandler(this._viewer.camera.changed, this._onCameraChanged.bind(this));
7366
+
7367
+ // FPS
7368
+ if (this._showFPS) {
7369
+ this._addCesiumHandler(this._viewer.scene.postRender, this._onPostRender.bind(this));
7370
+ this._fpsFrameCount = 0;
7371
+ this._fpsLastTime = performance.now();
7372
+ }
7373
+
7374
+ // 初始更新一次
7375
+ this._onCameraChanged();
7376
+ }
7377
+
7378
+ _onMouseMove(e) {
7379
+ const rect = this._viewer.scene.canvas.getBoundingClientRect();
7380
+ const x = e.clientX - rect.left;
7381
+ const y = e.clientY - rect.top;
7382
+ const screenPos = new Cesium.Cartesian2(x, y);
7383
+
7384
+ let cartesian = null;
7385
+
7386
+ // 优先使用 globe.pick 获取真实地表(含地形)高度
7387
+ const ray = this._viewer.camera.getPickRay(screenPos);
7388
+ if (ray) {
7389
+ cartesian = this._viewer.scene.globe.pick(ray, this._viewer.scene);
7390
+ }
7391
+
7392
+ // 如果地表不可见,回退到椭球面
7393
+ if (!cartesian) {
7394
+ cartesian = this._viewer.camera.pickEllipsoid(screenPos, this._viewer.scene.globe.ellipsoid);
7395
+ }
7396
+
7397
+ if (cartesian) {
7398
+ const cartographic = Cesium.Cartographic.fromCartesian(cartesian);
7399
+ this._lastMouseData = {
7400
+ lng: Cesium.Math.toDegrees(cartographic.longitude).toFixed(this._lngDecimal),
7401
+ lat: Cesium.Math.toDegrees(cartographic.latitude).toFixed(this._latDecimal),
7402
+ alt: cartographic.height.toFixed(2)
7403
+ };
7404
+ } else {
7405
+ this._lastMouseData = { lng: '-', lat: '-', alt: '-' };
7406
+ }
7407
+
7408
+ this._updateContent();
7409
+ }
7410
+
7411
+ _onMouseLeave() {
7412
+ this._lastMouseData = { lng: '-', lat: '-', alt: '-' };
7413
+ this._updateContent();
7414
+ }
7415
+
7416
+ _onCameraChanged() {
7417
+ const camera = this._viewer.camera;
7418
+ const positionCartographic = camera.positionCartographic;
7419
+
7420
+ this._lastCameraData = {
7421
+ heading: Cesium.Math.toDegrees(camera.heading).toFixed(2),
7422
+ pitch: Cesium.Math.toDegrees(camera.pitch).toFixed(2),
7423
+ cameraHeight: positionCartographic.height.toFixed(2),
7424
+ level: this._getZoomLevel().toFixed(0)
7425
+ };
7426
+
7427
+ this._updateContent();
7428
+ }
7429
+
7430
+ _onPostRender() {
7431
+ this._fpsFrameCount++;
7432
+ const now = performance.now();
7433
+ const elapsed = now - this._fpsLastTime;
7434
+
7435
+ if (elapsed >= 1000) {
7436
+ this._fps = Math.round((this._fpsFrameCount * 1000) / elapsed);
7437
+ this._fpsFrameCount = 0;
7438
+ this._fpsLastTime = now;
7439
+ this._updateContent();
7440
+ }
7441
+ }
7442
+
7443
+ _getZoomLevel() {
7444
+ const camera = this._viewer.camera;
7445
+ const positionCartographic = camera.positionCartographic;
7446
+
7447
+ // 计算相机到正下方地表点的距离(视高),而不是到自己位置的距离
7448
+ const surfaceCartesian = Cesium.Cartesian3.fromRadians(
7449
+ positionCartographic.longitude,
7450
+ positionCartographic.latitude,
7451
+ 0
7452
+ );
7453
+ const distance = Cesium.Cartesian3.distance(camera.position, surfaceCartesian);
7454
+
7455
+ if (!distance || distance <= 0 || !Number.isFinite(distance)) return 0;
7456
+
7457
+ // 赤道周长约 40075017 米,映射为类似瓦片层级
7458
+ const equatorCircumference = 6378137 * 2 * Math.PI;
7459
+ return Math.max(0, Math.floor(Math.log2(equatorCircumference / distance)) + 1);
7460
+ }
7461
+
7462
+ _updateContent() {
7463
+ if (!this._container) return;
7464
+
7465
+ const data = {
7466
+ ...this._lastMouseData,
7467
+ ...this._lastCameraData,
7468
+ fps: this._fps
7469
+ };
7470
+
7471
+ let html = this._template;
7472
+ Object.keys(data).forEach(key => {
7473
+ html = html.replace(new RegExp(`{${key}}`, 'g'), data[key]);
7474
+ });
7475
+
7476
+ // 如果开启 FPS 但模板里没有 {fps},则自动追加到末尾
7477
+ if (this._showFPS && !this._template.includes('{fps}')) {
7478
+ html += ` FPS:${this._fps}`;
7479
+ }
7480
+
7481
+ this._container.innerHTML = html;
7482
+ }
7483
+ }
7484
+
7485
+ /**
7486
+ * 缩放控件
7487
+ * 提供放大/缩小两个按钮,默认挂到 SDK 统一工具栏(xs3d-viewer-toolbar)。
7488
+ */
7489
+ class Zoom extends BaseControl {
7490
+ /**
7491
+ * @param {object} options
7492
+ * @param {number} [options.duration=0.5] 缩放动画时长(秒)
7493
+ */
7494
+ constructor(options = {}) {
7495
+ super({
7496
+ type: 'zoom',
7497
+ ...options
7498
+ });
7499
+
7500
+ this._duration = options.duration ?? 0.5;
7501
+ }
7502
+
7503
+ _createContainer() {
7504
+ const div = super._createContainer();
7505
+ div.className += ' xs3d-zoom';
7506
+ div.style.display = 'contents';
7507
+ return div;
7508
+ }
7509
+
7510
+ _getParentContainer() {
7511
+ if (this._options.container) {
7512
+ return super._getParentContainer();
7513
+ }
7514
+ return this._getOrCreateToolbar();
7515
+ }
7516
+
7517
+ _mount() {
7518
+ this._clearAbsolutePositionIfInToolbar();
7519
+
7520
+ this._zoomInBtn = this._createButton('+', '放大');
7521
+ this._zoomOutBtn = this._createButton('-', '缩小');
7522
+
7523
+ this._container.appendChild(this._zoomInBtn);
7524
+ this._container.appendChild(this._zoomOutBtn);
7525
+ }
7526
+
7527
+ _createButton(label, title) {
7528
+ const btn = document.createElement('button');
7529
+ btn.type = 'button';
7530
+ btn.title = title;
7531
+ btn.className = 'cesium-button cesium-toolbar-button xs3d-zoom-button';
7532
+ btn.textContent = label;
7533
+ btn.style.width = '32px';
7534
+ btn.style.height = '32px';
7535
+ btn.style.lineHeight = '1';
7536
+ btn.style.fontSize = '18px';
7537
+ btn.style.fontWeight = 'bold';
7538
+ btn.style.display = 'flex';
7539
+ btn.style.alignItems = 'center';
7540
+ btn.style.justifyContent = 'center';
7541
+ btn.style.cursor = 'pointer';
7542
+ // 在统一工具栏内按 order 排列:指南针 -1,放大 1,缩小 2
7543
+ btn.style.order = label === '+' ? '1' : '2';
7544
+ return btn;
7545
+ }
7546
+
7547
+ _bindEvents() {
7548
+ this._addDomEvent(this._zoomInBtn, 'click', (e) => {
7549
+ e.stopPropagation();
7550
+ this._zoomIn();
7551
+ });
7552
+
7553
+ this._addDomEvent(this._zoomOutBtn, 'click', (e) => {
7554
+ e.stopPropagation();
7555
+ this._zoomOut();
7556
+ });
7557
+ }
7558
+
7559
+ _zoomIn() {
7560
+ this._zoomBy(1);
7561
+ }
7562
+
7563
+ _zoomOut() {
7564
+ this._zoomBy(-1);
7565
+ }
7566
+
7567
+ /**
7568
+ * 按地图层级步进缩放
7569
+ * @param {number} levelDelta 层级变化量,+1 放大一级,-1 缩小一级
7570
+ */
7571
+ _zoomBy(levelDelta) {
7572
+ const camera = this._viewer.camera;
7573
+ const cartographic = camera.positionCartographic;
7574
+ const currentLevel = this._getZoomLevel();
7575
+ const targetLevel = Math.max(0, Math.min(24, currentLevel + levelDelta));
7576
+ const targetHeight = this._getHeightByZoomLevel(targetLevel);
7577
+
7578
+ camera.flyTo({
7579
+ destination: Cesium.Cartesian3.fromRadians(
7580
+ cartographic.longitude,
7581
+ cartographic.latitude,
7582
+ targetHeight
7583
+ ),
7584
+ orientation: {
7585
+ heading: camera.heading,
7586
+ pitch: camera.pitch,
7587
+ roll: camera.roll
7588
+ },
7589
+ duration: this._duration
7590
+ });
7591
+ }
7592
+
7593
+ _getZoomLevel() {
7594
+ const positionCartographic = this._viewer.camera.positionCartographic;
7595
+ const surfaceCartesian = Cesium.Cartesian3.fromRadians(
7596
+ positionCartographic.longitude,
7597
+ positionCartographic.latitude,
7598
+ 0
7599
+ );
7600
+ const distance = Cesium.Cartesian3.distance(this._viewer.camera.position, surfaceCartesian);
7601
+ if (!distance || distance <= 0 || !Number.isFinite(distance)) return 0;
7602
+ const equatorCircumference = 6378137 * 2 * Math.PI;
7603
+ return Math.max(0, Math.floor(Math.log2(equatorCircumference / distance)) + 1);
7604
+ }
7605
+
7606
+ _getHeightByZoomLevel(level) {
7607
+ const equatorCircumference = 6378137 * 2 * Math.PI;
7608
+ return equatorCircumference / Math.pow(2, Math.max(0, level - 1));
7609
+ }
7610
+ }
7611
+
7612
+ /**
7613
+ * 指南针控件
7614
+ * 参考 Mars3D:外环随相机 heading 旋转,拖拽外环可旋转视角,
7615
+ * 拖拽中心球可调整俯仰角,双击复位到正北。
7616
+ */
7617
+ class Compass extends BaseControl {
7618
+ /**
7619
+ * @param {object} options
7620
+ * @param {number} [options.size=60] 指南针尺寸(px)
7621
+ * @param {boolean} [options.rotation=true] 是否启用中心拖拽调整俯仰角
7622
+ * @param {number} [options.top=5] 顶部偏移(未挂到工具栏时生效)
7623
+ * @param {number} [options.right=10] 右侧偏移(未挂到工具栏时生效)
7624
+ */
7625
+ constructor(options = {}) {
7626
+ super({
7627
+ type: 'compass',
7628
+ top: 5,
7629
+ right: 5,
7630
+ ...options
7631
+ });
7632
+
7633
+ this._size = options.size ?? 60;
7634
+ this._rotationEnabled = options.rotation !== false;
7635
+
7636
+ // 临时鼠标事件句柄(需要手动清理)
7637
+ this._moveHandle = null;
7638
+ this._upHandle = null;
7639
+ }
7640
+
7641
+ _createContainer() {
7642
+ const div = super._createContainer();
7643
+ div.className += ' xs3d-compass';
7644
+ div.style.width = `${this._size}px`;
7645
+ div.style.height = `${this._size}px`;
7646
+ div.style.borderRadius = '50%';
7647
+ div.style.background = '#3f4854';
7648
+ div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.35)';
7649
+ div.style.cursor = 'pointer';
7650
+ div.style.display = 'flex';
7651
+ div.style.alignItems = 'center';
7652
+ div.style.justifyContent = 'center';
7653
+ div.style.overflow = 'hidden';
7654
+ div.style.userSelect = 'none';
7655
+ return div;
7656
+ }
7657
+
7658
+ _getParentContainer() {
7659
+ if (this._options.container) {
7660
+ return super._getParentContainer();
7661
+ }
7662
+ // 指南针单独定位在地图右上角,不放入工具栏
7663
+ return this._viewer?.container;
7664
+ }
7665
+
7666
+ _mount() {
7667
+ this._clearAbsolutePositionIfInToolbar();
7668
+
7669
+ // 外环:随 heading 旋转
7670
+ this._outerEl = document.createElement('div');
7671
+ this._outerEl.style.position = 'absolute';
7672
+ this._outerEl.style.top = '0';
7673
+ this._outerEl.style.left = '0';
7674
+ this._outerEl.style.width = '100%';
7675
+ this._outerEl.style.height = '100%';
7676
+ this._outerEl.innerHTML = this._getOuterSvg();
7677
+ this._outerEl.style.transition = 'transform 0.1s linear';
7678
+
7679
+ // 内球:固定中心
7680
+ this._innerEl = document.createElement('div');
7681
+ this._innerEl.style.position = 'absolute';
7682
+ this._innerEl.style.top = '50%';
7683
+ this._innerEl.style.left = '50%';
7684
+ this._innerEl.style.width = `${Math.round(this._size * 0.42)}px`;
7685
+ this._innerEl.style.height = `${Math.round(this._size * 0.42)}px`;
7686
+ this._innerEl.style.transform = 'translate(-50%, -50%)';
7687
+ this._innerEl.innerHTML = this._getInnerSvg();
7688
+
7689
+ // 旋转弧:拖拽中心时显示
7690
+ this._arcEl = document.createElement('div');
7691
+ this._arcEl.style.position = 'absolute';
7692
+ this._arcEl.style.top = '0';
7693
+ this._arcEl.style.left = '0';
7694
+ this._arcEl.style.width = '100%';
7695
+ this._arcEl.style.height = '100%';
7696
+ this._arcEl.style.visibility = 'hidden';
7697
+ this._arcEl.innerHTML = this._getArcSvg();
7698
+
7699
+ this._container.appendChild(this._outerEl);
7700
+ this._container.appendChild(this._innerEl);
7701
+ this._container.appendChild(this._arcEl);
7702
+ }
7703
+
7704
+ _getOuterSvg() {
7705
+ return `
7706
+ <svg viewBox="0 0 100 100" width="100%" height="100%">
7707
+ <defs>
7708
+ <linearGradient id="compass-ring" x1="0%" y1="0%" x2="0%" y2="100%">
7709
+ <stop offset="0%" style="stop-color:#4a5563"/>
7710
+ <stop offset="100%" style="stop-color:#2d333b"/>
7711
+ </linearGradient>
7712
+ </defs>
7713
+ <circle cx="50" cy="50" r="48" fill="url(#compass-ring)" stroke="#5a6470" stroke-width="1"/>
7714
+ <g stroke="#ffffff" stroke-width="1" opacity="0.7">
7715
+ <line x1="50" y1="4" x2="50" y2="12"/>
7716
+ <line x1="50" y1="88" x2="50" y2="96"/>
7717
+ <line x1="4" y1="50" x2="12" y2="50"/>
7718
+ <line x1="88" y1="50" x2="96" y2="50"/>
7719
+ </g>
7720
+ <text x="50" y="24" text-anchor="middle" fill="#ff4d4f" font-size="12" font-weight="bold">N</text>
7721
+ <text x="50" y="86" text-anchor="middle" fill="#999" font-size="10">S</text>
7722
+ <text x="86" y="54" text-anchor="middle" fill="#999" font-size="10">E</text>
7723
+ <text x="14" y="54" text-anchor="middle" fill="#999" font-size="10">W</text>
7724
+ </svg>
7725
+ `;
7726
+ }
7727
+
7728
+ _getInnerSvg() {
7729
+ return `
7730
+ <svg viewBox="0 0 100 100" width="100%" height="100%">
7731
+ <circle cx="50" cy="50" r="48" fill="#ffffff"/>
7732
+ <circle cx="50" cy="50" r="38" fill="#68adfe"/>
7733
+ <circle cx="50" cy="50" r="14" fill="#ffffff"/>
7734
+ <path d="M50 30 L56 46 L50 44 L44 46 Z" fill="#3f4854"/>
7735
+ <circle cx="50" cy="50" r="4" fill="#3f4854"/>
7736
+ </svg>
7737
+ `;
7738
+ }
7739
+
7740
+ _getArcSvg() {
7741
+ return `
7742
+ <svg viewBox="0 0 100 100" width="100%" height="100%">
7743
+ <path d="M50 4 A46 46 0 0 1 96 50" fill="none" stroke="#68adfe" stroke-width="4" opacity="0.6"/>
7744
+ </svg>
7745
+ `;
7746
+ }
7747
+
7748
+ _bindEvents() {
7749
+ // 相机方向变化时旋转外环
7750
+ this._addCesiumHandler(this._viewer.camera.changed, this._updateRotation.bind(this));
7751
+
7752
+ // 鼠标交互
7753
+ this._addDomEvent(this._container, 'mousedown', this._onMouseDown.bind(this));
7754
+ this._addDomEvent(this._container, 'dblclick', this._onDoubleClick.bind(this));
7755
+
7756
+ this._updateRotation();
7757
+ }
7758
+
7759
+ _updateRotation() {
7760
+ if (!this._outerEl) return;
7761
+ const heading = Cesium.Math.toDegrees(this._viewer.camera.heading);
7762
+ this._outerEl.style.transform = `rotate(${-heading}deg)`;
7763
+ }
7764
+
7765
+ _onMouseDown(e) {
7766
+ e.preventDefault();
7767
+ e.stopPropagation();
7768
+
7769
+ const rect = this._container.getBoundingClientRect();
7770
+ const centerX = rect.left + rect.width / 2;
7771
+ const centerY = rect.top + rect.height / 2;
7772
+ const dx = e.clientX - centerX;
7773
+ const dy = e.clientY - centerY;
7774
+ const distance = Math.sqrt(dx * dx + dy * dy);
7775
+ const radius = rect.width / 2;
7776
+
7777
+ if (this._rotationEnabled && distance < radius * 0.38) {
7778
+ this._startOrbit(e);
7779
+ } else if (distance < radius) {
7780
+ this._startRotate(e);
7781
+ }
7782
+ }
7783
+
7784
+ _getAngle(e) {
7785
+ const rect = this._container.getBoundingClientRect();
7786
+ const centerX = rect.left + rect.width / 2;
7787
+ const centerY = rect.top + rect.height / 2;
7788
+ return Math.atan2(e.clientY - centerY, e.clientX - centerX);
7789
+ }
7790
+
7791
+ _startRotate(e) {
7792
+ this._mode = 'rotate';
7793
+ this._lastAngle = this._getAngle(e);
7794
+ this._rotateFrame = this._getRotateFrame();
7795
+
7796
+ this._moveHandle = (ev) => this._onRotateMove(ev);
7797
+ this._upHandle = () => this._stopRotate();
7798
+ document.addEventListener('mousemove', this._moveHandle, false);
7799
+ document.addEventListener('mouseup', this._upHandle, false);
7800
+ }
7801
+
7802
+ _onRotateMove(e) {
7803
+ if (this._mode !== 'rotate') return;
7804
+ const angle = this._getAngle(e);
7805
+ let delta = angle - this._lastAngle;
7806
+ this._lastAngle = angle;
7807
+
7808
+ // 处理角度跳变(-PI 到 PI)
7809
+ if (delta > Math.PI) delta -= Math.PI * 2;
7810
+ if (delta < -Math.PI) delta += Math.PI * 2;
7811
+
7812
+ const camera = this._viewer.camera;
7813
+ if (this._rotateFrame) {
7814
+ const oldTransform = Cesium.Matrix4.clone(camera.transform);
7815
+ camera.lookAtTransform(this._rotateFrame);
7816
+ camera.rotateRight(delta);
7817
+ camera.lookAtTransform(oldTransform);
7818
+ } else {
7819
+ camera.setView({
7820
+ destination: camera.position,
7821
+ orientation: {
7822
+ heading: camera.heading - delta,
7823
+ pitch: camera.pitch,
7824
+ roll: camera.roll
7825
+ }
7826
+ });
7827
+ }
7828
+ }
7829
+
7830
+ _stopRotate() {
7831
+ this._mode = null;
7832
+ this._rotateFrame = null;
7833
+ document.removeEventListener('mousemove', this._moveHandle, false);
7834
+ document.removeEventListener('mouseup', this._upHandle, false);
7835
+ this._moveHandle = null;
7836
+ this._upHandle = null;
7837
+ }
7838
+
7839
+ _startOrbit(e) {
7840
+ this._mode = 'orbit';
7841
+ this._startY = e.clientY;
7842
+ this._startPitch = this._viewer.camera.pitch;
7843
+ this._startHeading = this._viewer.camera.heading;
7844
+ if (this._arcEl) this._arcEl.style.visibility = 'visible';
7845
+
7846
+ this._moveHandle = (ev) => this._onOrbitMove(ev);
7847
+ this._upHandle = () => this._stopOrbit();
7848
+ document.addEventListener('mousemove', this._moveHandle, false);
7849
+ document.addEventListener('mouseup', this._upHandle, false);
7850
+ }
7851
+
7852
+ _onOrbitMove(e) {
7853
+ if (this._mode !== 'orbit') return;
7854
+ const deltaY = this._startY - e.clientY;
7855
+ const sensitivity = 0.004;
7856
+ const newPitch = Cesium.Math.clamp(
7857
+ this._startPitch + deltaY * sensitivity,
7858
+ -Cesium.Math.PI_OVER_TWO + 0.01,
7859
+ Cesium.Math.PI_OVER_TWO - 0.01
7860
+ );
7861
+ this._viewer.camera.setView({
7862
+ destination: this._viewer.camera.position,
7863
+ orientation: {
7864
+ heading: this._startHeading,
7865
+ pitch: newPitch,
7866
+ roll: this._viewer.camera.roll
7867
+ }
7868
+ });
7869
+ }
7870
+
7871
+ _stopOrbit() {
7872
+ this._mode = null;
7873
+ if (this._arcEl) this._arcEl.style.visibility = 'hidden';
7874
+ document.removeEventListener('mousemove', this._moveHandle, false);
7875
+ document.removeEventListener('mouseup', this._upHandle, false);
7876
+ this._moveHandle = null;
7877
+ this._upHandle = null;
7878
+ }
7879
+
7880
+ _getRotateFrame() {
7881
+ const scene = this._viewer.scene;
7882
+ const camera = this._viewer.camera;
7883
+ if (scene.mode === Cesium.SceneMode.MORPHING) return null;
7884
+
7885
+ let center;
7886
+ if (this._viewer.trackedEntity) {
7887
+ center = this._viewer.trackedEntity.position.getValue(this._viewer.clock.currentTime);
7888
+ } else {
7889
+ const ray = new Cesium.Ray(camera.positionWC, camera.directionWC);
7890
+ center = scene.globe.pick(ray, scene);
7891
+ }
7892
+ if (!center) {
7893
+ center = camera.positionWC;
7894
+ }
7895
+ return Cesium.Transforms.eastNorthUpToFixedFrame(center, scene.globe.ellipsoid);
7896
+ }
7897
+
7898
+ _onDoubleClick() {
7899
+ const camera = this._viewer.camera;
7900
+ const cartographic = camera.positionCartographic;
7901
+ camera.flyTo({
7902
+ destination: Cesium.Cartesian3.fromRadians(
7903
+ cartographic.longitude,
7904
+ cartographic.latitude,
7905
+ cartographic.height
7906
+ ),
7907
+ orientation: {
7908
+ heading: 0,
7909
+ pitch: camera.pitch,
7910
+ roll: camera.roll
7911
+ },
7912
+ duration: 0.5
7913
+ });
7914
+ }
7915
+
7916
+ remove(destroy = true) {
7917
+ this._stopRotate();
7918
+ this._stopOrbit();
7919
+ return super.remove(destroy);
7920
+ }
7921
+ }
7922
+
7923
+ /**
7924
+ * 比例尺控件
7925
+ * 参考 Mars3D 实现:在屏幕底部取水平相邻两像素点,计算其地表测地距离,
7926
+ * 从而得到“1 像素对应多少米”,再动态选择合适刻度显示。
7927
+ */
7928
+ class DistanceLegend extends BaseControl {
7929
+ /**
7930
+ * @param {object} options
7931
+ * @param {number} [options.bottom=20] 底部偏移
7932
+ * @param {number} [options.left=20] 左侧偏移
7933
+ * @param {number} [options.maxWidth=100] 比例尺最大宽度(px)
7934
+ */
7935
+ constructor(options = {}) {
7936
+ super({
7937
+ type: 'distanceLegend',
7938
+ bottom: 20,
7939
+ left: 20,
7940
+ ...options
7941
+ });
7942
+
7943
+ this._maxWidth = options.maxWidth ?? 100;
7944
+
7945
+ // 刻度基数 1,2,3,5 及其 10^n 倍
7946
+ const base = [1, 2, 3, 5];
7947
+ this._distances = [];
7948
+ for (let i = 0; i < 7; i++) {
7949
+ const factor = Math.pow(10, i);
7950
+ base.forEach(b => this._distances.push(b * factor));
7951
+ }
7952
+
7953
+ this._geodesic = new Cesium.EllipsoidGeodesic();
7954
+ this._lastUpdate = 0;
7955
+ }
7956
+
7957
+ _createContainer() {
7958
+ const div = super._createContainer();
7959
+ div.className += ' xs3d-distance-legend';
7960
+ div.style.display = 'flex';
7961
+ div.style.flexDirection = 'column';
7962
+ div.style.alignItems = 'center';
7963
+ div.style.color = '#fff';
7964
+ div.style.fontSize = '11px';
7965
+ div.style.fontFamily = 'Microsoft YaHei, sans-serif';
7966
+ div.style.textShadow = '0 1px 2px rgba(0,0,0,0.5)';
7967
+ div.style.userSelect = 'none';
7968
+ div.style.pointerEvents = 'none';
7969
+ div.style.visibility = 'hidden';
7970
+ return div;
7971
+ }
7972
+
7973
+ _mount() {
7974
+ this._label = document.createElement('div');
7975
+ this._label.style.marginBottom = '2px';
7976
+
7977
+ this._line = document.createElement('div');
7978
+ this._line.style.height = '6px';
7979
+ this._line.style.borderLeft = '2px solid #fff';
7980
+ this._line.style.borderRight = '2px solid #fff';
7981
+ this._line.style.borderBottom = '2px solid #fff';
7982
+ this._line.style.boxSizing = 'border-box';
7983
+
7984
+ this._container.appendChild(this._label);
7985
+ this._container.appendChild(this._line);
7986
+ }
7987
+
7988
+ _bindEvents() {
7989
+ this._addCesiumHandler(this._viewer.scene.postRender, this._update.bind(this));
7990
+ }
7991
+
7992
+ _update(scene, time) {
7993
+ if (!this._viewer) return;
7994
+
7995
+ // 每 250ms 更新一次,避免频繁重绘
7996
+ const now = performance.now();
7997
+ if (now < this._lastUpdate + 250) return;
7998
+ this._lastUpdate = now;
7999
+
8000
+ const camera = this._viewer.camera;
8001
+ const canvas = scene.canvas;
8002
+ const width = canvas.width;
8003
+ const height = canvas.height;
8004
+
8005
+ if (!width || !height) return;
8006
+
8007
+ // 屏幕底部中心相邻两像素
8008
+ const x = Math.floor(width / 2);
8009
+ const y = Math.max(0, height - 1);
8010
+
8011
+ const leftRay = camera.getPickRay(new Cesium.Cartesian2(x, y));
8012
+ const rightRay = camera.getPickRay(new Cesium.Cartesian2(x + 1, y));
8013
+ if (!leftRay || !rightRay) return;
8014
+
8015
+ const leftPosition = scene.globe.pick(leftRay, scene);
8016
+ const rightPosition = scene.globe.pick(rightRay, scene);
8017
+ if (!leftPosition || !rightPosition) {
8018
+ this._container.style.visibility = 'hidden';
8019
+ return;
8020
+ }
8021
+
8022
+ this._geodesic.setEndPoints(
8023
+ scene.globe.ellipsoid.cartesianToCartographic(leftPosition),
8024
+ scene.globe.ellipsoid.cartesianToCartographic(rightPosition)
8025
+ );
8026
+
8027
+ const pixelDistance = this._geodesic.surfaceDistance;
8028
+ if (!pixelDistance || pixelDistance <= 0 || !Number.isFinite(pixelDistance)) {
8029
+ this._container.style.visibility = 'hidden';
8030
+ return;
8031
+ }
8032
+
8033
+ let distance = 0;
8034
+ for (let i = this._distances.length - 1; i >= 0; i--) {
8035
+ if (this._distances[i] / pixelDistance < this._maxWidth) {
8036
+ distance = this._distances[i];
8037
+ break;
8038
+ }
8039
+ }
8040
+
8041
+ if (distance) {
8042
+ const barWidth = Math.floor(distance / pixelDistance);
8043
+ this._label.textContent = distance >= 1000 ? `${distance / 1000} km` : `${distance} m`;
8044
+ this._line.style.width = `${barWidth}px`;
8045
+ this._container.style.visibility = 'visible';
8046
+ } else {
8047
+ this._container.style.visibility = 'hidden';
8048
+ }
8049
+ }
8050
+ }
8051
+
8052
+ /**
8053
+ * 控件管理模块
8054
+ * 统一管理地图控件的添加、移除和访问。
8055
+ */
8056
+ class ControlModule {
8057
+ constructor(viewer) {
8058
+ this._viewer = viewer;
8059
+ this._controls = new Map();
8060
+ this._toolbar = null;
8061
+ }
8062
+
8063
+ /**
8064
+ * 添加控件
8065
+ * @param {string|BaseControl} type 控件类型或控件实例
8066
+ * @param {object} [options] 控件配置(当 type 为字符串时)
8067
+ * @returns {BaseControl} 控件实例
8068
+ */
8069
+ addControl(type, options = {}) {
8070
+ // 内置工具栏控件(zoom)共享统一容器,compass 单独定位在工具栏上方
8071
+ if (typeof type === 'string' && !options.container && type === 'zoom') {
8072
+ options = { ...options, container: this._getOrCreateToolbar() };
8073
+ }
8074
+
8075
+ let control;
8076
+
8077
+ if (typeof type === 'string') {
8078
+ control = this._createControl(type, options);
8079
+ } else if (type && typeof type.addTo === 'function') {
8080
+ control = type;
8081
+ } else {
8082
+ console.warn(`ControlModule: 未知控件类型 ${type}`);
8083
+ return null;
8084
+ }
8085
+
8086
+ if (!control) return null;
8087
+
8088
+ // 同类型控件先移除
8089
+ if (this._controls.has(control.type)) {
8090
+ this.removeControl(control.type);
8091
+ }
8092
+
8093
+ control.addTo(this._viewer);
8094
+ this._controls.set(control.type, control);
8095
+
8096
+ return control;
8097
+ }
8098
+
8099
+ /**
8100
+ * 移除控件
8101
+ * @param {string} type 控件类型
8102
+ * @param {boolean} [destroy=true] 是否销毁
8103
+ * @returns {boolean} 是否移除成功
8104
+ */
8105
+ removeControl(type, destroy = true) {
8106
+ const control = this._controls.get(type);
8107
+ if (!control) return false;
8108
+
8109
+ control.remove(destroy);
8110
+ this._controls.delete(type);
8111
+ return true;
8112
+ }
8113
+
8114
+ /**
8115
+ * 获取控件
8116
+ * @param {string} type 控件类型
8117
+ * @returns {BaseControl|undefined}
8118
+ */
8119
+ getControl(type) {
8120
+ return this._controls.get(type);
8121
+ }
8122
+
8123
+ /**
8124
+ * 获取所有已添加的控件
8125
+ * @returns {BaseControl[]}
8126
+ */
8127
+ getAllControls() {
8128
+ return Array.from(this._controls.values());
8129
+ }
8130
+
8131
+ /**
8132
+ * 显示控件
8133
+ * @param {string} type
8134
+ */
8135
+ showControl(type) {
8136
+ const control = this._controls.get(type);
8137
+ if (control) control.show();
8138
+ return this;
8139
+ }
8140
+
8141
+ /**
8142
+ * 隐藏控件
8143
+ * @param {string} type
8144
+ */
8145
+ hideControl(type) {
8146
+ const control = this._controls.get(type);
8147
+ if (control) control.hide();
8148
+ return this;
8149
+ }
8150
+
8151
+ /**
8152
+ * 批量添加控件
8153
+ * @param {object} controlsConfig { locationBar: true, zoom: { ... }, ... }
8154
+ */
8155
+ addControls(controlsConfig = {}) {
8156
+ const keys = Object.keys(controlsConfig);
8157
+ // 让 compass 先于 zoom 添加,确保指南针在工具栏顶部
8158
+ keys.sort((a, b) => {
8159
+ if (a === 'compass') return -1;
8160
+ if (b === 'compass') return 1;
8161
+ return 0;
8162
+ });
8163
+
8164
+ keys.forEach(type => {
8165
+ const config = controlsConfig[type];
8166
+ if (config === true) {
8167
+ this.addControl(type);
8168
+ } else if (config && typeof config === 'object') {
8169
+ this.addControl(type, config);
8170
+ }
8171
+ });
8172
+ return this;
8173
+ }
8174
+
8175
+ /**
8176
+ * 移除所有控件
8177
+ * @param {boolean} [destroy=true]
8178
+ */
8179
+ removeAllControls(destroy = true) {
8180
+ this._controls.forEach((control, type) => {
8181
+ control.remove(destroy);
8182
+ });
8183
+ this._controls.clear();
8184
+ return this;
8185
+ }
8186
+
8187
+ /**
8188
+ * 销毁模块
8189
+ */
8190
+ destroy() {
8191
+ this.removeAllControls(true);
8192
+ if (this._toolbar && this._toolbar.parentNode) {
8193
+ this._toolbar.parentNode.removeChild(this._toolbar);
8194
+ }
8195
+ this._toolbar = null;
8196
+ this._viewer = null;
8197
+ }
8198
+
8199
+ /**
8200
+ * 获取或创建统一工具栏
8201
+ */
8202
+ _getOrCreateToolbar() {
8203
+ if (this._toolbar) return this._toolbar;
8204
+
8205
+ const viewerContainer = this._viewer?.container;
8206
+ if (!viewerContainer) return null;
8207
+
8208
+ this._toolbar = document.createElement('div');
8209
+ this._toolbar.className = 'xs3d-viewer-toolbar';
8210
+ this._toolbar.style.position = 'absolute';
8211
+ this._toolbar.style.top = '70px';
8212
+ this._toolbar.style.right = '10px';
8213
+ this._toolbar.style.display = 'flex';
8214
+ this._toolbar.style.flexDirection = 'column';
8215
+ this._toolbar.style.alignItems = 'center';
8216
+ this._toolbar.style.gap = '4px';
8217
+ this._toolbar.style.padding = '4px';
8218
+ // this._toolbar.style.background = 'rgba(63, 72, 84, 0.95)';
8219
+ this._toolbar.style.borderRadius = '4px';
8220
+ this._toolbar.style.boxShadow = '0 1px 4px rgba(0,0,0,0.35)';
8221
+ this._toolbar.style.zIndex = '1001';
8222
+ viewerContainer.appendChild(this._toolbar);
8223
+ return this._toolbar;
8224
+ }
8225
+
8226
+ /**
8227
+ * 根据类型创建内置控件
8228
+ */
8229
+ _createControl(type, options) {
8230
+ switch (type) {
8231
+ case 'locationBar':
8232
+ return new LocationBar(options);
8233
+ case 'zoom':
8234
+ return new Zoom(options);
8235
+ case 'compass':
8236
+ return new Compass(options);
8237
+ case 'distanceLegend':
8238
+ return new DistanceLegend(options);
8239
+ default:
8240
+ console.warn(`ControlModule: 未注册的内置控件类型 "${type}"`);
8241
+ return null;
8242
+ }
8243
+ }
8244
+ }
8245
+
7025
8246
  /**
7026
8247
  * 模块管理器(对应平台 GetModule 逻辑)
7027
8248
  * 统一管理场景、图元、事件模块的实例
@@ -7070,6 +8291,16 @@ class ModuleManager {
7070
8291
  }).init();
7071
8292
  this.registerModule('EditorModule', editorModule);
7072
8293
 
8294
+ // 初始化控件模块
8295
+ const controlModule = new ControlModule(this.cesiumViewer);
8296
+ this.registerModule('ControlModule', controlModule);
8297
+
8298
+ // 如果配置中声明了 controls,自动挂载
8299
+ const controlsConfig = Config.getInstance().getConfig().controls;
8300
+ if (controlsConfig) {
8301
+ controlModule.addControls(controlsConfig);
8302
+ }
8303
+
7073
8304
  return this.cesiumViewer;
7074
8305
  }
7075
8306
 
@@ -7125,6 +8356,14 @@ class ModuleManager {
7125
8356
  return this.getModule('EditorModule');
7126
8357
  }
7127
8358
 
8359
+ /**
8360
+ * 控件模块快捷访问
8361
+ * @returns {ControlModule}
8362
+ */
8363
+ get controlModule() {
8364
+ return this.getModule('ControlModule');
8365
+ }
8366
+
7128
8367
  /**
7129
8368
  * 注册模块(内部使用)
7130
8369
  * @param {string} moduleName 模块名称
@@ -7158,6 +8397,15 @@ class ModuleManager {
7158
8397
  console.warn('EditorModule 销毁失败:', e);
7159
8398
  }
7160
8399
 
8400
+ try {
8401
+ const controlModule = this.modules.get('ControlModule');
8402
+ if (controlModule && typeof controlModule.destroy === 'function') {
8403
+ controlModule.destroy();
8404
+ }
8405
+ } catch (e) {
8406
+ console.warn('ControlModule 销毁失败:', e);
8407
+ }
8408
+
7161
8409
  this.cesiumViewer.destroy();
7162
8410
  this.cesiumViewer = null;
7163
8411
  this.modules.clear();
@@ -7371,8 +8619,15 @@ const xs3d = {
7371
8619
  GraphicModule,
7372
8620
  EventModule,
7373
8621
  EditorModule,
8622
+ ControlModule,
7374
8623
  CoordinateUtil,
7375
8624
  GeoCalcUtil,
8625
+ // 导出控件类
8626
+ BaseControl,
8627
+ LocationBar,
8628
+ Zoom,
8629
+ Compass,
8630
+ DistanceLegend,
7376
8631
  // 导出工具枚举
7377
8632
  DrawType,
7378
8633
  EditMode,