在地图开发里,矢量数据编辑是个绕不开的活儿。无论是做地理信息采集,还是搞城市规划,总得让人能在地图上画点东西、改点东西。Cesium作为一个强大的三维地球引擎,本身并没有提供一套现成的“编辑工具”,但这不代表我们没办法实现。今天我就用大白话,把Cesium里矢量数据编辑功能的开发思路,以及最常见的“数据同步”问题,掰开揉碎了聊一聊。

一、为啥要自己做编辑功能

你可能会想,Cesium不是已经有Entity和GeoJSONDataSource了吗?直接加载个数据不就行了?没错,加载很容易,但编辑是另一码事。比如你加载一个小区的地块边界,发现边界画歪了,想拽一下顶点,这不是开个开关就能做的。官方没有提供“编辑模式”,也没有友好的交互API,所以我们必须自己动手。

实际项目里,编辑功能用在很多地方。我给几个比较典型的场景:

  • 城市级管网管理:维修人员需要在地图上标记出问题井盖,并拖动井盖位置到实际坐标。
  • 应急事件标绘:指挥中心要在三维地图上画一个警戒区,事后还得调整边界大小。
  • 地理数据外业采集:工作人员拿着平板在野外打点,回来后还能微调照片对应的位置。

这些场景都需要“画完还能改”,而且改完以后得把新坐标存回数据库。这就引出一个核心问题:数据同步。

二、基础思路:画图、选中、改坐标

在Cesium里做编辑,思路其实挺朴素的。所有可视化对象,不管是点、线、面,最终都是“位置信息”在驱动。点就是一个坐标,线就是一串坐标,面就是首尾相连的一串坐标。编辑的本质,就是把这些坐标拿出来,让用户能拖拽,然后再放回去。

那么问题来了,Cesium里的图形对象,比如Entity,它的坐标被包在一堆包装类里。要想编辑,就得和这些包装类打交道。我们用得最多的就是Cesium.Cartesian3,这是三维坐标,而平时业务数据通常是经纬度。建议在内部统一用Cartesian3,数据同步给后端时再转成经纬度。

三、搞一个最简单可用的编辑示例

为了让不熟悉Cesium的朋友也能看明白,我写一个纯JavaScript的示例。这个例子不依赖任何额外的库,就用Cesium本身的能力,实现一个点实体的拖拽编辑。整个示例的流程是:先在地球上创建一个点,然后用鼠标左键点住它,移动鼠标,松手,点就跑到新位置了。

3.1 初始化一个Cesium场景

首先你得有一个viewer,这是所有操作的基础。假设你已经引入了Cesium的CDN,或者用npm包都行,这里我用ES6模块写法。

// 技术栈:JavaScript(Cesium)
// 初始化一个三维地球
const viewer = new Cesium.Viewer('cesiumContainer', {
  // 前端调试时关掉默认的太阳和月亮,省得视觉干扰
  selectionIndicator: false,
  // 关闭默认的双击定位,避免和编辑冲突
  sceneModePicker: false,
  // 地形不要开,这样拾取和拖拽会更直接
  terrainProvider: Cesium.createWorldTerrainAsync()
});

注意,这里关掉了selectionIndicator,因为我们不需要Cesium自带的那个绿色方框,后面会自己写高亮逻辑。

3.2 创建一个可编辑的点实体

我们创建一个点实体,把它放到北京天安门附近。初始坐标用经纬度,然后转成Cartesian3。

// 创建一个点实体,后续编辑操作都围绕它进行
const pointEntity = viewer.entities.add({
  position: Cesium.Cartesian3.fromDegrees(116.391, 39.907),
  point: {
    pixelSize: 15,
    color: Cesium.Color.ORANGE,
    // 给点加个外圈,选中的时候看得更清楚
    outlineColor: Cesium.Color.WHITE,
    outlineWidth: 2
  }
});

// 调整视角,方便观察
viewer.camera.flyTo({
  destination: Cesium.Cartesian3.fromDegrees(116.391, 39.907, 10000)
});

