Cesium:基于cesium-plot-js的标绘

一、说明

1、安装

bash 复制代码
npm install cesium-plot-js

2、标绘类型

  • 多边形
  • 矩形
  • 三角形
  • 圆形
  • 扇形
  • 椭圆
  • 半月面
  • 自由面
  • 自由线
  • 曲线
  • 细直箭头
  • 曲线箭头
  • 直箭头
  • 进攻方向箭头
  • 燕尾进攻方向箭头
  • 分队战斗方向
  • 燕尾分队战斗方向
  • 突击方向
  • 双箭头

3、支持的动画

  • 淡入
  • 淡出
  • 生长

4、目标拆分

  • 交互标绘 :鼠标点选绘制、双击结束、点击图形进入编辑、支持显隐/生长动画/删除/导出
  • 数据驱动上图 :给一段 JSON(id/type/points/style)即可批量上图,并能按 id 增量更新

二、交互标绘

1、交互标绘封装

  • militaryPlot.js
javascript 复制代码
import CesiumPlot from 'cesium-plot-js'

// cesium-plot-js 全部标绘类型映射(用于 UI 下拉与类型识别)
export const PLOT_TYPES = [
    { type: 'Polygon', kind: 'polygon', label: '多边形' },
    { type: 'Reactangle', kind: 'polygon', label: '矩形' },
    { type: 'Triangle', kind: 'polygon', label: '三角形' },
    { type: 'Circle', kind: 'polygon', label: '圆形' },
    { type: 'Sector', kind: 'polygon', label: '扇形' },
    { type: 'Ellipse', kind: 'polygon', label: '椭圆' },
    { type: 'Lune', kind: 'polygon', label: '半月面' },
    { type: 'FreehandPolygon', kind: 'polygon', label: '自由面' },

    { type: 'FreehandLine', kind: 'line', label: '自由线' },
    { type: 'Curve', kind: 'line', label: '曲线' },
    { type: 'StraightArrow', kind: 'line', label: '细直箭头' },
    { type: 'CurvedArrow', kind: 'line', label: '曲线箭头' },

    { type: 'FineArrow', kind: 'polygon', label: '直箭头' },
    { type: 'AttackArrow', kind: 'polygon', label: '进攻方向箭头' },
    { type: 'SwallowtailAttackArrow', kind: 'polygon', label: '燕尾进攻方向箭头' },
    { type: 'SquadCombat', kind: 'polygon', label: '分队战斗方向' },
    { type: 'SwallowtailSquadCombat', kind: 'polygon', label: '燕尾分队战斗方向' },
    { type: 'AssaultDirection', kind: 'polygon', label: '突击方向' },
    { type: 'DoubleArrow', kind: 'polygon', label: '双箭头' }
]

// 支持"生长动画"的类型集合(cesium-plot-js 能力限定)
export const PLOT_GROWTH_TYPES = new Set([
    'StraightArrow',
    'CurvedArrow',
    'FineArrow',
    'AttackArrow',
    'SwallowtailAttackArrow',
    'SquadCombat',
    'SwallowtailSquadCombat',
    'AssaultDirection',
    'DoubleArrow'
])

const PLOT_TYPE_META = PLOT_TYPES.reduce((acc, cur) => {
    acc[cur.type] = cur
    return acc
}, {})

// 从 window 取 Cesium(本项目采用 public/Cesium 静态注入方式)
function getCesium() {
    const Cesium = window.Cesium
    if (!Cesium) throw new Error('Cesium 未加载:请确认 public/index.html 已注入 Cesium.js')
    return Cesium
}

// Cartesian3 -> 经纬度(用于导出/持久化)
function cartesianToDegrees(Cesium, cartesian) {
    const carto = Cesium.Cartographic.fromCartesian(cartesian)
    return {
        lon: Cesium.Math.toDegrees(carto.longitude),
        lat: Cesium.Math.toDegrees(carto.latitude),
        height: carto.height || 0
    }
}

// 经纬度 -> Cartesian3(用于导入/重建图形)
function degreesToCartesian(Cesium, p) {
    const lon = Number(p.lon)
    const lat = Number(p.lat)
    const height = typeof p.height === 'number' ? p.height : Number(p.height || 0)
    return Cesium.Cartesian3.fromDegrees(lon, lat, Number.isFinite(height) ? height : 0)
}

// 统一样式配置的默认值(UI 简化配置 -> 内部规范化)
function normalizeStyleConfig(kind, config) {
    const base = {
        kind,
        fillColor: 'rgba(59, 178, 208, 0.45)',
        outlineColor: 'rgba(59, 178, 208, 1)',
        outlineWidth: 3,
        lineColor: 'rgba(59, 178, 208, 1)',
        lineWidth: 3
    }
    return Object.assign(base, config || {})
}

// 将 UI 的样式配置转换为 cesium-plot-js 需要的 style 结构(PolygonStyle/LineStyle)
function toCesiumPlotStyle(Cesium, styleConfig) {
    if (!styleConfig) return undefined
    if (styleConfig.kind === 'line') {
        return {
            material: Cesium.Color.fromCssColorString(styleConfig.lineColor),
            lineWidth: Number(styleConfig.lineWidth) || 2
        }
    }
    return {
        material: Cesium.Color.fromCssColorString(styleConfig.fillColor),
        outlineMaterial: Cesium.Color.fromCssColorString(styleConfig.outlineColor),
        outlineWidth: Number(styleConfig.outlineWidth) || 2
    }
}

// 交互标绘管理器:统一创建/事件/显隐/定位/导入导出
export class MilitaryPlotManager {
    constructor(viewer, options = {}) {
        this.viewer = viewer
        this.options = options || {}
        this.items = []
        this._seq = 0
    }

