ARTICLE · 1075918
第15篇:数据图层-表格点位——把 Excel 里的一行行坐标,撒成满屏的点
大家好,我是 Cesium 酱(也可以叫我"本猿"),一名在 WebGIS 领域摸爬滚打多年的前端开发者。
上一篇我们从 Cesium 里往外"问"——鼠标挪到哪儿、经纬度是多少、比例尺几比几。今天反过来,我们往里"喂":把你电脑上那张几百行的 Excel 表格,一键撒成地球上的一片点。
你心里可能嘀咕:第 2 篇不是讲过本地矢量数据加载吗?
不一样。第 2 篇讲的是 GeoJSON / KML / SHP ——它们是"天生带着几何的地理文件";而现实里,业务同事塞给你的往往是一张普普通通的表格:一列名称、一列经度、一列纬度,后面跟着十几列业务字段。它长得跟地理数据毫无关系,唯一有关系的,是那两列数字恰好是坐标。
本篇要做的事就一件:读这种"长得很像普通表格"的文件,让它自己长出位置来。
看完这篇,你会得到:一个能在浏览器里跑起来的面板 —— 选一个表格文件 → 挑工作表 → 指定经度/纬度/标注字段 → 一键加载成点图层 → 开关图层、隐藏标注、飞行定位、点选看全部属性。
为什么值得折腾:这是把"业务数据"变成"空间数据"最短的一条路。台账、巡线点位、门店清单、设备登记表、传感器列表……它们几乎永远不会是 GeoJSON,但十有八九有一经一纬两列。能把这条路跑通,你的地球才真正吃得上单位里的存量数据。
前置条件:
Node ≥ 18,能跑 Vite Cesium ^1.144.0(本篇本地 node_modules即此版本)- xlsx(SheetJS)^0.18.5
—— 本篇唯一的新依赖, npm i xlsx Vue ^3.5(面板用 <script setup>写,换成 React / 原生也照跑)一个 Bing Maps Key(走 createMapScene+loadBingImagery那套底座,第 1 篇起就熟了)
💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。
一、先跑通最小闭环
目标:不管理任何 UI,先用最短的路径把一张表变成球上的点。
准备一个 stations.xlsx,第一行是表头,后面每行一个点:
下面这段是能直接跑的最小版本(把面板、多工作表、类型声明全砍掉,只留主干):
import { Cartesian2, Cartesian3, Color, CustomDataSource, PropertyBag, ConstantProperty } from 'cesium'import * as XLSX from 'xlsx'// 1. 文件 -> 行对象数组async function readTable(file: File) {const workbook = XLSX.read(await file.arrayBuffer(), { cellDates: true })const sheet = workbook.Sheets[workbook.SheetNames[0]] // 改这里:默认取第一个工作表return XLSX.utils.sheet_to_json(sheet, { defval: null, raw: false }) as Record<string, string>[]}// 2. 行 -> Entityfunction addPoints(viewer: any, rows: Record<string, string>[]) {const source = new CustomDataSource('站点表') // 改这里:图层名for (const row of rows) {const lon = Number(row['经度']) // 改这里:字段名const lat = Number(row['纬度'])// 坐标不合法就跳过,不报错if (!Number.isFinite(lon) || !Number.isFinite(lat)) continueif (lon < -180 || lon > 180 || lat < -90 || lat > 90) continuesource.entities.add({name: String(row['名称'] ?? '未命名'),position: Cartesian3.fromDegrees(lon, lat),point: { pixelSize: 10, color: Color.fromHsl(0.55, 0.68, 0.58, 1), outlineColor: Color.WHITE, outlineWidth: 1 },label: { text: String(row['名称'] ?? ''), font: '12px sans-serif', fillColor: Color.WHITE, pixelOffset: new Cartesian2(0, -18) },// 每一列都塞进属性包,点选时原样取回properties: new PropertyBag(Object.fromEntries(Object.entries(row).map(([k, v]) => [k, new ConstantProperty(String(v))])))})}viewer.dataSources.add(source)viewer.flyTo(source)return source}
✅ 到这里,你应该看到:一堆点出现在 Bing 影像上,每个点上方还坠着一行名字。没看到?先查两件事:一是经纬度列有没有被当成文本带引号(会在 Number() 那步变 NaN 被跳过),二是经度纬度有没有写反(写反的老哥一般会发现点撒在了南极附近)。
二、分步详解