3.3 给这个点加上拖拽逻辑

这里最关键的地方是鼠标事件的注册。Cesium提供了ScreenSpaceEventHandler,我们可以监听左键按下、鼠标移动、左键松开这三个事件。

// 初始化屏幕空间事件处理器
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
let selectedEntity = null; // 当前选中的实体
let isDragging = false;    // 是否正在拖拽

// 鼠标左键按下:拾取实体,并记录选中状态
handler.setInputAction((movement) => {
  // 从场景中拾取鼠标位置的图形对象
  const picked = viewer.scene.pick(movement.position);
  if (Cesium.defined(picked)) {
    // picked.id 就是点击到的实体
    selectedEntity = picked.id;
    isDragging = true;
    // 把当前点的颜色改一下,表示进入编辑状态
    selectedEntity.point.color = Cesium.Color.RED;
  }
}, Cesium.ScreenSpaceEventType.LEFT_DOWN);

// 鼠标移动:如果正在拖拽,就更新实体的位置
handler.setInputAction((movement) => {
  if (isDragging && selectedEntity) {
    // 将鼠标屏幕坐标转换为地球上的三维坐标(前提是射线和地球有交点)
    const cartesian = viewer.camera.pickEllipsoid(movement.endPosition, viewer.scene.globe);
    if (cartesian) {
      // 更新位置
      selectedEntity.position = Cesium.Cartesian3.clone(cartesian);
    }
  }
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);

// 鼠标左键松开:结束拖拽,恢复颜色
handler.setInputAction(() => {
  if (selectedEntity) {
    selectedEntity.point.color = Cesium.Color.ORANGE;
  }
  selectedEntity = null;
  isDragging = false;
}, Cesium.ScreenSpaceEventType.LEFT_UP);

这个示例虽然简单,但已经能跑通“点”的编辑了。线的编辑就是把entity的polyline.positions数组拿出来替换,面的编辑则是polygon.hierarchy。原理都一样,只是数据结构上稍微麻烦点。

四、扩展:让线和面也能编辑

点和线面最大的区别是顶点数量不同。拖拽一个顶点,需要知道拖的是哪一个。拿polygon举例,它内部的数据结构是PolygonHierarchy,位置信息在hierarchy.positions里。我们可以在鼠标按下时,把所有的顶点也渲染成一个个小点,让用户去点这些小点。

下面是一个“线”编辑的简化示例,你可以看到关键套路:在鼠标移动时,判断拖拽的是哪个顶点,然后修改positions数组里对应的一项。

// 假设有一条线,包含三个顶点
const polyline = viewer.entities.add({
  polyline: {
    positions: Cesium.Cartesian3.fromDegreesArray([
      116.4, 39.9,
      116.5, 39.95,
      116.6, 39.88
    ]),
    width: 5,
    material: Cesium.Color.BLUE
  }
});

// 为了方便编辑,给每个顶点创建一个小圆点
const vertexEntities = [];
function createVertexEntities(positions) {
  vertexEntities.forEach(e => viewer.entities.remove(e));
  vertexEntities.length = 0;
  
  positions.forEach((pos, index) => {
    // 注意:用index给实体起个名字,方便后续索引
    const vertex = viewer.entities.add({
      position: pos,
      point: {
        pixelSize: 10,
        color: Cesium.Color.WHITE,
        outlineColor: Cesium.Color.BLACK,
        outlineWidth: 1
      },
      // 自定义属性:记录顶点下标
      vertexIndex: index
    });
    vertexEntities.push(vertex);
  });
}

// 初始化顶点
createVertexEntities(polyline.polyline.positions.getValue());

// 同样的鼠标事件,但这次要判断拾取到的是“顶点实体”
handler.setInputAction((movement) => {
  const picked = viewer.scene.pick(movement.position);
  if (Cesium.defined(picked) && picked.id.vertexIndex !== undefined) {
    selectedVertexId = picked.id;
    isDragging = true;
  }
}, Cesium.ScreenSpaceEventType.LEFT_DOWN);

handler.setInputAction((movement) => {
  if (isDragging && selectedVertexId) {
    const cartesian = viewer.camera.pickEllipsoid(movement.endPosition, viewer.scene.globe);
    if (cartesian) {
      // 第一步:更新线实体里的positions
      const positions = polyline.polyline.positions.getValue();
      positions[selectedVertexId.vertexIndex] = cartesian;
      polyline.polyline.positions = positions;
      
      // 第二步:更新顶点实体的位置
      selectedVertexId.position = cartesian;
    }
  }
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);

你可能注意到了,线和面的编辑比点麻烦,因为要维护“线实体”和“顶点实体”的双重对应关系。建议把顶点和原图形封装成一个类,避免代码乱飞。

五、数据同步:这是真正的难点

图形编辑只是客户端的事,真正要命的是“改完之后,数据怎么同步”。这里的同步包含两层意思:一是Cesium里的图形和前端业务数据之间的同步,二是前端业务数据和后端数据库之间的同步。很多新手栽跟头就栽在这。

5.1 第一个坑:Cesium里的数据不是纯数据

Cesium里的Entity位置,是一个Property对象,它会自动计算插值、动画等。你直接读entity.position拿不到坐标数字,得调用getValue()。而且每次拖拽,你都得手动更新这个Property。

为了解决这个问题,我习惯的做法是:不直接用Cesium的Entity做业务数据源,而是保留一份“纯数据”的JavaScript数组,Cesium里的Entity只是这份数据的一个“视图”。每次编辑操作,先修改纯数据,再让Entity也跟着变。

// 定义一份纯业务数据,里面存经纬度
let featureData = {
  id: '123',
  name: '一号区域',
  // 用经纬度数组存储位置,方便发送给后端
  coordinates: [
    [116.4, 39.9],
    [116.5, 39.95],
    [116.6, 39.88]
  ]
};

// 创建一个函数:把业务数据渲染成Cesium实体
function renderFeature(data) {
  let positions = data.coordinates.map(coord => Cesium.Cartesian3.fromDegrees(coord[0], coord[1]));
  // 如果实体已经存在,先更新;不存在就创建
  let entity = viewer.entities.getById(data.id);
  if (entity) {
    entity.polyline.positions = positions;
  } else {
    entity = viewer.entities.add({
      id: data.id,
      polyline: {
        positions: positions,
        width: 4,
        material: Cesium.Color.GREEN
      }
    });
  }
}

// 每次编辑完顶点后,把坐标回写回featureData
function updateDataFromEntity(entity) {
  const positions = entity.polyline.positions.getValue();
  featureData.coordinates = positions.map(cartesian => {
    const lonLat = Cesium.Cartographic.fromCartesian(cartesian);
    return [
      Cesium.Math.toDegrees(lonLat.longitude),
      Cesium.Math.toDegrees(lonLat.latitude)
    ];
  });
}

这种“数据驱动视图”的思路,会让你的代码清爽很多。以后不管数据是来自GeoJSON还是数据库,都能用同一套逻辑处理。

5.2 第二个坑:如何自动感知数据变化

手动调用updateDataFromEntity当然可以,但容易漏。更好的办法是用Object.defineProperty或者Proxy来监听数据变化。拿一个简单例子说:

// 技术栈:JavaScript(ES6 Proxy)
// 创建一个被观察的坐标数组
function createObservedArray(callback) {
  const arr = [];
  const proxied = new Proxy(arr, {
    get(target, property) {
      return Reflect.get(target, property);
    },
    set(target, property, value) {
      const result = Reflect.set(target, property, value);
      // 注意:数组的长度变化也走set,我们要过滤掉
      if (property !== 'length') {
        callback(target, property);
      }
      return result;
    }
  });
  return proxied;
}

// 业务坐标数组
let observedCoords = createObservedArray((latestArray, changedIndex) => {
  // 在这里触发保存逻辑,可以节流后发送到后端
  console.log('坐标被修改了:索引', changedIndex, '当前数组', latestArray);
});

// 模拟编辑:当你把顶点拖动后,往这个数组里重新赋值
observedCoords.push([116.4, 39.9]); // 第一次添加触发回调
observedCoords[0] = [116.41, 39.91]; // 修改时触发回调

你可能会问,Cesium的Entity更新怎么办?因为Proxy只关心业务数据,所以我们还要再结合上一步的renderFeature。流程变成:编辑顶点时,修改observedCoords,然后renderFeature监听到变化,用新的坐标去更新实体。这样你就不再手动同步实体了。

5.3 第三个坑:同步给后端时的格式问题

地图数据的后端存储格式有很多种,常见的有WKT、GeoJSON、自定义JSON。这里我比较推荐用GeoJSON,因为Cesium自己也有GeoJSONDataSource,而且前端很多库都认这个格式。

下面是一段把实体数据导出成GeoJSON的代码,你可以用这个格式来存库。

// 将featureData转换成GeoJSON Feature
function toGeoJSON(featureData) {
  return {
    type: 'Feature',
    properties: {
      id: featureData.id,
      name: featureData.name
    },
    geometry: {
      type: 'Polygon', // 根据你的实体类型可以是Point/LineString/Polygon
      coordinates: [
        featureData.coordinates.concat([featureData.coordinates[0]]) // 闭合
      ]
    }
  };
}

// 发送到后端使用fetch
async function saveToBackend(geojson) {
  const response = await fetch('https://your-backend.com/api/features', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(geojson)
  });
  if (!response.ok) {
    throw new Error('保存失败: ' + response.status);
  }
}

前后端约定好GeoJSON格式,同步问题就解决了一大半。

六、高级一点:多人同时编辑怎么办

真实项目里,往往不只一个人在外业录数据。两个人同时编辑同一条线,就需要考虑冲突。Cesium本身不关心这个问题,这得靠后端解决。常见方案是“乐观锁”:你提交数据时,带上当前数据版本号version,后端对比这个版本号,如果和数据库里的一样,就允许写入,同时把版本号加1;如果不一样,就返回“版本冲突”,提示前端刷新。

下面是一个带版本号的提交逻辑示例:

// 技术栈:JavaScript
// 提交数据前,先从服务器拿到当前版本号
let currentVersion = 0;

async function fetchFeature(id) {
  const res = await fetch(`https://your-backend.com/api/features/${id}`);
  const data = await res.json();
  currentVersion = data.version;
  return data;
}

async function commitUpdate(updatedFeature) {
  const res = await fetch(`https://your-backend.com/api/features/${updatedFeature.id}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
      // 版本号放在请求头里,或者放在请求体里都行
      'X-Version': currentVersion
    },
    body: JSON.stringify(updatedFeature)
  });
  if (res.status === 409) {
    // 冲突了,需要拉取最新数据
    alert('这个要素已经被别人改过了,请重新加载后再编辑');
    await refreshData();
  } else if (res.ok) {
    // 成功,更新本地版本号
    currentVersion = res.headers.get('X-Version') || Number(currentVersion) + 1;
  }
}

多人协同是个大话题,但基本思路就这些。如果你是单机工具,就不用考虑这么复杂了。

七、关联技术:坐标转换和动态更新

这里我再多提两句和编辑关系紧密的技术,避免大家走弯路。

7.1 坐标转换的苦头

三维场景里,鼠标在屏幕上是一个二维坐标,想要知道它对应地球上的哪个点,就要用camera.pickEllipsoid或scene.pickPosition。前者是跟椭球体求交,适合平地上;后者是跟场景里的实际几何体求交,适合有地形或者模型的情况。注意,pickPosition需要开启depthTestAgainstTerrain为true,否则拾取的是地形下面的隐藏表面。

// 如果你想让鼠标能挂在楼房或地形上,请开启深度检测
viewer.scene.globe.depthTestAgainstTerrain = true;

开启后,在地形起伏的区域也能准确定位。不过性能会有一点点下降,自己权衡。

7.2 用CallbackProperty实现实时响应

有时候我们想在拖动过程中让某个标绘对象跟着变化,比如拖动一个面时,面的面积要实时显示在界面上。这时可以用CallbackProperty。它是一个惰性求值的属性,Cesium每帧渲染时都会调用回调函数,从而获取最新值。

// 技术栈:JavaScript(Cesium)
const areaLabel = viewer.entities.add({
  position: new Cesium.CallbackProperty(() => {
    // 注意这个回调会在每一帧被调用
    return Cesium.Cartesian3.fromDegrees(featureData.coordinates[0]?.[0] || 0, 
                                         featureData.coordinates[0]?.[1] || 0);
  }, false),
  label: {
    text: new Cesium.CallbackProperty(() => {
      return '面积: ' + calculateArea().toFixed(2) + ' 平方米';
    }, false),
    font: '14px sans-serif',
    pixelOffset: new Cesium.Cartesian2(0, -30)
  }
});

用CallbackProperty可以让你少写很多entity的更新代码,在这个例子中,你只需要一直维护featureData,UI自动就会跟着变。

八、技术优缺点分析

做这个功能,本质上是你自己搭了一套小的编辑框架,我来说说这么搞的优缺点。

8.1 优点

  • 灵活,完全可控:你想让用户只能移动顶点,还是可以整体平移,全都是自己定义。
  • 数据分离,好维护:只要坚持“业务数据驱动视图”的原则,后期接任何后端都很容易。
  • 可以深度集成交互:比如右键删除顶点、双击完成绘制,这些工业级功能,官方编辑器给不了你。

8.2 缺点

  • 开发量不小:点线面的编辑逻辑都要自己写,尤其还要处理顶点拾取、高亮、变形。
  • 性能需要自己优化:当一个面上百个顶点时,每帧都要重新计算位置,可能导致卡顿。
  • 兼容性要自己测:不同浏览器对Cesium鼠标事件的处理有细微差别,比如触屏设备,需要额外适配。

九、注意事项和坑

写到这里,我再把平时踩过的坑集中列一遍,希望你能绕开。

  • 不要在鼠标移动里直接entity.positions = newArray,这样每次都会触发Cesium内部重新解析属性,性能不够的时候会掉帧。你可以先getValue()拿到原始数组,修改数组里的某一项,然后再赋值回去。
  • 拾取实体时,要判断picked.id是不是一个Entity,因为Cesium也可能拾取到地形或者模型。最好用picked.id instanceof Cesium.Entity或者检查自定义属性。
  • 保存数据前一定要做坐标合法性校验。比如经度范围-180到180,纬度-90到90。我曾经遇到过用户拖拽到地球背面,然后经度突然变成-181的情况,导致后端报错。
  • 处理历史记录时,要保存的是“业务数据”的快照,而不是Cesium内部对象的快照。因为内部对象每次渲染都会变,记录不了真正的历史。
  • 如果数据量很大,不要把所有顶点都创建成和业务实体相同的实体,而是创建成临时的“编辑点”,编辑结束后立刻销毁。避免场景里同时存在几百个小点,影响渲染性能。

十、总结

在Cesium里做矢量数据编辑,难点不在于“画”,而在于“改”和“同步”。画个点很容易,但改一个点、同步一个点,涉及鼠标拾取、坐标转换、数据模型分离、后端提交这一整套链路。我建议你从最小功能开始,先做一个点的拖拽,跑通全流程,然后在“保持业务数据与Cesium实体同步”这个核心原则下,逐步加上线、面、顶点增删、撤销回滚等功能。数据同步问题,记住一句话:永远不要直接改Cesium里的Entity了事,始终维护一份纯数据,再让Entity去适配它。这样,不管前端怎么变,后端怎么改,心里都有底。

最后我也想说,技术博客能帮你的只是抛砖引玉。真正把编辑功能做好,还得靠你在项目里不断摸爬滚打,踩各种地形、性能、权限相关的坑。别怕麻烦,多试几次,就熟手了。