吉安感知网项目-前端
chenyao
5 days ago 899f3474a9bc54d1a72710b4fd992176c1888e70
applications/drone-command/src/components/map-container/device-map-container.vue
@@ -5,75 +5,105 @@
         :show-admin-boundary="true" :zoom-to-boundary="true" :enable-stage-emit="true"
         :cluster-height="CLUSTER_HEIGHT" :detail-height="DETAIL_HEIGHT" @ready="handleMapReady"
         @stage-change="handleStageChange" />
      <div v-if="props.showLayerControl" class="layer-control-root" :class="{ collapsed: props.leftCollapsed }">
      <div v-if="props.showLayerControl" class="layer-control-root" :class="{ collapsed: props.rightCollapsed }">
         <div class="layer-control-wrap" ref="layerWrapRef">
            <div class="layer-control" @click="toggleLayerPanel">
            <div class="layer-control" @click.stop="toggleLayerPanel">
               <img :src="layerControlIcon" alt="图层控制" />
            </div>
            <div v-if="showLayerPanel" class="layer-panel">
               <div class="panel-title">图层管理</div>
               <div class="panel-content">
                  <el-tree class="command-tree" :data="layerTree" show-checkbox default-expand-all node-key="key"
                     :props="layerTreeProps" :default-checked-keys="defaultCheckedKeys" />
            <transition name="layer-panel-slide">
               <div v-if="showLayerPanel" class="layer-panel">
                  <div class="panel-title">图层管理</div>
                  <div class="panel-content">
                     <el-tree ref="layerTreeRef" class="command-tree map-layer-tree" :data="layerTree"
                     icon="el-icon-arrow-down-bold"
                        show-checkbox default-expand-all node-key="key" :props="layerTreeProps"
                        :default-checked-keys="treeCheckedKeys" @check="handleLayerCheck" />
                  </div>
               </div>
            </transition>
         </div>
      </div>
      <div v-if="props.showLayerControl" class="base-layer-switch-root" :class="{ collapsed: props.rightCollapsed }">
         <div class="base-layer-switch-wrap" @mouseenter="showBaseLayerPanel = true"
            @mouseleave="showBaseLayerPanel = false">
            <div class="base-layer-trigger">
               <img class="base-layer-trigger-thumb" :src="currentBaseLayer.icon" :alt="currentBaseLayer.label">
               <div class="base-layer-trigger-label">{{ currentBaseLayer.shortLabel }}</div>
            </div>
            <transition name="layer-panel-slide">
               <div v-if="showBaseLayerPanel" class="base-layer-panel">
                  <div class="base-map-options">
                     <div class="base-map-card" :class="{ active: baseLayerKey === 'base-satellite' }"
                        @click="handleBaseLayerSelect('base-satellite')">
                        <img class="base-map-thumb" :src="yxIcon" alt="">
                        <div class="base-map-label">卫星地图</div>
                     </div>
                     <div class="base-map-card" :class="{ active: baseLayerKey === 'base-standard' }"
                        @click="handleBaseLayerSelect('base-standard')">
                        <img class="base-map-thumb" :src="dzIcon" alt="">
                        <div class="base-map-label">标准地图</div>
                     </div>
                  </div>
               </div>
            </transition>
         </div>
      </div>
      <DevicePopup
         :visible="popupVisible && !isDronePopup"
         :position="popupPosition"
         :device="selectedDevice"
         @close="closePopup"
      />
      <DevicePopup :visible="popupVisible && !isDronePopup" :position="popupPosition" :device="selectedDevice"
         @close="closePopup" />
      <DronePopup
         :visible="popupVisible && isDronePopup"
         :position="popupPosition"
         :drone="selectedDevice"
         :favorite="Boolean(selectedDevice?.isFavorite)"
         @close="closePopup"
         @toggle-favorite="toggleDroneFavorite"
         @signal="handleDroneSignal"
         @counter="handleDroneCounter"
      />
      <DronePopup :visible="popupVisible && isDronePopup" :position="popupPosition" :drone="selectedDevice"
         :favorite="Boolean(selectedDevice?.isFavorite)" @close="closePopup" @toggle-favorite="handleDroneFavorite"
         @signal="handleDroneSignal" @counter="handleDroneCounter" />
   </div>
