天地图 Cesium 行政区划可视化:长沙市区县边界「金色流光 + 点击闪烁」,CodeBuddy 辅助开发全记录

一、前言

行政区划边界是 GIS 大屏与政务可视化中最常见的底图元素。如何在三维地球上把「长沙市 9 个区县」的边界画得又准又好看,并且做出流动的高科技感?本文给出一个零构建、纯前端的完整方案:

  • 调用天地图行政区划接口 /v2/administrative 获取长沙 9 个区县的 WKT 边界;
  • 手写解析器把 MULTIPOLYGON 解析为外环 + 洞环,绘制填充面 + 统一边界线 + 中心点标签;
  • 用 Cesium 自定义材质(Fabric Shader) 实现边界上的金色流光(亮头 + 渐隐拖尾的「金色彗星」沿边界滚动);
  • 实现悬停高亮、点击区县边界闪烁 3 秒、图例联动等交互。

值得一提的是,本文绝大部分代码(接口调用、WKT 解析算法、GLSL 流光 Shader、闪烁交互)都是在 CodeBuddy(AI 编程助手) 的辅助下完成------后文我会单独盘点 AI 在其中发挥的作用。


二、最终效果

打开页面后,三维地球自动定位到长沙上空,整体效果如下:

  1. 长沙 9 个区县以不同颜色半透明填充面 呈现,边界线统一为金色流光,光点沿边界持续滚动;
  2. 鼠标悬停某个区县时,该区县填充加深、边界加粗变青,实现联动高亮;
  3. 点击 某个区县时,其边界在 3 秒内快速闪烁(青色高亮 ↔ 金色流光交替),随后恢复常态;
  4. 左侧面板提供填充 / 边界 / 区县名 / 流动边界 开关,以及图例列表,点击图例相机飞至对应区县。

图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


四、准备工作

  1. 申请 Token

    前往 天地图 Key 控制台 免费申请,将代码中的 token 替换为你自己的 Key(建议使用「浏览器端」类型,并勾选行政区划服务)。

  2. 依赖说明

    本例直接使用天地图官方 CDN 封装的 Cesium 及插件(Cesium.js、Cesium_ext_min.js 等),无需 npm 安装 。Cesium.Map 是天地图对 Cesium.Viewer 的封装。

  3. 运行环境

    需要联网(瓦片与行政区划接口均为在线服务)。用任意静态服务器托管即可:

    bash 复制代码
    cd 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 承担了从需求到代码的大部分工作:

  1. 接口与解析 :根据「加载长沙区县边界」这一句话,自动生成 /v2/administrative 的两段式调用,以及 MULTIPOLYGON 的 WKT 解析器(正则提取 + 括号深度计数);
  2. GPU 特效 :手写 czm_getMaterial GLSL,实现「亮头 + 渐隐拖尾」的金色彗星流光;
  3. Bug 定位 :当「流光没生效」时,快速定位到 st.s 归一化坐标的误用,并给出 st.s * 光点数量 的修正方案;
  4. 交互实现:用双定时器实现「点击边界闪烁 3 秒」的完整逻辑。

图6:在 CodeBuddy 中通过自然语言描述需求,AI 生成接口调用的代码。

体会上,AI 更适合作为「有经验的前端 / GLSL 伙伴」:把意图说清楚,它能快速产出可运行代码;但在关键坑点(如 st.s 坐标、czm_frameNumber 动画循环)上,仍需要人去验证和校准------这正是「人 + AI」协作的正确打开方式。


十、总结

本文用天地图 Cesium + 行政区划接口,零构建地实现了一个「长沙市区县行政区划可视化」页面,并叠加了流动边界 与点击闪烁两个提升观感的交互。

核心收获:

  • 天地图行政区划接口两段式调用,配合手写 WKT 解析器,快速拿到并绘制多级边界;
  • Cesium 自定义材质是低成本实现 GPU 动画(流光 / 飞线)的利器,但要吃透 materialInput.st 坐标语义;
  • czm_frameNumber 动画依赖持续渲染循环,requestAnimationFrame 兜底很实用。