上周帮一个做应急调度的朋友看代码,他给我发来一张截图:Excel 里 12 行点位,名称那一列,12 个全叫 “北大门”。
我第一反应是他把名称写错了。结果他说,原始点位名明明是 “一号点位” 到 “十二号点位”,是自己在属性里加了一条 “名称 = 北大门”,导出之后就变成这样了。
我说,行,那把代码给我看看。这一看,就是一整天。
我们上次做的事情,是从热力图那口锅里往外舀数据。今天换个方向——先把一个个点标到球上,再让它变成一张能交出去的表。
点位标记这东西,看着是标绘里最不起眼的一块:点一下地图,球上多一个点,旁边列个清单,导出个 CSV 完事。可真正把它接到业务上,问题全在"两份数据"之间——球上那份是给眼睛看的,清单那份是给人交接的,导出那份是要进别人 Excel 的。三份数据只要有一处口径不一致,交付的时候就等着挨问吧。
本猿在项目里被问过最狠的一句是:“这个点和表里第 5 行,是同一个点吗?” 当时我只能盯着屏幕数点。
大家好,我是 Cesium 酱(也可以叫我 “本猿”),一名在 WebGIS 领域摸爬滚打多年的前端开发者。
这几天降温,码字的时候手边一直放着一杯热水。写到第三节的时候水凉了,我也没喝——因为正在跟disableDepthTestDistance较劲。
本文目标:从拾取一个坐标出发,把 “球上点 / 清单 / 导出” 这三条链路的实现拆开,并且盯住它们中间那几处会悄悄丢东西的地方——精度、样式、属性名,一个都不放过。💪
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
一、痛点与场景:为什么点位清单这么容易出错?
🎯 背景现状:点位标记是标绘功能的起点,也是所有"图上作业"的落点。它看起来只是"一个点",但它同时承担三个身份:屏幕上的图形、界面里的数据、文件里的记录。字段一多、点位一多,这三个身份就开始各说各话。
典型场景里,它要同时满足这些需求:
- 🖱️两种录入方式:手动敲经纬度(精确到小数点后 6 位),或者直接在地图上点一下拾取
- 🏷️每条点位带自定义属性:闸机、摄像头、责任人、备注……字段是用户自己加的,代码事先不知道有哪些
- 📋清单要能操作:跳转定位、改名、编辑属性、单条删除、一键清空
- 📤导出要能交接:CSV 给 Excel 打开不能乱码,Excel 要给同事填数字、能参与计算
🤦 传统痛点:
| 常见做法 | 问题 |
|---|---|
| 只画点,不存数据 | 点画完就"死"在球上了,清单是空的,导出无从谈起 |
| 清单和球各存一份 | 两套数据两处维护,删点、改名总有一边没跟上 |
坐标直接toFixed(5)存 | 看着整洁,实际最坏丢掉半米多;再导出一次,误差还会叠加 |
| 属性名由用户随便填 | 撞上内置列名(名称 / 经度 / 纬度)就把正经数据顶掉,且不报错 |
💡 本文方案:
1️⃣一份记录、两个出口:每次添加先建一条PointRecord进清单,再照它建一个Entity上球;球上的点只负责"显示",清单才是唯一事实源。
2️⃣拾取的点地坐标只留经纬度:pickCartographic给出经纬高,写进记录时只记longitude/latitude,高度交给heightReference在渲染期决定——听上去很合理,下一节我们看它会丢什么。
3️⃣导出走两条独立路径:CSV 手写字符串拼接(快、体积小),Excel 交给 SheetJS(能当数字用、列宽友好),两条路各自处理属性列。
✨ 一句话总结:把点位当成一份 “记录 + 一个投影”,用清单守住唯一事实源,再让导出把这份记录翻译成别人看得懂的表格。
二、核心技术原理:一份记录、两个出口
2.1 数据结构:清单里到底存了什么
整个模块的数据结构只有两个类型,很克制:
// 源码级剧透:src/cases/point-markers/PointMarkersDemo.vuetypePointAttribute={key:string// 属性名,用户自己填,代码不校验value:string// 属性值,一律按字符串存}typePointRecord={id:string// point-{时间戳}-{自增序号}name:string// "点位1"、"点位2"……longitude:number// 完整 double,不做任何截断latitude:number// 完整 double,不做任何截断attributes:PointAttribute[]// 自定义属性,顺序即录入顺序}几个刻意的选择:
- 📝属性值一律是字符串:用户填的是"闸机"不是 1,是"A-12"不是 12,导出时 CSV 侧原样输出、Excel 侧也原样输出,避免中途被当数字处理掉前导零
- 🔢经纬度存完整 double:界面显示几位、导出几位,那是"格式化"的事,不在存储层做截断
- 🎯
id用时间戳 + 自增序号:point-1735234567890-3,同一毫秒内连加两个点也不会撞 id —— Cesium 的entities.add()对重复 id 会直接抛错(实测DeveloperError: An entity with id point-1 already exists in this collection.)
2.2 核心流程:一次添加要写两份数据
从"用户点了一下地图"到"球上出现一个点、清单里多一行",中间的完整链路是这样的:
核心亮点:第 ④ 步那一个分叉是整篇的关键 🔪 —— 同一个addPoint里,既要往points[]里塞一条记录,又要往viewer.entities里加一个实体。这两份数据从此各自演化:清单里改名不会动球上的标签,球上的样式改面板也不会回头改已存在的点。这不是 bug,但它是后来所有"对不上"的源头。
2.3 球上那一份,为什么用 Entity 的 point + label
"在地球上标一个点"至少有四条路,我们选了最省事也最容易被误解的那条:
| 方案 | 原理 | 成本 | 适用 |
|---|---|---|---|
Entity+point+label | 数据源层,两个图形挂在同一个实体上,共享一个position | 🐢 每个点一个实体,千级尚可 | 点位需要对号入座、要能编辑(本文) |
PointPrimitiveCollection | 渲染层集合,一次 Draw 画一堆点 | ⚡ 万级轻松 | 纯展示、无交互、无清单 |
BillboardCollection | 用图片当点,能做各种图标 | ⚡ 万级轻松 | 需要自定义图标、要缩放质感 |
EntityCluster | 点上再叠一层聚合 | 🐌 聚合本身有开销 | 点位过密、需要"合并成数字" |
选Entity的理由很直白:我们的点要能被"认领"。清单里点"跳转",得知道跳到哪个实体;点"删除",得把对应的实体从场景里摘掉。Entity自带 id 和统一的位置,配一个Map<id, Entity>就能把两份数据对上号。
简单说:Entity是"带户口本的点",PointPrimitive是"打工的点"——便宜、快,但叫不出名字。
三、代码实战:三个关键实现
⚠️温馨提示:本文的实现范围是"点位标记 + 清单管理 + 导出"这条最小闭环,不包含拖动改位置、撤销栈、CZML 持久化(这些留给后面的篇目)。另外,示例底图走 Bing 影像,坐标拾取复用measure-lib里的三级降级方案。
3.1 addPoint:一次点击,写下两份数据
functionaddPoint(longitude:number,latitude:number,name?:string):void{if(!viewer||viewer.isDestroyed())return// 🔑 校验只有两步:是不是数字、在不在合法区间if(!isFiniteNumber(longitude)||!isFiniteNumber(latitude)){flashStatus('经纬度数值无效,请检查输入');return}if(longitude<-180||longitude>180||latitude<-90||latitude>90){flashStatus('经纬度超出有效范围(经度 ±180,纬度 ±90)');return}constrecord:PointRecord={id:`point-${Date.now()}-${nextIndex}`,name:name??`点位${nextIndex}`,longitude,latitude,attributes:[]}nextIndex+=1points.value.push(record)// 🌰 出口一:清单// 🌰 出口二:球上的实体constcolor=Color.fromCssColorString(MARKER_COLORS[(points.value.length-1)%MARKER_COLORS.length])constposition=Cartesian3.fromDegrees(longitude,latitude)// 🔑 高度缺省 = 0constentity=viewer.entities.add({id:record.id,position,point:{// 🔑 贴不贴地,建这一刻就定死了pixelSize:pointSize.value,color,outlineColor:Color.WHITE,outlineWidth:1.5,heightReference:clampToGround.value?HeightReference.CLAMP_TO_GROUND:HeightReference.NONE,disableDepthTestDistance:Number.POSITIVE_INFINITY},label:{// 🔑 标签写的是坐标,不是名称text:`${latitude.toFixed(6)},${longitude.toFixed(6)}`,font:'12px sans-serif',fillColor:Color.WHITE,outlineColor:Color.BLACK,outlineWidth:3,showBackground:true,backgroundColor:Color.fromCssColorString('#0a2030').withAlpha(0.75),verticalOrigin:VerticalOrigin.BOTTOM,horizontalOrigin:HorizontalOrigin.CENTER,pixelOffset:newCartesian2(0,-20),disableDepthTestDistance:Number.POSITIVE_INFINITY}})entityMap.set(record.id,entity)selectedId.value=record.idclearStatus()}🔑 关键点说明:
Cartesian3.fromDegrees(lon, lat)的缺省高度是 0,也就是椭球面。点地拾取得到的Cartographic里是带height的,但写进record时只留了经纬度 —— 于是这个高度在数据层就没了。实测(探针跑 Cesium 内核):地形高 50 m 处,不贴地的点和真位置相差整整50.000 m;1000 m 处差1000.000 m,一点折扣都不打。- 颜色不是跟点走的,是跟"添加时的下标"走的:
MARKER_COLORS[(points.length - 1) % 8]。8 色循环,只要中间删过点,下标就错位。实测:加 3 个点 → 删掉第 2 个 → 再加 1 个,新点拿到的下标是 2,#4a9eff直接和现存的"点位3"撞色。 outlineWidth: 1.5在贴地路径上会被Math.round:PointVisualizer处理heightReference非 NONE 的点时,会走 Billboard 分支,outlineWidth和pixelSize都会先Math.round()(这个坑在第 33 篇拆heightReference时已经领教过一次)。所以 1.5 实际是 2。disableDepthTestDistance给的是Infinity:注意它是平方之后再进 shader 的。BillboardCollection里一行disableDepthTestDistance *= disableDepthTestDistance,Infinity 平方还是 Infinity,紧接着被换成-1.0这个哨兵值送进 shader;顶点着色器里disableDepthTestDistanceSq < 0.0成立 →enableDepthCheck = 0,并把gl_Position.z直接压到近平面。结果就是:这个点永远画在最前面,山挡不住它,地球背面也挡不住它。想用有限值的人要小心——传 1000,实际生效的分界是 √1000 ≈ 31.6 m。
nextIndex只增不减,所以清空之后再添加,第一个点叫"点位 5"(实测),而不是从"点位 1"重来。
3.2 样式开关为什么"只管新点"
界面上有两个样式开关:点大小滑块(4 ~ 24)和贴地开关。它们看起来应该"实时生效",但实际只影响之后新建的点:
pointSize.value=24// 面板里的 ref 变了clampToGround.value=false// 开关也拨了// 但是——已存在的实体身上是这样的:// entity.point.pixelSize → ConstantProperty,getValue() 恒为构造时的 10// entity.point.heightReference → ConstantProperty,恒为构造时的 CLAMP_TO_GROUND🔑 关键点说明:
Entity在构造时会把传进去的普通值包成ConstantProperty。实测确认:entity.point.pixelSize.constructor.name === 'ConstantProperty'、isConstant === true,getValue()永远是10;同一个位置上新建一个点,getValue()才是24。PointVisualizer每帧都会读一次Property.getValueOrDefault(pointGraphics._pixelSize, time, ...)—— 也就是说渲染侧是"每帧问一次",只要 Property 变了就会跟着变。问题不在 Cesium,在我们没去写回这个 Property。- 想让滑块真正实时,要么遍历
entityMap一个个entity.point.pixelSize = new ConstantProperty(v),要么一开始就用CallbackProperty(() => pointSize.value, false)把它挂成"每帧求值"。
这一条不写进正文容易挨骂,因为用户的第一反应一定是"我拖了滑块,为什么没反应"。
3.3 属性去重:同名的 key,后者胜
属性编辑里允许用户加任意多条"属性名 = 属性值",保存时会做一次去重:
functionsaveEdit():void{constrecord=points.value.find((p)=>p.id===editingId.value)if(!record){closeEdit();return}constname=editName.value.trim()if(!name){flashStatus('名称不能为空');return}// 🔑 名称是必填constcleaned:PointAttribute[]=[]constseen=newSet<string>()editAttrs.value.forEach((attribute)=>{constkey=attribute.key.trim()if(!key)return// 🔑 空属性名直接丢if(seen.has(key)){constexisting=cleaned.find((item)=>item.key===key)if(existing)existing.value=attribute.value// 🔑 重名:后来的值覆盖先前的return}seen.add(key)cleaned.push({key,value:attribute.value})})record.name=name record.attributes=cleanedcloseEdit()flashStatus('点位信息已更新')}🔑 关键点说明:
- 去重是"后者胜":同名的两条属性,最后一条的值留下来。这个策略本身无所谓对错,但要在 UI 上说清楚,否则用户以为"第一条优先"。
- 属性名只
trim,不做任何合法性校验:它可以是空的(丢)、可以是空格(trim 后空 → 丢)、也可以叫 “名称”——而 “名称” 恰好是导出时的内置列名。这就是开头那张截图的成因,下一节单独说。 - 改名字不会同步到球上的标签:
label.text写的是坐标字符串,record.name只活在清单和导出里。所以"我在清单里把点位 3 改成 3 号闸机",球上那个标签纹丝不动——它在设计上就不是名称标签。
3.4 导出:两条路,各自的脾气
functioncsvCell(value:string):string{// 🔑 只转义引号、逗号、换行return/[",\n]/.test(value)?`"${value.replace(/"/g,'""')}"`:value}functionexportCsv():void{constkeys=attributeKeys()// 全部点位属性名的并集,按首次出现顺序constheader=['序号','名称','经度','纬度',...keys].join(',')constrows=points.value.map((p,i)=>{constattrs=attributeMap(p)return[i+1,p.name,p.longitude.toFixed(6),p.latitude.toFixed(6),...keys.map((key)=>attrs[key]??'')].map((cell)=>csvCell(String(cell))).join(',')})constblob=newBlob([`\ufeff${header}\n${rows.join('\n')}`],{type:'text/csv;charset=utf-8'})// 🔑 BOM 必须有,否则 Excel 打开中文乱码downloadBlob(blob,'点位清单.csv')}functionexportExcel():void{if(points.value.length===0){flashStatus('点位清单为空,无可导出的数据');return}constkeys=attributeKeys()constdata=points.value.map((p,i)=>{constattrs=attributeMap(p)constrow:Record<string,number|string>={序号:i+1,名称:p.name,// 🔑 内置列先写经度:Number(p.longitude.toFixed(6)),纬度:Number(p.latitude.toFixed(6))}keys.forEach((key)=>{row[key]=attrs[key]??''})// 🔑 属性列后写 → 能覆盖returnrow})constsheet=XLSX.utils.json_to_sheet(data)constworkbook=XLSX.utils.book_new()XLSX.utils.book_append_sheet(workbook,sheet,'点位清单')XLSX.writeFile(workbook,'点位清单.xlsx')}🔑 关键点说明:
- CSV 用
toFixed(6),Excel 用Number(toFixed(6)):前者写进文件是字符串,后者在 xlsx 里是真的数字单元格(实测t: 'n'数字单元格,v = 116.000123)。要参与计算就用 Excel 那条,要绝对原样就留着 CSV 那条。 - CSV 表头的属性列是"拼上去"的:
['序号','名称','经度','纬度', ...keys]—— 用户属性名如果也叫 “名称”,表头就是序号,名称,经度,纬度,名称,类型,两列同名,谁也不覆盖谁,只是多一列。 - Excel 反过来,是"覆盖"的:
row['名称'] = p.name先写,属性列row[key] = attrs[key]后写 —— 同名时后写覆盖先写。实测:一号点位在导出的表里变成了北大门,而且不报错、不提示,肉眼还得靠对比才能发现。 csvCell的转义清单里没有\r:值里带一个回车符(比如从别处粘贴进来的多行备注),CSV 就在那一格断行了。=、@、+、-开头的值也不会被处理 —— 在 Excel 里这些是公式前缀,属于经典的 CSV 注入面,导出给外部之前最好加一层前缀过滤。__proto__这个属性名会出事:row['__proto__'] = 'x'不是赋值而是改原型(字符串会被静默忽略),实测该属性在Object.keys()里根本不出现;而 CSV 那条路读attrs['__proto__']拿到的是Object.prototype,String()之后写出[object Object]。同一个数据,两条导出路径给出两种结果。
四、功能亮点与性能优势
先把真实跑起来的画面放上来:
4.1 清单与球上的双向操作
- 🎯跳转:
camera.flyTo到该点上方8000 m,飞行时长 1 s —— 高度是写死的,点位在城市里刚好,跑到高原地带就会觉得"怎么贴这么近" - ✏️编辑:打开对话框改名称与任意条属性,经纬度显示为只读(
toFixed(6))——设计上不允许拖动改位置 - 🗑️删除:从
entityMap里拿实体entities.remove,同步splice清单,两边一起走
4.2 一个坐标,四种精度
这是本篇最容易挨问的一块:同一个坐标,在四个地方露出的精度不一样。实测数据(北京 39.9975°N,取 12 位小数的拾取坐标):
| 出口 | 写法 | 最坏偏差 | 平均偏差 |
|---|---|---|---|
球上Entity.position | 完整 double | 0.0000 m | 0.0000 m |
标签text/ 导出 | toFixed(6) | 0.0584 m | 0.0256 m |
| 清单显示 | toFixed(5) | 0.5977 m | 0.3936 m |
| 导出后再导入 | toFixed(6)往返 | 0.0663 m | 0.0432 m |
顺带把"一位小数到底值多少米"记下来:在北京,1e-5°是纬度 0.5552 m、经度 0.4270 m;1e-6°是纬度 0.0555 m、经度 0.0427 m。1°纬度实测 111042.8 m,1°经度实测 85395.9 m;后者比 111320 × cos39.9975°(85279.2 m)多出 116.7 m,因为 WGS84 的卯酉圈曲率半径(6386975.2 m)比赤道半径大 0.139%。
结论很朴素:清单里少给的那一位,最坏就是半米多。给同事核对点位、给施工队找井盖,半米可能刚好压在马路中线两侧。
4.3 导出性能:CSV 比 Excel 快一个量级
测试条件是本猿自己的机器、纯 CPU 侧生成(不含浏览器下载动作),属性固定两列:
| 点位数 | CSV 生成 | CSV 体积 | Excel 生成 | Excel 体积 |
|---|---|---|---|---|
| 100 | 0.163 ms ⚡ | 4.6 KB | 7.316 ms | 36.6 KB |
| 1000 | 1.220 ms ⚡ | 48.6 KB | 14.351 ms | 234.4 KB |
| 5000 | 3.689 ms ⚡ | 255.6 KB | 61.932 ms | 1152.3 KB |
| 10000 | 8.290 ms ⚡ | 514.4 KB | 103.522 ms | 2299.8 KB |
10000 个点:CSV 8.29 ms / 514 KB,Excel 103.5 ms / 2.3 MB ——慢 12.5 倍、大 4.5 倍。所以界面上的策略是"两个都给",要快给 CSV,要格式给 Excel。
顺带一组实体侧的成本(new Entity+EntityCollection.add,不含渲染):
| 点位数 | 总耗时 | 单点耗时 |
|---|---|---|
| 100 | 3.739 ms | 0.03739 ms |
| 1000 | 22.771 ms | 0.02277 ms |
| 5000 | 122.145 ms | 0.02443 ms |
每个点还要跑一次Color.fromCssColorString(实测单次 0.000361 ms)和一次withAlpha(0.000344 ms)——所以颜色解析和背景色如果是固定值,提到循环外面会更舒服。
核心优势:
- 🚀纯前端运行:拾取、清单、导出全在浏览器里,零服务器成本,点位数据不出本机
- 🧩属性结构自由:属性列取并集后统一对齐,缺属性的行留空,不要求所有点字段一致
- 📦两条导出路径:CSV 兼容一切文本工具,Excel 直接给业务同事填数
- 🎯清单是唯一事实源:球上负责"看",清单负责"改"和"交",职责划得清
五、小贴士:避坑指南
🎯 关于坐标精度
点位数据一旦落进业务,精度就不只是"显示几位"的问题。⚠️ 重要提示:存储层永远存完整 double,只在"给人看"和"写文件"这两处格式化,且两处保持一致。
- 清单列表里至少显示到 6 位小数,或者干脆显示"经纬度 + 到最近参考点的距离"
- 导出前问一句"这份表拿去干什么":核对位置 → CSV 留 6 位;填数据 → Excel 用数字单元格
- 导入回项目时,6 位小数的往返偏差最坏 0.0663 m,可以接受;但如果对方把坐标粘进 Excel 又被自动保留成 2 位小数(“116.36”),那就是几公里的事了
📦 关于导出与属性命名
同名的坑有两种表现,值得单独贴一张图(上面这张就是那两张表的实录)。
⚠️ 重要提示:给用户的属性名加一层保留字校验,比出了事再解释便宜得多。
- 保留字至少包含:
序号、名称、经度、纬度,一律拒绝或自动改名(如"名称(自定义)") - 属性名不放空、不含首尾空格、长度给个上限(20 字够用)
- 拒掉
__proto__、constructor这类原型链上的名字——它们在 JS 里不是普通字符串键 - 属性值导出前过滤
=、+、-、@开头,或者统一加一个前置单引号 - CSV 的单元格转义要补上
\r
⚡ 关于性能
⚠️ 重要提示:Entity的每一个Property(pixelSize、color、heightReference……)在赋值时都会被包装成ConstantProperty。想改已有点的样式,必须显式写回 Property,别指望改一个外部的ref就能生效。
- 1000 点以内用 Entity 直管没问题;上万点应该换
PointPrimitiveCollection+ 外置数据数组 - 颜色、背景色这些固定值提到循环外
- 批量改样式时把
entityMap遍历一次写回,别在watch里逐点触发 disableDepthTestDistance: Infinity很方便,但每个点都关掉深度测试意味着它们之间的遮挡关系也没了,点位密集时会互相压字
🗺️ 关于高度与贴地
⚠️ 重要提示:Cartesian3.fromDegrees(lon, lat)的高度缺省是 0(椭球面),而地图上拾取到的点是带地形高度的。这两个值不是一回事。
- 需要"浮在地形上方"的点,用
HeightReference.RELATIVE_TO_GROUND(它是叠加,不是替换) - 需要"站在地形上"的点,用
CLAMP_TO_GROUND(替换高度,但依赖深度纹理) - 如果你的场景没加载地形,
CLAMP_TO_GROUND贴的就是椭球面——和NONE在视觉上没区别,容易误判成"开关坏了"
坦诚说,这个工具目前还不完美:拖动改位置、批量导入、撤销栈、CZML 持久化都还没有;属性编辑只支持字符串值,没有类型与校验。但"球上一个点"和"表里一行数据"之间的那条线,这篇算是把它理直了。
六、写在最后
一个点位,在屏幕上它是一颗圆点,/ 在清单里它是一行字,在文件里它是一格数据。/ 我们总以为标个点是"画一下",/ 直到有人问:球上那个点,是表里第几行?
从 #33 拆完heightReference的两条渲染路径,到今天把清单与导出接上,标绘线终于把"点"这件事讲完整了。
现在,Cesium 上的一个点,既能被眼睛看见,也能被表格接住了。🎉
如果你有更好的想法,或者在实际使用中遇到问题,欢迎来 GitHub 提 Issue!
开源地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX
🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)
仓库公开,直接到 GitHub 取即可。本篇涉及的目录:
src/cases/point-markers/—— 演示面板与清单 / 导出逻辑(PointMarkersDemo.vue,478 行)+index.ts(id: point-markers/title: 标记标绘-点位标记与清单/category: draw/tag: 点位管理/updatedAt: 2026-08-28)src/cases/measure-lib/pick.ts——pickPosition三级降级拾取 +pickCartographic(共 28 行,与量测线的几篇共用同一份)
Cesium酱の百宝箱 · 第 53 篇
点落球成影,行归表有踪
2026 年深秋,与你共探标绘之尺