</template>
<script setup>
import * as Cesium from 'cesium'
import CommonCesiumMap from '@/components/map-container/common-cesium-map.vue'
import { geomAnalysis } from '@ztzf/utils'
import { fwDefenseZonePageApi } from '@/views/areaManage/defenseZone/defenseZoneApi'
import { fwAreaDividePageApi } from '@/views/areaManage/partition/partitionApi'
import { fwDefenseSceneListApi } from '@/views/areaManage/sceneConfig/sceneConfigApi'
import { cockpitAggregationApi } from '@/api/dataCockpit'
import { buildEllipsePositions } from '@/utils/cesium/shapeTools'
import { AREA_TYPE_STYLE_MAP, BUFFER_LEVEL_STYLES, DEFAULT_AREA_STYLE } from '@ztzf/constants'
import { newAreaDivideList, newDefenseSceneManageList } from '@/api/dataCockpit/index'
import { newCockpitAggregationApi } from '@/api/dataCockpit'
import layerControlIcon from '@/assets/images/dataCockpit/layerControl.png'
import equipmentIcon from '@/assets/images/dataCockpit/map/equipment.png'
import offlineEquipmentIcon from '@/assets/images/dataCockpit/map/offline-equipment.png'
import droneIcon from '@/assets/images/dataCockpit/map/drone.png'
import aggregationIcon from '@/assets/images/dataCockpit/map/aggregation.png'
import commandPostIcon from '@/assets/images/dataCockpit/map/command-post.png'
import jaGeojsonRaw from '@/assets/geojson/ja.geojson?raw'
import DevicePopup from './components/DevicePopup.vue'
import DronePopup from './components/DronePopup.vue'
import dayjs from 'dayjs'
import {
   RING_STYLES,
   addDeviceRings,
   createDroneTrackMaterial,
   createRadialGradientMaterial,
   getTexturedVertexFormat,
} from './device-map-materials'
import { getPointPositionsHeight } from '@/utils/cesium/mapUtil'
import { createDeviceRangePrimitiveWithHeight } from '@/utils/cesium/deviceRange'
import dzIcon from '@/assets/images/dataCockpit/map/dz-map-layer.png'
import yxIcon from '@/assets/images/dataCockpit/map/yx-map-layer.png'
const CLUSTER_HEIGHT = 100000
const DETAIL_HEIGHT = 10000
const POLYGON_HEIGHT_M = 0.2
const DRONE_TRACK_DURATION_S = 30 * 60
const FIXED_DRONE_TRACK_IDS = ['6000000000609', '6000000000608']
const props = defineProps({
   onlineDevices: {
   allDevices: {
      type: Array,
      default: () => [],
   },
   leftCollapsed: {
   alarmDrones: {
      type: Array,
      default: () => [],
   },
   rightCollapsed: {
      type: Boolean,
      default: false,
   },
@@ -86,34 +116,47 @@
      default: true,
   },
})
const emit = defineEmits(['droneSignal', 'droneCounter'])
const emit = defineEmits(['droneSignal', 'droneCounter', 'droneFavorite'])
const DEFAULT_ZONE_PAGE_SIZE = 999
const mapRef = ref(null)
let viewer = null
let publicCesium = null
let cockpitPrimitiveLayer = null
const devicePickMap = new Map()
const dronePickMap = new Map()
let deviceBillboardCollection = null
let deviceRingOutlinePrimitives = []
let deviceRingFillPrimitives = []
let defenseZonePrimitive = null
let defenseZoneOutlinePrimitive = null
let partitionPrimitive = null
let partitionOutlinePrimitive = null
let partitionFillPrimitives = []
let partitionOutlinePrimitives = []
let aggregationSource = null
let commandPostBillboardCollection = null
let droneTrackBillboardCollection = null
let droneTrackPolylineCollection = null
let droneTrackTickHandler = null
let droneTrackRafId = null
let droneTrackStartTime = null
let droneTrackAnimStartAt = 0
let droneTrackLastTickAt = 0
let droneTrackRuntime = []
let droneTrackSource = null
let mapReadyHandled = false
let favoritePulseRafId = null
let favoritePulseStartAt = 0
let favoritePulseElapsed = 0
const favoritePulseEntities = []
const pulseBaseColor = Cesium.Color.fromCssColorString('#FF3B30')
const PULSE_MIN_RADIUS_M = 20
const PULSE_MAX_RADIUS_M = 80
const PULSE_DURATION_S = 1.4
const detailVisible = ref(true)
const clusterVisible = ref(false)
const countyCenterMap = new Map()
const showLayerPanel = ref(false)
const showBaseLayerPanel = ref(false)
const layerWrapRef = ref(null)
const layerTreeRef = ref(null)
const selectedDevice = ref(null)
const selectedTargetType = ref('device')
let selectedDeviceBillboard = null
@@ -123,17 +166,29 @@
   label: 'label',
   children: 'children',
}
const defaultCheckedKeys = ['global', 'city-base']
const baseLayerKeys = ['base-standard', 'base-satellite']
const defaultCheckedKeys = ['ja-terrain', 'admin', 'city-base']
const baseLayerKey = ref('base-satellite')
const baseLayerMeta = {
   'base-standard': { icon: dzIcon, label: '标准地图', shortLabel: '标准' },
   'base-satellite': { icon: yxIcon, label: '卫星地图', shortLabel: '影像' },
}
const currentBaseLayer = computed(() => baseLayerMeta[baseLayerKey.value] || baseLayerMeta['base-satellite'])
const treeCheckedKeys = ref([...defaultCheckedKeys])
const layerTree = ref([
   {
      key: 'base',
      label: '地理信息图层',
      children: [
         { key: 'global', label: '全球地形' },
         { key: 'ja-terrain', label: '吉安地形' },
         { key: 'admin', label: '行政区划' },
      ],
   },
   {
])
/**
 * {
      key: 'city',
      label: '城市CIM图层',
      children: [
@@ -151,7 +206,62 @@
         { key: 'route', label: '飞行航路' },
      ],
   },
])
 */
const adminBoundaryVisible = ref(treeCheckedKeys.value.includes('admin'))
const isFavorited = item => {
   const value = item?.favorited ?? item?.isFavorite
   return value === 1 || value === '1' || value === true
}
let pulseCanvas = null
const getPulseCanvas = () => {
   if (pulseCanvas) return pulseCanvas
   const size = 256
   const canvas = document.createElement('canvas')
   canvas.width = size
   canvas.height = size
   const ctx = canvas.getContext('2d')
   const center = size / 2
   const radius = center - 2
   const gradient = ctx.createRadialGradient(center, center, radius * 0.1, center, center, radius)
   gradient.addColorStop(0, 'rgba(255,59,48,0.55)')
   gradient.addColorStop(0.35, 'rgba(255,59,48,0.35)')
   gradient.addColorStop(0.7, 'rgba(255,59,48,0.15)')
   gradient.addColorStop(1, 'rgba(255,59,48,0)')
   ctx.fillStyle = gradient
   ctx.beginPath()
   ctx.arc(center, center, radius, 0, Math.PI * 2)
   ctx.fill()
   pulseCanvas = canvas
   return canvas
}
const createFavoritePulseEntity = (billboard) => {
   if (!viewer || !billboard) return
   const pulse = {
      offset: Math.random() * PULSE_DURATION_S,
      entity: null,
   }
   pulse.entity = viewer.entities.add({
      position: new Cesium.CallbackProperty(() => getBillboardPosition(billboard), false),
      point: {
         pixelSize: new Cesium.CallbackProperty(() => getPulseRadius(pulse), false),
         color: pulseBaseColor.withAlpha(0.4),
      },
   })
   favoritePulseEntities.push(pulse)
}
const getBillboardPosition = billboard => {
   if (!viewer || !billboard) return null
   const position = billboard.position
   if (position?.getValue) {
      return position.getValue(viewer.clock.currentTime)
   }
   return position
}
const getDevicePosition = item => {
   const longitudeRaw = item.longitude ?? item.lng ?? item.lon
@@ -170,6 +280,7 @@
   if (!Number.isFinite(range) || range <= 0) return null
   return range
}
const ensureCockpitPrimitiveLayer = () => {
   if (!viewer) return
@@ -201,20 +312,22 @@
   if (!viewer) return
   ensureCockpitPrimitiveLayer()
   cockpitPrimitiveLayer.removeAll(false)
   if (defenseZonePrimitive) cockpitPrimitiveLayer.add(defenseZonePrimitive)
   if (defenseZoneOutlinePrimitive) cockpitPrimitiveLayer.add(defenseZoneOutlinePrimitive)
   if (partitionPrimitive) cockpitPrimitiveLayer.add(partitionPrimitive)
   if (partitionOutlinePrimitive) cockpitPrimitiveLayer.add(partitionOutlinePrimitive)
   if (partitionFillPrimitives.length) {
      partitionFillPrimitives.forEach(primitive => cockpitPrimitiveLayer.add(primitive))
   }
   if (partitionOutlinePrimitives.length) {
      partitionOutlinePrimitives.forEach(primitive => cockpitPrimitiveLayer.add(primitive))
   }
   if (deviceRingFillPrimitives.length) {
      deviceRingFillPrimitives.forEach(primitive => cockpitPrimitiveLayer.add(primitive))
   }
   if (deviceRingOutlinePrimitives.length) {
      deviceRingOutlinePrimitives.forEach(primitive => cockpitPrimitiveLayer.add(primitive))
   }
   if (commandPostBillboardCollection) cockpitPrimitiveLayer.add(commandPostBillboardCollection)
   if (deviceBillboardCollection) cockpitPrimitiveLayer.add(deviceBillboardCollection)
   if (droneTrackPolylineCollection) cockpitPrimitiveLayer.add(droneTrackPolylineCollection)
   if (droneTrackBillboardCollection) cockpitPrimitiveLayer.add(droneTrackBillboardCollection)
   if (deviceBillboardCollection) cockpitPrimitiveLayer.add(deviceBillboardCollection)
   if (commandPostBillboardCollection) cockpitPrimitiveLayer.add(commandPostBillboardCollection)
}
const clearDeviceEntities = () => {
@@ -244,27 +357,16 @@
   }
}
const clearDefenseZoneEntities = () => {
   if (!viewer) return
   if (defenseZonePrimitive) {
      removeCockpitPrimitive(defenseZonePrimitive)
      defenseZonePrimitive = null
   }
   if (defenseZoneOutlinePrimitive) {
      removeCockpitPrimitive(defenseZoneOutlinePrimitive)
      defenseZoneOutlinePrimitive = null
   }
}
const clearPartitionEntities = () => {
   if (!viewer) return
   if (partitionPrimitive) {
      removeCockpitPrimitive(partitionPrimitive)
      partitionPrimitive = null
   if (partitionFillPrimitives.length) {
      partitionFillPrimitives.forEach(primitive => removeCockpitPrimitive(primitive))
      partitionFillPrimitives = []
   }
   if (partitionOutlinePrimitive) {
      removeCockpitPrimitive(partitionOutlinePrimitive)
      partitionOutlinePrimitive = null
   if (partitionOutlinePrimitives.length) {
      partitionOutlinePrimitives.forEach(primitive => removeCockpitPrimitive(primitive))
      partitionOutlinePrimitives = []
   }
}
@@ -291,12 +393,24 @@
   }
}
const getPulseRadius = pulse => {
   const phase = ((favoritePulseElapsed + pulse.offset) % PULSE_DURATION_S) / PULSE_DURATION_S
   const wave = phase < 0.5 ? phase * 2 : (1 - phase) * 2
   return PULSE_MIN_RADIUS_M + (PULSE_MAX_RADIUS_M - PULSE_MIN_RADIUS_M) * wave
}
const setDetailVisibility = visible => {
   detailVisible.value = visible
   if (defenseZonePrimitive) defenseZonePrimitive.show = visible
   if (defenseZoneOutlinePrimitive) defenseZoneOutlinePrimitive.show = visible
   if (partitionPrimitive) partitionPrimitive.show = visible
   if (partitionOutlinePrimitive) partitionOutlinePrimitive.show = visible
   if (partitionFillPrimitives.length) {
      partitionFillPrimitives.forEach(primitive => {
         primitive.show = visible
      })
   }
   if (partitionOutlinePrimitives.length) {
      partitionOutlinePrimitives.forEach(primitive => {
         primitive.show = visible
      })
   }
   if (commandPostBillboardCollection) commandPostBillboardCollection.show = visible
   if (deviceBillboardCollection) deviceBillboardCollection.show = visible
   if (deviceRingFillPrimitives.length) {
@@ -314,12 +428,23 @@
const setClusterVisibility = visible => {
   clusterVisible.value = visible
   if (aggregationSource) aggregationSource.show = visible
   updateAggregationVisibility()
}
const updateAggregationVisibility = () => {
   if (!aggregationSource) return
   aggregationSource.show = clusterVisible.value && adminBoundaryVisible.value
}
const setDroneVisibility = visible => {
   if (droneTrackBillboardCollection) droneTrackBillboardCollection.show = visible
   if (droneTrackPolylineCollection) droneTrackPolylineCollection.show = visible
   if (droneTrackSource) droneTrackSource.show = visible
   if (favoritePulseEntities.length) {
      favoritePulseEntities.forEach(pulse => {
         if (pulse?.entity) pulse.entity.show = visible
      })
   }
   if (!visible && selectedTargetType.value === 'drone') {
      closePopup()
   }
@@ -342,50 +467,103 @@
   })
}
const buildSimulatedTrackPoints = () => {
   return [
      {
         longitude: 114.963191,
         latitude: 27.136716,
         height: 120
      },
      {
         longitude:114.957308,
         latitude: 27.138452,
         height: 120
      },
      {
         longitude:114.952,
         latitude: 27.136317,
         height: 120
      },
      {
         longitude:114.949293,
         latitude: 27.133864,
         height: 120
      },
      {
         longitude:114.944666,
         latitude: 27.130526,
         height: 120
      },
      {
         longitude:114.945909,
         latitude:27.127845,
         height: 120
      },
      {
         longitude:114.962974,
         latitude:27.136242,
         height: 120
      },
const buildSimulatedTrackPoints = center => {
   const basePoints = [
      [
         {
            longitude: 114.929475,
            latitude: 27.136575,
            height: 200
         },
         {
            longitude: 114.928466,
            latitude: 27.134556,
            height: 200
         },
         {
            longitude: 114.928466,
            latitude: 27.134556,
            height: 320
         },
         {
            longitude: 114.928474,
            latitude: 27.134397,
            height: 320
         },
         {
            longitude: 114.927932,
            latitude: 27.133385,
            height: 320
         },
         {
            longitude: 114.927365,
            latitude: 27.132097,
            height: 320
         },
         {
            longitude: 114.929818,
            latitude: 27.131645,
            height: 320
         },
         {
            longitude: 114.924644,
            latitude: 27.136622,
            height: 120
         },
      ],
      [
         {
            longitude: 114.936386,
            latitude: 26.825890,
            height: 120
         },
         {
            longitude: 114.933976,
            latitude: 26.827145,
            height: 120
         },
         {
            longitude: 114.931281,
            latitude: 26.827801,
            height: 120
         },
         {
            longitude: 114.931281,
            latitude: 26.827801,
            height: 400
         },
         {
            longitude: 114.931159,
            latitude: 26.826940,
            height: 400
         },
         {
            longitude: 114.933707,
            latitude: 26.826704,
            height: 400
         },
         {
            longitude: 114.935197,
            latitude: 26.826933,
            height: 400
         },
         {
            longitude: 114.936371,
            latitude: 26.828504,
            height: 400
         },
      ],
   ]
   return basePoints[center.trackIndex] || basePoints[1]
}
const clearDroneTrackEntities = () => {
   if (!viewer) return
   stopDroneTrackAnimation()
   stopFavoritePulseAnimation()
   if (droneTrackBillboardCollection) {
      if (!droneTrackBillboardCollection.isDestroyed?.()) {
         removeCockpitPrimitive(droneTrackBillboardCollection)
@@ -398,7 +576,17 @@
      }
      droneTrackPolylineCollection = null
   }
   if (droneTrackSource) {
      viewer.dataSources.remove(droneTrackSource)
      droneTrackSource = null
   }
   droneTrackRuntime = []
   if (favoritePulseEntities.length) {
      favoritePulseEntities.forEach(pulse => {
         if (pulse?.entity) viewer?.entities?.remove(pulse.entity)
      })
      favoritePulseEntities.length = 0
   }
   dronePickMap.clear()
   if (selectedTargetType.value === 'drone') {
      closePopup()
@@ -425,116 +613,221 @@
   }
}
const startDroneTrackAnimation = () => {
const ensureDroneTrackSource = () => {
   if (!viewer) return
   stopDroneTrackAnimation()
   droneTrackStartTime = Cesium.JulianDate.now()
   droneTrackTickHandler = clock => {
      if (!viewer || viewer.isDestroyed?.() || !viewer.scene) {
         stopDroneTrackAnimation()
         return
      }
      if (!droneTrackRuntime.length) return
      const current = clock.currentTime
      const elapsed = Cesium.JulianDate.secondsDifference(current, droneTrackStartTime)
      droneTrackRuntime.forEach(track => {
         if (!track?.billboard || track.billboard.isDestroyed?.()) return
         if (!track.billboard._scene) return
         if (!track.positions || track.positions.length < 2) return
         const duration = track.duration
         if (duration <= 0) return
         const t = ((elapsed % duration) + duration) % duration
         const seg = Math.min(track.positions.length - 2, Math.floor(t / track.segmentDuration))
         const ratio = (t - seg * track.segmentDuration) / track.segmentDuration
         const pos = Cesium.Cartesian3.lerp(
   if (!droneTrackSource) {
      droneTrackSource = new Cesium.CustomDataSource('droneTrackSource')
      viewer.dataSources.add(droneTrackSource)
   }
}
const setupDroneTrackClock = () => {
   if (!viewer) return
   const now = new Date()
   const noon = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0, 0)
   const startTime = Cesium.JulianDate.fromDate(noon)
   const stopTime = Cesium.JulianDate.addSeconds(startTime, DRONE_TRACK_DURATION_S, new Cesium.JulianDate())
   viewer.clock.startTime = startTime
   viewer.clock.stopTime = stopTime
   viewer.clock.currentTime = startTime
   viewer.clock.multiplier = 1
   viewer.clock.clockStep = Cesium.ClockStep.SYSTEM_CLOCK_MULTIPLIER
   viewer.clock.clockRange = Cesium.ClockRange.CLAMPED
}
const updateDroneTrackPositions = elapsed => {
   if (!viewer || !viewer.scene || !droneTrackRuntime.length) return
   droneTrackRuntime.forEach(track => {
      if (!track?.billboard || track.billboard.isDestroyed?.()) return
      if (!track?.polyline) return
      if (!track.positions || track.positions.length < 2) return
      const duration = track.duration
      if (duration <= 0) return
      const t = Math.min(Math.max(elapsed, 0), duration)
      const seg = Math.min(track.positions.length - 2, Math.floor(t / track.segmentDuration))
      const ratio = (t - seg * track.segmentDuration) / track.segmentDuration
      const isEntityPosition = typeof track.billboard?.position?.getValue === 'function'
      const pos = isEntityPosition
         ? getBillboardPosition(track.billboard)
         : Cesium.Cartesian3.lerp(
            track.positions[seg],
            track.positions[seg + 1],
            ratio,
            new Cesium.Cartesian3()
         )
      if (!pos) return
      if (!isEntityPosition) {
         track.billboard.position = pos
      })
   }
   viewer.clock.onTick.addEventListener(droneTrackTickHandler)
      }
      const pathCount = Math.min(track.positions.length - 1, Math.floor(t / track.segmentDuration))
      const pathPositions = track.positions.slice(0, pathCount + 1)
      track.polyline.positions = [...pathPositions, pos]
   })
}
const startDroneTrackAnimation = () => {
   if (!viewer) return
   stopDroneTrackAnimation()
   droneTrackStartTime = viewer.clock.startTime
   viewer.clock.shouldAnimate = true
   droneTrackAnimStartAt = performance.now()
   droneTrackLastTickAt = droneTrackAnimStartAt
   const renderTick = now => {
      if (!viewer || viewer.isDestroyed?.()) {
         droneTrackRafId = null
         return
      }
      const deltaSeconds = Math.max(0, (now - droneTrackLastTickAt) / 1000)
      droneTrackLastTickAt = now
      if (viewer.clock?.currentTime && viewer.clock?.stopTime) {
         const nextTime = Cesium.JulianDate.addSeconds(
            viewer.clock.currentTime,
            deltaSeconds * (viewer.clock.multiplier || 1),
            new Cesium.JulianDate()
         )
         if (Cesium.JulianDate.greaterThan(nextTime, viewer.clock.stopTime)) {
            viewer.clock.currentTime = viewer.clock.stopTime
         } else {
            viewer.clock.currentTime = nextTime
         }
      }
      const elapsed = Cesium.JulianDate.secondsDifference(viewer.clock.currentTime, droneTrackStartTime)
      updateDroneTrackPositions(elapsed)
      viewer.scene?.requestRender?.()
      droneTrackRafId = requestAnimationFrame(renderTick)
   }
   droneTrackRafId = requestAnimationFrame(renderTick)
}
const stopDroneTrackAnimation = () => {
   if (!viewer || !droneTrackTickHandler) return
   viewer.clock.onTick.removeEventListener(droneTrackTickHandler)
   droneTrackTickHandler = null
   if (droneTrackRafId) {
      cancelAnimationFrame(droneTrackRafId)
      droneTrackRafId = null
   }
}
const renderSimulatedDroneTrack = () => {
const startFavoritePulseAnimation = () => {
   if (!viewer || favoritePulseRafId) return
   favoritePulseStartAt = performance.now()
   const tick = now => {
      if (!viewer || viewer.isDestroyed?.() || !favoritePulseEntities.length) {
         favoritePulseRafId = null
         return
      }
      favoritePulseElapsed = (now - favoritePulseStartAt) / 1000
      viewer.scene.requestRender()
      favoritePulseRafId = requestAnimationFrame(tick)
   }
   favoritePulseRafId = requestAnimationFrame(tick)
}
const stopFavoritePulseAnimation = () => {
   if (favoritePulseRafId) {
      cancelAnimationFrame(favoritePulseRafId)
      favoritePulseRafId = null
   }
}
const renderSimulatedDroneTrack = (list) => {
   if (!viewer) return
   clearDroneTrackEntities()
   if (!list?.length) return
   ensureDroneTrackCollections()
   ensureDroneTrackSource()
   setupDroneTrackClock()
   droneTrackBillboardCollection.show = detailVisible.value
   droneTrackPolylineCollection.show = detailVisible.value
   droneTrackRuntime = []
   const baseCenters = [['默认区域', { longitude:114.958541, latitude: 27.121917 }]]
   const segmentDuration = 6
   const baseTrackColor = Cesium.Color.fromCssColorString('red')
   baseCenters.forEach(([centerName, center], trackIndex) => {
      const points = buildSimulatedTrackPoints(center)
      if (points.length < 2) return
      const positions = points.map(point =>
         Cesium.Cartesian3.fromDegrees(point.longitude, point.latitude, point.height)
      )
      let trackMaterial = createDroneTrackMaterial({
         color: baseTrackColor,
         speed: 4.5,
         headWidth: 0.2,
         glowPower: 1.8,
         backgroundAlpha: 0.34,
      })
      if (!trackMaterial) {
         trackMaterial = Cesium.Material.fromType('Color', { color: baseTrackColor })
      }
      droneTrackPolylineCollection.add({
         positions,
         width: 3,
         material: trackMaterial,
      })
      const droneId = `drone-track-${trackIndex}`
      const billboard = droneTrackBillboardCollection.add({
         position: positions[positions.length - 1],
         image: droneIcon,
         width: 36,
         height: 36,
         verticalOrigin: Cesium.VerticalOrigin.CENTER,
         disableDepthTestDistance: Number.POSITIVE_INFINITY,
      })
      billboard.id = droneId
      const speedMs = Math.round(Cesium.Cartesian3.distance(positions[0], positions[1]) / segmentDuration)
      ; (FIXED_DRONE_TRACK_IDS || []).forEach((trackId, trackIndex) => {
         const item = (list || []).find(entry => String(entry?.id) === String(trackId))
         if (!item) return
         const position = getDevicePosition(item)
         if (!position) return
         const points = buildSimulatedTrackPoints({ ...position, height: item.flightHeightM, trackIndex })
         if (points.length < 2) return
         const positions = points.map(point =>
            Cesium.Cartesian3.fromDegrees(point.longitude, point.latitude, point.height)
         )
         const segmentDuration = DRONE_TRACK_DURATION_S / Math.max(positions.length - 1, 1)
         let trackMaterial = createDroneTrackMaterial({
            color: baseTrackColor,
            speed: 4.5,
            headWidth: 0.2,
            glowPower: 1.8,
            backgroundAlpha: 0.34,
         })
         if (!trackMaterial) {
            trackMaterial = Cesium.Material.fromType('Color', { color: baseTrackColor })
         }
         const polyline = droneTrackPolylineCollection.add({
            positions: [positions[0]],
            width: 3,
            material: trackMaterial,
         })
         const droneId = `drone-alarm-${trackId}`
         const startTime = viewer.clock.startTime
         const stopTime = viewer.clock.stopTime
         const positionProperty = new Cesium.SampledPositionProperty()
         positions.forEach((pos, index) => {
            const sampleTime = Cesium.JulianDate.addSeconds(
               startTime,
               index * segmentDuration,
               new Cesium.JulianDate()
            )
            positionProperty.addSample(sampleTime, pos)
         })
         positionProperty.setInterpolationOptions({
            interpolationDegree: 1,
            interpolationAlgorithm: Cesium.LinearApproximation,
         })
         const entity = droneTrackSource.entities.add({
            id: droneId,
            availability: new Cesium.TimeIntervalCollection([
               new Cesium.TimeInterval({
                  start: startTime,
                  stop: stopTime,
               }),
            ]),
            position: positionProperty,
            orientation: new Cesium.VelocityOrientationProperty(positionProperty),
            billboard: {
               image: droneIcon,
               width: 36,
               height: 36,
               verticalOrigin: Cesium.VerticalOrigin.CENTER,
               disableDepthTestDistance: Number.POSITIVE_INFINITY,
            },
         })
         const speedMs = Math.round(Cesium.Cartesian3.distance(positions[0], positions[1]) / segmentDuration)
      dronePickMap.set(droneId, {
         data: {
            droneName: `无人机-${trackIndex + 1}`,
            droneSerialNo: `UAV-${trackIndex + 1}`,
            flightHeightM: points[0].height,
            flightSpeedMs: speedMs,
            longitude: points[0].longitude,
            latitude: points[0].latitude,
            flightStatus: 1,
            deviceName: centerName,
            dataSource: centerName,
            counterDeviceName: centerName,
         },
         billboard,
            data: {
               ...item,
               flightHeightM: item.flightHeightM ?? points[0].height,
               flightSpeedMs: item.flightSpeedMs ?? speedMs,
               longitude: item.longitude ?? points[0].longitude,
               latitude: item.latitude ?? points[0].latitude,
            },
            billboard: entity,
         })
         if (isFavorited(item)) {
            createFavoritePulseEntity(entity)
         }
         droneTrackRuntime.push({
            positions,
            polyline,
            billboard: entity,
            segmentDuration,
            duration: DRONE_TRACK_DURATION_S,
         })
      })
      droneTrackRuntime.push({
         positions,
         billboard,
         segmentDuration,
         duration: (points.length - 1) * segmentDuration,
      })
   })
   viewer.clock.multiplier = 1
   startDroneTrackAnimation()
   startFavoritePulseAnimation()
   reorderCockpitPrimitives()
}
const renderDeviceEntities = devices => {
const renderDeviceEntities = async devices => {
   if (!viewer) return
   ensureCockpitPrimitiveLayer()
   ensureDeviceCollections()
@@ -549,178 +842,270 @@
   }
   devicePickMap.clear()
   deviceBillboardCollection.show = detailVisible.value
   const ringFillInstancesByStyle = RING_STYLES.map(() => [])
   const ringOutlineInstancesByStyle = RING_STYLES.map(() => [])
   const deviceEntries = []
   const devicePositions = []
   devices.forEach((item, index) => {
      const isOnline = String(item.status) === '0'
      const position = getDevicePosition(item)
      if (!position) return
      const entityId = `online-device-${item.id ?? index}-${index}`
      const rangeMeters = getDeviceRange(item)
      addDeviceRings(position, ringFillInstancesByStyle, ringOutlineInstancesByStyle, rangeMeters)
      const billboard = deviceBillboardCollection.add({
         position: Cesium.Cartesian3.fromDegrees(position.longitude, position.latitude, 0),
         image: equipmentIcon,
         width: 40,
         height: 56,
         verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
      })
      billboard.id = entityId
      devicePickMap.set(entityId, { data: item, billboard })
      devicePositions.push({ lng: position.longitude, lat: position.latitude })
      deviceEntries.push({ position, rangeMeters, isOnline, entityId, data: item })
   })
   if (deviceEntries.length) {
      const heights = await getPointPositionsHeight(devicePositions, viewer)
      deviceEntries.forEach((entry, index) => {
         const height = Number(heights?.[index]?.ASL)
         const centerHeight = Number.isFinite(height) ? height : 0
         const billboard = deviceBillboardCollection.add({
            position: Cesium.Cartesian3.fromDegrees(
               entry.position.longitude,
               entry.position.latitude,
               centerHeight
            ),
            image: entry.isOnline ? equipmentIcon : offlineEquipmentIcon,
            width: 40,
            height: 56,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            disableDepthTestDistance: Number.POSITIVE_INFINITY,
         })
         billboard.id = entry.entityId
         devicePickMap.set(entry.entityId, { data: entry.data, billboard })
         if (!entry.isOnline) return
         if (!Number.isFinite(entry.rangeMeters) || entry.rangeMeters <= 0) return
         const primitive = createDeviceRangePrimitiveWithHeight(entry.position, entry.rangeMeters, centerHeight)
         if (!primitive) return
         primitive.show = detailVisible.value
         addCockpitPrimitive(primitive)
         deviceRingFillPrimitives.push(primitive)
      })
   }
   if (selectedTargetType.value === 'device' && selectedDeviceBillboard && !devicePickMap.has(selectedDeviceBillboard.id)) {
      closePopup()
   }
   ringFillInstancesByStyle.forEach((instances, index) => {
      if (!instances.length) return
      const ring = RING_STYLES[index]
      const alpha = ring.alpha ?? 0.64
      const innerRatio =
         typeof ring.innerRatio === 'number'
            ? ring.innerRatio
            : ring.inner && ring.outer
               ? Math.min(Math.max(ring.inner / ring.outer, 0), 0.9)
               : 0
      const material = createRadialGradientMaterial(
         Cesium.Color.fromCssColorString(ring.gradient[0]).withAlpha(alpha),
         Cesium.Color.fromCssColorString(ring.gradient[1]).withAlpha(alpha),
         {
            gamma: 1.7,
            innerCutoff: innerRatio,
   reorderCockpitPrimitives()
}
const elevatePositions = async (positions, height = POLYGON_HEIGHT_M) => {
   if (!Array.isArray(positions) || !positions.length) return []
   if (!viewer) return positions
   const points = positions
      .map(pos => {
         const carto = Cesium.Cartographic.fromCartesian(pos)
         if (!carto) return null
         return {
            lng: Cesium.Math.toDegrees(carto.longitude),
            lat: Cesium.Math.toDegrees(carto.latitude),
         }
      )
      const primitive = new Cesium.GroundPrimitive({
         geometryInstances: instances,
         appearance: new Cesium.MaterialAppearance({
            material,
            translucent: true,
         }),
      })
      primitive.show = detailVisible.value
      addCockpitPrimitive(primitive)
      deviceRingFillPrimitives.push(primitive)
   })
   ringOutlineInstancesByStyle.forEach(instances => {
      if (!instances.length) return
      const primitive = new Cesium.GroundPolylinePrimitive({
         geometryInstances: instances,
         appearance: new Cesium.PolylineColorAppearance(),
      })
      primitive.show = detailVisible.value
      addCockpitPrimitive(primitive)
      deviceRingOutlinePrimitives.push(primitive)
   })
   reorderCockpitPrimitives()
      .filter(Boolean)
   if (!points.length) return positions
   const heights = await getPointPositionsHeight(points, viewer)
   return heights.map(item => Cesium.Cartesian3.fromDegrees(item.longitude, item.latitude, (item.ASL ?? 0) + height))
}
const getDefenseZonePositions = geom => {
   const points = geomAnalysis(geom)
   if (points.length < 3) return []
   const first = points[0]
   const last = points[points.length - 1]
   const list =
      first && last && first.longitude === last.longitude && first.latitude === last.latitude
         ? points.slice(0, -1)
         : points
   return list.map(item => Cesium.Cartesian3.fromDegrees(item.longitude, item.latitude))
const getAreaTypeStyle = areaType => {
   return AREA_TYPE_STYLE_MAP?.[String(areaType)] || DEFAULT_AREA_STYLE
}
const buildZonePrimitives = (zones, lineColor, fillColor1, fillColor2) => {
   if (!viewer) return { primitive: null, outlinePrimitive: null }
   const polygonInstances = []
   const lineInstances = []
   const texturedVertexFormat = getTexturedVertexFormat()
   ; (zones || []).forEach((zone, index) => {
      if (!zone?.geom) return
      const positions = getDefenseZonePositions(zone.geom)
      if (!positions.length) return
      const polygon = new Cesium.PolygonGeometry({
         polygonHierarchy: new Cesium.PolygonHierarchy(positions),
         vertexFormat: texturedVertexFormat,
const buildPartitionPrimitives = async shapes => {
   if (!viewer) return { primitives: [], outlinePrimitives: [] }
   const vertexFormat = getTexturedVertexFormat()
   const groups = new Map()
   const tasks = []
      ; (shapes || []).forEach(shape => {
         if (!shape?.positions || shape.positions.length < 3) return
         const outlineColor = shape.outlineColor
         const fillColor = shape.fillColor
         const key = `${outlineColor.toCssColorString()}|${fillColor.toCssColorString()}`
         if (!groups.has(key)) {
            groups.set(key, { outlineColor, fillColor, polygonInstances: [], lineInstances: [] })
         }
         const group = groups.get(key)
         const positions = shape.positions
         tasks.push(
            elevatePositions(positions).then(elevatedPositions => {
               if (!elevatedPositions.length) return
               const polygon = new Cesium.PolygonGeometry({
                  polygonHierarchy: new Cesium.PolygonHierarchy(elevatedPositions),
                  perPositionHeight: true,
                  vertexFormat,
               })
               group.polygonInstances.push(
                  new Cesium.GeometryInstance({
                     geometry: polygon,
                  })
               )
               const linePositions =
                  elevatedPositions.length > 1
                     ? [...elevatedPositions, elevatedPositions[0]]
                     : elevatedPositions
               group.lineInstances.push(
                  new Cesium.GeometryInstance({
                     geometry: new Cesium.PolylineGeometry({
                        positions: linePositions,
                        width: 2,
                     }),
                     attributes: {
                        color: Cesium.ColorGeometryInstanceAttribute.fromColor(outlineColor),
                     },
                  })
               )
            })
         )
      })
      polygonInstances.push(
         new Cesium.GeometryInstance({
            geometry: polygon,
   if (tasks.length) await Promise.all(tasks)
   const primitives = []
   const outlinePrimitives = []
   groups.forEach(group => {
      if (group.polygonInstances.length) {
         const material = createRadialGradientMaterial(group.outlineColor, group.fillColor, {
            gamma: 1.7,
            innerCutoff: 0,
         })
      )
      const linePositions = positions.length > 1 ? [...positions, positions[0]] : positions
      lineInstances.push(
         new Cesium.GeometryInstance({
            geometry: new Cesium.GroundPolylineGeometry({
               positions: linePositions,
               width: 2,
         const primitive = new Cesium.Primitive({
            geometryInstances: group.polygonInstances,
            appearance: new Cesium.MaterialAppearance({
               material,
               translucent: true,
            }),
            attributes: {
               color: Cesium.ColorGeometryInstanceAttribute.fromColor(
                  Cesium.Color.fromCssColorString(lineColor)
               ),
            },
         })
      )
         addCockpitPrimitive(primitive)
         primitives.push(primitive)
      }
      if (group.lineInstances.length) {
         const outlinePrimitive = new Cesium.Primitive({
            geometryInstances: group.lineInstances,
            appearance: new Cesium.PolylineColorAppearance(),
         })
         addCockpitPrimitive(outlinePrimitive)
         outlinePrimitives.push(outlinePrimitive)
      }
   })
   let primitive = null
   if (polygonInstances.length) {
      const baseColor1 = Cesium.Color.fromCssColorString(fillColor1)
      const baseColor2 = Cesium.Color.fromCssColorString(fillColor2)
      const material = createRadialGradientMaterial(baseColor1.withAlpha(0.64), baseColor2.withAlpha(0.64))
      primitive = new Cesium.GroundPrimitive({
         geometryInstances: polygonInstances,
         appearance: new Cesium.MaterialAppearance({
            material,
            translucent: true,
         }),
      })
      addCockpitPrimitive(primitive)
   }
   let outlinePrimitive = null
   if (lineInstances.length) {
      outlinePrimitive = new Cesium.GroundPolylinePrimitive({
         geometryInstances: lineInstances,
         appearance: new Cesium.PolylineColorAppearance(),
      })
      addCockpitPrimitive(outlinePrimitive)
   }
   return { primitive, outlinePrimitive }
   return { primitives, outlinePrimitives }
}
const renderDefenseZones = zones => {
   if (!viewer) return
   clearDefenseZoneEntities()
   const result = buildZonePrimitives(zones, '#72F3FF', '#72F3FF', '#0A7C88')
   defenseZonePrimitive = result.primitive
   defenseZoneOutlinePrimitive = result.outlinePrimitive
   if (defenseZonePrimitive) defenseZonePrimitive.show = detailVisible.value
   if (defenseZoneOutlinePrimitive) defenseZoneOutlinePrimitive.show = detailVisible.value
   reorderCockpitPrimitives()
const parseGeomJson = geomJson => {
   if (!geomJson) return null
   if (typeof geomJson === 'object') return geomJson
   if (typeof geomJson !== 'string') return null
   const trimmed = geomJson.trim()
   if (!trimmed) return null
   try {
      return JSON.parse(trimmed)
   } catch (error) { }
   return null
}
const renderPartitions = zones => {
const normalizeShapePoint = point => {
   if (!point) return null
   const lng = point?.lng ?? point?.longitude
   const lat = point?.lat ?? point?.latitude
   const height = Number.isFinite(Number(point?.height)) ? Number(point.height) : 0
   if (!Number.isFinite(Number(lng)) || !Number.isFinite(Number(lat))) return null
   return { lng: Number(lng), lat: Number(lat), height }
}
const buildShapePositions = (points = []) => {
   const normalized = points.map(normalizeShapePoint).filter(Boolean)
   return normalized.map(point => Cesium.Cartesian3.fromDegrees(point.lng, point.lat, point.height))
}
const getShapeDisplayPoints = shape => {
   if (Array.isArray(shape?.displayPoints) && shape.displayPoints.length) {
      return shape.displayPoints
   }
   return shape?.points || []
}
const resolvePartitionShapes = areas => {
   const shapes = []
      ; (areas || []).forEach(area => {
         const extList = Array.isArray(area?.fwAreaDivideExtList) ? area.fwAreaDivideExtList : []
         extList.forEach((item, index) => {
            const isShapePayload = item?.drawType || item?.points
            const parsed = isShapePayload ? item : parseGeomJson(item?.geomJson)
            if (!parsed) return
            const shape = {
               id: parsed?.id || `shape_${Date.now()}_${index}_${Math.random().toString(16).slice(2, 6)}`,
               drawType: parsed?.drawType ?? 'polygon',
               areaType: parsed?.areaType ?? item?.areaTypeKey ?? item?.areaType ?? '',
               points: Array.isArray(parsed?.points) ? parsed.points : [],
               displayPoints: Array.isArray(parsed?.displayPoints) ? parsed.displayPoints : null,
               meta: parsed?.meta ?? null,
            }
            if (shape.drawType === 'buffer' && shape.meta?.bufferRadii?.length && shape.meta?.center) {
               const center = shape.meta.center
               const centerCartesian = Cesium.Cartesian3.fromDegrees(
                  center.lng,
                  center.lat,
                  center.height || 0
               )
               const radii = shape.meta.bufferRadii
                  .map(radius => Number(radius))
                  .filter(radius => Number.isFinite(radius) && radius > 0)
               radii.forEach((radius, levelIndex) => {
                  const positions = buildEllipsePositions(centerCartesian, radius, radius)
                  const style = BUFFER_LEVEL_STYLES[levelIndex] || BUFFER_LEVEL_STYLES[BUFFER_LEVEL_STYLES.length - 1]
                  if (positions.length >= 3) {
                     shapes.push({
                        positions,
                        fillColor: style.fill,
                        outlineColor: style.outline,
                     })
                  }
               })
               return
            }
            const positions = buildShapePositions(getShapeDisplayPoints(shape))
            if (positions.length >= 3) {
               const style = getAreaTypeStyle(shape.areaType)
               shapes.push({
                  positions,
                  fillColor: style.fill,
                  outlineColor: style.outline,
               })
            }
         })
      })
   return shapes
}
const renderPartitions = async zones => {
   if (!viewer) return
   clearPartitionEntities()
   const result = buildZonePrimitives(zones, '#FFD772', '#FFD772', '#A86B00')
   partitionPrimitive = result.primitive
   partitionOutlinePrimitive = result.outlinePrimitive
   if (partitionPrimitive) partitionPrimitive.show = detailVisible.value
   if (partitionOutlinePrimitive) partitionOutlinePrimitive.show = detailVisible.value
   reorderCockpitPrimitives()
}
const loadDefenseZones = async () => {
   if (!viewer) return
   try {
      const res = await fwDefenseZonePageApi({ current: 1, size: DEFAULT_ZONE_PAGE_SIZE })
      renderDefenseZones(res?.data?.data?.records ?? [])
   } catch (error) {
      renderDefenseZones([])
   const shapes = resolvePartitionShapes(zones)
   const result = await buildPartitionPrimitives(shapes)
   partitionFillPrimitives = result.primitives
   partitionOutlinePrimitives = result.outlinePrimitives
   if (partitionFillPrimitives.length) {
      partitionFillPrimitives.forEach(primitive => {
         primitive.show = detailVisible.value
      })
   }
   if (partitionOutlinePrimitives.length) {
      partitionOutlinePrimitives.forEach(primitive => {
         primitive.show = detailVisible.value
      })
   }
   reorderCockpitPrimitives()
}
const loadPartitions = async () => {
   if (!viewer) return
   try {
      const res = await fwAreaDividePageApi({ current: 1, size: DEFAULT_ZONE_PAGE_SIZE })
      renderPartitions(res?.data?.data?.records ?? [])
      const res = await newAreaDivideList({
         isSetSceneManage: 1,
         flyTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
      })
      await renderPartitions(res?.data?.data ?? [])
   } catch (error) {
      renderPartitions([])
      await renderPartitions([])
   }
}
@@ -732,7 +1117,7 @@
      viewer.dataSources.add(aggregationSource)
   }
   clearAggregationEntities()
   aggregationSource.show = clusterVisible.value
   updateAggregationVisibility()
   const countMap = new Map()
      ; (list || []).forEach(item => {
         if (!item?.type) return
@@ -783,7 +1168,7 @@
const updatePopupPosition = () => {
   if (!viewer || !selectedDeviceBillboard) return
   const cartesian = selectedDeviceBillboard.position
   const cartesian = getBillboardPosition(selectedDeviceBillboard)
   if (!cartesian) return
   const screenPosition = viewer.scene.cartesianToCanvasCoordinates(cartesian)
   if (!screenPosition) return
@@ -839,35 +1224,51 @@
const popupPosition = ref({ x: 0, y: 0 })
const popupVisible = computed(() => Boolean(selectedDevice.value))
const isDronePopup = computed(() => selectedTargetType.value === 'drone')
const toggleDroneFavorite = () => {
const handleDroneFavorite = () => {
   if (!selectedDevice.value) return
   selectedDevice.value.isFavorite = !selectedDevice.value.isFavorite
   emit('droneFavorite', selectedDevice.value)
}
const handleDroneSignal = () => emit('droneSignal', selectedDevice.value)
const handleDroneCounter = () => emit('droneCounter', selectedDevice.value)
const renderCommandPosts = list => {
const renderCommandPosts = async list => {
   if (!viewer) return
   ensureCommandPostCollection()
   commandPostBillboardCollection.removeAll()
   commandPostBillboardCollection.show = detailVisible.value
   ; (list || []).forEach((item, index) => {
      const position = getDevicePosition(item)
      if (!position) return
      commandPostBillboardCollection.add({
         position: Cesium.Cartesian3.fromDegrees(position.longitude, position.latitude, 0),
         image: commandPostIcon,
         width: 40,
         height: 56,
         verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
   const entries = []
   const positions = []
      ; (list || []).forEach((item, index) => {
         const position = getDevicePosition(item)
         if (!position) return
         entries.push({ position, index })
         positions.push({ lng: position.longitude, lat: position.latitude })
      })
   })
   if (positions.length) {
      const heights = await getPointPositionsHeight(positions, viewer)
      entries.forEach((entry, idx) => {
         const height = Number(heights?.[idx]?.ASL)
         const centerHeight = Number.isFinite(height) ? height : 0
         commandPostBillboardCollection.add({
            position: Cesium.Cartesian3.fromDegrees(
               entry.position.longitude,
               entry.position.latitude,
               centerHeight
            ),
            image: commandPostIcon,
            width: 40,
            height: 56,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            disableDepthTestDistance: Number.POSITIVE_INFINITY,
         })
      })
   }
   reorderCockpitPrimitives()
}
const loadAggregation = async () => {
   try {
      const res = await cockpitAggregationApi({
      const res = await newCockpitAggregationApi({
         effectiveRangeKmIsNotNull: 1
      })
      renderAggregation(res?.data?.data ?? [])
@@ -878,16 +1279,18 @@
const loadCommandPosts = async () => {
   try {
      const res = await fwDefenseSceneListApi()
      renderCommandPosts(res?.data?.data ?? [])
      const res = await newDefenseSceneManageList({
         time: dayjs().format('YYYY-MM-DD HH:mm:ss')
      })
      await renderCommandPosts(res?.data?.data ?? [])
   } catch (error) {
      renderCommandPosts([])
      await renderCommandPosts([])
   }
}
watch(
   () => props.onlineDevices,
   () => props.allDevices,
   devices => {
      renderDeviceEntities(devices || [])
   },
@@ -895,15 +1298,53 @@
)
watch(
   () => props.leftCollapsed,
   () => props.alarmDrones,
   list => {
      renderSimulatedDroneTrack(list || [])
      if (selectedTargetType.value === 'drone' && selectedDevice.value) {
         const selectedId = selectedDevice.value.alarmRecordId ?? selectedDevice.value.id
         const match = (list || []).find(item => (item?.alarmRecordId ?? item?.id) === selectedId)
         if (match) selectedDevice.value = { ...selectedDevice.value, ...match }
      }
   },
   { deep: true }
)
watch(
   () => props.rightCollapsed,
   isCollapsed => {
      if (isCollapsed) showLayerPanel.value = false
      if (!isCollapsed) return
      showLayerPanel.value = false
      showBaseLayerPanel.value = false
   }
)
const toggleLayerPanel = () => {
   if (!props.showLayerControl) return
   showLayerPanel.value = !showLayerPanel.value
}
const applyLayerVisibility = checkedKeys => {
   publicCesium?.switchLayers?.(baseLayerKey.value === 'base-standard' ? 0 : 4)
   const showTerrain = checkedKeys.includes('ja-terrain')
   publicCesium?.setTerrainVisible?.(showTerrain)
   const showAdmin = checkedKeys.includes('admin')
   adminBoundaryVisible.value = showAdmin
   mapRef.value?.setAdminBoundaryVisible?.(showAdmin)
   updateAggregationVisibility()
}
const handleLayerCheck = (_data, state) => {
   const checkedKeys = state?.checkedKeys ?? layerTreeRef.value?.getCheckedKeys?.() ?? []
   treeCheckedKeys.value = checkedKeys
   applyLayerVisibility(checkedKeys)
}
const handleBaseLayerSelect = key => {
   if (!baseLayerKeys.includes(key)) return
   if (baseLayerKey.value === key) return
   baseLayerKey.value = key
   applyLayerVisibility(treeCheckedKeys.value)
}
const updateStageDisplay = stage => {
@@ -913,18 +1354,21 @@
   setDroneVisibility(!showCluster)
}
const handleMapReady = ({ viewer: mapViewer }) => {
const handleMapReady = async ({ viewer: mapViewer, publicCesium: mapPublic }) => {
   if (mapReadyHandled) return
   mapReadyHandled = true
   viewer = mapViewer
   publicCesium = mapPublic
   ensureCockpitPrimitiveLayer()
   applyLayerVisibility(treeCheckedKeys.value)
   const height = viewer?.camera?.positionCartographic?.height
   const stage = getStageByHeight(height)
   updateStageDisplay(stage)
   renderDeviceEntities(props.onlineDevices)
   loadDefenseZones()
   await loadCommandPosts()
   renderDeviceEntities(props.allDevices)
   loadPartitions()
   loadAggregation()
   loadCommandPosts()
   renderSimulatedDroneTrack()
   renderSimulatedDroneTrack(props.alarmDrones)
   initDeviceClickHandler()
}
@@ -932,7 +1376,7 @@
   updateStageDisplay(stage)
}
const handleClickOutside = event => {
const handleLayerClickOutside = event => {
   if (!showLayerPanel.value) return
   const target = event.target
   if (layerWrapRef.value?.contains(target)) return
@@ -940,15 +1384,14 @@
}
onMounted(() => {
   document.addEventListener('click', handleClickOutside)
   document.addEventListener('click', handleLayerClickOutside)
   const map = mapRef.value?.getMap()
   if (map?.viewer) handleMapReady(map)
})
onBeforeUnmount(() => {
   document.removeEventListener('click', handleClickOutside)
   document.removeEventListener('click', handleLayerClickOutside)
   clearDeviceEntities()
   clearDefenseZoneEntities()
   clearPartitionEntities()
   clearAggregationEntities()
   clearCommandPostEntities()
@@ -960,6 +1403,21 @@
   }
   cockpitPrimitiveLayer = null
   viewer = null
   publicCesium = null
})
const getMap = () => {
   return mapRef.value?.getMap()
}
// 暴露给父组件调用
const flyTo = (options) => {
   mapRef.value?.flyToPoint(options)
}
defineExpose({
   getMap,
   flyTo
})
</script>
@@ -980,14 +1438,14 @@
.layer-control-root {
   position: absolute;
   left: 337px;
   bottom: 22px;
   z-index: 9;
   right: 337px;
   top: 95px;
   z-index: 11;
   transition: transform 0.3s ease-in-out;
   pointer-events: none;
   &.collapsed {
      transform: translateX(-317px);
      transform: translateX(317px);
   }
}
@@ -1014,12 +1472,13 @@
   display: flex;
   flex-direction: column;
   position: absolute;
   left: 66px;
   bottom: 0;
   right: 66px;
   top: 0;
   width: 160px;
   max-height: 442px;
   background: #191932;
   border-radius: 8px 8px 8px 8px;
   z-index: 99;
   .panel-title {
      padding: 0 16px;
@@ -1041,6 +1500,137 @@
      flex: 1;
      overflow: auto;
   }
}
.base-layer-switch-root {
   position: absolute;
   right: 337px;
   bottom: 78px;
   z-index: 11;
   transition: transform 0.3s ease-in-out;
   pointer-events: none;
   &.collapsed {
      transform: translateX(317px);
   }
}
.base-layer-switch-wrap {
   position: relative;
   pointer-events: auto;
}
.base-layer-trigger {
   width: 55px;
   padding: 8px 0;
   border-radius: 8px;
   background: #191932;
   display: flex;
   flex-direction: column;
   align-items: center;
   gap: 6px;
   cursor: pointer;
}
.base-layer-trigger-thumb {
   width: 36px;
   height: 36px;
   border-radius: 6px;
   display: block;
   // border: 2px solid #fff;
   background-size: cover;
   background-position: center;
   box-sizing: border-box;
}
.base-layer-trigger-label {
   margin-top: 8px;
   font-size: 12px;
   line-height: 1;
   color: #ffffff;
   font-weight: 700;
}
.base-layer-panel {
   position: absolute;
   right: calc(100% + 10px);
   bottom: 0;
   margin-bottom: 0;
   width: 160px;
   background: #191932;
   border-radius: 8px;
   z-index: 99;
   &::after {
      content: '';
      position: absolute;
      right: -10px;
      top: 0;
      width: 10px;
      height: 100%;
      background: transparent;
   }
}
.base-map-options {
   padding: 8px;
   display: grid;
   grid-template-columns: repeat(2, minmax(0, 1fr));
   gap: 8px;
}
.base-map-card {
   display: flex;
   flex-direction: column;
   align-items: center;
   justify-content: center;
   gap: 6px;
   border-radius: 6px;
   color: #d8e6ff;
   font-size: 12px;
   cursor: pointer;
   transition: all 0.2s ease;
   .base-map-thumb {
      width: 36px;
      height: 36px;
      border-radius: 6px;
      // border: 2px solid #fff;
      background-size: cover;
      background-position: center;
      box-sizing: border-box;
   }
   .base-map-label {
      margin-top: 8px;
      font-size: 12px;
      line-height: 1;
      color: #ffffff;
      font-weight: 700;
   }
   // &.active {
   //    .base-map-thumb {
   //       border: 2px solid #2ea8ff;
   //    }
   //    .base-map-label {
   //       color: #2ea8ff;
   //    }
   // }
}
.layer-panel-slide-enter-active,
.layer-panel-slide-leave-active {
   transition: transform 0.24s ease, opacity 0.24s ease;
   transform-origin: right center;
}
.layer-panel-slide-enter-from,
.layer-panel-slide-leave-to {
   transform: translateX(24px);
   opacity: 0;
}
</style>