一、前言
行政区划边界是 GIS 大屏与政务可视化中最常见的底图元素。如何在三维地球上把「长沙市 9 个区县」的边界画得又准又好看,并且做出流动的高科技感?本文给出一个零构建、纯前端的完整方案:
- 调用天地图行政区划接口
/v2/administrative获取长沙 9 个区县的 WKT 边界; - 手写解析器把
MULTIPOLYGON解析为外环 + 洞环,绘制填充面 + 统一边界线 + 中心点标签; - 用 Cesium 自定义材质(Fabric Shader) 实现边界上的金色流光(亮头 + 渐隐拖尾的「金色彗星」沿边界滚动);
- 实现悬停高亮、点击区县边界闪烁 3 秒、图例联动等交互。
值得一提的是,本文绝大部分代码(接口调用、WKT 解析算法、GLSL 流光 Shader、闪烁交互)都是在 CodeBuddy(AI 编程助手) 的辅助下完成------后文我会单独盘点 AI 在其中发挥的作用。
二、最终效果
打开页面后,三维地球自动定位到长沙上空,整体效果如下:
- 长沙 9 个区县以不同颜色半透明填充面 呈现,边界线统一为金色流光,光点沿边界持续滚动;
- 鼠标悬停某个区县时,该区县填充加深、边界加粗变青,实现联动高亮;
- 点击 某个区县时,其边界在 3 秒内快速闪烁(青色高亮 ↔ 金色流光交替),随后恢复常态;
- 左侧面板提供填充 / 边界 / 区县名 / 流动边界 开关,以及图例列表,点击图例相机飞至对应区县。

图1:页面加载后的全景效果,9 个区县填充面配色区分。