    // cesium-plot-js 的 hide/growth 等在编辑态(edit)下可能不生效,
    // 因此执行这些操作前,统一把几何切回静态态(static)
    ensureGeometryStatic(geometry) {
        if (!geometry) return
        if (typeof geometry.getState !== 'function' || typeof geometry.setState !== 'function') return
        const state = geometry.getState()
        if (state === 'edit') {
            if (typeof geometry.removeControlPoints === 'function') geometry.removeControlPoints()
            if (typeof geometry.disableDrag === 'function') geometry.disableDrag()
            geometry.setState('static')
        }
    }

    getCesium() {
        return getCesium()
    }

    // 获取类型元信息(kind=polygon/line、中文名等)
    getTypeMeta(type) {
        return PLOT_TYPE_META[type] || { type, kind: 'polygon', label: type }
    }

    // 为每个图形生成稳定 id(用于列表操作/导出)
    nextId() {
        this._seq += 1
        return `plot_${Date.now()}_${this._seq}`
    }

    // 合并默认样式与覆盖样式
    createStyleConfig(type, override) {
        const meta = this.getTypeMeta(type)
        const base = normalizeStyleConfig(meta.kind, this.options.defaultStyleConfig)
        return Object.assign(base, override || {})
    }

    // 创建交互绘制实例(交互事件由 cesium-plot-js 内部接管)
    createPlot(type, styleConfig) {
        const Cesium = this.getCesium()
        const meta = this.getTypeMeta(type)
        const style = toCesiumPlotStyle(Cesium, normalizeStyleConfig(meta.kind, styleConfig))
        const PlotClass = CesiumPlot[type]
        if (!PlotClass) throw new Error(`cesium-plot-js 不支持的类型:${type}`)
        return new PlotClass(Cesium, this.viewer, style)
    }

    // 通过关键点位数据重建图形(用于导入/回显)
    createPlotFromData(data) {
        const Cesium = this.getCesium()
        const meta = this.getTypeMeta(data.type)
        const style = toCesiumPlotStyle(Cesium, normalizeStyleConfig(meta.kind, data.styleConfig))
        const cartesianPoints = (data.points || []).map((p) => degreesToCartesian(Cesium, p))
        return CesiumPlot.createGeometryFromData(Cesium, this.viewer, {
            type: data.type,
            cartesianPoints,
            style
        })
    }

    // 开始一次交互绘制:返回 item(供 UI 列表管理)
    add(type, styleConfig) {
        const id = this.nextId()
        const meta = this.getTypeMeta(type)
        const normalizedStyleConfig = this.createStyleConfig(type, styleConfig)
        const geometry = this.createPlot(type, normalizedStyleConfig)

        const item = {
            id,
            type,
            kind: meta.kind,
            label: meta.label,
            styleConfig: normalizedStyleConfig,
            geometry,
            points: []
        }

        // 绘制结束:更新关键点位(用于导出/定位/后续编辑)
        geometry.on('drawEnd', (points) => {
            item.points = (points || []).slice()
            if (typeof this.options.onDrawEnd === 'function') this.options.onDrawEnd(item)
        })
        // 编辑结束:同步关键点位(确保导出/定位等拿到最新数据)
        geometry.on('editEnd', (points) => {
            item.points = (points || []).slice()
            if (typeof this.options.onEditEnd === 'function') this.options.onEditEnd(item)
        })

        this.items.push(item)
        if (typeof this.options.onAdd === 'function') this.options.onAdd(item)
        return item
    }

    // 通过数据直接添加图形(不走交互绘制,常用于导入/初始化)
    addFromData(data) {
        const id = data.id || this.nextId()
        const meta = this.getTypeMeta(data.type)
        const geometry = this.createPlotFromData(data)
        const item = {
            id,
            type: data.type,
            kind: meta.kind,
            label: meta.label,
            styleConfig: this.createStyleConfig(data.type, data.styleConfig),
            geometry,
            points: geometry.getPoints ? geometry.getPoints() : []
        }

        // 数据添加的图形同样支持编辑,编辑结束同步点位
        geometry.on('editEnd', (points) => {
            item.points = (points || []).slice()
            if (typeof this.options.onEditEnd === 'function') this.options.onEditEnd(item)
        })

        this.items.push(item)
        if (typeof this.options.onAdd === 'function') this.options.onAdd(item)
        return item
    }

    // 删除指定图形
    remove(id) {
        const idx = this.items.findIndex((it) => it.id === id)
        if (idx < 0) return
        const item = this.items[idx]
        if (item && item.geometry && typeof item.geometry.remove === 'function') {
            item.geometry.remove()
        }
        this.items.splice(idx, 1)
        if (typeof this.options.onRemove === 'function') this.options.onRemove(item)
    }

    // 清空全部图形
    clear() {
        const ids = this.items.map((it) => it.id)
        ids.forEach((id) => this.remove(id))
    }

    // 显示(支持淡入动画)
    show(id, opts) {
        const item = this.items.find((it) => it.id === id)
        if (!item) return
        this.ensureGeometryStatic(item.geometry)
        if (item.geometry && typeof item.geometry.show === 'function') item.geometry.show(opts)
    }

    // 隐藏(支持淡出动画)
    hide(id, opts) {
        const item = this.items.find((it) => it.id === id)
        if (!item) return
        this.ensureGeometryStatic(item.geometry)
        if (item.geometry && typeof item.geometry.hide === 'function') item.geometry.hide(opts)
    }

    // 生长动画(仅部分类型支持)
    startGrowthAnimation(id, opts) {
        const item = this.items.find((it) => it.id === id)
        if (!item) return
        if (!PLOT_GROWTH_TYPES.has(item.type)) return
        this.ensureGeometryStatic(item.geometry)
        if (item.geometry && typeof item.geometry.startGrowthAnimation === 'function') item.geometry.startGrowthAnimation(opts)
    }

