吉安感知网项目-前端
shuishen
7 hours ago 6e88705bd5b443a259b24c17c8a299765d059d96
applications/drone-command/src/components/map-container/device-map-container.vue
@@ -1,101 +1,109 @@
<template>
<template>
   <div class="map-shell">
      <CommonCesiumMap ref="mapRef" class="command-cesium map-container" :dom-id="props.containerId" :active="true"
         :flat-mode="false" :terrain="false" :layer-mode="4" :contour="false" :boundary="false"
         :show-admin-boundary="true" :zoom-to-boundary="true" :enable-stage-emit="true" :cluster-height="100000"
         @ready="handleMapReady" @stage-change="handleStageChange" />
      <div v-if="props.showLayerControl" class="layer-control-root" :class="{ collapsed: props.leftCollapsed }">
         :flat-mode="false" :terrain="true" :layer-mode="4" :contour="false" :boundary="false"
         :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.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 :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>
      <div v-if="popupVisible" class="device-popup" :style="popupStyle" @click.stop>
         <div class="device-popup__header">
            <span class="device-popup__title">{{ popupTitle }}</span>
      <DevicePopup :visible="popupVisible && !isDronePopup" :position="popupPosition" :device="selectedDevice"
         @close="closePopup" />
            <img class="device-popup__close" :src="popupClose" alt="" @click.stop="closePopup">
         </div>
         <div class="device-popup__type">
            <span class="value">{{ popupTypeText }}</span>
         </div>
         <div class="device-popup__subtitle">
            <span class="device-popup__dot" :class="popupActionClass"></span>
            <span class="device-popup__action">{{ popupActionText }}</span>
         </div>
         <div class="device-popup__rows">
            <div class="device-popup__row">
               <div class="device-popup__col">
                  <span class="label">状态</span>
                  <span class="value" :class="popupStatusClass">{{ popupStatusText }}</span>
               </div>
               <span class="divider">|</span>
               <div class="device-popup__col">
                  <span class="label">电量</span>
                  <span class="value">{{ popupBatteryText }}</span>
               </div>
            </div>
            <div class="device-popup__row">
               <div class="device-popup__col">
                  <span class="label">方位角</span>
                  <span class="value">{{ popupAzimuthText }}</span>
               </div>
               <span class="divider">|</span>
               <div class="device-popup__col">
                  <span class="label">俯仰角</span>
                  <span class="value">{{ popupElevationText }}</span>
               </div>
            </div>
            <div class="device-popup__row">
               <span class="label">侦测目标</span>
               <span class="value">{{ popupDetectCountText }}</span>
            </div>
            <div class="device-popup__row">
               <span class="label">有效范围</span>
               <span class="value">{{ popupRangeText }}</span>
            </div>
         </div>
      </div>
      <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 popupClose from '@/assets/images/dataCockpit/map/popup-close.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 {
   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,
   },