图2:放大后可看到(亮头 + 渐隐拖尾)沿边界线滚动。
三、运行时序图
下图展示页面从加载到完成行政区划绘制、再到用户交互的完整时序:
天地图行政区划接口(/v2/administrative) Cesium/天地图三维引擎 js/changsha-admin.js changsha-admin.html 浏览器 天地图行政区划接口(/v2/administrative) Cesium/天地图三维引擎 js/changsha-admin.js changsha-admin.html 浏览器 #mermaid-svg-8SzpFiaKhMBX0ccX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8SzpFiaKhMBX0ccX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8SzpFiaKhMBX0ccX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8SzpFiaKhMBX0ccX .error-icon{fill:#552222;}#mermaid-svg-8SzpFiaKhMBX0ccX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8SzpFiaKhMBX0ccX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8SzpFiaKhMBX0ccX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8SzpFiaKhMBX0ccX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8SzpFiaKhMBX0ccX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8SzpFiaKhMBX0ccX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8SzpFiaKhMBX0ccX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8SzpFiaKhMBX0ccX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8SzpFiaKhMBX0ccX .marker.cross{stroke:#333333;}#mermaid-svg-8SzpFiaKhMBX0ccX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8SzpFiaKhMBX0ccX p{margin:0;}#mermaid-svg-8SzpFiaKhMBX0ccX .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8SzpFiaKhMBX0ccX text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-8SzpFiaKhMBX0ccX .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-8SzpFiaKhMBX0ccX .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-8SzpFiaKhMBX0ccX .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-8SzpFiaKhMBX0ccX .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-8SzpFiaKhMBX0ccX #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-8SzpFiaKhMBX0ccX .sequenceNumber{fill:white;}#mermaid-svg-8SzpFiaKhMBX0ccX #sequencenumber{fill:#333;}#mermaid-svg-8SzpFiaKhMBX0ccX #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-8SzpFiaKhMBX0ccX .messageText{fill:#333;stroke:none;}#mermaid-svg-8SzpFiaKhMBX0ccX .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8SzpFiaKhMBX0ccX .labelText,#mermaid-svg-8SzpFiaKhMBX0ccX .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-8SzpFiaKhMBX0ccX .loopText,#mermaid-svg-8SzpFiaKhMBX0ccX .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-8SzpFiaKhMBX0ccX .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-8SzpFiaKhMBX0ccX .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-8SzpFiaKhMBX0ccX .noteText,#mermaid-svg-8SzpFiaKhMBX0ccX .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-8SzpFiaKhMBX0ccX .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8SzpFiaKhMBX0ccX .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8SzpFiaKhMBX0ccX .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-8SzpFiaKhMBX0ccX .actorPopupMenu{position:absolute;}#mermaid-svg-8SzpFiaKhMBX0ccX .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-8SzpFiaKhMBX0ccX .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-8SzpFiaKhMBX0ccX .actor-man circle,#mermaid-svg-8SzpFiaKhMBX0ccX line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-8SzpFiaKhMBX0ccX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 交互:悬停高亮 / 点击边界闪烁3秒 / 图例联动 / 开关显隐 加载页面 1 加载天地图 Cesium CDN 脚本 + css/style.css 2 执行 js/changsha-admin.js 3 1. new Cesium.Map() 初始化三维球 4 2. 叠加影像(img_w)/国界(ibo_w) 5 3. 定义金色流光材质 FLOW_MATERIAL(Fabric Shader) 6 4. camera.setView(长沙上空) 7 5. fetchAdmin('长沙市', childLevel=1) 8 返回 9 个区县(名称/编码/中心点) 9 6. 逐区县 fetchAdmin(gb, childLevel=0) 获取 boundary 10 返回各区县 WKT MULTIPOLYGON 11 7. parseWktPolygons() 解析外环+洞环 12 8. addDistrict() 绘制填充面+流光边界+标签 13 9. renderLegend() 渲染图例 14 10. flyToOverview() 总览取景 15
四、准备工作
-
申请 Token
前往 天地图 Key 控制台 免费申请,将代码中的
token替换为你自己的 Key(建议使用「浏览器端」类型,并勾选行政区划服务)。 -
依赖说明
本例直接使用天地图官方 CDN 封装的 Cesium 及插件(
Cesium.js、Cesium_ext_min.js等),无需 npm 安装 。Cesium.Map是天地图对Cesium.Viewer的封装。 -
运行环境
需要联网(瓦片与行政区划接口均为在线服务)。用任意静态服务器托管即可:
bashcd case20260924 python -m http.server 8080 # 浏览器访问 http://localhost:8080/changsha-admin.html
五、工程结构
case20260924/
├── changsha-admin.html # 页面骨架(CDN 引用 + 面板 + 图例容器)
├── css/
│ └── style.css # 复用项目统一样式(面板等)
└── js/
└── changsha-admin.js # 主逻辑:初始化 + 行政区划 + 边界流光 + 闪烁交互

