吉安感知网项目-前端
罗广辉
2026-01-26 beb95fb5fc166804056abafd70fc01ac27de7621
applications/drone-command/src/components/map-container/device-map-container.vue
@@ -1,38 +1,42 @@
<template>
   <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"
      @ready="handleMapReady"
   />
   <div v-if="props.showLayerControl" class="layer-control-root" :class="{ collapsed: props.leftCollapsed }">
      <div class="layer-control-wrap" ref="layerWrapRef">
         <div class="layer-control" @click="toggleLayerPanel">
            <img :src="layerControlIcon" alt="图层控制" />
         </div>
         <div v-if="showLayerPanel" class="layer-panel">
            <div class="panel-title">图层管理</div>
<template>
   <div class="map-shell">
      <CommonCesiumMap ref="mapRef" class="command-cesium map-container" :dom-id="props.containerId" :active="true"
         :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 }">
         <div class="layer-control-wrap" ref="layerWrapRef">
            <div class="layer-control" @click="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"
               />
               <div class="panel-content">
                  <el-tree :data="layerTree" show-checkbox default-expand-all node-key="key"
                     :props="layerTreeProps" :default-checked-keys="defaultCheckedKeys" />
               </div>
            </div>
         </div>
      </div>
      <DevicePopup
         :visible="popupVisible && !isDronePopup"
         :position="popupPosition"
         :device="selectedDevice"
         @close="closePopup"
      />
      <DronePopup
         :visible="popupVisible && isDronePopup"
         :position="popupPosition"
         :drone="selectedDevice"
         :favorite="Boolean(selectedDevice?.isFavorite)"
         @close="closePopup"
         @toggle-favorite="toggleDroneFavorite"
         @signal="handleDroneSignal"
         @counter="handleDroneCounter"
      />
   </div>
