吉安感知网项目-前端
shuishen
2026-01-26 7589cd256fb347f7ff6c86da5dd90ae2dfa4e071
applications/drone-command/src/components/map-container/device-map-container.vue
@@ -1,7 +1,7 @@
<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"
         :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="100000"
         @ready="handleMapReady" @stage-change="handleStageChange" />
      <div v-if="props.showLayerControl" class="layer-control-root" :class="{ collapsed: props.leftCollapsed }">
@@ -20,58 +20,23 @@
         </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="toggleDroneFavorite"
         @signal="handleDroneSignal"
         @counter="handleDroneCounter"
      />
   </div>
</template>
@@ -85,10 +50,19 @@
import { cockpitAggregationApi } from '@/api/dataCockpit'
import layerControlIcon from '@/assets/images/dataCockpit/layerControl.png'
import equipmentIcon from '@/assets/images/dataCockpit/map/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 {
   RING_STYLES,
   addDeviceRings,
   createDroneTrackMaterial,
   createRadialGradientMaterial,
   getTexturedVertexFormat,
} from './device-map-materials'
const props = defineProps({
   onlineDevices: {
@@ -108,24 +82,37 @@
      default: true,
   },
})
const emit = defineEmits(['droneSignal', 'droneCounter'])
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 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 aggregationSource = null
let commandPostSource = null
let commandPostBillboardCollection = null
let droneTrackBillboardCollection = null
let droneTrackPolylineCollection = null
let droneTrackTickHandler = null
let droneTrackStartTime = null
let droneTrackRuntime = []
const detailVisible = ref(true)
const clusterVisible = ref(false)
const countyCenterMap = new Map()
const showLayerPanel = ref(false)
const layerWrapRef = ref(null)
const selectedDevice = ref(null)
let selectedDeviceEntity = null
const selectedTargetType = ref('device')
let selectedDeviceBillboard = null
let deviceClickHandler = null
let popupRenderHandler = null
const layerTreeProps = {
@@ -172,23 +159,101 @@
   return { longitude, latitude }
}
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 (defenseZonePrimitive) cockpitPrimitiveLayer.add(defenseZonePrimitive)
   if (defenseZoneOutlinePrimitive) cockpitPrimitiveLayer.add(defenseZoneOutlinePrimitive)
   if (partitionPrimitive) cockpitPrimitiveLayer.add(partitionPrimitive)
   if (partitionOutlinePrimitive) cockpitPrimitiveLayer.add(partitionOutlinePrimitive)
   if (deviceRingFillPrimitives.length) {
      deviceRingFillPrimitives.forEach(primitive => cockpitPrimitiveLayer.add(primitive))
   }
   if (deviceRingOutlinePrimitives.length) {
      deviceRingOutlinePrimitives.forEach(primitive => cockpitPrimitiveLayer.add(primitive))
   }
   if (droneTrackPolylineCollection) cockpitPrimitiveLayer.add(droneTrackPolylineCollection)
   if (droneTrackBillboardCollection) cockpitPrimitiveLayer.add(droneTrackBillboardCollection)
   if (deviceBillboardCollection) cockpitPrimitiveLayer.add(deviceBillboardCollection)
   if (commandPostBillboardCollection) cockpitPrimitiveLayer.add(commandPostBillboardCollection)
}
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 ensureDeviceCollections = () => {
   if (!viewer) return
   ensureCockpitPrimitiveLayer()
   if (!deviceBillboardCollection) {
      deviceBillboardCollection = new Cesium.BillboardCollection()
      addCockpitPrimitive(deviceBillboardCollection)
   }
}
const clearDefenseZoneEntities = () => {
   if (!defenseZoneSource) return
   defenseZoneSource.entities.removeAll()
   if (!viewer) return
   if (defenseZonePrimitive) {
      removeCockpitPrimitive(defenseZonePrimitive)
      defenseZonePrimitive = null
   }
   if (defenseZoneOutlinePrimitive) {
      removeCockpitPrimitive(defenseZoneOutlinePrimitive)
      defenseZoneOutlinePrimitive = null
   }
}
const clearPartitionEntities = () => {
   if (!partitionSource) return
   partitionSource.entities.removeAll()
   if (!viewer) return
   if (partitionPrimitive) {
      removeCockpitPrimitive(partitionPrimitive)
      partitionPrimitive = null
   }
   if (partitionOutlinePrimitive) {
      removeCockpitPrimitive(partitionOutlinePrimitive)
      partitionOutlinePrimitive = null
   }
}
const clearAggregationEntities = () => {
@@ -197,20 +262,42 @@
}
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 setDetailVisibility = visible => {
   detailVisible.value = visible
   if (defenseZoneSource) defenseZoneSource.show = visible
   if (partitionSource) partitionSource.show = visible
   if (commandPostSource) commandPostSource.show = visible
   if (defenseZonePrimitive) defenseZonePrimitive.show = visible
   if (defenseZoneOutlinePrimitive) defenseZoneOutlinePrimitive.show = visible
   if (partitionPrimitive) partitionPrimitive.show = visible
   if (partitionOutlinePrimitive) partitionOutlinePrimitive.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 => {
@@ -229,140 +316,244 @@
   })
}
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),
         },
         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 buildSimulatedTrackPoints = base => {
   ensureCountyCenterMap()
   const anchor = base || { longitude: 116.397, latitude: 39.908 }
   const offsets = [
      [0, 0, 120],
      [0.02, 0.012, 150],
      [0.04, 0.006, 180],
      [0.06, -0.008, 200],
      [0.03, -0.02, 160],
      [0.0, -0.015, 140],
      [-0.02, -0.005, 120],
      [-0.01, 0.01, 130],
   ]
   return offsets.map(item => ({
      longitude: anchor.longitude + item[0],
      latitude: anchor.latitude + item[1],
      height: item[2],
   }))
}
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))
const clearDroneTrackEntities = () => {
   if (!viewer) return
   stopDroneTrackAnimation()
   if (droneTrackBillboardCollection) {
      if (!droneTrackBillboardCollection.isDestroyed?.()) {
         removeCockpitPrimitive(droneTrackBillboardCollection)
      }
      droneTrackBillboardCollection = null
   }
   return positions
   if (droneTrackPolylineCollection) {
      if (!droneTrackPolylineCollection.isDestroyed?.()) {
         removeCockpitPrimitive(droneTrackPolylineCollection)
      }
      droneTrackPolylineCollection = null
   }
   droneTrackRuntime = []
   dronePickMap.clear()
   if (selectedTargetType.value === 'drone') {
      closePopup()
   }
}
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,
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 startDroneTrackAnimation = () => {
   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(
            track.positions[seg],
            track.positions[seg + 1],
            ratio,
            new Cesium.Cartesian3()
         )
         track.billboard.position = pos
      })
   }
   viewer.clock.onTick.addEventListener(droneTrackTickHandler)
   viewer.clock.shouldAnimate = true
}
const stopDroneTrackAnimation = () => {
   if (!viewer || !droneTrackTickHandler) return
   viewer.clock.onTick.removeEventListener(droneTrackTickHandler)
   droneTrackTickHandler = null
}
const renderSimulatedDroneTrack = () => {
   if (!viewer) return
   clearDroneTrackEntities()
   ensureCountyCenterMap()
   ensureDroneTrackCollections()
   droneTrackRuntime = []
   const centers = Array.from(countyCenterMap.entries())
   const baseCenters = centers.length
      ? centers.slice(0, 4)
      : [['默认区域', { longitude: 116.397, latitude: 39.908 }]]
   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[0],
         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)
      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,
      })
      droneTrackRuntime.push({
         positions,
         billboard,
         segmentDuration,
         duration: (points.length - 1) * segmentDuration,
      })
   })
}
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)
      )
   }
   viewer.clock.multiplier = 1
   startDroneTrackAnimation()
   reorderCockpitPrimitives()
}
const renderDeviceEntities = devices => {
   if (!viewer) return
   clearDeviceEntities()
   ensureCockpitPrimitiveLayer()
   ensureDeviceCollections()
   deviceBillboardCollection.removeAll()
   if (deviceRingFillPrimitives.length) {
      deviceRingFillPrimitives.forEach(primitive => removeCockpitPrimitive(primitive))
      deviceRingFillPrimitives = []
   }
   if (deviceRingOutlinePrimitives.length) {
      deviceRingOutlinePrimitives.forEach(primitive => removeCockpitPrimitive(primitive))
      deviceRingOutlinePrimitives = []
   }
   devicePickMap.clear()
   deviceBillboardCollection.show = detailVisible.value
   const ringFillInstancesByStyle = RING_STYLES.map(() => [])
   const ringOutlineInstancesByStyle = RING_STYLES.map(() => [])
   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,
      const entityId = `online-device-${item.id ?? index}-${index}`
      addDeviceRings(position, ringFillInstancesByStyle, ringOutlineInstancesByStyle)
      const billboard = deviceBillboardCollection.add({
         position: Cesium.Cartesian3.fromDegrees(position.longitude, position.latitude, 0),
         billboard: {
            image: equipmentIcon,
            width: 40,
            height: 56,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
         },
         image: equipmentIcon,
         width: 40,
         height: 56,
         verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
      })
      deviceEntityMap.set(entityId, { data: item, entity })
      billboard.id = entityId
      devicePickMap.set(entityId, { data: item, billboard })
   })
   if (selectedDeviceEntity && !deviceEntityMap.has(selectedDeviceEntity.id)) {
   if (selectedTargetType.value === 'device' && selectedDeviceBillboard && !devicePickMap.has(selectedDeviceBillboard.id)) {
      closePopup()
   }
   ringFillInstancesByStyle.forEach((instances, index) => {
      if (!instances.length) return
      const ring = RING_STYLES[index]
      const material = createRadialGradientMaterial(
         Cesium.Color.fromCssColorString(ring.gradient[0]).withAlpha(0.64),
         Cesium.Color.fromCssColorString(ring.gradient[1]).withAlpha(0.64)
      )
      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()
}
const getDefenseZonePositions = geom => {
@@ -377,70 +568,84 @@
   return list.map(item => Cesium.Cartesian3.fromDegrees(item.longitude, item.latitude))
}
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) => {
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 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 polygon = new Cesium.PolygonGeometry({
         polygonHierarchy: new Cesium.PolygonHierarchy(positions),
         vertexFormat: texturedVertexFormat,
      })
      polygonInstances.push(
         new Cesium.GeometryInstance({
            geometry: polygon,
         })
      )
      const linePositions = positions.length > 1 ? [...positions, positions[0]] : positions
      lineInstances.push(
         new Cesium.GeometryInstance({
            geometry: new Cesium.GroundPolylineGeometry({
               positions: linePositions,
               width: 2,
            }),
            attributes: {
               color: Cesium.ColorGeometryInstanceAttribute.fromColor(
                  Cesium.Color.fromCssColorString(lineColor)
               ),
            },
         })
      )
   })
   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 }
}
const renderDefenseZones = zones => {
   if (!viewer) return
   if (!defenseZoneSource) {
      defenseZoneSource = new Cesium.CustomDataSource('defenseZoneSource')
      viewer.dataSources.add(defenseZoneSource)
   }
   clearDefenseZoneEntities()
   defenseZoneSource.show = detailVisible.value
   renderZonePolygons({
      zones,
      source: defenseZoneSource,
      idPrefix: 'defense-zone',
      lineColor: '#19D266',
      fillGradient: ['#2AEDBF', '#012B11'],
   })
   const result = buildZonePrimitives(zones, '#19D266', '#2AEDBF', '#012B11')
   defenseZonePrimitive = result.primitive
   defenseZoneOutlinePrimitive = result.outlinePrimitive
   if (defenseZonePrimitive) defenseZonePrimitive.show = detailVisible.value
   if (defenseZoneOutlinePrimitive) defenseZoneOutlinePrimitive.show = detailVisible.value
   reorderCockpitPrimitives()
}
const renderPartitions = zones => {
   if (!viewer) return
   if (!partitionSource) {
      partitionSource = new Cesium.CustomDataSource('partitionSource')
      viewer.dataSources.add(partitionSource)
   }
   clearPartitionEntities()
   partitionSource.show = detailVisible.value
   renderZonePolygons({
      zones,
      source: partitionSource,
      idPrefix: 'partition-zone',
      lineColor: '#FFCD2A',
      fillGradient: ['#FFC609', '#583300'],
   })
   const result = buildZonePrimitives(zones, '#FFCD2A', '#FFC609', '#583300')
   partitionPrimitive = result.primitive
   partitionOutlinePrimitive = result.outlinePrimitive
   if (partitionPrimitive) partitionPrimitive.show = detailVisible.value
   if (partitionOutlinePrimitive) partitionOutlinePrimitive.show = detailVisible.value
   reorderCockpitPrimitives()
}
const loadDefenseZones = async () => {
@@ -496,11 +701,10 @@
            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,
         },
