| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407 |
- /**
- * Cesium 引擎适配层(票 27):本仓库里唯一 `import 'cesium'` 的地方。
- *
- * 只在 `mapService.mountMap()` 完成 `window.CESIUM_BASE_URL` 注入之后被动态 import,
- * 因此静态 import 提升不会踩到时序(见 services/cesiumAssets.js)。
- *
- * 本模块负责两件事:
- * 1. 「把引擎接到宿主上」(票 27):唯一 `Viewer`、自带控件与默认相机输入全关、
- * 不碰需要账号的能力;底图按 `domain/basemap.js` 的计划挂(票 29),
- * 没配就不挂(票 27 的「底图未配置不伪造配准」);
- * 2. 「把相机契约接到引擎上」(票 03):`createCameraEngine()` 把三键契约需要的原语
- * (弹簧臂 `lookAtTransform(东北天局部系, HeadingPitchRange)`、屏幕中心地面拾取、
- * 中心点投影)包成 services/cameraControl.js 的引擎接口 —— 相机语义本身不在本文件,
- * 这里只做 Cesium 方言的翻译。
- */
- import {
- Cartesian2,
- BoundingSphere,
- Intersect,
- Cartesian3,
- Cartographic,
- Cesium3DTileset,
- Cesium3DTileStyle,
- CesiumTerrainProvider,
- Color,
- CustomDataSource,
- DirectionalLight,
- Ellipsoid,
- EllipsoidalOccluder,
- ImageBasedLighting,
- HeightReference,
- HeadingPitchRange,
- ImageryLayer,
- LabelStyle,
- Math as CesiumMath,
- Matrix3,
- Matrix4,
- OrientedBoundingBox,
- PostProcessStage,
- Rectangle,
- RequestScheduler,
- SceneTransforms,
- TileMapServiceImageryProvider,
- Transforms,
- UrlTemplateImageryProvider,
- VerticalOrigin,
- Viewer,
- buildModuleUrl,
- sampleTerrainMostDetailed,
- } from 'cesium'
- import 'cesium/Build/Cesium/Widgets/widgets.css'
- // 强调的**判定**在 domain(纯函数,可脱浏览器断言);本层只把结论写成显示参数。
- import { EMPHASIS_STYLE, shouldEmphasize } from '../domain/mapEmphasis.js'
- import { MAP_LABEL_STYLE, mapLabelText } from '../domain/mapLabelStyle.js'
- // 批次 3 第四轮:标签省略走 domain/ellipsis.js 的共用契约(与 DOM 侧 EllipsisText 同语义)。
- import { ellipsisForMapLabel } from '../domain/ellipsis.js'
- import { createTilesetLoadingController } from './tilesetLoading.js'
- import { tilesetOptions } from './tilesetOptions.js'
- import { createRegionLoadingController, contentAdmission } from './regionLoading.js'
- import { removeSceneTileset } from './tilesetCleanup.js'
- const regionControllers = new WeakMap()
- // 票 47:动效的**相位与映射**同样在 domain(纯函数,可跨时间采样断言);
- // 本层只把相位写成 billboard 的 scale/alpha/rotation。
- import { DEVICE_EFFECT, RING_LAYERS, blinkStrengthAt, glowScaleAt, glowStrengthAt, hasGlow, pulseStrengthAt, ringEntityCount, ringFrameAt, spinAngleAt } from '../domain/deviceEffects.js'
- // 场景调色的兜底色直接取 domain 的默认(不再手抄一份 #2fa8ff 的浮点,两处会漂)。
- // 注意:GRADE_DEFAULTS 来自 sceneGrade;parseHexColor 的 owner 是 fog.js ——
- // sceneGrade 自己也只是导入它,从这里导 parseHexColor 会是 ESM 链接错误(引擎整个起不来)。
- import { GRADE_DEFAULTS } from '../domain/sceneGrade.js'
- import { parseHexColor } from '../domain/fog.js'
- // 批次 3:合成计划宿主读数 + 母版合成渲染。
- import { planToDataset } from '../domain/markerPlan.js'
- import { renderMarker } from './markerAssets.js'
- const DEG = Math.PI / 180
- const VIEWER_OPTIONS = {
- // 自带控件全关(导航/时间轴/拾取框等)。
- animation: false,
- timeline: false,
- geocoder: false,
- homeButton: false,
- sceneModePicker: false,
- baseLayerPicker: false,
- navigationHelpButton: false,
- navigationInstructionsInitiallyVisible: false,
- fullscreenButton: false,
- infoBox: false,
- selectionIndicator: false,
- vrButton: false,
- // 相机静止、无新瓦片时减少重复绘制;按需渲染不等于解码/GPU 上传自动分帧。
- // maximumRenderTimeChange 是模拟时钟变化阈值,不是墙钟时间的固定渲染间隔。
- requestRenderMode: true,
- maximumRenderTimeChange: 0.5,
- }
- /** 按底图计划建 imagery layer;未配置或计划无效时返回 false = 不配底图(票 27:不伪造地理配准)。 */
- function createBaseLayer(plan) {
- if (!plan || !plan.ready) return false
- if (plan.kind === 'tms') {
- // Cesium 自带的离线 TMS(随 Assets 目录进产物,不需要账号或外网)。
- return ImageryLayer.fromProviderAsync(TileMapServiceImageryProvider.fromUrl(buildModuleUrl(plan.url)))
- }
- if (plan.kind === 'xyz') {
- const options = { url: plan.url }
- if (plan.subdomains && plan.subdomains.length) options.subdomains = plan.subdomains
- if (Number.isInteger(plan.minimumLevel)) options.minimumLevel = plan.minimumLevel
- if (Number.isInteger(plan.maximumLevel)) options.maximumLevel = plan.maximumLevel
- if (plan.credit) options.credit = plan.credit
- return new ImageryLayer(new UrlTemplateImageryProvider(options))
- }
- return false
- }
- /**
- * 在宿主要素上创建 Viewer。
- *
- * **署名容器(票 25)**:`creditContainer` **不是开关,是「放哪个容器」**——
- * Cesium 的 Viewer 构造写死 `defined(options.creditContainer) ? options.creditContainer : bottomContainer`,
- * 随后 CesiumWidget **两条路径都会**建 CreditDisplay
- * (`creditContainer.appendChild(innerCreditContainer)`)。
- * 所以传 `false`/`''` 既关不掉、还会让 `getElement()` 拿到 null 而崩。
- * 真正能生效的做法是**自己给一个不进 DOM 的容器**:Cesium 照旧把署名写进去,
- * 但它不在文档流里,屏幕上不出现,destroy 时 Cesium 也会自行摘掉内部节点。
- *
- * 合法性前提(本部署已核实,勿轻易推翻):地形走 `CesiumTerrainProvider.fromUrl(本地 URL)`、
- * 影像走内置 TMS 与 `UrlTemplateImageryProvider`、3D Tiles 走 `Cesium3DTileset.fromUrl(本地 URL)`,
- * **全链路无 Cesium ion 服务与 ion 资产**。ion 署名义务只在用到 ion 资产时成立,
- * 故此处隐藏署名不构成违约;自有数据源署名由地图信息面板在业务界面另行展示。
- *
- * @param {Element} host 宿主要素(`.map-canvas`)
- * @param {object} [basemapPlan] `domain/basemap.js` 的底图计划(票 29);缺省 = 不配底图
- * @returns {Viewer}
- */
- export function createViewer(host, basemapPlan) {
- // 票 25:离屏署名容器 —— 故意不 appendChild,署名因此不上屏。
- const creditContainer = document.createElement('div')
- const viewer = new Viewer(host, { ...VIEWER_OPTIONS, creditContainer, baseLayer: createBaseLayer(basemapPlan) })
- const controller = viewer.scene.screenSpaceCameraController
- controller.enableRotate = false
- controller.enableZoom = false
- controller.enableTilt = false
- controller.enableLook = false
- controller.enableTranslate = false
- // 球体底色用驾驶舱底色:没配底图时看到的就是这个球,配了底图会被影像盖住。
- viewer.scene.globe.baseColor = Color.fromCssColorString('#04121f')
- // 地面视图必须保留地形深度,否则埋在地表以下的 3D Tiles 也会覆盖到地形之上。
- // 回归:tools/city-massing/verify-occlusion.mjs 用实际渲染像素检查遮挡。
- viewer.scene.globe.depthTestAgainstTerrain = true
- return viewer
- }
- export function resizeViewer(viewer) {
- viewer.resize()
- }
- /** 叠加影像(票 21):本地精细切片画在底图之上,URL 模板里可用 `{reverseY}` 表示 TMS 的 y。 */
- function createOverlayProvider(overlay) {
- const options = { url: overlay.url }
- if (Number.isInteger(overlay.minimumLevel)) options.minimumLevel = overlay.minimumLevel
- if (Number.isInteger(overlay.maximumLevel)) options.maximumLevel = overlay.maximumLevel
- if (overlay.bounds) options.rectangle = Rectangle.fromDegrees(overlay.bounds[0], overlay.bounds[1], overlay.bounds[2], overlay.bounds[3])
- if (overlay.credit) options.credit = overlay.credit
- return new UrlTemplateImageryProvider(options)
- }
- /**
- * 批量采样真实地表高程(票 34 的建模用途):一次请求把整张网格取回来,
- * 比逐点调 `sampleTerrainHeight()` 快得多(逐点会重复拉同一批瓦片)。
- * @param {Array<{lon: number, lat: number}>} points
- * @returns {Promise<Array<number|null>>} 与入参等长;无地形或取不到的点为 null
- */
- export async function sampleTerrainHeights(viewer, points) {
- const list = Array.isArray(points) ? points : []
- if (!viewer || !viewer.terrainProvider || !list.length) return list.map(() => null)
- try {
- const cartos = list.map((p) => Cartographic.fromDegrees(p.lon, p.lat))
- const sampled = await sampleTerrainMostDetailed(viewer.terrainProvider, cartos)
- return sampled.map((c) => (c && Number.isFinite(c.height) ? c.height : null))
- } catch {
- return list.map(() => null)
- }
- }
- /**
- * 采样一点的**真实地表高程**(票 30):地形感知的中心点/预设视角靠它把高度从「椭球 0 米」
- * 换成实际地形高度,否则相机高度按 0 算,一拉近就落进山体(实测 range 120m 全黑)。
- * @returns {Promise<number|null>} 米;没有地形或采样失败返回 null
- */
- export async function sampleTerrainHeight(viewer, lon, lat) {
- if (!viewer || !viewer.terrainProvider) return null
- try {
- const carto = Cartographic.fromDegrees(lon, lat)
- const [sampled] = await sampleTerrainMostDetailed(viewer.terrainProvider, [carto])
- const height = sampled && sampled.height
- return Number.isFinite(height) ? height : null
- } catch {
- return null
- }
- }
- /**
- * 挂地形(票 21)。DEM 是**量化网格**:`CesiumTerrainProvider.fromUrl(<layer.json 所在目录>)`,
- * `layer.json` 里的 `scheme: "tms"` 由 Cesium 自己认,服务端不需要翻 y。
- * @returns {Promise<string>} `'ready'`;失败时抛错,由调用方记成失败态(保留椭球兜底)
- */
- export async function applyTerrain(viewer, terrainPlan) {
- const provider = await CesiumTerrainProvider.fromUrl(terrainPlan.url, {
- requestVertexNormals: terrainPlan.requestVertexNormals !== false,
- })
- viewer.terrainProvider = provider
- return 'ready'
- }
- /**
- * 叠加影像句柄(票 39):把「精细层」包成一个只暴露必需动作的对象,供交接状态机驱动。
- *
- * ── 两个必须讲清楚的 Cesium 事实(本票实现依据,都查过源码)──
- *
- * 1. **隐藏期间仍要继续预取瓦片**(决策 08 第 1 轮 Q4=A「看不见不等于停止加载」)。
- * 瓦片是否被请求由 `GlobeSurfaceTile` 按 **`layer.show`** 决定
- * (`Source/Scene/GlobeSurfaceTile.js:691` `if (layer.show) { layer._createTileImagerySkeletons(...) }`),
- * **不看 `alpha`**。所以"隐藏精细层"的正确做法是 **`alpha = 0` 且保持 `show = true`** ——
- * 用 `show = false` 会真的停掉预取,等揭示时才开始下载,正好造成"先空白再跳方块"。
- *
- * 2. **alpha 只允许一处写**。本模块提供 `setAlpha()` 作为唯一入口,
- * 服务层与原型都不得直接摸 `layer.alpha`(原型若同时写会互相打架,见 mapService 的隔离注释)。
- */
- function createOverlayHandle(layer) {
- return {
- layer,
- /** 该层的原始 alpha(销毁/还原时用)。 */
- initialAlpha: Number.isFinite(layer.alpha) ? layer.alpha : 1,
- /** 当前由交接写入的 alpha(读数与断言用;不代表"画面已遮住")。 */
- alpha: Number.isFinite(layer.alpha) ? layer.alpha : 1,
- /**
- * 写 alpha(**唯一入口**)。
- *
- * 刻意**不**动 `show`:`alpha = 0` 即不可见,但 `show` 保持 true 让瓦片继续预取
- * (见上方事实 1)。UI 不可见 ≠ 停止加载。
- */
- setAlpha(value) {
- const next = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0))
- this.alpha = next
- layer.alpha = next
- return next
- },
- /** 当前层对象是否还有效(viewer 销毁后不要再碰)。 */
- alive() {
- return !!layer && typeof layer.alpha === 'number'
- },
- }
- }
- /**
- * 把相机**直接放到指定椭球高度**(DEV 诊断用;保持当前经纬度与姿态)。
- *
- * 为什么放在引擎层:`Cartesian3` 只允许出现在本仓唯一 `import 'cesium'` 的地方(ADR 0004),
- * 服务层不碰 Cesium 类型。
- *
- * 为什么不能直接赋值:`camera.positionWC` / `positionCartographic` 在
- * `Source/Scene/Camera.js` 里**只有 getter**(内部由 `_position` 推导),
- * 赋值不会生效 —— 必须走 `setView()` 这个受支持入口。
- *
- * @returns {number|null} 实际下发的椭球高度(米);失败返回 null
- */
- export function setCameraAltitude(viewer, altitudeM) {
- if (!viewer || !viewer.camera || !viewer.scene || !viewer.scene.globe) return null
- if (!Number.isFinite(altitudeM)) return null
- try {
- const ellipsoid = viewer.scene.globe.ellipsoid
- const carto = ellipsoid.cartesianToCartographic(viewer.camera.positionWC)
- if (!carto) return null
- const camera = viewer.camera
- camera.setView({
- destination: Cartesian3.fromRadians(carto.longitude, carto.latitude, altitudeM),
- orientation: { heading: camera.heading, pitch: camera.pitch, roll: camera.roll },
- })
- return Math.round(altitudeM)
- } catch (error) {
- return null
- }
- }
- /** 按配置顺序叠加影像层,返回**句柄数组**(票 39 起需要驱动 alpha,故不再只返回层数)。 */
- export function applyOverlays(viewer, overlays) {
- const handles = []
- for (const overlay of overlays || []) {
- const layer = new ImageryLayer(createOverlayProvider(overlay))
- viewer.imageryLayers.add(layer)
- handles.push(createOverlayHandle(layer))
- }
- return handles
- }
- /**
- * 目标视野的瓦片就绪读数(**如实标注它是代理信号**)。
- *
- * `globe.tilesLoaded` = **地形与影像加载队列为空**(`Source/Scene/Globe.js:422` 的 getter),
- * 它**不证明**目标视野逐像素就绪 —— 决策票 08 明确警告过这一点。所以这里连同
- * "它是代理"这个事实一起返回,免得下游把它当充分证明。
- *
- * 瓦片失败:Cesium 在**没有任何 errorEvent 监听者**时会把错误直接抛到窗口
- * (`ImageryLayer.js:1708` `handleError`),所以本层挂一个监听者把失败**收进读数**,
- * 而不是让它变成页面级报错(走查的「无页面级报错」会因此误伤)。
- */
- export function createOverlayReadiness(handles = []) {
- const state = { loaded: false, failed: 0, lastError: '' }
- const removers = []
- for (const handle of handles) {
- const layer = handle && handle.layer
- if (!layer || !layer.errorEvent || typeof layer.errorEvent.addEventListener !== 'function') continue
- // 注意:Cesium 的 `Event.addEventListener` **返回一个"移除函数"**,不是 id
- // (`Source/Core/Event.js` 末尾 `return function () { event.removeEventListener(...) }`)。
- const remove = layer.errorEvent.addEventListener((error) => {
- state.failed += 1
- state.lastError = String((error && error.message) || error || '瓦片加载失败')
- })
- if (typeof remove === 'function') removers.push(remove)
- }
- return {
- state,
- /** 由服务层在渲染循环里刷新(`globe.tilesLoaded` 是 getter,读它不便宜,故不在这里轮询)。 */
- refresh(globe) {
- state.loaded = !!(globe && globe.tilesLoaded)
- return state
- },
- detach() {
- for (const remove of removers) {
- try {
- remove()
- } catch {
- // 场景已销毁:忽略
- }
- }
- removers.length = 0
- },
- }
- }
- /**
- * 3D Tiles 的公共诊断读数:排查「tileset.json 拉到了、内容却没请求」时,
- * 使用 Cesium 1.145 的公开 `tilesLoaded`、内存和 tile 事件结果;不读取内部 statistics/ready/contentState。
- * `visited`/`selected` 保留为 null,因为 Cesium 没有公开的稳定逐帧字段。
- */
- export function readTilesetDiagnostics(tilesets) {
- const list = Array.isArray(tilesets) ? tilesets : []
- return list.map((tileset) => {
- const root = tileset.root || {}
- return {
- visited: null,
- selected: null,
- contentReady: root ? Number(Boolean(root.content)) : 0,
- tilesLoaded: Boolean(tileset.tilesLoaded),
- memoryBytes: tileset.totalMemoryUsageInBytes || 0,
- rootHasContent: Boolean(root && root.content),
- rootChildren: Array.isArray(root && root.children) ? root.children.length : 0,
- maximumScreenSpaceError: tileset.maximumScreenSpaceError,
- }
- })
- }
- /**
- * 挂 3D Tiles / 倾斜摄影(票 21):每个条目一个 tileset。
- * 单项失败不影响其它项;返回 `{ ready, failed, instances }` 供上层如实报状态
- * (页面不白屏、不静默),并把实例交出去给视图条件改透明度。
- */
- function applyBuildingLighting(scene, tileset) {
- // Derive a stable local daylight from this dataset's geographic bounds; do not key lighting to the camera.
- const origin = tileset.boundingSphere?.center
- if (!origin) return
- const frame = Transforms.eastNorthUpToFixedFrame(origin)
- const direction = Matrix4.multiplyByPointAsVector(frame, new Cartesian3(-0.4, -0.6, -1), new Cartesian3())
- Cartesian3.normalize(direction, direction)
- scene.light = new DirectionalLight({ direction, intensity: 2.4 })
- tileset.imageBasedLighting = new ImageBasedLighting({
- imageBasedLightingFactor: new Cartesian2(1, 1),
- sphericalHarmonicCoefficients: [new Cartesian3(0.65, 0.65, 0.65), ...Array.from({ length: 8 }, () => new Cartesian3(0, 0, 0))],
- })
- }
- function boundsFromTilesetRoot(root) {
- const box = root && root.boundingVolume && root.boundingVolume.box
- if (!Array.isArray(box) || box.length < 12) return null
- const transform = Array.isArray(root.transform) && root.transform.length === 16
- ? Matrix4.fromArray(root.transform)
- : Matrix4.IDENTITY
- const center = Matrix4.multiplyByPoint(transform, new Cartesian3(box[0], box[1], box[2]), new Cartesian3())
- const radii = [
- Matrix4.multiplyByPointAsVector(transform, new Cartesian3(box[3], box[4], box[5]), new Cartesian3()),
- Matrix4.multiplyByPointAsVector(transform, new Cartesian3(box[6], box[7], box[8]), new Cartesian3()),
- Matrix4.multiplyByPointAsVector(transform, new Cartesian3(box[9], box[10], box[11]), new Cartesian3()),
- ]
- const halfAxes = Matrix3.fromArray(radii.flatMap((axis) => [axis.x, axis.y, axis.z]))
- return new OrientedBoundingBox(center, halfAxes)
- }
- async function readTilesetRootBounds(plan, signal) {
- const response = await fetch(plan.url, { cache: 'no-store', signal })
- if (!response.ok) throw new Error(`tileset metadata ${response.status}: ${plan.url}`)
- const json = await response.json()
- const bounds = boundsFromTilesetRoot(json.root)
- if (!bounds) throw new Error(`tileset root bounding box missing: ${plan.url}`)
- return bounds
- }
- function createTilesetCullingController(scene, plans, instances, failed, onChange) {
- const camera = scene && scene.camera
- if (!camera || !plans.length) return null
- const xyz = (value) => [value.x, value.y, value.z]
- const regionHandles = []
- const regionAbort = new AbortController()
- let regionPipeline = false
- async function initializeRegions(model, plan) {
- if (!plan.regionManifest) return
- const response = await fetch(plan.regionManifest, { cache: 'no-store', signal: regionAbort.signal })
- if (!response.ok) throw new Error('region metadata ' + response.status)
- const manifest = await response.json()
- if (model.isDestroyed() || regionAbort.signal.aborted) return
- const url = new URL(plan.regionManifest, document.baseURI)
- const transform = Matrix4.fromArray(manifest.transform)
- const regions = manifest.regions.map(r => {
- const spheres = (r.admissionBounds || [r.bounds]).map(volume => {
- const s = volume.sphere || [...volume.box.slice(0, 3), Math.hypot(volume.box[3], volume.box[7], volume.box[11])]
- return new BoundingSphere(Matrix4.multiplyByPoint(transform, new Cartesian3(...s.slice(0, 3)), new Cartesian3()), s[3])
- })
- return { ...r, url: new URL(r.url, url).href, admissionSpheres: spheres,
- expectedContentUrls: r.contentUrls.map(p => new URL(p, new URL(r.url, url)).href),
- expectedPreviewContentUrls: (r.previewContentUrls || []).map(p => new URL(p, new URL(r.url, url)).href),
- } })
- const localInstances = []
- let cullingVolume, coarseRendered = false
- const region = createRegionLoadingController({ regions,
- canStart: () => coarseRendered && model.tilesLoaded && model.show && !model.isDestroyed(),
- beforeRefresh: () => { cullingVolume = camera.frustum.computeCullingVolume(camera.positionWC, camera.directionWC, camera.upWC) },
- onFailure: (r, error) => { failed.push({ url: error.url || r.url, error: String(error.message || error) }); onChange?.({ ready: instances.length, instances, failed }) },
- distance: r => {
- const result = contentAdmission(r.admissionSpheres,
- sphere => cullingVolume.computeVisibility(sphere) !== Intersect.OUTSIDE,
- sphere => Cartesian3.distance(camera.positionWC, sphere.center))
- r.centerDistance = result.centerDistance
- return result.distance
- },
- load: r => Cesium3DTileset.fromUrl(r.url, { ...tilesetOptions(plan), loadSiblings: true }),
- add: (fine) => { scene.primitives.add(fine); localInstances.push(fine); instances.push(fine); onChange?.({ ready: instances.length, instances, failed }) },
- remove: fine => { const i = instances.indexOf(fine); if (i >= 0) instances.splice(i, 1); const j = localInstances.indexOf(fine); if (j >= 0) localInstances.splice(j, 1);
- removeSceneTileset(scene, fine) },
- requestRender: () => scene.requestRender(),
- })
- region.setPipeline(regionPipeline)
- const unmask = model.tileVisible.addEventListener(tile => {
- if (tile.content?.trianglesLength > 0) coarseRendered = true
- const c = tile.content
- if (c?.featuresLength === 1) { const feature = c.getFeature(0); feature.show = !region.detailReady(feature.getProperty('region')) }
- })
- const detachFrame = scene.postRender.addEventListener(region.onRender)
- regionControllers.set(model, region)
- regionHandles.push({ region, detachFrame, unmask })
- region.refresh()
- scene.requestRender()
- }
- const controller = createTilesetLoadingController({
- plans, instances, failed, onChange,
- prepareBounds: readTilesetRootBounds,
- readCamera: () => ({ position: xyz(camera.positionWC), direction: xyz(camera.directionWC), up: xyz(camera.upWC) }),
- distanceTo: (bounds) => Math.sqrt(bounds.distanceSquaredTo(camera.positionWC)),
- load: (plan) => Cesium3DTileset.fromUrl(plan.url, tilesetOptions(plan)),
- add: (instance, plan) => {
- if (plan.lighting === true) applyBuildingLighting(scene, instance)
- scene.primitives.add(instance)
- if (plan.regionManifest) void initializeRegions(instance, plan).catch(error => {
- if (regionAbort.signal.aborted) return
- failed.push({ url: plan.regionManifest, error: String(error.message || error) }); onChange?.({ ready: instances.length, instances, failed })
- })
- },
- remove: (instance) => {
- removeSceneTileset(scene, instance)
- },
- requestRender: () => scene.requestRender(),
- })
- const detachCamera = camera.changed.addEventListener(controller.refresh)
- const detachRender = scene.postRender.addEventListener(controller.onRender)
- let detached = false
- return {
- ...controller,
- setPipeline(value) { regionPipeline = !!value; for (const h of regionHandles) h.region.setPipeline(regionPipeline) },
- regionSnapshot() { return regionHandles.flatMap(h => h.region.snapshot()) },
- detach() {
- if (detached) return
- detached = true
- regionAbort.abort()
- for (const h of regionHandles) { h.detachFrame(); h.unmask(); h.region.detach() }
- detachCamera(); detachRender(); controller.detach()
- },
- }
- }
- /**
- * 3D Tiles 懒加载:启动只读根 tileset.json 的包围盒,eager 批次立即加载,
- * 其余批次只有相机进入 loadDistance 才初始化;离开较远区域后销毁非 eager 批次。
- * 已加载 tileset 仍由 Cesium 做 tile 级视锥剔除和内部 LOD。
- */
- export async function applyTilesets(scene, tilesets, onChange, onController) {
- // 网络节流只限制请求,不能代替 GLB 解码/上传完成的批次闸门。
- RequestScheduler.maximumRequests = 10
- RequestScheduler.maximumRequestsPerServer = 5
- const instances = []
- const failed = []
- const culling = createTilesetCullingController(scene, tilesets || [], instances, failed, onChange)
- if (onController) onController(culling)
- if (culling) await culling.prepare()
- return { ready: instances.length, failed, instances, culling }
- }
- /**
- * 视图条件对图层的影响(票 21):管线视图把地形与 3D Tiles 压成半透明(保留空间参照,
- * 不完全隐藏),地面视图还原为完全不透明。
- *
- * 地形用 Cesium 的 `globe.translucency`(前台/背面 alpha 一起设,避免只透一半);
- * 3D Tiles 用 `Cesium3DTileStyle` 的 color alpha。两者都只改显示,不动数据,也不碰相机。
- * @param {object} viewer
- * @param {Array} tilesets 已挂上的 tileset 实例(applyTilesets 的 instances)
- * @param {object} plan `domain/viewModes.js` 的 planViewMode()
- */
- export function applyViewModeLayers(viewer, tilesets, plan) {
- if (!viewer || !plan) return
- const globe = viewer.scene && viewer.scene.globe
- if (globe) {
- // 始终参与真实地形深度;管线透视只由显式 translucency 开关控制。
- globe.depthTestAgainstTerrain = true
- const alpha = Number.isFinite(plan.terrainAlpha) ? plan.terrainAlpha : 1
- globe.translucency.enabled = alpha < 1
- globe.translucency.frontFaceAlpha = alpha
- globe.translucency.backFaceAlpha = alpha
- }
- const alpha = Number.isFinite(plan.tilesetAlpha) ? plan.tilesetAlpha : 1
- for (const tileset of tilesets || []) {
- regionControllers.get(tileset)?.setPipeline(plan.mode === 'pipeline')
- try {
- // 地面视图还原 = 清掉样式(而不是设成 white/1,省得留一份无意义样式)。
- tileset.style = alpha < 1 ? new Cesium3DTileStyle({ color: `color('white', ${alpha})` }) : undefined
- } catch {
- // 单个 tileset 不支持 style 时忽略,不影响其它图层。
- }
- }
- }
- export function destroyViewer(viewer) {
- if (!viewer.isDestroyed()) viewer.destroy()
- }
- // ================================================================ 自建视距雾(票 38)
- /** 自建雾处理级的名字(唯一一个)。 */
- export const FOG_STAGE_NAME = 'cockpit-area-fog'
- /**
- * 世界射线距离雾的片元着色器(**B 方案:只处理有有效几何深度的地物**)。
- *
- * 契约来源 `cockpit-primary-area-guardrails/spec-fog.md`(决策票 07 固化 B 方案)。三条硬约束:
- *
- * 1. **无有效深度 ≠ 天空**,但本级**不重绘它**:`rawDepth >= 1.0` 时整段返回原色
- * (不套雾色、不做地平线渐变)。依据决策票 07「采用 B:仅地物距离雾……无有效几何深度背景保持原颜色」,
- * 以及其只读复核「深度清为 1.0,天空盒与大气不写深度」「无深度值也可能对应没写自身深度的透明内容」。
- * 2. **只反解一次对数深度**:`czm_windowToEyeCoordinates(gl_FragCoord.xy, rawDepth)` 这个重载
- * **内部自己解 log**(`windowToEyeCoordinates.glsl`),喂给它的是**原始** `texture(depthTexture).r`。
- * 先 `czm_reverseLogDepth` 再传进去会解两次 —— 复核里点名的坑。
- * 沿视线的距离取重建位置的长度,不取 `-z`;并按 `gl_FragCoord.w` 做齐次除法。
- * 3. **两端语义精确**:0 处全清、`endDistance` 处全遮蔽。曲线形状(smoothstep + 保端点光学厚度)
- * 在 `domain/fog.js` 的 `fogAttenuation` 里算好,这里只做 `mix`,**GLSL 不重写一份数学**
- * (两处各写一份必然漂移,而端点语义是本票的验收点)。
- *
- * 与原型 `prototypes/fog-prototype-stage.js`(**已归档到 `throwaway/cockpit-prototypes` 分支**,
- * 票 40 收口)的区别:去掉只有决策票 07 才有的 C 方案分支
- * (地平线染色与太阳暖色,已裁定不采用),并改用 CPU 侧算好的强度 uniform。
- */
- const FOG_FRAGMENT_SHADER = `
- uniform sampler2D colorTexture;
- uniform sampler2D depthTexture;
- uniform vec3 fogColor;
- uniform float effectStrength;
- uniform float startDistance;
- uniform float endDistance;
- in vec2 v_textureCoordinates;
- // 世界射线距离 → 不透明度。与 domain/fog.js 的 fogAttenuation 同一形状,
- // 但它必须在 GPU 上逐片元算,所以这里保留一份等价实现;**端点断言在 CPU 侧**。
- float fogAlpha(float distanceM) {
- if (distanceM <= startDistance) return 0.0;
- if (distanceM >= endDistance) return 1.0;
- float t = (distanceM - startDistance) / (endDistance - startDistance);
- float smoothT = t * t * (3.0 - 2.0 * t);
- return (1.0 - exp(-3.0 * smoothT)) / (1.0 - exp(-3.0));
- }
- void main() {
- vec4 base = texture(colorTexture, v_textureCoordinates);
- float rawDepth = texture(depthTexture, v_textureCoordinates).r;
- // 无有效几何深度:**保留原色**(B 方案)。这一支也不做"天空分类"的臆断。
- if (rawDepth >= 1.0) {
- out_FragColor = base;
- return;
- }
- // 原始深度交给这个重载,它内部自己解 log;再补一次齐次除法。
- vec4 eye = czm_windowToEyeCoordinates(gl_FragCoord.xy, rawDepth);
- vec3 eyePos = eye.xyz / eye.w;
- float distanceM = length(eyePos);
- float alpha = clamp(fogAlpha(distanceM) * effectStrength, 0.0, 1.0);
- out_FragColor = vec4(mix(base.rgb, fogColor, alpha), base.a);
- }`
- /**
- * 在场景上建**唯一**一个自建雾处理级(票 38)。幂等:同一 viewer 只会有一个。
- *
- * 三条实现要点,每条都对着决策票 07 的只读复核:
- * · **能力探测交给引擎**:stage 声明了 `depthTexture` 而设备不支持时,
- * `PostProcessStage` 自己会整级禁用(`_isSupported`);我们只如实读回 `stage.ready`
- * 与 `scene.context.depthTexture`,不自己造探测逻辑。
- * · **复用对象**:进出与调强度都只改 uniform,不 remove/add(`PostProcessStageCollection.remove`
- * 会 destroy,对象不可复用)。只有离开地图才 `remove` + 释放。
- * · **禁用不等于零开销**:规格明确「禁用后引擎仍可能准备/清理后处理 framebuffer」,
- * 所以对外措辞只说"禁用",不承诺"完全没有 GPU 开销"。
- *
- * @param {Viewer} viewer
- * @param {object} params `domain/fog.js` 的 `planFog()` 结果
- * @returns {{stage: PostProcessStage, uniforms: object, supported: boolean, destroy: () => void}}
- */
- export function createFogStage(viewer, params) {
- const scene = viewer.scene
- /**
- * 雾的动态 uniform **必须用函数传**(不能用常量)。
- *
- * 依据(Cesium 1.145 `PostProcessStage` 源码,本票实测确认):
- * · 构造时 `_actualUniforms[name] = uniforms[name]` 会**快照一份**常量值;
- * · 之后每帧喂给 shader 的是 `_actualUniforms`(`getUniformMapFunction` 读它),
- * 而 `stage._uniforms` 在构造末尾被**换成另一个对象**。
- * 所以「传常量 + 事后改那个常量对象」**永远不会生效** —— 改了原始对象,
- * 快照仍是初值,shader 拿到的始终是初值。症状极具迷惑性:
- * 读数、dataset、`stage.enabled` 全都说"已启用、强度 1",而**画面上什么都没有**。
- *
- * 这也正是可丢弃原型 `prototypes/fog-prototype-stage.js`(**已归档到
- * `throwaway/cockpit-prototypes` 分支**,票 40 收口)用 `() => params.amount`
- * 这种写法能跑通的原因 —— 函数形式的 uniform 每帧现取,天然支持动态改值。
- */
- const state = {
- fogColor: new Cartesian3(1, 1, 1),
- effectStrength: 0,
- startDistance: params.startDistance,
- endDistance: params.endDistance,
- }
- const stage = scene.postProcessStages.add(
- new PostProcessStage({
- name: FOG_STAGE_NAME,
- fragmentShader: FOG_FRAGMENT_SHADER,
- uniforms: {
- fogColor: () => state.fogColor,
- effectStrength: () => state.effectStrength,
- startDistance: () => state.startDistance,
- endDistance: () => state.endDistance,
- },
- }),
- )
- // 先禁用:强度为 0 时不该有任何一次全屏 pass(首次启用由宿主显式打开)。
- stage.enabled = false
- return {
- stage,
- state,
- /** 深度能力:`Context.depthTexture` 是**布尔能力标志**(不是纹理对象,且不在 d.ts 里)。 */
- supported: scene.context ? scene.context.depthTexture === true : false,
- /**
- * DEV 诊断:对比"我们以为写进去的强度"与"Cesium 真正喂给 shader 的强度"。
- *
- * 读的是 `stage._uniformMap[name]()` —— 那是每帧实际取值的那条路径,
- * 也是唯一能证伪"值到底有没有送进 shader"的地方。两者不一致就说明 uniform 通道没接对
- * (常量快照坑:直接写原始对象绕过了 setter,`_actualUniforms` 永远是初值)。
- */
- readUniformDiagnostics() {
- let delivered = null
- let storedKind = null
- let storedValue = null
- try {
- const uniformMap = stage._uniformMap
- delivered = uniformMap && typeof uniformMap.effectStrength === 'function' ? uniformMap.effectStrength() : null
- } catch (error) {
- delivered = null
- }
- try {
- // `_actualUniforms` 在**构造时**就写好了(不等渲染),所以这一项在隐藏标签页里也可读。
- // 它是判定"动态 uniform 有没有接对"的决定性证据:
- // · 存的是 `function` → 每帧现取,改 state 立刻生效(正确形态);
- // · 存的是 `number` → 构造时快照,事后改原始对象**永远不生效**(本票的坑)。
- const stored = stage._actualUniforms ? stage._actualUniforms.effectStrength : undefined
- storedKind = typeof stored
- storedValue = typeof stored === 'number' ? stored : null
- } catch (error) {
- storedKind = null
- }
- return {
- intended: state.effectStrength,
- deliveredToShader: delivered,
- matches: delivered === state.effectStrength,
- /** `'function'` = 动态(对);`'number'` = 常量快照(错,改了不生效)。 */
- storedKind,
- storedValue,
- enabled: stage.enabled,
- ready: stage.ready === true,
- }
- },
- destroy() {
- if (scene.isDestroyed && scene.isDestroyed()) return
- if (stage.isDestroyed && stage.isDestroyed()) return
- scene.postProcessStages.remove(stage)
- },
- }
- }
- /** 把强度与颜色写进 uniform(**只改 dynamic state,不重建 stage**)。 */
- export function applyFogUniforms(handle, { strength, colorRgb, startDistance, endDistance }) {
- if (!handle || !handle.state) return
- const state = handle.state
- if (Number.isFinite(strength)) state.effectStrength = Math.min(1, Math.max(0, strength))
- if (colorRgb) state.fogColor = new Cartesian3(colorRgb.r, colorRgb.g, colorRgb.b)
- if (Number.isFinite(startDistance)) state.startDistance = startDistance
- if (Number.isFinite(endDistance)) state.endDistance = endDistance
- // 开关与强度同源:强度为 0 就没有必要跑这一遍全屏 pass。
- if (handle.stage) handle.stage.enabled = state.effectStrength > 0
- }
- /** 该处理级是否已可执行(**不等于**画面已被遮住 —— 规格明确区分这两件事)。 */
- export function fogStageExecutable(handle) {
- if (!handle || !handle.stage) return false
- if (handle.stage.isDestroyed && handle.stage.isDestroyed()) return false
- return handle.stage.ready === true
- }
- /** 当前是否有自建雾处理级挂在场景上(排查与走查读数)。 */
- export function hasFogStage(viewer) {
- if (!viewer || !viewer.scene || !viewer.scene.postProcessStages) return false
- return viewer.scene.postProcessStages.length > 0
- }
- // ================================================================ 场景调色(科技蓝滤镜)
- const GRADE_STAGE_NAME = 'cockpit-scene-grade'
- /**
- * 全屏调色 fragment shader:降饱和 → 对比度 → 亮度 → 阴影偏蓝。
- * 数学与 `domain/sceneGrade.js` 的 `gradeColor()` **同序同式**(那边是本式的逐位镜像,
- * 逻辑闸门在 domain 上断言的结论才对引擎侧成立)。不做 hue-rotate 双色 ——
- * 状态点的红/绿是第一线索(票 14),色相顺序不能动。
- */
- const GRADE_FRAGMENT_SHADER = `
- uniform sampler2D colorTexture;
- uniform float gradeSaturation;
- uniform float gradeBrightness;
- uniform float gradeContrast;
- uniform vec3 gradeTint;
- uniform float gradeTintStrength;
- in vec2 v_textureCoordinates;
- void main(void)
- {
- vec4 base = texture(colorTexture, v_textureCoordinates);
- float luma = dot(base.rgb, vec3(0.2126, 0.7152, 0.0722));
- vec3 color = mix(vec3(luma), base.rgb, gradeSaturation);
- color = clamp((color - 0.5) * gradeContrast + 0.5, 0.0, 1.0);
- color = clamp(color * gradeBrightness, 0.0, 1.0);
- float tintWeight = clamp(gradeTintStrength * (1.0 - luma), 0.0, 1.0);
- color = mix(color, gradeTint, tintWeight);
- out_FragColor = vec4(color, base.a);
- }`
- /**
- * 在场景上建**唯一**一个调色处理级。常驻(不做进出生命周期):enabled 随配置。
- *
- * uniform 一律**函数形态**(票 38 实测的坑:常量会被 PostProcessStage 在构造时快照,
- * 事后改原始对象永远不生效;函数每帧现取,改 state 立即生效)。
- * 不需要深度纹理能力(雾才要),所以没有 support 探测 —— 建失败走 try/catch 如实上抛。
- *
- * @param {Viewer} viewer
- * @param {object} params `domain/sceneGrade.js` 的 `planSceneGrade()` 结果
- */
- export function createSceneGradeStage(viewer, params) {
- const scene = viewer.scene
- const tint = params.tintRgb || parseHexColor(GRADE_DEFAULTS.tint)
- const state = {
- saturation: params.saturation,
- brightness: params.brightness,
- contrast: params.contrast,
- tint: new Cartesian3(tint.r, tint.g, tint.b),
- tintStrength: params.tintStrength,
- }
- const stage = scene.postProcessStages.add(
- new PostProcessStage({
- name: GRADE_STAGE_NAME,
- fragmentShader: GRADE_FRAGMENT_SHADER,
- uniforms: {
- gradeSaturation: () => state.saturation,
- gradeBrightness: () => state.brightness,
- gradeContrast: () => state.contrast,
- gradeTint: () => state.tint,
- gradeTintStrength: () => state.tintStrength,
- },
- }),
- )
- stage.enabled = params.enabled !== false
- return {
- stage,
- state,
- destroy() {
- if (scene.isDestroyed && scene.isDestroyed()) return
- if (stage.isDestroyed && stage.isDestroyed()) return
- scene.postProcessStages.remove(stage)
- },
- }
- }
- /** 把调色参数写进 uniform(**只改 dynamic state,不重建 stage**;与 applyFogUniforms 同纪律)。 */
- export function applySceneGradeUniforms(handle, { saturation, brightness, contrast, tintRgb, tintStrength, enabled }) {
- if (!handle || !handle.state) return
- const state = handle.state
- if (Number.isFinite(saturation)) state.saturation = saturation
- if (Number.isFinite(brightness)) state.brightness = brightness
- if (Number.isFinite(contrast)) state.contrast = contrast
- if (tintRgb) state.tint = new Cartesian3(tintRgb.r, tintRgb.g, tintRgb.b)
- if (Number.isFinite(tintStrength)) state.tintStrength = tintStrength
- if (handle.stage) handle.stage.enabled = enabled !== false
- }
- // ================================================================ 点位标记(票 09)
- /**
- * 点位标记的数据源名:**唯一一个**,重新同步时整份替换而不是叠加
- * (否则翻一页就多画一批,地图上会出现已经不存在于当前快照的点)。
- */
- export const POINT_MARKER_SOURCE = 'cockpit-point-markers'
- /** 管线数据源名(票 22 交付;票 14 的高亮已经按它扫,落地当天即生效,不需要再改高亮)。 */
- export const PIPELINE_SOURCE = 'cockpit-pipelines'
- /**
- * 关联监测设备图层的**独立**数据源名(票 42)。
- *
- * 与 `POINT_MARKER_SOURCE` 分开是**结构性**的:关联设备只进这个临时图层,
- * 永远不写回摘要标记计划(规格:摘要分页、摘要图例、`data-map-markers*` 都不含它)。
- */
- export const DEVICE_MARKER_SOURCE = 'cockpit-associated-device-markers'
- /**
- * 建/取点位标记的数据源(幂等)。
- *
- * **必须自己缓存实例,不能只靠 `dataSources.getByName()`**:
- * Cesium 的 `DataSourceCollection.add()` 是**异步**的(`Promise.resolve(ds).then(...)` 里才 push
- * 进 `_dataSources`)。同一个 tick 里「先写后读」会走成这样:
- * 1. `syncPointMarkers()` 建了数据源 A(还没注册进集合)并把 8 个标记加进 A;
- * 2. 紧接着 `readPointMarkers()` 调 `getByName()` —— 集合里还没有 A,于是**又建了一个空数据源 B**,
- * 读回来 0 个标记。
- * 症状极具迷惑性:模块计数与 dataset 都说 8,组件渲染出来的却是「点位标记 0」,
- * 而晚一点的 DEV 把手调用(那时 A 已注册)又读回 8 —— 同一个页面两套答案。
- * 这是视觉探针截图里「图例有计数、标记写 0」的真正原因(走查没抓到,因为它读的是 dataset 与 DEV 把手)。
- *
- * 用 WeakMap 按 viewer 缓存:viewer 销毁后条目自然消失,不会漏实例,也不会在集合里留空数据源。
- */
- const markerSources = new WeakMap()
- function pointMarkerSource(viewer) {
- const cached = markerSources.get(viewer)
- if (cached) return cached
- // 兜一道:热重载等情况下集合里可能已经有同名数据源,优先复用它。
- const existing = viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0]
- if (existing) {
- markerSources.set(viewer, existing)
- return existing
- }
- const source = new CustomDataSource(POINT_MARKER_SOURCE)
- viewer.dataSources.add(source)
- markerSources.set(viewer, source)
- return source
- }
- /** 地图文字:浅亮字面、状态色细描边,名称与聚合计数共用。
- * 批次 3 走查修订:位置符号标签加**深色底板**(配色对齐 UI 面板深蓝系)提升可读;
- * `pixelOffset` 可覆盖默认偏移(BOTTOM 锚定的标记需要把标签顶到图标上方)。
- * `maxWidthPx` 限宽(文字可明显超出图标),超宽截断走 **domain/ellipsis.js 的共用契约**
- * (`ellipsisForMapLabel`——与 DOM 侧 EllipsisText 同一份省略语义,媒介不同实现各异)。
- * 完整名称走通用 hover:截断的 label 只管「认得出」,全名由 hover tooltip(DOM 层)
- * 与实体 description 承载,不塞进标签文本。 */
- function mapTextLabel(text, color, kind = 'name', show = true, pixelOffset = null, maxWidthPx = 0) {
- const style = MAP_LABEL_STYLE[kind]
- const tone = Color.fromCssColorString(color) || Color.WHITE
- // 限宽截断走共用契约(字宽估算的字号从本标签的 font 里取)。
- const fontMatch = /(\d+(?:\.\d+)?)px/.exec(style.font)
- const fontSize = fontMatch ? Number(fontMatch[1]) : 12
- const { shown } = ellipsisForMapLabel(text, maxWidthPx, fontSize)
- return {
- text: mapLabelText(shown, kind),
- font: style.font,
- fillColor: Color.WHITE,
- outlineColor: tone.withAlpha(0.65),
- outlineWidth: style.outlineWidth,
- style: LabelStyle.FILL_AND_OUTLINE,
- pixelOffset: pixelOffset || new Cartesian2(0, style.offsetY),
- heightReference: HeightReference.CLAMP_TO_GROUND,
- verticalOrigin: VerticalOrigin.BOTTOM,
- // 深色底板:label 与地图影像同色系时不再糊在一起(走查反馈)。
- // 底板配色对齐 UI 面板风格(tokens 的面板深蓝 + 青色线):Cesium 底板不支持渐变/圆角,
- // 用「深蓝实底 + 面板线色」近似 cockpit 面板观感;文字描边已弱化(底板承担对比度)。
- showBackground: true,
- backgroundColor: Color.fromCssColorString('#03203c').withAlpha(0.78),
- // 走查第五轮:文字到框边的距离收窄(4→3),底板更贴字。
- backgroundPadding: new Cartesian2(3, 3),
- show,
- }
- }
- /**
- * 点位标记的**差量同步**(票 33)。
- *
- * 以前这里是 `removeAll()` 整层重建(票 26 诊断:与管线层全量重赋值同批发生,
- * 刷新期标记同步闪烁)。现在按 `point|${key}` 索引 + 内容指纹做增删改:
- * · 指纹未变 → 一个属性都不写(实体原样留在图上,不闪);
- * · 指纹变了 / 坐标失效 / 从计划里消失 → 只动那一个实体;
- * · 重新上图仍用**同一个实体 id**,拾取与读数路径(`readPointMarkers` 等)不变。
- * 指纹取自标记计划输入(非 Cesium 对象),除 key 外一切影响渲染的字段都进来。
- *
- * @param {Viewer} viewer
- * @param {Array} markers `domain/pointStatus.js` 的 `planPointMarkers().markers`
- * @param {(marker: object) => string} colorOf 取该标记的 CSS 颜色(服务层从 token 读,引擎不写色值)
- * @param {(shape: string, color: string) => string} imageOf 形状+颜色 → 图片 URL(domain/markerSvg)
- * @returns {{rendered: number, skipped: number, unchanged: number}}
- */
- const markerEntityIndex = new WeakMap() // viewer → Map(`point|${key}` → { entity, fp })
- function markerFingerprint(marker) {
- return JSON.stringify([
- marker.key, marker.longitude, marker.latitude, marker.status,
- marker.tone, marker.shape, marker.visualKey, marker.code,
- marker.name || '', marker.deviceCodes || [],
- ])
- }
- export function syncPointMarkers(viewer, markers, colorOf, imageOf, planOf, tintOf) {
- const source = pointMarkerSource(viewer)
- let index = markerEntityIndex.get(viewer)
- if (!index) {
- index = new Map()
- markerEntityIndex.set(viewer, index)
- }
- const wanted = new Set()
- let rendered = 0
- let skipped = 0
- let unchanged = 0
- for (const marker of markers || []) {
- const id = `point|${marker.key}`
- // 经纬度缺失或非有限值不上图:计划层已按 `renderable` 过滤,
- // 这里再挡一道,免得脏数据把整批 entity 建崩(一条坏数据不该让其余点位消失)。
- if (!Number.isFinite(marker.longitude) || !Number.isFinite(marker.latitude)) {
- skipped += 1
- // 票 33:曾经上过图、现在坐标失效的,按差量撤下——不留在图上冒充仍可渲染。
- const stale = index.get(id)
- if (stale) {
- source.entities.remove(stale.entity)
- index.delete(id)
- }
- continue
- }
- wanted.add(id)
- const fp = markerFingerprint(marker)
- const record = index.get(id)
- if (record && record.fp === fp) {
- unchanged += 1
- continue
- }
- const color = colorOf(marker)
- if (record) source.entities.remove(record.entity)
- // 批次 3:合成计划(若有)决定母版/内层/锚点/降饱和与最终尺寸;尖端锚定。
- // 未传 planOf(单测/旧调用方)时保持中心锚定的旧 billboard 行为。
- // 第三轮:billboard 绘制尺寸用 `plan.draw`(含描边余量),本体锚点比例按 pad 重算——
- // 尖端在含 pad 画布里的相对位置 = (pad + 本体高 × 1) / 绘制高(BOTTOM 对齐的是画布底)。
- const plan = planOf ? planOf(marker) : null
- const size = plan ? plan.draw.width : 18
- const height = plan ? plan.draw.height : 18
- const entity = source.entities.add({
- id,
- position: Cartesian3.fromDegrees(marker.longitude, marker.latitude),
- billboard: {
- // 首帧用同步兜底(纯色水滴形),异步合成完成后替换为双层合成位图。
- image: plan ? imageOf(marker.shape, color) : imageOf(marker.shape, color),
- // 像素尺寸:宽度基准(点 18 / 设备 14),高按母版比例。
- width: size,
- height,
- scale: 1,
- // 贴地:有地形时点位落在地表而不是椭球面。
- heightReference: HeightReference.CLAMP_TO_GROUND,
- // 批次 3:尖端锚定——含 pad 画布的底边比尖端低 pad 屏显值,pixelOffset 下移补偿,
- // 使水滴尖(而非描边外沿)落在真实坐标。
- verticalOrigin: plan ? VerticalOrigin.BOTTOM : VerticalOrigin.CENTER,
- pixelOffset: plan ? new Cartesian2(0, plan.draw.pad) : new Cartesian2(0, 0),
- disableDepthTestDistance: Number.POSITIVE_INFINITY,
- },
- label: marker.name ? {
- // 批次 3:BOTTOM 锚定下标签顶到图标上方(计划层统一产出偏移),修「标签与图标重叠」。
- // 首帧用原生 label(截断文本),异步**底板位图**完成后整体替换——
- // 圆角 + 青色装饰边的底板 Cesium 原生画不出(走查反馈「硬得不行」)。
- ...mapTextLabel(marker.name, color, 'name', true, plan ? new Cartesian2(0, plan.labelOffsetY) : null, plan ? plan.labelMaxWidth : 0),
- disableDepthTestDistance: Number.POSITIVE_INFINITY,
- } : undefined,
- // 完整名称给通用 hover/拾取链路(走查反馈「hover 时显示详情」)。
- description: marker.name ? `<table class="cesium-infoBox-defaultTable"><tr><td>名称</td><td>${marker.name}</td></tr><tr><td>编码</td><td>${marker.code}</td></tr><tr><td>状态</td><td>${marker.label || marker.status}</td></tr></table>` : undefined,
- // 供走查/人工排查读状态:不依赖中文字符串,也不依赖读图。
- properties: {
- pointCode: marker.code,
- // 批次 3:hover tooltip 用完整名称(label 只放截断文本)。
- pointName: marker.name || '',
- deviceCodes: marker.deviceCodes || [],
- pointKind: marker.kind,
- pointStatus: marker.status,
- visualKey: marker.visualKey,
- shape: marker.shape,
- tone: marker.tone,
- // 批次 3:合成计划读数(规格 10 宿主观测接缝)。
- ...(plan ? planToDataset(plan) : {}),
- },
- })
- index.set(id, { entity, fp })
- rendered += 1
- // 异步合成:完成后替换 billboard 图片(指纹含计划读数,避免重复合成)。
- if (plan && planOf) {
- renderMarker(plan, { tintHex: tintOf ? tintOf(marker) : color })
- .then((dataUrl) => {
- // 实体可能已被差量撤下:只在仍在图上时替换。
- if (index.get(id) && entity.billboard) {
- entity.billboard.image = dataUrl
- }
- })
- .catch(() => {
- // 合成失败保留同步兜底图(降级可见),不阻塞其余标记。
- })
- // 批次 3 第五轮曾试「底板烘位图替换 label」——维护者复核**文字消失**(Cesium label
- // 的 image/text 互斥渲染 + text=' ' 导致零宽),已**回退为原生 label 底板**:
- // 文字可见性优先于底板美化;圆角/装饰边等 Cesium 引擎限制如实登记(走查遗留)。
- }
- }
- // 本轮不该出现的(被计划裁掉的):逐个撤下,不再整层 removeAll。
- for (const [id, record] of [...index.entries()]) {
- if (wanted.has(id)) continue
- source.entities.remove(record.entity)
- index.delete(id)
- }
- return { rendered, skipped, unchanged }
- }
- /**
- * 只读回点位标记的**真实图层内容**(走查据此断言「标记真的画在地图上」,
- * 而不是只信计划算出来的数)。
- *
- * 除了计数与状态分布,还回每个视觉键对应的 billboard 图片 URL:走查用它证明
- * 「每个状态的颜色确实不同、形状确实是形状」——这两件事只有从引擎实体上读才算证据。
- */
- export function readPointMarkers(viewer) {
- const source = pointMarkerSource(viewer)
- const byStatus = {}
- const shapes = new Set()
- const images = {}
- for (const entity of source.entities.values) {
- const properties = entity.properties || {}
- const get = (key) => (properties[key] ? properties[key].getValue() : '')
- const visualKey = get('visualKey') || 'UNKNOWN'
- const shape = get('shape') || ''
- byStatus[visualKey] = (byStatus[visualKey] || 0) + 1
- if (shape) shapes.add(shape)
- // 同一视觉键只留一份图片,够判「不同状态图片不同」。
- if (!images[visualKey]) {
- const billboard = entity.billboard
- images[visualKey] = {
- shape,
- tone: get('tone'),
- image: billboard ? billboard.image.getValue() : '',
- }
- }
- }
- return { count: source.entities.values.length, byStatus, shapes: [...shapes].filter(Boolean).sort(), images }
- }
- /**
- * 只读回点位标记的**窗口坐标**(票 45 走查用:真实鼠标要点的位置)。
- *
- * 与 `readOverlapTargets` 同一条纪律:只回投影后的屏幕坐标与身份,不出 Cesium 实体。
- * 用**逻辑可见性** `entity.show`(`isShowing` 是渲染态,headless 下会差一帧)。
- */
- export function readPointMarkerTargets(viewer) {
- const source = pointMarkerSource(viewer)
- const rect = viewer && viewer.canvas ? viewer.canvas.getBoundingClientRect() : null
- if (!rect || !viewer.scene) return []
- const ellipsoid = viewer.scene.globe && viewer.scene.globe.ellipsoid
- return source.entities.values
- .map((entity) => {
- const properties = entity.properties || {}
- const get = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
- const position = entity.position && entity.position.getValue ? entity.position.getValue(viewer.clock.currentTime) : null
- if (!position) return null
- // 点位标记是 `HeightReference.CLAMP_TO_GROUND` **贴地形**渲染的;
- // 直接投影 `entity.position`(椭球零高度)会与画面上看到的标记差出一段地形高度,
- // 真实鼠标按那个坐标点会**打不中**(票 45 真实路径走查抓到)。
- // 这里与渲染同口径:先取地形高度,再投影。
- let world = position
- if (ellipsoid) {
- const carto = ellipsoid.cartesianToCartographic(position)
- if (carto) {
- let height = 0
- const sampled = viewer.scene.globe.getHeight(carto)
- if (Number.isFinite(sampled)) height = sampled
- world = Cartesian3.fromRadians(carto.longitude, carto.latitude, height)
- }
- }
- const projected = SceneTransforms.worldToWindowCoordinates(viewer.scene, world)
- if (!projected || !Number.isFinite(projected.x) || !Number.isFinite(projected.y)) return null
- return {
- x: projected.x + rect.left,
- y: projected.y + rect.top,
- code: get('pointCode'),
- status: get('pointStatus'),
- visible: entity.show !== false,
- }
- })
- .filter(Boolean)
- }
- /** 清空点位标记(快照失效/刷新前用:旧快照的点不得留在图上)。 */
- export function clearPointMarkers(viewer) {
- pointMarkerSource(viewer).entities.removeAll()
- // 票 33:差量索引一起作废——实体已整层清掉,留指纹会让下次同步误判「未变」。
- markerEntityIndex.delete(viewer)
- }
- // ================================================================ 关联监测设备图层(票 42)
- const deviceSources = new WeakMap()
- /** 建/取关联设备图层的数据源(幂等,与点位标记同款:必须自己缓存,理由见上方注释)。 */
- function deviceMarkerSource(viewer) {
- const cached = deviceSources.get(viewer)
- if (cached) return cached
- const existing = viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0]
- if (existing) {
- deviceSources.set(viewer, existing)
- return existing
- }
- const source = new CustomDataSource(DEVICE_MARKER_SOURCE)
- viewer.dataSources.add(source)
- deviceSources.set(viewer, source)
- return source
- }
- /**
- * 把**当前选中点位的关联设备**同步到独立临时图层(票 42)。
- *
- * 三件事是规格硬要求,都体现在签名与实体属性上:
- * 1. **整份替换**(`removeAll` 后重建):关闭/换点/刷新时旧图层立即撤下,不会与新的混在一起;
- * 2. **独立身份**:实体 id 与属性都带 `deviceCode` **和** `monitoringPointCode`,
- * 绝不写 `pointCode` —— 设备不冒充监测点(票 05 的结构性保证);
- * 3. **尺寸小于监测点**(14 vs 18):层级靠尺寸区分,具体形状/资产接缝归票 44。
- *
- * 调用方只传**已判定可上图**的行(`domain/deviceLayer.js` 的 `placement === 'drawn'`);
- * 这里再挡一道经纬度,避免一条脏数据把整批 entity 建崩。
- *
- * @param {Viewer} viewer
- * @param {Array} devices 领域行(含 `code`/`pointCode`/`longitude`/`latitude`/`status`)
- * @param {(device: object) => string} colorOf 取该设备的 CSS 颜色(色值仍由服务层从 token 读)
- * @param {(shape: string, color: string) => string} imageOf 形状+颜色 → 图片 URL
- * @returns {{rendered: number, skipped: number}}
- */
- export function syncAssociatedDevices(viewer, devices, colorOf, imageOf, overlapGroups = [], planOf, tintOf) {
- const source = deviceMarkerSource(viewer)
- source.entities.removeAll()
- // 票 47:实体是**整份重建**的,动效基准与循环必须一起复位
- // (否则基准表里留着上一批实体的 id,动效会写到不存在的对象上)。
- deviceBaseStyle.clear()
- stopDeviceEffects()
- let rendered = 0
- let skipped = 0
- const groupsByCode = new Map()
- for (const group of Array.isArray(overlapGroups) ? overlapGroups : []) {
- if (!group || group.k < 2) continue
- for (const row of group.rows || []) groupsByCode.set(row.code, group)
- }
- for (const device of devices || []) {
- if (!Number.isFinite(device.longitude) || !Number.isFinite(device.latitude)) {
- skipped += 1
- continue
- }
- const color = colorOf(device)
- // 票 44:尺寸与形状都来自**单一资产接缝**(`domain/deviceAssets.js` 的计划),
- // 引擎不认识"设备该多大",也不写死尺寸字面量。
- const size = Number.isFinite(device.size) ? device.size : 14
- const group = groupsByCode.get(device.code)
- // 批次 3:关联设备走实心母版合成计划(第三轮:绘制尺寸含描边余量 `plan.draw`)。
- const plan = planOf ? planOf(device) : null
- const drawW = plan ? plan.draw.width : size
- const drawH = plan ? plan.draw.height : size
- const entity = source.entities.add({
- id: `device|${device.pointCode}|${device.code}`,
- position: Cartesian3.fromDegrees(device.longitude, device.latitude),
- show: !group,
- billboard: {
- image: imageOf(device.shape || 'circle', color, size),
- width: drawW,
- height: drawH,
- scale: 1,
- // 第三轮:含 pad 画布的底边比尖端低 pad 屏显值,下移补偿(尖端落真实坐标)。
- pixelOffset: plan ? new Cartesian2(0, plan.draw.pad) : new Cartesian2(0, 0),
- heightReference: HeightReference.CLAMP_TO_GROUND,
- verticalOrigin: plan ? VerticalOrigin.BOTTOM : VerticalOrigin.CENTER,
- },
- label: group ? mapTextLabel(`聚合 ${group.k} 台${group.visual.label ? ` · 组内${group.visual.label}` : ''}`, color, 'count', false, plan ? new Cartesian2(0, plan.labelOffsetY) : null, plan ? plan.labelMaxWidth : 0) : undefined,
- properties: {
- deviceCode: device.code,
- // 批次 3:hover tooltip 用完整名称(label 只放截断文本)。
- deviceName: device.name || '',
- monitoringPointCode: device.pointCode,
- deviceStatus: device.status || '',
- deviceShape: device.shape || 'circle',
- assetMode: device.assetMode || '',
- assetReason: device.assetReason || '',
- assetReasonText: device.assetReasonText || '',
- overlapGroup: group ? group.key : '',
- overlapPointCode: group ? group.pointCode : '',
- overlapAggregate: false,
- overlapEntry: false,
- overlapCount: group ? group.k : 0,
- overlapFanIndex: -1,
- // 批次 3:合成计划读数(规格 10 宿主观测接缝)。
- ...(plan ? planToDataset(plan) : {}),
- },
- })
- rendered += 1
- // 异步合成:完成后替换 billboard 图片(失败保留同步兜底)。
- if (plan) {
- renderMarker(plan, { tintHex: tintOf ? tintOf(device) : color })
- .then((dataUrl) => {
- if (entity.billboard) entity.billboard.image = dataUrl
- })
- .catch(() => {})
- }
- }
- // 聚合符号没有 deviceCode:拾取它只能返回 overlap action,不能冒充任一台设备。
- for (const group of Array.isArray(overlapGroups) ? overlapGroups : []) {
- if (!group || group.k < 2 || !group.rows.length) continue
- const representative = group.rows.find((row) => row.status === (group.visual && group.visual.status)) || group.rows[0]
- const color = colorOf(representative)
- const size = Number.isFinite(representative.size) ? representative.size : 14
- source.entities.add({
- id: `overlap|${group.pointCode}|${group.key}`,
- position: Cartesian3.fromDegrees(representative.longitude, representative.latitude),
- billboard: {
- image: imageOf(group.visual.shape, color, size),
- width: size * 1.5,
- height: size * 1.5,
- scale: 1,
- pixelOffset: new Cartesian2(0, 0),
- heightReference: HeightReference.CLAMP_TO_GROUND,
- verticalOrigin: VerticalOrigin.CENTER,
- },
- label: mapTextLabel(`聚合 ${group.k} 台${group.visual.label ? ` · 组内${group.visual.label}` : ''}`, color, 'count'),
- properties: {
- overlapAggregate: true,
- overlapGroup: group.key,
- overlapPointCode: group.pointCode,
- overlapCount: group.k,
- overlapState: group.state,
- overlapLabel: group.label,
- deviceStatus: group.visual.status,
- overlapEffectCode: representative.code,
- // 不能含 `deviceCode` / `monitoringPointCode`:聚合符号不冒充设备身份。
- },
- })
- }
- return { rendered: devices ? devices.length : 0, skipped }
- }
- /**
- * 展开/预览重叠设备:只改 billboard `pixelOffset`(屏幕渲染层);实体 position 与属性经纬度不动。
- * `offsets` 是 `{[deviceCode]: {x,y}}`;collapsed 时恢复为 0。
- */
- export function nudgeOverlap(viewer, groupKey) {
- const source = deviceMarkerSource(viewer)
- let touched = 0
- for (const entity of source.entities.values) {
- const properties = entity.properties || {}
- const group = properties.overlapGroup && properties.overlapGroup.getValue ? properties.overlapGroup.getValue() : ''
- const aggregate = properties.overlapAggregate && properties.overlapAggregate.getValue ? properties.overlapAggregate.getValue() : false
- const entry = properties.overlapEntry && properties.overlapEntry.getValue ? properties.overlapEntry.getValue() : false
- if (group !== groupKey || (!aggregate && !entry)) continue
- if (entity.billboard) {
- entity.billboard.scale = entry ? 1.6 : 1.2
- entity.billboard.outlineWidth = 2
- entity.billboard.outlineColor = Color.WHITE.withAlpha(0.85)
- }
- touched += 1
- }
- return touched
- }
- /** 清空重叠聚合面板hover弱提示。 */
- export function clearOverlapNudge(viewer) {
- const source = deviceMarkerSource(viewer)
- for (const entity of source.entities.values) {
- const properties = entity.properties || {}
- const aggregate = properties.overlapAggregate && properties.overlapAggregate.getValue ? properties.overlapAggregate.getValue() : false
- if (aggregate && entity.billboard) {
- entity.billboard.scale = 1
- entity.billboard.outlineWidth = 0
- }
- }
- }
- export function setOverlapPresentation(viewer, { groupKey = '', state = 'collapsed', preview = false, offsets = {}, selectedCode = '' } = {}) {
- const source = deviceMarkerSource(viewer)
- let touched = 0
- for (const entity of source.entities.values) {
- const properties = entity.properties || {}
- const read = (key) => properties[key] && properties[key].getValue ? properties[key].getValue() : ''
- if (!groupKey || read('overlapGroup') !== groupKey) continue
- const aggregate = read('overlapAggregate') === true
- const showFan = state === 'expanded'
- if (aggregate) {
- // 选中设备接替中心入口;展开时保留聚合入口并压暗,计数始终可读。
- entity.show = !selectedCode
- if (entity.billboard) {
- entity.billboard.color = Color.WHITE.withAlpha(showFan && !preview ? 0.35 : 1)
- entity.billboard.scale = preview ? 1.2 : 1
- entity.billboard.outlineWidth = preview ? 2 : 0
- entity.billboard.outlineColor = Color.WHITE
- }
- } else {
- const code = read('deviceCode')
- const selected = Boolean(selectedCode && code === selectedCode)
- const offset = selected ? { x: 0, y: 0 } : offsets[code]
- entity.show = selected || (showFan && Boolean(offset))
- if (entity.billboard) {
- entity.billboard.pixelOffset = offset
- ? new Cartesian2(offset.x, offset.y)
- : new Cartesian2(0, 0)
- }
- if (entity.label) entity.label.show = selected
- if (properties.overlapEntry && properties.overlapEntry.setValue) properties.overlapEntry.setValue(selected)
- else if (properties.addProperty) properties.addProperty('overlapEntry', selected)
- }
- touched += 1
- }
- if (viewer.scene && viewer.scene.requestRender) viewer.scene.requestRender()
- return touched
- }
- /**
- * 票 47:关联设备图层的每帧投影读数(DOM overlay 消费)。
- *
- * 只回**当前可见**的设备实体:折叠聚合下的个体 `show=false` 不进 DOM(聚合符号按票面
- * Out of Scope 留在 billboard);展开扇形后的个体带着 pixelOffset 回来,DOM 与 billboard
- * 同位同量。坐标 = 窗口坐标(与 `projectPivot` 同系);`scale`/`alpha` 读的是实体上
- * **当前真值**(选中 1.6 / hover 1.3 / 弱化 0.75 / 强调 1.55 都含在内)——
- * DOM 镜像它才不会出现「比 billboard 小一圈」。
- */
- export function projectDeviceMarkers(viewer) {
- const source = deviceMarkerSource(viewer)
- if (!viewer || !viewer.scene || !viewer.canvas || !source || source.show === false) return []
- const out = []
- for (const entity of source.entities.values) {
- const properties = entity.properties || {}
- const read = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
- const code = read('deviceCode')
- if (!code || entity.show === false) continue
- // projectMarkerEntity 已含画布原点(窗口坐标)与 pixelOffset(尖端落点)——直接用,
- // 调用方只减宿主原点(与 projectToWindow 的口径只差 host 一层)。
- const projected = projectMarkerEntity(viewer, entity)
- if (!projected) continue
- const color = entity.billboard && entity.billboard.color ? entity.billboard.color.getValue() : null
- out.push({
- key: `device|${read('monitoringPointCode')}|${code}`,
- code,
- pointCode: read('monitoringPointCode'),
- x: projected.x,
- y: projected.y,
- scale: entity.billboard && entity.billboard.scale ? Number(entity.billboard.scale.getValue()) || 1 : 1,
- alpha: color && Number.isFinite(color.alpha) ? color.alpha : 1,
- })
- }
- return out
- }
- /**
- * 票 47:点位标记(监测点/未关联设备)的每帧缩放读数。
- * 强调(`highlightEntities` 的 1.55)写在 billboard.scale 上,DOM 要镜像同一个量,
- * 否则面板联动强调时两层尺寸不一致(维护者走查抓到的「小了一圈」)。
- * @returns {Map<string, number>} `kind|code` → scale(没实体 = 不在图上,DOM 自行隐藏)
- */
- export function readPointMarkerScales(viewer) {
- const scales = new Map()
- const source = (viewer && markerSources.get(viewer)) || (viewer && viewer.dataSources ? viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0] : null)
- if (!source) return scales
- for (const entity of source.entities.values) {
- const properties = entity.properties || {}
- const read = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
- const code = read('pointCode')
- if (!code) continue
- const kind = read('pointKind') || 'monitoring-point'
- scales.set(`${kind}|${code}`, entity.billboard && entity.billboard.scale ? Number(entity.billboard.scale.getValue()) || 1 : 1)
- }
- return scales
- }
- /** 只读回关联设备图层的真实内容(走查据此断言「设备真的画在独立图层上」,以及「画成的样子来自接缝」)。 */
- export function readAssociatedDevices(viewer) {
- const source = deviceMarkerSource(viewer)
- return source.entities.values.map((entity) => {
- const properties = entity.properties || {}
- const get = (key) => (properties[key] ? properties[key].getValue() : '')
- // 票 44:实体上**已经**带着接缝的结论(形状/资产模式/降级原因),原读取器却把它们丢掉了 ——
- // 于是「地图上真画的那台设备是什么形状、什么资产模式」在引擎侧读不到,走查只能退回去读
- // 计划行(那是"打算画什么",不是"画成了什么")。这里如实读回来。
- // 尺寸取 **billboard 的真实宽高**,而不是再读一个属性:这样读到的是真画上去的尺寸。
- const size = entity.billboard && entity.billboard.width ? entity.billboard.width.getValue() : null
- return {
- id: entity.id,
- deviceCode: get('deviceCode'),
- monitoringPointCode: get('monitoringPointCode'),
- deviceStatus: get('deviceStatus'),
- shape: get('deviceShape'),
- size,
- assetMode: get('assetMode'),
- assetReason: get('assetReason'),
- assetReasonText: get('assetReasonText'),
- visible: entity.isShowing === true,
- overlapAggregate: get('overlapAggregate') === true,
- overlapEntry: get('overlapEntry') === true,
- overlapGroup: get('overlapGroup'),
- overlapPointCode: get('overlapPointCode'),
- overlapCount: get('overlapCount'),
- labelText: entity.label && entity.label.text ? entity.label.text.getValue() : '',
- pixelOffset: entity.billboard && entity.billboard.pixelOffset
- ? entity.billboard.pixelOffset.getValue()
- : null,
- }
- })
- }
- /** 清空关联设备图层(关闭 / 换点 / 快照失效 / 离开页面都走它)。 */
- /**
- * 票 45:重叠实体的只读窗口坐标(真实 Cesium 投影 + pixelOffset)。
- * 走查用它把真实鼠标送到聚合符号/展开子点;只读渲染坐标,不写设备经纬度。
- */
- export function readOverlapTargets(viewer) {
- const source = deviceMarkerSource(viewer)
- const rect = viewer && viewer.canvas ? viewer.canvas.getBoundingClientRect() : null
- if (!rect || !viewer.scene) return []
- return source.entities.values
- .filter((entity) => entity.properties && entity.properties.overlapGroup)
- .map((entity) => {
- const projected = projectMarkerEntity(viewer, entity)
- if (!projected) return null
- const properties = entity.properties || {}
- const get = (key) => properties[key] && properties[key].getValue ? properties[key].getValue() : ''
- return {
- x: projected.x,
- y: projected.y,
- // 用**逻辑可见性** `entity.show`(Cesium 里 `isShowing` 是渲染态,headless 下
- // `show` 改变后可能要等一帧才同步;拾取判据不能依赖渲染帧,否则展开态设备
- // 会被误滤掉而聚合却可见 —— 真机走查抓到过这一帧)。
- visible: entity.show !== false,
- aggregate: get('overlapAggregate') === true,
- deviceCode: get('deviceCode'),
- pointCode: get('monitoringPointCode') || get('overlapPointCode'),
- status: get('deviceStatus'),
- overlapEntry: get('overlapEntry') === true,
- overlapGroup: get('overlapGroup'),
- }
- })
- .filter(Boolean)
- }
- export function clearAssociatedDevices(viewer) {
- // 票 47:图层撤下 ⇒ 特效随图层一起消失(决策票 07:不需要单独的消失逻辑,
- // 因为特效就挂在图层上)。这里显式停循环,避免空转写帧。
- stopDeviceEffects()
- deviceBaseStyle.clear()
- deviceMarkerSource(viewer).entities.removeAll()
- }
- /**
- * 屏幕坐标 → **设备标记身份**(票 42 的独立拾取入口;票 43 会在此基础上做双向联动)。
- *
- * 与 `pickPointMarkerAt()` 同一条纪律:只认本图层上带 `deviceCode` 的实体,
- * 回**身份**不回 Cesium 实体,且**设备优先于监测点**(票 15 的拾取优先级)。
- */
- function overlapTargetAt(viewer, windowX, windowY) {
- const targets = readOverlapTargets(viewer)
- let nearest = null
- let distance = Infinity
- for (const target of targets.filter((item) => item.visible)) {
- const current = Math.hypot(target.x - windowX, target.y - windowY)
- if (current <= 24 && current < distance) {
- nearest = target
- distance = current
- }
- }
- if (!nearest) return null
- if (nearest.aggregate) return { kind: 'monitoring-device-overlap', code: '', pointCode: '', overlapGroup: nearest.overlapGroup }
- if (nearest.deviceCode) return {
- kind: 'monitoring-device', code: nearest.deviceCode, pointCode: nearest.pointCode, status: nearest.status,
- overlapEntry: nearest.overlapEntry, overlapGroup: nearest.overlapGroup,
- }
- return null
- }
- export function pickPipeObjectAt(viewer, windowX, windowY) {
- if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
- const rect = viewer.canvas.getBoundingClientRect()
- const picked = viewer.scene.pick(new Cartesian2(windowX - rect.left, windowY - rect.top))
- const source = pipelineSource(viewer)
- const id = picked && picked.id
- if (source && id && source.entities.values.includes(id)) {
- const properties = id.properties || {}
- const read = (key) => properties[key] && properties[key].getValue ? properties[key].getValue() : ''
- const kind = read('kind')
- const code = read('code')
- if (kind === 'pipe' && code) return { kind: 'pipeline', code, objectKind: 'pipe' }
- if (kind === 'component' && code) return { kind: 'pipeline', code, objectKind: 'component' }
- }
- // Instanced glTF batches expose the batch id and instanceId through public pick data.
- // Resolve only that instance; never report the first item in a batch as the hit.
- if (id && id.kind === 'pipe-model-batch' && Number.isInteger(picked.instanceId)) {
- const instance = Array.isArray(id.instances) ? id.instances[picked.instanceId] : ''
- const match = /^(pipe|component):(.*)$/.exec(String(instance || ''))
- if (match) return { kind: 'pipeline', code: match[2], objectKind: match[1] === 'pipe' ? 'pipe' : 'component' }
- }
- return null
- }
- /** 设备、监测点之外的第三层拾取入口:只返回管段/构件身份。 */
- /** 仅穿过本管线图层;其它场景对象仍按 Cesium 的前后顺序遮挡。 */
- function pickThroughPipelines(viewer, position) {
- const scene = viewer.scene
- const picked = scene.pick(position)
- const source = pipelineSource(viewer)
- const isPipeline = (hit) => {
- const id = hit && hit.id
- return Boolean(id && (id.kind === 'pipe-model-batch' || (source && source.entities.values.includes(id))))
- }
- if (!isPipeline(picked)) return picked
- // 普通拾取不受影响;只有最上层是管线时才执行多层拾取。
- if (typeof scene.drillPick !== 'function') return null
- return scene.drillPick(position).find((hit) => !isPipeline(hit)) || null
- }
- export function pickDeviceMarkerAt(viewer, windowX, windowY) {
- if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
- // 票45:展开后的设备拥有屏幕pixelOffset;先按公开投影读数做近邻身份判定,
- // 避免同屏监测点/地形顶层对象让普通scene.pick吞掉真实设备。仍保持普通拾取语义。
- const projectedHit = overlapTargetAt(viewer, windowX, windowY)
- if (projectedHit) return projectedHit
- const rect = viewer.canvas.getBoundingClientRect()
- const picked = pickThroughPipelines(viewer, new Cartesian2(windowX - rect.left, windowY - rect.top))
- const entity = picked && picked.id
- const properties = entity && entity.properties
- if (properties) {
- const read = (key) => (properties[key] ? properties[key].getValue() : '')
- if (read('overlapAggregate') === true) {
- return {
- kind: 'monitoring-device-overlap',
- code: '',
- pointCode: read('overlapPointCode'),
- overlapGroup: read('overlapGroup'),
- overlapCount: read('overlapCount'),
- }
- }
- const code = read('deviceCode')
- if (code) return {
- kind: 'monitoring-device', code, pointCode: read('monitoringPointCode'), status: read('deviceStatus'),
- name: read('deviceName') || '',
- overlapEntry: read('overlapEntry') === true, overlapGroup: read('overlapGroup'),
- }
- }
- // 重叠聚合与监测点同屏时,scene.pick 可能只返回顶层点位;
- // 用聚合实体投影做近邻补偿,仍是普通拾取、不改真实位置。
- return overlapTargetAt(viewer, windowX, windowY)
- }
- // ================================================================ 设备点的对应反应(票 43)
- /**
- * 把「每台设备的反应档位」写进显示参数(票 43 的反应语言)。
- *
- * 三条纪律都体现在这里:
- * 1. **不用颜色**:只改 `scale` 与 `color.alpha`(弱化是压暗,不是换色)——
- * 色键语义仍只属于设备状态(票 44/07);
- * 2. **外环**用 billboard 的 `outlineWidth`/`outlineColor`(Cesium 公开属性),
- * 不用新建实体、不用着色器 —— 关掉时写回 0;
- * 3. **每个实体都过一遍**:命中与未命中都要写回值,否则上次的强化会一直留在图上
- * (与票 14 的 `highlightEntities` 同一条教训)。
- *
- * @param {Viewer} viewer
- * @param {Array} reactions `domain/deviceCorrespondence.js` 的 `planDeviceReactions().reactions`
- * @param {(reaction: string) => object} styleOf 档位 → `{ scale, alpha, ring }`
- * @returns {number} 本次被写入的实体数(0 = 图层为空 / 引擎没就绪)
- */
- export function applyDeviceReactions(viewer, reactions, styleOf) {
- const source = deviceSources.get(viewer) || (viewer && viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null)
- if (!source) return 0
- const byId = new Map()
- for (const item of reactions || []) {
- if (item && item.code) byId.set(`device|${item.pointCode}|${item.code}`, item.reaction)
- }
- let written = 0
- for (const entity of source.entities.values) {
- // 图层里没有对应反应的实体(理论上不该有)也按常态写回,避免残留强化。
- const reaction = byId.get(entity.id) || 'normal'
- const style = styleOf(reaction) || { scale: 1, alpha: 1, ring: false }
- const billboard = entity.billboard
- if (!billboard || (entity.properties && (entity.properties.effectFx ||
- (entity.properties.overlapAggregate && entity.properties.overlapAggregate.getValue() === true)))) continue
- const isDevice = entity.properties && entity.properties.deviceCode
- billboard.scale = isDevice ? Math.max(1, style.scale) : style.scale
- // 设备中心保持不透明;选中/hover 仍用放大与外围特效区分。
- billboard.color = Color.WHITE.withAlpha(isDevice ? 1 : style.alpha)
- // 选中外环:Cesium billboard 自带的描边,不用新建实体。
- billboard.outlineWidth = style.ring ? 3 : 0
- billboard.outlineColor = Color.WHITE
- // 票 47:记下**基准**(动效在此之上做乘性修饰,不覆盖反应量)。
- deviceBaseStyle.set(entity.id, { scale: style.scale, alpha: style.alpha })
- written += 1
- }
- return written
- }
- /**
- * 票 47:设备状态**动效**驱动器(决策票 07 的三个动效 + 门控 + 降级)。
- *
- * 中心符号保持不透明和稳定形状;选中/hover 的尺寸强化仍保留。
- * 扩张、闪烁、渐隐和旋转只作用于外围光环,不再与中心弱化透明度相乘。
- * 场景按需渲染:存在动态设备时 requestRender,关闭或降级时取消动画循环。
- *
- * ── 载体是场景内 billboard,**不是后处理级** ──
- * 决策票 07 已裁定:特效走 billboard ⇒「先特效、后雾」自动成立,不占 `PostProcessStageCollection`、
- * 与雾无排序冲突,也不新增穿雾例外。
- */
- const deviceEffectState = {
- viewer: null,
- plan: null,
- selectedCode: '',
- raf: 0,
- tick: 0,
- }
- /** 反应量写下的**基准** scale/alpha(动效在其上做乘性修饰)。键=实体 id。 */
- const deviceBaseStyle = new Map()
- /**
- * 票 47(科技感方向):光环实体池。
- *
- * ── 为什么用**独立 billboard 实体**而不是改设备图标本身 ──
- * 决策票 07 的参考图要的是「多层同心扩散环 + 中心辉光 + 能量描边」;
- * 设备图标只有一个 billboard,塞不下多层环。所以另建一层光环实体(`fx|` 前缀):
- * · **不带 `deviceCode`** ⇒ 按研究笔记第 7 条,它不参与设备拾取判定,不会挡住设备点;
- * · 位置跟设备同坐标,随图层一起重建/销毁(特效挂在图层上);
- * · 每台最多 `RING_LAYERS` 个环 + 1 个辉光,同处一个数据源(一次 draw call 量级)。
- */
- const RING_ENTITY_PREFIX = 'fx|'
- function ringEntitiesOf(source, code) {
- return source.entities.values.filter((entity) =>
- typeof entity.id === 'string' &&
- entity.id.startsWith(RING_ENTITY_PREFIX) &&
- entity.id.includes(`|${code}|`))
- }
- /** 建/更新某台设备的光环实体(层数固定:缺则建、数量不符则整组重建)。 */
- function ensureRingEntities(source, device, color, fxImageOf) {
- const size = Number.isFinite(device.size) ? device.size : 14
- // 层数与"要不要辉光"都问 domain(引擎不认识业务状态名,只认"几层环")。
- const wanted = ringEntityCount(device.status)
- if (!wanted) return []
- const existing = ringEntitiesOf(source, device.code)
- if (existing.length === wanted) return existing
- for (const entity of existing) source.entities.remove(entity)
- const created = []
- for (let layer = 0; layer < RING_LAYERS; layer += 1) {
- created.push(source.entities.add({
- id: `${RING_ENTITY_PREFIX}${device.pointCode}|${device.code}|ring${layer}`,
- position: Cartesian3.fromDegrees(device.longitude, device.latitude),
- billboard: {
- // 环用**能量场图片**(径向渐变柔和边缘):比 2px 描边圆更像特效,
- // 放大时边缘自然扩散,不会一放大就细成一根线(首版真机截图抓到的问题)。
- image: fxImageOf('ring', color, size),
- width: size,
- height: size,
- scale: 1,
- color: Color.WHITE.withAlpha(0),
- heightReference: HeightReference.CLAMP_TO_GROUND,
- verticalOrigin: VerticalOrigin.CENTER,
- },
- properties: {
- effectFx: true,
- effectFxKind: 'ring',
- effectFxLayer: layer,
- effectFxFor: device.code,
- effectFxStatus: device.status,
- effectFxDisplayGroup: '',
- // 刻意**不写 deviceCode / monitoringPointCode**:光环不冒充设备、不吃拾取。
- },
- }))
- }
- if (hasGlow(device.status)) {
- created.push(source.entities.add({
- id: `${RING_ENTITY_PREFIX}${device.pointCode}|${device.code}|glow`,
- position: Cartesian3.fromDegrees(device.longitude, device.latitude),
- billboard: {
- image: fxImageOf('glow', color, size),
- width: size,
- height: size,
- scale: 1,
- color: Color.WHITE.withAlpha(0),
- heightReference: HeightReference.CLAMP_TO_GROUND,
- verticalOrigin: VerticalOrigin.CENTER,
- },
- properties: {
- effectFx: true,
- effectFxKind: 'glow',
- effectFxLayer: -1,
- effectFxFor: device.code,
- effectFxStatus: device.status,
- effectFxDisplayGroup: '',
- },
- }))
- }
- return created
- }
- /** 清掉某台(或全部)光环实体。 */
- function clearRingEntities(source, code = '') {
- for (const entity of source.entities.values.slice()) {
- if (typeof entity.id !== 'string' || !entity.id.startsWith(RING_ENTITY_PREFIX)) continue
- if (code && !entity.id.includes(`|${code}|`)) continue
- source.entities.remove(entity)
- }
- }
- function baseStyleOf(entityId) {
- return deviceBaseStyle.get(entityId) || { scale: 1, alpha: 1 }
- }
- function animatedByCode(plan) {
- const map = new Map()
- for (const item of (plan && plan.animated) || []) map.set(item.code, item.effect)
- return map
- }
- function writeEffectFrame() {
- const viewer = deviceEffectState.viewer
- const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null))
- if (!viewer || !source) return
- const plan = deviceEffectState.plan
- const animated = animatedByCode(plan)
- const now = Date.now()
- for (const entity of source.entities.values) {
- const billboard = entity.billboard
- if (!billboard) continue
- const properties = entity.properties || {}
- const code = properties.deviceCode && properties.deviceCode.getValue ? properties.deviceCode.getValue() : ''
- if (!code) continue
- const base = baseStyleOf(entity.id)
- // 中心符号负责识别,外围光环负责动画;不把闪烁与弱化透明度乘到中心上。
- billboard.scale = Math.max(1, base.scale)
- billboard.color = Color.WHITE.withAlpha(1)
- billboard.rotation = 0
- }
- // ── 科技感光环层(多层同心扩散环 + 中心辉光)──
- // 光环实体不带 deviceCode,所以**不参与上面的设备图标循环**,也不吃拾取。
- const selectedBoost = 1.25
- const owners = new Map(source.entities.values
- .filter((entity) => entity.properties && entity.properties.deviceCode)
- .map((entity) => [entity.properties.deviceCode.getValue(), entity]))
- const aggregates = new Map(source.entities.values
- .filter((entity) => entity.properties && entity.properties.overlapAggregate && entity.properties.overlapAggregate.getValue() === true)
- .map((entity) => [entity.properties.overlapGroup.getValue(), entity]))
- const entries = new Map([...owners.values()]
- .filter((entity) => entity.show !== false && entity.properties.overlapEntry && entity.properties.overlapEntry.getValue() === true)
- .map((entity) => [entity.properties.overlapGroup.getValue(), entity]))
- for (const entity of source.entities.values) {
- const billboard = entity.billboard
- const properties = entity.properties || {}
- const isFx = properties.effectFx && properties.effectFx.getValue && properties.effectFx.getValue() === true
- if (!billboard || !isFx) continue
- const forCode = properties.effectFxFor && properties.effectFxFor.getValue ? properties.effectFxFor.getValue() : ''
- const status = properties.effectFxStatus && properties.effectFxStatus.getValue ? properties.effectFxStatus.getValue() : ''
- const kind = properties.effectFxKind && properties.effectFxKind.getValue ? properties.effectFxKind.getValue() : ''
- const layer = properties.effectFxLayer && properties.effectFxLayer.getValue ? properties.effectFxLayer.getValue() : 0
- const effect = forCode ? animated.get(forCode) : ''
- const owner = owners.get(forCode)
- const groupKey = owner && owner.properties.overlapGroup ? owner.properties.overlapGroup.getValue() : ''
- const aggregate = aggregates.get(groupKey)
- const entry = entries.get(groupKey)
- const entryHasGroupEffect = entry && aggregate && animated.has(entry.properties.deviceCode.getValue()) &&
- entry.properties.deviceStatus.getValue() === aggregate.properties.deviceStatus.getValue()
- // 聚合最高状态的光环借用当前入口;设备来源身份和状态不变。
- const representsGroup = !entryHasGroupEffect && owner && owner.show === false && aggregate && aggregate.properties.overlapEffectCode &&
- aggregate.properties.overlapEffectCode.getValue() === forCode
- const displayOwner = representsGroup ? (entry || aggregate) : owner
- entity.show = Boolean(effect && source.show !== false && displayOwner && displayOwner.show !== false)
- if (properties.effectFxDisplayGroup) properties.effectFxDisplayGroup.setValue(representsGroup ? groupKey : '')
- if (displayOwner && displayOwner.position) entity.position = displayOwner.position
- billboard.pixelOffset = displayOwner && displayOwner.billboard ? displayOwner.billboard.pixelOffset : new Cartesian2(0, 0)
- if (!effect) {
- // 无动效(正常/离线/未知、特效关闭或降级):光环**完全透明**,不留残影。
- billboard.scale = 1
- billboard.color = Color.WHITE.withAlpha(0)
- continue
- }
- const isSelected = deviceEffectState.selectedCode && forCode === deviceEffectState.selectedCode
- const boost = isSelected ? selectedBoost : 1
- if (kind === 'glow') {
- const strength = glowStrengthAt(now)
- billboard.scale = glowScaleAt(now) * boost
- billboard.color = Color.WHITE.withAlpha(0.32 * strength * boost)
- continue
- }
- // 多层环:每层相位错开、由内向外扩张渐隐。
- const frame = ringFrameAt(status, now)
- const ring = frame.rings[layer] || { scale: 1, alpha: 0 }
- billboard.scale = ring.scale * boost
- const flicker = effect === DEVICE_EFFECT.BLINK ? blinkStrengthAt(now) : 1
- billboard.color = Color.WHITE.withAlpha(Math.min(1, ring.alpha * boost * flicker))
- billboard.rotation = effect === DEVICE_EFFECT.SPIN ? spinAngleAt(now) : 0
- }
- if (animated.size && viewer.scene && viewer.scene.requestRender) viewer.scene.requestRender()
- deviceEffectState.tick += 1
- }
- function ensureEffectLoop() {
- if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') return
- if (deviceEffectState.raf) return
- const step = () => {
- if (!deviceEffectState.raf) return
- writeEffectFrame()
- deviceEffectState.raf = window.requestAnimationFrame(step)
- }
- deviceEffectState.raf = window.requestAnimationFrame(step)
- }
- /**
- * 应用动效计划(由服务层在设备图层同步后调用)。
- * @param {Viewer} viewer
- * @param {object} plan `domain/deviceEffects.js` 的 `planDeviceEffects()` 输出
- * @param {object} [options] `{ selectedCode }` 选中设备额外强化
- * @returns {boolean} 是否启动了动效
- */
- export function applyDeviceEffects(viewer, plan, options = {}) {
- deviceEffectState.viewer = viewer || null
- deviceEffectState.plan = plan || null
- deviceEffectState.selectedCode = (options && options.selectedCode) || ''
- const running = Boolean(viewer && plan && plan.enabled === true && !plan.degraded && plan.animated && plan.animated.length)
- // 光环实体按计划重建:参与的设备建环,其余(含降级/关闭时全部)清掉 —— 降级后动画全停,
- // 光环也必须一起消失,否则"停了动画却还留着环"会被读成"还在报警"。
- const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null))
- if (source) {
- if (running && options && options.devices) {
- const animatedCodes = new Set((plan.animated || []).map((item) => item.code))
- for (const device of options.devices) {
- if (!animatedCodes.has(device.code)) { clearRingEntities(source, device.code); continue }
- ensureRingEntities(
- source,
- device,
- options.colorOf ? options.colorOf(device) : Color.WHITE,
- options.fxImageOf || (() => ''),
- )
- }
- // 已不在本批设备里的光环(换点/换状态)也要清。
- const known = new Set(options.devices.map((item) => item.code))
- for (const entity of source.entities.values.slice()) {
- const isFx = entity.properties && entity.properties.effectFx && entity.properties.effectFx.getValue && entity.properties.effectFx.getValue() === true
- if (!isFx) continue
- const forCode = entity.properties.effectFxFor && entity.properties.effectFxFor.getValue ? entity.properties.effectFxFor.getValue() : ''
- if (!known.has(forCode) || !animatedCodes.has(forCode)) source.entities.remove(entity)
- }
- } else {
- clearRingEntities(source)
- }
- }
- if (running) ensureEffectLoop()
- else if (deviceEffectState.raf && typeof window !== 'undefined') {
- window.cancelAnimationFrame(deviceEffectState.raf)
- deviceEffectState.raf = 0
- }
- // 中心符号保持可读;停用时外围动画复位。
- writeEffectFrame()
- return running
- }
- /** 停止动效并写回基准(图层撤下 / 详情关闭 / 特效被关掉)。 */
- export function stopDeviceEffects() {
- if (typeof window !== 'undefined' && deviceEffectState.raf && typeof window.cancelAnimationFrame === 'function') {
- window.cancelAnimationFrame(deviceEffectState.raf)
- }
- deviceEffectState.raf = 0
- deviceEffectState.plan = null
- deviceEffectState.selectedCode = ''
- writeEffectFrame()
- }
- /**
- * 只读读回每台设备的**动效状态与当前帧属性**(走查的"真在跑"证据)。
- *
- * 三层证据的第 2 层(场景图跨帧快照):走查对同一设备**隔帧读两次**,
- * 断言 `scale`/`alpha`(脉冲/闪烁)或 `rotation`(旋转环)确实变了,
- * 而正常/离线的设备**完全不变**。这样就不是"拿两份 JS 数据互比"的恒真断言。
- */
- export function readDeviceEffects(viewer) {
- const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null))
- if (!source) return { tick: deviceEffectState.tick, devices: [] }
- const animated = animatedByCode(deviceEffectState.plan)
- const devices = []
- for (const entity of source.entities.values) {
- const billboard = entity.billboard
- if (!billboard) continue
- const properties = entity.properties || {}
- const get = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
- const code = get('deviceCode')
- if (!code) continue
- const color = billboard.color && billboard.color.getValue ? billboard.color.getValue() : null
- devices.push({
- code,
- status: get('deviceStatus'),
- effect: animated.get(code) || '',
- animated: animated.has(code),
- scale: billboard.scale && billboard.scale.getValue ? billboard.scale.getValue() : billboard.scale,
- alpha: color ? color.alpha : null,
- rotation: billboard.rotation && billboard.rotation.getValue ? billboard.rotation.getValue() : (billboard.rotation || 0),
- })
- }
- // 科技感光环的逐层读数(多层同心环 + 辉光):走查据此断言"环真的在扩张渐隐"。
- const fx = []
- for (const entity of source.entities.values) {
- const billboard = entity.billboard
- if (!billboard) continue
- const properties = entity.properties || {}
- const get = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
- if (get('effectFx') !== true) continue
- const color = billboard.color && billboard.color.getValue ? billboard.color.getValue() : null
- fx.push({
- for: get('effectFxFor'),
- displayGroup: get('effectFxDisplayGroup'),
- visible: source.show !== false && entity.show !== false && entity.isShowing !== false,
- pixelOffset: billboard.pixelOffset && billboard.pixelOffset.getValue ? billboard.pixelOffset.getValue() : billboard.pixelOffset,
- kind: get('effectFxKind'),
- layer: get('effectFxLayer'),
- status: get('effectFxStatus'),
- scale: billboard.scale && billboard.scale.getValue ? billboard.scale.getValue() : billboard.scale,
- alpha: color ? color.alpha : null,
- rotation: billboard.rotation && billboard.rotation.getValue ? billboard.rotation.getValue() : (billboard.rotation || 0),
- })
- }
- return {
- tick: deviceEffectState.tick,
- running: deviceEffectState.raf !== 0,
- degraded: Boolean(deviceEffectState.plan && deviceEffectState.plan.degraded),
- enabled: Boolean(deviceEffectState.plan && deviceEffectState.plan.enabled),
- ringLayers: RING_LAYERS,
- devices,
- fx,
- }
- }
- /**
- * 选中设备的**归属引线**:从设备点连到监测点标记中心(票 04/12 的面板锚点)。
- *
- * 用一条独立 polyline 实体画在同一个数据源上(`lead|...` 前缀,不参与设备拾取判定)。
- * 重合时调用方不该调本函数(`planDeviceLead().draw === false`),所以这里只负责画。
- */
- export function syncDeviceLead(viewer, { deviceLon, deviceLat, pointLon, pointLat, deviceCode, pointCode } = {}) {
- const source = deviceMarkerSource(viewer)
- // 先清掉上一条引线(同一时刻最多一条:只画**当前选中**那台的归属)。
- for (const entity of source.entities.values.slice()) {
- if (typeof entity.id === 'string' && entity.id.startsWith('lead|')) source.entities.remove(entity)
- }
- if (![deviceLon, deviceLat, pointLon, pointLat].every(Number.isFinite)) return false
- source.entities.add({
- id: `lead|${pointCode}|${deviceCode}`,
- polyline: {
- positions: Cartesian3.fromDegreesArray([deviceLon, deviceLat, pointLon, pointLat]),
- width: 2,
- // 引线是**归属指示**,不用色键(那是设备状态语义);用中性白并压半透明。
- material: Color.WHITE.withAlpha(0.7),
- // 贴地线会随地形起伏扭动,引线要的是"直连"语义,所以不贴地。
- clampToGround: false,
- },
- })
- return true
- }
- /** 清空选中引线(取消选中 / 返回点位 / 图层撤下)。 */
- export function clearDeviceLead(viewer) {
- const source = deviceSources.get(viewer) || (viewer && viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null)
- if (!source) return false
- for (const entity of source.entities.values.slice()) {
- if (typeof entity.id === 'string' && entity.id.startsWith('lead|')) source.entities.remove(entity)
- }
- return true
- }
- /**
- * 屏幕坐标 → 设备标记身份(**hover 用**,与 `pickDeviceMarkerAt` 同一条判据)。
- *
- * 单独一个函数只是为了让 hover 的调用点读起来是"我在做 hover 拾取",
- * 实现完全复用点选那一个(**不另写一套判据** —— 两套迟早会分歧)。
- */
- export function pickDeviceMarkerForHover(viewer, windowX, windowY) {
- return pickDeviceMarkerAt(viewer, windowX, windowY)
- }
- /**
- * 强调一批编码对应的地图实体(票 14:点事件详情时高亮关联设备与关联管线)。
- *
- * 只调**已存在实体**的显示参数,不新增实体 —— 事件因此永远不会变成独立地图图层
- * (票 13 的硬纪律)。两处来源都扫:
- * 1. 点位标记数据源(票 09):按实体属性 `pointCode` / `deviceCodes` 匹配;
- * 2. 管线数据源(**票 22 才交付**):按 `pipelineCode` / `deviceCode` 匹配。
- * 管线层还没落地时这一处自然扫到 0 个,不需要开关,也不会报错 ——
- * 票 22 落地的管线只要带这两个属性之一,本函数就自动把它一起高亮。
- *
- * **传空数组 = 还原全部**(详情收起时用):初值存在 `emphasisBase` 里,还原是把
- * 上次记下的原值写回去,而不是"猜一个默认宽度" —— 否则收起详情后点位标记会一直胖着、
- * 管线的线宽也回不到原样。
- *
- * @param {Viewer} viewer
- * @param {Array<string>} codes 要高亮的编码(点位码 / 设备码 / 管线码);空数组表示还原
- * @returns {number} 本次被强调的实体数(0 = 目标还不在图上,调用方据此说人话)
- */
- export function highlightEntities(viewer, codes) {
- if (!viewer || !viewer.dataSources) return 0
- const wanted = new Set((Array.isArray(codes) ? codes : [codes]).map((item) => String(item || '').trim()).filter(Boolean))
- let matched = 0
- const devices = deviceSources.get(viewer) || viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0]
- for (const source of [pointMarkerSource(viewer), pipelineSource(viewer), devices]) {
- if (!source) continue
- for (const entity of source.entities.values) {
- const properties = (entity && entity.properties) || {}
- const read = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
- const list = read('deviceCodes')
- const candidates = [
- read('pointCode'),
- read('pipelineCode'),
- read('deviceCode'),
- ...(Array.isArray(list) ? list : []),
- ]
- // 每个实体都过判定:命中与**未命中**都要写回值,后者即"还原"。
- const hit = shouldEmphasize(candidates, wanted)
- if (hit) matched += 1
- const base = hit ? rememberEmphasisBase(entity) : restoreEmphasisBase(entity)
- if (entity.billboard) entity.billboard.scale = hit ? EMPHASIS_STYLE.markerScale : base.billboardScale
- if (entity.polyline) entity.polyline.width = hit ? EMPHASIS_STYLE.pipelineWidth : base.polylineWidth
- }
- }
- return matched
- }
- /** 强调前的原值(按实体缓存:还原要有据可依,不能猜默认值)。 */
- const emphasisBase = new WeakMap()
- function rememberEmphasisBase(entity) {
- if (!emphasisBase.has(entity)) {
- emphasisBase.set(entity, {
- billboardScale: entity.billboard ? entity.billboard.scale : 1,
- polylineWidth: entity.polyline ? entity.polyline.width : undefined,
- })
- }
- return emphasisBase.get(entity)
- }
- function restoreEmphasisBase(entity) {
- const base = emphasisBase.get(entity)
- emphasisBase.delete(entity)
- return base || { billboardScale: 1, polylineWidth: undefined }
- }
- /** 管线数据源(票 22 交付时才存在;没有就回 null,不当成错误)。 */
- export function pipelineSource(viewer) {
- if (!viewer || !viewer.dataSources) return null
- return viewer.dataSources.getByName(PIPELINE_SOURCE)[0] || null
- }
- /** Currently loaded terrain height; null means no reliable surface sample yet. */
- export function groundHeightAt(viewer, lon, lat) {
- const globe = viewer && viewer.scene && viewer.scene.globe
- if (!globe || !Number.isFinite(lon) || !Number.isFinite(lat)) return null
- const height = globe.getHeight(Cartographic.fromDegrees(lon, lat))
- return Number.isFinite(height) ? height : null
- }
- export function hasPointMarker(viewer, kind, code) {
- if (!viewer || !code) return false
- const source = markerSources.get(viewer) || viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0]
- return !!(source && source.show && source.entities.getById(`point|${kind}|${code}`))
- }
- /**
- * 把**渲染中的**贴地标记中心投到窗口坐标(票 41 的锚定锚点)。
- *
- * 锚点必须与**眼睛看到的那个标记**一致,所以高度取值顺序照 Cesium 画 billboard 的实际情况:
- * 1. 地形已有该处瓦片 → `globe.getHeight()` 给的实测高程(贴地后的真实中心);
- * 2. 瓦片还没加载/无地形 → **椭球面**(Cesium 的 `CLAMP_TO_GROUND` 在无地形数据时正是画在椭球面上)。
- *
- * 第 2 条是**真机走查抓到的缺陷**:早先写成「配了地形就必须取到高度,取不到回 null」,
- * 于是相机一飞到尚未加载瓦片的区域,`getHeight` 返回 undefined,锚定被误判成"标记离屏"、
- * 整块面板消失 —— 而画面上那个标记明明还看得见。锚点判据不能比"可见性"本身更严。
- *
- * 可见性只用 Cesium **公开**判据:相机前方(`directionWC` 点积)+ 地球遮挡(`EllipsoidalOccluder`),
- * 再交给 `SceneTransforms` 投影;不做第二套相机矩阵手算。
- */
- export function projectPointMarker(viewer, kind, code) {
- if (!viewer || !viewer.scene || !viewer.canvas || !code) return null
- const source = markerSources.get(viewer) || viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0]
- if (!source || !source.show) return null
- const entity = source.entities.getById(`point|${kind}|${code}`)
- return projectMarkerEntity(viewer, entity)
- }
- /** 关联设备锚向实际绘制的设备实体,不回退到父监测点。 */
- export function hasDeviceMarker(viewer, code, pointCode = '') {
- return Boolean(findDeviceMarker(viewer, code, pointCode))
- }
- export function projectDeviceMarker(viewer, code, pointCode = '') {
- return projectMarkerEntity(viewer, findDeviceMarker(viewer, code, pointCode))
- }
- function findDeviceMarker(viewer, code, pointCode) {
- if (!viewer || !code) return null
- const source = deviceSources.get(viewer) || viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0]
- if (!source || !source.show) return null
- return source.entities.values.find((entity) => {
- const properties = entity.properties || {}
- return properties.deviceCode && properties.deviceCode.getValue() === code
- && (!pointCode || (properties.monitoringPointCode && properties.monitoringPointCode.getValue() === pointCode))
- && entity.show !== false
- }) || null
- }
- /** 贴地投影与屏幕分离共用一份口径,返回视觉中心及当前图标尺寸。 */
- function projectMarkerEntity(viewer, entity) {
- if (!viewer || !viewer.scene || !viewer.canvas || !entity || !entity.isShowing || !entity.position || !entity.billboard) return null
- const original = entity.position.getValue(viewer.clock.currentTime)
- if (!original) return null
- const ellipsoid = viewer.scene.globe.ellipsoid
- const carto = ellipsoid.cartesianToCartographic(original)
- if (!carto) return null
- let height = 0
- const sampled = viewer.scene.globe.getHeight(carto)
- if (Number.isFinite(sampled)) height = sampled
- const world = Cartesian3.fromRadians(carto.longitude, carto.latitude, height)
- const camera = viewer.camera
- const toward = Cartesian3.subtract(world, camera.positionWC, new Cartesian3())
- const occluder = new EllipsoidalOccluder(ellipsoid, camera.positionWC)
- if (Cartesian3.dot(toward, camera.directionWC) <= 0 || !occluder.isPointVisible(world)) return null
- const at = SceneTransforms.worldToWindowCoordinates(viewer.scene, world)
- if (!at || !Number.isFinite(at.x) || !Number.isFinite(at.y)) return null
- const rect = viewer.canvas.getBoundingClientRect()
- const time = viewer.clock.currentTime
- const read = (property, fallback) => property && property.getValue ? property.getValue(time) : fallback
- const offset = read(entity.billboard.pixelOffset, { x: 0, y: 0 })
- const scale = read(entity.billboard.scale, 1)
- const iconSize = Math.max(read(entity.billboard.width, 18), read(entity.billboard.height, 18)) * scale
- return { x: at.x + rect.left + offset.x, y: at.y + rect.top + offset.y, iconSize }
- }
- /**
- * 屏幕坐标 → **点位标记身份**(票 10 的地图点选)。
- *
- * 三件事必须说清:
- * 1. 只认点位标记数据源上的实体 —— 拾到地形、影像、3D Tiles 或空白一律回 `null`
- * (不是「拾到什么就打开详情」);判据是实体属性里有 `pointCode`;
- * 2. 坐标要吃**画布坐标**,而事件给的是窗口坐标:`scene.pick` 用的是画布内部坐标系,
- * 所以先减去画布原点(票 03 的 `projectPivot()` 踩过同一个坑,那里是加、这里是减);
- * 3. 回的是**标记身份**而不是 Cesium 实体:引擎对象不出这一层,上层只认 kind/code/status
- * (与 `syncPointMarkers` 只认 shape/tone 是同一条纪律)。
- *
- * @returns {{kind: string, code: string, status: string}|null}
- */
- export function pickPointMarkerAt(viewer, windowX, windowY) {
- if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
- const rect = viewer.canvas.getBoundingClientRect()
- const picked = pickThroughPipelines(viewer, new Cartesian2(windowX - rect.left, windowY - rect.top))
- const entity = picked && picked.id
- const properties = entity && entity.properties
- if (!properties || !properties.pointCode) return null
- const read = (key) => (properties[key] ? properties[key].getValue() : '')
- const code = read('pointCode')
- if (!code) return null
- return { kind: read('pointKind'), code, status: read('pointStatus'), name: read('pointName') || '' }
- }
- /**
- * 点位标记 hover 拾取(批次 3:给「hover 显示完整名称」的通用 tooltip 链路用)。
- * 与 `pickPointMarkerAt` 同一实体源,但**额外带 name** 且不参与点选竞态。
- */
- export function pickPointMarkerForHover(viewer, windowX, windowY) {
- return pickPointMarkerAt(viewer, windowX, windowY)
- }
- /**
- * 经纬度 → 画布窗口坐标(批次 3 第五轮,DOM overlay 的 3D 定位入口)。
- * @returns {{x: number, y: number}|null} 相对画布左上角;未就绪/不可投影返回 null
- */
- export function projectPointToWindow(viewer, longitude, latitude) {
- if (!viewer || !viewer.scene) return null
- const world = Cartesian3.fromDegrees(longitude, latitude, 0)
- const at = SceneTransforms.worldToWindowCoordinates(viewer.scene, world)
- if (!at || !Number.isFinite(at.x) || !Number.isFinite(at.y)) return null
- return { x: at.x, y: at.y }
- }
- /**
- * 相机契约的 Cesium 适配(票 03)。返回 services/cameraControl.js 期望的引擎接口;
- * 相机语义(三键怎么算、中心是谁)在 domain/camera.js 与 cameraControl.js,本函数只翻译方言。
- * @param {Viewer} viewer
- */
- export function createCameraEngine(viewer) {
- const scene = viewer.scene
- const camera = viewer.camera
- let frameHandle = null
- /**
- * 经纬高 → 该点东北天局部系(弹簧臂原语要求中心是局部地表点)。
- *
- * 极点保护:`eastNorthUpToFixedFrame` 内部用 `east = up × north` 求东向,
- * 纬度到 ±90 时 up 与 north 共线、叉积退化为零向量,局部系不再是正交基,
- * `lookAtTransform` 拿它当参照就会出现「整个地球乱转」。
- * 域层已把中心纬度夹在 ±POLAR_LAT_LIMIT 内(见 domain/camera.js),
- * 这里是引擎侧的第二道防线:任何调用方直接喂端点纬度也不会把参照系搞坏。
- */
- function frameAt(pivot) {
- const lat = Math.min(89.9, Math.max(-89.9, pivot.lat))
- return Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(pivot.lon, lat, pivot.height || 0))
- }
- return {
- applyView(pivot, hpr) {
- camera.lookAtTransform(
- frameAt(pivot),
- new HeadingPitchRange(hpr.heading * DEG, hpr.pitch * DEG, hpr.range),
- )
- },
- /** 反读当前相机相对该中心的弹簧臂参数,用于挂载时对齐真实相机(避免首次交互跳变)。 */
- readView(pivot) {
- const inverse = Matrix4.inverseTransformation(frameAt(pivot), new Matrix4())
- const local = Matrix4.multiplyByPoint(inverse, camera.positionWC, new Cartesian3())
- const range = Cartesian3.magnitude(local)
- if (!(range > 0)) return { heading: 0, pitch: -60, range: 1 }
- const heading = CesiumMath.toDegrees(Math.atan2(local.x, local.y))
- const pitch = -CesiumMath.toDegrees(Math.asin(Math.min(1, Math.max(-1, local.z / range))))
- return { heading: (heading + 360) % 360, pitch, range }
- },
- /**
- * 屏幕中心的地面拾取点(票 21/30 改进):**优先按地形拾取**,拿不到高度再退回椭球。
- *
- * 为什么必须按地形:纯 `pickEllipsoid` 给的是椭球面交点(高程 0),而沅陵县城地面高程
- * 两百多米起。用它当中心,弹簧臂 range 一降到几百米,相机就落进山体内部 —— 实测 range 120 m
- * 时整屏全黑(票 21 走查记录)。地形就绪后用 `globe.getHeight` 把中心抬到真实地表,
- * 相机高度随之正确,近景才不会钻进山里。
- */
- pickGroundAtCenter() {
- const canvas = scene.canvas
- const at = new Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2)
- const ellipsoid = (scene.globe && scene.globe.ellipsoid) || Ellipsoid.WGS84
- const hit = camera.pickEllipsoid(at, ellipsoid)
- if (!hit) return null
- const carto = ellipsoid.cartesianToCartographic(hit)
- const lon = CesiumMath.toDegrees(carto.longitude)
- const lat = CesiumMath.toDegrees(carto.latitude)
- // 地形就绪时把中心抬到真实地表;取样不到就保持 0(等价于旧行为,不会更糟)。
- let height = 0
- const globe = scene.globe
- if (globe && typeof globe.getHeight === 'function') {
- const sampled = globe.getHeight(carto)
- if (Number.isFinite(sampled)) height = sampled
- }
- return { lon, lat, height }
- },
- /**
- * 中心点投影到**窗口坐标**(与 `getBoundingClientRect()` 同一坐标系)。
- *
- * 实测 `SceneTransforms.worldToWindowCoordinates()` 返回的是**相对画布左上角**的值
- * (画布在壳里下移了一段,直接用会与 `getBoundingClientRect()` 差出一个画布原点偏移,
- * 走查里表现为「中心点没落在画布中心」的假失败)。这里显式加上画布原点,
- * 让消费方拿到的就是窗口坐标,别再靠语义猜。
- */
- projectPivot(pivot) {
- const at = SceneTransforms.worldToWindowCoordinates(scene, Cartesian3.fromDegrees(pivot.lon, pivot.lat, pivot.height || 0))
- if (!at) return null
- const rect = viewer.canvas.getBoundingClientRect()
- return { x: at.x + rect.left, y: at.y + rect.top }
- },
- /**
- * **相机正下方**的地形高度(米),取不到返回 null(票 37 的防入地输入)。
- *
- * 为什么是「相机正下方」而不是「pivot 下方」:防入地要保证的是**相机自己**别钻进山体。
- * 相机在 pivot 沿 heading/pitch 退开 range 的位置上,两者可能隔着几百米、翻过一个山脊,
- * 用 pivot 的地形判断会在斜坡上误判。
- */
- groundHeightBelowCamera() {
- const globe = scene.globe
- if (!globe || typeof globe.getHeight !== 'function') return null
- try {
- const carto = globe.ellipsoid.cartesianToCartographic(camera.positionWC)
- if (!carto) return null
- const sampled = globe.getHeight(carto)
- return Number.isFinite(sampled) ? sampled : null
- } catch (error) {
- return null
- }
- },
- /** 相机当前**绝对**高度(米,椭球高),取不到返回 null。防入地的另一半输入。 */
- cameraHeight() {
- try {
- const carto = scene.globe.ellipsoid.cartesianToCartographic(camera.positionWC)
- return carto && Number.isFinite(carto.height) ? carto.height : null
- } catch (error) {
- return null
- }
- },
- inputTarget() {
- return viewer.canvas
- },
- viewport() {
- return { width: scene.canvas.clientWidth, height: scene.canvas.clientHeight }
- },
- fovY() {
- return camera.frustum.fovy || 60 * DEG
- },
- now() {
- return typeof performance !== 'undefined' ? performance.now() : Date.now()
- },
- requestFrame(callback) {
- frameHandle = requestAnimationFrame(callback)
- },
- }
- }
|