    // 定位到图形:通过关键点位计算包围球,并固定 pitch=-90° 俯视居中显示
    flyTo(id, opts) {
        const Cesium = this.getCesium()
        const item = this.items.find((it) => it.id === id)
        if (!item || !item.geometry) return
        const options = Object.assign({ duration: 1.2 }, opts || {})
        this.ensureGeometryStatic(item.geometry)

        const points =
            item.geometry && typeof item.geometry.getPoints === 'function'
                ? item.geometry.getPoints()
                : Array.isArray(item.points)
                  ? item.points
                  : []
        const usablePoints = (points || []).filter((p) => Cesium.defined(p) && typeof p.x === 'number' && typeof p.y === 'number')
        if (usablePoints.length === 0) return

        const sphere = Cesium.BoundingSphere.fromPoints(usablePoints)
        if (!sphere || !Number.isFinite(sphere.radius)) return

        const range = Number.isFinite(options.range) ? options.range : Math.max(800, sphere.radius * 3.0)
        const pitch = -Cesium.Math.PI_OVER_TWO
        const heading = Number.isFinite(options.heading) ? options.heading : 0
        const offset = new Cesium.HeadingPitchRange(heading, pitch, range)
        const cameraOptions = Object.assign({}, options, { offset })
        return this.viewer.camera.flyToBoundingSphere(sphere, cameraOptions)
    }

    // 导出:返回可持久化 JSON(经纬度形式的关键点位)
    exportData() {
        const Cesium = this.getCesium()
        return this.items.map((it) => {
            const points = (it.geometry && typeof it.geometry.getPoints === 'function' ? it.geometry.getPoints() : it.points || []).map(
                (p) => cartesianToDegrees(Cesium, p)
            )
            return {
                id: it.id,
                type: it.type,
                styleConfig: it.styleConfig,
                points
            }
        })
    }

    // 导入:根据导出的 JSON 重建图形(可选先清空)
    importData(list, options = {}) {
        const arr = Array.isArray(list) ? list : []
        if (options.clear) this.clear()
        return arr.map((data) => this.addFromData(data))
    }

    // 释放资源
    destroy() {
        this.clear()
        this.viewer = null
    }
}

2、交互标绘 demo

  • militaryPlot.vue
html 复制代码
<template>
    <div class="military-plot">
        <el-card class="military-plot-card" shadow="always">
            <div class="military-plot-title">标绘</div>

            <div class="military-plot-row">
                <el-select v-model="selectedType" size="small" class="military-plot-type" :disabled="!viewer" filterable>
                    <el-option v-for="opt in typeOptions" :key="opt.type" :label="opt.label" :value="opt.type" />
                </el-select>
                <el-button type="primary" size="small" :disabled="!viewer" @click="onStartDraw">开始绘制</el-button>
                <el-button size="small" :disabled="!viewer" @click="onClear">清空</el-button>
            </div>

            <div class="military-plot-row">
                <template v-if="selectedKind === 'polygon'">
                    <div class="military-plot-style-item">
                        <span class="military-plot-style-label">填充</span>
                        <el-color-picker v-model="style.fillColor" size="small" :disabled="!viewer" show-alpha />
                    </div>
                    <div class="military-plot-style-item">
                        <span class="military-plot-style-label">描边</span>
                        <el-color-picker v-model="style.outlineColor" size="small" :disabled="!viewer" show-alpha />
                    </div>
                    <div class="military-plot-style-item">
                        <span class="military-plot-style-label">宽度</span>
                        <el-input-number
                            v-model="style.outlineWidth"
                            size="small"
                            :min="1"
                            :max="20"
                            controls-position="right"
                            :disabled="!viewer"
                        />
                    </div>
                </template>
                <template v-else>
                    <div class="military-plot-style-item">
                        <span class="military-plot-style-label">颜色</span>
                        <el-color-picker v-model="style.lineColor" size="small" :disabled="!viewer" show-alpha />
                    </div>
                    <div class="military-plot-style-item">
                        <span class="military-plot-style-label">宽度</span>
                        <el-input-number
                            v-model="style.lineWidth"
                            size="small"
                            :min="1"
                            :max="20"
                            controls-position="right"
                            :disabled="!viewer"
                        />
                    </div>
                </template>
            </div>

            <div class="military-plot-hint">
                左键加点,双击结束。点击已绘制图形进入编辑,再点空白结束编辑。
            </div>

            <div class="military-plot-row military-plot-actions">
                <el-button size="small" :disabled="!viewer" @click="onExportOpen">导出</el-button>
                <el-button size="small" :disabled="!viewer" @click="onImportOpen">导入</el-button>
                <el-button size="small" :disabled="!viewer" @click="onSaveLocal">本地保存</el-button>
                <el-button size="small" :disabled="!viewer" @click="onLoadLocal">本地读取</el-button>
            </div>

            <el-table
                v-if="items.length > 0"
                :data="items"
                size="small"
                border
                height="260"
                class="military-plot-table"
                :row-key="(row) => row.id"
            >
                <el-table-column prop="label" label="类型" width="130" />
                <el-table-column label="点数" width="60">
                    <template #default="{ row }">
                        {{ row.pointsCount }}
                    </template>
                </el-table-column>
                <el-table-column label="操作" min-width="180">
                    <template #default="{ row }">
                        <el-button-group>
                            <el-button size="small" :disabled="!viewer" @click="onFlyTo(row)">定位</el-button>
                            <el-button size="small" :disabled="!viewer" @click="onToggleVisible(row)">
                                {{ row.visible ? '隐藏' : '显示' }}
                            </el-button>
                            <el-button size="small" :disabled="!viewer || !row.canGrowth" @click="onGrowth(row)">生长</el-button>
                            <el-button size="small" type="danger" :disabled="!viewer" @click="onRemove(row)">删除</el-button>
                        </el-button-group>
                    </template>
                </el-table-column>
            </el-table>

            <div v-else class="military-plot-empty">暂无标绘,选择类型后点击"开始绘制"。</div>

            <el-dialog v-model="exportDialogVisible" title="导出标绘数据" width="720px">
                <el-input v-model="exportText" type="textarea" :rows="14" readonly />
                <template #footer>
                    <el-button @click="exportDialogVisible = false">关闭</el-button>
                </template>
            </el-dialog>

            <el-dialog v-model="importDialogVisible" title="导入标绘数据" width="720px">
                <el-input v-model="importText" type="textarea" :rows="14" placeholder="粘贴导出的 JSON 数组" />
                <template #footer>
                    <el-button @click="importDialogVisible = false">取消</el-button>
                    <el-button type="primary" :disabled="!viewer" @click="onImportConfirm">导入</el-button>
                </template>
            </el-dialog>
        </el-card>
    </div>