</template>
@@ -42,8 +46,23 @@
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 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 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: {
@@ -63,16 +82,39 @@
      default: true,
   },
})
const emit = defineEmits(['droneSignal', 'droneCounter'])
const DEFAULT_ZONE_PAGE_SIZE = 999
const mapRef = ref(null)
let viewer = null
const deviceEntityIds = new Set()
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 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)
const selectedTargetType = ref('device')
let selectedDeviceBillboard = null
let deviceClickHandler = null
let popupRenderHandler = null
const layerTreeProps = {
   label: 'label',
   children: 'children',
@@ -117,151 +159,401 @@
   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()
   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 = () => {
   if (!aggregationSource) return
   aggregationSource.entities.removeAll()
}
const RING_STYLES = [
   { inner: 0, outer: 2000, gradient: ['#FF361C', '#360B00'] }
]
const clearCommandPostEntities = () => {
   if (!viewer) return
   if (commandPostBillboardCollection) {
      commandPostBillboardCollection.removeAll()
      removeCockpitPrimitive(commandPostBillboardCollection)
      commandPostBillboardCollection = null
   }
}
const MATERIAL_TYPE = 'RadialGradientMaterial'
let materialRegistered = false
const ensureCommandPostCollection = () => {
   if (!viewer) return
   ensureCockpitPrimitiveLayer()
   if (!commandPostBillboardCollection) {
      commandPostBillboardCollection = new Cesium.BillboardCollection()
      addCockpitPrimitive(commandPostBillboardCollection)
   }
}
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 setDetailVisibility = visible => {
   detailVisible.value = visible
   if (defenseZonePrimitive) defenseZonePrimitive.show = visible
   if (defenseZoneOutlinePrimitive) defenseZoneOutlinePrimitive.show = visible
   if (partitionPrimitive) partitionPrimitive.show = visible
   if (partitionOutlinePrimitive) partitionOutlinePrimitive.show = visible
   if (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()
}
const setClusterVisibility = visible => {
   clusterVisible.value = visible
   if (aggregationSource) aggregationSource.show = visible
}
const ensureCountyCenterMap = () => {
   if (countyCenterMap.size) return
   const geojson = JSON.parse(jaGeojsonRaw)
   geojson.features?.forEach(feature => {
      const name = feature?.properties?.name
      const center = feature?.properties?.centroid || feature?.properties?.center
      if (!name || !Array.isArray(center) || center.length < 2) return
      countyCenterMap.set(name, { longitude: center[0], latitude: center[1] })
   })
}
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 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 addDeviceRings = (center, baseId) => {
   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,
         polygon: {
            hierarchy: new Cesium.PolygonHierarchy(outerPositions, holes),
            material,
const clearDroneTrackEntities = () => {
   if (!viewer) return
   stopDroneTrackAnimation()
   if (droneTrackBillboardCollection) {
      if (!droneTrackBillboardCollection.isDestroyed?.()) {
         removeCockpitPrimitive(droneTrackBillboardCollection)
      }
      droneTrackBillboardCollection = null
   }
   if (droneTrackPolylineCollection) {
      if (!droneTrackPolylineCollection.isDestroyed?.()) {
         removeCockpitPrimitive(droneTrackPolylineCollection)
      }
      droneTrackPolylineCollection = null
   }
   droneTrackRuntime = []
   dronePickMap.clear()
   if (selectedTargetType.value === 'drone') {
      closePopup()
   }
}
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)
      viewer.entities.add({
         id: entityId,
      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.34,
            height: 40.34,
         },
         image: equipmentIcon,
         width: 40,
         height: 56,
         verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
      })
      billboard.id = entityId
      devicePickMap.set(entityId, { data: item, billboard })
   })
   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 => {
@@ -276,68 +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()
   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()
   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 () => {
@@ -357,6 +665,166 @@
      renderPartitions(res?.data?.data?.records ?? [])
   } catch (error) {
      renderPartitions([])
   }
}
const renderAggregation = list => {
   if (!viewer) return
   ensureCountyCenterMap()
   if (!aggregationSource) {
      aggregationSource = new Cesium.CustomDataSource('aggregationSource')
      viewer.dataSources.add(aggregationSource)
   }
   clearAggregationEntities()
   aggregationSource.show = clusterVisible.value
   const countMap = new Map()
      ; (list || []).forEach(item => {
         if (!item?.type) return
         countMap.set(item.type, Number(item.count ?? 0))
      })
   Array.from(countyCenterMap.entries()).forEach(([name, center], index) => {
      const position = Cesium.Cartesian3.fromDegrees(center.longitude, center.latitude, 0)
      const count = countMap.get(name) ?? 0
      aggregationSource.entities.add({
         id: `aggregation-${name}-${index}`,
         position,
         billboard: {
            image: aggregationIcon,
            width: 66.73,
            height: 43,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
         },
         label: {
            text: `${count}`,
            fillColor: Cesium.Color.WHITE,
            style: Cesium.LabelStyle.FILL_AND_OUTLINE,
            verticalOrigin: Cesium.VerticalOrigin.CENTER,
            horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
            font: '11pt Source Han Sans CN',
            eyeOffset: new Cesium.Cartesian3(0, 0, -20), // 让label "浮" 在广告牌前面
            pixelOffset: new Cesium.Cartesian2(0, -35),
            disableDepthTestDistance: Number.POSITIVE_INFINITY,
            heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
         },
      })
   })
}
const getPickedTarget = picks => {
   for (const pick of picks) {
      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 || !selectedDeviceBillboard) return
   const cartesian = selectedDeviceBillboard.position
   if (!cartesian) return
   const screenPosition = viewer.scene.cartesianToCanvasCoordinates(cartesian)
   if (!screenPosition) return
   popupPosition.value = { x: screenPosition.x, y: screenPosition.y }
}
const startPopupRender = () => {
   if (!viewer || popupRenderHandler) return
   popupRenderHandler = () => updatePopupPosition()
   viewer.scene.postRender.addEventListener(popupRenderHandler)
   updatePopupPosition()
}
const stopPopupRender = () => {
   if (!viewer || !popupRenderHandler) return
   viewer.scene.postRender.removeEventListener(popupRenderHandler)
   popupRenderHandler = null
}
const handleDeviceClick = movement => {
   if (!viewer) return
   const picks = viewer.scene.drillPick(movement.position) || []
   const pickedTarget = getPickedTarget(picks)
   if (!pickedTarget) {
      closePopup()
      return
   }
   selectedTargetType.value = pickedTarget.type
   selectedDevice.value = pickedTarget.data
   selectedDeviceBillboard = pickedTarget.billboard
   startPopupRender()
}
const initDeviceClickHandler = () => {
   if (deviceClickHandler || !viewer) return
   deviceClickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)
   deviceClickHandler.setInputAction(handleDeviceClick, Cesium.ScreenSpaceEventType.LEFT_CLICK)
}
const destroyDeviceClickHandler = () => {
   deviceClickHandler?.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK)
   deviceClickHandler?.destroy()
   deviceClickHandler = null
}
function closePopup () {
   selectedDevice.value = null
   selectedDeviceBillboard = null
   selectedTargetType.value = 'device'
   stopPopupRender()
}
const popupPosition = ref({ x: 0, y: 0 })
const popupVisible = computed(() => Boolean(selectedDevice.value))
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
   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()
      renderAggregation(res?.data?.data ?? [])
   } catch (error) {
      renderAggregation([])
   }
}
const loadCommandPosts = async () => {
   try {
      const res = await fwDefenseSceneListApi()
      renderCommandPosts(res?.data?.data ?? [])
   } catch (error) {
      renderCommandPosts([])
   }
}
@@ -381,11 +849,29 @@
   showLayerPanel.value = !showLayerPanel.value
}
const updateStageDisplay = stage => {
   const showCluster = stage === 'cluster'
   setClusterVisibility(showCluster)
   setDetailVisibility(!showCluster)
}
const handleMapReady = ({ viewer: mapViewer }) => {
   viewer = mapViewer
   ensureCockpitPrimitiveLayer()
   const height = viewer?.camera?.positionCartographic?.height
   const stage = height != null && height >= 100000 ? 'cluster' : 'detail'
   updateStageDisplay(stage)
   renderDeviceEntities(props.onlineDevices)
   loadDefenseZones()
   loadPartitions()
   loadAggregation()
   loadCommandPosts()
   renderSimulatedDroneTrack()
   initDeviceClickHandler()
}
const handleStageChange = stage => {
   updateStageDisplay(stage)
}
const handleClickOutside = event => {
@@ -406,11 +892,26 @@
   clearDeviceEntities()
   clearDefenseZoneEntities()
   clearPartitionEntities()
   clearAggregationEntities()
   clearCommandPostEntities()
   clearDroneTrackEntities()
   closePopup()
   destroyDeviceClickHandler()
   if (cockpitPrimitiveLayer && viewer?.scene?.primitives) {
      viewer.scene.primitives.remove(cockpitPrimitiveLayer)
   }
   cockpitPrimitiveLayer = null
   viewer = null
})
</script>
<style lang="scss" scoped>
.map-shell {
   position: relative;
   width: 100%;
   height: 100%;
}
.map-container {
   position: absolute;
   top: 0;
@@ -565,3 +1066,4 @@
   border-color: #a1a3d4;
}
</style>