2.1 第一步:把表格读成"行对象数组"
这一步做什么:一个二进制文件 → 一组 JS 对象,每个对象是一行,键是表头。
做:
import * as XLSX from 'xlsx'async function parseTable(file: File) {const workbook = XLSX.read(await file.arrayBuffer(), { cellDates: true })return workbook.SheetNames.map((name) => {const sheet = workbook.Sheets[name]// 数据区:第一行当表头,每行转成一个对象const rows = XLSX.utils.sheet_to_json(sheet, { defval: null, raw: false })// 表头行:header:1 让它返回"数组的数组",取第 0 行const headers = (XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null })[0] ?? []).map((v: unknown) => String(v)).filter(Boolean)return { name, rows, headers }}).filter((s) => s.rows.length > 0 && s.headers.length > 0)}
为什么这么写(三个选项都是刚需,本猿在本机 SheetJS 0.18.5 上一一试过,不是照抄文档):
defval: null—— 不加会丢列。实测同一张表(第二行中间那格是空的):写法 结果 不带 defval{"A":1,"C":3}—— 空格子那一列连键都不出现 defval: null{"A":1,"B":null,"C":3}—— 列一直在,只是值为 null 不加的话,
Object.entries(row)遍历出的列数会随行浮动,属性面板一会儿多一会儿少。raw: false—— 让所有单元格都变成字符串。实测:数字12345.678→"12345.678",日期 →"9/22/26",空白 →null。这么做的动机很实在:raw: true时日期会读成Date对象(本猿实测 typeof 是object),直接塞进 Cesium 的PropertyBag再String()出来,会是Tue Sep 22 2026 ...这种一眼假的格式。统一成字符串,属性面板显示什么由我们自己决定。代价也很实在:大数字会被压成科学计数法(实测
1234567890123→"1.23457E+12")。经纬度不受影响,但手机号、身份证、设备编号这类长整数会丢精度——4.2 节给解法。header: 1——sheet_to_json默认把第一行消费掉当表头,再也拿不到。想单独取表头,就得用{ header: 1 }让它返回"数组的数组",第 0 项就是表头行。
✅ 验证点:控制台 console.log(rows),看到的是 [{名称:"一号站点", 经度:"116.3912", ...}, ...],所有值都是字符串或 null。

⚠️ 报错:
Table 'Sheet1' has no data/ 加载后一个点都没有 原因:.filter()那一步被滤掉了 ——rows.length === 0(表里只有表头没数据行),或headers.length === 0(第一行整行是空的)。 解决:让表头老老实实待在第一行、第一列开始,中间不留空行、A1不合并单元格。
2.2 第二步:猜字段——经度列在哪儿?
这一步做什么:不让用户手填列名,自动把疑似经度/纬度/名称的那几列挑出来。
做:
functionguessField(names: string[], patterns: RegExp[]): string{return names.find((name) => patterns.some((p) => p.test(name))) ?? names[0] ?? ''}// 按顺序试,命中第一个就停const longitudeField = guessField(headers, [/经度/i, /longitude/i, /^lon/i, /x坐标/i])const latitudeField = guessField(headers, [/纬度/i, /latitude/i, /^lat/^lng{sheet.name}`)sheet.rows.forEach((row, index) => {const longitude = Number(row[sheet.longitudeField])const latitude = Number(row[sheet.latitudeField])// 数值校验:非数字、越界,一律静默跳过if(!Number.isFinite(longitude) || !Number.isFinite(latitude)|| longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) return// 每一列都包一层 ConstantProperty:告诉 Cesium "这个值不会随时间变"const values = Object.fromEntries(Object.entries(row).map(([key, value]) => [key, new ConstantProperty(String(value))]))const label = sheet.labelField ? String(row[sheet.labelField] ?? '') : ''source.entities.add({name: label || `站点表 - {index + 1}`,position: Cartesian3.fromDegrees(longitude, latitude), // 注意:没传第三参数 = 高度 0point: { pixelSize: 10, color: layerColor, outlineColor: Color.WHITE, outlineWidth: 1 },label: label ? {text: label, font: '12px sans-serif', fillColor: Color.WHITE,show: new ConstantProperty(true),pixelOffset: new Cartesian2(0, -18), // 往上抬 18px,别压住点}: undefined,properties: new PropertyBag(values) // 原始表格字段全留在这})})viewer.dataSources.add(source)void viewer.flyTo(source)
为什么:
CustomDataSource而不是直接viewer.entities.add—— 它是一份独立的"抽屉"。整层开关 = source.show = false;整层飞 =viewer.flyTo(source);整层删 =dataSources.remove(source, true)。一个 Excel 的一个工作表 = 一个CustomDataSource= 一个图层,语义天然对齐。往viewer.entities里直接堆,等你加第二张表时就分不清谁是谁了。ConstantProperty包一层—— Cesium 的属性系统默认认为值是"随时间变化的"(相机一动、时间一变就要重算)。直接塞普通字符串,某些版本会把类型转换得不明不白;显式包成 ConstantProperty就是明确告诉它"这个值永远不变,别每次都算"。- 颜色里的
0.61803398875—— 黄金比。相邻图层的色相强行拉开 0.618 圈,肉眼不会把两层看成同一种颜色。(缺点是每次 Math.random()起点不同,同样的表加载两次颜色不一样,想要稳定配色就换成index * 0.618 % 1。) return而不是报错—— 一行数据有问题是常态(有人在某行写了"待确认"),整张表不该因为它失败。
✅ 验证点:点了"加载选中图层"后,相机自动飞过去,图层列表新增一行,写着 XLSX · 42 点。
⚠️ 报错:
所选工作表没有找到有效的经纬度记录原因:所有行都被上面那个return拦掉了。最常见的两种:经纬度写反(39.9075填进经度列 → 经度 39.9 合法但纬度列的 116.39 > 90 被拦),或者坐标是度分秒字符串(116°23'28"→Number()得NaN)。 解决:把度分秒先换算成十进制小数。度分秒转十进制度:度 + 分/60 + 秒/3600。
2.4 第四步:图层管理的四个按钮
面板每个图层右边四个小控件,分别对应四件事:
// ① 眼睛:整层显隐function toggleLayer(layer: PointLayer) {layer.dataSource.show = !layer.visible// ...同步 UI 状态}// ② 标:只开关文字,点还在function toggleLabels(layer: PointLayer) {const labelsVisible = !layer.labelsVisiblelayer.dataSource.entities.values.forEach((entity) => {if (entity.label) entity.label.show = new ConstantProperty(labelsVisible)})// ...同步 UI 状态}// ③ 跳:飞过去function flyToLayer(layer: PointLayer) { void viewer?.flyTo(layer.dataSource) }// ④ ✕:移除图层function removeLayer(layer: PointLayer) {viewer?.dataSources.remove(layer.dataSource, true)layers.value = layers.value.filter((item) => item !== layer)selectedInfo.value = null}
为什么:这里有两个地方容易搞错。
- 标注开关是逐个 Entity 改的,不是一次性改图层
。因为文字属于 Entity.label,压根没有"图层级 label 开关"。本猿在 Cesium 1.144 的Cesium.d.ts里翻过:CustomDataSource上确实只有一个show: boolean(整层开关),没有labelsVisible。所以只能遍历。 remove(source, true)的true不能漏。类型定义白纸黑字写着 remove(dataSource: DataSource, destroy?: boolean): boolean,默认值是false—— 只摘出渲染队列,Entity、几何、GPU 资源全部留在内存里。图层列表里看着是没了,其实还占着地方,多删几次就会体验到"越删越卡"。
✅ 验证点:反复开关眼睛十次,帧率不掉;✕ 删掉图层后再开发面板,内存应该回落。

2.5 第五步:点选看属性
这一步做什么:鼠标点一个点,把这一行的全部原始字段摊出来。
做:
function handleEntityPick(position: Cartesian2) {if (!viewer || disposed || viewer.isDestroyed()) returnconst entity = viewer.scene.pick(position)?.id as Entity | undefinedif (!entity?.properties) { selectedInfo.value = null; return }// 属性是"随时间取值"的,要传一个时间进去;静态数据传当前时刻即可const values = entity.properties.getValue(JulianDate.now()) as Record<string, unknown> | undefinedselectedInfo.value = {name: entity.name || '表格记录',properties: Object.entries(values ?? {}).map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : String(v)])}}// 挂载clickHandler = new ScreenSpaceEventHandler(viewer.scene.canvas)clickHandler.setInputAction((movement: ScreenSpaceEventHandler.PositionedEvent) => handleEntityPick(movement.position),ScreenSpaceEventType.LEFT_CLICK)
为什么:
scene.pick返回的是一个 pick 对象,真正想要的 Entity 在它的 .id上。properties.getValue(time)必须传时间。Cesium 的属性系统天生支持时间序列(一个对象在不同时刻有不同值),对静态数据传 JulianDate.now()就行。不传会得到undefined。三个守卫( disposed/viewer.isDestroyed()/entity?.properties)缺一不可:组件卸载瞬间正好有一次 pending 的点击回调,是很常见的事。
顺便说一句卸载——三样东西必须一起收,漏一个就是内存泄漏:
onBeforeUnmount(() => {disposed = trueclickHandler?.destroy() // ① 事件句柄layers.value.forEach((l) => viewer?.dataSources.remove(l.dataSource, true)) // ② 各图层(带 destroy)destroyScene(viewer) // ③ 场景本身viewer = undefined})
✅ 验证点:点一个点,左上角弹出属性面板,除了经纬度还带着"类型""装机容量"这些原表格里的业务字段——它们一个字都没丢。

三、完整代码
把上面几段拼起来,就是这个案例的完整形态(面板模板省略了纯样式部分,核心逻辑一行没砍):
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'import { Cartesian2, Cartesian3, Color, ConstantProperty, CustomDataSource,JulianDate, PropertyBag, ScreenSpaceEventHandler, ScreenSpaceEventType,type Entity, type Viewer } from 'cesium'import * as XLSX from 'xlsx'import { createMapScene, destroyScene, loadBingImagery, type SceneCallbacks } from '../../lib/cesium-scene'type CellValue = string | number | boolean | nulltype TableRow = Record<string, CellValue>type ParsedSheet = {name: string; rows: TableRow[]; headers: string[]longitudeField: string; latitudeField: string; labelField: string}type PointLayer = {id: number; name: string; format: string; featureCount: numbervisible: boolean; labelsVisible: boolean; dataSource: CustomDataSource}const container = ref<HTMLElement | null>(null)const layers = shallowRef<PointLayer[]>([])const pendingSheets = ref<ParsedSheet[]>([])const selectedSheetNames = ref<string[]>([])const pendingFileName = ref('')const errorMessage = ref('')const loading = ref(false)const selectedInfo = ref<{ name: string; properties: Array<[string, string]> } | null>(null)let viewer: Viewer | undefinedlet clickHandler: ScreenSpaceEventHandler | undefinedlet layerSequence = 0let disposed = falseconst sceneCallbacks: SceneCallbacks = {onStatus: (message) => { /* 顶部遮罩提示 */ },onBasemapReady: () => { /* 底图就绪,清掉提示 */ }}const formatCell = (v: CellValue) => (v === null || v === undefined ? '' : String(v))/** 表头推断:按顺序试正则,一个没中就退到第一列 */functionguessField(names: string[], patterns: RegExp[]): string{return names.find((n) => patterns.some((p) => p.test(n))) ?? names[0] ?? ''}async function parseTable(file: File) {const workbook = XLSX.read(await file.arrayBuffer(), { cellDates: true })const sheets = workbook.SheetNames.map((name) => {const sheet = workbook.Sheets[name]const rows = XLSX.utils.sheet_to_json<TableRow>(sheet, { defval: null, raw: false })const rawHeaders = XLSX.utils.sheet_to_json<CellValue[]>(sheet, { header: 1, defval: null })[0] ?? []const headers = rawHeaders.map(formatCell).filter(Boolean)return {name, rows, headers,longitudeField: guessField(headers, [/经度/i, /longitude/i, /^lon/i, /x坐标/i]),latitudeField: guessField(headers, [/纬度/i, /latitude/i, /^lat{invalid.name}"选择经度和纬度字段`; return }let firstDataSource: CustomDataSource | undefinedselectedSheets.forEach((sheet, sheetIndex) => {const layerName = `{sheet.name}`const source = new CustomDataSource(layerName)const layerColor = Color.fromHsl((Math.random() + sheetIndex * 0.61803398875) % 1, 0.68, 0.58, 1)sheet.rows.forEach((row, index) => {const longitude = Number(row[sheet.longitudeField])const latitude = Number(row[sheet.latitudeField])if (!Number.isFinite(longitude) || !Number.isFinite(latitude)|| longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) returnconst values = Object.fromEntries(Object.entries(row).map(([k, v]) => [k, new ConstantProperty(formatCell(v))]))const label = sheet.labelField ? formatCell(row[sheet.labelField]) : ''source.entities.add({name: label || `{index + 1}`,position: Cartesian3.fromDegrees(longitude, latitude),point: { pixelSize: 10, color: layerColor, outlineColor: Color.WHITE, outlineWidth: 1 },label: label ? { text: label, font: '12px sans-serif', fillColor: Color.WHITE,show: new ConstantProperty(true), pixelOffset: new Cartesian2(0, -18) } : undefined,properties: new PropertyBag(values)})})if (source.entities.values.length > 0) {viewer?.dataSources.add(source)layers.value = [...layers.value, { id: ++layerSequence, name: layerName, format: 'XLSX',featureCount: source.entities.values.length, visible: true, labelsVisible: true, dataSource: source }]firstDataSource ??= source}})if (!firstDataSource) { errorMessage.value = '所选工作表没有找到有效的经纬度记录'; return }void viewer.flyTo(firstDataSource)pendingSheets.value = []; selectedSheetNames.value = []; pendingFileName.value = ''}function handleEntityPick(position: Cartesian2) {if (!viewer || disposed || viewer.isDestroyed()) returnconst entity = viewer.scene.pick(position)?.id as Entity | undefinedif (!entity?.properties) { selectedInfo.value = null; return }const values = entity.properties.getValue(JulianDate.now()) as Record<string, unknown> | undefinedselectedInfo.value = {name: entity.name || '表格记录',properties: Object.entries(values ?? {}).map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : String(v)])}}onMounted(() => {if (!container.value) returntry {viewer = createMapScene(container.value, sceneCallbacks)loadBingImagery(viewer, sceneCallbacks)clickHandler = new ScreenSpaceEventHandler(viewer.scene.canvas)clickHandler.setInputAction((movement: ScreenSpaceEventHandler.PositionedEvent) => handleEntityPick(movement.position),ScreenSpaceEventType.LEFT_CLICK)} catch (error) { errorMessage.value = String(error) }})onBeforeUnmount(() => {disposed = trueclickHandler?.destroy()layers.value.forEach((l) => viewer?.dataSources.remove(l.dataSource, true))destroyScene(viewer)viewer = undefined})</script>
四、进阶与常见疑问
4.1 CSV 的中文表头为什么全成了 åç§°?
这个坑本篇必须讲,因为它只在 CSV / TSV / TXT 上发作,Excel 原文件不会。
本猿在本机 SheetJS 0.18.5 上,拿同一份中文 CSV 做了实测:
["åç§°","ç»åº¦","纬度"] | |
EF BB BF)的 CSV | ["名称","经度","纬度"] |
{ codepage: 65001 } | ["名称","经度","纬度"] |
codepage: 936 都乱码(set_cptable 在 npm 版里压根没导出) | |
.xlsx 文件 | ["名称","经度","纬度"] |
原因一句话:SheetJS 对纯文本类文件,没检测到 BOM 时就按单字节旧编码去解。中文是多字节 UTF-8,被拆成一个个字节重新解释,就成了上表第一行那个样子。
三条解法,按推荐顺序:
- 给文件加 UTF-8 BOM
。VS Code 里右下角编码 → “通过编码保存” → 选 UTF-8 with BOM;或者用命令行给存量文件一次性补上: # 给没有 BOM 的 UTF-8 CSV 补上 BOMprintf '\xEF\xBB\xBF' | cat - input.csv > output.csv - 在代码里强制 UTF-8
: XLSX.read(buf, { cellDates: true, codepage: 65001 })。实测有效,不需要额外依赖。 - 别用 CSV
。让导出方直接给 .xlsx—— 这条路从根上没有编码问题。
⚠️ 顺带提醒:如果你的用户是业务同事,与其让他们学 BOM,不如在 UI 上直接兜一层——检测到
.csv/.tsv/.txt扩展名就自动补codepage: 65001,成本一行代码。
4.2 属性面板里的长数字怎么变成 1.23457E+12 了?
这是 2.1 节 raw: false 的代价。实测:单元格 1234567890123 在 raw: true 下读到的是 1234567890123(number),在 raw: false 下是 "1.23457E+12"(字符串)——因为它走的是"按单元格格式渲染后的显示值"这条路,Excel 里显示给人的就是这个样子。
经纬度(8 位以内小数)不受影响,受影响的是手机号、身份证号、设备编号、订单号这类长整数。
改法是只对这一列放开:别整表换 raw: true(那样日期又会变成 Date 对象),而是在读完之后,用 workbook.Sheets[x] 的原始单元格把关键列取回来:
// encode_cell 拼出 "A2" 这样的单元格地址,直接取 SheetJS 的原始单元格对象const ref = XLSX.utils.encode_cell({ r: rowIndex + 1, c: colIndex }) // +1 跳过表头行const cell = sheet[ref]// 实测 cell = {"t":"n","v":1234567890123,"w":"1.23457E+12"}// t = 类型(n 数字 / s 字符串 / d 日期)// v = 原始值 ← 我们要的就是它// w = 格式化后的显示值 ← 被科学计数法坑的就是它const rawValue = cell?.v
或者更简单粗暴:让导出方把编号列设成"文本"格式(选中列 → 设置单元格格式 → 文本),w 就不会被科学计数法渲染。
4.3 坐标对了,但点全部沉到地形底下了?
本篇面板只加载了 Bing 影像,没有加载地形,所以 Cartesian3.fromDegrees(lon, lat) 省略的第三个参数——高度 0——正好等于椭球面,也就是脚下的地面,一切正常。
一旦你自己加上了 createWorldTerrainAsync(第 10 篇那套),"高度 0"就成了椭球高,而真实的山体表面往往在椭球面之上几百米 —— 点会被埋进山里。
此时要么给 Entity 补 heightReference: HeightReference.CLAMP_TO_GROUND 让它贴地(点会自带地形起伏),要么用第 19 篇那一套先采样真实高程再赋值。这是个组合使用时才会现身的坑,单独跑本篇永远遇不到。
4.4 一张表三千个点,会不会卡?
Entity API 的好处是省心,代价是每个 Entity 都是一条完整的可视化对象,要参与属性更新、包围球计算、屏幕空间集合的调度。几千这个量级浏览器扛得住,再往上就该换路线了。
三条渐进的路:
- 先开内置聚合
: CustomDataSource上就有 clustering(本猿在 1.144 的Cesium.d.ts里确认过,show旁边就是它): 一句话的收益是:缩到全球视角时,远处不再糊成一片毛团,而是几个带数字的聚合标记。layer.dataSource.clustering.enabled = truelayer.dataSource.clustering.pixelRange = 40 // 相距 40 像素内的点抱成一团 - 换 Primitive API
: PointPrimitiveCollection直接喂位置数组,比 Entity 轻一个数量级。 - 走专门的数据可视化线
:本仓库的 CASE_LIST #229「数据可视化-海量点实时聚合」就是为这个场景准备的,到时候我们再细聊。
4.5 能不能加高度、换图标、导出回去?
都能,都属于"这篇没做但可以马上加":
- 加高度
:表格再加一列"高程", Cartesian3.fromDegrees(lon, lat, Number(row['高程']))。 - 换图标
:把 point换成billboard: { image: '/marker.png', width: 24, height: 24 },图标可以用 Canvas 现场画(画个带底衬的圆角标签再toDataURL()喂进去),这样你就能用任意字符当点标记了。 - 导出回去
:这次是从表格流到地球;反方向——把球上编辑过的点导回一份新的 xlsx——只需 XLSX.utils.json_to_sheet(rows)+XLSX.writeFile(wb, 'out.xlsx'),SheetJS 两头都能吃。本猿把这留作延伸练习,做完这篇就算真会了。
延伸练习:
顺手验一下 SheetJS 的自动识别能力:把同一份内容分别存成逗号分隔、分号分隔、制表符分隔三个文件喂进去——本猿实测 0.18.5 会自动嗅探分隔符,三份都能在同一份代码里正常读出,不用传任何 FS/sep参数。把"猜字段"升级成可记忆:用户手动选过一次"项目经度"这种叫法后,把它记进 localStorage,下次同一张表直接命中,不必再选第二遍。造一张含对象型字段的表(比如某列里放 {"a":1}这样的内容),确认属性面板能把它按JSON.stringify展开,而不是显示成[object Object]。
五、写在最后
我们做的事其实很朴素:一个文件 → 一堆行对象 → 一堆 Entity → 一叠互相独立的图层抽屉,然后再给它们配上"整层开关、隐藏标注、飞行定位、点开看原始字段"这四种基本操作。
放在真实项目里,这套东西的典型用法是:把业务台账直接变成一张可以巡查、可以按属性筛的空间底图——巡线点位不用再去 GIS 软件里手工数字化一遍,门店清单不用先导入 PostGIS 再发布服务,谁维护台账谁就有地图,地名改了台账一改地图上跟着变。
往后这条数据图层线还能往下挖:给它配上一个能真正接收拖拽文件的落区(现在是藏在 <input type="file"> 后面的一个小按钮)、给每个图层独立配色与图例、点密度大了自动换 PrimitiveCluster / 聚合标记(#229 海量点实时聚合),以及最实用的——把"加载"那一层扩到也吃 GeoJSON / 数据库返回的 JSON 数组,让"表格"变成"任意行结构数据"的通用入口。
如果你有更好的想法,或者在实际使用中遇到问题,欢迎来 GitHub 提 Issue!
🌟 项目已开源,欢迎Star!(你的 Star ⭐ 就是对我最大的支持!)
- 开源地址
:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX 仓库公开,无需回复口令,直接到 GitHub 取即可。本篇代码在 src/cases/table-points/,配套的createMapScene/loadBingImagery在src/lib/cesium-scene.ts。
Cesium酱の百宝箱 · 第 15 篇 一行成一豆,万点铺山河 二〇二六年仲秋,与你共读表格里的世间坐标