</template>

<script>
import { ElMessage } from 'element-plus'
import { MilitaryPlotManager, PLOT_GROWTH_TYPES, PLOT_TYPES } from '@/utils/map/militaryPlot'

// localStorage key:用于保存/读取导出的标绘数据
const STORAGE_KEY = 'cesium_demo_military_plot'

export default {
    name: 'MilitaryPlot',
    props: {
        viewer: {
            type: Object,
            default: null
        }
    },
    data() {
        return {
            manager: null,
            typeOptions: PLOT_TYPES,
            selectedType: 'FineArrow',
            exportDialogVisible: false,
            importDialogVisible: false,
            exportText: '',
            importText: '',
            visibility: {},
            items: [],
            style: {
                fillColor: 'rgba(59, 178, 208, 0.45)',
                outlineColor: 'rgba(59, 178, 208, 1)',
                outlineWidth: 3,
                lineColor: 'rgba(59, 178, 208, 1)',
                lineWidth: 3
            }
        }
    },
    computed: {
        selectedKind() {
            const meta = (this.typeOptions || []).find((t) => t.type === this.selectedType)
            return meta && meta.kind ? meta.kind : 'polygon'
        }
    },
    watch: {
        viewer: {
            immediate: true,
            handler(next) {
                if (!next) return
                // viewer 就绪后再初始化标绘管理器
                if (!this.manager) this.initManager()
                this.syncItems()
            }
        }
    },
    methods: {
        // 创建标绘管理器,并通过回调同步 UI 列表
        initManager() {
            this.manager = new MilitaryPlotManager(this.viewer, {
                defaultStyleConfig: {},
                onAdd: () => this.syncItems(),
                onRemove: () => this.syncItems(),
                onDrawEnd: () => this.syncItems(),
                onEditEnd: () => this.syncItems()
            })
        },
        // 从 manager.items 生成 UI 列表数据(点数/是否可生长/显隐状态)
        syncItems() {
            if (!this.manager) {
                this.items = []
                return
            }
            const next = (this.manager.items || []).map((it) => {
                const pointsCount = it.geometry && typeof it.geometry.getPoints === 'function' ? it.geometry.getPoints().length : (it.points || []).length
                const visible = this.visibility[it.id] !== false
                return {
                    id: it.id,
                    type: it.type,
                    label: it.label,
                    pointsCount,
                    canGrowth: PLOT_GROWTH_TYPES.has(it.type),
                    visible
                }
            })
            this.items = next
        },
        // 获取当前 UI 的样式配置(会在创建图形时转换为 cesium-plot-js style)
        currentStyleConfig() {
            if (this.selectedKind === 'line') {
                return {
                    kind: 'line',
                    lineColor: this.style.lineColor,
                    lineWidth: this.style.lineWidth
                }
            }
            return {
                kind: 'polygon',
                fillColor: this.style.fillColor,
                outlineColor: this.style.outlineColor,
                outlineWidth: this.style.outlineWidth
            }
        },
        // 开始一次交互绘制:左键加点,双击结束(交互由 cesium-plot-js 接管)
        onStartDraw() {
            if (!this.viewer) return
            if (!this.manager) this.initManager()
            this.manager.add(this.selectedType, this.currentStyleConfig())
            this.syncItems()
            ElMessage.success('已进入绘制状态:左键加点,双击结束')
        },
        // 清空全部标绘
        onClear() {
            if (!this.manager) return
            this.manager.clear()
            this.visibility = {}
            this.syncItems()
            ElMessage.success('已清空标绘')
        },
        // 删除单个标绘
        onRemove(row) {
            if (!this.manager) return
            this.manager.remove(row.id)
            delete this.visibility[row.id]
            this.syncItems()
        },
        // 定位到标绘中心(内部固定 pitch=-90° 俯视)
        onFlyTo(row) {
            if (!this.manager) return
            this.manager.flyTo(row.id)
        },
        // 显示/隐藏(支持淡入淡出动画)
        onToggleVisible(row) {
            if (!this.manager) return
            const currentlyVisible = this.visibility[row.id] !== false
            if (currentlyVisible) {
                this.manager.hide(row.id, { duration: 350 })
                this.visibility[row.id] = false
            } else {
                this.manager.show(row.id, { duration: 350 })
                this.visibility[row.id] = true
            }
            this.syncItems()
        },
        // 生长动画(仅 PLOT_GROWTH_TYPES 中的类型支持)
        onGrowth(row) {
            if (!this.manager) return
            this.manager.startGrowthAnimation(row.id, { duration: 1600 })
        },
        // 导出:把当前全部标绘数据转为 JSON(经纬度点位)
        onExportOpen() {
            if (!this.manager) return
            this.exportText = JSON.stringify(this.manager.exportData(), null, 2)
            this.exportDialogVisible = true
        },
        // 打开导入弹窗
        onImportOpen() {
            this.importText = ''
            this.importDialogVisible = true
        },
        // 导入:清空后重建(保持与导入数据一致)
        onImportConfirm() {
            try {
                const list = JSON.parse(String(this.importText || '[]'))
                if (!this.manager) this.initManager()
                this.manager.importData(list, { clear: true })
                this.visibility = {}
                this.syncItems()
                this.importDialogVisible = false
                ElMessage.success('导入成功')
            } catch (e) {
                ElMessage.error(String(e && e.message ? e.message : e))
            }
        },
        // 保存到本地(localStorage)
        onSaveLocal() {
            if (!this.manager) return
            const data = this.manager.exportData()
            localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
            ElMessage.success('已保存到本地')
        },
        // 从本地读取并重建
        onLoadLocal() {
            try {
                const text = localStorage.getItem(STORAGE_KEY)
                if (!text) {
                    ElMessage.warning('本地没有已保存的数据')
                    return
                }
                const list = JSON.parse(text)
                if (!this.manager) this.initManager()
                this.manager.importData(list, { clear: true })
                this.visibility = {}
                this.syncItems()
                ElMessage.success('已从本地读取')
            } catch (e) {
                ElMessage.error(String(e && e.message ? e.message : e))
            }
        }
    },
    beforeUnmount() {
        if (this.manager) {
            // 组件卸载时释放实体/事件
            this.manager.destroy()
            this.manager = null
        }
    }
}
</script>

