夜雨聆风学习资料网

ARTICLE · 1075918

第15篇:数据图层-表格点位——把 Excel 里的一行行坐标,撒成满屏的点

第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,第一行是表头,后面每行一个点:

名称
经度
纬度
类型
装机容量
一号站点
116.3912
39.9075
光伏
120
二号站点
121.4737
31.2304
风电
80
三号站点
113.2644
23.1291
光伏
45

下面这段是能直接跑的最小版本(把面板、多工作表、类型声明全砍掉,只留主干):

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)) continue    if (lon < -180 || lon > 180 || lat < -90 || lat > 90) continue    source.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),   // 注意:没传第三参数 = 高度 0    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),                    // 往上抬 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.labelsVisible  layer.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()) return  const entity = viewer.scene.pick(position)?.id as Entity | undefined  if (!entity?.properties) { selectedInfo.value = null; return }  // 属性是"随时间取值"的,要传一个时间进去;静态数据传当前时刻即可  const values = entity.properties.getValue(JulianDate.now()) as Record<string, unknown> | undefined  selectedInfo.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 = true  clickHandler?.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: number  visible: 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 | undefined  selectedSheets.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) return      const 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()) return  const entity = viewer.scene.pick(position)?.id as Entity | undefined  if (!entity?.properties) { selectedInfo.value = null; return }  const values = entity.properties.getValue(JulianDate.now()) as Record<string, unknown> | undefined  selectedInfo.value = {    name: entity.name || '表格记录',    properties: Object.entries(values ?? {}).map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : String(v)])  }}onMounted(() => {  if (!container.value) return  try {    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 = true  clickHandler?.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 做了实测:

输入
解析结果(表头)
UTF-8 编码,无 BOM 的 CSV
["åç§°","ç»åº¦","纬度"]
 ❌ 乱成一锅粥
UTF-8 编码,带 BOM(EF BB BF)的 CSV
["名称","经度","纬度"]
 ✅
无 BOM + { codepage: 65001 }
["名称","经度","纬度"]
 ✅
GBK 编码的 CSV(Excel “另存为 CSV” 的常见产物)
无论加不加 codepage: 936 都乱码(set_cptable 在 npm 版里压根没导出)
真正的 .xlsx 文件
["名称","经度","纬度"]
 ✅ —— 它的字符串在 XML 里就是 UTF-8,不存在编码问题

原因一句话:SheetJS 对纯文本类文件,没检测到 BOM 时就按单字节旧编码去解。中文是多字节 UTF-8,被拆成一个个字节重新解释,就成了上表第一行那个样子。

三条解法,按推荐顺序:

  1. 给文件加 UTF-8 BOM
    。VS Code 里右下角编码 → “通过编码保存” → 选 UTF-8 with BOM;或者用命令行给存量文件一次性补上:
    # 给没有 BOM 的 UTF-8 CSV 补上 BOMprintf '\xEF\xBB\xBF' | cat - input.csv > output.csv
  2. 在代码里强制 UTF-8
    :XLSX.read(buf, { cellDates: true, codepage: 65001 })。实测有效,不需要额外依赖。
  3. 别用 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 都是一条完整的可视化对象,要参与属性更新、包围球计算、屏幕空间集合的调度。几千这个量级浏览器扛得住,再往上就该换路线了。

三条渐进的路:

  1. 先开内置聚合
    :CustomDataSource 上就有 clustering(本猿在 1.144 的 Cesium.d.ts 里确认过,show 旁边就是它):
    layer.dataSource.clustering.enabled = truelayer.dataSource.clustering.pixelRange = 40     // 相距 40 像素内的点抱成一团
     一句话的收益是:缩到全球视角时,远处不再糊成一片毛团,而是几个带数字的聚合标记。
  2. 换 Primitive API
    :PointPrimitiveCollection 直接喂位置数组,比 Entity 轻一个数量级。
  3. 走专门的数据可视化线
    :本仓库的 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 两头都能吃。本猿把这留作延伸练习,做完这篇就算真会了。

延伸练习:

  1. 顺手验一下 SheetJS 的自动识别能力:把同一份内容分别存成逗号分隔、分号分隔、制表符分隔三个文件喂进去——本猿实测 0.18.5 会自动嗅探分隔符,三份都能在同一份代码里正常读出,不用传任何 FS / sep 参数。
  2. 把"猜字段"升级成可记忆:用户手动选过一次"项目经度"这种叫法后,把它记进 localStorage,下次同一张表直接命中,不必再选第二遍。
  3. 造一张含对象型字段的表(比如某列里放 {"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 篇 一行成一豆,万点铺山河 二〇二六年仲秋,与你共读表格里的世间坐标

相关学习资料