@@ -108,41 +116,79 @@
      default: true,
   },
})
const emit = defineEmits(['droneSignal', 'droneCounter', 'droneFavorite'])
const DEFAULT_ZONE_PAGE_SIZE = 999
const mapRef = ref(null)
let viewer = null
const deviceEntityIds = new Set()
const deviceEntityMap = new Map()
let defenseZoneSource = null
let partitionSource = null
let publicCesium = null
let cockpitPrimitiveLayer = null
const devicePickMap = new Map()
const dronePickMap = new Map()
let deviceBillboardCollection = null
let deviceRingOutlinePrimitives = []
let deviceRingFillPrimitives = []
let partitionFillPrimitives = []
let partitionOutlinePrimitives = []
let aggregationSource = null
let commandPostSource = 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)
let selectedDeviceEntity = null
const selectedTargetType = ref('device')
let selectedDeviceBillboard = null
let deviceClickHandler = null
let popupRenderHandler = null
const layerTreeProps = {
   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: [
@@ -160,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
@@ -172,23 +273,101 @@
   return { longitude, latitude }
}
const getDeviceRange = item => {
   if (!item) return null
   const rawRange = item.effectiveRangeKm ?? item.range ?? item.coverRadiusM
   const range = Number(rawRange)
   if (!Number.isFinite(range) || range <= 0) return null
   return range
}
const ensureCockpitPrimitiveLayer = () => {
   if (!viewer) return
   if (!cockpitPrimitiveLayer) {
      cockpitPrimitiveLayer = new Cesium.PrimitiveCollection({ destroyPrimitives: false })
      viewer.scene.primitives.add(cockpitPrimitiveLayer)
   }
}
const addCockpitPrimitive = primitive => {
   if (!primitive) return
   if (cockpitPrimitiveLayer) {
      cockpitPrimitiveLayer.add(primitive)
      return
   }
   viewer?.scene?.primitives?.add(primitive)
}
const removeCockpitPrimitive = (primitive, destroy = true) => {
   if (!primitive) return
   if (cockpitPrimitiveLayer) {
      cockpitPrimitiveLayer.remove(primitive, destroy)
      return
   }
   viewer?.scene?.primitives?.remove(primitive)
}
const reorderCockpitPrimitives = () => {
   if (!viewer) return
   ensureCockpitPrimitiveLayer()
   cockpitPrimitiveLayer.removeAll(false)
   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)
}
const clearDeviceEntities = () => {
   if (!viewer) return
   deviceEntityIds.forEach(id => {
      viewer.entities.removeById(id)
   })
   deviceEntityIds.clear()
   deviceEntityMap.clear()
   if (deviceBillboardCollection) {
      deviceBillboardCollection.removeAll()
      removeCockpitPrimitive(deviceBillboardCollection)
      deviceBillboardCollection = null
   }
   if (deviceRingFillPrimitives.length) {
      deviceRingFillPrimitives.forEach(primitive => removeCockpitPrimitive(primitive))
      deviceRingFillPrimitives = []
   }
   if (deviceRingOutlinePrimitives.length) {
      deviceRingOutlinePrimitives.forEach(primitive => removeCockpitPrimitive(primitive))
      deviceRingOutlinePrimitives = []
   }
   devicePickMap.clear()
}
const clearDefenseZoneEntities = () => {
   if (!defenseZoneSource) return
   defenseZoneSource.entities.removeAll()
const ensureDeviceCollections = () => {
   if (!viewer) return
   ensureCockpitPrimitiveLayer()
   if (!deviceBillboardCollection) {
      deviceBillboardCollection = new Cesium.BillboardCollection()
      addCockpitPrimitive(deviceBillboardCollection)
   }
}
const clearPartitionEntities = () => {
   if (!partitionSource) return
   partitionSource.entities.removeAll()
   if (!viewer) return
   if (partitionFillPrimitives.length) {
      partitionFillPrimitives.forEach(primitive => removeCockpitPrimitive(primitive))
      partitionFillPrimitives = []
   }
   if (partitionOutlinePrimitives.length) {
      partitionOutlinePrimitives.forEach(primitive => removeCockpitPrimitive(primitive))
      partitionOutlinePrimitives = []
   }
}
const clearAggregationEntities = () => {
@@ -197,25 +376,84 @@
}
const clearCommandPostEntities = () => {
   if (!commandPostSource) return
   commandPostSource.entities.removeAll()
   if (!viewer) return
   if (commandPostBillboardCollection) {
      commandPostBillboardCollection.removeAll()
      removeCockpitPrimitive(commandPostBillboardCollection)
      commandPostBillboardCollection = null
   }
}
const ensureCommandPostCollection = () => {
   if (!viewer) return
   ensureCockpitPrimitiveLayer()
   if (!commandPostBillboardCollection) {
      commandPostBillboardCollection = new Cesium.BillboardCollection()
      addCockpitPrimitive(commandPostBillboardCollection)
   }
}
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 (defenseZoneSource) defenseZoneSource.show = visible
   if (partitionSource) partitionSource.show = visible
   if (commandPostSource) commandPostSource.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) {
      deviceRingFillPrimitives.forEach(primitive => {
         primitive.show = visible
      })
   }
   if (deviceRingOutlinePrimitives.length) {
      deviceRingOutlinePrimitives.forEach(primitive => {
         primitive.show = visible
      })
   }
   if (!visible) closePopup()
   deviceEntityIds.forEach(id => {
      const entity = viewer?.entities?.getById(id)
      if (entity) entity.show = visible
   })
}
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()
   }
}
const getStageByHeight = height => {
   if (height == null) return 'detail'
   if (height >= CLUSTER_HEIGHT) return 'cluster'
   if (height <= DETAIL_HEIGHT) return 'detail'
   return 'mid'
}
const ensureCountyCenterMap = () => {
@@ -229,237 +467,645 @@
   })
}
const RING_STYLES = [
   { inner: 0, outer: 2000, gradient: ['#FF361C', '#360B00'] }
]
const MATERIAL_TYPE = 'RadialGradientMaterial'
let materialRegistered = false
const registerRadialGradientMaterial = () => {
   if (materialRegistered || !Cesium?.Material) return
   materialRegistered = true
   Cesium.Material._materialCache.addMaterial(MATERIAL_TYPE, {
      fabric: {
         type: MATERIAL_TYPE,
         uniforms: {
            color1: new Cesium.Color(1.0, 1.0, 1.0, 1.0),
            color2: new Cesium.Color(0.0, 0.0, 0.0, 1.0),
const buildSimulatedTrackPoints = center => {
   const basePoints = [
      [
         {
            longitude: 114.929475,
            latitude: 27.136575,
            height: 200
         },
         source: `
            czm_material czm_getMaterial(czm_materialInput materialInput) {
               czm_material material = czm_getDefaultMaterial(materialInput);
               vec2 st = materialInput.st - vec2(0.5);
               float t = clamp(length(st) * 2.0, 0.0, 1.0);
               vec4 color = mix(color1, color2, t);
               material.diffuse = color.rgb;
               material.alpha = color.a;
               return material;
            }
         `,
      },
      translucent: () => true,
   })
}
const buildCirclePositions = (center, radiusMeters, steps = 64) => {
   const positions = []
   const latRad = Cesium.Math.toRadians(center.latitude)
   const metersToLat = 1 / 111320
   const metersToLon = 1 / (111320 * Math.cos(latRad))
   for (let i = 0; i <= steps; i += 1) {
      const angle = Cesium.Math.toRadians((i / steps) * 360)
      const dx = radiusMeters * Math.cos(angle)
      const dy = radiusMeters * Math.sin(angle)
      const lon = center.longitude + dx * metersToLon
      const lat = center.latitude + dy * metersToLat
      positions.push(Cesium.Cartesian3.fromDegrees(lon, lat))
   }
   return positions
}
const addDeviceRings = (center, baseId, visible) => {
   RING_STYLES.forEach((ring, index) => {
      const outerPositions = buildCirclePositions(center, ring.outer)
      const holes = ring.inner
         ? [new Cesium.PolygonHierarchy(buildCirclePositions(center, ring.inner))]
         : []
      const entityId = `${baseId}-ring-${index}`
      deviceEntityIds.add(entityId)
      registerRadialGradientMaterial()
      const [startColor, endColor] = ring.gradient
      const color1 = Cesium.Color.fromCssColorString(startColor).withAlpha(0.34)
      const color2 = Cesium.Color.fromCssColorString(endColor).withAlpha(0.34)
      const material = new RadialGradientMaterialProperty(color1, color2)
      viewer.entities.add({
         id: entityId,
         show: visible,
         polygon: {
            hierarchy: new Cesium.PolygonHierarchy(outerPositions, holes),
            material,
         {
            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]
}
class RadialGradientMaterialProperty {
   constructor(color1, color2) {
      this._definitionChanged = new Cesium.Event()
      this.color1 = color1
      this.color2 = color2
   }
   get isConstant () {
      return true
   }
   get definitionChanged () {
      return this._definitionChanged
   }
   getType () {
      return MATERIAL_TYPE
   }
   getValue (time, result) {
      const target = result || {}
      target.color1 = this.color1
      target.color2 = this.color2
      return target
   }
   equals (other) {
      return (
         other instanceof RadialGradientMaterialProperty &&
         Cesium.Color.equals(this.color1, other.color1) &&
         Cesium.Color.equals(this.color2, other.color2)
      )
   }
}
const renderDeviceEntities = devices => {
const clearDroneTrackEntities = () => {
   if (!viewer) return
   clearDeviceEntities()
   devices.forEach((item, index) => {
      const position = getDevicePosition(item)
      if (!position) return
      const entityId = `online-device-${item.id ?? index}`
      deviceEntityIds.add(entityId)
      addDeviceRings(position, entityId, detailVisible.value)
      const entity = viewer.entities.add({
         id: entityId,
         show: detailVisible.value,
         position: Cesium.Cartesian3.fromDegrees(position.longitude, position.latitude, 0),
         billboard: {
            image: equipmentIcon,
            width: 40,
            height: 56,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
         },
   stopDroneTrackAnimation()
   stopFavoritePulseAnimation()
   if (droneTrackBillboardCollection) {
      if (!droneTrackBillboardCollection.isDestroyed?.()) {
         removeCockpitPrimitive(droneTrackBillboardCollection)
      }
      droneTrackBillboardCollection = null
   }
   if (droneTrackPolylineCollection) {
      if (!droneTrackPolylineCollection.isDestroyed?.()) {
         removeCockpitPrimitive(droneTrackPolylineCollection)
      }
      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)
      })
      deviceEntityMap.set(entityId, { data: item, entity })
   })
   if (selectedDeviceEntity && !deviceEntityMap.has(selectedDeviceEntity.id)) {
      favoritePulseEntities.length = 0
   }
   dronePickMap.clear()
   if (selectedTargetType.value === 'drone') {
      closePopup()
   }
}
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 ensureDroneTrackCollections = () => {
   if (!viewer) return
   ensureCockpitPrimitiveLayer()
   if (droneTrackBillboardCollection?.isDestroyed?.()) {
      droneTrackBillboardCollection = null
   }
   if (droneTrackPolylineCollection?.isDestroyed?.()) {
      droneTrackPolylineCollection = null
   }
   if (!droneTrackBillboardCollection) {
      droneTrackBillboardCollection = new Cesium.BillboardCollection()
      addCockpitPrimitive(droneTrackBillboardCollection)
   }
   if (!droneTrackPolylineCollection) {
      droneTrackPolylineCollection = new Cesium.PolylineCollection()
      addCockpitPrimitive(droneTrackPolylineCollection)
   }
}
const renderZonePolygons = ({ zones, source, idPrefix, lineColor, fillGradient }) => {
   if (!viewer || !source) return
   registerRadialGradientMaterial()
   const borderColor = Cesium.Color.fromCssColorString(lineColor)
   const fillColorStart = Cesium.Color.fromCssColorString(fillGradient[0]).withAlpha(0.34)
   const fillColorEnd = Cesium.Color.fromCssColorString(fillGradient[1]).withAlpha(0.34)
   const fillMaterial = new RadialGradientMaterialProperty(fillColorStart, fillColorEnd)
   zones.forEach((zone, index) => {
      if (!zone?.geom) return
      const positions = getDefenseZonePositions(zone.geom)
      if (!positions.length) return
      const entityId = `${idPrefix}-${zone.id ?? index}`
      const linePositions = positions.length > 1 ? [...positions, positions[0]] : positions
      source.entities.add({
         id: entityId,
         polygon: {
            hierarchy: new Cesium.PolygonHierarchy(positions),
            material: fillMaterial,
            outline: true,
            outlineColor: borderColor,
            heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
         },
         polyline: {
            positions: linePositions,
            clampToGround: true,
            width: 2,
            material: borderColor,
         },
const ensureDroneTrackSource = () => {
   if (!viewer) return
   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
      }
      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 = () => {
   droneTrackTickHandler = null
   if (droneTrackRafId) {
      cancelAnimationFrame(droneTrackRafId)
      droneTrackRafId = null
   }
}
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 baseTrackColor = Cesium.Color.fromCssColorString('red')
      ; (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: {
               ...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,
         })
      })
   })
   startDroneTrackAnimation()
   startFavoritePulseAnimation()
   reorderCockpitPrimitives()
}
const renderDefenseZones = zones => {
const renderDeviceEntities = async devices => {
   if (!viewer) return
   if (!defenseZoneSource) {
      defenseZoneSource = new Cesium.CustomDataSource('defenseZoneSource')
      viewer.dataSources.add(defenseZoneSource)
   ensureCockpitPrimitiveLayer()
   ensureDeviceCollections()
   deviceBillboardCollection.removeAll()
   if (deviceRingFillPrimitives.length) {
      deviceRingFillPrimitives.forEach(primitive => removeCockpitPrimitive(primitive))
      deviceRingFillPrimitives = []
   }
   clearDefenseZoneEntities()
   defenseZoneSource.show = detailVisible.value
   renderZonePolygons({
      zones,
      source: defenseZoneSource,
      idPrefix: 'defense-zone',
      lineColor: '#19D266',
      fillGradient: ['#2AEDBF', '#012B11'],
   })
}
const renderPartitions = zones => {
   if (!viewer) return
   if (!partitionSource) {
      partitionSource = new Cesium.CustomDataSource('partitionSource')
      viewer.dataSources.add(partitionSource)
   if (deviceRingOutlinePrimitives.length) {
      deviceRingOutlinePrimitives.forEach(primitive => removeCockpitPrimitive(primitive))
      deviceRingOutlinePrimitives = []
   }
   clearPartitionEntities()
   partitionSource.show = detailVisible.value
   renderZonePolygons({
      zones,
      source: partitionSource,
      idPrefix: 'partition-zone',
      lineColor: '#FFCD2A',
      fillGradient: ['#FFC609', '#583300'],
   devicePickMap.clear()
   deviceBillboardCollection.show = detailVisible.value
   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)
      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()
   }
   reorderCockpitPrimitives()
}
const loadDefenseZones = async () => {
   if (!viewer) return
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),
         }
      })
      .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 getAreaTypeStyle = areaType => {
   return AREA_TYPE_STYLE_MAP?.[String(areaType)] || DEFAULT_AREA_STYLE
}
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),
                     },
                  })
               )
            })
         )
      })
   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 primitive = new Cesium.Primitive({
            geometryInstances: group.polygonInstances,
            appearance: new Cesium.MaterialAppearance({
               material,
               translucent: true,
            }),
         })
         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)
      }
   })
   return { primitives, outlinePrimitives }
}
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 {
      const res = await fwDefenseZonePageApi({ current: 1, size: DEFAULT_ZONE_PAGE_SIZE })
      renderDefenseZones(res?.data?.data?.records ?? [])
   } catch (error) {
      renderDefenseZones([])
      return JSON.parse(trimmed)
   } catch (error) { }
   return null
}
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 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([])
   }
}
@@ -471,7 +1117,7 @@
      viewer.dataSources.add(aggregationSource)
   }
   clearAggregationEntities()
   aggregationSource.show = clusterVisible.value
   updateAggregationVisibility()
   const countMap = new Map()
      ; (list || []).forEach(item => {
         if (!item?.type) return
@@ -488,7 +1134,7 @@
            width: 66.73,
            height: 43,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
            // disableDepthTestDistance: Number.POSITIVE_INFINITY,
         },
         label: {
            text: `${count}`,
@@ -496,33 +1142,33 @@
            style: Cesium.LabelStyle.FILL_AND_OUTLINE,
            verticalOrigin: Cesium.VerticalOrigin.CENTER,
            horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
            font: '12pt Source Han Sans CN',
            font: '11pt Source Han Sans CN',
            eyeOffset: new Cesium.Cartesian3(0, 0, -20), // 让label "浮" 在广告牌前面
            pixelOffset: new Cesium.Cartesian2(2, -11),
            pixelOffset: new Cesium.Cartesian2(0, -48),
            pixelOffset: new Cesium.Cartesian2(0, -35),
            disableDepthTestDistance: Number.POSITIVE_INFINITY,
            heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
         },
      })
   })
}
const getPickedDevice = picks => {
const getPickedTarget = picks => {
   for (const pick of picks) {
      const entityId = typeof pick?.id === 'string' ? pick.id : pick?.id?.id
      if (entityId && deviceEntityMap.has(entityId)) {
         return deviceEntityMap.get(entityId)
      const pickId = pick?.id
      const resolvedId = typeof pickId === 'string' ? pickId : pickId?.id
      if (resolvedId && devicePickMap.has(resolvedId)) {
         return { type: 'device', ...devicePickMap.get(resolvedId) }
      }
      if (resolvedId && dronePickMap.has(resolvedId)) {
         return { type: 'drone', ...dronePickMap.get(resolvedId) }
      }
   }
   return null
}
const updatePopupPosition = () => {
   if (!viewer || !selectedDeviceEntity) return
   const cartesian =
      selectedDeviceEntity.position?.getValue?.(Cesium.JulianDate.now()) ||
      selectedDeviceEntity.position?._value
   if (!viewer || !selectedDeviceBillboard) return
   const cartesian = getBillboardPosition(selectedDeviceBillboard)
   if (!cartesian) return
   const screenPosition = viewer.scene.cartesianToCanvasCoordinates(cartesian)
   if (!screenPosition) return
@@ -545,13 +1191,14 @@
const handleDeviceClick = movement => {
   if (!viewer) return
   const picks = viewer.scene.drillPick(movement.position) || []
   const pickedDevice = getPickedDevice(picks)
   if (!pickedDevice) {
   const pickedTarget = getPickedTarget(picks)
   if (!pickedTarget) {
      closePopup()
      return
   }
   selectedDevice.value = pickedDevice.data
   selectedDeviceEntity = pickedDevice.entity
   selectedTargetType.value = pickedTarget.type
   selectedDevice.value = pickedTarget.data
   selectedDeviceBillboard = pickedTarget.billboard
   startPopupRender()
}
@@ -569,104 +1216,61 @@
function closePopup () {
   selectedDevice.value = null
   selectedDeviceEntity = null
   selectedDeviceBillboard = null
   selectedTargetType.value = 'device'
   stopPopupRender()
}
const popupPosition = ref({ x: 0, y: 0 })
const popupVisible = computed(() => Boolean(selectedDevice.value))
const popupStyle = computed(() => ({
   left: `${popupPosition.value.x}px`,
   top: `${popupPosition.value.y}px`,
}))
const isDronePopup = computed(() => selectedTargetType.value === 'drone')
const handleDroneFavorite = () => {
   if (!selectedDevice.value) return
   emit('droneFavorite', selectedDevice.value)
}
const handleDroneSignal = () => emit('droneSignal', selectedDevice.value)
const handleDroneCounter = () => emit('droneCounter', selectedDevice.value)
const popupTitle = computed(() => selectedDevice.value?.deviceName || selectedDevice.value?.deviceModel || '-')
const popupStatusText = computed(() => {
   const status = selectedDevice.value?.status
   if (status === 0) return '在线'
   if (status === 1) return '离线'
   if (status === 2) return '故障'
   if (status === 3) return '报废'
   return '-'
})
const popupStatusClass = computed(() => (selectedDevice.value?.status === 0 ? 'online' : ''))
const popupActionText = computed(() => {
   const workMode = selectedDevice.value?.workMode
   if (workMode === 1 || workMode === '1') return '侦测中'
   if (workMode === 2 || workMode === '2') return '信号干扰中'
   if (workMode === 3 || workMode === '3') return '诱导驱离中'
   if (workMode === 4 || workMode === '4') return '待机'
   return workMode || '-'
})
const popupActionClass = computed(() => {
   const workMode = selectedDevice.value?.workMode
   if (workMode === 1 || workMode === '1') return 'is-detecting'
   if (workMode === 2 || workMode === '2') return 'is-jamming'
   if (workMode === 3 || workMode === '3') return 'is-driving'
   if (workMode === 4 || workMode === '4') return 'is-standby'
   return 'is-standby'
})
const popupTypeText = computed(() => {
   const type = selectedDevice.value?.deviceType
   if (type === 1 || type === '1') return '便捷侦测箱'
   if (type === 2 || type === '2') return '反制枪'
   if (type === 3 || type === '3') return '察打一体'
   return type || '-'
})
const popupBatteryText = computed(() => {
   const val = selectedDevice.value?.batteryPct
   if (val == null) return '-'
   return `${val}%`
})
const popupAzimuthText = computed(() => {
   const val = selectedDevice.value?.azimuth
   if (val == null) return '-'
   return `${val}°`
})
const popupElevationText = computed(() => {
   const val = selectedDevice.value?.elevation
   if (val == null) return '-'
   return `${val}°`
})
const popupDetectCountText = computed(() => {
   const val = selectedDevice.value?.detectTargetCnt
   if (val == null) return '-'
   return `${val}台`
})
const popupRangeText = computed(() => {
   const val = selectedDevice.value?.effectiveRangeKm
   if (val == null) return '-'
   return `${val}KM`
})
const renderCommandPosts = list => {
const renderCommandPosts = async list => {
   if (!viewer) return
   if (!commandPostSource) {
      commandPostSource = new Cesium.CustomDataSource('commandPostSource')
      viewer.dataSources.add(commandPostSource)
   }
   clearCommandPostEntities()
   commandPostSource.show = detailVisible.value
   ensureCommandPostCollection()
   commandPostBillboardCollection.removeAll()
   commandPostBillboardCollection.show = detailVisible.value
   const entries = []
   const positions = []
      ; (list || []).forEach((item, index) => {
         const position = getDevicePosition(item)
         if (!position) return
         commandPostSource.entities.add({
            id: `command-post-${item.id ?? index}`,
            position: Cesium.Cartesian3.fromDegrees(position.longitude, position.latitude, 0),
            billboard: {
               image: commandPostIcon,
               width: 40,
               height: 56,
               verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
               heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
            },
         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 ?? [])
   } catch (error) {
      renderAggregation([])
@@ -675,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 || [])
   },
@@ -692,9 +1298,24 @@
)
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
   }
)
@@ -703,22 +1324,51 @@
   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 => {
   const showCluster = stage === 'cluster'
   setClusterVisibility(showCluster)
   setDetailVisibility(!showCluster)
   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 = height != null && height >= 100000 ? 'cluster' : 'detail'
   const stage = getStageByHeight(height)
   updateStageDisplay(stage)
   renderDeviceEntities(props.onlineDevices)
   loadDefenseZones()
   await loadCommandPosts()
   renderDeviceEntities(props.allDevices)
   loadPartitions()
   loadAggregation()
   loadCommandPosts()
   renderSimulatedDroneTrack(props.alarmDrones)
   initDeviceClickHandler()
}
@@ -726,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
@@ -734,21 +1384,40 @@
}
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()
   clearDroneTrackEntities()
   closePopup()
   destroyDeviceClickHandler()
   if (cockpitPrimitiveLayer && viewer?.scene?.primitives) {
      viewer.scene.primitives.remove(cockpitPrimitiveLayer)
   }
   cockpitPrimitiveLayer = null
   viewer = null
   publicCesium = null
})
const getMap = () => {
   return mapRef.value?.getMap()
}
// 暴露给父组件调用
const flyTo = (options) => {
   mapRef.value?.flyToPoint(options)
}
defineExpose({
   getMap,
   flyTo
})
</script>
@@ -767,168 +1436,16 @@
   height: 100%;
}
.device-popup {
   position: absolute;
   transform: translate(-50%, calc(-100% - 72px));
   width: 202px;
   padding: 0 16px 20px;
   border-radius: 16px;
   background: rgba(5, 5, 15, 0.7);
   backdrop-filter: blur(16px);
   color: #C3C3DD;
   font-family: Source Han Sans CN, Source Han Sans CN;
   font-size: 12px;
   pointer-events: auto;
   z-index: 11;
   box-sizing: border-box;
   &::after {
      content: "";
      position: absolute;
      left: 50%;
      bottom: -8px;
      transform: translateX(-50%);
      width: 0;
      height: 0;
      border-left: 8px solid transparent;
      border-right: 8px solid transparent;
      border-top: 8px solid rgba(5, 5, 15, 0.7);
   }
   .device-popup__header {
      height: 40px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      position: relative;
      font-weight: bold;
      font-size: 14px;
      color: #FFFFFF;
      &::after {
         content: "";
         position: absolute;
         left: 0;
         bottom: 0;
         width: 50px;
         height: 2px;
         background: #284FE3;
         box-shadow: 0px 3px 2px 0px rgba(15, 89, 255, 0.1), 0px 7px 5px 0px rgba(15, 89, 255, 0.15), 0px 13px 10px 0px rgba(15, 89, 255, 0.18), 0px 22px 18px 0px rgba(15, 89, 255, 0.21), 0px 42px 33px 0px rgba(15, 89, 255, 0.26), 0px 100px 80px 0px rgba(15, 89, 255, 0.36);
         border-radius: 5px 5px 0px 0px;
      }
      &::before {
         content: "";
         position: absolute;
         left: 55px;
         right: 0;
         bottom: 0;
         height: 2px;
         background: #323245;
         border-radius: 0px 0px 5px 5px;
      }
      .device-popup__close {
         cursor: pointer;
      }
   }
   .device-popup__type {
      display: flex;
      align-items: center;
      gap: 6px;
      font-size: 12px;
      margin-top: 8px;
      margin-bottom: 10px;
      color: #d2d7df;
      .label {
         color: #9aa4b2;
      }
   }
   .device-popup__subtitle {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      padding: 2px 10px;
      border-radius: 999px;
      background: rgba(255, 255, 255, 0.06);
      font-size: 12px;
      margin-bottom: 10px;
      .device-popup__dot {
         width: 6px;
         height: 6px;
         border-radius: 50%;
         background: #6b7685;
         &.is-detecting {
            background: #35f08c;
            box-shadow: 0 0 6px rgba(53, 240, 140, 0.8);
         }
         &.is-jamming {
            background: #ff4444;
            box-shadow: 0 0 6px rgba(255, 68, 68, 0.7);
         }
         &.is-driving {
            background: #ffb020;
            box-shadow: 0 0 6px rgba(255, 176, 32, 0.7);
         }
         &.is-standby {
            background: #9aa4b2;
         }
      }
   }
   .device-popup__rows {
      display: flex;
      flex-direction: column;
      gap: 6px;
      font-size: 12px;
      color: #d2d7df;
      .device-popup__row {
         display: flex;
         align-items: center;
         margin-bottom: 10px;
         .device-popup__col {
            flex: 1;
            display: flex;
            align-items: center;
            &:last-child {
               justify-content: flex-end;
            }
         }
      }
      .value {
         margin-left: 10px;
      }
      .value.online {
         color: #35f08c;
      }
   }
}
.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);
   }
}
@@ -955,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;
@@ -983,85 +1501,136 @@
      overflow: auto;
   }
   ::v-deep(.el-tree) {
      background: transparent;
      color: #c3c3dd;
      font-size: 12px;
}
      .el-tree-node {
         line-height: 30px !important;
.base-layer-switch-root {
   position: absolute;
   right: 337px;
   bottom: 78px;
   z-index: 11;
   transition: transform 0.3s ease-in-out;
   pointer-events: none;
         .el-tree-node__content {
            padding-left: 0 !important;
            display: flex;
            align-items: center;
            height: 40px !important;
            line-height: 40px !important;
            border-bottom: 1px solid rgba(70, 70, 100, 0.5);
            box-sizing: border-box;
         }
         .el-tree-node__children {
            .el-tree-node__content {
               border: none;
            }
         }
         &:focus {
            .el-tree-node__content {
               background: transparent !important;
            }
         }
      }
   &.collapsed {
      transform: translateX(317px);
   }
}
.layer-panel :deep(.el-tree-node__label) {
   color: #c3c3dd;
.base-layer-switch-wrap {
   position: relative;
   pointer-events: auto;
}
.layer-panel :deep(.el-tree-node__expand-icon) {
   order: 3;
   margin-left: 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;
}
.layer-panel :deep(.el-tree-node__expand-icon.is-leaf) {
   visibility: hidden;
.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;
}
.layer-panel :deep(.el-checkbox) {
   order: 1;
}
.layer-panel :deep(.el-tree-node__label) {
   order: 2;
}
.layer-panel :deep(.el-tree-node__expand-icon:not(.is-leaf) ~ .el-checkbox) {
   display: none;
}
.layer-panel :deep(.el-tree-node__content:hover),
.layer-panel :deep(.el-tree-node__content:focus) {
   background: transparent !important;
}
.layer-panel :deep(.el-tree-node.is-current > .el-tree-node__content) {
   background: transparent !important;
.base-layer-trigger-label {
   margin-top: 8px;
   font-size: 12px;
   line-height: 1;
   color: #ffffff;
   font-weight: 700;
}
.layer-panel :deep(.el-tree-node.is-current > .el-tree-node__content .el-tree-node__label) {
   color: #ffffff;
.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;
   }
}
.layer-panel :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
   background-color: #023aff;
   border-color: #023aff;
.base-map-options {
   padding: 8px;
   display: grid;
   grid-template-columns: repeat(2, minmax(0, 1fr));
   gap: 8px;
}
.layer-panel :deep(.el-checkbox__inner) {
   background-color: transparent;
   border-color: #a1a3d4;
.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>