<style lang="scss" scoped>
.military-plot {
    position: absolute;
    top: 12px;
    right: 12px;
    z-index: 10;
}

.military-plot-card {
    width: 460px;
    background: rgba(255, 255, 255, 0.9);
    backdrop-filter: blur(6px);
}

.military-plot-title {
    font-weight: 600;
    margin-bottom: 10px;
}

.military-plot-row {
    display: flex;
    gap: 8px;
    align-items: center;
    margin-bottom: 10px;
    flex-wrap: wrap;
}

.military-plot-type {
    width: 220px;
}

.military-plot-style-item {
    display: inline-flex;
    gap: 6px;
    align-items: center;
}

.military-plot-style-label {
    font-size: 12px;
    color: #606266;
}

.military-plot-hint {
    font-size: 12px;
    color: #909399;
    line-height: 1.4;
    margin-bottom: 10px;
}

.military-plot-actions {
    margin-bottom: 8px;
}

.military-plot-table {
    width: 100%;
}

.military-plot-empty {
    font-size: 12px;
    color: #909399;
    padding: 10px 0;
}
</style>

3、效果图

三、数据驱动

1、数据驱动封装

  • militaryPlotByData.js
javascript 复制代码
import CesiumPlot from 'cesium-plot-js'
import { PLOT_GROWTH_TYPES, PLOT_TYPES } from './militaryPlot'

// 数据驱动上图:根据 JSON 数据直接构建 cesium-plot-js 图形(不走交互绘制)
const PLOT_TYPE_META = PLOT_TYPES.reduce((acc, cur) => {
    acc[cur.type] = cur
    return acc
}, {})

// 从 window 取 Cesium(本项目采用 public/Cesium 静态注入方式)
function getCesium() {
    const Cesium = window.Cesium
    if (!Cesium) throw new Error('Cesium 未加载:请确认 public/index.html 已注入 Cesium.js')
    return Cesium
}

// 统一样式配置默认值(与交互标绘保持一致)
function normalizeStyleConfig(kind, config) {
    const base = {
        kind,
        fillColor: 'rgba(59, 178, 208, 0.45)',
        outlineColor: 'rgba(59, 178, 208, 1)',
        outlineWidth: 3,
        lineColor: 'rgba(59, 178, 208, 1)',
        lineWidth: 3
    }
    return Object.assign(base, config || {})
}

// 将 UI 的样式配置转换为 cesium-plot-js 需要的 style 结构(PolygonStyle/LineStyle)
function toCesiumPlotStyle(Cesium, styleConfig) {
    if (!styleConfig) return undefined
    if (styleConfig.kind === 'line') {
        return {
            material: Cesium.Color.fromCssColorString(styleConfig.lineColor),
            lineWidth: Number(styleConfig.lineWidth) || 2
        }
    }
    return {
        material: Cesium.Color.fromCssColorString(styleConfig.fillColor),
        outlineMaterial: Cesium.Color.fromCssColorString(styleConfig.outlineColor),
        outlineWidth: Number(styleConfig.outlineWidth) || 2
    }
}

// 经纬度 -> Cartesian3(用于 createGeometryFromData)
function degreesToCartesian(Cesium, p) {
    const lon = Number(p.lon)
    const lat = Number(p.lat)
    const height = typeof p.height === 'number' ? p.height : Number(p.height || 0)
    return Cesium.Cartesian3.fromDegrees(lon, lat, Number.isFinite(height) ? height : 0)
}

// Cartesian3 -> 经纬度(用于导出)
function cartesianToDegrees(Cesium, cartesian) {
    const carto = Cesium.Cartographic.fromCartesian(cartesian)
    return {
        lon: Cesium.Math.toDegrees(carto.longitude),
        lat: Cesium.Math.toDegrees(carto.latitude),
        height: carto.height || 0
    }
}

// 简单稳定序列化:用于生成 hash(只用于 demo 级别 diff)
function stableStringify(obj) {
    return JSON.stringify(obj, Object.keys(obj || {}).sort())
}