@@ -508,21 +712,23 @@
   })
}
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 = selectedDeviceBillboard.position
   if (!cartesian) return
   const screenPosition = viewer.scene.cartesianToCanvasCoordinates(cartesian)
   if (!screenPosition) return
@@ -545,13 +751,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 +776,45 @@
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 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 isDronePopup = computed(() => selectedTargetType.value === 'drone')
const toggleDroneFavorite = () => {
   if (!selectedDevice.value) return
   selectedDevice.value.isFavorite = !selectedDevice.value.isFavorite
}
const handleDroneSignal = () => emit('droneSignal', selectedDevice.value)
const handleDroneCounter = () => emit('droneCounter', selectedDevice.value)
const renderCommandPosts = list => {
   if (!viewer) return
   if (!commandPostSource) {
      commandPostSource = new Cesium.CustomDataSource('commandPostSource')
      viewer.dataSources.add(commandPostSource)
   }
   clearCommandPostEntities()
   commandPostSource.show = detailVisible.value
      ; (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,
            },
         })
   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,
      })
   })
   reorderCockpitPrimitives()
}
const loadAggregation = async () => {
   try {
      const res = await cockpitAggregationApi()
      const res = await cockpitAggregationApi({
         effectiveRangeKmIsNotNull: 1
      })
      renderAggregation(res?.data?.data ?? [])
   } catch (error) {
      renderAggregation([])
@@ -711,6 +859,7 @@
const handleMapReady = ({ viewer: mapViewer }) => {
   viewer = mapViewer
   ensureCockpitPrimitiveLayer()
   const height = viewer?.camera?.positionCartographic?.height
   const stage = height != null && height >= 100000 ? 'cluster' : 'detail'
   updateStageDisplay(stage)
@@ -719,6 +868,7 @@
   loadPartitions()
   loadAggregation()
   loadCommandPosts()
   renderSimulatedDroneTrack()
   initDeviceClickHandler()
}
@@ -746,8 +896,13 @@
   clearPartitionEntities()
   clearAggregationEntities()
   clearCommandPostEntities()
   clearDroneTrackEntities()
   closePopup()
   destroyDeviceClickHandler()
   if (cockpitPrimitiveLayer && viewer?.scene?.primitives) {
      viewer.scene.primitives.remove(cockpitPrimitiveLayer)
   }
   cockpitPrimitiveLayer = null
   viewer = null
})
</script>
@@ -765,158 +920,6 @@
   left: 0;
   width: 100%;
   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 {
@@ -1065,3 +1068,4 @@
   border-color: #a1a3d4;
}
</style>