图3:项目文件结构,HTML 页面 + 复用样式 + 主逻辑脚本。
六、代码实现
1. changsha-admin.html
页面骨架:引入天地图 Cesium 脚本,放置三维容器 #cesiumContainer、控制面板 #searchPanel(含四个显隐开关 + 总览按钮)以及图例容器 #legendList。
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>长沙市区县行政区划</title>
<script type="text/javascript" cesium="true" src="https://api.tianditu.gov.cn/cdn/demo/sanwei/static/cesium/Cesium.js"></script>
<script type="text/javascript" cesium="true" src="https://api.tianditu.gov.cn/cdn/plugins/cesium/Cesium_ext_min.js"></script>
<script type="text/javascript" cesium="true" src="https://api.tianditu.gov.cn/cdn/plugins/cesium/long.min.js"></script>
<script type="text/javascript" cesium="true" src="https://api.tianditu.gov.cn/cdn/plugins/cesium/bytebuffer.min.js"></script>
<script type="text/javascript" cesium="true" src="https://api.tianditu.gov.cn/cdn/plugins/cesium/protobuf.min.js"></script>
<link rel="stylesheet" cesium="true" href="https://api.tianditu.gov.cn/cdn/demo/sanwei/static/cesium/Widgets/widgets.css">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div id="cesiumContainer"></div>
<div id="searchPanel">
<div class="sp-header">
<div class="sp-title">长沙市区县行政区划</div>
<div class="sp-sub">数据源:天地图行政区划接口(v2/administrative)</div>
<button id="btnTogglePanel" class="sp-toggle" title="收起/展开面板">-</button>
</div>
<div class="sp-body">
<div id="adminStatus" class="sp-status">正在加载...</div>
<div class="sp-row">
<label><input id="chkFill" type="checkbox" checked /> 填充</label>
<label><input id="chkBorder" type="checkbox" checked /> 边界</label>
<label><input id="chkLabel" type="checkbox" checked /> 区县名</label>
<label><input id="chkFlow" type="checkbox" checked /> 流动边界</label>
<button id="btnOverview" class="btn">总览</button>
</div>
<div id="legendList"></div>
</div>
</div>
<script src="js/changsha-admin.js"></script>
</body>
</html>
2. 基础配置与 Viewer 初始化
配置天地图 Token、服务域名与子域;Cesium.Map 初始化时开启 shouldAnimate ,并额外加一个 requestAnimationFrame 兜底,确保自定义材质里的 czm_frameNumber(帧计数)能逐帧递增、流光持续流动。
javascript
var token = "你的天地图Token";
var tdtUrl = 'https://t{s}.tianditu.gov.cn/';
var subdomains = ['0', '1', '2', '3', '4', '5', '6', '7'];
var ADMIN_KEYWORD = '长沙市';
var ADMIN_CENTER = [112.933, 28.231]; // 长沙市中心(经度, 纬度)
// 创建天地图 Cesium 三维球实例
var viewer = new Cesium.Map('cesiumContainer', {
shouldAnimate: true,
selectionIndicator: false,
baseLayerPicker: false,
fullscreenButton: false,
geocoder: false,
homeButton: false,
infoBox: false,
sceneModePicker: false,
timeline: false,
navigationHelpButton: false,
navigationInstructionsInitiallyVisible: false,
showRenderLoopErrors: false,
shadows: false
});
// 显式开启时钟动画 + requestAnimationFrame 兜底,保证 czm_frameNumber 逐帧递增
viewer.clock.shouldAnimate = true;
(function flowTick() {
viewer.scene.requestRender();
requestAnimationFrame(flowTick);
})();
viewer.scene.fxaa = true;
viewer.scene.globe.showGroundAtmosphere = true;
// 限制相机俯角,避免贴地导致边界看不清
viewer.scene.screenSpaceCameraController.constrainedPitch = Cesium.Math.toRadians(-20);
// 影像底图
viewer.imageryLayers.addImageryProvider(new Cesium.UrlTemplateImageryProvider({
url: tdtUrl + 'DataServer?T=img_w&x={x}&y={y}&l={z}&tk=' + token,
subdomains: subdomains,
tilingScheme: new Cesium.WebMercatorTilingScheme(),
maximumLevel: 18
}));
// 国界注记
viewer.imageryLayers.addImageryProvider(new Cesium.UrlTemplateImageryProvider({
url: tdtUrl + 'DataServer?T=ibo_w&x={x}&y={y}&l={z}&tk=' + token,
subdomains: subdomains,
tilingScheme: new Cesium.WebMercatorTilingScheme(),
maximumLevel: 10
}));
// 行政区划专题图不使用地形高程:填充面与边界线都绘制在椭球面上
viewer.scene.globe.depthTestAgainstTerrain = false;
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(ADMIN_CENTER[0], ADMIN_CENTER[1], 260000)
});
3. 行政区划接口调用
fetchAdmin 调用天地图行政区划接口。keyword 支持名称或区划编码,childLevel 控制下级级数,extensions=true 时查询目标自身会返回 boundary(WKT 轮廓)。
javascript
function fetchAdmin(keyword, childLevel, cb) {
var url = 'https://api.tianditu.gov.cn/v2/administrative?keyword='
+ encodeURIComponent(keyword)
+ '&childLevel=' + childLevel
+ '&extensions=true&tk=' + token;
fetch(url)
.then(function (resp) { return resp.json(); })
.then(function (data) {
if (data && data.status === 200 && data.data
&& data.data.district && data.data.district.length) {
cb(data.data.district[0]);
} else {
cb(null);
}
})
.catch(function () { cb(null); });
}
关键点:先查「长沙市」(
childLevel=1)拿到 9 个区县的名称 / 编码 / 中心点;再按每个区县的gb编码逐个查询(childLevel=0),才能拿到各自的boundary边界数据。
4. WKT 边界解析(核心算法)
天地图返回的边界是 WKT 格式(POLYGON / MULTIPOLYGON),需要手写解析器拆出外环 + 洞环:
javascript
function parseWktPolygons(wkt) {
var polys = [];
if (!wkt || typeof wkt !== 'string') return polys;
wkt = wkt.trim();
var m = wkt.match(/^(MULTIPOLYGON|POLYGON)/i);
if (!m) return polys;
var isMulti = (m[1].toUpperCase() === 'MULTIPOLYGON');
var body = wkt.slice(wkt.indexOf('('));
// 1) 提取所有环(最内层括号内的坐标串)
var rings = [];
var re = /\(([^()]*)\)/g, rm;
while ((rm = re.exec(body)) !== null) {
var pts = [];
var seg = rm[1].split(',');
for (var k = 0; k < seg.length; k++) {
var pair = seg[k].trim().split(/\s+/);
if (pair.length >= 2) {
var lng = parseFloat(pair[0]);
var lat = parseFloat(pair[1]);
if (isFinite(lng) && isFinite(lat)) pts.push([lng, lat]);
}
}
if (pts.length) rings.push(pts);
}
// 2) 通过括号深度统计每个多边形包含的环数
var counts = [];
var depth = 0, curCount = 0;
var containerDepth = isMulti ? 1 : 0;
for (var i = 0; i < body.length; i++) {
var ch = body[i];
if (ch === '(') {
depth++;
if (depth === containerDepth + 1) { curCount = 0; }
else if (depth === containerDepth + 2) { curCount++; }
} else if (ch === ')') {
if (depth === containerDepth + 1) { counts.push(curCount); }
depth--;
}
}
// 3) 按环数组装多边形:首环为外环,其余为洞
var idx = 0;
for (var p = 0; p < counts.length; p++) {
var c = counts[p];
if (c <= 0) continue;
var poly = { outer: null, holes: [] };
for (var r = 0; r < c; r++) {
var ring = rings[idx++];
if (!poly.outer) poly.outer = ring;
else poly.holes.push(ring);
}
polys.push(poly);
}
return polys;
}
这个解析器用「正则提取坐标环 + 括号深度统计」两步完成,无需引入第三方 WKT 库,是本例的关键之一(由 CodeBuddy 生成)。
5. 边界绘制:填充面 + 流光边界 + 标签
每个区县由「半透明填充面(独立配色)+ 金色流光边界线(外环 + 洞环)+ 中心点名称标签」组成,三者共用同一组椭球面顶点,保证边界完全贴合。
javascript
function addDistrict(info, color) {
var name = info.name;
var polys = parseWktPolygons(info.boundary);
var reg = { info: info, color: color, fill: [], line: [], label: null };
polys.forEach(function (poly) {
if (!poly.outer || poly.outer.length < 3) return;
var outer = ringToCartesian(poly.outer);
var holes = [];
poly.holes.forEach(function (h) {
if (h.length >= 3) holes.push(new Cesium.PolygonHierarchy(ringToCartesian(h)));
});
// ① 填充面
var fill = viewer.entities.add({
polygon: {
hierarchy: new Cesium.PolygonHierarchy(outer, holes),
material: color.withAlpha(FILL_ALPHA)
}
});
fill._distName = name;
reg.fill.push(fill);
// ② 边界线:外环 + 洞环,统一使用金色流光材质
var rings = [poly.outer].concat(poly.holes);
rings.forEach(function (ring) {
if (ring.length < 2) return;
var pts = closeRing(ringToCartesian(ring));
var line = viewer.entities.add({
polyline: {
positions: pts,
width: BORDER_WIDTH,
material: FLOW_MATERIAL
}
});
line._distName = name;
reg.line.push(line);
});
});
// ③ 中心点名标签
if (info.center) {
var label = viewer.entities.add({
position: Cesium.Cartesian3.fromDegrees(info.center.lng, info.center.lat, 0),
label: {
text: name,
font: 'bold 15px "Microsoft YaHei", sans-serif',
fillColor: Cesium.Color.WHITE,
outlineColor: Cesium.Color.fromCssColorString('#0b1f33'),
outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
pixelOffset: new Cesium.Cartesian2(0, -10),
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM
}
});
label._distName = name;
reg.label = label;
}
districtRegistry[name] = reg;
}