// 生成数据 hash:同 id 的数据不变则不重建,减少闪烁与性能开销
function hashPlotData(data) {
    const points = Array.isArray(data.points) ? data.points : []
    const styleConfig = data.styleConfig || {}
    return `${data.type || ''}|${stableStringify(styleConfig)}|${stableStringify(points)}`
}

// 生成全类型示例数据:用于 demo 一键上图
export function createAllTypesSampleData(center = { lon: 116.403963, lat: 39.915119 }, options = {}) {
    const spacing = typeof options.spacing === 'number' ? options.spacing : 0.08
    const base = {
        fillColor: 'rgba(59, 178, 208, 0.35)',
        outlineColor: 'rgba(59, 178, 208, 0.95)',
        outlineWidth: 3,
        lineColor: 'rgba(59, 178, 208, 0.95)',
        lineWidth: 3
    }

    return PLOT_TYPES.map((meta, idx) => {
        const col = idx % 5
        const row = Math.floor(idx / 5)
        const lon0 = center.lon + col * spacing
        const lat0 = center.lat - row * spacing
        return createSampleDataForType(meta.type, { lon: lon0, lat: lat0 }, Object.assign({}, base, options.styleConfig || {}))
    })
}

// 生成单类型示例数据:用于快速测试单个图形的数据格式
export function createSampleDataForType(type, center = { lon: 116.403963, lat: 39.915119 }, styleConfig = {}) {
    const meta = PLOT_TYPE_META[type] || { type, kind: 'polygon' }
    const kind = meta.kind || 'polygon'
    const d = 0.02

    const lon = center.lon
    const lat = center.lat

    const pts2 = [
        { lon: lon - d, lat: lat - d },
        { lon: lon + d, lat: lat + d }
    ]
    const pts3 = [
        { lon: lon - d, lat: lat - d },
        { lon: lon + d, lat: lat - d },
        { lon: lon + d, lat: lat + d }
    ]
    const pts4 = [
        { lon: lon - d, lat: lat - d },
        { lon: lon + d, lat: lat - d },
        { lon: lon + d, lat: lat + d },
        { lon: lon - d, lat: lat + d }
    ]
    const pts5 = [
        { lon: lon - d, lat: lat - d },
        { lon: lon - d * 0.2, lat: lat + d },
        { lon: lon + d * 0.6, lat: lat + d * 0.6 },
        { lon: lon + d, lat: lat - d * 0.2 },
        { lon: lon + d * 0.2, lat: lat - d }
    ]

    const pointsByType = {
        Polygon: pts4,
        Reactangle: pts2,
        Triangle: [
            { lon: lon - d, lat: lat - d },
            { lon: lon + d, lat: lat - d },
            { lon: lon, lat: lat + d }
        ],
        Circle: pts2,
        Sector: [
            { lon: lon, lat: lat },
            { lon: lon + d, lat: lat },
            { lon: lon, lat: lat + d }
        ],
        Ellipse: pts2,
        Lune: pts3,
        StraightArrow: pts2,
        CurvedArrow: pts3,
        FineArrow: pts2,
        AttackArrow: pts3,
        SwallowtailAttackArrow: pts3,
        SquadCombat: pts3,
        SwallowtailSquadCombat: pts3,
        AssaultDirection: pts2,
        DoubleArrow: [
            { lon: lon - d, lat: lat - d },
            { lon: lon, lat: lat + d },
            { lon: lon + d, lat: lat - d },
            { lon: lon, lat: lat - d * 0.2 }
        ],
        FreehandLine: pts5,
        FreehandPolygon: pts5,
        Curve: pts4
    }

    const points = pointsByType[type] || (kind === 'line' ? pts4 : pts4)

    return {
        id: `plot_${type}_${Date.now()}_${Math.floor(Math.random() * 100000)}`,
        type,
        styleConfig: Object.assign({ kind }, styleConfig || {}),
        points
    }
}

// 数据驱动图层:维护一组"数据 -> 图形实例"的映射,并支持 setData 增量更新
export class MilitaryPlotByDataLayer {
    constructor(viewer, options = {}) {
        this.viewer = viewer
        this.options = options || {}
        this.items = new Map()
    }

    getCesium() {
        return getCesium()
    }

    ensureGeometryStatic(geometry) {
        if (!geometry) return
        if (typeof geometry.getState !== 'function' || typeof geometry.setState !== 'function') return
        const state = geometry.getState()
        if (state === 'edit') {
            if (typeof geometry.removeControlPoints === 'function') geometry.removeControlPoints()
            if (typeof geometry.disableDrag === 'function') geometry.disableDrag()
            geometry.setState('static')
        }
    }

    getTypeMeta(type) {
        return PLOT_TYPE_META[type] || { type, kind: 'polygon', label: type }
    }

    // 根据一条数据创建图形实例(关键点位为 Cartesian3)
    _createGeometryFromData(data) {
        const Cesium = this.getCesium()
        const meta = this.getTypeMeta(data.type)
        const styleConfig = normalizeStyleConfig(meta.kind, data.styleConfig)
        const style = toCesiumPlotStyle(Cesium, styleConfig)
        const cartesianPoints = (data.points || []).map((p) => degreesToCartesian(Cesium, p))
        return CesiumPlot.createGeometryFromData(Cesium, this.viewer, {
            type: data.type,
            cartesianPoints,
            style
        })
    }

