前阵子接了一个三维园区的可视化需求甲方交付的原始数据不是常见的shp也不是GeoJSON而是一整套带样式引用的KML。我手里的渲染工程是基于earthsdk3搭的数据源模块只认GeoJSON对KML基本是不接单的状态。于是earthsdk3 kml转geojson这件事从一句搜索关键词变成了我一周的日常。这篇文章把整个转换过程拆开讲清楚。从KML的标签骨架讲到GeoJSON的对象结构再到一个能直接塞进Node脚本或前端工程里用的转换器实现最后是我在EarthSDK3里加载转换结果时踩过的几个坑。适合正好卡在这个环节的同行也适合刚接触三维地球数据处理的同学——照着我这个路子走至少能少绕我走过的这几道弯。1. 为什么非要把KML换成GeoJSON——格式差异与转换动机最早我确实想过直接在EarthSDK3里写一个KML解析器硬啃几天XML把它渲染成实体。但试着处理了几份KML之后我放弃了。不是解析不了而是KML这种格式在设计上就和前端实时的三维渲染需求不对付转换几乎是绕不开的一步。1.1 KML在实际项目里给我的三个麻烦第一KML是XML格式。XML的标签嵌套、命名空间、CDATA每一层都在增加解析负担。平时几十个Placemark看不出问题一旦数据量到了几千个要素DOMParser解析DOM树的时间肉眼可见地变长内存占用也上去了。而GeoJSON走的是JSON.parse浏览器原生级别的速度量级上去依然轻松。第二KML的样式是全局的。一个Document里先定义一堆带id的Style各个Placemark再用styleUrl去引用。这种样式与数据分离的设计在桌面GIS里很合理但在前端工程里就很别扭我遍历几何体的时候必须额外维护一张样式表渲染时再手动关联回去。GeoJSON的properties天生就可以挂任意属性样式信息直接跟着要素走前端拿起来就用。第三KML坐标是一段带逗号和空格的字符串不是结构化数组。比如116.1,39.1,0 116.2,39.1,0一段文本里既要分辨点内部用逗号又要分辨点与点之间用空白解析逻辑看着简单实际处理时全是边界情况。GeoJSON则全部是嵌套数组规规矩矩几乎没有歧义。1.2 GeoJSON到底好在哪以及转换后的实际收益GeoJSON这种格式其实就做对了两件事第一它把几何结构固定成Point/LineString/Polygon/Multi*这几种极少的类型前端代码可以用一个switch把所有情况覆盖掉第二它完全建立在前端天然支持的JSON之上不需要额外的解析器能直接进数据源。在EarthSDK3这个场景里转换的收益很直接对比维度KMLGeoJSON数据格式XML文本JSON对象几何表达Point/LineString/Polygon/MultiGeometryPoint/LineString/Polygon/MultiPolygon等坐标形式经度,纬度,高度逗号分隔点间空格分隔[经度, 纬度]嵌套数组样式载体全局Style styleUrl引用properties任意属性EarthSDK3数据源通常需要自写解析直接注册GeoJSON数据源前端处理成本XML DOM解析 样式映射JSON.parse 属性直读转换之后数据从一个需要反复解读XML语义的文档变成一个可以交给数据源直接注册的JSON对象。尤其在处理点、线、面混合数据时FeatureCollection结构能让每个几何体独立拥有id和properties后续做拾取、高亮、属性弹窗都方便得多。2. 转换前先认清KML的骨架标签、坐标与样式的映射写转换程序之前必须先搞清楚KML里每个节点到底在表达什么。这一节不铺垫理论直接拿一份真实KML片段拆开看。2.1 一份典型KML文件的逐段拆解下面是我从项目数据里抽出来的简化片段保留了最常见的结构?xml version1.0 encodingUTF-8? kml xmlnshttp://www.opengis.net/kml/2.2 Document name园区示意.kml/name Style idredStyle LineStyle colorff0000ff/color width3/width /LineStyle PolyStyle color80ff0000/color /PolyStyle /Style Folder name主楼区域/name Placemark name1号主楼/name styleUrl#redStyle/styleUrl Polygon outerBoundaryIs LinearRing coordinates 116.1,39.1,0 116.2,39.1,0 116.2,39.2,0 116.1,39.2,0 116.1,39.1,0 /coordinates /LinearRing /outerBoundaryIs /Polygon /Placemark /Folder /Document /kml这个文件提供了转换时需要的全部关键信息Document和Folder只是容器真正重要的对象是Placemark。每个Placemark对应GeoJSON里的一个Feature。name、description这类标签直接变成properties.name、properties.description。styleUrl是#redStyle引用的是上面Style idredStyle里定义的颜色和线宽。这里最容易被忽略的是样式定义可能离引用它的Placemark隔了很长的距离转换时必须先把所有Style扫一遍建立映射不能边遍历边查。Polygon里outerBoundaryIs是外环innerBoundaryIs是内环洞每个环都包在LinearRing里坐标写在coordinates里。coordinates里的点是经度,纬度,高度格式点与点之间由空格或换行分隔。高度可以省略省略时就是[lng, lat]两位数组。2.2 几何类型与GeoJSON要素的映射关系KML的几何标签和GeoJSON类型的对应关系可以整理成下面这张表KML节点GeoJSON类型说明PointPointcoordinates为单个经纬度数组LineStringLineStringcoordinates为经纬度数组的数组PolygonPolygoncoordinates为环数组第一个是外环后续是内环MultiGeometry按内容拆分为多个Feature我处理时优先拆兼容性最好几个容易踩的细节MultiGeometry里如果有多个Point也可以合并成MultiPoint如果多个Polygon可以合并成MultiPolygon。但我实测下来拆成多个Feature在EarthSDK3里最省心因为每个要素可以独立控制样式和拾取不用额外处理GeometryCollection这个GeoJSON里兼容性最差的类型。坐标顺序方面KML和GeoJSON的经纬度顺序是一致的都是经度在前纬度在后。这点很多初学者会犹豫想着要不要交换成纬度,经度千万别换换了位置就跑到海上去了。KML坐标里的高度字段GeoJSON允许放在第三位但很多前端三维引擎对带z坐标的GeoJSON处理并不可靠我建议先把高度剥离出来存到properties.altitude里后面要用再取。3. 手写一个可用的转换器核心步骤与代码下面这个转换器我一直在用支持Point、LineString、Polygon、MultiGeometry四种结构能处理样式引用和ExtendedData扩展属性。它依赖的DOMParser是浏览器和Node端都有的标准XML解析接口在Node脚本里配合xmldom/xmldom包使用即可。3.1 读取XML与Placemark遍历先把KML文件读进来解析成DOM树然后拿到所有Placemark节点const fs require(fs); const { DOMParser } require(xmldom/xmldom); function kmlToGeoJson(kmlFile, outputFile) { const kmlText fs.readFileSync(kmlFile, utf-8); const doc new DOMParser().parseFromString(kmlText, text/xml); const placemarkList doc.getElementsByTagName(Placemark); const styleMap collectStyles(doc); const features []; Array.from(placemarkList).forEach((placemark, index) { const properties extractProperties(placemark); const styleId (properties.styleUrl || ).replace(/^#/, ); const styleInfo styleMap[styleId]; if (styleInfo) { properties._kmlStyle styleInfo; properties.lineColor styleInfo.lineColor; properties.lineWidth styleInfo.lineWidth; properties.fillColor styleInfo.fillColor; } const geometries collectGeometries(placemark); if (geometries.length 0) return; if (geometries.length 1) { features.push(buildFeature(${properties.name || placemark}-${index}, properties, geometryToGeoJson(geometries[0]))); } else { geometries.forEach((geom, geomIndex) { const copiedProps { ...properties, partIndex: geomIndex }; features.push(buildFeature(${properties.name || placemark}-${index}-${geomIndex}, copiedProps, geometryToGeoJson(geom))); }); } }); const result { type: FeatureCollection, features }; fs.writeFileSync(outputFile, JSON.stringify(result, null, 2), utf-8); return result; }这里有一个关键设计多个几何体共享一份properties时拆出来的每个Feature都复制一份属性副本并且加一个partIndex字段区分批次。这样后续在EarthSDK3里做样式批量设置时能清楚知道哪些要素是从同一个Placemark拆出来的。3.2 属性提取与Element顺序的坑extractProperties负责把name、description、styleUrl和ExtendedData全部捞出来function extractProperties(placemark) { const props {}; const nameNode placemark.getElementsByTagName(name)[0]; const descNode placemark.getElementsByTagName(description)[0]; const styleUrlNode placemark.getElementsByTagName(styleUrl)[0]; if (nameNode) props.name nameNode.textContent.trim(); if (descNode) props.description descNode.textContent.trim(); if (styleUrlNode) props.styleUrl styleUrlNode.textContent.trim(); const extData placemark.getElementsByTagName(ExtendedData)[0]; if (extData) { const dataItems extData.getElementsByTagName(Data); Array.from(dataItems).forEach(item { const key item.getAttribute(name) || unknown; const valueNode item.getElementsByTagName(value)[0]; props[key] valueNode ? valueNode.textContent.trim() : ; }); } return props; }使用getElementsByTagName时有个顺序陷阱如果Placemark内部存在多层嵌套getElementsByTagName(name)可能取到子元素里的name而不是Placemark自己的name。在实际KML里Folder下有namePlacemark下也有nameDOM的getElementsByTagName是深度优先遍历的顺序并不保证。稳妥做法是直接遍历placemark.childNodes只取第一层元素节点按tagName匹配function extractProperties(placemark) { const props {}; Array.from(placemark.childNodes || []).forEach(child { if (child.nodeType ! 1) return; const tag child.tagName; if (tag name) props.name child.textContent.trim(); if (tag description) props.description child.textContent.trim(); if (tag styleUrl) props.styleUrl child.textContent.trim(); if (tag ExtendedData) { Array.from(child.getElementsByTagName(Data)).forEach(item { props[item.getAttribute(name) || unknown] item.textContent.trim(); }); } }); return props; }这个版本只认Placemark的直接子节点不会误抄Folder或者嵌套MultiGeometry里的同名标签。3.3 三类基础几何的坐标解析接下来是最核心的坐标解析逻辑。先递归收集Placemark下的几何节点再把每个几何节点转成GeoJSON对象const GEOMETRY_TAGS [point, linestring, polygon]; function collectGeometries(root, acc []) { Array.from(root.childNodes || []).forEach(child { if (child.nodeType ! 1) return; const tag child.tagName.toLowerCase(); if (tag multigeometry) { collectGeometries(child, acc); } else if (GEOMETRY_TAGS.includes(tag)) { acc.push(child); } }); return acc; } function geometryToGeoJson(geomNode) { const tag geomNode.tagName.toLowerCase(); if (tag point) { const coordText geomNode.getElementsByTagName(coordinates)[0].textContent; return { type: Point, coordinates: parseCoord(coordText) }; } if (tag linestring) { const coordText geomNode.getElementsByTagName(coordinates)[0].textContent; return { type: LineString, coordinates: parseCoordList(coordText) }; } if (tag polygon) { const outerNode geomNode.getElementsByTagName(outerBoundaryIs)[0]; const rings [parseBoundaryRing(outerNode)]; const innerNodes geomNode.getElementsByTagName(innerBoundaryIs); Array.from(innerNodes).forEach(inner rings.push(parseBoundaryRing(inner))); return { type: Polygon, coordinates: rings }; } throw new Error(Unsupported geometry tag: tag); } function parseCoord(coordText) { const firstToken coordText.trim().split(/\s/)[0]; const parts firstToken.split(,).map(Number); return parts.length 3 ? [parts[0], parts[1], parts[2]] : [parts[0], parts[1]]; } function parseCoordList(coordText) { return coordText.trim().split(/\s/).map(token { const parts token.split(,).map(Number); return parts.length 3 ? [parts[0], parts[1], parts[2]] : [parts[0], parts[1]]; }); } function parseBoundaryRing(boundaryNode) { const linearRing boundaryNode.getElementsByTagName(LinearRing)[0]; const coordText linearRing.getElementsByTagName(coordinates)[0].textContent; return parseCoordList(coordText); }这里最关键的是coordText.trim().split(/\s/)。KML的coordinates内部点与点之间可能用空格、换行、制表符任意混排如果图省事用split( )遇到换行符直接出NaN。trim()再配合split(/\s/)把所有空白统一成分割符是所有解析坑里性价比最高的一个修复。3.4 MultiGeometry拆解与Feature生成collectGeometries遇到multigeometry标签会递归下沉把里面所有的几何节点都收集到同一个数组里。所以一个含两个Point和一个Polygon的MultiGeometry最终会产出三个Feature每个要素使用同一份properties副本function buildFeature(id, properties, geometry) { return { type: Feature, id, properties, geometry }; }这种拆法的好处前面提过EarthSDK3对Feature级别的数据支持最完善可以在拾取时直接拿到id定位到原始要素。坏处是会多出一些重复属性但GeoJSON是文本格式这点冗余完全可接受。如果对输出体积敏感可以在生成时去掉_kmlStyle里的中间字段只保留最终需要的属性。3.5 样式表解析与KML颜色转换样式解析和几何解析是两条线先扫描所有Style id...建立映射再按需引用function collectStyles(doc) { const styleMap {}; Array.from(doc.getElementsByTagName(Style)).forEach(styleNode { const id styleNode.getAttribute(id); if (!id) return; const lineColor readNestedText(styleNode, LineStyle, color); const lineWidth readNestedText(styleNode, LineStyle, width); const fillColor readNestedText(styleNode, PolyStyle, color); const fill readNestedText(styleNode, PolyStyle, fill); styleMap[id] { lineColor, lineWidth, fillColor, fill }; }); return styleMap; } function readNestedText(styleNode, ...tags) { let node styleNode; for (const tag of tags) { node Array.from(node.childNodes || []) .filter(n n.nodeType 1) .find(n n.tagName tag); if (!node) return undefined; } return node.textContent.trim(); }拿到KML颜色之后必须做一次格式转换。KML的颜色是aabbggrr八个十六进制字符顺序是透明度、蓝、绿、红和Web常见的#rrggbb完全相反function kmlColorToCss(aabbggrr) { if (!aabbggrr || aabbggrr.length 8) return #FFFFFF; const alpha aabbggrr.slice(0, 2); const blue aabbggrr.slice(2, 4); const green aabbggrr.slice(4, 6); const red aabbggrr.slice(6, 8); const r parseInt(red, 16); const g parseInt(green, 16); const b parseInt(blue, 16); const a parseInt(alpha, 16) / 255; return rgba(${r}, ${g}, ${b}, ${a.toFixed(2)}); }这根链接前面说的样式映射串了起来properties._kmlStyle里存的是原始KML颜色值而properties.lineColor、properties.fillColor存的是转好的CSS颜色字符串EarthSDK3里可以直接喂给材质对象。4. EarthSDK3加载转换结果数据源注册与样式还原转换器跑通之后GeoJSON文件落地接下来就要让earthsdk3把它加载进三维场景。这一节讲数据源怎么注册、样式怎么还原以及坐标顺序的检查方法。4.1 数据源注册的最小可用代码EarthSDK3的具体构造方式和数据源API在不同发布版里有差异但套路是一致的先实例化一个地球对象再通过数据源管理对象注册GeoJSON。下面这个骨架我用的是最常见的形态你们按自己项目里实例化地球的那一行替换即可const earth new EarthSDK.Earth(mapDiv, { // 相机、地形、底图等基础配置 }); const response await fetch(./converted.geojson); const geojson await response.json(); const dataSource await earth.dataSources.addGeoJsonData(geojson); // 如果SDK版本支持直接传URL也可以这样 // const dataSource await earth.dataSources.addGeoJson(./converted.geojson);拿到dataSource之后EarthSDK3会把GeoJSON里的每个Feature自动变成一个可渲染实体。此时需要注意一点GeoJSON标准本身不携带视觉样式所以如果KML文件里定义了红色边框、半透明填充转换后并不会自动还原。必须手动遍历实体把转换器写入的属性套到实体样式上。4.2 用_kmlStyle属性还原KML样式我在转换器设计阶段就刻意让属性字段自描述properties._kmlStyle里存了完整的样式信息properties.lineColor存了可直接使用的CSS颜色。加载之后遍历实体做样式赋值dataSource.entities.values.forEach(entity { const properties entity.properties ? entity.properties.getValue() : {}; const kmlStyle properties._kmlStyle; if (!kmlStyle) return; const lineColor kmlStyle.lineColor || #FFFFFF; const lineWidth Number(kmlStyle.lineWidth || 2); const fillColor kmlStyle.fillColor || #FFFFFF; if (entity.polygon) { entity.polygon.material Color.fromCssColorString(fillColor); entity.polygon.outline true; entity.polygon.outlineColor Color.fromCssColorString(lineColor); entity.polygon.outlineWidth lineWidth 0 ? lineWidth : 1; } if (entity.polyline) { entity.polyline.material Color.fromCssColorString(lineColor); entity.polyline.width lineWidth; } if (entity.point) { entity.point.pixelSize 8; entity.point.color Color.fromCssColorString(fillColor); entity.point.outlineColor Color.fromCssColorString(lineColor); entity.point.outlineWidth 1.5; } });这种还原方式虽然绕了一步但好处是转换器不用关心SDK的内部实现产出的GeoJSON可以同时兼容Leaflet、Mapbox和EarthSDK3等多种渲染环境。如果你想在SDK里做数据源级封装也可以把这段样式逻辑做进一个工具函数后续加载任意GeoJSON都能复用。4.3 CRS与坐标顺序检查清单KML坐标使用的参考系是WGS84经纬度GeoJSON同样如此所以不需要做投影换带但有两项检查我每次都会做第一坐标顺序。KML和GeoJSON都是经度在前、纬度在后这一点不用交换。如果你发现转换后的数据整体偏移、跑到海里去大概率是某个中间环节把[lat, lng]当成[lng, lat]了。定位方法很简单取转换结果里第一个Feature的坐标和原始KML的coordinates文本对比如果经纬度对调了在解析函数里把顺序换回来。第二坐标范围合理性。在转换脚本里加一个极值统计打印所有点的lng和lat范围快速确认数据是否在一个合理的区域内const lngs []; const lats []; JSON.stringify(result).match(/\[([^,]),([^\]])\]/g).forEach(m { const parts m.replace(/[\[\]]/g, ).split(,); lngs.push(Number(parts[0])); lats.push(Number(parts[1])); }); console.log(lng range:, Math.min(...lngs), -, Math.max(...lngs)); console.log(lat range:, Math.min(...lats), -, Math.max(...lats));如果你的数据应该在杭州结果却跨度好几万公里那就是坐标顺序或投影出了问题趁早排查别等渲染出来再对着飞走的地标抓狂。5. 实测中踩过的坑与处理清单这章是实际项目里最容易翻车的地方。转换本身不难难的是转换完以后数据能用、样式不丢、位置不飘。我把调试过程中最花时间的四类问题整理出来按排查优先级排好。5.1 坐标字符串里的空白字符不是只有空格我调试得最久的一次问题出在一份KML的坐标字符串里混着Windows的\r\n换行和Tab制表符。当时偷懒用了split( )结果坐标数组里出现了一大堆NaN转换结果直接全灭。统计下来坐标文本里的空白字符基本是这三种排列组合普通空格、换行、制表符。trim()之后用split(/\s/)是最稳的处理方式const tokens coordText.trim().split(/\s/);另外还有一个小坑某些KML导出工具会在坐标里加一个看不见的BOM头或者多余空行所以parseCoordList里第一步必须是trim()否则第一个坐标会带上不可见字符转成NaN。5.2 KML颜色是AABBGGRR不是Web颜色这个坑非常隐蔽。KML的colorffff0000/color按直觉理解应该是红色但实际上它的通道顺序是alpha、蓝、绿、红所以ffff0000是纯蓝色。我在第一版转换器里直接把textContent原样塞给EarthSDK3的材质结果所有多边形都变成了诡异的蓝色排查了半小时才反应过来是颜色通道顺序的问题。ff0000ff这个值最迷惑人——它转换出来恰好是红色会让人误以为格式跟Web一致。一旦遇到蓝色、绿色等非巧合的颜色就原形毕露。正确的做法永远是用kmlColorToCss做通道重排不要把原始值拿来直接用。另一个坑是透明度KML里80ff0000表示半透明蓝alpha是80十六进制128约50%透明转换后是rgba(0, 0, 255, 0.50)。如果只是想普通显示alpha转换错了会导致多边形看起来消失了因为alpha为0时任何颜色都不可见。5.3 带洞多边形与环的方向问题KML的Polygon可以同时有outerBoundaryIs和多个innerBoundaryIsGeoJSON的Polygon坐标第一层是外环后面是内环。转换时环的顺序不对渲染出来的多边形会被裁掉一大块。GeoJSON规范要求外环是逆时针方向右手定则内环是顺时针方向。KML没有这个强制要求导出工具各写各的。严谨的做法是转换后做一个方向校验function ringSignedArea(ring) { let area 0; for (let i 0; i ring.length; i) { const [x1, y1] ring[i]; const [x2, y2] ring[(i 1) % ring.length]; area x1 * y2 - x2 * y1; } return area / 2; } // 外环强制逆时针 if (ringSignedArea(outerRing) 0) outerRing.reverse(); // 内环强制顺时针 innerRings.forEach(ring { if (ringSignedArea(ring) 0) ring.reverse(); });用鞋带公式计算有向面积在普通经纬度范围内足够可靠。跨日期变更线和极区的极端数据会失真但那些数据本身也不适合直接用GeoJSON在前端渲染。环的闭合也很重要。KML的LinearRing首尾两点通常相同GeoJSON不强制闭合但建议保留闭合。如果某个KML导出工具把首尾点去掉了转换后EarthSDK3渲染多边形可能出现一条细缝建议在处理时做一步闭合检查。5.4 高度、贴地与extrude语义KML里每个坐标可以带高度值还有一个容易忽略的altitudeMode标签常见值是clampToGround贴地、relativeToGround相对地面、absolute绝对海拔。如果转换时不管三七二十一把所有高度都塞进GeoJSON的[lng, lat, alt]里EarthSDK3会把高度理解成绝对的几何高度一个本来是贴地的面片可能直接飞到半空。我的处理原则是如果KML没有明确使用absolute或relativeToGround默认把高度从坐标里剥离存入properties.altitude。只有确定了高度模式再接回实体的高度属性。同时PolyStyle里的extrude标签表示是否拉伸成体这个语义GeoJSON完全没有对应物也需要先落到properties.extrude再由SDK层决定用拉伸体还是平面渲染。在大多数园区、城市的可视化场景里贴地显示就够了。执着于把每个高度都转出来反而会在SDK里引入一堆不可控的渲染问题。最后分享一点个人体会。这个转换器大概只写了200行但调试真正花时间的全在样式、嵌套和高度语义上纯几何转换反而一天就搞完了。如果你也想做类似转换我建议先把测试KML分成纯点、纯线、纯面、带样式、带嵌套五类数据分别跑通再处理大文件。一次性拿大文件调试出了问题很难定位是解析问题、样式问题还是SDK渲染问题。分开测每一步的边界都清清楚楚效率高得多。