图4:9 个区县填充面配色区分,中心点显示区县名标签。
6. 金色流光材质(重点)
流光效果通过 Cesium 自定义材质(Fabric Shader) 实现。核心是 czm_getMaterial 里的 GLSL:
javascript
var FLOW_COUNT = 26.0; // 每条边界上的金色光点数量(越大越密)
var FLOW_SPEED = 0.08; // 流动速度(相位增量/帧,越大越快)
var FLOW_BASE_ALPHA = 0.30; // 暗段(拖尾)透明度
var FLOW_FALLOFF = 2.5; // 拖尾衰减指数
var FLOW_COLOR = Cesium.Color.fromCssColorString('#ffd700'); // 金色
var FLOW_MATERIAL_SOURCE =
'czm_material czm_getMaterial(czm_materialInput materialInput)\n' +
'{\n' +
' czm_material material = czm_getDefaultMaterial(materialInput);\n' +
' vec2 st = materialInput.st;\n' +
' float phase = st.s * flowCount + czm_frameNumber * flowSpeed;\n' +
' float t = fract(phase);\n' +
' float band = pow(1.0 - t, flowFalloff);\n' +
' vec3 dimGold = color.rgb * 0.22;\n' +
' vec3 brightGold = mix(color.rgb, vec3(1.0), 0.7);\n' +
' material.diffuse = mix(dimGold, brightGold, band);\n' +
' material.alpha = mix(flowBaseAlpha, 1.0, band);\n' +
' return material;\n' +
'}';
var FLOW_MATERIAL = new Cesium.Material({
fabric: {
type: 'PolylineFlow',
uniforms: {
color: FLOW_COLOR,
flowCount: FLOW_COUNT,
flowSpeed: FLOW_SPEED,
flowBaseAlpha: FLOW_BASE_ALPHA,
flowFalloff: FLOW_FALLOFF
},
source: FLOW_MATERIAL_SOURCE
}
});
// 显式标记半透明,确保 alpha 被正确混合
FLOW_MATERIAL.translucent = true;
Shader 逻辑拆解:
st.s是沿线方向的纹理坐标,st.s * flowCount把整条线分成 26 段,每段一颗「彗星」;czm_frameNumber * flowSpeed随时间推进相位,fract()取小数得到周期内位置t;pow(1.0 - t, flowFalloff)让头部(t≈0)最亮、向尾部快速衰减,形成亮头 + 渐隐拖尾;dimGold/brightGold混合出金色层次,material.alpha让暗段半透明,突出流动感。
7. 高亮与「点击闪烁 3 秒」交互
交互分三层:常态(金色流光 / 白色)、悬停高亮(填充加深 + 边界变青)、点击闪烁(边界在 3 秒内亮暗交替后恢复)。
javascript
var _hoverName = null;
var _selectedName = null; // 当前处于「点击闪烁」的区县名
var _flashOn = false;
var _flashTimer = null;
var _flashInterval = null;
var FLASH_DURATION = 3000; // 闪烁总时长(毫秒)
var FLASH_INTERVAL = 220; // 亮/暗切换间隔(毫秒)
function setDistrictHighlight(name, on) {
var reg = districtRegistry[name];
if (!reg) return;
reg.fill.forEach(function (f) {
f.polygon.material = reg.color.withAlpha(on ? FILL_ALPHA_HL : FILL_ALPHA);
});
reg.line.forEach(function (l) {
l.polyline.width = on ? BORDER_WIDTH_HL : BORDER_WIDTH;
l.polyline.material = on ? HIGHLIGHT_COLOR : normalBorderMaterial();
});
if (reg.label) reg.label.label.scale = on ? 1.25 : 1.0;
}
// 边界线常态材质:勾选「流动边界」用流光材质,否则纯白
function normalBorderMaterial() {
return document.getElementById('chkFlow').checked ? FLOW_MATERIAL : BORDER_COLOR;
}
// 只切换边界的亮/暗(闪烁用),不动填充与标签
function setBorderFlash(name, on) {
var reg = districtRegistry[name];
if (!reg) return;
reg.line.forEach(function (l) {
l.polyline.width = on ? BORDER_WIDTH_HL : BORDER_WIDTH;
l.polyline.material = on ? HIGHLIGHT_COLOR : normalBorderMaterial();
});
}
function clearFlash() {
if (_flashInterval) { clearInterval(_flashInterval); _flashInterval = null; }
if (_flashTimer) { clearTimeout(_flashTimer); _flashTimer = null; }
}
// 点击区县:填充高亮 + 标签放大,边界高亮/常态交替闪烁 FLASH_DURATION
function flashDistrict(name) {
clearFlash();
if (_selectedName && _selectedName !== name) setDistrictHighlight(_selectedName, false);
_selectedName = name;
if (!name) return;
setDistrictHighlight(name, true);
_flashOn = true;
_flashInterval = setInterval(function () {
_flashOn = !_flashOn;
setBorderFlash(name, _flashOn);
}, FLASH_INTERVAL);
_flashTimer = setTimeout(function () {
clearFlash();
if (_hoverName === name) {
setDistrictHighlight(name, true); // 鼠标仍悬停:稳定为悬停高亮
} else {
setDistrictHighlight(name, false); // 恢复常态
}
_selectedName = null;
}, FLASH_DURATION);
}
拾取绑定:鼠标移动做悬停高亮,左键点击触发闪烁:
javascript
var handler = new Cesium.ScreenSpaceEventHandler(viewer.canvas);
handler.setInputAction(function (movement) {
var picked = viewer.scene.pick(movement.endPosition);
var name = pickDistrictName(picked);
setHover(name);
viewer.canvas.style.cursor = name ? 'pointer' : 'default';
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
handler.setInputAction(function (click) {
var picked = viewer.scene.pick(click.position);
var name = pickDistrictName(picked);
if (name) {
var reg = districtRegistry[name];
flashDistrict(name);
if (reg && reg.info) setStatus(name + '(编码 ' + reg.info.gb + ')');
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);

图5:点击区县后,其边界不同颜色之间快速交替闪烁 3 秒。
七、核心知识点讲解
1. 行政区划接口 /v2/administrative
keyword可为名称或区划编码,childLevel控制返回的下级层数;- 只有
extensions=true时,查询目标自身才返回boundary(WKT),子级默认只有名称 / 编码 / 中心点; - 因此要拿到 9 个区县的边界,需「先查市 → 再按
gb逐个查区县」两段式调用。
2. WKT MULTIPOLYGON 的解析思路
WKT 里 MULTIPOLYGON(((外环1),(洞1)),((外环2))) 括号嵌套层次分明,用「正则提取最内层坐标串 + 括号深度计数」即可还原出「每个多边形包含哪些环」。首环为外环,其余为洞,交给 Cesium.PolygonHierarchy。
3. Cesium 自定义材质(Fabric)与流光
Cesium.Material({ fabric: { uniforms, source } }) 的 source 就是一段 czm_getMaterial GLSL 函数,会被塞进片元着色器使用。
关键坑点(本文踩过) :多段线材质里
materialInput.st.s是沿线 0~1 的归一化纹理坐标 ,不是「米 / 像素」。若按直觉写成st.s / 间距(除以一个几十上百的数),整条线的相位几乎不变,结果是只有一个光点、流动慢到肉眼看不出 ,看起来就像「没生效」。正确做法是用乘法st.s * 光点数量来产生多个重复光点。
4. czm_frameNumber 与动画兜底
czm_frameNumber 是 Cesium 自动注入的「帧计数」uniform,只有渲染循环持续运行时才会递增。为保证流光持续流动:
- 初始化时
shouldAnimate: true; - 额外用
requestAnimationFrame循环调用viewer.scene.requestRender()兜底,兼容天地图Cesium.Map某些场景下默认渲染循环未持续运行的情况。
5. 点击闪烁的实现要点
闪烁用「两个定时器」控制:setInterval 每 220ms 切换边界亮 / 暗,setTimeout 在 3000ms 后清空并恢复常态。切换函数 setBorderFlash 只动边界,避免干扰填充与标签状态。
八、常见问题与踩坑记录
| 问题 | 原因 / 解决 |
|---|---|
| 流光不动、几乎只有一个点 | st.s 是 0~1 归一化坐标,误用「除以间距」;应改为「st.s * 光点数量」 |
| 流光不流动(静态金色) | czm_frameNumber 未递增:确认 shouldAnimate: true,必要时加 requestAnimationFrame 兜底 requestRender() |
| 边界完全透明/不显示 | 材质半透明未生效:设置 FLOW_MATERIAL.translucent = true |
| 区县边界查询不到 | 需两段式调用:先查市(childLevel=1)拿子级 gb,再逐个查区县(childLevel=0) |
| 接口返回 status 非 200 | 确认 Token 已启用「行政区划」服务,且为浏览器端类型 |
| 边界与填充错位 | 填充面与边界线应共用同一组椭球面顶点,且不开启地形贴地 |
九、CodeBuddy 与 TdtCesium 能力盘点
TdtCesium 提供了什么
本文依赖天地图封装的 Cesium(Cesium.Map)能力:
- 开箱即用的三维引擎 :
Cesium.Map一行初始化三维球,叠加影像、国界注记等服务; - 行政区划服务 :
/v2/administrative免费返回区划边界 WKT,配合Cesium.PolygonHierarchy/Polyline即可成图; - 完整的材质体系 :支持
Cesium.Material自定义 Fabric Shader,让边界流光这类 GPU 动画成为可能; - 实体 API :
Polygon/Polyline/Label实体 +ScreenSpaceEventHandler拾取,交互开发成本极低。
CodeBuddy(AI 编程助手)帮了什么
在整个开发过程中,CodeBuddy 承担了从需求到代码的大部分工作:
- 接口与解析 :根据「加载长沙区县边界」这一句话,自动生成
/v2/administrative的两段式调用,以及MULTIPOLYGON的 WKT 解析器(正则提取 + 括号深度计数); - GPU 特效 :手写
czm_getMaterialGLSL,实现「亮头 + 渐隐拖尾」的金色彗星流光; - Bug 定位 :当「流光没生效」时,快速定位到
st.s归一化坐标的误用,并给出st.s * 光点数量的修正方案; - 交互实现:用双定时器实现「点击边界闪烁 3 秒」的完整逻辑。

图6:在 CodeBuddy 中通过自然语言描述需求,AI 生成接口调用的代码。
体会上,AI 更适合作为「有经验的前端 / GLSL 伙伴」:把意图说清楚,它能快速产出可运行代码;但在关键坑点(如
st.s坐标、czm_frameNumber动画循环)上,仍需要人去验证和校准------这正是「人 + AI」协作的正确打开方式。
十、总结
本文用天地图 Cesium + 行政区划接口,零构建地实现了一个「长沙市区县行政区划可视化」页面,并叠加了流动边界 与点击闪烁两个提升观感的交互。
核心收获:
- 天地图行政区划接口两段式调用,配合手写 WKT 解析器,快速拿到并绘制多级边界;
- Cesium 自定义材质是低成本实现 GPU 动画(流光 / 飞线)的利器,但要吃透
materialInput.st坐标语义; czm_frameNumber动画依赖持续渲染循环,requestAnimationFrame兜底很实用。