    // 设置全量数据(内部按 id 做增量更新,默认清理缺失项)
    // list: [{ id, type, styleConfig, points:[{lon,lat,height?}, ...] }]
    setData(list, options = {}) {
        const arr = Array.isArray(list) ? list : []
        const clearMissing = options.clearMissing !== false

        const nextIds = new Set()
        const errors = []

        arr.forEach((raw) => {
            if (!raw || !raw.id || !raw.type) return
            nextIds.add(raw.id)

            // hash 相同则无需重建
            const prev = this.items.get(raw.id)
            const nextHash = hashPlotData(raw)
            if (prev && prev.hash === nextHash) return

            if (prev && prev.geometry && typeof prev.geometry.remove === 'function') {
                prev.geometry.remove()
            }

            try {
                const geometry = this._createGeometryFromData(raw)
                const meta = this.getTypeMeta(raw.type)
                const item = {
                    id: raw.id,
                    type: raw.type,
                    label: meta.label,
                    kind: meta.kind,
                    styleConfig: normalizeStyleConfig(meta.kind, raw.styleConfig),
                    geometry,
                    hash: nextHash,
                    visible: true
                }
                this.items.set(raw.id, item)
            } catch (e) {
                errors.push({ id: raw.id, type: raw.type, message: String(e && e.message ? e.message : e) })
            }
        })

        // 默认删除不在新数据中的图形(保持与数据一致)
        if (clearMissing) {
            Array.from(this.items.keys()).forEach((id) => {
                if (nextIds.has(id)) return
                this.remove(id)
            })
        }

        if (typeof this.options.onChange === 'function') this.options.onChange(this.getList(), errors)
        return { list: this.getList(), errors }
    }

    // 用于 UI 展示的简表
    getList() {
        return Array.from(this.items.values()).map((it) => {
            const pointsCount = it.geometry && typeof it.geometry.getPoints === 'function' ? it.geometry.getPoints().length : 0
            return {
                id: it.id,
                type: it.type,
                label: it.label,
                kind: it.kind,
                pointsCount,
                canGrowth: PLOT_GROWTH_TYPES.has(it.type),
                visible: it.visible !== false
            }
        })
    }

    // 删除指定 id
    remove(id) {
        const item = this.items.get(id)
        if (!item) return
        if (item.geometry && typeof item.geometry.remove === 'function') item.geometry.remove()
        this.items.delete(id)
        if (typeof this.options.onChange === 'function') this.options.onChange(this.getList(), [])
    }

    // 清空全部
    clear() {
        Array.from(this.items.keys()).forEach((id) => this.remove(id))
    }

    // 显示(支持淡入动画)
    show(id, opts) {
        const item = this.items.get(id)
        if (!item) return
        item.visible = true
        if (item.geometry && typeof item.geometry.show === 'function') item.geometry.show(opts)
        if (typeof this.options.onChange === 'function') this.options.onChange(this.getList(), [])
    }

    // 隐藏(支持淡出动画)
    hide(id, opts) {
        const item = this.items.get(id)
        if (!item) return
        item.visible = false
        if (item.geometry && typeof item.geometry.hide === 'function') item.geometry.hide(opts)
        if (typeof this.options.onChange === 'function') this.options.onChange(this.getList(), [])
    }

    // 生长动画(仅部分类型支持)
    startGrowthAnimation(id, opts) {
        const item = this.items.get(id)
        if (!item) return
        if (!PLOT_GROWTH_TYPES.has(item.type)) return
        this.ensureGeometryStatic(item.geometry)
        if (item.geometry && typeof item.geometry.startGrowthAnimation === 'function') item.geometry.startGrowthAnimation(opts)
    }

    // 定位:与交互标绘一致,使用点位包围球定位并固定 pitch=-90°
    flyTo(id, opts) {
        const Cesium = this.getCesium()
        const item = this.items.get(id)
        if (!item) return
        const options = Object.assign({ duration: 1.2 }, opts || {})
        this.ensureGeometryStatic(item.geometry)

        const points = item.geometry && typeof item.geometry.getPoints === 'function' ? item.geometry.getPoints() : []
        const usablePoints = (points || []).filter((p) => Cesium.defined(p) && typeof p.x === 'number' && typeof p.y === 'number')
        if (usablePoints.length === 0) return

        const sphere = Cesium.BoundingSphere.fromPoints(usablePoints)
        if (!sphere || !Number.isFinite(sphere.radius)) return

        const range = Number.isFinite(options.range) ? options.range : Math.max(800, sphere.radius * 3.0)
        const heading = Number.isFinite(options.heading) ? options.heading : 0
        const offset = new Cesium.HeadingPitchRange(heading, -Cesium.Math.PI_OVER_TWO, range)
        const cameraOptions = Object.assign({}, options, { offset })
        return this.viewer.camera.flyToBoundingSphere(sphere, cameraOptions)
    }

    // 导出:返回可持久化 JSON(经纬度形式)
    exportData() {
        const Cesium = this.getCesium()
        return Array.from(this.items.values()).map((it) => {
            const points = it.geometry && typeof it.geometry.getPoints === 'function' ? it.geometry.getPoints() : []
            return {
                id: it.id,
                type: it.type,
                styleConfig: it.styleConfig,
                points: (points || []).map((p) => cartesianToDegrees(Cesium, p))
            }
        })
    }

    // 释放资源
    destroy() {
        this.clear()
        this.viewer = null
    }
}

2、数据驱动 demo

  • militaryPlotByData.vue
