cesiumEngine.js 97 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048
  1. /**
  2. * Cesium 引擎适配层(票 27):本仓库里唯一 `import 'cesium'` 的地方。
  3. *
  4. * 只在 `mapService.mountMap()` 完成 `window.CESIUM_BASE_URL` 注入之后被动态 import,
  5. * 因此静态 import 提升不会踩到时序(见 services/cesiumAssets.js)。
  6. *
  7. * 本模块负责两件事:
  8. * 1. 「把引擎接到宿主上」(票 27):唯一 `Viewer`、自带控件与默认相机输入全关、
  9. * 不碰需要账号的能力;底图按 `domain/basemap.js` 的计划挂(票 29),
  10. * 没配就不挂(票 27 的「底图未配置不伪造配准」);
  11. * 2. 「把相机契约接到引擎上」(票 03):`createCameraEngine()` 把三键契约需要的原语
  12. * (弹簧臂 `lookAtTransform(东北天局部系, HeadingPitchRange)`、屏幕中心地面拾取、
  13. * 中心点投影)包成 services/cameraControl.js 的引擎接口 —— 相机语义本身不在本文件,
  14. * 这里只做 Cesium 方言的翻译。
  15. */
  16. import {
  17. Cartesian2,
  18. Cartesian3,
  19. Cartographic,
  20. Cesium3DTileset,
  21. Cesium3DTileStyle,
  22. CesiumTerrainProvider,
  23. Color,
  24. CustomDataSource,
  25. DirectionalLight,
  26. Ellipsoid,
  27. EllipsoidalOccluder,
  28. ImageBasedLighting,
  29. HeightReference,
  30. HeadingPitchRange,
  31. ImageryLayer,
  32. LabelStyle,
  33. Math as CesiumMath,
  34. Matrix4,
  35. PostProcessStage,
  36. Rectangle,
  37. RequestScheduler,
  38. SceneTransforms,
  39. TileMapServiceImageryProvider,
  40. Transforms,
  41. UrlTemplateImageryProvider,
  42. VerticalOrigin,
  43. Viewer,
  44. buildModuleUrl,
  45. sampleTerrainMostDetailed,
  46. } from 'cesium'
  47. import 'cesium/Build/Cesium/Widgets/widgets.css'
  48. // 强调的**判定**在 domain(纯函数,可脱浏览器断言);本层只把结论写成显示参数。
  49. import { EMPHASIS_STYLE, shouldEmphasize } from '../domain/mapEmphasis.js'
  50. // 票 47:动效的**相位与映射**同样在 domain(纯函数,可跨时间采样断言);
  51. // 本层只把相位写成 billboard 的 scale/alpha/rotation。
  52. import { DEVICE_EFFECT, RING_LAYERS, blinkStrengthAt, glowScaleAt, glowStrengthAt, hasGlow, pulseStrengthAt, ringEntityCount, ringFrameAt, spinAngleAt } from '../domain/deviceEffects.js'
  53. const DEG = Math.PI / 180
  54. const VIEWER_OPTIONS = {
  55. // 自带控件全关(导航/时间轴/拾取框等)。
  56. animation: false,
  57. timeline: false,
  58. geocoder: false,
  59. homeButton: false,
  60. sceneModePicker: false,
  61. baseLayerPicker: false,
  62. navigationHelpButton: false,
  63. navigationInstructionsInitiallyVisible: false,
  64. fullscreenButton: false,
  65. infoBox: false,
  66. selectionIndicator: false,
  67. vrButton: false,
  68. // 按需渲染:相机不动且无新瓦片时不再空转 60fps 渲染循环 —— 模型加载(解码+GPU 上传)
  69. // 独占主线程,"加载一开始整个场景都卡"的直接缓解。相机一动、瓦片一到货就自动出帧;
  70. // maximumRenderTimeChange 兜底:即使无场景变化也至少每 0.5s 渲一帧,保证自建雾
  71. // 等 uniform 更新最迟半秒内可见(自转/雾动画都绑定相机运动,不受影响)。
  72. requestRenderMode: true,
  73. maximumRenderTimeChange: 0.5,
  74. }
  75. /** 按底图计划建 imagery layer;未配置或计划无效时返回 false = 不配底图(票 27:不伪造地理配准)。 */
  76. function createBaseLayer(plan) {
  77. if (!plan || !plan.ready) return false
  78. if (plan.kind === 'tms') {
  79. // Cesium 自带的离线 TMS(随 Assets 目录进产物,不需要账号或外网)。
  80. return ImageryLayer.fromProviderAsync(TileMapServiceImageryProvider.fromUrl(buildModuleUrl(plan.url)))
  81. }
  82. if (plan.kind === 'xyz') {
  83. const options = { url: plan.url }
  84. if (plan.subdomains && plan.subdomains.length) options.subdomains = plan.subdomains
  85. if (Number.isInteger(plan.minimumLevel)) options.minimumLevel = plan.minimumLevel
  86. if (Number.isInteger(plan.maximumLevel)) options.maximumLevel = plan.maximumLevel
  87. if (plan.credit) options.credit = plan.credit
  88. return new ImageryLayer(new UrlTemplateImageryProvider(options))
  89. }
  90. return false
  91. }
  92. /**
  93. * 在宿主要素上创建 Viewer。
  94. * @param {Element} host 宿主要素(`.map-canvas`)
  95. * @param {object} [basemapPlan] `domain/basemap.js` 的底图计划(票 29);缺省 = 不配底图
  96. * @returns {Viewer}
  97. */
  98. export function createViewer(host, basemapPlan) {
  99. const viewer = new Viewer(host, { ...VIEWER_OPTIONS, baseLayer: createBaseLayer(basemapPlan) })
  100. const controller = viewer.scene.screenSpaceCameraController
  101. controller.enableRotate = false
  102. controller.enableZoom = false
  103. controller.enableTilt = false
  104. controller.enableLook = false
  105. controller.enableTranslate = false
  106. // 球体底色用驾驶舱底色:没配底图时看到的就是这个球,配了底图会被影像盖住。
  107. viewer.scene.globe.baseColor = Color.fromCssColorString('#04121f')
  108. // 地面视图必须保留地形深度,否则埋在地表以下的 3D Tiles 也会覆盖到地形之上。
  109. // 回归:tools/city-massing/verify-occlusion.mjs 用实际渲染像素检查遮挡。
  110. viewer.scene.globe.depthTestAgainstTerrain = true
  111. return viewer
  112. }
  113. export function resizeViewer(viewer) {
  114. viewer.resize()
  115. }
  116. /** 叠加影像(票 21):本地精细切片画在底图之上,URL 模板里可用 `{reverseY}` 表示 TMS 的 y。 */
  117. function createOverlayProvider(overlay) {
  118. const options = { url: overlay.url }
  119. if (Number.isInteger(overlay.minimumLevel)) options.minimumLevel = overlay.minimumLevel
  120. if (Number.isInteger(overlay.maximumLevel)) options.maximumLevel = overlay.maximumLevel
  121. if (overlay.bounds) options.rectangle = Rectangle.fromDegrees(overlay.bounds[0], overlay.bounds[1], overlay.bounds[2], overlay.bounds[3])
  122. if (overlay.credit) options.credit = overlay.credit
  123. return new UrlTemplateImageryProvider(options)
  124. }
  125. /**
  126. * 批量采样真实地表高程(票 34 的建模用途):一次请求把整张网格取回来,
  127. * 比逐点调 `sampleTerrainHeight()` 快得多(逐点会重复拉同一批瓦片)。
  128. * @param {Array<{lon: number, lat: number}>} points
  129. * @returns {Promise<Array<number|null>>} 与入参等长;无地形或取不到的点为 null
  130. */
  131. export async function sampleTerrainHeights(viewer, points) {
  132. const list = Array.isArray(points) ? points : []
  133. if (!viewer || !viewer.terrainProvider || !list.length) return list.map(() => null)
  134. try {
  135. const cartos = list.map((p) => Cartographic.fromDegrees(p.lon, p.lat))
  136. const sampled = await sampleTerrainMostDetailed(viewer.terrainProvider, cartos)
  137. return sampled.map((c) => (c && Number.isFinite(c.height) ? c.height : null))
  138. } catch {
  139. return list.map(() => null)
  140. }
  141. }
  142. /**
  143. * 采样一点的**真实地表高程**(票 30):地形感知的中心点/预设视角靠它把高度从「椭球 0 米」
  144. * 换成实际地形高度,否则相机高度按 0 算,一拉近就落进山体(实测 range 120m 全黑)。
  145. * @returns {Promise<number|null>} 米;没有地形或采样失败返回 null
  146. */
  147. export async function sampleTerrainHeight(viewer, lon, lat) {
  148. if (!viewer || !viewer.terrainProvider) return null
  149. try {
  150. const carto = Cartographic.fromDegrees(lon, lat)
  151. const [sampled] = await sampleTerrainMostDetailed(viewer.terrainProvider, [carto])
  152. const height = sampled && sampled.height
  153. return Number.isFinite(height) ? height : null
  154. } catch {
  155. return null
  156. }
  157. }
  158. /**
  159. * 挂地形(票 21)。DEM 是**量化网格**:`CesiumTerrainProvider.fromUrl(<layer.json 所在目录>)`,
  160. * `layer.json` 里的 `scheme: "tms"` 由 Cesium 自己认,服务端不需要翻 y。
  161. * @returns {Promise<string>} `'ready'`;失败时抛错,由调用方记成失败态(保留椭球兜底)
  162. */
  163. export async function applyTerrain(viewer, terrainPlan) {
  164. const provider = await CesiumTerrainProvider.fromUrl(terrainPlan.url, {
  165. requestVertexNormals: terrainPlan.requestVertexNormals !== false,
  166. })
  167. viewer.terrainProvider = provider
  168. return 'ready'
  169. }
  170. /**
  171. * 叠加影像句柄(票 39):把「精细层」包成一个只暴露必需动作的对象,供交接状态机驱动。
  172. *
  173. * ── 两个必须讲清楚的 Cesium 事实(本票实现依据,都查过源码)──
  174. *
  175. * 1. **隐藏期间仍要继续预取瓦片**(决策 08 第 1 轮 Q4=A「看不见不等于停止加载」)。
  176. * 瓦片是否被请求由 `GlobeSurfaceTile` 按 **`layer.show`** 决定
  177. * (`Source/Scene/GlobeSurfaceTile.js:691` `if (layer.show) { layer._createTileImagerySkeletons(...) }`),
  178. * **不看 `alpha`**。所以"隐藏精细层"的正确做法是 **`alpha = 0` 且保持 `show = true`** ——
  179. * 用 `show = false` 会真的停掉预取,等揭示时才开始下载,正好造成"先空白再跳方块"。
  180. *
  181. * 2. **alpha 只允许一处写**。本模块提供 `setAlpha()` 作为唯一入口,
  182. * 服务层与原型都不得直接摸 `layer.alpha`(原型若同时写会互相打架,见 mapService 的隔离注释)。
  183. */
  184. function createOverlayHandle(layer) {
  185. return {
  186. layer,
  187. /** 该层的原始 alpha(销毁/还原时用)。 */
  188. initialAlpha: Number.isFinite(layer.alpha) ? layer.alpha : 1,
  189. /** 当前由交接写入的 alpha(读数与断言用;不代表"画面已遮住")。 */
  190. alpha: Number.isFinite(layer.alpha) ? layer.alpha : 1,
  191. /**
  192. * 写 alpha(**唯一入口**)。
  193. *
  194. * 刻意**不**动 `show`:`alpha = 0` 即不可见,但 `show` 保持 true 让瓦片继续预取
  195. * (见上方事实 1)。UI 不可见 ≠ 停止加载。
  196. */
  197. setAlpha(value) {
  198. const next = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0))
  199. this.alpha = next
  200. layer.alpha = next
  201. return next
  202. },
  203. /** 当前层对象是否还有效(viewer 销毁后不要再碰)。 */
  204. alive() {
  205. return !!layer && typeof layer.alpha === 'number'
  206. },
  207. }
  208. }
  209. /**
  210. * 把相机**直接放到指定椭球高度**(DEV 诊断用;保持当前经纬度与姿态)。
  211. *
  212. * 为什么放在引擎层:`Cartesian3` 只允许出现在本仓唯一 `import 'cesium'` 的地方(ADR 0004),
  213. * 服务层不碰 Cesium 类型。
  214. *
  215. * 为什么不能直接赋值:`camera.positionWC` / `positionCartographic` 在
  216. * `Source/Scene/Camera.js` 里**只有 getter**(内部由 `_position` 推导),
  217. * 赋值不会生效 —— 必须走 `setView()` 这个受支持入口。
  218. *
  219. * @returns {number|null} 实际下发的椭球高度(米);失败返回 null
  220. */
  221. export function setCameraAltitude(viewer, altitudeM) {
  222. if (!viewer || !viewer.camera || !viewer.scene || !viewer.scene.globe) return null
  223. if (!Number.isFinite(altitudeM)) return null
  224. try {
  225. const ellipsoid = viewer.scene.globe.ellipsoid
  226. const carto = ellipsoid.cartesianToCartographic(viewer.camera.positionWC)
  227. if (!carto) return null
  228. const camera = viewer.camera
  229. camera.setView({
  230. destination: Cartesian3.fromRadians(carto.longitude, carto.latitude, altitudeM),
  231. orientation: { heading: camera.heading, pitch: camera.pitch, roll: camera.roll },
  232. })
  233. return Math.round(altitudeM)
  234. } catch (error) {
  235. return null
  236. }
  237. }
  238. /** 按配置顺序叠加影像层,返回**句柄数组**(票 39 起需要驱动 alpha,故不再只返回层数)。 */
  239. export function applyOverlays(viewer, overlays) {
  240. const handles = []
  241. for (const overlay of overlays || []) {
  242. const layer = new ImageryLayer(createOverlayProvider(overlay))
  243. viewer.imageryLayers.add(layer)
  244. handles.push(createOverlayHandle(layer))
  245. }
  246. return handles
  247. }
  248. /**
  249. * 目标视野的瓦片就绪读数(**如实标注它是代理信号**)。
  250. *
  251. * `globe.tilesLoaded` = **地形与影像加载队列为空**(`Source/Scene/Globe.js:422` 的 getter),
  252. * 它**不证明**目标视野逐像素就绪 —— 决策票 08 明确警告过这一点。所以这里连同
  253. * "它是代理"这个事实一起返回,免得下游把它当充分证明。
  254. *
  255. * 瓦片失败:Cesium 在**没有任何 errorEvent 监听者**时会把错误直接抛到窗口
  256. * (`ImageryLayer.js:1708` `handleError`),所以本层挂一个监听者把失败**收进读数**,
  257. * 而不是让它变成页面级报错(走查的「无页面级报错」会因此误伤)。
  258. */
  259. export function createOverlayReadiness(handles = []) {
  260. const state = { loaded: false, failed: 0, lastError: '' }
  261. const removers = []
  262. for (const handle of handles) {
  263. const layer = handle && handle.layer
  264. if (!layer || !layer.errorEvent || typeof layer.errorEvent.addEventListener !== 'function') continue
  265. // 注意:Cesium 的 `Event.addEventListener` **返回一个"移除函数"**,不是 id
  266. // (`Source/Core/Event.js` 末尾 `return function () { event.removeEventListener(...) }`)。
  267. const remove = layer.errorEvent.addEventListener((error) => {
  268. state.failed += 1
  269. state.lastError = String((error && error.message) || error || '瓦片加载失败')
  270. })
  271. if (typeof remove === 'function') removers.push(remove)
  272. }
  273. return {
  274. state,
  275. /** 由服务层在渲染循环里刷新(`globe.tilesLoaded` 是 getter,读它不便宜,故不在这里轮询)。 */
  276. refresh(globe) {
  277. state.loaded = !!(globe && globe.tilesLoaded)
  278. return state
  279. },
  280. detach() {
  281. for (const remove of removers) {
  282. try {
  283. remove()
  284. } catch {
  285. // 场景已销毁:忽略
  286. }
  287. }
  288. removers.length = 0
  289. },
  290. }
  291. }
  292. /**
  293. * 3D Tiles 的公共诊断读数:排查「tileset.json 拉到了、内容却没请求」时,
  294. * 使用 Cesium 1.145 的公开 `tilesLoaded`、内存和 tile 事件结果;不读取内部 statistics/ready/contentState。
  295. * `visited`/`selected` 保留为 null,因为 Cesium 没有公开的稳定逐帧字段。
  296. */
  297. export function readTilesetDiagnostics(tilesets) {
  298. const list = Array.isArray(tilesets) ? tilesets : []
  299. return list.map((tileset) => {
  300. const root = tileset.root || {}
  301. return {
  302. visited: null,
  303. selected: null,
  304. contentReady: root ? Number(Boolean(root.content)) : 0,
  305. tilesLoaded: Boolean(tileset.tilesLoaded),
  306. memoryBytes: tileset.totalMemoryUsageInBytes || 0,
  307. rootHasContent: Boolean(root && root.content),
  308. rootChildren: Array.isArray(root && root.children) ? root.children.length : 0,
  309. maximumScreenSpaceError: tileset.maximumScreenSpaceError,
  310. }
  311. })
  312. }
  313. /**
  314. * 挂 3D Tiles / 倾斜摄影(票 21):每个条目一个 tileset。
  315. * 单项失败不影响其它项;返回 `{ ready, failed, instances }` 供上层如实报状态
  316. * (页面不白屏、不静默),并把实例交出去给视图条件改透明度。
  317. */
  318. function applyBuildingLighting(scene, tileset) {
  319. // Derive a stable local daylight from this dataset's geographic bounds; do not key lighting to the camera.
  320. const origin = tileset.boundingSphere?.center
  321. if (!origin) return
  322. const frame = Transforms.eastNorthUpToFixedFrame(origin)
  323. const direction = Matrix4.multiplyByPointAsVector(frame, new Cartesian3(-0.4, -0.6, -1), new Cartesian3())
  324. Cartesian3.normalize(direction, direction)
  325. scene.light = new DirectionalLight({ direction, intensity: 2.4 })
  326. tileset.imageBasedLighting = new ImageBasedLighting({
  327. imageBasedLightingFactor: new Cartesian2(1, 1),
  328. sphericalHarmonicCoefficients: [new Cartesian3(0.65, 0.65, 0.65), ...Array.from({ length: 8 }, () => new Cartesian3(0, 0, 0))],
  329. })
  330. }
  331. const DEFAULT_TILESET_MAX_DISTANCE = 18000
  332. const DEFAULT_TILESET_LOAD_DISTANCE = 6000
  333. const MAX_LAZY_TILESET_LOADS = 1
  334. /** 相机静止门槛:位移 ≤8m 且持续 600ms 才允许启动批次加载。 */
  335. const CAMERA_IDLE_METERS = 8
  336. const CAMERA_IDLE_MS = 600
  337. /** 3D Tiles 资产缓存上限(字节):LOD 内容超过即淘汰,防止显存/内存无界增长。 */
  338. const TILESET_CACHE_BYTES = 512 * 1024 * 1024
  339. const TILESET_CACHE_OVERFLOW_BYTES = 256 * 1024 * 1024
  340. function sphereFromTilesetRoot(root) {
  341. const box = root && root.boundingVolume && root.boundingVolume.box
  342. if (!Array.isArray(box) || box.length < 12) return null
  343. const transform = Array.isArray(root.transform) && root.transform.length === 16
  344. ? Matrix4.fromArray(root.transform)
  345. : Matrix4.IDENTITY
  346. const center = Matrix4.multiplyByPoint(transform, new Cartesian3(box[0], box[1], box[2]), new Cartesian3())
  347. const radii = [
  348. Matrix4.multiplyByPointAsVector(transform, new Cartesian3(box[3], box[4], box[5]), new Cartesian3()),
  349. Matrix4.multiplyByPointAsVector(transform, new Cartesian3(box[6], box[7], box[8]), new Cartesian3()),
  350. Matrix4.multiplyByPointAsVector(transform, new Cartesian3(box[9], box[10], box[11]), new Cartesian3()),
  351. ]
  352. const radius = Math.sqrt(radii.reduce((sum, axis) => sum + Cartesian3.magnitudeSquared(axis), 0))
  353. return { center, radius }
  354. }
  355. async function readTilesetRootSphere(plan) {
  356. const response = await fetch(plan.url, { cache: 'no-store' })
  357. if (!response.ok) throw new Error(`tileset metadata ${response.status}: ${plan.url}`)
  358. const json = await response.json()
  359. const sphere = sphereFromTilesetRoot(json.root)
  360. if (!sphere) throw new Error(`tileset root bounding box missing: ${plan.url}`)
  361. return sphere
  362. }
  363. function tilesetOptions(plan) {
  364. const options = {
  365. skipLevelOfDetail: true,
  366. baseScreenSpaceError: 4096,
  367. skipScreenSpaceErrorFactor: 32,
  368. skipLevels: 2,
  369. immediatelyLoadDesiredLevelOfDetail: false,
  370. loadSiblings: false,
  371. cullRequestsWhileMoving: true,
  372. cullRequestsWhileMovingMultiplier: 90,
  373. dynamicScreenSpaceError: true,
  374. dynamicScreenSpaceErrorDensity: 0.01,
  375. dynamicScreenSpaceErrorFactor: 32,
  376. foveatedScreenSpaceError: true,
  377. foveatedConeSize: 0.1,
  378. foveatedMinimumScreenSpaceErrorRelaxation: 0,
  379. // SSE 提高到 96:只让视口中央占屏很大的单元才细化到 LOD0,避免进入批次瞬间
  380. // 68 个 LOD1 单元 + LOD0 高模同时上屏(近景卡顿的直接来源)。
  381. maximumScreenSpaceError: Number.isFinite(plan.maximumScreenSpaceError) ? plan.maximumScreenSpaceError : 96,
  382. cacheBytes: TILESET_CACHE_BYTES,
  383. maximumCacheOverflowBytes: TILESET_CACHE_OVERFLOW_BYTES,
  384. }
  385. return options
  386. }
  387. function createTilesetCullingController(scene, entries, instances, failed) {
  388. const camera = scene && scene.camera
  389. if (!camera || !camera.changed || !Array.isArray(entries) || !entries.length) return null
  390. let disposed = false
  391. let activeLoads = 0
  392. let lastCameraPosition = null
  393. let lastCameraMoveAt = 0
  394. let idleRetryTimer = null
  395. async function loadEntry(entry) {
  396. if (disposed || entry.loading || entry.instance) return
  397. entry.loading = true
  398. activeLoads += 1
  399. try {
  400. const instance = await Cesium3DTileset.fromUrl(entry.plan.url, tilesetOptions(entry.plan))
  401. if (disposed) {
  402. instance.destroy()
  403. return
  404. }
  405. if (entry.plan.lighting === true) applyBuildingLighting(scene, instance)
  406. instance.tileFailed.addEventListener((error) => {
  407. failed.push({ url: error.url || entry.plan.url, error: error.message || '3D Tiles 内容加载失败' })
  408. })
  409. scene.primitives.add(instance)
  410. entry.instance = instance
  411. instances.push(instance)
  412. } catch (error) {
  413. failed.push({ url: entry.plan.url, error: String((error && error.message) || error) })
  414. } finally {
  415. entry.loading = false
  416. activeLoads = Math.max(0, activeLoads - 1)
  417. refresh()
  418. }
  419. }
  420. function refresh() {
  421. if (disposed) return
  422. const cameraPosition = camera.positionWC
  423. // 相机静止判定:与上一次 refresh 的位置差。移动中**只推迟批次加载**(显示/显存
  424. // 切换照常实时更新),加载推迟到相机停下——"模型加载一开始整个场景都卡"的
  425. // 根因就是加载与移动在同一批帧里争主线程。LOD 判定只用这个包围球距离,不用相机高度。
  426. if (lastCameraPosition) {
  427. const moved = Cartesian3.distance(lastCameraPosition, cameraPosition)
  428. if (moved > CAMERA_IDLE_METERS) lastCameraMoveAt = Date.now()
  429. }
  430. lastCameraPosition = Cartesian3.clone(cameraPosition)
  431. const candidates = []
  432. for (const entry of entries) {
  433. if (!entry.sphere) continue
  434. const maximumDistance = Number.isFinite(entry.plan.maximumDistance)
  435. ? entry.plan.maximumDistance
  436. : DEFAULT_TILESET_MAX_DISTANCE
  437. const loadDistance = Number.isFinite(entry.plan.loadDistance)
  438. ? entry.plan.loadDistance
  439. : Math.min(DEFAULT_TILESET_LOAD_DISTANCE, maximumDistance)
  440. const distance = Math.max(0, Cartesian3.distance(cameraPosition, entry.sphere.center) - entry.sphere.radius)
  441. entry.distance = distance
  442. const isFarLayer = entry.plan.role === 'far'
  443. if (entry.instance) {
  444. const visible = isFarLayer ? true : distance <= loadDistance
  445. if (entry.instance.show !== visible) entry.instance.show = visible
  446. if (!entry.plan.eager && !isFarLayer && distance > loadDistance * 2) {
  447. scene.primitives.remove(entry.instance)
  448. entry.instance.destroy()
  449. const index = instances.indexOf(entry.instance)
  450. if (index >= 0) instances.splice(index, 1)
  451. entry.instance = null
  452. }
  453. } else if (!entry.loading && (entry.plan.eager || isFarLayer || distance <= loadDistance)) {
  454. candidates.push({ entry, distance })
  455. }
  456. }
  457. // 远景粗模层只在**没有任何精细批次实际上屏**时才显示:
  458. // 精细批次先预载(loadDistance 提前于显示),上屏后才切走粗模,避免加载间隙出现空白县城。
  459. const fineShowing = entries.some((entry) => entry.plan.role !== 'far' && entry.instance && entry.instance.show)
  460. for (const entry of entries) {
  461. if (entry.plan.role === 'far' && entry.instance && entry.instance.show !== !fineShowing) {
  462. entry.instance.show = !fineShowing
  463. }
  464. }
  465. if (activeLoads < MAX_LAZY_TILESET_LOADS && candidates.length) {
  466. if (Date.now() - lastCameraMoveAt >= CAMERA_IDLE_MS) {
  467. // 相机已静止:可以安全加载(加载期间 requestRenderMode 让解码不被渲染循环挤占)。
  468. candidates.sort((a, b) => a.distance - b.distance)
  469. void loadEntry(candidates[0].entry)
  470. } else {
  471. // 仍在移动:挂一个短定时器再查一次——停下后没有 camera.changed 事件,
  472. // 没有这个重查,加载会被永远推迟。
  473. if (!idleRetryTimer) {
  474. idleRetryTimer = setTimeout(() => {
  475. idleRetryTimer = null
  476. refresh()
  477. }, CAMERA_IDLE_MS)
  478. }
  479. }
  480. }
  481. }
  482. const detach = camera.changed.addEventListener(refresh)
  483. return {
  484. refresh,
  485. async prepare() {
  486. await Promise.all(entries.map(async (entry) => {
  487. try {
  488. entry.sphere = await readTilesetRootSphere(entry.plan)
  489. } catch (error) {
  490. failed.push({ url: entry.plan.url, error: String((error && error.message) || error) })
  491. }
  492. }))
  493. await Promise.all(entries.filter((entry) => entry.plan.eager !== false).map((entry) => loadEntry(entry)))
  494. refresh()
  495. },
  496. detach() {
  497. disposed = true
  498. if (idleRetryTimer) {
  499. clearTimeout(idleRetryTimer)
  500. idleRetryTimer = null
  501. }
  502. detach()
  503. },
  504. }
  505. }
  506. /**
  507. * 3D Tiles 懒加载:启动只读根 tileset.json 的包围盒,eager 批次立即加载,
  508. * 其余批次只有相机进入 loadDistance 才初始化;离开较远区域后销毁非 eager 批次。
  509. * 已加载 tileset 仍由 Cesium 做 tile 级视锥剔除和内部 LOD。
  510. */
  511. export async function applyTilesets(scene, tilesets) {
  512. // 全局请求节流:默认 50 并发会让进入批次瞬间的 GLB 下载+解码挤在同一批帧里(近景卡顿)。
  513. // 限到 10/5:模型、地形、影像三类请求共享同一调度器,加载期间后两者必须还有余量。
  514. try {
  515. RequestScheduler.maximumRequests = 10
  516. RequestScheduler.maximumRequestsPerServer = 5
  517. } catch {
  518. // 老版本没有这些字段时保持默认。
  519. }
  520. const instances = []
  521. const failed = []
  522. const entries = (tilesets || []).map((plan) => ({ plan, sphere: null, instance: null, loading: false, distance: null }))
  523. const culling = createTilesetCullingController(scene, entries, instances, failed)
  524. if (culling) await culling.prepare()
  525. return { ready: instances.length, failed, instances, culling }
  526. }
  527. /**
  528. * 视图条件对图层的影响(票 21):管线视图把地形与 3D Tiles 压成半透明(保留空间参照,
  529. * 不完全隐藏),地面视图还原为完全不透明。
  530. *
  531. * 地形用 Cesium 的 `globe.translucency`(前台/背面 alpha 一起设,避免只透一半);
  532. * 3D Tiles 用 `Cesium3DTileStyle` 的 color alpha。两者都只改显示,不动数据,也不碰相机。
  533. * @param {object} viewer
  534. * @param {Array} tilesets 已挂上的 tileset 实例(applyTilesets 的 instances)
  535. * @param {object} plan `domain/viewModes.js` 的 planViewMode()
  536. */
  537. export function applyViewModeLayers(viewer, tilesets, plan) {
  538. if (!viewer || !plan) return
  539. const globe = viewer.scene && viewer.scene.globe
  540. if (globe) {
  541. // 始终参与真实地形深度;管线透视只由显式 translucency 开关控制。
  542. globe.depthTestAgainstTerrain = true
  543. const alpha = Number.isFinite(plan.terrainAlpha) ? plan.terrainAlpha : 1
  544. globe.translucency.enabled = alpha < 1
  545. globe.translucency.frontFaceAlpha = alpha
  546. globe.translucency.backFaceAlpha = alpha
  547. }
  548. const alpha = Number.isFinite(plan.tilesetAlpha) ? plan.tilesetAlpha : 1
  549. for (const tileset of tilesets || []) {
  550. try {
  551. // 地面视图还原 = 清掉样式(而不是设成 white/1,省得留一份无意义样式)。
  552. tileset.style = alpha < 1 ? new Cesium3DTileStyle({ color: `color('white', ${alpha})` }) : undefined
  553. } catch {
  554. // 单个 tileset 不支持 style 时忽略,不影响其它图层。
  555. }
  556. }
  557. }
  558. export function destroyViewer(viewer) {
  559. viewer.destroy()
  560. }
  561. // ================================================================ 自建视距雾(票 38)
  562. /** 自建雾处理级的名字(唯一一个)。 */
  563. export const FOG_STAGE_NAME = 'cockpit-area-fog'
  564. /**
  565. * 世界射线距离雾的片元着色器(**B 方案:只处理有有效几何深度的地物**)。
  566. *
  567. * 契约来源 `cockpit-primary-area-guardrails/spec-fog.md`(决策票 07 固化 B 方案)。三条硬约束:
  568. *
  569. * 1. **无有效深度 ≠ 天空**,但本级**不重绘它**:`rawDepth >= 1.0` 时整段返回原色
  570. * (不套雾色、不做地平线渐变)。依据决策票 07「采用 B:仅地物距离雾……无有效几何深度背景保持原颜色」,
  571. * 以及其只读复核「深度清为 1.0,天空盒与大气不写深度」「无深度值也可能对应没写自身深度的透明内容」。
  572. * 2. **只反解一次对数深度**:`czm_windowToEyeCoordinates(gl_FragCoord.xy, rawDepth)` 这个重载
  573. * **内部自己解 log**(`windowToEyeCoordinates.glsl`),喂给它的是**原始** `texture(depthTexture).r`。
  574. * 先 `czm_reverseLogDepth` 再传进去会解两次 —— 复核里点名的坑。
  575. * 沿视线的距离取重建位置的长度,不取 `-z`;并按 `gl_FragCoord.w` 做齐次除法。
  576. * 3. **两端语义精确**:0 处全清、`endDistance` 处全遮蔽。曲线形状(smoothstep + 保端点光学厚度)
  577. * 在 `domain/fog.js` 的 `fogAttenuation` 里算好,这里只做 `mix`,**GLSL 不重写一份数学**
  578. * (两处各写一份必然漂移,而端点语义是本票的验收点)。
  579. *
  580. * 与原型 `prototypes/fog-prototype-stage.js`(**已归档到 `throwaway/cockpit-prototypes` 分支**,
  581. * 票 40 收口)的区别:去掉只有决策票 07 才有的 C 方案分支
  582. * (地平线染色与太阳暖色,已裁定不采用),并改用 CPU 侧算好的强度 uniform。
  583. */
  584. const FOG_FRAGMENT_SHADER = `
  585. uniform sampler2D colorTexture;
  586. uniform sampler2D depthTexture;
  587. uniform vec3 fogColor;
  588. uniform float effectStrength;
  589. uniform float startDistance;
  590. uniform float endDistance;
  591. in vec2 v_textureCoordinates;
  592. // 世界射线距离 → 不透明度。与 domain/fog.js 的 fogAttenuation 同一形状,
  593. // 但它必须在 GPU 上逐片元算,所以这里保留一份等价实现;**端点断言在 CPU 侧**。
  594. float fogAlpha(float distanceM) {
  595. if (distanceM <= startDistance) return 0.0;
  596. if (distanceM >= endDistance) return 1.0;
  597. float t = (distanceM - startDistance) / (endDistance - startDistance);
  598. float smoothT = t * t * (3.0 - 2.0 * t);
  599. return (1.0 - exp(-3.0 * smoothT)) / (1.0 - exp(-3.0));
  600. }
  601. void main() {
  602. vec4 base = texture(colorTexture, v_textureCoordinates);
  603. float rawDepth = texture(depthTexture, v_textureCoordinates).r;
  604. // 无有效几何深度:**保留原色**(B 方案)。这一支也不做"天空分类"的臆断。
  605. if (rawDepth >= 1.0) {
  606. out_FragColor = base;
  607. return;
  608. }
  609. // 原始深度交给这个重载,它内部自己解 log;再补一次齐次除法。
  610. vec4 eye = czm_windowToEyeCoordinates(gl_FragCoord.xy, rawDepth);
  611. vec3 eyePos = eye.xyz / eye.w;
  612. float distanceM = length(eyePos);
  613. float alpha = clamp(fogAlpha(distanceM) * effectStrength, 0.0, 1.0);
  614. out_FragColor = vec4(mix(base.rgb, fogColor, alpha), base.a);
  615. }`
  616. /**
  617. * 在场景上建**唯一**一个自建雾处理级(票 38)。幂等:同一 viewer 只会有一个。
  618. *
  619. * 三条实现要点,每条都对着决策票 07 的只读复核:
  620. * · **能力探测交给引擎**:stage 声明了 `depthTexture` 而设备不支持时,
  621. * `PostProcessStage` 自己会整级禁用(`_isSupported`);我们只如实读回 `stage.ready`
  622. * 与 `scene.context.depthTexture`,不自己造探测逻辑。
  623. * · **复用对象**:进出与调强度都只改 uniform,不 remove/add(`PostProcessStageCollection.remove`
  624. * 会 destroy,对象不可复用)。只有离开地图才 `remove` + 释放。
  625. * · **禁用不等于零开销**:规格明确「禁用后引擎仍可能准备/清理后处理 framebuffer」,
  626. * 所以对外措辞只说"禁用",不承诺"完全没有 GPU 开销"。
  627. *
  628. * @param {Viewer} viewer
  629. * @param {object} params `domain/fog.js` 的 `planFog()` 结果
  630. * @returns {{stage: PostProcessStage, uniforms: object, supported: boolean, destroy: () => void}}
  631. */
  632. export function createFogStage(viewer, params) {
  633. const scene = viewer.scene
  634. /**
  635. * 雾的动态 uniform **必须用函数传**(不能用常量)。
  636. *
  637. * 依据(Cesium 1.145 `PostProcessStage` 源码,本票实测确认):
  638. * · 构造时 `_actualUniforms[name] = uniforms[name]` 会**快照一份**常量值;
  639. * · 之后每帧喂给 shader 的是 `_actualUniforms`(`getUniformMapFunction` 读它),
  640. * 而 `stage._uniforms` 在构造末尾被**换成另一个对象**。
  641. * 所以「传常量 + 事后改那个常量对象」**永远不会生效** —— 改了原始对象,
  642. * 快照仍是初值,shader 拿到的始终是初值。症状极具迷惑性:
  643. * 读数、dataset、`stage.enabled` 全都说"已启用、强度 1",而**画面上什么都没有**。
  644. *
  645. * 这也正是可丢弃原型 `prototypes/fog-prototype-stage.js`(**已归档到
  646. * `throwaway/cockpit-prototypes` 分支**,票 40 收口)用 `() => params.amount`
  647. * 这种写法能跑通的原因 —— 函数形式的 uniform 每帧现取,天然支持动态改值。
  648. */
  649. const state = {
  650. fogColor: new Cartesian3(1, 1, 1),
  651. effectStrength: 0,
  652. startDistance: params.startDistance,
  653. endDistance: params.endDistance,
  654. }
  655. const stage = scene.postProcessStages.add(
  656. new PostProcessStage({
  657. name: FOG_STAGE_NAME,
  658. fragmentShader: FOG_FRAGMENT_SHADER,
  659. uniforms: {
  660. fogColor: () => state.fogColor,
  661. effectStrength: () => state.effectStrength,
  662. startDistance: () => state.startDistance,
  663. endDistance: () => state.endDistance,
  664. },
  665. }),
  666. )
  667. // 先禁用:强度为 0 时不该有任何一次全屏 pass(首次启用由宿主显式打开)。
  668. stage.enabled = false
  669. return {
  670. stage,
  671. state,
  672. /** 深度能力:`Context.depthTexture` 是**布尔能力标志**(不是纹理对象,且不在 d.ts 里)。 */
  673. supported: scene.context ? scene.context.depthTexture === true : false,
  674. /**
  675. * DEV 诊断:对比"我们以为写进去的强度"与"Cesium 真正喂给 shader 的强度"。
  676. *
  677. * 读的是 `stage._uniformMap[name]()` —— 那是每帧实际取值的那条路径,
  678. * 也是唯一能证伪"值到底有没有送进 shader"的地方。两者不一致就说明 uniform 通道没接对
  679. * (常量快照坑:直接写原始对象绕过了 setter,`_actualUniforms` 永远是初值)。
  680. */
  681. readUniformDiagnostics() {
  682. let delivered = null
  683. let storedKind = null
  684. let storedValue = null
  685. try {
  686. const uniformMap = stage._uniformMap
  687. delivered = uniformMap && typeof uniformMap.effectStrength === 'function' ? uniformMap.effectStrength() : null
  688. } catch (error) {
  689. delivered = null
  690. }
  691. try {
  692. // `_actualUniforms` 在**构造时**就写好了(不等渲染),所以这一项在隐藏标签页里也可读。
  693. // 它是判定"动态 uniform 有没有接对"的决定性证据:
  694. // · 存的是 `function` → 每帧现取,改 state 立刻生效(正确形态);
  695. // · 存的是 `number` → 构造时快照,事后改原始对象**永远不生效**(本票的坑)。
  696. const stored = stage._actualUniforms ? stage._actualUniforms.effectStrength : undefined
  697. storedKind = typeof stored
  698. storedValue = typeof stored === 'number' ? stored : null
  699. } catch (error) {
  700. storedKind = null
  701. }
  702. return {
  703. intended: state.effectStrength,
  704. deliveredToShader: delivered,
  705. matches: delivered === state.effectStrength,
  706. /** `'function'` = 动态(对);`'number'` = 常量快照(错,改了不生效)。 */
  707. storedKind,
  708. storedValue,
  709. enabled: stage.enabled,
  710. ready: stage.ready === true,
  711. }
  712. },
  713. destroy() {
  714. if (scene.isDestroyed && scene.isDestroyed()) return
  715. if (stage.isDestroyed && stage.isDestroyed()) return
  716. scene.postProcessStages.remove(stage)
  717. },
  718. }
  719. }
  720. /** 把强度与颜色写进 uniform(**只改 dynamic state,不重建 stage**)。 */
  721. export function applyFogUniforms(handle, { strength, colorRgb, startDistance, endDistance }) {
  722. if (!handle || !handle.state) return
  723. const state = handle.state
  724. if (Number.isFinite(strength)) state.effectStrength = Math.min(1, Math.max(0, strength))
  725. if (colorRgb) state.fogColor = new Cartesian3(colorRgb.r, colorRgb.g, colorRgb.b)
  726. if (Number.isFinite(startDistance)) state.startDistance = startDistance
  727. if (Number.isFinite(endDistance)) state.endDistance = endDistance
  728. // 开关与强度同源:强度为 0 就没有必要跑这一遍全屏 pass。
  729. if (handle.stage) handle.stage.enabled = state.effectStrength > 0
  730. }
  731. /** 该处理级是否已可执行(**不等于**画面已被遮住 —— 规格明确区分这两件事)。 */
  732. export function fogStageExecutable(handle) {
  733. if (!handle || !handle.stage) return false
  734. if (handle.stage.isDestroyed && handle.stage.isDestroyed()) return false
  735. return handle.stage.ready === true
  736. }
  737. /** 当前是否有自建雾处理级挂在场景上(排查与走查读数)。 */
  738. export function hasFogStage(viewer) {
  739. if (!viewer || !viewer.scene || !viewer.scene.postProcessStages) return false
  740. return viewer.scene.postProcessStages.length > 0
  741. }
  742. // ================================================================ 点位标记(票 09)
  743. /**
  744. * 点位标记的数据源名:**唯一一个**,重新同步时整份替换而不是叠加
  745. * (否则翻一页就多画一批,地图上会出现已经不存在于当前快照的点)。
  746. */
  747. export const POINT_MARKER_SOURCE = 'cockpit-point-markers'
  748. /** 管线数据源名(票 22 交付;票 14 的高亮已经按它扫,落地当天即生效,不需要再改高亮)。 */
  749. export const PIPELINE_SOURCE = 'cockpit-pipelines'
  750. /**
  751. * 关联监测设备图层的**独立**数据源名(票 42)。
  752. *
  753. * 与 `POINT_MARKER_SOURCE` 分开是**结构性**的:关联设备只进这个临时图层,
  754. * 永远不写回摘要标记计划(规格:摘要分页、摘要图例、`data-map-markers*` 都不含它)。
  755. */
  756. export const DEVICE_MARKER_SOURCE = 'cockpit-associated-device-markers'
  757. /**
  758. * 建/取点位标记的数据源(幂等)。
  759. *
  760. * **必须自己缓存实例,不能只靠 `dataSources.getByName()`**:
  761. * Cesium 的 `DataSourceCollection.add()` 是**异步**的(`Promise.resolve(ds).then(...)` 里才 push
  762. * 进 `_dataSources`)。同一个 tick 里「先写后读」会走成这样:
  763. * 1. `syncPointMarkers()` 建了数据源 A(还没注册进集合)并把 8 个标记加进 A;
  764. * 2. 紧接着 `readPointMarkers()` 调 `getByName()` —— 集合里还没有 A,于是**又建了一个空数据源 B**,
  765. * 读回来 0 个标记。
  766. * 症状极具迷惑性:模块计数与 dataset 都说 8,组件渲染出来的却是「点位标记 0」,
  767. * 而晚一点的 DEV 把手调用(那时 A 已注册)又读回 8 —— 同一个页面两套答案。
  768. * 这是视觉探针截图里「图例有计数、标记写 0」的真正原因(走查没抓到,因为它读的是 dataset 与 DEV 把手)。
  769. *
  770. * 用 WeakMap 按 viewer 缓存:viewer 销毁后条目自然消失,不会漏实例,也不会在集合里留空数据源。
  771. */
  772. const markerSources = new WeakMap()
  773. function pointMarkerSource(viewer) {
  774. const cached = markerSources.get(viewer)
  775. if (cached) return cached
  776. // 兜一道:热重载等情况下集合里可能已经有同名数据源,优先复用它。
  777. const existing = viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0]
  778. if (existing) {
  779. markerSources.set(viewer, existing)
  780. return existing
  781. }
  782. const source = new CustomDataSource(POINT_MARKER_SOURCE)
  783. viewer.dataSources.add(source)
  784. markerSources.set(viewer, source)
  785. return source
  786. }
  787. /**
  788. * 把标记计划同步到地图上(票 09 验收第 1 条:标记按后端返回状态渲染)。
  789. *
  790. * 形状与颜色**都来自计划**(`domain/pointStatus.js` + 服务层读出的语义色):
  791. * 引擎这一层不认识「报警」这种业务词,只认 `shape` 与 `color`,所以状态→视觉的映射
  792. * 只有一份,不会在这里长出第二份。
  793. *
  794. * `HeightReference.CLAMP_TO_GROUND` 让标记贴着地形(票 21 有 DEM,票 30 已有地形感知拾取),
  795. * 否则点位会浮在山体上方或埋进去。缺坐标/非法坐标的对象**不在计划里**(票 07 已拦下)。
  796. *
  797. * @param {Viewer} viewer
  798. * @param {Array} markers `domain/pointStatus.js` 的 `planPointMarkers().markers`
  799. * @param {(marker: object) => string} colorOf 取该标记的 CSS 颜色(服务层从 token 读,引擎不写色值)
  800. * @param {(shape: string, color: string) => string} imageOf 形状+颜色 → 图片 URL(domain/markerSvg)
  801. * @returns {{rendered: number, skipped: number}}
  802. */
  803. export function syncPointMarkers(viewer, markers, colorOf, imageOf) {
  804. const source = pointMarkerSource(viewer)
  805. source.entities.removeAll()
  806. let rendered = 0
  807. let skipped = 0
  808. for (const marker of markers || []) {
  809. // 经纬度缺失或非有限值不上图:计划层已按 `renderable` 过滤,
  810. // 这里再挡一道,免得脏数据把整批 entity 建崩(一条坏数据不该让其余点位消失)。
  811. if (!Number.isFinite(marker.longitude) || !Number.isFinite(marker.latitude)) {
  812. skipped += 1
  813. continue
  814. }
  815. const color = colorOf(marker)
  816. source.entities.add({
  817. id: `point|${marker.key}`,
  818. position: Cartesian3.fromDegrees(marker.longitude, marker.latitude),
  819. billboard: {
  820. image: imageOf(marker.shape, color),
  821. // 像素尺寸同步放大:大屏上 12px 的点位看不清形状(形状是「不只靠颜色」的那一半)。
  822. width: 18,
  823. height: 18,
  824. scale: 1,
  825. // 贴地:有地形时点位落在地表而不是椭球面。
  826. heightReference: HeightReference.CLAMP_TO_GROUND,
  827. verticalOrigin: VerticalOrigin.CENTER,
  828. disableDepthTestDistance: Number.POSITIVE_INFINITY,
  829. },
  830. // 供走查/人工排查读状态:不依赖中文字符串,也不依赖读图。
  831. properties: {
  832. pointCode: marker.code,
  833. deviceCodes: marker.deviceCodes || [],
  834. pointKind: marker.kind,
  835. pointStatus: marker.status,
  836. visualKey: marker.visualKey,
  837. shape: marker.shape,
  838. tone: marker.tone,
  839. },
  840. })
  841. rendered += 1
  842. }
  843. return { rendered, skipped }
  844. }
  845. /**
  846. * 只读回点位标记的**真实图层内容**(走查据此断言「标记真的画在地图上」,
  847. * 而不是只信计划算出来的数)。
  848. *
  849. * 除了计数与状态分布,还回每个视觉键对应的 billboard 图片 URL:走查用它证明
  850. * 「每个状态的颜色确实不同、形状确实是形状」——这两件事只有从引擎实体上读才算证据。
  851. */
  852. export function readPointMarkers(viewer) {
  853. const source = pointMarkerSource(viewer)
  854. const byStatus = {}
  855. const shapes = new Set()
  856. const images = {}
  857. for (const entity of source.entities.values) {
  858. const properties = entity.properties || {}
  859. const get = (key) => (properties[key] ? properties[key].getValue() : '')
  860. const visualKey = get('visualKey') || 'UNKNOWN'
  861. const shape = get('shape') || ''
  862. byStatus[visualKey] = (byStatus[visualKey] || 0) + 1
  863. if (shape) shapes.add(shape)
  864. // 同一视觉键只留一份图片,够判「不同状态图片不同」。
  865. if (!images[visualKey]) {
  866. const billboard = entity.billboard
  867. images[visualKey] = {
  868. shape,
  869. tone: get('tone'),
  870. image: billboard ? billboard.image.getValue() : '',
  871. }
  872. }
  873. }
  874. return { count: source.entities.values.length, byStatus, shapes: [...shapes].filter(Boolean).sort(), images }
  875. }
  876. /**
  877. * 只读回点位标记的**窗口坐标**(票 45 走查用:真实鼠标要点的位置)。
  878. *
  879. * 与 `readOverlapTargets` 同一条纪律:只回投影后的屏幕坐标与身份,不出 Cesium 实体。
  880. * 用**逻辑可见性** `entity.show`(`isShowing` 是渲染态,headless 下会差一帧)。
  881. */
  882. export function readPointMarkerTargets(viewer) {
  883. const source = pointMarkerSource(viewer)
  884. const rect = viewer && viewer.canvas ? viewer.canvas.getBoundingClientRect() : null
  885. if (!rect || !viewer.scene) return []
  886. const ellipsoid = viewer.scene.globe && viewer.scene.globe.ellipsoid
  887. return source.entities.values
  888. .map((entity) => {
  889. const properties = entity.properties || {}
  890. const get = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
  891. const position = entity.position && entity.position.getValue ? entity.position.getValue(viewer.clock.currentTime) : null
  892. if (!position) return null
  893. // 点位标记是 `HeightReference.CLAMP_TO_GROUND` **贴地形**渲染的;
  894. // 直接投影 `entity.position`(椭球零高度)会与画面上看到的标记差出一段地形高度,
  895. // 真实鼠标按那个坐标点会**打不中**(票 45 真实路径走查抓到)。
  896. // 这里与渲染同口径:先取地形高度,再投影。
  897. let world = position
  898. if (ellipsoid) {
  899. const carto = ellipsoid.cartesianToCartographic(position)
  900. if (carto) {
  901. let height = 0
  902. const sampled = viewer.scene.globe.getHeight(carto)
  903. if (Number.isFinite(sampled)) height = sampled
  904. world = Cartesian3.fromRadians(carto.longitude, carto.latitude, height)
  905. }
  906. }
  907. const projected = SceneTransforms.worldToWindowCoordinates(viewer.scene, world)
  908. if (!projected || !Number.isFinite(projected.x) || !Number.isFinite(projected.y)) return null
  909. return {
  910. x: projected.x + rect.left,
  911. y: projected.y + rect.top,
  912. code: get('pointCode'),
  913. status: get('pointStatus'),
  914. visible: entity.show !== false,
  915. }
  916. })
  917. .filter(Boolean)
  918. }
  919. /** 清空点位标记(快照失效/刷新前用:旧快照的点不得留在图上)。 */
  920. export function clearPointMarkers(viewer) {
  921. pointMarkerSource(viewer).entities.removeAll()
  922. }
  923. // ================================================================ 关联监测设备图层(票 42)
  924. const deviceSources = new WeakMap()
  925. /** 建/取关联设备图层的数据源(幂等,与点位标记同款:必须自己缓存,理由见上方注释)。 */
  926. function deviceMarkerSource(viewer) {
  927. const cached = deviceSources.get(viewer)
  928. if (cached) return cached
  929. const existing = viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0]
  930. if (existing) {
  931. deviceSources.set(viewer, existing)
  932. return existing
  933. }
  934. const source = new CustomDataSource(DEVICE_MARKER_SOURCE)
  935. viewer.dataSources.add(source)
  936. deviceSources.set(viewer, source)
  937. return source
  938. }
  939. /**
  940. * 把**当前选中点位的关联设备**同步到独立临时图层(票 42)。
  941. *
  942. * 三件事是规格硬要求,都体现在签名与实体属性上:
  943. * 1. **整份替换**(`removeAll` 后重建):关闭/换点/刷新时旧图层立即撤下,不会与新的混在一起;
  944. * 2. **独立身份**:实体 id 与属性都带 `deviceCode` **和** `monitoringPointCode`,
  945. * 绝不写 `pointCode` —— 设备不冒充监测点(票 05 的结构性保证);
  946. * 3. **尺寸小于监测点**(14 vs 18):层级靠尺寸区分,具体形状/资产接缝归票 44。
  947. *
  948. * 调用方只传**已判定可上图**的行(`domain/deviceLayer.js` 的 `placement === 'drawn'`);
  949. * 这里再挡一道经纬度,避免一条脏数据把整批 entity 建崩。
  950. *
  951. * @param {Viewer} viewer
  952. * @param {Array} devices 领域行(含 `code`/`pointCode`/`longitude`/`latitude`/`status`)
  953. * @param {(device: object) => string} colorOf 取该设备的 CSS 颜色(色值仍由服务层从 token 读)
  954. * @param {(shape: string, color: string) => string} imageOf 形状+颜色 → 图片 URL
  955. * @returns {{rendered: number, skipped: number}}
  956. */
  957. export function syncAssociatedDevices(viewer, devices, colorOf, imageOf, overlapGroups = []) {
  958. const source = deviceMarkerSource(viewer)
  959. source.entities.removeAll()
  960. // 票 47:实体是**整份重建**的,动效基准与循环必须一起复位
  961. // (否则基准表里留着上一批实体的 id,动效会写到不存在的对象上)。
  962. deviceBaseStyle.clear()
  963. stopDeviceEffects()
  964. let rendered = 0
  965. let skipped = 0
  966. const groupsByCode = new Map()
  967. for (const group of Array.isArray(overlapGroups) ? overlapGroups : []) {
  968. if (!group || group.k < 2) continue
  969. for (const row of group.rows || []) groupsByCode.set(row.code, group)
  970. }
  971. for (const device of devices || []) {
  972. if (!Number.isFinite(device.longitude) || !Number.isFinite(device.latitude)) {
  973. skipped += 1
  974. continue
  975. }
  976. const color = colorOf(device)
  977. // 票 44:尺寸与形状都来自**单一资产接缝**(`domain/deviceAssets.js` 的计划),
  978. // 引擎不认识"设备该多大",也不写死尺寸字面量。
  979. const size = Number.isFinite(device.size) ? device.size : 14
  980. const group = groupsByCode.get(device.code)
  981. source.entities.add({
  982. id: `device|${device.pointCode}|${device.code}`,
  983. position: Cartesian3.fromDegrees(device.longitude, device.latitude),
  984. show: !group,
  985. billboard: {
  986. image: imageOf(device.shape || 'circle', color, size),
  987. width: size,
  988. height: size,
  989. scale: 1,
  990. pixelOffset: new Cartesian2(0, 0),
  991. heightReference: HeightReference.CLAMP_TO_GROUND,
  992. verticalOrigin: VerticalOrigin.CENTER,
  993. },
  994. properties: {
  995. deviceCode: device.code,
  996. monitoringPointCode: device.pointCode,
  997. deviceStatus: device.status || '',
  998. deviceShape: device.shape || 'circle',
  999. assetMode: device.assetMode || '',
  1000. assetReason: device.assetReason || '',
  1001. assetReasonText: device.assetReasonText || '',
  1002. overlapGroup: group ? group.key : '',
  1003. overlapPointCode: group ? group.pointCode : '',
  1004. overlapAggregate: false,
  1005. overlapFanIndex: -1,
  1006. },
  1007. })
  1008. rendered += 1
  1009. }
  1010. // 聚合符号没有 deviceCode:拾取它只能返回 overlap action,不能冒充任一台设备。
  1011. for (const group of Array.isArray(overlapGroups) ? overlapGroups : []) {
  1012. if (!group || group.k < 2 || !group.rows.length) continue
  1013. const representative = group.rows.find((row) => row.status === (group.visual && group.visual.status)) || group.rows[0]
  1014. const color = colorOf(representative)
  1015. const size = Number.isFinite(representative.size) ? representative.size : 14
  1016. source.entities.add({
  1017. id: `overlap|${group.pointCode}|${group.key}`,
  1018. position: Cartesian3.fromDegrees(representative.longitude, representative.latitude),
  1019. billboard: {
  1020. image: imageOf(group.visual.shape, color, size),
  1021. width: size,
  1022. height: size,
  1023. scale: 1,
  1024. pixelOffset: new Cartesian2(0, 0),
  1025. heightReference: HeightReference.CLAMP_TO_GROUND,
  1026. verticalOrigin: VerticalOrigin.CENTER,
  1027. },
  1028. label: {
  1029. text: String(group.k),
  1030. font: 'bold 12px sans-serif',
  1031. fillColor: Color.WHITE,
  1032. outlineColor: Color.BLACK,
  1033. outlineWidth: 2,
  1034. style: LabelStyle.FILL_AND_OUTLINE,
  1035. pixelOffset: new Cartesian2(14, -12),
  1036. verticalOrigin: VerticalOrigin.CENTER,
  1037. },
  1038. properties: {
  1039. overlapAggregate: true,
  1040. overlapGroup: group.key,
  1041. overlapPointCode: group.pointCode,
  1042. overlapCount: group.k,
  1043. overlapState: group.state,
  1044. overlapLabel: group.label,
  1045. deviceStatus: group.visual.status,
  1046. // 不能含 `deviceCode` / `monitoringPointCode`:聚合符号不冒充设备身份。
  1047. },
  1048. })
  1049. }
  1050. return { rendered: devices ? devices.length : 0, skipped }
  1051. }
  1052. /**
  1053. * 展开/预览重叠设备:只改 billboard `pixelOffset`(屏幕渲染层);实体 position 与属性经纬度不动。
  1054. * `offsets` 是 `{[deviceCode]: {x,y}}`;collapsed 时恢复为 0。
  1055. */
  1056. export function nudgeOverlap(viewer, groupKey) {
  1057. const source = deviceMarkerSource(viewer)
  1058. let touched = 0
  1059. for (const entity of source.entities.values) {
  1060. const properties = entity.properties || {}
  1061. const group = properties.overlapGroup && properties.overlapGroup.getValue ? properties.overlapGroup.getValue() : ''
  1062. const aggregate = properties.overlapAggregate && properties.overlapAggregate.getValue ? properties.overlapAggregate.getValue() : false
  1063. if (group !== groupKey || aggregate !== true) continue
  1064. if (entity.billboard) {
  1065. entity.billboard.scale = 1.18
  1066. entity.billboard.outlineWidth = 2
  1067. entity.billboard.outlineColor = Color.WHITE.withAlpha(0.85)
  1068. }
  1069. touched += 1
  1070. }
  1071. return touched
  1072. }
  1073. /** 清空重叠聚合面板hover弱提示。 */
  1074. export function clearOverlapNudge(viewer) {
  1075. const source = deviceMarkerSource(viewer)
  1076. for (const entity of source.entities.values) {
  1077. const properties = entity.properties || {}
  1078. const aggregate = properties.overlapAggregate && properties.overlapAggregate.getValue ? properties.overlapAggregate.getValue() : false
  1079. if (aggregate && entity.billboard) {
  1080. entity.billboard.scale = 1
  1081. entity.billboard.outlineWidth = 0
  1082. }
  1083. }
  1084. }
  1085. export function setOverlapPresentation(viewer, { groupKey = '', state = 'collapsed', preview = false, offsets = {} } = {}) {
  1086. const source = deviceMarkerSource(viewer)
  1087. let touched = 0
  1088. for (const entity of source.entities.values) {
  1089. const properties = entity.properties || {}
  1090. const read = (key) => properties[key] && properties[key].getValue ? properties[key].getValue() : ''
  1091. if (!groupKey || read('overlapGroup') !== groupKey) continue
  1092. const aggregate = read('overlapAggregate') === true
  1093. const showFan = state === 'expanded'
  1094. if (aggregate) {
  1095. entity.show = showFan ? preview : true
  1096. } else {
  1097. const code = read('deviceCode')
  1098. const offset = offsets[code]
  1099. entity.show = showFan && Boolean(offset)
  1100. if (entity.billboard) {
  1101. entity.billboard.pixelOffset = offset
  1102. ? new Cartesian2(offset.x, offset.y)
  1103. : new Cartesian2(0, 0)
  1104. }
  1105. }
  1106. touched += 1
  1107. }
  1108. return touched
  1109. }
  1110. /** 只读回关联设备图层的真实内容(走查据此断言「设备真的画在独立图层上」,以及「画成的样子来自接缝」)。 */
  1111. export function readAssociatedDevices(viewer) {
  1112. const source = deviceMarkerSource(viewer)
  1113. return source.entities.values.map((entity) => {
  1114. const properties = entity.properties || {}
  1115. const get = (key) => (properties[key] ? properties[key].getValue() : '')
  1116. // 票 44:实体上**已经**带着接缝的结论(形状/资产模式/降级原因),原读取器却把它们丢掉了 ——
  1117. // 于是「地图上真画的那台设备是什么形状、什么资产模式」在引擎侧读不到,走查只能退回去读
  1118. // 计划行(那是"打算画什么",不是"画成了什么")。这里如实读回来。
  1119. // 尺寸取 **billboard 的真实宽高**,而不是再读一个属性:这样读到的是真画上去的尺寸。
  1120. const size = entity.billboard && entity.billboard.width ? entity.billboard.width.getValue() : null
  1121. return {
  1122. id: entity.id,
  1123. deviceCode: get('deviceCode'),
  1124. monitoringPointCode: get('monitoringPointCode'),
  1125. deviceStatus: get('deviceStatus'),
  1126. shape: get('deviceShape'),
  1127. size,
  1128. assetMode: get('assetMode'),
  1129. assetReason: get('assetReason'),
  1130. assetReasonText: get('assetReasonText'),
  1131. visible: entity.isShowing === true,
  1132. overlapAggregate: get('overlapAggregate') === true,
  1133. overlapGroup: get('overlapGroup'),
  1134. overlapPointCode: get('overlapPointCode'),
  1135. overlapCount: get('overlapCount'),
  1136. pixelOffset: entity.billboard && entity.billboard.pixelOffset
  1137. ? entity.billboard.pixelOffset.getValue()
  1138. : null,
  1139. }
  1140. })
  1141. }
  1142. /** 清空关联设备图层(关闭 / 换点 / 快照失效 / 离开页面都走它)。 */
  1143. /**
  1144. * 票 45:重叠实体的只读窗口坐标(真实 Cesium 投影 + pixelOffset)。
  1145. * 走查用它把真实鼠标送到聚合符号/展开子点;只读渲染坐标,不写设备经纬度。
  1146. */
  1147. export function readOverlapTargets(viewer) {
  1148. const source = deviceMarkerSource(viewer)
  1149. const rect = viewer && viewer.canvas ? viewer.canvas.getBoundingClientRect() : null
  1150. if (!rect || !viewer.scene) return []
  1151. return source.entities.values
  1152. .filter((entity) => entity.properties && entity.properties.overlapGroup)
  1153. .map((entity) => {
  1154. const position = entity.position && entity.position.getValue ? entity.position.getValue(viewer.clock.currentTime) : null
  1155. const projected = position ? SceneTransforms.worldToWindowCoordinates(viewer.scene, position) : null
  1156. if (!projected || !Number.isFinite(projected.x) || !Number.isFinite(projected.y)) return null
  1157. const properties = entity.properties || {}
  1158. const get = (key) => properties[key] && properties[key].getValue ? properties[key].getValue() : ''
  1159. const offset = entity.billboard && entity.billboard.pixelOffset && entity.billboard.pixelOffset.getValue
  1160. ? entity.billboard.pixelOffset.getValue()
  1161. : { x: 0, y: 0 }
  1162. return {
  1163. x: projected.x + rect.left + (offset.x || 0),
  1164. y: projected.y + rect.top + (offset.y || 0),
  1165. // 用**逻辑可见性** `entity.show`(Cesium 里 `isShowing` 是渲染态,headless 下
  1166. // `show` 改变后可能要等一帧才同步;拾取判据不能依赖渲染帧,否则展开态设备
  1167. // 会被误滤掉而聚合却可见 —— 真机走查抓到过这一帧)。
  1168. visible: entity.show !== false,
  1169. aggregate: get('overlapAggregate') === true,
  1170. deviceCode: get('deviceCode'),
  1171. overlapGroup: get('overlapGroup'),
  1172. }
  1173. })
  1174. .filter(Boolean)
  1175. }
  1176. export function clearAssociatedDevices(viewer) {
  1177. // 票 47:图层撤下 ⇒ 特效随图层一起消失(决策票 07:不需要单独的消失逻辑,
  1178. // 因为特效就挂在图层上)。这里显式停循环,避免空转写帧。
  1179. stopDeviceEffects()
  1180. deviceBaseStyle.clear()
  1181. deviceMarkerSource(viewer).entities.removeAll()
  1182. }
  1183. /**
  1184. * 屏幕坐标 → **设备标记身份**(票 42 的独立拾取入口;票 43 会在此基础上做双向联动)。
  1185. *
  1186. * 与 `pickPointMarkerAt()` 同一条纪律:只认本图层上带 `deviceCode` 的实体,
  1187. * 回**身份**不回 Cesium 实体,且**设备优先于监测点**(票 15 的拾取优先级)。
  1188. */
  1189. function overlapTargetAt(viewer, windowX, windowY) {
  1190. const targets = readOverlapTargets(viewer)
  1191. let nearest = null
  1192. let distance = Infinity
  1193. for (const target of targets.filter((item) => item.visible)) {
  1194. const current = Math.hypot(target.x - windowX, target.y - windowY)
  1195. if (current <= 24 && current < distance) {
  1196. nearest = target
  1197. distance = current
  1198. }
  1199. }
  1200. if (!nearest) return null
  1201. if (nearest.aggregate) return { kind: 'monitoring-device-overlap', code: '', pointCode: '', overlapGroup: nearest.overlapGroup }
  1202. if (nearest.deviceCode) return { kind: 'monitoring-device', code: nearest.deviceCode, pointCode: '', status: '' }
  1203. return null
  1204. }
  1205. export function pickDeviceMarkerAt(viewer, windowX, windowY) {
  1206. if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
  1207. // 票45:展开后的设备拥有屏幕pixelOffset;先按公开投影读数做近邻身份判定,
  1208. // 避免同屏监测点/地形顶层对象让普通scene.pick吞掉真实设备。仍保持普通拾取语义。
  1209. const projectedHit = overlapTargetAt(viewer, windowX, windowY)
  1210. if (projectedHit) return projectedHit
  1211. const rect = viewer.canvas.getBoundingClientRect()
  1212. const picked = viewer.scene.pick(new Cartesian2(windowX - rect.left, windowY - rect.top))
  1213. const entity = picked && picked.id
  1214. const properties = entity && entity.properties
  1215. if (properties) {
  1216. const read = (key) => (properties[key] ? properties[key].getValue() : '')
  1217. if (read('overlapAggregate') === true) {
  1218. return {
  1219. kind: 'monitoring-device-overlap',
  1220. code: '',
  1221. pointCode: read('overlapPointCode'),
  1222. overlapGroup: read('overlapGroup'),
  1223. overlapCount: read('overlapCount'),
  1224. }
  1225. }
  1226. const code = read('deviceCode')
  1227. if (code) return { kind: 'monitoring-device', code, pointCode: read('monitoringPointCode'), status: read('deviceStatus') }
  1228. }
  1229. // 重叠聚合与监测点同屏时,scene.pick 可能只返回顶层点位;
  1230. // 用聚合实体投影做近邻补偿,仍是普通拾取、不改真实位置。
  1231. return overlapTargetAt(viewer, windowX, windowY)
  1232. }
  1233. /** 某台设备标记是否存在(暂隐/重新定位类判据用)。 */
  1234. export function hasDeviceMarker(viewer, pointCode, code) {
  1235. if (!viewer || !code) return false
  1236. const source = deviceSources.get(viewer) || viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0]
  1237. return !!(source && source.show && source.entities.getById(`device|${pointCode}|${code}`))
  1238. }
  1239. // ================================================================ 设备点的对应反应(票 43)
  1240. /**
  1241. * 把「每台设备的反应档位」写进显示参数(票 43 的反应语言)。
  1242. *
  1243. * 三条纪律都体现在这里:
  1244. * 1. **不用颜色**:只改 `scale` 与 `color.alpha`(弱化是压暗,不是换色)——
  1245. * 色键语义仍只属于设备状态(票 44/07);
  1246. * 2. **外环**用 billboard 的 `outlineWidth`/`outlineColor`(Cesium 公开属性),
  1247. * 不用新建实体、不用着色器 —— 关掉时写回 0;
  1248. * 3. **每个实体都过一遍**:命中与未命中都要写回值,否则上次的强化会一直留在图上
  1249. * (与票 14 的 `highlightEntities` 同一条教训)。
  1250. *
  1251. * @param {Viewer} viewer
  1252. * @param {Array} reactions `domain/deviceCorrespondence.js` 的 `planDeviceReactions().reactions`
  1253. * @param {(reaction: string) => object} styleOf 档位 → `{ scale, alpha, ring }`
  1254. * @returns {number} 本次被写入的实体数(0 = 图层为空 / 引擎没就绪)
  1255. */
  1256. export function applyDeviceReactions(viewer, reactions, styleOf) {
  1257. const source = deviceSources.get(viewer) || (viewer && viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null)
  1258. if (!source) return 0
  1259. const byId = new Map()
  1260. for (const item of reactions || []) {
  1261. if (item && item.code) byId.set(`device|${item.pointCode}|${item.code}`, item.reaction)
  1262. }
  1263. let written = 0
  1264. for (const entity of source.entities.values) {
  1265. // 图层里没有对应反应的实体(理论上不该有)也按常态写回,避免残留强化。
  1266. const reaction = byId.get(entity.id) || 'normal'
  1267. const style = styleOf(reaction) || { scale: 1, alpha: 1, ring: false }
  1268. const billboard = entity.billboard
  1269. if (!billboard) continue
  1270. billboard.scale = style.scale
  1271. // 弱化=压暗(降 alpha),**不动色相**:颜色仍表达设备状态。
  1272. billboard.color = Color.WHITE.withAlpha(style.alpha)
  1273. // 选中外环:Cesium billboard 自带的描边,不用新建实体。
  1274. billboard.outlineWidth = style.ring ? 3 : 0
  1275. billboard.outlineColor = Color.WHITE
  1276. // 票 47:记下**基准**(动效在此之上做乘性修饰,不覆盖反应量)。
  1277. deviceBaseStyle.set(entity.id, { scale: style.scale, alpha: style.alpha })
  1278. written += 1
  1279. }
  1280. return written
  1281. }
  1282. /**
  1283. * 票 47:设备状态**动效**驱动器(决策票 07 的三个动效 + 门控 + 降级)。
  1284. *
  1285. * ── 为什么要和票 43 的"反应量"**合成**而不是各写各的 ──
  1286. * `applyDeviceReactions()` 已经按选中/hover/弱化写 `scale` 与 `alpha`;动效也要动这两个属性。
  1287. * 若动效直接赋值,两者会互相抹掉(表现为"选中态闪一下就没了"或"动画把弱化顶回去")。
  1288. * 因此这里记住**反应量写下的基准值**(`deviceBaseStyle`),动效只在基准上做**乘性**修饰。
  1289. *
  1290. * ── 为什么不用 `CallbackProperty` ──
  1291. * `isConstant=false` 的回调会持续请求新帧(票 02 已核),代价被限制在**异常设备**上是对的,
  1292. * 但更简单且可控的做法是:一个 rAF 循环只写**参与动效的实体**,其余实体一个字节不动。
  1293. * 场景本身未开 `requestRenderMode`,所以这个循环不额外改变渲染节奏。
  1294. *
  1295. * ── 载体是场景内 billboard,**不是后处理级** ──
  1296. * 决策票 07 已裁定:特效走 billboard ⇒「先特效、后雾」自动成立,不占 `PostProcessStageCollection`、
  1297. * 与雾无排序冲突,也不新增穿雾例外。
  1298. */
  1299. const deviceEffectState = {
  1300. viewer: null,
  1301. plan: null,
  1302. selectedCode: '',
  1303. raf: 0,
  1304. tick: 0,
  1305. }
  1306. /** 反应量写下的**基准** scale/alpha(动效在其上做乘性修饰)。键=实体 id。 */
  1307. const deviceBaseStyle = new Map()
  1308. /**
  1309. * 票 47(科技感方向):光环实体池。
  1310. *
  1311. * ── 为什么用**独立 billboard 实体**而不是改设备图标本身 ──
  1312. * 决策票 07 的参考图要的是「多层同心扩散环 + 中心辉光 + 能量描边」;
  1313. * 设备图标只有一个 billboard,塞不下多层环。所以另建一层光环实体(`fx|` 前缀):
  1314. * · **不带 `deviceCode`** ⇒ 按研究笔记第 7 条,它不参与设备拾取判定,不会挡住设备点;
  1315. * · 位置跟设备同坐标,随图层一起重建/销毁(特效挂在图层上);
  1316. * · 每台最多 `RING_LAYERS` 个环 + 1 个辉光,同处一个数据源(一次 draw call 量级)。
  1317. */
  1318. const RING_ENTITY_PREFIX = 'fx|'
  1319. function ringEntitiesOf(source, code) {
  1320. return source.entities.values.filter((entity) =>
  1321. typeof entity.id === 'string' &&
  1322. entity.id.startsWith(RING_ENTITY_PREFIX) &&
  1323. entity.id.includes(`|${code}|`))
  1324. }
  1325. /** 建/更新某台设备的光环实体(层数固定:缺则建、数量不符则整组重建)。 */
  1326. function ensureRingEntities(source, device, color, fxImageOf) {
  1327. const size = Number.isFinite(device.size) ? device.size : 14
  1328. // 层数与"要不要辉光"都问 domain(引擎不认识业务状态名,只认"几层环")。
  1329. const wanted = ringEntityCount(device.status)
  1330. if (!wanted) return []
  1331. const existing = ringEntitiesOf(source, device.code)
  1332. if (existing.length === wanted) return existing
  1333. for (const entity of existing) source.entities.remove(entity)
  1334. const created = []
  1335. for (let layer = 0; layer < RING_LAYERS; layer += 1) {
  1336. created.push(source.entities.add({
  1337. id: `${RING_ENTITY_PREFIX}${device.pointCode}|${device.code}|ring${layer}`,
  1338. position: Cartesian3.fromDegrees(device.longitude, device.latitude),
  1339. billboard: {
  1340. // 环用**能量场图片**(径向渐变柔和边缘):比 2px 描边圆更像特效,
  1341. // 放大时边缘自然扩散,不会一放大就细成一根线(首版真机截图抓到的问题)。
  1342. image: fxImageOf('ring', color, size),
  1343. width: size,
  1344. height: size,
  1345. scale: 1,
  1346. color: Color.WHITE.withAlpha(0),
  1347. heightReference: HeightReference.CLAMP_TO_GROUND,
  1348. verticalOrigin: VerticalOrigin.CENTER,
  1349. },
  1350. properties: {
  1351. effectFx: true,
  1352. effectFxKind: 'ring',
  1353. effectFxLayer: layer,
  1354. effectFxFor: device.code,
  1355. effectFxStatus: device.status,
  1356. // 刻意**不写 deviceCode / monitoringPointCode**:光环不冒充设备、不吃拾取。
  1357. },
  1358. }))
  1359. }
  1360. if (hasGlow(device.status)) {
  1361. created.push(source.entities.add({
  1362. id: `${RING_ENTITY_PREFIX}${device.pointCode}|${device.code}|glow`,
  1363. position: Cartesian3.fromDegrees(device.longitude, device.latitude),
  1364. billboard: {
  1365. image: fxImageOf('glow', color, size),
  1366. width: size,
  1367. height: size,
  1368. scale: 1,
  1369. color: Color.WHITE.withAlpha(0),
  1370. heightReference: HeightReference.CLAMP_TO_GROUND,
  1371. verticalOrigin: VerticalOrigin.CENTER,
  1372. },
  1373. properties: {
  1374. effectFx: true,
  1375. effectFxKind: 'glow',
  1376. effectFxLayer: -1,
  1377. effectFxFor: device.code,
  1378. effectFxStatus: device.status,
  1379. },
  1380. }))
  1381. }
  1382. return created
  1383. }
  1384. /** 清掉某台(或全部)光环实体。 */
  1385. function clearRingEntities(source, code = '') {
  1386. for (const entity of source.entities.values.slice()) {
  1387. if (typeof entity.id !== 'string' || !entity.id.startsWith(RING_ENTITY_PREFIX)) continue
  1388. if (code && !entity.id.includes(`|${code}|`)) continue
  1389. source.entities.remove(entity)
  1390. }
  1391. }
  1392. function baseStyleOf(entityId) {
  1393. return deviceBaseStyle.get(entityId) || { scale: 1, alpha: 1 }
  1394. }
  1395. function animatedByCode(plan) {
  1396. const map = new Map()
  1397. for (const item of (plan && plan.animated) || []) map.set(item.code, item.effect)
  1398. return map
  1399. }
  1400. function writeEffectFrame() {
  1401. const viewer = deviceEffectState.viewer
  1402. const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null))
  1403. if (!viewer || !source) return
  1404. const plan = deviceEffectState.plan
  1405. const animated = animatedByCode(plan)
  1406. const now = Date.now()
  1407. // 选中那台**额外强化**(决策票 07:选中不等于"只有它有特效",而是它更强)。
  1408. const amp = 0.34
  1409. const ampSelected = 0.55
  1410. for (const entity of source.entities.values) {
  1411. const billboard = entity.billboard
  1412. if (!billboard) continue
  1413. const properties = entity.properties || {}
  1414. const code = properties.deviceCode && properties.deviceCode.getValue ? properties.deviceCode.getValue() : ''
  1415. const base = baseStyleOf(entity.id)
  1416. const effect = code ? animated.get(code) : ''
  1417. if (!effect) {
  1418. // 不参与动效(正常/离线/未知,或特效关闭/降级):写回**基准**,不留上一帧的残留。
  1419. billboard.scale = base.scale
  1420. billboard.color = Color.WHITE.withAlpha(base.alpha)
  1421. billboard.rotation = 0
  1422. continue
  1423. }
  1424. const isSelected = deviceEffectState.selectedCode && code === deviceEffectState.selectedCode
  1425. const strength = isSelected ? ampSelected : amp
  1426. if (effect === DEVICE_EFFECT.SPIN) {
  1427. // 旋转环:只转角度,不呼吸(与脉冲/闪烁区分开)。
  1428. billboard.rotation = spinAngleAt(now)
  1429. billboard.scale = base.scale
  1430. billboard.color = Color.WHITE.withAlpha(base.alpha)
  1431. continue
  1432. }
  1433. const factor = effect === DEVICE_EFFECT.BLINK ? blinkStrengthAt(now) : pulseStrengthAt(now)
  1434. billboard.scale = base.scale * (1 + strength * factor)
  1435. // 脉冲/闪烁同时走**透明度**:只放大不透明度变化时,远看几乎看不出在动。
  1436. billboard.color = Color.WHITE.withAlpha(base.alpha * (0.45 + 0.55 * factor))
  1437. billboard.rotation = 0
  1438. }
  1439. // ── 科技感光环层(多层同心扩散环 + 中心辉光)──
  1440. // 光环实体不带 deviceCode,所以**不参与上面的设备图标循环**,也不吃拾取。
  1441. const selectedBoost = 1.25
  1442. for (const entity of source.entities.values) {
  1443. const billboard = entity.billboard
  1444. const properties = entity.properties || {}
  1445. const isFx = properties.effectFx && properties.effectFx.getValue && properties.effectFx.getValue() === true
  1446. if (!billboard || !isFx) continue
  1447. const forCode = properties.effectFxFor && properties.effectFxFor.getValue ? properties.effectFxFor.getValue() : ''
  1448. const status = properties.effectFxStatus && properties.effectFxStatus.getValue ? properties.effectFxStatus.getValue() : ''
  1449. const kind = properties.effectFxKind && properties.effectFxKind.getValue ? properties.effectFxKind.getValue() : ''
  1450. const layer = properties.effectFxLayer && properties.effectFxLayer.getValue ? properties.effectFxLayer.getValue() : 0
  1451. const effect = forCode ? animated.get(forCode) : ''
  1452. if (!effect) {
  1453. // 无动效(正常/离线/未知、特效关闭或降级):光环**完全透明**,不留残影。
  1454. billboard.scale = 1
  1455. billboard.color = Color.WHITE.withAlpha(0)
  1456. continue
  1457. }
  1458. const isSelected = deviceEffectState.selectedCode && forCode === deviceEffectState.selectedCode
  1459. const boost = isSelected ? selectedBoost : 1
  1460. if (kind === 'glow') {
  1461. const strength = glowStrengthAt(now)
  1462. billboard.scale = glowScaleAt(now) * boost
  1463. billboard.color = Color.WHITE.withAlpha(0.32 * strength * boost)
  1464. continue
  1465. }
  1466. // 多层环:每层相位错开、由内向外扩张渐隐。
  1467. const frame = ringFrameAt(status, now)
  1468. const ring = frame.rings[layer] || { scale: 1, alpha: 0 }
  1469. billboard.scale = ring.scale * boost
  1470. billboard.color = Color.WHITE.withAlpha(Math.min(1, ring.alpha * boost))
  1471. }
  1472. deviceEffectState.tick += 1
  1473. }
  1474. function ensureEffectLoop() {
  1475. if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') return
  1476. if (deviceEffectState.raf) return
  1477. const step = () => {
  1478. if (!deviceEffectState.raf) return
  1479. writeEffectFrame()
  1480. deviceEffectState.raf = window.requestAnimationFrame(step)
  1481. }
  1482. deviceEffectState.raf = window.requestAnimationFrame(step)
  1483. }
  1484. /**
  1485. * 应用动效计划(由服务层在设备图层同步后调用)。
  1486. * @param {Viewer} viewer
  1487. * @param {object} plan `domain/deviceEffects.js` 的 `planDeviceEffects()` 输出
  1488. * @param {object} [options] `{ selectedCode }` 选中设备额外强化
  1489. * @returns {boolean} 是否启动了动效
  1490. */
  1491. export function applyDeviceEffects(viewer, plan, options = {}) {
  1492. deviceEffectState.viewer = viewer || null
  1493. deviceEffectState.plan = plan || null
  1494. deviceEffectState.selectedCode = (options && options.selectedCode) || ''
  1495. const running = Boolean(viewer && plan && plan.enabled === true && !plan.degraded && plan.animated && plan.animated.length)
  1496. // 光环实体按计划重建:参与的设备建环,其余(含降级/关闭时全部)清掉 —— 降级后动画全停,
  1497. // 光环也必须一起消失,否则"停了动画却还留着环"会被读成"还在报警"。
  1498. const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null))
  1499. if (source) {
  1500. if (running && options && options.devices) {
  1501. const animatedCodes = new Set((plan.animated || []).map((item) => item.code))
  1502. for (const device of options.devices) {
  1503. if (!animatedCodes.has(device.code)) { clearRingEntities(source, device.code); continue }
  1504. ensureRingEntities(
  1505. source,
  1506. device,
  1507. options.colorOf ? options.colorOf(device) : Color.WHITE,
  1508. options.fxImageOf || (() => ''),
  1509. )
  1510. }
  1511. // 已不在本批设备里的光环(换点/换状态)也要清。
  1512. const known = new Set(options.devices.map((item) => item.code))
  1513. for (const entity of source.entities.values.slice()) {
  1514. const isFx = entity.properties && entity.properties.effectFx && entity.properties.effectFx.getValue && entity.properties.effectFx.getValue() === true
  1515. if (!isFx) continue
  1516. const forCode = entity.properties.effectFxFor && entity.properties.effectFxFor.getValue ? entity.properties.effectFxFor.getValue() : ''
  1517. if (!known.has(forCode) || !animatedCodes.has(forCode)) source.entities.remove(entity)
  1518. }
  1519. } else {
  1520. clearRingEntities(source)
  1521. }
  1522. }
  1523. if (running) ensureEffectLoop()
  1524. // 即使不跑动效也要写一帧:把上一轮可能残留的放大/透明度**写回基准**。
  1525. writeEffectFrame()
  1526. return running
  1527. }
  1528. /** 停止动效并写回基准(图层撤下 / 详情关闭 / 特效被关掉)。 */
  1529. export function stopDeviceEffects() {
  1530. if (typeof window !== 'undefined' && deviceEffectState.raf && typeof window.cancelAnimationFrame === 'function') {
  1531. window.cancelAnimationFrame(deviceEffectState.raf)
  1532. }
  1533. deviceEffectState.raf = 0
  1534. deviceEffectState.plan = null
  1535. deviceEffectState.selectedCode = ''
  1536. writeEffectFrame()
  1537. }
  1538. /**
  1539. * 只读读回每台设备的**动效状态与当前帧属性**(走查的"真在跑"证据)。
  1540. *
  1541. * 三层证据的第 2 层(场景图跨帧快照):走查对同一设备**隔帧读两次**,
  1542. * 断言 `scale`/`alpha`(脉冲/闪烁)或 `rotation`(旋转环)确实变了,
  1543. * 而正常/离线的设备**完全不变**。这样就不是"拿两份 JS 数据互比"的恒真断言。
  1544. */
  1545. export function readDeviceEffects(viewer) {
  1546. const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null))
  1547. if (!source) return { tick: deviceEffectState.tick, devices: [] }
  1548. const animated = animatedByCode(deviceEffectState.plan)
  1549. const devices = []
  1550. for (const entity of source.entities.values) {
  1551. const billboard = entity.billboard
  1552. if (!billboard) continue
  1553. const properties = entity.properties || {}
  1554. const get = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
  1555. const code = get('deviceCode')
  1556. if (!code) continue
  1557. const color = billboard.color && billboard.color.getValue ? billboard.color.getValue() : null
  1558. devices.push({
  1559. code,
  1560. status: get('deviceStatus'),
  1561. effect: animated.get(code) || '',
  1562. animated: animated.has(code),
  1563. scale: billboard.scale && billboard.scale.getValue ? billboard.scale.getValue() : billboard.scale,
  1564. alpha: color ? color.alpha : null,
  1565. rotation: billboard.rotation && billboard.rotation.getValue ? billboard.rotation.getValue() : (billboard.rotation || 0),
  1566. })
  1567. }
  1568. // 科技感光环的逐层读数(多层同心环 + 辉光):走查据此断言"环真的在扩张渐隐"。
  1569. const fx = []
  1570. for (const entity of source.entities.values) {
  1571. const billboard = entity.billboard
  1572. if (!billboard) continue
  1573. const properties = entity.properties || {}
  1574. const get = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
  1575. if (get('effectFx') !== true) continue
  1576. const color = billboard.color && billboard.color.getValue ? billboard.color.getValue() : null
  1577. fx.push({
  1578. for: get('effectFxFor'),
  1579. kind: get('effectFxKind'),
  1580. layer: get('effectFxLayer'),
  1581. status: get('effectFxStatus'),
  1582. scale: billboard.scale && billboard.scale.getValue ? billboard.scale.getValue() : billboard.scale,
  1583. alpha: color ? color.alpha : null,
  1584. })
  1585. }
  1586. return {
  1587. tick: deviceEffectState.tick,
  1588. running: deviceEffectState.raf !== 0,
  1589. degraded: Boolean(deviceEffectState.plan && deviceEffectState.plan.degraded),
  1590. enabled: Boolean(deviceEffectState.plan && deviceEffectState.plan.enabled),
  1591. ringLayers: RING_LAYERS,
  1592. devices,
  1593. fx,
  1594. }
  1595. }
  1596. /**
  1597. * 选中设备的**归属引线**:从设备点连到监测点标记中心(票 04/12 的面板锚点)。
  1598. *
  1599. * 用一条独立 polyline 实体画在同一个数据源上(`lead|...` 前缀,不参与设备拾取判定)。
  1600. * 重合时调用方不该调本函数(`planDeviceLead().draw === false`),所以这里只负责画。
  1601. */
  1602. export function syncDeviceLead(viewer, { deviceLon, deviceLat, pointLon, pointLat, deviceCode, pointCode } = {}) {
  1603. const source = deviceMarkerSource(viewer)
  1604. // 先清掉上一条引线(同一时刻最多一条:只画**当前选中**那台的归属)。
  1605. for (const entity of source.entities.values.slice()) {
  1606. if (typeof entity.id === 'string' && entity.id.startsWith('lead|')) source.entities.remove(entity)
  1607. }
  1608. if (![deviceLon, deviceLat, pointLon, pointLat].every(Number.isFinite)) return false
  1609. source.entities.add({
  1610. id: `lead|${pointCode}|${deviceCode}`,
  1611. polyline: {
  1612. positions: Cartesian3.fromDegreesArray([deviceLon, deviceLat, pointLon, pointLat]),
  1613. width: 2,
  1614. // 引线是**归属指示**,不用色键(那是设备状态语义);用中性白并压半透明。
  1615. material: Color.WHITE.withAlpha(0.7),
  1616. // 贴地线会随地形起伏扭动,引线要的是"直连"语义,所以不贴地。
  1617. clampToGround: false,
  1618. },
  1619. })
  1620. return true
  1621. }
  1622. /** 清空选中引线(取消选中 / 返回点位 / 图层撤下)。 */
  1623. export function clearDeviceLead(viewer) {
  1624. const source = deviceSources.get(viewer) || (viewer && viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null)
  1625. if (!source) return false
  1626. for (const entity of source.entities.values.slice()) {
  1627. if (typeof entity.id === 'string' && entity.id.startsWith('lead|')) source.entities.remove(entity)
  1628. }
  1629. return true
  1630. }
  1631. /**
  1632. * 屏幕坐标 → 设备标记身份(**hover 用**,与 `pickDeviceMarkerAt` 同一条判据)。
  1633. *
  1634. * 单独一个函数只是为了让 hover 的调用点读起来是"我在做 hover 拾取",
  1635. * 实现完全复用点选那一个(**不另写一套判据** —— 两套迟早会分歧)。
  1636. */
  1637. export function pickDeviceMarkerForHover(viewer, windowX, windowY) {
  1638. return pickDeviceMarkerAt(viewer, windowX, windowY)
  1639. }
  1640. /**
  1641. * 强调一批编码对应的地图实体(票 14:点事件详情时高亮关联设备与关联管线)。
  1642. *
  1643. * 只调**已存在实体**的显示参数,不新增实体 —— 事件因此永远不会变成独立地图图层
  1644. * (票 13 的硬纪律)。两处来源都扫:
  1645. * 1. 点位标记数据源(票 09):按实体属性 `pointCode` / `deviceCodes` 匹配;
  1646. * 2. 管线数据源(**票 22 才交付**):按 `pipelineCode` / `deviceCode` 匹配。
  1647. * 管线层还没落地时这一处自然扫到 0 个,不需要开关,也不会报错 ——
  1648. * 票 22 落地的管线只要带这两个属性之一,本函数就自动把它一起高亮。
  1649. *
  1650. * **传空数组 = 还原全部**(详情收起时用):初值存在 `emphasisBase` 里,还原是把
  1651. * 上次记下的原值写回去,而不是"猜一个默认宽度" —— 否则收起详情后点位标记会一直胖着、
  1652. * 管线的线宽也回不到原样。
  1653. *
  1654. * @param {Viewer} viewer
  1655. * @param {Array<string>} codes 要高亮的编码(点位码 / 设备码 / 管线码);空数组表示还原
  1656. * @returns {number} 本次被强调的实体数(0 = 目标还不在图上,调用方据此说人话)
  1657. */
  1658. export function highlightEntities(viewer, codes) {
  1659. if (!viewer || !viewer.dataSources) return 0
  1660. const wanted = new Set((Array.isArray(codes) ? codes : [codes]).map((item) => String(item || '').trim()).filter(Boolean))
  1661. let matched = 0
  1662. for (const source of [pointMarkerSource(viewer), pipelineSource(viewer)]) {
  1663. if (!source) continue
  1664. for (const entity of source.entities.values) {
  1665. const properties = (entity && entity.properties) || {}
  1666. const read = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
  1667. const list = read('deviceCodes')
  1668. const candidates = [
  1669. read('pointCode'),
  1670. read('pipelineCode'),
  1671. read('deviceCode'),
  1672. ...(Array.isArray(list) ? list : []),
  1673. ]
  1674. // 每个实体都过判定:命中与**未命中**都要写回值,后者即"还原"。
  1675. const hit = shouldEmphasize(candidates, wanted)
  1676. if (hit) matched += 1
  1677. const base = hit ? rememberEmphasisBase(entity) : restoreEmphasisBase(entity)
  1678. if (entity.billboard) entity.billboard.scale = hit ? EMPHASIS_STYLE.markerScale : base.billboardScale
  1679. if (entity.polyline) entity.polyline.width = hit ? EMPHASIS_STYLE.pipelineWidth : base.polylineWidth
  1680. }
  1681. }
  1682. return matched
  1683. }
  1684. /** 强调前的原值(按实体缓存:还原要有据可依,不能猜默认值)。 */
  1685. const emphasisBase = new WeakMap()
  1686. function rememberEmphasisBase(entity) {
  1687. if (!emphasisBase.has(entity)) {
  1688. emphasisBase.set(entity, {
  1689. billboardScale: entity.billboard ? entity.billboard.scale : 1,
  1690. polylineWidth: entity.polyline ? entity.polyline.width : undefined,
  1691. })
  1692. }
  1693. return emphasisBase.get(entity)
  1694. }
  1695. function restoreEmphasisBase(entity) {
  1696. const base = emphasisBase.get(entity)
  1697. emphasisBase.delete(entity)
  1698. return base || { billboardScale: 1, polylineWidth: undefined }
  1699. }
  1700. /** 管线数据源(票 22 交付时才存在;没有就回 null,不当成错误)。 */
  1701. export function pipelineSource(viewer) {
  1702. if (!viewer || !viewer.dataSources) return null
  1703. return viewer.dataSources.getByName(PIPELINE_SOURCE)[0] || null
  1704. }
  1705. /** Currently loaded terrain height; null means no reliable surface sample yet. */
  1706. export function groundHeightAt(viewer, lon, lat) {
  1707. const globe = viewer && viewer.scene && viewer.scene.globe
  1708. if (!globe || !Number.isFinite(lon) || !Number.isFinite(lat)) return null
  1709. const height = globe.getHeight(Cartographic.fromDegrees(lon, lat))
  1710. return Number.isFinite(height) ? height : null
  1711. }
  1712. export function hasPointMarker(viewer, kind, code) {
  1713. if (!viewer || !code) return false
  1714. const source = markerSources.get(viewer) || viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0]
  1715. return !!(source && source.show && source.entities.getById(`point|${kind}|${code}`))
  1716. }
  1717. /**
  1718. * 把**渲染中的**贴地标记中心投到窗口坐标(票 41 的锚定锚点)。
  1719. *
  1720. * 锚点必须与**眼睛看到的那个标记**一致,所以高度取值顺序照 Cesium 画 billboard 的实际情况:
  1721. * 1. 地形已有该处瓦片 → `globe.getHeight()` 给的实测高程(贴地后的真实中心);
  1722. * 2. 瓦片还没加载/无地形 → **椭球面**(Cesium 的 `CLAMP_TO_GROUND` 在无地形数据时正是画在椭球面上)。
  1723. *
  1724. * 第 2 条是**真机走查抓到的缺陷**:早先写成「配了地形就必须取到高度,取不到回 null」,
  1725. * 于是相机一飞到尚未加载瓦片的区域,`getHeight` 返回 undefined,锚定被误判成"标记离屏"、
  1726. * 整块面板消失 —— 而画面上那个标记明明还看得见。锚点判据不能比"可见性"本身更严。
  1727. *
  1728. * 可见性只用 Cesium **公开**判据:相机前方(`directionWC` 点积)+ 地球遮挡(`EllipsoidalOccluder`),
  1729. * 再交给 `SceneTransforms` 投影;不做第二套相机矩阵手算。
  1730. */
  1731. export function projectPointMarker(viewer, kind, code) {
  1732. if (!viewer || !viewer.scene || !viewer.canvas || !code) return null
  1733. const source = markerSources.get(viewer) || viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0]
  1734. if (!source || !source.show) return null
  1735. const entity = source.entities.getById(`point|${kind}|${code}`)
  1736. if (!entity || !entity.isShowing || !entity.position || !entity.billboard) return null
  1737. const original = entity.position.getValue(viewer.clock.currentTime)
  1738. if (!original) return null
  1739. const ellipsoid = viewer.scene.globe.ellipsoid
  1740. const carto = ellipsoid.cartesianToCartographic(original)
  1741. if (!carto) return null
  1742. let height = 0
  1743. const sampled = viewer.scene.globe.getHeight(carto)
  1744. if (Number.isFinite(sampled)) height = sampled
  1745. const world = Cartesian3.fromRadians(carto.longitude, carto.latitude, height)
  1746. const camera = viewer.camera
  1747. const toward = Cartesian3.subtract(world, camera.positionWC, new Cartesian3())
  1748. const occluder = new EllipsoidalOccluder(ellipsoid, camera.positionWC)
  1749. if (Cartesian3.dot(toward, camera.directionWC) <= 0 || !occluder.isPointVisible(world)) return null
  1750. const at = SceneTransforms.worldToWindowCoordinates(viewer.scene, world)
  1751. if (!at || !Number.isFinite(at.x) || !Number.isFinite(at.y)) return null
  1752. const rect = viewer.canvas.getBoundingClientRect()
  1753. return { x: at.x + rect.left, y: at.y + rect.top }
  1754. }
  1755. /**
  1756. * 屏幕坐标 → **点位标记身份**(票 10 的地图点选)。
  1757. *
  1758. * 三件事必须说清:
  1759. * 1. 只认点位标记数据源上的实体 —— 拾到地形、影像、3D Tiles 或空白一律回 `null`
  1760. * (不是「拾到什么就打开详情」);判据是实体属性里有 `pointCode`;
  1761. * 2. 坐标要吃**画布坐标**,而事件给的是窗口坐标:`scene.pick` 用的是画布内部坐标系,
  1762. * 所以先减去画布原点(票 03 的 `projectPivot()` 踩过同一个坑,那里是加、这里是减);
  1763. * 3. 回的是**标记身份**而不是 Cesium 实体:引擎对象不出这一层,上层只认 kind/code/status
  1764. * (与 `syncPointMarkers` 只认 shape/tone 是同一条纪律)。
  1765. *
  1766. * @returns {{kind: string, code: string, status: string}|null}
  1767. */
  1768. export function pickPointMarkerAt(viewer, windowX, windowY) {
  1769. if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
  1770. const rect = viewer.canvas.getBoundingClientRect()
  1771. const picked = viewer.scene.pick(new Cartesian2(windowX - rect.left, windowY - rect.top))
  1772. const entity = picked && picked.id
  1773. const properties = entity && entity.properties
  1774. if (!properties || !properties.pointCode) return null
  1775. const read = (key) => (properties[key] ? properties[key].getValue() : '')
  1776. const code = read('pointCode')
  1777. if (!code) return null
  1778. return { kind: read('pointKind'), code, status: read('pointStatus') }
  1779. }
  1780. /**
  1781. * 相机契约的 Cesium 适配(票 03)。返回 services/cameraControl.js 期望的引擎接口;
  1782. * 相机语义(三键怎么算、中心是谁)在 domain/camera.js 与 cameraControl.js,本函数只翻译方言。
  1783. * @param {Viewer} viewer
  1784. */
  1785. export function createCameraEngine(viewer) {
  1786. const scene = viewer.scene
  1787. const camera = viewer.camera
  1788. let frameHandle = null
  1789. /**
  1790. * 经纬高 → 该点东北天局部系(弹簧臂原语要求中心是局部地表点)。
  1791. *
  1792. * 极点保护:`eastNorthUpToFixedFrame` 内部用 `east = up × north` 求东向,
  1793. * 纬度到 ±90 时 up 与 north 共线、叉积退化为零向量,局部系不再是正交基,
  1794. * `lookAtTransform` 拿它当参照就会出现「整个地球乱转」。
  1795. * 域层已把中心纬度夹在 ±POLAR_LAT_LIMIT 内(见 domain/camera.js),
  1796. * 这里是引擎侧的第二道防线:任何调用方直接喂端点纬度也不会把参照系搞坏。
  1797. */
  1798. function frameAt(pivot) {
  1799. const lat = Math.min(89.9, Math.max(-89.9, pivot.lat))
  1800. return Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(pivot.lon, lat, pivot.height || 0))
  1801. }
  1802. return {
  1803. applyView(pivot, hpr) {
  1804. camera.lookAtTransform(
  1805. frameAt(pivot),
  1806. new HeadingPitchRange(hpr.heading * DEG, hpr.pitch * DEG, hpr.range),
  1807. )
  1808. },
  1809. /** 反读当前相机相对该中心的弹簧臂参数,用于挂载时对齐真实相机(避免首次交互跳变)。 */
  1810. readView(pivot) {
  1811. const inverse = Matrix4.inverseTransformation(frameAt(pivot), new Matrix4())
  1812. const local = Matrix4.multiplyByPoint(inverse, camera.positionWC, new Cartesian3())
  1813. const range = Cartesian3.magnitude(local)
  1814. if (!(range > 0)) return { heading: 0, pitch: -60, range: 1 }
  1815. const heading = CesiumMath.toDegrees(Math.atan2(local.x, local.y))
  1816. const pitch = -CesiumMath.toDegrees(Math.asin(Math.min(1, Math.max(-1, local.z / range))))
  1817. return { heading: (heading + 360) % 360, pitch, range }
  1818. },
  1819. /**
  1820. * 屏幕中心的地面拾取点(票 21/30 改进):**优先按地形拾取**,拿不到高度再退回椭球。
  1821. *
  1822. * 为什么必须按地形:纯 `pickEllipsoid` 给的是椭球面交点(高程 0),而沅陵县城地面高程
  1823. * 两百多米起。用它当中心,弹簧臂 range 一降到几百米,相机就落进山体内部 —— 实测 range 120 m
  1824. * 时整屏全黑(票 21 走查记录)。地形就绪后用 `globe.getHeight` 把中心抬到真实地表,
  1825. * 相机高度随之正确,近景才不会钻进山里。
  1826. */
  1827. pickGroundAtCenter() {
  1828. const canvas = scene.canvas
  1829. const at = new Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2)
  1830. const ellipsoid = (scene.globe && scene.globe.ellipsoid) || Ellipsoid.WGS84
  1831. const hit = camera.pickEllipsoid(at, ellipsoid)
  1832. if (!hit) return null
  1833. const carto = ellipsoid.cartesianToCartographic(hit)
  1834. const lon = CesiumMath.toDegrees(carto.longitude)
  1835. const lat = CesiumMath.toDegrees(carto.latitude)
  1836. // 地形就绪时把中心抬到真实地表;取样不到就保持 0(等价于旧行为,不会更糟)。
  1837. let height = 0
  1838. const globe = scene.globe
  1839. if (globe && typeof globe.getHeight === 'function') {
  1840. const sampled = globe.getHeight(carto)
  1841. if (Number.isFinite(sampled)) height = sampled
  1842. }
  1843. return { lon, lat, height }
  1844. },
  1845. /**
  1846. * 中心点投影到**窗口坐标**(与 `getBoundingClientRect()` 同一坐标系)。
  1847. *
  1848. * 实测 `SceneTransforms.worldToWindowCoordinates()` 返回的是**相对画布左上角**的值
  1849. * (画布在壳里下移了一段,直接用会与 `getBoundingClientRect()` 差出一个画布原点偏移,
  1850. * 走查里表现为「中心点没落在画布中心」的假失败)。这里显式加上画布原点,
  1851. * 让消费方拿到的就是窗口坐标,别再靠语义猜。
  1852. */
  1853. projectPivot(pivot) {
  1854. const at = SceneTransforms.worldToWindowCoordinates(scene, Cartesian3.fromDegrees(pivot.lon, pivot.lat, pivot.height || 0))
  1855. if (!at) return null
  1856. const rect = viewer.canvas.getBoundingClientRect()
  1857. return { x: at.x + rect.left, y: at.y + rect.top }
  1858. },
  1859. /**
  1860. * **相机正下方**的地形高度(米),取不到返回 null(票 37 的防入地输入)。
  1861. *
  1862. * 为什么是「相机正下方」而不是「pivot 下方」:防入地要保证的是**相机自己**别钻进山体。
  1863. * 相机在 pivot 沿 heading/pitch 退开 range 的位置上,两者可能隔着几百米、翻过一个山脊,
  1864. * 用 pivot 的地形判断会在斜坡上误判。
  1865. */
  1866. groundHeightBelowCamera() {
  1867. const globe = scene.globe
  1868. if (!globe || typeof globe.getHeight !== 'function') return null
  1869. try {
  1870. const carto = globe.ellipsoid.cartesianToCartographic(camera.positionWC)
  1871. if (!carto) return null
  1872. const sampled = globe.getHeight(carto)
  1873. return Number.isFinite(sampled) ? sampled : null
  1874. } catch (error) {
  1875. return null
  1876. }
  1877. },
  1878. /** 相机当前**绝对**高度(米,椭球高),取不到返回 null。防入地的另一半输入。 */
  1879. cameraHeight() {
  1880. try {
  1881. const carto = scene.globe.ellipsoid.cartesianToCartographic(camera.positionWC)
  1882. return carto && Number.isFinite(carto.height) ? carto.height : null
  1883. } catch (error) {
  1884. return null
  1885. }
  1886. },
  1887. inputTarget() {
  1888. return viewer.canvas
  1889. },
  1890. viewport() {
  1891. return { width: scene.canvas.clientWidth, height: scene.canvas.clientHeight }
  1892. },
  1893. fovY() {
  1894. return camera.frustum.fovy || 60 * DEG
  1895. },
  1896. now() {
  1897. return typeof performance !== 'undefined' ? performance.now() : Date.now()
  1898. },
  1899. requestFrame(callback) {
  1900. frameHandle = requestAnimationFrame(callback)
  1901. },
  1902. }
  1903. }