html 复制代码
<template>
    <div class="military-plot-by-data">
        <el-card class="military-plot-by-data-card" shadow="always">
            <div class="military-plot-by-data-title">数据标绘</div>

            <div class="military-plot-by-data-row">
                <el-select v-model="selectedType" size="small" class="military-plot-by-data-type" :disabled="!viewer"
                    filterable>
                    <el-option v-for="opt in typeOptions" :key="opt.type" :label="opt.label" :value="opt.type" />
                </el-select>
                <el-button size="small" :disabled="!viewer" @click="onFillOneSample">生成示例</el-button>
                <el-button size="small" type="primary" :disabled="!viewer" @click="onFillAllSamples">全类型示例</el-button>
            </div>

            <div class="military-plot-by-data-row">
                <el-button type="primary" size="small" :disabled="!viewer" @click="onApply">上图</el-button>
                <el-button size="small" :disabled="!viewer" @click="onExport">导出</el-button>
                <el-button size="small" :disabled="!viewer" @click="onClear">清空</el-button>
            </div>

            <el-input v-model="dataText" type="textarea" :rows="26" class="military-plot-by-data-textarea"
                placeholder="数据格式:[{ id, type, styleConfig, points:[{lon,lat,height?}, ...] }]" />

        </el-card>
    </div>
</template>

<script>
import { ElMessage } from 'element-plus'
import { PLOT_TYPES } from '@/utils/map/militaryPlot'
import { MilitaryPlotByDataLayer, createAllTypesSampleData, createSampleDataForType } from '@/utils/map/militaryPlotByData'

export default {
    name: 'MilitaryPlotByData',
    props: {
        viewer: {
            type: Object,
            default: null
        }
    },
    data() {
        return {
            // 数据驱动图层:内部维护 id -> 图形实例映射
            layer: null,
            // 下拉:全部标绘类型
            typeOptions: PLOT_TYPES,
            selectedType: 'FineArrow',
            // JSON 输入框内容:[{id,type,styleConfig,points:[{lon,lat,height?}]}]
            dataText: ''
        }
    },
    watch: {
        viewer: {
            immediate: true,
            handler(next) {
                if (!next) return
                // viewer 就绪后再初始化图层
                if (!this.layer) this.initLayer()
            }
        }
    },
    methods: {
        // 创建数据驱动图层
        initLayer() {
            this.layer = new MilitaryPlotByDataLayer(this.viewer)
        },
        // 生成一个类型的示例数据(便于快速验证数据格式)
        onFillOneSample() {
            const sample = createSampleDataForType(this.selectedType)
            this.dataText = JSON.stringify([sample], null, 2)
        },
        // 生成全部类型示例数据(覆盖所有标绘类型)
        onFillAllSamples() {
            const list = createAllTypesSampleData()
            this.dataText = JSON.stringify(list, null, 2)
        },
        // 上图:解析 JSON -> setData 增量更新 -> 上图后定位到最后一个图形
        onApply() {
            try {
                if (!this.layer) this.initLayer()
                const list = JSON.parse(String(this.dataText || '[]'))
                const result = this.layer.setData(list, { clearMissing: true })
                if (result.errors && result.errors.length > 0) {
                    ElMessage.warning(`上图完成,但有 ${result.errors.length} 个图形失败`)
                } else {
                    ElMessage.success('上图成功')
                }
                const arr = Array.isArray(list) ? list : []
                const last = arr.length > 0 ? arr[arr.length - 1] : null
                if (last && last.id) {
                    if (this.layer) this.layer.flyTo(last.id)
                }
            } catch (e) {
                ElMessage.error(String(e && e.message ? e.message : e))
            }
        },
        // 导出:将当前已上图图形导出回 JSON 输入框
        onExport() {
            if (!this.layer) return
            this.dataText = JSON.stringify(this.layer.exportData(), null, 2)
            ElMessage.success('已导出到输入框')
        },
        // 清空:移除图层内全部图形,并重置输入框
        onClear() {
            if (!this.layer) return
            this.layer.clear()
            this.dataText = '[]'
            ElMessage.success('已清空')
        }
    },
    beforeUnmount() {
        if (this.layer) {
            // 组件卸载时释放实体/事件
            this.layer.destroy()
            this.layer = null
        }
    }
}
</script>

<style lang="scss" scoped>
.military-plot-by-data {
    position: absolute;
    top: 12px;
    left: 12px;
    z-index: 10;
}

.military-plot-by-data-card {
    width: 440px;
    background: rgba(255, 255, 255, 0.9);
    backdrop-filter: blur(6px);
}

.military-plot-by-data-title {
    font-weight: 600;
    margin-bottom: 10px;
}

.military-plot-by-data-row {
    display: flex;
    gap: 8px;
    align-items: center;
    margin-bottom: 10px;
    flex-wrap: wrap;
}

.military-plot-by-data-type {
    width: 200px;
}

.military-plot-by-data-hint {
    font-size: 12px;
    color: #909399;
    line-height: 1.4;
    margin: 8px 0 10px;
}
</style>

3、效果图

相关推荐
用户437611903021516 小时前
让 AI 用自然语言操控三维地球 -- Cesium MCP 开源实践
gis·cesium
李剑一17 小时前
数字孪生大屏必看:Cesium 3D 模型选中交互,3 种高亮效果拿来就用!
前端·vue.js·cesium
李剑一2 天前
解决 Cesium 网络卡顿!5 分钟加载天地图,内网也能流畅用,附完整代码
前端·vue.js·cesium
GISBox2 天前
GISBox 2.1.7 版本更新:新增批量矢量导入功能,多项问题修复
gis·cesium·属性表·矢量·gisbox·场景编辑·切片转换
李剑一5 天前
超实用!数字孪生 Cesium 园区 3D 模型加载,一次学会的保姆级教程
前端·vue.js·cesium
李剑一12 天前
拿来就用!Vue3+Cesium 飞入效果封装,3D大屏多场景直接复用
前端·vue.js·cesium
曲折13 天前
Cesium-气象要素PNG色斑图叠加
前端·cesium
GIS开发特训营1 个月前
Cesium Shader 实战:三维 GIS 可视化进阶教程
gis·cesium·gis开发·webgis
GDAL1 个月前
viewer.camera.flyTo 全面教程
cesium·camera·flyto